mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
15 Commits
fix-window
...
v0.1.70-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f92a296ce0 | ||
|
|
6786024333 | ||
|
|
56855dd6fd | ||
|
|
00a5b27586 | ||
|
|
73ed98c623 | ||
|
|
927309e867 | ||
|
|
2d2ee02ce3 | ||
|
|
4d4fbf7257 | ||
|
|
67c93dba49 | ||
|
|
2d9c7747fb | ||
|
|
4fa1db8d06 | ||
|
|
92be6c0cba | ||
|
|
4f0b264886 | ||
|
|
bad304c2d3 | ||
|
|
0785ee31f0 |
51
CHANGELOG.md
51
CHANGELOG.md
@@ -1,5 +1,56 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.70-beta.1 - 2026-05-07
|
||||
|
||||
### Added
|
||||
|
||||
- **One-click ACP providers** — add Cursor, Hermes, Qwen Coder, Kimi Code, and other ACP agents from a built-in catalog instead of writing config by hand.
|
||||
- Codex `/goal` slash command — set or update the goal mid-turn while a Codex agent is running.
|
||||
- Detect GitHub issue and PR URLs pasted into the composer search.
|
||||
- `paseo worktree create` CLI command, with parity to the MCP `create_worktree` tool.
|
||||
- `paseo schedule update` to edit a schedule in place without recreating it.
|
||||
- `paseo schedule run-once` for cron-style triggers, plus `--mode` on `schedule` and `loop`. Background runs now default to unattended mode.
|
||||
- Projects settings now lists workspaces from any remote — GitLab, Gitea, Bitbucket, self-hosted, and SSH-style URLs, not just GitHub. ([#681](https://github.com/getpaseo/paseo/pull/681) by [@krumpyzoid](https://github.com/krumpyzoid))
|
||||
|
||||
### Improved
|
||||
|
||||
- Skills now install, update, and uninstall on demand instead of silently auto-syncing on every desktop launch.
|
||||
- Self-hosted relays can opt into `wss://` for TLS connections.
|
||||
- Workspace open targets only show options reachable from the current daemon.
|
||||
- Combobox search matches model descriptions, not just names.
|
||||
- Codex image attachments render inline as path markdown.
|
||||
- Subagent task notifications no longer clutter the parent agent's timeline.
|
||||
- Voice mode: quieter thinking tone and small UI polish.
|
||||
- Settings sidebar order: Projects now appears after General.
|
||||
- Electron upgraded to 41.2.0 for the desktop app.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Linux:** Workspace file watchers no longer storm with events on busy working trees, fixing CPU spikes on large repos. ([#794](https://github.com/getpaseo/paseo/pull/794) by [@312223105](https://github.com/312223105))
|
||||
- ACP-based agents launch terminal shell commands reliably. ([#793](https://github.com/getpaseo/paseo/pull/793) by [@ebg1223](https://github.com/ebg1223))
|
||||
- Checkout shortstat now counts untracked files. ([#608](https://github.com/getpaseo/paseo/issues/608), [#762](https://github.com/getpaseo/paseo/pull/762) by [@somus](https://github.com/somus))
|
||||
- Relay endpoints on port 443 use TLS automatically. ([#774](https://github.com/getpaseo/paseo/pull/774) by [@caoer](https://github.com/caoer))
|
||||
- Desktop CLI passthrough TTY handling — interactive commands now behave correctly when launched from the desktop app.
|
||||
- The CLI honors the `PASEO_PASSWORD` environment variable for password-protected daemons.
|
||||
- Daemon shutdown terminates all child processes cleanly using tree-kill.
|
||||
- Agent spawn paths handle missing executables and unusual install layouts more reliably.
|
||||
- OpenCode now forwards provider retry errors instead of silently swallowing them.
|
||||
- Codex import no longer reverts to the wrong default mode.
|
||||
- Pane keyboard shortcuts no longer fire while you're typing in an editable field.
|
||||
- Cold workspace URL navigation now lands in the correct sidebar entry on web.
|
||||
- Workspace navigation regression on web fixed.
|
||||
- Duplicate workspace shell navigation eliminated.
|
||||
- The 'Update installed' callout no longer flashes incorrectly.
|
||||
- Browser pane reload focus and devtools handling.
|
||||
- MCP terminal capture now includes scrollback.
|
||||
- Worktree branches no longer get renamed when an agent is created against an existing worktree from MCP.
|
||||
- Creating an agent in a subdirectory of a registered workspace now runs in that subdirectory instead of jumping up to the parent. ([#551](https://github.com/getpaseo/paseo/issues/551))
|
||||
- Non-GitHub project display names are derived from the remote owner/repo instead of the local path.
|
||||
- Desktop IPC wrapped in shared mutation/query hooks, fixing stale state and intermittent failures. ([#761](https://github.com/getpaseo/paseo/issues/761))
|
||||
- `paseo schedule create --host` now requires `--cwd` to avoid running schedules in the wrong directory.
|
||||
- `paseo schedule create --every` runs once immediately by default, then on the configured interval.
|
||||
- MCP `create_agent` validates the requested mode and refuses silent cross-provider inheritance.
|
||||
|
||||
## 0.1.69 - 2026-05-05
|
||||
|
||||
### Fixed
|
||||
|
||||
26
package-lock.json
generated
26
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.70-beta.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.70-beta.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -38547,7 +38547,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.70-beta.1",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -38673,10 +38673,10 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.70-beta.1",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/server": "0.1.69",
|
||||
"@getpaseo/server": "0.1.70-beta.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -38719,7 +38719,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.70-beta.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -38768,7 +38768,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.70-beta.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.25",
|
||||
@@ -38804,7 +38804,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.70-beta.1",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -38830,7 +38830,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.70-beta.1",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38845,12 +38845,12 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.70-beta.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@getpaseo/highlight": "0.1.69",
|
||||
"@getpaseo/relay": "0.1.69",
|
||||
"@getpaseo/highlight": "0.1.70-beta.1",
|
||||
"@getpaseo/relay": "0.1.70-beta.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@mariozechner/pi-agent-core": "^0.70.2",
|
||||
"@mariozechner/pi-ai": "^0.70.2",
|
||||
@@ -39304,7 +39304,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.70-beta.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.70-beta.1",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.70-beta.1",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -67,6 +67,22 @@ describe("combined model selector helpers", () => {
|
||||
expect(matchesSearch(rows[1], "gpt-5.4")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches across label, provider, and description with multi-token fuzzy search", () => {
|
||||
const row = {
|
||||
favoriteKey: "opencode:opencode-zen/kimi-k2.5",
|
||||
provider: "opencode",
|
||||
providerLabel: "OpenCode",
|
||||
modelId: "opencode-zen/kimi-k2.5",
|
||||
modelLabel: "Kimi K2.5",
|
||||
description: "OpenCode Zen - kimi",
|
||||
};
|
||||
|
||||
expect(matchesSearch(row, "kimi zen")).toBe(true);
|
||||
expect(matchesSearch(row, "zen kimi")).toBe(true);
|
||||
expect(matchesSearch(row, "k2.5 zen")).toBe(true);
|
||||
expect(matchesSearch(row, "kimi gemini")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the selected trigger label model-only", () => {
|
||||
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
|
||||
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
|
||||
|
||||
@@ -48,7 +48,10 @@ export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): b
|
||||
return true;
|
||||
}
|
||||
|
||||
return [row.modelLabel, row.modelId, row.providerLabel].some((value) =>
|
||||
value.toLowerCase().includes(normalizedQuery),
|
||||
);
|
||||
const haystack = [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
const tokens = normalizedQuery.split(/\s+/).filter((token) => token.length > 0);
|
||||
return tokens.every((token) => haystack.includes(token));
|
||||
}
|
||||
|
||||
@@ -7,12 +7,34 @@ import { settingsStyles } from "@/styles/settings";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { useCliInstall, useSkillsInstall } from "@/desktop/hooks/use-install-status";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import {
|
||||
shouldUseDesktopDaemon,
|
||||
type SkillOp,
|
||||
type SkillsStatus,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status";
|
||||
|
||||
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
|
||||
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
|
||||
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
|
||||
const UNINSTALL_MESSAGE =
|
||||
"Removes all Paseo orchestration skills from ~/.agents, ~/.claude, ~/.codex.";
|
||||
|
||||
const OP_KIND_ORDER: Record<SkillOp["kind"], number> = { add: 0, update: 1, delete: 2 };
|
||||
const OP_KIND_LABEL: Record<SkillOp["kind"], string> = {
|
||||
add: "Add skill",
|
||||
update: "Update skill",
|
||||
delete: "Delete skill",
|
||||
};
|
||||
|
||||
function formatUpdateMessage(ops: readonly SkillOp[]): string {
|
||||
const sorted = [...ops].sort((a, b) => {
|
||||
const kindOrder = OP_KIND_ORDER[a.kind] - OP_KIND_ORDER[b.kind];
|
||||
return kindOrder !== 0 ? kindOrder : a.name.localeCompare(b.name);
|
||||
});
|
||||
return sorted.map((op) => `${OP_KIND_LABEL[op.kind]} ${op.name}`).join("\n");
|
||||
}
|
||||
|
||||
export function IntegrationsSection() {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -25,16 +47,18 @@ export function IntegrationsSection() {
|
||||
} = useCliInstall();
|
||||
const {
|
||||
status: skillsStatus,
|
||||
isInstalling: isInstallingSkills,
|
||||
isWorking: isSkillsWorking,
|
||||
install: installSkills,
|
||||
update: updateSkills,
|
||||
uninstall: uninstallSkills,
|
||||
refresh: refreshSkillsStatus,
|
||||
} = useSkillsInstall();
|
||||
} = useSkillsStatus();
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!showSection) return undefined;
|
||||
refreshCliStatus();
|
||||
refreshSkillsStatus();
|
||||
void refreshSkillsStatus();
|
||||
return undefined;
|
||||
}, [refreshCliStatus, refreshSkillsStatus, showSection]),
|
||||
);
|
||||
@@ -45,9 +69,33 @@ export function IntegrationsSection() {
|
||||
}, [installCli, isInstallingCli]);
|
||||
|
||||
const handleInstallSkills = useCallback(() => {
|
||||
if (isInstallingSkills) return;
|
||||
installSkills();
|
||||
}, [installSkills, isInstallingSkills]);
|
||||
if (isSkillsWorking) return;
|
||||
void installSkills();
|
||||
}, [installSkills, isSkillsWorking]);
|
||||
|
||||
const handleUpdateSkills = useCallback(async () => {
|
||||
if (isSkillsWorking) return;
|
||||
const ops = skillsStatus?.ops ?? [];
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Update Paseo skills?",
|
||||
message: ops.length > 0 ? formatUpdateMessage(ops) : "Sync bundled skills to your machine.",
|
||||
confirmLabel: "Update",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
await updateSkills();
|
||||
}, [isSkillsWorking, skillsStatus, updateSkills]);
|
||||
|
||||
const handleUninstallSkills = useCallback(async () => {
|
||||
if (isSkillsWorking) return;
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Uninstall Paseo skills?",
|
||||
message: UNINSTALL_MESSAGE,
|
||||
confirmLabel: "Uninstall",
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
await uninstallSkills();
|
||||
}, [isSkillsWorking, uninstallSkills]);
|
||||
|
||||
const handleOpenCliDocs = useCallback(() => {
|
||||
void openExternalUrl(CLI_DOCS_URL);
|
||||
@@ -96,6 +144,8 @@ export function IntegrationsSection() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const skillsState = skillsStatus?.state ?? null;
|
||||
|
||||
return (
|
||||
<SettingsSection title="Integrations" trailing={trailing}>
|
||||
<View style={settingsStyles.card}>
|
||||
@@ -130,30 +180,69 @@ export function IntegrationsSection() {
|
||||
<Text style={settingsStyles.rowTitle}>Orchestration skills</Text>
|
||||
</View>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Teach your agents to orchestrate through the CLI
|
||||
{skillsState === "drift"
|
||||
? "Update available"
|
||||
: "Teach your agents to orchestrate through the CLI"}
|
||||
</Text>
|
||||
</View>
|
||||
{skillsStatus?.installed ? (
|
||||
<View style={styles.installedLabel}>
|
||||
<Check size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.mutedText}>Installed</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={handleInstallSkills}
|
||||
disabled={isInstallingSkills}
|
||||
>
|
||||
{isInstallingSkills ? "Installing..." : "Install"}
|
||||
</Button>
|
||||
)}
|
||||
<SkillsActions
|
||||
state={skillsState}
|
||||
isWorking={isSkillsWorking}
|
||||
onInstall={handleInstallSkills}
|
||||
onUpdate={handleUpdateSkills}
|
||||
onUninstall={handleUninstallSkills}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkillsActionsProps {
|
||||
state: SkillsStatus["state"] | null;
|
||||
isWorking: boolean;
|
||||
onInstall: () => void;
|
||||
onUpdate: () => void;
|
||||
onUninstall: () => void;
|
||||
}
|
||||
|
||||
function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: SkillsActionsProps) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
if (state === "up-to-date") {
|
||||
return (
|
||||
<View style={styles.actionsRow}>
|
||||
<View style={styles.installedLabel}>
|
||||
<Check size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.mutedText}>Installed</Text>
|
||||
</View>
|
||||
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
|
||||
Uninstall
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "drift") {
|
||||
return (
|
||||
<View style={styles.actionsRow}>
|
||||
<Button variant="outline" size="sm" onPress={onUpdate} disabled={isWorking}>
|
||||
{isWorking ? "Working..." : "Update"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
|
||||
Uninstall
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="outline" size="sm" onPress={onInstall} disabled={isWorking}>
|
||||
{isWorking ? "Installing..." : "Install"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
headerLinks: {
|
||||
flexDirection: "row",
|
||||
@@ -174,4 +263,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
actionsRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -220,10 +220,67 @@ export async function installCli(): Promise<InstallStatus> {
|
||||
return parseInstallStatus(await invokeDesktopCommand("install_cli"));
|
||||
}
|
||||
|
||||
export async function getSkillsInstallStatus(): Promise<InstallStatus> {
|
||||
return parseInstallStatus(await invokeDesktopCommand("get_skills_install_status"));
|
||||
export type SkillsState = "not-installed" | "up-to-date" | "drift";
|
||||
|
||||
export type SkillOp =
|
||||
| { kind: "add"; name: string }
|
||||
| { kind: "update"; name: string }
|
||||
| { kind: "delete"; name: string };
|
||||
|
||||
export interface SkillsStatus {
|
||||
state: SkillsState;
|
||||
ops: SkillOp[];
|
||||
}
|
||||
|
||||
export async function installSkills(): Promise<InstallStatus> {
|
||||
return parseInstallStatus(await invokeDesktopCommand("install_skills"));
|
||||
function parseSkillsState(value: unknown): SkillsState {
|
||||
switch (value) {
|
||||
case "not-installed":
|
||||
case "up-to-date":
|
||||
case "drift":
|
||||
return value;
|
||||
default:
|
||||
throw new Error(`Unexpected skills status state: ${String(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseSkillOp(raw: unknown): SkillOp {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("Unexpected skill op response.");
|
||||
}
|
||||
const name = toStringOrNull(raw.name);
|
||||
if (!name) throw new Error("Skill op missing name.");
|
||||
switch (raw.kind) {
|
||||
case "add":
|
||||
return { kind: "add", name };
|
||||
case "update":
|
||||
return { kind: "update", name };
|
||||
case "delete":
|
||||
return { kind: "delete", name };
|
||||
default:
|
||||
throw new Error(`Unexpected skill op kind: ${String(raw.kind)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseSkillsStatus(raw: unknown): SkillsStatus {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("Unexpected skills status response.");
|
||||
}
|
||||
const ops = Array.isArray(raw.ops) ? raw.ops.map(parseSkillOp) : [];
|
||||
return { state: parseSkillsState(raw.state), ops };
|
||||
}
|
||||
|
||||
export async function getSkillsStatus(): Promise<SkillsStatus> {
|
||||
return parseSkillsStatus(await invokeDesktopCommand("get_skills_status"));
|
||||
}
|
||||
|
||||
export async function installSkills(): Promise<SkillsStatus> {
|
||||
return parseSkillsStatus(await invokeDesktopCommand("install_skills"));
|
||||
}
|
||||
|
||||
export async function updateSkills(): Promise<SkillsStatus> {
|
||||
return parseSkillsStatus(await invokeDesktopCommand("update_skills"));
|
||||
}
|
||||
|
||||
export async function uninstallSkills(): Promise<SkillsStatus> {
|
||||
return parseSkillsStatus(await invokeDesktopCommand("uninstall_skills"));
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import React from "react";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useCliInstall, useSkillsInstall } from "./use-install-status";
|
||||
import { useCliInstall, useSkillsStatus } from "./use-install-status";
|
||||
|
||||
const toast = vi.hoisted(() => ({
|
||||
error: vi.fn(),
|
||||
@@ -15,9 +15,11 @@ const toast = vi.hoisted(() => ({
|
||||
|
||||
const desktopDaemon = vi.hoisted(() => ({
|
||||
getCliInstallStatus: vi.fn(),
|
||||
getSkillsInstallStatus: vi.fn(),
|
||||
installCli: vi.fn(),
|
||||
getSkillsStatus: vi.fn(),
|
||||
installSkills: vi.fn(),
|
||||
updateSkills: vi.fn(),
|
||||
uninstallSkills: vi.fn(),
|
||||
shouldUseDesktopDaemon: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
@@ -89,11 +91,9 @@ describe("useCliInstall", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSkillsInstall", () => {
|
||||
describe("useSkillsStatus", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
desktopDaemon.getSkillsInstallStatus.mockResolvedValue({ installed: true });
|
||||
desktopDaemon.installSkills.mockResolvedValue({ installed: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -101,34 +101,155 @@ describe("useSkillsInstall", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("loads skills install status", async () => {
|
||||
const { result } = renderDesktopHook(() => useSkillsInstall());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toEqual({ installed: true });
|
||||
it("loads the current skills status", async () => {
|
||||
desktopDaemon.getSkillsStatus.mockResolvedValue({
|
||||
state: "up-to-date",
|
||||
ops: [],
|
||||
});
|
||||
|
||||
const { result } = renderDesktopHook(() => useSkillsStatus());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toEqual({ state: "up-to-date", ops: [] });
|
||||
});
|
||||
expect(result.current.isWorking).toBe(false);
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("toasts and exposes skills install errors", async () => {
|
||||
const error = new Error("Missing IPC handler");
|
||||
desktopDaemon.getSkillsInstallStatus.mockResolvedValue({ installed: false });
|
||||
desktopDaemon.installSkills.mockRejectedValue(error);
|
||||
const { result } = renderDesktopHook(() => useSkillsInstall());
|
||||
it("install transitions a not-installed status to up-to-date and reflects the response directly", async () => {
|
||||
desktopDaemon.getSkillsStatus.mockResolvedValue({
|
||||
state: "not-installed",
|
||||
ops: [{ kind: "add", name: "paseo" }],
|
||||
});
|
||||
desktopDaemon.installSkills.mockResolvedValue({ state: "up-to-date", ops: [] });
|
||||
|
||||
const { result } = renderDesktopHook(() => useSkillsStatus());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toEqual({ installed: false });
|
||||
expect(result.current.status?.state).toBe("not-installed");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.install();
|
||||
});
|
||||
|
||||
expect(desktopDaemon.installSkills).toHaveBeenCalledOnce();
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toEqual({ state: "up-to-date", ops: [] });
|
||||
});
|
||||
});
|
||||
|
||||
it("update transitions drift to up-to-date", async () => {
|
||||
desktopDaemon.getSkillsStatus.mockResolvedValue({
|
||||
state: "drift",
|
||||
ops: [{ kind: "update", name: "paseo" }],
|
||||
});
|
||||
desktopDaemon.updateSkills.mockResolvedValue({ state: "up-to-date", ops: [] });
|
||||
|
||||
const { result } = renderDesktopHook(() => useSkillsStatus());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status?.state).toBe("drift");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.update();
|
||||
});
|
||||
|
||||
expect(desktopDaemon.updateSkills).toHaveBeenCalledOnce();
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toEqual({ state: "up-to-date", ops: [] });
|
||||
});
|
||||
});
|
||||
|
||||
it("uninstall transitions up-to-date back to not-installed", async () => {
|
||||
desktopDaemon.getSkillsStatus.mockResolvedValue({ state: "up-to-date", ops: [] });
|
||||
desktopDaemon.uninstallSkills.mockResolvedValue({
|
||||
state: "not-installed",
|
||||
ops: [{ kind: "add", name: "paseo" }],
|
||||
});
|
||||
|
||||
const { result } = renderDesktopHook(() => useSkillsStatus());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status?.state).toBe("up-to-date");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.uninstall();
|
||||
});
|
||||
|
||||
expect(desktopDaemon.uninstallSkills).toHaveBeenCalledOnce();
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toEqual({
|
||||
state: "not-installed",
|
||||
ops: [{ kind: "add", name: "paseo" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("isWorking flips while a mutation is in flight", async () => {
|
||||
desktopDaemon.getSkillsStatus.mockResolvedValue({
|
||||
state: "not-installed",
|
||||
ops: [{ kind: "add", name: "paseo" }],
|
||||
});
|
||||
|
||||
let resolveInstall: ((value: unknown) => void) | null = null;
|
||||
desktopDaemon.installSkills.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveInstall = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderDesktopHook(() => useSkillsStatus());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status?.state).toBe("not-installed");
|
||||
});
|
||||
expect(result.current.isWorking).toBe(false);
|
||||
|
||||
let installPromise: Promise<void> = Promise.resolve();
|
||||
act(() => {
|
||||
result.current.install();
|
||||
installPromise = result.current.install();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isWorking).toBe(true);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveInstall?.({ state: "up-to-date", ops: [] });
|
||||
await installPromise;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isWorking).toBe(false);
|
||||
});
|
||||
expect(result.current.status).toEqual({ state: "up-to-date", ops: [] });
|
||||
});
|
||||
|
||||
it("toasts and exposes errors when install fails", async () => {
|
||||
const error = new Error("Missing IPC handler");
|
||||
desktopDaemon.getSkillsStatus.mockResolvedValue({
|
||||
state: "not-installed",
|
||||
ops: [{ kind: "add", name: "paseo" }],
|
||||
});
|
||||
desktopDaemon.installSkills.mockRejectedValue(error);
|
||||
|
||||
const { result } = renderDesktopHook(() => useSkillsStatus());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status?.state).toBe("not-installed");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.install();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe(error);
|
||||
});
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith("Unable to install orchestration skills.");
|
||||
expect(console.error).toHaveBeenCalledWith("[Integrations] Failed to install skills", error);
|
||||
});
|
||||
|
||||
@@ -2,11 +2,14 @@ import { useCallback } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getCliInstallStatus,
|
||||
getSkillsInstallStatus,
|
||||
getSkillsStatus,
|
||||
installCli,
|
||||
installSkills,
|
||||
shouldUseDesktopDaemon,
|
||||
type InstallStatus,
|
||||
type SkillsStatus,
|
||||
uninstallSkills,
|
||||
updateSkills,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import {
|
||||
useDesktopIpcErrorReporter,
|
||||
@@ -14,11 +17,7 @@ import {
|
||||
} from "@/desktop/hooks/desktop-ipc-error";
|
||||
|
||||
const CLI_INSTALL_STATUS_QUERY_KEY = ["desktop", "integrations", "cli-install-status"] as const;
|
||||
const SKILLS_INSTALL_STATUS_QUERY_KEY = [
|
||||
"desktop",
|
||||
"integrations",
|
||||
"skills-install-status",
|
||||
] as const;
|
||||
const SKILLS_STATUS_QUERY_KEY = ["desktop", "integrations", "skills-status"] as const;
|
||||
|
||||
interface DesktopInstallHookResult {
|
||||
status: InstallStatus | null;
|
||||
@@ -77,25 +76,43 @@ export function useCliInstall(): DesktopInstallHookResult {
|
||||
};
|
||||
}
|
||||
|
||||
export function useSkillsInstall(): DesktopInstallHookResult {
|
||||
export interface SkillsStatusHookResult {
|
||||
status: SkillsStatus | null;
|
||||
isLoading: boolean;
|
||||
isWorking: boolean;
|
||||
error: Error | null;
|
||||
refresh: () => Promise<void>;
|
||||
install: () => Promise<void>;
|
||||
update: () => Promise<void>;
|
||||
uninstall: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useSkillsStatus(): SkillsStatusHookResult {
|
||||
const queryClient = useQueryClient();
|
||||
const reportError = useDesktopIpcErrorReporter();
|
||||
const enabled = shouldUseDesktopDaemon();
|
||||
|
||||
const statusQuery = useQuery<InstallStatus, Error>({
|
||||
queryKey: SKILLS_INSTALL_STATUS_QUERY_KEY,
|
||||
queryFn: getSkillsInstallStatus,
|
||||
const statusQuery = useQuery<SkillsStatus, Error>({
|
||||
queryKey: SKILLS_STATUS_QUERY_KEY,
|
||||
queryFn: getSkillsStatus,
|
||||
enabled,
|
||||
retry: false,
|
||||
});
|
||||
const { data: installStatus, error: statusError, isLoading, refetch } = statusQuery;
|
||||
const { data: status, error: statusError, isLoading, refetch } = statusQuery;
|
||||
useDesktopIpcQueryErrorToast({
|
||||
error: statusQuery.error,
|
||||
message: "Unable to check orchestration skills install status.",
|
||||
message: "Unable to check orchestration skills status.",
|
||||
logLabel: "[Integrations] Failed to load skills status",
|
||||
});
|
||||
|
||||
const installMutation = useMutation<InstallStatus, Error>({
|
||||
const setStatus = useCallback(
|
||||
(next: SkillsStatus) => {
|
||||
queryClient.setQueryData<SkillsStatus>(SKILLS_STATUS_QUERY_KEY, next);
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const installMutation = useMutation<SkillsStatus, Error>({
|
||||
mutationFn: installSkills,
|
||||
onError: (error) => {
|
||||
reportError({
|
||||
@@ -104,23 +121,65 @@ export function useSkillsInstall(): DesktopInstallHookResult {
|
||||
logLabel: "[Integrations] Failed to install skills",
|
||||
});
|
||||
},
|
||||
onSuccess: (nextStatus) => {
|
||||
queryClient.setQueryData<InstallStatus>(SKILLS_INSTALL_STATUS_QUERY_KEY, nextStatus);
|
||||
void queryClient.invalidateQueries({ queryKey: SKILLS_INSTALL_STATUS_QUERY_KEY });
|
||||
},
|
||||
onSuccess: setStatus,
|
||||
});
|
||||
const { error: installError, isPending: isInstalling, mutate: install } = installMutation;
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
void refetch();
|
||||
const updateMutation = useMutation<SkillsStatus, Error>({
|
||||
mutationFn: updateSkills,
|
||||
onError: (error) => {
|
||||
reportError({
|
||||
error,
|
||||
message: "Unable to update orchestration skills.",
|
||||
logLabel: "[Integrations] Failed to update skills",
|
||||
});
|
||||
},
|
||||
onSuccess: setStatus,
|
||||
});
|
||||
|
||||
const uninstallMutation = useMutation<SkillsStatus, Error>({
|
||||
mutationFn: uninstallSkills,
|
||||
onError: (error) => {
|
||||
reportError({
|
||||
error,
|
||||
message: "Unable to uninstall orchestration skills.",
|
||||
logLabel: "[Integrations] Failed to uninstall skills",
|
||||
});
|
||||
},
|
||||
onSuccess: setStatus,
|
||||
});
|
||||
|
||||
const isWorking =
|
||||
installMutation.isPending || updateMutation.isPending || uninstallMutation.isPending;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
await refetch();
|
||||
}, [refetch]);
|
||||
|
||||
const install = useCallback(async () => {
|
||||
await installMutation.mutateAsync().catch(() => undefined);
|
||||
}, [installMutation]);
|
||||
|
||||
const update = useCallback(async () => {
|
||||
await updateMutation.mutateAsync().catch(() => undefined);
|
||||
}, [updateMutation]);
|
||||
|
||||
const uninstall = useCallback(async () => {
|
||||
await uninstallMutation.mutateAsync().catch(() => undefined);
|
||||
}, [uninstallMutation]);
|
||||
|
||||
return {
|
||||
status: installStatus ?? null,
|
||||
status: status ?? null,
|
||||
isLoading,
|
||||
isInstalling,
|
||||
error: statusError ?? installError ?? null,
|
||||
install,
|
||||
isWorking,
|
||||
error:
|
||||
statusError ??
|
||||
installMutation.error ??
|
||||
updateMutation.error ??
|
||||
uninstallMutation.error ??
|
||||
null,
|
||||
refresh,
|
||||
install,
|
||||
update,
|
||||
uninstall,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -184,21 +184,6 @@ describe("UpdateCalloutSource", () => {
|
||||
expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("shows only the changelog action once the update is installed", async () => {
|
||||
updaterState.value = {
|
||||
...updaterState.value,
|
||||
status: "installed",
|
||||
availableUpdate: null,
|
||||
};
|
||||
await renderHarness(root!);
|
||||
|
||||
expect(container?.textContent).toContain("Update installed");
|
||||
expect(
|
||||
container?.querySelector('[data-testid="update-callout-action-0"]')?.textContent,
|
||||
).toContain("What's new");
|
||||
expect(container?.querySelector('[data-testid="update-callout-action-1"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("disables the install action and shows Installing... while installing", async () => {
|
||||
updaterState.value = {
|
||||
...updaterState.value,
|
||||
|
||||
@@ -13,25 +13,18 @@ import { openExternalUrl } from "@/utils/open-external-url";
|
||||
const CHECK_INTERVAL_MS = 30 * 60 * 1000;
|
||||
const CHANGELOG_URL = "https://paseo.sh/changelog";
|
||||
|
||||
function resolveUpdateCalloutTitle(args: {
|
||||
isInstalled: boolean;
|
||||
isInstalling: boolean;
|
||||
isError: boolean;
|
||||
}): string {
|
||||
if (args.isInstalled) return "Update installed";
|
||||
function resolveUpdateCalloutTitle(args: { isInstalling: boolean; isError: boolean }): string {
|
||||
if (args.isInstalling) return "Installing update";
|
||||
if (args.isError) return "Update failed";
|
||||
return "Update available";
|
||||
}
|
||||
|
||||
function resolveUpdateCalloutDescription(args: {
|
||||
isInstalled: boolean;
|
||||
isInstalling: boolean;
|
||||
isError: boolean;
|
||||
errorMessage: string | null;
|
||||
latestVersion: string | undefined;
|
||||
}): ReactNode {
|
||||
if (args.isInstalled) return "Restart to use the new version.";
|
||||
if (args.isInstalling) return "Installing and restarting...";
|
||||
if (args.isError) return args.errorMessage ?? "Something went wrong.";
|
||||
if (args.latestVersion) {
|
||||
@@ -43,7 +36,6 @@ function resolveUpdateCalloutDescription(args: {
|
||||
}
|
||||
|
||||
function buildUpdateCalloutActions(args: {
|
||||
isInstalled: boolean;
|
||||
isInstalling: boolean;
|
||||
isError: boolean;
|
||||
openChangelog: () => void;
|
||||
@@ -53,7 +45,7 @@ function buildUpdateCalloutActions(args: {
|
||||
const actions: SidebarCalloutAction[] = [{ label: "What's new", onPress: args.openChangelog }];
|
||||
if (args.isError) {
|
||||
actions.push({ label: "Retry", onPress: args.retry, variant: "primary" });
|
||||
} else if (!args.isInstalled) {
|
||||
} else {
|
||||
actions.push({
|
||||
label: args.isInstalling ? "Installing..." : "Install & restart",
|
||||
onPress: args.install,
|
||||
@@ -107,29 +99,21 @@ export function UpdateCalloutSource() {
|
||||
if (!isDesktopApp) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
status !== "available" &&
|
||||
status !== "installed" &&
|
||||
status !== "installing" &&
|
||||
status !== "error"
|
||||
) {
|
||||
if (status !== "available" && status !== "installing" && status !== "error") {
|
||||
return;
|
||||
}
|
||||
|
||||
const isInstalled = status === "installed";
|
||||
const isError = status === "error";
|
||||
const isAvailable = !isInstalled && !isInstalling && !isError;
|
||||
const isAvailable = !isInstalling && !isError;
|
||||
|
||||
const title = resolveUpdateCalloutTitle({ isInstalled, isInstalling, isError });
|
||||
const title = resolveUpdateCalloutTitle({ isInstalling, isError });
|
||||
const description = resolveUpdateCalloutDescription({
|
||||
isInstalled,
|
||||
isInstalling,
|
||||
isError,
|
||||
errorMessage,
|
||||
latestVersion: availableUpdate?.latestVersion ?? undefined,
|
||||
});
|
||||
const actions = buildUpdateCalloutActions({
|
||||
isInstalled,
|
||||
isInstalling,
|
||||
isError,
|
||||
openChangelog,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { Fragment, useCallback, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import {
|
||||
Alert,
|
||||
@@ -745,16 +745,19 @@ function SettingsSidebar({
|
||||
) : null}
|
||||
<View style={sidebarStyles.list}>
|
||||
{items.map((item) => (
|
||||
<SidebarSectionButton
|
||||
key={item.id}
|
||||
itemId={item.id}
|
||||
label={item.label}
|
||||
icon={item.icon}
|
||||
isSelected={selectedSectionId === item.id}
|
||||
onSelect={onSelectSection}
|
||||
/>
|
||||
<Fragment key={item.id}>
|
||||
<SidebarSectionButton
|
||||
itemId={item.id}
|
||||
label={item.label}
|
||||
icon={item.icon}
|
||||
isSelected={selectedSectionId === item.id}
|
||||
onSelect={onSelectSection}
|
||||
/>
|
||||
{item.id === "general" ? (
|
||||
<SidebarProjectsButton isSelected={isProjectsSelected} onSelect={onSelectProjects} />
|
||||
) : null}
|
||||
</Fragment>
|
||||
))}
|
||||
<SidebarProjectsButton isSelected={isProjectsSelected} onSelect={onSelectProjects} />
|
||||
</View>
|
||||
<SidebarSeparator />
|
||||
<View style={sidebarStyles.list}>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
|
||||
import { buildGitHubBranchTreeUrl } from "@/utils/github-repo-url";
|
||||
@@ -27,6 +28,7 @@ import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { filterTargetsForDaemonLocation } from "./workspace-open-targets";
|
||||
|
||||
interface WorkspaceOpenInEditorButtonProps {
|
||||
serverId: string;
|
||||
@@ -38,6 +40,7 @@ interface OpenTarget {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ReactElement;
|
||||
requiresLocalDaemon: boolean;
|
||||
onOpen: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
@@ -82,14 +85,16 @@ export function WorkspaceOpenInEditorButton({
|
||||
const toast = useToast();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const isLocalDaemon = useIsLocalDaemon(serverId);
|
||||
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
|
||||
|
||||
const shouldLoadTargets =
|
||||
const shouldQueryWorkspace =
|
||||
isWeb && Boolean(client && isConnected) && cwd.trim().length > 0 && isAbsolutePath(cwd);
|
||||
const shouldLoadEditorTargets = shouldQueryWorkspace && isLocalDaemon;
|
||||
|
||||
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
|
||||
queryKey: ["available-editors", serverId],
|
||||
enabled: shouldLoadTargets,
|
||||
enabled: shouldLoadEditorTargets,
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
@@ -112,7 +117,7 @@ export function WorkspaceOpenInEditorButton({
|
||||
|
||||
const { status: checkoutStatus } = useCheckoutStatusQuery({
|
||||
serverId,
|
||||
cwd: shouldLoadTargets ? cwd : "",
|
||||
cwd: shouldQueryWorkspace ? cwd : "",
|
||||
});
|
||||
|
||||
const editorTargets = useMemo<OpenTarget[]>(
|
||||
@@ -121,6 +126,7 @@ export function WorkspaceOpenInEditorButton({
|
||||
id: editor.id,
|
||||
label: editor.label,
|
||||
icon: <ThemedEditorAppIcon editorId={editor.id} size={16} uniProps={mutedColorMapping} />,
|
||||
requiresLocalDaemon: true,
|
||||
onOpen: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
@@ -149,13 +155,20 @@ export function WorkspaceOpenInEditorButton({
|
||||
id: "github",
|
||||
label: "GitHub",
|
||||
icon: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
|
||||
requiresLocalDaemon: false,
|
||||
onOpen: () => openExternalUrl(url),
|
||||
};
|
||||
}, [checkoutStatus]);
|
||||
|
||||
const targets = useMemo(
|
||||
() => (githubTarget ? [...editorTargets, githubTarget] : editorTargets),
|
||||
[editorTargets, githubTarget],
|
||||
() =>
|
||||
filterTargetsForDaemonLocation(
|
||||
githubTarget ? [...editorTargets, githubTarget] : editorTargets,
|
||||
{
|
||||
isLocalDaemon,
|
||||
},
|
||||
),
|
||||
[editorTargets, githubTarget, isLocalDaemon],
|
||||
);
|
||||
|
||||
const targetIds = useMemo(() => targets.map((target) => target.id), [targets]);
|
||||
@@ -210,7 +223,7 @@ export function WorkspaceOpenInEditorButton({
|
||||
}
|
||||
}, [primaryOption, handleOpenTarget]);
|
||||
|
||||
if (!shouldLoadTargets || !primaryOption || targets.length === 0) {
|
||||
if (!shouldQueryWorkspace || !primaryOption || targets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { filterTargetsForDaemonLocation } from "./workspace-open-targets";
|
||||
|
||||
describe("filterTargetsForDaemonLocation", () => {
|
||||
const targets = [
|
||||
{ id: "cursor", requiresLocalDaemon: true },
|
||||
{ id: "vscode", requiresLocalDaemon: true },
|
||||
{ id: "github", requiresLocalDaemon: false },
|
||||
];
|
||||
|
||||
it("keeps local app targets and URL targets for the local daemon", () => {
|
||||
expect(filterTargetsForDaemonLocation(targets, { isLocalDaemon: true })).toEqual(targets);
|
||||
});
|
||||
|
||||
it("hides local app targets for a remote daemon", () => {
|
||||
expect(filterTargetsForDaemonLocation(targets, { isLocalDaemon: false })).toEqual([
|
||||
{ id: "github", requiresLocalDaemon: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves target order after filtering", () => {
|
||||
expect(
|
||||
filterTargetsForDaemonLocation(
|
||||
[
|
||||
{ id: "github", requiresLocalDaemon: false },
|
||||
{ id: "finder", requiresLocalDaemon: true },
|
||||
{ id: "docs", requiresLocalDaemon: false },
|
||||
],
|
||||
{ isLocalDaemon: false },
|
||||
),
|
||||
).toEqual([
|
||||
{ id: "github", requiresLocalDaemon: false },
|
||||
{ id: "docs", requiresLocalDaemon: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
13
packages/app/src/screens/workspace/workspace-open-targets.ts
Normal file
13
packages/app/src/screens/workspace/workspace-open-targets.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface WorkspaceOpenTargetAvailability {
|
||||
requiresLocalDaemon: boolean;
|
||||
}
|
||||
|
||||
export function filterTargetsForDaemonLocation<Target extends WorkspaceOpenTargetAvailability>(
|
||||
targets: readonly Target[],
|
||||
input: { isLocalDaemon: boolean },
|
||||
): Target[] {
|
||||
if (input.isLocalDaemon) {
|
||||
return [...targets];
|
||||
}
|
||||
return targets.filter((target) => !target.requiresLocalDaemon);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.70-beta.1",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/server": "0.1.69",
|
||||
"@getpaseo/server": "0.1.70-beta.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -4,16 +4,12 @@ setlocal
|
||||
set "SCRIPT_DIR=%~dp0"
|
||||
set "RESOURCES_DIR=%SCRIPT_DIR%.."
|
||||
set "APP_EXECUTABLE=%RESOURCES_DIR%\..\Paseo.exe"
|
||||
set "CLI_EXECUTABLE=%RESOURCES_DIR%\..\PaseoCli.exe"
|
||||
if not exist "%APP_EXECUTABLE%" (
|
||||
echo Bundled Paseo executable not found at %APP_EXECUTABLE% 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
if not exist "%CLI_EXECUTABLE%" (
|
||||
set "CLI_EXECUTABLE=%APP_EXECUTABLE%"
|
||||
)
|
||||
|
||||
set "ELECTRON_RUN_AS_NODE=1"
|
||||
set "PASEO_NODE_ENV=production"
|
||||
"%CLI_EXECUTABLE%" --disable-warning=DEP0040 "%RESOURCES_DIR%\app.asar.unpacked\dist\daemon\node-entrypoint-runner.js" node-script "%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js" %*
|
||||
"%APP_EXECUTABLE%" --disable-warning=DEP0040 "%RESOURCES_DIR%\app.asar.unpacked\dist\daemon\node-entrypoint-runner.js" node-script "%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js" %*
|
||||
exit /b %errorlevel%
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.70-beta.1",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -4,9 +4,6 @@ const path = require("path");
|
||||
const { smokePackagedDesktopApp } = require("./smoke-packaged-desktop-app.js");
|
||||
|
||||
const EXECUTABLE_NAME = "Paseo";
|
||||
const WINDOWS_CLI_EXECUTABLE_NAME = "PaseoCli";
|
||||
const IMAGE_SUBSYSTEM_WINDOWS_CUI = 3;
|
||||
const PE_SUBSYSTEM_OFFSET_FROM_PE_HEADER = 0x5c;
|
||||
|
||||
// electron-builder arch enum → Node.js arch string
|
||||
const ARCH_MAP = { 0: "ia32", 1: "x64", 2: "armv7l", 3: "arm64", 4: "universal" };
|
||||
@@ -124,50 +121,12 @@ function fmtMB(bytes) {
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function setWindowsExecutableSubsystem(filePath, subsystem) {
|
||||
const fd = fs.openSync(filePath, "r+");
|
||||
try {
|
||||
const dosHeader = Buffer.alloc(64);
|
||||
fs.readSync(fd, dosHeader, 0, dosHeader.length, 0);
|
||||
if (dosHeader.toString("ascii", 0, 2) !== "MZ") {
|
||||
throw new Error(`Invalid Windows executable DOS header: ${filePath}`);
|
||||
}
|
||||
|
||||
const peHeaderOffset = dosHeader.readUInt32LE(0x3c);
|
||||
const peSignature = Buffer.alloc(4);
|
||||
fs.readSync(fd, peSignature, 0, peSignature.length, peHeaderOffset);
|
||||
if (peSignature.toString("ascii") !== "PE\u0000\u0000") {
|
||||
throw new Error(`Invalid Windows executable PE header: ${filePath}`);
|
||||
}
|
||||
|
||||
const subsystemOffset = peHeaderOffset + PE_SUBSYSTEM_OFFSET_FROM_PE_HEADER;
|
||||
const subsystemBuffer = Buffer.alloc(2);
|
||||
subsystemBuffer.writeUInt16LE(subsystem);
|
||||
fs.writeSync(fd, subsystemBuffer, 0, subsystemBuffer.length, subsystemOffset);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
function createWindowsCliExecutable(appOutDir) {
|
||||
const appExecutable = path.join(appOutDir, `${EXECUTABLE_NAME}.exe`);
|
||||
const cliExecutable = path.join(appOutDir, `${WINDOWS_CLI_EXECUTABLE_NAME}.exe`);
|
||||
|
||||
fs.copyFileSync(appExecutable, cliExecutable);
|
||||
setWindowsExecutableSubsystem(cliExecutable, IMAGE_SUBSYSTEM_WINDOWS_CUI);
|
||||
console.log(`Created Windows CLI executable: ${cliExecutable}`);
|
||||
}
|
||||
|
||||
exports.default = async function afterPack(context) {
|
||||
const platform = context.electronPlatformName;
|
||||
const arch = ARCH_MAP[context.arch] || process.arch;
|
||||
|
||||
pruneNativeModules(context.appOutDir, platform, arch);
|
||||
|
||||
if (platform === "win32") {
|
||||
createWindowsCliExecutable(context.appOutDir);
|
||||
}
|
||||
|
||||
if (platform === "linux" || platform === "win32") {
|
||||
if (arch !== process.arch) {
|
||||
console.log(
|
||||
@@ -188,6 +147,3 @@ async function smokeUnpackedAppIfRequested(appOutDir) {
|
||||
appPath: appOutDir,
|
||||
});
|
||||
}
|
||||
|
||||
exports.createWindowsCliExecutable = createWindowsCliExecutable;
|
||||
exports.setWindowsExecutableSubsystem = setWindowsExecutableSubsystem;
|
||||
|
||||
@@ -17,12 +17,13 @@ import {
|
||||
downloadAndInstallUpdate,
|
||||
type AppReleaseChannel,
|
||||
} from "../features/auto-updater.js";
|
||||
import { getCliInstallStatus, installCli } from "../integrations/cli-install/index.js";
|
||||
import {
|
||||
installCli,
|
||||
getCliInstallStatus,
|
||||
getSkillsStatus,
|
||||
installSkills,
|
||||
getSkillsInstallStatus,
|
||||
} from "../integrations/integrations-manager.js";
|
||||
uninstallSkills,
|
||||
updateSkills,
|
||||
} from "../integrations/skills/index.js";
|
||||
import {
|
||||
openLocalTransportSession,
|
||||
sendLocalTransportMessage,
|
||||
@@ -531,8 +532,10 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
|
||||
get_local_daemon_version: () => getLocalDaemonVersion(),
|
||||
install_cli: () => installCli(),
|
||||
get_cli_install_status: () => getCliInstallStatus(),
|
||||
get_skills_status: () => getSkillsStatus(),
|
||||
install_skills: () => installSkills(),
|
||||
get_skills_install_status: () => getSkillsInstallStatus(),
|
||||
update_skills: () => updateSkills(),
|
||||
uninstall_skills: () => uninstallSkills(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
describe("desktop packaging", () => {
|
||||
it("unpacks server zsh shell integration files for external shells", () => {
|
||||
@@ -36,38 +33,4 @@ describe("desktop packaging", () => {
|
||||
expect(deps[required], `${required} must be declared in dependencies`).toBe("*");
|
||||
}
|
||||
});
|
||||
|
||||
it("uses a console-subsystem executable for the bundled Windows CLI shim", () => {
|
||||
const cmd = readFileSync(join(packageRoot, "bin", "paseo.cmd"), "utf8");
|
||||
|
||||
expect(cmd).toContain("PaseoCli.exe");
|
||||
expect(cmd).toContain('"%CLI_EXECUTABLE%"');
|
||||
});
|
||||
|
||||
it("can mark the Windows CLI executable as console subsystem", () => {
|
||||
const { setWindowsExecutableSubsystem } = require(
|
||||
join(packageRoot, "scripts", "after-pack.js"),
|
||||
) as {
|
||||
setWindowsExecutableSubsystem: (filePath: string, subsystem: number) => void;
|
||||
};
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "paseo-pe-subsystem-"));
|
||||
const exePath = join(tempDir, "probe.exe");
|
||||
const peHeaderOffset = 0x80;
|
||||
const subsystemOffset = peHeaderOffset + 0x5c;
|
||||
const bytes = Buffer.alloc(subsystemOffset + 2);
|
||||
bytes.write("MZ", 0, "ascii");
|
||||
bytes.writeUInt32LE(peHeaderOffset, 0x3c);
|
||||
bytes.write("PE\u0000\u0000", peHeaderOffset, "ascii");
|
||||
bytes.writeUInt16LE(2, subsystemOffset);
|
||||
writeFileSync(exePath, bytes);
|
||||
|
||||
try {
|
||||
setWindowsExecutableSubsystem(exePath, 3);
|
||||
|
||||
const patched = readFileSync(exePath);
|
||||
expect(patched.readUInt16LE(subsystemOffset)).toBe(3);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,16 +196,9 @@ function buildCheckResult(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleQuitAndInstall(onBeforeQuit?: () => Promise<void>): void {
|
||||
// Use a short delay to allow the renderer to receive the response.
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
if (onBeforeQuit) await onBeforeQuit();
|
||||
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
|
||||
} catch (error) {
|
||||
console.error("[auto-updater] quitAndInstall failed:", error);
|
||||
}
|
||||
}, 1500);
|
||||
async function performQuitAndInstall(onBeforeQuit?: () => Promise<void>): Promise<void> {
|
||||
if (onBeforeQuit) await onBeforeQuit();
|
||||
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -314,7 +307,7 @@ export async function downloadAndInstallUpdate(
|
||||
|
||||
const readyVersion = cachedUpdateInfo.version;
|
||||
if (isReadyToInstallVersion(readyVersion)) {
|
||||
scheduleQuitAndInstall(onBeforeQuit);
|
||||
await performQuitAndInstall(onBeforeQuit);
|
||||
return {
|
||||
installed: true,
|
||||
version: readyVersion,
|
||||
@@ -336,7 +329,7 @@ export async function downloadAndInstallUpdate(
|
||||
await autoUpdater.downloadUpdate();
|
||||
downloadedUpdateVersion = readyVersion;
|
||||
downloading = false;
|
||||
scheduleQuitAndInstall(onBeforeQuit);
|
||||
await performQuitAndInstall(onBeforeQuit);
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
|
||||
1
packages/desktop/src/integrations/cli-install/index.ts
Normal file
1
packages/desktop/src/integrations/cli-install/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { getCliInstallStatus, installCli } from "./install.js";
|
||||
71
packages/desktop/src/integrations/cli-install/install.ts
Normal file
71
packages/desktop/src/integrations/cli-install/install.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { app } from "electron";
|
||||
import log from "electron-log/main";
|
||||
import { resolveCliInstallSourcePath } from "./path.js";
|
||||
import { getBundledCliShimPath, getCliTargetPath, getLocalBinDir } from "./paths.js";
|
||||
import { ensurePathInShellRc } from "./shell-rc.js";
|
||||
|
||||
interface InstallStatus {
|
||||
installed: boolean;
|
||||
}
|
||||
|
||||
async function pathOrSymlinkExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.lstat(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function installCli(): Promise<InstallStatus> {
|
||||
const targetPath = getCliTargetPath();
|
||||
const shimPath = getBundledCliShimPath();
|
||||
const installSourcePath = resolveCliInstallSourcePath({
|
||||
platform: process.platform,
|
||||
isPackaged: app.isPackaged,
|
||||
executablePath: app.getPath("exe"),
|
||||
shimPath,
|
||||
appImagePath: process.env.APPIMAGE,
|
||||
});
|
||||
const binDir = getLocalBinDir();
|
||||
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
|
||||
if (process.platform === "win32") {
|
||||
if (await pathOrSymlinkExists(targetPath)) {
|
||||
await fs.unlink(targetPath);
|
||||
}
|
||||
// Generate a thin .cmd trampoline that delegates to the bundled shim.
|
||||
// Only the app install path is baked in — internal details (asar layout,
|
||||
// entrypoint scripts) live in the bundled shim and update with the app.
|
||||
const cmdContent = [
|
||||
"@echo off",
|
||||
`set "BUNDLED_CLI=${shimPath}"`,
|
||||
`if not exist "%BUNDLED_CLI%" (`,
|
||||
` echo Paseo CLI not found at %BUNDLED_CLI% — is Paseo installed? 1>&2`,
|
||||
` exit /b 1`,
|
||||
`)`,
|
||||
`call "%BUNDLED_CLI%" %*`,
|
||||
`exit /b %errorlevel%`,
|
||||
].join("\r\n");
|
||||
await fs.writeFile(targetPath, cmdContent, "utf-8");
|
||||
} else {
|
||||
if (await pathOrSymlinkExists(targetPath)) {
|
||||
await fs.unlink(targetPath);
|
||||
}
|
||||
await fs.symlink(installSourcePath, targetPath);
|
||||
}
|
||||
|
||||
const { shellUpdated } = await ensurePathInShellRc();
|
||||
if (shellUpdated) {
|
||||
log.info("[integrations] Updated shell rc with ~/.local/bin PATH");
|
||||
}
|
||||
|
||||
return getCliInstallStatus();
|
||||
}
|
||||
|
||||
export async function getCliInstallStatus(): Promise<InstallStatus> {
|
||||
const targetPath = getCliTargetPath();
|
||||
return { installed: await pathOrSymlinkExists(targetPath) };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveCliInstallSourcePath } from "./cli-install-path";
|
||||
import { resolveCliInstallSourcePath } from "./path";
|
||||
|
||||
describe("cli-install-path", () => {
|
||||
it("uses the bundled shim for packaged macOS installs", () => {
|
||||
31
packages/desktop/src/integrations/cli-install/paths.ts
Normal file
31
packages/desktop/src/integrations/cli-install/paths.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { app } from "electron";
|
||||
|
||||
export function getLocalBinDir(): string {
|
||||
return path.join(os.homedir(), ".local", "bin");
|
||||
}
|
||||
|
||||
export function getCliTargetPath(): string {
|
||||
const filename = process.platform === "win32" ? "paseo.cmd" : "paseo";
|
||||
return path.join(getLocalBinDir(), filename);
|
||||
}
|
||||
|
||||
export function getBundledCliShimPath(): string {
|
||||
const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo";
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
const electronExePath = app.getPath("exe");
|
||||
const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, "");
|
||||
return path.join(appBundle, "Contents", "Resources", "bin", cliShimFilename);
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const electronExePath = app.getPath("exe");
|
||||
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
|
||||
}
|
||||
|
||||
// Linux
|
||||
const electronExePath = app.getPath("exe");
|
||||
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
|
||||
}
|
||||
97
packages/desktop/src/integrations/cli-install/shell-rc.ts
Normal file
97
packages/desktop/src/integrations/cli-install/shell-rc.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import log from "electron-log/main";
|
||||
|
||||
export interface ShellRcInfo {
|
||||
shell: string;
|
||||
rcFile: string;
|
||||
pathCheckPattern: RegExp;
|
||||
exportLine: string;
|
||||
}
|
||||
|
||||
async function pathOrSymlinkExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.lstat(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function detectShellRcInfo(): ShellRcInfo | null {
|
||||
if (process.platform === "win32") return null;
|
||||
|
||||
const shell = process.env.SHELL;
|
||||
if (!shell) return null;
|
||||
|
||||
const shellName = path.basename(shell);
|
||||
|
||||
if (shellName === "zsh") {
|
||||
return {
|
||||
shell: "zsh",
|
||||
rcFile: path.join(os.homedir(), ".zshrc"),
|
||||
pathCheckPattern: /\.local\/bin/,
|
||||
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
|
||||
};
|
||||
}
|
||||
|
||||
if (shellName === "bash") {
|
||||
const rcFile =
|
||||
process.platform === "darwin"
|
||||
? path.join(os.homedir(), ".bash_profile")
|
||||
: path.join(os.homedir(), ".bashrc");
|
||||
return {
|
||||
shell: "bash",
|
||||
rcFile,
|
||||
pathCheckPattern: /\.local\/bin/,
|
||||
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
|
||||
};
|
||||
}
|
||||
|
||||
if (shellName === "fish") {
|
||||
return {
|
||||
shell: "fish",
|
||||
rcFile: path.join(os.homedir(), ".config", "fish", "config.fish"),
|
||||
pathCheckPattern: /\.local\/bin/,
|
||||
exportLine: "fish_add_path $HOME/.local/bin",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function pathAlreadyContainsLocalBin(): boolean {
|
||||
const pathEnv = process.env.PATH ?? "";
|
||||
const localBin = path.join(os.homedir(), ".local", "bin");
|
||||
return pathEnv.split(path.delimiter).some((p) => p === localBin || p === "~/.local/bin");
|
||||
}
|
||||
|
||||
export async function ensurePathInShellRc(): Promise<{ shellUpdated: boolean }> {
|
||||
if (pathAlreadyContainsLocalBin()) {
|
||||
return { shellUpdated: false };
|
||||
}
|
||||
|
||||
const info = detectShellRcInfo();
|
||||
if (!info) {
|
||||
return { shellUpdated: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const exists = await pathOrSymlinkExists(info.rcFile);
|
||||
if (exists) {
|
||||
const content = await fs.readFile(info.rcFile, "utf-8");
|
||||
if (info.pathCheckPattern.test(content)) {
|
||||
return { shellUpdated: false };
|
||||
}
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(info.rcFile), { recursive: true });
|
||||
await fs.appendFile(info.rcFile, `\n# Added by Paseo\n${info.exportLine}\n`);
|
||||
|
||||
return { shellUpdated: true };
|
||||
} catch (err) {
|
||||
log.warn("[integrations] Failed to update shell rc file", { rcFile: info.rcFile, err });
|
||||
return { shellUpdated: false };
|
||||
}
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { app } from "electron";
|
||||
import log from "electron-log/main";
|
||||
import { resolveCliInstallSourcePath } from "./cli-install-path.js";
|
||||
import { syncSkills } from "./skill-sync.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface InstallStatus {
|
||||
installed: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SKILL_NAMES = [
|
||||
"paseo",
|
||||
"paseo-advisor",
|
||||
"paseo-committee",
|
||||
"paseo-epic",
|
||||
"paseo-handoff",
|
||||
"paseo-loop",
|
||||
"paseo-orchestrate",
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filesystem helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function pathOrSymlinkExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.lstat(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getLocalBinDir(): string {
|
||||
return path.join(os.homedir(), ".local", "bin");
|
||||
}
|
||||
|
||||
function getCliTargetPath(): string {
|
||||
const filename = process.platform === "win32" ? "paseo.cmd" : "paseo";
|
||||
return path.join(getLocalBinDir(), filename);
|
||||
}
|
||||
|
||||
function getBundledCliShimPath(): string {
|
||||
const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo";
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
const electronExePath = app.getPath("exe");
|
||||
const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, "");
|
||||
return path.join(appBundle, "Contents", "Resources", "bin", cliShimFilename);
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const electronExePath = app.getPath("exe");
|
||||
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
|
||||
}
|
||||
|
||||
// Linux
|
||||
const electronExePath = app.getPath("exe");
|
||||
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
|
||||
}
|
||||
|
||||
function getBundledSkillsDir(): string {
|
||||
if (app.isPackaged) {
|
||||
return path.join(process.resourcesPath, "skills");
|
||||
}
|
||||
return path.join(__dirname, "..", "..", "..", "..", "skills");
|
||||
}
|
||||
|
||||
function getAgentsSkillsDir(): string {
|
||||
return path.join(os.homedir(), ".agents", "skills");
|
||||
}
|
||||
|
||||
function getClaudeSkillsDir(): string {
|
||||
return path.join(os.homedir(), ".claude", "skills");
|
||||
}
|
||||
|
||||
function getCodexSkillsDir(): string {
|
||||
return path.join(os.homedir(), ".codex", "skills");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shell PATH helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ShellRcInfo {
|
||||
shell: string;
|
||||
rcFile: string;
|
||||
pathCheckPattern: RegExp;
|
||||
exportLine: string;
|
||||
}
|
||||
|
||||
function detectShellRcInfo(): ShellRcInfo | null {
|
||||
if (process.platform === "win32") return null;
|
||||
|
||||
const shell = process.env.SHELL;
|
||||
if (!shell) return null;
|
||||
|
||||
const shellName = path.basename(shell);
|
||||
|
||||
if (shellName === "zsh") {
|
||||
return {
|
||||
shell: "zsh",
|
||||
rcFile: path.join(os.homedir(), ".zshrc"),
|
||||
pathCheckPattern: /\.local\/bin/,
|
||||
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
|
||||
};
|
||||
}
|
||||
|
||||
if (shellName === "bash") {
|
||||
const rcFile =
|
||||
process.platform === "darwin"
|
||||
? path.join(os.homedir(), ".bash_profile")
|
||||
: path.join(os.homedir(), ".bashrc");
|
||||
return {
|
||||
shell: "bash",
|
||||
rcFile,
|
||||
pathCheckPattern: /\.local\/bin/,
|
||||
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
|
||||
};
|
||||
}
|
||||
|
||||
if (shellName === "fish") {
|
||||
return {
|
||||
shell: "fish",
|
||||
rcFile: path.join(os.homedir(), ".config", "fish", "config.fish"),
|
||||
pathCheckPattern: /\.local\/bin/,
|
||||
exportLine: "fish_add_path $HOME/.local/bin",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function pathAlreadyContainsLocalBin(): boolean {
|
||||
const pathEnv = process.env.PATH ?? "";
|
||||
const localBin = path.join(os.homedir(), ".local", "bin");
|
||||
return pathEnv.split(path.delimiter).some((p) => p === localBin || p === "~/.local/bin");
|
||||
}
|
||||
|
||||
async function ensurePathInShellRc(): Promise<{ shellUpdated: boolean }> {
|
||||
if (pathAlreadyContainsLocalBin()) {
|
||||
return { shellUpdated: false };
|
||||
}
|
||||
|
||||
const info = detectShellRcInfo();
|
||||
if (!info) {
|
||||
return { shellUpdated: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const exists = await pathOrSymlinkExists(info.rcFile);
|
||||
if (exists) {
|
||||
const content = await fs.readFile(info.rcFile, "utf-8");
|
||||
if (info.pathCheckPattern.test(content)) {
|
||||
return { shellUpdated: false };
|
||||
}
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(info.rcFile), { recursive: true });
|
||||
await fs.appendFile(info.rcFile, `\n# Added by Paseo\n${info.exportLine}\n`);
|
||||
|
||||
return { shellUpdated: true };
|
||||
} catch (err) {
|
||||
log.warn("[integrations] Failed to update shell rc file", { rcFile: info.rcFile, err });
|
||||
return { shellUpdated: false };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI Installation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function installCli(): Promise<InstallStatus> {
|
||||
const targetPath = getCliTargetPath();
|
||||
const shimPath = getBundledCliShimPath();
|
||||
const installSourcePath = resolveCliInstallSourcePath({
|
||||
platform: process.platform,
|
||||
isPackaged: app.isPackaged,
|
||||
executablePath: app.getPath("exe"),
|
||||
shimPath,
|
||||
appImagePath: process.env.APPIMAGE,
|
||||
});
|
||||
const binDir = getLocalBinDir();
|
||||
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
|
||||
if (process.platform === "win32") {
|
||||
if (await pathOrSymlinkExists(targetPath)) {
|
||||
await fs.unlink(targetPath);
|
||||
}
|
||||
// Generate a thin .cmd trampoline that delegates to the bundled shim.
|
||||
// Only the app install path is baked in — internal details (asar layout,
|
||||
// entrypoint scripts) live in the bundled shim and update with the app.
|
||||
const cmdContent = [
|
||||
"@echo off",
|
||||
`set "BUNDLED_CLI=${shimPath}"`,
|
||||
`if not exist "%BUNDLED_CLI%" (`,
|
||||
` echo Paseo CLI not found at %BUNDLED_CLI% — is Paseo installed? 1>&2`,
|
||||
` exit /b 1`,
|
||||
`)`,
|
||||
`call "%BUNDLED_CLI%" %*`,
|
||||
`exit /b %errorlevel%`,
|
||||
].join("\r\n");
|
||||
await fs.writeFile(targetPath, cmdContent, "utf-8");
|
||||
} else {
|
||||
if (await pathOrSymlinkExists(targetPath)) {
|
||||
await fs.unlink(targetPath);
|
||||
}
|
||||
await fs.symlink(installSourcePath, targetPath);
|
||||
}
|
||||
|
||||
const { shellUpdated } = await ensurePathInShellRc();
|
||||
if (shellUpdated) {
|
||||
log.info("[integrations] Updated shell rc with ~/.local/bin PATH");
|
||||
}
|
||||
|
||||
return getCliInstallStatus();
|
||||
}
|
||||
|
||||
export async function getCliInstallStatus(): Promise<InstallStatus> {
|
||||
const targetPath = getCliTargetPath();
|
||||
return { installed: await pathOrSymlinkExists(targetPath) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skills Installation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getSkillSyncTargets(): {
|
||||
sourceDir: string;
|
||||
agentsDir: string;
|
||||
claudeDir: string;
|
||||
codexDir: string;
|
||||
} {
|
||||
return {
|
||||
sourceDir: getBundledSkillsDir(),
|
||||
agentsDir: getAgentsSkillsDir(),
|
||||
claudeDir: getClaudeSkillsDir(),
|
||||
codexDir: getCodexSkillsDir(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function installSkills(): Promise<InstallStatus> {
|
||||
const targets = getSkillSyncTargets();
|
||||
log.info("[integrations] installSkills", targets);
|
||||
|
||||
const result = await syncSkills({
|
||||
...targets,
|
||||
skillNames: SKILL_NAMES,
|
||||
onSkillError: (skillName, error) => {
|
||||
log.warn("[integrations] skill install failed", { skillName, error });
|
||||
},
|
||||
});
|
||||
|
||||
log.info("[integrations] installSkills done", result);
|
||||
return getSkillsInstallStatus();
|
||||
}
|
||||
|
||||
export async function autoUpdateSkillsIfInstalled(): Promise<{
|
||||
ran: boolean;
|
||||
changedFiles: number;
|
||||
processedSkills: number;
|
||||
}> {
|
||||
const targets = getSkillSyncTargets();
|
||||
const installedMarker = path.join(targets.agentsDir, "paseo", "SKILL.md");
|
||||
|
||||
try {
|
||||
await fs.access(installedMarker);
|
||||
} catch {
|
||||
return { ran: false, changedFiles: 0, processedSkills: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await syncSkills({
|
||||
...targets,
|
||||
skillNames: SKILL_NAMES,
|
||||
onSkillError: (skillName, error) => {
|
||||
log.warn("[integrations] skill auto-update failed", { skillName, error });
|
||||
},
|
||||
});
|
||||
if (result.changedFiles > 0) {
|
||||
log.info("[integrations] auto-updated paseo skills", result);
|
||||
} else {
|
||||
log.info("[integrations] paseo skills already up to date", result);
|
||||
}
|
||||
return { ran: true, ...result };
|
||||
} catch (error) {
|
||||
log.warn("[integrations] auto-update skills aborted", { error });
|
||||
return { ran: false, changedFiles: 0, processedSkills: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSkillsInstallStatus(): Promise<InstallStatus> {
|
||||
const claudeDir = getClaudeSkillsDir();
|
||||
const accessResults = await Promise.all(
|
||||
SKILL_NAMES.map((skillName) =>
|
||||
fs
|
||||
.access(path.join(claudeDir, skillName, "SKILL.md"))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
),
|
||||
);
|
||||
return { installed: accessResults.every(Boolean) };
|
||||
}
|
||||
10
packages/desktop/src/integrations/skills/index.ts
Normal file
10
packages/desktop/src/integrations/skills/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
getSkillsStatus,
|
||||
installSkills,
|
||||
uninstallSkills,
|
||||
updateSkills,
|
||||
type SkillOp,
|
||||
type SkillsState,
|
||||
type SkillsStatus,
|
||||
type SkillTargets,
|
||||
} from "./operations.js";
|
||||
302
packages/desktop/src/integrations/skills/operations.test.ts
Normal file
302
packages/desktop/src/integrations/skills/operations.test.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
app: {
|
||||
getPath: vi.fn(() => "/tmp/paseo-user-data"),
|
||||
isPackaged: false,
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
getSkillsStatus,
|
||||
installSkills,
|
||||
PASEO_SKILL_NAMES,
|
||||
type SkillTargets,
|
||||
uninstallSkills,
|
||||
updateSkills,
|
||||
} from "./operations";
|
||||
|
||||
interface Sandbox {
|
||||
root: string;
|
||||
targets: SkillTargets;
|
||||
}
|
||||
|
||||
async function makeSandbox(): Promise<Sandbox> {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-skills-"));
|
||||
const targets: SkillTargets = {
|
||||
sourceDir: path.join(root, "bundle"),
|
||||
agentsDir: path.join(root, "home", ".agents", "skills"),
|
||||
claudeDir: path.join(root, "home", ".claude", "skills"),
|
||||
codexDir: path.join(root, "home", ".codex", "skills"),
|
||||
};
|
||||
await fs.mkdir(targets.sourceDir, { recursive: true });
|
||||
return { root, targets };
|
||||
}
|
||||
|
||||
async function writeFiles(rootDir: string, files: Record<string, string>): Promise<void> {
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const full = path.join(rootDir, rel);
|
||||
await fs.mkdir(path.dirname(full), { recursive: true });
|
||||
await fs.writeFile(full, content);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeBundleSkill(
|
||||
sourceDir: string,
|
||||
name: string,
|
||||
files: Record<string, string>,
|
||||
): Promise<void> {
|
||||
await writeFiles(path.join(sourceDir, name), files);
|
||||
}
|
||||
|
||||
async function writeOnDiskSkill(
|
||||
agentsDir: string,
|
||||
name: string,
|
||||
files: Record<string, string>,
|
||||
): Promise<void> {
|
||||
await writeFiles(path.join(agentsDir, name), files);
|
||||
}
|
||||
|
||||
async function writeCurrentBundle(sourceDir: string): Promise<void> {
|
||||
await writeBundleSkill(sourceDir, "paseo", { "SKILL.md": "paseo-v1" });
|
||||
await writeBundleSkill(sourceDir, "paseo-loop", { "SKILL.md": "loop-v1" });
|
||||
}
|
||||
|
||||
async function pathExists(p: string): Promise<boolean> {
|
||||
return fs
|
||||
.access(p)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
describe("getSkillsStatus", () => {
|
||||
let sandbox: Sandbox;
|
||||
|
||||
beforeEach(async () => {
|
||||
sandbox = await makeSandbox();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(sandbox.root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns not-installed with add ops for every bundled skill when nothing is on disk", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
|
||||
const status = await getSkillsStatus(sandbox.targets);
|
||||
|
||||
expect(status.state).toBe("not-installed");
|
||||
expect(status.ops).toEqual([
|
||||
{ kind: "add", name: "paseo" },
|
||||
{ kind: "add", name: "paseo-loop" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns not-installed when only user-personal skill dirs exist (the live bug)", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
for (const name of ["unslop", "tdd", "devbox"]) {
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, name, { "SKILL.md": `user-${name}` });
|
||||
}
|
||||
|
||||
const status = await getSkillsStatus(sandbox.targets);
|
||||
|
||||
expect(status.state).toBe("not-installed");
|
||||
expect(status.ops).toEqual([
|
||||
{ kind: "add", name: "paseo" },
|
||||
{ kind: "add", name: "paseo-loop" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns up-to-date when every bundled skill matches on disk", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
|
||||
|
||||
const status = await getSkillsStatus(sandbox.targets);
|
||||
|
||||
expect(status).toEqual({ state: "up-to-date", ops: [] });
|
||||
});
|
||||
|
||||
it("returns drift with a single update op when one bundled file diverges", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
|
||||
|
||||
const status = await getSkillsStatus(sandbox.targets);
|
||||
|
||||
expect(status.state).toBe("drift");
|
||||
expect(status.ops).toEqual([{ kind: "update", name: "paseo" }]);
|
||||
});
|
||||
|
||||
it("returns drift with add ops for the bundled skills missing from disk", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
|
||||
|
||||
const status = await getSkillsStatus(sandbox.targets);
|
||||
|
||||
expect(status.state).toBe("drift");
|
||||
expect(status.ops).toEqual([{ kind: "add", name: "paseo-loop" }]);
|
||||
});
|
||||
|
||||
it("returns drift with a delete op for a legacy skill name still on disk", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
|
||||
|
||||
const status = await getSkillsStatus(sandbox.targets);
|
||||
|
||||
expect(status.state).toBe("drift");
|
||||
expect(status.ops).toEqual([{ kind: "delete", name: "paseo-chat" }]);
|
||||
});
|
||||
|
||||
it("emits add + update + delete ops sorted by name when state is mixed", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
|
||||
|
||||
const status = await getSkillsStatus(sandbox.targets);
|
||||
|
||||
expect(status.state).toBe("drift");
|
||||
expect(status.ops).toEqual([
|
||||
{ kind: "update", name: "paseo" },
|
||||
{ kind: "delete", name: "paseo-chat" },
|
||||
{ kind: "add", name: "paseo-loop" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("installSkills / updateSkills", () => {
|
||||
let sandbox: Sandbox;
|
||||
|
||||
beforeEach(async () => {
|
||||
sandbox = await makeSandbox();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(sandbox.root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("installs from a clean machine, populates all three targets, and leaves user dirs alone", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "unslop", { "SKILL.md": "user-unslop" });
|
||||
|
||||
const status = await installSkills(sandbox.targets);
|
||||
|
||||
expect(status).toEqual({ state: "up-to-date", ops: [] });
|
||||
for (const name of ["paseo", "paseo-loop"]) {
|
||||
expect(
|
||||
await fs.readFile(path.join(sandbox.targets.agentsDir, name, "SKILL.md"), "utf-8"),
|
||||
).toBe(name === "paseo" ? "paseo-v1" : "loop-v1");
|
||||
expect(
|
||||
await fs.readFile(path.join(sandbox.targets.codexDir, name, "SKILL.md"), "utf-8"),
|
||||
).toBe(name === "paseo" ? "paseo-v1" : "loop-v1");
|
||||
expect(await pathExists(path.join(sandbox.targets.claudeDir, name))).toBe(true);
|
||||
}
|
||||
expect(
|
||||
await fs.readFile(path.join(sandbox.targets.agentsDir, "unslop", "SKILL.md"), "utf-8"),
|
||||
).toBe("user-unslop");
|
||||
});
|
||||
|
||||
it("converges to up-to-date when state has missing + edited + legacy skills", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
|
||||
await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo-chat", { "SKILL.md": "chat-old" });
|
||||
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo-chat", { "SKILL.md": "chat-old" });
|
||||
|
||||
const status = await updateSkills(sandbox.targets);
|
||||
|
||||
expect(status).toEqual({ state: "up-to-date", ops: [] });
|
||||
expect(
|
||||
await fs.readFile(path.join(sandbox.targets.agentsDir, "paseo", "SKILL.md"), "utf-8"),
|
||||
).toBe("paseo-v1");
|
||||
expect(
|
||||
await fs.readFile(path.join(sandbox.targets.agentsDir, "paseo-loop", "SKILL.md"), "utf-8"),
|
||||
).toBe("loop-v1");
|
||||
for (const dir of [
|
||||
sandbox.targets.agentsDir,
|
||||
sandbox.targets.claudeDir,
|
||||
sandbox.targets.codexDir,
|
||||
]) {
|
||||
expect(await pathExists(path.join(dir, "paseo-chat"))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("is idempotent — running install twice keeps state at up-to-date", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
|
||||
const first = await installSkills(sandbox.targets);
|
||||
const second = await installSkills(sandbox.targets);
|
||||
|
||||
expect(first).toEqual({ state: "up-to-date", ops: [] });
|
||||
expect(second).toEqual({ state: "up-to-date", ops: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("uninstallSkills", () => {
|
||||
let sandbox: Sandbox;
|
||||
|
||||
beforeEach(async () => {
|
||||
sandbox = await makeSandbox();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(sandbox.root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("removes every Paseo skill from all three targets and preserves user dirs", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
await installSkills(sandbox.targets);
|
||||
for (const name of ["unslop", "tdd", "devbox"]) {
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, name, { "SKILL.md": `user-${name}` });
|
||||
}
|
||||
|
||||
const status = await uninstallSkills(sandbox.targets);
|
||||
|
||||
expect(status.state).toBe("not-installed");
|
||||
for (const name of PASEO_SKILL_NAMES) {
|
||||
expect(await pathExists(path.join(sandbox.targets.agentsDir, name))).toBe(false);
|
||||
expect(await pathExists(path.join(sandbox.targets.claudeDir, name))).toBe(false);
|
||||
expect(await pathExists(path.join(sandbox.targets.codexDir, name))).toBe(false);
|
||||
}
|
||||
for (const name of ["unslop", "tdd", "devbox"]) {
|
||||
expect(
|
||||
await fs.readFile(path.join(sandbox.targets.agentsDir, name, "SKILL.md"), "utf-8"),
|
||||
).toBe(`user-${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("is a no-op when nothing is installed", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
|
||||
const status = await uninstallSkills(sandbox.targets);
|
||||
|
||||
expect(status.state).toBe("not-installed");
|
||||
});
|
||||
|
||||
it("cleans up legacy skill names that linger in agents, claude, and codex", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
for (const dir of [
|
||||
sandbox.targets.agentsDir,
|
||||
sandbox.targets.claudeDir,
|
||||
sandbox.targets.codexDir,
|
||||
]) {
|
||||
await writeOnDiskSkill(dir, "paseo-chat", { "SKILL.md": "chat-old" });
|
||||
}
|
||||
|
||||
const status = await uninstallSkills(sandbox.targets);
|
||||
|
||||
expect(status.state).toBe("not-installed");
|
||||
for (const dir of [
|
||||
sandbox.targets.agentsDir,
|
||||
sandbox.targets.claudeDir,
|
||||
sandbox.targets.codexDir,
|
||||
]) {
|
||||
expect(await pathExists(path.join(dir, "paseo-chat"))).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
164
packages/desktop/src/integrations/skills/operations.ts
Normal file
164
packages/desktop/src/integrations/skills/operations.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
getAgentsSkillsDir,
|
||||
getBundledSkillsDir,
|
||||
getClaudeSkillsDir,
|
||||
getCodexSkillsDir,
|
||||
} from "./paths.js";
|
||||
import { listFilesRecursive, removeSkill, syncSkills } from "./sync.js";
|
||||
|
||||
export type SkillsState = "not-installed" | "up-to-date" | "drift";
|
||||
|
||||
export type SkillOp =
|
||||
| { kind: "add"; name: string }
|
||||
| { kind: "update"; name: string }
|
||||
| { kind: "delete"; name: string };
|
||||
|
||||
export interface SkillsStatus {
|
||||
state: SkillsState;
|
||||
ops: SkillOp[];
|
||||
}
|
||||
|
||||
export interface SkillTargets {
|
||||
sourceDir: string;
|
||||
agentsDir: string;
|
||||
claudeDir: string;
|
||||
codexDir: string;
|
||||
}
|
||||
|
||||
export const PASEO_SKILL_NAMES = [
|
||||
"paseo",
|
||||
"paseo-advisor",
|
||||
"paseo-chat",
|
||||
"paseo-committee",
|
||||
"paseo-epic",
|
||||
"paseo-handoff",
|
||||
"paseo-loop",
|
||||
"paseo-orchestrate",
|
||||
"paseo-orchestrator",
|
||||
] as const;
|
||||
|
||||
type SkillFiles = Map<string, string>;
|
||||
|
||||
function resolveSkillTargets(): SkillTargets {
|
||||
return {
|
||||
sourceDir: getBundledSkillsDir(),
|
||||
agentsDir: getAgentsSkillsDir(),
|
||||
claudeDir: getClaudeSkillsDir(),
|
||||
codexDir: getCodexSkillsDir(),
|
||||
};
|
||||
}
|
||||
|
||||
async function hashSkillDir(skillDir: string): Promise<SkillFiles | null> {
|
||||
const stat = await fs.stat(skillDir).catch(() => null);
|
||||
if (!stat?.isDirectory()) return null;
|
||||
|
||||
const rels = await listFilesRecursive(skillDir);
|
||||
const files: SkillFiles = new Map();
|
||||
for (const rel of rels) {
|
||||
const buf = await fs.readFile(path.join(skillDir, rel));
|
||||
const sha = createHash("sha256").update(buf).digest("hex");
|
||||
files.set(toPosix(rel), sha);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function hashSkills(rootDir: string): Promise<Map<string, SkillFiles>> {
|
||||
const out = new Map<string, SkillFiles>();
|
||||
for (const name of PASEO_SKILL_NAMES) {
|
||||
const files = await hashSkillDir(path.join(rootDir, name));
|
||||
if (files !== null) out.set(name, files);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function diff(bundle: Map<string, SkillFiles>, disk: Map<string, SkillFiles>): SkillOp[] {
|
||||
const ops: SkillOp[] = [];
|
||||
for (const name of PASEO_SKILL_NAMES) {
|
||||
const b = bundle.get(name);
|
||||
const d = disk.get(name);
|
||||
if (b && !d) ops.push({ kind: "add", name });
|
||||
else if (b && d && !filesEqual(b, d)) ops.push({ kind: "update", name });
|
||||
else if (!b && d) ops.push({ kind: "delete", name });
|
||||
}
|
||||
ops.sort((a, b) => compareStrings(a.name, b.name));
|
||||
return ops;
|
||||
}
|
||||
|
||||
function filesEqual(a: SkillFiles, b: SkillFiles): boolean {
|
||||
if (a.size !== b.size) return false;
|
||||
for (const [rel, sha] of a) {
|
||||
if (b.get(rel) !== sha) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function toPosix(p: string): string {
|
||||
return p.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function compareStrings(a: string, b: string): number {
|
||||
if (a < b) return -1;
|
||||
if (a > b) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function getSkillsStatus(targets?: SkillTargets): Promise<SkillsStatus> {
|
||||
const t = targets ?? resolveSkillTargets();
|
||||
const [bundle, disk] = await Promise.all([hashSkills(t.sourceDir), hashSkills(t.agentsDir)]);
|
||||
const ops = diff(bundle, disk);
|
||||
|
||||
if (disk.size === 0) return { state: "not-installed", ops };
|
||||
if (ops.length === 0) return { state: "up-to-date", ops };
|
||||
return { state: "drift", ops };
|
||||
}
|
||||
|
||||
async function applySkills(targets: SkillTargets): Promise<SkillsStatus> {
|
||||
const status = await getSkillsStatus(targets);
|
||||
|
||||
const writes = status.ops
|
||||
.filter((op) => op.kind === "add" || op.kind === "update")
|
||||
.map((op) => op.name);
|
||||
if (writes.length > 0) {
|
||||
await syncSkills({
|
||||
sourceDir: targets.sourceDir,
|
||||
agentsDir: targets.agentsDir,
|
||||
claudeDir: targets.claudeDir,
|
||||
codexDir: targets.codexDir,
|
||||
skillNames: writes,
|
||||
});
|
||||
}
|
||||
|
||||
for (const op of status.ops) {
|
||||
if (op.kind !== "delete") continue;
|
||||
await removeSkill(op.name, {
|
||||
agentsDir: targets.agentsDir,
|
||||
claudeDir: targets.claudeDir,
|
||||
codexDir: targets.codexDir,
|
||||
});
|
||||
}
|
||||
|
||||
return getSkillsStatus(targets);
|
||||
}
|
||||
|
||||
export async function installSkills(targets?: SkillTargets): Promise<SkillsStatus> {
|
||||
return applySkills(targets ?? resolveSkillTargets());
|
||||
}
|
||||
|
||||
export async function updateSkills(targets?: SkillTargets): Promise<SkillsStatus> {
|
||||
return applySkills(targets ?? resolveSkillTargets());
|
||||
}
|
||||
|
||||
export async function uninstallSkills(targets?: SkillTargets): Promise<SkillsStatus> {
|
||||
const t = targets ?? resolveSkillTargets();
|
||||
for (const name of PASEO_SKILL_NAMES) {
|
||||
await removeSkill(name, {
|
||||
agentsDir: t.agentsDir,
|
||||
claudeDir: t.claudeDir,
|
||||
codexDir: t.codexDir,
|
||||
});
|
||||
}
|
||||
return getSkillsStatus(t);
|
||||
}
|
||||
22
packages/desktop/src/integrations/skills/paths.ts
Normal file
22
packages/desktop/src/integrations/skills/paths.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { app } from "electron";
|
||||
|
||||
export function getBundledSkillsDir(): string {
|
||||
if (app.isPackaged) {
|
||||
return path.join(process.resourcesPath, "skills");
|
||||
}
|
||||
return path.join(__dirname, "..", "..", "..", "..", "..", "skills");
|
||||
}
|
||||
|
||||
export function getAgentsSkillsDir(): string {
|
||||
return path.join(os.homedir(), ".agents", "skills");
|
||||
}
|
||||
|
||||
export function getClaudeSkillsDir(): string {
|
||||
return path.join(os.homedir(), ".claude", "skills");
|
||||
}
|
||||
|
||||
export function getCodexSkillsDir(): string {
|
||||
return path.join(os.homedir(), ".codex", "skills");
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { syncSkills } from "./skill-sync";
|
||||
import { removeSkill, syncSkills } from "./sync";
|
||||
|
||||
interface Sandbox {
|
||||
root: string;
|
||||
@@ -70,6 +70,11 @@ describe("syncSkills", () => {
|
||||
"utf-8",
|
||||
);
|
||||
expect(agentsContent).toBe("new paseo content");
|
||||
const claudeContent = await fs.readFile(
|
||||
path.join(sandbox.claudeDir, "paseo", "SKILL.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(claudeContent).toBe("new paseo content");
|
||||
const codexContent = await fs.readFile(
|
||||
path.join(sandbox.codexDir, "paseo", "SKILL.md"),
|
||||
"utf-8",
|
||||
@@ -107,10 +112,10 @@ describe("syncSkills", () => {
|
||||
),
|
||||
).toBe("roles content");
|
||||
|
||||
const claudeLink = path.join(sandbox.claudeDir, "paseo-epic");
|
||||
const lstat = await fs.lstat(claudeLink);
|
||||
expect(lstat.isSymbolicLink()).toBe(true);
|
||||
expect(await fs.readFile(path.join(claudeLink, "references", "roles.md"), "utf-8")).toBe(
|
||||
const claudeSkillDir = path.join(sandbox.claudeDir, "paseo-epic");
|
||||
expect((await fs.lstat(claudeSkillDir)).isDirectory()).toBe(true);
|
||||
expect(await fs.readFile(path.join(claudeSkillDir, "SKILL.md"), "utf-8")).toBe("epic content");
|
||||
expect(await fs.readFile(path.join(claudeSkillDir, "references", "roles.md"), "utf-8")).toBe(
|
||||
"roles content",
|
||||
);
|
||||
});
|
||||
@@ -254,3 +259,46 @@ describe("syncSkills", () => {
|
||||
expect(result.processedSkills).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeSkill", () => {
|
||||
let sandbox: Sandbox;
|
||||
|
||||
beforeEach(async () => {
|
||||
sandbox = await makeSandbox();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(sandbox.root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("removes the skill from all three targets when present", async () => {
|
||||
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "content" });
|
||||
await syncSkills({
|
||||
sourceDir: sandbox.sourceDir,
|
||||
agentsDir: sandbox.agentsDir,
|
||||
claudeDir: sandbox.claudeDir,
|
||||
codexDir: sandbox.codexDir,
|
||||
skillNames: ["paseo"],
|
||||
});
|
||||
|
||||
await removeSkill("paseo", {
|
||||
agentsDir: sandbox.agentsDir,
|
||||
claudeDir: sandbox.claudeDir,
|
||||
codexDir: sandbox.codexDir,
|
||||
});
|
||||
|
||||
await expect(fs.access(path.join(sandbox.agentsDir, "paseo"))).rejects.toThrow();
|
||||
await expect(fs.access(path.join(sandbox.claudeDir, "paseo"))).rejects.toThrow();
|
||||
await expect(fs.access(path.join(sandbox.codexDir, "paseo"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("does not throw when targets are missing", async () => {
|
||||
await expect(
|
||||
removeSkill("does-not-exist", {
|
||||
agentsDir: sandbox.agentsDir,
|
||||
claudeDir: sandbox.claudeDir,
|
||||
codexDir: sandbox.codexDir,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,6 @@ export interface SkillSyncOptions {
|
||||
claudeDir: string;
|
||||
codexDir: string;
|
||||
skillNames: readonly string[];
|
||||
platform?: NodeJS.Platform;
|
||||
onSkillError?: (skillName: string, error: unknown) => void;
|
||||
}
|
||||
|
||||
@@ -25,7 +24,7 @@ async function writeFileIfChanged(srcPath: string, dstPath: string): Promise<boo
|
||||
return true;
|
||||
}
|
||||
|
||||
async function listFilesRecursive(rootDir: string): Promise<string[]> {
|
||||
export async function listFilesRecursive(rootDir: string): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
async function walk(dir: string): Promise<void> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
@@ -53,38 +52,24 @@ async function syncDirectoryFiles(srcDir: string, dstDir: string): Promise<numbe
|
||||
return changed;
|
||||
}
|
||||
|
||||
async function ensureClaudeSkillLink(
|
||||
skillName: string,
|
||||
agentsDir: string,
|
||||
claudeDir: string,
|
||||
platform: NodeJS.Platform,
|
||||
): Promise<number> {
|
||||
await fs.mkdir(claudeDir, { recursive: true });
|
||||
const target = path.join(agentsDir, skillName);
|
||||
const linkPath = path.join(claudeDir, skillName);
|
||||
export interface RemoveSkillTargets {
|
||||
agentsDir: string;
|
||||
claudeDir: string;
|
||||
codexDir: string;
|
||||
}
|
||||
|
||||
// Always rebuild the link rather than diffing it. fs.rm with force: true is
|
||||
// a no-op when nothing is there, and matches existing install behavior.
|
||||
// On Windows, `fs.rm` does not follow junctions, so the agents-side content
|
||||
// is preserved.
|
||||
await fs.rm(linkPath, { recursive: true, force: true });
|
||||
|
||||
if (platform === "win32") {
|
||||
try {
|
||||
// Junctions don't require Developer Mode / admin like regular symlinks do.
|
||||
await fs.symlink(target, linkPath, "junction");
|
||||
return 0;
|
||||
} catch {
|
||||
return await syncDirectoryFiles(target, linkPath);
|
||||
}
|
||||
export async function removeSkill(skillName: string, targets: RemoveSkillTargets): Promise<void> {
|
||||
const paths = [
|
||||
path.join(targets.agentsDir, skillName),
|
||||
path.join(targets.claudeDir, skillName),
|
||||
path.join(targets.codexDir, skillName),
|
||||
];
|
||||
for (const p of paths) {
|
||||
await fs.rm(p, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
await fs.symlink(target, linkPath);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncResult> {
|
||||
const platform = options.platform ?? process.platform;
|
||||
let changedFiles = 0;
|
||||
let processedSkills = 0;
|
||||
|
||||
@@ -100,11 +85,9 @@ export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncRe
|
||||
path.join(options.agentsDir, skillName),
|
||||
);
|
||||
|
||||
changedFiles += await ensureClaudeSkillLink(
|
||||
skillName,
|
||||
options.agentsDir,
|
||||
options.claudeDir,
|
||||
platform,
|
||||
changedFiles += await syncDirectoryFiles(
|
||||
bundleSkillDir,
|
||||
path.join(options.claudeDir, skillName),
|
||||
);
|
||||
|
||||
changedFiles += await syncDirectoryFiles(
|
||||
@@ -114,7 +97,8 @@ export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncRe
|
||||
|
||||
processedSkills++;
|
||||
} catch (error) {
|
||||
options.onSkillError?.(skillName, error);
|
||||
if (!options.onSkillError) throw error;
|
||||
options.onSkillError(skillName, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,6 @@ import {
|
||||
createBeforeQuitHandler,
|
||||
stopDesktopManagedDaemonOnQuitIfNeeded,
|
||||
} from "./daemon/quit-lifecycle.js";
|
||||
import { autoUpdateSkillsIfInstalled } from "./integrations/integrations-manager.js";
|
||||
import { runDesktopStartup } from "./desktop-startup.js";
|
||||
|
||||
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
|
||||
@@ -661,10 +660,6 @@ async function bootstrap(): Promise<void> {
|
||||
registerNotificationHandlers();
|
||||
registerOpenerHandlers();
|
||||
|
||||
void autoUpdateSkillsIfInstalled().catch((error) => {
|
||||
log.warn("[integrations] auto-update skills failed", error);
|
||||
});
|
||||
|
||||
await createMainWindow();
|
||||
|
||||
app.on("activate", async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.70-beta.1",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.70-beta.1",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.70-beta.1",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.70-beta.1",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -58,8 +58,8 @@
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@getpaseo/highlight": "0.1.69",
|
||||
"@getpaseo/relay": "0.1.69",
|
||||
"@getpaseo/highlight": "0.1.70-beta.1",
|
||||
"@getpaseo/relay": "0.1.70-beta.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@mariozechner/pi-agent-core": "^0.70.2",
|
||||
"@mariozechner/pi-ai": "^0.70.2",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { type ChildProcess } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import type {
|
||||
PermissionOption,
|
||||
PromptResponse,
|
||||
@@ -23,6 +25,7 @@ import { transformPiModels } from "./pi-direct-agent.js";
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { asInternals } from "../../test-utils/class-mocks.js";
|
||||
import * as spawnUtils from "../../../utils/spawn.js";
|
||||
|
||||
interface ACPSessionInternals {
|
||||
sessionId: string | null;
|
||||
@@ -114,6 +117,14 @@ function createSessionWithConfig(
|
||||
);
|
||||
}
|
||||
|
||||
function createTerminalChildStub(): ChildProcess {
|
||||
const child = new EventEmitter() as ChildProcess;
|
||||
child.stdout = new EventEmitter() as ChildProcess["stdout"];
|
||||
child.stderr = new EventEmitter() as ChildProcess["stderr"];
|
||||
child.kill = vi.fn(() => true) as ChildProcess["kill"];
|
||||
return child;
|
||||
}
|
||||
|
||||
function selectConfigOption(
|
||||
category: "mode" | "model" | "thought_level",
|
||||
values: string[],
|
||||
@@ -303,6 +314,78 @@ describe("createLoggedNdJsonStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACPAgentSession terminal tools", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("runs single-string terminal commands through the platform shell", async () => {
|
||||
const child = createTerminalChildStub();
|
||||
const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
|
||||
const session = createSession();
|
||||
const shell = spawnUtils.platformShell();
|
||||
|
||||
await session.createTerminal({
|
||||
sessionId: "session-1",
|
||||
command: "git -C /repo status --short",
|
||||
cwd: "/repo",
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
shell.command,
|
||||
[...shell.flag, "git -C /repo status --short"],
|
||||
expect.objectContaining({ cwd: "/repo" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves explicit terminal argv", async () => {
|
||||
const child = createTerminalChildStub();
|
||||
const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
|
||||
const session = createSession();
|
||||
|
||||
await session.createTerminal({
|
||||
sessionId: "session-1",
|
||||
command: "git",
|
||||
args: ["status", "--short"],
|
||||
cwd: "/repo",
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
"git",
|
||||
["status", "--short"],
|
||||
expect.objectContaining({ cwd: "/repo" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("surfaces spawn errors through terminal output and waitForTerminalExit", async () => {
|
||||
const child = createTerminalChildStub();
|
||||
vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
|
||||
const session = createSession();
|
||||
|
||||
const terminal = await session.createTerminal({
|
||||
sessionId: "session-1",
|
||||
command: "missing-command",
|
||||
});
|
||||
child.emit("error", new Error("spawn missing-command ENOENT"));
|
||||
|
||||
await expect(
|
||||
session.waitForTerminalExit({
|
||||
sessionId: "session-1",
|
||||
terminalId: terminal.terminalId,
|
||||
}),
|
||||
).rejects.toThrow("spawn missing-command ENOENT");
|
||||
await expect(
|
||||
session.terminalOutput({
|
||||
sessionId: "session-1",
|
||||
terminalId: terminal.terminalId,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
output: "spawn missing-command ENOENT\n",
|
||||
truncated: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapACPUsage", () => {
|
||||
test("maps ACP usage fields into Paseo usage", () => {
|
||||
expect(
|
||||
|
||||
@@ -92,7 +92,7 @@ import {
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
|
||||
import { findExecutable } from "../../../utils/executable.js";
|
||||
import { spawnProcess } from "../../../utils/spawn.js";
|
||||
import { platformShell, spawnProcess } from "../../../utils/spawn.js";
|
||||
|
||||
function assertChildWithPipes(
|
||||
child: ChildProcess,
|
||||
@@ -106,6 +106,22 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function resolveTerminalCommand(
|
||||
command: string,
|
||||
args?: string[],
|
||||
): { command: string; args: string[] } {
|
||||
if (args && args.length > 0) {
|
||||
return { command, args };
|
||||
}
|
||||
|
||||
if (!/\s/.test(command.trim())) {
|
||||
return { command, args: [] };
|
||||
}
|
||||
|
||||
const shell = platformShell();
|
||||
return { command: shell.command, args: [...shell.flag, command] };
|
||||
}
|
||||
|
||||
const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
@@ -1464,7 +1480,8 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
const env = Object.fromEntries(
|
||||
(params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value]),
|
||||
);
|
||||
const child = spawnProcess(params.command, params.args ?? [], {
|
||||
const terminalCommand = resolveTerminalCommand(params.command, params.args);
|
||||
const child = spawnProcess(terminalCommand.command, terminalCommand.args, {
|
||||
cwd: params.cwd ?? this.config.cwd,
|
||||
...createProviderEnvSpec({
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
@@ -1479,6 +1496,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
resolveExit = resolve;
|
||||
rejectExit = reject;
|
||||
});
|
||||
waitForExit.catch(() => undefined);
|
||||
|
||||
const entry: TerminalEntry = {
|
||||
id: terminalId,
|
||||
@@ -1498,9 +1516,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
child.stderr!.on("data", (chunk: Buffer | string) =>
|
||||
appendTerminalOutput(entry, chunk.toString()),
|
||||
);
|
||||
child.once("error", (error) =>
|
||||
rejectExit(error instanceof Error ? error : new Error(String(error))),
|
||||
);
|
||||
child.once("error", (error) => {
|
||||
const spawnError = error instanceof Error ? error : new Error(String(error));
|
||||
appendTerminalOutput(entry, `${spawnError.message}\n`);
|
||||
rejectExit(spawnError);
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
const exit = { exitCode: code, signal };
|
||||
entry.exit = exit;
|
||||
|
||||
@@ -10,6 +10,7 @@ describe("getClaudeModels", () => {
|
||||
"claude-opus-4-7",
|
||||
"claude-opus-4-6[1m]",
|
||||
"claude-opus-4-6",
|
||||
"claude-sonnet-4-6[1m]",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-haiku-4-5",
|
||||
]);
|
||||
|
||||
@@ -45,6 +45,13 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [
|
||||
isDefault: true,
|
||||
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-sonnet-4-6[1m]",
|
||||
label: "Sonnet 4.6 1M",
|
||||
description: "Sonnet 4.6 with 1M context window",
|
||||
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-sonnet-4-6",
|
||||
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
||||
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
|
||||
import { withTimeout } from "../../../utils/promise-timeout.js";
|
||||
import { spawnProcess } from "../../../utils/spawn.js";
|
||||
import { execCommand, spawnProcess } from "../../../utils/spawn.js";
|
||||
import { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js";
|
||||
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
|
||||
import {
|
||||
@@ -1228,6 +1228,20 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
serverStatus = `Unavailable (${toDiagnosticErrorMessage(error)})`;
|
||||
}
|
||||
|
||||
let authValue = "Not checked";
|
||||
if (resolvedBinary) {
|
||||
try {
|
||||
const { stdout, stderr } = await execCommand(resolvedBinary, ["auth", "list"], {
|
||||
...createProviderEnvSpec(),
|
||||
timeout: 5_000,
|
||||
});
|
||||
const text = (stdout.trim() || stderr.trim()).trim();
|
||||
authValue = text ? `\n ${text.replace(/\n/g, "\n ")}` : "(empty)";
|
||||
} catch (error) {
|
||||
authValue = `Error - ${toDiagnosticErrorMessage(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (available) {
|
||||
try {
|
||||
const models = await this.listModels({ cwd: homedir(), force: false });
|
||||
@@ -1263,6 +1277,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown",
|
||||
},
|
||||
{ label: "Server", value: serverStatus },
|
||||
{ label: "Auth", value: authValue },
|
||||
{ label: "Models", value: modelsValue },
|
||||
{ label: "Status", value: status },
|
||||
]),
|
||||
|
||||
@@ -3007,18 +3007,11 @@ export class Session {
|
||||
if (!resolvedWorkspace) {
|
||||
throw new Error(`Workspace not found: ${msg.workspaceId}`);
|
||||
}
|
||||
const snapshot = await this.agentManager.createAgent(
|
||||
{
|
||||
...sessionConfig,
|
||||
cwd: resolvedWorkspace.cwd,
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
labels,
|
||||
workspaceId: resolvedWorkspace.workspaceId,
|
||||
initialPrompt: trimmedPrompt,
|
||||
},
|
||||
);
|
||||
const snapshot = await this.agentManager.createAgent(sessionConfig, undefined, {
|
||||
labels,
|
||||
workspaceId: resolvedWorkspace.workspaceId,
|
||||
initialPrompt: trimmedPrompt,
|
||||
});
|
||||
await this.forwardAgentUpdate(snapshot);
|
||||
|
||||
await this.sendInitialCreateAgentPrompt({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test, vi } from "vitest";
|
||||
@@ -10,6 +10,18 @@ import type {
|
||||
EditorTargetDescriptorPayload,
|
||||
SessionOutboundMessage,
|
||||
} from "../shared/messages.js";
|
||||
import { AgentManager } from "./agent/agent-manager.js";
|
||||
import { AgentStorage } from "./agent/agent-storage.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentCreateSessionOptions,
|
||||
AgentLaunchContext,
|
||||
AgentPersistenceHandle,
|
||||
AgentRunResult,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
} from "./agent/agent-sdk-types.js";
|
||||
import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js";
|
||||
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
|
||||
import {
|
||||
@@ -30,6 +42,8 @@ import {
|
||||
findByType,
|
||||
} from "./test-utils/session-stubs.js";
|
||||
import {
|
||||
FileBackedProjectRegistry,
|
||||
FileBackedWorkspaceRegistry,
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
} from "./workspace-registry.js";
|
||||
@@ -274,6 +288,101 @@ function createWorkspaceRuntimeSnapshot(
|
||||
};
|
||||
}
|
||||
|
||||
const CREATE_AGENT_TEST_CAPABILITIES = {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
} as const;
|
||||
|
||||
class CreateAgentTestSession implements AgentSession {
|
||||
readonly provider = "codex";
|
||||
readonly id = "create-agent-test-session";
|
||||
readonly capabilities = CREATE_AGENT_TEST_CAPABILITIES;
|
||||
|
||||
constructor(private readonly config: AgentSessionConfig) {}
|
||||
|
||||
async run(): Promise<AgentRunResult> {
|
||||
return { sessionId: this.id, finalText: "", timeline: [] };
|
||||
}
|
||||
|
||||
async startTurn(): Promise<{ turnId: string }> {
|
||||
return { turnId: "turn-1" };
|
||||
}
|
||||
|
||||
subscribe(): () => void {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
|
||||
|
||||
async getRuntimeInfo() {
|
||||
return {
|
||||
provider: this.provider,
|
||||
sessionId: this.id,
|
||||
model: this.config.model ?? null,
|
||||
modeId: this.config.modeId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async getAvailableModes() {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getCurrentMode() {
|
||||
return null;
|
||||
}
|
||||
|
||||
async setMode(): Promise<void> {}
|
||||
|
||||
getPendingPermissions() {
|
||||
return [];
|
||||
}
|
||||
|
||||
async respondToPermission(): Promise<void> {}
|
||||
|
||||
describePersistence(): AgentPersistenceHandle {
|
||||
return { provider: this.provider, sessionId: this.id };
|
||||
}
|
||||
|
||||
async interrupt(): Promise<void> {}
|
||||
|
||||
async close(): Promise<void> {}
|
||||
}
|
||||
|
||||
class CreateAgentTestClient implements AgentClient {
|
||||
readonly provider = "codex";
|
||||
readonly capabilities = CREATE_AGENT_TEST_CAPABILITIES;
|
||||
|
||||
async createSession(
|
||||
config: AgentSessionConfig,
|
||||
_launchContext?: AgentLaunchContext,
|
||||
_options?: AgentCreateSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
return new CreateAgentTestSession(config);
|
||||
}
|
||||
|
||||
async resumeSession(
|
||||
_handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
): Promise<AgentSession> {
|
||||
return new CreateAgentTestSession({
|
||||
provider: this.provider,
|
||||
cwd: overrides?.cwd ?? process.cwd(),
|
||||
});
|
||||
}
|
||||
|
||||
async listModels() {
|
||||
return [{ provider: this.provider, id: "gpt-test", label: "GPT Test", isDefault: true }];
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function createSessionForWorkspaceTests(
|
||||
options: {
|
||||
appVersion?: string | null;
|
||||
@@ -361,6 +470,135 @@ function createSessionForWorkspaceTests(
|
||||
return session;
|
||||
}
|
||||
|
||||
test("create_agent_request keeps requested child cwd when grouped under an existing parent workspace", async () => {
|
||||
const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-cwd-"));
|
||||
try {
|
||||
const parent = path.join(workdir, "parent");
|
||||
const child = path.join(parent, "child");
|
||||
mkdirSync(child, { 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-000000000551",
|
||||
});
|
||||
const projectRegistry = new FileBackedProjectRegistry(
|
||||
path.join(workdir, "projects.json"),
|
||||
asSessionLogger(logger),
|
||||
);
|
||||
const workspaceRegistry = new FileBackedWorkspaceRegistry(
|
||||
path.join(workdir, "workspaces.json"),
|
||||
asSessionLogger(logger),
|
||||
);
|
||||
const workspaceGitService = createNoopWorkspaceGitService({
|
||||
getCheckout: async (cwd: string) => ({
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: null,
|
||||
worktreeRoot: parent,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
}),
|
||||
});
|
||||
|
||||
await projectRegistry.upsert(
|
||||
createPersistedProjectRecord({
|
||||
projectId: "proj-parent",
|
||||
rootPath: parent,
|
||||
kind: "git",
|
||||
displayName: "parent",
|
||||
createdAt: "2026-05-07T00:00:00.000Z",
|
||||
updatedAt: "2026-05-07T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
await workspaceRegistry.upsert(
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-parent",
|
||||
projectId: "proj-parent",
|
||||
cwd: parent,
|
||||
kind: "local_checkout",
|
||||
displayName: "parent",
|
||||
createdAt: "2026-05-07T00:00:00.000Z",
|
||||
updatedAt: "2026-05-07T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = asTestSession(
|
||||
new Session({
|
||||
clientId: "test-client",
|
||||
appVersion: null,
|
||||
onMessage: (message) => emitted.push(message),
|
||||
logger: asSessionLogger(logger),
|
||||
downloadTokenStore: asDownloadTokenStore(),
|
||||
pushTokenStore: asPushTokenStore(),
|
||||
paseoHome: path.join(workdir, "paseo-home"),
|
||||
agentManager,
|
||||
agentStorage,
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
chatService: asChatService(),
|
||||
scheduleService: asScheduleService(),
|
||||
loopService: asLoopService(),
|
||||
checkoutDiffManager: asCheckoutDiffManager({
|
||||
subscribe: async () => ({
|
||||
initial: { cwd: child, files: [], error: null },
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
scheduleRefreshForCwd: () => {},
|
||||
getMetrics: () => ({
|
||||
checkoutDiffTargetCount: 0,
|
||||
checkoutDiffSubscriptionCount: 0,
|
||||
checkoutDiffWatcherCount: 0,
|
||||
checkoutDiffFallbackRefreshTargetCount: 0,
|
||||
}),
|
||||
dispose: () => {},
|
||||
}),
|
||||
workspaceGitService,
|
||||
daemonConfigStore: asDaemonConfigStore({
|
||||
get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }),
|
||||
onChange: () => () => {},
|
||||
}),
|
||||
mcpBaseUrl: null,
|
||||
stt: null,
|
||||
tts: null,
|
||||
terminalManager: null,
|
||||
}),
|
||||
);
|
||||
|
||||
await session.handleMessage({
|
||||
type: "create_agent_request",
|
||||
requestId: "req-create-child",
|
||||
config: { provider: "codex", cwd: child },
|
||||
attachments: [],
|
||||
});
|
||||
|
||||
const [createdAgent] = agentManager.listAgents();
|
||||
expect(createdAgent?.cwd).toBe(child);
|
||||
await expect(session.buildProjectPlacementForCwd(createdAgent.cwd)).resolves.toMatchObject({
|
||||
projectKey: "proj-parent",
|
||||
checkout: { cwd: parent },
|
||||
});
|
||||
expect(findByType(emitted, "status")?.payload).toMatchObject({
|
||||
status: "agent_created",
|
||||
agent: { cwd: child },
|
||||
});
|
||||
} finally {
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("unsupported persisted agents are excluded from active lists but preserved in history payloads", async () => {
|
||||
const session = createSessionForWorkspaceTests({ appVersion: "0.1.45" });
|
||||
const storedRecord = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
@@ -11,11 +12,13 @@ import {
|
||||
import {
|
||||
getCheckoutDiff as getCheckoutDiffUncached,
|
||||
getCheckoutStatus as getCheckoutStatusUncached,
|
||||
resolveAbsoluteGitDir as resolveAbsoluteGitDirReal,
|
||||
type CheckoutDiffCompare,
|
||||
type CheckoutDiffResult,
|
||||
type CheckoutStatusGit,
|
||||
type PullRequestStatusResult,
|
||||
} from "../utils/checkout-git.js";
|
||||
import { runGitCommand as runGitCommandReal } from "../utils/run-git-command.js";
|
||||
import {
|
||||
WorkspaceGitServiceImpl,
|
||||
type WorkspaceGitRuntimeSnapshot,
|
||||
@@ -207,6 +210,7 @@ interface CreateServiceOptions {
|
||||
runGitFetch?: ReturnType<typeof vi.fn>;
|
||||
runGitCommand?: ReturnType<typeof vi.fn>;
|
||||
watch?: ReturnType<typeof vi.fn>;
|
||||
readdir?: ReturnType<typeof vi.fn>;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
@@ -1454,4 +1458,48 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("Linux working tree walker excludes gitignored directories", async () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: "linux" });
|
||||
|
||||
const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "workspace-git-service-ignored-")));
|
||||
const repoDir = join(tempDir, "repo");
|
||||
mkdirSync(join(repoDir, "ignored", "deep"), { recursive: true });
|
||||
mkdirSync(join(repoDir, "kept"), { recursive: true });
|
||||
execSync("git init -b main", { cwd: repoDir, stdio: "pipe" });
|
||||
writeFileSync(join(repoDir, ".gitignore"), "ignored/\n");
|
||||
writeFileSync(join(repoDir, "ignored", "log.txt"), "noise\n");
|
||||
writeFileSync(join(repoDir, "ignored", "deep", "log.txt"), "noise\n");
|
||||
writeFileSync(join(repoDir, "kept", "file.txt"), "keep\n");
|
||||
|
||||
const watchedPaths: string[] = [];
|
||||
const watchSpy = (watchPath: string) => {
|
||||
watchedPaths.push(watchPath);
|
||||
return { close: vi.fn(), on: vi.fn().mockReturnThis() };
|
||||
};
|
||||
|
||||
const service = createService({
|
||||
watch: watchSpy as never,
|
||||
readdir: readdir as never,
|
||||
runGitCommand: runGitCommandReal as never,
|
||||
getCheckoutStatus: getCheckoutStatusUncached as never,
|
||||
resolveAbsoluteGitDir: resolveAbsoluteGitDirReal as never,
|
||||
});
|
||||
|
||||
try {
|
||||
const subscription = await service.requestWorkingTreeWatch(repoDir, vi.fn());
|
||||
|
||||
const ignoredRoot = join(repoDir, "ignored");
|
||||
expect(watchedPaths.filter((path) => path.startsWith(ignoredRoot))).toEqual([]);
|
||||
expect(watchedPaths).toContain(repoDir);
|
||||
expect(watchedPaths).toContain(join(repoDir, "kept"));
|
||||
|
||||
subscription.unsubscribe();
|
||||
} finally {
|
||||
service.dispose();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,9 @@ const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_000;
|
||||
const WORKSPACE_GIT_CONSUMER_TTL_MS = 15_000;
|
||||
// Non-forced refresh triggers share this minimum gap to absorb watcher/self-heal bursts; force bypasses it.
|
||||
const WORKSPACE_GIT_INTERNAL_MIN_GAP_MS = 2_000;
|
||||
const LINUX_WATCH_MAX_DIRS = 5_000;
|
||||
const LINUX_WATCH_REFRESH_COOLDOWN_MS = 2_000;
|
||||
const LINUX_WATCH_IGNORE_TTL_MS = 5 * 60 * 1_000;
|
||||
|
||||
const linuxWatchReaddirConcurrency =
|
||||
parseInt(process.env.PASEO_LINUX_WATCH_READDIR_CONCURRENCY ?? "16", 10) || 16;
|
||||
@@ -323,6 +326,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
private readonly repoTargets = new Map<string, RepoGitTarget>();
|
||||
private readonly workingTreeWatchTargets = new Map<string, WorkingTreeWatchTarget>();
|
||||
private readonly workingTreeWatchSetups = new Map<string, Promise<WorkingTreeWatchTarget>>();
|
||||
private readonly linuxIgnoredDirsCache = new Map<string, { ignored: Set<string>; ts: number }>();
|
||||
private readonly branchValidationCache = new Map<
|
||||
string,
|
||||
WorkspaceGitAuxiliaryReadCacheEntry<WorkspaceGitBranchValidationResult>
|
||||
@@ -1195,6 +1199,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
"Failed to refresh Linux working tree watchers",
|
||||
);
|
||||
}
|
||||
if (target.linuxTreeRefreshQueued) {
|
||||
await new Promise((r) => setTimeout(r, LINUX_WATCH_REFRESH_COOLDOWN_MS));
|
||||
}
|
||||
} while (target.linuxTreeRefreshQueued);
|
||||
})();
|
||||
|
||||
@@ -1206,11 +1213,17 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}
|
||||
|
||||
private async listLinuxWatchDirectories(rootPath: string): Promise<string[]> {
|
||||
const ignored = await this.loadLinuxIgnoredDirs(rootPath);
|
||||
const directories: string[] = [];
|
||||
let currentLevel: string[] = [rootPath];
|
||||
let capped = false;
|
||||
|
||||
while (currentLevel.length > 0) {
|
||||
directories.push(...currentLevel);
|
||||
if (directories.length >= LINUX_WATCH_MAX_DIRS) {
|
||||
capped = true;
|
||||
break;
|
||||
}
|
||||
const readResults = await Promise.all(
|
||||
currentLevel.map((directory) =>
|
||||
linuxWatchReaddirLimit(async () => {
|
||||
@@ -1231,15 +1244,59 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
if (!entry.isDirectory() || entry.name === ".git") {
|
||||
continue;
|
||||
}
|
||||
nextLevel.push(join(directory, entry.name));
|
||||
const childPath = join(directory, entry.name);
|
||||
if (ignored.has(childPath)) {
|
||||
continue;
|
||||
}
|
||||
nextLevel.push(childPath);
|
||||
}
|
||||
}
|
||||
currentLevel = nextLevel;
|
||||
}
|
||||
|
||||
if (capped) {
|
||||
this.logger.warn(
|
||||
{ rootPath, limit: LINUX_WATCH_MAX_DIRS, walked: directories.length },
|
||||
"Linux working tree exceeds watcher cap; skipping deeper directories",
|
||||
);
|
||||
}
|
||||
|
||||
return directories;
|
||||
}
|
||||
|
||||
private async loadLinuxIgnoredDirs(rootPath: string): Promise<Set<string>> {
|
||||
const cached = this.linuxIgnoredDirsCache.get(rootPath);
|
||||
if (cached && Date.now() - cached.ts < LINUX_WATCH_IGNORE_TTL_MS) {
|
||||
return cached.ignored;
|
||||
}
|
||||
|
||||
const ignored = new Set<string>();
|
||||
try {
|
||||
const result = await this.deps.runGitCommand(
|
||||
["ls-files", "-o", "-i", "--directory", "--exclude-standard"],
|
||||
{ cwd: rootPath, env: READ_ONLY_GIT_ENV },
|
||||
);
|
||||
for (const raw of result.stdout.split("\n")) {
|
||||
if (!raw.endsWith("/")) {
|
||||
continue;
|
||||
}
|
||||
const rel = raw.replace(/\/+$/, "");
|
||||
if (!rel) {
|
||||
continue;
|
||||
}
|
||||
ignored.add(resolve(rootPath, rel));
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.debug(
|
||||
{ err: error, rootPath },
|
||||
"Failed to load gitignore directories; falling back to name-based skip only",
|
||||
);
|
||||
}
|
||||
|
||||
this.linuxIgnoredDirsCache.set(rootPath, { ignored, ts: Date.now() });
|
||||
return ignored;
|
||||
}
|
||||
|
||||
private async refreshWorkspaceTarget(
|
||||
target: WorkspaceGitTarget,
|
||||
request: WorkspaceGitRefreshRequest,
|
||||
@@ -1587,6 +1644,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
target.watchers = [];
|
||||
target.watchedPaths.clear();
|
||||
target.listeners.clear();
|
||||
if (target.repoWatchPath) {
|
||||
this.linuxIgnoredDirsCache.delete(target.repoWatchPath);
|
||||
}
|
||||
}
|
||||
|
||||
private closeRepoTarget(target: RepoGitTarget): void {
|
||||
|
||||
@@ -1698,6 +1698,44 @@ describe("GitHubService", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats a GitHub issue or PR URL as a search for that number", async () => {
|
||||
const runner = createRunner([issueJson("Issue title"), searchPullRequestJson("PR title")]);
|
||||
const service = createGitHubService({
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
now: () => 100,
|
||||
});
|
||||
|
||||
await service.searchIssuesAndPrs({
|
||||
cwd: "/repo",
|
||||
query: "https://github.com/getpaseo/paseo/pull/793",
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
expect(runner.calls.map((call) => call.args)).toEqual([
|
||||
[
|
||||
"issue",
|
||||
"list",
|
||||
"--search",
|
||||
"793",
|
||||
"--json",
|
||||
"number,title,url,state,body,labels,updatedAt",
|
||||
"--limit",
|
||||
"5",
|
||||
],
|
||||
[
|
||||
"pr",
|
||||
"list",
|
||||
"--search",
|
||||
"793",
|
||||
"--json",
|
||||
"number,title,url,state,body,labels,baseRefName,headRefName,updatedAt",
|
||||
"--limit",
|
||||
"5",
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("searches only GitHub PRs when the search kinds request excludes issues", async () => {
|
||||
const runner = createRunner([searchPullRequestJson("PR title")]);
|
||||
const service = createGitHubService({
|
||||
|
||||
@@ -933,11 +933,12 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
const readOptions: GitHubReadOptions = input.force
|
||||
? { force: true, reason: input.reason }
|
||||
: { force: false, reason: input.reason };
|
||||
const query = normalizeGitHubSearchQuery(input.query);
|
||||
const [issuesResult, prsResult] = await Promise.allSettled([
|
||||
shouldFetchIssues
|
||||
? this.listIssues({
|
||||
cwd: input.cwd,
|
||||
query: input.query,
|
||||
query,
|
||||
limit: input.limit,
|
||||
...readOptions,
|
||||
})
|
||||
@@ -945,7 +946,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
shouldFetchPullRequests
|
||||
? this.listPullRequests({
|
||||
cwd: input.cwd,
|
||||
query: input.query,
|
||||
query,
|
||||
limit: input.limit,
|
||||
...readOptions,
|
||||
})
|
||||
@@ -1171,6 +1172,15 @@ async function runGhCommand(
|
||||
});
|
||||
}
|
||||
|
||||
const GITHUB_ISSUE_OR_PR_URL_PATTERN =
|
||||
/^https?:\/\/github\.com\/[^/\s]+\/[^/\s]+\/(?:pull|issues)\/(\d+)(?:[/?#].*)?$/i;
|
||||
|
||||
function normalizeGitHubSearchQuery(query: string): string {
|
||||
const trimmed = query.trim();
|
||||
const match = trimmed.match(GITHUB_ISSUE_OR_PR_URL_PATTERN);
|
||||
return match ? match[1] : query;
|
||||
}
|
||||
|
||||
function buildCacheKey(params: { cwd: string; method: string; args: unknown }): string {
|
||||
return `${params.cwd}:${params.method}:${stableStringify(params.args)}`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.70-beta.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user