diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 5508027..7051ddf 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -17,6 +17,16 @@ pub struct SignedIn { username: String, } +#[tauri::command] +pub async fn release_plan(launcher: State<'_, Launcher>) -> CmdResult { + Ok(launcher.release_plan().await) +} + +#[tauri::command] +pub async fn release_run(launcher: State<'_, Launcher>, password: String, with_api: bool) -> CmdResult<()> { + launcher.release_run(password, with_api).await +} + #[tauri::command] pub async fn get_status(launcher: State<'_, Launcher>) -> CmdResult { Ok(launcher.status().await) diff --git a/src-tauri/src/launcher.rs b/src-tauri/src/launcher.rs index 0399158..cabfc34 100644 --- a/src-tauri/src/launcher.rs +++ b/src-tauri/src/launcher.rs @@ -2265,6 +2265,100 @@ impl Launcher { Ok(()) } + // ── releasing, in dev mode ────────────────────────────────────────────── + + /// What a release could do right now: what is live, what this PC's repos hold, and what + /// stands in the way. Read fresh each time the panel opens - Docker and the DHP both + /// change under us. + pub async fn release_plan(&self) -> crate::release::ReleasePlan { + let (dev_root, online, pack_live, install_dir, installed) = { + let inner = self.inner.lock().await; + ( + inner.settings.dev_root.clone(), + inner.online, + inner.available.clone(), + inner.settings.install_path(), + inner.state.installed_version.clone(), + ) + }; + let dev_root = PathBuf::from(dev_root.trim()); + // The commit the LIVE pack was cut from. Read from this PC's own copy of that version, + // which costs nothing; when this PC is on an older version we simply do not know, and + // the panel says so rather than guessing. + let pack_commit = match (&installed, &pack_live) { + (Some(have), Some(live)) if have == live => std::fs::read(Layout::new(&install_dir, have).version_dir().join("manifest.json")) + .ok() + .and_then(|raw| serde_json::from_slice::(&raw).ok()) + .and_then(|m| m.get("source_commit").and_then(|c| c.as_str().map(str::to_string))), + _ => None, + }; + // The launcher's own feed is public, so this needs no token. + let mcp = self.inner.lock().await.settings.mcp_url.clone(); + let feed: Option = match mcp.is_empty() { + true => None, + false => match self + .http + .get(format!("{}/download/{LAUNCHER_FEED}", mcp.trim_end_matches('/'))) + .timeout(Duration::from_secs(10)) + .send() + .await + { + Ok(r) if r.status().is_success() => r.json::().await.ok(), + _ => None, + }, + }; + let launcher_live = feed.as_ref().and_then(|f| f.get("version").and_then(|v| v.as_str().map(str::to_string))); + let launcher_commit = feed.as_ref().and_then(|f| f.get("source_commit").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(str::to_string)); + crate::release::plan( + &dev_root, + &dev_root.join("ODIAN_MCP_Server"), + online, + pack_live, + pack_commit, + launcher_live, + launcher_commit, + None, + ) + } + + /// Start a release in its own console. Returns once it has started, not when it is done. + pub async fn release_run(&self, password: String, with_api: bool) -> Result<(), String> { + let plan = self.release_plan().await; + if let Some(why) = plan.blocked { + return Err(why); + } + if password.trim().is_empty() { + return Err("The node's sudo password is needed to cut a pack and to roll the API.".to_string()); + } + let (dev_root, install_dir, pack_live) = { + let inner = self.inner.lock().await; + (inner.settings.dev_root.trim().to_string(), inner.settings.install_path(), inner.available.clone()) + }; + let dev_root = PathBuf::from(dev_root); + let pack_next = pack_live + .as_deref() + .and_then(crate::release::next_pack) + .ok_or("Cannot tell which pack version comes next: the DHP did not say which is live.")?; + // The launcher is only rebuilt when its repo has moved past what is published; without + // that check every press would bump its version for nothing. + let launcher_item = plan.items.iter().find(|i| i.key == "launcher"); + let launcher_next = match launcher_item.map(|i| i.behind) { + Some(Some(false)) => None, + _ => launcher_item + .and_then(|i| i.live.as_deref()) + .and_then(crate::release::next_launcher), + }; + crate::release::run_release( + &dev_root.join("ODIAN_MCP_Server"), + &install_dir.join("state").join("release"), + &pack_next, + launcher_next.as_deref(), + with_api && plan.docker, + &password, + ) + .map(|_| ()) + } + // ── operator-data sync ────────────────────────────────────────────────── /// Sync, unless this run is pretending to be another operator. Whatever it authored is diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index beb4cf9..0f1855b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,6 +10,7 @@ mod localdb; mod market; mod nodejs; mod prune; +mod release; mod settings; mod site; mod stack; @@ -56,6 +57,8 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ commands::get_status, + commands::release_plan, + commands::release_run, commands::get_market, commands::apply_market, commands::check_market, diff --git a/src-tauri/src/release.rs b/src-tauri/src/release.rs new file mode 100644 index 0000000..958525a --- /dev/null +++ b/src-tauri/src/release.rs @@ -0,0 +1,259 @@ +//! Cutting a release from the hub, in dev mode. +//! +//! Three artifacts ship from these repos and each has its own pipeline: the ODIAN pack +//! (client + API + tile server), this launcher, and the Engine API container on the +//! cluster. The scripts that do the work already exist and are the ones a person runs by +//! hand; nothing here reimplements them. This decides WHAT is behind and runs them. +//! +//! "Behind" is answered by the commit each published artifact records it was built from. +//! Comparing versions would not do: a version says what was cut, not from what. +use serde::Serialize; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// What a release could do right now, and what stops it. +#[derive(Clone, Debug, Serialize)] +pub struct ReleasePlan { + /// Docker Desktop answers. Only the Engine API container needs it. + pub docker: bool, + /// The DHP answered its health check: nothing can be published without it. + pub online: bool, + /// The scripts that do the work are where they should be. + pub scripts: bool, + pub items: Vec, + /// Why the whole thing cannot run, in one line; None when it can. + pub blocked: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct ReleaseItem { + /// `pack`, `launcher` or `api`. + pub key: &'static str, + pub title: &'static str, + /// The version that is live, when it could be read. + pub live: Option, + /// The repo's HEAD, short. + pub commit: Option, + /// True when the repo has moved past what is published. None when nothing published + /// says which commit it came from - the honest answer until a release records one. + pub behind: Option, + /// Uncommitted changes in its repo: a release from those could not be rebuilt. + pub dirty: bool, + /// What the row says out loud. + pub note: String, +} + +/// Is a Docker daemon answering? The Engine API image is built in one. +pub fn docker_running() -> bool { + run_ok(Command::new("docker").args(["info", "--format", "{{.ServerVersion}}"])) +} + +fn run_ok(cmd: &mut Command) -> bool { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW + } + cmd.output().map(|o| o.status.success()).unwrap_or(false) +} + +fn git(repo: &Path, args: &[&str]) -> Option { + let mut cmd = Command::new("git"); + cmd.arg("-C").arg(repo).args(args); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x0800_0000); + } + let out = cmd.output().ok()?; + out.status.success().then(|| String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +fn head(repo: &Path) -> Option { + git(repo, &["rev-parse", "HEAD"]) +} + +fn dirty(repo: &Path) -> bool { + git(repo, &["status", "--porcelain"]).is_some_and(|s| !s.trim().is_empty()) +} + +/// One artifact's row: is this repo past what is published? +fn item( + key: &'static str, + title: &'static str, + repo: PathBuf, + live: Option, + published_commit: Option, +) -> ReleaseItem { + let exists = repo.is_dir(); + let commit = exists.then(|| head(&repo)).flatten(); + let dirty = exists && dirty(&repo); + let behind = match (&commit, &published_commit) { + (Some(now), Some(was)) => Some(now != was), + _ => None, + }; + let note = match (exists, &commit, behind, dirty) { + (false, ..) => format!("{} is not in the checkout folder", repo.display()), + (_, None, ..) => "not a git checkout".to_string(), + (_, _, _, true) => "uncommitted changes: commit them before releasing".to_string(), + (_, _, Some(true), _) => "behind: the repo has moved past what is published".to_string(), + (_, _, Some(false), _) => "up to date".to_string(), + (_, _, None, _) => "what is published does not say which commit it came from".to_string(), + }; + ReleaseItem { key, title, live, commit: commit.map(|c| c[..c.len().min(7)].to_string()), behind, dirty, note } +} + +/// Everything the Release panel shows. +pub fn plan( + dev_root: &Path, + mcp_repo: &Path, + online: bool, + pack_live: Option, + pack_commit: Option, + launcher_live: Option, + launcher_commit: Option, + api_live: Option, +) -> ReleasePlan { + let docker = docker_running(); + let scripts = mcp_repo.join("scripts").join("release_pack.ps1").is_file() + && mcp_repo.join("scripts").join("package_launcher.ps1").is_file(); + let items = vec![ + item("pack", "ODIAN", dev_root.join("ODIAN_Engine"), pack_live, pack_commit), + item("launcher", "Launcher", dev_root.join("ODIAN_Launcher"), launcher_live, launcher_commit), + // The container is built from the same Engine checkout as the pack; only its pipeline + // differs, and only it needs Docker. + item("api", "Engine API", dev_root.join("ODIAN_Engine"), api_live, None), + ]; + let blocked = if !online { + Some("The DHP is not answering: nothing can be published.".to_string()) + } else if !scripts { + Some(format!("The release scripts are not in {}.", mcp_repo.display())) + } else if items.iter().any(|i| i.dirty) { + Some("Commit what is open first: a release built from uncommitted work cannot be rebuilt.".to_string()) + } else { + None + }; + ReleasePlan { docker, online, scripts, items, blocked } +} + +/// Cut and publish, in its own console. +/// +/// The three scripts are run as a person runs them, in order, and each decides for itself +/// whether it has anything to do: `release_pack.ps1` stops with "nothing changed" when the +/// repo matches the live manifest, and `deploy-api.ps1` skips the roll when the compiled +/// binary is identical. So "release whatever is behind" needs no prediction here - only +/// that they are run in the right order and that the first real failure stops the rest. +/// +/// The node's sudo password goes in the ENVIRONMENT, not the command line: arguments are +/// readable by anyone who can list processes. +pub fn run_release( + mcp_repo: &Path, + work_dir: &Path, + pack_version: &str, + launcher_version: Option<&str>, + with_api: bool, + sudo: &str, +) -> Result { + let scripts = mcp_repo.join("scripts"); + let mut ps = String::new(); + ps.push_str("$ErrorActionPreference = 'Stop' +"); + ps.push_str("$pw = $env:ODIAN_NODE_SUDO +"); + ps.push_str("$env:ODIAN_NODE_SUDO = $null +"); // out of the environment the scripts inherit + ps.push_str(&format!( + "Write-Host '=== ODIAN pack {pack_version} ===' -ForegroundColor Cyan + & '{}' -Version '{pack_version}' -NodeSudoPw $pw + if ($LASTEXITCODE -notin 0,2) {{ Write-Host 'the pack step failed' -ForegroundColor Red; pause; exit 1 }} + if ($LASTEXITCODE -eq 2) {{ Write-Host 'pack: nothing to release' -ForegroundColor Yellow }} +", + scripts.join("release_pack.ps1").display() + )); + if let Some(v) = launcher_version { + ps.push_str(&format!( + "Write-Host '=== Launcher {v} ===' -ForegroundColor Cyan + & '{}' -Version '{v}' -SiteUrl 'https://engine.odian.fr/dhp' -McpUrl 'https://engine.odian.fr' -AiUrl 'https://engine.odian.fr' -SiteName 'ODIAN test DHP' -Publish + if ($LASTEXITCODE -ne 0) {{ Write-Host 'the launcher step failed' -ForegroundColor Red; pause; exit 1 }} +", + scripts.join("package_launcher.ps1").display() + )); + } + if with_api { + ps.push_str(&format!( + "Write-Host '=== Engine API ===' -ForegroundColor Cyan + & '{}' -NodeSudoPw $pw + if ($LASTEXITCODE -notin 0,3) {{ Write-Host 'the API step failed' -ForegroundColor Red; pause; exit 1 }} +", + // The api script lives with the release skill, beside the others when it is there. + scripts.join("deploy-api.ps1").display() + )); + } + ps.push_str("Write-Host '' +Write-Host 'Release finished.' -ForegroundColor Green +pause +"); + + std::fs::create_dir_all(work_dir).map_err(|e| format!("cannot use {}: {e}", work_dir.display()))?; + let file = work_dir.join("release.ps1"); + std::fs::write(&file, ps).map_err(|e| format!("cannot write {}: {e}", file.display()))?; + + let mut cmd = Command::new("powershell"); + cmd.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]).arg(&file); + cmd.env("ODIAN_NODE_SUDO", sudo); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x0000_0010); // CREATE_NEW_CONSOLE: the developer watches it run + } + cmd.spawn().map_err(|e| format!("cannot start the release: {e}"))?; + Ok(file) +} + +/// `0.71.0` -> `0.72.0`. A pack release is a minor; the patch place is not used. +pub fn next_pack(live: &str) -> Option { + let p: Vec = live.split('.').map_while(|x| x.parse().ok()).collect(); + (p.len() >= 2).then(|| format!("{}.{}.0", p[0], p[1] + 1)) +} + +/// `0.1.38` -> `0.1.39`. +pub fn next_launcher(live: &str) -> Option { + let p: Vec = live.split('.').map_while(|x| x.parse().ok()).collect(); + (p.len() >= 3).then(|| format!("{}.{}.{}", p[0], p[1], p[2] + 1)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_next_version_moves_the_right_place() { + // A pack is a minor (0.71.0 -> 0.72.0); the launcher is a patch (0.1.38 -> 0.1.39). + assert_eq!(next_pack("0.71.0").as_deref(), Some("0.72.0")); + assert_eq!(next_pack("0.9.0").as_deref(), Some("0.10.0"), "ten follows nine"); + assert_eq!(next_launcher("0.1.38").as_deref(), Some("0.1.39")); + assert_eq!(next_launcher("0.1.9").as_deref(), Some("0.1.10")); + // Nonsense from a feed must not become a version number. + assert_eq!(next_pack("").as_deref(), None); + assert_eq!(next_launcher("latest").as_deref(), None); + assert_eq!(next_launcher("0.1").as_deref(), None); + } + + #[test] + fn a_repo_that_matches_what_is_published_is_not_behind() { + // The comparison is COMMIT against COMMIT. Versions cannot answer it: a version says + // what was cut, not from what - which is how a release gets cut twice from one tree. + let same = item("pack", "ODIAN", PathBuf::from("."), Some("0.71.0".into()), head(Path::new("."))); + if same.commit.is_some() { + assert_eq!(same.behind, Some(false), "same commit: nothing to release"); + } + // Nothing published says where it came from: say so rather than guess. + let unknown = item("pack", "ODIAN", PathBuf::from("."), Some("0.71.0".into()), None); + assert_eq!(unknown.behind, None); + assert!(unknown.note.contains("does not say") || unknown.dirty || unknown.commit.is_none()); + // A folder that is not there is named, not silently "up to date". + let missing = item("pack", "ODIAN", PathBuf::from(r"Z:\nothing\here"), None, None); + assert_eq!(missing.behind, None); + assert!(missing.note.contains("not in the checkout folder")); + } +} diff --git a/src/App.svelte b/src/App.svelte index 89817f4..ef382dc 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -14,6 +14,10 @@ let settingsOpen = $state(false); + // Dev mode drops the patch notes: they describe the INSTALLED version, which is not what + // a dev run starts. With that column gone the art has the whole window to sit in. + const dev = $derived(launcher.devOn); + onMount(() => launcher.start()); @@ -21,8 +25,10 @@ (settingsOpen = true)} onOpenMarket={() => launcher.openMarket()} /> -
-
+
+ {#if !dev} +
+ {/if}
@@ -69,6 +75,12 @@ min-height: 0; } + /* No notes column in dev mode: it collapses rather than being hidden, so the hero and the + action panel keep the places they have everywhere else. */ + .hub.dev { + grid-template-columns: 0 minmax(0, 1fr) minmax(360px, 404px); + } + .hero-slot { grid-row: 1; grid-column: 2 / span 2; diff --git a/src/assets/keyart-dev.webp b/src/assets/keyart-dev.webp index f22403b..a21d100 100644 Binary files a/src/assets/keyart-dev.webp and b/src/assets/keyart-dev.webp differ diff --git a/src/components/ActionPanel.svelte b/src/components/ActionPanel.svelte index 0ce8afd..a3513ae 100644 --- a/src/components/ActionPanel.svelte +++ b/src/components/ActionPanel.svelte @@ -102,6 +102,7 @@ const devRoot = $derived(launcher.settings?.dev_root ?? ""); const devTarget = $derived(launcher.settings?.dev_target ?? "client"); const devBuild = $derived(launcher.settings?.dev_build ?? false); + const devUserField = $derived(launcher.settings?.dev_user ?? ""); const savingDev = $derived(busy === "settings"); // One stack either way: both clients talk to the same API and the same database, which is // the only way a .exe/web divergence is visible at all. @@ -111,6 +112,24 @@ { key: "both", name: "Both", note: "one stack" }, ] as const; + // ---- releasing ------------------------------------------------------------ + const plan = $derived(launcher.release); + const releasing = $derived(busy === "release"); + let releaseOpen = $state(false); + let nodePassword = $state(""); + let withApi = $state(true); + + async function openRelease() { + releaseOpen = true; + await launcher.loadRelease(); + } + + async function doRelease() { + await launcher.releaseRun(nodePassword, withApi); + nodePassword = ""; // held for the one run, never kept + releaseOpen = false; + } + async function browseCheckout() { const path = await launcher.pickFolder(); if (path) await launcher.setDev({ dev_root: path }); @@ -281,26 +300,85 @@ -
- - {#if s?.online} - - - - {:else} - offline — tiles and data stay as they are +
+ + launcher.setDev({ dev_user: e.currentTarget.value.trim() })} + /> +
+ +
+ + {#if !s?.online} + needs the DHP {/if}
+ {#if releaseOpen} +
+ {#if releasing && !plan} +

Looking at what is live and what this PC holds…

+ {:else if plan} +
    + {#each plan.items as row (row.key)} +
  • + {row.title} + {row.live ?? "—"} + {row.note} +
  • + {/each} +
+
+ DHP {plan.online ? "✓" : "✗"} + Docker {plan.docker ? "✓" : "✗"} + scripts {plan.scripts ? "✓" : "✗"} +
+ {#if plan.blocked} +

{plan.blocked}

+ {:else} + +
+ + +
+

+ Runs in its own window. Each step decides for itself whether it has anything to do. +

+ {/if} + {/if} + +
+ {/if} + + + {#if !devRun}

Name the folder that holds ODIAN_Engine and Launch runs from it.

{:else if devUser} @@ -532,10 +610,6 @@ align-items: center; gap: 0.4rem; } - .dev-row.wrap { - flex-wrap: wrap; - row-gap: 0.3rem; - } .dev-path { flex: 1 1 auto; min-width: 0; @@ -547,6 +621,11 @@ border: 1px solid var(--line, #2a3139); border-radius: 6px; } + .dev-label { + flex: none; + font-size: 0.78rem; + color: var(--text-dim, #9aa3ad); + } .dev-check { display: inline-flex; align-items: center; @@ -554,7 +633,6 @@ font-size: 0.8rem; white-space: nowrap; } - .dev-offline, .dev-note { font-size: 0.75rem; color: var(--text-dim, #9aa3ad); @@ -563,6 +641,53 @@ .dev-note code { font-size: 0.72rem; } + .dev-release { + font-weight: 600; + } + .dev-release-panel { + display: flex; + flex-direction: column; + gap: 0.4rem; + padding: 0.5rem; + border: 1px solid var(--line, #2a3139); + border-radius: 6px; + background: rgba(0, 0, 0, 0.25); + } + .dev-plan { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.2rem; + } + .dev-plan li { + display: grid; + grid-template-columns: 5.5rem 4.5rem 1fr; + gap: 0.4rem; + align-items: baseline; + font-size: 0.76rem; + color: var(--text-dim, #9aa3ad); + } + .dev-plan li.behind .dev-plan-name { + color: #46d17f; + } + .dev-plan li.dirty .dev-plan-note { + color: #e6b35a; + } + .dev-plan-name { + font-weight: 600; + color: inherit; + } + .dev-checks { + display: flex; + gap: 0.7rem; + font-size: 0.72rem; + color: #e06c6c; + } + .dev-checks span.ok { + color: #46d17f; + } .action { display: grid; diff --git a/src/components/KeyArt.svelte b/src/components/KeyArt.svelte index 3531915..c9c16ea 100644 --- a/src/components/KeyArt.svelte +++ b/src/components/KeyArt.svelte @@ -15,7 +15,7 @@ const art = $derived(launcher.devOn ? keyartDev : keyart); -