diff --git a/build.zig b/build.zig index d898c8d..c21e269 100644 --- a/build.zig +++ b/build.zig @@ -42,10 +42,30 @@ pub fn build(b: *std.Build) void { .root_module = fake_curl_fail_module, }); const install_fake_curl_fail = b.addInstallArtifact(fake_curl_fail_exe, .{}); + const fake_codex_module = b.createModule(.{ + .root_source_file = b.path("tests/fake_codex.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + .imports = &.{ + .{ .name = "app_runtime", .module = b.createModule(.{ + .root_source_file = b.path("src/core/runtime.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }) }, + }, + }); + const fake_codex_exe = b.addExecutable(.{ + .name = "fake-codex", + .root_module = fake_codex_module, + }); + const install_fake_codex = b.addInstallArtifact(fake_codex_exe, .{}); const test_helpers_step = b.step("test-helpers", "Install test helper binaries"); test_helpers_step.dependOn(b.getInstallStep()); test_helpers_step.dependOn(&install_fake_curl.step); test_helpers_step.dependOn(&install_fake_curl_fail.step); + test_helpers_step.dependOn(&install_fake_codex.step); const run_cmd = b.addRunArtifact(exe); if (b.args) |args| { diff --git a/src/cli/login.zig b/src/cli/login.zig index e45d97b..4179de0 100644 --- a/src/cli/login.zig +++ b/src/cli/login.zig @@ -1,9 +1,60 @@ +const builtin = @import("builtin"); const std = @import("std"); +const http_env = @import("../api/http_env.zig"); +const http_executable = @import("../api/http_executable.zig"); const app_runtime = @import("../core/runtime.zig"); const io_util = @import("../core/io_util.zig"); const types = @import("types.zig"); const output = @import("output.zig"); +pub const WindowsCodexPathKind = enum { + exe, + cmd, + bat, + ps1, +}; + +pub const WindowsCodexPath = struct { + path: []u8, + kind: WindowsCodexPathKind, + + pub fn deinit(self: *WindowsCodexPath, allocator: std.mem.Allocator) void { + allocator.free(self.path); + } +}; + +const CodexLaunch = struct { + owned_paths: [1]?[]u8 = .{null}, + argv_storage: [9][]const u8 = undefined, + argv_len: usize = 0, + + fn argv(self: *const CodexLaunch) []const []const u8 { + return self.argv_storage[0..self.argv_len]; + } + + fn deinit(self: *CodexLaunch, allocator: std.mem.Allocator) void { + for (self.owned_paths) |maybe_path| { + if (maybe_path) |path| allocator.free(path); + } + } +}; + +const WindowsCodexPathList = std.ArrayList(WindowsCodexPath); + +const PowerShellHost = enum { + powershell, + pwsh, +}; + +pub const RetryableWindowsCodexBuildError = enum { + powershell_not_found, +}; + +pub const WindowsCodexLaunchFailure = struct { + hint_name: []const u8, + err: anyerror, +}; + pub fn codexLoginArgs(opts: types.LoginOptions) []const []const u8 { return if (opts.device_auth) &[_][]const u8{ "codex", "login", "--device-auth" } @@ -11,6 +62,247 @@ pub fn codexLoginArgs(opts: types.LoginOptions) []const []const u8 { &[_][]const u8{ "codex", "login" }; } +pub fn resolveWindowsCodexPathEntryAlloc( + allocator: std.mem.Allocator, + entry: []const u8, +) !?WindowsCodexPath { + var candidates = try collectWindowsCodexPathEntriesAlloc(allocator, &[_][]const u8{entry}); + errdefer deinitWindowsCodexPathList(allocator, &candidates); + + if (candidates.items.len == 0) return null; + + const resolved = candidates.orderedRemove(0); + deinitWindowsCodexPathList(allocator, &candidates); + return resolved; +} + +pub fn resolveWindowsCodexPathEntriesAlloc( + allocator: std.mem.Allocator, + entries: []const []const u8, +) !?WindowsCodexPath { + var candidates = try collectWindowsCodexPathEntriesAlloc(allocator, entries); + errdefer deinitWindowsCodexPathList(allocator, &candidates); + + if (candidates.items.len == 0) return null; + + const resolved = candidates.orderedRemove(0); + deinitWindowsCodexPathList(allocator, &candidates); + return resolved; +} + +fn resolvePathEntryCandidateAlloc( + allocator: std.mem.Allocator, + entry: []const u8, + candidate_name: []const u8, +) !?[]u8 { + const candidate = try std.fs.path.join(allocator, &[_][]const u8{ entry, candidate_name }); + errdefer allocator.free(candidate); + + if (!accessPath(candidate)) { + allocator.free(candidate); + return null; + } + + return candidate; +} + +fn windowsCodexCandidateName(kind: WindowsCodexPathKind) []const u8 { + return switch (kind) { + .exe => "codex.exe", + .cmd => "codex.cmd", + .bat => "codex.bat", + .ps1 => "codex.ps1", + }; +} + +fn appendWindowsCodexPathCandidateIfAvailable( + allocator: std.mem.Allocator, + candidates: *WindowsCodexPathList, + entry: []const u8, + kind: WindowsCodexPathKind, +) !void { + if (try resolvePathEntryCandidateAlloc(allocator, entry, windowsCodexCandidateName(kind))) |path| { + try candidates.append(allocator, .{ .path = path, .kind = kind }); + } +} + +fn appendWindowsCodexPathEntryCandidatesAlloc( + allocator: std.mem.Allocator, + native_candidates: *WindowsCodexPathList, + ps1_candidates: *WindowsCodexPathList, + entry: []const u8, +) !void { + try appendWindowsCodexPathCandidateIfAvailable(allocator, native_candidates, entry, .exe); + try appendWindowsCodexPathCandidateIfAvailable(allocator, native_candidates, entry, .cmd); + try appendWindowsCodexPathCandidateIfAvailable(allocator, native_candidates, entry, .bat); + try appendWindowsCodexPathCandidateIfAvailable(allocator, ps1_candidates, entry, .ps1); +} + +fn deinitWindowsCodexPathList(allocator: std.mem.Allocator, candidates: *WindowsCodexPathList) void { + for (candidates.items) |*candidate| candidate.deinit(allocator); + candidates.deinit(allocator); +} + +fn appendWindowsCodexPathLists( + allocator: std.mem.Allocator, + dst: *WindowsCodexPathList, + src: *WindowsCodexPathList, +) !void { + try dst.appendSlice(allocator, src.items); + src.clearRetainingCapacity(); +} + +fn collectWindowsCodexPathEntriesAlloc( + allocator: std.mem.Allocator, + entries: []const []const u8, +) !WindowsCodexPathList { + var native_candidates: WindowsCodexPathList = .empty; + errdefer deinitWindowsCodexPathList(allocator, &native_candidates); + var ps1_candidates: WindowsCodexPathList = .empty; + errdefer deinitWindowsCodexPathList(allocator, &ps1_candidates); + + for (entries) |entry| { + if (entry.len == 0) continue; + try appendWindowsCodexPathEntryCandidatesAlloc(allocator, &native_candidates, &ps1_candidates, entry); + } + + try appendWindowsCodexPathLists(allocator, &native_candidates, &ps1_candidates); + ps1_candidates.deinit(allocator); + return native_candidates; +} + +fn accessPath(path: []const u8) bool { + if (std.fs.path.isAbsolute(path)) { + std.Io.Dir.accessAbsolute(app_runtime.io(), path, .{}) catch return false; + return true; + } + + std.Io.Dir.cwd().access(app_runtime.io(), path, .{}) catch return false; + return true; +} + +fn resolveWindowsCodexPathValueAlloc( + allocator: std.mem.Allocator, + path_value: []const u8, + native_candidates: *WindowsCodexPathList, + ps1_candidates: *WindowsCodexPathList, +) !void { + var path_it = std.mem.splitScalar(u8, path_value, std.fs.path.delimiter); + while (path_it.next()) |entry| { + if (entry.len == 0) continue; + try appendWindowsCodexPathEntryCandidatesAlloc(allocator, native_candidates, ps1_candidates, entry); + } +} + +fn collectWindowsCodexPathsAlloc(allocator: std.mem.Allocator) !WindowsCodexPathList { + var native_candidates: WindowsCodexPathList = .empty; + errdefer deinitWindowsCodexPathList(allocator, &native_candidates); + var ps1_candidates: WindowsCodexPathList = .empty; + errdefer deinitWindowsCodexPathList(allocator, &ps1_candidates); + + const path_value = http_env.getEnvVarOwned(allocator, "PATH") catch |err| switch (err) { + error.EnvironmentVariableNotFound => { + try appendWindowsCodexPathLists(allocator, &native_candidates, &ps1_candidates); + ps1_candidates.deinit(allocator); + return native_candidates; + }, + else => return err, + }; + defer allocator.free(path_value); + + try resolveWindowsCodexPathValueAlloc(allocator, path_value, &native_candidates, &ps1_candidates); + try appendWindowsCodexPathLists(allocator, &native_candidates, &ps1_candidates); + ps1_candidates.deinit(allocator); + return native_candidates; +} + +fn resolveOptionalExecutableAlloc( + allocator: std.mem.Allocator, + executable: []const u8, +) !?[]u8 { + return http_executable.ensureExecutableAvailableAlloc(allocator, executable) catch |err| switch (err) { + error.ExecutableRequired => null, + else => return err, + }; +} + +fn resolveWindowsPowerShellExecutableAlloc(allocator: std.mem.Allocator) ![]u8 { + if (try resolveOptionalExecutableAlloc(allocator, "powershell.exe")) |path| return path; + if (try resolveOptionalExecutableAlloc(allocator, "pwsh.exe")) |path| return path; + return error.PowerShellNotFound; +} + +fn resolveWindowsPowerShellExecutableForHostAlloc( + allocator: std.mem.Allocator, + host: PowerShellHost, +) ![]u8 { + return switch (host) { + .powershell => (try resolveOptionalExecutableAlloc(allocator, "powershell.exe")) orelse error.PowerShellNotFound, + .pwsh => (try resolveOptionalExecutableAlloc(allocator, "pwsh.exe")) orelse error.PowerShellNotFound, + }; +} + +fn buildCodexLaunchAlloc(allocator: std.mem.Allocator, opts: types.LoginOptions) !CodexLaunch { + _ = allocator; + var launch = CodexLaunch{}; + const args = codexLoginArgs(opts); + @memcpy(launch.argv_storage[0..args.len], args); + launch.argv_len = args.len; + return launch; +} + +fn buildWindowsCodexLaunchAlloc( + allocator: std.mem.Allocator, + resolved: *const WindowsCodexPath, + opts: types.LoginOptions, +) !CodexLaunch { + switch (resolved.kind) { + .exe, .cmd, .bat => { + var launch = CodexLaunch{}; + launch.argv_storage[0] = resolved.path; + launch.argv_storage[1] = "login"; + launch.argv_len = 2; + if (opts.device_auth) { + launch.argv_storage[2] = "--device-auth"; + launch.argv_len = 3; + } + return launch; + }, + .ps1 => { + return buildWindowsPowerShellCodexLaunchAlloc(allocator, resolved.path, opts, null); + }, + } +} + +fn buildWindowsPowerShellCodexLaunchAlloc( + allocator: std.mem.Allocator, + script_path: []const u8, + opts: types.LoginOptions, + preferred_host: ?PowerShellHost, +) !CodexLaunch { + const powershell = if (preferred_host) |host| + try resolveWindowsPowerShellExecutableForHostAlloc(allocator, host) + else + try resolveWindowsPowerShellExecutableAlloc(allocator); + errdefer allocator.free(powershell); + + var launch = CodexLaunch{ .owned_paths = .{powershell} }; + launch.argv_storage[0] = powershell; + launch.argv_storage[1] = "-NoLogo"; + launch.argv_storage[2] = "-NoProfile"; + launch.argv_storage[3] = "-ExecutionPolicy"; + launch.argv_storage[4] = "Bypass"; + launch.argv_storage[5] = "-File"; + launch.argv_storage[6] = script_path; + launch.argv_storage[7] = "login"; + launch.argv_len = 8; + if (opts.device_auth) { + launch.argv_storage[8] = "--device-auth"; + launch.argv_len = 9; + } + return launch; +} + fn ensureCodexLoginSucceeded(term: std.process.Child.Term) !void { switch (term) { .exited => |code| { @@ -29,13 +321,162 @@ fn writeCodexLoginLaunchFailureHint(err_name: []const u8) !void { try out.flush(); } +fn retryableWindowsCodexBuildErrorName(err: RetryableWindowsCodexBuildError) []const u8 { + return switch (err) { + .powershell_not_found => "PowerShellNotFound", + }; +} + +fn retryableWindowsCodexBuildErrorValue(err: RetryableWindowsCodexBuildError) anyerror { + return switch (err) { + .powershell_not_found => error.PowerShellNotFound, + }; +} + +fn shouldRetryWindowsCodexBuild(err: anyerror, kind: WindowsCodexPathKind) ?RetryableWindowsCodexBuildError { + return switch (err) { + error.PowerShellNotFound => switch (kind) { + .ps1 => .powershell_not_found, + else => null, + }, + else => null, + }; +} + +pub fn finalRetryableWindowsCodexLaunchFailure( + last_retryable_spawn_error: ?std.process.SpawnError, + last_retryable_build_error: ?RetryableWindowsCodexBuildError, +) ?WindowsCodexLaunchFailure { + if (last_retryable_build_error) |build_err| { + if (last_retryable_spawn_error) |spawn_err| { + if (spawn_err != error.FileNotFound) { + return .{ + .hint_name = @errorName(spawn_err), + .err = spawn_err, + }; + } + } + + return .{ + .hint_name = retryableWindowsCodexBuildErrorName(build_err), + .err = retryableWindowsCodexBuildErrorValue(build_err), + }; + } + + if (last_retryable_spawn_error) |spawn_err| { + return .{ + .hint_name = @errorName(spawn_err), + .err = spawn_err, + }; + } + + return null; +} + +fn shouldRetryWindowsCodexLaunch(err: std.process.SpawnError, kind: WindowsCodexPathKind) bool { + return switch (err) { + error.FileNotFound => true, + error.AccessDenied => switch (kind) { + .exe, .cmd, .bat, .ps1 => true, + }, + else => false, + }; +} + +fn launchUsesWindowsPowerShellHost(launch: *const CodexLaunch) bool { + if (launch.argv_len == 0) return false; + return std.ascii.eqlIgnoreCase(std.fs.path.basename(launch.argv_storage[0]), "powershell.exe"); +} + pub fn runCodexLogin(opts: types.LoginOptions, codex_home: []const u8) !void { var env_map = try app_runtime.currentEnviron().createMap(std.heap.page_allocator); defer env_map.deinit(); try env_map.put("CODEX_HOME", codex_home); + if (builtin.os.tag == .windows) { + var candidates = try collectWindowsCodexPathsAlloc(std.heap.page_allocator); + defer deinitWindowsCodexPathList(std.heap.page_allocator, &candidates); + + if (candidates.items.len == 0) { + writeCodexLoginLaunchFailureHint("FileNotFound") catch {}; + return error.FileNotFound; + } + + var last_retryable_spawn_error: ?std.process.SpawnError = null; + var last_retryable_build_error: ?RetryableWindowsCodexBuildError = null; + candidate_loop: for (candidates.items) |*candidate| { + var launch = buildWindowsCodexLaunchAlloc(std.heap.page_allocator, candidate, opts) catch |err| { + if (shouldRetryWindowsCodexBuild(err, candidate.kind)) |retryable_err| { + last_retryable_build_error = retryable_err; + continue :candidate_loop; + } + writeCodexLoginLaunchFailureHint(@errorName(err)) catch {}; + return err; + }; + + var child = child: { + spawn_attempt: while (true) { + break :child std.process.spawn(app_runtime.io(), .{ + .argv = launch.argv(), + .environ_map = &env_map, + .stdin = .inherit, + .stdout = .inherit, + .stderr = .inherit, + }) catch |err| { + if (candidate.kind == .ps1 and launchUsesWindowsPowerShellHost(&launch) and err == error.AccessDenied) { + last_retryable_spawn_error = err; + launch.deinit(std.heap.page_allocator); + launch = buildWindowsPowerShellCodexLaunchAlloc( + std.heap.page_allocator, + candidate.path, + opts, + .pwsh, + ) catch |build_err| { + if (shouldRetryWindowsCodexBuild(build_err, candidate.kind)) |retryable_err| { + last_retryable_build_error = retryable_err; + continue :candidate_loop; + } + writeCodexLoginLaunchFailureHint(@errorName(build_err)) catch {}; + return build_err; + }; + continue :spawn_attempt; + } + + launch.deinit(std.heap.page_allocator); + if (shouldRetryWindowsCodexLaunch(err, candidate.kind)) { + last_retryable_spawn_error = err; + continue :candidate_loop; + } + writeCodexLoginLaunchFailureHint(@errorName(err)) catch {}; + return err; + }; + } + }; + launch.deinit(std.heap.page_allocator); + + const term = child.wait(app_runtime.io()) catch |err| { + writeCodexLoginLaunchFailureHint(@errorName(err)) catch {}; + return err; + }; + return ensureCodexLoginSucceeded(term); + } + + const failure = finalRetryableWindowsCodexLaunchFailure( + last_retryable_spawn_error, + last_retryable_build_error, + ) orelse unreachable; + writeCodexLoginLaunchFailureHint(failure.hint_name) catch {}; + return failure.err; + } + + var launch = buildCodexLaunchAlloc(std.heap.page_allocator, opts) catch |err| { + writeCodexLoginLaunchFailureHint(@errorName(err)) catch {}; + return err; + }; + defer launch.deinit(std.heap.page_allocator); + var child = std.process.spawn(app_runtime.io(), .{ - .argv = codexLoginArgs(opts), + .argv = launch.argv(), .environ_map = &env_map, .stdin = .inherit, .stdout = .inherit, diff --git a/src/cli/output.zig b/src/cli/output.zig index 97e71a9..df5054c 100644 --- a/src/cli/output.zig +++ b/src/cli/output.zig @@ -457,6 +457,10 @@ pub fn writeCodexLoginLaunchFailureHintTo(out: *std.Io.Writer, err_name: []const try writeHintPrefixTo(out, use_color); try out.writeAll(" Ensure the Codex CLI is installed and available in your environment.\n"); try out.writeAll(" Then run `codex login` manually and retry your command.\n"); + } else if (std.mem.eql(u8, err_name, "PowerShellNotFound")) { + try out.writeAll(" the `codex.ps1` launcher requires PowerShell, but neither `powershell.exe` nor `pwsh.exe` was found in your PATH.\n\n"); + try writeHintPrefixTo(out, use_color); + try out.writeAll(" Install PowerShell, or use a Codex CLI installation that provides `codex.exe`, `codex.cmd`, or `codex.bat`, then retry your command.\n"); } else { try out.writeAll(" failed to launch the `codex login` process.\n\n"); try writeHintPrefixTo(out, use_color); diff --git a/src/workflows/preflight.zig b/src/workflows/preflight.zig index 05bb89e..f646286 100644 --- a/src/workflows/preflight.zig +++ b/src/workflows/preflight.zig @@ -38,6 +38,7 @@ pub fn isHandledCliError(err: anyerror) bool { err == error.CodexCliPathNotAccessible or err == error.CodexCliPathNotFile or err == error.AppLaunchFailed or + err == error.PowerShellNotFound or err == error.UnsupportedRegistryVersion or err == error.WindowsAppLaunchRequiresWindows or err == error.WindowsAppPlatformRequiresWindows or diff --git a/tests/cli_behavior_test.zig b/tests/cli_behavior_test.zig index f5ce30d..b76b4e6 100644 --- a/tests/cli_behavior_test.zig +++ b/tests/cli_behavior_test.zig @@ -1,5 +1,6 @@ const std = @import("std"); const cli = @import("codex_auth").cli; +const fs = @import("codex_auth").core.compat_fs; const registry = @import("codex_auth").registry; const ansi = struct { @@ -807,6 +808,19 @@ test "Scenario: Given codex login client missing when rendering then detection h try std.testing.expect(std.mem.indexOf(u8, hint, "Ensure the Codex CLI is installed and available in your environment.") != null); } +test "Scenario: Given PowerShell is missing for the codex ps1 launcher when rendering then the hint names PowerShell" { + const gpa = std.testing.allocator; + var aw: std.Io.Writer.Allocating = .init(gpa); + defer aw.deinit(); + + try cli.output.writeCodexLoginLaunchFailureHintTo(&aw.writer, "PowerShellNotFound", false); + + const hint = aw.written(); + try std.testing.expect(std.mem.indexOf(u8, hint, "the `codex.ps1` launcher requires PowerShell") != null); + try std.testing.expect(std.mem.indexOf(u8, hint, "Install PowerShell, or use a Codex CLI installation that provides `codex.exe`, `codex.cmd`, or `codex.bat`, then retry your command.") != null); + try std.testing.expect(std.mem.indexOf(u8, hint, "the `codex` executable was not found in your PATH.") == null); +} + test "Scenario: Given login help when rendering then device auth usage is included" { const gpa = std.testing.allocator; var aw: std.Io.Writer.Allocating = .init(gpa); @@ -824,6 +838,159 @@ test "Scenario: Given login options when building codex argv then device auth is try expectArgv(cli.login.codexLoginArgs(.{ .device_auth = true }), &[_][]const u8{ "codex", "login", "--device-auth" }); } +test "Scenario: Given winget and npm Windows launchers when resolving then PATH entry order is preserved" { + const gpa = std.testing.allocator; + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.makePath("winget-bin"); + try tmp.dir.makePath("npm-bin"); + try tmp.dir.writeFile(.{ .sub_path = "winget-bin/codex.exe", .data = "" }); + try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex", .data = "#!/bin/sh\nexit 1\n" }); + try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex.cmd", .data = "@echo off\r\nexit /b 0\r\n" }); + try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex.bat", .data = "@echo off\r\nexit /b 0\r\n" }); + try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex.ps1", .data = "exit 0\n" }); + const root_dir = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(root_dir); + const winget_dir = try std.fs.path.join(gpa, &[_][]const u8{ root_dir, "winget-bin" }); + defer gpa.free(winget_dir); + const npm_dir = try std.fs.path.join(gpa, &[_][]const u8{ root_dir, "npm-bin" }); + defer gpa.free(npm_dir); + + var exe_first = (try cli.login.resolveWindowsCodexPathEntriesAlloc(gpa, &[_][]const u8{ winget_dir, npm_dir })) orelse return error.TestUnexpectedResult; + defer exe_first.deinit(gpa); + try std.testing.expectEqual(cli.login.WindowsCodexPathKind.exe, exe_first.kind); + try std.testing.expect(std.mem.endsWith(u8, exe_first.path, "codex.exe")); + + var cmd_first = (try cli.login.resolveWindowsCodexPathEntriesAlloc(gpa, &[_][]const u8{ npm_dir, winget_dir })) orelse return error.TestUnexpectedResult; + defer cmd_first.deinit(gpa); + try std.testing.expectEqual(cli.login.WindowsCodexPathKind.cmd, cmd_first.kind); + try std.testing.expect(std.mem.endsWith(u8, cmd_first.path, "codex.cmd")); +} + +test "Scenario: Given exe cmd bat and ps1 in one Windows directory when resolving then the fixed launcher priority wins" { + const gpa = std.testing.allocator; + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.makePath("mixed-bin"); + try tmp.dir.writeFile(.{ .sub_path = "mixed-bin/codex.exe", .data = "" }); + try tmp.dir.writeFile(.{ .sub_path = "mixed-bin/codex.cmd", .data = "@echo off\r\nexit /b 0\r\n" }); + try tmp.dir.writeFile(.{ .sub_path = "mixed-bin/codex.bat", .data = "@echo off\r\nexit /b 0\r\n" }); + try tmp.dir.writeFile(.{ .sub_path = "mixed-bin/codex.ps1", .data = "exit 0\n" }); + const root_dir = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(root_dir); + const mixed_dir = try std.fs.path.join(gpa, &[_][]const u8{ root_dir, "mixed-bin" }); + defer gpa.free(mixed_dir); + + var resolved = (try cli.login.resolveWindowsCodexPathEntriesAlloc(gpa, &[_][]const u8{mixed_dir})) orelse return error.TestUnexpectedResult; + defer resolved.deinit(gpa); + try std.testing.expectEqual(cli.login.WindowsCodexPathKind.exe, resolved.kind); + try std.testing.expect(std.mem.endsWith(u8, resolved.path, "codex.exe")); +} + +test "Scenario: Given only a batch Windows launcher when resolving then bat is used before ps1" { + const gpa = std.testing.allocator; + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.makePath("npm-bin"); + try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex", .data = "#!/bin/sh\nexit 1\n" }); + try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex.bat", .data = "@echo off\r\nexit /b 0\r\n" }); + try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex.ps1", .data = "exit 0\n" }); + + const root_dir = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(root_dir); + const npm_dir = try std.fs.path.join(gpa, &[_][]const u8{ root_dir, "npm-bin" }); + defer gpa.free(npm_dir); + + var resolved = (try cli.login.resolveWindowsCodexPathEntriesAlloc(gpa, &[_][]const u8{npm_dir})) orelse return error.TestUnexpectedResult; + defer resolved.deinit(gpa); + try std.testing.expectEqual(cli.login.WindowsCodexPathKind.bat, resolved.kind); + try std.testing.expect(std.mem.endsWith(u8, resolved.path, "codex.bat")); +} + +test "Scenario: Given only the bare npm shell launcher on Windows when resolving then it is ignored" { + const gpa = std.testing.allocator; + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.makePath("npm-bin"); + try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex", .data = "#!/bin/sh\nexit 1\n" }); + + const root_dir = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(root_dir); + const npm_dir = try std.fs.path.join(gpa, &[_][]const u8{ root_dir, "npm-bin" }); + defer gpa.free(npm_dir); + + try std.testing.expect((try cli.login.resolveWindowsCodexPathEntriesAlloc(gpa, &[_][]const u8{npm_dir})) == null); +} + +test "Scenario: Given an earlier PowerShell launcher and a later native Windows launcher when resolving then ps1 stays a global fallback" { + const gpa = std.testing.allocator; + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.makePath("npm-bin"); + try tmp.dir.makePath("winget-bin"); + try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex", .data = "#!/bin/sh\nexit 1\n" }); + try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex.ps1", .data = "exit 0\n" }); + try tmp.dir.writeFile(.{ .sub_path = "winget-bin/codex.exe", .data = "" }); + + const root_dir = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(root_dir); + const npm_dir = try std.fs.path.join(gpa, &[_][]const u8{ root_dir, "npm-bin" }); + defer gpa.free(npm_dir); + const winget_dir = try std.fs.path.join(gpa, &[_][]const u8{ root_dir, "winget-bin" }); + defer gpa.free(winget_dir); + + var resolved = (try cli.login.resolveWindowsCodexPathEntriesAlloc(gpa, &[_][]const u8{ npm_dir, winget_dir })) orelse return error.TestUnexpectedResult; + defer resolved.deinit(gpa); + + try std.testing.expectEqual(cli.login.WindowsCodexPathKind.exe, resolved.kind); + try std.testing.expect(std.mem.endsWith(u8, resolved.path, "codex.exe")); +} + +test "Scenario: Given only PowerShell Windows launcher when resolving then ps1 is used after cmd is absent" { + const gpa = std.testing.allocator; + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.makePath("npm-bin"); + try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex", .data = "#!/bin/sh\nexit 1\n" }); + try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex.ps1", .data = "exit 0\n" }); + + const root_dir = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(root_dir); + const npm_dir = try std.fs.path.join(gpa, &[_][]const u8{ root_dir, "npm-bin" }); + defer gpa.free(npm_dir); + + var resolved = (try cli.login.resolveWindowsCodexPathEntriesAlloc(gpa, &[_][]const u8{npm_dir})) orelse return error.TestUnexpectedResult; + defer resolved.deinit(gpa); + try std.testing.expectEqual(cli.login.WindowsCodexPathKind.ps1, resolved.kind); + try std.testing.expect(std.mem.endsWith(u8, resolved.path, "codex.ps1")); +} + +test "Scenario: Given retryable Windows build and spawn failures when selecting the final hint then build failure beats generic FileNotFound" { + const failure = cli.login.finalRetryableWindowsCodexLaunchFailure( + error.FileNotFound, + .powershell_not_found, + ) orelse return error.TestUnexpectedResult; + + try std.testing.expectEqualStrings("PowerShellNotFound", failure.hint_name); + try std.testing.expect(failure.err == error.PowerShellNotFound); +} + +test "Scenario: Given retryable Windows build and spawn failures when selecting the final hint then non-generic spawn failure still wins" { + const failure = cli.login.finalRetryableWindowsCodexLaunchFailure( + error.AccessDenied, + .powershell_not_found, + ) orelse return error.TestUnexpectedResult; + + try std.testing.expectEqualStrings("AccessDenied", failure.hint_name); + try std.testing.expect(failure.err == error.AccessDenied); +} + test "Scenario: Given switch with positional query when parsing then non-interactive target is preserved" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "switch", "user@example.com" }; diff --git a/tests/cli_integration_test.zig b/tests/cli_integration_test.zig index 4dbc500..50af228 100644 --- a/tests/cli_integration_test.zig +++ b/tests/cli_integration_test.zig @@ -138,6 +138,22 @@ fn fakeCodexCommandPath() []const u8 { return if (builtin.os.tag == .windows) "fake-bin/codex.cmd" else "fake-bin/codex"; } +fn fakeCodexPowerShellPath() []const u8 { + return "fake-bin/codex.ps1"; +} + +fn fakeCodexBatchPath() []const u8 { + return "fake-bin/codex.bat"; +} + +fn fakeCodexExePath() []const u8 { + return "fake-bin/codex.exe"; +} + +fn fakeBareWindowsCodexPath() []const u8 { + return "fake-bin/codex"; +} + fn writeFailingFakeCodex(dir: fs.Dir, exit_code: u8) !void { var script_buf: [128]u8 = undefined; const script = if (builtin.os.tag == .windows) @@ -158,6 +174,7 @@ fn writeSuccessfulFakeCodex(dir: fs.Dir) !void { const script = if (builtin.os.tag == .windows) "@echo off\r\n" ++ + ">\"%HOME%\\fake-codex-launcher.txt\" echo cmd\r\n" ++ ">\"%HOME%\\fake-codex-argv.txt\" echo %*\r\n" ++ ">\"%HOME%\\fake-codex-home.txt\" echo %CODEX_HOME%\r\n" ++ "set \"CODEX_HOME_DIR=%CODEX_HOME%\"\r\n" ++ @@ -167,6 +184,7 @@ fn writeSuccessfulFakeCodex(dir: fs.Dir) !void { "exit /b 0\r\n" else "#!/bin/sh\n" ++ + "printf '%s\\n' 'posix' > \"$HOME/fake-codex-launcher.txt\"\n" ++ "printf '%s\\n' \"$*\" > \"$HOME/fake-codex-argv.txt\"\n" ++ "printf '%s\\n' \"$CODEX_HOME\" > \"$HOME/fake-codex-home.txt\"\n" ++ "CODEX_HOME_DIR=\"${CODEX_HOME:-$HOME/.codex}\"\n" ++ @@ -187,6 +205,7 @@ fn writeStrictExistingCodexHomeFakeCodex(dir: fs.Dir) !void { const script = if (builtin.os.tag == .windows) "@echo off\r\n" ++ + ">\"%HOME%\\fake-codex-launcher.txt\" echo cmd\r\n" ++ ">\"%HOME%\\fake-codex-argv.txt\" echo %*\r\n" ++ ">\"%HOME%\\fake-codex-home.txt\" echo %CODEX_HOME%\r\n" ++ "set \"CODEX_HOME_DIR=%CODEX_HOME%\"\r\n" ++ @@ -196,6 +215,7 @@ fn writeStrictExistingCodexHomeFakeCodex(dir: fs.Dir) !void { "exit /b 0\r\n" else "#!/bin/sh\n" ++ + "printf '%s\\n' 'posix' > \"$HOME/fake-codex-launcher.txt\"\n" ++ "printf '%s\\n' \"$*\" > \"$HOME/fake-codex-argv.txt\"\n" ++ "printf '%s\\n' \"$CODEX_HOME\" > \"$HOME/fake-codex-home.txt\"\n" ++ "CODEX_HOME_DIR=\"${CODEX_HOME:-$HOME/.codex}\"\n" ++ @@ -212,6 +232,74 @@ fn writeStrictExistingCodexHomeFakeCodex(dir: fs.Dir) !void { } } +fn writeStrictExistingCodexHomeFakeCodexBatch(dir: fs.Dir) !void { + if (builtin.os.tag != .windows) return; + + const script = + "@echo off\r\n" ++ + ">\"%HOME%\\fake-codex-launcher.txt\" echo bat\r\n" ++ + ">\"%HOME%\\fake-codex-argv.txt\" echo %*\r\n" ++ + ">\"%HOME%\\fake-codex-home.txt\" echo %CODEX_HOME%\r\n" ++ + "set \"CODEX_HOME_DIR=%CODEX_HOME%\"\r\n" ++ + "if \"%CODEX_HOME_DIR%\"==\"\" set \"CODEX_HOME_DIR=%HOME%\\.codex\"\r\n" ++ + "if not exist \"%CODEX_HOME_DIR%\" exit /b 42\r\n" ++ + "copy /Y \"%HOME%\\fake-auth.json\" \"%CODEX_HOME_DIR%\\auth.json\" >NUL\r\n" ++ + "exit /b 0\r\n"; + + try dir.writeFile(.{ .sub_path = fakeCodexBatchPath(), .data = script }); +} + +fn writeBrokenBareWindowsCodex(dir: fs.Dir) !void { + if (builtin.os.tag != .windows) return; + try dir.writeFile(.{ + .sub_path = fakeBareWindowsCodexPath(), + .data = "#!/bin/sh\nexit 99\n", + }); +} + +fn writeSuccessfulFakeCodexPowerShellAt(dir: fs.Dir, sub_path: []const u8) !void { + if (builtin.os.tag != .windows) return; + + const script = + "$homePath = $env:HOME\r\n" ++ + "[System.IO.File]::WriteAllText((Join-Path $homePath 'fake-codex-launcher.txt'), \"ps1`n\")\r\n" ++ + "[System.IO.File]::WriteAllText((Join-Path $homePath 'fake-codex-argv.txt'), (($args -join ' ') + \"`n\"))\r\n" ++ + "[System.IO.File]::WriteAllText((Join-Path $homePath 'fake-codex-home.txt'), ($env:CODEX_HOME + \"`n\"))\r\n" ++ + "$codexHomeDir = $env:CODEX_HOME\r\n" ++ + "if ([string]::IsNullOrEmpty($codexHomeDir)) { $codexHomeDir = Join-Path $homePath '.codex' }\r\n" ++ + "if (-not (Test-Path -LiteralPath $codexHomeDir)) { New-Item -ItemType Directory -Path $codexHomeDir | Out-Null }\r\n" ++ + "Copy-Item -Force (Join-Path $homePath 'fake-auth.json') (Join-Path $codexHomeDir 'auth.json')\r\n"; + + try dir.writeFile(.{ .sub_path = sub_path, .data = script }); +} + +fn writeSuccessfulFakeCodexPowerShell(dir: fs.Dir) !void { + try writeSuccessfulFakeCodexPowerShellAt(dir, fakeCodexPowerShellPath()); +} + +fn writeSuccessfulFakeCodexExeAt( + allocator: std.mem.Allocator, + dir: fs.Dir, + project_root: []const u8, + sub_path: []const u8, +) !void { + if (builtin.os.tag != .windows) return; + + const built_fake_codex = try builtFakeCodexPathAlloc(allocator, project_root); + defer allocator.free(built_fake_codex); + const fake_codex_data = try fixtures.readFileAlloc(allocator, built_fake_codex); + defer allocator.free(fake_codex_data); + try dir.writeFile(.{ .sub_path = sub_path, .data = fake_codex_data }); +} + +fn writeSuccessfulFakeCodexExe( + allocator: std.mem.Allocator, + dir: fs.Dir, + project_root: []const u8, +) !void { + try writeSuccessfulFakeCodexExeAt(allocator, dir, project_root, fakeCodexExePath()); +} + fn fakeCurlCommandPath() []const u8 { return if (builtin.os.tag == .windows) "fake-curl-bin/curl.exe" else "fake-curl-bin/curl"; } @@ -295,6 +383,18 @@ fn builtFakeCurlFailPathAlloc(allocator: std.mem.Allocator, project_root: []cons return fs.path.join(allocator, &[_][]const u8{ prefix, "bin", exe_name }); } +fn builtFakeCodexPathAlloc(allocator: std.mem.Allocator, project_root: []const u8) ![]u8 { + const exe_name = if (builtin.os.tag == .windows) "fake-codex.exe" else "fake-codex"; + const install_prefix = getEnvVarOwned(allocator, cli_integration_install_prefix_env) catch |err| switch (err) { + error.EnvironmentVariableNotFound => null, + else => return err, + }; + defer if (install_prefix) |dir| allocator.free(dir); + + const prefix = install_prefix orelse return fs.path.join(allocator, &[_][]const u8{ project_root, "zig-out", "bin", exe_name }); + return fs.path.join(allocator, &[_][]const u8{ prefix, "bin", exe_name }); +} + fn prependPathEntryAlloc(allocator: std.mem.Allocator, entry: []const u8) ![]u8 { var env_map = try getEnvMap(allocator); defer env_map.deinit(); @@ -874,6 +974,251 @@ test "Scenario: Given strict codex login when running login then scratch CODEX_H try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[0].email, expected_email)); } +test "Scenario: Given npm-style Windows codex wrappers when running login then the bare script is ignored and codex.cmd is launched" { + if (builtin.os.tag != .windows) return error.SkipZigTest; + + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + try tmp.dir.makePath(".codex"); + try tmp.dir.makePath("fake-bin"); + + const expected_email = "windows-cmd@example.com"; + const fake_auth = try fixtures.authJsonWithEmailPlan(gpa, expected_email, "plus"); + defer gpa.free(fake_auth); + try tmp.dir.writeFile(.{ .sub_path = "fake-auth.json", .data = fake_auth }); + try writeBrokenBareWindowsCodex(tmp.dir); + try writeStrictExistingCodexHomeFakeCodex(tmp.dir); + + const fake_bin_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "fake-bin" }); + defer gpa.free(fake_bin_path); + const path_override = try prependPathEntryAlloc(gpa, fake_bin_path); + defer gpa.free(path_override); + + const result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + path_override, + &[_][]const u8{ "login", "--device-auth" }, + ); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectSuccess(result); + + const launcher_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "fake-codex-launcher.txt" }); + defer gpa.free(launcher_path); + const launcher_data = try fixtures.readFileAlloc(gpa, launcher_path); + defer gpa.free(launcher_data); + try std.testing.expectEqualStrings("cmd", std.mem.trim(u8, launcher_data, " \r\n")); +} + +test "Scenario: Given only a Windows batch codex wrapper when running login then codex.bat is launched" { + if (builtin.os.tag != .windows) return error.SkipZigTest; + + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + try tmp.dir.makePath(".codex"); + try tmp.dir.makePath("fake-bin"); + + const expected_email = "windows-bat@example.com"; + const fake_auth = try fixtures.authJsonWithEmailPlan(gpa, expected_email, "plus"); + defer gpa.free(fake_auth); + try tmp.dir.writeFile(.{ .sub_path = "fake-auth.json", .data = fake_auth }); + try writeBrokenBareWindowsCodex(tmp.dir); + try writeStrictExistingCodexHomeFakeCodexBatch(tmp.dir); + + const fake_bin_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "fake-bin" }); + defer gpa.free(fake_bin_path); + const path_override = try prependPathEntryAlloc(gpa, fake_bin_path); + defer gpa.free(path_override); + + const result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + path_override, + &[_][]const u8{ "login", "--device-auth" }, + ); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectSuccess(result); + + const launcher_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "fake-codex-launcher.txt" }); + defer gpa.free(launcher_path); + const launcher_data = try fixtures.readFileAlloc(gpa, launcher_path); + defer gpa.free(launcher_data); + try std.testing.expectEqualStrings("bat", std.mem.trim(u8, launcher_data, " \r\n")); +} + +test "Scenario: Given only a PowerShell Windows codex wrapper when running login then codex.ps1 is launched" { + if (builtin.os.tag != .windows) return error.SkipZigTest; + + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + try tmp.dir.makePath(".codex"); + try tmp.dir.makePath("fake-bin"); + + const expected_email = "windows-ps1@example.com"; + const fake_auth = try fixtures.authJsonWithEmailPlan(gpa, expected_email, "plus"); + defer gpa.free(fake_auth); + try tmp.dir.writeFile(.{ .sub_path = "fake-auth.json", .data = fake_auth }); + try writeBrokenBareWindowsCodex(tmp.dir); + try writeSuccessfulFakeCodexPowerShell(tmp.dir); + + const fake_bin_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "fake-bin" }); + defer gpa.free(fake_bin_path); + const path_override = try prependPathEntryAlloc(gpa, fake_bin_path); + defer gpa.free(path_override); + + const result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + path_override, + &[_][]const u8{ "login", "--device-auth" }, + ); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectSuccess(result); + + const launcher_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "fake-codex-launcher.txt" }); + defer gpa.free(launcher_path); + const launcher_data = try fixtures.readFileAlloc(gpa, launcher_path); + defer gpa.free(launcher_data); + try std.testing.expectEqualStrings("ps1", std.mem.trim(u8, launcher_data, " \r\n")); +} + +test "Scenario: Given a winget-style Windows codex launcher when running login then codex.exe is launched" { + if (builtin.os.tag != .windows) return error.SkipZigTest; + + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + try tmp.dir.makePath(".codex"); + try tmp.dir.makePath("fake-bin"); + + const expected_email = "windows-exe@example.com"; + const fake_auth = try fixtures.authJsonWithEmailPlan(gpa, expected_email, "plus"); + defer gpa.free(fake_auth); + try tmp.dir.writeFile(.{ .sub_path = "fake-auth.json", .data = fake_auth }); + try writeSuccessfulFakeCodexExe(gpa, tmp.dir, project_root); + + const fake_bin_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "fake-bin" }); + defer gpa.free(fake_bin_path); + const path_override = try prependPathEntryAlloc(gpa, fake_bin_path); + defer gpa.free(path_override); + + const result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + path_override, + &[_][]const u8{ "login", "--device-auth" }, + ); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectSuccess(result); + + const launcher_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "fake-codex-launcher.txt" }); + defer gpa.free(launcher_path); + const launcher_data = try fixtures.readFileAlloc(gpa, launcher_path); + defer gpa.free(launcher_data); + try std.testing.expectEqualStrings("exe", std.mem.trim(u8, launcher_data, " \r\n")); +} + +test "Scenario: Given an earlier PowerShell launcher and a later exe launcher when running login then ps1 stays a global fallback" { + if (builtin.os.tag != .windows) return error.SkipZigTest; + + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + try tmp.dir.makePath(".codex"); + try tmp.dir.makePath("ps1-bin"); + try tmp.dir.makePath("exe-bin"); + + const expected_email = "windows-ps1-first@example.com"; + const fake_auth = try fixtures.authJsonWithEmailPlan(gpa, expected_email, "plus"); + defer gpa.free(fake_auth); + try tmp.dir.writeFile(.{ .sub_path = "fake-auth.json", .data = fake_auth }); + try tmp.dir.writeFile(.{ .sub_path = "ps1-bin/codex", .data = "#!/bin/sh\nexit 99\n" }); + + try writeSuccessfulFakeCodexPowerShellAt(tmp.dir, "ps1-bin/codex.ps1"); + try writeSuccessfulFakeCodexExeAt(gpa, tmp.dir, project_root, "exe-bin/codex.exe"); + + const ps1_bin_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "ps1-bin" }); + defer gpa.free(ps1_bin_path); + const exe_bin_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "exe-bin" }); + defer gpa.free(exe_bin_path); + const exe_then_inherited_path = try prependPathEntryAlloc(gpa, exe_bin_path); + defer gpa.free(exe_then_inherited_path); + const path_override = try std.fmt.allocPrint(gpa, "{s}{c}{s}", .{ + ps1_bin_path, + fs.path.delimiter, + exe_then_inherited_path, + }); + defer gpa.free(path_override); + + const result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + path_override, + &[_][]const u8{ "login", "--device-auth" }, + ); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectSuccess(result); + + const launcher_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "fake-codex-launcher.txt" }); + defer gpa.free(launcher_path); + const launcher_data = try fixtures.readFileAlloc(gpa, launcher_path); + defer gpa.free(launcher_data); + try std.testing.expectEqualStrings("exe", std.mem.trim(u8, launcher_data, " \r\n")); +} + test "Scenario: Given refreshed active auth before login when running login then old account snapshot is synced first" { const gpa = std.testing.allocator; const project_root = try projectRootAlloc(gpa); diff --git a/tests/fake_codex.zig b/tests/fake_codex.zig new file mode 100644 index 0000000..94ee98e --- /dev/null +++ b/tests/fake_codex.zig @@ -0,0 +1,46 @@ +const std = @import("std"); +const app_runtime = @import("app_runtime"); + +fn getEnvVarOwned(allocator: std.mem.Allocator, name: []const u8) ![]u8 { + var env_map = try app_runtime.currentEnviron().createMap(allocator); + defer env_map.deinit(); + + const value = env_map.get(name) orelse return error.EnvironmentVariableNotFound; + return try allocator.dupe(u8, value); +} + +pub fn main(init: std.process.Init) !void { + const io = init.io; + const arena = init.arena.allocator(); + const args = try init.minimal.args.toSlice(arena); + + const home_root = try getEnvVarOwned(arena, "HOME"); + const codex_home = getEnvVarOwned(arena, "CODEX_HOME") catch |err| switch (err) { + error.EnvironmentVariableNotFound => try std.fs.path.join(arena, &[_][]const u8{ home_root, ".codex" }), + else => return err, + }; + + var home_dir = try std.Io.Dir.openDirAbsolute(io, home_root, .{}); + defer home_dir.close(io); + + var argv_buf = std.ArrayList(u8).empty; + defer argv_buf.deinit(arena); + for (args[1..], 0..) |arg, i| { + if (i != 0) try argv_buf.append(arena, ' '); + try argv_buf.appendSlice(arena, arg); + } + try argv_buf.append(arena, '\n'); + + try home_dir.writeFile(io, .{ .sub_path = "fake-codex-launcher.txt", .data = "exe\n" }); + try home_dir.writeFile(io, .{ .sub_path = "fake-codex-argv.txt", .data = argv_buf.items }); + + const codex_home_with_newline = try std.mem.concat(arena, u8, &[_][]const u8{ codex_home, "\n" }); + try home_dir.writeFile(io, .{ .sub_path = "fake-codex-home.txt", .data = codex_home_with_newline }); + + try std.Io.Dir.cwd().createDirPath(io, codex_home); + var codex_home_dir = try std.Io.Dir.openDirAbsolute(io, codex_home, .{}); + defer codex_home_dir.close(io); + + const auth_data = try home_dir.readFileAlloc(io, "fake-auth.json", arena, .limited(1024 * 1024)); + try codex_home_dir.writeFile(io, .{ .sub_path = "auth.json", .data = auth_data }); +} diff --git a/tests/workflows_live_test.zig b/tests/workflows_live_test.zig index 68ec42f..cf72219 100644 --- a/tests/workflows_live_test.zig +++ b/tests/workflows_live_test.zig @@ -34,6 +34,10 @@ test "handled cli errors include missing curl" { try std.testing.expect(isHandledCliError(error.CurlRequired)); } +test "handled cli errors include missing PowerShell for codex ps1 launcher" { + try std.testing.expect(isHandledCliError(error.PowerShellNotFound)); +} + test "curl preflight skips api key only usage refreshes" { const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{});