Compare commits
2 Commits
alpha-v202
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf3caab862 | ||
|
|
2556907f36 |
@@ -18,8 +18,12 @@ pub(crate) fn build_command(
|
||||
request.permission_mode,
|
||||
)?;
|
||||
|
||||
let mut command = crate::hidden_command(binary);
|
||||
let target = crate::cli_agent_runtime::command_target_avoiding_windows_cmd_shim(binary)?;
|
||||
let mut command = crate::hidden_command(&target.program);
|
||||
crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary);
|
||||
if let Some(first_arg) = target.first_arg {
|
||||
command.arg(first_arg);
|
||||
}
|
||||
command
|
||||
.args(build_args())
|
||||
.arg(build_prompt(request))
|
||||
@@ -300,10 +304,15 @@ mod tests {
|
||||
}
|
||||
|
||||
fn assert_pi_json_mode_args(args: &[String]) {
|
||||
assert_eq!(args[0], "--mode");
|
||||
assert_eq!(args[1], "json");
|
||||
assert!(args.contains(&"--no-session".to_string()));
|
||||
assert!(args.contains(&"--extension".to_string()));
|
||||
assert_eq!(
|
||||
(
|
||||
args.first().map(String::as_str),
|
||||
args.get(1).map(String::as_str),
|
||||
args.iter().any(|arg| arg == "--no-session"),
|
||||
args.iter().any(|arg| arg == "--extension"),
|
||||
),
|
||||
(Some("--mode"), Some("json"), true, true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -324,10 +333,20 @@ mod tests {
|
||||
}
|
||||
|
||||
fn assert_command_identity(command: &std::process::Command, actual_args: &[&OsStr]) {
|
||||
assert_eq!(command.get_program(), OsStr::new("pi"));
|
||||
assert_eq!(actual_args[0], OsStr::new("--mode"));
|
||||
assert_eq!(actual_args[1], OsStr::new("json"));
|
||||
assert_eq!(actual_args.last(), Some(&OsStr::new("Rename the note")));
|
||||
assert_eq!(
|
||||
(
|
||||
command.get_program(),
|
||||
actual_args.first().copied(),
|
||||
actual_args.get(1).copied(),
|
||||
actual_args.last().copied(),
|
||||
),
|
||||
(
|
||||
OsStr::new("pi"),
|
||||
Some(OsStr::new("--mode")),
|
||||
Some(OsStr::new("json")),
|
||||
Some(OsStr::new("Rename the note")),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_command_vault_scope(
|
||||
@@ -340,6 +359,50 @@ mod tests {
|
||||
assert!(agent_dir.join("mcp.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_avoids_windows_cmd_shim_for_prompt_args() {
|
||||
let _env_lock = PI_AGENT_ENV_LOCK.lock().unwrap();
|
||||
let source_agent_dir = tempfile::tempdir().unwrap();
|
||||
let agent_dir = tempfile::tempdir().unwrap();
|
||||
let _guard = EnvGuard::set("PI_CODING_AGENT_DIR", source_agent_dir.path());
|
||||
let shim = agent_dir.path().join("pi.cmd");
|
||||
let launcher = agent_dir
|
||||
.path()
|
||||
.join("node_modules")
|
||||
.join("@withpi")
|
||||
.join("pi")
|
||||
.join("bin")
|
||||
.join("pi.exe");
|
||||
std::fs::create_dir_all(launcher.parent().unwrap()).unwrap();
|
||||
std::fs::write(&launcher, "native pi launcher").unwrap();
|
||||
std::fs::write(
|
||||
&shim,
|
||||
r#"@ECHO off
|
||||
"%~dp0\node_modules\@withpi\pi\bin\pi.exe" %*
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let command = build_command(&shim, &request(), agent_dir.path()).unwrap();
|
||||
let actual_args = command.get_args().collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
command.get_program() != shim.as_os_str(),
|
||||
command.get_program(),
|
||||
actual_args.first().copied(),
|
||||
actual_args.last().copied(),
|
||||
),
|
||||
(
|
||||
true,
|
||||
launcher.as_os_str(),
|
||||
Some(OsStr::new("--mode")),
|
||||
Some(OsStr::new("Rename the note")),
|
||||
),
|
||||
"Pi npm .cmd shims cannot be spawned directly on Windows"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_seeds_temp_agent_dir_from_existing_pi_config() {
|
||||
let _env_lock = PI_AGENT_ENV_LOCK.lock().unwrap();
|
||||
@@ -429,10 +492,20 @@ mod tests {
|
||||
}
|
||||
|
||||
fn assert_base_mcp_config(json: &serde_json::Value) {
|
||||
assert_eq!(json["settings"]["toolPrefix"], "none");
|
||||
assert_eq!(json["mcpServers"]["tolaria"]["command"], "node");
|
||||
assert_eq!(json["mcpServers"]["tolaria"]["lifecycle"], "lazy");
|
||||
assert_eq!(json["mcpServers"]["tolaria"]["directTools"], true);
|
||||
assert_eq!(
|
||||
(
|
||||
&json["settings"]["toolPrefix"],
|
||||
&json["mcpServers"]["tolaria"]["command"],
|
||||
&json["mcpServers"]["tolaria"]["lifecycle"],
|
||||
&json["mcpServers"]["tolaria"]["directTools"],
|
||||
),
|
||||
(
|
||||
&serde_json::json!("none"),
|
||||
&serde_json::json!("node"),
|
||||
&serde_json::json!("lazy"),
|
||||
&serde_json::json!(true),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_tolaria_mcp_env(json: &serde_json::Value) {
|
||||
|
||||
@@ -88,12 +88,19 @@ fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf>
|
||||
}
|
||||
|
||||
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
|
||||
first_existing_path_for_platform(stdout, cfg!(windows))
|
||||
}
|
||||
|
||||
fn first_existing_path_for_platform(stdout: &str, windows: bool) -> Option<PathBuf> {
|
||||
stdout.lines().find_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let candidate = PathBuf::from(trimmed);
|
||||
if windows && !crate::cli_agent_runtime::has_windows_cli_extension(&candidate) {
|
||||
return None;
|
||||
}
|
||||
candidate.exists().then_some(candidate)
|
||||
})
|
||||
}
|
||||
@@ -358,6 +365,18 @@ mod tests {
|
||||
assert_eq!(first_existing_path(&stdout), Some(pi));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_existing_windows_path_skips_extensionless_npm_wrapper() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let wrapper = dir.path().join("pi");
|
||||
let shim = dir.path().join("pi.cmd");
|
||||
std::fs::write(&wrapper, "#!/bin/sh\n").unwrap();
|
||||
std::fs::write(&shim, "@ECHO off\n").unwrap();
|
||||
let stdout = format!("{}\n{}\n", wrapper.display(), shim.display());
|
||||
|
||||
assert_eq!(first_existing_path_for_platform(&stdout, true), Some(shim));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn command_path_from_shell_finds_pi_from_interactive_login_shell() {
|
||||
|
||||
@@ -9,6 +9,7 @@ vi.mock('../lib/appUpdater', () => ({
|
||||
RESTART_REQUIRED_FOLDER_PICKER_MESSAGE:
|
||||
'Tolaria needs a restart before macOS can open another folder picker. Restart to apply the downloaded update and try again.',
|
||||
isRestartRequiredAfterUpdate: vi.fn(() => false),
|
||||
markRestartRequiredAfterUpdate: vi.fn(),
|
||||
}))
|
||||
|
||||
const openMock = vi.fn()
|
||||
@@ -21,12 +22,16 @@ import { pickFolder } from './vault-dialog'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import {
|
||||
isRestartRequiredAfterUpdate,
|
||||
markRestartRequiredAfterUpdate,
|
||||
RESTART_REQUIRED_FOLDER_PICKER_MESSAGE,
|
||||
} from '../lib/appUpdater'
|
||||
|
||||
describe('pickFolder', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.clearAllMocks()
|
||||
openMock.mockReset()
|
||||
vi.mocked(isRestartRequiredAfterUpdate).mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('returns user input from prompt in browser mode', async () => {
|
||||
@@ -70,6 +75,15 @@ describe('pickFolder', () => {
|
||||
await expect(pickFolder('Select vault')).rejects.toThrow(RESTART_REQUIRED_FOLDER_PICKER_MESSAGE)
|
||||
})
|
||||
|
||||
it('translates an NSOpenPanel panic into the restart-required folder picker error', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
vi.mocked(isRestartRequiredAfterUpdate).mockReturnValue(false)
|
||||
openMock.mockRejectedValue('panic: unexpected NULL returned from +[NSOpenPanel openPanel]')
|
||||
|
||||
await expect(pickFolder('Select vault')).rejects.toThrow(RESTART_REQUIRED_FOLDER_PICKER_MESSAGE)
|
||||
expect(markRestartRequiredAfterUpdate).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('normalizes a native single-selection array to its first folder path', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
vi.mocked(isRestartRequiredAfterUpdate).mockReturnValue(false)
|
||||
@@ -98,6 +112,7 @@ describe('pickFolder', () => {
|
||||
const secondRequest = pickFolder('Open vault folder')
|
||||
|
||||
await expect(secondRequest).resolves.toBeNull()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(openMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveOpen?.('/Users/test/restored-vault')
|
||||
|
||||
@@ -7,9 +7,12 @@
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import {
|
||||
isRestartRequiredAfterUpdate,
|
||||
markRestartRequiredAfterUpdate,
|
||||
RESTART_REQUIRED_FOLDER_PICKER_MESSAGE,
|
||||
} from '../lib/appUpdater'
|
||||
|
||||
const NS_OPEN_PANEL_UNAVAILABLE_MARKER = 'unexpected NULL returned from +[NSOpenPanel openPanel]'
|
||||
|
||||
export class NativeFolderPickerBlockedError extends Error {
|
||||
constructor(message = RESTART_REQUIRED_FOLDER_PICKER_MESSAGE) {
|
||||
super(message)
|
||||
@@ -23,6 +26,16 @@ export function isNativeFolderPickerBlockedError(
|
||||
return error instanceof NativeFolderPickerBlockedError
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (typeof error === 'string') return error
|
||||
if (error instanceof Error) return error.message
|
||||
return ''
|
||||
}
|
||||
|
||||
function isUnavailableNativeFolderPicker(error: unknown): boolean {
|
||||
return errorMessage(error).includes(NS_OPEN_PANEL_UNAVAILABLE_MARKER)
|
||||
}
|
||||
|
||||
export function formatFolderPickerActionError(
|
||||
action: string,
|
||||
error: unknown,
|
||||
@@ -31,12 +44,7 @@ export function formatFolderPickerActionError(
|
||||
return error.message
|
||||
}
|
||||
|
||||
const message =
|
||||
typeof error === 'string'
|
||||
? error
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: ''
|
||||
const message = errorMessage(error)
|
||||
|
||||
return message ? `${action}: ${message}` : action
|
||||
}
|
||||
@@ -77,6 +85,28 @@ function normalizePickedFolderPath(selected: string | string[] | null): string |
|
||||
|
||||
let folderPickerRequestInFlight = false
|
||||
|
||||
async function pickNativeFolder(title?: string): Promise<string | null> {
|
||||
if (isRestartRequiredAfterUpdate()) {
|
||||
throw new NativeFolderPickerBlockedError()
|
||||
}
|
||||
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog')
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: title ?? 'Select folder',
|
||||
})
|
||||
return normalizePickedFolderPath(selected)
|
||||
} catch (error) {
|
||||
if (isUnavailableNativeFolderPicker(error)) {
|
||||
markRestartRequiredAfterUpdate()
|
||||
throw new NativeFolderPickerBlockedError()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a native folder picker dialog (Tauri) or falls back to prompt (browser).
|
||||
* Returns the selected folder path, or null if the user cancelled.
|
||||
@@ -87,17 +117,7 @@ export async function pickFolder(title?: string): Promise<string | null> {
|
||||
folderPickerRequestInFlight = true
|
||||
try {
|
||||
if (isTauri()) {
|
||||
if (isRestartRequiredAfterUpdate()) {
|
||||
throw new NativeFolderPickerBlockedError()
|
||||
}
|
||||
|
||||
const { open } = await import('@tauri-apps/plugin-dialog')
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: title ?? 'Select folder',
|
||||
})
|
||||
return normalizePickedFolderPath(selected)
|
||||
return await pickNativeFolder(title)
|
||||
}
|
||||
return normalizePickedFolderPath(prompt(title ?? 'Enter folder path:'))
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user