diff --git a/agents/VERSION_BUMP.md b/agents/VERSION_BUMP.md new file mode 100644 index 000000000..e7eda8812 --- /dev/null +++ b/agents/VERSION_BUMP.md @@ -0,0 +1,201 @@ +# StartOS Version Bump Guide + +This document explains how to bump the StartOS version across the entire codebase. + +## Overview + +When bumping from version `X.Y.Z-alpha.N` to `X.Y.Z-alpha.N+1`, you need to update files in multiple locations across the repository. The `// VERSION_BUMP` comment markers indicate where changes are needed. + +## Files to Update + +### 1. Core Rust Crate Version + +**File: `core/startos/Cargo.toml`** + +Update the version string (line ~18): + +```toml +version = "0.4.0-alpha.15" # VERSION_BUMP +``` + +**File: `core/Cargo.lock`** + +This file is auto-generated. After updating `Cargo.toml`, run: + +```bash +cd core +cargo check +``` + +This will update the version in `Cargo.lock` automatically. + +### 2. Create New Version Migration Module + +**File: `core/startos/src/version/vX_Y_Z_alpha_N+1.rs`** + +Create a new version file by copying the previous version and updating: + +```rust +use exver::{PreReleaseSegment, VersionRange}; + +use super::v0_3_5::V0_3_0_COMPAT; +use super::{VersionT, v0_4_0_alpha_14}; // Update to previous version +use crate::prelude::*; + +lazy_static::lazy_static! { + static ref V0_4_0_alpha_15: exver::Version = exver::Version::new( + [0, 4, 0], + [PreReleaseSegment::String("alpha".into()), 15.into()] // Update number + ); +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct Version; + +impl VersionT for Version { + type Previous = v0_4_0_alpha_14::Version; // Update to previous version + type PreUpRes = (); + + async fn pre_up(self) -> Result { + Ok(()) + } + fn semver(self) -> exver::Version { + V0_4_0_alpha_15.clone() // Update version name + } + fn compat(self) -> &'static VersionRange { + &V0_3_0_COMPAT + } + #[instrument(skip_all)] + fn up(self, _db: &mut Value, _: Self::PreUpRes) -> Result { + // Add migration logic here if needed + Ok(Value::Null) + } + fn down(self, _db: &mut Value) -> Result<(), Error> { + // Add rollback logic here if needed + Ok(()) + } +} +``` + +### 3. Update Version Module Registry + +**File: `core/startos/src/version/mod.rs`** + +Make changes in **5 locations**: + +#### Location 1: Module Declaration (~line 57) + +Add the new module after the previous version: + +```rust +mod v0_4_0_alpha_14; +mod v0_4_0_alpha_15; // Add this +``` + +#### Location 2: Current Type Alias (~line 59) + +Update the `Current` type and move the `// VERSION_BUMP` comment: + +```rust +pub type Current = v0_4_0_alpha_15::Version; // VERSION_BUMP +``` + +#### Location 3: Version Enum (~line 175) + +Remove `// VERSION_BUMP` from the previous version, add new variant, add comment: + +```rust + V0_4_0_alpha_14(Wrapper), + V0_4_0_alpha_15(Wrapper), // VERSION_BUMP + Other(exver::Version), +``` + +#### Location 4: as_version_t() Match (~line 233) + +Remove `// VERSION_BUMP`, add new match arm, add comment: + +```rust + Self::V0_4_0_alpha_14(v) => DynVersion(Box::new(v.0)), + Self::V0_4_0_alpha_15(v) => DynVersion(Box::new(v.0)), // VERSION_BUMP + Self::Other(v) => { +``` + +#### Location 5: as_exver() Match (~line 284, inside #[cfg(test)]) + +Remove `// VERSION_BUMP`, add new match arm, add comment: + +```rust + Version::V0_4_0_alpha_14(Wrapper(x)) => x.semver(), + Version::V0_4_0_alpha_15(Wrapper(x)) => x.semver(), // VERSION_BUMP + Version::Other(x) => x.clone(), +``` + +### 4. SDK TypeScript Version + +**File: `sdk/package/lib/StartSdk.ts`** + +Update the OSVersion constant (~line 64): + +```typescript +export const OSVersion = testTypeVersion("0.4.0-alpha.15"); +``` + +### 5. Web UI Package Version + +**File: `web/package.json`** + +Update the version field: + +```json +{ + "name": "startos-ui", + "version": "0.4.0-alpha.15", + ... +} +``` + +**File: `web/package-lock.json`** + +This file is auto-generated, but it's faster to update manually. Find all instances of "startos-ui" and update the version field. + +## Verification Step + +``` +make +``` + +## VERSION_BUMP Comment Pattern + +The `// VERSION_BUMP` comment serves as a marker for where to make changes next time: + +- Always **remove** it from the old location +- **Add** the new version entry +- **Move** the comment to mark the new location + +This pattern helps you quickly find all the places that need updating in the next version bump. + +## Summary Checklist + +- [ ] Update `core/startos/Cargo.toml` version +- [ ] Create new `core/startos/src/version/vX_Y_Z_alpha_N+1.rs` file +- [ ] Update `core/startos/src/version/mod.rs` in 5 locations +- [ ] Run `cargo check` to update `core/Cargo.lock` +- [ ] Update `sdk/package/lib/StartSdk.ts` OSVersion +- [ ] Update `web/package.json` and `web/package-lock.json` version +- [ ] Verify all changes compile/build successfully + +## Migration Logic + +The `up()` and `down()` methods in the version file handle database migrations: + +- **up()**: Migrates the database from the previous version to this version +- **down()**: Rolls back from this version to the previous version +- **pre_up()**: Runs before migration, useful for pre-migration checks or data gathering + +If no migration is needed, return `Ok(Value::Null)` for `up()` and `Ok(())` for `down()`. + +For complex migrations, you may need to: + +1. Update `type PreUpRes` to pass data between `pre_up()` and `up()` +2. Implement database transformations in the `up()` method +3. Implement reverse transformations in `down()` for rollback support diff --git a/build/lib/scripts/upgrade b/build/lib/scripts/upgrade index ee56e5c76..c986027ff 100755 --- a/build/lib/scripts/upgrade +++ b/build/lib/scripts/upgrade @@ -61,7 +61,7 @@ fi chroot /media/startos/next bash -e << "EOF" -if dpkg -s grub-common 2>&1 > /dev/null; then +if [ -f /boot/grub/grub.cfg ]; then grub-install /dev/$(eval $(lsblk -o MOUNTPOINT,PKNAME -P | grep 'MOUNTPOINT="/media/startos/root"') && echo $PKNAME) update-grub fi @@ -70,7 +70,7 @@ EOF sync -umount -R /media/startos/next +umount -Rl /media/startos/next umount /media/startos/upper umount /media/startos/lower diff --git a/core/Cargo.lock b/core/Cargo.lock index 3cf72c8c4..5c7daee00 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -7908,7 +7908,7 @@ dependencies = [ [[package]] name = "start-os" -version = "0.4.0-alpha.14" +version = "0.4.0-alpha.15" dependencies = [ "aes 0.7.5", "arti-client", diff --git a/core/startos/Cargo.toml b/core/startos/Cargo.toml index e10fe6c34..7c16ef24a 100644 --- a/core/startos/Cargo.toml +++ b/core/startos/Cargo.toml @@ -15,7 +15,7 @@ license = "MIT" name = "start-os" readme = "README.md" repository = "https://github.com/Start9Labs/start-os" -version = "0.4.0-alpha.14" # VERSION_BUMP +version = "0.4.0-alpha.15" # VERSION_BUMP [lib] name = "startos" diff --git a/core/startos/src/install/mod.rs b/core/startos/src/install/mod.rs index 5a13204f5..54002805a 100644 --- a/core/startos/src/install/mod.rs +++ b/core/startos/src/install/mod.rs @@ -29,6 +29,7 @@ use crate::registry::context::{RegistryContext, RegistryUrlParams}; use crate::registry::package::get::GetPackageResponse; use crate::rpc_continuations::{Guid, RpcContinuation}; use crate::s9pk::manifest::PackageId; +use crate::s9pk::v2::SIG_CONTEXT; use crate::upload::upload; use crate::util::Never; use crate::util::io::open_file; @@ -154,6 +155,8 @@ pub async fn install( })? .s9pk; + asset.validate(SIG_CONTEXT, asset.all_signers())?; + let progress_tracker = FullProgressTracker::new(); let download_progress = progress_tracker.add_phase("Downloading".into(), Some(100)); let download = ctx diff --git a/core/startos/src/progress.rs b/core/startos/src/progress.rs index d570e8a90..0a5ba351f 100644 --- a/core/startos/src/progress.rs +++ b/core/startos/src/progress.rs @@ -353,7 +353,7 @@ impl FullProgressTracker { } } pub fn progress_bar_task(&self, name: &str) -> NonDetachingJoinHandle<()> { - let mut stream = self.stream(None); + let mut stream = self.stream(Some(Duration::from_millis(200))); let mut bar = PhasedProgressBar::new(name); tokio::spawn(async move { while let Some(progress) = stream.next().await { diff --git a/core/startos/src/registry/asset.rs b/core/startos/src/registry/asset.rs index fb317c153..421352804 100644 --- a/core/startos/src/registry/asset.rs +++ b/core/startos/src/registry/asset.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::path::Path; use std::sync::Arc; use chrono::{DateTime, Utc}; @@ -84,6 +85,26 @@ impl RegistryAsset { ) .await } + pub async fn download_to( + &self, + path: impl AsRef, + client: Client, + progress: PhaseProgressTrackerHandle, + ) -> Result< + ( + S9pk>>, + Arc, + ), + Error, + > { + let source = Arc::new( + BufferedHttpSource::with_path(path, client, self.url.clone(), progress).await?, + ); + Ok(( + S9pk::deserialize(&source, Some(&self.commitment)).await?, + source, + )) + } } pub struct BufferedHttpSource { @@ -91,6 +112,19 @@ pub struct BufferedHttpSource { file: UploadingFile, } impl BufferedHttpSource { + pub async fn with_path( + path: impl AsRef, + client: Client, + url: Url, + progress: PhaseProgressTrackerHandle, + ) -> Result { + let (mut handle, file) = UploadingFile::with_path(path, progress).await?; + let response = client.get(url).send().await?; + Ok(Self { + _download: tokio::spawn(async move { handle.download(response).await }).into(), + file, + }) + } pub async fn new( client: Client, url: Url, @@ -103,6 +137,9 @@ impl BufferedHttpSource { file, }) } + pub async fn wait_for_buffered(&self) -> Result<(), Error> { + self.file.wait_for_complete().await + } } impl ArchiveSource for BufferedHttpSource { type FetchReader = ::FetchReader; diff --git a/core/startos/src/registry/package/get.rs b/core/startos/src/registry/package/get.rs index 8f61e51ef..a14b44108 100644 --- a/core/startos/src/registry/package/get.rs +++ b/core/startos/src/registry/package/get.rs @@ -1,19 +1,27 @@ use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; use clap::{Parser, ValueEnum}; use exver::{ExtendedVersion, VersionRange}; -use imbl_value::InternedString; +use helpers::to_tmp_path; +use imbl_value::{InternedString, json}; use itertools::Itertools; use models::PackageId; use serde::{Deserialize, Serialize}; use ts_rs::TS; +use crate::context::CliContext; use crate::prelude::*; +use crate::progress::{FullProgressTracker, ProgressUnits}; use crate::registry::context::RegistryContext; use crate::registry::device_info::DeviceInfo; use crate::registry::package::index::{PackageIndex, PackageVersionInfo}; +use crate::s9pk::merkle_archive::source::ArchiveSource; +use crate::s9pk::v2::SIG_CONTEXT; use crate::util::VersionString; +use crate::util::io::TrackingIO; use crate::util::serde::{WithIoFormat, display_serializable}; +use crate::util::tui::choose; #[derive( Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, TS, ValueEnum, @@ -352,8 +360,7 @@ pub fn display_package_info( info: Value, ) -> Result<(), Error> { if let Some(format) = params.format { - display_serializable(format, info); - return Ok(()); + return display_serializable(format, info); } if let Some(_) = params.rest.id { @@ -387,3 +394,90 @@ pub fn display_package_info( } Ok(()) } + +#[derive(Debug, Deserialize, Serialize, TS, Parser)] +#[serde(rename_all = "camelCase")] +pub struct CliDownloadParams { + pub id: PackageId, + #[arg(long, short = 'v')] + #[ts(type = "string | null")] + pub target_version: Option, + #[arg(short, long)] + pub dest: Option, +} + +pub async fn cli_download( + ctx: CliContext, + CliDownloadParams { + ref id, + target_version, + dest, + }: CliDownloadParams, +) -> Result<(), Error> { + let progress_tracker = FullProgressTracker::new(); + let mut fetching_progress = progress_tracker.add_phase("Fetching".into(), Some(1)); + let download_progress = progress_tracker.add_phase("Downloading".into(), Some(100)); + let mut verify_progress = progress_tracker.add_phase("Verifying".into(), Some(10)); + + let progress = progress_tracker.progress_bar_task("Downloading S9PK..."); + + fetching_progress.start(); + let mut res: GetPackageResponse = from_value( + ctx.call_remote::( + "package.get", + json!({ + "id": &id, + "targetVersion": &target_version, + }), + ) + .await?, + )?; + let PackageVersionInfo { s9pk, .. } = match res.best.len() { + 0 => { + return Err(Error::new( + eyre!( + "Could not find a version of {id} that satisfies {}", + target_version.unwrap_or(VersionRange::Any) + ), + ErrorKind::NotFound, + )); + } + 1 => res.best.pop_first().unwrap().1, + _ => { + let choices = res.best.keys().cloned().collect::>(); + let version = choose( + &format!("Multiple flavors of {id} available. Choose a version to download:"), + &choices, + ) + .await?; + res.best.remove(version).unwrap() + } + }; + s9pk.validate(SIG_CONTEXT, s9pk.all_signers())?; + fetching_progress.complete(); + + let dest = dest.unwrap_or_else(|| Path::new(".").join(id).with_extension("s9pk")); + let dest_tmp = to_tmp_path(&dest).with_kind(ErrorKind::Filesystem)?; + let (mut parsed, source) = s9pk + .download_to(&dest_tmp, ctx.client.clone(), download_progress) + .await?; + if let Some(size) = source.size().await { + verify_progress.set_total(size); + } + verify_progress.set_units(Some(ProgressUnits::Bytes)); + let mut progress_sink = verify_progress.writer(tokio::io::sink()); + parsed + .serialize(&mut TrackingIO::new(0, &mut progress_sink), true) + .await?; + progress_sink.into_inner().1.complete(); + + source.wait_for_buffered().await?; + tokio::fs::rename(dest_tmp, dest).await?; + + progress_tracker.complete(); + progress.await.unwrap(); + + println!("Download Complete"); + + Ok(()) +} diff --git a/core/startos/src/registry/package/mod.rs b/core/startos/src/registry/package/mod.rs index db9059a7f..ad6a0abed 100644 --- a/core/startos/src/registry/package/mod.rs +++ b/core/startos/src/registry/package/mod.rs @@ -1,4 +1,4 @@ -use rpc_toolkit::{Context, HandlerExt, ParentHandler, from_fn_async}; +use rpc_toolkit::{Context, HandlerExt, ParentHandler, from_fn_async, from_fn_async_local}; use crate::context::CliContext; use crate::prelude::*; @@ -54,6 +54,12 @@ pub fn package_api() -> ParentHandler { .with_about("List installation candidate package(s)") .with_call_remote::(), ) + .subcommand( + "download", + from_fn_async_local(get::cli_download) + .no_display() + .with_about("Download an s9pk"), + ) .subcommand( "category", category::category_api::() diff --git a/core/startos/src/upload.rs b/core/startos/src/upload.rs index ca34f34d1..5812834da 100644 --- a/core/startos/src/upload.rs +++ b/core/startos/src/upload.rs @@ -1,4 +1,5 @@ use std::io::SeekFrom; +use std::path::Path; use std::pin::Pin; use std::sync::Arc; use std::task::Poll; @@ -56,6 +57,7 @@ struct Progress { tracker: PhaseProgressTrackerHandle, expected_size: Option, written: u64, + complete: bool, error: Option, } impl Progress { @@ -111,9 +113,7 @@ impl Progress { } async fn ready(watch: &mut watch::Receiver) -> Result<(), Error> { match &*watch - .wait_for(|progress| { - progress.error.is_some() || Some(progress.written) == progress.expected_size - }) + .wait_for(|progress| progress.error.is_some() || progress.complete) .await .map_err(|_| { Error::new( @@ -126,8 +126,9 @@ impl Progress { } } fn complete(&mut self) -> bool { + let mut changed = !self.complete; self.tracker.complete(); - match self { + changed |= match self { Self { expected_size: Some(size), written, @@ -165,18 +166,21 @@ impl Progress { true } _ => false, - } + }; + self.complete = true; + changed } } #[derive(Clone)] pub struct UploadingFile { - tmp_dir: Arc, + tmp_dir: Option>, file: MultiCursorFile, progress: watch::Receiver, } impl UploadingFile { - pub async fn new( + pub async fn with_path( + path: impl AsRef, mut progress: PhaseProgressTrackerHandle, ) -> Result<(UploadHandle, Self), Error> { progress.set_units(Some(ProgressUnits::Bytes)); @@ -185,25 +189,35 @@ impl UploadingFile { expected_size: None, written: 0, error: None, + complete: false, }); - let tmp_dir = Arc::new(TmpDir::new().await?); - let file = create_file(tmp_dir.join("upload.tmp")).await?; + let file = create_file(path).await?; let uploading = Self { - tmp_dir: tmp_dir.clone(), + tmp_dir: None, file: MultiCursorFile::open(&file).await?, progress: progress.1, }; Ok(( UploadHandle { - tmp_dir, + tmp_dir: None, file, progress: progress.0, }, uploading, )) } + pub async fn new(progress: PhaseProgressTrackerHandle) -> Result<(UploadHandle, Self), Error> { + let tmp_dir = Arc::new(TmpDir::new().await?); + let (mut handle, mut file) = Self::with_path(tmp_dir.join("upload.tmp"), progress).await?; + handle.tmp_dir = Some(tmp_dir.clone()); + file.tmp_dir = Some(tmp_dir); + Ok((handle, file)) + } + pub async fn wait_for_complete(&self) -> Result<(), Error> { + Progress::ready(&mut self.progress.clone()).await + } pub async fn delete(self) -> Result<(), Error> { - if let Ok(tmp_dir) = Arc::try_unwrap(self.tmp_dir) { + if let Some(Ok(tmp_dir)) = self.tmp_dir.map(Arc::try_unwrap) { tmp_dir.delete().await?; } Ok(()) @@ -234,7 +248,7 @@ impl ArchiveSource for UploadingFile { #[pin_project::pin_project(project = UploadingFileReaderProjection)] pub struct UploadingFileReader { - tmp_dir: Arc, + tmp_dir: Option>, position: u64, to_seek: Option, #[pin] @@ -330,7 +344,7 @@ impl AsyncSeek for UploadingFileReader { #[pin_project::pin_project(PinnedDrop)] pub struct UploadHandle { - tmp_dir: Arc, + tmp_dir: Option>, #[pin] file: File, progress: watch::Sender, @@ -377,6 +391,9 @@ impl UploadHandle { break; } } + if let Err(e) = self.file.sync_all().await { + self.progress.send_if_modified(|p| p.handle_error(&e)); + } } } #[pin_project::pinned_drop] diff --git a/core/startos/src/version/mod.rs b/core/startos/src/version/mod.rs index 9f1feb571..0df8ff57a 100644 --- a/core/startos/src/version/mod.rs +++ b/core/startos/src/version/mod.rs @@ -54,8 +54,9 @@ mod v0_4_0_alpha_11; mod v0_4_0_alpha_12; mod v0_4_0_alpha_13; mod v0_4_0_alpha_14; +mod v0_4_0_alpha_15; -pub type Current = v0_4_0_alpha_14::Version; // VERSION_BUMP +pub type Current = v0_4_0_alpha_15::Version; // VERSION_BUMP impl Current { #[instrument(skip(self, db))] @@ -171,7 +172,8 @@ enum Version { V0_4_0_alpha_11(Wrapper), V0_4_0_alpha_12(Wrapper), V0_4_0_alpha_13(Wrapper), - V0_4_0_alpha_14(Wrapper), // VERSION_BUMP + V0_4_0_alpha_14(Wrapper), + V0_4_0_alpha_15(Wrapper), // VERSION_BUMP Other(exver::Version), } @@ -228,7 +230,8 @@ impl Version { Self::V0_4_0_alpha_11(v) => DynVersion(Box::new(v.0)), Self::V0_4_0_alpha_12(v) => DynVersion(Box::new(v.0)), Self::V0_4_0_alpha_13(v) => DynVersion(Box::new(v.0)), - Self::V0_4_0_alpha_14(v) => DynVersion(Box::new(v.0)), // VERSION_BUMP + Self::V0_4_0_alpha_14(v) => DynVersion(Box::new(v.0)), + Self::V0_4_0_alpha_15(v) => DynVersion(Box::new(v.0)), // VERSION_BUMP Self::Other(v) => { return Err(Error::new( eyre!("unknown version {v}"), @@ -277,7 +280,8 @@ impl Version { Version::V0_4_0_alpha_11(Wrapper(x)) => x.semver(), Version::V0_4_0_alpha_12(Wrapper(x)) => x.semver(), Version::V0_4_0_alpha_13(Wrapper(x)) => x.semver(), - Version::V0_4_0_alpha_14(Wrapper(x)) => x.semver(), // VERSION_BUMP + Version::V0_4_0_alpha_14(Wrapper(x)) => x.semver(), + Version::V0_4_0_alpha_15(Wrapper(x)) => x.semver(), // VERSION_BUMP Version::Other(x) => x.clone(), } } diff --git a/core/startos/src/version/v0_4_0_alpha_15.rs b/core/startos/src/version/v0_4_0_alpha_15.rs new file mode 100644 index 000000000..bce4f3949 --- /dev/null +++ b/core/startos/src/version/v0_4_0_alpha_15.rs @@ -0,0 +1,37 @@ +use exver::{PreReleaseSegment, VersionRange}; + +use super::v0_3_5::V0_3_0_COMPAT; +use super::{VersionT, v0_4_0_alpha_14}; +use crate::prelude::*; + +lazy_static::lazy_static! { + static ref V0_4_0_alpha_15: exver::Version = exver::Version::new( + [0, 4, 0], + [PreReleaseSegment::String("alpha".into()), 15.into()] + ); +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct Version; + +impl VersionT for Version { + type Previous = v0_4_0_alpha_14::Version; + type PreUpRes = (); + + async fn pre_up(self) -> Result { + Ok(()) + } + fn semver(self) -> exver::Version { + V0_4_0_alpha_15.clone() + } + fn compat(self) -> &'static VersionRange { + &V0_3_0_COMPAT + } + #[instrument(skip_all)] + fn up(self, _db: &mut Value, _: Self::PreUpRes) -> Result { + Ok(Value::Null) + } + fn down(self, _db: &mut Value) -> Result<(), Error> { + Ok(()) + } +} diff --git a/image-recipe/build.sh b/image-recipe/build.sh index 179ca779f..516b3f2a8 100755 --- a/image-recipe/build.sh +++ b/image-recipe/build.sh @@ -205,6 +205,7 @@ fi useradd --shell /bin/bash -G startos -m start9 echo start9:embassy | chpasswd usermod -aG sudo start9 +usermod -aG systemd-journal start9 echo "start9 ALL=(ALL:ALL) NOPASSWD: ALL" | sudo tee "/etc/sudoers.d/010_start9-nopasswd" diff --git a/sdk/package/lib/StartSdk.ts b/sdk/package/lib/StartSdk.ts index 6045dc961..01788f00e 100644 --- a/sdk/package/lib/StartSdk.ts +++ b/sdk/package/lib/StartSdk.ts @@ -61,7 +61,7 @@ import { } from "../../base/lib/inits" import { DropGenerator } from "../../base/lib/util/Drop" -export const OSVersion = testTypeVersion("0.4.0-alpha.14") +export const OSVersion = testTypeVersion("0.4.0-alpha.15") // prettier-ignore type AnyNeverCond = diff --git a/web/package-lock.json b/web/package-lock.json index 99441370f..ceaadff03 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "startos-ui", - "version": "0.4.0-alpha.14", + "version": "0.4.0-alpha.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "startos-ui", - "version": "0.4.0-alpha.14", + "version": "0.4.0-alpha.15", "license": "MIT", "dependencies": { "@angular/animations": "^20.3.0", diff --git a/web/package.json b/web/package.json index bf00db71b..a571a44f5 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "startos-ui", - "version": "0.4.0-alpha.14", + "version": "0.4.0-alpha.15", "author": "Start9 Labs, Inc", "homepage": "https://start9.com/", "license": "MIT", diff --git a/web/projects/marketplace/src/pages/show/about.component.ts b/web/projects/marketplace/src/pages/show/about.component.ts index 17f432e33..16f680a59 100644 --- a/web/projects/marketplace/src/pages/show/about.component.ts +++ b/web/projects/marketplace/src/pages/show/about.component.ts @@ -70,6 +70,7 @@ import { MarketplaceItemComponent } from './item.component'
+

{{ 'Description' | i18n }}

diff --git a/web/projects/ui/src/app/routes/portal/routes/services/routes/actions.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/routes/actions.component.ts index 33a332a16..b62be6515 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/routes/actions.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/routes/actions.component.ts @@ -73,9 +73,7 @@ export default class ServiceActionsRoute { .pipe( filter(pkg => pkg.stateInfo.state === 'installed'), map(pkg => { - const specialGroup = Object.values(pkg.actions).some( - pkg => !!pkg.group, - ) + const specialGroup = Object.values(pkg.actions).some(a => !!a.group) ? 'Other' : 'General' return { @@ -90,9 +88,15 @@ export default class ServiceActionsRoute { group: action.group || specialGroup, })) .sort((a, b) => { - if (a.group === specialGroup) return 1 - if (b.group === specialGroup) return -1 - return a.group.localeCompare(b.group) // Optional: sort others alphabetically + if (a.group === specialGroup && b.group !== specialGroup) + return 1 + if (b.group === specialGroup && a.group !== specialGroup) + return -1 + + const groupCompare = a.group.localeCompare(b.group) // sort groups lexicographically + if (groupCompare !== 0) return groupCompare + + return a.id.localeCompare(b.id) // sort actions within groups lexicographically }) .reduce< Record<