Skip to content

Commit 9aa6476

Browse files
feat(cli): add cap CLI with upload, record, settings, and video management
1 parent 9932a61 commit 9aa6476

File tree

28 files changed

+4073
-145
lines changed

28 files changed

+4073
-145
lines changed

Cargo.lock

Lines changed: 158 additions & 44 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ members = [
44
"apps/cli",
55
"apps/desktop/src-tauri",
66
"crates/*",
7+
"crates/upload",
78
"crates/workspace-hack",
89
]
910

apps/cli/Cargo.toml

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,30 +3,53 @@ name = "cap"
33
version = "0.1.0"
44
edition = "2024"
55

6+
[features]
7+
default = []
8+
record = [
9+
"dep:cap-project",
10+
"dep:cap-rendering",
11+
"dep:cap-editor",
12+
"dep:cap-media",
13+
"dep:cap-recording",
14+
"dep:cap-export",
15+
"dep:cap-camera",
16+
"dep:scap-targets",
17+
"dep:ffmpeg",
18+
"dep:flume",
19+
"dep:libc",
20+
]
21+
622
[dependencies]
723
clap = { version = "4.5.23", features = ["derive"] }
824
cap-utils = { path = "../../crates/utils" }
9-
cap-project = { path = "../../crates/project" }
10-
cap-rendering = { path = "../../crates/rendering" }
11-
cap-editor = { path = "../../crates/editor" }
12-
cap-media = { path = "../../crates/media" }
25+
cap-project = { path = "../../crates/project", optional = true }
26+
cap-rendering = { path = "../../crates/rendering", optional = true }
27+
cap-editor = { path = "../../crates/editor", optional = true }
28+
cap-media = { path = "../../crates/media", optional = true }
1329
cap-flags = { path = "../../crates/flags" }
14-
cap-recording = { path = "../../crates/recording" }
15-
cap-export = { path = "../../crates/export" }
16-
cap-camera = { path = "../../crates/camera" }
17-
scap-targets = { path = "../../crates/scap-targets" }
30+
cap-recording = { path = "../../crates/recording", optional = true }
31+
cap-export = { path = "../../crates/export", optional = true }
32+
cap-camera = { path = "../../crates/camera", optional = true }
33+
cap-upload = { path = "../../crates/upload" }
34+
scap-targets = { path = "../../crates/scap-targets", optional = true }
1835
serde = { workspace = true }
1936
serde_json = "1.0.133"
2037
tokio.workspace = true
2138
uuid = { version = "1.11.1", features = ["v4"] }
22-
ffmpeg = { workspace = true }
39+
ffmpeg = { workspace = true, optional = true }
2340
tracing.workspace = true
2441
tracing-subscriber = "0.3.19"
25-
flume.workspace = true
42+
flume = { workspace = true, optional = true }
43+
indicatif = "0.17"
44+
open = "5"
45+
percent-encoding = { workspace = true }
46+
libc = { version = "0.2", optional = true }
47+
dirs = "5"
48+
chrono = { version = "0.4", features = ["serde"] }
2649
workspace-hack = { version = "0.1", path = "../../crates/workspace-hack" }
2750

2851
[target.'cfg(target_os = "macos")'.dependencies]
29-
cidre = { workspace = true }
52+
cidre = { workspace = true, optional = true }
3053

3154
[lints]
3255
workspace = true

apps/cli/src/auth.rs

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
use cap_upload::AuthConfig;
2+
use clap::{Args, Subcommand};
3+
use tokio::io::{AsyncReadExt, AsyncWriteExt};
4+
use tokio::net::TcpListener;
5+
6+
#[derive(Args)]
7+
pub struct AuthArgs {
8+
#[command(subcommand)]
9+
command: AuthCommands,
10+
}
11+
12+
#[derive(Subcommand)]
13+
enum AuthCommands {
14+
Login(LoginArgs),
15+
Logout,
16+
Status,
17+
}
18+
19+
#[derive(Args)]
20+
struct LoginArgs {
21+
#[arg(long, default_value = "https://cap.so")]
22+
server: String,
23+
#[arg(long)]
24+
api_key: Option<String>,
25+
}
26+
27+
impl AuthArgs {
28+
pub async fn run(self, json: bool) -> Result<(), String> {
29+
match self.command {
30+
AuthCommands::Login(args) => login(args, json).await,
31+
AuthCommands::Logout => logout(json),
32+
AuthCommands::Status => status(json),
33+
}
34+
}
35+
}
36+
37+
async fn login(args: LoginArgs, json: bool) -> Result<(), String> {
38+
let server_url = args.server.trim_end_matches('/').to_string();
39+
40+
if let Some(api_key) = args.api_key {
41+
let path =
42+
AuthConfig::save(&server_url, &api_key).map_err(|e| format!("Failed to save: {e}"))?;
43+
44+
if json {
45+
println!(
46+
"{}",
47+
serde_json::json!({
48+
"status": "logged_in",
49+
"server_url": server_url,
50+
"config_path": path.display().to_string()
51+
})
52+
);
53+
} else {
54+
eprintln!("Logged in to {server_url}");
55+
eprintln!("Config saved to {}", path.display());
56+
}
57+
return Ok(());
58+
}
59+
60+
let listener = TcpListener::bind("127.0.0.1:0")
61+
.await
62+
.map_err(|e| format!("Failed to bind local listener: {e}"))?;
63+
let port = listener
64+
.local_addr()
65+
.map_err(|e| format!("Failed to get port: {e}"))?
66+
.port();
67+
68+
let auth_url = format!(
69+
"{}/api/desktop/session/request?type=api_key&port={}&platform=web",
70+
server_url, port
71+
);
72+
73+
eprintln!("Opening browser for login...");
74+
eprintln!("If the browser does not open, visit: {auth_url}");
75+
76+
if open::that(&auth_url).is_err() {
77+
eprintln!("Could not open browser automatically.");
78+
}
79+
80+
eprintln!("Waiting for authentication...");
81+
82+
let (mut stream, _addr) =
83+
tokio::time::timeout(std::time::Duration::from_secs(300), listener.accept())
84+
.await
85+
.map_err(|_| "Login timed out after 5 minutes. Please try again.".to_string())?
86+
.map_err(|e| format!("Failed to accept connection: {e}"))?;
87+
88+
let mut buf = vec![0u8; 4096];
89+
let n = stream
90+
.read(&mut buf)
91+
.await
92+
.map_err(|e| format!("Failed to read: {e}"))?;
93+
let request = String::from_utf8_lossy(&buf[..n]);
94+
95+
let api_key = extract_query_param(&request, "api_key");
96+
let user_id = extract_query_param(&request, "user_id");
97+
98+
let response_body = if api_key.is_some() {
99+
"Authentication successful! You can close this tab."
100+
} else {
101+
"Authentication failed. Please try again."
102+
};
103+
104+
let response = format!(
105+
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
106+
response_body.len(),
107+
response_body
108+
);
109+
110+
stream
111+
.write_all(response.as_bytes())
112+
.await
113+
.map_err(|e| format!("Failed to write response: {e}"))?;
114+
115+
let api_key = api_key.ok_or("Server did not return an API key")?;
116+
117+
let path = AuthConfig::save(&server_url, &api_key)
118+
.map_err(|e| format!("Failed to save credentials: {e}"))?;
119+
120+
if json {
121+
println!(
122+
"{}",
123+
serde_json::json!({
124+
"status": "logged_in",
125+
"server_url": server_url,
126+
"user_id": user_id,
127+
"config_path": path.display().to_string()
128+
})
129+
);
130+
} else {
131+
eprintln!("Logged in to {server_url}");
132+
if let Some(uid) = user_id {
133+
eprintln!("User: {uid}");
134+
}
135+
eprintln!("Config saved to {}", path.display());
136+
}
137+
138+
Ok(())
139+
}
140+
141+
fn logout(json: bool) -> Result<(), String> {
142+
AuthConfig::remove().map_err(|e| format!("Failed to remove credentials: {e}"))?;
143+
144+
if json {
145+
println!("{}", serde_json::json!({"status": "logged_out"}));
146+
} else {
147+
eprintln!("Logged out successfully.");
148+
}
149+
Ok(())
150+
}
151+
152+
fn status(json: bool) -> Result<(), String> {
153+
match AuthConfig::resolve() {
154+
Ok(config) => {
155+
if json {
156+
println!(
157+
"{}",
158+
serde_json::json!({
159+
"status": "logged_in",
160+
"server_url": config.server_url,
161+
})
162+
);
163+
} else {
164+
eprintln!("Logged in to {}", config.server_url);
165+
}
166+
Ok(())
167+
}
168+
Err(_) => {
169+
if json {
170+
println!("{}", serde_json::json!({"status": "not_logged_in"}));
171+
} else {
172+
eprintln!(
173+
"Not logged in. Run \"cap auth login --server URL\" or set CAP_API_KEY and CAP_SERVER_URL environment variables."
174+
);
175+
}
176+
Ok(())
177+
}
178+
}
179+
}
180+
181+
fn extract_query_param(request: &str, param: &str) -> Option<String> {
182+
let first_line = request.lines().next()?;
183+
let path = first_line.split_whitespace().nth(1)?;
184+
let query = path.split('?').nth(1)?;
185+
for pair in query.split('&') {
186+
let mut kv = pair.splitn(2, '=');
187+
if let (Some(key), Some(value)) = (kv.next(), kv.next()) {
188+
if key == param {
189+
return Some(
190+
percent_encoding::percent_decode_str(value)
191+
.decode_utf8_lossy()
192+
.to_string(),
193+
);
194+
}
195+
}
196+
}
197+
None
198+
}

0 commit comments

Comments
 (0)