mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 10:21:52 +00:00
chore: bump sdk to beta.54, add device-info RPC, improve SDK abort handling and InputSpec filtering
- Bump SDK version to 0.4.0-beta.54 - Add `server.device-info` RPC endpoint and `s9pk select` CLI command - Extract `HardwareRequirements::is_compatible()` method, reuse in registry filtering - Add `AbortedError` class with `muteUnhandled` flag, replace generic abort errors - Handle unhandled promise rejections in container-runtime with mute support - Improve `InputSpec.filter()` with `keepByDefault` param and boolean filter values - Accept readonly tuples in `CommandType` and `splitCommand` - Remove `sync_host` calls from host API handlers (binding/address changes) - Filter mDNS hostnames by secure gateway availability - Derive mDNS enabled state from LAN IPs in web UI - Add "Open UI" action to address table, disable mDNS toggle - Hide debug details in service error component - Update rpc-toolkit docs for no-params handlers
This commit is contained in:
2
container-runtime/package-lock.json
generated
2
container-runtime/package-lock.json
generated
@@ -37,7 +37,7 @@
|
||||
},
|
||||
"../sdk/dist": {
|
||||
"name": "@start9labs/start-sdk",
|
||||
"version": "0.4.0-beta.53",
|
||||
"version": "0.4.0-beta.54",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@iarna/toml": "^3.0.0",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { RpcListener } from "./Adapters/RpcListener"
|
||||
import { SystemForEmbassy } from "./Adapters/Systems/SystemForEmbassy"
|
||||
import { AllGetDependencies } from "./Interfaces/AllGetDependencies"
|
||||
import { getSystem } from "./Adapters/Systems"
|
||||
|
||||
@@ -7,6 +6,18 @@ const getDependencies: AllGetDependencies = {
|
||||
system: getSystem,
|
||||
}
|
||||
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
if (
|
||||
reason instanceof Error &&
|
||||
"muteUnhandled" in reason &&
|
||||
reason.muteUnhandled
|
||||
) {
|
||||
// mute
|
||||
} else {
|
||||
console.error("Unhandled promise rejection", reason)
|
||||
}
|
||||
})
|
||||
|
||||
for (let s of ["SIGTERM", "SIGINT", "SIGHUP"]) {
|
||||
process.on(s, (s) => {
|
||||
console.log(`Caught ${s}`)
|
||||
|
||||
@@ -21,6 +21,14 @@ pub async fn my_handler(ctx: RpcContext, params: MyParams) -> Result<MyResponse,
|
||||
from_fn_async(my_handler)
|
||||
```
|
||||
|
||||
If a handler takes no params, simply omit the params argument entirely (no need for `_: Empty`):
|
||||
|
||||
```rust
|
||||
pub async fn no_params_handler(ctx: RpcContext) -> Result<MyResponse, Error> {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### `from_fn_async_local` - Non-thread-safe async handlers
|
||||
For async functions that are not `Send` (cannot be safely moved between threads). Use when working with non-thread-safe types.
|
||||
|
||||
@@ -181,9 +189,9 @@ pub struct MyParams {
|
||||
|
||||
### Adding a New RPC Endpoint
|
||||
|
||||
1. Define params struct with `Deserialize, Serialize, Parser, TS`
|
||||
1. Define params struct with `Deserialize, Serialize, Parser, TS` (skip if no params needed)
|
||||
2. Choose handler type based on sync/async and thread-safety
|
||||
3. Write handler function taking `(Context, Params) -> Result<Response, Error>`
|
||||
3. Write handler function taking `(Context, Params) -> Result<Response, Error>` (omit Params if none needed)
|
||||
4. Add to parent handler with appropriate extensions (display modifiers before `with_about`)
|
||||
5. TypeScript types auto-generated via `make ts-bindings`
|
||||
|
||||
|
||||
@@ -268,6 +268,18 @@ pub fn server<C: Context>() -> ParentHandler<C> {
|
||||
.with_about("about.display-time-uptime")
|
||||
.with_call_remote::<CliContext>(),
|
||||
)
|
||||
.subcommand(
|
||||
"device-info",
|
||||
ParentHandler::<C, WithIoFormat<Empty>>::new().root_handler(
|
||||
from_fn_async(system::device_info)
|
||||
.with_display_serializable()
|
||||
.with_custom_display_fn(|handle, result| {
|
||||
system::display_device_info(handle.params, result)
|
||||
})
|
||||
.with_about("about.get-device-info")
|
||||
.with_call_remote::<CliContext>(),
|
||||
),
|
||||
)
|
||||
.subcommand(
|
||||
"experimental",
|
||||
system::experimental::<C>().with_about("about.commands-experimental"),
|
||||
|
||||
@@ -20,9 +20,6 @@ use crate::context::RpcContext;
|
||||
use crate::middleware::auth::DbContext;
|
||||
use crate::prelude::*;
|
||||
use crate::rpc_continuations::OpenAuthedContinuations;
|
||||
use crate::util::Invoke;
|
||||
use crate::util::io::{create_file_mod, read_file_to_string};
|
||||
use crate::util::serde::{BASE64, const_true};
|
||||
use crate::util::sync::SyncMutex;
|
||||
|
||||
pub trait SessionAuthContext: DbContext {
|
||||
|
||||
@@ -204,7 +204,6 @@ pub async fn add_public_domain<Kind: HostApiKind>(
|
||||
})
|
||||
.await
|
||||
.result?;
|
||||
Kind::sync_host(&ctx, inheritance).await?;
|
||||
|
||||
tokio::task::spawn_blocking(|| {
|
||||
crate::net::dns::query_dns(ctx, crate::net::dns::QueryDnsParams { fqdn })
|
||||
@@ -242,7 +241,6 @@ pub async fn remove_public_domain<Kind: HostApiKind>(
|
||||
})
|
||||
.await
|
||||
.result?;
|
||||
Kind::sync_host(&ctx, inheritance).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -279,7 +277,6 @@ pub async fn add_private_domain<Kind: HostApiKind>(
|
||||
})
|
||||
.await
|
||||
.result?;
|
||||
Kind::sync_host(&ctx, inheritance).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -306,7 +303,6 @@ pub async fn remove_private_domain<Kind: HostApiKind>(
|
||||
})
|
||||
.await
|
||||
.result?;
|
||||
Kind::sync_host(&ctx, inheritance).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -358,5 +358,5 @@ pub async fn set_address_enabled<Kind: HostApiKind>(
|
||||
})
|
||||
.await
|
||||
.result?;
|
||||
Kind::sync_host(&ctx, inheritance).await
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::future::Future;
|
||||
use std::net::{IpAddr, SocketAddrV4};
|
||||
use std::panic::RefUnwindSafe;
|
||||
|
||||
@@ -182,15 +181,26 @@ impl Model<Host> {
|
||||
opt.secure
|
||||
.map_or(true, |s| !(s.ssl && opt.add_ssl.is_some()))
|
||||
}) {
|
||||
available.insert(HostnameInfo {
|
||||
ssl: opt.secure.map_or(false, |s| s.ssl),
|
||||
public: false,
|
||||
hostname: mdns_host.clone(),
|
||||
port: Some(port),
|
||||
metadata: HostnameMetadata::Mdns {
|
||||
gateways: mdns_gateways.clone(),
|
||||
},
|
||||
});
|
||||
let mdns_gateways = if opt.secure.is_some() {
|
||||
mdns_gateways.clone()
|
||||
} else {
|
||||
mdns_gateways
|
||||
.iter()
|
||||
.filter(|g| gateways.get(*g).map_or(false, |g| g.secure()))
|
||||
.cloned()
|
||||
.collect()
|
||||
};
|
||||
if !mdns_gateways.is_empty() {
|
||||
available.insert(HostnameInfo {
|
||||
ssl: opt.secure.map_or(false, |s| s.ssl),
|
||||
public: false,
|
||||
hostname: mdns_host.clone(),
|
||||
port: Some(port),
|
||||
metadata: HostnameMetadata::Mdns {
|
||||
gateways: mdns_gateways,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(port) = net.assigned_ssl_port {
|
||||
available.insert(HostnameInfo {
|
||||
@@ -429,10 +439,6 @@ pub trait HostApiKind: 'static {
|
||||
inheritance: &Self::Inheritance,
|
||||
db: &'a mut DatabaseModel,
|
||||
) -> Result<&'a mut Model<Host>, Error>;
|
||||
fn sync_host(
|
||||
ctx: &RpcContext,
|
||||
inheritance: Self::Inheritance,
|
||||
) -> impl Future<Output = Result<(), Error>> + Send;
|
||||
}
|
||||
pub struct ForPackage;
|
||||
impl HostApiKind for ForPackage {
|
||||
@@ -451,12 +457,6 @@ impl HostApiKind for ForPackage {
|
||||
) -> Result<&'a mut Model<Host>, Error> {
|
||||
host_for(db, Some(package), host)
|
||||
}
|
||||
async fn sync_host(ctx: &RpcContext, (package, host): Self::Inheritance) -> Result<(), Error> {
|
||||
let service = ctx.services.get(&package).await;
|
||||
let service_ref = service.as_ref().or_not_found(&package)?;
|
||||
service_ref.sync_host(host).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
pub struct ForServer;
|
||||
impl HostApiKind for ForServer {
|
||||
@@ -472,9 +472,6 @@ impl HostApiKind for ForServer {
|
||||
) -> Result<&'a mut Model<Host>, Error> {
|
||||
host_for(db, None, &HostId::default())
|
||||
}
|
||||
async fn sync_host(ctx: &RpcContext, _: Self::Inheritance) -> Result<(), Error> {
|
||||
ctx.os_net_service.sync_host(HostId::default()).await
|
||||
}
|
||||
}
|
||||
|
||||
pub fn host_api<C: Context>() -> ParentHandler<C, RequiresPackageId> {
|
||||
|
||||
@@ -725,13 +725,6 @@ impl NetService {
|
||||
.result
|
||||
}
|
||||
|
||||
pub async fn sync_host(&self, _id: HostId) -> Result<(), Error> {
|
||||
let current = self.synced.peek(|v| *v);
|
||||
let mut w = self.synced.clone();
|
||||
w.wait_for(|v| *v > current).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_all(mut self) -> Result<(), Error> {
|
||||
if Weak::upgrade(&self.data.lock().await.controller).is_none() {
|
||||
self.shutdown = true;
|
||||
|
||||
@@ -9,7 +9,7 @@ use async_compression::tokio::bufread::GzipEncoder;
|
||||
use axum::Router;
|
||||
use axum::body::Body;
|
||||
use axum::extract::{self as x, Request};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::response::Response;
|
||||
use axum::routing::{any, get};
|
||||
use base64::display::Base64Display;
|
||||
use digest::Digest;
|
||||
|
||||
@@ -255,30 +255,7 @@ impl Model<PackageVersionInfo> {
|
||||
}
|
||||
if let Some(hw) = &device_info.hardware {
|
||||
self.as_s9pks_mut().mutate(|s9pks| {
|
||||
s9pks.retain(|(hw_req, _)| {
|
||||
if let Some(arch) = &hw_req.arch {
|
||||
if !arch.contains(&hw.arch) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(ram) = hw_req.ram {
|
||||
if hw.ram < ram {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(dev) = &hw.devices {
|
||||
for device_filter in &hw_req.device {
|
||||
if !dev
|
||||
.iter()
|
||||
.filter(|d| d.class() == &*device_filter.class)
|
||||
.any(|d| device_filter.matches(d))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
});
|
||||
s9pks.retain(|(hw_req, _)| hw_req.is_compatible(hw));
|
||||
if hw.devices.is_some() {
|
||||
s9pks.sort_by_key(|(req, _)| req.specificity_desc());
|
||||
} else {
|
||||
|
||||
@@ -3,16 +3,17 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use clap::Parser;
|
||||
use rpc_toolkit::{Empty, HandlerExt, ParentHandler, from_fn_async};
|
||||
use rpc_toolkit::{Empty, HandlerArgs, HandlerExt, ParentHandler, from_fn_async};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::process::Command;
|
||||
use ts_rs::TS;
|
||||
use url::Url;
|
||||
|
||||
use crate::ImageId;
|
||||
use crate::context::CliContext;
|
||||
use crate::context::{CliContext, RpcContext};
|
||||
use crate::prelude::*;
|
||||
use crate::s9pk::manifest::Manifest;
|
||||
use crate::registry::device_info::DeviceInfo;
|
||||
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;
|
||||
@@ -70,6 +71,15 @@ pub fn s9pk() -> ParentHandler<CliContext> {
|
||||
.no_display()
|
||||
.with_about("about.publish-s9pk"),
|
||||
)
|
||||
.subcommand(
|
||||
"select",
|
||||
from_fn_async(select)
|
||||
.with_custom_display_fn(|_, path: PathBuf| {
|
||||
println!("{}", path.display());
|
||||
Ok(())
|
||||
})
|
||||
.with_about("about.select-s9pk-for-device"),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Parser)]
|
||||
@@ -323,3 +333,93 @@ async fn publish(ctx: CliContext, S9pkPath { s9pk: s9pk_path }: S9pkPath) -> Res
|
||||
.await?;
|
||||
crate::registry::package::add::cli_add_package_impl(ctx, s9pk, vec![s3url], false).await
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Parser)]
|
||||
struct SelectParams {
|
||||
#[arg(help = "help.arg.s9pk-file-paths")]
|
||||
s9pks: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
async fn select(
|
||||
HandlerArgs {
|
||||
context,
|
||||
params: SelectParams { s9pks },
|
||||
..
|
||||
}: HandlerArgs<CliContext, SelectParams>,
|
||||
) -> Result<PathBuf, Error> {
|
||||
// Resolve file list: use provided paths or scan cwd for *.s9pk
|
||||
let paths = if s9pks.is_empty() {
|
||||
let mut found = Vec::new();
|
||||
let mut entries = tokio::fs::read_dir(".").await?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("s9pk") {
|
||||
found.push(path);
|
||||
}
|
||||
}
|
||||
if found.is_empty() {
|
||||
return Err(Error::new(
|
||||
eyre!("no .s9pk files found in current directory"),
|
||||
ErrorKind::NotFound,
|
||||
));
|
||||
}
|
||||
found
|
||||
} else {
|
||||
s9pks
|
||||
};
|
||||
|
||||
// Fetch DeviceInfo from the target server
|
||||
let device_info: DeviceInfo = from_value(
|
||||
context
|
||||
.call_remote::<RpcContext>("server.device-info", imbl_value::json!({}))
|
||||
.await?,
|
||||
)?;
|
||||
|
||||
// Filter and rank s9pk files by compatibility
|
||||
let mut compatible: Vec<(PathBuf, HardwareRequirements)> = Vec::new();
|
||||
for path in &paths {
|
||||
let s9pk = match super::S9pk::open(path, None).await {
|
||||
Ok(s9pk) => s9pk,
|
||||
Err(e) => {
|
||||
tracing::warn!("skipping {}: {e}", path.display());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let manifest = s9pk.as_manifest();
|
||||
|
||||
// OS version check: package's required OS version must be in server's compat range
|
||||
if !manifest.metadata.os_version.satisfies(&device_info.os.compat) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let hw_req = &manifest.hardware_requirements;
|
||||
|
||||
if let Some(hw) = &device_info.hardware {
|
||||
if !hw_req.is_compatible(hw) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
compatible.push((path.clone(), hw_req.clone()));
|
||||
}
|
||||
|
||||
if compatible.is_empty() {
|
||||
return Err(Error::new(
|
||||
eyre!(
|
||||
"no compatible s9pk found for device (arch: {}, os: {})",
|
||||
device_info
|
||||
.hardware
|
||||
.as_ref()
|
||||
.map(|h| h.arch.to_string())
|
||||
.unwrap_or_else(|| "unknown".into()),
|
||||
device_info.os.version,
|
||||
),
|
||||
ErrorKind::NotFound,
|
||||
));
|
||||
}
|
||||
|
||||
// Sort by specificity (most specific first)
|
||||
compatible.sort_by_key(|(_, req)| req.specificity_desc());
|
||||
|
||||
Ok(compatible.into_iter().next().unwrap().0)
|
||||
}
|
||||
|
||||
@@ -154,6 +154,32 @@ pub struct HardwareRequirements {
|
||||
pub arch: Option<BTreeSet<InternedString>>,
|
||||
}
|
||||
impl HardwareRequirements {
|
||||
/// Returns true if this s9pk's hardware requirements are satisfied by the given hardware.
|
||||
pub fn is_compatible(&self, hw: &crate::registry::device_info::HardwareInfo) -> bool {
|
||||
if let Some(arch) = &self.arch {
|
||||
if !arch.contains(&hw.arch) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(ram) = self.ram {
|
||||
if hw.ram < ram {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(devices) = &hw.devices {
|
||||
for device_filter in &self.device {
|
||||
if !devices
|
||||
.iter()
|
||||
.filter(|d| d.class() == &*device_filter.class)
|
||||
.any(|d| device_filter.matches(d))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// returns a value that can be used as a sort key to get most specific requirements first
|
||||
pub fn specificity_desc(&self) -> (u32, u32, u64) {
|
||||
(
|
||||
|
||||
@@ -52,7 +52,7 @@ use crate::util::serde::Pem;
|
||||
use crate::util::sync::SyncMutex;
|
||||
use crate::util::tui::choose;
|
||||
use crate::volume::data_dir;
|
||||
use crate::{ActionId, CAP_1_KiB, DATA_DIR, HostId, ImageId, PackageId};
|
||||
use crate::{ActionId, CAP_1_KiB, DATA_DIR, ImageId, PackageId};
|
||||
|
||||
pub mod action;
|
||||
pub mod cli;
|
||||
@@ -683,14 +683,6 @@ impl Service {
|
||||
memory_usage: MiB::from_MiB(used),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn sync_host(&self, host_id: HostId) -> Result<(), Error> {
|
||||
self.seed
|
||||
.persistent_container
|
||||
.net_service
|
||||
.sync_host(host_id)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
struct ServiceActorSeed {
|
||||
|
||||
@@ -20,6 +20,7 @@ use crate::context::{CliContext, RpcContext};
|
||||
use crate::disk::util::{get_available, get_used};
|
||||
use crate::logs::{LogSource, LogsParams, SYSTEM_UNIT};
|
||||
use crate::prelude::*;
|
||||
use crate::registry::device_info::DeviceInfo;
|
||||
use crate::rpc_continuations::{Guid, RpcContinuation, RpcContinuations};
|
||||
use crate::shutdown::Shutdown;
|
||||
use crate::util::Invoke;
|
||||
@@ -249,6 +250,67 @@ pub async fn time(ctx: RpcContext, _: Empty) -> Result<TimeInfo, Error> {
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn device_info(ctx: RpcContext) -> Result<DeviceInfo, Error> {
|
||||
DeviceInfo::load(&ctx).await
|
||||
}
|
||||
|
||||
pub fn display_device_info(
|
||||
params: WithIoFormat<Empty>,
|
||||
info: DeviceInfo,
|
||||
) -> Result<(), Error> {
|
||||
use prettytable::*;
|
||||
|
||||
if let Some(format) = params.format {
|
||||
return display_serializable(format, info);
|
||||
}
|
||||
|
||||
let mut table = Table::new();
|
||||
table.add_row(row![br -> "PLATFORM", &*info.os.platform]);
|
||||
table.add_row(row![br -> "OS VERSION", info.os.version.to_string()]);
|
||||
table.add_row(row![br -> "OS COMPAT", info.os.compat.to_string()]);
|
||||
if let Some(lang) = &info.os.language {
|
||||
table.add_row(row![br -> "LANGUAGE", &**lang]);
|
||||
}
|
||||
if let Some(hw) = &info.hardware {
|
||||
table.add_row(row![br -> "ARCH", &*hw.arch]);
|
||||
table.add_row(row![br -> "RAM", format_ram(hw.ram)]);
|
||||
if let Some(devices) = &hw.devices {
|
||||
for dev in devices {
|
||||
let (class, desc) = match dev {
|
||||
crate::util::lshw::LshwDevice::Processor(p) => (
|
||||
"PROCESSOR",
|
||||
p.product.as_deref().unwrap_or("unknown").to_string(),
|
||||
),
|
||||
crate::util::lshw::LshwDevice::Display(d) => (
|
||||
"DISPLAY",
|
||||
format!(
|
||||
"{}{}",
|
||||
d.product.as_deref().unwrap_or("unknown"),
|
||||
d.driver
|
||||
.as_deref()
|
||||
.map(|drv| format!(" ({})", drv))
|
||||
.unwrap_or_default()
|
||||
),
|
||||
),
|
||||
};
|
||||
table.add_row(row![br -> class, desc]);
|
||||
}
|
||||
}
|
||||
}
|
||||
table.print_tty(false)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_ram(bytes: u64) -> String {
|
||||
const GIB: u64 = 1024 * 1024 * 1024;
|
||||
const MIB: u64 = 1024 * 1024;
|
||||
if bytes >= GIB {
|
||||
format!("{:.1} GiB", bytes as f64 / GIB as f64)
|
||||
} else {
|
||||
format!("{:.1} MiB", bytes as f64 / MIB as f64)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn logs<C: Context + AsRef<RpcContinuations>>() -> ParentHandler<C, LogsParams> {
|
||||
crate::logs::logs(|_: &C, _| async { Ok(LogSource::Unit(SYSTEM_UNIT)) })
|
||||
}
|
||||
|
||||
@@ -26,23 +26,39 @@ export type LazyBuild<ExpectedOut, Type> = (
|
||||
* Defines which keys to keep when filtering an InputSpec.
|
||||
* Use `true` to keep a field as-is, or a nested object to filter sub-fields of an object-typed field.
|
||||
*/
|
||||
export type FilterKeys<T> = {
|
||||
[K in keyof T]?: T[K] extends Record<string, any>
|
||||
? true | FilterKeys<T[K]>
|
||||
: true
|
||||
export type FilterKeys<F> = {
|
||||
[K in keyof F]?: F[K] extends Record<string, any>
|
||||
? boolean | FilterKeys<F[K]>
|
||||
: boolean
|
||||
}
|
||||
|
||||
type RetainKey<T, F, Default extends boolean> = {
|
||||
[K in keyof T]: K extends keyof F
|
||||
? F[K] extends false
|
||||
? never
|
||||
: K
|
||||
: Default extends true
|
||||
? K
|
||||
: never
|
||||
}[keyof T]
|
||||
|
||||
/**
|
||||
* Computes the resulting type after applying a {@link FilterKeys} shape to a type.
|
||||
*/
|
||||
export type ApplyFilter<T, F> = {
|
||||
[K in Extract<keyof F, keyof T>]: F[K] extends true
|
||||
? T[K]
|
||||
: T[K] extends Record<string, any>
|
||||
? F[K] extends FilterKeys<T[K]>
|
||||
? ApplyFilter<T[K], F[K]>
|
||||
: T[K]
|
||||
: T[K]
|
||||
export type ApplyFilter<T, F, Default extends boolean = false> = {
|
||||
[K in RetainKey<T, F, Default>]: K extends keyof F
|
||||
? true extends F[K]
|
||||
? F[K] extends true
|
||||
? T[K]
|
||||
: T[K] | undefined
|
||||
: T[K] extends Record<string, any>
|
||||
? F[K] extends FilterKeys<T[K]>
|
||||
? ApplyFilter<T[K], F[K]>
|
||||
: undefined
|
||||
: undefined
|
||||
: Default extends true
|
||||
? T[K]
|
||||
: undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -226,14 +242,15 @@ export class InputSpec<
|
||||
* const filtered = full.filter({ name: true, settings: { debug: true } })
|
||||
* ```
|
||||
*/
|
||||
filter<F extends FilterKeys<Type>>(
|
||||
filter<F extends FilterKeys<Type>, Default extends boolean = false>(
|
||||
keys: F,
|
||||
keepByDefault?: Default,
|
||||
): InputSpec<
|
||||
ApplyFilter<Type, F> & ApplyFilter<StaticValidatedAs, F>,
|
||||
ApplyFilter<StaticValidatedAs, F>
|
||||
ApplyFilter<Type, F, Default> & ApplyFilter<StaticValidatedAs, F, Default>,
|
||||
ApplyFilter<StaticValidatedAs, F, Default>
|
||||
> {
|
||||
const newSpec: Record<string, Value<any>> = {}
|
||||
for (const k of Object.keys(keys)) {
|
||||
for (const k of Object.keys(this.spec)) {
|
||||
const filterVal = (keys as any)[k]
|
||||
const value = (this.spec as any)[k] as Value<any> | undefined
|
||||
if (!value) continue
|
||||
@@ -242,11 +259,16 @@ export class InputSpec<
|
||||
} else if (typeof filterVal === 'object' && filterVal !== null) {
|
||||
const objectMeta = value._objectSpec
|
||||
if (objectMeta) {
|
||||
const filteredInner = objectMeta.inputSpec.filter(filterVal)
|
||||
const filteredInner = objectMeta.inputSpec.filter(
|
||||
filterVal,
|
||||
keepByDefault,
|
||||
)
|
||||
newSpec[k] = Value.object(objectMeta.params, filteredInner)
|
||||
} else {
|
||||
newSpec[k] = value
|
||||
}
|
||||
} else if (keepByDefault && filterVal !== false) {
|
||||
newSpec[k] = value
|
||||
}
|
||||
}
|
||||
const newValidator = z.object(
|
||||
|
||||
@@ -145,7 +145,11 @@ export function isUseEntrypoint(
|
||||
* - An explicit argv array
|
||||
* - A {@link UseEntrypoint} to use the container's built-in entrypoint
|
||||
*/
|
||||
export type CommandType = string | [string, ...string[]] | UseEntrypoint
|
||||
export type CommandType =
|
||||
| string
|
||||
| [string, ...string[]]
|
||||
| readonly [string, ...string[]]
|
||||
| UseEntrypoint
|
||||
|
||||
/** The return type from starting a daemon — provides `wait()` and `term()` controls. */
|
||||
export type DaemonReturned = {
|
||||
|
||||
10
sdk/base/lib/util/AbortedError.ts
Normal file
10
sdk/base/lib/util/AbortedError.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export class AbortedError extends Error {
|
||||
readonly muteUnhandled = true as const
|
||||
declare cause?: unknown
|
||||
|
||||
constructor(message?: string, options?: { cause?: unknown }) {
|
||||
super(message)
|
||||
this.name = 'AbortedError'
|
||||
if (options?.cause !== undefined) this.cause = options.cause
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Effects } from '../Effects'
|
||||
import { AbortedError } from './AbortedError'
|
||||
import { DropGenerator, DropPromise } from './Drop'
|
||||
|
||||
export class GetOutboundGateway {
|
||||
@@ -38,7 +39,7 @@ export class GetOutboundGateway {
|
||||
})
|
||||
await waitForNext
|
||||
}
|
||||
return new Promise<never>((_, rej) => rej(new Error('aborted')))
|
||||
return new Promise<never>((_, rej) => rej(new AbortedError()))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Effects } from '../Effects'
|
||||
import * as T from '../types'
|
||||
import { AbortedError } from './AbortedError'
|
||||
import { DropGenerator, DropPromise } from './Drop'
|
||||
|
||||
export class GetSystemSmtp {
|
||||
@@ -39,7 +40,7 @@ export class GetSystemSmtp {
|
||||
})
|
||||
await waitForNext
|
||||
}
|
||||
return new Promise<never>((_, rej) => rej(new Error('aborted')))
|
||||
return new Promise<never>((_, rej) => rej(new AbortedError()))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ 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'
|
||||
@@ -394,7 +395,7 @@ export class GetServiceInterface<Mapped = ServiceInterfaceFilled | null> {
|
||||
}
|
||||
await waitForNext
|
||||
}
|
||||
return new Promise<never>((_, rej) => rej(new Error('aborted')))
|
||||
return new Promise<never>((_, rej) => rej(new AbortedError()))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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'
|
||||
@@ -105,7 +106,7 @@ export class GetServiceInterfaces<Mapped = ServiceInterfaceFilled[]> {
|
||||
}
|
||||
await waitForNext
|
||||
}
|
||||
return new Promise<never>((_, rej) => rej(new Error('aborted')))
|
||||
return new Promise<never>((_, rej) => rej(new AbortedError()))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,5 +22,6 @@ export { splitCommand } from './splitCommand'
|
||||
export { nullIfEmpty } from './nullIfEmpty'
|
||||
export { deepMerge, partialDiff } from './deepMerge'
|
||||
export { deepEqual } from './deepEqual'
|
||||
export { AbortedError } from './AbortedError'
|
||||
export * as regexes from './regexes'
|
||||
export { stringFromStdErrOut } from './stringFromStdErrOut'
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { AllowReadonly } from '../types'
|
||||
|
||||
/**
|
||||
* Normalizes a command into an argv-style string array.
|
||||
* If given a string, wraps it as `["sh", "-c", command]`.
|
||||
@@ -13,8 +15,8 @@
|
||||
* ```
|
||||
*/
|
||||
export const splitCommand = (
|
||||
command: string | [string, ...string[]],
|
||||
command: string | AllowReadonly<[string, ...string[]]>,
|
||||
): string[] => {
|
||||
if (Array.isArray(command)) return command
|
||||
return ['sh', '-c', command]
|
||||
return ['sh', '-c', command as string]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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'
|
||||
|
||||
@@ -64,7 +65,7 @@ export class GetServiceManifest<Mapped = Manifest> {
|
||||
}
|
||||
await waitForNext
|
||||
}
|
||||
return new Promise<never>((_, rej) => rej(new Error('aborted')))
|
||||
return new Promise<never>((_, rej) => rej(new AbortedError()))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 {
|
||||
@@ -50,7 +51,7 @@ export class GetSslCertificate {
|
||||
})
|
||||
await waitForNext
|
||||
}
|
||||
return new Promise<never>((_, rej) => rej(new Error('aborted')))
|
||||
return new Promise<never>((_, rej) => rej(new AbortedError()))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { asError, deepEqual } from '../../../base/lib/util'
|
||||
import { AbortedError, asError, deepEqual } from '../../../base/lib/util'
|
||||
import { DropGenerator, DropPromise } from '../../../base/lib/util/Drop'
|
||||
import { PathBase } from './Volume'
|
||||
|
||||
@@ -289,7 +289,7 @@ export class FileHelper<A> {
|
||||
await onCreated(this.path).catch((e) => console.error(asError(e)))
|
||||
}
|
||||
}
|
||||
return new Promise<never>((_, rej) => rej(new Error('aborted')))
|
||||
return new Promise<never>((_, rej) => rej(new AbortedError()))
|
||||
}
|
||||
|
||||
private readOnChange<B>(
|
||||
|
||||
4
sdk/package/package-lock.json
generated
4
sdk/package/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@start9labs/start-sdk",
|
||||
"version": "0.4.0-beta.53",
|
||||
"version": "0.4.0-beta.54",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@start9labs/start-sdk",
|
||||
"version": "0.4.0-beta.53",
|
||||
"version": "0.4.0-beta.54",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@iarna/toml": "^3.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@start9labs/start-sdk",
|
||||
"version": "0.4.0-beta.53",
|
||||
"version": "0.4.0-beta.54",
|
||||
"description": "Software development kit to facilitate packaging services for StartOS",
|
||||
"main": "./package/lib/index.js",
|
||||
"types": "./package/lib/index.d.ts",
|
||||
|
||||
@@ -30,6 +30,19 @@ import { DomainHealthService } from './domain-health.service'
|
||||
selector: 'td[actions]',
|
||||
template: `
|
||||
<div class="desktop">
|
||||
@if (address().ui) {
|
||||
<a
|
||||
tuiIconButton
|
||||
appearance="flat-grayscale"
|
||||
iconStart="@tui.external-link"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
[attr.href]="address().enabled ? address().url : null"
|
||||
[class.disabled]="!address().enabled"
|
||||
>
|
||||
{{ 'Open UI' | i18n }}
|
||||
</a>
|
||||
}
|
||||
@if (address().deletable) {
|
||||
<button
|
||||
tuiIconButton
|
||||
@@ -102,6 +115,19 @@ import { DomainHealthService } from './domain-health.service'
|
||||
>
|
||||
{{ 'Actions' | i18n }}
|
||||
<tui-data-list *tuiTextfieldDropdown (click)="open.set(false)">
|
||||
@if (address().ui) {
|
||||
<a
|
||||
tuiOption
|
||||
new
|
||||
iconStart="@tui.external-link"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
[attr.href]="address().enabled ? address().url : null"
|
||||
[class.disabled]="!address().enabled"
|
||||
>
|
||||
{{ 'Open UI' | i18n }}
|
||||
</a>
|
||||
}
|
||||
<button
|
||||
tuiOption
|
||||
new
|
||||
@@ -179,6 +205,11 @@ import { DomainHealthService } from './domain-health.service'
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
pointer-events: none;
|
||||
opacity: var(--tui-disabled-opacity);
|
||||
}
|
||||
|
||||
.mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ import { DomainHealthService } from './domain-health.service'
|
||||
tuiSwitch
|
||||
size="s"
|
||||
[showIcons]="false"
|
||||
[disabled]="toggling()"
|
||||
[disabled]="toggling() || address.hostnameInfo.metadata.kind === 'mdns'"
|
||||
[ngModel]="address.enabled"
|
||||
(ngModelChange)="onToggleEnabled()"
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,10 @@ function isPublicIp(h: T.HostnameInfo): boolean {
|
||||
return h.public && (h.metadata.kind === 'ipv4' || h.metadata.kind === 'ipv6')
|
||||
}
|
||||
|
||||
export function isLanIp(h: T.HostnameInfo): boolean {
|
||||
return !h.public && (h.metadata.kind === 'ipv4' || h.metadata.kind === 'ipv6')
|
||||
}
|
||||
|
||||
function isEnabled(addr: T.DerivedAddressInfo, h: T.HostnameInfo): boolean {
|
||||
if (isPublicIp(h)) {
|
||||
if (h.port === null) return true
|
||||
@@ -135,11 +139,23 @@ export class InterfaceService {
|
||||
|
||||
return gateways
|
||||
.filter(g => (groupMap.get(g.id)?.length ?? 0) > 0)
|
||||
.map(g => ({
|
||||
gatewayId: g.id,
|
||||
gatewayName: g.name,
|
||||
addresses: groupMap.get(g.id)!.sort(sortDomainsFirst),
|
||||
}))
|
||||
.map(g => {
|
||||
const addresses = groupMap.get(g.id)!.sort(sortDomainsFirst)
|
||||
|
||||
// Derive mDNS enabled state from LAN IPs on this gateway
|
||||
const lanIps = addresses.filter(a => isLanIp(a.hostnameInfo))
|
||||
for (const a of addresses) {
|
||||
if (a.hostnameInfo.metadata.kind === 'mdns') {
|
||||
a.enabled = lanIps.some(ip => ip.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
gatewayId: g.id,
|
||||
gatewayName: g.name,
|
||||
addresses,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
getPluginGroups(
|
||||
|
||||
@@ -16,7 +16,6 @@ import { getManifest } from 'src/app/utils/get-package-data'
|
||||
template: `
|
||||
<header>{{ 'Service Launch Error' | i18n }}</header>
|
||||
<p class="error-message">{{ error?.details }}</p>
|
||||
<p>{{ error?.debug }}</p>
|
||||
<h4>
|
||||
{{ 'Actions' | i18n }}
|
||||
<tui-icon [tuiTooltip]="hint" />
|
||||
|
||||
Reference in New Issue
Block a user