diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8edf9eef..0d9bdd84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,8 +71,8 @@ jobs: - name: Verify preload bundle output run: | - test -f apps/desktop/dist-electron/preload.js - grep -nE "desktopBridge|getLocalEnvironmentBootstrap|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.js + test -f apps/desktop/dist-electron/preload.cjs + grep -nE "desktopBridge|getLocalEnvironmentBootstrap|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.cjs release_smoke: name: Release Smoke diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ede05be3..028326dd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -138,12 +138,12 @@ jobs: matrix: include: - label: macOS arm64 - runner: macos-14 + runner: macos-26 platform: mac target: dmg arch: arm64 - label: macOS x64 - runner: macos-15-intel + runner: macos-26-intel platform: mac target: dmg arch: x64 @@ -153,10 +153,15 @@ jobs: target: AppImage arch: x64 - label: Windows x64 - runner: windows-2022 + runner: windows-2022 # blacksmith-32vcpu-windows-2025 platform: win target: nsis arch: x64 + # - label: Windows arm64 + # runner: windows-11-arm + # platform: win + # target: nsis + # arch: arm64 steps: - name: Checkout uses: actions/checkout@v6 @@ -269,6 +274,18 @@ jobs: done fi + # Enable if Windows arm64 builds are enabled. + # Windows updater metadata is channel-specific (for example + # "latest.yml" or "nightly.yml"). Suffix each per-arch copy so the + # release job can merge matching arm64/x64 manifests back into one + # canonical manifest per channel. + # if [[ "${{ matrix.platform }}" == "win" ]]; then + # shopt -s nullglob + # for manifest in release-publish/*.yml; do + # mv "$manifest" "${manifest%.yml}-win-${{ matrix.arch }}.yml" + # done + # fi + - name: Upload build artifacts uses: actions/upload-artifact@v7 with: @@ -292,6 +309,14 @@ jobs: with: node-version-file: package.json + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - name: Install dependencies + run: bun install --frozen-lockfile --ignore-scripts + - name: Download all desktop artifacts uses: actions/download-artifact@v8 with: @@ -305,11 +330,40 @@ jobs: for x64_manifest in release-assets/*-mac-x64.yml; do arm64_manifest="${x64_manifest%-x64.yml}.yml" if [[ -f "$arm64_manifest" ]]; then - node scripts/merge-mac-update-manifests.ts "$arm64_manifest" "$x64_manifest" + node scripts/merge-update-manifests.ts --platform mac "$arm64_manifest" "$x64_manifest" rm -f "$x64_manifest" fi done + # - name: Merge Windows updater manifests + # run: | + # shopt -s nullglob + # found_windows_manifest=false + # for x64_manifest in release-assets/*-win-x64.yml; do + # if [[ "$(basename "$x64_manifest")" == builder-debug-* ]]; then + # continue + # fi + + # arm64_manifest="${x64_manifest/-x64.yml/-arm64.yml}" + # output_manifest="${x64_manifest/-win-x64.yml/.yml}" + # if [[ ! -f "$arm64_manifest" ]]; then + # echo "Missing matching arm64 Windows manifest for $x64_manifest" >&2 + # exit 1 + # fi + + # found_windows_manifest=true + # node scripts/merge-update-manifests.ts --platform win \ + # "$arm64_manifest" \ + # "$x64_manifest" \ + # "$output_manifest" + # rm -f "$arm64_manifest" "$x64_manifest" + # done + + # if [[ "$found_windows_manifest" != true ]]; then + # echo "No Windows updater manifests found to merge." >&2 + # exit 1 + # fi + - name: Publish release if: needs.preflight.outputs.previous_tag != '' uses: softprops/action-gh-release@v2 @@ -392,6 +446,9 @@ jobs: with: node-version-file: package.json + - name: Install dependencies + run: bun install --frozen-lockfile + - id: update_versions name: Update version strings env: diff --git a/.gitignore b/.gitignore index 01d62a43..6c48782f 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,3 @@ apps/web/src/components/__screenshots__ __screenshots__/ .tanstack squashfs-root/ -core.* diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a38ffd2d..5fbd3021 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,14 +1,15 @@ { "name": "@t3tools/desktop", - "version": "0.0.17", + "version": "0.0.20", "private": true, - "main": "dist-electron/main.js", + "type": "module", + "main": "dist-electron/main.cjs", "scripts": { "dev": "bun run --parallel dev:bundle dev:electron", "dev:bundle": "tsdown --watch", - "dev:electron": "bun run scripts/dev-electron.mjs", + "dev:electron": "node scripts/dev-electron.mjs", "build": "tsdown", - "start": "bun run scripts/start-electron.mjs", + "start": "node scripts/start-electron.mjs", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests", "smoke-test": "node scripts/smoke-test.mjs" diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 7c0d55ac..9a7e68df 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -17,12 +17,12 @@ if (!Number.isInteger(port) || port <= 0) { } const requiredFiles = [ - "dist-electron/main.js", - "dist-electron/preload.js", + "dist-electron/main.cjs", + "dist-electron/preload.cjs", "../server/dist/bin.mjs", ]; const watchedDirectories = [ - { directory: "dist-electron", files: new Set(["main.js", "preload.js"]) }, + { directory: "dist-electron", files: new Set(["main.cjs", "preload.cjs"]) }, { directory: "../server/dist", files: new Set(["bin.mjs"]) }, ]; const forcedShutdownTimeoutMs = 1_500; @@ -69,7 +69,7 @@ function startApp() { const app = spawn( resolveElectronPath(), - [`--t3code-dev-root=${desktopDir}`, "dist-electron/main.js"], + [`--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"], { cwd: desktopDir, env: childEnv, diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 77d9df31..1453cbe6 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -8,7 +8,6 @@ import { mkdirSync, mkdtempSync, readFileSync, - readdirSync, rmSync, statSync, writeFileSync, @@ -20,7 +19,7 @@ import { fileURLToPath } from "node:url"; const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; const APP_BUNDLE_ID = isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code"; -const LAUNCHER_VERSION = 1; +const LAUNCHER_VERSION = 2; const __dirname = dirname(fileURLToPath(import.meta.url)); export const desktopDir = resolve(__dirname, ".."); @@ -121,40 +120,6 @@ function patchMainBundleInfoPlist(appBundlePath, iconPath) { copyFileSync(iconPath, join(resourcesDir, "electron.icns")); } -function patchHelperBundleInfoPlists(appBundlePath) { - const frameworksDir = join(appBundlePath, "Contents", "Frameworks"); - if (!existsSync(frameworksDir)) { - return; - } - - for (const entry of readdirSync(frameworksDir, { withFileTypes: true })) { - if (!entry.isDirectory() || !entry.name.endsWith(".app")) { - continue; - } - if (!entry.name.startsWith("Electron Helper")) { - continue; - } - - const helperPlistPath = join(frameworksDir, entry.name, "Contents", "Info.plist"); - if (!existsSync(helperPlistPath)) { - continue; - } - - const suffix = entry.name.replace("Electron Helper", "").replace(".app", "").trim(); - const helperName = suffix - ? `${APP_DISPLAY_NAME} Helper ${suffix}` - : `${APP_DISPLAY_NAME} Helper`; - const helperIdSuffix = suffix.replace(/[()]/g, "").trim().toLowerCase().replace(/\s+/g, "-"); - const helperBundleId = helperIdSuffix - ? `${APP_BUNDLE_ID}.helper.${helperIdSuffix}` - : `${APP_BUNDLE_ID}.helper`; - - setPlistString(helperPlistPath, "CFBundleDisplayName", helperName); - setPlistString(helperPlistPath, "CFBundleName", helperName); - setPlistString(helperPlistPath, "CFBundleIdentifier", helperBundleId); - } -} - function readJson(path) { try { return JSON.parse(readFileSync(path, "utf8")); @@ -192,7 +157,6 @@ function buildMacLauncher(electronBinaryPath) { rmSync(targetAppBundlePath, { recursive: true, force: true }); cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true }); patchMainBundleInfoPlist(targetAppBundlePath, iconPath); - patchHelperBundleInfoPlists(targetAppBundlePath); writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`); return targetBinaryPath; @@ -206,5 +170,11 @@ export function resolveElectronPath() { return electronBinaryPath; } + // Dev launches do not need a renamed app bundle badly enough to risk breaking + // Electron helper resource lookup on macOS. + if (isDevelopment) { + return electronBinaryPath; + } + return buildMacLauncher(electronBinaryPath); } diff --git a/apps/desktop/scripts/smoke-test.mjs b/apps/desktop/scripts/smoke-test.mjs index 883da720..fdbe69b7 100644 --- a/apps/desktop/scripts/smoke-test.mjs +++ b/apps/desktop/scripts/smoke-test.mjs @@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const desktopDir = resolve(__dirname, ".."); const electronBin = resolve(desktopDir, "node_modules/.bin/electron"); -const mainJs = resolve(desktopDir, "dist-electron/main.js"); +const mainJs = resolve(desktopDir, "dist-electron/main.cjs"); console.log("\nLaunching Electron smoke test..."); diff --git a/apps/desktop/scripts/start-electron.mjs b/apps/desktop/scripts/start-electron.mjs index bf93adb6..375dbfe5 100644 --- a/apps/desktop/scripts/start-electron.mjs +++ b/apps/desktop/scripts/start-electron.mjs @@ -5,7 +5,7 @@ import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; const childEnv = { ...process.env }; delete childEnv.ELECTRON_RUN_AS_NODE; -const child = spawn(resolveElectronPath(), ["dist-electron/main.js"], { +const child = spawn(resolveElectronPath(), ["dist-electron/main.cjs"], { stdio: "inherit", cwd: desktopDir, env: childEnv, diff --git a/apps/desktop/src/appBranding.test.ts b/apps/desktop/src/appBranding.test.ts index 93e872fb..5e3e3a5a 100644 --- a/apps/desktop/src/appBranding.test.ts +++ b/apps/desktop/src/appBranding.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { resolveDesktopAppBranding, resolveDesktopAppStageLabel } from "./appBranding"; +import { resolveDesktopAppBranding, resolveDesktopAppStageLabel } from "./appBranding.ts"; describe("resolveDesktopAppStageLabel", () => { it("uses Dev in desktop development", () => { diff --git a/apps/desktop/src/appBranding.ts b/apps/desktop/src/appBranding.ts index fe1d1318..3cb1539f 100644 --- a/apps/desktop/src/appBranding.ts +++ b/apps/desktop/src/appBranding.ts @@ -1,7 +1,8 @@ import type { DesktopAppBranding, DesktopAppStageLabel } from "@t3tools/contracts"; +import { isNightlyDesktopVersion } from "./updateChannels.ts"; + const APP_BASE_NAME = "T3 Code"; -const NIGHTLY_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/; export function resolveDesktopAppStageLabel(input: { readonly isDevelopment: boolean; @@ -11,7 +12,7 @@ export function resolveDesktopAppStageLabel(input: { return "Dev"; } - return NIGHTLY_VERSION_PATTERN.test(input.appVersion) ? "Nightly" : "Alpha"; + return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "Alpha"; } export function resolveDesktopAppBranding(input: { diff --git a/apps/desktop/src/backendPort.test.ts b/apps/desktop/src/backendPort.test.ts index 8f586deb..774e31b8 100644 --- a/apps/desktop/src/backendPort.test.ts +++ b/apps/desktop/src/backendPort.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { resolveDesktopBackendPort } from "./backendPort"; +import { resolveDesktopBackendPort } from "./backendPort.ts"; describe("resolveDesktopBackendPort", () => { it("returns the starting port when it is available", async () => { diff --git a/apps/desktop/src/backendReadiness.test.ts b/apps/desktop/src/backendReadiness.test.ts index 33a5ef6b..0d49842a 100644 --- a/apps/desktop/src/backendReadiness.test.ts +++ b/apps/desktop/src/backendReadiness.test.ts @@ -4,7 +4,7 @@ import { BackendReadinessAbortedError, isBackendReadinessAborted, waitForHttpReady, -} from "./backendReadiness"; +} from "./backendReadiness.ts"; describe("waitForHttpReady", () => { it("returns once the backend serves the requested readiness path", async () => { diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index 8db524c4..27f1e1d9 100644 --- a/apps/desktop/src/clientPersistence.test.ts +++ b/apps/desktop/src/clientPersistence.test.ts @@ -18,7 +18,7 @@ import { writeSavedEnvironmentRegistry, writeSavedEnvironmentSecret, type DesktopSecretStorage, -} from "./clientPersistence"; +} from "./clientPersistence.ts"; const tempDirectories: string[] = []; @@ -52,6 +52,10 @@ const clientSettings: ClientSettings = { confirmThreadArchive: true, confirmThreadDelete: false, diffWordWrap: true, + sidebarProjectGroupingMode: "repository_path", + sidebarProjectGroupingOverrides: { + "environment-1:/tmp/project-a": "separate", + }, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", timestampFormat: "24-hour", @@ -75,24 +79,6 @@ describe("clientPersistence", () => { expect(readClientSettings(settingsPath)).toEqual(clientSettings); }); - it("migrates partial persisted client settings with schema defaults", () => { - const settingsPath = makeTempPath("client-settings.json"); - fs.writeFileSync( - settingsPath, - `${JSON.stringify({ settings: { confirmThreadArchive: true, timestampFormat: "24-hour" } })}\n`, - "utf8", - ); - - expect(readClientSettings(settingsPath)).toEqual({ - confirmThreadArchive: true, - confirmThreadDelete: true, - diffWordWrap: false, - sidebarProjectSortOrder: "updated_at", - sidebarThreadSortOrder: "updated_at", - timestampFormat: "24-hour", - }); - }); - it("persists and reloads saved environment metadata", () => { const registryPath = makeTempPath("saved-environments.json"); diff --git a/apps/desktop/src/clientPersistence.ts b/apps/desktop/src/clientPersistence.ts index 7549e9ab..ad08a003 100644 --- a/apps/desktop/src/clientPersistence.ts +++ b/apps/desktop/src/clientPersistence.ts @@ -6,8 +6,8 @@ import { type ClientSettings, type PersistedSavedEnvironmentRecord, } from "@t3tools/contracts"; -import * as Schema from "effect/Schema"; import { Predicate } from "effect"; +import * as Schema from "effect/Schema"; interface ClientSettingsDocument { readonly settings: ClientSettings; @@ -88,12 +88,12 @@ function toPersistedSavedEnvironmentRecord( } export function readClientSettings(settingsPath: string): ClientSettings | null { - const settings = readJsonFile(settingsPath)?.settings; - if (!settings) { + const raw = readJsonFile(settingsPath)?.settings; + if (!raw) { return null; } try { - return Schema.decodeSync(ClientSettingsSchema)(settings); + return Schema.decodeUnknownSync(ClientSettingsSchema)(raw); } catch { return null; } diff --git a/apps/desktop/src/confirmDialog.test.ts b/apps/desktop/src/confirmDialog.test.ts index 4a4c0ddb..de1d23eb 100644 --- a/apps/desktop/src/confirmDialog.test.ts +++ b/apps/desktop/src/confirmDialog.test.ts @@ -11,7 +11,7 @@ vi.mock("electron", () => ({ }, })); -import { showDesktopConfirmDialog } from "./confirmDialog"; +import { showDesktopConfirmDialog } from "./confirmDialog.ts"; describe("showDesktopConfirmDialog", () => { beforeEach(() => { diff --git a/apps/desktop/src/desktopSettings.test.ts b/apps/desktop/src/desktopSettings.test.ts index 7efdc88e..5489bb89 100644 --- a/apps/desktop/src/desktopSettings.test.ts +++ b/apps/desktop/src/desktopSettings.test.ts @@ -7,10 +7,11 @@ import { afterEach, describe, expect, it } from "vitest"; import { DEFAULT_DESKTOP_SETTINGS, readDesktopSettings, + resolveDefaultDesktopSettings, setDesktopServerExposurePreference, setDesktopUpdateChannelPreference, writeDesktopSettings, -} from "./desktopSettings"; +} from "./desktopSettings.ts"; const tempDirectories: string[] = []; @@ -28,7 +29,15 @@ function makeSettingsPath() { describe("desktopSettings", () => { it("returns defaults when no settings file exists", () => { - expect(readDesktopSettings(makeSettingsPath())).toEqual(DEFAULT_DESKTOP_SETTINGS); + expect(readDesktopSettings(makeSettingsPath(), "0.0.17")).toEqual(DEFAULT_DESKTOP_SETTINGS); + }); + + it("defaults packaged nightly builds to the nightly update channel", () => { + expect(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1")).toEqual({ + serverExposureMode: "local-only", + updateChannel: "nightly", + updateChannelConfiguredByUser: false, + }); }); it("persists and reloads the configured server exposure mode", () => { @@ -37,11 +46,13 @@ describe("desktopSettings", () => { writeDesktopSettings(settingsPath, { serverExposureMode: "network-accessible", updateChannel: "latest", + updateChannelConfiguredByUser: true, }); - expect(readDesktopSettings(settingsPath)).toEqual({ + expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual({ serverExposureMode: "network-accessible", updateChannel: "latest", + updateChannelConfiguredByUser: true, }); }); @@ -51,12 +62,14 @@ describe("desktopSettings", () => { { serverExposureMode: "local-only", updateChannel: "latest", + updateChannelConfiguredByUser: false, }, "network-accessible", ), ).toEqual({ serverExposureMode: "network-accessible", updateChannel: "latest", + updateChannelConfiguredByUser: false, }); }); @@ -66,12 +79,14 @@ describe("desktopSettings", () => { { serverExposureMode: "local-only", updateChannel: "latest", + updateChannelConfiguredByUser: false, }, "nightly", ), ).toEqual({ serverExposureMode: "local-only", updateChannel: "nightly", + updateChannelConfiguredByUser: true, }); }); @@ -79,6 +94,90 @@ describe("desktopSettings", () => { const settingsPath = makeSettingsPath(); fs.writeFileSync(settingsPath, "{not-json", "utf8"); - expect(readDesktopSettings(settingsPath)).toEqual(DEFAULT_DESKTOP_SETTINGS); + expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual(DEFAULT_DESKTOP_SETTINGS); + }); + + it("falls back to the nightly channel for legacy nightly settings without an update track", () => { + const settingsPath = makeSettingsPath(); + fs.writeFileSync(settingsPath, JSON.stringify({ serverExposureMode: "local-only" }), "utf8"); + + expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ + serverExposureMode: "local-only", + updateChannel: "nightly", + updateChannelConfiguredByUser: false, + }); + }); + + it("preserves a legacy saved stable channel on nightly builds", () => { + const settingsPath = makeSettingsPath(); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + serverExposureMode: "local-only", + updateChannel: "latest", + }), + "utf8", + ); + + expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ + serverExposureMode: "local-only", + updateChannel: "latest", + updateChannelConfiguredByUser: false, + }); + }); + + it("preserves an explicit stable choice on nightly builds", () => { + const settingsPath = makeSettingsPath(); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + serverExposureMode: "local-only", + updateChannel: "latest", + updateChannelConfiguredByUser: true, + }), + "utf8", + ); + + expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ + serverExposureMode: "local-only", + updateChannel: "latest", + updateChannelConfiguredByUser: true, + }); + }); + + it("preserves a legacy explicit stable choice on nightly builds", () => { + const settingsPath = makeSettingsPath(); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + serverExposureMode: "local-only", + updateChannel: "latest", + }), + "utf8", + ); + + expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ + serverExposureMode: "local-only", + updateChannel: "latest", + updateChannelConfiguredByUser: false, + }); + }); + + it("does not treat legacy nightly settings as an explicit track override", () => { + const settingsPath = makeSettingsPath(); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + serverExposureMode: "local-only", + updateChannel: "nightly", + }), + "utf8", + ); + + expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual({ + serverExposureMode: "local-only", + updateChannel: "latest", + updateChannelConfiguredByUser: false, + }); }); }); diff --git a/apps/desktop/src/desktopSettings.ts b/apps/desktop/src/desktopSettings.ts index 7fc0ddd7..b014893d 100644 --- a/apps/desktop/src/desktopSettings.ts +++ b/apps/desktop/src/desktopSettings.ts @@ -2,16 +2,27 @@ import * as FS from "node:fs"; import * as Path from "node:path"; import type { DesktopServerExposureMode, DesktopUpdateChannel } from "@t3tools/contracts"; +import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; + export interface DesktopSettings { readonly serverExposureMode: DesktopServerExposureMode; readonly updateChannel: DesktopUpdateChannel; + readonly updateChannelConfiguredByUser: boolean; } export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { serverExposureMode: "local-only", updateChannel: "latest", + updateChannelConfiguredByUser: false, }; +export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings { + return { + ...DEFAULT_DESKTOP_SETTINGS, + updateChannel: resolveDefaultDesktopUpdateChannel(appVersion), + }; +} + export function setDesktopServerExposurePreference( settings: DesktopSettings, requestedMode: DesktopServerExposureMode, @@ -28,33 +39,50 @@ export function setDesktopUpdateChannelPreference( settings: DesktopSettings, requestedChannel: DesktopUpdateChannel, ): DesktopSettings { - return settings.updateChannel === requestedChannel - ? settings - : { - ...settings, - updateChannel: requestedChannel, - }; + return { + ...settings, + updateChannel: requestedChannel, + updateChannelConfiguredByUser: true, + }; } -export function readDesktopSettings(settingsPath: string): DesktopSettings { +export function readDesktopSettings(settingsPath: string, appVersion: string): DesktopSettings { + const defaultSettings = resolveDefaultDesktopSettings(appVersion); + try { if (!FS.existsSync(settingsPath)) { - return DEFAULT_DESKTOP_SETTINGS; + return defaultSettings; } const raw = FS.readFileSync(settingsPath, "utf8"); const parsed = JSON.parse(raw) as { readonly serverExposureMode?: unknown; readonly updateChannel?: unknown; + readonly updateChannelConfiguredByUser?: unknown; }; + const parsedUpdateChannel = + parsed.updateChannel === "nightly" || parsed.updateChannel === "latest" + ? parsed.updateChannel + : null; + const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined; + const updateChannelConfiguredByUser = parsed.updateChannelConfiguredByUser === true; + const updateChannel = + parsedUpdateChannel !== null && + (updateChannelConfiguredByUser || + (isLegacySettings && + (parsedUpdateChannel === defaultSettings.updateChannel || + (defaultSettings.updateChannel === "nightly" && parsedUpdateChannel === "latest")))) + ? parsedUpdateChannel + : defaultSettings.updateChannel; return { serverExposureMode: parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", - updateChannel: parsed.updateChannel === "nightly" ? "nightly" : "latest", + updateChannel, + updateChannelConfiguredByUser, }; } catch { - return DEFAULT_DESKTOP_SETTINGS; + return defaultSettings; } } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 987cad34..3ef80f5c 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -36,14 +36,14 @@ import { autoUpdater } from "electron-updater"; import type { ContextMenuItem } from "@t3tools/contracts"; import { RotatingFileSink } from "@t3tools/shared/logging"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; -import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPort } from "./backendPort"; +import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPort } from "./backendPort.ts"; import { DEFAULT_DESKTOP_SETTINGS, readDesktopSettings, setDesktopServerExposurePreference, setDesktopUpdateChannelPreference, writeDesktopSettings, -} from "./desktopSettings"; +} from "./desktopSettings.ts"; import { readClientSettings, readSavedEnvironmentRegistry, @@ -52,13 +52,14 @@ import { writeClientSettings, writeSavedEnvironmentRegistry, writeSavedEnvironmentSecret, -} from "./clientPersistence"; -import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness"; -import { showDesktopConfirmDialog } from "./confirmDialog"; -import { resolveDesktopServerExposure } from "./serverExposure"; -import { syncShellEnvironment } from "./syncShellEnvironment"; -import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState"; -import { ServerListeningDetector } from "./serverListeningDetector"; +} from "./clientPersistence.ts"; +import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness.ts"; +import { showDesktopConfirmDialog } from "./confirmDialog.ts"; +import { resolveDesktopServerExposure } from "./serverExposure.ts"; +import { syncShellEnvironment } from "./syncShellEnvironment.ts"; +import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts"; +import { doesVersionMatchDesktopUpdateChannel } from "./updateChannels.ts"; +import { ServerListeningDetector } from "./serverListeningDetector.ts"; import { createInitialDesktopUpdateState, reduceDesktopUpdateStateOnCheckFailure, @@ -70,9 +71,9 @@ import { reduceDesktopUpdateStateOnInstallFailure, reduceDesktopUpdateStateOnNoUpdate, reduceDesktopUpdateStateOnUpdateAvailable, -} from "./updateMachine"; -import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch"; -import { resolveDesktopAppBranding } from "./appBranding"; +} from "./updateMachine.ts"; +import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts"; +import { resolveDesktopAppBranding } from "./appBranding.ts"; syncShellEnvironment(); @@ -159,6 +160,35 @@ const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linu const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; +function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] { + const normalizedItems: ContextMenuItem[] = []; + + for (const sourceItem of source) { + if (typeof sourceItem.id !== "string" || typeof sourceItem.label !== "string") { + continue; + } + + const normalizedItem: ContextMenuItem = { + id: sourceItem.id, + label: sourceItem.label, + destructive: sourceItem.destructive === true, + disabled: sourceItem.disabled === true, + }; + + if (sourceItem.children) { + const normalizedChildren = normalizeContextMenuItems(sourceItem.children); + if (normalizedChildren.length === 0) { + continue; + } + normalizedItem.children = normalizedChildren; + } + + normalizedItems.push(normalizedItem); + } + + return normalizedItems; +} + type WindowTitleBarOptions = Pick< BrowserWindowConstructorOptions, "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition" @@ -190,7 +220,7 @@ let desktopLogSink: RotatingFileSink | null = null; let backendLogSink: RotatingFileSink | null = null; let restoreStdIoCapture: (() => void) | null = null; let backendObservabilitySettings = readPersistedBackendObservabilitySettings(); -let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH); +let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH, app.getVersion()); let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode; let destructiveMenuIconCache: Electron.NativeImage | null | undefined; @@ -1140,7 +1170,7 @@ function applyAutoUpdaterChannel(channel: DesktopUpdateChannel): void { autoUpdater.allowPrerelease = channel === "nightly"; autoUpdater.allowDowngrade = channel === "nightly"; console.info( - `[desktop-updater] Using update channel '${channel}' (allowPrerelease=${channel === "nightly"}).`, + `[desktop-updater] Using update channel '${channel}' (allowPrerelease=${channel === "nightly"}, allowDowngrade=${channel === "nightly"}).`, ); } @@ -1285,6 +1315,15 @@ function configureAutoUpdater(): void { console.info("[desktop-updater] Looking for updates..."); }); autoUpdater.on("update-available", (info) => { + if (!doesVersionMatchDesktopUpdateChannel(info.version, updateState.channel)) { + console.info( + `[desktop-updater] Ignoring ${info.version} because it does not match the selected '${updateState.channel}' channel.`, + ); + setUpdateState(reduceDesktopUpdateStateOnNoUpdate(updateState, new Date().toISOString())); + lastLoggedDownloadMilestone = -1; + return; + } + setUpdateState( reduceDesktopUpdateStateOnUpdateAvailable( updateState, @@ -1705,14 +1744,7 @@ function registerIpcHandlers(): void { ipcMain.handle( CONTEXT_MENU_CHANNEL, async (_event, items: ContextMenuItem[], position?: { x: number; y: number }) => { - const normalizedItems = items - .filter((item) => typeof item.id === "string" && typeof item.label === "string") - .map((item) => ({ - id: item.id, - label: item.label, - destructive: item.destructive === true, - disabled: item.disabled === true, - })); + const normalizedItems = normalizeContextMenuItems(items); if (normalizedItems.length === 0) { return null; } @@ -1733,28 +1765,37 @@ function registerIpcHandlers(): void { if (!window) return null; return new Promise((resolve) => { - const template: MenuItemConstructorOptions[] = []; - let hasInsertedDestructiveSeparator = false; - for (const item of normalizedItems) { - if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { - template.push({ type: "separator" }); - hasInsertedDestructiveSeparator = true; - } - const itemOption: MenuItemConstructorOptions = { - label: item.label, - enabled: !item.disabled, - click: () => resolve(item.id), - }; - if (item.destructive) { - const destructiveIcon = getDestructiveMenuIcon(); - if (destructiveIcon) { - itemOption.icon = destructiveIcon; + const buildTemplate = ( + entries: readonly ContextMenuItem[], + ): MenuItemConstructorOptions[] => { + const template: MenuItemConstructorOptions[] = []; + let hasInsertedDestructiveSeparator = false; + for (const item of entries) { + if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { + template.push({ type: "separator" }); + hasInsertedDestructiveSeparator = true; + } + const itemOption: MenuItemConstructorOptions = { + label: item.label, + enabled: !item.disabled, + }; + if (item.children && item.children.length > 0) { + itemOption.submenu = buildTemplate(item.children); + } else { + itemOption.click = () => resolve(item.id); + } + if (item.destructive && (!item.children || item.children.length === 0)) { + const destructiveIcon = getDestructiveMenuIcon(); + if (destructiveIcon) { + itemOption.icon = destructiveIcon; + } } + template.push(itemOption); } - template.push(itemOption); - } + return template; + }; - const menu = Menu.buildFromTemplate(template); + const menu = Menu.buildFromTemplate(buildTemplate(normalizedItems)); menu.popup({ window, ...popupPosition, @@ -1792,13 +1833,14 @@ function registerIpcHandlers(): void { } const nextChannel = rawChannel as DesktopUpdateChannel; - if (nextChannel === desktopSettings.updateChannel) { - return updateState; - } desktopSettings = setDesktopUpdateChannelPreference(desktopSettings, nextChannel); writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings); + if (nextChannel === updateState.channel) { + return updateState; + } + const enabled = shouldEnableAutoUpdates(); setUpdateState(createBaseUpdateState(nextChannel, enabled)); @@ -1925,7 +1967,7 @@ function createWindow(): BrowserWindow { title: APP_DISPLAY_NAME, ...getWindowTitleBarOptions(), webPreferences: { - preload: Path.join(__dirname, "preload.js"), + preload: Path.join(__dirname, "preload.cjs"), contextIsolation: true, nodeIntegration: false, sandbox: true, diff --git a/apps/desktop/src/runtimeArch.test.ts b/apps/desktop/src/runtimeArch.test.ts index 258a8fb2..a3173598 100644 --- a/apps/desktop/src/runtimeArch.test.ts +++ b/apps/desktop/src/runtimeArch.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch"; +import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts"; describe("resolveDesktopRuntimeInfo", () => { it("detects Rosetta-translated Intel builds on Apple Silicon", () => { diff --git a/apps/desktop/src/serverExposure.test.ts b/apps/desktop/src/serverExposure.test.ts index b1ae4bef..c83bbc21 100644 --- a/apps/desktop/src/serverExposure.test.ts +++ b/apps/desktop/src/serverExposure.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { resolveDesktopServerExposure, resolveLanAdvertisedHost } from "./serverExposure"; +import { resolveDesktopServerExposure, resolveLanAdvertisedHost } from "./serverExposure.ts"; describe("resolveLanAdvertisedHost", () => { it("prefers an explicit host override", () => { diff --git a/apps/desktop/src/serverListeningDetector.test.ts b/apps/desktop/src/serverListeningDetector.test.ts index b7c66b63..fcf9f50a 100644 --- a/apps/desktop/src/serverListeningDetector.test.ts +++ b/apps/desktop/src/serverListeningDetector.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { ServerListeningDetector } from "./serverListeningDetector"; +import { ServerListeningDetector } from "./serverListeningDetector.ts"; describe("ServerListeningDetector", () => { it("resolves when the server logs the listening line", async () => { diff --git a/apps/desktop/src/syncShellEnvironment.test.ts b/apps/desktop/src/syncShellEnvironment.test.ts index 7d457889..1c13f772 100644 --- a/apps/desktop/src/syncShellEnvironment.test.ts +++ b/apps/desktop/src/syncShellEnvironment.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { syncShellEnvironment } from "./syncShellEnvironment"; +import { syncShellEnvironment } from "./syncShellEnvironment.ts"; describe("syncShellEnvironment", () => { it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => { @@ -148,7 +148,7 @@ describe("syncShellEnvironment", () => { expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); }); - it("does nothing outside macOS and linux", () => { + it("does nothing on unsupported platforms", () => { const env: NodeJS.ProcessEnv = { SHELL: "C:/Program Files/Git/bin/bash.exe", PATH: "C:\\Windows\\System32", @@ -160,7 +160,7 @@ describe("syncShellEnvironment", () => { })); syncShellEnvironment(env, { - platform: "win32", + platform: "freebsd", readEnvironment, }); @@ -168,4 +168,122 @@ describe("syncShellEnvironment", () => { expect(env.PATH).toBe("C:\\Windows\\System32"); expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); }); + + it("hydrates PATH on Windows by merging PowerShell PATH with inherited PATH", () => { + const env: NodeJS.ProcessEnv = { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }; + const readWindowsEnvironment = vi.fn(() => ({ + PATH: "C:\\Custom\\Bin;C:\\Windows\\System32", + })); + const isWindowsCommandAvailable = vi.fn(() => true); + + syncShellEnvironment(env, { + platform: "win32", + readWindowsEnvironment, + isWindowsCommandAvailable, + }); + + expect(readWindowsEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false }); + expect(env.PATH).toBe( + [ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Custom\\Bin", + "C:\\Windows\\System32", + ].join(";"), + ); + expect(isWindowsCommandAvailable).toHaveBeenCalledTimes(1); + }); + + it("loads the PowerShell profile on Windows when node is not available", () => { + const env: NodeJS.ProcessEnv = { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }; + const readWindowsEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => + options?.loadProfile + ? { + PATH: "C:\\Profile\\Node;C:\\Windows\\System32", + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + } + : { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }, + ); + const isWindowsCommandAvailable = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true); + + syncShellEnvironment(env, { + platform: "win32", + readWindowsEnvironment, + isWindowsCommandAvailable, + }); + + expect(env.PATH).toBe( + [ + "C:\\Profile\\Node", + "C:\\Windows\\System32", + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Custom\\Bin", + ].join(";"), + ); + expect(env.FNM_DIR).toBe("C:\\Users\\testuser\\AppData\\Roaming\\fnm"); + expect(env.FNM_MULTISHELL_PATH).toBe( + "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + ); + expect(readWindowsEnvironment).toHaveBeenNthCalledWith(1, ["PATH"], { loadProfile: false }); + expect(readWindowsEnvironment).toHaveBeenNthCalledWith( + 2, + ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], + { loadProfile: true }, + ); + }); + + it("preserves baseline Windows env when the profile probe fails", () => { + const env: NodeJS.ProcessEnv = { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + USERPROFILE: "C:\\Users\\testuser", + }; + const readWindowsEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => { + if (options?.loadProfile) { + throw new Error("profile load failed"); + } + return { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }; + }, + ); + const isWindowsCommandAvailable = vi.fn(() => false); + + syncShellEnvironment(env, { + platform: "win32", + readWindowsEnvironment, + isWindowsCommandAvailable, + }); + + expect(env.PATH).toBe( + [ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Custom\\Bin", + "C:\\Windows\\System32", + ].join(";"), + ); + expect(env.SSH_AUTH_SOCK).toBeUndefined(); + }); }); diff --git a/apps/desktop/src/syncShellEnvironment.ts b/apps/desktop/src/syncShellEnvironment.ts index 7e031b11..373187bd 100644 --- a/apps/desktop/src/syncShellEnvironment.ts +++ b/apps/desktop/src/syncShellEnvironment.ts @@ -3,9 +3,19 @@ import { mergePathEntries, readPathFromLaunchctl, readEnvironmentFromLoginShell, + resolveWindowsEnvironment, +} from "@t3tools/shared/shell"; +import type { + CommandAvailabilityOptions, ShellEnvironmentReader, + WindowsShellEnvironmentReader, } from "@t3tools/shared/shell"; +type WindowsCommandAvailabilityChecker = ( + command: string, + options?: CommandAvailabilityOptions, +) => boolean; + const LOGIN_SHELL_ENV_NAMES = [ "PATH", "SSH_AUTH_SOCK", @@ -25,19 +35,39 @@ export function syncShellEnvironment( options: { platform?: NodeJS.Platform; readEnvironment?: ShellEnvironmentReader; + readWindowsEnvironment?: WindowsShellEnvironmentReader; + isWindowsCommandAvailable?: WindowsCommandAvailabilityChecker; readLaunchctlPath?: typeof readPathFromLaunchctl; userShell?: string; logWarning?: (message: string, error?: unknown) => void; } = {}, ): void { const platform = options.platform ?? process.platform; - if (platform !== "darwin" && platform !== "linux") return; const logWarning = options.logWarning ?? logShellEnvironmentWarning; const readEnvironment = options.readEnvironment ?? readEnvironmentFromLoginShell; const shellEnvironment: Partial> = {}; try { + if (platform === "win32") { + const repairedEnvironment = resolveWindowsEnvironment(env, { + ...(options.readWindowsEnvironment + ? { readEnvironment: options.readWindowsEnvironment } + : {}), + ...(options.isWindowsCommandAvailable + ? { commandAvailable: options.isWindowsCommandAvailable } + : {}), + }); + for (const [key, value] of Object.entries(repairedEnvironment)) { + if (value !== undefined) { + env[key] = value; + } + } + return; + } + + if (platform !== "darwin" && platform !== "linux") return; + for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) { try { Object.assign(shellEnvironment, readEnvironment(shell, LOGIN_SHELL_ENV_NAMES)); diff --git a/apps/desktop/src/updateChannels.test.ts b/apps/desktop/src/updateChannels.test.ts new file mode 100644 index 00000000..f815fbd8 --- /dev/null +++ b/apps/desktop/src/updateChannels.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { + doesVersionMatchDesktopUpdateChannel, + isNightlyDesktopVersion, + resolveDefaultDesktopUpdateChannel, +} from "./updateChannels.ts"; + +describe("isNightlyDesktopVersion", () => { + it("detects packaged nightly versions", () => { + expect(isNightlyDesktopVersion("0.0.17-nightly.20260415.1")).toBe(true); + }); + + it("does not flag stable versions as nightly", () => { + expect(isNightlyDesktopVersion("0.0.17")).toBe(false); + }); +}); + +describe("resolveDefaultDesktopUpdateChannel", () => { + it("defaults stable builds to latest", () => { + expect(resolveDefaultDesktopUpdateChannel("0.0.17")).toBe("latest"); + }); + + it("defaults nightly builds to nightly", () => { + expect(resolveDefaultDesktopUpdateChannel("0.0.17-nightly.20260415.1")).toBe("nightly"); + }); +}); + +describe("doesVersionMatchDesktopUpdateChannel", () => { + it("accepts nightly releases on the nightly channel", () => { + expect(doesVersionMatchDesktopUpdateChannel("0.0.17-nightly.20260416.1", "nightly")).toBe(true); + }); + + it("rejects stable releases on the nightly channel", () => { + expect(doesVersionMatchDesktopUpdateChannel("0.0.17", "nightly")).toBe(false); + }); + + it("rejects nightly releases on the stable channel", () => { + expect(doesVersionMatchDesktopUpdateChannel("0.0.17-nightly.20260416.1", "latest")).toBe(false); + }); +}); diff --git a/apps/desktop/src/updateChannels.ts b/apps/desktop/src/updateChannels.ts new file mode 100644 index 00000000..615b8e6d --- /dev/null +++ b/apps/desktop/src/updateChannels.ts @@ -0,0 +1,18 @@ +import type { DesktopUpdateChannel } from "@t3tools/contracts"; + +const NIGHTLY_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/; + +export function isNightlyDesktopVersion(version: string): boolean { + return NIGHTLY_VERSION_PATTERN.test(version); +} + +export function resolveDefaultDesktopUpdateChannel(appVersion: string): DesktopUpdateChannel { + return isNightlyDesktopVersion(appVersion) ? "nightly" : "latest"; +} + +export function doesVersionMatchDesktopUpdateChannel( + version: string, + channel: DesktopUpdateChannel, +): boolean { + return resolveDefaultDesktopUpdateChannel(version) === channel; +} diff --git a/apps/desktop/src/updateMachine.test.ts b/apps/desktop/src/updateMachine.test.ts index a6fbcfb5..e2f0519d 100644 --- a/apps/desktop/src/updateMachine.test.ts +++ b/apps/desktop/src/updateMachine.test.ts @@ -11,7 +11,7 @@ import { reduceDesktopUpdateStateOnInstallFailure, reduceDesktopUpdateStateOnNoUpdate, reduceDesktopUpdateStateOnUpdateAvailable, -} from "./updateMachine"; +} from "./updateMachine.ts"; const runtimeInfo = { hostArch: "x64", diff --git a/apps/desktop/src/updateMachine.ts b/apps/desktop/src/updateMachine.ts index c767dfd2..7d5ed271 100644 --- a/apps/desktop/src/updateMachine.ts +++ b/apps/desktop/src/updateMachine.ts @@ -4,7 +4,7 @@ import type { DesktopUpdateState, } from "@t3tools/contracts"; -import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState"; +import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState.ts"; export function createInitialDesktopUpdateState( currentVersion: string, diff --git a/apps/desktop/src/updateState.test.ts b/apps/desktop/src/updateState.test.ts index 9d7fe5b7..c2bb4ba1 100644 --- a/apps/desktop/src/updateState.test.ts +++ b/apps/desktop/src/updateState.test.ts @@ -6,7 +6,7 @@ import { getAutoUpdateDisabledReason, nextStatusAfterDownloadFailure, shouldBroadcastDownloadProgress, -} from "./updateState"; +} from "./updateState.ts"; const baseState: DesktopUpdateState = { enabled: true, diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 0ca5bcaa..ff3e4cd0 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "composite": true, "types": ["node", "electron"], - "lib": ["ES2023", "DOM", "esnext.disposable"] + "lib": ["ESNext", "DOM", "esnext.disposable"] }, "include": ["src", "tsdown.config.ts"] } diff --git a/apps/desktop/tsdown.config.ts b/apps/desktop/tsdown.config.ts index f3ebc973..53b00393 100644 --- a/apps/desktop/tsdown.config.ts +++ b/apps/desktop/tsdown.config.ts @@ -4,7 +4,7 @@ const shared = { format: "cjs" as const, outDir: "dist-electron", sourcemap: true, - outExtensions: () => ({ js: ".js" }), + outExtensions: () => ({ js: ".cjs" }), }; export default defineConfig([ diff --git a/apps/server/package.json b/apps/server/package.json index 249f0710..2843d207 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.17", + "version": "0.0.20", "license": "MIT", "repository": { "type": "git", @@ -15,15 +15,17 @@ ], "type": "module", "scripts": { - "dev": "bun run src/bin.ts", + "dev": "node --watch src/bin.ts", "build": "node scripts/cli.ts build", + "build:bundle": "tsdown", "start": "node dist/bin.mjs", "prepare": "effect-language-service patch", "typecheck": "tsc --noEmit", - "test": "vitest run" + "test": "vitest run", + "test:process-reaper": "vitest run src/server.test.ts src/provider/Layers/ClaudeAdapter.test.ts src/provider/Layers/ProviderSessionDirectory.test.ts src/provider/Layers/ProviderSessionReaper.test.ts src/provider/Layers/CodexAdapter.test.ts" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.77", + "@anthropic-ai/claude-agent-sdk": "^0.2.111", "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index 299da67f..efaa2b3b 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -147,13 +147,13 @@ const buildCmd = Command.make( yield* Effect.log("[cli] Running tsdown..."); yield* runCommand( - ChildProcess.make({ + ChildProcess.make(process.execPath, ["--run", "build:bundle"], { cwd: serverDir, stdout: config.verbose ? "inherit" : "ignore", stderr: "inherit", - // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd). + // Windows needs shell mode to resolve `.cmd` shims on PATH. shell: process.platform === "win32", - })`bun tsdown`, + }), ); const webDist = path.join(repoRoot, "apps/web/dist"); @@ -203,10 +203,8 @@ const publishCmd = Command.make( } yield* Effect.acquireUseRelease( - // Acquire: backup package.json, resolve catalog: deps, strip devDependencies/scripts + // Acquire: backup package.json, resolve catalog dependencies, and strip devDependencies/scripts Effect.gen(function* () { - // Resolve catalog dependencies before any file mutations. If this throws, - // acquire fails and no release hook runs, so filesystem must still be untouched. const version = Option.getOrElse(config.appVersion, () => serverPackageJson.version); const pkg: PackageJson = { name: serverPackageJson.name, @@ -216,25 +214,22 @@ const publishCmd = Command.make( version, engines: serverPackageJson.engines, files: serverPackageJson.files, - dependencies: serverPackageJson.dependencies, - overrides: rootPackageJson.overrides, + dependencies: resolveCatalogDependencies( + serverPackageJson.dependencies, + rootPackageJson.workspaces.catalog, + "apps/server", + ), + overrides: resolveCatalogDependencies( + rootPackageJson.overrides, + rootPackageJson.workspaces.catalog, + "apps/server", + ), }; - pkg.dependencies = resolveCatalogDependencies( - pkg.dependencies, - rootPackageJson.workspaces.catalog, - "apps/server dependencies", - ); - pkg.overrides = resolveCatalogDependencies( - pkg.overrides, - rootPackageJson.workspaces.catalog, - "root overrides", - ); - const original = yield* fs.readFileString(packageJsonPath); yield* fs.writeFileString(backupPath, original); yield* fs.writeFileString(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`); - yield* Effect.log("[cli] Resolved package.json for publish"); + yield* Effect.log("[cli] Prepared package.json for publish"); const iconBackups = yield* applyPublishIconOverrides(repoRoot, serverDir); return { iconBackups }; diff --git a/apps/server/src/auth/Layers/AuthControlPlane.test.ts b/apps/server/src/auth/Layers/AuthControlPlane.test.ts index 9fc09112..280fbc16 100644 --- a/apps/server/src/auth/Layers/AuthControlPlane.test.ts +++ b/apps/server/src/auth/Layers/AuthControlPlane.test.ts @@ -2,7 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { Effect, Layer } from "effect"; -import { ServerConfigShape } from "../../config.ts"; +import type { ServerConfigShape } from "../../config.ts"; import { ServerConfig } from "../../config.ts"; import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts"; import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; diff --git a/apps/server/src/auth/Layers/AuthControlPlane.ts b/apps/server/src/auth/Layers/AuthControlPlane.ts index 98b21078..1bf4909e 100644 --- a/apps/server/src/auth/Layers/AuthControlPlane.ts +++ b/apps/server/src/auth/Layers/AuthControlPlane.ts @@ -10,8 +10,10 @@ import { layerConfig as SqlitePersistenceLayerLive } from "../../persistence/Lay import { AuthControlPlane, AuthControlPlaneError, - AuthControlPlaneShape, DEFAULT_SESSION_SUBJECT, +} from "../Services/AuthControlPlane.ts"; +import type { + AuthControlPlaneShape, IssuedBearerSession, IssuedPairingLink, } from "../Services/AuthControlPlane.ts"; diff --git a/apps/server/src/auth/Services/AuthControlPlane.ts b/apps/server/src/auth/Services/AuthControlPlane.ts index b59e330b..4b3cf474 100644 --- a/apps/server/src/auth/Services/AuthControlPlane.ts +++ b/apps/server/src/auth/Services/AuthControlPlane.ts @@ -5,7 +5,7 @@ import type { AuthSessionId, } from "@t3tools/contracts"; import { Data, DateTime, Duration, Effect, Context } from "effect"; -import { SessionRole } from "./SessionCredentialService"; +import type { SessionRole } from "./SessionCredentialService.ts"; export const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; diff --git a/apps/server/src/auth/utils.test.ts b/apps/server/src/auth/utils.test.ts index a767b77d..e7a540d8 100644 --- a/apps/server/src/auth/utils.test.ts +++ b/apps/server/src/auth/utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { deriveAuthClientMetadata } from "./utils"; +import { deriveAuthClientMetadata } from "./utils.ts"; describe("deriveAuthClientMetadata", () => { it("labels Electron user agents as Electron instead of Chrome", () => { diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 063d4332..4ae638db 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -5,13 +5,14 @@ import * as Layer from "effect/Layer"; import { Command } from "effect/unstable/cli"; import { NetService } from "@t3tools/shared/Net"; -import { cli } from "./cli"; -import { version } from "../package.json" with { type: "json" }; +import { cli } from "./cli.ts"; +import packageJson from "../package.json" with { type: "json" }; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); -Command.run(cli, { version }).pipe( - Effect.scoped, - Effect.provide(CliRuntimeLayer), - NodeRuntime.runMain, +NodeRuntime.runMain( + Command.run(cli, { version: packageJson.version }).pipe( + Effect.scoped, + Effect.provide(CliRuntimeLayer), + ), ); diff --git a/apps/server/src/bootstrap.test.ts b/apps/server/src/bootstrap.test.ts index 3fce6af9..e4cdfab1 100644 --- a/apps/server/src/bootstrap.test.ts +++ b/apps/server/src/bootstrap.test.ts @@ -10,7 +10,7 @@ import * as Fiber from "effect/Fiber"; import { TestClock } from "effect/testing"; import { vi } from "vitest"; -import { readBootstrapEnvelope, resolveFdPath } from "./bootstrap"; +import { readBootstrapEnvelope, resolveFdPath } from "./bootstrap.ts"; import { assertNone, assertSome } from "@effect/vitest/utils"; const openSyncInterceptor = vi.hoisted(() => ({ failPath: null as string | null })); diff --git a/apps/server/src/cli-config.test.ts b/apps/server/src/cli-config.test.ts index 6fa6e0c9..5adece73 100644 --- a/apps/server/src/cli-config.test.ts +++ b/apps/server/src/cli-config.test.ts @@ -5,8 +5,8 @@ import { ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect" import { NetService } from "@t3tools/shared/Net"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { deriveServerPaths } from "./config"; -import { resolveServerConfig } from "./cli"; +import { deriveServerPaths } from "./config.ts"; +import { resolveServerConfig } from "./cli.ts"; it.layer(NodeServices.layer)("cli config resolution", (it) => { const defaultObservabilityConfig = { diff --git a/apps/server/src/cli.test.ts b/apps/server/src/cli.test.ts index 7ebde010..acdf656e 100644 --- a/apps/server/src/cli.test.ts +++ b/apps/server/src/cli.test.ts @@ -37,8 +37,7 @@ import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); const runCli = (args: ReadonlyArray) => Command.runWith(cli, { version: "0.0.0" })(args); -const runCliWithRuntime = (args: ReadonlyArray) => - runCli(args).pipe(Effect.provide(CliRuntimeLayer)); +const runCliWithRuntime = (args: ReadonlyArray) => runCli(args); const captureStdout = (effect: Effect.Effect) => Effect.gen(function* () { @@ -147,7 +146,7 @@ const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Ef ); }); -it.layer(NodeServices.layer)("cli log-level parsing", (it) => { +it.layer(CliRuntimeLayer)("cli log-level parsing", (it) => { it.effect("accepts the built-in lowercase log-level flag values", () => runCliWithRuntime(["--log-level", "debug", "--version"]), ); diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index 5f737509..4fc23a1d 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -40,30 +40,31 @@ import { RuntimeMode, type ServerConfigShape, type StartupPresentation, -} from "./config"; -import { readBootstrapEnvelope } from "./bootstrap"; -import { expandHomePath, resolveBaseDir } from "./os-jank"; -import { runServer } from "./server"; +} from "./config.ts"; +import { readBootstrapEnvelope } from "./bootstrap.ts"; +import { expandHomePath, resolveBaseDir } from "./os-jank.ts"; +import { runServer } from "./server.ts"; import { AuthControlPlaneRuntimeLive } from "./auth/Layers/AuthControlPlane.ts"; import { formatIssuedPairingCredential, formatIssuedSession, formatPairingCredentialList, formatSessionList, -} from "./cliAuthFormat"; -import { AuthControlPlane, AuthControlPlaneShape } from "./auth/Services/AuthControlPlane.ts"; +} from "./cliAuthFormat.ts"; +import { AuthControlPlane } from "./auth/Services/AuthControlPlane.ts"; +import type { AuthControlPlaneShape } from "./auth/Services/AuthControlPlane.ts"; import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; -import { OrchestrationLayerLive } from "./orchestration/runtimeLayer"; +import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; -import { getAutoBootstrapDefaultModelSelection } from "./serverRuntimeStartup"; +import { getAutoBootstrapDefaultModelSelection } from "./serverRuntimeStartup.ts"; import { clearPersistedServerRuntimeState, readPersistedServerRuntimeState, -} from "./serverRuntimeState"; -import { WorkspacePaths } from "./workspace/Services/WorkspacePaths"; -import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths"; +} from "./serverRuntimeState.ts"; +import { WorkspacePaths } from "./workspace/Services/WorkspacePaths.ts"; +import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index ab3b7a56..c644b108 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -15,7 +15,7 @@ import { normalizeCodexModelSlug, readCodexAccountSnapshot, resolveCodexModelForAccount, -} from "./codexAppServerManager"; +} from "./codexAppServerManager.ts"; const asThreadId = (value: string): ThreadId => ThreadId.make(value); @@ -470,6 +470,458 @@ describe("startSession", () => { manager.stopAll(); } }); + + it("keeps the existing session alive when replacement startup fails before initialization", async () => { + const manager = new CodexAppServerManager(); + const existingContext = { + session: { + provider: "codex", + status: "ready", + threadId: asThreadId("thread-1"), + runtimeMode: "full-access", + createdAt: "2026-02-10T00:00:00.000Z", + updatedAt: "2026-02-10T00:00:00.000Z", + }, + }; + + ( + manager as unknown as { + sessions: Map; + } + ).sessions.set(asThreadId("thread-1"), existingContext); + + const disposeSession = vi + .spyOn( + manager as unknown as { + disposeSession: ( + context: typeof existingContext, + options?: { readonly emitLifecycleEvent?: boolean }, + ) => void; + }, + "disposeSession", + ) + .mockImplementation(() => {}); + const assertSupportedCodexCliVersion = vi + .spyOn( + manager as unknown as { + assertSupportedCodexCliVersion: (input: { + binaryPath: string; + cwd: string; + homePath?: string; + }) => void; + }, + "assertSupportedCodexCliVersion", + ) + .mockImplementation(() => {}); + const processCwd = vi.spyOn(process, "cwd").mockImplementation(() => { + throw new Error("cwd missing"); + }); + + try { + await expect( + manager.startSession({ + threadId: asThreadId("thread-1"), + provider: "codex", + binaryPath: "codex", + runtimeMode: "full-access", + }), + ).rejects.toThrow("cwd missing"); + + expect(disposeSession).not.toHaveBeenCalled(); + expect(assertSupportedCodexCliVersion).not.toHaveBeenCalled(); + expect( + ( + manager as unknown as { + sessions: Map; + } + ).sessions.get(asThreadId("thread-1")), + ).toBe(existingContext); + } finally { + disposeSession.mockRestore(); + assertSupportedCodexCliVersion.mockRestore(); + processCwd.mockRestore(); + ( + manager as unknown as { + sessions: Map; + } + ).sessions.clear(); + manager.stopAll(); + } + }); + + it("keeps the existing session mapped when replacement startup fails even if disposal would fail", async () => { + const manager = new CodexAppServerManager(); + const existingContext = { + session: { + provider: "codex", + status: "ready", + threadId: asThreadId("thread-1"), + runtimeMode: "full-access", + createdAt: "2026-02-10T00:00:00.000Z", + updatedAt: "2026-02-10T00:00:00.000Z", + }, + }; + + ( + manager as unknown as { + sessions: Map; + } + ).sessions.set(asThreadId("thread-1"), existingContext); + + const disposeSession = vi + .spyOn( + manager as unknown as { + disposeSession: ( + context: typeof existingContext, + options?: { readonly emitLifecycleEvent?: boolean }, + ) => void; + }, + "disposeSession", + ) + .mockImplementation(() => { + throw new Error("dispose failed"); + }); + const assertSupportedCodexCliVersion = vi + .spyOn( + manager as unknown as { + assertSupportedCodexCliVersion: (input: { + binaryPath: string; + cwd: string; + homePath?: string; + }) => void; + }, + "assertSupportedCodexCliVersion", + ) + .mockImplementation(() => {}); + const processCwd = vi.spyOn(process, "cwd").mockImplementation(() => { + throw new Error("cwd missing"); + }); + + try { + await expect( + manager.startSession({ + threadId: asThreadId("thread-1"), + provider: "codex", + binaryPath: "codex", + runtimeMode: "full-access", + }), + ).rejects.toThrow("cwd missing"); + + expect(disposeSession).not.toHaveBeenCalled(); + expect(assertSupportedCodexCliVersion).not.toHaveBeenCalled(); + expect( + ( + manager as unknown as { + sessions: Map; + } + ).sessions.get(asThreadId("thread-1")), + ).toBe(existingContext); + } finally { + disposeSession.mockRestore(); + assertSupportedCodexCliVersion.mockRestore(); + processCwd.mockRestore(); + ( + manager as unknown as { + sessions: Map; + } + ).sessions.clear(); + manager.stopAll(); + } + }); + + it("stops both the active and in-flight replacement sessions for the thread", () => { + const manager = new CodexAppServerManager(); + const threadId = asThreadId("thread-1"); + const existingContext = { + session: { + provider: "codex", + status: "ready", + threadId, + runtimeMode: "full-access", + createdAt: "2026-02-10T00:00:00.000Z", + updatedAt: "2026-02-10T00:00:00.000Z", + }, + pending: new Map(), + pendingApprovals: new Map(), + pendingUserInputs: new Map(), + collabReceiverTurns: new Map(), + stopping: false, + output: { + close: vi.fn(), + }, + child: { + killed: true, + }, + }; + ( + manager as unknown as { + sessions: Map; + } + ).sessions.set(threadId, existingContext); + + const pendingContext = { + session: { + provider: "codex", + status: "connecting", + threadId, + runtimeMode: "full-access", + createdAt: "2026-02-10T00:00:01.000Z", + updatedAt: "2026-02-10T00:00:01.000Z", + }, + pending: new Map(), + pendingApprovals: new Map(), + pendingUserInputs: new Map(), + collabReceiverTurns: new Map(), + stopping: false, + output: { + close: vi.fn(), + }, + child: { + killed: true, + }, + }; + ( + manager as unknown as { + pendingSessions: Map; + } + ).pendingSessions.set(threadId, pendingContext); + + const disposeSession = vi.spyOn( + manager as unknown as { + disposeSession: ( + context: unknown, + options?: { readonly emitLifecycleEvent?: boolean }, + ) => void; + }, + "disposeSession", + ); + + try { + manager.stopSession(threadId); + + expect(disposeSession).toHaveBeenCalledTimes(2); + expect(disposeSession.mock.calls[0]?.[0]).toBe(pendingContext); + expect(disposeSession.mock.calls[1]?.[0]).toBe(existingContext); + expect( + ( + manager as unknown as { + sessions: Map; + } + ).sessions.has(threadId), + ).toBe(false); + expect( + ( + manager as unknown as { + pendingSessions: Map; + } + ).pendingSessions.has(threadId), + ).toBe(false); + } finally { + disposeSession.mockRestore(); + ( + manager as unknown as { + sessions: Map; + pendingSessions: Map; + } + ).sessions.clear(); + ( + manager as unknown as { + pendingSessions: Map; + } + ).pendingSessions.clear(); + manager.stopAll(); + } + }); + + it("replaces an in-flight pending startup before beginning a new session", async () => { + const manager = new CodexAppServerManager(); + const threadId = asThreadId("thread-pending-replacement"); + const pendingContext = { + session: { + provider: "codex", + status: "connecting", + threadId, + runtimeMode: "full-access", + createdAt: "2026-02-10T00:00:01.000Z", + updatedAt: "2026-02-10T00:00:01.000Z", + }, + pending: new Map(), + pendingApprovals: new Map(), + pendingUserInputs: new Map(), + collabReceiverTurns: new Map(), + stopping: false, + output: { close: vi.fn() }, + child: { killed: true }, + }; + ( + manager as unknown as { + pendingSessions: Map; + } + ).pendingSessions.set(threadId, pendingContext); + + const disposeSession = vi.spyOn( + manager as unknown as { + disposeSession: ( + context: unknown, + options?: { readonly emitLifecycleEvent?: boolean }, + ) => void; + }, + "disposeSession", + ); + const processCwd = vi.spyOn(process, "cwd").mockImplementation(() => { + throw new Error("cwd missing"); + }); + + try { + await expect( + manager.startSession({ + threadId, + provider: "codex", + binaryPath: "codex", + runtimeMode: "full-access", + }), + ).rejects.toThrow("cwd missing"); + + expect(disposeSession).toHaveBeenCalledWith( + pendingContext, + expect.objectContaining({ emitLifecycleEvent: false }), + ); + expect( + ( + manager as unknown as { + pendingSessions: Map; + } + ).pendingSessions.has(threadId), + ).toBe(false); + } finally { + disposeSession.mockRestore(); + processCwd.mockRestore(); + manager.stopAll(); + } + }); + + it("removes pending startup sessions when the child exits before ready", () => { + const manager = new CodexAppServerManager(); + const threadId = asThreadId("thread-exit-pending"); + const exitHandlers: Array<(code: number | null, signal: NodeJS.Signals | null) => void> = []; + type PendingExitTestContext = { + session: { + provider: "codex"; + status: "connecting"; + threadId: ThreadId; + runtimeMode: "full-access"; + createdAt: string; + updatedAt: string; + }; + pending: Map; + pendingApprovals: Map; + pendingUserInputs: Map; + collabReceiverTurns: Map; + stopping: boolean; + output: { + on: ReturnType; + close: ReturnType; + }; + child: { + stderr: { + on: ReturnType; + }; + on: ReturnType; + killed: boolean; + }; + }; + + const context: PendingExitTestContext = { + session: { + provider: "codex", + status: "connecting", + threadId, + runtimeMode: "full-access", + createdAt: "2026-02-10T00:00:01.000Z", + updatedAt: "2026-02-10T00:00:01.000Z", + }, + pending: new Map(), + pendingApprovals: new Map(), + pendingUserInputs: new Map(), + collabReceiverTurns: new Map(), + stopping: false, + output: { + on: vi.fn(), + close: vi.fn(), + }, + child: { + stderr: { + on: vi.fn(), + }, + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (event === "exit") { + exitHandlers.push( + handler as (code: number | null, signal: NodeJS.Signals | null) => void, + ); + } + }), + killed: false, + }, + }; + + ( + manager as unknown as { + pendingSessions: Map; + } + ).pendingSessions.set(threadId, context); + + ( + manager as unknown as { + attachProcessListeners: (context: PendingExitTestContext) => void; + } + ).attachProcessListeners(context); + + expect(exitHandlers).toHaveLength(1); + + exitHandlers[0]!(1, null); + + expect( + ( + manager as unknown as { + pendingSessions: Map; + } + ).pendingSessions.has(threadId), + ).toBe(false); + }); + + it("does not treat pending startup sessions as active via hasSession", () => { + const manager = new CodexAppServerManager(); + const threadId = asThreadId("thread-pending-only"); + + ( + manager as unknown as { + pendingSessions: Map< + ThreadId, + { + session: { + provider: "codex"; + status: "connecting"; + threadId: ThreadId; + runtimeMode: "full-access"; + createdAt: string; + updatedAt: string; + }; + } + >; + } + ).pendingSessions.set(threadId, { + session: { + provider: "codex", + status: "connecting", + threadId, + runtimeMode: "full-access", + createdAt: "2026-02-10T00:00:01.000Z", + updatedAt: "2026-02-10T00:00:01.000Z", + }, + }); + + expect(manager.hasSession(threadId)).toBe(false); + }); }); describe("sendTurn", () => { diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 230ba8e3..4f1b8a7f 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -25,16 +25,16 @@ import { formatCodexCliUpgradeMessage, isCodexCliVersionSupported, parseCodexCliVersion, -} from "./provider/codexCliVersion"; +} from "./provider/codexCliVersion.ts"; import { readCodexAccountSnapshot, resolveCodexModelForAccount, type CodexAccountSnapshot, -} from "./provider/codexAccount"; -import { buildCodexInitializeParams, killCodexChildProcess } from "./provider/codexAppServer"; +} from "./provider/codexAccount.ts"; +import { buildCodexInitializeParams, killCodexChildProcess } from "./provider/codexAppServer.ts"; -export { buildCodexInitializeParams } from "./provider/codexAppServer"; -export { readCodexAccountSnapshot, resolveCodexModelForAccount } from "./provider/codexAccount"; +export { buildCodexInitializeParams } from "./provider/codexAppServer.ts"; +export { readCodexAccountSnapshot, resolveCodexModelForAccount } from "./provider/codexAccount.ts"; type PendingRequestKey = string; @@ -437,6 +437,7 @@ export interface CodexAppServerManagerEvents { export class CodexAppServerManager extends EventEmitter { private readonly sessions = new Map(); + private readonly pendingSessions = new Map(); private runPromise: (effect: Effect.Effect) => Promise; constructor(services?: Context.Context) { @@ -448,8 +449,27 @@ export class CodexAppServerManager extends EventEmitter([...this.sessions.keys(), ...this.pendingSessions.keys()]); + for (const threadId of threadIds) { this.stopSession(threadId); } } @@ -993,7 +1064,12 @@ export class CodexAppServerManager extends EventEmitter ({ runProcess: vi.fn(), })); -import { runProcess } from "../../processRunner"; +import { runProcess } from "../../processRunner.ts"; import { GitHubCli } from "../Services/GitHubCli.ts"; import { GitHubCliLive } from "./GitHubCli.ts"; diff --git a/apps/server/src/git/Layers/GitHubCli.ts b/apps/server/src/git/Layers/GitHubCli.ts index 1a687b0e..dbacdf63 100644 --- a/apps/server/src/git/Layers/GitHubCli.ts +++ b/apps/server/src/git/Layers/GitHubCli.ts @@ -1,7 +1,7 @@ import { Effect, Layer, Result, Schema, SchemaIssue } from "effect"; import { TrimmedNonEmptyString } from "@t3tools/contracts"; -import { runProcess } from "../../processRunner"; +import { runProcess } from "../../processRunner.ts"; import { GitHubCliError } from "@t3tools/contracts"; import { GitHubCli, diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index fd991273..4af752f4 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -630,6 +630,7 @@ function makeManager(input?: { ghScenario?: FakeGhScenario; textGeneration?: Partial; setupScriptRunner?: ProjectSetupScriptRunnerShape; + serverSettingsOverrides?: Parameters[0]; }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); const textGeneration = createTextGeneration(input?.textGeneration); @@ -637,7 +638,7 @@ function makeManager(input?: { prefix: "t3-git-manager-test-", }); - const serverSettingsLayer = ServerSettingsService.layerTest(); + const serverSettingsLayer = ServerSettingsService.layerTest(input?.serverSettingsOverrides); const gitCoreLayer = GitCoreLive.pipe( Layer.provideMerge(NodeServices.layer), @@ -1294,6 +1295,93 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect( + "falls back from copilot git text generation settings before generating commit text", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + fs.writeFileSync(path.join(repoDir, "copilot-fallback.txt"), "fallback\n"); + yield* runGit(repoDir, ["add", "copilot-fallback.txt"]); + + let seenModelSelection: ModelSelection | null = null; + const { manager } = yield* makeManager({ + serverSettingsOverrides: { + textGenerationModelSelection: { + provider: "copilot", + model: "gpt-5-mini", + }, + }, + textGeneration: { + generateCommitMessage: (input) => + Effect.sync(() => { + seenModelSelection = input.modelSelection; + return { subject: "Fallback commit", body: "" }; + }), + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + }); + + expect(result.commit.status).toBe("created"); + expect(seenModelSelection).toEqual({ + provider: "codex", + model: "gpt-5.4-mini", + }); + }), + ); + + it.effect( + "falls back from copilot git text generation settings before generating PR content", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/copilot-pr-fallback"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + fs.writeFileSync(path.join(repoDir, "pr-fallback.txt"), "fallback\n"); + yield* runGit(repoDir, ["add", "pr-fallback.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Prepare PR fallback"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/copilot-pr-fallback"]); + + let seenModelSelection: ModelSelection | null = null; + const { manager } = yield* makeManager({ + serverSettingsOverrides: { + textGenerationModelSelection: { + provider: "copilot", + model: "gpt-5-mini", + }, + }, + textGeneration: { + generatePrContent: (input) => + Effect.sync(() => { + seenModelSelection = input.modelSelection; + return { title: "Fallback PR", body: "Generated" }; + }), + }, + ghScenario: { + prListSequence: [JSON.stringify([]), JSON.stringify([])], + createdPrUrl: "https://github.com/pingdotgg/codething-mvp/pull/999", + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "create_pr", + }); + + expect(result.pr.status).toBe("created"); + expect(seenModelSelection).toEqual({ + provider: "codex", + model: "gpt-5.4-mini", + }); + }), + ); + it.effect("uses custom commit message when provided", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index a84427a1..dadf2f7e 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -38,7 +38,8 @@ import { type GitManagerShape, type GitRunStackedActionOptions, } from "../Services/GitManager.ts"; -import { GitCore, GitStatusDetails } from "../Services/GitCore.ts"; +import { GitCore } from "../Services/GitCore.ts"; +import type { GitStatusDetails } from "../Services/GitCore.ts"; import { GitHubCli, type GitHubPullRequestSummary } from "../Services/GitHubCli.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; import { ProjectSetupScriptRunner } from "../../project/Services/ProjectSetupScriptRunner.ts"; diff --git a/apps/server/src/git/Layers/RoutingTextGeneration.ts b/apps/server/src/git/Layers/RoutingTextGeneration.ts index 0324a47c..c14b4d9e 100644 --- a/apps/server/src/git/Layers/RoutingTextGeneration.ts +++ b/apps/server/src/git/Layers/RoutingTextGeneration.ts @@ -4,7 +4,8 @@ * request input. * * When `modelSelection.provider` is `"claudeAgent"` the request is forwarded to - * the Claude layer; Copilot and Codex both use the Codex text-generation path. + * the Claude layer; unsupported or absent providers fall back to the Codex + * implementation as a defensive last resort. * * @module RoutingTextGeneration */ @@ -12,6 +13,7 @@ import { Effect, Layer, Context } from "effect"; import { TextGeneration, + isTextGenerationProvider, type TextGenerationProvider, type TextGenerationShape, } from "../Services/TextGeneration.ts"; @@ -38,15 +40,25 @@ const makeRoutingTextGeneration = Effect.gen(function* () { const codex = yield* CodexTextGen; const claude = yield* ClaudeTextGen; - const route = (provider?: TextGenerationProvider): TextGenerationShape => - provider === "claudeAgent" ? claude : codex; + const route = (provider?: TextGenerationProvider): TextGenerationShape => { + if (provider === "claudeAgent") { + return claude; + } + return codex; + }; + + const resolveProvider = (provider: string | undefined): TextGenerationProvider => + isTextGenerationProvider(provider as never) ? (provider as TextGenerationProvider) : "codex"; return { generateCommitMessage: (input) => - route(input.modelSelection.provider).generateCommitMessage(input), - generatePrContent: (input) => route(input.modelSelection.provider).generatePrContent(input), - generateBranchName: (input) => route(input.modelSelection.provider).generateBranchName(input), - generateThreadTitle: (input) => route(input.modelSelection.provider).generateThreadTitle(input), + route(resolveProvider(input.modelSelection.provider)).generateCommitMessage(input), + generatePrContent: (input) => + route(resolveProvider(input.modelSelection.provider)).generatePrContent(input), + generateBranchName: (input) => + route(resolveProvider(input.modelSelection.provider)).generateBranchName(input), + generateThreadTitle: (input) => + route(resolveProvider(input.modelSelection.provider)).generateThreadTitle(input), } satisfies TextGenerationShape; }); diff --git a/apps/server/src/git/Services/GitHubCli.ts b/apps/server/src/git/Services/GitHubCli.ts index 216c24bf..81a53761 100644 --- a/apps/server/src/git/Services/GitHubCli.ts +++ b/apps/server/src/git/Services/GitHubCli.ts @@ -8,7 +8,7 @@ import { Context } from "effect"; import type { Effect } from "effect"; -import type { ProcessRunResult } from "../../processRunner"; +import type { ProcessRunResult } from "../../processRunner.ts"; import type { GitHubCliError } from "@t3tools/contracts"; export interface GitHubPullRequestSummary { diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index 2ba4a6b2..b6802b6a 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -8,12 +8,23 @@ */ import { Context } from "effect"; import type { Effect } from "effect"; -import type { ChatAttachment, ModelSelection } from "@t3tools/contracts"; +import { + GIT_TEXT_GENERATION_PROVIDERS, + type ChatAttachment, + type ModelSelection, + type ProviderKind, +} from "@t3tools/contracts"; import type { TextGenerationError } from "@t3tools/contracts"; /** Providers that support git text generation (commit messages, PR content, branch names). */ -export type TextGenerationProvider = "codex" | "copilot" | "claudeAgent"; +export type TextGenerationProvider = (typeof GIT_TEXT_GENERATION_PROVIDERS)[number]; + +export function isTextGenerationProvider( + provider: ProviderKind | undefined, +): provider is TextGenerationProvider { + return provider === "codex" || provider === "claudeAgent"; +} export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 7420156b..88cc5ada 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -15,12 +15,12 @@ import { ATTACHMENTS_ROUTE_PREFIX, normalizeAttachmentRelativePath, resolveAttachmentRelativePath, -} from "./attachmentPaths"; -import { resolveAttachmentPathById } from "./attachmentStore"; -import { resolveStaticDir, ServerConfig } from "./config"; +} from "./attachmentPaths.ts"; +import { resolveAttachmentPathById } from "./attachmentStore.ts"; +import { resolveStaticDir, ServerConfig } from "./config.ts"; import { decodeOtlpTraceRecords } from "./observability/TraceRecord.ts"; import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; -import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver"; +import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts"; import { ServerAuth } from "./auth/Services/ServerAuth.ts"; import { respondToAuthError } from "./auth/http.ts"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 5a6be309..46a3e21c 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -3,7 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { assertFailure } from "@effect/vitest/utils"; import { Cause, Effect, FileSystem, Layer, Logger, Path, Schema } from "effect"; -import { ServerConfig } from "./config"; +import { ServerConfig } from "./config.ts"; import { DEFAULT_KEYBINDINGS, @@ -13,7 +13,7 @@ import { compileResolvedKeybindingRule, compileResolvedKeybindingsConfig, parseKeybindingShortcut, -} from "./keybindings"; +} from "./keybindings.ts"; import { KeybindingsConfigError } from "@t3tools/contracts"; const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index b473f77c..07ae9156 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -19,7 +19,7 @@ import { THREAD_JUMP_KEYBINDING_COMMANDS, type ServerConfigIssue, } from "@t3tools/contracts"; -import { Mutable } from "effect/Types"; +import type { Mutable } from "effect/Types"; import { Array, Cache, @@ -44,7 +44,7 @@ import { Stream, } from "effect"; import * as Semaphore from "effect/Semaphore"; -import { ServerConfig } from "./config"; +import { ServerConfig } from "./config.ts"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; type WhenToken = diff --git a/apps/server/src/observability/LocalFileTracer.ts b/apps/server/src/observability/LocalFileTracer.ts index cde5a176..a3d43ea1 100644 --- a/apps/server/src/observability/LocalFileTracer.ts +++ b/apps/server/src/observability/LocalFileTracer.ts @@ -1,7 +1,8 @@ import type * as Exit from "effect/Exit"; import { Effect, Option, Tracer } from "effect"; -import { EffectTraceRecord, spanToTraceRecord } from "./TraceRecord.ts"; +import { spanToTraceRecord } from "./TraceRecord.ts"; +import type { EffectTraceRecord } from "./TraceRecord.ts"; import { makeTraceSink, type TraceSink } from "./TraceSink.ts"; export interface LocalFileTracerOptions { @@ -27,12 +28,16 @@ class LocalFileSpan implements Tracer.Span { status: Tracer.SpanStatus; attributes: Map; events: Array<[name: string, startTime: bigint, attributes: Record]>; + private readonly delegate: Tracer.Span; + private readonly push: (record: EffectTraceRecord) => void; constructor( options: Parameters[0], - private readonly delegate: Tracer.Span, - private readonly push: (record: EffectTraceRecord) => void, + delegate: Tracer.Span, + push: (record: EffectTraceRecord) => void, ) { + this.delegate = delegate; + this.push = push; this.name = delegate.name; this.spanId = delegate.spanId; this.traceId = delegate.traceId; diff --git a/apps/server/src/open.test.ts b/apps/server/src/open.test.ts index 382daab2..77e85072 100644 --- a/apps/server/src/open.test.ts +++ b/apps/server/src/open.test.ts @@ -8,7 +8,7 @@ import { launchDetached, resolveAvailableEditors, resolveEditorLaunch, -} from "./open"; +} from "./open.ts"; it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { it.effect("returns commands for command-based editors", () => diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index 698cc008..98dfcaf4 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -7,10 +7,9 @@ * @module Open */ import { spawn } from "node:child_process"; -import { accessSync, constants, statSync } from "node:fs"; -import { extname, join } from "node:path"; import { EDITORS, OpenError, type EditorId } from "@t3tools/contracts"; +import { isCommandAvailable, type CommandAvailabilityOptions } from "@t3tools/shared/shell"; import { Context, Effect, Layer } from "effect"; // ============================== @@ -18,6 +17,7 @@ import { Context, Effect, Layer } from "effect"; // ============================== export { OpenError }; +export { isCommandAvailable } from "@t3tools/shared/shell"; export interface OpenInEditorInput { readonly cwd: string; @@ -29,11 +29,6 @@ interface EditorLaunch { readonly args: ReadonlyArray; } -interface CommandAvailabilityOptions { - readonly platform?: NodeJS.Platform; - readonly env?: NodeJS.ProcessEnv; -} - const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/; function parseTargetPathAndPosition(target: string): { @@ -106,111 +101,6 @@ function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { } } -function stripWrappingQuotes(value: string): string { - return value.replace(/^"+|"+$/g, ""); -} - -function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string { - return env.PATH ?? env.Path ?? env.path ?? ""; -} - -function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray { - const rawValue = env.PATHEXT; - const fallback = [".COM", ".EXE", ".BAT", ".CMD"]; - if (!rawValue) return fallback; - - const parsed = rawValue - .split(";") - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0) - .map((entry) => (entry.startsWith(".") ? entry.toUpperCase() : `.${entry.toUpperCase()}`)); - return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback; -} - -function resolveCommandCandidates( - command: string, - platform: NodeJS.Platform, - windowsPathExtensions: ReadonlyArray, -): ReadonlyArray { - if (platform !== "win32") return [command]; - const extension = extname(command); - const normalizedExtension = extension.toUpperCase(); - - if (extension.length > 0 && windowsPathExtensions.includes(normalizedExtension)) { - const commandWithoutExtension = command.slice(0, -extension.length); - return Array.from( - new Set([ - command, - `${commandWithoutExtension}${normalizedExtension}`, - `${commandWithoutExtension}${normalizedExtension.toLowerCase()}`, - ]), - ); - } - - const candidates: string[] = []; - for (const extension of windowsPathExtensions) { - candidates.push(`${command}${extension}`); - candidates.push(`${command}${extension.toLowerCase()}`); - } - return Array.from(new Set(candidates)); -} - -function isExecutableFile( - filePath: string, - platform: NodeJS.Platform, - windowsPathExtensions: ReadonlyArray, -): boolean { - try { - const stat = statSync(filePath); - if (!stat.isFile()) return false; - if (platform === "win32") { - const extension = extname(filePath); - if (extension.length === 0) return false; - return windowsPathExtensions.includes(extension.toUpperCase()); - } - accessSync(filePath, constants.X_OK); - return true; - } catch { - return false; - } -} - -function resolvePathDelimiter(platform: NodeJS.Platform): string { - return platform === "win32" ? ";" : ":"; -} - -export function isCommandAvailable( - command: string, - options: CommandAvailabilityOptions = {}, -): boolean { - const platform = options.platform ?? process.platform; - const env = options.env ?? process.env; - const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; - const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions); - - if (command.includes("/") || command.includes("\\")) { - return commandCandidates.some((candidate) => - isExecutableFile(candidate, platform, windowsPathExtensions), - ); - } - - const pathValue = resolvePathEnvironmentVariable(env); - if (pathValue.length === 0) return false; - const pathEntries = pathValue - .split(resolvePathDelimiter(platform)) - .map((entry) => stripWrappingQuotes(entry.trim())) - .filter((entry) => entry.length > 0); - - for (const pathEntry of pathEntries) { - for (const candidate of commandCandidates) { - if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) { - return true; - } - } - } - return false; -} - export function resolveAvailableEditors( platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 0b1b203b..71445b46 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -21,8 +21,8 @@ import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts"; -import { CheckpointStoreError } from "../../checkpointing/Errors.ts"; -import { OrchestrationDispatchError } from "../Errors.ts"; +import type { CheckpointStoreError } from "../../checkpointing/Errors.ts"; +import type { OrchestrationDispatchError } from "../Errors.ts"; import { isGitRepository } from "../../git/Utils.ts"; import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts"; import { WorkspaceEntries } from "../../workspace/Services/WorkspaceEntries.ts"; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index aa1109a4..d981ae0d 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1284,6 +1284,14 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } return; } + // Only approval-requested activities should create pending-approval + // rows. Other activity kinds that happen to carry a requestId + // (e.g. user-input.requested / user-input.resolved) must not + // pollute this projection — they have their own accounting via + // derivePendingUserInputCountFromActivities. + if (event.payload.activity.kind !== "approval.requested") { + return; + } if (Option.isSome(existingRow) && existingRow.value.status === "resolved") { return; } diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 7a4913ca..e00c1681 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -101,6 +101,7 @@ describe("ProviderCommandReactor", () => { readonly baseDir?: string; readonly threadModelSelection?: ModelSelection; readonly sessionModelSwitch?: "unsupported" | "in-session"; + readonly serverSettingsOverrides?: Parameters[0]; }) { const now = new Date().toISOString(); const baseDir = input?.baseDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-")); @@ -262,7 +263,7 @@ describe("ProviderCommandReactor", () => { generateThreadTitle, }), ), - Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(ServerSettingsService.layerTest(input?.serverSettingsOverrides)), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index e4e772dc..db2bd2d4 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -19,7 +19,8 @@ import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; import { GitCore } from "../../git/Services/GitCore.ts"; import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts"; import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; -import { ProviderAdapterRequestError, ProviderServiceError } from "../../provider/Errors.ts"; +import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; +import type { ProviderServiceError } from "../../provider/Errors.ts"; import { TextGeneration } from "../../git/Services/TextGeneration.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index 177a23ec..811d9b8a 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -6,10 +6,10 @@ import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; -import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore"; -import { ServerConfig } from "../config"; -import { parseBase64DataUrl } from "../imageMime"; -import { WorkspacePaths } from "../workspace/Services/WorkspacePaths"; +import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts"; +import { ServerConfig } from "../config.ts"; +import { parseBase64DataUrl } from "../imageMime.ts"; +import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts"; export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => Effect.gen(function* () { diff --git a/apps/server/src/os-jank.test.ts b/apps/server/src/os-jank.test.ts index 89eba62d..c49a4120 100644 --- a/apps/server/src/os-jank.test.ts +++ b/apps/server/src/os-jank.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { fixPath } from "./os-jank"; +import { fixPath } from "./os-jank.ts"; describe("fixPath", () => { it("hydrates PATH on linux using the resolved login shell", () => { @@ -53,7 +53,120 @@ describe("fixPath", () => { expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); }); - it("does nothing outside macOS and linux even when SHELL is set", () => { + it("repairs PATH on Windows by merging PowerShell PATH with inherited PATH", () => { + const env: NodeJS.ProcessEnv = { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }; + const readWindowsEnvironment = vi.fn(() => ({ + PATH: "C:\\Custom\\Bin;C:\\Windows\\System32", + })); + const isWindowsCommandAvailable = vi.fn(() => true); + + fixPath({ + env, + platform: "win32", + readWindowsEnvironment, + isWindowsCommandAvailable, + }); + + expect(readWindowsEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false }); + expect(env.PATH).toBe( + [ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Custom\\Bin", + "C:\\Windows\\System32", + ].join(";"), + ); + }); + + it("applies profile-derived fnm variables on Windows when node is missing", () => { + const env: NodeJS.ProcessEnv = { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }; + const readWindowsEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => + options?.loadProfile + ? { + PATH: "C:\\Profile\\Node;C:\\Windows\\System32", + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + } + : { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }, + ); + const isWindowsCommandAvailable = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true); + + fixPath({ + env, + platform: "win32", + readWindowsEnvironment, + isWindowsCommandAvailable, + }); + + expect(env.PATH).toBe( + [ + "C:\\Profile\\Node", + "C:\\Windows\\System32", + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Custom\\Bin", + ].join(";"), + ); + expect(env.FNM_DIR).toBe("C:\\Users\\testuser\\AppData\\Roaming\\fnm"); + expect(env.FNM_MULTISHELL_PATH).toBe( + "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + ); + }); + + it("preserves baseline PATH on Windows when the profile probe fails", () => { + const env: NodeJS.ProcessEnv = { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + USERPROFILE: "C:\\Users\\testuser", + }; + const readWindowsEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => { + if (options?.loadProfile) { + throw new Error("profile load failed"); + } + return { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }; + }, + ); + const isWindowsCommandAvailable = vi.fn(() => false); + + fixPath({ + env, + platform: "win32", + readWindowsEnvironment, + isWindowsCommandAvailable, + }); + + expect(env.PATH).toBe( + [ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Custom\\Bin", + "C:\\Windows\\System32", + ].join(";"), + ); + }); + + it("does nothing on unsupported platforms", () => { const env: NodeJS.ProcessEnv = { SHELL: "C:/Program Files/Git/bin/bash.exe", PATH: "C:\\Windows\\System32", @@ -62,7 +175,7 @@ describe("fixPath", () => { fixPath({ env, - platform: "win32", + platform: "freebsd", readPath, }); diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 33b67128..47574c14 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -1,12 +1,21 @@ import * as OS from "node:os"; import { Effect, Path } from "effect"; import { + readPathFromLoginShell, + readEnvironmentFromWindowsShell, + resolveWindowsEnvironment, + type CommandAvailabilityOptions, + type WindowsShellEnvironmentReader, listLoginShellCandidates, mergePathEntries, readPathFromLaunchctl, - readPathFromLoginShell, } from "@t3tools/shared/shell"; +type WindowsCommandAvailabilityChecker = ( + command: string, + options?: CommandAvailabilityOptions, +) => boolean; + function logPathHydrationWarning(message: string, error?: unknown): void { console.warn(`[server] ${message}`, error instanceof Error ? error.message : (error ?? "")); } @@ -16,19 +25,36 @@ export function fixPath( env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; readPath?: typeof readPathFromLoginShell; + readWindowsEnvironment?: WindowsShellEnvironmentReader; + isWindowsCommandAvailable?: WindowsCommandAvailabilityChecker; readLaunchctlPath?: typeof readPathFromLaunchctl; userShell?: string; logWarning?: (message: string, error?: unknown) => void; } = {}, ): void { const platform = options.platform ?? process.platform; - if (platform !== "darwin" && platform !== "linux") return; - const env = options.env ?? process.env; const logWarning = options.logWarning ?? logPathHydrationWarning; const readPath = options.readPath ?? readPathFromLoginShell; try { + if (platform === "win32") { + const repairedEnvironment = resolveWindowsEnvironment(env, { + readEnvironment: options.readWindowsEnvironment ?? readEnvironmentFromWindowsShell, + ...(options.isWindowsCommandAvailable + ? { commandAvailable: options.isWindowsCommandAvailable } + : {}), + }); + for (const [key, value] of Object.entries(repairedEnvironment)) { + if (value !== undefined) { + env[key] = value; + } + } + return; + } + + if (platform !== "darwin" && platform !== "linux") return; + let shellPath: string | undefined; for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) { try { diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 01c649f7..023e3bca 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -37,6 +37,7 @@ import Migration0021 from "./Migrations/021_AuthSessionClientMetadata.ts"; import Migration0022 from "./Migrations/022_AuthSessionLastConnectedAt.ts"; import Migration0023 from "./Migrations/023_ProjectionThreadShellSummary.ts"; import Migration0024 from "./Migrations/024_BackfillProjectionThreadShellSummary.ts"; +import Migration0025 from "./Migrations/025_CleanupInvalidProjectionPendingApprovals.ts"; /** * Migration loader with all migrations defined inline. @@ -73,6 +74,7 @@ export const migrationEntries = [ [22, "AuthSessionLastConnectedAt", Migration0022], [23, "ProjectionThreadShellSummary", Migration0023], [24, "BackfillProjectionThreadShellSummary", Migration0024], + [25, "CleanupInvalidProjectionPendingApprovals", Migration0025], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts b/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts new file mode 100644 index 00000000..060cd471 --- /dev/null +++ b/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts @@ -0,0 +1,196 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("025_CleanupInvalidProjectionPendingApprovals", (it) => { + it.effect("removes pending-approval rows that do not come from approval requests", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 24 }); + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + created_at, + updated_at, + archived_at, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + deleted_at + ) + VALUES + ( + 'thread-valid', + 'project-1', + 'Valid thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'approval-required', + 'default', + NULL, + NULL, + 'turn-valid', + '2026-04-13T00:00:00.000Z', + '2026-04-13T00:00:00.000Z', + NULL, + NULL, + 2, + 0, + 0, + NULL + ), + ( + 'thread-invalid', + 'project-1', + 'Invalid thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'approval-required', + 'default', + NULL, + NULL, + 'turn-invalid', + '2026-04-13T00:00:00.000Z', + '2026-04-13T00:00:00.000Z', + NULL, + NULL, + 1, + 0, + 0, + NULL + ) + `; + + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + VALUES + ( + 'activity-approval-requested', + 'thread-valid', + 'turn-valid', + 'approval', + 'approval.requested', + 'Command approval requested', + '{"requestId":"approval-valid","requestKind":"command"}', + NULL, + '2026-04-13T00:01:00.000Z' + ), + ( + 'activity-user-input-requested', + 'thread-invalid', + 'turn-invalid', + 'info', + 'user-input.requested', + 'User input requested', + '{"requestId":"input-invalid","questions":[{"id":"scope","header":"Scope","question":"What should I inspect?","options":[{"label":"Server","description":"Inspect server code."}]}]}', + NULL, + '2026-04-13T00:02:00.000Z' + ) + `; + + yield* sql` + INSERT INTO projection_pending_approvals ( + request_id, + thread_id, + turn_id, + status, + decision, + created_at, + resolved_at + ) + VALUES + ( + 'approval-valid', + 'thread-valid', + 'turn-valid', + 'pending', + NULL, + '2026-04-13T00:01:00.000Z', + NULL + ), + ( + 'input-invalid', + 'thread-invalid', + 'turn-invalid', + 'pending', + NULL, + '2026-04-13T00:02:00.000Z', + NULL + ), + ( + 'input-invalid-resolved', + 'thread-valid', + 'turn-valid', + 'resolved', + NULL, + '2026-04-13T00:03:00.000Z', + '2026-04-13T00:04:00.000Z' + ) + `; + + yield* runMigrations({ toMigrationInclusive: 25 }); + + const approvalRows = yield* sql<{ + readonly requestId: string; + readonly status: string; + }>` + SELECT + request_id AS "requestId", + status + FROM projection_pending_approvals + ORDER BY request_id ASC + `; + assert.deepStrictEqual(approvalRows, [ + { + requestId: "approval-valid", + status: "pending", + }, + ]); + + const threadCounts = yield* sql<{ + readonly threadId: string; + readonly pendingApprovalCount: number; + }>` + SELECT + thread_id AS "threadId", + pending_approval_count AS "pendingApprovalCount" + FROM projection_threads + ORDER BY thread_id ASC + `; + assert.deepStrictEqual(threadCounts, [ + { + threadId: "thread-invalid", + pendingApprovalCount: 0, + }, + { + threadId: "thread-valid", + pendingApprovalCount: 1, + }, + ]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.ts b/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.ts new file mode 100644 index 00000000..33a6512c --- /dev/null +++ b/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.ts @@ -0,0 +1,27 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + DELETE FROM projection_pending_approvals + WHERE NOT EXISTS ( + SELECT 1 + FROM projection_thread_activities AS activity + WHERE activity.kind = 'approval.requested' + AND json_extract(activity.payload_json, '$.requestId') + = projection_pending_approvals.request_id + ) + `; + + yield* sql` + UPDATE projection_threads + SET pending_approval_count = COALESCE(( + SELECT COUNT(*) + FROM projection_pending_approvals + WHERE projection_pending_approvals.thread_id = projection_threads.thread_id + AND projection_pending_approvals.status = 'pending' + ), 0) + `; +}); diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index dd909116..15ad4daf 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { runProcess } from "./processRunner"; +import { runProcess } from "./processRunner.ts"; describe("runProcess", () => { it("fails when output exceeds max buffer in default mode", async () => { diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts index 57f44648..98257b97 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts @@ -1,3 +1,5 @@ +import { realpathSync } from "node:fs"; + import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { Duration, Effect, FileSystem, Layer } from "effect"; @@ -10,6 +12,10 @@ import { RepositoryIdentityResolverLive, } from "./RepositoryIdentityResolver.ts"; +const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/"); +const normalizeResolvedPath = (value: string) => + normalizePathSeparators(realpathSync.native(value)); + const git = (cwd: string, args: ReadonlyArray) => Effect.promise(() => runProcess("git", ["-C", cwd, ...args])); @@ -41,6 +47,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { expect(identity).not.toBeNull(); expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(normalizeResolvedPath(identity?.rootPath ?? "")).toBe(normalizeResolvedPath(cwd)); expect(identity?.displayName).toBe("t3tools/t3code"); expect(identity?.provider).toBe("github"); expect(identity?.owner).toBe("t3tools"); @@ -48,6 +55,27 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { }).pipe(Effect.provide(RepositoryIdentityResolverLive)), ); + it.effect("returns the git top-level root path when resolving from a nested workspace", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const repoRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-nested-root-test-", + }); + const nestedWorkspace = `${repoRoot}/packages/web`; + + yield* fileSystem.makeDirectory(nestedWorkspace, { recursive: true }); + yield* git(repoRoot, ["init"]); + yield* git(repoRoot, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); + + const resolver = yield* RepositoryIdentityResolver; + const identity = yield* resolver.resolve(nestedWorkspace); + + expect(identity).not.toBeNull(); + expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(normalizeResolvedPath(identity?.rootPath ?? "")).toBe(normalizeResolvedPath(repoRoot)); + }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + ); + it.effect("returns null for non-git folders and repos without remotes", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -112,7 +140,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { ); it.effect( - "refreshes cached null identities after the negative TTL when a remote is configured later", + "keeps null identities cached across repeated resolves until the negative TTL expires", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -128,8 +156,10 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); - const cachedIdentity = yield* resolver.resolve(cwd); - expect(cachedIdentity).toBeNull(); + for (const _attempt of [1, 2, 3]) { + const cachedIdentity = yield* resolver.resolve(cwd); + expect(cachedIdentity).toBeNull(); + } yield* TestClock.adjust(Duration.millis(120)); diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts index 531737ec..30712355 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts @@ -42,6 +42,7 @@ function pickPrimaryRemote( function buildRepositoryIdentity(input: { readonly remoteName: string; readonly remoteUrl: string; + readonly rootPath: string; }): RepositoryIdentity { const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); const hostingProvider = detectGitHostingProviderFromRemoteUrl(input.remoteUrl); @@ -57,6 +58,7 @@ function buildRepositoryIdentity(input: { remoteName: input.remoteName, remoteUrl: input.remoteUrl, }, + rootPath: input.rootPath, ...(repositoryPath ? { displayName: repositoryPath } : {}), ...(hostingProvider ? { provider: hostingProvider.kind } : {}), ...(owner ? { owner } : {}), @@ -66,7 +68,7 @@ function buildRepositoryIdentity(input: { const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1); -const DEFAULT_NEGATIVE_CACHE_TTL = Duration.seconds(10); +const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(1); interface RepositoryIdentityResolverOptions { readonly cacheCapacity?: number; @@ -108,7 +110,7 @@ async function resolveRepositoryIdentityFromCacheKey( } const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.stdout)); - return remote ? buildRepositoryIdentity(remote) : null; + return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null; } catch { return null; } diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index b9bf61dc..51988125 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -134,6 +134,7 @@ class FakeClaudeQuery implements AsyncIterable { function makeHarness(config?: { readonly nativeEventLogPath?: string; readonly nativeEventLogger?: ClaudeAdapterLiveOptions["nativeEventLogger"]; + readonly resolveCliVersion?: ClaudeAdapterLiveOptions["resolveCliVersion"]; readonly cwd?: string; readonly baseDir?: string; }) { @@ -150,6 +151,11 @@ function makeHarness(config?: { createInput = input; return query; }, + ...(config?.resolveCliVersion + ? { + resolveCliVersion: config.resolveCliVersion, + } + : {}), ...(config?.nativeEventLogger ? { nativeEventLogger: config.nativeEventLogger, @@ -351,6 +357,334 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("maps the Claude Opus 4.7 default effort to the SDK-supported max value", () => { + const harness = makeHarness({ + resolveCliVersion: () => Effect.succeed("2.1.111"), + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-7", + }, + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.effort, "max"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("maps xhigh effort for Claude Opus 4.7 to the SDK-supported max value", () => { + const harness = makeHarness({ + resolveCliVersion: () => Effect.succeed("2.1.111"), + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-7", + options: { + effort: "xhigh", + }, + }, + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.effort, "max"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("downgrades Claude Opus 4.7 to Opus 4.6 when the installed CLI is too old", () => { + const harness = makeHarness({ + resolveCliVersion: () => Effect.succeed("2.1.110"), + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-7", + }, + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.model, "claude-opus-4-6"); + assert.equal(session.model, "claude-opus-4-6"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect( + "fails explicitly when Claude Opus 4.7 support cannot be verified at session start", + () => { + const harness = makeHarness({ + resolveCliVersion: () => Effect.succeed(null), + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const result = yield* adapter + .startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-7", + }, + runtimeMode: "full-access", + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + if (result._tag !== "Failure") { + return; + } + assert.deepEqual( + result.failure, + new ProviderAdapterValidationError({ + provider: "claudeAgent", + operation: "startSession", + issue: + "Claude Opus 4.7 requires a verified Claude CLI version. Unable to confirm support because the installed CLI version could not be determined.", + }), + ); + assert.equal(harness.getLastCreateQueryInput(), undefined); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }, + ); + + it.effect("rejects Claude Opus 4.7 aliases when CLI version is unknown at session start", () => { + const harness = makeHarness({ + resolveCliVersion: () => Effect.succeed(null), + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + for (const model of ["opus", "opus-4.7", "claude-opus-4.7"] as const) { + const result = yield* adapter + .startSession({ + threadId: ThreadId.make(`${THREAD_ID}-${model}`), + provider: "claudeAgent", + modelSelection: { + provider: "claudeAgent", + model, + }, + runtimeMode: "full-access", + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + if (result._tag !== "Failure") { + continue; + } + assert.deepEqual( + result.failure, + new ProviderAdapterValidationError({ + provider: "claudeAgent", + operation: "startSession", + issue: + "Claude Opus 4.7 requires a verified Claude CLI version. Unable to confirm support because the installed CLI version could not be determined.", + }), + ); + } + assert.equal(harness.getLastCreateQueryInput(), undefined); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("downgrades in-turn Claude Opus 4.7 switches when the installed CLI is too old", () => { + let resolveCliVersionCalls = 0; + const harness = makeHarness({ + resolveCliVersion: () => { + resolveCliVersionCalls += 1; + return Effect.succeed("2.1.110"); + }, + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "hello", + attachments: [], + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-7", + }, + }); + + assert.deepEqual(harness.query.setModelCalls, ["claude-opus-4-6"]); + assert.equal(resolveCliVersionCalls, 2); + const sessions = yield* adapter.listSessions(); + assert.equal(sessions[0]?.model, "claude-opus-4-6"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect( + "refreshes the cached Claude CLI version only when Claude Opus 4.7 is requested", + () => { + let resolveCliVersionCalls = 0; + const harness = makeHarness({ + resolveCliVersion: () => { + resolveCliVersionCalls += 1; + return Effect.succeed(resolveCliVersionCalls === 1 ? "2.1.110" : "2.1.111"); + }, + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "use default model", + attachments: [], + }); + assert.equal(resolveCliVersionCalls, 1); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "switch to opus", + attachments: [], + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-7", + }, + }); + + assert.equal(resolveCliVersionCalls, 2); + assert.deepEqual(harness.query.setModelCalls, ["claude-opus-4-7"]); + assert.equal((yield* adapter.listSessions())[0]?.model, "claude-opus-4-7"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }, + ); + + it.effect("fails explicitly when in-turn Claude Opus 4.7 support cannot be verified", () => { + const harness = makeHarness({ + resolveCliVersion: () => Effect.succeed(null), + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + + const result = yield* adapter + .sendTurn({ + threadId: THREAD_ID, + input: "hello", + attachments: [], + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-7", + }, + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + if (result._tag !== "Failure") { + return; + } + assert.deepEqual( + result.failure, + new ProviderAdapterValidationError({ + provider: "claudeAgent", + operation: "sendTurn", + issue: + "Claude Opus 4.7 requires a verified Claude CLI version. Unable to confirm support because the installed CLI version could not be determined.", + }), + ); + assert.deepEqual(harness.query.setModelCalls, []); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("rejects Claude Opus 4.7 aliases when CLI version is unknown in-turn", () => { + const harness = makeHarness({ + resolveCliVersion: () => Effect.succeed(null), + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + + for (const model of ["opus", "opus-4.7", "claude-opus-4.7"] as const) { + const result = yield* adapter + .sendTurn({ + threadId: THREAD_ID, + input: "hello", + attachments: [], + modelSelection: { + provider: "claudeAgent", + model, + }, + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + if (result._tag !== "Failure") { + continue; + } + assert.deepEqual( + result.failure, + new ProviderAdapterValidationError({ + provider: "claudeAgent", + operation: "sendTurn", + issue: + "Claude Opus 4.7 requires a verified Claude CLI version. Unable to confirm support because the installed CLI version could not be determined.", + }), + ); + } + assert.deepEqual(harness.query.setModelCalls, []); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("falls back to default effort when unsupported max is requested for Sonnet 4.6", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -1260,6 +1594,206 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("keeps the current session alive when replacement validation fails", () => { + const queries: FakeClaudeQuery[] = []; + let resolveCliVersionCalls = 0; + const layer = makeClaudeAdapterLive({ + createQuery: () => { + const query = new FakeClaudeQuery(); + queries.push(query); + return query; + }, + resolveCliVersion: () => { + resolveCliVersionCalls += 1; + return Effect.succeed(resolveCliVersionCalls === 1 ? "2.1.111" : null); + }, + }).pipe( + Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const firstSession = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + + const result = yield* adapter + .startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + resumeCursor: firstSession.resumeCursor, + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-7", + }, + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + if (result._tag !== "Failure") { + return; + } + assert.deepEqual( + result.failure, + new ProviderAdapterValidationError({ + provider: "claudeAgent", + operation: "startSession", + issue: + "Claude Opus 4.7 requires a verified Claude CLI version. Unable to confirm support because the installed CLI version could not be determined.", + }), + ); + + const activeSessions = yield* adapter.listSessions(); + assert.equal(queries.length, 1); + assert.equal(queries[0]?.closeCalls, 0); + assert.equal(yield* adapter.hasSession(THREAD_ID), true); + assert.equal(activeSessions.length, 1); + assert.deepEqual(activeSessions[0]?.resumeCursor, firstSession.resumeCursor); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(layer), + ); + }); + + it.effect("closes the previous session only after replacing an existing thread session", () => { + const queries: FakeClaudeQuery[] = []; + const layer = makeClaudeAdapterLive({ + createQuery: () => { + const query = new FakeClaudeQuery(); + queries.push(query); + return query; + }, + }).pipe( + Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const firstSession = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + + const secondSession = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + resumeCursor: firstSession.resumeCursor, + }); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const activeSessions = yield* adapter.listSessions(); + + assert.equal(queries.length, 2); + assert.equal(queries[0]?.closeCalls, 1); + assert.equal(queries[1]?.closeCalls, 0); + assert.equal(yield* adapter.hasSession(THREAD_ID), true); + assert.equal(activeSessions.length, 1); + assert.deepEqual(activeSessions[0]?.resumeCursor, secondSession.resumeCursor); + assert.deepEqual( + runtimeEvents.map((event) => event.type), + [ + "session.started", + "session.configured", + "session.state.changed", + "session.started", + "session.configured", + "session.state.changed", + ], + ); + assert.equal( + runtimeEvents.some((event) => event.type === "session.exited"), + false, + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(layer), + ); + }); + + it.effect("does not trust version output from failing Claude CLI probes", () => { + const tempRoot = mkdtempSync(path.join(os.tmpdir(), "t3-claude-version-probe-")); + const fakeBinaryPath = path.join( + tempRoot, + process.platform === "win32" ? "claude.cmd" : "claude", + ); + if (process.platform === "win32") { + writeFileSync(fakeBinaryPath, "@echo off\r\necho claude 2.1.111 1>&2\r\nexit /b 1\r\n"); + } else { + writeFileSync( + fakeBinaryPath, + "#!/usr/bin/env sh\n" + "echo 'claude 2.1.111' >&2\n" + "exit 1\n", + ); + chmodSync(fakeBinaryPath, 0o755); + } + + const layer = makeClaudeAdapterLive().pipe( + Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + claudeAgent: { + binaryPath: fakeBinaryPath, + }, + }, + }), + ), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const result = yield* adapter + .startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-7", + }, + runtimeMode: "full-access", + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + if (result._tag !== "Failure") { + return; + } + assert.deepEqual( + result.failure, + new ProviderAdapterValidationError({ + provider: "claudeAgent", + operation: "startSession", + issue: + "Claude Opus 4.7 requires a verified Claude CLI version. Unable to confirm support because the installed CLI version could not be determined.", + }), + ); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + rmSync(tempRoot, { recursive: true, force: true }); + }), + ), + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(layer), + ); + }); + it.effect("stopSession does not throw into the SDK prompt consumer", () => { // The SDK consumes user messages via `for await (... of prompt)`. // Stopping a session must end that loop cleanly — not throw an error. diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 508325e7..ea17bde9 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -17,9 +17,9 @@ import { type SDKResultMessage, type SettingSource, type SDKUserMessage, - ModelUsage, - NonNullableUsage, + type ModelUsage, } from "@anthropic-ai/claude-agent-sdk"; +import { parseCliArgs } from "@t3tools/shared/cliArgs"; import { ApprovalRequestId, type CanonicalItemType, @@ -40,15 +40,17 @@ import { ThreadId, TurnId, type UserInputQuestion, - ClaudeCodeEffort, + type ClaudeAgentEffort, } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, + normalizeModelSlug, resolveApiModelId, resolveEffort, trimOrNull, } from "@t3tools/shared/model"; import { + Option, Cause, DateTime, Deferred, @@ -62,11 +64,11 @@ import { Ref, Stream, } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -import { getClaudeModelCapabilities } from "./ClaudeProvider.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -77,6 +79,12 @@ import { } from "../Errors.ts"; import { ClaudeAdapter, type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { getClaudeModelCapabilities, resolveClaudeModelForVersion } from "./ClaudeProvider.ts"; +import { + DEFAULT_TIMEOUT_MS, + parseGenericCliVersion, + spawnAndCollect, +} from "../providerSnapshot.ts"; const PROVIDER = "claudeAgent" as const; type ClaudeTextStreamKind = Extract; @@ -84,6 +92,7 @@ type ClaudeToolResultStreamKind = Extract< RuntimeContentStreamKind, "command_output" | "file_change_output" >; +type ClaudeSdkEffort = NonNullable; type PromptQueueItem = | { @@ -147,6 +156,8 @@ interface ClaudeSessionContext { session: ProviderSession; readonly promptQueue: Queue.Queue; readonly query: ClaudeQueryRuntime; + readonly claudeBinaryPath: string; + installedClaudeVersion: string | null; streamFiber: Fiber.Fiber | undefined; readonly startedAt: string; readonly basePermissionMode: PermissionMode | undefined; @@ -180,6 +191,7 @@ export interface ClaudeAdapterLiveOptions { readonly prompt: AsyncIterable; readonly options: ClaudeQueryOptions; }) => ClaudeQueryRuntime; + readonly resolveCliVersion?: (binaryPath: string) => Effect.Effect; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; } @@ -215,13 +227,19 @@ function normalizeClaudeStreamMessages(cause: Cause.Cause): ReadonlyArray return squashed.length > 0 ? [squashed] : []; } -function getEffectiveClaudeCodeEffort( - effort: ClaudeCodeEffort | null | undefined, -): Exclude | null { +function getEffectiveClaudeAgentEffort( + effort: ClaudeAgentEffort | null | undefined, +): ClaudeSdkEffort | null { if (!effort) { return null; } - return effort === "ultrathink" ? null : effort; + if (effort === "ultrathink") { + return null; + } + if (effort === "xhigh") { + return "max"; + } + return effort; } function isClaudeInterruptedMessage(message: string): boolean { @@ -289,7 +307,7 @@ function maxClaudeContextWindowFromModelUsage( } function normalizeClaudeTokenUsage( - value: NonNullableUsage | undefined, + value: unknown, contextWindow?: number, ): ThreadTokenUsageSnapshot | undefined { if (!value || typeof value !== "object") { @@ -463,7 +481,10 @@ function isTodoTool(toolName: string): boolean { return toolName.toLowerCase().includes("todowrite"); } -type PlanStep = { step: string; status: "pending" | "inProgress" | "completed" }; +type PlanStep = { + step: string; + status: "pending" | "inProgress" | "completed"; +}; function extractPlanStepsFromTodoInput(input: Record): PlanStep[] | null { // TodoWrite format: { todos: [{ content, status, activeForm? }] } @@ -953,6 +974,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ) { const fileSystem = yield* FileSystem.FileSystem; const serverConfig = yield* ServerConfig; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const nativeEventLogger = options?.nativeEventLogger ?? (options?.nativeEventLogPath !== undefined @@ -966,7 +988,17 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ((input: { readonly prompt: AsyncIterable; readonly options: ClaudeQueryOptions; - }) => query({ prompt: input.prompt, options: input.options }) as ClaudeQueryRuntime); + }) => + query({ + prompt: input.prompt, + options: input.options, + }) as ClaudeQueryRuntime); + const resolveCliVersion = + options?.resolveCliVersion ?? + ((binaryPath: string) => + resolveClaudeCliVersionEffect(binaryPath).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + )); const sessions = new Map(); const runtimeEventQueue = yield* Queue.unbounded(); @@ -1005,7 +1037,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(typeof message.session_id === "string" ? { providerThreadId: message.session_id } : {}), - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + ...(context.turnState + ? { + turnId: asCanonicalTurnId(context.turnState.turnId), + } + : {}), ...(itemId ? { itemId: ProviderItemId.make(itemId) } : {}), payload: message, }, @@ -1394,7 +1430,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(typeof accumulatedTotalProcessedTokens === "number" && Number.isFinite(accumulatedTotalProcessedTokens) && accumulatedTotalProcessedTokens > lastGoodUsage.usedTokens - ? { totalProcessedTokens: accumulatedTotalProcessedTokens } + ? { + totalProcessedTokens: accumulatedTotalProcessedTokens, + } : {}), } : accumulatedSnapshot; @@ -1458,7 +1496,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( input: tool.input, }, }, - providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), + providerRefs: nativeProviderRefs(context, { + providerItemId: tool.itemId, + }), raw: { source: "claude.sdk.message", method: "claude/result", @@ -1580,7 +1620,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( threadId: context.session.threadId, turnId: context.turnState.turnId, ...(assistantBlockEntry?.block - ? { itemId: asRuntimeItemId(assistantBlockEntry.block.itemId) } + ? { + itemId: asRuntimeItemId(assistantBlockEntry.block.itemId), + } : {}), payload: { streamKind, @@ -1639,7 +1681,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( provider: PROVIDER, createdAt: stamp.createdAt, threadId: context.session.threadId, - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + ...(context.turnState + ? { + turnId: asCanonicalTurnId(context.turnState.turnId), + } + : {}), itemId: asRuntimeItemId(nextTool.itemId), payload: { itemType: nextTool.itemType, @@ -1651,7 +1697,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( input: nextTool.input, }, }, - providerRefs: nativeProviderRefs(context, { providerItemId: nextTool.itemId }), + providerRefs: nativeProviderRefs(context, { + providerItemId: nextTool.itemId, + }), raw: { source: "claude.sdk.message", method: "claude/stream_event/content_block_delta/input_json_delta", @@ -1670,7 +1718,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( provider: PROVIDER, createdAt: planStamp.createdAt, threadId: context.session.threadId, - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + ...(context.turnState + ? { + turnId: asCanonicalTurnId(context.turnState.turnId), + } + : {}), payload: { plan: planSteps, }, @@ -1740,7 +1792,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( input: toolInput, }, }, - providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), + providerRefs: nativeProviderRefs(context, { + providerItemId: tool.itemId, + }), raw: { source: "claude.sdk.message", method: "claude/stream_event/content_block_start", @@ -1812,7 +1866,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(tool.detail ? { detail: tool.detail } : {}), data: toolData, }, - providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), + providerRefs: nativeProviderRefs(context, { + providerItemId: tool.itemId, + }), raw: { source: "claude.sdk.message", method: "claude/user", @@ -1835,7 +1891,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( streamKind, delta: toolResult.text, }, - providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), + providerRefs: nativeProviderRefs(context, { + providerItemId: tool.itemId, + }), raw: { source: "claude.sdk.message", method: "claude/user", @@ -1860,7 +1918,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(tool.detail ? { detail: tool.detail } : {}), data: toolData, }, - providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), + providerRefs: nativeProviderRefs(context, { + providerItemId: tool.itemId, + }), raw: { source: "claude.sdk.message", method: "claude/user", @@ -2216,7 +2276,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( payload: { summary: message.summary, ...(message.preceding_tool_use_ids.length > 0 - ? { precedingToolUseIds: message.preceding_tool_use_ids } + ? { + precedingToolUseIds: message.preceding_tool_use_ids, + } : {}), }, }); @@ -2397,7 +2459,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } - sessions.delete(context.session.threadId); + if (sessions.get(context.session.threadId) === context) { + sessions.delete(context.session.threadId); + } }); const requireSession = ( @@ -2433,6 +2497,15 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } + const existingContext = sessions.get(input.threadId); + if (existingContext) { + yield* Effect.logWarning("claude.session.replacing", { + threadId: input.threadId, + existingSessionStatus: existingContext.session.status, + reason: "startSession called with existing active session", + }); + } + const startedAt = yield* nowIso; const resumeState = readClaudeResumeState(input.resumeCursor); const threadId = input.threadId; @@ -2468,7 +2541,10 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const handleAskUserQuestion = Effect.fn("handleAskUserQuestion")(function* ( context: ClaudeSessionContext, toolInput: Record, - callbackOptions: { readonly signal: AbortSignal; readonly toolUseID?: string }, + callbackOptions: { + readonly signal: AbortSignal; + readonly toolUseID?: string; + }, ) { const requestId = ApprovalRequestId.make(yield* Random.nextUUIDv4); @@ -2504,7 +2580,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( provider: PROVIDER, createdAt: requestedStamp.createdAt, threadId: context.session.threadId, - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + ...(context.turnState + ? { + turnId: asCanonicalTurnId(context.turnState.turnId), + } + : {}), requestId: asRuntimeRequestId(requestId), payload: { questions }, providerRefs: nativeProviderRefs(context, { @@ -2513,7 +2593,10 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( raw: { source: "claude.sdk.permission", method: "canUseTool/AskUserQuestion", - payload: { toolName: "AskUserQuestion", input: toolInput }, + payload: { + toolName: "AskUserQuestion", + input: toolInput, + }, }, }); @@ -2528,7 +2611,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( pendingUserInputs.delete(requestId); runFork(Deferred.succeed(answersDeferred, {} as ProviderUserInputAnswers)); }; - callbackOptions.signal.addEventListener("abort", onAbort, { once: true }); + callbackOptions.signal.addEventListener("abort", onAbort, { + once: true, + }); // Block until the user provides answers. const answers = yield* Deferred.await(answersDeferred); @@ -2542,7 +2627,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( provider: PROVIDER, createdAt: resolvedStamp.createdAt, threadId: context.session.threadId, - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + ...(context.turnState + ? { + turnId: asCanonicalTurnId(context.turnState.turnId), + } + : {}), requestId: asRuntimeRequestId(requestId), payload: { answers }, providerRefs: nativeProviderRefs(context, { @@ -2712,7 +2801,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( behavior: "allow", updatedInput: toolInput, ...(decision === "acceptForSession" && pendingApproval.suggestions - ? { updatedPermissions: [...pendingApproval.suggestions] } + ? { + updatedPermissions: [...pendingApproval.suggestions], + } : {}), } satisfies PermissionResult; } @@ -2742,18 +2833,34 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ), ); const claudeBinaryPath = claudeSettings.binaryPath; + const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags; const modelSelection = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; - const caps = getClaudeModelCapabilities(modelSelection?.model); - const apiModelId = modelSelection ? resolveApiModelId(modelSelection) : undefined; + const installedClaudeVersion = yield* resolveCliVersion(claudeBinaryPath); + const normalizedModel = yield* validateClaudeSelectedModel({ + model: modelSelection?.model, + installedVersion: installedClaudeVersion, + operation: "startSession", + }); + const resolvedModelSelection = + modelSelection && normalizedModel.model + ? { + ...modelSelection, + model: normalizedModel.model, + } + : modelSelection; + const caps = getClaudeModelCapabilities(resolvedModelSelection?.model); + const apiModelId = resolvedModelSelection + ? resolveApiModelId(resolvedModelSelection) + : undefined; const effort = (resolveEffort(caps, modelSelection?.options?.effort) ?? - null) as ClaudeCodeEffort | null; + null) as ClaudeAgentEffort | null; const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode; const thinking = typeof modelSelection?.options?.thinking === "boolean" && caps.supportsThinkingToggle ? modelSelection.options.thinking : undefined; - const effectiveEffort = getEffectiveClaudeCodeEffort(effort); + const effectiveEffort = getEffectiveClaudeAgentEffort(effort); const runtimeModeToPermission: Record = { "auto-accept-edits": "acceptEdits", "full-access": "bypassPermissions", @@ -2781,6 +2888,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( canUseTool, env: process.env, ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), + ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), }; const queryRuntime = yield* Effect.try({ @@ -2804,7 +2912,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( status: "ready", runtimeMode: input.runtimeMode, ...(input.cwd ? { cwd: input.cwd } : {}), - ...(modelSelection?.model ? { model: modelSelection.model } : {}), + ...(resolvedModelSelection?.model ? { model: resolvedModelSelection.model } : {}), ...(threadId ? { threadId } : {}), resumeCursor: { ...(threadId ? { threadId } : {}), @@ -2820,6 +2928,8 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( session, promptQueue, query: queryRuntime, + claudeBinaryPath, + installedClaudeVersion, streamFiber: undefined, startedAt, basePermissionMode: permissionMode, @@ -2838,6 +2948,18 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }; yield* Ref.set(contextRef, context); sessions.set(threadId, context); + if (existingContext) { + yield* stopSessionInternal(existingContext, { + emitExitEvent: false, + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("claude.session.replace.stop-failed", { + threadId, + cause, + }), + ), + ); + } const sessionStartedStamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ @@ -2913,6 +3035,8 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const context = yield* requireSession(input.threadId); const modelSelection = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; + const requestedModel = + normalizeModelSlug(modelSelection?.model, "claudeAgent") ?? modelSelection?.model?.trim(); if (context.turnState) { // Auto-close a stale synthetic turn (from background agent responses @@ -2920,8 +3044,28 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* completeTurn(context, "completed"); } - if (modelSelection?.model) { - const apiModelId = resolveApiModelId(modelSelection); + const installedClaudeVersion = + requestedModel === "claude-opus-4-7" + ? yield* resolveCliVersion(context.claudeBinaryPath).pipe( + Effect.tap((version) => + Effect.sync(() => void (context.installedClaudeVersion = version)), + ), + ) + : context.installedClaudeVersion; + const normalizedModel = yield* validateClaudeSelectedModel({ + model: modelSelection?.model, + installedVersion: installedClaudeVersion, + operation: "sendTurn", + }); + const resolvedModelSelection = + modelSelection && normalizedModel.model + ? { + ...modelSelection, + model: normalizedModel.model, + } + : modelSelection; + if (resolvedModelSelection?.model) { + const apiModelId = resolveApiModelId(resolvedModelSelection); if (context.currentApiModelId !== apiModelId) { yield* Effect.tryPromise({ try: () => context.query.setModel(apiModelId), @@ -2931,7 +3075,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } context.session = { ...context.session, - model: modelSelection.model, + model: resolvedModelSelection.model, }; } @@ -2979,7 +3123,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( createdAt: turnStartedStamp.createdAt, threadId: context.session.threadId, turnId, - payload: modelSelection?.model ? { model: modelSelection.model } : {}, + payload: resolvedModelSelection?.model ? { model: resolvedModelSelection.model } : {}, providerRefs: {}, }); @@ -3129,3 +3273,66 @@ export const ClaudeAdapterLive = Layer.effect(ClaudeAdapter, makeClaudeAdapter() export function makeClaudeAdapterLive(options?: ClaudeAdapterLiveOptions) { return Layer.effect(ClaudeAdapter, makeClaudeAdapter(options)); } +function normalizeClaudeSelectedModel(input: { + readonly model: string | undefined; + readonly installedVersion: string | null | undefined; +}): { readonly model: string | undefined; readonly downgradedFrom: string | undefined } { + const canonicalModel = normalizeModelSlug(input.model, "claudeAgent") ?? input.model?.trim(); + const resolvedModel = resolveClaudeModelForVersion(canonicalModel, input.installedVersion); + return { + model: resolvedModel, + downgradedFrom: + canonicalModel === "claude-opus-4-7" && resolvedModel !== "claude-opus-4-7" + ? "claude-opus-4-7" + : undefined, + }; +} + +function validateClaudeSelectedModel(input: { + readonly model: string | undefined; + readonly installedVersion: string | null | undefined; + readonly operation: "startSession" | "sendTurn"; +}): Effect.Effect< + { readonly model: string | undefined; readonly downgradedFrom: string | undefined }, + ProviderAdapterValidationError +> { + const canonicalModel = normalizeModelSlug(input.model, "claudeAgent") ?? input.model?.trim(); + if (canonicalModel === "claude-opus-4-7" && !input.installedVersion) { + return Effect.fail( + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: input.operation, + issue: + "Claude Opus 4.7 requires a verified Claude CLI version. Unable to confirm support because the installed CLI version could not be determined.", + }), + ); + } + + return Effect.succeed( + normalizeClaudeSelectedModel({ + model: input.model, + installedVersion: input.installedVersion, + }), + ); +} + +function resolveClaudeCliVersionEffect( + binaryPath: string, +): Effect.Effect { + const command = ChildProcess.make(binaryPath, ["--version"], { + shell: process.platform === "win32", + }); + return spawnAndCollect(binaryPath, command).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.map((result) => { + if (!Option.isSome(result)) { + return null; + } + if (result.value.code !== 0) { + return null; + } + return parseGenericCliVersion(`${result.value.stdout}\n${result.value.stderr}`); + }), + Effect.orElseSucceed(() => null), + ); +} diff --git a/apps/server/src/provider/Layers/ClaudeProvider.test.ts b/apps/server/src/provider/Layers/ClaudeProvider.test.ts new file mode 100644 index 00000000..bca0e432 --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeProvider.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { + MINIMUM_CLAUDE_OPUS_4_7_VERSION, + resolveClaudeModelForVersion, + supportsClaudeOpus47, +} from "./ClaudeProvider.ts"; + +describe("ClaudeProvider", () => { + describe("supportsClaudeOpus47", () => { + it("requires a known version", () => { + expect(supportsClaudeOpus47(null)).toBe(false); + expect(supportsClaudeOpus47(undefined)).toBe(false); + expect(supportsClaudeOpus47("")).toBe(false); + }); + + it("accepts stable and newer prerelease versions semver-correctly", () => { + expect(supportsClaudeOpus47(MINIMUM_CLAUDE_OPUS_4_7_VERSION)).toBe(true); + expect(supportsClaudeOpus47("2.1.112-beta.1")).toBe(true); + expect(supportsClaudeOpus47("2.1.111-beta.1")).toBe(false); + }); + }); + + describe("resolveClaudeModelForVersion", () => { + it("does not expose Claude Opus 4.7 when the version is unknown", () => { + expect(resolveClaudeModelForVersion("claude-opus-4-7", null)).toBe("claude-opus-4-6"); + expect(resolveClaudeModelForVersion("opus", null)).toBe("claude-opus-4-6"); + expect(resolveClaudeModelForVersion("opus-4.7", undefined)).toBe("claude-opus-4-6"); + }); + + it("keeps supported Claude Opus 4.7 aliases once the CLI version is known to support them", () => { + expect(resolveClaudeModelForVersion("claude-opus-4-7", "2.1.111")).toBe("claude-opus-4-7"); + expect(resolveClaudeModelForVersion("opus", "2.1.112-beta.1")).toBe("claude-opus-4-7"); + }); + }); +}); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index b7c3c314..a1aa1940 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -10,6 +10,7 @@ import type { import { Cache, Duration, Effect, Equal, Layer, Option, Result, Schema, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { normalizeModelSlug } from "@t3tools/shared/model"; import { query as claudeQuery, type SlashCommand as ClaudeSlashCommand, @@ -25,10 +26,11 @@ import { providerModelsFromSettings, spawnAndCollect, type CommandResult, -} from "../providerSnapshot"; -import { makeManagedServerProvider } from "../makeManagedServerProvider"; -import { ClaudeProvider } from "../Services/ClaudeProvider"; -import { ServerSettingsService } from "../../serverSettings"; +} from "../providerSnapshot.ts"; +import { compareCliVersions } from "../cliVersion.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { ClaudeProvider } from "../Services/ClaudeProvider.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { ServerSettingsError } from "@t3tools/contracts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = { @@ -40,7 +42,30 @@ const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = { }; const PROVIDER = "claudeAgent" as const; +export const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; const BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: "claude-opus-4-7", + name: "Claude Opus 4.7", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra High", isDefault: true }, + { value: "max", label: "Max" }, + { value: "ultrathink", label: "Ultrathink" }, + ], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [ + { value: "200k", label: "200k", isDefault: true }, + { value: "1m", label: "1M" }, + ], + promptInjectedEffortLevels: ["ultrathink"], + } satisfies ModelCapabilities, + }, { slug: "claude-opus-4-6", name: "Claude Opus 4.6", @@ -96,6 +121,58 @@ const BUILT_IN_MODELS: ReadonlyArray = [ }, ]; +export function supportsClaudeOpus47(version: string | null | undefined): boolean { + if (!version) { + return false; + } + + const normalized = version.trim(); + if (!normalized) { + return false; + } + + return compareCliVersions(normalized, MINIMUM_CLAUDE_OPUS_4_7_VERSION) >= 0; +} + +function getBuiltInClaudeModelsForVersion( + version: string | null | undefined, +): ReadonlyArray { + if (supportsClaudeOpus47(version)) { + return BUILT_IN_MODELS; + } + return BUILT_IN_MODELS.filter((model) => model.slug !== "claude-opus-4-7"); +} + +function normalizeClaudeCustomModelsForVersion( + customModels: ReadonlyArray, + version: string | null | undefined, +): ReadonlyArray { + return customModels.flatMap((model) => { + const resolved = resolveClaudeModelForVersion(model, version); + return resolved ? [resolved] : []; + }); +} + +export function formatClaudeOpus47UpgradeMessage(version: string | null): string { + const versionLabel = version ? `v${version}` : "the installed version"; + return `Claude Code ${versionLabel} is too old for Claude Opus 4.7. Upgrade to v${MINIMUM_CLAUDE_OPUS_4_7_VERSION} or newer to access it.`; +} + +export function resolveClaudeModelForVersion( + model: string | null | undefined, + version: string | null | undefined, +): string | undefined { + const trimmed = model?.trim(); + if (!trimmed) { + return undefined; + } + const normalized = normalizeModelSlug(trimmed, PROVIDER) ?? trimmed; + if (normalized !== "claude-opus-4-7") { + return trimmed; + } + return supportsClaudeOpus47(version) ? normalized : "claude-opus-4-6"; +} + export function getClaudeModelCapabilities(model: string | null | undefined): ModelCapabilities { const slug = model?.trim(); return ( @@ -484,10 +561,10 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( Effect.map((settings) => settings.providers.claudeAgent), ); const checkedAt = new Date().toISOString(); - const models = providerModelsFromSettings( - BUILT_IN_MODELS, + const allModels = providerModelsFromSettings( + getBuiltInClaudeModelsForVersion(null), PROVIDER, - claudeSettings.customModels, + normalizeClaudeCustomModelsForVersion(claudeSettings.customModels, null), DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); @@ -496,7 +573,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( provider: PROVIDER, enabled: false, checkedAt, - models, + models: allModels, probe: { installed: false, version: null, @@ -518,7 +595,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( provider: PROVIDER, enabled: claudeSettings.enabled, checkedAt, - models, + models: allModels, probe: { installed: !isCommandMissingCause(error), version: null, @@ -536,7 +613,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( provider: PROVIDER, enabled: claudeSettings.enabled, checkedAt, - models, + models: allModels, probe: { installed: true, version: null, @@ -556,7 +633,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( provider: PROVIDER, enabled: claudeSettings.enabled, checkedAt, - models, + models: allModels, probe: { installed: true, version: parsedVersion, @@ -569,6 +646,16 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } + const models = providerModelsFromSettings( + getBuiltInClaudeModelsForVersion(parsedVersion), + PROVIDER, + normalizeClaudeCustomModelsForVersion(claudeSettings.customModels, parsedVersion), + DEFAULT_CLAUDE_MODEL_CAPABILITIES, + ); + const opus47UpgradeMessage = supportsClaudeOpus47(parsedVersion) + ? undefined + : formatClaudeOpus47UpgradeMessage(parsedVersion); + const slashCommands = (resolveSlashCommands ? yield* resolveSlashCommands(claudeSettings.binaryPath).pipe( @@ -658,7 +745,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ...parsed.auth, ...(authMetadata ? authMetadata : {}), }, - ...(parsed.message ? { message: parsed.message } : {}), + ...(parsed.message + ? { message: parsed.message } + : opus47UpgradeMessage + ? { message: opus47UpgradeMessage } + : {}), }, }); }); @@ -666,9 +757,9 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvider => { const checkedAt = new Date().toISOString(); const models = providerModelsFromSettings( - BUILT_IN_MODELS, + getBuiltInClaudeModelsForVersion(null), PROVIDER, - claudeSettings.customModels, + normalizeClaudeCustomModelsForVersion(claudeSettings.customModels, null), DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index c4ee33b7..03ba0ce4 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -146,6 +146,7 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory getBinding: () => Effect.succeed(Option.none()), remove: () => Effect.void, listThreadIds: () => Effect.succeed([]), + listBindings: () => Effect.succeed([]), }); const validationManager = new FakeCodexManager(); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index d3f8c742..de4aceea 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -32,22 +32,22 @@ import { providerModelsFromSettings, spawnAndCollect, type CommandResult, -} from "../providerSnapshot"; -import { makeManagedServerProvider } from "../makeManagedServerProvider"; +} from "../providerSnapshot.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { formatCodexCliUpgradeMessage, isCodexCliVersionSupported, parseCodexCliVersion, -} from "../codexCliVersion"; +} from "../codexCliVersion.ts"; import { adjustCodexModelsForAccount, codexAuthSubLabel, codexAuthSubType, type CodexAccountSnapshot, -} from "../codexAccount"; -import { probeCodexDiscovery } from "../codexAppServer"; -import { CodexProvider } from "../Services/CodexProvider"; -import { ServerSettingsService } from "../../serverSettings"; +} from "../codexAccount.ts"; +import { probeCodexDiscovery } from "../codexAppServer.ts"; +import { CodexProvider } from "../Services/CodexProvider.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { ServerSettingsError } from "@t3tools/contracts"; const DEFAULT_CODEX_MODEL_CAPABILITIES: ModelCapabilities = { diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 0615e2c8..3e12a13a 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -99,8 +99,11 @@ class FakeCopilotClient { async (_sessionId: string, _config: unknown) => this.session, ); public readonly stopImpl = vi.fn(async () => [] as Error[]); + private readonly session: FakeCopilotSession; - constructor(private readonly session: FakeCopilotSession) {} + constructor(session: FakeCopilotSession) { + this.session = session; + } start() { return this.startImpl(); diff --git a/apps/server/src/provider/Layers/CopilotProvider.ts b/apps/server/src/provider/Layers/CopilotProvider.ts index 842ec938..87efafb2 100644 --- a/apps/server/src/provider/Layers/CopilotProvider.ts +++ b/apps/server/src/provider/Layers/CopilotProvider.ts @@ -11,11 +11,11 @@ import type { } from "@t3tools/contracts"; import { Effect, Equal, Exit, Layer, Stream } from "effect"; -import { ServerSettingsService } from "../../serverSettings"; -import { makeManagedServerProvider } from "../makeManagedServerProvider"; -import { buildServerProvider, providerModelsFromSettings } from "../providerSnapshot"; -import { CopilotProvider } from "../Services/CopilotProvider"; -import { normalizeCopilotCliPathOverride, resolveBundledCopilotCliPath } from "./copilotCliPath"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { buildServerProvider, providerModelsFromSettings } from "../providerSnapshot.ts"; +import { CopilotProvider } from "../Services/CopilotProvider.ts"; +import { normalizeCopilotCliPathOverride, resolveBundledCopilotCliPath } from "./copilotCliPath.ts"; const PROVIDER = "copilot" as const; @@ -263,10 +263,9 @@ function resolveRuntimeModels(models: ReadonlyArray, settings: Copilo } export const checkCopilotProviderStatus = Effect.fn("checkCopilotProviderStatus")(function* () { - const settings = yield* Effect.service(ServerSettingsService).pipe( - Effect.flatMap((service) => service.getSettings), - Effect.map((allSettings) => allSettings.providers.copilot), - ); + const serverSettings = yield* ServerSettingsService; + const allSettings = yield* serverSettings.getSettings; + const settings = allSettings.providers.copilot; const checkedAt = new Date().toISOString(); const configuredBinaryPath = normalizeCopilotCliPathOverride(settings.binaryPath); @@ -394,10 +393,10 @@ export const CopilotProviderLive = Layer.effect( }), ); class CopilotProbeError extends Error { - constructor( - message: string, - readonly causeValue: unknown, - ) { + constructor(message: string, causeValue: unknown) { super(message); + this.causeValue = causeValue; } + + readonly causeValue: unknown; } diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index 39422d19..8937b208 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -4,9 +4,12 @@ import { assertFailure } from "@effect/vitest/utils"; import { Effect, Layer, Stream } from "effect"; -import { ClaudeAdapter, ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; -import { CopilotAdapter, CopilotAdapterShape } from "../Services/CopilotAdapter.ts"; -import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; +import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; +import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; +import { CopilotAdapter } from "../Services/CopilotAdapter.ts"; +import type { CopilotAdapterShape } from "../Services/CopilotAdapter.ts"; +import { CodexAdapter } from "../Services/CodexAdapter.ts"; +import type { CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; import { ProviderUnsupportedError } from "../Errors.ts"; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 93d146ac..d7c6614f 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -28,12 +28,12 @@ import { hasCustomModelProvider, parseAuthStatusFromOutput, readCodexConfigModelProvider, -} from "./CodexProvider"; -import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider"; -import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry"; -import { ServerConfig } from "../../config"; -import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings"; -import { ProviderRegistry } from "../Services/ProviderRegistry"; +} from "./CodexProvider.ts"; +import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider.ts"; +import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts"; +import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; // ── Test helpers ──────────────────────────────────────────────────── @@ -973,6 +973,174 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ), ); + it.effect( + "includes Claude Opus 4.7 with xhigh as the default effort on supported versions", + () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus(); + const opus47 = status.models.find((model) => model.slug === "claude-opus-4-7"); + if (!opus47) { + assert.fail("Expected Claude Opus 4.7 to be present for Claude Code v2.1.111."); + } + if (!opus47.capabilities) { + assert.fail( + "Expected Claude Opus 4.7 capabilities to be present for Claude Code v2.1.111.", + ); + } + assert.deepStrictEqual( + opus47.capabilities.reasoningEffortLevels.find((level) => level.isDefault), + { value: "xhigh", label: "Extra High", isDefault: true }, + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.111\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("hides Claude Opus 4.7 on older Claude Code versions", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus(); + assert.strictEqual( + status.models.some((model) => model.slug === "claude-opus-4-7"), + false, + ); + assert.strictEqual( + status.message, + "Claude Code v2.1.110 is too old for Claude Opus 4.7. Upgrade to v2.1.111 or newer to access it.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.110\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect( + "includes Claude Opus 4.7 on Claude prerelease builds newer than the minimum stable version", + () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus(); + assert.strictEqual( + status.models.some((model) => model.slug === "claude-opus-4-7"), + true, + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") + return { stdout: "2.1.112-beta.1\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("normalizes custom Claude Opus 4.7 models on older Claude Code versions", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + yield* serverSettings.updateSettings({ + providers: { + claudeAgent: { + customModels: ["claude-opus-4-7", "claude-custom"], + }, + }, + }); + + const status = yield* checkClaudeProviderStatus(); + assert.strictEqual( + status.models.some((model) => model.slug === "claude-opus-4-7"), + false, + ); + assert.strictEqual( + status.models.filter((model) => model.slug === "claude-opus-4-6").length, + 1, + ); + assert.strictEqual( + status.models.some((model) => model.slug === "claude-custom"), + true, + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.110\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("gates Claude Opus 4.7 aliases from custom model settings on older versions", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + yield* serverSettings.updateSettings({ + providers: { + claudeAgent: { + customModels: ["opus", "opus-4.7", "claude-opus-4.7"], + }, + }, + }); + + const status = yield* checkClaudeProviderStatus(); + assert.strictEqual( + status.models.some((model) => model.slug === "claude-opus-4-7"), + false, + ); + assert.strictEqual( + status.models.filter((model) => model.slug === "claude-opus-4-6").length, + 1, + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.110\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("returns a display label for claude subscription types", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus(() => Effect.succeed("maxplan")); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index d6658855..e403abb5 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -6,17 +6,17 @@ import type { ProviderKind, ServerProvider } from "@t3tools/contracts"; import { Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "effect"; -import { ServerConfig } from "../../config"; -import { ClaudeProviderLive } from "./ClaudeProvider"; -import { CopilotProviderLive } from "./CopilotProvider"; -import { CodexProviderLive } from "./CodexProvider"; -import type { ClaudeProviderShape } from "../Services/ClaudeProvider"; -import { ClaudeProvider } from "../Services/ClaudeProvider"; -import type { CopilotProviderShape } from "../Services/CopilotProvider"; -import { CopilotProvider } from "../Services/CopilotProvider"; -import type { CodexProviderShape } from "../Services/CodexProvider"; -import { CodexProvider } from "../Services/CodexProvider"; -import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry"; +import { ServerConfig } from "../../config.ts"; +import { ClaudeProviderLive } from "./ClaudeProvider.ts"; +import { CopilotProviderLive } from "./CopilotProvider.ts"; +import { CodexProviderLive } from "./CodexProvider.ts"; +import type { ClaudeProviderShape } from "../Services/ClaudeProvider.ts"; +import { ClaudeProvider } from "../Services/ClaudeProvider.ts"; +import type { CopilotProviderShape } from "../Services/CopilotProvider.ts"; +import { CopilotProvider } from "../Services/CopilotProvider.ts"; +import type { CodexProviderShape } from "../Services/CodexProvider.ts"; +import { CodexProvider } from "../Services/CodexProvider.ts"; +import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; import { hydrateCachedProvider, PROVIDER_CACHE_IDS, @@ -24,13 +24,13 @@ import { readProviderStatusCache, resolveProviderStatusCachePath, writeProviderStatusCache, -} from "../providerStatusCache"; +} from "../providerStatusCache.ts"; const loadProviders = ( codexProvider: CodexProviderShape, copilotProvider: CopilotProviderShape, claudeProvider: ClaudeProviderShape, -): Effect.Effect => +): Effect.Effect => Effect.all([codexProvider.getSnapshot, copilotProvider.getSnapshot, claudeProvider.getSnapshot], { concurrency: "unbounded", }); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 56f9f8d6..6ba2f61e 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -24,6 +24,7 @@ import { Effect, Fiber, Layer, Metric, Option, PubSub, Ref, Stream } from "effec import * as SqlClient from "effect/unstable/sql/SqlClient"; import { + ProviderAdapterValidationError, ProviderAdapterSessionNotFoundError, ProviderUnsupportedError, ProviderValidationError, @@ -654,6 +655,251 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("stops stale sessions in other providers after a successful replacement start", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + const directory = yield* ProviderSessionDirectory; + const threadId = asThreadId("thread-provider-replacement"); + + const codexSession = yield* provider.startSession(threadId, { + provider: "codex", + threadId, + cwd: "/tmp/project-provider-replacement", + runtimeMode: "full-access", + }); + + routing.codex.stopSession.mockClear(); + routing.claude.stopSession.mockClear(); + + const claudeSession = yield* provider.startSession(threadId, { + provider: "claudeAgent", + threadId, + cwd: "/tmp/project-provider-replacement", + runtimeMode: "full-access", + }); + + assert.equal(codexSession.provider, "codex"); + assert.equal(claudeSession.provider, "claudeAgent"); + assert.deepEqual(routing.codex.stopSession.mock.calls, [[threadId]]); + assert.equal(routing.claude.stopSession.mock.calls.length, 0); + const binding = Option.getOrUndefined(yield* directory.getBinding(threadId)); + assert.equal(binding?.provider, "claudeAgent"); + assert.deepEqual(binding?.resumeCursor, claudeSession.resumeCursor); + + const sessions = yield* provider.listSessions(); + assert.deepEqual( + sessions + .filter((session) => session.threadId === threadId) + .map((session) => session.provider), + ["claudeAgent"], + ); + }), + ); + + it.effect("persists the replacement binding before stopping stale providers", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + const directory = yield* ProviderSessionDirectory; + const threadId = asThreadId("thread-provider-binding-order"); + + yield* provider.startSession(threadId, { + provider: "codex", + threadId, + cwd: "/tmp/project-provider-binding-order", + runtimeMode: "full-access", + }); + + const bindingDuringStopRef = { current: null as null | unknown }; + const originalStopSession = routing.codex.stopSession.getMockImplementation(); + routing.codex.stopSession.mockImplementation((stoppedThreadId: ThreadId) => + Effect.gen(function* () { + bindingDuringStopRef.current = Option.getOrUndefined( + yield* Effect.orDie(directory.getBinding(stoppedThreadId)), + ); + if (!originalStopSession) { + return; + } + return yield* originalStopSession(stoppedThreadId); + }), + ); + + const replacement = yield* provider.startSession(threadId, { + provider: "claudeAgent", + threadId, + cwd: "/tmp/project-provider-binding-order", + runtimeMode: "full-access", + }); + + const bindingDuringStop = bindingDuringStopRef.current as { + provider?: string; + resumeCursor?: unknown; + } | null; + assert.equal(bindingDuringStop?.provider, "claudeAgent"); + assert.deepEqual(bindingDuringStop?.resumeCursor, replacement.resumeCursor); + + routing.codex.stopSession.mockImplementation(originalStopSession ?? (() => Effect.void)); + }), + ); + + it.effect("rolls back replacement startup when stopping a stale provider fails", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + const directory = yield* ProviderSessionDirectory; + const threadId = asThreadId("thread-provider-stop-failure"); + + const initial = yield* provider.startSession(threadId, { + provider: "codex", + threadId, + cwd: "/tmp/project-provider-stop-failure", + runtimeMode: "full-access", + }); + + routing.codex.stopSession.mockImplementationOnce(() => + Effect.fail( + new ProviderAdapterValidationError({ + provider: "codex", + operation: "ProviderService.test", + issue: "simulated stale stop failure", + }), + ), + ); + + const failure = yield* Effect.flip( + provider.startSession(threadId, { + provider: "claudeAgent", + threadId, + cwd: "/tmp/project-provider-stop-failure", + runtimeMode: "full-access", + }), + ); + + assert.instanceOf(failure, ProviderValidationError); + assert.include(failure.issue, "Failed to stop stale provider session"); + assert.equal(routing.claude.stopSession.mock.calls.length, 1); + assert.deepEqual(routing.claude.stopSession.mock.calls[0], [threadId]); + const binding = Option.getOrUndefined(yield* directory.getBinding(threadId)); + assert.equal(binding?.provider, "codex"); + assert.deepEqual(binding?.resumeCursor, initial.resumeCursor); + + const sessions = yield* provider.listSessions(); + assert.deepEqual( + sessions + .filter((session) => session.threadId === threadId) + .map((session) => session.provider) + .toSorted(), + ["codex"], + ); + }), + ); + + it.effect("keeps the replacement binding when replacement shutdown fails during rollback", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + const directory = yield* ProviderSessionDirectory; + const threadId = asThreadId("thread-provider-replacement-stop-failure"); + + const initial = yield* provider.startSession(threadId, { + provider: "codex", + threadId, + cwd: "/tmp/project-provider-replacement-stop-failure", + runtimeMode: "full-access", + }); + + routing.codex.stopSession.mockImplementationOnce(() => + Effect.fail( + new ProviderAdapterValidationError({ + provider: "codex", + operation: "ProviderService.test", + issue: "simulated stale stop failure", + }), + ), + ); + routing.claude.stopSession.mockImplementationOnce(() => + Effect.fail( + new ProviderAdapterValidationError({ + provider: "claudeAgent", + operation: "ProviderService.test", + issue: "simulated replacement stop failure", + }), + ), + ); + + const failure = yield* Effect.flip( + provider.startSession(threadId, { + provider: "claudeAgent", + threadId, + cwd: "/tmp/project-provider-replacement-stop-failure", + runtimeMode: "full-access", + }), + ); + + assert.instanceOf(failure, ProviderValidationError); + assert.include(failure.issue, "Failed to stop stale provider session"); + + const binding = Option.getOrUndefined(yield* directory.getBinding(threadId)); + assert.equal(binding?.provider, "claudeAgent"); + assert.equal(routing.claude.stopSession.mock.calls.length >= 1, true); + assert.deepEqual(routing.claude.stopSession.mock.calls.at(-1), [threadId]); + + const sessions = yield* provider.listSessions(); + assert.deepEqual( + sessions + .filter((session) => session.threadId === threadId) + .map((session) => session.provider) + .toSorted(), + ["claudeAgent", "codex"], + ); + }), + ); + + it.effect("does not let stale shutdown remove the active replacement binding", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + const directory = yield* ProviderSessionDirectory; + const threadId = asThreadId("thread-provider-stale-delete-guard"); + + yield* provider.startSession(threadId, { + provider: "claudeAgent", + threadId, + cwd: "/tmp/project-provider-stale-delete-guard", + runtimeMode: "full-access", + }); + + const originalClaudeStopSession = routing.claude.stopSession.getMockImplementation(); + routing.claude.stopSession.mockImplementationOnce((stoppedThreadId: ThreadId) => + Effect.gen(function* () { + const bindingBeforeDelete = Option.getOrUndefined( + yield* Effect.orDie(directory.getBinding(stoppedThreadId)), + ); + assert.equal(bindingBeforeDelete?.provider, "codex"); + if (!originalClaudeStopSession) { + return; + } + return yield* originalClaudeStopSession(stoppedThreadId); + }), + ); + + const replacement = yield* provider.startSession(threadId, { + provider: "codex", + threadId, + cwd: "/tmp/project-provider-stale-delete-guard", + runtimeMode: "full-access", + }); + + const binding = Option.getOrUndefined(yield* directory.getBinding(threadId)); + assert.equal(binding?.provider, "codex"); + assert.deepEqual(binding?.resumeCursor, replacement.resumeCursor); + + const sessions = yield* provider.listSessions(); + assert.deepEqual( + sessions + .filter((session) => session.threadId === threadId) + .map((session) => session.provider), + ["codex"], + ); + }), + ); + it.effect("recovers stale sessions for sendTurn using persisted cwd", () => Effect.gen(function* () { const provider = yield* ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 85fe9fbc..4207e025 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -297,6 +297,68 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( return { adapter: recovered.adapter, threadId: input.threadId, isActive: true } as const; }); + const restorePersistedBinding = ( + threadId: ThreadId, + binding: ProviderRuntimeBinding | undefined, + ) => + binding + ? directory.upsert({ + threadId: binding.threadId, + provider: binding.provider, + ...(binding.adapterKey !== undefined ? { adapterKey: binding.adapterKey } : {}), + ...(binding.runtimeMode !== undefined ? { runtimeMode: binding.runtimeMode } : {}), + ...(binding.status !== undefined ? { status: binding.status } : {}), + ...(binding.resumeCursor !== undefined ? { resumeCursor: binding.resumeCursor } : {}), + ...(binding.runtimePayload !== undefined + ? { runtimePayload: binding.runtimePayload } + : {}), + }) + : directory.remove(threadId); + + const stopStaleSessionsForThread = Effect.fn("stopStaleSessionsForThread")(function* (input: { + readonly threadId: ThreadId; + readonly currentProvider: ProviderSession["provider"]; + }) { + const failures = yield* Effect.forEach(adapters, (adapter) => + adapter.provider === input.currentProvider + ? Effect.succeed(null) + : Effect.gen(function* () { + const hasSession = yield* adapter.hasSession(input.threadId); + if (!hasSession) { + return null; + } + + return yield* adapter.stopSession(input.threadId).pipe( + Effect.tap(() => + analytics.record("provider.session.stopped", { + provider: adapter.provider, + }), + ), + Effect.as(null), + Effect.catchCause((cause) => + Effect.logWarning("provider.session.stop-stale-failed", { + threadId: input.threadId, + provider: adapter.provider, + cause, + }).pipe(Effect.as([adapter.provider, cause] as const)), + ), + ); + }), + ); + + const failure = failures.find((result) => result !== null); + if (failure) { + const [provider, cause] = failure; + return yield* Effect.fail( + new ProviderValidationError({ + operation: "ProviderService.startSession", + issue: `Failed to stop stale provider session for '${provider}'.`, + cause, + }), + ); + } + }); + const startSession: ProviderServiceShape["startSession"] = Effect.fn("startSession")( function* (threadId, rawInput) { const parsed = yield* decodeInputOrValidationError({ @@ -354,6 +416,27 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( yield* upsertSessionBinding(session, threadId, { modelSelection: input.modelSelection, }); + yield* stopStaleSessionsForThread({ + threadId, + currentProvider: adapter.provider, + }).pipe( + Effect.catch((error) => + adapter.stopSession(threadId).pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => + Effect.logWarning("provider.session.stop-replacement-failed", { + threadId, + provider: adapter.provider, + cause, + }).pipe(Effect.andThen(Effect.fail(error))), + onSuccess: () => + restorePersistedBinding(threadId, persistedBinding).pipe( + Effect.andThen(Effect.fail(error)), + ), + }), + ), + ), + ); yield* analytics.record("provider.session.started", { provider: session.provider, runtimeMode: input.runtimeMode, diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 5b4ab061..338b2d03 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -133,6 +133,78 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL } })); + it("lists persisted bindings with metadata in oldest-first order", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const runtimeRepository = yield* ProviderSessionRuntimeRepository; + + const olderThreadId = ThreadId.make("thread-runtime-older"); + const newerThreadId = ThreadId.make("thread-runtime-newer"); + + yield* runtimeRepository.upsert({ + threadId: newerThreadId, + providerName: "codex", + adapterKey: "codex", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T12:05:00.000Z", + resumeCursor: { + opaque: "resume-newer", + }, + runtimePayload: { + cwd: "/tmp/newer", + }, + }); + + yield* runtimeRepository.upsert({ + threadId: olderThreadId, + providerName: "claudeAgent", + adapterKey: "claudeAgent", + runtimeMode: "approval-required", + status: "starting", + lastSeenAt: "2026-04-14T12:00:00.000Z", + resumeCursor: { + opaque: "resume-older", + }, + runtimePayload: { + cwd: "/tmp/older", + }, + }); + + const bindings = yield* directory.listBindings(); + + assert.deepEqual(bindings, [ + { + threadId: olderThreadId, + provider: "claudeAgent", + adapterKey: "claudeAgent", + runtimeMode: "approval-required", + status: "starting", + lastSeenAt: "2026-04-14T12:00:00.000Z", + resumeCursor: { + opaque: "resume-older", + }, + runtimePayload: { + cwd: "/tmp/older", + }, + }, + { + threadId: newerThreadId, + provider: "codex", + adapterKey: "codex", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T12:05:00.000Z", + resumeCursor: { + opaque: "resume-newer", + }, + runtimePayload: { + cwd: "/tmp/newer", + }, + }, + ]); + })); + it("resets adapterKey to the new provider when provider changes without an explicit adapter key", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 9b2b5ea6..e9bb04b7 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -1,11 +1,13 @@ import { ProviderKind, type ThreadId } from "@t3tools/contracts"; import { Effect, Layer, Option, Schema } from "effect"; +import type { ProviderSessionRuntime } from "../../persistence/Services/ProviderSessionRuntime.ts"; import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; import { ProviderSessionDirectoryPersistenceError, ProviderValidationError } from "../Errors.ts"; import { ProviderSessionDirectory, type ProviderRuntimeBinding, + type ProviderRuntimeBindingWithMetadata, type ProviderSessionDirectoryShape, } from "../Services/ProviderSessionDirectory.ts"; @@ -50,6 +52,27 @@ function mergeRuntimePayload( return next; } +function toRuntimeBinding( + runtime: ProviderSessionRuntime, + operation: string, +): Effect.Effect { + return decodeProviderKind(runtime.providerName, operation).pipe( + Effect.map( + (provider) => + ({ + threadId: runtime.threadId, + provider, + adapterKey: runtime.adapterKey, + runtimeMode: runtime.runtimeMode, + status: runtime.status, + resumeCursor: runtime.resumeCursor, + runtimePayload: runtime.runtimePayload, + lastSeenAt: runtime.lastSeenAt, + }) satisfies ProviderRuntimeBindingWithMetadata, + ), + ); +} + const makeProviderSessionDirectory = Effect.gen(function* () { const repository = yield* ProviderSessionRuntimeRepository; @@ -60,18 +83,8 @@ const makeProviderSessionDirectory = Effect.gen(function* () { Option.match(runtime, { onNone: () => Effect.succeed(Option.none()), onSome: (value) => - decodeProviderKind(value.providerName, "ProviderSessionDirectory.getBinding").pipe( - Effect.map((provider) => - Option.some({ - threadId: value.threadId, - provider, - adapterKey: value.adapterKey, - runtimeMode: value.runtimeMode, - status: value.status, - resumeCursor: value.resumeCursor, - runtimePayload: value.runtimePayload, - }), - ), + toRuntimeBinding(value, "ProviderSessionDirectory.getBinding").pipe( + Effect.map((binding) => Option.some(binding)), ), }), ), @@ -145,12 +158,25 @@ const makeProviderSessionDirectory = Effect.gen(function* () { Effect.map((rows) => rows.map((row) => row.threadId)), ); + const listBindings: ProviderSessionDirectoryShape["listBindings"] = () => + repository.list().pipe( + Effect.mapError(toPersistenceError("ProviderSessionDirectory.listBindings:list")), + Effect.flatMap((rows) => + Effect.forEach( + rows, + (row) => toRuntimeBinding(row, "ProviderSessionDirectory.listBindings"), + { concurrency: "unbounded" }, + ), + ), + ); + return { upsert, getProvider, getBinding, remove, listThreadIds, + listBindings, } satisfies ProviderSessionDirectoryShape; }); diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts new file mode 100644 index 00000000..1f7fb14c --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -0,0 +1,644 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; +import { Effect, Exit, Layer, ManagedRuntime, Option, Scope, Stream } from "effect"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "../../orchestration/Services/OrchestrationEngine.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; +import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; +import { ProviderValidationError } from "../Errors.ts"; +import { ProviderSessionReaper } from "../Services/ProviderSessionReaper.ts"; +import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts"; +import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; +import { makeProviderSessionReaperLive } from "./ProviderSessionReaper.ts"; + +const defaultModelSelection = { + provider: "codex", + model: "gpt-5-codex", +} as const; + +async function waitFor( + predicate: () => boolean | Promise, + timeoutMs = 2_000, +): Promise { + const deadline = Date.now() + timeoutMs; + const poll = async (): Promise => { + if (await predicate()) { + return; + } + if (Date.now() >= deadline) { + throw new Error("Timed out waiting for expectation."); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + return poll(); + }; + + return poll(); +} + +const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; + +function makeReadModel( + threads: ReadonlyArray<{ + readonly id: ThreadId; + readonly latestTurnCompletedAt?: string | null; + readonly session: { + readonly threadId: ThreadId; + readonly status: "starting" | "running" | "ready" | "interrupted" | "stopped" | "error"; + readonly providerName: "codex" | "claudeAgent"; + readonly runtimeMode: "approval-required" | "full-access" | "auto-accept-edits"; + readonly activeTurnId: TurnId | null; + readonly lastError: string | null; + readonly updatedAt: string; + } | null; + }>, +) { + const now = new Date().toISOString(); + const projectId = ProjectId.make("project-provider-session-reaper"); + + return { + snapshotSequence: 0, + updatedAt: now, + projects: [ + { + id: projectId, + title: "Provider Reaper Project", + workspaceRoot: "/tmp/provider-reaper-project", + defaultModelSelection, + scripts: [], + createdAt: now, + updatedAt: now, + deletedAt: null, + }, + ], + threads: threads.map((thread) => ({ + id: thread.id, + projectId, + title: `Thread ${thread.id}`, + modelSelection: defaultModelSelection, + interactionMode: "default" as const, + runtimeMode: "full-access" as const, + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + latestTurn: thread.latestTurnCompletedAt + ? { + turnId: TurnId.make(`${thread.id}-latest-turn`), + state: "completed" as const, + requestedAt: thread.latestTurnCompletedAt, + startedAt: thread.latestTurnCompletedAt, + completedAt: thread.latestTurnCompletedAt, + assistantMessageId: null, + } + : null, + messages: [], + session: thread.session, + activities: [], + proposedPlans: [], + checkpoints: [], + deletedAt: null, + })), + }; +} + +describe("ProviderSessionReaper", () => { + let runtime: ManagedRuntime.ManagedRuntime< + ProviderSessionReaper | ProviderSessionRuntimeRepository, + unknown + > | null = null; + let scope: Scope.Closeable | null = null; + + afterEach(async () => { + if (scope) { + await Effect.runPromise(Scope.close(scope, Exit.void)); + } + scope = null; + if (runtime) { + await runtime.dispose(); + } + runtime = null; + }); + + async function createHarness(input: { + readonly readModel: ReturnType; + readonly stopSessionImplementation?: (input: { + readonly threadId: ThreadId; + }) => ReturnType; + }) { + const stoppedThreadIds = new Set(); + const stopSession = vi.fn( + (request) => + (input.stopSessionImplementation + ? input.stopSessionImplementation(request) + : Effect.sync(() => { + stoppedThreadIds.add(request.threadId); + })) as ReturnType, + ); + + const providerService: ProviderServiceShape = { + startSession: () => unsupported(), + sendTurn: () => unsupported(), + interruptTurn: () => unsupported(), + respondToRequest: () => unsupported(), + respondToUserInput: () => unsupported(), + stopSession, + listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), + rollbackConversation: () => unsupported(), + streamEvents: Stream.empty, + }; + + const orchestrationEngine: OrchestrationEngineShape = { + getReadModel: () => Effect.succeed(input.readModel), + readEvents: () => Stream.empty, + dispatch: () => unsupported(), + streamDomainEvents: Stream.empty, + }; + + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + Layer.provide(SqlitePersistenceMemory), + ); + const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const layer = makeProviderSessionReaperLive({ + inactivityThresholdMs: 1_000, + sweepIntervalMs: 60_000, + }).pipe( + Layer.provideMerge(providerSessionDirectoryLayer), + Layer.provideMerge(runtimeRepositoryLayer), + Layer.provideMerge(Layer.succeed(ProviderService, providerService)), + Layer.provideMerge(Layer.succeed(OrchestrationEngineService, orchestrationEngine)), + Layer.provideMerge(NodeServices.layer), + ); + + runtime = ManagedRuntime.make(layer); + return { stopSession, stoppedThreadIds }; + } + + it("reaps stale persisted sessions without active turns", async () => { + const threadId = ThreadId.make("thread-reaper-stale"); + const now = new Date().toISOString(); + const harness = await createHarness({ + readModel: makeReadModel([ + { + id: threadId, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + ]), + }); + const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + + await runtime!.runPromise( + repository.upsert({ + threadId, + providerName: "claudeAgent", + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T00:00:00.000Z", + resumeCursor: { + opaque: "resume-stale", + }, + runtimePayload: null, + }), + ); + + const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + + await waitFor(() => harness.stopSession.mock.calls.length === 1); + + expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ threadId }); + expect(harness.stoppedThreadIds.has(threadId)).toBe(true); + }); + + it("does not reap a session immediately after a long turn finishes", async () => { + const threadId = ThreadId.make("thread-reaper-long-turn-complete"); + const completedAt = new Date(Date.now() - 100).toISOString(); + const harness = await createHarness({ + readModel: makeReadModel([ + { + id: threadId, + latestTurnCompletedAt: completedAt, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: "2026-04-14T00:00:00.000Z", + }, + }, + ]), + }); + const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + + await runtime!.runPromise( + repository.upsert({ + threadId, + providerName: "claudeAgent", + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T00:00:00.000Z", + resumeCursor: { + opaque: "resume-long-turn", + }, + runtimePayload: null, + }), + ); + + const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(harness.stopSession).not.toHaveBeenCalled(); + }); + + it("skips stale sessions when the thread still has an active turn", async () => { + const threadId = ThreadId.make("thread-reaper-active-turn"); + const turnId = TurnId.make("turn-reaper-active"); + const now = new Date().toISOString(); + const harness = await createHarness({ + readModel: makeReadModel([ + { + id: threadId, + session: { + threadId, + status: "running", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: turnId, + lastError: null, + updatedAt: now, + }, + }, + ]), + }); + const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + + await runtime!.runPromise( + repository.upsert({ + threadId, + providerName: "claudeAgent", + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T00:00:00.000Z", + resumeCursor: { + opaque: "resume-active-turn", + }, + runtimePayload: null, + }), + ); + + const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(harness.stopSession).not.toHaveBeenCalled(); + const remaining = await runtime!.runPromise(repository.getByThreadId({ threadId })); + expect(Option.isSome(remaining)).toBe(true); + }); + + it("does not reap sessions that are still within the inactivity threshold", async () => { + const threadId = ThreadId.make("thread-reaper-fresh"); + const now = new Date().toISOString(); + const harness = await createHarness({ + readModel: makeReadModel([ + { + id: threadId, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + ]), + }); + const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + + await runtime!.runPromise( + repository.upsert({ + threadId, + providerName: "claudeAgent", + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "running", + lastSeenAt: now, + resumeCursor: { + opaque: "resume-fresh", + }, + runtimePayload: null, + }), + ); + + const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(harness.stopSession).not.toHaveBeenCalled(); + const remaining = await runtime!.runPromise(repository.getByThreadId({ threadId })); + expect(Option.isSome(remaining)).toBe(true); + }); + + it("skips persisted sessions that are already marked stopped", async () => { + const threadId = ThreadId.make("thread-reaper-stopped"); + const now = new Date().toISOString(); + const harness = await createHarness({ + readModel: makeReadModel([ + { + id: threadId, + session: { + threadId, + status: "stopped", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + ]), + }); + const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + + await runtime!.runPromise( + repository.upsert({ + threadId, + providerName: "claudeAgent", + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "stopped", + lastSeenAt: "2026-04-14T00:00:00.000Z", + resumeCursor: { + opaque: "resume-stopped", + }, + runtimePayload: null, + }), + ); + + const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(harness.stopSession).not.toHaveBeenCalled(); + const remaining = await runtime!.runPromise(repository.getByThreadId({ threadId })); + expect(Option.isSome(remaining)).toBe(true); + }); + + it("continues reaping other sessions when one stop attempt fails", async () => { + const failedThreadId = ThreadId.make("thread-reaper-stop-failure"); + const reapedThreadId = ThreadId.make("thread-reaper-stop-success"); + const now = new Date().toISOString(); + const harness = await createHarness({ + readModel: makeReadModel([ + { + id: failedThreadId, + session: { + threadId: failedThreadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + { + id: reapedThreadId, + session: { + threadId: reapedThreadId, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + ]), + stopSessionImplementation: (request) => + request.threadId === failedThreadId + ? Effect.fail( + new ProviderValidationError({ + operation: "ProviderSessionReaper.test", + issue: "simulated stop failure", + }), + ) + : Effect.void, + }); + const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + + await runtime!.runPromise( + repository.upsert({ + threadId: failedThreadId, + providerName: "claudeAgent", + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T00:00:00.000Z", + resumeCursor: { + opaque: "resume-failure", + }, + runtimePayload: null, + }), + ); + await runtime!.runPromise( + repository.upsert({ + threadId: reapedThreadId, + providerName: "codex", + adapterKey: "codex", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T00:01:00.000Z", + resumeCursor: { + opaque: "resume-success", + }, + runtimePayload: null, + }), + ); + + const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + + await waitFor(() => harness.stopSession.mock.calls.length === 2); + + expect(harness.stopSession.mock.calls.map(([request]) => request.threadId)).toEqual([ + failedThreadId, + reapedThreadId, + ]); + }); + + it("continues reaping other sessions when one stop attempt defects", async () => { + const defectThreadId = ThreadId.make("thread-reaper-stop-defect"); + const reapedThreadId = ThreadId.make("thread-reaper-stop-after-defect"); + const now = new Date().toISOString(); + const harness = await createHarness({ + readModel: makeReadModel([ + { + id: defectThreadId, + session: { + threadId: defectThreadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + { + id: reapedThreadId, + session: { + threadId: reapedThreadId, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + ]), + stopSessionImplementation: (request) => + request.threadId === defectThreadId + ? Effect.die(new Error("simulated stop defect")) + : Effect.void, + }); + const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + + await runtime!.runPromise( + repository.upsert({ + threadId: defectThreadId, + providerName: "claudeAgent", + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T00:00:00.000Z", + resumeCursor: { + opaque: "resume-defect", + }, + runtimePayload: null, + }), + ); + await runtime!.runPromise( + repository.upsert({ + threadId: reapedThreadId, + providerName: "codex", + adapterKey: "codex", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T00:01:00.000Z", + resumeCursor: { + opaque: "resume-after-defect", + }, + runtimePayload: null, + }), + ); + + const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + + await waitFor(() => harness.stopSession.mock.calls.length === 2); + + expect(harness.stopSession.mock.calls.map(([request]) => request.threadId)).toEqual([ + defectThreadId, + reapedThreadId, + ]); + }); + + it("skips reaping when the binding was refreshed after the sweep snapshot", async () => { + const threadId = ThreadId.make("thread-reaper-replacement-race"); + const now = new Date().toISOString(); + const harness = await createHarness({ + readModel: makeReadModel([ + { + id: threadId, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + ]), + stopSessionImplementation: () => + Effect.fail( + new ProviderValidationError({ + operation: "ProviderSessionReaper.test", + issue: "should not stop refreshed session", + }), + ), + }); + const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + + await runtime!.runPromise( + repository.upsert({ + threadId, + providerName: "codex", + adapterKey: "codex", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T00:00:00.000Z", + resumeCursor: { + opaque: "resume-race-old", + }, + runtimePayload: null, + }), + ); + + const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + + await runtime!.runPromise( + repository.upsert({ + threadId, + providerName: "claudeAgent", + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "running", + lastSeenAt: new Date().toISOString(), + resumeCursor: { + opaque: "resume-race-new", + }, + runtimePayload: null, + }), + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(harness.stopSession).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.ts new file mode 100644 index 00000000..4803e45c --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.ts @@ -0,0 +1,159 @@ +import { Duration, Effect, Layer, Schedule } from "effect"; + +import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; +import { + ProviderSessionReaper, + type ProviderSessionReaperShape, +} from "../Services/ProviderSessionReaper.ts"; +import { ProviderService } from "../Services/ProviderService.ts"; + +const DEFAULT_INACTIVITY_THRESHOLD_MS = 30 * 60 * 1000; +const DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1000; + +export interface ProviderSessionReaperLiveOptions { + readonly inactivityThresholdMs?: number; + readonly sweepIntervalMs?: number; +} + +const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) => + Effect.gen(function* () { + const providerService = yield* ProviderService; + const directory = yield* ProviderSessionDirectory; + const orchestrationEngine = yield* OrchestrationEngineService; + + const inactivityThresholdMs = Math.max( + 1, + options?.inactivityThresholdMs ?? DEFAULT_INACTIVITY_THRESHOLD_MS, + ); + const sweepIntervalMs = Math.max(1, options?.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS); + + const sweep = Effect.gen(function* () { + const readModel = yield* orchestrationEngine.getReadModel(); + const threadsById = new Map(readModel.threads.map((thread) => [thread.id, thread] as const)); + const bindings = yield* directory.listBindings(); + const now = Date.now(); + let reapedCount = 0; + + for (const binding of bindings) { + if (binding.status === "stopped") { + continue; + } + + const thread = threadsById.get(binding.threadId); + const lastSeenMs = Math.max( + ...[binding.lastSeenAt, thread?.latestTurn?.completedAt] + .flatMap((value) => + typeof value === "string" && value.length > 0 ? [Date.parse(value)] : [], + ) + .filter(Number.isFinite), + ); + if (Number.isNaN(lastSeenMs)) { + yield* Effect.logWarning("provider.session.reaper.invalid-last-seen", { + threadId: binding.threadId, + provider: binding.provider, + lastSeenAt: binding.lastSeenAt, + }); + continue; + } + + const idleDurationMs = now - lastSeenMs; + if (idleDurationMs < inactivityThresholdMs) { + continue; + } + + if (thread?.session?.activeTurnId != null) { + yield* Effect.logDebug("provider.session.reaper.skipped-active-turn", { + threadId: binding.threadId, + activeTurnId: thread.session.activeTurnId, + idleDurationMs, + }); + continue; + } + + const currentBinding = (yield* directory.listBindings()).find( + (candidate) => candidate.threadId === binding.threadId, + ); + if ( + !currentBinding || + currentBinding.provider !== binding.provider || + currentBinding.lastSeenAt !== binding.lastSeenAt || + currentBinding.status === "stopped" + ) { + yield* Effect.logDebug("provider.session.reaper.skipped-updated-binding", { + threadId: binding.threadId, + provider: binding.provider, + lastSeenAt: binding.lastSeenAt, + currentProvider: currentBinding?.provider ?? null, + currentLastSeenAt: currentBinding?.lastSeenAt ?? null, + currentStatus: currentBinding?.status ?? null, + }); + continue; + } + + const reaped = yield* providerService.stopSession({ threadId: binding.threadId }).pipe( + Effect.tap(() => + Effect.logInfo("provider.session.reaped", { + threadId: binding.threadId, + provider: binding.provider, + idleDurationMs, + reason: "inactivity_threshold", + }), + ), + Effect.as(true), + Effect.catchCause((cause) => + Effect.logWarning("provider.session.reaper.stop-failed", { + threadId: binding.threadId, + provider: binding.provider, + idleDurationMs, + cause, + }).pipe(Effect.as(false)), + ), + ); + + if (reaped) { + reapedCount += 1; + } + } + + if (reapedCount > 0) { + yield* Effect.logInfo("provider.session.reaper.sweep-complete", { + reapedCount, + totalBindings: bindings.length, + }); + } + }); + + const start: ProviderSessionReaperShape["start"] = () => + Effect.gen(function* () { + yield* Effect.forkScoped( + sweep.pipe( + Effect.catch((error: unknown) => + Effect.logWarning("provider.session.reaper.sweep-failed", { + error, + }), + ), + Effect.catchDefect((defect: unknown) => + Effect.logWarning("provider.session.reaper.sweep-defect", { + defect, + }), + ), + Effect.repeat(Schedule.spaced(Duration.millis(sweepIntervalMs))), + ), + ); + + yield* Effect.logInfo("provider.session.reaper.started", { + inactivityThresholdMs, + sweepIntervalMs, + }); + }); + + return { + start, + } satisfies ProviderSessionReaperShape; + }); + +export const makeProviderSessionReaperLive = (options?: ProviderSessionReaperLiveOptions) => + Layer.effect(ProviderSessionReaper, makeProviderSessionReaper(options)); + +export const ProviderSessionReaperLive = makeProviderSessionReaperLive(); diff --git a/apps/server/src/provider/Services/ClaudeProvider.ts b/apps/server/src/provider/Services/ClaudeProvider.ts index 7f90c549..7e21ac56 100644 --- a/apps/server/src/provider/Services/ClaudeProvider.ts +++ b/apps/server/src/provider/Services/ClaudeProvider.ts @@ -1,6 +1,6 @@ import { Context } from "effect"; -import type { ServerProviderShape } from "./ServerProvider"; +import type { ServerProviderShape } from "./ServerProvider.ts"; export interface ClaudeProviderShape extends ServerProviderShape {} diff --git a/apps/server/src/provider/Services/CodexProvider.ts b/apps/server/src/provider/Services/CodexProvider.ts index 6820d4cb..e116f1a7 100644 --- a/apps/server/src/provider/Services/CodexProvider.ts +++ b/apps/server/src/provider/Services/CodexProvider.ts @@ -1,6 +1,6 @@ import { Context } from "effect"; -import type { ServerProviderShape } from "./ServerProvider"; +import type { ServerProviderShape } from "./ServerProvider.ts"; export interface CodexProviderShape extends ServerProviderShape {} diff --git a/apps/server/src/provider/Services/CopilotProvider.ts b/apps/server/src/provider/Services/CopilotProvider.ts index cff00993..2dddc77a 100644 --- a/apps/server/src/provider/Services/CopilotProvider.ts +++ b/apps/server/src/provider/Services/CopilotProvider.ts @@ -1,6 +1,6 @@ import { Context } from "effect"; -import type { ServerProviderShape } from "./ServerProvider"; +import type { ServerProviderShape } from "./ServerProvider.ts"; export interface CopilotProviderShape extends ServerProviderShape {} diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts index aa048362..a5be4d63 100644 --- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -22,6 +22,10 @@ export interface ProviderRuntimeBinding { readonly runtimeMode?: RuntimeMode; } +export interface ProviderRuntimeBindingWithMetadata extends ProviderRuntimeBinding { + readonly lastSeenAt: string; +} + export type ProviderSessionDirectoryReadError = ProviderSessionDirectoryPersistenceError; export type ProviderSessionDirectoryWriteError = @@ -49,6 +53,11 @@ export interface ProviderSessionDirectoryShape { ReadonlyArray, ProviderSessionDirectoryPersistenceError >; + + readonly listBindings: () => Effect.Effect< + ReadonlyArray, + ProviderSessionDirectoryPersistenceError + >; } export class ProviderSessionDirectory extends Context.Service< diff --git a/apps/server/src/provider/Services/ProviderSessionReaper.ts b/apps/server/src/provider/Services/ProviderSessionReaper.ts new file mode 100644 index 00000000..b13b6f7e --- /dev/null +++ b/apps/server/src/provider/Services/ProviderSessionReaper.ts @@ -0,0 +1,14 @@ +import { Context } from "effect"; +import type { Effect, Scope } from "effect"; + +export interface ProviderSessionReaperShape { + /** + * Start the background provider session reaper within the provided scope. + */ + readonly start: () => Effect.Effect; +} + +export class ProviderSessionReaper extends Context.Service< + ProviderSessionReaper, + ProviderSessionReaperShape +>()("t3/provider/Services/ProviderSessionReaper") {} diff --git a/apps/server/src/provider/cliVersion.test.ts b/apps/server/src/provider/cliVersion.test.ts new file mode 100644 index 00000000..133ba39b --- /dev/null +++ b/apps/server/src/provider/cliVersion.test.ts @@ -0,0 +1,26 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { compareCliVersions, normalizeCliVersion } from "./cliVersion.ts"; + +describe("cliVersion", () => { + it("normalizes versions with a missing patch segment", () => { + assert.strictEqual(normalizeCliVersion("2.1"), "2.1.0"); + }); + + it("compares prerelease versions before stable versions", () => { + assert.isTrue(compareCliVersions("2.1.111-beta.1", "2.1.111") < 0); + }); + + it("preserves multi-hyphen prerelease identifiers during normalization", () => { + assert.strictEqual(normalizeCliVersion("2.1.111-beta-1"), "2.1.111-beta-1"); + }); + + it("compares multi-hyphen prerelease identifiers semantically", () => { + assert.isTrue(compareCliVersions("2.1.111-beta-1", "2.1.111-beta-2") < 0); + assert.isTrue(compareCliVersions("2.1.111-beta-2", "2.1.111-beta.1") > 0); + }); + + it("rejects malformed numeric segments", () => { + assert.isTrue(compareCliVersions("1.2.3abc", "1.2.10") > 0); + }); +}); diff --git a/apps/server/src/provider/cliVersion.ts b/apps/server/src/provider/cliVersion.ts new file mode 100644 index 00000000..ebb5b7b1 --- /dev/null +++ b/apps/server/src/provider/cliVersion.ts @@ -0,0 +1,129 @@ +interface ParsedCliSemver { + readonly major: number; + readonly minor: number; + readonly patch: number; + readonly prerelease: ReadonlyArray; +} + +const CLI_VERSION_NUMBER_SEGMENT = /^\d+$/; + +export function normalizeCliVersion(version: string): string { + const trimmed = version.trim(); + const firstHyphenIndex = trimmed.indexOf("-"); + const main = firstHyphenIndex === -1 ? trimmed : trimmed.slice(0, firstHyphenIndex); + const prerelease = + firstHyphenIndex === -1 ? undefined : trimmed.slice(firstHyphenIndex + 1).trim(); + const segments = (main ?? "") + .split(".") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0); + + if (segments.length === 2) { + segments.push("0"); + } + + return prerelease ? `${segments.join(".")}-${prerelease}` : segments.join("."); +} + +function parseCliSemver(version: string): ParsedCliSemver | null { + const normalized = normalizeCliVersion(version); + const firstHyphenIndex = normalized.indexOf("-"); + const main = firstHyphenIndex === -1 ? normalized : normalized.slice(0, firstHyphenIndex); + const prerelease = firstHyphenIndex === -1 ? undefined : normalized.slice(firstHyphenIndex + 1); + const segments = main.split("."); + if (segments.length !== 3) { + return null; + } + + const [majorSegment, minorSegment, patchSegment] = segments; + if (majorSegment === undefined || minorSegment === undefined || patchSegment === undefined) { + return null; + } + if ( + !CLI_VERSION_NUMBER_SEGMENT.test(majorSegment) || + !CLI_VERSION_NUMBER_SEGMENT.test(minorSegment) || + !CLI_VERSION_NUMBER_SEGMENT.test(patchSegment) + ) { + return null; + } + + const major = Number.parseInt(majorSegment, 10); + const minor = Number.parseInt(minorSegment, 10); + const patch = Number.parseInt(patchSegment, 10); + if (![major, minor, patch].every(Number.isInteger)) { + return null; + } + + return { + major, + minor, + patch, + prerelease: + prerelease + ?.split(".") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0) ?? [], + }; +} + +function comparePrereleaseIdentifier(left: string, right: string): number { + const leftNumeric = /^\d+$/.test(left); + const rightNumeric = /^\d+$/.test(right); + + if (leftNumeric && rightNumeric) { + return Number.parseInt(left, 10) - Number.parseInt(right, 10); + } + if (leftNumeric) { + return -1; + } + if (rightNumeric) { + return 1; + } + return left.localeCompare(right); +} + +export function compareCliVersions(left: string, right: string): number { + const parsedLeft = parseCliSemver(left); + const parsedRight = parseCliSemver(right); + if (!parsedLeft || !parsedRight) { + return left.localeCompare(right); + } + + if (parsedLeft.major !== parsedRight.major) { + return parsedLeft.major - parsedRight.major; + } + if (parsedLeft.minor !== parsedRight.minor) { + return parsedLeft.minor - parsedRight.minor; + } + if (parsedLeft.patch !== parsedRight.patch) { + return parsedLeft.patch - parsedRight.patch; + } + + if (parsedLeft.prerelease.length === 0 && parsedRight.prerelease.length === 0) { + return 0; + } + if (parsedLeft.prerelease.length === 0) { + return 1; + } + if (parsedRight.prerelease.length === 0) { + return -1; + } + + const length = Math.max(parsedLeft.prerelease.length, parsedRight.prerelease.length); + for (let index = 0; index < length; index += 1) { + const leftIdentifier = parsedLeft.prerelease[index]; + const rightIdentifier = parsedRight.prerelease[index]; + if (leftIdentifier === undefined) { + return -1; + } + if (rightIdentifier === undefined) { + return 1; + } + const comparison = comparePrereleaseIdentifier(leftIdentifier, rightIdentifier); + if (comparison !== 0) { + return comparison; + } + } + + return 0; +} diff --git a/apps/server/src/provider/codexAppServer.ts b/apps/server/src/provider/codexAppServer.ts index 7b3c9eeb..24a9e29c 100644 --- a/apps/server/src/provider/codexAppServer.ts +++ b/apps/server/src/provider/codexAppServer.ts @@ -1,7 +1,7 @@ import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; import readline from "node:readline"; import type { ServerProviderSkill } from "@t3tools/contracts"; -import { readCodexAccountSnapshot, type CodexAccountSnapshot } from "./codexAccount"; +import { readCodexAccountSnapshot, type CodexAccountSnapshot } from "./codexAccount.ts"; interface JsonRpcProbeResponse { readonly id?: unknown; diff --git a/apps/server/src/provider/codexCliVersion.ts b/apps/server/src/provider/codexCliVersion.ts index 54402001..33f7cf85 100644 --- a/apps/server/src/provider/codexCliVersion.ts +++ b/apps/server/src/provider/codexCliVersion.ts @@ -1,121 +1,10 @@ +import { compareCliVersions, normalizeCliVersion } from "./cliVersion.ts"; + const CODEX_VERSION_PATTERN = /\bv?(\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?)\b/; export const MINIMUM_CODEX_CLI_VERSION = "0.37.0"; -interface ParsedSemver { - readonly major: number; - readonly minor: number; - readonly patch: number; - readonly prerelease: ReadonlyArray; -} - -function normalizeCodexVersion(version: string): string { - const [main, prerelease] = version.trim().split("-", 2); - const segments = (main ?? "") - .split(".") - .map((segment) => segment.trim()) - .filter((segment) => segment.length > 0); - - if (segments.length === 2) { - segments.push("0"); - } - - return prerelease ? `${segments.join(".")}-${prerelease}` : segments.join("."); -} - -function parseSemver(version: string): ParsedSemver | null { - const normalized = normalizeCodexVersion(version); - const [main = "", prerelease] = normalized.split("-", 2); - const segments = main.split("."); - if (segments.length !== 3) { - return null; - } - - const [majorSegment, minorSegment, patchSegment] = segments; - if (majorSegment === undefined || minorSegment === undefined || patchSegment === undefined) { - return null; - } - - const major = Number.parseInt(majorSegment, 10); - const minor = Number.parseInt(minorSegment, 10); - const patch = Number.parseInt(patchSegment, 10); - if (![major, minor, patch].every(Number.isInteger)) { - return null; - } - - return { - major, - minor, - patch, - prerelease: - prerelease - ?.split(".") - .map((segment) => segment.trim()) - .filter((segment) => segment.length > 0) ?? [], - }; -} - -function comparePrereleaseIdentifier(left: string, right: string): number { - const leftNumeric = /^\d+$/.test(left); - const rightNumeric = /^\d+$/.test(right); - - if (leftNumeric && rightNumeric) { - return Number.parseInt(left, 10) - Number.parseInt(right, 10); - } - if (leftNumeric) { - return -1; - } - if (rightNumeric) { - return 1; - } - return left.localeCompare(right); -} - -export function compareCodexCliVersions(left: string, right: string): number { - const parsedLeft = parseSemver(left); - const parsedRight = parseSemver(right); - if (!parsedLeft || !parsedRight) { - return left.localeCompare(right); - } - - if (parsedLeft.major !== parsedRight.major) { - return parsedLeft.major - parsedRight.major; - } - if (parsedLeft.minor !== parsedRight.minor) { - return parsedLeft.minor - parsedRight.minor; - } - if (parsedLeft.patch !== parsedRight.patch) { - return parsedLeft.patch - parsedRight.patch; - } - - if (parsedLeft.prerelease.length === 0 && parsedRight.prerelease.length === 0) { - return 0; - } - if (parsedLeft.prerelease.length === 0) { - return 1; - } - if (parsedRight.prerelease.length === 0) { - return -1; - } - - const length = Math.max(parsedLeft.prerelease.length, parsedRight.prerelease.length); - for (let index = 0; index < length; index += 1) { - const leftIdentifier = parsedLeft.prerelease[index]; - const rightIdentifier = parsedRight.prerelease[index]; - if (leftIdentifier === undefined) { - return -1; - } - if (rightIdentifier === undefined) { - return 1; - } - const comparison = comparePrereleaseIdentifier(leftIdentifier, rightIdentifier); - if (comparison !== 0) { - return comparison; - } - } - - return 0; -} +export const compareCodexCliVersions = compareCliVersions; export function parseCodexCliVersion(output: string): string | null { const match = CODEX_VERSION_PATTERN.exec(output); @@ -123,12 +12,7 @@ export function parseCodexCliVersion(output: string): string | null { return null; } - const parsed = parseSemver(match[1]); - if (!parsed) { - return null; - } - - return normalizeCodexVersion(match[1]); + return normalizeCliVersion(match[1]); } export function isCodexCliVersionSupported(version: string): boolean { diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index 856594c1..1d3bf52f 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -2,7 +2,7 @@ import type { ServerProvider } from "@t3tools/contracts"; import { Duration, Effect, PubSub, Ref, Scope, Stream } from "effect"; import * as Semaphore from "effect/Semaphore"; -import type { ServerProviderShape } from "./Services/ServerProvider"; +import type { ServerProviderShape } from "./Services/ServerProvider.ts"; import { ServerSettingsError } from "@t3tools/contracts"; export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* < diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 8e4f69dc..2b0fc9dc 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -11,7 +11,7 @@ import type { import { Effect, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { normalizeModelSlug } from "@t3tools/shared/model"; -import { isWindowsCommandNotFound } from "../processRunner"; +import { isWindowsCommandNotFound } from "../processRunner.ts"; export const DEFAULT_TIMEOUT_MS = 4_000; @@ -98,7 +98,7 @@ export function extractAuthBoolean(value: unknown): boolean | undefined { } export function parseGenericCliVersion(output: string): string | null { - const match = output.match(/\b(\d+\.\d+\.\d+)\b/); + const match = output.match(/\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/); return match?.[1] ?? null; } @@ -148,7 +148,7 @@ export function buildServerProvider(input: { checkedAt: input.checkedAt, ...(input.probe.message ? { message: input.probe.message } : {}), models: input.models, - ...(input.probe.quotaSnapshots && input.probe.quotaSnapshots.length > 0 + ...(input.probe.quotaSnapshots !== undefined ? { quotaSnapshots: [...input.probe.quotaSnapshots] } : {}), slashCommands: [...(input.slashCommands ?? [])], diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts index 45b009bd..c5efb881 100644 --- a/apps/server/src/provider/providerStatusCache.test.ts +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -8,7 +8,7 @@ import { readProviderStatusCache, resolveProviderStatusCachePath, writeProviderStatusCache, -} from "./providerStatusCache"; +} from "./providerStatusCache.ts"; const makeProvider = ( provider: ServerProvider["provider"], @@ -104,7 +104,6 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { status: cachedCodex.status, auth: cachedCodex.auth, checkedAt: cachedCodex.checkedAt, - quotaSnapshots: cachedCodex.quotaSnapshots, slashCommands: cachedCodex.slashCommands, skills: cachedCodex.skills, message: cachedCodex.message, @@ -112,6 +111,151 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { ); }); + it("preserves cached runtime-discovered models during cache hydration", () => { + const cachedCopilot = makeProvider("copilot", { + models: [ + { + slug: "gpt-5", + name: "GPT-5", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + { + slug: "claude-opus-4.7", + name: "Claude Opus 4.7", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ], + }); + const fallbackCopilot = makeProvider("copilot", { + models: [ + { + slug: "gpt-5", + name: "GPT-5 fallback", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ], + }); + + assert.deepStrictEqual( + hydrateCachedProvider({ + cachedProvider: cachedCopilot, + fallbackProvider: fallbackCopilot, + }).models, + cachedCopilot.models, + ); + }); + + it("does not resurrect removed cached custom models during cache hydration", () => { + const cachedClaude = makeProvider("claudeAgent", { + models: [ + { + slug: "claude-custom-removed", + name: "Claude Custom Removed", + isCustom: true, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + { + slug: "claude-runtime-discovered", + name: "Claude Runtime Discovered", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ], + }); + const fallbackClaude = makeProvider("claudeAgent", { + models: [ + { + slug: "claude-opus-4-6", + name: "Claude Opus 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ], + }); + + assert.deepStrictEqual( + hydrateCachedProvider({ + cachedProvider: cachedClaude, + fallbackProvider: fallbackClaude, + }).models, + [fallbackClaude.models[0]!, cachedClaude.models[1]!], + ); + }); + + it("preserves missing quota snapshots during cache hydration", () => { + const cachedCopilot = makeProvider("copilot", { + quotaSnapshots: undefined, + }); + const fallbackCopilot = makeProvider("copilot", { + quotaSnapshots: [ + { + key: "premium_interactions", + entitlementRequests: 100, + usedRequests: 25, + remainingPercentage: 75, + overage: 0, + overageAllowedWithExhaustedQuota: false, + }, + ], + }); + + assert.deepStrictEqual( + hydrateCachedProvider({ + cachedProvider: cachedCopilot, + fallbackProvider: fallbackCopilot, + }), + { + ...fallbackCopilot, + installed: cachedCopilot.installed, + version: cachedCopilot.version, + status: cachedCopilot.status, + auth: cachedCopilot.auth, + checkedAt: cachedCopilot.checkedAt, + slashCommands: cachedCopilot.slashCommands, + skills: cachedCopilot.skills, + }, + ); + }); + it("ignores stale cached enabled state when the provider is now disabled", () => { const cachedCodex = makeProvider("codex", { checkedAt: "2026-04-10T12:00:00.000Z", diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index e6272a3e..fece8d68 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -35,6 +35,20 @@ export const hydrateCachedProvider = (input: { return input.fallbackProvider; } + const mergedModels = (() => { + const modelsBySlug = new Map(); + for (const model of input.fallbackProvider.models) { + modelsBySlug.set(model.slug, model); + } + for (const model of input.cachedProvider.models) { + if (model.isCustom && !modelsBySlug.has(model.slug)) { + continue; + } + modelsBySlug.set(model.slug, model); + } + return [...modelsBySlug.values()]; + })(); + const { message: _fallbackMessage, ...fallbackWithoutMessage } = input.fallbackProvider; const hydratedProvider: ServerProvider = { ...fallbackWithoutMessage, @@ -43,7 +57,10 @@ export const hydrateCachedProvider = (input: { status: input.cachedProvider.status, auth: input.cachedProvider.auth, checkedAt: input.cachedProvider.checkedAt, - quotaSnapshots: input.cachedProvider.quotaSnapshots, + ...(input.cachedProvider.quotaSnapshots !== undefined + ? { quotaSnapshots: input.cachedProvider.quotaSnapshots } + : {}), + models: mergedModels, slashCommands: input.cachedProvider.slashCommands, skills: input.cachedProvider.skills, }; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index b2c16abb..5e980ecf 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -11,6 +11,7 @@ import { KeybindingRule, MessageId, OpenError, + type OrchestrationThreadShell, TerminalNotRunningError, type OrchestrationCommand, type OrchestrationEvent, @@ -74,6 +75,7 @@ import { type ProjectionSnapshotQueryShape, } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; +import { PersistenceSqlError } from "./persistence/Errors.ts"; import { ProviderRegistry, type ProviderRegistryShape, @@ -166,7 +168,33 @@ const makeDefaultOrchestrationReadModel = () => { }; }; -const workspaceAndProjectServicesLayer = Layer.mergeAll( +const makeDefaultOrchestrationThreadShell = ( + overrides: Partial = {}, +): OrchestrationThreadShell => { + const now = new Date().toISOString(); + return { + id: defaultThreadId, + projectId: defaultProjectId, + title: "Default Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + }; +}; + +const _workspaceAndProjectServicesLayer = Layer.mergeAll( WorkspacePathsLive, WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive)), WorkspaceFileSystemLive.pipe( @@ -343,9 +371,32 @@ const buildAppUnderTest = (options?: { ...options?.config, }; const layerConfig = Layer.succeed(ServerConfig, config); + const gitCoreLayer = Layer.mock(GitCore)({ + isInsideWorkTree: () => Effect.succeed(false), + listWorkspaceFiles: () => + Effect.succeed({ + paths: [], + truncated: false, + }), + filterIgnoredPaths: (_cwd, relativePaths) => Effect.succeed(relativePaths), + ...options?.layers?.gitCore, + }); const gitManagerLayer = Layer.mock(GitManager)({ ...options?.layers?.gitManager, }); + const workspaceEntriesLayer = WorkspaceEntriesLive.pipe( + Layer.provide(WorkspacePathsLive), + Layer.provideMerge(gitCoreLayer), + ); + const workspaceAndProjectServicesLayer = Layer.mergeAll( + WorkspacePathsLive, + workspaceEntriesLayer, + WorkspaceFileSystemLive.pipe( + Layer.provide(WorkspacePathsLive), + Layer.provide(workspaceEntriesLayer), + ), + ProjectFaviconResolverLive, + ); const gitStatusBroadcasterLayer = options?.layers?.gitStatusBroadcaster ? Layer.mock(GitStatusBroadcaster)({ ...options.layers.gitStatusBroadcaster, @@ -389,11 +440,7 @@ const buildAppUnderTest = (options?: { ...options?.layers?.open, }), ), - Layer.provide( - Layer.mock(GitCore)({ - ...options?.layers?.gitCore, - }), - ), + Layer.provide(gitCoreLayer), Layer.provide(gitManagerLayer), Layer.provideMerge(gitStatusBroadcasterLayer), Layer.provide( @@ -1990,6 +2037,58 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes websocket rpc projects.searchEntries excludes gitignored files", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-project-search-gitignored-", + }); + yield* fs.writeFileString(path.join(workspaceDir, ".gitignore"), ".venv/\n"); + yield* fs.makeDirectory(path.join(workspaceDir, ".venv", "lib"), { recursive: true }); + yield* fs.writeFileString( + path.join(workspaceDir, ".venv", "lib", "ignored-search-target.ts"), + "export const ignored = true;", + ); + yield* fs.makeDirectory(path.join(workspaceDir, "src"), { recursive: true }); + yield* fs.writeFileString( + path.join(workspaceDir, "src", "tracked.ts"), + "export const ok = 1;", + ); + + yield* buildAppUnderTest({ + layers: { + gitCore: { + isInsideWorkTree: () => Effect.succeed(true), + listWorkspaceFiles: () => + Effect.succeed({ + paths: ["src/tracked.ts"], + truncated: false, + }), + filterIgnoredPaths: (_cwd, relativePaths) => + Effect.succeed( + relativePaths.filter((relativePath) => !relativePath.startsWith(".venv/")), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsSearchEntries]({ + cwd: workspaceDir, + query: "ignored-search-target", + limit: 10, + }), + ), + ); + + assert.equal(response.entries.length, 0); + assert.equal(response.truncated, false); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc projects.searchEntries errors", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -2945,21 +3044,48 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("closes thread terminals after a successful archive command", () => + it.effect("stops the provider session and closes thread terminals after archive", () => Effect.gen(function* () { const threadId = ThreadId.make("thread-archive"); - const closeInputs: Array[0]> = []; + const effects: string[] = []; + const dispatchedCommands: Array = []; + const now = new Date().toISOString(); yield* buildAppUnderTest({ layers: { terminalManager: { close: (input) => Effect.sync(() => { - closeInputs.push(input); + effects.push(`terminal.close:${input.threadId}`); }), }, orchestrationEngine: { - dispatch: () => Effect.succeed({ sequence: 8 }), + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + return { sequence: dispatchedCommands.length }; + }), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }), + ), + ), }, }, }); @@ -2975,8 +3101,421 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); - assert.equal(dispatchResult.sequence, 8); - assert.deepEqual(closeInputs, [{ threadId }]); + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, [ + "dispatch:thread.archive", + "dispatch:thread.session.stop", + `terminal.close:${threadId}`, + ]); + const sessionStopCommand = dispatchedCommands[1]; + assert.equal(sessionStopCommand?.type, "thread.session.stop"); + if (sessionStopCommand?.type === "thread.session.stop") { + assert.equal(sessionStopCommand.threadId, threadId); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("checks session status before archiving removes the thread from active lookups", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-archive-precheck"); + const effects: string[] = []; + const dispatchedCommands: Array = []; + const now = new Date().toISOString(); + let archived = false; + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + if (command.type === "thread.archive") { + archived = true; + } + return { sequence: dispatchedCommands.length }; + }), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.sync(() => { + effects.push(`query:thread-shell:${archived ? "archived" : "active"}`); + return archived + ? Option.none() + : Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }), + ); + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.archive", + commandId: CommandId.make("cmd-thread-archive-precheck"), + threadId, + }), + ), + ); + + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, [ + "query:thread-shell:active", + "dispatch:thread.archive", + "dispatch:thread.session.stop", + `terminal.close:${threadId}`, + ]); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.archive", "thread.session.stop"], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("archives without dispatching session stop when the thread has no session", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-archive-no-session"); + const effects: string[] = []; + const dispatchedCommands: Array = []; + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + return { sequence: dispatchedCommands.length }; + }), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some(makeDefaultOrchestrationThreadShell({ id: threadId, session: null })), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.archive", + commandId: CommandId.make("cmd-thread-archive-no-session"), + threadId, + }), + ), + ); + + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, ["dispatch:thread.archive", `terminal.close:${threadId}`]); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.archive"], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "archives without dispatching session stop when the thread session is already stopped", + () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-archive-stopped-session"); + const effects: string[] = []; + const dispatchedCommands: Array = []; + const now = new Date().toISOString(); + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + return { sequence: dispatchedCommands.length }; + }), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "stopped", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }), + ), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.archive", + commandId: CommandId.make("cmd-thread-archive-stopped-session"), + threadId, + }), + ), + ); + + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, ["dispatch:thread.archive", `terminal.close:${threadId}`]); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.archive"], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("archives and still closes terminals when session stop fails", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-archive-stop-failure"); + const effects: string[] = []; + const dispatchedCommands: Array = []; + const now = new Date().toISOString(); + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + if (command.type === "thread.session.stop") { + return Effect.fail( + new OrchestrationListenerCallbackError({ + listener: "domain-event", + detail: "simulated archive stop failure", + }), + ); + } + return Effect.succeed({ sequence: dispatchedCommands.length }); + }, + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }), + ), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.archive", + commandId: CommandId.make("cmd-thread-archive-stop-failure"), + threadId, + }), + ), + ); + + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, [ + "dispatch:thread.archive", + "dispatch:thread.session.stop", + `terminal.close:${threadId}`, + ]); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.archive", "thread.session.stop"], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("archives and still closes terminals when session stop defects", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-archive-stop-defect"); + const effects: string[] = []; + const dispatchedCommands: Array = []; + const now = new Date().toISOString(); + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + if (command.type === "thread.session.stop") { + return Effect.die(new Error("simulated archive stop defect")); + } + return Effect.succeed({ sequence: dispatchedCommands.length }); + }, + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }), + ), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.archive", + commandId: CommandId.make("cmd-thread-archive-stop-defect"), + threadId, + }), + ), + ); + + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, [ + "dispatch:thread.archive", + "dispatch:thread.session.stop", + `terminal.close:${threadId}`, + ]); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.archive", "thread.session.stop"], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("archives and stops the session defensively when snapshot lookup fails", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-archive-lookup-failure"); + const effects: string[] = []; + const dispatchedCommands: Array = []; + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + return { sequence: dispatchedCommands.length }; + }), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.fail( + new PersistenceSqlError({ + operation: "getThreadShellById", + detail: "simulated thread lookup failure", + }), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.archive", + commandId: CommandId.make("cmd-thread-archive-lookup-failure"), + threadId, + }), + ), + ); + + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, [ + "dispatch:thread.archive", + "dispatch:thread.session.stop", + `terminal.close:${threadId}`, + ]); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.archive", "thread.session.stop"], + ); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index d01a0d2c..1a93b49d 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,7 +1,7 @@ import { Effect, Layer } from "effect"; import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; -import { ServerConfig } from "./config"; +import { ServerConfig } from "./config.ts"; import { attachmentsRouteLayer, otlpTracesProxyRouteLayer, @@ -9,46 +9,47 @@ import { serverEnvironmentRouteLayer, staticAndDevRouteLayer, browserApiCorsLayer, -} from "./http"; -import { fixPath } from "./os-jank"; -import { websocketRpcRouteLayer } from "./ws"; -import { OpenLive } from "./open"; -import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite"; -import { ServerLifecycleEventsLive } from "./serverLifecycleEvents"; -import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; -import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; -import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; -import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime"; -import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; -import { makeCopilotAdapterLive } from "./provider/Layers/CopilotAdapter"; -import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; -import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; -import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; -import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery"; -import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; -import { GitCoreLive } from "./git/Layers/GitCore"; -import { GitHubCliLive } from "./git/Layers/GitHubCli"; -import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster"; -import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration"; -import { TerminalManagerLive } from "./terminal/Layers/Manager"; -import { GitManagerLive } from "./git/Layers/GitManager"; -import { KeybindingsLive } from "./keybindings"; -import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup"; -import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor"; -import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus"; -import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; -import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor"; -import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor"; -import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry"; -import { ServerSettingsLive } from "./serverSettings"; -import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver"; -import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver"; -import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries"; -import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem"; -import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths"; -import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner"; -import { ObservabilityLive } from "./observability/Layers/Observability"; -import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment"; +} from "./http.ts"; +import { fixPath } from "./os-jank.ts"; +import { websocketRpcRouteLayer } from "./ws.ts"; +import { OpenLive } from "./open.ts"; +import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; +import { ServerLifecycleEventsLive } from "./serverLifecycleEvents.ts"; +import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService.ts"; +import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger.ts"; +import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; +import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime.ts"; +import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter.ts"; +import { makeCopilotAdapterLive } from "./provider/Layers/CopilotAdapter.ts"; +import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter.ts"; +import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; +import { makeProviderServiceLive } from "./provider/Layers/ProviderService.ts"; +import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; +import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts"; +import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts"; +import { GitCoreLive } from "./git/Layers/GitCore.ts"; +import { GitHubCliLive } from "./git/Layers/GitHubCli.ts"; +import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster.ts"; +import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration.ts"; +import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; +import { GitManagerLive } from "./git/Layers/GitManager.ts"; +import { KeybindingsLive } from "./keybindings.ts"; +import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup.ts"; +import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor.ts"; +import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus.ts"; +import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion.ts"; +import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; +import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; +import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; +import { ServerSettingsLive } from "./serverSettings.ts"; +import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; +import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; +import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; +import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; +import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; +import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts"; +import { ObservabilityLive } from "./observability/Layers/Observability.ts"; +import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts"; import { authBearerBootstrapRouteLayer, authBootstrapRouteLayer, @@ -60,27 +61,27 @@ import { authPairingCredentialRouteLayer, authSessionRouteLayer, authWebSocketTokenRouteLayer, -} from "./auth/http"; -import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore"; -import { ServerAuthLive } from "./auth/Layers/ServerAuth"; -import { OrchestrationLayerLive } from "./orchestration/runtimeLayer"; +} from "./auth/http.ts"; +import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; +import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; +import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { clearPersistedServerRuntimeState, makePersistedServerRuntimeState, persistServerRuntimeState, -} from "./serverRuntimeState"; +} from "./serverRuntimeState.ts"; import { orchestrationDispatchRouteLayer, orchestrationSnapshotRouteLayer, -} from "./orchestration/http"; +} from "./orchestration/http.ts"; const PtyAdapterLive = Layer.unwrap( Effect.gen(function* () { if (typeof Bun !== "undefined") { - const BunPTY = yield* Effect.promise(() => import("./terminal/Layers/BunPTY")); + const BunPTY = yield* Effect.promise(() => import("./terminal/Layers/BunPTY.ts")); return BunPTY.layer; } else { - const NodePTY = yield* Effect.promise(() => import("./terminal/Layers/NodePTY")); + const NodePTY = yield* Effect.promise(() => import("./terminal/Layers/NodePTY.ts")); return NodePTY.layer; } }), @@ -135,6 +136,10 @@ const CheckpointingLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointStoreLive), ); +const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( + Layer.provide(ProviderSessionRuntimeRepositoryLive), +); + const ProviderLayerLive = Layer.unwrap( Effect.gen(function* () { const { providerEventLogPath } = yield* ServerConfig; @@ -144,9 +149,6 @@ const ProviderLayerLive = Layer.unwrap( const canonicalEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, { stream: "canonical", }); - const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( - Layer.provide(ProviderSessionRuntimeRepositoryLive), - ); const codexAdapterLayer = makeCodexAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); @@ -160,11 +162,14 @@ const ProviderLayerLive = Layer.unwrap( Layer.provide(codexAdapterLayer), Layer.provide(copilotAdapterLayer), Layer.provide(claudeAdapterLayer), - Layer.provideMerge(providerSessionDirectoryLayer), + Layer.provideMerge(ProviderSessionDirectoryLayerLive), ); return makeProviderServiceLive( canonicalEventLogger ? { canonicalEventLogger } : undefined, - ).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer)); + ).pipe( + Layer.provide(adapterRegistryLayer), + Layer.provideMerge(ProviderSessionDirectoryLayerLive), + ); }), ); @@ -185,13 +190,20 @@ const GitLayerLive = Layer.empty.pipe( const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); +const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( + Layer.provide(WorkspacePathsLive), + Layer.provideMerge(GitCoreLive), +); + +const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe( + Layer.provide(WorkspacePathsLive), + Layer.provide(WorkspaceEntriesLayerLive), +); + const WorkspaceLayerLive = Layer.mergeAll( WorkspacePathsLive, - WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive)), - WorkspaceFileSystemLive.pipe( - Layer.provide(WorkspacePathsLive), - Layer.provide(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), - ), + WorkspaceEntriesLayerLive, + WorkspaceFileSystemLayerLive, ); const AuthLayerLive = ServerAuthLive.pipe( @@ -199,12 +211,16 @@ const AuthLayerLive = ServerAuthLive.pipe( Layer.provide(ServerSecretStoreLive), ); +const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( + Layer.provideMerge(ProviderLayerLive), + Layer.provideMerge(OrchestrationLayerLive), +); + const RuntimeDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(CheckpointingLayerLive), Layer.provideMerge(GitLayerLive), - Layer.provideMerge(OrchestrationLayerLive), - Layer.provideMerge(ProviderLayerLive), + Layer.provideMerge(ProviderRuntimeLayerLive), Layer.provideMerge(TerminalLayerLive), Layer.provideMerge(PersistenceLayerLive), Layer.provideMerge(KeybindingsLive), diff --git a/apps/server/src/serverLogger.ts b/apps/server/src/serverLogger.ts index ea098dcb..57d51b2a 100644 --- a/apps/server/src/serverLogger.ts +++ b/apps/server/src/serverLogger.ts @@ -1,6 +1,6 @@ import { Effect, Logger, References, Layer } from "effect"; -import { ServerConfig } from "./config"; +import { ServerConfig } from "./config.ts"; export const ServerLoggerLive = Effect.gen(function* () { const config = yield* ServerConfig; diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 823e3b47..99728f68 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -21,23 +21,24 @@ import { Console, } from "effect"; -import { ServerConfig } from "./config"; -import { Keybindings } from "./keybindings"; -import { Open } from "./open"; -import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; -import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; -import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; -import { ServerLifecycleEvents } from "./serverLifecycleEvents"; -import { ServerSettingsService } from "./serverSettings"; -import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; -import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; -import { ServerAuth } from "./auth/Services/ServerAuth"; +import { ServerConfig } from "./config.ts"; +import { Keybindings } from "./keybindings.ts"; +import { Open } from "./open.ts"; +import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor.ts"; +import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; +import { ServerSettingsService } from "./serverSettings.ts"; +import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; +import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; +import { ServerAuth } from "./auth/Services/ServerAuth.ts"; +import { ProviderSessionReaper } from "./provider/Services/ProviderSessionReaper.ts"; import { formatHeadlessServeOutput, formatHostForUrl, isWildcardHost, issueHeadlessServeAccessInfo, -} from "./startupAccess"; +} from "./startupAccess.ts"; export class ServerRuntimeStartupError extends Data.TaggedError("ServerRuntimeStartupError")<{ readonly message: string; @@ -281,6 +282,7 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { const serverConfig = yield* ServerConfig; const keybindings = yield* Keybindings; const orchestrationReactor = yield* OrchestrationReactor; + const providerSessionReaper = yield* ProviderSessionReaper; const lifecycleEvents = yield* ServerLifecycleEvents; const serverSettings = yield* ServerSettingsService; const serverEnvironment = yield* ServerEnvironment; @@ -325,7 +327,10 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { yield* Effect.logDebug("startup phase: starting orchestration reactors"); yield* runStartupPhase( "reactors.start", - orchestrationReactor.start().pipe(Scope.provide(reactorScope)), + Effect.gen(function* () { + yield* orchestrationReactor.start().pipe(Scope.provide(reactorScope)); + yield* providerSessionReaper.start().pipe(Scope.provide(reactorScope)); + }), ); const welcomeBase = yield* resolveWelcomeBase; diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index 00c83844..569e4ac1 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -1,7 +1,7 @@ import { Effect, FileSystem, Option, Path, Schema } from "effect"; -import { type ServerConfigShape } from "./config"; -import { formatHostForUrl, isWildcardHost } from "./startupAccess"; +import { type ServerConfigShape } from "./config.ts"; +import { formatHostForUrl, isWildcardHost } from "./startupAccess.ts"; export const PersistedServerRuntimeState = Schema.Struct({ version: Schema.Literal(1), diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index dcf3fa18..30b086ae 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -2,8 +2,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { DEFAULT_SERVER_SETTINGS, ServerSettingsPatch } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import { Effect, FileSystem, Layer, Schema } from "effect"; -import { ServerConfig } from "./config"; -import { ServerSettingsLive, ServerSettingsService } from "./serverSettings"; +import { ServerConfig } from "./config.ts"; +import { ServerSettingsLive, ServerSettingsService } from "./serverSettings.ts"; const makeServerSettingsLayer = () => ServerSettingsLive.pipe( @@ -41,6 +41,23 @@ it.layer(NodeServices.layer)("server settings", (it) => { }, }, ); + + assert.deepEqual( + decodePatch({ + providers: { + claudeAgent: { + launchArgs: "--verbose --dangerously-skip-permissions", + }, + }, + }), + { + providers: { + claudeAgent: { + launchArgs: "--verbose --dangerously-skip-permissions", + }, + }, + }, + ); }), ); @@ -92,6 +109,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { enabled: true, binaryPath: "/usr/local/bin/claude", customModels: ["claude-custom"], + launchArgs: "", }); assert.deepEqual(next.textGenerationModelSelection, { provider: "codex", @@ -141,6 +159,90 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("drops stale text generation options when resetting model selection", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + + yield* serverSettings.updateSettings({ + textGenerationModelSelection: { + provider: "codex", + model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + options: { + reasoningEffort: "high", + fastMode: true, + }, + }, + }); + + const next = yield* serverSettings.updateSettings({ + textGenerationModelSelection: { + provider: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.provider, + model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + }, + }); + + assert.deepEqual(next.textGenerationModelSelection, { + provider: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.provider, + model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("falls back from unsupported copilot git text generation selections", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + + const next = yield* serverSettings.updateSettings({ + textGenerationModelSelection: { + provider: "copilot", + model: "gpt-5-mini", + }, + }); + + assert.deepEqual(next.textGenerationModelSelection, { + provider: "codex", + model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("persists a disabled selected provider while read-time access still falls back", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const serverConfig = yield* ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + + const next = yield* serverSettings.updateSettings({ + providers: { + codex: { + enabled: false, + }, + }, + textGenerationModelSelection: { + provider: "codex", + model: "gpt-5.4", + }, + }); + + assert.deepEqual(next.textGenerationModelSelection, { + provider: "claudeAgent", + model: "claude-haiku-4-5", + }); + + const persisted = JSON.parse(yield* fileSystem.readFileString(serverConfig.settingsPath)); + assert.deepEqual(persisted.textGenerationModelSelection, { + provider: "codex", + model: "gpt-5.4", + }); + + const readBack = yield* serverSettings.getSettings; + assert.deepEqual(readBack.textGenerationModelSelection, { + provider: "claudeAgent", + model: "claude-haiku-4-5", + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("trims provider path settings when updates are applied", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsService; @@ -167,6 +269,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { enabled: true, binaryPath: "/opt/homebrew/bin/claude", customModels: [], + launchArgs: "", }); }).pipe(Effect.provide(makeServerSettingsLayer())), ); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 3f8ee12d..a50aaeba 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -13,6 +13,7 @@ import { DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, DEFAULT_SERVER_SETTINGS, + GIT_TEXT_GENERATION_PROVIDERS, type ModelSelection, type ProviderKind, ServerSettings, @@ -39,9 +40,10 @@ import { Cause, } from "effect"; import * as Semaphore from "effect/Semaphore"; -import { ServerConfig } from "./config"; +import { ServerConfig } from "./config.ts"; import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; export interface ServerSettingsShape { /** Start the settings runtime and attach file watching. */ @@ -77,11 +79,25 @@ export class ServerSettingsService extends Context.Service< return { start: Effect.void, ready: Effect.void, - getSettings: Ref.get(currentSettingsRef), + getSettings: Ref.get(currentSettingsRef).pipe(Effect.map(resolveTextGenerationProvider)), updateSettings: (patch) => Ref.get(currentSettingsRef).pipe( - Effect.map((currentSettings) => deepMerge(currentSettings, patch)), + Effect.flatMap((currentSettings) => + Schema.decodeEffect(ServerSettings)( + applyServerSettingsPatch(currentSettings, patch), + ).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath: "", + detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`, + cause, + }), + ), + ), + ), Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), + Effect.map(resolveTextGenerationProvider), ), streamChanges: Stream.empty, } satisfies ServerSettingsShape; @@ -101,13 +117,18 @@ const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "copilot", "claudeAgen */ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings { const selection = settings.textGenerationModelSelection; - if (settings.providers[selection.provider].enabled) { - return settings; + if (selection.provider === "codex" || selection.provider === "claudeAgent") { + if (settings.providers[selection.provider].enabled) { + return settings; + } } - const fallback = PROVIDER_ORDER.find((p) => settings.providers[p].enabled); + const fallback = PROVIDER_ORDER.find( + (provider): provider is (typeof GIT_TEXT_GENERATION_PROVIDERS)[number] => + (provider === "codex" || provider === "claudeAgent") && settings.providers[provider].enabled, + ); if (!fallback) { - // No providers enabled — return as-is; callers will report the error. + // No supported providers enabled — return as-is; callers will report the error. return settings; } @@ -314,7 +335,9 @@ const makeServerSettings = Effect.gen(function* () { writeSemaphore.withPermits(1)( Effect.gen(function* () { const current = yield* getSettingsFromCache; - const next = yield* Schema.decodeEffect(ServerSettings)(deepMerge(current, patch)).pipe( + const next = yield* Schema.decodeEffect(ServerSettings)( + applyServerSettingsPatch(current, patch), + ).pipe( Effect.mapError( (cause) => new ServerSettingsError({ @@ -324,10 +347,11 @@ const makeServerSettings = Effect.gen(function* () { }), ), ); + const resolvedNext = resolveTextGenerationProvider(next); yield* writeSettingsAtomically(next); yield* Cache.set(settingsCache, cacheKey, next); - yield* emitChange(next); - return resolveTextGenerationProvider(next); + yield* emitChange(resolvedNext); + return resolvedNext; }), ), get streamChanges() { diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts index ef6ece31..03c01170 100644 --- a/apps/server/src/startupAccess.test.ts +++ b/apps/server/src/startupAccess.test.ts @@ -7,7 +7,7 @@ import { resolveHeadlessConnectionHost, resolveHeadlessConnectionString, resolveListeningPort, -} from "./startupAccess"; +} from "./startupAccess.ts"; it("prefers localhost when no explicit host is configured", () => { expect(resolveHeadlessConnectionHost(undefined)).toBe("localhost"); diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index a350d729..32791901 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -4,8 +4,8 @@ import { QrCode } from "@t3tools/shared/qrCode"; import { Effect } from "effect"; import { HttpServer } from "effect/unstable/http"; -import { ServerConfig } from "./config"; -import { ServerAuth } from "./auth/Services/ServerAuth"; +import { ServerConfig } from "./config.ts"; +import { ServerAuth } from "./auth/Services/ServerAuth.ts"; export interface HeadlessServeAccessInfo { readonly connectionString: string; diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index d7784eb8..e81393bb 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -1,7 +1,7 @@ import { Effect, FileSystem, Path, Random, Schema } from "effect"; import * as Crypto from "node:crypto"; import { homedir } from "node:os"; -import { ServerConfig } from "../config"; +import { ServerConfig } from "../config.ts"; const CodexAuthJsonSchema = Schema.Struct({ tokens: Schema.Struct({ diff --git a/apps/server/src/telemetry/Layers/AnalyticsService.ts b/apps/server/src/telemetry/Layers/AnalyticsService.ts index e933576d..9067b71a 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.ts +++ b/apps/server/src/telemetry/Layers/AnalyticsService.ts @@ -13,7 +13,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab import { ServerConfig } from "../../config.ts"; import { AnalyticsService, type AnalyticsServiceShape } from "../Services/AnalyticsService.ts"; import { getTelemetryIdentifier } from "../Identify.ts"; -import { version } from "../../../package.json" with { type: "json" }; +import packageJson from "../../../package.json" with { type: "json" }; interface BufferedAnalyticsEvent { readonly event: string; @@ -86,7 +86,7 @@ const makeAnalyticsService = Effect.gen(function* () { platform: process.platform, wsl: process.env.WSL_DISTRO_NAME, arch: process.arch, - t3CodeVersion: version, + t3CodeVersion: packageJson.version, clientType, }, timestamp: event.capturedAt, diff --git a/apps/server/src/terminal/Layers/BunPTY.ts b/apps/server/src/terminal/Layers/BunPTY.ts index 1fb4bdd6..f0aab813 100644 --- a/apps/server/src/terminal/Layers/BunPTY.ts +++ b/apps/server/src/terminal/Layers/BunPTY.ts @@ -1,13 +1,16 @@ import { Effect, Layer } from "effect"; -import { PtyAdapter, PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY"; +import { PtyAdapter } from "../Services/PTY.ts"; +import type { PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY.ts"; class BunPtyProcess implements PtyProcess { private readonly dataListeners = new Set<(data: string) => void>(); private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); private readonly decoder = new TextDecoder(); + private readonly process: Bun.Subprocess; private didExit = false; - constructor(private readonly process: Bun.Subprocess) { + constructor(process: Bun.Subprocess) { + this.process = process; void this.process.exited .then((exitCode) => { this.emitExit({ diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index a9f2c6b2..9d41c3de 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -24,25 +24,28 @@ import { import { TestClock } from "effect/testing"; import { expect } from "vitest"; -import type { TerminalManagerShape } from "../Services/Manager"; +import type { TerminalManagerShape } from "../Services/Manager.ts"; import { type PtyAdapterShape, type PtyExitEvent, type PtyProcess, type PtySpawnInput, PtySpawnError, -} from "../Services/PTY"; -import { makeTerminalManagerWithOptions } from "./Manager"; +} from "../Services/PTY.ts"; +import { makeTerminalManagerWithOptions } from "./Manager.ts"; class FakePtyProcess implements PtyProcess { readonly writes: string[] = []; readonly resizeCalls: Array<{ cols: number; rows: number }> = []; readonly killSignals: Array = []; + readonly pid: number; private readonly dataListeners = new Set<(data: string) => void>(); private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); killed = false; - constructor(readonly pid: number) {} + constructor(pid: number) { + this.pid = pid; + } write(data: string): void { this.writes.push(data); @@ -88,9 +91,12 @@ class FakePtyAdapter implements PtyAdapterShape { readonly spawnInputs: PtySpawnInput[] = []; readonly processes: FakePtyProcess[] = []; readonly spawnFailures: Error[] = []; + private readonly mode: "sync" | "async"; private nextPid = 9000; - constructor(private readonly mode: "sync" | "async" = "sync") {} + constructor(mode: "sync" | "async" = "sync") { + this.mode = mode; + } spawn(input: PtySpawnInput): Effect.Effect { this.spawnInputs.push(input); @@ -188,6 +194,8 @@ function multiTerminalHistoryLogPath( interface CreateManagerOptions { shellResolver?: () => string; + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; subprocessChecker?: (terminalPid: number) => Effect.Effect; subprocessPollIntervalMs?: number; processKillGraceMs?: number; @@ -222,6 +230,8 @@ const createManager = ( historyLineLimit, ptyAdapter, ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), + ...(options.platform !== undefined ? { platform: options.platform } : {}), + ...(options.env !== undefined ? { env: options.env } : {}), ...(options.subprocessChecker !== undefined ? { subprocessChecker: options.subprocessChecker } : {}), @@ -291,9 +301,8 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( it.effect("preserves non-notFound cwd stat failures", () => Effect.gen(function* () { - if (typeof process.getuid === "function" && process.getuid() === 0) { - return; - } + if (process.platform === "win32") return; + const { manager, baseDir } = yield* createManager(); const blockedRoot = path.join(baseDir, "blocked-root"); const blockedCwd = path.join(blockedRoot, "cwd"); @@ -824,8 +833,12 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( it.effect("retries with fallback shells when preferred shell spawn fails", () => Effect.gen(function* () { + const missingShell = + process.platform === "win32" + ? "C:\\definitely\\missing-shell.exe" + : "/definitely/missing-shell -l"; const { manager, ptyAdapter } = yield* createManager(5, { - shellResolver: () => "/definitely/missing-shell -l", + shellResolver: () => missingShell, }); ptyAdapter.spawnFailures.push(new Error("posix_spawnp failed.")); @@ -833,12 +846,17 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( assert.equal(snapshot.status, "running"); expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2); - expect(ptyAdapter.spawnInputs[0]?.shell).toBe("/definitely/missing-shell"); + expect(ptyAdapter.spawnInputs[0]?.shell).toBe( + process.platform === "win32" ? missingShell : "/definitely/missing-shell", + ); if (process.platform === "win32") { expect( ptyAdapter.spawnInputs.some( - (input) => input.shell === "cmd.exe" || input.shell === "powershell.exe", + (input) => + input.shell === "pwsh.exe" || + input.shell === "powershell.exe" || + input.shell === "cmd.exe", ), ).toBe(true); } else { @@ -851,6 +869,56 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( }), ); + it.effect("prefers PowerShell over ComSpec for Windows terminals", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + platform: "win32", + env: { + ComSpec: "C:\\Windows\\System32\\cmd.exe", + PATH: "C:\\Windows\\System32", + SystemRoot: "C:\\Windows", + }, + }); + + yield* manager.open(openInput()); + + expect(ptyAdapter.spawnInputs[0]).toEqual( + expect.objectContaining({ + shell: "pwsh.exe", + args: ["-NoLogo"], + }), + ); + }), + ); + + it.effect("falls back to built-in PowerShell by absolute path on Windows", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + platform: "win32", + env: { + ComSpec: "C:\\Windows\\System32\\cmd.exe", + PATH: "C:\\Windows\\System32", + SystemRoot: "C:\\Windows", + }, + shellResolver: () => "C:\\missing\\custom-shell.exe", + }); + ptyAdapter.spawnFailures.push( + new Error("spawn custom-shell.exe ENOENT"), + new Error("spawn pwsh.exe ENOENT"), + ); + + yield* manager.open(openInput()); + + expect(ptyAdapter.spawnInputs.map((input) => input.shell)).toEqual([ + "C:\\missing\\custom-shell.exe", + "pwsh.exe", + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + ]); + expect(ptyAdapter.spawnInputs[1]?.args).toEqual(["-NoLogo"]); + expect(ptyAdapter.spawnInputs[2]?.args).toEqual(["-NoLogo"]); + }), + ); + it.effect("filters app runtime env variables from terminal sessions", () => Effect.gen(function* () { const originalValues = new Map(); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index 4bdeba68..5e14db8e 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -22,13 +22,13 @@ import { SynchronizedRef, } from "effect"; -import { ServerConfig } from "../../config"; +import { ServerConfig } from "../../config.ts"; import { increment, terminalRestartsTotal, terminalSessionsTotal, -} from "../../observability/Metrics"; -import { runProcess } from "../../processRunner"; +} from "../../observability/Metrics.ts"; +import { runProcess } from "../../processRunner.ts"; import { TerminalCwdError, TerminalHistoryError, @@ -36,14 +36,14 @@ import { TerminalNotRunningError, TerminalSessionLookupError, type TerminalManagerShape, -} from "../Services/Manager"; +} from "../Services/Manager.ts"; import { PtyAdapter, PtySpawnError, type PtyAdapterShape, type PtyExitEvent, type PtyProcess, -} from "../Services/PTY"; +} from "../Services/PTY.ts"; const DEFAULT_HISTORY_LINE_LIMIT = 5_000; const DEFAULT_PERSIST_DEBOUNCE_MS = 40; @@ -186,19 +186,25 @@ function enqueueProcessEvent( return true; } -function defaultShellResolver(): string { - if (process.platform === "win32") { - return process.env.ComSpec ?? "cmd.exe"; +function defaultShellResolver( + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): string { + if (platform === "win32") { + return "pwsh.exe"; } - return process.env.SHELL ?? "bash"; + return env.SHELL ?? "bash"; } -function normalizeShellCommand(value: string | undefined): string | null { +function normalizeShellCommand( + value: string | undefined, + platform: NodeJS.Platform = process.platform, +): string | null { if (!value) return null; const trimmed = value.trim(); if (trimmed.length === 0) return null; - if (process.platform === "win32") { + if (platform === "win32") { return trimmed; } @@ -207,15 +213,42 @@ function normalizeShellCommand(value: string | undefined): string | null { return firstToken.replace(/^['"]|['"]$/g, ""); } -function shellCandidateFromCommand(command: string | null): ShellCandidate | null { +function shellCandidateFromCommand( + command: string | null, + platform: NodeJS.Platform = process.platform, +): ShellCandidate | null { if (!command || command.length === 0) return null; - const shellName = path.basename(command).toLowerCase(); - if (process.platform !== "win32" && shellName === "zsh") { + const shellName = + platform === "win32" + ? path.win32.basename(command).toLowerCase() + : path.basename(command).toLowerCase(); + if (platform === "win32" && (shellName === "pwsh.exe" || shellName === "powershell.exe")) { + return { shell: command, args: ["-NoLogo"] }; + } + if (platform !== "win32" && shellName === "zsh") { return { shell: command, args: ["-o", "nopromptsp"] }; } return { shell: command }; } +function windowsSystemRoot(env: NodeJS.ProcessEnv): string { + return env.SystemRoot?.trim() || env.windir?.trim() || "C:\\Windows"; +} + +function windowsPowerShellPath(env: NodeJS.ProcessEnv): string { + return path.win32.join( + windowsSystemRoot(env), + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); +} + +function windowsCmdPath(env: NodeJS.ProcessEnv): string { + return path.win32.join(windowsSystemRoot(env), "System32", "cmd.exe"); +} + function formatShellCandidate(candidate: ShellCandidate): string { if (!candidate.args || candidate.args.length === 0) return candidate.shell; return `${candidate.shell} ${candidate.args.join(" ")}`; @@ -234,27 +267,37 @@ function uniqueShellCandidates(candidates: Array): ShellC return ordered; } -function resolveShellCandidates(shellResolver: () => string): ShellCandidate[] { - const requested = shellCandidateFromCommand(normalizeShellCommand(shellResolver())); +function resolveShellCandidates( + shellResolver: () => string, + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): ShellCandidate[] { + const requested = shellCandidateFromCommand( + normalizeShellCommand(shellResolver(), platform), + platform, + ); - if (process.platform === "win32") { + if (platform === "win32") { return uniqueShellCandidates([ requested, - shellCandidateFromCommand(process.env.ComSpec ?? null), - shellCandidateFromCommand("powershell.exe"), - shellCandidateFromCommand("cmd.exe"), + shellCandidateFromCommand("pwsh.exe", platform), + shellCandidateFromCommand(windowsPowerShellPath(env), platform), + shellCandidateFromCommand("powershell.exe", platform), + shellCandidateFromCommand(env.ComSpec ?? null, platform), + shellCandidateFromCommand(windowsCmdPath(env), platform), + shellCandidateFromCommand("cmd.exe", platform), ]); } return uniqueShellCandidates([ requested, - shellCandidateFromCommand(normalizeShellCommand(process.env.SHELL)), - shellCandidateFromCommand("/bin/zsh"), - shellCandidateFromCommand("/bin/bash"), - shellCandidateFromCommand("/bin/sh"), - shellCandidateFromCommand("zsh"), - shellCandidateFromCommand("bash"), - shellCandidateFromCommand("sh"), + shellCandidateFromCommand(normalizeShellCommand(env.SHELL, platform), platform), + shellCandidateFromCommand("/bin/zsh", platform), + shellCandidateFromCommand("/bin/bash", platform), + shellCandidateFromCommand("/bin/sh", platform), + shellCandidateFromCommand("zsh", platform), + shellCandidateFromCommand("bash", platform), + shellCandidateFromCommand("sh", platform), ]); } @@ -651,6 +694,8 @@ interface TerminalManagerOptions { historyLineLimit?: number; ptyAdapter: PtyAdapterShape; shellResolver?: () => string; + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; subprocessChecker?: TerminalSubprocessChecker; subprocessPollIntervalMs?: number; processKillGraceMs?: number; @@ -674,7 +719,9 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const logsDir = options.logsDir; const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; - const shellResolver = options.shellResolver ?? defaultShellResolver; + const platform = options.platform ?? process.platform; + const baseEnv = options.env ?? process.env; + const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); const subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker; const subprocessPollIntervalMs = options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; @@ -1337,8 +1384,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith increment(terminalSessionsTotal, { lifecycle: eventType }).pipe( Effect.andThen( Effect.gen(function* () { - const shellCandidates = resolveShellCandidates(shellResolver); - const terminalEnv = createTerminalSpawnEnv(process.env, session.runtimeEnv); + const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv); + const terminalEnv = createTerminalSpawnEnv(baseEnv, session.runtimeEnv); const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session); ptyProcess = spawnResult.process; startedShell = spawnResult.shellLabel; diff --git a/apps/server/src/terminal/Layers/NodePTY.test.ts b/apps/server/src/terminal/Layers/NodePTY.test.ts index 58fcc70e..06f18631 100644 --- a/apps/server/src/terminal/Layers/NodePTY.test.ts +++ b/apps/server/src/terminal/Layers/NodePTY.test.ts @@ -1,7 +1,7 @@ import { FileSystem, Path, Effect } from "effect"; import { assert, it } from "@effect/vitest"; -import { ensureNodePtySpawnHelperExecutable } from "./NodePTY"; +import { ensureNodePtySpawnHelperExecutable } from "./NodePTY.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; it.layer(NodeServices.layer)("ensureNodePtySpawnHelperExecutable", (it) => { diff --git a/apps/server/src/terminal/Layers/NodePTY.ts b/apps/server/src/terminal/Layers/NodePTY.ts index cf1fdd21..1c75a4a9 100644 --- a/apps/server/src/terminal/Layers/NodePTY.ts +++ b/apps/server/src/terminal/Layers/NodePTY.ts @@ -1,7 +1,13 @@ import { createRequire } from "node:module"; import { Effect, FileSystem, Layer, Path } from "effect"; -import { PtyAdapter, PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY"; +import { PtyAdapter } from "../Services/PTY.ts"; +import { + PtySpawnError, + type PtyAdapterShape, + type PtyExitEvent, + type PtyProcess, +} from "../Services/PTY.ts"; let didEnsureSpawnHelperExecutable = false; @@ -46,7 +52,11 @@ export const ensureNodePtySpawnHelperExecutable = Effect.fn(function* (explicitP }); class NodePtyProcess implements PtyProcess { - constructor(private readonly process: import("node-pty").IPty) {} + private readonly process: import("node-pty").IPty; + + constructor(process: import("node-pty").IPty) { + this.process = process; + } get pid(): number { return this.process.pid; @@ -103,12 +113,21 @@ export const layer = Layer.effect( return { spawn: Effect.fn(function* (input) { yield* ensureNodePtySpawnHelperExecutableCached; - const ptyProcess = nodePty.spawn(input.shell, input.args ?? [], { - cwd: input.cwd, - cols: input.cols, - rows: input.rows, - env: input.env, - name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color", + const ptyProcess = yield* Effect.try({ + try: () => + nodePty.spawn(input.shell, input.args ?? [], { + cwd: input.cwd, + cols: input.cols, + rows: input.rows, + env: input.env, + name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color", + }), + catch: (cause) => + new PtySpawnError({ + adapter: "node-pty", + message: cause instanceof Error ? cause.message : "Failed to spawn PTY process", + cause, + }), }); return new NodePtyProcess(ptyProcess); }), diff --git a/apps/server/src/terminal/Services/Manager.ts b/apps/server/src/terminal/Services/Manager.ts index b59c4721..fb7a7da7 100644 --- a/apps/server/src/terminal/Services/Manager.ts +++ b/apps/server/src/terminal/Services/Manager.ts @@ -22,7 +22,7 @@ import { TerminalSessionStatus, TerminalWriteInput, } from "@t3tools/contracts"; -import { PtyProcess } from "./PTY"; +import type { PtyProcess } from "./PTY.ts"; import { Effect, Context } from "effect"; export { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 96b5b54d..48f6b615 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -27,42 +27,42 @@ import { clamp } from "effect/Number"; import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; -import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; -import { ServerConfig } from "./config"; -import { GitCore } from "./git/Services/GitCore"; -import { GitManager } from "./git/Services/GitManager"; -import { GitStatusBroadcaster } from "./git/Services/GitStatusBroadcaster"; -import { Keybindings } from "./keybindings"; -import { Open, resolveAvailableEditors } from "./open"; -import { normalizeDispatchCommand } from "./orchestration/Normalizer"; -import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; -import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; +import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts"; +import { ServerConfig } from "./config.ts"; +import { GitCore } from "./git/Services/GitCore.ts"; +import { GitManager } from "./git/Services/GitManager.ts"; +import { GitStatusBroadcaster } from "./git/Services/GitStatusBroadcaster.ts"; +import { Keybindings } from "./keybindings.ts"; +import { Open, resolveAvailableEditors } from "./open.ts"; +import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; +import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { observeRpcEffect, observeRpcStream, observeRpcStreamEffect, -} from "./observability/RpcInstrumentation"; -import { ProviderRegistry } from "./provider/Services/ProviderRegistry"; -import { ServerLifecycleEvents } from "./serverLifecycleEvents"; -import { ServerRuntimeStartup } from "./serverRuntimeStartup"; -import { ServerSettingsService } from "./serverSettings"; -import { TerminalManager } from "./terminal/Services/Manager"; -import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries"; -import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem"; -import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths"; -import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner"; -import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver"; -import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; -import { ServerAuth } from "./auth/Services/ServerAuth"; +} from "./observability/RpcInstrumentation.ts"; +import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; +import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; +import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; +import { ServerSettingsService } from "./serverSettings.ts"; +import { TerminalManager } from "./terminal/Services/Manager.ts"; +import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; +import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; +import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; +import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner.ts"; +import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver.ts"; +import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; +import { ServerAuth } from "./auth/Services/ServerAuth.ts"; import { BootstrapCredentialService, type BootstrapCredentialChange, -} from "./auth/Services/BootstrapCredentialService"; +} from "./auth/Services/BootstrapCredentialService.ts"; import { SessionCredentialService, type SessionCredentialChange, -} from "./auth/Services/SessionCredentialService"; -import { respondToAuthError } from "./auth/http"; +} from "./auth/Services/SessionCredentialService.ts"; +import { respondToAuthError } from "./auth/http.ts"; function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< OrchestrationEvent, @@ -550,8 +550,53 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => ORCHESTRATION_WS_METHODS.dispatchCommand, Effect.gen(function* () { const normalizedCommand = yield* normalizeDispatchCommand(command); + const shouldStopSessionAfterArchive = + normalizedCommand.type === "thread.archive" + ? yield* projectionSnapshotQuery + .getThreadShellById(normalizedCommand.threadId) + .pipe( + Effect.map( + Option.match({ + onNone: () => false, + onSome: (thread) => + thread.session !== null && thread.session.status !== "stopped", + }), + ), + Effect.catchCause((cause) => + Effect.logWarning( + "failed to inspect thread session before archive; stopping session defensively", + { + threadId: normalizedCommand.threadId, + cause, + }, + ).pipe(Effect.as(true)), + ), + ) + : false; const result = yield* dispatchNormalizedCommand(normalizedCommand); if (normalizedCommand.type === "thread.archive") { + if (shouldStopSessionAfterArchive) { + yield* Effect.gen(function* () { + const stopCommand = yield* normalizeDispatchCommand({ + type: "thread.session.stop", + commandId: CommandId.make( + `session-stop-for-archive:${normalizedCommand.commandId}`, + ), + threadId: normalizedCommand.threadId, + createdAt: new Date().toISOString(), + }); + + yield* dispatchNormalizedCommand(stopCommand); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to stop provider session during archive", { + threadId: normalizedCommand.threadId, + cause, + }), + ), + ); + } + yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe( Effect.catch((error) => Effect.logWarning("failed to close thread terminals after archive", { diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 07d52467..c19bdbf4 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -3,9 +3,7 @@ "compilerOptions": { "composite": true, "types": ["node", "bun"], - "lib": ["ES2023", "esnext.disposable"], - "noEmit": true, - "allowImportingTsExtensions": true, + "lib": ["ESNext", "esnext.disposable"], "plugins": [ { "name": "@effect/language-service", diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts index 1c5b2f0d..660d6942 100644 --- a/apps/server/vitest.config.ts +++ b/apps/server/vitest.config.ts @@ -1,6 +1,6 @@ import { defineConfig, mergeConfig } from "vitest/config"; -import baseConfig from "../../vitest.config"; +import baseConfig from "../../vitest.config.ts"; export default mergeConfig( baseConfig, diff --git a/apps/web/package.json b/apps/web/package.json index 362eeecc..b18defeb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.17", + "version": "0.0.20", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/chat-scroll.test.ts b/apps/web/src/chat-scroll.test.ts deleted file mode 100644 index 5311fb40..00000000 --- a/apps/web/src/chat-scroll.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "./chat-scroll"; - -describe("isScrollContainerNearBottom", () => { - it("returns true when already at bottom", () => { - expect( - isScrollContainerNearBottom({ - scrollTop: 600, - clientHeight: 400, - scrollHeight: 1_000, - }), - ).toBe(true); - }); - - it("returns true when within the auto-scroll threshold", () => { - expect( - isScrollContainerNearBottom({ - scrollTop: 540, - clientHeight: 400, - scrollHeight: 1_000, - }), - ).toBe(true); - }); - - it("returns false when the user is meaningfully above the bottom", () => { - expect( - isScrollContainerNearBottom({ - scrollTop: 520, - clientHeight: 400, - scrollHeight: 1_000, - }), - ).toBe(false); - }); - - it("clamps negative thresholds to zero", () => { - expect( - isScrollContainerNearBottom( - { - scrollTop: 539, - clientHeight: 400, - scrollHeight: 1_000, - }, - -1, - ), - ).toBe(false); - }); - - it("falls back to the default threshold for non-finite values", () => { - expect( - isScrollContainerNearBottom( - { - scrollTop: 540, - clientHeight: 400, - scrollHeight: 1_000, - }, - Number.NaN, - ), - ).toBe(true); - expect(AUTO_SCROLL_BOTTOM_THRESHOLD_PX).toBe(64); - }); -}); diff --git a/apps/web/src/chat-scroll.ts b/apps/web/src/chat-scroll.ts deleted file mode 100644 index 35190ab1..00000000 --- a/apps/web/src/chat-scroll.ts +++ /dev/null @@ -1,24 +0,0 @@ -export const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64; - -interface ScrollPosition { - scrollTop: number; - clientHeight: number; - scrollHeight: number; -} - -export function isScrollContainerNearBottom( - position: ScrollPosition, - thresholdPx = AUTO_SCROLL_BOTTOM_THRESHOLD_PX, -): boolean { - const threshold = Number.isFinite(thresholdPx) - ? Math.max(0, thresholdPx) - : AUTO_SCROLL_BOTTOM_THRESHOLD_PX; - - const { scrollTop, clientHeight, scrollHeight } = position; - if (![scrollTop, clientHeight, scrollHeight].every(Number.isFinite)) { - return true; - } - - const distanceFromBottom = scrollHeight - clientHeight - scrollTop; - return distanceFromBottom <= threshold; -} diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index 94b7598d..c08c4c1e 100644 --- a/apps/web/src/clientPersistenceStorage.test.ts +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -37,15 +37,21 @@ function getTestWindow(): Window & typeof globalThis { const testWindow = { localStorage, } as Window & typeof globalThis; - vi.stubGlobal("window", testWindow); - vi.stubGlobal("localStorage", localStorage); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: testWindow, + }); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: localStorage, + }); return testWindow; } afterEach(() => { - vi.resetModules(); - vi.unstubAllGlobals(); vi.restoreAllMocks(); + Reflect.deleteProperty(globalThis, "window"); + Reflect.deleteProperty(globalThis, "localStorage"); }); describe("clientPersistenceStorage", () => { @@ -78,25 +84,51 @@ describe("clientPersistenceStorage", () => { }); }); - it("migrates partial legacy client settings by filling decoding defaults", async () => { + it("migrates legacy browser client settings during hydration", async () => { const testWindow = getTestWindow(); testWindow.localStorage.setItem( - "t3code:client-settings:v1", + "t3code:app-settings:v1", JSON.stringify({ confirmThreadArchive: true, + confirmThreadDelete: false, + diffWordWrap: true, + sidebarProjectGroupingMode: "repository_path", + sidebarProjectGroupingOverrides: { + "/repo": "separate", + }, + sidebarProjectSortOrder: "manual", + sidebarThreadSortOrder: "created_at", timestampFormat: "24-hour", }), ); - const { readBrowserClientSettings } = await import("./clientPersistenceStorage"); + const { CLIENT_SETTINGS_STORAGE_KEY, readBrowserClientSettings } = + await import("./clientPersistenceStorage"); expect(readBrowserClientSettings()).toEqual({ confirmThreadArchive: true, - confirmThreadDelete: true, - diffWordWrap: false, - sidebarProjectSortOrder: "updated_at", - sidebarThreadSortOrder: "updated_at", + confirmThreadDelete: false, + diffWordWrap: true, + sidebarProjectGroupingMode: "repository_path", + sidebarProjectGroupingOverrides: { + "/repo": "separate", + }, + sidebarProjectSortOrder: "manual", + sidebarThreadSortOrder: "created_at", + timestampFormat: "24-hour", + }); + expect(JSON.parse(testWindow.localStorage.getItem(CLIENT_SETTINGS_STORAGE_KEY)!)).toEqual({ + confirmThreadArchive: true, + confirmThreadDelete: false, + diffWordWrap: true, + sidebarProjectGroupingMode: "repository_path", + sidebarProjectGroupingOverrides: { + "/repo": "separate", + }, + sidebarProjectSortOrder: "manual", + sidebarThreadSortOrder: "created_at", timestampFormat: "24-hour", }); + expect(testWindow.localStorage.getItem("t3code:app-settings:v1")).toBeNull(); }); }); diff --git a/apps/web/src/clientPersistenceStorage.ts b/apps/web/src/clientPersistenceStorage.ts index a3ad603d..56b0f6c5 100644 --- a/apps/web/src/clientPersistenceStorage.ts +++ b/apps/web/src/clientPersistenceStorage.ts @@ -12,6 +12,7 @@ import { getLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorag export const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; export const SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY = "t3code:saved-environment-registry:v1"; +const LEGACY_CLIENT_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; const BrowserSavedEnvironmentRecordSchema = Schema.Struct({ environmentId: EnvironmentId, @@ -48,27 +49,88 @@ function toPersistedSavedEnvironmentRecord( }; } +function readLegacyBrowserClientSettings(): ClientSettings | null { + const raw = window.localStorage.getItem(LEGACY_CLIENT_SETTINGS_STORAGE_KEY); + if (!raw) { + return null; + } + + try { + const parsed = JSON.parse(raw) as Record; + const sidebarProjectGroupingOverrides = + parsed.sidebarProjectGroupingOverrides && + typeof parsed.sidebarProjectGroupingOverrides === "object" && + !Array.isArray(parsed.sidebarProjectGroupingOverrides) + ? Object.fromEntries( + Object.entries(parsed.sidebarProjectGroupingOverrides).filter( + ([key, value]) => + typeof key === "string" && + key.length > 0 && + (value === "repository" || value === "repository_path" || value === "separate"), + ), + ) + : undefined; + + const migrated = Schema.decodeSync(ClientSettingsSchema)({ + ...DEFAULT_CLIENT_SETTINGS, + ...(typeof parsed.confirmThreadArchive === "boolean" + ? { confirmThreadArchive: parsed.confirmThreadArchive } + : {}), + ...(typeof parsed.confirmThreadDelete === "boolean" + ? { confirmThreadDelete: parsed.confirmThreadDelete } + : {}), + ...(typeof parsed.diffWordWrap === "boolean" ? { diffWordWrap: parsed.diffWordWrap } : {}), + ...(parsed.sidebarProjectGroupingMode === "repository" || + parsed.sidebarProjectGroupingMode === "repository_path" || + parsed.sidebarProjectGroupingMode === "separate" + ? { sidebarProjectGroupingMode: parsed.sidebarProjectGroupingMode } + : {}), + ...(sidebarProjectGroupingOverrides ? { sidebarProjectGroupingOverrides } : {}), + ...(parsed.sidebarProjectSortOrder === "updated_at" || + parsed.sidebarProjectSortOrder === "created_at" || + parsed.sidebarProjectSortOrder === "manual" + ? { sidebarProjectSortOrder: parsed.sidebarProjectSortOrder } + : {}), + ...(parsed.sidebarThreadSortOrder === "updated_at" || + parsed.sidebarThreadSortOrder === "created_at" + ? { sidebarThreadSortOrder: parsed.sidebarThreadSortOrder } + : {}), + ...(parsed.timestampFormat === "locale" || + parsed.timestampFormat === "12-hour" || + parsed.timestampFormat === "24-hour" + ? { timestampFormat: parsed.timestampFormat } + : {}), + }); + + window.localStorage.setItem(CLIENT_SETTINGS_STORAGE_KEY, JSON.stringify(migrated)); + window.localStorage.removeItem(LEGACY_CLIENT_SETTINGS_STORAGE_KEY); + return migrated; + } catch { + return null; + } +} + export function readBrowserClientSettings(): ClientSettings | null { if (!hasWindow()) { return null; } try { - return getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema); + const settings = getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema); + return settings ?? readLegacyBrowserClientSettings(); } catch { try { const raw = window.localStorage.getItem(CLIENT_SETTINGS_STORAGE_KEY); - if (!raw) { - return null; + if (raw) { + const parsed = JSON.parse(raw) as Partial; + return Schema.decodeSync(ClientSettingsSchema)({ + ...DEFAULT_CLIENT_SETTINGS, + ...parsed, + }); } - const parsed = JSON.parse(raw) as Partial; - return Schema.decodeSync(ClientSettingsSchema)({ - ...DEFAULT_CLIENT_SETTINGS, - ...parsed, - }); - } catch { - return null; - } + } catch {} + + return readLegacyBrowserClientSettings(); } } diff --git a/apps/web/src/components/ChatMarkdown.browser.tsx b/apps/web/src/components/ChatMarkdown.browser.tsx index 48f345bd..42f0ab80 100644 --- a/apps/web/src/components/ChatMarkdown.browser.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.tsx @@ -63,7 +63,7 @@ describe("ChatMarkdown", () => { ); try { - const link = page.getByRole("link", { name: "PermissionRule.ts:1" }); + const link = page.getByRole("link", { name: "PermissionRule.ts · L1" }); await expect.element(link).toBeInTheDocument(); await expect.element(link).toHaveAttribute("href", `${filePath}#L1`); @@ -76,4 +76,111 @@ describe("ChatMarkdown", () => { await screen.unmount(); } }); + + it("shows column information inline when present", async () => { + const filePath = + "/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts"; + const screen = await render( + , + ); + + try { + const link = page.getByRole("link", { name: "PermissionRule.ts · L1:C7" }); + await expect.element(link).toBeInTheDocument(); + await expect.element(link).toHaveAttribute("href", `${filePath}#L1C7`); + + await link.click(); + + await vi.waitFor(() => { + expect(openInPreferredEditorMock).toHaveBeenCalledWith( + expect.anything(), + `${filePath}:1:7`, + ); + }); + } finally { + await screen.unmount(); + } + }); + + it("disambiguates duplicate file basenames inline", async () => { + const firstPath = "/Users/yashsingh/p/t3code/apps/web/src/components/chat/MessagesTimeline.tsx"; + const secondPath = "/Users/yashsingh/p/t3code/apps/web/src/components/MessagesTimeline.tsx"; + const screen = await render( + , + ); + + try { + await expect + .element(page.getByRole("link", { name: "MessagesTimeline.tsx · components/chat" })) + .toBeInTheDocument(); + await expect + .element(page.getByRole("link", { name: "MessagesTimeline.tsx · src/components" })) + .toBeInTheDocument(); + } finally { + await screen.unmount(); + } + }); + + it("keeps normal web links unchanged", async () => { + const screen = await render( + , + ); + + try { + const link = page.getByRole("link", { name: "OpenAI" }); + await expect.element(link).toBeInTheDocument(); + await expect.element(link).toHaveAttribute("href", "https://openai.com/docs"); + await expect.element(link).toHaveAttribute("target", "_blank"); + } finally { + await screen.unmount(); + } + }); + + it("renders bare file urls as clickable file links", async () => { + const filePath = + "/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts"; + const screen = await render( + , + ); + + try { + const link = page.getByRole("link", { name: "PermissionRule.ts · L8" }); + await expect.element(link).toBeInTheDocument(); + await expect.element(link).toHaveAttribute("href", `${filePath}#L8`); + + await link.click(); + + await vi.waitFor(() => { + expect(openInPreferredEditorMock).toHaveBeenCalledWith(expect.anything(), `${filePath}:8`); + }); + } finally { + await screen.unmount(); + } + }); + + it("keeps trailing punctuation outside bare file URL editor targets", async () => { + const filePath = + "/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts"; + const screen = await render( + , + ); + + try { + const link = page.getByRole("link", { name: "PermissionRule.ts · L8" }); + await expect.element(link).toBeInTheDocument(); + await expect.element(link).toHaveAttribute("href", `${filePath}#L8`); + await expect.element(page.getByText(", then continue")).toBeInTheDocument(); + + await link.click(); + + await vi.waitFor(() => { + expect(openInPreferredEditorMock).toHaveBeenCalledWith(expect.anything(), `${filePath}:8`); + }); + } finally { + await screen.unmount(); + } + }); }); diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx new file mode 100644 index 00000000..29d33a40 --- /dev/null +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -0,0 +1,42 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import ChatMarkdown from "./ChatMarkdown"; + +describe("ChatMarkdown", () => { + it("renders standalone file URLs with file-link behavior", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("chat-markdown-file-link"); + expect(html).toContain("index.ts"); + expect(html).toContain("L12"); + }); + + it("renders bare file URLs inside plain text as file links", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("chat-markdown-file-link"); + expect(html).toContain("index.ts"); + expect(html).toContain("L12"); + }); + + it("excludes trailing punctuation from bare file URL targets", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('href="/home/project/src/index.ts#L12"'); + expect(html).toContain("chat-markdown-file-link"); + expect(html).toContain(", then continue."); + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 366f9231..d56551b6 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -3,6 +3,7 @@ import { CheckIcon, CopyIcon } from "lucide-react"; import React, { Children, Suspense, + type MouseEvent as ReactMouseEvent, isValidElement, use, useCallback, @@ -17,13 +18,17 @@ import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; import { defaultUrlTransform } from "react-markdown"; import remarkGfm from "remark-gfm"; +import { VscodeEntryIcon } from "./chat/VscodeEntryIcon"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { toastManager } from "./ui/toast"; import { openInPreferredEditor } from "../editorPreferences"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; -import { resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref } from "../markdown-links"; +import { resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref } from "../markdown-links"; import { readLocalApi } from "../localApi"; +import { cn } from "../lib/utils"; class CodeHighlightErrorBoundary extends React.Component< { fallback: ReactNode; children: ReactNode }, @@ -236,34 +241,435 @@ function SuspenseShikiCodeBlock({ ); } +interface MarkdownFileLinkProps { + href: string; + targetPath: string; + displayPath: string; + filePath: string; + label: string; + theme: "light" | "dark"; + className?: string | undefined; +} + +const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g; +const STANDALONE_FILE_URL_PATTERN = /\bfile:\/\/\/[^\s<>()]+/gi; +const STANDALONE_FILE_URL_TRAILING_PUNCTUATION_PATTERN = /[.,!?;:]+$/; + +function splitStandaloneFileUrlCandidate(value: string): { href: string; trailingText: string } { + if (value.length === 0) { + return { href: value, trailingText: "" }; + } + + const trailingPunctuationMatch = value.match(STANDALONE_FILE_URL_TRAILING_PUNCTUATION_PATTERN); + const trailingText = trailingPunctuationMatch?.[0] ?? ""; + if (!trailingText) { + return { href: value, trailingText: "" }; + } + + return { + href: value.slice(0, value.length - trailingText.length), + trailingText, + }; +} +const MARKDOWN_FILE_LINK_CLASS_NAME = + "chat-markdown-file-link relative top-[2px] max-w-full no-underline"; +const MARKDOWN_FILE_LINK_ICON_CLASS_NAME = "chat-markdown-file-link-icon size-3.5 shrink-0"; +const MARKDOWN_FILE_LINK_LABEL_CLASS_NAME = "chat-markdown-file-link-label truncate"; + +function pathParentSegments(path: string): string[] { + const normalized = path.replaceAll("\\", "/"); + const segments = normalized.split("/").filter((segment) => segment.length > 0); + return segments.slice(0, -1); +} + +function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray): Map { + const groups = new Map>(); + for (const filePath of filePaths) { + const pathSegments = filePath + .replaceAll("\\", "/") + .split("/") + .filter((segment) => segment.length > 0); + const basename = pathSegments[pathSegments.length - 1]; + if (!basename) continue; + const group = groups.get(basename) ?? new Set(); + group.add(filePath); + groups.set(basename, group); + } + + const suffixByPath = new Map(); + for (const group of groups.values()) { + const uniquePaths = [...group]; + if (uniquePaths.length < 2) continue; + + const parentSegmentsByPath = new Map( + uniquePaths.map((filePath) => [filePath, pathParentSegments(filePath)]), + ); + const minUniqueDepthByPath = new Map(); + + for (const filePath of uniquePaths) { + const segments = parentSegmentsByPath.get(filePath) ?? []; + let resolvedDepth = segments.length; + for (let depth = 1; depth <= segments.length; depth += 1) { + const candidate = segments.slice(-depth).join("/"); + const collision = uniquePaths.some((otherPath) => { + if (otherPath === filePath) return false; + const otherSegments = parentSegmentsByPath.get(otherPath) ?? []; + return otherSegments.slice(-depth).join("/") === candidate; + }); + if (!collision) { + resolvedDepth = depth; + break; + } + } + minUniqueDepthByPath.set(filePath, resolvedDepth); + } + + for (const filePath of uniquePaths) { + const segments = parentSegmentsByPath.get(filePath) ?? []; + if (segments.length === 0) continue; + const minUniqueDepth = minUniqueDepthByPath.get(filePath) ?? 1; + const suffixDepth = Math.min(segments.length, Math.max(minUniqueDepth, 2)); + suffixByPath.set(filePath, segments.slice(-suffixDepth).join("/")); + } + } + + return suffixByPath; +} + +function extractMarkdownLinkHrefs(text: string): string[] { + const hrefs: string[] = []; + for (const match of text.matchAll(MARKDOWN_LINK_HREF_PATTERN)) { + const href = match[1]?.trim(); + if (!href) continue; + hrefs.push(href); + } + return hrefs; +} + +function extractStandaloneFileUrlHrefs(text: string): string[] { + const hrefs: string[] = []; + for (const match of text.matchAll(STANDALONE_FILE_URL_PATTERN)) { + const href = match[0]?.trim(); + if (!href) continue; + const candidate = splitStandaloneFileUrlCandidate(href); + if (!candidate.href) continue; + hrefs.push(candidate.href); + } + return hrefs; +} + +function normalizeMarkdownLinkHrefKey(href: string): string { + return rewriteMarkdownFileUriHref(href.trim()) ?? href.trim(); +} + +function remarkStandaloneFileUrls() { + return (tree: { + children?: Array<{ + type?: string; + value?: string; + children?: Array; + }>; + }) => { + const visitChildren = ( + parent: { + children?: Array<{ + type?: string; + value?: string; + children?: Array; + }>; + } | null, + ) => { + if (!parent?.children) { + return; + } + + for (let index = 0; index < parent.children.length; index += 1) { + const child = parent.children[index]; + if (!child || typeof child !== "object") { + continue; + } + + if (child.type === "text" && typeof child.value === "string") { + const matches = [...child.value.matchAll(STANDALONE_FILE_URL_PATTERN)]; + if (matches.length === 0) { + continue; + } + + const replacementNodes: Array> = []; + let lastIndex = 0; + for (const match of matches) { + const href = match[0]; + const matchIndex = match.index ?? -1; + if (!href || matchIndex < lastIndex) { + continue; + } + if (matchIndex > lastIndex) { + replacementNodes.push({ + type: "text", + value: child.value.slice(lastIndex, matchIndex), + }); + } + const candidate = splitStandaloneFileUrlCandidate(href); + if (!candidate.href) { + replacementNodes.push({ + type: "text", + value: href, + }); + lastIndex = matchIndex + href.length; + continue; + } + replacementNodes.push({ + type: "link", + url: candidate.href, + title: null, + children: [{ type: "text", value: candidate.href }], + }); + if (candidate.trailingText.length > 0) { + replacementNodes.push({ + type: "text", + value: candidate.trailingText, + }); + } + lastIndex = matchIndex + href.length; + } + + if (lastIndex < child.value.length) { + replacementNodes.push({ + type: "text", + value: child.value.slice(lastIndex), + }); + } + + parent.children.splice(index, 1, ...replacementNodes); + index += replacementNodes.length - 1; + continue; + } + + if ( + child.type === "link" || + child.type === "linkReference" || + child.type === "definition" || + child.type === "code" || + child.type === "inlineCode" + ) { + continue; + } + + visitChildren( + child as { + children?: Array<{ + type?: string; + value?: string; + children?: Array; + }>; + }, + ); + } + }; + + visitChildren(tree); + }; +} + +const MarkdownFileLink = memo(function MarkdownFileLink({ + href, + targetPath, + displayPath, + filePath, + label, + theme, + className, +}: MarkdownFileLinkProps) { + const handleOpen = useCallback(() => { + const api = readLocalApi(); + if (!api) { + toastManager.add({ + type: "error", + title: "Open in editor is unavailable", + }); + return; + } + + void openInPreferredEditor(api, targetPath).catch((error) => { + toastManager.add({ + type: "error", + title: "Unable to open file", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }); + }, [targetPath]); + + const handleCopy = useCallback((value: string, title: string) => { + if (typeof window === "undefined" || !navigator.clipboard?.writeText) { + toastManager.add({ + type: "error", + title: `Failed to copy ${title.toLowerCase()}`, + description: "Clipboard API unavailable.", + }); + return; + } + + void navigator.clipboard.writeText(value).then( + () => { + toastManager.add({ + type: "success", + title: `${title} copied`, + description: value, + }); + }, + (error) => { + toastManager.add({ + type: "error", + title: `Failed to copy ${title.toLowerCase()}`, + description: error instanceof Error ? error.message : "An error occurred.", + }); + }, + ); + }, []); + + const handleContextMenu = useCallback( + async (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + const api = readLocalApi(); + if (!api) return; + + const clicked = await api.contextMenu.show( + [ + { id: "open", label: "Open in editor" }, + { id: "copy-relative", label: "Copy relative path" }, + { id: "copy-full", label: "Copy full path" }, + ] as const, + { x: event.clientX, y: event.clientY }, + ); + + if (clicked === "open") { + handleOpen(); + return; + } + if (clicked === "copy-relative") { + handleCopy(displayPath, "Relative path"); + return; + } + if (clicked === "copy-full") { + handleCopy(targetPath, "Full path"); + } + }, + [displayPath, handleCopy, handleOpen, targetPath], + ); + + return ( + + { + event.preventDefault(); + event.stopPropagation(); + handleOpen(); + }} + onContextMenu={handleContextMenu} + > + + {label} + + } + /> + +
+ {displayPath} +
+
+
+ ); +}, areMarkdownFileLinkPropsEqual); + +function areMarkdownFileLinkPropsEqual( + previous: Readonly, + next: Readonly, +): boolean { + return ( + previous.href === next.href && + previous.targetPath === next.targetPath && + previous.displayPath === next.displayPath && + previous.filePath === next.filePath && + previous.label === next.label && + previous.theme === next.theme && + previous.className === next.className + ); +} + function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const diffThemeName = resolveDiffThemeName(resolvedTheme); + const markdownFileLinkMetaByHref = useMemo(() => { + const metaByHref = new Map< + string, + NonNullable> + >(); + for (const href of [ + ...extractMarkdownLinkHrefs(text), + ...extractStandaloneFileUrlHrefs(text), + ]) { + const normalizedHref = normalizeMarkdownLinkHrefKey(href); + if (metaByHref.has(normalizedHref)) continue; + const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd); + if (meta) { + metaByHref.set(normalizedHref, meta); + } + } + return metaByHref; + }, [cwd, text]); + const fileLinkParentSuffixByPath = useMemo(() => { + const filePaths = [...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath); + return buildFileLinkParentSuffixByPath(filePaths); + }, [markdownFileLinkMetaByHref]); const markdownUrlTransform = useCallback((href: string) => { return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); }, []); const markdownComponents = useMemo( () => ({ a({ node: _node, href, ...props }) { - const targetPath = resolveMarkdownFileLinkTarget(href, cwd); - if (!targetPath) { + const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; + const fileLinkMeta = normalizedHref + ? (markdownFileLinkMetaByHref.get(normalizedHref) ?? + resolveMarkdownFileLinkMeta(normalizedHref, cwd)) + : null; + if (!fileLinkMeta) { return ; } + const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath); + const labelParts = [fileLinkMeta.basename]; + if (typeof parentSuffix === "string" && parentSuffix.length > 0) { + labelParts.push(parentSuffix); + } + if (fileLinkMeta.line) { + labelParts.push( + `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`, + ); + } + return ( - { - event.preventDefault(); - event.stopPropagation(); - const api = readLocalApi(); - if (api) { - void openInPreferredEditor(api, targetPath); - } else { - console.warn("Native API not found. Unable to open file in editor."); - } - }} + ); }, @@ -289,13 +695,20 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { ); }, }), - [cwd, diffThemeName, isStreaming], + [ + diffThemeName, + cwd, + fileLinkParentSuffixByPath, + isStreaming, + markdownFileLinkMetaByHref, + resolvedTheme, + ], ); return (
diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 1774a15e..41f62733 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -17,12 +17,7 @@ import { OrchestrationSessionStatus, DEFAULT_SERVER_SETTINGS, } from "@t3tools/contracts"; -import { - scopedProjectKey, - scopedThreadKey, - scopeProjectRef, - scopeThreadRef, -} from "@t3tools/client-runtime"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { HttpResponse, http, ws } from "msw"; import { setupWorker } from "msw/browser"; @@ -52,6 +47,7 @@ import { __resetLocalApiForTests } from "../localApi"; import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; import { getServerConfig } from "../rpc/serverState"; import { getRouter } from "../router"; +import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; @@ -78,7 +74,18 @@ const THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, THREAD_ID); const THREAD_KEY = scopedThreadKey(THREAD_REF); const UUID_ROUTE_RE = /^\/draft\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const PROJECT_DRAFT_KEY = `${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`; -const PROJECT_KEY = scopedProjectKey(scopeProjectRef(LOCAL_ENVIRONMENT_ID, PROJECT_ID)); +const PROJECT_LOGICAL_KEY = deriveLogicalProjectKeyFromSettings( + { + environmentId: LOCAL_ENVIRONMENT_ID, + id: PROJECT_ID, + cwd: "/repo/project", + repositoryIdentity: null, + }, + { + sidebarProjectGroupingMode: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingOverrides, + }, +); const NOW_ISO = "2026-03-04T12:00:00.000Z"; const BASE_TIME_MS = Date.parse(NOW_ISO); const ATTACHMENT_SVG = ""; @@ -1638,12 +1645,12 @@ describe("ChatView timeline estimator parity (full app)", () => { customWsRpcResolver = null; document.body.innerHTML = ""; }); - it("re-expands the bootstrap project using its scoped key", async () => { + it("re-expands the bootstrap project using its logical key", async () => { useUiStateStore.setState({ projectExpandedById: { - [PROJECT_KEY]: false, + [PROJECT_LOGICAL_KEY]: false, }, - projectOrder: [PROJECT_KEY], + projectOrder: [PROJECT_LOGICAL_KEY], threadLastVisitedAtById: {}, }); @@ -1658,7 +1665,7 @@ describe("ChatView timeline estimator parity (full app)", () => { try { await vi.waitFor( () => { - expect(useUiStateStore.getState().projectExpandedById[PROJECT_KEY]).toBe(true); + expect(useUiStateStore.getState().projectExpandedById[PROJECT_LOGICAL_KEY]).toBe(true); }, { timeout: 8_000, interval: 16 }, ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b3410f57..78b4acdd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,7 +1,7 @@ import { type ApprovalRequestId, DEFAULT_MODEL_BY_PROVIDER, - type ClaudeCodeEffort, + type ClaudeAgentEffort, type EnvironmentId, type MessageId, type ModelSelection, @@ -38,6 +38,7 @@ import { readEnvironmentApi } from "../environmentApi"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; +import { createModelSelection } from "../modelSelectionUtils"; import { collapseExpandedComposerCursor, parseStandaloneComposerSlashCommand, @@ -92,6 +93,8 @@ import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; +import { useMediaQuery } from "../hooks/useMediaQuery"; +import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; @@ -111,7 +114,7 @@ import { getProviderModelCapabilities, resolveSelectableProvider } from "../prov import { useSettings } from "../hooks/useSettings"; import { resolveAppModelSelection } from "../modelSelection"; import { isTerminalFocused } from "../lib/terminalFocus"; -import { deriveLogicalProjectKey } from "../logicalProject"; +import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, @@ -171,7 +174,7 @@ import { } from "~/rpc/serverState"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; import { retainThreadDetailSubscription } from "../environments/runtime/service"; -import { createModelSelection } from "../modelSelectionUtils"; +import { RightPanelSheet } from "./RightPanelSheet"; const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; @@ -304,7 +307,7 @@ function formatOutgoingPrompt(params: { }): string { const caps = getProviderModelCapabilities(params.models, params.model, params.provider); if (params.effort && caps.promptInjectedEffortLevels.includes(params.effort)) { - return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeCodeEffort | null); + return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeAgentEffort | null); } return params.text; } @@ -676,6 +679,7 @@ export default function ChatView(props: ChatViewProps) { const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); const [planSidebarOpen, setPlanSidebarOpen] = useState(false); + const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); // Tracks whether the user explicitly dismissed the sidebar for the active turn. const planSidebarDismissedForTurnRef = useRef(null); // When set, the thread-change reset effect will open the sidebar instead of closing it. @@ -844,10 +848,16 @@ export default function ChatView(props: ChatViewProps) { const primaryEnvironmentId = usePrimaryEnvironmentId(); const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); + const projectGroupingSettings = useSettings((settings) => ({ + sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, + })); const logicalProjectEnvironments = useMemo(() => { if (!activeProject) return []; - const logicalKey = deriveLogicalProjectKey(activeProject); - const memberProjects = allProjects.filter((p) => deriveLogicalProjectKey(p) === logicalKey); + const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); + const memberProjects = allProjects.filter( + (p) => deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) === logicalKey, + ); const seen = new Set(); const envs: Array<{ environmentId: EnvironmentId; @@ -883,6 +893,7 @@ export default function ChatView(props: ChatViewProps) { }, [ activeProject, allProjects, + projectGroupingSettings, primaryEnvironmentId, savedEnvironmentRegistry, savedEnvironmentRuntimeById, @@ -912,7 +923,10 @@ export default function ChatView(props: ChatViewProps) { throw new Error("No active project is available for this pull request."); } const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id); - const logicalProjectKey = deriveLogicalProjectKey(activeProject); + const logicalProjectKey = deriveLogicalProjectKeyFromSettings( + activeProject, + projectGroupingSettings, + ); const storedDraftSession = getDraftSessionByLogicalProjectKey(logicalProjectKey); if (storedDraftSession) { setDraftThreadContext(storedDraftSession.draftId, input); @@ -973,6 +987,7 @@ export default function ChatView(props: ChatViewProps) { getDraftSessionByLogicalProjectKey, isServerThread, navigate, + projectGroupingSettings, routeKind, setDraftThreadContext, setLogicalProjectDraftThreadId, @@ -1898,6 +1913,11 @@ export default function ChatView(props: ChatViewProps) { return !open; }); }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); + const closePlanSidebar = useCallback(() => { + setPlanSidebarOpen(false); + planSidebarDismissedForTurnRef.current = + activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; + }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); const persistThreadSettingsForNextTurn = useCallback( async (input: { @@ -2511,13 +2531,13 @@ export default function ChatView(props: ChatViewProps) { } } const title = truncate(titleSeed); - const threadCreateModelSelection = createModelSelection({ + const threadCreateModelSelection: ModelSelection = createModelSelection({ provider: ctxSelectedProvider, model: ctxSelectedModel || activeProject.defaultModelSelection?.model || DEFAULT_MODEL_BY_PROVIDER.codex, - ...(ctxSelectedModelSelection.options !== undefined + ...(ctxSelectedModelSelection.options ? { options: ctxSelectedModelSelection.options } : {}), }); @@ -3091,7 +3111,7 @@ export default function ChatView(props: ChatViewProps) { providerStatuses, model, ); - const nextModelSelection = createModelSelection({ + const nextModelSelection: ModelSelection = createModelSelection({ provider: resolvedProvider, model: resolvedModel, }); @@ -3395,7 +3415,7 @@ export default function ChatView(props: ChatViewProps) { {/* end chat column */} {/* Plan sidebar */} - {planSidebarOpen ? ( + {planSidebarOpen && !shouldUsePlanSidebarSheet ? ( { - setPlanSidebarOpen(false); - // Track that the user explicitly dismissed for this turn so auto-open won't fight them. - planSidebarDismissedForTurnRef.current = - activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; - }} + mode="sidebar" + onClose={closePlanSidebar} /> ) : null}
@@ -3431,6 +3447,21 @@ export default function ChatView(props: ChatViewProps) { onAddTerminalContext={addTerminalContextToDraft} /> ))} + {shouldUsePlanSidebarSheet ? ( + + + + ) : null} {expandedImage && ( diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 3e2f1ec8..866db58f 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -17,6 +17,10 @@ export interface CommandPaletteItem { readonly description?: string; readonly timestamp?: string; readonly icon: ReactNode; + /** Optional content rendered inline before the title text. */ + readonly titleLeadingContent?: ReactNode; + /** Optional content rendered inline after the title text (before the timestamp). */ + readonly titleTrailingContent?: ReactNode; readonly shortcutCommand?: KeybindingCommand; } @@ -102,20 +106,24 @@ export function buildProjectActionItems(input: { })); } -export function buildThreadActionItems(input: { - threads: ReadonlyArray< - Pick< - SidebarThreadSummary, - "archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title" - > & { - updatedAt?: string | undefined; - latestUserMessageAt?: string | null; - } - >; +export type BuildThreadActionItemsThread = Pick< + SidebarThreadSummary, + "archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title" +> & { + updatedAt?: string | undefined; + latestUserMessageAt?: string | null; +}; + +export function buildThreadActionItems(input: { + threads: ReadonlyArray; activeThreadId?: Thread["id"]; projectTitleById: ReadonlyMap; sortOrder: SidebarThreadSortOrder; icon: ReactNode; + /** Optional content rendered inline before the title text per-thread. */ + renderLeadingContent?: (thread: TThread) => ReactNode; + /** Optional content rendered inline after the title text per-thread. */ + renderTrailingContent?: (thread: TThread) => ReactNode; runThread: (thread: Pick) => Promise; limit?: number; }): CommandPaletteActionItem[] { @@ -140,6 +148,9 @@ export function buildThreadActionItems(input: { descriptionParts.push("Current thread"); } + const leadingContent = input.renderLeadingContent?.(thread); + const trailingContent = input.renderTrailingContent?.(thread); + return { kind: "action", value: `thread:${thread.id}`, @@ -150,6 +161,8 @@ export function buildThreadActionItems(input: { thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, ), icon: input.icon, + ...(leadingContent ? { titleLeadingContent: leadingContent } : {}), + ...(trailingContent ? { titleTrailingContent: trailingContent } : {}), run: async () => { await input.runThread(thread); }, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index fbbeda10..929a9f87 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -89,6 +89,7 @@ import { import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteResults } from "./CommandPaletteResults"; import { ProjectFavicon } from "./ProjectFavicon"; +import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; import { useServerKeybindings } from "../rpc/serverState"; import { resolveShortcutCommand } from "../keybindings"; import { @@ -504,6 +505,8 @@ function OpenCommandPaletteDialog() { projectTitleById, sortOrder: settings.sidebarThreadSortOrder, icon: , + renderLeadingContent: (thread) => , + renderTrailingContent: (thread) => , runThread: async (thread) => { await navigate({ to: "/$environmentId/$threadId", diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index e2841d58..8cdf0694 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -86,14 +86,20 @@ function CommandPaletteResultRow(props: { {props.item.icon} {props.item.description ? ( - {props.item.title} + + {props.item.titleLeadingContent} + {props.item.title} + {props.item.titleTrailingContent} + {props.item.description} ) : ( - + + {props.item.titleLeadingContent} {props.item.title} + {props.item.titleTrailingContent} )} {props.item.timestamp ? ( diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 95157d78..81130996 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -32,11 +32,8 @@ import { type ElementNode, type LexicalNode, type SerializedLexicalNode, - TextNode, - type EditorConfig, type EditorState, type NodeKey, - type SerializedTextNode, type Spread, } from "lexical"; import { @@ -103,7 +100,7 @@ type SerializedComposerMentionNode = Spread< type: "composer-mention"; version: 1; }, - SerializedTextNode + SerializedLexicalNode >; type SerializedComposerSkillNode = Spread< @@ -132,7 +129,40 @@ const ComposerTerminalContextActionsContext = createContext<{ onRemoveTerminalContext: () => {}, }); -class ComposerMentionNode extends TextNode { +function ComposerMentionDecorator(props: { path: string }) { + const theme = resolvedThemeFromDocument(); + const chip = ( + + + {basenameOfPath(props.path)} + + ); + + return ( + + + + {props.path} + + + ); +} + +class ComposerMentionNode extends DecoratorNode { __path: string; static override getType(): string { @@ -144,12 +174,12 @@ class ComposerMentionNode extends TextNode { } static override importJSON(serializedNode: SerializedComposerMentionNode): ComposerMentionNode { - return $createComposerMentionNode(serializedNode.path); + return $createComposerMentionNode(serializedNode.path).updateFromJSON(serializedNode); } constructor(path: string, key?: NodeKey) { + super(key); const normalizedPath = path.startsWith("@") ? path.slice(1) : path; - super(`@${normalizedPath}`, key); this.__path = normalizedPath; } @@ -162,41 +192,26 @@ class ComposerMentionNode extends TextNode { }; } - override createDOM(_config: EditorConfig): HTMLElement { + override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = COMPOSER_INLINE_CHIP_CLASS_NAME; - dom.contentEditable = "false"; - dom.setAttribute("spellcheck", "false"); - renderMentionChipDom(dom, this.__path); + dom.className = "inline-flex align-middle leading-none"; return dom; } - override updateDOM( - prevNode: ComposerMentionNode, - dom: HTMLElement, - _config: EditorConfig, - ): boolean { - dom.contentEditable = "false"; - if (prevNode.__text !== this.__text || prevNode.__path !== this.__path) { - renderMentionChipDom(dom, this.__path); - } - return false; - } - - override canInsertTextBefore(): false { + override updateDOM(): false { return false; } - override canInsertTextAfter(): false { - return false; + override getTextContent(): string { + return `@${this.__path}`; } - override isTextEntity(): true { + override isInline(): true { return true; } - override isToken(): true { - return true; + override decorate(): ReactElement { + return ; } } @@ -434,26 +449,6 @@ function resolvedThemeFromDocument(): "light" | "dark" { return document.documentElement.classList.contains("dark") ? "dark" : "light"; } -function renderMentionChipDom(container: HTMLElement, pathValue: string): void { - container.textContent = ""; - container.style.setProperty("user-select", "none"); - container.style.setProperty("-webkit-user-select", "none"); - - const theme = resolvedThemeFromDocument(); - const icon = document.createElement("img"); - icon.alt = ""; - icon.ariaHidden = "true"; - icon.className = COMPOSER_INLINE_CHIP_ICON_CLASS_NAME; - icon.loading = "lazy"; - icon.src = getVscodeIconUrlForEntry(pathValue, inferEntryKindFromPath(pathValue), theme); - - const label = document.createElement("span"); - label.className = COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME; - label.textContent = basenameOfPath(pathValue); - - container.append(icon, label); -} - function terminalContextSignature(contexts: ReadonlyArray): string { return contexts .map((context) => @@ -595,12 +590,9 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb } if ($isTextNode(node)) { - if (node instanceof ComposerMentionNode) { - return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); - } return offset + Math.min(pointOffset, node.getTextContentSize()); } - if (node instanceof ComposerSkillNode || node instanceof ComposerTerminalContextNode) { + if (isComposerInlineTokenNode(node)) { return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } @@ -642,12 +634,9 @@ function getExpandedAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: numbe } if ($isTextNode(node)) { - if (node instanceof ComposerMentionNode) { - return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); - } return offset + Math.min(pointOffset, node.getTextContentSize()); } - if (node instanceof ComposerSkillNode || node instanceof ComposerTerminalContextNode) { + if (isComposerInlineTokenNode(node)) { return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } @@ -673,10 +662,7 @@ function findSelectionPointAtOffset( node: LexicalNode, remainingRef: { value: number }, ): { key: string; offset: number; type: "text" | "element" } | null { - if (node instanceof ComposerMentionNode || node instanceof ComposerSkillNode) { - return findSelectionPointForInlineToken(node, remainingRef); - } - if (node instanceof ComposerTerminalContextNode) { + if (isComposerInlineTokenNode(node)) { return findSelectionPointForInlineToken(node, remainingRef); } diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 487d2952..3e0e31aa 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -80,6 +80,7 @@ function createBaseServerConfig(): ServerConfig { auth: { status: "authenticated" }, checkedAt: NOW_ISO, models: [], + quotaSnapshots: [], slashCommands: [], skills: [], }, @@ -99,7 +100,7 @@ function createBaseServerConfig(): ServerConfig { providers: { codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, copilot: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, - claudeAgent: { enabled: true, binaryPath: "", customModels: [] }, + claudeAgent: { enabled: true, binaryPath: "", customModels: [], launchArgs: "" }, }, }, }; diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index 489e38f4..00b9da2b 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -59,6 +59,7 @@ interface PlanSidebarProps { markdownCwd: string | undefined; workspaceRoot: string | undefined; timestampFormat: TimestampFormat; + mode?: "sheet" | "sidebar"; onClose: () => void; } @@ -70,6 +71,7 @@ const PlanSidebar = memo(function PlanSidebar({ markdownCwd, workspaceRoot, timestampFormat, + mode = "sidebar", onClose, }: PlanSidebarProps) { const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false); @@ -123,7 +125,14 @@ const PlanSidebar = memo(function PlanSidebar({ }, [environmentId, planMarkdown, workspaceRoot]); return ( -
+
{/* Header */}
diff --git a/apps/web/src/components/RightPanelSheet.tsx b/apps/web/src/components/RightPanelSheet.tsx new file mode 100644 index 00000000..ebc4aa0a --- /dev/null +++ b/apps/web/src/components/RightPanelSheet.tsx @@ -0,0 +1,30 @@ +import { type ReactNode } from "react"; + +import { RIGHT_PANEL_SHEET_CLASS_NAME } from "../rightPanelLayout"; +import { Sheet, SheetPopup } from "./ui/sheet"; + +export function RightPanelSheet(props: { + children: ReactNode; + open: boolean; + onClose: () => void; +}) { + return ( + { + if (!open) { + props.onClose(); + } + }} + > + + {props.children} + + + ); +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cff71cf6..ad6dd3a3 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -11,6 +11,12 @@ import { TerminalIcon, TriangleAlertIcon, } from "lucide-react"; +import { + prStatusIndicator, + resolveThreadPr, + terminalStatusFromRunningIds, + ThreadStatusLabel, +} from "./ThreadStatusIndicators"; import { ProjectFavicon } from "./ProjectFavicon"; import { autoAnimate } from "@formkit/auto-animate"; import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; @@ -31,13 +37,13 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd- import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import { + type ContextMenuItem, type DesktopUpdateState, ProjectId, - type ScopedProjectRef, type ScopedThreadRef, + type SidebarProjectGroupingMode, type ThreadEnvMode, ThreadId, - type GitStatusResult, } from "@t3tools/contracts"; import { parseScopedThreadKey, @@ -59,7 +65,6 @@ import { isMacPlatform, newCommandId } from "../lib/utils"; import { selectProjectByRef, selectProjectsAcrossEnvironments, - selectSidebarThreadsForProjectRef, selectSidebarThreadsForProjectRefs, selectSidebarThreadsAcrossEnvironments, selectThreadByRef, @@ -102,7 +107,26 @@ import { } from "./desktopUpdate.logic"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { Button } from "./ui/button"; -import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { Input } from "./ui/input"; +import { + Menu, + MenuGroup, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator, + MenuTrigger, +} from "./ui/menu"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { SidebarContent, @@ -142,12 +166,18 @@ import { CommandDialogTrigger } from "./ui/command"; import { readEnvironmentApi } from "../environmentApi"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; -import { deriveLogicalProjectKey } from "../logicalProject"; +import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey } from "../logicalProject"; import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; -import type { Project, SidebarThreadSummary } from "../types"; +import type { SidebarThreadSummary } from "../types"; +import { + buildPhysicalToLogicalProjectKeyMap, + buildSidebarProjectSnapshots, + type SidebarProjectGroupMember, + type SidebarProjectSnapshot, +} from "../sidebarProjectGrouping"; const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", @@ -163,6 +193,11 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { easing: "ease-out", } as const; const EMPTY_THREAD_JUMP_LABELS = new Map(); +const PROJECT_GROUPING_MODE_LABELS: Record = { + repository: "Group by repository", + repository_path: "Group by repository path", + separate: "Keep separate", +}; function threadJumpLabelMapsEqual( left: ReadonlyMap, @@ -182,6 +217,28 @@ function threadJumpLabelMapsEqual( return true; } +function formatProjectMemberActionLabel( + member: SidebarProjectGroupMember, + groupedProjectCount: number, +): string { + if (groupedProjectCount <= 1) { + return member.name; + } + + return member.environmentLabel ? `${member.environmentLabel} — ${member.cwd}` : member.cwd; +} + +function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): string { + switch (mode) { + case "repository": + return "Projects from the same repository share one sidebar row."; + case "repository_path": + return "Projects group only when both the repository and repo-relative path match."; + case "separate": + return "Every project path gets its own sidebar row."; + } +} + function buildThreadJumpLabelMap(input: { keybindings: ReturnType; platform: string; @@ -212,122 +269,6 @@ function buildThreadJumpLabelMap(input: { return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; } -type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; - -type SidebarProjectSnapshot = Project & { - projectKey: string; - environmentPresence: EnvironmentPresence; - memberProjectRefs: readonly ScopedProjectRef[]; - /** Labels for remote environments this project lives in. */ - remoteEnvironmentLabels: readonly string[]; -}; -interface TerminalStatusIndicator { - label: "Terminal process running"; - colorClass: string; - pulse: boolean; -} - -interface PrStatusIndicator { - label: "PR open" | "PR closed" | "PR merged"; - colorClass: string; - tooltip: string; - url: string; -} - -type ThreadPr = GitStatusResult["pr"]; - -function ThreadStatusLabel({ - status, - compact = false, -}: { - status: ThreadStatusPill; - compact?: boolean; -}) { - if (compact) { - return ( - - - {status.label} - - ); - } - - return ( - - - {status.label} - - ); -} - -function terminalStatusFromRunningIds( - runningTerminalIds: string[], -): TerminalStatusIndicator | null { - if (runningTerminalIds.length === 0) { - return null; - } - return { - label: "Terminal process running", - colorClass: "text-teal-600 dark:text-teal-300/90", - pulse: true, - }; -} - -function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null { - if (!pr) return null; - - if (pr.state === "open") { - return { - label: "PR open", - colorClass: "text-emerald-600 dark:text-emerald-300/90", - tooltip: `#${pr.number} PR open: ${pr.title}`, - url: pr.url, - }; - } - if (pr.state === "closed") { - return { - label: "PR closed", - colorClass: "text-zinc-500 dark:text-zinc-400/80", - tooltip: `#${pr.number} PR closed: ${pr.title}`, - url: pr.url, - }; - } - if (pr.state === "merged") { - return { - label: "PR merged", - colorClass: "text-violet-600 dark:text-violet-300/90", - tooltip: `#${pr.number} PR merged: ${pr.title}`, - url: pr.url, - }; - } - return null; -} - -function resolveThreadPr( - threadBranch: string | null, - gitStatus: GitStatusResult | null, -): ThreadPr | null { - if (threadBranch === null || gitStatus === null || gitStatus.branch !== threadBranch) { - return null; - } - - return gitStatus.pr ?? null; -} - interface SidebarThreadRowProps { thread: SidebarThreadSummary; projectCwd: string | null; @@ -996,6 +937,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const defaultThreadEnvMode = useSettings( (settings) => settings.defaultThreadEnvMode, ); + const projectGroupingSettings = useSettings((settings) => ({ + sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, + })); + const { updateSettings } = useUpdateSettings(); const router = useRouter(); const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); const toggleProject = useUiStateStore((state) => state.toggleProject); @@ -1073,58 +1019,27 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec useShallow( useMemo( () => (state: import("../store").AppState) => - selectSidebarThreadsForProjectRef( - state, - scopeProjectRef(project.environmentId, project.id), - ), - [project.environmentId, project.id], - ), - ), - ); - // For grouped projects that span multiple environments, also fetch - // threads from the other member project refs. - const otherMemberRefs = useMemo( - () => - project.memberProjectRefs.filter( - (ref) => ref.environmentId !== project.environmentId || ref.projectId !== project.id, - ), - [project.memberProjectRefs, project.environmentId, project.id], - ); - const otherMemberThreads = useStore( - useShallow( - useMemo( - () => - otherMemberRefs.length === 0 - ? () => [] as SidebarThreadSummary[] - : (state: import("../store").AppState) => - selectSidebarThreadsForProjectRefs(state, otherMemberRefs), - [otherMemberRefs], + selectSidebarThreadsForProjectRefs(state, project.memberProjectRefs), + [project.memberProjectRefs], ), ), ); - const allSidebarThreads = useMemo( - () => - otherMemberThreads.length === 0 ? sidebarThreads : [...sidebarThreads, ...otherMemberThreads], - [sidebarThreads, otherMemberThreads], - ); const sidebarThreadByKey = useMemo( () => new Map( - allSidebarThreads.map( + sidebarThreads.map( (thread) => [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, ), ), - [allSidebarThreads], + [sidebarThreads], ); // Keep a ref so callbacks can read the latest map without appearing in // dependency arrays (avoids invalidating every thread-row memo on each // thread-list change). const sidebarThreadByKeyRef = useRef(sidebarThreadByKey); sidebarThreadByKeyRef.current = sidebarThreadByKey; - // All threads from the representative + other member environments are - // already fetched into allSidebarThreads, so we can use them directly. - const projectThreads = allSidebarThreads; + const projectThreads = sidebarThreads; const projectExpanded = useUiStateStore( (state) => state.projectExpandedById[project.projectKey] ?? true, ); @@ -1141,9 +1056,43 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const [renamingThreadKey, setRenamingThreadKey] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); const [confirmingArchiveThreadKey, setConfirmingArchiveThreadKey] = useState(null); + const [projectRenameTarget, setProjectRenameTarget] = useState( + null, + ); + const [projectRenameTitle, setProjectRenameTitle] = useState(""); + const [projectGroupingTarget, setProjectGroupingTarget] = + useState(null); + const [projectGroupingSelection, setProjectGroupingSelection] = useState< + SidebarProjectGroupingMode | "inherit" + >("inherit"); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef(null); const confirmArchiveButtonRefs = useRef(new Map()); + const memberProjectByScopedKey = useMemo( + () => + new Map( + project.memberProjects.map((member) => [ + scopedProjectKey(scopeProjectRef(member.environmentId, member.id)), + member, + ]), + ), + [project.memberProjects], + ); + const memberThreadCountByPhysicalKey = useMemo(() => { + const counts = new Map( + project.memberProjects.map((member) => [member.physicalProjectKey, 0] as const), + ); + for (const thread of projectThreads) { + const member = memberProjectByScopedKey.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ); + if (!member) { + continue; + } + counts.set(member.physicalProjectKey, (counts.get(member.physicalProjectKey) ?? 0) + 1); + } + return counts; + }, [memberProjectByScopedKey, project.memberProjects, projectThreads]); const { projectStatus, visibleProjectThreads, orderedProjectThreadKeys } = useMemo(() => { const lastVisitedAtByThreadKey = new Map( @@ -1318,6 +1267,88 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec [suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef], ); + const openProjectRenameDialog = useCallback((member: SidebarProjectGroupMember) => { + setProjectRenameTarget(member); + setProjectRenameTitle(member.name); + }, []); + + const openProjectGroupingDialog = useCallback( + (member: SidebarProjectGroupMember) => { + const overrideKey = deriveProjectGroupingOverrideKey(member); + setProjectGroupingTarget(member); + setProjectGroupingSelection( + projectGroupingSettings.sidebarProjectGroupingOverrides?.[overrideKey] ?? "inherit", + ); + }, + [projectGroupingSettings.sidebarProjectGroupingOverrides], + ); + + const handleRemoveProject = useCallback( + async (member: SidebarProjectGroupMember) => { + const api = readLocalApi(); + if (!api) { + return; + } + + if ((memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0) > 0) { + toastManager.add({ + type: "warning", + title: "Project is not empty", + description: "Delete all threads in this project before removing it.", + }); + return; + } + + const message = [ + `Remove project "${member.name}"?`, + `Path: ${member.cwd}`, + ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []), + "This removes only this project entry.", + ].join("\n"); + const confirmed = await api.dialogs.confirm(message); + if (!confirmed) { + return; + } + + const memberProjectRef = scopeProjectRef(member.environmentId, member.id); + + try { + const projectDraftThread = getDraftThreadByProjectRef(memberProjectRef); + if (projectDraftThread) { + clearComposerDraftForThread(projectDraftThread.draftId); + } + clearProjectDraftThreadId(memberProjectRef); + const projectApi = readEnvironmentApi(member.environmentId); + if (!projectApi) { + throw new Error("Project API unavailable."); + } + await projectApi.orchestration.dispatchCommand({ + type: "project.delete", + commandId: newCommandId(), + projectId: member.id, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error removing project."; + console.error("Failed to remove project", { + projectId: member.id, + environmentId: member.environmentId, + error, + }); + toastManager.add({ + type: "error", + title: `Failed to remove "${member.name}"`, + description: message, + }); + } + }, + [ + clearComposerDraftForThread, + clearProjectDraftThreadId, + getDraftThreadByProjectRef, + memberThreadCountByPhysicalKey, + ], + ); + const handleProjectButtonContextMenu = useCallback( (event: React.MouseEvent) => { event.preventDefault(); @@ -1326,73 +1357,103 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const api = readLocalApi(); if (!api) return; + const actionHandlers = new Map Promise | void>(); + const makeLeaf = ( + action: "rename" | "grouping" | "copy-path" | "delete", + member: SidebarProjectGroupMember, + options?: { + destructive?: boolean; + disabled?: boolean; + }, + ): ContextMenuItem => { + const id = `${action}:${member.physicalProjectKey}`; + actionHandlers.set(id, () => { + switch (action) { + case "rename": + openProjectRenameDialog(member); + return; + case "grouping": + openProjectGroupingDialog(member); + return; + case "copy-path": + copyPathToClipboard(member.cwd, { path: member.cwd }); + return; + case "delete": + return handleRemoveProject(member); + } + }); + + return { + id, + label: formatProjectMemberActionLabel(member, project.groupedProjectCount), + ...(options?.destructive ? { destructive: true } : {}), + ...(options?.disabled ? { disabled: true } : {}), + }; + }; + + const buildTargetedItem = ( + action: "rename" | "grouping" | "copy-path" | "delete", + label: string, + options?: { + destructive?: boolean; + isDisabled?: (member: SidebarProjectGroupMember) => boolean; + }, + ): ContextMenuItem => { + if (project.memberProjects.length === 1) { + const singleMember = project.memberProjects[0]!; + return { + ...makeLeaf(action, singleMember, { + ...(options?.destructive ? { destructive: true } : {}), + ...(options?.isDisabled?.(singleMember) ? { disabled: true } : {}), + }), + label, + }; + } + + return { + id: `${action}:submenu`, + label, + children: project.memberProjects.map((member) => + makeLeaf(action, member, { + ...(options?.destructive ? { destructive: true } : {}), + ...(options?.isDisabled?.(member) ? { disabled: true } : {}), + }), + ), + }; + }; + const clicked = await api.contextMenu.show( [ - { id: "copy-path", label: "Copy Project Path" }, - { id: "delete", label: "Remove project", destructive: true }, + buildTargetedItem("rename", "Rename project"), + buildTargetedItem("grouping", "Project grouping…"), + buildTargetedItem("copy-path", "Copy Project Path"), + buildTargetedItem("delete", "Remove project", { + destructive: true, + isDisabled: (member) => + (memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0) > 0, + }), ], { x: event.clientX, y: event.clientY, }, ); - if (clicked === "copy-path") { - copyPathToClipboard(project.cwd, { path: project.cwd }); - return; - } - if (clicked !== "delete") return; - if (projectThreads.length > 0) { - toastManager.add({ - type: "warning", - title: "Project is not empty", - description: "Delete all threads in this project before removing it.", - }); + if (!clicked) { return; } - const confirmed = await api.dialogs.confirm(`Remove project "${project.name}"?`); - if (!confirmed) return; - - try { - const projectDraftThread = getDraftThreadByProjectRef( - scopeProjectRef(project.environmentId, project.id), - ); - if (projectDraftThread) { - clearComposerDraftForThread(projectDraftThread.draftId); - } - clearProjectDraftThreadId(scopeProjectRef(project.environmentId, project.id)); - const projectApi = readEnvironmentApi(project.environmentId); - if (!projectApi) { - throw new Error("Project API unavailable."); - } - await projectApi.orchestration.dispatchCommand({ - type: "project.delete", - commandId: newCommandId(), - projectId: project.id, - }); - } catch (error) { - const message = - error instanceof Error ? error.message : "Unknown error removing project."; - console.error("Failed to remove project", { projectId: project.id, error }); - toastManager.add({ - type: "error", - title: `Failed to remove "${project.name}"`, - description: message, - }); - } + await actionHandlers.get(clicked)?.(); })(); }, [ - clearComposerDraftForThread, - clearProjectDraftThreadId, copyPathToClipboard, - getDraftThreadByProjectRef, - project.cwd, - project.environmentId, - project.id, - project.name, - projectThreads.length, + handleRemoveProject, + memberThreadCountByPhysicalKey, + openProjectGroupingDialog, + openProjectRenameDialog, + project.groupedProjectCount, + project.memberProjects, suppressProjectClickForContextMenuRef, ], ); @@ -1503,10 +1564,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ], ); - const handleCreateThreadClick = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); + const createThreadForProjectMember = useCallback( + (member: SidebarProjectGroupMember) => { const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; const currentRouteTarget = resolveThreadRouteTarget(currentRouteParams); @@ -1522,12 +1581,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ? (draftStore.getDraftSession(currentRouteTarget.draftId) ?? null) : null; const seedContext = resolveSidebarNewThreadSeedContext({ - projectId: project.id, + projectId: member.id, defaultEnvMode: resolveSidebarNewThreadEnvMode({ defaultEnvMode: defaultThreadEnvMode, }), activeThread: - currentActiveThread && currentActiveThread.projectId === project.id + currentActiveThread && currentActiveThread.projectId === member.id ? { projectId: currentActiveThread.projectId, branch: currentActiveThread.branch, @@ -1535,7 +1594,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } : null, activeDraftThread: - currentActiveDraftThread && currentActiveDraftThread.projectId === project.id + currentActiveDraftThread && currentActiveDraftThread.projectId === member.id ? { projectId: currentActiveDraftThread.projectId, branch: currentActiveDraftThread.branch, @@ -1544,7 +1603,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } : null, }); - void handleNewThread(scopeProjectRef(project.environmentId, project.id), { + void handleNewThread(scopeProjectRef(member.environmentId, member.id), { ...(seedContext.branch !== undefined ? { branch: seedContext.branch } : {}), ...(seedContext.worktreePath !== undefined ? { worktreePath: seedContext.worktreePath } @@ -1552,7 +1611,47 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec envMode: seedContext.envMode, }); }, - [defaultThreadEnvMode, handleNewThread, project.environmentId, project.id, router], + [defaultThreadEnvMode, handleNewThread, router], + ); + + const handleCreateThreadClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + if (project.memberProjects.length === 1) { + createThreadForProjectMember(project.memberProjects[0]!); + return; + } + + void (async () => { + const api = readLocalApi(); + if (!api) { + return; + } + const clicked = await api.contextMenu.show( + project.memberProjects.map((member) => ({ + id: member.physicalProjectKey, + label: formatProjectMemberActionLabel(member, project.groupedProjectCount), + })), + { + x: event.clientX, + y: event.clientY, + }, + ); + if (!clicked) { + return; + } + const targetMember = project.memberProjects.find( + (member) => member.physicalProjectKey === clicked, + ); + if (!targetMember) { + return; + } + createThreadForProjectMember(targetMember); + })(); + }, + [createThreadForProjectMember, project.groupedProjectCount, project.memberProjects], ); const attemptArchiveThread = useCallback( @@ -1623,6 +1722,88 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec [], ); + const closeProjectRenameDialog = useCallback(() => { + setProjectRenameTarget(null); + setProjectRenameTitle(""); + }, []); + + const submitProjectRename = useCallback(async () => { + if (!projectRenameTarget) { + return; + } + + const trimmed = projectRenameTitle.trim(); + if (trimmed.length === 0) { + toastManager.add({ + type: "warning", + title: "Project title cannot be empty", + }); + return; + } + + if (trimmed === projectRenameTarget.name) { + closeProjectRenameDialog(); + return; + } + + const api = readEnvironmentApi(projectRenameTarget.environmentId); + if (!api) { + toastManager.add({ + type: "error", + title: "Failed to rename project", + description: "Project API unavailable.", + }); + return; + } + + try { + await api.orchestration.dispatchCommand({ + type: "project.meta.update", + commandId: newCommandId(), + projectId: projectRenameTarget.id, + title: trimmed, + }); + closeProjectRenameDialog(); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to rename project", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + }, [closeProjectRenameDialog, projectRenameTarget, projectRenameTitle]); + + const closeProjectGroupingDialog = useCallback(() => { + setProjectGroupingTarget(null); + setProjectGroupingSelection("inherit"); + }, []); + + const saveProjectGroupingPreference = useCallback(() => { + if (!projectGroupingTarget) { + return; + } + + const overrideKey = deriveProjectGroupingOverrideKey(projectGroupingTarget); + const nextOverrides = { + ...projectGroupingSettings.sidebarProjectGroupingOverrides, + }; + if (projectGroupingSelection === "inherit") { + delete nextOverrides[overrideKey]; + } else { + nextOverrides[overrideKey] = projectGroupingSelection; + } + updateSettings({ + sidebarProjectGroupingOverrides: nextOverrides, + }); + closeProjectGroupingDialog(); + }, [ + closeProjectGroupingDialog, + projectGroupingSelection, + projectGroupingSettings.sidebarProjectGroupingOverrides, + projectGroupingTarget, + updateSettings, + ]); + const handleThreadContextMenu = useCallback( async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { const api = readLocalApi(); @@ -1630,7 +1811,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const threadKey = scopedThreadKey(threadRef); const thread = sidebarThreadByKeyRef.current.get(threadKey) ?? null; if (!thread) return; - const threadWorkspacePath = thread.worktreePath ?? project.cwd ?? null; + const threadProject = memberProjectByScopedKey.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ); + const threadWorkspacePath = thread.worktreePath ?? threadProject?.cwd ?? project.cwd ?? null; const clicked = await api.contextMenu.show( [ { id: "rename", label: "Rename thread" }, @@ -1689,6 +1873,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec copyThreadIdToClipboard, deleteThread, markThreadUnread, + memberProjectByScopedKey, project.cwd, ], ); @@ -1732,8 +1917,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec /> )} - - {project.name} + + + {project.displayName} + + {project.groupedProjectCount > 1 ? ( + + {project.groupedProjectCount} projects + + ) : null} {/* Environment badge – visible by default, crossfades with the @@ -1766,7 +1958,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
+ + + + + + { + if (!open) { + closeProjectGroupingDialog(); + } + }} + > + + + Project grouping + + {projectGroupingTarget + ? `Choose how ${projectGroupingTarget.cwd} should be grouped in the sidebar.` + : "Choose how this project should be grouped in the sidebar."} + + + +
+ Grouping rule + +
+

+ {projectGroupingSelection === "inherit" + ? projectGroupingModeDescription(projectGroupingSettings.sidebarProjectGroupingMode) + : projectGroupingModeDescription(projectGroupingSelection)} +

+
+ + + + +
+
); }); @@ -1853,13 +2163,17 @@ type SortableProjectHandleProps = Pick< function ProjectSortMenu({ projectSortOrder, threadSortOrder, + projectGroupingMode, onProjectSortOrderChange, onThreadSortOrderChange, + onProjectGroupingModeChange, }: { projectSortOrder: SidebarProjectSortOrder; threadSortOrder: SidebarThreadSortOrder; + projectGroupingMode: SidebarProjectGroupingMode; onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; + onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void; }) { return ( @@ -1912,6 +2226,30 @@ function ProjectSortMenu({ ))} + + +
+ Group projects +
+ { + if (value === "repository" || value === "repository_path" || value === "separate") { + onProjectGroupingModeChange(value); + } + }} + > + {( + Object.entries(PROJECT_GROUPING_MODE_LABELS) as Array< + [SidebarProjectGroupingMode, string] + > + ).map(([value, label]) => ( + + {label} + + ))} + +
); @@ -2029,6 +2367,7 @@ interface SidebarProjectsContentProps { handleDesktopUpdateButtonClick: () => void; projectSortOrder: SidebarProjectSortOrder; threadSortOrder: SidebarThreadSortOrder; + projectGroupingMode: SidebarProjectGroupingMode; updateSettings: ReturnType["updateSettings"]; openAddProject: () => void; isManualProjectSorting: boolean; @@ -2068,6 +2407,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( handleDesktopUpdateButtonClick, projectSortOrder, threadSortOrder, + projectGroupingMode, updateSettings, openAddProject, isManualProjectSorting, @@ -2108,6 +2448,12 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( }, [updateSettings], ); + const handleProjectGroupingModeChange = useCallback( + (groupingMode: SidebarProjectGroupingMode) => { + updateSettings({ sidebarProjectGroupingMode: groupingMode }); + }, + [updateSettings], + ); return ( @@ -2166,8 +2512,10 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( s.sidebarThreadSortOrder); const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder); + const sidebarProjectGroupingMode = useSettings((s) => s.sidebarProjectGroupingMode); + const projectGroupingSettings = useSettings((settings) => ({ + sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, + })); const { updateSettings } = useUpdateSettings(); const { handleNewThread } = useNewThreadHandler(); const { archiveThread, deleteThread } = useThreadActions(); @@ -2307,92 +2660,41 @@ export default function Sidebar() { const primaryEnvironmentId = usePrimaryEnvironmentId(); const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); - const orderedProjects = useMemo(() => { - return orderItemsByPreferredIds({ - items: projects, - preferredIds: projectOrder, - getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), - }); - }, [projectOrder, projects]); - // Build a mapping from physical project key → logical project key for // cross-environment grouping. Projects that share a repositoryIdentity // canonicalKey are treated as one logical project in the sidebar. const physicalToLogicalKey = useMemo(() => { - const mapping = new Map(); - for (const project of orderedProjects) { - const physicalKey = scopedProjectKey(scopeProjectRef(project.environmentId, project.id)); - mapping.set(physicalKey, deriveLogicalProjectKey(project)); - } - return mapping; - }, [orderedProjects]); + return buildPhysicalToLogicalProjectKeyMap({ + projects, + settings: projectGroupingSettings, + }); + }, [projectGroupingSettings, projects]); + const projectPhysicalKeyByScopedRef = useMemo( + () => + new Map( + projects.map((project) => [ + scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + derivePhysicalProjectKey(project), + ]), + ), + [projects], + ); const sidebarProjects = useMemo(() => { - // Group projects by logical key while preserving insertion order from - // orderedProjects. - const groupedMembers = new Map(); - for (const project of orderedProjects) { - const logicalKey = deriveLogicalProjectKey(project); - const existing = groupedMembers.get(logicalKey); - if (existing) { - existing.push(project); - } else { - groupedMembers.set(logicalKey, [project]); - } - } - - const result: SidebarProjectSnapshot[] = []; - const seen = new Set(); - for (const project of orderedProjects) { - const logicalKey = deriveLogicalProjectKey(project); - if (seen.has(logicalKey)) continue; - seen.add(logicalKey); - - const members = groupedMembers.get(logicalKey)!; - // Prefer the primary environment's project as the representative. - const representative: Project | undefined = - (primaryEnvironmentId - ? members.find((p) => p.environmentId === primaryEnvironmentId) - : undefined) ?? members[0]; - if (!representative) continue; - const hasLocal = - primaryEnvironmentId !== null && - members.some((p) => p.environmentId === primaryEnvironmentId); - const hasRemote = - primaryEnvironmentId !== null - ? members.some((p) => p.environmentId !== primaryEnvironmentId) - : false; - - const refs = members.map((p) => scopeProjectRef(p.environmentId, p.id)); - const remoteLabels = members - .filter((p) => primaryEnvironmentId !== null && p.environmentId !== primaryEnvironmentId) - .map((p) => { - const rt = savedEnvironmentRuntimeById[p.environmentId]; - const saved = savedEnvironmentRegistry[p.environmentId]; - return rt?.descriptor?.label ?? saved?.label ?? p.environmentId; - }); - const snapshot: SidebarProjectSnapshot = { - id: representative.id, - environmentId: representative.environmentId, - name: representative.name, - cwd: representative.cwd, - repositoryIdentity: representative.repositoryIdentity ?? null, - defaultModelSelection: representative.defaultModelSelection, - createdAt: representative.createdAt, - updatedAt: representative.updatedAt, - scripts: representative.scripts, - projectKey: logicalKey, - environmentPresence: - hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", - memberProjectRefs: refs, - remoteEnvironmentLabels: remoteLabels, - }; - result.push(snapshot); - } - return result; + return buildSidebarProjectSnapshots({ + projects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => { + const rt = savedEnvironmentRuntimeById[environmentId]; + const saved = savedEnvironmentRegistry[environmentId]; + return rt?.descriptor?.label ?? saved?.label ?? null; + }, + }); }, [ - orderedProjects, + projectGroupingSettings, primaryEnvironmentId, + projects, savedEnvironmentRegistry, savedEnvironmentRuntimeById, ]); @@ -2419,18 +2721,22 @@ export default function Sidebar() { } const activeThread = sidebarThreadByKey.get(routeThreadKey); if (!activeThread) return null; - const physicalKey = scopedProjectKey( - scopeProjectRef(activeThread.environmentId, activeThread.projectId), - ); + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)); return physicalToLogicalKey.get(physicalKey) ?? physicalKey; - }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey]); + }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); // Group threads by logical project key so all threads from grouped projects // are displayed together. const threadsByProjectKey = useMemo(() => { const next = new Map(); for (const thread of sidebarThreads) { - const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; const existing = next.get(logicalKey); if (existing) { @@ -2440,7 +2746,7 @@ export default function Sidebar() { } } return next; - }, [sidebarThreads, physicalToLogicalKey]); + }, [sidebarThreads, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); const getCurrentSidebarShortcutContext = useCallback( () => ({ terminalFocus: isTerminalFocused(), @@ -2507,9 +2813,7 @@ export default function Sidebar() { const activeProject = sidebarProjects.find((project) => project.projectKey === active.id); const overProject = sidebarProjects.find((project) => project.projectKey === over.id); if (!activeProject || !overProject) return; - const activeMemberKeys = activeProject.memberProjectRefs.map(scopedProjectKey); - const overMemberKeys = overProject.memberProjectRefs.map(scopedProjectKey); - reorderProjects(activeMemberKeys, overMemberKeys); + reorderProjects([activeProject.projectKey], [overProject.projectKey]); }, [sidebarProjectSortOrder, reorderProjects, sidebarProjects], ); @@ -2552,12 +2856,19 @@ export default function Sidebar() { [sidebarThreads], ); const sortedProjects = useMemo(() => { - const sortableProjects = sidebarProjects.map((project) => ({ - ...project, - id: project.projectKey, - })); + const sortableProjects = orderItemsByPreferredIds({ + items: sidebarProjects.map((project) => ({ + ...project, + id: project.projectKey, + })), + preferredIds: projectOrder, + getId: (project) => project.id, + }); const sortableThreads = visibleThreads.map((thread) => { - const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); return { ...thread, projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId, @@ -2574,6 +2885,7 @@ export default function Sidebar() { }, [ sidebarProjectSortOrder, physicalToLogicalKey, + projectPhysicalKeyByScopedRef, sidebarProjectByKey, sidebarProjects, visibleThreads, @@ -2978,6 +3290,7 @@ export default function Sidebar() { handleDesktopUpdateButtonClick={handleDesktopUpdateButtonClick} projectSortOrder={sidebarProjectSortOrder} threadSortOrder={sidebarThreadSortOrder} + projectGroupingMode={sidebarProjectGroupingMode} updateSettings={updateSettings} openAddProject={openAddProjectCommandPalette} isManualProjectSorting={isManualProjectSorting} diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx new file mode 100644 index 00000000..497e0f88 --- /dev/null +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -0,0 +1,241 @@ +import { scopeProjectRef, scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; +import type { GitStatusResult } from "@t3tools/contracts"; +import { CloudIcon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; +import { useMemo } from "react"; +import { usePrimaryEnvironmentId } from "../environments/primary"; +import { + useSavedEnvironmentRegistryStore, + useSavedEnvironmentRuntimeStore, +} from "../environments/runtime"; +import { useGitStatus } from "../lib/gitStatusState"; +import { type AppState, selectProjectByRef, useStore } from "../store"; +import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; +import { useUiStateStore } from "../uiStateStore"; +import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic"; +import type { SidebarThreadSummary } from "../types"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +export interface PrStatusIndicator { + label: "PR open" | "PR closed" | "PR merged"; + colorClass: string; + tooltip: string; + url: string; +} + +export interface TerminalStatusIndicator { + label: "Terminal process running"; + colorClass: string; + pulse: boolean; +} + +export type ThreadPr = GitStatusResult["pr"]; + +export function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null { + if (!pr) return null; + + if (pr.state === "open") { + return { + label: "PR open", + colorClass: "text-emerald-600 dark:text-emerald-300/90", + tooltip: `#${pr.number} PR open: ${pr.title}`, + url: pr.url, + }; + } + if (pr.state === "closed") { + return { + label: "PR closed", + colorClass: "text-zinc-500 dark:text-zinc-400/80", + tooltip: `#${pr.number} PR closed: ${pr.title}`, + url: pr.url, + }; + } + if (pr.state === "merged") { + return { + label: "PR merged", + colorClass: "text-violet-600 dark:text-violet-300/90", + tooltip: `#${pr.number} PR merged: ${pr.title}`, + url: pr.url, + }; + } + return null; +} + +export function resolveThreadPr( + threadBranch: string | null, + gitStatus: GitStatusResult | null, +): ThreadPr | null { + if (threadBranch === null || gitStatus === null || gitStatus.branch !== threadBranch) { + return null; + } + + return gitStatus.pr ?? null; +} + +export function terminalStatusFromRunningIds( + runningTerminalIds: string[], +): TerminalStatusIndicator | null { + if (runningTerminalIds.length === 0) { + return null; + } + return { + label: "Terminal process running", + colorClass: "text-teal-600 dark:text-teal-300/90", + pulse: true, + }; +} + +export function ThreadStatusLabel({ + status, + compact = false, +}: { + status: ThreadStatusPill; + compact?: boolean; +}) { + if (compact) { + return ( + + + {status.label} + + ); + } + + return ( + + + {status.label} + + ); +} + +/** + * Non-interactive leading status icons for a thread row in compact contexts + * like the command palette. Shows the PR state icon (if present) and the + * thread status dot, matching the sidebar's leading indicators. + */ +export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummary }) { + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const lastVisitedAt = useUiStateStore( + (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)], + ); + const threadProjectCwd = useStore( + useMemo( + () => (state: AppState) => + selectProjectByRef(state, scopeProjectRef(thread.environmentId, thread.projectId))?.cwd ?? + null, + [thread.environmentId, thread.projectId], + ), + ); + const gitCwd = thread.worktreePath ?? threadProjectCwd; + const gitStatus = useGitStatus({ + environmentId: thread.environmentId, + cwd: thread.branch != null ? gitCwd : null, + }); + const pr = resolveThreadPr(thread.branch, gitStatus.data); + const prStatus = prStatusIndicator(pr); + const threadStatus = resolveThreadStatusPill({ + thread: { + ...thread, + lastVisitedAt, + }, + }); + + if (!prStatus && !threadStatus) { + return null; + } + + return ( + + {prStatus ? ( + + + } + > + + + {prStatus.tooltip} + + ) : null} + {threadStatus ? : null} + + ); +} + +/** + * Non-interactive trailing status icons for a thread row in compact contexts + * like the command palette. Shows a terminal-running indicator and a remote + * environment indicator, matching the sidebar's trailing indicators. + */ +export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSummary }) { + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const runningTerminalIds = useTerminalStateStore( + (state) => + selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, + ); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const isRemoteThread = + primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; + const remoteEnvLabel = useSavedEnvironmentRuntimeStore( + (state) => state.byId[thread.environmentId]?.descriptor?.label ?? null, + ); + const remoteEnvSavedLabel = useSavedEnvironmentRegistryStore( + (state) => state.byId[thread.environmentId]?.label ?? null, + ); + const threadEnvironmentLabel = isRemoteThread + ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "Remote") + : null; + const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); + + if (!terminalStatus && !isRemoteThread) { + return null; + } + + return ( + + {terminalStatus ? ( + + + + ) : null} + {isRemoteThread ? ( + + + } + > + + + {threadEnvironmentLabel} + + ) : null} + + ); +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 82018ed1..29bc06c2 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -54,6 +54,7 @@ import { insertInlineTerminalContextPlaceholder, removeInlineTerminalContextPlaceholder, } from "../../lib/terminalContext"; +import { createModelSelection } from "../../modelSelectionUtils"; import { shouldUseCompactComposerPrimaryActions, shouldUseCompactComposerFooter, @@ -74,7 +75,6 @@ import { renderProviderTraitsMenuContent, renderProviderTraitsPicker, } from "./composerProviderRegistry"; -import { getProviderModelOptions } from "../../modelSelectionUtils"; import { ContextWindowMeter } from "./ContextWindowMeter"; import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; import { basenameOfPath } from "../../vscode-icons"; @@ -130,7 +130,6 @@ const runtimeModeConfig: Record< const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[]; const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; -import { createModelSelection } from "../../modelSelectionUtils"; const extendReplacementRangeForTrailingSpace = ( text: string, @@ -595,9 +594,7 @@ export const ChatComposer = memo( createModelSelection({ provider: selectedProvider, model: selectedModel, - ...(selectedModelOptionsForDispatch !== undefined - ? { options: selectedModelOptionsForDispatch } - : {}), + ...(selectedModelOptionsForDispatch ? { options: selectedModelOptionsForDispatch } : {}), }), [selectedModel, selectedModelOptionsForDispatch, selectedProvider], ); @@ -614,7 +611,7 @@ export const ChatComposer = memo( [providerStatuses], ); const selectedModelForPickerWithCustomFallback = useMemo(() => { - const currentOptions = modelOptionsByProvider[selectedProvider] ?? []; + const currentOptions = modelOptionsByProvider[selectedProvider]; return currentOptions.some((option) => option.slug === selectedModelForPicker) ? selectedModelForPicker : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); @@ -624,7 +621,7 @@ export const ChatComposer = memo( AVAILABLE_PROVIDER_OPTIONS.filter( (option) => lockedProvider === null || option.value === lockedProvider, ).flatMap((option) => - (modelOptionsByProvider[option.value] ?? []).map(({ slug, name }) => ({ + modelOptionsByProvider[option.value].map(({ slug, name }) => ({ provider: option.value, providerLabel: option.label, slug, @@ -904,7 +901,7 @@ export const ChatComposer = memo( ...(routeKind === "draft" && draftId ? { draftId } : {}), model: selectedModel, models: selectedProviderModels, - modelOptions: getProviderModelOptions(selectedProvider, composerModelOptions), + modelOptions: composerModelOptions?.[selectedProvider], prompt, onPromptChange: setPromptFromTraits, }); @@ -914,7 +911,7 @@ export const ChatComposer = memo( ...(routeKind === "draft" && draftId ? { draftId } : {}), model: selectedModel, models: selectedProviderModels, - modelOptions: getProviderModelOptions(selectedProvider, composerModelOptions), + modelOptions: composerModelOptions?.[selectedProvider], prompt, onPromptChange: setPromptFromTraits, }); diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.tsx index 678815bd..0eb5c8a1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.tsx @@ -157,36 +157,4 @@ describe("MessagesTimeline", () => { await screen.unmount(); } }); - - it("does not render command output detail in collapsed command rows", async () => { - const screen = await render( - , - ); - - try { - await expect.element(page.getByText("Bash", { exact: true })).toBeVisible(); - await expect.element(page.getByText("src/index.ts")).not.toBeInTheDocument(); - await expect.element(page.getByText("package.json")).not.toBeInTheDocument(); - } finally { - await screen.unmount(); - } - }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx new file mode 100644 index 00000000..a8a53831 --- /dev/null +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -0,0 +1,189 @@ +import { EnvironmentId, MessageId } from "@t3tools/contracts"; +import { createRef } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import type { LegendListRef } from "@legendapp/list/react"; + +vi.mock("@legendapp/list/react", async () => { + const React = await import("react"); + + const LegendList = React.forwardRef(function MockLegendList( + props: { + data: Array<{ id: string }>; + keyExtractor: (item: { id: string }) => string; + renderItem: (args: { item: { id: string } }) => React.ReactNode; + ListHeaderComponent?: React.ReactNode; + ListFooterComponent?: React.ReactNode; + }, + _ref: React.ForwardedRef, + ) { + return ( +
+ {props.ListHeaderComponent} + {props.data.map((item) => ( +
{props.renderItem({ item })}
+ ))} + {props.ListFooterComponent} +
+ ); + }); + + return { LegendList }; +}); + +function matchMedia() { + return { + matches: false, + addEventListener: () => {}, + removeEventListener: () => {}, + }; +} + +beforeAll(() => { + const classList = { + add: () => {}, + remove: () => {}, + toggle: () => {}, + contains: () => false, + }; + + vi.stubGlobal("localStorage", { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + clear: () => {}, + }); + vi.stubGlobal("window", { + matchMedia, + addEventListener: () => {}, + removeEventListener: () => {}, + requestAnimationFrame: (callback: FrameRequestCallback) => { + callback(0); + return 0; + }, + cancelAnimationFrame: () => {}, + desktopBridge: undefined, + }); + vi.stubGlobal("document", { + documentElement: { + classList, + offsetHeight: 0, + }, + }); +}); + +const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); + +function buildProps() { + return { + isWorking: false, + activeTurnInProgress: false, + activeTurnId: null, + activeTurnStartedAt: null, + listRef: createRef(), + completionDividerBeforeEntryId: null, + completionSummary: null, + turnDiffSummaryByAssistantMessageId: new Map(), + routeThreadKey: "environment-local:thread-1", + onOpenTurnDiff: () => {}, + revertTurnCountByUserMessageId: new Map(), + onRevertUserMessage: () => {}, + isRevertingCheckpoint: false, + onImageExpand: () => {}, + activeThreadEnvironmentId: ACTIVE_THREAD_ENVIRONMENT_ID, + markdownCwd: undefined, + resolvedTheme: "light" as const, + timestampFormat: "locale" as const, + workspaceRoot: undefined, + onIsAtEndChange: () => {}, + }; +} + +describe("MessagesTimeline", () => { + it("renders inline terminal labels with the composer chip UI", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + ", + "- Terminal 1 lines 1-5:", + " 1 | julius@mac effect-http-ws-cli % bun i", + " 2 | bun install v1.3.9 (cf6cdbbb)", + "", + ].join("\n"), + createdAt: "2026-03-17T19:12:28.000Z", + streaming: false, + }, + }, + ]} + />, + ); + + expect(markup).toContain("Terminal 1 lines 1-5"); + expect(markup).toContain("lucide-terminal"); + expect(markup).toContain("yoo what's "); + }, 20_000); + + it("renders context compaction entries in the normal work log", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Context compacted"); + expect(markup).toContain("Work log"); + }); + + it("formats changed file paths from the workspace root", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("t3code/apps/web/src/session-logic.ts"); + expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index aad865c8..91ab3cc9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -60,6 +60,7 @@ import { formatInlineTerminalContextLabel, textContainsInlineTerminalContextLabels, } from "./userMessageTerminalContexts"; +import { formatWorkspaceRelativePath } from "../../filePathDisplay"; // --------------------------------------------------------------------------- // Context — shared state consumed by every row component via useContext. @@ -528,6 +529,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ }: { groupedEntries: Extract["groupedEntries"]; }) { + const { workspaceRoot } = use(TimelineRowCtx); const [isExpanded, setIsExpanded] = useState(false); const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; const visibleEntries = @@ -559,7 +561,11 @@ const WorkGroupSection = memo(function WorkGroupSection({ )}
{visibleEntries.map((workEntry) => ( - + ))}
@@ -861,6 +867,7 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { function workEntryPreview( workEntry: Pick, + workspaceRoot: string | undefined, ) { if (workEntry.command) return workEntry.command; if (workEntry.itemType === "command_execution") return null; @@ -868,9 +875,10 @@ function workEntryPreview( if ((workEntry.changedFiles?.length ?? 0) === 0) return null; const [firstPath] = workEntry.changedFiles ?? []; if (!firstPath) return null; + const displayPath = formatWorkspaceRelativePath(firstPath, workspaceRoot); return workEntry.changedFiles!.length === 1 - ? firstPath - : `${firstPath} +${workEntry.changedFiles!.length - 1} more`; + ? displayPath + : `${displayPath} +${workEntry.changedFiles!.length - 1} more`; } function workEntryRawCommand( @@ -925,12 +933,13 @@ function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string { const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workEntry: TimelineWorkEntry; + workspaceRoot: string | undefined; }) { - const { workEntry } = props; + const { workEntry, workspaceRoot } = props; const iconConfig = workToneIcon(workEntry.tone); const EntryIcon = workEntryIcon(workEntry); const heading = toolWorkEntryHeading(workEntry); - const preview = workEntryPreview(workEntry); + const preview = workEntryPreview(workEntry, workspaceRoot); const rawCommand = workEntryRawCommand(workEntry); const displayText = preview ? `${heading} - ${preview}` : heading; const hasChangedFiles = (workEntry.changedFiles?.length ?? 0) > 0; @@ -989,15 +998,18 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
{hasChangedFiles && !previewIsChangedFiles && (
- {workEntry.changedFiles?.slice(0, 4).map((filePath) => ( - - {filePath} - - ))} + {workEntry.changedFiles?.slice(0, 4).map((filePath) => { + const displayPath = formatWorkspaceRelativePath(filePath, workspaceRoot); + return ( + + {displayPath} + + ); + })} {(workEntry.changedFiles?.length ?? 0) > 4 && ( +{(workEntry.changedFiles?.length ?? 0) - 4} diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index abedcd6e..802b4b5d 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -24,6 +24,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ status: "ready", auth: { status: "authenticated" }, checkedAt: new Date().toISOString(), + quotaSnapshots: [], slashCommands: [], skills: [], models: [ @@ -61,6 +62,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ status: "ready", auth: { status: "authenticated" }, checkedAt: new Date().toISOString(), + quotaSnapshots: [], slashCommands: [], skills: [], models: [ @@ -124,6 +126,7 @@ function buildCodexProvider(models: ServerProvider["models"]): ServerProvider { auth: { status: "authenticated" }, checkedAt: new Date().toISOString(), models, + quotaSnapshots: [], slashCommands: [], skills: [], }; diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index e2c95fbe..93e8b847 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -67,11 +67,17 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { activeProviderIconClassName?: string; compact?: boolean; disabled?: boolean; + allowedProviders?: ReadonlyArray; triggerVariant?: VariantProps["variant"]; triggerClassName?: string; onProviderModelChange: (provider: ProviderKind, model: string) => void; }) { const [isMenuOpen, setIsMenuOpen] = useState(false); + const allowedProviders = + props.allowedProviders ?? AVAILABLE_PROVIDER_OPTIONS.map((option) => option.value); + const availableProviderOptions = AVAILABLE_PROVIDER_OPTIONS.filter((option) => + allowedProviders.includes(option.value), + ); const activeProvider = props.lockedProvider ?? props.provider; const selectedProviderOptions = props.modelOptionsByProvider[activeProvider]; const selectedModelLabel = @@ -221,7 +227,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { ) : ( <> - {AVAILABLE_PROVIDER_OPTIONS.map((option) => { + {availableProviderOptions.map((option) => { const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; const liveProvider = props.providers ? getProviderSnapshot(props.providers, option.value) diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index 686a36b6..6d88bedd 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -44,6 +44,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ status: "ready", auth: { status: "authenticated" }, checkedAt: "2026-01-01T00:00:00.000Z", + quotaSnapshots: [], slashCommands: [], skills: [], models: [ @@ -72,6 +73,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ status: "ready", auth: { status: "authenticated" }, checkedAt: "2026-01-01T00:00:00.000Z", + quotaSnapshots: [], slashCommands: [], skills: [], models: [ @@ -500,4 +502,21 @@ describe("TraitsPicker (Codex)", () => { options: { fastMode: true }, }); }); + + it("persists sticky codex reasoning effort changes", async () => { + await using _ = await mountCodexPicker({ + options: { reasoningEffort: "high", fastMode: false }, + }); + + await page.getByRole("button").click(); + await page.getByRole("menuitemradio", { name: "Extra High" }).click(); + + expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.codex).toMatchObject({ + provider: "codex", + options: { + reasoningEffort: "xhigh", + fastMode: false, + }, + }); + }); }); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 5faa9106..94e3bf95 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -1,6 +1,7 @@ import { type ClaudeModelOptions, type CodexModelOptions, + type CopilotModelOptions, type ProviderKind, type ProviderModelOptions, type ScopedThreadRef, @@ -50,9 +51,12 @@ function getRawEffort( provider: ProviderKind, modelOptions: ProviderOptions | null | undefined, ): string | null { - if (provider === "codex" || provider === "copilot") { + if (provider === "codex") { return trimOrNull((modelOptions as CodexModelOptions | undefined)?.reasoningEffort); } + if (provider === "copilot") { + return trimOrNull((modelOptions as CopilotModelOptions | undefined)?.reasoningEffort); + } return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.effort); } @@ -71,9 +75,15 @@ function buildNextOptions( modelOptions: ProviderOptions | null | undefined, patch: Record, ): ProviderOptions { - if (provider === "codex" || provider === "copilot") { + if (provider === "codex") { return { ...(modelOptions as CodexModelOptions | undefined), ...patch } as CodexModelOptions; } + if (provider === "copilot") { + return { + ...(modelOptions as CopilotModelOptions | undefined), + ...patch, + } as CopilotModelOptions; + } return { ...(modelOptions as ClaudeModelOptions | undefined), ...patch } as ClaudeModelOptions; } @@ -210,7 +220,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ const stripped = prompt.replace(/^Ultrathink:\s*/i, ""); onPromptChange(stripped); } - const effortKey = provider === "claudeAgent" ? "effort" : "reasoningEffort"; + const effortKey = provider === "claudeAgent" ? "effort" : ("reasoningEffort" as const); updateModelOptions( buildNextOptions(provider, modelOptions, { [effortKey]: nextOption.value }), ); @@ -372,7 +382,7 @@ export const TraitsPicker = memo(function TraitsPicker({ .filter(Boolean) .join(" · "); - const isCodexStyle = provider === "codex" || provider === "copilot"; + const isCodexStyle = provider === "codex"; return ( = [ }, ]; +const COPILOT_MODELS: ReadonlyArray = [ + { + slug: "claude-sonnet-4", + name: "Claude Sonnet 4", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + ], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, +]; + const CLAUDE_MODELS: ReadonlyArray = [ { slug: "claude-opus-4-6", @@ -153,6 +171,28 @@ describe("getComposerProviderState", () => { }); }); + it("normalizes copilot dispatch options using reasoningEffort", () => { + const state = getComposerProviderState({ + provider: "copilot", + model: "claude-sonnet-4", + models: COPILOT_MODELS, + prompt: "", + modelOptions: { + copilot: { + reasoningEffort: "medium", + }, + }, + }); + + expect(state).toEqual({ + provider: "copilot", + promptEffort: "medium", + modelOptionsForDispatch: { + reasoningEffort: "medium", + }, + }); + }); + it("preserves codex fast mode when it is the only active option", () => { const state = getComposerProviderState({ provider: "codex", diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index ba195b7e..82f20aaf 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -11,6 +11,7 @@ import { import { useQueryClient } from "@tanstack/react-query"; import { type ReactNode, useCallback, useMemo, useRef, useState } from "react"; import { + GIT_TEXT_GENERATION_PROVIDERS, PROVIDER_DISPLAY_NAMES, type DesktopUpdateChannel, type ScopedThreadRef, @@ -105,7 +106,8 @@ type InstallProviderSettings = { title: string; binaryPlaceholder: string; binaryDescription: ReactNode; - homePathKey?: "codexHomePath"; + homePathKey?: "codexHomePath" | "copilotHomePath"; + homePathLabel?: string; homePlaceholder?: string; homeDescription?: ReactNode; }; @@ -117,24 +119,26 @@ const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ binaryPlaceholder: "Codex binary path", binaryDescription: "Path to the Codex binary", homePathKey: "codexHomePath", + homePathLabel: "CODEX_HOME path", homePlaceholder: "CODEX_HOME", homeDescription: "Optional custom Codex home and config directory.", }, - { - provider: "copilot", - title: "GitHub Copilot", - binaryPlaceholder: "GitHub Copilot CLI path (optional)", - binaryDescription: - "Optional path to a Copilot CLI binary. Leave blank to use the bundled SDK CLI.", - homePlaceholder: "~/.copilot", - homeDescription: "Optional Copilot home/config directory used for auth and mcp-config.json.", - }, { provider: "claudeAgent", title: "Claude", binaryPlaceholder: "Claude binary path", binaryDescription: "Path to the Claude binary", }, + { + provider: "copilot", + title: "GitHub Copilot", + binaryPlaceholder: "Copilot CLI path", + binaryDescription: "Optional path to the GitHub Copilot CLI binary", + homePathKey: "copilotHomePath", + homePathLabel: "COPILOT_HOME path", + homePlaceholder: "COPILOT_HOME", + homeDescription: "Optional custom GitHub Copilot home and config directory.", + }, ] as const; const PROVIDER_STATUS_STYLES = { @@ -527,7 +531,8 @@ export function GeneralSettingsPanel() { claudeAgent: Boolean( settings.providers.claudeAgent.binaryPath !== DEFAULT_UNIFIED_SETTINGS.providers.claudeAgent.binaryPath || - settings.providers.claudeAgent.customModels.length > 0, + settings.providers.claudeAgent.customModels.length > 0 || + settings.providers.claudeAgent.launchArgs !== "", ), }); const [customModelInputByProvider, setCustomModelInputByProvider] = useState< @@ -562,7 +567,6 @@ export function GeneralSettingsPanel() { const availableEditors = useServerAvailableEditors(); const observability = useServerObservability(); const serverProviders = useServerProviders(); - const codexHomePath = settings.providers.codex.homePath; const logsDirectoryPath = observability?.logsDirectoryPath ?? null; const diagnosticsDescription = (() => { const exports: string[] = []; @@ -576,7 +580,11 @@ export function GeneralSettingsPanel() { return exports.length > 0 ? `${mode}. OTLP exporting ${exports.join(" and ")}.` : `${mode}.`; })(); - const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); + const textGenerationModelSelection = resolveAppModelSelectionState( + settings, + serverProviders, + GIT_TEXT_GENERATION_PROVIDERS, + ); const textGenProvider = textGenerationModelSelection.provider; const textGenModel = textGenerationModelSelection.model; const textGenModelOptions = textGenerationModelSelection.options; @@ -749,8 +757,15 @@ export function GeneralSettingsPanel() { binaryPlaceholder: providerSettings.binaryPlaceholder, binaryDescription: providerSettings.binaryDescription, homePathKey: providerSettings.homePathKey, + homePathLabel: providerSettings.homePathLabel, homePlaceholder: providerSettings.homePlaceholder, homeDescription: providerSettings.homeDescription, + homePathValue: + providerSettings.provider === "codex" + ? settings.providers.codex.homePath + : providerSettings.provider === "copilot" + ? settings.providers.copilot.homePath + : undefined, binaryPathValue: providerConfig.binaryPath, isDirty: !Equal.equals(providerConfig, defaultProviderConfig), liveProvider, @@ -1044,6 +1059,7 @@ export function GeneralSettingsPanel() { lockedProvider={null} providers={serverProviders} modelOptionsByProvider={gitModelOptionsByProvider} + allowedProviders={GIT_TEXT_GENERATION_PROVIDERS} triggerVariant="outline" triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" onProviderModelChange={(provider, model) => { @@ -1051,9 +1067,10 @@ export function GeneralSettingsPanel() { textGenerationModelSelection: resolveAppModelSelectionState( { ...settings, - textGenerationModelSelection: { provider, model }, + textGenerationModelSelection: createModelSelection({ provider, model }), }, serverProviders, + GIT_TEXT_GENERATION_PROVIDERS, ), }); }} @@ -1079,10 +1096,11 @@ export function GeneralSettingsPanel() { textGenerationModelSelection: createModelSelection({ provider: textGenProvider, model: textGenModel, - ...(nextOptions !== undefined ? { options: nextOptions } : {}), + ...(nextOptions ? { options: nextOptions } : {}), }), }, serverProviders, + GIT_TEXT_GENERATION_PROVIDERS, ), }); }} @@ -1266,18 +1284,18 @@ export function GeneralSettingsPanel() { className="block" > - CODEX_HOME path + {providerCard.homePathLabel} updateSettings({ providers: { ...settings.providers, - codex: { - ...settings.providers.codex, + [providerCard.provider]: { + ...settings.providers[providerCard.provider], homePath: event.target.value, }, }, @@ -1295,6 +1313,37 @@ export function GeneralSettingsPanel() {
) : null} + {providerCard.provider === "claudeAgent" ? ( +
+ +
+ ) : null} +
Models
@@ -1407,9 +1456,7 @@ export function GeneralSettingsPanel() { placeholder={ providerCard.provider === "codex" ? "gpt-6.7-codex-ultra-preview" - : providerCard.provider === "copilot" - ? "gpt-5" - : "claude-sonnet-5-0" + : "claude-sonnet-5-0" } spellCheck={false} /> diff --git a/apps/web/src/components/timelineHeight.test.ts b/apps/web/src/components/timelineHeight.test.ts deleted file mode 100644 index 35c90d01..00000000 --- a/apps/web/src/components/timelineHeight.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { appendTerminalContextsToPrompt } from "../lib/terminalContext"; -import { buildInlineTerminalContextText } from "./chat/userMessageTerminalContexts"; -import { estimateTimelineMessageHeight } from "./timelineHeight"; - -describe("estimateTimelineMessageHeight", () => { - it("uses assistant sizing rules for assistant messages", () => { - expect( - estimateTimelineMessageHeight({ - role: "assistant", - text: "a".repeat(144), - }), - ).toBe(86.5); - }); - - it("uses assistant sizing rules for system messages", () => { - expect( - estimateTimelineMessageHeight({ - role: "system", - text: "a".repeat(144), - }), - ).toBe(86.5); - }); - - it("adds one attachment row for one or two user attachments", () => { - expect( - estimateTimelineMessageHeight({ - role: "user", - text: "hello", - attachments: [{ id: "1" }], - }), - ).toBe(234); - - expect( - estimateTimelineMessageHeight({ - role: "user", - text: "hello", - attachments: [{ id: "1" }, { id: "2" }], - }), - ).toBe(234); - }); - - it("adds a second attachment row for three or four user attachments", () => { - expect( - estimateTimelineMessageHeight({ - role: "user", - text: "hello", - attachments: [{ id: "1" }, { id: "2" }, { id: "3" }], - }), - ).toBe(350); - - expect( - estimateTimelineMessageHeight({ - role: "user", - text: "hello", - attachments: [{ id: "1" }, { id: "2" }, { id: "3" }, { id: "4" }], - }), - ).toBe(350); - }); - - it("does not cap long user message estimates", () => { - expect( - estimateTimelineMessageHeight({ - role: "user", - text: "a".repeat(56 * 120), - }), - ).toBe(2736); - }); - - it("counts explicit newlines for user message estimates", () => { - expect( - estimateTimelineMessageHeight({ - role: "user", - text: "first\nsecond\nthird", - }), - ).toBe(162); - }); - - it("adds terminal context chrome without counting the hidden block as message text", () => { - const prompt = appendTerminalContextsToPrompt("Investigate this", [ - { - terminalId: "default", - terminalLabel: "Terminal 1", - lineStart: 40, - lineEnd: 43, - text: [ - "git status", - "M apps/web/src/components/chat/MessagesTimeline.tsx", - "?? tmp", - "", - ].join("\n"), - }, - ]); - - expect( - estimateTimelineMessageHeight({ - role: "user", - text: prompt, - }), - ).toBe( - estimateTimelineMessageHeight({ - role: "user", - text: `${buildInlineTerminalContextText([{ header: "Terminal 1 lines 40-43" }])} Investigate this`, - }), - ); - }); - - it("uses narrower width to increase user line wrapping", () => { - const message = { - role: "user" as const, - text: "a".repeat(52), - }; - - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(140); - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(118); - }); - - it("does not clamp user wrapping too aggressively on very narrow layouts", () => { - const message = { - role: "user" as const, - text: "a".repeat(20), - }; - - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 100 })).toBe(184); - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(118); - }); - - it("uses narrower width to increase assistant line wrapping", () => { - const message = { - role: "assistant" as const, - text: "a".repeat(200), - }; - - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(154.75); - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(86.5); - }); -}); diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts deleted file mode 100644 index 776fe9ad..00000000 --- a/apps/web/src/components/timelineHeight.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { deriveDisplayedUserMessageState } from "../lib/terminalContext"; -import { buildInlineTerminalContextText } from "./chat/userMessageTerminalContexts"; - -const ASSISTANT_CHARS_PER_LINE_FALLBACK = 72; -const USER_CHARS_PER_LINE_FALLBACK = 56; -const USER_LINE_HEIGHT_PX = 22; -const ASSISTANT_LINE_HEIGHT_PX = 22.75; -// Assistant rows render as markdown content plus a compact timestamp meta line. -// The DOM baseline is much smaller than the user bubble chrome, so model it -// separately instead of reusing the old shared constant. -const ASSISTANT_BASE_HEIGHT_PX = 41; -const USER_BASE_HEIGHT_PX = 96; -const ATTACHMENTS_PER_ROW = 2; -// Full-app browser measurements land closer to a ~116px attachment row once -// the bubble shrinks to content width, so calibrate the estimate to that DOM. -const USER_ATTACHMENT_ROW_HEIGHT_PX = 116; -const USER_BUBBLE_WIDTH_RATIO = 0.8; -const USER_BUBBLE_HORIZONTAL_PADDING_PX = 32; -const ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX = 8; -const USER_MONO_AVG_CHAR_WIDTH_PX = 8.4; -const ASSISTANT_AVG_CHAR_WIDTH_PX = 7.2; -const MIN_USER_CHARS_PER_LINE = 4; -const MIN_ASSISTANT_CHARS_PER_LINE = 20; - -interface TimelineMessageHeightInput { - role: "user" | "assistant" | "system"; - text: string; - attachments?: ReadonlyArray<{ id: string }>; -} - -interface TimelineHeightEstimateLayout { - timelineWidthPx: number | null; -} - -function estimateWrappedLineCount(text: string, charsPerLine: number): number { - if (text.length === 0) return 1; - - // Avoid allocating via split for long logs; iterate once and count wrapped lines. - let lines = 0; - let currentLineLength = 0; - for (let index = 0; index < text.length; index += 1) { - if (text.charCodeAt(index) === 10) { - lines += Math.max(1, Math.ceil(currentLineLength / charsPerLine)); - currentLineLength = 0; - continue; - } - currentLineLength += 1; - } - - lines += Math.max(1, Math.ceil(currentLineLength / charsPerLine)); - return lines; -} - -function isFinitePositiveNumber(value: number | null | undefined): value is number { - return typeof value === "number" && Number.isFinite(value) && value > 0; -} - -function estimateCharsPerLineForUser(timelineWidthPx: number | null): number { - if (!isFinitePositiveNumber(timelineWidthPx)) return USER_CHARS_PER_LINE_FALLBACK; - const bubbleWidthPx = timelineWidthPx * USER_BUBBLE_WIDTH_RATIO; - const textWidthPx = Math.max(bubbleWidthPx - USER_BUBBLE_HORIZONTAL_PADDING_PX, 0); - return Math.max(MIN_USER_CHARS_PER_LINE, Math.floor(textWidthPx / USER_MONO_AVG_CHAR_WIDTH_PX)); -} - -function estimateCharsPerLineForAssistant(timelineWidthPx: number | null): number { - if (!isFinitePositiveNumber(timelineWidthPx)) return ASSISTANT_CHARS_PER_LINE_FALLBACK; - const textWidthPx = Math.max(timelineWidthPx - ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX, 0); - return Math.max( - MIN_ASSISTANT_CHARS_PER_LINE, - Math.floor(textWidthPx / ASSISTANT_AVG_CHAR_WIDTH_PX), - ); -} - -export function estimateTimelineMessageHeight( - message: TimelineMessageHeightInput, - layout: TimelineHeightEstimateLayout = { timelineWidthPx: null }, -): number { - if (message.role === "assistant") { - const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); - const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); - return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * ASSISTANT_LINE_HEIGHT_PX; - } - - if (message.role === "user") { - const charsPerLine = estimateCharsPerLineForUser(layout.timelineWidthPx); - const displayedUserMessage = deriveDisplayedUserMessageState(message.text); - const renderedText = - displayedUserMessage.contexts.length > 0 - ? [ - buildInlineTerminalContextText(displayedUserMessage.contexts), - displayedUserMessage.visibleText, - ] - .filter((part) => part.length > 0) - .join(" ") - : displayedUserMessage.visibleText; - const estimatedLines = estimateWrappedLineCount(renderedText, charsPerLine); - const attachmentCount = message.attachments?.length ?? 0; - const attachmentRows = Math.ceil(attachmentCount / ATTACHMENTS_PER_ROW); - const attachmentHeight = attachmentRows * USER_ATTACHMENT_ROW_HEIGHT_PX; - return USER_BASE_HEIGHT_PX + estimatedLines * USER_LINE_HEIGHT_PX + attachmentHeight; - } - - // `system` messages are not rendered in the chat timeline, but keep a stable - // explicit branch in case they are present in timeline data. - const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); - const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); - return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * ASSISTANT_LINE_HEIGHT_PX; -} diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index 001a240d..cd7f5632 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -18,6 +18,7 @@ function Input({ nativeInput = false, ...props }: InputProps) { + const { style, ...inputProps } = props; const inputClassName = cn( "h-8.5 w-full min-w-0 rounded-[inherit] px-[calc(--spacing(3)-1px)] leading-8.5 outline-none placeholder:text-muted-foreground/72 sm:h-7.5 sm:leading-7.5 [transition:background-color_5000000s_ease-in-out_0s]", size === "sm" && "h-7.5 px-[calc(--spacing(2.5)-1px)] leading-7.5 sm:h-6.5 sm:leading-6.5", @@ -45,14 +46,27 @@ function Input({ className={inputClassName} data-slot="input" size={typeof size === "number" ? size : undefined} - {...props} + style={ + typeof style === "function" + ? style({ + disabled: false, + valid: true, + touched: false, + dirty: false, + filled: false, + focused: false, + }) + : style + } + {...inputProps} /> ) : ( )} diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index ba4da4af..34a59fde 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1,7 +1,6 @@ import { + CLAUDE_AGENT_EFFORT_OPTIONS, CODEX_REASONING_EFFORT_OPTIONS, - type ClaudeCodeEffort, - type CodexReasoningEffort, DEFAULT_MODEL_BY_PROVIDER, type EnvironmentId, ModelSelection, @@ -30,6 +29,7 @@ import { normalizeModelSlug } from "@t3tools/shared/model"; import { useMemo } from "react"; import { getLocalStorageItem } from "./hooks/useLocalStorage"; import { resolveAppModelSelection } from "./modelSelection"; +import { createModelSelection } from "./modelSelectionUtils"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types"; import { type TerminalContextDraft, @@ -40,7 +40,6 @@ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { useShallow } from "zustand/react/shallow"; import { createDebouncedStorage, createMemoryStorage } from "./lib/storage"; -import { createModelSelection, getProviderModelOptions } from "./modelSelectionUtils"; import { getDefaultServerModel } from "./providerModels"; import { UnifiedSettings } from "@t3tools/contracts/settings"; @@ -105,8 +104,11 @@ const PersistedComposerThreadDraftState = Schema.Struct({ }); type PersistedComposerThreadDraftState = typeof PersistedComposerThreadDraftState.Type; +const CodexReasoningEffort = Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS); +const ClaudeAgentEffort = Schema.Literals(CLAUDE_AGENT_EFFORT_OPTIONS); + const LegacyCodexFields = Schema.Struct({ - effort: Schema.optionalKey(Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS)), + effort: Schema.optionalKey(CodexReasoningEffort), codexFastMode: Schema.optionalKey(Schema.Boolean), serviceTier: Schema.optionalKey(Schema.String), }); @@ -542,28 +544,18 @@ function normalizeProviderModelOptions( candidate?.codex && typeof candidate.codex === "object" ? (candidate.codex as Record) : null; - const copilotCandidate = - candidate?.copilot && typeof candidate.copilot === "object" - ? (candidate.copilot as Record) - : null; const claudeCandidate = candidate?.claudeAgent && typeof candidate.claudeAgent === "object" ? (candidate.claudeAgent as Record) : null; - const codexReasoningEffort: CodexReasoningEffort | undefined = - codexCandidate?.reasoningEffort === "low" || - codexCandidate?.reasoningEffort === "medium" || - codexCandidate?.reasoningEffort === "high" || - codexCandidate?.reasoningEffort === "xhigh" - ? codexCandidate.reasoningEffort - : provider === "codex" && - (legacy?.effort === "low" || - legacy?.effort === "medium" || - legacy?.effort === "high" || - legacy?.effort === "xhigh") + const codexReasoningEffort = Schema.is(CodexReasoningEffort)(codexCandidate?.reasoningEffort) + ? codexCandidate.reasoningEffort + : provider === "codex" + ? Schema.is(CodexReasoningEffort)(legacy?.effort) ? legacy.effort - : undefined; + : undefined + : undefined; const codexFastMode = codexCandidate?.fastMode === true ? true @@ -581,13 +573,20 @@ function normalizeProviderModelOptions( } : undefined; - const copilotReasoningEffort: CodexReasoningEffort | undefined = - copilotCandidate?.reasoningEffort === "low" || - copilotCandidate?.reasoningEffort === "medium" || - copilotCandidate?.reasoningEffort === "high" || - copilotCandidate?.reasoningEffort === "xhigh" - ? copilotCandidate.reasoningEffort - : undefined; + const copilotReasoningEffort = + candidate?.copilot && + typeof candidate.copilot === "object" && + Schema.is(CodexReasoningEffort)((candidate.copilot as Record).reasoningEffort) + ? ((candidate.copilot as Record).reasoningEffort as + | "xhigh" + | "high" + | "medium" + | "low") + : provider === "copilot" + ? Schema.is(CodexReasoningEffort)(legacy?.effort) + ? (legacy.effort as "xhigh" | "high" | "medium" | "low") + : undefined + : undefined; const copilot = copilotReasoningEffort !== undefined ? { reasoningEffort: copilotReasoningEffort } : undefined; @@ -597,14 +596,9 @@ function normalizeProviderModelOptions( : claudeCandidate?.thinking === false ? false : undefined; - const claudeEffort: ClaudeCodeEffort | undefined = - claudeCandidate?.effort === "low" || - claudeCandidate?.effort === "medium" || - claudeCandidate?.effort === "high" || - claudeCandidate?.effort === "max" || - claudeCandidate?.effort === "ultrathink" - ? claudeCandidate.effort - : undefined; + const claudeEffort = Schema.is(ClaudeAgentEffort)(claudeCandidate?.effort) + ? claudeCandidate.effort + : undefined; const claudeFastMode = claudeCandidate?.fastMode === true ? true @@ -674,7 +668,7 @@ function normalizeModelSelection( return createModelSelection({ provider, model, - ...(options !== undefined ? { options } : {}), + ...(options ? { options } : {}), }); } @@ -687,11 +681,11 @@ function legacySyncModelSelectionOptions( if (modelSelection === null) { return null; } - const options = getProviderModelOptions(modelSelection.provider, modelOptions); + const options = modelOptions?.[modelSelection.provider]; return createModelSelection({ provider: modelSelection.provider, model: modelSelection.model, - ...(options !== undefined ? { options } : {}), + ...(options ? { options } : {}), }); } @@ -737,7 +731,7 @@ function legacyToModelSelectionByProvider( // Add entries from the options bag (for non-active providers) if (modelOptions) { for (const provider of ["codex", "copilot", "claudeAgent"] as const) { - const options = getProviderModelOptions(provider, modelOptions); + const options = modelOptions[provider]; if (options && Object.keys(options).length > 0) { result[provider] = createModelSelection({ provider, @@ -2241,7 +2235,7 @@ const composerDraftStore = create()( nextMap[normalized.provider] = createModelSelection({ provider: normalized.provider, model: normalized.model, - ...(current?.options !== undefined ? { options: current.options } : {}), + ...(current?.options ? { options: current.options } : {}), }); } } @@ -2292,8 +2286,10 @@ const composerDraftStore = create()( }); } else if (current?.options) { // Remove options but keep the selection - const { options: _, ...rest } = current; - nextMap[provider] = rest as ModelSelection; + nextMap[provider] = createModelSelection({ + provider, + model: current.model, + }); } } if (Equal.equals(base.modelSelectionByProvider, nextMap)) { @@ -2342,8 +2338,10 @@ const composerDraftStore = create()( options: providerOpts, }); } else if (currentForProvider?.options) { - const { options: _, ...rest } = currentForProvider; - nextMap[normalizedProvider] = rest as ModelSelection; + nextMap[normalizedProvider] = createModelSelection({ + provider: normalizedProvider, + model: currentForProvider.model, + }); } // Handle sticky persistence @@ -2354,10 +2352,10 @@ const composerDraftStore = create()( const stickyBase = nextStickyMap[normalizedProvider] ?? base.modelSelectionByProvider[normalizedProvider] ?? - ({ + createModelSelection({ provider: normalizedProvider, model: DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], - } as ModelSelection); + }); if (providerOpts) { nextStickyMap[normalizedProvider] = createModelSelection({ provider: normalizedProvider, @@ -2365,8 +2363,10 @@ const composerDraftStore = create()( options: providerOpts, }); } else if (stickyBase.options) { - const { options: _, ...rest } = stickyBase; - nextStickyMap[normalizedProvider] = rest as ModelSelection; + nextStickyMap[normalizedProvider] = createModelSelection({ + provider: normalizedProvider, + model: stickyBase.model, + }); } nextStickyActiveProvider = base.activeProvider ?? normalizedProvider; } diff --git a/apps/web/src/contextMenuFallback.test.ts b/apps/web/src/contextMenuFallback.test.ts new file mode 100644 index 00000000..598d0d8b --- /dev/null +++ b/apps/web/src/contextMenuFallback.test.ts @@ -0,0 +1,221 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { showContextMenuFallback } from "./contextMenuFallback"; + +type FakeListener = (event: FakeDomEvent) => void; + +class FakeDomEvent { + defaultPrevented = false; + + constructor( + readonly type: string, + init: Record = {}, + ) { + Object.assign(this, init); + } + + preventDefault() { + this.defaultPrevented = true; + } +} + +class FakeElement { + children: FakeElement[] = []; + parent: FakeElement | null = null; + style: Record & { cssText?: string } = {}; + dataset: Record = {}; + className = ""; + disabled = false; + type = ""; + private textValue = ""; + private readonly listeners = new Map(); + + constructor(readonly tagName: string) {} + + appendChild(child: FakeElement) { + child.parent = this; + this.children.push(child); + return child; + } + + remove() { + if (!this.parent) { + return; + } + const index = this.parent.children.indexOf(this); + if (index >= 0) { + this.parent.children.splice(index, 1); + } + this.parent = null; + } + + addEventListener(type: string, listener: FakeListener) { + const existing = this.listeners.get(type) ?? []; + existing.push(listener); + this.listeners.set(type, existing); + } + + dispatchEvent(event: FakeDomEvent) { + for (const listener of this.listeners.get(event.type) ?? []) { + listener(event); + } + return true; + } + + set textContent(value: string) { + this.textValue = value; + } + + get textContent() { + return `${this.textValue}${this.children.map((child) => child.textContent).join("")}`; + } + + querySelectorAll(tagName: string): FakeElement[] { + const matches: FakeElement[] = []; + if (this.tagName === tagName) { + matches.push(this); + } + for (const child of this.children) { + matches.push(...child.querySelectorAll(tagName)); + } + return matches; + } + + getBoundingClientRect() { + const left = Number.parseInt(this.style.left ?? "0", 10) || 0; + const top = Number.parseInt(this.style.top ?? "0", 10) || 0; + const width = this.tagName === "div" ? 180 : 140; + const height = this.tagName === "div" ? 120 : 28; + return { + left, + top, + width, + height, + right: left + width, + bottom: top + height, + }; + } +} + +class FakeBody extends FakeElement { + private html = ""; + + constructor() { + super("body"); + } + + set innerHTML(value: string) { + this.html = value; + this.children = []; + } + + get innerHTML() { + return this.html; + } +} + +class FakeDocument { + body = new FakeBody(); + private readonly listeners = new Map(); + + createElement(tagName: string) { + return new FakeElement(tagName); + } + + addEventListener(type: string, listener: FakeListener) { + const existing = this.listeners.get(type) ?? []; + existing.push(listener); + this.listeners.set(type, existing); + } + + removeEventListener(type: string, listener: FakeListener) { + const existing = this.listeners.get(type); + if (!existing) { + return; + } + const index = existing.indexOf(listener); + if (index >= 0) { + existing.splice(index, 1); + } + } + + querySelectorAll(tagName: string) { + return this.body.querySelectorAll(tagName); + } +} + +function findButton(label: string): FakeElement | undefined { + return (document as unknown as FakeDocument) + .querySelectorAll("button") + .find((button) => button.textContent.includes(label)); +} + +beforeEach(() => { + vi.stubGlobal("document", new FakeDocument()); + vi.stubGlobal("window", { + innerWidth: 1280, + innerHeight: 800, + }); + vi.stubGlobal("requestAnimationFrame", (callback: (time: number) => void) => { + callback(0); + return 0; + }); + vi.stubGlobal( + "MouseEvent", + class extends FakeDomEvent { + constructor(type: string, init: Record = {}) { + super(type, init); + } + }, + ); + vi.stubGlobal( + "KeyboardEvent", + class extends FakeDomEvent { + constructor(type: string, init: Record = {}) { + super(type, init); + } + }, + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("showContextMenuFallback", () => { + it("resolves a clicked flat menu item", async () => { + const selectionPromise = showContextMenuFallback([ + { id: "rename", label: "Rename" }, + { id: "delete", label: "Delete", destructive: true }, + ]); + + const renameButton = findButton("Rename"); + expect(renameButton).toBeTruthy(); + renameButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + await expect(selectionPromise).resolves.toBe("rename"); + }); + + it("opens nested submenus and resolves the clicked leaf id", async () => { + const selectionPromise = showContextMenuFallback([ + { + id: "rename:submenu", + label: "Rename project", + children: [ + { id: "rename:project-a", label: "/tmp/project-a" }, + { id: "rename:project-b", label: "/tmp/project-b" }, + ], + }, + ]); + + const parentButton = findButton("Rename project"); + expect(parentButton).toBeTruthy(); + parentButton?.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + + const childButton = findButton("/tmp/project-b"); + expect(childButton).toBeTruthy(); + childButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + await expect(selectionPromise).resolves.toBe("rename:project-b"); + }); +}); diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 9fd1a129..cda90df5 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -1,9 +1,22 @@ import type { ContextMenuItem } from "@t3tools/contracts"; +function clampMenuPosition(menu: HTMLDivElement, preferredLeft: number, preferredTop: number) { + const rect = menu.getBoundingClientRect(); + const left = Math.min( + Math.max(4, preferredLeft), + Math.max(4, window.innerWidth - rect.width - 4), + ); + const top = Math.min( + Math.max(4, preferredTop), + Math.max(4, window.innerHeight - rect.height - 4), + ); + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; +} + /** * Imperative DOM-based context menu for non-Electron environments. - * Shows a positioned dropdown and returns a promise that resolves - * with the clicked item id, or null if dismissed. + * Supports nested submenus and resolves with the clicked leaf item id. */ export function showContextMenuFallback( items: readonly ContextMenuItem[], @@ -13,62 +26,117 @@ export function showContextMenuFallback( const overlay = document.createElement("div"); overlay.style.cssText = "position:fixed;inset:0;z-index:9999"; - const menu = document.createElement("div"); - menu.className = - "fixed z-[10000] min-w-[140px] rounded-md border border-border bg-popover py-1 shadow-xl animate-in fade-in zoom-in-95"; - - const x = position?.x ?? 0; - const y = position?.y ?? 0; - menu.style.top = `${y}px`; - menu.style.left = `${x}px`; + const menuStack: HTMLDivElement[] = []; - function cleanup(result: T | null) { + const cleanup = (result: T | null) => { document.removeEventListener("keydown", onKeyDown); overlay.remove(); - menu.remove(); + for (const menu of menuStack) { + menu.remove(); + } resolve(result); - } + }; - function onKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") { - e.preventDefault(); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); cleanup(null); } - } + }; - overlay.addEventListener("mousedown", () => cleanup(null)); - document.addEventListener("keydown", onKeyDown); - - for (const item of items) { - const btn = document.createElement("button"); - btn.type = "button"; - btn.textContent = item.label; - const isDestructiveAction = item.destructive === true || item.id === "delete"; - const isDisabled = item.disabled === true; - btn.disabled = isDisabled; - btn.className = isDisabled - ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-muted-foreground/60 cursor-not-allowed" - : isDestructiveAction - ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default" - : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-popover-foreground hover:bg-accent cursor-default"; - if (!isDisabled) { - btn.addEventListener("click", () => cleanup(item.id)); + const closeMenusFromLevel = (level: number) => { + while (menuStack.length > level) { + menuStack.pop()?.remove(); } - menu.appendChild(btn); - } + }; - document.body.appendChild(overlay); - document.body.appendChild(menu); + const openMenu = ( + entries: readonly ContextMenuItem[], + preferredLeft: number, + preferredTop: number, + level: number, + ) => { + closeMenusFromLevel(level); - // Adjust if menu overflows viewport - requestAnimationFrame(() => { - const rect = menu.getBoundingClientRect(); - if (rect.right > window.innerWidth) { - menu.style.left = `${window.innerWidth - rect.width - 4}px`; - } - if (rect.bottom > window.innerHeight) { - menu.style.top = `${window.innerHeight - rect.height - 4}px`; + const menu = document.createElement("div"); + menu.className = + "fixed z-[10000] min-w-[160px] rounded-md border border-border bg-popover py-1 shadow-xl animate-in fade-in zoom-in-95"; + menu.style.left = `${preferredLeft}px`; + menu.style.top = `${preferredTop}px`; + menu.dataset.level = String(level); + + for (const item of entries) { + const button = document.createElement("button"); + button.type = "button"; + const hasChildren = Array.isArray(item.children) && item.children.length > 0; + const isLeafDestructive = + !hasChildren && (item.destructive === true || item.id === ("delete" as T)); + const isDisabled = item.disabled === true; + button.disabled = isDisabled; + button.className = isDisabled + ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-muted-foreground/60 cursor-not-allowed" + : isLeafDestructive + ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default" + : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-popover-foreground hover:bg-accent cursor-default"; + + const label = document.createElement("span"); + label.className = "min-w-0 flex-1 truncate"; + label.textContent = item.label; + button.appendChild(label); + + if (hasChildren) { + const chevron = document.createElement("span"); + chevron.className = "shrink-0 text-muted-foreground/70"; + chevron.textContent = "›"; + button.appendChild(chevron); + } + + if (!isDisabled) { + if (hasChildren) { + button.addEventListener("mouseenter", () => { + const rect = button.getBoundingClientRect(); + const nextLeft = rect.right + 4; + const nextTop = rect.top; + openMenu(item.children!, nextLeft, nextTop, level + 1); + + const childMenu = menuStack[level + 1]; + if (!childMenu) { + return; + } + const childRect = childMenu.getBoundingClientRect(); + if (childRect.right > window.innerWidth) { + clampMenuPosition(childMenu, rect.left - childRect.width - 4, rect.top); + } + }); + button.addEventListener("click", (event) => { + event.preventDefault(); + }); + } else { + button.addEventListener("mouseenter", () => { + closeMenusFromLevel(level + 1); + }); + button.addEventListener("click", () => cleanup(item.id)); + } + } + + menu.appendChild(button); } - }); + + menu.addEventListener("mouseenter", () => { + closeMenusFromLevel(level + 1); + }); + + document.body.appendChild(menu); + menuStack[level] = menu; + + requestAnimationFrame(() => { + clampMenuPosition(menu, preferredLeft, preferredTop); + }); + }; + + overlay.addEventListener("mousedown", () => cleanup(null)); + document.addEventListener("keydown", onKeyDown); + document.body.appendChild(overlay); + openMenu(items, position?.x ?? 0, position?.y ?? 0, 0); }); } diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index 4e847318..310c75b3 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -10,7 +10,13 @@ import { type AppState, type EnvironmentState, } from "./store"; -import { deriveLogicalProjectKey } from "./logicalProject"; +import { + deriveLogicalProjectKey, + deriveLogicalProjectKeyFromSettings, + derivePhysicalProjectKey, + deriveProjectGroupLabel, + resolveProjectGroupingMode, +} from "./logicalProject"; import type { Project, SidebarThreadSummary } from "./types"; import { DEFAULT_INTERACTION_MODE } from "./types"; @@ -31,6 +37,10 @@ const threadL1 = ThreadId.make("thread-local-only-1"); const threadRO1 = ThreadId.make("thread-remote-only-1"); const SHARED_REPO_CANONICAL_KEY = "github.com/example/shared-repo"; +const DEFAULT_GROUPING_SETTINGS = { + sidebarProjectGroupingMode: "repository" as const, + sidebarProjectGroupingOverrides: {}, +}; // ── Factory Helpers ────────────────────────────────────────────────── @@ -238,9 +248,7 @@ describe("environment grouping", () => { environmentId: primaryEnvId, name: "local-only", }); - const key = deriveLogicalProjectKey(project); - expect(key).toContain(primaryEnvId); - expect(key).toContain(localOnlyProjectId); + expect(deriveLogicalProjectKey(project)).toBe(derivePhysicalProjectKey(project)); }); it("groups projects from different environments that share the same canonical key", () => { @@ -273,6 +281,134 @@ describe("environment grouping", () => { expect(deriveLogicalProjectKey(primary)).toBe(deriveLogicalProjectKey(remote)); }); + it("groups repo root and nested projects from the same repository by default", () => { + const rootProject = makeProject({ + id: sharedProjectPrimaryId, + environmentId: primaryEnvId, + name: "shared-repo", + cwd: "/workspace/repo", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + rootPath: "/workspace/repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + const nestedProject = makeProject({ + id: localOnlyProjectId, + environmentId: primaryEnvId, + name: "web", + cwd: "/workspace/repo/apps/web", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + rootPath: "/workspace/repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + + expect(deriveLogicalProjectKey(rootProject)).toBe(SHARED_REPO_CANONICAL_KEY); + expect(deriveLogicalProjectKey(nestedProject)).toBe(SHARED_REPO_CANONICAL_KEY); + }); + + it("uses repository path grouping when requested", () => { + const rootProject = makeProject({ + id: sharedProjectPrimaryId, + environmentId: primaryEnvId, + name: "shared-repo", + cwd: "/workspace/repo", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + rootPath: "/workspace/repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + const nestedProject = makeProject({ + id: localOnlyProjectId, + environmentId: primaryEnvId, + name: "web", + cwd: "/workspace/repo/apps/web", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + rootPath: "/workspace/repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + + expect( + deriveLogicalProjectKey(rootProject, { + groupingMode: "repository_path", + }), + ).toBe(SHARED_REPO_CANONICAL_KEY); + expect( + deriveLogicalProjectKey(nestedProject, { + groupingMode: "repository_path", + }), + ).toBe(`${SHARED_REPO_CANONICAL_KEY}::apps/web`); + }); + + it("groups matching nested project paths across environments when repo roots differ", () => { + const primary = makeProject({ + id: sharedProjectPrimaryId, + environmentId: primaryEnvId, + name: "web", + cwd: "/workspace/repo/apps/web", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + rootPath: "/workspace/repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + const remote = makeProject({ + id: sharedProjectRemoteId, + environmentId: remoteEnvId, + name: "web", + cwd: "/srv/checkout/apps/web", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + rootPath: "/srv/checkout", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + + expect( + deriveLogicalProjectKey(primary, { + groupingMode: "repository_path", + }), + ).toBe(`${SHARED_REPO_CANONICAL_KEY}::apps/web`); + expect( + deriveLogicalProjectKey(primary, { + groupingMode: "repository_path", + }), + ).toBe( + deriveLogicalProjectKey(remote, { + groupingMode: "repository_path", + }), + ); + }); + it("does NOT group projects without shared canonical key", () => { const local = makeProject({ id: localOnlyProjectId, @@ -286,6 +422,32 @@ describe("environment grouping", () => { }); expect(deriveLogicalProjectKey(local)).not.toBe(deriveLogicalProjectKey(remote)); }); + + it("uses per-project overrides from settings", () => { + const project = makeProject({ + id: sharedProjectPrimaryId, + environmentId: primaryEnvId, + name: "shared-repo", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + + expect(resolveProjectGroupingMode(project, DEFAULT_GROUPING_SETTINGS)).toBe("repository"); + expect( + deriveLogicalProjectKeyFromSettings(project, { + ...DEFAULT_GROUPING_SETTINGS, + sidebarProjectGroupingOverrides: { + [derivePhysicalProjectKey(project)]: "separate", + }, + }), + ).toBe(derivePhysicalProjectKey(project)); + }); }); describe("selectProjectsAcrossEnvironments", () => { @@ -298,6 +460,152 @@ describe("environment grouping", () => { }); }); + describe("deriveProjectGroupLabel", () => { + it("prefers a renamed representative project title over shared repository names", () => { + expect( + deriveProjectGroupLabel({ + representative: { + name: "My Renamed Workspace", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + name: "shared-repo", + displayName: "Shared Repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }, + members: [ + { + name: "My Renamed Workspace", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + name: "shared-repo", + displayName: "Shared Repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }, + { + name: "shared-repo", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + name: "shared-repo", + displayName: "Shared Repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }, + ], + }), + ).toBe("My Renamed Workspace"); + }); + + it("keeps shared repository display names for unrenamed grouped projects", () => { + expect( + deriveProjectGroupLabel({ + representative: { + name: "shared-repo", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + name: "shared-repo", + displayName: "Shared Repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }, + members: [ + { + name: "shared-repo", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + name: "shared-repo", + displayName: "Shared Repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }, + { + name: "shared-repo", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + name: "shared-repo", + displayName: "Shared Repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }, + ], + }), + ).toBe("Shared Repo"); + }); + + it("uses a renamed non-representative member title when it is the only custom label", () => { + expect( + deriveProjectGroupLabel({ + representative: { + name: "shared-repo", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + name: "shared-repo", + displayName: "Shared Repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }, + members: [ + { + name: "shared-repo", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + name: "shared-repo", + displayName: "Shared Repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }, + { + name: "Remote Workspace", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + name: "shared-repo", + displayName: "Shared Repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }, + ], + }), + ).toBe("Remote Workspace"); + }); + }); + describe("selectSidebarThreadsAcrossEnvironments", () => { it("returns all sidebar thread summaries from all environments", () => { const state = makeFixtureState(); diff --git a/apps/web/src/environments/runtime/service.test.ts b/apps/web/src/environments/runtime/service.test.ts index 7a4af404..a6aaab26 100644 --- a/apps/web/src/environments/runtime/service.test.ts +++ b/apps/web/src/environments/runtime/service.test.ts @@ -1,6 +1,48 @@ +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; -import { shouldApplyTerminalEvent } from "./service"; +import { syncProjects, type UiState } from "~/uiStateStore"; +import type { Project } from "~/types"; +import { buildProjectUiSyncInputs, shouldApplyTerminalEvent } from "./service"; + +const PRIMARY_ENVIRONMENT_ID = EnvironmentId.make("env-local"); +const REMOTE_ENVIRONMENT_ID = EnvironmentId.make("env-remote"); + +function makeProject( + input: Partial & Pick, +): Project { + return { + id: input.id, + environmentId: input.environmentId, + cwd: input.cwd, + name: input.name ?? "project", + createdAt: input.createdAt ?? "2026-04-17T00:00:00.000Z", + updatedAt: input.updatedAt ?? "2026-04-17T00:00:00.000Z", + repositoryIdentity: input.repositoryIdentity ?? { + canonicalKey: "github.com/t3tools/repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/t3tools/repo.git", + }, + displayName: "t3code-copilot", + name: "t3code-copilot", + rootPath: "/repo", + }, + defaultModelSelection: input.defaultModelSelection ?? null, + scripts: input.scripts ?? [], + }; +} + +function makeUiState(input?: Partial): UiState { + return { + projectExpandedById: {}, + projectOrder: [], + threadLastVisitedAtById: {}, + threadChangedFilesExpandedById: {}, + ...input, + }; +} describe("shouldApplyTerminalEvent", () => { it("applies terminal events for draft-only threads", () => { @@ -39,3 +81,76 @@ describe("shouldApplyTerminalEvent", () => { ).toBe(true); }); }); + +describe("buildProjectUiSyncInputs", () => { + it("uses logical project keys so grouped rows keep stable expansion state", () => { + const localProject = makeProject({ + id: ProjectId.make("project-local"), + environmentId: PRIMARY_ENVIRONMENT_ID, + cwd: "/repo", + }); + const remoteProject = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: REMOTE_ENVIRONMENT_ID, + cwd: "/repo", + }); + + const initialState = makeUiState({ + projectExpandedById: { + "github.com/t3tools/repo": false, + }, + projectOrder: ["github.com/t3tools/repo"], + }); + + const next = syncProjects( + initialState, + buildProjectUiSyncInputs([localProject, remoteProject]), + ); + + expect(next.projectOrder).toEqual(["github.com/t3tools/repo"]); + expect(next.projectExpandedById["github.com/t3tools/repo"]).toBe(false); + }); + + it("deduplicates grouped projects before syncing ui state", () => { + const localProject = makeProject({ + id: ProjectId.make("project-local"), + environmentId: PRIMARY_ENVIRONMENT_ID, + cwd: "/repo", + }); + const remoteProject = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: REMOTE_ENVIRONMENT_ID, + cwd: "/repo", + name: "Remote rename", + }); + + expect(buildProjectUiSyncInputs([localProject, remoteProject])).toEqual([ + { + key: "github.com/t3tools/repo", + logicalId: "github.com/t3tools/repo", + cwd: "/repo", + }, + ]); + }); + + it("selects a deterministic cwd for grouped rows before dedupe", () => { + const remoteProject = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: REMOTE_ENVIRONMENT_ID, + cwd: "/repo-z", + }); + const localProject = makeProject({ + id: ProjectId.make("project-local"), + environmentId: PRIMARY_ENVIRONMENT_ID, + cwd: "/repo-a", + }); + + expect(buildProjectUiSyncInputs([remoteProject, localProject])).toEqual([ + { + key: "github.com/t3tools/repo", + logicalId: "github.com/t3tools/repo", + cwd: "/repo-a", + }, + ]); + }); +}); diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index 086bff6b..8ab65e2a 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -13,9 +13,7 @@ import { Throttler } from "@tanstack/react-pacer"; import { createKnownEnvironment, getKnownEnvironmentWsBaseUrl, - scopedProjectKey, scopedThreadKey, - scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime"; @@ -24,6 +22,7 @@ import { markPromotedDraftThreadsByRef, useComposerDraftStore, } from "~/composerDraftStore"; +import { getUnifiedSettingsSnapshot } from "~/hooks/useSettings"; import { ensureLocalApi } from "~/localApi"; import { collectActiveTerminalThreadIds } from "~/lib/terminalStateCleanup"; import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects"; @@ -62,6 +61,7 @@ import { useTerminalStateStore } from "~/terminalStateStore"; import { useUiStateStore } from "~/uiStateStore"; import { WsTransport } from "../../rpc/wsTransport"; import { createWsRpcClient, type WsRpcClient } from "../../rpc/wsRpcClient"; +import { deriveLogicalProjectKeyFromSettings } from "../../logicalProject"; type EnvironmentServiceState = { readonly queryClient: QueryClient; @@ -466,14 +466,50 @@ function coalesceOrchestrationUiEvents( return coalesced; } +export function buildProjectUiSyncInputs( + projects: ReturnType, +) { + const projectGroupingSettings = getUnifiedSettingsSnapshot(); + const logicalProjectInputs = projects.map((project, index) => ({ + key: deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings), + logicalId: deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings), + cwd: project.cwd, + incomingIndex: index, + })); + logicalProjectInputs.sort((left, right) => { + const byLogicalId = left.logicalId.localeCompare(right.logicalId); + if (byLogicalId !== 0) { + return byLogicalId; + } + const byCwd = left.cwd.localeCompare(right.cwd); + if (byCwd !== 0) { + return byCwd; + } + return left.incomingIndex - right.incomingIndex; + }); + + const inputsByLogicalProjectKey = new Map< + string, + { key: string; logicalId: string; cwd: string; incomingIndex: number } + >(); + for (const input of logicalProjectInputs) { + if (!inputsByLogicalProjectKey.has(input.logicalId)) { + inputsByLogicalProjectKey.set(input.logicalId, input); + } + } + + return [...inputsByLogicalProjectKey.values()] + .toSorted((left, right) => left.incomingIndex - right.incomingIndex) + .map(({ key, logicalId, cwd }) => ({ + key, + logicalId, + cwd, + })); +} + function syncProjectUiFromStore() { const projects = selectProjectsAcrossEnvironments(useStore.getState()); - useUiStateStore.getState().syncProjects( - projects.map((project) => ({ - key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), - cwd: project.cwd, - })), - ); + useUiStateStore.getState().syncProjects(buildProjectUiSyncInputs(projects)); } function syncThreadUiFromStore() { @@ -541,12 +577,7 @@ function applyRecoveredEventBatch( useStore.getState().applyOrchestrationEvents(uiEvents, environmentId); if (needsProjectUiSync) { const projects = selectProjectsAcrossEnvironments(useStore.getState()); - useUiStateStore.getState().syncProjects( - projects.map((project) => ({ - key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), - cwd: project.cwd, - })), - ); + useUiStateStore.getState().syncProjects(buildProjectUiSyncInputs(projects)); } const needsThreadUiSync = events.some( diff --git a/apps/web/src/filePathDisplay.test.ts b/apps/web/src/filePathDisplay.test.ts new file mode 100644 index 00000000..c196b567 --- /dev/null +++ b/apps/web/src/filePathDisplay.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { formatWorkspaceRelativePath } from "./filePathDisplay"; + +describe("formatWorkspaceRelativePath", () => { + it("formats absolute workspace paths from the workspace root", () => { + expect( + formatWorkspaceRelativePath( + "C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts:501", + "C:/Users/mike/dev-stuff/t3code", + ), + ).toBe("t3code/apps/web/src/session-logic.ts:501"); + }); + + it("prefixes relative paths with the workspace root label", () => { + expect( + formatWorkspaceRelativePath( + "apps/web/src/session-logic.ts:501", + "C:/Users/mike/dev-stuff/t3code", + ), + ).toBe("t3code/apps/web/src/session-logic.ts:501"); + }); + + it("keeps paths already rooted at the workspace label stable", () => { + expect( + formatWorkspaceRelativePath( + "t3code/apps/web/src/session-logic.ts:501", + "C:/Users/mike/dev-stuff/t3code", + ), + ).toBe("t3code/apps/web/src/session-logic.ts:501"); + }); + + it("preserves columns when present", () => { + expect( + formatWorkspaceRelativePath( + "/C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts:501:9", + "C:/Users/mike/dev-stuff/t3code", + ), + ).toBe("t3code/apps/web/src/session-logic.ts:501:9"); + }); +}); diff --git a/apps/web/src/filePathDisplay.ts b/apps/web/src/filePathDisplay.ts new file mode 100644 index 00000000..5a6e2a02 --- /dev/null +++ b/apps/web/src/filePathDisplay.ts @@ -0,0 +1,57 @@ +import { splitPathAndPosition } from "./terminal-links"; + +function normalizePathSeparators(path: string): string { + return path.replaceAll("\\", "/"); +} + +function canonicalizeWindowsDrivePath(path: string): string { + return /^\/[A-Za-z]:\//.test(path) ? path.slice(1) : path; +} + +function trimTrailingPathSeparators(path: string): string { + return path.replace(/[\\/]+$/, ""); +} + +function basenameOfPath(path: string): string { + const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; +} + +function stripRelativePrefixes(path: string): string { + return path.replace(/^\.\/+/, "").replace(/^\/+/, ""); +} + +export function formatWorkspaceRelativePath( + pathWithPosition: string, + workspaceRoot: string | undefined, +): string { + const { path, line, column } = splitPathAndPosition(pathWithPosition); + const normalizedPath = canonicalizeWindowsDrivePath(normalizePathSeparators(path)); + + let displayPath = normalizedPath; + if (workspaceRoot) { + const normalizedWorkspaceRoot = canonicalizeWindowsDrivePath( + normalizePathSeparators(trimTrailingPathSeparators(workspaceRoot)), + ); + const workspaceLabel = basenameOfPath(normalizedWorkspaceRoot); + const pathForCompare = normalizedPath.toLowerCase(); + const workspaceForCompare = normalizedWorkspaceRoot.toLowerCase(); + const workspaceWithSeparator = `${workspaceForCompare}/`; + const workspaceLabelWithSeparator = `${workspaceLabel.toLowerCase()}/`; + + if (pathForCompare === workspaceForCompare) { + displayPath = workspaceLabel; + } else if (pathForCompare.startsWith(workspaceWithSeparator)) { + const relativeSuffix = normalizedPath.slice(normalizedWorkspaceRoot.length + 1); + displayPath = `${workspaceLabel}/${relativeSuffix}`; + } else if (!normalizedPath.startsWith("/")) { + const relativePath = stripRelativePrefixes(normalizedPath); + displayPath = pathForCompare.startsWith(workspaceLabelWithSeparator) + ? normalizedPath + : `${workspaceLabel}/${relativePath}`; + } + } + + if (!line) return displayPath; + return `${displayPath}:${line}${column ? `:${column}` : ""}`; +} diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index f5677356..4a169d09 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -10,14 +10,19 @@ import { } from "../composerDraftStore"; import { newDraftId, newThreadId } from "../lib/utils"; import { orderItemsByPreferredIds } from "../components/Sidebar.logic"; -import { deriveLogicalProjectKey } from "../logicalProject"; +import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; import { selectProjectsAcrossEnvironments, useStore } from "../store"; import { createThreadSelectorByRef } from "../storeSelectors"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { useUiStateStore } from "../uiStateStore"; +import { useSettings } from "./useSettings"; function useNewThreadState() { const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store))); + const projectGroupingSettings = useSettings((settings) => ({ + sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, + })); const router = useRouter(); const getCurrentRouteTarget = useCallback(() => { const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; @@ -48,7 +53,7 @@ function useNewThreadState() { candidate.environmentId === projectRef.environmentId, ); const logicalProjectKey = project - ? deriveLogicalProjectKey(project) + ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings) : scopedProjectKey(projectRef); const hasBranchOption = options?.branch !== undefined; const hasWorktreePathOption = options?.worktreePath !== undefined; @@ -129,7 +134,7 @@ function useNewThreadState() { }); })(); }, - [getCurrentRouteTarget, router, projects], + [getCurrentRouteTarget, projectGroupingSettings, router, projects], ); } @@ -143,6 +148,10 @@ export function useNewThreadHandler() { export function useHandleNewThread() { const projectOrder = useUiStateStore((store) => store.projectOrder); + const projectGroupingSettings = useSettings((settings) => ({ + sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, + })); const routeTarget = useParams({ strict: false, select: (params) => resolveThreadRouteTarget(params), @@ -164,9 +173,9 @@ export function useHandleNewThread() { return orderItemsByPreferredIds({ items: projects, preferredIds: projectOrder, - getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + getId: (project) => deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings), }); - }, [projectOrder, projects]); + }, [projectGroupingSettings, projectOrder, projects]); const handleNewThread = useNewThreadState(); return { diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 3dc2cf9b..8c4c54fa 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -19,7 +19,7 @@ import { } from "@t3tools/contracts/settings"; import { ensureLocalApi } from "~/localApi"; import { Struct } from "effect"; -import { deepMerge } from "@t3tools/shared/Struct"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/serverState"; const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; @@ -39,6 +39,14 @@ function getClientSettingsSnapshot(): ClientSettings { return clientSettingsSnapshot; } +export function getUnifiedSettingsSnapshot(): UnifiedSettings { + return { + ...DEFAULT_UNIFIED_SETTINGS, + ...getServerConfig()?.settings, + ...getClientSettingsSnapshot(), + }; +} + function replaceClientSettingsSnapshot(settings: ClientSettings): void { clientSettingsSnapshot = settings; emitClientSettingsChange(); @@ -64,7 +72,7 @@ async function hydrateClientSettings(): Promise { try { const persistedSettings = await ensureLocalApi().persistence.getClientSettings(); if (persistedSettings) { - replaceClientSettingsSnapshot(persistedSettings); + replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings }); } } catch (error) { console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, error); @@ -154,7 +162,7 @@ export function useUpdateSettings() { if (Object.keys(serverPatch).length > 0) { const currentServerConfig = getServerConfig(); if (currentServerConfig) { - applySettingsUpdated(deepMerge(currentServerConfig.settings, serverPatch)); + applySettingsUpdated(applyServerSettingsPatch(currentServerConfig.settings, serverPatch)); } // Fire-and-forget RPC — push will reconcile on success void ensureLocalApi().server.updateSettings(serverPatch); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 833fb1d8..6a4db398 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -324,6 +324,47 @@ label:has(> select#reasoning-effort) select { font-size: 0.75rem; } +.chat-markdown-file-link { + display: inline-flex; + align-items: center; + gap: 0.28rem; + border: 1px solid color-mix(in srgb, var(--border) 92%, transparent); + border-radius: 0.375rem; + background: color-mix(in srgb, var(--muted) 88%, var(--background)); + padding: 0.08rem 0.34rem; + color: var(--foreground); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.75rem; + line-height: 1.15; + vertical-align: text-bottom; + transition: + background-color 120ms ease, + border-color 120ms ease, + color 120ms ease, + opacity 120ms ease; +} + +.chat-markdown-file-link:hover { + opacity: 1; + color: var(--foreground); + border-color: color-mix(in srgb, var(--border) 65%, var(--foreground)); + background: color-mix(in srgb, var(--muted) 72%, var(--background)); +} + +.chat-markdown-file-link:focus-visible { + outline: none; + box-shadow: 0 0 0 1px color-mix(in srgb, var(--ring) 70%, transparent); +} + +.chat-markdown-file-link-icon { + opacity: 0.72; +} + +.chat-markdown-file-link-label { + color: color-mix(in srgb, var(--foreground) 88%, transparent); + line-height: 1.1; +} + .chat-markdown pre { max-width: 100%; overflow-x: auto; @@ -340,6 +381,24 @@ label:has(> select#reasoning-effort) select { font-size: 0.75rem; } +.markdown-file-link-tooltip-scroll { + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--border) 78%, transparent) transparent; +} + +.markdown-file-link-tooltip-scroll::-webkit-scrollbar { + height: 6px; +} + +.markdown-file-link-tooltip-scroll::-webkit-scrollbar-track { + background: transparent; +} + +.markdown-file-link-tooltip-scroll::-webkit-scrollbar-thumb { + border-radius: 999px; + background: color-mix(in srgb, var(--border) 78%, transparent); +} + .chat-markdown .chat-markdown-codeblock { --chat-markdown-codeblock-copy-button-space: 1.5rem; position: relative; diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 06b16313..10e71a0f 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -215,6 +215,7 @@ const defaultProviders: ReadonlyArray = [ auth: { status: "authenticated" }, checkedAt: "2026-01-01T00:00:00.000Z", models: [], + quotaSnapshots: [], slashCommands: [], skills: [], }, @@ -528,13 +529,20 @@ describe("wsApi", () => { }); it("reads and writes persistence through the desktop bridge when available", async () => { - const getClientSettings = vi.fn().mockResolvedValue({ + const clientSettings = { confirmThreadArchive: true, confirmThreadDelete: false, diffWordWrap: true, - sidebarProjectSortOrder: "manual", - sidebarThreadSortOrder: "created_at", - timestampFormat: "24-hour", + sidebarProjectGroupingMode: "repository_path" as const, + sidebarProjectGroupingOverrides: { + "environment-local:/tmp/project": "separate" as const, + }, + sidebarProjectSortOrder: "manual" as const, + sidebarThreadSortOrder: "created_at" as const, + timestampFormat: "24-hour" as const, + }; + const getClientSettings = vi.fn().mockResolvedValue({ + ...clientSettings, }); const setClientSettings = vi.fn().mockResolvedValue(undefined); const getSavedEnvironmentRegistry = vi.fn().mockResolvedValue([]); @@ -556,14 +564,7 @@ describe("wsApi", () => { const api = createLocalApi(rpcClientMock as never); await api.persistence.getClientSettings(); - await api.persistence.setClientSettings({ - confirmThreadArchive: true, - confirmThreadDelete: false, - diffWordWrap: true, - sidebarProjectSortOrder: "manual", - sidebarThreadSortOrder: "created_at", - timestampFormat: "24-hour", - }); + await api.persistence.setClientSettings(clientSettings); await api.persistence.getSavedEnvironmentRegistry(); await api.persistence.setSavedEnvironmentRegistry([]); await api.persistence.getSavedEnvironmentSecret(EnvironmentId.make("environment-local")); @@ -574,14 +575,7 @@ describe("wsApi", () => { await api.persistence.removeSavedEnvironmentSecret(EnvironmentId.make("environment-local")); expect(getClientSettings).toHaveBeenCalledWith(); - expect(setClientSettings).toHaveBeenCalledWith({ - confirmThreadArchive: true, - confirmThreadDelete: false, - diffWordWrap: true, - sidebarProjectSortOrder: "manual", - sidebarThreadSortOrder: "created_at", - timestampFormat: "24-hour", - }); + expect(setClientSettings).toHaveBeenCalledWith(clientSettings); expect(getSavedEnvironmentRegistry).toHaveBeenCalledWith(); expect(setSavedEnvironmentRegistry).toHaveBeenCalledWith([]); expect(getSavedEnvironmentSecret).toHaveBeenCalledWith("environment-local"); @@ -592,15 +586,20 @@ describe("wsApi", () => { it("falls back to browser storage for persistence when the desktop bridge is missing", async () => { const { createLocalApi } = await import("./localApi"); const api = createLocalApi(rpcClientMock as never); - - await api.persistence.setClientSettings({ + const clientSettings = { confirmThreadArchive: true, confirmThreadDelete: false, diffWordWrap: true, - sidebarProjectSortOrder: "manual", - sidebarThreadSortOrder: "created_at", - timestampFormat: "24-hour", - }); + sidebarProjectGroupingMode: "repository_path" as const, + sidebarProjectGroupingOverrides: { + "environment-local:/tmp/project": "separate" as const, + }, + sidebarProjectSortOrder: "manual" as const, + sidebarThreadSortOrder: "created_at" as const, + timestampFormat: "24-hour" as const, + }; + + await api.persistence.setClientSettings(clientSettings); await api.persistence.setSavedEnvironmentRegistry([ { environmentId: EnvironmentId.make("environment-local"), @@ -616,14 +615,7 @@ describe("wsApi", () => { "bearer-token", ); - await expect(api.persistence.getClientSettings()).resolves.toEqual({ - confirmThreadArchive: true, - confirmThreadDelete: false, - diffWordWrap: true, - sidebarProjectSortOrder: "manual", - sidebarThreadSortOrder: "created_at", - timestampFormat: "24-hour", - }); + await expect(api.persistence.getClientSettings()).resolves.toEqual(clientSettings); await expect(api.persistence.getSavedEnvironmentRegistry()).resolves.toEqual([ { environmentId: EnvironmentId.make("environment-local"), diff --git a/apps/web/src/logicalProject.ts b/apps/web/src/logicalProject.ts index 78944187..8f015184 100644 --- a/apps/web/src/logicalProject.ts +++ b/apps/web/src/logicalProject.ts @@ -1,19 +1,190 @@ import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; -import type { ScopedProjectRef } from "@t3tools/contracts"; +import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; +import { normalizeProjectPathForComparison } from "./lib/projectPaths"; import type { Project } from "./types"; +export interface ProjectGroupingSettings { + sidebarProjectGroupingMode: SidebarProjectGroupingMode; + sidebarProjectGroupingOverrides: Record; +} + +export type ProjectGroupingMode = SidebarProjectGroupingMode; + +function uniqueNonEmptyValues(values: ReadonlyArray): string[] { + const seen = new Set(); + const unique: string[] = []; + for (const value of values) { + const trimmed = value?.trim(); + if (!trimmed || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + unique.push(trimmed); + } + return unique; +} + +function deriveRepositoryRelativeProjectPath( + project: Pick, +): string | null { + const rootPath = project.repositoryIdentity?.rootPath?.trim(); + if (!rootPath) { + return null; + } + + const normalizedProjectPath = normalizeProjectPathForComparison(project.cwd); + const normalizedRootPath = normalizeProjectPathForComparison(rootPath); + if (normalizedProjectPath.length === 0 || normalizedRootPath.length === 0) { + return null; + } + + if (normalizedProjectPath === normalizedRootPath) { + return ""; + } + + const separator = normalizedRootPath.includes("\\") ? "\\" : "/"; + const rootPrefix = `${normalizedRootPath}${separator}`; + if (!normalizedProjectPath.startsWith(rootPrefix)) { + return null; + } + + return normalizedProjectPath.slice(rootPrefix.length).replaceAll("\\", "/"); +} + +export function derivePhysicalProjectKeyFromPath(environmentId: string, cwd: string): string { + return `${environmentId}:${normalizeProjectPathForComparison(cwd)}`; +} + +export function derivePhysicalProjectKey(project: Pick): string { + return derivePhysicalProjectKeyFromPath(project.environmentId, project.cwd); +} + +export function deriveProjectGroupingOverrideKey( + project: Pick, +): string { + return derivePhysicalProjectKey(project); +} + +export function resolveProjectGroupingMode( + project: Pick, + settings: ProjectGroupingSettings, +): SidebarProjectGroupingMode { + return ( + settings.sidebarProjectGroupingOverrides?.[deriveProjectGroupingOverrideKey(project)] ?? + settings.sidebarProjectGroupingMode + ); +} + +function deriveRepositoryScopedKey( + project: Pick, + groupingMode: SidebarProjectGroupingMode, +): string | null { + const canonicalKey = project.repositoryIdentity?.canonicalKey; + if (!canonicalKey) { + return null; + } + + if (groupingMode === "repository") { + return canonicalKey; + } + + const relativeProjectPath = deriveRepositoryRelativeProjectPath(project); + if (relativeProjectPath === null) { + return canonicalKey; + } + + return relativeProjectPath.length === 0 + ? canonicalKey + : `${canonicalKey}::${relativeProjectPath}`; +} + export function deriveLogicalProjectKey( - project: Pick, + project: Pick, + options?: { + groupingMode?: SidebarProjectGroupingMode; + }, ): string { + const groupingMode = options?.groupingMode ?? "repository"; + if (groupingMode === "separate") { + return derivePhysicalProjectKey(project); + } + return ( - project.repositoryIdentity?.canonicalKey ?? + deriveRepositoryScopedKey(project, groupingMode) ?? + derivePhysicalProjectKey(project) ?? scopedProjectKey(scopeProjectRef(project.environmentId, project.id)) ); } +export function deriveLogicalProjectKeyFromSettings( + project: Pick, + settings: ProjectGroupingSettings, +): string { + return deriveLogicalProjectKey(project, { + groupingMode: resolveProjectGroupingMode(project, settings), + }); +} + export function deriveLogicalProjectKeyFromRef( projectRef: ScopedProjectRef, - project: Pick | null | undefined, + project: Pick | null | undefined, + options?: { + groupingMode?: SidebarProjectGroupingMode; + }, ): string { - return project?.repositoryIdentity?.canonicalKey ?? scopedProjectKey(projectRef); + return project ? deriveLogicalProjectKey(project, options) : scopedProjectKey(projectRef); +} + +export function deriveProjectGroupLabel(input: { + representative: Pick; + members: ReadonlyArray>; +}): string { + const representativeName = input.representative.name.trim(); + const representativeRepositoryDisplayName = + input.representative.repositoryIdentity?.displayName?.trim() ?? null; + const representativeRepositoryName = + input.representative.repositoryIdentity?.name?.trim() ?? null; + + if ( + representativeName.length > 0 && + representativeName !== representativeRepositoryDisplayName && + representativeName !== representativeRepositoryName + ) { + return representativeName; + } + + const renamedMemberNames = uniqueNonEmptyValues( + input.members.flatMap((member) => { + const memberName = member.name.trim(); + const memberRepositoryDisplayName = member.repositoryIdentity?.displayName?.trim() ?? null; + const memberRepositoryName = member.repositoryIdentity?.name?.trim() ?? null; + if ( + memberName.length === 0 || + memberName === memberRepositoryDisplayName || + memberName === memberRepositoryName + ) { + return []; + } + return [memberName]; + }), + ); + if (renamedMemberNames.length === 1) { + return renamedMemberNames[0]!; + } + + const sharedDisplayNames = uniqueNonEmptyValues( + input.members.map((member) => member.repositoryIdentity?.displayName), + ); + if (sharedDisplayNames.length === 1) { + return sharedDisplayNames[0]!; + } + + const sharedRepositoryNames = uniqueNonEmptyValues( + input.members.map((member) => member.repositoryIdentity?.name), + ); + if (sharedRepositoryNames.length === 1) { + return sharedRepositoryNames[0]!; + } + + return representativeName; } diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index d3ca8bc9..a49512d8 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref } from "./markdown-links"; +import { + resolveMarkdownFileLinkMeta, + resolveMarkdownFileLinkTarget, + rewriteMarkdownFileUriHref, +} from "./markdown-links"; describe("rewriteMarkdownFileUriHref", () => { it("rewrites file uri hrefs into direct path hrefs", () => { @@ -57,6 +61,29 @@ describe("resolveMarkdownFileLinkTarget", () => { ); }); + it("formats tooltip display paths relative to the cwd when possible", () => { + expect( + resolveMarkdownFileLinkMeta( + "file:///C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts#L501", + "C:/Users/mike/dev-stuff/t3code", + ), + ).toMatchObject({ + displayPath: "t3code/apps/web/src/session-logic.ts:501", + }); + }); + + it("formats tooltip display paths relative to the cwd for slash-prefixed windows paths", () => { + expect( + resolveMarkdownFileLinkMeta( + "/C:/Users/mike/dev-stuff/t3code/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx", + "C:/Users/mike/dev-stuff/t3code", + ), + ).toMatchObject({ + displayPath: + "t3code/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx", + }); + }); + it("does not treat app routes as file links", () => { expect(resolveMarkdownFileLinkTarget("/chat/settings")).toBeNull(); }); diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index b5dcab01..003fb040 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -1,4 +1,5 @@ -import { resolvePathLinkTarget } from "./terminal-links"; +import { formatWorkspaceRelativePath } from "./filePathDisplay"; +import { resolvePathLinkTarget, splitPathAndPosition } from "./terminal-links"; const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; @@ -21,6 +22,15 @@ const POSIX_FILE_ROOT_PREFIXES = [ "/root/", ] as const; +export interface MarkdownFileLinkMeta { + filePath: string; + targetPath: string; + displayPath: string; + basename: string; + line?: number; + column?: number; +} + function safeDecode(value: string): string { try { return decodeURIComponent(value); @@ -143,3 +153,31 @@ export function resolveMarkdownFileLinkTarget( if (!cwd) return null; return resolvePathLinkTarget(pathWithPosition, cwd); } + +function basenameOfPath(path: string): string { + const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; +} + +export function resolveMarkdownFileLinkMeta( + href: string | undefined, + cwd?: string, +): MarkdownFileLinkMeta | null { + const targetPath = resolveMarkdownFileLinkTarget(href, cwd); + if (!targetPath) return null; + + const { path, line, column } = splitPathAndPosition(targetPath); + const parsedLine = line ? Number.parseInt(line, 10) : Number.NaN; + const parsedColumn = column ? Number.parseInt(column, 10) : Number.NaN; + const lineNumber = Number.isFinite(parsedLine) ? parsedLine : undefined; + const columnNumber = Number.isFinite(parsedColumn) ? parsedColumn : undefined; + + return { + filePath: path, + targetPath, + displayPath: formatWorkspaceRelativePath(targetPath, cwd), + basename: basenameOfPath(path), + ...(lineNumber !== undefined ? { line: lineNumber } : {}), + ...(columnNumber !== undefined ? { column: columnNumber } : {}), + }; +} diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 9605e20a..8dbcc6a6 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -182,15 +182,34 @@ export function getCustomModelOptionsByProvider( }; } +function resolveAllowedProvider( + providers: ReadonlyArray, + requestedProvider: ProviderKind, + allowedProviders?: ReadonlyArray, +): ProviderKind { + if (!allowedProviders || allowedProviders.includes(requestedProvider)) { + return resolveSelectableProvider(providers, requestedProvider); + } + + const firstAllowedEnabled = allowedProviders.find( + (provider) => providers.find((candidate) => candidate.provider === provider)?.enabled ?? true, + ); + return resolveSelectableProvider( + providers, + firstAllowedEnabled ?? allowedProviders[0] ?? requestedProvider, + ); +} + export function resolveAppModelSelectionState( settings: UnifiedSettings, providers: ReadonlyArray, + allowedProviders?: ReadonlyArray, ): ModelSelection { const selection = settings.textGenerationModelSelection ?? { provider: "codex" as const, model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, }; - const provider = resolveSelectableProvider(providers, selection.provider); + const provider = resolveAllowedProvider(providers, selection.provider, allowedProviders); // When the provider changed due to fallback (e.g. selected provider was disabled), // don't carry over the old provider's model — use the fallback provider's default. diff --git a/apps/web/src/rightPanelLayout.ts b/apps/web/src/rightPanelLayout.ts new file mode 100644 index 00000000..c94f52a9 --- /dev/null +++ b/apps/web/src/rightPanelLayout.ts @@ -0,0 +1,2 @@ +export const RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)"; +export const RIGHT_PANEL_SHEET_CLASS_NAME = "w-[min(88vw,820px)] max-w-[820px] p-0"; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 8c5046af..b0c0713f 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -22,6 +22,11 @@ import { Button } from "../components/ui/button"; import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { readLocalApi } from "../localApi"; +import { useSettings } from "../hooks/useSettings"; +import { + deriveLogicalProjectKeyFromSettings, + derivePhysicalProjectKeyFromPath, +} from "../logicalProject"; import { getServerConfigUpdatedNotification, ServerConfigUpdatedNotification, @@ -204,6 +209,10 @@ function EventRouter() { const setActiveEnvironmentId = useStore((store) => store.setActiveEnvironmentId); const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); + const projectGroupingSettings = useSettings((settings) => ({ + sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, + })); const readPathname = useEffectEvent(() => pathname); const handledBootstrapThreadIdRef = useRef(null); const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0); @@ -224,14 +233,21 @@ function EventRouter() { if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { return; } - useUiStateStore - .getState() - .setProjectExpanded( - scopedProjectKey( - scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId), - ), - true, + const bootstrapEnvironmentState = + useStore.getState().environmentStateById[payload.environment.environmentId]; + const bootstrapProject = + bootstrapEnvironmentState?.projectById[payload.bootstrapProjectId] ?? null; + const bootstrapProjectKey = + (bootstrapProject + ? deriveLogicalProjectKeyFromSettings(bootstrapProject, projectGroupingSettings) + : null) ?? + (serverConfig?.cwd + ? derivePhysicalProjectKeyFromPath(payload.environment.environmentId, serverConfig.cwd) + : null) ?? + scopedProjectKey( + scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId), ); + useUiStateStore.getState().setProjectExpanded(bootstrapProjectKey, true); if (readPathname() !== "/") { return; diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index fa3f59b9..ff20673e 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -1,5 +1,5 @@ import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router"; -import { Suspense, lazy, type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from "react"; import ChatView from "../components/ChatView"; import { threadHasStarted } from "../components/ChatView.logic"; @@ -17,45 +17,19 @@ import { stripDiffSearchParams, } from "../diffRouteSearch"; import { useMediaQuery } from "../hooks/useMediaQuery"; +import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { selectEnvironmentState, selectThreadExistsByRef, useStore } from "../store"; import { createThreadSelectorByRef } from "../storeSelectors"; import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes"; -import { Sheet, SheetPopup } from "../components/ui/sheet"; +import { RightPanelSheet } from "../components/RightPanelSheet"; import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; const DiffPanel = lazy(() => import("../components/DiffPanel")); -const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)"; const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width"; const DIFF_INLINE_DEFAULT_WIDTH = "clamp(28rem,48vw,44rem)"; const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 26 * 16; const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208; -const DiffPanelSheet = (props: { - children: ReactNode; - diffOpen: boolean; - onCloseDiff: () => void; -}) => { - return ( - { - if (!open) { - props.onCloseDiff(); - } - }} - > - - {props.children} - - - ); -}; - const DiffLoadingFallback = (props: { mode: DiffPanelMode }) => { return ( }> @@ -192,7 +166,7 @@ function ChatThreadRouteView() { const serverThreadStarted = threadHasStarted(serverThread); const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; const diffOpen = search.diff === "1"; - const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); + const shouldUseDiffSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); const currentThreadKey = threadRef ? `${threadRef.environmentId}:${threadRef.threadId}` : null; const [diffPanelMountState, setDiffPanelMountState] = useState(() => ({ threadKey: currentThreadKey, @@ -293,9 +267,9 @@ function ChatThreadRouteView() { routeKind="server" /> - + {shouldRenderDiffContent ? : null} - + ); } diff --git a/apps/web/src/rpc/serverState.test.ts b/apps/web/src/rpc/serverState.test.ts index a587fcd9..a6d5c38e 100644 --- a/apps/web/src/rpc/serverState.test.ts +++ b/apps/web/src/rpc/serverState.test.ts @@ -48,6 +48,7 @@ const defaultProviders: ReadonlyArray = [ auth: { status: "authenticated" }, checkedAt: "2026-01-01T00:00:00.000Z", models: [], + quotaSnapshots: [], slashCommands: [], skills: [], }, diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts new file mode 100644 index 00000000..8909c1bf --- /dev/null +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -0,0 +1,118 @@ +import { scopeProjectRef } from "@t3tools/client-runtime"; +import type { EnvironmentId, ScopedProjectRef } from "@t3tools/contracts"; +import { + deriveLogicalProjectKeyFromSettings, + derivePhysicalProjectKey, + deriveProjectGroupLabel, + type ProjectGroupingSettings, +} from "./logicalProject"; +import type { Project } from "./types"; + +export type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; + +export interface SidebarProjectGroupMember extends Project { + physicalProjectKey: string; + environmentLabel: string | null; +} + +export interface SidebarProjectSnapshot extends Project { + projectKey: string; + displayName: string; + groupedProjectCount: number; + environmentPresence: EnvironmentPresence; + memberProjects: readonly SidebarProjectGroupMember[]; + memberProjectRefs: readonly ScopedProjectRef[]; + remoteEnvironmentLabels: readonly string[]; +} + +export function buildPhysicalToLogicalProjectKeyMap(input: { + projects: ReadonlyArray; + settings: ProjectGroupingSettings; +}): Map { + const mapping = new Map(); + for (const project of input.projects) { + mapping.set( + derivePhysicalProjectKey(project), + deriveLogicalProjectKeyFromSettings(project, input.settings), + ); + } + return mapping; +} + +export function buildSidebarProjectSnapshots(input: { + projects: ReadonlyArray; + settings: ProjectGroupingSettings; + primaryEnvironmentId: EnvironmentId | null; + resolveEnvironmentLabel: (environmentId: EnvironmentId) => string | null; +}): SidebarProjectSnapshot[] { + const groupedMembers = new Map(); + for (const project of input.projects) { + const logicalKey = deriveLogicalProjectKeyFromSettings(project, input.settings); + const member: SidebarProjectGroupMember = { + ...project, + physicalProjectKey: derivePhysicalProjectKey(project), + environmentLabel: input.resolveEnvironmentLabel(project.environmentId), + }; + const existing = groupedMembers.get(logicalKey); + if (existing) { + existing.push(member); + } else { + groupedMembers.set(logicalKey, [member]); + } + } + + const result: SidebarProjectSnapshot[] = []; + const seen = new Set(); + for (const project of input.projects) { + const logicalKey = deriveLogicalProjectKeyFromSettings(project, input.settings); + if (seen.has(logicalKey)) { + continue; + } + seen.add(logicalKey); + + const members = groupedMembers.get(logicalKey) ?? []; + const representative = + (input.primaryEnvironmentId + ? members.find((member) => member.environmentId === input.primaryEnvironmentId) + : null) ?? members[0]; + if (!representative) { + continue; + } + + const hasLocal = + input.primaryEnvironmentId !== null && + members.some((member) => member.environmentId === input.primaryEnvironmentId); + const hasRemote = + input.primaryEnvironmentId !== null + ? members.some((member) => member.environmentId !== input.primaryEnvironmentId) + : false; + const remoteEnvironmentLabels = members + .filter( + (member) => + input.primaryEnvironmentId !== null && + member.environmentId !== input.primaryEnvironmentId, + ) + .flatMap((member) => (member.environmentLabel ? [member.environmentLabel] : [])) + .filter((label, index, labels) => labels.indexOf(label) === index); + + result.push({ + ...representative, + projectKey: logicalKey, + displayName: + members.length > 1 + ? deriveProjectGroupLabel({ + representative, + members, + }) + : representative.name, + groupedProjectCount: members.length, + environmentPresence: + hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", + memberProjects: members, + memberProjectRefs: members.map((member) => scopeProjectRef(member.environmentId, member.id)), + remoteEnvironmentLabels, + }); + } + + return result; +} diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index dc7a5365..9bb01ba0 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -746,59 +746,6 @@ describe("incremental orchestration updates", () => { expect(threadsOf(next)[0]?.messages).toHaveLength(1); }); - it("preserves copilot provider when thread.session-set arrives", () => { - const thread = makeThread({ - modelSelection: { - provider: "copilot", - model: DEFAULT_MODEL_BY_PROVIDER.copilot, - }, - }); - const state = makeState(thread); - - const next = applyOrchestrationEvent( - state, - makeEvent("thread.session-set", { - threadId: thread.id, - session: { - threadId: thread.id, - status: "running", - providerName: "copilot", - runtimeMode: "full-access", - activeTurnId: TurnId.make("turn-1"), - lastError: null, - updatedAt: "2026-02-27T00:00:02.000Z", - }, - }), - localEnvironmentId, - ); - - expect(threadsOf(next)[0]?.session?.provider).toBe("copilot"); - }); - - it("falls back to codex for invalid session providers", () => { - const thread = makeThread(); - const state = makeState(thread); - - const next = applyOrchestrationEvent( - state, - makeEvent("thread.session-set", { - threadId: thread.id, - session: { - threadId: thread.id, - status: "running", - providerName: "invalid-provider" as never, - runtimeMode: "full-access", - activeTurnId: TurnId.make("turn-1"), - lastError: null, - updatedAt: "2026-02-27T00:00:02.000Z", - }, - }), - localEnvironmentId, - ); - - expect(threadsOf(next)[0]?.session?.provider).toBe("codex"); - }); - it("does not regress latestTurn when an older turn diff completes late", () => { const state = makeState( makeThread({ diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index c24d9dca..018752ce 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1,4 +1,4 @@ -import type { +import { EnvironmentId, MessageId, OrchestrationCheckpointSummary, @@ -15,13 +15,13 @@ import type { OrchestrationThreadShell, OrchestrationThreadActivity, ProjectId, + ProviderKind, ScopedProjectRef, ScopedThreadRef, ThreadId, TurnId, } from "@t3tools/contracts"; -import { ProviderKind } from "@t3tools/contracts"; -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { resolveModelSlugForProvider } from "@t3tools/shared/model"; import { create } from "zustand"; import { @@ -131,7 +131,7 @@ function arraysEqual(left: readonly T[], right: readonly T[]): boolean { } function normalizeModelSelection< - T extends { provider: "codex" | "copilot" | "claudeAgent"; model: string }, + T extends { provider: ProviderKind; model: string } & Record, >(selection: T): T { return { ...selection, @@ -1003,7 +1003,7 @@ function toLegacySessionStatus( function toLegacyProvider(providerName: string | null): ProviderKind { if (Schema.is(ProviderKind)(providerName)) { - return providerName; + return providerName as ProviderKind; } return "codex"; } diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts index 0d40a90e..a4eeda42 100644 --- a/apps/web/src/terminal-links.ts +++ b/apps/web/src/terminal-links.ts @@ -137,7 +137,7 @@ function inferHomeFromCwd(cwd: string): string | undefined { return undefined; } -function splitPathAndPosition(value: string): { +export function splitPathAndPosition(value: string): { path: string; line: string | undefined; column: string | undefined; diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index c906bbc1..78d5a4e0 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -219,6 +219,84 @@ describe("uiStateStore pure functions", () => { expect(next.projectExpandedById[recreatedProject2]).toBe(false); }); + it("syncProjects replays persisted physical project order for grouped logical rows", () => { + const primaryProjectKey = "environment-local:/tmp/project-a"; + const secondaryProjectKey = "environment-remote:/tmp/project-a"; + const otherProjectKey = "environment-local:/tmp/project-b"; + const initialState = makeUiState({ + projectOrder: [secondaryProjectKey, primaryProjectKey, otherProjectKey], + }); + + const next = syncProjects(initialState, [ + { key: primaryProjectKey, cwd: "/tmp/project-a" }, + { key: secondaryProjectKey, cwd: "/tmp/project-a" }, + { key: otherProjectKey, cwd: "/tmp/project-b" }, + ]); + + expect(next.projectOrder).toEqual([secondaryProjectKey, primaryProjectKey, otherProjectKey]); + }); + + it("syncProjects replays grouped expansion state by logical id when cwd changes", () => { + const logicalProjectId = "github.com/t3tools/project-a"; + const previousProjectKey = "environment-local:/tmp/project-a"; + const recreatedProjectKey = "environment-local:/tmp/project-a-renamed"; + + const initialState = syncProjects( + makeUiState({ + projectExpandedById: { + [previousProjectKey]: false, + }, + projectOrder: [previousProjectKey], + }), + [{ key: previousProjectKey, logicalId: logicalProjectId, cwd: "/tmp/project-a" }], + ); + + const next = syncProjects(initialState, [ + { + key: recreatedProjectKey, + logicalId: logicalProjectId, + cwd: "/tmp/project-a-renamed", + }, + ]); + + expect(next.projectOrder).toEqual([recreatedProjectKey]); + expect(next.projectExpandedById[recreatedProjectKey]).toBe(false); + }); + + it("syncProjects replays grouped order by logical id when cwd changes", () => { + const logicalProjectA = "github.com/t3tools/project-a"; + const logicalProjectB = "github.com/t3tools/project-b"; + const previousProjectKeyA = "environment-local:/tmp/project-a"; + const previousProjectKeyB = "environment-local:/tmp/project-b"; + const recreatedProjectKeyA = "environment-remote:/tmp/project-a-renamed"; + const recreatedProjectKeyB = "environment-local:/tmp/project-b-renamed"; + + const initialState = syncProjects( + makeUiState({ + projectOrder: [previousProjectKeyB, previousProjectKeyA], + }), + [ + { key: previousProjectKeyA, logicalId: logicalProjectA, cwd: "/tmp/project-a" }, + { key: previousProjectKeyB, logicalId: logicalProjectB, cwd: "/tmp/project-b" }, + ], + ); + + const next = syncProjects(initialState, [ + { + key: recreatedProjectKeyA, + logicalId: logicalProjectA, + cwd: "/tmp/project-a-renamed", + }, + { + key: recreatedProjectKeyB, + logicalId: logicalProjectB, + cwd: "/tmp/project-b-renamed", + }, + ]); + + expect(next.projectOrder).toEqual([recreatedProjectKeyB, recreatedProjectKeyA]); + }); + it("syncProjects returns a new state when only project cwd changes", () => { const project1 = ProjectId.make("project-1"); const initialState = syncProjects( diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 7ae72320..96760e63 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -16,7 +16,9 @@ const LEGACY_PERSISTED_STATE_KEYS = [ ] as const; interface PersistedUiState { + expandedProjectLogicalIds?: string[]; expandedProjectCwds?: string[]; + projectOrderLogicalIds?: string[]; projectOrderCwds?: string[]; threadChangedFilesExpandedById?: Record>; } @@ -36,6 +38,7 @@ export interface UiState extends UiProjectState, UiThreadState {} export interface SyncProjectInput { key: string; cwd: string; + logicalId?: string | undefined; } export interface SyncThreadInput { @@ -43,6 +46,14 @@ export interface SyncThreadInput { seedVisitedAt?: string | undefined; } +function appendUniqueString(target: string[], seen: Set, value: string | undefined): void { + if (!value || value.length === 0 || seen.has(value)) { + return; + } + seen.add(value); + target.push(value); +} + const initialState: UiState = { projectExpandedById: {}, projectOrder: [], @@ -50,9 +61,12 @@ const initialState: UiState = { threadChangedFilesExpandedById: {}, }; +const persistedExpandedProjectLogicalIds = new Set(); const persistedExpandedProjectCwds = new Set(); +const persistedProjectOrderLogicalIds: string[] = []; const persistedProjectOrderCwds: string[] = []; const currentProjectCwdById = new Map(); +const currentProjectLogicalIdById = new Map(); let legacyKeysCleanedUp = false; function readPersistedState(): UiState { @@ -114,13 +128,29 @@ function sanitizePersistedThreadChangedFilesExpanded( } function hydratePersistedProjectState(parsed: PersistedUiState): void { + persistedExpandedProjectLogicalIds.clear(); persistedExpandedProjectCwds.clear(); + persistedProjectOrderLogicalIds.length = 0; persistedProjectOrderCwds.length = 0; + for (const logicalId of parsed.expandedProjectLogicalIds ?? []) { + if (typeof logicalId === "string" && logicalId.length > 0) { + persistedExpandedProjectLogicalIds.add(logicalId); + } + } for (const cwd of parsed.expandedProjectCwds ?? []) { if (typeof cwd === "string" && cwd.length > 0) { persistedExpandedProjectCwds.add(cwd); } } + for (const logicalId of parsed.projectOrderLogicalIds ?? []) { + if ( + typeof logicalId === "string" && + logicalId.length > 0 && + !persistedProjectOrderLogicalIds.includes(logicalId) + ) { + persistedProjectOrderLogicalIds.push(logicalId); + } + } for (const cwd of parsed.projectOrderCwds ?? []) { if (typeof cwd === "string" && cwd.length > 0 && !persistedProjectOrderCwds.includes(cwd)) { persistedProjectOrderCwds.push(cwd); @@ -133,16 +163,38 @@ function persistState(state: UiState): void { return; } try { - const expandedProjectCwds = Object.entries(state.projectExpandedById) - .filter(([, expanded]) => expanded) - .flatMap(([projectId]) => { - const cwd = currentProjectCwdById.get(projectId); - return cwd ? [cwd] : []; - }); - const projectOrderCwds = state.projectOrder.flatMap((projectId) => { - const cwd = currentProjectCwdById.get(projectId); - return cwd ? [cwd] : []; - }); + const expandedProjectLogicalIds: string[] = []; + const expandedProjectCwds: string[] = []; + const seenExpandedLogicalIds = new Set(); + const seenExpandedCwds = new Set(); + for (const [projectId, expanded] of Object.entries(state.projectExpandedById)) { + if (!expanded) { + continue; + } + appendUniqueString( + expandedProjectLogicalIds, + seenExpandedLogicalIds, + currentProjectLogicalIdById.get(projectId), + ); + appendUniqueString( + expandedProjectCwds, + seenExpandedCwds, + currentProjectCwdById.get(projectId), + ); + } + + const projectOrderLogicalIds: string[] = []; + const projectOrderCwds: string[] = []; + const seenOrderLogicalIds = new Set(); + const seenOrderCwds = new Set(); + for (const projectId of state.projectOrder) { + appendUniqueString( + projectOrderLogicalIds, + seenOrderLogicalIds, + currentProjectLogicalIdById.get(projectId), + ); + appendUniqueString(projectOrderCwds, seenOrderCwds, currentProjectCwdById.get(projectId)); + } const threadChangedFilesExpandedById = Object.fromEntries( Object.entries(state.threadChangedFilesExpandedById).flatMap(([threadId, turns]) => { const nextTurns = Object.fromEntries( @@ -154,7 +206,9 @@ function persistState(state: UiState): void { window.localStorage.setItem( PERSISTED_STATE_KEY, JSON.stringify({ + expandedProjectLogicalIds, expandedProjectCwds, + projectOrderLogicalIds, projectOrderCwds, threadChangedFilesExpandedById, } satisfies PersistedUiState), @@ -211,34 +265,59 @@ function nestedBooleanRecordsEqual( export function syncProjects(state: UiState, projects: readonly SyncProjectInput[]): UiState { const previousProjectCwdById = new Map(currentProjectCwdById); + const previousProjectLogicalIdById = new Map(currentProjectLogicalIdById); const previousProjectIdByCwd = new Map( [...previousProjectCwdById.entries()].map(([projectId, cwd]) => [cwd, projectId] as const), ); + const previousProjectIdByLogicalId = new Map( + [...previousProjectLogicalIdById.entries()].map( + ([projectId, logicalId]) => [logicalId, projectId] as const, + ), + ); currentProjectCwdById.clear(); + currentProjectLogicalIdById.clear(); for (const project of projects) { currentProjectCwdById.set(project.key, project.cwd); + currentProjectLogicalIdById.set(project.key, project.logicalId ?? project.key); } const cwdMappingChanged = previousProjectCwdById.size !== currentProjectCwdById.size || projects.some((project) => previousProjectCwdById.get(project.key) !== project.cwd); + const logicalIdMappingChanged = + previousProjectLogicalIdById.size !== currentProjectLogicalIdById.size || + projects.some( + (project) => + previousProjectLogicalIdById.get(project.key) !== (project.logicalId ?? project.key), + ); const nextExpandedById: Record = {}; const previousExpandedById = state.projectExpandedById; + const persistedOrderByLogicalId = new Map( + persistedProjectOrderLogicalIds.map((logicalId, index) => [logicalId, index] as const), + ); const persistedOrderByCwd = new Map( persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const), ); const mappedProjects = projects.map((project, index) => { + const logicalId = project.logicalId ?? project.key; + const previousProjectIdForLogicalId = previousProjectIdByLogicalId.get(logicalId); const previousProjectIdForCwd = previousProjectIdByCwd.get(project.cwd); const expanded = previousExpandedById[project.key] ?? + (previousProjectIdForLogicalId + ? previousExpandedById[previousProjectIdForLogicalId] + : undefined) ?? (previousProjectIdForCwd ? previousExpandedById[previousProjectIdForCwd] : undefined) ?? - (persistedExpandedProjectCwds.size > 0 - ? persistedExpandedProjectCwds.has(project.cwd) - : true); + (persistedExpandedProjectLogicalIds.size > 0 + ? persistedExpandedProjectLogicalIds.has(logicalId) + : persistedExpandedProjectCwds.size > 0 + ? persistedExpandedProjectCwds.has(project.cwd) + : true); nextExpandedById[project.key] = expanded; return { id: project.key, cwd: project.cwd, + logicalId, incomingIndex: index, }; }); @@ -246,6 +325,9 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput const nextProjectOrder = state.projectOrder.length > 0 ? (() => { + const nextProjectIdByLogicalId = new Map( + mappedProjects.map((project) => [project.logicalId, project.id] as const), + ); const nextProjectIdByCwd = new Map( mappedProjects.map((project) => [project.cwd, project.id] as const), ); @@ -256,6 +338,13 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput const matchedProjectId = (projectId in nextExpandedById ? projectId : undefined) ?? (() => { + const previousLogicalId = previousProjectLogicalIdById.get(projectId); + if (previousLogicalId) { + const nextProjectId = nextProjectIdByLogicalId.get(previousLogicalId); + if (nextProjectId) { + return nextProjectId; + } + } const previousCwd = previousProjectCwdById.get(projectId); return previousCwd ? nextProjectIdByCwd.get(previousCwd) : undefined; })(); @@ -280,8 +369,10 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput id: project.id, incomingIndex: project.incomingIndex, orderIndex: + persistedOrderByLogicalId.get(project.logicalId) ?? persistedOrderByCwd.get(project.cwd) ?? - persistedProjectOrderCwds.length + project.incomingIndex, + Math.max(persistedProjectOrderLogicalIds.length, persistedProjectOrderCwds.length) + + project.incomingIndex, })) .toSorted((left, right) => { const byOrder = left.orderIndex - right.orderIndex; @@ -295,7 +386,8 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput if ( recordsEqual(state.projectExpandedById, nextExpandedById) && projectOrdersEqual(state.projectOrder, nextProjectOrder) && - !cwdMappingChanged + !cwdMappingChanged && + !logicalIdMappingChanged ) { return state; } diff --git a/apps/web/src/wsTransport.test.ts b/apps/web/src/wsTransport.test.ts deleted file mode 100644 index 3d7f9f6e..00000000 --- a/apps/web/src/wsTransport.test.ts +++ /dev/null @@ -1,765 +0,0 @@ -import { DEFAULT_SERVER_SETTINGS, WS_METHODS } from "@t3tools/contracts"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { - __resetClientTracingForTests, - configureClientTracing, -} from "./observability/clientTracing"; -import { - getSlowRpcAckRequests, - resetRequestLatencyStateForTests, - setSlowRpcAckThresholdMsForTests, -} from "./rpc/requestLatencyState"; -import { - getWsConnectionStatus, - getWsConnectionUiState, - resetWsConnectionStateForTests, -} from "./rpc/wsConnectionState"; -import { WsTransport } from "./wsTransport"; - -type WsEventType = "open" | "message" | "close" | "error"; -type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string }; -type WsListener = (event?: WsEvent) => void; - -const sockets: MockWebSocket[] = []; - -class MockWebSocket { - static readonly CONNECTING = 0; - static readonly OPEN = 1; - static readonly CLOSING = 2; - static readonly CLOSED = 3; - - readyState = MockWebSocket.CONNECTING; - readonly sent: string[] = []; - readonly url: string; - private readonly listeners = new Map>(); - - constructor(url: string) { - this.url = url; - sockets.push(this); - } - - addEventListener(type: WsEventType, listener: WsListener) { - const listeners = this.listeners.get(type) ?? new Set(); - listeners.add(listener); - this.listeners.set(type, listeners); - } - - removeEventListener(type: WsEventType, listener: WsListener) { - this.listeners.get(type)?.delete(listener); - } - - send(data: string) { - this.sent.push(data); - } - - close(code = 1000, reason = "") { - this.readyState = MockWebSocket.CLOSED; - this.emit("close", { code, reason, type: "close" }); - } - - open() { - this.readyState = MockWebSocket.OPEN; - this.emit("open", { type: "open" }); - } - - serverMessage(data: unknown) { - this.emit("message", { data, type: "message" }); - } - - error() { - this.emit("error", { type: "error" }); - } - - private emit(type: WsEventType, event?: WsEvent) { - const listeners = this.listeners.get(type); - if (!listeners) return; - for (const listener of listeners) { - listener(event); - } - } -} - -const originalWebSocket = globalThis.WebSocket; -const originalFetch = globalThis.fetch; - -const mockEnvironment = { - environmentId: "environment-local", - label: "Local environment", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, -} as const; - -function getSocket(): MockWebSocket { - const socket = sockets.at(-1); - if (!socket) { - throw new Error("Expected a websocket instance"); - } - return socket; -} - -async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise { - const startedAt = Date.now(); - for (;;) { - try { - assertion(); - return; - } catch (error) { - if (Date.now() - startedAt >= timeoutMs) { - throw error; - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } - } -} - -beforeEach(() => { - vi.useRealTimers(); - sockets.length = 0; - resetRequestLatencyStateForTests(); - resetWsConnectionStateForTests(); - - Object.defineProperty(globalThis, "window", { - configurable: true, - value: { - location: { - origin: "http://localhost:3020", - hostname: "localhost", - port: "3020", - protocol: "http:", - }, - desktopBridge: undefined, - }, - }); - Object.defineProperty(globalThis, "navigator", { - configurable: true, - value: { onLine: true }, - }); - - globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; -}); - -afterEach(async () => { - globalThis.WebSocket = originalWebSocket; - globalThis.fetch = originalFetch; - resetRequestLatencyStateForTests(); - resetWsConnectionStateForTests(); - await __resetClientTracingForTests(); - vi.restoreAllMocks(); -}); - -describe("WsTransport", () => { - it("normalizes root websocket urls to /ws and preserves query params", async () => { - const transport = new WsTransport("ws://localhost:3020/?token=secret-token"); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - expect(getSocket().url).toBe("ws://localhost:3020/ws?token=secret-token"); - await transport.dispose(); - }); - - it("uses wss when falling back to an https page origin", async () => { - Object.assign(window.location, { - origin: "https://app.example.com", - hostname: "app.example.com", - port: "", - protocol: "https:", - }); - - const transport = new WsTransport(); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - expect(getSocket().url).toBe("wss://app.example.com/ws"); - await transport.dispose(); - }); - - it("tracks initial connection failures for the app error state", async () => { - const transport = new WsTransport("ws://localhost:3020"); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - expect(getWsConnectionStatus()).toMatchObject({ - attemptCount: 1, - phase: "connecting", - socketUrl: "ws://localhost:3020/ws", - }); - - socket.error(); - socket.close(1006, "server unavailable"); - - await waitFor(() => { - expect(getWsConnectionStatus()).toMatchObject({ - closeCode: 1006, - closeReason: "server unavailable", - hasConnected: false, - lastError: "Unable to connect to the T3 server WebSocket.", - phase: "disconnected", - }); - }); - expect(getWsConnectionUiState(getWsConnectionStatus())).toBe("error"); - - await transport.dispose(); - }); - - it("surfaces reconnecting state after a live socket disconnects", async () => { - const transport = new WsTransport("ws://localhost:3020"); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(getWsConnectionStatus()).toMatchObject({ - hasConnected: true, - phase: "connected", - }); - }); - - socket.close(1013, "try again later"); - - await waitFor(() => { - expect(getWsConnectionStatus()).toMatchObject({ - closeReason: "try again later", - hasConnected: true, - }); - }); - expect(getWsConnectionUiState(getWsConnectionStatus())).toBe("reconnecting"); - - await transport.dispose(); - }); - - it("reconnects the websocket session without disposing the transport", async () => { - const transport = new WsTransport("ws://localhost:3020"); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const firstSocket = getSocket(); - firstSocket.open(); - - await waitFor(() => { - expect(getWsConnectionStatus()).toMatchObject({ - hasConnected: true, - phase: "connected", - }); - }); - - await transport.reconnect(); - - await waitFor(() => { - expect(sockets).toHaveLength(2); - }); - - const secondSocket = getSocket(); - expect(secondSocket).not.toBe(firstSocket); - expect(firstSocket.readyState).toBe(MockWebSocket.CLOSED); - - const requestPromise = transport.request((client) => - client[WS_METHODS.serverUpsertKeybinding]({ - command: "terminal.toggle", - key: "ctrl+k", - }), - ); - - secondSocket.open(); - - await waitFor(() => { - expect(secondSocket.sent).toHaveLength(1); - }); - - const requestMessage = JSON.parse(secondSocket.sent[0] ?? "{}") as { id: string }; - secondSocket.serverMessage( - JSON.stringify({ - _tag: "Exit", - requestId: requestMessage.id, - exit: { - _tag: "Success", - value: { - keybindings: [], - issues: [], - }, - }, - }), - ); - - await expect(requestPromise).resolves.toEqual({ - keybindings: [], - issues: [], - }); - - await transport.dispose(); - }); - - it("marks unary requests as slow until the first server ack arrives", async () => { - const slowAckThresholdMs = 25; - setSlowRpcAckThresholdMsForTests(slowAckThresholdMs); - const transport = new WsTransport("ws://localhost:3020"); - - const requestPromise = transport.request((client) => - client[WS_METHODS.serverUpsertKeybinding]({ - command: "terminal.toggle", - key: "ctrl+k", - }), - ); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(socket.sent).toHaveLength(1); - }); - - const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; - await waitFor(() => { - expect(getSlowRpcAckRequests()).toMatchObject([ - { - requestId: requestMessage.id, - tag: WS_METHODS.serverUpsertKeybinding, - }, - ]); - }, 1_000); - - socket.serverMessage( - JSON.stringify({ - _tag: "Exit", - requestId: requestMessage.id, - exit: { - _tag: "Success", - value: { - keybindings: [], - issues: [], - }, - }, - }), - ); - - await expect(requestPromise).resolves.toEqual({ - keybindings: [], - issues: [], - }); - expect(getSlowRpcAckRequests()).toEqual([]); - - await transport.dispose(); - }, 5_000); - - it("sends unary RPC requests and resolves successful exits", async () => { - const transport = new WsTransport("ws://localhost:3020"); - - const requestPromise = transport.request((client) => - client[WS_METHODS.serverUpsertKeybinding]({ - command: "terminal.toggle", - key: "ctrl+k", - }), - ); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(socket.sent).toHaveLength(1); - }); - - const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { - _tag: string; - id: string; - payload: unknown; - tag: string; - }; - expect(requestMessage).toMatchObject({ - _tag: "Request", - tag: WS_METHODS.serverUpsertKeybinding, - payload: { - command: "terminal.toggle", - key: "ctrl+k", - }, - }); - - socket.serverMessage( - JSON.stringify({ - _tag: "Exit", - requestId: requestMessage.id, - exit: { - _tag: "Success", - value: { - keybindings: [], - issues: [], - }, - }, - }), - ); - - await expect(requestPromise).resolves.toEqual({ - keybindings: [], - issues: [], - }); - - await transport.dispose(); - }); - - it("delivers stream chunks to subscribers", async () => { - const transport = new WsTransport("ws://localhost:3020"); - const listener = vi.fn(); - - const unsubscribe = transport.subscribe( - (client) => client[WS_METHODS.subscribeServerLifecycle]({}), - listener, - ); - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(socket.sent).toHaveLength(1); - }); - - const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string; tag: string }; - expect(requestMessage.tag).toBe(WS_METHODS.subscribeServerLifecycle); - - const welcomeEvent = { - version: 1, - sequence: 1, - type: "welcome", - payload: { - environment: mockEnvironment, - cwd: "/tmp/workspace", - projectName: "workspace", - }, - }; - - socket.serverMessage( - JSON.stringify({ - _tag: "Chunk", - requestId: requestMessage.id, - values: [welcomeEvent], - }), - ); - - await waitFor(() => { - expect(listener).toHaveBeenCalledWith(welcomeEvent); - }); - - unsubscribe(); - await transport.dispose(); - }); - - it("re-subscribes stream listeners after the stream exits", async () => { - const transport = new WsTransport("ws://localhost:3020"); - const listener = vi.fn(); - const onResubscribe = vi.fn(); - - const unsubscribe = transport.subscribe( - (client) => client[WS_METHODS.subscribeServerLifecycle]({}), - listener, - { onResubscribe }, - ); - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(socket.sent).toHaveLength(1); - }); - - const firstRequest = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; - socket.serverMessage( - JSON.stringify({ - _tag: "Chunk", - requestId: firstRequest.id, - values: [ - { - version: 1, - sequence: 1, - type: "welcome", - payload: { - environment: mockEnvironment, - cwd: "/tmp/one", - projectName: "one", - }, - }, - ], - }), - ); - socket.serverMessage( - JSON.stringify({ - _tag: "Exit", - requestId: firstRequest.id, - exit: { - _tag: "Success", - value: null, - }, - }), - ); - - await waitFor(() => { - const nextRequest = socket.sent - .map((message) => JSON.parse(message) as { _tag?: string; id?: string }) - .find((message) => message._tag === "Request" && message.id !== firstRequest.id); - expect(nextRequest).toBeDefined(); - }); - expect(onResubscribe).toHaveBeenCalledOnce(); - - const secondRequest = socket.sent - .map((message) => JSON.parse(message) as { _tag?: string; id?: string; tag?: string }) - .find( - (message): message is { _tag: "Request"; id: string; tag: string } => - message._tag === "Request" && message.id !== firstRequest.id, - ); - if (!secondRequest) { - throw new Error("Expected a resubscribe request"); - } - expect(secondRequest.tag).toBe(WS_METHODS.subscribeServerLifecycle); - expect(secondRequest.id).not.toBe(firstRequest.id); - - const secondEvent = { - version: 1, - sequence: 2, - type: "welcome", - payload: { - environment: mockEnvironment, - cwd: "/tmp/two", - projectName: "two", - }, - }; - socket.serverMessage( - JSON.stringify({ - _tag: "Chunk", - requestId: secondRequest.id, - values: [secondEvent], - }), - ); - - await waitFor(() => { - expect(listener).toHaveBeenLastCalledWith(secondEvent); - }); - - unsubscribe(); - await transport.dispose(); - }); - - it("does not fire onResubscribe when the first stream attempt exits before any value", async () => { - const transport = new WsTransport("ws://localhost:3020"); - const listener = vi.fn(); - const onResubscribe = vi.fn(); - - const unsubscribe = transport.subscribe( - (client) => client[WS_METHODS.subscribeServerLifecycle]({}), - listener, - { onResubscribe }, - ); - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(socket.sent).toHaveLength(1); - }); - - const firstRequest = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; - socket.serverMessage( - JSON.stringify({ - _tag: "Exit", - requestId: firstRequest.id, - exit: { - _tag: "Success", - value: null, - }, - }), - ); - - await waitFor(() => { - const nextRequest = socket.sent - .map((message) => JSON.parse(message) as { _tag?: string; id?: string }) - .find((message) => message._tag === "Request" && message.id !== firstRequest.id); - expect(nextRequest).toBeDefined(); - }); - expect(onResubscribe).not.toHaveBeenCalled(); - expect(listener).not.toHaveBeenCalled(); - - unsubscribe(); - await transport.dispose(); - }); - - it("streams finite request events without re-subscribing", async () => { - const transport = new WsTransport("ws://localhost:3020"); - const listener = vi.fn(); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - const socket = getSocket(); - socket.open(); - - const requestPromise = transport.requestStream( - (client) => - client[WS_METHODS.gitRunStackedAction]({ - actionId: "action-1", - cwd: "/repo", - action: "commit", - }), - listener, - ); - - await waitFor(() => { - expect(socket.sent).toHaveLength(1); - }); - - const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; - const progressEvent = { - actionId: "action-1", - cwd: "/repo", - action: "commit", - kind: "phase_started", - phase: "commit", - label: "Committing...", - } as const; - - socket.serverMessage( - JSON.stringify({ - _tag: "Chunk", - requestId: requestMessage.id, - values: [progressEvent], - }), - ); - socket.serverMessage( - JSON.stringify({ - _tag: "Exit", - requestId: requestMessage.id, - exit: { - _tag: "Success", - value: null, - }, - }), - ); - - await expect(requestPromise).resolves.toBeUndefined(); - expect(listener).toHaveBeenCalledWith(progressEvent); - expect( - socket.sent.filter((message) => { - const parsed = JSON.parse(message) as { _tag?: string; tag?: string }; - return parsed._tag === "Request" && parsed.tag === WS_METHODS.gitRunStackedAction; - }), - ).toHaveLength(1); - await transport.dispose(); - }); - - it("closes the client scope on the transport runtime before disposing the runtime", async () => { - const callOrder: string[] = []; - let resolveClose!: () => void; - const closePromise = new Promise((resolve) => { - resolveClose = resolve; - }); - - const runtime = { - runPromise: vi.fn(async () => { - callOrder.push("close:start"); - await closePromise; - callOrder.push("close:done"); - return undefined; - }), - dispose: vi.fn(async () => { - callOrder.push("runtime:dispose"); - }), - }; - const transport = { - disposed: false, - session: { - clientScope: {} as never, - runtime, - }, - closeSession: ( - WsTransport.prototype as unknown as { - closeSession: (session: { - clientScope: unknown; - runtime: { dispose: () => Promise; runPromise: () => Promise }; - }) => Promise; - } - ).closeSession, - } as unknown as WsTransport; - - void WsTransport.prototype.dispose.call(transport); - - expect(runtime.runPromise).toHaveBeenCalledTimes(1); - expect(runtime.dispose).not.toHaveBeenCalled(); - expect((transport as unknown as { disposed: boolean }).disposed).toBe(true); - - resolveClose(); - - await waitFor(() => { - expect(runtime.dispose).toHaveBeenCalledTimes(1); - }); - - expect(callOrder).toEqual(["close:start", "close:done", "runtime:dispose"]); - }); - - it("propagates OTLP trace ids for ws transport requests when client tracing is enabled", async () => { - await configureClientTracing({ - exportIntervalMs: 10, - }); - - const transport = new WsTransport("ws://localhost:3020"); - const requestPromise = transport.request((client) => client[WS_METHODS.serverGetSettings]({})); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(socket.sent).toHaveLength(1); - }); - - const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { - id: string; - spanId?: string; - traceId?: string; - }; - expect(requestMessage.traceId).toMatch(/^[0-9a-f]{32}$/); - expect(requestMessage.spanId).toMatch(/^[0-9a-f]{16}$/); - - socket.serverMessage( - JSON.stringify({ - _tag: "Exit", - requestId: requestMessage.id, - exit: { - _tag: "Success", - value: DEFAULT_SERVER_SETTINGS, - }, - }), - ); - - await expect(requestPromise).resolves.toEqual(DEFAULT_SERVER_SETTINGS); - await transport.dispose(); - }); -}); diff --git a/apps/web/src/wsTransport.ts b/apps/web/src/wsTransport.ts deleted file mode 100644 index 0cff5438..00000000 --- a/apps/web/src/wsTransport.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { - Cause, - Duration, - Effect, - Exit, - Layer, - ManagedRuntime, - Option, - Scope, - Stream, -} from "effect"; -import { RpcClient } from "effect/unstable/rpc"; - -import { ClientTracingLive, configureClientTracing } from "./observability/clientTracing"; -import { - createWsRpcProtocolLayer, - makeWsRpcProtocolClient, - type WsRpcProtocolClient, - type WsRpcProtocolSocketUrlProvider, -} from "./rpc/protocol"; - -interface SubscribeOptions { - readonly retryDelay?: Duration.Input; - readonly onResubscribe?: () => void; -} - -interface RequestOptions { - readonly timeout?: Option.Option; -} - -const DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS = Duration.millis(250); -const NOOP: () => void = () => undefined; - -interface TransportSession { - readonly clientPromise: Promise; - readonly clientScope: Scope.Closeable; - readonly runtime: ManagedRuntime.ManagedRuntime; -} - -function formatErrorMessage(error: unknown): string { - if (error instanceof Error && error.message.trim().length > 0) { - return error.message; - } - return String(error); -} - -export class WsTransport { - private readonly tracingReady: Promise; - private readonly url: WsRpcProtocolSocketUrlProvider; - private disposed = false; - private reconnectChain: Promise = Promise.resolve(); - private session: TransportSession; - - constructor(url?: string) { - this.url = url ?? resolveDefaultSocketUrl(); - this.tracingReady = configureClientTracing(); - this.session = this.createSession(); - } - - async request( - execute: (client: WsRpcProtocolClient) => Effect.Effect, - _options?: RequestOptions, - ): Promise { - if (this.disposed) { - throw new Error("Transport disposed"); - } - - await this.tracingReady; - const session = this.session; - const client = await session.clientPromise; - return await session.runtime.runPromise(Effect.suspend(() => execute(client))); - } - - async requestStream( - connect: (client: WsRpcProtocolClient) => Stream.Stream, - listener: (value: TValue) => void, - ): Promise { - if (this.disposed) { - throw new Error("Transport disposed"); - } - - await this.tracingReady; - const session = this.session; - const client = await session.clientPromise; - await session.runtime.runPromise( - Stream.runForEach(connect(client), (value) => - Effect.sync(() => { - try { - listener(value); - } catch { - // Swallow listener errors so the stream can finish cleanly. - } - }), - ), - ); - } - - subscribe( - connect: (client: WsRpcProtocolClient) => Stream.Stream, - listener: (value: TValue) => void, - options?: SubscribeOptions, - ): () => void { - if (this.disposed) { - return () => undefined; - } - - let active = true; - let hasReceivedValue = false; - const retryDelayMs = Duration.toMillis( - Duration.fromInputUnsafe(options?.retryDelay ?? DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS), - ); - let cancelCurrentStream: () => void = NOOP; - - void (async () => { - for (;;) { - if (!active || this.disposed) { - return; - } - - try { - if (hasReceivedValue) { - try { - options?.onResubscribe?.(); - } catch { - // Swallow reconnect hook errors so the stream can recover. - } - } - - const session = this.session; - const runningStream = this.runStreamOnSession( - session, - connect, - listener, - () => active, - () => { - hasReceivedValue = true; - }, - ); - cancelCurrentStream = runningStream.cancel; - await runningStream.completed; - cancelCurrentStream = NOOP; - } catch (error) { - cancelCurrentStream = NOOP; - if (!active || this.disposed) { - return; - } - - console.warn("WebSocket RPC subscription disconnected", { - error: formatErrorMessage(error), - }); - await sleep(retryDelayMs); - } - } - })(); - - return () => { - active = false; - cancelCurrentStream(); - }; - } - - async reconnect() { - if (this.disposed) { - throw new Error("Transport disposed"); - } - - const reconnectOperation = this.reconnectChain.then(async () => { - if (this.disposed) { - throw new Error("Transport disposed"); - } - - const previousSession = this.session; - this.session = this.createSession(); - await this.closeSession(previousSession); - }); - - this.reconnectChain = reconnectOperation.catch(() => undefined); - await reconnectOperation; - } - - async dispose() { - if (this.disposed) { - return; - } - this.disposed = true; - await this.closeSession(this.session); - } - - private closeSession(session: TransportSession) { - return session.runtime.runPromise(Scope.close(session.clientScope, Exit.void)).finally(() => { - session.runtime.dispose(); - }); - } - - private createSession(): TransportSession { - const runtime = ManagedRuntime.make( - Layer.mergeAll(createWsRpcProtocolLayer(this.url), ClientTracingLive), - ); - const clientScope = runtime.runSync(Scope.make()); - return { - runtime, - clientScope, - clientPromise: runtime.runPromise(Scope.provide(clientScope)(makeWsRpcProtocolClient)), - }; - } - - private runStreamOnSession( - session: TransportSession, - connect: (client: WsRpcProtocolClient) => Stream.Stream, - listener: (value: TValue) => void, - isActive: () => boolean, - markValueReceived: () => void, - ): { - readonly cancel: () => void; - readonly completed: Promise; - } { - let resolveCompleted!: () => void; - let rejectCompleted!: (error: unknown) => void; - const completed = new Promise((resolve, reject) => { - resolveCompleted = resolve; - rejectCompleted = reject; - }); - const cancel = session.runtime.runCallback( - Effect.promise(() => this.tracingReady).pipe( - Effect.flatMap(() => Effect.promise(() => session.clientPromise)), - Effect.flatMap((client) => - Stream.runForEach(connect(client), (value) => - Effect.sync(() => { - if (!isActive()) { - return; - } - - markValueReceived(); - try { - listener(value); - } catch { - // Swallow listener errors so the stream stays live. - } - }), - ), - ), - ), - { - onExit: (exit) => { - if (Exit.isSuccess(exit)) { - resolveCompleted(); - return; - } - - rejectCompleted(Cause.squash(exit.cause)); - }, - }, - ); - - return { - cancel, - completed, - }; - } -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -function resolveDefaultSocketUrl(): string { - if (typeof window === "undefined") { - return "ws://localhost:3020"; - } - - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const host = - window.location.host || - [window.location.hostname, window.location.port].filter((part) => part.length > 0).join(":"); - - if (!host) { - throw new Error("Unable to resolve websocket host from window.location."); - } - - return `${protocol}//${host}`; -} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 178f4bcb..4dd68d72 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -2,6 +2,10 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "composite": true, + "module": "Preserve", + "moduleResolution": "Bundler", + "erasableSyntaxOnly": false, + "verbatimModuleSyntax": false, "jsx": "react-jsx", "lib": ["ES2023", "DOM", "DOM.Iterable"], "types": ["vite/client"], diff --git a/bun.lock b/bun.lock index 2a581a40..e8d18df5 100644 --- a/bun.lock +++ b/bun.lock @@ -14,7 +14,7 @@ }, "apps/desktop": { "name": "@t3tools/desktop", - "version": "0.0.17", + "version": "0.0.20", "dependencies": { "effect": "catalog:", "electron": "40.6.0", @@ -42,12 +42,12 @@ }, "apps/server": { "name": "t3", - "version": "0.0.17", + "version": "0.0.20", "bin": { "t3": "./dist/bin.mjs", }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.77", + "@anthropic-ai/claude-agent-sdk": "^0.2.111", "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", @@ -72,7 +72,7 @@ }, "apps/web": { "name": "@t3tools/web", - "version": "0.0.17", + "version": "0.0.20", "dependencies": { "@base-ui/react": "^1.2.0", "@dnd-kit/core": "^6.3.1", @@ -137,7 +137,7 @@ }, "packages/contracts": { "name": "@t3tools/contracts", - "version": "0.0.17", + "version": "0.0.20", "dependencies": { "effect": "catalog:", }, @@ -203,7 +203,7 @@ "@effect/platform-node-shared": "4.0.0-beta.45", "@effect/sql-sqlite-bun": "4.0.0-beta.45", "@effect/vitest": "4.0.0-beta.45", - "@types/bun": "^1.3.9", + "@types/bun": "^1.3.11", "@types/node": "^24.10.13", "effect": "4.0.0-beta.45", "tsdown": "^0.20.3", @@ -211,7 +211,9 @@ "vitest": "^4.0.0", }, "packages": { - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.77", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-t+R1BW3ahCFMNM7/8WJq7+Gw9KPA9Cl7UUK8fWPokJZ75cf/xwEd9MqB+MVNoQT45dJiom/wxybT7tqYPkCqyg=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.112", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-vMFoiDKlOive8p3tphpV1gQaaytOipwGJ+uw9mvvaLQUODSC2+fCdRDAY25i2Tsv+lOtxzXBKctmaDuWqZY7ig=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="], "@astrojs/check": ["@astrojs/check@0.9.8", "", { "dependencies": { "@astrojs/language-server": "^2.16.5", "chokidar": "^4.0.3", "kleur": "^4.1.5", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": "^5.0.0" }, "bin": { "astro-check": "bin/astro-check.js" } }, "sha512-LDng8446QLS5ToKjRHd3bgUdirvemVVExV7nRyJfW2wV36xuv7vDxwy5NWN9zqeSEDgg0Tv84sP+T3yEq+Zlkw=="], @@ -219,11 +221,11 @@ "@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.8.0", "", { "dependencies": { "picomatch": "^4.0.3" } }, "sha512-J56GrhEiV+4dmrGLPNOl2pZjpHXAndWVyiVDYGDuw6MWKpBSEMLdFxHzeM/6sqaknw9M+HFfHZAcvi3OfT3D/w=="], - "@astrojs/language-server": ["@astrojs/language-server@2.16.5", "", { "dependencies": { "@astrojs/compiler": "^2.13.1", "@astrojs/yaml2ts": "^0.2.3", "@jridgewell/sourcemap-codec": "^1.5.5", "@volar/kit": "~2.4.28", "@volar/language-core": "~2.4.28", "@volar/language-server": "~2.4.28", "@volar/language-service": "~2.4.28", "muggle-string": "^0.4.1", "tinyglobby": "^0.2.15", "volar-service-css": "0.0.70", "volar-service-emmet": "0.0.70", "volar-service-html": "0.0.70", "volar-service-prettier": "0.0.70", "volar-service-typescript": "0.0.70", "volar-service-typescript-twoslash-queries": "0.0.70", "volar-service-yaml": "0.0.70", "vscode-html-languageservice": "^5.6.2", "vscode-uri": "^3.1.0" }, "peerDependencies": { "prettier": "^3.0.0", "prettier-plugin-astro": ">=0.11.0" }, "optionalPeers": ["prettier", "prettier-plugin-astro"], "bin": { "astro-ls": "bin/nodeServer.js" } }, "sha512-MEQvrbuiFDEo+LCO4vvYuTr3eZ4IluZ/n4BbUv77AWAJNEj/n0j7VqTvdL1rGloNTIKZTUd46p5RwYKsxQGY8w=="], + "@astrojs/language-server": ["@astrojs/language-server@2.16.6", "", { "dependencies": { "@astrojs/compiler": "^2.13.1", "@astrojs/yaml2ts": "^0.2.3", "@jridgewell/sourcemap-codec": "^1.5.5", "@volar/kit": "~2.4.28", "@volar/language-core": "~2.4.28", "@volar/language-server": "~2.4.28", "@volar/language-service": "~2.4.28", "muggle-string": "^0.4.1", "tinyglobby": "^0.2.15", "volar-service-css": "0.0.70", "volar-service-emmet": "0.0.70", "volar-service-html": "0.0.70", "volar-service-prettier": "0.0.70", "volar-service-typescript": "0.0.70", "volar-service-typescript-twoslash-queries": "0.0.70", "volar-service-yaml": "0.0.70", "vscode-html-languageservice": "^5.6.2", "vscode-uri": "^3.1.0" }, "peerDependencies": { "prettier": "^3.0.0", "prettier-plugin-astro": ">=0.11.0" }, "optionalPeers": ["prettier", "prettier-plugin-astro"], "bin": { "astro-ls": "bin/nodeServer.js" } }, "sha512-N990lu+HSFiG57owR0XBkr02BYMgiLCshLf+4QG4v6jjSWkBeQGnzqi+E1L08xFPPJ7eEeXnxPXGLaVv5pa4Ug=="], - "@astrojs/markdown-remark": ["@astrojs/markdown-remark@7.0.0", "", { "dependencies": { "@astrojs/internal-helpers": "0.8.0", "@astrojs/prism": "4.0.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^4.0.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-jTAXHPy45L7o1ljH4jYV+ShtOHtyQUa1mGp3a5fJp1soX8lInuTJQ6ihmldHzVM4Q7QptU4SzIDIcKbBJO7sXQ=="], + "@astrojs/markdown-remark": ["@astrojs/markdown-remark@7.1.0", "", { "dependencies": { "@astrojs/internal-helpers": "0.8.0", "@astrojs/prism": "4.0.1", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "retext-smartypants": "^6.2.0", "shiki": "^4.0.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-P+HnCsu2js3BoTc8kFmu+E9gOcFeMdPris75g+Zl4sY8+bBRbSQV6xzcBDbZ27eE7yBGEGQoqjpChx+KJYIPYQ=="], - "@astrojs/prism": ["@astrojs/prism@4.0.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-NndtNPpxaGinRpRytljGBvYHpTOwHycSZ/c+lQi5cHvkqqrHKWdkPEhImlODBNmbuB+vyQUNUDXyjzt66CihJg=="], + "@astrojs/prism": ["@astrojs/prism@4.0.1", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-nksZQVjlferuWzhPsBpQ1JE5XuKAf1id1/9Hj4a9KG4+ofrlzxUUwX4YGQF/SuDiuiGKEnzopGOt38F3AnVWsQ=="], "@astrojs/telemetry": ["@astrojs/telemetry@3.3.0", "", { "dependencies": { "ci-info": "^4.2.0", "debug": "^4.4.0", "dlv": "^1.1.3", "dset": "^3.1.4", "is-docker": "^3.0.0", "is-wsl": "^3.1.0", "which-pm-runs": "^1.1.0" } }, "sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ=="], @@ -253,15 +255,15 @@ "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], - "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="], + "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], - "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], - "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], @@ -269,17 +271,19 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - "@base-ui/react": ["@base-ui/react@1.3.0", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@base-ui/utils": "0.2.6", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "tabbable": "^6.4.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-FwpKqZbPz14AITp1CVgf4AjhKPe1OeeVKSBMdgD10zbFlj3QSWelmtCMLi2+/PFZZcIm3l87G7rwtCZJwHyXWA=="], + "@base-ui/react": ["@base-ui/react@1.4.0", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@base-ui/utils": "0.2.7", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@date-fns/tz": "^1.2.0", "@types/react": "^17 || ^18 || ^19", "date-fns": "^4.0.0", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-QcqdVbr/+ba2/RAKJIV1PV6S02Q5+r6a4Eym8ndBw+ZbBILkkmQAyRxXCg/pArrHnkrGeU8goe26aw0h6eE8pg=="], - "@base-ui/utils": ["@base-ui/utils@0.2.6", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-yQ+qeuqohwhsNpoYDqqXaLllYAkPCP4vYdDrVo8FQXaAPfHWm1pG/Vm+jmGTA5JFS0BAIjookyapuJFY8F9PIw=="], + "@base-ui/utils": ["@base-ui/utils@0.2.7", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-nXYKhiL/0JafyJE8PfcflipGftOftlIwKd72rU15iZ1M5yqgg5J9P8NHU71GReDuXco5MJA/eVQqUT5WRqX9sA=="], "@blazediff/core": ["@blazediff/core@1.9.1", "", {}, "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA=="], "@capsizecss/unpack": ["@capsizecss/unpack@4.0.0", "", { "dependencies": { "fontkitten": "^1.0.0" } }, "sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA=="], - "@clack/core": ["@clack/core@1.1.0", "", { "dependencies": { "sisteransi": "^1.0.5" } }, "sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA=="], + "@clack/core": ["@clack/core@1.2.0", "", { "dependencies": { "fast-wrap-ansi": "^0.1.3", "sisteransi": "^1.0.5" } }, "sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg=="], - "@clack/prompts": ["@clack/prompts@1.1.0", "", { "dependencies": { "@clack/core": "1.1.0", "sisteransi": "^1.0.5" } }, "sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g=="], + "@clack/prompts": ["@clack/prompts@1.2.0", "", { "dependencies": { "@clack/core": "1.2.0", "fast-string-width": "^1.1.0", "fast-wrap-ansi": "^0.1.3", "sisteransi": "^1.0.5" } }, "sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w=="], + + "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], @@ -321,63 +325,63 @@ "@emmetio/stream-reader-utils": ["@emmetio/stream-reader-utils@0.1.0", "", {}, "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A=="], - "@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="], + "@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], - "@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="], + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.27.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.4", "", { "os": "android", "cpu": "arm64" }, "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.27.4", "", { "os": "android", "cpu": "x64" }, "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.4", "", { "os": "linux", "cpu": "arm" }, "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.4", "", { "os": "linux", "cpu": "x64" }, "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.4", "", { "os": "none", "cpu": "x64" }, "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], @@ -391,21 +395,23 @@ "@formkit/auto-animate": ["@formkit/auto-animate@0.9.0", "", {}, "sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA=="], - "@github/copilot": ["@github/copilot@1.0.27", "", { "optionalDependencies": { "@github/copilot-darwin-arm64": "1.0.27", "@github/copilot-darwin-x64": "1.0.27", "@github/copilot-linux-arm64": "1.0.27", "@github/copilot-linux-x64": "1.0.27", "@github/copilot-win32-arm64": "1.0.27", "@github/copilot-win32-x64": "1.0.27" }, "bin": { "copilot": "npm-loader.js" } }, "sha512-f9rlylQWzXRWyK+KkCOmC/wCKXbqQUwfwRkgT8p5JqHlTBvmJ6CS8M9aPo4ycv0aJjtbasLlkYHdrfITMA1cjg=="], + "@github/copilot": ["@github/copilot@1.0.31", "", { "optionalDependencies": { "@github/copilot-darwin-arm64": "1.0.31", "@github/copilot-darwin-x64": "1.0.31", "@github/copilot-linux-arm64": "1.0.31", "@github/copilot-linux-x64": "1.0.31", "@github/copilot-win32-arm64": "1.0.31", "@github/copilot-win32-x64": "1.0.31" }, "bin": { "copilot": "npm-loader.js" } }, "sha512-AfoVW9pHsKQGtLCpPcvQ8TOwBVF8meo5srle/8cqRSsx882CpIQx5C4uNs6zwrCtqMTo8M8D6zlDIbXkLudrXw=="], - "@github/copilot-darwin-arm64": ["@github/copilot-darwin-arm64@1.0.27", "", { "os": "darwin", "cpu": "arm64", "bin": { "copilot-darwin-arm64": "copilot" } }, "sha512-F0mzfLTGngGugSfTuDtG4MMsAK4U8u+Okcb2ftrn9ObHakz/Fzr3DOMld2T8GyzQIbhOnmOYwOk2UvOAZTq/Vg=="], + "@github/copilot-darwin-arm64": ["@github/copilot-darwin-arm64@1.0.31", "", { "os": "darwin", "cpu": "arm64", "bin": { "copilot-darwin-arm64": "copilot" } }, "sha512-DnAbe87U55/egBu/SFdMniQfhnYjfP3ZXXhrba3DZMXQI+91iRAGfPFKAsSlekl0zfNFw8toOkiafr9Hu2lHvA=="], - "@github/copilot-darwin-x64": ["@github/copilot-darwin-x64@1.0.27", "", { "os": "darwin", "cpu": "x64", "bin": { "copilot-darwin-x64": "copilot" } }, "sha512-Nn1KME4kZDsve+HOMbwvO0XfCznyZN9mzh+DRL+Q5e2CF0PIxIcJC7zP9t1/dBux/CUOyDppniUd5OVTuqbWVQ=="], + "@github/copilot-darwin-x64": ["@github/copilot-darwin-x64@1.0.31", "", { "os": "darwin", "cpu": "x64", "bin": { "copilot-darwin-x64": "copilot" } }, "sha512-mFmuYT3N1JE3zRIwCAPaXGDstL8Npa62Jey3vT4Lo003NfzQrBzvZ4ObAVMTmFQ6pRZzj39rTTKp1vLYGg+K0w=="], - "@github/copilot-linux-arm64": ["@github/copilot-linux-arm64@1.0.27", "", { "os": "linux", "cpu": "arm64", "bin": { "copilot-linux-arm64": "copilot" } }, "sha512-tg91mQQIChPDdSZCJ2e6iNIvjaOhBAT78o0jkxjF2Hn9bmNt8Iu/ywDUorugtPM+0t82PZY8AwUPkyMmuYokTQ=="], + "@github/copilot-linux-arm64": ["@github/copilot-linux-arm64@1.0.31", "", { "os": "linux", "cpu": "arm64", "bin": { "copilot-linux-arm64": "copilot" } }, "sha512-R5V7EIqn92f9YMe3zbQkW++Mw8WErDy6hA8Rr95bSJGiTVyWdj5kqPWSAPH6MLjFbC1T5cJQm/1we+QP3XO3Cw=="], - "@github/copilot-linux-x64": ["@github/copilot-linux-x64@1.0.27", "", { "os": "linux", "cpu": "x64", "bin": { "copilot-linux-x64": "copilot" } }, "sha512-E2cJLoiT5hWtuLPbVS04fxTM5F7yJL2Xazlf44PLXWPzbp5LQvQ+0SDSxnaAkRVT/DqtrtKitYMCxuDQpkdH7Q=="], + "@github/copilot-linux-x64": ["@github/copilot-linux-x64@1.0.31", "", { "os": "linux", "cpu": "x64", "bin": { "copilot-linux-x64": "copilot" } }, "sha512-LmcCGmYP9QLim/YMu5e1UlVeqCt/cuMI0fIqkdHs68h+0FGreSnHpn7nA9RbjAbQuPq9HFWeFjG5UpbAHM71Xg=="], "@github/copilot-sdk": ["@github/copilot-sdk@0.2.2", "", { "dependencies": { "@github/copilot": "^1.0.21", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" } }, "sha512-VZCqS08YlUM90bUKJ7VLeIxgTTEHtfXBo84T1IUMNvXRREX2csjPH6Z+CPw3S2468RcCLvzBXcc9LtJJTLIWFw=="], - "@github/copilot-win32-arm64": ["@github/copilot-win32-arm64@1.0.27", "", { "os": "win32", "cpu": "arm64", "bin": { "copilot-win32-arm64": "copilot.exe" } }, "sha512-/V530uFEHf3Pl6itJX4nJjx5fX9RAEIejDiqCDoKvuL8prFHGvx2CoKEz00+1QGpQHN0Z2PA0spN9a8V8o+/KA=="], + "@github/copilot-win32-arm64": ["@github/copilot-win32-arm64@1.0.31", "", { "os": "win32", "cpu": "arm64", "bin": { "copilot-win32-arm64": "copilot.exe" } }, "sha512-OlMPsQYFbl1hzrE0t703BwB9k8lQauQ4ETiiKpXSV4FxUb3DAU9PqWcy1pZoBjmLCni9h1ASQQKmPQ9ERJPm3g=="], + + "@github/copilot-win32-x64": ["@github/copilot-win32-x64@1.0.31", "", { "os": "win32", "cpu": "x64", "bin": { "copilot-win32-x64": "copilot.exe" } }, "sha512-nK8uRdlKH6TNk1cjBqEPTvzWQxwnDPgNN3M5bB7TBXL6EsaFdUJePz4tqutUPoPbSKQqo+DtmJGT3/+A30ZcXg=="], - "@github/copilot-win32-x64": ["@github/copilot-win32-x64@1.0.27", "", { "os": "win32", "cpu": "x64", "bin": { "copilot-win32-x64": "copilot.exe" } }, "sha512-ifRG64DAWG09AV6TIvkd5X08DaVMdyvrBC0Iavr75XVA1B9dKldocJAfVtQzhZTkjo/PLHRFTaAaPMNhGTfziA=="], + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], @@ -525,6 +531,8 @@ "@lexical/yjs": ["@lexical/yjs@0.41.0", "", { "dependencies": { "@lexical/offset": "0.41.0", "@lexical/selection": "0.41.0", "lexical": "0.41.0" }, "peerDependencies": { "yjs": ">=13.5.22" } }, "sha512-PaKTxSbVC4fpqUjQ7vUL9RkNF1PjL8TFl5jRe03PqoPYpE33buf3VXX6+cOUEfv9+uknSqLCPHoBS/4jN3a97w=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], @@ -539,7 +547,7 @@ "@mswjs/interceptors": ["@mswjs/interceptors@0.41.3", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], "@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="], @@ -549,8 +557,6 @@ "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], - "@oxc-project/runtime": ["@oxc-project/runtime@0.115.0", "", {}, "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ=="], - "@oxc-project/types": ["@oxc-project/types@0.112.0", "", {}, "sha512-m6RebKHIRsax2iCwVpYW2ErQwa4ywHJrE4sCK3/8JK8ZZAWOKXaRJFl/uP51gaVyyXlaS4+chU1nSCdzYf6QqQ=="], "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.40.0", "", { "os": "android", "cpu": "arm" }, "sha512-S6zd5r1w/HmqR8t0CTnGjFTBLDq2QKORPwriCHxo4xFNuhmOTABGjPaNvCJJVnrKBLsohOeiDX3YqQfJPF+FXw=="], @@ -591,51 +597,51 @@ "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.40.0", "", { "os": "win32", "cpu": "x64" }, "sha512-/Zmj0yTYSvmha6TG1QnoLqVT7ZMRDqXvFXXBQpIjteEwx9qvUYMBH2xbiOFhDeMUJkGwC3D6fdKsFtaqUvkwNA=="], - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.56.0", "", { "os": "android", "cpu": "arm" }, "sha512-IyfYPthZyiSKwAv/dLjeO18SaK8MxLI9Yss2JrRDyweQAkuL3LhEy7pwIwI7uA3KQc1Vdn20kdmj3q0oUIQL6A=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.60.0", "", { "os": "android", "cpu": "arm" }, "sha512-YdeJKaZckDQL1qa62a1aKq/goyq48aX3yOxaaWqWb4sau4Ee4IiLbamftNLU3zbePky6QsDj6thnSSzHRBjDfA=="], - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.56.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Ga5zYrzH6vc/VFxhn6MmyUnYEfy9vRpwTIks99mY3j6Nz30yYpIkWryI0QKPCgvGUtDSXVLEaMum5nA+WrNOSg=="], + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.60.0", "", { "os": "android", "cpu": "arm64" }, "sha512-7ANS7PpXCfq84xZQ8E5WPs14gwcuPcl+/8TFNXfpSu0CQBXz3cUo2fDpHT8v8HJN+Ut02eacvMAzTnc9s6X4tw=="], - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.56.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ogmbdJysnw/D4bDcpf1sPLpFThZ48lYp4aKYm10Z/6Nh1SON6NtnNhTNOlhEY296tDFItsZUz+2tgcSYqh8Eyw=="], + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.60.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pJsgd9AfplLGBm1fIr25V6V14vMrayhx4uIQvlfH7jWs2SZwSrvi3TfgfJySB8T+hvyEH8K2zXljQiUnkgUnfQ=="], - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.56.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-x8QE1h+RAtQ2g+3KPsP6Fk/tdz6zJQUv5c7fTrJxXV3GHOo+Ry5p/PsogU4U+iUZg0rj6hS+E4xi+mnwwlDCWQ=="], + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.60.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ue1aXHX49ivwflKqGJc7zcd/LeLgbhaTcDCQStgx5x06AXgjEAZmvrlMuIkWd4AL4FHQe6QJ9f33z04Cg448VQ=="], - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.56.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6G+WMZvwJpMvY7my+/SHEjb7BTk/PFbePqLpmVmUJRIsJMy/UlyYqjpuh0RCgYYkPLcnXm1rUM04kbTk8yS1Yg=="], + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.60.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YCyQzsQtusQw+gNRW9rRTifSO+Dt/+dtCl2NHoDMZqJlRTEZ/Oht9YnuporI9yiTx7+cB+eqzX3MtHHVHGIWhg=="], - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.56.0", "", { "os": "linux", "cpu": "arm" }, "sha512-YYHBsk/sl7fYwQOok+6W5lBPeUEvisznV/HZD2IfZmF3Bns6cPC3Z0vCtSEOaAWTjYWN3jVsdu55jMxKlsdlhg=="], + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-c7dxM2Zksa45Qw16i2iGY3Fti2NirJ38FrsBsKw+qcJ0OtqTsBgKJLF0xV+yLG56UH01Z8WRPgsw31e0MoRoGQ=="], - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.56.0", "", { "os": "linux", "cpu": "arm" }, "sha512-+AZK8rOUr78y8WT6XkDb04IbMRqauNV+vgT6f8ZLOH8wnpQ9i7Nol0XLxAu+Cq7Sb+J9wC0j6Km5hG8rj47/yQ=="], + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-ZWALoA42UYqBEP1Tbw9OWURgFGS1nWj2AAvLdY6ZcGx/Gj93qVCBKjcvwXMupZibYwFbi9s/rzqkZseb/6gVtQ=="], - "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.56.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-urse2SnugwJRojUkGSSeH2LPMaje5Q50yQtvtL9HFckiyeqXzoFwOAZqD5TR29R2lq7UHidfFDM9EGcchcbb8A=="], + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-tpy+1w4p9hN5CicMCxqNy6ymfRtV5ayE573vFNjp1k1TN/qhLFgflveZoE/0++RlkHikBz2vY545NWm/hp7big=="], - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.56.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-rkTZkBfJ4TYLjansjSzL6mgZOdN5IvUnSq3oNJSLwBcNvy3dlgQtpHPrRxrCEbbcp7oQ6If0tkNaqfOsphYZ9g=="], + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-eDYDXZGhQAXyn6GwtwiX/qcLS0HlOLPJ/+iiIY8RYr+3P8oKBmgKxADLlniL6FtWfE7pPk7IGN9/xvDEvDvFeg=="], - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.56.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-uqL1kMH3u69/e1CH2EJhP3CP28jw2ExLsku4o8RVAZ7fySo9zOyI2fy9pVlTAp4voBLVgzndXi3SgtdyCTa2aA=="], + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.60.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nxehly5XYBHUWI9VJX1bqCf9j/B43DaK/aS/T1fcxCpX3PA4Rm9BB54nPD1CKayT8xg6REN1ao+01hSRNgy8OA=="], - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-j0CcMBOgV6KsRaBdsebIeiy7hCjEvq2KdEsiULf2LZqAq0v1M1lWjelhCV57LxsqaIGChXFuFJ0RiFrSRHPhSg=="], + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-j1qf/NaUfOWQutjeoooNG1Q0zsK0XGmSu1uDLq3cctquRF3j7t9Hxqf/76ehCc5GEUAanth2W4Fa+XT1RFg/nw=="], - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-7VDOiL8cDG3DQ/CY3yKjbV1c4YPvc4vH8qW09Vv+5ukq3l/Kcyr6XGCd5NvxUmxqDb2vjMpM+eW/4JrEEsUetA=="], + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-YELKPRefQ/q/h3RUmeRfPCUhh2wBvgV1RyZ/F9M9u8cDyXsQW2ojv1DeWQTt466yczDITjZnIOg/s05pk7Ve2A=="], - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.56.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-JGRpX0M+ikD3WpwJ7vKcHKV6Kg0dT52BW2Eu2BupXotYeqGXBrbY+QPkAyKO6MNgKozyTNaRh3r7g+VWgyAQYQ=="], + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.60.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-JkO3C6Gki7Y6h/MiIkFKvHFOz98/YWvQ4WYbK9DLXACMP2rjULzkeGyAzorJE5S1dzLQGFgeqvN779kSFwoV1g=="], - "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.56.0", "", { "os": "linux", "cpu": "x64" }, "sha512-dNaICPvtmuxFP/VbqdofrLqdS3bM/AKJN3LMJD52si44ea7Be1cBk6NpfIahaysG9Uo+L98QKddU9CD5L8UHnQ=="], + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-XjKHdFVCpZZZSWBCKyyqCq65s2AKXykMXkjLoKYODrD+f5toLhlwsMESscu8FbgnJQ4Y/dpR/zdazsahmgBJIA=="], - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.56.0", "", { "os": "linux", "cpu": "x64" }, "sha512-pF1vOtM+GuXmbklM1hV8WMsn6tCNPvkUzklj/Ej98JhlanbmA2RB1BILgOpwSuCTRTIYx2MXssmEyQQ90QF5aA=="], + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-js29ZWIuPhNWzY8NC7KoffEMEeWG105vbmm+8EOJsC+T/jHBiKIJEUF78+F/IrgEWMMP9N0kRND4Pp75+xAhKg=="], - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.56.0", "", { "os": "none", "cpu": "arm64" }, "sha512-bp8NQ4RE6fDIFLa4bdBiOA+TAvkNkg+rslR+AvvjlLTYXLy9/uKAYLQudaQouWihLD/hgkrXIKKzXi5IXOewwg=="], + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.60.0", "", { "os": "none", "cpu": "arm64" }, "sha512-H+PUITKHk04stFpWj3x3Kg08Afp/bcXSBi0EhasR5a0Vw7StXHTzdl655PUI0fB4qdh2Wsu6Dsi+3ACxPoyQnA=="], - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.56.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-PxT4OJDfMOQBzo3OlzFb9gkoSD+n8qSBxyVq2wQSZIHFQYGEqIRTo9M0ZStvZm5fdhMqaVYpOnJvH2hUMEDk/g=="], + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.60.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-WA/yc7f7ZfCefBXVzNHn1Ztulb1EFwNBb4jMZ6pjML0zz6pHujlF3Q3jySluz3XHl/GNeMTntG1seUBWVMlMag=="], - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.56.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-PTRy6sIEPqy2x8PTP1baBNReN/BNEFmde0L+mYeHmjXE1Vlcc9+I5nsqENsB2yAm5wLkzPoTNCMY/7AnabT4/A=="], + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.60.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-33YxL1sqwYNZXtn3MD/4dno6s0xeedXOJlT1WohkVD565WvohClZUr7vwKdAk954n4xiEWJkewiCr+zLeq7AeA=="], - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.56.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ZHa0clocjLmIDr+1LwoWtxRcoYniAvERotvwKUYKhH41NVfl0Y4LNbyQkwMZzwDvKklKGvGZ5+DAG58/Ik47tQ=="], + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.60.0", "", { "os": "win32", "cpu": "x64" }, "sha512-JOro4ZcfBLamJCyfURQmOQByoorgOdx3ZjAkSqnb/CyG/i+lN3KoV5LAgk5ZAW6DPq7/Cx7n23f8DuTWXTWgyQ=="], - "@pierre/diffs": ["@pierre/diffs@1.1.0", "", { "dependencies": { "@pierre/theme": "0.0.22", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-wbxrzcmanJuHZb81iir09j42uU9AnKxXDtAuEQJbAnti5f2UfYdCQYejawuHZStFrlsMacCZLh/dDHmqvAaQCw=="], + "@pierre/diffs": ["@pierre/diffs@1.1.15", "", { "dependencies": { "@pierre/theme": "0.0.28", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-Gj863E+aSpc0H3C4cH0fQTaF/tP9yYfhnilR7/dS72qq8thqNpR3fo3jURHRtRKz6KJJ10anxcurHP7b3ZUQkw=="], - "@pierre/theme": ["@pierre/theme@0.0.22", "", {}, "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA=="], + "@pierre/theme": ["@pierre/theme@0.0.28", "", {}, "sha512-1j/H/fECBuc9dEvntdWI+l435HZapw+RCJTlqCA6BboQ5TjlnE005j/ROWutXIs8aq5OAc82JI2Kwk4A1WWBgw=="], "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], - "@preact/signals-core": ["@preact/signals-core@1.14.0", "", {}, "sha512-AowtCcCU/33lFlh1zRFf/u+12rfrhtNakj7UpaGEsmMwUKpKWMVvcktOGcwBBNiB4lWrZWc01LhiyyzVklJyaQ=="], + "@preact/signals-core": ["@preact/signals-core@1.14.1", "", {}, "sha512-vxPpfXqrwUe9lpjqfYNjAF/0RF/eFGeLgdJzdmIIZjpOnTmGmAB4BjWone562mJGMRP4frU6iZ6ei3PDsu52Ng=="], "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], @@ -653,9 +659,9 @@ "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z03/wrqau9Bicfgb3Dbs6SYTHliELk2PM2LpG2nFd+cGupTMF5kanLEcj2vuuJLLhptNyS61rtk7SOZ+lPsTUA=="], - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "ppc64" }, "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ=="], - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "s390x" }, "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "s390x" }, "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ=="], "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.3", "", { "os": "linux", "cpu": "x64" }, "sha512-iSXXZsQp08CSilff/DCTFZHSVEpEwdicV3W8idHyrByrcsRDVh9sGC3sev6d8BygSGj3vt8GvUKBPCoyMA4tgQ=="], @@ -669,7 +675,7 @@ "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.3", "", { "os": "win32", "cpu": "x64" }, "sha512-a4VUQZH7LxGbUJ3qJ/TzQG8HxdHvf+jOnqf7B7oFx1TEBm+j2KNL2zr5SQ7wHkNAcaPevF6gf9tQnVBnC4mD+A=="], - "@rolldown/plugin-babel": ["@rolldown/plugin-babel@0.2.1", "", { "dependencies": { "picomatch": "^4.0.3" }, "peerDependencies": { "@babel/core": "^7.29.0 || ^8.0.0-rc.1", "@babel/plugin-transform-runtime": "^7.29.0 || ^8.0.0-rc.1", "@babel/runtime": "^7.27.0 || ^8.0.0-rc.1", "rolldown": "^1.0.0-rc.5", "vite": "^8.0.0" }, "optionalPeers": ["@babel/plugin-transform-runtime", "@babel/runtime", "vite"] }, "sha512-pHDVHqFv26JNC8I500JZ0H4h1kvSyiE3V9gjEO9pRAgD1KrIdJvcHCokV6f7gG7Rx4vMOD11V8VUOpqdyGbKBw=="], + "@rolldown/plugin-babel": ["@rolldown/plugin-babel@0.2.3", "", { "dependencies": { "picomatch": "^4.0.4" }, "peerDependencies": { "@babel/core": "^7.29.0 || ^8.0.0-rc.1", "@babel/plugin-transform-runtime": "^7.29.0 || ^8.0.0-rc.1", "@babel/runtime": "^7.27.0 || ^8.0.0-rc.1", "rolldown": "^1.0.0-rc.5", "vite": "^8.0.0" }, "optionalPeers": ["@babel/plugin-transform-runtime", "@babel/runtime", "vite"] }, "sha512-+zEk16yGlz1F9STiRr6uG9hmIXb6nprjLczV/htGptYuLoCuxb+itZ03RKCEeOhBpDDd1NU7qF6x1VLMUp62bw=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="], @@ -713,35 +719,35 @@ "@t3tools/web": ["@t3tools/web@workspace:apps/web"], - "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], + "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="], + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="], - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="], + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg=="], - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="], + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg=="], - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="], + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw=="], - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="], + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ=="], - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="], + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ=="], - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="], + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw=="], - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="], + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag=="], - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="], + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg=="], - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="], + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ=="], - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="], + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.2", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q=="], - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="], + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ=="], - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="], + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="], - "@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="], + "@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="], "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.3", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw=="], @@ -749,27 +755,39 @@ "@tanstack/pacer": ["@tanstack/pacer@0.18.0", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0", "@tanstack/store": "^0.8.0" } }, "sha512-qhCRSFei0hokQr3xYcQXqxsRD/LKlgHCxHXtKHrQoImp4x2Zu6tUOpUGVH4y2qexIrzSu3aibQBNNfC3Eay6Mg=="], - "@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="], + "@tanstack/query-core": ["@tanstack/query-core@5.99.0", "", {}, "sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ=="], "@tanstack/react-pacer": ["@tanstack/react-pacer@0.19.4", "", { "dependencies": { "@tanstack/pacer": "0.18.0", "@tanstack/react-store": "^0.8.0" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-coj8ULAuR0qFpjAKD44gTgRuZyjxU6Xu+IX5MwwYvr4e61OtZcJshaExoOBKpCGde0Edb12jDnzzj2Im13Qm9Q=="], - "@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="], + "@tanstack/react-query": ["@tanstack/react-query@5.99.0", "", { "dependencies": { "@tanstack/query-core": "5.99.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw=="], - "@tanstack/react-router": ["@tanstack/react-router@1.167.3", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.1", "@tanstack/router-core": "1.167.3", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-1qbSy4r+O7IBdmPLlcKsjB041Gq2MMnIEAYSGIjaMZIL4duUIQnOWLw4jTfjKil/IJz/9rO5JcvrbxOG5UTSdg=="], + "@tanstack/react-router": ["@tanstack/react-router@1.168.22", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.168.15", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-W2LyfkfJtDCf//jOjZeUBWwOVl8iDRVTECpGHa2M28MT3T5/VVnjgicYNHR/ax0Filk1iU67MRjcjHheTYvK1Q=="], "@tanstack/react-store": ["@tanstack/react-store@0.8.1", "", { "dependencies": { "@tanstack/store": "0.8.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XItJt+rG8c5Wn/2L/bnxys85rBpm0BfMbhb4zmPVLXAKY9POrp1xd6IbU4PKoOI+jSEGc3vntPRfLGSgXfE2Ig=="], - "@tanstack/router-core": ["@tanstack/router-core@1.167.3", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-M/CxrTGKk1fsySJjd+Pzpbi3YLDz+cJSutDjSTMy12owWlOgHV/I6kzR0UxyaBlHraM6XgMHNA0XdgsS1fa4Nw=="], + "@tanstack/router-core": ["@tanstack/router-core@1.168.15", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^3.0.0", "seroval": "^1.5.0", "seroval-plugins": "^1.5.0" }, "bin": { "intent": "bin/intent.js" } }, "sha512-Wr0424NDtD8fT/uALobMZ9DdcfsTyXtW5IPR++7zvW8/7RaIOeaqXpVDId8ywaGtqPWLWOfaUg2zUtYtukoXYA=="], - "@tanstack/router-generator": ["@tanstack/router-generator@1.166.11", "", { "dependencies": { "@tanstack/router-core": "1.167.3", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.6", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-Q/49wxURbft1oNOvo/eVAWZq/lNLK3nBGlavqhLToAYXY6LCzfMtRlE/y3XPHzYC9pZc09u5jvBR1k1E4hyGDQ=="], + "@tanstack/router-generator": ["@tanstack/router-generator@1.166.32", "", { "dependencies": { "@babel/types": "^7.28.5", "@tanstack/router-core": "1.168.15", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "magic-string": "^0.30.21", "prettier": "^3.5.0", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-VuusKwEXcgKq+myq1JQfZogY8scTXIIeFls50dJ/UXgCXWp5n14iFreYNlg41wURcak2oA3M+t2TVfD0xUUD6g=="], - "@tanstack/router-plugin": ["@tanstack/router-plugin@1.166.12", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.167.3", "@tanstack/router-generator": "1.166.11", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.6", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.167.3", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-PYsnN6goK6zBaVo63UVKjofv69+HHMKRQXymwN55JYKguNnNR8OZ6E12icPb0Olc5uIpPiGz1YI2+rbpmNKGHA=="], + "@tanstack/router-plugin": ["@tanstack/router-plugin@1.167.22", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.168.15", "@tanstack/router-generator": "1.166.32", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.168.21", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"], "bin": { "intent": "bin/intent.js" } }, "sha512-wYPzIvBK8bcmXVUpZfSgGBXOrfBAdF4odKevz6rejio5rEd947NtKDF5R7eYdwlAOmRqYpLJnJ1QHkc5t8bY4w=="], "@tanstack/router-utils": ["@tanstack/router-utils@1.161.6", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-nRcYw+w2OEgK6VfjirYvGyPLOK+tZQz1jkYcmH5AjMamQ9PycnlxZF2aEZtPpNoUsaceX2bHptn6Ub5hGXqNvw=="], - "@tanstack/store": ["@tanstack/store@0.9.2", "", {}, "sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA=="], + "@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="], + + "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.7", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ=="], + + "@turbo/darwin-64": ["@turbo/darwin-64@2.9.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-X/56SnVXIQZBLKwniGTwEQTGmtE5brSACnKMBWpY3YafuxVYefrC2acamfjgxP7BG5w3I+6jf0UrLoSzgPcSJg=="], - "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.6", "", {}, "sha512-EGWs9yvJA821pUkwkiZLQW89CzUumHyJy8NKq229BubyoWXfDw1oWnTJYSS/hhbLiwP9+KpopjeF5wWwnCCyeQ=="], + "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aalBeSl4agT/QtYGDyf/XLajedWzUC9Vg/pm/YO6QQ93vkQ91Vz5uK1ta5RbVRDozQSz4njxUNqRNmOXDzW+qw=="], + + "@turbo/linux-64": ["@turbo/linux-64@2.9.6", "", { "os": "linux", "cpu": "x64" }, "sha512-YKi05jnNHaD7vevgYwahpzGwbsNNTwzU2c7VZdmdFm7+cGDP4oREUWSsainiMfRqjRuolQxBwRn8wf1jmu+YZA=="], + + "@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-02o/ZS69cOYEDczXvOB2xmyrtzjQ2hVFtWZK1iqxXUfzMmTjZK4UumrfNnjckSg+gqeBfnPRHa0NstA173Ik3g=="], + + "@turbo/windows-64": ["@turbo/windows-64@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-wVdQjvnBI15wB6JrA+43CtUtagjIMmX6XYO758oZHAsCNSxqRlJtdyujih0D8OCnwCRWiGWGI63zAxR0hO6s9g=="], + + "@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-1XUUyWW0W6FTSqGEhU8RHVqb2wP1SPkr7hIvBlMEwH9jr+sJQK5kqeosLJ/QaUv4ecSAd1ZhIrLoW7qslAzT4A=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], @@ -781,13 +799,13 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], + "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], - "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], @@ -809,7 +827,7 @@ "@types/nlcst": ["@types/nlcst@2.0.3", "", { "dependencies": { "@types/unist": "*" } }, "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA=="], - "@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], + "@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], @@ -829,23 +847,23 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], - "@vitest/browser": ["@vitest/browser@4.1.0", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.0", "@vitest/utils": "4.1.0", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.0.3", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.0" } }, "sha512-tG/iOrgbiHQks0ew7CdelUyNEHkv8NLrt+CqdTivIuoSnXvO7scWMn4Kqo78/UGY1NJ6Hv+vp8BvRnED/bjFdQ=="], + "@vitest/browser": ["@vitest/browser@4.1.4", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.4", "@vitest/utils": "4.1.4", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.4" } }, "sha512-TrNaY/yVOwxtrxNsDUC/wQ56xSwplpytTeRAqF/197xV/ZddxxulBsxR6TrhVMyniJmp9in8d5u0AcDaNRY30w=="], - "@vitest/browser-playwright": ["@vitest/browser-playwright@4.1.0", "", { "dependencies": { "@vitest/browser": "4.1.0", "@vitest/mocker": "4.1.0", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "playwright": "*", "vitest": "4.1.0" } }, "sha512-2RU7pZELY9/aVMLmABNy1HeZ4FX23FXGY1jRuHLHgWa2zaAE49aNW2GLzebW+BmbTZIKKyFF1QXvk7DEWViUCQ=="], + "@vitest/browser-playwright": ["@vitest/browser-playwright@4.1.4", "", { "dependencies": { "@vitest/browser": "4.1.4", "@vitest/mocker": "4.1.4", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "playwright": "*", "vitest": "4.1.4" } }, "sha512-q3PchVhZINX23Pv+RERgAtDlp6wzVkID/smOPnZ5YGWpeWUe3jMNYppeVh15j4il3G7JIJty1d1Kicpm0HSMig=="], - "@vitest/expect": ["@vitest/expect@4.1.0", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.0", "@vitest/utils": "4.1.0", "chai": "^6.2.2", "tinyrainbow": "^3.0.3" } }, "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA=="], + "@vitest/expect": ["@vitest/expect@4.1.4", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.4", "@vitest/utils": "4.1.4", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww=="], - "@vitest/mocker": ["@vitest/mocker@4.1.0", "", { "dependencies": { "@vitest/spy": "4.1.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw=="], + "@vitest/mocker": ["@vitest/mocker@4.1.4", "", { "dependencies": { "@vitest/spy": "4.1.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg=="], - "@vitest/pretty-format": ["@vitest/pretty-format@4.1.0", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A=="], + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.4", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A=="], - "@vitest/runner": ["@vitest/runner@4.1.0", "", { "dependencies": { "@vitest/utils": "4.1.0", "pathe": "^2.0.3" } }, "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ=="], + "@vitest/runner": ["@vitest/runner@4.1.4", "", { "dependencies": { "@vitest/utils": "4.1.4", "pathe": "^2.0.3" } }, "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ=="], - "@vitest/snapshot": ["@vitest/snapshot@4.1.0", "", { "dependencies": { "@vitest/pretty-format": "4.1.0", "@vitest/utils": "4.1.0", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg=="], + "@vitest/snapshot": ["@vitest/snapshot@4.1.4", "", { "dependencies": { "@vitest/pretty-format": "4.1.4", "@vitest/utils": "4.1.4", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw=="], - "@vitest/spy": ["@vitest/spy@4.1.0", "", {}, "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw=="], + "@vitest/spy": ["@vitest/spy@4.1.4", "", {}, "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ=="], - "@vitest/utils": ["@vitest/utils@4.1.0", "", { "dependencies": { "@vitest/pretty-format": "4.1.0", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" } }, "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw=="], + "@vitest/utils": ["@vitest/utils@4.1.4", "", { "dependencies": { "@vitest/pretty-format": "4.1.4", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw=="], "@volar/kit": ["@volar/kit@2.4.28", "", { "dependencies": { "@volar/language-service": "2.4.28", "@volar/typescript": "2.4.28", "typesafe-path": "^0.2.2", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "typescript": "*" } }, "sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg=="], @@ -867,12 +885,16 @@ "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -891,9 +913,7 @@ "ast-kit": ["ast-kit@3.0.0-beta.1", "", { "dependencies": { "@babel/parser": "^8.0.0-beta.4", "estree-walker": "^3.0.3", "pathe": "^2.0.3" } }, "sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw=="], - "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], - - "astro": ["astro@6.0.5", "", { "dependencies": { "@astrojs/compiler": "^3.0.0", "@astrojs/internal-helpers": "0.8.0", "@astrojs/markdown-remark": "7.0.0", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.0.1", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^1.1.1", "devalue": "^5.6.3", "diff": "^8.0.3", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^2.0.0", "esbuild": "^0.27.3", "flattie": "^1.1.1", "fontace": "~0.4.1", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "js-yaml": "^4.1.1", "magic-string": "^0.30.21", "magicast": "^0.5.2", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "obug": "^2.1.1", "p-limit": "^7.3.0", "p-queue": "^9.1.0", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.3", "rehype": "^13.0.2", "semver": "^7.7.4", "shiki": "^4.0.0", "smol-toml": "^1.6.0", "svgo": "^4.0.0", "tinyclip": "^0.1.6", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", "unifont": "~0.7.4", "unist-util-visit": "^5.1.0", "unstorage": "^1.17.4", "vfile": "^6.0.3", "vite": "^7.3.1", "vitefu": "^1.1.2", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", "zod": "^4.3.6" }, "optionalDependencies": { "sharp": "^0.34.0" }, "bin": { "astro": "bin/astro.mjs" } }, "sha512-JnLCwaoCaRXIHuIB8yNztJrd7M3hXrHUMAoQmeXtEBKxRu/738REhaCZ1lapjrS9HlpHsWTu3JUXTERB/0PA7g=="], + "astro": ["astro@6.1.7", "", { "dependencies": { "@astrojs/compiler": "^3.0.1", "@astrojs/internal-helpers": "0.8.0", "@astrojs/markdown-remark": "7.1.0", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.1.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^1.1.1", "devalue": "^5.6.3", "diff": "^8.0.3", "dset": "^3.1.4", "es-module-lexer": "^2.0.0", "esbuild": "^0.27.3", "flattie": "^1.1.1", "fontace": "~0.4.1", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "js-yaml": "^4.1.1", "magic-string": "^0.30.21", "magicast": "^0.5.2", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "obug": "^2.1.1", "p-limit": "^7.3.0", "p-queue": "^9.1.0", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.3", "rehype": "^13.0.2", "semver": "^7.7.4", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "svgo": "^4.0.1", "tinyclip": "^0.1.12", "tinyexec": "^1.0.4", "tinyglobby": "^0.2.15", "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", "unifont": "~0.7.4", "unist-util-visit": "^5.1.0", "unstorage": "^1.17.4", "vfile": "^6.0.3", "vite": "^7.3.1", "vitefu": "^1.1.2", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", "zod": "^4.3.6" }, "optionalDependencies": { "sharp": "^0.34.0" }, "bin": { "astro": "bin/astro.mjs" } }, "sha512-pvZysIUV2C2nRv8N7cXAkCLcfDQz/axAxF09SqiTz1B+xnvbhy6KzL2I6J15ZBXk8k0TfMD75dJ151QyQmAqZA=="], "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], @@ -903,35 +923,43 @@ "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.8", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "birpc": ["birpc@4.0.0", "", {}, "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw=="], + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], "builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="], - "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], "cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="], "cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="], - "caniuse-lite": ["caniuse-lite@1.0.30001779", "", {}, "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001788", "", {}, "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -971,11 +999,21 @@ "common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="], + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], - "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="], + "cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="], @@ -989,6 +1027,8 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], @@ -1007,10 +1047,12 @@ "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], - "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], @@ -1019,11 +1061,11 @@ "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], - "devalue": ["devalue@5.6.4", "", {}, "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="], + "devalue": ["devalue@5.7.1", "", {}, "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA=="], "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], - "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], @@ -1039,11 +1081,15 @@ "dts-resolver": ["dts-resolver@2.1.3", "", { "peerDependencies": { "oxc-resolver": ">=11.0.0" }, "optionalPeers": ["oxc-resolver"] }, "sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + "effect": ["effect@4.0.0-beta.45", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-vvNrUWqnzBIW1hRMa+zw0CLRW6HLgdu7hQ6K7PT/rS+UY/73Ma11O+Oi9oc9zwL8KcN37M47UDseAdlF0bGNWw=="], "electron": ["electron@40.6.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-ett8W+yOFGDuM0vhJMamYSkrbV3LoaffzJd9GfjI96zRAxyrNqUSKqBpf/WGbQCweDxX2pkUCUfrv4wwKpsFZA=="], - "electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="], + "electron-to-chromium": ["electron-to-chromium@1.5.340", "", {}, "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA=="], "electron-updater": ["electron-updater@6.8.3", "", { "dependencies": { "builder-util-runtime": "9.5.1", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ=="], @@ -1053,9 +1099,11 @@ "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], - "enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="], + "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], @@ -1067,40 +1115,60 @@ "es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="], + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], - "esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="], + "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], - "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.3.2", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], - "fast-check": ["fast-check@4.6.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-h7H6Dm0Fy+H4ciQYFxFjXnXkzR2kr9Fb22c0UBpHnm59K2zpr2t13aPTHlltFiNT6zuxp6HMPAVVvgur4BLdpA=="], + "fast-check": ["fast-check@4.7.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-string-truncated-width": ["fast-string-truncated-width@1.2.1", "", {}, "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow=="], + + "fast-string-width": ["fast-string-width@1.1.0", "", { "dependencies": { "fast-string-truncated-width": "^1.2.0" } }, "sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ=="], + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "fast-wrap-ansi": ["fast-wrap-ansi@0.1.6", "", { "dependencies": { "fast-string-width": "^1.1.0" } }, "sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w=="], + "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], "flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="], @@ -1109,17 +1177,27 @@ "fontkitten": ["fontkitten@1.0.3", "", { "dependencies": { "tiny-inflate": "^1.0.3" } }, "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw=="], + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], - "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], + "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], @@ -1135,12 +1213,16 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "graphql": ["graphql@16.13.1", "", {}, "sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ=="], + "graphql": ["graphql@16.13.2", "", {}, "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig=="], - "h3": ["h3@1.15.6", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.5", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-oi15ESLW5LRthZ+qPCi5GNasY/gvynSKUQxgiovrY63bPAtG59wtM+LSrlcwvOHAXzGrXVLnI97brbkdPF9WoQ=="], + "h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="], "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], @@ -1165,7 +1247,9 @@ "headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="], - "hookable": ["hookable@6.1.0", "", {}, "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw=="], + "hono": ["hono@4.12.14", "", {}, "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w=="], + + "hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="], "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], @@ -1175,15 +1259,25 @@ "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "import-without-cache": ["import-without-cache@0.2.5", "", {}, "sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], - "ioredis": ["ioredis@5.10.0", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA=="], + "ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="], + + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], "iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="], @@ -1213,14 +1307,20 @@ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], - "isbot": ["isbot@5.1.36", "", {}, "sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ=="], + "isbot": ["isbot@5.1.39", "", {}, "sha512-obH0yYahGXdzNxo+djmHhBYThUKDkz565cxkIlt2L9hXfv1NlaLKoDBHo6KxXsYrIXx2RK3x5vY36CfZcobxEw=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -1229,8 +1329,12 @@ "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], @@ -1287,7 +1391,7 @@ "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], - "lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="], + "lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="], "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], @@ -1301,6 +1405,8 @@ "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mdast-util-definitions": ["mdast-util-definitions@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ=="], "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], @@ -1335,6 +1441,10 @@ "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], @@ -1393,6 +1503,10 @@ "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="], + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], @@ -1413,6 +1527,8 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "neotraverse": ["neotraverse@0.6.18", "", {}, "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA=="], "nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="], @@ -1427,7 +1543,7 @@ "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], - "node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="], + "node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], @@ -1435,6 +1551,10 @@ "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], @@ -1443,6 +1563,8 @@ "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], @@ -1455,13 +1577,13 @@ "oxfmt": ["oxfmt@0.40.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.40.0", "@oxfmt/binding-android-arm64": "0.40.0", "@oxfmt/binding-darwin-arm64": "0.40.0", "@oxfmt/binding-darwin-x64": "0.40.0", "@oxfmt/binding-freebsd-x64": "0.40.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.40.0", "@oxfmt/binding-linux-arm-musleabihf": "0.40.0", "@oxfmt/binding-linux-arm64-gnu": "0.40.0", "@oxfmt/binding-linux-arm64-musl": "0.40.0", "@oxfmt/binding-linux-ppc64-gnu": "0.40.0", "@oxfmt/binding-linux-riscv64-gnu": "0.40.0", "@oxfmt/binding-linux-riscv64-musl": "0.40.0", "@oxfmt/binding-linux-s390x-gnu": "0.40.0", "@oxfmt/binding-linux-x64-gnu": "0.40.0", "@oxfmt/binding-linux-x64-musl": "0.40.0", "@oxfmt/binding-openharmony-arm64": "0.40.0", "@oxfmt/binding-win32-arm64-msvc": "0.40.0", "@oxfmt/binding-win32-ia32-msvc": "0.40.0", "@oxfmt/binding-win32-x64-msvc": "0.40.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-g0C3I7xUj4b4DcagevM9kgH6+pUHytikxUcn3/VUkvzTNaaXBeyZqb7IBsHwojeXm4mTBEC/aBjBTMVUkZwWUQ=="], - "oxlint": ["oxlint@1.56.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.56.0", "@oxlint/binding-android-arm64": "1.56.0", "@oxlint/binding-darwin-arm64": "1.56.0", "@oxlint/binding-darwin-x64": "1.56.0", "@oxlint/binding-freebsd-x64": "1.56.0", "@oxlint/binding-linux-arm-gnueabihf": "1.56.0", "@oxlint/binding-linux-arm-musleabihf": "1.56.0", "@oxlint/binding-linux-arm64-gnu": "1.56.0", "@oxlint/binding-linux-arm64-musl": "1.56.0", "@oxlint/binding-linux-ppc64-gnu": "1.56.0", "@oxlint/binding-linux-riscv64-gnu": "1.56.0", "@oxlint/binding-linux-riscv64-musl": "1.56.0", "@oxlint/binding-linux-s390x-gnu": "1.56.0", "@oxlint/binding-linux-x64-gnu": "1.56.0", "@oxlint/binding-linux-x64-musl": "1.56.0", "@oxlint/binding-openharmony-arm64": "1.56.0", "@oxlint/binding-win32-arm64-msvc": "1.56.0", "@oxlint/binding-win32-ia32-msvc": "1.56.0", "@oxlint/binding-win32-x64-msvc": "1.56.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.15.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-Q+5Mj5PVaH/R6/fhMMFzw4dT+KPB+kQW4kaL8FOIq7tfhlnEVp6+3lcWqFruuTNlUo9srZUW3qH7Id4pskeR6g=="], + "oxlint": ["oxlint@1.60.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.60.0", "@oxlint/binding-android-arm64": "1.60.0", "@oxlint/binding-darwin-arm64": "1.60.0", "@oxlint/binding-darwin-x64": "1.60.0", "@oxlint/binding-freebsd-x64": "1.60.0", "@oxlint/binding-linux-arm-gnueabihf": "1.60.0", "@oxlint/binding-linux-arm-musleabihf": "1.60.0", "@oxlint/binding-linux-arm64-gnu": "1.60.0", "@oxlint/binding-linux-arm64-musl": "1.60.0", "@oxlint/binding-linux-ppc64-gnu": "1.60.0", "@oxlint/binding-linux-riscv64-gnu": "1.60.0", "@oxlint/binding-linux-riscv64-musl": "1.60.0", "@oxlint/binding-linux-s390x-gnu": "1.60.0", "@oxlint/binding-linux-x64-gnu": "1.60.0", "@oxlint/binding-linux-x64-musl": "1.60.0", "@oxlint/binding-openharmony-arm64": "1.60.0", "@oxlint/binding-win32-arm64-msvc": "1.60.0", "@oxlint/binding-win32-ia32-msvc": "1.60.0", "@oxlint/binding-win32-x64-msvc": "1.60.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.18.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-tnRzTWiWJ9pg3ftRWnD0+Oqh78L6ZSwcEudvCZaER0PIqiAnNyXj5N1dPwjmNpDalkKS9m/WMLN1CTPUBPmsgw=="], "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="], "p-limit": ["p-limit@7.3.0", "", { "dependencies": { "yocto-queue": "^1.2.1" } }, "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw=="], - "p-queue": ["p-queue@9.1.0", "", { "dependencies": { "eventemitter3": "^5.0.1", "p-timeout": "^7.0.0" } }, "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw=="], + "p-queue": ["p-queue@9.1.2", "", { "dependencies": { "eventemitter3": "^5.0.1", "p-timeout": "^7.0.0" } }, "sha512-ktsDOALzTYTWWF1PbkNVg2rOt+HaOaMWJMUnt7T3qf5tvZ1L8dBW3tObzprBcXNMKkwj+yFSLqHso0x+UFcJXw=="], "p-timeout": ["p-timeout@7.0.1", "", {}, "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg=="], @@ -1473,8 +1595,12 @@ "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], @@ -1485,17 +1611,19 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], - "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], + "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], + + "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], - "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + "postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], - "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], + "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], @@ -1503,9 +1631,13 @@ "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], - "pure-rand": ["pure-rand@8.1.0", "", {}, "sha512-53B3MB8wetRdD6JZ4W/0gDKaOvKwuXrEmV1auQc0hASWge8rieKV4PCCVNVbJ+i24miiubb4c/B+dg8Ho0ikYw=="], + "pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="], + + "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], @@ -1513,9 +1645,13 @@ "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="], - "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], - "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], + + "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="], "react-error-boundary": ["react-error-boundary@6.1.1", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w=="], @@ -1523,8 +1659,6 @@ "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], - "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], @@ -1583,9 +1717,13 @@ "rolldown-plugin-dts": ["rolldown-plugin-dts@0.22.5", "", { "dependencies": { "@babel/generator": "8.0.0-rc.2", "@babel/helper-validator-identifier": "8.0.0-rc.2", "@babel/parser": "8.0.0-rc.2", "@babel/types": "8.0.0-rc.2", "ast-kit": "^3.0.0-beta.1", "birpc": "^4.0.0", "dts-resolver": "^2.1.3", "get-tsconfig": "^4.13.6", "obug": "^2.1.1" }, "peerDependencies": { "@ts-macro/tsc": "^0.3.6", "@typescript/native-preview": ">=7.0.0-dev.20250601.1", "rolldown": "^1.0.0-rc.3", "typescript": "^5.0.0 || ^6.0.0-beta", "vue-tsc": "~3.2.0" }, "optionalPeers": ["@ts-macro/tsc", "@typescript/native-preview", "typescript", "vue-tsc"] }, "sha512-M/HXfM4cboo+jONx9Z0X+CUf3B5tCi7ni+kR5fUW50Fp9AlZk0oVLesibGWgCXDKFp5lpgQ9yhKoImUFjl3VZw=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], - "sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], @@ -1593,16 +1731,34 @@ "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], - "seroval": ["seroval@1.5.1", "", {}, "sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA=="], + "seroval": ["seroval@1.5.2", "", {}, "sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q=="], + + "seroval-plugins": ["seroval-plugins@1.5.2", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg=="], - "seroval-plugins": ["seroval-plugins@1.5.1", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw=="], + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="], + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], @@ -1611,9 +1767,7 @@ "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], - "smol-toml": ["smol-toml@1.6.0", "", {}, "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw=="], - - "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -1627,7 +1781,7 @@ "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - "std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="], + "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], "strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="], @@ -1653,36 +1807,34 @@ "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], - "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], + "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], - "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + "tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="], "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], - "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], - "tiny-typed-emitter": ["tiny-typed-emitter@2.1.0", "", {}, "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA=="], - "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="], - "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyclip": ["tinyclip@0.1.12", "", {}, "sha512-Ae3OVUqifDw0wBriIBS7yVaW44Dp6eSHQcyq4Igc7eN2TJH/2YsicswaW+J/OuMvhpDPOKEgpAZCjkb4hpoyeA=="], - "tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="], + "tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="], - "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], - "tldts": ["tldts@7.0.26", "", { "dependencies": { "tldts-core": "^7.0.26" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ=="], + "tldts": ["tldts@7.0.28", "", { "dependencies": { "tldts-core": "^7.0.28" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw=="], - "tldts-core": ["tldts-core@7.0.26", "", {}, "sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew=="], + "tldts-core": ["tldts-core@7.0.28", "", {}, "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], @@ -1695,6 +1847,8 @@ "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], "tsdown": ["tsdown@0.20.3", "", { "dependencies": { "ansis": "^4.2.0", "cac": "^6.7.14", "defu": "^6.1.4", "empathic": "^2.0.0", "hookable": "^6.0.1", "import-without-cache": "^0.2.5", "obug": "^2.1.1", "picomatch": "^4.0.3", "rolldown": "1.0.0-rc.3", "rolldown-plugin-dts": "^0.22.1", "semver": "^7.7.3", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tree-kill": "^1.2.2", "unconfig-core": "^7.4.2", "unrun": "^0.2.27" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@vitejs/devtools": "*", "publint": "^0.3.0", "typescript": "^5.0.0", "unplugin-lightningcss": "^0.4.0", "unplugin-unused": "^0.5.0" }, "optionalPeers": ["@arethetypeswrong/core", "@vitejs/devtools", "publint", "typescript", "unplugin-lightningcss", "unplugin-unused"], "bin": { "tsdown": "dist/run.mjs" } }, "sha512-qWOUXSbe4jN8JZEgrkc/uhJpC8VN2QpNu3eZkBWwNuTEjc/Ik1kcc54ycfcQ5QPRHeu9OQXaLfCI3o7pEJgB2w=="], @@ -1703,21 +1857,11 @@ "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], - "turbo": ["turbo@2.8.17", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.17", "turbo-darwin-arm64": "2.8.17", "turbo-linux-64": "2.8.17", "turbo-linux-arm64": "2.8.17", "turbo-windows-64": "2.8.17", "turbo-windows-arm64": "2.8.17" }, "bin": { "turbo": "bin/turbo" } }, "sha512-YwPsNSqU2f/RXU/+Kcb7cPkPZARxom4+me7LKEdN5jsvy2tpfze3zDZ4EiGrJnvOm9Avu9rK0aaYsP7qZ3iz7A=="], - - "turbo-darwin-64": ["turbo-darwin-64@2.8.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZFkv2hv7zHpAPEXBF6ouRRXshllOavYc+jjcrYyVHvxVTTwJWsBZwJ/gpPzmOKGvkSjsEyDO5V6aqqtZzwVF+Q=="], + "turbo": ["turbo@2.9.6", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.6", "@turbo/darwin-arm64": "2.9.6", "@turbo/linux-64": "2.9.6", "@turbo/linux-arm64": "2.9.6", "@turbo/windows-64": "2.9.6", "@turbo/windows-arm64": "2.9.6" }, "bin": { "turbo": "bin/turbo" } }, "sha512-+v2QJey7ZUeUiuigkU+uFfklvNUyPI2VO2vBpMYJA+a1hKFLFiKtUYlRHdb3P9CrAvMzi0upbjI4WT+zKtqkBg=="], - "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5DXqhQUt24ycEryXDfMNKEkW5TBHs+QmU23a2qxXwwFDaJsWcPo2obEhBxxdEPOv7qmotjad+09RGeWCcJ9JDw=="], + "type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="], - "turbo-linux-64": ["turbo-linux-64@2.8.17", "", { "os": "linux", "cpu": "x64" }, "sha512-KLUbz6w7F73D/Ihh51hVagrKR0/CTsPEbRkvXLXvoND014XJ4BCrQUqSxlQ4/hu+nqp1v5WlM85/h3ldeyujuA=="], - - "turbo-linux-arm64": ["turbo-linux-arm64@2.8.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-pJK67XcNJH40lTAjFu7s/rUlobgVXyB3A3lDoq+/JccB3hf+SysmkpR4Itlc93s8LEaFAI4mamhFuTV17Z6wOg=="], - - "turbo-windows-64": ["turbo-windows-64@2.8.17", "", { "os": "win32", "cpu": "x64" }, "sha512-EijeQ6zszDMmGZLP2vT2RXTs/GVi9rM0zv2/G4rNu2SSRSGFapgZdxgW4b5zUYLVaSkzmkpWlGfPfj76SW9yUg=="], - - "turbo-windows-arm64": ["turbo-windows-arm64@2.8.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-crpfeMPkfECd4V1PQ/hMoiyVcOy04+bWedu/if89S15WhOalHZ2BYUi6DOJhZrszY+mTT99OwpOsj4wNfb/GHQ=="], - - "type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="], + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], "typesafe-path": ["typesafe-path@0.2.2", "", {}, "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA=="], @@ -1733,7 +1877,7 @@ "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], - "undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="], + "undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], @@ -1761,11 +1905,13 @@ "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], - "unrun": ["unrun@0.2.32", "", { "dependencies": { "rolldown": "1.0.0-rc.9" }, "peerDependencies": { "synckit": "^0.11.11" }, "optionalPeers": ["synckit"], "bin": { "unrun": "dist/cli.mjs" } }, "sha512-opd3z6791rf281JdByf0RdRQrpcc7WyzqittqIXodM/5meNWdTwrVxeyzbaCp4/Rgls/um14oUaif1gomO8YGg=="], + "unrun": ["unrun@0.2.36", "", { "dependencies": { "rolldown": "1.0.0-rc.16" }, "peerDependencies": { "synckit": "^0.11.11" }, "optionalPeers": ["synckit"], "bin": { "unrun": "dist/cli.mjs" } }, "sha512-ICAGv44LHSKjCdI4B4rk99lJLHXBweutO4MUwu3cavMlYtXID0Tn5e1Kwe/Uj6BSAuHHXfi1JheFVCYhcXHfAg=="], - "unstorage": ["unstorage@1.17.4", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.5", "lru-cache": "^11.2.0", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-fHK0yNg38tBiJKp/Vgsq4j0JEsCmgqH58HAn707S7zGkArbZsVr/CwINoi+nh3h98BRCwKvx1K3Xg9u3VV83sw=="], + "unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="], "until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="], @@ -1775,19 +1921,21 @@ "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], - "vite": ["vite@8.0.0", "", { "dependencies": { "@oxc-project/runtime": "0.115.0", "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.9", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.0.0-alpha.31", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q=="], + "vite": ["vite@8.0.8", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="], - "vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="], + "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], - "vitest": ["vitest@4.1.0", "", { "dependencies": { "@vitest/expect": "4.1.0", "@vitest/mocker": "4.1.0", "@vitest/pretty-format": "4.1.0", "@vitest/runner": "4.1.0", "@vitest/snapshot": "4.1.0", "@vitest/spy": "4.1.0", "@vitest/utils": "4.1.0", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.0", "@vitest/browser-preview": "4.1.0", "@vitest/browser-webdriverio": "4.1.0", "@vitest/ui": "4.1.0", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw=="], + "vitest": ["vitest@4.1.4", "", { "dependencies": { "@vitest/expect": "4.1.4", "@vitest/mocker": "4.1.4", "@vitest/pretty-format": "4.1.4", "@vitest/runner": "4.1.4", "@vitest/snapshot": "4.1.4", "@vitest/spy": "4.1.4", "@vitest/utils": "4.1.4", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.4", "@vitest/browser-preview": "4.1.4", "@vitest/browser-webdriverio": "4.1.4", "@vitest/coverage-istanbul": "4.1.4", "@vitest/coverage-v8": "4.1.4", "@vitest/ui": "4.1.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg=="], - "vitest-browser-react": ["vitest-browser-react@2.1.0", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "vitest": "^4.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/cOVQ+dZojhavfsbHjcfzB3zrUxG39HIbGdvK9vSBdGc8b8HRu5Bql0p8aXtKw4sb8/E8n5XEncQxvqHtfjjag=="], + "vitest-browser-react": ["vitest-browser-react@2.2.0", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "vitest": "^4.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oY3KM6305kwJMa6nHo92vVtkOsih7mjEf12dLKuphaF+9ywWPEc+qanIBd394SZ6m5LadVEaG6dicvvizOzmjA=="], "volar-service-css": ["volar-service-css@0.0.70", "", { "dependencies": { "vscode-css-languageservice": "^6.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-K1qyOvBpE3rzdAv3e4/6Rv5yizrYPy5R/ne3IWCAzLBuMO4qBMV3kSqWzj6KUVe6S0AnN6wxF7cRkiaKfYMYJw=="], @@ -1827,6 +1975,8 @@ "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "which-pm-runs": ["which-pm-runs@1.1.0", "", {}, "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA=="], "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], @@ -1835,7 +1985,7 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], @@ -1845,7 +1995,7 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], "yaml-language-server": ["yaml-language-server@1.20.0", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "prettier": "^3.5.0", "request-light": "^0.5.7", "vscode-json-languageservice": "4.1.8", "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-uri": "^3.0.2", "yaml": "2.7.1" }, "bin": { "yaml-language-server": "bin/yaml-language-server" } }, "sha512-qhjK/bzSRZ6HtTvgeFvjNPJGWdZ0+x5NREV/9XZWFjIGezew2b4r5JPy66IfOhd5OA7KeFwk1JfmEbnTvev0cA=="], @@ -1863,6 +2013,8 @@ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "zustand": ["zustand@5.0.12", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], @@ -1895,9 +2047,11 @@ "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + "@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + "@pierre/diffs/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], - "@rolldown/plugin-babel/rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="], + "@rolldown/plugin-babel/rolldown": ["rolldown@1.0.0-rc.16", "", { "dependencies": { "@oxc-project/types": "=0.126.0", "@rolldown/pluginutils": "1.0.0-rc.16" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-x64": "1.0.0-rc.16", "@rolldown/binding-freebsd-x64": "1.0.0-rc.16", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.16", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.16", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.16", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g=="], "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], @@ -1905,25 +2059,19 @@ "@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], - "@tailwindcss/node/lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], - - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@tanstack/pacer/@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="], - - "@tanstack/react-router/@tanstack/react-store": ["@tanstack/react-store@0.9.2", "", { "dependencies": { "@tanstack/store": "0.9.2", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ=="], - - "@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="], + "@tanstack/react-router/@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="], "@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -1933,7 +2081,7 @@ "@tanstack/router-utils/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "ast-kit/@babel/parser": ["@babel/parser@8.0.0-rc.2", "", { "dependencies": { "@babel/types": "^8.0.0-rc.2" }, "bin": "./bin/babel-parser.js" }, "sha512-29AhEtcq4x8Dp3T72qvUMZHx0OMXCj4Jy/TEReQa+KWLln524Cj1fWb3QFi0l/xSpptQBR6y9RNEXuxpFvwiUQ=="], @@ -1943,31 +2091,33 @@ "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - "h3/cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="], + "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "h3/cookie-es": ["cookie-es@1.2.3", "", {}, "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw=="], "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], "rolldown-plugin-dts/@babel/parser": ["@babel/parser@8.0.0-rc.2", "", { "dependencies": { "@babel/types": "^8.0.0-rc.2" }, "bin": "./bin/babel-parser.js" }, "sha512-29AhEtcq4x8Dp3T72qvUMZHx0OMXCj4Jy/TEReQa+KWLln524Cj1fWb3QFi0l/xSpptQBR6y9RNEXuxpFvwiUQ=="], "rolldown-plugin-dts/@babel/types": ["@babel/types@8.0.0-rc.2", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.2", "@babel/helper-validator-identifier": "^8.0.0-rc.2" } }, "sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw=="], + "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], "tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "unrun/rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="], + "unrun/rolldown": ["rolldown@1.0.0-rc.16", "", { "dependencies": { "@oxc-project/types": "=0.126.0", "@rolldown/pluginutils": "1.0.0-rc.16" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-x64": "1.0.0-rc.16", "@rolldown/binding-freebsd-x64": "1.0.0-rc.16", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.16", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.16", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.16", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g=="], "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "vite/rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="], + "vite/rolldown": ["rolldown@1.0.0-rc.15", "", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="], "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], @@ -1979,7 +2129,7 @@ "yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - "@babel/generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.2", "", {}, "sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ=="], + "@babel/generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.3", "", {}, "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA=="], "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], @@ -1997,57 +2147,37 @@ "@pierre/diffs/shiki/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], - "@rolldown/plugin-babel/rolldown/@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="], + "@rolldown/plugin-babel/rolldown/@oxc-project/types": ["@oxc-project/types@0.126.0", "", {}, "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ=="], - "@rolldown/plugin-babel/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="], + "@rolldown/plugin-babel/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.16", "", { "os": "android", "cpu": "arm64" }, "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA=="], - "@rolldown/plugin-babel/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ=="], + "@rolldown/plugin-babel/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ=="], - "@rolldown/plugin-babel/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg=="], + "@rolldown/plugin-babel/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ=="], - "@rolldown/plugin-babel/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.9", "", { "os": "freebsd", "cpu": "x64" }, "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q=="], + "@rolldown/plugin-babel/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.16", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g=="], - "@rolldown/plugin-babel/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm" }, "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ=="], + "@rolldown/plugin-babel/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm" }, "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg=="], - "@rolldown/plugin-babel/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg=="], + "@rolldown/plugin-babel/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg=="], - "@rolldown/plugin-babel/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg=="], + "@rolldown/plugin-babel/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg=="], - "@rolldown/plugin-babel/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "x64" }, "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg=="], + "@rolldown/plugin-babel/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg=="], - "@rolldown/plugin-babel/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.9", "", { "os": "linux", "cpu": "x64" }, "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA=="], + "@rolldown/plugin-babel/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w=="], - "@rolldown/plugin-babel/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.9", "", { "os": "none", "cpu": "arm64" }, "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog=="], + "@rolldown/plugin-babel/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.16", "", { "os": "none", "cpu": "arm64" }, "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA=="], - "@rolldown/plugin-babel/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.9", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g=="], + "@rolldown/plugin-babel/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.16", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ=="], - "@rolldown/plugin-babel/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA=="], + "@rolldown/plugin-babel/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q=="], - "@rolldown/plugin-babel/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.9", "", { "os": "win32", "cpu": "x64" }, "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ=="], + "@rolldown/plugin-babel/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "x64" }, "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g=="], - "@rolldown/plugin-babel/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.9", "", {}, "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw=="], + "@rolldown/plugin-babel/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.16", "", {}, "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA=="], - "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], - - "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="], - - "@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="], - - "@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="], - - "@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="], - - "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="], - - "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="], - - "@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="], - - "@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="], - - "@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="], - - "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], + "@tanstack/react-router/@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], "@tanstack/router-plugin/chokidar/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -2057,72 +2187,82 @@ "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], - "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.2", "", {}, "sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ=="], + "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.3", "", {}, "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA=="], - "unrun/rolldown/@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="], + "unrun/rolldown/@oxc-project/types": ["@oxc-project/types@0.126.0", "", {}, "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ=="], - "unrun/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="], + "unrun/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.16", "", { "os": "android", "cpu": "arm64" }, "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA=="], - "unrun/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ=="], + "unrun/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ=="], - "unrun/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg=="], + "unrun/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ=="], - "unrun/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.9", "", { "os": "freebsd", "cpu": "x64" }, "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q=="], + "unrun/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.16", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g=="], - "unrun/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm" }, "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ=="], + "unrun/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm" }, "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg=="], - "unrun/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg=="], + "unrun/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg=="], - "unrun/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg=="], + "unrun/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg=="], - "unrun/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "x64" }, "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg=="], + "unrun/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg=="], - "unrun/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.9", "", { "os": "linux", "cpu": "x64" }, "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA=="], + "unrun/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w=="], - "unrun/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.9", "", { "os": "none", "cpu": "arm64" }, "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog=="], + "unrun/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.16", "", { "os": "none", "cpu": "arm64" }, "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA=="], - "unrun/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.9", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g=="], + "unrun/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.16", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ=="], - "unrun/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA=="], + "unrun/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q=="], - "unrun/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.9", "", { "os": "win32", "cpu": "x64" }, "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ=="], + "unrun/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "x64" }, "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g=="], - "unrun/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.9", "", {}, "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw=="], + "unrun/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.16", "", {}, "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA=="], "unstorage/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], - "vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="], + "vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.124.0", "", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="], + + "vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.15", "", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="], + + "vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg=="], + + "vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw=="], + + "vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.15", "", { "os": "freebsd", "cpu": "x64" }, "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw=="], + + "vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm" }, "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA=="], - "vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="], + "vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w=="], - "vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ=="], + "vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ=="], - "vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg=="], + "vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "ppc64" }, "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ=="], - "vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.9", "", { "os": "freebsd", "cpu": "x64" }, "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q=="], + "vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "s390x" }, "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ=="], - "vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm" }, "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ=="], + "vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA=="], - "vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg=="], + "vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw=="], - "vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg=="], + "vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.15", "", { "os": "none", "cpu": "arm64" }, "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg=="], - "vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "x64" }, "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg=="], + "vite/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.15", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.3" }, "cpu": "none" }, "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q=="], - "vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.9", "", { "os": "linux", "cpu": "x64" }, "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA=="], + "vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA=="], - "vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.9", "", { "os": "none", "cpu": "arm64" }, "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog=="], + "vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "x64" }, "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g=="], - "vite/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.9", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g=="], + "vite/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="], - "vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA=="], + "@rolldown/plugin-babel/rolldown/@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], - "vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.9", "", { "os": "win32", "cpu": "x64" }, "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ=="], + "@tanstack/router-plugin/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "vite/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.9", "", {}, "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw=="], + "ast-kit/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.3", "", {}, "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA=="], - "@tanstack/router-plugin/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "unrun/rolldown/@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], - "ast-kit/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.2", "", {}, "sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ=="], + "vite/rolldown/@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], } } diff --git a/docs/observability.md b/docs/observability.md index dde10935..5b98d116 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -69,11 +69,11 @@ npx t3 ``` ```bash -bun dev +node --run dev ``` ```bash -bun dev:desktop +node --run dev:desktop ``` ### Option 2: Run With A Local LGTM Stack @@ -122,13 +122,13 @@ npx t3 Monorepo web/server dev: ```bash -bun dev +node --run dev ``` Monorepo desktop dev: ```bash -bun dev:desktop +node --run dev:desktop ``` Packaged desktop app: diff --git a/docs/release.md b/docs/release.md index 7486dc69..fc17d3e3 100644 --- a/docs/release.md +++ b/docs/release.md @@ -37,7 +37,7 @@ This document covers the unified release workflow for stable and nightly desktop - tag format: `nightly-vX.Y.Z-nightly.YYYYMMDD.` - release name includes the short commit SHA - `make_latest` is always `false` -- Uses the current `apps/desktop/package.json` semver core (`X.Y.Z`) as the nightly base, then appends a nightly prerelease suffix. +- Uses the next stable patch version as the nightly base. For example, `0.0.17` produces nightlies on `0.0.18-nightly.*`. - Publishes Electron auto-update metadata to the dedicated `nightly` updater channel, so desktop users can opt into that track independently from stable. - Publishes the CLI package (`apps/server`, npm package `t3`) to the `nightly` npm dist-tag using the same nightly version. - Does not commit version bumps back to `main`. diff --git a/package.json b/package.json index 97b30e6d..9e412b5b 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "@effect/sql-sqlite-bun": "4.0.0-beta.45", "@effect/vitest": "4.0.0-beta.45", "@effect/language-service": "0.84.2", - "@types/bun": "^1.3.9", + "@types/bun": "^1.3.11", "@types/node": "^24.10.13", "tsdown": "^0.20.3", "typescript": "^5.7.3", @@ -33,7 +33,7 @@ "start": "turbo run start --filter=t3", "start:desktop": "turbo run start --filter=@t3tools/desktop", "start:marketing": "turbo run preview --filter=@t3tools/marketing", - "start:mock-update-server": "bun run scripts/mock-update-server.ts", + "start:mock-update-server": "node scripts/mock-update-server.ts", "build": "turbo run build", "build:marketing": "turbo run build --filter=@t3tools/marketing", "build:desktop": "turbo run build --filter=@t3tools/desktop --filter=t3", @@ -49,7 +49,9 @@ "dist:desktop:dmg:arm64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch arm64", "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch x64", "dist:desktop:linux": "node scripts/build-desktop-artifact.ts --platform linux --target AppImage --arch x64", - "dist:desktop:win": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64", + "dist:desktop:win": "node scripts/build-desktop-artifact.ts --platform win --target nsis", + "dist:desktop:win:arm64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch arm64", + "dist:desktop:win:x64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64", "release:smoke": "node scripts/release-smoke.ts", "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo", "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" @@ -72,10 +74,10 @@ "vite": "^8.0.0" }, "engines": { - "bun": "^1.3.9", + "bun": "^1.3.11", "node": "^24.13.1" }, - "packageManager": "bun@1.3.9", + "packageManager": "bun@1.3.11", "msw": { "workerDirectory": [ "apps/web/public" diff --git a/packages/client-runtime/src/index.ts b/packages/client-runtime/src/index.ts index 5dd6b9af..9ca76328 100644 --- a/packages/client-runtime/src/index.ts +++ b/packages/client-runtime/src/index.ts @@ -1,2 +1,2 @@ -export * from "./knownEnvironment"; -export * from "./scoped"; +export * from "./knownEnvironment.ts"; +export * from "./scoped.ts"; diff --git a/packages/client-runtime/src/knownEnvironment.test.ts b/packages/client-runtime/src/knownEnvironment.test.ts index dca56c1e..a40161e9 100644 --- a/packages/client-runtime/src/knownEnvironment.test.ts +++ b/packages/client-runtime/src/knownEnvironment.test.ts @@ -1,7 +1,7 @@ import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; -import { createKnownEnvironment, getKnownEnvironmentHttpBaseUrl } from "./knownEnvironment"; +import { createKnownEnvironment, getKnownEnvironmentHttpBaseUrl } from "./knownEnvironment.ts"; import { parseScopedProjectKey, parseScopedThreadKey, @@ -10,7 +10,7 @@ import { scopedThreadKey, scopeProjectRef, scopeThreadRef, -} from "./scoped"; +} from "./scoped.ts"; describe("known environment bootstrap helpers", () => { it("creates known environments from explicit server base urls", () => { diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 63ce74a1..8b499267 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.0.17", + "version": "0.0.20", "private": true, "files": [ "dist" diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 73327a45..8110104e 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -1,6 +1,6 @@ import { Schema } from "effect"; -import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas"; +import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas.ts"; /** * Declares the server's overall authentication posture. diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 569f096d..8444e8b1 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { TrimmedNonEmptyString } from "./baseSchemas"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; export const EditorLaunchStyle = Schema.Literals(["direct-path", "goto", "line-column"]); export type EditorLaunchStyle = typeof EditorLaunchStyle.Type; diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index bc3b5459..aa34c339 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -1,6 +1,6 @@ import { Effect, Schema } from "effect"; -import { EnvironmentId, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas"; +import { EnvironmentId, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; export const ExecutionEnvironmentPlatformOs = Schema.Literals([ "darwin", @@ -51,6 +51,7 @@ export type RepositoryIdentityLocator = typeof RepositoryIdentityLocator.Type; export const RepositoryIdentity = Schema.Struct({ canonicalKey: TrimmedNonEmptyString, locator: RepositoryIdentityLocator, + rootPath: Schema.optionalKey(TrimmedNonEmptyString), displayName: Schema.optionalKey(TrimmedNonEmptyString), provider: Schema.optionalKey(TrimmedNonEmptyString), owner: Schema.optionalKey(TrimmedNonEmptyString), diff --git a/packages/contracts/src/filesystem.ts b/packages/contracts/src/filesystem.ts index 41b1eb2b..a518e2e9 100644 --- a/packages/contracts/src/filesystem.ts +++ b/packages/contracts/src/filesystem.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { TrimmedNonEmptyString } from "./baseSchemas"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; const FILESYSTEM_PATH_MAX_LENGTH = 512; diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index d5b2d7df..ebd5324f 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -7,7 +7,7 @@ import { GitRunStackedActionResult, GitRunStackedActionInput, GitResolvePullRequestResult, -} from "./git"; +} from "./git.ts"; const decodeCreateWorktreeInput = Schema.decodeUnknownSync(GitCreateWorktreeInput); const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync( diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 345208ac..47d74dc3 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas"; +import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; const GIT_LIST_BRANCHES_MAX_LIMIT = 200; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 0f2327d2..47081d8d 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,17 +1,17 @@ -export * from "./baseSchemas"; -export * from "./auth"; -export * from "./environment"; -export * from "./ipc"; -export * from "./terminal"; -export * from "./provider"; -export * from "./providerRuntime"; -export * from "./model"; -export * from "./keybindings"; -export * from "./server"; -export * from "./settings"; -export * from "./git"; -export * from "./orchestration"; -export * from "./editor"; -export * from "./project"; -export * from "./filesystem"; -export * from "./rpc"; +export * from "./baseSchemas.ts"; +export * from "./auth.ts"; +export * from "./environment.ts"; +export * from "./ipc.ts"; +export * from "./terminal.ts"; +export * from "./provider.ts"; +export * from "./providerRuntime.ts"; +export * from "./model.ts"; +export * from "./keybindings.ts"; +export * from "./server.ts"; +export * from "./settings.ts"; +export * from "./git.ts"; +export * from "./orchestration.ts"; +export * from "./editor.ts"; +export * from "./project.ts"; +export * from "./filesystem.ts"; +export * from "./rpc.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index c2d68133..a1abc0fa 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -17,19 +17,19 @@ import type { GitStatusInput, GitStatusResult, GitCreateBranchResult, -} from "./git"; -import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem"; +} from "./git.ts"; +import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts"; import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult, ProjectWriteFileInput, ProjectWriteFileResult, -} from "./project"; +} from "./project.ts"; import type { ServerConfig, ServerProviderUpdatedPayload, ServerUpsertKeybindingResult, -} from "./server"; +} from "./server.ts"; import type { TerminalClearInput, TerminalCloseInput, @@ -39,8 +39,8 @@ import type { TerminalRestartInput, TerminalSessionSnapshot, TerminalWriteInput, -} from "./terminal"; -import type { ServerUpsertKeybindingInput } from "./server"; +} from "./terminal.ts"; +import type { ServerUpsertKeybindingInput } from "./server.ts"; import type { ClientOrchestrationCommand, OrchestrationGetFullThreadDiffInput, @@ -50,16 +50,17 @@ import type { OrchestrationShellStreamItem, OrchestrationSubscribeThreadInput, OrchestrationThreadStreamItem, -} from "./orchestration"; -import type { EnvironmentId } from "./baseSchemas"; -import { EditorId } from "./editor"; -import { ClientSettings, ServerSettings, ServerSettingsPatch } from "./settings"; +} from "./orchestration.ts"; +import type { EnvironmentId } from "./baseSchemas.ts"; +import { EditorId } from "./editor.ts"; +import { ServerSettings, type ClientSettings, type ServerSettingsPatch } from "./settings.ts"; export interface ContextMenuItem { id: T; label: string; destructive?: boolean; disabled?: boolean; + children?: readonly ContextMenuItem[]; } export type DesktopUpdateStatus = diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 092d5344..79c2feb8 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -7,7 +7,7 @@ import { KeybindingRule, ResolvedKeybindingRule, ResolvedKeybindingsConfig, -} from "./keybindings"; +} from "./keybindings.ts"; const decode = ( schema: S, diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 72067eac..1296e74c 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { TrimmedString } from "./baseSchemas"; +import { TrimmedString } from "./baseSchemas.ts"; export const MAX_KEYBINDING_VALUE_LENGTH = 64; const MAX_KEYBINDING_WHEN_LENGTH = 256; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 6875e4ec..7bc9e37f 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -1,12 +1,19 @@ import { Schema } from "effect"; -import { TrimmedNonEmptyString } from "./baseSchemas"; -import type { ProviderKind } from "./orchestration"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import type { ProviderKind } from "./orchestration.ts"; export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const; export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORT_OPTIONS)[number]; -export const CLAUDE_CODE_EFFORT_OPTIONS = ["low", "medium", "high", "max", "ultrathink"] as const; -export type ClaudeCodeEffort = (typeof CLAUDE_CODE_EFFORT_OPTIONS)[number]; -export type ProviderReasoningEffort = CodexReasoningEffort | ClaudeCodeEffort; +export const CLAUDE_AGENT_EFFORT_OPTIONS = [ + "low", + "medium", + "high", + "xhigh", + "max", + "ultrathink", +] as const; +export type ClaudeAgentEffort = (typeof CLAUDE_AGENT_EFFORT_OPTIONS)[number]; +export type ProviderReasoningEffort = CodexReasoningEffort | ClaudeAgentEffort; export const CodexModelOptions = Schema.Struct({ reasoningEffort: Schema.optional(Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS)), @@ -21,7 +28,7 @@ export type CopilotModelOptions = typeof CopilotModelOptions.Type; export const ClaudeModelOptions = Schema.Struct({ thinking: Schema.optional(Schema.Boolean), - effort: Schema.optional(Schema.Literals(CLAUDE_CODE_EFFORT_OPTIONS)), + effort: Schema.optional(Schema.Literals(CLAUDE_AGENT_EFFORT_OPTIONS)), fastMode: Schema.optional(Schema.Boolean), contextWindow: Schema.optional(Schema.String), }); @@ -65,13 +72,17 @@ export const DEFAULT_MODEL_BY_PROVIDER: Record = { export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; -/** Per-provider text generation model defaults. */ export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record = { codex: "gpt-5.4-mini", copilot: "gpt-5-mini", claudeAgent: "claude-haiku-4-5", }; +export const GIT_TEXT_GENERATION_PROVIDERS = [ + "codex", + "claudeAgent", +] as const satisfies ReadonlyArray; + export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record> = { codex: { "gpt-5-codex": "gpt-5.4", @@ -105,7 +116,9 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record = { codex: "Codex", copilot: "GitHub Copilot", diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index ad46e380..223efd6d 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -19,7 +19,7 @@ import { ThreadCreatedPayload, ThreadTurnDiff, ThreadTurnStartRequestedPayload, -} from "./orchestration"; +} from "./orchestration.ts"; const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput); const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 1d26cf3f..c60d281f 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1,6 +1,6 @@ import { Effect, Option, Schema, SchemaIssue, Struct } from "effect"; -import { ClaudeModelOptions, CodexModelOptions, CopilotModelOptions } from "./model"; -import { RepositoryIdentity } from "./environment"; +import { ClaudeModelOptions, CodexModelOptions, CopilotModelOptions } from "./model.ts"; +import { RepositoryIdentity } from "./environment.ts"; import { ApprovalRequestId, CheckpointRef, @@ -14,7 +14,7 @@ import { ThreadId, TrimmedNonEmptyString, TurnId, -} from "./baseSchemas"; +} from "./baseSchemas.ts"; export const ORCHESTRATION_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 2851120d..d089951b 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; +import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512; diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index 37469984..bd20b7e9 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { Schema } from "effect"; -import { ProviderSendTurnInput, ProviderSessionStartInput } from "./provider"; +import { ProviderSendTurnInput, ProviderSessionStartInput } from "./provider.ts"; const decodeProviderSessionStartInput = Schema.decodeUnknownSync(ProviderSessionStartInput); const decodeProviderSendTurnInput = Schema.decodeUnknownSync(ProviderSendTurnInput); diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 16102920..e27e3aa7 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { TrimmedNonEmptyString } from "./baseSchemas"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ApprovalRequestId, EventId, @@ -7,7 +7,7 @@ import { ProviderItemId, ThreadId, TurnId, -} from "./baseSchemas"; +} from "./baseSchemas.ts"; import { ChatAttachment, ModelSelection, @@ -21,7 +21,7 @@ import { ProviderSandboxMode, ProviderUserInputAnswers, RuntimeMode, -} from "./orchestration"; +} from "./orchestration.ts"; const ProviderSessionStatus = Schema.Literals([ "connecting", diff --git a/packages/contracts/src/providerRuntime.test.ts b/packages/contracts/src/providerRuntime.test.ts index 9d9c395c..7b822a28 100644 --- a/packages/contracts/src/providerRuntime.test.ts +++ b/packages/contracts/src/providerRuntime.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { Schema } from "effect"; -import { ProviderRuntimeEvent } from "./providerRuntime"; +import { ProviderRuntimeEvent } from "./providerRuntime.ts"; const decodeRuntimeEvent = Schema.decodeUnknownSync(ProviderRuntimeEvent); diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 111e9ded..345e04f7 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -11,8 +11,8 @@ import { ThreadId, TrimmedNonEmptyString, TurnId, -} from "./baseSchemas"; -import { ProviderKind } from "./orchestration"; +} from "./baseSchemas.ts"; +import { ProviderKind } from "./orchestration.ts"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; const UnknownRecordSchema = Schema.Record(Schema.String, Schema.Unknown); diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index ebdab2c4..5dec716a 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -2,9 +2,13 @@ import { Schema } from "effect"; import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; -import { OpenError, OpenInEditorInput } from "./editor"; -import { AuthAccessStreamEvent } from "./auth"; -import { FilesystemBrowseInput, FilesystemBrowseResult, FilesystemBrowseError } from "./filesystem"; +import { OpenError, OpenInEditorInput } from "./editor.ts"; +import { AuthAccessStreamEvent } from "./auth.ts"; +import { + FilesystemBrowseInput, + FilesystemBrowseResult, + FilesystemBrowseError, +} from "./filesystem.ts"; import { GitActionProgressEvent, GitCheckoutInput, @@ -29,8 +33,8 @@ import { GitStatusInput, GitStatusResult, GitStatusStreamEvent, -} from "./git"; -import { KeybindingsConfigError } from "./keybindings"; +} from "./git.ts"; +import { KeybindingsConfigError } from "./keybindings.ts"; import { ClientOrchestrationCommand, ORCHESTRATION_WS_METHODS, @@ -43,7 +47,7 @@ import { OrchestrationReplayEventsError, OrchestrationReplayEventsInput, OrchestrationRpcSchemas, -} from "./orchestration"; +} from "./orchestration.ts"; import { ProjectSearchEntriesError, ProjectSearchEntriesInput, @@ -51,7 +55,7 @@ import { ProjectWriteFileError, ProjectWriteFileInput, ProjectWriteFileResult, -} from "./project"; +} from "./project.ts"; import { TerminalClearInput, TerminalCloseInput, @@ -62,7 +66,7 @@ import { TerminalRestartInput, TerminalSessionSnapshot, TerminalWriteInput, -} from "./terminal"; +} from "./terminal.ts"; import { ServerConfigStreamEvent, ServerConfig, @@ -70,8 +74,8 @@ import { ServerProviderUpdatedPayload, ServerUpsertKeybindingInput, ServerUpsertKeybindingResult, -} from "./server"; -import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings"; +} from "./server.ts"; +import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; export const WS_METHODS = { // Project registry methods diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index 6e5f70c2..e26b3e33 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -1,7 +1,7 @@ import { Schema } from "effect"; import { describe, expect, it } from "vitest"; -import { ServerProvider } from "./server"; +import { ServerProvider } from "./server.ts"; const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); @@ -23,4 +23,22 @@ describe("ServerProvider", () => { expect(parsed.slashCommands).toEqual([]); expect(parsed.skills).toEqual([]); }); + + it("keeps quotaSnapshots undefined when legacy snapshots omit them", () => { + const parsed = decodeServerProvider({ + provider: "codex", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { + status: "authenticated", + }, + checkedAt: "2026-04-10T00:00:00.000Z", + models: [], + }); + + expect(parsed.quotaSnapshots).toBeUndefined(); + expect("quotaSnapshots" in parsed).toBe(false); + }); }); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index c7ea75e5..b33022c1 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,18 +1,18 @@ import { Effect, Schema } from "effect"; -import { ExecutionEnvironmentDescriptor } from "./environment"; -import { ServerAuthDescriptor } from "./auth"; +import { ExecutionEnvironmentDescriptor } from "./environment.ts"; +import { ServerAuthDescriptor } from "./auth.ts"; import { IsoDateTime, NonNegativeInt, ProjectId, ThreadId, TrimmedNonEmptyString, -} from "./baseSchemas"; -import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; -import { EditorId } from "./editor"; -import { ModelCapabilities } from "./model"; -import { ProviderKind } from "./orchestration"; -import { ServerSettings } from "./settings"; +} from "./baseSchemas.ts"; +import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings.ts"; +import { EditorId } from "./editor.ts"; +import { ModelCapabilities } from "./model.ts"; +import { ProviderKind } from "./orchestration.ts"; +import { ServerSettings } from "./settings.ts"; const KeybindingsMalformedConfigIssue = Schema.Struct({ kind: Schema.Literal("keybindings.malformed-config"), @@ -50,6 +50,16 @@ export const ServerProviderAuth = Schema.Struct({ }); export type ServerProviderAuth = typeof ServerProviderAuth.Type; +export const ServerProviderModel = Schema.Struct({ + slug: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, + isCustom: Schema.Boolean, + billingMultiplier: Schema.optional(Schema.Number), + maxContextWindowTokens: Schema.optional(NonNegativeInt), + capabilities: Schema.NullOr(ModelCapabilities), +}); +export type ServerProviderModel = typeof ServerProviderModel.Type; + export const ServerProviderQuotaSnapshot = Schema.Struct({ key: TrimmedNonEmptyString, entitlementRequests: NonNegativeInt, @@ -63,16 +73,6 @@ export const ServerProviderQuotaSnapshot = Schema.Struct({ }); export type ServerProviderQuotaSnapshot = typeof ServerProviderQuotaSnapshot.Type; -export const ServerProviderModel = Schema.Struct({ - slug: TrimmedNonEmptyString, - name: TrimmedNonEmptyString, - isCustom: Schema.Boolean, - capabilities: Schema.NullOr(ModelCapabilities), - billingMultiplier: Schema.optional(Schema.Number), - maxContextWindowTokens: Schema.optional(NonNegativeInt), -}); -export type ServerProviderModel = typeof ServerProviderModel.Type; - export const ServerProviderSlashCommandInput = Schema.Struct({ hint: TrimmedNonEmptyString, }); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts new file mode 100644 index 00000000..92bd4be6 --- /dev/null +++ b/packages/contracts/src/settings.test.ts @@ -0,0 +1,46 @@ +import { Schema } from "effect"; +import { describe, expect, it } from "vitest"; + +import { ServerSettingsPatch } from "./settings.ts"; + +const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); + +describe("ServerSettingsPatch", () => { + it("preserves Claude launchArgs in provider patches", () => { + expect( + decodeServerSettingsPatch({ + providers: { + claudeAgent: { + launchArgs: "--verbose --dangerously-skip-permissions", + }, + }, + }), + ).toEqual({ + providers: { + claudeAgent: { + launchArgs: "--verbose --dangerously-skip-permissions", + }, + }, + }); + }); + + it("does not expose unsupported Codex launchArgs in provider patches", () => { + const parsed = decodeServerSettingsPatch({ + providers: { + codex: { + binaryPath: "/tmp/codex", + launchArgs: "--dangerously-skip-permissions", + }, + }, + }); + + expect(parsed).toEqual({ + providers: { + codex: { + binaryPath: "/tmp/codex", + }, + }, + }); + expect("launchArgs" in (parsed.providers?.codex ?? {})).toBe(false); + }); +}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 969087da..9a780e29 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1,14 +1,14 @@ import { Effect } from "effect"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; -import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas"; +import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; import { ClaudeModelOptions, CodexModelOptions, CopilotModelOptions, DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, -} from "./model"; -import { ModelSelection } from "./orchestration"; +} from "./model.ts"; +import { ModelSelection } from "./orchestration.ts"; // ── Client Settings (local-only) ─────────────────────────────── @@ -24,10 +24,25 @@ export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; +export const SidebarProjectGroupingMode = Schema.Literals([ + "repository", + "repository_path", + "separate", +]); +export type SidebarProjectGroupingMode = typeof SidebarProjectGroupingMode.Type; +export const DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE: SidebarProjectGroupingMode = "repository"; + export const ClientSettingsSchema = Schema.Struct({ confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), + ), + sidebarProjectGroupingOverrides: Schema.Record( + TrimmedNonEmptyString, + SidebarProjectGroupingMode, + ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_SORT_ORDER)), ), @@ -71,6 +86,7 @@ export const ClaudeSettings = Schema.Struct({ enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), binaryPath: makeBinaryPathSetting("claude"), customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + launchArgs: Schema.String.pipe(Schema.withDecodingDefault(Effect.succeed(""))), }); export type ClaudeSettings = typeof ClaudeSettings.Type; @@ -183,6 +199,7 @@ const ClaudeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(Schema.String), customModels: Schema.optionalKey(Schema.Array(Schema.String)), + launchArgs: Schema.optionalKey(Schema.String), }); const CopilotSettingsPatch = Schema.Struct({ diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts index 1bef8db3..3feae674 100644 --- a/packages/contracts/src/terminal.test.ts +++ b/packages/contracts/src/terminal.test.ts @@ -11,7 +11,7 @@ import { TerminalSessionSnapshot, TerminalThreadInput, TerminalWriteInput, -} from "./terminal"; +} from "./terminal.ts"; function decodeSync(schema: S, input: unknown): Schema.Schema.Type { return Schema.decodeUnknownSync(schema as never)(input) as Schema.Schema.Type; diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index 3fe883b4..21bd74a0 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -1,5 +1,5 @@ import { Effect, Schema } from "effect"; -import { TrimmedNonEmptyString } from "./baseSchemas"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; export const DEFAULT_TERMINAL_ID = "default"; diff --git a/packages/shared/package.json b/packages/shared/package.json index fe11f2e3..3789e3cf 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -60,6 +60,10 @@ "types": "./src/qrCode.ts", "import": "./src/qrCode.ts" }, + "./cliArgs": { + "types": "./src/cliArgs.ts", + "import": "./src/cliArgs.ts" + }, "./path": { "types": "./src/path.ts", "import": "./src/path.ts" diff --git a/packages/shared/src/DrainableWorker.test.ts b/packages/shared/src/DrainableWorker.test.ts index 1d7a3a83..0033038d 100644 --- a/packages/shared/src/DrainableWorker.test.ts +++ b/packages/shared/src/DrainableWorker.test.ts @@ -2,7 +2,7 @@ import { it } from "@effect/vitest"; import { describe, expect } from "vitest"; import { Deferred, Effect } from "effect"; -import { makeDrainableWorker } from "./DrainableWorker"; +import { makeDrainableWorker } from "./DrainableWorker.ts"; describe("makeDrainableWorker", () => { it.live("waits for work enqueued during active processing before draining", () => diff --git a/packages/shared/src/KeyedCoalescingWorker.test.ts b/packages/shared/src/KeyedCoalescingWorker.test.ts index 2226bbd0..78c3a6b9 100644 --- a/packages/shared/src/KeyedCoalescingWorker.test.ts +++ b/packages/shared/src/KeyedCoalescingWorker.test.ts @@ -2,7 +2,7 @@ import { it } from "@effect/vitest"; import { describe, expect } from "vitest"; import { Deferred, Effect } from "effect"; -import { makeKeyedCoalescingWorker } from "./KeyedCoalescingWorker"; +import { makeKeyedCoalescingWorker } from "./KeyedCoalescingWorker.ts"; describe("makeKeyedCoalescingWorker", () => { it.live("waits for latest work enqueued during active processing before draining the key", () => diff --git a/packages/shared/src/Net.test.ts b/packages/shared/src/Net.test.ts index 137a9416..19033a08 100644 --- a/packages/shared/src/Net.test.ts +++ b/packages/shared/src/Net.test.ts @@ -3,7 +3,7 @@ import * as Net from "node:net"; import { assert, describe, it } from "@effect/vitest"; import { Effect } from "effect"; -import { NetError, NetService } from "./Net"; +import { NetError, NetService } from "./Net.ts"; const closeServer = (server: Net.Server) => Effect.sync(() => { diff --git a/packages/shared/src/String.test.ts b/packages/shared/src/String.test.ts index d70bfe84..92730cd5 100644 --- a/packages/shared/src/String.test.ts +++ b/packages/shared/src/String.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { truncate } from "./String"; +import { truncate } from "./String.ts"; describe("truncate", () => { it("trims surrounding whitespace", () => { diff --git a/packages/shared/src/cliArgs.test.ts b/packages/shared/src/cliArgs.test.ts new file mode 100644 index 00000000..c6cee827 --- /dev/null +++ b/packages/shared/src/cliArgs.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from "vitest"; + +import { parseCliArgs } from "./cliArgs.ts"; + +describe("parseCliArgs", () => { + it("returns empty result for empty string", () => { + expect(parseCliArgs("")).toEqual({ flags: {}, positionals: [] }); + }); + + it("returns empty result for whitespace-only string", () => { + expect(parseCliArgs(" ")).toEqual({ flags: {}, positionals: [] }); + }); + + it("returns empty result for empty array", () => { + expect(parseCliArgs([])).toEqual({ flags: {}, positionals: [] }); + }); + + it("parses --chrome boolean flag", () => { + expect(parseCliArgs("--chrome")).toEqual({ + flags: { chrome: null }, + positionals: [], + }); + }); + + it("parses --chrome with --verbose", () => { + expect(parseCliArgs("--chrome --verbose")).toEqual({ + flags: { chrome: null, verbose: null }, + positionals: [], + }); + }); + + it("parses --effort with a value", () => { + expect(parseCliArgs("--effort high")).toEqual({ + flags: { effort: "high" }, + positionals: [], + }); + }); + + it("parses --chrome --effort high --debug", () => { + expect(parseCliArgs("--chrome --effort high --debug")).toEqual({ + flags: { chrome: null, effort: "high", debug: null }, + positionals: [], + }); + }); + + it("parses --model with full model name", () => { + expect(parseCliArgs("--model claude-sonnet-4-6")).toEqual({ + flags: { model: "claude-sonnet-4-6" }, + positionals: [], + }); + }); + + it("parses --append-system-prompt with value and --chrome", () => { + expect(parseCliArgs("--append-system-prompt always-think-step-by-step --chrome")).toEqual({ + flags: { "append-system-prompt": "always-think-step-by-step", chrome: null }, + positionals: [], + }); + }); + + it("parses --max-budget-usd with numeric value", () => { + expect(parseCliArgs("--chrome --max-budget-usd 5.00")).toEqual({ + flags: { chrome: null, "max-budget-usd": "5.00" }, + positionals: [], + }); + }); + + it("parses --effort=high syntax", () => { + expect(parseCliArgs("--effort=high")).toEqual({ + flags: { effort: "high" }, + positionals: [], + }); + }); + + it("parses --key=value mixed with boolean flags", () => { + expect(parseCliArgs("--chrome --model=claude-sonnet-4-6 --debug")).toEqual({ + flags: { chrome: null, model: "claude-sonnet-4-6", debug: null }, + positionals: [], + }); + }); + + it("collects positional arguments", () => { + expect(parseCliArgs("1.2.3")).toEqual({ + flags: {}, + positionals: ["1.2.3"], + }); + }); + + it("collects positionals mixed with flags (argv array)", () => { + expect(parseCliArgs(["1.2.3", "--root", "/path", "--github-output"])).toEqual({ + flags: { root: "/path", "github-output": null }, + positionals: ["1.2.3"], + }); + }); + + it("handles extra whitespace between tokens", () => { + expect(parseCliArgs(" --chrome --verbose ")).toEqual({ + flags: { chrome: null, verbose: null }, + positionals: [], + }); + }); + + it("preserves quoted values in string input", () => { + expect(parseCliArgs('--append-system-prompt "always think step by step" --chrome')).toEqual({ + flags: { "append-system-prompt": "always think step by step", chrome: null }, + positionals: [], + }); + }); + + it("preserves escaped whitespace in string input", () => { + expect( + parseCliArgs(String.raw`--append-system-prompt always\ think\ step\ by\ step --chrome`), + ).toEqual({ + flags: { "append-system-prompt": "always think step by step", chrome: null }, + positionals: [], + }); + }); + + it("preserves literal backslashes in Windows-style paths", () => { + expect(parseCliArgs(String.raw`--root C:\Users\testuser\project --chrome`)).toEqual({ + flags: { root: String.raw`C:\Users\testuser\project`, chrome: null }, + positionals: [], + }); + }); + + it("preserves literal backslashes inside quoted values", () => { + expect(parseCliArgs(String.raw`--root "C:\Program Files\Claude" --chrome`)).toEqual({ + flags: { root: String.raw`C:\Program Files\Claude`, chrome: null }, + positionals: [], + }); + }); + + it("keeps quoted Windows paths ending in a backslash separate from following args", () => { + expect(parseCliArgs('--root "C:\\Program Files\\Claude\\" --chrome')).toEqual({ + flags: { root: "C:\\Program Files\\Claude\\", chrome: null }, + positionals: [], + }); + }); + + it("keeps quoted positional Windows paths ending in a backslash separate from following args", () => { + expect(parseCliArgs('"C:\\Program Files\\Claude\\" --chrome')).toEqual({ + flags: { chrome: null }, + positionals: ["C:\\Program Files\\Claude\\"], + }); + }); + + it("still unescapes escaped quotes and backslashes in string input", () => { + expect( + parseCliArgs(String.raw`--append-system-prompt "say \"hi\" from C:\\tools" --chrome`), + ).toEqual({ + flags: { "append-system-prompt": String.raw`say "hi" from C:\tools`, chrome: null }, + positionals: [], + }); + }); + + it("parses quoted values in --key=value syntax", () => { + expect(parseCliArgs('--launch-arg="--project Claude Code" --debug')).toEqual({ + flags: { "launch-arg": "--project Claude Code", debug: null }, + positionals: [], + }); + }); + + it("keeps quoted --key value arguments starting with -- as values", () => { + expect(parseCliArgs('--launch-arg "--project Claude Code" --debug')).toEqual({ + flags: { "launch-arg": "--project Claude Code", debug: null }, + positionals: [], + }); + }); + + it("keeps quoted dangerous-skip flag strings as values", () => { + expect(parseCliArgs('--launch-arg "--dangerously-skip-permissions" --debug')).toEqual({ + flags: { "launch-arg": "--dangerously-skip-permissions", debug: null }, + positionals: [], + }); + }); + + it("treats quoted standalone double-dash tokens as positional values rather than flags", () => { + expect(parseCliArgs('"--dangerously-skip-permissions" --debug')).toEqual({ + flags: { debug: null }, + positionals: ["--dangerously-skip-permissions"], + }); + }); + + it("preserves intentionally empty quoted values", () => { + expect(parseCliArgs('--append-system-prompt "" --chrome')).toEqual({ + flags: { "append-system-prompt": "", chrome: null }, + positionals: [], + }); + }); + + it("ignores bare -- with no flag name", () => { + expect(parseCliArgs("--")).toEqual({ flags: {}, positionals: [] }); + }); + + it("boolean flag does not consume next token as value", () => { + expect(parseCliArgs(["--github-output", "1.2.3"], { booleanFlags: ["github-output"] })).toEqual( + { + flags: { "github-output": null }, + positionals: ["1.2.3"], + }, + ); + }); + + it("non-boolean flag still consumes next token", () => { + expect(parseCliArgs(["--root", "/path", "1.2.3"], { booleanFlags: ["github-output"] })).toEqual( + { + flags: { root: "/path" }, + positionals: ["1.2.3"], + }, + ); + }); + + it("mixes boolean and value flags with positionals", () => { + expect( + parseCliArgs(["--github-output", "--root", "/path", "1.2.3"], { + booleanFlags: ["github-output"], + }), + ).toEqual({ + flags: { "github-output": null, root: "/path" }, + positionals: ["1.2.3"], + }); + }); +}); diff --git a/packages/shared/src/cliArgs.ts b/packages/shared/src/cliArgs.ts new file mode 100644 index 00000000..7244f657 --- /dev/null +++ b/packages/shared/src/cliArgs.ts @@ -0,0 +1,169 @@ +export interface ParsedCliArgs { + readonly flags: Record; + readonly positionals: string[]; +} + +export interface ParseCliArgsOptions { + readonly booleanFlags?: readonly string[]; +} + +interface ParsedCliToken { + readonly value: string; + readonly quoted: boolean; +} + +function tokenizeCliArgs(input: string): ParsedCliToken[] { + const tokens: ParsedCliToken[] = []; + let current = ""; + let quote: '"' | "'" | null = null; + let tokenStarted = false; + let tokenQuoted = false; + + const pushCurrent = () => { + if (!tokenStarted) { + return; + } + tokens.push({ value: current, quoted: tokenQuoted }); + current = ""; + tokenStarted = false; + tokenQuoted = false; + }; + + const trimmed = input.trim(); + for (let index = 0; index < trimmed.length; index++) { + const char = trimmed[index]!; + const nextChar = trimmed[index + 1]; + + if (quote) { + if (char === "\\" && nextChar !== undefined && (nextChar === quote || nextChar === "\\")) { + const afterEscapedChar = trimmed[index + 2]; + const looksLikeQuotedWindowsPath = + quote === '"' && nextChar === '"' && /^[A-Za-z]:\\/.test(current); + if ( + looksLikeQuotedWindowsPath && + (afterEscapedChar === undefined || /\s/.test(afterEscapedChar)) + ) { + current += "\\"; + tokenStarted = true; + continue; + } + current += nextChar; + tokenStarted = true; + index += 1; + continue; + } + + if (char === quote) { + quote = null; + } else { + current += char; + tokenStarted = true; + } + continue; + } + + if (char === "\\") { + const shouldUnescape = + nextChar !== undefined && + (/\s/.test(nextChar) || nextChar === '"' || nextChar === "'" || nextChar === "\\"); + if (shouldUnescape) { + current += nextChar; + tokenStarted = true; + index += 1; + } else { + current += "\\"; + tokenStarted = true; + } + continue; + } + + if (char === '"' || char === "'") { + tokenStarted = true; + tokenQuoted = true; + quote = char; + continue; + } + + if (/\s/.test(char)) { + pushCurrent(); + continue; + } + + current += char; + tokenStarted = true; + } + + pushCurrent(); + return tokens; +} + +/** + * Parse CLI-style arguments into flags and positionals. + * + * Accepts a string (split by whitespace) or a pre-split argv array. + * Supports `--key value`, `--key=value`, and `--flag` (boolean) syntax. + * + * parseCliArgs("") + * → { flags: {}, positionals: [] } + * + * parseCliArgs("--chrome") + * → { flags: { chrome: null }, positionals: [] } + * + * parseCliArgs("--chrome --effort high") + * → { flags: { chrome: null, effort: "high" }, positionals: [] } + * + * parseCliArgs("--effort=high") + * → { flags: { effort: "high" }, positionals: [] } + * + * parseCliArgs(["1.2.3", "--root", "/path", "--github-output"], { booleanFlags: ["github-output"] }) + * → { flags: { root: "/path", "github-output": null }, positionals: ["1.2.3"] } + */ +export function parseCliArgs( + args: string | readonly string[], + options?: ParseCliArgsOptions, +): ParsedCliArgs { + const tokens = + typeof args === "string" + ? tokenizeCliArgs(args) + : Array.from(args, (value) => ({ value, quoted: false })); + const booleanSet = options?.booleanFlags ? new Set(options.booleanFlags) : undefined; + + const flags: Record = {}; + const positionals: string[] = []; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]!; + const tokenValue = token.value; + + if (tokenValue.startsWith("--") && (!token.quoted || tokenValue.includes("="))) { + const rest = tokenValue.slice(2); + if (!rest) continue; + + // Handle --key=value syntax + const eqIndex = rest.indexOf("="); + if (eqIndex !== -1) { + flags[rest.slice(0, eqIndex)] = rest.slice(eqIndex + 1); + continue; + } + + // Known boolean flag — never consumes next token + if (booleanSet?.has(rest)) { + flags[rest] = null; + continue; + } + + // Handle --key value or --flag (boolean) + const next = tokens[i + 1]; + if (next !== undefined && (!next.value.startsWith("--") || next.quoted)) { + flags[rest] = next.value; + i++; + } else { + flags[rest] = null; + } + } else { + positionals.push(tokenValue); + } + } + + return { flags, positionals }; +} diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index ba3af6c7..2160c460 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -8,7 +8,7 @@ import { normalizeGitRemoteUrl, parseGitHubRepositoryNameWithOwnerFromRemoteUrl, WORKTREE_BRANCH_PREFIX, -} from "./git"; +} from "./git.ts"; describe("normalizeGitRemoteUrl", () => { it("canonicalizes equivalent GitHub remotes across protocol variants", () => { diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 280f2031..314d0c6b 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -10,6 +10,7 @@ import { isClaudeUltrathinkPrompt, normalizeClaudeModelOptionsWithCapabilities, normalizeCodexModelOptionsWithCapabilities, + normalizeCopilotModelOptionsWithCapabilities, normalizeModelSlug, resolveApiModelId, resolveContextWindow, @@ -18,7 +19,7 @@ import { resolveModelSlugForProvider, resolveSelectableModel, trimOrNull, -} from "./model"; +} from "./model.ts"; const codexCaps: ModelCapabilities = { reasoningEffortLevels: [ @@ -46,13 +47,19 @@ const claudeCaps: ModelCapabilities = { promptInjectedEffortLevels: ["ultrathink"], }; +const noOptionsCaps: ModelCapabilities = { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], +}; + describe("normalizeModelSlug", () => { it("maps known aliases to canonical slugs", () => { expect(normalizeModelSlug("gpt-5-codex")).toBe("gpt-5.4"); expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex"); expect(normalizeModelSlug("opus", "copilot")).toBe("claude-opus-4.7"); - expect(normalizeModelSlug("opus-4.7", "copilot")).toBe("claude-opus-4.7"); - expect(normalizeModelSlug("claude-opus-4.7", "copilot")).toBe("claude-opus-4.7"); expect(normalizeModelSlug("sonnet", "claudeAgent")).toBe("claude-sonnet-4-6"); }); @@ -67,15 +74,10 @@ describe("normalizeModelSlug", () => { describe("resolveModelSlug", () => { it("returns defaults when the model is missing", () => { expect(resolveModelSlug(undefined, "codex")).toBe(DEFAULT_MODEL_BY_PROVIDER.codex); - expect(resolveModelSlugForProvider("claudeAgent", undefined)).toBe( DEFAULT_MODEL_BY_PROVIDER.claudeAgent, ); }); - - it("preserves normalized unknown models", () => { - expect(resolveModelSlug("custom/internal-model", "codex")).toBe("custom/internal-model"); - }); }); describe("resolveSelectableModel", () => { @@ -85,121 +87,48 @@ describe("resolveSelectableModel", () => { { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, ]; expect(resolveSelectableModel("codex", "gpt-5.3-codex", options)).toBe("gpt-5.3-codex"); - expect(resolveSelectableModel("codex", "gpt-5.3 codex", options)).toBe("gpt-5.3-codex"); expect(resolveSelectableModel("claudeAgent", "sonnet", options)).toBe("claude-sonnet-4-6"); }); }); describe("capability helpers", () => { - it("reads default efforts", () => { + it("read defaults and support", () => { expect(getDefaultEffort(codexCaps)).toBe("high"); expect(getDefaultEffort(claudeCaps)).toBe("high"); - }); - - it("checks effort support", () => { expect(hasEffortLevel(codexCaps, "xhigh")).toBe(true); - expect(hasEffortLevel(codexCaps, "max")).toBe(false); + expect(hasContextWindowOption(claudeCaps, "1m")).toBe(true); + expect(getDefaultContextWindow(claudeCaps)).toBe("1m"); }); }); describe("resolveEffort", () => { - it("returns the explicit value when supported and not prompt-injected", () => { + it("resolves supported values and defaults", () => { expect(resolveEffort(codexCaps, "xhigh")).toBe("xhigh"); - expect(resolveEffort(codexCaps, "high")).toBe("high"); - expect(resolveEffort(claudeCaps, "medium")).toBe("medium"); - }); - - it("falls back to default when value is unsupported", () => { expect(resolveEffort(codexCaps, "bogus")).toBe("high"); - expect(resolveEffort(claudeCaps, "bogus")).toBe("high"); - }); - - it("returns the default when no value is provided", () => { - expect(resolveEffort(codexCaps, undefined)).toBe("high"); - expect(resolveEffort(codexCaps, null)).toBe("high"); - expect(resolveEffort(codexCaps, "")).toBe("high"); - expect(resolveEffort(codexCaps, " ")).toBe("high"); - }); - - it("excludes prompt-injected efforts and falls back to default", () => { expect(resolveEffort(claudeCaps, "ultrathink")).toBe("high"); }); +}); - it("returns undefined for models with no effort levels", () => { - const noCaps: ModelCapabilities = { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }; - expect(resolveEffort(noCaps, undefined)).toBeUndefined(); - expect(resolveEffort(noCaps, "high")).toBeUndefined(); +describe("resolveContextWindow", () => { + it("resolves explicit and default values", () => { + expect(resolveContextWindow(claudeCaps, "200k")).toBe("200k"); + expect(resolveContextWindow(claudeCaps, "bogus")).toBe("1m"); + expect(resolveContextWindow(codexCaps, undefined)).toBeUndefined(); }); }); describe("misc helpers", () => { - it("detects ultrathink prompts", () => { + it("handles prompt effort and trim", () => { expect(isClaudeUltrathinkPrompt("Ultrathink:\nInvestigate")).toBe(true); - expect(isClaudeUltrathinkPrompt("Investigate")).toBe(false); - }); - - it("prefixes ultrathink prompts once", () => { expect(applyClaudePromptEffortPrefix("Investigate", "ultrathink")).toBe( "Ultrathink:\nInvestigate", ); - expect(applyClaudePromptEffortPrefix("Ultrathink:\nInvestigate", "ultrathink")).toBe( - "Ultrathink:\nInvestigate", - ); - }); - - it("trims strings to null", () => { expect(trimOrNull(" hi ")).toBe("hi"); - expect(trimOrNull(" ")).toBeNull(); - }); -}); - -describe("context window helpers", () => { - it("reads default context window", () => { - expect(getDefaultContextWindow(claudeCaps)).toBe("1m"); - }); - - it("returns null for models without context window options", () => { - expect(getDefaultContextWindow(codexCaps)).toBeNull(); - }); - - it("checks context window support", () => { - expect(hasContextWindowOption(claudeCaps, "1m")).toBe(true); - expect(hasContextWindowOption(claudeCaps, "200k")).toBe(true); - expect(hasContextWindowOption(claudeCaps, "bogus")).toBe(false); - expect(hasContextWindowOption(codexCaps, "1m")).toBe(false); - }); -}); - -describe("resolveContextWindow", () => { - it("returns the explicit value when supported", () => { - expect(resolveContextWindow(claudeCaps, "200k")).toBe("200k"); - expect(resolveContextWindow(claudeCaps, "1m")).toBe("1m"); - }); - - it("falls back to default when value is unsupported", () => { - expect(resolveContextWindow(claudeCaps, "bogus")).toBe("1m"); - }); - - it("returns the default when no value is provided", () => { - expect(resolveContextWindow(claudeCaps, undefined)).toBe("1m"); - expect(resolveContextWindow(claudeCaps, null)).toBe("1m"); - expect(resolveContextWindow(claudeCaps, "")).toBe("1m"); - }); - - it("returns undefined for models with no context window options", () => { - expect(resolveContextWindow(codexCaps, undefined)).toBeUndefined(); - expect(resolveContextWindow(codexCaps, "1m")).toBeUndefined(); }); }); describe("resolveApiModelId", () => { - it("appends [1m] suffix for 1m context window", () => { + it("applies claude context window suffix", () => { expect( resolveApiModelId({ provider: "claudeAgent", @@ -209,80 +138,49 @@ describe("resolveApiModelId", () => { ).toBe("claude-opus-4-6[1m]"); }); - it("returns the model as-is for 200k context window", () => { - expect( - resolveApiModelId({ - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { contextWindow: "200k" }, - }), - ).toBe("claude-opus-4-6"); - }); - - it("returns the model as-is when no context window is set", () => { - expect(resolveApiModelId({ provider: "claudeAgent", model: "claude-opus-4-6" })).toBe( - "claude-opus-4-6", - ); - expect( - resolveApiModelId({ provider: "claudeAgent", model: "claude-opus-4-6", options: {} }), - ).toBe("claude-opus-4-6"); - }); - - it("returns the model as-is for Codex selections", () => { + it("leaves codex untouched", () => { expect(resolveApiModelId({ provider: "codex", model: "gpt-5.4" })).toBe("gpt-5.4"); }); }); -describe("normalize*ModelOptionsWithCapabilities", () => { - it("preserves explicit false codex fast mode", () => { +describe("normalize model options", () => { + it("preserves codex fast mode and claude context window", () => { expect( normalizeCodexModelOptionsWithCapabilities(codexCaps, { reasoningEffort: "high", fastMode: false, }), - ).toEqual({ - reasoningEffort: "high", - fastMode: false, - }); - }); + ).toEqual({ reasoningEffort: "high", fastMode: false }); - it("preserves the default Claude context window explicitly", () => { expect( - normalizeClaudeModelOptionsWithCapabilities( - { - ...claudeCaps, - contextWindowOptions: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, - ], - }, - { - effort: "high", - contextWindow: "200k", - }, - ), - ).toEqual({ - effort: "high", - contextWindow: "200k", - }); + normalizeClaudeModelOptionsWithCapabilities(claudeCaps, { + effort: "high", + contextWindow: "200k", + }), + ).toEqual({ effort: "high", contextWindow: "200k" }); }); - it("omits unsupported Claude context window options", () => { + it("returns undefined when normalization removes every option", () => { expect( - normalizeClaudeModelOptionsWithCapabilities( - { - ...claudeCaps, - reasoningEffortLevels: [], - supportsThinkingToggle: true, - contextWindowOptions: [], - }, - { - thinking: true, - contextWindow: "1m", - }, - ), - ).toEqual({ - thinking: true, - }); + normalizeCodexModelOptionsWithCapabilities(noOptionsCaps, { + reasoningEffort: "high", + fastMode: true, + }), + ).toBeUndefined(); + + expect( + normalizeCopilotModelOptionsWithCapabilities(noOptionsCaps, { + reasoningEffort: "high", + }), + ).toBeUndefined(); + + expect( + normalizeClaudeModelOptionsWithCapabilities(noOptionsCaps, { + effort: "high", + thinking: false, + fastMode: true, + contextWindow: "1m", + }), + ).toBeUndefined(); }); }); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 1cd7a1ab..094b947f 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -1,9 +1,10 @@ import { DEFAULT_MODEL_BY_PROVIDER, MODEL_SLUG_ALIASES_BY_PROVIDER, - type ClaudeCodeEffort, + type ClaudeAgentEffort, type ClaudeModelOptions, type CodexModelOptions, + type CopilotModelOptions, type ModelCapabilities, type ModelSelection, type ProviderKind, @@ -14,28 +15,14 @@ export interface SelectableModelOption { name: string; } -// ── Effort helpers ──────────────────────────────────────────────────── - -/** Check whether a capabilities object includes a given effort value. */ export function hasEffortLevel(caps: ModelCapabilities, value: string): boolean { return caps.reasoningEffortLevels.some((l) => l.value === value); } -/** Return the default effort value for a capabilities object, or null if none. */ export function getDefaultEffort(caps: ModelCapabilities): string | null { return caps.reasoningEffortLevels.find((l) => l.isDefault)?.value ?? null; } -/** - * Resolve a raw effort option against capabilities. - * - * Returns the effective effort value — the explicit value if supported and not - * prompt-injected, otherwise the model's default. Returns `undefined` only - * when the model has no effort levels at all. - * - * Prompt-injected efforts (e.g. "ultrathink") are excluded because they are - * applied via prompt text, not the effort API parameter. - */ export function resolveEffort( caps: ModelCapabilities, raw: string | null | undefined, @@ -52,30 +39,14 @@ export function resolveEffort( return defaultValue ?? undefined; } -// ── Context window helpers ─────────────────────────────────────────── - -/** Check whether a capabilities object includes a given context window value. */ export function hasContextWindowOption(caps: ModelCapabilities, value: string): boolean { return caps.contextWindowOptions.some((o) => o.value === value); } -/** Return the default context window value, or `null` if none is defined. */ export function getDefaultContextWindow(caps: ModelCapabilities): string | null { return caps.contextWindowOptions.find((o) => o.isDefault)?.value ?? null; } -/** - * Resolve a raw `contextWindow` option against capabilities. - * - * Returns the effective context window value — the explicit value if supported, - * otherwise the model's default. Returns `undefined` only when the model has - * no context window options at all. - * - * Unlike effort levels (where the API has matching defaults), the context - * window requires an explicit API suffix (e.g. `[1m]`), so we always preserve - * the resolved value to avoid ambiguity between "user chose the default" and - * "not specified". - */ export function resolveContextWindow( caps: ModelCapabilities, raw: string | null | undefined, @@ -85,32 +56,40 @@ export function resolveContextWindow( return hasContextWindowOption(caps, raw) ? raw : (defaultValue ?? undefined); } +function emptyObjectToUndefined(value: T): T | undefined { + return Object.keys(value).length === 0 ? undefined : value; +} + +type Mutable = { + -readonly [K in keyof T]: T[K]; +}; + export function normalizeCodexModelOptionsWithCapabilities( caps: ModelCapabilities, modelOptions: CodexModelOptions | null | undefined, ): CodexModelOptions | undefined { const reasoningEffort = resolveEffort(caps, modelOptions?.reasoningEffort); const fastMode = caps.supportsFastMode ? modelOptions?.fastMode : undefined; - const nextOptions: CodexModelOptions = { - ...(reasoningEffort - ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] } - : {}), - ...(fastMode !== undefined ? { fastMode } : {}), - }; - return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; + const normalized: Mutable = {}; + if (reasoningEffort) { + normalized.reasoningEffort = reasoningEffort as CodexModelOptions["reasoningEffort"]; + } + if (fastMode !== undefined) { + normalized.fastMode = fastMode; + } + return emptyObjectToUndefined(normalized); } export function normalizeCopilotModelOptionsWithCapabilities( caps: ModelCapabilities, - modelOptions: CodexModelOptions | null | undefined, -): CodexModelOptions | undefined { + modelOptions: CopilotModelOptions | null | undefined, +): CopilotModelOptions | undefined { const reasoningEffort = resolveEffort(caps, modelOptions?.reasoningEffort); - const nextOptions: CodexModelOptions = { - ...(reasoningEffort - ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] } - : {}), - }; - return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; + const normalized: Mutable = {}; + if (reasoningEffort) { + normalized.reasoningEffort = reasoningEffort as CopilotModelOptions["reasoningEffort"]; + } + return emptyObjectToUndefined(normalized); } export function normalizeClaudeModelOptionsWithCapabilities( @@ -121,13 +100,20 @@ export function normalizeClaudeModelOptionsWithCapabilities( const thinking = caps.supportsThinkingToggle ? modelOptions?.thinking : undefined; const fastMode = caps.supportsFastMode ? modelOptions?.fastMode : undefined; const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow); - const nextOptions: ClaudeModelOptions = { - ...(thinking !== undefined ? { thinking } : {}), - ...(effort ? { effort: effort as ClaudeModelOptions["effort"] } : {}), - ...(fastMode !== undefined ? { fastMode } : {}), - ...(contextWindow !== undefined ? { contextWindow } : {}), - }; - return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; + const normalized: Mutable = {}; + if (thinking !== undefined) { + normalized.thinking = thinking; + } + if (effort) { + normalized.effort = effort as ClaudeModelOptions["effort"]; + } + if (fastMode !== undefined) { + normalized.fastMode = fastMode; + } + if (contextWindow !== undefined) { + normalized.contextWindow = contextWindow as ClaudeModelOptions["contextWindow"]; + } + return emptyObjectToUndefined(normalized); } export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean { @@ -202,24 +188,12 @@ export function resolveModelSlugForProvider( return resolveModelSlug(model, provider); } -/** Trim a string, returning null for empty/missing values. */ export function trimOrNull(value: T | null | undefined): T | null { if (typeof value !== "string") return null; const trimmed = value.trim() as T; return trimmed || null; } -/** - * Resolve the actual API model identifier from a model selection. - * - * Provider-aware: each provider can map `contextWindow` (or other options) - * to whatever the API requires — a model-id suffix, a separate parameter, etc. - * The canonical slug stored in the selection stays unchanged so the - * capabilities system keeps working. - * - * Expects `contextWindow` to already be resolved (via `resolveContextWindow`) - * to the effective value, not stripped to `undefined` for defaults. - */ export function resolveApiModelId(modelSelection: ModelSelection): string { switch (modelSelection.provider) { case "claudeAgent": { @@ -238,7 +212,7 @@ export function resolveApiModelId(modelSelection: ModelSelection): string { export function applyClaudePromptEffortPrefix( text: string, - effort: ClaudeCodeEffort | null | undefined, + effort: ClaudeAgentEffort | null | undefined, ): string { const trimmed = text.trim(); if (!trimmed) { diff --git a/packages/shared/src/path.test.ts b/packages/shared/src/path.test.ts index 912e1e13..1c74c59a 100644 --- a/packages/shared/src/path.test.ts +++ b/packages/shared/src/path.test.ts @@ -4,7 +4,7 @@ import { isUncPath, isWindowsAbsolutePath, isWindowsDrivePath, -} from "./path"; +} from "./path.ts"; describe("path helpers", () => { it("detects windows drive paths", () => { diff --git a/packages/shared/src/qrCode.ts b/packages/shared/src/qrCode.ts index 678d38c1..490e11fa 100644 --- a/packages/shared/src/qrCode.ts +++ b/packages/shared/src/qrCode.ts @@ -1,4 +1,3 @@ -// @ts-nocheck /* oxlint-disable eslint/no-useless-escape */ /* * QR Code generator library (TypeScript) @@ -25,960 +24,962 @@ "use strict"; -namespace qrcodegen { - type bit = number; - type byte = number; - type int = number; - - /*---- QR Code symbol class ----*/ - - /* - * A QR Code symbol, which is a type of two-dimension barcode. - * Invented by Denso Wave and described in the ISO/IEC 18004 standard. - * Instances of this class represent an immutable square grid of dark and light cells. - * The class provides static factory functions to create a QR Code from text or binary data. - * The class covers the QR Code Model 2 specification, supporting all versions (sizes) - * from 1 to 40, all 4 error correction levels, and 4 character encoding modes. - * - * Ways to create a QR Code object: - * - High level: Take the payload data and call QrCode.encodeText() or QrCode.encodeBinary(). - * - Mid level: Custom-make the list of segments and call QrCode.encodeSegments(). - * - Low level: Custom-make the array of data codeword bytes (including - * segment headers and final padding, excluding error correction codewords), - * supply the appropriate version number, and call the QrCode() constructor. - * (Note that all ways require supplying the desired error correction level.) - */ - export class QrCode { - /*-- Static factory functions (high level) --*/ - - // Returns a QR Code representing the given Unicode text string at the given error correction level. - // As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer - // Unicode code points (not UTF-16 code units) if the low error correction level is used. The smallest possible - // QR Code version is automatically chosen for the output. The ECC level of the result may be higher than the - // ecl argument if it can be done without increasing the version. - public static encodeText(text: string, ecl: QrCode.Ecc): QrCode { - const segs: Array = qrcodegen.QrSegment.makeSegments(text); - return QrCode.encodeSegments(segs, ecl); - } +type bit = number; +type byte = number; +type int = number; - // Returns a QR Code representing the given binary data at the given error correction level. - // This function always encodes using the binary segment mode, not any text mode. The maximum number of - // bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output. - // The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version. - public static encodeBinary(data: Readonly>, ecl: QrCode.Ecc): QrCode { - const seg: QrSegment = qrcodegen.QrSegment.makeBytes(data); - return QrCode.encodeSegments([seg], ecl); - } +/*---- QR Code symbol class ----*/ - /*-- Static factory functions (mid level) --*/ - - // Returns a QR Code representing the given segments with the given encoding parameters. - // The smallest possible QR Code version within the given range is automatically - // chosen for the output. Iff boostEcl is true, then the ECC level of the result - // may be higher than the ecl argument if it can be done without increasing the - // version. The mask number is either between 0 to 7 (inclusive) to force that - // mask, or -1 to automatically choose an appropriate mask (which may be slow). - // This function allows the user to create a custom sequence of segments that switches - // between modes (such as alphanumeric and byte) to encode text in less space. - // This is a mid-level API; the high-level API is encodeText() and encodeBinary(). - public static encodeSegments( - segs: Readonly>, - ecl: QrCode.Ecc, - minVersion: int = 1, - maxVersion: int = 40, - mask: int = -1, - boostEcl: boolean = true, - ): QrCode { - if ( - !( - QrCode.MIN_VERSION <= minVersion && - minVersion <= maxVersion && - maxVersion <= QrCode.MAX_VERSION - ) || - mask < -1 || - mask > 7 - ) - throw new RangeError("Invalid value"); - - // Find the minimal version number to use - let version: int; - let dataUsedBits: int; - for (version = minVersion; ; version++) { - const dataCapacityBits: int = QrCode.getNumDataCodewords(version, ecl) * 8; // Number of data bits available - const usedBits: number = QrSegment.getTotalBits(segs, version); - if (usedBits <= dataCapacityBits) { - dataUsedBits = usedBits; - break; // This version number is found to be suitable - } - if (version >= maxVersion) - // All versions in the range could not fit the given data - throw new RangeError("Data too long"); - } +/* + * A QR Code symbol, which is a type of two-dimension barcode. + * Invented by Denso Wave and described in the ISO/IEC 18004 standard. + * Instances of this class represent an immutable square grid of dark and light cells. + * The class provides static factory functions to create a QR Code from text or binary data. + * The class covers the QR Code Model 2 specification, supporting all versions (sizes) + * from 1 to 40, all 4 error correction levels, and 4 character encoding modes. + * + * Ways to create a QR Code object: + * - High level: Take the payload data and call QrCode.encodeText() or QrCode.encodeBinary(). + * - Mid level: Custom-make the list of segments and call QrCode.encodeSegments(). + * - Low level: Custom-make the array of data codeword bytes (including + * segment headers and final padding, excluding error correction codewords), + * supply the appropriate version number, and call the QrCode() constructor. + * (Note that all ways require supplying the desired error correction level.) + */ +export class QrCode { + public static Ecc: typeof QrCodeEcc; + + /*-- Static factory functions (high level) --*/ + + // Returns a QR Code representing the given Unicode text string at the given error correction level. + // As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer + // Unicode code points (not UTF-16 code units) if the low error correction level is used. The smallest possible + // QR Code version is automatically chosen for the output. The ECC level of the result may be higher than the + // ecl argument if it can be done without increasing the version. + public static encodeText(text: string, ecl: QrCodeEcc): QrCode { + const segs: Array = QrSegment.makeSegments(text); + return QrCode.encodeSegments(segs, ecl); + } - // Increase the error correction level while the data still fits in the current version number - for (const newEcl of [QrCode.Ecc.MEDIUM, QrCode.Ecc.QUARTILE, QrCode.Ecc.HIGH]) { - // From low to high - if (boostEcl && dataUsedBits <= QrCode.getNumDataCodewords(version, newEcl) * 8) - ecl = newEcl; - } + // Returns a QR Code representing the given binary data at the given error correction level. + // This function always encodes using the binary segment mode, not any text mode. The maximum number of + // bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output. + // The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version. + public static encodeBinary(data: Readonly>, ecl: QrCodeEcc): QrCode { + const seg: QrSegment = QrSegment.makeBytes(data); + return QrCode.encodeSegments([seg], ecl); + } - // Concatenate all segments to create the data bit string - let bb: Array = []; - for (const seg of segs) { - appendBits(seg.mode.modeBits, 4, bb); - appendBits(seg.numChars, seg.mode.numCharCountBits(version), bb); - for (const b of seg.getData()) bb.push(b); + /*-- Static factory functions (mid level) --*/ + + // Returns a QR Code representing the given segments with the given encoding parameters. + // The smallest possible QR Code version within the given range is automatically + // chosen for the output. Iff boostEcl is true, then the ECC level of the result + // may be higher than the ecl argument if it can be done without increasing the + // version. The mask number is either between 0 to 7 (inclusive) to force that + // mask, or -1 to automatically choose an appropriate mask (which may be slow). + // This function allows the user to create a custom sequence of segments that switches + // between modes (such as alphanumeric and byte) to encode text in less space. + // This is a mid-level API; the high-level API is encodeText() and encodeBinary(). + public static encodeSegments( + segs: Readonly>, + ecl: QrCodeEcc, + minVersion: int = 1, + maxVersion: int = 40, + mask: int = -1, + boostEcl: boolean = true, + ): QrCode { + if ( + !( + QrCode.MIN_VERSION <= minVersion && + minVersion <= maxVersion && + maxVersion <= QrCode.MAX_VERSION + ) || + mask < -1 || + mask > 7 + ) + throw new RangeError("Invalid value"); + + // Find the minimal version number to use + let version: int; + let dataUsedBits: int; + for (version = minVersion; ; version++) { + const dataCapacityBits: int = QrCode.getNumDataCodewords(version, ecl) * 8; // Number of data bits available + const usedBits: number = QrSegment.getTotalBits(segs, version); + if (usedBits <= dataCapacityBits) { + dataUsedBits = usedBits; + break; // This version number is found to be suitable } - assert(bb.length == dataUsedBits); - - // Add terminator and pad up to a byte if applicable - const dataCapacityBits: int = QrCode.getNumDataCodewords(version, ecl) * 8; - assert(bb.length <= dataCapacityBits); - appendBits(0, Math.min(4, dataCapacityBits - bb.length), bb); - appendBits(0, (8 - (bb.length % 8)) % 8, bb); - assert(bb.length % 8 == 0); - - // Pad with alternating bytes until data capacity is reached - for (let padByte = 0xec; bb.length < dataCapacityBits; padByte ^= 0xec ^ 0x11) - appendBits(padByte, 8, bb); - - // Pack bits into bytes in big endian - let dataCodewords: Array = []; - while (dataCodewords.length * 8 < bb.length) dataCodewords.push(0); - bb.forEach((b: bit, i: int) => (dataCodewords[i >>> 3] |= b << (7 - (i & 7)))); - - // Create the QR Code object - return new QrCode(version, ecl, dataCodewords, mask); + if (version >= maxVersion) + // All versions in the range could not fit the given data + throw new RangeError("Data too long"); } - /*-- Fields --*/ - - // The width and height of this QR Code, measured in modules, between - // 21 and 177 (inclusive). This is equal to version * 4 + 17. - public readonly size: int; - - // The index of the mask pattern used in this QR Code, which is between 0 and 7 (inclusive). - // Even if a QR Code is created with automatic masking requested (mask = -1), - // the resulting object still has a mask value between 0 and 7. - public readonly mask: int; - - // The modules of this QR Code (false = light, true = dark). - // Immutable after constructor finishes. Accessed through getModule(). - private readonly modules: Array> = []; - - // Indicates function modules that are not subjected to masking. Discarded when constructor finishes. - private readonly isFunction: Array> = []; - - /*-- Constructor (low level) and fields --*/ - - // Creates a new QR Code with the given version number, - // error correction level, data codeword bytes, and mask number. - // This is a low-level API that most users should not use directly. - // A mid-level API is the encodeSegments() function. - public constructor( - // The version number of this QR Code, which is between 1 and 40 (inclusive). - // This determines the size of this barcode. - public readonly version: int, - - // The error correction level used in this QR Code. - public readonly errorCorrectionLevel: QrCode.Ecc, - - dataCodewords: Readonly>, - - msk: int, - ) { - // Check scalar arguments - if (version < QrCode.MIN_VERSION || version > QrCode.MAX_VERSION) - throw new RangeError("Version value out of range"); - if (msk < -1 || msk > 7) throw new RangeError("Mask value out of range"); - this.size = version * 4 + 17; - - // Initialize both grids to be size*size arrays of Boolean false - let row: Array = []; - for (let i = 0; i < this.size; i++) row.push(false); - for (let i = 0; i < this.size; i++) { - this.modules.push(row.slice()); // Initially all light - this.isFunction.push(row.slice()); - } - - // Compute ECC, draw modules - this.drawFunctionPatterns(); - const allCodewords: Array = this.addEccAndInterleave(dataCodewords); - this.drawCodewords(allCodewords); - - // Do masking - if (msk == -1) { - // Automatically choose best mask - let minPenalty: int = 1000000000; - for (let i = 0; i < 8; i++) { - this.applyMask(i); - this.drawFormatBits(i); - const penalty: int = this.getPenaltyScore(); - if (penalty < minPenalty) { - msk = i; - minPenalty = penalty; - } - this.applyMask(i); // Undoes the mask due to XOR - } - } - assert(0 <= msk && msk <= 7); - this.mask = msk; - this.applyMask(msk); // Apply the final choice of mask - this.drawFormatBits(msk); // Overwrite old format bits + // Increase the error correction level while the data still fits in the current version number + for (const newEcl of [QrCode.Ecc.MEDIUM, QrCode.Ecc.QUARTILE, QrCode.Ecc.HIGH]) { + // From low to high + if (boostEcl && dataUsedBits <= QrCode.getNumDataCodewords(version, newEcl) * 8) ecl = newEcl; + } - this.isFunction = []; + // Concatenate all segments to create the data bit string + let bb: Array = []; + for (const seg of segs) { + appendBits(seg.mode.modeBits, 4, bb); + appendBits(seg.numChars, seg.mode.numCharCountBits(version), bb); + for (const b of seg.getData()) bb.push(b); } + assert(bb.length == dataUsedBits); - /*-- Accessor methods --*/ + // Add terminator and pad up to a byte if applicable + const dataCapacityBits: int = QrCode.getNumDataCodewords(version, ecl) * 8; + assert(bb.length <= dataCapacityBits); + appendBits(0, Math.min(4, dataCapacityBits - bb.length), bb); + appendBits(0, (8 - (bb.length % 8)) % 8, bb); + assert(bb.length % 8 == 0); - // Returns the color of the module (pixel) at the given coordinates, which is false - // for light or true for dark. The top left corner has the coordinates (x=0, y=0). - // If the given coordinates are out of bounds, then false (light) is returned. - public getModule(x: int, y: int): boolean { - return 0 <= x && x < this.size && 0 <= y && y < this.size && this.modules[y][x]; - } + // Pad with alternating bytes until data capacity is reached + for (let padByte = 0xec; bb.length < dataCapacityBits; padByte ^= 0xec ^ 0x11) + appendBits(padByte, 8, bb); - /*-- Private helper methods for constructor: Drawing function modules --*/ + // Pack bits into bytes in big endian + let dataCodewords: Array = []; + while (dataCodewords.length * 8 < bb.length) dataCodewords.push(0); + bb.forEach((b: bit, i: int) => (dataCodewords[i >>> 3]! |= b << (7 - (i & 7)))); - // Reads this object's version field, and draws and marks all function modules. - private drawFunctionPatterns(): void { - // Draw horizontal and vertical timing patterns - for (let i = 0; i < this.size; i++) { - this.setFunctionModule(6, i, i % 2 == 0); - this.setFunctionModule(i, 6, i % 2 == 0); - } + // Create the QR Code object + return new QrCode(version, ecl, dataCodewords, mask); + } - // Draw 3 finder patterns (all corners except bottom right; overwrites some timing modules) - this.drawFinderPattern(3, 3); - this.drawFinderPattern(this.size - 4, 3); - this.drawFinderPattern(3, this.size - 4); - - // Draw numerous alignment patterns - const alignPatPos: Array = this.getAlignmentPatternPositions(); - const numAlign: int = alignPatPos.length; - for (let i = 0; i < numAlign; i++) { - for (let j = 0; j < numAlign; j++) { - // Don't draw on the three finder corners - if ( - !((i == 0 && j == 0) || (i == 0 && j == numAlign - 1) || (i == numAlign - 1 && j == 0)) - ) - this.drawAlignmentPattern(alignPatPos[i], alignPatPos[j]); + /*-- Fields --*/ + + // The width and height of this QR Code, measured in modules, between + // 21 and 177 (inclusive). This is equal to version * 4 + 17. + public readonly version: int; + public readonly errorCorrectionLevel: QrCodeEcc; + public readonly size: int; + + // The index of the mask pattern used in this QR Code, which is between 0 and 7 (inclusive). + // Even if a QR Code is created with automatic masking requested (mask = -1), + // the resulting object still has a mask value between 0 and 7. + public readonly mask: int; + + // The modules of this QR Code (false = light, true = dark). + // Immutable after constructor finishes. Accessed through getModule(). + private readonly modules: Array> = []; + + // Indicates function modules that are not subjected to masking. Discarded when constructor finishes. + private readonly isFunction: Array> = []; + + /*-- Constructor (low level) and fields --*/ + + // Creates a new QR Code with the given version number, + // error correction level, data codeword bytes, and mask number. + // This is a low-level API that most users should not use directly. + // A mid-level API is the encodeSegments() function. + public constructor( + // The version number of this QR Code, which is between 1 and 40 (inclusive). + // This determines the size of this barcode. + version: int, + + // The error correction level used in this QR Code. + errorCorrectionLevel: QrCodeEcc, + + dataCodewords: Readonly>, + + msk: int, + ) { + this.version = version; + this.errorCorrectionLevel = errorCorrectionLevel; + + // Check scalar arguments + if (version < QrCode.MIN_VERSION || version > QrCode.MAX_VERSION) + throw new RangeError("Version value out of range"); + if (msk < -1 || msk > 7) throw new RangeError("Mask value out of range"); + this.size = version * 4 + 17; + + // Initialize both grids to be size*size arrays of Boolean false + let row: Array = []; + for (let i = 0; i < this.size; i++) row.push(false); + for (let i = 0; i < this.size; i++) { + this.modules.push(row.slice()); // Initially all light + this.isFunction.push(row.slice()); + } + + // Compute ECC, draw modules + this.drawFunctionPatterns(); + const allCodewords: Array = this.addEccAndInterleave(dataCodewords); + this.drawCodewords(allCodewords); + + // Do masking + if (msk == -1) { + // Automatically choose best mask + let minPenalty: int = 1000000000; + for (let i = 0; i < 8; i++) { + this.applyMask(i); + this.drawFormatBits(i); + const penalty: int = this.getPenaltyScore(); + if (penalty < minPenalty) { + msk = i; + minPenalty = penalty; } + this.applyMask(i); // Undoes the mask due to XOR } - - // Draw configuration data - this.drawFormatBits(0); // Dummy mask value; overwritten later in the constructor - this.drawVersion(); } + assert(0 <= msk && msk <= 7); + this.mask = msk; + this.applyMask(msk); // Apply the final choice of mask + this.drawFormatBits(msk); // Overwrite old format bits - // Draws two copies of the format bits (with its own error correction code) - // based on the given mask and this object's error correction level field. - private drawFormatBits(mask: int): void { - // Calculate error correction code and pack bits - const data: int = (this.errorCorrectionLevel.formatBits << 3) | mask; // errCorrLvl is uint2, mask is uint3 - let rem: int = data; - for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537); - const bits = ((data << 10) | rem) ^ 0x5412; // uint15 - assert(bits >>> 15 == 0); - - // Draw first copy - for (let i = 0; i <= 5; i++) this.setFunctionModule(8, i, getBit(bits, i)); - this.setFunctionModule(8, 7, getBit(bits, 6)); - this.setFunctionModule(8, 8, getBit(bits, 7)); - this.setFunctionModule(7, 8, getBit(bits, 8)); - for (let i = 9; i < 15; i++) this.setFunctionModule(14 - i, 8, getBit(bits, i)); - - // Draw second copy - for (let i = 0; i < 8; i++) this.setFunctionModule(this.size - 1 - i, 8, getBit(bits, i)); - for (let i = 8; i < 15; i++) this.setFunctionModule(8, this.size - 15 + i, getBit(bits, i)); - this.setFunctionModule(8, this.size - 8, true); // Always dark - } + this.isFunction = []; + } + + /*-- Accessor methods --*/ + + // Returns the color of the module (pixel) at the given coordinates, which is false + // for light or true for dark. The top left corner has the coordinates (x=0, y=0). + // If the given coordinates are out of bounds, then false (light) is returned. + public getModule(x: int, y: int): boolean { + return 0 <= x && x < this.size && 0 <= y && y < this.size && this.modules[y]![x]!; + } - // Draws two copies of the version bits (with its own error correction code), - // based on this object's version field, iff 7 <= version <= 40. - private drawVersion(): void { - if (this.version < 7) return; - - // Calculate error correction code and pack bits - let rem: int = this.version; // version is uint6, in the range [7, 40] - for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25); - const bits: int = (this.version << 12) | rem; // uint18 - assert(bits >>> 18 == 0); - - // Draw two copies - for (let i = 0; i < 18; i++) { - const color: boolean = getBit(bits, i); - const a: int = this.size - 11 + (i % 3); - const b: int = Math.floor(i / 3); - this.setFunctionModule(a, b, color); - this.setFunctionModule(b, a, color); + /*-- Private helper methods for constructor: Drawing function modules --*/ + + // Reads this object's version field, and draws and marks all function modules. + private drawFunctionPatterns(): void { + // Draw horizontal and vertical timing patterns + for (let i = 0; i < this.size; i++) { + this.setFunctionModule(6, i, i % 2 == 0); + this.setFunctionModule(i, 6, i % 2 == 0); + } + + // Draw 3 finder patterns (all corners except bottom right; overwrites some timing modules) + this.drawFinderPattern(3, 3); + this.drawFinderPattern(this.size - 4, 3); + this.drawFinderPattern(3, this.size - 4); + + // Draw numerous alignment patterns + const alignPatPos: Array = this.getAlignmentPatternPositions(); + const numAlign: int = alignPatPos.length; + for (let i = 0; i < numAlign; i++) { + for (let j = 0; j < numAlign; j++) { + // Don't draw on the three finder corners + if (!((i == 0 && j == 0) || (i == 0 && j == numAlign - 1) || (i == numAlign - 1 && j == 0))) + this.drawAlignmentPattern(alignPatPos[i]!, alignPatPos[j]!); } } - // Draws a 9*9 finder pattern including the border separator, - // with the center module at (x, y). Modules can be out of bounds. - private drawFinderPattern(x: int, y: int): void { - for (let dy = -4; dy <= 4; dy++) { - for (let dx = -4; dx <= 4; dx++) { - const dist: int = Math.max(Math.abs(dx), Math.abs(dy)); // Chebyshev/infinity norm - const xx: int = x + dx; - const yy: int = y + dy; - if (0 <= xx && xx < this.size && 0 <= yy && yy < this.size) - this.setFunctionModule(xx, yy, dist != 2 && dist != 4); - } - } + // Draw configuration data + this.drawFormatBits(0); // Dummy mask value; overwritten later in the constructor + this.drawVersion(); + } + + // Draws two copies of the format bits (with its own error correction code) + // based on the given mask and this object's error correction level field. + private drawFormatBits(mask: int): void { + // Calculate error correction code and pack bits + const data: int = (this.errorCorrectionLevel.formatBits << 3) | mask; // errCorrLvl is uint2, mask is uint3 + let rem: int = data; + for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537); + const bits = ((data << 10) | rem) ^ 0x5412; // uint15 + assert(bits >>> 15 == 0); + + // Draw first copy + for (let i = 0; i <= 5; i++) this.setFunctionModule(8, i, getBit(bits, i)); + this.setFunctionModule(8, 7, getBit(bits, 6)); + this.setFunctionModule(8, 8, getBit(bits, 7)); + this.setFunctionModule(7, 8, getBit(bits, 8)); + for (let i = 9; i < 15; i++) this.setFunctionModule(14 - i, 8, getBit(bits, i)); + + // Draw second copy + for (let i = 0; i < 8; i++) this.setFunctionModule(this.size - 1 - i, 8, getBit(bits, i)); + for (let i = 8; i < 15; i++) this.setFunctionModule(8, this.size - 15 + i, getBit(bits, i)); + this.setFunctionModule(8, this.size - 8, true); // Always dark + } + + // Draws two copies of the version bits (with its own error correction code), + // based on this object's version field, iff 7 <= version <= 40. + private drawVersion(): void { + if (this.version < 7) return; + + // Calculate error correction code and pack bits + let rem: int = this.version; // version is uint6, in the range [7, 40] + for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25); + const bits: int = (this.version << 12) | rem; // uint18 + assert(bits >>> 18 == 0); + + // Draw two copies + for (let i = 0; i < 18; i++) { + const color: boolean = getBit(bits, i); + const a: int = this.size - 11 + (i % 3); + const b: int = Math.floor(i / 3); + this.setFunctionModule(a, b, color); + this.setFunctionModule(b, a, color); } + } - // Draws a 5*5 alignment pattern, with the center module - // at (x, y). All modules must be in bounds. - private drawAlignmentPattern(x: int, y: int): void { - for (let dy = -2; dy <= 2; dy++) { - for (let dx = -2; dx <= 2; dx++) - this.setFunctionModule(x + dx, y + dy, Math.max(Math.abs(dx), Math.abs(dy)) != 1); + // Draws a 9*9 finder pattern including the border separator, + // with the center module at (x, y). Modules can be out of bounds. + private drawFinderPattern(x: int, y: int): void { + for (let dy = -4; dy <= 4; dy++) { + for (let dx = -4; dx <= 4; dx++) { + const dist: int = Math.max(Math.abs(dx), Math.abs(dy)); // Chebyshev/infinity norm + const xx: int = x + dx; + const yy: int = y + dy; + if (0 <= xx && xx < this.size && 0 <= yy && yy < this.size) + this.setFunctionModule(xx, yy, dist != 2 && dist != 4); } } + } - // Sets the color of a module and marks it as a function module. - // Only used by the constructor. Coordinates must be in bounds. - private setFunctionModule(x: int, y: int, isDark: boolean): void { - this.modules[y][x] = isDark; - this.isFunction[y][x] = true; + // Draws a 5*5 alignment pattern, with the center module + // at (x, y). All modules must be in bounds. + private drawAlignmentPattern(x: int, y: int): void { + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) + this.setFunctionModule(x + dx, y + dy, Math.max(Math.abs(dx), Math.abs(dy)) != 1); } + } - /*-- Private helper methods for constructor: Codewords and masking --*/ - - // Returns a new byte string representing the given data with the appropriate error correction - // codewords appended to it, based on this object's version and error correction level. - private addEccAndInterleave(data: Readonly>): Array { - const ver: int = this.version; - const ecl: QrCode.Ecc = this.errorCorrectionLevel; - if (data.length != QrCode.getNumDataCodewords(ver, ecl)) - throw new RangeError("Invalid argument"); - - // Calculate parameter numbers - const numBlocks: int = QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver]; - const blockEccLen: int = QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver]; - const rawCodewords: int = Math.floor(QrCode.getNumRawDataModules(ver) / 8); - const numShortBlocks: int = numBlocks - (rawCodewords % numBlocks); - const shortBlockLen: int = Math.floor(rawCodewords / numBlocks); - - // Split data into blocks and append ECC to each block - let blocks: Array> = []; - const rsDiv: Array = QrCode.reedSolomonComputeDivisor(blockEccLen); - for (let i = 0, k = 0; i < numBlocks; i++) { - let dat: Array = data.slice( - k, - k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1), - ); - k += dat.length; - const ecc: Array = QrCode.reedSolomonComputeRemainder(dat, rsDiv); - if (i < numShortBlocks) dat.push(0); - blocks.push(dat.concat(ecc)); - } + // Sets the color of a module and marks it as a function module. + // Only used by the constructor. Coordinates must be in bounds. + private setFunctionModule(x: int, y: int, isDark: boolean): void { + this.modules[y]![x] = isDark; + this.isFunction[y]![x] = true; + } - // Interleave (not concatenate) the bytes from every block into a single sequence - let result: Array = []; - for (let i = 0; i < blocks[0].length; i++) { - blocks.forEach((block, j) => { - // Skip the padding byte in short blocks - if (i != shortBlockLen - blockEccLen || j >= numShortBlocks) result.push(block[i]); - }); - } - assert(result.length == rawCodewords); - return result; - } + /*-- Private helper methods for constructor: Codewords and masking --*/ + + // Returns a new byte string representing the given data with the appropriate error correction + // codewords appended to it, based on this object's version and error correction level. + private addEccAndInterleave(data: Readonly>): Array { + const ver: int = this.version; + const ecl: QrCodeEcc = this.errorCorrectionLevel; + if (data.length != QrCode.getNumDataCodewords(ver, ecl)) + throw new RangeError("Invalid argument"); + + // Calculate parameter numbers + const numBlocks: int = QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal]![ver]!; + const blockEccLen: int = QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal]![ver]!; + const rawCodewords: int = Math.floor(QrCode.getNumRawDataModules(ver) / 8); + const numShortBlocks: int = numBlocks - (rawCodewords % numBlocks); + const shortBlockLen: int = Math.floor(rawCodewords / numBlocks); + + // Split data into blocks and append ECC to each block + let blocks: Array> = []; + const rsDiv: Array = QrCode.reedSolomonComputeDivisor(blockEccLen); + for (let i = 0, k = 0; i < numBlocks; i++) { + let dat: Array = data.slice( + k, + k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1), + ); + k += dat.length; + const ecc: Array = QrCode.reedSolomonComputeRemainder(dat, rsDiv); + if (i < numShortBlocks) dat.push(0); + blocks.push(dat.concat(ecc)); + } + + // Interleave (not concatenate) the bytes from every block into a single sequence + let result: Array = []; + for (let i = 0; i < blocks[0]!.length; i++) { + blocks.forEach((block, j) => { + // Skip the padding byte in short blocks + if (i != shortBlockLen - blockEccLen || j >= numShortBlocks) result.push(block[i]!); + }); + } + assert(result.length == rawCodewords); + return result; + } - // Draws the given sequence of 8-bit codewords (data and error correction) onto the entire - // data area of this QR Code. Function modules need to be marked off before this is called. - private drawCodewords(data: Readonly>): void { - if (data.length != Math.floor(QrCode.getNumRawDataModules(this.version) / 8)) - throw new RangeError("Invalid argument"); - let i: int = 0; // Bit index into the data - // Do the funny zigzag scan - for (let right = this.size - 1; right >= 1; right -= 2) { - // Index of right column in each column pair - if (right == 6) right = 5; - for (let vert = 0; vert < this.size; vert++) { - // Vertical counter - for (let j = 0; j < 2; j++) { - const x: int = right - j; // Actual x coordinate - const upward: boolean = ((right + 1) & 2) == 0; - const y: int = upward ? this.size - 1 - vert : vert; // Actual y coordinate - if (!this.isFunction[y][x] && i < data.length * 8) { - this.modules[y][x] = getBit(data[i >>> 3], 7 - (i & 7)); - i++; - } - // If this QR Code has any remainder bits (0 to 7), they were assigned as - // 0/false/light by the constructor and are left unchanged by this method + // Draws the given sequence of 8-bit codewords (data and error correction) onto the entire + // data area of this QR Code. Function modules need to be marked off before this is called. + private drawCodewords(data: Readonly>): void { + if (data.length != Math.floor(QrCode.getNumRawDataModules(this.version) / 8)) + throw new RangeError("Invalid argument"); + let i: int = 0; // Bit index into the data + // Do the funny zigzag scan + for (let right = this.size - 1; right >= 1; right -= 2) { + // Index of right column in each column pair + if (right == 6) right = 5; + for (let vert = 0; vert < this.size; vert++) { + // Vertical counter + for (let j = 0; j < 2; j++) { + const x: int = right - j; // Actual x coordinate + const upward: boolean = ((right + 1) & 2) == 0; + const y: int = upward ? this.size - 1 - vert : vert; // Actual y coordinate + if (!this.isFunction[y]![x]! && i < data.length * 8) { + this.modules[y]![x] = getBit(data[i >>> 3]!, 7 - (i & 7)); + i++; } + // If this QR Code has any remainder bits (0 to 7), they were assigned as + // 0/false/light by the constructor and are left unchanged by this method } } - assert(i == data.length * 8); } + assert(i == data.length * 8); + } - // XORs the codeword modules in this QR Code with the given mask pattern. - // The function modules must be marked and the codeword bits must be drawn - // before masking. Due to the arithmetic of XOR, calling applyMask() with - // the same mask value a second time will undo the mask. A final well-formed - // QR Code needs exactly one (not zero, two, etc.) mask applied. - private applyMask(mask: int): void { - if (mask < 0 || mask > 7) throw new RangeError("Mask value out of range"); - for (let y = 0; y < this.size; y++) { - for (let x = 0; x < this.size; x++) { - let invert: boolean; - switch (mask) { - case 0: - invert = (x + y) % 2 == 0; - break; - case 1: - invert = y % 2 == 0; - break; - case 2: - invert = x % 3 == 0; - break; - case 3: - invert = (x + y) % 3 == 0; - break; - case 4: - invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 == 0; - break; - case 5: - invert = ((x * y) % 2) + ((x * y) % 3) == 0; - break; - case 6: - invert = (((x * y) % 2) + ((x * y) % 3)) % 2 == 0; - break; - case 7: - invert = (((x + y) % 2) + ((x * y) % 3)) % 2 == 0; - break; - default: - throw new Error("Unreachable"); - } - if (!this.isFunction[y][x] && invert) this.modules[y][x] = !this.modules[y][x]; + // XORs the codeword modules in this QR Code with the given mask pattern. + // The function modules must be marked and the codeword bits must be drawn + // before masking. Due to the arithmetic of XOR, calling applyMask() with + // the same mask value a second time will undo the mask. A final well-formed + // QR Code needs exactly one (not zero, two, etc.) mask applied. + private applyMask(mask: int): void { + if (mask < 0 || mask > 7) throw new RangeError("Mask value out of range"); + for (let y = 0; y < this.size; y++) { + for (let x = 0; x < this.size; x++) { + let invert: boolean; + switch (mask) { + case 0: + invert = (x + y) % 2 == 0; + break; + case 1: + invert = y % 2 == 0; + break; + case 2: + invert = x % 3 == 0; + break; + case 3: + invert = (x + y) % 3 == 0; + break; + case 4: + invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 == 0; + break; + case 5: + invert = ((x * y) % 2) + ((x * y) % 3) == 0; + break; + case 6: + invert = (((x * y) % 2) + ((x * y) % 3)) % 2 == 0; + break; + case 7: + invert = (((x + y) % 2) + ((x * y) % 3)) % 2 == 0; + break; + default: + throw new Error("Unreachable"); } + if (!this.isFunction[y]![x]! && invert) this.modules[y]![x] = !this.modules[y]![x]!; } } + } - // Calculates and returns the penalty score based on state of this QR Code's current modules. - // This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score. - private getPenaltyScore(): int { - let result: int = 0; + // Calculates and returns the penalty score based on state of this QR Code's current modules. + // This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score. + private getPenaltyScore(): int { + let result: int = 0; - // Adjacent modules in row having same color, and finder-like patterns - for (let y = 0; y < this.size; y++) { - let runColor = false; - let runX = 0; - let runHistory = [0, 0, 0, 0, 0, 0, 0]; - for (let x = 0; x < this.size; x++) { - if (this.modules[y][x] == runColor) { - runX++; - if (runX == 5) result += QrCode.PENALTY_N1; - else if (runX > 5) result++; - } else { - this.finderPenaltyAddHistory(runX, runHistory); - if (!runColor) - result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; - runColor = this.modules[y][x]; - runX = 1; - } - } - result += - this.finderPenaltyTerminateAndCount(runColor, runX, runHistory) * QrCode.PENALTY_N3; - } - // Adjacent modules in column having same color, and finder-like patterns + // Adjacent modules in row having same color, and finder-like patterns + for (let y = 0; y < this.size; y++) { + let runColor = false; + let runX = 0; + let runHistory = [0, 0, 0, 0, 0, 0, 0]; for (let x = 0; x < this.size; x++) { - let runColor = false; - let runY = 0; - let runHistory = [0, 0, 0, 0, 0, 0, 0]; - for (let y = 0; y < this.size; y++) { - if (this.modules[y][x] == runColor) { - runY++; - if (runY == 5) result += QrCode.PENALTY_N1; - else if (runY > 5) result++; - } else { - this.finderPenaltyAddHistory(runY, runHistory); - if (!runColor) - result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; - runColor = this.modules[y][x]; - runY = 1; - } + if (this.modules[y]![x] === runColor) { + runX++; + if (runX == 5) result += QrCode.PENALTY_N1; + else if (runX > 5) result++; + } else { + this.finderPenaltyAddHistory(runX, runHistory); + if (!runColor) result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; + runColor = this.modules[y]![x]!; + runX = 1; } - result += - this.finderPenaltyTerminateAndCount(runColor, runY, runHistory) * QrCode.PENALTY_N3; } - - // 2*2 blocks of modules having same color - for (let y = 0; y < this.size - 1; y++) { - for (let x = 0; x < this.size - 1; x++) { - const color: boolean = this.modules[y][x]; - if ( - color == this.modules[y][x + 1] && - color == this.modules[y + 1][x] && - color == this.modules[y + 1][x + 1] - ) - result += QrCode.PENALTY_N2; + result += this.finderPenaltyTerminateAndCount(runColor, runX, runHistory) * QrCode.PENALTY_N3; + } + // Adjacent modules in column having same color, and finder-like patterns + for (let x = 0; x < this.size; x++) { + let runColor = false; + let runY = 0; + let runHistory = [0, 0, 0, 0, 0, 0, 0]; + for (let y = 0; y < this.size; y++) { + if (this.modules[y]![x] === runColor) { + runY++; + if (runY == 5) result += QrCode.PENALTY_N1; + else if (runY > 5) result++; + } else { + this.finderPenaltyAddHistory(runY, runHistory); + if (!runColor) result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; + runColor = this.modules[y]![x]!; + runY = 1; } } - - // Balance of dark and light modules - let dark: int = 0; - for (const row of this.modules) - dark = row.reduce((sum, color) => sum + (color ? 1 : 0), dark); - const total: int = this.size * this.size; // Note that size is odd, so dark/total != 1/2 - // Compute the smallest integer k >= 0 such that (45-5k)% <= dark/total <= (55+5k)% - const k: int = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1; - assert(0 <= k && k <= 9); - result += k * QrCode.PENALTY_N4; - assert(0 <= result && result <= 2568888); // Non-tight upper bound based on default values of PENALTY_N1, ..., N4 - return result; - } - - /*-- Private helper functions --*/ - - // Returns an ascending list of positions of alignment patterns for this version number. - // Each position is in the range [0,177), and are used on both the x and y axes. - // This could be implemented as lookup table of 40 variable-length lists of integers. - private getAlignmentPatternPositions(): Array { - if (this.version == 1) return []; - else { - const numAlign: int = Math.floor(this.version / 7) + 2; - const step: int = - this.version == 32 ? 26 : Math.ceil((this.version * 4 + 4) / (numAlign * 2 - 2)) * 2; - let result: Array = [6]; - for (let pos = this.size - 7; result.length < numAlign; pos -= step) - result.splice(1, 0, pos); - return result; + result += this.finderPenaltyTerminateAndCount(runColor, runY, runHistory) * QrCode.PENALTY_N3; + } + + // 2*2 blocks of modules having same color + for (let y = 0; y < this.size - 1; y++) { + for (let x = 0; x < this.size - 1; x++) { + const color: boolean = this.modules[y]![x]!; + if ( + color == this.modules[y]![x + 1]! && + color == this.modules[y + 1]![x]! && + color == this.modules[y + 1]![x + 1]! + ) + result += QrCode.PENALTY_N2; } } - // Returns the number of data bits that can be stored in a QR Code of the given version number, after - // all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8. - // The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table. - private static getNumRawDataModules(ver: int): int { - if (ver < QrCode.MIN_VERSION || ver > QrCode.MAX_VERSION) - throw new RangeError("Version number out of range"); - let result: int = (16 * ver + 128) * ver + 64; - if (ver >= 2) { - const numAlign: int = Math.floor(ver / 7) + 2; - result -= (25 * numAlign - 10) * numAlign - 55; - if (ver >= 7) result -= 36; - } - assert(208 <= result && result <= 29648); + // Balance of dark and light modules + let dark: int = 0; + for (const row of this.modules) dark = row.reduce((sum, color) => sum + (color ? 1 : 0), dark); + const total: int = this.size * this.size; // Note that size is odd, so dark/total != 1/2 + // Compute the smallest integer k >= 0 such that (45-5k)% <= dark/total <= (55+5k)% + const k: int = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1; + assert(0 <= k && k <= 9); + result += k * QrCode.PENALTY_N4; + assert(0 <= result && result <= 2568888); // Non-tight upper bound based on default values of PENALTY_N1, ..., N4 + return result; + } + + /*-- Private helper functions --*/ + + // Returns an ascending list of positions of alignment patterns for this version number. + // Each position is in the range [0,177), and are used on both the x and y axes. + // This could be implemented as lookup table of 40 variable-length lists of integers. + private getAlignmentPatternPositions(): Array { + if (this.version == 1) return []; + else { + const numAlign: int = Math.floor(this.version / 7) + 2; + const step: int = + this.version == 32 ? 26 : Math.ceil((this.version * 4 + 4) / (numAlign * 2 - 2)) * 2; + let result: Array = [6]; + for (let pos = this.size - 7; result.length < numAlign; pos -= step) result.splice(1, 0, pos); return result; } + } - // Returns the number of 8-bit data (i.e. not error correction) codewords contained in any - // QR Code of the given version number and error correction level, with remainder bits discarded. - // This stateless pure function could be implemented as a (40*4)-cell lookup table. - private static getNumDataCodewords(ver: int, ecl: QrCode.Ecc): int { - return ( - Math.floor(QrCode.getNumRawDataModules(ver) / 8) - - QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver] * - QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver] - ); - } + // Returns the number of data bits that can be stored in a QR Code of the given version number, after + // all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8. + // The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table. + private static getNumRawDataModules(ver: int): int { + if (ver < QrCode.MIN_VERSION || ver > QrCode.MAX_VERSION) + throw new RangeError("Version number out of range"); + let result: int = (16 * ver + 128) * ver + 64; + if (ver >= 2) { + const numAlign: int = Math.floor(ver / 7) + 2; + result -= (25 * numAlign - 10) * numAlign - 55; + if (ver >= 7) result -= 36; + } + assert(208 <= result && result <= 29648); + return result; + } - // Returns a Reed-Solomon ECC generator polynomial for the given degree. This could be - // implemented as a lookup table over all possible parameter values, instead of as an algorithm. - private static reedSolomonComputeDivisor(degree: int): Array { - if (degree < 1 || degree > 255) throw new RangeError("Degree out of range"); - // Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1. - // For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array [255, 8, 93]. - let result: Array = []; - for (let i = 0; i < degree - 1; i++) result.push(0); - result.push(1); // Start off with the monomial x^0 - - // Compute the product polynomial (x - r^0) * (x - r^1) * (x - r^2) * ... * (x - r^{degree-1}), - // and drop the highest monomial term which is always 1x^degree. - // Note that r = 0x02, which is a generator element of this field GF(2^8/0x11D). - let root = 1; - for (let i = 0; i < degree; i++) { - // Multiply the current product by (x - r^i) - for (let j = 0; j < result.length; j++) { - result[j] = QrCode.reedSolomonMultiply(result[j], root); - if (j + 1 < result.length) result[j] ^= result[j + 1]; - } - root = QrCode.reedSolomonMultiply(root, 0x02); - } - return result; - } + // Returns the number of 8-bit data (i.e. not error correction) codewords contained in any + // QR Code of the given version number and error correction level, with remainder bits discarded. + // This stateless pure function could be implemented as a (40*4)-cell lookup table. + private static getNumDataCodewords(ver: int, ecl: QrCodeEcc): int { + return ( + Math.floor(QrCode.getNumRawDataModules(ver) / 8) - + QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal]![ver]! * + QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal]![ver]! + ); + } - // Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials. - private static reedSolomonComputeRemainder( - data: Readonly>, - divisor: Readonly>, - ): Array { - let result: Array = divisor.map((_) => 0); - for (const b of data) { - // Polynomial division - const factor: byte = b ^ (result.shift() as byte); - result.push(0); - divisor.forEach((coef, i) => (result[i] ^= QrCode.reedSolomonMultiply(coef, factor))); + // Returns a Reed-Solomon ECC generator polynomial for the given degree. This could be + // implemented as a lookup table over all possible parameter values, instead of as an algorithm. + private static reedSolomonComputeDivisor(degree: int): Array { + if (degree < 1 || degree > 255) throw new RangeError("Degree out of range"); + // Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1. + // For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array [255, 8, 93]. + let result: Array = []; + for (let i = 0; i < degree - 1; i++) result.push(0); + result.push(1); // Start off with the monomial x^0 + + // Compute the product polynomial (x - r^0) * (x - r^1) * (x - r^2) * ... * (x - r^{degree-1}), + // and drop the highest monomial term which is always 1x^degree. + // Note that r = 0x02, which is a generator element of this field GF(2^8/0x11D). + let root = 1; + for (let i = 0; i < degree; i++) { + // Multiply the current product by (x - r^i) + for (let j = 0; j < result.length; j++) { + result[j] = QrCode.reedSolomonMultiply(result[j]!, root); + if (j + 1 < result.length) result[j]! ^= result[j + 1]!; } - return result; + root = QrCode.reedSolomonMultiply(root, 0x02); } + return result; + } - // Returns the product of the two given field elements modulo GF(2^8/0x11D). The arguments and result - // are unsigned 8-bit integers. This could be implemented as a lookup table of 256*256 entries of uint8. - private static reedSolomonMultiply(x: byte, y: byte): byte { - if (x >>> 8 != 0 || y >>> 8 != 0) throw new RangeError("Byte out of range"); - // Russian peasant multiplication - let z: int = 0; - for (let i = 7; i >= 0; i--) { - z = (z << 1) ^ ((z >>> 7) * 0x11d); - z ^= ((y >>> i) & 1) * x; - } - assert(z >>> 8 == 0); - return z as byte; - } + // Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials. + private static reedSolomonComputeRemainder( + data: Readonly>, + divisor: Readonly>, + ): Array { + let result: Array = divisor.map((_) => 0); + for (const b of data) { + // Polynomial division + const factor: byte = b ^ (result.shift() as byte); + result.push(0); + divisor.forEach((coef, i) => (result[i]! ^= QrCode.reedSolomonMultiply(coef, factor))); + } + return result; + } - // Can only be called immediately after a light run is added, and - // returns either 0, 1, or 2. A helper function for getPenaltyScore(). - private finderPenaltyCountPatterns(runHistory: Readonly>): int { - const n: int = runHistory[1]; - assert(n <= this.size * 3); - const core: boolean = - n > 0 && - runHistory[2] == n && - runHistory[3] == n * 3 && - runHistory[4] == n && - runHistory[5] == n; - return ( - (core && runHistory[0] >= n * 4 && runHistory[6] >= n ? 1 : 0) + - (core && runHistory[6] >= n * 4 && runHistory[0] >= n ? 1 : 0) - ); - } + // Returns the product of the two given field elements modulo GF(2^8/0x11D). The arguments and result + // are unsigned 8-bit integers. This could be implemented as a lookup table of 256*256 entries of uint8. + private static reedSolomonMultiply(x: byte, y: byte): byte { + if (x >>> 8 != 0 || y >>> 8 != 0) throw new RangeError("Byte out of range"); + // Russian peasant multiplication + let z: int = 0; + for (let i = 7; i >= 0; i--) { + z = (z << 1) ^ ((z >>> 7) * 0x11d); + z ^= ((y >>> i) & 1) * x; + } + assert(z >>> 8 == 0); + return z as byte; + } - // Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore(). - private finderPenaltyTerminateAndCount( - currentRunColor: boolean, - currentRunLength: int, - runHistory: Array, - ): int { - if (currentRunColor) { - // Terminate dark run - this.finderPenaltyAddHistory(currentRunLength, runHistory); - currentRunLength = 0; - } - currentRunLength += this.size; // Add light border to final run + // Can only be called immediately after a light run is added, and + // returns either 0, 1, or 2. A helper function for getPenaltyScore(). + private finderPenaltyCountPatterns(runHistory: Readonly>): int { + const n: int = runHistory[1]!; + assert(n <= this.size * 3); + const core: boolean = + n > 0 && + runHistory[2] === n && + runHistory[3] === n * 3 && + runHistory[4] === n && + runHistory[5] === n; + return ( + (core && runHistory[0]! >= n * 4 && runHistory[6]! >= n ? 1 : 0) + + (core && runHistory[6]! >= n * 4 && runHistory[0]! >= n ? 1 : 0) + ); + } + + // Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore(). + private finderPenaltyTerminateAndCount( + currentRunColor: boolean, + currentRunLength: int, + runHistory: Array, + ): int { + if (currentRunColor) { + // Terminate dark run this.finderPenaltyAddHistory(currentRunLength, runHistory); - return this.finderPenaltyCountPatterns(runHistory); + currentRunLength = 0; } + currentRunLength += this.size; // Add light border to final run + this.finderPenaltyAddHistory(currentRunLength, runHistory); + return this.finderPenaltyCountPatterns(runHistory); + } - // Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore(). - private finderPenaltyAddHistory(currentRunLength: int, runHistory: Array): void { - if (runHistory[0] == 0) currentRunLength += this.size; // Add light border to initial run - runHistory.pop(); - runHistory.unshift(currentRunLength); - } + // Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore(). + private finderPenaltyAddHistory(currentRunLength: int, runHistory: Array): void { + if (runHistory[0] === 0) currentRunLength += this.size; // Add light border to initial run + runHistory.pop(); + runHistory.unshift(currentRunLength); + } - /*-- Constants and tables --*/ - - // The minimum version number supported in the QR Code Model 2 standard. - public static readonly MIN_VERSION: int = 1; - // The maximum version number supported in the QR Code Model 2 standard. - public static readonly MAX_VERSION: int = 40; - - // For use in getPenaltyScore(), when evaluating which mask is best. - private static readonly PENALTY_N1: int = 3; - private static readonly PENALTY_N2: int = 3; - private static readonly PENALTY_N3: int = 40; - private static readonly PENALTY_N4: int = 10; - - private static readonly ECC_CODEWORDS_PER_BLOCK: Array> = [ - // Version: (note that index 0 is for padding, and is set to an illegal value) - //0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level - [ - -1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, - 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, - ], // Low - [ - -1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, - 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, - ], // Medium - [ - -1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, - 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, - ], // Quartile - [ - -1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, - 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, - ], // High - ]; - - private static readonly NUM_ERROR_CORRECTION_BLOCKS: Array> = [ - // Version: (note that index 0 is for padding, and is set to an illegal value) - //0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level - [ - -1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, - 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25, - ], // Low - [ - -1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, - 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49, - ], // Medium - [ - -1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, - 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68, - ], // Quartile - [ - -1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, - 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81, - ], // High - ]; - } - - // Appends the given number of low-order bits of the given value - // to the given buffer. Requires 0 <= len <= 31 and 0 <= val < 2^len. - function appendBits(val: int, len: int, bb: Array): void { - if (len < 0 || len > 31 || val >>> len != 0) throw new RangeError("Value out of range"); - for ( - let i = len - 1; - i >= 0; - i-- // Append bit by bit - ) - bb.push((val >>> i) & 1); - } - - // Returns true iff the i'th bit of x is set to 1. - function getBit(x: int, i: int): boolean { - return ((x >>> i) & 1) != 0; - } - - // Throws an exception if the given condition is false. - function assert(cond: boolean): void { - if (!cond) throw new Error("Assertion error"); - } - - /*---- Data segment class ----*/ - - /* - * A segment of character/binary/control data in a QR Code symbol. - * Instances of this class are immutable. - * The mid-level way to create a segment is to take the payload data - * and call a static factory function such as QrSegment.makeNumeric(). - * The low-level way to create a segment is to custom-make the bit buffer - * and call the QrSegment() constructor with appropriate values. - * This segment class imposes no length restrictions, but QR Codes have restrictions. - * Even in the most favorable conditions, a QR Code can only hold 7089 characters of data. - * Any segment longer than this is meaningless for the purpose of generating QR Codes. - */ - export class QrSegment { - /*-- Static factory functions (mid level) --*/ - - // Returns a segment representing the given binary data encoded in - // byte mode. All input byte arrays are acceptable. Any text string - // can be converted to UTF-8 bytes and encoded as a byte mode segment. - public static makeBytes(data: Readonly>): QrSegment { - let bb: Array = []; - for (const b of data) appendBits(b, 8, bb); - return new QrSegment(QrSegment.Mode.BYTE, data.length, bb); - } + /*-- Constants and tables --*/ + + // The minimum version number supported in the QR Code Model 2 standard. + public static readonly MIN_VERSION: int = 1; + // The maximum version number supported in the QR Code Model 2 standard. + public static readonly MAX_VERSION: int = 40; + + // For use in getPenaltyScore(), when evaluating which mask is best. + private static readonly PENALTY_N1: int = 3; + private static readonly PENALTY_N2: int = 3; + private static readonly PENALTY_N3: int = 40; + private static readonly PENALTY_N4: int = 10; + + private static readonly ECC_CODEWORDS_PER_BLOCK: Array> = [ + // Version: (note that index 0 is for padding, and is set to an illegal value) + //0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level + [ + -1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, + 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, + ], // Low + [ + -1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, + 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, + ], // Medium + [ + -1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, + 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, + ], // Quartile + [ + -1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, + 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, + ], // High + ]; + + private static readonly NUM_ERROR_CORRECTION_BLOCKS: Array> = [ + // Version: (note that index 0 is for padding, and is set to an illegal value) + //0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level + [ + -1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, + 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25, + ], // Low + [ + -1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, + 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49, + ], // Medium + [ + -1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, + 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68, + ], // Quartile + [ + -1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, + 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81, + ], // High + ]; +} - // Returns a segment representing the given string of decimal digits encoded in numeric mode. - public static makeNumeric(digits: string): QrSegment { - if (!QrSegment.isNumeric(digits)) - throw new RangeError("String contains non-numeric characters"); - let bb: Array = []; - for (let i = 0; i < digits.length; ) { - // Consume up to 3 digits per iteration - const n: int = Math.min(digits.length - i, 3); - appendBits(parseInt(digits.substring(i, i + n), 10), n * 3 + 1, bb); - i += n; - } - return new QrSegment(QrSegment.Mode.NUMERIC, digits.length, bb); - } +// Appends the given number of low-order bits of the given value +// to the given buffer. Requires 0 <= len <= 31 and 0 <= val < 2^len. +function appendBits(val: int, len: int, bb: Array): void { + if (len < 0 || len > 31 || val >>> len != 0) throw new RangeError("Value out of range"); + for ( + let i = len - 1; + i >= 0; + i-- // Append bit by bit + ) + bb.push((val >>> i) & 1); +} - // Returns a segment representing the given text string encoded in alphanumeric mode. - // The characters allowed are: 0 to 9, A to Z (uppercase only), space, - // dollar, percent, asterisk, plus, hyphen, period, slash, colon. - public static makeAlphanumeric(text: string): QrSegment { - if (!QrSegment.isAlphanumeric(text)) - throw new RangeError("String contains unencodable characters in alphanumeric mode"); - let bb: Array = []; - let i: int; - for (i = 0; i + 2 <= text.length; i += 2) { - // Process groups of 2 - let temp: int = QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)) * 45; - temp += QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i + 1)); - appendBits(temp, 11, bb); - } - if (i < text.length) - // 1 character remaining - appendBits(QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)), 6, bb); - return new QrSegment(QrSegment.Mode.ALPHANUMERIC, text.length, bb); - } +// Returns true iff the i'th bit of x is set to 1. +function getBit(x: int, i: int): boolean { + return ((x >>> i) & 1) != 0; +} - // Returns a new mutable list of zero or more segments to represent the given Unicode text string. - // The result may use various segment modes and switch modes to optimize the length of the bit stream. - public static makeSegments(text: string): Array { - // Select the most efficient segment encoding automatically - if (text == "") return []; - else if (QrSegment.isNumeric(text)) return [QrSegment.makeNumeric(text)]; - else if (QrSegment.isAlphanumeric(text)) return [QrSegment.makeAlphanumeric(text)]; - else return [QrSegment.makeBytes(QrSegment.toUtf8ByteArray(text))]; - } +// Throws an exception if the given condition is false. +function assert(cond: boolean): void { + if (!cond) throw new Error("Assertion error"); +} - // Returns a segment representing an Extended Channel Interpretation - // (ECI) designator with the given assignment value. - public static makeEci(assignVal: int): QrSegment { - let bb: Array = []; - if (assignVal < 0) throw new RangeError("ECI assignment value out of range"); - else if (assignVal < 1 << 7) appendBits(assignVal, 8, bb); - else if (assignVal < 1 << 14) { - appendBits(0b10, 2, bb); - appendBits(assignVal, 14, bb); - } else if (assignVal < 1000000) { - appendBits(0b110, 3, bb); - appendBits(assignVal, 21, bb); - } else throw new RangeError("ECI assignment value out of range"); - return new QrSegment(QrSegment.Mode.ECI, 0, bb); - } +/*---- Data segment class ----*/ - // Tests whether the given string can be encoded as a segment in numeric mode. - // A string is encodable iff each character is in the range 0 to 9. - public static isNumeric(text: string): boolean { - return QrSegment.NUMERIC_REGEX.test(text); - } +/* + * A segment of character/binary/control data in a QR Code symbol. + * Instances of this class are immutable. + * The mid-level way to create a segment is to take the payload data + * and call a static factory function such as QrSegment.makeNumeric(). + * The low-level way to create a segment is to custom-make the bit buffer + * and call the QrSegment() constructor with appropriate values. + * This segment class imposes no length restrictions, but QR Codes have restrictions. + * Even in the most favorable conditions, a QR Code can only hold 7089 characters of data. + * Any segment longer than this is meaningless for the purpose of generating QR Codes. + */ +export class QrSegment { + public static Mode: typeof QrSegmentMode; + + /*-- Static factory functions (mid level) --*/ + + // Returns a segment representing the given binary data encoded in + // byte mode. All input byte arrays are acceptable. Any text string + // can be converted to UTF-8 bytes and encoded as a byte mode segment. + public static makeBytes(data: Readonly>): QrSegment { + let bb: Array = []; + for (const b of data) appendBits(b, 8, bb); + return new QrSegment(QrSegment.Mode.BYTE, data.length, bb); + } - // Tests whether the given string can be encoded as a segment in alphanumeric mode. - // A string is encodable iff each character is in the following set: 0 to 9, A to Z - // (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon. - public static isAlphanumeric(text: string): boolean { - return QrSegment.ALPHANUMERIC_REGEX.test(text); - } + // Returns a segment representing the given string of decimal digits encoded in numeric mode. + public static makeNumeric(digits: string): QrSegment { + if (!QrSegment.isNumeric(digits)) + throw new RangeError("String contains non-numeric characters"); + let bb: Array = []; + for (let i = 0; i < digits.length; ) { + // Consume up to 3 digits per iteration + const n: int = Math.min(digits.length - i, 3); + appendBits(parseInt(digits.substring(i, i + n), 10), n * 3 + 1, bb); + i += n; + } + return new QrSegment(QrSegment.Mode.NUMERIC, digits.length, bb); + } - /*-- Constructor (low level) and fields --*/ - - // Creates a new QR Code segment with the given attributes and data. - // The character count (numChars) must agree with the mode and the bit buffer length, - // but the constraint isn't checked. The given bit buffer is cloned and stored. - public constructor( - // The mode indicator of this segment. - public readonly mode: QrSegment.Mode, - - // The length of this segment's unencoded data. Measured in characters for - // numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode. - // Always zero or positive. Not the same as the data's bit length. - public readonly numChars: int, - - // The data bits of this segment. Accessed through getData(). - private readonly bitData: Array, - ) { - if (numChars < 0) throw new RangeError("Invalid argument"); - this.bitData = bitData.slice(); // Make defensive copy - } + // Returns a segment representing the given text string encoded in alphanumeric mode. + // The characters allowed are: 0 to 9, A to Z (uppercase only), space, + // dollar, percent, asterisk, plus, hyphen, period, slash, colon. + public static makeAlphanumeric(text: string): QrSegment { + if (!QrSegment.isAlphanumeric(text)) + throw new RangeError("String contains unencodable characters in alphanumeric mode"); + let bb: Array = []; + let i: int; + for (i = 0; i + 2 <= text.length; i += 2) { + // Process groups of 2 + let temp: int = QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)) * 45; + temp += QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i + 1)); + appendBits(temp, 11, bb); + } + if (i < text.length) + // 1 character remaining + appendBits(QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)), 6, bb); + return new QrSegment(QrSegment.Mode.ALPHANUMERIC, text.length, bb); + } - /*-- Methods --*/ + // Returns a new mutable list of zero or more segments to represent the given Unicode text string. + // The result may use various segment modes and switch modes to optimize the length of the bit stream. + public static makeSegments(text: string): Array { + // Select the most efficient segment encoding automatically + if (text == "") return []; + else if (QrSegment.isNumeric(text)) return [QrSegment.makeNumeric(text)]; + else if (QrSegment.isAlphanumeric(text)) return [QrSegment.makeAlphanumeric(text)]; + else return [QrSegment.makeBytes(QrSegment.toUtf8ByteArray(text))]; + } - // Returns a new copy of the data bits of this segment. - public getData(): Array { - return this.bitData.slice(); // Make defensive copy - } + // Returns a segment representing an Extended Channel Interpretation + // (ECI) designator with the given assignment value. + public static makeEci(assignVal: int): QrSegment { + let bb: Array = []; + if (assignVal < 0) throw new RangeError("ECI assignment value out of range"); + else if (assignVal < 1 << 7) appendBits(assignVal, 8, bb); + else if (assignVal < 1 << 14) { + appendBits(0b10, 2, bb); + appendBits(assignVal, 14, bb); + } else if (assignVal < 1000000) { + appendBits(0b110, 3, bb); + appendBits(assignVal, 21, bb); + } else throw new RangeError("ECI assignment value out of range"); + return new QrSegment(QrSegment.Mode.ECI, 0, bb); + } - // (Package-private) Calculates and returns the number of bits needed to encode the given segments at - // the given version. The result is infinity if a segment has too many characters to fit its length field. - public static getTotalBits(segs: Readonly>, version: int): number { - let result: number = 0; - for (const seg of segs) { - const ccbits: int = seg.mode.numCharCountBits(version); - if (seg.numChars >= 1 << ccbits) return Infinity; // The segment's length doesn't fit the field's bit width - result += 4 + ccbits + seg.bitData.length; - } - return result; + // Tests whether the given string can be encoded as a segment in numeric mode. + // A string is encodable iff each character is in the range 0 to 9. + public static isNumeric(text: string): boolean { + return QrSegment.NUMERIC_REGEX.test(text); + } + + // Tests whether the given string can be encoded as a segment in alphanumeric mode. + // A string is encodable iff each character is in the following set: 0 to 9, A to Z + // (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon. + public static isAlphanumeric(text: string): boolean { + return QrSegment.ALPHANUMERIC_REGEX.test(text); + } + + /*-- Constructor (low level) and fields --*/ + + public readonly mode: QrSegmentMode; + public readonly numChars: int; + private readonly bitData: Array; + + // Creates a new QR Code segment with the given attributes and data. + // The character count (numChars) must agree with the mode and the bit buffer length, + // but the constraint isn't checked. The given bit buffer is cloned and stored. + public constructor( + // The mode indicator of this segment. + mode: QrSegmentMode, + + // The length of this segment's unencoded data. Measured in characters for + // numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode. + // Always zero or positive. Not the same as the data's bit length. + numChars: int, + + // The data bits of this segment. Accessed through getData(). + bitData: Array, + ) { + this.mode = mode; + this.numChars = numChars; + if (numChars < 0) throw new RangeError("Invalid argument"); + this.bitData = bitData.slice(); // Make defensive copy + } + + /*-- Methods --*/ + + // Returns a new copy of the data bits of this segment. + public getData(): Array { + return this.bitData.slice(); // Make defensive copy + } + + // (Package-private) Calculates and returns the number of bits needed to encode the given segments at + // the given version. The result is infinity if a segment has too many characters to fit its length field. + public static getTotalBits(segs: Readonly>, version: int): number { + let result: number = 0; + for (const seg of segs) { + const ccbits: int = seg.mode.numCharCountBits(version); + if (seg.numChars >= 1 << ccbits) return Infinity; // The segment's length doesn't fit the field's bit width + result += 4 + ccbits + seg.bitData.length; } + return result; + } - // Returns a new array of bytes representing the given string encoded in UTF-8. - private static toUtf8ByteArray(str: string): Array { - str = encodeURI(str); - let result: Array = []; - for (let i = 0; i < str.length; i++) { - if (str.charAt(i) != "%") result.push(str.charCodeAt(i)); - else { - result.push(parseInt(str.substring(i + 1, i + 3), 16)); - i += 2; - } + // Returns a new array of bytes representing the given string encoded in UTF-8. + private static toUtf8ByteArray(str: string): Array { + str = encodeURI(str); + let result: Array = []; + for (let i = 0; i < str.length; i++) { + if (str.charAt(i) != "%") result.push(str.charCodeAt(i)); + else { + result.push(parseInt(str.substring(i + 1, i + 3), 16)); + i += 2; } - return result; } + return result; + } - /*-- Constants --*/ + /*-- Constants --*/ - // Describes precisely all strings that are encodable in numeric mode. - private static readonly NUMERIC_REGEX: RegExp = /^[0-9]*$/; + // Describes precisely all strings that are encodable in numeric mode. + private static readonly NUMERIC_REGEX: RegExp = /^[0-9]*$/; - // Describes precisely all strings that are encodable in alphanumeric mode. - private static readonly ALPHANUMERIC_REGEX: RegExp = /^[A-Z0-9 $%*+.\/:-]*$/; + // Describes precisely all strings that are encodable in alphanumeric mode. + private static readonly ALPHANUMERIC_REGEX: RegExp = /^[A-Z0-9 $%*+.\/:-]*$/; - // The set of all legal characters in alphanumeric mode, - // where each character value maps to the index in the string. - private static readonly ALPHANUMERIC_CHARSET: string = - "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"; - } + // The set of all legal characters in alphanumeric mode, + // where each character value maps to the index in the string. + private static readonly ALPHANUMERIC_CHARSET: string = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"; } /*---- Public helper enumeration ----*/ -namespace qrcodegen.QrCode { - type int = number; +class QrCodeEcc { + /*-- Constants --*/ - /* - * The error correction level in a QR Code symbol. Immutable. - */ - export class Ecc { - /*-- Constants --*/ + public static readonly LOW = new QrCodeEcc(0, 1); // The QR Code can tolerate about 7% erroneous codewords + public static readonly MEDIUM = new QrCodeEcc(1, 0); // The QR Code can tolerate about 15% erroneous codewords + public static readonly QUARTILE = new QrCodeEcc(2, 3); // The QR Code can tolerate about 25% erroneous codewords + public static readonly HIGH = new QrCodeEcc(3, 2); // The QR Code can tolerate about 30% erroneous codewords - public static readonly LOW = new Ecc(0, 1); // The QR Code can tolerate about 7% erroneous codewords - public static readonly MEDIUM = new Ecc(1, 0); // The QR Code can tolerate about 15% erroneous codewords - public static readonly QUARTILE = new Ecc(2, 3); // The QR Code can tolerate about 25% erroneous codewords - public static readonly HIGH = new Ecc(3, 2); // The QR Code can tolerate about 30% erroneous codewords + public readonly ordinal: int; + public readonly formatBits: int; - /*-- Constructor and fields --*/ + /*-- Constructor and fields --*/ - private constructor( - // In the range 0 to 3 (unsigned 2-bit integer). - public readonly ordinal: int, - // (Package-private) In the range 0 to 3 (unsigned 2-bit integer). - public readonly formatBits: int, - ) {} + private constructor( + // In the range 0 to 3 (unsigned 2-bit integer). + ordinal: int, + // (Package-private) In the range 0 to 3 (unsigned 2-bit integer). + formatBits: int, + ) { + this.ordinal = ordinal; + this.formatBits = formatBits; } } /*---- Public helper enumeration ----*/ -namespace qrcodegen.QrSegment { - type int = number; +class QrSegmentMode { + /*-- Constants --*/ - /* - * Describes how a segment's data bits are interpreted. Immutable. - */ - export class Mode { - /*-- Constants --*/ + public static readonly NUMERIC = new QrSegmentMode(0x1, [10, 12, 14]); + public static readonly ALPHANUMERIC = new QrSegmentMode(0x2, [9, 11, 13]); + public static readonly BYTE = new QrSegmentMode(0x4, [8, 16, 16]); + public static readonly KANJI = new QrSegmentMode(0x8, [8, 10, 12]); + public static readonly ECI = new QrSegmentMode(0x7, [0, 0, 0]); - public static readonly NUMERIC = new Mode(0x1, [10, 12, 14]); - public static readonly ALPHANUMERIC = new Mode(0x2, [9, 11, 13]); - public static readonly BYTE = new Mode(0x4, [8, 16, 16]); - public static readonly KANJI = new Mode(0x8, [8, 10, 12]); - public static readonly ECI = new Mode(0x7, [0, 0, 0]); + public readonly modeBits: int; + private readonly numBitsCharCount: [int, int, int]; - /*-- Constructor and fields --*/ + /*-- Constructor and fields --*/ - private constructor( - // The mode indicator bits, which is a uint4 value (range 0 to 15). - public readonly modeBits: int, - // Number of character count bits for three different version ranges. - private readonly numBitsCharCount: [int, int, int], - ) {} + private constructor( + // The mode indicator bits, which is a uint4 value (range 0 to 15). + modeBits: int, + // Number of character count bits for three different version ranges. + numBitsCharCount: [int, int, int], + ) { + this.modeBits = modeBits; + this.numBitsCharCount = numBitsCharCount; + } - /*-- Method --*/ + /*-- Method --*/ - // (Package-private) Returns the bit width of the character count field for a segment in - // this mode in a QR Code at the given version number. The result is in the range [0, 16]. - public numCharCountBits(ver: int): int { - return this.numBitsCharCount[Math.floor((ver + 7) / 17)]; - } + // (Package-private) Returns the bit width of the character count field for a segment in + // this mode in a QR Code at the given version number. The result is in the range [0, 16]. + public numCharCountBits(ver: int): int { + return this.numBitsCharCount[Math.floor((ver + 7) / 17)]!; } } -export const QrCode = qrcodegen.QrCode; -export const QrSegment = qrcodegen.QrSegment; +QrCode.Ecc = QrCodeEcc; +QrSegment.Mode = QrSegmentMode; diff --git a/packages/shared/src/searchRanking.test.ts b/packages/shared/src/searchRanking.test.ts index d8c4b3d6..dc43770b 100644 --- a/packages/shared/src/searchRanking.test.ts +++ b/packages/shared/src/searchRanking.test.ts @@ -6,7 +6,7 @@ import { normalizeSearchQuery, scoreQueryMatch, scoreSubsequenceMatch, -} from "./searchRanking"; +} from "./searchRanking.ts"; describe("normalizeSearchQuery", () => { it("trims and lowercases queries", () => { diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 0ac5e415..155e18cf 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -1,9 +1,11 @@ +import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; import { + applyServerSettingsPatch, extractPersistedServerObservabilitySettings, normalizePersistedServerSettingString, parsePersistedServerObservabilitySettings, -} from "./serverSettings"; +} from "./serverSettings.ts"; describe("serverSettings helpers", () => { it("normalizes optional persisted strings", () => { @@ -50,4 +52,109 @@ describe("serverSettings helpers", () => { otlpMetricsUrl: undefined, }); }); + + it("replaces text generation selection when provider/model are provided", () => { + const current = { + ...DEFAULT_SERVER_SETTINGS, + textGenerationModelSelection: { + provider: "codex" as const, + model: "gpt-5.4-mini", + options: { + reasoningEffort: "high" as const, + fastMode: true, + }, + }, + }; + + expect( + applyServerSettingsPatch(current, { + textGenerationModelSelection: { + provider: "codex", + model: "gpt-5.4-mini", + }, + }).textGenerationModelSelection, + ).toEqual({ + provider: "codex", + model: "gpt-5.4-mini", + }); + }); + + it("still deep merges text generation selection when only options are provided", () => { + const current = { + ...DEFAULT_SERVER_SETTINGS, + textGenerationModelSelection: { + provider: "codex" as const, + model: "gpt-5.4-mini", + options: { + reasoningEffort: "high" as const, + fastMode: true, + }, + }, + }; + + expect( + applyServerSettingsPatch(current, { + textGenerationModelSelection: { + options: { + fastMode: false, + }, + }, + }).textGenerationModelSelection, + ).toEqual({ + provider: "codex", + model: "gpt-5.4-mini", + options: { + reasoningEffort: "high", + fastMode: false, + }, + }); + }); + + it("uses the new provider default git model when switching providers without a model", () => { + const current = { + ...DEFAULT_SERVER_SETTINGS, + textGenerationModelSelection: { + provider: "codex" as const, + model: "gpt-5.4-mini", + options: { + reasoningEffort: "high" as const, + fastMode: true, + }, + }, + }; + + expect( + applyServerSettingsPatch(current, { + textGenerationModelSelection: { + provider: "claudeAgent", + }, + }).textGenerationModelSelection, + ).toEqual({ + provider: "claudeAgent", + model: "claude-haiku-4-5", + }); + }); + + it("preserves Claude launchArgs when applying a provider settings patch", () => { + const current = { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + claudeAgent: { + ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent, + launchArgs: "--dangerously-skip-permissions", + }, + }, + }; + + expect( + applyServerSettingsPatch(current, { + providers: { + claudeAgent: { + launchArgs: "--verbose --dangerously-skip-permissions", + }, + }, + }).providers.claudeAgent.launchArgs, + ).toBe("--verbose --dangerously-skip-permissions"); + }); }); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index e7b25606..411e9636 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -1,6 +1,11 @@ -import { ServerSettings } from "@t3tools/contracts"; +import { + DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, + ServerSettings, + type ServerSettingsPatch, +} from "@t3tools/contracts"; import { Schema } from "effect"; -import { fromLenientJson } from "./schemaJson"; +import { deepMerge } from "./Struct.ts"; +import { fromLenientJson } from "./schemaJson.ts"; const ServerSettingsJson = fromLenientJson(ServerSettings); @@ -38,3 +43,88 @@ export function parsePersistedServerObservabilitySettings( return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; } } + +function shouldReplaceTextGenerationModelSelection( + patch: ServerSettingsPatch["textGenerationModelSelection"] | undefined, +): boolean { + return Boolean(patch && (patch.provider !== undefined || patch.model !== undefined)); +} + +/** + * Applies a server settings patch while treating textGenerationModelSelection as + * replace-on-provider/model updates. This prevents stale nested options from + * surviving a reset patch that intentionally omits options. + */ +export function applyServerSettingsPatch( + current: ServerSettings, + patch: ServerSettingsPatch, +): ServerSettings { + const selectionPatch = patch.textGenerationModelSelection; + const next = deepMerge(current, patch); + if (!selectionPatch || !shouldReplaceTextGenerationModelSelection(selectionPatch)) { + return next; + } + + const currentProvider = current.textGenerationModelSelection.provider; + const provider = selectionPatch.provider ?? currentProvider; + const model = + selectionPatch.model ?? + (selectionPatch.provider !== undefined && selectionPatch.provider !== currentProvider + ? DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[provider] + : current.textGenerationModelSelection.model); + if (provider === "codex") { + const textGenerationModelSelection = selectionPatch.options + ? ({ + provider: "codex", + model, + options: selectionPatch.options as Extract< + ServerSettings["textGenerationModelSelection"], + { provider: "codex" } + >["options"], + } as Extract) + : ({ provider: "codex", model } as Extract< + ServerSettings["textGenerationModelSelection"], + { provider: "codex" } + >); + return { + ...next, + textGenerationModelSelection, + }; + } + if (provider === "copilot") { + const textGenerationModelSelection = selectionPatch.options + ? ({ + provider: "copilot", + model, + options: selectionPatch.options as Extract< + ServerSettings["textGenerationModelSelection"], + { provider: "copilot" } + >["options"], + } as Extract) + : ({ provider: "copilot", model } as Extract< + ServerSettings["textGenerationModelSelection"], + { provider: "copilot" } + >); + return { + ...next, + textGenerationModelSelection, + }; + } + const textGenerationModelSelection = selectionPatch.options + ? ({ + provider: "claudeAgent", + model, + options: selectionPatch.options as Extract< + ServerSettings["textGenerationModelSelection"], + { provider: "claudeAgent" } + >["options"], + } as Extract) + : ({ provider: "claudeAgent", model } as Extract< + ServerSettings["textGenerationModelSelection"], + { provider: "claudeAgent" } + >); + return { + ...next, + textGenerationModelSelection, + }; +} diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 1c6494a5..322d5753 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -2,12 +2,17 @@ import { describe, expect, it, vi } from "vitest"; import { extractPathFromShellOutput, + isCommandAvailable, listLoginShellCandidates, mergePathEntries, + mergePathValues, readEnvironmentFromLoginShell, + readEnvironmentFromWindowsShell, readPathFromLaunchctl, readPathFromLoginShell, -} from "./shell"; + resolveKnownWindowsCliDirs, + resolveWindowsEnvironment, +} from "./shell.ts"; describe("extractPathFromShellOutput", () => { it("extracts the path between capture markers", () => { @@ -188,3 +193,382 @@ describe("mergePathEntries", () => { ); }); }); + +describe("readEnvironmentFromWindowsShell", () => { + it("extracts environment variables from a PowerShell command", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >( + () => + "__T3CODE_ENV_PATH_START__\nC:\\Users\\testuser\\AppData\\Roaming\\npm\n__T3CODE_ENV_PATH_END__\n", + ); + + expect(readEnvironmentFromWindowsShell(["PATH"], execFile)).toEqual({ + PATH: "C:\\Users\\testuser\\AppData\\Roaming\\npm", + }); + expect(execFile).toHaveBeenCalledWith( + "C:\\Program Files\\PowerShell\\7\\pwsh.exe", + expect.arrayContaining(["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"]), + { encoding: "utf8", timeout: 5000 }, + ); + }); + + it("merges machine, user, and inherited PATH entries when probing PATH", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >( + () => + "__T3CODE_ENV_PATH_START__\nC:\\Machine\\Node;C:\\Users\\testuser\\AppData\\Roaming\\npm;C:\\Windows\\System32\n__T3CODE_ENV_PATH_END__\n", + ); + + expect(readEnvironmentFromWindowsShell(["PATH"], execFile)).toEqual({ + PATH: "C:\\Machine\\Node;C:\\Users\\testuser\\AppData\\Roaming\\npm;C:\\Windows\\System32", + }); + + const firstCall = execFile.mock.calls[0]; + expect(firstCall?.[1]?.at(-1)).toContain( + "[Environment]::GetEnvironmentVariable('PATH', 'User')", + ); + expect(firstCall?.[1]?.at(-1)).toContain( + "[Environment]::GetEnvironmentVariable('PATH', 'Machine')", + ); + expect(firstCall?.[1]?.at(-1)).toContain("$env:PATH"); + }); + + it("strips CRLF delimiters from captured PowerShell values", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >( + () => + "__T3CODE_ENV_FNM_DIR_START__\r\nC:\\Users\\testuser\\AppData\\Roaming\\fnm\r\n__T3CODE_ENV_FNM_DIR_END__\r\n", + ); + + expect(readEnvironmentFromWindowsShell(["FNM_DIR"], execFile)).toEqual({ + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + }); + }); + + it("omits -NoProfile when loadProfile is enabled", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >(() => "__T3CODE_ENV_PATH_START__\nC:\\Tools\n__T3CODE_ENV_PATH_END__\n"); + + expect(readEnvironmentFromWindowsShell(["PATH"], { loadProfile: true }, execFile)).toEqual({ + PATH: "C:\\Tools", + }); + expect(execFile).toHaveBeenCalledWith( + "C:\\Program Files\\PowerShell\\7\\pwsh.exe", + expect.arrayContaining(["-NoLogo", "-NonInteractive", "-Command"]), + { encoding: "utf8", timeout: 5000 }, + ); + expect(execFile.mock.calls[0]?.[1]).not.toContain("-NoProfile"); + }); + + it("falls back to PATH-based shells when bootstrap PowerShell paths are unavailable", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >((file) => { + if (file !== "pwsh.exe") { + throw new Error(`spawn ${file} ENOENT`); + } + return "__T3CODE_ENV_PATH_START__\nC:\\Tools\n__T3CODE_ENV_PATH_END__\n"; + }); + + expect(readEnvironmentFromWindowsShell(["PATH"], execFile)).toEqual({ + PATH: "C:\\Tools", + }); + expect(execFile).toHaveBeenNthCalledWith( + 1, + "C:\\Program Files\\PowerShell\\7\\pwsh.exe", + expect.any(Array), + { + encoding: "utf8", + timeout: 5000, + }, + ); + expect(execFile).toHaveBeenNthCalledWith( + 2, + "C:\\Program Files (x86)\\PowerShell\\7\\pwsh.exe", + expect.any(Array), + { + encoding: "utf8", + timeout: 5000, + }, + ); + expect(execFile).toHaveBeenNthCalledWith( + 3, + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + expect.any(Array), + { + encoding: "utf8", + timeout: 5000, + }, + ); + expect(execFile).toHaveBeenNthCalledWith(4, "pwsh.exe", expect.any(Array), { + encoding: "utf8", + timeout: 5000, + }); + }); + + it("uses absolute Windows PowerShell paths before PATH lookups", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >((file) => { + if (file === "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe") { + return "__T3CODE_ENV_PATH_START__\nC:\\Profile\\Node\n__T3CODE_ENV_PATH_END__\n"; + } + throw new Error(`spawn ${file} ENOENT`); + }); + + expect(readEnvironmentFromWindowsShell(["PATH"], { loadProfile: true }, execFile)).toEqual({ + PATH: "C:\\Profile\\Node", + }); + expect(execFile).toHaveBeenCalledTimes(3); + }); +}); + +describe("mergePathValues", () => { + it("dedupes case-insensitively on Windows while preserving preferred order", () => { + expect( + mergePathValues( + 'C:\\Users\\testuser\\AppData\\Roaming\\npm;"C:\\Program Files\\nodejs"', + "c:\\users\\testuser\\appdata\\roaming\\npm;C:\\Windows\\System32", + "win32", + ), + ).toBe( + 'C:\\Users\\testuser\\AppData\\Roaming\\npm;"C:\\Program Files\\nodejs";C:\\Windows\\System32', + ); + }); + + it("dedupes case-sensitively on POSIX", () => { + expect(mergePathValues("/usr/local/bin:/usr/bin", "/usr/bin:/USR/BIN", "linux")).toBe( + "/usr/local/bin:/usr/bin:/USR/BIN", + ); + }); +}); + +describe("resolveKnownWindowsCliDirs", () => { + it("returns known Windows CLI install directories in priority order", () => { + expect( + resolveKnownWindowsCliDirs({ + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }), + ).toEqual([ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + ]); + }); +}); + +describe("isCommandAvailable", () => { + it("returns false when PATH is empty", () => { + expect( + isCommandAvailable("definitely-not-installed", { + platform: "win32", + env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + }), + ).toBe(false); + }); +}); + +describe("resolveWindowsEnvironment", () => { + it("returns the baseline no-profile PATH patch when node is already available", () => { + const readEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => + options?.loadProfile + ? { PATH: "C:\\Profile\\Bin" } + : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" }, + ); + const commandAvailable = vi.fn(() => true); + + expect( + resolveWindowsEnvironment( + { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }, + { + readEnvironment, + commandAvailable, + }, + ), + ).toEqual({ + PATH: [ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Shell\\Bin", + "C:\\Windows\\System32", + ].join(";"), + }); + expect(readEnvironment).toHaveBeenCalledTimes(1); + expect(readEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false }); + expect(commandAvailable).toHaveBeenCalledWith( + "node", + expect.objectContaining({ + platform: "win32", + }), + ); + }); + + it("recovers node from registry-backed PATH entries before loading the profile", () => { + const readEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => + options?.loadProfile + ? { PATH: "C:\\Profile\\Node;C:\\Windows\\System32" } + : { PATH: "C:\\Users\\testuser\\AppData\\Roaming\\npm;C:\\Machine\\Node" }, + ); + const commandAvailable = vi.fn((command: string, probe) => { + if (command !== "node") { + return false; + } + + return ( + probe?.platform === "win32" && + typeof probe.env?.PATH === "string" && + probe.env.PATH.includes("C:\\Machine\\Node") + ); + }); + + expect( + resolveWindowsEnvironment( + { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }, + { + readEnvironment, + commandAvailable, + }, + ), + ).toEqual({ + PATH: [ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Machine\\Node", + "C:\\Windows\\System32", + ].join(";"), + }); + expect(readEnvironment).toHaveBeenCalledTimes(1); + }); + + it("loads the PowerShell profile when baseline env cannot resolve node", () => { + const readEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => + options?.loadProfile + ? { + PATH: "C:\\Profile\\Node;C:\\Windows\\System32", + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + } + : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" }, + ); + const commandAvailable = vi.fn(() => false); + + expect( + resolveWindowsEnvironment( + { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }, + { + readEnvironment, + commandAvailable, + }, + ), + ).toEqual({ + PATH: [ + "C:\\Profile\\Node", + "C:\\Windows\\System32", + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Shell\\Bin", + ].join(";"), + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + }); + expect(readEnvironment).toHaveBeenNthCalledWith(1, ["PATH"], { loadProfile: false }); + expect(readEnvironment).toHaveBeenNthCalledWith(2, ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], { + loadProfile: true, + }); + expect(commandAvailable).toHaveBeenCalledTimes(1); + }); + + it("keeps the baseline env when profiled probe still does not resolve node", () => { + const readEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => + options?.loadProfile ? { FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm" } : {}, + ); + const commandAvailable = vi.fn(() => false); + + expect( + resolveWindowsEnvironment( + { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + USERPROFILE: "C:\\Users\\testuser", + }, + { + readEnvironment, + commandAvailable, + }, + ), + ).toEqual({ + PATH: [ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Windows\\System32", + ].join(";"), + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + }); + expect(commandAvailable).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 9cd20688..ad45260c 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -1,9 +1,19 @@ import * as OS from "node:os"; import { execFileSync } from "node:child_process"; +import { accessSync, constants, statSync } from "node:fs"; +import { extname, join } from "node:path"; const PATH_CAPTURE_START = "__T3CODE_PATH_START__"; const PATH_CAPTURE_END = "__T3CODE_PATH_END__"; const SHELL_ENV_NAME_PATTERN = /^[A-Z0-9_]+$/; +const WINDOWS_PATH_DELIMITER = ";"; +const POSIX_PATH_DELIMITER = ":"; +const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; +const WINDOWS_POWERSHELL_BOOTSTRAP_PATHS = [ + "C:\\Program Files\\PowerShell\\7\\pwsh.exe", + "C:\\Program Files (x86)\\PowerShell\\7\\pwsh.exe", + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", +] as const; type ExecFileSyncLike = ( file: string, @@ -11,6 +21,15 @@ type ExecFileSyncLike = ( options: { encoding: "utf8"; timeout: number }, ) => string; +export interface CommandAvailabilityOptions { + readonly platform?: NodeJS.Platform; + readonly env?: NodeJS.ProcessEnv; +} + +export interface WindowsEnvironmentProbeOptions { + readonly loadProfile?: boolean; +} + function trimNonEmpty(value: string | null | undefined): string | undefined { const trimmed = value?.trim(); return trimmed && trimmed.length > 0 ? trimmed : undefined; @@ -127,6 +146,46 @@ function buildEnvironmentCaptureCommand(names: ReadonlyArray): string { .join("; "); } +function buildWindowsEnvironmentCaptureCommand(names: ReadonlyArray): string { + const mergePathCommand = [ + "$pathValues = @(", + " [Environment]::GetEnvironmentVariable('PATH', 'User'),", + " [Environment]::GetEnvironmentVariable('PATH', 'Machine'),", + " $env:PATH", + ")", + "$seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)", + "$entries = foreach ($pathValue in $pathValues) {", + " if ([string]::IsNullOrWhiteSpace($pathValue)) { continue }", + " foreach ($entry in $pathValue -split ';') {", + " $trimmed = $entry.Trim()", + " if ($trimmed.Length -eq 0) { continue }", + " $normalized = $trimmed.Trim('\"')", + " if ($normalized.Length -eq 0) { continue }", + " if ($seen.Add($normalized)) { $trimmed }", + " }", + "}", + "$value = [string]::Join(';', $entries)", + ].join("; "); + + return [ + "$ErrorActionPreference = 'Stop'", + ...names.flatMap((name) => { + if (!SHELL_ENV_NAME_PATTERN.test(name)) { + throw new Error(`Unsupported environment variable name: ${name}`); + } + + return [ + `Write-Output '${envCaptureStart(name)}'`, + ...(name === "PATH" + ? [mergePathCommand] + : [`$value = [Environment]::GetEnvironmentVariable('${name}')`]), + "if ($null -ne $value -and $value.Length -gt 0) { Write-Output $value }", + `Write-Output '${envCaptureEnd(name)}'`, + ]; + }), + ].join("; "); +} + function extractEnvironmentValue(output: string, name: string): string | undefined { const startMarker = envCaptureStart(name); const endMarker = envCaptureEnd(name); @@ -137,13 +196,10 @@ function extractEnvironmentValue(output: string, name: string): string | undefin const endIndex = output.indexOf(endMarker, valueStartIndex); if (endIndex === -1) return undefined; - let value = output.slice(valueStartIndex, endIndex); - if (value.startsWith("\n")) { - value = value.slice(1); - } - if (value.endsWith("\n")) { - value = value.slice(0, -1); - } + const value = output + .slice(valueStartIndex, endIndex) + .replace(/^\r?\n/, "") + .replace(/\r?\n$/, ""); return value.length > 0 ? value : undefined; } @@ -178,3 +234,284 @@ export const readEnvironmentFromLoginShell: ShellEnvironmentReader = ( return environment; }; + +export type WindowsShellEnvironmentReader = ( + names: ReadonlyArray, + options?: WindowsEnvironmentProbeOptions, +) => Partial>; + +export function readEnvironmentFromWindowsShell( + names: ReadonlyArray, + execFile?: ExecFileSyncLike, +): Partial>; +export function readEnvironmentFromWindowsShell( + names: ReadonlyArray, + options?: WindowsEnvironmentProbeOptions, + execFile?: ExecFileSyncLike, +): Partial>; +export function readEnvironmentFromWindowsShell( + names: ReadonlyArray, + optionsOrExecFile?: WindowsEnvironmentProbeOptions | ExecFileSyncLike, + maybeExecFile?: ExecFileSyncLike, +): Partial> { + if (names.length === 0) { + return {}; + } + + const options = + typeof optionsOrExecFile === "function" + ? ({} satisfies WindowsEnvironmentProbeOptions) + : (optionsOrExecFile ?? {}); + const execFile: ExecFileSyncLike = + typeof optionsOrExecFile === "function" + ? optionsOrExecFile + : (maybeExecFile ?? (execFileSync as ExecFileSyncLike)); + const command = buildWindowsEnvironmentCaptureCommand(names); + const args = [ + "-NoLogo", + ...(options.loadProfile ? ([] as const) : (["-NoProfile"] as const)), + "-NonInteractive", + "-Command", + command, + ]; + for (const shell of [...WINDOWS_POWERSHELL_BOOTSTRAP_PATHS, ...WINDOWS_SHELL_CANDIDATES]) { + try { + const output = execFile(shell, args, { encoding: "utf8", timeout: 5000 }); + + const environment: Partial> = {}; + for (const name of names) { + const value = extractEnvironmentValue(output, name); + if (value !== undefined) { + environment[name] = value; + } + } + return environment; + } catch { + continue; + } + } + + return {}; +} + +function stripWrappingQuotes(value: string): string { + return value.replace(/^"+|"+$/g, ""); +} + +function pathDelimiterForPlatform(platform: NodeJS.Platform): string { + return platform === "win32" ? WINDOWS_PATH_DELIMITER : POSIX_PATH_DELIMITER; +} + +function normalizePathEntryForComparison(entry: string, platform: NodeJS.Platform): string { + const normalized = stripWrappingQuotes(entry.trim()); + return platform === "win32" ? normalized.toLowerCase() : normalized; +} + +export function mergePathValues( + preferredPath: string | undefined, + inheritedPath: string | undefined, + platform: NodeJS.Platform, +): string | undefined { + const delimiter = pathDelimiterForPlatform(platform); + const merged: string[] = []; + const seen = new Set(); + + for (const rawValue of [preferredPath, inheritedPath]) { + if (!rawValue) continue; + + for (const entry of rawValue.split(delimiter)) { + const trimmed = entry.trim(); + if (trimmed.length === 0) continue; + + const normalized = normalizePathEntryForComparison(trimmed, platform); + if (normalized.length === 0 || seen.has(normalized)) continue; + + seen.add(normalized); + merged.push(trimmed); + } + } + + return merged.length > 0 ? merged.join(delimiter) : undefined; +} + +function readEnvPath(env: NodeJS.ProcessEnv): string | undefined { + return env.PATH ?? env.Path ?? env.path; +} + +function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string { + return readEnvPath(env) ?? ""; +} + +function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray { + const rawValue = env.PATHEXT; + const fallback = [".COM", ".EXE", ".BAT", ".CMD"]; + if (!rawValue) return fallback; + + const parsed = rawValue + .split(";") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .map((entry) => (entry.startsWith(".") ? entry.toUpperCase() : `.${entry.toUpperCase()}`)); + return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback; +} + +function resolveCommandCandidates( + command: string, + platform: NodeJS.Platform, + windowsPathExtensions: ReadonlyArray, +): ReadonlyArray { + if (platform !== "win32") return [command]; + const extension = extname(command); + const normalizedExtension = extension.toUpperCase(); + + if (extension.length > 0 && windowsPathExtensions.includes(normalizedExtension)) { + const commandWithoutExtension = command.slice(0, -extension.length); + return Array.from( + new Set([ + command, + `${commandWithoutExtension}${normalizedExtension}`, + `${commandWithoutExtension}${normalizedExtension.toLowerCase()}`, + ]), + ); + } + + const candidates: string[] = []; + for (const candidateExtension of windowsPathExtensions) { + candidates.push(`${command}${candidateExtension}`); + candidates.push(`${command}${candidateExtension.toLowerCase()}`); + } + return Array.from(new Set(candidates)); +} + +function isExecutableFile( + filePath: string, + platform: NodeJS.Platform, + windowsPathExtensions: ReadonlyArray, +): boolean { + try { + const stat = statSync(filePath); + if (!stat.isFile()) return false; + if (platform === "win32") { + const extension = extname(filePath); + if (extension.length === 0) return false; + return windowsPathExtensions.includes(extension.toUpperCase()); + } + accessSync(filePath, constants.X_OK); + return true; + } catch { + return false; + } +} + +export function isCommandAvailable( + command: string, + options: CommandAvailabilityOptions = {}, +): boolean { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; + const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions); + + if (command.includes("/") || command.includes("\\")) { + return commandCandidates.some((candidate) => + isExecutableFile(candidate, platform, windowsPathExtensions), + ); + } + + const pathValue = resolvePathEnvironmentVariable(env); + if (pathValue.length === 0) return false; + const pathEntries = pathValue + .split(pathDelimiterForPlatform(platform)) + .map((entry) => stripWrappingQuotes(entry.trim())) + .filter((entry) => entry.length > 0); + + for (const pathEntry of pathEntries) { + for (const candidate of commandCandidates) { + if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) { + return true; + } + } + } + return false; +} + +export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray { + const appData = env.APPDATA?.trim(); + const localAppData = env.LOCALAPPDATA?.trim(); + const userProfile = env.USERPROFILE?.trim(); + + return [ + ...(appData ? [`${appData}\\npm`] : []), + ...(localAppData ? [`${localAppData}\\Programs\\nodejs`, `${localAppData}\\Volta\\bin`] : []), + ...(localAppData ? [`${localAppData}\\pnpm`] : []), + ...(userProfile ? [`${userProfile}\\.bun\\bin`, `${userProfile}\\scoop\\shims`] : []), + ]; +} + +export interface WindowsEnvironmentResolverOptions { + readonly readEnvironment?: WindowsShellEnvironmentReader; + readonly commandAvailable?: typeof isCommandAvailable; +} + +function readWindowsEnvironmentSafely( + readEnvironment: WindowsShellEnvironmentReader, + names: ReadonlyArray, + options?: WindowsEnvironmentProbeOptions, +): Partial> { + try { + return readEnvironment(names, options); + } catch { + return {}; + } +} + +function mergeWindowsEnv( + currentEnv: NodeJS.ProcessEnv, + patch: Partial>, +): NodeJS.ProcessEnv { + const nextEnv: NodeJS.ProcessEnv = { ...currentEnv }; + for (const [key, value] of Object.entries(patch)) { + if (value !== undefined) { + nextEnv[key] = value; + } + } + return nextEnv; +} + +export function resolveWindowsEnvironment( + env: NodeJS.ProcessEnv, + options: WindowsEnvironmentResolverOptions = {}, +): Partial { + const readEnvironment = options.readEnvironment ?? readEnvironmentFromWindowsShell; + const commandAvailable = options.commandAvailable ?? isCommandAvailable; + const inheritedPath = readEnvPath(env); + const shellPath = readWindowsEnvironmentSafely(readEnvironment, ["PATH"], { + loadProfile: false, + }).PATH; + const mergedPath = mergePathValues(shellPath, inheritedPath, "win32"); + const knownCliPath = resolveKnownWindowsCliDirs(env).join(WINDOWS_PATH_DELIMITER); + const baselinePath = mergePathValues(knownCliPath, mergedPath, "win32"); + const baselinePatch: Partial = baselinePath ? { PATH: baselinePath } : {}; + const baselineEnv = mergeWindowsEnv(env, baselinePatch); + + if (commandAvailable("node", { platform: "win32", env: baselineEnv })) { + return baselinePatch; + } + + const profiledEnvironment = readWindowsEnvironmentSafely( + readEnvironment, + ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], + { loadProfile: true }, + ); + const profiledPath = mergePathValues(profiledEnvironment.PATH, baselinePath, "win32"); + const profiledPatch: Partial = { + ...(profiledPath ? { PATH: profiledPath } : {}), + ...(profiledEnvironment.FNM_DIR ? { FNM_DIR: profiledEnvironment.FNM_DIR } : {}), + ...(profiledEnvironment.FNM_MULTISHELL_PATH + ? { FNM_MULTISHELL_PATH: profiledEnvironment.FNM_MULTISHELL_PATH } + : {}), + }; + return Object.keys(profiledPatch).length > 0 + ? { ...baselinePatch, ...profiledPatch } + : baselinePatch; +} diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 86452693..e66ad091 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -3,6 +3,7 @@ import { assert, it } from "@effect/vitest"; import { ConfigProvider, Effect, Option } from "effect"; import { + createBuildConfig, resolveBuildOptions, resolveDesktopBuildIconAssets, resolveDesktopProductName, @@ -42,6 +43,23 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.equal(resolveMockUpdateServerUrl(4123), "http://localhost:4123"); }); + it.effect("keeps electron-builder npm rebuilds enabled for Windows artifacts", () => + Effect.gen(function* () { + const config = yield* createBuildConfig("win", "nsis", "0.0.17", false, false, undefined); + assert.equal("npmRebuild" in config, false); + }), + ); + + it.effect("keeps Windows executable resource editing enabled for unsigned artifacts", () => + Effect.gen(function* () { + const config = yield* createBuildConfig("win", "nsis", "0.0.17", false, false, undefined); + assert.deepStrictEqual(config.win, { + target: ["nsis"], + icon: "icon.ico", + }); + }), + ); + it.effect("normalizes mock update server ports from env-style strings", () => Effect.gen(function* () { assert.equal(yield* resolveMockUpdateServerPort(undefined), undefined); diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 5d3a437d..3810a0ea 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -1,19 +1,27 @@ #!/usr/bin/env node -import { spawnSync } from "node:child_process"; -import { existsSync } from "node:fs"; -import { join } from "node:path"; - import rootPackageJson from "../package.json" with { type: "json" }; import desktopPackageJson from "../apps/desktop/package.json" with { type: "json" }; import serverPackageJson from "../apps/server/package.json" with { type: "json" }; import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; +import { getDefaultBuildArch } from "./lib/build-target-arch.ts"; import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { Config, Data, Effect, FileSystem, Layer, Logger, Option, Path, Schema } from "effect"; +import { + Config, + Data, + Effect, + FileSystem, + Layer, + Logger, + Option, + Path, + Schema, + Stream, +} from "effect"; import { Command, Flag } from "effect/unstable/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -82,14 +90,7 @@ function getDefaultArch(platform: typeof BuildPlatform.Type): typeof BuildArch.T return "x64"; } - if (process.arch === "arm64" && config.archChoices.includes("arm64")) { - return "arm64"; - } - if (process.arch === "x64" && config.archChoices.includes("x64")) { - return "x64"; - } - - return config.archChoices[0] ?? "x64"; + return getDefaultBuildArch(platform, process.arch, process.env, config); } class BuildScriptError extends Data.TaggedError("BuildScriptError")<{ @@ -97,12 +98,49 @@ class BuildScriptError extends Data.TaggedError("BuildScriptError")<{ readonly cause?: unknown; }> {} -function resolveGitCommitHash(repoRoot: string): string { - const result = spawnSync("git", ["rev-parse", "--short=12", "HEAD"], { - cwd: repoRoot, - encoding: "utf8", - }); - if (result.status !== 0) { +const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + ); + +const spawnAndCollectOutput = Effect.fn("spawnAndCollectOutput")(function* ( + command: ChildProcess.Command, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn(command); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + + return { stdout, stderr, exitCode } as const; +}); + +const resolveGitCommitHash = Effect.fn("resolveGitCommitHash")(function* (repoRoot: string) { + const result = yield* spawnAndCollectOutput( + ChildProcess.make("git", ["rev-parse", "--short=12", "HEAD"], { + cwd: repoRoot, + }), + ).pipe( + Effect.catch(() => + Effect.succeed({ + stdout: "", + stderr: "", + exitCode: 1, + }), + ), + ); + + if (result.exitCode !== 0) { return "unknown"; } const hash = result.stdout.trim(); @@ -110,11 +148,13 @@ function resolveGitCommitHash(repoRoot: string): string { return "unknown"; } return hash.toLowerCase(); -} +}); -function resolvePythonForNodeGyp(): string | undefined { +const resolvePythonForNodeGyp = Effect.fn("resolvePythonForNodeGyp")(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const configured = process.env.npm_config_python ?? process.env.PYTHON; - if (configured && existsSync(configured)) { + if (configured && (yield* fs.exists(configured))) { return configured; } @@ -122,28 +162,37 @@ function resolvePythonForNodeGyp(): string | undefined { const localAppData = process.env.LOCALAPPDATA; if (localAppData) { for (const version of ["Python313", "Python312", "Python311", "Python310"]) { - const candidate = join(localAppData, "Programs", "Python", version, "python.exe"); - if (existsSync(candidate)) { + const candidate = path.join(localAppData, "Programs", "Python", version, "python.exe"); + if (yield* fs.exists(candidate)) { return candidate; } } } } - const probe = spawnSync("python", ["-c", "import sys;print(sys.executable)"], { - encoding: "utf8", - }); - if (probe.status !== 0) { + const probe = yield* spawnAndCollectOutput( + ChildProcess.make("python", ["-c", "import sys;print(sys.executable)"]), + ).pipe( + Effect.catch(() => + Effect.succeed({ + stdout: "", + stderr: "", + exitCode: 1, + }), + ), + ); + + if (probe.exitCode !== 0) { return undefined; } const executable = probe.stdout.trim(); - if (!executable || !existsSync(executable)) { + if (!executable || !(yield* fs.exists(executable))) { return undefined; } return executable; -} +}); interface ResolvedBuildOptions { readonly platform: typeof BuildPlatform.Type; @@ -509,7 +558,7 @@ export function resolveDesktopProductName(version: string): string { : (desktopPackageJson.productName ?? "T3 Code"); } -const createBuildConfig = Effect.fn("createBuildConfig")(function* ( +export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( platform: typeof BuildPlatform.Type, target: string, version: string, @@ -660,7 +709,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const appVersion = options.version ?? serverPackageJson.version; const iconAssets = resolveDesktopBuildIconAssets(appVersion); - const commitHash = resolveGitCommitHash(repoRoot); + const commitHash = yield* resolveGitCommitHash(repoRoot); const mkdir = options.keepStage ? fs.makeTempDirectory : fs.makeTempDirectoryScoped; const stageRoot = yield* mkdir({ prefix: `t3code-desktop-${options.platform}-stage-`, @@ -715,9 +764,9 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( options.platform, stageResourcesDir, { - macIconPng: join(repoRoot, iconAssets.macIconPng), - linuxIconPng: join(repoRoot, iconAssets.linuxIconPng), - windowsIconIco: join(repoRoot, iconAssets.windowsIconIco), + macIconPng: path.join(repoRoot, iconAssets.macIconPng), + linuxIconPng: path.join(repoRoot, iconAssets.linuxIconPng), + windowsIconIco: path.join(repoRoot, iconAssets.windowsIconIco), }, options.verbose, ); @@ -733,7 +782,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( private: true, description: "T3 Code desktop build", author: "T3 Tools", - main: "apps/desktop/dist-electron/main.js", + main: "apps/desktop/dist-electron/main.cjs", build: yield* createBuildConfig( options.platform, options.target, @@ -762,7 +811,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( ...commandOutputOptions(options.verbose), // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd). shell: process.platform === "win32", - })`bun install --production`, + })`bun install --production --omit optional`, ); const buildEnv: NodeJS.ProcessEnv = { @@ -783,7 +832,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( } if (process.platform === "win32") { - const python = resolvePythonForNodeGyp(); + const python = yield* resolvePythonForNodeGyp(); if (python) { buildEnv.PYTHON = python; buildEnv.npm_config_python = python; @@ -802,7 +851,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( ...commandOutputOptions(options.verbose), // Windows needs shell mode to resolve .cmd shims. shell: process.platform === "win32", - })`bunx electron-builder ${platformConfig.cliFlag} --${options.arch} --publish never`, + })`bun x --install=fallback electron-builder ${platformConfig.cliFlag} --${options.arch} --publish never`, ); const stageDistDir = path.join(stageAppDir, "dist"); diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index b880f1bc..429dbef1 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -1,8 +1,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { homedir } from "node:os"; +import * as NodeOS from "node:os"; import { resolve } from "node:path"; import { assert, describe, it } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Path } from "effect"; import { checkPortAvailabilityOnHosts, @@ -49,6 +49,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { describe("createDevRunnerEnv", () => { it.effect("defaults T3CODE_HOME to ~/.t3 when not provided", () => Effect.gen(function* () { + const path = yield* Path.Path; const env = yield* createDevRunnerEnv({ mode: "dev", baseEnv: {}, @@ -63,12 +64,13 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { devUrl: undefined, }); - assert.equal(env.T3CODE_HOME, resolve(homedir(), ".t3")); + assert.equal(env.T3CODE_HOME, path.resolve(NodeOS.homedir(), ".t3")); }), ); it.effect("supports explicit typed overrides", () => Effect.gen(function* () { + const path = yield* Path.Path; const env = yield* createDevRunnerEnv({ mode: "dev:server", baseEnv: {}, @@ -83,7 +85,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { devUrl: new URL("http://localhost:7331"), }); - assert.equal(env.T3CODE_HOME, resolve("/tmp/custom-t3")); + assert.equal(env.T3CODE_HOME, path.resolve("/tmp/custom-t3")); assert.equal(env.T3CODE_PORT, "4222"); assert.equal(env.VITE_HTTP_URL, "http://localhost:4222"); assert.equal(env.VITE_WS_URL, "ws://localhost:4222"); @@ -142,6 +144,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { it.effect("uses custom t3Home when provided", () => Effect.gen(function* () { + const path = yield* Path.Path; const env = yield* createDevRunnerEnv({ mode: "dev", baseEnv: {}, @@ -156,7 +159,65 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { devUrl: undefined, }); - assert.equal(env.T3CODE_HOME, resolve("/tmp/my-t3")); + assert.equal(env.T3CODE_HOME, path.resolve("/tmp/my-t3")); + }), + ); + + it.effect("pins desktop dev to a stable backend port and websocket url", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const env = yield* createDevRunnerEnv({ + mode: "dev:desktop", + baseEnv: { + T3CODE_PORT: "13773", + T3CODE_MODE: "web", + T3CODE_NO_BROWSER: "0", + T3CODE_HOST: "0.0.0.0", + VITE_WS_URL: "ws://localhost:13773", + }, + serverOffset: 0, + webOffset: 0, + t3Home: "/tmp/my-t3", + noBrowser: true, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: "127.0.0.1", + port: 4222, + devUrl: undefined, + }); + + assert.equal(env.T3CODE_HOME, path.resolve("/tmp/my-t3")); + assert.equal(env.PORT, "5733"); + assert.equal(env.VITE_DEV_SERVER_URL, "http://127.0.0.1:5733"); + assert.equal(env.HOST, "127.0.0.1"); + assert.equal(env.T3CODE_PORT, "4222"); + assert.equal(env.VITE_HTTP_URL, "http://127.0.0.1:4222"); + assert.equal(env.T3CODE_MODE, undefined); + assert.equal(env.T3CODE_NO_BROWSER, undefined); + assert.equal(env.T3CODE_HOST, undefined); + assert.equal(env.VITE_WS_URL, "ws://127.0.0.1:4222"); + }), + ); + + it.effect("defaults dev server mode to the higher backend port range", () => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode: "dev", + baseEnv: {}, + serverOffset: 0, + webOffset: 0, + t3Home: undefined, + noBrowser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + }); + + assert.equal(env.T3CODE_PORT, "13773"); + assert.equal(env.VITE_HTTP_URL, "http://localhost:13773"); + assert.equal(env.VITE_WS_URL, "ws://localhost:13773"); }), ); diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 4d34fe38..1621b60d 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { homedir } from "node:os"; +import * as NodeOS from "node:os"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -17,7 +17,7 @@ const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1"; const DEV_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::1", "::"] as const; export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) => - path.join(homedir(), ".t3"), + path.join(NodeOS.homedir(), ".t3"), ); const MODE_ARGS = { @@ -523,11 +523,10 @@ const cliRuntimeLayer = Layer.mergeAll( NetService.layer, ); -const runtimeProgram = Command.run(devRunnerCli, { version: "0.0.0" }).pipe( - Effect.scoped, - Effect.provide(cliRuntimeLayer), -); - if (import.meta.main) { - NodeRuntime.runMain(runtimeProgram); + Command.run(devRunnerCli, { version: "0.0.0" }).pipe( + Effect.scoped, + Effect.provide(cliRuntimeLayer), + NodeRuntime.runMain, + ); } diff --git a/scripts/lib/build-target-arch.test.ts b/scripts/lib/build-target-arch.test.ts new file mode 100644 index 00000000..56251d3f --- /dev/null +++ b/scripts/lib/build-target-arch.test.ts @@ -0,0 +1,61 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { getDefaultBuildArch, resolveHostProcessArch } from "./build-target-arch.ts"; + +describe("build-target-arch", () => { + it("prefers arm64 for Windows-on-Arm hosts running x64 emulation", () => { + // Windows-on-Arm can run an x64 Node process under emulation while still + // exposing the real host CPU via PROCESSOR_ARCHITEW6432. + const hostArch = resolveHostProcessArch("win32", "x64", { + PROCESSOR_ARCHITECTURE: "AMD64", // The currently running Node process is x64. + PROCESSOR_ARCHITEW6432: "ARM64", // Windows exposes the real host CPU here when x64 runs under ARM emulation. + }); + + assert.equal(hostArch, "arm64"); + }); + + it("falls back to x64 for native x64 Windows hosts", () => { + const hostArch = resolveHostProcessArch("win32", "x64", { + PROCESSOR_ARCHITECTURE: "AMD64", // Both the process and the Windows host are native x64. + }); + + assert.equal(hostArch, "x64"); + }); + + it("keeps arm64 when the current process is already native arm64", () => { + const hostArch = resolveHostProcessArch("win32", "arm64", {}); + + assert.equal(hostArch, "arm64"); + }); + + it("uses the resolved host arch when selecting the default Windows build arch", () => { + // This mirrors the packaging script's default-path behavior: the current + // process is x64, but the machine itself is ARM64, so the default build + // target should be win-arm64 rather than win-x64. + const arch = getDefaultBuildArch( + "win", + "x64", + { + PROCESSOR_ARCHITECTURE: "AMD64", // The currently running Node process is x64. + PROCESSOR_ARCHITEW6432: "ARM64", // The process is x64, but the actual Windows host is ARM64. + }, + { archChoices: ["x64", "arm64"] }, + ); + + assert.equal(arch, "arm64"); + }); + + it("does not apply Windows host env heuristics for non-Windows targets", () => { + const arch = getDefaultBuildArch( + "linux", + "x64", + { + PROCESSOR_ARCHITECTURE: "AMD64", + PROCESSOR_ARCHITEW6432: "ARM64", + }, + { archChoices: ["x64", "arm64"] }, + ); + + assert.equal(arch, "x64"); + }); +}); diff --git a/scripts/lib/build-target-arch.ts b/scripts/lib/build-target-arch.ts new file mode 100644 index 00000000..8c396484 --- /dev/null +++ b/scripts/lib/build-target-arch.ts @@ -0,0 +1,50 @@ +export type BuildArch = "arm64" | "x64" | "universal"; +export type BuildPlatform = "mac" | "linux" | "win"; + +interface PlatformConfig { + readonly archChoices: ReadonlyArray; +} + +function normalizeWindowsArch(value: string | undefined): BuildArch | undefined { + const normalized = value?.trim().toLowerCase(); + if (!normalized) return undefined; + if (normalized.includes("arm64") || normalized === "aarch64") return "arm64"; + if (normalized.includes("amd64") || normalized.includes("x64")) return "x64"; + return undefined; +} + +export function resolveHostProcessArch( + platform: NodeJS.Platform, + processArch: NodeJS.Architecture, + env: NodeJS.ProcessEnv, +): BuildArch | undefined { + if (processArch === "arm64") return "arm64"; + if (processArch === "x64") { + if (platform !== "win32") return "x64"; + + // On Windows-on-Arm, x64 Node/Bun can run under emulation while the host + // still reports ARM64 via the processor environment variables. + return ( + normalizeWindowsArch(env.PROCESSOR_ARCHITEW6432) ?? + normalizeWindowsArch(env.PROCESSOR_ARCHITECTURE) ?? + "x64" + ); + } + return undefined; +} + +export function getDefaultBuildArch( + platform: BuildPlatform, + processArch: NodeJS.Architecture, + env: NodeJS.ProcessEnv, + platformConfig: PlatformConfig, +): BuildArch { + const hostPlatform: NodeJS.Platform = + platform === "win" ? "win32" : platform === "mac" ? "darwin" : "linux"; + const hostArch = resolveHostProcessArch(hostPlatform, processArch, env); + if (hostArch && platformConfig.archChoices.includes(hostArch)) { + return hostArch; + } + + return platformConfig.archChoices[0] ?? "x64"; +} diff --git a/scripts/merge-mac-update-manifests.ts b/scripts/lib/update-manifest.ts similarity index 58% rename from scripts/merge-mac-update-manifests.ts rename to scripts/lib/update-manifest.ts index c59bc76b..191a3c0e 100644 --- a/scripts/merge-mac-update-manifests.ts +++ b/scripts/lib/update-manifest.ts @@ -1,23 +1,19 @@ -import { readFileSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -interface MacUpdateFile { +export interface UpdateManifestFile { readonly url: string; readonly sha512: string; readonly size: number; } -type MacUpdateScalar = string | number | boolean; +export type UpdateManifestScalar = string | number | boolean; -interface MacUpdateManifest { +export interface UpdateManifest { readonly version: string; readonly releaseDate: string; - readonly files: ReadonlyArray; - readonly extras: Readonly>; + readonly files: ReadonlyArray; + readonly extras: Readonly>; } -interface MutableMacUpdateFile { +interface MutableUpdateManifestFile { url?: string; sha512?: string; size?: number; @@ -31,10 +27,11 @@ function stripSingleQuotes(value: string): string { } function parseFileRecord( - currentFile: MutableMacUpdateFile | null, + currentFile: MutableUpdateManifestFile | null, sourcePath: string, lineNumber: number, -): MacUpdateFile | null { + platformLabel: string, +): UpdateManifestFile | null { if (currentFile === null) { return null; } @@ -44,7 +41,7 @@ function parseFileRecord( typeof currentFile.size !== "number" ) { throw new Error( - `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: incomplete file entry.`, + `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: incomplete file entry.`, ); } return { @@ -54,7 +51,7 @@ function parseFileRecord( }; } -function parseScalarValue(rawValue: string): MacUpdateScalar { +function parseScalarValue(rawValue: string): UpdateManifestScalar { const trimmed = rawValue.trim(); const isQuoted = trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2; const value = isQuoted ? trimmed.slice(1, -1).replace(/''/g, "'") : trimmed; @@ -67,14 +64,18 @@ function parseScalarValue(rawValue: string): MacUpdateScalar { return value; } -export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpdateManifest { +export function parseUpdateManifest( + raw: string, + sourcePath: string, + platformLabel: string, +): UpdateManifest { const lines = raw.split(/\r?\n/); - const files: MacUpdateFile[] = []; - const extras: Record = {}; + const files: UpdateManifestFile[] = []; + const extras: Record = {}; let version: string | null = null; let releaseDate: string | null = null; let inFiles = false; - let currentFile: MutableMacUpdateFile | null = null; + let currentFile: MutableUpdateManifestFile | null = null; for (const [index, rawLine] of lines.entries()) { const lineNumber = index + 1; @@ -83,7 +84,7 @@ export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpda const fileUrlMatch = line.match(/^ - url:\s*(.+)$/); if (fileUrlMatch?.[1]) { - const finalized = parseFileRecord(currentFile, sourcePath, lineNumber); + const finalized = parseFileRecord(currentFile, sourcePath, lineNumber, platformLabel); if (finalized) files.push(finalized); currentFile = { url: stripSingleQuotes(fileUrlMatch[1].trim()) }; inFiles = true; @@ -94,7 +95,7 @@ export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpda if (fileShaMatch?.[1]) { if (currentFile === null) { throw new Error( - `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: sha512 without a file entry.`, + `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: sha512 without a file entry.`, ); } currentFile.sha512 = stripSingleQuotes(fileShaMatch[1].trim()); @@ -105,7 +106,7 @@ export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpda if (fileSizeMatch?.[1]) { if (currentFile === null) { throw new Error( - `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: size without a file entry.`, + `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: size without a file entry.`, ); } currentFile.size = Number(fileSizeMatch[1]); @@ -118,7 +119,7 @@ export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpda } if (inFiles && currentFile !== null) { - const finalized = parseFileRecord(currentFile, sourcePath, lineNumber); + const finalized = parseFileRecord(currentFile, sourcePath, lineNumber, platformLabel); if (finalized) files.push(finalized); currentFile = null; } @@ -127,7 +128,7 @@ export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpda const topLevelMatch = line.match(/^([A-Za-z][A-Za-z0-9]*):\s*(.+)$/); if (!topLevelMatch?.[1] || topLevelMatch[2] === undefined) { throw new Error( - `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: unsupported line '${line}'.`, + `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: unsupported line '${line}'.`, ); } @@ -137,7 +138,7 @@ export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpda if (key === "version") { if (typeof value !== "string") { throw new Error( - `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: version must be a string.`, + `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: version must be a string.`, ); } version = value; @@ -147,7 +148,7 @@ export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpda if (key === "releaseDate") { if (typeof value !== "string") { throw new Error( - `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: releaseDate must be a string.`, + `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: releaseDate must be a string.`, ); } releaseDate = value; @@ -161,17 +162,19 @@ export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpda extras[key] = value; } - const finalized = parseFileRecord(currentFile, sourcePath, lines.length); + const finalized = parseFileRecord(currentFile, sourcePath, lines.length, platformLabel); if (finalized) files.push(finalized); if (!version) { - throw new Error(`Invalid macOS update manifest at ${sourcePath}: missing version.`); + throw new Error(`Invalid ${platformLabel} update manifest at ${sourcePath}: missing version.`); } if (!releaseDate) { - throw new Error(`Invalid macOS update manifest at ${sourcePath}: missing releaseDate.`); + throw new Error( + `Invalid ${platformLabel} update manifest at ${sourcePath}: missing releaseDate.`, + ); } if (files.length === 0) { - throw new Error(`Invalid macOS update manifest at ${sourcePath}: missing files.`); + throw new Error(`Invalid ${platformLabel} update manifest at ${sourcePath}: missing files.`); } return { @@ -183,16 +186,17 @@ export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpda } function mergeExtras( - primary: Readonly>, - secondary: Readonly>, -): Record { - const merged: Record = { ...primary }; + primary: Readonly>, + secondary: Readonly>, + platformLabel: string, +): Record { + const merged: Record = { ...primary }; for (const [key, value] of Object.entries(secondary)) { const existing = merged[key]; if (existing !== undefined && existing !== value) { throw new Error( - `Cannot merge macOS update manifests: conflicting '${key}' values ('${existing}' vs '${value}').`, + `Cannot merge ${platformLabel} update manifests: conflicting '${key}' values ('${existing}' vs '${value}').`, ); } merged[key] = value; @@ -201,22 +205,23 @@ function mergeExtras( return merged; } -export function mergeMacUpdateManifests( - primary: MacUpdateManifest, - secondary: MacUpdateManifest, -): MacUpdateManifest { +export function mergeUpdateManifests( + primary: UpdateManifest, + secondary: UpdateManifest, + platformLabel: string, +): UpdateManifest { if (primary.version !== secondary.version) { throw new Error( - `Cannot merge macOS update manifests with different versions (${primary.version} vs ${secondary.version}).`, + `Cannot merge ${platformLabel} update manifests with different versions (${primary.version} vs ${secondary.version}).`, ); } - const filesByUrl = new Map(); + const filesByUrl = new Map(); for (const file of [...primary.files, ...secondary.files]) { const existing = filesByUrl.get(file.url); if (existing && (existing.sha512 !== file.sha512 || existing.size !== file.size)) { throw new Error( - `Cannot merge macOS update manifests: conflicting file entry for ${file.url}.`, + `Cannot merge ${platformLabel} update manifests: conflicting file entry for ${file.url}.`, ); } filesByUrl.set(file.url, file); @@ -227,7 +232,7 @@ export function mergeMacUpdateManifests( releaseDate: primary.releaseDate >= secondary.releaseDate ? primary.releaseDate : secondary.releaseDate, files: [...filesByUrl.values()], - extras: mergeExtras(primary.extras, secondary.extras), + extras: mergeExtras(primary.extras, secondary.extras, platformLabel), }; } @@ -235,15 +240,20 @@ function quoteYamlString(value: string): string { return `'${value.replace(/'/g, "''")}'`; } -function serializeScalarValue(value: MacUpdateScalar): string { +function serializeScalarValue(value: UpdateManifestScalar): string { if (typeof value === "string") { return quoteYamlString(value); } return String(value); } -export function serializeMacUpdateManifest(manifest: MacUpdateManifest): string { - const lines = [`version: ${manifest.version}`, "files:"]; +export function serializeUpdateManifest( + manifest: UpdateManifest, + options: { + readonly platformLabel: string; + }, +): string { + const lines = [`version: ${quoteYamlString(manifest.version)}`, "files:"]; for (const file of manifest.files) { lines.push(` - url: ${file.url}`); @@ -254,7 +264,9 @@ export function serializeMacUpdateManifest(manifest: MacUpdateManifest): string for (const key of Object.keys(manifest.extras).toSorted()) { const value = manifest.extras[key]; if (value === undefined) { - throw new Error(`Cannot serialize macOS update manifest: missing value for '${key}'.`); + throw new Error( + `Cannot serialize ${options.platformLabel} update manifest: missing value for '${key}'.`, + ); } lines.push(`${key}: ${serializeScalarValue(value)}`); } @@ -263,25 +275,3 @@ export function serializeMacUpdateManifest(manifest: MacUpdateManifest): string lines.push(""); return lines.join("\n"); } - -function main(args: ReadonlyArray): void { - const [arm64PathArg, x64PathArg, outputPathArg] = args; - if (!arm64PathArg || !x64PathArg) { - throw new Error( - "Usage: node scripts/merge-mac-update-manifests.ts [output-path]", - ); - } - - const arm64Path = resolve(arm64PathArg); - const x64Path = resolve(x64PathArg); - const outputPath = resolve(outputPathArg ?? arm64PathArg); - - const arm64Manifest = parseMacUpdateManifest(readFileSync(arm64Path, "utf8"), arm64Path); - const x64Manifest = parseMacUpdateManifest(readFileSync(x64Path, "utf8"), x64Path); - const merged = mergeMacUpdateManifests(arm64Manifest, x64Manifest); - writeFileSync(outputPath, serializeMacUpdateManifest(merged)); -} - -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { - main(process.argv.slice(2)); -} diff --git a/scripts/merge-mac-update-manifests.test.ts b/scripts/merge-mac-update-manifests.test.ts deleted file mode 100644 index 22d2e762..00000000 --- a/scripts/merge-mac-update-manifests.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; - -import { - mergeMacUpdateManifests, - parseMacUpdateManifest, - serializeMacUpdateManifest, -} from "./merge-mac-update-manifests.ts"; - -describe("merge-mac-update-manifests", () => { - it("merges arm64 and x64 macOS update manifests into one multi-arch manifest", () => { - const arm64 = parseMacUpdateManifest( - `version: 0.0.4 -files: - - url: T3-Code-0.0.4-arm64.zip - sha512: arm64zip - size: 125621344 - - url: T3-Code-0.0.4-arm64.dmg - sha512: arm64dmg - size: 131754935 -path: T3-Code-0.0.4-arm64.zip -sha512: arm64zip -releaseDate: '2026-03-07T10:32:14.587Z' -`, - "latest-mac.yml", - ); - - const x64 = parseMacUpdateManifest( - `version: 0.0.4 -files: - - url: T3-Code-0.0.4-x64.zip - sha512: x64zip - size: 132000112 - - url: T3-Code-0.0.4-x64.dmg - sha512: x64dmg - size: 138148807 -path: T3-Code-0.0.4-x64.zip -sha512: x64zip -releaseDate: '2026-03-07T10:36:07.540Z' -`, - "latest-mac-x64.yml", - ); - - const merged = mergeMacUpdateManifests(arm64, x64); - - assert.equal(merged.version, "0.0.4"); - assert.equal(merged.releaseDate, "2026-03-07T10:36:07.540Z"); - assert.deepStrictEqual( - merged.files.map((file) => file.url), - [ - "T3-Code-0.0.4-arm64.zip", - "T3-Code-0.0.4-arm64.dmg", - "T3-Code-0.0.4-x64.zip", - "T3-Code-0.0.4-x64.dmg", - ], - ); - - const serialized = serializeMacUpdateManifest(merged); - assert.ok(!serialized.includes("path:")); - assert.equal((serialized.match(/- url:/g) ?? []).length, 4); - }); - - it("rejects mismatched manifest versions", () => { - const arm64 = parseMacUpdateManifest( - `version: 0.0.4 -files: - - url: T3-Code-0.0.4-arm64.zip - sha512: arm64zip - size: 1 -releaseDate: '2026-03-07T10:32:14.587Z' -`, - "latest-mac.yml", - ); - - const x64 = parseMacUpdateManifest( - `version: 0.0.5 -files: - - url: T3-Code-0.0.5-x64.zip - sha512: x64zip - size: 1 -releaseDate: '2026-03-07T10:36:07.540Z' -`, - "latest-mac-x64.yml", - ); - - assert.throws(() => mergeMacUpdateManifests(arm64, x64), /different versions/); - }); - - it("preserves quoted scalars as strings", () => { - const manifest = parseMacUpdateManifest( - `version: '1.0' -files: - - url: T3-Code-1.0-x64.zip - sha512: zipsha - size: 1 -releaseName: 'true' -minimumSystemVersion: '13.0' -stagingPercentage: 50 -releaseDate: '2026-03-07T10:36:07.540Z' -`, - "latest-mac.yml", - ); - - assert.equal(manifest.version, "1.0"); - assert.equal(manifest.extras.releaseName, "true"); - assert.equal(manifest.extras.minimumSystemVersion, "13.0"); - assert.equal(manifest.extras.stagingPercentage, 50); - }); -}); diff --git a/scripts/merge-update-manifests.test.ts b/scripts/merge-update-manifests.test.ts new file mode 100644 index 00000000..3f2e3b08 --- /dev/null +++ b/scripts/merge-update-manifests.test.ts @@ -0,0 +1,306 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; +import { Command, CliError } from "effect/unstable/cli"; + +import { + mergePlatformUpdateManifests, + mergeUpdateManifestsCommand, + parsePlatformUpdateManifest, + serializePlatformUpdateManifest, +} from "./merge-update-manifests.ts"; + +const runCli = Command.runWith(mergeUpdateManifestsCommand, { version: "0.0.0" }); + +describe("merge-update-manifests", () => { + it("merges arm64 and x64 macOS update manifests into one multi-arch manifest", () => { + const arm64 = parsePlatformUpdateManifest( + "mac", + `version: 0.0.4 +files: + - url: T3-Code-0.0.4-arm64.zip + sha512: arm64zip + size: 125621344 + - url: T3-Code-0.0.4-arm64.dmg + sha512: arm64dmg + size: 131754935 +path: T3-Code-0.0.4-arm64.zip +sha512: arm64zip +releaseDate: '2026-03-07T10:32:14.587Z' +`, + "latest-mac.yml", + ); + + const x64 = parsePlatformUpdateManifest( + "mac", + `version: 0.0.4 +files: + - url: T3-Code-0.0.4-x64.zip + sha512: x64zip + size: 132000112 + - url: T3-Code-0.0.4-x64.dmg + sha512: x64dmg + size: 138148807 +path: T3-Code-0.0.4-x64.zip +sha512: x64zip +releaseDate: '2026-03-07T10:36:07.540Z' +`, + "latest-mac-x64.yml", + ); + + const merged = mergePlatformUpdateManifests("mac", arm64, x64); + + assert.equal(merged.version, "0.0.4"); + assert.equal(merged.releaseDate, "2026-03-07T10:36:07.540Z"); + assert.deepStrictEqual( + merged.files.map((file) => file.url), + [ + "T3-Code-0.0.4-arm64.zip", + "T3-Code-0.0.4-arm64.dmg", + "T3-Code-0.0.4-x64.zip", + "T3-Code-0.0.4-x64.dmg", + ], + ); + + const serialized = serializePlatformUpdateManifest("mac", merged); + assert.ok(!serialized.includes("path:")); + assert.equal((serialized.match(/- url:/g) ?? []).length, 4); + }); + + it("merges arm64 and x64 Windows update manifests into one multi-arch manifest", () => { + const arm64 = parsePlatformUpdateManifest( + "win", + `version: 0.0.4 +files: + - url: T3-Code-0.0.4-arm64.exe + sha512: arm64exe + size: 125621344 + - url: T3-Code-0.0.4-arm64.exe.blockmap + sha512: arm64blockmap + size: 131754 +path: T3-Code-0.0.4-arm64.exe +sha512: arm64exe +releaseDate: '2026-03-07T10:32:14.587Z' +`, + "latest-win-arm64.yml", + ); + + const x64 = parsePlatformUpdateManifest( + "win", + `version: 0.0.4 +files: + - url: T3-Code-0.0.4-x64.exe + sha512: x64exe + size: 132000112 + - url: T3-Code-0.0.4-x64.exe.blockmap + sha512: x64blockmap + size: 138148 +path: T3-Code-0.0.4-x64.exe +sha512: x64exe +releaseDate: '2026-03-07T10:36:07.540Z' +`, + "latest-win-x64.yml", + ); + + const merged = mergePlatformUpdateManifests("win", arm64, x64); + + assert.equal(merged.version, "0.0.4"); + assert.equal(merged.releaseDate, "2026-03-07T10:36:07.540Z"); + assert.deepStrictEqual( + merged.files.map((file) => file.url), + [ + "T3-Code-0.0.4-arm64.exe", + "T3-Code-0.0.4-arm64.exe.blockmap", + "T3-Code-0.0.4-x64.exe", + "T3-Code-0.0.4-x64.exe.blockmap", + ], + ); + + const serialized = serializePlatformUpdateManifest("win", merged); + assert.ok(!serialized.includes("path:")); + assert.equal((serialized.match(/- url:/g) ?? []).length, 4); + }); + + it("rejects mismatched manifest versions", () => { + const primary = parsePlatformUpdateManifest( + "win", + `version: 0.0.4 +files: + - url: T3-Code-0.0.4-arm64.exe + sha512: arm64exe + size: 1 +releaseDate: '2026-03-07T10:32:14.587Z' +`, + "latest-win-arm64.yml", + ); + + const secondary = parsePlatformUpdateManifest( + "win", + `version: 0.0.5 +files: + - url: T3-Code-0.0.5-x64.exe + sha512: x64exe + size: 1 +releaseDate: '2026-03-07T10:36:07.540Z' +`, + "latest-win-x64.yml", + ); + + assert.throws( + () => mergePlatformUpdateManifests("win", primary, secondary), + /different versions/, + ); + }); + + it("preserves quoted scalars as strings", () => { + const manifest = parsePlatformUpdateManifest( + "mac", + `version: '1.0' +files: + - url: T3-Code-1.0-x64.zip + sha512: zipsha + size: 1 +releaseName: 'true' +minimumSystemVersion: '13.0' +stagingPercentage: 50 +releaseDate: '2026-03-07T10:36:07.540Z' +`, + "latest-mac.yml", + ); + + assert.equal(manifest.version, "1.0"); + assert.equal(manifest.extras.releaseName, "true"); + assert.equal(manifest.extras.minimumSystemVersion, "13.0"); + assert.equal(manifest.extras.stagingPercentage, 50); + }); + + it("round-trips numeric-looking versions as strings", () => { + const original = parsePlatformUpdateManifest( + "win", + `version: '1.0' +files: + - url: T3-Code-1.0-x64.exe + sha512: exesha + size: 1 +releaseDate: '2026-03-07T10:36:07.540Z' +`, + "latest-win-x64.yml", + ); + + const serialized = serializePlatformUpdateManifest("win", original); + assert.ok(serialized.includes("version: '1.0'")); + + const reparsed = parsePlatformUpdateManifest("win", serialized, "latest-win-x64.yml"); + assert.equal(reparsed.version, "1.0"); + }); +}); + +it.layer(NodeServices.layer)("merge-update-manifests cli", (it) => { + const arm64MacManifest = `version: 0.0.4 +files: + - url: T3-Code-0.0.4-arm64.zip + sha512: arm64zip + size: 125621344 + - url: T3-Code-0.0.4-arm64.dmg + sha512: arm64dmg + size: 131754935 +path: T3-Code-0.0.4-arm64.zip +sha512: arm64zip +releaseDate: '2026-03-07T10:32:14.587Z' +`; + + const x64MacManifest = `version: 0.0.4 +files: + - url: T3-Code-0.0.4-x64.zip + sha512: x64zip + size: 132000112 + - url: T3-Code-0.0.4-x64.dmg + sha512: x64dmg + size: 138148807 +path: T3-Code-0.0.4-x64.zip +sha512: x64zip +releaseDate: '2026-03-07T10:36:07.540Z' +`; + + it.effect("writes the merged manifest back to the primary path by default", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "merge-update-manifests-cli-", + }); + const primaryPath = path.join(baseDir, "latest-mac.yml"); + const secondaryPath = path.join(baseDir, "latest-mac-x64.yml"); + + yield* fs.writeFileString(primaryPath, arm64MacManifest); + yield* fs.writeFileString(secondaryPath, x64MacManifest); + + yield* runCli(["--platform", "mac", primaryPath, secondaryPath]); + + const merged = yield* fs.readFileString(primaryPath); + assert.ok(merged.includes("T3-Code-0.0.4-arm64.zip")); + assert.ok(merged.includes("T3-Code-0.0.4-x64.zip")); + assert.ok(!merged.includes("path:")); + }), + ); + + it.effect("writes the merged manifest to an explicit output path", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "merge-update-manifests-cli-output-", + }); + const primaryPath = path.join(baseDir, "latest-win-arm64.yml"); + const secondaryPath = path.join(baseDir, "latest-win-x64.yml"); + const outputPath = path.join(baseDir, "latest-win.yml"); + + yield* fs.writeFileString( + primaryPath, + `version: 0.0.4 +files: + - url: T3-Code-0.0.4-arm64.exe + sha512: arm64exe + size: 125621344 +releaseDate: '2026-03-07T10:32:14.587Z' +`, + ); + yield* fs.writeFileString( + secondaryPath, + `version: 0.0.4 +files: + - url: T3-Code-0.0.4-x64.exe + sha512: x64exe + size: 132000112 +releaseDate: '2026-03-07T10:36:07.540Z' +`, + ); + + yield* runCli(["--platform", "win", primaryPath, secondaryPath, outputPath]); + + const merged = yield* fs.readFileString(outputPath); + assert.ok(merged.includes("T3-Code-0.0.4-arm64.exe")); + assert.ok(merged.includes("T3-Code-0.0.4-x64.exe")); + }), + ); + + it.effect("rejects invalid platform values during cli parsing", () => + Effect.gen(function* () { + const error = yield* runCli(["--platform", "linux", "a.yml", "b.yml"]).pipe(Effect.flip); + + if (!CliError.isCliError(error)) { + assert.fail(`Expected CliError, got ${String(error)}`); + } + + const platformError = + error._tag === "ShowHelp" ? (error.errors[0] as CliError.CliError | undefined) : error; + + if (!platformError || platformError._tag !== "InvalidValue") { + assert.fail(`Expected InvalidValue, got ${String(platformError?._tag)}`); + } + + assert.equal(platformError.option, "platform"); + assert.equal(platformError.value, "linux"); + }), + ); +}); diff --git a/scripts/merge-update-manifests.ts b/scripts/merge-update-manifests.ts new file mode 100644 index 00000000..1913cd71 --- /dev/null +++ b/scripts/merge-update-manifests.ts @@ -0,0 +1,108 @@ +#!/usr/bin/env node + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Effect, FileSystem, Option, Path, Schema } from "effect"; +import { Argument, Command, Flag } from "effect/unstable/cli"; + +import { + mergeUpdateManifests, + parseUpdateManifest, + serializeUpdateManifest, + type UpdateManifest, +} from "./lib/update-manifest.ts"; + +const UpdateManifestPlatform = Schema.Literals(["mac", "win"]); +export type UpdateManifestPlatform = typeof UpdateManifestPlatform.Type; + +function getPlatformLabel(platform: UpdateManifestPlatform): string { + return platform === "mac" ? "macOS" : "Windows"; +} + +export function parsePlatformUpdateManifest( + platform: UpdateManifestPlatform, + raw: string, + sourcePath: string, +): UpdateManifest { + return parseUpdateManifest(raw, sourcePath, getPlatformLabel(platform)); +} + +export function mergePlatformUpdateManifests( + platform: UpdateManifestPlatform, + primary: UpdateManifest, + secondary: UpdateManifest, +): UpdateManifest { + return mergeUpdateManifests(primary, secondary, getPlatformLabel(platform)); +} + +export function serializePlatformUpdateManifest( + platform: UpdateManifestPlatform, + manifest: UpdateManifest, +): string { + return serializeUpdateManifest(manifest, { + platformLabel: getPlatformLabel(platform), + }); +} + +export const mergeUpdateManifestFiles = Effect.fn("mergeUpdateManifestFiles")(function* ( + platform: UpdateManifestPlatform, + primaryPathArg: string, + secondaryPathArg: string, + outputPathArg: string | undefined, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const primaryPath = path.resolve(primaryPathArg); + const secondaryPath = path.resolve(secondaryPathArg); + const outputPath = path.resolve(outputPathArg ?? primaryPathArg); + + const primaryManifest = parsePlatformUpdateManifest( + platform, + yield* fs.readFileString(primaryPath), + primaryPath, + ); + const secondaryManifest = parsePlatformUpdateManifest( + platform, + yield* fs.readFileString(secondaryPath), + secondaryPath, + ); + const merged = mergePlatformUpdateManifests(platform, primaryManifest, secondaryManifest); + + yield* fs.writeFileString(outputPath, serializePlatformUpdateManifest(platform, merged)); +}); + +export const mergeUpdateManifestsCommand = Command.make( + "merge-update-manifests", + { + platform: Flag.choice("platform", UpdateManifestPlatform.literals).pipe( + Flag.withDescription("Update manifest platform."), + ), + primaryPath: Argument.string("primary-path").pipe( + Argument.withDescription("Primary update manifest path. Defaults to the output path."), + ), + secondaryPath: Argument.string("secondary-path").pipe( + Argument.withDescription( + "Secondary update manifest path to merge into the primary manifest.", + ), + ), + outputPath: Argument.string("output-path").pipe( + Argument.withDescription("Optional output path for the merged manifest."), + Argument.optional, + ), + }, + ({ platform, primaryPath, secondaryPath, outputPath }) => + mergeUpdateManifestFiles( + platform, + primaryPath, + secondaryPath, + Option.getOrUndefined(outputPath), + ), +).pipe(Command.withDescription("Merge two Electron updater manifests into a multi-arch manifest.")); + +if (import.meta.main) { + Command.run(mergeUpdateManifestsCommand, { version: "0.0.0" }).pipe( + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, + ); +} diff --git a/scripts/mock-update-server.test.ts b/scripts/mock-update-server.test.ts new file mode 100644 index 00000000..94467ac9 --- /dev/null +++ b/scripts/mock-update-server.test.ts @@ -0,0 +1,119 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { NodeHttpServer } from "@effect/platform-node"; +import { assert, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path } from "effect"; +import { HttpClient, HttpRouter } from "effect/unstable/http"; + +import { makeMockUpdateRouteLayer, resolveRootRealPath } from "./mock-update-server.ts"; + +const withMockUpdateServer = (rootRealPath: string, effect: Effect.Effect) => + effect.pipe( + Effect.provide( + HttpRouter.serve(makeMockUpdateRouteLayer(rootRealPath), { + disableListenLog: true, + disableLogger: true, + }).pipe(Layer.provideMerge(NodeHttpServer.layerTest)), + ), + ); + +it.layer(NodeServices.layer)("mock-update-server", (it) => { + it.effect("serves files from the configured root", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "mock-update-server-root-", + }); + const rootRealPath = yield* fileSystem.realPath(root); + const filePath = path.join(root, "latest.yml"); + + yield* fileSystem.writeFileString(filePath, "version: 0.0.1\n"); + + yield* withMockUpdateServer( + rootRealPath, + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const response = yield* client.get("/latest.yml"); + + assert.equal(response.status, 200); + assert.equal(response.headers["content-type"], "text/yaml"); + assert.equal(yield* response.text, "version: 0.0.1\n"); + }), + ); + }), + ); + + it.effect("rejects encoded path traversal outside the configured root", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "mock-update-server-root-", + }); + const outside = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "mock-update-server-outside-", + }); + const rootRealPath = yield* fileSystem.realPath(root); + + yield* fileSystem.writeFileString(path.join(outside, "secret.txt"), "nope\n"); + + yield* withMockUpdateServer( + rootRealPath, + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const response = yield* client.get("/%2e%2e/secret.txt"); + + assert.equal(response.status, 404); + assert.equal(yield* response.text, "Not Found"); + }), + ); + }), + ); + + it.effect("rejects symlinked files that escape the configured root", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "mock-update-server-root-", + }); + const outside = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "mock-update-server-outside-", + }); + const rootRealPath = yield* fileSystem.realPath(root); + const outsideFile = path.join(outside, "outside.yml"); + const linksDir = path.join(root, "links"); + const symlinkPath = path.join(linksDir, "outside.yml"); + + yield* fileSystem.writeFileString(outsideFile, "version: outside\n"); + yield* fileSystem.makeDirectory(linksDir, { recursive: true }); + yield* fileSystem.symlink(outsideFile, symlinkPath); + + yield* withMockUpdateServer( + rootRealPath, + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const response = yield* client.get("/links/outside.yml"); + + assert.equal(response.status, 404); + assert.equal(yield* response.text, "Not Found"); + }), + ); + }), + ); + + it.effect("falls back to the resolved path when the configured root does not exist yet", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "mock-update-server-missing-root-", + }); + const missingRoot = path.join(parent, "release-mock"); + + const resolved = yield* resolveRootRealPath(missingRoot); + + assert.equal(resolved, missingRoot); + }), + ); +}); diff --git a/scripts/mock-update-server.ts b/scripts/mock-update-server.ts index 57dab49f..fc8ee93f 100644 --- a/scripts/mock-update-server.ts +++ b/scripts/mock-update-server.ts @@ -1,44 +1,167 @@ -import { resolve, relative } from "node:path"; -import { realpathSync } from "node:fs"; +import * as NodeHttp from "node:http"; -const port = Number(process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT ?? 3000); -const root = - process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_ROOT ?? - resolve(import.meta.dirname, "..", "release-mock"); +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Config, Effect, FileSystem, Layer, Path } from "effect"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; -const mockServerLog = (level: "info" | "warn" | "error" = "info", message: string) => { - console[level](`[mock-update-server] ${message}`); -}; - -function isWithinRoot(filePath: string): boolean { - try { - return !relative(realpathSync(root), realpathSync(filePath)).startsWith("."); - } catch (error) { - mockServerLog("error", `Error checking if file is within root: ${error}`); - return false; - } +interface MockUpdateServerConfig { + readonly port: number; + readonly rootRealPath: string; } -Bun.serve({ - port, - hostname: "localhost", - fetch: async (request) => { - const url = new URL(request.url); - const path = url.pathname; - mockServerLog("info", `Request received for path: ${path}`); - const filePath = resolve(root, `.${path}`); - if (!isWithinRoot(filePath)) { - mockServerLog("warn", `Attempted to access file outside of root: ${filePath}`); - return new Response("Not Found", { status: 404 }); +export const resolveRootRealPath = (resolvedRoot: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem + .realPath(resolvedRoot) + .pipe( + Effect.catch((error) => + error._tag === "PlatformError" && error.reason?._tag === "NotFound" + ? Effect.succeed(resolvedRoot) + : Effect.fail(error), + ), + ); + }); + +const resolveMockUpdateServerConfig = Effect.gen(function* () { + const path = yield* Path.Path; + const config = yield* Config.all({ + port: Config.port("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe(Config.withDefault(3000)), + root: Config.string("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_ROOT").pipe( + Config.withDefault("../release-mock"), + ), + }).asEffect(); + + const resolvedRoot = path.resolve(import.meta.dirname, config.root); + + return { + port: config.port, + rootRealPath: yield* resolveRootRealPath(resolvedRoot), + } satisfies MockUpdateServerConfig; +}); + +const isOutsideRoot = (rootRealPath: string, filePath: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + const relativePath = path.relative(rootRealPath, filePath); + return ( + relativePath === ".." || relativePath.startsWith("../") || relativePath.startsWith("..\\") + ); + }); + +const isWithinRoot = (rootRealPath: string, filePath: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const resolvedFilePath = yield* fileSystem.realPath(filePath).pipe( + Effect.match({ + onFailure: () => undefined, + onSuccess: (resolvedPath) => resolvedPath, + }), + ); + + return ( + resolvedFilePath !== undefined && !(yield* isOutsideRoot(rootRealPath, resolvedFilePath)) + ); + }); + +const resolveRequestedFilePath = (rootRealPath: string, requestUrl: string | undefined) => + Effect.gen(function* () { + const path = yield* Path.Path; + const rawPath = (requestUrl ?? "/").split("?", 1)[0] ?? "/"; + const decodedPath = yield* Effect.try({ + try: () => decodeURIComponent(rawPath), + catch: () => null, + }).pipe( + Effect.match({ + onFailure: () => undefined, + onSuccess: (value) => value, + }), + ); + + if (!decodedPath) { + return undefined; } - const file = Bun.file(filePath); - if (!(await file.exists())) { - mockServerLog("warn", `Attempted to access non-existent file: ${filePath}`); - return new Response("Not Found", { status: 404 }); + + if (decodedPath.includes("\0")) { + return undefined; } - mockServerLog("info", `Serving file: ${filePath}`); - return new Response(file.stream()); - }, -}); -mockServerLog("info", `running on http://localhost:${port}`); + const filePath = path.resolve( + rootRealPath, + `.${decodedPath.startsWith("/") ? decodedPath : `/${decodedPath}`}`, + ); + + return (yield* isOutsideRoot(rootRealPath, filePath)) ? undefined : filePath; + }); + +const isServableFile = (rootRealPath: string, filePath: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const stat = yield* fileSystem.stat(filePath).pipe( + Effect.match({ + onFailure: () => undefined, + onSuccess: (info) => info, + }), + ); + + if (stat?.type !== "File") { + return false; + } + + return yield* isWithinRoot(rootRealPath, filePath); + }); + +export const makeMockUpdateRouteLayer = (rootRealPath: string) => { + return HttpRouter.add( + "*", + "*", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const requestPath = (request.url ?? "/").split("?", 1)[0] ?? "/"; + yield* Effect.logInfo(`Request received for path: ${requestPath}`); + + const filePath = yield* resolveRequestedFilePath(rootRealPath, request.url); + if (!filePath) { + yield* Effect.logWarning(`Attempted to access file outside of root: ${request.url ?? "/"}`); + return HttpServerResponse.text("Not Found", { status: 404 }); + } + + if (!(yield* isServableFile(rootRealPath, filePath))) { + yield* Effect.logWarning(`Attempted to access invalid file: ${filePath}`); + return HttpServerResponse.text("Not Found", { status: 404 }); + } + + yield* Effect.logInfo(`Serving file: ${filePath}`); + return yield* HttpServerResponse.file(filePath, { status: 200 }); + }).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + yield* Effect.logError(`Unhandled mock update request failure: ${cause}`); + return HttpServerResponse.text("Internal Server Error", { status: 500 }); + }), + ), + ), + ); +}; + +const makeMockUpdateServerLayer = (config: MockUpdateServerConfig) => + HttpRouter.serve(makeMockUpdateRouteLayer(config.rootRealPath)).pipe( + Layer.provideMerge( + NodeHttpServer.layer(NodeHttp.createServer, { + host: "localhost", + port: config.port, + }), + ), + Layer.provideMerge(NodeServices.layer), + ); + +if (import.meta.main) { + resolveMockUpdateServerConfig.pipe( + Effect.map(makeMockUpdateServerLayer), + Layer.unwrap, + Layer.launch, + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, + ); +} diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 3ca283c1..0d95b494 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -1,5 +1,13 @@ import { execFileSync } from "node:child_process"; -import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -70,12 +78,91 @@ releaseDate: '2026-03-08T10:36:07.540Z' return { arm64Path, x64Path }; } +function writeWindowsManifestFixtures( + targetRoot: string, + channel: string, +): { arm64Path: string; x64Path: string } { + const assetDirectory = resolve(targetRoot, "release-assets"); + mkdirSync(assetDirectory, { recursive: true }); + + const arm64Path = resolve(assetDirectory, `${channel}-win-arm64.yml`); + const x64Path = resolve(assetDirectory, `${channel}-win-x64.yml`); + + writeFileSync( + arm64Path, + `version: 9.9.9-smoke.0 +files: + - url: T3-Code-9.9.9-smoke.0-arm64.exe + sha512: arm64exe + size: 126621344 + - url: T3-Code-9.9.9-smoke.0-arm64.exe.blockmap + sha512: arm64blockmap + size: 152344 +path: T3-Code-9.9.9-smoke.0-arm64.exe +sha512: arm64exe +releaseDate: '2026-03-08T10:32:14.587Z' +`, + ); + + writeFileSync( + x64Path, + `version: 9.9.9-smoke.0 +files: + - url: T3-Code-9.9.9-smoke.0-x64.exe + sha512: x64exe + size: 132000112 + - url: T3-Code-9.9.9-smoke.0-x64.exe.blockmap + sha512: x64blockmap + size: 160112 +path: T3-Code-9.9.9-smoke.0-x64.exe +sha512: x64exe +releaseDate: '2026-03-08T10:36:07.540Z' +`, + ); + + return { arm64Path, x64Path }; +} + +function writeWindowsBuilderDebugFixtures(targetRoot: string): { + arm64Path: string; + x64Path: string; +} { + const assetDirectory = resolve(targetRoot, "release-assets"); + mkdirSync(assetDirectory, { recursive: true }); + + const arm64Path = resolve(assetDirectory, "builder-debug-win-arm64.yml"); + const x64Path = resolve(assetDirectory, "builder-debug-win-x64.yml"); + const debugFixture = `arm64: + firstOrDefaultFilePatterns: + - '**/*' +nsis: + script: |- + !include "example.nsh" +`; + + writeFileSync(arm64Path, debugFixture); + writeFileSync(x64Path, debugFixture); + + return { arm64Path, x64Path }; +} function assertContains(haystack: string, needle: string, message: string): void { if (!haystack.includes(needle)) { throw new Error(message); } } +function assertExists(path: string, message: string): void { + if (!existsSync(path)) { + throw new Error(message); + } +} + +function assertMissing(path: string, message: string): void { + if (existsSync(path)) { + throw new Error(message); + } +} + const tempRoot = mkdtempSync(join(tmpdir(), "t3-release-smoke-")); try { @@ -127,24 +214,30 @@ try { ); assertContains( nightlyReleaseMetadata, - "version=9.9.9-nightly.20260413.321", + "version=9.9.10-nightly.20260413.321", "Expected nightly metadata to contain the derived nightly version.", ); assertContains( nightlyReleaseMetadata, - "tag=nightly-v9.9.9-nightly.20260413.321", + "tag=nightly-v9.9.10-nightly.20260413.321", "Expected nightly metadata to contain the derived nightly tag.", ); assertContains( nightlyReleaseMetadata, - "name=T3 Code Nightly 9.9.9-nightly.20260413.321 (abcdef123456)", + "name=T3 Code Nightly 9.9.10-nightly.20260413.321 (abcdef123456)", "Expected nightly metadata to include the short commit SHA in the release name.", ); const { arm64Path, x64Path } = writeMacManifestFixtures(tempRoot); execFileSync( process.execPath, - [resolve(repoRoot, "scripts/merge-mac-update-manifests.ts"), arm64Path, x64Path], + [ + resolve(repoRoot, "scripts/merge-update-manifests.ts"), + "--platform", + "mac", + arm64Path, + x64Path, + ], { cwd: repoRoot, stdio: "inherit", @@ -163,6 +256,122 @@ try { "Merged manifest is missing the x64 asset.", ); + const { arm64Path: winArm64Path, x64Path: winX64Path } = writeWindowsManifestFixtures( + tempRoot, + "latest", + ); + const mergedWindowsManifestPath = resolve(tempRoot, "release-assets/latest.yml"); + const { arm64Path: nightlyWinArm64Path, x64Path: nightlyWinX64Path } = + writeWindowsManifestFixtures(tempRoot, "nightly"); + const mergedNightlyWindowsManifestPath = resolve(tempRoot, "release-assets/nightly.yml"); + const { arm64Path: previewWinArm64Path, x64Path: previewWinX64Path } = + writeWindowsManifestFixtures(tempRoot, "preview"); + const mergedPreviewWindowsManifestPath = resolve(tempRoot, "release-assets/preview.yml"); + const { arm64Path: winDebugArm64Path, x64Path: winDebugX64Path } = + writeWindowsBuilderDebugFixtures(tempRoot); + execFileSync( + "bash", + [ + "-lc", + ` + release_assets_dir=${JSON.stringify(resolve(tempRoot, "release-assets"))} + shopt -s nullglob + found_windows_manifest=false + for x64_manifest in "$release_assets_dir"/*-win-x64.yml; do + if [[ "$(basename "$x64_manifest")" == builder-debug-* ]]; then + continue + fi + + arm64_manifest="\${x64_manifest/-x64.yml/-arm64.yml}" + output_manifest="\${x64_manifest/-win-x64.yml/.yml}" + if [[ ! -f "$arm64_manifest" ]]; then + echo "Missing matching arm64 Windows manifest for $x64_manifest" >&2 + exit 1 + fi + + found_windows_manifest=true + node ${JSON.stringify(resolve(repoRoot, "scripts/merge-update-manifests.ts"))} --platform win \ + "$arm64_manifest" \ + "$x64_manifest" \ + "$output_manifest" + rm -f "$arm64_manifest" "$x64_manifest" + done + + if [[ "$found_windows_manifest" != true ]]; then + echo "No Windows updater manifests found to merge." >&2 + exit 1 + fi + `, + ], + { + cwd: repoRoot, + stdio: "inherit", + }, + ); + + const mergedWindowsManifest = readFileSync(mergedWindowsManifestPath, "utf8"); + assertContains( + mergedWindowsManifest, + "T3-Code-9.9.9-smoke.0-arm64.exe", + "Merged Windows manifest is missing the arm64 asset.", + ); + assertContains( + mergedWindowsManifest, + "T3-Code-9.9.9-smoke.0-x64.exe", + "Merged Windows manifest is missing the x64 asset.", + ); + const mergedNightlyWindowsManifest = readFileSync(mergedNightlyWindowsManifestPath, "utf8"); + assertContains( + mergedNightlyWindowsManifest, + "T3-Code-9.9.9-smoke.0-arm64.exe", + "Merged nightly Windows manifest is missing the arm64 asset.", + ); + assertContains( + mergedNightlyWindowsManifest, + "T3-Code-9.9.9-smoke.0-x64.exe", + "Merged nightly Windows manifest is missing the x64 asset.", + ); + const mergedPreviewWindowsManifest = readFileSync(mergedPreviewWindowsManifestPath, "utf8"); + assertContains( + mergedPreviewWindowsManifest, + "T3-Code-9.9.9-smoke.0-arm64.exe", + "Merged preview Windows manifest is missing the arm64 asset.", + ); + assertContains( + mergedPreviewWindowsManifest, + "T3-Code-9.9.9-smoke.0-x64.exe", + "Merged preview Windows manifest is missing the x64 asset.", + ); + assertMissing( + winArm64Path, + "Windows release smoke unexpectedly kept the arm64 updater manifest.", + ); + assertMissing(winX64Path, "Windows release smoke unexpectedly kept the x64 updater manifest."); + assertMissing( + nightlyWinArm64Path, + "Windows release smoke unexpectedly kept the nightly arm64 updater manifest.", + ); + assertMissing( + nightlyWinX64Path, + "Windows release smoke unexpectedly kept the nightly x64 updater manifest.", + ); + assertMissing( + previewWinArm64Path, + "Windows release smoke unexpectedly kept the preview arm64 updater manifest.", + ); + assertMissing( + previewWinX64Path, + "Windows release smoke unexpectedly kept the preview x64 updater manifest.", + ); + assertExists( + winDebugArm64Path, + "Windows release smoke unexpectedly removed the arm64 builder debug fixture.", + ); + assertExists( + winDebugX64Path, + "Windows release smoke unexpectedly removed the x64 builder debug fixture.", + ); + console.log("Release smoke checks passed."); } finally { rmSync(tempRoot, { recursive: true, force: true }); diff --git a/scripts/resolve-nightly-release.test.ts b/scripts/resolve-nightly-release.test.ts index 8a381434..56358d6c 100644 --- a/scripts/resolve-nightly-release.test.ts +++ b/scripts/resolve-nightly-release.test.ts @@ -3,6 +3,7 @@ import { assert, it } from "@effect/vitest"; import { resolveNightlyBaseVersion, resolveNightlyReleaseMetadata, + resolveNightlyTargetVersion, } from "./resolve-nightly-release.ts"; it("strips prerelease and build metadata when deriving the nightly base version", () => { @@ -11,14 +12,20 @@ it("strips prerelease and build metadata when deriving the nightly base version" assert.equal(resolveNightlyBaseVersion("1.2.3-beta.4+build.9"), "1.2.3"); }); +it("bumps the patch version before deriving nightly prerelease versions", () => { + assert.equal(resolveNightlyTargetVersion("0.0.17"), "0.0.18"); + assert.equal(resolveNightlyTargetVersion("9.9.9-smoke.0"), "9.9.10"); + assert.equal(resolveNightlyTargetVersion("1.2.3-beta.4+build.9"), "1.2.4"); +}); + it("derives nightly metadata including the short commit sha in the release name", () => { assert.deepStrictEqual( - resolveNightlyReleaseMetadata("9.9.9", "20260413", 321, "abcdef1234567890"), + resolveNightlyReleaseMetadata("9.9.10", "20260413", 321, "abcdef1234567890"), { - baseVersion: "9.9.9", - version: "9.9.9-nightly.20260413.321", - tag: "nightly-v9.9.9-nightly.20260413.321", - name: "T3 Code Nightly 9.9.9-nightly.20260413.321 (abcdef123456)", + baseVersion: "9.9.10", + version: "9.9.10-nightly.20260413.321", + tag: "nightly-v9.9.10-nightly.20260413.321", + name: "T3 Code Nightly 9.9.10-nightly.20260413.321 (abcdef123456)", shortSha: "abcdef123456", }, ); diff --git a/scripts/resolve-nightly-release.ts b/scripts/resolve-nightly-release.ts index 571baec8..4a92ef63 100644 --- a/scripts/resolve-nightly-release.ts +++ b/scripts/resolve-nightly-release.ts @@ -32,6 +32,17 @@ const decodeDesktopPackageJson = Schema.decodeUnknownEffect( export const resolveNightlyBaseVersion = (version: string) => version.replace(/[-+].*$/, ""); +export const resolveNightlyTargetVersion = (version: string) => { + const stableCore = resolveNightlyBaseVersion(version); + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(stableCore); + if (!match) { + throw new Error(`Invalid desktop package version '${version}'.`); + } + + const [, major, minor, patch] = match; + return `${major}.${minor}.${Number(patch) + 1}`; +}; + export const resolveNightlyReleaseMetadata = ( baseVersion: string, date: string, @@ -59,7 +70,7 @@ const readDesktopBaseVersion = Effect.fn("readDesktopBaseVersion")(function* ( const packageJson = yield* fs .readFileString(packageJsonPath) .pipe(Effect.flatMap(decodeDesktopPackageJson)); - return resolveNightlyBaseVersion(packageJson.version); + return resolveNightlyTargetVersion(packageJson.version); }); const writeOutput = Effect.fn("writeOutput")(function* ( diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json index e9ed7c8a..3b189a76 100644 --- a/scripts/tsconfig.json +++ b/scripts/tsconfig.json @@ -2,10 +2,8 @@ "extends": "../tsconfig.base.json", "compilerOptions": { "composite": true, - "types": ["node", "bun"], - "lib": ["ES2023", "esnext.disposable"], - "noEmit": true, - "allowImportingTsExtensions": true, + "types": ["node"], + "lib": ["ESNext", "esnext.disposable"], "plugins": [ { "name": "@effect/language-service" diff --git a/scripts/update-release-package-versions.test.ts b/scripts/update-release-package-versions.test.ts new file mode 100644 index 00000000..df2b194c --- /dev/null +++ b/scripts/update-release-package-versions.test.ts @@ -0,0 +1,213 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { ConfigProvider, Effect, FileSystem, Layer, Path, Schema, SchemaGetter } from "effect"; +import { Command, CliError } from "effect/unstable/cli"; +import * as TestConsole from "effect/testing/TestConsole"; + +import { + releasePackageFiles, + updateReleasePackageVersions, + updateReleasePackageVersionsCommand, +} from "./update-release-package-versions.ts"; + +const ScriptTestLayer = Layer.mergeAll(NodeServices.layer, TestConsole.layer); +const runCli = Command.runWith(updateReleasePackageVersionsCommand, { version: "0.0.0" }); +const PackageJsonSchema = Schema.Record(Schema.String, Schema.Unknown); +const PrettyJsonString = SchemaGetter.parseJson().compose( + SchemaGetter.stringifyJson({ space: 2 }), +); +const PackageJsonPrettyJson = Schema.fromJsonString(PackageJsonSchema).pipe( + Schema.encode({ + decode: PrettyJsonString, + encode: PrettyJsonString, + }), +); +const decodePackageJson = Schema.decodeUnknownEffect(PackageJsonPrettyJson); +const encodePackageJson = Schema.encodeSync(PackageJsonPrettyJson); + +const writePackageJsonFixtures = Effect.fn("writePackageJsonFixtures")(function* ( + rootDir: string, + version: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + for (const relativePath of releasePackageFiles) { + const filePath = path.join(rootDir, relativePath); + yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fs.writeFileString( + filePath, + `${encodePackageJson({ + name: relativePath, + version, + private: true, + })}\n`, + ); + } +}); + +const readReleaseVersions = Effect.fn("readReleaseVersions")(function* (rootDir: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const versions = new Map(); + + for (const relativePath of releasePackageFiles) { + const filePath = path.join(rootDir, relativePath); + const packageJson = yield* fs.readFileString(filePath).pipe(Effect.flatMap(decodePackageJson)); + versions.set(relativePath, String(packageJson.version)); + } + + return versions; +}); + +const captureLogs = (effect: Effect.Effect) => + Effect.gen(function* () { + const result = yield* effect; + const logs = (yield* TestConsole.logLines).filter( + (line): line is string => typeof line === "string", + ); + return { result, logs }; + }); + +it.layer(ScriptTestLayer)("update-release-package-versions", (it) => { + it.effect("updates all release package versions under the provided root", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "update-release-package-versions-", + }); + + yield* writePackageJsonFixtures(baseDir, "0.0.1"); + + const result = yield* updateReleasePackageVersions("1.2.3", { rootDir: baseDir }); + const versions = yield* readReleaseVersions(baseDir); + + assert.deepStrictEqual(result, { changed: true }); + assert.deepStrictEqual( + Array.from(versions.entries()), + releasePackageFiles.map((relativePath) => [relativePath, "1.2.3"]), + ); + }), + ); + + it.effect("returns changed=false when all versions already match", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "update-release-package-versions-unchanged-", + }); + + yield* writePackageJsonFixtures(baseDir, "1.2.3"); + + const result = yield* updateReleasePackageVersions("1.2.3", { rootDir: baseDir }); + + assert.deepStrictEqual(result, { changed: false }); + }), + ); + + it.effect("accepts flags before the version positional and appends changed output", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "update-release-package-versions-cli-", + }); + const githubOutputPath = path.join(baseDir, "github-output.txt"); + + yield* writePackageJsonFixtures(baseDir, "0.0.1"); + + yield* runCli(["--github-output", "--root", baseDir, "2.0.0"]).pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + GITHUB_OUTPUT: githubOutputPath, + }, + }), + ), + ), + ); + + const githubOutput = yield* fs.readFileString(githubOutputPath); + assert.equal(githubOutput, "changed=true\n"); + }), + ); + + it.effect("logs when nothing changed", () => + captureLogs( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "update-release-package-versions-cli-log-", + }); + + yield* writePackageJsonFixtures(baseDir, "3.0.0"); + yield* runCli(["3.0.0", "--root", baseDir]); + }), + ).pipe( + Effect.tap(({ logs }) => { + assert.deepStrictEqual(logs, ["All package.json versions already match release version."]); + return Effect.void; + }), + ), + ); + + it.effect("requires GITHUB_OUTPUT when --github-output is set", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "update-release-package-versions-cli-missing-output-", + }); + + yield* writePackageJsonFixtures(baseDir, "0.0.1"); + + const error = yield* runCli(["4.0.0", "--root", baseDir, "--github-output"]).pipe( + Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))), + Effect.flip, + ); + + assert.equal( + error.message, + 'SchemaError(Expected string, got undefined\n at ["GITHUB_OUTPUT"])', + ); + }), + ); + + it.effect("rejects unknown flags during cli parsing", () => + Effect.gen(function* () { + const error = yield* runCli(["1.2.3", "--unknown"]).pipe(Effect.flip); + + if (!CliError.isCliError(error)) { + assert.fail(`Expected CliError, got ${String(error)}`); + } + + const optionError = + error._tag === "ShowHelp" ? (error.errors[0] as CliError.CliError | undefined) : error; + + if (!optionError || optionError._tag !== "UnrecognizedOption") { + assert.fail(`Expected UnrecognizedOption, got ${String(optionError?._tag)}`); + } + + assert.equal(optionError.option, "--unknown"); + }), + ); + + it.effect("rejects a missing version positional during cli parsing", () => + Effect.gen(function* () { + const error = yield* runCli(["--github-output"]).pipe(Effect.flip); + + if (!CliError.isCliError(error)) { + assert.fail(`Expected CliError, got ${String(error)}`); + } + + const versionError = + error._tag === "ShowHelp" ? (error.errors[0] as CliError.CliError | undefined) : error; + + if (!versionError || versionError._tag !== "MissingArgument") { + assert.fail(`Expected MissingArgument, got ${String(versionError?._tag)}`); + } + + assert.equal(versionError.argument, "version"); + }), + ); +}); diff --git a/scripts/update-release-package-versions.ts b/scripts/update-release-package-versions.ts index b860b85e..d2baa85a 100644 --- a/scripts/update-release-package-versions.ts +++ b/scripts/update-release-package-versions.ts @@ -1,6 +1,9 @@ -import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +#!/usr/bin/env node + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Config, Console, Effect, FileSystem, Option, Path, Schema, SchemaGetter } from "effect"; +import { Argument, Command, Flag } from "effect/unstable/cli"; export const releasePackageFiles = [ "apps/server/package.json", @@ -10,103 +13,82 @@ export const releasePackageFiles = [ ] as const; interface UpdateReleasePackageVersionsOptions { - readonly rootDir?: string; -} - -interface MutablePackageJson { - version?: string; - [key: string]: unknown; + readonly rootDir?: string | undefined; } -export function updateReleasePackageVersions( +const PackageJsonSchema = Schema.Record(Schema.String, Schema.Unknown); +const PrettyJsonString = SchemaGetter.parseJson().compose( + SchemaGetter.stringifyJson({ space: 2 }), +); +const PackageJsonPrettyJson = Schema.fromJsonString(PackageJsonSchema).pipe( + Schema.encode({ + decode: PrettyJsonString, + encode: PrettyJsonString, + }), +); +const decodePackageJson = Schema.decodeUnknownEffect(PackageJsonPrettyJson); +const encodePackageJson = Schema.encodeSync(PackageJsonPrettyJson); + +export const updateReleasePackageVersions = Effect.fn("updateReleasePackageVersions")(function* ( version: string, options: UpdateReleasePackageVersionsOptions = {}, -): { changed: boolean } { - const rootDir = resolve(options.rootDir ?? process.cwd()); +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const rootDir = path.resolve(options.rootDir ?? process.cwd()); let changed = false; for (const relativePath of releasePackageFiles) { - const filePath = resolve(rootDir, relativePath); - const packageJson = JSON.parse(readFileSync(filePath, "utf8")) as MutablePackageJson; + const filePath = path.join(rootDir, relativePath); + const packageJson = yield* fs.readFileString(filePath).pipe(Effect.flatMap(decodePackageJson)); if (packageJson.version === version) { continue; } - packageJson.version = version; - writeFileSync(filePath, `${JSON.stringify(packageJson, null, 2)}\n`); + yield* fs.writeFileString(filePath, `${encodePackageJson({ ...packageJson, version })}\n`); changed = true; } return { changed }; -} - -function parseArgs(argv: ReadonlyArray): { - version: string; - rootDir: string | undefined; - writeGithubOutput: boolean; -} { - let version: string | undefined; - let rootDir: string | undefined; - let writeGithubOutput = false; - - for (let index = 0; index < argv.length; index += 1) { - const argument = argv[index]; - if (argument === undefined) { - continue; - } - - if (argument === "--github-output") { - writeGithubOutput = true; - continue; - } - - if (argument === "--root") { - rootDir = argv[index + 1]; - if (!rootDir) { - throw new Error("Missing value for --root."); - } - index += 1; - continue; - } - - if (argument.startsWith("--")) { - throw new Error(`Unknown argument: ${argument}`); - } - - if (version !== undefined) { - throw new Error("Only one release version can be provided."); - } - version = argument; - } - - if (!version) { - throw new Error( - "Usage: node scripts/update-release-package-versions.ts [--root ] [--github-output]", - ); - } - - return { version, rootDir, writeGithubOutput }; -} - -const isMain = - process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url); - -if (isMain) { - const { version, rootDir, writeGithubOutput } = parseArgs(process.argv.slice(2)); - const { changed } = updateReleasePackageVersions( - version, - rootDir === undefined ? {} : { rootDir }, +}); + +const writeGithubOutput = Effect.fn("writeGithubOutput")(function* (changed: boolean) { + const fs = yield* FileSystem.FileSystem; + const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT"); + yield* fs.writeFileString(githubOutputPath, `changed=${changed}\n`, { flag: "a" }); +}); + +export const updateReleasePackageVersionsCommand = Command.make( + "update-release-package-versions", + { + version: Argument.string("version").pipe( + Argument.withDescription("Release version to write into each releasable package.json."), + ), + root: Flag.string("root").pipe( + Flag.withDescription("Workspace root used to resolve the release package manifests."), + Flag.optional, + ), + githubOutput: Flag.boolean("github-output").pipe( + Flag.withDescription("Append changed= to GITHUB_OUTPUT."), + Flag.withDefault(false), + ), + }, + ({ version, root, githubOutput }) => + updateReleasePackageVersions(version, { + rootDir: Option.getOrUndefined(root), + }).pipe( + Effect.tap(({ changed }) => + changed + ? Effect.void + : Console.log("All package.json versions already match release version."), + ), + Effect.tap(({ changed }) => (githubOutput ? writeGithubOutput(changed) : Effect.void)), + ), +).pipe(Command.withDescription("Update release package versions across the workspace.")); + +if (import.meta.main) { + Command.run(updateReleasePackageVersionsCommand, { version: "0.0.0" }).pipe( + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, ); - - if (!changed) { - console.log("All package.json versions already match release version."); - } - - if (writeGithubOutput) { - const githubOutputPath = process.env.GITHUB_OUTPUT; - if (!githubOutputPath) { - throw new Error("GITHUB_OUTPUT is required when --github-output is set."); - } - appendFileSync(githubOutputPath, `changed=${changed}\n`); - } } diff --git a/tsconfig.base.json b/tsconfig.base.json index 538fa0f0..8d481cc7 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,8 +1,13 @@ { "compilerOptions": { - "target": "ES2023", - "module": "ESNext", - "moduleResolution": "Bundler", + "target": "ESNext", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true,