diff --git a/README.md b/README.md index 30f8b57d8..085ef3f0b 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ From the workspace root: ```sh cargo run -p decodex --bin decodex -- --help +cargo run -p decodex --bin decodex -- app cargo run -p decodex --bin decodex -- probe stdio:// cargo run -p decodex --bin decodex -- project list cargo run -p decodex --bin decodex -- status @@ -327,6 +328,8 @@ default `127.0.0.1:8192` runtime. ```sh DECODEX_APP_SERVER_URL=http://127.0.0.1:57399 open -n target/decodex-app/Decodex.app +DECODEX_APP_SERVER_URL=http://127.0.0.1:57399 cargo run -p decodex --bin decodex -- app \ + --bundle target/decodex-app/Decodex.app --new ``` Use hidden `decodex serve --dev --listen-address ` only when @@ -336,8 +339,11 @@ Linear, dispatch work, or accept `--config`. Decodex App's normal fallback server is ordinary `decodex serve --listen-address 127.0.0.1:8192`; the CLI owns the default scheduler cadences. App launch connects to an existing live default listener instead of starting a duplicate server only when -`DECODEX_APP_SERVER_URL` is unset. For dashboard and App preview UI work, prefer the -single mock server above. +`DECODEX_APP_SERVER_URL` is unset. `decodex app` opens the installed Decodex App by +default and preserves the caller's environment, including any explicit +`DECODEX_APP_SERVER_URL` override. Use `--bundle ` and `--new` when +previewing a staged app. For dashboard and App preview UI work, prefer the single mock +server above. The dashboard semantics and local-vs-external state boundary live in `docs/reference/operator-control-plane.md`. diff --git a/apps/decodex/src/cli.rs b/apps/decodex/src/cli.rs index 8f25119fa..7b4c07ac5 100644 --- a/apps/decodex/src/cli.rs +++ b/apps/decodex/src/cli.rs @@ -68,6 +68,7 @@ pub(crate) struct Cli { impl Cli { pub(crate) fn run(&self) -> Result<()> { match &self.command { + Command::App(args) => args.run(), Command::Commit(args) => args.run(), Command::Land(args) => args.run(), Command::Run(args) => args.run(), @@ -122,6 +123,21 @@ impl ProjectConfigArgs { } } +#[derive(Debug, Args)] +struct AppCommand { + /// Open this Decodex app bundle instead of the installed `Decodex` app. + #[arg(long, value_name = "APP_BUNDLE")] + bundle: Option, + /// Ask LaunchServices to open a new app instance. + #[arg(short = 'n', long)] + new: bool, +} +impl AppCommand { + fn run(&self) -> Result<()> { + open_decodex_app(self.bundle.as_deref(), self.new) + } +} + #[derive(Debug, Args)] struct AccountCommand { #[command(subcommand)] @@ -1608,6 +1624,8 @@ impl From for AttemptDispatchMode { #[allow(clippy::large_enum_variant)] #[derive(Debug, Subcommand)] enum Command { + /// Open the native Decodex App. + App(AppCommand), /// Create a signed local commit with a `decodex/commit/1` message. Commit(CommitCommand), /// Land the current reviewed lane with a GitHub admin merge commit. @@ -1865,15 +1883,55 @@ fn styles() -> Styles { .placeholder(AnsiColor::Green.on_default()) } +#[cfg(any(target_os = "macos", test))] +fn decodex_app_open_args(bundle: Option<&Path>, new: bool) -> Vec { + let mut args = Vec::new(); + + if new { + args.push(std::ffi::OsString::from("-n")); + } + + if let Some(bundle) = bundle { + args.push(bundle.as_os_str().to_owned()); + } else { + args.push(std::ffi::OsString::from("-a")); + args.push(std::ffi::OsString::from("Decodex")); + } + + args +} + +#[cfg(target_os = "macos")] +fn open_decodex_app(bundle: Option<&Path>, new: bool) -> Result<()> { + let args = decodex_app_open_args(bundle, new); + let status = std::process::Command::new("/usr/bin/open") + .args(args) + .status() + .map_err(|error| eyre::eyre!("Failed to start `open` for Decodex App: {error}"))?; + + if !status.success() { + eyre::bail!("Failed to open Decodex App: `open` exited with {status}"); + } + + println!("Opened Decodex App."); + + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +fn open_decodex_app(_bundle: Option<&Path>, _new: bool) -> Result<()> { + eyre::bail!("`decodex app` is only supported on macOS"); +} + #[cfg(test)] mod tests { - use std::path::Path; + use std::{ffi::OsString, path::Path}; use clap::Parser; use crate::cli::{ - AccountCommand, AccountSubcommand, AccountUseCommand, AttemptCommand, Cli, Command, - CommitCommand, DiagnoseCommand, EvidenceCommand, IntakeCommand, IntakeGoalCommand, + AccountCommand, AccountSubcommand, AccountUseCommand, AppCommand, AttemptCommand, Cli, + Command, CommitCommand, DiagnoseCommand, EvidenceCommand, IntakeCommand, IntakeGoalCommand, IntakeIssuesCommand, IntakeSubcommand, LandCommand, LaneCommand, LaneInspectCommand, LaneInterruptCommand, LaneSteerCommand, LaneSubcommand, LegacyCloseoutRecoveryCommand, MergedCloseoutRecoveryCommand, ProbeCommand, ProjectCommand, ProjectConfigArgs, @@ -1889,6 +1947,47 @@ mod tests { StatusCommand, }; + #[test] + fn parses_app_command() { + let cli = Cli::parse_from(["decodex", "app"]); + + assert!(matches!(cli.command, Command::App(AppCommand { bundle: None, new: false }))); + } + + #[test] + fn parses_app_bundle_and_new_instance() { + let cli = Cli::parse_from([ + "decodex", + "app", + "--bundle", + "target/decodex-app/Decodex.app", + "--new", + ]); + + assert!(matches!( + cli.command, + Command::App(AppCommand { + bundle: Some(bundle), + new: true, + }) if bundle == Path::new("target/decodex-app/Decodex.app") + )); + } + + #[test] + fn builds_macos_open_arguments_for_decodex_app() { + assert_eq!( + super::decodex_app_open_args(None, false), + vec![OsString::from("-a"), OsString::from("Decodex")] + ); + assert_eq!( + super::decodex_app_open_args(Some(Path::new("target/decodex-app/Decodex.app")), true), + vec![ + OsString::from("-n"), + Path::new("target/decodex-app/Decodex.app").as_os_str().to_owned(), + ] + ); + } + #[test] fn parses_commit_with_authority_related_and_breaking() { let cli = Cli::parse_from([ diff --git a/docs/reference/operator-control-plane.md b/docs/reference/operator-control-plane.md index 94d0da513..e262c4172 100644 --- a/docs/reference/operator-control-plane.md +++ b/docs/reference/operator-control-plane.md @@ -63,6 +63,10 @@ for future dispatch, uses the CLI-owned default cadences, and serves the dashboa account APIs, `GET /api/operator-snapshot`, `POST /api/linear-scan`, `GET /api/lane/inspect`, and `POST /api/lane/interrupt` from the single local listener. +Use `decodex app` to open the installed macOS app from the CLI; use +`decodex app --bundle --new` for a staged app bundle. The launch preserves +the caller's environment, so `DECODEX_APP_SERVER_URL` remains an explicit App preview +override when set. `decodex serve` has two hardcoded scheduler cadences: