use std::{
collections::HashMap,
fmt,
io::Write,
process::{Command, Stdio},
};
use anyhow::{Context, Result, bail};
use serde::Deserialize;
pub const DEFAULT_VIEWER_BASE: &str = "https://joshkarpel.github.io/claude-scriptorium/";
const VIEWER_TEMPLATE: &str = include_str!("../docs/index.html");
const DEFAULT_API_BASE: &str = "https://api.github.com";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Identity {
pub login: String,
pub host: String,
pub token_source: String,
}
impl fmt::Display for Identity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} on {} (auth: {})",
self.login, self.host, self.token_source
)
}
}
pub fn resolve_identity() -> Result<Identity> {
let host = resolve_host(std::env::var("GH_HOST").ok(), &authenticated_hosts()?);
let status = gh(&[
"auth",
"status",
"--active",
"--hostname",
&host,
"--json",
"hosts",
])?;
parse_identity(&status, &host)
}
pub fn publish(html: &str, filename: &str, description: &str, public: bool) -> Result<String> {
let mut args = vec![
"gist",
"create",
"-",
"--filename",
filename,
"--desc",
description,
];
if public {
args.push("--public");
}
let mut child = Command::new("gh")
.args(&args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.context("running gh gist create (is the GitHub CLI installed?)")?;
child
.stdin
.take()
.context("gh gist create stdin was not captured")?
.write_all(html.as_bytes())
.context("piping html to gh gist create")?;
let output = child
.wait_with_output()
.context("waiting for gh gist create")?;
if !output.status.success() {
bail!("gh gist create failed");
}
let gist_url = String::from_utf8(output.stdout)
.context("gh gist create produced non-UTF-8 output")?
.trim()
.to_owned();
Ok(gist_url)
}
pub fn fetch(gist: &str) -> Result<Vec<(String, String)>> {
let id = gist_id(gist);
let files = gh(&["gist", "view", id, "--files"])?;
let mut fetched = Vec::new();
for name in files.lines().map(str::trim).filter(|line| !line.is_empty()) {
let contents = gh(&["gist", "view", id, "--raw", "--filename", name])?;
fetched.push((name.to_owned(), contents));
}
Ok(fetched)
}
fn gist_id(gist: &str) -> &str {
gist.trim_end_matches('/')
.rsplit('/')
.next()
.unwrap_or(gist)
}
fn resolve_host(gh_host: Option<String>, authenticated: &[String]) -> String {
if let Some(host) = gh_host.filter(|host| !host.is_empty()) {
return host;
}
if let [only] = authenticated {
return only.clone();
}
"github.com".to_owned()
}
fn parse_identity(status: &str, host: &str) -> Result<Identity> {
let status: AuthStatus = serde_json::from_str(status).context("parsing gh auth status")?;
let account = status
.hosts
.get(host)
.into_iter()
.flatten()
.find(|account| account.active)
.with_context(|| format!("gh has no active account for {host}"))?;
Ok(Identity {
login: account.login.clone(),
host: account.host.clone(),
token_source: account.token_source.clone(),
})
}
pub fn preview_url(viewer_base: &str, gist_url: &str, filename: &str) -> String {
let id = gist_id(gist_url);
let base = viewer_base.trim_end_matches('/');
format!("{base}/?{id}/{filename}")
}
pub fn scaffold_viewer(ghes_host: Option<&str>) -> Result<String> {
let Some(host) = ghes_host else {
return Ok(VIEWER_TEMPLATE.to_owned());
};
let api_base = format!("https://{host}/api/v3");
let rewritten =
VIEWER_TEMPLATE.replace(&format!("'{DEFAULT_API_BASE}'"), &format!("'{api_base}'"));
if rewritten == VIEWER_TEMPLATE {
bail!("viewer template no longer contains the API base to rewrite for a GHES host");
}
Ok(rewritten)
}
fn authenticated_hosts() -> Result<Vec<String>> {
let output = gh(&[
"auth",
"status",
"--json",
"hosts",
"--jq",
".hosts | keys[]",
])?;
Ok(output
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_owned)
.collect())
}
fn gh(args: &[&str]) -> Result<String> {
let output = Command::new("gh")
.args(args)
.stderr(Stdio::inherit())
.output()
.context("running gh (is the GitHub CLI installed?)")?;
if !output.status.success() {
bail!("gh {} failed", args.join(" "));
}
String::from_utf8(output.stdout).context("gh produced non-UTF-8 output")
}
#[derive(Deserialize)]
struct AuthStatus {
hosts: HashMap<String, Vec<Account>>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Account {
login: String,
host: String,
token_source: String,
active: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gh_host_env_wins_over_authenticated_hosts() {
let host = resolve_host(
Some("ghe.example.com".to_owned()),
&["github.com".to_owned()],
);
assert_eq!(host, "ghe.example.com");
}
#[test]
fn empty_gh_host_falls_through_to_the_sole_authenticated_host() {
let host = resolve_host(Some(String::new()), &["ghe.example.com".to_owned()]);
assert_eq!(host, "ghe.example.com");
}
#[test]
fn several_authenticated_hosts_default_to_github_com() {
let host = resolve_host(
None,
&["ghe.example.com".to_owned(), "github.com".to_owned()],
);
assert_eq!(host, "github.com");
}
#[test]
fn no_authenticated_hosts_default_to_github_com() {
assert_eq!(resolve_host(None, &[]), "github.com");
}
#[test]
fn parse_identity_picks_the_active_account_for_the_host() {
let status = r#"{"hosts":{"github.com":[
{"login":"other","host":"github.com","tokenSource":"keyring","active":false},
{"login":"scribe","host":"github.com","tokenSource":"/etc/gh/hosts.yml","active":true}
]}}"#;
let identity = parse_identity(status, "github.com").unwrap();
assert_eq!(identity.login, "scribe");
assert_eq!(identity.host, "github.com");
assert_eq!(identity.token_source, "/etc/gh/hosts.yml");
}
#[test]
fn parse_identity_fails_when_the_host_has_no_active_account() {
let status = r#"{"hosts":{"github.com":[
{"login":"scribe","host":"github.com","tokenSource":"keyring","active":false}
]}}"#;
assert!(parse_identity(status, "github.com").is_err());
}
#[test]
fn preview_url_addresses_the_gist_through_the_viewer_base() {
let preview = preview_url(
"https://joshkarpel.github.io/claude-scriptorium/",
"https://gist.github.com/scribe/abc123",
"session-7.html",
);
assert_eq!(
preview,
"https://joshkarpel.github.io/claude-scriptorium/?abc123/session-7.html"
);
}
#[test]
fn preview_url_tolerates_a_viewer_base_without_a_trailing_slash() {
let preview = preview_url(
"https://viewer.example.com",
"https://gist.github.com/scribe/abc123",
"session-7.html",
);
assert_eq!(preview, "https://viewer.example.com/?abc123/session-7.html");
}
#[test]
fn gist_id_reduces_a_page_url_to_its_trailing_id() {
assert_eq!(gist_id("https://gist.github.com/scribe/abc123"), "abc123");
assert_eq!(gist_id("https://gist.github.com/scribe/abc123/"), "abc123");
assert_eq!(gist_id("abc123"), "abc123");
}
#[test]
fn scaffold_viewer_is_the_template_verbatim_for_github_com() {
assert_eq!(scaffold_viewer(None).unwrap(), VIEWER_TEMPLATE);
}
#[test]
fn scaffold_viewer_rewrites_the_api_base_for_a_ghes_host() {
let viewer = scaffold_viewer(Some("ghe.example.com")).unwrap();
assert!(viewer.contains("'https://ghe.example.com/api/v3'"));
assert!(!viewer.contains("'https://api.github.com'"));
}
}