diff --git a/.github/workflows/linux-bundle.yml b/.github/workflows/linux-bundle.yml index cc6d6a2..30c7c9b 100644 --- a/.github/workflows/linux-bundle.yml +++ b/.github/workflows/linux-bundle.yml @@ -44,7 +44,7 @@ jobs: The AppImage and deb in `Stellr-Linux-x86_64-packages-UNSIGNED-NOT-FOR-RELEASE` are intentionally unsigned development artifacts and must not be published as an official release. SUMMARY - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: Stellr-Linux-x86_64-packages-UNSIGNED-NOT-FOR-RELEASE path: artifacts/linux-x86_64/* @@ -55,7 +55,7 @@ jobs: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v6 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: Stellr-Linux-x86_64-packages-UNSIGNED-NOT-FOR-RELEASE path: artifacts/linux-x86_64 @@ -82,7 +82,7 @@ jobs: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v6 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: Stellr-Linux-x86_64-packages-UNSIGNED-NOT-FOR-RELEASE path: artifacts/linux-x86_64 diff --git a/.github/workflows/macos-bundle.yml b/.github/workflows/macos-bundle.yml index cf09847..513b35d 100644 --- a/.github/workflows/macos-bundle.yml +++ b/.github/workflows/macos-bundle.yml @@ -34,7 +34,7 @@ jobs: `Stellr-macOS-universal-DMG-UNSIGNED-NOT-FOR-RELEASE` has no trusted Developer ID identity and must not be published as an official release. SUMMARY - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: Stellr-macOS-universal-DMG-UNSIGNED-NOT-FOR-RELEASE path: artifacts/macos-universal/*UNSIGNED-NOT-FOR-RELEASE* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9bd2933..46d0d7c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,17 +74,22 @@ jobs: - name: Build signed Windows NSIS package shell: powershell run: .\scripts\build-windows-nsis.ps1 -Channel Release -CertificateThumbprint $env:WINDOWS_CERTIFICATE_THUMBPRINT + - name: Prove the companion CLI from PowerShell and cmd.exe + shell: powershell + env: + GITHUB_TOKEN: ${{ github.token }} + run: .\scripts\smoke-windows-cli.ps1 -ExecutablePath target\release\stellr.exe - name: Prove the real application-process boundary shell: powershell env: GITHUB_TOKEN: ${{ github.token }} - run: .\scripts\smoke-windows-application-process.ps1 -ExecutablePath target\release\stellr.exe + run: .\scripts\smoke-windows-application-process.ps1 -ExecutablePath target\release\stellr-desktop.exe - name: Smoke signed install, WebView2 launch, and uninstall shell: powershell run: | $installer = Get-ChildItem artifacts\windows-x64\*.exe -File | Select-Object -ExpandProperty FullName -First 1 .\scripts\smoke-windows-nsis.ps1 -InstallerPath $installer - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: release-windows path: artifacts/windows-x64/* @@ -135,7 +140,7 @@ jobs: run: bash scripts/build-macos-universal.sh Release - name: Verify signature, slices, and signed launch run: bash scripts/inspect-macos-dmg.sh artifacts/macos-universal/*.dmg Release - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: release-macos path: artifacts/macos-universal/* @@ -164,7 +169,7 @@ jobs: - run: npm --prefix web ci - name: Build Linux release packages run: bash scripts/build-linux-packages.sh Release - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: release-linux path: artifacts/linux-x86_64/* @@ -175,7 +180,7 @@ jobs: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v6 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: release-linux path: artifacts/linux-x86_64 @@ -200,7 +205,7 @@ jobs: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v6 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: release-linux path: artifacts/linux-x86_64 @@ -214,7 +219,7 @@ jobs: contents: write issues: write steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: pattern: release-* merge-multiple: true diff --git a/.github/workflows/windows-bundle.yml b/.github/workflows/windows-bundle.yml index 83179a5..5753c67 100644 --- a/.github/workflows/windows-bundle.yml +++ b/.github/workflows/windows-bundle.yml @@ -27,11 +27,16 @@ jobs: - name: Build unsigned Windows x64 NSIS installer shell: powershell run: .\scripts\build-windows-nsis.ps1 -Channel Development + - name: Prove the companion CLI from PowerShell and cmd.exe + shell: powershell + env: + GITHUB_TOKEN: ${{ github.token }} + run: .\scripts\smoke-windows-cli.ps1 -ExecutablePath target\release\stellr.exe - name: Prove the real desktop application-process boundary shell: powershell env: GITHUB_TOKEN: ${{ github.token }} - run: .\scripts\smoke-windows-application-process.ps1 -ExecutablePath target\release\stellr.exe + run: .\scripts\smoke-windows-application-process.ps1 -ExecutablePath target\release\stellr-desktop.exe - name: Smoke install, WebView2 launch, and uninstall shell: powershell run: | @@ -45,7 +50,7 @@ jobs: `Stellr-Windows-x64-NSIS-UNSIGNED-NOT-FOR-RELEASE` is intentionally unsigned and must not be published as an official release. "@ | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: Stellr-Windows-x64-NSIS-UNSIGNED-NOT-FOR-RELEASE path: artifacts/windows-x64/*UNSIGNED-NOT-FOR-RELEASE* diff --git a/CHANGELOG.md b/CHANGELOG.md index b23b52b..e482023 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +- Upgraded all bundle artifact uploads to `actions/upload-artifact@v7` and + downloads to `actions/download-artifact@v8`, removing the obsolete + action-runtime warnings without suppressing them. +- Hardened Windows application startup smokes with a measured 90-second cold + start budget and captured native startup-stage diagnostics on failure. +- Split the Windows desktop and CLI entry points so installed shortcuts and + protocol launches open without a terminal while `stellr serve` retains + native PowerShell and Command Prompt behavior. - Fixed installed desktop startup so a no-argument launch opens the existing empty repository-selection shell instead of treating the installation directory as a Git repository. diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 3ee6e2c..764b636 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "stellr-app" version = "0.1.0" +default-run = "stellr-desktop" edition.workspace = true license.workspace = true repository.workspace = true @@ -9,6 +10,10 @@ repository.workspace = true name = "stellr" path = "src/main.rs" +[[bin]] +name = "stellr-desktop" +path = "src/desktop_main.rs" + [dependencies] async-trait.workspace = true axum = "0.8" diff --git a/crates/app/src/cli.rs b/crates/app/src/cli.rs index 83b2565..af61951 100644 --- a/crates/app/src/cli.rs +++ b/crates/app/src/cli.rs @@ -5,14 +5,26 @@ use std::path::PathBuf; use clap::{Args, Parser, Subcommand}; #[derive(Parser)] -#[command(name = "stellr")] +#[command(name = "stellr", version)] pub struct Cli { #[command(subcommand)] pub command: Option, - #[arg(value_name = "STELLR_LINK", hide = true)] + #[arg( + value_name = "STELLR_LINK", + hide = true, + value_parser = parse_protocol_target + )] pub protocol_target: Option, } +fn parse_protocol_target(value: &str) -> Result { + if value.starts_with("stellr://") { + Ok(value.to_owned()) + } else { + Err("expected a Stellr subcommand or stellr:// link".to_owned()) + } +} + #[derive(Subcommand)] pub enum Command { #[cfg(debug_assertions)] diff --git a/crates/app/src/desktop_main.rs b/crates/app/src/desktop_main.rs new file mode 100644 index 0000000..fef37f0 --- /dev/null +++ b/crates/app/src/desktop_main.rs @@ -0,0 +1,17 @@ +#![cfg_attr( + all(target_os = "windows", not(debug_assertions)), + windows_subsystem = "windows" +)] + +fn main() { + #[cfg(not(all(target_os = "windows", not(debug_assertions))))] + let result = stellr_app::entrypoints::run_cli(); + #[cfg(all(target_os = "windows", not(debug_assertions)))] + let result = stellr_app::entrypoints::run_desktop(); + + if let Err(_error) = result { + #[cfg(debug_assertions)] + eprintln!("{_error}"); + std::process::exit(1); + } +} diff --git a/crates/app/src/entrypoints.rs b/crates/app/src/entrypoints.rs new file mode 100644 index 0000000..f7c4f2a --- /dev/null +++ b/crates/app/src/entrypoints.rs @@ -0,0 +1,291 @@ +use std::{ffi::OsString, io::Write, path::PathBuf, sync::Arc, time::Duration}; + +use clap::Parser; +use stellr_github::{auth::resolve_token, cache::Cache, sync::GithubProvider}; +use stellr_server::spaces::SpaceStore; + +use crate::{ + cli::{Cli, Command, ServeArgs}, + desktop::{self, DesktopLaunch}, + runtime::{RuntimeOptions, SessionAuth, start}, +}; + +pub type DynError = Box; + +fn effective_launch_dir( + current: PathBuf, + appimage: Option, + original_working_dir: Option, +) -> PathBuf { + if appimage.is_some() + && let Some(original) = original_working_dir.map(PathBuf::from) + && original.is_absolute() + { + return original; + } + + current +} + +fn launch_current_dir() -> std::io::Result { + Ok(effective_launch_dir( + std::env::current_dir()?, + std::env::var_os("APPIMAGE"), + std::env::var_os("OWD"), + )) +} + +fn desktop_launch_from_cli(cli: Cli, cwd: PathBuf) -> Result { + match cli.command { + Some(Command::Open(args)) => Ok(DesktopLaunch { + cwd, + target: Some(args.target), + restore_route: false, + }), + None => Ok(DesktopLaunch { + cwd, + restore_route: cli.protocol_target.is_none(), + target: cli.protocol_target, + }), + Some(Command::Serve(_)) => Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "serve is available through stellr.exe", + ) + .into()), + #[cfg(debug_assertions)] + Some(Command::Acceptance(_)) => Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "acceptance is available through stellr.exe", + ) + .into()), + } +} + +pub fn desktop_launch_from(args: I, cwd: PathBuf) -> Result +where + I: IntoIterator, + T: Into + Clone, +{ + desktop_launch_from_cli(Cli::try_parse_from(args)?, cwd) +} + +pub fn run_desktop() -> Result<(), DynError> { + let launch = desktop_launch_from(std::env::args_os(), launch_current_dir()?)?; + desktop::run(launch).map_err(Into::into) +} + +pub fn run_cli() -> Result<(), DynError> { + let cli = Cli::parse(); + match cli { + #[cfg(debug_assertions)] + Cli { + command: Some(Command::Acceptance(args)), + .. + } => tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()? + .block_on(crate::acceptance::run(args.github_base, args.profile)), + Cli { + command: Some(Command::Serve(args)), + .. + } => tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()? + .block_on(serve(args)), + desktop_cli => { + let launch = desktop_launch_from_cli(desktop_cli, launch_current_dir()?)?; + desktop::run(launch).map_err(Into::into) + } + } +} + +async fn serve(args: ServeArgs) -> Result<(), DynError> { + let provider_token = resolve_token()?; + let provider = Arc::new(GithubProvider::new(provider_token)?); + let runtime = start( + RuntimeOptions { + address: args.addr, + session_auth: if args.no_token { + SessionAuth::Disabled + } else { + SessionAuth::Required + }, + issue: args.issue, + spaces_file: SpaceStore::default_file(), + cache_root: Cache::default_root(), + poll_interval: Duration::from_secs(30), + }, + provider, + ) + .await?; + println!("stellr cockpit: {}", runtime.cockpit_url()); + std::io::stdout().flush()?; + + runtime.wait().await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::{num::NonZeroU64, path::PathBuf}; + + use clap::Parser; + + use super::{desktop_launch_from, effective_launch_dir}; + use crate::cli::{Cli, Command}; + + #[test] + fn desktop_entry_accepts_bare_open_and_protocol_launches() { + let cwd = PathBuf::from(r"D:\Apps\Stellr"); + + let bare = desktop_launch_from(["stellr-desktop"], cwd.clone()).unwrap(); + assert!(bare.target.is_none()); + assert!(bare.restore_route); + + let open = desktop_launch_from(["stellr-desktop", "open", "teloverge/stellr"], cwd.clone()) + .unwrap(); + assert_eq!(open.target.as_deref(), Some("teloverge/stellr")); + assert!(!open.restore_route); + + let protocol = desktop_launch_from( + ["stellr-desktop", "stellr://space?repo=teloverge%2Fstellr"], + cwd, + ) + .unwrap(); + assert_eq!( + protocol.target.as_deref(), + Some("stellr://space?repo=teloverge%2Fstellr") + ); + } + + #[test] + fn desktop_entry_rejects_console_only_serve() { + let error = desktop_launch_from( + ["stellr-desktop", "serve"], + PathBuf::from(r"D:\Apps\Stellr"), + ) + .err() + .expect("the desktop entry must reject console-only serve"); + + assert!(error.to_string().contains("stellr.exe")); + } + + #[test] + fn bare_launch_selects_desktop_mode_without_a_repository_target() { + let launch = + desktop_launch_from(["stellr"], std::path::PathBuf::from(r"D:\Apps\Stellr")).unwrap(); + + assert!(launch.target.is_none()); + assert!(launch.restore_route); + } + + #[test] + fn serve_defaults_to_loopback_port_8787_with_session_auth() { + let parsed = Cli::try_parse_from(["stellr", "serve"]).unwrap(); + let Some(Command::Serve(args)) = parsed.command else { + panic!("serve should select the serve command") + }; + + assert_eq!(args.addr, "127.0.0.1:8787"); + assert!(!args.no_token); + assert_eq!(args.issue, None); + } + + #[test] + fn serve_accepts_a_custom_address_and_open_server_flag() { + let parsed = + Cli::try_parse_from(["stellr", "serve", "--addr", "127.0.0.1:0", "--no-token"]) + .unwrap(); + let Some(Command::Serve(args)) = parsed.command else { + panic!("serve should select the serve command") + }; + + assert_eq!(args.addr, "127.0.0.1:0"); + assert!(args.no_token); + } + + #[test] + fn serve_accepts_a_positive_conversation_issue() { + let parsed = + Cli::try_parse_from(["stellr", "serve", "--addr", "127.0.0.1:0", "--issue", "14"]) + .unwrap(); + let Some(Command::Serve(args)) = parsed.command else { + panic!("serve should select the serve command") + }; + + assert_eq!(args.issue.map(NonZeroU64::get), Some(14)); + assert!(Cli::try_parse_from(["stellr", "serve", "--issue", "0"]).is_err()); + } + + #[test] + fn open_accepts_one_path_url_slug_or_stellr_target() { + for target in [ + r"D:\dev\stellr", + "teloverge/stellr", + "https://github.com/teloverge/stellr/issues/62", + "stellr://space?repo=teloverge%2Fstellr&issue=62", + ] { + let parsed = Cli::try_parse_from(["stellr", "open", target]).unwrap(); + let Some(Command::Open(args)) = parsed.command else { + panic!("open should select the desktop open command") + }; + assert_eq!(args.target, target); + } + assert!(Cli::try_parse_from(["stellr", "open"]).is_err()); + assert!(Cli::try_parse_from(["stellr", "open", "one", "two"]).is_err()); + } + + #[test] + fn a_registered_stellr_protocol_link_is_accepted_as_the_hidden_root_target() { + let link = "stellr://space?repo=teloverge%2Fstellr&issue=62"; + let parsed = Cli::try_parse_from(["stellr", link]).unwrap(); + + assert!(parsed.command.is_none()); + assert_eq!(parsed.protocol_target.as_deref(), Some(link)); + } + + #[test] + fn an_arbitrary_root_argument_is_an_invalid_console_command() { + assert!(Cli::try_parse_from(["stellr", "not-a-command"]).is_err()); + } + + #[test] + fn appimage_launch_uses_the_callers_original_working_directory() { + let mounted = std::env::temp_dir().join("mounted-appimage").join("usr"); + let original = std::env::temp_dir().join("stellr-repository"); + + assert_eq!( + effective_launch_dir( + mounted, + Some("/tmp/Stellr.AppImage".into()), + Some(original.clone().into_os_string()), + ), + original + ); + } + + #[test] + fn unpackaged_launch_ignores_an_unpaired_original_working_directory() { + let current = std::env::temp_dir().join("stellr-repository"); + let unrelated = std::env::temp_dir().join("unrelated"); + + assert_eq!( + effective_launch_dir(current.clone(), None, Some(unrelated.into_os_string()),), + current + ); + } + + #[test] + fn appimage_launch_rejects_a_relative_original_working_directory() { + let mounted = std::env::temp_dir().join("mounted-appimage").join("usr"); + + assert_eq!( + effective_launch_dir( + mounted.clone(), + Some("/tmp/Stellr.AppImage".into()), + Some("relative-repository".into()), + ), + mounted + ); + } +} diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index fbba8bf..5bc4d29 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -3,7 +3,9 @@ #[cfg(debug_assertions)] pub mod acceptance; pub mod auth_activation; +pub mod cli; pub mod desktop; +pub mod entrypoints; pub mod route_state; pub mod runtime; pub mod target; diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 4632226..edcad3f 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -1,236 +1,6 @@ -mod cli; - -use std::{ffi::OsString, io::Write, path::PathBuf, sync::Arc, time::Duration}; - -use clap::Parser; -use stellr_app::runtime::{RuntimeOptions, SessionAuth, start}; -use stellr_github::{auth::resolve_token, cache::Cache, sync::GithubProvider}; -use stellr_server::spaces::SpaceStore; - -use crate::cli::{Cli, Command, ServeArgs}; - -type DynError = Box; - -fn effective_launch_dir( - current: PathBuf, - appimage: Option, - original_working_dir: Option, -) -> PathBuf { - if appimage.is_some() - && let Some(original) = original_working_dir.map(PathBuf::from) - && original.is_absolute() - { - return original; - } - - current -} - -fn launch_current_dir() -> std::io::Result { - Ok(effective_launch_dir( - std::env::current_dir()?, - std::env::var_os("APPIMAGE"), - std::env::var_os("OWD"), - )) -} - -fn default_desktop_launch( - cwd: PathBuf, - protocol_target: Option, -) -> stellr_app::desktop::DesktopLaunch { - let restore_route = protocol_target.is_none(); - stellr_app::desktop::DesktopLaunch { - cwd, - target: protocol_target, - restore_route, - } -} - fn main() { - if let Err(error) = run() { + if let Err(error) = stellr_app::entrypoints::run_cli() { eprintln!("{error}"); std::process::exit(1); } } - -fn run() -> Result<(), DynError> { - let cli = Cli::parse(); - match cli.command { - #[cfg(debug_assertions)] - Some(Command::Acceptance(args)) => tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build()? - .block_on(stellr_app::acceptance::run(args.github_base, args.profile)), - Some(Command::Serve(args)) => tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build()? - .block_on(serve(args)), - Some(Command::Open(args)) => { - let cwd = launch_current_dir()?; - stellr_app::desktop::run(stellr_app::desktop::DesktopLaunch { - cwd, - target: Some(args.target), - restore_route: false, - }) - .map_err(Into::into) - } - None => { - let cwd = launch_current_dir()?; - stellr_app::desktop::run(default_desktop_launch(cwd, cli.protocol_target)) - .map_err(Into::into) - } - } -} - -async fn serve(args: ServeArgs) -> Result<(), DynError> { - let provider_token = resolve_token()?; - let provider = Arc::new(GithubProvider::new(provider_token)?); - let runtime = start( - RuntimeOptions { - address: args.addr, - session_auth: if args.no_token { - SessionAuth::Disabled - } else { - SessionAuth::Required - }, - issue: args.issue, - spaces_file: SpaceStore::default_file(), - cache_root: Cache::default_root(), - poll_interval: Duration::from_secs(30), - }, - provider, - ) - .await?; - println!("stellr cockpit: {}", runtime.cockpit_url()); - std::io::stdout().flush()?; - - runtime.wait().await?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::num::NonZeroU64; - - use clap::Parser; - - use crate::cli::{Cli, Command}; - - #[test] - fn bare_launch_selects_desktop_mode_without_a_repository_target() { - let parsed = Cli::try_parse_from(["stellr"]).unwrap(); - - assert!(parsed.command.is_none()); - let launch = super::default_desktop_launch( - std::path::PathBuf::from(r"D:\Apps\Stellr"), - parsed.protocol_target, - ); - assert!(launch.target.is_none()); - assert!(launch.restore_route); - } - - #[test] - fn serve_defaults_to_loopback_port_8787_with_session_auth() { - let parsed = Cli::try_parse_from(["stellr", "serve"]).unwrap(); - let Some(Command::Serve(args)) = parsed.command else { - panic!("serve should select the serve command") - }; - - assert_eq!(args.addr, "127.0.0.1:8787"); - assert!(!args.no_token); - assert_eq!(args.issue, None); - } - - #[test] - fn serve_accepts_a_custom_address_and_open_server_flag() { - let parsed = - Cli::try_parse_from(["stellr", "serve", "--addr", "127.0.0.1:0", "--no-token"]) - .unwrap(); - let Some(Command::Serve(args)) = parsed.command else { - panic!("serve should select the serve command") - }; - - assert_eq!(args.addr, "127.0.0.1:0"); - assert!(args.no_token); - } - - #[test] - fn serve_accepts_a_positive_conversation_issue() { - let parsed = - Cli::try_parse_from(["stellr", "serve", "--addr", "127.0.0.1:0", "--issue", "14"]) - .unwrap(); - let Some(Command::Serve(args)) = parsed.command else { - panic!("serve should select the serve command") - }; - - assert_eq!(args.issue.map(NonZeroU64::get), Some(14)); - assert!(Cli::try_parse_from(["stellr", "serve", "--issue", "0"]).is_err()); - } - - #[test] - fn open_accepts_one_path_url_slug_or_stellr_target() { - for target in [ - r"D:\dev\stellr", - "teloverge/stellr", - "https://github.com/teloverge/stellr/issues/62", - "stellr://space?repo=teloverge%2Fstellr&issue=62", - ] { - let parsed = Cli::try_parse_from(["stellr", "open", target]).unwrap(); - let Some(Command::Open(args)) = parsed.command else { - panic!("open should select the desktop open command") - }; - assert_eq!(args.target, target); - } - assert!(Cli::try_parse_from(["stellr", "open"]).is_err()); - assert!(Cli::try_parse_from(["stellr", "open", "one", "two"]).is_err()); - } - - #[test] - fn a_registered_stellr_protocol_link_is_accepted_as_the_hidden_root_target() { - let link = "stellr://space?repo=teloverge%2Fstellr&issue=62"; - let parsed = Cli::try_parse_from(["stellr", link]).unwrap(); - - assert!(parsed.command.is_none()); - assert_eq!(parsed.protocol_target.as_deref(), Some(link)); - } - - #[test] - fn appimage_launch_uses_the_callers_original_working_directory() { - let mounted = std::env::temp_dir().join("mounted-appimage").join("usr"); - let original = std::env::temp_dir().join("stellr-repository"); - - assert_eq!( - super::effective_launch_dir( - mounted, - Some("/tmp/Stellr.AppImage".into()), - Some(original.clone().into_os_string()), - ), - original - ); - } - - #[test] - fn unpackaged_launch_ignores_an_unpaired_original_working_directory() { - let current = std::env::temp_dir().join("stellr-repository"); - let unrelated = std::env::temp_dir().join("unrelated"); - - assert_eq!( - super::effective_launch_dir(current.clone(), None, Some(unrelated.into_os_string()),), - current - ); - } - - #[test] - fn appimage_launch_rejects_a_relative_original_working_directory() { - let mounted = std::env::temp_dir().join("mounted-appimage").join("usr"); - - assert_eq!( - super::effective_launch_dir( - mounted.clone(), - Some("/tmp/Stellr.AppImage".into()), - Some("relative-repository".into()), - ), - mounted - ); - } -} diff --git a/crates/app/tauri.conf.json b/crates/app/tauri.conf.json index 1f8a45f..cab1f32 100644 --- a/crates/app/tauri.conf.json +++ b/crates/app/tauri.conf.json @@ -1,6 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Stellr", + "mainBinaryName": "stellr", "version": "0.1.0", "identifier": "com.teloverge.stellr", "build": { diff --git a/crates/app/tauri.windows.conf.json b/crates/app/tauri.windows.conf.json new file mode 100644 index 0000000..a9997d2 --- /dev/null +++ b/crates/app/tauri.windows.conf.json @@ -0,0 +1,3 @@ +{ + "mainBinaryName": "stellr-desktop" +} diff --git a/docs/superpowers/plans/2026-08-04-windows-launch-and-ci-hardening.md b/docs/superpowers/plans/2026-08-04-windows-launch-and-ci-hardening.md new file mode 100644 index 0000000..2f31886 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-windows-launch-and-ci-hardening.md @@ -0,0 +1,546 @@ +# Windows Launch and CI Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship console-free installed Windows desktop launches, preserve Stellr's native CLI behavior, make Windows startup failures diagnostic, and remove the obsolete artifact-action runtime warning through supported upgrades. + +**Architecture:** Split the Windows package into a GUI-subsystem `stellr-desktop.exe` entry point and the existing console-subsystem `stellr.exe` CLI, both backed by one shared Rust launch assembly. Configure Tauri's Windows bundle to use the desktop binary as its main application and include the CLI as a companion binary. Harden both real Windows startup smokes around a shared 90-second deadline and diagnostic-capture contract, then upgrade all six upload actions together. + +**Tech Stack:** Rust 2024, Clap 4, Tauri 2.11, native Windows PE/Win32 process semantics, PowerShell 5.1+, NSIS, GitHub Actions, Node.js 24. + +## Global Constraints + +- Use native Windows 11 PowerShell, `cargo.exe`, `rustc.exe`, and `npm.cmd`; do not use WSL or Linux toolchains for local implementation or validation. +- `stellr-desktop.exe` is the packaged Windows GUI entry point; `stellr.exe` remains the console CLI. +- Release desktop PE subsystem must be `IMAGE_SUBSYSTEM_WINDOWS_GUI` (2); release CLI PE subsystem must be `IMAGE_SUBSYSTEM_WINDOWS_CUI` (3). +- Debug builds of both entry points retain console-subsystem diagnostics. +- Preserve `stellr serve`, help, version, invalid-command, bare, `open`, and protocol behavior at their approved entry points. +- Both Windows startup smokes default to a measured 90-second deadline and remain fail-closed. +- Upgrade the six `actions/upload-artifact` uses to `v7`; do not suppress, filter, or waive runtime warnings. +- Keep release notes append-only and newest-first; add pending work only under `Unreleased`. +- Implement issues #75, #76, and #77 sequentially on one branch and publish one pull request after combined verification and review. + +--- + +### Task 1: Share desktop launch assembly across two entry points (Issue #75) + +**Files:** +- Create: `crates/app/src/entrypoints.rs` +- Create: `crates/app/src/desktop_main.rs` +- Modify: `crates/app/src/cli.rs` +- Modify: `crates/app/src/lib.rs` +- Modify: `crates/app/src/main.rs` +- Modify: `crates/app/Cargo.toml` + +**Interfaces:** +- Consumes: `Cli`, `Command`, `DesktopLaunch`, `stellr_app::desktop::run`, shared runtime startup, and process arguments. +- Produces: `entrypoints::run_cli() -> Result<(), DynError>`, `entrypoints::run_desktop() -> Result<(), DynError>`, and `entrypoints::desktop_launch_from(args, cwd) -> Result`. + +- [ ] **Step 1: Write failing shared-launch tests** + +Move the existing launch-intent tests out of the binary-only module and add public-seam coverage for the desktop entry point's accepted argument shapes: + +```rust +#[test] +fn desktop_entry_accepts_bare_open_and_protocol_launches() { + let cwd = PathBuf::from(r"D:\Apps\Stellr"); + + let bare = desktop_launch_from(["stellr-desktop"], cwd.clone()).unwrap(); + assert!(bare.target.is_none()); + assert!(bare.restore_route); + + let open = desktop_launch_from( + ["stellr-desktop", "open", "teloverge/stellr"], + cwd.clone(), + ) + .unwrap(); + assert_eq!(open.target.as_deref(), Some("teloverge/stellr")); + assert!(!open.restore_route); + + let protocol = desktop_launch_from( + ["stellr-desktop", "stellr://space?repo=teloverge%2Fstellr"], + cwd, + ) + .unwrap(); + assert_eq!( + protocol.target.as_deref(), + Some("stellr://space?repo=teloverge%2Fstellr") + ); +} + +#[test] +fn desktop_entry_rejects_console_only_serve() { + let error = desktop_launch_from( + ["stellr-desktop", "serve"], + PathBuf::from(r"D:\Apps\Stellr"), + ) + .unwrap_err(); + assert!(error.to_string().contains("stellr.exe")); +} +``` + +- [ ] **Step 2: Run the focused tests and verify red** + +Run: + +```powershell +cargo.exe test -p stellr-app entrypoints::tests -- --nocapture +``` + +Expected: compilation fails because the shared `entrypoints` module and `desktop_launch_from` interface do not exist. + +- [ ] **Step 3: Move command and launch dispatch behind the shared interface** + +Export the CLI module from the library, enable Clap's version output, and implement the shared launch adapter: + +```rust +pub type DynError = Box; + +pub fn desktop_launch_from(args: I, cwd: PathBuf) -> Result +where + I: IntoIterator, + T: Into + Clone, +{ + let cli = Cli::try_parse_from(args)?; + desktop_launch_from_cli(cli, cwd) +} + +fn desktop_launch_from_cli(cli: Cli, cwd: PathBuf) -> Result { + match cli.command { + Some(Command::Open(args)) => Ok(DesktopLaunch { + cwd, + target: Some(args.target), + restore_route: false, + }), + None => Ok(DesktopLaunch { + cwd, + restore_route: cli.protocol_target.is_none(), + target: cli.protocol_target, + }), + Some(Command::Serve(_)) => Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "serve is available through stellr.exe", + ) + .into()), + #[cfg(debug_assertions)] + Some(Command::Acceptance(_)) => Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "acceptance is available through stellr.exe", + ) + .into()), + } +} +``` + +Move the existing runtime/serve dispatch into `run_cli`; make `run_desktop` call `desktop_launch_from(std::env::args_os(), launch_current_dir()?)` and then the existing native desktop runner. Reduce `main.rs` to console error reporting around `run_cli`. + +Add the second binary target: + +```toml +[[bin]] +name = "stellr-desktop" +path = "src/desktop_main.rs" +``` + +The desktop adapter declares the release-only Windows GUI subsystem and exits non-zero after the existing native startup error path: + +```rust +#![cfg_attr( + all(target_os = "windows", not(debug_assertions)), + windows_subsystem = "windows" +)] + +fn main() { + if stellr_app::entrypoints::run_desktop().is_err() { + std::process::exit(1); + } +} +``` + +- [ ] **Step 4: Run the entry-point and CLI tests** + +Run: + +```powershell +cargo.exe test -p stellr-app entrypoints::tests -- --nocapture +cargo.exe test -p stellr-app --test serve_test -- --nocapture +cargo.exe run -p stellr-app --bin stellr -- --version +cargo.exe clippy -p stellr-app --all-targets --locked -- -D warnings +``` + +Expected: focused tests pass, the CLI prints version `0.1.0`, and Clippy exits 0 without warnings. + +- [ ] **Step 5: Commit the shared-entry slice** + +```powershell +git add crates/app/Cargo.toml crates/app/src/cli.rs crates/app/src/desktop_main.rs crates/app/src/entrypoints.rs crates/app/src/lib.rs crates/app/src/main.rs +git commit -m "feat: split Windows desktop and CLI entry points" +``` + +### Task 2: Package and prove both Windows executables (Issue #75) + +**Files:** +- Create: `crates/app/tauri.windows.conf.json` +- Create: `scripts/assert-windows-pe-subsystem.ps1` +- Modify: `crates/app/tauri.conf.json` +- Modify: `scripts/build-windows-nsis.ps1` +- Modify: `scripts/smoke-windows-nsis.ps1` +- Modify: `scripts/tests/windows-packaging.tests.ps1` +- Modify: `.github/workflows/windows-bundle.yml` +- Modify: `.github/workflows/release.yml` +- Modify: `CHANGELOG.md` + +**Interfaces:** +- Consumes: Cargo binary targets `stellr` and `stellr-desktop`, Tauri `mainBinaryName`, Tauri Windows `bundle.externalBin`, PE optional-header subsystem field, and NSIS uninstall metadata. +- Produces: a Windows installer whose application target is `stellr-desktop.exe` and whose install directory also contains `stellr.exe`; `assert-windows-pe-subsystem.ps1 -ExecutablePath -ExpectedSubsystem WindowsGui|WindowsCui`. + +- [ ] **Step 1: Add failing packaging-contract assertions** + +Extend the Windows packaging contract to require the platform configuration, dual PE gate, desktop workflow path, and companion CLI installation: + +```powershell +$windowsConfig = Get-Content (Join-Path $repo 'crates\app\tauri.windows.conf.json') -Raw | ConvertFrom-Json +Assert-True ($windowsConfig.mainBinaryName -eq 'stellr-desktop') ` + 'Windows packages must use the console-free desktop entry point.' +Assert-True ($workflow.Contains('target\release\stellr-desktop.exe')) ` + 'The application-process smoke must launch the desktop entry point.' +Assert-True ($buildContract.Contains('binaries/stellr')) ` + 'The Windows package must include the companion Stellr CLI.' +Assert-True ($buildContract.Contains('WindowsGui')) 'The build must verify the desktop PE subsystem.' +Assert-True ($buildContract.Contains('WindowsCui')) 'The build must verify the CLI PE subsystem.' +``` + +- [ ] **Step 2: Run the Windows packaging contract and verify red** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/tests/windows-packaging.tests.ps1 +``` + +Expected: FAIL because the Windows-specific Tauri configuration and PE assertion script do not exist. + +- [ ] **Step 3: Implement behavioral PE inspection** + +Create a native PowerShell assertion that reads the real PE header. Validate the DOS `MZ` signature, PE signature, optional-header magic, and subsystem value at offset `0x44` within the optional header: + +```powershell +$peOffset = [BitConverter]::ToInt32($bytes, 0x3c) +$optionalHeaderOffset = $peOffset + 24 +$magic = [BitConverter]::ToUInt16($bytes, $optionalHeaderOffset) +if ($magic -notin @(0x10b, 0x20b)) { throw "Unsupported PE optional-header magic: 0x$($magic.ToString('x4'))" } +$actual = [BitConverter]::ToUInt16($bytes, $optionalHeaderOffset + 0x44) +$expected = if ($ExpectedSubsystem -eq 'WindowsGui') { 2 } else { 3 } +if ($actual -ne $expected) { throw "Expected $ExpectedSubsystem ($expected), found subsystem $actual in $ExecutablePath." } +``` + +Exercise the parser in the packaging contract against native `%WINDIR%\explorer.exe` as GUI and `%ComSpec%` as CUI before relying on it for Stellr. + +- [ ] **Step 4: Configure and build the dual-entry Windows package** + +Set base `mainBinaryName` to `stellr` so Linux and macOS retain their current main binary. Add a Windows override: + +```json +{ + "mainBinaryName": "stellr-desktop" +} +``` + +Set the Cargo package `default-run` to `stellr-desktop` so Tauri can select the application target when two binaries exist. Route that target through the desktop-only dispatcher solely for Windows release builds; debug Windows and every non-Windows build retain the full CLI dispatcher so existing `cargo run -- serve` and packaged Linux/macOS behavior remain compatible. + +Before invoking Tauri on Windows, build `stellr.exe`, obtain the native host tuple from `rustc --print host-tuple`, and copy the CLI to the Tauri sidecar name `crates/app/binaries/stellr-.exe`. Pass `bundle.externalBin = ["binaries/stellr"]` through a temporary Tauri CLI config only during packaging, because a static sidecar declaration would make ordinary Cargo tests require a generated release binary. Remove only that generated copy and temporary config in the build script's `finally` block. After Tauri builds the desktop binary, assert: + +```powershell +& $peAssertion -ExecutablePath (Join-Path $repo 'target\release\stellr-desktop.exe') -ExpectedSubsystem WindowsGui +& $peAssertion -ExecutablePath (Join-Path $repo 'target\release\stellr.exe') -ExpectedSubsystem WindowsCui +``` + +Point the Windows workflow application-process smoke at `target\release\stellr-desktop.exe`. Make the NSIS smoke fall back to `stellr-desktop.exe`, require adjacent `stellr.exe`, and preserve the current DisplayIcon-first discovery so it proves the installed shortcut target. + +- [ ] **Step 5: Run focused packaging and release-build proof** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/tests/windows-packaging.tests.ps1 +npm.cmd --prefix web ci +powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/build-windows-nsis.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/assert-windows-pe-subsystem.ps1 -ExecutablePath target\release\stellr-desktop.exe -ExpectedSubsystem WindowsGui +powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/assert-windows-pe-subsystem.ps1 -ExecutablePath target\release\stellr.exe -ExpectedSubsystem WindowsCui +``` + +Expected: packaging contract passes, the unsigned NSIS installer builds, and both real PE assertions pass. + +- [ ] **Step 6: Prove native CLI waiting and output** + +Run the release CLI directly through both native shells: + +```powershell +& .\target\release\stellr.exe --version +cmd.exe /d /c ".\target\release\stellr.exe --version && exit /b 0" +``` + +Expected: both print `stellr 0.1.0`, wait for completion, and exit 0. Start `serve` on an ephemeral loopback address with controlled test credentials through the existing application-process seam, observe `stellr cockpit:`, then terminate it through the existing bounded process harness. + +- [ ] **Step 7: Add the newest Unreleased entry and commit Issue #75** + +Prepend this pending release-note entry under `## Unreleased` without changing shipped sections: + +```markdown +- Split the Windows desktop and CLI entry points so installed shortcuts and + protocol launches open without a terminal while `stellr serve` retains + native PowerShell and Command Prompt behavior. +``` + +Then commit: + +```powershell +git add .github/workflows/windows-bundle.yml .github/workflows/release.yml CHANGELOG.md crates/app/tauri.conf.json crates/app/tauri.windows.conf.json scripts/assert-windows-pe-subsystem.ps1 scripts/build-windows-nsis.ps1 scripts/smoke-windows-nsis.ps1 scripts/tests/windows-packaging.tests.ps1 +git commit -m "build: package console-free Windows desktop entry" +``` + +### Task 3: Make Windows startup failures deadline-bound and diagnostic (Issue #76) + +**Files:** +- Modify: `scripts/smoke-windows-application-process.ps1` +- Modify: `scripts/smoke-windows-nsis.ps1` +- Modify: `scripts/tests/release-boundary.tests.ps1` +- Modify: `scripts/tests/windows-packaging.tests.ps1` +- Modify: `CHANGELOG.md` + +**Interfaces:** +- Consumes: child process environment, `STELLR_STARTUP_DIAGNOSTICS=1`, process standard output/error, `MainWindowHandle`, and `MainWindowTitle`. +- Produces: `StartupTimeoutSeconds` defaulting to 90, deadline-based window waits, per-launch logs, and failure messages containing exit state plus the last startup marker. + +- [ ] **Step 1: Add failing smoke-contract assertions** + +For both smoke scripts require these observable contract tokens: + +```powershell +Assert-True ($smoke.Contains('[int]$StartupTimeoutSeconds = 90')) ` + 'The startup smoke must expose the approved 90-second budget.' +Assert-True ($smoke.Contains('[Diagnostics.Stopwatch]::StartNew()')) ` + 'The startup smoke must measure a deadline instead of counting attempts.' +Assert-True ($smoke.Contains('STELLR_STARTUP_DIAGNOSTICS')) ` + 'The startup smoke must enable native stage diagnostics for the child.' +Assert-True ($smoke.Contains('RedirectStandardError')) ` + 'The startup smoke must capture startup diagnostics.' +Assert-True ($smoke.Contains('STELLR_DESKTOP_STARTUP_STAGE')) ` + 'The startup failure must expose the last native startup stage.' +``` + +- [ ] **Step 2: Run both contract tests and verify red** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/tests/release-boundary.tests.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/tests/windows-packaging.tests.ps1 +``` + +Expected: both fail on the missing named timeout and diagnostic capture. + +- [ ] **Step 3: Implement child-scoped diagnostics and deadline waits** + +Add the named parameter to each smoke. For every desktop launch, allocate unique output/error paths below `$env:RUNNER_TEMP` (falling back to `[IO.Path]::GetTempPath()`), save the previous diagnostic environment value, set it to `1` only while `Start-Process` inherits the environment, and restore or remove it in `finally`. + +Start each process with: + +```powershell +$process = Start-Process -FilePath $executable ` + -ArgumentList $Arguments ` + -WorkingDirectory $workingDirectory ` + -RedirectStandardOutput $stdoutPath ` + -RedirectStandardError $stderrPath ` + -PassThru +``` + +Replace the 60-attempt startup loops with: + +```powershell +$startup = [Diagnostics.Stopwatch]::StartNew() +while ($startup.Elapsed.TotalSeconds -lt $StartupTimeoutSeconds) { + Start-Sleep -Milliseconds 500 + $process.Refresh() + if ($process.HasExited) { throw (New-StellrStartupFailure $process $stdoutPath $stderrPath) } + if ($process.MainWindowTitle -eq 'Stellr' -and $process.MainWindowHandle -ne 0) { return $process } +} +throw (New-StellrStartupFailure $process $stdoutPath $stderrPath) +``` + +`New-StellrStartupFailure` reads both logs without deleting them, extracts the final `STELLR_DESKTOP_STARTUP_STAGE=` or `STELLR_DESKTOP_STARTUP_ERROR=` line, and includes it with process id/exit state in the thrown message. The timeout path remains non-zero. + +- [ ] **Step 4: Run focused contracts and PowerShell parsing** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/tests/release-boundary.tests.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/tests/windows-packaging.tests.ps1 +$null = [scriptblock]::Create((Get-Content scripts/smoke-windows-application-process.ps1 -Raw)) +$null = [scriptblock]::Create((Get-Content scripts/smoke-windows-nsis.ps1 -Raw)) +``` + +Expected: contract markers print, and both scripts parse without errors. + +- [ ] **Step 5: Add the newest Unreleased entry and commit Issue #76** + +Prepend this entry under `Unreleased`: + +```markdown +- Hardened Windows application startup smokes with a measured 90-second cold + start budget and captured native startup-stage diagnostics on failure. +``` + +Then commit: + +```powershell +git add CHANGELOG.md scripts/smoke-windows-application-process.ps1 scripts/smoke-windows-nsis.ps1 scripts/tests/release-boundary.tests.ps1 scripts/tests/windows-packaging.tests.ps1 +git commit -m "test: diagnose Windows startup smoke failures" +``` + +### Task 4: Upgrade all bundle artifact uploads (Issue #77) + +**Files:** +- Modify: `.github/workflows/linux-bundle.yml` +- Modify: `.github/workflows/macos-bundle.yml` +- Modify: `.github/workflows/windows-bundle.yml` +- Modify: `.github/workflows/release.yml` +- Modify: `scripts/tests/release-boundary.tests.ps1` +- Modify: `CHANGELOG.md` + +**Interfaces:** +- Consumes: the six existing artifact names, paths, and `if-no-files-found` contracts. +- Produces: exactly six `actions/upload-artifact@v7` upload steps and zero obsolete upload `@v4` steps. + +- [ ] **Step 1: Add a failing exact-count workflow contract** + +Read every workflow as one string and assert exact upload-version counts: + +```powershell +$workflowText = (Get-ChildItem (Join-Path $repo '.github\workflows') -File | + ForEach-Object { Get-Content $_.FullName -Raw }) -join "`n" +$v7Uploads = [regex]::Matches($workflowText, 'actions/upload-artifact@v7').Count +$v4Uploads = [regex]::Matches($workflowText, 'actions/upload-artifact@v4').Count +Assert-True ($v7Uploads -eq 6) "Expected six v7 artifact uploads; found $v7Uploads." +Assert-True ($v4Uploads -eq 0) "Obsolete v4 artifact uploads remain: $v4Uploads." +``` + +- [ ] **Step 2: Run the release-boundary contract and verify red** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/tests/release-boundary.tests.ps1 +``` + +Expected: FAIL with zero v7 uploads and six remaining v4 uploads. + +- [ ] **Step 3: Upgrade the six upload sites without changing inputs** + +Change only `actions/upload-artifact@v4` to `actions/upload-artifact@v7` in the Linux, macOS, Windows, and three release jobs. Preserve every `with.name`, `with.path`, and `if-no-files-found` value. Do not add warning filters or `continue-on-error`. + +- [ ] **Step 4: Run workflow contracts and inspect the diff** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/tests/release-boundary.tests.ps1 +rg -n "actions/upload-artifact@" .github/workflows +git diff -- .github/workflows +``` + +Expected: exactly six v7 lines appear, no v4 upload line appears, and the diff changes no artifact input. + +- [ ] **Step 5: Add the newest Unreleased entry and commit Issue #77** + +Prepend this entry under `Unreleased`: + +```markdown +- Upgraded all bundle artifact uploads to `actions/upload-artifact@v7`, removing + the obsolete action-runtime warning without suppressing it. +``` + +Then commit: + +```powershell +git add .github/workflows/linux-bundle.yml .github/workflows/macos-bundle.yml .github/workflows/windows-bundle.yml .github/workflows/release.yml CHANGELOG.md scripts/tests/release-boundary.tests.ps1 +git commit -m "ci: upgrade artifact uploads to v7" +``` + +### Task 5: Verify, review, and publish one combined pull request + +**Files:** +- Modify only files required to resolve verified failures or actionable review findings. + +**Interfaces:** +- Consumes: the completed #75, #76, and #77 commits. +- Produces: one reviewed branch and one pull request that closes all three issues after required GitHub checks pass. + +- [ ] **Step 1: Run the complete native validation matrix** + +Run: + +```powershell +Get-ChildItem scripts\tests -Filter *.tests.ps1 | ForEach-Object { & $_.FullName } +cargo.exe fmt --all -- --check +cargo.exe clippy --workspace --all-targets --locked -- -D warnings +cargo.exe test --workspace --locked +cargo.exe build --workspace --locked +npm.cmd --prefix web run check +npm.cmd --prefix web test +npm.cmd --prefix web run build +git diff --check +``` + +Expected: every command exits 0 and all contract markers print. + +- [ ] **Step 2: Build the real unsigned Windows installer and recheck PE boundaries** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/build-windows-nsis.ps1 -Channel Development +powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/assert-windows-pe-subsystem.ps1 -ExecutablePath target\release\stellr-desktop.exe -ExpectedSubsystem WindowsGui +powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/assert-windows-pe-subsystem.ps1 -ExecutablePath target\release\stellr.exe -ExpectedSubsystem WindowsCui +``` + +Expected: unsigned installer creation and both PE assertions pass. Do not replace an existing local installation without separate user authorization. + +- [ ] **Step 3: Review the complete branch** + +Run the repository `code-review` skill against merge base `origin/main`. Resolve every actionable correctness or standards finding and rerun the proportional focused gates plus the full affected matrix. + +- [ ] **Step 4: Commit final review corrections if needed** + +```powershell +git add -- .github/workflows/linux-bundle.yml .github/workflows/macos-bundle.yml .github/workflows/release.yml .github/workflows/windows-bundle.yml CHANGELOG.md crates/app/Cargo.toml crates/app/src/cli.rs crates/app/src/desktop_main.rs crates/app/src/entrypoints.rs crates/app/src/lib.rs crates/app/src/main.rs crates/app/tauri.conf.json crates/app/tauri.windows.conf.json scripts/assert-windows-pe-subsystem.ps1 scripts/build-windows-nsis.ps1 scripts/smoke-windows-application-process.ps1 scripts/smoke-windows-nsis.ps1 scripts/tests/release-boundary.tests.ps1 scripts/tests/windows-packaging.tests.ps1 +git commit -m "fix: address Windows hardening review" +``` + +Skip this commit when review produces no code changes; never create an empty commit. + +- [ ] **Step 5: Push and open one pull request** + +Push `codex/windows-launch-hardening` over native Windows OpenSSH. Open one ready-for-review PR whose body contains: + +```markdown +## Summary +- split the Windows desktop and CLI entry points so installed launch is console-free +- make Windows startup smokes deadline-bound and diagnostic +- upgrade all bundle artifact uploads to v7 without suppressing warnings + +## Validation +- complete native PowerShell contract suite +- Rust format, Clippy, test, and build matrix +- frontend check, test, and production build +- unsigned Windows NSIS build plus GUI/CUI PE-header assertions + +Closes #75 +Closes #76 +Closes #77 +``` + +- [ ] **Step 6: Wait for GitHub checks** + +Wait for the pull request's required CI, Windows bundle/application-process/installed smokes, and Linux/macOS bundle uploads. If a check fails, inspect the exact live logs, fix only the demonstrated defect, rerun local proportional gates, push the correction, and wait again. Confirm the artifact runtime warning is absent rather than merely hidden. diff --git a/docs/superpowers/specs/2026-08-03-windows-launch-and-ci-hardening-design.md b/docs/superpowers/specs/2026-08-03-windows-launch-and-ci-hardening-design.md new file mode 100644 index 0000000..bab3772 --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-windows-launch-and-ci-hardening-design.md @@ -0,0 +1,245 @@ +# Windows Launch and CI Hardening Design + +**Date:** 2026-08-03 +**Status:** Approved for implementation +**Amended:** 2026-08-04 - approved separate desktop and CLI entry points after +confirming Windows shell wait semantics are fixed by the PE subsystem. + +## Problem + +The packaged Windows executable is currently linked as a console application +(`WINDOWS_CUI`). Launching it from the Start menu or an installed shortcut +therefore creates a terminal window before the native Stellr window appears. +That terminal is an implementation detail and should not be visible during an +ordinary desktop launch. + +Stellr is also a real command-line application. The current executable +provides `serve`, help, version, and error output. Switching that executable to +the Windows GUI subsystem would fix the desktop symptom by silently breaking +supported CLI behavior: interactive `cmd.exe` does not wait for a GUI-subsystem +process, and attaching that process to the parent console cannot change the +shell's wait and exit-code decision. + +The Windows application-process smoke test has a separate reliability gap. It +uses a fixed 30-second polling loop and reports only that no window appeared. +The same commit passed when the failed GitHub Actions job was rerun, with the +desktop step taking 24 seconds overall. Stellr already emits opt-in startup +stage diagnostics, but this smoke does not capture them. + +Finally, all six workflow upload steps still use `actions/upload-artifact@v4`. +GitHub's Node 24 runner transition warns because that action line uses an older +internal Node runtime. Stellr's own JavaScript jobs already select Node 24; the +remaining warning is action dependency debt, not a project Node-version +setting to suppress. + +## Product Decision + +Windows packages contain two thin entry points backed by the same application +assembly: + +- `stellr-desktop.exe` is the packaged GUI entry point. Release builds use the + Windows GUI subsystem, and the installer shortcut, display icon, and `stellr` + protocol registration target it. +- `stellr.exe` remains the console-subsystem CLI entry point. It preserves + `serve`, help, version, invalid-command output, native shell waiting, and exit + codes from PowerShell and `cmd.exe`. + +Debug builds of both entry points remain console-subsystem applications so +developers retain ordinary terminal diagnostics. The Windows installer +includes the CLI beside the desktop executable; it does not duplicate runtime +or domain logic between them. + +Windows startup smoke tests allow a measured 90-second cold-start budget and +capture existing startup diagnostics on failure. A larger legitimate startup +budget is not a success fallback: the test still fails if the window does not +appear or the process exits. + +All six workflow references to `actions/upload-artifact@v4` move to the current +supported `v7` line. The Node warning is fixed by upgrading its source rather +than hiding, filtering, or ignoring it. + +## Goals + +- Launch the installed desktop application without a visible terminal window. +- Preserve the installed `stellr` CLI and its interactive behavior from native + PowerShell and `cmd.exe`. +- Keep debug-build console behavior unchanged. +- Make Windows startup failures report the last known native startup stage. +- Tolerate legitimate cold-run variance without accepting a missing window. +- Remove the artifact action's obsolete runtime warning by upgrading the + action dependency. +- Lock these properties down at source, binary, script, and GitHub Actions + seams. + +## Non-Goals + +- Remove consoles from debug builds. +- Hide a console after it flashes; release desktop launch must avoid creating + it in the first place. +- Silence, filter, or waive GitHub Actions runtime warnings. +- Change desktop navigation, repository selection, authentication, or server + semantics. +- Split the desktop and CLI into separate products or duplicated application + implementations; they remain two entry points into one Stellr product and + shared assembly. +- Change installer signing or release-signing policy. + +## Design + +### Executable boundary and release subsystem + +The app crate exposes a desktop binary in addition to the existing CLI binary. +Only the desktop entry point declares the Windows GUI subsystem, and only when +compiling a release build for Windows. Conceptually, its crate-level contract +is equivalent to: + +```rust +#![cfg_attr( + all(target_os = "windows", not(debug_assertions)), + windows_subsystem = "windows" +)] +``` + +The target predicate keeps non-Windows builds untouched. The debug predicate +keeps local desktop-entry diagnostics attached to the developer's console. The +final packaged desktop executable must have PE subsystem +`IMAGE_SUBSYSTEM_WINDOWS_GUI` (value 2), while the CLI executable must retain +`IMAGE_SUBSYSTEM_WINDOWS_CUI` (value 3). Both values are verified from the +built binaries rather than inferred from source text. + +### Shared launch assembly + +The desktop and CLI entry points are dispatch adapters, not separate +applications. Existing command parsing, working-directory resolution, desktop +launch construction, runtime startup, and error behavior move behind shared +application interfaces where both entry points need them. + +The desktop entry point accepts the launch shapes supplied by Windows desktop +integration: bare startup, `open `, and a direct `stellr://...` +activation. It never exposes `serve`, help, version, or general parser errors +through a nonexistent console. Controlled desktop startup failures continue to +use the existing native error dialog. + +The console entry point owns the complete CLI grammar. `serve`, help, version, +and invalid-command paths remain ordinary console operations. A CLI `open` +invocation may host the desktop in the console process the user deliberately +started; it does not create an additional terminal window. + +On Windows, Tauri bundles `stellr-desktop.exe` as the main application binary +and includes `stellr.exe` as the companion CLI. The Start menu shortcut, +uninstall metadata display icon, and deep-link registration resolve to the +desktop binary. The installed CLI remains independently launchable for users +who intentionally choose the command line. + +### Windows startup smoke diagnostics + +Both Windows smoke scripts replace the fixed `60 * 500 ms` polling loop with a +named `StartupTimeoutSeconds` parameter that defaults to 90 seconds. A +stopwatch/deadline controls the loop so the configured budget is clear and +does not depend on a magic attempt count. + +For each spawned Stellr process, the script: + +1. enables `STELLR_STARTUP_DIAGNOSTICS=1` only for the child launch and restores + the caller's environment afterward; +2. redirects standard output and standard error to per-launch temporary logs; +3. waits until the real desktop window is visible, the process exits, or the + deadline expires; +4. on failure, reports the exit state and captured diagnostic lines, including + the last `STELLR_DESKTOP_STARTUP_STAGE` or + `STELLR_DESKTOP_STARTUP_ERROR` marker; +5. still exits non-zero when the window never becomes visible. + +The installed-application smoke keeps its existing installer and process +boundary assertions. The application-process smoke remains a real packaged +release launch, not a mocked window test. + +### GitHub Actions dependency upgrade + +Every current upload site moves together from +`actions/upload-artifact@v4` to `actions/upload-artifact@v7`: + +- Linux bundle workflow; +- macOS bundle workflow; +- Windows bundle workflow; +- Windows, macOS, and Linux jobs in the release workflow. + +Artifact names, paths, retention behavior, and downstream release consumption +remain unchanged. Workflow contract tests prohibit old `v4` references and +require the six expected `v7` references. GitHub Actions is then run to prove +the upgraded action uploads each platform artifact successfully and the Node +runtime warning is absent. + +## Testing Strategy + +Implementation follows red-green-refactor. + +1. Add focused Rust tests for shared desktop launch construction and the + desktop entry point's bare/open/protocol argument shapes. +2. Add Windows packaging tests that inspect the actual release PE headers, + require GUI subsystem value 2 for `stellr-desktop.exe`, and require console + subsystem value 3 for `stellr.exe`. +3. Add an installed-package contract proving the shortcut/display icon and + protocol activation use the desktop binary while the CLI is also installed. +4. Add or extend script contract tests to require the 90-second named timeout, + deadline-based polling, child-scoped startup diagnostics, failure log + emission, and non-zero timeout behavior. +5. Add workflow contract coverage requiring exactly six + `actions/upload-artifact@v7` uses and no `@v4` uses. +6. Run the focused native tests, full Rust test suite, frontend checks, and + Windows release build locally as appropriate. +7. Exercise the built release CLI from native PowerShell and `cmd.exe`, proving + visible `serve`/help output, controllability, and exit behavior. +8. Let GitHub's Windows application-process and installed-application smokes + prove the actual desktop window boundary. All platform bundle uploads must + also pass with the upgraded action. + +Tests must avoid asserting implementation text when they can inspect behavior +or artifacts. The subsystem gate reads the actual PE header; the smoke gate +observes an actual top-level Stellr window; the CLI gate launches the actual +release executable. + +## Failure Handling + +- A desktop process that exits before showing its window fails immediately and + prints its captured startup diagnostics. +- A desktop process that remains alive without a visible window for 90 seconds + fails with its last recorded startup stage. +- A missing or wrongly linked companion CLI fails the packaging contract; the + desktop executable is never used as a quiet CLI fallback. +- An artifact upload regression fails its owning workflow; no warning filter or + `continue-on-error` is introduced. +- If `upload-artifact@v7` requires a real input or consumption change, update + that workflow explicitly and test it rather than pinning back to silence the + warning. + +## Documentation and Release Notes + +The `Unreleased` changelog records the console-free installed desktop launch, +the diagnostic startup-smoke hardening, and the artifact action upgrade. +Existing shipped version sections remain unchanged and are not rewritten or +made cumulative. + +## Acceptance Criteria + +- Launching installed Stellr normally creates the native window without a + terminal window appearing or remaining visible. +- The packaged desktop PE subsystem is `IMAGE_SUBSYSTEM_WINDOWS_GUI`, and the + companion CLI subsystem is `IMAGE_SUBSYSTEM_WINDOWS_CUI`. +- Debug builds retain their ordinary console subsystem and diagnostics. +- Release `stellr serve`, help, version, and invalid-command paths remain + visible and usable from native PowerShell and `cmd.exe` through the companion + CLI. +- The installed shortcut and protocol registration launch + `stellr-desktop.exe`; bare, `open`, and protocol desktop launches never + allocate a console. +- The Windows package installs both entry points without duplicating runtime or + domain logic. +- Both Windows startup smokes use a named 90-second deadline and fail with the + captured startup stage when the real window does not appear. +- All six artifact upload steps use `actions/upload-artifact@v7`; no `@v4` + upload references or warning-suppression mechanisms remain. +- GitHub Actions uploads the expected Linux, macOS, and Windows artifacts + successfully without the obsolete action-runtime warning. +- Focused tests, the full native test matrix, frontend checks, release build, + packaged process smokes, and workflow checks pass. diff --git a/scripts/assert-windows-pe-subsystem.ps1 b/scripts/assert-windows-pe-subsystem.ps1 new file mode 100644 index 0000000..4790911 --- /dev/null +++ b/scripts/assert-windows-pe-subsystem.ps1 @@ -0,0 +1,40 @@ +param( + [Parameter(Mandatory = $true)] + [string]$ExecutablePath, + + [Parameter(Mandatory = $true)] + [ValidateSet('WindowsGui', 'WindowsCui')] + [string]$ExpectedSubsystem +) + +$ErrorActionPreference = 'Stop' +$executable = (Resolve-Path -LiteralPath $ExecutablePath).Path +$bytes = [IO.File]::ReadAllBytes($executable) + +if ($bytes.Length -lt 0x40 -or $bytes[0] -ne 0x4d -or $bytes[1] -ne 0x5a) { + throw "The file is not a valid DOS/PE image: $executable" +} + +$peOffset = [BitConverter]::ToInt32($bytes, 0x3c) +if ($peOffset -lt 0 -or $peOffset + 24 + 0x46 -gt $bytes.Length) { + throw "The PE header lies outside the file: $executable" +} +if ($bytes[$peOffset] -ne 0x50 -or $bytes[$peOffset + 1] -ne 0x45 -or + $bytes[$peOffset + 2] -ne 0 -or $bytes[$peOffset + 3] -ne 0) { + throw "The PE signature is invalid: $executable" +} + +$optionalHeaderOffset = $peOffset + 24 +$magic = [BitConverter]::ToUInt16($bytes, $optionalHeaderOffset) +if ($magic -notin @(0x10b, 0x20b)) { + throw "Unsupported PE optional-header magic 0x$($magic.ToString('x4')): $executable" +} + +$actual = [BitConverter]::ToUInt16($bytes, $optionalHeaderOffset + 0x44) +$expected = if ($ExpectedSubsystem -eq 'WindowsGui') { 2 } else { 3 } +if ($actual -ne $expected) { + throw "Expected $ExpectedSubsystem ($expected), found subsystem $actual in $executable." +} + +Write-Output "WINDOWS_PE_SUBSYSTEM=$ExpectedSubsystem" +Write-Output "WINDOWS_PE_EXECUTABLE=$executable" diff --git a/scripts/build-windows-nsis.ps1 b/scripts/build-windows-nsis.ps1 index 9dbd9d3..e61c1c9 100644 --- a/scripts/build-windows-nsis.ps1 +++ b/scripts/build-windows-nsis.ps1 @@ -26,21 +26,40 @@ if ($Channel -eq 'Release' -and [string]::IsNullOrWhiteSpace($CertificateThumbpr if ($LASTEXITCODE -ne 0) { throw "Frontend build failed with exit code $LASTEXITCODE." } $tauriArguments = @('build', '--bundles', 'nsis') -$temporarySigningConfig = $null +$temporaryBundleConfig = $null +$hostTuple = (& rustc --print host-tuple).Trim() +if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($hostTuple)) { + throw 'Could not resolve the native Rust host tuple for the companion CLI.' +} +if ($hostTuple -ne 'x86_64-pc-windows-msvc') { + throw "The supported Windows sidecar target is x86_64-pc-windows-msvc; detected $hostTuple." +} + +& cargo build --package stellr-app --release --bin stellr +if ($LASTEXITCODE -ne 0) { throw "Companion Stellr CLI build failed with exit code $LASTEXITCODE." } + +$cliBinary = Join-Path $repo 'target\release\stellr.exe' +$sidecarDirectory = Join-Path $repo 'crates\app\binaries' +$sidecarBinary = Join-Path $sidecarDirectory "stellr-$hostTuple.exe" +New-Item -ItemType Directory -Path $sidecarDirectory -Force | Out-Null +Copy-Item -LiteralPath $cliBinary -Destination $sidecarBinary -Force + +$bundleConfig = @{ + externalBin = @('binaries/stellr') +} if ($Channel -eq 'Release') { - $temporarySigningConfig = Join-Path ([IO.Path]::GetTempPath()) "stellr-windows-signing-$PID.json" - $signingConfig = @{ - bundle = @{ - windows = @{ - certificateThumbprint = $CertificateThumbprint - digestAlgorithm = 'sha256' - timestampUrl = 'http://timestamp.digicert.com' - } - } - } | ConvertTo-Json -Depth 4 - [IO.File]::WriteAllText($temporarySigningConfig, $signingConfig) - $tauriArguments += @('--config', $temporarySigningConfig) + $bundleConfig.windows = @{ + certificateThumbprint = $CertificateThumbprint + digestAlgorithm = 'sha256' + timestampUrl = 'http://timestamp.digicert.com' + } } +$temporaryBundleConfig = Join-Path ([IO.Path]::GetTempPath()) "stellr-windows-bundle-$PID.json" +$configOverride = @{ + bundle = $bundleConfig +} | ConvertTo-Json -Depth 4 +[IO.File]::WriteAllText($temporaryBundleConfig, $configOverride) +$tauriArguments += @('--config', $temporaryBundleConfig) try { Push-Location (Join-Path $repo 'crates\app') @@ -48,11 +67,16 @@ try { if ($LASTEXITCODE -ne 0) { throw "Tauri NSIS build failed with exit code $LASTEXITCODE." } } finally { Pop-Location - if ($null -ne $temporarySigningConfig) { - Remove-Item -LiteralPath $temporarySigningConfig -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $sidecarBinary -Force -ErrorAction SilentlyContinue + if ($null -ne $temporaryBundleConfig) { + Remove-Item -LiteralPath $temporaryBundleConfig -Force -ErrorAction SilentlyContinue } } +$peAssertion = Join-Path $repo 'scripts\assert-windows-pe-subsystem.ps1' +& $peAssertion -ExecutablePath (Join-Path $repo 'target\release\stellr-desktop.exe') -ExpectedSubsystem WindowsGui +& $peAssertion -ExecutablePath $cliBinary -ExpectedSubsystem WindowsCui + $bundleDirectory = Join-Path $repo 'target\release\bundle\nsis' $installer = Get-ChildItem -LiteralPath $bundleDirectory -Filter '*-setup.exe' | Sort-Object LastWriteTimeUtc -Descending | diff --git a/scripts/smoke-windows-application-process.ps1 b/scripts/smoke-windows-application-process.ps1 index f6bf69a..2123898 100644 --- a/scripts/smoke-windows-application-process.ps1 +++ b/scripts/smoke-windows-application-process.ps1 @@ -6,16 +6,19 @@ param( [int]$InitialIssue = 70, [string]$InitialIssueTitle = 'M2: Prove and document the complete release boundary', [int]$ForwardedIssue = 66, - [string]$ForwardedIssueTitle = 'M2: Ship the Polar Observatory shell and native actions' + [string]$ForwardedIssueTitle = 'M2: Ship the Polar Observatory shell and native actions', + [int]$StartupTimeoutSeconds = 90 ) $ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'windows-startup-diagnostics.ps1') if ($env:CI -ne 'true') { throw 'This smoke modifies the desktop profile and may run only on a disposable CI account.' } if ([string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN)) { throw 'GITHUB_TOKEN is required to prove authenticated application startup.' } +if ($StartupTimeoutSeconds -le 0) { throw 'StartupTimeoutSeconds must be positive.' } Add-Type -AssemblyName UIAutomationClient Add-Type @' @@ -31,22 +34,24 @@ $executable = (Resolve-Path $ExecutablePath).Path $workingDirectory = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path $routeFile = Join-Path ([Environment]::GetFolderPath('ApplicationData')) 'stellr\config\desktop-route.json' $primary = $null +$startupLogRoot = New-StellrStartupLogRoot "stellr-application-startup-$PID" +$startupSequence = 0 function Start-Stellr([string[]]$Arguments) { + $script:startupSequence++ + $startupLog = New-StellrStartupLog $startupLogRoot "launch-$($script:startupSequence)" $start = @{ FilePath = $executable WorkingDirectory = $workingDirectory - PassThru = $true } if ($Arguments.Count -gt 0) { $start.ArgumentList = $Arguments } - $process = Start-Process @start - for ($attempt = 0; $attempt -lt 60; $attempt++) { - Start-Sleep -Milliseconds 500 - $process.Refresh() - if ($process.HasExited) { throw "Stellr exited during startup with code $($process.ExitCode)." } - if ($process.MainWindowTitle -eq 'Stellr' -and $process.MainWindowHandle -ne 0) { return $process } - } - throw 'The real Stellr process did not create its desktop window.' + $process = Start-StellrProcessWithDiagnostics $start $startupLog + Wait-StellrDesktopWindow ` + $process ` + $startupLog ` + $StartupTimeoutSeconds ` + 'Stellr exited during startup.' ` + "Stellr did not create its desktop window within $StartupTimeoutSeconds seconds." } function Find-Element([System.Diagnostics.Process]$Process, [string]$Name) { @@ -96,12 +101,25 @@ try { } $forwardedUrl = "https://github.com/$Repository/issues/$ForwardedIssue" - $second = Start-Process -FilePath $executable -WorkingDirectory $workingDirectory -ArgumentList @('open', $forwardedUrl) -PassThru + $secondLog = New-StellrStartupLog $startupLogRoot 'second-instance' + $secondStart = @{ + FilePath = $executable + WorkingDirectory = $workingDirectory + ArgumentList = @('open', $forwardedUrl) + } + $second = Start-StellrProcessWithDiagnostics $secondStart $secondLog if (-not $second.WaitForExit(15000)) { Stop-Process -Id $second.Id -Force -ErrorAction SilentlyContinue - throw 'The second instance did not forward its route and exit.' + $second.WaitForExit() + $second.Refresh() + throw (Get-StellrStartupFailure $second $secondLog 'The second instance did not forward its route and exit.') + } + $second.WaitForExit() + $second.Refresh() + $secondExitCode = Get-StellrProcessExitCode $second + if ($secondExitCode -ne 0) { + throw (Get-StellrStartupFailure $second $secondLog 'The second instance failed while forwarding its route.') } - if ($second.ExitCode -ne 0) { throw "The second instance exited with code $($second.ExitCode)." } Find-Element $primary $ForwardedIssueTitle | Out-Null [StellrWindowControl]::ShowWindowAsync($primary.MainWindowHandle, 6) | Out-Null diff --git a/scripts/smoke-windows-cli.ps1 b/scripts/smoke-windows-cli.ps1 new file mode 100644 index 0000000..be7b2b4 --- /dev/null +++ b/scripts/smoke-windows-cli.ps1 @@ -0,0 +1,131 @@ +param( + [Parameter(Mandatory = $true)] + [string]$ExecutablePath, + [int]$StartupTimeoutSeconds = 30 +) + +$ErrorActionPreference = 'Stop' +if ($StartupTimeoutSeconds -le 0) { throw 'StartupTimeoutSeconds must be positive.' } +if ([string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN)) { + throw 'GITHUB_TOKEN is required to exercise serve mode.' +} + +$executable = (Resolve-Path $ExecutablePath).Path + +function Invoke-StellrFromPowerShell([string[]]$Arguments) { + $previousPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $output = & $executable @Arguments 2>&1 + $exitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousPreference + } + [pscustomobject]@{ + ExitCode = $exitCode + Output = $output -join [Environment]::NewLine + } +} + +function Invoke-StellrFromCmd([string[]]$Arguments) { + $command = '"' + $executable + '" ' + ($Arguments -join ' ') + $previousPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $output = & $env:ComSpec /d /s /c $command 2>&1 + $exitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousPreference + } + [pscustomobject]@{ + ExitCode = $exitCode + Output = $output -join [Environment]::NewLine + } +} + +function Assert-StellrSuccess([pscustomobject]$Result, [string]$Pattern, [string]$Description) { + if ($Result.ExitCode -ne 0) { + throw "$Description exited with code $($Result.ExitCode).`n$($Result.Output)" + } + if ($Result.Output -notmatch $Pattern) { + throw "$Description did not print the expected output '$Pattern'.`n$($Result.Output)" + } +} + +foreach ($shell in @( + @{ Name = 'PowerShell'; Invoke = ${function:Invoke-StellrFromPowerShell} }, + @{ Name = 'cmd.exe'; Invoke = ${function:Invoke-StellrFromCmd} } +)) { + $version = & $shell.Invoke @('--version') + Assert-StellrSuccess $version '^stellr [0-9]+\.[0-9]+\.[0-9]+' "$($shell.Name) version" + + $help = & $shell.Invoke @('--help') + Assert-StellrSuccess $help 'Usage: stellr' "$($shell.Name) help" + + $serveHelp = & $shell.Invoke @('serve', '--help') + Assert-StellrSuccess $serveHelp 'Usage: stellr(\.exe)? serve' "$($shell.Name) serve help" + + $invalid = & $shell.Invoke @('not-a-command') + if ($invalid.ExitCode -eq 0 -or $invalid.Output -notmatch 'error:') { + throw "$($shell.Name) invalid-command behavior was not visible and non-zero.`n$($invalid.Output)" + } +} + +function Test-StellrServeFromShell( + [string]$ShellName, + [string]$FileName, + [string]$Arguments +) { + $start = New-Object System.Diagnostics.ProcessStartInfo + $start.FileName = $FileName + $start.Arguments = $Arguments + $start.UseShellExecute = $false + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start.CreateNoWindow = $true + $shellProcess = New-Object System.Diagnostics.Process + $shellProcess.StartInfo = $start + + try { + if (-not $shellProcess.Start()) { throw "$ShellName serve mode could not be started." } + $nextLine = $shellProcess.StandardOutput.ReadLineAsync() + $startup = [Diagnostics.Stopwatch]::StartNew() + $cockpitUrl = $null + while ($startup.Elapsed.TotalSeconds -lt $StartupTimeoutSeconds) { + Start-Sleep -Milliseconds 250 + if ($nextLine.IsCompleted) { + $line = $nextLine.Result + if ($line -match 'stellr cockpit: (?http://127\.0\.0\.1:[0-9]+/(?:\?token=[0-9a-f]+)?)') { + $cockpitUrl = $Matches.url + break + } + if ($null -ne $line) { $nextLine = $shellProcess.StandardOutput.ReadLineAsync() } + } + if ($shellProcess.HasExited) { + throw "$ShellName serve mode exited early with code $($shellProcess.ExitCode).`n$($shellProcess.StandardError.ReadToEnd())" + } + } + if ([string]::IsNullOrWhiteSpace($cockpitUrl)) { + throw "$ShellName serve mode did not print its cockpit URL within $StartupTimeoutSeconds seconds." + } + if ($shellProcess.HasExited) { throw "$ShellName did not wait for the console-subsystem CLI." } + $response = Invoke-WebRequest -UseBasicParsing -Uri $cockpitUrl -TimeoutSec 10 + if ($response.StatusCode -ne 200) { throw "$ShellName serve mode returned HTTP $($response.StatusCode)." } + } finally { + if (-not $shellProcess.HasExited) { + & (Join-Path $env:SystemRoot 'System32\taskkill.exe') /PID $shellProcess.Id /T /F | Out-Null + $shellProcess.WaitForExit() + } + $shellProcess.Dispose() + } +} + +$escapedExecutable = $executable.Replace("'", "''") +$powerShellArguments = '-NoProfile -Command "& ''' + $escapedExecutable + ''' serve --addr 127.0.0.1:0 --no-token"' +Test-StellrServeFromShell 'PowerShell' (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') $powerShellArguments +$cmdArguments = '/d /s /c ""' + $executable + '" serve --addr 127.0.0.1:0 --no-token"' +Test-StellrServeFromShell 'cmd.exe' $env:ComSpec $cmdArguments + +Write-Output 'WINDOWS_CLI_POWERSHELL_PASSED=true' +Write-Output 'WINDOWS_CLI_CMD_PASSED=true' +Write-Output 'WINDOWS_CLI_SERVE_CONTROL_PASSED=true' diff --git a/scripts/smoke-windows-nsis.ps1 b/scripts/smoke-windows-nsis.ps1 index a9e2d5a..72e89d2 100644 --- a/scripts/smoke-windows-nsis.ps1 +++ b/scripts/smoke-windows-nsis.ps1 @@ -1,13 +1,18 @@ param( [Parameter(Mandatory = $true)] - [string]$InstallerPath + [string]$InstallerPath, + [int]$StartupTimeoutSeconds = 90 ) $ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'windows-startup-diagnostics.ps1') +if ($StartupTimeoutSeconds -le 0) { throw 'StartupTimeoutSeconds must be positive.' } $installer = (Resolve-Path $InstallerPath).Path $appProcess = $null $installedExecutable = $null $uninstaller = $null +$routeFile = Join-Path ([Environment]::GetFolderPath('ApplicationData')) 'stellr\config\desktop-route.json' +$startupLogRoot = New-StellrStartupLogRoot "stellr-installed-startup-$PID" function Get-StellrUninstallRecord { $roots = @( @@ -40,7 +45,17 @@ function Get-WebView2Version { return $null } +function Get-StellrShortcutTarget { + $programs = Join-Path ([Environment]::GetFolderPath('StartMenu')) 'Programs' + $shortcut = Get-ChildItem -LiteralPath $programs -Filter 'Stellr.lnk' -File -Recurse | + Select-Object -First 1 + if ($null -eq $shortcut) { throw 'The installed Stellr Start menu shortcut is missing.' } + $shell = New-Object -ComObject WScript.Shell + $shell.CreateShortcut($shortcut.FullName).TargetPath +} + try { + Remove-Item -LiteralPath $routeFile -Force -ErrorAction SilentlyContinue if ($null -ne (Get-StellrUninstallRecord)) { throw 'A Stellr installation already exists; refusing to overwrite it during a clean-runner smoke test.' } @@ -62,10 +77,26 @@ try { if (-not [string]::IsNullOrWhiteSpace($record.DisplayIcon)) { $installedExecutable = Get-ExecutableFromCommand $record.DisplayIcon } elseif (-not [string]::IsNullOrWhiteSpace($record.InstallLocation)) { - $installedExecutable = Join-Path $record.InstallLocation 'stellr.exe' + $installedExecutable = Join-Path $record.InstallLocation 'stellr-desktop.exe' } if ([string]::IsNullOrWhiteSpace($installedExecutable) -or -not (Test-Path -LiteralPath $installedExecutable)) { - throw 'The installed Stellr executable could not be located.' + throw 'The installed Stellr desktop executable could not be located.' + } + if ((Split-Path -Leaf $installedExecutable) -ne 'stellr-desktop.exe') { + throw "The uninstall DisplayIcon does not target stellr-desktop.exe: $installedExecutable" + } + $installedCli = Join-Path (Split-Path -Parent $installedExecutable) 'stellr.exe' + if (-not (Test-Path -LiteralPath $installedCli)) { throw 'The installed companion Stellr CLI is missing.' } + + $shortcutTarget = Get-StellrShortcutTarget + if ((Resolve-Path $shortcutTarget).Path -ne (Resolve-Path $installedExecutable).Path) { + throw "The Start menu shortcut does not target stellr-desktop.exe: $shortcutTarget" + } + $protocolKey = 'Registry::HKEY_CURRENT_USER\Software\Classes\stellr\shell\open\command' + $protocolCommand = (Get-Item -LiteralPath $protocolKey -ErrorAction Stop).GetValue('') + $protocolTarget = Get-ExecutableFromCommand $protocolCommand + if ((Resolve-Path $protocolTarget).Path -ne (Resolve-Path $installedExecutable).Path) { + throw "The stellr protocol does not target stellr-desktop.exe: $protocolCommand" } $webViewVersion = Get-WebView2Version @@ -74,20 +105,34 @@ try { } $installDirectory = Split-Path -Parent $installedExecutable - $appProcess = Start-Process -FilePath $installedExecutable ` - -WorkingDirectory $installDirectory ` - -PassThru - $windowReady = $false + $startupLog = New-StellrStartupLog $startupLogRoot 'installed' + $start = @{ + FilePath = $installedExecutable + WorkingDirectory = $installDirectory + } + $appProcess = Start-StellrProcessWithDiagnostics $start $startupLog + $appProcess = Wait-StellrDesktopWindow ` + $appProcess ` + $startupLog ` + $StartupTimeoutSeconds ` + 'Installed Stellr exited during startup.' ` + "Installed Stellr did not show its native window within $StartupTimeoutSeconds seconds." + + $protocolUri = 'stellr://space?repo=teloverge%2Fstellr&issue=57' + Start-Process -FilePath $protocolUri | Out-Null + $protocolRoute = $null for ($attempt = 0; $attempt -lt 60; $attempt++) { - Start-Sleep -Milliseconds 500 - $appProcess.Refresh() - if ($appProcess.HasExited) { throw "Installed Stellr exited early with code $($appProcess.ExitCode)." } - if ($appProcess.MainWindowTitle -eq 'Stellr' -and $appProcess.MainWindowHandle -ne 0) { - $windowReady = $true - break + if (Test-Path -LiteralPath $routeFile) { + $protocolRoute = Get-Content -LiteralPath $routeFile -Raw | ConvertFrom-Json + if ($protocolRoute.space -eq 'teloverge-stellr' -and $protocolRoute.issue -eq 57) { break } } + Start-Sleep -Milliseconds 500 + } + if ($null -eq $protocolRoute -or $protocolRoute.space -ne 'teloverge-stellr' -or $protocolRoute.issue -ne 57) { + throw 'The installed stellr:// activation did not reach and persist the desktop route.' } - if (-not $windowReady) { throw 'Installed Stellr did not show its native window.' } + $appProcess.Refresh() + if ($appProcess.HasExited) { throw 'The installed desktop exited during protocol activation.' } $webViewProcess = $null for ($attempt = 0; $attempt -lt 20; $attempt++) { @@ -114,6 +159,11 @@ try { $uninstaller = $null Write-Output "WEBVIEW2_VERSION=$webViewVersion" + Write-Output 'WINDOWS_DISPLAY_ICON_TARGET=stellr-desktop.exe' + Write-Output 'WINDOWS_START_MENU_TARGET=stellr-desktop.exe' + Write-Output 'WINDOWS_PROTOCOL_TARGET=stellr-desktop.exe' + Write-Output 'WINDOWS_PROTOCOL_ACTIVATION_PASSED=true' + Write-Output 'WINDOWS_COMPANION_CLI_INSTALLED=true' Write-Output 'WINDOWS_BARE_INSTALL_DIRECTORY_STARTUP_PASSED=true' Write-Output 'WINDOWS_NSIS_SMOKE_PASSED=true' } finally { @@ -123,4 +173,5 @@ try { if ($null -ne $uninstaller -and (Test-Path -LiteralPath $uninstaller)) { Start-Process -FilePath $uninstaller -ArgumentList '/S' -Wait -WindowStyle Hidden -ErrorAction SilentlyContinue } + Remove-Item -LiteralPath $routeFile -Force -ErrorAction SilentlyContinue } diff --git a/scripts/tests/linux-packaging.tests.ps1 b/scripts/tests/linux-packaging.tests.ps1 index 7f9ef36..0f5ee8b 100644 --- a/scripts/tests/linux-packaging.tests.ps1 +++ b/scripts/tests/linux-packaging.tests.ps1 @@ -13,7 +13,8 @@ Assert-True ($workflow.Contains('runs-on: ubuntu-22.04')) 'Linux packages must u Assert-True ($workflow.Contains('libwebkit2gtk-4.1-dev')) 'The workflow must install the Tauri WebKitGTK 4.1 toolchain.' Assert-True ($workflow.Contains('UNSIGNED-NOT-FOR-RELEASE')) 'Development packages must be unmistakably unsigned.' Assert-True ($workflow.Contains('GITHUB_STEP_SUMMARY')) 'The workflow summary must warn that development packages are unsigned.' -Assert-True ($workflow.Contains('actions/download-artifact@v4')) 'Smoke jobs must download packages into clean runners.' +Assert-True ([regex]::Matches($workflow, 'actions/download-artifact@v8').Count -eq 2) ` + 'Both smoke jobs must download packages with the current Node 24 artifact action.' Assert-True ($workflow.Contains('smoke-appimage')) 'The AppImage must have its own clean-runner smoke job.' Assert-True ($workflow.Contains('smoke-deb')) 'The deb must have its own clean-runner smoke job.' Assert-True ($workflow.Contains('libegl1')) 'The clean AppImage runner must install the EGL runtime required by WebKitGTK.' diff --git a/scripts/tests/release-boundary.tests.ps1 b/scripts/tests/release-boundary.tests.ps1 index 41d123b..32a996e 100644 --- a/scripts/tests/release-boundary.tests.ps1 +++ b/scripts/tests/release-boundary.tests.ps1 @@ -7,14 +7,41 @@ function Assert-True([bool]$Condition, [string]$Message) { } $processSmokePath = Join-Path $repo 'scripts\smoke-windows-application-process.ps1' +$startupHelperPath = Join-Path $repo 'scripts\windows-startup-diagnostics.ps1' Assert-True (Test-Path $processSmokePath) 'The real Windows application-process smoke is missing.' +Assert-True (Test-Path $startupHelperPath) 'The shared Windows startup diagnostics helper is missing.' $processSmoke = Get-Content $processSmokePath -Raw +$startupHelper = Get-Content $startupHelperPath -Raw Assert-True ($processSmoke.Contains('GITHUB_TOKEN')) 'The process smoke must prove authenticated startup.' -Assert-True ($processSmoke.Contains('Start-Process')) 'The process smoke must launch the real Stellr binary.' +Assert-True ($startupHelper.Contains('Start-Process')) 'The process smoke must launch the real Stellr binary.' Assert-True ($processSmoke.Contains('second instance')) 'The process smoke must prove second-instance forwarding.' Assert-True ($processSmoke.Contains('ShowWindowAsync')) 'The process smoke must exercise native focus transitions.' Assert-True ($processSmoke.Contains('WINDOWS_ROUTE_RESTORED_AFTER_RELAUNCH=true')) ` 'The process smoke must prove route restoration after relaunch.' +Assert-True ($processSmoke.Contains('[int]$StartupTimeoutSeconds = 90')) ` + 'The process smoke must expose the approved 90-second startup budget.' +Assert-True ($processSmoke.Contains('windows-startup-diagnostics.ps1')) ` + 'The process smoke must use the shared startup diagnostics boundary.' +Assert-True ($startupHelper.Contains('[Diagnostics.Stopwatch]::StartNew()')) ` + 'The process smoke must measure a startup deadline instead of counting attempts.' +Assert-True ($startupHelper.Contains('STELLR_STARTUP_DIAGNOSTICS')) ` + 'The process smoke must enable native stage diagnostics for each child.' +Assert-True ($startupHelper.Contains('RedirectStandardError')) ` + 'The process smoke must capture startup diagnostics.' +Assert-True ($startupHelper.Contains('STELLR_DESKTOP_STARTUP_STAGE')) ` + 'The process smoke must report the last native startup stage.' +Assert-True ($startupHelper.Contains('GetExitCodeProcess')) ` + 'The process smoke must read redirected child exit codes through the native Windows handle.' +Assert-True ($startupHelper.Contains('[void]$process.Handle')) ` + 'The process launcher must retain the native handle before a short-lived child exits.' +Assert-True ($processSmoke.Contains('New-StellrStartupLog $startupLogRoot ''second-instance''')) ` + 'The second-instance launch must use per-launch diagnostics too.' +Assert-True ($processSmoke.Contains('$second.WaitForExit()')) ` + 'The second-instance smoke must complete the native process wait before reading ExitCode.' +Assert-True ($processSmoke.Contains('$second.Refresh()')) ` + 'The second-instance smoke must refresh the native process before reading ExitCode.' +Assert-True ($processSmoke.Contains('$secondExitCode = Get-StellrProcessExitCode $second')) ` + 'The second-instance smoke must use the reliable native exit-code reader.' $releasePath = Join-Path $repo '.github\workflows\release.yml' Assert-True (Test-Path $releasePath) 'The fail-closed tagged release workflow is missing.' @@ -27,6 +54,17 @@ Assert-True ($release.Contains('APPLE_CERTIFICATE')) 'Tagged releases must requi Assert-True ($release.Contains('UNSIGNED-NOT-FOR-RELEASE')) 'Publication must explicitly reject development artifacts.' Assert-True ($release.Contains('gh release create')) 'Only the final gated job may create the draft release.' +$workflowText = (Get-ChildItem (Join-Path $repo '.github\workflows') -File | + ForEach-Object { Get-Content $_.FullName -Raw }) -join "`n" +$v7Uploads = [regex]::Matches($workflowText, 'actions/upload-artifact@v7').Count +$v4Uploads = [regex]::Matches($workflowText, 'actions/upload-artifact@v4').Count +$v8Downloads = [regex]::Matches($workflowText, 'actions/download-artifact@v8').Count +$v4Downloads = [regex]::Matches($workflowText, 'actions/download-artifact@v4').Count +Assert-True ($v7Uploads -eq 6) "Expected six v7 artifact uploads; found $v7Uploads." +Assert-True ($v4Uploads -eq 0) "Obsolete v4 artifact uploads remain: $v4Uploads." +Assert-True ($v8Downloads -eq 5) "Expected five v8 artifact downloads; found $v8Downloads." +Assert-True ($v4Downloads -eq 0) "Obsolete v4 artifact downloads remain: $v4Downloads." + $ci = Get-Content (Join-Path $repo '.github\workflows\ci.yml') -Raw Assert-True ($ci.Contains('libwebkit2gtk-4.1-dev')) 'Linux CI must install the native Tauri WebKitGTK toolchain.' Assert-True ($ci.Contains('cargo clippy --workspace --all-targets -- -D warnings')) ` @@ -48,7 +86,12 @@ foreach ($required in @( $changelog = Get-Content (Join-Path $repo 'CHANGELOG.md') $unreleased = [Array]::IndexOf($changelog, '## Unreleased') Assert-True ($unreleased -ge 0) 'The changelog must retain an Unreleased section.' -Assert-True ($changelog[$unreleased + 2].Contains('native desktop shell')) ` - 'The newest Unreleased entry must describe the completed native desktop shell.' +$nextRelease = $unreleased + 1 +while ($nextRelease -lt $changelog.Count -and -not $changelog[$nextRelease].StartsWith('## ')) { + $nextRelease++ +} +$unreleasedBody = $changelog[($unreleased + 1)..($nextRelease - 1)] -join "`n" +Assert-True ($unreleasedBody.Contains('native desktop shell')) ` + 'Unreleased must retain the completed native desktop shell entry.' Write-Output 'RELEASE_BOUNDARY_CONTRACT_PASSED=true' diff --git a/scripts/tests/windows-packaging.tests.ps1 b/scripts/tests/windows-packaging.tests.ps1 index 8dcfaf2..dd8d06a 100644 --- a/scripts/tests/windows-packaging.tests.ps1 +++ b/scripts/tests/windows-packaging.tests.ps1 @@ -12,6 +12,15 @@ Assert-True ($config.bundle.windows.webviewInstallMode.type -eq 'downloadBootstr Assert-True ($config.bundle.windows.nsis.installMode -eq 'currentUser') ` 'The NSIS installer must support non-elevated clean-runner install and uninstall.' +$windowsConfigPath = Join-Path $repo 'crates\app\tauri.windows.conf.json' +Assert-True (Test-Path $windowsConfigPath) 'The Windows-specific Tauri entry-point configuration is missing.' +$windowsConfig = Get-Content $windowsConfigPath -Raw | ConvertFrom-Json +Assert-True ($windowsConfig.mainBinaryName -eq 'stellr-desktop') ` + 'Windows packages must use the console-free desktop entry point.' +$cargoManifest = Get-Content (Join-Path $repo 'crates\app\Cargo.toml') -Raw +Assert-True ($cargoManifest.Contains('default-run = "stellr-desktop"')) ` + 'Tauri must select the dedicated desktop binary when the package has multiple entry points.' + $workflowPath = Join-Path $repo '.github\workflows\windows-bundle.yml' Assert-True (Test-Path $workflowPath) 'The native Windows bundle workflow is missing.' $workflow = Get-Content $workflowPath -Raw @@ -19,6 +28,9 @@ Assert-True ($workflow.Contains('runs-on: windows-latest')) 'The bundle must bui Assert-True ($workflow.Contains('UNSIGNED-NOT-FOR-RELEASE')) 'Development artifacts must be unmistakably unsigned.' Assert-True ($workflow.Contains('GITHUB_STEP_SUMMARY')) 'The workflow summary must warn that development artifacts are unsigned.' Assert-True ($workflow.Contains('smoke-windows-nsis.ps1')) 'The clean-runner installer smoke test must gate artifacts.' +Assert-True ($workflow.Contains('smoke-windows-cli.ps1')) 'The release CLI shell matrix must gate artifacts.' +Assert-True ($workflow.Contains('target\release\stellr-desktop.exe')) ` + 'The application-process smoke must launch the desktop entry point.' $releaseWorkflow = Get-Content (Join-Path $repo '.github\workflows\release.yml') -Raw Assert-True ($releaseWorkflow.Contains('WINDOWS_CERTIFICATE_BASE64')) 'Tagged builds must require the Windows certificate secret.' Assert-True ($releaseWorkflow.Contains('WINDOWS_CERTIFICATE_PASSWORD')) 'Tagged builds must require the certificate password secret.' @@ -27,15 +39,65 @@ Assert-True ((Get-Content (Join-Path $repo '.gitignore') -Raw).Contains('artifac $buildScript = Join-Path $repo 'scripts\build-windows-nsis.ps1' $smokeScript = Join-Path $repo 'scripts\smoke-windows-nsis.ps1' +$cliSmokeScript = Join-Path $repo 'scripts\smoke-windows-cli.ps1' +$startupHelperPath = Join-Path $repo 'scripts\windows-startup-diagnostics.ps1' $signingScript = Join-Path $repo 'scripts\assert-windows-signing.ps1' +$peSubsystemScript = Join-Path $repo 'scripts\assert-windows-pe-subsystem.ps1' Assert-True (Test-Path $buildScript) 'The reproducible Windows NSIS build script is missing.' Assert-True (Test-Path $smokeScript) 'The Windows install/launch/uninstall smoke script is missing.' +Assert-True (Test-Path $cliSmokeScript) 'The Windows release CLI smoke script is missing.' +Assert-True (Test-Path $startupHelperPath) 'The shared Windows startup diagnostics helper is missing.' Assert-True (Test-Path $signingScript) 'The fail-closed Windows signing preflight is missing.' +Assert-True (Test-Path $peSubsystemScript) 'The Windows PE-subsystem assertion is missing.' $buildContract = Get-Content $buildScript -Raw Assert-True ($buildContract.Contains('Get-AuthenticodeSignature')) ` 'The build must verify whether the copied artifact is signed.' Assert-True ($buildContract.Contains('PROCESSOR_ARCHITECTURE')) ` 'The supported build must reject non-x64 Windows hosts.' +Assert-True ($buildContract.Contains('WindowsGui')) 'The build must verify the desktop PE subsystem.' +Assert-True ($buildContract.Contains('WindowsCui')) 'The build must verify the CLI PE subsystem.' +Assert-True ($buildContract.Contains('externalBin')) 'The Windows package must configure a companion binary.' +Assert-True ($buildContract.Contains('binaries/stellr')) 'The Windows package must include the Stellr CLI.' + +& $peSubsystemScript -ExecutablePath (Join-Path $env:WINDIR 'explorer.exe') -ExpectedSubsystem WindowsGui | Out-Null +& $peSubsystemScript -ExecutablePath $env:ComSpec -ExpectedSubsystem WindowsCui | Out-Null + +$smokeContract = Get-Content $smokeScript -Raw +$startupHelperContract = Get-Content $startupHelperPath -Raw +Assert-True ($smokeContract.Contains('stellr-desktop.exe')) ` + 'The installed smoke fallback must select the desktop entry point.' +Assert-True ($smokeContract.Contains('WINDOWS_COMPANION_CLI_INSTALLED=true')) ` + 'The installed smoke must prove the companion CLI is present.' +Assert-True ($smokeContract.Contains('WINDOWS_START_MENU_TARGET=stellr-desktop.exe')) ` + 'The installed smoke must prove the Start menu shortcut targets the desktop binary.' +Assert-True ($smokeContract.Contains('WINDOWS_PROTOCOL_TARGET=stellr-desktop.exe')) ` + 'The installed smoke must prove the Stellr protocol targets the desktop binary.' +Assert-True ($smokeContract.Contains('WINDOWS_PROTOCOL_ACTIVATION_PASSED=true')) ` + 'The installed smoke must invoke the registered Stellr protocol and observe its route.' +Assert-True ($smokeContract.Contains('WINDOWS_DISPLAY_ICON_TARGET=stellr-desktop.exe')) ` + 'The installed smoke must prove uninstall metadata uses the desktop binary.' +Assert-True ($smokeContract.Contains('[int]$StartupTimeoutSeconds = 90')) ` + 'The installed smoke must expose the approved 90-second startup budget.' +Assert-True ($smokeContract.Contains('windows-startup-diagnostics.ps1')) ` + 'The installed smoke must use the shared startup diagnostics boundary.' +Assert-True ($startupHelperContract.Contains('[Diagnostics.Stopwatch]::StartNew()')) ` + 'The installed smoke must measure a startup deadline instead of counting attempts.' +Assert-True ($startupHelperContract.Contains('STELLR_STARTUP_DIAGNOSTICS')) ` + 'The installed smoke must enable native stage diagnostics for the child.' +Assert-True ($startupHelperContract.Contains('RedirectStandardError')) ` + 'The installed smoke must capture startup diagnostics.' +Assert-True ($startupHelperContract.Contains('STELLR_DESKTOP_STARTUP_STAGE')) ` + 'The installed smoke must report the last native startup stage.' + +$cliSmokeContract = Get-Content $cliSmokeScript -Raw +Assert-True ($cliSmokeContract.Contains('$env:ComSpec')) 'The CLI smoke must exercise cmd.exe.' +Assert-True ($cliSmokeContract.Contains('powershell.exe')) 'The live CLI smoke must exercise PowerShell.' +Assert-True ($cliSmokeContract.Contains("@('--help')")) 'The CLI smoke must exercise help.' +Assert-True ($cliSmokeContract.Contains("@('--version')")) 'The CLI smoke must exercise version.' +Assert-True ($cliSmokeContract.Contains("@('not-a-command')")) 'The CLI smoke must exercise invalid-command behavior.' +Assert-True ($cliSmokeContract.Contains("@('serve'")) 'The CLI smoke must exercise and control serve mode.' +Assert-True ($cliSmokeContract.Contains('Test-StellrServeFromShell')) ` + 'Live serve mode must run through both native shells.' $signingRejected = $false try { diff --git a/scripts/windows-startup-diagnostics.ps1 b/scripts/windows-startup-diagnostics.ps1 new file mode 100644 index 0000000..69d3eff --- /dev/null +++ b/scripts/windows-startup-diagnostics.ps1 @@ -0,0 +1,119 @@ +if ($null -eq ('StellrNativeProcess' -as [type])) { + Add-Type @' +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; + +public static class StellrNativeProcess { + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode); + + public static int ReadExitCode(Process process) { + uint exitCode; + if (!GetExitCodeProcess(process.Handle, out exitCode)) { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + return unchecked((int)exitCode); + } +} +'@ +} + +function Get-StellrProcessExitCode([System.Diagnostics.Process]$Process) { + if (-not $Process.HasExited) { throw "Process $($Process.Id) is still running." } + [StellrNativeProcess]::ReadExitCode($Process) +} + +function New-StellrStartupLogRoot([string]$Name) { + $base = if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) { + [IO.Path]::GetTempPath() + } else { + $env:RUNNER_TEMP + } + Join-Path $base $Name +} + +function New-StellrStartupLog([string]$Root, [string]$Name) { + New-Item -ItemType Directory -Path $Root -Force | Out-Null + [pscustomobject]@{ + StdoutPath = Join-Path $Root "$Name-stdout.log" + StderrPath = Join-Path $Root "$Name-stderr.log" + } +} + +function Start-StellrProcessWithDiagnostics( + [hashtable]$StartParameters, + [pscustomobject]$StartupLog +) { + $parameters = @{} + foreach ($entry in $StartParameters.GetEnumerator()) { + $parameters[$entry.Key] = $entry.Value + } + $parameters.PassThru = $true + $parameters.RedirectStandardOutput = $StartupLog.StdoutPath + $parameters.RedirectStandardError = $StartupLog.StderrPath + + $previousDiagnostics = [Environment]::GetEnvironmentVariable('STELLR_STARTUP_DIAGNOSTICS', 'Process') + try { + $env:STELLR_STARTUP_DIAGNOSTICS = '1' + $process = Start-Process @parameters + [void]$process.Handle + $process + } finally { + if ($null -eq $previousDiagnostics) { + Remove-Item Env:\STELLR_STARTUP_DIAGNOSTICS -ErrorAction SilentlyContinue + } else { + $env:STELLR_STARTUP_DIAGNOSTICS = $previousDiagnostics + } + } +} + +function Get-StellrStartupFailure( + [System.Diagnostics.Process]$Process, + [pscustomobject]$StartupLog, + [string]$Reason +) { + $lines = @() + foreach ($path in @($StartupLog.StdoutPath, $StartupLog.StderrPath)) { + if (Test-Path -LiteralPath $path) { + $lines += @(Get-Content -LiteralPath $path -ErrorAction SilentlyContinue) + } + } + $marker = $lines | + Where-Object { $_ -match '^STELLR_DESKTOP_STARTUP_(STAGE|ERROR)=' } | + Select-Object -Last 1 + if ([string]::IsNullOrWhiteSpace($marker)) { $marker = 'STELLR_DESKTOP_STARTUP_STAGE=' } + $state = if ($Process.HasExited) { + try { + "exited with code $(Get-StellrProcessExitCode $Process)" + } catch { + "exited with an unreadable code: $($_.Exception.Message)" + } + } else { + 'remained running' + } + $diagnostics = if ($lines.Count -eq 0) { '' } else { $lines -join [Environment]::NewLine } + "$Reason Process $($Process.Id) $state. Last startup marker: $marker`n$diagnostics" +} + +function Wait-StellrDesktopWindow( + [System.Diagnostics.Process]$Process, + [pscustomobject]$StartupLog, + [int]$TimeoutSeconds, + [string]$ExitReason, + [string]$TimeoutReason +) { + $startup = [Diagnostics.Stopwatch]::StartNew() + while ($startup.Elapsed.TotalSeconds -lt $TimeoutSeconds) { + Start-Sleep -Milliseconds 500 + $Process.Refresh() + if ($Process.HasExited) { + throw (Get-StellrStartupFailure $Process $StartupLog $ExitReason) + } + if ($Process.MainWindowTitle -eq 'Stellr' -and $Process.MainWindowHandle -ne 0) { + return $Process + } + } + throw (Get-StellrStartupFailure $Process $StartupLog $TimeoutReason) +}