diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index d44c31ea..0ee67847 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -113,6 +113,8 @@ jobs: packages/ui/src/lib/hooks/use-foreground-refresh.test.ts packages/ui/src/lib/launch-errors.test.ts packages/ui/src/lib/message-selection-position.test.ts + packages/ui/src/lib/model-visibility.test.ts + packages/ui/src/lib/runtime-env.test.ts packages/ui/src/lib/trailing-resync.test.ts packages/ui/src/stores/abort-created-workspace-cleanup.test.ts packages/ui/src/stores/app-session-reconciliation.test.ts @@ -128,6 +130,7 @@ jobs: packages/ui/src/stores/message-v2/message-hydration-authority.test.ts packages/ui/src/stores/message-v2/message-status.test.ts packages/ui/src/stores/message-v2/normalizers.test.ts + packages/ui/src/stores/pty-store.test.ts packages/ui/src/stores/session-generation-recovery.test.ts packages/ui/src/stores/session-pagination.test.ts packages/ui/src/stores/session-pending-state.test.ts diff --git a/.opencode/skills/codenomad-architecture-guide/SKILL.md b/.opencode/skills/codenomad-architecture-guide/SKILL.md index 3d64eefc..68add01a 100644 --- a/.opencode/skills/codenomad-architecture-guide/SKILL.md +++ b/.opencode/skills/codenomad-architecture-guide/SKILL.md @@ -20,7 +20,7 @@ description: | - There is no `packages/opencode-plugin/`. Do not restore plugin tools, plugin routes, or plugin packaging. - The server owns one shared OpenCode service through `OpenCodeSharedService` and its custom lease-locked discovery, launcher, process-proof, and authenticated-stop lifecycle. Production does not call `Service.ensure` or `Service.stop` directly. Workspaces are native OpenCode `Location`/directory scopes, not separate OpenCode processes. - The UI uses generated Promise clients from `OpenCode.make()` through the CodeNomad proxy. -- OpenCode owns session APIs, native Shell (`client.session.shell`) and session instructions (`client.session.instructions.entry`). PTY/background-process parity is not integrated. +- OpenCode owns session APIs, native Shell (`client.session.shell`), session instructions (`client.session.instructions.entry`), and location-scoped native PTYs. Shell remains separate. The Status panel lists PTYs, refreshes on PTY events/reconnect, displays native metadata, and supports title updates and ownership-checked removal. Exact `next-17353` has no PTY output/read/stream or separate stop API, so output and distinct stop are unavailable; removal is the native stop action for a running PTY. - CodeNomad owns workspace lifecycle, directory authorization, Git status/diff/stage/unstage/commit, Yolo persistence/auto-replies, and `/api/events`. - V2 service startup requires a user-configured `OPENCODE_DB`. There is no default path; never share a V1 database with V2. Environment changes apply at service start/restart. @@ -61,7 +61,7 @@ description: | | Public `@opencode-ai/sdk` examples | Installed experimental `@opencode-ai/client@0.0.0-next-17353` declarations | | One `opencode serve` per workspace | One CodeNomad-managed shared service | | Per-worktree clients/processes | Root proxy client plus native location/directory inputs | -| Reintroducing `packages/opencode-plugin` | Native OpenCode Shell/instructions; no PTY/background parity claim | +| Reintroducing `packages/opencode-plugin` or server plugin/background-process paths | Separate native Shell/instructions and native PTY management | | OpenCode APIs for stage/commit/Yolo policy | CodeNomad routes and managers | | Hardcoded UI strings | `t()` / `tGlobal()` and every locale | diff --git a/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md b/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md index 0a4e76af..f7d95e9f 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md +++ b/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md @@ -16,12 +16,12 @@ The server uses `packages/server/src/workspaces/opencode-service.ts` for a custo | Owner | Responsibilities | Main paths | |---|---|---| -| OpenCode V2 | Sessions, messages, permissions/questions, files, native Shell and instructions | experimental `@opencode-ai/client@0.0.0-next-17353` protocol | +| OpenCode V2 | Sessions, messages, permissions/questions, files, native Shell/instructions, location-scoped PTYs | experimental `@opencode-ai/client@0.0.0-next-17353` protocol | | CodeNomad server | Shared service lifecycle, locations, proxy authorization, Git mutations, Yolo, auth, storage, speech, SSE multiplexing | `packages/server/src/` | | CodeNomad UI | Generated Promise clients, state reconciliation, interaction and rendering | `packages/ui/src/` | | Desktop hosts | Start CodeNomad and provide native OS integration | `packages/electron-app/`, `packages/tauri-app/` | -`packages/opencode-plugin/` and the server plugin/background-process integration were deleted. Native Shell is integrated; PTY/background-process parity is not. Do not use deleted paths as extension points. +Native Shell remains separate from PTY management. The Status panel lists location-scoped PTYs, refreshes on PTY events/reconnect, displays native metadata, and supports title updates and ownership-checked removal. Exact `next-17353` has no PTY output/read/stream or separate stop endpoint; output display and a distinct stop action are unavailable, and removal is the native stop action for a running PTY. `packages/opencode-plugin/` and the server plugin/background-process integration remain deleted and must not be restored or used as extension points. ## HTTP And Events diff --git a/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md b/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md index ad0aada3..777eb943 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md +++ b/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md @@ -9,15 +9,18 @@ 5. CodeNomad publishes workspace events on `/api/events` and exposes `/workspaces/:id/instance` as the authorized native API proxy. 6. Final-owner deletion queues location eviction. Proven final shared-service shutdown flushes queued evictions and sends an authenticated stop only if no live CodeNomad peer remains and the exact daemon identity still matches. -## Prompt, Shell And Instructions +## Prompt, Shell, Instructions, And PTYs 1. UI obtains `getRootClient(instanceId)`. 2. Conversation mode updates `client.session.instructions.entry` for the voice instruction. -3. A normal prompt calls `client.session.prompt`; `!` shell mode calls native `client.session.shell`. There is no integrated PTY/background-process parity. -4. The proxy checks directory/session ownership and forwards to the shared service's `/api/*` route. -5. One upstream event subscription feeds `InstanceEventBridge`, then CodeNomad `/api/events`, then UI stores. +3. A normal prompt calls `client.session.prompt`; `!` shell mode calls native `client.session.shell`. Native Shell remains separate from PTY management. +4. The Status panel lists native PTYs for the active location, displays their native metadata, and refreshes on PTY events and reconnect. +5. Title updates and removal use native PTY APIs; the proxy verifies native `cwd` ownership before ID-scoped operations. Removing a running PTY is its native stop action. +6. Exact `next-17353` has no PTY output/read/stream API or separate stop endpoint, so output display and a distinct stop action are unavailable. +7. The proxy checks directory/session ownership and forwards to the shared service's `/api/*` route. +8. One upstream event subscription feeds `InstanceEventBridge`, then CodeNomad `/api/events`, then UI stores. -No CodeNomad OpenCode plugin participates in this flow. +No CodeNomad OpenCode plugin participates in this flow. `packages/opencode-plugin` and server plugin/background-process paths remain deleted and must not be restored. ## Permission And Yolo diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md index d5d5ce19..e8c3f27c 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md @@ -26,11 +26,13 @@ | Concern | Owner | |---|---| -| Session/message/Shell/instructions | OpenCode native API; PTY/background parity not integrated | +| Session/message/Shell/instructions | OpenCode native API; Shell remains separate from PTY management | +| PTY list/metadata/title/remove | Location-scoped OpenCode native API through CodeNomad ownership checks; Status UI refreshes on PTY events/reconnect | +| PTY output/distinct stop | Unavailable in exact `next-17353`; removal is the native stop action for a running PTY | | Service discovery/start/stop | CodeNomad hardened adapter using selected OpenCode primitives | | Workspace and directory authorization | CodeNomad | | Git status/diff and mutations | CodeNomad | | Yolo policy/persistence/auto-reply | CodeNomad | | Browser event multiplexing | CodeNomad `/api/events` | -Do not restore `@opencode-ai/sdk`, per-workspace processes, `packages/opencode-plugin`, plugin background-process tools, or deleted plugin/runtime file paths. +Exact `next-17353` has no PTY output/read/stream API or separate stop endpoint, so the UI cannot display PTY output or offer a distinct stop action. Do not restore `@opencode-ai/sdk`, per-workspace processes, `packages/opencode-plugin`, server plugin/background-process tools, or deleted plugin/runtime file paths. diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md index b369aa1f..e8c2b8c8 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md @@ -22,11 +22,15 @@ const client = OpenCode.make({ baseUrl, fetch: createInstanceFetch(baseUrl) }) Use `getRootClient(instanceId)` from `packages/ui/src/stores/opencode-client.ts`. Native location/directory inputs replace the old per-worktree-client pattern. Destroy cached clients when an instance is removed. -## Native Shell And Instructions +## Native Shell, Instructions, And PTYs - Shell mode calls `client.session.shell({ sessionID, command })`. - Conversation mode adds/removes `client.session.instructions.entry` before `client.session.prompt`. -- PTY and background-process feature parity are not integrated. +- Shell remains separate from native PTY management. +- PTYs are location-scoped and listed with `client.pty.list`; the Status panel refreshes on PTY lifecycle events and reconnect, displays native metadata, and supports title updates. +- PTY ID operations are ownership-checked against the native `cwd`. Removal is the native stop action for a running PTY. +- Exact `next-17353` has no PTY output/read/stream API or separate stop endpoint, so output display and a distinct stop action are unavailable. +- Keep `packages/opencode-plugin` and server plugin/background-process paths deleted. ## Event Flow diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5cfa93eb..1d4021d1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -116,7 +116,8 @@ Then open a pull request on GitHub targeting the `dev` branch. - V2 startup requires a user-configured, non-empty `OPENCODE_DB`; there is no CodeNomad default. Never reuse a V1 database for V2. Environment changes take effect when the shared service starts or restarts. - OpenCode session calls use `/workspaces/:id/instance/api/*`; CodeNomad control routes and multiplexed events use `/api/*` and `/api/events`. - The proxy is method/path allowlisted, so new upstream functionality is not exposed automatically. -- Native `client.session.shell` and `client.session.instructions.entry` cover Shell and prompt instructions. PTY/background-process parity is not integrated, and there is no `packages/opencode-plugin` integration. +- Native Shell (`client.session.shell`) and prompt instructions (`client.session.instructions.entry`) remain separate from native V2 PTY management. +- Native PTYs are location-scoped and listed in the Status panel. The UI refreshes them on PTY events and reconnect, displays native metadata, and supports title updates and ownership-checked removal. With exact `0.0.0-next-17353`, removal is the native stop action for a running PTY; there is no PTY output/read/stream API or separate stop endpoint, so output display and a distinct stop action are unavailable. `packages/opencode-plugin` and the server plugin/background-process paths remain deleted and must not be restored. - Native events are volatile. Reconnect handlers must refetch authoritative state instead of assuming missed events will replay. - Git mutations and Yolo policy remain CodeNomad-owned server boundaries. diff --git a/dev-docs/SUMMARY.md b/dev-docs/SUMMARY.md index b6cd7907..822128a8 100644 --- a/dev-docs/SUMMARY.md +++ b/dev-docs/SUMMARY.md @@ -303,7 +303,9 @@ tasks/ Task tracking - Database: user-supplied `OPENCODE_DB` is required for V2 and must never be shared with V1; changes apply at service start/restart - Events: volatile native stream with authoritative reconnect reconciliation - Proxy: explicit method/path allowlist; upstream additions are not automatic -- Shell and instructions: native session APIs; PTY/background-process parity is not integrated +- Shell and instructions: native session APIs, separate from PTY management +- PTYs: location-scoped native entries in Status, refreshed on PTY events/reconnect with metadata, title updates, and ownership-checked removal; exact `next-17353` has no output/read/stream or separate stop API, so output and distinct stop are unavailable and removal stops a running PTY +- Legacy plugin/background processes: `packages/opencode-plugin` and server plugin/background-process paths remain deleted - Git mutations and Yolo: CodeNomad-owned ## Estimated Timeline diff --git a/dev-docs/architecture.md b/dev-docs/architecture.md index fe634b67..d590ff0e 100644 --- a/dev-docs/architecture.md +++ b/dev-docs/architecture.md @@ -57,13 +57,14 @@ Current native events include session lifecycle/output events (`session.created` | Sessions, messages, permission/question APIs | OpenCode V2 | | Shell mode | `client.session.shell` | | Conversation instructions | `client.session.instructions.entry` | -| PTY/background-process parity | Not integrated | +| PTY management | Location-scoped OpenCode V2 API through the ownership-checking proxy; Status panel UI | +| PTY output and distinct stop | Unavailable in exact `next-17353`; removal is the native stop action for a running PTY | | Workspace lifecycle and directory authorization | CodeNomad | | Git status/diff/stage/unstage/commit | CodeNomad server | | Yolo state, persistence and auto-accept | CodeNomad server | | Browser SSE multiplexing | CodeNomad server | -Native Shell and session instructions replace the corresponding deleted plugin-backed integrations. Do not claim PTY/background-process parity or restore deleted plugin routes. +Native Shell remains separate from PTY management. The Status panel lists location-scoped native PTYs, refreshes on PTY events/reconnect, displays native metadata, and allows title updates and ownership-checked removal. Exact `next-17353` exposes no PTY output/read/stream API or separate stop endpoint, so output display and a distinct stop action are unavailable. `packages/opencode-plugin` and the server plugin/background-process paths remain deleted and must not be restored. ## Persistence diff --git a/dev-docs/technical-implementation.md b/dev-docs/technical-implementation.md index f5813827..2a96f0b0 100644 --- a/dev-docs/technical-implementation.md +++ b/dev-docs/technical-implementation.md @@ -34,7 +34,9 @@ await client.session.shell({ sessionID, command }) await client.session.instructions.entry.put({ sessionID, key, value }) ``` -Shell mode and conversation instructions are upstream features. They do not require a CodeNomad plugin. CodeNomad does not currently integrate PTY or background-process parity. +Shell mode and conversation instructions are upstream features and remain separate from native V2 PTYs. None requires a CodeNomad plugin. + +Native PTYs are location-scoped and listed in the Status panel. `packages/ui/src/stores/pty-store.ts` refreshes the list on native PTY events and reconnect, exposes native metadata, and supports title updates and removal. The proxy verifies PTY `cwd` ownership before ID-scoped operations. With exact `0.0.0-next-17353`, the client has no PTY output/read/stream API and no separate stop endpoint: output is not displayed, and removing a running PTY is the only native stop action. ## Routing And Security @@ -55,7 +57,7 @@ Yolo also remains CodeNomad-owned. `AutoAcceptManager` persists policy state, ob `InstanceEventBridge` consumes the one shared `client.event.subscribe()` iterable. It maps location-scoped events to workspace IDs and publishes `instance.event` through the CodeNomad `EventBus`. This stream is volatile: reconnection does not replay a guaranteed history, so UI stores refetch sessions and pending requests and other consumers must re-read authoritative file/config state. -Use current protocol names. Session events include `session.created`, `session.renamed`, `session.moved`, `session.status`, `session.idle`, `session.execution.*`, `session.compaction.*`, `session.text.*`, `session.reasoning.*`, and `session.tool.*`; file and config invalidations are `filesystem.changed` and `config.updated`. +Use current protocol names. Session events include `session.created`, `session.renamed`, `session.moved`, `session.status`, `session.idle`, `session.execution.*`, `session.compaction.*`, `session.text.*`, `session.reasoning.*`, and `session.tool.*`; PTY refresh events include `pty.created`, `pty.updated`, `pty.exited`, and `pty.deleted`; file and config invalidations are `filesystem.changed` and `config.updated`. ## Current Structure @@ -75,9 +77,10 @@ packages/ui/src/ stores/opencode-client.ts root client authority stores/session-api.ts session queries/lifecycle stores/session-actions.ts prompt, Shell, instructions + stores/pty-store.ts location-scoped native PTY state/actions ``` -Deleted plugin, background-process, and per-workspace runtime files are not architectural extension points. +Deleted `packages/opencode-plugin`, server plugin/background-process, and per-workspace runtime files are not architectural extension points and must not be restored. ## Validation diff --git a/package-lock.json b/package-lock.json index 96e5d853..13e5ef8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codenomad-workspace", - "version": "0.18.0", + "version": "0.19.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codenomad-workspace", - "version": "0.18.0", + "version": "0.19.0", "license": "MIT", "dependencies": { "7zip-bin": "^5.2.0", @@ -13621,7 +13621,7 @@ }, "packages/electron-app": { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.18.0", + "version": "0.19.0", "license": "MIT", "dependencies": { "yaml": "^2.4.2" @@ -13648,7 +13648,7 @@ }, "packages/server": { "name": "@neuralnomads/codenomad", - "version": "0.18.0", + "version": "0.19.0", "license": "MIT", "dependencies": { "@fastify/cors": "^8.5.0", @@ -13691,7 +13691,7 @@ }, "packages/tauri-app": { "name": "@codenomad/tauri-app", - "version": "0.18.0", + "version": "0.19.0", "license": "MIT", "devDependencies": { "@tauri-apps/cli": "^2.9.4" @@ -13699,7 +13699,7 @@ }, "packages/ui": { "name": "@codenomad/ui", - "version": "0.18.0", + "version": "0.19.0", "license": "MIT", "dependencies": { "@git-diff-view/solid": "^0.0.8", diff --git a/package.json b/package.json index 724f81bc..5e3ae0a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codenomad-workspace", - "version": "0.18.0", + "version": "0.19.0", "private": true, "description": "CodeNomad monorepo workspace", "license": "MIT", diff --git a/packages/electron-app/electron/main/ipc.ts b/packages/electron-app/electron/main/ipc.ts index 6e664d90..2f2bd147 100644 --- a/packages/electron-app/electron/main/ipc.ts +++ b/packages/electron-app/electron/main/ipc.ts @@ -1,7 +1,9 @@ -import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron" +import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, shell, type OpenDialogOptions } from "electron" import fs from "fs" import { requestMicrophoneAccess } from "./permissions" import type { CliProcessManager, CliStatus } from "./process-manager" +import { openWorkspaceTarget, type WorkspaceEditor, type WorkspaceOpenTarget } from "./workspace-open" +import { setWorkspaceMenuEnabled } from "./menu" let wakeLockId: number | null = null @@ -18,6 +20,37 @@ interface DialogOpenResult { paths: string[] } +async function resolveLocalWorkspaceFolder( + mainWindow: BrowserWindow, + cliManager: CliProcessManager, + instanceId: string, + worktreeSlug: string, +): Promise { + const baseUrl = cliManager.getStatus().url + if (!baseUrl) throw new Error("Local CodeNomad server is unavailable") + const cookieName = cliManager.getAuthCookieName() + const cookie = (await mainWindow.webContents.session.cookies.get({ url: baseUrl, name: cookieName }))[0] + const headers = cookie ? { Cookie: `${cookie.name}=${cookie.value}` } : undefined + const workspaceResponse = await fetch(`${baseUrl.replace(/\/$/, "")}/api/workspaces/${encodeURIComponent(instanceId)}`, { + headers, + signal: AbortSignal.timeout(5_000), + }) + if (!workspaceResponse.ok) throw new Error("Workspace is not active") + const workspace = await workspaceResponse.json() as { path?: unknown } + if (typeof workspace.path !== "string") throw new Error("Workspace path is unavailable") + if (worktreeSlug === "root") return workspace.path + + const worktreeResponse = await fetch( + `${baseUrl.replace(/\/$/, "")}/api/workspaces/${encodeURIComponent(instanceId)}/worktrees`, + { headers, signal: AbortSignal.timeout(5_000) }, + ) + if (!worktreeResponse.ok) throw new Error("Workspace worktrees are unavailable") + const payload = await worktreeResponse.json() as { worktrees?: Array<{ slug?: unknown; directory?: unknown }> } + const worktree = payload.worktrees?.find((candidate) => candidate.slug === worktreeSlug) + if (!worktree || typeof worktree.directory !== "string") throw new Error("Selected worktree is unavailable") + return worktree.directory +} + export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager) { cliManager.on("status", (status: CliStatus) => { if (!mainWindow.isDestroyed()) { @@ -88,6 +121,49 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan return directories }) + ipcMain.handle( + "workspace:openTarget", + async (event, payload: { target?: unknown; instanceId?: unknown; worktreeSlug?: unknown; path?: unknown; editor?: unknown }): Promise<{ ok: true }> => { + if (mainWindow.isDestroyed() || event.sender !== mainWindow.webContents || event.senderFrame !== mainWindow.webContents.mainFrame) { + throw new Error("Workspace open requests are limited to the local main window") + } + const localUrl = cliManager.getStatus().url + if (!localUrl || new URL(event.senderFrame.url).origin !== new URL(localUrl).origin) { + throw new Error("Workspace open requests require the local CodeNomad origin") + } + const target = payload?.target + const instanceId = payload?.instanceId + const worktreeSlug = payload?.worktreeSlug + const editor = payload?.editor + if ( + (target !== "default" && target !== "reveal" && target !== "terminal" && target !== "editor") + || typeof instanceId !== "string" + || typeof worktreeSlug !== "string" + || (payload.path !== undefined && typeof payload.path !== "string") + || (editor !== undefined && editor !== "vscode" && editor !== "cursor" && editor !== "zed" && editor !== "vscodium") + ) { + throw new Error("Invalid workspace open request") + } + const workspaceFolder = await resolveLocalWorkspaceFolder(mainWindow, cliManager, instanceId, worktreeSlug) + await openWorkspaceTarget( + target as WorkspaceOpenTarget, + workspaceFolder, + payload.path as string | undefined, + editor as WorkspaceEditor | undefined, + { openPath: (path) => shell.openPath(path), revealPath: (path) => shell.showItemInFolder(path) }, + ) + return { ok: true } + }, + ) + + ipcMain.handle("workspace:setMenuEnabled", (event, enabled: unknown): { ok: true } => { + if (mainWindow.isDestroyed() || event.sender !== mainWindow.webContents || event.senderFrame !== mainWindow.webContents.mainFrame) { + throw new Error("Workspace menu updates are limited to the local main window") + } + setWorkspaceMenuEnabled(enabled === true) + return { ok: true } + }) + ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => { const next = Boolean(enabled) if (next) { diff --git a/packages/electron-app/electron/main/menu.ts b/packages/electron-app/electron/main/menu.ts index 7ecbc195..ae345ba4 100644 --- a/packages/electron-app/electron/main/menu.ts +++ b/packages/electron-app/electron/main/menu.ts @@ -1,12 +1,34 @@ -import { Menu, BrowserWindow, MenuItemConstructorOptions } from "electron" +import { app, Menu, BrowserWindow, MenuItemConstructorOptions } from "electron" interface ApplicationMenuActions { reload(): void forceReload(): void } +let workspaceActionsRequested = false +let applicationMenu: Menu | null = null +let localMainWindow: BrowserWindow | null = null + +function updateWorkspaceMenuState() { + const enabled = workspaceActionsRequested && BrowserWindow.getFocusedWindow() === localMainWindow + for (const id of ["open-workspace-folder", "open-workspace-terminal", "open-workspace-editor"]) { + const item = applicationMenu?.getMenuItemById(id) + if (item) item.enabled = enabled + } +} + +export function setWorkspaceMenuEnabled(enabled: boolean) { + workspaceActionsRequested = enabled + updateWorkspaceMenuState() +} + export function createApplicationMenu(mainWindow: BrowserWindow, actions: ApplicationMenuActions) { + localMainWindow = mainWindow const isMac = process.platform === "darwin" + const sendCommand = (id: string) => () => { + if (id.startsWith("open-workspace-") && BrowserWindow.getFocusedWindow() !== mainWindow) return + mainWindow.webContents.send("menu:action", id) + } const template: MenuItemConstructorOptions[] = [ ...(isMac @@ -31,9 +53,20 @@ export function createApplicationMenu(mainWindow: BrowserWindow, actions: Applic { label: "New Instance", accelerator: "CmdOrCtrl+N", - click: () => { - mainWindow.webContents.send("menu:newInstance") - }, + click: sendCommand("new-instance"), + }, + { type: "separator" as const }, + { id: "open-workspace-folder", label: "Open Project Folder", click: sendCommand("open-workspace-folder") }, + { id: "open-workspace-terminal", label: "Open Terminal Here", click: sendCommand("open-workspace-terminal") }, + { + id: "open-workspace-editor", + label: "Open Project In", + submenu: [ + { label: "VS Code", click: sendCommand("open-workspace-editor-vscode") }, + { label: "Cursor", click: sendCommand("open-workspace-editor-cursor") }, + { label: "Zed", click: sendCommand("open-workspace-editor-zed") }, + { label: "VSCodium", click: sendCommand("open-workspace-editor-vscodium") }, + ], }, { type: "separator" as const }, isMac ? { role: "close" as const } : { role: "quit" as const }, @@ -85,5 +118,14 @@ export function createApplicationMenu(mainWindow: BrowserWindow, actions: Applic ] const menu = Menu.buildFromTemplate(template) + applicationMenu = menu Menu.setApplicationMenu(menu) + updateWorkspaceMenuState() + mainWindow.webContents.on("did-start-navigation", (_event, _url, _isInPlace, isMainFrame) => { + if (!isMainFrame) return + workspaceActionsRequested = false + updateWorkspaceMenuState() + }) + app.on("browser-window-focus", updateWorkspaceMenuState) + app.on("browser-window-blur", updateWorkspaceMenuState) } diff --git a/packages/electron-app/electron/main/workspace-open.test.ts b/packages/electron-app/electron/main/workspace-open.test.ts new file mode 100644 index 00000000..a59b824d --- /dev/null +++ b/packages/electron-app/electron/main/workspace-open.test.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict" +import { EventEmitter } from "node:events" +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import test from "node:test" +import type { ChildProcess } from "node:child_process" +import { defaultOpenMode, editorCandidates, openWorkspaceTarget } from "./workspace-open" + +test("selects only the requested editor", () => { + assert.deepEqual(editorCandidates("zed", "linux", {}), [{ command: "zed", verifyStart: true }]) + assert.deepEqual(editorCandidates("vscodium", "darwin", {}), [ + { command: "/usr/bin/open", args: ["-a", "VSCodium"], waitForExit: true }, + ]) +}) + +test("opens only paths inside the workspace", async () => { + const root = mkdtempSync(join(tmpdir(), "codenomad-workspace-")) + const outside = mkdtempSync(join(tmpdir(), "codenomad-outside-")) + const opened: string[] = [] + const dependencies = { + openPath: async (path: string) => { opened.push(path); return "" }, + revealPath: () => undefined, + } + + try { + await openWorkspaceTarget("default", root, ".", undefined, dependencies) + assert.deepEqual(opened, [root]) + await assert.rejects(openWorkspaceTarget("default", root, outside, undefined, dependencies), /outside the workspace/) + } finally { + rmSync(root, { recursive: true, force: true }) + rmSync(outside, { recursive: true, force: true }) + } +}) + +test("edits Windows scripts and rejects executable default-open targets", () => { + const root = mkdtempSync(join(tmpdir(), "codenomad-executable-")) + const script = join(root, "run.cmd") + writeFileSync(script, "echo unsafe") + try { + assert.equal(defaultOpenMode(script, "win32"), "edit") + const document = join(root, "notes.txt") + writeFileSync(document, "safe") + assert.equal(defaultOpenMode(document, "win32"), "open") + const executable = join(root, "run.exe") + writeFileSync(executable, "unsafe") + assert.equal(defaultOpenMode(executable, "win32"), "choose") + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test("launches terminals in the selected directory without a shell", async () => { + const root = mkdtempSync(join(tmpdir(), "codenomad-terminal-")) + const nested = join(root, "nested") + mkdirSync(nested) + const launches: Array<{ command: string; args: readonly string[]; cwd: string }> = [] + const spawnProcess = (command: string, args: readonly string[], options: { cwd: string }) => { + launches.push({ command, args, cwd: options.cwd }) + const child = new EventEmitter() as ChildProcess + child.unref = () => child + queueMicrotask(() => { + child.emit("spawn") + child.emit("exit", 0) + }) + return child + } + + try { + await openWorkspaceTarget("terminal", root, "nested", undefined, { + openPath: async () => "", + revealPath: () => undefined, + spawnProcess, + }) + assert.equal(launches[0]?.cwd, nested) + assert.equal(launches[0]?.args.includes(nested), false) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/packages/electron-app/electron/main/workspace-open.ts b/packages/electron-app/electron/main/workspace-open.ts new file mode 100644 index 00000000..4a4a11e1 --- /dev/null +++ b/packages/electron-app/electron/main/workspace-open.ts @@ -0,0 +1,238 @@ +import { spawn, type ChildProcess } from "node:child_process" +import { existsSync, realpathSync, statSync } from "node:fs" +import { extname, isAbsolute, join, relative, resolve } from "node:path" + +export type WorkspaceOpenTarget = "default" | "reveal" | "terminal" | "editor" +export type WorkspaceEditor = "vscode" | "cursor" | "zed" | "vscodium" + +interface LaunchCandidate { + command: string + args?: string[] + passPath?: boolean + waitForExit?: boolean + verifyStart?: boolean +} + +type SpawnProcess = (command: string, args: readonly string[], options: { + cwd: string + detached: boolean + stdio: "ignore" +}) => ChildProcess + +interface WorkspaceOpenDependencies { + openPath: (path: string) => Promise + revealPath: (path: string) => void + spawnProcess?: SpawnProcess +} + +const BLOCKED_DEFAULT_OPEN_EXTENSIONS = new Set([ + ".appimage", ".application", ".chm", ".com", ".cpl", ".desktop", ".exe", ".jar", ".lnk", ".msi", + ".msp", ".pif", ".scr", ".url", +]) + +const SAFE_WINDOWS_OPEN_EXTENSIONS = new Set([ + ".7z", ".avi", ".bmp", ".c", ".cc", ".cfg", ".conf", ".cpp", ".cs", ".css", ".csv", ".dart", + ".diff", ".docx", ".env", ".flac", ".fs", ".fsx", ".gif", ".go", ".gz", ".h", ".hpp", + ".htm", ".html", ".ini", ".java", ".jpeg", ".jpg", ".json", ".jsonc", ".jsx", ".kt", ".kts", + ".less", ".lock", ".log", ".lua", ".md", ".markdown", ".mkv", ".mov", ".mp3", ".mp4", ".ogg", + ".patch", ".pdf", ".png", ".pptx", ".r", ".rar", ".rmd", ".rs", ".scss", ".sql", ".svg", + ".svelte", ".swift", ".tar", ".toml", ".ts", ".tsx", ".txt", ".vue", ".wav", ".webm", ".webp", + ".xlsx", ".xml", ".yaml", ".yml", ".zip", +]) + +const WINDOWS_EDIT_EXTENSIONS = new Set([ + ".bat", ".cmd", ".js", ".jse", ".pl", ".ps1", ".psd1", ".psm1", ".py", ".pyw", ".rb", ".reg", + ".vbe", ".vbs", ".wsf", ".wsh", +]) + +export function defaultOpenMode(path: string, platform: NodeJS.Platform): "open" | "edit" | "choose" { + const stats = statSync(path) + const extension = extname(path).toLowerCase() + if (stats.isDirectory()) { + if (platform === "darwin" && extension === ".app") throw new Error("Application bundles cannot be opened externally") + return "open" + } + if (platform === "win32") { + if (SAFE_WINDOWS_OPEN_EXTENSIONS.has(extension)) return "open" + if (WINDOWS_EDIT_EXTENSIONS.has(extension)) return "edit" + return "choose" + } + if (BLOCKED_DEFAULT_OPEN_EXTENSIONS.has(extension)) { + throw new Error("Executable files cannot be opened externally") + } + if (stats.isFile() && (stats.mode & 0o111) !== 0) { + throw new Error("Executable files cannot be opened externally") + } + return "open" +} + +export function editorCandidates(editor: WorkspaceEditor, platform: NodeJS.Platform, env: NodeJS.ProcessEnv): LaunchCandidate[] { + if (platform === "darwin") { + const names: Record = { + vscode: "Visual Studio Code", + cursor: "Cursor", + zed: "Zed", + vscodium: "VSCodium", + } + return [{ command: "/usr/bin/open", args: ["-a", names[editor]], waitForExit: true }] + } + + if (platform === "win32") { + const paths: Record> = { + vscode: [ + env.LOCALAPPDATA && join(env.LOCALAPPDATA, "Programs", "Microsoft VS Code", "Code.exe"), + env.ProgramFiles && join(env.ProgramFiles, "Microsoft VS Code", "Code.exe"), + ], + cursor: [env.LOCALAPPDATA && join(env.LOCALAPPDATA, "Programs", "cursor", "Cursor.exe")], + zed: [env.LOCALAPPDATA && join(env.LOCALAPPDATA, "Programs", "Zed", "Zed.exe")], + vscodium: [ + env.LOCALAPPDATA && join(env.LOCALAPPDATA, "Programs", "VSCodium", "VSCodium.exe"), + env.ProgramFiles && join(env.ProgramFiles, "VSCodium", "VSCodium.exe"), + ], + } + return paths[editor] + .filter((command): command is string => Boolean(command)) + .filter((command) => !isAbsolute(command) || existsSync(command)) + .map((command) => ({ command })) + } + + const commands: Record = { vscode: "code", cursor: "cursor", zed: "zed", vscodium: "codium" } + return [{ command: commands[editor], verifyStart: true }] +} + +export function terminalCandidates(platform: NodeJS.Platform, env: NodeJS.ProcessEnv, folder = "."): LaunchCandidate[] { + if (platform === "darwin") return [{ command: "/usr/bin/open", args: ["-a", "Terminal"], waitForExit: true }] + if (platform === "win32") { + const systemRoot = env.SystemRoot?.trim() || "C:\\Windows" + const command = env.ComSpec?.trim() || join(systemRoot, "System32", "cmd.exe") + const powershell = join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe") + const script = `Set-Location -LiteralPath '${folder.replace(/'/g, "''")}'` + const encoded = Buffer.from(script, "utf16le").toString("base64") + return [{ + command, + args: ["/D", "/C", "start", "", powershell, "-NoExit", "-EncodedCommand", encoded], + passPath: false, + waitForExit: true, + }] + } + + const configured = env.TERMINAL?.trim() + return [ + ...(configured ? [{ command: configured, passPath: false, verifyStart: true }] : []), + ...["xdg-terminal-exec", "x-terminal-emulator", "gnome-terminal", "konsole", "kitty", "alacritty", "wezterm"] + .map((command) => ({ command, passPath: false, verifyStart: true })), + ] +} + +function launch(candidate: LaunchCandidate, selectedPath: string, cwd: string, spawnProcess: SpawnProcess): Promise { + return new Promise((resolve, reject) => { + let settled = false + let verificationTimer: ReturnType | undefined + const finish = (error?: Error) => { + if (settled) return + settled = true + if (verificationTimer) clearTimeout(verificationTimer) + if (error) reject(error) + else resolve() + } + const args = [...(candidate.args ?? []), ...(candidate.passPath === false ? [] : [selectedPath])] + const child = spawnProcess(candidate.command, args, { + cwd, + detached: true, + stdio: "ignore", + }) + child.once("spawn", () => { + if (!candidate.waitForExit) { + if (candidate.verifyStart) { + verificationTimer = setTimeout(() => { + child.unref() + finish() + }, 250) + return + } + child.unref() + finish() + } + }) + child.once("exit", (code) => { + if (!candidate.waitForExit && !candidate.verifyStart) return + if (code === 0) finish() + else finish(new Error(`${candidate.command} exited with code ${code ?? "unknown"}`)) + }) + child.once("error", (error) => finish(error)) + }) +} + +async function launchFirst(candidates: LaunchCandidate[], selectedPath: string, cwd: string, spawnProcess: SpawnProcess): Promise { + let lastError: unknown + for (const candidate of candidates) { + try { + await launch(candidate, selectedPath, cwd, spawnProcess) + return + } catch (error) { + lastError = error + } + } + throw lastError instanceof Error ? lastError : new Error("No supported application was found") +} + +export async function openWorkspaceTarget( + target: WorkspaceOpenTarget, + workspaceFolder: string, + path = ".", + editor?: WorkspaceEditor, + dependencies?: WorkspaceOpenDependencies, +): Promise { + if (!dependencies) throw new Error("Native workspace openers are unavailable") + const spawnProcess = dependencies.spawnProcess ?? spawn + const root = realpathSync(workspaceFolder) + if (!statSync(root).isDirectory()) throw new Error("Workspace folder does not exist") + const selectedPath = realpathSync(resolve(root, path)) + const relativePath = relative(root, selectedPath) + if (relativePath === ".." || relativePath.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(relativePath)) { + throw new Error("Selected path is outside the workspace") + } + const cwd = statSync(selectedPath).isDirectory() ? selectedPath : resolve(selectedPath, "..") + + if (target === "default") { + const mode = defaultOpenMode(selectedPath, process.platform) + if (mode === "edit" || mode === "choose") { + const systemRoot = process.env.SystemRoot?.trim() || "C:\\Windows" + const powershell = join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe") + const rundll32 = join(systemRoot, "System32", "rundll32.exe") + const script = `$ErrorActionPreference='Stop'; Start-Process -FilePath '${selectedPath.replace(/'/g, "''")}' -Verb Edit` + const encoded = Buffer.from(script, "utf16le").toString("base64") + const candidates: LaunchCandidate[] = [ + ...(mode === "edit" ? [{ + command: powershell, + args: ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], + passPath: false, + waitForExit: true, + }] : []), + { + command: rundll32, + args: ["shell32.dll,OpenAs_RunDLL"], + verifyStart: true, + }, + ] + await launchFirst(candidates, selectedPath, cwd, spawnProcess) + return + } + const error = await dependencies.openPath(selectedPath) + if (error) throw new Error(error) + return + } + + if (target === "reveal") { + dependencies.revealPath(selectedPath) + return + } + + if (!statSync(selectedPath).isDirectory() && target === "terminal") throw new Error("Terminal target is not a folder") + if (target === "editor" && !editor) throw new Error("Editor is required") + + const candidates = target === "terminal" + ? terminalCandidates(process.platform, process.env, selectedPath) + : editorCandidates(editor!, process.platform, process.env) + await launchFirst(candidates, selectedPath, cwd, spawnProcess) +} diff --git a/packages/electron-app/electron/preload/index.cjs b/packages/electron-app/electron/preload/index.cjs index 0a35f669..618b5858 100644 --- a/packages/electron-app/electron/preload/index.cjs +++ b/packages/electron-app/electron/preload/index.cjs @@ -26,6 +26,13 @@ const localElectronAPI = { restartCli: () => ipcRenderer.invoke("cli:restart"), openDialog: (options) => ipcRenderer.invoke("dialog:open", options), getDirectoryPaths: (paths) => ipcRenderer.invoke("filesystem:getDirectoryPaths", paths), + openWorkspaceTarget: (payload) => ipcRenderer.invoke("workspace:openTarget", payload), + setWorkspaceMenuEnabled: (enabled) => ipcRenderer.invoke("workspace:setMenuEnabled", Boolean(enabled)), + onMenuAction: (callback) => { + const handler = (_event, action) => callback(action) + ipcRenderer.on("menu:action", handler) + return () => ipcRenderer.removeListener("menu:action", handler) + }, getPathForFile: (file) => { try { return webUtils.getPathForFile(file) diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index d3ce819a..a1b20031 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.18.0", + "version": "0.19.0", "description": "CodeNomad - AI coding assistant", "license": "MIT", "author": { @@ -24,7 +24,7 @@ "prebuild": "npm run prepare:resources", "build": "electron-vite build", "typecheck": "tsc --noEmit -p tsconfig.json", - "test:native": "node --import tsx --test electron/main/client-state-cross-host.test.ts electron/main/client-state-process.test.ts electron/main/client-state.test.ts electron/main/client-state-ipc.test.ts electron/main/client-state-navigation.test.ts electron/main/client-state-lifecycle.test.ts electron/main/process-stop.test.ts electron/main/renderer-client-state-flush.test.ts electron/main/renderer-origin.test.ts electron/main/serialized-lifecycle.test.ts electron/main/window-state.test.ts", + "test:native": "node --import tsx --test electron/main/client-state-cross-host.test.ts electron/main/client-state-process.test.ts electron/main/client-state.test.ts electron/main/client-state-ipc.test.ts electron/main/client-state-navigation.test.ts electron/main/client-state-lifecycle.test.ts electron/main/process-stop.test.ts electron/main/renderer-client-state-flush.test.ts electron/main/renderer-origin.test.ts electron/main/serialized-lifecycle.test.ts electron/main/window-state.test.ts electron/main/workspace-open.test.ts", "preview": "electron-vite preview", "build:binaries": "node scripts/build.js", "build:mac": "node scripts/build.js mac", diff --git a/packages/server/package-lock.json b/packages/server/package-lock.json index eff79de6..cd709585 100644 --- a/packages/server/package-lock.json +++ b/packages/server/package-lock.json @@ -1,24 +1,25 @@ { "name": "@neuralnomads/codenomad", - "version": "0.18.0", + "version": "0.19.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@neuralnomads/codenomad", - "version": "0.18.0", + "version": "0.19.0", "license": "MIT", "dependencies": { - "@fastify/cors": "^11.2.0", - "@fastify/reply-from": "^12.6.2", - "@fastify/static": "^9.1.1", + "@fastify/cors": "^8.5.0", + "@fastify/reply-from": "^9.8.0", + "@fastify/static": "^7.0.4", + "@opencode-ai/client": "0.0.0-next-17353", "commander": "^12.1.0", - "fastify": "^5.8.5", + "fastify": "^4.28.1", "fuzzysort": "^2.0.4", "node-forge": "^1.3.3", "openai": "^6.27.0", "pino": "^9.4.0", - "undici": "^8.1.0", + "undici": "^6.19.8", "yaml": "^2.4.2", "yauzl": "^2.10.0", "zod": "^3.23.8" @@ -29,7 +30,6 @@ "devDependencies": { "@types/node-forge": "^1.3.14", "@types/yauzl": "^2.10.0", - "bun": "^1.3.13", "cross-env": "^7.0.3", "ts-node": "^10.9.2", "tsx": "^4.20.6", @@ -492,231 +492,137 @@ } }, "node_modules/@fastify/accept-negotiator": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", - "integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-1.1.0.tgz", + "integrity": "sha512-OIHZrb2ImZ7XG85HXOONLcJWGosv7sIvM2ifAPQVhg9Lv7qdmMBNVaai4QTdyuaqbKM5eO6sLSQOYI7wEQeCJQ==", + "license": "MIT", + "engines": { + "node": ">=14" + } }, "node_modules/@fastify/ajv-compiler": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", - "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz", + "integrity": "sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==", "license": "MIT", "dependencies": { - "ajv": "^8.12.0", - "ajv-formats": "^3.0.1", - "fast-uri": "^3.0.0" + "ajv": "^8.11.0", + "ajv-formats": "^2.1.1", + "fast-uri": "^2.0.0" + } + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "license": "MIT", + "engines": { + "node": ">=14" } }, "node_modules/@fastify/cors": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz", - "integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-8.5.0.tgz", + "integrity": "sha512-/oZ1QSb02XjP0IK1U0IXktEsw/dUBTxJOW7IpIeO8c/tNalw/KjoNSJv1Sf6eqoBPO+TDGkifq6ynFK3v68HFQ==", "license": "MIT", "dependencies": { - "fastify-plugin": "^5.0.0", - "toad-cache": "^3.7.0" + "fastify-plugin": "^4.0.0", + "mnemonist": "0.39.6" } }, "node_modules/@fastify/error": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", - "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz", + "integrity": "sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==", "license": "MIT" }, "node_modules/@fastify/fast-json-stringify-compiler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", - "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.3.0.tgz", + "integrity": "sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==", "license": "MIT", "dependencies": { - "fast-json-stringify": "^6.0.0" + "fast-json-stringify": "^5.7.0" } }, - "node_modules/@fastify/forwarded": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", - "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, "node_modules/@fastify/merge-json-schemas": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", - "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.1.1.tgz", + "integrity": "sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==", "license": "MIT", "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/@fastify/proxy-addr": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", - "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/forwarded": "^3.0.0", - "ipaddr.js": "^2.1.0" + "fast-deep-equal": "^3.1.3" } }, "node_modules/@fastify/reply-from": { - "version": "12.6.2", - "resolved": "https://registry.npmjs.org/@fastify/reply-from/-/reply-from-12.6.2.tgz", - "integrity": "sha512-FhMvsRJa4HMG0q0/Yi06sgwVFd/Wc8E/+RbHsqB0Q+883C66RPrS6qwZKBPje2mVzsyEb9sjq7wlyvi35zRW0A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/@fastify/reply-from/-/reply-from-9.8.0.tgz", + "integrity": "sha512-bPNVaFhEeNI0Lyl6404YZaPFokudCplidE3QoOcr78yOy6H9sYw97p5KPYvY/NJNUHfFtvxOaSAHnK+YSiv/Mg==", "license": "MIT", "dependencies": { - "@fastify/error": "^4.0.0", + "@fastify/error": "^3.0.0", "end-of-stream": "^1.4.4", - "fast-content-type-parse": "^3.0.0", - "fast-querystring": "^1.1.2", - "fastify-plugin": "^5.0.1", + "fast-content-type-parse": "^1.1.0", + "fast-querystring": "^1.0.0", + "fastify-plugin": "^4.0.0", "toad-cache": "^3.7.0", - "undici": "^7.0.0" + "undici": "^5.19.1" } }, "node_modules/@fastify/reply-from/node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, "engines": { - "node": ">=20.18.1" + "node": ">=14.0" } }, "node_modules/@fastify/send": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", - "integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-2.1.0.tgz", + "integrity": "sha512-yNYiY6sDkexoJR0D8IDy3aRP3+L4wdqCpvx5WP+VtEU58sn7USmKynBzDQex5X42Zzvw2gNzzYgP90UfWShLFA==", "license": "MIT", "dependencies": { - "@lukeed/ms": "^2.0.2", + "@lukeed/ms": "^2.0.1", "escape-html": "~1.0.3", "fast-decode-uri-component": "^1.0.1", - "http-errors": "^2.0.0", - "mime": "^3" + "http-errors": "2.0.0", + "mime": "^3.0.0" } }, "node_modules/@fastify/static": { - "version": "9.1.3", - "resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.1.3.tgz", - "integrity": "sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-7.0.4.tgz", + "integrity": "sha512-p2uKtaf8BMOZWLs6wu+Ihg7bWNBdjNgCwDza4MJtTqg+5ovKmcbgbR9Xs5/smZ1YISfzKOCNYmZV8LaCj+eJ1Q==", "license": "MIT", "dependencies": { - "@fastify/accept-negotiator": "^2.0.0", - "@fastify/send": "^4.0.0", - "content-disposition": "^1.0.1", - "fastify-plugin": "^5.0.0", - "fastq": "^1.17.1", - "glob": "^13.0.0" + "@fastify/accept-negotiator": "^1.0.0", + "@fastify/send": "^2.0.0", + "content-disposition": "^0.5.3", + "fastify-plugin": "^4.0.0", + "fastq": "^1.17.0", + "glob": "^10.3.4" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" } }, "node_modules/@jridgewell/resolve-uri": { @@ -756,173 +662,121 @@ "node": ">=8" } }, - "node_modules/@oven/bun-darwin-aarch64": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.13.tgz", - "integrity": "sha512-qAS6Hg8Q14ckfBuqJ2Zh7gBQSVSUHeibSq4OFqBTv6DzyJuxYlr0sdYQzmYmnbPxbqobekqUDTa/4XEaqRi7vg==", + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ] }, - "node_modules/@oven/bun-darwin-x64": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.13.tgz", - "integrity": "sha512-kGePeDD4IN4imo+H4uLjQGZLmvyYQg+nKr2P0nt4ksXXrWA4HE+mb0/TUPHfRI127DocXQpew+fvrHuHR5mpJQ==", + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ] }, - "node_modules/@oven/bun-darwin-x64-baseline": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.13.tgz", - "integrity": "sha512-gMEQayUpmCPYaE9zkNBj9TiQqHupnhjOYcuSzxFjzIjHJBUO4VjNnrpbKVeXNs+rKHFothORDd2QKquu5paSPQ==", + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", "cpu": [ - "x64" + "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "darwin" + "linux" ] }, - "node_modules/@oven/bun-linux-aarch64": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.13.tgz", - "integrity": "sha512-NbLOJdr+RBFO1vFZ2YUFg4oVJ+2ua6zrwo4ZWRs0jKKcGJWtbY2wY5uz+i0PkwH6b9HYaYDgVTzE4ev06ncYZw==", + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@oven/bun-linux-aarch64-musl": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.13.tgz", - "integrity": "sha512-UV9EE18VE5aRhWtV2L6MTAGGn3slhJJ2OW/m+FJM15maHm0qf1V7TaZY0FovxhdQRvnklSiQ7Ntv0H5TUX4w0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.13.tgz", - "integrity": "sha512-UwttIUXoe9fS+40OcjoaRHgZw+HCPFqBVWEXkXqAJ3W7wA0XPZrWsoMAD9sGh3TaLqrwdiMo5xPogwpXhOtVXA==", + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@oven/bun-linux-x64-baseline": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.13.tgz", - "integrity": "sha512-fOi4ziKzgJG4UrrNd4AicBs6Fu9GY5xOqg+9tC76nuZNDAdSh6++kzab6TNi1Ck0Yzq6zIBIdGit6/0uSbBn8A==", + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", "cpu": [ "x64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64-musl": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.13.tgz", - "integrity": "sha512-+VHhE44kEjCXcTFHyc81zfTxL9+vzh9RqIh7gM1iWNhxpctD9kzntbUkP3UTFTwwNjoou1o8VRyxQafvc4OepA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64-musl-baseline": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.13.tgz", - "integrity": "sha512-fqBKuiiWLEu2dVkowZaXgKS98xfrvBqivdoxRtRP3eINcpI1dcelGbsOz+Xphn7tbGAuBiE1/0AelvvvdqS9rg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-windows-aarch64": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-aarch64/-/bun-windows-aarch64-1.3.13.tgz", - "integrity": "sha512-+EvdRWRCRg95Xea4M2lqSJFTjzQBTJDQTMlbG8bmwFkVTN16MdmSH7xhfxVQWUOyZBLEpIwuNFIlBBxVCwSUyQ==", - "cpu": [ - "arm64" - ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ] }, - "node_modules/@oven/bun-windows-x64": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.13.tgz", - "integrity": "sha512-vqDEFX63ZZQF3YstPSpPD+RxNm5AILPdUuuKpNwsj7ld4NjhdHUYkAmLXDtKNWt9JMRL10bop//W8faY/LV+RQ==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@opencode-ai/client": { + "version": "0.0.0-next-17353", + "resolved": "https://registry.npmjs.org/@opencode-ai/client/-/client-0.0.0-next-17353.tgz", + "integrity": "sha512-LuIl8eL3ZLpoiPBNHvBVn8bcsQnxEvplf5nC83RN5PUK1Wo2pKGTW+e22VMqRhmTz12mxH8n0W4ys5/SFtbyiA==", "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@opencode-ai/protocol": "0.0.0-next-17353", + "@opencode-ai/schema": "0.0.0-next-17353" + }, + "peerDependencies": { + "effect": "4.0.0-beta.101" + }, + "peerDependenciesMeta": { + "effect": { + "optional": true + } + } }, - "node_modules/@oven/bun-windows-x64-baseline": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.13.tgz", - "integrity": "sha512-6gy4hhQSjq/T/S9hC9m3NxY0RY+9Ww+XNlB+8koIMTsMSYEjk7Ho+hFHQz1Bn4W61Ub7Vykufg+jgDgPfa2GFA==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@opencode-ai/protocol": { + "version": "0.0.0-next-17353", + "resolved": "https://registry.npmjs.org/@opencode-ai/protocol/-/protocol-0.0.0-next-17353.tgz", + "integrity": "sha512-QnCRsd3hznEXsTAQxloFBA97VQC3gHFGjB91ugZjmvKzcWYVvuBjMp86GZzv1ZWsmUCemiIwvyp6v4d7hjg/SQ==", "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@opencode-ai/schema": "0.0.0-next-17353", + "effect": "4.0.0-beta.101" + } + }, + "node_modules/@opencode-ai/schema": { + "version": "0.0.0-next-17353", + "resolved": "https://registry.npmjs.org/@opencode-ai/schema/-/schema-0.0.0-next-17353.tgz", + "integrity": "sha512-7ZfJmXOsMI16w0UpqRvtwh3Dice4DG0irufuNqIINUc2aHWL8LOpZkpyhu+n9KKbCYOj/VMeWGLwhx8u5KIoNA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "1.1.0", + "effect": "4.0.0-beta.101" + } }, "node_modules/@pinojs/redact": { "version": "0.4.0", @@ -930,6 +784,22 @@ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -964,6 +834,7 @@ "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1021,9 +892,9 @@ } }, "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -1037,9 +908,9 @@ } }, "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -1053,6 +924,46 @@ } } }, + "node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -1070,44 +981,28 @@ } }, "node_modules/avvio": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", - "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-8.4.0.tgz", + "integrity": "sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==", "license": "MIT", "dependencies": { - "@fastify/error": "^4.0.0", + "@fastify/error": "^3.3.0", "fastq": "^1.17.1" } }, "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "license": "MIT", "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" + "balanced-match": "^1.0.0" } }, "node_modules/buffer-crc32": { @@ -1119,41 +1014,24 @@ "node": "*" } }, - "node_modules/bun": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/bun/-/bun-1.3.13.tgz", - "integrity": "sha512-b9T4xZ8KqCHs4+TkHJv540LG1B8OD7noKu0Qaizusx3jFtMDHY6osNqgbaOlwW2B8RB2AKzz+sjzlGKIGxIjZw==", - "cpu": [ - "arm64", - "x64" - ], - "dev": true, - "hasInstallScript": true, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "license": "MIT", - "os": [ - "darwin", - "linux", - "win32" - ], - "bin": { - "bun": "bin/bun.exe", - "bunx": "bin/bunx.exe" + "dependencies": { + "color-name": "~1.1.4" }, - "optionalDependencies": { - "@oven/bun-darwin-aarch64": "1.3.13", - "@oven/bun-darwin-x64": "1.3.13", - "@oven/bun-darwin-x64-baseline": "1.3.13", - "@oven/bun-linux-aarch64": "1.3.13", - "@oven/bun-linux-aarch64-musl": "1.3.13", - "@oven/bun-linux-x64": "1.3.13", - "@oven/bun-linux-x64-baseline": "1.3.13", - "@oven/bun-linux-x64-musl": "1.3.13", - "@oven/bun-linux-x64-musl-baseline": "1.3.13", - "@oven/bun-windows-aarch64": "1.3.13", - "@oven/bun-windows-x64": "1.3.13", - "@oven/bun-windows-x64-baseline": "1.3.13" + "engines": { + "node": ">=7.0.0" } }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -1164,29 +1042,24 @@ } }, "node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "safe-buffer": "5.2.1" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "engines": { + "node": ">= 0.6" } }, "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "license": "MIT", "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">= 0.6" } }, "node_modules/create-require": { @@ -1219,7 +1092,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -1239,13 +1111,14 @@ "node": ">= 0.8" } }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "license": "MIT", + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, "engines": { - "node": ">=6" + "node": ">=8" } }, "node_modules/diff": { @@ -1258,6 +1131,36 @@ "node": ">=0.3.1" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/effect": { + "version": "4.0.0-beta.101", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.101.tgz", + "integrity": "sha512-HjowumlIo+orthn4jMlEJPuzIYPBV+uq/XiciHWhiedLsXQpWHdNJHO5d59BVDP5s1LPuvERcktwFqRXnJqnhA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.9.0", + "find-my-way-ts": "^0.1.6", + "ini": "^7.0.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^2.0.4", + "multipasta": "^0.2.8", + "toml": "^4.1.2", + "uuid": "^14.0.1", + "yaml": "^2.9.0" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -1315,20 +1218,32 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, - "node_modules/fast-content-type-parse": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", - "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", + "node_modules/fast-check": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", + "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", "funding": [ { - "type": "github", - "url": "https://github.com/sponsors/fastify" + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" }, { "type": "opencollective", - "url": "https://opencollective.com/fastify" + "url": "https://opencollective.com/fast-check" } ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/fast-content-type-parse": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", + "integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==", "license": "MIT" }, "node_modules/fast-decode-uri-component": { @@ -1344,29 +1259,37 @@ "license": "MIT" }, "node_modules/fast-json-stringify": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", - "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "version": "5.16.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-5.16.1.tgz", + "integrity": "sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==", "license": "MIT", "dependencies": { - "@fastify/merge-json-schemas": "^0.2.0", - "ajv": "^8.12.0", + "@fastify/merge-json-schemas": "^0.1.0", + "ajv": "^8.10.0", "ajv-formats": "^3.0.1", - "fast-uri": "^3.0.0", - "json-schema-ref-resolver": "^3.0.0", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^2.1.0", + "json-schema-ref-resolver": "^1.0.1", "rfdc": "^1.2.0" } }, + "node_modules/fast-json-stringify/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/fast-querystring": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", @@ -1377,25 +1300,15 @@ } }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.4.tgz", + "integrity": "sha512-GntYZbd2KSiFfoZI3Y02rXKihfsPwdWfiHrwKVLuU1i810D0SYw7fCarLxaRO2VvneTrbzCxSz3GnvEfUiApug==", + "license": "MIT" }, "node_modules/fastify": { - "version": "5.8.5", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz", - "integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==", + "version": "4.29.1", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.1.tgz", + "integrity": "sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==", "funding": [ { "type": "github", @@ -1408,37 +1321,34 @@ ], "license": "MIT", "dependencies": { - "@fastify/ajv-compiler": "^4.0.5", - "@fastify/error": "^4.0.0", - "@fastify/fast-json-stringify-compiler": "^5.0.0", - "@fastify/proxy-addr": "^5.0.0", + "@fastify/ajv-compiler": "^3.5.0", + "@fastify/error": "^3.4.0", + "@fastify/fast-json-stringify-compiler": "^4.3.0", "abstract-logging": "^2.0.1", - "avvio": "^9.0.0", - "fast-json-stringify": "^6.0.0", - "find-my-way": "^9.0.0", - "light-my-request": "^6.0.0", - "pino": "^9.14.0 || ^10.1.0", - "process-warning": "^5.0.0", - "rfdc": "^1.3.1", - "secure-json-parse": "^4.0.0", - "semver": "^7.6.0", - "toad-cache": "^3.7.0" + "avvio": "^8.3.0", + "fast-content-type-parse": "^1.1.0", + "fast-json-stringify": "^5.8.0", + "find-my-way": "^8.0.0", + "light-my-request": "^5.11.0", + "pino": "^9.0.0", + "process-warning": "^3.0.0", + "proxy-addr": "^2.0.7", + "rfdc": "^1.3.0", + "secure-json-parse": "^2.7.0", + "semver": "^7.5.4", + "toad-cache": "^3.3.0" } }, "node_modules/fastify-plugin": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", - "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz", + "integrity": "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==", + "license": "MIT" + }, + "node_modules/fastify/node_modules/process-warning": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", + "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==", "license": "MIT" }, "node_modules/fastq": { @@ -1460,17 +1370,48 @@ } }, "node_modules/find-my-way": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", - "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz", + "integrity": "sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", - "safe-regex2": "^5.0.0" + "safe-regex2": "^3.1.0" }, "engines": { - "node": ">=20" + "node": ">=14" + } + }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", + "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "license": "MIT" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" } }, "node_modules/fsevents": { @@ -1508,40 +1449,40 @@ } }, "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "license": "BlueOak-1.0.0", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, - "engines": { - "node": "18 || 20 || >=22" + "bin": { + "glob": "dist/esm/bin.mjs" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", "license": "MIT", "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" }, "engines": { "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" } }, "node_modules/inherits": { @@ -1550,39 +1491,61 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz", + "integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==", + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/ipaddr.js": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", - "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "license": "MIT", "engines": { - "node": ">= 10" + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" } }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/json-schema-ref-resolver": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", - "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz", + "integrity": "sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==", "license": "MIT", "dependencies": { - "dequal": "^2.0.3" + "fast-deep-equal": "^3.1.3" } }, "node_modules/json-schema-traverse": { @@ -1591,51 +1554,34 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", + "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", + "license": "Apache-2.0" + }, "node_modules/light-my-request": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", - "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz", + "integrity": "sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==", "license": "BSD-3-Clause", "dependencies": { - "cookie": "^1.0.1", - "process-warning": "^4.0.0", - "set-cookie-parser": "^2.6.0" + "cookie": "^0.7.0", + "process-warning": "^3.0.0", + "set-cookie-parser": "^2.4.1" } }, "node_modules/light-my-request/node_modules/process-warning": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", - "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", + "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==", "license": "MIT" }, "node_modules/lru-cache": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", - "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" }, "node_modules/make-error": { "version": "1.3.6", @@ -1657,15 +1603,15 @@ } }, "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "license": "BlueOak-1.0.0", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^2.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": ">=16 || 14 >=14.17" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -1680,6 +1626,52 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mnemonist": { + "version": "0.39.6", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.6.tgz", + "integrity": "sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.1" + } + }, + "node_modules/msgpackr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.5.tgz", + "integrity": "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/multipasta": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz", + "integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==", + "license": "MIT" + }, "node_modules/node-forge": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", @@ -1689,6 +1681,27 @@ "node": ">= 6.13.0" } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -1728,27 +1741,32 @@ } } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": "18 || 20 || >=22" + "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -1813,6 +1831,35 @@ ], "license": "MIT" }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", @@ -1848,9 +1895,9 @@ } }, "node_modules/ret": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", - "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.4.3.tgz", + "integrity": "sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==", "license": "MIT", "engines": { "node": ">=10" @@ -1872,26 +1919,33 @@ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "license": "MIT" }, - "node_modules/safe-regex2": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", - "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "funding": [ { "type": "github", - "url": "https://github.com/sponsors/fastify" + "url": "https://github.com/sponsors/feross" }, { - "type": "opencollective", - "url": "https://opencollective.com/fastify" + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" } ], + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz", + "integrity": "sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==", "license": "MIT", "dependencies": { - "ret": "~0.5.0" - }, - "bin": { - "safe-regex2": "bin/safe-regex2.js" + "ret": "~0.4.0" } }, "node_modules/safe-stable-stringify": { @@ -1904,19 +1958,9 @@ } }, "node_modules/secure-json-parse": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", - "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", "license": "BSD-3-Clause" }, "node_modules/semver": { @@ -1947,7 +1991,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -1960,12 +2003,23 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/sonic-boom": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", @@ -1985,14 +2039,110 @@ } }, "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", "engines": { "node": ">= 0.8" } }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/thread-stream": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", @@ -2020,6 +2170,15 @@ "node": ">=0.6" } }, + "node_modules/toml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz", + "integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -2090,6 +2249,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2099,12 +2259,12 @@ } }, "node_modules/undici": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.1.0.tgz", - "integrity": "sha512-E9MkTS4xXLnRPYqxH2e6Hr2/49e7WFDKczKcCaFH4VaZs2iNvHMqeIkyUAD9vM8kujy9TjVrRlQ5KkdEJxB2pw==", + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", "license": "MIT", "engines": { - "node": ">=22.19.0" + "node": ">=18.17" } }, "node_modules/undici-types": { @@ -2114,6 +2274,19 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -2125,7 +2298,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -2137,6 +2309,97 @@ "node": ">= 8" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -2144,9 +2407,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -2183,6 +2446,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/packages/server/package.json b/packages/server/package.json index a858fae0..b1087c50 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad", - "version": "0.18.0", + "version": "0.19.0", "description": "CodeNomad Server", "license": "MIT", "author": { diff --git a/packages/server/src/settings/service.test.ts b/packages/server/src/settings/service.test.ts new file mode 100644 index 00000000..505a5cca --- /dev/null +++ b/packages/server/src/settings/service.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { SettingsService } from "./service" + +function serviceWithStore(store: Record) { + const service = Object.create(SettingsService.prototype) as SettingsService + Object.assign(service as any, { configStore: store, eventBus: undefined }) + return service +} + +describe("SettingsService config persistence", () => { + it("normalizes and persists a document patch once", () => { + let writes = 0 + const service = serviceWithStore({ + get: () => ({ server: { logLevel: "info" } }), + replace: (value: unknown) => { + writes += 1 + return value + }, + mergePatch: () => assert.fail("must not persist an intermediate document"), + }) + + const result = service.mergePatchDoc("config", { ui: { theme: "dark" } }) + assert.equal(writes, 1) + assert.deepEqual(result, { server: { logLevel: "INFO" }, ui: { theme: "dark" } }) + }) + + it("normalizes and persists a server-owner patch once", () => { + let writes = 0 + const service = serviceWithStore({ + getOwner: () => ({ logLevel: "info", sidecars: [] }), + replaceOwner: (_owner: string, value: unknown) => { + writes += 1 + return value + }, + mergePatchOwner: () => assert.fail("must not persist an intermediate owner"), + }) + + const result = service.mergePatchOwner("config", "server", { sidecars: [{ id: "one" }] }) + assert.equal(writes, 1) + assert.deepEqual(result, { logLevel: "INFO", sidecars: [{ id: "one" }] }) + }) + + it("does not report a persisted patch as failed when an event listener throws", () => { + let warnings = 0 + const service = serviceWithStore({ + getOwner: () => ({}), + mergePatchOwner: (_owner: string, patch: unknown) => patch, + }) + Object.assign(service as any, { + eventBus: { publish: () => { throw new Error("listener failed") } }, + logger: { warn: () => { warnings += 1 } }, + }) + + assert.deepEqual(service.mergePatchOwner("config", "ui", { theme: "dark" }), { theme: "dark" }) + assert.equal(warnings, 1) + }) +}) diff --git a/packages/server/src/settings/service.ts b/packages/server/src/settings/service.ts index f4f0409c..7bb10e72 100644 --- a/packages/server/src/settings/service.ts +++ b/packages/server/src/settings/service.ts @@ -6,6 +6,7 @@ import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store" import { migrateSettingsLayout } from "./migrate" import type { WorkspaceEventPayload } from "../api-types" import { sanitizeConfigOwner } from "./public-config" +import { applyMergePatch } from "./merge-patch" export type DocKind = "config" | "state" @@ -67,8 +68,16 @@ export class SettingsService { private readonly logger: Logger, ) { migrateSettingsLayout(location, logger) - this.configStore = new YamlDocStore(location.configYamlPath, logger.child({ component: "settings-config" })) - this.stateStore = new YamlDocStore(location.stateYamlPath, logger.child({ component: "settings-state" })) + this.configStore = new YamlDocStore( + location.configYamlPath, + logger.child({ component: "settings-config" }), + { throwOnPersistError: true }, + ) + this.stateStore = new YamlDocStore( + location.stateYamlPath, + logger.child({ component: "settings-state" }), + { throwOnPersistError: true }, + ) } getDoc(kind: DocKind): SettingsDoc { @@ -85,9 +94,12 @@ export class SettingsService { } mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc { + if (!isPlainObject(patch)) { + throw new Error("Patch must be a JSON object") + } const updated = kind === "config" - ? this.configStore.replace(normalizeConfigDoc(this.configStore.mergePatch(patch))) + ? this.configStore.replace(normalizeConfigDoc(applyMergePatch(this.configStore.get(), patch) as SettingsDoc)) : this.stateStore.mergePatch(patch) this.publish(kind, "*") return updated @@ -104,10 +116,16 @@ export class SettingsService { } mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc { + if (!isPlainObject(patch)) { + throw new Error("Patch must be a JSON object") + } const updated = kind === "config" ? owner === "server" - ? this.configStore.replaceOwner(owner, normalizeServerConfigOwner(this.configStore.mergePatchOwner(owner, patch))) + ? this.configStore.replaceOwner( + owner, + normalizeServerConfigOwner(applyMergePatch(this.configStore.getOwner(owner), patch) as SettingsDoc), + ) : this.configStore.mergePatchOwner(owner, patch) : this.stateStore.mergePatchOwner(owner, patch) this.publish(kind, owner, updated) @@ -123,6 +141,10 @@ export class SettingsService { owner, value: kind === "config" ? sanitizeConfigOwner(owner, nextValue) : nextValue, } as any - this.eventBus.publish(payload) + try { + this.eventBus.publish(payload) + } catch (error) { + this.logger.warn({ err: error, kind, owner }, "Failed to publish settings change") + } } } diff --git a/packages/server/src/settings/yaml-doc-store.test.ts b/packages/server/src/settings/yaml-doc-store.test.ts new file mode 100644 index 00000000..1dc98edc --- /dev/null +++ b/packages/server/src/settings/yaml-doc-store.test.ts @@ -0,0 +1,84 @@ +import assert from "node:assert/strict" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { describe, it } from "node:test" +import { YamlDocStore } from "./yaml-doc-store" + +describe("YamlDocStore", () => { + it("reports persistence failures without replacing the cached document", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-yaml-store-")) + const parent = path.join(root, "settings") + const file = path.join(parent, "config.yaml") + const store = new YamlDocStore(file, { warn() {} } as any, { throwOnPersistError: true }) + + try { + store.replace({ version: 1 }) + fs.rmSync(parent, { recursive: true }) + fs.writeFileSync(parent, "blocks directory creation") + + assert.throws(() => store.replace({ version: 2 })) + assert.deepEqual(store.get(), { version: 1 }) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }) + + it("keeps the live document intact when atomic replacement fails", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-yaml-store-")) + const file = path.join(root, "config.yaml") + const store = new YamlDocStore(file, { warn() {} } as any, { throwOnPersistError: true }) + const renameSync = fs.renameSync + + try { + store.replace({ version: 1 }) + ;(fs as any).renameSync = () => { throw new Error("replacement failed") } + + assert.throws(() => store.replace({ version: 2 })) + assert.match(fs.readFileSync(file, "utf8"), /version: 1/) + assert.deepEqual(store.get(), { version: 1 }) + + ;(fs as any).renameSync = renameSync + store.replace({ version: 2 }) + assert.match(fs.readFileSync(file, "utf8"), /version: 2/) + } finally { + ;(fs as any).renameSync = renameSync + fs.rmSync(root, { recursive: true, force: true }) + } + }) + + it("preserves private file permissions", { skip: process.platform === "win32" }, () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-yaml-store-")) + const file = path.join(root, "config.yaml") + const store = new YamlDocStore(file, { warn() {} } as any, { throwOnPersistError: true }) + + try { + store.replace({ version: 1 }) + fs.chmodSync(file, 0o664) + store.replace({ version: 2 }) + assert.equal(fs.statSync(file).mode & 0o777, 0o664) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }) + + it("updates the final target without replacing a symlink chain", { skip: process.platform === "win32" }, () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-yaml-store-")) + const target = path.join(root, "target.yaml") + const intermediate = path.join(root, "current.yaml") + const link = path.join(root, "config.yaml") + fs.writeFileSync(target, "version: 1\n") + fs.symlinkSync(target, intermediate) + fs.symlinkSync(intermediate, link) + const store = new YamlDocStore(link, { warn() {} } as any, { throwOnPersistError: true }) + + try { + store.replace({ version: 2 }) + assert.equal(fs.lstatSync(link).isSymbolicLink(), true) + assert.equal(fs.lstatSync(intermediate).isSymbolicLink(), true) + assert.match(fs.readFileSync(target, "utf8"), /version: 2/) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/server/src/settings/yaml-doc-store.ts b/packages/server/src/settings/yaml-doc-store.ts index 91c5540c..95090c10 100644 --- a/packages/server/src/settings/yaml-doc-store.ts +++ b/packages/server/src/settings/yaml-doc-store.ts @@ -1,5 +1,6 @@ import fs from "fs" import path from "path" +import { randomUUID } from "node:crypto" import { parse as parseYaml, stringify as stringifyYaml } from "yaml" import type { Logger } from "../logger" import { applyMergePatch, isPlainObject } from "./merge-patch" @@ -18,6 +19,25 @@ function normalizeDoc(input: unknown): SettingsDoc { return input } +function resolveWriteDestination(filePath: string): string { + let current = path.resolve(filePath) + const seen = new Set() + + while (true) { + let stat: fs.Stats + try { + stat = fs.lstatSync(current) + } catch (error: any) { + if (error?.code === "ENOENT") return current + throw error + } + if (!stat.isSymbolicLink()) return current + if (seen.has(current)) throw new Error(`Circular settings symlink: ${filePath}`) + seen.add(current) + current = path.resolve(path.dirname(current), fs.readlinkSync(current)) + } +} + export class YamlDocStore { private cache: SettingsDoc = {} private loaded = false @@ -25,6 +45,7 @@ export class YamlDocStore { constructor( private readonly filePath: string, private readonly logger: Logger, + private readonly options: { throwOnPersistError?: boolean } = {}, ) {} load(): SettingsDoc { @@ -58,9 +79,17 @@ export class YamlDocStore { replace(next: unknown): SettingsDoc { const normalized = normalizeDoc(next) + const previousCache = this.cache + const previousLoaded = this.loaded this.cache = normalized this.loaded = true - this.persist() + try { + this.persist() + } catch (error) { + this.cache = previousCache + this.loaded = previousLoaded + throw error + } return this.cache } @@ -99,12 +128,27 @@ export class YamlDocStore { } private persist() { + let tempPath: string | undefined try { - fs.mkdirSync(path.dirname(this.filePath), { recursive: true }) + const destination = resolveWriteDestination(this.filePath) + fs.mkdirSync(path.dirname(destination), { recursive: true }) const yaml = stringifyYaml(this.cache as any) - fs.writeFileSync(this.filePath, ensureTrailingNewline(yaml), "utf-8") + const mode = fs.existsSync(destination) ? fs.statSync(destination).mode & 0o777 : 0o600 + tempPath = `${destination}.${process.pid}.${randomUUID()}.tmp` + fs.writeFileSync(tempPath, ensureTrailingNewline(yaml), { encoding: "utf-8", mode }) + if (process.platform !== "win32") fs.chmodSync(tempPath, mode) + fs.renameSync(tempPath, destination) } catch (error) { this.logger.warn({ err: error, filePath: this.filePath }, "Failed to persist YAML doc") + if (this.options.throwOnPersistError) throw error + } finally { + if (tempPath) { + try { + fs.rmSync(tempPath, { force: true }) + } catch (error) { + this.logger.warn({ err: error, tempPath }, "Failed to remove temporary YAML doc") + } + } } } } diff --git a/packages/server/src/sidecars/manager.test.ts b/packages/server/src/sidecars/manager.test.ts new file mode 100644 index 00000000..65eb1d12 --- /dev/null +++ b/packages/server/src/sidecars/manager.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { SideCarManager } from "./manager" + +const existing = { + id: "existing", + kind: "port", + name: "Existing", + port: 65534, + insecure: true, + prefixMode: "strip", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +} + +function manager(sidecars: unknown[] = []) { + return new SideCarManager({ + settings: { + getOwner: () => ({ sidecars }), + mergePatchOwner: () => { throw new Error("disk full") }, + } as any, + eventBus: { publish() {} } as any, + logger: { warn() {} } as any, + }) +} + +describe("SideCarManager persistence rollback", () => { + it("restores create, update, and delete state when persistence fails", async () => { + const createManager = manager() + await assert.rejects(createManager.create({ + kind: "port", + name: "New", + port: 65533, + insecure: true, + prefixMode: "strip", + })) + assert.deepEqual(await createManager.list(), []) + + const updateManager = manager([existing]) + await assert.rejects(updateManager.update(existing.id, { name: "Changed" })) + assert.equal((await updateManager.get(existing.id))?.name, existing.name) + + const deleteManager = manager([existing]) + await assert.rejects(deleteManager.delete(existing.id)) + assert.equal((await deleteManager.get(existing.id))?.id, existing.id) + }) +}) diff --git a/packages/server/src/sidecars/manager.ts b/packages/server/src/sidecars/manager.ts index 57593c30..5fce0677 100644 --- a/packages/server/src/sidecars/manager.ts +++ b/packages/server/src/sidecars/manager.ts @@ -82,7 +82,13 @@ export class SideCarManager { this.configs.set(record.id, record) this.runtime.set(record.id, { status: "stopped" }) - this.persistConfigs() + try { + this.persistConfigs() + } catch (error) { + this.configs.delete(record.id) + this.runtime.delete(record.id) + throw error + } await this.refreshPortSideCar(record.id) return this.toSideCar(record) } @@ -98,13 +104,19 @@ export class SideCarManager { ): Promise { const record = this.requireConfig(id) + const previous = { ...record } record.name = typeof input.name === "string" ? input.name.trim() : record.name record.port = typeof input.port === "number" ? input.port : record.port record.insecure = typeof input.insecure === "boolean" ? input.insecure : record.insecure record.prefixMode = typeof input.prefixMode === "string" ? input.prefixMode : record.prefixMode record.updatedAt = new Date().toISOString() - this.persistConfigs() + try { + this.persistConfigs() + } catch (error) { + this.configs.set(id, previous) + throw error + } await this.refreshPortSideCar(id) return this.toSideCar(record) } @@ -113,9 +125,16 @@ export class SideCarManager { const record = this.configs.get(id) if (!record) return false + const runtime = this.runtime.get(id) this.configs.delete(id) this.runtime.delete(id) - this.persistConfigs() + try { + this.persistConfigs() + } catch (error) { + this.configs.set(id, record) + if (runtime) this.runtime.set(id, runtime) + throw error + } this.options.eventBus.publish({ type: "sidecar.removed", sidecarId: id }) return true } diff --git a/packages/server/src/workspaces/__tests__/git-worktrees.test.ts b/packages/server/src/workspaces/__tests__/git-worktrees.test.ts index bc6382a1..55e17432 100644 --- a/packages/server/src/workspaces/__tests__/git-worktrees.test.ts +++ b/packages/server/src/workspaces/__tests__/git-worktrees.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import path from "node:path" @@ -8,30 +9,15 @@ import { listWorktrees } from "../git-worktrees" describe("listWorktrees", () => { it("uses the selected workspace folder for the root worktree directory", async () => { const temp = mkdtempSync(path.join(tmpdir(), "codenomad-git-worktrees-")) - const binDir = path.join(temp, "bin") const repoRoot = path.join(temp, "repo") const workspaceFolder = path.join(repoRoot, "proj-1") - const originalPath = process.env.PATH try { - mkdirSync(binDir, { recursive: true }) + execFileSync("git", ["init", "--initial-branch=main", repoRoot]) mkdirSync(workspaceFolder, { recursive: true }) - - const gitPath = path.join(binDir, process.platform === "win32" ? "git.cmd" : "git") - const porcelain = [ - `worktree ${repoRoot}`, - "HEAD 1111111", - "branch refs/heads/main", - "", - ].join("\n") - - if (process.platform === "win32") { - writeFileSync(gitPath, `@echo off\r\nif "%1"=="worktree" if "%2"=="list" if "%3"=="--porcelain" (\r\necho ${porcelain.replace(/\n/g, "\r\necho ")}\r\nexit /b 0\r\n)\r\nexit /b 1\r\n`) - } else { - writeFileSync(gitPath, `#!/bin/sh\nif [ "$1" = "worktree" ] && [ "$2" = "list" ] && [ "$3" = "--porcelain" ]; then\nprintf '%s\n' '${porcelain.replace(/'/g, "'\\''")}'\nexit 0\nfi\nexit 1\n`, { mode: 0o755 }) - } - - process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}` + writeFileSync(path.join(repoRoot, "README.md"), "test\n") + execFileSync("git", ["-C", repoRoot, "add", "README.md"]) + execFileSync("git", ["-C", repoRoot, "-c", "user.name=CodeNomad Test", "-c", "user.email=test@codenomad.local", "commit", "-m", "test"]) const worktrees = await listWorktrees({ repoRoot, workspaceFolder }) @@ -41,7 +27,6 @@ describe("listWorktrees", () => { assert.equal(worktrees[0]?.branch, "main") assert.notEqual(worktrees[0]?.directory, repoRoot) } finally { - process.env.PATH = originalPath rmSync(temp, { recursive: true, force: true }) } }) diff --git a/packages/tauri-app/Cargo.lock b/packages/tauri-app/Cargo.lock index ec3f8a49..26bb265c 100644 --- a/packages/tauri-app/Cargo.lock +++ b/packages/tauri-app/Cargo.lock @@ -497,7 +497,7 @@ dependencies = [ [[package]] name = "codenomad-tauri" -version = "0.18.0" +version = "0.19.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -1217,6 +1217,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -3373,7 +3374,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "http-body-util", diff --git a/packages/tauri-app/package.json b/packages/tauri-app/package.json index 64645e0a..4f1f6cc5 100644 --- a/packages/tauri-app/package.json +++ b/packages/tauri-app/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/tauri-app", - "version": "0.18.0", + "version": "0.19.0", "private": true, "license": "MIT", "scripts": { diff --git a/packages/tauri-app/src-tauri/Cargo.toml b/packages/tauri-app/src-tauri/Cargo.toml index eada6713..94d56fcd 100644 --- a/packages/tauri-app/src-tauri/Cargo.toml +++ b/packages/tauri-app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codenomad-tauri" -version = "0.18.0" +version = "0.19.0" edition = "2021" license = "MIT" @@ -14,7 +14,7 @@ serde_json = "1" serde_yaml = "0.9" base64 = "0.22" rustls = { version = "0.23", features = ["ring"] } -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } regex = "1" parking_lot = "0.12" anyhow = "1" diff --git a/packages/tauri-app/src-tauri/capabilities/remote-window-notifications.json b/packages/tauri-app/src-tauri/capabilities/remote-window-notifications.json new file mode 100644 index 00000000..210c8d88 --- /dev/null +++ b/packages/tauri-app/src-tauri/capabilities/remote-window-notifications.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://schema.tauri.app/capabilities.json", + "identifier": "remote-window-notifications", + "description": "Grant remote CodeNomad windows access only to native OS notifications.", + "local": false, + "remote": { + "urls": ["http://*:*", "https://*:*"] + }, + "windows": ["remote-*"], + "permissions": [ + "notification:allow-is-permission-granted", + "notification:allow-request-permission", + "notification:allow-notify" + ] +} diff --git a/packages/tauri-app/src-tauri/gen/schemas/capabilities.json b/packages/tauri-app/src-tauri/gen/schemas/capabilities.json index 3ab025a9..37b0f153 100644 --- a/packages/tauri-app/src-tauri/gen/schemas/capabilities.json +++ b/packages/tauri-app/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}} \ No newline at end of file +{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]},"remote-window-notifications":{"identifier":"remote-window-notifications","description":"Grant remote CodeNomad windows access only to native OS notifications.","remote":{"urls":["http://*:*","https://*:*"]},"local":false,"windows":["remote-*"],"permissions":["notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify"]}} \ No newline at end of file diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index 58bd937b..9fc96470 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -479,6 +479,21 @@ fn extract_cookie_value(set_cookie: &str, name: &str) -> Option { Some(value.to_string()) } +fn is_loopback_http_url(base_url: &str) -> bool { + let Ok(parsed) = Url::parse(base_url) else { + return false; + }; + if parsed.scheme() != "http" || !parsed.username().is_empty() || parsed.password().is_some() { + return false; + } + match parsed.host() { + Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"), + Some(url::Host::Ipv4(host)) => host.is_loopback(), + Some(url::Host::Ipv6(host)) => host.is_loopback(), + None => false, + } +} + fn exchange_bootstrap_token( base_url: &str, token: &str, @@ -702,6 +717,13 @@ pub struct CliStatus { pub error: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LocalCliAccess { + pub(crate) base_url: String, + pub(crate) cookie_name: String, + pub(crate) session_cookie: String, +} + impl Default for CliStatus { fn default() -> Self { Self { @@ -721,6 +743,7 @@ pub struct CliProcessManager { #[cfg(windows)] job: Arc>>, bootstrap_token: Arc>>, + local_access: Arc>>, lifecycle: Arc>, generation: Arc, } @@ -733,6 +756,7 @@ impl CliProcessManager { #[cfg(windows)] job: Arc::new(Mutex::new(None)), bootstrap_token: Arc::new(Mutex::new(None)), + local_access: Arc::new(Mutex::new(None)), lifecycle: Arc::new(Mutex::new(())), generation: Arc::new(AtomicU64::new(0)), } @@ -741,9 +765,10 @@ impl CliProcessManager { pub fn start(&self, app: AppHandle, dev: bool) -> anyhow::Result<()> { let _lifecycle = self.lifecycle.lock(); let generation = self.advance_generation(); + *self.bootstrap_token.lock() = None; + *self.local_access.lock() = None; log_line(&format!("start requested (dev={dev})")); self.stop_tracked_child()?; - *self.bootstrap_token.lock() = None; { let mut status = self.status.lock(); status.state = CliState::Starting; @@ -768,6 +793,8 @@ impl CliProcessManager { pub fn stop(&self) -> anyhow::Result<()> { let _lifecycle = self.lifecycle.lock(); self.advance_generation(); + *self.bootstrap_token.lock() = None; + *self.local_access.lock() = None; self.stop_tracked_child()?; self.reset_stopped_status(); Ok(()) @@ -839,10 +866,12 @@ impl CliProcessManager { status.port = None; status.url = None; status.error = None; + *self.local_access.lock() = None; } fn publish_error(&self, app: &AppHandle, generation: u64, message: String) { self.with_current_generation(generation, || { + *self.local_access.lock() = None; let mut status = self.status.lock(); status.state = CliState::Error; status.error = Some(message.clone()); @@ -857,6 +886,14 @@ impl CliProcessManager { self.status.lock().clone() } + pub(crate) fn local_cli_access(&self) -> Option { + let _lifecycle = self.lifecycle.lock(); + if self.status.lock().state != CliState::Ready { + return None; + } + self.local_access.lock().clone() + } + fn spawn_cli( manager: CliProcessManager, app: AppHandle, @@ -1129,6 +1166,7 @@ impl CliProcessManager { } Poll::Exited(code) => { manager.with_current_generation(generation, || { + *manager.local_access.lock() = None; let mut status = manager.status.lock(); if status.state != CliState::Ready { status.state = CliState::Error; @@ -1261,10 +1299,8 @@ impl CliProcessManager { log_line(&format!("cli ready on {base_url}")); if let Some(token) = token { - // Token exchange is only implemented for loopback HTTP. If localUrl is HTTPS, - // skip the exchange and let the user authenticate normally. - let scheme = Url::parse(&base_url).ok().map(|u| u.scheme().to_string()); - if scheme.as_deref() != Some("http") { + // Native credentials are only established against the managed loopback listener. + if !is_loopback_http_url(&base_url) { navigate_main(manager, generation, app, &base_url); } else { match exchange_bootstrap_token(&base_url, &token, &auth_cookie_name) { @@ -1279,6 +1315,13 @@ impl CliProcessManager { log_line(&format!("failed to set session cookie: {err}")); navigate_main(manager, generation, app, &format!("{base_url}/login")); } else { + manager.with_current_generation(generation, || { + *manager.local_access.lock() = Some(LocalCliAccess { + base_url: base_url.clone(), + cookie_name: auth_cookie_name.to_string(), + session_cookie: session_id, + }); + }); navigate_main(manager, generation, app, &base_url); } } @@ -1721,6 +1764,34 @@ mod tests { assert_eq!(manager.status().state, CliState::Ready); } + #[test] + fn local_cli_access_requires_readiness_and_clears_on_stop() { + let manager = CliProcessManager::new(); + let access = LocalCliAccess { + base_url: "http://127.0.0.1:3000".into(), + cookie_name: "codenomad_session_test".into(), + session_cookie: "secret".into(), + }; + *manager.local_access.lock() = Some(access.clone()); + + assert_eq!(manager.local_cli_access(), None); + manager.status.lock().state = CliState::Ready; + assert_eq!(manager.local_cli_access(), Some(access)); + + manager.stop().unwrap(); + assert_eq!(manager.local_cli_access(), None); + } + + #[test] + fn native_auth_is_limited_to_loopback_http() { + assert!(is_loopback_http_url("http://127.0.0.1:3000")); + assert!(is_loopback_http_url("http://[::1]:3000")); + assert!(is_loopback_http_url("http://localhost:3000")); + assert!(!is_loopback_http_url("https://localhost:3000")); + assert!(!is_loopback_http_url("http://remote.example:3000")); + assert!(!is_loopback_http_url("http://user@localhost:3000")); + } + #[test] fn stop_waits_for_an_authorized_spawn_section() { let manager = CliProcessManager::new(); diff --git a/packages/tauri-app/src-tauri/src/main.rs b/packages/tauri-app/src-tauri/src/main.rs index 1ccd3842..0b0f0e6a 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -9,6 +9,7 @@ mod linux_tls; mod managed_node; mod shutdown; mod windows_update; +mod workspace_open; use cli_manager::{CliProcessManager, CliStatus}; use keepawake::KeepAwake; @@ -19,7 +20,9 @@ use std::collections::{HashMap, HashSet}; use std::future::Future; use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; -use tauri::menu::{AboutMetadata, MenuBuilder, MenuItem, PredefinedMenuItem, SubmenuBuilder}; +use tauri::menu::{ + AboutMetadata, MenuBuilder, MenuItem, PredefinedMenuItem, Submenu, SubmenuBuilder, +}; use tauri::plugin::{Builder as PluginBuilder, TauriPlugin}; use tauri::webview::{PageLoadEvent, Webview}; use tauri::{ @@ -43,7 +46,8 @@ use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID; const ZOOM_STEP: f64 = 0.1; const RELEASES_URL: &str = "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"; const LOCAL_WINDOW_CONTEXT_SCRIPT: &str = "window.__CODENOMAD_WINDOW_CONTEXT__ = 'local';"; -const REMOTE_WINDOW_CONTEXT_SCRIPT: &str = "window.__CODENOMAD_WINDOW_CONTEXT__ = 'remote';"; +const REMOTE_WINDOW_CONTEXT_SCRIPT: &str = + "window.__CODENOMAD_RUNTIME_HOST__ = 'tauri'; window.__CODENOMAD_WINDOW_CONTEXT__ = 'remote';"; #[cfg(windows)] const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client"; @@ -56,6 +60,70 @@ pub struct AppState { pub remote_skip_tls_verify: Mutex>, pub remote_tls_handlers: Mutex>, pub remote_titles: Mutex>, + pub workspace_menu_items: Mutex>, + pub workspace_menu_requested_enabled: Mutex, +} + +pub struct WorkspaceMenuItems { + folder: MenuItem, + terminal: MenuItem, + editor: Submenu, +} + +fn update_workspace_menu_state(app: &AppHandle) { + let state = app.state::(); + let requested = state + .workspace_menu_requested_enabled + .lock() + .map(|value| *value) + .unwrap_or(false); + let focused = app + .get_webview_window("main") + .and_then(|window| window.is_focused().ok()) + .unwrap_or(false); + if let Ok(items) = state.workspace_menu_items.lock() { + if let Some(items) = items.as_ref() { + let enabled = requested && focused; + let _ = items.folder.set_enabled(enabled); + let _ = items.terminal.set_enabled(enabled); + let _ = items.editor.set_enabled(enabled); + } + }; +} + +#[tauri::command] +fn set_workspace_menu_enabled( + window: tauri::WebviewWindow, + app: AppHandle, + state: tauri::State<'_, AppState>, + enabled: bool, +) -> Result<(), String> { + if window.label() != "main" { + return Err("Workspace menu updates are limited to the local main window".into()); + } + if !enabled { + *state + .workspace_menu_requested_enabled + .lock() + .map_err(|error| error.to_string())? = false; + update_workspace_menu_state(&app); + return Ok(()); + } + let config = state + .manager + .local_cli_access() + .ok_or("Local CodeNomad server is unavailable")?; + let expected = Url::parse(&config.base_url).map_err(|error| error.to_string())?; + let current = window.url().map_err(|error| error.to_string())?; + if current.origin() != expected.origin() { + return Err("Workspace menu updates require the local CodeNomad origin".into()); + } + *state + .workspace_menu_requested_enabled + .lock() + .map_err(|error| error.to_string())? = enabled; + update_workspace_menu_state(&app); + Ok(()) } #[derive(Debug, Deserialize)] @@ -197,19 +265,20 @@ fn should_allow_window_origin( window_label: &str, url: &Url, ) -> bool { - if should_allow_internal(url) { - return true; - } - let state = app_handle.state::(); let Ok(allowed) = state.remote_origins.lock() else { return false; }; - if let Some(origin) = allowed.get(window_label) { - return origin == &url.origin().ascii_serialization(); - } + should_allow_registered_origin(allowed.get(window_label).map(String::as_str), url) +} - false +fn should_allow_registered_origin(registered_origin: Option<&str>, url: &Url) -> bool { + if let Some(origin) = registered_origin { + if matches!(url.scheme(), "http" | "https") { + return origin == url.origin().ascii_serialization(); + } + } + should_allow_internal(url) } fn intercept_navigation(webview: &Webview, url: &Url) -> bool { @@ -345,6 +414,9 @@ async fn open_remote_window_impl( let app_handle = app.clone(); let label_for_cleanup = label.clone(); window.on_window_event(move |event| { + if matches!(event, WindowEvent::Focused(_)) { + update_workspace_menu_state(&app_handle); + } if let WindowEvent::Destroyed = event { if let Ok(mut origins) = app_handle.state::().remote_origins.lock() { origins.remove(&label_for_cleanup); @@ -583,8 +655,21 @@ fn main() { remote_skip_tls_verify: Mutex::new(HashMap::new()), remote_tls_handlers: Mutex::new(HashSet::new()), remote_titles: Mutex::new(HashMap::new()), + workspace_menu_items: Mutex::new(None), + workspace_menu_requested_enabled: Mutex::new(false), }) .on_page_load(|webview, payload| { + if webview.label() == "main" && payload.event() == PageLoadEvent::Started { + if let Ok(mut enabled) = webview + .app_handle() + .state::() + .workspace_menu_requested_enabled + .lock() + { + *enabled = false; + } + update_workspace_menu_state(&webview.app_handle()); + } if matches!( payload.event(), PageLoadEvent::Started | PageLoadEvent::Finished @@ -605,6 +690,12 @@ fn main() { shutdown::install_windows_session_end_handler(&window) .map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))?; let _ = window.eval(LOCAL_WINDOW_CONTEXT_SCRIPT); + let app_handle = app.handle().clone(); + window.on_window_event(move |event| { + if matches!(event, WindowEvent::Focused(_)) { + update_workspace_menu_state(&app_handle); + } + }); } if let Some(shortcut) = fullscreen_shortcut() { let shortcut_manager = app.handle().global_shortcut(); @@ -649,14 +740,27 @@ fn main() { client_state::client_state_clear, client_state::client_state_renderer_flushed, client_state::client_state_navigation_flushed, - windows_update::install_stable_update + windows_update::install_stable_update, + workspace_open::open_workspace_target, + set_workspace_menu_enabled ]) .on_menu_event(|app_handle, event| { match event.id().0.as_str() { // File menu - "new_instance" => { + action @ ("new-instance" + | "open-workspace-folder" + | "open-workspace-terminal" + | "open-workspace-editor-vscode" + | "open-workspace-editor-cursor" + | "open-workspace-editor-zed" + | "open-workspace-editor-vscodium") => { if let Some(window) = app_handle.get_webview_window("main") { - let _ = window.emit("menu:newInstance", ()); + if action.starts_with("open-workspace-") + && !window.is_focused().unwrap_or(false) + { + return; + } + let _ = window.emit("menu:action", action); } } "quit" => { @@ -859,22 +963,60 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> { // File menu - create New Instance with accelerator let new_instance_item = MenuItem::with_id( app, - "new_instance", + "new-instance", "New Instance", true, Some("CmdOrCtrl+N"), )?; + let open_folder_item = MenuItem::with_id( + app, + "open-workspace-folder", + "Open Project Folder", + true, + None::<&str>, + )?; + let open_terminal_item = MenuItem::with_id( + app, + "open-workspace-terminal", + "Open Terminal Here", + true, + None::<&str>, + )?; + let open_editor_menu = SubmenuBuilder::new(app, "Open Project In") + .text("open-workspace-editor-vscode", "VS Code") + .text("open-workspace-editor-cursor", "Cursor") + .text("open-workspace-editor-zed", "Zed") + .text("open-workspace-editor-vscodium", "VSCodium") + .build()?; + open_folder_item.set_enabled(false)?; + open_terminal_item.set_enabled(false)?; + open_editor_menu.set_enabled(false)?; + if let Ok(mut items) = app.state::().workspace_menu_items.lock() { + *items = Some(WorkspaceMenuItems { + folder: open_folder_item.clone(), + terminal: open_terminal_item.clone(), + editor: open_editor_menu.clone(), + }); + } let file_menu = if is_mac { SubmenuBuilder::new(app, "File") .item(&new_instance_item) .separator() + .item(&open_folder_item) + .item(&open_terminal_item) + .item(&open_editor_menu) + .separator() .close_window() .build()? } else { SubmenuBuilder::new(app, "File") .item(&new_instance_item) .separator() + .item(&open_folder_item) + .item(&open_terminal_item) + .item(&open_editor_menu) + .separator() .text("quit", "Quit") .build()? }; @@ -1035,8 +1177,13 @@ fn build_about_metadata(version: &str, include_update_link: bool) -> AboutMetada #[cfg(test)] mod menu_tests { - use super::{build_about_metadata, run_update_with_fallback, RELEASES_URL}; + use super::{ + build_about_metadata, run_update_with_fallback, should_allow_registered_origin, + RELEASES_URL, REMOTE_WINDOW_CONTEXT_SCRIPT, + }; + use serde_json::json; use std::sync::atomic::{AtomicBool, Ordering}; + use url::Url; #[test] fn failed_update_uses_release_fallback() { @@ -1067,4 +1214,53 @@ mod menu_tests { assert_eq!(metadata.website, None); assert_eq!(metadata.website_label, None); } + + #[test] + fn remote_windows_identify_as_remote_tauri_windows() { + assert!(REMOTE_WINDOW_CONTEXT_SCRIPT.contains("__CODENOMAD_RUNTIME_HOST__ = 'tauri'")); + assert!(REMOTE_WINDOW_CONTEXT_SCRIPT.contains("__CODENOMAD_WINDOW_CONTEXT__ = 'remote'")); + + let capability: serde_json::Value = serde_json::from_str(include_str!( + "../capabilities/remote-window-notifications.json" + )) + .unwrap(); + assert_eq!(capability["local"], false); + assert_eq!( + capability["remote"]["urls"], + json!(["http://*:*", "https://*:*"]) + ); + assert_eq!(capability["windows"], json!(["remote-*"])); + assert_eq!( + capability["permissions"], + json!([ + "notification:allow-is-permission-granted", + "notification:allow-request-permission", + "notification:allow-notify" + ]) + ); + + let config: serde_json::Value = + serde_json::from_str(include_str!("../tauri.conf.json")).unwrap(); + assert!(config["app"]["security"]["capabilities"] + .as_array() + .unwrap() + .contains(&json!("remote-window-notifications"))); + } + + #[test] + fn remote_windows_stay_on_their_registered_http_origin() { + let origin = "https://remote.example:9898"; + assert!(should_allow_registered_origin( + Some(origin), + &Url::parse("https://remote.example:9898/settings").unwrap() + )); + assert!(!should_allow_registered_origin( + Some(origin), + &Url::parse("http://localhost:9898/").unwrap() + )); + assert!(should_allow_registered_origin( + Some(origin), + &Url::parse("about:blank").unwrap() + )); + } } diff --git a/packages/tauri-app/src-tauri/src/workspace_open.rs b/packages/tauri-app/src-tauri/src/workspace_open.rs new file mode 100644 index 00000000..fbb41ce2 --- /dev/null +++ b/packages/tauri-app/src-tauri/src/workspace_open.rs @@ -0,0 +1,634 @@ +use crate::{cli_manager::CliProcessManager, AppState}; +#[cfg(windows)] +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use serde::de::DeserializeOwned; +use serde::Deserialize; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::Duration; +use tauri::State; + +#[derive(Clone)] +struct LaunchCandidate { + command: PathBuf, + args: Vec, + pass_path: bool, + wait_for_exit: bool, + verify_start: bool, +} + +#[derive(Deserialize)] +struct WorkspaceDescriptor { + path: String, +} + +#[derive(Deserialize)] +struct WorktreeDescriptor { + slug: String, + directory: String, +} + +#[derive(Deserialize)] +struct WorktreeListResponse { + worktrees: Vec, +} + +fn get_local_json( + manager: &CliProcessManager, + path: &str, +) -> Result { + let config = manager + .local_cli_access() + .ok_or("Local CodeNomad server is unavailable")?; + let url = format!("{}{}", config.base_url.trim_end_matches('/'), path); + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .map_err(|error| error.to_string())?; + let mut request = client.get(url); + request = request.header( + reqwest::header::COOKIE, + format!("{}={}", config.cookie_name, config.session_cookie), + ); + request + .send() + .and_then(reqwest::blocking::Response::error_for_status) + .map_err(|error| error.to_string())? + .json::() + .map_err(|error| error.to_string()) +} + +fn resolve_active_workspace_folder( + manager: &CliProcessManager, + instance_id: &str, + worktree_slug: &str, +) -> Result { + if instance_id.is_empty() + || !instance_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') + { + return Err("Invalid workspace id".into()); + } + let workspace: WorkspaceDescriptor = + get_local_json(manager, &format!("/api/workspaces/{instance_id}"))?; + if worktree_slug == "root" { + return Ok(workspace.path); + } + let payload: WorktreeListResponse = + get_local_json(manager, &format!("/api/workspaces/{instance_id}/worktrees"))?; + payload + .worktrees + .into_iter() + .find(|worktree| worktree.slug == worktree_slug) + .map(|worktree| worktree.directory) + .ok_or_else(|| "Selected worktree is unavailable".into()) +} + +fn editor_candidates(editor: &str) -> Result, String> { + #[cfg(target_os = "macos")] + { + let name = match editor { + "vscode" => "Visual Studio Code", + "cursor" => "Cursor", + "zed" => "Zed", + "vscodium" => "VSCodium", + _ => return Err("Unsupported editor".into()), + }; + return Ok(vec![LaunchCandidate { + command: "/usr/bin/open".into(), + args: vec!["-a".into(), name.into()], + pass_path: true, + wait_for_exit: true, + verify_start: false, + }]); + } + + #[cfg(windows)] + { + let paths: Vec = match editor { + "vscode" => vec![ + std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .map(|path| path.join("Programs/Microsoft VS Code/Code.exe")), + std::env::var_os("ProgramFiles") + .map(PathBuf::from) + .map(|path| path.join("Microsoft VS Code/Code.exe")), + ], + "cursor" => vec![std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .map(|path| path.join("Programs/cursor/Cursor.exe"))], + "zed" => vec![std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .map(|path| path.join("Programs/Zed/Zed.exe"))], + "vscodium" => vec![ + std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .map(|path| path.join("Programs/VSCodium/VSCodium.exe")), + std::env::var_os("ProgramFiles") + .map(PathBuf::from) + .map(|path| path.join("VSCodium/VSCodium.exe")), + ], + _ => return Err("Unsupported editor".into()), + } + .into_iter() + .flatten() + .filter(|path| !path.is_absolute() || path.is_file()) + .collect(); + return Ok(paths + .into_iter() + .map(|command| LaunchCandidate { + command, + args: Vec::new(), + pass_path: true, + wait_for_exit: false, + verify_start: false, + }) + .collect()); + } + + #[cfg(all(not(windows), not(target_os = "macos")))] + { + let command = match editor { + "vscode" => "code", + "cursor" => "cursor", + "zed" => "zed", + "vscodium" => "codium", + _ => return Err("Unsupported editor".into()), + }; + Ok(vec![LaunchCandidate { + command: command.into(), + args: Vec::new(), + pass_path: true, + wait_for_exit: false, + verify_start: true, + }]) + } +} + +fn terminal_candidates(folder: &Path) -> Vec { + #[cfg(target_os = "macos")] + return vec![LaunchCandidate { + command: "/usr/bin/open".into(), + args: vec!["-a".into(), "Terminal".into()], + pass_path: true, + wait_for_exit: true, + verify_start: false, + }]; + + #[cfg(windows)] + { + let system_root = std::env::var_os("SystemRoot") + .map(PathBuf::from) + .unwrap_or_else(|| r"C:\Windows".into()); + let command = std::env::var_os("ComSpec") + .map(PathBuf::from) + .unwrap_or_else(|| system_root.join("System32/cmd.exe")); + let powershell = system_root.join("System32/WindowsPowerShell/v1.0/powershell.exe"); + let escaped = folder.to_string_lossy().replace('\'', "''"); + let script = format!("Set-Location -LiteralPath '{escaped}'"); + return vec![LaunchCandidate { + command, + args: vec![ + "/D".into(), + "/C".into(), + "start".into(), + "".into(), + powershell.to_string_lossy().into_owned(), + "-NoExit".into(), + "-EncodedCommand".into(), + encode_powershell_script(&script), + ], + pass_path: false, + wait_for_exit: true, + verify_start: false, + }]; + } + + #[cfg(all(not(windows), not(target_os = "macos")))] + { + let configured = std::env::var("TERMINAL") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .into_iter(); + configured + .chain( + [ + "xdg-terminal-exec", + "x-terminal-emulator", + "gnome-terminal", + "konsole", + "kitty", + "alacritty", + "wezterm", + ] + .map(str::to_string), + ) + .map(|command| LaunchCandidate { + command: command.into(), + args: Vec::new(), + pass_path: false, + wait_for_exit: false, + verify_start: true, + }) + .collect() + } +} + +#[cfg(windows)] +fn encode_powershell_script(script: &str) -> String { + let bytes: Vec = script.encode_utf16().flat_map(u16::to_le_bytes).collect(); + BASE64.encode(bytes) +} + +fn spawn_candidate( + candidate: &LaunchCandidate, + selected_path: &Path, + cwd: &Path, +) -> std::io::Result<()> { + let mut command = Command::new(&candidate.command); + command.args(&candidate.args); + if candidate.pass_path { + command.arg(selected_path); + } + let mut child = command + .current_dir(cwd) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + if candidate.wait_for_exit { + let status = child.wait()?; + if !status.success() { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!("{} exited with {status}", candidate.command.display()), + )); + } + } else if candidate.verify_start { + for _ in 0..10 { + if let Some(status) = child.try_wait()? { + if status.success() { + return Ok(()); + } + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!("{} exited with {status}", candidate.command.display()), + )); + } + std::thread::sleep(std::time::Duration::from_millis(25)); + } + std::thread::spawn(move || { + let _ = child.wait(); + }); + } + Ok(()) +} + +fn launch_first( + candidates: Vec, + selected_path: &Path, + cwd: &Path, +) -> Result<(), String> { + let mut last_error = None; + for candidate in candidates { + match spawn_candidate(&candidate, selected_path, cwd) { + Ok(()) => return Ok(()), + Err(error) => last_error = Some(error), + } + } + Err(last_error + .map(|error| error.to_string()) + .unwrap_or_else(|| "No supported application was found".into())) +} + +fn resolve_workspace_path( + workspace_folder: &str, + path: &str, +) -> Result<(PathBuf, PathBuf), String> { + let root = PathBuf::from(workspace_folder) + .canonicalize() + .map_err(|error| error.to_string())?; + if !root.is_dir() { + return Err("Workspace folder does not exist".into()); + } + let selected = root + .join(path) + .canonicalize() + .map_err(|error| error.to_string())?; + if !selected.starts_with(&root) { + return Err("Selected path is outside the workspace".into()); + } + Ok((root, selected)) +} + +#[cfg(windows)] +fn platform_launch_path(path: &Path) -> PathBuf { + let value = path.to_string_lossy(); + if let Some(rest) = value.strip_prefix(r"\\?\UNC\") { + return format!(r"\\{rest}").into(); + } + value.strip_prefix(r"\\?\").unwrap_or(&value).into() +} + +#[derive(Debug, PartialEq, Eq)] +enum DefaultOpenMode { + Open, + Edit, + Choose, +} + +fn default_open_mode(path: &Path) -> Result { + #[cfg(not(windows))] + const BLOCKED: &[&str] = &[ + "appimage", + "application", + "chm", + "com", + "cpl", + "desktop", + "exe", + "jar", + "lnk", + "msi", + "msp", + "pif", + "scr", + "url", + ]; + #[cfg(windows)] + const SAFE_WINDOWS_OPEN: &[&str] = &[ + "7z", "avi", "bmp", "c", "cc", "cfg", "conf", "cpp", "cs", "css", "csv", "dart", "diff", + "docx", "env", "flac", "fs", "fsx", "gif", "go", "gz", "h", "hpp", "htm", "html", "ini", + "java", "jpeg", "jpg", "json", "jsonc", "jsx", "kt", "kts", "less", "lock", "log", "lua", + "md", "markdown", "mkv", "mov", "mp3", "mp4", "ogg", "patch", "pdf", "png", "pptx", "r", + "rar", "rmd", "rs", "scss", "sql", "svg", "svelte", "swift", "tar", "toml", "ts", "tsx", + "txt", "vue", "wav", "webm", "webp", "xlsx", "xml", "yaml", "yml", "zip", + ]; + #[cfg(windows)] + const WINDOWS_EDIT: &[&str] = &[ + "bat", "cmd", "js", "jse", "pl", "ps1", "psd1", "psm1", "py", "pyw", "rb", "reg", "vbe", + "vbs", "wsf", "wsh", + ]; + let metadata = path.metadata().map_err(|error| error.to_string())?; + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + if metadata.is_dir() { + if cfg!(target_os = "macos") && extension == "app" { + return Err("Application bundles cannot be opened externally".into()); + } + return Ok(DefaultOpenMode::Open); + } + #[cfg(windows)] + { + return Ok(if SAFE_WINDOWS_OPEN.contains(&extension.as_str()) { + DefaultOpenMode::Open + } else if WINDOWS_EDIT.contains(&extension.as_str()) { + DefaultOpenMode::Edit + } else { + DefaultOpenMode::Choose + }); + } + + #[cfg(unix)] + { + if BLOCKED.contains(&extension.as_str()) { + return Err("Executable files cannot be opened externally".into()); + } + if metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 { + return Err("Executable files cannot be opened externally".into()); + } + return Ok(DefaultOpenMode::Open); + } + + #[cfg(not(any(windows, unix)))] + Ok(DefaultOpenMode::Open) +} + +#[cfg(not(windows))] +fn platform_launch_path(path: &Path) -> PathBuf { + path.to_path_buf() +} + +fn open_workspace_target_impl( + manager: &CliProcessManager, + target: String, + instance_id: String, + worktree_slug: String, + path: Option, + editor: Option, +) -> Result<(), String> { + let workspace_folder = resolve_active_workspace_folder(manager, &instance_id, &worktree_slug)?; + let (root, selected) = + resolve_workspace_path(&workspace_folder, path.as_deref().unwrap_or("."))?; + let cwd = if selected.is_dir() { + selected.clone() + } else { + selected.parent().unwrap_or(root.as_path()).to_path_buf() + }; + let launch_path = platform_launch_path(&selected); + let launch_cwd = platform_launch_path(&cwd); + + match target.as_str() { + "default" => { + let mode = default_open_mode(&selected)?; + #[cfg(windows)] + if mode != DefaultOpenMode::Open { + let system_root = std::env::var_os("SystemRoot") + .map(PathBuf::from) + .unwrap_or_else(|| r"C:\Windows".into()); + let powershell = system_root.join("System32/WindowsPowerShell/v1.0/powershell.exe"); + let rundll32 = system_root.join("System32/rundll32.exe"); + let escaped = launch_path.to_string_lossy().replace('\'', "''"); + let script = format!( + "$ErrorActionPreference='Stop'; Start-Process -FilePath '{escaped}' -Verb Edit" + ); + let mut candidates = Vec::new(); + if mode == DefaultOpenMode::Edit { + candidates.push(LaunchCandidate { + command: powershell, + args: vec![ + "-NoProfile".into(), + "-NonInteractive".into(), + "-EncodedCommand".into(), + encode_powershell_script(&script), + ], + pass_path: false, + wait_for_exit: true, + verify_start: false, + }); + } + candidates.push(LaunchCandidate { + command: rundll32, + args: vec!["shell32.dll,OpenAs_RunDLL".into()], + pass_path: true, + wait_for_exit: false, + verify_start: true, + }); + return launch_first(candidates, &launch_path, &launch_cwd); + } + #[cfg(target_os = "macos")] + let candidates = vec![LaunchCandidate { + command: "/usr/bin/open".into(), + args: Vec::new(), + pass_path: true, + wait_for_exit: true, + verify_start: false, + }]; + #[cfg(windows)] + let candidates = vec![LaunchCandidate { + command: "explorer.exe".into(), + args: Vec::new(), + pass_path: true, + wait_for_exit: false, + verify_start: false, + }]; + #[cfg(all(not(windows), not(target_os = "macos")))] + let candidates = vec![ + LaunchCandidate { + command: "xdg-open".into(), + args: Vec::new(), + pass_path: true, + wait_for_exit: false, + verify_start: true, + }, + LaunchCandidate { + command: "gio".into(), + args: vec!["open".into()], + pass_path: true, + wait_for_exit: false, + verify_start: true, + }, + ]; + launch_first(candidates, &launch_path, &launch_cwd) + } + "reveal" => { + #[cfg(target_os = "macos")] + let candidates = vec![LaunchCandidate { + command: "/usr/bin/open".into(), + args: vec!["-R".into()], + pass_path: true, + wait_for_exit: true, + verify_start: false, + }]; + #[cfg(windows)] + let candidates = vec![LaunchCandidate { + command: "explorer.exe".into(), + args: vec![format!("/select,{}", launch_path.display())], + pass_path: false, + wait_for_exit: false, + verify_start: false, + }]; + #[cfg(all(not(windows), not(target_os = "macos")))] + let candidates = vec![ + LaunchCandidate { + command: "xdg-open".into(), + args: Vec::new(), + pass_path: true, + wait_for_exit: false, + verify_start: true, + }, + LaunchCandidate { + command: "gio".into(), + args: vec!["open".into()], + pass_path: true, + wait_for_exit: false, + verify_start: true, + }, + ]; + let reveal_path = if cfg!(all(not(windows), not(target_os = "macos"))) { + launch_cwd.as_path() + } else { + launch_path.as_path() + }; + launch_first(candidates, reveal_path, &launch_cwd) + } + "terminal" => { + if !selected.is_dir() { + return Err("Terminal target is not a folder".into()); + } + launch_first(terminal_candidates(&launch_path), &launch_path, &launch_cwd) + } + "editor" => launch_first( + editor_candidates(editor.as_deref().ok_or("Editor is required")?)?, + &launch_path, + &launch_cwd, + ), + _ => Err("Unsupported workspace open target".into()), + } +} + +#[tauri::command] +pub async fn open_workspace_target( + window: tauri::WebviewWindow, + state: State<'_, AppState>, + target: String, + instance_id: String, + worktree_slug: String, + path: Option, + editor: Option, +) -> Result<(), String> { + if window.label() != "main" { + return Err("Workspace open requests are limited to the local main window".into()); + } + let config = state + .manager + .local_cli_access() + .ok_or("Local CodeNomad server is unavailable")?; + let expected = url::Url::parse(&config.base_url).map_err(|error| error.to_string())?; + let current = window.url().map_err(|error| error.to_string())?; + if current.origin() != expected.origin() { + return Err("Workspace open requests require the local CodeNomad origin".into()); + } + + let manager = state.manager.clone(); + tauri::async_runtime::spawn_blocking(move || { + open_workspace_target_impl(&manager, target, instance_id, worktree_slug, path, editor) + }) + .await + .map_err(|error| error.to_string())? +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_paths_outside_the_workspace() { + let workspace = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let result = resolve_workspace_path( + workspace.path().to_str().unwrap(), + outside.path().to_str().unwrap(), + ); + assert!(result.is_err()); + } + + #[test] + fn edits_windows_scripts_and_rejects_executable_default_open_targets() { + let workspace = tempfile::tempdir().unwrap(); + let script = workspace.path().join("run.cmd"); + std::fs::write(&script, "echo unsafe").unwrap(); + #[cfg(windows)] + assert_eq!(default_open_mode(&script).unwrap(), DefaultOpenMode::Edit); + let document = workspace.path().join("notes.txt"); + std::fs::write(&document, "safe").unwrap(); + #[cfg(windows)] + assert_eq!(default_open_mode(&document).unwrap(), DefaultOpenMode::Open); + let executable = workspace.path().join("run.exe"); + std::fs::write(&executable, "unsafe").unwrap(); + #[cfg(windows)] + assert_eq!( + default_open_mode(&executable).unwrap(), + DefaultOpenMode::Choose + ); + #[cfg(unix)] + assert!(default_open_mode(&executable).is_err()); + } +} diff --git a/packages/tauri-app/src-tauri/tauri.conf.json b/packages/tauri-app/src-tauri/tauri.conf.json index e01de7c3..bed5e90d 100644 --- a/packages/tauri-app/src-tauri/tauri.conf.json +++ b/packages/tauri-app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CodeNomad", - "version": "0.18.0", + "version": "0.19.0", "identifier": "ai.neuralnomads.codenomad.client", "build": { "beforeDevCommand": "npm run dev:bootstrap", @@ -37,7 +37,8 @@ ] }, "capabilities": [ - "main-window-native-dialogs" + "main-window-native-dialogs", + "remote-window-notifications" ] } }, diff --git a/packages/ui/package.json b/packages/ui/package.json index 03a5a37e..4d2bf243 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/ui", - "version": "0.18.0", + "version": "0.19.0", "private": true, "license": "MIT", "type": "module", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 1dd3cd3a..992e354f 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -28,6 +28,8 @@ import { initReleaseNotifications } from "./stores/releases" import { isTauriHost, isWebHost, runtimeEnv } from "./lib/runtime-env" import { useI18n } from "./lib/i18n" import { setWakeLockDesired } from "./lib/native/wake-lock" +import { resolveResolvable } from "./lib/commands" +import { setWorkspaceMenuEnabled } from "./lib/workspace-open" import { isSelectingFolder, setIsSelectingFolder, @@ -608,26 +610,42 @@ const App: Component = () => { getActiveSessionIdForInstance: activeSessionIdForInstance, }) - // Listen for Tauri menu events + // Native menus execute the same commands as the command palette. onMount(() => { + const executeMenuAction = (action: unknown) => { + if (typeof action !== "string") return + const command = paletteCommands().find((candidate) => candidate.id === action) + if (command && !(command.disabled && resolveResolvable(command.disabled))) executeCommand(command) + } + if (isTauriHost()) { const tauriBridge = (window as { __TAURI__?: { event?: { listen: (event: string, handler: (event: { payload: unknown }) => void) => Promise<() => void> } } }).__TAURI__ if (tauriBridge?.event) { let unlistenMenu: (() => void) | null = null - tauriBridge.event.listen("menu:newInstance", () => { - handleNewInstanceRequest() + tauriBridge.event.listen("menu:action", (event) => { + executeMenuAction(event.payload) }).then((unlisten) => { unlistenMenu = unlisten }).catch((error) => { - log.error("Failed to listen for menu:newInstance event", error) + log.error("Failed to listen for native menu actions", error) }) onCleanup(() => { unlistenMenu?.() }) } + return } + + const unsubscribe = window.electronAPI?.onMenuAction?.(executeMenuAction) + onCleanup(() => unsubscribe?.()) + }) + + createEffect(() => { + void setWorkspaceMenuEnabled(Boolean(activeInstance())).catch((error) => { + log.warn("Failed to update native workspace menu state", error) + }) }) return ( diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx index bdc4f3ee..ede61777 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx @@ -1,17 +1,41 @@ import { For, Show, Suspense, createEffect, createMemo, createSignal, lazy, type Accessor, type Component, type JSX } from "solid-js" -import { Copy, RefreshCw, Save, Search, WrapText } from "lucide-solid" +import { Copy, ExternalLink, FolderOpen, RefreshCw, Save, Search, TerminalSquare, WrapText } from "lucide-solid" import SplitFilePanel from "../components/SplitFilePanel" import { Markdown } from "../../../../markdown" import { copyToClipboard } from "../../../../../lib/clipboard" import { showToastNotification } from "../../../../../lib/notifications" import { useTheme } from "../../../../../lib/theme" +import ActionOverflowMenu, { type ActionOverflowMenuItem } from "../../../../action-overflow-menu" +import { canOpenWorkspacePaths, openWorkspacePath } from "../../../../../lib/workspace-open" const LazyMonacoFileViewer = lazy(() => import("../../../../file-viewer/monaco-file-viewer").then((module) => ({ default: module.MonacoFileViewer })), ) +const isWindowsDesktop = typeof navigator !== "undefined" && /windows/i.test(navigator.userAgent) +const isMacDesktop = typeof navigator !== "undefined" && /macintosh|mac os x/i.test(navigator.userAgent) +const safeWindowsOpenExtensions = new Set([ + "7z", "avi", "bmp", "c", "cc", "cfg", "conf", "cpp", "cs", "css", "csv", "dart", "diff", "docx", + "env", "flac", "fs", "fsx", "gif", "go", "gz", "h", "hpp", "htm", "html", "ini", "java", "jpeg", "jpg", + "json", "jsonc", "jsx", "kt", "kts", "less", "lock", "log", "lua", "md", "markdown", "mkv", "mov", "mp3", + "mp4", "ogg", "patch", "pdf", "png", "pptx", "r", "rar", "rmd", "rs", "scss", "sql", "svg", "svelte", + "swift", "tar", "toml", "ts", "tsx", "txt", "vue", "wav", "webm", "webp", "xlsx", "xml", "yaml", "yml", "zip", +]) +const windowsEditExtensions = new Set([ + "bat", "cmd", "js", "jse", "pl", "ps1", "psd1", "psm1", "py", "pyw", "rb", "reg", "vbe", "vbs", "wsf", "wsh", +]) + +function windowsDefaultAction(path: string): "open" | "edit" | "choose" { + const name = path.split(/[\\/]/).pop() ?? "" + const dot = name.lastIndexOf(".") + const extension = dot > 0 ? name.slice(dot + 1).toLowerCase() : "" + if (!isWindowsDesktop || (extension && safeWindowsOpenExtensions.has(extension))) return "open" + if (extension && windowsEditExtensions.has(extension)) return "edit" + return "choose" +} + function isMarkdownPath(path: string | null | undefined): boolean { if (!path) return false return /\.(md|markdown|mdown|mkdn)$/i.test(path) @@ -41,6 +65,8 @@ interface FilesTabProps { parentPath: Accessor scopeKey: Accessor + instanceId: string + worktreeSlug: Accessor onLoadEntries: (path: string) => void onRequestOpenFile: (path: string) => void @@ -119,6 +145,78 @@ const FilesTab: Component = (props) => { }) } + const handleNativeOpen = async ( + target: "default" | "reveal" | "terminal", + path: string, + ) => { + try { + await openWorkspacePath({ + target, + instanceId: props.instanceId, + worktreeSlug: props.worktreeSlug(), + path, + }) + } catch (error) { + showToastNotification({ + message: props.t("instanceShell.filesShell.toast.openError", { + message: error instanceof Error ? error.message : String(error), + }), + variant: "error", + }) + } + } + + const rowActions = (item: FileBrowserEntry): ActionOverflowMenuItem[] => { + const items: ActionOverflowMenuItem[] = [] + if (canOpenWorkspacePaths()) { + if (item.type === "directory") { + const isMacApp = isMacDesktop && item.path.toLowerCase().endsWith(".app") + items.push( + { + key: "open-folder", + label: props.t(isMacApp + ? "instanceShell.filesShell.actions.showInFolder" + : "instanceShell.filesShell.actions.openFolder"), + icon: , + onSelect: () => handleNativeOpen(isMacApp ? "reveal" : "default", item.path), + }, + { + key: "open-terminal", + label: props.t("instanceShell.filesShell.actions.openTerminal"), + icon: , + onSelect: () => handleNativeOpen("terminal", item.path), + }, + ) + } else { + if (isWindowsDesktop) { + items.push({ + key: "open-default", + label: props.t({ + open: "instanceShell.filesShell.actions.openDefault", + edit: "instanceShell.filesShell.actions.editDefault", + choose: "instanceShell.filesShell.actions.chooseApplication", + }[windowsDefaultAction(item.path)]), + icon: , + onSelect: () => handleNativeOpen("default", item.path), + }) + } + items.push({ + key: "show-in-folder", + label: props.t("instanceShell.filesShell.actions.showInFolder"), + icon: , + onSelect: () => handleNativeOpen("reveal", item.path), + }) + } + } + items.push({ + key: "copy-path", + label: props.t("instanceShell.filesShell.actions.copyPath"), + icon: , + onSelect: () => handleCopyPath(item.path), + }) + return items + } + createEffect(() => { if (!showingMarkdownPreview()) return requestAnimationFrame(() => markdownPreviewRef?.focus()) @@ -194,15 +292,28 @@ const FilesTab: Component = (props) => {
{item.type}
- + } > - - +
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}> + +
+ diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx index 96756e3c..45fb5507 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx @@ -300,6 +300,8 @@ export function createFilesTabRuntime(options: FilesTabRuntimeOptions): () => JS wordWrapMode={filesWordWrapMode} parentPath={browserParentPath} scopeKey={browserScopeKey} + instanceId={options.instanceId} + worktreeSlug={options.worktreeSlug} onLoadEntries={(path: string) => void loadBrowserEntries(path)} onRequestOpenFile={(path: string) => void handleOpenBrowserFileRequest(path)} onRefresh={() => void refreshFilesTab()} diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index b3441654..ab7a04a4 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -238,7 +238,7 @@ export default function MessagePart(props: MessagePartProps) { sessionId={props.sessionId} isDark={isDark()} size={isAssistantMessage() ? "tight" : "base"} - escapeRawHtml={props.messageType === "user"} + escapeRawHtml onRendered={props.onRendered} /> diff --git a/packages/ui/src/components/model-selector.tsx b/packages/ui/src/components/model-selector.tsx index fba3680d..0ae19371 100644 --- a/packages/ui/src/components/model-selector.tsx +++ b/packages/ui/src/components/model-selector.tsx @@ -5,8 +5,9 @@ import { ChevronDown, PlugZap, Star } from "lucide-solid" import type { Model } from "../types/session" import { useI18n } from "../lib/i18n" import { getLogger } from "../lib/logger" -import { uiState, toggleFavoriteModelPreference } from "../stores/preferences" +import { getProviderModelVisibilityPreference, uiState, toggleFavoriteModelPreference } from "../stores/preferences" import { ProviderManagerModal } from "./provider-auth/provider-manager-modal" +import { isModelVisible, resolvePickerValue } from "../lib/model-visibility" const log = getLogger("session") interface ModelSelectorProps { @@ -20,6 +21,7 @@ interface FlatModel extends Model { providerName: string key: string searchText: string + unavailable?: boolean } interface ModelGroup { @@ -67,7 +69,7 @@ export default function ModelSelector(props: ModelSelectorProps) { } }) - const allModels = createMemo(() => + const catalogModels = createMemo(() => instanceProviders().flatMap((p) => p.models.map((m) => ({ ...m, @@ -78,6 +80,13 @@ export default function ModelSelector(props: ModelSelectorProps) { ), ) + const allModels = createMemo(() => catalogModels().filter((model) => + isModelVisible( + getProviderModelVisibilityPreference(model.providerId), + model.id, + ), + )) + const sortedModels = createMemo(() => [...allModels()].sort((left, right) => { const providerComparison = compareIds(left.providerId, right.providerId) @@ -106,9 +115,22 @@ export default function ModelSelector(props: ModelSelectorProps) { const hasFavorites = createMemo(() => favoriteModels().length > 0) - const currentModelValue = createMemo(() => - allModels().find((m) => m.providerId === props.currentModel.providerId && m.id === props.currentModel.modelId), - ) + const currentModelValue = createMemo(() => { + const current = props.currentModel + const found = catalogModels().find((model) => model.providerId === current.providerId && model.id === current.modelId) + if (found) return found + if (!current.providerId || !current.modelId) return undefined + const providerName = instanceProviders().find((provider) => provider.id === current.providerId)?.name ?? current.providerId + return { + id: current.modelId, + name: t("modelSelector.unavailableModel", { model: current.modelId }), + providerId: current.providerId, + providerName, + key: `${current.providerId}/${current.modelId}`, + searchText: `${current.modelId} ${providerName} ${current.providerId}`, + unavailable: true, + } + }) const currentModelIsFavorite = createMemo(() => { const current = props.currentModel @@ -134,10 +156,9 @@ export default function ModelSelector(props: ModelSelectorProps) { }) const visibleOptions = createMemo(() => { - if (!favoritesOnlyEnabled()) { - return sortedModels() - } - return favoriteModels() + const visible = favoritesOnlyEnabled() ? favoriteModels() : sortedModels() + const current = currentModelValue() + return current?.unavailable ? [current, ...visible] : visible }) const groupedVisibleOptions = createMemo(() => { @@ -169,8 +190,27 @@ export default function ModelSelector(props: ModelSelectorProps) { ]), ) + const comboboxValue = createMemo(() => { + const options = pickerOptions().filter((option): option is FlatModel => !isProviderHeaderOption(option)) + return resolvePickerValue(currentModelValue(), options) + }) + + const currentModelLabel = createMemo(() => + t("modelSelector.trigger.primary", { model: currentModelValue()?.name ?? t("modelSelector.none") }), + ) + + const currentModelAccessibleLabel = createMemo(() => { + const current = currentModelValue() + if (!current) return currentModelLabel() + return t("modelSelector.trigger.ariaLabel", { + model: current.name, + provider: current.providerName, + id: `${current.providerId}/${current.id}`, + }) + }) + const handleChange = async (value: PickerOption | null) => { - if (!value || isProviderHeaderOption(value)) return + if (!value || isProviderHeaderOption(value) || value.unavailable) return await props.onModelChange({ providerId: value.providerId, modelId: value.id }) } @@ -268,7 +308,7 @@ export default function ModelSelector(props: ModelSelectorProps) {