Compare commits

..

2 Commits

Author SHA1 Message Date
lucaronin
bf3caab862 fix: recover blocked native folder picker 2026-06-07 15:00:51 +02:00
lucaronin
2556907f36 fix: launch Pi CLI from Windows shims 2026-06-07 12:04:55 +02:00
4 changed files with 157 additions and 30 deletions

View File

@@ -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) {

View File

@@ -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() {

View File

@@ -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')

View File

@@ -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 {