harn-cli 0.8.21

CLI for the Harn programming language — run, test, REPL, format, and lint
Documentation
use std::time::Duration;

use clap::Parser as ClapParser;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;

use crate::cli::{ConnectArgs, ConnectCommand, ConnectGenericArgs, ConnectOAuthArgs};
use crate::package::{self, ConnectorRecoveryCopy};

mod callback;
mod github;
mod linear;
mod oauth;
mod status;
mod store;
mod workspace;

use self::github::run_connect_github;
use self::linear::run_connect_linear;
use self::oauth::{
    run_connect_generic, run_connect_linear_oauth, run_connect_named_oauth, run_connect_refresh,
    run_connect_registered_provider,
};
use self::status::{run_connect_setup_plan, run_connect_status};
use self::store::{run_connect_api_key, run_connect_list, run_connect_revoke};

#[cfg(test)]
use self::{callback::*, github::*, linear::*, oauth::*, status::*};

const MANIFEST: &str = "harn.toml";
const DEFAULT_LINEAR_API_BASE_URL: &str = "https://api.linear.app/graphql";
const OAUTH_CALLBACK_TIMEOUT: Duration = Duration::from_secs(5 * 60);
const CONNECT_INDEX_NAMESPACE: &str = "connect";
const CONNECT_INDEX_NAME: &str = "index";

#[derive(Clone, Debug)]
struct OAuthProviderDefaults {
    authorization_endpoint: &'static str,
    token_endpoint: &'static str,
    token_auth_method: &'static str,
    default_resource: &'static str,
    default_scope: Option<&'static str>,
}

#[derive(Clone, Debug)]
struct OAuthConnectRequest {
    provider: String,
    resource: String,
    authorization_endpoint: Option<String>,
    token_endpoint: Option<String>,
    registration_endpoint: Option<String>,
    client_id: Option<String>,
    client_secret: Option<String>,
    scopes: Option<String>,
    redirect_uri: String,
    token_auth_method: Option<String>,
    no_open: bool,
    json: bool,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
struct StoredConnectorToken {
    provider: String,
    access_token: String,
    #[serde(default)]
    refresh_token: Option<String>,
    #[serde(default)]
    expires_at_unix: Option<i64>,
    token_endpoint: String,
    client_id: String,
    #[serde(default)]
    client_secret: Option<String>,
    token_endpoint_auth_method: String,
    resource: String,
    #[serde(default)]
    scopes: Option<String>,
    connected_at_unix: i64,
    #[serde(default)]
    last_used_at_unix: Option<i64>,
}

#[derive(Clone, Debug, Default, Serialize, Deserialize)]
struct ConnectIndex {
    #[serde(default)]
    providers: Vec<ConnectIndexEntry>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
struct ConnectIndexEntry {
    provider: String,
    kind: String,
    secret_id: String,
    #[serde(default)]
    expires_at_unix: Option<i64>,
    #[serde(default)]
    scopes: Option<String>,
    connected_at_unix: i64,
    #[serde(default)]
    last_used_at_unix: Option<i64>,
}

#[derive(Clone, Debug, Deserialize)]
struct OAuthProtectedResource {
    #[serde(default)]
    authorization_servers: Vec<String>,
}

#[derive(Clone, Debug, Deserialize)]
struct OAuthServerMetadata {
    authorization_endpoint: String,
    token_endpoint: String,
    #[serde(default)]
    registration_endpoint: Option<String>,
    #[serde(default)]
    token_endpoint_auth_methods_supported: Vec<String>,
    #[serde(default)]
    code_challenge_methods_supported: Vec<String>,
}

#[derive(Clone, Debug, Deserialize)]
struct DynamicClientRegistrationResponse {
    client_id: String,
    #[serde(default)]
    client_secret: Option<String>,
    #[serde(default)]
    token_endpoint_auth_method: Option<String>,
}

#[derive(Clone, Debug, Deserialize)]
struct TokenResponse {
    access_token: String,
    #[serde(default)]
    refresh_token: Option<String>,
    #[serde(default)]
    expires_in: Option<i64>,
    #[serde(default)]
    ok: Option<bool>,
    #[serde(default)]
    error: Option<String>,
    #[serde(flatten)]
    _extra: serde_json::Map<String, JsonValue>,
}

#[derive(Clone, Debug, Serialize)]
struct ConnectStatusReport {
    schema_version: u32,
    manifest: Option<String>,
    connectors: Vec<ConnectorStatus>,
}

#[derive(Clone, Debug, Serialize)]
struct ConnectorStatus {
    id: String,
    installed: bool,
    usable: bool,
    status: String,
    reason: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    auth_type: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    flow: Option<String>,
    required_scopes: Vec<String>,
    missing_scopes: Vec<String>,
    required_secrets: Vec<String>,
    missing_secrets: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    secret_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    expires_at_unix: Option<i64>,
    health_checks: Vec<ConnectorHealthStatus>,
    recovery: ConnectorRecoveryCopy,
}

#[derive(Clone, Debug, Serialize)]
struct ConnectorHealthStatus {
    id: String,
    kind: String,
    status: String,
    #[serde(skip_serializing_if = "String::is_empty")]
    detail: String,
}

#[derive(Clone, Debug, Serialize)]
struct ConnectSetupPlan {
    schema_version: u32,
    connector: String,
    installed: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    manifest: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    auth_type: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    flow: Option<String>,
    required_scopes: Vec<String>,
    required_secrets: Vec<String>,
    setup_command: Vec<String>,
    validation_command: Vec<String>,
    health_checks: Vec<package::ConnectorHealthCheckManifest>,
    recovery: ConnectorRecoveryCopy,
    steps: Vec<ConnectSetupStep>,
}

#[derive(Clone, Debug, Serialize)]
struct ConnectSetupStep {
    id: String,
    title: String,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    command: Vec<String>,
    #[serde(skip_serializing_if = "String::is_empty")]
    body: String,
}

pub(crate) async fn run_connect(args: ConnectArgs) {
    if let Err(error) = run_connect_inner(args).await {
        eprintln!("error: {error}");
        std::process::exit(1);
    }
}

async fn run_connect_inner(args: ConnectArgs) -> Result<(), String> {
    let actions = args.list as u8
        + args.revoke.is_some() as u8
        + args.refresh.is_some() as u8
        + (!args.generic.is_empty()) as u8
        + args.command.is_some() as u8;
    if actions != 1 {
        return Err(
            "choose exactly one connect action: status, setup-plan, api-key, a provider subcommand, --generic, --list, --revoke, or --refresh"
                .to_string(),
        );
    }

    if args.list {
        return run_connect_list(args.json).await;
    }
    if let Some(provider) = args.revoke {
        return run_connect_revoke(&provider, args.json).await;
    }
    if let Some(provider) = args.refresh {
        return run_connect_refresh(&provider, args.json).await;
    }
    if !args.generic.is_empty() {
        if args.generic.len() != 2 {
            return Err("--generic expects <provider> <url>".to_string());
        }
        return run_connect_generic(&ConnectGenericArgs {
            provider: args.generic[0].clone(),
            url: args.generic[1].clone(),
            oauth: ConnectOAuthArgs {
                client_id: None,
                client_secret: None,
                scope: None,
                resource: None,
                auth_url: None,
                token_url: None,
                token_auth_method: None,
                redirect_uri: "http://127.0.0.1:0/oauth/callback".to_string(),
                no_open: false,
                json: args.json,
            },
        })
        .await;
    }

    let json_output = args.json;
    match args.command.expect("validated one command action") {
        ConnectCommand::Status(mut status) => {
            status.json |= json_output;
            run_connect_status(&status).await
        }
        ConnectCommand::SetupPlan(mut setup_plan) => {
            setup_plan.json |= json_output;
            run_connect_setup_plan(&setup_plan)
        }
        ConnectCommand::ApiKey(mut api_key) => {
            api_key.json |= json_output;
            run_connect_api_key(&api_key).await
        }
        ConnectCommand::Github(args) => run_connect_github(&args).await,
        ConnectCommand::Linear(args) if args.url.is_some() => run_connect_linear(&args).await,
        ConnectCommand::Linear(args) => run_connect_linear_oauth(&args).await,
        ConnectCommand::Slack(args) => run_connect_named_oauth("slack", &args).await,
        ConnectCommand::Notion(args) => run_connect_named_oauth("notion", &args).await,
        ConnectCommand::Generic(args) => run_connect_generic(&args).await,
        ConnectCommand::Provider(raw) => {
            let parsed = parse_external_provider_connect(raw, json_output)?;
            run_connect_registered_provider(&parsed.provider, &parsed.oauth).await
        }
    }
}

#[derive(Debug, ClapParser)]
#[command(no_binary_name = true, disable_help_flag = true)]
struct ExternalProviderConnectArgs {
    provider: String,
    #[command(flatten)]
    oauth: ConnectOAuthArgs,
}

fn parse_external_provider_connect(
    raw: Vec<String>,
    json_output: bool,
) -> Result<ExternalProviderConnectArgs, String> {
    let mut parsed = ExternalProviderConnectArgs::try_parse_from(raw)
        .map_err(|error| error.render().to_string())?;
    parsed.oauth.json |= json_output;
    Ok(parsed)
}

#[cfg(test)]
mod tests;