add comments to everything potentially consumer facing (#3127)

* add comments to everything potentially consumer facing

* rework smtp

---------

Co-authored-by: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com>
This commit is contained in:
Matt Hill
2026-02-24 14:29:09 -07:00
committed by GitHub
parent 3974c09369
commit d4e019c87b
51 changed files with 1796 additions and 116 deletions

View File

@@ -3145,7 +3145,7 @@ help.arg.smtp-from:
fr_FR: "Adresse de l'expéditeur" fr_FR: "Adresse de l'expéditeur"
pl_PL: "Adres nadawcy e-mail" pl_PL: "Adres nadawcy e-mail"
help.arg.smtp-login: help.arg.smtp-username:
en_US: "SMTP authentication username" en_US: "SMTP authentication username"
de_DE: "SMTP-Authentifizierungsbenutzername" de_DE: "SMTP-Authentifizierungsbenutzername"
es_ES: "Nombre de usuario de autenticación SMTP" es_ES: "Nombre de usuario de autenticación SMTP"
@@ -3166,13 +3166,20 @@ help.arg.smtp-port:
fr_FR: "Port du serveur SMTP" fr_FR: "Port du serveur SMTP"
pl_PL: "Port serwera SMTP" pl_PL: "Port serwera SMTP"
help.arg.smtp-server: help.arg.smtp-host:
en_US: "SMTP server hostname" en_US: "SMTP server hostname"
de_DE: "SMTP-Server-Hostname" de_DE: "SMTP-Server-Hostname"
es_ES: "Nombre de host del servidor SMTP" es_ES: "Nombre de host del servidor SMTP"
fr_FR: "Nom d'hôte du serveur SMTP" fr_FR: "Nom d'hôte du serveur SMTP"
pl_PL: "Nazwa hosta serwera SMTP" pl_PL: "Nazwa hosta serwera SMTP"
help.arg.smtp-security:
en_US: "Connection security mode (starttls or tls)"
de_DE: "Verbindungssicherheitsmodus (starttls oder tls)"
es_ES: "Modo de seguridad de conexión (starttls o tls)"
fr_FR: "Mode de sécurité de connexion (starttls ou tls)"
pl_PL: "Tryb zabezpieczeń połączenia (starttls lub tls)"
help.arg.smtp-to: help.arg.smtp-to:
en_US: "Email recipient address" en_US: "Email recipient address"
de_DE: "E-Mail-Empfängeradresse" de_DE: "E-Mail-Empfängeradresse"

View File

@@ -1049,20 +1049,36 @@ async fn get_disk_info() -> Result<MetricsDisk, Error> {
}) })
} }
#[derive(
Debug, Clone, Copy, Default, serde::Serialize, serde::Deserialize, TS, clap::ValueEnum,
)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub enum SmtpSecurity {
#[default]
Starttls,
Tls,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Parser, TS)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Parser, TS)]
#[ts(export)] #[ts(export)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SmtpValue { pub struct SmtpValue {
#[arg(long, help = "help.arg.smtp-server")] #[arg(long, help = "help.arg.smtp-host")]
pub server: String, #[serde(alias = "server")]
pub host: String,
#[arg(long, help = "help.arg.smtp-port")] #[arg(long, help = "help.arg.smtp-port")]
pub port: u16, pub port: u16,
#[arg(long, help = "help.arg.smtp-from")] #[arg(long, help = "help.arg.smtp-from")]
pub from: String, pub from: String,
#[arg(long, help = "help.arg.smtp-login")] #[arg(long, help = "help.arg.smtp-username")]
pub login: String, #[serde(alias = "login")]
pub username: String,
#[arg(long, help = "help.arg.smtp-password")] #[arg(long, help = "help.arg.smtp-password")]
pub password: Option<String>, pub password: Option<String>,
#[arg(long, help = "help.arg.smtp-security")]
#[serde(default)]
pub security: SmtpSecurity,
} }
pub async fn set_system_smtp(ctx: RpcContext, smtp: SmtpValue) -> Result<(), Error> { pub async fn set_system_smtp(ctx: RpcContext, smtp: SmtpValue) -> Result<(), Error> {
let smtp = Some(smtp); let smtp = Some(smtp);
@@ -1121,47 +1137,63 @@ pub async fn set_ifconfig_url(
#[ts(export)] #[ts(export)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct TestSmtpParams { pub struct TestSmtpParams {
#[arg(long, help = "help.arg.smtp-server")] #[arg(long, help = "help.arg.smtp-host")]
pub server: String, pub host: String,
#[arg(long, help = "help.arg.smtp-port")] #[arg(long, help = "help.arg.smtp-port")]
pub port: u16, pub port: u16,
#[arg(long, help = "help.arg.smtp-from")] #[arg(long, help = "help.arg.smtp-from")]
pub from: String, pub from: String,
#[arg(long, help = "help.arg.smtp-to")] #[arg(long, help = "help.arg.smtp-to")]
pub to: String, pub to: String,
#[arg(long, help = "help.arg.smtp-login")] #[arg(long, help = "help.arg.smtp-username")]
pub login: String, pub username: String,
#[arg(long, help = "help.arg.smtp-password")] #[arg(long, help = "help.arg.smtp-password")]
pub password: String, pub password: String,
#[arg(long, help = "help.arg.smtp-security")]
#[serde(default)]
pub security: SmtpSecurity,
} }
pub async fn test_smtp( pub async fn test_smtp(
_: RpcContext, _: RpcContext,
TestSmtpParams { TestSmtpParams {
server, host,
port, port,
from, from,
to, to,
login, username,
password, password,
security,
}: TestSmtpParams, }: TestSmtpParams,
) -> Result<(), Error> { ) -> Result<(), Error> {
use lettre::message::header::ContentType; use lettre::message::header::ContentType;
use lettre::transport::smtp::authentication::Credentials; use lettre::transport::smtp::authentication::Credentials;
use lettre::transport::smtp::client::{Tls, TlsParameters};
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
AsyncSmtpTransport::<Tokio1Executor>::relay(&server)? let creds = Credentials::new(username, password);
.port(port) let message = Message::builder()
.credentials(Credentials::new(login, password)) .from(from.parse()?)
.build() .to(to.parse()?)
.send( .subject("StartOS Test Email")
Message::builder() .header(ContentType::TEXT_PLAIN)
.from(from.parse()?) .body("This is a test email sent from your StartOS Server".to_owned())?;
.to(to.parse()?)
.subject("StartOS Test Email") let transport = match security {
.header(ContentType::TEXT_PLAIN) SmtpSecurity::Starttls => AsyncSmtpTransport::<Tokio1Executor>::relay(&host)?
.body("This is a test email sent from your StartOS Server".to_owned())?, .port(port)
) .credentials(creds)
.await?; .build(),
SmtpSecurity::Tls => {
let tls = TlsParameters::new(host.clone())?;
AsyncSmtpTransport::<Tokio1Executor>::relay(&host)?
.port(port)
.tls(Tls::Wrapper(tls))
.credentials(creds)
.build()
}
};
transport.send(message).await?;
Ok(()) Ok(())
} }

View File

@@ -166,6 +166,9 @@ impl VersionT for Version {
// Rebuild from actual assigned ports in all bindings // Rebuild from actual assigned ports in all bindings
migrate_available_ports(db); migrate_available_ports(db);
// Migrate SMTP: rename server->host, login->username, add security field
migrate_smtp(db);
// Delete ui.name (moved to serverInfo.name) // Delete ui.name (moved to serverInfo.name)
if let Some(ui) = db if let Some(ui) = db
.get_mut("public") .get_mut("public")
@@ -269,6 +272,25 @@ fn migrate_available_ports(db: &mut Value) {
} }
} }
fn migrate_smtp(db: &mut Value) {
if let Some(smtp) = db
.get_mut("public")
.and_then(|p| p.get_mut("serverInfo"))
.and_then(|s| s.get_mut("smtp"))
.and_then(|s| s.as_object_mut())
{
if let Some(server) = smtp.remove("server") {
smtp.insert("host".into(), server);
}
if let Some(login) = smtp.remove("login") {
smtp.insert("username".into(), login);
}
if !smtp.contains_key("security") {
smtp.insert("security".into(), json!("starttls"));
}
}
}
fn denormalize_hostname(s: &str) -> String { fn denormalize_hostname(s: &str) -> String {
let mut cap = true; let mut cap = true;
s.chars() s.chars()

View File

@@ -6,19 +6,28 @@ import { z } from 'zod'
import { DeepPartial } from '../../../types' import { DeepPartial } from '../../../types'
import { InputSpecTools, createInputSpecTools } from './inputSpecTools' import { InputSpecTools, createInputSpecTools } from './inputSpecTools'
/** Options passed to a lazy builder function when resolving dynamic form field values. */
export type LazyBuildOptions<Type> = { export type LazyBuildOptions<Type> = {
/** The effects interface for runtime operations (e.g. reading files, querying state). */
effects: Effects effects: Effects
/** Previously saved form data to pre-fill the form with, or `null` for fresh creation. */
prefill: DeepPartial<Type> | null prefill: DeepPartial<Type> | null
} }
/**
* A function that lazily produces a value, potentially using effects and prefill data.
* Used by `dynamic*` variants of {@link Value} to compute form field options at runtime.
*/
export type LazyBuild<ExpectedOut, Type> = ( export type LazyBuild<ExpectedOut, Type> = (
options: LazyBuildOptions<Type>, options: LazyBuildOptions<Type>,
) => Promise<ExpectedOut> | ExpectedOut ) => Promise<ExpectedOut> | ExpectedOut
/** Extracts the runtime type from an {@link InputSpec}. */
// prettier-ignore // prettier-ignore
export type ExtractInputSpecType<A extends InputSpec<Record<string, any>, any>> = export type ExtractInputSpecType<A extends InputSpec<Record<string, any>, any>> =
A extends InputSpec<infer B, any> ? B : A extends InputSpec<infer B, any> ? B :
never never
/** Extracts the static validation type from an {@link InputSpec}. */
export type ExtractInputSpecStaticValidatedAs< export type ExtractInputSpecStaticValidatedAs<
A extends InputSpec<any, Record<string, any>>, A extends InputSpec<any, Record<string, any>>,
> = A extends InputSpec<any, infer B> ? B : never > = A extends InputSpec<any, infer B> ? B : never
@@ -27,10 +36,12 @@ export type ExtractInputSpecStaticValidatedAs<
// A extends Record<string, any> | InputSpec<Record<string, any>>, // A extends Record<string, any> | InputSpec<Record<string, any>>,
// > = A extends InputSpec<infer B> ? DeepPartial<B> : DeepPartial<A> // > = A extends InputSpec<infer B> ? DeepPartial<B> : DeepPartial<A>
/** Maps an object type to a record of {@link Value} entries for use with `InputSpec.of`. */
export type InputSpecOf<A extends Record<string, any>> = { export type InputSpecOf<A extends Record<string, any>> = {
[K in keyof A]: Value<A[K]> [K in keyof A]: Value<A[K]>
} }
/** A value that is either directly provided or lazily computed via a {@link LazyBuild} function. */
export type MaybeLazyValues<A, T> = LazyBuild<A, T> | A export type MaybeLazyValues<A, T> = LazyBuild<A, T> | A
/** /**
* InputSpecs are the specs that are used by the os input specification form for this service. * InputSpecs are the specs that are used by the os input specification form for this service.
@@ -100,6 +111,11 @@ export class InputSpec<
) {} ) {}
public _TYPE: Type = null as any as Type public _TYPE: Type = null as any as Type
public _PARTIAL: DeepPartial<Type> = null as any as DeepPartial<Type> public _PARTIAL: DeepPartial<Type> = null as any as DeepPartial<Type>
/**
* Builds the runtime form specification and combined Zod validator from this InputSpec's fields.
*
* @returns An object containing the resolved `spec` (field specs keyed by name) and a combined `validator`
*/
async build<OuterType>(options: LazyBuildOptions<OuterType>): Promise<{ async build<OuterType>(options: LazyBuildOptions<OuterType>): Promise<{
spec: { spec: {
[K in keyof Type]: ValueSpec [K in keyof Type]: ValueSpec
@@ -123,6 +139,12 @@ export class InputSpec<
} }
} }
/**
* Adds a single named field to this spec, returning a new `InputSpec` with the extended type.
*
* @param key - The field key name
* @param build - A {@link Value} instance, or a function receiving typed tools that returns one
*/
addKey<Key extends string, V extends Value<any, any, any>>( addKey<Key extends string, V extends Value<any, any, any>>(
key: Key, key: Key,
build: V | ((tools: InputSpecTools<Type>) => V), build: V | ((tools: InputSpecTools<Type>) => V),
@@ -146,6 +168,11 @@ export class InputSpec<
return new InputSpec(newSpec, newValidator as any) return new InputSpec(newSpec, newValidator as any)
} }
/**
* Adds multiple fields to this spec at once, returning a new `InputSpec` with extended types.
*
* @param build - A record of {@link Value} entries, or a function receiving typed tools that returns one
*/
add<AddSpec extends Record<string, Value<any, any, any>>>( add<AddSpec extends Record<string, Value<any, any, any>>>(
build: AddSpec | ((tools: InputSpecTools<Type>) => AddSpec), build: AddSpec | ((tools: InputSpecTools<Type>) => AddSpec),
): InputSpec< ): InputSpec<
@@ -174,6 +201,17 @@ export class InputSpec<
return new InputSpec(newSpec, newValidator as any) return new InputSpec(newSpec, newValidator as any)
} }
/**
* Creates an `InputSpec` from a plain record of {@link Value} entries.
*
* @example
* ```ts
* const spec = InputSpec.of({
* username: Value.text({ name: 'Username', required: true, default: null }),
* verbose: Value.toggle({ name: 'Verbose Logging', default: false }),
* })
* ```
*/
static of<Spec extends Record<string, Value<any, any>>>(spec: Spec) { static of<Spec extends Record<string, Value<any, any>>>(spec: Spec) {
const validator = z.object( const validator = z.object(
Object.fromEntries( Object.fromEntries(

View File

@@ -9,6 +9,14 @@ import {
} from '../inputSpecTypes' } from '../inputSpecTypes'
import { z } from 'zod' import { z } from 'zod'
/**
* Builder class for defining list-type form fields.
*
* A list presents an interface to add, remove, and reorder items. Items can be
* either text strings ({@link List.text}) or structured objects ({@link List.obj}).
*
* Used with {@link Value.list} to include a list field in an {@link InputSpec}.
*/
export class List< export class List<
Type extends StaticValidatedAs, Type extends StaticValidatedAs,
StaticValidatedAs = Type, StaticValidatedAs = Type,
@@ -26,6 +34,12 @@ export class List<
) {} ) {}
readonly _TYPE: Type = null as any readonly _TYPE: Type = null as any
/**
* Creates a list of text input items.
*
* @param a - List-level options (name, description, min/max length, defaults)
* @param aSpec - Item-level options (patterns, input mode, masking, generation)
*/
static text( static text(
a: { a: {
name: string name: string
@@ -97,6 +111,7 @@ export class List<
}, validator) }, validator)
} }
/** Like {@link List.text} but options are resolved lazily at runtime via a builder function. */
static dynamicText<OuterType = unknown>( static dynamicText<OuterType = unknown>(
getA: LazyBuild< getA: LazyBuild<
{ {
@@ -150,6 +165,12 @@ export class List<
}, validator) }, validator)
} }
/**
* Creates a list of structured object items, each defined by a nested {@link InputSpec}.
*
* @param a - List-level options (name, description, min/max length)
* @param aSpec - Item-level options (the nested spec, display expression, uniqueness constraint)
*/
static obj< static obj<
Type extends StaticValidatedAs, Type extends StaticValidatedAs,
StaticValidatedAs extends Record<string, any>, StaticValidatedAs extends Record<string, any>,

View File

@@ -15,12 +15,15 @@ import { _, once } from '../../../util'
import { z } from 'zod' import { z } from 'zod'
import { DeepPartial } from '../../../types' import { DeepPartial } from '../../../types'
/** Zod schema for a file upload result — validates `{ path, commitment: { hash, size } }`. */
export const fileInfoParser = z.object({ export const fileInfoParser = z.object({
path: z.string(), path: z.string(),
commitment: z.object({ hash: z.string(), size: z.number() }), commitment: z.object({ hash: z.string(), size: z.number() }),
}) })
/** The parsed result of a file upload, containing the file path and its content commitment (hash + size). */
export type FileInfo = z.infer<typeof fileInfoParser> export type FileInfo = z.infer<typeof fileInfoParser>
/** Conditional type: returns `T` if `Required` is `true`, otherwise `T | null`. */
export type AsRequired<T, Required extends boolean> = Required extends true export type AsRequired<T, Required extends boolean> = Required extends true
? T ? T
: T | null : T | null
@@ -37,6 +40,19 @@ function asRequiredParser<Type, Input extends { required: boolean }>(
return parser.nullable() as any return parser.nullable() as any
} }
/**
* Core builder class for defining a single form field in a service configuration spec.
*
* Each static factory method (e.g. `Value.text()`, `Value.toggle()`, `Value.select()`) creates
* a typed `Value` instance representing a specific field type. Dynamic variants (e.g. `Value.dynamicText()`)
* allow the field options to be computed lazily at runtime.
*
* Use with {@link InputSpec} to compose complete form specifications.
*
* @typeParam Type - The runtime type this field produces when filled in
* @typeParam StaticValidatedAs - The compile-time validated type (usually same as Type)
* @typeParam OuterType - The parent form's type context (used by dynamic variants)
*/
export class Value< export class Value<
Type extends StaticValidatedAs, Type extends StaticValidatedAs,
StaticValidatedAs = Type, StaticValidatedAs = Type,
@@ -99,6 +115,7 @@ export class Value<
validator, validator,
) )
} }
/** Like {@link Value.toggle} but options are resolved lazily at runtime via a builder function. */
static dynamicToggle<OuterType = unknown>( static dynamicToggle<OuterType = unknown>(
a: LazyBuild< a: LazyBuild<
{ {
@@ -225,6 +242,7 @@ export class Value<
validator, validator,
) )
} }
/** Like {@link Value.text} but options are resolved lazily at runtime via a builder function. */
static dynamicText<Required extends boolean, OuterType = unknown>( static dynamicText<Required extends boolean, OuterType = unknown>(
getA: LazyBuild< getA: LazyBuild<
{ {
@@ -345,6 +363,7 @@ export class Value<
return { spec: built, validator } return { spec: built, validator }
}, validator) }, validator)
} }
/** Like {@link Value.textarea} but options are resolved lazily at runtime via a builder function. */
static dynamicTextarea<Required extends boolean, OuterType = unknown>( static dynamicTextarea<Required extends boolean, OuterType = unknown>(
getA: LazyBuild< getA: LazyBuild<
{ {
@@ -467,6 +486,7 @@ export class Value<
validator, validator,
) )
} }
/** Like {@link Value.number} but options are resolved lazily at runtime via a builder function. */
static dynamicNumber<Required extends boolean, OuterType = unknown>( static dynamicNumber<Required extends boolean, OuterType = unknown>(
getA: LazyBuild< getA: LazyBuild<
{ {
@@ -562,6 +582,7 @@ export class Value<
) )
} }
/** Like {@link Value.color} but options are resolved lazily at runtime via a builder function. */
static dynamicColor<Required extends boolean, OuterType = unknown>( static dynamicColor<Required extends boolean, OuterType = unknown>(
getA: LazyBuild< getA: LazyBuild<
{ {
@@ -659,6 +680,7 @@ export class Value<
validator, validator,
) )
} }
/** Like {@link Value.datetime} but options are resolved lazily at runtime via a builder function. */
static dynamicDatetime<Required extends boolean, OuterType = unknown>( static dynamicDatetime<Required extends boolean, OuterType = unknown>(
getA: LazyBuild< getA: LazyBuild<
{ {
@@ -769,6 +791,7 @@ export class Value<
validator, validator,
) )
} }
/** Like {@link Value.select} but options are resolved lazily at runtime via a builder function. */
static dynamicSelect< static dynamicSelect<
Values extends Record<string, string>, Values extends Record<string, string>,
OuterType = unknown, OuterType = unknown,
@@ -889,6 +912,7 @@ export class Value<
validator, validator,
) )
} }
/** Like {@link Value.multiselect} but options are resolved lazily at runtime via a builder function. */
static dynamicMultiselect< static dynamicMultiselect<
Values extends Record<string, string>, Values extends Record<string, string>,
OuterType = unknown, OuterType = unknown,
@@ -977,6 +1001,12 @@ export class Value<
} }
}, spec.validator) }, spec.validator)
} }
/**
* Displays a file upload input field.
*
* @param a.extensions - Allowed file extensions (e.g. `[".pem", ".crt"]`)
* @param a.required - Whether a file must be selected
*/
static file<Required extends boolean>(a: { static file<Required extends boolean>(a: {
name: string name: string
description?: string | null description?: string | null
@@ -1000,6 +1030,7 @@ export class Value<
asRequiredParser(fileInfoParser, a), asRequiredParser(fileInfoParser, a),
) )
} }
/** Like {@link Value.file} but options are resolved lazily at runtime via a builder function. */
static dynamicFile<Required extends boolean, OuterType = unknown>( static dynamicFile<Required extends boolean, OuterType = unknown>(
a: LazyBuild< a: LazyBuild<
{ {
@@ -1102,6 +1133,7 @@ export class Value<
} }
}, a.variants.validator) }, a.variants.validator)
} }
/** Like {@link Value.union} but options (including which variants are available) are resolved lazily at runtime. */
static dynamicUnion< static dynamicUnion<
VariantValues extends { VariantValues extends {
[K in string]: { [K in string]: {
@@ -1123,6 +1155,7 @@ export class Value<
OuterType OuterType
>, >,
): Value<UnionRes<VariantValues>, UnionRes<VariantValues>, OuterType> ): Value<UnionRes<VariantValues>, UnionRes<VariantValues>, OuterType>
/** Like {@link Value.union} but options are resolved lazily, with an explicit static validator type. */
static dynamicUnion< static dynamicUnion<
StaticVariantValues extends { StaticVariantValues extends {
[K in string]: { [K in string]: {
@@ -1300,6 +1333,12 @@ export class Value<
}, z.any()) }, z.any())
} }
/**
* Transforms the validated output value using a mapping function.
* The form field itself remains unchanged, but the value is transformed after validation.
*
* @param fn - A function to transform the validated value
*/
map<U>(fn: (value: StaticValidatedAs) => U): Value<U, U, OuterType> { map<U>(fn: (value: StaticValidatedAs) => U): Value<U, U, OuterType> {
return new Value<U, U, OuterType>(async (options) => { return new Value<U, U, OuterType>(async (options) => {
const built = await this.build(options) const built = await this.build(options)

View File

@@ -8,6 +8,11 @@ import {
} from './inputSpec' } from './inputSpec'
import { z } from 'zod' import { z } from 'zod'
/**
* The runtime result type of a discriminated union form field.
* Contains `selection` (the chosen variant key), `value` (the variant's form data),
* and optionally `other` (partial data from previously selected variants).
*/
export type UnionRes< export type UnionRes<
VariantValues extends { VariantValues extends {
[K in string]: { [K in string]: {
@@ -28,6 +33,7 @@ export type UnionRes<
} }
}[K] }[K]
/** Like {@link UnionRes} but using the static (Zod-inferred) validated types. */
export type UnionResStaticValidatedAs< export type UnionResStaticValidatedAs<
VariantValues extends { VariantValues extends {
[K in string]: { [K in string]: {
@@ -118,6 +124,11 @@ export class Variants<
>, >,
) {} ) {}
readonly _TYPE: UnionRes<VariantValues> = null as any readonly _TYPE: UnionRes<VariantValues> = null as any
/**
* Creates a `Variants` instance from a record mapping variant keys to their display name and form spec.
*
* @param a - A record of `{ name: string, spec: InputSpec }` entries, one per variant
*/
static of< static of<
VariantValues extends { VariantValues extends {
[K in string]: { [K in string]: {

View File

@@ -5,42 +5,124 @@ import { Value } from './builder/value'
import { Variants } from './builder/variants' import { Variants } from './builder/variants'
/** /**
* Base SMTP settings, to be used by StartOS for system wide SMTP * Creates an SMTP field spec with provider-specific defaults pre-filled.
*/ */
export const customSmtp: InputSpec<SmtpValue> = InputSpec.of< function smtpFields(
InputSpecOf<SmtpValue> defaults: {
>({ host?: string
server: Value.text({ port?: number
name: 'SMTP Server', security?: 'starttls' | 'tls'
required: true, } = {},
default: null, ): InputSpec<SmtpValue> {
}), return InputSpec.of<InputSpecOf<SmtpValue>>({
port: Value.number({ host: Value.text({
name: 'Port', name: 'Host',
required: true, required: true,
default: 587, default: defaults.host ?? null,
min: 1, placeholder: 'smtp.example.com',
max: 65535, }),
integer: true, port: Value.number({
}), name: 'Port',
from: Value.text({ required: true,
name: 'From Address', default: defaults.port ?? 587,
required: true, min: 1,
default: null, max: 65535,
placeholder: 'Example Name <test@example.com>', integer: true,
inputmode: 'email', }),
patterns: [Patterns.emailWithName], security: Value.select({
}), name: 'Connection Security',
login: Value.text({ default: defaults.security ?? 'starttls',
name: 'Login', values: {
required: true, starttls: 'STARTTLS',
default: null, tls: 'TLS',
}), },
password: Value.text({ }),
name: 'Password', from: Value.text({
required: false, name: 'From Address',
default: null, required: true,
masked: true, default: null,
placeholder: 'Example Name <test@example.com>',
patterns: [Patterns.emailWithName],
}),
username: Value.text({
name: 'Username',
required: true,
default: null,
}),
password: Value.text({
name: 'Password',
required: false,
default: null,
masked: true,
}),
})
}
/**
* Base SMTP settings with no provider-specific defaults.
*/
export const customSmtp = smtpFields()
/**
* Provider presets for SMTP configuration.
* Each variant has SMTP fields pre-filled with the provider's recommended settings.
*/
export const smtpProviderVariants = Variants.of({
gmail: {
name: 'Gmail',
spec: smtpFields({
host: 'smtp.gmail.com',
port: 587,
security: 'starttls',
}),
},
ses: {
name: 'Amazon SES',
spec: smtpFields({
host: 'email-smtp.us-east-1.amazonaws.com',
port: 587,
security: 'starttls',
}),
},
sendgrid: {
name: 'SendGrid',
spec: smtpFields({
host: 'smtp.sendgrid.net',
port: 587,
security: 'starttls',
}),
},
mailgun: {
name: 'Mailgun',
spec: smtpFields({
host: 'smtp.mailgun.org',
port: 587,
security: 'starttls',
}),
},
protonmail: {
name: 'Proton Mail',
spec: smtpFields({
host: 'smtp.protonmail.ch',
port: 587,
security: 'starttls',
}),
},
other: {
name: 'Other',
spec: customSmtp,
},
})
/**
* System SMTP settings with provider presets.
* Wraps smtpProviderVariants in a union for use by the system email settings page.
*/
export const systemSmtpSpec = InputSpec.of({
provider: Value.union({
name: 'Provider',
default: null as any,
variants: smtpProviderVariants,
}), }),
}) })
@@ -55,19 +137,24 @@ const smtpVariants = Variants.of({
'A custom from address for this service. If not provided, the system from address will be used.', 'A custom from address for this service. If not provided, the system from address will be used.',
required: false, required: false,
default: null, default: null,
placeholder: '<name>test@example.com', placeholder: 'Name <test@example.com>',
inputmode: 'email', patterns: [Patterns.emailWithName],
patterns: [Patterns.email],
}), }),
}), }),
}, },
custom: { custom: {
name: 'Custom Credentials', name: 'Custom Credentials',
spec: customSmtp, spec: InputSpec.of({
provider: Value.union({
name: 'Provider',
default: null as any,
variants: smtpProviderVariants,
}),
}),
}, },
}) })
/** /**
* For service inputSpec. Gives users 3 options for SMTP: (1) disabled, (2) use system SMTP settings, (3) use custom SMTP settings * For service inputSpec. Gives users 3 options for SMTP: (1) disabled, (2) use system SMTP settings, (3) use custom SMTP settings with provider presets
*/ */
export const smtpInputSpec = Value.dynamicUnion(async ({ effects }) => { export const smtpInputSpec = Value.dynamicUnion(async ({ effects }) => {
const smtp = await new GetSystemSmtp(effects).once() const smtp = await new GetSystemSmtp(effects).once()

View File

@@ -1,4 +1,12 @@
/**
* A record mapping field keys to their {@link ValueSpec} definitions.
* This is the root shape of a dynamic form specification — it defines the complete set
* of configurable fields for a service or action.
*/
export type InputSpec = Record<string, ValueSpec> export type InputSpec = Record<string, ValueSpec>
/**
* The discriminator for all supported form field types.
*/
export type ValueType = export type ValueType =
| 'text' | 'text'
| 'textarea' | 'textarea'
@@ -13,6 +21,7 @@ export type ValueType =
| 'file' | 'file'
| 'union' | 'union'
| 'hidden' | 'hidden'
/** Union of all concrete form field spec types. Discriminate on the `type` field. */
export type ValueSpec = ValueSpecOf<ValueType> export type ValueSpec = ValueSpecOf<ValueType>
/** core spec types. These types provide the metadata for performing validations */ /** core spec types. These types provide the metadata for performing validations */
// prettier-ignore // prettier-ignore
@@ -32,37 +41,56 @@ export type ValueSpecOf<T extends ValueType> =
T extends "hidden" ? ValueSpecHidden : T extends "hidden" ? ValueSpecHidden :
never never
/** Spec for a single-line text input field. */
export type ValueSpecText = { export type ValueSpecText = {
/** Display label for the field. */
name: string name: string
/** Optional help text displayed below the field. */
description: string | null description: string | null
/** Optional warning message displayed to the user. */
warning: string | null warning: string | null
type: 'text' type: 'text'
/** Regex patterns used to validate the input value. */
patterns: Pattern[] patterns: Pattern[]
/** Minimum character length, or `null` for no minimum. */
minLength: number | null minLength: number | null
/** Maximum character length, or `null` for no maximum. */
maxLength: number | null maxLength: number | null
/** Whether the field should obscure input (e.g. for passwords). */
masked: boolean masked: boolean
/** HTML input mode hint for mobile keyboards. */
inputmode: 'text' | 'email' | 'tel' | 'url' inputmode: 'text' | 'email' | 'tel' | 'url'
/** Placeholder text shown when the field is empty. */
placeholder: string | null placeholder: string | null
/** Whether the field must have a value. */
required: boolean required: boolean
/** Default value, which may be a literal string or a {@link RandomString} generation spec. */
default: DefaultString | null default: DefaultString | null
/** `false` if editable, or a string message explaining why the field is disabled. */
disabled: false | string disabled: false | string
/** If set, provides a "generate" button that fills the field with a random string matching this spec. */
generate: null | RandomString generate: null | RandomString
/** Whether the field value cannot be changed after initial configuration. */
immutable: boolean immutable: boolean
} }
/** Spec for a multi-line textarea input field. */
export type ValueSpecTextarea = { export type ValueSpecTextarea = {
name: string name: string
description: string | null description: string | null
warning: string | null warning: string | null
type: 'textarea' type: 'textarea'
/** Regex patterns used to validate the input value. */
patterns: Pattern[] patterns: Pattern[]
placeholder: string | null placeholder: string | null
minLength: number | null minLength: number | null
maxLength: number | null maxLength: number | null
/** Minimum number of visible rows. */
minRows: number minRows: number
/** Maximum number of visible rows before scrolling. */
maxRows: number maxRows: number
required: boolean required: boolean
default: string | null default: string | null
@@ -70,12 +98,18 @@ export type ValueSpecTextarea = {
immutable: boolean immutable: boolean
} }
/** Spec for a numeric input field. */
export type ValueSpecNumber = { export type ValueSpecNumber = {
type: 'number' type: 'number'
/** Minimum allowed value, or `null` for unbounded. */
min: number | null min: number | null
/** Maximum allowed value, or `null` for unbounded. */
max: number | null max: number | null
/** Whether only whole numbers are accepted. */
integer: boolean integer: boolean
/** Step increment for the input spinner, or `null` for any precision. */
step: number | null step: number | null
/** Display label for the unit (e.g. `"MB"`, `"seconds"`), shown next to the field. */
units: string | null units: string | null
placeholder: string | null placeholder: string | null
name: string name: string
@@ -86,6 +120,7 @@ export type ValueSpecNumber = {
disabled: false | string disabled: false | string
immutable: boolean immutable: boolean
} }
/** Spec for a browser-native color picker field. */
export type ValueSpecColor = { export type ValueSpecColor = {
name: string name: string
description: string | null description: string | null
@@ -93,34 +128,44 @@ export type ValueSpecColor = {
type: 'color' type: 'color'
required: boolean required: boolean
/** Default hex color string (e.g. `"#ff0000"`), or `null`. */
default: string | null default: string | null
disabled: false | string disabled: false | string
immutable: boolean immutable: boolean
} }
/** Spec for a date, time, or datetime input field. */
export type ValueSpecDatetime = { export type ValueSpecDatetime = {
name: string name: string
description: string | null description: string | null
warning: string | null warning: string | null
type: 'datetime' type: 'datetime'
required: boolean required: boolean
/** Controls which kind of picker is displayed. */
inputmode: 'date' | 'time' | 'datetime-local' inputmode: 'date' | 'time' | 'datetime-local'
/** Minimum selectable date/time as an ISO string, or `null`. */
min: string | null min: string | null
/** Maximum selectable date/time as an ISO string, or `null`. */
max: string | null max: string | null
default: string | null default: string | null
disabled: false | string disabled: false | string
immutable: boolean immutable: boolean
} }
/** Spec for a single-select field displayed as radio buttons in a modal. */
export type ValueSpecSelect = { export type ValueSpecSelect = {
/** Map of option keys to display labels. */
values: Record<string, string> values: Record<string, string>
name: string name: string
description: string | null description: string | null
warning: string | null warning: string | null
type: 'select' type: 'select'
default: string | null default: string | null
/** `false` if all enabled, a string disabling the whole field, or an array of disabled option keys. */
disabled: false | string | string[] disabled: false | string | string[]
immutable: boolean immutable: boolean
} }
/** Spec for a multi-select field displayed as checkboxes in a modal. */
export type ValueSpecMultiselect = { export type ValueSpecMultiselect = {
/** Map of option keys to display labels. */
values: Record<string, string> values: Record<string, string>
name: string name: string
@@ -128,12 +173,17 @@ export type ValueSpecMultiselect = {
warning: string | null warning: string | null
type: 'multiselect' type: 'multiselect'
/** Minimum number of selections required, or `null`. */
minLength: number | null minLength: number | null
/** Maximum number of selections allowed, or `null`. */
maxLength: number | null maxLength: number | null
/** `false` if all enabled, a string disabling the whole field, or an array of disabled option keys. */
disabled: false | string | string[] disabled: false | string | string[]
/** Array of option keys selected by default. */
default: string[] default: string[]
immutable: boolean immutable: boolean
} }
/** Spec for a boolean toggle (on/off switch). */
export type ValueSpecToggle = { export type ValueSpecToggle = {
name: string name: string
description: string | null description: string | null
@@ -144,57 +194,81 @@ export type ValueSpecToggle = {
disabled: false | string disabled: false | string
immutable: boolean immutable: boolean
} }
/**
* Spec for a discriminated union field — displays a dropdown for variant selection,
* and each variant can have its own nested sub-form.
*/
export type ValueSpecUnion = { export type ValueSpecUnion = {
name: string name: string
description: string | null description: string | null
warning: string | null warning: string | null
type: 'union' type: 'union'
/** Map of variant keys to their display name and nested form spec. */
variants: Record< variants: Record<
string, string,
{ {
/** Display name for this variant in the dropdown. */
name: string name: string
/** Nested form spec shown when this variant is selected. */
spec: InputSpec spec: InputSpec
} }
> >
/** `false` if all enabled, a string disabling the whole field, or an array of disabled variant keys. */
disabled: false | string | string[] disabled: false | string | string[]
default: string | null default: string | null
immutable: boolean immutable: boolean
} }
/** Spec for a file upload input field. */
export type ValueSpecFile = { export type ValueSpecFile = {
name: string name: string
description: string | null description: string | null
warning: string | null warning: string | null
type: 'file' type: 'file'
/** Allowed file extensions (e.g. `[".pem", ".crt"]`). */
extensions: string[] extensions: string[]
required: boolean required: boolean
} }
/** Spec for a collapsible grouping of nested fields (a "sub-form"). */
export type ValueSpecObject = { export type ValueSpecObject = {
name: string name: string
description: string | null description: string | null
warning: string | null warning: string | null
type: 'object' type: 'object'
/** The nested form spec containing this object's fields. */
spec: InputSpec spec: InputSpec
} }
/** Spec for a hidden field — not displayed to the user but included in the form data. */
export type ValueSpecHidden = { export type ValueSpecHidden = {
type: 'hidden' type: 'hidden'
} }
/** The two supported list item types. */
export type ListValueSpecType = 'text' | 'object' export type ListValueSpecType = 'text' | 'object'
/** Maps a {@link ListValueSpecType} to its concrete list item spec. */
// prettier-ignore // prettier-ignore
export type ListValueSpecOf<T extends ListValueSpecType> = export type ListValueSpecOf<T extends ListValueSpecType> =
T extends "text" ? ListValueSpecText : T extends "text" ? ListValueSpecText :
T extends "object" ? ListValueSpecObject : T extends "object" ? ListValueSpecObject :
never never
/** A list field spec — union of text-list and object-list variants. */
export type ValueSpecList = ValueSpecListOf<ListValueSpecType> export type ValueSpecList = ValueSpecListOf<ListValueSpecType>
/**
* Spec for a list field — an interface to add, remove, and edit items in an ordered collection.
* The `spec` field determines whether list items are text strings or structured objects.
*/
export type ValueSpecListOf<T extends ListValueSpecType> = { export type ValueSpecListOf<T extends ListValueSpecType> = {
name: string name: string
description: string | null description: string | null
warning: string | null warning: string | null
type: 'list' type: 'list'
/** The item spec — determines whether this is a list of text values or objects. */
spec: ListValueSpecOf<T> spec: ListValueSpecOf<T>
/** Minimum number of items, or `null` for no minimum. */
minLength: number | null minLength: number | null
/** Maximum number of items, or `null` for no maximum. */
maxLength: number | null maxLength: number | null
disabled: false | string disabled: false | string
/** Default list items to populate on creation. */
default: default:
| string[] | string[]
| DefaultString[] | DefaultString[]
@@ -203,10 +277,14 @@ export type ValueSpecListOf<T extends ListValueSpecType> = {
| readonly DefaultString[] | readonly DefaultString[]
| readonly Record<string, unknown>[] | readonly Record<string, unknown>[]
} }
/** A regex validation pattern with a human-readable description of what it enforces. */
export type Pattern = { export type Pattern = {
/** The regex pattern string (without delimiters). */
regex: string regex: string
/** A user-facing explanation shown when validation fails (e.g. `"Must be a valid email"`). */
description: string description: string
} }
/** Spec for text items within a list field. */
export type ListValueSpecText = { export type ListValueSpecText = {
type: 'text' type: 'text'
patterns: Pattern[] patterns: Pattern[]
@@ -218,13 +296,24 @@ export type ListValueSpecText = {
inputmode: 'text' | 'email' | 'tel' | 'url' inputmode: 'text' | 'email' | 'tel' | 'url'
placeholder: string | null placeholder: string | null
} }
/** Spec for object items within a list field. */
export type ListValueSpecObject = { export type ListValueSpecObject = {
type: 'object' type: 'object'
/** The form spec for each object item. */
spec: InputSpec spec: InputSpec
/** Defines how uniqueness is determined among list items. */
uniqueBy: UniqueBy uniqueBy: UniqueBy
/** An expression used to generate the display string for each item in the list summary (e.g. a key path). */
displayAs: string | null displayAs: string | null
} }
/**
* Describes how list items determine uniqueness.
* - `null`: no uniqueness constraint
* - `string`: unique by a specific field key
* - `{ any: UniqueBy[] }`: unique if any of the sub-constraints match
* - `{ all: UniqueBy[] }`: unique if all sub-constraints match together
*/
export type UniqueBy = export type UniqueBy =
| null | null
| string | string
@@ -234,12 +323,21 @@ export type UniqueBy =
| { | {
all: readonly UniqueBy[] | UniqueBy[] all: readonly UniqueBy[] | UniqueBy[]
} }
/** A default value that is either a literal string or a {@link RandomString} generation spec. */
export type DefaultString = string | RandomString export type DefaultString = string | RandomString
/** Spec for generating a random string — used for default passwords, API keys, etc. */
export type RandomString = { export type RandomString = {
/** The character set to draw from (e.g. `"a-zA-Z0-9"`). */
charset: string charset: string
/** The length of the generated string. */
len: number len: number
} }
// sometimes the type checker needs just a little bit of help /**
* Type guard that narrows a {@link ValueSpec} to a {@link ValueSpecListOf} of a specific item type.
*
* @param t - The value spec to check
* @param s - The list item type to narrow to (`"text"` or `"object"`)
*/
export function isValueSpecListOf<S extends ListValueSpecType>( export function isValueSpecListOf<S extends ListValueSpecType>(
t: ValueSpec, t: ValueSpec,
s: S, s: S,

View File

@@ -1,6 +1,17 @@
import { DeepMap } from 'deep-equality-data-structures' import { DeepMap } from 'deep-equality-data-structures'
import * as P from './exver' import * as P from './exver'
/**
* Compile-time utility type that validates a version string literal conforms to semver format.
*
* Resolves to `unknown` if valid, `never` if invalid. Used with {@link testTypeVersion}.
*
* @example
* ```ts
* type Valid = ValidateVersion<"1.2.3"> // unknown (valid)
* type Invalid = ValidateVersion<"-3"> // never (invalid)
* ```
*/
// prettier-ignore // prettier-ignore
export type ValidateVersion<T extends String> = export type ValidateVersion<T extends String> =
T extends `-${infer A}` ? never : T extends `-${infer A}` ? never :
@@ -9,12 +20,32 @@ T extends `${infer A}-${string}` ? ValidateVersion<A> :
T extends `${bigint}.${infer A}` ? ValidateVersion<A> : T extends `${bigint}.${infer A}` ? ValidateVersion<A> :
never never
/**
* Compile-time utility type that validates an extended version string literal.
*
* Extended versions have the format `upstream:downstream` or `#flavor:upstream:downstream`.
*
* @example
* ```ts
* type Valid = ValidateExVer<"1.2.3:0"> // valid
* type Flavored = ValidateExVer<"#bitcoin:1.0:0"> // valid
* type Bad = ValidateExVer<"1.2-3"> // never (invalid)
* ```
*/
// prettier-ignore // prettier-ignore
export type ValidateExVer<T extends string> = export type ValidateExVer<T extends string> =
T extends `#${string}:${infer A}:${infer B}` ? ValidateVersion<A> & ValidateVersion<B> : T extends `#${string}:${infer A}:${infer B}` ? ValidateVersion<A> & ValidateVersion<B> :
T extends `${infer A}:${infer B}` ? ValidateVersion<A> & ValidateVersion<B> : T extends `${infer A}:${infer B}` ? ValidateVersion<A> & ValidateVersion<B> :
never never
/**
* Validates a tuple of extended version string literals at compile time.
*
* @example
* ```ts
* type Valid = ValidateExVers<["1.0:0", "2.0:0"]> // valid
* ```
*/
// prettier-ignore // prettier-ignore
export type ValidateExVers<T> = export type ValidateExVers<T> =
T extends [] ? unknown[] : T extends [] ? unknown[] :
@@ -460,6 +491,28 @@ class VersionRangeTable {
} }
} }
/**
* Represents a parsed version range expression used to match against {@link Version} or {@link ExtendedVersion} values.
*
* Version ranges support standard comparison operators (`=`, `>`, `<`, `>=`, `<=`, `!=`),
* caret (`^`) and tilde (`~`) ranges, boolean logic (`&&`, `||`, `!`), and flavor matching (`#flavor`).
*
* @example
* ```ts
* const range = VersionRange.parse(">=1.0.0:0 && <2.0.0:0")
* const version = ExtendedVersion.parse("1.5.0:0")
* console.log(range.satisfiedBy(version)) // true
*
* // Combine ranges with boolean logic
* const combined = VersionRange.and(
* VersionRange.parse(">=1.0:0"),
* VersionRange.parse("<3.0:0"),
* )
*
* // Match a specific flavor
* const flavored = VersionRange.parse("#bitcoin")
* ```
*/
export class VersionRange { export class VersionRange {
constructor(public atom: Anchor | And | Or | Not | P.Any | P.None | Flavor) {} constructor(public atom: Anchor | And | Or | Not | P.Any | P.None | Flavor) {}
@@ -488,6 +541,7 @@ export class VersionRange {
} }
} }
/** Serializes this version range back to its canonical string representation. */
toString(): string { toString(): string {
switch (this.atom.type) { switch (this.atom.type) {
case 'Anchor': case 'Anchor':
@@ -563,38 +617,69 @@ export class VersionRange {
return result return result
} }
/**
* Parses a version range string into a `VersionRange`.
*
* @param range - A version range expression, e.g. `">=1.0.0:0 && <2.0.0:0"`, `"^1.2:0"`, `"*"`
* @returns The parsed `VersionRange`
* @throws If the string is not a valid version range expression
*/
static parse(range: string): VersionRange { static parse(range: string): VersionRange {
return VersionRange.parseRange( return VersionRange.parseRange(
P.parse(range, { startRule: 'VersionRange' }), P.parse(range, { startRule: 'VersionRange' }),
) )
} }
/**
* Creates a version range from a comparison operator and an {@link ExtendedVersion}.
*
* @param operator - One of `"="`, `">"`, `"<"`, `">="`, `"<="`, `"!="`, `"^"`, `"~"`
* @param version - The version to compare against
*/
static anchor(operator: P.CmpOp, version: ExtendedVersion) { static anchor(operator: P.CmpOp, version: ExtendedVersion) {
return new VersionRange({ type: 'Anchor', operator, version }) return new VersionRange({ type: 'Anchor', operator, version })
} }
/**
* Creates a version range that matches only versions with the specified flavor.
*
* @param flavor - The flavor string to match, or `null` for the default (unflavored) variant
*/
static flavor(flavor: string | null) { static flavor(flavor: string | null) {
return new VersionRange({ type: 'Flavor', flavor }) return new VersionRange({ type: 'Flavor', flavor })
} }
/**
* Parses a legacy "emver" format version range string.
*
* @param range - A version range in the legacy emver format
* @returns The parsed `VersionRange`
*/
static parseEmver(range: string): VersionRange { static parseEmver(range: string): VersionRange {
return VersionRange.parseRange( return VersionRange.parseRange(
P.parse(range, { startRule: 'EmverVersionRange' }), P.parse(range, { startRule: 'EmverVersionRange' }),
) )
} }
/** Returns the intersection of this range with another (logical AND). */
and(right: VersionRange) { and(right: VersionRange) {
return new VersionRange({ type: 'And', left: this, right }) return new VersionRange({ type: 'And', left: this, right })
} }
/** Returns the union of this range with another (logical OR). */
or(right: VersionRange) { or(right: VersionRange) {
return new VersionRange({ type: 'Or', left: this, right }) return new VersionRange({ type: 'Or', left: this, right })
} }
/** Returns the negation of this range (logical NOT). */
not() { not() {
return new VersionRange({ type: 'Not', value: this }) return new VersionRange({ type: 'Not', value: this })
} }
/**
* Returns the logical AND (intersection) of multiple version ranges.
* Short-circuits on `none()` and skips `any()`.
*/
static and(...xs: Array<VersionRange>) { static and(...xs: Array<VersionRange>) {
let y = VersionRange.any() let y = VersionRange.any()
for (let x of xs) { for (let x of xs) {
@@ -613,6 +698,10 @@ export class VersionRange {
return y return y
} }
/**
* Returns the logical OR (union) of multiple version ranges.
* Short-circuits on `any()` and skips `none()`.
*/
static or(...xs: Array<VersionRange>) { static or(...xs: Array<VersionRange>) {
let y = VersionRange.none() let y = VersionRange.none()
for (let x of xs) { for (let x of xs) {
@@ -631,14 +720,21 @@ export class VersionRange {
return y return y
} }
/** Returns a version range that matches all versions (wildcard `*`). */
static any() { static any() {
return new VersionRange({ type: 'Any' }) return new VersionRange({ type: 'Any' })
} }
/** Returns a version range that matches no versions (`!`). */
static none() { static none() {
return new VersionRange({ type: 'None' }) return new VersionRange({ type: 'None' })
} }
/**
* Returns `true` if the given version satisfies this range.
*
* @param version - A {@link Version} or {@link ExtendedVersion} to test
*/
satisfiedBy(version: Version | ExtendedVersion) { satisfiedBy(version: Version | ExtendedVersion) {
return version.satisfies(this) return version.satisfies(this)
} }
@@ -714,29 +810,60 @@ export class VersionRange {
} }
} }
/** Returns `true` if any version exists that could satisfy this range. */
satisfiable(): boolean { satisfiable(): boolean {
return VersionRangeTable.collapse(this.tables()) !== false return VersionRangeTable.collapse(this.tables()) !== false
} }
/** Returns `true` if this range and `other` share at least one satisfying version. */
intersects(other: VersionRange): boolean { intersects(other: VersionRange): boolean {
return VersionRange.and(this, other).satisfiable() return VersionRange.and(this, other).satisfiable()
} }
/**
* Returns a canonical (simplified) form of this range using minterm expansion.
* Useful for normalizing complex boolean expressions into a minimal representation.
*/
normalize(): VersionRange { normalize(): VersionRange {
return VersionRangeTable.minterms(this.tables()) return VersionRangeTable.minterms(this.tables())
} }
} }
/**
* Represents a semantic version number with numeric segments and optional prerelease identifiers.
*
* Follows semver precedence rules: numeric segments are compared left-to-right,
* and a version with prerelease identifiers has lower precedence than the same version without.
*
* @example
* ```ts
* const v = Version.parse("1.2.3")
* console.log(v.toString()) // "1.2.3"
* console.log(v.compare(Version.parse("1.3.0"))) // "less"
*
* const pre = Version.parse("2.0.0-beta.1")
* console.log(pre.compare(Version.parse("2.0.0"))) // "less" (prerelease < release)
* ```
*/
export class Version { export class Version {
constructor( constructor(
/** The numeric version segments (e.g. `[1, 2, 3]` for `"1.2.3"`). */
public number: number[], public number: number[],
/** Optional prerelease identifiers (e.g. `["beta", 1]` for `"-beta.1"`). */
public prerelease: (string | number)[], public prerelease: (string | number)[],
) {} ) {}
/** Serializes this version to its string form (e.g. `"1.2.3"` or `"1.0.0-beta.1"`). */
toString(): string { toString(): string {
return `${this.number.join('.')}${this.prerelease.length > 0 ? `-${this.prerelease.join('.')}` : ''}` return `${this.number.join('.')}${this.prerelease.length > 0 ? `-${this.prerelease.join('.')}` : ''}`
} }
/**
* Compares this version against another using semver precedence rules.
*
* @param other - The version to compare against
* @returns `'greater'`, `'equal'`, or `'less'`
*/
compare(other: Version): 'greater' | 'equal' | 'less' { compare(other: Version): 'greater' | 'equal' | 'less' {
const numLen = Math.max(this.number.length, other.number.length) const numLen = Math.max(this.number.length, other.number.length)
for (let i = 0; i < numLen; i++) { for (let i = 0; i < numLen; i++) {
@@ -783,6 +910,11 @@ export class Version {
return 'equal' return 'equal'
} }
/**
* Compares two versions, returning a numeric value suitable for use with `Array.sort()`.
*
* @returns `-1` if less, `0` if equal, `1` if greater
*/
compareForSort(other: Version): -1 | 0 | 1 { compareForSort(other: Version): -1 | 0 | 1 {
switch (this.compare(other)) { switch (this.compare(other)) {
case 'greater': case 'greater':
@@ -794,11 +926,21 @@ export class Version {
} }
} }
/**
* Parses a version string into a `Version` instance.
*
* @param version - A semver-compatible string, e.g. `"1.2.3"` or `"1.0.0-beta.1"`
* @throws If the string is not a valid version
*/
static parse(version: string): Version { static parse(version: string): Version {
const parsed = P.parse(version, { startRule: 'Version' }) const parsed = P.parse(version, { startRule: 'Version' })
return new Version(parsed.number, parsed.prerelease) return new Version(parsed.number, parsed.prerelease)
} }
/**
* Returns `true` if this version satisfies the given {@link VersionRange}.
* Internally treats this as an unflavored {@link ExtendedVersion} with downstream `0`.
*/
satisfies(versionRange: VersionRange): boolean { satisfies(versionRange: VersionRange): boolean {
return new ExtendedVersion(null, this, new Version([0], [])).satisfies( return new ExtendedVersion(null, this, new Version([0], [])).satisfies(
versionRange, versionRange,
@@ -806,18 +948,50 @@ export class Version {
} }
} }
// #flavor:0.1.2-beta.1:0 /**
* Represents an extended version with an optional flavor, an upstream version, and a downstream version.
*
* The format is `#flavor:upstream:downstream` (e.g. `#bitcoin:1.2.3:0`) or `upstream:downstream`
* for unflavored versions. Flavors allow multiple variants of a package to coexist.
*
* - **flavor**: An optional string identifier for the variant (e.g. `"bitcoin"`, `"litecoin"`)
* - **upstream**: The version of the upstream software being packaged
* - **downstream**: The version of the StartOS packaging itself
*
* Versions with different flavors are incomparable (comparison returns `null`).
*
* @example
* ```ts
* const v = ExtendedVersion.parse("#bitcoin:1.2.3:0")
* console.log(v.flavor) // "bitcoin"
* console.log(v.upstream) // Version { number: [1, 2, 3] }
* console.log(v.downstream) // Version { number: [0] }
* console.log(v.toString()) // "#bitcoin:1.2.3:0"
*
* const range = VersionRange.parse(">=1.0.0:0")
* console.log(v.satisfies(range)) // true
* ```
*/
export class ExtendedVersion { export class ExtendedVersion {
constructor( constructor(
/** The flavor identifier (e.g. `"bitcoin"`), or `null` for unflavored versions. */
public flavor: string | null, public flavor: string | null,
/** The upstream software version. */
public upstream: Version, public upstream: Version,
/** The downstream packaging version. */
public downstream: Version, public downstream: Version,
) {} ) {}
/** Serializes this extended version to its string form (e.g. `"#bitcoin:1.2.3:0"` or `"1.0.0:1"`). */
toString(): string { toString(): string {
return `${this.flavor ? `#${this.flavor}:` : ''}${this.upstream.toString()}:${this.downstream.toString()}` return `${this.flavor ? `#${this.flavor}:` : ''}${this.upstream.toString()}:${this.downstream.toString()}`
} }
/**
* Compares this extended version against another.
*
* @returns `'greater'`, `'equal'`, `'less'`, or `null` if the flavors differ (incomparable)
*/
compare(other: ExtendedVersion): 'greater' | 'equal' | 'less' | null { compare(other: ExtendedVersion): 'greater' | 'equal' | 'less' | null {
if (this.flavor !== other.flavor) { if (this.flavor !== other.flavor) {
return null return null
@@ -829,6 +1003,10 @@ export class ExtendedVersion {
return this.downstream.compare(other.downstream) return this.downstream.compare(other.downstream)
} }
/**
* Lexicographic comparison — compares flavors alphabetically first, then versions.
* Unlike {@link compare}, this never returns `null`: different flavors are ordered alphabetically.
*/
compareLexicographic(other: ExtendedVersion): 'greater' | 'equal' | 'less' { compareLexicographic(other: ExtendedVersion): 'greater' | 'equal' | 'less' {
if ((this.flavor || '') > (other.flavor || '')) { if ((this.flavor || '') > (other.flavor || '')) {
return 'greater' return 'greater'
@@ -839,6 +1017,10 @@ export class ExtendedVersion {
} }
} }
/**
* Returns a numeric comparison result suitable for use with `Array.sort()`.
* Uses lexicographic ordering (flavors sorted alphabetically, then by version).
*/
compareForSort(other: ExtendedVersion): 1 | 0 | -1 { compareForSort(other: ExtendedVersion): 1 | 0 | -1 {
switch (this.compareLexicographic(other)) { switch (this.compareLexicographic(other)) {
case 'greater': case 'greater':
@@ -850,26 +1032,37 @@ export class ExtendedVersion {
} }
} }
/** Returns `true` if this version is strictly greater than `other`. Returns `false` if flavors differ. */
greaterThan(other: ExtendedVersion): boolean { greaterThan(other: ExtendedVersion): boolean {
return this.compare(other) === 'greater' return this.compare(other) === 'greater'
} }
/** Returns `true` if this version is greater than or equal to `other`. Returns `false` if flavors differ. */
greaterThanOrEqual(other: ExtendedVersion): boolean { greaterThanOrEqual(other: ExtendedVersion): boolean {
return ['greater', 'equal'].includes(this.compare(other) as string) return ['greater', 'equal'].includes(this.compare(other) as string)
} }
/** Returns `true` if this version equals `other` (same flavor, upstream, and downstream). */
equals(other: ExtendedVersion): boolean { equals(other: ExtendedVersion): boolean {
return this.compare(other) === 'equal' return this.compare(other) === 'equal'
} }
/** Returns `true` if this version is strictly less than `other`. Returns `false` if flavors differ. */
lessThan(other: ExtendedVersion): boolean { lessThan(other: ExtendedVersion): boolean {
return this.compare(other) === 'less' return this.compare(other) === 'less'
} }
/** Returns `true` if this version is less than or equal to `other`. Returns `false` if flavors differ. */
lessThanOrEqual(other: ExtendedVersion): boolean { lessThanOrEqual(other: ExtendedVersion): boolean {
return ['less', 'equal'].includes(this.compare(other) as string) return ['less', 'equal'].includes(this.compare(other) as string)
} }
/**
* Parses an extended version string into an `ExtendedVersion`.
*
* @param extendedVersion - A string like `"1.2.3:0"` or `"#bitcoin:1.0.0:0"`
* @throws If the string is not a valid extended version
*/
static parse(extendedVersion: string): ExtendedVersion { static parse(extendedVersion: string): ExtendedVersion {
const parsed = P.parse(extendedVersion, { startRule: 'ExtendedVersion' }) const parsed = P.parse(extendedVersion, { startRule: 'ExtendedVersion' })
return new ExtendedVersion( return new ExtendedVersion(
@@ -879,6 +1072,12 @@ export class ExtendedVersion {
) )
} }
/**
* Parses a legacy "emver" format extended version string.
*
* @param extendedVersion - A version string in the legacy emver format
* @throws If the string is not a valid emver version (error message includes the input string)
*/
static parseEmver(extendedVersion: string): ExtendedVersion { static parseEmver(extendedVersion: string): ExtendedVersion {
try { try {
const parsed = P.parse(extendedVersion, { startRule: 'Emver' }) const parsed = P.parse(extendedVersion, { startRule: 'Emver' })
@@ -1014,8 +1213,29 @@ export class ExtendedVersion {
} }
} }
/**
* Compile-time type-checking helper that validates an extended version string literal.
* If the string is invalid, TypeScript will report a type error at the call site.
*
* @example
* ```ts
* testTypeExVer("1.2.3:0") // compiles
* testTypeExVer("#bitcoin:1.0:0") // compiles
* testTypeExVer("invalid") // type error
* ```
*/
export const testTypeExVer = <T extends string>(t: T & ValidateExVer<T>) => t export const testTypeExVer = <T extends string>(t: T & ValidateExVer<T>) => t
/**
* Compile-time type-checking helper that validates a version string literal.
* If the string is invalid, TypeScript will report a type error at the call site.
*
* @example
* ```ts
* testTypeVersion("1.2.3") // compiles
* testTypeVersion("-3") // type error
* ```
*/
export const testTypeVersion = <T extends string>(t: T & ValidateVersion<T>) => export const testTypeVersion = <T extends string>(t: T & ValidateVersion<T>) =>
t t

View File

@@ -2,21 +2,37 @@ import { VersionRange } from '../../../base/lib/exver'
import * as T from '../../../base/lib/types' import * as T from '../../../base/lib/types'
import { once } from '../util' import { once } from '../util'
/**
* The reason a service's init function is being called:
* - `'install'` — first-time installation
* - `'update'` — after a package update
* - `'restore'` — after restoring from backup
* - `null` — regular startup (no special lifecycle event)
*/
export type InitKind = 'install' | 'update' | 'restore' | null export type InitKind = 'install' | 'update' | 'restore' | null
/** Function signature for an init handler that runs during service startup. */
export type InitFn<Kind extends InitKind = InitKind> = ( export type InitFn<Kind extends InitKind = InitKind> = (
effects: T.Effects, effects: T.Effects,
kind: Kind, kind: Kind,
) => Promise<void | null | undefined> ) => Promise<void | null | undefined>
/** Object form of an init handler — implements an `init()` method. */
export interface InitScript<Kind extends InitKind = InitKind> { export interface InitScript<Kind extends InitKind = InitKind> {
init(effects: T.Effects, kind: Kind): Promise<void> init(effects: T.Effects, kind: Kind): Promise<void>
} }
/** Either an {@link InitScript} object or an {@link InitFn} function. */
export type InitScriptOrFn<Kind extends InitKind = InitKind> = export type InitScriptOrFn<Kind extends InitKind = InitKind> =
| InitScript<Kind> | InitScript<Kind>
| InitFn<Kind> | InitFn<Kind>
/**
* Composes multiple init handlers into a single `ExpectedExports.init`-compatible function.
* Handlers are executed sequentially in the order provided.
*
* @param inits - One or more init handlers to compose
*/
export function setupInit(...inits: InitScriptOrFn[]): T.ExpectedExports.init { export function setupInit(...inits: InitScriptOrFn[]): T.ExpectedExports.init {
return async (opts) => { return async (opts) => {
for (const idx in inits) { for (const idx in inits) {
@@ -42,6 +58,7 @@ export function setupInit(...inits: InitScriptOrFn[]): T.ExpectedExports.init {
} }
} }
/** Normalizes an {@link InitScriptOrFn} into an {@link InitScript} object. */
export function setupOnInit(onInit: InitScriptOrFn): InitScript { export function setupOnInit(onInit: InitScriptOrFn): InitScript {
return 'init' in onInit return 'init' in onInit
? onInit ? onInit

View File

@@ -1,6 +1,9 @@
import { ExtendedVersion, VersionRange } from '../../../base/lib/exver' import { ExtendedVersion, VersionRange } from '../../../base/lib/exver'
import * as T from '../../../base/lib/types' import * as T from '../../../base/lib/types'
/**
* Function signature for an uninit handler that runs during service shutdown/uninstall.
*/
export type UninitFn = ( export type UninitFn = (
effects: T.Effects, effects: T.Effects,
/** /**
@@ -13,6 +16,7 @@ export type UninitFn = (
target: VersionRange | ExtendedVersion | null, target: VersionRange | ExtendedVersion | null,
) => Promise<void | null | undefined> ) => Promise<void | null | undefined>
/** Object form of an uninit handler — implements an `uninit()` method. */
export interface UninitScript { export interface UninitScript {
uninit( uninit(
effects: T.Effects, effects: T.Effects,
@@ -27,8 +31,15 @@ export interface UninitScript {
): Promise<void> ): Promise<void>
} }
/** Either a {@link UninitScript} object or a {@link UninitFn} function. */
export type UninitScriptOrFn = UninitScript | UninitFn export type UninitScriptOrFn = UninitScript | UninitFn
/**
* Composes multiple uninit handlers into a single `ExpectedExports.uninit`-compatible function.
* Handlers are executed sequentially in the order provided.
*
* @param uninits - One or more uninit handlers to compose
*/
export function setupUninit( export function setupUninit(
...uninits: UninitScriptOrFn[] ...uninits: UninitScriptOrFn[]
): T.ExpectedExports.uninit { ): T.ExpectedExports.uninit {
@@ -40,6 +51,7 @@ export function setupUninit(
} }
} }
/** Normalizes a {@link UninitScriptOrFn} into a {@link UninitScript} object. */
export function setupOnUninit(onUninit: UninitScriptOrFn): UninitScript { export function setupOnUninit(onUninit: UninitScriptOrFn): UninitScript {
return 'uninit' in onUninit return 'uninit' in onUninit
? onUninit ? onUninit

View File

@@ -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 SmtpSecurity = 'starttls' | 'tls'

View File

@@ -1,9 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { SmtpSecurity } from './SmtpSecurity'
export type SmtpValue = { export type SmtpValue = {
server: string host: string
port: number port: number
from: string from: string
login: string username: string
password: string | null password: string | null
security: SmtpSecurity
} }

View File

@@ -1,10 +1,12 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { SmtpSecurity } from './SmtpSecurity'
export type TestSmtpParams = { export type TestSmtpParams = {
server: string host: string
port: number port: number
from: string from: string
to: string to: string
login: string username: string
password: string password: string
security: SmtpSecurity
} }

View File

@@ -270,6 +270,7 @@ export { SideloadResponse } from './SideloadResponse'
export { SignalStrength } from './SignalStrength' export { SignalStrength } from './SignalStrength'
export { SignAssetParams } from './SignAssetParams' export { SignAssetParams } from './SignAssetParams'
export { SignerInfo } from './SignerInfo' export { SignerInfo } from './SignerInfo'
export { SmtpSecurity } from './SmtpSecurity'
export { SmtpValue } from './SmtpValue' export { SmtpValue } from './SmtpValue'
export { SshAddParams } from './SshAddParams' export { SshAddParams } from './SshAddParams'
export { SshDeleteParams } from './SshDeleteParams' export { SshDeleteParams } from './SshDeleteParams'

View File

@@ -12,6 +12,11 @@ import { FileContents } from './merkleArchive/fileContents'
const magicAndVersion = new Uint8Array([59, 59, 2]) const magicAndVersion = new Uint8Array([59, 59, 2])
/**
* Compares two `Uint8Array` instances byte-by-byte for equality.
*
* @returns `true` if both arrays have the same length and identical bytes
*/
export function compare(a: Uint8Array, b: Uint8Array) { export function compare(a: Uint8Array, b: Uint8Array) {
if (a.length !== b.length) return false if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) { for (let i = 0; i < a.length; i++) {
@@ -20,12 +25,41 @@ export function compare(a: Uint8Array, b: Uint8Array) {
return true return true
} }
/**
* Represents a parsed `.s9pk` package archive — the binary distribution format for StartOS services.
*
* An `S9pk` wraps a verified {@link Manifest}, a {@link MerkleArchive} containing the package's
* assets (icon, license, dependency metadata), and the total archive size in bytes.
*
* @example
* ```ts
* const s9pk = await S9pk.deserialize(file, null)
* console.log(s9pk.manifest.id) // e.g. "bitcoind"
* console.log(s9pk.size) // archive size in bytes
* const icon = await s9pk.icon() // base64 data URL
* const license = await s9pk.license()
* ```
*/
export class S9pk { export class S9pk {
private constructor( private constructor(
/** The parsed package manifest containing metadata, dependencies, and interface definitions. */
readonly manifest: Manifest, readonly manifest: Manifest,
/** The Merkle-verified archive containing the package's files. */
readonly archive: MerkleArchive, readonly archive: MerkleArchive,
/** The total size of the archive in bytes. */
readonly size: number, readonly size: number,
) {} ) {}
/**
* Deserializes an `S9pk` from a `Blob` (e.g. a `File` from a browser file input).
*
* Validates the magic bytes and version header, then parses the Merkle archive structure.
* If a `commitment` is provided, the archive is cryptographically verified against it.
*
* @param source - The raw `.s9pk` file as a `Blob`
* @param commitment - An optional Merkle commitment to verify the archive against, or `null` to skip verification
* @returns A fully parsed `S9pk` instance
* @throws If the magic bytes are invalid or the archive fails verification
*/
static async deserialize( static async deserialize(
source: Blob, source: Blob,
commitment: MerkleArchiveCommitment | null, commitment: MerkleArchiveCommitment | null,
@@ -57,6 +91,14 @@ export class S9pk {
return new S9pk(manifest, archive, source.size) return new S9pk(manifest, archive, source.size)
} }
/**
* Extracts the package icon from the archive and returns it as a base64-encoded data URL.
*
* Looks for a file named `icon.*` with an image MIME type (e.g. `icon.png`, `icon.svg`).
*
* @returns A data URL string like `"data:image/png;base64,..."` suitable for use in `<img src>`.
* @throws If no icon file is found in the archive
*/
async icon(): Promise<DataUrl> { async icon(): Promise<DataUrl> {
const iconName = Object.keys(this.archive.contents.contents).find( const iconName = Object.keys(this.archive.contents.contents).find(
(name) => (name) =>
@@ -73,6 +115,12 @@ export class S9pk {
) )
} }
/**
* Returns the metadata (e.g. `{ title }`) for a specific dependency by its package ID.
*
* @param id - The dependency's package identifier (e.g. `"bitcoind"`)
* @returns The dependency metadata object, or `null` if the dependency is not present in the archive
*/
async dependencyMetadataFor(id: PackageId) { async dependencyMetadataFor(id: PackageId) {
const entry = this.archive.contents.getPath([ const entry = this.archive.contents.getPath([
'dependencies', 'dependencies',
@@ -85,6 +133,12 @@ export class S9pk {
) as { title: string } ) as { title: string }
} }
/**
* Returns the icon for a specific dependency as a base64 data URL.
*
* @param id - The dependency's package identifier
* @returns A data URL string, or `null` if the dependency or its icon is not present
*/
async dependencyIconFor(id: PackageId) { async dependencyIconFor(id: PackageId) {
const dir = this.archive.contents.getPath(['dependencies', id]) const dir = this.archive.contents.getPath(['dependencies', id])
if (!dir || !(dir.contents instanceof DirectoryContents)) return null if (!dir || !(dir.contents instanceof DirectoryContents)) return null
@@ -101,6 +155,12 @@ export class S9pk {
) )
} }
/**
* Returns a merged record of all dependency metadata (title, icon, description, optional flag)
* for every dependency declared in the manifest.
*
* @returns A record keyed by package ID, each containing `{ title, icon, description, optional }`
*/
async dependencyMetadata() { async dependencyMetadata() {
return Object.fromEntries( return Object.fromEntries(
await Promise.all( await Promise.all(
@@ -119,6 +179,12 @@ export class S9pk {
) )
} }
/**
* Reads and returns the `LICENSE.md` file from the archive as a UTF-8 string.
*
* @returns The full license text
* @throws If `LICENSE.md` is not found in the archive
*/
async license(): Promise<string> { async license(): Promise<string> {
const file = this.archive.contents.getPath(['LICENSE.md']) const file = this.archive.contents.getPath(['LICENSE.md'])
if (!file || !(file.contents instanceof FileContents)) if (!file || !(file.contents instanceof FileContents))

View File

@@ -20,20 +20,32 @@ export {
CurrentDependenciesResult, CurrentDependenciesResult,
} from './dependencies/setupDependencies' } from './dependencies/setupDependencies'
/** An object that can be built into a terminable daemon process. */
export type DaemonBuildable = { export type DaemonBuildable = {
build(): Promise<{ build(): Promise<{
term(): Promise<void> term(): Promise<void>
}> }>
} }
/** The three categories of service network interfaces. */
export type ServiceInterfaceType = 'ui' | 'p2p' | 'api' export type ServiceInterfaceType = 'ui' | 'p2p' | 'api'
/** A Node.js signal name (e.g. `"SIGTERM"`, `"SIGKILL"`). */
export type Signals = NodeJS.Signals export type Signals = NodeJS.Signals
/** The SIGTERM signal — used for graceful daemon termination. */
export const SIGTERM: Signals = 'SIGTERM' export const SIGTERM: Signals = 'SIGTERM'
/** The SIGKILL signal — used for forceful daemon termination. */
export const SIGKILL: Signals = 'SIGKILL' export const SIGKILL: Signals = 'SIGKILL'
/** Sentinel value (`-1`) indicating that no timeout should be applied. */
export const NO_TIMEOUT = -1 export const NO_TIMEOUT = -1
/** A function that builds an absolute file path from a volume name and relative path. */
export type PathMaker = (options: { volume: string; path: string }) => string export type PathMaker = (options: { volume: string; path: string }) => string
/** A value that may or may not be wrapped in a `Promise`. */
export type MaybePromise<A> = Promise<A> | A export type MaybePromise<A> = Promise<A> | A
/**
* Namespace defining the required exports for a StartOS service package.
* Every package must export implementations matching these types.
*/
export namespace ExpectedExports { export namespace ExpectedExports {
version: 1 version: 1
@@ -62,10 +74,16 @@ export namespace ExpectedExports {
target: ExtendedVersion | VersionRange | null target: ExtendedVersion | VersionRange | null
}) => Promise<unknown> }) => Promise<unknown>
/** The package manifest describing the service's metadata, dependencies, and interfaces. */
export type manifest = Manifest export type manifest = Manifest
/** The map of user-invocable actions defined by this service. */
export type actions = Actions<Record<ActionId, Action<ActionId, any>>> export type actions = Actions<Record<ActionId, Action<ActionId, any>>>
} }
/**
* The complete ABI (Application Binary Interface) for a StartOS service package.
* Maps all required exports to their expected types.
*/
export type ABI = { export type ABI = {
createBackup: ExpectedExports.createBackup createBackup: ExpectedExports.createBackup
main: ExpectedExports.main main: ExpectedExports.main
@@ -74,53 +92,78 @@ export type ABI = {
manifest: ExpectedExports.manifest manifest: ExpectedExports.manifest
actions: ExpectedExports.actions actions: ExpectedExports.actions
} }
/** A time value in milliseconds. */
export type TimeMs = number export type TimeMs = number
/** A version string in string form. */
export type VersionString = string export type VersionString = string
declare const DaemonProof: unique symbol declare const DaemonProof: unique symbol
/** Opaque branded type proving that a daemon was started. Cannot be constructed directly. */
export type DaemonReceipt = { export type DaemonReceipt = {
[DaemonProof]: never [DaemonProof]: never
} }
/** A running daemon with methods to wait for completion or terminate it. */
export type Daemon = { export type Daemon = {
/** Waits for the daemon to exit and returns its exit message. */
wait(): Promise<string> wait(): Promise<string>
/** Terminates the daemon. */
term(): Promise<null> term(): Promise<null>
[DaemonProof]: never [DaemonProof]: never
} }
/** The result status of a health check (extracted from `NamedHealthCheckResult`). */
export type HealthStatus = NamedHealthCheckResult['result'] export type HealthStatus = NamedHealthCheckResult['result']
/** SMTP mail server configuration values. */
export type SmtpValue = { export type SmtpValue = {
server: string host: string
port: number port: number
from: string from: string
login: string username: string
password: string | null | undefined password: string | null | undefined
security: 'starttls' | 'tls'
} }
/**
* Marker class indicating that a container should use its own built-in entrypoint
* rather than a custom command. Optionally accepts an override command array.
*/
export class UseEntrypoint { export class UseEntrypoint {
readonly USE_ENTRYPOINT = 'USE_ENTRYPOINT' readonly USE_ENTRYPOINT = 'USE_ENTRYPOINT'
constructor(readonly overridCmd?: string[]) {} constructor(readonly overridCmd?: string[]) {}
} }
/** Type guard that checks if a {@link CommandType} is a {@link UseEntrypoint} instance. */
export function isUseEntrypoint( export function isUseEntrypoint(
command: CommandType, command: CommandType,
): command is UseEntrypoint { ): command is UseEntrypoint {
return typeof command === 'object' && 'USE_ENTRYPOINT' in command return typeof command === 'object' && 'USE_ENTRYPOINT' in command
} }
/**
* The ways to specify a command to run in a container:
* - A shell string (run via `sh -c`)
* - 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[]] | UseEntrypoint
/** The return type from starting a daemon — provides `wait()` and `term()` controls. */
export type DaemonReturned = { export type DaemonReturned = {
/** Waits for the daemon process to exit. */
wait(): Promise<unknown> wait(): Promise<unknown>
/** Sends a signal to terminate the daemon. If it doesn't exit within `timeout` ms, sends SIGKILL. */
term(options?: { signal?: Signals; timeout?: number }): Promise<null> term(options?: { signal?: Signals; timeout?: number }): Promise<null>
} }
export declare const hostName: unique symbol export declare const hostName: unique symbol
// asdflkjadsf.onion | 1.2.3.4 /** A branded string type for hostnames (e.g. `.onion` addresses or IP addresses). */
export type Hostname = string & { [hostName]: never } export type Hostname = string & { [hostName]: never }
/** A string identifier for a service network interface. */
export type ServiceInterfaceId = string export type ServiceInterfaceId = string
export { ServiceInterface } export { ServiceInterface }
/** Maps effect method names to their kebab-case RPC equivalents. */
export type EffectMethod<T extends StringObject = Effects> = { export type EffectMethod<T extends StringObject = Effects> = {
[K in keyof T]-?: K extends string [K in keyof T]-?: K extends string
? T[K] extends Function ? T[K] extends Function
@@ -131,6 +174,7 @@ export type EffectMethod<T extends StringObject = Effects> = {
: never : never
}[keyof T] }[keyof T]
/** Options for rsync-based file synchronization (used in backup/restore). */
export type SyncOptions = { export type SyncOptions = {
/** delete files that exist in the target directory, but not in the source directory */ /** delete files that exist in the target directory, but not in the source directory */
delete: boolean delete: boolean
@@ -156,49 +200,68 @@ export type Metadata = {
mode: number mode: number
} }
/** Result type for setting a service's dependency configuration and restart signal. */
export type SetResult = { export type SetResult = {
dependsOn: DependsOn dependsOn: DependsOn
signal: Signals signal: Signals
} }
/** A string identifier for a StartOS package (e.g. `"bitcoind"`). */
export type PackageId = string export type PackageId = string
/** A user-facing message string. */
export type Message = string export type Message = string
/** Whether a dependency needs to be actively running or merely installed. */
export type DependencyKind = 'running' | 'exists' export type DependencyKind = 'running' | 'exists'
/**
* Maps package IDs to the health check IDs that must pass before this service considers
* the dependency satisfied.
*/
export type DependsOn = { export type DependsOn = {
[packageId: string]: string[] | readonly string[] [packageId: string]: string[] | readonly string[]
} }
/**
* A typed error that can be displayed to the user.
* Either a plain error message string, or a structured error code with description.
*/
export type KnownError = export type KnownError =
| { error: string } | { error: string }
| { | {
errorCode: [number, string] | readonly [number, string] errorCode: [number, string] | readonly [number, string]
} }
/** An array of dependency requirements for a service. */
export type Dependencies = Array<DependencyRequirement> export type Dependencies = Array<DependencyRequirement>
/** Recursively makes all properties of `T` optional. */
export type DeepPartial<T> = T extends [infer A, ...infer Rest] export type DeepPartial<T> = T extends [infer A, ...infer Rest]
? [DeepPartial<A>, ...DeepPartial<Rest>] ? [DeepPartial<A>, ...DeepPartial<Rest>]
: T extends {} : T extends {}
? { [P in keyof T]?: DeepPartial<T[P]> } ? { [P in keyof T]?: DeepPartial<T[P]> }
: T : T
/** Recursively removes all `readonly` modifiers from `T`. */
export type DeepWritable<T> = { export type DeepWritable<T> = {
-readonly [K in keyof T]: T[K] -readonly [K in keyof T]: T[K]
} }
/** Casts a value to {@link DeepWritable} (identity at runtime, removes `readonly` at the type level). */
export function writable<T>(value: T): DeepWritable<T> { export function writable<T>(value: T): DeepWritable<T> {
return value return value
} }
/** Recursively makes all properties of `T` readonly. */
export type DeepReadonly<T> = { export type DeepReadonly<T> = {
readonly [P in keyof T]: DeepReadonly<T[P]> readonly [P in keyof T]: DeepReadonly<T[P]>
} }
/** Casts a value to {@link DeepReadonly} (identity at runtime, adds `readonly` at the type level). */
export function readonly<T>(value: T): DeepReadonly<T> { export function readonly<T>(value: T): DeepReadonly<T> {
return value return value
} }
/** Accepts either a mutable or deeply-readonly version of `T`. */
export type AllowReadonly<T> = export type AllowReadonly<T> =
| T | T
| { | {

View File

@@ -1,3 +1,11 @@
/**
* Converts an unknown thrown value into an Error instance.
* If `e` is already an Error, wraps it; if a string, uses it as the message;
* otherwise JSON-serializes it as the error message.
*
* @param e - The unknown value to convert
* @returns An Error instance
*/
export const asError = (e: unknown) => { export const asError = (e: unknown) => {
if (e instanceof Error) { if (e instanceof Error) {
return new Error(e as any) return new Error(e as any)

View File

@@ -1,3 +1,18 @@
/**
* Performs a deep structural equality check across all provided arguments.
* Returns true only if every argument is deeply equal to every other argument.
* Handles primitives, arrays, and plain objects recursively.
*
* @param args - Two or more values to compare for deep equality
* @returns True if all arguments are deeply equal
*
* @example
* ```ts
* deepEqual({ a: 1 }, { a: 1 }) // true
* deepEqual([1, 2], [1, 2], [1, 2]) // true
* deepEqual({ a: 1 }, { a: 2 }) // false
* ```
*/
export function deepEqual(...args: unknown[]) { export function deepEqual(...args: unknown[]) {
const objects = args.filter( const objects = args.filter(
(x): x is object => typeof x === 'object' && x !== null, (x): x is object => typeof x === 'object' && x !== null,

View File

@@ -1,3 +1,13 @@
/**
* Computes the partial difference between two values.
* Returns `undefined` if the values are equal, or `{ diff }` containing only the changed parts.
* For arrays, the diff contains only items in `next` that have no deep-equal counterpart in `prev`.
* For objects, the diff contains only keys whose values changed.
*
* @param prev - The original value
* @param next - The updated value
* @returns An object containing the diff, or `undefined` if the values are equal
*/
export function partialDiff<T>( export function partialDiff<T>(
prev: T, prev: T,
next: T, next: T,
@@ -46,6 +56,14 @@ export function partialDiff<T>(
} }
} }
/**
* Deeply merges multiple values together. Objects are merged key-by-key recursively.
* Arrays are merged by appending items that are not already present (by deep equality).
* Primitives are resolved by taking the last argument.
*
* @param args - The values to merge, applied left to right
* @returns The merged result
*/
export function deepMerge(...args: unknown[]): unknown { export function deepMerge(...args: unknown[]): unknown {
const lastItem = (args as any)[args.length - 1] const lastItem = (args as any)[args.length - 1]
if (typeof lastItem !== 'object' || !lastItem) return lastItem if (typeof lastItem !== 'object' || !lastItem) return lastItem

View File

@@ -1,6 +1,14 @@
import { DefaultString } from '../actions/input/inputSpecTypes' import { DefaultString } from '../actions/input/inputSpecTypes'
import { getRandomString } from './getRandomString' import { getRandomString } from './getRandomString'
/**
* Resolves a DefaultString spec into a concrete string value.
* If the spec is a plain string, returns it directly.
* If it is a random-string specification, generates a random string accordingly.
*
* @param defaultSpec - A string literal or a random-string generation spec
* @returns The resolved default string value
*/
export function getDefaultString(defaultSpec: DefaultString): string { export function getDefaultString(defaultSpec: DefaultString): string {
if (typeof defaultSpec === 'string') { if (typeof defaultSpec === 'string') {
return defaultSpec return defaultSpec

View File

@@ -1,19 +1,41 @@
import { ExtendedVersion } from '../exver' import { ExtendedVersion } from '../exver'
/**
* A vertex (node) in a directed graph, holding metadata and a list of connected edges.
* @typeParam VMetadata - The type of metadata stored on vertices
* @typeParam EMetadata - The type of metadata stored on edges
*/
export type Vertex<VMetadata = null, EMetadata = null> = { export type Vertex<VMetadata = null, EMetadata = null> = {
metadata: VMetadata metadata: VMetadata
edges: Array<Edge<EMetadata, VMetadata>> edges: Array<Edge<EMetadata, VMetadata>>
} }
/**
* A directed edge connecting two vertices, with its own metadata.
* @typeParam EMetadata - The type of metadata stored on edges
* @typeParam VMetadata - The type of metadata stored on the connected vertices
*/
export type Edge<EMetadata = null, VMetadata = null> = { export type Edge<EMetadata = null, VMetadata = null> = {
metadata: EMetadata metadata: EMetadata
from: Vertex<VMetadata, EMetadata> from: Vertex<VMetadata, EMetadata>
to: Vertex<VMetadata, EMetadata> to: Vertex<VMetadata, EMetadata>
} }
/**
* A directed graph data structure supporting vertex/edge management and graph traversal algorithms
* including breadth-first search, reverse BFS, and shortest path computation.
*
* @typeParam VMetadata - The type of metadata stored on vertices
* @typeParam EMetadata - The type of metadata stored on edges
*/
export class Graph<VMetadata = null, EMetadata = null> { export class Graph<VMetadata = null, EMetadata = null> {
private readonly vertices: Array<Vertex<VMetadata, EMetadata>> = [] private readonly vertices: Array<Vertex<VMetadata, EMetadata>> = []
constructor() {} constructor() {}
/**
* Serializes the graph to a JSON string for debugging.
* @param metadataRepr - Optional function to transform metadata values before serialization
* @returns A pretty-printed JSON string of the graph structure
*/
dump( dump(
metadataRepr: (metadata: VMetadata | EMetadata) => any = (a) => a, metadataRepr: (metadata: VMetadata | EMetadata) => any = (a) => a,
): string { ): string {
@@ -30,6 +52,13 @@ export class Graph<VMetadata = null, EMetadata = null> {
2, 2,
) )
} }
/**
* Adds a new vertex to the graph, optionally connecting it to existing vertices via edges.
* @param metadata - The metadata to attach to the new vertex
* @param fromEdges - Edges pointing from existing vertices to this new vertex
* @param toEdges - Edges pointing from this new vertex to existing vertices
* @returns The newly created vertex
*/
addVertex( addVertex(
metadata: VMetadata, metadata: VMetadata,
fromEdges: Array<Omit<Edge<EMetadata, VMetadata>, 'to'>>, fromEdges: Array<Omit<Edge<EMetadata, VMetadata>, 'to'>>,
@@ -60,6 +89,11 @@ export class Graph<VMetadata = null, EMetadata = null> {
this.vertices.push(vertex) this.vertices.push(vertex)
return vertex return vertex
} }
/**
* Returns a generator that yields all vertices matching the predicate.
* @param predicate - A function to test each vertex
* @returns A generator of matching vertices
*/
findVertex( findVertex(
predicate: (vertex: Vertex<VMetadata, EMetadata>) => boolean, predicate: (vertex: Vertex<VMetadata, EMetadata>) => boolean,
): Generator<Vertex<VMetadata, EMetadata>, null> { ): Generator<Vertex<VMetadata, EMetadata>, null> {
@@ -74,6 +108,13 @@ export class Graph<VMetadata = null, EMetadata = null> {
} }
return gen() return gen()
} }
/**
* Adds a directed edge between two existing vertices.
* @param metadata - The metadata to attach to the edge
* @param from - The source vertex
* @param to - The destination vertex
* @returns The newly created edge
*/
addEdge( addEdge(
metadata: EMetadata, metadata: EMetadata,
from: Vertex<VMetadata, EMetadata>, from: Vertex<VMetadata, EMetadata>,
@@ -88,6 +129,11 @@ export class Graph<VMetadata = null, EMetadata = null> {
edge.to.edges.push(edge) edge.to.edges.push(edge)
return edge return edge
} }
/**
* Performs a breadth-first traversal following outgoing edges from the starting vertex or vertices.
* @param from - A starting vertex, or a predicate to select multiple starting vertices
* @returns A generator yielding vertices in BFS order
*/
breadthFirstSearch( breadthFirstSearch(
from: from:
| Vertex<VMetadata, EMetadata> | Vertex<VMetadata, EMetadata>
@@ -139,6 +185,11 @@ export class Graph<VMetadata = null, EMetadata = null> {
return rec(from) return rec(from)
} }
} }
/**
* Performs a reverse breadth-first traversal following incoming edges from the starting vertex or vertices.
* @param to - A starting vertex, or a predicate to select multiple starting vertices
* @returns A generator yielding vertices in reverse BFS order
*/
reverseBreadthFirstSearch( reverseBreadthFirstSearch(
to: to:
| Vertex<VMetadata, EMetadata> | Vertex<VMetadata, EMetadata>
@@ -190,6 +241,12 @@ export class Graph<VMetadata = null, EMetadata = null> {
return rec(to) return rec(to)
} }
} }
/**
* Finds the shortest path (by edge count) between two vertices using BFS.
* @param from - The starting vertex, or a predicate to select starting vertices
* @param to - The target vertex, or a predicate to identify target vertices
* @returns An array of edges forming the shortest path, or `null` if no path exists
*/
shortestPath( shortestPath(
from: from:
| Vertex<VMetadata, EMetadata> | Vertex<VMetadata, EMetadata>

View File

@@ -15,6 +15,21 @@ const digitsMs = (digits: string | null, multiplier: number) => {
const divideBy = multiplier / Math.pow(10, digits.length - 1) const divideBy = multiplier / Math.pow(10, digits.length - 1)
return Math.round(value * divideBy) return Math.round(value * divideBy)
} }
/**
* Converts a human-readable time string to milliseconds.
* Supports units: `ms`, `s`, `m`, `h`, `d`. If a number is passed, it is returned as-is.
*
* @param time - A time string (e.g. `"500ms"`, `"1.5s"`, `"2h"`) or a numeric millisecond value
* @returns The time in milliseconds, or `undefined` if `time` is falsy
* @throws Error if the string format is invalid
*
* @example
* ```ts
* inMs("2s") // 2000
* inMs("1.5h") // 5400000
* inMs(500) // 500
* ```
*/
export const inMs = (time?: string | number) => { export const inMs = (time?: string | number) => {
if (typeof time === 'number') return time if (typeof time === 'number') return time
if (!time) return undefined if (!time) return undefined

View File

@@ -1,3 +1,14 @@
/**
* Represents an IPv4 or IPv6 address as raw octets with arithmetic and comparison operations.
*
* IPv4 addresses have 4 octets, IPv6 addresses have 16 octets.
*
* @example
* ```ts
* const ip = IpAddress.parse("192.168.1.1")
* const next = ip.add(1) // 192.168.1.2
* ```
*/
export class IpAddress { export class IpAddress {
private renderedOctets: number[] private renderedOctets: number[]
protected constructor( protected constructor(
@@ -6,6 +17,13 @@ export class IpAddress {
) { ) {
this.renderedOctets = [...octets] this.renderedOctets = [...octets]
} }
/**
* Parses an IP address string into an IpAddress instance.
* Supports both IPv4 dotted-decimal and IPv6 colon-hex notation (including `::` shorthand).
* @param address - The IP address string to parse
* @returns A new IpAddress instance
* @throws Error if the address format is invalid
*/
static parse(address: string): IpAddress { static parse(address: string): IpAddress {
let octets let octets
if (address.includes(':')) { if (address.includes(':')) {
@@ -39,6 +57,12 @@ export class IpAddress {
} }
return new IpAddress(octets, address) return new IpAddress(octets, address)
} }
/**
* Creates an IpAddress from a raw octet array.
* @param octets - Array of 4 octets (IPv4) or 16 octets (IPv6), each 0-255
* @returns A new IpAddress instance
* @throws Error if the octet array length is not 4 or 16, or any octet exceeds 255
*/
static fromOctets(octets: number[]) { static fromOctets(octets: number[]) {
if (octets.length == 4) { if (octets.length == 4) {
if (octets.some((o) => o > 255)) { if (octets.some((o) => o > 255)) {
@@ -66,15 +90,24 @@ export class IpAddress {
throw new Error('invalid ip address') throw new Error('invalid ip address')
} }
} }
/** Returns true if this is an IPv4 address (4 octets). */
isIpv4(): boolean { isIpv4(): boolean {
return this.octets.length === 4 return this.octets.length === 4
} }
/** Returns true if this is an IPv6 address (16 octets). */
isIpv6(): boolean { isIpv6(): boolean {
return this.octets.length === 16 return this.octets.length === 16
} }
/** Returns true if this is a public IPv4 address (not in any private range). */
isPublic(): boolean { isPublic(): boolean {
return this.isIpv4() && !PRIVATE_IPV4_RANGES.some((r) => r.contains(this)) return this.isIpv4() && !PRIVATE_IPV4_RANGES.some((r) => r.contains(this))
} }
/**
* Returns a new IpAddress incremented by `n`.
* @param n - The integer amount to add (fractional part is truncated)
* @returns A new IpAddress with the result
* @throws Error on overflow
*/
add(n: number): IpAddress { add(n: number): IpAddress {
let octets = [...this.octets] let octets = [...this.octets]
n = Math.floor(n) n = Math.floor(n)
@@ -92,6 +125,12 @@ export class IpAddress {
} }
return IpAddress.fromOctets(octets) return IpAddress.fromOctets(octets)
} }
/**
* Returns a new IpAddress decremented by `n`.
* @param n - The integer amount to subtract (fractional part is truncated)
* @returns A new IpAddress with the result
* @throws Error on underflow
*/
sub(n: number): IpAddress { sub(n: number): IpAddress {
let octets = [...this.octets] let octets = [...this.octets]
n = Math.floor(n) n = Math.floor(n)
@@ -109,6 +148,11 @@ export class IpAddress {
} }
return IpAddress.fromOctets(octets) return IpAddress.fromOctets(octets)
} }
/**
* Compares this address to another, returning -1, 0, or 1.
* @param other - An IpAddress instance or string to compare against
* @returns -1 if this < other, 0 if equal, 1 if this > other
*/
cmp(other: string | IpAddress): -1 | 0 | 1 { cmp(other: string | IpAddress): -1 | 0 | 1 {
if (typeof other === 'string') other = IpAddress.parse(other) if (typeof other === 'string') other = IpAddress.parse(other)
const len = Math.max(this.octets.length, other.octets.length) const len = Math.max(this.octets.length, other.octets.length)
@@ -123,6 +167,7 @@ export class IpAddress {
} }
return 0 return 0
} }
/** The string representation of this IP address (e.g. `"192.168.1.1"` or `"::1"`). Cached and recomputed only when octets change. */
get address(): string { get address(): string {
if ( if (
this.renderedOctets.length === this.octets.length && this.renderedOctets.length === this.octets.length &&
@@ -160,6 +205,17 @@ export class IpAddress {
} }
} }
/**
* Represents an IP network (CIDR notation) combining an IP address with a prefix length.
* Extends IpAddress with network-specific operations like containment checks and broadcast calculation.
*
* @example
* ```ts
* const net = IpNet.parse("192.168.1.0/24")
* net.contains("192.168.1.100") // true
* net.broadcast() // 192.168.1.255
* ```
*/
export class IpNet extends IpAddress { export class IpNet extends IpAddress {
private constructor( private constructor(
octets: number[], octets: number[],
@@ -168,18 +224,35 @@ export class IpNet extends IpAddress {
) { ) {
super(octets, address) super(octets, address)
} }
/**
* Creates an IpNet from an IpAddress and prefix length.
* @param ip - The base IP address
* @param prefix - The CIDR prefix length (0-32 for IPv4, 0-128 for IPv6)
* @returns A new IpNet instance
* @throws Error if prefix exceeds the address bit length
*/
static fromIpPrefix(ip: IpAddress, prefix: number): IpNet { static fromIpPrefix(ip: IpAddress, prefix: number): IpNet {
if (prefix > ip.octets.length * 8) { if (prefix > ip.octets.length * 8) {
throw new Error('invalid prefix') throw new Error('invalid prefix')
} }
return new IpNet(ip.octets, prefix, ip.address) return new IpNet(ip.octets, prefix, ip.address)
} }
/**
* Parses a CIDR notation string (e.g. `"192.168.1.0/24"`) into an IpNet.
* @param ipnet - The CIDR string to parse
* @returns A new IpNet instance
*/
static parse(ipnet: string): IpNet { static parse(ipnet: string): IpNet {
const [address, prefixStr] = ipnet.split('/', 2) const [address, prefixStr] = ipnet.split('/', 2)
const ip = IpAddress.parse(address) const ip = IpAddress.parse(address)
const prefix = Number(prefixStr) const prefix = Number(prefixStr)
return IpNet.fromIpPrefix(ip, prefix) return IpNet.fromIpPrefix(ip, prefix)
} }
/**
* Checks whether this network contains the given address or subnet.
* @param address - An IP address or subnet (string, IpAddress, or IpNet)
* @returns True if the address falls within this network's range
*/
contains(address: string | IpAddress | IpNet): boolean { contains(address: string | IpAddress | IpNet): boolean {
if (typeof address === 'string') address = IpAddress.parse(address) if (typeof address === 'string') address = IpAddress.parse(address)
if (address instanceof IpNet && address.prefix < this.prefix) return false if (address instanceof IpNet && address.prefix < this.prefix) return false
@@ -197,6 +270,7 @@ export class IpNet extends IpAddress {
const mask = 255 ^ (255 >> prefix) const mask = 255 ^ (255 >> prefix)
return (this.octets[idx] & mask) === (address.octets[idx] & mask) return (this.octets[idx] & mask) === (address.octets[idx] & mask)
} }
/** Returns the network address (all host bits zeroed) for this subnet. */
zero(): IpAddress { zero(): IpAddress {
let octets: number[] = [] let octets: number[] = []
let prefix = this.prefix let prefix = this.prefix
@@ -213,6 +287,7 @@ export class IpNet extends IpAddress {
return IpAddress.fromOctets(octets) return IpAddress.fromOctets(octets)
} }
/** Returns the broadcast address (all host bits set to 1) for this subnet. */
broadcast(): IpAddress { broadcast(): IpAddress {
let octets: number[] = [] let octets: number[] = []
let prefix = this.prefix let prefix = this.prefix
@@ -229,11 +304,13 @@ export class IpNet extends IpAddress {
return IpAddress.fromOctets(octets) return IpAddress.fromOctets(octets)
} }
/** The CIDR notation string for this network (e.g. `"192.168.1.0/24"`). */
get ipnet() { get ipnet() {
return `${this.address}/${this.prefix}` return `${this.address}/${this.prefix}`
} }
} }
/** All private IPv4 ranges: loopback (127.0.0.0/8), Class A (10.0.0.0/8), Class B (172.16.0.0/12), Class C (192.168.0.0/16). */
export const PRIVATE_IPV4_RANGES = [ export const PRIVATE_IPV4_RANGES = [
IpNet.parse('127.0.0.0/8'), IpNet.parse('127.0.0.0/8'),
IpNet.parse('10.0.0.0/8'), IpNet.parse('10.0.0.0/8'),
@@ -241,8 +318,12 @@ export const PRIVATE_IPV4_RANGES = [
IpNet.parse('192.168.0.0/16'), IpNet.parse('192.168.0.0/16'),
] ]
/** IPv4 loopback network (127.0.0.0/8). */
export const IPV4_LOOPBACK = IpNet.parse('127.0.0.0/8') export const IPV4_LOOPBACK = IpNet.parse('127.0.0.0/8')
/** IPv6 loopback address (::1/128). */
export const IPV6_LOOPBACK = IpNet.parse('::1/128') export const IPV6_LOOPBACK = IpNet.parse('::1/128')
/** IPv6 link-local network (fe80::/10). */
export const IPV6_LINK_LOCAL = IpNet.parse('fe80::/10') export const IPV6_LINK_LOCAL = IpNet.parse('fe80::/10')
/** Carrier-Grade NAT (CGNAT) address range (100.64.0.0/10), per RFC 6598. */
export const CGNAT = IpNet.parse('100.64.0.0/10') export const CGNAT = IpNet.parse('100.64.0.0/10')

View File

@@ -1,3 +1,16 @@
/**
* Wraps a function so it is only executed once. Subsequent calls return the cached result.
*
* @param fn - The function to execute at most once
* @returns A wrapper that lazily evaluates `fn` on first call and caches the result
*
* @example
* ```ts
* const getConfig = once(() => loadExpensiveConfig())
* getConfig() // loads config
* getConfig() // returns cached result
* ```
*/
export function once<B>(fn: () => B): () => B { export function once<B>(fn: () => B): () => B {
let result: [B] | [] = [] let result: [B] | [] = []
return () => { return () => {

View File

@@ -1,57 +1,68 @@
import { Pattern } from '../actions/input/inputSpecTypes' import { Pattern } from '../actions/input/inputSpecTypes'
import * as regexes from './regexes' import * as regexes from './regexes'
/** Pattern for validating IPv6 addresses. */
export const ipv6: Pattern = { export const ipv6: Pattern = {
regex: regexes.ipv6.matches(), regex: regexes.ipv6.matches(),
description: 'Must be a valid IPv6 address', description: 'Must be a valid IPv6 address',
} }
/** Pattern for validating IPv4 addresses. */
export const ipv4: Pattern = { export const ipv4: Pattern = {
regex: regexes.ipv4.matches(), regex: regexes.ipv4.matches(),
description: 'Must be a valid IPv4 address', description: 'Must be a valid IPv4 address',
} }
/** Pattern for validating hostnames (RFC-compliant). */
export const hostname: Pattern = { export const hostname: Pattern = {
regex: regexes.hostname.matches(), regex: regexes.hostname.matches(),
description: 'Must be a valid hostname', description: 'Must be a valid hostname',
} }
/** Pattern for validating `.local` mDNS hostnames. */
export const localHostname: Pattern = { export const localHostname: Pattern = {
regex: regexes.localHostname.matches(), regex: regexes.localHostname.matches(),
description: 'Must be a valid ".local" hostname', description: 'Must be a valid ".local" hostname',
} }
/** Pattern for validating HTTP/HTTPS URLs. */
export const url: Pattern = { export const url: Pattern = {
regex: regexes.url.matches(), regex: regexes.url.matches(),
description: 'Must be a valid URL', description: 'Must be a valid URL',
} }
/** Pattern for validating `.local` URLs (mDNS/LAN). */
export const localUrl: Pattern = { export const localUrl: Pattern = {
regex: regexes.localUrl.matches(), regex: regexes.localUrl.matches(),
description: 'Must be a valid ".local" URL', description: 'Must be a valid ".local" URL',
} }
/** Pattern for validating ASCII-only strings (printable characters). */
export const ascii: Pattern = { export const ascii: Pattern = {
regex: regexes.ascii.matches(), regex: regexes.ascii.matches(),
description: description:
'May only contain ASCII characters. See https://www.w3schools.com/charsets/ref_html_ascii.asp', 'May only contain ASCII characters. See https://www.w3schools.com/charsets/ref_html_ascii.asp',
} }
/** Pattern for validating fully qualified domain names (FQDNs). */
export const domain: Pattern = { export const domain: Pattern = {
regex: regexes.domain.matches(), regex: regexes.domain.matches(),
description: 'Must be a valid Fully Qualified Domain Name', description: 'Must be a valid Fully Qualified Domain Name',
} }
/** Pattern for validating email addresses. */
export const email: Pattern = { export const email: Pattern = {
regex: regexes.email.matches(), regex: regexes.email.matches(),
description: 'Must be a valid email address', description: 'Must be a valid email address',
} }
/** Pattern for validating email addresses, optionally with a display name (e.g. `"John Doe <john@example.com>"`). */
export const emailWithName: Pattern = { export const emailWithName: Pattern = {
regex: regexes.emailWithName.matches(), regex: regexes.emailWithName.matches(),
description: 'Must be a valid email address, optionally with a name', description: 'Must be a valid email address, optionally with a name',
} }
/** Pattern for validating base64-encoded strings. */
export const base64: Pattern = { export const base64: Pattern = {
regex: regexes.base64.matches(), regex: regexes.base64.matches(),
description: description:

View File

@@ -1,3 +1,16 @@
/**
* A wrapper around RegExp that supports composition into larger patterns.
* Provides helpers to produce anchored (full-match), grouped (sub-expression),
* and unanchored (contains) regex source strings.
*
* @example
* ```ts
* const digit = new ComposableRegex(/\d+/)
* digit.matches() // "^\\d+$"
* digit.contains() // "\\d+"
* digit.asExpr() // "(\\d+)"
* ```
*/
export class ComposableRegex { export class ComposableRegex {
readonly regex: RegExp readonly regex: RegExp
constructor(regex: RegExp | string) { constructor(regex: RegExp | string) {
@@ -7,69 +20,94 @@ export class ComposableRegex {
this.regex = new RegExp(regex) this.regex = new RegExp(regex)
} }
} }
/** Returns the regex source wrapped in a capturing group, suitable for embedding in a larger expression. */
asExpr(): string { asExpr(): string {
return `(${this.regex.source})` return `(${this.regex.source})`
} }
/** Returns the regex source anchored with `^...$` for full-string matching. */
matches(): string { matches(): string {
return `^${this.regex.source}$` return `^${this.regex.source}$`
} }
/** Returns the raw regex source string for substring/containment matching. */
contains(): string { contains(): string {
return this.regex.source return this.regex.source
} }
} }
/**
* Escapes all regex special characters in a string so it can be used as a literal in a RegExp.
* @param str - The string to escape
* @returns The escaped string safe for regex interpolation
*/
export const escapeLiteral = (str: string) => export const escapeLiteral = (str: string) =>
str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
/** Composable regex for matching IPv6 addresses (all standard forms including `::` shorthand). */
// https://ihateregex.io/expr/ipv6/ // https://ihateregex.io/expr/ipv6/
export const ipv6 = new ComposableRegex( export const ipv6 = new ComposableRegex(
/(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))/, /(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))/,
) )
/** Composable regex for matching IPv4 addresses in dotted-decimal notation. */
// https://ihateregex.io/expr/ipv4/ // https://ihateregex.io/expr/ipv4/
export const ipv4 = new ComposableRegex( export const ipv4 = new ComposableRegex(
/(\b25[0-5]|\b2[0-4][0-9]|\b[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}/, /(\b25[0-5]|\b2[0-4][0-9]|\b[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}/,
) )
/** Composable regex for matching RFC-compliant hostnames. */
export const hostname = new ComposableRegex( export const hostname = new ComposableRegex(
/(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])/, /(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])/,
) )
/** Composable regex for matching `.local` mDNS hostnames. */
export const localHostname = new ComposableRegex( export const localHostname = new ComposableRegex(
/[-a-zA-Z0-9@:%._\+~#=]{1,256}\.local/, /[-a-zA-Z0-9@:%._\+~#=]{1,256}\.local/,
) )
/** Composable regex for matching HTTP/HTTPS URLs. */
// https://ihateregex.io/expr/url/ // https://ihateregex.io/expr/url/
export const url = new ComposableRegex( export const url = new ComposableRegex(
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/, /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/,
) )
/** Composable regex for matching `.local` URLs (mDNS/LAN). */
export const localUrl = new ComposableRegex( export const localUrl = new ComposableRegex(
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.local\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/, /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.local\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/,
) )
/** Composable regex for matching printable ASCII characters (space through tilde). */
// https://ihateregex.io/expr/ascii/ // https://ihateregex.io/expr/ascii/
export const ascii = new ComposableRegex(/[ -~]*/) export const ascii = new ComposableRegex(/[ -~]*/)
/** Composable regex for matching fully qualified domain names. */
export const domain = new ComposableRegex(/[A-Za-z0-9.-]+\.[A-Za-z]{2,}/) export const domain = new ComposableRegex(/[A-Za-z0-9.-]+\.[A-Za-z]{2,}/)
/** Composable regex for matching email addresses. */
// https://www.regular-expressions.info/email.html // https://www.regular-expressions.info/email.html
export const email = new ComposableRegex(`[A-Za-z0-9._%+-]+@${domain.asExpr()}`) export const email = new ComposableRegex(`[A-Za-z0-9._%+-]+@${domain.asExpr()}`)
/** Composable regex for matching email addresses optionally preceded by a display name (e.g. `"Name <email>"`). */
export const emailWithName = new ComposableRegex( export const emailWithName = new ComposableRegex(
`${email.asExpr()}|([^<]*<${email.asExpr()}>)`, `${email.asExpr()}|([^<]*<${email.asExpr()}>)`,
) )
/** Composable regex for matching base64-encoded strings (no whitespace). */
//https://rgxdb.com/r/1NUN74O6 //https://rgxdb.com/r/1NUN74O6
export const base64 = new ComposableRegex( export const base64 = new ComposableRegex(
/(?:[a-zA-Z0-9+\/]{4})*(?:|(?:[a-zA-Z0-9+\/]{3}=)|(?:[a-zA-Z0-9+\/]{2}==)|(?:[a-zA-Z0-9+\/]{1}===))/, /(?:[a-zA-Z0-9+\/]{4})*(?:|(?:[a-zA-Z0-9+\/]{3}=)|(?:[a-zA-Z0-9+\/]{2}==)|(?:[a-zA-Z0-9+\/]{1}===))/,
) )
/** Composable regex for matching base64-encoded strings that may contain interspersed whitespace. */
//https://rgxdb.com/r/1NUN74O6 //https://rgxdb.com/r/1NUN74O6
export const base64Whitespace = new ComposableRegex( export const base64Whitespace = new ComposableRegex(
/(?:([a-zA-Z0-9+\/]\s*){4})*(?:|(?:([a-zA-Z0-9+\/]\s*){3}=)|(?:([a-zA-Z0-9+\/]\s*){2}==)|(?:([a-zA-Z0-9+\/]\s*){1}===))/, /(?:([a-zA-Z0-9+\/]\s*){4})*(?:|(?:([a-zA-Z0-9+\/]\s*){3}=)|(?:([a-zA-Z0-9+\/]\s*){2}==)|(?:([a-zA-Z0-9+\/]\s*){1}===))/,
) )
/**
* Creates a composable regex for matching PEM-encoded blocks with the given label.
* @param label - The PEM label (e.g. `"CERTIFICATE"`, `"RSA PRIVATE KEY"`)
* @returns A ComposableRegex matching `-----BEGIN <label>-----...-----END <label>-----`
*/
export const pem = (label: string) => export const pem = (label: string) =>
new ComposableRegex( new ComposableRegex(
`-----BEGIN ${escapeLiteral(label)}-----\r?\n[a-zA-Z0-9+/\n\r=]*?\r?\n-----END ${escapeLiteral(label)}-----`, `-----BEGIN ${escapeLiteral(label)}-----\r?\n[a-zA-Z0-9+/\n\r=]*?\r?\n-----END ${escapeLiteral(label)}-----`,

View File

@@ -1,3 +1,17 @@
/**
* Normalizes a command into an argv-style string array.
* If given a string, wraps it as `["sh", "-c", command]`.
* If given a tuple, returns it as-is.
*
* @param command - A shell command string or a pre-split argv tuple
* @returns An argv-style string array suitable for process execution
*
* @example
* ```ts
* splitCommand("echo hello") // ["sh", "-c", "echo hello"]
* splitCommand(["node", "index.js"]) // ["node", "index.js"]
* ```
*/
export const splitCommand = ( export const splitCommand = (
command: string | [string, ...string[]], command: string | [string, ...string[]],
): string[] => { ): string[] => {

View File

@@ -1,3 +1,10 @@
/**
* Extracts a string result from a stdout/stderr pair.
* Returns `stdout` on success; rejects with `stderr` if it is non-empty.
*
* @param x - An object containing `stdout` and `stderr` strings
* @returns A promise resolving to `stdout`, or rejecting with `stderr`
*/
export async function stringFromStdErrOut(x: { export async function stringFromStdErrOut(x: {
stdout: string stdout: string
stderr: string stderr: string

View File

@@ -1,21 +1,47 @@
import * as T from '../types' import * as T from '../types'
/**
* Flattens an intersection type into a single object type for improved readability in IDE tooltips.
* Arrays pass through unchanged; objects are remapped to a single flat type.
*
* @example
* ```ts
* type Merged = FlattenIntersection<{ a: 1 } & { b: 2 }>
* // Result: { a: 1; b: 2 }
* ```
*/
// prettier-ignore // prettier-ignore
export type FlattenIntersection<T> = export type FlattenIntersection<T> =
T extends ArrayLike<any> ? T : T extends ArrayLike<any> ? T :
T extends object ? {} & {[P in keyof T]: T[P]} : T extends object ? {} & {[P in keyof T]: T[P]} :
T; T;
/** Shorthand alias for {@link FlattenIntersection}. */
export type _<T> = FlattenIntersection<T> export type _<T> = FlattenIntersection<T>
/**
* Type guard that checks whether a value is a {@link T.KnownError}.
* Returns true if the value is an object containing an `error` or `error-code` property.
*
* @param e - The value to check
* @returns True if `e` is a KnownError
*/
export const isKnownError = (e: unknown): e is T.KnownError => export const isKnownError = (e: unknown): e is T.KnownError =>
e instanceof Object && ('error' in e || 'error-code' in e) e instanceof Object && ('error' in e || 'error-code' in e)
declare const affine: unique symbol declare const affine: unique symbol
/**
* A branded/nominal type wrapper using a unique symbol to make structurally identical types incompatible.
* Useful for creating distinct type identities at the type level.
*/
export type Affine<A> = { [affine]: A } export type Affine<A> = { [affine]: A }
type NeverPossible = { [affine]: string } type NeverPossible = { [affine]: string }
/**
* Evaluates to `never` if `A` is `any`, otherwise resolves to `A`.
* Useful for preventing `any` from silently propagating through generic constraints.
*/
export type NoAny<A> = NeverPossible extends A export type NoAny<A> = NeverPossible extends A
? keyof NeverPossible extends keyof A ? keyof NeverPossible extends keyof A
? never ? never
@@ -54,6 +80,14 @@ type Numbers = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
type CapitalChars = CapitalLetters | Numbers type CapitalChars = CapitalLetters | Numbers
/**
* Converts a PascalCase or camelCase string type to kebab-case at the type level.
*
* @example
* ```ts
* type Result = ToKebab<"FooBar"> // "foo-bar"
* ```
*/
export type ToKebab<S extends string> = S extends string export type ToKebab<S extends string> = S extends string
? S extends `${infer Head}${CapitalChars}${infer Tail}` // string has a capital char somewhere ? S extends `${infer Head}${CapitalChars}${infer Tail}` // string has a capital char somewhere
? Head extends '' // there is a capital char in the first position ? Head extends '' // there is a capital char in the first position
@@ -101,6 +135,7 @@ export type ToKebab<S extends string> = S extends string
: S /* 'abc' */ : S /* 'abc' */
: never : never
/** A generic object type with string keys and unknown values. */
export type StringObject = Record<string, unknown> export type StringObject = Record<string, unknown>
function test() { function test() {

View File

@@ -9,7 +9,12 @@ import {
import { ServiceInterfaceType, Effects } from '../../base/lib/types' import { ServiceInterfaceType, Effects } from '../../base/lib/types'
import * as patterns from '../../base/lib/util/patterns' import * as patterns from '../../base/lib/util/patterns'
import { Backups } from './backup/Backups' import { Backups } from './backup/Backups'
import { smtpInputSpec } from '../../base/lib/actions/input/inputSpecConstants' import {
smtpInputSpec,
systemSmtpSpec,
customSmtp,
smtpProviderVariants,
} from '../../base/lib/actions/input/inputSpecConstants'
import { Daemon, Daemons } from './mainFn/Daemons' import { Daemon, Daemons } from './mainFn/Daemons'
import { checkPortListening } from './health/checkFns/checkPortListening' import { checkPortListening } from './health/checkFns/checkPortListening'
import { checkWebUrl, runHealthScript } from './health/checkFns' import { checkWebUrl, runHealthScript } from './health/checkFns'
@@ -62,6 +67,7 @@ import {
import { getOwnServiceInterfaces } from '../../base/lib/util/getServiceInterfaces' import { getOwnServiceInterfaces } from '../../base/lib/util/getServiceInterfaces'
import { Volumes, createVolumes } from './util/Volume' import { Volumes, createVolumes } from './util/Volume'
/** The minimum StartOS version required by this SDK release */
export const OSVersion = testTypeVersion('0.4.0-alpha.20') export const OSVersion = testTypeVersion('0.4.0-alpha.20')
// prettier-ignore // prettier-ignore
@@ -71,11 +77,29 @@ type AnyNeverCond<T extends any[], Then, Else> =
T extends [any, ...infer U] ? AnyNeverCond<U,Then, Else> : T extends [any, ...infer U] ? AnyNeverCond<U,Then, Else> :
never never
/**
* The top-level SDK facade for building StartOS service packages.
*
* Use `StartSdk.of()` to create an uninitialized instance, then call `.withManifest()`
* to bind it to a manifest, and finally `.build()` to obtain the full toolkit of helpers
* for actions, daemons, backups, interfaces, health checks, and more.
*
* @typeParam Manifest - The service manifest type; starts as `never` until `.withManifest()` is called.
*/
export class StartSdk<Manifest extends T.SDKManifest> { export class StartSdk<Manifest extends T.SDKManifest> {
private constructor(readonly manifest: Manifest) {} private constructor(readonly manifest: Manifest) {}
/**
* Create an uninitialized StartSdk instance. Call `.withManifest()` next.
* @returns A new StartSdk with no manifest bound.
*/
static of() { static of() {
return new StartSdk<never>(null as never) return new StartSdk<never>(null as never)
} }
/**
* Bind a manifest to the SDK, producing a typed SDK instance.
* @param manifest - The service manifest definition
* @returns A new StartSdk instance parameterized by the given manifest type
*/
withManifest<Manifest extends T.SDKManifest = never>(manifest: Manifest) { withManifest<Manifest extends T.SDKManifest = never>(manifest: Manifest) {
return new StartSdk<Manifest>(manifest) return new StartSdk<Manifest>(manifest)
} }
@@ -88,6 +112,14 @@ export class StartSdk<Manifest extends T.SDKManifest> {
return null as any return null as any
} }
/**
* Finalize the SDK and return the full set of helpers for building a StartOS service.
*
* This method is only callable after `.withManifest()` has been called (enforced at the type level).
*
* @param isReady - Type-level gate; resolves to `true` only when a manifest is bound.
* @returns An object containing all SDK utilities: actions, daemons, backups, interfaces, health checks, volumes, triggers, and more.
*/
build(isReady: AnyNeverCond<[Manifest], 'Build not ready', true>) { build(isReady: AnyNeverCond<[Manifest], 'Build not ready', true>) {
type NestedEffects = 'subcontainer' | 'store' | 'action' | 'plugin' type NestedEffects = 'subcontainer' | 'store' | 'action' | 'plugin'
type InterfaceEffects = type InterfaceEffects =
@@ -137,13 +169,19 @@ export class StartSdk<Manifest extends T.SDKManifest> {
} }
return { return {
/** The bound service manifest */
manifest: this.manifest, manifest: this.manifest,
/** Volume path helpers derived from the manifest volume definitions */
volumes: createVolumes(this.manifest), volumes: createVolumes(this.manifest),
...startSdkEffectWrapper, ...startSdkEffectWrapper,
/** Persist the current data version to the StartOS effect system */
setDataVersion, setDataVersion,
/** Retrieve the current data version from the StartOS effect system */
getDataVersion, getDataVersion,
action: { action: {
/** Execute an action by its ID, optionally providing input */
run: actions.runAction, run: actions.runAction,
/** Create a task notification for a specific package's action */
createTask: <T extends ActionInfo<T.ActionId, any>>( createTask: <T extends ActionInfo<T.ActionId, any>>(
effects: T.Effects, effects: T.Effects,
packageId: T.PackageId, packageId: T.PackageId,
@@ -158,6 +196,7 @@ export class StartSdk<Manifest extends T.SDKManifest> {
severity, severity,
options: options, options: options,
}), }),
/** Create a task notification for this service's own action (uses manifest.id automatically) */
createOwnTask: <T extends ActionInfo<T.ActionId, any>>( createOwnTask: <T extends ActionInfo<T.ActionId, any>>(
effects: T.Effects, effects: T.Effects,
action: T, action: T,
@@ -171,9 +210,20 @@ export class StartSdk<Manifest extends T.SDKManifest> {
severity, severity,
options: options, options: options,
}), }),
/**
* Clear one or more task notifications by their replay IDs
* @param effects - The effects context
* @param replayIds - One or more replay IDs of the tasks to clear
*/
clearTask: (effects: T.Effects, ...replayIds: string[]) => clearTask: (effects: T.Effects, ...replayIds: string[]) =>
effects.action.clearTasks({ only: replayIds }), effects.action.clearTasks({ only: replayIds }),
}, },
/**
* Check whether the specified (or all) dependencies are satisfied.
* @param effects - The effects context
* @param packageIds - Optional subset of dependency IDs to check; defaults to all
* @returns An object describing which dependencies are satisfied and which are not
*/
checkDependencies: checkDependencies as < checkDependencies: checkDependencies as <
DependencyId extends keyof Manifest['dependencies'] & DependencyId extends keyof Manifest['dependencies'] &
T.PackageId = keyof Manifest['dependencies'] & T.PackageId, T.PackageId = keyof Manifest['dependencies'] & T.PackageId,
@@ -182,11 +232,25 @@ export class StartSdk<Manifest extends T.SDKManifest> {
packageIds?: DependencyId[], packageIds?: DependencyId[],
) => Promise<CheckDependencies<DependencyId>>, ) => Promise<CheckDependencies<DependencyId>>,
serviceInterface: { serviceInterface: {
/** Retrieve a single service interface belonging to this package by its ID */
getOwn: getOwnServiceInterface, getOwn: getOwnServiceInterface,
/** Retrieve a single service interface from any package */
get: getServiceInterface, get: getServiceInterface,
/** Retrieve all service interfaces belonging to this package */
getAllOwn: getOwnServiceInterfaces, getAllOwn: getOwnServiceInterfaces,
/** Retrieve all service interfaces, optionally filtering by package */
getAll: getServiceInterfaces, getAll: getServiceInterfaces,
}, },
/**
* Get the container IP address with reactive subscription support.
*
* Returns an object with multiple read strategies: `const()` for a value
* that retries on change, `once()` for a single read, `watch()` for an async
* generator, `onChange()` for a callback, and `waitFor()` to block until a predicate is met.
*
* @param effects - The effects context
* @param options - Optional filtering options (e.g. `containerId`)
*/
getContainerIp: ( getContainerIp: (
effects: T.Effects, effects: T.Effects,
options: Omit< options: Omit<
@@ -279,9 +343,22 @@ export class StartSdk<Manifest extends T.SDKManifest> {
}, },
MultiHost: { MultiHost: {
/**
* Create a new MultiHost instance for binding ports and exporting interfaces.
* @param effects - The effects context
* @param id - A unique identifier for this multi-host group
*/
of: (effects: Effects, id: string) => new MultiHost({ id, effects }), of: (effects: Effects, id: string) => new MultiHost({ id, effects }),
}, },
/**
* Return `null` if the given string is empty, otherwise return the string unchanged.
* Useful for converting empty user input into explicit null values.
*/
nullIfEmpty, nullIfEmpty,
/**
* Indicate that a daemon should use the container image's configured entrypoint.
* @param overrideCmd - Optional command arguments to append after the entrypoint
*/
useEntrypoint: (overrideCmd?: string[]) => useEntrypoint: (overrideCmd?: string[]) =>
new T.UseEntrypoint(overrideCmd), new T.UseEntrypoint(overrideCmd),
/** /**
@@ -396,7 +473,12 @@ export class StartSdk<Manifest extends T.SDKManifest> {
run: Run<{}>, run: Run<{}>,
) => Action.withoutInput(id, metadata, run), ) => Action.withoutInput(id, metadata, run),
}, },
inputSpecConstants: { smtpInputSpec }, inputSpecConstants: {
smtpInputSpec,
systemSmtpSpec,
customSmtp,
smtpProviderVariants,
},
/** /**
* @description Use this function to create a service interface. * @description Use this function to create a service interface.
* @param effects * @param effects
@@ -444,21 +526,37 @@ export class StartSdk<Manifest extends T.SDKManifest> {
masked: boolean masked: boolean
}, },
) => new ServiceInterfaceBuilder({ ...options, effects }), ) => new ServiceInterfaceBuilder({ ...options, effects }),
/**
* Get the system SMTP configuration with reactive subscription support.
* @param effects - The effects context
*/
getSystemSmtp: <E extends Effects>(effects: E) => getSystemSmtp: <E extends Effects>(effects: E) =>
new GetSystemSmtp(effects), new GetSystemSmtp(effects),
/**
* Get the outbound network gateway address with reactive subscription support.
* @param effects - The effects context
*/
getOutboundGateway: <E extends Effects>(effects: E) => getOutboundGateway: <E extends Effects>(effects: E) =>
new GetOutboundGateway(effects), new GetOutboundGateway(effects),
/**
* Get an SSL certificate for the given hostnames with reactive subscription support.
* @param effects - The effects context
* @param hostnames - The hostnames to obtain a certificate for
* @param algorithm - Optional algorithm preference (e.g. Ed25519)
*/
getSslCertificate: <E extends Effects>( getSslCertificate: <E extends Effects>(
effects: E, effects: E,
hostnames: string[], hostnames: string[],
algorithm?: T.Algorithm, 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, getServiceManifest,
healthCheck: { healthCheck: {
checkPortListening, checkPortListening,
checkWebUrl, checkWebUrl,
runHealthScript, runHealthScript,
}, },
/** Common utility patterns (e.g. hostname regex, port validators) */
patterns, patterns,
/** /**
* @description Use this function to list every Action offered by the service. Actions will be displayed in the provided order. * @description Use this function to list every Action offered by the service. Actions will be displayed in the provided order.
@@ -638,21 +736,47 @@ export class StartSdk<Manifest extends T.SDKManifest> {
* ``` * ```
*/ */
setupInterfaces: setupServiceInterfaces, setupInterfaces: setupServiceInterfaces,
/**
* Define the main entrypoint for the service. The provided function should
* configure and return a `Daemons` instance describing all long-running processes.
* @param fn - Async function that receives `effects` and returns a `Daemons` instance
*/
setupMain: ( setupMain: (
fn: (o: { effects: Effects }) => Promise<Daemons<Manifest, any>>, fn: (o: { effects: Effects }) => Promise<Daemons<Manifest, any>>,
) => setupMain<Manifest>(fn), ) => setupMain<Manifest>(fn),
/** Built-in trigger strategies for controlling health-check polling intervals */
trigger: { trigger: {
/** Default trigger: polls at a fixed interval */
defaultTrigger, defaultTrigger,
/** Trigger with a cooldown period between checks */
cooldownTrigger, cooldownTrigger,
/** Switches to a different interval after the first successful check */
changeOnFirstSuccess, changeOnFirstSuccess,
/** Uses different intervals based on success vs failure results */
successFailure, successFailure,
}, },
Mounts: { Mounts: {
/**
* Create an empty Mounts builder for declaring volume, asset, dependency, and backup mounts.
* @returns A new Mounts instance with no mounts configured
*/
of: Mounts.of<Manifest>, of: Mounts.of<Manifest>,
}, },
Backups: { Backups: {
/**
* Create a Backups configuration that backs up entire volumes by name.
* @param volumeNames - Volume IDs from the manifest to include in backups
*/
ofVolumes: Backups.ofVolumes<Manifest>, ofVolumes: Backups.ofVolumes<Manifest>,
/**
* Create a Backups configuration from explicit sync path pairs.
* @param syncs - Array of `{ dataPath, backupPath }` objects
*/
ofSyncs: Backups.ofSyncs<Manifest>, ofSyncs: Backups.ofSyncs<Manifest>,
/**
* Create a Backups configuration with custom rsync options (e.g. exclude patterns).
* @param options - Partial sync options to override defaults
*/
withOptions: Backups.withOptions<Manifest>, withOptions: Backups.withOptions<Manifest>,
}, },
InputSpec: { InputSpec: {
@@ -687,11 +811,20 @@ export class StartSdk<Manifest extends T.SDKManifest> {
InputSpec.of<Spec>(spec), InputSpec.of<Spec>(spec),
}, },
Daemon: { Daemon: {
/**
* Create a single Daemon that wraps a long-running process with automatic restart logic.
* Returns a curried function: call with `(effects, subcontainer, exec)`.
*/
get of() { get of() {
return Daemon.of<Manifest>() return Daemon.of<Manifest>()
}, },
}, },
Daemons: { Daemons: {
/**
* Create a new Daemons builder for defining the service's daemon topology.
* Chain `.addDaemon()` calls to register each long-running process.
* @param effects - The effects context
*/
of(effects: Effects) { of(effects: Effects) {
return Daemons.of<Manifest>({ effects }) return Daemons.of<Manifest>({ effects })
}, },
@@ -798,6 +931,19 @@ export class StartSdk<Manifest extends T.SDKManifest> {
} }
} }
/**
* Run a one-shot command inside a temporary subcontainer.
*
* Creates a subcontainer, executes the command, and destroys the subcontainer when finished.
* Throws an {@link ExitError} if the command exits with a non-zero code or signal.
*
* @param effects - The effects context
* @param image - The container image to use
* @param command - The command to execute (string array or UseEntrypoint)
* @param options - Mount and command options
* @param name - Optional human-readable name for debugging
* @returns The stdout and stderr output of the command
*/
export async function runCommand<Manifest extends T.SDKManifest>( export async function runCommand<Manifest extends T.SDKManifest>(
effects: Effects, effects: Effects,
image: { imageId: keyof Manifest['images'] & T.ImageId; sharedRun?: boolean }, image: { imageId: keyof Manifest['images'] & T.ImageId; sharedRun?: boolean },

View File

@@ -5,10 +5,12 @@ import { Affine, asError } from '../util'
import { ExtendedVersion, VersionRange } from '../../../base/lib' import { ExtendedVersion, VersionRange } from '../../../base/lib'
import { InitKind, InitScript } from '../../../base/lib/inits' import { InitKind, InitScript } from '../../../base/lib/inits'
/** Default rsync options used for backup and restore operations */
export const DEFAULT_OPTIONS: T.SyncOptions = { export const DEFAULT_OPTIONS: T.SyncOptions = {
delete: true, delete: true,
exclude: [], exclude: [],
} }
/** A single source-to-destination sync pair for backup and restore */
export type BackupSync<Volumes extends string> = { export type BackupSync<Volumes extends string> = {
dataPath: `/media/startos/volumes/${Volumes}/${string}` dataPath: `/media/startos/volumes/${Volumes}/${string}`
backupPath: `/media/startos/backup/${string}` backupPath: `/media/startos/backup/${string}`
@@ -17,8 +19,18 @@ export type BackupSync<Volumes extends string> = {
restoreOptions?: Partial<T.SyncOptions> restoreOptions?: Partial<T.SyncOptions>
} }
/** Effects type narrowed for backup/restore contexts, preventing reuse outside that scope */
export type BackupEffects = T.Effects & Affine<'Backups'> export type BackupEffects = T.Effects & Affine<'Backups'>
/**
* Configures backup and restore operations using rsync.
*
* Supports syncing entire volumes or custom path pairs, with optional pre/post hooks
* for both backup and restore phases. Implements {@link InitScript} so it can be used
* as a restore-init step in `setupInit`.
*
* @typeParam M - The service manifest type
*/
export class Backups<M extends T.SDKManifest> implements InitScript { export class Backups<M extends T.SDKManifest> implements InitScript {
private constructor( private constructor(
private options = DEFAULT_OPTIONS, private options = DEFAULT_OPTIONS,
@@ -31,6 +43,11 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
private postRestore = async (effects: BackupEffects) => {}, private postRestore = async (effects: BackupEffects) => {},
) {} ) {}
/**
* Create a Backups configuration that backs up entire volumes by name.
* Each volume is synced to a corresponding directory under `/media/startos/backup/volumes/`.
* @param volumeNames - One or more volume IDs from the manifest
*/
static ofVolumes<M extends T.SDKManifest = never>( static ofVolumes<M extends T.SDKManifest = never>(
...volumeNames: Array<M['volumes'][number]> ...volumeNames: Array<M['volumes'][number]>
): Backups<M> { ): Backups<M> {
@@ -42,18 +59,31 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
) )
} }
/**
* Create a Backups configuration from explicit source/destination sync pairs.
* @param syncs - Array of `{ dataPath, backupPath }` objects with optional per-sync options
*/
static ofSyncs<M extends T.SDKManifest = never>( static ofSyncs<M extends T.SDKManifest = never>(
...syncs: BackupSync<M['volumes'][number]>[] ...syncs: BackupSync<M['volumes'][number]>[]
) { ) {
return syncs.reduce((acc, x) => acc.addSync(x), new Backups<M>()) return syncs.reduce((acc, x) => acc.addSync(x), new Backups<M>())
} }
/**
* Create an empty Backups configuration with custom default rsync options.
* Chain `.addVolume()` or `.addSync()` to add sync targets.
* @param options - Partial rsync options to override defaults (e.g. `{ exclude: ['cache'] }`)
*/
static withOptions<M extends T.SDKManifest = never>( static withOptions<M extends T.SDKManifest = never>(
options?: Partial<T.SyncOptions>, options?: Partial<T.SyncOptions>,
) { ) {
return new Backups<M>({ ...DEFAULT_OPTIONS, ...options }) return new Backups<M>({ ...DEFAULT_OPTIONS, ...options })
} }
/**
* Override the default rsync options for both backup and restore.
* @param options - Partial rsync options to merge with current defaults
*/
setOptions(options?: Partial<T.SyncOptions>) { setOptions(options?: Partial<T.SyncOptions>) {
this.options = { this.options = {
...this.options, ...this.options,
@@ -62,6 +92,10 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
return this return this
} }
/**
* Override rsync options used only during backup (not restore).
* @param options - Partial rsync options for the backup phase
*/
setBackupOptions(options?: Partial<T.SyncOptions>) { setBackupOptions(options?: Partial<T.SyncOptions>) {
this.backupOptions = { this.backupOptions = {
...this.backupOptions, ...this.backupOptions,
@@ -70,6 +104,10 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
return this return this
} }
/**
* Override rsync options used only during restore (not backup).
* @param options - Partial rsync options for the restore phase
*/
setRestoreOptions(options?: Partial<T.SyncOptions>) { setRestoreOptions(options?: Partial<T.SyncOptions>) {
this.restoreOptions = { this.restoreOptions = {
...this.restoreOptions, ...this.restoreOptions,
@@ -78,26 +116,47 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
return this return this
} }
/**
* Register a hook to run before backup rsync begins (e.g. dump a database).
* @param fn - Async function receiving backup-scoped effects
*/
setPreBackup(fn: (effects: BackupEffects) => Promise<void>) { setPreBackup(fn: (effects: BackupEffects) => Promise<void>) {
this.preBackup = fn this.preBackup = fn
return this return this
} }
/**
* Register a hook to run after backup rsync completes.
* @param fn - Async function receiving backup-scoped effects
*/
setPostBackup(fn: (effects: BackupEffects) => Promise<void>) { setPostBackup(fn: (effects: BackupEffects) => Promise<void>) {
this.postBackup = fn this.postBackup = fn
return this return this
} }
/**
* Register a hook to run before restore rsync begins.
* @param fn - Async function receiving backup-scoped effects
*/
setPreRestore(fn: (effects: BackupEffects) => Promise<void>) { setPreRestore(fn: (effects: BackupEffects) => Promise<void>) {
this.preRestore = fn this.preRestore = fn
return this return this
} }
/**
* Register a hook to run after restore rsync completes.
* @param fn - Async function receiving backup-scoped effects
*/
setPostRestore(fn: (effects: BackupEffects) => Promise<void>) { setPostRestore(fn: (effects: BackupEffects) => Promise<void>) {
this.postRestore = fn this.postRestore = fn
return this return this
} }
/**
* Add a volume to the backup set by its ID.
* @param volume - The volume ID from the manifest
* @param options - Optional per-volume rsync overrides
*/
addVolume( addVolume(
volume: M['volumes'][number], volume: M['volumes'][number],
options?: Partial<{ options?: Partial<{
@@ -113,11 +172,19 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
}) })
} }
/**
* Add a custom sync pair to the backup set.
* @param sync - A `{ dataPath, backupPath }` object with optional per-sync rsync options
*/
addSync(sync: BackupSync<M['volumes'][0]>) { addSync(sync: BackupSync<M['volumes'][0]>) {
this.backupSet.push(sync) this.backupSet.push(sync)
return this return this
} }
/**
* Execute the backup: runs pre-hook, rsyncs all configured paths, saves the data version, then runs post-hook.
* @param effects - The effects context
*/
async createBackup(effects: T.Effects) { async createBackup(effects: T.Effects) {
await this.preBackup(effects as BackupEffects) await this.preBackup(effects as BackupEffects)
for (const item of this.backupSet) { for (const item of this.backupSet) {
@@ -149,6 +216,10 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
} }
} }
/**
* Execute the restore: runs pre-hook, rsyncs all configured paths from backup to data, restores the data version, then runs post-hook.
* @param effects - The effects context
*/
async restoreBackup(effects: T.Effects) { async restoreBackup(effects: T.Effects) {
this.preRestore(effects as BackupEffects) this.preRestore(effects as BackupEffects)

View File

@@ -3,6 +3,11 @@ import * as T from '../../../base/lib/types'
import { _ } from '../util' import { _ } from '../util'
import { InitScript } from '../../../base/lib/inits' import { InitScript } from '../../../base/lib/inits'
/**
* Parameters for `setupBackups`. Either:
* - An array of volume IDs to back up entirely, or
* - An async factory function that returns a fully configured {@link Backups} instance
*/
export type SetupBackupsParams<M extends T.SDKManifest> = export type SetupBackupsParams<M extends T.SDKManifest> =
| M['volumes'][number][] | M['volumes'][number][]
| ((_: { effects: T.Effects }) => Promise<Backups<M>>) | ((_: { effects: T.Effects }) => Promise<Backups<M>>)
@@ -12,6 +17,15 @@ type SetupBackupsRes = {
restoreInit: InitScript restoreInit: InitScript
} }
/**
* Set up backup and restore exports for the service.
*
* Returns `{ createBackup, restoreInit }` which should be exported and wired into
* the service's init and backup entry points.
*
* @param options - Either an array of volume IDs or an async factory returning a Backups instance
* @returns An object with `createBackup` (the backup export) and `restoreInit` (an InitScript for restore)
*/
export function setupBackups<M extends T.SDKManifest>( export function setupBackups<M extends T.SDKManifest>(
options: SetupBackupsParams<M>, options: SetupBackupsParams<M>,
) { ) {

View File

@@ -5,6 +5,7 @@ import { TriggerInput } from '../trigger/TriggerInput'
import { defaultTrigger } from '../trigger/defaultTrigger' import { defaultTrigger } from '../trigger/defaultTrigger'
import { once, asError, Drop } from '../util' import { once, asError, Drop } from '../util'
/** Parameters for creating a health check */
export type HealthCheckParams = { export type HealthCheckParams = {
id: HealthCheckId id: HealthCheckId
name: string name: string
@@ -13,6 +14,13 @@ export type HealthCheckParams = {
fn(): Promise<HealthCheckResult> | HealthCheckResult fn(): Promise<HealthCheckResult> | HealthCheckResult
} }
/**
* A periodic health check that reports daemon readiness to the StartOS UI.
*
* Polls at an interval controlled by a {@link Trigger}, reporting results as
* "starting" (during the grace period), "success", or "failure". Automatically
* pauses when the daemon is stopped and resumes when restarted.
*/
export class HealthCheck extends Drop { export class HealthCheck extends Drop {
private started: number | null = null private started: number | null = null
private setStarted = (started: number | null) => { private setStarted = (started: number | null) => {
@@ -91,13 +99,21 @@ export class HealthCheck extends Drop {
} }
}) })
} }
/**
* Create a new HealthCheck instance and begin its polling loop.
* @param effects - The effects context for reporting health status
* @param options - Health check configuration (ID, name, check function, trigger, grace period)
* @returns A new HealthCheck instance
*/
static of(effects: Effects, options: HealthCheckParams): HealthCheck { static of(effects: Effects, options: HealthCheckParams): HealthCheck {
return new HealthCheck(effects, options) return new HealthCheck(effects, options)
} }
/** Signal that the daemon is running, enabling health check polling */
start() { start() {
if (this.started) return if (this.started) return
this.setStarted(performance.now()) this.setStarted(performance.now())
} }
/** Signal that the daemon has stopped, pausing health check polling */
stop() { stop() {
if (!this.started) return if (!this.started) return
this.setStarted(null) this.setStarted(null)

View File

@@ -1,3 +1,9 @@
import { T } from '../../../../base/lib' import { T } from '../../../../base/lib'
/**
* The result of a single health check invocation.
*
* Contains a `result` field ("success", "failure", or "starting") and an optional `message`.
* This is the unnamed variant -- the health check name is added by the framework.
*/
export type HealthCheckResult = Omit<T.NamedHealthCheckResult, 'name'> export type HealthCheckResult = Omit<T.NamedHealthCheckResult, 'name'>

View File

@@ -3,6 +3,14 @@ export { checkPortListening } from './checkPortListening'
export { HealthCheckResult } from './HealthCheckResult' export { HealthCheckResult } from './HealthCheckResult'
export { checkWebUrl } from './checkWebUrl' export { checkWebUrl } from './checkWebUrl'
/**
* Create a promise that rejects after the specified timeout.
* Useful for racing against long-running health checks.
*
* @param ms - Timeout duration in milliseconds
* @param options.message - Custom error message (defaults to "Timed out")
* @returns A promise that never resolves, only rejects after the timeout
*/
export function timeoutPromise(ms: number, { message = 'Timed out' } = {}) { export function timeoutPromise(ms: number, { message = 'Timed out' } = {}) {
return new Promise<never>((resolve, reject) => return new Promise<never>((resolve, reject) =>
setTimeout(() => reject(new Error(message)), ms), setTimeout(() => reject(new Error(message)), ms),

View File

@@ -8,6 +8,15 @@ import * as cp from 'child_process'
import * as fs from 'node:fs/promises' import * as fs from 'node:fs/promises'
import { DaemonCommandType, ExecCommandOptions, ExecFnOptions } from './Daemons' import { DaemonCommandType, ExecCommandOptions, ExecFnOptions } from './Daemons'
/**
* Low-level controller for a single running process inside a subcontainer (or as a JS function).
*
* Manages the child process lifecycle: spawning, waiting, and signal-based termination.
* Used internally by {@link Daemon} to manage individual command executions.
*
* @typeParam Manifest - The service manifest type
* @typeParam C - The subcontainer type, or `null` for JS-only commands
*/
export class CommandController< export class CommandController<
Manifest extends T.SDKManifest, Manifest extends T.SDKManifest,
C extends SubContainer<Manifest> | null, C extends SubContainer<Manifest> | null,
@@ -21,6 +30,13 @@ export class CommandController<
) { ) {
super() super()
} }
/**
* Factory method to create a new CommandController.
*
* Returns a curried async function: `(effects, subcontainer, exec) => CommandController`.
* If the exec spec has an `fn` property, runs the function; otherwise spawns a shell command
* in the subcontainer.
*/
static of< static of<
Manifest extends T.SDKManifest, Manifest extends T.SDKManifest,
C extends SubContainer<Manifest> | null, C extends SubContainer<Manifest> | null,
@@ -130,6 +146,10 @@ export class CommandController<
} }
} }
} }
/**
* Wait for the command to finish. Optionally terminate after a timeout.
* @param options.timeout - Milliseconds to wait before terminating. Defaults to no timeout.
*/
async wait({ timeout = NO_TIMEOUT } = {}) { async wait({ timeout = NO_TIMEOUT } = {}) {
if (timeout > 0) if (timeout > 0)
setTimeout(() => { setTimeout(() => {
@@ -156,6 +176,15 @@ export class CommandController<
await this.subcontainer?.destroy() await this.subcontainer?.destroy()
} }
} }
/**
* Terminate the running command by sending a signal.
*
* Sends the specified signal (default: SIGTERM), then escalates to SIGKILL
* after the timeout expires. Destroys the subcontainer after the process exits.
*
* @param options.signal - The signal to send (default: SIGTERM)
* @param options.timeout - Milliseconds before escalating to SIGKILL
*/
async term({ signal = SIGTERM, timeout = this.sigtermTimeout } = {}) { async term({ signal = SIGTERM, timeout = this.sigtermTimeout } = {}) {
try { try {
if (!this.state.exited) { if (!this.state.exited) {

View File

@@ -13,10 +13,15 @@ import { Oneshot } from './Oneshot'
const TIMEOUT_INCREMENT_MS = 1000 const TIMEOUT_INCREMENT_MS = 1000
const MAX_TIMEOUT_MS = 30000 const MAX_TIMEOUT_MS = 30000
/** /**
* This is a wrapper around CommandController that has a state of off, where the command shouldn't be running * A managed long-running process wrapper around {@link CommandController}.
* and the others state of running, where it will keep a living running command *
* When started, the daemon automatically restarts its underlying command on failure
* with exponential backoff (up to 30 seconds). When stopped, the command is terminated
* gracefully. Implements {@link Drop} for automatic cleanup when the context is left.
*
* @typeParam Manifest - The service manifest type
* @typeParam C - The subcontainer type, or `null` for JS-only daemons
*/ */
export class Daemon< export class Daemon<
Manifest extends T.SDKManifest, Manifest extends T.SDKManifest,
C extends SubContainer<Manifest> | null = SubContainer<Manifest> | null, C extends SubContainer<Manifest> | null = SubContainer<Manifest> | null,
@@ -33,9 +38,16 @@ export class Daemon<
) { ) {
super() super()
} }
/** Returns true if this daemon is a one-shot process (exits after success) */
isOneshot(): this is Oneshot<Manifest> { isOneshot(): this is Oneshot<Manifest> {
return this.oneshot return this.oneshot
} }
/**
* Factory method to create a new Daemon.
*
* Returns a curried function: `(effects, subcontainer, exec) => Daemon`.
* The daemon auto-terminates when the effects context is left.
*/
static of<Manifest extends T.SDKManifest>() { static of<Manifest extends T.SDKManifest>() {
return <C extends SubContainer<Manifest> | null>( return <C extends SubContainer<Manifest> | null>(
effects: T.Effects, effects: T.Effects,
@@ -57,6 +69,12 @@ export class Daemon<
return res return res
} }
} }
/**
* Start the daemon. If it is already running, this is a no-op.
*
* The daemon will automatically restart on failure with increasing backoff
* until {@link term} is called.
*/
async start() { async start() {
if (this.commandController) { if (this.commandController) {
return return
@@ -105,6 +123,17 @@ export class Daemon<
console.error(asError(err)) console.error(asError(err))
}) })
} }
/**
* Terminate the daemon, stopping its underlying command.
*
* Sends the configured signal (default SIGTERM) and waits for the process to exit.
* Optionally destroys the subcontainer after termination.
*
* @param termOptions - Optional termination settings
* @param termOptions.signal - The signal to send (default: SIGTERM)
* @param termOptions.timeout - Milliseconds to wait before SIGKILL
* @param termOptions.destroySubcontainer - Whether to destroy the subcontainer after exit
*/
async term(termOptions?: { async term(termOptions?: {
signal?: NodeJS.Signals | undefined signal?: NodeJS.Signals | undefined
timeout?: number | undefined timeout?: number | undefined
@@ -125,14 +154,20 @@ export class Daemon<
this.exiting = null this.exiting = null
} }
} }
/** Get a reference-counted handle to the daemon's subcontainer, or null if there is none */
subcontainerRc(): SubContainerRc<Manifest> | null { subcontainerRc(): SubContainerRc<Manifest> | null {
return this.subcontainer?.rc() ?? null return this.subcontainer?.rc() ?? null
} }
/** Check whether this daemon shares the same subcontainer as another daemon */
sharesSubcontainerWith( sharesSubcontainerWith(
other: Daemon<Manifest, SubContainer<Manifest> | null>, other: Daemon<Manifest, SubContainer<Manifest> | null>,
): boolean { ): boolean {
return this.subcontainer?.guid === other.subcontainer?.guid return this.subcontainer?.guid === other.subcontainer?.guid
} }
/**
* Register a callback to be invoked each time the daemon's process exits.
* @param fn - Callback receiving `true` on clean exit, `false` on error
*/
onExit(fn: (success: boolean) => void) { onExit(fn: (success: boolean) => void) {
this.onExitFns.push(fn) this.onExitFns.push(fn)
} }

View File

@@ -16,8 +16,15 @@ import { Daemon } from './Daemon'
import { CommandController } from './CommandController' import { CommandController } from './CommandController'
import { Oneshot } from './Oneshot' import { Oneshot } from './Oneshot'
/** Promisified version of `child_process.exec` */
export const cpExec = promisify(CP.exec) export const cpExec = promisify(CP.exec)
/** Promisified version of `child_process.execFile` */
export const cpExecFile = promisify(CP.execFile) export const cpExecFile = promisify(CP.execFile)
/**
* Configuration for a daemon's health-check readiness probe.
*
* Determines how the system knows when a daemon is healthy and ready to serve.
*/
export type Ready = { export type Ready = {
/** A human-readable display name for the health check. If null, the health check itself will be from the UI */ /** A human-readable display name for the health check. If null, the health check itself will be from the UI */
display: string | null display: string | null
@@ -45,6 +52,10 @@ export type Ready = {
trigger?: Trigger trigger?: Trigger
} }
/**
* Options for running a daemon as a shell command inside a subcontainer.
* Includes the command to run, optional signal/timeout, environment, user, and stdio callbacks.
*/
export type ExecCommandOptions = { export type ExecCommandOptions = {
command: T.CommandType command: T.CommandType
// Defaults to the DEFAULT_SIGTERM_TIMEOUT = 30_000ms // Defaults to the DEFAULT_SIGTERM_TIMEOUT = 30_000ms
@@ -61,6 +72,11 @@ export type ExecCommandOptions = {
onStderr?: (chunk: Buffer | string | any) => void onStderr?: (chunk: Buffer | string | any) => void
} }
/**
* Options for running a daemon via an async function that may optionally return
* a command to execute in the subcontainer. The function receives an `AbortSignal`
* for cooperative cancellation.
*/
export type ExecFnOptions< export type ExecFnOptions<
Manifest extends T.SDKManifest, Manifest extends T.SDKManifest,
C extends SubContainer<Manifest> | null, C extends SubContainer<Manifest> | null,
@@ -73,6 +89,10 @@ export type ExecFnOptions<
sigtermTimeout?: number sigtermTimeout?: number
} }
/**
* The execution specification for a daemon: either an {@link ExecFnOptions} (async function)
* or an {@link ExecCommandOptions} (shell command, only valid when a subcontainer is provided).
*/
export type DaemonCommandType< export type DaemonCommandType<
Manifest extends T.SDKManifest, Manifest extends T.SDKManifest,
C extends SubContainer<Manifest> | null, C extends SubContainer<Manifest> | null,
@@ -385,6 +405,13 @@ export class Daemons<Manifest extends T.SDKManifest, Ids extends string>
return null return null
} }
/**
* Gracefully terminate all daemons in reverse dependency order.
*
* Daemons with no remaining dependents are shut down first, proceeding
* until all daemons have been terminated. Falls back to a bulk shutdown
* if a dependency cycle is detected.
*/
async term() { async term() {
const remaining = new Set(this.healthDaemons) const remaining = new Set(this.healthDaemons)
@@ -427,6 +454,10 @@ export class Daemons<Manifest extends T.SDKManifest, Ids extends string>
} }
} }
/**
* Start all registered daemons and their health checks.
* @returns This `Daemons` instance, now running
*/
async build() { async build() {
for (const daemon of this.healthDaemons) { for (const daemon of this.healthDaemons) {
await daemon.updateStatus() await daemon.updateStatus()

View File

@@ -49,6 +49,15 @@ type DependencyOpts<Manifest extends T.SDKManifest> = {
readonly: boolean readonly: boolean
} & SharedOptions } & SharedOptions
/**
* Immutable builder for declaring filesystem mounts into a subcontainer.
*
* Supports mounting volumes, static assets, dependency volumes, and backup directories.
* Each `mount*` method returns a new `Mounts` instance (immutable builder pattern).
*
* @typeParam Manifest - The service manifest type
* @typeParam Backups - Tracks whether backup mounts have been added (type-level flag)
*/
export class Mounts< export class Mounts<
Manifest extends T.SDKManifest, Manifest extends T.SDKManifest,
Backups extends SharedOptions = never, Backups extends SharedOptions = never,
@@ -60,10 +69,19 @@ export class Mounts<
readonly backups: Backups[], readonly backups: Backups[],
) {} ) {}
/**
* Create an empty Mounts builder with no mounts configured.
* @returns A new Mounts instance ready for chaining mount declarations
*/
static of<Manifest extends T.SDKManifest>() { static of<Manifest extends T.SDKManifest>() {
return new Mounts<Manifest>([], [], [], []) return new Mounts<Manifest>([], [], [], [])
} }
/**
* Add a volume mount from the service's own volumes.
* @param options - Volume ID, mountpoint, readonly flag, and optional subpath
* @returns A new Mounts instance with this volume added
*/
mountVolume(options: VolumeOpts<Manifest>) { mountVolume(options: VolumeOpts<Manifest>) {
return new Mounts<Manifest, Backups>( return new Mounts<Manifest, Backups>(
[...this.volumes, options], [...this.volumes, options],
@@ -73,6 +91,11 @@ export class Mounts<
) )
} }
/**
* Add a read-only mount of the service's packaged static assets.
* @param options - Mountpoint and optional subpath within the assets directory
* @returns A new Mounts instance with this asset mount added
*/
mountAssets(options: SharedOptions) { mountAssets(options: SharedOptions) {
return new Mounts<Manifest, Backups>( return new Mounts<Manifest, Backups>(
[...this.volumes], [...this.volumes],
@@ -82,6 +105,11 @@ export class Mounts<
) )
} }
/**
* Add a mount from a dependency package's volume.
* @param options - Dependency ID, volume ID, mountpoint, readonly flag, and optional subpath
* @returns A new Mounts instance with this dependency mount added
*/
mountDependency<DependencyManifest extends T.SDKManifest>( mountDependency<DependencyManifest extends T.SDKManifest>(
options: DependencyOpts<DependencyManifest>, options: DependencyOpts<DependencyManifest>,
) { ) {
@@ -93,6 +121,11 @@ export class Mounts<
) )
} }
/**
* Add a mount of the backup directory. Only valid during backup/restore operations.
* @param options - Mountpoint and optional subpath within the backup directory
* @returns A new Mounts instance with this backup mount added
*/
mountBackups(options: SharedOptions) { mountBackups(options: SharedOptions) {
return new Mounts< return new Mounts<
Manifest, Manifest,
@@ -108,6 +141,11 @@ export class Mounts<
) )
} }
/**
* Compile all declared mounts into the low-level mount array consumed by the subcontainer runtime.
* @throws If any two mounts share the same mountpoint
* @returns An array of `{ mountpoint, options }` objects
*/
build(): MountArray { build(): MountArray {
const mountpoints = new Set() const mountpoints = new Set()
for (let mountpoint of this.volumes for (let mountpoint of this.volumes

View File

@@ -3,6 +3,7 @@ import { Daemons } from './Daemons'
import '../../../base/lib/interfaces/ServiceInterfaceBuilder' import '../../../base/lib/interfaces/ServiceInterfaceBuilder'
import '../../../base/lib/interfaces/Origin' import '../../../base/lib/interfaces/Origin'
/** Default time in milliseconds to wait for a process to exit after SIGTERM before escalating to SIGKILL */
export const DEFAULT_SIGTERM_TIMEOUT = 60_000 export const DEFAULT_SIGTERM_TIMEOUT = 60_000
/** /**
* Used to ensure that the main function is running with the valid proofs. * Used to ensure that the main function is running with the valid proofs.

View File

@@ -24,6 +24,15 @@ export function setupManifest<
return manifest return manifest
} }
/**
* Build the final publishable manifest by combining the SDK manifest definition
* with version graph metadata, OS version, SDK version, and computed fields
* (migration ranges, hardware requirements, alerts, etc.).
*
* @param versions - The service's VersionGraph, used to extract the current version, release notes, and migration ranges
* @param manifest - The SDK manifest definition (from `setupManifest`)
* @returns A fully resolved Manifest ready for packaging
*/
export function buildManifest< export function buildManifest<
Id extends string, Id extends string,
Version extends string, Version extends string,

View File

@@ -69,6 +69,14 @@ async function bind(
await execFile('mount', [...args, from, to]) await execFile('mount', [...args, from, to])
} }
/**
* Interface representing an isolated container environment for running service processes.
*
* Provides methods for executing commands, spawning processes, mounting filesystems,
* and writing files within the container's rootfs. Comes in two flavors:
* {@link SubContainerOwned} (owns the underlying filesystem) and
* {@link SubContainerRc} (reference-counted handle to a shared container).
*/
export interface SubContainer< export interface SubContainer<
Manifest extends T.SDKManifest, Manifest extends T.SDKManifest,
Effects extends T.Effects = T.Effects, Effects extends T.Effects = T.Effects,
@@ -84,6 +92,11 @@ export interface SubContainer<
*/ */
subpath(path: string): string subpath(path: string): string
/**
* Apply filesystem mounts (volumes, assets, dependencies, backups) to this subcontainer.
* @param mounts - The Mounts configuration to apply
* @returns This subcontainer instance for chaining
*/
mount( mount(
mounts: Effects extends BackupEffects mounts: Effects extends BackupEffects
? Mounts< ? Mounts<
@@ -96,6 +109,7 @@ export interface SubContainer<
: Mounts<Manifest, never>, : Mounts<Manifest, never>,
): Promise<this> ): Promise<this>
/** Destroy this subcontainer and clean up its filesystem */
destroy: () => Promise<null> destroy: () => Promise<null>
/** /**
@@ -136,11 +150,22 @@ export interface SubContainer<
stderr: string | Buffer stderr: string | Buffer
}> }>
/**
* Launch a command as the init (PID 1) process of the subcontainer.
* Replaces the current leader process.
* @param command - The command and arguments to execute
* @param options - Optional environment, working directory, and user overrides
*/
launch( launch(
command: string[], command: string[],
options?: CommandOptions, options?: CommandOptions,
): Promise<cp.ChildProcessWithoutNullStreams> ): Promise<cp.ChildProcessWithoutNullStreams>
/**
* Spawn a command inside the subcontainer as a non-init process.
* @param command - The command and arguments to execute
* @param options - Optional environment, working directory, user, and stdio overrides
*/
spawn( spawn(
command: string[], command: string[],
options?: CommandOptions & StdioOptions, options?: CommandOptions & StdioOptions,
@@ -162,8 +187,13 @@ export interface SubContainer<
options?: Parameters<typeof fs.writeFile>[2], options?: Parameters<typeof fs.writeFile>[2],
): Promise<void> ): Promise<void>
/**
* Create a reference-counted handle to this subcontainer.
* The underlying container is only destroyed when all handles are released.
*/
rc(): SubContainerRc<Manifest, Effects> rc(): SubContainerRc<Manifest, Effects>
/** Returns true if this is an owned subcontainer (not a reference-counted handle) */
isOwned(): this is SubContainerOwned<Manifest, Effects> isOwned(): this is SubContainerOwned<Manifest, Effects>
} }
@@ -679,6 +709,12 @@ export class SubContainerOwned<
} }
} }
/**
* A reference-counted handle to a {@link SubContainerOwned}.
*
* Multiple `SubContainerRc` instances can share one underlying subcontainer.
* The subcontainer is destroyed only when the last reference is released via `destroy()`.
*/
export class SubContainerRc< export class SubContainerRc<
Manifest extends T.SDKManifest, Manifest extends T.SDKManifest,
Effects extends T.Effects = T.Effects, Effects extends T.Effects = T.Effects,
@@ -901,14 +937,17 @@ export type StdioOptions = {
stdio?: cp.IOType stdio?: cp.IOType
} }
/** UID/GID mapping for mount id-remapping (see kernel idmappings docs) */
export type IdMap = { fromId: number; toId: number; range: number } export type IdMap = { fromId: number; toId: number; range: number }
/** Union of all mount option types supported by the subcontainer runtime */
export type MountOptions = export type MountOptions =
| MountOptionsVolume | MountOptionsVolume
| MountOptionsAssets | MountOptionsAssets
| MountOptionsPointer | MountOptionsPointer
| MountOptionsBackup | MountOptionsBackup
/** Mount options for binding a service volume into a subcontainer */
export type MountOptionsVolume = { export type MountOptionsVolume = {
type: 'volume' type: 'volume'
volumeId: string volumeId: string
@@ -918,6 +957,7 @@ export type MountOptionsVolume = {
idmap: IdMap[] idmap: IdMap[]
} }
/** Mount options for binding packaged static assets into a subcontainer */
export type MountOptionsAssets = { export type MountOptionsAssets = {
type: 'assets' type: 'assets'
subpath: string | null subpath: string | null
@@ -925,6 +965,7 @@ export type MountOptionsAssets = {
idmap: { fromId: number; toId: number; range: number }[] idmap: { fromId: number; toId: number; range: number }[]
} }
/** Mount options for binding a dependency package's volume into a subcontainer */
export type MountOptionsPointer = { export type MountOptionsPointer = {
type: 'pointer' type: 'pointer'
packageId: string packageId: string
@@ -934,6 +975,7 @@ export type MountOptionsPointer = {
idmap: { fromId: number; toId: number; range: number }[] idmap: { fromId: number; toId: number; range: number }[]
} }
/** Mount options for binding the backup directory into a subcontainer */
export type MountOptionsBackup = { export type MountOptionsBackup = {
type: 'backup' type: 'backup'
subpath: string | null subpath: string | null
@@ -944,6 +986,10 @@ function wait(time: number) {
return new Promise((resolve) => setTimeout(resolve, time)) return new Promise((resolve) => setTimeout(resolve, time))
} }
/**
* Error thrown when a subcontainer command exits with a non-zero code or signal.
* Contains the full result including stdout, stderr, exit code, and exit signal.
*/
export class ExitError extends Error { export class ExitError extends Error {
constructor( constructor(
readonly command: string, readonly command: string,

View File

@@ -84,8 +84,17 @@ function filterUndefined<A>(a: A): A {
return a return a
} }
/**
* Bidirectional transformers for converting between the raw file format and
* the application-level data type. Used with FileHelper factory methods.
*
* @typeParam Raw - The native type the file format parses to (e.g. `Record<string, unknown>` for JSON)
* @typeParam Transformed - The application-level type after transformation
*/
export type Transformers<Raw = unknown, Transformed = unknown> = { export type Transformers<Raw = unknown, Transformed = unknown> = {
/** Transform raw parsed data into the application type */
onRead: (value: Raw) => Transformed onRead: (value: Raw) => Transformed
/** Transform application data back into the raw format for writing */
onWrite: (value: Transformed) => Raw onWrite: (value: Transformed) => Raw
} }
@@ -343,6 +352,19 @@ export class FileHelper<A> {
) )
} }
/**
* Create a reactive reader for this file.
*
* Returns an object with multiple read strategies:
* - `once()` - Read the file once and return the parsed value
* - `const(effects)` - Read once but re-read when the file changes (for use with constRetry)
* - `watch(effects)` - Async generator yielding new values on each file change
* - `onChange(effects, callback)` - Fire a callback on each file change
* - `waitFor(effects, predicate)` - Block until the file value satisfies a predicate
*
* @param map - Optional transform function applied after validation
* @param eq - Optional equality function to deduplicate watch emissions
*/
read(): ReadType<A> read(): ReadType<A>
read<B>( read<B>(
map: (value: A) => B, map: (value: A) => B,
@@ -575,6 +597,11 @@ export class FileHelper<A> {
) )
} }
/**
* Create a File Helper for a .ini file.
*
* Supports optional encode/decode options and custom transformers.
*/
static ini<A extends Record<string, unknown>>( static ini<A extends Record<string, unknown>>(
path: ToPath, path: ToPath,
shape: Validator<Record<string, unknown>, A>, shape: Validator<Record<string, unknown>, A>,
@@ -601,6 +628,11 @@ export class FileHelper<A> {
) )
} }
/**
* Create a File Helper for a .env file (KEY=VALUE format, one per line).
*
* Lines starting with `#` are treated as comments and ignored on read.
*/
static env<A extends Record<string, string>>( static env<A extends Record<string, string>>(
path: ToPath, path: ToPath,
shape: Validator<Record<string, string>, A>, shape: Validator<Record<string, string>, A>,

View File

@@ -12,6 +12,11 @@ import {
import { Graph, Vertex, once } from '../util' import { Graph, Vertex, once } from '../util'
import { IMPOSSIBLE, VersionInfo } from './VersionInfo' import { IMPOSSIBLE, VersionInfo } from './VersionInfo'
/**
* Read the current data version from the effects system.
* @param effects - The effects context
* @returns The parsed ExtendedVersion or VersionRange, or null if no version is set
*/
export async function getDataVersion(effects: T.Effects) { export async function getDataVersion(effects: T.Effects) {
const versionStr = await effects.getDataVersion() const versionStr = await effects.getDataVersion()
if (!versionStr) return null if (!versionStr) return null
@@ -22,6 +27,11 @@ export async function getDataVersion(effects: T.Effects) {
} }
} }
/**
* Persist a data version to the effects system.
* @param effects - The effects context
* @param version - The version to set, or null to clear it
*/
export async function setDataVersion( export async function setDataVersion(
effects: T.Effects, effects: T.Effects,
version: ExtendedVersion | VersionRange | null, version: ExtendedVersion | VersionRange | null,
@@ -37,6 +47,14 @@ function isRange(v: ExtendedVersion | VersionRange): v is VersionRange {
return 'satisfiedBy' in v return 'satisfiedBy' in v
} }
/**
* Check whether two version specifiers overlap (i.e. share at least one common version).
* Works with any combination of ExtendedVersion and VersionRange.
*
* @param a - First version or range
* @param b - Second version or range
* @returns True if the two specifiers overlap
*/
export function overlaps( export function overlaps(
a: ExtendedVersion | VersionRange, a: ExtendedVersion | VersionRange,
b: ExtendedVersion | VersionRange, b: ExtendedVersion | VersionRange,
@@ -49,6 +67,16 @@ export function overlaps(
) )
} }
/**
* A directed graph of service versions and their migration paths.
*
* Builds a graph from {@link VersionInfo} definitions, then uses shortest-path
* search to find and execute migration sequences between any two versions.
* Implements both {@link InitScript} (for install/update migrations) and
* {@link UninitScript} (for uninstall/downgrade migrations).
*
* @typeParam CurrentVersion - The string literal type of the current service version
*/
export class VersionGraph<CurrentVersion extends string> export class VersionGraph<CurrentVersion extends string>
implements InitScript, UninitScript implements InitScript, UninitScript
{ {
@@ -58,6 +86,7 @@ export class VersionGraph<CurrentVersion extends string>
ExtendedVersion | VersionRange, ExtendedVersion | VersionRange,
((opts: { effects: T.Effects }) => Promise<void>) | undefined ((opts: { effects: T.Effects }) => Promise<void>) | undefined
> >
/** Dump the version graph as a human-readable string for debugging */
dump(): string { dump(): string {
return this.graph().dump((metadata) => metadata?.toString()) return this.graph().dump((metadata) => metadata?.toString())
} }
@@ -168,6 +197,18 @@ export class VersionGraph<CurrentVersion extends string>
>(options: { current: VersionInfo<CurrentVersion>; other: OtherVersions }) { >(options: { current: VersionInfo<CurrentVersion>; other: OtherVersions }) {
return new VersionGraph(options.current, options.other) return new VersionGraph(options.current, options.other)
} }
/**
* Execute the shortest migration path between two versions.
*
* Finds the shortest path in the version graph from `from` to `to`,
* executes each migration step in order, and updates the data version after each step.
*
* @param options.effects - The effects context
* @param options.from - The source version or range
* @param options.to - The target version or range
* @returns The final data version after migration
* @throws If no migration path exists between the two versions
*/
async migrate({ async migrate({
effects, effects,
from, from,
@@ -217,6 +258,10 @@ export class VersionGraph<CurrentVersion extends string>
`cannot migrate from ${from.toString()} to ${to.toString()}`, `cannot migrate from ${from.toString()} to ${to.toString()}`,
) )
} }
/**
* Compute the version range from which the current version can be reached via migration.
* Uses reverse breadth-first search from the current version vertex.
*/
canMigrateFrom = once(() => canMigrateFrom = once(() =>
Array.from( Array.from(
this.graph().reverseBreadthFirstSearch((v) => this.graph().reverseBreadthFirstSearch((v) =>
@@ -234,6 +279,10 @@ export class VersionGraph<CurrentVersion extends string>
) )
.normalize(), .normalize(),
) )
/**
* Compute the version range that the current version can migrate to.
* Uses forward breadth-first search from the current version vertex.
*/
canMigrateTo = once(() => canMigrateTo = once(() =>
Array.from( Array.from(
this.graph().breadthFirstSearch((v) => this.graph().breadthFirstSearch((v) =>
@@ -252,6 +301,11 @@ export class VersionGraph<CurrentVersion extends string>
.normalize(), .normalize(),
) )
/**
* InitScript implementation: migrate from the stored data version to the current version.
* If no data version exists (fresh install), sets it to the current version.
* @param effects - The effects context
*/
async init(effects: T.Effects): Promise<void> { async init(effects: T.Effects): Promise<void> {
const from = await getDataVersion(effects) const from = await getDataVersion(effects)
if (from) { if (from) {
@@ -265,6 +319,13 @@ export class VersionGraph<CurrentVersion extends string>
} }
} }
/**
* UninitScript implementation: migrate from the current data version to the target version.
* Used during uninstall or downgrade to prepare data for the target version.
*
* @param effects - The effects context
* @param target - The target version to migrate to, or null to clear the data version
*/
async uninit( async uninit(
effects: T.Effects, effects: T.Effects,
target: VersionRange | ExtendedVersion | null, target: VersionRange | ExtendedVersion | null,

View File

@@ -1,8 +1,17 @@
import { ValidateExVer } from '../../../base/lib/exver' import { ValidateExVer } from '../../../base/lib/exver'
import * as T from '../../../base/lib/types' import * as T from '../../../base/lib/types'
/**
* Sentinel value indicating that a migration in a given direction is not possible.
* Use this for `migrations.up` or `migrations.down` to prevent migration.
*/
export const IMPOSSIBLE: unique symbol = Symbol('IMPOSSIBLE') export const IMPOSSIBLE: unique symbol = Symbol('IMPOSSIBLE')
/**
* Configuration options for a single service version definition.
*
* @typeParam Version - The string literal exver version number
*/
export type VersionOptions<Version extends string> = { export type VersionOptions<Version extends string> = {
/** The exver-compliant version number */ /** The exver-compliant version number */
version: Version & ValidateExVer<Version> version: Version & ValidateExVer<Version>
@@ -33,6 +42,14 @@ export type VersionOptions<Version extends string> = {
} }
} }
/**
* Represents a single version of the service, including its release notes,
* migration scripts, and backwards-compatibility declarations.
*
* By convention, each version gets its own file (e.g. `versions/v1_0_0.ts`).
*
* @typeParam Version - The string literal exver version number
*/
export class VersionInfo<Version extends string> { export class VersionInfo<Version extends string> {
private _version: null | Version = null private _version: null | Version = null
private constructor( private constructor(

View File

@@ -1,12 +1,12 @@
{ {
"name": "@start9labs/start-sdk", "name": "@start9labs/start-sdk",
"version": "0.4.0-beta.51", "version": "0.4.0-beta.52",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@start9labs/start-sdk", "name": "@start9labs/start-sdk",
"version": "0.4.0-beta.51", "version": "0.4.0-beta.52",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@iarna/toml": "^3.0.0", "@iarna/toml": "^3.0.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@start9labs/start-sdk", "name": "@start9labs/start-sdk",
"version": "0.4.0-beta.51", "version": "0.4.0-beta.52",
"description": "Software development kit to facilitate packaging services for StartOS", "description": "Software development kit to facilitate packaging services for StartOS",
"main": "./package/lib/index.js", "main": "./package/lib/index.js",
"types": "./package/lib/index.d.ts", "types": "./package/lib/index.d.ts",

View File

@@ -116,19 +116,6 @@ export default class ServiceAboutRoute {
}, },
], ],
}, },
{
header: 'Source Code',
items: [
{
name: 'Upstream service',
value: manifest.upstreamRepo,
},
{
name: 'StartOS package',
value: manifest.packageRepo,
},
],
},
{ {
header: 'Links', header: 'Links',
items: [ items: [
@@ -146,6 +133,19 @@ export default class ServiceAboutRoute {
}, },
], ],
}, },
{
header: 'Source Code',
items: [
{
name: 'Upstream service',
value: manifest.upstreamRepo,
},
{
name: 'StartOS package',
value: manifest.packageRepo,
},
],
},
] ]
}), }),
), ),

View File

@@ -1,5 +1,10 @@
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import {
ChangeDetectionStrategy,
Component,
inject,
signal,
} from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterLink } from '@angular/router' import { RouterLink } from '@angular/router'
import { import {
@@ -10,11 +15,11 @@ import {
i18nPipe, i18nPipe,
LoadingService, LoadingService,
} from '@start9labs/shared' } from '@start9labs/shared'
import { inputSpec, IST } from '@start9labs/start-sdk' import { inputSpec } from '@start9labs/start-sdk'
import { TuiButton, TuiTextfield, TuiTitle } from '@taiga-ui/core' import { TuiButton, TuiTextfield, TuiTitle } from '@taiga-ui/core'
import { TuiHeader } from '@taiga-ui/layout' import { TuiHeader } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { switchMap, tap } from 'rxjs' import { Subscription, switchMap, tap } from 'rxjs'
import { FormGroupComponent } from 'src/app/routes/portal/components/form/containers/group.component' import { FormGroupComponent } from 'src/app/routes/portal/components/form/containers/group.component'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormService } from 'src/app/services/form.service' import { FormService } from 'src/app/services/form.service'
@@ -22,6 +27,32 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service' import { TitleDirective } from 'src/app/services/title.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
const PROVIDER_HINTS: Record<string, string> = {
gmail:
'Requires an App Password. Enable 2FA in your Google account, then generate an App Password.',
ses: 'Use SMTP credentials (not IAM credentials). Update the host to match your SES region.',
sendgrid:
"Username is 'apikey' (literal). Password is your SendGrid API key.",
mailgun: 'Use SMTP credentials from your Mailgun domain settings.',
protonmail:
'Requires a Proton for Business account. Use your Proton email as username.',
}
function detectProviderKey(host: string | undefined): string {
if (!host) return 'other'
const providers: Record<string, string> = {
'smtp.gmail.com': 'gmail',
'smtp.sendgrid.net': 'sendgrid',
'smtp.mailgun.org': 'mailgun',
'smtp.protonmail.ch': 'protonmail',
}
for (const [h, key] of Object.entries(providers)) {
if (host === h) return key
}
if (host.endsWith('.amazonaws.com')) return 'ses'
return 'other'
}
@Component({ @Component({
template: ` template: `
<ng-container *title> <ng-container *title>
@@ -52,6 +83,9 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
@if (spec | async; as resolved) { @if (spec | async; as resolved) {
<form-group [spec]="resolved" /> <form-group [spec]="resolved" />
} }
@if (providerHint()) {
<p class="provider-hint">{{ providerHint() }}</p>
}
<footer> <footer>
@if (isSaved) { @if (isSaved) {
<button <button
@@ -116,6 +150,12 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
footer { footer {
justify-content: flex-end; justify-content: flex-end;
} }
.provider-hint {
margin: 0.5rem 0 0;
font-size: 0.85rem;
opacity: 0.7;
}
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
@@ -142,27 +182,45 @@ export default class SystemEmailComponent {
private readonly api = inject(ApiService) private readonly api = inject(ApiService)
private readonly i18n = inject(i18nPipe) private readonly i18n = inject(i18nPipe)
readonly providerHint = signal('')
private providerSub: Subscription | null = null
testAddress = '' testAddress = ''
isSaved = false isSaved = false
readonly spec: Promise<IST.InputSpec> = configBuilderToSpec( readonly spec = configBuilderToSpec(inputSpec.constants.systemSmtpSpec)
inputSpec.constants.customSmtp,
)
readonly form$ = this.patch.watch$('serverInfo', 'smtp').pipe( readonly form$ = this.patch.watch$('serverInfo', 'smtp').pipe(
tap(value => (this.isSaved = !!value)), tap(value => {
switchMap(async value => this.isSaved = !!value
this.formService.createForm(await this.spec, value), }),
), switchMap(async value => {
const spec = await this.spec
const formData = value
? { provider: { selection: detectProviderKey(value.host), value } }
: undefined
const form = this.formService.createForm(spec, formData)
// Watch provider selection for hints
this.providerSub?.unsubscribe()
const selectionCtrl = form.get('provider.selection')
if (selectionCtrl) {
this.providerHint.set(PROVIDER_HINTS[selectionCtrl.value] || '')
this.providerSub = selectionCtrl.valueChanges.subscribe(key => {
this.providerHint.set(PROVIDER_HINTS[key] || '')
})
}
return form
}),
) )
async save( async save(formValue: Record<string, any> | null): Promise<void> {
value: typeof inputSpec.constants.customSmtp._TYPE | null,
): Promise<void> {
const loader = this.loader.open('Saving').subscribe() const loader = this.loader.open('Saving').subscribe()
try { try {
if (value) { if (formValue) {
await this.api.setSmtp(value) await this.api.setSmtp(formValue['provider'].value)
this.isSaved = true this.isSaved = true
} else { } else {
await this.api.clearSmtp({}) await this.api.clearSmtp({})
@@ -175,15 +233,16 @@ export default class SystemEmailComponent {
} }
} }
async sendTestEmail(value: typeof inputSpec.constants.customSmtp._TYPE) { async sendTestEmail(formValue: Record<string, any>) {
const smtpValue = formValue['provider'].value
const loader = this.loader.open('Sending email').subscribe() const loader = this.loader.open('Sending email').subscribe()
const success = const success =
`${this.i18n.transform('A test email has been sent to')} ${this.testAddress}. <i>${this.i18n.transform('Check your spam folder and mark as not spam.')}</i>` as i18nKey `${this.i18n.transform('A test email has been sent to')} ${this.testAddress}. <i>${this.i18n.transform('Check your spam folder and mark as not spam.')}</i>` as i18nKey
try { try {
await this.api.testSmtp({ await this.api.testSmtp({
...value, ...smtpValue,
password: value.password || '', password: smtpValue.password || '',
to: this.testAddress, to: this.testAddress,
}) })
this.dialog this.dialog