diff options
| author | t <t@tjp.lol> | 2026-06-13 12:09:00 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-15 15:08:32 -0600 |
| commit | 73324a15aa524cf75deebc4172f5a1f0d0051bc6 (patch) | |
| tree | 85c49f695b3cc5e2b3bbce63e9408ca3ff9b3cf3 /src/subcommand.zig | |
| parent | 1b5917e9eb309b4b78e8d2b88b66e7f7bc1834ef (diff) | |
auth: CLI auth manager + `panto auth` subcommands
Add src/auth_manager.zig (turn-time resolution: load→login→refresh→exchange→
build credential, patch live config) and `panto auth status|login|logout`
subcommands with a line-based device-code presenter. Add config_file.loadFromString.
Diffstat (limited to 'src/subcommand.zig')
| -rw-r--r-- | src/subcommand.zig | 207 |
1 files changed, 207 insertions, 0 deletions
diff --git a/src/subcommand.zig b/src/subcommand.zig index 21d2065..b145d4b 100644 --- a/src/subcommand.zig +++ b/src/subcommand.zig @@ -24,6 +24,8 @@ const lua_bridge = @import("lua_bridge.zig"); const luarocks_runtime = @import("luarocks_runtime.zig"); const self_exe = @import("self_exe.zig"); const session_paths = @import("session_paths.zig"); +const config_file = @import("config_file.zig"); +const panto_home = @import("panto_home.zig"); const panto = @import("panto"); const c = lua_bridge.c; @@ -78,6 +80,10 @@ pub fn dispatch( try runSessionsSubcommand(allocator, io, environ_map); return .done; } + if (std.mem.eql(u8, sub, "auth")) { + try runAuthSubcommand(allocator, io, environ_map, &it); + return .done; + } if (std.mem.eql(u8, sub, "--help") or std.mem.eql(u8, sub, "-h") or std.mem.eql(u8, sub, "help")) { try printHelp(io); return .done; @@ -97,6 +103,11 @@ fn printHelp(io: Io) !void { \\ panto --resume Resume the most recent conversation in this directory. \\ panto --resume <id> Resume the conversation whose id begins with <id>. \\ panto sessions List saved sessions for this directory. + \\ panto auth status Show configured auth sessions and login state. + \\ panto auth login <name> + \\ Log in to an OAuth auth session (device flow). + \\ panto auth logout <name> + \\ Forget a stored OAuth token. \\ panto bootstrap [--force] \\ Run the luarocks bootstrap and exit. \\ panto lua [args...] Drop into the embedded Lua interpreter. @@ -313,6 +324,202 @@ fn trimCreated(iso: []const u8) []const u8 { } // --------------------------------------------------------------------------- +// `panto auth` +// --------------------------------------------------------------------------- + +/// Line-based device-code presenter for the `panto auth login` flow (the TUI +/// is not running here). Prints the verification URL + user code to stdout. +const CliPresenter = struct { + io: Io, + + fn deviceCode(ptr: *anyopaque, prompt: panto.DeviceCodePrompt) void { + const self: *CliPresenter = @ptrCast(@alignCast(ptr)); + var buf: [1024]u8 = undefined; + var fw = std.Io.File.stdout().writer(self.io, &buf); + const w = &fw.interface; + w.print( + "\nTo authorize, open this URL in a browser:\n {s}\n\nand enter the code:\n {s}\n\n", + .{ prompt.verification_uri, prompt.user_code }, + ) catch {}; + fw.flush() catch {}; + } + + fn status(ptr: *anyopaque, msg: []const u8) void { + const self: *CliPresenter = @ptrCast(@alignCast(ptr)); + var buf: [256]u8 = undefined; + var fw = std.Io.File.stdout().writer(self.io, &buf); + const w = &fw.interface; + w.print("{s}\n", .{msg}) catch {}; + fw.flush() catch {}; + } + + const vtable: panto.Presenter.VTable = .{ + .on_device_code = deviceCode, + .on_status = status, + }; + + fn presenter(self: *CliPresenter) panto.Presenter { + return .{ .ptr = self, .vtable = &vtable }; + } +}; + +fn nowUnix(io: Io) i64 { + const ns = std.Io.Clock.now(.real, io).nanoseconds; + return @intCast(@divFloor(ns, std.time.ns_per_s)); +} + +/// `panto auth [status|login <name>|logout <name>]`. +fn runAuthSubcommand( + allocator: Allocator, + io: Io, + environ_map: *const std.process.Environ.Map, + it: *std.process.Args.Iterator, +) !void { + var cwd_buf: [std.fs.max_path_bytes]u8 = undefined; + const cwd_n = try std.process.currentPath(io, &cwd_buf); + const cwd = cwd_buf[0..cwd_n]; + + var cfg = config_file.load(allocator, io, environ_map, cwd) catch |err| { + std.log.err("panto auth: failed to load config ({t})", .{err}); + return err; + }; + defer cfg.deinit(); + + var layout = try panto_home.resolve(allocator, environ_map); + defer layout.deinit(); + const auth_dir = layout.auth_dir; + + var out_buf: [4096]u8 = undefined; + var out_file = std.Io.File.stdout().writer(io, &out_buf); + const out = &out_file.interface; + + const action = it.next() orelse "status"; + + if (std.mem.eql(u8, action, "status")) { + try authStatus(allocator, io, &cfg, auth_dir, out); + try out_file.flush(); + return; + } + if (std.mem.eql(u8, action, "logout")) { + const name = it.next() orelse { + try out.writeAll("usage: panto auth logout <name>\n"); + try out_file.flush(); + return; + }; + const removed = try panto.deleteTokenSet(allocator, io, auth_dir, name); + if (removed) { + try out.print("logged out of '{s}'\n", .{name}); + } else { + try out.print("no stored token for '{s}'\n", .{name}); + } + try out_file.flush(); + return; + } + if (std.mem.eql(u8, action, "login")) { + const name = it.next() orelse { + try out.writeAll("usage: panto auth login <name>\n"); + try out_file.flush(); + return; + }; + try authLogin(allocator, io, &cfg, auth_dir, name, out); + try out_file.flush(); + return; + } + + try out.print("unknown auth action '{s}' (try: status, login, logout)\n", .{action}); + try out_file.flush(); +} + +fn authStatus( + allocator: Allocator, + io: Io, + cfg: *const config_file.Config, + auth_dir: []const u8, + out: *std.Io.Writer, +) !void { + if (cfg.auths.len == 0) { + try out.writeAll("no auth sessions configured\n"); + return; + } + const now = nowUnix(io); + for (cfg.auths) |a| { + switch (a.config) { + .api_key => { + const state = if (a.resolved_api_key != null) "resolved" else "unresolved (key/env missing)"; + try out.print("{s} api_key {s}\n", .{ a.name, state }); + }, + .oauth_device => { + var loaded = panto.loadTokenSet(allocator, io, auth_dir, a.name) catch null; + defer if (loaded) |*l| l.deinit(); + if (loaded) |l| { + const ts = l.value; + if (ts.expires_at) |exp| { + const mins = @divFloor(exp - now, 60); + try out.print("{s} oauth_device logged in (access expires in ~{d}m)\n", .{ a.name, mins }); + } else { + try out.print("{s} oauth_device logged in\n", .{a.name}); + } + } else { + try out.print("{s} oauth_device not logged in (run: panto auth login {s})\n", .{ a.name, a.name }); + } + }, + } + } +} + +fn authLogin( + allocator: Allocator, + io: Io, + cfg: *const config_file.Config, + auth_dir: []const u8, + name: []const u8, + out: *std.Io.Writer, +) !void { + const a = cfg.auth(name) orelse { + try out.print("no auth session named '{s}' in config\n", .{name}); + return; + }; + const oauth = switch (a.config) { + .oauth_device => |o| o, + .api_key => { + try out.print("'{s}' is an api_key session; nothing to log in to\n", .{name}); + return; + }, + }; + + panto.init(allocator, io); + defer panto.deinit(); + const client = panto.httpClient(); + + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const aa = arena.allocator(); + + var presenter = CliPresenter{ .io = io }; + const toks = panto.oauthLogin(aa, io, client, oauth, presenter.presenter()) catch |err| { + try out.print("login failed: {t}\n", .{err}); + return; + }; + + const now = nowUnix(io); + var ts = try panto.tokensToTokenSet(aa, oauth, toks, now); + + // Run the secondary exchange now (if configured) so the first turn is + // immediately usable and we surface any exchange error during login. + if (oauth.exchange) |exchange| { + if (ts.access_token) |access| { + ts.exchange = panto.runExchange(aa, client, exchange, access) catch |err| blk: { + try out.print("note: token exchange failed ({t}); will retry on first use\n", .{err}); + break :blk null; + }; + } + } + + try panto.saveTokenSet(allocator, io, auth_dir, name, ts); + try out.print("\nauthorized — '{s}' is ready to use.\n", .{name}); +} + +// --------------------------------------------------------------------------- // `panto lua` argv plumbing — sketched against the older Args API for // reference (kept here so the design notes survive the implementation). // --------------------------------------------------------------------------- |
