mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-09 01:45:00 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
965ee97e4a | ||
|
|
02168446fb | ||
|
|
0ac8175015 |
@@ -2,7 +2,6 @@ import path from "node:path";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
BOTS_PLUGIN_ID,
|
||||
COMPACT,
|
||||
@@ -177,37 +176,6 @@ test.describe("Plugin sidebar items", () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
test("a short compact sidebar keeps workspaces usable while navigation scrolls", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 600 });
|
||||
await gotoAppShell(page);
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||
await expect(botRow(page, "status")).toBeAttached();
|
||||
const workspace = visibleTestId(page, `sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
await expect(workspace).toBeInViewport({ ratio: 1 });
|
||||
|
||||
await test.step("scrolling navigation reaches its last row without moving workspaces", async () => {
|
||||
const before = await boxOf(workspace);
|
||||
await visibleTestId(page, "sidebar-global-new-workspace").hover();
|
||||
await page.mouse.wheel(0, 800);
|
||||
await expect(botRow(page, "status")).toBeInViewport({ ratio: 1 });
|
||||
await expect(workspace).toBeInViewport({ ratio: 1 });
|
||||
expect((await boxOf(workspace)).y).toBe(before.y);
|
||||
await botRow(page, "status").click();
|
||||
await expect(page.getByText("Bot status details", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Close bot status", exact: true }).click();
|
||||
});
|
||||
|
||||
await test.step("the workspace still opens and closes the sidebar", async () => {
|
||||
await workspace.click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Open menu", exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(workspace).not.toBeInViewport();
|
||||
});
|
||||
});
|
||||
|
||||
test("a restored plugin screen waits for its catalog before rendering", async ({ page }) => {
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
|
||||
@@ -12,13 +12,12 @@ import { useTranslation } from "react-i18next";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet as RNStyleSheet,
|
||||
Text,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Gesture, ScrollView as GHScrollView } from "react-native-gesture-handler";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -42,7 +41,6 @@ import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-heade
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative as platformIsNative } from "@/constants/platform";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { useImportSession } from "@/hooks/use-import-session";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
@@ -557,14 +555,6 @@ function MobileSidebar({
|
||||
[insetsTop, insetsBottom, theme.colors.surfaceSidebar],
|
||||
);
|
||||
|
||||
const navRows = (
|
||||
<SidebarNavRows
|
||||
style={styles.mobileNavRowsGroup}
|
||||
onBeforeNavigate={closeSidebar}
|
||||
firstRowTrailing={closeButton}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<MobilePanelOverlay
|
||||
panel="agent-list"
|
||||
@@ -573,29 +563,11 @@ function MobileSidebar({
|
||||
>
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
<WindowChromeSafeArea placement="below" />
|
||||
{platformIsNative ? (
|
||||
<GHScrollView
|
||||
// Same contract as the workspace list: vertical drags scroll the nav
|
||||
// block, a clear horizontal intent still activates the close pan.
|
||||
simultaneousHandlers={closeGestureRef}
|
||||
style={styles.mobileNavScroll}
|
||||
showsVerticalScrollIndicator={false}
|
||||
alwaysBounceVertical={false}
|
||||
testID="sidebar-nav-scroll"
|
||||
>
|
||||
{navRows}
|
||||
</GHScrollView>
|
||||
) : (
|
||||
<ScrollView
|
||||
style={styles.mobileNavScroll}
|
||||
showsVerticalScrollIndicator={false}
|
||||
alwaysBounceVertical={false}
|
||||
testID="sidebar-nav-scroll"
|
||||
>
|
||||
{navRows}
|
||||
</ScrollView>
|
||||
)}
|
||||
<SidebarSeparator />
|
||||
<SidebarNavRows
|
||||
style={styles.sidebarHeaderGroup}
|
||||
onBeforeNavigate={closeSidebar}
|
||||
firstRowTrailing={closeButton}
|
||||
/>
|
||||
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
@@ -867,18 +839,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
sidebarHeaderGroupBelowChrome: {
|
||||
paddingTop: 0,
|
||||
},
|
||||
// The nav block caps at half the sidebar; a long item list scrolls inside the
|
||||
// cap instead of crowding the workspace list off a short screen.
|
||||
mobileNavScroll: {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
maxHeight: "50%",
|
||||
},
|
||||
mobileNavRowsGroup: {
|
||||
paddingTop: theme.spacing[2],
|
||||
gap: 2,
|
||||
paddingBottom: theme.spacing[1.5],
|
||||
},
|
||||
sidebarContent: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
|
||||
@@ -41,7 +41,6 @@ class Observation implements ManagedObservation, ObservationHost {
|
||||
readonly root: string;
|
||||
readonly metrics: ObserverMetrics;
|
||||
private ignoredRoots: string[];
|
||||
private matchesIgnoredPath: (path: string) => boolean;
|
||||
private status: "open" | "failed" | "closed" = "open";
|
||||
private readonly backend: ObservationBackend;
|
||||
private readonly pendingEvents = new Map<string, FileChange>();
|
||||
@@ -62,7 +61,6 @@ class Observation implements ManagedObservation, ObservationHost {
|
||||
this.root = input.root;
|
||||
this.metrics = input.metrics;
|
||||
this.ignoredRoots = input.paths.normalizeIgnoredRoots(input.root, input.options.ignore ?? []);
|
||||
this.matchesIgnoredPath = input.paths.createIgnoreMatcher(this.ignoredRoots);
|
||||
this.backend = input.createBackend(this);
|
||||
}
|
||||
|
||||
@@ -91,7 +89,6 @@ class Observation implements ManagedObservation, ObservationHost {
|
||||
const next = this.input.paths.normalizeIgnoredRoots(this.root, paths);
|
||||
if (samePaths(this.ignoredRoots, next)) return;
|
||||
this.ignoredRoots = next;
|
||||
this.matchesIgnoredPath = this.input.paths.createIgnoreMatcher(next);
|
||||
for (const path of this.pendingEvents.keys()) {
|
||||
if (this.isIgnored(path)) this.pendingEvents.delete(path);
|
||||
}
|
||||
@@ -123,7 +120,7 @@ class Observation implements ManagedObservation, ObservationHost {
|
||||
}
|
||||
|
||||
isIgnored(path: string): boolean {
|
||||
return this.matchesIgnoredPath(path);
|
||||
return this.ignoredRoots.some((root) => this.input.paths.isInside(root, path));
|
||||
}
|
||||
|
||||
isPathInside(root: string, path: string): boolean {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { posix, win32 } from "node:path";
|
||||
import { win32 } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { createObserverPaths } from "./paths.js";
|
||||
|
||||
@@ -40,29 +40,3 @@ describe("collapse", () => {
|
||||
expect(paths.collapse([parent, sibling, descendant]).sort()).toEqual([parent, sibling].sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe("ignored root matching", () => {
|
||||
test.each(["linux", "win32"] as const)(
|
||||
"matches ancestors and sibling boundaries on %s",
|
||||
(platform) => {
|
||||
const paths = createObserverPaths(platform);
|
||||
const root = platform === "win32" ? "C:\\repo" : "/repo";
|
||||
const api = platform === "win32" ? win32 : posix;
|
||||
const ignored = api.join(root, "output");
|
||||
const matches = paths.createIgnoreMatcher([ignored]);
|
||||
expect(matches(ignored)).toBe(true);
|
||||
expect(matches(api.join(ignored, "nested", "file.txt"))).toBe(true);
|
||||
expect(matches(api.join(root, "output-sibling", "file.txt"))).toBe(false);
|
||||
expect(matches(root)).toBe(false);
|
||||
expect(matches(api.join(root, "src", "file.txt"))).toBe(false);
|
||||
if (platform === "win32") {
|
||||
expect(matches(api.join(ignored, "nested", "file.txt").toUpperCase())).toBe(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("an empty ignore set accepts every path", () => {
|
||||
const matches = createObserverPaths("linux").createIgnoreMatcher([]);
|
||||
expect(matches("/repo/file.txt")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { posix, win32 } from "node:path";
|
||||
import { createPathContainmentMatcher } from "../../../utils/path.js";
|
||||
|
||||
export interface ObserverPaths {
|
||||
isInside(root: string, path: string): boolean;
|
||||
normalizeIgnoredRoots(root: string, paths: string[]): string[];
|
||||
collapse(paths: string[]): string[];
|
||||
createIgnoreMatcher(roots: string[]): (path: string) => boolean;
|
||||
isExpectedWatchDisappearance(error: unknown): boolean;
|
||||
}
|
||||
|
||||
@@ -52,9 +50,6 @@ export function createObserverPaths(platform: NodeJS.Platform): ObserverPaths {
|
||||
return {
|
||||
isInside,
|
||||
collapse,
|
||||
createIgnoreMatcher(roots) {
|
||||
return createPathContainmentMatcher({ roots, compareAsWindows: platform === "win32" });
|
||||
},
|
||||
normalizeIgnoredRoots(root, paths) {
|
||||
const inside = paths
|
||||
.map((path) => pathApi.resolve(path))
|
||||
|
||||
@@ -3,11 +3,7 @@ import { readlink } from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
import pLimit from "p-limit";
|
||||
import { dirname, resolve as resolvePath, sep } from "node:path";
|
||||
import {
|
||||
createPathContainmentMatcher,
|
||||
looksLikeDefiniteWindowsPath,
|
||||
normalizePathPreservingCase,
|
||||
} from "../utils/path.js";
|
||||
import { looksLikeDefiniteWindowsPath, normalizePathPreservingCase } from "../utils/path.js";
|
||||
|
||||
const nativeRealpath = promisify(realpath.native);
|
||||
const jsRealpath = promisify(realpath);
|
||||
@@ -81,15 +77,15 @@ export class GitObservationPaths {
|
||||
excluded.push(this.windows ? subtree.toLowerCase() : subtree);
|
||||
}
|
||||
}
|
||||
const isExcluded = createPathContainmentMatcher({
|
||||
roots: excluded,
|
||||
compareAsWindows: this.windows,
|
||||
});
|
||||
const prefixes = excluded.map((root) => `${root}/`);
|
||||
return (candidate) => {
|
||||
const normalized = this.normalizeEventPath(candidate);
|
||||
const comparable = this.windows ? normalized.toLowerCase() : normalized;
|
||||
if (this.aliases.includes(comparable)) return false;
|
||||
return !isExcluded(comparable);
|
||||
for (let i = 0; i < excluded.length; i++) {
|
||||
if (comparable === excluded[i] || comparable.startsWith(prefixes[i])) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ import {
|
||||
type WorkspaceGitRuntimeSnapshot,
|
||||
} from "./workspace-git-service.js";
|
||||
import type { CheckoutSnapshotFacts, CheckoutStatusGit } from "../utils/checkout-git.js";
|
||||
import pino from "pino";
|
||||
import type pino from "pino";
|
||||
import type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js";
|
||||
import { WorkspaceAutoName } from "./workspace-auto-name.js";
|
||||
import type { ForgeService } from "../services/forge-service.js";
|
||||
@@ -1266,19 +1266,25 @@ test("create_agent_request launches from an exact subdirectory in a created work
|
||||
test("create_agent_request does not title an existing workspace from the agent prompt", async () => {
|
||||
vi.useFakeTimers();
|
||||
const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-existing-title-"));
|
||||
const errors: string[] = [];
|
||||
const logger = pino({ level: "error" }, { write: (entry) => errors.push(entry) });
|
||||
const agentStorage = new AgentStorage(path.join(workdir, "agents"), asSessionLogger(logger));
|
||||
const agentManager = new AgentManager({
|
||||
clients: { codex: new CreateAgentTestClient() },
|
||||
registry: agentStorage,
|
||||
logger: asSessionLogger(logger),
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000552",
|
||||
});
|
||||
try {
|
||||
const cwd = path.join(workdir, "repo");
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
trace: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const agentStorage = new AgentStorage(path.join(workdir, "agents"), asSessionLogger(logger));
|
||||
const agentManager = new AgentManager({
|
||||
clients: { codex: new CreateAgentTestClient() },
|
||||
registry: agentStorage,
|
||||
logger: asSessionLogger(logger),
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000552",
|
||||
});
|
||||
const projectRegistry = new FileBackedProjectRegistry(
|
||||
path.join(workdir, "projects.json"),
|
||||
asSessionLogger(logger),
|
||||
@@ -1382,14 +1388,9 @@ test("create_agent_request does not title an existing workspace from the agent p
|
||||
updatedAt: "2026-05-07T00:00:00.000Z",
|
||||
});
|
||||
} finally {
|
||||
// Fake timers do not drain filesystem writes started by the initial prompt.
|
||||
await agentManager.flush();
|
||||
vi.useRealTimers();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
// Also catch writes that teardown would otherwise abandon after deleting their directory.
|
||||
await agentManager.flush();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("create_agent_request does not publish the pre-prompt snapshot after the prompt starts", async () => {
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { execFile as execFileCallback, execFileSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
|
||||
test("a large ignored-directory listing establishes a working-tree watcher before its deadline", async () => {
|
||||
const artifacts = path.resolve(".artifacts");
|
||||
await mkdir(artifacts, { recursive: true });
|
||||
const root = await mkdtemp(path.join(artifacts, "large-ignore-"));
|
||||
const repo = path.join(root, "repo");
|
||||
await mkdir(repo);
|
||||
try {
|
||||
execFileSync("git", ["init", "-q", repo]);
|
||||
await writeFile(path.join(repo, ".gitignore"), "probes/\n");
|
||||
// An ignored parent containing force-tracked files cannot be excluded as a whole.
|
||||
// Keep the observed directory count small enough for Linux's watcher limit.
|
||||
for (let parent = 0; parent < 400; parent++) {
|
||||
const directory = path.join(repo, "probes", `p${parent}`);
|
||||
await mkdir(directory, { recursive: true });
|
||||
await writeFile(path.join(directory, "notes.md"), "tracked\n");
|
||||
await Promise.all(
|
||||
Array.from({ length: 100 }, async (_, index) => {
|
||||
const ignored = path.join(directory, `out${index}`);
|
||||
await mkdir(ignored);
|
||||
await writeFile(path.join(ignored, "data.bin"), "ignored\n");
|
||||
}),
|
||||
);
|
||||
}
|
||||
execFileSync("git", ["add", ".gitignore"], { cwd: repo });
|
||||
execFileSync("git", ["add", "-f", "--", "**/notes.md"], { cwd: repo });
|
||||
const listing = execFileSync(
|
||||
"git",
|
||||
["ls-files", "-o", "-i", "--directory", "--exclude-standard"],
|
||||
{ cwd: repo, encoding: "utf8", maxBuffer: 8 * 1024 * 1024 },
|
||||
);
|
||||
expect(listing.trim().split("\n")).toHaveLength(40_000);
|
||||
|
||||
// Bound the failing baseline in a child: timed-out subscriptions continue their
|
||||
// startup scan until it settles, so awaiting disposal would hide the failure.
|
||||
const source = `
|
||||
import pino from "pino";
|
||||
import { WorkspaceGitServiceImpl } from "./src/server/workspace-git-service.ts";
|
||||
const service = new WorkspaceGitServiceImpl({
|
||||
logger: pino({ enabled: false }), paseoHome: process.argv[2],
|
||||
});
|
||||
let maxTickCpuMs = 0;
|
||||
let previous = process.cpuUsage();
|
||||
const timer = setInterval(() => {
|
||||
const elapsed = process.cpuUsage(previous);
|
||||
maxTickCpuMs = Math.max(maxTickCpuMs, (elapsed.user + elapsed.system) / 1000);
|
||||
previous = process.cpuUsage();
|
||||
}, 10);
|
||||
const watch = await service.requestWorkingTreeWatch(process.argv[1], () => {});
|
||||
const active = service.getMetrics().fileObserver.activeObservationCount;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
clearInterval(timer);
|
||||
console.log(JSON.stringify({ active, maxTickCpuMs }));
|
||||
if (active !== 1) process.exit(0);
|
||||
watch.unsubscribe();
|
||||
await service.dispose();
|
||||
`;
|
||||
const { stdout } = await execFile(
|
||||
process.execPath,
|
||||
["--import", "tsx", "--input-type=module", "--eval", source, repo, path.join(root, "home")],
|
||||
{ cwd: path.resolve(import.meta.dirname, "../.."), timeout: 30_000, maxBuffer: 1024 * 1024 },
|
||||
);
|
||||
const measurement = JSON.parse(stdout.trim());
|
||||
expect(measurement.active).toBe(1);
|
||||
// CPU time excludes time descheduled by other parallel test/build processes.
|
||||
// A linear ignored-root event filter alone burns over seven CPU seconds here.
|
||||
expect(measurement.maxTickCpuMs).toBeLessThan(2_000);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
@@ -42,11 +42,7 @@ import {
|
||||
type ForgeResolver,
|
||||
} from "../services/forge-resolver.js";
|
||||
import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js";
|
||||
import {
|
||||
createPathContainmentMatcher,
|
||||
isPathInsideRoot,
|
||||
looksLikeDefiniteWindowsPath,
|
||||
} from "../utils/path.js";
|
||||
import { isPathInsideRoot, looksLikeDefiniteWindowsPath } from "../utils/path.js";
|
||||
import {
|
||||
createRunGitCommand,
|
||||
runGitCommand,
|
||||
@@ -1943,7 +1939,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
if (target.knownDirectories.has(directory) || matchesWatchPath(directory)) {
|
||||
continue;
|
||||
}
|
||||
// `directory` and `gitDir` derive from the same
|
||||
// `directory` and `gitDir`/`ignoredDirectory` all derive from the same
|
||||
// `target.watchPath` used to build this subscription, and `directory`
|
||||
// itself is the same `dirname(event.path)` value already trusted as a
|
||||
// plain string key by `target.knownDirectories.has()` above — no realpath
|
||||
@@ -1954,7 +1950,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
if (isPathInsideRoot(gitDir, directory)) {
|
||||
continue;
|
||||
}
|
||||
if (!target.acceptsEvent(directory)) {
|
||||
let ignored = false;
|
||||
for (const ignoredDirectory of target.ignoredDirectories) {
|
||||
if (isPathInsideRoot(ignoredDirectory, directory)) {
|
||||
ignored = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ignored) {
|
||||
continue;
|
||||
}
|
||||
target.knownDirectories.add(directory);
|
||||
@@ -2044,13 +2047,47 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* String-prefix containment, not `isRealpathInsideRoot`: every entry on
|
||||
* both sides is a plain path built from `target.watchPath` by this same
|
||||
* class (`dirname(event.path)` for `knownDirectories`, `resolve(watchPath,
|
||||
* ...)` for `ignoredDirectories` in `loadIgnoredDirs`) — no realpath
|
||||
* divergence to reconcile, per the same reasoning as
|
||||
* `noteWorkingTreeDirectories`. Normalizing each ignored root once up front
|
||||
* instead of recomputing per pair turns a 30k-known x 40-ignored scan from
|
||||
* ~26s of synchronous realpath syscalls into a bounded number of cheap
|
||||
* string comparisons.
|
||||
*
|
||||
* Case-folding still needs to happen for a Windows-looking root, same as
|
||||
* `isPathInsideRoot`/`isRealpathInsideRoot` in `../utils/path.js` — a
|
||||
* case-insensitive filesystem can report a known directory whose case
|
||||
* diverges from the git-reported ignored root. Since both sides derive
|
||||
* from the single `target.watchPath` for this target, whether to fold is
|
||||
* one decision for the whole call (`looksLikeDefiniteWindowsPath`
|
||||
* reused from `../utils/path.js`, not reimplemented here), not a per-pair
|
||||
* one — so it costs nothing beyond the already-precomputed prefixes.
|
||||
*/
|
||||
private pruneKnownDirectories(target: WorkingTreeWatchTarget): void {
|
||||
const isIgnored = createPathContainmentMatcher({
|
||||
roots: target.ignoredDirectories,
|
||||
compareAsWindows: looksLikeDefiniteWindowsPath(target.watchPath),
|
||||
});
|
||||
if (target.ignoredDirectories.size === 0) {
|
||||
return;
|
||||
}
|
||||
const foldCase = looksLikeDefiniteWindowsPath(target.watchPath);
|
||||
const comparable = (path: string) =>
|
||||
foldCase ? path.replaceAll("\\", "/").toLowerCase() : path;
|
||||
const ignoredPrefixes: string[] = [];
|
||||
const foldedIgnoredExact = foldCase ? new Set<string>() : null;
|
||||
for (const ignoredDirectory of target.ignoredDirectories) {
|
||||
const ignored = comparable(ignoredDirectory);
|
||||
ignoredPrefixes.push(`${ignored}/`);
|
||||
foldedIgnoredExact?.add(ignored);
|
||||
}
|
||||
for (const directory of target.knownDirectories) {
|
||||
if (isIgnored(directory)) {
|
||||
const comparableDirectory = comparable(directory);
|
||||
const comparableDirectoryWithSep = `${comparableDirectory}/`;
|
||||
const isIgnored =
|
||||
(foldedIgnoredExact ?? target.ignoredDirectories).has(comparableDirectory) ||
|
||||
ignoredPrefixes.some((prefix) => comparableDirectoryWithSep.startsWith(prefix));
|
||||
if (isIgnored) {
|
||||
target.knownDirectories.delete(directory);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
areEquivalentPaths,
|
||||
createPathEquivalenceMatcher,
|
||||
createPathContainmentMatcher,
|
||||
getRealpathAwareRelativePath,
|
||||
isPathInsideRoot,
|
||||
} from "./path.js";
|
||||
@@ -98,40 +97,3 @@ describe("path equivalence", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("indexed path containment", () => {
|
||||
test("preserves POSIX case and component boundaries", () => {
|
||||
const matches = createPathContainmentMatcher({
|
||||
roots: ["/repo/Build/", "/repo/cache"],
|
||||
compareAsWindows: false,
|
||||
});
|
||||
expect(matches("/repo/Build/nested/file")).toBe(true);
|
||||
expect(matches("/repo/cache")).toBe(true);
|
||||
expect(matches("/repo/Build-sibling/file")).toBe(false);
|
||||
expect(matches("/repo/build/file")).toBe(false);
|
||||
expect(matches("/repo")).toBe(false);
|
||||
});
|
||||
|
||||
test("normalizes Windows case, separators and extended namespaces", () => {
|
||||
const matches = createPathContainmentMatcher({
|
||||
roots: ["C:/Repo/Build", String.raw`\\server\share\cache`],
|
||||
compareAsWindows: true,
|
||||
});
|
||||
expect(matches(String.raw`\\?\C:\REPO\build\file`)).toBe(true);
|
||||
expect(matches(String.raw`\\?\UNC\SERVER\SHARE\Cache\file`)).toBe(true);
|
||||
expect(matches("C:/Repo/Build-Sibling/file")).toBe(false);
|
||||
expect(matches(String.raw`\\server\share\cache-sibling\file`)).toBe(false);
|
||||
});
|
||||
|
||||
test("handles filesystem roots and empty sets", () => {
|
||||
expect(
|
||||
createPathContainmentMatcher({ roots: ["/"], compareAsWindows: false })("/repo/file"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
createPathContainmentMatcher({ roots: ["C:/"], compareAsWindows: true })("c:/repo/file"),
|
||||
).toBe(true);
|
||||
expect(createPathContainmentMatcher({ roots: [], compareAsWindows: false })("/repo/file")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,31 +72,6 @@ export function stopPathContainmentMetrics(): number {
|
||||
return checks;
|
||||
}
|
||||
|
||||
interface PathContainmentOptions {
|
||||
roots: Iterable<string>;
|
||||
compareAsWindows: boolean;
|
||||
}
|
||||
|
||||
/** Compile lexical subtree membership without scanning unrelated roots per candidate. */
|
||||
export function createPathContainmentMatcher({
|
||||
roots,
|
||||
compareAsWindows,
|
||||
}: PathContainmentOptions): (candidate: string) => boolean {
|
||||
const pathApi = compareAsWindows ? nodePath.win32 : nodePath.posix;
|
||||
const indexed = new Set(
|
||||
Array.from(roots, (root) => normalizePathForComparison(root, compareAsWindows)),
|
||||
);
|
||||
return (candidate) => {
|
||||
let current = normalizePathForComparison(candidate, compareAsWindows);
|
||||
for (;;) {
|
||||
if (indexed.has(current)) return true;
|
||||
const parent = pathApi.dirname(current);
|
||||
if (parent === current) return false;
|
||||
current = parent;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function isPathInsideRoot(root: string, candidate: string): boolean {
|
||||
if (pathContainmentChecks !== null) pathContainmentChecks += 1;
|
||||
return getRelativePathInsideRoot(root, candidate) !== null;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { z } from "zod";
|
||||
|
||||
interface Database {
|
||||
prepare(sql: string): { all(...args: string[]): unknown[] };
|
||||
close(): void;
|
||||
}
|
||||
interface Sqlite {
|
||||
DatabaseSync: new (path: string, options: { readOnly: boolean }) => Database;
|
||||
}
|
||||
interface DatabaseLogin {
|
||||
key: string | null;
|
||||
}
|
||||
export interface StorePaths {
|
||||
file: string;
|
||||
database: string;
|
||||
}
|
||||
|
||||
const fileSchema = z.object({
|
||||
"opencode-go": z.object({ type: z.literal("api"), key: z.string().min(1) }).optional(),
|
||||
});
|
||||
const rowSchema = z.object({ value: z.string() });
|
||||
const keySchema = z.object({ type: z.literal("key"), key: z.string().min(1) });
|
||||
|
||||
export function defaultStorePaths(env: NodeJS.ProcessEnv = process.env): StorePaths {
|
||||
const dataHome = env.XDG_DATA_HOME || join(env.HOME || homedir(), ".local", "share");
|
||||
const directory = join(dataHome, "opencode");
|
||||
const database = resolve(directory, env.OPENCODE_DB || "opencode.db");
|
||||
return { file: join(directory, "auth.json"), database };
|
||||
}
|
||||
|
||||
export async function readFileKey(path: string): Promise<string | null> {
|
||||
try {
|
||||
const auth = fileSchema.parse(JSON.parse(await readFile(path, "utf8")));
|
||||
return auth["opencode-go"]?.key ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readDatabaseLogin(path: string): DatabaseLogin | null {
|
||||
// Keep the legacy source loadable on Node versions without node:sqlite.
|
||||
const sqlite = z
|
||||
.object({
|
||||
DatabaseSync: z.custom<Sqlite["DatabaseSync"]>((value) => typeof value === "function"),
|
||||
})
|
||||
.safeParse(process.getBuiltinModule?.("node:sqlite"));
|
||||
if (!sqlite.success || !existsSync(path)) return null;
|
||||
const db = new sqlite.data.DatabaseSync(path, { readOnly: true });
|
||||
try {
|
||||
// V1 also has opencode.db, but only v2 has the credential table.
|
||||
const tables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'credential'")
|
||||
.all();
|
||||
if (!tables.length) return null;
|
||||
const [value] = db
|
||||
.prepare(
|
||||
// OpenCode also selects migrated rows with no active flag, ordered by creation time.
|
||||
"SELECT value FROM credential WHERE integration_id = ? ORDER BY active DESC, time_created DESC, id DESC LIMIT 1",
|
||||
)
|
||||
.all("opencode-go");
|
||||
const row = rowSchema.safeParse(value);
|
||||
if (!row.success) return { key: null };
|
||||
let credential: unknown;
|
||||
try {
|
||||
credential = JSON.parse(row.data.value);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) return { key: null };
|
||||
throw error;
|
||||
}
|
||||
const parsed = keySchema.safeParse(credential);
|
||||
return { key: parsed.success ? parsed.data.key : null };
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,25 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
import { discover, fetchUsage } from "./usage.js";
|
||||
import { defaultStorePaths } from "./stores.js";
|
||||
|
||||
import { inputSchema } from "../shared/input.js";
|
||||
|
||||
let fixtureDirectory: string;
|
||||
let fixturePath: string;
|
||||
function stores(path: string) {
|
||||
return { file: path, database: join(dirname(path), "opencode.db") };
|
||||
}
|
||||
async function database() {
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const db = new DatabaseSync(join(fixtureDirectory, "opencode.db"));
|
||||
db.exec(
|
||||
"CREATE TABLE credential (id TEXT PRIMARY KEY, integration_id TEXT, value TEXT, active INTEGER, time_updated INTEGER, time_created INTEGER DEFAULT 0)",
|
||||
);
|
||||
return db;
|
||||
}
|
||||
beforeEach(async () => {
|
||||
fixtureDirectory = await mkdtemp(join(tmpdir(), "go-default-"));
|
||||
fixturePath = join(fixtureDirectory, "auth.json");
|
||||
@@ -22,6 +34,9 @@ afterEach(async () => {
|
||||
|
||||
test("discovery input rejects an explicit API key", () => {
|
||||
expect(inputSchema.safeParse({ apiKey: "unused" }).success).toBe(false);
|
||||
expect(
|
||||
inputSchema.safeParse({ store: "database", path: "opencode.db", apiKey: "unused" }).success,
|
||||
).toBe(false);
|
||||
expect(() => inputSchema.parse({})).toThrow();
|
||||
});
|
||||
|
||||
@@ -33,14 +48,216 @@ const upstreamResponse = {
|
||||
},
|
||||
};
|
||||
|
||||
test("uses the active v2 Go key instead of a stale legacy key without writing either store", async () => {
|
||||
const databasePath = join(fixtureDirectory, "opencode.db");
|
||||
const db = await database();
|
||||
const insert = db.prepare(
|
||||
"INSERT INTO credential (id, integration_id, value, active, time_updated) VALUES (?, ?, ?, ?, ?)",
|
||||
);
|
||||
insert.run("inactive", "opencode-go", JSON.stringify({ type: "key", key: "inactive-key" }), 0, 3);
|
||||
insert.run("active", "opencode-go", JSON.stringify({ type: "key", key: "active-key" }), 1, 1);
|
||||
insert.run("zen", "opencode", JSON.stringify({ type: "key", key: "zen-key" }), 1, 4);
|
||||
db.close();
|
||||
const beforeDatabase = await readFile(databasePath);
|
||||
const beforeFile = await readFile(fixturePath);
|
||||
const accounts = await discover(stores(fixturePath));
|
||||
expect(accounts).toEqual([
|
||||
{
|
||||
key: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
input: { store: "database", path: databasePath },
|
||||
},
|
||||
]);
|
||||
const input = inputSchema.parse(accounts[0]!.input);
|
||||
const report = await fetchUsage(input, async (_url, init) => {
|
||||
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer active-key");
|
||||
return Response.json(upstreamResponse);
|
||||
});
|
||||
expect(report.status).toBe("available");
|
||||
expect(report.windows.map((window) => window.usedPct)).toEqual([21, 42, 100]);
|
||||
expect(await readFile(databasePath)).toEqual(beforeDatabase);
|
||||
expect(await readFile(fixturePath)).toEqual(beforeFile);
|
||||
});
|
||||
|
||||
test("discovers v2 Go usage without a legacy auth.json", async () => {
|
||||
const db = await database();
|
||||
db.prepare(
|
||||
"INSERT INTO credential (id, integration_id, value, active, time_updated) VALUES (?, ?, ?, ?, ?)",
|
||||
).run("go", "opencode-go", JSON.stringify({ type: "key", key: "database-key" }), 1, 1);
|
||||
db.close();
|
||||
await rm(fixturePath);
|
||||
const accounts = await discover(stores(fixturePath));
|
||||
expect(accounts).toEqual([
|
||||
{
|
||||
key: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
input: { store: "database", path: join(fixtureDirectory, "opencode.db") },
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(accounts)).not.toContain("database-key");
|
||||
});
|
||||
|
||||
test("refresh follows the active Go account and re-reads its rotated key", async () => {
|
||||
const db = await database();
|
||||
const insert = db.prepare(
|
||||
"INSERT INTO credential (id, integration_id, value, active, time_updated) VALUES (?, ?, ?, ?, ?)",
|
||||
);
|
||||
insert.run("first", "opencode-go", JSON.stringify({ type: "key", key: "first-key" }), 1, 1);
|
||||
insert.run("second", "opencode-go", JSON.stringify({ type: "key", key: "second-key" }), 0, 2);
|
||||
db.close();
|
||||
const paths = stores(fixturePath);
|
||||
const accounts = await discover(paths);
|
||||
const input = inputSchema.parse(accounts[0]!.input);
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const writer = new DatabaseSync(paths.database);
|
||||
writer.exec("UPDATE credential SET active = CASE id WHEN 'second' THEN 1 ELSE 0 END");
|
||||
writer
|
||||
.prepare("UPDATE credential SET value = ? WHERE id = 'second'")
|
||||
.run(JSON.stringify({ type: "key", key: "rotated-key" }));
|
||||
writer.close();
|
||||
expect(await discover(paths)).toEqual(accounts);
|
||||
const before = await readFile(paths.database);
|
||||
const report = await fetchUsage(input, async (_url, init) => {
|
||||
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer rotated-key");
|
||||
return Response.json(upstreamResponse);
|
||||
});
|
||||
expect(report.status).toBe("available");
|
||||
expect(JSON.stringify(report)).not.toContain("rotated-key");
|
||||
expect(await readFile(paths.database)).toEqual(before);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[null, false],
|
||||
[null, true],
|
||||
[0, false],
|
||||
[0, true],
|
||||
])("discovers a selected Go key with active=%s and legacy file=%s", async (active, legacy) => {
|
||||
const db = await database();
|
||||
db.prepare(
|
||||
"INSERT INTO credential (id, integration_id, value, active, time_updated) VALUES (?, ?, ?, ?, ?)",
|
||||
).run("migrated", "opencode-go", JSON.stringify({ type: "key", key: "migrated-key" }), active, 1);
|
||||
db.close();
|
||||
if (!legacy) await rm(fixturePath);
|
||||
const accounts = await discover(stores(fixturePath));
|
||||
expect(accounts).toEqual([
|
||||
{
|
||||
key: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
input: { store: "database", path: join(fixtureDirectory, "opencode.db") },
|
||||
},
|
||||
]);
|
||||
const report = await fetchUsage(inputSchema.parse(accounts[0]!.input), async (_url, init) => {
|
||||
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer migrated-key");
|
||||
return Response.json(upstreamResponse);
|
||||
});
|
||||
expect(report.status).toBe("available");
|
||||
});
|
||||
|
||||
test("selects the newest created Go key when no explicit active key exists", async () => {
|
||||
const db = await database();
|
||||
const insert = db.prepare(
|
||||
"INSERT INTO credential (id, integration_id, value, active, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
);
|
||||
insert.run("older", "opencode-go", JSON.stringify({ type: "key", key: "older-key" }), null, 1, 9);
|
||||
insert.run("newer-a", "opencode-go", JSON.stringify({ type: "key", key: "tie-key" }), null, 2, 8);
|
||||
insert.run(
|
||||
"newer-b",
|
||||
"opencode-go",
|
||||
JSON.stringify({ type: "key", key: "selected-key" }),
|
||||
null,
|
||||
2,
|
||||
1,
|
||||
);
|
||||
db.close();
|
||||
const accounts = await discover(stores(fixturePath));
|
||||
expect(accounts).toHaveLength(1);
|
||||
await fetchUsage(inputSchema.parse(accounts[0]!.input), async (_url, init) => {
|
||||
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer selected-key");
|
||||
return Response.json(upstreamResponse);
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
["other provider", "opencode", 1, JSON.stringify({ type: "key", key: "zen-key" })],
|
||||
["Console OAuth", "opencode-go", 1, JSON.stringify({ type: "oauth", access: "oauth-token" })],
|
||||
["malformed credential", "opencode-go", 1, "invalid JSON"],
|
||||
["empty key", "opencode-go", 1, JSON.stringify({ type: "key", key: "" })],
|
||||
])(
|
||||
"does not revive a legacy key for a v2 store with %s",
|
||||
async (_name, provider, active, value) => {
|
||||
const db = await database();
|
||||
db.prepare(
|
||||
"INSERT INTO credential (id, integration_id, value, active, time_updated) VALUES (?, ?, ?, ?, ?)",
|
||||
).run("credential", provider, value, active, 1);
|
||||
db.close();
|
||||
expect(await discover(stores(fixturePath))).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
test("keeps legacy discovery when the v1 session database has no credential table", async () => {
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const db = new DatabaseSync(join(fixtureDirectory, "opencode.db"));
|
||||
db.exec("CREATE TABLE session (id TEXT PRIMARY KEY)");
|
||||
db.close();
|
||||
expect(await discover(stores(fixturePath))).toEqual([
|
||||
{
|
||||
key: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
input: { store: "file", path: fixturePath },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("fails before fetching when the selected database disappears", async () => {
|
||||
await expect(
|
||||
fetchUsage({ store: "database", path: join(fixtureDirectory, "missing.db") }, async () => {
|
||||
throw new Error("must not fetch");
|
||||
}),
|
||||
).rejects.toThrow("login store no longer exists");
|
||||
});
|
||||
|
||||
test("resolves default and overridden OpenCode store paths", () => {
|
||||
expect(defaultStorePaths({ HOME: fixtureDirectory })).toEqual({
|
||||
file: join(fixtureDirectory, ".local", "share", "opencode", "auth.json"),
|
||||
database: join(fixtureDirectory, ".local", "share", "opencode", "opencode.db"),
|
||||
});
|
||||
const customDatabase = join(fixtureDirectory, "custom.db");
|
||||
expect(
|
||||
defaultStorePaths({ XDG_DATA_HOME: fixtureDirectory, OPENCODE_DB: customDatabase }),
|
||||
).toEqual({
|
||||
file: join(fixtureDirectory, "opencode", "auth.json"),
|
||||
database: customDatabase,
|
||||
});
|
||||
});
|
||||
|
||||
test("discovers a relative OPENCODE_DB under the OpenCode data directory", async () => {
|
||||
const paths = defaultStorePaths({
|
||||
XDG_DATA_HOME: fixtureDirectory,
|
||||
OPENCODE_DB: "go.db",
|
||||
});
|
||||
const databasePath = join(fixtureDirectory, "opencode", "go.db");
|
||||
await mkdir(dirname(databasePath));
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const db = new DatabaseSync(databasePath);
|
||||
db.exec(
|
||||
"CREATE TABLE credential (id TEXT PRIMARY KEY, integration_id TEXT, value TEXT, active INTEGER, time_updated INTEGER, time_created INTEGER DEFAULT 0)",
|
||||
);
|
||||
db.prepare(
|
||||
"INSERT INTO credential (id, integration_id, value, active, time_updated) VALUES (?, ?, ?, ?, ?)",
|
||||
).run("go", "opencode-go", JSON.stringify({ type: "key", key: "relative-key" }), 1, 1);
|
||||
db.close();
|
||||
expect(await discover(paths)).toEqual([
|
||||
{
|
||||
key: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
input: { store: "database", path: databasePath },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("discovers and fetches the default key from read-only auth.json", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "paseo-go-usage-"));
|
||||
const path = join(directory, "auth.json");
|
||||
const content = JSON.stringify({ "opencode-go": { type: "api", key: "fixture-key" } });
|
||||
try {
|
||||
await writeFile(path, content);
|
||||
expect(await discover(path)).toEqual([
|
||||
{ key: expect.stringMatching(/^[a-f0-9]{64}$/), input: { path } },
|
||||
expect(await discover(stores(path))).toEqual([
|
||||
{ key: expect.stringMatching(/^[a-f0-9]{64}$/), input: { store: "file", path } },
|
||||
]);
|
||||
const requests: Array<{ url: string; authorization: string | null }> = [];
|
||||
const fetchApi = async (url: string | URL | Request, init?: RequestInit) => {
|
||||
@@ -50,7 +267,7 @@ test("discovers and fetches the default key from read-only auth.json", async ()
|
||||
});
|
||||
return Response.json(upstreamResponse);
|
||||
};
|
||||
const report = await fetchUsage({ path }, fetchApi as typeof fetch);
|
||||
const report = await fetchUsage({ store: "file", path }, fetchApi);
|
||||
expect(requests).toEqual([
|
||||
{ url: "https://opencode.ai/zen/go/v1/usage", authorization: "Bearer fixture-key" },
|
||||
]);
|
||||
@@ -75,9 +292,9 @@ test("omits default discovery when auth.json lacks an OpenCode Go API key", asyn
|
||||
const path = join(directory, "auth.json");
|
||||
try {
|
||||
await writeFile(path, JSON.stringify({ openai: { type: "oauth", access: "other-token" } }));
|
||||
expect(await discover(path)).toEqual([]);
|
||||
expect(await discover(stores(path))).toEqual([]);
|
||||
await expect(
|
||||
fetchUsage({ path }, async () => {
|
||||
fetchUsage({ store: "file", path }, async () => {
|
||||
throw new Error("must not fetch");
|
||||
}),
|
||||
).rejects.toThrow("login store no longer exists");
|
||||
@@ -88,7 +305,7 @@ test("omits default discovery when auth.json lacks an OpenCode Go API key", asyn
|
||||
|
||||
test.each([401, 403])("maps HTTP %i to unavailable", async (status) => {
|
||||
const report = await fetchUsage(
|
||||
{ path: fixturePath },
|
||||
{ store: "file", path: fixturePath },
|
||||
async () => new Response(null, { status }),
|
||||
);
|
||||
expect(report).toEqual({
|
||||
@@ -100,5 +317,5 @@ test.each([401, 403])("maps HTTP %i to unavailable", async (status) => {
|
||||
test.each(["missing file", "unrelated file"])("discovers no accounts for %s", async (scenario) => {
|
||||
const path = join(fixtureDirectory, "missing.json");
|
||||
if (scenario === "unrelated file") await writeFile(path, "{}");
|
||||
expect(await discover(path)).toEqual([]);
|
||||
expect(await discover(stores(path))).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
hashAccountKey,
|
||||
@@ -11,15 +8,7 @@ import {
|
||||
type UsageReport,
|
||||
} from "@getpaseo/plugin/server/usage";
|
||||
import type { Input } from "../shared/input.js";
|
||||
|
||||
const authSchema = z
|
||||
.object({
|
||||
"opencode-go": z
|
||||
.object({ type: z.literal("api"), key: z.string().min(1) })
|
||||
.passthrough()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
import { defaultStorePaths, readDatabaseLogin, readFileKey } from "./stores.js";
|
||||
const windowSchema = z.object({
|
||||
status: z.enum(["ok", "rate-limited"]),
|
||||
percent: z.number().finite(),
|
||||
@@ -29,32 +18,30 @@ const responseSchema = z.object({
|
||||
usage: z.object({ rolling: windowSchema, weekly: windowSchema, monthly: windowSchema }),
|
||||
});
|
||||
|
||||
function authPath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return join(
|
||||
env.XDG_DATA_HOME || join(env.HOME || homedir(), ".local", "share"),
|
||||
"opencode",
|
||||
"auth.json",
|
||||
);
|
||||
}
|
||||
|
||||
export async function readDefaultKey(path = authPath()): Promise<string | null> {
|
||||
try {
|
||||
const auth = authSchema.parse(JSON.parse(await readFile(path, "utf8")));
|
||||
return auth["opencode-go"]?.key ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
export async function discover(paths = defaultStorePaths()): Promise<UsageAccount[]> {
|
||||
const login = readDatabaseLogin(paths.database);
|
||||
if (login) {
|
||||
return login.key
|
||||
? [
|
||||
{
|
||||
key: hashAccountKey(paths.database),
|
||||
input: { store: "database", path: paths.database },
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function discover(path = authPath()): Promise<UsageAccount[]> {
|
||||
return (await readDefaultKey(path)) ? [{ key: hashAccountKey(path), input: { path } }] : [];
|
||||
const key = await readFileKey(paths.file);
|
||||
return key
|
||||
? [{ key: hashAccountKey(paths.file), input: { store: "file", path: paths.file } }]
|
||||
: [];
|
||||
}
|
||||
|
||||
export async function fetchUsage(
|
||||
input: Input,
|
||||
fetchApi: typeof fetch = fetch,
|
||||
): Promise<UsageReport> {
|
||||
const apiKey = await readDefaultKey(input.path);
|
||||
const apiKey =
|
||||
input.store === "database" ? readDatabaseLogin(input.path)?.key : await readFileKey(input.path);
|
||||
if (!apiKey) throw new Error("OpenCode Go login store no longer exists");
|
||||
const response = await fetchApi("https://opencode.ai/zen/go/v1/usage", {
|
||||
headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { z } from "zod";
|
||||
export const inputSchema = z.object({ path: z.string().min(1) }).strict();
|
||||
export const inputSchema = z
|
||||
.object({ store: z.enum(["file", "database"]), path: z.string().min(1) })
|
||||
.strict();
|
||||
export type Input = z.infer<typeof inputSchema>;
|
||||
|
||||
Reference in New Issue
Block a user