mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Merge pull request #409 from RWDai/feat/issue-373-cli-install
feat: add API key CLI install flow
This commit is contained in:
@@ -24,5 +24,7 @@ pub(crate) fn mount_public_support_routes(router: Router<AppState>) -> Router<Ap
|
|||||||
.route("/api/capabilities", get(proxy_request))
|
.route("/api/capabilities", get(proxy_request))
|
||||||
.route("/api/capabilities/user-configurable", get(proxy_request))
|
.route("/api/capabilities/user-configurable", get(proxy_request))
|
||||||
.route("/api/capabilities/model/{*model_path}", get(proxy_request))
|
.route("/api/capabilities/model/{*model_path}", get(proxy_request))
|
||||||
|
.route("/install/{*install_path}", get(proxy_request))
|
||||||
|
.route("/i/{*install_path}", get(proxy_request))
|
||||||
.route("/", get(proxy_request))
|
.route("/", get(proxy_request))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -468,6 +468,20 @@ pub(super) fn classify_public_support_route(
|
|||||||
"user:self",
|
"user:self",
|
||||||
false,
|
false,
|
||||||
))
|
))
|
||||||
|
} else if method == http::Method::POST
|
||||||
|
&& has_single_nested_suffix_after_prefix(
|
||||||
|
normalized_path,
|
||||||
|
"/api/users/me/api-keys/",
|
||||||
|
"install-sessions",
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Some(classified(
|
||||||
|
"public_support",
|
||||||
|
"users_me",
|
||||||
|
"api_key_install_session_create",
|
||||||
|
"user:self",
|
||||||
|
false,
|
||||||
|
))
|
||||||
} else if method == http::Method::POST
|
} else if method == http::Method::POST
|
||||||
&& normalized_path.starts_with("/api/me/management-tokens/")
|
&& normalized_path.starts_with("/api/me/management-tokens/")
|
||||||
&& normalized_path.ends_with("/regenerate")
|
&& normalized_path.ends_with("/regenerate")
|
||||||
@@ -659,6 +673,17 @@ pub(super) fn classify_public_support_route(
|
|||||||
"public:system_catalog",
|
"public:system_catalog",
|
||||||
false,
|
false,
|
||||||
))
|
))
|
||||||
|
} else if method == http::Method::GET
|
||||||
|
&& (has_single_segment_after_prefix(normalized_path, "/install/")
|
||||||
|
|| has_single_segment_after_prefix(normalized_path, "/i/"))
|
||||||
|
{
|
||||||
|
Some(classified(
|
||||||
|
"public_support",
|
||||||
|
"install",
|
||||||
|
"script",
|
||||||
|
"public:install",
|
||||||
|
false,
|
||||||
|
))
|
||||||
} else if method == http::Method::GET && normalized_path == "/test-connection" {
|
} else if method == http::Method::GET && normalized_path == "/test-connection" {
|
||||||
Some(classified(
|
Some(classified(
|
||||||
"public_support",
|
"public_support",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use http::Uri;
|
use http::Uri;
|
||||||
|
|
||||||
use super::{classify_control_route, headers};
|
use super::{classify_control_route, headers, GatewayPublicRequestContext};
|
||||||
|
use crate::handlers::shared::local_proxy_route_requires_buffered_body;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classifies_models_list_as_public_support_route() {
|
fn classifies_models_list_as_public_support_route() {
|
||||||
@@ -366,6 +367,11 @@ fn classifies_users_me_routes_as_public_support_route() {
|
|||||||
"/api/users/me/api-keys",
|
"/api/users/me/api-keys",
|
||||||
"api_keys_create",
|
"api_keys_create",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
http::Method::POST,
|
||||||
|
"/api/users/me/api-keys/key-1/install-sessions",
|
||||||
|
"api_key_install_session_create",
|
||||||
|
),
|
||||||
(
|
(
|
||||||
http::Method::PUT,
|
http::Method::PUT,
|
||||||
"/api/users/me/api-keys/key-1",
|
"/api/users/me/api-keys/key-1",
|
||||||
@@ -452,6 +458,25 @@ fn classifies_users_me_routes_as_public_support_route() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn user_api_key_install_session_create_buffers_request_body() {
|
||||||
|
let headers = headers(&[]);
|
||||||
|
let uri: Uri = "/api/users/me/api-keys/key-1/install-sessions"
|
||||||
|
.parse()
|
||||||
|
.expect("uri should parse");
|
||||||
|
let decision =
|
||||||
|
classify_control_route(&http::Method::POST, &uri, &headers).expect("route should classify");
|
||||||
|
let context = GatewayPublicRequestContext::from_request_parts(
|
||||||
|
"trace-install-session",
|
||||||
|
&http::Method::POST,
|
||||||
|
&uri,
|
||||||
|
&headers,
|
||||||
|
Some(decision),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(local_proxy_route_requires_buffered_body(&context));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classifies_payment_callback_as_public_support_route() {
|
fn classifies_payment_callback_as_public_support_route() {
|
||||||
let headers = headers(&[]);
|
let headers = headers(&[]);
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ mod support_announcements;
|
|||||||
mod support_auth;
|
mod support_auth;
|
||||||
#[path = "support/dashboard.rs"]
|
#[path = "support/dashboard.rs"]
|
||||||
mod support_dashboard;
|
mod support_dashboard;
|
||||||
|
#[path = "support/install.rs"]
|
||||||
|
mod support_install;
|
||||||
#[path = "support/models.rs"]
|
#[path = "support/models.rs"]
|
||||||
mod support_models;
|
mod support_models;
|
||||||
#[path = "support/monitoring.rs"]
|
#[path = "support/monitoring.rs"]
|
||||||
@@ -61,6 +63,10 @@ use self::support_auth::{
|
|||||||
build_auth_settings_payload, extract_client_device_id, maybe_build_local_auth_response,
|
build_auth_settings_payload, extract_client_device_id, maybe_build_local_auth_response,
|
||||||
};
|
};
|
||||||
use self::support_dashboard::maybe_build_local_dashboard_response;
|
use self::support_dashboard::maybe_build_local_dashboard_response;
|
||||||
|
use self::support_install::{
|
||||||
|
handle_users_me_api_key_install_session_create, maybe_build_local_install_response,
|
||||||
|
users_me_api_key_install_sessions_path_matches,
|
||||||
|
};
|
||||||
use self::support_models::{
|
use self::support_models::{
|
||||||
build_models_auth_error_response, maybe_build_local_models_response, models_api_format,
|
build_models_auth_error_response, maybe_build_local_models_response, models_api_format,
|
||||||
};
|
};
|
||||||
@@ -146,6 +152,10 @@ pub(crate) async fn maybe_build_local_public_support_response(
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if decision.route_family.as_deref() == Some("install") {
|
||||||
|
return maybe_build_local_install_response(state, request_context).await;
|
||||||
|
}
|
||||||
|
|
||||||
if decision.route_family.as_deref() == Some("payment_callback") {
|
if decision.route_family.as_deref() == Some("payment_callback") {
|
||||||
return maybe_build_local_payment_callback_response(
|
return maybe_build_local_payment_callback_response(
|
||||||
state,
|
state,
|
||||||
|
|||||||
561
apps/aether-gateway/src/handlers/public/support/install.rs
Normal file
561
apps/aether-gateway/src/handlers/public/support/install.rs
Normal file
@@ -0,0 +1,561 @@
|
|||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
http,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
build_auth_error_response, decrypt_catalog_secret_with_fallbacks,
|
||||||
|
resolve_authenticated_local_user, AppState, GatewayPublicRequestContext,
|
||||||
|
};
|
||||||
|
|
||||||
|
const INSTALL_SESSION_TTL_SECS: u64 = 15 * 60;
|
||||||
|
const INSTALL_SESSION_KEY_PREFIX: &str = "install:session:";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
enum InstallTargetCli {
|
||||||
|
ClaudeCode,
|
||||||
|
CodexCli,
|
||||||
|
GeminiCli,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
enum InstallTargetSystem {
|
||||||
|
Macos,
|
||||||
|
Linux,
|
||||||
|
Windows,
|
||||||
|
Auto,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct UsersMeCreateInstallSessionRequest {
|
||||||
|
target_cli: InstallTargetCli,
|
||||||
|
target_system: InstallTargetSystem,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct StoredInstallSession {
|
||||||
|
api_key_id: String,
|
||||||
|
api_key_name: String,
|
||||||
|
api_key: String,
|
||||||
|
base_url: String,
|
||||||
|
target_cli: InstallTargetCli,
|
||||||
|
target_system: InstallTargetSystem,
|
||||||
|
expires_at_unix_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn users_me_api_key_install_sessions_path_matches(request_path: &str) -> bool {
|
||||||
|
users_me_api_key_install_session_id_from_path(request_path).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn users_me_api_key_install_session_id_from_path(request_path: &str) -> Option<String> {
|
||||||
|
let raw = request_path
|
||||||
|
.strip_prefix("/api/users/me/api-keys/")?
|
||||||
|
.trim()
|
||||||
|
.trim_matches('/');
|
||||||
|
let mut segments = raw.split('/').map(str::trim);
|
||||||
|
let api_key_id = segments.next()?.to_string();
|
||||||
|
let suffix = segments.next()?;
|
||||||
|
(suffix == "install-sessions" && segments.next().is_none()).then_some(api_key_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_code_from_path(request_path: &str) -> Option<(String, bool)> {
|
||||||
|
let raw = request_path
|
||||||
|
.strip_prefix("/install/")
|
||||||
|
.or_else(|| request_path.strip_prefix("/i/"))?
|
||||||
|
.trim()
|
||||||
|
.trim_matches('/');
|
||||||
|
if raw.is_empty() || raw.contains('/') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let is_powershell = raw.ends_with(".ps1");
|
||||||
|
let code = raw.strip_suffix(".ps1").unwrap_or(raw).trim();
|
||||||
|
(!code.is_empty()).then(|| (code.to_string(), is_powershell))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_session_runtime_key(code: &str) -> String {
|
||||||
|
format!("{INSTALL_SESSION_KEY_PREFIX}{code}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_install_code() -> String {
|
||||||
|
uuid::Uuid::new_v4()
|
||||||
|
.simple()
|
||||||
|
.to_string()
|
||||||
|
.chars()
|
||||||
|
.take(24)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_secs_now() -> u64 {
|
||||||
|
chrono::Utc::now().timestamp().max(0) as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base_url_from_request(
|
||||||
|
headers: &http::HeaderMap,
|
||||||
|
request_context: &GatewayPublicRequestContext,
|
||||||
|
) -> String {
|
||||||
|
if let Some(value) = std::env::var("AETHER_PUBLIC_BASE_URL")
|
||||||
|
.ok()
|
||||||
|
.or_else(|| std::env::var("PUBLIC_BASE_URL").ok())
|
||||||
|
.map(|value| value.trim().trim_end_matches('/').to_string())
|
||||||
|
.filter(|value| value.starts_with("https://") || value.starts_with("http://"))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
let host = crate::headers::header_value_str(headers, "x-forwarded-host")
|
||||||
|
.or_else(|| request_context.host_header.clone())
|
||||||
|
.map(|value| value.trim().trim_end_matches('/').to_string())
|
||||||
|
.filter(|value| {
|
||||||
|
!value.is_empty()
|
||||||
|
&& !value.contains('/')
|
||||||
|
&& !value.contains('\\')
|
||||||
|
&& !value.contains('@')
|
||||||
|
&& !value.contains(char::is_whitespace)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "localhost".to_string());
|
||||||
|
let proto = crate::headers::header_value_str(headers, "x-forwarded-proto")
|
||||||
|
.map(|value| value.trim().trim_end_matches(':').to_ascii_lowercase())
|
||||||
|
.filter(|value| value == "http" || value == "https")
|
||||||
|
.unwrap_or_else(|| "http".to_string());
|
||||||
|
format!("{proto}://{host}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shell_single_quote(value: &str) -> String {
|
||||||
|
format!("'{}'", value.replace('\'', "'\\''"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn powershell_single_quote(value: &str) -> String {
|
||||||
|
format!("'{}'", value.replace('\'', "''"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cli_label(target_cli: InstallTargetCli) -> &'static str {
|
||||||
|
match target_cli {
|
||||||
|
InstallTargetCli::ClaudeCode => "Claude Code",
|
||||||
|
InstallTargetCli::CodexCli => "Codex CLI",
|
||||||
|
InstallTargetCli::GeminiCli => "Gemini CLI",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn system_label(target_system: InstallTargetSystem) -> &'static str {
|
||||||
|
match target_system {
|
||||||
|
InstallTargetSystem::Macos => "macOS",
|
||||||
|
InstallTargetSystem::Linux => "Linux",
|
||||||
|
InstallTargetSystem::Windows => "Windows",
|
||||||
|
InstallTargetSystem::Auto => "Auto",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn npm_package(target_cli: InstallTargetCli) -> &'static str {
|
||||||
|
match target_cli {
|
||||||
|
InstallTargetCli::ClaudeCode => "@anthropic-ai/claude-code",
|
||||||
|
InstallTargetCli::CodexCli => "@openai/codex",
|
||||||
|
InstallTargetCli::GeminiCli => "@google/gemini-cli",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cli_binary(target_cli: InstallTargetCli) -> &'static str {
|
||||||
|
match target_cli {
|
||||||
|
InstallTargetCli::ClaudeCode => "claude",
|
||||||
|
InstallTargetCli::CodexCli => "codex",
|
||||||
|
InstallTargetCli::GeminiCli => "gemini",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_unix_script(session: &StoredInstallSession) -> String {
|
||||||
|
let target_cli = match session.target_cli {
|
||||||
|
InstallTargetCli::ClaudeCode => "claude_code",
|
||||||
|
InstallTargetCli::CodexCli => "codex_cli",
|
||||||
|
InstallTargetCli::GeminiCli => "gemini_cli",
|
||||||
|
};
|
||||||
|
let target_system = match session.target_system {
|
||||||
|
InstallTargetSystem::Macos => "macos",
|
||||||
|
InstallTargetSystem::Linux => "linux",
|
||||||
|
InstallTargetSystem::Windows => "windows",
|
||||||
|
InstallTargetSystem::Auto => "auto",
|
||||||
|
};
|
||||||
|
|
||||||
|
format!(
|
||||||
|
r###"#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
TARGET_CLI={target_cli}
|
||||||
|
TARGET_SYSTEM={target_system}
|
||||||
|
AETHER_BASE_URL={base_url}
|
||||||
|
AETHER_API_KEY={api_key}
|
||||||
|
CLI_LABEL={label}
|
||||||
|
CLI_BIN={binary}
|
||||||
|
NPM_PACKAGE={npm_package}
|
||||||
|
|
||||||
|
say() {{ printf '%s\n' "[Aether] $1"; }}
|
||||||
|
fail() {{ printf '%s\n' "[Aether] $1" >&2; exit 1; }}
|
||||||
|
|
||||||
|
os="$(uname -s 2>/dev/null || printf unknown)"
|
||||||
|
case "$os" in
|
||||||
|
Darwin) actual_system=macos ;;
|
||||||
|
Linux) actual_system=linux ;;
|
||||||
|
MINGW*|MSYS*|CYGWIN*) fail "检测到 Windows shell,请在 PowerShell 中使用 Windows 命令:irm <url>.ps1 | iex" ;;
|
||||||
|
*) fail "不支持的系统:$os" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ "$TARGET_SYSTEM" = "windows" ]; then
|
||||||
|
fail "该 install code 绑定 Windows,请复制 PowerShell 命令执行。"
|
||||||
|
fi
|
||||||
|
if [ "$TARGET_SYSTEM" != "auto" ] && [ "$TARGET_SYSTEM" != "$actual_system" ]; then
|
||||||
|
fail "所选系统 $TARGET_SYSTEM 与当前系统 $actual_system 不一致,请回到 Aether 重新选择目标系统。"
|
||||||
|
fi
|
||||||
|
|
||||||
|
say "准备安装/复用 $CLI_LABEL"
|
||||||
|
if ! command -v "$CLI_BIN" >/dev/null 2>&1; then
|
||||||
|
command -v npm >/dev/null 2>&1 || fail "未找到 $CLI_BIN,也未找到 npm。请先安装 Node.js/npm 后重试。"
|
||||||
|
say "未找到 $CLI_BIN,正在通过 npm 安装 $NPM_PACKAGE"
|
||||||
|
npm install -g "$NPM_PACKAGE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
mkdir -p "$HOME/.aether"
|
||||||
|
cat > "$HOME/.aether/client.env" <<EOF
|
||||||
|
AETHER_BASE_URL=$AETHER_BASE_URL
|
||||||
|
AETHER_API_KEY=$AETHER_API_KEY
|
||||||
|
EOF
|
||||||
|
chmod 600 "$HOME/.aether/client.env" 2>/dev/null || true
|
||||||
|
|
||||||
|
case "$TARGET_CLI" in
|
||||||
|
claude_code)
|
||||||
|
mkdir -p "$HOME/.claude"
|
||||||
|
python3 - "$HOME/.claude/settings.json" "$AETHER_BASE_URL" "$AETHER_API_KEY" <<'PY'
|
||||||
|
import json, pathlib, sys
|
||||||
|
path = pathlib.Path(sys.argv[1])
|
||||||
|
data = json.loads(path.read_text() or '{{}}') if path.exists() else {{}}
|
||||||
|
env = data.setdefault('env', {{}})
|
||||||
|
env['ANTHROPIC_BASE_URL'] = sys.argv[2]
|
||||||
|
env['ANTHROPIC_AUTH_TOKEN'] = sys.argv[3]
|
||||||
|
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n')
|
||||||
|
PY
|
||||||
|
chmod 600 "$HOME/.claude/settings.json" 2>/dev/null || true
|
||||||
|
;;
|
||||||
|
codex_cli)
|
||||||
|
mkdir -p "$HOME/.codex"
|
||||||
|
cat > "$HOME/.codex/auth.json" <<EOF
|
||||||
|
{{"OPENAI_API_KEY":"$AETHER_API_KEY"}}
|
||||||
|
EOF
|
||||||
|
cat > "$HOME/.codex/config.toml" <<EOF
|
||||||
|
# Managed by Aether
|
||||||
|
model_provider = "aether"
|
||||||
|
|
||||||
|
[model_providers.aether]
|
||||||
|
name = "Aether"
|
||||||
|
base_url = "$AETHER_BASE_URL/v1"
|
||||||
|
env_key = "OPENAI_API_KEY"
|
||||||
|
wire_api = "chat"
|
||||||
|
EOF
|
||||||
|
chmod 600 "$HOME/.codex/auth.json" "$HOME/.codex/config.toml" 2>/dev/null || true
|
||||||
|
;;
|
||||||
|
gemini_cli)
|
||||||
|
mkdir -p "$HOME/.gemini"
|
||||||
|
cat > "$HOME/.gemini/.env" <<EOF
|
||||||
|
GEMINI_API_KEY=$AETHER_API_KEY
|
||||||
|
GOOGLE_API_KEY=$AETHER_API_KEY
|
||||||
|
GOOGLE_GEMINI_BASE_URL=$AETHER_BASE_URL
|
||||||
|
AETHER_BASE_URL=$AETHER_BASE_URL
|
||||||
|
EOF
|
||||||
|
python3 - "$HOME/.gemini/settings.json" "$AETHER_BASE_URL" <<'PY'
|
||||||
|
import json, pathlib, sys
|
||||||
|
path = pathlib.Path(sys.argv[1])
|
||||||
|
data = json.loads(path.read_text() or '{{}}') if path.exists() else {{}}
|
||||||
|
data.setdefault('aether', {{}})['baseUrl'] = sys.argv[2]
|
||||||
|
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n')
|
||||||
|
PY
|
||||||
|
chmod 600 "$HOME/.gemini/.env" "$HOME/.gemini/settings.json" 2>/dev/null || true
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
say "$CLI_LABEL 已配置到 Aether。执行 $CLI_BIN --version 验证安装。"
|
||||||
|
"###,
|
||||||
|
target_cli = target_cli,
|
||||||
|
target_system = target_system,
|
||||||
|
base_url = shell_single_quote(&session.base_url),
|
||||||
|
api_key = shell_single_quote(&session.api_key),
|
||||||
|
label = shell_single_quote(cli_label(session.target_cli)),
|
||||||
|
binary = shell_single_quote(cli_binary(session.target_cli)),
|
||||||
|
npm_package = shell_single_quote(npm_package(session.target_cli)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_powershell_script(session: &StoredInstallSession) -> String {
|
||||||
|
let target_cli = match session.target_cli {
|
||||||
|
InstallTargetCli::ClaudeCode => "claude_code",
|
||||||
|
InstallTargetCli::CodexCli => "codex_cli",
|
||||||
|
InstallTargetCli::GeminiCli => "gemini_cli",
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
r###"$ErrorActionPreference = 'Stop'
|
||||||
|
$TargetCli = {target_cli}
|
||||||
|
$TargetSystem = {target_system}
|
||||||
|
$AetherBaseUrl = {base_url}
|
||||||
|
$AetherApiKey = {api_key}
|
||||||
|
$CliLabel = {label}
|
||||||
|
$CliBin = {binary}
|
||||||
|
$NpmPackage = {npm_package}
|
||||||
|
|
||||||
|
function Say($Message) {{ Write-Host "[Aether] $Message" }}
|
||||||
|
function Fail($Message) {{ Write-Error "[Aether] $Message"; exit 1 }}
|
||||||
|
|
||||||
|
if ($TargetSystem -ne 'auto' -and $TargetSystem -ne 'windows') {{ Fail "该 install code 绑定 $TargetSystem,请复制 macOS/Linux 命令执行。" }}
|
||||||
|
|
||||||
|
Say "准备安装/复用 $CliLabel"
|
||||||
|
if (-not (Get-Command $CliBin -ErrorAction SilentlyContinue)) {{
|
||||||
|
if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {{ Fail "未找到 $CliBin,也未找到 npm。请先安装 Node.js/npm 后重试。" }}
|
||||||
|
Say "未找到 $CliBin,正在通过 npm 安装 $NpmPackage"
|
||||||
|
npm install -g $NpmPackage
|
||||||
|
}}
|
||||||
|
|
||||||
|
$HomeDir = [Environment]::GetFolderPath('UserProfile')
|
||||||
|
$AetherDir = Join-Path $HomeDir '.aether'
|
||||||
|
New-Item -ItemType Directory -Force -Path $AetherDir | Out-Null
|
||||||
|
Set-Content -Path (Join-Path $AetherDir 'client.env') -Value "AETHER_BASE_URL=$AetherBaseUrl`nAETHER_API_KEY=$AetherApiKey`n" -Encoding UTF8
|
||||||
|
|
||||||
|
if ($TargetCli -eq 'claude_code') {{
|
||||||
|
$Dir = Join-Path $HomeDir '.claude'; New-Item -ItemType Directory -Force -Path $Dir | Out-Null
|
||||||
|
$Path = Join-Path $Dir 'settings.json'
|
||||||
|
$Data = if (Test-Path $Path) {{ Get-Content $Path -Raw | ConvertFrom-Json -AsHashtable }} else {{ @{{}} }}
|
||||||
|
if (-not $Data.ContainsKey('env')) {{ $Data.env = @{{}} }}
|
||||||
|
$Data.env.ANTHROPIC_BASE_URL = $AetherBaseUrl
|
||||||
|
$Data.env.ANTHROPIC_AUTH_TOKEN = $AetherApiKey
|
||||||
|
$Data | ConvertTo-Json -Depth 8 | Set-Content $Path -Encoding UTF8
|
||||||
|
}} elseif ($TargetCli -eq 'codex_cli') {{
|
||||||
|
$Dir = Join-Path $HomeDir '.codex'; New-Item -ItemType Directory -Force -Path $Dir | Out-Null
|
||||||
|
Set-Content (Join-Path $Dir 'auth.json') -Value (@{{ OPENAI_API_KEY = $AetherApiKey }} | ConvertTo-Json) -Encoding UTF8
|
||||||
|
Set-Content (Join-Path $Dir 'config.toml') -Value "# Managed by Aether`nmodel_provider = \"aether\"`n`n[model_providers.aether]`nname = \"Aether\"`nbase_url = \"$AetherBaseUrl/v1\"`nenv_key = \"OPENAI_API_KEY\"`nwire_api = \"chat\"`n" -Encoding UTF8
|
||||||
|
}} elseif ($TargetCli -eq 'gemini_cli') {{
|
||||||
|
$Dir = Join-Path $HomeDir '.gemini'; New-Item -ItemType Directory -Force -Path $Dir | Out-Null
|
||||||
|
Set-Content (Join-Path $Dir '.env') -Value "GEMINI_API_KEY=$AetherApiKey`nGOOGLE_API_KEY=$AetherApiKey`nGOOGLE_GEMINI_BASE_URL=$AetherBaseUrl`nAETHER_BASE_URL=$AetherBaseUrl`n" -Encoding UTF8
|
||||||
|
$Path = Join-Path $Dir 'settings.json'
|
||||||
|
$Data = if (Test-Path $Path) {{ Get-Content $Path -Raw | ConvertFrom-Json -AsHashtable }} else {{ @{{}} }}
|
||||||
|
$Data.aether = @{{ baseUrl = $AetherBaseUrl }}
|
||||||
|
$Data | ConvertTo-Json -Depth 8 | Set-Content $Path -Encoding UTF8
|
||||||
|
}}
|
||||||
|
|
||||||
|
Say "$CliLabel 已配置到 Aether。执行 $CliBin --version 验证安装。"
|
||||||
|
"###,
|
||||||
|
target_cli = powershell_single_quote(target_cli),
|
||||||
|
target_system = powershell_single_quote(match session.target_system {
|
||||||
|
InstallTargetSystem::Macos => "macos",
|
||||||
|
InstallTargetSystem::Linux => "linux",
|
||||||
|
InstallTargetSystem::Windows => "windows",
|
||||||
|
InstallTargetSystem::Auto => "auto",
|
||||||
|
}),
|
||||||
|
base_url = powershell_single_quote(&session.base_url),
|
||||||
|
api_key = powershell_single_quote(&session.api_key),
|
||||||
|
label = powershell_single_quote(cli_label(session.target_cli)),
|
||||||
|
binary = powershell_single_quote(cli_binary(session.target_cli)),
|
||||||
|
npm_package = powershell_single_quote(npm_package(session.target_cli)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_users_me_api_key_install_session_create(
|
||||||
|
state: &AppState,
|
||||||
|
request_context: &GatewayPublicRequestContext,
|
||||||
|
headers: &http::HeaderMap,
|
||||||
|
request_body: Option<&axum::body::Bytes>,
|
||||||
|
) -> Response<Body> {
|
||||||
|
let auth = match resolve_authenticated_local_user(state, request_context, headers).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(response) => return response,
|
||||||
|
};
|
||||||
|
let Some(api_key_id) =
|
||||||
|
users_me_api_key_install_session_id_from_path(&request_context.request_path)
|
||||||
|
else {
|
||||||
|
return build_auth_error_response(http::StatusCode::NOT_FOUND, "API密钥不存在", false);
|
||||||
|
};
|
||||||
|
let Some(request_body) = request_body else {
|
||||||
|
return build_auth_error_response(http::StatusCode::BAD_REQUEST, "请求数据验证失败", false);
|
||||||
|
};
|
||||||
|
let payload = match serde_json::from_slice::<UsersMeCreateInstallSessionRequest>(request_body) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => {
|
||||||
|
return build_auth_error_response(
|
||||||
|
http::StatusCode::BAD_REQUEST,
|
||||||
|
"请求数据验证失败",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let records = match state
|
||||||
|
.list_auth_api_key_export_records_by_user_ids(std::slice::from_ref(&auth.user.id))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(err) => {
|
||||||
|
return build_auth_error_response(
|
||||||
|
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("user api key lookup failed: {err:?}"),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Some(record) = records
|
||||||
|
.into_iter()
|
||||||
|
.find(|record| !record.is_standalone && record.api_key_id == api_key_id)
|
||||||
|
else {
|
||||||
|
return build_auth_error_response(http::StatusCode::NOT_FOUND, "API密钥不存在", false);
|
||||||
|
};
|
||||||
|
let Some(ciphertext) = record
|
||||||
|
.key_encrypted
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
else {
|
||||||
|
return build_auth_error_response(
|
||||||
|
http::StatusCode::BAD_REQUEST,
|
||||||
|
"该密钥没有存储完整密钥信息",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let Some(api_key) = decrypt_catalog_secret_with_fallbacks(state.encryption_key(), ciphertext)
|
||||||
|
else {
|
||||||
|
return build_auth_error_response(
|
||||||
|
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"解密密钥失败",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let code = generate_install_code();
|
||||||
|
let expires_at_unix_secs = unix_secs_now().saturating_add(INSTALL_SESSION_TTL_SECS);
|
||||||
|
let session = StoredInstallSession {
|
||||||
|
api_key_id: record.api_key_id.clone(),
|
||||||
|
api_key_name: record.name.unwrap_or_else(|| "API Key".to_string()),
|
||||||
|
api_key,
|
||||||
|
base_url: base_url_from_request(headers, request_context),
|
||||||
|
target_cli: payload.target_cli,
|
||||||
|
target_system: payload.target_system,
|
||||||
|
expires_at_unix_secs,
|
||||||
|
};
|
||||||
|
let serialized = match serde_json::to_string(&session) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(err) => {
|
||||||
|
return build_auth_error_response(
|
||||||
|
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("install session serialize failed: {err:?}"),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(err) = state
|
||||||
|
.runtime_kv_setex(
|
||||||
|
&install_session_runtime_key(&code),
|
||||||
|
&serialized,
|
||||||
|
INSTALL_SESSION_TTL_SECS,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return build_auth_error_response(
|
||||||
|
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("install session create failed: {err:?}"),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let base_url = session.base_url.trim_end_matches('/');
|
||||||
|
Json(json!({
|
||||||
|
"install_code": code,
|
||||||
|
"expires_at_unix_secs": expires_at_unix_secs,
|
||||||
|
"expires_in_seconds": INSTALL_SESSION_TTL_SECS,
|
||||||
|
"target_cli": session.target_cli,
|
||||||
|
"target_cli_label": cli_label(session.target_cli),
|
||||||
|
"target_system": session.target_system,
|
||||||
|
"target_system_label": system_label(session.target_system),
|
||||||
|
"unix_command": format!("curl -fsSL {base_url}/install/{code} | sh"),
|
||||||
|
"powershell_command": format!("irm {base_url}/install/{code}.ps1 | iex"),
|
||||||
|
}))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn maybe_build_local_install_response(
|
||||||
|
state: &AppState,
|
||||||
|
request_context: &GatewayPublicRequestContext,
|
||||||
|
) -> Option<Response<Body>> {
|
||||||
|
let decision = request_context.control_decision.as_ref()?;
|
||||||
|
if decision.route_family.as_deref() != Some("install") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let Some((code, wants_powershell)) = install_code_from_path(&request_context.request_path)
|
||||||
|
else {
|
||||||
|
return Some(build_auth_error_response(
|
||||||
|
http::StatusCode::NOT_FOUND,
|
||||||
|
"install code 不存在或已失效",
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let raw = match state
|
||||||
|
.runtime_kv_getdel(&install_session_runtime_key(&code))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(value)) => value,
|
||||||
|
Ok(None) => {
|
||||||
|
return Some(build_auth_error_response(
|
||||||
|
http::StatusCode::NOT_FOUND,
|
||||||
|
"install code 不存在、已过期或已使用",
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
return Some(build_auth_error_response(
|
||||||
|
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("install session lookup failed: {err:?}"),
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let session = match serde_json::from_str::<StoredInstallSession>(&raw) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => {
|
||||||
|
return Some(build_auth_error_response(
|
||||||
|
http::StatusCode::BAD_REQUEST,
|
||||||
|
"install code 数据无效",
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if session.expires_at_unix_secs <= unix_secs_now() {
|
||||||
|
return Some(build_auth_error_response(
|
||||||
|
http::StatusCode::NOT_FOUND,
|
||||||
|
"install code 已过期",
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let body = if wants_powershell {
|
||||||
|
build_powershell_script(&session)
|
||||||
|
} else {
|
||||||
|
build_unix_script(&session)
|
||||||
|
};
|
||||||
|
let content_type = if wants_powershell {
|
||||||
|
"text/plain; charset=utf-8"
|
||||||
|
} else {
|
||||||
|
"text/x-shellscript; charset=utf-8"
|
||||||
|
};
|
||||||
|
let mut response = Response::new(Body::from(body));
|
||||||
|
response.headers_mut().insert(
|
||||||
|
http::header::CONTENT_TYPE,
|
||||||
|
http::HeaderValue::from_static(content_type),
|
||||||
|
);
|
||||||
|
response.headers_mut().insert(
|
||||||
|
http::header::CACHE_CONTROL,
|
||||||
|
http::HeaderValue::from_static("no-store"),
|
||||||
|
);
|
||||||
|
response.headers_mut().insert(
|
||||||
|
http::header::PRAGMA,
|
||||||
|
http::HeaderValue::from_static("no-cache"),
|
||||||
|
);
|
||||||
|
response.headers_mut().insert(
|
||||||
|
http::header::HeaderName::from_static("x-content-type-options"),
|
||||||
|
http::HeaderValue::from_static("nosniff"),
|
||||||
|
);
|
||||||
|
Some(response)
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
use super::{
|
use super::{
|
||||||
auth_password_policy_level, build_auth_error_response, build_auth_wallet_summary_payload,
|
auth_password_policy_level, build_auth_error_response, build_auth_wallet_summary_payload,
|
||||||
decrypt_catalog_secret_with_fallbacks, encrypt_catalog_secret_with_fallbacks, handle_auth_me,
|
decrypt_catalog_secret_with_fallbacks, encrypt_catalog_secret_with_fallbacks, handle_auth_me,
|
||||||
query_param_optional_bool, query_param_value, resolve_authenticated_local_user,
|
handle_users_me_api_key_install_session_create, query_param_optional_bool, query_param_value,
|
||||||
unix_secs_to_rfc3339, validate_auth_register_password, AppState, AuthenticatedLocalUserContext,
|
resolve_authenticated_local_user, unix_secs_to_rfc3339,
|
||||||
GatewayPublicRequestContext, PUBLIC_CAPABILITY_DEFINITIONS,
|
users_me_api_key_install_sessions_path_matches, validate_auth_register_password, AppState,
|
||||||
|
AuthenticatedLocalUserContext, GatewayPublicRequestContext, PUBLIC_CAPABILITY_DEFINITIONS,
|
||||||
};
|
};
|
||||||
use crate::admin_api::build_admin_endpoint_health_status_payload;
|
use crate::admin_api::build_admin_endpoint_health_status_payload;
|
||||||
use crate::handlers::internal::build_management_token_payload;
|
use crate::handlers::internal::build_management_token_payload;
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ use axum::{body::Body, http, response::Response};
|
|||||||
use super::{
|
use super::{
|
||||||
handle_auth_me, handle_users_me_api_key_capabilities_put, handle_users_me_api_key_create,
|
handle_auth_me, handle_users_me_api_key_capabilities_put, handle_users_me_api_key_create,
|
||||||
handle_users_me_api_key_delete, handle_users_me_api_key_detail_get,
|
handle_users_me_api_key_delete, handle_users_me_api_key_detail_get,
|
||||||
handle_users_me_api_key_patch, handle_users_me_api_key_providers_put,
|
handle_users_me_api_key_install_session_create, handle_users_me_api_key_patch,
|
||||||
handle_users_me_api_key_update, handle_users_me_api_keys_get, handle_users_me_available_models,
|
handle_users_me_api_key_providers_put, handle_users_me_api_key_update,
|
||||||
|
handle_users_me_api_keys_get, handle_users_me_available_models,
|
||||||
handle_users_me_delete_other_sessions, handle_users_me_delete_session,
|
handle_users_me_delete_other_sessions, handle_users_me_delete_session,
|
||||||
handle_users_me_detail_put, handle_users_me_endpoint_status_get,
|
handle_users_me_detail_put, handle_users_me_endpoint_status_get,
|
||||||
handle_users_me_management_token_create, handle_users_me_management_token_delete,
|
handle_users_me_management_token_create, handle_users_me_management_token_delete,
|
||||||
@@ -17,8 +18,8 @@ use super::{
|
|||||||
handle_users_me_providers_get, handle_users_me_sessions_get, handle_users_me_update_session,
|
handle_users_me_providers_get, handle_users_me_sessions_get, handle_users_me_update_session,
|
||||||
handle_users_me_usage_active_get, handle_users_me_usage_get, handle_users_me_usage_heatmap_get,
|
handle_users_me_usage_active_get, handle_users_me_usage_get, handle_users_me_usage_heatmap_get,
|
||||||
handle_users_me_usage_interval_timeline_get, users_me_api_key_capabilities_path_matches,
|
handle_users_me_usage_interval_timeline_get, users_me_api_key_capabilities_path_matches,
|
||||||
users_me_api_key_detail_path_matches, users_me_api_key_providers_path_matches,
|
users_me_api_key_detail_path_matches, users_me_api_key_install_sessions_path_matches,
|
||||||
users_me_management_token_detail_path_matches,
|
users_me_api_key_providers_path_matches, users_me_management_token_detail_path_matches,
|
||||||
users_me_management_token_regenerate_path_matches,
|
users_me_management_token_regenerate_path_matches,
|
||||||
users_me_management_token_toggle_path_matches, users_me_management_tokens_root,
|
users_me_management_token_toggle_path_matches, users_me_management_tokens_root,
|
||||||
users_me_session_detail_path_matches, AppState, GatewayPublicRequestContext,
|
users_me_session_detail_path_matches, AppState, GatewayPublicRequestContext,
|
||||||
@@ -167,6 +168,19 @@ pub(crate) async fn maybe_build_local_users_me_response(
|
|||||||
.await,
|
.await,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Some("api_key_install_session_create")
|
||||||
|
if users_me_api_key_install_sessions_path_matches(&request_context.request_path) =>
|
||||||
|
{
|
||||||
|
Some(
|
||||||
|
handle_users_me_api_key_install_session_create(
|
||||||
|
state,
|
||||||
|
request_context,
|
||||||
|
headers,
|
||||||
|
request_body,
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
}
|
||||||
Some("management_token_regenerate")
|
Some("management_token_regenerate")
|
||||||
if users_me_management_token_regenerate_path_matches(&request_context.request_path) =>
|
if users_me_management_token_regenerate_path_matches(&request_context.request_path) =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -443,7 +443,11 @@ pub(crate) fn public_support_local_requires_buffered_body(
|
|||||||
) | (
|
) | (
|
||||||
Some("users_me"),
|
Some("users_me"),
|
||||||
http::Method::POST,
|
http::Method::POST,
|
||||||
Some("api_keys_create" | "management_tokens_create"),
|
Some(
|
||||||
|
"api_keys_create"
|
||||||
|
| "api_key_install_session_create"
|
||||||
|
| "management_tokens_create",
|
||||||
|
),
|
||||||
) | (
|
) | (
|
||||||
Some("wallet"),
|
Some("wallet"),
|
||||||
http::Method::POST,
|
http::Method::POST,
|
||||||
|
|||||||
@@ -171,6 +171,21 @@ export interface ApiKey {
|
|||||||
force_capabilities?: Record<string, boolean> | null // 强制能力配置
|
force_capabilities?: Record<string, boolean> | null // 强制能力配置
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type InstallTargetCli = 'claude_code' | 'codex_cli' | 'gemini_cli'
|
||||||
|
export type InstallTargetSystem = 'macos' | 'linux' | 'windows' | 'auto'
|
||||||
|
|
||||||
|
export interface ApiKeyInstallSession {
|
||||||
|
install_code: string
|
||||||
|
expires_at_unix_secs: number
|
||||||
|
expires_in_seconds: number
|
||||||
|
target_cli: InstallTargetCli
|
||||||
|
target_cli_label: string
|
||||||
|
target_system: InstallTargetSystem
|
||||||
|
target_system_label: string
|
||||||
|
unix_command: string
|
||||||
|
powershell_command: string
|
||||||
|
}
|
||||||
|
|
||||||
// 不再需要 ProviderBinding 接口
|
// 不再需要 ProviderBinding 接口
|
||||||
|
|
||||||
export interface ChangePasswordRequest {
|
export interface ChangePasswordRequest {
|
||||||
@@ -270,6 +285,17 @@ export const meApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async createApiKeyInstallSession(
|
||||||
|
keyId: string,
|
||||||
|
data: { target_cli: InstallTargetCli; target_system: InstallTargetSystem }
|
||||||
|
): Promise<ApiKeyInstallSession> {
|
||||||
|
const response = await apiClient.post<ApiKeyInstallSession>(
|
||||||
|
`/api/users/me/api-keys/${keyId}/install-sessions`,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
// 使用统计
|
// 使用统计
|
||||||
async getUsage(params?: {
|
async getUsage(params?: {
|
||||||
start_date?: string
|
start_date?: string
|
||||||
|
|||||||
@@ -191,6 +191,15 @@
|
|||||||
<!-- 操作按钮 -->
|
<!-- 操作按钮 -->
|
||||||
<TableCell class="py-4">
|
<TableCell class="py-4">
|
||||||
<div class="flex justify-center gap-1">
|
<div class="flex justify-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
title="一键安装并配置 CLI"
|
||||||
|
@click="openInstallDialog(apiKey)"
|
||||||
|
>
|
||||||
|
<Terminal class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -273,6 +282,15 @@
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-0.5 flex-shrink-0">
|
<div class="flex items-center gap-0.5 flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7"
|
||||||
|
title="一键安装并配置 CLI"
|
||||||
|
@click="openInstallDialog(apiKey)"
|
||||||
|
>
|
||||||
|
<Terminal class="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -503,13 +521,110 @@
|
|||||||
<template #footer>
|
<template #footer>
|
||||||
<Button
|
<Button
|
||||||
class="h-10 px-5"
|
class="h-10 px-5"
|
||||||
@click="showKeyDialog = false"
|
@click="closeCreatedKeyDialog"
|
||||||
>
|
>
|
||||||
确定
|
确定
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- 一键安装并配置 CLI 对话框 -->
|
||||||
|
<Dialog
|
||||||
|
v-model="showInstallDialog"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="border-b border-border px-6 py-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
|
||||||
|
<Terminal class="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="text-lg font-semibold text-foreground leading-tight">
|
||||||
|
一键安装并配置 CLI
|
||||||
|
</h3>
|
||||||
|
<p class="text-xs text-muted-foreground truncate">
|
||||||
|
当前密钥:{{ selectedInstallApiKey?.name || '未选择' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="space-y-5">
|
||||||
|
<div class="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||||
|
选择要配置的 CLI 和目标系统,Aether 会生成 15 分钟内有效的一次性 install code。页面命令不会包含原始 API Key。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label class="text-sm font-semibold">目标 CLI</Label>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
|
<Button
|
||||||
|
v-for="option in installCliOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:variant="installCli === option.value ? 'default' : 'outline'"
|
||||||
|
class="justify-start h-auto py-3"
|
||||||
|
@click="selectInstallCli(option.value)"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label class="text-sm font-semibold">目标系统</Label>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
|
<Button
|
||||||
|
v-for="option in installSystemOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:variant="installSystem === option.value ? 'default' : 'outline'"
|
||||||
|
class="justify-start h-auto py-3"
|
||||||
|
@click="selectInstallSystem(option.value)"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<Label class="text-sm font-semibold">复制到目标机器执行</Label>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:disabled="installLoading || !selectedInstallApiKey"
|
||||||
|
@click="refreshInstallCommand"
|
||||||
|
>
|
||||||
|
{{ installLoading ? '生成中...' : '重新生成' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-border/60 bg-background overflow-hidden">
|
||||||
|
<pre class="max-h-32 overflow-x-auto whitespace-pre-wrap break-all p-3 text-xs font-mono">{{ installCommand || '正在生成短命令...' }}</pre>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
{{ installCommandHint }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="h-10 px-5"
|
||||||
|
@click="showInstallDialog = false"
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
class="h-10 px-5 shadow-lg shadow-primary/20"
|
||||||
|
:disabled="!installCommand || installLoading"
|
||||||
|
@click="copyTextToClipboard(installCommand)"
|
||||||
|
>
|
||||||
|
复制命令
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<!-- 删除确认对话框 -->
|
<!-- 删除确认对话框 -->
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
v-model="showDeleteDialog"
|
v-model="showDeleteDialog"
|
||||||
@@ -525,8 +640,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, computed, watch } from 'vue'
|
||||||
import { meApi, type ApiKey } from '@/api/me'
|
import { meApi, type ApiKey, type InstallTargetCli, type InstallTargetSystem, type ApiKeyInstallSession } from '@/api/me'
|
||||||
import Card from '@/components/ui/card.vue'
|
import Card from '@/components/ui/card.vue'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
import Input from '@/components/ui/input.vue'
|
import Input from '@/components/ui/input.vue'
|
||||||
@@ -543,17 +658,28 @@ import {
|
|||||||
TableRow
|
TableRow
|
||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
import RefreshButton from '@/components/ui/refresh-button.vue'
|
import RefreshButton from '@/components/ui/refresh-button.vue'
|
||||||
import { Plus, Key, Copy, Trash2, Loader2, Activity, CheckCircle, Power, SquarePen } from 'lucide-vue-next'
|
import { Plus, Key, Copy, Trash2, Loader2, Activity, CheckCircle, Power, SquarePen, Terminal } from 'lucide-vue-next'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import { formatRateLimitSimple } from '@/utils/format'
|
import { formatRateLimitSimple } from '@/utils/format'
|
||||||
import { parseNumberInput } from '@/utils/form'
|
import { parseNumberInput } from '@/utils/form'
|
||||||
import { getErrorStatus } from '@/types/api-error'
|
import { getErrorStatus } from '@/types/api-error'
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
const { success, error: showError } = useToast()
|
const { success, error: showError } = useToast()
|
||||||
|
|
||||||
|
const installCliOptions: Array<{ value: InstallTargetCli; label: string }> = [
|
||||||
|
{ value: 'claude_code', label: 'Claude Code' },
|
||||||
|
{ value: 'codex_cli', label: 'Codex CLI' },
|
||||||
|
{ value: 'gemini_cli', label: 'Gemini CLI' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const installSystemOptions: Array<{ value: Exclude<InstallTargetSystem, 'auto'>; label: string }> = [
|
||||||
|
{ value: 'macos', label: 'macOS' },
|
||||||
|
{ value: 'linux', label: 'Linux' },
|
||||||
|
{ value: 'windows', label: 'Windows' }
|
||||||
|
]
|
||||||
|
|
||||||
const apiKeys = ref<ApiKey[]>([])
|
const apiKeys = ref<ApiKey[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const creating = ref(false)
|
const creating = ref(false)
|
||||||
@@ -571,6 +697,7 @@ const paginatedApiKeys = computed(() => {
|
|||||||
const showCreateDialog = ref(false)
|
const showCreateDialog = ref(false)
|
||||||
const showKeyDialog = ref(false)
|
const showKeyDialog = ref(false)
|
||||||
const showDeleteDialog = ref(false)
|
const showDeleteDialog = ref(false)
|
||||||
|
const showInstallDialog = ref(false)
|
||||||
|
|
||||||
const newKeyName = ref('')
|
const newKeyName = ref('')
|
||||||
const newKeyRateLimit = ref<number | undefined>(undefined)
|
const newKeyRateLimit = ref<number | undefined>(undefined)
|
||||||
@@ -578,11 +705,38 @@ const newKeyConcurrentLimit = ref<number | undefined>(undefined)
|
|||||||
const newKeyValue = ref('')
|
const newKeyValue = ref('')
|
||||||
const keyToDelete = ref<ApiKey | null>(null)
|
const keyToDelete = ref<ApiKey | null>(null)
|
||||||
const editingApiKey = ref<ApiKey | null>(null)
|
const editingApiKey = ref<ApiKey | null>(null)
|
||||||
|
const selectedInstallApiKey = ref<ApiKey | null>(null)
|
||||||
|
const pendingFirstInstallApiKey = ref<ApiKey | null>(null)
|
||||||
|
const installCli = ref<InstallTargetCli>('claude_code')
|
||||||
|
const installSystem = ref<Exclude<InstallTargetSystem, 'auto'>>('linux')
|
||||||
|
const installSession = ref<ApiKeyInstallSession | null>(null)
|
||||||
|
const installLoading = ref(false)
|
||||||
|
|
||||||
|
const installCommand = computed(() => {
|
||||||
|
if (!installSession.value) return ''
|
||||||
|
return installSystem.value === 'windows'
|
||||||
|
? installSession.value.powershell_command
|
||||||
|
: installSession.value.unix_command
|
||||||
|
})
|
||||||
|
|
||||||
|
const installCommandHint = computed(() => {
|
||||||
|
if (installSystem.value === 'windows') {
|
||||||
|
return 'Windows 请在 PowerShell 中执行。install code 使用后立即失效,如需再次执行请重新生成。'
|
||||||
|
}
|
||||||
|
return 'macOS / Linux 请在 sh 兼容终端中执行。install code 使用后立即失效,如需再次执行请重新生成。'
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
installSystem.value = detectCurrentSystem()
|
||||||
loadApiKeys()
|
loadApiKeys()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(showKeyDialog, (isOpen) => {
|
||||||
|
if (!isOpen && pendingFirstInstallApiKey.value) {
|
||||||
|
closeCreatedKeyDialog()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
async function loadApiKeys() {
|
async function loadApiKeys() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -618,6 +772,57 @@ function openCreateApiKeyDialog() {
|
|||||||
showCreateDialog.value = true
|
showCreateDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function detectCurrentSystem(): Exclude<InstallTargetSystem, 'auto'> {
|
||||||
|
const platform = window.navigator.platform.toLowerCase()
|
||||||
|
const userAgent = window.navigator.userAgent.toLowerCase()
|
||||||
|
if (platform.includes('mac')) return 'macos'
|
||||||
|
if (platform.includes('win') || userAgent.includes('windows')) return 'windows'
|
||||||
|
return 'linux'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openInstallDialog(apiKey: ApiKey) {
|
||||||
|
selectedInstallApiKey.value = apiKey
|
||||||
|
installSession.value = null
|
||||||
|
showInstallDialog.value = true
|
||||||
|
await refreshInstallCommand()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectInstallCli(value: InstallTargetCli) {
|
||||||
|
installCli.value = value
|
||||||
|
await refreshInstallCommand()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectInstallSystem(value: Exclude<InstallTargetSystem, 'auto'>) {
|
||||||
|
installSystem.value = value
|
||||||
|
await refreshInstallCommand()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshInstallCommand() {
|
||||||
|
if (!selectedInstallApiKey.value) return
|
||||||
|
installLoading.value = true
|
||||||
|
installSession.value = null
|
||||||
|
try {
|
||||||
|
installSession.value = await meApi.createApiKeyInstallSession(selectedInstallApiKey.value.id, {
|
||||||
|
target_cli: installCli.value,
|
||||||
|
target_system: installSystem.value,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
log.error('生成 CLI 安装命令失败:', error)
|
||||||
|
showError(parseApiError(error, '生成 CLI 安装命令失败'))
|
||||||
|
} finally {
|
||||||
|
installLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCreatedKeyDialog() {
|
||||||
|
showKeyDialog.value = false
|
||||||
|
const pending = pendingFirstInstallApiKey.value
|
||||||
|
pendingFirstInstallApiKey.value = null
|
||||||
|
if (pending) {
|
||||||
|
void openInstallDialog(pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function closeApiKeyDialog() {
|
function closeApiKeyDialog() {
|
||||||
showCreateDialog.value = false
|
showCreateDialog.value = false
|
||||||
editingApiKey.value = null
|
editingApiKey.value = null
|
||||||
@@ -634,6 +839,7 @@ async function saveApiKey() {
|
|||||||
|
|
||||||
creating.value = true
|
creating.value = true
|
||||||
try {
|
try {
|
||||||
|
const isCreatingFirstApiKey = !editingApiKey.value && apiKeys.value.length === 0
|
||||||
if (editingApiKey.value) {
|
if (editingApiKey.value) {
|
||||||
await meApi.updateApiKey(editingApiKey.value.id, {
|
await meApi.updateApiKey(editingApiKey.value.id, {
|
||||||
name: newKeyName.value,
|
name: newKeyName.value,
|
||||||
@@ -648,6 +854,9 @@ async function saveApiKey() {
|
|||||||
concurrent_limit: newKeyConcurrentLimit.value,
|
concurrent_limit: newKeyConcurrentLimit.value,
|
||||||
})
|
})
|
||||||
newKeyValue.value = newKey.key || ''
|
newKeyValue.value = newKey.key || ''
|
||||||
|
if (isCreatingFirstApiKey) {
|
||||||
|
pendingFirstInstallApiKey.value = newKey
|
||||||
|
}
|
||||||
showKeyDialog.value = true
|
showKeyDialog.value = true
|
||||||
success('API 密钥创建成功')
|
success('API 密钥创建成功')
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user