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:
Aiden McClelland
2026-02-26 14:08:33 -07:00
parent 7f66c62848
commit d422cd3c66
33 changed files with 379 additions and 117 deletions

View File

@@ -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(

View File

@@ -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 = {

View 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
}
}

View File

@@ -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()))
}
/**

View File

@@ -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()))
}
/**

View File

@@ -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()))
}
/**

View File

@@ -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()))
}
/**

View File

@@ -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'

View File

@@ -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]
}

View File

@@ -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()))
}
/**

View File

@@ -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()))
}
/**

View File

@@ -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>(

View File

@@ -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",

View File

@@ -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",