From efd90d3bdf0c7d09e34b80303336824b727d0ae0 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Sun, 8 Mar 2026 21:40:33 -0600 Subject: [PATCH 01/71] refactor: add delete_dir utility and use across codebase Adds a delete_dir helper that ignores NotFound errors (matching the existing delete_file pattern) and replaces the repeated metadata-check-then-remove_dir_all pattern throughout the codebase. --- core/src/backup/backup_bulk.rs | 4 +--- core/src/disk/mount/backup.rs | 4 +--- core/src/init.rs | 12 +++--------- core/src/service/uninstall.rs | 12 +++++------- core/src/setup.rs | 4 +--- core/src/util/io.rs | 14 ++++++++++++++ 6 files changed, 25 insertions(+), 25 deletions(-) diff --git a/core/src/backup/backup_bulk.rs b/core/src/backup/backup_bulk.rs index 722498f3c..4c95bcad9 100644 --- a/core/src/backup/backup_bulk.rs +++ b/core/src/backup/backup_bulk.rs @@ -323,9 +323,7 @@ async fn perform_backup( os_backup_file.save().await?; let luks_folder_old = backup_guard.path().join("luks.old"); - if tokio::fs::metadata(&luks_folder_old).await.is_ok() { - tokio::fs::remove_dir_all(&luks_folder_old).await?; - } + crate::util::io::delete_dir(&luks_folder_old).await?; let luks_folder_bak = backup_guard.path().join("luks"); if tokio::fs::metadata(&luks_folder_bak).await.is_ok() { tokio::fs::rename(&luks_folder_bak, &luks_folder_old).await?; diff --git a/core/src/disk/mount/backup.rs b/core/src/disk/mount/backup.rs index 2c89981dc..f2b232ca0 100644 --- a/core/src/disk/mount/backup.rs +++ b/core/src/disk/mount/backup.rs @@ -53,9 +53,7 @@ impl BackupMountGuard { })?, )? } else { - if tokio::fs::metadata(&crypt_path).await.is_ok() { - tokio::fs::remove_dir_all(&crypt_path).await?; - } + crate::util::io::delete_dir(&crypt_path).await?; Default::default() }; let enc_key = if let (Some(hash), Some(wrapped_key)) = ( diff --git a/core/src/init.rs b/core/src/init.rs index 8b6a91625..e5792adea 100644 --- a/core/src/init.rs +++ b/core/src/init.rs @@ -291,21 +291,15 @@ pub async fn init( init_tmp.start(); let tmp_dir = Path::new(PACKAGE_DATA).join("tmp"); - if tokio::fs::metadata(&tmp_dir).await.is_ok() { - tokio::fs::remove_dir_all(&tmp_dir).await?; - } + crate::util::io::delete_dir(&tmp_dir).await?; if tokio::fs::metadata(&tmp_dir).await.is_err() { tokio::fs::create_dir_all(&tmp_dir).await?; } let tmp_var = Path::new(PACKAGE_DATA).join("tmp/var"); - if tokio::fs::metadata(&tmp_var).await.is_ok() { - tokio::fs::remove_dir_all(&tmp_var).await?; - } + crate::util::io::delete_dir(&tmp_var).await?; crate::disk::mount::util::bind(&tmp_var, "/var/tmp", false).await?; let downloading = Path::new(PACKAGE_DATA).join("archive/downloading"); - if tokio::fs::metadata(&downloading).await.is_ok() { - tokio::fs::remove_dir_all(&downloading).await?; - } + crate::util::io::delete_dir(&downloading).await?; let tmp_docker = Path::new(PACKAGE_DATA).join("tmp").join(*CONTAINER_TOOL); crate::disk::mount::util::bind(&tmp_docker, *CONTAINER_DATADIR, false).await?; init_tmp.complete(); diff --git a/core/src/service/uninstall.rs b/core/src/service/uninstall.rs index 2f6515024..ec8b92468 100644 --- a/core/src/service/uninstall.rs +++ b/core/src/service/uninstall.rs @@ -101,13 +101,11 @@ pub async fn cleanup(ctx: &RpcContext, id: &PackageId, soft: bool) -> Result<(), if !soft { let path = Path::new(DATA_DIR).join(PKG_VOLUME_DIR).join(&manifest.id); - if tokio::fs::metadata(&path).await.is_ok() { - tokio::fs::remove_dir_all(&path).await?; - } - let logs_dir = Path::new(PACKAGE_DATA).join("logs").join(&manifest.id); - if tokio::fs::metadata(&logs_dir).await.is_ok() { - #[cfg(not(feature = "dev"))] - tokio::fs::remove_dir_all(&logs_dir).await?; + crate::util::io::delete_dir(&path).await?; + #[cfg(not(feature = "dev"))] + { + let logs_dir = Path::new(PACKAGE_DATA).join("logs").join(&manifest.id); + crate::util::io::delete_dir(&logs_dir).await?; } } }, diff --git a/core/src/setup.rs b/core/src/setup.rs index 2166cfc08..850647752 100644 --- a/core/src/setup.rs +++ b/core/src/setup.rs @@ -738,9 +738,7 @@ async fn migrate( ); let tmpdir = Path::new(package_data_transfer_args.0).join("tmp"); - if tokio::fs::metadata(&tmpdir).await.is_ok() { - tokio::fs::remove_dir_all(&tmpdir).await?; - } + crate::util::io::delete_dir(&tmpdir).await?; let ordering = std::sync::atomic::Ordering::Relaxed; diff --git a/core/src/util/io.rs b/core/src/util/io.rs index 99940c373..f1478e8b0 100644 --- a/core/src/util/io.rs +++ b/core/src/util/io.rs @@ -1047,6 +1047,20 @@ pub async fn delete_file(path: impl AsRef) -> Result<(), Error> { .with_ctx(|_| (ErrorKind::Filesystem, lazy_format!("delete {path:?}"))) } +pub async fn delete_dir(path: impl AsRef) -> Result<(), Error> { + let path = path.as_ref(); + tokio::fs::remove_dir_all(path) + .await + .or_else(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + Ok(()) + } else { + Err(e) + } + }) + .with_ctx(|_| (ErrorKind::Filesystem, lazy_format!("delete dir {path:?}"))) +} + #[instrument(skip_all)] pub async fn rename(src: impl AsRef, dst: impl AsRef) -> Result<(), Error> { let src = src.as_ref(); From 95a519cbe8fa5e8f954e3a3dbd7e92b380d48d0d Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Sun, 8 Mar 2026 21:40:55 -0600 Subject: [PATCH 02/71] feat: improve service version migration and data version handling Extract get_data_version into a shared function used by both effects and service_map. Use the actual data version (instead of the previous package version) when computing migration targets, and skip migrations when the target range is unsatisfiable. Also detect install vs update based on the presence of a data version file rather than load disposition alone. --- core/src/service/effects/version.rs | 10 +--- core/src/service/mod.rs | 31 +++++++++-- core/src/service/rpc.rs | 6 ++ core/src/service/service_map.rs | 74 ++++++++++++++++--------- sdk/package/lib/version/VersionGraph.ts | 3 + 5 files changed, 87 insertions(+), 37 deletions(-) diff --git a/core/src/service/effects/version.rs b/core/src/service/effects/version.rs index 185e1f629..7b82e060c 100644 --- a/core/src/service/effects/version.rs +++ b/core/src/service/effects/version.rs @@ -2,7 +2,7 @@ use std::path::Path; use crate::DATA_DIR; use crate::service::effects::prelude::*; -use crate::util::io::{delete_file, maybe_read_file_to_string, write_file_atomic}; +use crate::util::io::{delete_file, write_file_atomic}; use crate::volume::PKG_VOLUME_DIR; #[derive(Debug, Clone, Serialize, Deserialize, TS, Parser)] @@ -36,11 +36,5 @@ pub async fn set_data_version( #[instrument(skip_all)] pub async fn get_data_version(context: EffectContext) -> Result, Error> { let context = context.deref()?; - let package_id = &context.seed.id; - let path = Path::new(DATA_DIR) - .join(PKG_VOLUME_DIR) - .join(package_id) - .join("data") - .join(".version"); - maybe_read_file_to_string(path).await + crate::service::get_data_version(&context.seed.id).await } diff --git a/core/src/service/mod.rs b/core/src/service/mod.rs index f17f2d266..d904e77c9 100644 --- a/core/src/service/mod.rs +++ b/core/src/service/mod.rs @@ -46,12 +46,14 @@ use crate::service::uninstall::cleanup; use crate::util::Never; use crate::util::actor::concurrent::ConcurrentActor; use crate::util::future::NonDetachingJoinHandle; -use crate::util::io::{AsyncReadStream, AtomicFile, TermSize, delete_file}; +use crate::util::io::{ + AsyncReadStream, AtomicFile, TermSize, delete_file, maybe_read_file_to_string, +}; use crate::util::net::WebSocket; use crate::util::serde::Pem; use crate::util::sync::SyncMutex; use crate::util::tui::choose; -use crate::volume::data_dir; +use crate::volume::{PKG_VOLUME_DIR, data_dir}; use crate::{ActionId, CAP_1_KiB, DATA_DIR, ImageId, PackageId}; pub mod action; @@ -81,6 +83,17 @@ pub enum LoadDisposition { Undo, } +/// Read the data version file for a service from disk. +/// Returns `Ok(None)` if the file does not exist (fresh install). +pub async fn get_data_version(id: &PackageId) -> Result, Error> { + let path = Path::new(DATA_DIR) + .join(PKG_VOLUME_DIR) + .join(id) + .join("data") + .join(".version"); + maybe_read_file_to_string(&path).await +} + struct RootCommand(pub String); #[derive(Clone, Debug, Serialize, Deserialize, Default, TS)] @@ -390,12 +403,17 @@ impl Service { tracing::error!("Error opening s9pk for install: {e}"); tracing::debug!("{e:?}") }) { + let init_kind = if get_data_version(id).await.ok().flatten().is_some() { + InitKind::Update + } else { + InitKind::Install + }; if let Ok(service) = Self::install( ctx.clone(), s9pk, &s9pk_path, &None, - InitKind::Install, + init_kind, None::, None, ) @@ -424,12 +442,17 @@ impl Service { tracing::error!("Error opening s9pk for update: {e}"); tracing::debug!("{e:?}") }) { + let init_kind = if get_data_version(id).await.ok().flatten().is_some() { + InitKind::Update + } else { + InitKind::Install + }; if let Ok(service) = Self::install( ctx.clone(), s9pk, &s9pk_path, &None, - InitKind::Update, + init_kind, None::, None, ) diff --git a/core/src/service/rpc.rs b/core/src/service/rpc.rs index b5c8ed01c..94c15f42f 100644 --- a/core/src/service/rpc.rs +++ b/core/src/service/rpc.rs @@ -107,6 +107,12 @@ impl ExitParams { target: Some(InternedString::from_display(range)), } } + pub fn target_str(s: &str) -> Self { + Self { + id: Guid::new(), + target: Some(InternedString::intern(s)), + } + } pub fn uninstall() -> Self { Self { id: Guid::new(), diff --git a/core/src/service/service_map.rs b/core/src/service/service_map.rs index 697578a7c..80a6ea5d4 100644 --- a/core/src/service/service_map.rs +++ b/core/src/service/service_map.rs @@ -28,7 +28,7 @@ use crate::s9pk::S9pk; use crate::s9pk::manifest::PackageId; use crate::s9pk::merkle_archive::source::FileSource; use crate::service::rpc::{ExitParams, InitKind}; -use crate::service::{LoadDisposition, Service, ServiceRef}; +use crate::service::{LoadDisposition, Service, ServiceRef, get_data_version}; use crate::sign::commitment::merkle_archive::MerkleArchiveCommitment; use crate::status::{DesiredStatus, StatusInfo}; use crate::util::future::NonDetachingJoinHandle; @@ -310,36 +310,60 @@ impl ServiceMap { .handle_last(async move { finalization_progress.start(); let s9pk = S9pk::open(&installed_path, Some(&id)).await?; + let data_version = get_data_version(&id).await?; let prev = if let Some(service) = service.take() { ensure_code!( recovery_source.is_none(), ErrorKind::InvalidRequest, "cannot restore over existing package" ); - let prev_version = service - .seed - .persistent_container - .s9pk - .as_manifest() - .version - .clone(); - let prev_can_migrate_to = &service - .seed - .persistent_container - .s9pk - .as_manifest() - .can_migrate_to; - let next_version = &s9pk.as_manifest().version; - let next_can_migrate_from = &s9pk.as_manifest().can_migrate_from; - let uninit = if prev_version.satisfies(next_can_migrate_from) { - ExitParams::target_version(&*prev_version) - } else if next_version.satisfies(prev_can_migrate_to) { - ExitParams::target_version(&s9pk.as_manifest().version) + let uninit = if let Some(ref data_ver) = data_version { + let prev_can_migrate_to = &service + .seed + .persistent_container + .s9pk + .as_manifest() + .can_migrate_to; + let next_version = &s9pk.as_manifest().version; + let next_can_migrate_from = + &s9pk.as_manifest().can_migrate_from; + if let Ok(data_ver_ev) = + data_ver.parse::() + { + if data_ver_ev.satisfies(next_can_migrate_from) { + ExitParams::target_str(data_ver) + } else if next_version.satisfies(prev_can_migrate_to) { + ExitParams::target_version(&s9pk.as_manifest().version) + } else { + ExitParams::target_range(&VersionRange::and( + prev_can_migrate_to.clone(), + next_can_migrate_from.clone(), + )) + } + } else if let Ok(data_ver_range) = + data_ver.parse::() + { + ExitParams::target_range(&VersionRange::and( + data_ver_range, + next_can_migrate_from.clone(), + )) + } else if next_version.satisfies(prev_can_migrate_to) { + ExitParams::target_version(&s9pk.as_manifest().version) + } else { + ExitParams::target_range(&VersionRange::and( + prev_can_migrate_to.clone(), + next_can_migrate_from.clone(), + )) + } } else { - ExitParams::target_range(&VersionRange::and( - prev_can_migrate_to.clone(), - next_can_migrate_from.clone(), - )) + ExitParams::target_version( + &*service + .seed + .persistent_container + .s9pk + .as_manifest() + .version, + ) }; let cleanup = service.uninstall(uninit, false, false).await?; progress.complete(); @@ -354,7 +378,7 @@ impl ServiceMap { ®istry, if recovery_source.is_some() { InitKind::Restore - } else if prev.is_some() { + } else if data_version.is_some() { InitKind::Update } else { InitKind::Install diff --git a/sdk/package/lib/version/VersionGraph.ts b/sdk/package/lib/version/VersionGraph.ts index 84d24269e..2b82c67c3 100644 --- a/sdk/package/lib/version/VersionGraph.ts +++ b/sdk/package/lib/version/VersionGraph.ts @@ -331,6 +331,9 @@ export class VersionGraph target: VersionRange | ExtendedVersion | null, ): Promise { if (target) { + if (isRange(target) && !target.satisfiable()) { + return + } const from = await getDataVersion(effects) if (from) { target = await this.migrate({ From ba71f205dd68cb06309624eeeb0dbfdf4174cc3d Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Sun, 8 Mar 2026 21:41:05 -0600 Subject: [PATCH 03/71] fix: mark private domain hostnames as non-public --- core/src/net/host/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/core/src/net/host/mod.rs b/core/src/net/host/mod.rs index c77b4aa26..941cc15f3 100644 --- a/core/src/net/host/mod.rs +++ b/core/src/net/host/mod.rs @@ -283,7 +283,7 @@ impl Model { }; available.insert(HostnameInfo { ssl: opt.secure.map_or(false, |s| s.ssl), - public: true, + public: false, hostname: domain.clone(), port: Some(port), metadata: HostnameMetadata::PrivateDomain { gateways }, @@ -300,7 +300,7 @@ impl Model { } available.insert(HostnameInfo { ssl: true, - public: true, + public: false, hostname: domain, port: Some(port), metadata: HostnameMetadata::PrivateDomain { @@ -314,7 +314,7 @@ impl Model { { available.insert(HostnameInfo { ssl: true, - public: true, + public: false, hostname: domain, port: Some(opt.preferred_external_port), metadata: HostnameMetadata::PrivateDomain { From 68ae365897b25a5d69dcc1d54b91acf15478e48b Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Sun, 8 Mar 2026 21:41:15 -0600 Subject: [PATCH 04/71] feat: add bridge filter kind to service interface Adds 'bridge' as a FilterKind to exclude LXC bridge interface hostnames from non-local service interfaces. --- sdk/base/lib/util/getServiceInterface.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/sdk/base/lib/util/getServiceInterface.ts b/sdk/base/lib/util/getServiceInterface.ts index e0cedc529..944e1c6b6 100644 --- a/sdk/base/lib/util/getServiceInterface.ts +++ b/sdk/base/lib/util/getServiceInterface.ts @@ -36,6 +36,7 @@ export const getHostname = (url: string): Hostname | null => { * - `'ipv6'` — IPv6 addresses only * - `'localhost'` — loopback addresses (`localhost`, `127.0.0.1`, `::1`) * - `'link-local'` — IPv6 link-local addresses (fe80::/10) + * - `'bridge'` — The LXC bridge interface * - `'plugin'` — hostnames provided by a plugin package */ type FilterKinds = @@ -46,6 +47,7 @@ type FilterKinds = | 'ipv6' | 'localhost' | 'link-local' + | 'bridge' | 'plugin' /** @@ -120,7 +122,11 @@ type FilterReturnTy = F extends { const nonLocalFilter = { exclude: { - kind: ['localhost', 'link-local'] as ('localhost' | 'link-local')[], + kind: ['localhost', 'link-local', 'bridge'] as ( + | 'localhost' + | 'link-local' + | 'bridge' + )[], }, } as const const publicFilter = { @@ -284,6 +290,9 @@ function filterRec( (kind.has('link-local') && h.metadata.kind === 'ipv6' && IPV6_LINK_LOCAL.contains(IpAddress.parse(h.hostname))) || + (kind.has('bridge') && + h.metadata.kind === 'ipv4' && + h.metadata.gateway === 'lxcbr0') || (kind.has('plugin') && h.metadata.kind === 'plugin')), ) } From ea8a7c0a57a974590bc7a198979909128753c690 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Sun, 8 Mar 2026 21:41:21 -0600 Subject: [PATCH 05/71] feat: add s9pk inspect commitment subcommand --- core/src/s9pk/rpc.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/core/src/s9pk/rpc.rs b/core/src/s9pk/rpc.rs index 2fafd7e0c..e84be8406 100644 --- a/core/src/s9pk/rpc.rs +++ b/core/src/s9pk/rpc.rs @@ -17,6 +17,7 @@ use crate::s9pk::manifest::{HardwareRequirements, Manifest}; use crate::s9pk::merkle_archive::source::multi_cursor_file::MultiCursorFile; use crate::s9pk::v2::SIG_CONTEXT; use crate::s9pk::v2::pack::ImageConfig; +use crate::sign::commitment::merkle_archive::MerkleArchiveCommitment; use crate::util::io::{TmpDir, create_file, open_file}; use crate::util::serde::{HandlerExtSerde, apply_expr}; use crate::util::{Apply, Invoke}; @@ -131,6 +132,13 @@ fn inspect() -> ParentHandler { .with_display_serializable() .with_about("about.display-s9pk-manifest"), ) + .subcommand( + "commitment", + from_fn_async(inspect_commitment) + .with_inherited(only_parent) + .with_display_serializable() + .with_about("about.display-s9pk-root-sighash-and-maxsize"), + ) } #[derive(Deserialize, Serialize, Parser, TS)] @@ -262,6 +270,15 @@ async fn inspect_manifest( Ok(s9pk.as_manifest().clone()) } +async fn inspect_commitment( + _: CliContext, + _: Empty, + S9pkPath { s9pk: s9pk_path }: S9pkPath, +) -> Result { + let s9pk = super::S9pk::open(&s9pk_path, None).await?; + s9pk.as_archive().commitment().await +} + async fn convert(ctx: CliContext, S9pkPath { s9pk: s9pk_path }: S9pkPath) -> Result<(), Error> { let mut s9pk = super::load( MultiCursorFile::from(open_file(&s9pk_path).await?), From 5316d6ea68ee54940dbb82a0e152b51e47b9ef02 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Sun, 8 Mar 2026 21:41:47 -0600 Subject: [PATCH 06/71] chore: update patch-db submodule Updates patch-db submodule and adjusts Cargo.toml path from patch-db/patch-db to patch-db/core. Switches from serde_cbor to ciborium. --- core/Cargo.lock | 12 ++---------- core/Cargo.toml | 4 +--- patch-db | 2 +- 3 files changed, 4 insertions(+), 14 deletions(-) diff --git a/core/Cargo.lock b/core/Cargo.lock index 739b886cf..0b7389bc6 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -4345,6 +4345,7 @@ name = "patch-db" version = "0.1.0" dependencies = [ "async-trait", + "ciborium", "fd-lock-rs", "futures", "imbl", @@ -4355,7 +4356,6 @@ dependencies = [ "nix 0.30.1", "patch-db-macro", "serde", - "serde_cbor 0.11.1", "thiserror 2.0.18", "tokio", "tracing", @@ -5377,7 +5377,7 @@ dependencies = [ "pin-project", "reqwest", "serde", - "serde_cbor 0.11.2", + "serde_cbor", "serde_json", "thiserror 2.0.18", "tokio", @@ -5783,14 +5783,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_cbor" -version = "0.11.1" -dependencies = [ - "half 1.8.3", - "serde", -] - [[package]] name = "serde_cbor" version = "0.11.2" diff --git a/core/Cargo.toml b/core/Cargo.toml index 9937dfaa1..cb1eb3c51 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -170,9 +170,7 @@ once_cell = "1.19.0" openssh-keys = "0.6.2" openssl = { version = "0.10.57", features = ["vendored"] } p256 = { version = "0.13.2", features = ["pem"] } -patch-db = { version = "*", path = "../patch-db/patch-db", features = [ - "trace", -] } +patch-db = { version = "*", path = "../patch-db/core", features = ["trace"] } pbkdf2 = "0.12.2" pin-project = "1.1.3" pkcs8 = { version = "0.10.2", features = ["std"] } diff --git a/patch-db b/patch-db index 05c93290c..003cb1dcf 160000 --- a/patch-db +++ b/patch-db @@ -1 +1 @@ -Subproject commit 05c93290c759bdf5e7308a24cf0d4a440ed287a0 +Subproject commit 003cb1dcf2a59330f7363cba2a8e81109acef85d From f56262b8454bab57a3ee57c357261c46cd68723c Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Sun, 8 Mar 2026 21:41:57 -0600 Subject: [PATCH 07/71] chore: remove --unhandled-rejections=warn from container-runtime --- container-runtime/container-runtime.service | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/container-runtime/container-runtime.service b/container-runtime/container-runtime.service index ed9d142f7..f04150969 100644 --- a/container-runtime/container-runtime.service +++ b/container-runtime/container-runtime.service @@ -5,7 +5,7 @@ OnFailure=container-runtime-failure.service [Service] Type=simple Environment=RUST_LOG=startos=debug -ExecStart=/usr/bin/node --experimental-detect-module --trace-warnings --unhandled-rejections=warn /usr/lib/startos/init/index.js +ExecStart=/usr/bin/node --experimental-detect-module --trace-warnings /usr/lib/startos/init/index.js Restart=no [Install] From 36bf55c133fee75a5a657c41cbe8c14265024f20 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Sun, 8 Mar 2026 21:42:10 -0600 Subject: [PATCH 08/71] chore: restructure release signatures into subdirectory Moves GPG signatures and keys into a signatures/ subdirectory before packing into signatures.tar.gz, preventing glob collisions. --- build/manage-release.sh | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/build/manage-release.sh b/build/manage-release.sh index c3b71717a..94387d2a6 100755 --- a/build/manage-release.sh +++ b/build/manage-release.sh @@ -198,20 +198,22 @@ cmd_sign() { enter_release_dir resolve_gh_user + mkdir -p signatures + for file in $(release_files); do - gpg -u $START9_GPG_KEY --detach-sign --armor -o "${file}.start9.asc" "$file" + gpg -u $START9_GPG_KEY --detach-sign --armor -o "signatures/${file}.start9.asc" "$file" if [ -n "$GH_USER" ] && [ -n "$GH_GPG_KEY" ]; then - gpg -u "$GH_GPG_KEY" --detach-sign --armor -o "${file}.${GH_USER}.asc" "$file" + gpg -u "$GH_GPG_KEY" --detach-sign --armor -o "signatures/${file}.${GH_USER}.asc" "$file" fi done - gpg --export -a $START9_GPG_KEY > start9.key.asc + gpg --export -a $START9_GPG_KEY > signatures/start9.key.asc if [ -n "$GH_USER" ] && [ -n "$GH_GPG_KEY" ]; then - gpg --export -a "$GH_GPG_KEY" > "${GH_USER}.key.asc" + gpg --export -a "$GH_GPG_KEY" > "signatures/${GH_USER}.key.asc" else >&2 echo 'Warning: could not determine GitHub user or GPG signing key, skipping personal signature' fi - tar -czvf signatures.tar.gz *.asc + tar -czvf signatures.tar.gz -C signatures . gh release upload -R $REPO "v$VERSION" signatures.tar.gz --clobber } @@ -229,17 +231,18 @@ cmd_cosign() { echo "Downloading existing signatures..." gh release download -R $REPO "v$VERSION" -p "signatures.tar.gz" -D "$(pwd)" --clobber - tar -xzf signatures.tar.gz + mkdir -p signatures + tar -xzf signatures.tar.gz -C signatures echo "Adding personal signatures as $GH_USER..." for file in $(release_files); do - gpg -u "$GH_GPG_KEY" --detach-sign --armor -o "${file}.${GH_USER}.asc" "$file" + gpg -u "$GH_GPG_KEY" --detach-sign --armor -o "signatures/${file}.${GH_USER}.asc" "$file" done - gpg --export -a "$GH_GPG_KEY" > "${GH_USER}.key.asc" + gpg --export -a "$GH_GPG_KEY" > "signatures/${GH_USER}.key.asc" echo "Re-packing signatures..." - tar -czvf signatures.tar.gz *.asc + tar -czvf signatures.tar.gz -C signatures . gh release upload -R $REPO "v$VERSION" signatures.tar.gz --clobber echo "Done. Personal signatures for $GH_USER added to v$VERSION." From 8ef4ef48955107aaaffb8da100fdcb44afdf8a01 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Sun, 8 Mar 2026 21:42:17 -0600 Subject: [PATCH 09/71] fix: add ca-certificates dependency to registry-deb --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 7ab474909..07aabb16f 100644 --- a/Makefile +++ b/Makefile @@ -155,7 +155,7 @@ results/$(BASENAME).deb: debian/dpkg-build.sh $(call ls-files,debian/startos) $( registry-deb: results/$(REGISTRY_BASENAME).deb results/$(REGISTRY_BASENAME).deb: debian/dpkg-build.sh $(call ls-files,debian/start-registry) $(REGISTRY_TARGETS) - PROJECT=start-registry PLATFORM=$(ARCH) REQUIRES=debian ./build/os-compat/run-compat.sh ./debian/dpkg-build.sh + PROJECT=start-registry PLATFORM=$(ARCH) REQUIRES=debian DEPENDS=ca-certificates ./build/os-compat/run-compat.sh ./debian/dpkg-build.sh tunnel-deb: results/$(TUNNEL_BASENAME).deb From 43e514f9ee5fe66ed026223f0ff84a7d3995129c Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Sun, 8 Mar 2026 21:42:58 -0600 Subject: [PATCH 10/71] fix: improve NVIDIA driver build in image recipe Move enable-kiosk earlier (before NVIDIA hook), add pkg-config to NVIDIA build deps, clean up .run installer after use, blacklist nouveau, and rebuild initramfs after NVIDIA driver installation. --- build/image-recipe/build.sh | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/build/image-recipe/build.sh b/build/image-recipe/build.sh index 8bd27daf3..787f71844 100755 --- a/build/image-recipe/build.sh +++ b/build/image-recipe/build.sh @@ -209,6 +209,10 @@ cat > config/hooks/normal/9000-install-startos.hook.chroot << EOF set -e +if [ "${IB_TARGET_PLATFORM}" != "raspberrypi" ]; then + /usr/lib/startos/scripts/enable-kiosk +fi + if [ "${NVIDIA}" = "1" ]; then # install a specific NVIDIA driver version @@ -236,7 +240,7 @@ if [ "${NVIDIA}" = "1" ]; then echo "[nvidia-hook] Target kernel version: \${KVER}" >&2 # Ensure kernel headers are present - TEMP_APT_DEPS=(build-essential) + TEMP_APT_DEPS=(build-essential pkg-config) if [ ! -e "/lib/modules/\${KVER}/build" ]; then TEMP_APT_DEPS+=(linux-headers-\${KVER}) fi @@ -279,6 +283,16 @@ if [ "${NVIDIA}" = "1" ]; then echo "[nvidia-hook] NVIDIA \${NVIDIA_DRIVER_VERSION} installation complete for kernel \${KVER}" >&2 + echo "[nvidia-hook] Removing .run installer..." >&2 + rm -f "\${RUN_PATH}" + + echo "[nvidia-hook] Blacklisting nouveau..." >&2 + echo "blacklist nouveau" > /etc/modprobe.d/blacklist-nouveau.conf + echo "options nouveau modeset=0" >> /etc/modprobe.d/blacklist-nouveau.conf + + echo "[nvidia-hook] Rebuilding initramfs..." >&2 + update-initramfs -u -k "\${KVER}" + echo "[nvidia-hook] Removing build dependencies..." >&2 apt-get purge -y nvidia-depends apt-get autoremove -y @@ -310,10 +324,6 @@ usermod -aG systemd-journal start9 echo "start9 ALL=(ALL:ALL) NOPASSWD: ALL" | sudo tee "/etc/sudoers.d/010_start9-nopasswd" -if [ "${IB_TARGET_PLATFORM}" != "raspberrypi" ]; then - /usr/lib/startos/scripts/enable-kiosk -fi - if ! [[ "${IB_OS_ENV}" =~ (^|-)dev($|-) ]]; then passwd -l start9 fi From c52fcf50873862f9aa6fbdf9e9f4b0dcf9646666 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Mon, 9 Mar 2026 15:24:56 -0600 Subject: [PATCH 11/71] feat: add DbWatchedCallbacks abstraction, TypedDbWatch-based callbacks, and SDK watchable wrappers - Extract DbWatchedCallbacks abstraction in callbacks.rs using SyncMutex for the repeated patchdb subscribe-wait-fire-remove callback pattern - Move get_host_info and get_status callbacks to use TypedDbWatch instead of raw db.subscribe, eliminating race conditions between reading and watching - Make getStatus return Option to handle uninstalled packages - Add getStatus .const/.once/.watch/.onChange wrapper in container-runtime for legacy SystemForEmbassy adapter - Add SDK watchable wrapper classes for all callback-enabled effects: GetStatus, GetServiceManifest, GetHostInfo, GetContainerIp, GetSslCertificate --- .../Systems/SystemForEmbassy/index.ts | 75 ++++++++ core/src/service/effects/callbacks.rs | 177 ++++++++++-------- core/src/service/effects/control.rs | 25 ++- core/src/service/effects/net/host.rs | 24 ++- core/src/service/service_actor.rs | 8 - sdk/base/lib/Effects.ts | 2 +- sdk/base/lib/util/GetContainerIp.ts | 112 +++++++++++ sdk/base/lib/util/GetHostInfo.ts | 112 +++++++++++ sdk/base/lib/util/GetServiceManifest.ts | 112 +++++++++++ sdk/base/lib/util/GetSslCertificate.ts | 118 ++++++++++++ sdk/base/lib/util/GetStatus.ts | 116 ++++++++++++ sdk/base/lib/util/index.ts | 5 + 12 files changed, 784 insertions(+), 102 deletions(-) create mode 100644 sdk/base/lib/util/GetContainerIp.ts create mode 100644 sdk/base/lib/util/GetHostInfo.ts create mode 100644 sdk/base/lib/util/GetServiceManifest.ts create mode 100644 sdk/base/lib/util/GetSslCertificate.ts create mode 100644 sdk/base/lib/util/GetStatus.ts diff --git a/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts b/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts index 15a97178d..65a6c56e8 100644 --- a/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts +++ b/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts @@ -42,6 +42,74 @@ function todo(): never { throw new Error("Not implemented") } +function getStatus( + effects: Effects, + options: Omit[0], "callback"> = {}, +) { + async function* watch(abort?: AbortSignal) { + const resolveCell = { resolve: () => {} } + effects.onLeaveContext(() => { + resolveCell.resolve() + }) + abort?.addEventListener("abort", () => resolveCell.resolve()) + while (effects.isInContext && !abort?.aborted) { + let callback: () => void = () => {} + const waitForNext = new Promise((resolve) => { + callback = resolve + resolveCell.resolve = resolve + }) + yield await effects.getStatus({ ...options, callback }) + await waitForNext + } + } + return { + const: () => + effects.getStatus({ + ...options, + callback: + effects.constRetry && + (() => effects.constRetry && effects.constRetry()), + }), + once: () => effects.getStatus(options), + watch: (abort?: AbortSignal) => { + const ctrl = new AbortController() + abort?.addEventListener("abort", () => ctrl.abort()) + return watch(ctrl.signal) + }, + onChange: ( + callback: ( + value: T.StatusInfo | null, + error?: Error, + ) => { cancel: boolean } | Promise<{ cancel: boolean }>, + ) => { + ;(async () => { + const ctrl = new AbortController() + for await (const value of watch(ctrl.signal)) { + try { + const res = await callback(value) + if (res.cancel) { + ctrl.abort() + break + } + } catch (e) { + console.error( + "callback function threw an error @ getStatus.onChange", + e, + ) + } + } + })() + .catch((e) => callback(null, e as Error)) + .catch((e) => + console.error( + "callback function threw an error @ getStatus.onChange", + e, + ), + ) + }, + } +} + /** * Local type for procedure values from the manifest. * The manifest's zod schemas use ZodTypeAny casts that produce `unknown` in zod v4. @@ -1046,6 +1114,8 @@ export class SystemForEmbassy implements System { timeoutMs: number | null, ): Promise { // TODO: docker + const status = await getStatus(effects, { packageId: id }).const() + if (!status) return await effects.mount({ location: `/media/embassy/${id}`, target: { @@ -1204,6 +1274,11 @@ async function updateConfig( if (specValue.target === "config") { const jp = require("jsonpath") const depId = specValue["package-id"] + const depStatus = await getStatus(effects, { packageId: depId }).const() + if (!depStatus) { + mutConfigValue[key] = null + continue + } await effects.mount({ location: `/media/embassy/${depId}`, target: { diff --git a/core/src/service/effects/callbacks.rs b/core/src/service/effects/callbacks.rs index d30665c96..b50c526f8 100644 --- a/core/src/service/effects/callbacks.rs +++ b/core/src/service/effects/callbacks.rs @@ -1,6 +1,6 @@ use std::cmp::min; use std::collections::{BTreeMap, BTreeSet}; -use std::sync::{Arc, Mutex, Weak}; +use std::sync::{Arc, Weak}; use std::time::{Duration, SystemTime}; use clap::Parser; @@ -8,13 +8,12 @@ use futures::future::join_all; use imbl::{OrdMap, Vector, vector}; use imbl_value::InternedString; use patch_db::TypedDbWatch; -use patch_db::json_ptr::JsonPointer; use serde::{Deserialize, Serialize}; use tracing::warn; use ts_rs::TS; -use crate::db::model::Database; use crate::db::model::public::NetworkInterfaceInfo; +use crate::net::host::Host; use crate::net::ssl::FullchainCertData; use crate::prelude::*; use crate::service::effects::context::EffectContext; @@ -23,23 +22,104 @@ use crate::service::rpc::{CallbackHandle, CallbackId}; use crate::service::{Service, ServiceActorSeed}; use crate::util::collections::EqMap; use crate::util::future::NonDetachingJoinHandle; +use crate::util::sync::SyncMutex; +use crate::status::StatusInfo; use crate::{GatewayId, HostId, PackageId, ServiceInterfaceId}; -#[derive(Default)] -pub struct ServiceCallbacks(Mutex); +/// Abstraction for callbacks that are triggered by patchdb subscriptions. +/// +/// Handles the subscribe-wait-fire-remove pattern: when a callback is first +/// registered for a key, a patchdb subscription is spawned. When the subscription +/// fires, all handlers are consumed and invoked, then the subscription stops. +/// A new subscription is created if a handler is registered again. +pub struct DbWatchedCallbacks { + label: &'static str, + inner: SyncMutex, Vec)>>, +} + +impl DbWatchedCallbacks { + pub fn new(label: &'static str) -> Self { + Self { + label, + inner: SyncMutex::new(BTreeMap::new()), + } + } + + pub fn add( + self: &Arc, + key: K, + watch: TypedDbWatch, + handler: CallbackHandler, + ) { + self.inner.mutate(|map| { + map.entry(key.clone()) + .or_insert_with(|| { + let this = Arc::clone(self); + let k = key; + let label = self.label; + ( + tokio::spawn(async move { + let mut watch = watch.untyped(); + if watch.changed().await.is_ok() { + if let Some(cbs) = this.inner.mutate(|map| { + map.remove(&k) + .map(|(_, handlers)| CallbackHandlers(handlers)) + .filter(|cb| !cb.0.is_empty()) + }) { + let value = watch + .peek_and_mark_seen() + .unwrap_or_default(); + if let Err(e) = cbs.call(vector![value]).await { + tracing::error!("Error in {label} callback: {e}"); + tracing::debug!("{e:?}"); + } + } + } + }) + .into(), + Vec::new(), + ) + }) + .1 + .push(handler); + }) + } + + pub fn gc(&self) { + self.inner.mutate(|map| { + map.retain(|_, (_, v)| { + v.retain(|h| h.handle.is_active() && h.seed.strong_count() > 0); + !v.is_empty() + }); + }) + } +} + +pub struct ServiceCallbacks { + inner: SyncMutex, + get_host_info: Arc>, + get_status: Arc>, +} + +impl Default for ServiceCallbacks { + fn default() -> Self { + Self { + inner: SyncMutex::new(ServiceCallbackMap::default()), + get_host_info: Arc::new(DbWatchedCallbacks::new("host info")), + get_status: Arc::new(DbWatchedCallbacks::new("get_status")), + } + } +} #[derive(Default)] struct ServiceCallbackMap { get_service_interface: BTreeMap<(PackageId, ServiceInterfaceId), Vec>, list_service_interfaces: BTreeMap>, get_system_smtp: Vec, - get_host_info: - BTreeMap<(PackageId, HostId), (NonDetachingJoinHandle<()>, Vec)>, get_ssl_certificate: EqMap< (BTreeSet, FullchainCertData, Algorithm), (NonDetachingJoinHandle<()>, Vec), >, - get_status: BTreeMap>, get_container_ip: BTreeMap>, get_service_manifest: BTreeMap>, get_outbound_gateway: BTreeMap, Vec)>, @@ -47,8 +127,7 @@ struct ServiceCallbackMap { impl ServiceCallbacks { fn mutate(&self, f: impl FnOnce(&mut ServiceCallbackMap) -> T) -> T { - let mut this = self.0.lock().unwrap(); - f(&mut *this) + self.inner.mutate(f) } pub fn gc(&self) { @@ -63,18 +142,10 @@ impl ServiceCallbacks { }); this.get_system_smtp .retain(|h| h.handle.is_active() && h.seed.strong_count() > 0); - this.get_host_info.retain(|_, (_, v)| { - v.retain(|h| h.handle.is_active() && h.seed.strong_count() > 0); - !v.is_empty() - }); this.get_ssl_certificate.retain(|_, (_, v)| { v.retain(|h| h.handle.is_active() && h.seed.strong_count() > 0); !v.is_empty() }); - this.get_status.retain(|_, v| { - v.retain(|h| h.handle.is_active() && h.seed.strong_count() > 0); - !v.is_empty() - }); this.get_service_manifest.retain(|_, v| { v.retain(|h| h.handle.is_active() && h.seed.strong_count() > 0); !v.is_empty() @@ -83,7 +154,9 @@ impl ServiceCallbacks { v.retain(|h| h.handle.is_active() && h.seed.strong_count() > 0); !v.is_empty() }); - }) + }); + self.get_host_info.gc(); + self.get_status.gc(); } pub(super) fn add_get_service_interface( @@ -151,51 +224,14 @@ impl ServiceCallbacks { } pub(super) fn add_get_host_info( - self: &Arc, - db: &TypedPatchDb, + &self, package_id: PackageId, host_id: HostId, + watch: TypedDbWatch, handler: CallbackHandler, ) { - self.mutate(|this| { - this.get_host_info - .entry((package_id.clone(), host_id.clone())) - .or_insert_with(|| { - let ptr: JsonPointer = - format!("/public/packageData/{}/hosts/{}", package_id, host_id) - .parse() - .expect("valid json pointer"); - let db = db.clone(); - let callbacks = Arc::clone(self); - let key = (package_id, host_id); - ( - tokio::spawn(async move { - let mut sub = db.subscribe(ptr).await; - while sub.recv().await.is_some() { - if let Some(cbs) = callbacks.mutate(|this| { - this.get_host_info - .remove(&key) - .map(|(_, handlers)| CallbackHandlers(handlers)) - .filter(|cb| !cb.0.is_empty()) - }) { - if let Err(e) = cbs.call(vector![]).await { - tracing::error!("Error in host info callback: {e}"); - tracing::debug!("{e:?}"); - } - } - // entry was removed when we consumed handlers, - // so stop watching — a new subscription will be - // created if the service re-registers - break; - } - }) - .into(), - Vec::new(), - ) - }) - .1 - .push(handler); - }) + self.get_host_info + .add((package_id, host_id), watch, handler); } pub(super) fn add_get_ssl_certificate( @@ -256,19 +292,14 @@ impl ServiceCallbacks { .push(handler); }) } - pub(super) fn add_get_status(&self, package_id: PackageId, handler: CallbackHandler) { - self.mutate(|this| this.get_status.entry(package_id).or_default().push(handler)) - } - #[must_use] - pub fn get_status(&self, package_id: &PackageId) -> Option { - self.mutate(|this| { - if let Some(watched) = this.get_status.remove(package_id) { - Some(CallbackHandlers(watched)) - } else { - None - } - .filter(|cb| !cb.0.is_empty()) - }) + + pub(super) fn add_get_status( + &self, + package_id: PackageId, + watch: TypedDbWatch, + handler: CallbackHandler, + ) { + self.get_status.add(package_id, watch, handler); } pub(super) fn add_get_container_ip(&self, package_id: PackageId, handler: CallbackHandler) { diff --git a/core/src/service/effects/control.rs b/core/src/service/effects/control.rs index 88931812f..edaf998e8 100644 --- a/core/src/service/effects/control.rs +++ b/core/src/service/effects/control.rs @@ -80,27 +80,32 @@ pub async fn get_status( package_id, callback, }: GetStatusParams, -) -> Result { +) -> Result, Error> { let context = context.deref()?; let id = package_id.unwrap_or_else(|| context.seed.id.clone()); - let db = context.seed.ctx.db.peek().await; + + let ptr = format!("/public/packageData/{}/statusInfo", id) + .parse() + .expect("valid json pointer"); + let mut watch = context + .seed + .ctx + .db + .watch(ptr) + .await + .typed::(); + + let status = watch.peek_and_mark_seen()?.de().ok(); if let Some(callback) = callback { let callback = callback.register(&context.seed.persistent_container); context.seed.ctx.callbacks.add_get_status( id.clone(), + watch, super::callbacks::CallbackHandler::new(&context, callback), ); } - let status = db - .as_public() - .as_package_data() - .as_idx(&id) - .or_not_found(&id)? - .as_status_info() - .de()?; - Ok(status) } diff --git a/core/src/service/effects/net/host.rs b/core/src/service/effects/net/host.rs index a20fcf189..193826aac 100644 --- a/core/src/service/effects/net/host.rs +++ b/core/src/service/effects/net/host.rs @@ -23,26 +23,30 @@ pub async fn get_host_info( }: GetHostInfoParams, ) -> Result, Error> { let context = context.deref()?; - let db = context.seed.ctx.db.peek().await; let package_id = package_id.unwrap_or_else(|| context.seed.id.clone()); + let ptr = format!("/public/packageData/{}/hosts/{}", package_id, host_id) + .parse() + .expect("valid json pointer"); + let mut watch = context + .seed + .ctx + .db + .watch(ptr) + .await + .typed::(); + + let res = watch.peek_and_mark_seen()?.de().ok(); + if let Some(callback) = callback { let callback = callback.register(&context.seed.persistent_container); context.seed.ctx.callbacks.add_get_host_info( - &context.seed.ctx.db, package_id.clone(), host_id.clone(), + watch, CallbackHandler::new(&context, callback), ); } - let res = db - .as_public() - .as_package_data() - .as_idx(&package_id) - .and_then(|m| m.as_hosts().as_idx(&host_id)) - .map(|m| m.de()) - .transpose()?; - Ok(res) } diff --git a/core/src/service/service_actor.rs b/core/src/service/service_actor.rs index 4fec11a08..ed8feafdf 100644 --- a/core/src/service/service_actor.rs +++ b/core/src/service/service_actor.rs @@ -1,7 +1,6 @@ use std::sync::Arc; use std::time::Duration; -use imbl::vector; use patch_db::TypedDbWatch; use super::ServiceActorSeed; @@ -99,16 +98,9 @@ async fn service_actor_loop<'a>( seed: &'a Arc, transition: &mut Option>, ) -> Result<(), Error> { - let id = &seed.id; let status_model = watch.peek_and_mark_seen()?; let status = status_model.de()?; - if let Some(callbacks) = seed.ctx.callbacks.get_status(id) { - callbacks - .call(vector![patch_db::ModelExt::into_value(status_model)]) - .await?; - } - match status { StatusInfo { desired: DesiredStatus::Running | DesiredStatus::Restarting, diff --git a/sdk/base/lib/Effects.ts b/sdk/base/lib/Effects.ts index d3d0b8923..554390654 100644 --- a/sdk/base/lib/Effects.ts +++ b/sdk/base/lib/Effects.ts @@ -69,7 +69,7 @@ export type Effects = { getStatus(options: { packageId?: PackageId callback?: () => void - }): Promise + }): Promise /** DEPRECATED: indicate to the host os what runstate the service is in */ setMainStatus(options: SetMainStatus): Promise diff --git a/sdk/base/lib/util/GetContainerIp.ts b/sdk/base/lib/util/GetContainerIp.ts new file mode 100644 index 000000000..6b0cc1d8d --- /dev/null +++ b/sdk/base/lib/util/GetContainerIp.ts @@ -0,0 +1,112 @@ +import { Effects } from '../Effects' +import { PackageId } from '../osBindings' +import { AbortedError } from './AbortedError' +import { DropGenerator, DropPromise } from './Drop' + +export class GetContainerIp { + constructor( + readonly effects: Effects, + readonly opts: { packageId?: PackageId } = {}, + ) {} + + /** + * Returns the container IP. Reruns the context from which it has been called if the underlying value changes + */ + const() { + return this.effects.getContainerIp({ + ...this.opts, + callback: + this.effects.constRetry && + (() => this.effects.constRetry && this.effects.constRetry()), + }) + } + /** + * Returns the container IP. Does nothing if the value changes + */ + once() { + return this.effects.getContainerIp(this.opts) + } + + private async *watchGen(abort?: AbortSignal) { + const resolveCell = { resolve: () => {} } + this.effects.onLeaveContext(() => { + resolveCell.resolve() + }) + abort?.addEventListener('abort', () => resolveCell.resolve()) + while (this.effects.isInContext && !abort?.aborted) { + let callback: () => void = () => {} + const waitForNext = new Promise((resolve) => { + callback = resolve + resolveCell.resolve = resolve + }) + yield await this.effects.getContainerIp({ + ...this.opts, + callback: () => callback(), + }) + await waitForNext + } + return new Promise((_, rej) => rej(new AbortedError())) + } + + /** + * Watches the container IP. Returns an async iterator that yields whenever the value changes + */ + watch(abort?: AbortSignal): AsyncGenerator { + const ctrl = new AbortController() + abort?.addEventListener('abort', () => ctrl.abort()) + return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort()) + } + + /** + * Watches the container IP. Takes a custom callback function to run whenever the value changes + */ + onChange( + callback: ( + value: string, + error?: Error, + ) => { cancel: boolean } | Promise<{ cancel: boolean }>, + ) { + ;(async () => { + const ctrl = new AbortController() + for await (const value of this.watch(ctrl.signal)) { + try { + const res = await callback(value) + if (res.cancel) { + ctrl.abort() + break + } + } catch (e) { + console.error( + 'callback function threw an error @ GetContainerIp.onChange', + e, + ) + } + } + })() + .catch((e) => callback('', e)) + .catch((e) => + console.error( + 'callback function threw an error @ GetContainerIp.onChange', + e, + ), + ) + } + + /** + * Watches the container IP. Returns when the predicate is true + */ + waitFor(pred: (value: string) => boolean): Promise { + const ctrl = new AbortController() + return DropPromise.of( + Promise.resolve().then(async () => { + for await (const next of this.watchGen(ctrl.signal)) { + if (pred(next)) { + return next + } + } + return '' + }), + () => ctrl.abort(), + ) + } +} diff --git a/sdk/base/lib/util/GetHostInfo.ts b/sdk/base/lib/util/GetHostInfo.ts new file mode 100644 index 000000000..81784412a --- /dev/null +++ b/sdk/base/lib/util/GetHostInfo.ts @@ -0,0 +1,112 @@ +import { Effects } from '../Effects' +import { Host, HostId, PackageId } from '../osBindings' +import { AbortedError } from './AbortedError' +import { DropGenerator, DropPromise } from './Drop' + +export class GetHostInfo { + constructor( + readonly effects: Effects, + readonly opts: { hostId: HostId; packageId?: PackageId }, + ) {} + + /** + * Returns host info. Reruns the context from which it has been called if the underlying value changes + */ + const() { + return this.effects.getHostInfo({ + ...this.opts, + callback: + this.effects.constRetry && + (() => this.effects.constRetry && this.effects.constRetry()), + }) + } + /** + * Returns host info. Does nothing if the value changes + */ + once() { + return this.effects.getHostInfo(this.opts) + } + + private async *watchGen(abort?: AbortSignal) { + const resolveCell = { resolve: () => {} } + this.effects.onLeaveContext(() => { + resolveCell.resolve() + }) + abort?.addEventListener('abort', () => resolveCell.resolve()) + while (this.effects.isInContext && !abort?.aborted) { + let callback: () => void = () => {} + const waitForNext = new Promise((resolve) => { + callback = resolve + resolveCell.resolve = resolve + }) + yield await this.effects.getHostInfo({ + ...this.opts, + callback: () => callback(), + }) + await waitForNext + } + return new Promise((_, rej) => rej(new AbortedError())) + } + + /** + * Watches host info. Returns an async iterator that yields whenever the value changes + */ + watch(abort?: AbortSignal): AsyncGenerator { + const ctrl = new AbortController() + abort?.addEventListener('abort', () => ctrl.abort()) + return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort()) + } + + /** + * Watches host info. Takes a custom callback function to run whenever the value changes + */ + onChange( + callback: ( + value: Host | null, + error?: Error, + ) => { cancel: boolean } | Promise<{ cancel: boolean }>, + ) { + ;(async () => { + const ctrl = new AbortController() + for await (const value of this.watch(ctrl.signal)) { + try { + const res = await callback(value) + if (res.cancel) { + ctrl.abort() + break + } + } catch (e) { + console.error( + 'callback function threw an error @ GetHostInfo.onChange', + e, + ) + } + } + })() + .catch((e) => callback(null, e)) + .catch((e) => + console.error( + 'callback function threw an error @ GetHostInfo.onChange', + e, + ), + ) + } + + /** + * Watches host info. Returns when the predicate is true + */ + waitFor(pred: (value: Host | null) => boolean): Promise { + const ctrl = new AbortController() + return DropPromise.of( + Promise.resolve().then(async () => { + for await (const next of this.watchGen(ctrl.signal)) { + if (pred(next)) { + return next + } + } + return null + }), + () => ctrl.abort(), + ) + } +} diff --git a/sdk/base/lib/util/GetServiceManifest.ts b/sdk/base/lib/util/GetServiceManifest.ts new file mode 100644 index 000000000..d7e13d69a --- /dev/null +++ b/sdk/base/lib/util/GetServiceManifest.ts @@ -0,0 +1,112 @@ +import { Effects } from '../Effects' +import { Manifest, PackageId } from '../osBindings' +import { AbortedError } from './AbortedError' +import { DropGenerator, DropPromise } from './Drop' + +export class GetServiceManifest { + constructor( + readonly effects: Effects, + readonly opts: { packageId: PackageId }, + ) {} + + /** + * Returns the service manifest. Reruns the context from which it has been called if the underlying value changes + */ + const() { + return this.effects.getServiceManifest({ + ...this.opts, + callback: + this.effects.constRetry && + (() => this.effects.constRetry && this.effects.constRetry()), + }) + } + /** + * Returns the service manifest. Does nothing if the value changes + */ + once() { + return this.effects.getServiceManifest(this.opts) + } + + private async *watchGen(abort?: AbortSignal) { + const resolveCell = { resolve: () => {} } + this.effects.onLeaveContext(() => { + resolveCell.resolve() + }) + abort?.addEventListener('abort', () => resolveCell.resolve()) + while (this.effects.isInContext && !abort?.aborted) { + let callback: () => void = () => {} + const waitForNext = new Promise((resolve) => { + callback = resolve + resolveCell.resolve = resolve + }) + yield await this.effects.getServiceManifest({ + ...this.opts, + callback: () => callback(), + }) + await waitForNext + } + return new Promise((_, rej) => rej(new AbortedError())) + } + + /** + * Watches the service manifest. Returns an async iterator that yields whenever the value changes + */ + watch(abort?: AbortSignal): AsyncGenerator { + const ctrl = new AbortController() + abort?.addEventListener('abort', () => ctrl.abort()) + return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort()) + } + + /** + * Watches the service manifest. Takes a custom callback function to run whenever the value changes + */ + onChange( + callback: ( + value: Manifest | null, + error?: Error, + ) => { cancel: boolean } | Promise<{ cancel: boolean }>, + ) { + ;(async () => { + const ctrl = new AbortController() + for await (const value of this.watch(ctrl.signal)) { + try { + const res = await callback(value) + if (res.cancel) { + ctrl.abort() + break + } + } catch (e) { + console.error( + 'callback function threw an error @ GetServiceManifest.onChange', + e, + ) + } + } + })() + .catch((e) => callback(null, e)) + .catch((e) => + console.error( + 'callback function threw an error @ GetServiceManifest.onChange', + e, + ), + ) + } + + /** + * Watches the service manifest. Returns when the predicate is true + */ + waitFor(pred: (value: Manifest) => boolean): Promise { + const ctrl = new AbortController() + return DropPromise.of( + Promise.resolve().then(async () => { + for await (const next of this.watchGen(ctrl.signal)) { + if (pred(next)) { + return next + } + } + throw new Error('context left before predicate passed') + }), + () => ctrl.abort(), + ) + } +} diff --git a/sdk/base/lib/util/GetSslCertificate.ts b/sdk/base/lib/util/GetSslCertificate.ts new file mode 100644 index 000000000..296c2695f --- /dev/null +++ b/sdk/base/lib/util/GetSslCertificate.ts @@ -0,0 +1,118 @@ +import { Effects } from '../Effects' +import { AbortedError } from './AbortedError' +import { DropGenerator, DropPromise } from './Drop' + +export class GetSslCertificate { + constructor( + readonly effects: Effects, + readonly opts: { + hostnames: string[] + algorithm?: 'ecdsa' | 'ed25519' + }, + ) {} + + /** + * Returns the SSL certificate. Reruns the context from which it has been called if the underlying value changes + */ + const() { + return this.effects.getSslCertificate({ + ...this.opts, + callback: + this.effects.constRetry && + (() => this.effects.constRetry && this.effects.constRetry()), + }) + } + /** + * Returns the SSL certificate. Does nothing if the value changes + */ + once() { + return this.effects.getSslCertificate(this.opts) + } + + private async *watchGen(abort?: AbortSignal) { + const resolveCell = { resolve: () => {} } + this.effects.onLeaveContext(() => { + resolveCell.resolve() + }) + abort?.addEventListener('abort', () => resolveCell.resolve()) + while (this.effects.isInContext && !abort?.aborted) { + let callback: () => void = () => {} + const waitForNext = new Promise((resolve) => { + callback = resolve + resolveCell.resolve = resolve + }) + yield await this.effects.getSslCertificate({ + ...this.opts, + callback: () => callback(), + }) + await waitForNext + } + return new Promise((_, rej) => rej(new AbortedError())) + } + + /** + * Watches the SSL certificate. Returns an async iterator that yields whenever the value changes + */ + watch( + abort?: AbortSignal, + ): AsyncGenerator<[string, string, string], never, unknown> { + const ctrl = new AbortController() + abort?.addEventListener('abort', () => ctrl.abort()) + return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort()) + } + + /** + * Watches the SSL certificate. Takes a custom callback function to run whenever the value changes + */ + onChange( + callback: ( + value: [string, string, string] | null, + error?: Error, + ) => { cancel: boolean } | Promise<{ cancel: boolean }>, + ) { + ;(async () => { + const ctrl = new AbortController() + for await (const value of this.watch(ctrl.signal)) { + try { + const res = await callback(value) + if (res.cancel) { + ctrl.abort() + break + } + } catch (e) { + console.error( + 'callback function threw an error @ GetSslCertificate.onChange', + e, + ) + } + } + })() + .catch((e) => callback(null, e)) + .catch((e) => + console.error( + 'callback function threw an error @ GetSslCertificate.onChange', + e, + ), + ) + } + + /** + * Watches the SSL certificate. Returns when the predicate is true + */ + waitFor( + pred: (value: [string, string, string]) => boolean, + ): Promise<[string, string, string]> { + const ctrl = new AbortController() + return DropPromise.of( + Promise.resolve().then(async () => { + for await (const next of this.watchGen(ctrl.signal)) { + if (pred(next)) { + return next + } + } + throw new Error('context left before predicate passed') + }), + () => ctrl.abort(), + ) + } +} diff --git a/sdk/base/lib/util/GetStatus.ts b/sdk/base/lib/util/GetStatus.ts new file mode 100644 index 000000000..8804cbadf --- /dev/null +++ b/sdk/base/lib/util/GetStatus.ts @@ -0,0 +1,116 @@ +import { Effects } from '../Effects' +import { PackageId, StatusInfo } from '../osBindings' +import { AbortedError } from './AbortedError' +import { DropGenerator, DropPromise } from './Drop' + +export class GetStatus { + constructor( + readonly effects: Effects, + readonly opts: { packageId?: PackageId } = {}, + ) {} + + /** + * Returns the service status. Reruns the context from which it has been called if the underlying value changes + */ + const() { + return this.effects.getStatus({ + ...this.opts, + callback: + this.effects.constRetry && + (() => this.effects.constRetry && this.effects.constRetry()), + }) + } + /** + * Returns the service status. Does nothing if the value changes + */ + once() { + return this.effects.getStatus(this.opts) + } + + private async *watchGen(abort?: AbortSignal) { + const resolveCell = { resolve: () => {} } + this.effects.onLeaveContext(() => { + resolveCell.resolve() + }) + abort?.addEventListener('abort', () => resolveCell.resolve()) + while (this.effects.isInContext && !abort?.aborted) { + let callback: () => void = () => {} + const waitForNext = new Promise((resolve) => { + callback = resolve + resolveCell.resolve = resolve + }) + yield await this.effects.getStatus({ + ...this.opts, + callback: () => callback(), + }) + await waitForNext + } + return new Promise((_, rej) => rej(new AbortedError())) + } + + /** + * Watches the service status. Returns an async iterator that yields whenever the value changes + */ + watch( + abort?: AbortSignal, + ): AsyncGenerator { + const ctrl = new AbortController() + abort?.addEventListener('abort', () => ctrl.abort()) + return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort()) + } + + /** + * Watches the service status. Takes a custom callback function to run whenever the value changes + */ + onChange( + callback: ( + value: StatusInfo | null, + error?: Error, + ) => { cancel: boolean } | Promise<{ cancel: boolean }>, + ) { + ;(async () => { + const ctrl = new AbortController() + for await (const value of this.watch(ctrl.signal)) { + try { + const res = await callback(value) + if (res.cancel) { + ctrl.abort() + break + } + } catch (e) { + console.error( + 'callback function threw an error @ GetStatus.onChange', + e, + ) + } + } + })() + .catch((e) => callback(null, e)) + .catch((e) => + console.error( + 'callback function threw an error @ GetStatus.onChange', + e, + ), + ) + } + + /** + * Watches the service status. Returns when the predicate is true + */ + waitFor( + pred: (value: StatusInfo | null) => boolean, + ): Promise { + const ctrl = new AbortController() + return DropPromise.of( + Promise.resolve().then(async () => { + for await (const next of this.watchGen(ctrl.signal)) { + if (pred(next)) { + return next + } + } + return null + }), + () => ctrl.abort(), + ) + } +} diff --git a/sdk/base/lib/util/index.ts b/sdk/base/lib/util/index.ts index e156cb97b..7f0f9306e 100644 --- a/sdk/base/lib/util/index.ts +++ b/sdk/base/lib/util/index.ts @@ -15,7 +15,12 @@ export { once } from './once' export { asError } from './asError' export * as Patterns from './patterns' export * from './typeHelpers' +export { GetContainerIp } from './GetContainerIp' +export { GetHostInfo } from './GetHostInfo' export { GetOutboundGateway } from './GetOutboundGateway' +export { GetServiceManifest } from './GetServiceManifest' +export { GetSslCertificate } from './GetSslCertificate' +export { GetStatus } from './GetStatus' export { GetSystemSmtp } from './GetSystemSmtp' export { Graph, Vertex } from './graph' export { inMs } from './inMs' From 76de6be7de5aefdf64df056ee9d7472015a16fad Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Mon, 9 Mar 2026 15:54:02 -0600 Subject: [PATCH 12/71] refactor: extract Watchable base class for SDK effect wrappers Eliminates boilerplate across 7 wrapper classes (GetContainerIp, GetHostInfo, GetOutboundGateway, GetServiceManifest, GetSslCertificate, GetStatus, GetSystemSmtp) by moving shared const/once/watch/onChange/ waitFor logic into an abstract Watchable base class. --- sdk/base/lib/util/GetContainerIp.ts | 110 ++--------------------- sdk/base/lib/util/GetHostInfo.ts | 110 ++--------------------- sdk/base/lib/util/GetOutboundGateway.ts | 106 ++-------------------- sdk/base/lib/util/GetServiceManifest.ts | 110 ++--------------------- sdk/base/lib/util/GetSslCertificate.ts | 114 ++---------------------- sdk/base/lib/util/GetStatus.ts | 114 ++---------------------- sdk/base/lib/util/GetSystemSmtp.ts | 110 ++--------------------- sdk/base/lib/util/Watchable.ts | 107 ++++++++++++++++++++++ sdk/base/lib/util/index.ts | 1 + 9 files changed, 162 insertions(+), 720 deletions(-) create mode 100644 sdk/base/lib/util/Watchable.ts diff --git a/sdk/base/lib/util/GetContainerIp.ts b/sdk/base/lib/util/GetContainerIp.ts index 6b0cc1d8d..dfec071ad 100644 --- a/sdk/base/lib/util/GetContainerIp.ts +++ b/sdk/base/lib/util/GetContainerIp.ts @@ -1,112 +1,18 @@ import { Effects } from '../Effects' import { PackageId } from '../osBindings' -import { AbortedError } from './AbortedError' -import { DropGenerator, DropPromise } from './Drop' +import { Watchable } from './Watchable' + +export class GetContainerIp extends Watchable { + protected readonly label = 'GetContainerIp' -export class GetContainerIp { constructor( - readonly effects: Effects, + effects: Effects, readonly opts: { packageId?: PackageId } = {}, - ) {} - - /** - * Returns the container IP. Reruns the context from which it has been called if the underlying value changes - */ - const() { - return this.effects.getContainerIp({ - ...this.opts, - callback: - this.effects.constRetry && - (() => this.effects.constRetry && this.effects.constRetry()), - }) - } - /** - * Returns the container IP. Does nothing if the value changes - */ - once() { - return this.effects.getContainerIp(this.opts) - } - - private async *watchGen(abort?: AbortSignal) { - const resolveCell = { resolve: () => {} } - this.effects.onLeaveContext(() => { - resolveCell.resolve() - }) - abort?.addEventListener('abort', () => resolveCell.resolve()) - while (this.effects.isInContext && !abort?.aborted) { - let callback: () => void = () => {} - const waitForNext = new Promise((resolve) => { - callback = resolve - resolveCell.resolve = resolve - }) - yield await this.effects.getContainerIp({ - ...this.opts, - callback: () => callback(), - }) - await waitForNext - } - return new Promise((_, rej) => rej(new AbortedError())) - } - - /** - * Watches the container IP. Returns an async iterator that yields whenever the value changes - */ - watch(abort?: AbortSignal): AsyncGenerator { - const ctrl = new AbortController() - abort?.addEventListener('abort', () => ctrl.abort()) - return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort()) - } - - /** - * Watches the container IP. Takes a custom callback function to run whenever the value changes - */ - onChange( - callback: ( - value: string, - error?: Error, - ) => { cancel: boolean } | Promise<{ cancel: boolean }>, ) { - ;(async () => { - const ctrl = new AbortController() - for await (const value of this.watch(ctrl.signal)) { - try { - const res = await callback(value) - if (res.cancel) { - ctrl.abort() - break - } - } catch (e) { - console.error( - 'callback function threw an error @ GetContainerIp.onChange', - e, - ) - } - } - })() - .catch((e) => callback('', e)) - .catch((e) => - console.error( - 'callback function threw an error @ GetContainerIp.onChange', - e, - ), - ) + super(effects) } - /** - * Watches the container IP. Returns when the predicate is true - */ - waitFor(pred: (value: string) => boolean): Promise { - const ctrl = new AbortController() - return DropPromise.of( - Promise.resolve().then(async () => { - for await (const next of this.watchGen(ctrl.signal)) { - if (pred(next)) { - return next - } - } - return '' - }), - () => ctrl.abort(), - ) + protected call(callback?: () => void) { + return this.effects.getContainerIp({ ...this.opts, callback }) } } diff --git a/sdk/base/lib/util/GetHostInfo.ts b/sdk/base/lib/util/GetHostInfo.ts index 81784412a..ef67f03fc 100644 --- a/sdk/base/lib/util/GetHostInfo.ts +++ b/sdk/base/lib/util/GetHostInfo.ts @@ -1,112 +1,18 @@ import { Effects } from '../Effects' import { Host, HostId, PackageId } from '../osBindings' -import { AbortedError } from './AbortedError' -import { DropGenerator, DropPromise } from './Drop' +import { Watchable } from './Watchable' + +export class GetHostInfo extends Watchable { + protected readonly label = 'GetHostInfo' -export class GetHostInfo { constructor( - readonly effects: Effects, + effects: Effects, readonly opts: { hostId: HostId; packageId?: PackageId }, - ) {} - - /** - * Returns host info. Reruns the context from which it has been called if the underlying value changes - */ - const() { - return this.effects.getHostInfo({ - ...this.opts, - callback: - this.effects.constRetry && - (() => this.effects.constRetry && this.effects.constRetry()), - }) - } - /** - * Returns host info. Does nothing if the value changes - */ - once() { - return this.effects.getHostInfo(this.opts) - } - - private async *watchGen(abort?: AbortSignal) { - const resolveCell = { resolve: () => {} } - this.effects.onLeaveContext(() => { - resolveCell.resolve() - }) - abort?.addEventListener('abort', () => resolveCell.resolve()) - while (this.effects.isInContext && !abort?.aborted) { - let callback: () => void = () => {} - const waitForNext = new Promise((resolve) => { - callback = resolve - resolveCell.resolve = resolve - }) - yield await this.effects.getHostInfo({ - ...this.opts, - callback: () => callback(), - }) - await waitForNext - } - return new Promise((_, rej) => rej(new AbortedError())) - } - - /** - * Watches host info. Returns an async iterator that yields whenever the value changes - */ - watch(abort?: AbortSignal): AsyncGenerator { - const ctrl = new AbortController() - abort?.addEventListener('abort', () => ctrl.abort()) - return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort()) - } - - /** - * Watches host info. Takes a custom callback function to run whenever the value changes - */ - onChange( - callback: ( - value: Host | null, - error?: Error, - ) => { cancel: boolean } | Promise<{ cancel: boolean }>, ) { - ;(async () => { - const ctrl = new AbortController() - for await (const value of this.watch(ctrl.signal)) { - try { - const res = await callback(value) - if (res.cancel) { - ctrl.abort() - break - } - } catch (e) { - console.error( - 'callback function threw an error @ GetHostInfo.onChange', - e, - ) - } - } - })() - .catch((e) => callback(null, e)) - .catch((e) => - console.error( - 'callback function threw an error @ GetHostInfo.onChange', - e, - ), - ) + super(effects) } - /** - * Watches host info. Returns when the predicate is true - */ - waitFor(pred: (value: Host | null) => boolean): Promise { - const ctrl = new AbortController() - return DropPromise.of( - Promise.resolve().then(async () => { - for await (const next of this.watchGen(ctrl.signal)) { - if (pred(next)) { - return next - } - } - return null - }), - () => ctrl.abort(), - ) + protected call(callback?: () => void) { + return this.effects.getHostInfo({ ...this.opts, callback }) } } diff --git a/sdk/base/lib/util/GetOutboundGateway.ts b/sdk/base/lib/util/GetOutboundGateway.ts index 460bb8b90..5a4cecb50 100644 --- a/sdk/base/lib/util/GetOutboundGateway.ts +++ b/sdk/base/lib/util/GetOutboundGateway.ts @@ -1,106 +1,14 @@ import { Effects } from '../Effects' -import { AbortedError } from './AbortedError' -import { DropGenerator, DropPromise } from './Drop' +import { Watchable } from './Watchable' -export class GetOutboundGateway { - constructor(readonly effects: Effects) {} +export class GetOutboundGateway extends Watchable { + protected readonly label = 'GetOutboundGateway' - /** - * Returns the effective outbound gateway. Reruns the context from which it has been called if the underlying value changes - */ - const() { - return this.effects.getOutboundGateway({ - callback: - this.effects.constRetry && - (() => this.effects.constRetry && this.effects.constRetry()), - }) - } - /** - * Returns the effective outbound gateway. Does nothing if the value changes - */ - once() { - return this.effects.getOutboundGateway({}) + constructor(effects: Effects) { + super(effects) } - private async *watchGen(abort?: AbortSignal) { - const resolveCell = { resolve: () => {} } - this.effects.onLeaveContext(() => { - resolveCell.resolve() - }) - abort?.addEventListener('abort', () => resolveCell.resolve()) - while (this.effects.isInContext && !abort?.aborted) { - let callback: () => void = () => {} - const waitForNext = new Promise((resolve) => { - callback = resolve - resolveCell.resolve = resolve - }) - yield await this.effects.getOutboundGateway({ - callback: () => callback(), - }) - await waitForNext - } - return new Promise((_, rej) => rej(new AbortedError())) - } - - /** - * Watches the effective outbound gateway. Returns an async iterator that yields whenever the value changes - */ - watch(abort?: AbortSignal): AsyncGenerator { - const ctrl = new AbortController() - abort?.addEventListener('abort', () => ctrl.abort()) - return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort()) - } - - /** - * Watches the effective outbound gateway. Takes a custom callback function to run whenever the value changes - */ - onChange( - callback: ( - value: string, - error?: Error, - ) => { cancel: boolean } | Promise<{ cancel: boolean }>, - ) { - ;(async () => { - const ctrl = new AbortController() - for await (const value of this.watch(ctrl.signal)) { - try { - const res = await callback(value) - if (res.cancel) { - ctrl.abort() - break - } - } catch (e) { - console.error( - 'callback function threw an error @ GetOutboundGateway.onChange', - e, - ) - } - } - })() - .catch((e) => callback('', e)) - .catch((e) => - console.error( - 'callback function threw an error @ GetOutboundGateway.onChange', - e, - ), - ) - } - - /** - * Watches the effective outbound gateway. Returns when the predicate is true - */ - waitFor(pred: (value: string) => boolean): Promise { - const ctrl = new AbortController() - return DropPromise.of( - Promise.resolve().then(async () => { - for await (const next of this.watchGen(ctrl.signal)) { - if (pred(next)) { - return next - } - } - return '' - }), - () => ctrl.abort(), - ) + protected call(callback?: () => void) { + return this.effects.getOutboundGateway({ callback }) } } diff --git a/sdk/base/lib/util/GetServiceManifest.ts b/sdk/base/lib/util/GetServiceManifest.ts index d7e13d69a..7a99e5aa0 100644 --- a/sdk/base/lib/util/GetServiceManifest.ts +++ b/sdk/base/lib/util/GetServiceManifest.ts @@ -1,112 +1,18 @@ import { Effects } from '../Effects' import { Manifest, PackageId } from '../osBindings' -import { AbortedError } from './AbortedError' -import { DropGenerator, DropPromise } from './Drop' +import { Watchable } from './Watchable' + +export class GetServiceManifest extends Watchable { + protected readonly label = 'GetServiceManifest' -export class GetServiceManifest { constructor( - readonly effects: Effects, + effects: Effects, readonly opts: { packageId: PackageId }, - ) {} - - /** - * Returns the service manifest. Reruns the context from which it has been called if the underlying value changes - */ - const() { - return this.effects.getServiceManifest({ - ...this.opts, - callback: - this.effects.constRetry && - (() => this.effects.constRetry && this.effects.constRetry()), - }) - } - /** - * Returns the service manifest. Does nothing if the value changes - */ - once() { - return this.effects.getServiceManifest(this.opts) - } - - private async *watchGen(abort?: AbortSignal) { - const resolveCell = { resolve: () => {} } - this.effects.onLeaveContext(() => { - resolveCell.resolve() - }) - abort?.addEventListener('abort', () => resolveCell.resolve()) - while (this.effects.isInContext && !abort?.aborted) { - let callback: () => void = () => {} - const waitForNext = new Promise((resolve) => { - callback = resolve - resolveCell.resolve = resolve - }) - yield await this.effects.getServiceManifest({ - ...this.opts, - callback: () => callback(), - }) - await waitForNext - } - return new Promise((_, rej) => rej(new AbortedError())) - } - - /** - * Watches the service manifest. Returns an async iterator that yields whenever the value changes - */ - watch(abort?: AbortSignal): AsyncGenerator { - const ctrl = new AbortController() - abort?.addEventListener('abort', () => ctrl.abort()) - return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort()) - } - - /** - * Watches the service manifest. Takes a custom callback function to run whenever the value changes - */ - onChange( - callback: ( - value: Manifest | null, - error?: Error, - ) => { cancel: boolean } | Promise<{ cancel: boolean }>, ) { - ;(async () => { - const ctrl = new AbortController() - for await (const value of this.watch(ctrl.signal)) { - try { - const res = await callback(value) - if (res.cancel) { - ctrl.abort() - break - } - } catch (e) { - console.error( - 'callback function threw an error @ GetServiceManifest.onChange', - e, - ) - } - } - })() - .catch((e) => callback(null, e)) - .catch((e) => - console.error( - 'callback function threw an error @ GetServiceManifest.onChange', - e, - ), - ) + super(effects) } - /** - * Watches the service manifest. Returns when the predicate is true - */ - waitFor(pred: (value: Manifest) => boolean): Promise { - const ctrl = new AbortController() - return DropPromise.of( - Promise.resolve().then(async () => { - for await (const next of this.watchGen(ctrl.signal)) { - if (pred(next)) { - return next - } - } - throw new Error('context left before predicate passed') - }), - () => ctrl.abort(), - ) + protected call(callback?: () => void) { + return this.effects.getServiceManifest({ ...this.opts, callback }) } } diff --git a/sdk/base/lib/util/GetSslCertificate.ts b/sdk/base/lib/util/GetSslCertificate.ts index 296c2695f..08d5b10c0 100644 --- a/sdk/base/lib/util/GetSslCertificate.ts +++ b/sdk/base/lib/util/GetSslCertificate.ts @@ -1,118 +1,20 @@ import { Effects } from '../Effects' -import { AbortedError } from './AbortedError' -import { DropGenerator, DropPromise } from './Drop' +import { Watchable } from './Watchable' + +export class GetSslCertificate extends Watchable<[string, string, string]> { + protected readonly label = 'GetSslCertificate' -export class GetSslCertificate { constructor( - readonly effects: Effects, + effects: Effects, readonly opts: { hostnames: string[] algorithm?: 'ecdsa' | 'ed25519' }, - ) {} - - /** - * Returns the SSL certificate. Reruns the context from which it has been called if the underlying value changes - */ - const() { - return this.effects.getSslCertificate({ - ...this.opts, - callback: - this.effects.constRetry && - (() => this.effects.constRetry && this.effects.constRetry()), - }) - } - /** - * Returns the SSL certificate. Does nothing if the value changes - */ - once() { - return this.effects.getSslCertificate(this.opts) - } - - private async *watchGen(abort?: AbortSignal) { - const resolveCell = { resolve: () => {} } - this.effects.onLeaveContext(() => { - resolveCell.resolve() - }) - abort?.addEventListener('abort', () => resolveCell.resolve()) - while (this.effects.isInContext && !abort?.aborted) { - let callback: () => void = () => {} - const waitForNext = new Promise((resolve) => { - callback = resolve - resolveCell.resolve = resolve - }) - yield await this.effects.getSslCertificate({ - ...this.opts, - callback: () => callback(), - }) - await waitForNext - } - return new Promise((_, rej) => rej(new AbortedError())) - } - - /** - * Watches the SSL certificate. Returns an async iterator that yields whenever the value changes - */ - watch( - abort?: AbortSignal, - ): AsyncGenerator<[string, string, string], never, unknown> { - const ctrl = new AbortController() - abort?.addEventListener('abort', () => ctrl.abort()) - return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort()) - } - - /** - * Watches the SSL certificate. Takes a custom callback function to run whenever the value changes - */ - onChange( - callback: ( - value: [string, string, string] | null, - error?: Error, - ) => { cancel: boolean } | Promise<{ cancel: boolean }>, ) { - ;(async () => { - const ctrl = new AbortController() - for await (const value of this.watch(ctrl.signal)) { - try { - const res = await callback(value) - if (res.cancel) { - ctrl.abort() - break - } - } catch (e) { - console.error( - 'callback function threw an error @ GetSslCertificate.onChange', - e, - ) - } - } - })() - .catch((e) => callback(null, e)) - .catch((e) => - console.error( - 'callback function threw an error @ GetSslCertificate.onChange', - e, - ), - ) + super(effects) } - /** - * Watches the SSL certificate. Returns when the predicate is true - */ - waitFor( - pred: (value: [string, string, string]) => boolean, - ): Promise<[string, string, string]> { - const ctrl = new AbortController() - return DropPromise.of( - Promise.resolve().then(async () => { - for await (const next of this.watchGen(ctrl.signal)) { - if (pred(next)) { - return next - } - } - throw new Error('context left before predicate passed') - }), - () => ctrl.abort(), - ) + protected call(callback?: () => void) { + return this.effects.getSslCertificate({ ...this.opts, callback }) } } diff --git a/sdk/base/lib/util/GetStatus.ts b/sdk/base/lib/util/GetStatus.ts index 8804cbadf..365217977 100644 --- a/sdk/base/lib/util/GetStatus.ts +++ b/sdk/base/lib/util/GetStatus.ts @@ -1,116 +1,18 @@ import { Effects } from '../Effects' import { PackageId, StatusInfo } from '../osBindings' -import { AbortedError } from './AbortedError' -import { DropGenerator, DropPromise } from './Drop' +import { Watchable } from './Watchable' + +export class GetStatus extends Watchable { + protected readonly label = 'GetStatus' -export class GetStatus { constructor( - readonly effects: Effects, + effects: Effects, readonly opts: { packageId?: PackageId } = {}, - ) {} - - /** - * Returns the service status. Reruns the context from which it has been called if the underlying value changes - */ - const() { - return this.effects.getStatus({ - ...this.opts, - callback: - this.effects.constRetry && - (() => this.effects.constRetry && this.effects.constRetry()), - }) - } - /** - * Returns the service status. Does nothing if the value changes - */ - once() { - return this.effects.getStatus(this.opts) - } - - private async *watchGen(abort?: AbortSignal) { - const resolveCell = { resolve: () => {} } - this.effects.onLeaveContext(() => { - resolveCell.resolve() - }) - abort?.addEventListener('abort', () => resolveCell.resolve()) - while (this.effects.isInContext && !abort?.aborted) { - let callback: () => void = () => {} - const waitForNext = new Promise((resolve) => { - callback = resolve - resolveCell.resolve = resolve - }) - yield await this.effects.getStatus({ - ...this.opts, - callback: () => callback(), - }) - await waitForNext - } - return new Promise((_, rej) => rej(new AbortedError())) - } - - /** - * Watches the service status. Returns an async iterator that yields whenever the value changes - */ - watch( - abort?: AbortSignal, - ): AsyncGenerator { - const ctrl = new AbortController() - abort?.addEventListener('abort', () => ctrl.abort()) - return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort()) - } - - /** - * Watches the service status. Takes a custom callback function to run whenever the value changes - */ - onChange( - callback: ( - value: StatusInfo | null, - error?: Error, - ) => { cancel: boolean } | Promise<{ cancel: boolean }>, ) { - ;(async () => { - const ctrl = new AbortController() - for await (const value of this.watch(ctrl.signal)) { - try { - const res = await callback(value) - if (res.cancel) { - ctrl.abort() - break - } - } catch (e) { - console.error( - 'callback function threw an error @ GetStatus.onChange', - e, - ) - } - } - })() - .catch((e) => callback(null, e)) - .catch((e) => - console.error( - 'callback function threw an error @ GetStatus.onChange', - e, - ), - ) + super(effects) } - /** - * Watches the service status. Returns when the predicate is true - */ - waitFor( - pred: (value: StatusInfo | null) => boolean, - ): Promise { - const ctrl = new AbortController() - return DropPromise.of( - Promise.resolve().then(async () => { - for await (const next of this.watchGen(ctrl.signal)) { - if (pred(next)) { - return next - } - } - return null - }), - () => ctrl.abort(), - ) + protected call(callback?: () => void) { + return this.effects.getStatus({ ...this.opts, callback }) } } diff --git a/sdk/base/lib/util/GetSystemSmtp.ts b/sdk/base/lib/util/GetSystemSmtp.ts index 03cedba6f..b45263549 100644 --- a/sdk/base/lib/util/GetSystemSmtp.ts +++ b/sdk/base/lib/util/GetSystemSmtp.ts @@ -1,111 +1,15 @@ import { Effects } from '../Effects' import * as T from '../types' -import { AbortedError } from './AbortedError' -import { DropGenerator, DropPromise } from './Drop' +import { Watchable } from './Watchable' -export class GetSystemSmtp { - constructor(readonly effects: Effects) {} +export class GetSystemSmtp extends Watchable { + protected readonly label = 'GetSystemSmtp' - /** - * Returns the system SMTP credentials. Reruns the context from which it has been called if the underlying value changes - */ - const() { - return this.effects.getSystemSmtp({ - callback: - this.effects.constRetry && - (() => this.effects.constRetry && this.effects.constRetry()), - }) - } - /** - * Returns the system SMTP credentials. Does nothing if the credentials change - */ - once() { - return this.effects.getSystemSmtp({}) + constructor(effects: Effects) { + super(effects) } - private async *watchGen(abort?: AbortSignal) { - const resolveCell = { resolve: () => {} } - this.effects.onLeaveContext(() => { - resolveCell.resolve() - }) - abort?.addEventListener('abort', () => resolveCell.resolve()) - while (this.effects.isInContext && !abort?.aborted) { - let callback: () => void = () => {} - const waitForNext = new Promise((resolve) => { - callback = resolve - resolveCell.resolve = resolve - }) - yield await this.effects.getSystemSmtp({ - callback: () => callback(), - }) - await waitForNext - } - return new Promise((_, rej) => rej(new AbortedError())) - } - - /** - * Watches the system SMTP credentials. Returns an async iterator that yields whenever the value changes - */ - watch( - abort?: AbortSignal, - ): AsyncGenerator { - const ctrl = new AbortController() - abort?.addEventListener('abort', () => ctrl.abort()) - return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort()) - } - - /** - * Watches the system SMTP credentials. Takes a custom callback function to run whenever the credentials change - */ - onChange( - callback: ( - value: T.SmtpValue | null, - error?: Error, - ) => { cancel: boolean } | Promise<{ cancel: boolean }>, - ) { - ;(async () => { - const ctrl = new AbortController() - for await (const value of this.watch(ctrl.signal)) { - try { - const res = await callback(value) - if (res.cancel) { - ctrl.abort() - break - } - } catch (e) { - console.error( - 'callback function threw an error @ GetSystemSmtp.onChange', - e, - ) - } - } - })() - .catch((e) => callback(null, e)) - .catch((e) => - console.error( - 'callback function threw an error @ GetSystemSmtp.onChange', - e, - ), - ) - } - - /** - * Watches the system SMTP credentials. Returns when the predicate is true - */ - waitFor( - pred: (value: T.SmtpValue | null) => boolean, - ): Promise { - const ctrl = new AbortController() - return DropPromise.of( - Promise.resolve().then(async () => { - for await (const next of this.watchGen(ctrl.signal)) { - if (pred(next)) { - return next - } - } - return null - }), - () => ctrl.abort(), - ) + protected call(callback?: () => void) { + return this.effects.getSystemSmtp({ callback }) } } diff --git a/sdk/base/lib/util/Watchable.ts b/sdk/base/lib/util/Watchable.ts new file mode 100644 index 000000000..e6e5fc427 --- /dev/null +++ b/sdk/base/lib/util/Watchable.ts @@ -0,0 +1,107 @@ +import { Effects } from '../Effects' +import { AbortedError } from './AbortedError' +import { DropGenerator, DropPromise } from './Drop' + +export abstract class Watchable { + constructor(readonly effects: Effects) {} + + protected abstract call(callback?: () => void): Promise + protected abstract readonly label: string + + /** + * Returns the value. Reruns the context from which it has been called if the underlying value changes + */ + const(): Promise { + return this.call( + this.effects.constRetry && + (() => this.effects.constRetry && this.effects.constRetry()), + ) + } + + /** + * Returns the value. Does nothing if the value changes + */ + once(): Promise { + return this.call() + } + + private async *watchGen(abort?: AbortSignal) { + const resolveCell = { resolve: () => {} } + this.effects.onLeaveContext(() => { + resolveCell.resolve() + }) + abort?.addEventListener('abort', () => resolveCell.resolve()) + while (this.effects.isInContext && !abort?.aborted) { + let callback: () => void = () => {} + const waitForNext = new Promise((resolve) => { + callback = resolve + resolveCell.resolve = resolve + }) + yield await this.call(() => callback()) + await waitForNext + } + return new Promise((_, rej) => rej(new AbortedError())) + } + + /** + * Watches the value. Returns an async iterator that yields whenever the value changes + */ + watch(abort?: AbortSignal): AsyncGenerator { + const ctrl = new AbortController() + abort?.addEventListener('abort', () => ctrl.abort()) + return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort()) + } + + /** + * Watches the value. Takes a custom callback function to run whenever the value changes + */ + onChange( + callback: ( + value: T | undefined, + error?: Error, + ) => { cancel: boolean } | Promise<{ cancel: boolean }>, + ) { + ;(async () => { + const ctrl = new AbortController() + for await (const value of this.watch(ctrl.signal)) { + try { + const res = await callback(value) + if (res.cancel) { + ctrl.abort() + break + } + } catch (e) { + console.error( + `callback function threw an error @ ${this.label}.onChange`, + e, + ) + } + } + })() + .catch((e) => callback(undefined, e)) + .catch((e) => + console.error( + `callback function threw an error @ ${this.label}.onChange`, + e, + ), + ) + } + + /** + * Watches the value. Returns when the predicate is true + */ + waitFor(pred: (value: T) => boolean): Promise { + const ctrl = new AbortController() + return DropPromise.of( + Promise.resolve().then(async () => { + for await (const next of this.watchGen(ctrl.signal)) { + if (pred(next)) { + return next + } + } + throw new AbortedError() + }), + () => ctrl.abort(), + ) + } +} diff --git a/sdk/base/lib/util/index.ts b/sdk/base/lib/util/index.ts index 7f0f9306e..3c4606f14 100644 --- a/sdk/base/lib/util/index.ts +++ b/sdk/base/lib/util/index.ts @@ -15,6 +15,7 @@ export { once } from './once' export { asError } from './asError' export * as Patterns from './patterns' export * from './typeHelpers' +export { Watchable } from './Watchable' export { GetContainerIp } from './GetContainerIp' export { GetHostInfo } from './GetHostInfo' export { GetOutboundGateway } from './GetOutboundGateway' From 7b05a7c585da23b5eb2d688386faca99d6c7e000 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Mon, 9 Mar 2026 23:02:10 -0600 Subject: [PATCH 13/71] fix: add network dependency to start-tunneld and rename web reset to uninit Add After/Wants network-online.target to prevent race where start-tunneld starts before the network interface is up, causing missing MASQUERADE rules. Rename `web reset` to `web uninit` for clarity. --- core/locales/i18n.yaml | 26 ++++++++++++++++++++------ core/src/tunnel/web.rs | 4 ++-- core/start-tunneld.service | 2 ++ 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/core/locales/i18n.yaml b/core/locales/i18n.yaml index 45a75fdc9..d0f92d305 100644 --- a/core/locales/i18n.yaml +++ b/core/locales/i18n.yaml @@ -857,6 +857,13 @@ error.set-sys-info: fr_FR: "Erreur de Définition des Infos Système" pl_PL: "Błąd Ustawiania Informacji o Systemie" +error.bios: + en_US: "BIOS/UEFI Error" + de_DE: "BIOS/UEFI-Fehler" + es_ES: "Error de BIOS/UEFI" + fr_FR: "Erreur BIOS/UEFI" + pl_PL: "Błąd BIOS/UEFI" + # disk/main.rs disk.main.disk-not-found: en_US: "StartOS disk not found." @@ -2914,6 +2921,13 @@ help.arg.log-limit: fr_FR: "Nombre maximum d'entrées de journal" pl_PL: "Maksymalna liczba wpisów logu" +help.arg.merge: + en_US: "Merge with existing version range instead of replacing" + de_DE: "Mit vorhandenem Versionsbereich zusammenführen statt ersetzen" + es_ES: "Combinar con el rango de versiones existente en lugar de reemplazar" + fr_FR: "Fusionner avec la plage de versions existante au lieu de remplacer" + pl_PL: "Połącz z istniejącym zakresem wersji zamiast zastępować" + help.arg.mirror-url: en_US: "URL of the mirror" de_DE: "URL des Spiegels" @@ -5204,12 +5218,12 @@ about.reset-user-interface-password: fr_FR: "Réinitialiser le mot de passe de l'interface utilisateur" pl_PL: "Zresetuj hasło interfejsu użytkownika" -about.reset-webserver: - en_US: "Reset the webserver" - de_DE: "Den Webserver zurücksetzen" - es_ES: "Restablecer el servidor web" - fr_FR: "Réinitialiser le serveur web" - pl_PL: "Zresetuj serwer internetowy" +about.uninitialize-webserver: + en_US: "Uninitialize the webserver" + de_DE: "Den Webserver deinitialisieren" + es_ES: "Desinicializar el servidor web" + fr_FR: "Désinitialiser le serveur web" + pl_PL: "Zdezinicjalizuj serwer internetowy" about.restart-server: en_US: "Restart the server" diff --git a/core/src/tunnel/web.rs b/core/src/tunnel/web.rs index 04e7f84c0..7e3eec70d 100644 --- a/core/src/tunnel/web.rs +++ b/core/src/tunnel/web.rs @@ -168,10 +168,10 @@ pub fn web_api() -> ParentHandler { .with_call_remote::(), ) .subcommand( - "reset", + "uninit", from_fn_async(reset_web) .no_display() - .with_about("about.reset-webserver") + .with_about("about.uninitialize-webserver") .with_call_remote::(), ) } diff --git a/core/start-tunneld.service b/core/start-tunneld.service index b0d0a2043..402326614 100644 --- a/core/start-tunneld.service +++ b/core/start-tunneld.service @@ -1,5 +1,7 @@ [Unit] Description=StartTunnel +After=network-online.target +Wants=network-online.target [Service] Type=simple From 3441d4d6d61462b05dff9e8b94dde3ed45df812f Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Mon, 9 Mar 2026 23:42:33 -0600 Subject: [PATCH 14/71] fix: update patch-db (ciborium revert) and create /media/startos as 750 - Update patch-db submodule: fixes DB null-nuke caused by ciborium's broken deserialize_str, and stack overflow from recursive apply_patches - Create /media/startos with mode 750 in initramfs before subdirectories --- build/lib/scripts/startos-initramfs-module | 1 + core/Cargo.lock | 11 ++++++++++- patch-db | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/build/lib/scripts/startos-initramfs-module b/build/lib/scripts/startos-initramfs-module index f093328cc..6299edd5b 100755 --- a/build/lib/scripts/startos-initramfs-module +++ b/build/lib/scripts/startos-initramfs-module @@ -104,6 +104,7 @@ local_mount_root() -olowerdir=/startos/config/overlay:/lower,upperdir=/upper/data,workdir=/upper/work \ overlay ${rootmnt} + mkdir -m 750 -p ${rootmnt}/media/startos mkdir -p ${rootmnt}/media/startos/config mount --bind /startos/config ${rootmnt}/media/startos/config mkdir -p ${rootmnt}/media/startos/images diff --git a/core/Cargo.lock b/core/Cargo.lock index 0b7389bc6..10caba090 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -4345,7 +4345,6 @@ name = "patch-db" version = "0.1.0" dependencies = [ "async-trait", - "ciborium", "fd-lock-rs", "futures", "imbl", @@ -4356,6 +4355,7 @@ dependencies = [ "nix 0.30.1", "patch-db-macro", "serde", + "serde_cbor_2", "thiserror 2.0.18", "tokio", "tracing", @@ -5793,6 +5793,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_cbor_2" +version = "0.13.0" +source = "git+https://github.com/dr-bonez/cbor.git#2ce7fe5a5ca5700aa095668b5ba67154b7f213a4" +dependencies = [ + "half 2.7.1", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" diff --git a/patch-db b/patch-db index 003cb1dcf..12227eab1 160000 --- a/patch-db +++ b/patch-db @@ -1 +1 @@ -Subproject commit 003cb1dcf2a59330f7363cba2a8e81109acef85d +Subproject commit 12227eab18ec2f56b66fa16f3e49411a6eaae6f2 From 9546fc9ce0e8ea64e13ecf9b4a51b601ca86c7ba Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Mon, 9 Mar 2026 23:54:48 -0600 Subject: [PATCH 15/71] fix: make GRUB serial console conditional on hardware availability Unconditionally enabling serial terminal broke gfxterm on EFI systems without a serial port. Now installs a /etc/grub.d/01_serial script that probes for the serial port before enabling it. Also copies unicode.pf2 font to boot partition for GRUB graphical mode. --- debian/startos/postinst | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/debian/startos/postinst b/debian/startos/postinst index 246589f57..24a433c2b 100755 --- a/debian/startos/postinst +++ b/debian/startos/postinst @@ -28,13 +28,12 @@ if [ -f /etc/default/grub ]; then sed -i '/\(^\|#\)'"$1"'=/d' /etc/default/grub printf '%s="%s"\n' "$1" "$2" >> /etc/default/grub } - # Enable both graphical and serial terminal output - grub_set GRUB_TERMINAL_INPUT 'console serial' - grub_set GRUB_TERMINAL_OUTPUT 'gfxterm serial' - # Remove GRUB_TERMINAL if present (replaced by INPUT/OUTPUT above) + # Graphical terminal (serial added conditionally via /etc/grub.d/01_serial) + grub_set GRUB_TERMINAL_INPUT 'console' + grub_set GRUB_TERMINAL_OUTPUT 'gfxterm' + # Remove GRUB_TERMINAL and GRUB_SERIAL_COMMAND if present sed -i '/^\(#\|\)GRUB_TERMINAL=/d' /etc/default/grub - # Serial console settings - grub_set GRUB_SERIAL_COMMAND 'serial --unit=0 --speed=115200 --word=8 --parity=no --stop=1' + sed -i '/^\(#\|\)GRUB_SERIAL_COMMAND=/d' /etc/default/grub # Graphics mode and splash background grub_set GRUB_GFXMODE 800x600 grub_set GRUB_GFXPAYLOAD_LINUX keep @@ -49,6 +48,24 @@ if [ -f /etc/default/grub ]; then mkdir -p /boot/grub/startos-theme cp -r /usr/lib/startos/grub-theme/* /boot/grub/startos-theme/ fi + # Copy font to boot partition so GRUB can load it without accessing rootfs + if [ -f /usr/share/grub/unicode.pf2 ]; then + mkdir -p /boot/grub/fonts + cp /usr/share/grub/unicode.pf2 /boot/grub/fonts/unicode.pf2 + fi + # Install conditional serial console script for GRUB + cat > /etc/grub.d/01_serial << 'GRUBEOF' +#!/bin/sh +cat << 'EOF' +# Conditionally enable serial console (avoids breaking gfxterm on EFI +# systems where the serial port is unavailable) +if serial --unit=0 --speed=115200 --word=8 --parity=no --stop=1; then + terminal_input console serial + terminal_output gfxterm serial +fi +EOF +GRUBEOF + chmod +x /etc/grub.d/01_serial fi VERSION="$(cat /usr/lib/startos/VERSION.txt)" From ccf6fa34b1ed659711c29bb85b94ccf28c211cd5 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Mon, 9 Mar 2026 23:59:18 -0600 Subject: [PATCH 16/71] fix: set correct binary name and version on all CLI commands --- core/src/bins/container_cli.rs | 8 ++++---- core/src/bins/registry.rs | 5 +++++ core/src/bins/start_cli.rs | 8 ++++---- core/src/bins/tunnel.rs | 5 +++++ 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/core/src/bins/container_cli.rs b/core/src/bins/container_cli.rs index a03204107..0f5c65226 100644 --- a/core/src/bins/container_cli.rs +++ b/core/src/bins/container_cli.rs @@ -7,10 +7,6 @@ use crate::service::cli::{ContainerCliContext, ContainerClientConfig}; use crate::util::logger::LOGGER; use crate::version::{Current, VersionT}; -lazy_static::lazy_static! { - static ref VERSION_STRING: String = Current::default().semver().to_string(); -} - pub fn main(args: impl IntoIterator) { LOGGER.enable(); if let Err(e) = CliApp::new( @@ -18,6 +14,10 @@ pub fn main(args: impl IntoIterator) { crate::service::effects::handler(), ) .mutate_command(super::translate_cli) + .mutate_command(|cmd| { + cmd.name("start-container") + .version(Current::default().semver().to_string()) + }) .run(args) { match e.data { diff --git a/core/src/bins/registry.rs b/core/src/bins/registry.rs index 13d0c54c2..49892247c 100644 --- a/core/src/bins/registry.rs +++ b/core/src/bins/registry.rs @@ -8,6 +8,7 @@ use tokio::signal::unix::signal; use tracing::instrument; use crate::context::CliContext; +use crate::version::{Current, VersionT}; use crate::context::config::ClientConfig; use crate::net::web_server::{Acceptor, WebServer}; use crate::prelude::*; @@ -101,6 +102,10 @@ pub fn cli(args: impl IntoIterator) { crate::registry::registry_api(), ) .mutate_command(super::translate_cli) + .mutate_command(|cmd| { + cmd.name("start-registry") + .version(Current::default().semver().to_string()) + }) .run(args) { match e.data { diff --git a/core/src/bins/start_cli.rs b/core/src/bins/start_cli.rs index e1d737be4..85847f110 100644 --- a/core/src/bins/start_cli.rs +++ b/core/src/bins/start_cli.rs @@ -8,10 +8,6 @@ use crate::context::config::ClientConfig; use crate::util::logger::LOGGER; use crate::version::{Current, VersionT}; -lazy_static::lazy_static! { - static ref VERSION_STRING: String = Current::default().semver().to_string(); -} - pub fn main(args: impl IntoIterator) { LOGGER.enable(); @@ -20,6 +16,10 @@ pub fn main(args: impl IntoIterator) { crate::main_api(), ) .mutate_command(super::translate_cli) + .mutate_command(|cmd| { + cmd.name("start-cli") + .version(Current::default().semver().to_string()) + }) .run(args) { match e.data { diff --git a/core/src/bins/tunnel.rs b/core/src/bins/tunnel.rs index 07db8f671..3c72e556a 100644 --- a/core/src/bins/tunnel.rs +++ b/core/src/bins/tunnel.rs @@ -13,6 +13,7 @@ use tracing::instrument; use visit_rs::Visit; use crate::context::CliContext; +use crate::version::{Current, VersionT}; use crate::context::config::ClientConfig; use crate::net::tls::TlsListener; use crate::net::web_server::{Accept, Acceptor, MetadataVisitor, WebServer}; @@ -186,6 +187,10 @@ pub fn cli(args: impl IntoIterator) { crate::tunnel::api::tunnel_api(), ) .mutate_command(super::translate_cli) + .mutate_command(|cmd| { + cmd.name("start-tunnel") + .version(Current::default().semver().to_string()) + }) .run(args) { match e.data { From 2586f841b8ff0861852f4f858a547f427a8acc67 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Mon, 9 Mar 2026 23:59:38 -0600 Subject: [PATCH 17/71] fix: make unmount idempotent by ignoring "not mounted" errors --- core/src/disk/mount/util.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/core/src/disk/mount/util.rs b/core/src/disk/mount/util.rs index 327bb2169..30b6a5435 100644 --- a/core/src/disk/mount/util.rs +++ b/core/src/disk/mount/util.rs @@ -52,13 +52,19 @@ pub async fn bind, P1: AsRef>( pub async fn unmount>(mountpoint: P, lazy: bool) -> Result<(), Error> { tracing::debug!("Unmounting {}.", mountpoint.as_ref().display()); let mut cmd = tokio::process::Command::new("umount"); + cmd.env("LANG", "C.UTF-8"); if lazy { cmd.arg("-l"); } - cmd.arg(mountpoint.as_ref()) + match cmd + .arg(mountpoint.as_ref()) .invoke(crate::ErrorKind::Filesystem) - .await?; - Ok(()) + .await + { + Ok(_) => Ok(()), + Err(e) if e.to_string().contains("not mounted") => Ok(()), + Err(e) => Err(e), + } } /// Unmounts all mountpoints under (and including) the given path, in reverse From 73c6696873ce321d01e353072dfcd6e24a8fa82c Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Mon, 9 Mar 2026 23:59:58 -0600 Subject: [PATCH 18/71] refactor: simplify AddPackageSignerParams merge field from Option to bool --- core/src/registry/package/signer.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/core/src/registry/package/signer.rs b/core/src/registry/package/signer.rs index 47ec7b13d..ee1cbc47a 100644 --- a/core/src/registry/package/signer.rs +++ b/core/src/registry/package/signer.rs @@ -59,8 +59,7 @@ pub struct AddPackageSignerParams { #[ts(type = "string | null")] pub versions: Option, #[arg(long, help = "help.arg.merge")] - #[ts(optional)] - pub merge: Option, + pub merge: bool, } pub async fn add_package_signer( @@ -89,7 +88,7 @@ pub async fn add_package_signer( .as_authorized_mut() .upsert(&signer, || Ok(VersionRange::None))? .mutate(|existing| { - *existing = if merge.unwrap_or(false) { + *existing = if merge { VersionRange::or(existing.clone(), versions) } else { versions From 8dd50eb9c03065782b9268ecfcf2ff384de7fdc8 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Tue, 10 Mar 2026 00:00:15 -0600 Subject: [PATCH 19/71] fix: move unpack progress completion after rename and reformat --- core/src/service/service_map.rs | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/core/src/service/service_map.rs b/core/src/service/service_map.rs index 80a6ea5d4..5c657d562 100644 --- a/core/src/service/service_map.rs +++ b/core/src/service/service_map.rs @@ -299,10 +299,11 @@ impl ServiceMap { s9pk.serialize(&mut progress_writer, true).await?; let (file, mut unpack_progress) = progress_writer.into_inner(); file.sync_all().await?; - unpack_progress.complete(); crate::util::io::rename(&download_path, &installed_path).await?; + unpack_progress.complete(); + Ok::<_, Error>(sync_progress_task) }) .await?; @@ -325,11 +326,8 @@ impl ServiceMap { .as_manifest() .can_migrate_to; let next_version = &s9pk.as_manifest().version; - let next_can_migrate_from = - &s9pk.as_manifest().can_migrate_from; - if let Ok(data_ver_ev) = - data_ver.parse::() - { + let next_can_migrate_from = &s9pk.as_manifest().can_migrate_from; + if let Ok(data_ver_ev) = data_ver.parse::() { if data_ver_ev.satisfies(next_can_migrate_from) { ExitParams::target_str(data_ver) } else if next_version.satisfies(prev_can_migrate_to) { @@ -340,9 +338,7 @@ impl ServiceMap { next_can_migrate_from.clone(), )) } - } else if let Ok(data_ver_range) = - data_ver.parse::() - { + } else if let Ok(data_ver_range) = data_ver.parse::() { ExitParams::target_range(&VersionRange::and( data_ver_range, next_can_migrate_from.clone(), @@ -357,12 +353,7 @@ impl ServiceMap { } } else { ExitParams::target_version( - &*service - .seed - .persistent_container - .s9pk - .as_manifest() - .version, + &*service.seed.persistent_container.s9pk.as_manifest().version, ) }; let cleanup = service.uninstall(uninit, false, false).await?; From d2f12a7efcca07f606f6f9b89296ff6ac91ec049 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Tue, 10 Mar 2026 00:09:08 -0600 Subject: [PATCH 20/71] fix: run apt-get update before installing registry deb in Docker image --- .github/workflows/start-registry.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/start-registry.yaml b/.github/workflows/start-registry.yaml index 03dcd95fb..29e462795 100644 --- a/.github/workflows/start-registry.yaml +++ b/.github/workflows/start-registry.yaml @@ -162,7 +162,7 @@ jobs: ADD *.deb . - RUN apt-get install -y ./*_$(uname -m).deb && rm *.deb + RUN apt-get update && apt-get install -y ./*_$(uname -m).deb && rm -rf *.deb /var/lib/apt/lists/* VOLUME /var/lib/startos From 36b8fda6db9252ab583fa3e1ee1597c2a702ffb4 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Tue, 10 Mar 2026 02:55:05 -0600 Subject: [PATCH 21/71] fix: gracefully handle mount failure in legacy dependenciesAutoconfig Non-legacy dependencies don't have an "embassy" volume, so the mount fails. Catch the error and skip autoconfig instead of crashing. --- .../Systems/SystemForEmbassy/index.ts | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts b/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts index 65a6c56e8..10b1d7ddc 100644 --- a/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts +++ b/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts @@ -1116,16 +1116,24 @@ export class SystemForEmbassy implements System { // TODO: docker const status = await getStatus(effects, { packageId: id }).const() if (!status) return - await effects.mount({ - location: `/media/embassy/${id}`, - target: { - packageId: id, - volumeId: "embassy", - subpath: null, - readonly: true, - idmap: [], - }, - }) + try { + await effects.mount({ + location: `/media/embassy/${id}`, + target: { + packageId: id, + volumeId: "embassy", + subpath: null, + readonly: true, + idmap: [], + }, + }) + } catch (e) { + console.error( + `Failed to mount dependency volume for ${id}, skipping autoconfig:`, + e, + ) + return + } configFile .withPath(`/media/embassy/${id}/config.json`) .read() From b67e554e761a5d2f521047c5ab7748e30ee73089 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Tue, 10 Mar 2026 11:27:48 -0600 Subject: [PATCH 22/71] bump sdk --- container-runtime/package-lock.json | 2 +- sdk/package/package-lock.json | 4 ++-- sdk/package/package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/container-runtime/package-lock.json b/container-runtime/package-lock.json index b690a6d74..22b581db5 100644 --- a/container-runtime/package-lock.json +++ b/container-runtime/package-lock.json @@ -37,7 +37,7 @@ }, "../sdk/dist": { "name": "@start9labs/start-sdk", - "version": "0.4.0-beta.58", + "version": "0.4.0-beta.59", "license": "MIT", "dependencies": { "@iarna/toml": "^3.0.0", diff --git a/sdk/package/package-lock.json b/sdk/package/package-lock.json index 868def13d..ea4287eb8 100644 --- a/sdk/package/package-lock.json +++ b/sdk/package/package-lock.json @@ -1,12 +1,12 @@ { "name": "@start9labs/start-sdk", - "version": "0.4.0-beta.58", + "version": "0.4.0-beta.59", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@start9labs/start-sdk", - "version": "0.4.0-beta.58", + "version": "0.4.0-beta.59", "license": "MIT", "dependencies": { "@iarna/toml": "^3.0.0", diff --git a/sdk/package/package.json b/sdk/package/package.json index 14cc168bc..2d31ac87e 100644 --- a/sdk/package/package.json +++ b/sdk/package/package.json @@ -1,6 +1,6 @@ { "name": "@start9labs/start-sdk", - "version": "0.4.0-beta.58", + "version": "0.4.0-beta.59", "description": "Software development kit to facilitate packaging services for StartOS", "main": "./package/lib/index.js", "types": "./package/lib/index.d.ts", From 00eecf3704df6534beb33d8cfcd68f6e81df0867 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Wed, 11 Mar 2026 12:13:56 -0600 Subject: [PATCH 23/71] fix: treat all private IPs as private traffic, not just same-subnet Previously, traffic was only classified as private if the source IP was in a known interface subnet. This prevented private access from VPNs on different VLANs. Now all RFC 1918 IPv4 and ULA/link-local IPv6 addresses are treated as private, and DNS resolution for private domains works for these sources by returning IPs from all interfaces. --- core/src/net/dns.rs | 13 +++++++++++++ core/src/net/utils.rs | 7 +++++++ core/src/net/vhost.rs | 7 ++++--- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/core/src/net/dns.rs b/core/src/net/dns.rs index 1083b74dc..49c667297 100644 --- a/core/src/net/dns.rs +++ b/core/src/net/dns.rs @@ -32,6 +32,7 @@ use crate::context::{CliContext, RpcContext}; use crate::db::model::Database; use crate::db::model::public::NetworkInterfaceInfo; use crate::net::gateway::NetworkInterfaceWatcher; +use crate::net::utils::is_private_ip; use crate::prelude::*; use crate::util::future::NonDetachingJoinHandle; use crate::util::io::file_string_stream; @@ -400,6 +401,18 @@ impl Resolver { }) }) { return Some(res); + } else if is_private_ip(src) { + // Source is a private IP not in any known subnet (e.g. VPN on a different VLAN). + // Return all private IPs from all interfaces. + let res: Vec = self.net_iface.peek(|i| { + i.values() + .filter_map(|i| i.ip_info.as_ref()) + .flat_map(|ip_info| ip_info.subnets.iter().map(|s| s.addr())) + .collect() + }); + if !res.is_empty() { + return Some(res); + } } else { tracing::warn!( "{}", diff --git a/core/src/net/utils.rs b/core/src/net/utils.rs index 9f3a3682c..61466ee71 100644 --- a/core/src/net/utils.rs +++ b/core/src/net/utils.rs @@ -66,6 +66,13 @@ pub fn ipv6_is_local(addr: Ipv6Addr) -> bool { addr.is_loopback() || (addr.segments()[0] & 0xfe00) == 0xfc00 || ipv6_is_link_local(addr) } +pub fn is_private_ip(addr: IpAddr) -> bool { + match addr { + IpAddr::V4(v4) => v4.is_private() || v4.is_loopback() || v4.is_link_local(), + IpAddr::V6(v6) => ipv6_is_local(v6), + } +} + fn parse_iface_ip(output: &str) -> Result, Error> { let output = output.trim(); if output.is_empty() { diff --git a/core/src/net/vhost.rs b/core/src/net/vhost.rs index 970a9ccb9..6b4962e50 100644 --- a/core/src/net/vhost.rs +++ b/core/src/net/vhost.rs @@ -38,7 +38,7 @@ use crate::net::ssl::{CertStore, RootCaTlsHandler}; use crate::net::tls::{ ChainedHandler, TlsHandlerAction, TlsHandlerWrapper, TlsListener, TlsMetadata, WrapTlsHandler, }; -use crate::net::utils::ipv6_is_link_local; +use crate::net::utils::{ipv6_is_link_local, is_private_ip}; use crate::net::web_server::{Accept, AcceptStream, ExtractVisitor, TcpMetadata, extract}; use crate::prelude::*; use crate::util::collections::EqSet; @@ -732,8 +732,9 @@ where }; let src = tcp.peer_addr.ip(); - // Public: source is outside all known subnets (direct internet) - let is_public = !ip_info.subnets.iter().any(|s| s.contains(&src)); + // Private: source is in a known subnet or is a private IP (e.g. VPN on a different VLAN) + let is_public = + !ip_info.subnets.iter().any(|s| s.contains(&src)) && !is_private_ip(src); if is_public { self.public.contains(&gw.id) From c59c619e12901d326d8d906bff8e13c224679256 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Wed, 11 Mar 2026 15:13:11 -0600 Subject: [PATCH 24/71] chore: update CLAUDE.md docs for commit signing and i18n rules --- CLAUDE.md | 2 +- core/CLAUDE.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7464695cf..da1c05c6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,7 @@ make test-core # Run Rust tests - Check component-level CLAUDE.md files for component-specific conventions. ALWAYS read it before operating on that component. - Follow existing patterns before inventing new ones - Always use `make` recipes when they exist for testing builds rather than manually invoking build commands +- **Commit signing:** Never push unsigned commits. Before pushing, check all unpushed commits for signatures with `git log --show-signature @{upstream}..HEAD`. If any are unsigned, prompt the user to sign them with `git rebase --exec 'git commit --amend -S --no-edit' @{upstream}`. ## Supplementary Documentation @@ -50,7 +51,6 @@ On startup: 1. **Check for `docs/USER.md`** - If it doesn't exist, prompt the user for their name/identifier and create it. This file is gitignored since it varies per developer. 2. **Check `docs/TODO.md` for relevant tasks** - Show TODOs that either: - - Have no `@username` tag (relevant to everyone) - Are tagged with the current user's identifier diff --git a/core/CLAUDE.md b/core/CLAUDE.md index 883e68991..b0fd6e7ce 100644 --- a/core/CLAUDE.md +++ b/core/CLAUDE.md @@ -22,7 +22,7 @@ cd sdk && make baseDist dist # Rebuild SDK after ts-bindings - Always run `cargo check -p start-os` after modifying Rust code - When adding RPC endpoints, follow the patterns in [rpc-toolkit.md](rpc-toolkit.md) - When modifying `#[ts(export)]` types, regenerate bindings and rebuild the SDK (see [ARCHITECTURE.md](../ARCHITECTURE.md#build-pipeline)) -- When adding i18n keys, add all 5 locales in `core/locales/i18n.yaml` (see [i18n-patterns.md](i18n-patterns.md)) +- **i18n is mandatory** — any user-facing string must go in `core/locales/i18n.yaml` with all 5 locales (`en_US`, `de_DE`, `es_ES`, `fr_FR`, `pl_PL`). This includes CLI subcommand descriptions (`about.`), CLI arg help (`help.arg.`), error messages (`error.`), notifications, setup messages, and any other text shown to users. Entries are alphabetically ordered within their section. See [i18n-patterns.md](i18n-patterns.md) - When using DB watches, follow the `TypedDbWatch` patterns in [patchdb.md](patchdb.md) - **Always use `.invoke(ErrorKind::...)` instead of `.status()` when running CLI commands** via `tokio::process::Command`. The `Invoke` trait (from `crate::util::Invoke`) captures stdout/stderr and checks exit codes properly. Using `.status()` leaks stderr directly to system logs, creating noise. For check-then-act patterns (e.g. `iptables -C`), use `.invoke(...).await.is_ok()` / `.is_err()` instead of `.status().await.map_or(false, |s| s.success())`. - Always use file utils in util::io instead of tokio::fs when available From a782cb270b4c9b8e17ef5d9499f7dd9d3d4bb0da Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Wed, 11 Mar 2026 15:13:40 -0600 Subject: [PATCH 25/71] refactor: consolidate SDK Watchable with generic map/eq and rename call to fetch --- sdk/base/lib/util/GetContainerIp.ts | 2 +- sdk/base/lib/util/GetHostInfo.ts | 2 +- sdk/base/lib/util/GetOutboundGateway.ts | 2 +- sdk/base/lib/util/GetServiceManifest.ts | 35 +++- sdk/base/lib/util/GetSslCertificate.ts | 2 +- sdk/base/lib/util/GetStatus.ts | 2 +- sdk/base/lib/util/GetSystemSmtp.ts | 2 +- sdk/base/lib/util/Watchable.ts | 125 ++++++++++--- sdk/base/lib/util/getServiceInterface.ts | 166 +++-------------- sdk/base/lib/util/getServiceInterfaces.ts | 168 +++--------------- sdk/base/lib/util/index.ts | 2 +- sdk/package/lib/StartSdk.ts | 175 +----------------- sdk/package/lib/util/GetServiceManifest.ts | 156 ---------------- sdk/package/lib/util/GetSslCertificate.ts | 122 ------------- sdk/package/lib/util/fileHelper.ts | 197 +++++++-------------- sdk/package/lib/util/index.ts | 2 - 16 files changed, 263 insertions(+), 897 deletions(-) delete mode 100644 sdk/package/lib/util/GetServiceManifest.ts delete mode 100644 sdk/package/lib/util/GetSslCertificate.ts diff --git a/sdk/base/lib/util/GetContainerIp.ts b/sdk/base/lib/util/GetContainerIp.ts index dfec071ad..7fb2cee2f 100644 --- a/sdk/base/lib/util/GetContainerIp.ts +++ b/sdk/base/lib/util/GetContainerIp.ts @@ -12,7 +12,7 @@ export class GetContainerIp extends Watchable { super(effects) } - protected call(callback?: () => void) { + protected fetch(callback?: () => void) { return this.effects.getContainerIp({ ...this.opts, callback }) } } diff --git a/sdk/base/lib/util/GetHostInfo.ts b/sdk/base/lib/util/GetHostInfo.ts index ef67f03fc..bd822781c 100644 --- a/sdk/base/lib/util/GetHostInfo.ts +++ b/sdk/base/lib/util/GetHostInfo.ts @@ -12,7 +12,7 @@ export class GetHostInfo extends Watchable { super(effects) } - protected call(callback?: () => void) { + protected fetch(callback?: () => void) { return this.effects.getHostInfo({ ...this.opts, callback }) } } diff --git a/sdk/base/lib/util/GetOutboundGateway.ts b/sdk/base/lib/util/GetOutboundGateway.ts index 5a4cecb50..7d8d70880 100644 --- a/sdk/base/lib/util/GetOutboundGateway.ts +++ b/sdk/base/lib/util/GetOutboundGateway.ts @@ -8,7 +8,7 @@ export class GetOutboundGateway extends Watchable { super(effects) } - protected call(callback?: () => void) { + protected fetch(callback?: () => void) { return this.effects.getOutboundGateway({ callback }) } } diff --git a/sdk/base/lib/util/GetServiceManifest.ts b/sdk/base/lib/util/GetServiceManifest.ts index 7a99e5aa0..2afa60fbb 100644 --- a/sdk/base/lib/util/GetServiceManifest.ts +++ b/sdk/base/lib/util/GetServiceManifest.ts @@ -1,18 +1,47 @@ import { Effects } from '../Effects' import { Manifest, PackageId } from '../osBindings' +import { deepEqual } from './deepEqual' import { Watchable } from './Watchable' -export class GetServiceManifest extends Watchable { +export class GetServiceManifest< + Mapped = Manifest | null, +> extends Watchable { protected readonly label = 'GetServiceManifest' constructor( effects: Effects, readonly opts: { packageId: PackageId }, + options?: { + map?: (value: Manifest | null) => Mapped + eq?: (a: Mapped, b: Mapped) => boolean + }, ) { - super(effects) + super(effects, options) } - protected call(callback?: () => void) { + protected fetch(callback?: () => void) { return this.effects.getServiceManifest({ ...this.opts, callback }) } } + +export function getServiceManifest( + effects: Effects, + packageId: PackageId, +): GetServiceManifest +export function getServiceManifest( + effects: Effects, + packageId: PackageId, + map: (manifest: Manifest | null) => Mapped, + eq?: (a: Mapped, b: Mapped) => boolean, +): GetServiceManifest +export function getServiceManifest( + effects: Effects, + packageId: PackageId, + map?: (manifest: Manifest | null) => Mapped, + eq?: (a: Mapped, b: Mapped) => boolean, +): GetServiceManifest { + return new GetServiceManifest(effects, { packageId }, { + map: map ?? ((a) => a as Mapped), + eq: eq ?? ((a, b) => deepEqual(a, b)), + }) +} diff --git a/sdk/base/lib/util/GetSslCertificate.ts b/sdk/base/lib/util/GetSslCertificate.ts index 08d5b10c0..72daee306 100644 --- a/sdk/base/lib/util/GetSslCertificate.ts +++ b/sdk/base/lib/util/GetSslCertificate.ts @@ -14,7 +14,7 @@ export class GetSslCertificate extends Watchable<[string, string, string]> { super(effects) } - protected call(callback?: () => void) { + protected fetch(callback?: () => void) { return this.effects.getSslCertificate({ ...this.opts, callback }) } } diff --git a/sdk/base/lib/util/GetStatus.ts b/sdk/base/lib/util/GetStatus.ts index 365217977..c1d3df38a 100644 --- a/sdk/base/lib/util/GetStatus.ts +++ b/sdk/base/lib/util/GetStatus.ts @@ -12,7 +12,7 @@ export class GetStatus extends Watchable { super(effects) } - protected call(callback?: () => void) { + protected fetch(callback?: () => void) { return this.effects.getStatus({ ...this.opts, callback }) } } diff --git a/sdk/base/lib/util/GetSystemSmtp.ts b/sdk/base/lib/util/GetSystemSmtp.ts index b45263549..2da804437 100644 --- a/sdk/base/lib/util/GetSystemSmtp.ts +++ b/sdk/base/lib/util/GetSystemSmtp.ts @@ -9,7 +9,7 @@ export class GetSystemSmtp extends Watchable { super(effects) } - protected call(callback?: () => void) { + protected fetch(callback?: () => void) { return this.effects.getSystemSmtp({ callback }) } } diff --git a/sdk/base/lib/util/Watchable.ts b/sdk/base/lib/util/Watchable.ts index e6e5fc427..22c2d3581 100644 --- a/sdk/base/lib/util/Watchable.ts +++ b/sdk/base/lib/util/Watchable.ts @@ -1,55 +1,124 @@ import { Effects } from '../Effects' import { AbortedError } from './AbortedError' +import { deepEqual } from './deepEqual' import { DropGenerator, DropPromise } from './Drop' -export abstract class Watchable { - constructor(readonly effects: Effects) {} +export abstract class Watchable { + protected readonly mapFn: (value: Raw) => Mapped + protected readonly eqFn: (a: Mapped, b: Mapped) => boolean - protected abstract call(callback?: () => void): Promise + constructor( + readonly effects: Effects, + options?: { + map?: (value: Raw) => Mapped + eq?: (a: Mapped, b: Mapped) => boolean + }, + ) { + this.mapFn = options?.map ?? ((a) => a as unknown as Mapped) + this.eqFn = options?.eq ?? ((a, b) => deepEqual(a, b)) + } + + /** + * Fetch the current value, optionally registering a callback for change notification. + * The callback should be invoked when the underlying data changes. + */ + protected abstract fetch(callback?: () => void): Promise protected abstract readonly label: string /** - * Returns the value. Reruns the context from which it has been called if the underlying value changes + * Produce a stream of raw values. Default implementation uses fetch() with + * effects callback in a loop. Override for custom subscription mechanisms + * (e.g. fs.watch). */ - const(): Promise { - return this.call( - this.effects.constRetry && - (() => this.effects.constRetry && this.effects.constRetry()), - ) - } - - /** - * Returns the value. Does nothing if the value changes - */ - once(): Promise { - return this.call() - } - - private async *watchGen(abort?: AbortSignal) { + protected async *produce(abort: AbortSignal): AsyncGenerator { const resolveCell = { resolve: () => {} } this.effects.onLeaveContext(() => { resolveCell.resolve() }) - abort?.addEventListener('abort', () => resolveCell.resolve()) - while (this.effects.isInContext && !abort?.aborted) { + abort.addEventListener('abort', () => resolveCell.resolve()) + while (this.effects.isInContext && !abort.aborted) { let callback: () => void = () => {} const waitForNext = new Promise((resolve) => { callback = resolve resolveCell.resolve = resolve }) - yield await this.call(() => callback()) + yield await this.fetch(() => callback()) await waitForNext } - return new Promise((_, rej) => rej(new AbortedError())) + } + + /** + * Lifecycle hook called when const() registers a subscription. + * Return a cleanup function to be called when the subscription ends. + * Override for side effects like FileHelper's consts tracking. + */ + protected onConstRegistered(_value: Mapped): (() => void) | void {} + + /** + * Internal generator that maps raw values and deduplicates using eq. + */ + private async *watchGen( + abort: AbortSignal, + ): AsyncGenerator { + let prev: { value: Mapped } | null = null + for await (const raw of this.produce(abort)) { + if (abort.aborted) return + const mapped = this.mapFn(raw) + if (!prev || !this.eqFn(prev.value, mapped)) { + prev = { value: mapped } + yield mapped + } + } + } + + /** + * Returns the value. Reruns the context from which it has been called if the underlying value changes + */ + async const(): Promise { + const abort = new AbortController() + const gen = this.watchGen(abort.signal) + const res = await gen.next() + const value = res.value as Mapped + if (this.effects.constRetry) { + const constRetry = this.effects.constRetry + const cleanup = this.onConstRegistered(value) + gen.next().then( + () => { + abort.abort() + cleanup?.() + constRetry() + }, + () => { + abort.abort() + cleanup?.() + }, + ) + } else { + abort.abort() + } + return value + } + + /** + * Returns the value. Does nothing if the value changes + */ + async once(): Promise { + return this.mapFn(await this.fetch()) } /** * Watches the value. Returns an async iterator that yields whenever the value changes */ - watch(abort?: AbortSignal): AsyncGenerator { + watch(abort?: AbortSignal): AsyncGenerator { const ctrl = new AbortController() abort?.addEventListener('abort', () => ctrl.abort()) - return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort()) + return DropGenerator.of( + (async function* (gen): AsyncGenerator { + yield* gen + throw new AbortedError() + })(this.watchGen(ctrl.signal)), + () => ctrl.abort(), + ) } /** @@ -57,13 +126,13 @@ export abstract class Watchable { */ onChange( callback: ( - value: T | undefined, + value: Mapped | undefined, error?: Error, ) => { cancel: boolean } | Promise<{ cancel: boolean }>, ) { ;(async () => { const ctrl = new AbortController() - for await (const value of this.watch(ctrl.signal)) { + for await (const value of this.watchGen(ctrl.signal)) { try { const res = await callback(value) if (res.cancel) { @@ -90,7 +159,7 @@ export abstract class Watchable { /** * Watches the value. Returns when the predicate is true */ - waitFor(pred: (value: T) => boolean): Promise { + waitFor(pred: (value: Mapped) => boolean): Promise { const ctrl = new AbortController() return DropPromise.of( Promise.resolve().then(async () => { diff --git a/sdk/base/lib/util/getServiceInterface.ts b/sdk/base/lib/util/getServiceInterface.ts index 944e1c6b6..527dc09dc 100644 --- a/sdk/base/lib/util/getServiceInterface.ts +++ b/sdk/base/lib/util/getServiceInterface.ts @@ -8,11 +8,10 @@ import { HostnameInfo, } from '../types' import { Effects } from '../Effects' -import { AbortedError } from './AbortedError' -import { DropGenerator, DropPromise } from './Drop' import { IpAddress, IPV6_LINK_LOCAL } from './ip' import { deepEqual } from './deepEqual' import { once } from './once' +import { Watchable } from './Watchable' export type UrlString = string export type HostId = string @@ -440,136 +439,29 @@ const makeInterfaceFilled = async ({ return interfaceFilled } -export class GetServiceInterface { +export class GetServiceInterface< + Mapped = ServiceInterfaceFilled | null, +> extends Watchable { + protected readonly label = 'GetServiceInterface' + constructor( - readonly effects: Effects, + effects: Effects, readonly opts: { id: string; packageId?: string }, - readonly map: (interfaces: ServiceInterfaceFilled | null) => Mapped, - readonly eq: (a: Mapped, b: Mapped) => boolean, - ) {} - - /** - * Returns the requested service interface. Reruns the context from which it has been called if the underlying value changes - */ - async const() { - let abort = new AbortController() - const watch = this.watch(abort.signal) - const res = await watch.next() - if (this.effects.constRetry) { - watch - .next() - .then(() => { - abort.abort() - this.effects.constRetry && this.effects.constRetry() - }) - .catch() - } - return res.value - } - /** - * Returns the requested service interface. Does nothing if the value changes - */ - async once() { - const { id, packageId } = this.opts - const interfaceFilled = await makeInterfaceFilled({ - effects: this.effects, - id, - packageId, - }) - - return this.map(interfaceFilled) - } - - private async *watchGen(abort?: AbortSignal) { - let prev = null as { value: Mapped } | null - const { id, packageId } = this.opts - const resolveCell = { resolve: () => {} } - this.effects.onLeaveContext(() => { - resolveCell.resolve() - }) - abort?.addEventListener('abort', () => resolveCell.resolve()) - while (this.effects.isInContext && !abort?.aborted) { - let callback: () => void = () => {} - const waitForNext = new Promise((resolve) => { - callback = resolve - resolveCell.resolve = resolve - }) - const next = this.map( - await makeInterfaceFilled({ - effects: this.effects, - id, - packageId, - callback, - }), - ) - if (!prev || !this.eq(prev.value, next)) { - yield next - } - await waitForNext - } - return new Promise((_, rej) => rej(new AbortedError())) - } - - /** - * Watches the requested service interface. Returns an async iterator that yields whenever the value changes - */ - watch(abort?: AbortSignal): AsyncGenerator { - const ctrl = new AbortController() - abort?.addEventListener('abort', () => ctrl.abort()) - return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort()) - } - - /** - * Watches the requested service interface. Takes a custom callback function to run whenever the value changes - */ - onChange( - callback: ( - value: Mapped | null, - error?: Error, - ) => { cancel: boolean } | Promise<{ cancel: boolean }>, + options?: { + map?: (value: ServiceInterfaceFilled | null) => Mapped + eq?: (a: Mapped, b: Mapped) => boolean + }, ) { - ;(async () => { - const ctrl = new AbortController() - for await (const value of this.watch(ctrl.signal)) { - try { - const res = await callback(value) - if (res.cancel) { - ctrl.abort() - break - } - } catch (e) { - console.error( - 'callback function threw an error @ GetServiceInterface.onChange', - e, - ) - } - } - })() - .catch((e) => callback(null, e)) - .catch((e) => - console.error( - 'callback function threw an error @ GetServiceInterface.onChange', - e, - ), - ) + super(effects, options) } - /** - * Watches the requested service interface. Returns when the predicate is true - */ - waitFor(pred: (value: Mapped) => boolean): Promise { - const ctrl = new AbortController() - return DropPromise.of( - Promise.resolve().then(async () => { - for await (const next of this.watchGen(ctrl.signal)) { - if (pred(next)) { - return next - } - } - throw new Error('context left before predicate passed') - }), - () => ctrl.abort(), - ) + protected fetch(callback?: () => void) { + return makeInterfaceFilled({ + effects: this.effects, + id: this.opts.id, + packageId: this.opts.packageId, + callback, + }) } } @@ -589,12 +481,10 @@ export function getOwnServiceInterface( map?: (interfaces: ServiceInterfaceFilled | null) => Mapped, eq?: (a: Mapped, b: Mapped) => boolean, ): GetServiceInterface { - return new GetServiceInterface( - effects, - { id }, - map ?? ((a) => a as Mapped), - eq ?? ((a, b) => deepEqual(a, b)), - ) + return new GetServiceInterface(effects, { id }, { + map: map ?? ((a) => a as Mapped), + eq: eq ?? ((a, b) => deepEqual(a, b)), + }) } export function getServiceInterface( @@ -613,10 +503,8 @@ export function getServiceInterface( map?: (interfaces: ServiceInterfaceFilled | null) => Mapped, eq?: (a: Mapped, b: Mapped) => boolean, ): GetServiceInterface { - return new GetServiceInterface( - effects, - opts, - map ?? ((a) => a as Mapped), - eq ?? ((a, b) => deepEqual(a, b)), - ) + return new GetServiceInterface(effects, opts, { + map: map ?? ((a) => a as Mapped), + eq: eq ?? ((a, b) => deepEqual(a, b)), + }) } diff --git a/sdk/base/lib/util/getServiceInterfaces.ts b/sdk/base/lib/util/getServiceInterfaces.ts index e6a745d56..73a176808 100644 --- a/sdk/base/lib/util/getServiceInterfaces.ts +++ b/sdk/base/lib/util/getServiceInterfaces.ts @@ -1,9 +1,8 @@ import { Effects } from '../Effects' import { PackageId } from '../osBindings' -import { AbortedError } from './AbortedError' import { deepEqual } from './deepEqual' -import { DropGenerator, DropPromise } from './Drop' import { ServiceInterfaceFilled, filledAddress } from './getServiceInterface' +import { Watchable } from './Watchable' const makeManyInterfaceFilled = async ({ effects, @@ -40,139 +39,34 @@ const makeManyInterfaceFilled = async ({ return serviceInterfacesFilled } -export class GetServiceInterfaces { +export class GetServiceInterfaces< + Mapped = ServiceInterfaceFilled[], +> extends Watchable { + protected readonly label = 'GetServiceInterfaces' + constructor( - readonly effects: Effects, + effects: Effects, readonly opts: { packageId?: string }, - readonly map: (interfaces: ServiceInterfaceFilled[]) => Mapped, - readonly eq: (a: Mapped, b: Mapped) => boolean, - ) {} - - /** - * Returns the service interfaces for the package. Reruns the context from which it has been called if the underlying value changes - */ - async const() { - let abort = new AbortController() - const watch = this.watch(abort.signal) - const res = await watch.next() - if (this.effects.constRetry) { - watch - .next() - .then(() => { - abort.abort() - this.effects.constRetry && this.effects.constRetry() - }) - .catch() - } - return res.value - } - /** - * Returns the service interfaces for the package. Does nothing if the value changes - */ - async once() { - const { packageId } = this.opts - const interfaceFilled: ServiceInterfaceFilled[] = - await makeManyInterfaceFilled({ - effects: this.effects, - packageId, - }) - - return this.map(interfaceFilled) - } - - private async *watchGen(abort?: AbortSignal) { - let prev = null as { value: Mapped } | null - const { packageId } = this.opts - const resolveCell = { resolve: () => {} } - this.effects.onLeaveContext(() => { - resolveCell.resolve() - }) - abort?.addEventListener('abort', () => resolveCell.resolve()) - while (this.effects.isInContext && !abort?.aborted) { - let callback: () => void = () => {} - const waitForNext = new Promise((resolve) => { - callback = resolve - resolveCell.resolve = resolve - }) - const next = this.map( - await makeManyInterfaceFilled({ - effects: this.effects, - packageId, - callback, - }), - ) - if (!prev || !this.eq(prev.value, next)) { - yield next - } - await waitForNext - } - return new Promise((_, rej) => rej(new AbortedError())) - } - - /** - * Watches the service interfaces for the package. Returns an async iterator that yields whenever the value changes - */ - watch(abort?: AbortSignal): AsyncGenerator { - const ctrl = new AbortController() - abort?.addEventListener('abort', () => ctrl.abort()) - return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort()) - } - - /** - * Watches the service interfaces for the package. Takes a custom callback function to run whenever the value changes - */ - onChange( - callback: ( - value: Mapped | null, - error?: Error, - ) => { cancel: boolean } | Promise<{ cancel: boolean }>, + options?: { + map?: (value: ServiceInterfaceFilled[]) => Mapped + eq?: (a: Mapped, b: Mapped) => boolean + }, ) { - ;(async () => { - const ctrl = new AbortController() - for await (const value of this.watch(ctrl.signal)) { - try { - const res = await callback(value) - if (res.cancel) { - ctrl.abort() - break - } - } catch (e) { - console.error( - 'callback function threw an error @ GetServiceInterfaces.onChange', - e, - ) - } - } - })() - .catch((e) => callback(null, e)) - .catch((e) => - console.error( - 'callback function threw an error @ GetServiceInterfaces.onChange', - e, - ), - ) + super(effects, options) } - /** - * Watches the service interfaces for the package. Returns when the predicate is true - */ - waitFor(pred: (value: Mapped) => boolean): Promise { - const ctrl = new AbortController() - return DropPromise.of( - Promise.resolve().then(async () => { - for await (const next of this.watchGen(ctrl.signal)) { - if (pred(next)) { - return next - } - } - throw new Error('context left before predicate passed') - }), - () => ctrl.abort(), - ) + protected fetch(callback?: () => void) { + return makeManyInterfaceFilled({ + effects: this.effects, + packageId: this.opts.packageId, + callback, + }) } } -export function getOwnServiceInterfaces(effects: Effects): GetServiceInterfaces +export function getOwnServiceInterfaces( + effects: Effects, +): GetServiceInterfaces export function getOwnServiceInterfaces( effects: Effects, map: (interfaces: ServiceInterfaceFilled[]) => Mapped, @@ -183,12 +77,10 @@ export function getOwnServiceInterfaces( map?: (interfaces: ServiceInterfaceFilled[]) => Mapped, eq?: (a: Mapped, b: Mapped) => boolean, ): GetServiceInterfaces { - return new GetServiceInterfaces( - effects, - {}, - map ?? ((a) => a as Mapped), - eq ?? ((a, b) => deepEqual(a, b)), - ) + return new GetServiceInterfaces(effects, {}, { + map: map ?? ((a) => a as Mapped), + eq: eq ?? ((a, b) => deepEqual(a, b)), + }) } export function getServiceInterfaces( @@ -207,10 +99,8 @@ export function getServiceInterfaces( map?: (interfaces: ServiceInterfaceFilled[]) => Mapped, eq?: (a: Mapped, b: Mapped) => boolean, ): GetServiceInterfaces { - return new GetServiceInterfaces( - effects, - opts, - map ?? ((a) => a as Mapped), - eq ?? ((a, b) => deepEqual(a, b)), - ) + return new GetServiceInterfaces(effects, opts, { + map: map ?? ((a) => a as Mapped), + eq: eq ?? ((a, b) => deepEqual(a, b)), + }) } diff --git a/sdk/base/lib/util/index.ts b/sdk/base/lib/util/index.ts index 3c4606f14..22cf037ba 100644 --- a/sdk/base/lib/util/index.ts +++ b/sdk/base/lib/util/index.ts @@ -19,7 +19,7 @@ export { Watchable } from './Watchable' export { GetContainerIp } from './GetContainerIp' export { GetHostInfo } from './GetHostInfo' export { GetOutboundGateway } from './GetOutboundGateway' -export { GetServiceManifest } from './GetServiceManifest' +export { GetServiceManifest, getServiceManifest } from './GetServiceManifest' export { GetSslCertificate } from './GetSslCertificate' export { GetStatus } from './GetStatus' export { GetSystemSmtp } from './GetSystemSmtp' diff --git a/sdk/package/lib/StartSdk.ts b/sdk/package/lib/StartSdk.ts index 9450e6b8e..4566b2666 100644 --- a/sdk/package/lib/StartSdk.ts +++ b/sdk/package/lib/StartSdk.ts @@ -59,7 +59,8 @@ import { setupOnInit, setupOnUninit, } from '../../base/lib/inits' -import { DropGenerator } from '../../base/lib/util/Drop' +import { GetContainerIp } from '../../base/lib/util/GetContainerIp' +import { GetStatus } from '../../base/lib/util/GetStatus' import { getOwnServiceInterface, ServiceInterfaceFilled, @@ -257,90 +258,7 @@ export class StartSdk { Parameters[0], 'callback' > = {}, - ) => { - async function* watch(abort?: AbortSignal) { - const resolveCell = { resolve: () => {} } - effects.onLeaveContext(() => { - resolveCell.resolve() - }) - abort?.addEventListener('abort', () => resolveCell.resolve()) - while (effects.isInContext && !abort?.aborted) { - let callback: () => void = () => {} - const waitForNext = new Promise((resolve) => { - callback = resolve - resolveCell.resolve = resolve - }) - yield await effects.getContainerIp({ ...options, callback }) - await waitForNext - } - } - return { - const: () => - effects.getContainerIp({ - ...options, - callback: - effects.constRetry && - (() => effects.constRetry && effects.constRetry()), - }), - once: () => effects.getContainerIp(options), - watch: (abort?: AbortSignal) => { - const ctrl = new AbortController() - abort?.addEventListener('abort', () => ctrl.abort()) - return DropGenerator.of(watch(ctrl.signal), () => ctrl.abort()) - }, - onChange: ( - callback: ( - value: string | null, - error?: Error, - ) => { cancel: boolean } | Promise<{ cancel: boolean }>, - ) => { - ;(async () => { - const ctrl = new AbortController() - for await (const value of watch(ctrl.signal)) { - try { - const res = await callback(value) - if (res.cancel) { - ctrl.abort() - break - } - } catch (e) { - console.error( - 'callback function threw an error @ getContainerIp.onChange', - e, - ) - } - } - })() - .catch((e) => callback(null, e)) - .catch((e) => - console.error( - 'callback function threw an error @ getContainerIp.onChange', - e, - ), - ) - }, - waitFor: async (pred: (value: string | null) => boolean) => { - const resolveCell = { resolve: () => {} } - effects.onLeaveContext(() => { - resolveCell.resolve() - }) - while (effects.isInContext) { - let callback: () => void = () => {} - const waitForNext = new Promise((resolve) => { - callback = resolve - resolveCell.resolve = resolve - }) - const res = await effects.getContainerIp({ ...options, callback }) - if (pred(res)) { - resolveCell.resolve() - return res - } - await waitForNext - } - return null - }, - } - }, + ) => new GetContainerIp(effects, options), /** * Get the service's current status with reactive subscription support. @@ -355,90 +273,7 @@ export class StartSdk { getStatus: ( effects: T.Effects, options: Omit[0], 'callback'> = {}, - ) => { - async function* watch(abort?: AbortSignal) { - const resolveCell = { resolve: () => {} } - effects.onLeaveContext(() => { - resolveCell.resolve() - }) - abort?.addEventListener('abort', () => resolveCell.resolve()) - while (effects.isInContext && !abort?.aborted) { - let callback: () => void = () => {} - const waitForNext = new Promise((resolve) => { - callback = resolve - resolveCell.resolve = resolve - }) - yield await effects.getStatus({ ...options, callback }) - await waitForNext - } - } - return { - const: () => - effects.getStatus({ - ...options, - callback: - effects.constRetry && - (() => effects.constRetry && effects.constRetry()), - }), - once: () => effects.getStatus(options), - watch: (abort?: AbortSignal) => { - const ctrl = new AbortController() - abort?.addEventListener('abort', () => ctrl.abort()) - return DropGenerator.of(watch(ctrl.signal), () => ctrl.abort()) - }, - onChange: ( - callback: ( - value: T.StatusInfo | null, - error?: Error, - ) => { cancel: boolean } | Promise<{ cancel: boolean }>, - ) => { - ;(async () => { - const ctrl = new AbortController() - for await (const value of watch(ctrl.signal)) { - try { - const res = await callback(value) - if (res.cancel) { - ctrl.abort() - break - } - } catch (e) { - console.error( - 'callback function threw an error @ getStatus.onChange', - e, - ) - } - } - })() - .catch((e) => callback(null, e)) - .catch((e) => - console.error( - 'callback function threw an error @ getStatus.onChange', - e, - ), - ) - }, - waitFor: async (pred: (value: T.StatusInfo | null) => boolean) => { - const resolveCell = { resolve: () => {} } - effects.onLeaveContext(() => { - resolveCell.resolve() - }) - while (effects.isInContext) { - let callback: () => void = () => {} - const waitForNext = new Promise((resolve) => { - callback = resolve - resolveCell.resolve = resolve - }) - const res = await effects.getStatus({ ...options, callback }) - if (pred(res)) { - resolveCell.resolve() - return res - } - await waitForNext - } - return null - }, - } - }, + ) => new GetStatus(effects, options), MultiHost: { /** @@ -646,7 +481,7 @@ export class StartSdk { effects: E, hostnames: string[], algorithm?: T.Algorithm, - ) => new GetSslCertificate(effects, hostnames, algorithm), + ) => new GetSslCertificate(effects, { hostnames, algorithm }), /** Retrieve the manifest of any installed service package by its ID */ getServiceManifest, healthCheck: { diff --git a/sdk/package/lib/util/GetServiceManifest.ts b/sdk/package/lib/util/GetServiceManifest.ts deleted file mode 100644 index 9f85570d2..000000000 --- a/sdk/package/lib/util/GetServiceManifest.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { Effects } from '../../../base/lib/Effects' -import { Manifest, PackageId } from '../../../base/lib/osBindings' -import { AbortedError } from '../../../base/lib/util/AbortedError' -import { DropGenerator, DropPromise } from '../../../base/lib/util/Drop' -import { deepEqual } from '../../../base/lib/util/deepEqual' - -export class GetServiceManifest { - constructor( - readonly effects: Effects, - readonly packageId: PackageId, - readonly map: (manifest: Manifest | null) => Mapped, - readonly eq: (a: Mapped, b: Mapped) => boolean, - ) {} - - /** - * Returns the manifest of a service. Reruns the context from which it has been called if the underlying value changes - */ - async const() { - let abort = new AbortController() - const watch = this.watch(abort.signal) - const res = await watch.next() - if (this.effects.constRetry) { - watch - .next() - .then(() => { - abort.abort() - this.effects.constRetry && this.effects.constRetry() - }) - .catch() - } - return res.value - } - /** - * Returns the manifest of a service. Does nothing if it changes - */ - async once() { - const manifest = await this.effects.getServiceManifest({ - packageId: this.packageId, - }) - return this.map(manifest) - } - - private async *watchGen(abort?: AbortSignal) { - let prev = null as { value: Mapped } | null - const resolveCell = { resolve: () => {} } - this.effects.onLeaveContext(() => { - resolveCell.resolve() - }) - abort?.addEventListener('abort', () => resolveCell.resolve()) - while (this.effects.isInContext && !abort?.aborted) { - let callback: () => void = () => {} - const waitForNext = new Promise((resolve) => { - callback = resolve - resolveCell.resolve = resolve - }) - const next = this.map( - await this.effects.getServiceManifest({ - packageId: this.packageId, - callback: () => callback(), - }), - ) - if (!prev || !this.eq(prev.value, next)) { - prev = { value: next } - yield next - } - await waitForNext - } - return new Promise((_, rej) => rej(new AbortedError())) - } - - /** - * Watches the manifest of a service. Returns an async iterator that yields whenever the value changes - */ - watch(abort?: AbortSignal): AsyncGenerator { - const ctrl = new AbortController() - abort?.addEventListener('abort', () => ctrl.abort()) - return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort()) - } - - /** - * Watches the manifest of a service. Takes a custom callback function to run whenever it changes - */ - onChange( - callback: ( - value: Mapped | null, - error?: Error, - ) => { cancel: boolean } | Promise<{ cancel: boolean }>, - ) { - ;(async () => { - const ctrl = new AbortController() - for await (const value of this.watch(ctrl.signal)) { - try { - const res = await callback(value) - if (res.cancel) { - ctrl.abort() - break - } - } catch (e) { - console.error( - 'callback function threw an error @ GetServiceManifest.onChange', - e, - ) - } - } - })() - .catch((e) => callback(null, e)) - .catch((e) => - console.error( - 'callback function threw an error @ GetServiceManifest.onChange', - e, - ), - ) - } - - /** - * Watches the manifest of a service. Returns when the predicate is true - */ - waitFor(pred: (value: Mapped) => boolean): Promise { - const ctrl = new AbortController() - return DropPromise.of( - Promise.resolve().then(async () => { - for await (const next of this.watchGen(ctrl.signal)) { - if (pred(next)) { - return next - } - } - throw new Error('context left before predicate passed') - }), - () => ctrl.abort(), - ) - } -} - -export function getServiceManifest( - effects: Effects, - packageId: PackageId, -): GetServiceManifest -export function getServiceManifest( - effects: Effects, - packageId: PackageId, - map: (manifest: Manifest | null) => Mapped, - eq?: (a: Mapped, b: Mapped) => boolean, -): GetServiceManifest -export function getServiceManifest( - effects: Effects, - packageId: PackageId, - map?: (manifest: Manifest | null) => Mapped, - eq?: (a: Mapped, b: Mapped) => boolean, -): GetServiceManifest { - return new GetServiceManifest( - effects, - packageId, - map ?? ((a) => a as Mapped), - eq ?? ((a, b) => deepEqual(a, b)), - ) -} diff --git a/sdk/package/lib/util/GetSslCertificate.ts b/sdk/package/lib/util/GetSslCertificate.ts deleted file mode 100644 index b9967bf22..000000000 --- a/sdk/package/lib/util/GetSslCertificate.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { T } from '..' -import { Effects } from '../../../base/lib/Effects' -import { AbortedError } from '../../../base/lib/util/AbortedError' -import { DropGenerator, DropPromise } from '../../../base/lib/util/Drop' - -export class GetSslCertificate { - constructor( - readonly effects: Effects, - readonly hostnames: string[], - readonly algorithm?: T.Algorithm, - ) {} - - /** - * Returns the an SSL Certificate for the given hostnames if permitted. Restarts the service if it changes - */ - const() { - return this.effects.getSslCertificate({ - hostnames: this.hostnames, - algorithm: this.algorithm, - callback: - this.effects.constRetry && - (() => this.effects.constRetry && this.effects.constRetry()), - }) - } - /** - * Returns the an SSL Certificate for the given hostnames if permitted. Does nothing if it changes - */ - once() { - return this.effects.getSslCertificate({ - hostnames: this.hostnames, - algorithm: this.algorithm, - }) - } - - private async *watchGen(abort?: AbortSignal) { - const resolveCell = { resolve: () => {} } - this.effects.onLeaveContext(() => { - resolveCell.resolve() - }) - abort?.addEventListener('abort', () => resolveCell.resolve()) - while (this.effects.isInContext && !abort?.aborted) { - let callback: () => void = () => {} - const waitForNext = new Promise((resolve) => { - callback = resolve - resolveCell.resolve = resolve - }) - yield await this.effects.getSslCertificate({ - hostnames: this.hostnames, - algorithm: this.algorithm, - callback: () => callback(), - }) - await waitForNext - } - return new Promise((_, rej) => rej(new AbortedError())) - } - - /** - * Watches the SSL Certificate for the given hostnames if permitted. Returns an async iterator that yields whenever the value changes - */ - watch( - abort?: AbortSignal, - ): AsyncGenerator<[string, string, string], never, unknown> { - const ctrl = new AbortController() - abort?.addEventListener('abort', () => ctrl.abort()) - return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort()) - } - - /** - * Watches the SSL Certificate for the given hostnames if permitted. Takes a custom callback function to run whenever it changes - */ - onChange( - callback: ( - value: [string, string, string] | null, - error?: Error, - ) => { cancel: boolean } | Promise<{ cancel: boolean }>, - ) { - ;(async () => { - const ctrl = new AbortController() - for await (const value of this.watch(ctrl.signal)) { - try { - const res = await callback(value) - if (res.cancel) { - ctrl.abort() - break - } - } catch (e) { - console.error( - 'callback function threw an error @ GetSslCertificate.onChange', - e, - ) - } - } - })() - .catch((e) => callback(null, e)) - .catch((e) => - console.error( - 'callback function threw an error @ GetSslCertificate.onChange', - e, - ), - ) - } - - /** - * Watches the SSL Certificate for the given hostnames if permitted. Returns when the predicate is true - */ - waitFor( - pred: (value: [string, string, string] | null) => boolean, - ): Promise<[string, string, string] | null> { - const ctrl = new AbortController() - return DropPromise.of( - Promise.resolve().then(async () => { - for await (const next of this.watchGen(ctrl.signal)) { - if (pred(next)) { - return next - } - } - return null - }), - () => ctrl.abort(), - ) - } -} diff --git a/sdk/package/lib/util/fileHelper.ts b/sdk/package/lib/util/fileHelper.ts index 6b428edfd..1bcde0c90 100644 --- a/sdk/package/lib/util/fileHelper.ts +++ b/sdk/package/lib/util/fileHelper.ts @@ -4,8 +4,8 @@ import * as TOML from '@iarna/toml' import * as INI from 'ini' import * as T from '../../../base/lib/types' import * as fs from 'node:fs/promises' -import { AbortedError, asError, deepEqual } from '../../../base/lib/util' -import { DropGenerator, DropPromise } from '../../../base/lib/util/Drop' +import { asError, deepEqual } from '../../../base/lib/util' +import { Watchable } from '../../../base/lib/util/Watchable' import { PathBase } from './Volume' const previousPath = /(.+?)\/([^/]*)$/ @@ -228,132 +228,72 @@ export class FileHelper { return map(this.validate(data)) } - private async readConst( + private createFileWatchable( effects: T.Effects, map: (value: A) => B, - eq: (left: B | null | undefined, right: B | null) => boolean, - ): Promise { - const watch = this.readWatch(effects, map, eq) - const res = await watch.next() - if (effects.constRetry) { - const record: (typeof this.consts)[number] = [ - effects.constRetry, - res.value, - map, - eq, - ] - this.consts.push(record) - watch - .next() - .then(() => { - this.consts = this.consts.filter((r) => r !== record) - effects.constRetry && effects.constRetry() - }) - .catch() - } - return res.value - } - - private async *readWatch( - effects: T.Effects, - map: (value: A) => B, - eq: (left: B | null | undefined, right: B | null) => boolean, - abort?: AbortSignal, + eq: (left: B | null, right: B | null) => boolean, ) { - let prev: { value: B | null } | null = null - while (effects.isInContext && !abort?.aborted) { - if (await exists(this.path)) { - const ctrl = new AbortController() - abort?.addEventListener('abort', () => ctrl.abort()) - const watch = fs.watch(this.path, { - persistent: false, - signal: ctrl.signal, - }) - const newRes = await this.readOnce(map) - const listen = Promise.resolve() - .then(async () => { - for await (const _ of watch) { - ctrl.abort() - return null - } - }) - .catch((e) => console.error(asError(e))) - if (!prev || !eq(prev.value, newRes)) { - console.error('yielding', JSON.stringify({ prev: prev, newRes })) - yield newRes - } - prev = { value: newRes } - await listen - } else { - yield null - await onCreated(this.path).catch((e) => console.error(asError(e))) - } + const doRead = async (): Promise => { + const data = await this.readFile() + if (!data) return null + return this.validate(data) } - return new Promise((_, rej) => rej(new AbortedError())) - } + const filePath = this.path + const fileHelper = this - private readOnChange( - effects: T.Effects, - callback: ( - value: B | null, - error?: Error, - ) => { cancel: boolean } | Promise<{ cancel: boolean }>, - map: (value: A) => B, - eq: (left: B | null | undefined, right: B | null) => boolean, - ) { - ;(async () => { - const ctrl = new AbortController() - for await (const value of this.readWatch(effects, map, eq, ctrl.signal)) { - try { - const res = await callback(value) - if (res.cancel) ctrl.abort() - } catch (e) { - console.error( - 'callback function threw an error @ FileHelper.read.onChange', - e, - ) - } + const wrappedMap = (raw: A | null): B | null => { + if (raw === null) return null + return map(raw) + } + + return new (class extends Watchable { + protected readonly label = 'FileHelper' + + protected async fetch() { + return doRead() } - })() - .catch((e) => callback(null, e)) - .catch((e) => - console.error( - 'callback function threw an error @ FileHelper.read.onChange', - e, - ), - ) - } - private readWaitFor( - effects: T.Effects, - pred: (value: B | null, error?: Error) => boolean, - map: (value: A) => B, - ): Promise { - const ctrl = new AbortController() - return DropPromise.of( - Promise.resolve().then(async () => { - const watch = this.readWatch(effects, map, (_) => false, ctrl.signal) - while (true) { - try { - const res = await watch.next() - if (pred(res.value)) { - ctrl.abort() - return res.value - } - if (res.done) { - break - } - } catch (e) { - if (pred(null, e as Error)) { - break - } + protected async *produce( + abort: AbortSignal, + ): AsyncGenerator { + while (this.effects.isInContext && !abort.aborted) { + if (await exists(filePath)) { + const ctrl = new AbortController() + abort.addEventListener('abort', () => ctrl.abort()) + const watch = fs.watch(filePath, { + persistent: false, + signal: ctrl.signal, + }) + yield await doRead() + await Promise.resolve() + .then(async () => { + for await (const _ of watch) { + ctrl.abort() + return null + } + }) + .catch((e) => console.error(asError(e))) + } else { + yield null + await onCreated(filePath).catch((e) => console.error(asError(e))) } } - ctrl.abort() - return null - }), - () => ctrl.abort(), - ) + } + + protected onConstRegistered(value: B | null): (() => void) | void { + if (!this.effects.constRetry) return + const record: (typeof fileHelper.consts)[number] = [ + this.effects.constRetry, + value, + wrappedMap, + eq, + ] + fileHelper.consts.push(record) + return () => { + fileHelper.consts = fileHelper.consts.filter((r) => r !== record) + } + } + })(effects, { map: wrappedMap, eq }) } /** @@ -372,7 +312,7 @@ export class FileHelper { read(): ReadType read( map: (value: A) => B, - eq?: (left: B | null | undefined, right: B | null) => boolean, + eq?: (left: B | null, right: B | null) => boolean, ): ReadType read( map?: (value: A) => any, @@ -382,24 +322,19 @@ export class FileHelper { eq = eq ?? deepEqual return { once: () => this.readOnce(map), - const: (effects: T.Effects) => this.readConst(effects, map, eq), - watch: (effects: T.Effects, abort?: AbortSignal) => { - const ctrl = new AbortController() - abort?.addEventListener('abort', () => ctrl.abort()) - return DropGenerator.of( - this.readWatch(effects, map, eq, ctrl.signal), - () => ctrl.abort(), - ) - }, + const: (effects: T.Effects) => + this.createFileWatchable(effects, map, eq).const(), + watch: (effects: T.Effects, abort?: AbortSignal) => + this.createFileWatchable(effects, map, eq).watch(abort), onChange: ( effects: T.Effects, callback: ( value: A | null, error?: Error, ) => { cancel: boolean } | Promise<{ cancel: boolean }>, - ) => this.readOnChange(effects, callback, map, eq), + ) => this.createFileWatchable(effects, map, eq).onChange(callback), waitFor: (effects: T.Effects, pred: (value: A | null) => boolean) => - this.readWaitFor(effects, pred, map), + this.createFileWatchable(effects, map, eq).waitFor(pred), } } diff --git a/sdk/package/lib/util/index.ts b/sdk/package/lib/util/index.ts index 5facab2f8..cdb59d470 100644 --- a/sdk/package/lib/util/index.ts +++ b/sdk/package/lib/util/index.ts @@ -1,6 +1,4 @@ export * from '../../../base/lib/util' -export { GetSslCertificate } from './GetSslCertificate' -export { GetServiceManifest, getServiceManifest } from './GetServiceManifest' export { Drop } from '../../../base/lib/util/Drop' export { Volume, Volumes } from './Volume' From 324f9d17cd2a4e20c5aa975a73ca74faf6de82b4 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Wed, 11 Mar 2026 15:13:48 -0600 Subject: [PATCH 26/71] fix: use z.union instead of z.intersection for health check schema --- .../Systems/SystemForEmbassy/matchManifest.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/container-runtime/src/Adapters/Systems/SystemForEmbassy/matchManifest.ts b/container-runtime/src/Adapters/Systems/SystemForEmbassy/matchManifest.ts index f3fe101eb..d3a309d18 100644 --- a/container-runtime/src/Adapters/Systems/SystemForEmbassy/matchManifest.ts +++ b/container-runtime/src/Adapters/Systems/SystemForEmbassy/matchManifest.ts @@ -10,6 +10,11 @@ const matchJsProcedure = z.object({ const matchProcedure = z.union([matchDockerProcedure, matchJsProcedure]) export type Procedure = z.infer +const healthCheckFields = { + name: z.string(), + "success-message": z.string().nullable().optional(), +} + const matchAction = z.object({ name: z.string(), description: z.string(), @@ -32,13 +37,10 @@ export const matchManifest = z.object({ .optional(), "health-checks": z.record( z.string(), - z.intersection( - matchProcedure, - z.object({ - name: z.string(), - "success-message": z.string().nullable().optional(), - }), - ), + z.union([ + matchDockerProcedure.extend(healthCheckFields), + matchJsProcedure.extend(healthCheckFields), + ]), ), config: z .object({ From 90b73dd3207857c9cc3be76d21865dec7b9d4a9f Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Wed, 11 Mar 2026 15:14:20 -0600 Subject: [PATCH 27/71] feat: support multiple echoip URLs with fallback Rename ifconfig_url to echoip_urls and iterate through configured URLs, falling back to the next one on failure. Reduces timeout per attempt from 10s to 5s. --- core/locales/i18n.yaml | 21 +++++++ core/src/db/model/public.rs | 15 +++-- core/src/lib.rs | 6 +- core/src/net/gateway.rs | 117 ++++++++++++++++++++++-------------- core/src/system/mod.rs | 14 ++--- 5 files changed, 111 insertions(+), 62 deletions(-) diff --git a/core/locales/i18n.yaml b/core/locales/i18n.yaml index d0f92d305..856617307 100644 --- a/core/locales/i18n.yaml +++ b/core/locales/i18n.yaml @@ -1592,6 +1592,13 @@ net.gateway.cannot-delete-without-connection: fr_FR: "Impossible de supprimer l'appareil sans connexion active" pl_PL: "Nie można usunąć urządzenia bez aktywnego połączenia" +net.gateway.no-configured-echoip-urls: + en_US: "No configured echoip URLs" + de_DE: "Keine konfigurierten EchoIP-URLs" + es_ES: "No hay URLs de echoip configuradas" + fr_FR: "Aucune URL echoip configurée" + pl_PL: "Brak skonfigurowanych adresów URL echoip" + # net/dns.rs net.dns.timeout-updating-catalog: en_US: "timed out waiting to update dns catalog" @@ -2753,6 +2760,13 @@ help.arg.download-directory: fr_FR: "Chemin du répertoire de téléchargement" pl_PL: "Ścieżka katalogu do pobrania" +help.arg.echoip-urls: + en_US: "Echo IP service URLs for external IP detection" + de_DE: "Echo-IP-Dienst-URLs zur externen IP-Erkennung" + es_ES: "URLs del servicio Echo IP para detección de IP externa" + fr_FR: "URLs du service Echo IP pour la détection d'IP externe" + pl_PL: "Adresy URL usługi Echo IP do wykrywania zewnętrznego IP" + help.arg.emulate-missing-arch: en_US: "Emulate missing architecture using this one" de_DE: "Fehlende Architektur mit dieser emulieren" @@ -5260,6 +5274,13 @@ about.set-country: fr_FR: "Définir le pays" pl_PL: "Ustaw kraj" +about.set-echoip-urls: + en_US: "Set the Echo IP service URLs" + de_DE: "Die Echo-IP-Dienst-URLs festlegen" + es_ES: "Establecer las URLs del servicio Echo IP" + fr_FR: "Définir les URLs du service Echo IP" + pl_PL: "Ustaw adresy URL usługi Echo IP" + about.set-hostname: en_US: "Set the server hostname" de_DE: "Den Server-Hostnamen festlegen" diff --git a/core/src/db/model/public.rs b/core/src/db/model/public.rs index 30ee515fd..a07375adf 100644 --- a/core/src/db/model/public.rs +++ b/core/src/db/model/public.rs @@ -146,7 +146,7 @@ impl Public { zram: true, governor: None, smtp: None, - ifconfig_url: default_ifconfig_url(), + echoip_urls: default_echoip_urls(), ram: 0, devices: Vec::new(), kiosk, @@ -168,8 +168,11 @@ fn get_platform() -> InternedString { (&*PLATFORM).into() } -pub fn default_ifconfig_url() -> Url { - "https://ifconfig.co".parse().unwrap() +pub fn default_echoip_urls() -> Vec { + vec![ + "https://ipconfig.io".parse().unwrap(), + "https://ifconfig.co".parse().unwrap(), + ] } #[derive(Debug, Deserialize, Serialize, HasModel, TS)] @@ -206,9 +209,9 @@ pub struct ServerInfo { pub zram: bool, pub governor: Option, pub smtp: Option, - #[serde(default = "default_ifconfig_url")] - #[ts(type = "string")] - pub ifconfig_url: Url, + #[serde(default = "default_echoip_urls")] + #[ts(type = "string[]")] + pub echoip_urls: Vec, #[ts(type = "number")] pub ram: u64, pub devices: Vec, diff --git a/core/src/lib.rs b/core/src/lib.rs index 10913503d..bf5805e88 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -400,10 +400,10 @@ pub fn server() -> ParentHandler { .with_call_remote::(), ) .subcommand( - "set-ifconfig-url", - from_fn_async(system::set_ifconfig_url) + "set-echoip-urls", + from_fn_async(system::set_echoip_urls) .no_display() - .with_about("about.set-ifconfig-url") + .with_about("about.set-echoip-urls") .with_call_remote::(), ) .subcommand( diff --git a/core/src/net/gateway.rs b/core/src/net/gateway.rs index a377eb453..33a608f14 100644 --- a/core/src/net/gateway.rs +++ b/core/src/net/gateway.rs @@ -205,7 +205,7 @@ pub async fn check_port( CheckPortParams { port, gateway }: CheckPortParams, ) -> Result { let db = ctx.db.peek().await; - let base_url = db.as_public().as_server_info().as_ifconfig_url().de()?; + let base_urls = db.as_public().as_server_info().as_echoip_urls().de()?; let gateways = db .as_public() .as_server_info() @@ -240,22 +240,41 @@ pub async fn check_port( let client = reqwest::Client::builder(); #[cfg(target_os = "linux")] let client = client.interface(gateway.as_str()); - let url = base_url - .join(&format!("/port/{port}")) - .with_kind(ErrorKind::ParseUrl)?; - let IfconfigPortRes { + let client = client.build()?; + + let mut res = None; + for base_url in base_urls { + let url = base_url + .join(&format!("/port/{port}")) + .with_kind(ErrorKind::ParseUrl)?; + res = Some( + async { + client + .get(url) + .timeout(Duration::from_secs(5)) + .send() + .await? + .error_for_status()? + .json() + .await + } + .await, + ); + if res.as_ref().map_or(false, |r| r.is_ok()) { + break; + } + } + let Some(IfconfigPortRes { ip, port, reachable: open_externally, - } = client - .build()? - .get(url) - .timeout(Duration::from_secs(10)) - .send() - .await? - .error_for_status()? - .json() - .await?; + }) = res.transpose()? + else { + return Err(Error::new( + eyre!("{}", t!("net.gateway.no-configured-echoip-urls")), + ErrorKind::Network, + )); + }; let hairpinning = tokio::time::timeout( Duration::from_secs(5), @@ -761,7 +780,7 @@ async fn get_wan_ipv4(iface: &str, base_url: &Url) -> Result, E let text = client .build()? .get(url) - .timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(5)) .send() .await? .error_for_status()? @@ -857,7 +876,7 @@ async fn watch_ip( .fuse() }); - let mut prev_attempt: Option = None; + let mut echoip_ratelimit_state: BTreeMap = BTreeMap::new(); loop { until @@ -967,7 +986,7 @@ async fn watch_ip( &dhcp4_proxy, &policy_guard, &iface, - &mut prev_attempt, + &mut echoip_ratelimit_state, db, write_to, device_type, @@ -1174,7 +1193,7 @@ async fn poll_ip_info( dhcp4_proxy: &Option>, policy_guard: &Option, iface: &GatewayId, - prev_attempt: &mut Option, + echoip_ratelimit_state: &mut BTreeMap, db: Option<&TypedPatchDb>, write_to: &Watch>, device_type: Option, @@ -1221,43 +1240,49 @@ async fn poll_ip_info( apply_policy_routing(guard, iface, &lan_ip).await?; } - let ifconfig_url = if let Some(db) = db { + let echoip_urls = if let Some(db) = db { db.peek() .await .as_public() .as_server_info() - .as_ifconfig_url() + .as_echoip_urls() .de() - .unwrap_or_else(|_| crate::db::model::public::default_ifconfig_url()) + .unwrap_or_else(|_| crate::db::model::public::default_echoip_urls()) } else { - crate::db::model::public::default_ifconfig_url() + crate::db::model::public::default_echoip_urls() }; - let wan_ip = if prev_attempt.map_or(true, |i| i.elapsed() > Duration::from_secs(300)) - && !subnets.is_empty() - && !matches!( - device_type, - Some(NetworkInterfaceType::Bridge | NetworkInterfaceType::Loopback) - ) { - let res = match get_wan_ipv4(iface.as_str(), &ifconfig_url).await { - Ok(a) => a, - Err(e) => { - tracing::error!( - "{}", - t!( - "net.gateway.failed-to-determine-wan-ip", - iface = iface.to_string(), - error = e.to_string() - ) - ); - tracing::debug!("{e:?}"); - None + let mut wan_ip = None; + for echoip_url in echoip_urls { + let wan_ip = if echoip_ratelimit_state + .get(&echoip_url) + .map_or(true, |i| i.elapsed() > Duration::from_secs(300)) + && !subnets.is_empty() + && !matches!( + device_type, + Some(NetworkInterfaceType::Bridge | NetworkInterfaceType::Loopback) + ) { + match get_wan_ipv4(iface.as_str(), &echoip_url).await { + Ok(a) => { + wan_ip = a; + } + Err(e) => { + tracing::error!( + "{}", + t!( + "net.gateway.failed-to-determine-wan-ip", + iface = iface.to_string(), + error = e.to_string() + ) + ); + tracing::debug!("{e:?}"); + } + }; + echoip_ratelimit_state.insert(echoip_url, Instant::now()); + if wan_ip.is_some() { + break; } }; - *prev_attempt = Some(Instant::now()); - res - } else { - None - }; + } let mut ip_info = IpInfo { name: name.clone(), scope_id, diff --git a/core/src/system/mod.rs b/core/src/system/mod.rs index b0570379b..9caf9687b 100644 --- a/core/src/system/mod.rs +++ b/core/src/system/mod.rs @@ -1172,21 +1172,21 @@ pub async fn clear_system_smtp(ctx: RpcContext) -> Result<(), Error> { } #[derive(Debug, Clone, Deserialize, Serialize, Parser)] -pub struct SetIfconfigUrlParams { - #[arg(help = "help.arg.ifconfig-url")] - pub url: url::Url, +pub struct SetEchoipUrlsParams { + #[arg(help = "help.arg.echoip-urls")] + pub urls: Vec, } -pub async fn set_ifconfig_url( +pub async fn set_echoip_urls( ctx: RpcContext, - SetIfconfigUrlParams { url }: SetIfconfigUrlParams, + SetEchoipUrlsParams { urls }: SetEchoipUrlsParams, ) -> Result<(), Error> { ctx.db .mutate(|db| { db.as_public_mut() .as_server_info_mut() - .as_ifconfig_url_mut() - .ser(&url) + .as_echoip_urls_mut() + .ser(&urls) }) .await .result From 10a5bc0280a86082a7491a43d9ec0fe16cd1fad6 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Wed, 11 Mar 2026 15:17:53 -0600 Subject: [PATCH 28/71] fix: add restart_again flag to DesiredStatus::Restarting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a restart is requested while the service is already restarting (stopped but not yet started), set restart_again so the actor will perform another stop→start cycle after the current one completes. --- core/src/service/effects/control.rs | 2 +- core/src/service/service_actor.rs | 4 +- core/src/service/service_map.rs | 7 +--- core/src/status/mod.rs | 40 ++++++++++++++----- sdk/base/lib/osBindings/DesiredStatus.ts | 2 +- .../services/api/embassy-mock-api.service.ts | 2 +- 6 files changed, 37 insertions(+), 20 deletions(-) diff --git a/core/src/service/effects/control.rs b/core/src/service/effects/control.rs index edaf998e8..4333cf907 100644 --- a/core/src/service/effects/control.rs +++ b/core/src/service/effects/control.rs @@ -163,7 +163,7 @@ pub async fn set_main_status( if prev.is_none() && status == SetMainStatusStatus::Running { s.as_desired_mut().map_mutate(|s| { Ok(match s { - DesiredStatus::Restarting => DesiredStatus::Running, + DesiredStatus::Restarting { .. } => DesiredStatus::Running, x => x, }) })?; diff --git a/core/src/service/service_actor.rs b/core/src/service/service_actor.rs index ed8feafdf..cac038cb3 100644 --- a/core/src/service/service_actor.rs +++ b/core/src/service/service_actor.rs @@ -103,7 +103,7 @@ async fn service_actor_loop<'a>( match status { StatusInfo { - desired: DesiredStatus::Running | DesiredStatus::Restarting, + desired: DesiredStatus::Running | DesiredStatus::Restarting { .. }, started: None, .. } => { @@ -114,7 +114,7 @@ async fn service_actor_loop<'a>( } StatusInfo { desired: - DesiredStatus::Stopped | DesiredStatus::Restarting | DesiredStatus::BackingUp { .. }, + DesiredStatus::Stopped | DesiredStatus::Restarting { .. } | DesiredStatus::BackingUp { .. }, started: Some(_), .. } => { diff --git a/core/src/service/service_map.rs b/core/src/service/service_map.rs index 5c657d562..5668b4094 100644 --- a/core/src/service/service_map.rs +++ b/core/src/service/service_map.rs @@ -243,12 +243,7 @@ impl ServiceMap { PackageState::Installing(installing) }, s9pk: installed_path, - status_info: StatusInfo { - error: None, - health: BTreeMap::new(), - started: None, - desired: DesiredStatus::Stopped, - }, + status_info: StatusInfo::default(), registry, developer_key: Pem::new(developer_key), icon, diff --git a/core/src/status/mod.rs b/core/src/status/mod.rs index ea4d0da98..a9e23cdb2 100644 --- a/core/src/status/mod.rs +++ b/core/src/status/mod.rs @@ -38,7 +38,17 @@ impl Model { .map_mutate(|s| Ok(Some(s.unwrap_or_else(|| Utc::now()))))?; self.as_desired_mut().map_mutate(|s| { Ok(match s { - DesiredStatus::Restarting => DesiredStatus::Running, + DesiredStatus::Restarting { + restart_again: true, + } => { + // Clear the flag but stay Restarting so actor will stop→start again + DesiredStatus::Restarting { + restart_again: false, + } + } + DesiredStatus::Restarting { + restart_again: false, + } => DesiredStatus::Running, a => a, }) })?; @@ -55,7 +65,9 @@ impl Model { Ok(()) } pub fn restart(&mut self) -> Result<(), Error> { - self.as_desired_mut().map_mutate(|s| Ok(s.restart()))?; + let started = self.as_started().transpose_ref().is_some(); + self.as_desired_mut() + .map_mutate(|s| Ok(s.restart(started)))?; self.as_health_mut().ser(&Default::default())?; Ok(()) } @@ -69,7 +81,7 @@ impl Model { DesiredStatus::BackingUp { on_complete: StartStop::Stop, } => DesiredStatus::Stopped, - DesiredStatus::Restarting => DesiredStatus::Running, + DesiredStatus::Restarting { .. } => DesiredStatus::Running, x => x, }) })?; @@ -84,9 +96,14 @@ impl Model { #[serde(rename_all_fields = "camelCase")] pub enum DesiredStatus { Stopped, - Restarting, + Restarting { + #[serde(default)] + restart_again: bool, + }, Running, - BackingUp { on_complete: StartStop }, + BackingUp { + on_complete: StartStop, + }, } impl Default for DesiredStatus { fn default() -> Self { @@ -97,7 +114,7 @@ impl DesiredStatus { pub fn running(&self) -> bool { match self { Self::Running - | Self::Restarting + | Self::Restarting { .. } | Self::BackingUp { on_complete: StartStop::Start, } => true, @@ -140,10 +157,15 @@ impl DesiredStatus { } } - pub fn restart(&self) -> Self { + pub fn restart(&self, started: bool) -> Self { match self { - Self::Running => Self::Restarting, - x => *x, // no-op: restart is meaningless in any other state + Self::Running => Self::Restarting { + restart_again: false, + }, + Self::Restarting { .. } if !started => Self::Restarting { + restart_again: true, + }, + x => *x, } } } diff --git a/sdk/base/lib/osBindings/DesiredStatus.ts b/sdk/base/lib/osBindings/DesiredStatus.ts index 72411a339..56afa2bca 100644 --- a/sdk/base/lib/osBindings/DesiredStatus.ts +++ b/sdk/base/lib/osBindings/DesiredStatus.ts @@ -3,6 +3,6 @@ import type { StartStop } from './StartStop' export type DesiredStatus = | { main: 'stopped' } - | { main: 'restarting' } + | { main: 'restarting'; restartAgain: boolean } | { main: 'running' } | { main: 'backing-up'; onComplete: StartStop } diff --git a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts index 10c2ed162..9f07c5426 100644 --- a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts @@ -1289,7 +1289,7 @@ export class MockApiService extends ApiService { op: PatchOp.REPLACE, path, value: { - desired: { main: 'restarting' }, + desired: { main: 'restarting', restartAgain: false }, started: null, error: null, health: {}, From effcec7e2e1aa9c68bae97fb95725288a1a53fea Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Wed, 11 Mar 2026 15:18:13 -0600 Subject: [PATCH 29/71] feat: add Secure Boot MOK key enrollment and module signing Generate DKMS MOK key pair during OS install, sign all unsigned kernel modules, and enroll the MOK certificate using the user's master password. On reboot, MokManager prompts the user to complete enrollment. Re-enrolls on every boot if the key exists but isn't enrolled yet. Adds setup wizard dialog to inform the user about the MokManager prompt. --- build/dpkg-deps/depends | 2 + build/image-recipe/build.sh | 10 ++ build/lib/scripts/sign-unsigned-modules | 76 +++++++++++ build/lib/scripts/upgrade | 9 ++ core/src/error.rs | 2 + core/src/init.rs | 5 + core/src/os_install/mod.rs | 63 ++++++--- core/src/setup.rs | 2 + core/src/util/mod.rs | 1 + core/src/util/mok.rs | 125 ++++++++++++++++++ sdk/base/lib/osBindings/SetupInfo.ts | 6 +- .../setup-wizard/src/app/app.component.ts | 1 + .../app/components/mok-enrollment.dialog.ts | 77 +++++++++++ .../setup-wizard/src/app/pages/drives.page.ts | 1 + .../src/app/pages/success.page.ts | 15 +++ .../src/app/services/mock-api.service.ts | 1 + .../src/app/services/state.service.ts | 2 + web/projects/setup-wizard/src/app/types.ts | 1 + .../shared/src/i18n/dictionaries/de.ts | 4 + .../shared/src/i18n/dictionaries/en.ts | 5 + .../shared/src/i18n/dictionaries/es.ts | 4 + .../shared/src/i18n/dictionaries/fr.ts | 4 + .../shared/src/i18n/dictionaries/pl.ts | 4 + 23 files changed, 400 insertions(+), 20 deletions(-) create mode 100755 build/lib/scripts/sign-unsigned-modules create mode 100644 core/src/util/mok.rs create mode 100644 web/projects/setup-wizard/src/app/components/mok-enrollment.dialog.ts diff --git a/build/dpkg-deps/depends b/build/dpkg-deps/depends index da2012ae2..3e527e4d9 100644 --- a/build/dpkg-deps/depends +++ b/build/dpkg-deps/depends @@ -11,6 +11,7 @@ cifs-utils conntrack cryptsetup curl +dkms dmidecode dnsutils dosfstools @@ -36,6 +37,7 @@ lvm2 lxc magic-wormhole man-db +mokutil ncdu net-tools network-manager diff --git a/build/image-recipe/build.sh b/build/image-recipe/build.sh index 787f71844..0b3024286 100755 --- a/build/image-recipe/build.sh +++ b/build/image-recipe/build.sh @@ -299,6 +299,16 @@ if [ "${NVIDIA}" = "1" ]; then echo "[nvidia-hook] Removed build dependencies." >&2 fi +# Install linux-kbuild for sign-file (Secure Boot module signing) +KVER_ALL="\$(ls -1t /boot/vmlinuz-* 2>/dev/null | head -n1 | sed 's|.*/vmlinuz-||')" +if [ -n "\${KVER_ALL}" ]; then + KBUILD_VER="\$(echo "\${KVER_ALL}" | grep -oP '^\d+\.\d+')" + if [ -n "\${KBUILD_VER}" ]; then + echo "[build] Installing linux-kbuild-\${KBUILD_VER} for Secure Boot support" >&2 + apt-get install -y "linux-kbuild-\${KBUILD_VER}" || echo "[build] WARNING: linux-kbuild-\${KBUILD_VER} not available" >&2 + fi +fi + cp /etc/resolv.conf /etc/resolv.conf.bak if [ "${IB_SUITE}" = trixie ] && [ "${IB_TARGET_ARCH}" != riscv64 ]; then diff --git a/build/lib/scripts/sign-unsigned-modules b/build/lib/scripts/sign-unsigned-modules new file mode 100755 index 000000000..fdaf11e88 --- /dev/null +++ b/build/lib/scripts/sign-unsigned-modules @@ -0,0 +1,76 @@ +#!/bin/bash + +# sign-unsigned-modules [--source --dest ] [--sign-file ] +# [--mok-key ] [--mok-pub ] +# +# Signs all unsigned kernel modules using the DKMS MOK key. +# +# Default (install) mode: +# Run inside a chroot. Finds and signs unsigned modules in /lib/modules in-place. +# sign-file and MOK key are auto-detected from standard paths. +# +# Overlay mode (--source/--dest): +# Finds unsigned modules in , copies to , signs the copies. +# Clears old signed modules in first. Used during upgrades where the +# overlay upper is tmpfs and writes would be lost. + +set -e + +SOURCE="" +DEST="" +SIGN_FILE="" +MOK_KEY="/var/lib/dkms/mok.key" +MOK_PUB="/var/lib/dkms/mok.pub" + +while [[ $# -gt 0 ]]; do + case $1 in + --source) SOURCE="$2"; shift 2;; + --dest) DEST="$2"; shift 2;; + --sign-file) SIGN_FILE="$2"; shift 2;; + --mok-key) MOK_KEY="$2"; shift 2;; + --mok-pub) MOK_PUB="$2"; shift 2;; + *) echo "Unknown option: $1" >&2; exit 1;; + esac +done + +# Auto-detect sign-file if not specified +if [ -z "$SIGN_FILE" ]; then + SIGN_FILE="$(ls -1 /usr/lib/linux-kbuild-*/scripts/sign-file 2>/dev/null | head -1)" +fi + +if [ -z "$SIGN_FILE" ] || [ ! -x "$SIGN_FILE" ]; then + exit 0 +fi + +if [ ! -f "$MOK_KEY" ] || [ ! -f "$MOK_PUB" ]; then + exit 0 +fi + +COUNT=0 + +if [ -n "$SOURCE" ] && [ -n "$DEST" ]; then + # Overlay mode: find unsigned in source, copy to dest, sign in dest + rm -rf "${DEST}"/lib/modules + + for ko in $(find "${SOURCE}"/lib/modules -name '*.ko' 2>/dev/null); do + if ! modinfo "$ko" 2>/dev/null | grep -q '^sig_id:'; then + rel_path="${ko#${SOURCE}}" + mkdir -p "${DEST}$(dirname "$rel_path")" + cp "$ko" "${DEST}${rel_path}" + "$SIGN_FILE" sha256 "$MOK_KEY" "$MOK_PUB" "${DEST}${rel_path}" + COUNT=$((COUNT + 1)) + fi + done +else + # In-place mode: sign modules directly + for ko in $(find /lib/modules -name '*.ko' 2>/dev/null); do + if ! modinfo "$ko" 2>/dev/null | grep -q '^sig_id:'; then + "$SIGN_FILE" sha256 "$MOK_KEY" "$MOK_PUB" "$ko" + COUNT=$((COUNT + 1)) + fi + done +fi + +if [ $COUNT -gt 0 ]; then + echo "[sign-modules] Signed $COUNT unsigned kernel modules" +fi diff --git a/build/lib/scripts/upgrade b/build/lib/scripts/upgrade index 35230eb0a..6945c7586 100755 --- a/build/lib/scripts/upgrade +++ b/build/lib/scripts/upgrade @@ -83,6 +83,15 @@ if [ -d /sys/firmware/efi ] && [ -f /media/startos/config/efi-installer-entry ]; fi fi +# Sign unsigned kernel modules for Secure Boot +SIGN_FILE="$(ls -1 /media/startos/next/usr/lib/linux-kbuild-*/scripts/sign-file 2>/dev/null | head -1)" +/media/startos/next/usr/lib/startos/scripts/sign-unsigned-modules \ + --source /media/startos/lower \ + --dest /media/startos/config/overlay \ + --sign-file "$SIGN_FILE" \ + --mok-key /media/startos/config/overlay/var/lib/dkms/mok.key \ + --mok-pub /media/startos/config/overlay/var/lib/dkms/mok.pub + sync umount -Rl /media/startos/next diff --git a/core/src/error.rs b/core/src/error.rs index 55b4494b1..88f664394 100644 --- a/core/src/error.rs +++ b/core/src/error.rs @@ -101,6 +101,7 @@ pub enum ErrorKind { UpdateFailed = 77, Smtp = 78, SetSysInfo = 79, + Bios = 80, } impl ErrorKind { pub fn as_str(&self) -> String { @@ -185,6 +186,7 @@ impl ErrorKind { UpdateFailed => t!("error.update-failed"), Smtp => t!("error.smtp"), SetSysInfo => t!("error.set-sys-info"), + Bios => t!("error.bios"), } .to_string() } diff --git a/core/src/init.rs b/core/src/init.rs index e5792adea..fe5f334f2 100644 --- a/core/src/init.rs +++ b/core/src/init.rs @@ -173,6 +173,11 @@ pub async fn init( RpcContext::init_auth_cookie().await?; local_auth.complete(); + // Re-enroll MOK on every boot if Secure Boot key exists but isn't enrolled yet + if let Err(e) = crate::util::mok::enroll_mok(std::path::Path::new(crate::util::mok::DKMS_MOK_PUB)).await { + tracing::warn!("MOK enrollment failed: {e}"); + } + load_database.start(); let db = cfg.db().await?; crate::version::Current::default().pre_init(&db).await?; diff --git a/core/src/os_install/mod.rs b/core/src/os_install/mod.rs index d04491acd..6a1c00f35 100644 --- a/core/src/os_install/mod.rs +++ b/core/src/os_install/mod.rs @@ -21,7 +21,7 @@ use crate::prelude::*; use crate::s9pk::merkle_archive::source::multi_cursor_file::MultiCursorFile; use crate::setup::SetupInfo; use crate::util::Invoke; -use crate::util::io::{TmpDir, delete_file, open_file, write_file_atomic}; +use crate::util::io::{TmpDir, delete_dir, delete_file, open_file, write_file_atomic}; use crate::util::serde::IoFormat; mod gpt; @@ -30,12 +30,7 @@ mod mbr; /// Get the EFI BootCurrent entry number (the entry firmware used to boot). /// Returns None on non-EFI systems or if BootCurrent is not set. async fn get_efi_boot_current() -> Result, Error> { - let efi_output = String::from_utf8( - Command::new("efibootmgr") - .invoke(ErrorKind::Grub) - .await?, - ) - .map_err(|e| Error::new(eyre!("efibootmgr output not valid UTF-8: {e}"), ErrorKind::Grub))?; + let efi_output = String::from_utf8(Command::new("efibootmgr").invoke(ErrorKind::Grub).await?)?; Ok(efi_output .lines() @@ -46,12 +41,7 @@ async fn get_efi_boot_current() -> Result, Error> { /// Promote a specific boot entry to first in the EFI boot order. async fn promote_efi_entry(entry: &str) -> Result<(), Error> { - let efi_output = String::from_utf8( - Command::new("efibootmgr") - .invoke(ErrorKind::Grub) - .await?, - ) - .map_err(|e| Error::new(eyre!("efibootmgr output not valid UTF-8: {e}"), ErrorKind::Grub))?; + let efi_output = String::from_utf8(Command::new("efibootmgr").invoke(ErrorKind::Grub).await?)?; let current_order = efi_output .lines() @@ -182,6 +172,7 @@ struct DataDrive { pub struct InstallOsResult { pub part_info: OsPartitionInfo, pub rootfs: TmpMountGuard, + pub mok_enrolled: bool, } pub async fn install_os_to( @@ -230,6 +221,7 @@ pub async fn install_os_to( delete_file(guard.path().join("config/upgrade")).await?; delete_file(guard.path().join("config/overlay/etc/hostname")).await?; delete_file(guard.path().join("config/disk.guid")).await?; + delete_dir(guard.path().join("config/lib/modules")).await?; Command::new("cp") .arg("-r") .arg(guard.path().join("config")) @@ -265,9 +257,7 @@ pub async fn install_os_to( let config_path = rootfs.path().join("config"); if tokio::fs::metadata("/tmp/config.bak").await.is_ok() { - if tokio::fs::metadata(&config_path).await.is_ok() { - tokio::fs::remove_dir_all(&config_path).await?; - } + crate::util::io::delete_dir(&config_path).await?; Command::new("cp") .arg("-r") .arg("/tmp/config.bak") @@ -402,6 +392,28 @@ pub async fn install_os_to( .invoke(crate::ErrorKind::OpenSsh) .await?; + // Secure Boot: generate MOK key, sign unsigned modules, enroll MOK + let mut mok_enrolled = false; + if use_efi && crate::util::mok::is_secure_boot_enabled().await { + let new_key = crate::util::mok::ensure_dkms_key(overlay.path()).await?; + tracing::info!( + "DKMS MOK key: {}", + if new_key { + "generated" + } else { + "already exists" + } + ); + + crate::util::mok::sign_unsigned_modules(overlay.path()).await?; + + let mok_pub = overlay.path().join(crate::util::mok::DKMS_MOK_PUB.trim_start_matches('/')); + match crate::util::mok::enroll_mok(&mok_pub).await { + Ok(enrolled) => mok_enrolled = enrolled, + Err(e) => tracing::warn!("MOK enrollment failed: {e}"), + } + } + let mut install = Command::new("chroot"); install.arg(overlay.path()).arg("grub-install"); if !use_efi { @@ -443,7 +455,11 @@ pub async fn install_os_to( tokio::fs::remove_dir_all(&work).await?; lower.unmount().await?; - Ok(InstallOsResult { part_info, rootfs }) + Ok(InstallOsResult { + part_info, + rootfs, + mok_enrolled, + }) } pub async fn install_os( @@ -500,7 +516,11 @@ pub async fn install_os( None }; - let InstallOsResult { part_info, rootfs } = install_os_to( + let InstallOsResult { + part_info, + rootfs, + mok_enrolled, + } = install_os_to( "/run/live/medium/live/filesystem.squashfs", &disk.logicalname, disk.capacity, @@ -529,6 +549,7 @@ pub async fn install_os( .mutate(|c| c.os_partitions = Some(part_info.clone())); let mut setup_info = SetupInfo::default(); + setup_info.mok_enrolled = mok_enrolled; if let Some(data_drive) = data_drive { let mut logicalname = &*data_drive.logicalname; @@ -612,7 +633,11 @@ pub async fn cli_install_os( let use_efi = efi.unwrap_or_else(|| !matches!(partition_table, Some(PartitionTable::Mbr))); - let InstallOsResult { part_info, rootfs } = install_os_to( + let InstallOsResult { + part_info, + rootfs, + mok_enrolled: _, + } = install_os_to( &squashfs, &disk, capacity, diff --git a/core/src/setup.rs b/core/src/setup.rs index 850647752..ebb177cbc 100644 --- a/core/src/setup.rs +++ b/core/src/setup.rs @@ -279,6 +279,7 @@ pub enum SetupStatusRes { pub struct SetupInfo { pub guid: Option, pub attach: bool, + pub mok_enrolled: bool, } #[derive(Debug, Deserialize, Serialize, TS)] @@ -630,6 +631,7 @@ async fn fresh_setup( }: SetupExecuteProgress, ) -> Result<(SetupResult, RpcContext), Error> { let account = AccountInfo::new(password, root_ca_start_time().await, hostname)?; + let db = ctx.db().await?; let kiosk = Some(kiosk.unwrap_or(true)).filter(|_| &*PLATFORM != "raspberrypi"); sync_kiosk(kiosk).await?; diff --git a/core/src/util/mod.rs b/core/src/util/mod.rs index 9aac08fd8..6cdc345a1 100644 --- a/core/src/util/mod.rs +++ b/core/src/util/mod.rs @@ -45,6 +45,7 @@ pub mod iter; pub mod logger; pub mod lshw; pub mod mime; +pub mod mok; pub mod net; pub mod rpc; pub mod rpc_client; diff --git a/core/src/util/mok.rs b/core/src/util/mok.rs new file mode 100644 index 000000000..129e30167 --- /dev/null +++ b/core/src/util/mok.rs @@ -0,0 +1,125 @@ +use std::path::Path; + +use tokio::process::Command; + +use crate::prelude::*; +use crate::util::Invoke; +use crate::util::io::{delete_file, maybe_open_file, write_file_atomic}; + +pub const DKMS_MOK_KEY: &str = "/var/lib/dkms/mok.key"; +pub const DKMS_MOK_PUB: &str = "/var/lib/dkms/mok.pub"; + +pub async fn is_secure_boot_enabled() -> bool { + String::from_utf8_lossy( + &Command::new("mokutil") + .arg("--sb-state") + .env("LANG", "C.UTF-8") + .invoke(ErrorKind::Bios) + .await + .unwrap_or_default(), + ) + .contains("SecureBoot enabled") +} + +/// Generate a DKMS MOK key pair if one doesn't exist. +pub async fn ensure_dkms_key(root: &Path) -> Result { + let key_path = root.join(DKMS_MOK_KEY.trim_start_matches('/')); + if maybe_open_file(&key_path).await?.is_some() { + return Ok(false); // Already exists + } + Command::new("chroot") + .arg(root) + .arg("dkms") + .arg("generate_mok") + .invoke(ErrorKind::Bios) + .await?; + Ok(true) // Newly generated +} + +/// Sign all unsigned kernel modules in the given root using the DKMS MOK key. +/// Calls the sign-unsigned-modules script inside the chroot. +pub async fn sign_unsigned_modules(root: &Path) -> Result<(), Error> { + Command::new("chroot") + .arg(root) + .arg("/usr/lib/startos/scripts/sign-unsigned-modules") + .invoke(ErrorKind::OpenSsl) + .await?; + Ok(()) +} + +/// Read the start9 user's password hash from /etc/shadow. +/// Returns None if the user doesn't exist or the password is locked. +async fn start9_shadow_hash() -> Result, Error> { + let shadow = tokio::fs::read_to_string("/etc/shadow").await?; + for line in shadow.lines() { + if let Some(("start9", rest)) = line.split_once(':') { + if let Some((hash, _)) = rest.split_once(':') { + let hash = hash.trim_start_matches("!"); + if hash.starts_with('$') { + return Ok(Some(hash.to_owned())); + } + // Locked or invalid password + return Ok(None); + } + } + } + Ok(None) +} + +/// Enroll the DKMS MOK certificate using the start9 user's password from /etc/shadow. +/// Idempotent: skips if already enrolled, or if the user's password is not yet set. +/// `mok_pub` is the path to the MOK public certificate (may be inside a chroot overlay during install). +/// Returns true if a new enrollment was staged. +pub async fn enroll_mok(mok_pub: &Path) -> Result { + tracing::info!("enroll_mok: checking EFI and mok_pub={}", mok_pub.display()); + if tokio::fs::metadata("/sys/firmware/efi").await.is_err() { + tracing::info!("enroll_mok: no EFI, skipping"); + return Ok(false); + } + if maybe_open_file(mok_pub).await?.is_none() { + tracing::info!("enroll_mok: mok_pub not found, skipping"); + return Ok(false); + } + + // Check if already enrolled in firmware + let test_output = Command::new("mokutil") + .arg("--test-key") + .arg(mok_pub) + .env("LANG", "C.UTF-8") + .invoke(ErrorKind::Bios) + .await?; + let test_str = String::from_utf8(test_output)?; + tracing::info!("enroll_mok: mokutil --test-key output: {test_str:?}"); + if test_str.contains("is enrolled") { + tracing::info!("enroll_mok: already enrolled, skipping"); + return Ok(false); + } + + let Some(hash) = start9_shadow_hash().await? else { + tracing::info!("enroll_mok: start9 user password not set, skipping"); + return Ok(false); + }; + + // Revoke any pending enrollment (so we can re-import with current password) + let _ = Command::new("mokutil") + .arg("--revoke-import") + .arg(mok_pub) + .invoke(ErrorKind::Bios) + .await; + + let hash_file = Path::new("/tmp/mok-password-hash"); + write_file_atomic(hash_file, &hash).await?; + + tracing::info!("Enrolling DKMS MOK certificate"); + let result = Command::new("mokutil") + .arg("--import") + .arg(mok_pub) + .arg("--hash-file") + .arg(hash_file) + .invoke(ErrorKind::Bios) + .await; + + delete_file(hash_file).await.log_err(); + result?; + Ok(true) +} diff --git a/sdk/base/lib/osBindings/SetupInfo.ts b/sdk/base/lib/osBindings/SetupInfo.ts index 06b6447e6..5f78d28c8 100644 --- a/sdk/base/lib/osBindings/SetupInfo.ts +++ b/sdk/base/lib/osBindings/SetupInfo.ts @@ -1,3 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type SetupInfo = { guid: string | null; attach: boolean } +export type SetupInfo = { + guid: string | null + attach: boolean + mokEnrolled: boolean +} diff --git a/web/projects/setup-wizard/src/app/app.component.ts b/web/projects/setup-wizard/src/app/app.component.ts index 1f9b84705..6276aab5b 100644 --- a/web/projects/setup-wizard/src/app/app.component.ts +++ b/web/projects/setup-wizard/src/app/app.component.ts @@ -40,6 +40,7 @@ export class AppComponent { this.stateService.dataDriveGuid = status.guid } this.stateService.attach = status.attach + this.stateService.mokEnrolled = status.mokEnrolled await this.router.navigate(['/language']) break diff --git a/web/projects/setup-wizard/src/app/components/mok-enrollment.dialog.ts b/web/projects/setup-wizard/src/app/components/mok-enrollment.dialog.ts new file mode 100644 index 000000000..03da02d11 --- /dev/null +++ b/web/projects/setup-wizard/src/app/components/mok-enrollment.dialog.ts @@ -0,0 +1,77 @@ +import { Component } from '@angular/core' +import { i18nPipe } from '@start9labs/shared' +import { TuiButton, TuiDialogContext, TuiIcon } from '@taiga-ui/core' +import { injectContext } from '@taiga-ui/polymorpheus' + +@Component({ + standalone: true, + imports: [TuiButton, TuiIcon, i18nPipe], + template: ` +
+ +
+

{{ 'Secure Boot Key Enrollment' | i18n }}

+

+ {{ + 'A signing key was enrolled for Secure Boot. On the next reboot, a blue screen (MokManager) will appear.' + | i18n + }} +

+
    +
  1. Select "Enroll MOK"
  2. +
  3. Select "Continue"
  4. +
  5. {{ 'Enter your StartOS master password when prompted' | i18n }}
  6. +
  7. Select "Reboot"
  8. +
+
+ +
+ `, + styles: ` + :host { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + } + + .icon-container { + margin-bottom: 1rem; + } + + .mok-icon { + width: 3rem; + height: 3rem; + color: var(--tui-status-info); + } + + h3 { + margin: 0 0 0.5rem; + } + + p { + margin: 0 0 1rem; + color: var(--tui-text-secondary); + } + + ol { + text-align: left; + margin: 0 0 1.5rem; + padding-left: 1.5rem; + + li { + margin-bottom: 0.25rem; + } + } + + footer { + display: flex; + justify-content: center; + } + `, +}) +export class MokEnrollmentDialog { + protected readonly context = injectContext>() +} diff --git a/web/projects/setup-wizard/src/app/pages/drives.page.ts b/web/projects/setup-wizard/src/app/pages/drives.page.ts index 1d20848c1..b473d1763 100644 --- a/web/projects/setup-wizard/src/app/pages/drives.page.ts +++ b/web/projects/setup-wizard/src/app/pages/drives.page.ts @@ -390,6 +390,7 @@ export default class DrivesPage { this.stateService.dataDriveGuid = result.guid this.stateService.attach = result.attach + this.stateService.mokEnrolled = result.mokEnrolled loader.unsubscribe() diff --git a/web/projects/setup-wizard/src/app/pages/success.page.ts b/web/projects/setup-wizard/src/app/pages/success.page.ts index c27a539ed..2a9e74903 100644 --- a/web/projects/setup-wizard/src/app/pages/success.page.ts +++ b/web/projects/setup-wizard/src/app/pages/success.page.ts @@ -19,6 +19,7 @@ import { ApiService } from '../services/api.service' import { StateService } from '../services/state.service' import { DocumentationComponent } from '../components/documentation.component' import { MatrixComponent } from '../components/matrix.component' +import { MokEnrollmentDialog } from '../components/mok-enrollment.dialog' import { RemoveMediaDialog } from '../components/remove-media.dialog' import { T } from '@start9labs/start-sdk' import { PolymorpheusComponent } from '@taiga-ui/polymorpheus' @@ -275,6 +276,20 @@ export default class SuccessPage implements AfterViewInit { await this.api.exit() } } + + if (this.stateService.mokEnrolled && this.result.needsRestart) { + this.dialogs + .openComponent( + new PolymorpheusComponent(MokEnrollmentDialog), + { + label: 'Secure Boot', + size: 's', + dismissible: false, + closeable: true, + }, + ) + .subscribe() + } } catch (e: any) { this.errorService.handleError(e) } diff --git a/web/projects/setup-wizard/src/app/services/mock-api.service.ts b/web/projects/setup-wizard/src/app/services/mock-api.service.ts index 743977e94..b749e9853 100644 --- a/web/projects/setup-wizard/src/app/services/mock-api.service.ts +++ b/web/projects/setup-wizard/src/app/services/mock-api.service.ts @@ -116,6 +116,7 @@ export class MockApiService extends ApiService { return { guid: 'mock-data-guid', attach: !params.dataDrive.wipe, + mokEnrolled: false, } } diff --git a/web/projects/setup-wizard/src/app/services/state.service.ts b/web/projects/setup-wizard/src/app/services/state.service.ts index a3aba2c7b..4a5b5090f 100644 --- a/web/projects/setup-wizard/src/app/services/state.service.ts +++ b/web/projects/setup-wizard/src/app/services/state.service.ts @@ -42,6 +42,7 @@ export class StateService { // From install response or status response (incomplete) dataDriveGuid = '' attach = false + mokEnrolled = false // Set during setup flow setupType?: SetupType @@ -116,6 +117,7 @@ export class StateService { this.keyboard = '' this.dataDriveGuid = '' this.attach = false + this.mokEnrolled = false this.setupType = undefined this.recoverySource = undefined } diff --git a/web/projects/setup-wizard/src/app/types.ts b/web/projects/setup-wizard/src/app/types.ts index 5d860666a..c810a4492 100644 --- a/web/projects/setup-wizard/src/app/types.ts +++ b/web/projects/setup-wizard/src/app/types.ts @@ -13,6 +13,7 @@ export interface InstallOsParams { export interface InstallOsRes { guid: string // data drive guid attach: boolean + mokEnrolled: boolean } // === Disk Info Helpers === diff --git a/web/projects/shared/src/i18n/dictionaries/de.ts b/web/projects/shared/src/i18n/dictionaries/de.ts index 95f7152bb..d3b08a699 100644 --- a/web/projects/shared/src/i18n/dictionaries/de.ts +++ b/web/projects/shared/src/i18n/dictionaries/de.ts @@ -709,4 +709,8 @@ export default { 786: 'Automatisch', 787: 'Ausgehender Datenverkehr', 788: 'Gateway verwenden', + 789: 'Secure-Boot-Schlüsselregistrierung', + 790: 'Ein Signaturschlüssel wurde für Secure Boot registriert. Beim nächsten Neustart erscheint ein blauer Bildschirm (MokManager).', + 791: 'Geben Sie Ihr StartOS-Master-Passwort ein, wenn Sie dazu aufgefordert werden', + 792: 'Verstanden', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/en.ts b/web/projects/shared/src/i18n/dictionaries/en.ts index e7856c8fc..150d0b7f9 100644 --- a/web/projects/shared/src/i18n/dictionaries/en.ts +++ b/web/projects/shared/src/i18n/dictionaries/en.ts @@ -709,4 +709,9 @@ export const ENGLISH: Record = { 'Auto': 786, 'Outbound Traffic': 787, 'Use gateway': 788, + // Secure Boot MOK enrollment + 'Secure Boot Key Enrollment': 789, + 'A signing key was enrolled for Secure Boot. On the next reboot, a blue screen (MokManager) will appear.': 790, + 'Enter your StartOS master password when prompted': 791, + 'Got it': 792, } diff --git a/web/projects/shared/src/i18n/dictionaries/es.ts b/web/projects/shared/src/i18n/dictionaries/es.ts index 11cf98218..c08883135 100644 --- a/web/projects/shared/src/i18n/dictionaries/es.ts +++ b/web/projects/shared/src/i18n/dictionaries/es.ts @@ -709,4 +709,8 @@ export default { 786: 'Automático', 787: 'Tráfico saliente', 788: 'Usar gateway', + 789: 'Registro de clave de Secure Boot', + 790: 'Se registró una clave de firma para Secure Boot. En el próximo reinicio, aparecerá una pantalla azul (MokManager).', + 791: 'Ingrese su contraseña maestra de StartOS cuando se le solicite', + 792: 'Entendido', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/fr.ts b/web/projects/shared/src/i18n/dictionaries/fr.ts index 678f5961b..23dce8287 100644 --- a/web/projects/shared/src/i18n/dictionaries/fr.ts +++ b/web/projects/shared/src/i18n/dictionaries/fr.ts @@ -709,4 +709,8 @@ export default { 786: 'Automatique', 787: 'Trafic sortant', 788: 'Utiliser la passerelle', + 789: "Enregistrement de la clé Secure Boot", + 790: "Une clé de signature a été enregistrée pour Secure Boot. Au prochain redémarrage, un écran bleu (MokManager) apparaîtra.", + 791: 'Entrez votre mot de passe principal StartOS lorsque vous y êtes invité', + 792: 'Compris', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/pl.ts b/web/projects/shared/src/i18n/dictionaries/pl.ts index b26484268..deef38a37 100644 --- a/web/projects/shared/src/i18n/dictionaries/pl.ts +++ b/web/projects/shared/src/i18n/dictionaries/pl.ts @@ -709,4 +709,8 @@ export default { 786: 'Automatycznie', 787: 'Ruch wychodzący', 788: 'Użyj bramy', + 789: 'Rejestracja klucza Secure Boot', + 790: 'Klucz podpisu został zarejestrowany dla Secure Boot. Przy następnym uruchomieniu pojawi się niebieski ekran (MokManager).', + 791: 'Wprowadź swoje hasło główne StartOS po wyświetleniu monitu', + 792: 'Rozumiem', } satisfies i18n From efc12691bd0ae4e34316899798795ced1c39d498 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Thu, 12 Mar 2026 11:09:15 -0600 Subject: [PATCH 30/71] chore: reformat SDK utility files --- sdk/base/lib/util/GetServiceManifest.ts | 19 ++++++++++++------- sdk/base/lib/util/getServiceInterface.ts | 12 ++++++++---- sdk/base/lib/util/getServiceInterfaces.ts | 16 +++++++++------- 3 files changed, 29 insertions(+), 18 deletions(-) diff --git a/sdk/base/lib/util/GetServiceManifest.ts b/sdk/base/lib/util/GetServiceManifest.ts index 2afa60fbb..09075f27b 100644 --- a/sdk/base/lib/util/GetServiceManifest.ts +++ b/sdk/base/lib/util/GetServiceManifest.ts @@ -3,9 +3,10 @@ import { Manifest, PackageId } from '../osBindings' import { deepEqual } from './deepEqual' import { Watchable } from './Watchable' -export class GetServiceManifest< - Mapped = Manifest | null, -> extends Watchable { +export class GetServiceManifest extends Watchable< + Manifest | null, + Mapped +> { protected readonly label = 'GetServiceManifest' constructor( @@ -40,8 +41,12 @@ export function getServiceManifest( map?: (manifest: Manifest | null) => Mapped, eq?: (a: Mapped, b: Mapped) => boolean, ): GetServiceManifest { - return new GetServiceManifest(effects, { packageId }, { - map: map ?? ((a) => a as Mapped), - eq: eq ?? ((a, b) => deepEqual(a, b)), - }) + return new GetServiceManifest( + effects, + { packageId }, + { + map: map ?? ((a) => a as Mapped), + eq: eq ?? ((a, b) => deepEqual(a, b)), + }, + ) } diff --git a/sdk/base/lib/util/getServiceInterface.ts b/sdk/base/lib/util/getServiceInterface.ts index 527dc09dc..b5b7af325 100644 --- a/sdk/base/lib/util/getServiceInterface.ts +++ b/sdk/base/lib/util/getServiceInterface.ts @@ -481,10 +481,14 @@ export function getOwnServiceInterface( map?: (interfaces: ServiceInterfaceFilled | null) => Mapped, eq?: (a: Mapped, b: Mapped) => boolean, ): GetServiceInterface { - return new GetServiceInterface(effects, { id }, { - map: map ?? ((a) => a as Mapped), - eq: eq ?? ((a, b) => deepEqual(a, b)), - }) + return new GetServiceInterface( + effects, + { id }, + { + map: map ?? ((a) => a as Mapped), + eq: eq ?? ((a, b) => deepEqual(a, b)), + }, + ) } export function getServiceInterface( diff --git a/sdk/base/lib/util/getServiceInterfaces.ts b/sdk/base/lib/util/getServiceInterfaces.ts index 73a176808..a11199927 100644 --- a/sdk/base/lib/util/getServiceInterfaces.ts +++ b/sdk/base/lib/util/getServiceInterfaces.ts @@ -64,9 +64,7 @@ export class GetServiceInterfaces< } } -export function getOwnServiceInterfaces( - effects: Effects, -): GetServiceInterfaces +export function getOwnServiceInterfaces(effects: Effects): GetServiceInterfaces export function getOwnServiceInterfaces( effects: Effects, map: (interfaces: ServiceInterfaceFilled[]) => Mapped, @@ -77,10 +75,14 @@ export function getOwnServiceInterfaces( map?: (interfaces: ServiceInterfaceFilled[]) => Mapped, eq?: (a: Mapped, b: Mapped) => boolean, ): GetServiceInterfaces { - return new GetServiceInterfaces(effects, {}, { - map: map ?? ((a) => a as Mapped), - eq: eq ?? ((a, b) => deepEqual(a, b)), - }) + return new GetServiceInterfaces( + effects, + {}, + { + map: map ?? ((a) => a as Mapped), + eq: eq ?? ((a, b) => deepEqual(a, b)), + }, + ) } export function getServiceInterfaces( From 0070a8e692ad2d6a3b14196eaefa7378019a88b2 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Thu, 12 Mar 2026 11:10:24 -0600 Subject: [PATCH 31/71] refactor: derive OsPartitionInfo from fstab instead of config.yaml Replace the serialized os_partitions field in ServerConfig with runtime fstab parsing. OsPartitionInfo::from_fstab() resolves PARTUUID/UUID/LABEL device specs via blkid and discovers the BIOS boot partition by scanning for its GPT type GUID via lsblk. Also removes the efibootmgr-based boot order management (replaced by GRUB-based USB detection in a subsequent commit) and adds a dedicated bios: Option field for the unformatted BIOS boot partition. --- build/lib/scripts/upgrade | 15 ---- core/src/context/config.rs | 4 - core/src/context/rpc.rs | 7 +- core/src/disk/mod.rs | 148 +++++++++++++++++++++++++++++++++---- core/src/os_install/gpt.rs | 12 ++- core/src/os_install/mbr.rs | 2 +- core/src/os_install/mod.rs | 91 ++--------------------- core/src/setup.rs | 4 +- 8 files changed, 153 insertions(+), 130 deletions(-) diff --git a/build/lib/scripts/upgrade b/build/lib/scripts/upgrade index 6945c7586..a7559987f 100755 --- a/build/lib/scripts/upgrade +++ b/build/lib/scripts/upgrade @@ -68,21 +68,6 @@ fi EOF -# Promote the USB installer boot entry back to first in EFI boot order. -# The entry number was saved during initial OS install. -if [ -d /sys/firmware/efi ] && [ -f /media/startos/config/efi-installer-entry ]; then - USB_ENTRY=$(cat /media/startos/config/efi-installer-entry) - if [ -n "$USB_ENTRY" ]; then - CURRENT_ORDER=$(efibootmgr | grep BootOrder | sed 's/BootOrder: //') - OTHER_ENTRIES=$(echo "$CURRENT_ORDER" | tr ',' '\n' | grep -v "$USB_ENTRY" | tr '\n' ',' | sed 's/,$//') - if [ -n "$OTHER_ENTRIES" ]; then - efibootmgr -o "$USB_ENTRY,$OTHER_ENTRIES" - else - efibootmgr -o "$USB_ENTRY" - fi - fi -fi - # Sign unsigned kernel modules for Secure Boot SIGN_FILE="$(ls -1 /media/startos/next/usr/lib/linux-kbuild-*/scripts/sign-file 2>/dev/null | head -1)" /media/startos/next/usr/lib/startos/scripts/sign-unsigned-modules \ diff --git a/core/src/context/config.rs b/core/src/context/config.rs index fb76f96ce..4e1dd4827 100644 --- a/core/src/context/config.rs +++ b/core/src/context/config.rs @@ -9,7 +9,6 @@ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use crate::MAIN_DATA; -use crate::disk::OsPartitionInfo; use crate::prelude::*; use crate::util::serde::IoFormat; use crate::version::VersionT; @@ -120,8 +119,6 @@ impl ClientConfig { pub struct ServerConfig { #[arg(short, long, help = "help.arg.config-file-path")] pub config: Option, - #[arg(skip)] - pub os_partitions: Option, #[arg(long, help = "help.arg.socks-listen-address")] pub socks_listen: Option, #[arg(long, help = "help.arg.revision-cache-size")] @@ -138,7 +135,6 @@ impl ContextConfig for ServerConfig { self.config.take() } fn merge_with(&mut self, other: Self) { - self.os_partitions = self.os_partitions.take().or(other.os_partitions); self.socks_listen = self.socks_listen.take().or(other.socks_listen); self.revision_cache_size = self .revision_cache_size diff --git a/core/src/context/rpc.rs b/core/src/context/rpc.rs index f1fb6343d..61ce35020 100644 --- a/core/src/context/rpc.rs +++ b/core/src/context/rpc.rs @@ -327,12 +327,7 @@ impl RpcContext { let seed = Arc::new(RpcContextSeed { is_closed: AtomicBool::new(false), - os_partitions: config.os_partitions.clone().ok_or_else(|| { - Error::new( - eyre!("{}", t!("context.rpc.os-partition-info-missing")), - ErrorKind::Filesystem, - ) - })?, + os_partitions: OsPartitionInfo::from_fstab().await?, wifi_interface: wifi_interface.clone(), ethernet_interface: find_eth_iface().await?, disk_guid, diff --git a/core/src/disk/mod.rs b/core/src/disk/mod.rs index aea0ad9a3..ed312aede 100644 --- a/core/src/disk/mod.rs +++ b/core/src/disk/mod.rs @@ -1,13 +1,17 @@ +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use itertools::Itertools; use lazy_format::lazy_format; use rpc_toolkit::{CallRemoteHandler, Context, Empty, HandlerExt, ParentHandler, from_fn_async}; use serde::{Deserialize, Serialize}; +use tokio::process::Command; -use crate::Error; +use crate::{Error, ErrorKind}; use crate::context::{CliContext, RpcContext}; use crate::disk::util::DiskInfo; +use crate::prelude::*; +use crate::util::Invoke; use crate::util::serde::{HandlerExtSerde, WithIoFormat, display_serializable}; pub mod fsck; @@ -21,27 +25,143 @@ pub const REPAIR_DISK_PATH: &str = "/media/startos/config/repair-disk"; #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct OsPartitionInfo { - pub efi: Option, pub bios: Option, pub boot: PathBuf, pub root: PathBuf, - #[serde(skip)] // internal use only + #[serde(default)] + pub extra_boot: BTreeMap, + #[serde(skip)] pub data: Option, } impl OsPartitionInfo { pub fn contains(&self, logicalname: impl AsRef) -> bool { - self.efi - .as_ref() - .map(|p| p == logicalname.as_ref()) - .unwrap_or(false) - || self - .bios - .as_ref() - .map(|p| p == logicalname.as_ref()) - .unwrap_or(false) - || &*self.boot == logicalname.as_ref() - || &*self.root == logicalname.as_ref() + let p = logicalname.as_ref(); + self.bios.as_deref() == Some(p) + || p == &*self.boot + || p == &*self.root + || self.extra_boot.values().any(|v| v == p) } + + /// Build partition info by parsing /etc/fstab and resolving device specs, + /// then discovering the BIOS boot partition (which is never mounted). + pub async fn from_fstab() -> Result { + let fstab = tokio::fs::read_to_string("/etc/fstab") + .await + .with_ctx(|_| (ErrorKind::Filesystem, "/etc/fstab"))?; + + let mut boot = None; + let mut root = None; + let mut extra_boot = BTreeMap::new(); + + for line in fstab.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let mut fields = line.split_whitespace(); + let Some(source) = fields.next() else { + continue; + }; + let Some(target) = fields.next() else { + continue; + }; + + let dev = match resolve_fstab_source(source).await { + Ok(d) => d, + Err(e) => { + tracing::warn!("Failed to resolve fstab source {source}: {e}"); + continue; + } + }; + + match target { + "/" => root = Some(dev), + "/boot" => boot = Some(dev), + t if t.starts_with("/boot/") => { + if let Some(name) = t.strip_prefix("/boot/") { + extra_boot.insert(name.to_string(), dev); + } + } + _ => {} + } + } + + let boot = boot.unwrap_or_default(); + let bios = if !boot.as_os_str().is_empty() { + find_bios_boot_partition(&boot).await.ok().flatten() + } else { + None + }; + + Ok(Self { + bios, + boot, + root: root.unwrap_or_default(), + extra_boot, + data: None, + }) + } +} + +const BIOS_BOOT_TYPE_GUID: &str = "21686148-6449-6e6f-744e-656564726548"; + +/// Find the BIOS boot partition on the same disk as `known_part`. +async fn find_bios_boot_partition(known_part: &Path) -> Result, Error> { + let output = Command::new("lsblk") + .args(["-n", "-l", "-o", "NAME,PKNAME,PARTTYPE"]) + .arg(known_part) + .invoke(ErrorKind::DiskManagement) + .await?; + let text = String::from_utf8(output)?; + + let parent_disk = text.lines().find_map(|line| { + let mut fields = line.split_whitespace(); + let _name = fields.next()?; + let pkname = fields.next()?; + (!pkname.is_empty()).then(|| pkname.to_string()) + }); + + let Some(parent_disk) = parent_disk else { + return Ok(None); + }; + + let output = Command::new("lsblk") + .args(["-n", "-l", "-o", "NAME,PARTTYPE"]) + .arg(format!("/dev/{parent_disk}")) + .invoke(ErrorKind::DiskManagement) + .await?; + let text = String::from_utf8(output)?; + + for line in text.lines() { + let mut fields = line.split_whitespace(); + let Some(name) = fields.next() else { continue }; + let Some(parttype) = fields.next() else { + continue; + }; + if parttype.eq_ignore_ascii_case(BIOS_BOOT_TYPE_GUID) { + return Ok(Some(PathBuf::from(format!("/dev/{name}")))); + } + } + + Ok(None) +} + +/// Resolve an fstab device spec (e.g. /dev/sda1, PARTUUID=..., UUID=...) to a +/// canonical device path. +async fn resolve_fstab_source(source: &str) -> Result { + if source.starts_with('/') { + return Ok( + tokio::fs::canonicalize(source) + .await + .unwrap_or_else(|_| PathBuf::from(source)), + ); + } + // PARTUUID=, UUID=, LABEL= — resolve via blkid + let output = Command::new("blkid") + .args(["-o", "device", "-t", source]) + .invoke(ErrorKind::DiskManagement) + .await?; + Ok(PathBuf::from(String::from_utf8(output)?.trim())) } pub fn disk() -> ParentHandler { diff --git a/core/src/os_install/gpt.rs b/core/src/os_install/gpt.rs index 0fe5d0665..932538d98 100644 --- a/core/src/os_install/gpt.rs +++ b/core/src/os_install/gpt.rs @@ -197,11 +197,19 @@ pub async fn partition( .invoke(crate::ErrorKind::DiskManagement) .await?; + let mut extra_boot = std::collections::BTreeMap::new(); + let bios; + if efi { + extra_boot.insert("efi".to_string(), partition_for(&disk_path, 1)); + bios = None; + } else { + bios = Some(partition_for(&disk_path, 1)); + } Ok(OsPartitionInfo { - efi: efi.then(|| partition_for(&disk_path, 1)), - bios: (!efi).then(|| partition_for(&disk_path, 1)), + bios, boot: partition_for(&disk_path, 2), root: partition_for(&disk_path, 3), + extra_boot, data: data_part, }) } diff --git a/core/src/os_install/mbr.rs b/core/src/os_install/mbr.rs index b121198f8..090fa9554 100644 --- a/core/src/os_install/mbr.rs +++ b/core/src/os_install/mbr.rs @@ -164,10 +164,10 @@ pub async fn partition( .await?; Ok(OsPartitionInfo { - efi: None, bios: None, boot: partition_for(&disk_path, 1), root: partition_for(&disk_path, 2), + extra_boot: Default::default(), data: data_part, }) } diff --git a/core/src/os_install/mod.rs b/core/src/os_install/mod.rs index 6a1c00f35..72918042d 100644 --- a/core/src/os_install/mod.rs +++ b/core/src/os_install/mod.rs @@ -27,53 +27,6 @@ use crate::util::serde::IoFormat; mod gpt; mod mbr; -/// Get the EFI BootCurrent entry number (the entry firmware used to boot). -/// Returns None on non-EFI systems or if BootCurrent is not set. -async fn get_efi_boot_current() -> Result, Error> { - let efi_output = String::from_utf8(Command::new("efibootmgr").invoke(ErrorKind::Grub).await?)?; - - Ok(efi_output - .lines() - .find(|line| line.starts_with("BootCurrent:")) - .and_then(|line| line.strip_prefix("BootCurrent:")) - .map(|s| s.trim().to_string())) -} - -/// Promote a specific boot entry to first in the EFI boot order. -async fn promote_efi_entry(entry: &str) -> Result<(), Error> { - let efi_output = String::from_utf8(Command::new("efibootmgr").invoke(ErrorKind::Grub).await?)?; - - let current_order = efi_output - .lines() - .find(|line| line.starts_with("BootOrder:")) - .and_then(|line| line.strip_prefix("BootOrder:")) - .map(|s| s.trim()) - .unwrap_or(""); - - if current_order.is_empty() || current_order.starts_with(entry) { - return Ok(()); - } - - let other_entries: Vec<&str> = current_order - .split(',') - .filter(|e| e.trim() != entry) - .collect(); - - let new_order = if other_entries.is_empty() { - entry.to_string() - } else { - format!("{},{}", entry, other_entries.join(",")) - }; - - Command::new("efibootmgr") - .arg("-o") - .arg(&new_order) - .invoke(ErrorKind::Grub) - .await?; - - Ok(()) -} - /// Probe a squashfs image to determine its target architecture async fn probe_squashfs_arch(squashfs_path: &Path) -> Result { let output = String::from_utf8( @@ -190,7 +143,7 @@ pub async fn install_os_to( let part_info = partition(disk_path, capacity, partition_table, protect, use_efi).await?; - if let Some(efi) = &part_info.efi { + if let Some(efi) = part_info.extra_boot.get("efi") { Command::new("mkfs.vfat") .arg(efi) .invoke(crate::ErrorKind::DiskManagement) @@ -307,10 +260,7 @@ pub async fn install_os_to( tokio::fs::write( rootfs.path().join("config/config.yaml"), - IoFormat::Yaml.to_vec(&ServerConfig { - os_partitions: Some(part_info.clone()), - ..Default::default() - })?, + IoFormat::Yaml.to_vec(&ServerConfig::default())?, ) .await?; @@ -329,7 +279,7 @@ pub async fn install_os_to( ReadWrite, ) .await?; - let efi = if let Some(efi) = &part_info.efi { + let efi = if let Some(efi) = part_info.extra_boot.get("efi") { Some( MountGuard::mount( &BlockDev::new(efi), @@ -370,8 +320,8 @@ pub async fn install_os_to( include_str!("fstab.template"), boot = part_info.boot.display(), efi = part_info - .efi - .as_ref() + .extra_boot + .get("efi") .map(|p| p.display().to_string()) .unwrap_or_else(|| "# N/A".to_owned()), root = part_info.root.display(), @@ -502,20 +452,6 @@ pub async fn install_os( let use_efi = tokio::fs::metadata("/sys/firmware/efi").await.is_ok(); - // Save the boot entry we booted from (the USB installer) before grub-install - // overwrites the boot order. - let boot_current = if use_efi { - match get_efi_boot_current().await { - Ok(entry) => entry, - Err(e) => { - tracing::warn!("Failed to get EFI BootCurrent: {e}"); - None - } - } - } else { - None - }; - let InstallOsResult { part_info, rootfs, @@ -531,23 +467,6 @@ pub async fn install_os( ) .await?; - // grub-install prepends its new entry to the EFI boot order, overriding the - // USB-first priority. Promote the USB entry (identified by BootCurrent from - // when we booted the installer) back to first, and persist the entry number - // so the upgrade script can do the same. - if let Some(ref entry) = boot_current { - if let Err(e) = promote_efi_entry(entry).await { - tracing::warn!("Failed to restore EFI boot order: {e}"); - } - let efi_entry_path = rootfs.path().join("config/efi-installer-entry"); - if let Err(e) = tokio::fs::write(&efi_entry_path, entry).await { - tracing::warn!("Failed to save EFI installer entry number: {e}"); - } - } - - ctx.config - .mutate(|c| c.os_partitions = Some(part_info.clone())); - let mut setup_info = SetupInfo::default(); setup_info.mok_enrolled = mok_enrolled; diff --git a/core/src/setup.rs b/core/src/setup.rs index ebb177cbc..1b33268b3 100644 --- a/core/src/setup.rs +++ b/core/src/setup.rs @@ -95,8 +95,8 @@ const LIVE_MEDIUM_PATH: &str = "/run/live/medium"; pub async fn list_disks(ctx: SetupContext) -> Result, Error> { let mut disks = crate::disk::util::list( - &ctx.config - .peek(|c| c.os_partitions.clone()) + &crate::disk::OsPartitionInfo::from_fstab() + .await .unwrap_or_default(), ) .await?; From d12b278a845f7dd52a7229be01e0391e2bd01dff Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Thu, 12 Mar 2026 11:11:14 -0600 Subject: [PATCH 32/71] feat: switch os_install root filesystem from ext4 to btrfs --- core/src/os_install/fstab.template | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/os_install/fstab.template b/core/src/os_install/fstab.template index c299a0aae..196a06be7 100644 --- a/core/src/os_install/fstab.template +++ b/core/src/os_install/fstab.template @@ -1,3 +1,3 @@ {boot} /boot vfat umask=0077 0 2 {efi} /boot/efi vfat umask=0077 0 1 -{root} / ext4 defaults 0 1 \ No newline at end of file +{root} / btrfs defaults 0 1 \ No newline at end of file From dba1cb93c11ab2a602fd41b647ed1a3ba2f85452 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Thu, 12 Mar 2026 11:12:04 -0600 Subject: [PATCH 33/71] feat: raspberry pi U-Boot + GPT + btrfs boot chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch Raspberry Pi builds from proprietary firmware direct-boot to a firmware → U-Boot → GRUB → kernel chain using GPT partitioning: - GPT partition layout with fixed UUIDs matching os_install: firmware (128MB), ESP (100MB), boot (2GB FAT32), root (btrfs) - U-Boot as the kernel in config.txt, chainloading GRUB EFI - Pi-specific GRUB config overrides (console, USB quirks, cgroup) - Btrfs root with shrink-to-minimum for image compression - init_resize.sh updated for GPT (sgdisk -e) and btrfs resize - Removed os-partitions from config.yaml (now derived from fstab) --- build/dpkg-deps/raspberrypi.depends | 3 +- build/image-recipe/Dockerfile | 2 + build/image-recipe/build.sh | 125 +++++++++++++----- build/image-recipe/raspberrypi/img/etc/fstab | 6 +- .../usr/lib/startos/scripts/init_resize.sh | 30 ++--- .../raspberrypi/squashfs/boot/cmdline.txt | 1 - .../raspberrypi/squashfs/boot/config.sh | 16 +-- .../raspberrypi/squashfs/boot/config.txt | 6 +- .../etc/default/grub.d/raspberrypi.cfg | 4 + .../squashfs/etc/startos/config.yaml | 3 - 10 files changed, 130 insertions(+), 66 deletions(-) delete mode 100644 build/image-recipe/raspberrypi/squashfs/boot/cmdline.txt create mode 100644 build/image-recipe/raspberrypi/squashfs/etc/default/grub.d/raspberrypi.cfg diff --git a/build/dpkg-deps/raspberrypi.depends b/build/dpkg-deps/raspberrypi.depends index b8f74d108..9066caffd 100644 --- a/build/dpkg-deps/raspberrypi.depends +++ b/build/dpkg-deps/raspberrypi.depends @@ -1,5 +1,6 @@ -- grub-efi ++ gdisk + parted ++ u-boot-rpi + raspberrypi-net-mods + raspberrypi-sys-mods + raspi-config diff --git a/build/image-recipe/Dockerfile b/build/image-recipe/Dockerfile index c53627214..13d1a80b0 100644 --- a/build/image-recipe/Dockerfile +++ b/build/image-recipe/Dockerfile @@ -23,6 +23,8 @@ RUN apt-get update && \ squashfs-tools \ rsync \ b3sum \ + btrfs-progs \ + gdisk \ dpkg-dev diff --git a/build/image-recipe/build.sh b/build/image-recipe/build.sh index 0b3024286..bc6fa43e7 100755 --- a/build/image-recipe/build.sh +++ b/build/image-recipe/build.sh @@ -132,6 +132,10 @@ ff02::1 ip6-allnodes ff02::2 ip6-allrouters EOT +# Installer marker file (used by installed GRUB to detect the live USB) +mkdir -p config/includes.binary +touch config/includes.binary/.startos-installer + if [ "${IB_TARGET_PLATFORM}" = "raspberrypi" ]; then mkdir -p config/includes.chroot git clone --depth=1 --branch=stable https://github.com/raspberrypi/rpi-firmware.git config/includes.chroot/boot @@ -322,9 +326,10 @@ fi if [ "${IB_TARGET_PLATFORM}" = "raspberrypi" ]; then ln -sf /usr/bin/pi-beep /usr/local/bin/beep - KERNEL_VERSION=${RPI_KERNEL_VERSION} sh /boot/config.sh > /boot/config.txt + sh /boot/config.sh > /boot/config.txt mkinitramfs -c gzip -o /boot/initrd.img-${RPI_KERNEL_VERSION}-rpi-v8 ${RPI_KERNEL_VERSION}-rpi-v8 mkinitramfs -c gzip -o /boot/initrd.img-${RPI_KERNEL_VERSION}-rpi-2712 ${RPI_KERNEL_VERSION}-rpi-2712 + cp /usr/lib/u-boot/rpi_arm64/u-boot.bin /boot/u-boot.bin fi useradd --shell /bin/bash -G startos -m start9 @@ -390,38 +395,65 @@ if [ "${IMAGE_TYPE}" = iso ]; then elif [ "${IMAGE_TYPE}" = img ]; then SECTOR_LEN=512 - BOOT_START=$((1024 * 1024)) # 1MiB - BOOT_LEN=$((512 * 1024 * 1024)) # 512MiB + FW_START=$((1024 * 1024)) # 1MiB (sector 2048) — Pi-specific + FW_LEN=$((128 * 1024 * 1024)) # 128MiB (Pi firmware + U-Boot + DTBs) + FW_END=$((FW_START + FW_LEN - 1)) + ESP_START=$((FW_END + 1)) # 100MB EFI System Partition (matches os_install) + ESP_LEN=$((100 * 1024 * 1024)) + ESP_END=$((ESP_START + ESP_LEN - 1)) + BOOT_START=$((ESP_END + 1)) # 2GB /boot (matches os_install) + BOOT_LEN=$((2 * 1024 * 1024 * 1024)) BOOT_END=$((BOOT_START + BOOT_LEN - 1)) ROOT_START=$((BOOT_END + 1)) ROOT_LEN=$((MAX_IMG_LEN - ROOT_START)) - ROOT_END=$((MAX_IMG_LEN - 1)) + + # Fixed GPT partition UUIDs (deterministic, based on old MBR disk ID cb15ae4d) + FW_UUID=cb15ae4d-0001-4000-8000-000000000001 + ESP_UUID=cb15ae4d-0002-4000-8000-000000000002 + BOOT_UUID=cb15ae4d-0003-4000-8000-000000000003 + ROOT_UUID=cb15ae4d-0004-4000-8000-000000000004 TARGET_NAME=$prep_results_dir/${IMAGE_BASENAME}.img truncate -s $MAX_IMG_LEN $TARGET_NAME sfdisk $TARGET_NAME <<-EOF - label: dos - label-id: 0xcb15ae4d - unit: sectors - sector-size: 512 + label: gpt - ${TARGET_NAME}1 : start=$((BOOT_START / SECTOR_LEN)), size=$((BOOT_LEN / SECTOR_LEN)), type=c, bootable - ${TARGET_NAME}2 : start=$((ROOT_START / SECTOR_LEN)), size=$((ROOT_LEN / SECTOR_LEN)), type=83 + ${TARGET_NAME}1 : start=$((FW_START / SECTOR_LEN)), size=$((FW_LEN / SECTOR_LEN)), type=EBD0A0A2-B9E5-4433-87C0-68B6B72699C7, uuid=${FW_UUID}, name="firmware" + ${TARGET_NAME}2 : start=$((ESP_START / SECTOR_LEN)), size=$((ESP_LEN / SECTOR_LEN)), type=C12A7328-F81F-11D2-BA4B-00A0C93EC93B, uuid=${ESP_UUID}, name="efi" + ${TARGET_NAME}3 : start=$((BOOT_START / SECTOR_LEN)), size=$((BOOT_LEN / SECTOR_LEN)), type=0FC63DAF-8483-4772-8E79-3D69D8477DE4, uuid=${BOOT_UUID}, name="boot" + ${TARGET_NAME}4 : start=$((ROOT_START / SECTOR_LEN)), size=$((ROOT_LEN / SECTOR_LEN)), type=B921B045-1DF0-41C3-AF44-4C6F280D3FAE, uuid=${ROOT_UUID}, name="root" EOF + FW_DEV=$(losetup --show -f --offset $FW_START --sizelimit $FW_LEN $TARGET_NAME) + ESP_DEV=$(losetup --show -f --offset $ESP_START --sizelimit $ESP_LEN $TARGET_NAME) BOOT_DEV=$(losetup --show -f --offset $BOOT_START --sizelimit $BOOT_LEN $TARGET_NAME) ROOT_DEV=$(losetup --show -f --offset $ROOT_START --sizelimit $ROOT_LEN $TARGET_NAME) - mkfs.vfat -F32 $BOOT_DEV - mkfs.ext4 $ROOT_DEV + mkfs.vfat -F32 -n firmware $FW_DEV + mkfs.vfat -F32 -n efi $ESP_DEV + mkfs.vfat -F32 -n boot $BOOT_DEV + mkfs.btrfs -f -L rootfs $ROOT_DEV TMPDIR=$(mktemp -d) - mkdir -p $TMPDIR/boot $TMPDIR/root - mount $ROOT_DEV $TMPDIR/root + # Extract boot files from squashfs to staging area + BOOT_STAGING=$(mktemp -d) + unsquashfs -n -f -d $BOOT_STAGING $prep_results_dir/binary/live/filesystem.squashfs boot + + # Mount partitions + mkdir -p $TMPDIR/firmware $TMPDIR/efi $TMPDIR/boot $TMPDIR/root + mount $FW_DEV $TMPDIR/firmware + mount $ESP_DEV $TMPDIR/efi mount $BOOT_DEV $TMPDIR/boot - unsquashfs -n -f -d $TMPDIR $prep_results_dir/binary/live/filesystem.squashfs boot + mount $ROOT_DEV $TMPDIR/root + + # Split boot files: firmware to Part 1, kernels/initramfs to Part 3 (/boot) + cp -a $BOOT_STAGING/boot/. $TMPDIR/firmware/ + for f in $TMPDIR/firmware/vmlinuz-* $TMPDIR/firmware/initrd.img-* $TMPDIR/firmware/System.map-* $TMPDIR/firmware/config-*; do + [ -e "$f" ] && mv "$f" $TMPDIR/boot/ + done + rm -rf $BOOT_STAGING mkdir $TMPDIR/root/images $TMPDIR/root/config B3SUM=$(b3sum $prep_results_dir/binary/live/filesystem.squashfs | head -c 16) @@ -434,40 +466,73 @@ elif [ "${IMAGE_TYPE}" = img ]; then mount -t overlay -o lowerdir=$TMPDIR/lower,workdir=$TMPDIR/root/config/work,upperdir=$TMPDIR/root/config/overlay overlay $TMPDIR/next if [ "${IB_TARGET_PLATFORM}" = "raspberrypi" ]; then - sed -i 's| boot=startos| boot=startos init=/usr/lib/startos/scripts/init_resize\.sh|' $TMPDIR/boot/cmdline.txt rsync -a $SOURCE_DIR/raspberrypi/img/ $TMPDIR/next/ + + # Install GRUB: ESP at /boot/efi (Part 2), /boot (Part 3) + mkdir -p $TMPDIR/next/boot $TMPDIR/next/boot/efi $TMPDIR/next/boot/firmware \ + $TMPDIR/next/dev $TMPDIR/next/proc $TMPDIR/next/sys $TMPDIR/next/media/startos/root + mount --bind $TMPDIR/boot $TMPDIR/next/boot + mount --bind $TMPDIR/efi $TMPDIR/next/boot/efi + mount --bind $TMPDIR/firmware $TMPDIR/next/boot/firmware + mount --bind /dev $TMPDIR/next/dev + mount --bind /proc $TMPDIR/next/proc + mount --bind /sys $TMPDIR/next/sys + mount --bind $TMPDIR/root $TMPDIR/next/media/startos/root + + chroot $TMPDIR/next grub-install --target=arm64-efi --removable --efi-directory=/boot/efi --boot-directory=/boot --no-nvram + chroot $TMPDIR/next update-grub + + umount $TMPDIR/next/media/startos/root + umount $TMPDIR/next/sys + umount $TMPDIR/next/proc + umount $TMPDIR/next/dev + umount $TMPDIR/next/boot/firmware + umount $TMPDIR/next/boot/efi + umount $TMPDIR/next/boot + + # Fix root= in grub.cfg: update-grub sees loop devices, but the + # real device uses a fixed GPT PARTUUID for root (Part 4). + sed -i "s|root=[^ ]*|root=PARTUUID=${ROOT_UUID}|g" $TMPDIR/boot/grub/grub.cfg + + # Inject first-boot resize script into GRUB config + sed -i 's| boot=startos| boot=startos init=/usr/lib/startos/scripts/init_resize\.sh|' $TMPDIR/boot/grub/grub.cfg fi umount $TMPDIR/next umount $TMPDIR/lower + umount $TMPDIR/firmware + umount $TMPDIR/efi umount $TMPDIR/boot umount $TMPDIR/root - - e2fsck -fy $ROOT_DEV - resize2fs -M $ROOT_DEV - - BLOCK_COUNT=$(dumpe2fs -h $ROOT_DEV | awk '/^Block count:/ { print $3 }') - BLOCK_SIZE=$(dumpe2fs -h $ROOT_DEV | awk '/^Block size:/ { print $3 }') - ROOT_LEN=$((BLOCK_COUNT * BLOCK_SIZE)) + # Shrink btrfs to minimum size + SHRINK_MNT=$(mktemp -d) + mount $ROOT_DEV $SHRINK_MNT + btrfs filesystem resize min $SHRINK_MNT + umount $SHRINK_MNT + rmdir $SHRINK_MNT + ROOT_LEN=$(btrfs inspect-internal dump-super $ROOT_DEV | awk '/^total_bytes/ {print $2}') losetup -d $ROOT_DEV losetup -d $BOOT_DEV + losetup -d $ESP_DEV + losetup -d $FW_DEV - # Recreate partition 2 with the new size using sfdisk + # Recreate partition table with shrunk root sfdisk $TARGET_NAME <<-EOF - label: dos - label-id: 0xcb15ae4d - unit: sectors - sector-size: 512 + label: gpt - ${TARGET_NAME}1 : start=$((BOOT_START / SECTOR_LEN)), size=$((BOOT_LEN / SECTOR_LEN)), type=c, bootable - ${TARGET_NAME}2 : start=$((ROOT_START / SECTOR_LEN)), size=$((ROOT_LEN / SECTOR_LEN)), type=83 + ${TARGET_NAME}1 : start=$((FW_START / SECTOR_LEN)), size=$((FW_LEN / SECTOR_LEN)), type=EBD0A0A2-B9E5-4433-87C0-68B6B72699C7, uuid=${FW_UUID}, name="firmware" + ${TARGET_NAME}2 : start=$((ESP_START / SECTOR_LEN)), size=$((ESP_LEN / SECTOR_LEN)), type=C12A7328-F81F-11D2-BA4B-00A0C93EC93B, uuid=${ESP_UUID}, name="efi" + ${TARGET_NAME}3 : start=$((BOOT_START / SECTOR_LEN)), size=$((BOOT_LEN / SECTOR_LEN)), type=0FC63DAF-8483-4772-8E79-3D69D8477DE4, uuid=${BOOT_UUID}, name="boot" + ${TARGET_NAME}4 : start=$((ROOT_START / SECTOR_LEN)), size=$((ROOT_LEN / SECTOR_LEN)), type=B921B045-1DF0-41C3-AF44-4C6F280D3FAE, uuid=${ROOT_UUID}, name="root" EOF TARGET_SIZE=$((ROOT_START + ROOT_LEN)) truncate -s $TARGET_SIZE $TARGET_NAME + # Move backup GPT to new end of disk after truncation + sgdisk -e $TARGET_NAME mv $TARGET_NAME $RESULTS_DIR/$IMAGE_BASENAME.img diff --git a/build/image-recipe/raspberrypi/img/etc/fstab b/build/image-recipe/raspberrypi/img/etc/fstab index 5f5164232..ece1fb4c7 100644 --- a/build/image-recipe/raspberrypi/img/etc/fstab +++ b/build/image-recipe/raspberrypi/img/etc/fstab @@ -1,2 +1,4 @@ -/dev/mmcblk0p1 /boot vfat umask=0077 0 2 -/dev/mmcblk0p2 / ext4 defaults 0 1 +PARTUUID=cb15ae4d-0001-4000-8000-000000000001 /boot/firmware vfat umask=0077 0 2 +PARTUUID=cb15ae4d-0002-4000-8000-000000000002 /boot/efi vfat umask=0077 0 1 +PARTUUID=cb15ae4d-0003-4000-8000-000000000003 /boot vfat umask=0077 0 2 +PARTUUID=cb15ae4d-0004-4000-8000-000000000004 / btrfs defaults 0 1 diff --git a/build/image-recipe/raspberrypi/img/usr/lib/startos/scripts/init_resize.sh b/build/image-recipe/raspberrypi/img/usr/lib/startos/scripts/init_resize.sh index 1fdca1c83..faa796c7b 100755 --- a/build/image-recipe/raspberrypi/img/usr/lib/startos/scripts/init_resize.sh +++ b/build/image-recipe/raspberrypi/img/usr/lib/startos/scripts/init_resize.sh @@ -12,15 +12,16 @@ get_variables () { BOOT_DEV_NAME=$(echo /sys/block/*/"${BOOT_PART_NAME}" | cut -d "/" -f 4) BOOT_PART_NUM=$(cat "/sys/block/${BOOT_DEV_NAME}/${BOOT_PART_NAME}/partition") - OLD_DISKID=$(fdisk -l "$ROOT_DEV" | sed -n 's/Disk identifier: 0x\([^ ]*\)/\1/p') - ROOT_DEV_SIZE=$(cat "/sys/block/${ROOT_DEV_NAME}/size") - if [ "$ROOT_DEV_SIZE" -le 67108864 ]; then - TARGET_END=$((ROOT_DEV_SIZE - 1)) + # GPT backup header/entries occupy last 33 sectors + USABLE_END=$((ROOT_DEV_SIZE - 34)) + + if [ "$USABLE_END" -le 67108864 ]; then + TARGET_END=$USABLE_END else TARGET_END=$((33554432 - 1)) DATA_PART_START=33554432 - DATA_PART_END=$((ROOT_DEV_SIZE - 1)) + DATA_PART_END=$USABLE_END fi PARTITION_TABLE=$(parted -m "$ROOT_DEV" unit s print | tr -d 's') @@ -61,33 +62,24 @@ main () { return 1 fi -# if [ "$ROOT_PART_END" -eq "$TARGET_END" ]; then -# reboot_pi -# fi - if ! echo Yes | parted -m --align=optimal "$ROOT_DEV" ---pretend-input-tty u s resizepart "$ROOT_PART_NUM" "$TARGET_END" ; then FAIL_REASON="Root partition resize failed" return 1 fi if [ -n "$DATA_PART_START" ]; then - if ! parted -ms --align=optimal "$ROOT_DEV" u s mkpart primary "$DATA_PART_START" "$DATA_PART_END"; then + if ! parted -ms --align=optimal "$ROOT_DEV" u s mkpart data "$DATA_PART_START" "$DATA_PART_END"; then FAIL_REASON="Data partition creation failed" return 1 fi fi - ( - echo x - echo i - echo "0xcb15ae4d" - echo r - echo w - ) | fdisk $ROOT_DEV + # Fix GPT backup header to reflect new partition layout + sgdisk -e "$ROOT_DEV" 2>/dev/null || true mount / -o remount,rw - resize2fs $ROOT_PART_DEV + btrfs filesystem resize max /media/startos/root if ! systemd-machine-id-setup --root=/media/startos/config/overlay/; then FAIL_REASON="systemd-machine-id-setup failed" @@ -111,7 +103,7 @@ mount / -o remount,ro beep if main; then - sed -i 's| init=/usr/lib/startos/scripts/init_resize\.sh||' /boot/cmdline.txt + sed -i 's| init=/usr/lib/startos/scripts/init_resize\.sh||' /boot/grub/grub.cfg echo "Resized root filesystem. Rebooting in 5 seconds..." sleep 5 else diff --git a/build/image-recipe/raspberrypi/squashfs/boot/cmdline.txt b/build/image-recipe/raspberrypi/squashfs/boot/cmdline.txt deleted file mode 100644 index f10c50da5..000000000 --- a/build/image-recipe/raspberrypi/squashfs/boot/cmdline.txt +++ /dev/null @@ -1 +0,0 @@ -usb-storage.quirks=152d:0562:u,14cd:121c:u,0781:cfcb:u console=serial0,115200 console=tty1 root=PARTUUID=cb15ae4d-02 rootfstype=ext4 fsck.repair=yes rootwait cgroup_enable=cpuset cgroup_memory=1 cgroup_enable=memory boot=startos \ No newline at end of file diff --git a/build/image-recipe/raspberrypi/squashfs/boot/config.sh b/build/image-recipe/raspberrypi/squashfs/boot/config.sh index 1c74bc1b2..d23120bef 100644 --- a/build/image-recipe/raspberrypi/squashfs/boot/config.sh +++ b/build/image-recipe/raspberrypi/squashfs/boot/config.sh @@ -27,20 +27,18 @@ disable_overscan=1 # (e.g. for USB device mode) or if USB support is not required. otg_mode=1 -[all] - [pi4] # Run as fast as firmware / board allows arm_boost=1 -kernel=vmlinuz-${KERNEL_VERSION}-rpi-v8 -initramfs initrd.img-${KERNEL_VERSION}-rpi-v8 followkernel - -[pi5] -kernel=vmlinuz-${KERNEL_VERSION}-rpi-2712 -initramfs initrd.img-${KERNEL_VERSION}-rpi-2712 followkernel [all] gpu_mem=16 dtoverlay=pwm-2chan,disable-bt -EOF \ No newline at end of file +# Enable UART for U-Boot and serial console +enable_uart=1 + +# Load U-Boot as the bootloader (GRUB is chainloaded from U-Boot) +kernel=u-boot.bin + +EOF diff --git a/build/image-recipe/raspberrypi/squashfs/boot/config.txt b/build/image-recipe/raspberrypi/squashfs/boot/config.txt index 4e1962a65..5bf25925d 100644 --- a/build/image-recipe/raspberrypi/squashfs/boot/config.txt +++ b/build/image-recipe/raspberrypi/squashfs/boot/config.txt @@ -84,4 +84,8 @@ arm_boost=1 gpu_mem=16 dtoverlay=pwm-2chan,disable-bt -auto_initramfs=1 \ No newline at end of file +# Enable UART for U-Boot and serial console +enable_uart=1 + +# Load U-Boot as the bootloader (GRUB is chainloaded from U-Boot) +kernel=u-boot.bin \ No newline at end of file diff --git a/build/image-recipe/raspberrypi/squashfs/etc/default/grub.d/raspberrypi.cfg b/build/image-recipe/raspberrypi/squashfs/etc/default/grub.d/raspberrypi.cfg new file mode 100644 index 000000000..0dc217b8b --- /dev/null +++ b/build/image-recipe/raspberrypi/squashfs/etc/default/grub.d/raspberrypi.cfg @@ -0,0 +1,4 @@ +# Raspberry Pi-specific GRUB overrides +# Overrides GRUB_CMDLINE_LINUX from /etc/default/grub with Pi-specific +# console devices and hardware quirks. +GRUB_CMDLINE_LINUX="boot=startos console=serial0,115200 console=tty1 usb-storage.quirks=152d:0562:u,14cd:121c:u,0781:cfcb:u cgroup_enable=cpuset cgroup_memory=1 cgroup_enable=memory" diff --git a/build/image-recipe/raspberrypi/squashfs/etc/startos/config.yaml b/build/image-recipe/raspberrypi/squashfs/etc/startos/config.yaml index 7c81ad513..a7d1a5eae 100644 --- a/build/image-recipe/raspberrypi/squashfs/etc/startos/config.yaml +++ b/build/image-recipe/raspberrypi/squashfs/etc/startos/config.yaml @@ -1,6 +1,3 @@ -os-partitions: - boot: /dev/mmcblk0p1 - root: /dev/mmcblk0p2 ethernet-interface: end0 wifi-interface: wlan0 disable-encryption: true From 3024db26548f7070193ff1b1e4adabfdc71cbed3 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Thu, 12 Mar 2026 11:12:42 -0600 Subject: [PATCH 34/71] feat: add GRUB installer USB boot detection via configfile Install a /etc/grub.d/07_startos_installer script that searches for a .startos-installer marker file at boot. When found, it creates a "StartOS Installer" menu entry that loads the USB's own grub.cfg via configfile, making it the default with a 5-second timeout. Uses configfile instead of chainloader because on hybrid ISOs the .startos-installer marker and /boot/grub/grub.cfg are on the ISO9660 root partition, while the EFI binary lives on a separate embedded ESP. chainloader would look for the EFI binary on the wrong partition. --- debian/startos/postinst | 40 +++++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/debian/startos/postinst b/debian/startos/postinst index 24a433c2b..a3e3e946a 100755 --- a/debian/startos/postinst +++ b/debian/startos/postinst @@ -54,18 +54,36 @@ if [ -f /etc/default/grub ]; then cp /usr/share/grub/unicode.pf2 /boot/grub/fonts/unicode.pf2 fi # Install conditional serial console script for GRUB - cat > /etc/grub.d/01_serial << 'GRUBEOF' -#!/bin/sh -cat << 'EOF' -# Conditionally enable serial console (avoids breaking gfxterm on EFI -# systems where the serial port is unavailable) -if serial --unit=0 --speed=115200 --word=8 --parity=no --stop=1; then - terminal_input console serial - terminal_output gfxterm serial -fi -EOF -GRUBEOF + cat > /etc/grub.d/01_serial <<-'GRUBEOF' + #!/bin/sh + cat << 'EOF' + # Conditionally enable serial console (avoids breaking gfxterm on EFI + # systems where the serial port is unavailable) + if serial --unit=0 --speed=115200 --word=8 --parity=no --stop=1; then + terminal_input console serial + terminal_output gfxterm serial + fi + EOF + GRUBEOF chmod +x /etc/grub.d/01_serial + # Install GRUB script to boot from StartOS installer USB when present. + # At boot, GRUB searches for the .startos-installer marker and, + # if found, loads the USB's own grub.cfg as the default boot entry. + cat > /etc/grub.d/07_startos_installer <<-'GRUBEOF' + #!/bin/sh + cat << 'EOF' + search --no-floppy --file --set=installer_dev /.startos-installer + if [ -n "$installer_dev" ]; then + menuentry "StartOS Installer" --id startos-installer { + set root=$installer_dev + configfile /boot/grub/grub.cfg + } + set default=startos-installer + set timeout=5 + fi + EOF + GRUBEOF + chmod +x /etc/grub.d/07_startos_installer fi VERSION="$(cat /usr/lib/startos/VERSION.txt)" From d1444b1175839068f71a1839ba2ed5fe694ab8b4 Mon Sep 17 00:00:00 2001 From: Matt Hill Date: Thu, 12 Mar 2026 12:02:38 -0600 Subject: [PATCH 35/71] ST port labels and move logout to settings (#3134) * chore: update packages (#3132) * chore: update packages * start tunnel messaging * chore: standalone * pbpaste instead --------- Co-authored-by: Matt Hill * port labels and move logout to settings * enable-disable forwards * Fix docs URLs in start-tunnel installer output (#3135) --------- Co-authored-by: Alex Inkin Co-authored-by: gStart9 <106188942+gStart9@users.noreply.github.com> --- core/src/tunnel/api.rs | 118 +- core/src/tunnel/context.rs | 6 +- core/src/tunnel/db.rs | 20 +- core/src/tunnel/web.rs | 22 +- web/package-lock.json | 2498 ++++++++++------- web/package.json | 53 +- .../components/menu/menu.component.module.ts | 42 - .../src/components/menu/menu.component.ts | 22 +- .../src/components/registry.component.ts | 4 +- .../{store-icon => }/store-icon.component.ts | 1 - .../store-icon/store-icon.component.module.ts | 10 - .../list/categories/categories.component.ts | 14 +- .../list/categories/categories.module.ts | 15 - .../src/pages/list/item/item.component.ts | 5 +- .../src/pages/list/item/item.module.ts | 12 - .../src/pages/list/search/search.component.ts | 5 +- .../src/pages/list/search/search.module.ts | 12 - .../dependencies/dependency-item.component.ts | 19 +- .../src/pages/show/flavors.component.ts | 11 +- .../src/pages/show/hero.component.ts | 4 +- .../src/pages/show/versions.component.ts | 3 +- .../src/pipes/filter-packages.pipe.ts | 7 - web/projects/marketplace/src/public-api.ts | 8 +- .../setup-wizard/src/app/app.component.ts | 10 +- .../src/app/{app.module.ts => app.config.ts} | 53 +- .../components/preserve-overwrite.dialog.ts | 1 - .../src/app/components/remove-media.dialog.ts | 1 - .../select-network-backup.dialog.ts | 1 - .../app/components/unlock-password.dialog.ts | 1 - web/projects/setup-wizard/src/main.ts | 12 +- .../shared/src/pipes/convert-bytes.pipe.ts | 22 + .../src/pipes/{shared => }/empty.pipe.ts | 6 +- .../exver.pipe.ts => exver-compares.pipe.ts} | 23 +- .../shared/src/pipes/exver/exver.module.ts | 8 - .../shared/src/pipes/shared/includes.pipe.ts | 11 - .../shared/src/pipes/shared/shared.module.ts | 10 - .../shared/src/pipes/shared/sort.pipe.ts | 35 - .../src/pipes/{shared => }/trust.pipe.ts | 5 +- .../unit-conversion/unit-conversion.module.ts | 8 - .../unit-conversion/unit-conversion.pipe.ts | 49 - web/projects/shared/src/public-api.ts | 12 +- .../src/app/routes/home/components/nav.ts | 39 +- .../routes/home/routes/port-forwards/add.ts | 10 +- .../home/routes/port-forwards/edit-label.ts | 83 + .../routes/home/routes/port-forwards/index.ts | 99 +- .../routes/home/routes/port-forwards/utils.ts | 2 + .../app/routes/home/routes/settings/index.ts | 37 +- .../src/app/services/api/api.service.ts | 13 + .../src/app/services/api/live-api.service.ts | 10 + .../src/app/services/api/mock-api.service.ts | 47 +- .../src/app/services/patch-db/data-model.ts | 16 +- web/projects/ui/src/app/app.component.ts | 5 +- web/projects/ui/src/app/app.config.ts | 199 ++ web/projects/ui/src/app/app.module.ts | 36 - web/projects/ui/src/app/app.providers.ts | 157 -- .../app/{routing.module.ts => app.routes.ts} | 25 +- .../routes/diagnostic/diagnostic.module.ts | 19 - .../routes/diagnostic/diagnostic.routes.ts | 12 + .../app/routes/diagnostic/home/home.module.ts | 19 - .../app/routes/diagnostic/home/home.page.ts | 14 +- .../routes/initializing/initializing.page.ts | 4 +- .../ui/src/app/routes/login/login.module.ts | 37 - .../ui/src/app/routes/login/login.page.ts | 29 +- .../components/header/header.component.ts | 4 +- .../backups/components/physical.component.ts | 4 +- .../components/controls.component.ts | 10 +- .../marketplace/components/menu.component.ts | 4 +- .../marketplace/components/tile.component.ts | 6 +- .../marketplace/marketplace.component.ts | 3 +- .../marketplace/modals/preview.component.ts | 9 +- .../marketplace/modals/registry.component.ts | 8 +- .../routes/sideload/package.component.ts | 6 +- .../routes/backups/backups.component.ts | 8 +- .../routes/backups/physical.component.ts | 8 +- .../routes/system/routes/dns/dns.component.ts | 2 - .../routes/gateways/gateways.component.ts | 2 - .../routes/updates/updates.component.ts | 4 +- web/projects/ui/src/main.ts | 11 +- web/tsconfig.json | 1 - 79 files changed, 2313 insertions(+), 1868 deletions(-) delete mode 100644 web/projects/marketplace/src/components/menu/menu.component.module.ts rename web/projects/marketplace/src/components/{store-icon => }/store-icon.component.ts (98%) delete mode 100644 web/projects/marketplace/src/components/store-icon/store-icon.component.module.ts delete mode 100644 web/projects/marketplace/src/pages/list/categories/categories.module.ts delete mode 100644 web/projects/marketplace/src/pages/list/item/item.module.ts delete mode 100644 web/projects/marketplace/src/pages/list/search/search.module.ts rename web/projects/setup-wizard/src/app/{app.module.ts => app.config.ts} (62%) create mode 100644 web/projects/shared/src/pipes/convert-bytes.pipe.ts rename web/projects/shared/src/pipes/{shared => }/empty.pipe.ts (54%) rename web/projects/shared/src/pipes/{exver/exver.pipe.ts => exver-compares.pipe.ts} (50%) delete mode 100644 web/projects/shared/src/pipes/exver/exver.module.ts delete mode 100644 web/projects/shared/src/pipes/shared/includes.pipe.ts delete mode 100644 web/projects/shared/src/pipes/shared/shared.module.ts delete mode 100644 web/projects/shared/src/pipes/shared/sort.pipe.ts rename web/projects/shared/src/pipes/{shared => }/trust.pipe.ts (68%) delete mode 100644 web/projects/shared/src/pipes/unit-conversion/unit-conversion.module.ts delete mode 100644 web/projects/shared/src/pipes/unit-conversion/unit-conversion.pipe.ts create mode 100644 web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/edit-label.ts create mode 100644 web/projects/ui/src/app/app.config.ts delete mode 100644 web/projects/ui/src/app/app.module.ts delete mode 100644 web/projects/ui/src/app/app.providers.ts rename web/projects/ui/src/app/{routing.module.ts => app.routes.ts} (54%) delete mode 100644 web/projects/ui/src/app/routes/diagnostic/diagnostic.module.ts create mode 100644 web/projects/ui/src/app/routes/diagnostic/diagnostic.routes.ts delete mode 100644 web/projects/ui/src/app/routes/diagnostic/home/home.module.ts delete mode 100644 web/projects/ui/src/app/routes/login/login.module.ts diff --git a/core/src/tunnel/api.rs b/core/src/tunnel/api.rs index 10c2f21c2..523dc3900 100644 --- a/core/src/tunnel/api.rs +++ b/core/src/tunnel/api.rs @@ -11,6 +11,7 @@ use crate::db::model::public::NetworkInterfaceType; use crate::net::forward::add_iptables_rule; use crate::prelude::*; use crate::tunnel::context::TunnelContext; +use crate::tunnel::db::PortForwardEntry; use crate::tunnel::wg::{WIREGUARD_INTERFACE_NAME, WgConfig, WgSubnetClients, WgSubnetConfig}; use crate::util::serde::{HandlerExtSerde, display_serializable}; @@ -51,6 +52,22 @@ pub fn tunnel_api() -> ParentHandler { .no_display() .with_about("about.remove-port-forward") .with_call_remote::(), + ) + .subcommand( + "update-label", + from_fn_async(update_forward_label) + .with_metadata("sync_db", Value::Bool(true)) + .no_display() + .with_about("about.update-port-forward-label") + .with_call_remote::(), + ) + .subcommand( + "set-enabled", + from_fn_async(set_forward_enabled) + .with_metadata("sync_db", Value::Bool(true)) + .no_display() + .with_about("about.enable-or-disable-port-forward") + .with_call_remote::(), ), ) .subcommand( @@ -453,11 +470,17 @@ pub async fn show_config( pub struct AddPortForwardParams { source: SocketAddrV4, target: SocketAddrV4, + #[arg(long)] + label: String, } pub async fn add_forward( ctx: TunnelContext, - AddPortForwardParams { source, target }: AddPortForwardParams, + AddPortForwardParams { + source, + target, + label, + }: AddPortForwardParams, ) -> Result<(), Error> { let prefix = ctx .net_iface @@ -482,10 +505,12 @@ pub async fn add_forward( m.insert(source, rc); }); + let entry = PortForwardEntry { target, label, enabled: true }; + ctx.db .mutate(|db| { db.as_port_forwards_mut() - .insert(&source, &target) + .insert(&source, &entry) .and_then(|replaced| { if replaced.is_some() { Err(Error::new( @@ -523,3 +548,92 @@ pub async fn remove_forward( } Ok(()) } + +#[derive(Deserialize, Serialize, Parser)] +#[serde(rename_all = "camelCase")] +pub struct UpdatePortForwardLabelParams { + source: SocketAddrV4, + label: String, +} + +pub async fn update_forward_label( + ctx: TunnelContext, + UpdatePortForwardLabelParams { source, label }: UpdatePortForwardLabelParams, +) -> Result<(), Error> { + ctx.db + .mutate(|db| { + db.as_port_forwards_mut().mutate(|pf| { + let entry = pf.0.get_mut(&source).ok_or_else(|| { + Error::new( + eyre!("Port forward from {source} not found"), + ErrorKind::NotFound, + ) + })?; + entry.label = label.clone(); + Ok(()) + }) + }) + .await + .result +} + +#[derive(Deserialize, Serialize, Parser)] +#[serde(rename_all = "camelCase")] +pub struct SetPortForwardEnabledParams { + source: SocketAddrV4, + enabled: bool, +} + +pub async fn set_forward_enabled( + ctx: TunnelContext, + SetPortForwardEnabledParams { source, enabled }: SetPortForwardEnabledParams, +) -> Result<(), Error> { + let target = ctx + .db + .mutate(|db| { + db.as_port_forwards_mut().mutate(|pf| { + let entry = pf.0.get_mut(&source).ok_or_else(|| { + Error::new( + eyre!("Port forward from {source} not found"), + ErrorKind::NotFound, + ) + })?; + entry.enabled = enabled; + Ok(entry.target) + }) + }) + .await + .result?; + + if enabled { + let prefix = ctx + .net_iface + .peek(|i| { + i.iter() + .find_map(|(_, i)| { + i.ip_info.as_ref().and_then(|i| { + i.subnets + .iter() + .find(|s| s.contains(&IpAddr::from(*target.ip()))) + }) + }) + .cloned() + }) + .map(|s| s.prefix_len()) + .unwrap_or(32); + let rc = ctx + .forward + .add_forward(source, target, prefix, None) + .await?; + ctx.active_forwards.mutate(|m| { + m.insert(source, rc); + }); + } else { + if let Some(rc) = ctx.active_forwards.mutate(|m| m.remove(&source)) { + drop(rc); + ctx.forward.gc().await?; + } + } + + Ok(()) +} diff --git a/core/src/tunnel/context.rs b/core/src/tunnel/context.rs index ac56eaa36..769f62787 100644 --- a/core/src/tunnel/context.rs +++ b/core/src/tunnel/context.rs @@ -184,7 +184,11 @@ impl TunnelContext { } let mut active_forwards = BTreeMap::new(); - for (from, to) in peek.as_port_forwards().de()?.0 { + for (from, entry) in peek.as_port_forwards().de()?.0 { + if !entry.enabled { + continue; + } + let to = entry.target; let prefix = net_iface .peek(|i| { i.iter() diff --git a/core/src/tunnel/db.rs b/core/src/tunnel/db.rs index bd83305fd..b18c01abf 100644 --- a/core/src/tunnel/db.rs +++ b/core/src/tunnel/db.rs @@ -53,7 +53,7 @@ impl Model { } self.as_port_forwards_mut().mutate(|pf| { Ok(pf.0.retain(|k, v| { - if keep_targets.contains(v.ip()) { + if keep_targets.contains(v.target.ip()) { keep_sources.insert(*k); true } else { @@ -70,11 +70,25 @@ fn export_bindings_tunnel_db() { TunnelDatabase::export_all_to("bindings/tunnel").unwrap(); } +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct PortForwardEntry { + pub target: SocketAddrV4, + #[serde(default)] + pub label: String, + #[serde(default = "default_true")] + pub enabled: bool, +} + +fn default_true() -> bool { + true +} + #[derive(Clone, Debug, Default, Deserialize, Serialize, TS)] -pub struct PortForwards(pub BTreeMap); +pub struct PortForwards(pub BTreeMap); impl Map for PortForwards { type Key = SocketAddrV4; - type Value = SocketAddrV4; + type Value = PortForwardEntry; fn key_str(key: &Self::Key) -> Result, Error> { Self::key_string(key) } diff --git a/core/src/tunnel/web.rs b/core/src/tunnel/web.rs index 7e3eec70d..49cb15930 100644 --- a/core/src/tunnel/web.rs +++ b/core/src/tunnel/web.rs @@ -524,26 +524,26 @@ pub async fn init_web(ctx: CliContext) -> Result<(), Error> { "To access your Web URL securely, trust your Root CA (displayed above) on your client device(s):\n", " - MacOS\n", " 1. Open the Terminal app\n", - " 2. Paste the following command (**DO NOT** click Return): pbcopy < ~/Desktop/ca.crt\n", + " 2. Type or copy/paste the following command (**DO NOT** click Enter/Return yet): pbpaste > ~/Desktop/tunnel-ca.crt\n", " 3. Copy your Root CA (including -----BEGIN CERTIFICATE----- and -----END CERTIFICATE-----)\n", - " 4. Back in Terminal, click Return. ca.crt is saved to your Desktop\n", - " 5. Complete by trusting your Root CA: https://docs.start9.com/device-guides/mac/ca.html\n", + " 4. Back in Terminal, click Enter/Return. tunnel-ca.crt is saved to your Desktop\n", + " 5. Complete by trusting your Root CA: https://docs.start9.com/start-os/0.4.0.x/user-manual/trust-ca.html?platform=Mac\n", " - Linux\n", " 1. Open gedit, nano, or any editor\n", " 2. Copy/paste your Root CA (including -----BEGIN CERTIFICATE----- and -----END CERTIFICATE-----)\n", - " 3. Name the file ca.crt and save as plaintext\n", - " 4. Complete by trusting your Root CA: https://docs.start9.com/device-guides/linux/ca.html\n", + " 3. Name the file tunnel-ca.crt and save as plaintext\n", + " 4. Complete by trusting your Root CA: https://docs.start9.com/start-os/0.4.0.x/user-manual/trust-ca.html?platform=Debian+%252F+Ubuntu\n", " - Windows\n", " 1. Open the Notepad app\n", " 2. Copy/paste your Root CA (including -----BEGIN CERTIFICATE----- and -----END CERTIFICATE-----)\n", - " 3. Name the file ca.crt and save as plaintext\n", - " 4. Complete by trusting your Root CA: https://docs.start9.com/device-guides/windows/ca.html\n", + " 3. Name the file tunnel-ca.crt and save as plaintext\n", + " 4. Complete by trusting your Root CA: https://docs.start9.com/start-os/0.4.0.x/user-manual/trust-ca.html?platform=Windows\n", " - Android/Graphene\n", - " 1. Send the ca.crt file (created above) to yourself\n", - " 2. Complete by trusting your Root CA: https://docs.start9.com/device-guides/android/ca.html\n", + " 1. Send the tunnel-ca.crt file (created above) to yourself\n", + " 2. Complete by trusting your Root CA: https://docs.start9.com/start-os/0.4.0.x/user-manual/trust-ca.html?platform=Android+%252F+Graphene\n", " - iOS\n", - " 1. Send the ca.crt file (created above) to yourself\n", - " 2. Complete by trusting your Root CA: https://docs.start9.com/device-guides/ios/ca.html\n", + " 1. Send the tunnel-ca.crt file (created above) to yourself\n", + " 2. Complete by trusting your Root CA: https://docs.start9.com/start-os/0.4.0.x/user-manual/trust-ca.html?platform=iOS\n", )); return Ok(()); diff --git a/web/package-lock.json b/web/package-lock.json index 869b99834..eab2b5519 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,34 +9,33 @@ "version": "0.4.0-alpha.20", "license": "MIT", "dependencies": { - "@angular/animations": "^20.3.0", - "@angular/cdk": "^20.1.0", - "@angular/common": "^20.3.0", - "@angular/compiler": "^20.3.0", - "@angular/core": "^20.3.0", - "@angular/forms": "^20.3.0", - "@angular/platform-browser": "^20.3.0", - "@angular/platform-browser-dynamic": "^20.1.0", - "@angular/pwa": "^20.3.0", - "@angular/router": "^20.3.0", - "@angular/service-worker": "^20.3.0", + "@angular/animations": "^21.2.1", + "@angular/cdk": "^21.2.1", + "@angular/common": "^21.2.1", + "@angular/compiler": "^21.2.1", + "@angular/core": "^21.2.1", + "@angular/forms": "^21.2.1", + "@angular/platform-browser": "^21.2.1", + "@angular/pwa": "^21.2.1", + "@angular/router": "^21.2.1", + "@angular/service-worker": "^21.2.1", "@materia-ui/ngx-monaco-editor": "^6.0.0", "@noble/curves": "^1.4.0", "@noble/hashes": "^1.4.0", "@start9labs/argon2": "^0.3.0", "@start9labs/start-sdk": "file:../sdk/baseDist", - "@taiga-ui/addon-charts": "4.66.0", - "@taiga-ui/addon-commerce": "4.66.0", - "@taiga-ui/addon-mobile": "4.66.0", - "@taiga-ui/addon-table": "4.66.0", - "@taiga-ui/cdk": "4.66.0", - "@taiga-ui/core": "4.66.0", + "@taiga-ui/addon-charts": "4.73.0", + "@taiga-ui/addon-commerce": "4.73.0", + "@taiga-ui/addon-mobile": "4.73.0", + "@taiga-ui/addon-table": "4.73.0", + "@taiga-ui/cdk": "4.73.0", + "@taiga-ui/core": "4.73.0", "@taiga-ui/dompurify": "4.1.11", "@taiga-ui/event-plugins": "4.7.0", - "@taiga-ui/experimental": "4.66.0", - "@taiga-ui/icons": "4.66.0", - "@taiga-ui/kit": "4.66.0", - "@taiga-ui/layout": "4.66.0", + "@taiga-ui/experimental": "4.73.0", + "@taiga-ui/icons": "4.73.0", + "@taiga-ui/kit": "4.73.0", + "@taiga-ui/layout": "4.73.0", "@taiga-ui/polymorpheus": "4.9.0", "ansi-to-html": "^0.7.2", "base64-js": "^1.5.1", @@ -56,7 +55,7 @@ "mime": "^4.0.3", "monaco-editor": "^0.33.0", "mustache": "^4.2.0", - "ng-qrcode": "^20.0.0", + "ng-qrcode": "^21.0.0", "node-jose": "^2.2.0", "patch-db-client": "file:../patch-db/client", "pbkdf2": "^3.1.2", @@ -68,10 +67,10 @@ }, "devDependencies": { "@angular-experts/hawkeye": "^1.7.2", - "@angular/build": "^20.1.0", - "@angular/cli": "^20.1.0", - "@angular/compiler-cli": "^20.1.0", - "@angular/language-service": "^20.1.0", + "@angular/build": "^21.2.1", + "@angular/cli": "^21.2.1", + "@angular/compiler-cli": "^21.2.1", + "@angular/language-service": "^21.2.1", "@types/dompurify": "3.0.5", "@types/estree": "^0.0.51", "@types/js-yaml": "^4.0.5", @@ -83,7 +82,7 @@ "@types/uuid": "^8.3.1", "husky": "^4.3.8", "lint-staged": "^13.2.0", - "ng-packagr": "^20.1.0", + "ng-packagr": "^21.2.0", "node-html-parser": "^5.3.3", "postcss": "^8.4.21", "prettier": "^3.5.3", @@ -143,57 +142,57 @@ } }, "node_modules/@algolia/abtesting": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.1.0.tgz", - "integrity": "sha512-sEyWjw28a/9iluA37KLGu8vjxEIlb60uxznfTUmXImy7H5NvbpSO6yYgmgH5KiD7j+zTUUihiST0jEP12IoXow==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.14.1.tgz", + "integrity": "sha512-Dkj0BgPiLAaim9sbQ97UKDFHJE/880wgStAM18U++NaJ/2Cws34J5731ovJifr6E3Pv4T2CqvMXf8qLCC417Ew==", "devOptional": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.35.0", - "@algolia/requester-browser-xhr": "5.35.0", - "@algolia/requester-fetch": "5.35.0", - "@algolia/requester-node-http": "5.35.0" + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-abtesting": { - "version": "5.35.0", - "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.35.0.tgz", - "integrity": "sha512-uUdHxbfHdoppDVflCHMxRlj49/IllPwwQ2cQ8DLC4LXr3kY96AHBpW0dMyi6ygkn2MtFCc6BxXCzr668ZRhLBQ==", + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.48.1.tgz", + "integrity": "sha512-LV5qCJdj+/m9I+Aj91o+glYszrzd7CX6NgKaYdTOj4+tUYfbS62pwYgUfZprYNayhkQpVFcrW8x8ZlIHpS23Vw==", "devOptional": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.35.0", - "@algolia/requester-browser-xhr": "5.35.0", - "@algolia/requester-fetch": "5.35.0", - "@algolia/requester-node-http": "5.35.0" + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-analytics": { - "version": "5.35.0", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.35.0.tgz", - "integrity": "sha512-SunAgwa9CamLcRCPnPHx1V2uxdQwJGqb1crYrRWktWUdld0+B2KyakNEeVn5lln4VyeNtW17Ia7V7qBWyM/Skw==", + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.48.1.tgz", + "integrity": "sha512-/AVoMqHhPm14CcHq7mwB+bUJbfCv+jrxlNvRjXAuO+TQa+V37N8k1b0ijaRBPdmSjULMd8KtJbQyUyabXOu6Kg==", "devOptional": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.35.0", - "@algolia/requester-browser-xhr": "5.35.0", - "@algolia/requester-fetch": "5.35.0", - "@algolia/requester-node-http": "5.35.0" + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-common": { - "version": "5.35.0", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.35.0.tgz", - "integrity": "sha512-ipE0IuvHu/bg7TjT2s+187kz/E3h5ssfTtjpg1LbWMgxlgiaZIgTTbyynM7NfpSJSKsgQvCQxWjGUO51WSCu7w==", + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.48.1.tgz", + "integrity": "sha512-VXO+qu2Ep6ota28ktvBm3sG53wUHS2n7bgLWmce5jTskdlCD0/JrV4tnBm1l7qpla1CeoQb8D7ShFhad+UoSOw==", "devOptional": true, "license": "MIT", "engines": { @@ -201,151 +200,151 @@ } }, "node_modules/@algolia/client-insights": { - "version": "5.35.0", - "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.35.0.tgz", - "integrity": "sha512-UNbCXcBpqtzUucxExwTSfAe8gknAJ485NfPN6o1ziHm6nnxx97piIbcBQ3edw823Tej2Wxu1C0xBY06KgeZ7gA==", + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.48.1.tgz", + "integrity": "sha512-zl+Qyb0nLg+Y5YvKp1Ij+u9OaPaKg2/EPzTwKNiVyOHnQJlFxmXyUZL1EInczAZsEY8hVpPCLtNfhMhfxluXKQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.35.0", - "@algolia/requester-browser-xhr": "5.35.0", - "@algolia/requester-fetch": "5.35.0", - "@algolia/requester-node-http": "5.35.0" + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-personalization": { - "version": "5.35.0", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.35.0.tgz", - "integrity": "sha512-/KWjttZ6UCStt4QnWoDAJ12cKlQ+fkpMtyPmBgSS2WThJQdSV/4UWcqCUqGH7YLbwlj3JjNirCu3Y7uRTClxvA==", + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.48.1.tgz", + "integrity": "sha512-r89Qf9Oo9mKWQXumRu/1LtvVJAmEDpn8mHZMc485pRfQUMAwSSrsnaw1tQ3sszqzEgAr1c7rw6fjBI+zrAXTOw==", "devOptional": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.35.0", - "@algolia/requester-browser-xhr": "5.35.0", - "@algolia/requester-fetch": "5.35.0", - "@algolia/requester-node-http": "5.35.0" + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-query-suggestions": { - "version": "5.35.0", - "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.35.0.tgz", - "integrity": "sha512-8oCuJCFf/71IYyvQQC+iu4kgViTODbXDk3m7yMctEncRSRV+u2RtDVlpGGfPlJQOrAY7OONwJlSHkmbbm2Kp/w==", + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.48.1.tgz", + "integrity": "sha512-TPKNPKfghKG/bMSc7mQYD9HxHRUkBZA4q1PEmHgICaSeHQscGqL4wBrKkhfPlDV1uYBKW02pbFMUhsOt7p4ZpA==", "devOptional": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.35.0", - "@algolia/requester-browser-xhr": "5.35.0", - "@algolia/requester-fetch": "5.35.0", - "@algolia/requester-node-http": "5.35.0" + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-search": { - "version": "5.35.0", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.35.0.tgz", - "integrity": "sha512-FfmdHTrXhIduWyyuko1YTcGLuicVbhUyRjO3HbXE4aP655yKZgdTIfMhZ/V5VY9bHuxv/fGEh3Od1Lvv2ODNTg==", + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.48.1.tgz", + "integrity": "sha512-4Fu7dnzQyQmMFknYwTiN/HxPbH4DyxvQ1m+IxpPp5oslOgz8m6PG5qhiGbqJzH4HiT1I58ecDiCAC716UyVA8Q==", "devOptional": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.35.0", - "@algolia/requester-browser-xhr": "5.35.0", - "@algolia/requester-fetch": "5.35.0", - "@algolia/requester-node-http": "5.35.0" + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/ingestion": { - "version": "1.35.0", - "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.35.0.tgz", - "integrity": "sha512-gPzACem9IL1Co8mM1LKMhzn1aSJmp+Vp434An4C0OBY4uEJRcqsLN3uLBlY+bYvFg8C8ImwM9YRiKczJXRk0XA==", + "version": "1.48.1", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.48.1.tgz", + "integrity": "sha512-/RFq3TqtXDUUawwic/A9xylA2P3LDMO8dNhphHAUOU51b1ZLHrmZ6YYJm3df1APz7xLY1aht6okCQf+/vmrV9w==", "devOptional": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.35.0", - "@algolia/requester-browser-xhr": "5.35.0", - "@algolia/requester-fetch": "5.35.0", - "@algolia/requester-node-http": "5.35.0" + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/monitoring": { - "version": "1.35.0", - "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.35.0.tgz", - "integrity": "sha512-w9MGFLB6ashI8BGcQoVt7iLgDIJNCn4OIu0Q0giE3M2ItNrssvb8C0xuwJQyTy1OFZnemG0EB1OvXhIHOvQwWw==", + "version": "1.48.1", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.48.1.tgz", + "integrity": "sha512-Of0jTeAZRyRhC7XzDSjJef0aBkgRcvRAaw0ooYRlOw57APii7lZdq+layuNdeL72BRq1snaJhoMMwkmLIpJScw==", "devOptional": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.35.0", - "@algolia/requester-browser-xhr": "5.35.0", - "@algolia/requester-fetch": "5.35.0", - "@algolia/requester-node-http": "5.35.0" + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/recommend": { - "version": "5.35.0", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.35.0.tgz", - "integrity": "sha512-AhrVgaaXAb8Ue0u2nuRWwugt0dL5UmRgS9LXe0Hhz493a8KFeZVUE56RGIV3hAa6tHzmAV7eIoqcWTQvxzlJeQ==", + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.48.1.tgz", + "integrity": "sha512-bE7JcpFXzxF5zHwj/vkl2eiCBvyR1zQ7aoUdO+GDXxGp0DGw7nI0p8Xj6u8VmRQ+RDuPcICFQcCwRIJT5tDJFw==", "devOptional": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.35.0", - "@algolia/requester-browser-xhr": "5.35.0", - "@algolia/requester-fetch": "5.35.0", - "@algolia/requester-node-http": "5.35.0" + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-browser-xhr": { - "version": "5.35.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.35.0.tgz", - "integrity": "sha512-diY415KLJZ6x1Kbwl9u96Jsz0OstE3asjXtJ9pmk1d+5gPuQ5jQyEsgC+WmEXzlec3iuVszm8AzNYYaqw6B+Zw==", + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.48.1.tgz", + "integrity": "sha512-MK3wZ2koLDnvH/AmqIF1EKbJlhRS5j74OZGkLpxI4rYvNi9Jn/C7vb5DytBnQ4KUWts7QsmbdwHkxY5txQHXVw==", "devOptional": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.35.0" + "@algolia/client-common": "5.48.1" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-fetch": { - "version": "5.35.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.35.0.tgz", - "integrity": "sha512-uydqnSmpAjrgo8bqhE9N1wgcB98psTRRQXcjc4izwMB7yRl9C8uuAQ/5YqRj04U0mMQ+fdu2fcNF6m9+Z1BzDQ==", + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.48.1.tgz", + "integrity": "sha512-2oDT43Y5HWRSIQMPQI4tA/W+TN/N2tjggZCUsqQV440kxzzoPGsvv9QP1GhQ4CoDa+yn6ygUsGp6Dr+a9sPPSg==", "devOptional": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.35.0" + "@algolia/client-common": "5.48.1" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-node-http": { - "version": "5.35.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.35.0.tgz", - "integrity": "sha512-RgLX78ojYOrThJHrIiPzT4HW3yfQa0D7K+MQ81rhxqaNyNBu4F1r+72LNHYH/Z+y9I1Mrjrd/c/Ue5zfDgAEjQ==", + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.48.1.tgz", + "integrity": "sha512-xcaCqbhupVWhuBP1nwbk1XNvwrGljozutEiLx06mvqDf3o8cHyEgQSHS4fKJM+UAggaWVnnFW+Nne5aQ8SUJXg==", "devOptional": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.35.0" + "@algolia/client-common": "5.48.1" }, "engines": { "node": ">= 14.0.0" @@ -366,15 +365,18 @@ } }, "node_modules/@angular-devkit/architect": { - "version": "0.2003.16", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2003.16.tgz", - "integrity": "sha512-W7FPVhZzIeHVP/duuKepfZU66LpQ0k9YMHFhrGpzaUuHPOwKmza6+pjVvvti3g6jzT8b1uVlb+XlYgNPZ5jrPQ==", + "version": "0.2102.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.1.tgz", + "integrity": "sha512-x2Qqz6oLYvEh9UBUG0AP1A4zROO/VP+k+zM9+4c2uZw1uqoBQFmutqgzncjVU7cR9R0RApgx9JRZHDFtQru68w==", "devOptional": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "20.3.16", + "@angular-devkit/core": "21.2.1", "rxjs": "7.8.2" }, + "bin": { + "architect": "bin/cli.js" + }, "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", @@ -382,13 +384,13 @@ } }, "node_modules/@angular-devkit/core": { - "version": "20.3.16", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-20.3.16.tgz", - "integrity": "sha512-6L9Lpe3lbkyz32gzqxZGVC8MhXxXht+yV+4LUsb4+6T/mG/V9lW6UTW0dhwVOS3vpWMEwpy75XHT298t7HcKEg==", + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.1.tgz", + "integrity": "sha512-TpXGjERqVPN8EPt7LdmWAwh0oNQ/6uWFutzGZiXhJy81n1zb1O1XrqhRAmvP1cAo5O+na6IV2JkkCmxL6F8GUg==", "license": "MIT", "peer": true, "dependencies": { - "ajv": "8.17.1", + "ajv": "8.18.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", "picomatch": "4.0.3", @@ -401,7 +403,7 @@ "yarn": ">= 1.13.0" }, "peerDependencies": { - "chokidar": "^4.0.0" + "chokidar": "^5.0.0" }, "peerDependenciesMeta": { "chokidar": { @@ -410,16 +412,16 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "20.3.16", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-20.3.16.tgz", - "integrity": "sha512-3K8QwTpKjnLo3hIvNzB9sTjrlkeRyMK0TxdwgTbwJseewGhXLl98oBoTCWM2ygtpskiWNpYqXJNIhoslNN65WQ==", + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.1.tgz", + "integrity": "sha512-CWoamHaasAHMjHcYqxbj0tMnoXxdGotcAz2SpiuWtH28Lnf5xfbTaJn/lwdMP8Wdh4tgA+uYh2l45A5auCwmkw==", "license": "MIT", "peer": true, "dependencies": { - "@angular-devkit/core": "20.3.16", + "@angular-devkit/core": "21.2.1", "jsonc-parser": "3.3.1", - "magic-string": "0.30.17", - "ora": "8.2.0", + "magic-string": "0.30.21", + "ora": "9.3.0", "rxjs": "7.8.2" }, "engines": { @@ -447,9 +449,9 @@ } }, "node_modules/@angular/animations": { - "version": "20.3.16", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.16.tgz", - "integrity": "sha512-N83/GFY5lKNyWgPV3xHHy2rb3/eP1ZLzSVI+dmMVbf3jbqwY1YPQcMiAG8UDzaILY1Dkus91kWLF8Qdr3nHAzg==", + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.2.1.tgz", + "integrity": "sha512-zT/S29pUTbziCLvZ2itBdNWd5i8tsXexofH7KA4n2yvYmK1EhNpE7TlHRjghmsHgtDt4VnGiMW4zXEyrl05Dwg==", "license": "MIT", "peer": true, "dependencies": { @@ -459,42 +461,43 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "20.3.16" + "@angular/core": "21.2.1" } }, "node_modules/@angular/build": { - "version": "20.3.16", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-20.3.16.tgz", - "integrity": "sha512-p1W3wwMG1Bs4tkPW7ceXO4woO1KCP28sjfpBJg32dIMW3dYSC+iWNmUkYS/wb4YEkqCV0wd6Apnd98mZjL6rNg==", + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.1.tgz", + "integrity": "sha512-cUpLNHJp9taII/FOcJHHfQYlMcZSRaf6eIxgSNS6Xfx1CeGoJNDN+J8+GFk+H1CPJt1EvbfyZ+dE5DbsgTD/QQ==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.2003.16", - "@babel/core": "7.28.3", + "@angular-devkit/architect": "0.2102.1", + "@babel/core": "7.29.0", "@babel/helper-annotate-as-pure": "7.27.3", "@babel/helper-split-export-declaration": "7.24.7", - "@inquirer/confirm": "5.1.14", - "@vitejs/plugin-basic-ssl": "2.1.0", - "beasties": "0.3.5", - "browserslist": "^4.23.0", - "esbuild": "0.25.9", + "@inquirer/confirm": "5.1.21", + "@vitejs/plugin-basic-ssl": "2.1.4", + "beasties": "0.4.1", + "browserslist": "^4.26.0", + "esbuild": "0.27.3", "https-proxy-agent": "7.0.6", "istanbul-lib-instrument": "6.0.3", "jsonc-parser": "3.3.1", - "listr2": "9.0.1", - "magic-string": "0.30.17", + "listr2": "9.0.5", + "magic-string": "0.30.21", "mrmime": "2.0.1", "parse5-html-rewriting-stream": "8.0.0", "picomatch": "4.0.3", - "piscina": "5.1.3", - "rollup": "4.52.3", - "sass": "1.90.0", - "semver": "7.7.2", + "piscina": "5.1.4", + "rolldown": "1.0.0-rc.4", + "sass": "1.97.3", + "semver": "7.7.4", "source-map-support": "0.5.21", - "tinyglobby": "0.2.14", - "vite": "7.1.11", - "watchpack": "2.4.4" + "tinyglobby": "0.2.15", + "undici": "7.22.0", + "vite": "7.3.1", + "watchpack": "2.5.1" }, "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0", @@ -502,25 +505,25 @@ "yarn": ">= 1.13.0" }, "optionalDependencies": { - "lmdb": "3.4.2" + "lmdb": "3.5.1" }, "peerDependencies": { - "@angular/compiler": "^20.0.0", - "@angular/compiler-cli": "^20.0.0", - "@angular/core": "^20.0.0", - "@angular/localize": "^20.0.0", - "@angular/platform-browser": "^20.0.0", - "@angular/platform-server": "^20.0.0", - "@angular/service-worker": "^20.0.0", - "@angular/ssr": "^20.3.16", + "@angular/compiler": "^21.0.0", + "@angular/compiler-cli": "^21.0.0", + "@angular/core": "^21.0.0", + "@angular/localize": "^21.0.0", + "@angular/platform-browser": "^21.0.0", + "@angular/platform-server": "^21.0.0", + "@angular/service-worker": "^21.0.0", + "@angular/ssr": "^21.2.1", "karma": "^6.4.0", "less": "^4.2.0", - "ng-packagr": "^20.0.0", + "ng-packagr": "^21.0.0", "postcss": "^8.4.0", "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", "tslib": "^2.3.0", - "typescript": ">=5.8 <6.0", - "vitest": "^3.1.1" + "typescript": ">=5.9 <6.0", + "vitest": "^4.0.8" }, "peerDependenciesMeta": { "@angular/core": { @@ -562,9 +565,9 @@ } }, "node_modules/@angular/cdk": { - "version": "20.2.14", - "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-20.2.14.tgz", - "integrity": "sha512-7bZxc01URbiPiIBWThQ69XwOxVduqEKN4PhpbF2AAyfMc/W8Hcr4VoIJOwL0O1Nkq5beS8pCAqoOeIgFyXd/kg==", + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.2.1.tgz", + "integrity": "sha512-JUFV8qLnO7CU5v4W0HzXSQrFkkJ4RH/qqdwrf9lup7YEnsLxB7cTGhsVisc9pWKAJsoNZ4pXCVOkqKc1mFL7dw==", "license": "MIT", "peer": true, "dependencies": { @@ -572,37 +575,38 @@ "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/common": "^20.0.0 || ^21.0.0", - "@angular/core": "^20.0.0 || ^21.0.0", + "@angular/common": "^21.0.0 || ^22.0.0", + "@angular/core": "^21.0.0 || ^22.0.0", + "@angular/platform-browser": "^21.0.0 || ^22.0.0", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/cli": { - "version": "20.3.16", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-20.3.16.tgz", - "integrity": "sha512-kjGp0ywIWebWrH6U5eCRkS4Tx1D/yMe2iT7DXMfEcLc8iMSrBozEriMJppbot9ou8O2LeEH5d1Nw0efNNo78Kw==", + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.1.tgz", + "integrity": "sha512-5SRfMTgwFj1zXOpfeZWHsxZBni0J4Xz7/CbewG47D6DmbstOrSdgt6eNzJ62R650t0G9dpri2YvToZgImtbjOQ==", "devOptional": true, "license": "MIT", "peer": true, "dependencies": { - "@angular-devkit/architect": "0.2003.16", - "@angular-devkit/core": "20.3.16", - "@angular-devkit/schematics": "20.3.16", - "@inquirer/prompts": "7.8.2", - "@listr2/prompt-adapter-inquirer": "3.0.1", + "@angular-devkit/architect": "0.2102.1", + "@angular-devkit/core": "21.2.1", + "@angular-devkit/schematics": "21.2.1", + "@inquirer/prompts": "7.10.1", + "@listr2/prompt-adapter-inquirer": "3.0.5", "@modelcontextprotocol/sdk": "1.26.0", - "@schematics/angular": "20.3.16", + "@schematics/angular": "21.2.1", "@yarnpkg/lockfile": "1.1.0", - "algoliasearch": "5.35.0", - "ini": "5.0.0", + "algoliasearch": "5.48.1", + "ini": "6.0.0", "jsonc-parser": "3.3.1", - "listr2": "9.0.1", - "npm-package-arg": "13.0.0", - "pacote": "21.0.4", - "resolve": "1.22.10", - "semver": "7.7.2", + "listr2": "9.0.5", + "npm-package-arg": "13.0.2", + "pacote": "21.3.1", + "parse5-html-rewriting-stream": "8.0.0", + "semver": "7.7.4", "yargs": "18.0.0", - "zod": "4.1.13" + "zod": "4.3.6" }, "bin": { "ng": "bin/ng.js" @@ -614,9 +618,9 @@ } }, "node_modules/@angular/common": { - "version": "20.3.16", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.16.tgz", - "integrity": "sha512-GRAziNlntwdnJy3F+8zCOvDdy7id0gITjDnM6P9+n2lXvtDuBLGJKU3DWBbvxcCjtD6JK/g/rEX5fbCxbUHkQQ==", + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.1.tgz", + "integrity": "sha512-xhv2i1Q9s1kpGbGsfj+o36+XUC/TQLcZyRuRxn3GwaN7Rv34FabC88ycpvoE+sW/txj4JRx9yPA0dRSZjwZ+Gg==", "license": "MIT", "peer": true, "dependencies": { @@ -626,14 +630,14 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "20.3.16", + "@angular/core": "21.2.1", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "20.3.16", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.16.tgz", - "integrity": "sha512-Pt9Ms9GwTThgzdxWBwMfN8cH1JEtQ2DK5dc2yxYtPSaD+WKmG9AVL1PrzIYQEbaKcWk2jxASUHpEWSlNiwo8uw==", + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.1.tgz", + "integrity": "sha512-FxWaSaii1vfHIFA+JksqQ8NGB2frfqCrs7Ju50a44kbwR4fmanfn/VsiS/CbwBp9vcyT/Br9X/jAG4RuK/U2nw==", "license": "MIT", "peer": true, "dependencies": { @@ -644,16 +648,16 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "20.3.16", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.3.16.tgz", - "integrity": "sha512-l3xF/fXfJAl/UrNnH9Ufkr79myjMgXdHq1mmmph2UnpeqilRB1b8lC9sLBV9MipQHVn3dwocxMIvtrcryfOaXw==", + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.1.tgz", + "integrity": "sha512-qYCWLGtEju4cDtYLi4ZzbwKoF0lcGs+Lc31kuESvAzYvWNgk2EUOtwWo8kbgpAzAwSYodtxW6Q90iWEwfU6elw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/core": "7.28.3", + "@babel/core": "7.29.0", "@jridgewell/sourcemap-codec": "^1.4.14", - "chokidar": "^4.0.0", + "chokidar": "^5.0.0", "convert-source-map": "^1.5.1", "reflect-metadata": "^0.2.0", "semver": "^7.0.0", @@ -668,8 +672,8 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "20.3.16", - "typescript": ">=5.8 <6.0" + "@angular/compiler": "21.2.1", + "typescript": ">=5.9 <6.1" }, "peerDependenciesMeta": { "typescript": { @@ -678,9 +682,9 @@ } }, "node_modules/@angular/core": { - "version": "20.3.16", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.16.tgz", - "integrity": "sha512-KSFPKvOmWWLCJBbEO+CuRUXfecX2FRuO0jNi9c54ptXMOPHlK1lIojUnyXmMNzjdHgRug8ci9qDuftvC2B7MKg==", + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.1.tgz", + "integrity": "sha512-pFTbg03s2ZI5cHNT+eWsGjwIIKiYkeAnodFbCAHjwFi9KCEYlTykFLjr9lcpGrBddfmAH7GE08Q73vgmsdcNHw==", "license": "MIT", "peer": true, "dependencies": { @@ -690,9 +694,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "20.3.16", + "@angular/compiler": "21.2.1", "rxjs": "^6.5.3 || ^7.4.0", - "zone.js": "~0.15.0" + "zone.js": "~0.15.0 || ~0.16.0" }, "peerDependenciesMeta": { "@angular/compiler": { @@ -704,28 +708,29 @@ } }, "node_modules/@angular/forms": { - "version": "20.3.16", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.16.tgz", - "integrity": "sha512-1yzbXpExTqATpVcqA3wGrq4ACFIP3mRxA4pbso5KoJU+/4JfzNFwLsDaFXKpm5uxwchVnj8KM2vPaDOkvtp7NA==", + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.1.tgz", + "integrity": "sha512-6aqOPk9xoa0dfeUDeEbhaiPhmt6MQrdn59qbGAomn9RMXA925TrHbJhSIkp9tXc2Fr4aJRi8zkD/cdXEc1IYeA==", "license": "MIT", "peer": true, "dependencies": { + "@standard-schema/spec": "^1.0.0", "tslib": "^2.3.0" }, "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "20.3.16", - "@angular/core": "20.3.16", - "@angular/platform-browser": "20.3.16", + "@angular/common": "21.2.1", + "@angular/core": "21.2.1", + "@angular/platform-browser": "21.2.1", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/language-service": { - "version": "20.3.16", - "resolved": "https://registry.npmjs.org/@angular/language-service/-/language-service-20.3.16.tgz", - "integrity": "sha512-0A/tSQPq5geIz2mMcZA5fzzbzT39v+ADQksnfPr8htNxtkYWy+EI5+d0+++k59NuvjLY4uTBqhRTRB9b1PKrjw==", + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@angular/language-service/-/language-service-21.2.1.tgz", + "integrity": "sha512-L8EaNhWDKMny18RURg/Ju2Dix2e7qLL/s2yDQrawgjQRmXAMnjimz10w/EiiG7FMK/Hj5fLycS5X8VITq1f2rg==", "dev": true, "license": "MIT", "engines": { @@ -733,9 +738,9 @@ } }, "node_modules/@angular/platform-browser": { - "version": "20.3.16", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.16.tgz", - "integrity": "sha512-YsrLS6vyS77i4pVHg4gdSBW74qvzHjpQRTVQ5Lv/OxIjJdYYYkMmjNalCNgy1ZuyY6CaLIB11ccxhrNnxfKGOQ==", + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.1.tgz", + "integrity": "sha512-k4SJLxIaLT26vLjLuFL+ho0BiG5PrdxEsjsXFC7w5iUhomeouzkHVTZ4t7gaLNKrdRD7QNtU4Faw0nL0yx0ZPQ==", "license": "MIT", "peer": true, "dependencies": { @@ -745,9 +750,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/animations": "20.3.16", - "@angular/common": "20.3.16", - "@angular/core": "20.3.16" + "@angular/animations": "21.2.1", + "@angular/common": "21.2.1", + "@angular/core": "21.2.1" }, "peerDependenciesMeta": { "@angular/animations": { @@ -755,32 +760,14 @@ } } }, - "node_modules/@angular/platform-browser-dynamic": { - "version": "20.3.16", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.16.tgz", - "integrity": "sha512-5mECCV9YeKH6ue239GXRTGeDSd/eTbM1j8dDejhm5cGnPBhTxRw4o+GgSrWTYtb6VmIYdwUGBTC+wCBphiaQ2A==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@angular/common": "20.3.16", - "@angular/compiler": "20.3.16", - "@angular/core": "20.3.16", - "@angular/platform-browser": "20.3.16" - } - }, "node_modules/@angular/pwa": { - "version": "20.3.16", - "resolved": "https://registry.npmjs.org/@angular/pwa/-/pwa-20.3.16.tgz", - "integrity": "sha512-F4YFgklMadJ/2kE/A/usj/Mi6X/zWpvveY9TiC8JNgC4HtD1NAg8ypIk21W1Uq17nows0rVxcJGR3ZJdBvbVyQ==", + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@angular/pwa/-/pwa-21.2.1.tgz", + "integrity": "sha512-oi4amOGT7g6voeBeyr9/8TBug+RSghmAVoyBNIrAPLNRXmhJGRtCom7WZoYHR4mJ8+Tf+PXcjm8RDO2Zq4AqpQ==", "license": "MIT", "dependencies": { - "@angular-devkit/schematics": "20.3.16", - "@schematics/angular": "20.3.16", + "@angular-devkit/schematics": "21.2.1", + "@schematics/angular": "21.2.1", "parse5-html-rewriting-stream": "8.0.0" }, "engines": { @@ -789,7 +776,7 @@ "yarn": ">= 1.13.0" }, "peerDependencies": { - "@angular/cli": "^20.3.16" + "@angular/cli": "^21.2.1" }, "peerDependenciesMeta": { "@angular/cli": { @@ -798,9 +785,9 @@ } }, "node_modules/@angular/router": { - "version": "20.3.16", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.16.tgz", - "integrity": "sha512-e1LiQFZaajKqc00cY5FboIrWJZSMnZ64GDp5R0UejritYrqorQQQNOqP1W85BMuY2owibMmxVfX+dJg/Mc8PuQ==", + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.1.tgz", + "integrity": "sha512-FUKG+8ImQYxmlDUdAs7+VeS/VrBNrbo0zGiKkzVNU/bbcCyroKXJLXFtkFI3qmROiJNyIta2IMBCHJvIjLIMig==", "license": "MIT", "peer": true, "dependencies": { @@ -810,16 +797,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "20.3.16", - "@angular/core": "20.3.16", - "@angular/platform-browser": "20.3.16", + "@angular/common": "21.2.1", + "@angular/core": "21.2.1", + "@angular/platform-browser": "21.2.1", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/service-worker": { - "version": "20.3.16", - "resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-20.3.16.tgz", - "integrity": "sha512-qme+jz3ySWas4JRif6NVaxWStas1XmOaws6EUfpei1AAlK0aBXmuTZtF3YAQDfP6RxLQP/axE0Vm1TpYhNYahA==", + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-21.2.1.tgz", + "integrity": "sha512-mFyEVh5KazB6wr9uoXhlDQQDaicH9/t2m6lsN+/t2y6iMPpTIuTbWYHXX1uVbLKcxne54ei78NgD3wNS7DMfmg==", "license": "MIT", "peer": true, "dependencies": { @@ -832,7 +819,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "20.3.16", + "@angular/core": "21.2.1", "rxjs": "^6.5.3 || ^7.4.0" } }, @@ -862,23 +849,23 @@ } }, "node_modules/@babel/core": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz", - "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.3", - "@babel/parser": "^7.28.3", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.3", - "@babel/types": "^7.28.2", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -1154,10 +1141,44 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", - "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "cpu": [ "ppc64" ], @@ -1172,9 +1193,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", - "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "cpu": [ "arm" ], @@ -1189,9 +1210,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", - "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", "cpu": [ "arm64" ], @@ -1206,9 +1227,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", - "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", "cpu": [ "x64" ], @@ -1223,9 +1244,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", - "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "cpu": [ "arm64" ], @@ -1240,9 +1261,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", - "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "cpu": [ "x64" ], @@ -1257,9 +1278,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", - "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "cpu": [ "arm64" ], @@ -1274,9 +1295,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", - "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "cpu": [ "x64" ], @@ -1291,9 +1312,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", - "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "cpu": [ "arm" ], @@ -1308,9 +1329,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", - "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "cpu": [ "arm64" ], @@ -1325,9 +1346,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", - "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "cpu": [ "ia32" ], @@ -1342,9 +1363,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", - "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "cpu": [ "loong64" ], @@ -1359,9 +1380,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", - "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "cpu": [ "mips64el" ], @@ -1376,9 +1397,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", - "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "cpu": [ "ppc64" ], @@ -1393,9 +1414,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", - "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "cpu": [ "riscv64" ], @@ -1410,9 +1431,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", - "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "cpu": [ "s390x" ], @@ -1427,9 +1448,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", - "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "cpu": [ "x64" ], @@ -1444,9 +1465,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", - "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", "cpu": [ "arm64" ], @@ -1461,9 +1482,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", - "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "cpu": [ "x64" ], @@ -1478,9 +1499,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", - "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", "cpu": [ "arm64" ], @@ -1495,9 +1516,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", - "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", "cpu": [ "x64" ], @@ -1512,9 +1533,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", - "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", "cpu": [ "arm64" ], @@ -1529,9 +1550,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", - "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "cpu": [ "x64" ], @@ -1546,9 +1567,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", - "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "cpu": [ "arm64" ], @@ -1563,9 +1584,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", - "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "cpu": [ "ia32" ], @@ -1580,9 +1601,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", - "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", "cpu": [ "x64" ], @@ -1596,10 +1617,31 @@ "node": ">=18" } }, + "node_modules/@gar/promise-retry": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.2.tgz", + "integrity": "sha512-Lm/ZLhDZcBECta3TmCQSngiQykFdfw+QtI1/GYMsZd4l3nG+P8WLB16XuS7WaBGLQ+9E+cOcWQsth9cayuGt8g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "retry": "^0.13.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@harperfast/extended-iterable": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@harperfast/extended-iterable/-/extended-iterable-1.0.3.tgz", + "integrity": "sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw==", + "dev": true, + "license": "Apache-2.0", + "optional": true + }, "node_modules/@hono/node-server": { - "version": "1.19.9", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", - "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", "devOptional": true, "license": "MIT", "engines": { @@ -1645,14 +1687,14 @@ } }, "node_modules/@inquirer/confirm": { - "version": "5.1.14", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.14.tgz", - "integrity": "sha512-5yR4IBfe0kXe59r1YCTG8WXkUbl7Z35HK87Sw+WUyGD8wNUx7JvY7laahzeytyE1oLn74bQnL7hstctQxisQ8Q==", + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.15", - "@inquirer/type": "^3.0.8" + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" }, "engines": { "node": ">=18" @@ -1840,23 +1882,23 @@ } }, "node_modules/@inquirer/prompts": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.8.2.tgz", - "integrity": "sha512-nqhDw2ZcAUrKNPwhjinJny903bRhI0rQhiDz1LksjeRxqa36i3l75+4iXbOy0rlDpLJGxqtgoPavQjmmyS5UJw==", + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", + "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", "devOptional": true, "license": "MIT", "peer": true, "dependencies": { - "@inquirer/checkbox": "^4.2.1", - "@inquirer/confirm": "^5.1.14", - "@inquirer/editor": "^4.2.17", - "@inquirer/expand": "^4.0.17", - "@inquirer/input": "^4.2.1", - "@inquirer/number": "^3.0.17", - "@inquirer/password": "^4.0.17", - "@inquirer/rawlist": "^4.1.5", - "@inquirer/search": "^3.1.0", - "@inquirer/select": "^4.3.1" + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" }, "engines": { "node": ">=18" @@ -2004,6 +2046,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -2032,26 +2085,26 @@ } }, "node_modules/@listr2/prompt-adapter-inquirer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.1.tgz", - "integrity": "sha512-3XFmGwm3u6ioREG+ynAQB7FoxfajgQnMhIu8wC5eo/Lsih4aKDg0VuIMGaOsYn7hJSJagSeaD4K8yfpkEoDEmA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.5.tgz", + "integrity": "sha512-WELs+hj6xcilkloBXYf9XXK8tYEnKsgLj01Xl5ONUJpKjmT5hGVUzNUS5tooUxs7pGMrw+jFD/41WpqW4V3LDA==", "devOptional": true, "license": "MIT", "dependencies": { - "@inquirer/type": "^3.0.7" + "@inquirer/type": "^3.0.8" }, "engines": { "node": ">=20.0.0" }, "peerDependencies": { "@inquirer/prompts": ">= 3 < 8", - "listr2": "9.0.1" + "listr2": "9.0.5" } }, "node_modules/@lmdb/lmdb-darwin-arm64": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.4.2.tgz", - "integrity": "sha512-NK80WwDoODyPaSazKbzd3NEJ3ygePrkERilZshxBViBARNz21rmediktGHExoj9n5t9+ChlgLlxecdFKLCuCKg==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.5.1.tgz", + "integrity": "sha512-tpfN4kKrrMpQ+If1l8bhmoNkECJi0iOu6AEdrTJvWVC+32sLxTARX5Rsu579mPImRP9YFWfWgeRQ5oav7zApQQ==", "cpu": [ "arm64" ], @@ -2063,9 +2116,9 @@ ] }, "node_modules/@lmdb/lmdb-darwin-x64": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-3.4.2.tgz", - "integrity": "sha512-zevaowQNmrp3U7Fz1s9pls5aIgpKRsKb3dZWDINtLiozh3jZI9fBrI19lYYBxqdyiIyNdlyiidPnwPShj4aK+w==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-3.5.1.tgz", + "integrity": "sha512-+a2tTfc3rmWhLAolFUWRgJtpSuu+Fw/yjn4rF406NMxhfjbMuiOUTDRvRlMFV+DzyjkwnokisskHbCWkS3Ly5w==", "cpu": [ "x64" ], @@ -2077,9 +2130,9 @@ ] }, "node_modules/@lmdb/lmdb-linux-arm": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-3.4.2.tgz", - "integrity": "sha512-OmHCULY17rkx/RoCoXlzU7LyR8xqrksgdYWwtYa14l/sseezZ8seKWXcogHcjulBddER5NnEFV4L/Jtr2nyxeg==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-3.5.1.tgz", + "integrity": "sha512-0EgcE6reYr8InjD7V37EgXcYrloqpxVPINy3ig1MwDSbl6LF/vXTYRH9OE1Ti1D8YZnB35ZH9aTcdfSb5lql2A==", "cpu": [ "arm" ], @@ -2091,9 +2144,9 @@ ] }, "node_modules/@lmdb/lmdb-linux-arm64": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-3.4.2.tgz", - "integrity": "sha512-ZBEfbNZdkneebvZs98Lq30jMY8V9IJzckVeigGivV7nTHJc+89Ctomp1kAIWKlwIG0ovCDrFI448GzFPORANYg==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-3.5.1.tgz", + "integrity": "sha512-aoERa5B6ywXdyFeYGQ1gbQpkMkDbEo45qVoXE5QpIRavqjnyPwjOulMkmkypkmsbJ5z4Wi0TBztON8agCTG0Vg==", "cpu": [ "arm64" ], @@ -2105,9 +2158,9 @@ ] }, "node_modules/@lmdb/lmdb-linux-x64": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-3.4.2.tgz", - "integrity": "sha512-vL9nM17C77lohPYE4YaAQvfZCSVJSryE4fXdi8M7uWPBnU+9DJabgKVAeyDb84ZM2vcFseoBE4/AagVtJeRE7g==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-3.5.1.tgz", + "integrity": "sha512-SqNDY1+vpji7bh0sFH5wlWyFTOzjbDOl0/kB5RLLYDAFyd/uw3n7wyrmas3rYPpAW7z18lMOi1yKlTPv967E3g==", "cpu": [ "x64" ], @@ -2119,9 +2172,9 @@ ] }, "node_modules/@lmdb/lmdb-win32-arm64": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-arm64/-/lmdb-win32-arm64-3.4.2.tgz", - "integrity": "sha512-SXWjdBfNDze4ZPeLtYIzsIeDJDJ/SdsA0pEXcUBayUIMO0FQBHfVZZyHXQjjHr4cvOAzANBgIiqaXRwfMhzmLw==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-arm64/-/lmdb-win32-arm64-3.5.1.tgz", + "integrity": "sha512-50v0O1Lt37cwrmR9vWZK5hRW0Aw+KEmxJJ75fge/zIYdvNKB/0bSMSVR5Uc2OV9JhosIUyklOmrEvavwNJ8D6w==", "cpu": [ "arm64" ], @@ -2133,9 +2186,9 @@ ] }, "node_modules/@lmdb/lmdb-win32-x64": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.4.2.tgz", - "integrity": "sha512-IY+r3bxKW6Q6sIPiMC0L533DEfRJSXibjSI3Ft/w9Q8KQBNqEIvUFXt+09wV8S5BRk0a8uSF19YWxuRwEfI90g==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.5.1.tgz", + "integrity": "sha512-qwosvPyl+zpUlp3gRb7UcJ3H8S28XHCzkv0Y0EgQToXjQP91ZD67EHSCDmaLjtKhe+GVIW5om1KUpzVLA0l6pg==", "cpu": [ "x64" ], @@ -2661,6 +2714,23 @@ "node": ">= 10" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@ng-web-apis/common": { "version": "4.14.0", "resolved": "https://registry.npmjs.org/@ng-web-apis/common/-/common-4.14.0.tgz", @@ -2849,18 +2919,18 @@ } }, "node_modules/@npmcli/git": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-7.0.1.tgz", - "integrity": "sha512-+XTFxK2jJF/EJJ5SoAzXk3qwIDfvFc5/g+bD274LZ7uY7LE8sTfG6Z8rOanPl2ZEvZWqNvmEdtXC25cE54VcoA==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-7.0.2.tgz", + "integrity": "sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg==", "devOptional": true, "license": "ISC", "dependencies": { + "@gar/promise-retry": "^1.0.0", "@npmcli/promise-spawn": "^9.0.0", "ini": "^6.0.0", "lru-cache": "^11.2.1", "npm-pick-manifest": "^11.0.1", "proc-log": "^6.0.0", - "promise-retry": "^2.0.1", "semver": "^7.3.5", "which": "^6.0.0" }, @@ -2868,16 +2938,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@npmcli/git/node_modules/ini": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", - "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", - "devOptional": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/@npmcli/git/node_modules/isexe": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", @@ -2898,16 +2958,6 @@ "node": "20 || >=22" } }, - "node_modules/@npmcli/git/node_modules/proc-log": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", - "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", - "devOptional": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/@npmcli/git/node_modules/which": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", @@ -2952,9 +3002,9 @@ } }, "node_modules/@npmcli/package-json": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-7.0.4.tgz", - "integrity": "sha512-0wInJG3j/K40OJt/33ax47WfWMzZTm6OQxB9cDhTt5huCP2a9g2GnlsxmfN+PulItNPIpPrZ+kfwwUil7eHcZQ==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-7.0.5.tgz", + "integrity": "sha512-iVuTlG3ORq2iaVa1IWUxAO/jIp77tUKBhoMjuzYW2kL4MLN1bi/ofqkZ7D7OOwh8coAx1/S2ge0rMdGv8sLSOQ==", "devOptional": true, "license": "ISC", "dependencies": { @@ -2964,22 +3014,12 @@ "json-parse-even-better-errors": "^5.0.0", "proc-log": "^6.0.0", "semver": "^7.5.3", - "validate-npm-package-license": "^3.0.4" + "spdx-expression-parse": "^4.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@npmcli/package-json/node_modules/proc-log": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", - "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", - "devOptional": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/@npmcli/promise-spawn": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-9.0.1.tgz", @@ -3030,9 +3070,9 @@ } }, "node_modules/@npmcli/run-script": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-10.0.3.tgz", - "integrity": "sha512-ER2N6itRkzWbbtVmZ9WKaWxVlKlOeBFF1/7xx+KA5J1xKa4JjUwBdb6tDpk0v1qA+d+VDwHI9qmLcXSWcmi+Rw==", + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-10.0.4.tgz", + "integrity": "sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg==", "devOptional": true, "license": "ISC", "dependencies": { @@ -3040,47 +3080,20 @@ "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "node-gyp": "^12.1.0", - "proc-log": "^6.0.0", - "which": "^6.0.0" + "proc-log": "^6.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@npmcli/run-script/node_modules/isexe": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", - "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", - "devOptional": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=20" - } - }, - "node_modules/@npmcli/run-script/node_modules/proc-log": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", - "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", - "devOptional": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@npmcli/run-script/node_modules/which": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", - "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "isexe": "^4.0.0" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" + "node_modules/@oxc-project/types": { + "version": "0.113.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.113.0.tgz", + "integrity": "sha512-Tp3XmgxwNQ9pEN9vxgJBAqdRamHibi76iowQ38O2I4PMpcvNRQNVsU2n1x1nv9yh0XoTrGFzf7cZSGxmixxrhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" } }, "node_modules/@parcel/watcher": { @@ -3401,6 +3414,234 @@ "license": "MIT", "optional": true }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.4.tgz", + "integrity": "sha512-vRq9f4NzvbdZavhQbjkJBx7rRebDKYR9zHfO/Wg486+I7bSecdUapzCm5cyXoK+LHokTxgSq7A5baAXUZkIz0w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.4.tgz", + "integrity": "sha512-kFgEvkWLqt3YCgKB5re9RlIrx9bRsvyVUnaTakEpOPuLGzLpLapYxE9BufJNvPg8GjT6mB1alN4yN1NjzoeM8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.4.tgz", + "integrity": "sha512-JXmaOJGsL/+rsmMfutcDjxWM2fTaVgCHGoXS7nE8Z3c9NAYjGqHvXrAhMUZvMpHS/k7Mg+X7n/MVKb7NYWKKww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.4.tgz", + "integrity": "sha512-ep3Catd6sPnHTM0P4hNEvIv5arnDvk01PfyJIJ+J3wVCG1eEaPo09tvFqdtcaTrkwQy0VWR24uz+cb4IsK53Qw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.4.tgz", + "integrity": "sha512-LwA5ayKIpnsgXJEwWc3h8wPiS33NMIHd9BhsV92T8VetVAbGe2qXlJwNVDGHN5cOQ22R9uYvbrQir2AB+ntT2w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.4.tgz", + "integrity": "sha512-AC1WsGdlV1MtGay/OQ4J9T7GRadVnpYRzTcygV1hKnypbYN20Yh4t6O1Sa2qRBMqv1etulUknqXjc3CTIsBu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.4.tgz", + "integrity": "sha512-lU+6rgXXViO61B4EudxtVMXSOfiZONR29Sys5VGSetUY7X8mg9FCKIIjcPPj8xNDeYzKl+H8F/qSKOBVFJChCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.4.tgz", + "integrity": "sha512-DZaN1f0PGp/bSvKhtw50pPsnln4T13ycDq1FrDWRiHmWt1JeW+UtYg9touPFf8yt993p8tS2QjybpzKNTxYEwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.4.tgz", + "integrity": "sha512-RnGxwZLN7fhMMAItnD6dZ7lvy+TI7ba+2V54UF4dhaWa/p8I/ys1E73KO6HmPmgz92ZkfD8TXS1IMV8+uhbR9g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.4.tgz", + "integrity": "sha512-6lcI79+X8klGiGd8yHuTgQRjuuJYNggmEml+RsyN596P23l/zf9FVmJ7K0KVKkFAeYEdg0iMUKyIxiV5vebDNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.4.tgz", + "integrity": "sha512-wz7ohsKCAIWy91blZ/1FlpPdqrsm1xpcEOQVveWoL6+aSPKL4VUcoYmmzuLTssyZxRpEwzuIxL/GDsvpjaBtOw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.4.tgz", + "integrity": "sha512-cfiMrfuWCIgsFmcVG0IPuO6qTRHvF7NuG3wngX1RZzc6dU8FuBFb+J3MIR5WrdTNozlumfgL4cvz+R4ozBCvsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.4.tgz", + "integrity": "sha512-p6UeR9y7ht82AH57qwGuFYn69S6CZ7LLKdCKy/8T3zS9VTrJei2/CGsTUV45Da4Z9Rbhc7G4gyWQ/Ioamqn09g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.4.tgz", + "integrity": "sha512-1BrrmTu0TWfOP1riA8uakjFc9bpIUGzVKETsOtzY39pPga8zELGDl8eu1Dx7/gjM5CAz14UknsUMpBO8L+YntQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/plugin-json": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", @@ -3453,9 +3694,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.3.tgz", - "integrity": "sha512-h6cqHGZ6VdnwliFG1NXvMPTy/9PS3h8oLh7ImwR+kl+oYnQizgjxsONmmPSb2C66RksfkfIxEVtDSEcJiO0tqw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -3467,9 +3708,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.3.tgz", - "integrity": "sha512-wd+u7SLT/u6knklV/ifG7gr5Qy4GUbH2hMWcDauPFJzmCZUAJ8L2bTkVXC2niOIxp8lk3iH/QX8kSrUxVZrOVw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -3481,9 +3722,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.3.tgz", - "integrity": "sha512-lj9ViATR1SsqycwFkJCtYfQTheBdvlWJqzqxwc9f2qrcVrQaF/gCuBRTiTolkRWS6KvNxSk4KHZWG7tDktLgjg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -3495,9 +3736,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.3.tgz", - "integrity": "sha512-+Dyo7O1KUmIsbzx1l+4V4tvEVnVQqMOIYtrxK7ncLSknl1xnMHLgn7gddJVrYPNZfEB8CIi3hK8gq8bDhb3h5A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -3509,9 +3750,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.3.tgz", - "integrity": "sha512-u9Xg2FavYbD30g3DSfNhxgNrxhi6xVG4Y6i9Ur1C7xUuGDW3banRbXj+qgnIrwRN4KeJ396jchwy9bCIzbyBEQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -3523,9 +3764,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.3.tgz", - "integrity": "sha512-5M8kyi/OX96wtD5qJR89a/3x5x8x5inXBZO04JWhkQb2JWavOWfjgkdvUqibGJeNNaz1/Z1PPza5/tAPXICI6A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -3537,9 +3778,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.3.tgz", - "integrity": "sha512-IoerZJ4l1wRMopEHRKOO16e04iXRDyZFZnNZKrWeNquh5d6bucjezgd+OxG03mOMTnS1x7hilzb3uURPkJ0OfA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -3551,9 +3792,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.3.tgz", - "integrity": "sha512-ZYdtqgHTDfvrJHSh3W22TvjWxwOgc3ThK/XjgcNGP2DIwFIPeAPNsQxrJO5XqleSlgDux2VAoWQ5iJrtaC1TbA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -3565,9 +3806,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.3.tgz", - "integrity": "sha512-NcViG7A0YtuFDA6xWSgmFb6iPFzHlf5vcqb2p0lGEbT+gjrEEz8nC/EeDHvx6mnGXnGCC1SeVV+8u+smj0CeGQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -3579,9 +3820,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.3.tgz", - "integrity": "sha512-d3pY7LWno6SYNXRm6Ebsq0DJGoiLXTb83AIPCXl9fmtIQs/rXoS8SJxxUNtFbJ5MiOvs+7y34np77+9l4nfFMw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -3593,9 +3834,23 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.3.tgz", - "integrity": "sha512-3y5GA0JkBuirLqmjwAKwB0keDlI6JfGYduMlJD/Rl7fvb4Ni8iKdQs1eiunMZJhwDWdCvrcqXRY++VEBbvk6Eg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -3607,9 +3862,23 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.3.tgz", - "integrity": "sha512-AUUH65a0p3Q0Yfm5oD2KVgzTKgwPyp9DSXc3UA7DtxhEb/WSPfbG4wqXeSN62OG5gSo18em4xv6dbfcUGXcagw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -3621,9 +3890,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.3.tgz", - "integrity": "sha512-1makPhFFVBqZE+XFg3Dkq+IkQ7JvmUrwwqaYBL2CE+ZpxPaqkGaiWFEWVGyvTwZace6WLJHwjVh/+CXbKDGPmg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -3635,9 +3904,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.3.tgz", - "integrity": "sha512-OOFJa28dxfl8kLOPMUOQBCO6z3X2SAfzIE276fwT52uXDWUS178KWq0pL7d6p1kz7pkzA0yQwtqL0dEPoVcRWg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -3649,9 +3918,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.3.tgz", - "integrity": "sha512-jMdsML2VI5l+V7cKfZx3ak+SLlJ8fKvLJ0Eoa4b9/vCUrzXKgoKxvHqvJ/mkWhFiyp88nCkM5S2v6nIwRtPcgg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -3663,9 +3932,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.3.tgz", - "integrity": "sha512-tPgGd6bY2M2LJTA1uGq8fkSPK8ZLYjDjY+ZLK9WHncCnfIz29LIXIqUgzCR0hIefzy6Hpbe8Th5WOSwTM8E7LA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -3677,9 +3946,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.3.tgz", - "integrity": "sha512-BCFkJjgk+WFzP+tcSMXq77ymAPIxsX9lFJWs+2JzuZTLtksJ2o5hvgTdIcZ5+oKzUDMwI0PfWzRBYAydAHF2Mw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -3690,10 +3959,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.3.tgz", - "integrity": "sha512-KTD/EqjZF3yvRaWUJdD1cW+IQBk4fbQaHYJUmP8N4XoKFZilVL8cobFSTDnjTtxWJQ3JYaMgF4nObY/+nYkumA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -3705,9 +3988,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.3.tgz", - "integrity": "sha512-+zteHZdoUYLkyYKObGHieibUFLbttX2r+58l27XZauq0tcWYYuKUwY2wjeCN9oK1Um2YgH2ibd6cnX/wFD7DuA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -3719,9 +4002,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.3.tgz", - "integrity": "sha512-of1iHkTQSo3kr6dTIRX6t81uj/c/b15HXVsPcEElN5sS859qHrOepM5p9G41Hah+CTqSh2r8Bm56dL2z9UQQ7g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -3733,9 +4016,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.3.tgz", - "integrity": "sha512-s0hybmlHb56mWVZQj8ra9048/WZTPLILKxcvcq+8awSZmyiSUZjjem1AhU3Tf4ZKpYhK4mg36HtHDOe8QJS5PQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -3747,9 +4030,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.3.tgz", - "integrity": "sha512-zGIbEVVXVtauFgl3MRwGWEN36P5ZGenHRMgNw88X5wEhEBpq0XrMEZwOn07+ICrwM17XO5xfMZqh0OldCH5VTA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -3761,9 +4044,9 @@ ] }, "node_modules/@rollup/wasm-node": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/wasm-node/-/wasm-node-4.57.1.tgz", - "integrity": "sha512-b0rcJH8ykEanfgTeDtlPubhphIUOx0oaAek+3hizTaFkoC1FBSTsY0GixwB4D5HZ5r3Gt2yI9c8M13OcW/kW5A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/wasm-node/-/wasm-node-4.59.0.tgz", + "integrity": "sha512-cKB/Pe05aJWQYw3UFS79Id+KVXdExBxWful0+CSl24z3ukwOgBSy6l39XZNwfm3vCh/fpUrAAs+T7PsJ6dC8NA==", "dev": true, "license": "MIT", "dependencies": { @@ -3788,13 +4071,13 @@ "license": "MIT" }, "node_modules/@schematics/angular": { - "version": "20.3.16", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-20.3.16.tgz", - "integrity": "sha512-KeOcsM5piwv/6tUKBmLD1zXTwtJlZBnR2WM/4T9ImaQbmFGe1MMHUABT5SQ3Bifv1YKCw58ImxiaQUY9sdNqEQ==", + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.1.tgz", + "integrity": "sha512-DjrHRMoILhbZ6tc7aNZWuHA1wCm1iU/JN1TxAwNEyIBgyU3Fx8Z5baK4w0TCpOIPt0RLWVgP2L7kka9aXWCUFA==", "license": "MIT", "dependencies": { - "@angular-devkit/core": "20.3.16", - "@angular-devkit/schematics": "20.3.16", + "@angular-devkit/core": "21.2.1", + "@angular-devkit/schematics": "21.2.1", "jsonc-parser": "3.3.1" }, "engines": { @@ -3854,16 +4137,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@sigstore/sign/node_modules/proc-log": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", - "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", - "devOptional": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/@sigstore/tuf": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-4.0.1.tgz", @@ -3893,6 +4166,12 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@start9labs/argon2": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@start9labs/argon2/-/argon2-0.3.0.tgz", @@ -3903,9 +4182,9 @@ "link": true }, "node_modules/@taiga-ui/addon-charts": { - "version": "4.66.0", - "resolved": "https://registry.npmjs.org/@taiga-ui/addon-charts/-/addon-charts-4.66.0.tgz", - "integrity": "sha512-PKC00Tbuhouz5mEylelhBvy+t4uVXQ6ZEh5YnpZu3KBOdEd2qO3uWZ7/rXLVW/mWvVEbU4PXOpTPfxl6J8ekCA==", + "version": "4.73.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/addon-charts/-/addon-charts-4.73.0.tgz", + "integrity": "sha512-BZhmupFnngsGICumSvaA7b8jf5lprQ5PD5Nqduq6iQ/pa02qv4VdR7HE2OM07kg4pBm4OX3OtJ0H9V0N/buchQ==", "license": "Apache-2.0", "dependencies": { "tslib": ">=2.8.1" @@ -3914,15 +4193,15 @@ "@angular/common": ">=16.0.0", "@angular/core": ">=16.0.0", "@ng-web-apis/common": "^4.14.0", - "@taiga-ui/cdk": "^4.66.0", - "@taiga-ui/core": "^4.66.0", + "@taiga-ui/cdk": "^4.73.0", + "@taiga-ui/core": "^4.73.0", "@taiga-ui/polymorpheus": "^4.9.0" } }, "node_modules/@taiga-ui/addon-commerce": { - "version": "4.66.0", - "resolved": "https://registry.npmjs.org/@taiga-ui/addon-commerce/-/addon-commerce-4.66.0.tgz", - "integrity": "sha512-tRWyuqK5j5nEjlk0x5HaeLArgVpAIJZNeMiPy//95v4/8tlHdQLM4gh3qcvwS70GN5fnlFXINWhnblvxSDv2dw==", + "version": "4.73.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/addon-commerce/-/addon-commerce-4.73.0.tgz", + "integrity": "sha512-jLNGPZgAnqhrLp5jJ2LNaDVuQs7SX1pUitDm4xpt26GnO7GuQmjY2E0SmZtEbBf/9ykROAnPzgWJbaIj6e5V0A==", "license": "Apache-2.0", "peer": true, "dependencies": { @@ -3936,18 +4215,18 @@ "@maskito/core": "^3.11.1", "@maskito/kit": "^3.11.1", "@ng-web-apis/common": "^4.14.0", - "@taiga-ui/cdk": "^4.66.0", - "@taiga-ui/core": "^4.66.0", - "@taiga-ui/i18n": "^4.66.0", - "@taiga-ui/kit": "^4.66.0", + "@taiga-ui/cdk": "^4.73.0", + "@taiga-ui/core": "^4.73.0", + "@taiga-ui/i18n": "^4.73.0", + "@taiga-ui/kit": "^4.73.0", "@taiga-ui/polymorpheus": "^4.9.0", "rxjs": ">=7.0.0" } }, "node_modules/@taiga-ui/addon-mobile": { - "version": "4.66.0", - "resolved": "https://registry.npmjs.org/@taiga-ui/addon-mobile/-/addon-mobile-4.66.0.tgz", - "integrity": "sha512-0Oc5E3h88KwBes3ozKFHcfJsbZeQPjFDAS56HfTSJUbeoSLsnxaWc0mwLbxcel49OOpOEThgrgUDZ3MEs7+yEQ==", + "version": "4.73.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/addon-mobile/-/addon-mobile-4.73.0.tgz", + "integrity": "sha512-5ohDbwkE2ur57eMYmM6Tl7IOvVNxzPiHnZRB5ZcntmDkFp7UBgSOFptelqraL9DP8w5LEmvrshT6ObygRQNe+Q==", "license": "Apache-2.0", "dependencies": { "tslib": ">=2.8.1" @@ -3957,18 +4236,18 @@ "@angular/common": ">=16.0.0", "@angular/core": ">=16.0.0", "@ng-web-apis/common": "^4.14.0", - "@taiga-ui/cdk": "^4.66.0", - "@taiga-ui/core": "^4.66.0", - "@taiga-ui/kit": "^4.66.0", - "@taiga-ui/layout": "^4.66.0", + "@taiga-ui/cdk": "^4.73.0", + "@taiga-ui/core": "^4.73.0", + "@taiga-ui/kit": "^4.73.0", + "@taiga-ui/layout": "^4.73.0", "@taiga-ui/polymorpheus": "^4.9.0", "rxjs": ">=7.0.0" } }, "node_modules/@taiga-ui/addon-table": { - "version": "4.66.0", - "resolved": "https://registry.npmjs.org/@taiga-ui/addon-table/-/addon-table-4.66.0.tgz", - "integrity": "sha512-rm/7kSyQEJIypTMBtqXT9Q9LwdOqIaqzClfl0j3W0/i0F/hzyjKz/ZUPJs8treGqZhDSlC/tk5M3eqVskZB1bQ==", + "version": "4.73.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/addon-table/-/addon-table-4.73.0.tgz", + "integrity": "sha512-fRxnCopvanInTdrVtNNXclY6T5u3fU87De564sngAcV9Y8Q1O44bZCyyDE/+jsoz3IIDZUD+2OEZjOGHMWLFOA==", "license": "Apache-2.0", "dependencies": { "tslib": ">=2.8.1" @@ -3977,18 +4256,18 @@ "@angular/common": ">=16.0.0", "@angular/core": ">=16.0.0", "@ng-web-apis/intersection-observer": "^4.14.0", - "@taiga-ui/cdk": "^4.66.0", - "@taiga-ui/core": "^4.66.0", - "@taiga-ui/i18n": "^4.66.0", - "@taiga-ui/kit": "^4.66.0", + "@taiga-ui/cdk": "^4.73.0", + "@taiga-ui/core": "^4.73.0", + "@taiga-ui/i18n": "^4.73.0", + "@taiga-ui/kit": "^4.73.0", "@taiga-ui/polymorpheus": "^4.9.0", "rxjs": ">=7.0.0" } }, "node_modules/@taiga-ui/cdk": { - "version": "4.66.0", - "resolved": "https://registry.npmjs.org/@taiga-ui/cdk/-/cdk-4.66.0.tgz", - "integrity": "sha512-5DFbwHo7JHKBjgizbGTaIRJsai20+ZknhOQ1SRYwRTc9+6C1HbY/gGC+cjJTLmEQvk14rOoz8qbeWzJx88BU2Q==", + "version": "4.73.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/cdk/-/cdk-4.73.0.tgz", + "integrity": "sha512-bNRr5ORof60KevnMINNhbLbj8U9xgEMNvagzTtrtjVfEvCOJE1/+ufCnWjjkEdT/V0fDnbuhKsu16ngctbOhXQ==", "license": "Apache-2.0", "peer": true, "dependencies": { @@ -3999,7 +4278,7 @@ "@angular-devkit/schematics": ">=16.0.0", "@schematics/angular": ">=16.0.0", "ng-morph": "^4.8.4", - "parse5": "^8.0.0" + "parse5": "^7.3.0" }, "peerDependencies": { "@angular/animations": ">=16.0.0", @@ -4013,14 +4292,41 @@ "@ng-web-apis/resize-observer": "^4.14.0", "@ng-web-apis/screen-orientation": "^4.14.0", "@taiga-ui/event-plugins": "^4.7.0", + "@taiga-ui/font-watcher": "~0.3.0", "@taiga-ui/polymorpheus": "^4.9.0", "rxjs": ">=7.0.0" } }, + "node_modules/@taiga-ui/cdk/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@taiga-ui/cdk/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "optional": true, + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/@taiga-ui/core": { - "version": "4.66.0", - "resolved": "https://registry.npmjs.org/@taiga-ui/core/-/core-4.66.0.tgz", - "integrity": "sha512-AjjH+xhgonjf9Xnx3SHNrP5VbsS9jdtGB3BCTQbicYd6QuujQBKldK0fnYMjCY3L0+lboI2OPCVg9PTliOdJ8A==", + "version": "4.73.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/core/-/core-4.73.0.tgz", + "integrity": "sha512-8EyHWgenkWxdfRP1LtqkE/bV/9a3mK8MtVmFxauEjcbeyafnE6MJ7K2KgD+iU9TZsOdH7nuWiA44Sv14t1wcUA==", "license": "Apache-2.0", "peer": true, "dependencies": { @@ -4035,9 +4341,9 @@ "@angular/router": ">=16.0.0", "@ng-web-apis/common": "^4.14.0", "@ng-web-apis/mutation-observer": "^4.14.0", - "@taiga-ui/cdk": "^4.66.0", + "@taiga-ui/cdk": "^4.73.0", "@taiga-ui/event-plugins": "^4.7.0", - "@taiga-ui/i18n": "^4.66.0", + "@taiga-ui/i18n": "^4.73.0", "@taiga-ui/polymorpheus": "^4.9.0", "rxjs": ">=7.0.0" } @@ -4073,9 +4379,9 @@ } }, "node_modules/@taiga-ui/experimental": { - "version": "4.66.0", - "resolved": "https://registry.npmjs.org/@taiga-ui/experimental/-/experimental-4.66.0.tgz", - "integrity": "sha512-H942VRcWBp2XtHD3KBqEvBQRapRBAZ16lGUqAilAc1vfHjqj3oTHZnFehv0o/2MFTWGb+UfR9F2RPTRFv54nHA==", + "version": "4.73.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/experimental/-/experimental-4.73.0.tgz", + "integrity": "sha512-OFRO4rhWY780gf/iMip7sJJ1BBT2rWG5663Qqu/+6SAF4pyzcgKq5uIAkULQYFBQwqBNyB0zAx3fZAqdQ8m34A==", "license": "Apache-2.0", "dependencies": { "tslib": ">=2.8.1" @@ -4083,19 +4389,26 @@ "peerDependencies": { "@angular/common": ">=16.0.0", "@angular/core": ">=16.0.0", - "@taiga-ui/addon-commerce": "^4.66.0", - "@taiga-ui/cdk": "^4.66.0", - "@taiga-ui/core": "^4.66.0", - "@taiga-ui/kit": "^4.66.0", - "@taiga-ui/layout": "^4.66.0", + "@taiga-ui/addon-commerce": "^4.73.0", + "@taiga-ui/cdk": "^4.73.0", + "@taiga-ui/core": "^4.73.0", + "@taiga-ui/kit": "^4.73.0", + "@taiga-ui/layout": "^4.73.0", "@taiga-ui/polymorpheus": "^4.9.0", "rxjs": ">=7.0.0" } }, + "node_modules/@taiga-ui/font-watcher": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/font-watcher/-/font-watcher-0.3.0.tgz", + "integrity": "sha512-ldI8XMvpVQEfxtcCzbLKs02QbqyB+qJKHIV/x19Q5mxs+kqrS3Pzm3j4mt8tPnuWhgi4+PHvAk3QLN9zmwcoJg==", + "license": "Apache-2.0", + "peer": true + }, "node_modules/@taiga-ui/i18n": { - "version": "4.70.0", - "resolved": "https://registry.npmjs.org/@taiga-ui/i18n/-/i18n-4.70.0.tgz", - "integrity": "sha512-9G8Kp+2LvD8vepPOjAHvU9cZ7aoqp2JqkQRFQOGqv7E9y25bU7PPMx9t/sbNNmzdXodv0g/zjMsimghkrldk3Q==", + "version": "4.73.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/i18n/-/i18n-4.73.0.tgz", + "integrity": "sha512-nU7pXeH3+mwQdWZCnXILDAZhRcQ4L/Gl+Y/G/yFpvkEfoCljhVsY0HZZgMSAEGfOSeS7R0ZhZX+9be6uHUvn4w==", "license": "Apache-2.0", "peer": true, "dependencies": { @@ -4108,18 +4421,18 @@ } }, "node_modules/@taiga-ui/icons": { - "version": "4.66.0", - "resolved": "https://registry.npmjs.org/@taiga-ui/icons/-/icons-4.66.0.tgz", - "integrity": "sha512-tEOPNy7zdw32q6oPVwN7dZONif1qQOrICVGuRpf6gsN91gLNdEdpoh+6X6rl17CR/qFxSfurE9GwhiKAogD9rw==", + "version": "4.73.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/icons/-/icons-4.73.0.tgz", + "integrity": "sha512-Ae8qTTf18OlAyFgv04aDkQ6T81J0c8NtFtGWMbNnNc2/CtVt2s0e7z6dcAvs0LODNidzXU6jn/OQT4w4PeAe+w==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.3.0" } }, "node_modules/@taiga-ui/kit": { - "version": "4.66.0", - "resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-4.66.0.tgz", - "integrity": "sha512-uqY3wslMs7KiBceaHPwCyWVrP8IPqb3OgAy1zd5DHosoUj/ciUl4JWVdx+QdsDypV/Cs4EZrqcIUtMDKQ/Zk0g==", + "version": "4.73.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-4.73.0.tgz", + "integrity": "sha512-8dypi4hZLi0GEFuraQERLNyKbR56BqQjU7c6p9x7X7iiMt4LpHUVH9+kd+e9irGWMr09SWLx4PFNV+q+T1c8TQ==", "license": "Apache-2.0", "peer": true, "dependencies": { @@ -4138,17 +4451,17 @@ "@ng-web-apis/intersection-observer": "^4.14.0", "@ng-web-apis/mutation-observer": "^4.14.0", "@ng-web-apis/resize-observer": "^4.14.0", - "@taiga-ui/cdk": "^4.66.0", - "@taiga-ui/core": "^4.66.0", - "@taiga-ui/i18n": "^4.66.0", + "@taiga-ui/cdk": "^4.73.0", + "@taiga-ui/core": "^4.73.0", + "@taiga-ui/i18n": "^4.73.0", "@taiga-ui/polymorpheus": "^4.9.0", "rxjs": ">=7.0.0" } }, "node_modules/@taiga-ui/layout": { - "version": "4.66.0", - "resolved": "https://registry.npmjs.org/@taiga-ui/layout/-/layout-4.66.0.tgz", - "integrity": "sha512-D6REwySoaPGZlkdqTfrWahMqziXOY7GGTm1pXWVYDi5kEcSP9+F8ojo6saHDlwhN+V4/2jlMrkseSPlfXbmngQ==", + "version": "4.73.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/layout/-/layout-4.73.0.tgz", + "integrity": "sha512-JX2DRCdGw3ayvW07RtI7MjrceO7m7/0wj2RfkjT2Pa93elWooS85lDVy88tlVfcfm/bV2ZGwbemyecAwNjk/yQ==", "license": "Apache-2.0", "peer": true, "dependencies": { @@ -4157,9 +4470,9 @@ "peerDependencies": { "@angular/common": ">=16.0.0", "@angular/core": ">=16.0.0", - "@taiga-ui/cdk": "^4.66.0", - "@taiga-ui/core": "^4.66.0", - "@taiga-ui/kit": "^4.66.0", + "@taiga-ui/cdk": "^4.73.0", + "@taiga-ui/core": "^4.73.0", + "@taiga-ui/kit": "^4.73.0", "@taiga-ui/polymorpheus": "^4.9.0", "rxjs": ">=7.0.0" } @@ -4192,13 +4505,13 @@ } }, "node_modules/@ts-morph/common/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "license": "ISC", "optional": true, "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -4260,47 +4573,55 @@ } }, "node_modules/@tufjs/models/node_modules/balanced-match": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz", - "integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "devOptional": true, "license": "MIT", - "dependencies": { - "jackspeak": "^4.2.3" - }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" } }, "node_modules/@tufjs/models/node_modules/brace-expansion": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", - "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "devOptional": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" } }, "node_modules/@tufjs/models/node_modules/minimatch": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz", - "integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "devOptional": true, "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/dompurify": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", @@ -4404,9 +4725,9 @@ "license": "MIT" }, "node_modules/@vitejs/plugin-basic-ssl": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.0.tgz", - "integrity": "sha512-dOxxrhgyDIEUADhb/8OlV9JIqYLgos03YorAueTIeOUskLJSEsfwCByjbu98ctXitUN3znXKp0bYD/WHSudCeA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.4.tgz", + "integrity": "sha512-HXciTXN/sDBYWgeAD4V4s0DN0g72x5mlxQhHxtYu3Tt8BLa6MzcJZUyDVFCdtjNs3bfENVHVzOsmooTVuNgAAw==", "dev": true, "license": "MIT", "engines": { @@ -4484,9 +4805,9 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -4517,26 +4838,26 @@ } }, "node_modules/algoliasearch": { - "version": "5.35.0", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.35.0.tgz", - "integrity": "sha512-Y+moNhsqgLmvJdgTsO4GZNgsaDWv8AOGAaPeIeHKlDn/XunoAqYbA+XNpBd1dW8GOXAUDyxC9Rxc7AV4kpFcIg==", + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.48.1.tgz", + "integrity": "sha512-Rf7xmeuIo7nb6S4mp4abW2faW8DauZyE2faBIKFaUfP3wnpOvNSbiI5AwVhqBNj0jPgBWEvhyCu0sLjN2q77Rg==", "devOptional": true, "license": "MIT", "dependencies": { - "@algolia/abtesting": "1.1.0", - "@algolia/client-abtesting": "5.35.0", - "@algolia/client-analytics": "5.35.0", - "@algolia/client-common": "5.35.0", - "@algolia/client-insights": "5.35.0", - "@algolia/client-personalization": "5.35.0", - "@algolia/client-query-suggestions": "5.35.0", - "@algolia/client-search": "5.35.0", - "@algolia/ingestion": "1.35.0", - "@algolia/monitoring": "1.35.0", - "@algolia/recommend": "5.35.0", - "@algolia/requester-browser-xhr": "5.35.0", - "@algolia/requester-fetch": "5.35.0", - "@algolia/requester-node-http": "5.35.0" + "@algolia/abtesting": "1.14.1", + "@algolia/client-abtesting": "5.48.1", + "@algolia/client-analytics": "5.48.1", + "@algolia/client-common": "5.48.1", + "@algolia/client-insights": "5.48.1", + "@algolia/client-personalization": "5.48.1", + "@algolia/client-query-suggestions": "5.48.1", + "@algolia/client-search": "5.48.1", + "@algolia/ingestion": "1.48.1", + "@algolia/monitoring": "1.48.1", + "@algolia/recommend": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" }, "engines": { "node": ">= 14.0.0" @@ -4733,9 +5054,9 @@ } }, "node_modules/beasties": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/beasties/-/beasties-0.3.5.tgz", - "integrity": "sha512-NaWu+f4YrJxEttJSm16AzMIFtVldCvaJ68b1L098KpqXmxt9xOLtKoLkKxb8ekhOrLqEJAbvT6n6SEvB/sac7A==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/beasties/-/beasties-0.4.1.tgz", + "integrity": "sha512-2Imdcw3LznDuxAbJM26RHniOLAzE6WgrK8OuvVXCQtNBS8rsnD9zsSEa3fHl4hHpUY7BYTlrpvtPVbvu9G6neg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -4746,10 +5067,11 @@ "htmlparser2": "^10.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.49", - "postcss-media-query-parser": "^0.2.3" + "postcss-media-query-parser": "^0.2.3", + "postcss-safe-parser": "^7.0.1" }, "engines": { - "node": ">=14.0.0" + "node": ">=18.0.0" } }, "node_modules/body-parser": { @@ -5065,17 +5387,17 @@ "license": "MIT" }, "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", "devOptional": true, "license": "MIT", "peer": true, "dependencies": { - "readdirp": "^4.0.1" + "readdirp": "^5.0.0" }, "engines": { - "node": ">= 14.16.0" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -5148,26 +5470,42 @@ } }, "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", + "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", "license": "MIT", "engines": { - "node": ">=6" + "node": ">=18.20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/cli-truncate": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", - "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", "devOptional": true, "license": "MIT", "dependencies": { - "slice-ansi": "^5.0.0", - "string-width": "^7.0.0" + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" }, "engines": { "node": ">=18" @@ -5176,6 +5514,40 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-truncate/node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", + "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -5777,6 +6149,7 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "devOptional": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -5789,31 +6162,6 @@ "node": ">= 0.8" } }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/entities": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", @@ -5914,9 +6262,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", - "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -5927,32 +6275,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.9", - "@esbuild/android-arm": "0.25.9", - "@esbuild/android-arm64": "0.25.9", - "@esbuild/android-x64": "0.25.9", - "@esbuild/darwin-arm64": "0.25.9", - "@esbuild/darwin-x64": "0.25.9", - "@esbuild/freebsd-arm64": "0.25.9", - "@esbuild/freebsd-x64": "0.25.9", - "@esbuild/linux-arm": "0.25.9", - "@esbuild/linux-arm64": "0.25.9", - "@esbuild/linux-ia32": "0.25.9", - "@esbuild/linux-loong64": "0.25.9", - "@esbuild/linux-mips64el": "0.25.9", - "@esbuild/linux-ppc64": "0.25.9", - "@esbuild/linux-riscv64": "0.25.9", - "@esbuild/linux-s390x": "0.25.9", - "@esbuild/linux-x64": "0.25.9", - "@esbuild/netbsd-arm64": "0.25.9", - "@esbuild/netbsd-x64": "0.25.9", - "@esbuild/openbsd-arm64": "0.25.9", - "@esbuild/openbsd-x64": "0.25.9", - "@esbuild/openharmony-arm64": "0.25.9", - "@esbuild/sunos-x64": "0.25.9", - "@esbuild/win32-arm64": "0.25.9", - "@esbuild/win32-ia32": "0.25.9", - "@esbuild/win32-x64": "0.25.9" + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" } }, "node_modules/escalade": { @@ -6127,13 +6475,13 @@ } }, "node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.0.tgz", + "integrity": "sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q==", "devOptional": true, "license": "MIT", "dependencies": { - "ip-address": "10.0.1" + "ip-address": "10.1.0" }, "engines": { "node": ">= 16" @@ -6488,9 +6836,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", "license": "MIT", "engines": { "node": ">=18" @@ -6614,16 +6962,16 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz", - "integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "devOptional": true, "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -6755,9 +7103,9 @@ } }, "node_modules/hono": { - "version": "4.11.9", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", - "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz", + "integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==", "devOptional": true, "license": "MIT", "peer": true, @@ -7104,42 +7452,39 @@ } }, "node_modules/ignore-walk/node_modules/balanced-match": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz", - "integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "devOptional": true, "license": "MIT", - "dependencies": { - "jackspeak": "^4.2.3" - }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" } }, "node_modules/ignore-walk/node_modules/brace-expansion": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", - "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "devOptional": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" } }, "node_modules/ignore-walk/node_modules/minimatch": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz", - "integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "devOptional": true, "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -7160,9 +7505,9 @@ } }, "node_modules/immutable": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", - "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", "dev": true, "license": "MIT" }, @@ -7212,13 +7557,13 @@ "license": "ISC" }, "node_modules/ini": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", - "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", "devOptional": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/injection-js": { @@ -7258,62 +7603,10 @@ } } }, - "node_modules/inquirer/node_modules/@inquirer/confirm": { - "version": "5.1.21", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", - "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/inquirer/node_modules/@inquirer/prompts": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", - "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/checkbox": "^4.3.2", - "@inquirer/confirm": "^5.1.21", - "@inquirer/editor": "^4.2.23", - "@inquirer/expand": "^4.0.23", - "@inquirer/input": "^4.3.1", - "@inquirer/number": "^3.0.23", - "@inquirer/password": "^4.0.23", - "@inquirer/rawlist": "^4.1.11", - "@inquirer/search": "^3.2.2", - "@inquirer/select": "^4.4.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "devOptional": true, "license": "MIT", "engines": { @@ -7353,7 +7646,7 @@ "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -7395,7 +7688,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -8037,14 +8330,14 @@ } }, "node_modules/listr2": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.1.tgz", - "integrity": "sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", "devOptional": true, "license": "MIT", "peer": true, "dependencies": { - "cli-truncate": "^4.0.0", + "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", @@ -8081,14 +8374,15 @@ } }, "node_modules/lmdb": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.4.2.tgz", - "integrity": "sha512-nwVGUfTBUwJKXd6lRV8pFNfnrCC1+l49ESJRM19t/tFb/97QfJEixe5DYRvug5JO7DSFKoKaVy7oGMt5rVqZvg==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.5.1.tgz", + "integrity": "sha512-NYHA0MRPjvNX+vSw8Xxg6FLKxzAG+e7Pt8RqAQA/EehzHVXq9SxDqJIN3JL1hK0dweb884y8kIh6rkWvPyg9Wg==", "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, "dependencies": { + "@harperfast/extended-iterable": "^1.0.3", "msgpackr": "^1.11.2", "node-addon-api": "^6.1.0", "node-gyp-build-optional-packages": "5.2.2", @@ -8099,13 +8393,13 @@ "download-lmdb-prebuilds": "bin/download-prebuilds.js" }, "optionalDependencies": { - "@lmdb/lmdb-darwin-arm64": "3.4.2", - "@lmdb/lmdb-darwin-x64": "3.4.2", - "@lmdb/lmdb-linux-arm": "3.4.2", - "@lmdb/lmdb-linux-arm64": "3.4.2", - "@lmdb/lmdb-linux-x64": "3.4.2", - "@lmdb/lmdb-win32-arm64": "3.4.2", - "@lmdb/lmdb-win32-x64": "3.4.2" + "@lmdb/lmdb-darwin-arm64": "3.5.1", + "@lmdb/lmdb-darwin-x64": "3.5.1", + "@lmdb/lmdb-linux-arm": "3.5.1", + "@lmdb/lmdb-linux-arm64": "3.5.1", + "@lmdb/lmdb-linux-x64": "3.5.1", + "@lmdb/lmdb-win32-arm64": "3.5.1", + "@lmdb/lmdb-win32-x64": "3.5.1" } }, "node_modules/locate-path": { @@ -8131,13 +8425,13 @@ "license": "MIT" }, "node_modules/log-symbols": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", - "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", + "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", "license": "MIT", "dependencies": { - "chalk": "^5.3.0", - "is-unicode-supported": "^1.3.0" + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" }, "engines": { "node": ">=18" @@ -8146,18 +8440,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/log-symbols/node_modules/is-unicode-supported": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", - "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/log-update": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", @@ -8255,12 +8537,12 @@ } }, "node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/make-dir": { @@ -8297,12 +8579,13 @@ "license": "ISC" }, "node_modules/make-fetch-happen": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.3.tgz", - "integrity": "sha512-iyyEpDty1mwW3dGlYXAJqC/azFn5PPvgKVwXayOGBSmKLxhKZ9fg4qIan2ePpp1vJIwfFiO34LAPZgq9SZW9Aw==", + "version": "15.0.4", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.4.tgz", + "integrity": "sha512-vM2sG+wbVeVGYcCm16mM3d5fuem9oC28n436HjsGO3LcxoTI8LNVa4rwZDn3f76+cWyT4GGJDxjTYU1I2nr6zw==", "devOptional": true, "license": "ISC", "dependencies": { + "@gar/promise-retry": "^1.0.0", "@npmcli/agent": "^4.0.0", "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", @@ -8312,23 +8595,12 @@ "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^6.0.0", - "promise-retry": "^2.0.1", "ssri": "^13.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/make-fetch-happen/node_modules/proc-log": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", - "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", - "devOptional": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/marked": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", @@ -8545,9 +8817,9 @@ } }, "node_modules/minipass-fetch": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.1.tgz", - "integrity": "sha512-yHK8pb0iCGat0lDrs/D6RZmCdaBT64tULXjdxjSMAqoDi18Q3qKEUTHypHQZQd9+FYpIS+lkvpq6C/R6SbUeRw==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.2.tgz", + "integrity": "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -8559,7 +8831,7 @@ "node": "^20.17.0 || >=22.9.0" }, "optionalDependencies": { - "encoding": "^0.1.13" + "iconv-lite": "^0.7.2" } }, "node_modules/minipass-flush": { @@ -8759,9 +9031,9 @@ } }, "node_modules/multimatch/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "optional": true, "dependencies": { @@ -8870,9 +9142,9 @@ } }, "node_modules/ng-packagr": { - "version": "20.3.2", - "resolved": "https://registry.npmjs.org/ng-packagr/-/ng-packagr-20.3.2.tgz", - "integrity": "sha512-yW5ME0hqTz38r/th/7zVwX5oSIw1FviSA2PUlGZdVjghDme/KX6iiwmOBmlt9E9whNmwijEC6Gn3KKbrsBx8ig==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/ng-packagr/-/ng-packagr-21.2.0.tgz", + "integrity": "sha512-ASlXEboqt+ZgKzNPx3YCr924xqQRFA5qgm77GHf0Fm13hx7gVFYVm6WCdYZyeX/p9NJjFWAL+mIMfhsx2SHKoA==", "dev": true, "license": "MIT", "peer": true, @@ -8882,16 +9154,16 @@ "@rollup/wasm-node": "^4.24.0", "ajv": "^8.17.1", "ansi-colors": "^4.1.3", - "browserslist": "^4.22.1", - "chokidar": "^4.0.1", + "browserslist": "^4.26.0", + "chokidar": "^5.0.0", "commander": "^14.0.0", "dependency-graph": "^1.0.0", - "esbuild": "^0.25.0", + "esbuild": "^0.27.0", "find-cache-directory": "^6.0.0", "injection-js": "^2.4.0", "jsonc-parser": "^3.3.1", "less": "^4.2.0", - "ora": "^8.2.0", + "ora": "^9.0.0", "piscina": "^5.0.0", "postcss": "^8.4.47", "rollup-plugin-dts": "^6.2.0", @@ -8909,10 +9181,10 @@ "rollup": "^4.24.0" }, "peerDependencies": { - "@angular/compiler-cli": "^20.0.0", + "@angular/compiler-cli": "^21.0.0 || ^21.2.0-next", "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", "tslib": "^2.3.0", - "typescript": ">=5.8 <6.0" + "typescript": ">=5.9 <6.0" }, "peerDependenciesMeta": { "tailwindcss": { @@ -8931,17 +9203,17 @@ } }, "node_modules/ng-qrcode": { - "version": "20.0.1", - "resolved": "https://registry.npmjs.org/ng-qrcode/-/ng-qrcode-20.0.1.tgz", - "integrity": "sha512-XKaPKbqaSXK5xPJYBO9gry88wGs6QeL1mK1dOkJaTutfrjDan9QbD47vFpHLxCCMkU0o5fIrbXqxm9h3Fi2PEA==", + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/ng-qrcode/-/ng-qrcode-21.0.0.tgz", + "integrity": "sha512-dtjzHsJXe/StMixj6FckeOw5hnjvAfr0VRSj55uDRyBJhdBMKtE9lvfPSYYOrMO4hWs6fInr1/XhFQyR2dVllA==", "license": "MIT", "dependencies": { "qrcode": "^1.5.3", "tslib": "^2.6.2" }, "peerDependencies": { - "@angular/common": ">=20 <21", - "@angular/core": ">=20 <21" + "@angular/common": ">=21 <22", + "@angular/core": ">=21 <22" } }, "node_modules/node-addon-api": { @@ -9012,16 +9284,6 @@ "node": ">=20" } }, - "node_modules/node-gyp/node_modules/proc-log": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", - "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", - "devOptional": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/node-gyp/node_modules/which": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", @@ -9215,25 +9477,25 @@ } }, "node_modules/npm-package-arg": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-13.0.0.tgz", - "integrity": "sha512-+t2etZAGcB7TbbLHfDwooV9ppB2LhhcT6A+L9cahsf9mEUAoQ6CktLEVvEnpD0N5CkX7zJqnPGaFtoQDy9EkHQ==", + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-13.0.2.tgz", + "integrity": "sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA==", "devOptional": true, "license": "ISC", "dependencies": { "hosted-git-info": "^9.0.0", - "proc-log": "^5.0.0", + "proc-log": "^6.0.0", "semver": "^7.3.5", - "validate-npm-package-name": "^6.0.0" + "validate-npm-package-name": "^7.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm-packlist": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-10.0.3.tgz", - "integrity": "sha512-zPukTwJMOu5X5uvm0fztwS5Zxyvmk38H/LfidkOMt3gbZVCyro2cD/ETzwzVPcWZA3JOyPznfUN/nkyFiyUbxg==", + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-10.0.4.tgz", + "integrity": "sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng==", "devOptional": true, "license": "ISC", "dependencies": { @@ -9244,16 +9506,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm-packlist/node_modules/proc-log": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", - "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", - "devOptional": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/npm-pick-manifest": { "version": "11.0.3", "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-11.0.3.tgz", @@ -9290,16 +9542,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm-registry-fetch/node_modules/proc-log": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", - "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", - "devOptional": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/npm-run-path": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", @@ -9453,23 +9695,38 @@ } }, "node_modules/ora": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", - "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.3.0.tgz", + "integrity": "sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==", "license": "MIT", "dependencies": { - "chalk": "^5.3.0", + "chalk": "^5.6.2", "cli-cursor": "^5.0.0", - "cli-spinners": "^2.9.2", + "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", - "is-unicode-supported": "^2.0.0", - "log-symbols": "^6.0.0", - "stdin-discarder": "^0.2.2", - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0" + "is-unicode-supported": "^2.1.0", + "log-symbols": "^7.0.1", + "stdin-discarder": "^0.3.1", + "string-width": "^8.1.0" }, "engines": { - "node": ">=18" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/string-width": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", + "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -9545,9 +9802,9 @@ "license": "BlueOak-1.0.0" }, "node_modules/pacote": { - "version": "21.0.4", - "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.0.4.tgz", - "integrity": "sha512-RplP/pDW0NNNDh3pnaoIWYPvNenS7UqMbXyvMqJczosiFWTeGGwJC2NQBLqKf4rGLFfwCOnntw1aEp9Jiqm1MA==", + "version": "21.3.1", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.3.1.tgz", + "integrity": "sha512-O0EDXi85LF4AzdjG74GUwEArhdvawi/YOHcsW6IijKNj7wm8IvEWNF5GnfuxNpQ/ZpO3L37+v8hqdVh8GgWYhg==", "devOptional": true, "license": "ISC", "dependencies": { @@ -9576,16 +9833,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/pacote/node_modules/proc-log": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", - "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", - "devOptional": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/pako": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", @@ -9757,7 +10004,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/path-scurry": { @@ -9889,9 +10136,9 @@ } }, "node_modules/piscina": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/piscina/-/piscina-5.1.3.tgz", - "integrity": "sha512-0u3N7H4+hbr40KjuVn2uNhOcthu/9usKhnw5vT3J7ply79v3D3M8naI00el9Klcy16x557VsEkkUQaHCWFXC/g==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/piscina/-/piscina-5.1.4.tgz", + "integrity": "sha512-7uU4ZnKeQq22t9AsmHGD2w4OYQGonwFnTypDypaWi7Qr2EvQIFVtG8J5D/3bE7W123Wdc9+v4CZDu5hJXVCtBg==", "dev": true, "license": "MIT", "engines": { @@ -9986,6 +10233,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10002,6 +10250,33 @@ "dev": true, "license": "MIT" }, + "node_modules/postcss-safe-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", + "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, "node_modules/prettier": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", @@ -10019,13 +10294,13 @@ } }, "node_modules/proc-log": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", - "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", "devOptional": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/process": { @@ -10057,6 +10332,16 @@ "node": ">=10" } }, + "node_modules/promise-retry/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -10329,13 +10614,13 @@ } }, "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", "devOptional": true, "license": "MIT", "engines": { - "node": ">= 14.18.0" + "node": ">= 20.19.0" }, "funding": { "type": "individual", @@ -10384,7 +10669,7 @@ "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "is-core-module": "^2.16.0", @@ -10443,9 +10728,9 @@ } }, "node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", "devOptional": true, "license": "MIT", "engines": { @@ -10503,10 +10788,42 @@ "node": ">= 0.8" } }, + "node_modules/rolldown": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.4.tgz", + "integrity": "sha512-V2tPDUrY3WSevrvU2E41ijZlpF+5PbZu4giH+VpNraaadsJGHa4fR6IFwsocVwEXDoAdIv5qgPPxgrvKAOIPtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.113.0", + "@rolldown/pluginutils": "1.0.0-rc.4" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.4", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.4", + "@rolldown/binding-darwin-x64": "1.0.0-rc.4", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.4", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.4", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.4", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.4", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.4", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.4", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.4", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.4", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.4" + } + }, "node_modules/rollup": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz", - "integrity": "sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "peer": true, @@ -10521,28 +10838,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.52.3", - "@rollup/rollup-android-arm64": "4.52.3", - "@rollup/rollup-darwin-arm64": "4.52.3", - "@rollup/rollup-darwin-x64": "4.52.3", - "@rollup/rollup-freebsd-arm64": "4.52.3", - "@rollup/rollup-freebsd-x64": "4.52.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.52.3", - "@rollup/rollup-linux-arm-musleabihf": "4.52.3", - "@rollup/rollup-linux-arm64-gnu": "4.52.3", - "@rollup/rollup-linux-arm64-musl": "4.52.3", - "@rollup/rollup-linux-loong64-gnu": "4.52.3", - "@rollup/rollup-linux-ppc64-gnu": "4.52.3", - "@rollup/rollup-linux-riscv64-gnu": "4.52.3", - "@rollup/rollup-linux-riscv64-musl": "4.52.3", - "@rollup/rollup-linux-s390x-gnu": "4.52.3", - "@rollup/rollup-linux-x64-gnu": "4.52.3", - "@rollup/rollup-linux-x64-musl": "4.52.3", - "@rollup/rollup-openharmony-arm64": "4.52.3", - "@rollup/rollup-win32-arm64-msvc": "4.52.3", - "@rollup/rollup-win32-ia32-msvc": "4.52.3", - "@rollup/rollup-win32-x64-gnu": "4.52.3", - "@rollup/rollup-win32-x64-msvc": "4.52.3", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, @@ -10569,16 +10889,6 @@ "typescript": "^4.5 || ^5.0" } }, - "node_modules/rollup-plugin-dts/node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, "node_modules/rollup/node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -10674,9 +10984,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.90.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.90.0.tgz", - "integrity": "sha512-9GUyuksjw70uNpb1MTYWsH9MQHOHY6kwfnkafC24+7aOMZn9+rVMBxRbLvw756mrBFbIsFg6Xw9IkR2Fnn3k+Q==", + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.3.tgz", + "integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==", "dev": true, "license": "MIT", "peer": true, @@ -10695,10 +11005,40 @@ "@parcel/watcher": "^2.4.1" } }, + "node_modules/sass/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/sass/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/sax": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", - "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz", + "integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==", "dev": true, "license": "BlueOak-1.0.0", "optional": true, @@ -10714,9 +11054,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "devOptional": true, "license": "ISC", "bin": { @@ -11006,7 +11346,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.0.0", @@ -11100,17 +11440,6 @@ "node": ">=0.10.0" } }, - "node_modules/spdx-correct": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", - "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, "node_modules/spdx-exceptions": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", @@ -11119,9 +11448,9 @@ "license": "CC-BY-3.0" }, "node_modules/spdx-expression-parse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -11130,9 +11459,9 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.22", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.22.tgz", - "integrity": "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==", + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", "devOptional": true, "license": "CC0-1.0" }, @@ -11167,9 +11496,9 @@ } }, "node_modules/stdin-discarder": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", - "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.1.tgz", + "integrity": "sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==", "license": "MIT", "engines": { "node": ">=18" @@ -11201,6 +11530,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "devOptional": true, "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -11259,7 +11589,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -11269,9 +11599,9 @@ } }, "node_modules/tar": { - "version": "7.5.7", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", - "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.10.tgz", + "integrity": "sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw==", "devOptional": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -11296,14 +11626,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "devOptional": true, "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -11607,9 +11937,9 @@ } }, "node_modules/tslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -11754,6 +12084,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -11869,25 +12209,14 @@ "dev": true, "license": "MIT" }, - "node_modules/validate-npm-package-license": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, "node_modules/validate-npm-package-name": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-6.0.2.tgz", - "integrity": "sha512-IUoow1YUtvoBBC06dXs8bR8B9vuA3aJfmQNKMoaPG/OFsPmoQvw8xh+6Ye25Gx9DQhoEom3Pcu9MKHerm/NpUQ==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", + "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", "devOptional": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/vary": { @@ -11901,14 +12230,14 @@ } }, "node_modules/vite": { - "version": "7.1.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", - "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "esbuild": "^0.25.0", + "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -11976,27 +12305,10 @@ } } }, - "node_modules/vite/node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, "node_modules/watchpack": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", - "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", "dev": true, "license": "MIT", "dependencies": { @@ -12265,6 +12577,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yoctocolors-cjs": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", @@ -12279,9 +12603,9 @@ } }, "node_modules/zod": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", - "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "devOptional": true, "license": "MIT", "peer": true, diff --git a/web/package.json b/web/package.json index 9d5818382..3b22c4817 100644 --- a/web/package.json +++ b/web/package.json @@ -33,34 +33,33 @@ "format:check": "prettier --check projects/" }, "dependencies": { - "@angular/animations": "^20.3.0", - "@angular/cdk": "^20.1.0", - "@angular/common": "^20.3.0", - "@angular/compiler": "^20.3.0", - "@angular/core": "^20.3.0", - "@angular/forms": "^20.3.0", - "@angular/platform-browser": "^20.3.0", - "@angular/platform-browser-dynamic": "^20.1.0", - "@angular/pwa": "^20.3.0", - "@angular/router": "^20.3.0", - "@angular/service-worker": "^20.3.0", + "@angular/animations": "^21.2.1", + "@angular/cdk": "^21.2.1", + "@angular/common": "^21.2.1", + "@angular/compiler": "^21.2.1", + "@angular/core": "^21.2.1", + "@angular/forms": "^21.2.1", + "@angular/platform-browser": "^21.2.1", + "@angular/pwa": "^21.2.1", + "@angular/router": "^21.2.1", + "@angular/service-worker": "^21.2.1", "@materia-ui/ngx-monaco-editor": "^6.0.0", "@noble/curves": "^1.4.0", "@noble/hashes": "^1.4.0", "@start9labs/argon2": "^0.3.0", "@start9labs/start-sdk": "file:../sdk/baseDist", - "@taiga-ui/addon-charts": "4.66.0", - "@taiga-ui/addon-commerce": "4.66.0", - "@taiga-ui/addon-mobile": "4.66.0", - "@taiga-ui/addon-table": "4.66.0", - "@taiga-ui/cdk": "4.66.0", - "@taiga-ui/core": "4.66.0", + "@taiga-ui/addon-charts": "4.73.0", + "@taiga-ui/addon-commerce": "4.73.0", + "@taiga-ui/addon-mobile": "4.73.0", + "@taiga-ui/addon-table": "4.73.0", + "@taiga-ui/cdk": "4.73.0", + "@taiga-ui/core": "4.73.0", "@taiga-ui/dompurify": "4.1.11", "@taiga-ui/event-plugins": "4.7.0", - "@taiga-ui/experimental": "4.66.0", - "@taiga-ui/icons": "4.66.0", - "@taiga-ui/kit": "4.66.0", - "@taiga-ui/layout": "4.66.0", + "@taiga-ui/experimental": "4.73.0", + "@taiga-ui/icons": "4.73.0", + "@taiga-ui/kit": "4.73.0", + "@taiga-ui/layout": "4.73.0", "@taiga-ui/polymorpheus": "4.9.0", "ansi-to-html": "^0.7.2", "base64-js": "^1.5.1", @@ -80,7 +79,7 @@ "mime": "^4.0.3", "monaco-editor": "^0.33.0", "mustache": "^4.2.0", - "ng-qrcode": "^20.0.0", + "ng-qrcode": "^21.0.0", "node-jose": "^2.2.0", "patch-db-client": "file:../patch-db/client", "pbkdf2": "^3.1.2", @@ -92,10 +91,10 @@ }, "devDependencies": { "@angular-experts/hawkeye": "^1.7.2", - "@angular/build": "^20.1.0", - "@angular/cli": "^20.1.0", - "@angular/compiler-cli": "^20.1.0", - "@angular/language-service": "^20.1.0", + "@angular/build": "^21.2.1", + "@angular/cli": "^21.2.1", + "@angular/compiler-cli": "^21.2.1", + "@angular/language-service": "^21.2.1", "@types/dompurify": "3.0.5", "@types/estree": "^0.0.51", "@types/js-yaml": "^4.0.5", @@ -107,7 +106,7 @@ "@types/uuid": "^8.3.1", "husky": "^4.3.8", "lint-staged": "^13.2.0", - "ng-packagr": "^20.1.0", + "ng-packagr": "^21.2.0", "node-html-parser": "^5.3.3", "postcss": "^8.4.21", "prettier": "^3.5.3", diff --git a/web/projects/marketplace/src/components/menu/menu.component.module.ts b/web/projects/marketplace/src/components/menu/menu.component.module.ts deleted file mode 100644 index 7a7fe81c3..000000000 --- a/web/projects/marketplace/src/components/menu/menu.component.module.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { CommonModule } from '@angular/common' -import { NgModule } from '@angular/core' -import { - DocsLinkDirective, - i18nPipe, - SharedPipesModule, -} from '@start9labs/shared' -import { - TuiAppearance, - TuiButton, - TuiIcon, - TuiLoader, - TuiPopup, -} from '@taiga-ui/core' -import { TuiDrawer, TuiSkeleton } from '@taiga-ui/kit' -import { CategoriesModule } from '../../pages/list/categories/categories.module' -import { SearchModule } from '../../pages/list/search/search.module' -import { StoreIconComponentModule } from '../store-icon/store-icon.component.module' -import { MenuComponent } from './menu.component' - -@NgModule({ - imports: [ - CommonModule, - SharedPipesModule, - SearchModule, - CategoriesModule, - TuiLoader, - TuiButton, - CategoriesModule, - StoreIconComponentModule, - TuiAppearance, - TuiIcon, - TuiSkeleton, - TuiDrawer, - TuiPopup, - i18nPipe, - DocsLinkDirective, - ], - declarations: [MenuComponent], - exports: [MenuComponent], -}) -export class MenuModule {} diff --git a/web/projects/marketplace/src/components/menu/menu.component.ts b/web/projects/marketplace/src/components/menu/menu.component.ts index 3a8588abc..55bdf47d9 100644 --- a/web/projects/marketplace/src/components/menu/menu.component.ts +++ b/web/projects/marketplace/src/components/menu/menu.component.ts @@ -1,3 +1,4 @@ +import { CommonModule } from '@angular/common' import { ChangeDetectionStrategy, Component, @@ -6,16 +7,35 @@ import { OnDestroy, signal, } from '@angular/core' +import { DocsLinkDirective, i18nPipe } from '@start9labs/shared' +import { TuiAppearance, TuiButton, TuiIcon, TuiPopup } from '@taiga-ui/core' +import { TuiDrawer, TuiSkeleton } from '@taiga-ui/kit' import { Subject, takeUntil } from 'rxjs' +import { CategoriesComponent } from '../../pages/list/categories/categories.component' +import { SearchComponent } from '../../pages/list/search/search.component' import { AbstractCategoryService } from '../../services/category.service' import { StoreDataWithUrl } from '../../types' +import { StoreIconComponent } from '../store-icon.component' @Component({ selector: 'menu', templateUrl: './menu.component.html', styleUrls: ['./menu.component.scss'], + imports: [ + CommonModule, + SearchComponent, + CategoriesComponent, + TuiButton, + StoreIconComponent, + TuiAppearance, + TuiIcon, + TuiSkeleton, + TuiDrawer, + TuiPopup, + i18nPipe, + DocsLinkDirective, + ], changeDetection: ChangeDetectionStrategy.OnPush, - standalone: false, }) export class MenuComponent implements OnDestroy { @Input({ required: true }) diff --git a/web/projects/marketplace/src/components/registry.component.ts b/web/projects/marketplace/src/components/registry.component.ts index 87a53997d..4e80353b4 100644 --- a/web/projects/marketplace/src/components/registry.component.ts +++ b/web/projects/marketplace/src/components/registry.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core' import { TuiIcon, TuiTitle } from '@taiga-ui/core' -import { StoreIconComponentModule } from './store-icon/store-icon.component.module' +import { StoreIconComponent } from './store-icon.component' @Component({ selector: '[registry]', @@ -17,7 +17,7 @@ import { StoreIconComponentModule } from './store-icon/store-icon.component.modu } `, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [StoreIconComponentModule, TuiIcon, TuiTitle], + imports: [StoreIconComponent, TuiIcon, TuiTitle], }) export class MarketplaceRegistryComponent { @Input() diff --git a/web/projects/marketplace/src/components/store-icon/store-icon.component.ts b/web/projects/marketplace/src/components/store-icon.component.ts similarity index 98% rename from web/projects/marketplace/src/components/store-icon/store-icon.component.ts rename to web/projects/marketplace/src/components/store-icon.component.ts index 6a41e3ad8..61138dae4 100644 --- a/web/projects/marketplace/src/components/store-icon/store-icon.component.ts +++ b/web/projects/marketplace/src/components/store-icon.component.ts @@ -21,7 +21,6 @@ import { knownRegistries, sameUrl } from '@start9labs/shared' `, styles: ':host { overflow: hidden; }', changeDetection: ChangeDetectionStrategy.OnPush, - standalone: false, }) export class StoreIconComponent { @Input() diff --git a/web/projects/marketplace/src/components/store-icon/store-icon.component.module.ts b/web/projects/marketplace/src/components/store-icon/store-icon.component.module.ts deleted file mode 100644 index c11896fe8..000000000 --- a/web/projects/marketplace/src/components/store-icon/store-icon.component.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { NgModule } from '@angular/core' -import { CommonModule } from '@angular/common' -import { StoreIconComponent } from './store-icon.component' - -@NgModule({ - declarations: [StoreIconComponent], - imports: [CommonModule], - exports: [StoreIconComponent], -}) -export class StoreIconComponentModule {} diff --git a/web/projects/marketplace/src/pages/list/categories/categories.component.ts b/web/projects/marketplace/src/pages/list/categories/categories.component.ts index 3d3cccd4a..2408d7016 100644 --- a/web/projects/marketplace/src/pages/list/categories/categories.component.ts +++ b/web/projects/marketplace/src/pages/list/categories/categories.component.ts @@ -1,3 +1,4 @@ +import { CommonModule } from '@angular/common' import { ChangeDetectionStrategy, Component, @@ -5,7 +6,11 @@ import { Input, Output, } from '@angular/core' +import { RouterModule } from '@angular/router' +import { LocalizePipe } from '@start9labs/shared' import { T } from '@start9labs/start-sdk' +import { TuiAppearance, TuiIcon } from '@taiga-ui/core' +import { TuiSkeleton } from '@taiga-ui/kit' const ICONS: Record = { all: '@tui.layout-grid', @@ -26,8 +31,15 @@ const ICONS: Record = { selector: 'marketplace-categories', templateUrl: 'categories.component.html', styleUrls: ['categories.component.scss'], + imports: [ + RouterModule, + CommonModule, + TuiAppearance, + TuiIcon, + TuiSkeleton, + LocalizePipe, + ], changeDetection: ChangeDetectionStrategy.OnPush, - standalone: false, }) export class CategoriesComponent { @Input() diff --git a/web/projects/marketplace/src/pages/list/categories/categories.module.ts b/web/projects/marketplace/src/pages/list/categories/categories.module.ts deleted file mode 100644 index 2d67d8aca..000000000 --- a/web/projects/marketplace/src/pages/list/categories/categories.module.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { TuiIcon, TuiAppearance } from '@taiga-ui/core' -import { CommonModule } from '@angular/common' -import { NgModule } from '@angular/core' -import { TuiSkeleton } from '@taiga-ui/kit' -import { LocalizePipe } from '@start9labs/shared' - -import { CategoriesComponent } from './categories.component' -import { RouterModule } from '@angular/router' - -@NgModule({ - imports: [RouterModule, CommonModule, TuiAppearance, TuiIcon, TuiSkeleton, LocalizePipe], - declarations: [CategoriesComponent], - exports: [CategoriesComponent], -}) -export class CategoriesModule {} diff --git a/web/projects/marketplace/src/pages/list/item/item.component.ts b/web/projects/marketplace/src/pages/list/item/item.component.ts index e903fecad..5d6f4f257 100644 --- a/web/projects/marketplace/src/pages/list/item/item.component.ts +++ b/web/projects/marketplace/src/pages/list/item/item.component.ts @@ -1,12 +1,15 @@ +import { CommonModule } from '@angular/common' import { ChangeDetectionStrategy, Component, Input } from '@angular/core' +import { RouterModule } from '@angular/router' +import { LocalizePipe, TickerComponent } from '@start9labs/shared' import { MarketplacePkg } from '../../../types' @Component({ selector: 'marketplace-item', templateUrl: 'item.component.html', styleUrls: ['item.component.scss'], + imports: [CommonModule, RouterModule, TickerComponent, LocalizePipe], changeDetection: ChangeDetectionStrategy.OnPush, - standalone: false, }) export class ItemComponent { @Input({ required: true }) diff --git a/web/projects/marketplace/src/pages/list/item/item.module.ts b/web/projects/marketplace/src/pages/list/item/item.module.ts deleted file mode 100644 index 682f24de5..000000000 --- a/web/projects/marketplace/src/pages/list/item/item.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { CommonModule } from '@angular/common' -import { NgModule } from '@angular/core' -import { RouterModule } from '@angular/router' -import { LocalizePipe, SharedPipesModule, TickerComponent } from '@start9labs/shared' -import { ItemComponent } from './item.component' - -@NgModule({ - declarations: [ItemComponent], - exports: [ItemComponent], - imports: [CommonModule, RouterModule, SharedPipesModule, TickerComponent, LocalizePipe], -}) -export class ItemModule {} diff --git a/web/projects/marketplace/src/pages/list/search/search.component.ts b/web/projects/marketplace/src/pages/list/search/search.component.ts index dc745186e..fe075ce3e 100644 --- a/web/projects/marketplace/src/pages/list/search/search.component.ts +++ b/web/projects/marketplace/src/pages/list/search/search.component.ts @@ -1,3 +1,4 @@ +import { CommonModule } from '@angular/common' import { ChangeDetectionStrategy, Component, @@ -5,13 +6,15 @@ import { Input, Output, } from '@angular/core' +import { FormsModule } from '@angular/forms' +import { TuiIcon } from '@taiga-ui/core' @Component({ selector: 'marketplace-search', templateUrl: 'search.component.html', styleUrls: ['search.component.scss'], + imports: [FormsModule, CommonModule, TuiIcon], changeDetection: ChangeDetectionStrategy.OnPush, - standalone: false, }) export class SearchComponent { @Input() diff --git a/web/projects/marketplace/src/pages/list/search/search.module.ts b/web/projects/marketplace/src/pages/list/search/search.module.ts deleted file mode 100644 index b72b618b4..000000000 --- a/web/projects/marketplace/src/pages/list/search/search.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { CommonModule } from '@angular/common' -import { NgModule } from '@angular/core' -import { FormsModule } from '@angular/forms' -import { TuiIcon } from '@taiga-ui/core' -import { SearchComponent } from './search.component' - -@NgModule({ - imports: [FormsModule, CommonModule, TuiIcon], - declarations: [SearchComponent], - exports: [SearchComponent], -}) -export class SearchModule {} diff --git a/web/projects/marketplace/src/pages/show/dependencies/dependency-item.component.ts b/web/projects/marketplace/src/pages/show/dependencies/dependency-item.component.ts index 216dc8d7c..102de14db 100644 --- a/web/projects/marketplace/src/pages/show/dependencies/dependency-item.component.ts +++ b/web/projects/marketplace/src/pages/show/dependencies/dependency-item.component.ts @@ -1,7 +1,12 @@ import { KeyValue } from '@angular/common' -import { ChangeDetectionStrategy, Component, inject, Input } from '@angular/core' +import { + ChangeDetectionStrategy, + Component, + inject, + Input, +} from '@angular/core' import { RouterModule } from '@angular/router' -import { ExverPipesModule, i18nPipe, i18nService } from '@start9labs/shared' +import { i18nPipe, i18nService } from '@start9labs/shared' import { T } from '@start9labs/start-sdk' import { TuiAvatar, TuiLineClamp } from '@taiga-ui/kit' import { MarketplacePkgBase } from '../../../types' @@ -20,9 +25,7 @@ import { MarketplacePkgBase } from '../../../types'
- - {{ getTitle(dep.key) }} - + {{ getTitle(dep.key) }}

@if (dep.value.optional) { ({{ 'Optional' | i18n }}) @@ -37,9 +40,7 @@ import { MarketplacePkgBase } from '../../../types' [content]="descContent" class="description" /> - - {{ dep.value.description }} - + {{ dep.value.description }}

`, @@ -94,7 +95,7 @@ import { MarketplacePkgBase } from '../../../types' } `, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [RouterModule, TuiAvatar, ExverPipesModule, TuiLineClamp, i18nPipe], + imports: [RouterModule, TuiAvatar, TuiLineClamp, i18nPipe], }) export class MarketplaceDepItemComponent { private readonly i18nService = inject(i18nService) diff --git a/web/projects/marketplace/src/pages/show/flavors.component.ts b/web/projects/marketplace/src/pages/show/flavors.component.ts index 03a406ef7..acec8c3d1 100644 --- a/web/projects/marketplace/src/pages/show/flavors.component.ts +++ b/web/projects/marketplace/src/pages/show/flavors.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core' import { RouterLink } from '@angular/router' -import { i18nPipe, SharedPipesModule } from '@start9labs/shared' +import { i18nPipe, TrustUrlPipe } from '@start9labs/shared' import { TuiTitle } from '@taiga-ui/core' import { TuiAvatar } from '@taiga-ui/kit' import { TuiCell } from '@taiga-ui/layout' @@ -47,14 +47,7 @@ import { MarketplacePkg } from '../../types' } `, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - RouterLink, - TuiCell, - TuiTitle, - SharedPipesModule, - TuiAvatar, - i18nPipe, - ], + imports: [RouterLink, TuiCell, TuiTitle, TrustUrlPipe, TuiAvatar, i18nPipe], }) export class MarketplaceFlavorsComponent { @Input() diff --git a/web/projects/marketplace/src/pages/show/hero.component.ts b/web/projects/marketplace/src/pages/show/hero.component.ts index 8579ddaa2..7a951838d 100644 --- a/web/projects/marketplace/src/pages/show/hero.component.ts +++ b/web/projects/marketplace/src/pages/show/hero.component.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core' -import { SharedPipesModule, TickerComponent } from '@start9labs/shared' +import { TickerComponent } from '@start9labs/shared' import { T } from '@start9labs/start-sdk' @Component({ @@ -118,7 +118,7 @@ import { T } from '@start9labs/start-sdk' } `, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [SharedPipesModule, TickerComponent], + imports: [TickerComponent], }) export class MarketplacePackageHeroComponent { @Input({ required: true }) diff --git a/web/projects/marketplace/src/pages/show/versions.component.ts b/web/projects/marketplace/src/pages/show/versions.component.ts index d40d6a187..307d10e10 100644 --- a/web/projects/marketplace/src/pages/show/versions.component.ts +++ b/web/projects/marketplace/src/pages/show/versions.component.ts @@ -7,7 +7,7 @@ import { TemplateRef, } from '@angular/core' import { FormsModule } from '@angular/forms' -import { DialogService, i18nPipe, SharedPipesModule } from '@start9labs/shared' +import { DialogService, i18nPipe } from '@start9labs/shared' import { TuiButton, TuiDialogContext } from '@taiga-ui/core' import { TuiRadioList } from '@taiga-ui/kit' import { filter } from 'rxjs' @@ -76,7 +76,6 @@ import { MarketplaceItemComponent } from './item.component' imports: [ MarketplaceItemComponent, TuiButton, - SharedPipesModule, FormsModule, TuiRadioList, i18nPipe, diff --git a/web/projects/marketplace/src/pipes/filter-packages.pipe.ts b/web/projects/marketplace/src/pipes/filter-packages.pipe.ts index d7b92a071..7ce4b0d6d 100644 --- a/web/projects/marketplace/src/pipes/filter-packages.pipe.ts +++ b/web/projects/marketplace/src/pipes/filter-packages.pipe.ts @@ -4,7 +4,6 @@ import Fuse from 'fuse.js' @Pipe({ name: 'filterPackages', - standalone: false, }) export class FilterPackagesPipe implements PipeTransform { transform( @@ -79,9 +78,3 @@ export class FilterPackagesPipe implements PipeTransform { .map(a => ({ ...a })) } } - -@NgModule({ - declarations: [FilterPackagesPipe], - exports: [FilterPackagesPipe], -}) -export class FilterPackagesPipeModule {} diff --git a/web/projects/marketplace/src/public-api.ts b/web/projects/marketplace/src/public-api.ts index 77a225b48..06ecdc625 100644 --- a/web/projects/marketplace/src/public-api.ts +++ b/web/projects/marketplace/src/public-api.ts @@ -3,11 +3,8 @@ */ export * from './pages/list/categories/categories.component' -export * from './pages/list/categories/categories.module' export * from './pages/list/item/item.component' -export * from './pages/list/item/item.module' export * from './pages/list/search/search.component' -export * from './pages/list/search/search.module' export * from './pages/show/link.component' export * from './pages/show/item.component' export * from './pages/show/links.component' @@ -22,10 +19,7 @@ export * from './pages/show/release-notes.component' export * from './pipes/filter-packages.pipe' -export * from './components/store-icon/store-icon.component' -export * from './components/store-icon/store-icon.component.module' -export * from './components/store-icon/store-icon.component' -export * from './components/menu/menu.component.module' +export * from './components/store-icon.component' export * from './components/menu/menu.component' export * from './components/registry.component' diff --git a/web/projects/setup-wizard/src/app/app.component.ts b/web/projects/setup-wizard/src/app/app.component.ts index 6276aab5b..5cb62eb9b 100644 --- a/web/projects/setup-wizard/src/app/app.component.ts +++ b/web/projects/setup-wizard/src/app/app.component.ts @@ -1,15 +1,17 @@ -import { Component, inject, DOCUMENT } from '@angular/core' -import { Router } from '@angular/router' +import { Component, DOCUMENT, inject, OnInit } from '@angular/core' +import { Router, RouterOutlet } from '@angular/router' import { ErrorService } from '@start9labs/shared' +import { TuiRoot } from '@taiga-ui/core' + import { ApiService } from './services/api.service' import { StateService } from './services/state.service' @Component({ selector: 'app-root', template: '', - standalone: false, + imports: [TuiRoot, RouterOutlet], }) -export class AppComponent { +export class AppComponent implements OnInit { private readonly api = inject(ApiService) private readonly errorService = inject(ErrorService) private readonly router = inject(Router) diff --git a/web/projects/setup-wizard/src/app/app.module.ts b/web/projects/setup-wizard/src/app/app.config.ts similarity index 62% rename from web/projects/setup-wizard/src/app/app.module.ts rename to web/projects/setup-wizard/src/app/app.config.ts index c6a815065..ac29d8862 100644 --- a/web/projects/setup-wizard/src/app/app.module.ts +++ b/web/projects/setup-wizard/src/app/app.config.ts @@ -3,9 +3,20 @@ import { withFetch, withInterceptorsFromDi, } from '@angular/common/http' -import { inject, NgModule, provideAppInitializer } from '@angular/core' -import { BrowserAnimationsModule } from '@angular/platform-browser/animations' -import { PreloadAllModules, RouterModule } from '@angular/router' +import { + ApplicationConfig, + inject, + provideAppInitializer, + provideZoneChangeDetection, + signal, +} from '@angular/core' +import { provideAnimations } from '@angular/platform-browser/animations' +import { + PreloadAllModules, + provideRouter, + withDisabledInitialNavigation, + withPreloading, +} from '@angular/router' import { WA_LOCATION } from '@ng-web-apis/common' import initArgon from '@start9labs/argon2' import { @@ -15,13 +26,16 @@ import { VERSION, WorkspaceConfig, } from '@start9labs/shared' -import { tuiButtonOptionsProvider, TuiRoot } from '@taiga-ui/core' -import { NG_EVENT_PLUGINS } from '@taiga-ui/event-plugins' +import { + tuiButtonOptionsProvider, + tuiTextfieldOptionsProvider, +} from '@taiga-ui/core' +import { provideEventPlugins } from '@taiga-ui/event-plugins' + +import { ROUTES } from './app.routes' import { ApiService } from './services/api.service' import { LiveApiService } from './services/live-api.service' import { MockApiService } from './services/mock-api.service' -import { AppComponent } from './app.component' -import { ROUTES } from './app.routes' const { useMocks, @@ -30,18 +44,16 @@ const { const version = require('../../../../package.json').version -@NgModule({ - declarations: [AppComponent], - imports: [ - BrowserAnimationsModule, - RouterModule.forRoot(ROUTES, { - preloadingStrategy: PreloadAllModules, - initialNavigation: 'disabled', - }), - TuiRoot, - ], +export const APP_CONFIG: ApplicationConfig = { providers: [ - NG_EVENT_PLUGINS, + provideZoneChangeDetection(), + provideAnimations(), + provideEventPlugins(), + provideRouter( + ROUTES, + withDisabledInitialNavigation(), + withPreloading(PreloadAllModules), + ), I18N_PROVIDERS, provideSetupLogsService(ApiService), tuiButtonOptionsProvider({ size: 'm' }), @@ -64,7 +76,6 @@ const version = require('../../../../package.json').version initArgon({ module_or_path }) }), + tuiTextfieldOptionsProvider({ cleaner: signal(false) }), ], - bootstrap: [AppComponent], -}) -export class AppModule {} +} diff --git a/web/projects/setup-wizard/src/app/components/preserve-overwrite.dialog.ts b/web/projects/setup-wizard/src/app/components/preserve-overwrite.dialog.ts index 2b5ed04cb..fdb111456 100644 --- a/web/projects/setup-wizard/src/app/components/preserve-overwrite.dialog.ts +++ b/web/projects/setup-wizard/src/app/components/preserve-overwrite.dialog.ts @@ -5,7 +5,6 @@ import { TuiDialogContext } from '@taiga-ui/core' import { injectContext } from '@taiga-ui/polymorpheus' @Component({ - standalone: true, imports: [TuiButton, i18nPipe], template: `

{{ 'This drive contains existing StartOS data.' | i18n }}

diff --git a/web/projects/setup-wizard/src/app/components/remove-media.dialog.ts b/web/projects/setup-wizard/src/app/components/remove-media.dialog.ts index 0daef9e94..7a9274290 100644 --- a/web/projects/setup-wizard/src/app/components/remove-media.dialog.ts +++ b/web/projects/setup-wizard/src/app/components/remove-media.dialog.ts @@ -4,7 +4,6 @@ import { TuiButton, TuiDialogContext } from '@taiga-ui/core' import { injectContext } from '@taiga-ui/polymorpheus' @Component({ - standalone: true, imports: [TuiButton, i18nPipe], template: `
diff --git a/web/projects/setup-wizard/src/app/components/select-network-backup.dialog.ts b/web/projects/setup-wizard/src/app/components/select-network-backup.dialog.ts index d9c9c1166..6cddadde7 100644 --- a/web/projects/setup-wizard/src/app/components/select-network-backup.dialog.ts +++ b/web/projects/setup-wizard/src/app/components/select-network-backup.dialog.ts @@ -11,7 +11,6 @@ interface Data { } @Component({ - standalone: true, imports: [FormsModule, TuiTextfield, TuiSelect, TuiDataListWrapper, i18nPipe], template: `

{{ 'Multiple backups found. Select which one to restore.' | i18n }}

diff --git a/web/projects/setup-wizard/src/app/components/unlock-password.dialog.ts b/web/projects/setup-wizard/src/app/components/unlock-password.dialog.ts index 88191bff6..2a536efb2 100644 --- a/web/projects/setup-wizard/src/app/components/unlock-password.dialog.ts +++ b/web/projects/setup-wizard/src/app/components/unlock-password.dialog.ts @@ -11,7 +11,6 @@ import { TuiPassword } from '@taiga-ui/kit' import { injectContext } from '@taiga-ui/polymorpheus' @Component({ - standalone: true, imports: [ FormsModule, TuiButton, diff --git a/web/projects/setup-wizard/src/main.ts b/web/projects/setup-wizard/src/main.ts index 11a215811..4d81e24b0 100644 --- a/web/projects/setup-wizard/src/main.ts +++ b/web/projects/setup-wizard/src/main.ts @@ -1,13 +1,11 @@ import { enableProdMode } from '@angular/core' -import { platformBrowserDynamic } from '@angular/platform-browser-dynamic' - -import { AppModule } from './app/app.module' -import { environment } from './environments/environment' +import { bootstrapApplication } from '@angular/platform-browser' +import { AppComponent } from 'src/app/app.component' +import { APP_CONFIG } from 'src/app/app.config' +import { environment } from 'src/environments/environment' if (environment.production) { enableProdMode() } -platformBrowserDynamic() - .bootstrapModule(AppModule) - .catch(err => console.error(err)) +bootstrapApplication(AppComponent, APP_CONFIG).catch(console.error) diff --git a/web/projects/shared/src/pipes/convert-bytes.pipe.ts b/web/projects/shared/src/pipes/convert-bytes.pipe.ts new file mode 100644 index 000000000..abc6dbfae --- /dev/null +++ b/web/projects/shared/src/pipes/convert-bytes.pipe.ts @@ -0,0 +1,22 @@ +import { Pipe, PipeTransform } from '@angular/core' + +// converts bytes to gigabytes +@Pipe({ + name: 'convertBytes', +}) +export class ConvertBytesPipe implements PipeTransform { + transform(bytes: number): string { + return convertBytes(bytes) + } +} + +export function convertBytes(bytes: number): string { + if (bytes === 0) return '0 Bytes' + + const k = 1024 + const i = Math.floor(Math.log(bytes) / Math.log(k)) + + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] +} + +const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] diff --git a/web/projects/shared/src/pipes/shared/empty.pipe.ts b/web/projects/shared/src/pipes/empty.pipe.ts similarity index 54% rename from web/projects/shared/src/pipes/shared/empty.pipe.ts rename to web/projects/shared/src/pipes/empty.pipe.ts index bd52b0c60..e2bbcc0a5 100644 --- a/web/projects/shared/src/pipes/shared/empty.pipe.ts +++ b/web/projects/shared/src/pipes/empty.pipe.ts @@ -1,13 +1,11 @@ import { Pipe, PipeTransform } from '@angular/core' -import { isEmptyObject } from '../../util/misc.util' +import { isEmptyObject } from '../util/misc.util' @Pipe({ name: 'empty', - standalone: false, }) export class EmptyPipe implements PipeTransform { transform(val: object | [] = {}): boolean { - if (Array.isArray(val)) return !val.length - return isEmptyObject(val) + return Array.isArray(val) ? !val.length : isEmptyObject(val) } } diff --git a/web/projects/shared/src/pipes/exver/exver.pipe.ts b/web/projects/shared/src/pipes/exver-compares.pipe.ts similarity index 50% rename from web/projects/shared/src/pipes/exver/exver.pipe.ts rename to web/projects/shared/src/pipes/exver-compares.pipe.ts index 66d7f43cf..967d66631 100644 --- a/web/projects/shared/src/pipes/exver/exver.pipe.ts +++ b/web/projects/shared/src/pipes/exver-compares.pipe.ts @@ -1,28 +1,11 @@ -import { Pipe, PipeTransform } from '@angular/core' -import { Exver } from '../../services/exver.service' - -@Pipe({ - name: 'satisfiesExver', - standalone: false, -}) -export class ExverSatisfiesPipe implements PipeTransform { - constructor(private readonly exver: Exver) {} - - transform(versionUnderTest?: string, range?: string): boolean { - return ( - !!versionUnderTest && - !!range && - this.exver.satisfies(versionUnderTest, range) - ) - } -} +import { inject, Pipe, PipeTransform } from '@angular/core' +import { Exver } from '../services/exver.service' @Pipe({ name: 'compareExver', - standalone: false, }) export class ExverComparesPipe implements PipeTransform { - constructor(private readonly exver: Exver) {} + private readonly exver = inject(Exver) transform(first: string, second: string): SemverResult { try { diff --git a/web/projects/shared/src/pipes/exver/exver.module.ts b/web/projects/shared/src/pipes/exver/exver.module.ts deleted file mode 100644 index 8fd90e429..000000000 --- a/web/projects/shared/src/pipes/exver/exver.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { NgModule } from '@angular/core' -import { ExverComparesPipe, ExverSatisfiesPipe } from './exver.pipe' - -@NgModule({ - declarations: [ExverComparesPipe, ExverSatisfiesPipe], - exports: [ExverComparesPipe, ExverSatisfiesPipe], -}) -export class ExverPipesModule {} diff --git a/web/projects/shared/src/pipes/shared/includes.pipe.ts b/web/projects/shared/src/pipes/shared/includes.pipe.ts deleted file mode 100644 index 3e2a289fd..000000000 --- a/web/projects/shared/src/pipes/shared/includes.pipe.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Pipe, PipeTransform } from '@angular/core' - -@Pipe({ - name: 'includes', - standalone: false, -}) -export class IncludesPipe implements PipeTransform { - transform(list: T[], val: T): boolean { - return list.includes(val) - } -} diff --git a/web/projects/shared/src/pipes/shared/shared.module.ts b/web/projects/shared/src/pipes/shared/shared.module.ts deleted file mode 100644 index a9f282d67..000000000 --- a/web/projects/shared/src/pipes/shared/shared.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { NgModule } from '@angular/core' -import { IncludesPipe } from './includes.pipe' -import { EmptyPipe } from './empty.pipe' -import { TrustUrlPipe } from './trust.pipe' - -@NgModule({ - declarations: [IncludesPipe, EmptyPipe, TrustUrlPipe], - exports: [IncludesPipe, EmptyPipe, TrustUrlPipe], -}) -export class SharedPipesModule {} diff --git a/web/projects/shared/src/pipes/shared/sort.pipe.ts b/web/projects/shared/src/pipes/shared/sort.pipe.ts deleted file mode 100644 index 03dae32fd..000000000 --- a/web/projects/shared/src/pipes/shared/sort.pipe.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Pipe, PipeTransform } from '@angular/core' - -@Pipe({ - name: 'sort', - standalone: false, -}) -export class SortPipe implements PipeTransform { - transform( - value: any[], - column: string = '', - direction: string = 'asc', - ): any[] { - // If the value is not an array or is empty, return the original value - if (!Array.isArray(value) || value.length === 0) { - return value - } - - // Clone the array to avoid modifying the original value - const sortedValue = [...value] - - // Define the sorting function based on the column and direction parameters - const sortingFn = (a: any, b: any): number => { - if (a[column] < b[column]) { - return direction === 'asc' ? -1 : 1 - } else if (a[column] > b[column]) { - return direction === 'asc' ? 1 : -1 - } else { - return 0 - } - } - - // Sort the array and return the result - return sortedValue.sort(sortingFn) - } -} diff --git a/web/projects/shared/src/pipes/shared/trust.pipe.ts b/web/projects/shared/src/pipes/trust.pipe.ts similarity index 68% rename from web/projects/shared/src/pipes/shared/trust.pipe.ts rename to web/projects/shared/src/pipes/trust.pipe.ts index 8eaa7ac79..08f21620f 100644 --- a/web/projects/shared/src/pipes/shared/trust.pipe.ts +++ b/web/projects/shared/src/pipes/trust.pipe.ts @@ -1,12 +1,11 @@ -import { Pipe, PipeTransform } from '@angular/core' +import { inject, Pipe, PipeTransform } from '@angular/core' import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser' @Pipe({ name: 'trustUrl', - standalone: false, }) export class TrustUrlPipe implements PipeTransform { - constructor(private readonly sanitizer: DomSanitizer) {} + private readonly sanitizer = inject(DomSanitizer) transform(base64Icon: string): SafeResourceUrl { return this.sanitizer.bypassSecurityTrustResourceUrl(base64Icon) diff --git a/web/projects/shared/src/pipes/unit-conversion/unit-conversion.module.ts b/web/projects/shared/src/pipes/unit-conversion/unit-conversion.module.ts deleted file mode 100644 index c66535bdd..000000000 --- a/web/projects/shared/src/pipes/unit-conversion/unit-conversion.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { NgModule } from '@angular/core' -import { ConvertBytesPipe, DurationToSecondsPipe } from './unit-conversion.pipe' - -@NgModule({ - declarations: [ConvertBytesPipe, DurationToSecondsPipe], - exports: [ConvertBytesPipe, DurationToSecondsPipe], -}) -export class UnitConversionPipesModule {} diff --git a/web/projects/shared/src/pipes/unit-conversion/unit-conversion.pipe.ts b/web/projects/shared/src/pipes/unit-conversion/unit-conversion.pipe.ts deleted file mode 100644 index daed96208..000000000 --- a/web/projects/shared/src/pipes/unit-conversion/unit-conversion.pipe.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Pipe, PipeTransform } from '@angular/core' - -// converts bytes to gigabytes -@Pipe({ - name: 'convertBytes', - standalone: false, -}) -export class ConvertBytesPipe implements PipeTransform { - transform(bytes: number): string { - return convertBytes(bytes) - } -} - -export function convertBytes(bytes: number): string { - if (bytes === 0) return '0 Bytes' - - const k = 1024 - const i = Math.floor(Math.log(bytes) / Math.log(k)) - - return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] -} - -@Pipe({ - name: 'durationToSeconds', - standalone: false, -}) -export class DurationToSecondsPipe implements PipeTransform { - transform(duration?: string | null): number { - if (!duration) return 0 - - const regex = /^([0-9]*(\.[0-9]+)?)(ns|µs|ms|s|m|d)$/ - const [, num, , unit] = duration.match(regex) || [] - const multiplier = (unit && unitsToSeconds[unit]) || NaN - - return unit ? Number(num) * multiplier : NaN - } -} - -const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] - -const unitsToSeconds: Record = { - ns: 1e-9, - µs: 1e-6, - ms: 0.001, - s: 1, - m: 60, - h: 3600, - d: 86400, -} diff --git a/web/projects/shared/src/public-api.ts b/web/projects/shared/src/public-api.ts index 1e5bcf979..44e763ceb 100644 --- a/web/projects/shared/src/public-api.ts +++ b/web/projects/shared/src/public-api.ts @@ -20,14 +20,10 @@ export * from './i18n/i18n.providers' export * from './i18n/i18n.service' export * from './i18n/localize.pipe' -export * from './pipes/exver/exver.module' -export * from './pipes/exver/exver.pipe' -export * from './pipes/shared/shared.module' -export * from './pipes/shared/empty.pipe' -export * from './pipes/shared/includes.pipe' -export * from './pipes/shared/trust.pipe' -export * from './pipes/unit-conversion/unit-conversion.module' -export * from './pipes/unit-conversion/unit-conversion.pipe' +export * from './pipes/exver-compares.pipe' +export * from './pipes/empty.pipe' +export * from './pipes/trust.pipe' +export * from './pipes/convert-bytes.pipe' export * from './pipes/markdown.pipe' export * from './services/copy.service' diff --git a/web/projects/start-tunnel/src/app/routes/home/components/nav.ts b/web/projects/start-tunnel/src/app/routes/home/components/nav.ts index 0473d0b63..5d978f4ca 100644 --- a/web/projects/start-tunnel/src/app/routes/home/components/nav.ts +++ b/web/projects/start-tunnel/src/app/routes/home/components/nav.ts @@ -1,10 +1,7 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core' -import { Router, RouterLink, RouterLinkActive } from '@angular/router' -import { ErrorService, LoadingService } from '@start9labs/shared' +import { RouterLink, RouterLinkActive } from '@angular/router' import { TuiButton } from '@taiga-ui/core' import { TuiBadgeNotification } from '@taiga-ui/kit' -import { ApiService } from 'src/app/services/api/api.service' -import { AuthService } from 'src/app/services/auth.service' import { SidebarService } from 'src/app/services/sidebar.service' import { UpdateService } from 'src/app/services/update.service' @@ -38,15 +35,6 @@ import { UpdateService } from 'src/app/services/update.service' }
- `, styles: ` :host { @@ -79,12 +67,6 @@ import { UpdateService } from 'src/app/services/update.service' } } - button { - width: 100%; - border-radius: 0; - justify-content: flex-start; - } - :host-context(tui-root._mobile) { position: absolute; top: 3.5rem; @@ -106,12 +88,7 @@ import { UpdateService } from 'src/app/services/update.service' }, }) export class Nav { - private readonly service = inject(AuthService) - private readonly router = inject(Router) protected readonly sidebars = inject(SidebarService) - protected readonly api = inject(ApiService) - private readonly loader = inject(LoadingService) - private readonly errorService = inject(ErrorService) protected readonly update = inject(UpdateService) protected readonly routes = [ @@ -131,18 +108,4 @@ export class Nav { link: 'port-forwards', }, ] as const - - protected async logout() { - const loader = this.loader.open().subscribe() - try { - await this.api.logout() - this.service.authenticated.set(false) - this.router.navigate(['.']) - } catch (e: any) { - console.error(e) - this.errorService.handleError(e) - } finally { - loader.unsubscribe() - } - } } diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/add.ts b/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/add.ts index 195c3bf99..ab9f9560f 100644 --- a/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/add.ts +++ b/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/add.ts @@ -36,6 +36,11 @@ import { MappedDevice, PortForwardsData } from './utils' @Component({ template: `
+ + + + + @if (mobile) { @@ -161,6 +166,7 @@ export class PortForwardsAdd { injectContext>() protected readonly form = inject(NonNullableFormBuilder).group({ + label: ['', Validators.required], externalip: ['', Validators.required], externalport: [null as number | null, Validators.required], device: [null as MappedDevice | null, Validators.required], @@ -185,19 +191,21 @@ export class PortForwardsAdd { const loader = this.loading.open().subscribe() - const { externalip, externalport, device, internalport, also80 } = + const { label, externalip, externalport, device, internalport, also80 } = this.form.getRawValue() try { await this.api.addForward({ source: `${externalip}:${externalport}`, target: `${device!.ip}:${internalport}`, + label, }) if (externalport === 443 && internalport === 443 && also80) { await this.api.addForward({ source: `${externalip}:80`, target: `${device!.ip}:443`, + label: `${label} (HTTP redirect)`, }) } } catch (e: any) { diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/edit-label.ts b/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/edit-label.ts new file mode 100644 index 000000000..3f98f0a74 --- /dev/null +++ b/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/edit-label.ts @@ -0,0 +1,83 @@ +import { AsyncPipe } from '@angular/common' +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { + NonNullableFormBuilder, + ReactiveFormsModule, + Validators, +} from '@angular/forms' +import { ErrorService, LoadingService } from '@start9labs/shared' +import { + TuiButton, + TuiDialogContext, + TuiError, + TuiTextfield, +} from '@taiga-ui/core' +import { TuiFieldErrorPipe } from '@taiga-ui/kit' +import { TuiForm } from '@taiga-ui/layout' +import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' +import { ApiService } from 'src/app/services/api/api.service' + +export interface EditLabelData { + readonly source: string + readonly label: string +} + +@Component({ + template: ` + + + + + + +
+ +
+ + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + AsyncPipe, + ReactiveFormsModule, + TuiButton, + TuiError, + TuiFieldErrorPipe, + TuiTextfield, + TuiForm, + ], +}) +export class PortForwardsEditLabel { + private readonly api = inject(ApiService) + private readonly loading = inject(LoadingService) + private readonly errorService = inject(ErrorService) + + protected readonly context = + injectContext>() + + protected readonly form = inject(NonNullableFormBuilder).group({ + label: [this.context.data.label, Validators.required], + }) + + protected async onSave() { + const loader = this.loading.open().subscribe() + + try { + await this.api.updateForwardLabel({ + source: this.context.data.source, + label: this.form.getRawValue().label, + }) + } catch (e: any) { + console.error(e) + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + this.context.$implicit.complete() + } + } +} + +export const PORT_FORWARDS_EDIT_LABEL = new PolymorpheusComponent( + PortForwardsEditLabel, +) diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/index.ts b/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/index.ts index b607f1876..26e8dd52c 100644 --- a/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/index.ts +++ b/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/index.ts @@ -3,18 +3,26 @@ import { Component, computed, inject, + signal, Signal, } from '@angular/core' import { toSignal } from '@angular/core/rxjs-interop' -import { ReactiveFormsModule } from '@angular/forms' +import { FormsModule } from '@angular/forms' import { ErrorService, LoadingService } from '@start9labs/shared' import { utils } from '@start9labs/start-sdk' -import { TuiButton } from '@taiga-ui/core' +import { + TuiButton, + TuiDataList, + TuiDropdown, + TuiLoader, + TuiTextfield, +} from '@taiga-ui/core' import { TuiDialogService } from '@taiga-ui/experimental' -import { TUI_CONFIRM } from '@taiga-ui/kit' +import { TUI_CONFIRM, TuiSwitch } from '@taiga-ui/kit' import { PatchDB } from 'patch-db-client' import { filter, map } from 'rxjs' import { PORT_FORWARDS_ADD } from 'src/app/routes/home/routes/port-forwards/add' +import { PORT_FORWARDS_EDIT_LABEL } from 'src/app/routes/home/routes/port-forwards/edit-label' import { ApiService } from 'src/app/services/api/api.service' import { TunnelData } from 'src/app/services/patch-db/data-model' @@ -25,6 +33,8 @@ import { MappedDevice, MappedForward } from './utils' + + @@ -39,6 +49,23 @@ import { MappedDevice, MappedForward } from './utils' @for (forward of forwards(); track $index) { + + @@ -47,11 +74,30 @@ import { MappedDevice, MappedForward } from './utils' + + @@ -62,7 +108,15 @@ import { MappedDevice, MappedForward } from './utils'
Label External IP External Port Device
+ + + + {{ forward.label || '—' }} {{ forward.externalip }} {{ forward.externalport }} {{ forward.device.name }}
`, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ReactiveFormsModule, TuiButton], + imports: [ + FormsModule, + TuiButton, + TuiDropdown, + TuiDataList, + TuiLoader, + TuiSwitch, + TuiTextfield, + ], }) export default class PortForwards { private readonly dialogs = inject(TuiDialogService) @@ -100,19 +154,36 @@ export default class PortForwards { ) protected readonly forwards = computed(() => - Object.entries(this.portForwards() || {}).map(([source, target]) => { + Object.entries(this.portForwards() || {}).map(([source, entry]) => { const sourceSplit = source.split(':') - const targetSplit = target.split(':') + const targetSplit = entry.target.split(':') return { externalip: sourceSplit[0]!, externalport: sourceSplit[1]!, device: this.devices().find(d => d.ip === targetSplit[0])!, internalport: targetSplit[1]!, + label: entry.label, + enabled: entry.enabled, } }), ) + protected readonly toggling = signal(null) + + protected async onToggle(forward: MappedForward, index: number) { + this.toggling.set(index) + const source = `${forward.externalip}:${forward.externalport}` + + try { + await this.api.setForwardEnabled({ source, enabled: !forward.enabled }) + } catch (e: any) { + this.errorService.handleError(e) + } finally { + this.toggling.set(null) + } + } + protected onAdd(): void { this.dialogs .open(PORT_FORWARDS_ADD, { @@ -122,6 +193,18 @@ export default class PortForwards { .subscribe() } + protected onEditLabel(forward: MappedForward): void { + this.dialogs + .open(PORT_FORWARDS_EDIT_LABEL, { + label: 'Edit label', + data: { + source: `${forward.externalip}:${forward.externalport}`, + label: forward.label, + }, + }) + .subscribe() + } + protected onDelete({ externalip, externalport }: MappedForward): void { this.dialogs .open(TUI_CONFIRM, { label: 'Are you sure?' }) diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/utils.ts b/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/utils.ts index 101c1eba9..c9b55f25f 100644 --- a/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/utils.ts +++ b/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/utils.ts @@ -10,6 +10,8 @@ export interface MappedForward { readonly externalport: string readonly device: MappedDevice readonly internalport: string + readonly label: string + readonly enabled: boolean } export interface PortForwardsData { diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/settings/index.ts b/web/projects/start-tunnel/src/app/routes/home/routes/settings/index.ts index e2360e52f..4a871ae99 100644 --- a/web/projects/start-tunnel/src/app/routes/home/routes/settings/index.ts +++ b/web/projects/start-tunnel/src/app/routes/home/routes/settings/index.ts @@ -4,11 +4,14 @@ import { inject, signal, } from '@angular/core' -import { ErrorService } from '@start9labs/shared' +import { Router } from '@angular/router' +import { ErrorService, LoadingService } from '@start9labs/shared' import { TuiAppearance, TuiButton, TuiTitle } from '@taiga-ui/core' import { TuiDialogService } from '@taiga-ui/experimental' import { TuiBadge, TuiButtonLoading } from '@taiga-ui/kit' import { TuiCard, TuiCell } from '@taiga-ui/layout' +import { ApiService } from 'src/app/services/api/api.service' +import { AuthService } from 'src/app/services/auth.service' import { UpdateService } from 'src/app/services/update.service' import { CHANGE_PASSWORD } from './change-password' @@ -50,6 +53,20 @@ import { CHANGE_PASSWORD } from './change-password' +
+ + Logout + + +
`, changeDetection: ChangeDetectionStrategy.OnPush, @@ -66,6 +83,10 @@ import { CHANGE_PASSWORD } from './change-password' export default class Settings { private readonly dialogs = inject(TuiDialogService) private readonly errorService = inject(ErrorService) + private readonly api = inject(ApiService) + private readonly auth = inject(AuthService) + private readonly router = inject(Router) + private readonly loading = inject(LoadingService) protected readonly update = inject(UpdateService) protected readonly checking = signal(false) @@ -98,4 +119,18 @@ export default class Settings { this.applying.set(false) } } + + protected async onLogout() { + const loader = this.loading.open().subscribe() + + try { + await this.api.logout() + this.auth.authenticated.set(false) + this.router.navigate(['/']) + } catch (e: any) { + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + } + } } diff --git a/web/projects/start-tunnel/src/app/services/api/api.service.ts b/web/projects/start-tunnel/src/app/services/api/api.service.ts index 401d7f43c..cb1b29e57 100644 --- a/web/projects/start-tunnel/src/app/services/api/api.service.ts +++ b/web/projects/start-tunnel/src/app/services/api/api.service.ts @@ -25,6 +25,8 @@ export abstract class ApiService { // forwards abstract addForward(params: AddForwardReq): Promise // port-forward.add abstract deleteForward(params: DeleteForwardReq): Promise // port-forward.remove + abstract updateForwardLabel(params: UpdateForwardLabelReq): Promise // port-forward.update-label + abstract setForwardEnabled(params: SetForwardEnabledReq): Promise // port-forward.set-enabled // update abstract checkUpdate(): Promise // update.check abstract applyUpdate(): Promise // update.apply @@ -60,12 +62,23 @@ export type DeleteDeviceReq = { export type AddForwardReq = { source: string // externalip:port target: string // internalip:port + label: string } export type DeleteForwardReq = { source: string } +export type UpdateForwardLabelReq = { + source: string + label: string +} + +export type SetForwardEnabledReq = { + source: string + enabled: boolean +} + export type TunnelUpdateResult = { status: string installed: string diff --git a/web/projects/start-tunnel/src/app/services/api/live-api.service.ts b/web/projects/start-tunnel/src/app/services/api/live-api.service.ts index cabf8200f..f0ed98b97 100644 --- a/web/projects/start-tunnel/src/app/services/api/live-api.service.ts +++ b/web/projects/start-tunnel/src/app/services/api/live-api.service.ts @@ -17,6 +17,8 @@ import { LoginReq, SubscribeRes, TunnelUpdateResult, + SetForwardEnabledReq, + UpdateForwardLabelReq, UpsertDeviceReq, UpsertSubnetReq, } from './api.service' @@ -104,6 +106,14 @@ export class LiveApiService extends ApiService { return this.rpcRequest({ method: 'port-forward.remove', params }) } + async updateForwardLabel(params: UpdateForwardLabelReq): Promise { + return this.rpcRequest({ method: 'port-forward.update-label', params }) + } + + async setForwardEnabled(params: SetForwardEnabledReq): Promise { + return this.rpcRequest({ method: 'port-forward.set-enabled', params }) + } + // update async checkUpdate(): Promise { diff --git a/web/projects/start-tunnel/src/app/services/api/mock-api.service.ts b/web/projects/start-tunnel/src/app/services/api/mock-api.service.ts index 6f82c597f..d24562b9d 100644 --- a/web/projects/start-tunnel/src/app/services/api/mock-api.service.ts +++ b/web/projects/start-tunnel/src/app/services/api/mock-api.service.ts @@ -10,6 +10,8 @@ import { LoginReq, SubscribeRes, TunnelUpdateResult, + SetForwardEnabledReq, + UpdateForwardLabelReq, UpsertDeviceReq, UpsertSubnetReq, } from './api.service' @@ -24,7 +26,12 @@ import { Revision, } from 'patch-db-client' import { toObservable } from '@angular/core/rxjs-interop' -import { mockTunnelData, WgClient, WgSubnet } from '../patch-db/data-model' +import { + mockTunnelData, + PortForwardEntry, + WgClient, + WgSubnet, +} from '../patch-db/data-model' @Injectable({ providedIn: 'root', @@ -171,11 +178,45 @@ export class MockApiService extends ApiService { async addForward(params: AddForwardReq): Promise { await pauseFor(1000) - const patch: AddOperation[] = [ + const patch: AddOperation[] = [ { op: PatchOp.ADD, path: `/portForwards/${params.source}`, - value: params.target, + value: { + target: params.target, + label: params.label || '', + enabled: true, + }, + }, + ] + this.mockRevision(patch) + + return null + } + + async updateForwardLabel(params: UpdateForwardLabelReq): Promise { + await pauseFor(1000) + + const patch: ReplaceOperation[] = [ + { + op: PatchOp.REPLACE, + path: `/portForwards/${params.source}/label`, + value: params.label, + }, + ] + this.mockRevision(patch) + + return null + } + + async setForwardEnabled(params: SetForwardEnabledReq): Promise { + await pauseFor(1000) + + const patch: ReplaceOperation[] = [ + { + op: PatchOp.REPLACE, + path: `/portForwards/${params.source}/enabled`, + value: params.enabled, }, ] this.mockRevision(patch) diff --git a/web/projects/start-tunnel/src/app/services/patch-db/data-model.ts b/web/projects/start-tunnel/src/app/services/patch-db/data-model.ts index 9df4fac6d..8bb5e23e0 100644 --- a/web/projects/start-tunnel/src/app/services/patch-db/data-model.ts +++ b/web/projects/start-tunnel/src/app/services/patch-db/data-model.ts @@ -1,8 +1,14 @@ import { T } from '@start9labs/start-sdk' +export type PortForwardEntry = { + target: string + label: string + enabled: boolean +} + export type TunnelData = { wg: WgServer - portForwards: Record + portForwards: Record gateways: Record } @@ -39,8 +45,12 @@ export const mockTunnelData: TunnelData = { }, }, portForwards: { - '69.1.1.42:443': '10.59.0.2:443', - '69.1.1.42:3000': '10.59.0.2:3000', + '69.1.1.42:443': { target: '10.59.0.2:443', label: 'HTTPS', enabled: true }, + '69.1.1.42:3000': { + target: '10.59.0.2:3000', + label: 'Grafana', + enabled: true, + }, }, gateways: { eth0: { diff --git a/web/projects/ui/src/app/app.component.ts b/web/projects/ui/src/app/app.component.ts index 1ef65c6fb..a25c0ab4f 100644 --- a/web/projects/ui/src/app/app.component.ts +++ b/web/projects/ui/src/app/app.component.ts @@ -1,14 +1,18 @@ import { Component, inject } from '@angular/core' import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import { RouterOutlet } from '@angular/router' import { i18nService } from '@start9labs/shared' +import { TuiRoot } from '@taiga-ui/core' import { PatchDB } from 'patch-db-client' import { merge } from 'rxjs' +import { ToastContainerComponent } from 'src/app/components/toast-container.component' import { PatchDataService } from './services/patch-data.service' import { DataModel } from './services/patch-db/data-model' import { PatchMonitorService } from './services/patch-monitor.service' @Component({ selector: 'app-root', + imports: [TuiRoot, RouterOutlet, ToastContainerComponent], template: ` @@ -26,7 +30,6 @@ import { PatchMonitorService } from './services/patch-monitor.service' font-family: 'Proxima Nova', system-ui; } `, - standalone: false, }) export class AppComponent { private readonly i18n = inject(i18nService) diff --git a/web/projects/ui/src/app/app.config.ts b/web/projects/ui/src/app/app.config.ts new file mode 100644 index 000000000..376a91bab --- /dev/null +++ b/web/projects/ui/src/app/app.config.ts @@ -0,0 +1,199 @@ +import { + provideHttpClient, + withFetch, + withInterceptorsFromDi, +} from '@angular/common/http' +import { + ApplicationConfig, + inject, + provideAppInitializer, + provideZoneChangeDetection, +} from '@angular/core' +import { UntypedFormBuilder } from '@angular/forms' +import { provideAnimations } from '@angular/platform-browser/animations' +import { + ActivationStart, + PreloadAllModules, + provideRouter, + Router, + withComponentInputBinding, + withDisabledInitialNavigation, + withInMemoryScrolling, + withPreloading, + withRouterConfig, +} from '@angular/router' +import { provideServiceWorker } from '@angular/service-worker' +import { WA_LOCATION } from '@ng-web-apis/common' +import initArgon from '@start9labs/argon2' +import { + AbstractCategoryService, + FilterPackagesPipe, +} from '@start9labs/marketplace' +import { + I18N_PROVIDERS, + I18N_STORAGE, + i18nService, + Languages, + RELATIVE_URL, + VERSION, + WorkspaceConfig, +} from '@start9labs/shared' +import { tuiObfuscateOptionsProvider } from '@taiga-ui/cdk' +import { + TUI_DATE_FORMAT, + TUI_DIALOGS_CLOSE, + TUI_MEDIA, + tuiAlertOptionsProvider, + tuiButtonOptionsProvider, + tuiDropdownOptionsProvider, + tuiNumberFormatProvider, +} from '@taiga-ui/core' +import { provideEventPlugins } from '@taiga-ui/event-plugins' +import { + TUI_DATE_TIME_VALUE_TRANSFORMER, + TUI_DATE_VALUE_TRANSFORMER, +} from '@taiga-ui/kit' +import { PatchDB } from 'patch-db-client' +import { filter, identity, merge, of, pairwise } from 'rxjs' +import { FilterUpdatesPipe } from 'src/app/routes/portal/routes/updates/filter-updates.pipe' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { LiveApiService } from 'src/app/services/api/embassy-live-api.service' +import { MockApiService } from 'src/app/services/api/embassy-mock-api.service' +import { AuthService } from 'src/app/services/auth.service' +import { CategoryService } from 'src/app/services/category.service' +import { ClientStorageService } from 'src/app/services/client-storage.service' +import { ConfigService } from 'src/app/services/config.service' +import { DateTransformerService } from 'src/app/services/date-transformer.service' +import { DatetimeTransformerService } from 'src/app/services/datetime-transformer.service' +import { + PATCH_CACHE, + PatchDbSource, +} from 'src/app/services/patch-db/patch-db-source' +import { StateService } from 'src/app/services/state.service' +import { StorageService } from 'src/app/services/storage.service' +import { environment } from 'src/environments/environment' + +import { ROUTES } from './app.routes' + +const { + useMocks, + ui: { api }, +} = require('../../../../config.json') as WorkspaceConfig + +export const APP_CONFIG: ApplicationConfig = { + providers: [ + provideZoneChangeDetection(), + provideAnimations(), + provideEventPlugins(), + provideHttpClient(withInterceptorsFromDi(), withFetch()), + provideRouter( + ROUTES, + withDisabledInitialNavigation(), + withComponentInputBinding(), + withPreloading(PreloadAllModules), + withInMemoryScrolling({ scrollPositionRestoration: 'enabled' }), + withRouterConfig({ paramsInheritanceStrategy: 'always' }), + ), + provideServiceWorker('ngsw-worker.js', { + enabled: environment.useServiceWorker, + // Register the ServiceWorker as soon as the application is stable + // or after 30 seconds (whichever comes first). + registrationStrategy: 'registerWhenStable:30000', + }), + I18N_PROVIDERS, + FilterPackagesPipe, + FilterUpdatesPipe, + UntypedFormBuilder, + tuiNumberFormatProvider({ decimalSeparator: '.', thousandSeparator: '' }), + tuiButtonOptionsProvider({ size: 'm' }), + tuiDropdownOptionsProvider({ appearance: 'start-os' }), + tuiAlertOptionsProvider({ + autoClose: appearance => (appearance === 'negative' ? 0 : 3000), + }), + { + provide: TUI_DATE_FORMAT, + useValue: of({ + mode: 'MDY', + separator: '/', + }), + }, + { + provide: TUI_DATE_VALUE_TRANSFORMER, + useClass: DateTransformerService, + }, + { + provide: TUI_DATE_TIME_VALUE_TRANSFORMER, + useClass: DatetimeTransformerService, + }, + { + provide: ApiService, + useClass: useMocks ? MockApiService : LiveApiService, + }, + { + provide: PatchDB, + deps: [PatchDbSource, PATCH_CACHE], + useClass: PatchDB, + }, + provideAppInitializer(() => { + const i18n = inject(i18nService) + const origin = inject(WA_LOCATION).origin + const module_or_path = new URL('/assets/argon2_bg.wasm', origin) + + initArgon({ module_or_path }) + inject(StorageService).migrate036() + inject(AuthService).init() + inject(ClientStorageService).init() + inject(Router).initialNavigation() + i18n.setLanguage(i18n.language || 'english') + }), + { + provide: RELATIVE_URL, + useValue: `/${api.url}/${api.version}`, + }, + { + provide: AbstractCategoryService, + useClass: CategoryService, + }, + { + provide: TUI_DIALOGS_CLOSE, + useFactory: () => + merge( + inject(Router).events.pipe(filter(e => e instanceof ActivationStart)), + inject(StateService).pipe( + pairwise(), + filter( + ([prev, curr]) => + prev === 'running' && + (curr === 'error' || curr === 'initializing'), + ), + ), + ), + }, + { + provide: I18N_STORAGE, + useFactory: () => { + const api = inject(ApiService) + + return (language: Languages) => api.setLanguage({ language }) + }, + }, + { + provide: VERSION, + useFactory: () => inject(ConfigService).version, + }, + tuiObfuscateOptionsProvider({ + recipes: { + mask: ({ length }) => '•'.repeat(length), + none: identity, + }, + }), + { + provide: TUI_MEDIA, + useValue: { + mobile: 1000, + desktopSmall: 1280, + desktopLarge: Infinity, + }, + }, + ], +} diff --git a/web/projects/ui/src/app/app.module.ts b/web/projects/ui/src/app/app.module.ts deleted file mode 100644 index 2cef248e8..000000000 --- a/web/projects/ui/src/app/app.module.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { - provideHttpClient, - withFetch, - withInterceptorsFromDi, -} from '@angular/common/http' -import { NgModule } from '@angular/core' -import { BrowserModule } from '@angular/platform-browser' -import { ServiceWorkerModule } from '@angular/service-worker' -import { TuiRoot } from '@taiga-ui/core' -import { ToastContainerComponent } from 'src/app/components/toast-container.component' -import { environment } from '../environments/environment' -import { AppComponent } from './app.component' -import { APP_PROVIDERS } from './app.providers' -import { RoutingModule } from './routing.module' - -@NgModule({ - declarations: [AppComponent], - imports: [ - BrowserModule, - RoutingModule, - ToastContainerComponent, - TuiRoot, - ServiceWorkerModule.register('ngsw-worker.js', { - enabled: environment.useServiceWorker, - // Register the ServiceWorker as soon as the application is stable - // or after 30 seconds (whichever comes first). - registrationStrategy: 'registerWhenStable:30000', - }), - ], - providers: [ - APP_PROVIDERS, - provideHttpClient(withInterceptorsFromDi(), withFetch()), - ], - bootstrap: [AppComponent], -}) -export class AppModule {} diff --git a/web/projects/ui/src/app/app.providers.ts b/web/projects/ui/src/app/app.providers.ts deleted file mode 100644 index 7528b2305..000000000 --- a/web/projects/ui/src/app/app.providers.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { inject, provideAppInitializer } from '@angular/core' -import { UntypedFormBuilder } from '@angular/forms' -import { provideAnimations } from '@angular/platform-browser/animations' -import { ActivationStart, Router } from '@angular/router' -import { WA_LOCATION } from '@ng-web-apis/common' -import initArgon from '@start9labs/argon2' -import { - AbstractCategoryService, - FilterPackagesPipe, -} from '@start9labs/marketplace' -import { - I18N_PROVIDERS, - I18N_STORAGE, - i18nService, - Languages, - RELATIVE_URL, - VERSION, - WorkspaceConfig, -} from '@start9labs/shared' -import { tuiObfuscateOptionsProvider } from '@taiga-ui/cdk' -import { - TUI_DATE_FORMAT, - TUI_DIALOGS_CLOSE, - TUI_MEDIA, - tuiAlertOptionsProvider, - tuiButtonOptionsProvider, - tuiDropdownOptionsProvider, - tuiNumberFormatProvider, -} from '@taiga-ui/core' -import { provideEventPlugins } from '@taiga-ui/event-plugins' -import { - TUI_DATE_TIME_VALUE_TRANSFORMER, - TUI_DATE_VALUE_TRANSFORMER, -} from '@taiga-ui/kit' -import { PatchDB } from 'patch-db-client' -import { filter, identity, merge, of, pairwise } from 'rxjs' -import { ConfigService } from 'src/app/services/config.service' -import { - PATCH_CACHE, - PatchDbSource, -} from 'src/app/services/patch-db/patch-db-source' -import { StateService } from 'src/app/services/state.service' -import { FilterUpdatesPipe } from './routes/portal/routes/updates/filter-updates.pipe' -import { ApiService } from './services/api/embassy-api.service' -import { LiveApiService } from './services/api/embassy-live-api.service' -import { MockApiService } from './services/api/embassy-mock-api.service' -import { AuthService } from './services/auth.service' -import { CategoryService } from './services/category.service' -import { ClientStorageService } from './services/client-storage.service' -import { DateTransformerService } from './services/date-transformer.service' -import { DatetimeTransformerService } from './services/datetime-transformer.service' -import { StorageService } from './services/storage.service' - -const { - useMocks, - ui: { api }, -} = require('../../../../config.json') as WorkspaceConfig - -export const APP_PROVIDERS = [ - provideAnimations(), - provideEventPlugins(), - I18N_PROVIDERS, - FilterPackagesPipe, - FilterUpdatesPipe, - UntypedFormBuilder, - tuiNumberFormatProvider({ decimalSeparator: '.', thousandSeparator: '' }), - tuiButtonOptionsProvider({ size: 'm' }), - tuiDropdownOptionsProvider({ appearance: 'start-os' }), - tuiAlertOptionsProvider({ - autoClose: appearance => (appearance === 'negative' ? 0 : 3000), - }), - { - provide: TUI_DATE_FORMAT, - useValue: of({ - mode: 'MDY', - separator: '/', - }), - }, - { - provide: TUI_DATE_VALUE_TRANSFORMER, - useClass: DateTransformerService, - }, - { - provide: TUI_DATE_TIME_VALUE_TRANSFORMER, - useClass: DatetimeTransformerService, - }, - { - provide: ApiService, - useClass: useMocks ? MockApiService : LiveApiService, - }, - { - provide: PatchDB, - deps: [PatchDbSource, PATCH_CACHE], - useClass: PatchDB, - }, - provideAppInitializer(() => { - const i18n = inject(i18nService) - const origin = inject(WA_LOCATION).origin - const module_or_path = new URL('/assets/argon2_bg.wasm', origin) - - initArgon({ module_or_path }) - inject(StorageService).migrate036() - inject(AuthService).init() - inject(ClientStorageService).init() - inject(Router).initialNavigation() - i18n.setLanguage(i18n.language || 'english') - }), - { - provide: RELATIVE_URL, - useValue: `/${api.url}/${api.version}`, - }, - { - provide: AbstractCategoryService, - useClass: CategoryService, - }, - { - provide: TUI_DIALOGS_CLOSE, - useFactory: () => - merge( - inject(Router).events.pipe(filter(e => e instanceof ActivationStart)), - inject(StateService).pipe( - pairwise(), - filter( - ([prev, curr]) => - prev === 'running' && - (curr === 'error' || curr === 'initializing'), - ), - ), - ), - }, - { - provide: I18N_STORAGE, - useFactory: () => { - const api = inject(ApiService) - - return (language: Languages) => api.setLanguage({ language }) - }, - }, - { - provide: VERSION, - useFactory: () => inject(ConfigService).version, - }, - tuiObfuscateOptionsProvider({ - recipes: { - mask: ({ length }) => '•'.repeat(length), - none: identity, - }, - }), - { - provide: TUI_MEDIA, - useValue: { - mobile: 1000, - desktopSmall: 1280, - desktopLarge: Infinity, - }, - }, -] diff --git a/web/projects/ui/src/app/routing.module.ts b/web/projects/ui/src/app/app.routes.ts similarity index 54% rename from web/projects/ui/src/app/routing.module.ts rename to web/projects/ui/src/app/app.routes.ts index 001159cfe..d88889533 100644 --- a/web/projects/ui/src/app/routing.module.ts +++ b/web/projects/ui/src/app/app.routes.ts @@ -1,14 +1,14 @@ import { NgModule } from '@angular/core' import { PreloadAllModules, RouterModule, Routes } from '@angular/router' +import { AuthGuard } from 'src/app/guards/auth.guard' +import { UnauthGuard } from 'src/app/guards/unauth.guard' import { stateNot } from 'src/app/services/state.service' -import { AuthGuard } from './guards/auth.guard' -import { UnauthGuard } from './guards/unauth.guard' -const routes: Routes = [ +export const ROUTES: Routes = [ { path: 'diagnostic', canActivate: [stateNot(['initializing', 'running'])], - loadChildren: () => import('./routes/diagnostic/diagnostic.module'), + loadChildren: () => import('./routes/diagnostic/diagnostic.routes'), }, { path: 'initializing', @@ -18,8 +18,7 @@ const routes: Routes = [ { path: 'login', canActivate: [UnauthGuard, stateNot(['error', 'initializing'])], - loadChildren: () => - import('./routes/login/login.module').then(m => m.LoginPageModule), + loadComponent: () => import('./routes/login/login.page'), }, { path: '', @@ -32,17 +31,3 @@ const routes: Routes = [ pathMatch: 'full', }, ] - -@NgModule({ - imports: [ - RouterModule.forRoot(routes, { - scrollPositionRestoration: 'enabled', - paramsInheritanceStrategy: 'always', - preloadingStrategy: PreloadAllModules, - initialNavigation: 'disabled', - bindToComponentInputs: true, - }), - ], - exports: [RouterModule], -}) -export class RoutingModule {} diff --git a/web/projects/ui/src/app/routes/diagnostic/diagnostic.module.ts b/web/projects/ui/src/app/routes/diagnostic/diagnostic.module.ts deleted file mode 100644 index b1a03dcd8..000000000 --- a/web/projects/ui/src/app/routes/diagnostic/diagnostic.module.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NgModule } from '@angular/core' -import { RouterModule, Routes } from '@angular/router' - -const ROUTES: Routes = [ - { - path: '', - loadChildren: () => - import('./home/home.module').then(m => m.HomePageModule), - }, - { - path: 'logs', - loadComponent: () => import('./logs.component'), - }, -] - -@NgModule({ - imports: [RouterModule.forChild(ROUTES)], -}) -export default class DiagnosticModule {} diff --git a/web/projects/ui/src/app/routes/diagnostic/diagnostic.routes.ts b/web/projects/ui/src/app/routes/diagnostic/diagnostic.routes.ts new file mode 100644 index 000000000..ce7ed40a1 --- /dev/null +++ b/web/projects/ui/src/app/routes/diagnostic/diagnostic.routes.ts @@ -0,0 +1,12 @@ +import { Routes } from '@angular/router' + +export default [ + { + path: '', + loadComponent: () => import('./home/home.page'), + }, + { + path: 'logs', + loadComponent: () => import('./logs.component'), + }, +] satisfies Routes diff --git a/web/projects/ui/src/app/routes/diagnostic/home/home.module.ts b/web/projects/ui/src/app/routes/diagnostic/home/home.module.ts deleted file mode 100644 index b4419cc2f..000000000 --- a/web/projects/ui/src/app/routes/diagnostic/home/home.module.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { TuiButton } from '@taiga-ui/core' -import { NgModule } from '@angular/core' -import { CommonModule } from '@angular/common' -import { RouterModule, Routes } from '@angular/router' -import { HomePage } from './home.page' -import { i18nPipe } from '@start9labs/shared' - -const ROUTES: Routes = [ - { - path: '', - component: HomePage, - }, -] - -@NgModule({ - imports: [CommonModule, TuiButton, RouterModule.forChild(ROUTES), i18nPipe], - declarations: [HomePage], -}) -export class HomePageModule {} diff --git a/web/projects/ui/src/app/routes/diagnostic/home/home.page.ts b/web/projects/ui/src/app/routes/diagnostic/home/home.page.ts index 589b661b0..b0ba69916 100644 --- a/web/projects/ui/src/app/routes/diagnostic/home/home.page.ts +++ b/web/projects/ui/src/app/routes/diagnostic/home/home.page.ts @@ -1,6 +1,14 @@ +import { CommonModule } from '@angular/common' import { Component, Inject } from '@angular/core' +import { RouterLink } from '@angular/router' import { WA_WINDOW } from '@ng-web-apis/common' -import { DialogService, i18nKey, LoadingService } from '@start9labs/shared' +import { + DialogService, + i18nKey, + i18nPipe, + LoadingService, +} from '@start9labs/shared' +import { TuiButton } from '@taiga-ui/core' import { filter } from 'rxjs' import { ApiService } from 'src/app/services/api/embassy-api.service' import { ConfigService } from 'src/app/services/config.service' @@ -9,9 +17,9 @@ import { ConfigService } from 'src/app/services/config.service' selector: 'diagnostic-home', templateUrl: 'home.component.html', styleUrls: ['home.page.scss'], - standalone: false, + imports: [CommonModule, TuiButton, i18nPipe, RouterLink], }) -export class HomePage { +export default class HomePage { restarted = false error?: { code: number diff --git a/web/projects/ui/src/app/routes/initializing/initializing.page.ts b/web/projects/ui/src/app/routes/initializing/initializing.page.ts index ef965d5ef..5435a8d33 100644 --- a/web/projects/ui/src/app/routes/initializing/initializing.page.ts +++ b/web/projects/ui/src/app/routes/initializing/initializing.page.ts @@ -20,9 +20,7 @@ import { ApiService } from 'src/app/services/api/embassy-api.service' import { StateService } from 'src/app/services/state.service' @Component({ - template: ` - - `, + template: '', providers: [provideSetupLogsService(ApiService)], styles: ':host { height: 100%; }', imports: [InitializingComponent], diff --git a/web/projects/ui/src/app/routes/login/login.module.ts b/web/projects/ui/src/app/routes/login/login.module.ts deleted file mode 100644 index 40e6d9a72..000000000 --- a/web/projects/ui/src/app/routes/login/login.module.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { CommonModule } from '@angular/common' -import { NgModule } from '@angular/core' -import { FormsModule } from '@angular/forms' -import { RouterModule, Routes } from '@angular/router' -import { i18nPipe } from '@start9labs/shared' -import { TuiAutoFocus } from '@taiga-ui/cdk' -import { TuiButton, TuiError, TuiIcon, TuiTextfield } from '@taiga-ui/core' -import { TuiPassword } from '@taiga-ui/kit' -import { TuiCardLarge } from '@taiga-ui/layout' -import { CAWizardComponent } from './ca-wizard/ca-wizard.component' -import { LoginPage } from './login.page' - -const routes: Routes = [ - { - path: '', - component: LoginPage, - }, -] - -@NgModule({ - imports: [ - CommonModule, - FormsModule, - CAWizardComponent, - TuiButton, - TuiCardLarge, - ...TuiTextfield, - TuiIcon, - TuiPassword, - TuiAutoFocus, - TuiError, - RouterModule.forChild(routes), - i18nPipe, - ], - declarations: [LoginPage], -}) -export class LoginPageModule {} diff --git a/web/projects/ui/src/app/routes/login/login.page.ts b/web/projects/ui/src/app/routes/login/login.page.ts index 819eb878c..48451f5b4 100644 --- a/web/projects/ui/src/app/routes/login/login.page.ts +++ b/web/projects/ui/src/app/routes/login/login.page.ts @@ -1,19 +1,38 @@ -import { Router } from '@angular/router' +import { CommonModule } from '@angular/common' +import { Component, DestroyRef, DOCUMENT, inject, Inject } from '@angular/core' import { takeUntilDestroyed } from '@angular/core/rxjs-interop' -import { Component, Inject, DestroyRef, inject, DOCUMENT } from '@angular/core' +import { FormsModule } from '@angular/forms' +import { Router } from '@angular/router' +import { i18nKey, i18nPipe, LoadingService } from '@start9labs/shared' +import { TuiAutoFocus } from '@taiga-ui/cdk' +import { TuiButton, TuiError, TuiIcon, TuiTextfield } from '@taiga-ui/core' +import { TuiPassword } from '@taiga-ui/kit' +import { TuiCardLarge } from '@taiga-ui/layout' +import { CAWizardComponent } from 'src/app/routes/login/ca-wizard/ca-wizard.component' import { ApiService } from 'src/app/services/api/embassy-api.service' import { AuthService } from 'src/app/services/auth.service' import { ConfigService } from 'src/app/services/config.service' -import { i18nKey, LoadingService } from '@start9labs/shared' @Component({ selector: 'login', templateUrl: './login.component.html', styleUrls: ['./login.page.scss'], + imports: [ + CommonModule, + FormsModule, + CAWizardComponent, + TuiButton, + TuiCardLarge, + TuiTextfield, + TuiIcon, + TuiPassword, + TuiAutoFocus, + TuiError, + i18nPipe, + ], providers: [], - standalone: false, }) -export class LoginPage { +export default class LoginPage { password = '' error: i18nKey | null = null diff --git a/web/projects/ui/src/app/routes/portal/components/header/header.component.ts b/web/projects/ui/src/app/routes/portal/components/header/header.component.ts index a4de82c14..5c1a136ab 100644 --- a/web/projects/ui/src/app/routes/portal/components/header/header.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/header/header.component.ts @@ -36,6 +36,7 @@ import { HeaderStatusComponent } from './status.component' height: 2.75rem; border-radius: var(--bumper); margin: var(--bumper); + clip-path: inset(0 round var(--bumper)); overflow: hidden; filter: grayscale(1) brightness(0.75); @@ -107,7 +108,8 @@ import { HeaderStatusComponent } from './status.component' &:has([data-status='success']) { --status: transparent; - filter: none; + // "none" breaks border radius in Firefox + filter: grayscale(0.001); } } diff --git a/web/projects/ui/src/app/routes/portal/routes/backups/components/physical.component.ts b/web/projects/ui/src/app/routes/portal/routes/backups/components/physical.component.ts index cea1995a6..efecfb6d6 100644 --- a/web/projects/ui/src/app/routes/portal/routes/backups/components/physical.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/backups/components/physical.component.ts @@ -5,7 +5,7 @@ import { Input, Output, } from '@angular/core' -import { UnitConversionPipesModule } from '@start9labs/shared' +import { ConvertBytesPipe } from '@start9labs/shared' import { TuiButton } from '@taiga-ui/core' import { TuiSkeleton } from '@taiga-ui/kit' import { UnknownDisk } from 'src/app/services/api/api.types' @@ -109,7 +109,7 @@ import { UnknownDisk } from 'src/app/services/api/api.types' } `, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [TuiButton, UnitConversionPipesModule, TuiSkeleton], + imports: [TuiButton, ConvertBytesPipe, TuiSkeleton], }) export class BackupsPhysicalComponent { @Input() diff --git a/web/projects/ui/src/app/routes/portal/routes/marketplace/components/controls.component.ts b/web/projects/ui/src/app/routes/portal/routes/marketplace/components/controls.component.ts index 142ef126d..3b93150a3 100644 --- a/web/projects/ui/src/app/routes/portal/routes/marketplace/components/controls.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/marketplace/components/controls.component.ts @@ -12,7 +12,7 @@ import { MarketplacePkg } from '@start9labs/marketplace' import { ErrorService, Exver, - ExverPipesModule, + ExverComparesPipe, i18nPipe, i18nService, LoadingService, @@ -107,7 +107,7 @@ type KEYS = 'id' | 'version' | 'alerts' | 'flavor' changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CommonModule, - ExverPipesModule, + ExverComparesPipe, TuiButton, ToManifestPipe, i18nPipe, @@ -150,7 +150,11 @@ export class MarketplaceControlsComponent { const originalUrl = localPkg?.registry || null if (!localPkg) { - if (await this.alerts.alertInstall(this.i18n.localize(this.pkg().alerts.install || ''))) { + if ( + await this.alerts.alertInstall( + this.i18n.localize(this.pkg().alerts.install || ''), + ) + ) { this.installOrUpload(currentUrl) } return diff --git a/web/projects/ui/src/app/routes/portal/routes/marketplace/components/menu.component.ts b/web/projects/ui/src/app/routes/portal/routes/marketplace/components/menu.component.ts index 31694510f..9afe379b7 100644 --- a/web/projects/ui/src/app/routes/portal/routes/marketplace/components/menu.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/marketplace/components/menu.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { CommonModule } from '@angular/common' -import { MenuModule } from '@start9labs/marketplace' +import { MenuComponent } from '@start9labs/marketplace' import { TuiIcon, TuiButton, TuiAppearance } from '@taiga-ui/core' import { MARKETPLACE_REGISTRY } from '../modals/registry.component' import { MarketplaceService } from 'src/app/services/marketplace.service' @@ -41,7 +41,7 @@ import { DialogService, i18nPipe } from '@start9labs/shared' changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CommonModule, - MenuModule, + MenuComponent, TuiButton, TuiIcon, TuiAppearance, diff --git a/web/projects/ui/src/app/routes/portal/routes/marketplace/components/tile.component.ts b/web/projects/ui/src/app/routes/portal/routes/marketplace/components/tile.component.ts index 617fdf98e..6e198bac3 100644 --- a/web/projects/ui/src/app/routes/portal/routes/marketplace/components/tile.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/marketplace/components/tile.component.ts @@ -1,4 +1,3 @@ -import { CommonModule } from '@angular/common' import { ChangeDetectionStrategy, Component, @@ -8,7 +7,7 @@ import { } from '@angular/core' import { toSignal } from '@angular/core/rxjs-interop' import { ActivatedRoute, Router } from '@angular/router' -import { ItemModule, MarketplacePkg } from '@start9labs/marketplace' +import { ItemComponent, MarketplacePkg } from '@start9labs/marketplace' import { TuiAutoFocus } from '@taiga-ui/cdk' import { TuiButton, TuiDropdownService, TuiPopup } from '@taiga-ui/core' import { TuiDrawer } from '@taiga-ui/kit' @@ -78,8 +77,7 @@ import { MarketplaceSidebarService } from '../services/sidebar.service' }, ], imports: [ - CommonModule, - ItemModule, + ItemComponent, TuiAutoFocus, TuiButton, TuiPopup, diff --git a/web/projects/ui/src/app/routes/portal/routes/marketplace/marketplace.component.ts b/web/projects/ui/src/app/routes/portal/routes/marketplace/marketplace.component.ts index 8c96721cd..1d0995ac0 100644 --- a/web/projects/ui/src/app/routes/portal/routes/marketplace/marketplace.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/marketplace/marketplace.component.ts @@ -5,7 +5,6 @@ import { ActivatedRoute, Router } from '@angular/router' import { AbstractCategoryService, FilterPackagesPipe, - FilterPackagesPipeModule, } from '@start9labs/marketplace' import { i18nPipe } from '@start9labs/shared' import { TuiScrollbar } from '@taiga-ui/core' @@ -153,7 +152,7 @@ import { ConfigService } from 'src/app/services/config.service' MarketplaceMenuComponent, MarketplaceNotificationComponent, TuiScrollbar, - FilterPackagesPipeModule, + FilterPackagesPipe, TitleDirective, i18nPipe, ], diff --git a/web/projects/ui/src/app/routes/portal/routes/marketplace/modals/preview.component.ts b/web/projects/ui/src/app/routes/portal/routes/marketplace/modals/preview.component.ts index 98b3cbefa..fb55cde37 100644 --- a/web/projects/ui/src/app/routes/portal/routes/marketplace/modals/preview.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/marketplace/modals/preview.component.ts @@ -15,12 +15,7 @@ import { MarketplaceReleaseNotesComponent, MarketplaceVersionsComponent, } from '@start9labs/marketplace' -import { - DialogService, - Exver, - MARKDOWN, - SharedPipesModule, -} from '@start9labs/shared' +import { DialogService, EmptyPipe, Exver, MARKDOWN } from '@start9labs/shared' import { TuiLoader } from '@taiga-ui/core' import { BehaviorSubject, @@ -115,7 +110,7 @@ import { MarketplaceControlsComponent } from '../components/controls.component' CommonModule, MarketplacePackageHeroComponent, MarketplaceDependenciesComponent, - SharedPipesModule, + EmptyPipe, TuiLoader, MarketplaceLinksComponent, MarketplaceFlavorsComponent, diff --git a/web/projects/ui/src/app/routes/portal/routes/marketplace/modals/registry.component.ts b/web/projects/ui/src/app/routes/portal/routes/marketplace/modals/registry.component.ts index a5d29a1a1..888f0b67d 100644 --- a/web/projects/ui/src/app/routes/portal/routes/marketplace/modals/registry.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/marketplace/modals/registry.component.ts @@ -1,10 +1,7 @@ import { CommonModule } from '@angular/common' import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { Router } from '@angular/router' -import { - MarketplaceRegistryComponent, - StoreIconComponentModule, -} from '@start9labs/marketplace' +import { MarketplaceRegistryComponent } from '@start9labs/marketplace' import { DialogService, ErrorService, @@ -14,6 +11,7 @@ import { sameUrl, toUrl, } from '@start9labs/shared' +import { IST, utils } from '@start9labs/start-sdk' import { TuiButton, TuiDialogContext, TuiIcon, TuiTitle } from '@taiga-ui/core' import { TuiCell } from '@taiga-ui/layout' import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' @@ -24,7 +22,6 @@ import { ApiService } from 'src/app/services/api/embassy-api.service' import { FormDialogService } from 'src/app/services/form-dialog.service' import { MarketplaceService } from 'src/app/services/marketplace.service' import { DataModel } from 'src/app/services/patch-db/data-model' -import { IST, utils } from '@start9labs/start-sdk' import { StorageService } from 'src/app/services/storage.service' @Component({ @@ -80,7 +77,6 @@ import { StorageService } from 'src/app/services/storage.service' TuiTitle, TuiButton, MarketplaceRegistryComponent, - StoreIconComponentModule, i18nPipe, ], }) diff --git a/web/projects/ui/src/app/routes/portal/routes/sideload/package.component.ts b/web/projects/ui/src/app/routes/portal/routes/sideload/package.component.ts index 2734270f7..7ac2c3ee0 100644 --- a/web/projects/ui/src/app/routes/portal/routes/sideload/package.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/sideload/package.component.ts @@ -1,4 +1,3 @@ -import { CommonModule } from '@angular/common' import { Component, inject, input } from '@angular/core' import { MarketplaceAboutComponent, @@ -7,7 +6,7 @@ import { MarketplacePackageHeroComponent, MarketplaceReleaseNotesComponent, } from '@start9labs/marketplace' -import { DialogService, MARKDOWN, SharedPipesModule } from '@start9labs/shared' +import { DialogService, EmptyPipe, MARKDOWN } from '@start9labs/shared' import { of } from 'rxjs' import { MarketplaceControlsComponent } from '../marketplace/components/controls.component' import { MarketplacePkgSideload } from './sideload.utils' @@ -70,8 +69,7 @@ import { MarketplacePkgSideload } from './sideload.utils' } `, imports: [ - CommonModule, - SharedPipesModule, + EmptyPipe, MarketplaceAboutComponent, MarketplaceLinksComponent, MarketplacePackageHeroComponent, diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/backups.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/backups.component.ts index 604cf9918..c4b4bdc0c 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/backups.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/backups.component.ts @@ -7,12 +7,7 @@ import { } from '@angular/core' import { toSignal } from '@angular/core/rxjs-interop' import { ActivatedRoute, RouterLink } from '@angular/router' -import { - DialogService, - DocsLinkDirective, - i18nPipe, - UnitConversionPipesModule, -} from '@start9labs/shared' +import { DialogService, DocsLinkDirective, i18nPipe } from '@start9labs/shared' import { TuiMapperPipe } from '@taiga-ui/cdk' import { TuiButton, @@ -150,7 +145,6 @@ import { BACKUP_RESTORE } from './restore.component' TuiNotification, TuiMapperPipe, TitleDirective, - UnitConversionPipesModule, BackupNetworkComponent, BackupPhysicalComponent, BackupProgressComponent, diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/physical.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/physical.component.ts index c74f526c6..2508656ac 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/physical.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/physical.component.ts @@ -5,11 +5,7 @@ import { output, } from '@angular/core' import { ActivatedRoute } from '@angular/router' -import { - DialogService, - i18nPipe, - UnitConversionPipesModule, -} from '@start9labs/shared' +import { ConvertBytesPipe, DialogService, i18nPipe } from '@start9labs/shared' import { TuiButton, TuiIcon } from '@taiga-ui/core' import { TuiTooltip } from '@taiga-ui/kit' import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component' @@ -115,7 +111,7 @@ import { BackupStatusComponent } from './status.component' TuiButton, TuiIcon, TuiTooltip, - UnitConversionPipesModule, + ConvertBytesPipe, PlaceholderComponent, BackupStatusComponent, TableComponent, diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/dns/dns.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/dns/dns.component.ts index 59b65b58c..091856bb4 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/dns/dns.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/dns/dns.component.ts @@ -1,4 +1,3 @@ -import { CommonModule } from '@angular/common' import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { toSignal } from '@angular/core/rxjs-interop' import { FormsModule, ReactiveFormsModule } from '@angular/forms' @@ -94,7 +93,6 @@ const ipv6 = `, changeDetection: ChangeDetectionStrategy.OnPush, imports: [ - CommonModule, FormsModule, ReactiveFormsModule, FormGroupComponent, diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/gateways.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/gateways.component.ts index cf41148b7..1b96e5f07 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/gateways.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/gateways.component.ts @@ -1,4 +1,3 @@ -import { CommonModule } from '@angular/common' import { ChangeDetectionStrategy, Component, @@ -144,7 +143,6 @@ import { GatewaysTableComponent } from './table.component' changeDetection: ChangeDetectionStrategy.OnPush, providers: [GatewayService], imports: [ - CommonModule, FormsModule, RouterLink, TuiButton, diff --git a/web/projects/ui/src/app/routes/portal/routes/updates/updates.component.ts b/web/projects/ui/src/app/routes/portal/routes/updates/updates.component.ts index d5e97ddc2..d3fe91d64 100644 --- a/web/projects/ui/src/app/routes/portal/routes/updates/updates.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/updates/updates.component.ts @@ -7,7 +7,7 @@ import { import { toSignal } from '@angular/core/rxjs-interop' import { Marketplace, - StoreIconComponentModule, + StoreIconComponent, StoreIdentity, } from '@start9labs/marketplace' import { TUI_IS_MOBILE } from '@taiga-ui/cdk' @@ -230,7 +230,7 @@ interface UpdatesData { TuiBadgeNotification, TuiFade, TuiButton, - StoreIconComponentModule, + StoreIconComponent, FilterUpdatesPipe, UpdatesItemComponent, TitleDirective, diff --git a/web/projects/ui/src/main.ts b/web/projects/ui/src/main.ts index 21499c3cd..4d81e24b0 100644 --- a/web/projects/ui/src/main.ts +++ b/web/projects/ui/src/main.ts @@ -1,12 +1,11 @@ import { enableProdMode } from '@angular/core' -import { platformBrowserDynamic } from '@angular/platform-browser-dynamic' -import { AppModule } from './app/app.module' -import { environment } from './environments/environment' +import { bootstrapApplication } from '@angular/platform-browser' +import { AppComponent } from 'src/app/app.component' +import { APP_CONFIG } from 'src/app/app.config' +import { environment } from 'src/environments/environment' if (environment.production) { enableProdMode() } -platformBrowserDynamic() - .bootstrapModule(AppModule) - .catch(err => console.error(err)) +bootstrapApplication(AppComponent, APP_CONFIG).catch(console.error) diff --git a/web/tsconfig.json b/web/tsconfig.json index a32787583..71c58df37 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -19,7 +19,6 @@ "importHelpers": true, "target": "es2022", "module": "es2020", - "lib": ["es2020", "dom"], "useDefineForClassFields": false, "paths": { /* These paths are relative to each app base folder */ From fd54e9ca918a8d7f773b4024e89a16713150ca81 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Thu, 12 Mar 2026 13:37:35 -0600 Subject: [PATCH 36/71] fix: use raspberrypi-archive-keyring for sqv-compatible GPG key The old raspberrypi.gpg.key has SHA1-only UID binding signatures, which sqv (Sequoia PGP) on Trixie rejects as of 2026-02-01. Fetch the key from the raspberrypi-archive-keyring package instead, which has re-signed bindings using SHA-256/512. --- build/image-recipe/build.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/build/image-recipe/build.sh b/build/image-recipe/build.sh index bc6fa43e7..bde6fd360 100755 --- a/build/image-recipe/build.sh +++ b/build/image-recipe/build.sh @@ -176,7 +176,13 @@ sed -i -e '2i set timeout=5' config/bootloaders/grub-pc/config.cfg mkdir -p config/archives if [ "${IB_TARGET_PLATFORM}" = "raspberrypi" ]; then - curl -fsSL https://archive.raspberrypi.com/debian/raspberrypi.gpg.key | gpg --dearmor -o config/archives/raspi.key + # Fetch the keyring package (not the old raspberrypi.gpg.key, which has + # SHA1-only binding signatures that sqv on Trixie rejects). + KEYRING_DEB=$(mktemp) + curl -fsSL -o "$KEYRING_DEB" https://archive.raspberrypi.com/debian/pool/main/r/raspberrypi-archive-keyring/raspberrypi-archive-keyring_2025.1+rpt1_all.deb + dpkg-deb -x "$KEYRING_DEB" "$KEYRING_DEB.d" + cp "$KEYRING_DEB.d/usr/share/keyrings/raspberrypi-archive-keyring.gpg" config/archives/raspi.key + rm -rf "$KEYRING_DEB" "$KEYRING_DEB.d" echo "deb [arch=${IB_TARGET_ARCH} signed-by=/etc/apt/trusted.gpg.d/raspi.key.gpg] https://archive.raspberrypi.com/debian/ ${IB_SUITE} main" > config/archives/raspi.list fi From c485edfa120c23ecb63b311bcf2f4c074bb00a43 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Thu, 12 Mar 2026 13:38:01 -0600 Subject: [PATCH 37/71] feat: tunnel TS exports, port forward labels, and db migrations - Add TS derive and type annotations to all tunnel API param structs - Export tunnel bindings to a tunnel/ subdirectory with index generation - Change port forward label from String to Option - Add TunnelDatabase::init() with default subnet creation - Add tunnel migration framework with m_00_port_forward_entry migration to convert legacy string-only port forwards to the new entry format --- Makefile | 4 ++ core/src/tunnel/api.rs | 48 +++++++++++++------ core/src/tunnel/context.rs | 25 ++++------ core/src/tunnel/db.rs | 48 +++++++++++++++++-- .../migrations/m_00_port_forward_entry.rs | 20 ++++++++ core/src/tunnel/migrations/mod.rs | 34 +++++++++++++ core/src/tunnel/mod.rs | 1 + .../lib/osBindings/AddPackageSignerParams.ts | 2 +- sdk/base/lib/osBindings/ServerInfo.ts | 2 +- sdk/base/lib/osBindings/index.ts | 1 + .../lib/osBindings/tunnel/AddDeviceParams.ts | 7 +++ .../lib/osBindings/tunnel/AddKeyParams.ts | 4 ++ .../osBindings/tunnel/AddPortForwardParams.ts | 7 +++ .../lib/osBindings/tunnel/AddSubnetParams.ts | 3 ++ sdk/base/lib/osBindings/tunnel/GatewayId.ts | 3 ++ sdk/base/lib/osBindings/tunnel/GatewayType.ts | 3 ++ sdk/base/lib/osBindings/tunnel/IpInfo.ts | 13 +++++ .../osBindings/tunnel/ListDevicesParams.ts | 3 ++ .../osBindings/tunnel/NetworkInterfaceInfo.ts | 10 ++++ .../osBindings/tunnel/NetworkInterfaceType.ts | 8 ++++ .../lib/osBindings/tunnel/PortForwardEntry.ts | 7 +++ .../lib/osBindings/tunnel/PortForwards.ts | 3 +- .../osBindings/tunnel/RemoveDeviceParams.ts | 3 ++ .../lib/osBindings/tunnel/RemoveKeyParams.ts | 4 ++ .../tunnel/RemovePortForwardParams.ts | 3 ++ .../osBindings/tunnel/SetPasswordParams.ts | 3 ++ .../tunnel/SetPortForwardEnabledParams.ts | 3 ++ .../lib/osBindings/tunnel/ShowConfigParams.ts | 7 +++ .../lib/osBindings/tunnel/SubnetParams.ts | 3 ++ .../lib/osBindings/tunnel/TunnelDatabase.ts | 4 +- .../tunnel/UpdatePortForwardLabelParams.ts | 6 +++ sdk/base/lib/osBindings/tunnel/index.ts | 35 ++++++++++++++ 32 files changed, 288 insertions(+), 39 deletions(-) create mode 100644 core/src/tunnel/migrations/m_00_port_forward_entry.rs create mode 100644 core/src/tunnel/migrations/mod.rs create mode 100644 sdk/base/lib/osBindings/tunnel/AddDeviceParams.ts create mode 100644 sdk/base/lib/osBindings/tunnel/AddKeyParams.ts create mode 100644 sdk/base/lib/osBindings/tunnel/AddPortForwardParams.ts create mode 100644 sdk/base/lib/osBindings/tunnel/AddSubnetParams.ts create mode 100644 sdk/base/lib/osBindings/tunnel/GatewayId.ts create mode 100644 sdk/base/lib/osBindings/tunnel/GatewayType.ts create mode 100644 sdk/base/lib/osBindings/tunnel/IpInfo.ts create mode 100644 sdk/base/lib/osBindings/tunnel/ListDevicesParams.ts create mode 100644 sdk/base/lib/osBindings/tunnel/NetworkInterfaceInfo.ts create mode 100644 sdk/base/lib/osBindings/tunnel/NetworkInterfaceType.ts create mode 100644 sdk/base/lib/osBindings/tunnel/PortForwardEntry.ts create mode 100644 sdk/base/lib/osBindings/tunnel/RemoveDeviceParams.ts create mode 100644 sdk/base/lib/osBindings/tunnel/RemoveKeyParams.ts create mode 100644 sdk/base/lib/osBindings/tunnel/RemovePortForwardParams.ts create mode 100644 sdk/base/lib/osBindings/tunnel/SetPasswordParams.ts create mode 100644 sdk/base/lib/osBindings/tunnel/SetPortForwardEnabledParams.ts create mode 100644 sdk/base/lib/osBindings/tunnel/ShowConfigParams.ts create mode 100644 sdk/base/lib/osBindings/tunnel/SubnetParams.ts create mode 100644 sdk/base/lib/osBindings/tunnel/UpdatePortForwardLabelParams.ts create mode 100644 sdk/base/lib/osBindings/tunnel/index.ts diff --git a/Makefile b/Makefile index 07aabb16f..d04824d67 100644 --- a/Makefile +++ b/Makefile @@ -283,6 +283,10 @@ core/bindings/index.ts: $(call ls-files, core) $(ENVIRONMENT_FILE) rm -rf core/bindings ./core/build/build-ts.sh ls core/bindings/*.ts | sed 's/core\/bindings\/\([^.]*\)\.ts/export { \1 } from ".\/\1";/g' | grep -v '"./index"' | tee core/bindings/index.ts + if [ -d core/bindings/tunnel ]; then \ + ls core/bindings/tunnel/*.ts | sed 's/core\/bindings\/tunnel\/\([^.]*\)\.ts/export { \1 } from ".\/\1";/g' | grep -v '"./index"' > core/bindings/tunnel/index.ts; \ + echo 'export * as Tunnel from "./tunnel";' >> core/bindings/index.ts; \ + fi npm --prefix sdk/base exec -- prettier --config=./sdk/base/package.json -w './core/bindings/**/*.ts' touch core/bindings/index.ts diff --git a/core/src/tunnel/api.rs b/core/src/tunnel/api.rs index 523dc3900..51fff1714 100644 --- a/core/src/tunnel/api.rs +++ b/core/src/tunnel/api.rs @@ -5,6 +5,7 @@ use imbl_value::InternedString; use ipnet::Ipv4Net; use rpc_toolkit::{Context, Empty, HandlerArgs, HandlerExt, ParentHandler, from_fn_async}; use serde::{Deserialize, Serialize}; +use ts_rs::TS; use crate::context::CliContext; use crate::db::model::public::NetworkInterfaceType; @@ -90,9 +91,10 @@ pub fn tunnel_api() -> ParentHandler { ) } -#[derive(Deserialize, Serialize, Parser)] +#[derive(Deserialize, Serialize, Parser, TS)] #[serde(rename_all = "camelCase")] pub struct SubnetParams { + #[ts(type = "string")] subnet: Ipv4Net, } @@ -168,7 +170,7 @@ pub fn device_api() -> ParentHandler { ) } -#[derive(Deserialize, Serialize, Parser)] +#[derive(Deserialize, Serialize, Parser, TS)] #[serde(rename_all = "camelCase")] pub struct AddSubnetParams { name: InternedString, @@ -293,11 +295,13 @@ pub async fn remove_subnet( Ok(()) } -#[derive(Deserialize, Serialize, Parser)] +#[derive(Deserialize, Serialize, Parser, TS)] #[serde(rename_all = "camelCase")] pub struct AddDeviceParams { + #[ts(type = "string")] subnet: Ipv4Net, name: InternedString, + #[ts(type = "string | null")] ip: Option, } @@ -354,10 +358,12 @@ pub async fn add_device( server.sync().await } -#[derive(Deserialize, Serialize, Parser)] +#[derive(Deserialize, Serialize, Parser, TS)] #[serde(rename_all = "camelCase")] pub struct RemoveDeviceParams { + #[ts(type = "string")] subnet: Ipv4Net, + #[ts(type = "string")] ip: Ipv4Addr, } @@ -383,9 +389,10 @@ pub async fn remove_device( ctx.gc_forwards(&keep).await } -#[derive(Deserialize, Serialize, Parser)] +#[derive(Deserialize, Serialize, Parser, TS)] #[serde(rename_all = "camelCase")] pub struct ListDevicesParams { + #[ts(type = "string")] subnet: Ipv4Net, } @@ -403,14 +410,18 @@ pub async fn list_devices( .de() } -#[derive(Deserialize, Serialize, Parser)] +#[derive(Deserialize, Serialize, Parser, TS)] #[serde(rename_all = "camelCase")] pub struct ShowConfigParams { + #[ts(type = "string")] subnet: Ipv4Net, + #[ts(type = "string")] ip: Ipv4Addr, + #[ts(type = "string | null")] wan_addr: Option, #[serde(rename = "__ConnectInfo_local_addr")] #[arg(skip)] + #[ts(skip)] local_addr: Option, } @@ -465,13 +476,15 @@ pub async fn show_config( .to_string()) } -#[derive(Deserialize, Serialize, Parser)] +#[derive(Deserialize, Serialize, Parser, TS)] #[serde(rename_all = "camelCase")] pub struct AddPortForwardParams { + #[ts(type = "string")] source: SocketAddrV4, + #[ts(type = "string")] target: SocketAddrV4, #[arg(long)] - label: String, + label: Option, } pub async fn add_forward( @@ -505,7 +518,11 @@ pub async fn add_forward( m.insert(source, rc); }); - let entry = PortForwardEntry { target, label, enabled: true }; + let entry = PortForwardEntry { + target, + label, + enabled: true, + }; ctx.db .mutate(|db| { @@ -528,9 +545,10 @@ pub async fn add_forward( Ok(()) } -#[derive(Deserialize, Serialize, Parser)] +#[derive(Deserialize, Serialize, Parser, TS)] #[serde(rename_all = "camelCase")] pub struct RemovePortForwardParams { + #[ts(type = "string")] source: SocketAddrV4, } @@ -549,11 +567,12 @@ pub async fn remove_forward( Ok(()) } -#[derive(Deserialize, Serialize, Parser)] +#[derive(Deserialize, Serialize, Parser, TS)] #[serde(rename_all = "camelCase")] pub struct UpdatePortForwardLabelParams { + #[ts(type = "string")] source: SocketAddrV4, - label: String, + label: Option, } pub async fn update_forward_label( @@ -569,7 +588,7 @@ pub async fn update_forward_label( ErrorKind::NotFound, ) })?; - entry.label = label.clone(); + entry.label = label; Ok(()) }) }) @@ -577,9 +596,10 @@ pub async fn update_forward_label( .result } -#[derive(Deserialize, Serialize, Parser)] +#[derive(Deserialize, Serialize, Parser, TS)] #[serde(rename_all = "camelCase")] pub struct SetPortForwardEnabledParams { + #[ts(type = "string")] source: SocketAddrV4, enabled: bool, } diff --git a/core/src/tunnel/context.rs b/core/src/tunnel/context.rs index 769f62787..1cb23c49e 100644 --- a/core/src/tunnel/context.rs +++ b/core/src/tunnel/context.rs @@ -10,8 +10,8 @@ use http::HeaderMap; use imbl::OrdMap; use imbl_value::InternedString; use include_dir::Dir; -use ipnet::Ipv4Net; use patch_db::PatchDb; +use patch_db::json_ptr::ROOT; use rpc_toolkit::yajrc::RpcError; use rpc_toolkit::{CallRemote, Context, Empty, ParentHandler}; use serde::{Deserialize, Serialize}; @@ -34,7 +34,8 @@ use crate::rpc_continuations::{OpenAuthedContinuations, RpcContinuations}; use crate::tunnel::TUNNEL_DEFAULT_LISTEN; use crate::tunnel::api::tunnel_api; use crate::tunnel::db::TunnelDatabase; -use crate::tunnel::wg::{WIREGUARD_INTERFACE_NAME, WgSubnetConfig}; +use crate::tunnel::migrations::run_migrations; +use crate::tunnel::wg::WIREGUARD_INTERFACE_NAME; use crate::util::collections::OrdMapIterMut; use crate::util::io::read_file_to_string; use crate::util::sync::{SyncMutex, Watch}; @@ -98,21 +99,11 @@ impl TunnelContext { tokio::fs::create_dir_all(&datadir).await?; } let db_path = datadir.join("tunnel.db"); - let db = TypedPatchDb::::load_or_init( - PatchDb::open(&db_path).await?, - || async { - let mut db = TunnelDatabase::default(); - db.wg.subnets.0.insert( - Ipv4Net::new_assert([10, 59, rand::random(), 1].into(), 24), - WgSubnetConfig { - name: "Default Subnet".into(), - ..Default::default() - }, - ); - Ok(db) - }, - ) - .await?; + let db = TypedPatchDb::::load_unchecked(PatchDb::open(&db_path).await?); + if db.dump(&ROOT).await.value.is_null() { + db.put(&ROOT, &TunnelDatabase::init()).await?; + } + db.mutate(|db| run_migrations(db)).await.result?; let listen = config.tunnel_listen.unwrap_or(TUNNEL_DEFAULT_LISTEN); let ip_info = crate::net::utils::load_ip_info().await?; let net_iface = db diff --git a/core/src/tunnel/db.rs b/core/src/tunnel/db.rs index b18c01abf..46197ce84 100644 --- a/core/src/tunnel/db.rs +++ b/core/src/tunnel/db.rs @@ -7,6 +7,7 @@ use axum::extract::ws; use clap::Parser; use imbl::{HashMap, OrdMap}; use imbl_value::InternedString; +use ipnet::Ipv4Net; use itertools::Itertools; use patch_db::Dump; use patch_db::json_ptr::{JsonPointer, ROOT}; @@ -25,25 +26,49 @@ use crate::rpc_continuations::{Guid, RpcContinuation}; use crate::sign::AnyVerifyingKey; use crate::tunnel::auth::SignerInfo; use crate::tunnel::context::TunnelContext; +use crate::tunnel::migrations; use crate::tunnel::web::WebserverInfo; -use crate::tunnel::wg::WgServer; +use crate::tunnel::wg::{WgServer, WgSubnetConfig}; use crate::util::serde::{HandlerExtSerde, apply_expr}; #[derive(Default, Deserialize, Serialize, HasModel, TS)] #[serde(rename_all = "camelCase")] #[model = "Model"] pub struct TunnelDatabase { + #[serde(default)] + #[ts(skip)] + pub migrations: BTreeSet, pub webserver: WebserverInfo, pub sessions: Sessions, pub password: Option, #[ts(as = "std::collections::HashMap::")] pub auth_pubkeys: HashMap, - #[ts(as = "std::collections::BTreeMap::")] + #[ts(as = "std::collections::BTreeMap::")] pub gateways: OrdMap, pub wg: WgServer, pub port_forwards: PortForwards, } +impl TunnelDatabase { + pub fn init() -> Self { + let mut db = Self { + migrations: migrations::MIGRATIONS + .iter() + .map(|m| m.name().into()) + .collect(), + ..Default::default() + }; + db.wg.subnets.0.insert( + Ipv4Net::new_assert([10, 59, rand::random(), 1].into(), 24), + WgSubnetConfig { + name: "Default Subnet".into(), + ..Default::default() + }, + ); + db + } +} + impl Model { pub fn gc_forwards(&mut self) -> Result, Error> { let mut keep_sources = BTreeSet::new(); @@ -67,15 +92,30 @@ impl Model { #[test] fn export_bindings_tunnel_db() { + use crate::tunnel::api::*; + use crate::tunnel::auth::{AddKeyParams, RemoveKeyParams, SetPasswordParams}; + TunnelDatabase::export_all_to("bindings/tunnel").unwrap(); + SubnetParams::export_all_to("bindings/tunnel").unwrap(); + AddSubnetParams::export_all_to("bindings/tunnel").unwrap(); + AddDeviceParams::export_all_to("bindings/tunnel").unwrap(); + RemoveDeviceParams::export_all_to("bindings/tunnel").unwrap(); + ListDevicesParams::export_all_to("bindings/tunnel").unwrap(); + ShowConfigParams::export_all_to("bindings/tunnel").unwrap(); + AddPortForwardParams::export_all_to("bindings/tunnel").unwrap(); + RemovePortForwardParams::export_all_to("bindings/tunnel").unwrap(); + UpdatePortForwardLabelParams::export_all_to("bindings/tunnel").unwrap(); + SetPortForwardEnabledParams::export_all_to("bindings/tunnel").unwrap(); + AddKeyParams::export_all_to("bindings/tunnel").unwrap(); + RemoveKeyParams::export_all_to("bindings/tunnel").unwrap(); + SetPasswordParams::export_all_to("bindings/tunnel").unwrap(); } #[derive(Clone, Debug, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase")] pub struct PortForwardEntry { pub target: SocketAddrV4, - #[serde(default)] - pub label: String, + pub label: Option, #[serde(default = "default_true")] pub enabled: bool, } diff --git a/core/src/tunnel/migrations/m_00_port_forward_entry.rs b/core/src/tunnel/migrations/m_00_port_forward_entry.rs new file mode 100644 index 000000000..32603ea9d --- /dev/null +++ b/core/src/tunnel/migrations/m_00_port_forward_entry.rs @@ -0,0 +1,20 @@ +use imbl_value::json; + +use super::TunnelMigration; +use crate::prelude::*; + +pub struct PortForwardEntry; +impl TunnelMigration for PortForwardEntry { + fn action(&self, db: &mut Value) -> Result<(), Error> { + for (_, value) in db["portForwards"].as_object_mut().unwrap().iter_mut() { + if value.is_string() { + *value = json!({ + "target": value.clone(), + "label": null, + "enabled": true, + }); + } + } + Ok(()) + } +} diff --git a/core/src/tunnel/migrations/mod.rs b/core/src/tunnel/migrations/mod.rs new file mode 100644 index 000000000..79c60403c --- /dev/null +++ b/core/src/tunnel/migrations/mod.rs @@ -0,0 +1,34 @@ +use patch_db::ModelExt; + +use crate::prelude::*; +use crate::tunnel::db::TunnelDatabase; + +mod m_00_port_forward_entry; + +pub trait TunnelMigration { + fn name(&self) -> &'static str { + let val = std::any::type_name_of_val(self); + val.rsplit_once("::").map_or(val, |v| v.1) + } + fn action(&self, db: &mut Value) -> Result<(), Error>; +} + +pub const MIGRATIONS: &[&dyn TunnelMigration] = &[ + &m_00_port_forward_entry::PortForwardEntry, +]; + +#[instrument(skip_all)] +pub fn run_migrations(db: &mut Model) -> Result<(), Error> { + let mut migrations = db.as_migrations().de().unwrap_or_default(); + for migration in MIGRATIONS { + let name = migration.name(); + if !migrations.contains(name) { + migration.action(ModelExt::as_value_mut(db))?; + migrations.insert(name.into()); + } + } + let mut db_deser = db.de()?; + db_deser.migrations = migrations; + db.ser(&db_deser)?; + Ok(()) +} diff --git a/core/src/tunnel/mod.rs b/core/src/tunnel/mod.rs index 5d69de7c0..ffb3f89b5 100644 --- a/core/src/tunnel/mod.rs +++ b/core/src/tunnel/mod.rs @@ -9,6 +9,7 @@ pub mod api; pub mod auth; pub mod context; pub mod db; +pub(crate) mod migrations; pub mod update; pub mod web; pub mod wg; diff --git a/sdk/base/lib/osBindings/AddPackageSignerParams.ts b/sdk/base/lib/osBindings/AddPackageSignerParams.ts index e9a7788ff..6baebf0c8 100644 --- a/sdk/base/lib/osBindings/AddPackageSignerParams.ts +++ b/sdk/base/lib/osBindings/AddPackageSignerParams.ts @@ -6,5 +6,5 @@ export type AddPackageSignerParams = { id: PackageId signer: Guid versions: string | null - merge?: boolean + merge: boolean } diff --git a/sdk/base/lib/osBindings/ServerInfo.ts b/sdk/base/lib/osBindings/ServerInfo.ts index a0eb98e0a..540110109 100644 --- a/sdk/base/lib/osBindings/ServerInfo.ts +++ b/sdk/base/lib/osBindings/ServerInfo.ts @@ -26,7 +26,7 @@ export type ServerInfo = { zram: boolean governor: Governor | null smtp: SmtpValue | null - ifconfigUrl: string + echoipUrls: string[] ram: number devices: Array kiosk: boolean | null diff --git a/sdk/base/lib/osBindings/index.ts b/sdk/base/lib/osBindings/index.ts index 3df8c985f..25e45f0f0 100644 --- a/sdk/base/lib/osBindings/index.ts +++ b/sdk/base/lib/osBindings/index.ts @@ -306,3 +306,4 @@ export { WifiInfo } from './WifiInfo' export { WifiListInfo } from './WifiListInfo' export { WifiListOut } from './WifiListOut' export { WifiSsidParams } from './WifiSsidParams' +export * as Tunnel from './tunnel' diff --git a/sdk/base/lib/osBindings/tunnel/AddDeviceParams.ts b/sdk/base/lib/osBindings/tunnel/AddDeviceParams.ts new file mode 100644 index 000000000..c5ff2738d --- /dev/null +++ b/sdk/base/lib/osBindings/tunnel/AddDeviceParams.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AddDeviceParams = { + subnet: string + name: string + ip: string | null +} diff --git a/sdk/base/lib/osBindings/tunnel/AddKeyParams.ts b/sdk/base/lib/osBindings/tunnel/AddKeyParams.ts new file mode 100644 index 000000000..5bb62746d --- /dev/null +++ b/sdk/base/lib/osBindings/tunnel/AddKeyParams.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AnyVerifyingKey } from './AnyVerifyingKey' + +export type AddKeyParams = { name: string; key: AnyVerifyingKey } diff --git a/sdk/base/lib/osBindings/tunnel/AddPortForwardParams.ts b/sdk/base/lib/osBindings/tunnel/AddPortForwardParams.ts new file mode 100644 index 000000000..ea50dca51 --- /dev/null +++ b/sdk/base/lib/osBindings/tunnel/AddPortForwardParams.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AddPortForwardParams = { + source: string + target: string + label: string | null +} diff --git a/sdk/base/lib/osBindings/tunnel/AddSubnetParams.ts b/sdk/base/lib/osBindings/tunnel/AddSubnetParams.ts new file mode 100644 index 000000000..8790ad8a4 --- /dev/null +++ b/sdk/base/lib/osBindings/tunnel/AddSubnetParams.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AddSubnetParams = { name: string } diff --git a/sdk/base/lib/osBindings/tunnel/GatewayId.ts b/sdk/base/lib/osBindings/tunnel/GatewayId.ts new file mode 100644 index 000000000..1b0cc9b38 --- /dev/null +++ b/sdk/base/lib/osBindings/tunnel/GatewayId.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type GatewayId = string diff --git a/sdk/base/lib/osBindings/tunnel/GatewayType.ts b/sdk/base/lib/osBindings/tunnel/GatewayType.ts new file mode 100644 index 000000000..aa7a2d6ed --- /dev/null +++ b/sdk/base/lib/osBindings/tunnel/GatewayType.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type GatewayType = 'inbound-outbound' | 'outbound-only' diff --git a/sdk/base/lib/osBindings/tunnel/IpInfo.ts b/sdk/base/lib/osBindings/tunnel/IpInfo.ts new file mode 100644 index 000000000..8cc7e206e --- /dev/null +++ b/sdk/base/lib/osBindings/tunnel/IpInfo.ts @@ -0,0 +1,13 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { NetworkInterfaceType } from './NetworkInterfaceType' + +export type IpInfo = { + name: string + scopeId: number + deviceType: NetworkInterfaceType | null + subnets: string[] + lanIp: string[] + wanIp: string | null + ntpServers: string[] + dnsServers: string[] +} diff --git a/sdk/base/lib/osBindings/tunnel/ListDevicesParams.ts b/sdk/base/lib/osBindings/tunnel/ListDevicesParams.ts new file mode 100644 index 000000000..2e2c17085 --- /dev/null +++ b/sdk/base/lib/osBindings/tunnel/ListDevicesParams.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ListDevicesParams = { subnet: string } diff --git a/sdk/base/lib/osBindings/tunnel/NetworkInterfaceInfo.ts b/sdk/base/lib/osBindings/tunnel/NetworkInterfaceInfo.ts new file mode 100644 index 000000000..a57f3c1e9 --- /dev/null +++ b/sdk/base/lib/osBindings/tunnel/NetworkInterfaceInfo.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GatewayType } from './GatewayType' +import type { IpInfo } from './IpInfo' + +export type NetworkInterfaceInfo = { + name: string | null + secure: boolean | null + ipInfo: IpInfo | null + type: GatewayType | null +} diff --git a/sdk/base/lib/osBindings/tunnel/NetworkInterfaceType.ts b/sdk/base/lib/osBindings/tunnel/NetworkInterfaceType.ts new file mode 100644 index 000000000..6c0d9c363 --- /dev/null +++ b/sdk/base/lib/osBindings/tunnel/NetworkInterfaceType.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type NetworkInterfaceType = + | 'ethernet' + | 'wireless' + | 'bridge' + | 'wireguard' + | 'loopback' diff --git a/sdk/base/lib/osBindings/tunnel/PortForwardEntry.ts b/sdk/base/lib/osBindings/tunnel/PortForwardEntry.ts new file mode 100644 index 000000000..1619d3f40 --- /dev/null +++ b/sdk/base/lib/osBindings/tunnel/PortForwardEntry.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PortForwardEntry = { + target: string + label: string | null + enabled: boolean +} diff --git a/sdk/base/lib/osBindings/tunnel/PortForwards.ts b/sdk/base/lib/osBindings/tunnel/PortForwards.ts index aa9991452..f2d249dd7 100644 --- a/sdk/base/lib/osBindings/tunnel/PortForwards.ts +++ b/sdk/base/lib/osBindings/tunnel/PortForwards.ts @@ -1,3 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PortForwardEntry } from './PortForwardEntry' -export type PortForwards = { [key: string]: string } +export type PortForwards = { [key: string]: PortForwardEntry } diff --git a/sdk/base/lib/osBindings/tunnel/RemoveDeviceParams.ts b/sdk/base/lib/osBindings/tunnel/RemoveDeviceParams.ts new file mode 100644 index 000000000..5fb6bb42c --- /dev/null +++ b/sdk/base/lib/osBindings/tunnel/RemoveDeviceParams.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type RemoveDeviceParams = { subnet: string; ip: string } diff --git a/sdk/base/lib/osBindings/tunnel/RemoveKeyParams.ts b/sdk/base/lib/osBindings/tunnel/RemoveKeyParams.ts new file mode 100644 index 000000000..cb1cf9049 --- /dev/null +++ b/sdk/base/lib/osBindings/tunnel/RemoveKeyParams.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AnyVerifyingKey } from './AnyVerifyingKey' + +export type RemoveKeyParams = { key: AnyVerifyingKey } diff --git a/sdk/base/lib/osBindings/tunnel/RemovePortForwardParams.ts b/sdk/base/lib/osBindings/tunnel/RemovePortForwardParams.ts new file mode 100644 index 000000000..2e85f5e77 --- /dev/null +++ b/sdk/base/lib/osBindings/tunnel/RemovePortForwardParams.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type RemovePortForwardParams = { source: string } diff --git a/sdk/base/lib/osBindings/tunnel/SetPasswordParams.ts b/sdk/base/lib/osBindings/tunnel/SetPasswordParams.ts new file mode 100644 index 000000000..f92cb8e7a --- /dev/null +++ b/sdk/base/lib/osBindings/tunnel/SetPasswordParams.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SetPasswordParams = { password: string } diff --git a/sdk/base/lib/osBindings/tunnel/SetPortForwardEnabledParams.ts b/sdk/base/lib/osBindings/tunnel/SetPortForwardEnabledParams.ts new file mode 100644 index 000000000..51f923436 --- /dev/null +++ b/sdk/base/lib/osBindings/tunnel/SetPortForwardEnabledParams.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SetPortForwardEnabledParams = { source: string; enabled: boolean } diff --git a/sdk/base/lib/osBindings/tunnel/ShowConfigParams.ts b/sdk/base/lib/osBindings/tunnel/ShowConfigParams.ts new file mode 100644 index 000000000..3f7eecf25 --- /dev/null +++ b/sdk/base/lib/osBindings/tunnel/ShowConfigParams.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ShowConfigParams = { + subnet: string + ip: string + wanAddr: string | null +} diff --git a/sdk/base/lib/osBindings/tunnel/SubnetParams.ts b/sdk/base/lib/osBindings/tunnel/SubnetParams.ts new file mode 100644 index 000000000..72981f8ae --- /dev/null +++ b/sdk/base/lib/osBindings/tunnel/SubnetParams.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubnetParams = { subnet: string } diff --git a/sdk/base/lib/osBindings/tunnel/TunnelDatabase.ts b/sdk/base/lib/osBindings/tunnel/TunnelDatabase.ts index 2f484b5b7..74b8eacd9 100644 --- a/sdk/base/lib/osBindings/tunnel/TunnelDatabase.ts +++ b/sdk/base/lib/osBindings/tunnel/TunnelDatabase.ts @@ -1,5 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AnyVerifyingKey } from './AnyVerifyingKey' +import type { GatewayId } from './GatewayId' +import type { NetworkInterfaceInfo } from './NetworkInterfaceInfo' import type { PortForwards } from './PortForwards' import type { Sessions } from './Sessions' import type { SignerInfo } from './SignerInfo' @@ -11,7 +13,7 @@ export type TunnelDatabase = { sessions: Sessions password: string | null authPubkeys: { [key: AnyVerifyingKey]: SignerInfo } - gateways: { [key: AnyVerifyingKey]: SignerInfo } + gateways: { [key: GatewayId]: NetworkInterfaceInfo } wg: WgServer portForwards: PortForwards } diff --git a/sdk/base/lib/osBindings/tunnel/UpdatePortForwardLabelParams.ts b/sdk/base/lib/osBindings/tunnel/UpdatePortForwardLabelParams.ts new file mode 100644 index 000000000..1697a1250 --- /dev/null +++ b/sdk/base/lib/osBindings/tunnel/UpdatePortForwardLabelParams.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type UpdatePortForwardLabelParams = { + source: string + label: string | null +} diff --git a/sdk/base/lib/osBindings/tunnel/index.ts b/sdk/base/lib/osBindings/tunnel/index.ts new file mode 100644 index 000000000..7c92639bb --- /dev/null +++ b/sdk/base/lib/osBindings/tunnel/index.ts @@ -0,0 +1,35 @@ +export { AddDeviceParams } from './AddDeviceParams' +export { AddKeyParams } from './AddKeyParams' +export { AddPortForwardParams } from './AddPortForwardParams' +export { AddSubnetParams } from './AddSubnetParams' +export { AnyVerifyingKey } from './AnyVerifyingKey' +export { Base64 } from './Base64' +export { GatewayId } from './GatewayId' +export { GatewayType } from './GatewayType' +export { IpInfo } from './IpInfo' +export { ListDevicesParams } from './ListDevicesParams' +export { NetworkInterfaceInfo } from './NetworkInterfaceInfo' +export { NetworkInterfaceType } from './NetworkInterfaceType' +export { Pem } from './Pem' +export { PortForwardEntry } from './PortForwardEntry' +export { PortForwards } from './PortForwards' +export { RemoveDeviceParams } from './RemoveDeviceParams' +export { RemoveKeyParams } from './RemoveKeyParams' +export { RemovePortForwardParams } from './RemovePortForwardParams' +export { Sessions } from './Sessions' +export { Session } from './Session' +export { SetPasswordParams } from './SetPasswordParams' +export { SetPortForwardEnabledParams } from './SetPortForwardEnabledParams' +export { ShowConfigParams } from './ShowConfigParams' +export { SignerInfo } from './SignerInfo' +export { SubnetParams } from './SubnetParams' +export { TunnelCertData } from './TunnelCertData' +export { TunnelDatabase } from './TunnelDatabase' +export { TunnelUpdateResult } from './TunnelUpdateResult' +export { UpdatePortForwardLabelParams } from './UpdatePortForwardLabelParams' +export { WebserverInfo } from './WebserverInfo' +export { WgConfig } from './WgConfig' +export { WgServer } from './WgServer' +export { WgSubnetClients } from './WgSubnetClients' +export { WgSubnetConfig } from './WgSubnetConfig' +export { WgSubnetMap } from './WgSubnetMap' From 6091314981ceec9aef161c14d262915880c6c629 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Thu, 12 Mar 2026 13:38:32 -0600 Subject: [PATCH 38/71] chore: simplify SDK Makefile js/dts copy with rsync --- sdk/Makefile | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/sdk/Makefile b/sdk/Makefile index 9370ab372..57437b7af 100644 --- a/sdk/Makefile +++ b/sdk/Makefile @@ -29,15 +29,7 @@ base/lib/exver/exver.ts: base/node_modules base/lib/exver/exver.pegjs baseDist: $(PACKAGE_TS_FILES) $(BASE_TS_FILES) base/package.json base/node_modules base/README.md base/LICENSE (cd base && npm run tsc) - # Copy hand-written .js/.d.ts pairs (no corresponding .ts source) into the output. - cd base/lib && find . -name '*.js' | while read f; do \ - base="$${f%.js}"; \ - if [ -f "$$base.d.ts" ] && [ ! -f "$$base.ts" ]; then \ - mkdir -p "../../baseDist/$$(dirname "$$f")"; \ - cp "$$f" "../../baseDist/$$f"; \ - cp "$$base.d.ts" "../../baseDist/$$base.d.ts"; \ - fi; \ - done + rsync -ac --include='*.js' --include='*.d.ts' --include='*/' --exclude='*' base/lib/ baseDist/ rsync -ac base/node_modules baseDist/ cp base/package.json baseDist/package.json cp base/README.md baseDist/README.md @@ -46,14 +38,7 @@ baseDist: $(PACKAGE_TS_FILES) $(BASE_TS_FILES) base/package.json base/node_modul dist: $(PACKAGE_TS_FILES) $(BASE_TS_FILES) package/package.json package/.npmignore package/node_modules package/README.md package/LICENSE (cd package && npm run tsc) - cd base/lib && find . -name '*.js' | while read f; do \ - base="$${f%.js}"; \ - if [ -f "$$base.d.ts" ] && [ ! -f "$$base.ts" ]; then \ - mkdir -p "../../dist/base/lib/$$(dirname "$$f")"; \ - cp "$$f" "../../dist/base/lib/$$f"; \ - cp "$$base.d.ts" "../../dist/base/lib/$$base.d.ts"; \ - fi; \ - done + rsync -ac --include='*.js' --include='*.d.ts' --include='*/' --exclude='*' base/lib/ dist/base/lib/ rsync -ac package/node_modules dist/ cp package/.npmignore dist/.npmignore cp package/package.json dist/package.json From 517bf80fc871dd73e54dd6e6e3eb23e6d2b45725 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Thu, 12 Mar 2026 13:38:42 -0600 Subject: [PATCH 39/71] feat: update start-tunnel web app for typed tunnel API - Use generated TS types for tunnel API params and data models - Simplify API service methods to use typed RPC calls - Update port forward UI for optional labels --- .../app/routes/home/routes/devices/utils.ts | 5 +- .../home/routes/port-forwards/edit-label.ts | 3 +- .../routes/home/routes/port-forwards/utils.ts | 5 +- .../src/app/services/api/api.service.ts | 95 ++++++------------- .../src/app/services/api/live-api.service.ts | 62 ++++++------ .../src/app/services/api/mock-api.service.ts | 65 +++++-------- .../src/app/services/patch-db/data-model.ts | 42 ++------ .../src/app/services/update.service.ts | 7 +- .../ui/src/app/services/api/mock-patch.ts | 2 +- 9 files changed, 107 insertions(+), 179 deletions(-) diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/devices/utils.ts b/web/projects/start-tunnel/src/app/routes/home/routes/devices/utils.ts index 8d3bf45cd..4ef719191 100644 --- a/web/projects/start-tunnel/src/app/routes/home/routes/devices/utils.ts +++ b/web/projects/start-tunnel/src/app/routes/home/routes/devices/utils.ts @@ -1,8 +1,7 @@ import { Signal } from '@angular/core' import { AbstractControl } from '@angular/forms' -import { utils } from '@start9labs/start-sdk' +import { T, utils } from '@start9labs/start-sdk' import { IpNet } from '@start9labs/start-sdk/util' -import { WgServer } from 'src/app/services/patch-db/data-model' export interface MappedDevice { readonly subnet: { @@ -16,7 +15,7 @@ export interface MappedDevice { export interface MappedSubnet { readonly range: string readonly name: string - readonly clients: WgServer['subnets']['']['clients'] + readonly clients: T.Tunnel.WgSubnetClients } export interface DeviceData { diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/edit-label.ts b/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/edit-label.ts index 3f98f0a74..e0838b42a 100644 --- a/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/edit-label.ts +++ b/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/edit-label.ts @@ -15,11 +15,12 @@ import { import { TuiFieldErrorPipe } from '@taiga-ui/kit' import { TuiForm } from '@taiga-ui/layout' import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' +import { T } from '@start9labs/start-sdk' import { ApiService } from 'src/app/services/api/api.service' export interface EditLabelData { readonly source: string - readonly label: string + readonly label: T.Tunnel.PortForwardEntry['label'] } @Component({ diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/utils.ts b/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/utils.ts index c9b55f25f..861ea5528 100644 --- a/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/utils.ts +++ b/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/utils.ts @@ -1,4 +1,5 @@ import { Signal } from '@angular/core' +import { T } from '@start9labs/start-sdk' export interface MappedDevice { readonly ip: string @@ -10,8 +11,8 @@ export interface MappedForward { readonly externalport: string readonly device: MappedDevice readonly internalport: string - readonly label: string - readonly enabled: boolean + readonly label: T.Tunnel.PortForwardEntry['label'] + readonly enabled: T.Tunnel.PortForwardEntry['enabled'] } export interface PortForwardsData { diff --git a/web/projects/start-tunnel/src/app/services/api/api.service.ts b/web/projects/start-tunnel/src/app/services/api/api.service.ts index cb1b29e57..f25d22b93 100644 --- a/web/projects/start-tunnel/src/app/services/api/api.service.ts +++ b/web/projects/start-tunnel/src/app/services/api/api.service.ts @@ -1,7 +1,8 @@ import { Injectable } from '@angular/core' +import { T } from '@start9labs/start-sdk' import { Dump } from 'patch-db-client' -import { TunnelData } from '../patch-db/data-model' import { Observable } from 'rxjs' +import { TunnelData } from '../patch-db/data-model' @Injectable({ providedIn: 'root', @@ -10,77 +11,43 @@ export abstract class ApiService { abstract openWebsocket$(guid: string): Observable abstract subscribe(): Promise // db.subscribe // auth - abstract login(params: LoginReq): Promise // auth.login + abstract login(params: T.Tunnel.SetPasswordParams): Promise // auth.login abstract logout(): Promise // auth.logout - abstract setPassword(params: LoginReq): Promise // auth.set-password + abstract setPassword(params: T.Tunnel.SetPasswordParams): Promise // auth.set-password // subnets - abstract addSubnet(params: UpsertSubnetReq): Promise // subnet.add - abstract editSubnet(params: UpsertSubnetReq): Promise // subnet.add - abstract deleteSubnet(params: DeleteSubnetReq): Promise // subnet.remove + abstract addSubnet( + params: T.Tunnel.SubnetParams & T.Tunnel.AddSubnetParams, + ): Promise // subnet.add + abstract editSubnet( + params: T.Tunnel.SubnetParams & T.Tunnel.AddSubnetParams, + ): Promise // subnet.edit + abstract deleteSubnet(params: T.Tunnel.SubnetParams): Promise // subnet.remove // devices - abstract addDevice(params: UpsertDeviceReq): Promise // device.add - abstract editDevice(params: UpsertDeviceReq): Promise // device.add - abstract deleteDevice(params: DeleteDeviceReq): Promise // device.remove - abstract showDeviceConfig(params: DeleteDeviceReq): Promise // device.show-config + abstract addDevice(params: T.Tunnel.AddDeviceParams): Promise // device.add + abstract editDevice(params: T.Tunnel.AddDeviceParams): Promise // device.edit + abstract deleteDevice(params: T.Tunnel.RemoveDeviceParams): Promise // device.remove + abstract showDeviceConfig( + params: T.Tunnel.RemoveDeviceParams, + ): Promise // device.show-config // forwards - abstract addForward(params: AddForwardReq): Promise // port-forward.add - abstract deleteForward(params: DeleteForwardReq): Promise // port-forward.remove - abstract updateForwardLabel(params: UpdateForwardLabelReq): Promise // port-forward.update-label - abstract setForwardEnabled(params: SetForwardEnabledReq): Promise // port-forward.set-enabled + abstract addForward( + params: T.Tunnel.AddPortForwardParams, + ): Promise // port-forward.add + abstract deleteForward( + params: T.Tunnel.RemovePortForwardParams, + ): Promise // port-forward.remove + abstract updateForwardLabel( + params: T.Tunnel.UpdatePortForwardLabelParams, + ): Promise // port-forward.update-label + abstract setForwardEnabled( + params: T.Tunnel.SetPortForwardEnabledParams, + ): Promise // port-forward.set-enabled // update - abstract checkUpdate(): Promise // update.check - abstract applyUpdate(): Promise // update.apply + abstract checkUpdate(): Promise // update.check + abstract applyUpdate(): Promise // update.apply } export type SubscribeRes = { dump: Dump guid: string } - -export type LoginReq = { password: string } - -export type UpsertSubnetReq = { - name: string - subnet: string -} - -export type DeleteSubnetReq = { - subnet: string -} - -export type UpsertDeviceReq = { - name: string - subnet: string - ip: string -} - -export type DeleteDeviceReq = { - subnet: string - ip: string -} - -export type AddForwardReq = { - source: string // externalip:port - target: string // internalip:port - label: string -} - -export type DeleteForwardReq = { - source: string -} - -export type UpdateForwardLabelReq = { - source: string - label: string -} - -export type SetForwardEnabledReq = { - source: string - enabled: boolean -} - -export type TunnelUpdateResult = { - status: string - installed: string - candidate: string -} diff --git a/web/projects/start-tunnel/src/app/services/api/live-api.service.ts b/web/projects/start-tunnel/src/app/services/api/live-api.service.ts index f0ed98b97..83aaa8515 100644 --- a/web/projects/start-tunnel/src/app/services/api/live-api.service.ts +++ b/web/projects/start-tunnel/src/app/services/api/live-api.service.ts @@ -8,20 +8,8 @@ import { } from '@start9labs/shared' import { filter, firstValueFrom, Observable } from 'rxjs' import { webSocket } from 'rxjs/webSocket' -import { - AddForwardReq, - ApiService, - DeleteDeviceReq, - DeleteForwardReq, - DeleteSubnetReq, - LoginReq, - SubscribeRes, - TunnelUpdateResult, - SetForwardEnabledReq, - UpdateForwardLabelReq, - UpsertDeviceReq, - UpsertSubnetReq, -} from './api.service' +import { T } from '@start9labs/start-sdk' +import { ApiService, SubscribeRes } from './api.service' import { AuthService } from '../auth.service' import { PATCH_CACHE } from '../patch-db/patch-db-source' @@ -54,7 +42,7 @@ export class LiveApiService extends ApiService { // auth - async login(params: LoginReq): Promise { + async login(params: T.Tunnel.SetPasswordParams): Promise { return this.rpcRequest({ method: 'auth.login', params }) } @@ -62,75 +50,87 @@ export class LiveApiService extends ApiService { return this.rpcRequest({ method: 'auth.logout', params: {} }) } - async setPassword(params: LoginReq): Promise { + async setPassword(params: T.Tunnel.SetPasswordParams): Promise { return this.rpcRequest({ method: 'auth.set-password', params }) } - async addSubnet(params: UpsertSubnetReq): Promise { + async addSubnet( + params: T.Tunnel.SubnetParams & T.Tunnel.AddSubnetParams, + ): Promise { return this.upsertSubnet(params) } - async editSubnet(params: UpsertSubnetReq): Promise { + async editSubnet( + params: T.Tunnel.SubnetParams & T.Tunnel.AddSubnetParams, + ): Promise { return this.upsertSubnet(params) } - async deleteSubnet(params: DeleteSubnetReq): Promise { + async deleteSubnet(params: T.Tunnel.SubnetParams): Promise { return this.rpcRequest({ method: 'subnet.remove', params }) } // devices - async addDevice(params: UpsertDeviceReq): Promise { + async addDevice(params: T.Tunnel.AddDeviceParams): Promise { return this.upsertDevice(params) } - async editDevice(params: UpsertDeviceReq): Promise { + async editDevice(params: T.Tunnel.AddDeviceParams): Promise { return this.upsertDevice(params) } - async deleteDevice(params: DeleteDeviceReq): Promise { + async deleteDevice(params: T.Tunnel.RemoveDeviceParams): Promise { return this.rpcRequest({ method: 'device.remove', params }) } - async showDeviceConfig(params: DeleteDeviceReq): Promise { + async showDeviceConfig(params: T.Tunnel.RemoveDeviceParams): Promise { return this.rpcRequest({ method: 'device.show-config', params }) } // forwards - async addForward(params: AddForwardReq): Promise { + async addForward(params: T.Tunnel.AddPortForwardParams): Promise { return this.rpcRequest({ method: 'port-forward.add', params }) } - async deleteForward(params: DeleteForwardReq): Promise { + async deleteForward( + params: T.Tunnel.RemovePortForwardParams, + ): Promise { return this.rpcRequest({ method: 'port-forward.remove', params }) } - async updateForwardLabel(params: UpdateForwardLabelReq): Promise { + async updateForwardLabel( + params: T.Tunnel.UpdatePortForwardLabelParams, + ): Promise { return this.rpcRequest({ method: 'port-forward.update-label', params }) } - async setForwardEnabled(params: SetForwardEnabledReq): Promise { + async setForwardEnabled( + params: T.Tunnel.SetPortForwardEnabledParams, + ): Promise { return this.rpcRequest({ method: 'port-forward.set-enabled', params }) } // update - async checkUpdate(): Promise { + async checkUpdate(): Promise { return this.rpcRequest({ method: 'update.check', params: {} }) } - async applyUpdate(): Promise { + async applyUpdate(): Promise { return this.rpcRequest({ method: 'update.apply', params: {} }) } // private - private async upsertSubnet(params: UpsertSubnetReq): Promise { + private async upsertSubnet( + params: T.Tunnel.SubnetParams & T.Tunnel.AddSubnetParams, + ): Promise { return this.rpcRequest({ method: 'subnet.add', params }) } - private async upsertDevice(params: UpsertDeviceReq): Promise { + private async upsertDevice(params: T.Tunnel.AddDeviceParams): Promise { return this.rpcRequest({ method: 'device.add', params }) } diff --git a/web/projects/start-tunnel/src/app/services/api/mock-api.service.ts b/web/projects/start-tunnel/src/app/services/api/mock-api.service.ts index d24562b9d..ed3ebe95e 100644 --- a/web/projects/start-tunnel/src/app/services/api/mock-api.service.ts +++ b/web/projects/start-tunnel/src/app/services/api/mock-api.service.ts @@ -1,21 +1,9 @@ import { inject, Injectable } from '@angular/core' import { shareReplay, Subject, tap } from 'rxjs' import { WebSocketSubject } from 'rxjs/webSocket' -import { - AddForwardReq, - ApiService, - DeleteDeviceReq, - DeleteForwardReq, - DeleteSubnetReq, - LoginReq, - SubscribeRes, - TunnelUpdateResult, - SetForwardEnabledReq, - UpdateForwardLabelReq, - UpsertDeviceReq, - UpsertSubnetReq, -} from './api.service' +import { ApiService, SubscribeRes } from './api.service' import { pauseFor } from '@start9labs/shared' +import { T } from '@start9labs/start-sdk' import { AuthService } from '../auth.service' import { AddOperation, @@ -26,12 +14,7 @@ import { Revision, } from 'patch-db-client' import { toObservable } from '@angular/core/rxjs-interop' -import { - mockTunnelData, - PortForwardEntry, - WgClient, - WgSubnet, -} from '../patch-db/data-model' +import { mockTunnelData } from '../patch-db/data-model' @Injectable({ providedIn: 'root', @@ -66,7 +49,7 @@ export class MockApiService extends ApiService { } } - async login(params: LoginReq): Promise { + async login(params: T.Tunnel.SetPasswordParams): Promise { await pauseFor(1000) return null } @@ -76,15 +59,15 @@ export class MockApiService extends ApiService { return null } - async setPassword(params: LoginReq): Promise { + async setPassword(params: T.Tunnel.SetPasswordParams): Promise { await pauseFor(1000) return null } - async addSubnet(params: UpsertSubnetReq): Promise { + async addSubnet(params: T.Tunnel.SubnetParams & T.Tunnel.AddSubnetParams): Promise { await pauseFor(1000) - const patch: AddOperation[] = [ + const patch: AddOperation[] = [ { op: PatchOp.ADD, path: `/wg/subnets/${replaceSlashes(params.subnet)}`, @@ -96,7 +79,7 @@ export class MockApiService extends ApiService { return null } - async editSubnet(params: UpsertSubnetReq): Promise { + async editSubnet(params: T.Tunnel.SubnetParams & T.Tunnel.AddSubnetParams): Promise { await pauseFor(1000) const patch: ReplaceOperation[] = [ @@ -111,7 +94,7 @@ export class MockApiService extends ApiService { return null } - async deleteSubnet(params: DeleteSubnetReq): Promise { + async deleteSubnet(params: T.Tunnel.SubnetParams): Promise { await pauseFor(1000) const patch: RemoveOperation[] = [ @@ -125,14 +108,14 @@ export class MockApiService extends ApiService { return null } - async addDevice(params: UpsertDeviceReq): Promise { + async addDevice(params: T.Tunnel.AddDeviceParams): Promise { await pauseFor(1000) - const patch: AddOperation[] = [ + const patch: AddOperation[] = [ { op: PatchOp.ADD, path: `/wg/subnets/${replaceSlashes(params.subnet)}/clients/${params.ip}`, - value: { name: params.name }, + value: { name: params.name, key: '', psk: '' }, }, ] this.mockRevision(patch) @@ -140,7 +123,7 @@ export class MockApiService extends ApiService { return null } - async editDevice(params: UpsertDeviceReq): Promise { + async editDevice(params: T.Tunnel.AddDeviceParams): Promise { await pauseFor(1000) const patch: ReplaceOperation[] = [ @@ -155,7 +138,7 @@ export class MockApiService extends ApiService { return null } - async deleteDevice(params: DeleteDeviceReq): Promise { + async deleteDevice(params: T.Tunnel.RemoveDeviceParams): Promise { await pauseFor(1000) const patch: RemoveOperation[] = [ @@ -169,22 +152,22 @@ export class MockApiService extends ApiService { return null } - async showDeviceConfig(params: DeleteDeviceReq): Promise { + async showDeviceConfig(params: T.Tunnel.RemoveDeviceParams): Promise { await pauseFor(1000) return MOCK_CONFIG } - async addForward(params: AddForwardReq): Promise { + async addForward(params: T.Tunnel.AddPortForwardParams): Promise { await pauseFor(1000) - const patch: AddOperation[] = [ + const patch: AddOperation[] = [ { op: PatchOp.ADD, path: `/portForwards/${params.source}`, value: { target: params.target, - label: params.label || '', + label: params.label || null, enabled: true, }, }, @@ -194,10 +177,10 @@ export class MockApiService extends ApiService { return null } - async updateForwardLabel(params: UpdateForwardLabelReq): Promise { + async updateForwardLabel(params: T.Tunnel.UpdatePortForwardLabelParams): Promise { await pauseFor(1000) - const patch: ReplaceOperation[] = [ + const patch: ReplaceOperation[] = [ { op: PatchOp.REPLACE, path: `/portForwards/${params.source}/label`, @@ -209,7 +192,7 @@ export class MockApiService extends ApiService { return null } - async setForwardEnabled(params: SetForwardEnabledReq): Promise { + async setForwardEnabled(params: T.Tunnel.SetPortForwardEnabledParams): Promise { await pauseFor(1000) const patch: ReplaceOperation[] = [ @@ -224,7 +207,7 @@ export class MockApiService extends ApiService { return null } - async deleteForward(params: DeleteForwardReq): Promise { + async deleteForward(params: T.Tunnel.RemovePortForwardParams): Promise { await pauseFor(1000) const patch: RemoveOperation[] = [ @@ -238,7 +221,7 @@ export class MockApiService extends ApiService { return null } - async checkUpdate(): Promise { + async checkUpdate(): Promise { await pauseFor(1000) return { status: 'update-available', @@ -247,7 +230,7 @@ export class MockApiService extends ApiService { } } - async applyUpdate(): Promise { + async applyUpdate(): Promise { await pauseFor(2000) return { status: 'updating', diff --git a/web/projects/start-tunnel/src/app/services/patch-db/data-model.ts b/web/projects/start-tunnel/src/app/services/patch-db/data-model.ts index 8bb5e23e0..0ac9d0c30 100644 --- a/web/projects/start-tunnel/src/app/services/patch-db/data-model.ts +++ b/web/projects/start-tunnel/src/app/services/patch-db/data-model.ts @@ -1,45 +1,21 @@ import { T } from '@start9labs/start-sdk' -export type PortForwardEntry = { - target: string - label: string - enabled: boolean -} - -export type TunnelData = { - wg: WgServer - portForwards: Record - gateways: Record -} - -export type WgServer = { - subnets: Record -} - -export type WgSubnet = { - name: string - clients: Record -} - -export type WgClient = { - name: string -} +export type TunnelData = Pick< + T.Tunnel.TunnelDatabase, + 'wg' | 'portForwards' | 'gateways' +> export const mockTunnelData: TunnelData = { wg: { + port: 51820, + key: '', subnets: { '10.59.0.0/24': { name: 'Family', clients: { - '10.59.0.2': { - name: 'Start9 Server', - }, - '10.59.0.3': { - name: 'Phone', - }, - '10.59.0.4': { - name: 'Laptop', - }, + '10.59.0.2': { name: 'Start9 Server', key: '', psk: '' }, + '10.59.0.3': { name: 'Phone', key: '', psk: '' }, + '10.59.0.4': { name: 'Laptop', key: '', psk: '' }, }, }, }, diff --git a/web/projects/start-tunnel/src/app/services/update.service.ts b/web/projects/start-tunnel/src/app/services/update.service.ts index 861b5c057..b246579ef 100644 --- a/web/projects/start-tunnel/src/app/services/update.service.ts +++ b/web/projects/start-tunnel/src/app/services/update.service.ts @@ -14,7 +14,8 @@ import { switchMap, takeWhile, } from 'rxjs' -import { ApiService, TunnelUpdateResult } from './api/api.service' +import { T } from '@start9labs/start-sdk' +import { ApiService } from './api/api.service' import { AuthService } from './auth.service' @Component({ @@ -34,7 +35,7 @@ export class UpdateService { private readonly dialogs = inject(TuiDialogService) private readonly errorService = inject(ErrorService) - readonly result = signal(null) + readonly result = signal(null) readonly hasUpdate = computed( () => this.result()?.status === 'update-available', ) @@ -60,7 +61,7 @@ export class UpdateService { this.setResult(result) } - private setResult(result: TunnelUpdateResult): void { + private setResult(result: T.Tunnel.TunnelUpdateResult): void { this.result.set(result) if (result.status === 'updating') { diff --git a/web/projects/ui/src/app/services/api/mock-patch.ts b/web/projects/ui/src/app/services/api/mock-patch.ts index 557310204..56bb797c9 100644 --- a/web/projects/ui/src/app/services/api/mock-patch.ts +++ b/web/projects/ui/src/app/services/api/mock-patch.ts @@ -239,7 +239,7 @@ export const mockPatchData: DataModel = { caFingerprint: '63:2B:11:99:44:40:17:DF:37:FC:C3:DF:0F:3D:15', ntpSynced: false, smtp: null, - ifconfigUrl: 'https://ifconfig.co', + echoipUrls: ['https://ipconfig.me', 'https://ifconfig.co'], platform: 'x86_64-nonfree', zram: true, governor: 'performance', From 0fa069126b6375e4bcc096a35d6d6854f9b9e0d3 Mon Sep 17 00:00:00 2001 From: Matt Hill Date: Thu, 12 Mar 2026 14:15:45 -0600 Subject: [PATCH 40/71] mok ux, autofill device and pf forms, docss for st, docs for start-sdk --- core/src/tunnel/web.rs | 27 +- sdk/ARCHITECTURE.md | 422 ++++++++++++++++++ sdk/CHANGELOG.md | 109 +++++ sdk/CLAUDE.md | 7 + sdk/CONTRIBUTING.md | 209 +++++++++ sdk/{base => }/LICENSE | 0 sdk/Makefile | 13 +- sdk/README.md | 103 +++++ sdk/package/LICENSE | 21 - sdk/package/README.md | 18 - .../app/components/mok-enrollment.dialog.ts | 189 ++++++-- .../src/app/pages/success.page.ts | 106 +++-- .../src/app/services/mock-api.service.ts | 2 +- .../shared/src/i18n/dictionaries/de.ts | 9 + .../shared/src/i18n/dictionaries/en.ts | 9 + .../shared/src/i18n/dictionaries/es.ts | 9 + .../shared/src/i18n/dictionaries/fr.ts | 13 +- .../shared/src/i18n/dictionaries/pl.ts | 9 + .../src/app/routes/home/routes/devices/add.ts | 15 +- .../routes/home/routes/port-forwards/add.ts | 9 +- .../routes/home/routes/port-forwards/utils.ts | 2 +- 21 files changed, 1147 insertions(+), 154 deletions(-) create mode 100644 sdk/ARCHITECTURE.md create mode 100644 sdk/CHANGELOG.md create mode 100644 sdk/CONTRIBUTING.md rename sdk/{base => }/LICENSE (100%) create mode 100644 sdk/README.md delete mode 100644 sdk/package/LICENSE delete mode 100644 sdk/package/README.md diff --git a/core/src/tunnel/web.rs b/core/src/tunnel/web.rs index 49cb15930..05be63c5b 100644 --- a/core/src/tunnel/web.rs +++ b/core/src/tunnel/web.rs @@ -519,32 +519,7 @@ pub async fn init_web(ctx: CliContext) -> Result<(), Error> { .or_not_found("certificate in chain")?; println!("📝 Root CA:"); print!("{cert}\n"); - - println!(concat!( - "To access your Web URL securely, trust your Root CA (displayed above) on your client device(s):\n", - " - MacOS\n", - " 1. Open the Terminal app\n", - " 2. Type or copy/paste the following command (**DO NOT** click Enter/Return yet): pbpaste > ~/Desktop/tunnel-ca.crt\n", - " 3. Copy your Root CA (including -----BEGIN CERTIFICATE----- and -----END CERTIFICATE-----)\n", - " 4. Back in Terminal, click Enter/Return. tunnel-ca.crt is saved to your Desktop\n", - " 5. Complete by trusting your Root CA: https://docs.start9.com/start-os/0.4.0.x/user-manual/trust-ca.html?platform=Mac\n", - " - Linux\n", - " 1. Open gedit, nano, or any editor\n", - " 2. Copy/paste your Root CA (including -----BEGIN CERTIFICATE----- and -----END CERTIFICATE-----)\n", - " 3. Name the file tunnel-ca.crt and save as plaintext\n", - " 4. Complete by trusting your Root CA: https://docs.start9.com/start-os/0.4.0.x/user-manual/trust-ca.html?platform=Debian+%252F+Ubuntu\n", - " - Windows\n", - " 1. Open the Notepad app\n", - " 2. Copy/paste your Root CA (including -----BEGIN CERTIFICATE----- and -----END CERTIFICATE-----)\n", - " 3. Name the file tunnel-ca.crt and save as plaintext\n", - " 4. Complete by trusting your Root CA: https://docs.start9.com/start-os/0.4.0.x/user-manual/trust-ca.html?platform=Windows\n", - " - Android/Graphene\n", - " 1. Send the tunnel-ca.crt file (created above) to yourself\n", - " 2. Complete by trusting your Root CA: https://docs.start9.com/start-os/0.4.0.x/user-manual/trust-ca.html?platform=Android+%252F+Graphene\n", - " - iOS\n", - " 1. Send the tunnel-ca.crt file (created above) to yourself\n", - " 2. Complete by trusting your Root CA: https://docs.start9.com/start-os/0.4.0.x/user-manual/trust-ca.html?platform=iOS\n", - )); + println!("Follow instructions to trust your Root CA (recommended): https://docs.start9.com/start-tunnel/installing/index.html#trust-your-root-ca"); return Ok(()); } diff --git a/sdk/ARCHITECTURE.md b/sdk/ARCHITECTURE.md new file mode 100644 index 000000000..f785b2494 --- /dev/null +++ b/sdk/ARCHITECTURE.md @@ -0,0 +1,422 @@ +# SDK Architecture + +The Start SDK is split into two npm packages that form a layered architecture: **base** provides the foundational types, ABI contract, and effects interface; **package** builds on base to provide the developer-facing SDK facade. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ package/ (@start9labs/start-sdk) │ +│ Developer-facing facade, daemon management, health checks, │ +│ backup system, file helpers, triggers, subcontainers │ +│ │ +│ ┌───────────────────────────────────────────────────────┐ │ +│ │ base/ (@start9labs/start-sdk-base) │ │ +│ │ ABI, Effects, OS bindings, actions/input builders, │ │ +│ │ ExVer parser, interfaces, dependencies, S9pk, utils │ │ +│ └───────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ ▲ + │ Effects calls (RPC) │ Callbacks + ▼ │ +┌─────────────────────────────────────────────────────────────┐ +│ StartOS Runtime (Rust supervisor) │ +│ Executes effects, manages containers, networking, storage │ +└─────────────────────────────────────────────────────────────┘ +``` + +The SDK follows [Semantic Versioning](https://semver.org/) within the `0.4.0-beta.*` pre-release series. The SDK version tracks independently from the StartOS release versions. + +## Base Package (`base/`) + +The base package is a self-contained library of types, interfaces, and low-level builders. It has no dependency on the package layer and can be used independently when only type definitions or validation are needed. + +### OS Bindings (`base/lib/osBindings/`) + +~325 auto-generated TypeScript files defining every type exchanged between the SDK and the StartOS runtime. These cover the full surface area of the system: manifests, actions, health checks, service interfaces, bind parameters, dependency requirements, alerts, SSL, domains, SMTP, networking, images, and more. + +All bindings are re-exported through `base/lib/osBindings/index.ts`. + +Key types include: +- `Manifest` — The full service package manifest as understood by the OS +- `ActionMetadata` — Describes an action's name, description, visibility, and availability +- `BindParams` — Port binding configuration (protocol, hostId, internal port) +- `ServiceInterface` — A network endpoint exported to users +- `DependencyRequirement` — Version range and health check requirements for a dependency +- `SetHealth` — Health check result reporting +- `HostnameInfo` / `Host` — Hostname and host metadata + +### ABI and Core Types (`base/lib/types.ts`) + +Defines the Application Binary Interface — the contract every service package must fulfill: + +```typescript +namespace ExpectedExports { + main // Start the service daemon(s) + init // Initialize on install/update/restore + uninit // Clean up on uninstall/update/shutdown + manifest // Service metadata + actions // User-invocable operations + createBackup // Export service data +} +``` + +Also defines foundational types used throughout the SDK: +- `Daemon` / `DaemonReturned` — Running process handles with `wait()` and `term()` +- `CommandType` — Shell string, argv array, or `UseEntrypoint` +- `ServiceInterfaceType` — `'ui' | 'api' | 'p2p'` +- `SmtpValue` — SMTP server configuration +- `KnownError` — Structured user-facing errors +- `DependsOn` — Package-to-health-check dependency mapping +- `PathMaker`, `MaybePromise`, `DeepPartial`, `DeepReadonly` — Utility types + +### Effects Interface (`base/lib/Effects.ts`) + +The bridge between TypeScript service code and the StartOS runtime. Every runtime capability is accessed through an `Effects` object passed to lifecycle hooks. + +Effects are organized by subsystem: + +| Subsystem | Methods | Purpose | +|-----------|---------|---------| +| **Action** | `export`, `clear`, `getInput`, `run`, `createTask`, `clearTasks` | Register and invoke user actions | +| **Control** | `restart`, `shutdown`, `getStatus`, `setMainStatus` | Service lifecycle control | +| **Dependency** | `setDependencies`, `getDependencies`, `checkDependencies`, `mount`, `getInstalledPackages`, `getServiceManifest` | Inter-service dependency management | +| **Health** | `setHealth` | Report health check results | +| **Subcontainer** | `createFs`, `destroyFs` | Container filesystem management | +| **Networking** | `bind`, `getServicePortForward`, `clearBindings`, `getHostInfo`, `getContainerIp`, `getOsIp`, `getOutboundGateway` | Port binding and network info | +| **Interfaces** | `exportServiceInterface`, `getServiceInterface`, `listServiceInterfaces`, `clearServiceInterfaces` | Service endpoint management | +| **Plugin** | `plugin.url.register`, `plugin.url.exportUrl`, `plugin.url.clearUrls` | Plugin system hooks | +| **SSL** | `getSslCertificate`, `getSslKey` | TLS certificate management | +| **System** | `getSystemSmtp`, `setDataVersion`, `getDataVersion` | System-wide configuration | + +Effects also support reactive callbacks: many methods accept an optional `callback` parameter that the runtime invokes when the underlying value changes, enabling the reactive subscription patterns (`const()`, `watch()`, etc.). + +### Action and Input System (`base/lib/actions/`) + +#### Actions (`setupActions.ts`) + +The `Action` class defines user-invocable operations. Two factory methods: +- `Action.withInput(id, metadata, inputSpec, prefill, execute)` — Action with a validated form +- `Action.withoutInput(id, metadata, execute)` — Action without user input + +`Actions` is a typed map accumulated via `.addAction()` chaining. + +#### Input Specification (`actions/input/`) + +A builder-pattern system for declaring validated form inputs: + +``` +inputSpec/ +├── builder/ +│ ├── inputSpec.ts — InputSpec.of() entry point +│ ├── value.ts — Value class (individual form fields) +│ ├── list.ts — List builder (arrays of values) +│ └── variants.ts — Variants/Union builder (conditional fields) +├── inputSpecTypes.ts — Type definitions for all field types +└── inputSpecConstants.ts — Pre-built specs (SMTP, etc.) +``` + +Supported field types via `Value`: +- `text`, `textarea`, `number` — Text and numeric input +- `toggle` — Boolean switch +- `select`, `multiselect` — Single/multi-choice dropdown +- `list` — Repeatable array of sub-values +- `color`, `datetime` — Specialized pickers +- `object` — Nested sub-form +- `union` / `dynamicUnion` — Conditional fields based on a discriminator + +### Dependencies (`base/lib/dependencies/`) + +- `setupDependencies.ts` — Declare what the service depends on (package IDs, version ranges, health checks) +- `dependencies.ts` — Runtime dependency checking via `checkDependencies()` + +### Interfaces (`base/lib/interfaces/`) + +Network interface declaration and port binding: + +- `setupInterfaces.ts` — Top-level `setupServiceInterfaces()` function +- `Host.ts` — `MultiHost` class for binding ports and exporting interfaces. A single MultiHost can bind a port and export multiple interfaces (e.g. a primary UI and admin UI on the same port with different paths) +- `ServiceInterfaceBuilder.ts` — Builder for constructing `ServiceInterface` objects with name, type, description, scheme overrides, username, path, and query params +- `setupExportedUrls.ts` — URL plugin support for exporting URLs to other services + +### Initialization (`base/lib/inits/`) + +- `setupInit.ts` — Compose init scripts that run on install, update, restore, or boot +- `setupUninit.ts` — Compose uninit scripts that run on uninstall, update, or shutdown +- `setupOnInit` / `setupOnUninit` — Register callbacks for specific init/uninit events + +Init scripts receive a `kind` parameter (`'install' | 'update' | 'restore' | null`) so they can branch logic based on the initialization context. + +### Extended Versioning (`base/lib/exver/`) + +A PEG parser-based versioning system that extends semver: + +- **`Version`** — Standard semantic version (`1.2.3-beta.1`) +- **`ExtendedVersion` (ExVer)** — Adds an optional flavor prefix and a downstream version: `#flavor:upstream:downstream` +- **`VersionRange`** — Boolean expressions over version comparisons (`>=1.0.0 && <2.0.0 || =3.0.0`) + +The parser is generated from `exver.pegjs` via Peggy and emitted as `exver.ts`. + +ExVer separates upstream project versions from StartOS wrapper versions, allowing the package maintainer's versioning to evolve independently from the upstream software. + +### S9pk Format (`base/lib/s9pk/`) + +Parser and verifier for `.s9pk` service package archives: + +- `S9pk` class — Deserialize and inspect package contents +- Merkle archive support for cryptographic verification of package integrity +- Methods: `deserialize()`, `icon()`, `license()`, etc. + +### Utilities (`base/lib/util/`) + +~28 utility modules including: + +**Reactive subscription wrappers** — Each wraps an Effects callback-based method into a consistent reactive API: +- `Watchable` — Base class providing `const()`, `once()`, `watch()`, `onChange()`, `waitFor()` +- `GetContainerIp`, `GetStatus`, `GetSystemSmtp`, `GetOutboundGateway`, `GetSslCertificate`, `GetHostInfo`, `GetServiceManifest` — Typed wrappers for specific Effects methods + +**General utilities:** +- `deepEqual` / `deepMerge` — Deep object comparison and merging +- `patterns` — Hostname regex, port validators +- `splitCommand` — Parse shell command strings into argv arrays +- `Drop` — RAII-style cleanup utility +- `graph` — Dependency graph utilities + +## Package Layer (`package/`) + +The package layer provides the developer-facing API. It re-exports everything from base and adds higher-level abstractions. + +### StartSdk Facade (`package/lib/StartSdk.ts`) + +The primary entry point for service developers. Constructed via a builder chain: + +```typescript +const sdk = StartSdk.of() + .withManifest(manifest) + .build(true) +``` + +The `.build()` method returns an object containing the entire SDK surface area, organized by concern: + +| Category | Members | Purpose | +|----------|---------|---------| +| **Manifest** | `manifest`, `volumes` | Access manifest data and volume paths | +| **Actions** | `Action.withInput`, `Action.withoutInput`, `Actions`, `action.run`, `action.createTask`, `action.createOwnTask`, `action.clearTask` | Define and manage user actions | +| **Daemons** | `Daemons.of`, `Daemon.of`, `setupMain` | Configure service processes | +| **Health** | `healthCheck.checkPortListening`, `.checkWebUrl`, `.runHealthScript` | Built-in health checks | +| **Interfaces** | `createInterface`, `MultiHost.of`, `setupInterfaces`, `serviceInterface.*` | Network endpoint management | +| **Backups** | `setupBackups`, `Backups.ofVolumes`, `Backups.ofSyncs`, `Backups.withOptions` | Backup configuration | +| **Dependencies** | `setupDependencies`, `checkDependencies` | Dependency declaration and verification | +| **Init/Uninit** | `setupInit`, `setupUninit`, `setupOnInit`, `setupOnUninit` | Lifecycle hooks | +| **Containers** | `SubContainer.of`, `SubContainer.withTemp`, `Mounts.of` | Container execution with mounts | +| **Forms** | `InputSpec.of`, `Value`, `Variants`, `List` | Form input builders | +| **Triggers** | `trigger.defaultTrigger`, `.cooldownTrigger`, `.changeOnFirstSuccess`, `.successFailure` | Health check polling strategies | +| **Reactive** | `getContainerIp`, `getStatus`, `getSystemSmtp`, `getOutboundGateway`, `getSslCertificate`, `getServiceManifest` | Subscription-based data access | +| **Plugins** | `plugin.url.register`, `plugin.url.exportUrl` | Plugin system (gated by manifest `plugins` field) | +| **Effects** | `restart`, `shutdown`, `setHealth`, `mount`, `clearBindings`, ... | Direct effect wrappers | +| **Utilities** | `nullIfEmpty`, `useEntrypoint`, `patterns`, `setDataVersion`, `getDataVersion` | Misc helpers | + +### Daemon Management (`package/lib/mainFn/`) + +The daemon subsystem manages long-running processes: + +``` +mainFn/ +├── Daemons.ts — Multi-daemon topology builder +├── Daemon.ts — Single daemon wrapper +├── HealthDaemon.ts — Health check executor +├── CommandController.ts — Command execution controller +├── Mounts.ts — Volume/asset/dependency mount builder +├── Oneshot.ts — One-time startup commands +└── index.ts — setupMain() entry point +``` + +**Daemons** is a builder that accumulates process definitions: +```typescript +sdk.Daemons.of(effects) + .addDaemon('db', { /* command, ready probe, mounts */ }) + .addDaemon('app', { requires: ['db'], /* ... */ }) + .addHealthCheck('primary', { /* ... */ }) +``` + +Features: +- Startup ordering via `requires` (dependency graph between daemons) +- Ready probes (wait for a daemon to be ready before starting dependents) +- Graceful shutdown with configurable signals and timeouts +- One-shot commands that run before daemons start + +**Mounts** declares what to attach to a container: +```typescript +sdk.Mounts.of() + .mountVolume('main', '/data') + .mountAssets('scripts', '/scripts') + .mountDependency('bitcoind', 'main', '/bitcoin-data', { readonly: true }) + .mountBackup('/backup') +``` + +### Health Checks (`package/lib/health/`) + +``` +health/ +├── HealthCheck.ts — Periodic probe with startup grace period +└── checkFns/ + ├── checkPortListening.ts — TCP port connectivity check + ├── checkWebUrl.ts — HTTP(S) status code check + └── runHealthScript.ts — Script exit code check +``` + +Health checks are paired with **triggers** that control polling behavior: +- `defaultTrigger` — Fixed interval (e.g. every 30s) +- `cooldownTrigger` — Wait longer after failures +- `changeOnFirstSuccess` — Rapid polling until first success, then slow down +- `successFailure` — Different intervals for healthy vs unhealthy states + +### Backup System (`package/lib/backup/`) + +``` +backup/ +├── setupBackups.ts — Top-level setup function +└── Backups.ts — Volume selection and rsync options +``` + +Three builder patterns: +- `Backups.ofVolumes('main', 'data')` — Back up entire volumes +- `Backups.ofSyncs([{ dataPath, backupPath }])` — Custom sync pairs +- `Backups.withOptions({ exclude: ['cache/'] })` — Rsync options + +### File Helpers (`package/lib/util/fileHelper.ts`) + +Type-safe configuration file management: + +```typescript +const configFile = FileHelper.yaml(effects, sdk.volumes.main.path('config.yml'), { + port: 8080, + debug: false, +}) + +// Reactive reading +const config = await configFile.read.const(effects) + +// Partial merge +await configFile.merge({ debug: true }) + +// Full write +await configFile.write({ port: 9090, debug: true }) +``` + +Supported formats: JSON, YAML, TOML, INI, ENV, and custom parsers. + +### Subcontainers (`package/lib/util/SubContainer.ts`) + +Execute commands in isolated container environments: + +```typescript +// Long-lived subcontainer +const container = await sdk.SubContainer.of(effects, { imageId: 'main' }, mounts, 'app') + +// One-shot execution +await sdk.SubContainer.withTemp(effects, { imageId: 'main' }, mounts, 'migrate', async (c) => { + await c.exec(['run-migrations']) +}) +``` + +### Manifest Building (`package/lib/manifest/`) + +```typescript +const manifest = setupManifest({ + id: 'my-service', + title: 'My Service', + license: 'MIT', + description: { short: '...', long: '...' }, + images: { main: { source: { dockerTag: 'myimage:1.0' } } }, + volumes: { main: {} }, + dependencies: {}, + // ... +}) + +export default buildManifest(manifest) +``` + +`buildManifest()` finalizes the manifest with the current SDK version, OS version compatibility, and migration version ranges. + +### Versioning (`package/lib/version/`) + +Helpers for data version management during migrations: + +```typescript +await sdk.setDataVersion(effects, '1.2.0:0') +const version = await sdk.getDataVersion(effects) +``` + +Used in init scripts to track which migration version the service's data has been brought to. + +### Internationalization (`package/lib/i18n/`) + +```typescript +const t = setupI18n({ en_US: enStrings, es_ES: esStrings }) +const greeting = t('hello', { name: 'World' }) // "Hello, World!" or "Hola, World!" +``` + +Supports locale fallback and Intl-based formatting. + +### Triggers (`package/lib/trigger/`) + +Polling strategy functions that determine when health checks run: + +```typescript +sdk.trigger.defaultTrigger({ timeout: 30_000 }) +sdk.trigger.cooldownTrigger({ timeout: 30_000, cooldown: 60_000 }) +sdk.trigger.changeOnFirstSuccess({ first: 5_000, then: 30_000 }) +sdk.trigger.successFailure({ success: 60_000, failure: 10_000 }) +``` + +## Build Pipeline + +See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed build instructions, make targets, and development workflow. + +At a high level: Peggy generates the ExVer parser, TypeScript compiles both packages in strict mode (base to `baseDist/`, package to `dist/`), hand-written `.js`/`.d.ts` pairs are copied into the output, and `node_modules` are bundled for self-contained distribution. + +## Data Flow + +A typical service package lifecycle: + +``` +1. INSTALL / UPDATE / RESTORE + ├── init({ effects, kind }) + │ ├── Version migrations (if update) + │ ├── setupDependencies() + │ ├── setupInterfaces() → bind ports, export interfaces + │ └── Actions registration → export actions to OS + │ +2. MAIN + │ setupMain() → Daemons.of(effects) + │ ├── Oneshots run first + │ ├── Daemons start in dependency order + │ ├── Health checks begin polling + │ └── Service runs until shutdown/restart + │ +3. SHUTDOWN / UNINSTALL / UPDATE + │ uninit({ effects, target }) + │ └── Version down-migrations (if needed) + │ +4. BACKUP (user-triggered) + createBackup({ effects }) + └── rsync volumes to backup location +``` + +## Key Design Patterns + +### Builder Pattern +Most SDK APIs use immutable builder chains: `Daemons.of().addDaemon().addHealthCheck()`, `Mounts.of().mountVolume().mountAssets()`, `Actions.of().addAction()`. This provides type accumulation — each chained call narrows the type to reflect what has been configured. + +### Effects as Capability System +All runtime interactions go through the `Effects` object rather than direct system calls. This makes the runtime boundary explicit, enables the OS to mediate all side effects, and makes service code testable by providing mock effects. + +### Reactive Subscriptions +The `Watchable` base class provides a consistent API for values that can change over time: +- `const(effects)` — Read once; if the value changes, triggers a retry of the enclosing context +- `once()` — Read once without reactivity +- `watch()` — Async generator yielding on each change +- `onChange(callback)` — Invoke callback on each change +- `waitFor(predicate)` — Block until a condition is met + +### Type-safe Manifest Threading +The manifest type flows through the entire SDK via generics. When you call `StartSdk.of().withManifest(manifest)`, the manifest's volume names, image IDs, dependency IDs, and plugin list become available as type constraints throughout all subsequent API calls. For example, `Mounts.of().mountVolume()` only accepts volume names declared in the manifest. diff --git a/sdk/CHANGELOG.md b/sdk/CHANGELOG.md new file mode 100644 index 000000000..881673e60 --- /dev/null +++ b/sdk/CHANGELOG.md @@ -0,0 +1,109 @@ +# Changelog + +## 0.4.0-beta.59 — StartOS v0.4.0-alpha.20 (2026-03-06) + +### Added + +- Support for preferred external ports besides 443 +- Bridge filter kind on service interfaces + +### Fixed + +- Merge version ranges when adding existing package signer +- Task fix for action task system + +## 0.4.0-beta.56 — StartOS v0.4.0-alpha.19 (2026-02-02) + +### Added + +- `getOutboundGateway` effect and SDK wrapper +- Improved service version migration and data version handling +- `zod-deep-partial` integration with `partialValidator` on `InputSpec` +- SMTP rework with improved provider variants and system SMTP spec + +### Changed + +- Migrated from `ts-matches` to `zod` across all TypeScript packages +- Builder-style `InputSpec` API with prefill plumbing +- Split `row_actions` into `remove_action` and `overflow_actions` for URL plugins + +### Fixed + +- Scoped public domain to single binding and return single port check +- Preserved `z` namespace types for SDK consumers +- `--arch` flag falls back to emulation when native image unavailable + +## 0.4.0-beta.54 — StartOS v0.4.0-alpha.18 (2026-01-27) + +### Added + +- Device info RPC +- Hardware acceleration and NVIDIA card support on nonfree images + +### Changed + +- Consolidated setup flow +- Improved SDK abort handling and `InputSpec` filtering + +## 0.4.0-beta.49 — StartOS v0.4.0-alpha.17 (2026-01-10) + +### Added + +- JSDoc comments on all consumer-facing APIs +- StartTunnel random subnet support +- Port 80 to 5443 tunnel mapping + +### Fixed + +- `EffectCreator` type corrections +- Allow multiple equal signs in ENV `FileHelper` values +- Miscellaneous alpha.16 follow-up fixes + +## 0.4.0-beta.45 — StartOS v0.4.0-alpha.16 (2025-12-18) + +### Added + +- `map` and `eq` on `getServiceInterface` watcher +- Flavor-aware version range handling + +### Changed + +- Refactored `StatusInfo` types +- Improved shutdown ordering for daemons +- Improved StartTunnel validation and garbage collection + +## 0.4.0-beta.43 — StartOS v0.4.0-alpha.15 (2025-11-26) + +### Fixed + +- Minor bugfixes for alpha.14 + +## 0.4.0-beta.42 — StartOS v0.4.0-alpha.14 (2025-11-20) + +### Fixed + +- Bugfixes for alpha.13 + +## 0.4.0-beta.41 — StartOS v0.4.0-alpha.13 (2025-11-15) + +### Fixed + +- Bugfixes for alpha.12 + +## 0.4.0-beta.40 — StartOS v0.4.0-alpha.12 (2025-11-07) + +### Added + +- StartTunnel integration +- Configurable `textarea` rows in `InputSpec` + +## 0.4.0-beta.39 — StartOS v0.4.0-alpha.11 (2025-09-24) + +### Added + +- Gateway limiting for StartTunnel +- Improved copy UX around Tor SSL + +### Changed + +- SDK type updates and internal improvements diff --git a/sdk/CLAUDE.md b/sdk/CLAUDE.md index d03111f86..6ebd1ce4c 100644 --- a/sdk/CLAUDE.md +++ b/sdk/CLAUDE.md @@ -6,3 +6,10 @@ TypeScript SDK for packaging services for StartOS (`@start9labs/start-sdk`). - `base/` — Core types, ABI definitions, effects interface (`@start9labs/start-sdk-base`) - `package/` — Full SDK for package developers, re-exports base + +## Releasing + +When bumping the SDK version (in `package/package.json`), always update `CHANGELOG.md`: +1. Add a new version heading at the top of the file +2. Use the format: `## — StartOS ()` +3. Categorize entries under `### Added`, `### Changed`, `### Fixed`, or `### Removed` diff --git a/sdk/CONTRIBUTING.md b/sdk/CONTRIBUTING.md new file mode 100644 index 000000000..de9b411b0 --- /dev/null +++ b/sdk/CONTRIBUTING.md @@ -0,0 +1,209 @@ +# Contributing to Start SDK + +This guide covers developing the SDK itself. If you're building a service package *using* the SDK, see the [packaging docs](https://docs.start9.com/packaging). + +For contributing to the broader StartOS project, see the root [CONTRIBUTING.md](../CONTRIBUTING.md). + +## Prerequisites + +- **Node.js v22+** (via [nvm](https://github.com/nvm-sh/nvm) recommended) +- **npm** (ships with Node.js) +- **GNU Make** + +Verify your setup: + +```bash +node --version # v22.x or higher +npm --version +make --version +``` + +## Repository Layout + +``` +sdk/ +├── base/ # @start9labs/start-sdk-base (core types, ABI, effects) +│ ├── lib/ # TypeScript source +│ ├── package.json +│ ├── tsconfig.json +│ └── jest.config.js +├── package/ # @start9labs/start-sdk (full developer-facing SDK) +│ ├── lib/ # TypeScript source +│ ├── package.json +│ ├── tsconfig.json +│ └── jest.config.js +├── baseDist/ # Build output for base (generated) +├── dist/ # Build output for package (generated, published to npm) +├── Makefile # Build orchestration +├── README.md +├── ARCHITECTURE.md +└── CLAUDE.md +``` + +## Getting Started + +Install dependencies for both sub-packages: + +```bash +cd sdk +make node_modules +``` + +This runs `npm ci` in both `base/` and `package/`. + +## Building + +### Full Build + +```bash +make bundle +``` + +This runs the complete pipeline: TypeScript compilation, hand-written pair copying, node_modules bundling, formatting, and tests. Outputs land in `baseDist/` (base) and `dist/` (package). + +### Individual Targets + +| Target | Description | +|--------|-------------| +| `make bundle` | Full build: compile + format + test | +| `make baseDist` | Compile base package only | +| `make dist` | Compile full package (depends on base) | +| `make fmt` | Run Prettier on all `.ts` files | +| `make check` | Type-check without emitting (both packages) | +| `make clean` | Remove all build artifacts and node_modules | + +### What the Build Does + +1. **Peggy parser generation** — `base/lib/exver/exver.pegjs` is compiled to `exver.ts` (the ExVer version parser) +2. **TypeScript compilation** — Strict mode, CommonJS output, declaration files + - `base/` compiles to `baseDist/` + - `package/` compiles to `dist/` +3. **Hand-written pair copying** — `.js`/`.d.ts` files without a corresponding `.ts` source are copied into the output directories. These are manually maintained JavaScript files with hand-written type declarations. +4. **Dependency bundling** — `node_modules/` is rsynced into both output directories so the published package is self-contained +5. **Formatting** — Prettier formats all TypeScript source +6. **Testing** — Jest runs both test suites + +## Testing + +```bash +# Run all tests (base + package) +make test + +# Run base tests only +make base/test + +# Run package tests only +make package/test + +# Run a specific test file directly +cd base && npx jest --testPathPattern=exver +cd package && npx jest --testPathPattern=host +``` + +Tests use [Jest](https://jestjs.io/) with [ts-jest](https://kulshekhar.github.io/ts-jest/) for TypeScript support. Configuration is in each sub-package's `jest.config.js`. + +### Test Files + +Tests live alongside their subjects or in dedicated `test/` directories: + +- `base/lib/test/` — ExVer parsing, input spec types, deep merge, graph utilities, type validation +- `base/lib/util/inMs.test.ts` — Time conversion utility +- `package/lib/test/` — Health checks, host binding, input spec builder + +Test files use the `.test.ts` extension and are excluded from compilation via `tsconfig.json`. + +## Formatting + +```bash +make fmt +``` + +Runs Prettier with the project config (single quotes, no semicolons, trailing commas, 2-space indent). The Prettier config lives in each sub-package's `package.json`: + +```json +{ + "trailingComma": "all", + "tabWidth": 2, + "semi": false, + "singleQuote": true +} +``` + +## Type Checking + +To check types without building: + +```bash +make check +``` + +Or directly per package: + +```bash +cd base && npm run check +cd package && npm run check +``` + +Both packages use strict TypeScript (`"strict": true`) targeting ES2021 with CommonJS module output. + +## Local Development with a Service Package + +To test SDK changes against a local service package without publishing to npm: + +```bash +# Build and create a local npm link +make link + +# In your service package directory +npm link @start9labs/start-sdk +``` + +This symlinks the built `dist/` into your global node_modules so your service package picks up local changes. + +## Publishing + +```bash +make publish +``` + +This builds the full bundle, then runs `npm publish --access=public --tag=latest` from `dist/`. The published package is `@start9labs/start-sdk`. + +Only the `dist/` directory is published — it contains the compiled JavaScript, declaration files, bundled dependencies, and package metadata. + +## Adding New Features + +### Base vs Package + +Decide where new code belongs: + +- **`base/`** — Types, interfaces, ABI contracts, OS bindings, and low-level builders that have no dependency on the package layer. Code here should be usable independently. +- **`package/`** — Developer-facing API, convenience wrappers, runtime helpers (daemons, health checks, backups, file helpers, subcontainers). Code here imports from base and adds higher-level abstractions. + +### Key Conventions + +- **Builder pattern** — Most APIs use immutable builder chains (`.addDaemon()`, `.mountVolume()`, `.addAction()`). Each call returns a new type that accumulates configuration. +- **Effects boundary** — All runtime interactions go through the `Effects` interface. Never call system APIs directly. +- **Manifest type threading** — The manifest type flows through generics so that volume names, image IDs, and dependency IDs are type-constrained. +- **Re-export from package** — If you add a new export to base, also re-export it from `package/lib/index.ts` (or expose it through `StartSdk.build()`). + +### Adding OS Bindings + +Types in `base/lib/osBindings/` mirror Rust types from the StartOS core. When Rust types change, the corresponding TypeScript bindings need updating. These are re-exported through `base/lib/osBindings/index.ts`. + +### Writing Tests + +- Place test files next to the code they test, or in the `test/` directory +- Use the `.test.ts` extension +- Tests run in Node.js with ts-jest — no browser environment + +## Commit Messages + +Follow [Conventional Commits](https://www.conventionalcommits.org/): + +``` +feat(sdk): add WebSocket health check +fix(sdk): correct ExVer range parsing for pre-release versions +test(sdk): add coverage for MultiHost port binding +``` + +See the root [CONTRIBUTING.md](../CONTRIBUTING.md#commit-messages) for the full convention. diff --git a/sdk/base/LICENSE b/sdk/LICENSE similarity index 100% rename from sdk/base/LICENSE rename to sdk/LICENSE diff --git a/sdk/Makefile b/sdk/Makefile index 57437b7af..c793b3594 100644 --- a/sdk/Makefile +++ b/sdk/Makefile @@ -27,23 +27,24 @@ bundle: baseDist dist | test fmt base/lib/exver/exver.ts: base/node_modules base/lib/exver/exver.pegjs cd base && npm run peggy -baseDist: $(PACKAGE_TS_FILES) $(BASE_TS_FILES) base/package.json base/node_modules base/README.md base/LICENSE +baseDist: $(PACKAGE_TS_FILES) $(BASE_TS_FILES) base/package.json base/node_modules base/README.md LICENSE (cd base && npm run tsc) rsync -ac --include='*.js' --include='*.d.ts' --include='*/' --exclude='*' base/lib/ baseDist/ rsync -ac base/node_modules baseDist/ cp base/package.json baseDist/package.json cp base/README.md baseDist/README.md - cp base/LICENSE baseDist/LICENSE + cp LICENSE baseDist/LICENSE touch baseDist -dist: $(PACKAGE_TS_FILES) $(BASE_TS_FILES) package/package.json package/.npmignore package/node_modules package/README.md package/LICENSE +dist: $(PACKAGE_TS_FILES) $(BASE_TS_FILES) package/package.json package/.npmignore package/node_modules README.md LICENSE CHANGELOG.md (cd package && npm run tsc) rsync -ac --include='*.js' --include='*.d.ts' --include='*/' --exclude='*' base/lib/ dist/base/lib/ rsync -ac package/node_modules dist/ cp package/.npmignore dist/.npmignore cp package/package.json dist/package.json - cp package/README.md dist/README.md - cp package/LICENSE dist/LICENSE + cp README.md dist/README.md + cp LICENSE dist/LICENSE + cp CHANGELOG.md dist/CHANGELOG.md touch dist full-bundle: bundle @@ -71,7 +72,7 @@ base/node_modules: base/package-lock.json node_modules: package/node_modules base/node_modules -publish: bundle package/package.json package/README.md package/LICENSE +publish: bundle package/package.json README.md LICENSE CHANGELOG.md cd dist && npm publish --access=public --tag=latest link: bundle diff --git a/sdk/README.md b/sdk/README.md new file mode 100644 index 000000000..e06b79d8c --- /dev/null +++ b/sdk/README.md @@ -0,0 +1,103 @@ +# Start SDK + +The Start SDK (`@start9labs/start-sdk`) is a TypeScript framework for packaging services to run on [StartOS](https://github.com/Start9Labs/start-os). It provides a strongly-typed, builder-pattern API for defining every aspect of a service package: manifests, daemons, health checks, networking interfaces, actions, backups, dependencies, configuration, and more. + +## Features + +- **Type-safe manifest definitions** - Declare your service's identity, metadata, images, volumes, and dependencies with full TypeScript inference. +- **Daemon management** - Define multi-process topologies with startup ordering, ready probes, and graceful shutdown via `Daemons.of().addDaemon()`. +- **Health checks** - Built-in checks for TCP port listening, HTTP(S) endpoints, and custom scripts, with configurable polling strategies (fixed interval, cooldown, adaptive). +- **Network interfaces** - Bind ports, export UI/API/P2P interfaces, and manage hostnames with MultiHost and ServiceInterfaceBuilder. +- **User actions** - Create interactive operations with validated form inputs (text, select, toggle, list, union/variants, and more) that users can trigger from the StartOS UI. +- **Backup and restore** - Rsync-based volume backups with exclude patterns and custom sync paths. +- **Dependency management** - Declare inter-service dependencies with version ranges, health check requirements, and volume mounts. +- **Configuration file helpers** - Read, write, and merge JSON, YAML, TOML, INI, and ENV files with type-safe `FileHelper`. +- **Reactive subscriptions** - Watch for changes to container IPs, SSL certificates, SMTP config, service status, and more with `const()`, `once()`, `watch()`, `onChange()`, and `waitFor()` patterns. +- **Extended versioning (ExVer)** - Flavor-aware semantic versioning with range matching, supporting independent upstream and downstream version tracking. +- **Internationalization** - Built-in i18n support with locale fallback and parameter substitution. +- **Container execution** - Run commands in subcontainers with volume mounts, environment variables, and entrypoint overrides. +- **Plugin system** - Extensible plugin architecture (e.g. `url-v0` for URL management). + +## Quick Start + +```typescript +import { setupManifest, buildManifest } from '@start9labs/start-sdk' + +const manifest = setupManifest({ + id: 'my-service', + title: 'My Service', + license: 'MIT', + // ... +}) + +export default buildManifest(manifest) +``` + +The primary entry point is the `StartSdk` facade: + +```typescript +import { StartSdk } from '@start9labs/start-sdk' +import { manifest } from './manifest' + +export const sdk = StartSdk.of().withManifest(manifest).build(true) +``` + +From there, `sdk` exposes the full toolkit: + +```typescript +// Define daemons +export const main = sdk.setupMain(async ({ effects }) => + sdk.Daemons.of(effects) + .addDaemon('primary', { /* ... */ }) +) + +// Define actions +export const setName = sdk.Action.withInput('set-name', /* ... */) + +// Define interfaces +export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => { + const multi = sdk.MultiHost.of(effects, 'web') + const origin = await multi.bindPort(80, { protocol: 'http' }) + const ui = sdk.createInterface(effects, { name: 'Web UI', id: 'ui', /* ... */ }) + return [await origin.export([ui])] +}) + +// Define backups +export const { createBackup, restoreBackup } = sdk.setupBackups( + async () => sdk.Backups.ofVolumes('main') +) +``` + +## Packages + +| Package | npm | Description | +|---------|-----|-------------| +| `package/` | `@start9labs/start-sdk` | Full SDK for service developers | +| `base/` | `@start9labs/start-sdk-base` | Core types, ABI definitions, and effects interface | + +## Documentation + +For comprehensive packaging guides, tutorials, and API reference: + +**[docs.start9.com/packaging](https://docs.start9.com/packaging)** + +The packaging docs cover: +- Environment setup and prerequisites +- Project structure and conventions +- Manifest, main, interfaces, actions, and all other service modules +- File models and configuration management +- Versioning, migrations, and initialization +- Dependencies and cross-service communication +- Building and installing `.s9pk` packages + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for environment setup, building from source, testing, and development workflow. + +## Architecture + +See [ARCHITECTURE.md](ARCHITECTURE.md) for a detailed overview of the SDK's internal structure, module responsibilities, and data flow. + +## License + +MIT diff --git a/sdk/package/LICENSE b/sdk/package/LICENSE deleted file mode 100644 index 793257b96..000000000 --- a/sdk/package/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2022 Start9 Labs - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/sdk/package/README.md b/sdk/package/README.md deleted file mode 100644 index d51b25b58..000000000 --- a/sdk/package/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# Start SDK - -## Config Conversion - -- Copy the old config json (from the getConfig.ts) -- Install the start-sdk with `npm i` -- paste the config into makeOutput.ts::oldSpecToBuilder (second param) -- Make the third param - -```ts - { - StartSdk: "start-sdk/lib", - } -``` - -- run the script `npm run buildOutput` to make the output.ts -- Copy this whole file into startos/procedures/config/spec.ts -- Fix all the TODO diff --git a/web/projects/setup-wizard/src/app/components/mok-enrollment.dialog.ts b/web/projects/setup-wizard/src/app/components/mok-enrollment.dialog.ts index 03da02d11..2bb3d1ccc 100644 --- a/web/projects/setup-wizard/src/app/components/mok-enrollment.dialog.ts +++ b/web/projects/setup-wizard/src/app/components/mok-enrollment.dialog.ts @@ -1,31 +1,72 @@ -import { Component } from '@angular/core' +import { Component, inject } from '@angular/core' import { i18nPipe } from '@start9labs/shared' import { TuiButton, TuiDialogContext, TuiIcon } from '@taiga-ui/core' import { injectContext } from '@taiga-ui/polymorpheus' +import { StateService } from '../services/state.service' @Component({ standalone: true, imports: [TuiButton, TuiIcon, i18nPipe], template: ` -
- + @if (!stateService.kiosk) { +
+
+
+
+
+
+
+
+
+

+ {{ + 'Connect a monitor and keyboard to your server before rebooting.' + | i18n + }} +

+ } @else { +
+ +
+

+ {{ 'Keep your monitor connected for the next reboot.' | i18n }} +

+ } + +
+

+ {{ + 'Your system has Secure Boot enabled, which requires all kernel modules to be signed with a trusted key. Some hardware drivers — such as those for NVIDIA GPUs — are not signed by the default distribution key. Enrolling the StartOS signing key allows your firmware to trust these modules so your hardware can be fully utilized.' + | i18n + }} +

+

+ {{ + 'On the next boot, a blue screen (MokManager) will appear. You will have 10 seconds to select "Enroll MOK" before it dismisses.' + | i18n + }} +

+

+ {{ + 'If you miss the window, simply reboot to try again. The blue screen will appear on every boot until the key is enrolled.' + | i18n + }} +

+

+ {{ 'After clicking "Enroll MOK":' | i18n }} +

+
    +
  1. Click "Continue"
  2. +
  3. + {{ 'When prompted, enter your StartOS password' | i18n }} +
  4. +
  5. Click "Reboot"
  6. +
-

{{ 'Secure Boot Key Enrollment' | i18n }}

-

- {{ - 'A signing key was enrolled for Secure Boot. On the next reboot, a blue screen (MokManager) will appear.' - | i18n - }} -

-
    -
  1. Select "Enroll MOK"
  2. -
  3. Select "Continue"
  4. -
  5. {{ 'Enter your StartOS master password when prompted' | i18n }}
  6. -
  7. Select "Reboot"
  8. -
+
`, @@ -41,29 +82,114 @@ import { injectContext } from '@taiga-ui/polymorpheus' margin-bottom: 1rem; } - .mok-icon { + .monitor-icon { width: 3rem; height: 3rem; color: var(--tui-status-info); } - h3 { - margin: 0 0 0.5rem; + .animation-container { + position: relative; + width: 160px; + height: 69px; + } + + .port { + position: absolute; + left: 20px; + top: 50%; + transform: translateY(-50%); + width: 28px; + height: 18px; + background: var(--tui-background-neutral-1); + border: 2px solid var(--tui-border-normal); + border-radius: 2px; + } + + .port-inner { + position: absolute; + top: 3px; + left: 3px; + right: 3px; + bottom: 3px; + background: var(--tui-background-neutral-2); + border-radius: 1px; + } + + .cable { + position: absolute; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + animation: slide-in 2s ease-in-out 0.5s infinite; + left: 130px; + } + + .cable-connector { + width: 18px; + height: 12px; + background: var(--tui-text-secondary); + border-radius: 1px; + } + + .cable-body { + width: 50px; + height: 6px; + background: var(--tui-text-tertiary); + border-radius: 0 3px 3px 0; + } + + @keyframes slide-in { + 0% { + left: 130px; + opacity: 0; + } + 5% { + left: 130px; + opacity: 1; + } + 60% { + left: 32px; + opacity: 1; + } + 80% { + left: 32px; + opacity: 1; + } + 100% { + left: 32px; + opacity: 0; + } + } + + .mok-info { + text-align: left; + margin-top: 0.5rem; + + p { + margin: 0 0 0.75rem; + color: var(--tui-text-secondary); + } + + .steps-label { + margin-bottom: 0.25rem; + font-weight: 500; + color: var(--tui-text-primary); + } + + ol { + margin: 0 0 1rem; + padding-left: 1.5rem; + + li { + margin-bottom: 0.25rem; + } + } } p { margin: 0 0 1rem; - color: var(--tui-text-secondary); - } - - ol { - text-align: left; - margin: 0 0 1.5rem; - padding-left: 1.5rem; - - li { - margin-bottom: 0.25rem; - } } footer { @@ -74,4 +200,5 @@ import { injectContext } from '@taiga-ui/polymorpheus' }) export class MokEnrollmentDialog { protected readonly context = injectContext>() + readonly stateService = inject(StateService) } diff --git a/web/projects/setup-wizard/src/app/pages/success.page.ts b/web/projects/setup-wizard/src/app/pages/success.page.ts index 2a9e74903..d524b67f1 100644 --- a/web/projects/setup-wizard/src/app/pages/success.page.ts +++ b/web/projects/setup-wizard/src/app/pages/success.page.ts @@ -1,10 +1,10 @@ import { AfterViewInit, Component, + DOCUMENT, ElementRef, inject, ViewChild, - DOCUMENT, } from '@angular/core' import { DialogService, @@ -12,17 +12,17 @@ import { ErrorService, i18nPipe, } from '@start9labs/shared' +import { T } from '@start9labs/start-sdk' import { TuiIcon, TuiLoader, TuiTitle } from '@taiga-ui/core' import { TuiAvatar } from '@taiga-ui/kit' import { TuiCardLarge, TuiCell, TuiHeader } from '@taiga-ui/layout' -import { ApiService } from '../services/api.service' -import { StateService } from '../services/state.service' +import { PolymorpheusComponent } from '@taiga-ui/polymorpheus' import { DocumentationComponent } from '../components/documentation.component' import { MatrixComponent } from '../components/matrix.component' import { MokEnrollmentDialog } from '../components/mok-enrollment.dialog' import { RemoveMediaDialog } from '../components/remove-media.dialog' -import { T } from '@start9labs/start-sdk' -import { PolymorpheusComponent } from '@taiga-ui/polymorpheus' +import { ApiService } from '../services/api.service' +import { StateService } from '../services/state.service' @Component({ template: ` @@ -50,7 +50,7 @@ import { PolymorpheusComponent } from '@taiga-ui/polymorpheus' } @else { @if (!stateService.kiosk) { - } - + @if (result.needsRestart) { + + @if (stateService.mokEnrolled) { + + } + + } @else if (stateService.kiosk) { + } @@ -137,22 +175,6 @@ import { PolymorpheusComponent } from '@taiga-ui/polymorpheus' } - @if (stateService.kiosk) { - - } } `, @@ -198,6 +220,7 @@ export default class SuccessPage implements AfterViewInit { lanAddress = '' downloaded = false usbRemoved = false + mokAcknowledged = false rebooting = false rebooted = false @@ -212,8 +235,6 @@ export default class SuccessPage implements AfterViewInit { } download() { - if (this.downloaded) return - const lanElem = this.document.getElementById('lan-addr') if (lanElem) lanElem.innerHTML = this.lanAddress @@ -243,6 +264,19 @@ export default class SuccessPage implements AfterViewInit { }) } + acknowledgeMok() { + this.dialogs + .openComponent(new PolymorpheusComponent(MokEnrollmentDialog), { + label: 'Secure Boot', + size: 'm', + dismissible: false, + closeable: false, + }) + .subscribe(() => { + this.mokAcknowledged = true + }) + } + exitKiosk() { this.api.exit() } @@ -252,6 +286,8 @@ export default class SuccessPage implements AfterViewInit { } async reboot() { + if (this.rebooting || this.rebooted) return + this.rebooting = true try { @@ -276,20 +312,6 @@ export default class SuccessPage implements AfterViewInit { await this.api.exit() } } - - if (this.stateService.mokEnrolled && this.result.needsRestart) { - this.dialogs - .openComponent( - new PolymorpheusComponent(MokEnrollmentDialog), - { - label: 'Secure Boot', - size: 's', - dismissible: false, - closeable: true, - }, - ) - .subscribe() - } } catch (e: any) { this.errorService.handleError(e) } diff --git a/web/projects/setup-wizard/src/app/services/mock-api.service.ts b/web/projects/setup-wizard/src/app/services/mock-api.service.ts index b749e9853..ab03e8eac 100644 --- a/web/projects/setup-wizard/src/app/services/mock-api.service.ts +++ b/web/projects/setup-wizard/src/app/services/mock-api.service.ts @@ -116,7 +116,7 @@ export class MockApiService extends ApiService { return { guid: 'mock-data-guid', attach: !params.dataDrive.wipe, - mokEnrolled: false, + mokEnrolled: true, } } diff --git a/web/projects/shared/src/i18n/dictionaries/de.ts b/web/projects/shared/src/i18n/dictionaries/de.ts index d3b08a699..8bbd10382 100644 --- a/web/projects/shared/src/i18n/dictionaries/de.ts +++ b/web/projects/shared/src/i18n/dictionaries/de.ts @@ -713,4 +713,13 @@ export default { 790: 'Ein Signaturschlüssel wurde für Secure Boot registriert. Beim nächsten Neustart erscheint ein blauer Bildschirm (MokManager).', 791: 'Geben Sie Ihr StartOS-Master-Passwort ein, wenn Sie dazu aufgefordert werden', 792: 'Verstanden', + 793: 'Secure-Boot-Registrierung', + 794: 'Vorbereitung auf die Secure-Boot-Schlüsselregistrierung beim nächsten Neustart', + 795: 'Schließen Sie vor dem Neustart einen Monitor und eine Tastatur an Ihren Server an.', + 796: 'Lassen Sie Ihren Monitor für den nächsten Neustart angeschlossen.', + 797: 'Beim nächsten Start erscheint ein blauer Bildschirm (MokManager). Sie haben 10 Sekunden, um "Enroll MOK" auszuwählen, bevor er verschwindet.', + 798: 'Falls Sie das Zeitfenster verpassen, starten Sie einfach neu und versuchen Sie es erneut. Der blaue Bildschirm erscheint bei jedem Start, bis der Schlüssel registriert ist.', + 799: 'Nach Klick auf "Enroll MOK":', + 800: 'Geben Sie bei Aufforderung Ihr StartOS-Passwort ein', + 801: 'Ihr System hat Secure Boot aktiviert, was erfordert, dass alle Kernel-Module mit einem vertrauenswürdigen Schlüssel signiert sind. Einige Hardware-Treiber \u2014 wie die für NVIDIA-GPUs \u2014 sind nicht mit dem Standard-Distributionsschlüssel signiert. Die Registrierung des StartOS-Signaturschlüssels ermöglicht es Ihrer Firmware, diesen Modulen zu vertrauen, damit Ihre Hardware vollständig genutzt werden kann.', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/en.ts b/web/projects/shared/src/i18n/dictionaries/en.ts index 150d0b7f9..70c10e67a 100644 --- a/web/projects/shared/src/i18n/dictionaries/en.ts +++ b/web/projects/shared/src/i18n/dictionaries/en.ts @@ -714,4 +714,13 @@ export const ENGLISH: Record = { 'A signing key was enrolled for Secure Boot. On the next reboot, a blue screen (MokManager) will appear.': 790, 'Enter your StartOS master password when prompted': 791, 'Got it': 792, + 'Secure Boot Enrollment': 793, + 'Prepare for Secure Boot key enrollment on the next reboot': 794, + 'Connect a monitor and keyboard to your server before rebooting.': 795, + 'Keep your monitor connected for the next reboot.': 796, + 'On the next boot, a blue screen (MokManager) will appear. You will have 10 seconds to select "Enroll MOK" before it dismisses.': 797, + 'If you miss the window, simply reboot to try again. The blue screen will appear on every boot until the key is enrolled.': 798, + 'After clicking "Enroll MOK":': 799, + 'When prompted, enter your StartOS password': 800, + 'Your system has Secure Boot enabled, which requires all kernel modules to be signed with a trusted key. Some hardware drivers \u2014 such as those for NVIDIA GPUs \u2014 are not signed by the default distribution key. Enrolling the StartOS signing key allows your firmware to trust these modules so your hardware can be fully utilized.': 801, } diff --git a/web/projects/shared/src/i18n/dictionaries/es.ts b/web/projects/shared/src/i18n/dictionaries/es.ts index c08883135..81c572249 100644 --- a/web/projects/shared/src/i18n/dictionaries/es.ts +++ b/web/projects/shared/src/i18n/dictionaries/es.ts @@ -713,4 +713,13 @@ export default { 790: 'Se registró una clave de firma para Secure Boot. En el próximo reinicio, aparecerá una pantalla azul (MokManager).', 791: 'Ingrese su contraseña maestra de StartOS cuando se le solicite', 792: 'Entendido', + 793: 'Registro de Secure Boot', + 794: 'Prepararse para el registro de clave de Secure Boot en el próximo reinicio', + 795: 'Conecte un monitor y un teclado a su servidor antes de reiniciar.', + 796: 'Mantenga su monitor conectado para el próximo reinicio.', + 797: 'En el próximo arranque, aparecerá una pantalla azul (MokManager). Tendrá 10 segundos para seleccionar "Enroll MOK" antes de que desaparezca.', + 798: 'Si pierde la oportunidad, simplemente reinicie para intentar de nuevo. La pantalla azul aparecerá en cada arranque hasta que se registre la clave.', + 799: 'Después de hacer clic en "Enroll MOK":', + 800: 'Cuando se le solicite, ingrese su contraseña de StartOS', + 801: 'Su sistema tiene Secure Boot habilitado, lo que requiere que todos los módulos del kernel estén firmados con una clave de confianza. Algunos controladores de hardware \u2014 como los de las GPU NVIDIA \u2014 no están firmados con la clave de distribución predeterminada. Registrar la clave de firma de StartOS permite que su firmware confíe en estos módulos para que su hardware pueda utilizarse completamente.', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/fr.ts b/web/projects/shared/src/i18n/dictionaries/fr.ts index 23dce8287..cc844b9c2 100644 --- a/web/projects/shared/src/i18n/dictionaries/fr.ts +++ b/web/projects/shared/src/i18n/dictionaries/fr.ts @@ -709,8 +709,17 @@ export default { 786: 'Automatique', 787: 'Trafic sortant', 788: 'Utiliser la passerelle', - 789: "Enregistrement de la clé Secure Boot", - 790: "Une clé de signature a été enregistrée pour Secure Boot. Au prochain redémarrage, un écran bleu (MokManager) apparaîtra.", + 789: 'Enregistrement de la clé Secure Boot', + 790: 'Une clé de signature a été enregistrée pour Secure Boot. Au prochain redémarrage, un écran bleu (MokManager) apparaîtra.', 791: 'Entrez votre mot de passe principal StartOS lorsque vous y êtes invité', 792: 'Compris', + 793: 'Enregistrement Secure Boot', + 794: "Préparez-vous à l'enregistrement de la clé Secure Boot au prochain redémarrage", + 795: 'Connectez un moniteur et un clavier à votre serveur avant de redémarrer.', + 796: 'Gardez votre moniteur connecté pour le prochain redémarrage.', + 797: 'Au prochain démarrage, un écran bleu (MokManager) apparaîtra. Vous aurez 10 secondes pour sélectionner "Enroll MOK" avant qu\'il ne disparaisse.', + 798: "Si vous manquez la fenêtre, redémarrez simplement pour réessayer. L'écran bleu apparaîtra à chaque démarrage jusqu'à ce que la clé soit enregistrée.", + 799: 'Après avoir cliqué sur "Enroll MOK" :', + 800: 'Lorsque vous y êtes invité, entrez votre mot de passe StartOS', + 801: "Votre système a Secure Boot activé, ce qui exige que tous les modules du noyau soient signés avec une clé de confiance. Certains pilotes matériels \u2014 comme ceux des GPU NVIDIA \u2014 ne sont pas signés par la clé de distribution par défaut. L'enregistrement de la clé de signature StartOS permet à votre firmware de faire confiance à ces modules afin que votre matériel puisse être pleinement utilisé.", } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/pl.ts b/web/projects/shared/src/i18n/dictionaries/pl.ts index deef38a37..a241ae2ff 100644 --- a/web/projects/shared/src/i18n/dictionaries/pl.ts +++ b/web/projects/shared/src/i18n/dictionaries/pl.ts @@ -713,4 +713,13 @@ export default { 790: 'Klucz podpisu został zarejestrowany dla Secure Boot. Przy następnym uruchomieniu pojawi się niebieski ekran (MokManager).', 791: 'Wprowadź swoje hasło główne StartOS po wyświetleniu monitu', 792: 'Rozumiem', + 793: 'Rejestracja Secure Boot', + 794: 'Przygotuj się do rejestracji klucza Secure Boot przy następnym uruchomieniu', + 795: 'Podłącz monitor i klawiaturę do serwera przed ponownym uruchomieniem.', + 796: 'Pozostaw monitor podłączony do następnego uruchomienia.', + 797: 'Przy następnym uruchomieniu pojawi się niebieski ekran (MokManager). Będziesz mieć 10 sekund na wybranie "Enroll MOK", zanim zniknie.', + 798: 'Jeśli przegapisz okno, po prostu uruchom ponownie i spróbuj jeszcze raz. Niebieski ekran będzie pojawiał się przy każdym uruchomieniu, dopóki klucz nie zostanie zarejestrowany.', + 799: 'Po kliknięciu "Enroll MOK":', + 800: 'Po wyświetleniu monitu wprowadź swoje hasło StartOS', + 801: 'Twój system ma włączony Secure Boot, co wymaga, aby wszystkie moduły jądra były podpisane zaufanym kluczem. Niektóre sterowniki sprzętowe \u2014 takie jak te dla GPU NVIDIA \u2014 nie są podpisane domyślnym kluczem dystrybucji. Zarejestrowanie klucza podpisu StartOS pozwala firmware ufać tym modułom, aby sprzęt mógł być w pełni wykorzystany.', } satisfies i18n diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/devices/add.ts b/web/projects/start-tunnel/src/app/routes/home/routes/devices/add.ts index 990ddf10d..56e16a5f3 100644 --- a/web/projects/start-tunnel/src/app/routes/home/routes/devices/add.ts +++ b/web/projects/start-tunnel/src/app/routes/home/routes/devices/add.ts @@ -6,7 +6,6 @@ import { Validators, } from '@angular/forms' import { ErrorService, LoadingService } from '@start9labs/shared' -import { utils } from '@start9labs/start-sdk' import { TUI_IS_MOBILE, TuiAutoFocus, @@ -121,15 +120,23 @@ export class DevicesAdd { protected readonly context = injectContext>() + private readonly autoSubnet = + !this.context.data.device && this.context.data.subnets().length === 1 + ? this.context.data.subnets().at(0) + : undefined + protected readonly form = inject(NonNullableFormBuilder).group({ name: [this.context.data.device?.name || '', Validators.required], subnet: [ - this.context.data.device?.subnet, + this.context.data.device?.subnet ?? this.autoSubnet, [Validators.required, subnetValidator], ], ip: [ - this.context.data.device?.ip || '', - [Validators.required, Validators.pattern(utils.Patterns.ipv4.regex)], + this.context.data.device?.ip || + (this.autoSubnet ? getIp(this.autoSubnet) : ''), + this.autoSubnet + ? [Validators.required, ipInSubnetValidator(this.autoSubnet.range)] + : [], ], }) diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/add.ts b/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/add.ts index ab9f9560f..9feed11b3 100644 --- a/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/add.ts +++ b/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/add.ts @@ -22,10 +22,10 @@ import { TuiCheckbox, TuiChevron, TuiDataListWrapper, + TuiElasticContainer, TuiFieldErrorPipe, TuiInputNumber, TuiSelect, - TuiElasticContainer, } from '@taiga-ui/kit' import { TuiForm } from '@taiga-ui/layout' import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' @@ -167,7 +167,12 @@ export class PortForwardsAdd { protected readonly form = inject(NonNullableFormBuilder).group({ label: ['', Validators.required], - externalip: ['', Validators.required], + externalip: [ + this.context.data.ips().length === 1 + ? (this.context.data.ips().at(0) ?? '') + : '', + Validators.required, + ], externalport: [null as number | null, Validators.required], device: [null as MappedDevice | null, Validators.required], internalport: [null as number | null, Validators.required], diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/utils.ts b/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/utils.ts index 861ea5528..0afb346f5 100644 --- a/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/utils.ts +++ b/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/utils.ts @@ -16,6 +16,6 @@ export interface MappedForward { } export interface PortForwardsData { - readonly ips: Signal + readonly ips: Signal readonly devices: Signal } From 3cf9dbc6d2e850f0a09f7f2aad28afbd6f70466d Mon Sep 17 00:00:00 2001 From: Matt Hill Date: Thu, 12 Mar 2026 17:35:25 -0600 Subject: [PATCH 41/71] update docs links --- .../src/app/components/documentation.component.ts | 2 +- .../src/app/routes/login/ca-wizard/ca-wizard.component.html | 2 +- .../app/routes/portal/components/header/menu.component.ts | 2 +- .../routes/portal/routes/backups/modals/jobs.component.ts | 2 +- .../portal/routes/backups/modals/targets.component.ts | 2 +- .../src/app/routes/portal/routes/metrics/time.component.ts | 2 +- .../system/routes/authorities/authorities.component.ts | 2 +- .../routes/system/routes/backups/backups.component.ts | 6 +++--- .../routes/portal/routes/system/routes/dns/dns.component.ts | 2 +- .../routes/system/routes/gateways/gateways.component.ts | 4 ++-- .../portal/routes/system/routes/smtp/smtp.component.ts | 2 +- .../routes/portal/routes/system/routes/ssh/ssh.component.ts | 2 +- .../portal/routes/system/routes/wifi/wifi.component.ts | 2 +- 13 files changed, 16 insertions(+), 16 deletions(-) diff --git a/web/projects/setup-wizard/src/app/components/documentation.component.ts b/web/projects/setup-wizard/src/app/components/documentation.component.ts index 07d1b4a12..f8ccc25f4 100644 --- a/web/projects/setup-wizard/src/app/components/documentation.component.ts +++ b/web/projects/setup-wizard/src/app/components/documentation.component.ts @@ -46,7 +46,7 @@ import { DocsLinkDirective } from '@start9labs/shared' Download your server's Root CA and follow instructions diff --git a/web/projects/ui/src/app/routes/login/ca-wizard/ca-wizard.component.html b/web/projects/ui/src/app/routes/login/ca-wizard/ca-wizard.component.html index 52c87c61c..18bb8b3e9 100644 --- a/web/projects/ui/src/app/routes/login/ca-wizard/ca-wizard.component.html +++ b/web/projects/ui/src/app/routes/login/ca-wizard/ca-wizard.component.html @@ -46,7 +46,7 @@ tuiButton docsLink size="s" - path="/start-os/user-manual/trust-ca.html" + path="/start-os/trust-ca.html" iconEnd="@tui.external-link" > {{ 'View instructions' | i18n }} diff --git a/web/projects/ui/src/app/routes/portal/components/header/menu.component.ts b/web/projects/ui/src/app/routes/portal/components/header/menu.component.ts index 55f5bc354..d11f7935b 100644 --- a/web/projects/ui/src/app/routes/portal/components/header/menu.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/header/menu.component.ts @@ -55,7 +55,7 @@ import { ABOUT } from './about.component' docsLink new iconStart="@tui.book-open-text" - path="/start-os/user-manual" + path="/start-os/index.html" > {{ 'User manual' | i18n }} diff --git a/web/projects/ui/src/app/routes/portal/routes/backups/modals/jobs.component.ts b/web/projects/ui/src/app/routes/portal/routes/backups/modals/jobs.component.ts index 970745044..a4fad6357 100644 --- a/web/projects/ui/src/app/routes/portal/routes/backups/modals/jobs.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/backups/modals/jobs.component.ts @@ -26,7 +26,7 @@ import { DocsLinkDirective } from 'projects/shared/src/public-api' Scheduling automatic backups is an excellent way to ensure your StartOS data is safely backed up. StartOS will issue a notification whenever one of your scheduled backups succeeds or fails. - + View instructions diff --git a/web/projects/ui/src/app/routes/portal/routes/backups/modals/targets.component.ts b/web/projects/ui/src/app/routes/portal/routes/backups/modals/targets.component.ts index 6bde58daa..6ed523e97 100644 --- a/web/projects/ui/src/app/routes/portal/routes/backups/modals/targets.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/backups/modals/targets.component.ts @@ -31,7 +31,7 @@ import { DocsLinkDirective } from 'projects/shared/src/public-api' backups. They can be physical drives plugged into your server, shared folders on your Local Area Network (LAN), or third party clouds such as Dropbox or Google Drive. - + View instructions diff --git a/web/projects/ui/src/app/routes/portal/routes/metrics/time.component.ts b/web/projects/ui/src/app/routes/portal/routes/metrics/time.component.ts index b6f1247e7..9bc6da0ad 100644 --- a/web/projects/ui/src/app/routes/portal/routes/metrics/time.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/metrics/time.component.ts @@ -49,7 +49,7 @@ import { TimeService } from 'src/app/services/time.service' docsLink iconEnd="@tui.external-link" appearance="" - path="/start-os/faq/index.html" + path="/start-os/faq.html" fragment="#clock-sync-failure" [pseudo]="true" [textContent]="'the docs' | i18n" diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/authorities/authorities.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/authorities/authorities.component.ts index 330849f52..590da78ae 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/authorities/authorities.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/authorities/authorities.component.ts @@ -21,7 +21,7 @@ import { AuthoritiesTableComponent } from './table.component' tuiIconButton size="xs" docsLink - path="/start-os/user-manual/trust-ca.html" + path="/start-os/trust-ca.html" appearance="icon" iconStart="@tui.book-open-text" > diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/backups.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/backups.component.ts index c4b4bdc0c..fe3471190 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/backups.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/backups.component.ts @@ -61,7 +61,7 @@ import { BACKUP_RESTORE } from './restore.component' diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/gateways.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/gateways.component.ts index 1b96e5f07..13d02b26e 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/gateways.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/gateways.component.ts @@ -42,7 +42,7 @@ import { GatewaysTableComponent } from './table.component' tuiIconButton size="xs" docsLink - path="/start-os/user-manual/gateways.html" + path="/start-os/gateways.html" appearance="icon" iconStart="@tui.book-open-text" > @@ -71,7 +71,7 @@ import { GatewaysTableComponent } from './table.component' tuiIconButton size="xs" docsLink - path="/start-os/user-manual/gateways.html" + path="/start-os/gateways.html" fragment="#outbound-traffic" appearance="icon" iconStart="@tui.book-open-text" diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/smtp/smtp.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/smtp/smtp.component.ts index ed037a25b..6559a028e 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/smtp/smtp.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/smtp/smtp.component.ts @@ -55,7 +55,7 @@ function detectProviderKey(host: string | undefined): string { tuiIconButton size="xs" docsLink - path="/start-os/user-manual/smtp.html" + path="/start-os/smtp.html" appearance="icon" iconStart="@tui.book-open-text" > diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/ssh/ssh.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/ssh/ssh.component.ts index 500acb399..101d26ea7 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/ssh/ssh.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/ssh/ssh.component.ts @@ -39,7 +39,7 @@ import { SSHTableComponent } from './table.component' tuiIconButton size="xs" docsLink - path="/start-os/user-manual/ssh.html" + path="/start-os/ssh.html" appearance="icon" iconStart="@tui.book-open-text" > diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/wifi/wifi.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/wifi/wifi.component.ts index 24a30ab15..ce4ee3fee 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/wifi/wifi.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/wifi/wifi.component.ts @@ -54,7 +54,7 @@ import { wifiSpec } from './wifi.const' tuiIconButton size="xs" docsLink - path="/start-os/user-manual/wifi.html" + path="/start-os/wifi.html" appearance="icon" iconStart="@tui.book-open-text" > From e2804f9b88e5554caf1cfdcc60773536a040e4fb Mon Sep 17 00:00:00 2001 From: Matt Hill Date: Thu, 12 Mar 2026 23:16:59 -0600 Subject: [PATCH 42/71] update workflows --- .github/actions/setup-build/action.yml | 10 +++++----- .github/workflows/start-cli.yaml | 4 ++-- .github/workflows/start-registry.yaml | 14 +++++++------- .github/workflows/start-tunnel.yaml | 4 ++-- .github/workflows/startos-iso.yaml | 16 ++++++++-------- .github/workflows/test.yaml | 2 +- 6 files changed, 25 insertions(+), 25 deletions(-) diff --git a/.github/actions/setup-build/action.yml b/.github/actions/setup-build/action.yml index b8efc4ebb..7cfcd4cc8 100644 --- a/.github/actions/setup-build/action.yml +++ b/.github/actions/setup-build/action.yml @@ -54,11 +54,11 @@ runs: - name: Set up Python if: inputs.setup-python == 'true' - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.x" - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: ${{ inputs.nodejs-version }} cache: npm @@ -66,15 +66,15 @@ runs: - name: Set up Docker QEMU if: inputs.setup-docker == 'true' - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx if: inputs.setup-docker == 'true' - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Configure sccache if: inputs.setup-sccache == 'true' - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | core.exportVariable('ACTIONS_RESULTS_URL', process.env.ACTIONS_RESULTS_URL || ''); diff --git a/.github/workflows/start-cli.yaml b/.github/workflows/start-cli.yaml index d536e6faf..7baf2e7f0 100644 --- a/.github/workflows/start-cli.yaml +++ b/.github/workflows/start-cli.yaml @@ -68,7 +68,7 @@ jobs: - name: Mount tmpfs if: ${{ github.event.inputs.runner == 'fast' }} run: sudo mount -t tmpfs tmpfs . - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: submodules: recursive - uses: ./.github/actions/setup-build @@ -82,7 +82,7 @@ jobs: SCCACHE_GHA_ENABLED: on SCCACHE_GHA_VERSION: 0 - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: start-cli_${{ matrix.triple }} path: core/target/${{ matrix.triple }}/release/start-cli diff --git a/.github/workflows/start-registry.yaml b/.github/workflows/start-registry.yaml index 29e462795..3e763cb7c 100644 --- a/.github/workflows/start-registry.yaml +++ b/.github/workflows/start-registry.yaml @@ -64,7 +64,7 @@ jobs: - name: Mount tmpfs if: ${{ github.event.inputs.runner == 'fast' }} run: sudo mount -t tmpfs tmpfs . - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: submodules: recursive - uses: ./.github/actions/setup-build @@ -78,7 +78,7 @@ jobs: SCCACHE_GHA_ENABLED: on SCCACHE_GHA_VERSION: 0 - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: start-registry_${{ matrix.arch }}.deb path: results/start-registry-*_${{ matrix.arch }}.deb @@ -102,13 +102,13 @@ jobs: if: ${{ github.event.inputs.runner == 'fast' }} - name: Set up docker QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: "Login to GitHub Container Registry" - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{github.actor}} @@ -116,14 +116,14 @@ jobs: - name: Docker meta id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ghcr.io/Start9Labs/startos-registry tags: | type=raw,value=${{ github.ref_name }} - name: Download debian package - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: pattern: start-registry_*.deb diff --git a/.github/workflows/start-tunnel.yaml b/.github/workflows/start-tunnel.yaml index 1e15c324a..43b7fb5de 100644 --- a/.github/workflows/start-tunnel.yaml +++ b/.github/workflows/start-tunnel.yaml @@ -64,7 +64,7 @@ jobs: - name: Mount tmpfs if: ${{ github.event.inputs.runner == 'fast' }} run: sudo mount -t tmpfs tmpfs . - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: submodules: recursive - uses: ./.github/actions/setup-build @@ -78,7 +78,7 @@ jobs: SCCACHE_GHA_ENABLED: on SCCACHE_GHA_VERSION: 0 - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: start-tunnel_${{ matrix.arch }}.deb path: results/start-tunnel-*_${{ matrix.arch }}.deb diff --git a/.github/workflows/startos-iso.yaml b/.github/workflows/startos-iso.yaml index 40dec852b..543bc739c 100644 --- a/.github/workflows/startos-iso.yaml +++ b/.github/workflows/startos-iso.yaml @@ -100,7 +100,7 @@ jobs: - name: Mount tmpfs if: ${{ github.event.inputs.runner == 'fast' }} run: sudo mount -t tmpfs tmpfs . - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: submodules: recursive - uses: ./.github/actions/setup-build @@ -114,7 +114,7 @@ jobs: SCCACHE_GHA_ENABLED: on SCCACHE_GHA_VERSION: 0 - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: compiled-${{ matrix.arch }}.tar path: compiled-${{ matrix.arch }}.tar @@ -209,14 +209,14 @@ jobs: run: sudo mkdir -p /opt/hostedtoolcache && sudo chown $USER:$USER /opt/hostedtoolcache - name: Set up docker QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: submodules: recursive - name: Download compiled artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: compiled-${{ env.ARCH }}.tar @@ -253,18 +253,18 @@ jobs: run: PLATFORM=${{ matrix.platform }} make img if: ${{ matrix.platform == 'raspberrypi' }} - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: ${{ matrix.platform }}.squashfs path: results/*.squashfs - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: ${{ matrix.platform }}.iso path: results/*.iso if: ${{ matrix.platform != 'raspberrypi' }} - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: ${{ matrix.platform }}.img path: results/*.img diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 94fac399f..426acfaee 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -24,7 +24,7 @@ jobs: if: github.event.pull_request.draft != true runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: submodules: recursive - uses: ./.github/actions/setup-build From 9f36bc5b5d55d57039904b5cf2cabc40d028843d Mon Sep 17 00:00:00 2001 From: Matt Hill Date: Fri, 13 Mar 2026 10:09:05 -0600 Subject: [PATCH 43/71] always show package id --- .../app/routes/portal/routes/notifications/item.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/projects/ui/src/app/routes/portal/routes/notifications/item.component.ts b/web/projects/ui/src/app/routes/portal/routes/notifications/item.component.ts index 29d27b7f0..1bb4be25b 100644 --- a/web/projects/ui/src/app/routes/portal/routes/notifications/item.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/notifications/item.component.ts @@ -44,7 +44,7 @@ import { DataModel } from 'src/app/services/patch-db/data-model' {{ item.packageId || '-' }} } } @else { - - + {{ item.packageId || '-' }} } From d8663cd3ae1bb237d487348ab11c06017b8ea20d Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Fri, 13 Mar 2026 12:03:10 -0600 Subject: [PATCH 44/71] fix: use ip route replace to avoid connectivity gap on gateway changes Replace the flush+add cycle in apply_policy_routing with ip route replace for each desired route, then delete stale routes. This eliminates the window where the per-interface routing table is empty, which caused temporary connectivity loss on other gateways. --- core/src/net/gateway.rs | 68 +++++++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 16 deletions(-) diff --git a/core/src/net/gateway.rs b/core/src/net/gateway.rs index 33a608f14..91a012df1 100644 --- a/core/src/net/gateway.rs +++ b/core/src/net/gateway.rs @@ -1018,18 +1018,16 @@ async fn apply_policy_routing( }) .copied(); - // Flush and rebuild per-interface routing table. - // Clone all non-default routes from the main table so that LAN IPs on - // other subnets remain reachable when the priority-75 catch-all overrides - // default routing, then replace the default route with this interface's. - Command::new("ip") - .arg("route") - .arg("flush") - .arg("table") - .arg(&table_str) - .invoke(ErrorKind::Network) - .await - .log_err(); + // Rebuild per-interface routing table using `ip route replace` to avoid + // the connectivity gap that a flush+add cycle would create. We replace + // every desired route in-place (each replace is atomic in the kernel), + // then delete any stale routes that are no longer in the desired set. + + // Collect the set of desired non-default route prefixes (the first + // whitespace-delimited token of each `ip route show` line is the + // destination prefix, e.g. "192.168.1.0/24" or "10.0.0.0/8"). + let mut desired_prefixes = BTreeSet::::new(); + if let Ok(main_routes) = Command::new("ip") .arg("route") .arg("show") @@ -1044,11 +1042,14 @@ async fn apply_policy_routing( if line.is_empty() || line.starts_with("default") { continue; } + if let Some(prefix) = line.split_whitespace().next() { + desired_prefixes.insert(prefix.to_owned()); + } let mut cmd = Command::new("ip"); - cmd.arg("route").arg("add"); + cmd.arg("route").arg("replace"); for part in line.split_whitespace() { // Skip status flags that appear in route output but - // are not valid for `ip route add`. + // are not valid for `ip route replace`. if part == "linkdown" || part == "dead" { continue; } @@ -1058,10 +1059,11 @@ async fn apply_policy_routing( cmd.invoke(ErrorKind::Network).await.log_err(); } } - // Add default route via this interface's gateway + + // Replace the default route via this interface's gateway. { let mut cmd = Command::new("ip"); - cmd.arg("route").arg("add").arg("default"); + cmd.arg("route").arg("replace").arg("default"); if let Some(gw) = ipv4_gateway { cmd.arg("via").arg(gw.to_string()); } @@ -1075,6 +1077,40 @@ async fn apply_policy_routing( cmd.invoke(ErrorKind::Network).await.log_err(); } + // Delete stale routes: any non-default route in the per-interface table + // whose prefix is not in the desired set. + if let Ok(existing_routes) = Command::new("ip") + .arg("route") + .arg("show") + .arg("table") + .arg(&table_str) + .invoke(ErrorKind::Network) + .await + .and_then(|b| String::from_utf8(b).with_kind(ErrorKind::Utf8)) + { + for line in existing_routes.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with("default") { + continue; + } + let Some(prefix) = line.split_whitespace().next() else { + continue; + }; + if desired_prefixes.contains(prefix) { + continue; + } + Command::new("ip") + .arg("route") + .arg("del") + .arg(prefix) + .arg("table") + .arg(&table_str) + .invoke(ErrorKind::Network) + .await + .log_err(); + } + } + // Ensure global CONNMARK restore rules in mangle PREROUTING (forwarded // packets) and OUTPUT (locally-generated replies). Both are needed: // PREROUTING handles DNAT-forwarded traffic, OUTPUT handles replies from From a81b1aa5a6f339e4abd0d65d9fa66c41083022fd Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Fri, 13 Mar 2026 12:03:37 -0600 Subject: [PATCH 45/71] feat: wait for db commit after tunnel add/remove Add a typed DbWatch at the end of add_tunnel and remove_tunnel that waits up to 15s for the sync loop to commit the gateway state change to patch-db before returning. --- core/locales/i18n.yaml | 15 +++++++++++ core/src/net/tunnel.rs | 56 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/core/locales/i18n.yaml b/core/locales/i18n.yaml index 856617307..0b7a688a2 100644 --- a/core/locales/i18n.yaml +++ b/core/locales/i18n.yaml @@ -1379,6 +1379,21 @@ net.tor.client-error: fr_FR: "Erreur du client Tor : %{error}" pl_PL: "Błąd klienta Tor: %{error}" +# net/tunnel.rs +net.tunnel.timeout-waiting-for-add: + en_US: "timed out waiting for gateway %{gateway} to appear in database" + de_DE: "Zeitüberschreitung beim Warten auf das Erscheinen von Gateway %{gateway} in der Datenbank" + es_ES: "se agotó el tiempo esperando que la puerta de enlace %{gateway} aparezca en la base de datos" + fr_FR: "délai d'attente dépassé pour l'apparition de la passerelle %{gateway} dans la base de données" + pl_PL: "upłynął limit czasu oczekiwania na pojawienie się bramy %{gateway} w bazie danych" + +net.tunnel.timeout-waiting-for-remove: + en_US: "timed out waiting for gateway %{gateway} to be removed from database" + de_DE: "Zeitüberschreitung beim Warten auf das Entfernen von Gateway %{gateway} aus der Datenbank" + es_ES: "se agotó el tiempo esperando que la puerta de enlace %{gateway} sea eliminada de la base de datos" + fr_FR: "délai d'attente dépassé pour la suppression de la passerelle %{gateway} de la base de données" + pl_PL: "upłynął limit czasu oczekiwania na usunięcie bramy %{gateway} z bazy danych" + # net/wifi.rs net.wifi.ssid-no-special-characters: en_US: "SSID may not have special characters" diff --git a/core/src/net/tunnel.rs b/core/src/net/tunnel.rs index da0f6d84c..694434514 100644 --- a/core/src/net/tunnel.rs +++ b/core/src/net/tunnel.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use clap::Parser; use imbl_value::InternedString; use patch_db::json_ptr::JsonPointer; @@ -8,7 +10,9 @@ use ts_rs::TS; use crate::GatewayId; use crate::context::{CliContext, RpcContext}; -use crate::db::model::public::{GatewayType, NetworkInterfaceInfo, NetworkInterfaceType}; +use crate::db::model::public::{ + GatewayType, NetworkInfo, NetworkInterfaceInfo, NetworkInterfaceType, +}; use crate::net::host::all_hosts; use crate::prelude::*; use crate::util::Invoke; @@ -139,6 +143,34 @@ pub async fn add_tunnel( .result?; } + // Wait for the sync loop to fully commit gateway state (addresses, hosts) + // to the database, with a 15-second timeout. + if tokio::time::timeout(Duration::from_secs(15), async { + let mut watch = ctx + .db + .watch("/public/serverInfo/network".parse::().unwrap()) + .await + .typed::(); + loop { + if watch + .peek()? + .as_gateways() + .as_idx(&iface) + .and_then(|g| g.as_ip_info().transpose_ref()) + .is_some() + { + break; + } + watch.changed().await?; + } + Ok::<_, Error>(()) + }) + .await + .is_err() + { + tracing::warn!("{}", t!("net.tunnel.timeout-waiting-for-add", gateway = iface.as_str())); + } + Ok(iface) } @@ -224,5 +256,27 @@ pub async fn remove_tunnel( .await .result?; + // Wait for the sync loop to fully commit gateway removal to the database, + // with a 15-second timeout. + if tokio::time::timeout(Duration::from_secs(15), async { + let mut watch = ctx + .db + .watch("/public/serverInfo/network".parse::().unwrap()) + .await + .typed::(); + loop { + if watch.peek()?.as_gateways().as_idx(&id).is_none() { + break; + } + watch.changed().await?; + } + Ok::<_, Error>(()) + }) + .await + .is_err() + { + tracing::warn!("{}", t!("net.tunnel.timeout-waiting-for-remove", gateway = id.as_str())); + } + Ok(()) } From fc4b887b714e2429c428c576a15449b345c12d5f Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Fri, 13 Mar 2026 12:08:49 -0600 Subject: [PATCH 46/71] fix: raspberry pi image build improvements - Move firmware config files to boot/firmware/ to match raspi-firmware package layout in Debian Trixie - Use nested mounts (firmware and efi inside boot) so squashfs boot files land on the correct partitions without manual splitting - Pre-calculate root partition size from squashfs instead of creating oversized btrfs and shrinking (avoids ioctl failure on loop devices) - Use named loop devices (/dev/startos-loop-*) with automatic cleanup of stale devices from previous failed builds - Use --rbind for /boot in upgrade scripts so nested mounts (efi, firmware) are automatically carried into the chroot --- build/image-recipe/build.sh | 92 +++++++++---------- .../squashfs/boot/{ => firmware}/config.sh | 0 .../squashfs/boot/{ => firmware}/config.txt | 0 build/lib/scripts/chroot-and-upgrade | 2 +- build/lib/scripts/upgrade | 11 +-- 5 files changed, 46 insertions(+), 59 deletions(-) rename build/image-recipe/raspberrypi/squashfs/boot/{ => firmware}/config.sh (100%) rename build/image-recipe/raspberrypi/squashfs/boot/{ => firmware}/config.txt (100%) diff --git a/build/image-recipe/build.sh b/build/image-recipe/build.sh index bde6fd360..e35b21b9a 100755 --- a/build/image-recipe/build.sh +++ b/build/image-recipe/build.sh @@ -1,7 +1,6 @@ #!/bin/bash set -e -MAX_IMG_LEN=$((4 * 1024 * 1024 * 1024)) # 4GB echo "==== StartOS Image Build ====" @@ -332,10 +331,10 @@ fi if [ "${IB_TARGET_PLATFORM}" = "raspberrypi" ]; then ln -sf /usr/bin/pi-beep /usr/local/bin/beep - sh /boot/config.sh > /boot/config.txt + sh /boot/firmware/config.sh > /boot/firmware/config.txt mkinitramfs -c gzip -o /boot/initrd.img-${RPI_KERNEL_VERSION}-rpi-v8 ${RPI_KERNEL_VERSION}-rpi-v8 mkinitramfs -c gzip -o /boot/initrd.img-${RPI_KERNEL_VERSION}-rpi-2712 ${RPI_KERNEL_VERSION}-rpi-2712 - cp /usr/lib/u-boot/rpi_arm64/u-boot.bin /boot/u-boot.bin + cp /usr/lib/u-boot/rpi_arm64/u-boot.bin /boot/firmware/u-boot.bin fi useradd --shell /bin/bash -G startos -m start9 @@ -411,7 +410,16 @@ elif [ "${IMAGE_TYPE}" = img ]; then BOOT_LEN=$((2 * 1024 * 1024 * 1024)) BOOT_END=$((BOOT_START + BOOT_LEN - 1)) ROOT_START=$((BOOT_END + 1)) - ROOT_LEN=$((MAX_IMG_LEN - ROOT_START)) + + # Size root partition to fit the squashfs + 256MB overhead for btrfs + # metadata and config overlay, avoiding the need for btrfs resize + SQUASHFS_SIZE=$(stat -c %s $prep_results_dir/binary/live/filesystem.squashfs) + ROOT_LEN=$(( SQUASHFS_SIZE + 256 * 1024 * 1024 )) + # Align to sector boundary + ROOT_LEN=$(( (ROOT_LEN + SECTOR_LEN - 1) / SECTOR_LEN * SECTOR_LEN )) + + # Total image: partitions + GPT backup header (34 sectors) + IMG_LEN=$((ROOT_START + ROOT_LEN + 34 * SECTOR_LEN)) # Fixed GPT partition UUIDs (deterministic, based on old MBR disk ID cb15ae4d) FW_UUID=cb15ae4d-0001-4000-8000-000000000001 @@ -420,7 +428,7 @@ elif [ "${IMAGE_TYPE}" = img ]; then ROOT_UUID=cb15ae4d-0004-4000-8000-000000000004 TARGET_NAME=$prep_results_dir/${IMAGE_BASENAME}.img - truncate -s $MAX_IMG_LEN $TARGET_NAME + truncate -s $IMG_LEN $TARGET_NAME sfdisk $TARGET_NAME <<-EOF label: gpt @@ -431,10 +439,23 @@ elif [ "${IMAGE_TYPE}" = img ]; then ${TARGET_NAME}4 : start=$((ROOT_START / SECTOR_LEN)), size=$((ROOT_LEN / SECTOR_LEN)), type=B921B045-1DF0-41C3-AF44-4C6F280D3FAE, uuid=${ROOT_UUID}, name="root" EOF - FW_DEV=$(losetup --show -f --offset $FW_START --sizelimit $FW_LEN $TARGET_NAME) - ESP_DEV=$(losetup --show -f --offset $ESP_START --sizelimit $ESP_LEN $TARGET_NAME) - BOOT_DEV=$(losetup --show -f --offset $BOOT_START --sizelimit $BOOT_LEN $TARGET_NAME) - ROOT_DEV=$(losetup --show -f --offset $ROOT_START --sizelimit $ROOT_LEN $TARGET_NAME) + # Create named loop device nodes (high minor numbers to avoid conflicts) + # and detach any stale ones from previous failed builds + FW_DEV=/dev/startos-loop-fw + ESP_DEV=/dev/startos-loop-esp + BOOT_DEV=/dev/startos-loop-boot + ROOT_DEV=/dev/startos-loop-root + for dev in $FW_DEV:200 $ESP_DEV:201 $BOOT_DEV:202 $ROOT_DEV:203; do + name=${dev%:*} + minor=${dev#*:} + [ -e $name ] || mknod $name b 7 $minor + losetup -d $name 2>/dev/null || true + done + + losetup $FW_DEV --offset $FW_START --sizelimit $FW_LEN $TARGET_NAME + losetup $ESP_DEV --offset $ESP_START --sizelimit $ESP_LEN $TARGET_NAME + losetup $BOOT_DEV --offset $BOOT_START --sizelimit $BOOT_LEN $TARGET_NAME + losetup $ROOT_DEV --offset $ROOT_START --sizelimit $ROOT_LEN $TARGET_NAME mkfs.vfat -F32 -n firmware $FW_DEV mkfs.vfat -F32 -n efi $ESP_DEV @@ -447,18 +468,16 @@ elif [ "${IMAGE_TYPE}" = img ]; then BOOT_STAGING=$(mktemp -d) unsquashfs -n -f -d $BOOT_STAGING $prep_results_dir/binary/live/filesystem.squashfs boot - # Mount partitions - mkdir -p $TMPDIR/firmware $TMPDIR/efi $TMPDIR/boot $TMPDIR/root - mount $FW_DEV $TMPDIR/firmware - mount $ESP_DEV $TMPDIR/efi + # Mount partitions (nested: firmware and efi inside boot) + mkdir -p $TMPDIR/boot $TMPDIR/root mount $BOOT_DEV $TMPDIR/boot + mkdir -p $TMPDIR/boot/firmware $TMPDIR/boot/efi + mount $FW_DEV $TMPDIR/boot/firmware + mount $ESP_DEV $TMPDIR/boot/efi mount $ROOT_DEV $TMPDIR/root - # Split boot files: firmware to Part 1, kernels/initramfs to Part 3 (/boot) - cp -a $BOOT_STAGING/boot/. $TMPDIR/firmware/ - for f in $TMPDIR/firmware/vmlinuz-* $TMPDIR/firmware/initrd.img-* $TMPDIR/firmware/System.map-* $TMPDIR/firmware/config-*; do - [ -e "$f" ] && mv "$f" $TMPDIR/boot/ - done + # Copy boot files — nested mounts route firmware/* to the firmware partition + cp -a $BOOT_STAGING/boot/. $TMPDIR/boot/ rm -rf $BOOT_STAGING mkdir $TMPDIR/root/images $TMPDIR/root/config @@ -475,11 +494,9 @@ elif [ "${IMAGE_TYPE}" = img ]; then rsync -a $SOURCE_DIR/raspberrypi/img/ $TMPDIR/next/ # Install GRUB: ESP at /boot/efi (Part 2), /boot (Part 3) - mkdir -p $TMPDIR/next/boot $TMPDIR/next/boot/efi $TMPDIR/next/boot/firmware \ + mkdir -p $TMPDIR/next/boot \ $TMPDIR/next/dev $TMPDIR/next/proc $TMPDIR/next/sys $TMPDIR/next/media/startos/root - mount --bind $TMPDIR/boot $TMPDIR/next/boot - mount --bind $TMPDIR/efi $TMPDIR/next/boot/efi - mount --bind $TMPDIR/firmware $TMPDIR/next/boot/firmware + mount --rbind $TMPDIR/boot $TMPDIR/next/boot mount --bind /dev $TMPDIR/next/dev mount --bind /proc $TMPDIR/next/proc mount --bind /sys $TMPDIR/next/sys @@ -492,9 +509,7 @@ elif [ "${IMAGE_TYPE}" = img ]; then umount $TMPDIR/next/sys umount $TMPDIR/next/proc umount $TMPDIR/next/dev - umount $TMPDIR/next/boot/firmware - umount $TMPDIR/next/boot/efi - umount $TMPDIR/next/boot + umount -l $TMPDIR/next/boot # Fix root= in grub.cfg: update-grub sees loop devices, but the # real device uses a fixed GPT PARTUUID for root (Part 4). @@ -507,39 +522,16 @@ elif [ "${IMAGE_TYPE}" = img ]; then umount $TMPDIR/next umount $TMPDIR/lower - umount $TMPDIR/firmware - umount $TMPDIR/efi + umount $TMPDIR/boot/firmware + umount $TMPDIR/boot/efi umount $TMPDIR/boot umount $TMPDIR/root - # Shrink btrfs to minimum size - SHRINK_MNT=$(mktemp -d) - mount $ROOT_DEV $SHRINK_MNT - btrfs filesystem resize min $SHRINK_MNT - umount $SHRINK_MNT - rmdir $SHRINK_MNT - ROOT_LEN=$(btrfs inspect-internal dump-super $ROOT_DEV | awk '/^total_bytes/ {print $2}') - losetup -d $ROOT_DEV losetup -d $BOOT_DEV losetup -d $ESP_DEV losetup -d $FW_DEV - # Recreate partition table with shrunk root - sfdisk $TARGET_NAME <<-EOF - label: gpt - - ${TARGET_NAME}1 : start=$((FW_START / SECTOR_LEN)), size=$((FW_LEN / SECTOR_LEN)), type=EBD0A0A2-B9E5-4433-87C0-68B6B72699C7, uuid=${FW_UUID}, name="firmware" - ${TARGET_NAME}2 : start=$((ESP_START / SECTOR_LEN)), size=$((ESP_LEN / SECTOR_LEN)), type=C12A7328-F81F-11D2-BA4B-00A0C93EC93B, uuid=${ESP_UUID}, name="efi" - ${TARGET_NAME}3 : start=$((BOOT_START / SECTOR_LEN)), size=$((BOOT_LEN / SECTOR_LEN)), type=0FC63DAF-8483-4772-8E79-3D69D8477DE4, uuid=${BOOT_UUID}, name="boot" - ${TARGET_NAME}4 : start=$((ROOT_START / SECTOR_LEN)), size=$((ROOT_LEN / SECTOR_LEN)), type=B921B045-1DF0-41C3-AF44-4C6F280D3FAE, uuid=${ROOT_UUID}, name="root" - EOF - - TARGET_SIZE=$((ROOT_START + ROOT_LEN)) - truncate -s $TARGET_SIZE $TARGET_NAME - # Move backup GPT to new end of disk after truncation - sgdisk -e $TARGET_NAME - mv $TARGET_NAME $RESULTS_DIR/$IMAGE_BASENAME.img fi diff --git a/build/image-recipe/raspberrypi/squashfs/boot/config.sh b/build/image-recipe/raspberrypi/squashfs/boot/firmware/config.sh similarity index 100% rename from build/image-recipe/raspberrypi/squashfs/boot/config.sh rename to build/image-recipe/raspberrypi/squashfs/boot/firmware/config.sh diff --git a/build/image-recipe/raspberrypi/squashfs/boot/config.txt b/build/image-recipe/raspberrypi/squashfs/boot/firmware/config.txt similarity index 100% rename from build/image-recipe/raspberrypi/squashfs/boot/config.txt rename to build/image-recipe/raspberrypi/squashfs/boot/firmware/config.txt diff --git a/build/lib/scripts/chroot-and-upgrade b/build/lib/scripts/chroot-and-upgrade index c8e16acaf..f14898316 100755 --- a/build/lib/scripts/chroot-and-upgrade +++ b/build/lib/scripts/chroot-and-upgrade @@ -34,7 +34,7 @@ set -- "${POSITIONAL_ARGS[@]}" # restore positional parameters if [ -z "$NO_SYNC" ]; then echo 'Syncing...' - umount -R /media/startos/next 2> /dev/null + umount -l /media/startos/next 2> /dev/null umount /media/startos/upper 2> /dev/null rm -rf /media/startos/upper /media/startos/next mkdir /media/startos/upper diff --git a/build/lib/scripts/upgrade b/build/lib/scripts/upgrade index a7559987f..309d0e9bb 100755 --- a/build/lib/scripts/upgrade +++ b/build/lib/scripts/upgrade @@ -24,7 +24,7 @@ fi unsquashfs -f -d / $1 boot -umount -R /media/startos/next 2> /dev/null || true +umount -l /media/startos/next 2> /dev/null || true umount /media/startos/upper 2> /dev/null || true umount /media/startos/lower 2> /dev/null || true @@ -47,14 +47,9 @@ mount --bind /tmp /media/startos/next/tmp mount --bind /dev /media/startos/next/dev mount --bind /sys /media/startos/next/sys mount --bind /proc /media/startos/next/proc -mount --bind /boot /media/startos/next/boot +mount --rbind /boot /media/startos/next/boot mount --bind /media/startos/root /media/startos/next/media/startos/root -if mountpoint /boot/efi 2>&1 > /dev/null; then - mkdir -p /media/startos/next/boot/efi - mount --bind /boot/efi /media/startos/next/boot/efi -fi - if mountpoint /sys/firmware/efi/efivars 2>&1 > /dev/null; then mount --bind /sys/firmware/efi/efivars /media/startos/next/sys/firmware/efi/efivars fi @@ -79,7 +74,7 @@ SIGN_FILE="$(ls -1 /media/startos/next/usr/lib/linux-kbuild-*/scripts/sign-file sync -umount -Rl /media/startos/next +umount -l /media/startos/next umount /media/startos/upper umount /media/startos/lower From d1b80cffb81f6ca65e0515f14a5218f46b0d0945 Mon Sep 17 00:00:00 2001 From: Matt Hill Date: Sat, 14 Mar 2026 14:26:55 -0600 Subject: [PATCH 47/71] fix bug with non-fresh install --- web/projects/setup-wizard/src/app/pages/password.page.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/projects/setup-wizard/src/app/pages/password.page.ts b/web/projects/setup-wizard/src/app/pages/password.page.ts index 6e8e7fcd7..3447cd427 100644 --- a/web/projects/setup-wizard/src/app/pages/password.page.ts +++ b/web/projects/setup-wizard/src/app/pages/password.page.ts @@ -175,7 +175,7 @@ export default class PasswordPage { Validators.maxLength(64), ]), confirm: new FormControl(''), - name: new FormControl('', [Validators.required]), + name: new FormControl('', this.isFresh ? [Validators.required] : []), }) readonly validator = (value: string) => (control: AbstractControl) => From ebb7916ecdeea8c21d94345f90e06cc723a3cee9 Mon Sep 17 00:00:00 2001 From: Matt Hill Date: Sun, 15 Mar 2026 21:43:34 -0600 Subject: [PATCH 48/71] docs: update ARCHITECTURE.md and CLAUDE.md for Angular 21 + Taiga UI 5 Update version references from Angular 20 to Angular 21 and Taiga UI to Taiga UI 5 across architecture docs. Update web/CLAUDE.md with improved Taiga golden rules: prioritize MCP server for docs, remove hardcoded component examples in favor of live doc lookups. Co-Authored-By: Claude Opus 4.6 (1M context) --- ARCHITECTURE.md | 4 +-- web/ARCHITECTURE.md | 2 +- web/CLAUDE.md | 77 +++++++-------------------------------------- 3 files changed, 15 insertions(+), 68 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 967978a54..9adb1bd9f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -5,7 +5,7 @@ StartOS is an open-source Linux distribution for running personal servers. It ma ## Tech Stack - Backend: Rust (async/Tokio, Axum web framework) -- Frontend: Angular 20 + TypeScript + TaigaUI +- Frontend: Angular 21 + TypeScript + Taiga UI 5 - Container runtime: Node.js/TypeScript with LXC - Database/State: Patch-DB (git submodule) - storage layer with reactive frontend sync - API: JSON-RPC via rpc-toolkit (see `core/rpc-toolkit.md`) @@ -30,7 +30,7 @@ StartOS is an open-source Linux distribution for running personal servers. It ma - **`core/`** — Rust backend daemon. Produces a single binary `startbox` that is symlinked as `startd` (main daemon), `start-cli` (CLI), `start-container` (runs inside LXC containers), `registrybox` (package registry), and `tunnelbox` (VPN/tunnel). Handles all backend logic: RPC API, service lifecycle, networking (DNS, ACME, WiFi, Tor, WireGuard), backups, and database state management. See [core/ARCHITECTURE.md](core/ARCHITECTURE.md). -- **`web/`** — Angular 20 + TypeScript workspace using Taiga UI. Contains three applications (admin UI, setup wizard, VPN management) and two shared libraries (common components/services, marketplace). Communicates with the backend exclusively via JSON-RPC. See [web/ARCHITECTURE.md](web/ARCHITECTURE.md). +- **`web/`** — Angular 21 + TypeScript workspace using Taiga UI 5. Contains three applications (admin UI, setup wizard, VPN management) and two shared libraries (common components/services, marketplace). Communicates with the backend exclusively via JSON-RPC. See [web/ARCHITECTURE.md](web/ARCHITECTURE.md). - **`container-runtime/`** — Node.js runtime that runs inside each service's LXC container. Loads the service's JavaScript from its S9PK package and manages subcontainers. Communicates with the host daemon via JSON-RPC over Unix socket. See [container-runtime/CLAUDE.md](container-runtime/CLAUDE.md). diff --git a/web/ARCHITECTURE.md b/web/ARCHITECTURE.md index 33d92abcf..7d8edc31f 100644 --- a/web/ARCHITECTURE.md +++ b/web/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Web Architecture -Angular 20 + TypeScript workspace using [Taiga UI](https://taiga-ui.dev/) component library. +Angular 21 + TypeScript workspace using [Taiga UI 5](https://taiga-ui.dev/) component library. ## API Layer (JSON-RPC) diff --git a/web/CLAUDE.md b/web/CLAUDE.md index b6b7f2a67..d454f696e 100644 --- a/web/CLAUDE.md +++ b/web/CLAUDE.md @@ -1,6 +1,6 @@ # Web — Angular Frontend -Angular 20 + TypeScript workspace using [Taiga UI](https://taiga-ui.dev/) component library. +Angular 21 + TypeScript workspace using [Taiga UI 5](https://taiga-ui.dev/) component library. ## Projects @@ -21,15 +21,22 @@ npm run check # Type check all projects ## Golden Rules -1. **Taiga-first.** Use Taiga components, directives, and APIs whenever possible. Avoid hand-rolled HTML/CSS unless absolutely necessary. If Taiga has a component for it, use it. +1. **Taiga does it all.** We use Taiga UI 5 for everything — components, directives, layout, dialogs, forms, icons, and styling. Do not hand-roll HTML/CSS when Taiga provides a solution. If you think Taiga can't do something, you're probably wrong — look it up first. -2. **Pattern-match.** Nearly anything we build has a similar example elsewhere in this codebase. Search for existing patterns before writing new code. Copy the conventions used in neighboring components. +2. **Follow existing patterns.** Before writing new code, search this codebase for a similar example. Nearly anything we build has a precedent. Copy the conventions used in neighboring components. Do not invent new patterns when established ones exist. -3. **When unsure about Taiga, ask or look it up.** Use `WebFetch` against `https://taiga-ui.dev/llms-full.txt` to search for component usage, or ask the user. Taiga docs are authoritative. See [Taiga UI Docs](#taiga-ui-docs) below. +3. **Never guess Taiga APIs.** Taiga UI 5 has its own way of doing things. Do not make up component names, directive names, input bindings, or usage patterns from memory. Always verify against the official docs or the MCP server. Getting it wrong wastes everyone's time. + +4. **Use the Taiga MCP server.** If a `taiga-ui-mcp` MCP server is available, use it to look up components and get documentation with code examples. It provides two tools: `get_list_components` (search/filter components) and `get_component_example` (get full docs and examples for a component). This is the fastest and most accurate way to get Taiga usage information. + +5. **Fall back to the Taiga docs.** If the MCP server is not available, use `WebFetch` against `https://taiga-ui.dev/llms-full.txt` to search for component usage. Taiga docs are authoritative — this project's code is not. See [Taiga UI Docs](#taiga-ui-docs) below. ## Taiga UI Docs -Taiga provides an LLM-friendly reference at `https://taiga-ui.dev/llms-full.txt` (~2200 lines covering all components with code examples). Use `WebFetch` to search it when you need to look up a component, directive, or API: +Taiga provides AI-friendly references at [taiga-ui.dev/ai-support](https://taiga-ui.dev/ai-support): + +- **MCP server** — [`taiga-ui-mcp`](https://github.com/taiga-family/taiga-ui-mcp) provides full access to Taiga UI component docs and Angular code examples via the Model Context Protocol. +- **llms-full.txt** — `https://taiga-ui.dev/llms-full.txt` (~2200 lines covering all components with code examples). Use `WebFetch` to search it: ``` WebFetch url=https://taiga-ui.dev/llms-full.txt prompt="How to use TuiTextfield with a select dropdown" @@ -50,63 +57,3 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for the web architecture: API layer, Patc - **`toSignal()`** to convert Observables (e.g., PatchDB watches) to signals. - **`ChangeDetectionStrategy.OnPush`** on almost all components. - **`takeUntilDestroyed(inject(DestroyRef))`** for subscription cleanup. - -## Common Taiga Patterns - -### Textfield + Select (dropdown) - -```html - - - - - - - -``` - -Provider to remove the X clear button: - -```typescript -providers: [tuiTextfieldOptionsProvider({ cleaner: signal(false) })] -``` - -### Buttons - -```html - - - -``` - -### Dialogs - -```typescript -// Confirmation -this.dialog.openConfirm({ label: 'Warning', data: { content: '...', yes: 'Confirm', no: 'Cancel' } }) - -// Custom component in dialog -this.dialog.openComponent(new PolymorpheusComponent(MyComponent, injector), { label: 'Title' }) -``` - -### Toggle - -```html - -``` - -### Errors & Tooltips - -```html - - - -``` - -### Layout - -```html - - - -``` From 7b8bb92d60536eb80cc3379c71aa28e8c692c994 Mon Sep 17 00:00:00 2001 From: waterplea Date: Mon, 16 Mar 2026 09:57:46 +0400 Subject: [PATCH 49/71] chore: fix --- .../setup-wizard/src/app/pages/home.page.ts | 6 +- .../src/app/pages/success.page.ts | 88 +++++++++---------- web/projects/setup-wizard/src/styles.scss | 4 - .../start-tunnel/src/app/app.config.ts | 2 +- .../app/routes/home/components/placeholder.ts | 35 ++++++++ .../src/app/routes/home/routes/devices/add.ts | 4 +- .../app/routes/home/routes/devices/index.ts | 40 ++++++--- .../routes/home/routes/port-forwards/add.ts | 4 +- .../home/routes/port-forwards/edit-label.ts | 11 +-- .../routes/home/routes/port-forwards/index.ts | 25 ++++-- .../app/routes/home/routes/subnets/index.ts | 39 +++++--- .../src/app/services/update.service.ts | 25 ++---- web/projects/start-tunnel/src/styles.scss | 24 +++-- .../services/routes/outlet.component.ts | 1 + web/projects/ui/src/styles.scss | 3 +- 15 files changed, 191 insertions(+), 120 deletions(-) create mode 100644 web/projects/start-tunnel/src/app/routes/home/components/placeholder.ts diff --git a/web/projects/setup-wizard/src/app/pages/home.page.ts b/web/projects/setup-wizard/src/app/pages/home.page.ts index 578b9c641..f11fda363 100644 --- a/web/projects/setup-wizard/src/app/pages/home.page.ts +++ b/web/projects/setup-wizard/src/app/pages/home.page.ts @@ -16,7 +16,7 @@ import { StateService } from '../services/state.service' @@ -24,7 +24,7 @@ import { StateService } from '../services/state.service' } @@ -165,10 +165,10 @@ import { StateService } from '../services/state.service' (click)="openLocalAddress()" > -
- {{ 'Open Local Address' | i18n }} -
{{ lanAddress }}
-
+ + {{ 'Open Local Address' | i18n }} + {{ lanAddress }} +