feat: Implement CLI daemon management and library commands

This commit is contained in:
Jamie Pine committed 2025-09-11 20:30:09 -04:00
1 parent ce7ada15f0
commit 50b15bbc1c
19 files changed
+206 -73

No files matched your search

+5 -2
View File
@@ -1,4 +1,5 @@
use sd_core::client::CoreClient;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum OutputFormat {
@@ -9,10 +10,12 @@ pub enum OutputFormat {
pub struct Context {
pub core: CoreClient,
pub format: OutputFormat,
pub data_dir: PathBuf,
pub socket_path: PathBuf,
}
impl Context {
pub fn new(core: CoreClient, format: OutputFormat) -> Self {
Self { core, format }
pub fn new(core: CoreClient, format: OutputFormat, data_dir: PathBuf, socket_path: PathBuf) -> Self {
Self { core, format, data_dir, socket_path }
}
}
+6 -4
View File
@@ -1,7 +1,7 @@
use anyhow::Result;
use clap::Subcommand;
use crate::context::{Context, OutputFormat};
use crate::context::Context;
#[derive(clap::Parser, Debug, Clone)]
pub struct FileCopyArgs {
@@ -31,11 +31,13 @@ pub struct FileCopyArgs {
impl FileCopyArgs {
pub fn to_input(&self) -> sd_core::ops::files::copy::input::FileCopyInput {
use sd_core::domain::addressing::{SdPath, SdPathBatch};
use sd_core::ops::files::copy::input::{CopyMethod, FileCopyInput};
let sources = self.sources.iter().map(|p| SdPath::local(p.clone())).collect::<Vec<_>>();
let destination = SdPath::local(self.destination.clone());
FileCopyInput {
library_id: None,
sources: self.sources.clone(),
destination: self.destination.clone(),
sources: SdPathBatch { paths: sources },
destination,
overwrite: self.overwrite,
verify_checksum: self.verify_checksum,
preserve_timestamps: self.preserve_timestamps,
+25 -3
View File
@@ -2,16 +2,38 @@ use anyhow::Result;
use clap::Subcommand;
use crate::context::{Context, OutputFormat};
use crate::util::output::{print_human_line, print_json};
use crate::util::output::print_json;
use sd_core::ops::libraries::create;
use sd_core::ops::libraries::session::set_current;
#[derive(Subcommand, Debug)]
pub enum LibraryCmd {
/// Create a new library
Create { name: String },
/// Switch to a different library
Switch { id: uuid::Uuid },
/// List libraries
List,
}
pub async fn run(ctx: &Context, cmd: LibraryCmd) -> Result<()> {
match cmd {
LibraryCmd::Create { name } => {
let input = create::LibraryCreateInput::new(name);
let bytes = ctx.core.action(&input).await?;
let out: create::LibraryCreateOutput = bincode::deserialize(&bytes)?;
println!("Created library {} with ID {} at {}", out.name, out.library_id, out.path.display());
}
LibraryCmd::Switch { id } => {
let input = set_current::SetCurrentLibraryInput { library_id: id };
let bytes = ctx.core.action(&input).await?;
let out: set_current::SetCurrentLibraryOutput = bincode::deserialize(&bytes)?;
if out.success {
println!("Switched to library {}", id);
} else {
println!("Failed to switch to library {}", id);
}
}
LibraryCmd::List => {
let libs: Vec<sd_core::ops::libraries::list::output::LibraryInfo> = ctx
.core
@@ -20,10 +42,10 @@ pub async fn run(ctx: &Context, cmd: LibraryCmd) -> Result<()> {
match ctx.format {
OutputFormat::Human => {
if libs.is_empty() {
print_human_line("No libraries found");
println!("No libraries found");
}
for l in libs {
print_human_line(&format!("- {} {}", l.id, l.path.display()));
println!("- {} {}", l.id, l.path.display());
}
}
OutputFormat::Json => print_json(&libs),
+20 -5
View File
@@ -16,8 +16,15 @@ pub enum LocationCmd {
pub async fn run(ctx: &Context, cmd: LocationCmd) -> Result<()> {
match cmd {
LocationCmd::Add { path, name, mode } => {
let out: sd_core::ops::locations::add::output::LocationAddOutput = ctx.core.action(&sd_core::ops::locations::add::action::LocationAddInput { path, name, mode: sd_core::ops::indexing::job::IndexMode::from(mode) }).await?;
println!("Added location {} -> {}", out.id, out.path.display());
let libs: Vec<sd_core::ops::libraries::list::output::LibraryInfo> = ctx
.core
.query(&sd_core::ops::libraries::list::query::ListLibrariesQuery::basic())
.await?;
let library_id = if libs.len() == 1 { libs[0].id } else { anyhow::bail!("Specify --library to add locations when multiple libraries exist") };
let result_bytes = ctx.core.action(&sd_core::ops::locations::add::action::LocationAddInput { library_id, path, name, mode: sd_core::ops::indexing::job::IndexMode::from(mode) }).await?;
let out: sd_core::ops::locations::add::output::LocationAddOutput = bincode::deserialize(&result_bytes)?;
println!("Added location {} -> {}", out.location_id, out.path.display());
}
LocationCmd::List => {
let libs: Vec<sd_core::ops::libraries::list::output::LibraryInfo> = ctx
@@ -29,11 +36,19 @@ pub async fn run(ctx: &Context, cmd: LocationCmd) -> Result<()> {
for loc in out.locations { println!("- {} {}", loc.id, loc.path.display()); }
}
LocationCmd::Remove { location_id } => {
let _out: sd_core::ops::locations::remove::output::LocationRemoveOutput = ctx.core.action(&sd_core::ops::locations::remove::action::LocationRemoveInput { location_id }).await?;
let libs: Vec<sd_core::ops::libraries::list::output::LibraryInfo> = ctx
.core
.query(&sd_core::ops::libraries::list::query::ListLibrariesQuery::basic())
.await?;
let library_id = if libs.len() == 1 { libs[0].id } else { anyhow::bail!("Specify --library to remove locations when multiple libraries exist") };
let result_bytes = ctx.core.action(&sd_core::ops::locations::remove::action::LocationRemoveInput { library_id, location_id }).await?;
let _out: sd_core::ops::locations::remove::output::LocationRemoveOutput = bincode::deserialize(&result_bytes)?;
println!("Removed location {}", location_id);
}
LocationCmd::Rescan { location_id, force: _ } => {
let _out: sd_core::ops::locations::rescan::output::LocationRescanOutput = ctx.core.action(&sd_core::ops::locations::rescan::action::LocationRescanInput { location_id }).await?;
LocationCmd::Rescan { location_id, force } => {
let result_bytes = ctx.core.action(&sd_core::ops::locations::rescan::action::LocationRescanInput { location_id, full_rescan: force }).await?;
let _out: sd_core::ops::locations::rescan::output::LocationRescanOutput = bincode::deserialize(&result_bytes)?;
println!("Rescan requested for {}", location_id);
}
}
+12 -17
View File
@@ -44,10 +44,7 @@ pub enum NetworkCmd {
Status,
/// List devices
Devices(NetworkDevicesArgs),
/// Start networking
Start,
/// Stop networking
Stop,
/// Pairing commands
#[command(subcommand)]
Pair(PairCmd),
@@ -86,17 +83,11 @@ pub async fn run(ctx: &Context, cmd: NetworkCmd) -> Result<()> {
OutputFormat::Json => print_json(&devices),
}
}
NetworkCmd::Start => {
let out: NetworkStartOutput = ctx.core.action(&NetworkStartInput {}).await?;
println!("Networking {}", if out.started { "started" } else { "already running" });
}
NetworkCmd::Stop => {
let out: NetworkStopOutput = ctx.core.action(&NetworkStopInput {}).await?;
println!("Networking {}", if out.stopped { "stopped" } else { "not running" });
}
NetworkCmd::Pair(pc) => match pc {
PairCmd::Generate { auto_accept } => {
let out: PairGenerateOutput = ctx.core.action(&PairGenerateInput { auto_accept }).await?;
let bytes = ctx.core.action(&PairGenerateInput { auto_accept }).await?;
let out: PairGenerateOutput = bincode::deserialize(&bytes)?;
match ctx.format {
OutputFormat::Human => {
println!("Pairing code: {}", out.code);
@@ -107,7 +98,8 @@ pub async fn run(ctx: &Context, cmd: NetworkCmd) -> Result<()> {
}
}
PairCmd::Join { code } => {
let out: PairJoinOutput = ctx.core.action(&PairJoinInput { code }).await?;
let bytes = ctx.core.action(&PairJoinInput { code }).await?;
let out: PairJoinOutput = bincode::deserialize(&bytes)?;
match ctx.format {
OutputFormat::Human => println!("Paired with {} ({})", out.device_name, out.paired_device_id),
OutputFormat::Json => print_json(&out),
@@ -124,18 +116,21 @@ pub async fn run(ctx: &Context, cmd: NetworkCmd) -> Result<()> {
}
}
PairCmd::Cancel { session_id } => {
let out: PairCancelOutput = ctx.core.action(&PairCancelInput { session_id }).await?;
let bytes = ctx.core.action(&PairCancelInput { session_id }).await?;
let out: PairCancelOutput = bincode::deserialize(&bytes)?;
println!("Cancelled: {}", out.cancelled);
}
},
NetworkCmd::Revoke { device_id } => {
let out: DeviceRevokeOutput = ctx.core.action(&DeviceRevokeInput { device_id }).await?;
let bytes = ctx.core.action(&DeviceRevokeInput { device_id }).await?;
let out: DeviceRevokeOutput = bincode::deserialize(&bytes)?;
println!("Revoked: {}", out.revoked);
}
NetworkCmd::Spacedrop(args) => {
use sd_core::domain::addressing::SdPath;
let paths = args.paths.iter().map(|s| SdPath::from_uri(s).unwrap_or_else(|_| SdPath::local(s))).collect::<Vec<_>>();
let out: SpacedropSendOutput = ctx.core.action(&SpacedropSendInput { device_id: args.device_id, paths, sender: args.sender }).await?;
let bytes = ctx.core.action(&SpacedropSendInput { device_id: args.device_id, paths, sender: args.sender }).await?;
let out: SpacedropSendOutput = bincode::deserialize(&bytes)?;
match ctx.format {
OutputFormat::Human => {
if let Some(j) = out.job_id { println!("Transfer job: {}", j); }
+33 -5
View File
@@ -39,6 +39,14 @@ struct Cli {
#[derive(Subcommand, Debug)]
enum Commands {
/// Start the Spacedrive daemon
Start {
/// Automatically start networking
#[arg(long)]
enable_networking: bool,
},
/// Stop the Spacedrive daemon
Stop,
/// Core info
Status,
/// Libraries operations
@@ -66,16 +74,36 @@ enum Commands {
async fn main() -> Result<()> {
let cli = Cli::parse();
let data_dir = cli.data_dir.unwrap_or(sd_core::config::default_data_dir()?);
let socket = if let Some(inst) = cli.instance {
let instance = cli.instance;
let socket_path = if let Some(inst) = &instance {
data_dir.join("daemon").join(format!("daemon-{}.sock", inst))
} else {
data_dir.join("daemon/daemon.sock")
};
let core = CoreClient::new(socket);
let ctx = Context::new(core, cli.format);
match cli.command {
Commands::Start { enable_networking } => {
println!("Starting daemon...");
sd_core::infra::daemon::bootstrap::start_default_server(socket_path, data_dir, enable_networking).await.map_err(|e| anyhow::anyhow!(e.to_string()))?;
}
Commands::Stop => {
println!("Stopping daemon...");
let core = CoreClient::new(socket_path.clone());
let _ = core.send_raw_request(&sd_core::infra::daemon::types::DaemonRequest::Shutdown).await.map_err(|e| anyhow::anyhow!(e.to_string()))?;
println!("Daemon stopped.");
}
_ => {
run_client_command(cli.command, cli.format, data_dir, socket_path).await?;
}
}
Ok(())
}
async fn run_client_command(command: Commands, format: OutputFormat, data_dir: std::path::PathBuf, socket_path: std::path::PathBuf) -> Result<()> {
let core = CoreClient::new(socket_path.clone());
let ctx = Context::new(core, format, data_dir, socket_path);
match command {
Commands::Status => {
let status: sd_core::ops::core::status::output::CoreStatus = ctx
.core
@@ -95,7 +123,7 @@ async fn main() -> Result<()> {
Commands::Location(cmd) => location::run(&ctx, cmd).await?,
Commands::Network(cmd) => network::run(&ctx, cmd).await?,
Commands::Job(cmd) => job::run(&ctx, cmd).await?,
_ => {} // Start and Stop are handled in main
}
Ok(())
}
+1
View File
@@ -0,0 +1 @@
pub mod output;
+3 -8
View File
@@ -1,10 +1,5 @@
use crate::context::OutputFormat;
use serde::Serialize;
pub fn print_human_line(line: &str) { println!("{}", line) }
pub fn print_json<T: serde::Serialize>(value: &T) {
println!("{}", serde_json::to_string_pretty(value).unwrap_or_else(|_| "{}".to_string()))
pub fn print_json<T: Serialize>(data: &T) {
println!("{}", serde_json::to_string_pretty(data).unwrap());
}
+4
View File
@@ -67,4 +67,8 @@ impl CoreClient {
Err(e) => Err(anyhow::anyhow!(e.to_string())),
}
}
pub async fn send_raw_request(&self, req: &DaemonRequest) -> Result<DaemonResponse> {
self.daemon.send(req).await.map_err(|e| anyhow::anyhow!(e.to_string()))
}
}
+5 -3
View File
@@ -2,10 +2,9 @@
//! Shared context providing access to core application components.
use crate::{
config::JobLoggingConfig, device::DeviceManager, infra::action::manager::ActionManager,
use crate::{config::JobLoggingConfig, device::DeviceManager, infra::action::manager::ActionManager,
infra::event::EventBus, crypto::library_key_manager::LibraryKeyManager, library::LibraryManager,
service::network::NetworkingService, volume::VolumeManager,
service::network::NetworkingService, volume::VolumeManager, infra::daemon::state::SessionStateService,
};
use std::{path::PathBuf, sync::Arc};
use tokio::sync::RwLock;
@@ -24,6 +23,7 @@ pub struct CoreContext {
// Job logging configuration
pub job_logging_config: Option<JobLoggingConfig>,
pub job_logs_dir: Option<PathBuf>,
pub session_state: Arc<SessionStateService>,
}
impl CoreContext {
@@ -34,6 +34,7 @@ impl CoreContext {
library_manager: Arc<LibraryManager>,
volume_manager: Arc<VolumeManager>,
library_key_manager: Arc<LibraryKeyManager>,
session_state: Arc<SessionStateService>,
) -> Self {
Self {
events,
@@ -45,6 +46,7 @@ impl CoreContext {
networking: Arc::new(RwLock::new(None)),
job_logging_config: None,
job_logs_dir: None,
session_state,
}
}
+3 -2
View File
@@ -9,9 +9,10 @@ use crate::infra::daemon::{
pub async fn start_default_server(
socket_path: PathBuf,
data_dir: PathBuf,
enable_networking: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let instances = Arc::new(CoreInstanceManager::new(data_dir.clone()));
let session = Arc::new(SessionStateService::new(data_dir));
let session = Arc::new(SessionStateService::new(data_dir.clone()));
let instances = Arc::new(CoreInstanceManager::new(data_dir.clone(), enable_networking, session.clone()));
let server = RpcServer::new(socket_path, instances, session);
server.start().await
}
+22 -5
View File
@@ -4,16 +4,24 @@ use std::sync::Arc;
use tokio::sync::RwLock;
use crate::Core;
use crate::infra::daemon::state::SessionStateService;
/// Manages lifecycle of Core instances (by name)
pub struct CoreInstanceManager {
instances: Arc<RwLock<HashMap<String, Arc<Core>>>>,
default_data_dir: PathBuf,
enable_networking: bool,
session_state: Arc<SessionStateService>,
}
impl CoreInstanceManager {
pub fn new(default_data_dir: PathBuf) -> Self {
Self { instances: Arc::new(RwLock::new(HashMap::new())), default_data_dir }
pub fn new(default_data_dir: PathBuf, enable_networking: bool, session_state: Arc<SessionStateService>) -> Self {
Self {
instances: Arc::new(RwLock::new(HashMap::new())),
default_data_dir,
enable_networking,
session_state,
}
}
/// Get or start the default instance
@@ -32,9 +40,18 @@ impl CoreInstanceManager {
}
let data_dir = data_dir.unwrap_or_else(|| self.default_data_dir.clone());
let core = Arc::new(Core::new_with_config(data_dir).await?);
self.instances.write().await.insert(name, core.clone());
Ok(core)
let core = Arc::new(Core::new_with_config(data_dir, self.session_state.clone()).await?);
if self.enable_networking {
let core_with_networking = Core::init_networking_shared(core.clone(), self.session_state.clone()).await?;
self.instances
.write()
.await
.insert(name, core_with_networking.clone());
Ok(core_with_networking)
} else {
self.instances.write().await.insert(name, core.clone());
Ok(core)
}
}
/// Shutdown a named instance
+14 -5
View File
@@ -47,13 +47,13 @@ impl RpcServer {
let req: Result<DaemonRequest, _> = serde_json::from_slice(&buf);
let resp = match req {
Ok(DaemonRequest::Ping) => DaemonResponse::Pong,
Ok(DaemonRequest::Action { method, payload }) => {
Ok(DaemonRequest::Action { ref method, ref payload }) => {
match instances.get_default().await {
Ok(core) => {
let session_snapshot = session.get().await;
match core
.execute_action_by_method(
&method,
method,
payload.clone(),
session_snapshot.clone(),
)
@@ -68,11 +68,11 @@ impl RpcServer {
}
}
}
Ok(DaemonRequest::Query { method, payload }) => {
Ok(DaemonRequest::Query { ref method, ref payload }) => {
match instances.get_default().await {
Ok(core) => {
// Pass-through for queries using opaque method string
match core.execute_query_by_method(&method, payload.clone()).await {
match core.execute_query_by_method(method, payload.clone()).await {
Ok(out) => DaemonResponse::Ok(out),
Err(e) => DaemonResponse::Error(e),
}
@@ -82,11 +82,20 @@ impl RpcServer {
}
}
}
Err(e) => DaemonResponse::Error(format!("Invalid request: {}", e)),
Ok(DaemonRequest::Shutdown) => {
// Handle shutdown request
DaemonResponse::Ok(Vec::new()) // Send an OK response
}
Err(ref e) => DaemonResponse::Error(format!("Invalid request: {}", e)), // Use ref e here
};
let _ = stream
.write_all(serde_json::to_string(&resp).unwrap().as_bytes())
.await;
if let Ok(ref req_inner) = req { // Use ref req_inner here
if let DaemonRequest::Shutdown = req_inner {
break Ok(()); // Break the loop to shut down the server
}
}
}
}
}
+1
View File
@@ -6,6 +6,7 @@ pub enum DaemonRequest {
Ping,
Action { method: String, payload: Vec<u8> },
Query { method: String, payload: Vec<u8> },
Shutdown,
}
#[derive(Debug, Serialize, Deserialize)]
+6 -7
View File
@@ -43,6 +43,7 @@ use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::{mpsc, RwLock};
use tracing::{error, info};
use crate::infra::daemon::state::SessionStateService;
/// Pending pairing request information
#[derive(Debug, Clone)]
@@ -140,14 +141,10 @@ pub struct Core {
}
impl Core {
/// Initialize a new Core instance with default data directory
pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
let data_dir = crate::config::default_data_dir()?;
Self::new_with_config(data_dir).await
}
/// Initialize a new Core instance with custom data directory
pub async fn new_with_config(data_dir: PathBuf) -> Result<Self, Box<dyn std::error::Error>> {
pub async fn new_with_config(data_dir: PathBuf, session_state: Arc<SessionStateService>) -> Result<Self, Box<dyn std::error::Error>> {
info!("Initializing Spacedrive Core at {:?}", data_dir);
// 1. Load or create app config
@@ -195,6 +192,7 @@ impl Core {
libraries.clone(),
volumes.clone(),
library_key_manager.clone(),
session_state.clone(), // Pass session_state here
);
// Set job logging configuration if enabled
@@ -398,12 +396,13 @@ impl Core {
/// Initialize networking from Arc<Core> - for daemon use
pub async fn init_networking_shared(
core: Arc<Core>,
session_state: Arc<SessionStateService>,
) -> Result<Arc<Core>, Box<dyn std::error::Error>> {
info!("Initializing networking for shared core...");
// Create a new Core with networking enabled
let mut new_core =
Core::new_with_config(core.config().read().await.data_dir.clone()).await?;
Core::new_with_config(core.config().read().await.data_dir.clone(), session_state).await?;
// Initialize networking on the new core
new_core.init_networking().await?;
@@ -22,8 +22,7 @@ impl crate::infra::action::CoreAction for SetCurrentLibraryAction {
self,
context: std::sync::Arc<crate::context::CoreContext>,
) -> Result<Self::Output, crate::infra::action::error::ActionError> {
// Persist in daemon session state if available (requires daemon to wire it),
// otherwise noop. For now, this sets nothing globally; placeholder success.
context.session_state.set_current_library(Some(self.input.library_id)).await.map_err(|e| crate::infra::action::error::ActionError::Internal(e.to_string()))?;
Ok(SetCurrentLibraryOutput { success: true })
}
+2 -3
View File
@@ -1,7 +1,7 @@
use super::output::{LocationInfo, LocationsListOutput};
use crate::{context::CoreContext, cqrs::Query};
use anyhow::Result;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use sea_orm::EntityTrait;
use std::sync::Arc;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@@ -17,12 +17,11 @@ impl Query for LocationsListQuery {
let library = context.library_manager.get_library(self.library_id).await.ok_or_else(|| anyhow::anyhow!("Library not found"))?;
let db = library.db().conn();
let rows = crate::infra::db::entities::location::Entity::find()
.filter(crate::infra::db::entities::location::Column::LibraryId.eq(self.library_id))
.all(db)
.await?;
let mut out = Vec::new();
for r in rows {
out.push(LocationInfo { id: r.uuid, path: std::path::PathBuf::from(r.path), name: r.name });
out.push(LocationInfo { id: r.uuid, path: std::path::PathBuf::from(r.name.clone().unwrap_or_default()), name: r.name.clone() });
}
Ok(LocationsListOutput { locations: out })
}
+42 -1
View File
@@ -3,10 +3,51 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::service::network::PairingState;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum SerializablePairingState {
Idle,
GeneratingCode,
Broadcasting,
Scanning,
WaitingForConnection,
Connecting,
Authenticating,
ExchangingKeys,
AwaitingConfirmation,
EstablishingSession,
ChallengeReceived,
ResponsePending,
ResponseSent,
Completed,
Failed { reason: String },
}
impl From<PairingState> for SerializablePairingState {
fn from(state: PairingState) -> Self {
match state {
PairingState::Idle => Self::Idle,
PairingState::GeneratingCode => Self::GeneratingCode,
PairingState::Broadcasting => Self::Broadcasting,
PairingState::Scanning => Self::Scanning,
PairingState::WaitingForConnection => Self::WaitingForConnection,
PairingState::Connecting => Self::Connecting,
PairingState::Authenticating => Self::Authenticating,
PairingState::ExchangingKeys => Self::ExchangingKeys,
PairingState::AwaitingConfirmation => Self::AwaitingConfirmation,
PairingState::EstablishingSession => Self::EstablishingSession,
PairingState::ChallengeReceived { .. } => Self::ChallengeReceived,
PairingState::ResponsePending { .. } => Self::ResponsePending,
PairingState::ResponseSent => Self::ResponseSent,
PairingState::Completed => Self::Completed,
PairingState::Failed { reason } => Self::Failed { reason },
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PairingSessionSummary {
pub id: Uuid,
pub state: PairingState,
pub state: SerializablePairingState,
pub remote_device_id: Option<Uuid>,
pub expires_at: Option<DateTime<Utc>>, // optional if available
}
+1 -1
View File
@@ -16,7 +16,7 @@ impl Query for PairStatusQuery {
for s in sessions.into_iter() {
sessions_out.push(PairingSessionSummary {
id: s.id,
state: s.state,
state: s.state.into(),
remote_device_id: s.remote_device_id,
expires_at: None,
});