Merge branch 'main' of github.com:getpaseo/paseo
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import { isSameOrDescendantPath } from "../../utils/paths.js";
|
||||
|
||||
export function addDeleteOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
@@ -69,12 +70,9 @@ export async function runDeleteCommand(
|
||||
if (options.all) {
|
||||
agents = agents.filter((a) => !a.archivedAt);
|
||||
} else if (options.cwd) {
|
||||
const filterCwd = options.cwd;
|
||||
agents = agents.filter((a) => {
|
||||
if (a.archivedAt) return false;
|
||||
const agentCwd = a.cwd.replace(/\/$/, "");
|
||||
const targetCwd = filterCwd.replace(/\/$/, "");
|
||||
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
|
||||
return isSameOrDescendantPath(options.cwd!, a.cwd);
|
||||
});
|
||||
} else if (id) {
|
||||
const fetchResult = await client.fetchAgent(id);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AgentSnapshotPayload } from "@getpaseo/server";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
|
||||
import { collectMultiple } from "../../utils/command-options.js";
|
||||
import { isSameOrDescendantPath } from "../../utils/paths.js";
|
||||
|
||||
export function addLsOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
@@ -193,11 +194,7 @@ export async function runLsCommand(
|
||||
|
||||
// Optional cwd filter.
|
||||
if (options.cwd) {
|
||||
const targetCwd = options.cwd.replace(/\/$/, "");
|
||||
agents = agents.filter((a) => {
|
||||
const agentCwd = a.cwd.replace(/\/$/, "");
|
||||
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
|
||||
});
|
||||
agents = agents.filter((a) => isSameOrDescendantPath(options.cwd!, a.cwd));
|
||||
}
|
||||
|
||||
// Apply label filtering only when explicitly requested.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import { isSameOrDescendantPath } from "../../utils/paths.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
SingleResult,
|
||||
@@ -75,12 +76,9 @@ export async function runStopCommand(
|
||||
agents = agents.filter((a) => !a.archivedAt);
|
||||
} else if (options.cwd) {
|
||||
// Stop agents in directory
|
||||
const filterCwd = options.cwd;
|
||||
agents = agents.filter((a) => {
|
||||
if (a.archivedAt) return false;
|
||||
const agentCwd = a.cwd.replace(/\/$/, "");
|
||||
const targetCwd = filterCwd.replace(/\/$/, "");
|
||||
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
|
||||
return isSameOrDescendantPath(options.cwd!, a.cwd);
|
||||
});
|
||||
} else if (id) {
|
||||
// Stop specific agent
|
||||
|
||||
@@ -294,7 +294,8 @@ export function resolveTcpHostFromListen(listen: string): string | null {
|
||||
normalized.startsWith("/") ||
|
||||
normalized.startsWith("unix://") ||
|
||||
normalized.startsWith("pipe://") ||
|
||||
normalized.startsWith("\\\\.\\pipe\\")
|
||||
normalized.startsWith("\\\\.\\pipe\\") ||
|
||||
/^[A-Za-z]:[/\\]/.test(normalized)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import path from "path";
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type {
|
||||
@@ -77,7 +78,7 @@ export async function runArchiveCommand(
|
||||
|
||||
// Find the worktree by name or branch
|
||||
const worktree = listResponse.worktrees.find((wt) => {
|
||||
const name = wt.worktreePath.split("/").pop();
|
||||
const name = path.basename(wt.worktreePath);
|
||||
return name === nameArg || wt.branchName === nameArg;
|
||||
});
|
||||
|
||||
@@ -105,7 +106,7 @@ export async function runArchiveCommand(
|
||||
throw error;
|
||||
}
|
||||
|
||||
const worktreeName = worktree.worktreePath.split("/").pop() ?? nameArg;
|
||||
const worktreeName = path.basename(worktree.worktreePath) || nameArg;
|
||||
|
||||
return {
|
||||
type: "single",
|
||||
|
||||
@@ -45,10 +45,15 @@ export function normalizeDaemonHost(raw: string): string | null {
|
||||
return trimmed.startsWith("\\\\.\\pipe\\") ? `pipe://${trimmed}` : trimmed;
|
||||
}
|
||||
|
||||
if (path.isAbsolute(trimmed)) {
|
||||
if (trimmed.startsWith("/") || trimmed.startsWith("~")) {
|
||||
return `unix://${trimmed}`;
|
||||
}
|
||||
|
||||
// Windows absolute paths (e.g. C:\Users\foo) are filesystem paths, not TCP or IPC targets.
|
||||
if (/^[A-Za-z]:[/\\]/.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
return `127.0.0.1:${trimmed}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Path utilities for cwd filtering in agent commands.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if `candidatePath` is the same directory as `basePath` or a descendant of it.
|
||||
*
|
||||
* Handles both Unix (/) and Windows (\) path separators, including mixed separators.
|
||||
* This is important because agent cwd paths come from the agent's OS (could be Windows)
|
||||
* while the CLI filter path comes from the user (could also be Windows or Unix).
|
||||
*/
|
||||
export function isSameOrDescendantPath(basePath: string, candidatePath: string): boolean {
|
||||
// Normalize both paths: replace all backslashes with forward slashes, strip trailing separator
|
||||
let normalizedBase = basePath.replace(/\\/g, "/").replace(/\/$/, "");
|
||||
let normalizedCandidate = candidatePath.replace(/\\/g, "/").replace(/\/$/, "");
|
||||
|
||||
// Windows paths are case-insensitive — detect by drive letter prefix (e.g. "C:/")
|
||||
if (/^[a-zA-Z]:\//.test(normalizedBase) || /^[a-zA-Z]:\//.test(normalizedCandidate)) {
|
||||
normalizedBase = normalizedBase.toLowerCase();
|
||||
normalizedCandidate = normalizedCandidate.toLowerCase();
|
||||
}
|
||||
|
||||
return (
|
||||
normalizedCandidate === normalizedBase ||
|
||||
normalizedCandidate.startsWith(normalizedBase + "/")
|
||||
);
|
||||
}
|
||||
@@ -42,4 +42,12 @@ console.log("=== Local Daemon Utility Helpers ===\n");
|
||||
console.log("✓ rejects empty and non-host listen values\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 5: rejects Windows absolute paths (not TCP endpoints)");
|
||||
assert.strictEqual(resolveTcpHostFromListen("C:\\Users\\foo\\.paseo\\paseo.sock"), null);
|
||||
assert.strictEqual(resolveTcpHostFromListen("D:\\project\\socket"), null);
|
||||
assert.strictEqual(resolveTcpHostFromListen("C:\\paseo.sock"), null);
|
||||
console.log("✓ rejects Windows absolute paths\n");
|
||||
}
|
||||
|
||||
console.log("=== All local daemon utility tests passed ===");
|
||||
|
||||
@@ -41,6 +41,13 @@ console.log("=== CLI IPC Target Helpers ===\n");
|
||||
console.log("✓ local unix socket paths normalize into IPC daemon targets\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 3b: Windows absolute paths are NOT treated as unix sockets");
|
||||
assert.strictEqual(normalizeDaemonHost("C:\\Users\\foo\\.paseo\\paseo.sock"), null);
|
||||
assert.strictEqual(normalizeDaemonHost("D:\\project\\socket"), null);
|
||||
console.log("✓ Windows absolute paths are not treated as unix sockets\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 4: default host resolution tries local IPC first, then localhost fallback");
|
||||
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-"));
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
/**
|
||||
* CWD Filter Path Tests
|
||||
*
|
||||
* Tests that cwd filtering in agent commands works correctly
|
||||
* with both Unix and Windows-style paths.
|
||||
*
|
||||
* Bug: The original code used hardcoded "/" separators for:
|
||||
* 1. Stripping trailing separators (only stripped "/", not "\")
|
||||
* 2. Descendant matching (appended "/" instead of platform separator)
|
||||
*
|
||||
* This causes Windows paths like "C:\Users\dev\project" to fail matching
|
||||
* against "C:\Users\dev\project\sub" because the code appends "/" instead
|
||||
* of "\" for the startsWith check.
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
import { isSameOrDescendantPath } from "../src/utils/paths.ts";
|
||||
|
||||
console.log("=== CWD Filter Path Tests ===\n");
|
||||
|
||||
// Test 1: Unix exact match
|
||||
{
|
||||
console.log("Test 1: Unix exact match");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("/home/user/project", "/home/user/project"),
|
||||
true,
|
||||
"exact Unix paths should match",
|
||||
);
|
||||
console.log("✓ Unix exact match\n");
|
||||
}
|
||||
|
||||
// Test 2: Unix descendant match
|
||||
{
|
||||
console.log("Test 2: Unix descendant match");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("/home/user/project", "/home/user/project/src"),
|
||||
true,
|
||||
"Unix descendant should match",
|
||||
);
|
||||
console.log("✓ Unix descendant match\n");
|
||||
}
|
||||
|
||||
// Test 3: Unix non-match (sibling)
|
||||
{
|
||||
console.log("Test 3: Unix non-match (sibling)");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("/home/user/project", "/home/user/other"),
|
||||
false,
|
||||
"sibling directories should not match",
|
||||
);
|
||||
console.log("✓ Unix non-match (sibling)\n");
|
||||
}
|
||||
|
||||
// Test 4: Unix prefix overlap (project vs project2)
|
||||
{
|
||||
console.log("Test 4: Unix prefix overlap (project vs project2)");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("/home/user/project", "/home/user/project2"),
|
||||
false,
|
||||
"prefix overlap without separator should not match",
|
||||
);
|
||||
console.log("✓ Unix prefix overlap\n");
|
||||
}
|
||||
|
||||
// Test 5: Unix trailing slash on base
|
||||
{
|
||||
console.log("Test 5: Unix trailing slash on base");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("/home/user/project/", "/home/user/project/src"),
|
||||
true,
|
||||
"trailing slash on base should still match descendants",
|
||||
);
|
||||
console.log("✓ Unix trailing slash on base\n");
|
||||
}
|
||||
|
||||
// Test 6: Unix trailing slash on candidate
|
||||
{
|
||||
console.log("Test 6: Unix trailing slash on candidate");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("/home/user/project", "/home/user/project/"),
|
||||
true,
|
||||
"trailing slash on candidate should match as same dir",
|
||||
);
|
||||
console.log("✓ Unix trailing slash on candidate\n");
|
||||
}
|
||||
|
||||
// Test 7: Windows exact match
|
||||
{
|
||||
console.log("Test 7: Windows exact match");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\project"),
|
||||
true,
|
||||
"exact Windows paths should match",
|
||||
);
|
||||
console.log("✓ Windows exact match\n");
|
||||
}
|
||||
|
||||
// Test 8: Windows descendant match
|
||||
{
|
||||
console.log("Test 8: Windows descendant match");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\project\\src"),
|
||||
true,
|
||||
"Windows descendant should match",
|
||||
);
|
||||
console.log("✓ Windows descendant match\n");
|
||||
}
|
||||
|
||||
// Test 9: Windows non-match (sibling)
|
||||
{
|
||||
console.log("Test 9: Windows non-match (sibling)");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\other"),
|
||||
false,
|
||||
"Windows sibling directories should not match",
|
||||
);
|
||||
console.log("✓ Windows non-match (sibling)\n");
|
||||
}
|
||||
|
||||
// Test 10: Windows prefix overlap (project vs project2)
|
||||
{
|
||||
console.log("Test 10: Windows prefix overlap (project vs project2)");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\project2"),
|
||||
false,
|
||||
"Windows prefix overlap without separator should not match",
|
||||
);
|
||||
console.log("✓ Windows prefix overlap\n");
|
||||
}
|
||||
|
||||
// Test 11: Windows trailing backslash on base
|
||||
{
|
||||
console.log("Test 11: Windows trailing backslash on base");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("C:\\Users\\dev\\project\\", "C:\\Users\\dev\\project\\src"),
|
||||
true,
|
||||
"trailing backslash on base should still match descendants",
|
||||
);
|
||||
console.log("✓ Windows trailing backslash on base\n");
|
||||
}
|
||||
|
||||
// Test 12: Windows trailing backslash on candidate
|
||||
{
|
||||
console.log("Test 12: Windows trailing backslash on candidate");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\project\\"),
|
||||
true,
|
||||
"trailing backslash on candidate should match as same dir",
|
||||
);
|
||||
console.log("✓ Windows trailing backslash on candidate\n");
|
||||
}
|
||||
|
||||
// Test 13: Mixed separators (agent might use \ while CLI sends /)
|
||||
{
|
||||
console.log("Test 13: Mixed separators");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("C:/Users/dev/project", "C:\\Users\\dev\\project\\src"),
|
||||
true,
|
||||
"mixed separators should still match",
|
||||
);
|
||||
console.log("✓ Mixed separators\n");
|
||||
}
|
||||
|
||||
// Test 14: Deep Windows descendant
|
||||
{
|
||||
console.log("Test 14: Deep Windows descendant");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath(
|
||||
"C:\\Users\\dev\\project",
|
||||
"C:\\Users\\dev\\project\\src\\components\\Button.tsx",
|
||||
),
|
||||
true,
|
||||
"deep Windows descendant should match",
|
||||
);
|
||||
console.log("✓ Deep Windows descendant\n");
|
||||
}
|
||||
|
||||
// Test 15: Case-insensitive Windows descendant match
|
||||
{
|
||||
console.log("Test 15: Case-insensitive Windows descendant match");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("C:\\Users\\Dev\\Project", "c:\\users\\dev\\project\\src"),
|
||||
true,
|
||||
"Windows paths with different casing should match (case-insensitive)",
|
||||
);
|
||||
console.log("✓ Case-insensitive Windows descendant match\n");
|
||||
}
|
||||
|
||||
// Test 16: Case-insensitive Windows exact match
|
||||
{
|
||||
console.log("Test 16: Case-insensitive Windows exact match");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("c:\\repo", "C:\\Repo"),
|
||||
true,
|
||||
"Windows paths with different casing should match exactly (case-insensitive)",
|
||||
);
|
||||
console.log("✓ Case-insensitive Windows exact match\n");
|
||||
}
|
||||
|
||||
// Test 17: Parent should not match
|
||||
{
|
||||
console.log("Test 15: Parent should not match");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("/home/user/project/src", "/home/user/project"),
|
||||
false,
|
||||
"parent directory should not match (only same-or-descendant)",
|
||||
);
|
||||
console.log("✓ Parent should not match\n");
|
||||
}
|
||||
|
||||
console.log("=== All CWD filter path tests passed ===");
|
||||
@@ -189,7 +189,8 @@ function resolveTcpHostFromListen(listen: string): string | null {
|
||||
normalized.startsWith("/") ||
|
||||
normalized.startsWith("unix://") ||
|
||||
normalized.startsWith("pipe://") ||
|
||||
normalized.startsWith("\\\\.\\pipe\\")
|
||||
normalized.startsWith("\\\\.\\pipe\\") ||
|
||||
/^[A-Za-z]:[/\\]/.test(normalized)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "vitest";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
|
||||
import { promises as fs } from "node:fs";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
@@ -352,6 +352,24 @@ describe("AgentStorage", () => {
|
||||
expect(records[0]?.internal).toBe(true);
|
||||
});
|
||||
|
||||
test("Windows drive-letter paths produce valid directory names", async () => {
|
||||
await storage.applySnapshot(
|
||||
createManagedAgent({
|
||||
id: "win-agent",
|
||||
cwd: "D:\\Users\\dev\\MyProject",
|
||||
}),
|
||||
);
|
||||
|
||||
const record = await storage.get("win-agent");
|
||||
expect(record).not.toBeNull();
|
||||
|
||||
// The persisted directory must not contain a colon (invalid on Windows)
|
||||
const dirs = readdirSync(storagePath);
|
||||
expect(dirs).toHaveLength(1);
|
||||
expect(dirs[0]).not.toContain(":");
|
||||
expect(dirs[0]).toBe("D-Users-dev-MyProject");
|
||||
});
|
||||
|
||||
test("remove deletes all duplicate record files across project directories", async () => {
|
||||
const agentId = "agent-duplicate";
|
||||
|
||||
|
||||
@@ -335,11 +335,16 @@ export class AgentStorage {
|
||||
}
|
||||
|
||||
function projectDirNameFromCwd(cwd: string): string {
|
||||
const trimmed = cwd.replace(/^[\\/]+/, "").replace(/[\\/]+$/, "");
|
||||
if (!trimmed) {
|
||||
return "root";
|
||||
// path.win32.parse handles drive letters, UNC roots, and Unix roots on all platforms
|
||||
const { root } = path.win32.parse(cwd);
|
||||
const withoutRoot = cwd.slice(root.length).replace(/[\\/]+$/, "");
|
||||
// Sanitize root: strip colons and separators, keep letters (e.g. "C:\" → "C", "\\server\share\" → "server-share")
|
||||
const sanitizedRoot = root.replace(/[:\\/]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
const prefix = sanitizedRoot ? sanitizedRoot + "-" : "";
|
||||
if (!withoutRoot) {
|
||||
return sanitizedRoot || "root";
|
||||
}
|
||||
return trimmed.replace(/[\\/]+/g, "-");
|
||||
return prefix + withoutRoot.replace(/[\\/]+/g, "-");
|
||||
}
|
||||
|
||||
async function writeFileAtomically(targetPath: string, payload: string) {
|
||||
|
||||
@@ -122,6 +122,23 @@ describe("paseo daemon bootstrap", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("parses whitespace-padded numeric port strings", () => {
|
||||
expect(parseListenString(" 6767 ")).toEqual({
|
||||
type: "tcp",
|
||||
host: "127.0.0.1",
|
||||
port: 6767,
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects Windows absolute paths that are not named pipes", () => {
|
||||
// A Windows drive path like C:\daemon must NOT be silently parsed as TCP
|
||||
// (split(":") would yield host="C" and port="\\daemon" which is nonsensical).
|
||||
expect(() => parseListenString(String.raw`C:\daemon`)).toThrow();
|
||||
expect(() => parseListenString(String.raw`D:\Users\foo\.paseo\daemon.sock`)).toThrow();
|
||||
// Single-letter "host" with no valid port is not a valid listen string
|
||||
expect(() => parseListenString(String.raw`C:\some\path`)).toThrow();
|
||||
});
|
||||
|
||||
test("parses Windows named pipes as managed IPC listen targets", () => {
|
||||
expect(parseListenString(String.raw`\\.\pipe\paseo-managed-test`)).toEqual({
|
||||
type: "pipe",
|
||||
|
||||
@@ -35,34 +35,43 @@ function resolveBoundListenTarget(
|
||||
};
|
||||
}
|
||||
|
||||
// Matches a Windows drive-letter path like C:\ or D:\
|
||||
const WINDOWS_DRIVE_RE = /^[A-Za-z]:\\/;
|
||||
|
||||
export function parseListenString(listen: string): ListenTarget {
|
||||
// 1. Windows named pipes: \\.\pipe\... or pipe://...
|
||||
if (listen.startsWith("\\\\.\\pipe\\") || listen.startsWith("pipe://")) {
|
||||
return {
|
||||
type: "pipe",
|
||||
path: listen.startsWith("pipe://") ? listen.slice("pipe://".length) : listen,
|
||||
};
|
||||
}
|
||||
// Unix socket: starts with / or ~ or contains .sock
|
||||
if (listen.startsWith("/") || listen.startsWith("~") || listen.includes(".sock")) {
|
||||
return { type: "socket", path: listen };
|
||||
}
|
||||
// Explicit unix:// prefix
|
||||
// 2. Explicit unix:// prefix
|
||||
if (listen.startsWith("unix://")) {
|
||||
return { type: "socket", path: listen.slice(7) };
|
||||
}
|
||||
// TCP: host:port or just port
|
||||
// 3. Reject Windows absolute drive paths — they are not Unix sockets
|
||||
if (WINDOWS_DRIVE_RE.test(listen)) {
|
||||
throw new Error(`Invalid listen string (Windows path is not a valid listen target): ${listen}`);
|
||||
}
|
||||
// 4. POSIX absolute path (/ or ~) — Unix socket
|
||||
if (listen.startsWith("/") || listen.startsWith("~")) {
|
||||
return { type: "socket", path: listen };
|
||||
}
|
||||
// 5. Pure numeric — TCP port on 127.0.0.1
|
||||
const trimmed = listen.trim();
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
const port = parseInt(trimmed, 10);
|
||||
return { type: "tcp", host: "127.0.0.1", port };
|
||||
}
|
||||
// 6. host:port — TCP
|
||||
if (listen.includes(":")) {
|
||||
const [host, portStr] = listen.split(":");
|
||||
const port = parseInt(portStr, 10);
|
||||
if (!Number.isFinite(port)) {
|
||||
const parsedPort = parseInt(portStr, 10);
|
||||
if (!Number.isFinite(parsedPort)) {
|
||||
throw new Error(`Invalid port in listen string: ${listen}`);
|
||||
}
|
||||
return { type: "tcp", host: host || "127.0.0.1", port };
|
||||
}
|
||||
// Just a port number
|
||||
const port = parseInt(listen, 10);
|
||||
if (Number.isFinite(port)) {
|
||||
return { type: "tcp", host: "127.0.0.1", port };
|
||||
return { type: "tcp", host: host || "127.0.0.1", port: parsedPort };
|
||||
}
|
||||
throw new Error(`Invalid listen string: ${listen}`);
|
||||
}
|
||||
|
||||
@@ -73,6 +73,12 @@ describe("TerminalManager", () => {
|
||||
await expect(manager.getTerminals("tmp")).rejects.toThrow("cwd must be absolute path");
|
||||
});
|
||||
|
||||
it("accepts Windows absolute paths", async () => {
|
||||
manager = createTerminalManager();
|
||||
await expect(manager.getTerminals("C:\\Users\\foo\\project")).resolves.not.toThrow();
|
||||
await expect(manager.getTerminals("D:\\MyProject")).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("creates separate terminals for different cwds", async () => {
|
||||
manager = createTerminalManager();
|
||||
const tmpTerminals = [await manager.createTerminal({ cwd: "/tmp" })];
|
||||
@@ -121,6 +127,18 @@ describe("TerminalManager", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not reject Windows absolute paths as relative", async () => {
|
||||
manager = createTerminalManager();
|
||||
// Should pass path validation (not throw "cwd must be absolute path").
|
||||
// The terminal may or may not spawn successfully on non-Windows hosts,
|
||||
// so we only assert the validation error is absent.
|
||||
try {
|
||||
await manager.createTerminal({ cwd: "C:\\Users\\foo\\project" });
|
||||
} catch (error) {
|
||||
expect((error as Error).message).not.toBe("cwd must be absolute path");
|
||||
}
|
||||
});
|
||||
|
||||
it("inherits registered env for the worktree root cwd", async () => {
|
||||
await withShell("/bin/sh", async () => {
|
||||
manager = createTerminalManager();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createTerminal, type TerminalSession } from "./terminal.js";
|
||||
import { resolve, sep } from "node:path";
|
||||
import { resolve, sep, win32, posix } from "node:path";
|
||||
|
||||
export interface TerminalListItem {
|
||||
id: string;
|
||||
@@ -37,7 +37,7 @@ export function createTerminalManager(): TerminalManager {
|
||||
const defaultEnvByRootCwd = new Map<string, Record<string, string>>();
|
||||
|
||||
function assertAbsolutePath(cwd: string): void {
|
||||
if (!cwd.startsWith("/")) {
|
||||
if (!posix.isAbsolute(cwd) && !win32.isAbsolute(cwd)) {
|
||||
throw new Error("cwd must be absolute path");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
NotGitRepoError,
|
||||
pushCurrentBranch,
|
||||
resolveRepositoryDefaultBranch,
|
||||
parseWorktreeList,
|
||||
isPaseoWorktreePath,
|
||||
isDescendantPath,
|
||||
} from "./checkout-git.js";
|
||||
import { createWorktree } from "./worktree.js";
|
||||
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
|
||||
@@ -812,4 +815,73 @@ const x = 1;
|
||||
).rejects.toThrow(/base/i);
|
||||
await expect(mergeToBase(worktree.worktreePath, {}, { paseoHome })).rejects.toThrow(/base/i);
|
||||
});
|
||||
|
||||
describe("parseWorktreeList", () => {
|
||||
it("parses porcelain worktree output", () => {
|
||||
const output = [
|
||||
"worktree /home/user/repo",
|
||||
"branch refs/heads/main",
|
||||
"",
|
||||
"worktree /home/user/.paseo/worktrees/feature",
|
||||
"branch refs/heads/feature",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const entries = parseWorktreeList(output);
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0]).toEqual({ path: "/home/user/repo", branchRef: "refs/heads/main" });
|
||||
expect(entries[1]).toEqual({
|
||||
path: "/home/user/.paseo/worktrees/feature",
|
||||
branchRef: "refs/heads/feature",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects bare repos", () => {
|
||||
const output = ["worktree /home/user/repo.git", "bare", ""].join("\n");
|
||||
const entries = parseWorktreeList(output);
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]?.isBare).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPaseoWorktreePath", () => {
|
||||
it("matches Unix .paseo/worktrees/ paths", () => {
|
||||
expect(isPaseoWorktreePath("/home/user/.paseo/worktrees/feature")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches Windows .paseo\\worktrees\\ paths", () => {
|
||||
expect(isPaseoWorktreePath("C:\\Users\\dev\\.paseo\\worktrees\\feature")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects paths without .paseo/worktrees segment", () => {
|
||||
expect(isPaseoWorktreePath("/home/user/repo")).toBe(false);
|
||||
expect(isPaseoWorktreePath("C:\\Users\\dev\\repo")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDescendantPath", () => {
|
||||
it("detects children with Unix separators", () => {
|
||||
expect(isDescendantPath("/home/user/repo/child", "/home/user/repo")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects children with Windows separators", () => {
|
||||
expect(isDescendantPath("C:\\repos\\child", "C:\\repos")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects the parent itself", () => {
|
||||
expect(isDescendantPath("/home/user/repo", "/home/user/repo")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects siblings that share a prefix", () => {
|
||||
expect(isDescendantPath("/home/user/repo-extra", "/home/user/repo")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles mixed separators", () => {
|
||||
expect(isDescendantPath("C:/repo/child", "C:\\repo")).toBe(true);
|
||||
});
|
||||
|
||||
it("is case insensitive on Windows paths", () => {
|
||||
expect(isDescendantPath("c:\\repo\\child", "C:\\repo")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -603,20 +603,39 @@ async function getMainRepoRoot(cwd: string): Promise<string> {
|
||||
});
|
||||
const worktrees = parseWorktreeList(worktreeOut);
|
||||
const nonBareNonPaseo = worktrees.filter(
|
||||
(wt) => !wt.isBare && !wt.path.includes("/.paseo/worktrees/"),
|
||||
(wt) => !wt.isBare && !isPaseoWorktreePath(wt.path),
|
||||
);
|
||||
const childrenOfBareRepo = nonBareNonPaseo.filter((wt) => wt.path.startsWith(normalized + "/"));
|
||||
const childrenOfBareRepo = nonBareNonPaseo.filter((wt) => isDescendantPath(wt.path, normalized));
|
||||
const mainChild = childrenOfBareRepo.find((wt) => basename(wt.path) === "main");
|
||||
return mainChild?.path ?? childrenOfBareRepo[0]?.path ?? nonBareNonPaseo[0]?.path ?? normalized;
|
||||
}
|
||||
|
||||
type GitWorktreeEntry = {
|
||||
export type GitWorktreeEntry = {
|
||||
path: string;
|
||||
branchRef?: string;
|
||||
isBare?: boolean;
|
||||
};
|
||||
|
||||
function parseWorktreeList(output: string): GitWorktreeEntry[] {
|
||||
/** Check whether a path contains a `.paseo/worktrees/` segment (both `/` and `\`). */
|
||||
export function isPaseoWorktreePath(p: string): boolean {
|
||||
return /[/\\]\.paseo[/\\]worktrees[/\\]/.test(p);
|
||||
}
|
||||
|
||||
/** True when `child` is strictly inside `parent` (handles both `/` and `\`). */
|
||||
export function isDescendantPath(child: string, parent: string): boolean {
|
||||
let c = child.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
let p = parent.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
// Case-insensitive on Windows (drive letter like C: or D:)
|
||||
if (/^[A-Za-z]:/.test(c) || /^[A-Za-z]:/.test(p)) {
|
||||
c = c.toLowerCase();
|
||||
p = p.toLowerCase();
|
||||
}
|
||||
if (!c.startsWith(p)) return false;
|
||||
if (c.length === p.length) return false;
|
||||
return c[p.length] === "/";
|
||||
}
|
||||
|
||||
export function parseWorktreeList(output: string): GitWorktreeEntry[] {
|
||||
const entries: GitWorktreeEntry[] = [];
|
||||
let current: GitWorktreeEntry | null = null;
|
||||
for (const line of output.split("\n")) {
|
||||
|
||||
Reference in New Issue
Block a user