mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d1ba7e3f3 |
@@ -1,56 +1,5 @@
|
|||||||
# Changelog
|
# 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
|
## 0.1.69 - 2026-05-05
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
Generated
+13
-13
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.70-beta.1",
|
"version": "0.1.69",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.70-beta.1",
|
"version": "0.1.69",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -38547,7 +38547,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.1.70-beta.1",
|
"version": "0.1.69",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
@@ -38673,10 +38673,10 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.70-beta.1",
|
"version": "0.1.69",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/server": "0.1.70-beta.1",
|
"@getpaseo/server": "0.1.69",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -38719,7 +38719,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.70-beta.1",
|
"version": "0.1.69",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "*",
|
"@getpaseo/cli": "*",
|
||||||
@@ -38768,7 +38768,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.1.70-beta.1",
|
"version": "0.1.69",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.0.25",
|
"@types/react": "^18.0.25",
|
||||||
@@ -38804,7 +38804,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.1.70-beta.1",
|
"version": "0.1.69",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lezer/common": "^1.5.0",
|
"@lezer/common": "^1.5.0",
|
||||||
"@lezer/cpp": "^1.1.5",
|
"@lezer/cpp": "^1.1.5",
|
||||||
@@ -38830,7 +38830,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.1.70-beta.1",
|
"version": "0.1.69",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -38845,12 +38845,12 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.1.70-beta.1",
|
"version": "0.1.69",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||||
"@getpaseo/highlight": "0.1.70-beta.1",
|
"@getpaseo/highlight": "0.1.69",
|
||||||
"@getpaseo/relay": "0.1.70-beta.1",
|
"@getpaseo/relay": "0.1.69",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@mariozechner/pi-agent-core": "^0.70.2",
|
"@mariozechner/pi-agent-core": "^0.70.2",
|
||||||
"@mariozechner/pi-ai": "^0.70.2",
|
"@mariozechner/pi-ai": "^0.70.2",
|
||||||
@@ -39304,7 +39304,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.1.70-beta.1",
|
"version": "0.1.69",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.20.3",
|
"@cloudflare/vite-plugin": "^1.20.3",
|
||||||
"@cloudflare/workers-types": "^4.20260114.0",
|
"@cloudflare/workers-types": "^4.20260114.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.70-beta.1",
|
"version": "0.1.69",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.1.70-beta.1",
|
"version": "0.1.69",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -67,22 +67,6 @@ describe("combined model selector helpers", () => {
|
|||||||
expect(matchesSearch(rows[1], "gpt-5.4")).toBe(true);
|
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", () => {
|
it("keeps the selected trigger label model-only", () => {
|
||||||
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
|
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
|
||||||
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
|
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
|
||||||
|
|||||||
@@ -48,10 +48,7 @@ export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): b
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const haystack = [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""]
|
return [row.modelLabel, row.modelId, row.providerLabel].some((value) =>
|
||||||
.join(" ")
|
value.toLowerCase().includes(normalizedQuery),
|
||||||
.toLowerCase();
|
);
|
||||||
|
|
||||||
const tokens = normalizedQuery.split(/\s+/).filter((token) => token.length > 0);
|
|
||||||
return tokens.every((token) => haystack.includes(token));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,34 +7,12 @@ import { settingsStyles } from "@/styles/settings";
|
|||||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
import {
|
import { useCliInstall, useSkillsInstall } from "@/desktop/hooks/use-install-status";
|
||||||
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 CLI_DOCS_URL = "https://paseo.sh/docs/cli";
|
||||||
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
|
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
|
||||||
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
|
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() {
|
export function IntegrationsSection() {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
@@ -47,18 +25,16 @@ export function IntegrationsSection() {
|
|||||||
} = useCliInstall();
|
} = useCliInstall();
|
||||||
const {
|
const {
|
||||||
status: skillsStatus,
|
status: skillsStatus,
|
||||||
isWorking: isSkillsWorking,
|
isInstalling: isInstallingSkills,
|
||||||
install: installSkills,
|
install: installSkills,
|
||||||
update: updateSkills,
|
|
||||||
uninstall: uninstallSkills,
|
|
||||||
refresh: refreshSkillsStatus,
|
refresh: refreshSkillsStatus,
|
||||||
} = useSkillsStatus();
|
} = useSkillsInstall();
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
if (!showSection) return undefined;
|
if (!showSection) return undefined;
|
||||||
refreshCliStatus();
|
refreshCliStatus();
|
||||||
void refreshSkillsStatus();
|
refreshSkillsStatus();
|
||||||
return undefined;
|
return undefined;
|
||||||
}, [refreshCliStatus, refreshSkillsStatus, showSection]),
|
}, [refreshCliStatus, refreshSkillsStatus, showSection]),
|
||||||
);
|
);
|
||||||
@@ -69,33 +45,9 @@ export function IntegrationsSection() {
|
|||||||
}, [installCli, isInstallingCli]);
|
}, [installCli, isInstallingCli]);
|
||||||
|
|
||||||
const handleInstallSkills = useCallback(() => {
|
const handleInstallSkills = useCallback(() => {
|
||||||
if (isSkillsWorking) return;
|
if (isInstallingSkills) return;
|
||||||
void installSkills();
|
installSkills();
|
||||||
}, [installSkills, isSkillsWorking]);
|
}, [installSkills, isInstallingSkills]);
|
||||||
|
|
||||||
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(() => {
|
const handleOpenCliDocs = useCallback(() => {
|
||||||
void openExternalUrl(CLI_DOCS_URL);
|
void openExternalUrl(CLI_DOCS_URL);
|
||||||
@@ -144,8 +96,6 @@ export function IntegrationsSection() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const skillsState = skillsStatus?.state ?? null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection title="Integrations" trailing={trailing}>
|
<SettingsSection title="Integrations" trailing={trailing}>
|
||||||
<View style={settingsStyles.card}>
|
<View style={settingsStyles.card}>
|
||||||
@@ -180,69 +130,30 @@ export function IntegrationsSection() {
|
|||||||
<Text style={settingsStyles.rowTitle}>Orchestration skills</Text>
|
<Text style={settingsStyles.rowTitle}>Orchestration skills</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={settingsStyles.rowHint}>
|
<Text style={settingsStyles.rowHint}>
|
||||||
{skillsState === "drift"
|
Teach your agents to orchestrate through the CLI
|
||||||
? "Update available"
|
|
||||||
: "Teach your agents to orchestrate through the CLI"}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<SkillsActions
|
{skillsStatus?.installed ? (
|
||||||
state={skillsState}
|
<View style={styles.installedLabel}>
|
||||||
isWorking={isSkillsWorking}
|
<Check size={14} color={theme.colors.foregroundMuted} />
|
||||||
onInstall={handleInstallSkills}
|
<Text style={styles.mutedText}>Installed</Text>
|
||||||
onUpdate={handleUpdateSkills}
|
</View>
|
||||||
onUninstall={handleUninstallSkills}
|
) : (
|
||||||
/>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onPress={handleInstallSkills}
|
||||||
|
disabled={isInstallingSkills}
|
||||||
|
>
|
||||||
|
{isInstallingSkills ? "Installing..." : "Install"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</SettingsSection>
|
</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) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
headerLinks: {
|
headerLinks: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -263,9 +174,4 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
},
|
||||||
actionsRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -220,67 +220,10 @@ export async function installCli(): Promise<InstallStatus> {
|
|||||||
return parseInstallStatus(await invokeDesktopCommand("install_cli"));
|
return parseInstallStatus(await invokeDesktopCommand("install_cli"));
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SkillsState = "not-installed" | "up-to-date" | "drift";
|
export async function getSkillsInstallStatus(): Promise<InstallStatus> {
|
||||||
|
return parseInstallStatus(await invokeDesktopCommand("get_skills_install_status"));
|
||||||
export type SkillOp =
|
|
||||||
| { kind: "add"; name: string }
|
|
||||||
| { kind: "update"; name: string }
|
|
||||||
| { kind: "delete"; name: string };
|
|
||||||
|
|
||||||
export interface SkillsStatus {
|
|
||||||
state: SkillsState;
|
|
||||||
ops: SkillOp[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSkillsState(value: unknown): SkillsState {
|
export async function installSkills(): Promise<InstallStatus> {
|
||||||
switch (value) {
|
return parseInstallStatus(await invokeDesktopCommand("install_skills"));
|
||||||
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 { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { useCliInstall, useSkillsStatus } from "./use-install-status";
|
import { useCliInstall, useSkillsInstall } from "./use-install-status";
|
||||||
|
|
||||||
const toast = vi.hoisted(() => ({
|
const toast = vi.hoisted(() => ({
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
@@ -15,11 +15,9 @@ const toast = vi.hoisted(() => ({
|
|||||||
|
|
||||||
const desktopDaemon = vi.hoisted(() => ({
|
const desktopDaemon = vi.hoisted(() => ({
|
||||||
getCliInstallStatus: vi.fn(),
|
getCliInstallStatus: vi.fn(),
|
||||||
|
getSkillsInstallStatus: vi.fn(),
|
||||||
installCli: vi.fn(),
|
installCli: vi.fn(),
|
||||||
getSkillsStatus: vi.fn(),
|
|
||||||
installSkills: vi.fn(),
|
installSkills: vi.fn(),
|
||||||
updateSkills: vi.fn(),
|
|
||||||
uninstallSkills: vi.fn(),
|
|
||||||
shouldUseDesktopDaemon: vi.fn(() => true),
|
shouldUseDesktopDaemon: vi.fn(() => true),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -91,9 +89,11 @@ describe("useCliInstall", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("useSkillsStatus", () => {
|
describe("useSkillsInstall", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
desktopDaemon.getSkillsInstallStatus.mockResolvedValue({ installed: true });
|
||||||
|
desktopDaemon.installSkills.mockResolvedValue({ installed: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -101,155 +101,34 @@ describe("useSkillsStatus", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("loads the current skills status", async () => {
|
it("loads skills install status", async () => {
|
||||||
desktopDaemon.getSkillsStatus.mockResolvedValue({
|
const { result } = renderDesktopHook(() => useSkillsInstall());
|
||||||
state: "up-to-date",
|
|
||||||
ops: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderDesktopHook(() => useSkillsStatus());
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.status).toEqual({ state: "up-to-date", ops: [] });
|
expect(result.current.status).toEqual({ installed: true });
|
||||||
});
|
});
|
||||||
expect(result.current.isWorking).toBe(false);
|
|
||||||
expect(toast.error).not.toHaveBeenCalled();
|
expect(toast.error).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("install transitions a not-installed status to up-to-date and reflects the response directly", async () => {
|
it("toasts and exposes skills install errors", 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?.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(() => {
|
|
||||||
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");
|
const error = new Error("Missing IPC handler");
|
||||||
desktopDaemon.getSkillsStatus.mockResolvedValue({
|
desktopDaemon.getSkillsInstallStatus.mockResolvedValue({ installed: false });
|
||||||
state: "not-installed",
|
|
||||||
ops: [{ kind: "add", name: "paseo" }],
|
|
||||||
});
|
|
||||||
desktopDaemon.installSkills.mockRejectedValue(error);
|
desktopDaemon.installSkills.mockRejectedValue(error);
|
||||||
|
const { result } = renderDesktopHook(() => useSkillsInstall());
|
||||||
const { result } = renderDesktopHook(() => useSkillsStatus());
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.status?.state).toBe("not-installed");
|
expect(result.current.status).toEqual({ installed: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
act(() => {
|
||||||
await result.current.install();
|
result.current.install();
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.error).toBe(error);
|
expect(result.current.error).toBe(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(toast.error).toHaveBeenCalledWith("Unable to install orchestration skills.");
|
expect(toast.error).toHaveBeenCalledWith("Unable to install orchestration skills.");
|
||||||
expect(console.error).toHaveBeenCalledWith("[Integrations] Failed to install skills", error);
|
expect(console.error).toHaveBeenCalledWith("[Integrations] Failed to install skills", error);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,14 +2,11 @@ import { useCallback } from "react";
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
getCliInstallStatus,
|
getCliInstallStatus,
|
||||||
getSkillsStatus,
|
getSkillsInstallStatus,
|
||||||
installCli,
|
installCli,
|
||||||
installSkills,
|
installSkills,
|
||||||
shouldUseDesktopDaemon,
|
shouldUseDesktopDaemon,
|
||||||
type InstallStatus,
|
type InstallStatus,
|
||||||
type SkillsStatus,
|
|
||||||
uninstallSkills,
|
|
||||||
updateSkills,
|
|
||||||
} from "@/desktop/daemon/desktop-daemon";
|
} from "@/desktop/daemon/desktop-daemon";
|
||||||
import {
|
import {
|
||||||
useDesktopIpcErrorReporter,
|
useDesktopIpcErrorReporter,
|
||||||
@@ -17,7 +14,11 @@ import {
|
|||||||
} from "@/desktop/hooks/desktop-ipc-error";
|
} from "@/desktop/hooks/desktop-ipc-error";
|
||||||
|
|
||||||
const CLI_INSTALL_STATUS_QUERY_KEY = ["desktop", "integrations", "cli-install-status"] as const;
|
const CLI_INSTALL_STATUS_QUERY_KEY = ["desktop", "integrations", "cli-install-status"] as const;
|
||||||
const SKILLS_STATUS_QUERY_KEY = ["desktop", "integrations", "skills-status"] as const;
|
const SKILLS_INSTALL_STATUS_QUERY_KEY = [
|
||||||
|
"desktop",
|
||||||
|
"integrations",
|
||||||
|
"skills-install-status",
|
||||||
|
] as const;
|
||||||
|
|
||||||
interface DesktopInstallHookResult {
|
interface DesktopInstallHookResult {
|
||||||
status: InstallStatus | null;
|
status: InstallStatus | null;
|
||||||
@@ -76,43 +77,25 @@ export function useCliInstall(): DesktopInstallHookResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillsStatusHookResult {
|
export function useSkillsInstall(): DesktopInstallHookResult {
|
||||||
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 queryClient = useQueryClient();
|
||||||
const reportError = useDesktopIpcErrorReporter();
|
const reportError = useDesktopIpcErrorReporter();
|
||||||
const enabled = shouldUseDesktopDaemon();
|
const enabled = shouldUseDesktopDaemon();
|
||||||
|
|
||||||
const statusQuery = useQuery<SkillsStatus, Error>({
|
const statusQuery = useQuery<InstallStatus, Error>({
|
||||||
queryKey: SKILLS_STATUS_QUERY_KEY,
|
queryKey: SKILLS_INSTALL_STATUS_QUERY_KEY,
|
||||||
queryFn: getSkillsStatus,
|
queryFn: getSkillsInstallStatus,
|
||||||
enabled,
|
enabled,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
const { data: status, error: statusError, isLoading, refetch } = statusQuery;
|
const { data: installStatus, error: statusError, isLoading, refetch } = statusQuery;
|
||||||
useDesktopIpcQueryErrorToast({
|
useDesktopIpcQueryErrorToast({
|
||||||
error: statusQuery.error,
|
error: statusQuery.error,
|
||||||
message: "Unable to check orchestration skills status.",
|
message: "Unable to check orchestration skills install status.",
|
||||||
logLabel: "[Integrations] Failed to load skills status",
|
logLabel: "[Integrations] Failed to load skills status",
|
||||||
});
|
});
|
||||||
|
|
||||||
const setStatus = useCallback(
|
const installMutation = useMutation<InstallStatus, Error>({
|
||||||
(next: SkillsStatus) => {
|
|
||||||
queryClient.setQueryData<SkillsStatus>(SKILLS_STATUS_QUERY_KEY, next);
|
|
||||||
},
|
|
||||||
[queryClient],
|
|
||||||
);
|
|
||||||
|
|
||||||
const installMutation = useMutation<SkillsStatus, Error>({
|
|
||||||
mutationFn: installSkills,
|
mutationFn: installSkills,
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
reportError({
|
reportError({
|
||||||
@@ -121,65 +104,23 @@ export function useSkillsStatus(): SkillsStatusHookResult {
|
|||||||
logLabel: "[Integrations] Failed to install skills",
|
logLabel: "[Integrations] Failed to install skills",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: setStatus,
|
onSuccess: (nextStatus) => {
|
||||||
});
|
queryClient.setQueryData<InstallStatus>(SKILLS_INSTALL_STATUS_QUERY_KEY, nextStatus);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: SKILLS_INSTALL_STATUS_QUERY_KEY });
|
||||||
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 { error: installError, isPending: isInstalling, mutate: install } = installMutation;
|
||||||
|
|
||||||
const uninstallMutation = useMutation<SkillsStatus, Error>({
|
const refresh = useCallback(() => {
|
||||||
mutationFn: uninstallSkills,
|
void refetch();
|
||||||
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]);
|
}, [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 {
|
return {
|
||||||
status: status ?? null,
|
status: installStatus ?? null,
|
||||||
isLoading,
|
isLoading,
|
||||||
isWorking,
|
isInstalling,
|
||||||
error:
|
error: statusError ?? installError ?? null,
|
||||||
statusError ??
|
|
||||||
installMutation.error ??
|
|
||||||
updateMutation.error ??
|
|
||||||
uninstallMutation.error ??
|
|
||||||
null,
|
|
||||||
refresh,
|
|
||||||
install,
|
install,
|
||||||
update,
|
refresh,
|
||||||
uninstall,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,6 +184,21 @@ describe("UpdateCalloutSource", () => {
|
|||||||
expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull();
|
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 () => {
|
it("disables the install action and shows Installing... while installing", async () => {
|
||||||
updaterState.value = {
|
updaterState.value = {
|
||||||
...updaterState.value,
|
...updaterState.value,
|
||||||
|
|||||||
@@ -13,18 +13,25 @@ import { openExternalUrl } from "@/utils/open-external-url";
|
|||||||
const CHECK_INTERVAL_MS = 30 * 60 * 1000;
|
const CHECK_INTERVAL_MS = 30 * 60 * 1000;
|
||||||
const CHANGELOG_URL = "https://paseo.sh/changelog";
|
const CHANGELOG_URL = "https://paseo.sh/changelog";
|
||||||
|
|
||||||
function resolveUpdateCalloutTitle(args: { isInstalling: boolean; isError: boolean }): string {
|
function resolveUpdateCalloutTitle(args: {
|
||||||
|
isInstalled: boolean;
|
||||||
|
isInstalling: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
}): string {
|
||||||
|
if (args.isInstalled) return "Update installed";
|
||||||
if (args.isInstalling) return "Installing update";
|
if (args.isInstalling) return "Installing update";
|
||||||
if (args.isError) return "Update failed";
|
if (args.isError) return "Update failed";
|
||||||
return "Update available";
|
return "Update available";
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveUpdateCalloutDescription(args: {
|
function resolveUpdateCalloutDescription(args: {
|
||||||
|
isInstalled: boolean;
|
||||||
isInstalling: boolean;
|
isInstalling: boolean;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
latestVersion: string | undefined;
|
latestVersion: string | undefined;
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
|
if (args.isInstalled) return "Restart to use the new version.";
|
||||||
if (args.isInstalling) return "Installing and restarting...";
|
if (args.isInstalling) return "Installing and restarting...";
|
||||||
if (args.isError) return args.errorMessage ?? "Something went wrong.";
|
if (args.isError) return args.errorMessage ?? "Something went wrong.";
|
||||||
if (args.latestVersion) {
|
if (args.latestVersion) {
|
||||||
@@ -36,6 +43,7 @@ function resolveUpdateCalloutDescription(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildUpdateCalloutActions(args: {
|
function buildUpdateCalloutActions(args: {
|
||||||
|
isInstalled: boolean;
|
||||||
isInstalling: boolean;
|
isInstalling: boolean;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
openChangelog: () => void;
|
openChangelog: () => void;
|
||||||
@@ -45,7 +53,7 @@ function buildUpdateCalloutActions(args: {
|
|||||||
const actions: SidebarCalloutAction[] = [{ label: "What's new", onPress: args.openChangelog }];
|
const actions: SidebarCalloutAction[] = [{ label: "What's new", onPress: args.openChangelog }];
|
||||||
if (args.isError) {
|
if (args.isError) {
|
||||||
actions.push({ label: "Retry", onPress: args.retry, variant: "primary" });
|
actions.push({ label: "Retry", onPress: args.retry, variant: "primary" });
|
||||||
} else {
|
} else if (!args.isInstalled) {
|
||||||
actions.push({
|
actions.push({
|
||||||
label: args.isInstalling ? "Installing..." : "Install & restart",
|
label: args.isInstalling ? "Installing..." : "Install & restart",
|
||||||
onPress: args.install,
|
onPress: args.install,
|
||||||
@@ -99,21 +107,29 @@ export function UpdateCalloutSource() {
|
|||||||
if (!isDesktopApp) {
|
if (!isDesktopApp) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (status !== "available" && status !== "installing" && status !== "error") {
|
if (
|
||||||
|
status !== "available" &&
|
||||||
|
status !== "installed" &&
|
||||||
|
status !== "installing" &&
|
||||||
|
status !== "error"
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isInstalled = status === "installed";
|
||||||
const isError = status === "error";
|
const isError = status === "error";
|
||||||
const isAvailable = !isInstalling && !isError;
|
const isAvailable = !isInstalled && !isInstalling && !isError;
|
||||||
|
|
||||||
const title = resolveUpdateCalloutTitle({ isInstalling, isError });
|
const title = resolveUpdateCalloutTitle({ isInstalled, isInstalling, isError });
|
||||||
const description = resolveUpdateCalloutDescription({
|
const description = resolveUpdateCalloutDescription({
|
||||||
|
isInstalled,
|
||||||
isInstalling,
|
isInstalling,
|
||||||
isError,
|
isError,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
latestVersion: availableUpdate?.latestVersion ?? undefined,
|
latestVersion: availableUpdate?.latestVersion ?? undefined,
|
||||||
});
|
});
|
||||||
const actions = buildUpdateCalloutActions({
|
const actions = buildUpdateCalloutActions({
|
||||||
|
isInstalled,
|
||||||
isInstalling,
|
isInstalling,
|
||||||
isError,
|
isError,
|
||||||
openChangelog,
|
openChangelog,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Fragment, useCallback, useMemo, useState, useSyncExternalStore } from "react";
|
import { useCallback, useMemo, useState, useSyncExternalStore } from "react";
|
||||||
import type { ComponentType, ReactNode } from "react";
|
import type { ComponentType, ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -745,19 +745,16 @@ function SettingsSidebar({
|
|||||||
) : null}
|
) : null}
|
||||||
<View style={sidebarStyles.list}>
|
<View style={sidebarStyles.list}>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<Fragment key={item.id}>
|
<SidebarSectionButton
|
||||||
<SidebarSectionButton
|
key={item.id}
|
||||||
itemId={item.id}
|
itemId={item.id}
|
||||||
label={item.label}
|
label={item.label}
|
||||||
icon={item.icon}
|
icon={item.icon}
|
||||||
isSelected={selectedSectionId === item.id}
|
isSelected={selectedSectionId === item.id}
|
||||||
onSelect={onSelectSection}
|
onSelect={onSelectSection}
|
||||||
/>
|
/>
|
||||||
{item.id === "general" ? (
|
|
||||||
<SidebarProjectsButton isSelected={isProjectsSelected} onSelect={onSelectProjects} />
|
|
||||||
) : null}
|
|
||||||
</Fragment>
|
|
||||||
))}
|
))}
|
||||||
|
<SidebarProjectsButton isSelected={isProjectsSelected} onSelect={onSelectProjects} />
|
||||||
</View>
|
</View>
|
||||||
<SidebarSeparator />
|
<SidebarSeparator />
|
||||||
<View style={sidebarStyles.list}>
|
<View style={sidebarStyles.list}>
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
||||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
|
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
|
||||||
import { buildGitHubBranchTreeUrl } from "@/utils/github-repo-url";
|
import { buildGitHubBranchTreeUrl } from "@/utils/github-repo-url";
|
||||||
@@ -28,7 +27,6 @@ import { openExternalUrl } from "@/utils/open-external-url";
|
|||||||
import { isAbsolutePath } from "@/utils/path";
|
import { isAbsolutePath } from "@/utils/path";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import { filterTargetsForDaemonLocation } from "./workspace-open-targets";
|
|
||||||
|
|
||||||
interface WorkspaceOpenInEditorButtonProps {
|
interface WorkspaceOpenInEditorButtonProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -40,7 +38,6 @@ interface OpenTarget {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: ReactElement;
|
icon: ReactElement;
|
||||||
requiresLocalDaemon: boolean;
|
|
||||||
onOpen: () => Promise<void> | void;
|
onOpen: () => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,16 +82,14 @@ export function WorkspaceOpenInEditorButton({
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
const isLocalDaemon = useIsLocalDaemon(serverId);
|
|
||||||
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
|
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
|
||||||
|
|
||||||
const shouldQueryWorkspace =
|
const shouldLoadTargets =
|
||||||
isWeb && Boolean(client && isConnected) && cwd.trim().length > 0 && isAbsolutePath(cwd);
|
isWeb && Boolean(client && isConnected) && cwd.trim().length > 0 && isAbsolutePath(cwd);
|
||||||
const shouldLoadEditorTargets = shouldQueryWorkspace && isLocalDaemon;
|
|
||||||
|
|
||||||
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
|
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
|
||||||
queryKey: ["available-editors", serverId],
|
queryKey: ["available-editors", serverId],
|
||||||
enabled: shouldLoadEditorTargets,
|
enabled: shouldLoadTargets,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
retry: false,
|
retry: false,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -117,7 +112,7 @@ export function WorkspaceOpenInEditorButton({
|
|||||||
|
|
||||||
const { status: checkoutStatus } = useCheckoutStatusQuery({
|
const { status: checkoutStatus } = useCheckoutStatusQuery({
|
||||||
serverId,
|
serverId,
|
||||||
cwd: shouldQueryWorkspace ? cwd : "",
|
cwd: shouldLoadTargets ? cwd : "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const editorTargets = useMemo<OpenTarget[]>(
|
const editorTargets = useMemo<OpenTarget[]>(
|
||||||
@@ -126,7 +121,6 @@ export function WorkspaceOpenInEditorButton({
|
|||||||
id: editor.id,
|
id: editor.id,
|
||||||
label: editor.label,
|
label: editor.label,
|
||||||
icon: <ThemedEditorAppIcon editorId={editor.id} size={16} uniProps={mutedColorMapping} />,
|
icon: <ThemedEditorAppIcon editorId={editor.id} size={16} uniProps={mutedColorMapping} />,
|
||||||
requiresLocalDaemon: true,
|
|
||||||
onOpen: async () => {
|
onOpen: async () => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error("Host is not connected");
|
throw new Error("Host is not connected");
|
||||||
@@ -155,20 +149,13 @@ export function WorkspaceOpenInEditorButton({
|
|||||||
id: "github",
|
id: "github",
|
||||||
label: "GitHub",
|
label: "GitHub",
|
||||||
icon: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
|
icon: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
|
||||||
requiresLocalDaemon: false,
|
|
||||||
onOpen: () => openExternalUrl(url),
|
onOpen: () => openExternalUrl(url),
|
||||||
};
|
};
|
||||||
}, [checkoutStatus]);
|
}, [checkoutStatus]);
|
||||||
|
|
||||||
const targets = useMemo(
|
const targets = useMemo(
|
||||||
() =>
|
() => (githubTarget ? [...editorTargets, githubTarget] : editorTargets),
|
||||||
filterTargetsForDaemonLocation(
|
[editorTargets, githubTarget],
|
||||||
githubTarget ? [...editorTargets, githubTarget] : editorTargets,
|
|
||||||
{
|
|
||||||
isLocalDaemon,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
[editorTargets, githubTarget, isLocalDaemon],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const targetIds = useMemo(() => targets.map((target) => target.id), [targets]);
|
const targetIds = useMemo(() => targets.map((target) => target.id), [targets]);
|
||||||
@@ -223,7 +210,7 @@ export function WorkspaceOpenInEditorButton({
|
|||||||
}
|
}
|
||||||
}, [primaryOption, handleOpenTarget]);
|
}, [primaryOption, handleOpenTarget]);
|
||||||
|
|
||||||
if (!shouldQueryWorkspace || !primaryOption || targets.length === 0) {
|
if (!shouldLoadTargets || !primaryOption || targets.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
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 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.70-beta.1",
|
"version": "0.1.69",
|
||||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||||
"bin": {
|
"bin": {
|
||||||
"paseo": "bin/paseo"
|
"paseo": "bin/paseo"
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/server": "0.1.70-beta.1",
|
"@getpaseo/server": "0.1.69",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
|
|||||||
@@ -4,12 +4,16 @@ setlocal
|
|||||||
set "SCRIPT_DIR=%~dp0"
|
set "SCRIPT_DIR=%~dp0"
|
||||||
set "RESOURCES_DIR=%SCRIPT_DIR%.."
|
set "RESOURCES_DIR=%SCRIPT_DIR%.."
|
||||||
set "APP_EXECUTABLE=%RESOURCES_DIR%\..\Paseo.exe"
|
set "APP_EXECUTABLE=%RESOURCES_DIR%\..\Paseo.exe"
|
||||||
|
set "CLI_EXECUTABLE=%RESOURCES_DIR%\..\PaseoCli.exe"
|
||||||
if not exist "%APP_EXECUTABLE%" (
|
if not exist "%APP_EXECUTABLE%" (
|
||||||
echo Bundled Paseo executable not found at %APP_EXECUTABLE% 1>&2
|
echo Bundled Paseo executable not found at %APP_EXECUTABLE% 1>&2
|
||||||
exit /b 1
|
exit /b 1
|
||||||
)
|
)
|
||||||
|
if not exist "%CLI_EXECUTABLE%" (
|
||||||
|
set "CLI_EXECUTABLE=%APP_EXECUTABLE%"
|
||||||
|
)
|
||||||
|
|
||||||
set "ELECTRON_RUN_AS_NODE=1"
|
set "ELECTRON_RUN_AS_NODE=1"
|
||||||
set "PASEO_NODE_ENV=production"
|
set "PASEO_NODE_ENV=production"
|
||||||
"%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" %*
|
"%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" %*
|
||||||
exit /b %errorlevel%
|
exit /b %errorlevel%
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.70-beta.1",
|
"version": "0.1.69",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo desktop app (Electron wrapper)",
|
"description": "Paseo desktop app (Electron wrapper)",
|
||||||
"homepage": "https://paseo.sh",
|
"homepage": "https://paseo.sh",
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ const path = require("path");
|
|||||||
const { smokePackagedDesktopApp } = require("./smoke-packaged-desktop-app.js");
|
const { smokePackagedDesktopApp } = require("./smoke-packaged-desktop-app.js");
|
||||||
|
|
||||||
const EXECUTABLE_NAME = "Paseo";
|
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
|
// electron-builder arch enum → Node.js arch string
|
||||||
const ARCH_MAP = { 0: "ia32", 1: "x64", 2: "armv7l", 3: "arm64", 4: "universal" };
|
const ARCH_MAP = { 0: "ia32", 1: "x64", 2: "armv7l", 3: "arm64", 4: "universal" };
|
||||||
@@ -121,12 +124,50 @@ function fmtMB(bytes) {
|
|||||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
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) {
|
exports.default = async function afterPack(context) {
|
||||||
const platform = context.electronPlatformName;
|
const platform = context.electronPlatformName;
|
||||||
const arch = ARCH_MAP[context.arch] || process.arch;
|
const arch = ARCH_MAP[context.arch] || process.arch;
|
||||||
|
|
||||||
pruneNativeModules(context.appOutDir, platform, arch);
|
pruneNativeModules(context.appOutDir, platform, arch);
|
||||||
|
|
||||||
|
if (platform === "win32") {
|
||||||
|
createWindowsCliExecutable(context.appOutDir);
|
||||||
|
}
|
||||||
|
|
||||||
if (platform === "linux" || platform === "win32") {
|
if (platform === "linux" || platform === "win32") {
|
||||||
if (arch !== process.arch) {
|
if (arch !== process.arch) {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -147,3 +188,6 @@ async function smokeUnpackedAppIfRequested(appOutDir) {
|
|||||||
appPath: appOutDir,
|
appPath: appOutDir,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
exports.createWindowsCliExecutable = createWindowsCliExecutable;
|
||||||
|
exports.setWindowsExecutableSubsystem = setWindowsExecutableSubsystem;
|
||||||
|
|||||||
@@ -17,13 +17,12 @@ import {
|
|||||||
downloadAndInstallUpdate,
|
downloadAndInstallUpdate,
|
||||||
type AppReleaseChannel,
|
type AppReleaseChannel,
|
||||||
} from "../features/auto-updater.js";
|
} from "../features/auto-updater.js";
|
||||||
import { getCliInstallStatus, installCli } from "../integrations/cli-install/index.js";
|
|
||||||
import {
|
import {
|
||||||
getSkillsStatus,
|
installCli,
|
||||||
|
getCliInstallStatus,
|
||||||
installSkills,
|
installSkills,
|
||||||
uninstallSkills,
|
getSkillsInstallStatus,
|
||||||
updateSkills,
|
} from "../integrations/integrations-manager.js";
|
||||||
} from "../integrations/skills/index.js";
|
|
||||||
import {
|
import {
|
||||||
openLocalTransportSession,
|
openLocalTransportSession,
|
||||||
sendLocalTransportMessage,
|
sendLocalTransportMessage,
|
||||||
@@ -532,10 +531,8 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
|
|||||||
get_local_daemon_version: () => getLocalDaemonVersion(),
|
get_local_daemon_version: () => getLocalDaemonVersion(),
|
||||||
install_cli: () => installCli(),
|
install_cli: () => installCli(),
|
||||||
get_cli_install_status: () => getCliInstallStatus(),
|
get_cli_install_status: () => getCliInstallStatus(),
|
||||||
get_skills_status: () => getSkillsStatus(),
|
|
||||||
install_skills: () => installSkills(),
|
install_skills: () => installSkills(),
|
||||||
update_skills: () => updateSkills(),
|
get_skills_install_status: () => getSkillsInstallStatus(),
|
||||||
uninstall_skills: () => uninstallSkills(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
describe("desktop packaging", () => {
|
describe("desktop packaging", () => {
|
||||||
it("unpacks server zsh shell integration files for external shells", () => {
|
it("unpacks server zsh shell integration files for external shells", () => {
|
||||||
@@ -33,4 +36,38 @@ describe("desktop packaging", () => {
|
|||||||
expect(deps[required], `${required} must be declared in dependencies`).toBe("*");
|
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,9 +196,16 @@ function buildCheckResult(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function performQuitAndInstall(onBeforeQuit?: () => Promise<void>): Promise<void> {
|
function scheduleQuitAndInstall(onBeforeQuit?: () => Promise<void>): void {
|
||||||
if (onBeforeQuit) await onBeforeQuit();
|
// Use a short delay to allow the renderer to receive the response.
|
||||||
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
if (onBeforeQuit) await onBeforeQuit();
|
||||||
|
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[auto-updater] quitAndInstall failed:", error);
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -307,7 +314,7 @@ export async function downloadAndInstallUpdate(
|
|||||||
|
|
||||||
const readyVersion = cachedUpdateInfo.version;
|
const readyVersion = cachedUpdateInfo.version;
|
||||||
if (isReadyToInstallVersion(readyVersion)) {
|
if (isReadyToInstallVersion(readyVersion)) {
|
||||||
await performQuitAndInstall(onBeforeQuit);
|
scheduleQuitAndInstall(onBeforeQuit);
|
||||||
return {
|
return {
|
||||||
installed: true,
|
installed: true,
|
||||||
version: readyVersion,
|
version: readyVersion,
|
||||||
@@ -329,7 +336,7 @@ export async function downloadAndInstallUpdate(
|
|||||||
await autoUpdater.downloadUpdate();
|
await autoUpdater.downloadUpdate();
|
||||||
downloadedUpdateVersion = readyVersion;
|
downloadedUpdateVersion = readyVersion;
|
||||||
downloading = false;
|
downloading = false;
|
||||||
await performQuitAndInstall(onBeforeQuit);
|
scheduleQuitAndInstall(onBeforeQuit);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
installed: true,
|
installed: true,
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { resolveCliInstallSourcePath } from "./path";
|
import { resolveCliInstallSourcePath } from "./cli-install-path";
|
||||||
|
|
||||||
describe("cli-install-path", () => {
|
describe("cli-install-path", () => {
|
||||||
it("uses the bundled shim for packaged macOS installs", () => {
|
it("uses the bundled shim for packaged macOS installs", () => {
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { getCliInstallStatus, installCli } from "./install.js";
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
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,31 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
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) };
|
||||||
|
}
|
||||||
+5
-53
@@ -2,7 +2,7 @@ import { promises as fs } from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { removeSkill, syncSkills } from "./sync";
|
import { syncSkills } from "./skill-sync";
|
||||||
|
|
||||||
interface Sandbox {
|
interface Sandbox {
|
||||||
root: string;
|
root: string;
|
||||||
@@ -70,11 +70,6 @@ describe("syncSkills", () => {
|
|||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
expect(agentsContent).toBe("new paseo content");
|
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(
|
const codexContent = await fs.readFile(
|
||||||
path.join(sandbox.codexDir, "paseo", "SKILL.md"),
|
path.join(sandbox.codexDir, "paseo", "SKILL.md"),
|
||||||
"utf-8",
|
"utf-8",
|
||||||
@@ -112,10 +107,10 @@ describe("syncSkills", () => {
|
|||||||
),
|
),
|
||||||
).toBe("roles content");
|
).toBe("roles content");
|
||||||
|
|
||||||
const claudeSkillDir = path.join(sandbox.claudeDir, "paseo-epic");
|
const claudeLink = path.join(sandbox.claudeDir, "paseo-epic");
|
||||||
expect((await fs.lstat(claudeSkillDir)).isDirectory()).toBe(true);
|
const lstat = await fs.lstat(claudeLink);
|
||||||
expect(await fs.readFile(path.join(claudeSkillDir, "SKILL.md"), "utf-8")).toBe("epic content");
|
expect(lstat.isSymbolicLink()).toBe(true);
|
||||||
expect(await fs.readFile(path.join(claudeSkillDir, "references", "roles.md"), "utf-8")).toBe(
|
expect(await fs.readFile(path.join(claudeLink, "references", "roles.md"), "utf-8")).toBe(
|
||||||
"roles content",
|
"roles content",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -259,46 +254,3 @@ describe("syncSkills", () => {
|
|||||||
expect(result.processedSkills).toBe(0);
|
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+35
-19
@@ -7,6 +7,7 @@ export interface SkillSyncOptions {
|
|||||||
claudeDir: string;
|
claudeDir: string;
|
||||||
codexDir: string;
|
codexDir: string;
|
||||||
skillNames: readonly string[];
|
skillNames: readonly string[];
|
||||||
|
platform?: NodeJS.Platform;
|
||||||
onSkillError?: (skillName: string, error: unknown) => void;
|
onSkillError?: (skillName: string, error: unknown) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ async function writeFileIfChanged(srcPath: string, dstPath: string): Promise<boo
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listFilesRecursive(rootDir: string): Promise<string[]> {
|
async function listFilesRecursive(rootDir: string): Promise<string[]> {
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
async function walk(dir: string): Promise<void> {
|
async function walk(dir: string): Promise<void> {
|
||||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||||
@@ -52,24 +53,38 @@ async function syncDirectoryFiles(srcDir: string, dstDir: string): Promise<numbe
|
|||||||
return changed;
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoveSkillTargets {
|
async function ensureClaudeSkillLink(
|
||||||
agentsDir: string;
|
skillName: string,
|
||||||
claudeDir: string;
|
agentsDir: string,
|
||||||
codexDir: 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 async function removeSkill(skillName: string, targets: RemoveSkillTargets): Promise<void> {
|
// Always rebuild the link rather than diffing it. fs.rm with force: true is
|
||||||
const paths = [
|
// a no-op when nothing is there, and matches existing install behavior.
|
||||||
path.join(targets.agentsDir, skillName),
|
// On Windows, `fs.rm` does not follow junctions, so the agents-side content
|
||||||
path.join(targets.claudeDir, skillName),
|
// is preserved.
|
||||||
path.join(targets.codexDir, skillName),
|
await fs.rm(linkPath, { recursive: true, force: true });
|
||||||
];
|
|
||||||
for (const p of paths) {
|
if (platform === "win32") {
|
||||||
await fs.rm(p, { recursive: true, force: true });
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await fs.symlink(target, linkPath);
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncResult> {
|
export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncResult> {
|
||||||
|
const platform = options.platform ?? process.platform;
|
||||||
let changedFiles = 0;
|
let changedFiles = 0;
|
||||||
let processedSkills = 0;
|
let processedSkills = 0;
|
||||||
|
|
||||||
@@ -85,9 +100,11 @@ export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncRe
|
|||||||
path.join(options.agentsDir, skillName),
|
path.join(options.agentsDir, skillName),
|
||||||
);
|
);
|
||||||
|
|
||||||
changedFiles += await syncDirectoryFiles(
|
changedFiles += await ensureClaudeSkillLink(
|
||||||
bundleSkillDir,
|
skillName,
|
||||||
path.join(options.claudeDir, skillName),
|
options.agentsDir,
|
||||||
|
options.claudeDir,
|
||||||
|
platform,
|
||||||
);
|
);
|
||||||
|
|
||||||
changedFiles += await syncDirectoryFiles(
|
changedFiles += await syncDirectoryFiles(
|
||||||
@@ -97,8 +114,7 @@ export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncRe
|
|||||||
|
|
||||||
processedSkills++;
|
processedSkills++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!options.onSkillError) throw error;
|
options.onSkillError?.(skillName, error);
|
||||||
options.onSkillError(skillName, error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
export {
|
|
||||||
getSkillsStatus,
|
|
||||||
installSkills,
|
|
||||||
uninstallSkills,
|
|
||||||
updateSkills,
|
|
||||||
type SkillOp,
|
|
||||||
type SkillsState,
|
|
||||||
type SkillsStatus,
|
|
||||||
type SkillTargets,
|
|
||||||
} from "./operations.js";
|
|
||||||
@@ -1,302 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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");
|
|
||||||
}
|
|
||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
createBeforeQuitHandler,
|
createBeforeQuitHandler,
|
||||||
stopDesktopManagedDaemonOnQuitIfNeeded,
|
stopDesktopManagedDaemonOnQuitIfNeeded,
|
||||||
} from "./daemon/quit-lifecycle.js";
|
} from "./daemon/quit-lifecycle.js";
|
||||||
|
import { autoUpdateSkillsIfInstalled } from "./integrations/integrations-manager.js";
|
||||||
import { runDesktopStartup } from "./desktop-startup.js";
|
import { runDesktopStartup } from "./desktop-startup.js";
|
||||||
|
|
||||||
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
|
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
|
||||||
@@ -660,6 +661,10 @@ async function bootstrap(): Promise<void> {
|
|||||||
registerNotificationHandlers();
|
registerNotificationHandlers();
|
||||||
registerOpenerHandlers();
|
registerOpenerHandlers();
|
||||||
|
|
||||||
|
void autoUpdateSkillsIfInstalled().catch((error) => {
|
||||||
|
log.warn("[integrations] auto-update skills failed", error);
|
||||||
|
});
|
||||||
|
|
||||||
await createMainWindow();
|
await createMainWindow();
|
||||||
|
|
||||||
app.on("activate", async () => {
|
app.on("activate", async () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.1.70-beta.1",
|
"version": "0.1.69",
|
||||||
"description": "Native module for two way audio streaming",
|
"description": "Native module for two way audio streaming",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ExpoTwoWayAudio",
|
"ExpoTwoWayAudio",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.1.70-beta.1",
|
"version": "0.1.69",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.1.70-beta.1",
|
"version": "0.1.69",
|
||||||
"description": "Paseo relay for bridging daemon and client connections",
|
"description": "Paseo relay for bridging daemon and client connections",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.1.70-beta.1",
|
"version": "0.1.69",
|
||||||
"description": "Paseo backend server",
|
"description": "Paseo backend server",
|
||||||
"files": [
|
"files": [
|
||||||
"dist/server",
|
"dist/server",
|
||||||
@@ -58,8 +58,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||||
"@getpaseo/highlight": "0.1.70-beta.1",
|
"@getpaseo/highlight": "0.1.69",
|
||||||
"@getpaseo/relay": "0.1.70-beta.1",
|
"@getpaseo/relay": "0.1.69",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@mariozechner/pi-agent-core": "^0.70.2",
|
"@mariozechner/pi-agent-core": "^0.70.2",
|
||||||
"@mariozechner/pi-ai": "^0.70.2",
|
"@mariozechner/pi-ai": "^0.70.2",
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { type ChildProcess } from "node:child_process";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
import { EventEmitter } from "node:events";
|
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import type {
|
import type {
|
||||||
PermissionOption,
|
PermissionOption,
|
||||||
PromptResponse,
|
PromptResponse,
|
||||||
@@ -25,7 +23,6 @@ import { transformPiModels } from "./pi-direct-agent.js";
|
|||||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||||
import { asInternals } from "../../test-utils/class-mocks.js";
|
import { asInternals } from "../../test-utils/class-mocks.js";
|
||||||
import * as spawnUtils from "../../../utils/spawn.js";
|
|
||||||
|
|
||||||
interface ACPSessionInternals {
|
interface ACPSessionInternals {
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
@@ -117,14 +114,6 @@ 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(
|
function selectConfigOption(
|
||||||
category: "mode" | "model" | "thought_level",
|
category: "mode" | "model" | "thought_level",
|
||||||
values: string[],
|
values: string[],
|
||||||
@@ -314,78 +303,6 @@ 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", () => {
|
describe("mapACPUsage", () => {
|
||||||
test("maps ACP usage fields into Paseo usage", () => {
|
test("maps ACP usage fields into Paseo usage", () => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ import {
|
|||||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||||
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
|
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
|
||||||
import { findExecutable } from "../../../utils/executable.js";
|
import { findExecutable } from "../../../utils/executable.js";
|
||||||
import { platformShell, spawnProcess } from "../../../utils/spawn.js";
|
import { spawnProcess } from "../../../utils/spawn.js";
|
||||||
|
|
||||||
function assertChildWithPipes(
|
function assertChildWithPipes(
|
||||||
child: ChildProcess,
|
child: ChildProcess,
|
||||||
@@ -106,22 +106,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
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 = {
|
const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = {
|
||||||
supportsStreaming: true,
|
supportsStreaming: true,
|
||||||
supportsSessionPersistence: true,
|
supportsSessionPersistence: true,
|
||||||
@@ -1480,8 +1464,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
const env = Object.fromEntries(
|
const env = Object.fromEntries(
|
||||||
(params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value]),
|
(params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value]),
|
||||||
);
|
);
|
||||||
const terminalCommand = resolveTerminalCommand(params.command, params.args);
|
const child = spawnProcess(params.command, params.args ?? [], {
|
||||||
const child = spawnProcess(terminalCommand.command, terminalCommand.args, {
|
|
||||||
cwd: params.cwd ?? this.config.cwd,
|
cwd: params.cwd ?? this.config.cwd,
|
||||||
...createProviderEnvSpec({
|
...createProviderEnvSpec({
|
||||||
runtimeSettings: this.runtimeSettings,
|
runtimeSettings: this.runtimeSettings,
|
||||||
@@ -1496,7 +1479,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
resolveExit = resolve;
|
resolveExit = resolve;
|
||||||
rejectExit = reject;
|
rejectExit = reject;
|
||||||
});
|
});
|
||||||
waitForExit.catch(() => undefined);
|
|
||||||
|
|
||||||
const entry: TerminalEntry = {
|
const entry: TerminalEntry = {
|
||||||
id: terminalId,
|
id: terminalId,
|
||||||
@@ -1516,11 +1498,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
child.stderr!.on("data", (chunk: Buffer | string) =>
|
child.stderr!.on("data", (chunk: Buffer | string) =>
|
||||||
appendTerminalOutput(entry, chunk.toString()),
|
appendTerminalOutput(entry, chunk.toString()),
|
||||||
);
|
);
|
||||||
child.once("error", (error) => {
|
child.once("error", (error) =>
|
||||||
const spawnError = error instanceof Error ? error : new Error(String(error));
|
rejectExit(error instanceof Error ? error : new Error(String(error))),
|
||||||
appendTerminalOutput(entry, `${spawnError.message}\n`);
|
);
|
||||||
rejectExit(spawnError);
|
|
||||||
});
|
|
||||||
child.once("exit", (code, signal) => {
|
child.once("exit", (code, signal) => {
|
||||||
const exit = { exitCode: code, signal };
|
const exit = { exitCode: code, signal };
|
||||||
entry.exit = exit;
|
entry.exit = exit;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ describe("getClaudeModels", () => {
|
|||||||
"claude-opus-4-7",
|
"claude-opus-4-7",
|
||||||
"claude-opus-4-6[1m]",
|
"claude-opus-4-6[1m]",
|
||||||
"claude-opus-4-6",
|
"claude-opus-4-6",
|
||||||
"claude-sonnet-4-6[1m]",
|
|
||||||
"claude-sonnet-4-6",
|
"claude-sonnet-4-6",
|
||||||
"claude-haiku-4-5",
|
"claude-haiku-4-5",
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -45,13 +45,6 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [
|
|||||||
isDefault: true,
|
isDefault: true,
|
||||||
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
|
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",
|
provider: "claude",
|
||||||
id: "claude-sonnet-4-6",
|
id: "claude-sonnet-4-6",
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import {
|
|||||||
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
||||||
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
|
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
|
||||||
import { withTimeout } from "../../../utils/promise-timeout.js";
|
import { withTimeout } from "../../../utils/promise-timeout.js";
|
||||||
import { execCommand, spawnProcess } from "../../../utils/spawn.js";
|
import { spawnProcess } from "../../../utils/spawn.js";
|
||||||
import { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js";
|
import { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js";
|
||||||
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
|
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
|
||||||
import {
|
import {
|
||||||
@@ -1228,20 +1228,6 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
serverStatus = `Unavailable (${toDiagnosticErrorMessage(error)})`;
|
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) {
|
if (available) {
|
||||||
try {
|
try {
|
||||||
const models = await this.listModels({ cwd: homedir(), force: false });
|
const models = await this.listModels({ cwd: homedir(), force: false });
|
||||||
@@ -1277,7 +1263,6 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown",
|
value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown",
|
||||||
},
|
},
|
||||||
{ label: "Server", value: serverStatus },
|
{ label: "Server", value: serverStatus },
|
||||||
{ label: "Auth", value: authValue },
|
|
||||||
{ label: "Models", value: modelsValue },
|
{ label: "Models", value: modelsValue },
|
||||||
{ label: "Status", value: status },
|
{ label: "Status", value: status },
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -3007,11 +3007,18 @@ export class Session {
|
|||||||
if (!resolvedWorkspace) {
|
if (!resolvedWorkspace) {
|
||||||
throw new Error(`Workspace not found: ${msg.workspaceId}`);
|
throw new Error(`Workspace not found: ${msg.workspaceId}`);
|
||||||
}
|
}
|
||||||
const snapshot = await this.agentManager.createAgent(sessionConfig, undefined, {
|
const snapshot = await this.agentManager.createAgent(
|
||||||
labels,
|
{
|
||||||
workspaceId: resolvedWorkspace.workspaceId,
|
...sessionConfig,
|
||||||
initialPrompt: trimmedPrompt,
|
cwd: resolvedWorkspace.cwd,
|
||||||
});
|
},
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
labels,
|
||||||
|
workspaceId: resolvedWorkspace.workspaceId,
|
||||||
|
initialPrompt: trimmedPrompt,
|
||||||
|
},
|
||||||
|
);
|
||||||
await this.forwardAgentUpdate(snapshot);
|
await this.forwardAgentUpdate(snapshot);
|
||||||
|
|
||||||
await this.sendInitialCreateAgentPrompt({
|
await this.sendInitialCreateAgentPrompt({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { homedir, tmpdir } from "node:os";
|
import { homedir, tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { expect, test, vi } from "vitest";
|
import { expect, test, vi } from "vitest";
|
||||||
@@ -10,18 +10,6 @@ import type {
|
|||||||
EditorTargetDescriptorPayload,
|
EditorTargetDescriptorPayload,
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
} from "../shared/messages.js";
|
} 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 type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js";
|
||||||
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
|
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
|
||||||
import {
|
import {
|
||||||
@@ -42,8 +30,6 @@ import {
|
|||||||
findByType,
|
findByType,
|
||||||
} from "./test-utils/session-stubs.js";
|
} from "./test-utils/session-stubs.js";
|
||||||
import {
|
import {
|
||||||
FileBackedProjectRegistry,
|
|
||||||
FileBackedWorkspaceRegistry,
|
|
||||||
createPersistedProjectRecord,
|
createPersistedProjectRecord,
|
||||||
createPersistedWorkspaceRecord,
|
createPersistedWorkspaceRecord,
|
||||||
} from "./workspace-registry.js";
|
} from "./workspace-registry.js";
|
||||||
@@ -288,101 +274,6 @@ 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(
|
function createSessionForWorkspaceTests(
|
||||||
options: {
|
options: {
|
||||||
appVersion?: string | null;
|
appVersion?: string | null;
|
||||||
@@ -470,135 +361,6 @@ function createSessionForWorkspaceTests(
|
|||||||
return session;
|
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 () => {
|
test("unsupported persisted agents are excluded from active lists but preserved in history payloads", async () => {
|
||||||
const session = createSessionForWorkspaceTests({ appVersion: "0.1.45" });
|
const session = createSessionForWorkspaceTests({ appVersion: "0.1.45" });
|
||||||
const storedRecord = {
|
const storedRecord = {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { readdir } from "node:fs/promises";
|
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
@@ -12,13 +11,11 @@ import {
|
|||||||
import {
|
import {
|
||||||
getCheckoutDiff as getCheckoutDiffUncached,
|
getCheckoutDiff as getCheckoutDiffUncached,
|
||||||
getCheckoutStatus as getCheckoutStatusUncached,
|
getCheckoutStatus as getCheckoutStatusUncached,
|
||||||
resolveAbsoluteGitDir as resolveAbsoluteGitDirReal,
|
|
||||||
type CheckoutDiffCompare,
|
type CheckoutDiffCompare,
|
||||||
type CheckoutDiffResult,
|
type CheckoutDiffResult,
|
||||||
type CheckoutStatusGit,
|
type CheckoutStatusGit,
|
||||||
type PullRequestStatusResult,
|
type PullRequestStatusResult,
|
||||||
} from "../utils/checkout-git.js";
|
} from "../utils/checkout-git.js";
|
||||||
import { runGitCommand as runGitCommandReal } from "../utils/run-git-command.js";
|
|
||||||
import {
|
import {
|
||||||
WorkspaceGitServiceImpl,
|
WorkspaceGitServiceImpl,
|
||||||
type WorkspaceGitRuntimeSnapshot,
|
type WorkspaceGitRuntimeSnapshot,
|
||||||
@@ -210,7 +207,6 @@ interface CreateServiceOptions {
|
|||||||
runGitFetch?: ReturnType<typeof vi.fn>;
|
runGitFetch?: ReturnType<typeof vi.fn>;
|
||||||
runGitCommand?: ReturnType<typeof vi.fn>;
|
runGitCommand?: ReturnType<typeof vi.fn>;
|
||||||
watch?: ReturnType<typeof vi.fn>;
|
watch?: ReturnType<typeof vi.fn>;
|
||||||
readdir?: ReturnType<typeof vi.fn>;
|
|
||||||
now?: () => Date;
|
now?: () => Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1458,48 +1454,4 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
|||||||
|
|
||||||
service.dispose();
|
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,9 +40,6 @@ const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_000;
|
|||||||
const WORKSPACE_GIT_CONSUMER_TTL_MS = 15_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.
|
// 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 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 =
|
const linuxWatchReaddirConcurrency =
|
||||||
parseInt(process.env.PASEO_LINUX_WATCH_READDIR_CONCURRENCY ?? "16", 10) || 16;
|
parseInt(process.env.PASEO_LINUX_WATCH_READDIR_CONCURRENCY ?? "16", 10) || 16;
|
||||||
@@ -326,7 +323,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
private readonly repoTargets = new Map<string, RepoGitTarget>();
|
private readonly repoTargets = new Map<string, RepoGitTarget>();
|
||||||
private readonly workingTreeWatchTargets = new Map<string, WorkingTreeWatchTarget>();
|
private readonly workingTreeWatchTargets = new Map<string, WorkingTreeWatchTarget>();
|
||||||
private readonly workingTreeWatchSetups = new Map<string, Promise<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<
|
private readonly branchValidationCache = new Map<
|
||||||
string,
|
string,
|
||||||
WorkspaceGitAuxiliaryReadCacheEntry<WorkspaceGitBranchValidationResult>
|
WorkspaceGitAuxiliaryReadCacheEntry<WorkspaceGitBranchValidationResult>
|
||||||
@@ -1199,9 +1195,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
"Failed to refresh Linux working tree watchers",
|
"Failed to refresh Linux working tree watchers",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (target.linuxTreeRefreshQueued) {
|
|
||||||
await new Promise((r) => setTimeout(r, LINUX_WATCH_REFRESH_COOLDOWN_MS));
|
|
||||||
}
|
|
||||||
} while (target.linuxTreeRefreshQueued);
|
} while (target.linuxTreeRefreshQueued);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -1213,17 +1206,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async listLinuxWatchDirectories(rootPath: string): Promise<string[]> {
|
private async listLinuxWatchDirectories(rootPath: string): Promise<string[]> {
|
||||||
const ignored = await this.loadLinuxIgnoredDirs(rootPath);
|
|
||||||
const directories: string[] = [];
|
const directories: string[] = [];
|
||||||
let currentLevel: string[] = [rootPath];
|
let currentLevel: string[] = [rootPath];
|
||||||
let capped = false;
|
|
||||||
|
|
||||||
while (currentLevel.length > 0) {
|
while (currentLevel.length > 0) {
|
||||||
directories.push(...currentLevel);
|
directories.push(...currentLevel);
|
||||||
if (directories.length >= LINUX_WATCH_MAX_DIRS) {
|
|
||||||
capped = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const readResults = await Promise.all(
|
const readResults = await Promise.all(
|
||||||
currentLevel.map((directory) =>
|
currentLevel.map((directory) =>
|
||||||
linuxWatchReaddirLimit(async () => {
|
linuxWatchReaddirLimit(async () => {
|
||||||
@@ -1244,59 +1231,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
if (!entry.isDirectory() || entry.name === ".git") {
|
if (!entry.isDirectory() || entry.name === ".git") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const childPath = join(directory, entry.name);
|
nextLevel.push(join(directory, entry.name));
|
||||||
if (ignored.has(childPath)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
nextLevel.push(childPath);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
currentLevel = nextLevel;
|
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;
|
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(
|
private async refreshWorkspaceTarget(
|
||||||
target: WorkspaceGitTarget,
|
target: WorkspaceGitTarget,
|
||||||
request: WorkspaceGitRefreshRequest,
|
request: WorkspaceGitRefreshRequest,
|
||||||
@@ -1644,9 +1587,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
target.watchers = [];
|
target.watchers = [];
|
||||||
target.watchedPaths.clear();
|
target.watchedPaths.clear();
|
||||||
target.listeners.clear();
|
target.listeners.clear();
|
||||||
if (target.repoWatchPath) {
|
|
||||||
this.linuxIgnoredDirsCache.delete(target.repoWatchPath);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private closeRepoTarget(target: RepoGitTarget): void {
|
private closeRepoTarget(target: RepoGitTarget): void {
|
||||||
|
|||||||
@@ -1698,44 +1698,6 @@ 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 () => {
|
it("searches only GitHub PRs when the search kinds request excludes issues", async () => {
|
||||||
const runner = createRunner([searchPullRequestJson("PR title")]);
|
const runner = createRunner([searchPullRequestJson("PR title")]);
|
||||||
const service = createGitHubService({
|
const service = createGitHubService({
|
||||||
|
|||||||
@@ -933,12 +933,11 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
const readOptions: GitHubReadOptions = input.force
|
const readOptions: GitHubReadOptions = input.force
|
||||||
? { force: true, reason: input.reason }
|
? { force: true, reason: input.reason }
|
||||||
: { force: false, reason: input.reason };
|
: { force: false, reason: input.reason };
|
||||||
const query = normalizeGitHubSearchQuery(input.query);
|
|
||||||
const [issuesResult, prsResult] = await Promise.allSettled([
|
const [issuesResult, prsResult] = await Promise.allSettled([
|
||||||
shouldFetchIssues
|
shouldFetchIssues
|
||||||
? this.listIssues({
|
? this.listIssues({
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
query,
|
query: input.query,
|
||||||
limit: input.limit,
|
limit: input.limit,
|
||||||
...readOptions,
|
...readOptions,
|
||||||
})
|
})
|
||||||
@@ -946,7 +945,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
shouldFetchPullRequests
|
shouldFetchPullRequests
|
||||||
? this.listPullRequests({
|
? this.listPullRequests({
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
query,
|
query: input.query,
|
||||||
limit: input.limit,
|
limit: input.limit,
|
||||||
...readOptions,
|
...readOptions,
|
||||||
})
|
})
|
||||||
@@ -1172,15 +1171,6 @@ 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 {
|
function buildCacheKey(params: { cwd: string; method: string; args: unknown }): string {
|
||||||
return `${params.cwd}:${params.method}:${stableStringify(params.args)}`;
|
return `${params.cwd}:${params.method}:${stableStringify(params.args)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.1.70-beta.1",
|
"version": "0.1.69",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user