Skip to content

Commit 07eddb5

Browse files
committed
cli: Implement logout command for WorkOS credential cleanup
Add `sce logout` command that removes locally stored WorkOS authentication tokens. The command gracefully handles the case where no credentials exist (already logged out state).
1 parent 2a9fa90 commit 07eddb5

10 files changed

Lines changed: 214 additions & 12 deletions

File tree

cli/src/app.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ enum Command {
114114
DoctorHelp,
115115
Login(services::login::LoginRequest),
116116
LoginHelp,
117+
Logout(services::logout::LogoutRequest),
118+
LogoutHelp,
117119
Mcp(services::mcp::McpRequest),
118120
McpHelp,
119121
Hooks(services::hooks::HookSubcommand),
@@ -133,6 +135,7 @@ impl Command {
133135
Self::Setup(_) | Self::SetupHelp => services::setup::NAME,
134136
Self::Doctor(_) | Self::DoctorHelp => services::doctor::NAME,
135137
Self::Login(_) | Self::LoginHelp => services::login::NAME,
138+
Self::Logout(_) | Self::LogoutHelp => services::logout::NAME,
136139
Self::Mcp(_) | Self::McpHelp => services::mcp::NAME,
137140
Self::Hooks(_) | Self::HooksHelp => services::hooks::NAME,
138141
Self::Sync(_) | Self::SyncHelp => services::sync::NAME,
@@ -352,6 +355,7 @@ fn parse_subcommand(value: String, tail_args: Vec<String>) -> Result<Command, Cl
352355
"setup" => parse_setup_subcommand(tail_args),
353356
"doctor" => parse_doctor_subcommand(tail_args),
354357
"login" => parse_login_subcommand(tail_args),
358+
"logout" => parse_logout_subcommand(tail_args),
355359
"mcp" => parse_mcp_subcommand(tail_args),
356360
"hooks" => parse_hooks_subcommand(tail_args),
357361
"sync" => parse_sync_subcommand(tail_args),
@@ -441,6 +445,16 @@ fn parse_sync_subcommand(args: Vec<String>) -> Result<Command, ClassifiedError>
441445
Ok(Command::Sync(request))
442446
}
443447

448+
fn parse_logout_subcommand(args: Vec<String>) -> Result<Command, ClassifiedError> {
449+
if args.len() == 1 && (args[0] == "--help" || args[0] == "-h") {
450+
return Ok(Command::LogoutHelp);
451+
}
452+
453+
let request = services::logout::parse_logout_request(args)
454+
.map_err(|error| ClassifiedError::validation(error.to_string()))?;
455+
Ok(Command::Logout(request))
456+
}
457+
444458
fn parse_hooks_subcommand(args: Vec<String>) -> Result<Command, ClassifiedError> {
445459
if args.len() == 1 && (args[0] == "--help" || args[0] == "-h") {
446460
return Ok(Command::HooksHelp);
@@ -514,6 +528,9 @@ fn dispatch(command: &Command) -> Result<String, ClassifiedError> {
514528
Command::LoginHelp => Ok(services::login::login_usage_text().to_string()),
515529
Command::Login(request) => services::login::run_login(*request)
516530
.map_err(|error| ClassifiedError::runtime(error.to_string())),
531+
Command::LogoutHelp => Ok(services::logout::logout_usage_text().to_string()),
532+
Command::Logout(request) => services::logout::run_logout(*request)
533+
.map_err(|error| ClassifiedError::runtime(error.to_string())),
517534
Command::McpHelp => Ok(services::mcp::mcp_usage_text().to_string()),
518535
Command::Mcp(request) => services::mcp::run_placeholder_mcp(*request)
519536
.map_err(|error| ClassifiedError::runtime(error.to_string())),
@@ -719,6 +736,16 @@ mod tests {
719736
assert_eq!(code, ExitCode::SUCCESS);
720737
}
721738

739+
#[test]
740+
fn logout_help_exits_success() {
741+
let code = run(vec![
742+
"sce".to_string(),
743+
"logout".to_string(),
744+
"--help".to_string(),
745+
]);
746+
assert_eq!(code, ExitCode::SUCCESS);
747+
}
748+
722749
#[test]
723750
fn hooks_help_exits_success() {
724751
let code = run(vec![
@@ -1036,6 +1063,17 @@ mod tests {
10361063
assert_eq!(command, Command::LoginHelp);
10371064
}
10381065

1066+
#[test]
1067+
fn parser_routes_logout_help() {
1068+
let command = parse_command(vec![
1069+
"sce".to_string(),
1070+
"logout".to_string(),
1071+
"--help".to_string(),
1072+
])
1073+
.expect("command should parse");
1074+
assert_eq!(command, Command::LogoutHelp);
1075+
}
1076+
10391077
#[test]
10401078
fn parser_routes_mcp_json_format() {
10411079
let command = parse_command(vec![

cli/src/command_surface.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ pub const COMMANDS: &[CommandContract] = &[
3939
status: ImplementationStatus::Implemented,
4040
purpose: "Authenticate with WorkOS via OAuth device flow",
4141
},
42+
CommandContract {
43+
name: services::logout::NAME,
44+
status: ImplementationStatus::Implemented,
45+
purpose: "Clear locally stored WorkOS authentication credentials",
46+
},
4247
CommandContract {
4348
name: services::mcp::NAME,
4449
status: ImplementationStatus::Placeholder,
@@ -91,11 +96,11 @@ Config usage:\n sce config <show|validate> [--format <text|json>] [options]\n\n
9196
Setup usage:\n sce setup [--opencode|--claude|--both] [--non-interactive] [--hooks] [--repo <path>]\n\n\
9297
Completion usage:\n sce completion --shell <bash|zsh|fish>\n\n\
9398
Output format contract:\n Supported commands accept --format <text|json>\n\n\
94-
Examples:\n sce setup\n sce setup --opencode --non-interactive --hooks\n sce setup --hooks --repo ../demo-repo\n sce doctor --format json\n sce version --format json\n WORKOS_CLIENT_ID=client_123 sce login\n\n\
99+
Examples:\n sce setup\n sce setup --opencode --non-interactive --hooks\n sce setup --hooks --repo ../demo-repo\n sce doctor --format json\n sce version --format json\n WORKOS_CLIENT_ID=client_123 sce login\n sce logout\n\n\
95100
Commands:\n{}\n\n\
96101
Setup defaults to interactive target selection when no setup target flag is passed, and installs hooks in the same run.\n\
97102
Use '--hooks' to install required git hooks for the current repository or '--repo <path>' for a specific repository.\n\
98-
`setup`, `doctor`, `hooks`, and `login` are implemented; `mcp` and `sync` remain placeholder-oriented.\n",
103+
`setup`, `doctor`, `hooks`, `login`, and `logout` are implemented; `mcp` and `sync` remain placeholder-oriented.\n",
99104
command_rows
100105
)
101106
}
@@ -143,6 +148,8 @@ mod tests {
143148
let help = help_text();
144149
assert!(help.contains("login"));
145150
assert!(help.contains("WORKOS_CLIENT_ID=client_123 sce login"));
151+
assert!(help.contains("logout"));
152+
assert!(help.contains("sce logout"));
146153
}
147154

148155
#[test]

cli/src/services/logout.rs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
use std::fs;
2+
use std::path::Path;
3+
4+
use anyhow::{bail, Context, Result};
5+
use lexopt::Arg;
6+
use lexopt::ValueExt;
7+
8+
use crate::services::token_storage::token_file_path;
9+
10+
pub const NAME: &str = "logout";
11+
12+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13+
pub struct LogoutRequest;
14+
15+
pub fn logout_usage_text() -> &'static str {
16+
"Usage:\n sce logout\n\nDescription:\n Delete locally stored WorkOS authentication tokens.\n\nExamples:\n sce logout"
17+
}
18+
19+
pub fn parse_logout_request(args: Vec<String>) -> Result<LogoutRequest> {
20+
let mut parser = lexopt::Parser::from_args(args);
21+
22+
if let Some(arg) = parser.next()? {
23+
match arg {
24+
Arg::Long("help") | Arg::Short('h') => {
25+
bail!("Use 'sce logout --help' for logout usage.");
26+
}
27+
Arg::Long(option) => {
28+
bail!(
29+
"Unknown logout option '--{}'. Run 'sce logout --help' to see valid usage.",
30+
option
31+
);
32+
}
33+
Arg::Short(option) => {
34+
bail!(
35+
"Unknown logout option '-{}'. Run 'sce logout --help' to see valid usage.",
36+
option
37+
);
38+
}
39+
Arg::Value(value) => {
40+
bail!(
41+
"Unexpected logout argument '{}'. Run 'sce logout --help' to see valid usage.",
42+
value.string()?
43+
);
44+
}
45+
}
46+
}
47+
48+
Ok(LogoutRequest)
49+
}
50+
51+
pub fn run_logout(_request: LogoutRequest) -> Result<String> {
52+
let path = token_file_path().context("failed to resolve token storage path")?;
53+
remove_token_file_at_path(&path)
54+
}
55+
56+
fn remove_token_file_at_path(path: &Path) -> Result<String> {
57+
match fs::remove_file(path) {
58+
Ok(()) => Ok(format!(
59+
"Logged out successfully. Removed stored WorkOS credentials at '{}'.",
60+
path.display()
61+
)),
62+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
63+
Ok("No stored WorkOS credentials were found. You are already logged out.".to_string())
64+
}
65+
Err(error) => Err(error)
66+
.with_context(|| {
67+
format!(
68+
"failed to delete stored WorkOS credentials at '{}'",
69+
path.display()
70+
)
71+
})
72+
.context(
73+
"Try: verify file permissions for the auth state directory and rerun 'sce logout'",
74+
),
75+
}
76+
}
77+
78+
#[cfg(test)]
79+
mod tests {
80+
use std::fs;
81+
82+
use super::{
83+
logout_usage_text, parse_logout_request, remove_token_file_at_path, LogoutRequest,
84+
};
85+
86+
fn unique_test_path(test_name: &str) -> std::path::PathBuf {
87+
let unique = format!(
88+
"sce-logout-{}-{}-{}",
89+
test_name,
90+
std::process::id(),
91+
std::time::SystemTime::now()
92+
.duration_since(std::time::UNIX_EPOCH)
93+
.expect("system clock should be after unix epoch")
94+
.as_nanos()
95+
);
96+
std::env::temp_dir().join(unique).join("tokens.json")
97+
}
98+
99+
#[test]
100+
fn parse_logout_accepts_no_arguments() {
101+
let request = parse_logout_request(vec![]).expect("logout request should parse");
102+
assert_eq!(request, LogoutRequest);
103+
}
104+
105+
#[test]
106+
fn parse_logout_rejects_unexpected_argument() {
107+
let error = parse_logout_request(vec!["extra".to_string()])
108+
.expect_err("unexpected argument should fail");
109+
assert_eq!(
110+
error.to_string(),
111+
"Unexpected logout argument 'extra'. Run 'sce logout --help' to see valid usage."
112+
);
113+
}
114+
115+
#[test]
116+
fn usage_mentions_logout_command() {
117+
let usage = logout_usage_text();
118+
assert!(usage.contains("sce logout"));
119+
}
120+
121+
#[test]
122+
fn remove_token_file_deletes_existing_file() {
123+
let token_path = unique_test_path("delete-existing");
124+
let parent = token_path.parent().expect("token path should have parent");
125+
fs::create_dir_all(parent).expect("should create parent directory");
126+
fs::write(&token_path, "{}").expect("should create token file");
127+
128+
let result = remove_token_file_at_path(&token_path).expect("logout should succeed");
129+
assert!(result.contains("Logged out successfully"));
130+
assert!(!token_path.exists());
131+
132+
let _ = fs::remove_dir_all(
133+
token_path
134+
.parent()
135+
.and_then(|path| path.parent())
136+
.expect("temp tree should have two parent levels"),
137+
);
138+
}
139+
140+
#[test]
141+
fn remove_token_file_handles_missing_file() {
142+
let token_path = unique_test_path("already-logged-out");
143+
144+
let result =
145+
remove_token_file_at_path(&token_path).expect("missing token file should not fail");
146+
assert_eq!(
147+
result,
148+
"No stored WorkOS credentials were found. You are already logged out."
149+
);
150+
}
151+
}

cli/src/services/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ pub mod hooks;
77
pub mod hosted_reconciliation;
88
pub mod local_db;
99
pub mod login;
10+
pub mod logout;
1011
pub mod mcp;
1112
pub mod observability;
1213
pub mod output_format;

context/architecture.md

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

0 commit comments

Comments
 (0)