//! CLI over the core: for debugging, and for parity tests against the TypeScript
//! reference.
const std = @import("std");
const usage = @import("usage.zig");
const help_text =
\\usage <command>
\\ providers supported providers info
\\ advance <provider> <credential.json> <responses.json>... advance feed state across rounds
\\ [--now-ms N]
\\ advance - the same, from one JSON envelope line on stdin:
\\ {"provider","credential","responses":[round...],"nowMs"?}
\\ claims <access-token> account id and email from an OAuth token
\\
;
fn writeTo(file: std.Io.File, io: std.Io, bytes: []const u8) !void {
var buffer: [8192]u8 = undefined;
var writer = file.writer(io, &buffer);
try writer.interface.writeAll(bytes);
try writer.flush();
}
fn emit(init: std.process.Init, bytes: []const u8) !void {
try writeTo(std.Io.File.stdout(), init.io, bytes);
try writeTo(std.Io.File.stdout(), init.io, "\n");
}
fn fail(init: std.process.Init, message: []const u8) !u8 {
try writeTo(std.Io.File.stderr(), init.io, message);
try writeTo(std.Io.File.stderr(), init.io, "\n\n");
try writeTo(std.Io.File.stderr(), init.io, help_text);
return 1;
}
fn nowMs() i64 {
var ts: std.posix.timespec = undefined;
if (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts)) != .SUCCESS) return 0;
return @as(i64, ts.sec) * 1000 + @divTrunc(@as(i64, ts.nsec), 1_000_000);
}
fn readFile(init: std.process.Init, path: []const u8, arena: std.mem.Allocator) ![]u8 {
return std.Io.Dir.cwd().readFileAlloc(init.io, path, arena, std.Io.Limit.limited(8 * 1024 * 1024));
}
/// Replays every round through one fresh state, printing one step per line: a host keeps the
/// responses, and the process keeps nothing.
fn advanceRounds(
init: std.process.Init,
arena: std.mem.Allocator,
provider: []const u8,
credential: []const u8,
rounds: []const []const u8,
now_ms: i64,
) !u8 {
var state = usage.State.init(arena);
defer state.deinit();
for (rounds) |responses| {
var w = usage.Writer.init(arena);
const outcome = usage.advance(arena, &state, provider, credential, responses, now_ms) catch |e| {
var err_w = usage.Writer.init(arena);
usage.writeStep(&err_w, .{ .err = @errorName(e) }) catch return 1;
try emit(init, err_w.items());
return 0;
};
try usage.writeStep(&w, outcome);
try emit(init, w.items());
}
return 0;
}
/// `advance -`: the whole call as one JSON envelope on stdin, so a credential never reaches argv,
/// where any local process can read it. The envelope ends at the first newline, not only at EOF:
/// a host that cannot half-close a pipe (an Erlang port) writes one line and reads the answer.
fn advanceEnvelope(init: std.process.Init, arena: std.mem.Allocator) !u8 {
const buffer = try arena.alloc(u8, 8 * 1024 * 1024);
var reader = std.Io.File.stdin().readerStreaming(init.io, buffer);
const input = (reader.interface.takeDelimiter('\n') catch |e| return fail(init, @errorName(e))) orelse
return fail(init, "the envelope is empty");
const parsed = std.json.parseFromSliceLeaky(std.json.Value, arena, input, .{}) catch
return fail(init, "the envelope is not JSON");
const root = usage.asObject(parsed) orelse return fail(init, "the envelope is not an object");
const provider = usage.asString(root.get("provider")) orelse return fail(init, "the envelope needs a provider");
const credential = root.get("credential") orelse return fail(init, "the envelope needs a credential");
const responses = usage.asArray(root.get("responses")) orelse return fail(init, "the envelope needs responses");
const now_ms = usage.toInt(i64, usage.asNumber(root.get("nowMs"))) orelse nowMs();
// An envelope asking for the first requests has no rounds yet; that is one empty round.
var rounds = std.ArrayList([]const u8).empty;
if (responses.items.len == 0) try rounds.append(arena, "[]");
for (responses.items) |round| {
try rounds.append(arena, try std.json.Stringify.valueAlloc(arena, round, .{}));
}
const credential_text = try std.json.Stringify.valueAlloc(arena, credential, .{});
return advanceRounds(init, arena, provider, credential_text, rounds.items, now_ms);
}
pub fn main(init: std.process.Init) !u8 {
const arena = init.arena.allocator();
const argv = try std.process.Args.toSlice(init.minimal.args, arena);
if (argv.len < 2) return fail(init, "missing command");
const command = argv[1];
if (std.mem.eql(u8, command, "providers")) {
var w = usage.Writer.init(arena);
try usage.writeProviders(&w);
try emit(init, w.items());
return 0;
}
if (std.mem.eql(u8, command, "advance")) {
if (argv.len == 3 and std.mem.eql(u8, argv[2], "-")) return advanceEnvelope(init, arena);
if (argv.len < 5) return fail(init, "advance needs <provider> <credential.json> <responses.json>... or -");
const provider = argv[2];
const cred_arg = argv[3];
var resp_args = std.ArrayList([]const u8).empty;
var now_ms = nowMs();
var i: usize = 4;
while (i < argv.len) : (i += 1) {
if (std.mem.eql(u8, argv[i], "--now-ms") and i + 1 < argv.len) {
now_ms = std.fmt.parseInt(i64, argv[i + 1], 10) catch now_ms;
i += 1;
} else {
try resp_args.append(arena, argv[i]);
}
}
if (resp_args.items.len == 0) return fail(init, "advance needs at least one responses argument");
const cred_str = if (std.mem.startsWith(u8, cred_arg, "{"))
cred_arg
else
readFile(init, cred_arg, arena) catch |e| return fail(init, @errorName(e));
var rounds = std.ArrayList([]const u8).empty;
for (resp_args.items) |resp_arg| {
const resp_str = if (std.mem.startsWith(u8, resp_arg, "[") or std.mem.startsWith(u8, resp_arg, "{"))
resp_arg
else
readFile(init, resp_arg, arena) catch |e| return fail(init, @errorName(e));
try rounds.append(arena, resp_str);
}
return advanceRounds(init, arena, provider, cred_str, rounds.items, now_ms);
}
if (std.mem.eql(u8, command, "claims")) {
if (argv.len < 3) return fail(init, "claims needs an access token");
var w = usage.Writer.init(arena);
const outcome = try usage.claimsFromAccessToken(arena, argv[2], &w);
switch (outcome) {
.ok => {
try emit(init, w.items());
return 0;
},
.err => |text| return fail(init, text),
}
}
return fail(init, "unknown command");
}