mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 10:21:52 +00:00
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:
@@ -3145,7 +3145,7 @@ help.arg.smtp-from:
|
||||
fr_FR: "Adresse de l'expéditeur"
|
||||
pl_PL: "Adres nadawcy e-mail"
|
||||
|
||||
help.arg.smtp-login:
|
||||
help.arg.smtp-username:
|
||||
en_US: "SMTP authentication username"
|
||||
de_DE: "SMTP-Authentifizierungsbenutzername"
|
||||
es_ES: "Nombre de usuario de autenticación SMTP"
|
||||
@@ -3166,13 +3166,20 @@ help.arg.smtp-port:
|
||||
fr_FR: "Port du serveur SMTP"
|
||||
pl_PL: "Port serwera SMTP"
|
||||
|
||||
help.arg.smtp-server:
|
||||
help.arg.smtp-host:
|
||||
en_US: "SMTP server hostname"
|
||||
de_DE: "SMTP-Server-Hostname"
|
||||
es_ES: "Nombre de host del servidor SMTP"
|
||||
fr_FR: "Nom d'hôte du serveur 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:
|
||||
en_US: "Email recipient address"
|
||||
de_DE: "E-Mail-Empfängeradresse"
|
||||
|
||||
@@ -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)]
|
||||
#[ts(export)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SmtpValue {
|
||||
#[arg(long, help = "help.arg.smtp-server")]
|
||||
pub server: String,
|
||||
#[arg(long, help = "help.arg.smtp-host")]
|
||||
#[serde(alias = "server")]
|
||||
pub host: String,
|
||||
#[arg(long, help = "help.arg.smtp-port")]
|
||||
pub port: u16,
|
||||
#[arg(long, help = "help.arg.smtp-from")]
|
||||
pub from: String,
|
||||
#[arg(long, help = "help.arg.smtp-login")]
|
||||
pub login: String,
|
||||
#[arg(long, help = "help.arg.smtp-username")]
|
||||
#[serde(alias = "login")]
|
||||
pub username: String,
|
||||
#[arg(long, help = "help.arg.smtp-password")]
|
||||
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> {
|
||||
let smtp = Some(smtp);
|
||||
@@ -1121,47 +1137,63 @@ pub async fn set_ifconfig_url(
|
||||
#[ts(export)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TestSmtpParams {
|
||||
#[arg(long, help = "help.arg.smtp-server")]
|
||||
pub server: String,
|
||||
#[arg(long, help = "help.arg.smtp-host")]
|
||||
pub host: String,
|
||||
#[arg(long, help = "help.arg.smtp-port")]
|
||||
pub port: u16,
|
||||
#[arg(long, help = "help.arg.smtp-from")]
|
||||
pub from: String,
|
||||
#[arg(long, help = "help.arg.smtp-to")]
|
||||
pub to: String,
|
||||
#[arg(long, help = "help.arg.smtp-login")]
|
||||
pub login: String,
|
||||
#[arg(long, help = "help.arg.smtp-username")]
|
||||
pub username: String,
|
||||
#[arg(long, help = "help.arg.smtp-password")]
|
||||
pub password: String,
|
||||
#[arg(long, help = "help.arg.smtp-security")]
|
||||
#[serde(default)]
|
||||
pub security: SmtpSecurity,
|
||||
}
|
||||
pub async fn test_smtp(
|
||||
_: RpcContext,
|
||||
TestSmtpParams {
|
||||
server,
|
||||
host,
|
||||
port,
|
||||
from,
|
||||
to,
|
||||
login,
|
||||
username,
|
||||
password,
|
||||
security,
|
||||
}: TestSmtpParams,
|
||||
) -> Result<(), Error> {
|
||||
use lettre::message::header::ContentType;
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::transport::smtp::client::{Tls, TlsParameters};
|
||||
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
|
||||
|
||||
AsyncSmtpTransport::<Tokio1Executor>::relay(&server)?
|
||||
.port(port)
|
||||
.credentials(Credentials::new(login, password))
|
||||
.build()
|
||||
.send(
|
||||
Message::builder()
|
||||
.from(from.parse()?)
|
||||
.to(to.parse()?)
|
||||
.subject("StartOS Test Email")
|
||||
.header(ContentType::TEXT_PLAIN)
|
||||
.body("This is a test email sent from your StartOS Server".to_owned())?,
|
||||
)
|
||||
.await?;
|
||||
let creds = Credentials::new(username, password);
|
||||
let message = Message::builder()
|
||||
.from(from.parse()?)
|
||||
.to(to.parse()?)
|
||||
.subject("StartOS Test Email")
|
||||
.header(ContentType::TEXT_PLAIN)
|
||||
.body("This is a test email sent from your StartOS Server".to_owned())?;
|
||||
|
||||
let transport = match security {
|
||||
SmtpSecurity::Starttls => AsyncSmtpTransport::<Tokio1Executor>::relay(&host)?
|
||||
.port(port)
|
||||
.credentials(creds)
|
||||
.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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -166,6 +166,9 @@ impl VersionT for Version {
|
||||
// Rebuild from actual assigned ports in all bindings
|
||||
migrate_available_ports(db);
|
||||
|
||||
// Migrate SMTP: rename server->host, login->username, add security field
|
||||
migrate_smtp(db);
|
||||
|
||||
// Delete ui.name (moved to serverInfo.name)
|
||||
if let Some(ui) = db
|
||||
.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 {
|
||||
let mut cap = true;
|
||||
s.chars()
|
||||
|
||||
@@ -6,19 +6,28 @@ import { z } from 'zod'
|
||||
import { DeepPartial } from '../../../types'
|
||||
import { InputSpecTools, createInputSpecTools } from './inputSpecTools'
|
||||
|
||||
/** Options passed to a lazy builder function when resolving dynamic form field values. */
|
||||
export type LazyBuildOptions<Type> = {
|
||||
/** The effects interface for runtime operations (e.g. reading files, querying state). */
|
||||
effects: Effects
|
||||
/** Previously saved form data to pre-fill the form with, or `null` for fresh creation. */
|
||||
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> = (
|
||||
options: LazyBuildOptions<Type>,
|
||||
) => Promise<ExpectedOut> | ExpectedOut
|
||||
|
||||
/** Extracts the runtime type from an {@link InputSpec}. */
|
||||
// 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 :
|
||||
never
|
||||
|
||||
/** Extracts the static validation type from an {@link InputSpec}. */
|
||||
export type ExtractInputSpecStaticValidatedAs<
|
||||
A extends InputSpec<any, Record<string, any>>,
|
||||
> = 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 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>> = {
|
||||
[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
|
||||
/**
|
||||
* 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 _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<{
|
||||
spec: {
|
||||
[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>>(
|
||||
key: Key,
|
||||
build: V | ((tools: InputSpecTools<Type>) => V),
|
||||
@@ -146,6 +168,11 @@ export class InputSpec<
|
||||
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>>>(
|
||||
build: AddSpec | ((tools: InputSpecTools<Type>) => AddSpec),
|
||||
): InputSpec<
|
||||
@@ -174,6 +201,17 @@ export class InputSpec<
|
||||
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) {
|
||||
const validator = z.object(
|
||||
Object.fromEntries(
|
||||
|
||||
@@ -9,6 +9,14 @@ import {
|
||||
} from '../inputSpecTypes'
|
||||
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<
|
||||
Type extends StaticValidatedAs,
|
||||
StaticValidatedAs = Type,
|
||||
@@ -26,6 +34,12 @@ export class List<
|
||||
) {}
|
||||
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(
|
||||
a: {
|
||||
name: string
|
||||
@@ -97,6 +111,7 @@ export class List<
|
||||
}, validator)
|
||||
}
|
||||
|
||||
/** Like {@link List.text} but options are resolved lazily at runtime via a builder function. */
|
||||
static dynamicText<OuterType = unknown>(
|
||||
getA: LazyBuild<
|
||||
{
|
||||
@@ -150,6 +165,12 @@ export class List<
|
||||
}, 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<
|
||||
Type extends StaticValidatedAs,
|
||||
StaticValidatedAs extends Record<string, any>,
|
||||
|
||||
@@ -15,12 +15,15 @@ import { _, once } from '../../../util'
|
||||
import { z } from 'zod'
|
||||
import { DeepPartial } from '../../../types'
|
||||
|
||||
/** Zod schema for a file upload result — validates `{ path, commitment: { hash, size } }`. */
|
||||
export const fileInfoParser = z.object({
|
||||
path: z.string(),
|
||||
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>
|
||||
|
||||
/** Conditional type: returns `T` if `Required` is `true`, otherwise `T | null`. */
|
||||
export type AsRequired<T, Required extends boolean> = Required extends true
|
||||
? T
|
||||
: T | null
|
||||
@@ -37,6 +40,19 @@ function asRequiredParser<Type, Input extends { required: boolean }>(
|
||||
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<
|
||||
Type extends StaticValidatedAs,
|
||||
StaticValidatedAs = Type,
|
||||
@@ -99,6 +115,7 @@ export class Value<
|
||||
validator,
|
||||
)
|
||||
}
|
||||
/** Like {@link Value.toggle} but options are resolved lazily at runtime via a builder function. */
|
||||
static dynamicToggle<OuterType = unknown>(
|
||||
a: LazyBuild<
|
||||
{
|
||||
@@ -225,6 +242,7 @@ export class Value<
|
||||
validator,
|
||||
)
|
||||
}
|
||||
/** Like {@link Value.text} but options are resolved lazily at runtime via a builder function. */
|
||||
static dynamicText<Required extends boolean, OuterType = unknown>(
|
||||
getA: LazyBuild<
|
||||
{
|
||||
@@ -345,6 +363,7 @@ export class Value<
|
||||
return { spec: built, validator }
|
||||
}, validator)
|
||||
}
|
||||
/** Like {@link Value.textarea} but options are resolved lazily at runtime via a builder function. */
|
||||
static dynamicTextarea<Required extends boolean, OuterType = unknown>(
|
||||
getA: LazyBuild<
|
||||
{
|
||||
@@ -467,6 +486,7 @@ export class Value<
|
||||
validator,
|
||||
)
|
||||
}
|
||||
/** Like {@link Value.number} but options are resolved lazily at runtime via a builder function. */
|
||||
static dynamicNumber<Required extends boolean, OuterType = unknown>(
|
||||
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>(
|
||||
getA: LazyBuild<
|
||||
{
|
||||
@@ -659,6 +680,7 @@ export class Value<
|
||||
validator,
|
||||
)
|
||||
}
|
||||
/** Like {@link Value.datetime} but options are resolved lazily at runtime via a builder function. */
|
||||
static dynamicDatetime<Required extends boolean, OuterType = unknown>(
|
||||
getA: LazyBuild<
|
||||
{
|
||||
@@ -769,6 +791,7 @@ export class Value<
|
||||
validator,
|
||||
)
|
||||
}
|
||||
/** Like {@link Value.select} but options are resolved lazily at runtime via a builder function. */
|
||||
static dynamicSelect<
|
||||
Values extends Record<string, string>,
|
||||
OuterType = unknown,
|
||||
@@ -889,6 +912,7 @@ export class Value<
|
||||
validator,
|
||||
)
|
||||
}
|
||||
/** Like {@link Value.multiselect} but options are resolved lazily at runtime via a builder function. */
|
||||
static dynamicMultiselect<
|
||||
Values extends Record<string, string>,
|
||||
OuterType = unknown,
|
||||
@@ -977,6 +1001,12 @@ export class Value<
|
||||
}
|
||||
}, 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: {
|
||||
name: string
|
||||
description?: string | null
|
||||
@@ -1000,6 +1030,7 @@ export class Value<
|
||||
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>(
|
||||
a: LazyBuild<
|
||||
{
|
||||
@@ -1102,6 +1133,7 @@ export class Value<
|
||||
}
|
||||
}, a.variants.validator)
|
||||
}
|
||||
/** Like {@link Value.union} but options (including which variants are available) are resolved lazily at runtime. */
|
||||
static dynamicUnion<
|
||||
VariantValues extends {
|
||||
[K in string]: {
|
||||
@@ -1123,6 +1155,7 @@ export class Value<
|
||||
OuterType
|
||||
>,
|
||||
): Value<UnionRes<VariantValues>, UnionRes<VariantValues>, OuterType>
|
||||
/** Like {@link Value.union} but options are resolved lazily, with an explicit static validator type. */
|
||||
static dynamicUnion<
|
||||
StaticVariantValues extends {
|
||||
[K in string]: {
|
||||
@@ -1300,6 +1333,12 @@ export class Value<
|
||||
}, 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> {
|
||||
return new Value<U, U, OuterType>(async (options) => {
|
||||
const built = await this.build(options)
|
||||
|
||||
@@ -8,6 +8,11 @@ import {
|
||||
} from './inputSpec'
|
||||
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<
|
||||
VariantValues extends {
|
||||
[K in string]: {
|
||||
@@ -28,6 +33,7 @@ export type UnionRes<
|
||||
}
|
||||
}[K]
|
||||
|
||||
/** Like {@link UnionRes} but using the static (Zod-inferred) validated types. */
|
||||
export type UnionResStaticValidatedAs<
|
||||
VariantValues extends {
|
||||
[K in string]: {
|
||||
@@ -118,6 +124,11 @@ export class Variants<
|
||||
>,
|
||||
) {}
|
||||
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<
|
||||
VariantValues extends {
|
||||
[K in string]: {
|
||||
|
||||
@@ -5,42 +5,124 @@ import { Value } from './builder/value'
|
||||
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<
|
||||
InputSpecOf<SmtpValue>
|
||||
>({
|
||||
server: Value.text({
|
||||
name: 'SMTP Server',
|
||||
required: true,
|
||||
default: null,
|
||||
}),
|
||||
port: Value.number({
|
||||
name: 'Port',
|
||||
required: true,
|
||||
default: 587,
|
||||
min: 1,
|
||||
max: 65535,
|
||||
integer: true,
|
||||
}),
|
||||
from: Value.text({
|
||||
name: 'From Address',
|
||||
required: true,
|
||||
default: null,
|
||||
placeholder: 'Example Name <test@example.com>',
|
||||
inputmode: 'email',
|
||||
patterns: [Patterns.emailWithName],
|
||||
}),
|
||||
login: Value.text({
|
||||
name: 'Login',
|
||||
required: true,
|
||||
default: null,
|
||||
}),
|
||||
password: Value.text({
|
||||
name: 'Password',
|
||||
required: false,
|
||||
default: null,
|
||||
masked: true,
|
||||
function smtpFields(
|
||||
defaults: {
|
||||
host?: string
|
||||
port?: number
|
||||
security?: 'starttls' | 'tls'
|
||||
} = {},
|
||||
): InputSpec<SmtpValue> {
|
||||
return InputSpec.of<InputSpecOf<SmtpValue>>({
|
||||
host: Value.text({
|
||||
name: 'Host',
|
||||
required: true,
|
||||
default: defaults.host ?? null,
|
||||
placeholder: 'smtp.example.com',
|
||||
}),
|
||||
port: Value.number({
|
||||
name: 'Port',
|
||||
required: true,
|
||||
default: defaults.port ?? 587,
|
||||
min: 1,
|
||||
max: 65535,
|
||||
integer: true,
|
||||
}),
|
||||
security: Value.select({
|
||||
name: 'Connection Security',
|
||||
default: defaults.security ?? 'starttls',
|
||||
values: {
|
||||
starttls: 'STARTTLS',
|
||||
tls: 'TLS',
|
||||
},
|
||||
}),
|
||||
from: Value.text({
|
||||
name: 'From Address',
|
||||
required: 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.',
|
||||
required: false,
|
||||
default: null,
|
||||
placeholder: '<name>test@example.com',
|
||||
inputmode: 'email',
|
||||
patterns: [Patterns.email],
|
||||
placeholder: 'Name <test@example.com>',
|
||||
patterns: [Patterns.emailWithName],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
custom: {
|
||||
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 }) => {
|
||||
const smtp = await new GetSystemSmtp(effects).once()
|
||||
|
||||
@@ -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>
|
||||
/**
|
||||
* The discriminator for all supported form field types.
|
||||
*/
|
||||
export type ValueType =
|
||||
| 'text'
|
||||
| 'textarea'
|
||||
@@ -13,6 +21,7 @@ export type ValueType =
|
||||
| 'file'
|
||||
| 'union'
|
||||
| 'hidden'
|
||||
/** Union of all concrete form field spec types. Discriminate on the `type` field. */
|
||||
export type ValueSpec = ValueSpecOf<ValueType>
|
||||
/** core spec types. These types provide the metadata for performing validations */
|
||||
// prettier-ignore
|
||||
@@ -32,37 +41,56 @@ export type ValueSpecOf<T extends ValueType> =
|
||||
T extends "hidden" ? ValueSpecHidden :
|
||||
never
|
||||
|
||||
/** Spec for a single-line text input field. */
|
||||
export type ValueSpecText = {
|
||||
/** Display label for the field. */
|
||||
name: string
|
||||
/** Optional help text displayed below the field. */
|
||||
description: string | null
|
||||
/** Optional warning message displayed to the user. */
|
||||
warning: string | null
|
||||
|
||||
type: 'text'
|
||||
/** Regex patterns used to validate the input value. */
|
||||
patterns: Pattern[]
|
||||
/** Minimum character length, or `null` for no minimum. */
|
||||
minLength: number | null
|
||||
/** Maximum character length, or `null` for no maximum. */
|
||||
maxLength: number | null
|
||||
/** Whether the field should obscure input (e.g. for passwords). */
|
||||
masked: boolean
|
||||
|
||||
/** HTML input mode hint for mobile keyboards. */
|
||||
inputmode: 'text' | 'email' | 'tel' | 'url'
|
||||
/** Placeholder text shown when the field is empty. */
|
||||
placeholder: string | null
|
||||
|
||||
/** Whether the field must have a value. */
|
||||
required: boolean
|
||||
/** Default value, which may be a literal string or a {@link RandomString} generation spec. */
|
||||
default: DefaultString | null
|
||||
/** `false` if editable, or a string message explaining why the field is disabled. */
|
||||
disabled: false | string
|
||||
/** If set, provides a "generate" button that fills the field with a random string matching this spec. */
|
||||
generate: null | RandomString
|
||||
/** Whether the field value cannot be changed after initial configuration. */
|
||||
immutable: boolean
|
||||
}
|
||||
/** Spec for a multi-line textarea input field. */
|
||||
export type ValueSpecTextarea = {
|
||||
name: string
|
||||
description: string | null
|
||||
warning: string | null
|
||||
|
||||
type: 'textarea'
|
||||
/** Regex patterns used to validate the input value. */
|
||||
patterns: Pattern[]
|
||||
placeholder: string | null
|
||||
minLength: number | null
|
||||
maxLength: number | null
|
||||
/** Minimum number of visible rows. */
|
||||
minRows: number
|
||||
/** Maximum number of visible rows before scrolling. */
|
||||
maxRows: number
|
||||
required: boolean
|
||||
default: string | null
|
||||
@@ -70,12 +98,18 @@ export type ValueSpecTextarea = {
|
||||
immutable: boolean
|
||||
}
|
||||
|
||||
/** Spec for a numeric input field. */
|
||||
export type ValueSpecNumber = {
|
||||
type: 'number'
|
||||
/** Minimum allowed value, or `null` for unbounded. */
|
||||
min: number | null
|
||||
/** Maximum allowed value, or `null` for unbounded. */
|
||||
max: number | null
|
||||
/** Whether only whole numbers are accepted. */
|
||||
integer: boolean
|
||||
/** Step increment for the input spinner, or `null` for any precision. */
|
||||
step: number | null
|
||||
/** Display label for the unit (e.g. `"MB"`, `"seconds"`), shown next to the field. */
|
||||
units: string | null
|
||||
placeholder: string | null
|
||||
name: string
|
||||
@@ -86,6 +120,7 @@ export type ValueSpecNumber = {
|
||||
disabled: false | string
|
||||
immutable: boolean
|
||||
}
|
||||
/** Spec for a browser-native color picker field. */
|
||||
export type ValueSpecColor = {
|
||||
name: string
|
||||
description: string | null
|
||||
@@ -93,34 +128,44 @@ export type ValueSpecColor = {
|
||||
|
||||
type: 'color'
|
||||
required: boolean
|
||||
/** Default hex color string (e.g. `"#ff0000"`), or `null`. */
|
||||
default: string | null
|
||||
disabled: false | string
|
||||
immutable: boolean
|
||||
}
|
||||
/** Spec for a date, time, or datetime input field. */
|
||||
export type ValueSpecDatetime = {
|
||||
name: string
|
||||
description: string | null
|
||||
warning: string | null
|
||||
type: 'datetime'
|
||||
required: boolean
|
||||
/** Controls which kind of picker is displayed. */
|
||||
inputmode: 'date' | 'time' | 'datetime-local'
|
||||
/** Minimum selectable date/time as an ISO string, or `null`. */
|
||||
min: string | null
|
||||
/** Maximum selectable date/time as an ISO string, or `null`. */
|
||||
max: string | null
|
||||
default: string | null
|
||||
disabled: false | string
|
||||
immutable: boolean
|
||||
}
|
||||
/** Spec for a single-select field displayed as radio buttons in a modal. */
|
||||
export type ValueSpecSelect = {
|
||||
/** Map of option keys to display labels. */
|
||||
values: Record<string, string>
|
||||
name: string
|
||||
description: string | null
|
||||
warning: string | null
|
||||
type: 'select'
|
||||
default: string | null
|
||||
/** `false` if all enabled, a string disabling the whole field, or an array of disabled option keys. */
|
||||
disabled: false | string | string[]
|
||||
immutable: boolean
|
||||
}
|
||||
/** Spec for a multi-select field displayed as checkboxes in a modal. */
|
||||
export type ValueSpecMultiselect = {
|
||||
/** Map of option keys to display labels. */
|
||||
values: Record<string, string>
|
||||
|
||||
name: string
|
||||
@@ -128,12 +173,17 @@ export type ValueSpecMultiselect = {
|
||||
warning: string | null
|
||||
|
||||
type: 'multiselect'
|
||||
/** Minimum number of selections required, or `null`. */
|
||||
minLength: number | null
|
||||
/** Maximum number of selections allowed, or `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[]
|
||||
/** Array of option keys selected by default. */
|
||||
default: string[]
|
||||
immutable: boolean
|
||||
}
|
||||
/** Spec for a boolean toggle (on/off switch). */
|
||||
export type ValueSpecToggle = {
|
||||
name: string
|
||||
description: string | null
|
||||
@@ -144,57 +194,81 @@ export type ValueSpecToggle = {
|
||||
disabled: false | string
|
||||
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 = {
|
||||
name: string
|
||||
description: string | null
|
||||
warning: string | null
|
||||
|
||||
type: 'union'
|
||||
/** Map of variant keys to their display name and nested form spec. */
|
||||
variants: Record<
|
||||
string,
|
||||
{
|
||||
/** Display name for this variant in the dropdown. */
|
||||
name: string
|
||||
/** Nested form spec shown when this variant is selected. */
|
||||
spec: InputSpec
|
||||
}
|
||||
>
|
||||
/** `false` if all enabled, a string disabling the whole field, or an array of disabled variant keys. */
|
||||
disabled: false | string | string[]
|
||||
default: string | null
|
||||
immutable: boolean
|
||||
}
|
||||
/** Spec for a file upload input field. */
|
||||
export type ValueSpecFile = {
|
||||
name: string
|
||||
description: string | null
|
||||
warning: string | null
|
||||
type: 'file'
|
||||
/** Allowed file extensions (e.g. `[".pem", ".crt"]`). */
|
||||
extensions: string[]
|
||||
required: boolean
|
||||
}
|
||||
/** Spec for a collapsible grouping of nested fields (a "sub-form"). */
|
||||
export type ValueSpecObject = {
|
||||
name: string
|
||||
description: string | null
|
||||
warning: string | null
|
||||
type: 'object'
|
||||
/** The nested form spec containing this object's fields. */
|
||||
spec: InputSpec
|
||||
}
|
||||
/** Spec for a hidden field — not displayed to the user but included in the form data. */
|
||||
export type ValueSpecHidden = {
|
||||
type: 'hidden'
|
||||
}
|
||||
/** The two supported list item types. */
|
||||
export type ListValueSpecType = 'text' | 'object'
|
||||
/** Maps a {@link ListValueSpecType} to its concrete list item spec. */
|
||||
// prettier-ignore
|
||||
export type ListValueSpecOf<T extends ListValueSpecType> =
|
||||
export type ListValueSpecOf<T extends ListValueSpecType> =
|
||||
T extends "text" ? ListValueSpecText :
|
||||
T extends "object" ? ListValueSpecObject :
|
||||
never
|
||||
/** A list field spec — union of text-list and object-list variants. */
|
||||
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> = {
|
||||
name: string
|
||||
description: string | null
|
||||
warning: string | null
|
||||
type: 'list'
|
||||
/** The item spec — determines whether this is a list of text values or objects. */
|
||||
spec: ListValueSpecOf<T>
|
||||
/** Minimum number of items, or `null` for no minimum. */
|
||||
minLength: number | null
|
||||
/** Maximum number of items, or `null` for no maximum. */
|
||||
maxLength: number | null
|
||||
disabled: false | string
|
||||
/** Default list items to populate on creation. */
|
||||
default:
|
||||
| string[]
|
||||
| DefaultString[]
|
||||
@@ -203,10 +277,14 @@ export type ValueSpecListOf<T extends ListValueSpecType> = {
|
||||
| readonly DefaultString[]
|
||||
| readonly Record<string, unknown>[]
|
||||
}
|
||||
/** A regex validation pattern with a human-readable description of what it enforces. */
|
||||
export type Pattern = {
|
||||
/** The regex pattern string (without delimiters). */
|
||||
regex: string
|
||||
/** A user-facing explanation shown when validation fails (e.g. `"Must be a valid email"`). */
|
||||
description: string
|
||||
}
|
||||
/** Spec for text items within a list field. */
|
||||
export type ListValueSpecText = {
|
||||
type: 'text'
|
||||
patterns: Pattern[]
|
||||
@@ -218,13 +296,24 @@ export type ListValueSpecText = {
|
||||
inputmode: 'text' | 'email' | 'tel' | 'url'
|
||||
placeholder: string | null
|
||||
}
|
||||
/** Spec for object items within a list field. */
|
||||
export type ListValueSpecObject = {
|
||||
type: 'object'
|
||||
/** The form spec for each object item. */
|
||||
spec: InputSpec
|
||||
/** Defines how uniqueness is determined among list items. */
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 =
|
||||
| null
|
||||
| string
|
||||
@@ -234,12 +323,21 @@ export type 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
|
||||
/** Spec for generating a random string — used for default passwords, API keys, etc. */
|
||||
export type RandomString = {
|
||||
/** The character set to draw from (e.g. `"a-zA-Z0-9"`). */
|
||||
charset: string
|
||||
/** The length of the generated string. */
|
||||
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>(
|
||||
t: ValueSpec,
|
||||
s: S,
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { DeepMap } from 'deep-equality-data-structures'
|
||||
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
|
||||
export type ValidateVersion<T extends String> =
|
||||
T extends `-${infer A}` ? never :
|
||||
@@ -9,12 +20,32 @@ T extends `${infer A}-${string}` ? ValidateVersion<A> :
|
||||
T extends `${bigint}.${infer A}` ? ValidateVersion<A> :
|
||||
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
|
||||
export type ValidateExVer<T extends string> =
|
||||
T extends `#${string}:${infer A}:${infer B}` ? ValidateVersion<A> & ValidateVersion<B> :
|
||||
T extends `${infer A}:${infer B}` ? ValidateVersion<A> & ValidateVersion<B> :
|
||||
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
|
||||
export type ValidateExVers<T> =
|
||||
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 {
|
||||
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 {
|
||||
switch (this.atom.type) {
|
||||
case 'Anchor':
|
||||
@@ -563,38 +617,69 @@ export class VersionRange {
|
||||
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 {
|
||||
return VersionRange.parseRange(
|
||||
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) {
|
||||
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) {
|
||||
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 {
|
||||
return VersionRange.parseRange(
|
||||
P.parse(range, { startRule: 'EmverVersionRange' }),
|
||||
)
|
||||
}
|
||||
|
||||
/** Returns the intersection of this range with another (logical AND). */
|
||||
and(right: VersionRange) {
|
||||
return new VersionRange({ type: 'And', left: this, right })
|
||||
}
|
||||
|
||||
/** Returns the union of this range with another (logical OR). */
|
||||
or(right: VersionRange) {
|
||||
return new VersionRange({ type: 'Or', left: this, right })
|
||||
}
|
||||
|
||||
/** Returns the negation of this range (logical NOT). */
|
||||
not() {
|
||||
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>) {
|
||||
let y = VersionRange.any()
|
||||
for (let x of xs) {
|
||||
@@ -613,6 +698,10 @@ export class VersionRange {
|
||||
return y
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the logical OR (union) of multiple version ranges.
|
||||
* Short-circuits on `any()` and skips `none()`.
|
||||
*/
|
||||
static or(...xs: Array<VersionRange>) {
|
||||
let y = VersionRange.none()
|
||||
for (let x of xs) {
|
||||
@@ -631,14 +720,21 @@ export class VersionRange {
|
||||
return y
|
||||
}
|
||||
|
||||
/** Returns a version range that matches all versions (wildcard `*`). */
|
||||
static any() {
|
||||
return new VersionRange({ type: 'Any' })
|
||||
}
|
||||
|
||||
/** Returns a version range that matches no versions (`!`). */
|
||||
static 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) {
|
||||
return version.satisfies(this)
|
||||
}
|
||||
@@ -714,29 +810,60 @@ export class VersionRange {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns `true` if any version exists that could satisfy this range. */
|
||||
satisfiable(): boolean {
|
||||
return VersionRangeTable.collapse(this.tables()) !== false
|
||||
}
|
||||
|
||||
/** Returns `true` if this range and `other` share at least one satisfying version. */
|
||||
intersects(other: VersionRange): boolean {
|
||||
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 {
|
||||
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 {
|
||||
constructor(
|
||||
/** The numeric version segments (e.g. `[1, 2, 3]` for `"1.2.3"`). */
|
||||
public number: number[],
|
||||
/** Optional prerelease identifiers (e.g. `["beta", 1]` for `"-beta.1"`). */
|
||||
public prerelease: (string | number)[],
|
||||
) {}
|
||||
|
||||
/** Serializes this version to its string form (e.g. `"1.2.3"` or `"1.0.0-beta.1"`). */
|
||||
toString(): string {
|
||||
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' {
|
||||
const numLen = Math.max(this.number.length, other.number.length)
|
||||
for (let i = 0; i < numLen; i++) {
|
||||
@@ -783,6 +910,11 @@ export class Version {
|
||||
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 {
|
||||
switch (this.compare(other)) {
|
||||
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 {
|
||||
const parsed = P.parse(version, { startRule: 'Version' })
|
||||
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 {
|
||||
return new ExtendedVersion(null, this, new Version([0], [])).satisfies(
|
||||
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 {
|
||||
constructor(
|
||||
/** The flavor identifier (e.g. `"bitcoin"`), or `null` for unflavored versions. */
|
||||
public flavor: string | null,
|
||||
/** The upstream software version. */
|
||||
public upstream: Version,
|
||||
/** The downstream packaging 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 {
|
||||
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 {
|
||||
if (this.flavor !== other.flavor) {
|
||||
return null
|
||||
@@ -829,6 +1003,10 @@ export class ExtendedVersion {
|
||||
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' {
|
||||
if ((this.flavor || '') > (other.flavor || '')) {
|
||||
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 {
|
||||
switch (this.compareLexicographic(other)) {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
return this.compare(other) === 'equal'
|
||||
}
|
||||
|
||||
/** Returns `true` if this version is strictly less than `other`. Returns `false` if flavors differ. */
|
||||
lessThan(other: ExtendedVersion): boolean {
|
||||
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 {
|
||||
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 {
|
||||
const parsed = P.parse(extendedVersion, { startRule: '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 {
|
||||
try {
|
||||
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
|
||||
|
||||
/**
|
||||
* 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>) =>
|
||||
t
|
||||
|
||||
|
||||
@@ -2,21 +2,37 @@ import { VersionRange } from '../../../base/lib/exver'
|
||||
import * as T from '../../../base/lib/types'
|
||||
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
|
||||
|
||||
/** Function signature for an init handler that runs during service startup. */
|
||||
export type InitFn<Kind extends InitKind = InitKind> = (
|
||||
effects: T.Effects,
|
||||
kind: Kind,
|
||||
) => Promise<void | null | undefined>
|
||||
|
||||
/** Object form of an init handler — implements an `init()` method. */
|
||||
export interface InitScript<Kind extends InitKind = InitKind> {
|
||||
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> =
|
||||
| InitScript<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 {
|
||||
return async (opts) => {
|
||||
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 {
|
||||
return 'init' in onInit
|
||||
? onInit
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { ExtendedVersion, VersionRange } from '../../../base/lib/exver'
|
||||
import * as T from '../../../base/lib/types'
|
||||
|
||||
/**
|
||||
* Function signature for an uninit handler that runs during service shutdown/uninstall.
|
||||
*/
|
||||
export type UninitFn = (
|
||||
effects: T.Effects,
|
||||
/**
|
||||
@@ -13,6 +16,7 @@ export type UninitFn = (
|
||||
target: VersionRange | ExtendedVersion | null,
|
||||
) => Promise<void | null | undefined>
|
||||
|
||||
/** Object form of an uninit handler — implements an `uninit()` method. */
|
||||
export interface UninitScript {
|
||||
uninit(
|
||||
effects: T.Effects,
|
||||
@@ -27,8 +31,15 @@ export interface UninitScript {
|
||||
): Promise<void>
|
||||
}
|
||||
|
||||
/** Either a {@link UninitScript} object or a {@link UninitFn} function. */
|
||||
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(
|
||||
...uninits: UninitScriptOrFn[]
|
||||
): 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 {
|
||||
return 'uninit' in onUninit
|
||||
? onUninit
|
||||
|
||||
3
sdk/base/lib/osBindings/SmtpSecurity.ts
Normal file
3
sdk/base/lib/osBindings/SmtpSecurity.ts
Normal 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'
|
||||
@@ -1,9 +1,11 @@
|
||||
// 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 = {
|
||||
server: string
|
||||
host: string
|
||||
port: number
|
||||
from: string
|
||||
login: string
|
||||
username: string
|
||||
password: string | null
|
||||
security: SmtpSecurity
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// 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 = {
|
||||
server: string
|
||||
host: string
|
||||
port: number
|
||||
from: string
|
||||
to: string
|
||||
login: string
|
||||
username: string
|
||||
password: string
|
||||
security: SmtpSecurity
|
||||
}
|
||||
|
||||
@@ -270,6 +270,7 @@ export { SideloadResponse } from './SideloadResponse'
|
||||
export { SignalStrength } from './SignalStrength'
|
||||
export { SignAssetParams } from './SignAssetParams'
|
||||
export { SignerInfo } from './SignerInfo'
|
||||
export { SmtpSecurity } from './SmtpSecurity'
|
||||
export { SmtpValue } from './SmtpValue'
|
||||
export { SshAddParams } from './SshAddParams'
|
||||
export { SshDeleteParams } from './SshDeleteParams'
|
||||
|
||||
@@ -12,6 +12,11 @@ import { FileContents } from './merkleArchive/fileContents'
|
||||
|
||||
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) {
|
||||
if (a.length !== b.length) return false
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
@@ -20,12 +25,41 @@ export function compare(a: Uint8Array, b: Uint8Array) {
|
||||
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 {
|
||||
private constructor(
|
||||
/** The parsed package manifest containing metadata, dependencies, and interface definitions. */
|
||||
readonly manifest: Manifest,
|
||||
/** The Merkle-verified archive containing the package's files. */
|
||||
readonly archive: MerkleArchive,
|
||||
/** The total size of the archive in bytes. */
|
||||
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(
|
||||
source: Blob,
|
||||
commitment: MerkleArchiveCommitment | null,
|
||||
@@ -57,6 +91,14 @@ export class S9pk {
|
||||
|
||||
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> {
|
||||
const iconName = Object.keys(this.archive.contents.contents).find(
|
||||
(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) {
|
||||
const entry = this.archive.contents.getPath([
|
||||
'dependencies',
|
||||
@@ -85,6 +133,12 @@ export class S9pk {
|
||||
) 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) {
|
||||
const dir = this.archive.contents.getPath(['dependencies', id])
|
||||
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() {
|
||||
return Object.fromEntries(
|
||||
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> {
|
||||
const file = this.archive.contents.getPath(['LICENSE.md'])
|
||||
if (!file || !(file.contents instanceof FileContents))
|
||||
|
||||
@@ -20,20 +20,32 @@ export {
|
||||
CurrentDependenciesResult,
|
||||
} from './dependencies/setupDependencies'
|
||||
|
||||
/** An object that can be built into a terminable daemon process. */
|
||||
export type DaemonBuildable = {
|
||||
build(): Promise<{
|
||||
term(): Promise<void>
|
||||
}>
|
||||
}
|
||||
|
||||
/** The three categories of service network interfaces. */
|
||||
export type ServiceInterfaceType = 'ui' | 'p2p' | 'api'
|
||||
/** A Node.js signal name (e.g. `"SIGTERM"`, `"SIGKILL"`). */
|
||||
export type Signals = NodeJS.Signals
|
||||
/** The SIGTERM signal — used for graceful daemon termination. */
|
||||
export const SIGTERM: Signals = 'SIGTERM'
|
||||
/** The SIGKILL signal — used for forceful daemon termination. */
|
||||
export const SIGKILL: Signals = 'SIGKILL'
|
||||
/** Sentinel value (`-1`) indicating that no timeout should be applied. */
|
||||
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
|
||||
/** A value that may or may not be wrapped in a `Promise`. */
|
||||
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 {
|
||||
version: 1
|
||||
|
||||
@@ -62,10 +74,16 @@ export namespace ExpectedExports {
|
||||
target: ExtendedVersion | VersionRange | null
|
||||
}) => Promise<unknown>
|
||||
|
||||
/** The package manifest describing the service's metadata, dependencies, and interfaces. */
|
||||
export type manifest = Manifest
|
||||
|
||||
/** The map of user-invocable actions defined by this service. */
|
||||
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 = {
|
||||
createBackup: ExpectedExports.createBackup
|
||||
main: ExpectedExports.main
|
||||
@@ -74,53 +92,78 @@ export type ABI = {
|
||||
manifest: ExpectedExports.manifest
|
||||
actions: ExpectedExports.actions
|
||||
}
|
||||
/** A time value in milliseconds. */
|
||||
export type TimeMs = number
|
||||
/** A version string in string form. */
|
||||
export type VersionString = string
|
||||
|
||||
declare const DaemonProof: unique symbol
|
||||
/** Opaque branded type proving that a daemon was started. Cannot be constructed directly. */
|
||||
export type DaemonReceipt = {
|
||||
[DaemonProof]: never
|
||||
}
|
||||
/** A running daemon with methods to wait for completion or terminate it. */
|
||||
export type Daemon = {
|
||||
/** Waits for the daemon to exit and returns its exit message. */
|
||||
wait(): Promise<string>
|
||||
/** Terminates the daemon. */
|
||||
term(): Promise<null>
|
||||
[DaemonProof]: never
|
||||
}
|
||||
|
||||
/** The result status of a health check (extracted from `NamedHealthCheckResult`). */
|
||||
export type HealthStatus = NamedHealthCheckResult['result']
|
||||
/** SMTP mail server configuration values. */
|
||||
export type SmtpValue = {
|
||||
server: string
|
||||
host: string
|
||||
port: number
|
||||
from: string
|
||||
login: string
|
||||
username: string
|
||||
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 {
|
||||
readonly USE_ENTRYPOINT = 'USE_ENTRYPOINT'
|
||||
constructor(readonly overridCmd?: string[]) {}
|
||||
}
|
||||
/** Type guard that checks if a {@link CommandType} is a {@link UseEntrypoint} instance. */
|
||||
export function isUseEntrypoint(
|
||||
command: CommandType,
|
||||
): command is UseEntrypoint {
|
||||
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
|
||||
|
||||
/** The return type from starting a daemon — provides `wait()` and `term()` controls. */
|
||||
export type DaemonReturned = {
|
||||
/** Waits for the daemon process to exit. */
|
||||
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>
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
/** A string identifier for a service network interface. */
|
||||
export type ServiceInterfaceId = string
|
||||
|
||||
export { ServiceInterface }
|
||||
|
||||
/** Maps effect method names to their kebab-case RPC equivalents. */
|
||||
export type EffectMethod<T extends StringObject = Effects> = {
|
||||
[K in keyof T]-?: K extends string
|
||||
? T[K] extends Function
|
||||
@@ -131,6 +174,7 @@ export type EffectMethod<T extends StringObject = Effects> = {
|
||||
: never
|
||||
}[keyof T]
|
||||
|
||||
/** Options for rsync-based file synchronization (used in backup/restore). */
|
||||
export type SyncOptions = {
|
||||
/** delete files that exist in the target directory, but not in the source directory */
|
||||
delete: boolean
|
||||
@@ -156,49 +200,68 @@ export type Metadata = {
|
||||
mode: number
|
||||
}
|
||||
|
||||
/** Result type for setting a service's dependency configuration and restart signal. */
|
||||
export type SetResult = {
|
||||
dependsOn: DependsOn
|
||||
signal: Signals
|
||||
}
|
||||
|
||||
/** A string identifier for a StartOS package (e.g. `"bitcoind"`). */
|
||||
export type PackageId = string
|
||||
/** A user-facing message string. */
|
||||
export type Message = string
|
||||
/** Whether a dependency needs to be actively running or merely installed. */
|
||||
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 = {
|
||||
[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 =
|
||||
| { error: string }
|
||||
| {
|
||||
errorCode: [number, string] | readonly [number, string]
|
||||
}
|
||||
|
||||
/** An array of dependency requirements for a service. */
|
||||
export type Dependencies = Array<DependencyRequirement>
|
||||
|
||||
/** Recursively makes all properties of `T` optional. */
|
||||
export type DeepPartial<T> = T extends [infer A, ...infer Rest]
|
||||
? [DeepPartial<A>, ...DeepPartial<Rest>]
|
||||
: T extends {}
|
||||
? { [P in keyof T]?: DeepPartial<T[P]> }
|
||||
: T
|
||||
|
||||
/** Recursively removes all `readonly` modifiers from `T`. */
|
||||
export type DeepWritable<T> = {
|
||||
-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> {
|
||||
return value
|
||||
}
|
||||
|
||||
/** Recursively makes all properties of `T` readonly. */
|
||||
export type DeepReadonly<T> = {
|
||||
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> {
|
||||
return value
|
||||
}
|
||||
|
||||
/** Accepts either a mutable or deeply-readonly version of `T`. */
|
||||
export type AllowReadonly<T> =
|
||||
| T
|
||||
| {
|
||||
|
||||
@@ -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) => {
|
||||
if (e instanceof Error) {
|
||||
return new Error(e as any)
|
||||
|
||||
@@ -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[]) {
|
||||
const objects = args.filter(
|
||||
(x): x is object => typeof x === 'object' && x !== null,
|
||||
|
||||
@@ -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>(
|
||||
prev: 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 {
|
||||
const lastItem = (args as any)[args.length - 1]
|
||||
if (typeof lastItem !== 'object' || !lastItem) return lastItem
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { DefaultString } from '../actions/input/inputSpecTypes'
|
||||
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 {
|
||||
if (typeof defaultSpec === 'string') {
|
||||
return defaultSpec
|
||||
|
||||
@@ -1,19 +1,41 @@
|
||||
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> = {
|
||||
metadata: 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> = {
|
||||
metadata: EMetadata
|
||||
from: 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> {
|
||||
private readonly vertices: Array<Vertex<VMetadata, EMetadata>> = []
|
||||
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(
|
||||
metadataRepr: (metadata: VMetadata | EMetadata) => any = (a) => a,
|
||||
): string {
|
||||
@@ -30,6 +52,13 @@ export class Graph<VMetadata = null, EMetadata = null> {
|
||||
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(
|
||||
metadata: VMetadata,
|
||||
fromEdges: Array<Omit<Edge<EMetadata, VMetadata>, 'to'>>,
|
||||
@@ -60,6 +89,11 @@ export class Graph<VMetadata = null, EMetadata = null> {
|
||||
this.vertices.push(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(
|
||||
predicate: (vertex: Vertex<VMetadata, EMetadata>) => boolean,
|
||||
): Generator<Vertex<VMetadata, EMetadata>, null> {
|
||||
@@ -74,6 +108,13 @@ export class Graph<VMetadata = null, EMetadata = null> {
|
||||
}
|
||||
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(
|
||||
metadata: EMetadata,
|
||||
from: Vertex<VMetadata, EMetadata>,
|
||||
@@ -88,6 +129,11 @@ export class Graph<VMetadata = null, EMetadata = null> {
|
||||
edge.to.edges.push(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(
|
||||
from:
|
||||
| Vertex<VMetadata, EMetadata>
|
||||
@@ -139,6 +185,11 @@ export class Graph<VMetadata = null, EMetadata = null> {
|
||||
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(
|
||||
to:
|
||||
| Vertex<VMetadata, EMetadata>
|
||||
@@ -190,6 +241,12 @@ export class Graph<VMetadata = null, EMetadata = null> {
|
||||
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(
|
||||
from:
|
||||
| Vertex<VMetadata, EMetadata>
|
||||
|
||||
@@ -15,6 +15,21 @@ const digitsMs = (digits: string | null, multiplier: number) => {
|
||||
const divideBy = multiplier / Math.pow(10, digits.length - 1)
|
||||
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) => {
|
||||
if (typeof time === 'number') return time
|
||||
if (!time) return undefined
|
||||
|
||||
@@ -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 {
|
||||
private renderedOctets: number[]
|
||||
protected constructor(
|
||||
@@ -6,6 +17,13 @@ export class IpAddress {
|
||||
) {
|
||||
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 {
|
||||
let octets
|
||||
if (address.includes(':')) {
|
||||
@@ -39,6 +57,12 @@ export class IpAddress {
|
||||
}
|
||||
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[]) {
|
||||
if (octets.length == 4) {
|
||||
if (octets.some((o) => o > 255)) {
|
||||
@@ -66,15 +90,24 @@ export class IpAddress {
|
||||
throw new Error('invalid ip address')
|
||||
}
|
||||
}
|
||||
/** Returns true if this is an IPv4 address (4 octets). */
|
||||
isIpv4(): boolean {
|
||||
return this.octets.length === 4
|
||||
}
|
||||
/** Returns true if this is an IPv6 address (16 octets). */
|
||||
isIpv6(): boolean {
|
||||
return this.octets.length === 16
|
||||
}
|
||||
/** Returns true if this is a public IPv4 address (not in any private range). */
|
||||
isPublic(): boolean {
|
||||
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 {
|
||||
let octets = [...this.octets]
|
||||
n = Math.floor(n)
|
||||
@@ -92,6 +125,12 @@ export class IpAddress {
|
||||
}
|
||||
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 {
|
||||
let octets = [...this.octets]
|
||||
n = Math.floor(n)
|
||||
@@ -109,6 +148,11 @@ export class IpAddress {
|
||||
}
|
||||
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 {
|
||||
if (typeof other === 'string') other = IpAddress.parse(other)
|
||||
const len = Math.max(this.octets.length, other.octets.length)
|
||||
@@ -123,6 +167,7 @@ export class IpAddress {
|
||||
}
|
||||
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 {
|
||||
if (
|
||||
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 {
|
||||
private constructor(
|
||||
octets: number[],
|
||||
@@ -168,18 +224,35 @@ export class IpNet extends IpAddress {
|
||||
) {
|
||||
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 {
|
||||
if (prefix > ip.octets.length * 8) {
|
||||
throw new Error('invalid prefix')
|
||||
}
|
||||
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 {
|
||||
const [address, prefixStr] = ipnet.split('/', 2)
|
||||
const ip = IpAddress.parse(address)
|
||||
const prefix = Number(prefixStr)
|
||||
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 {
|
||||
if (typeof address === 'string') address = IpAddress.parse(address)
|
||||
if (address instanceof IpNet && address.prefix < this.prefix) return false
|
||||
@@ -197,6 +270,7 @@ export class IpNet extends IpAddress {
|
||||
const mask = 255 ^ (255 >> prefix)
|
||||
return (this.octets[idx] & mask) === (address.octets[idx] & mask)
|
||||
}
|
||||
/** Returns the network address (all host bits zeroed) for this subnet. */
|
||||
zero(): IpAddress {
|
||||
let octets: number[] = []
|
||||
let prefix = this.prefix
|
||||
@@ -213,6 +287,7 @@ export class IpNet extends IpAddress {
|
||||
|
||||
return IpAddress.fromOctets(octets)
|
||||
}
|
||||
/** Returns the broadcast address (all host bits set to 1) for this subnet. */
|
||||
broadcast(): IpAddress {
|
||||
let octets: number[] = []
|
||||
let prefix = this.prefix
|
||||
@@ -229,11 +304,13 @@ export class IpNet extends IpAddress {
|
||||
|
||||
return IpAddress.fromOctets(octets)
|
||||
}
|
||||
/** The CIDR notation string for this network (e.g. `"192.168.1.0/24"`). */
|
||||
get ipnet() {
|
||||
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 = [
|
||||
IpNet.parse('127.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'),
|
||||
]
|
||||
|
||||
/** IPv4 loopback network (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')
|
||||
/** IPv6 link-local network (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')
|
||||
|
||||
@@ -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 {
|
||||
let result: [B] | [] = []
|
||||
return () => {
|
||||
|
||||
@@ -1,57 +1,68 @@
|
||||
import { Pattern } from '../actions/input/inputSpecTypes'
|
||||
import * as regexes from './regexes'
|
||||
|
||||
/** Pattern for validating IPv6 addresses. */
|
||||
export const ipv6: Pattern = {
|
||||
regex: regexes.ipv6.matches(),
|
||||
description: 'Must be a valid IPv6 address',
|
||||
}
|
||||
|
||||
/** Pattern for validating IPv4 addresses. */
|
||||
export const ipv4: Pattern = {
|
||||
regex: regexes.ipv4.matches(),
|
||||
description: 'Must be a valid IPv4 address',
|
||||
}
|
||||
|
||||
/** Pattern for validating hostnames (RFC-compliant). */
|
||||
export const hostname: Pattern = {
|
||||
regex: regexes.hostname.matches(),
|
||||
description: 'Must be a valid hostname',
|
||||
}
|
||||
|
||||
/** Pattern for validating `.local` mDNS hostnames. */
|
||||
export const localHostname: Pattern = {
|
||||
regex: regexes.localHostname.matches(),
|
||||
description: 'Must be a valid ".local" hostname',
|
||||
}
|
||||
|
||||
/** Pattern for validating HTTP/HTTPS URLs. */
|
||||
export const url: Pattern = {
|
||||
regex: regexes.url.matches(),
|
||||
description: 'Must be a valid URL',
|
||||
}
|
||||
|
||||
/** Pattern for validating `.local` URLs (mDNS/LAN). */
|
||||
export const localUrl: Pattern = {
|
||||
regex: regexes.localUrl.matches(),
|
||||
description: 'Must be a valid ".local" URL',
|
||||
}
|
||||
|
||||
/** Pattern for validating ASCII-only strings (printable characters). */
|
||||
export const ascii: Pattern = {
|
||||
regex: regexes.ascii.matches(),
|
||||
description:
|
||||
'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 = {
|
||||
regex: regexes.domain.matches(),
|
||||
description: 'Must be a valid Fully Qualified Domain Name',
|
||||
}
|
||||
|
||||
/** Pattern for validating email addresses. */
|
||||
export const email: Pattern = {
|
||||
regex: regexes.email.matches(),
|
||||
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 = {
|
||||
regex: regexes.emailWithName.matches(),
|
||||
description: 'Must be a valid email address, optionally with a name',
|
||||
}
|
||||
|
||||
/** Pattern for validating base64-encoded strings. */
|
||||
export const base64: Pattern = {
|
||||
regex: regexes.base64.matches(),
|
||||
description:
|
||||
|
||||
@@ -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 {
|
||||
readonly regex: RegExp
|
||||
constructor(regex: RegExp | string) {
|
||||
@@ -7,69 +20,94 @@ export class ComposableRegex {
|
||||
this.regex = new RegExp(regex)
|
||||
}
|
||||
}
|
||||
/** Returns the regex source wrapped in a capturing group, suitable for embedding in a larger expression. */
|
||||
asExpr(): string {
|
||||
return `(${this.regex.source})`
|
||||
}
|
||||
/** Returns the regex source anchored with `^...$` for full-string matching. */
|
||||
matches(): string {
|
||||
return `^${this.regex.source}$`
|
||||
}
|
||||
/** Returns the raw regex source string for substring/containment matching. */
|
||||
contains(): string {
|
||||
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) =>
|
||||
str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
/** Composable regex for matching IPv6 addresses (all standard forms including `::` shorthand). */
|
||||
// https://ihateregex.io/expr/ipv6/
|
||||
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]))/,
|
||||
)
|
||||
|
||||
/** Composable regex for matching IPv4 addresses in dotted-decimal notation. */
|
||||
// https://ihateregex.io/expr/ipv4/
|
||||
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}/,
|
||||
)
|
||||
|
||||
/** Composable regex for matching RFC-compliant hostnames. */
|
||||
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])/,
|
||||
)
|
||||
|
||||
/** Composable regex for matching `.local` mDNS hostnames. */
|
||||
export const localHostname = new ComposableRegex(
|
||||
/[-a-zA-Z0-9@:%._\+~#=]{1,256}\.local/,
|
||||
)
|
||||
|
||||
/** Composable regex for matching HTTP/HTTPS URLs. */
|
||||
// https://ihateregex.io/expr/url/
|
||||
export const url = new ComposableRegex(
|
||||
/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(
|
||||
/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/
|
||||
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,}/)
|
||||
|
||||
/** Composable regex for matching email addresses. */
|
||||
// https://www.regular-expressions.info/email.html
|
||||
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(
|
||||
`${email.asExpr()}|([^<]*<${email.asExpr()}>)`,
|
||||
)
|
||||
|
||||
/** Composable regex for matching base64-encoded strings (no whitespace). */
|
||||
//https://rgxdb.com/r/1NUN74O6
|
||||
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}===))/,
|
||||
)
|
||||
|
||||
/** Composable regex for matching base64-encoded strings that may contain interspersed whitespace. */
|
||||
//https://rgxdb.com/r/1NUN74O6
|
||||
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}===))/,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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) =>
|
||||
new ComposableRegex(
|
||||
`-----BEGIN ${escapeLiteral(label)}-----\r?\n[a-zA-Z0-9+/\n\r=]*?\r?\n-----END ${escapeLiteral(label)}-----`,
|
||||
|
||||
@@ -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 = (
|
||||
command: string | [string, ...string[]],
|
||||
): string[] => {
|
||||
|
||||
@@ -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: {
|
||||
stdout: string
|
||||
stderr: string
|
||||
|
||||
@@ -1,21 +1,47 @@
|
||||
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
|
||||
export type FlattenIntersection<T> =
|
||||
export type FlattenIntersection<T> =
|
||||
T extends ArrayLike<any> ? T :
|
||||
T extends object ? {} & {[P in keyof T]: T[P]} :
|
||||
T;
|
||||
|
||||
/** Shorthand alias for {@link FlattenIntersection}. */
|
||||
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 =>
|
||||
e instanceof Object && ('error' in e || 'error-code' in e)
|
||||
|
||||
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 }
|
||||
|
||||
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
|
||||
? keyof NeverPossible extends keyof A
|
||||
? never
|
||||
@@ -54,6 +80,14 @@ type Numbers = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
|
||||
|
||||
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
|
||||
? S extends `${infer Head}${CapitalChars}${infer Tail}` // string has a capital char somewhere
|
||||
? 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' */
|
||||
: never
|
||||
|
||||
/** A generic object type with string keys and unknown values. */
|
||||
export type StringObject = Record<string, unknown>
|
||||
|
||||
function test() {
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
import { ServiceInterfaceType, Effects } from '../../base/lib/types'
|
||||
import * as patterns from '../../base/lib/util/patterns'
|
||||
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 { checkPortListening } from './health/checkFns/checkPortListening'
|
||||
import { checkWebUrl, runHealthScript } from './health/checkFns'
|
||||
@@ -62,6 +67,7 @@ import {
|
||||
import { getOwnServiceInterfaces } from '../../base/lib/util/getServiceInterfaces'
|
||||
import { Volumes, createVolumes } from './util/Volume'
|
||||
|
||||
/** The minimum StartOS version required by this SDK release */
|
||||
export const OSVersion = testTypeVersion('0.4.0-alpha.20')
|
||||
|
||||
// prettier-ignore
|
||||
@@ -71,11 +77,29 @@ type AnyNeverCond<T extends any[], Then, Else> =
|
||||
T extends [any, ...infer U] ? AnyNeverCond<U,Then, Else> :
|
||||
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> {
|
||||
private constructor(readonly manifest: Manifest) {}
|
||||
/**
|
||||
* Create an uninitialized StartSdk instance. Call `.withManifest()` next.
|
||||
* @returns A new StartSdk with no manifest bound.
|
||||
*/
|
||||
static of() {
|
||||
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) {
|
||||
return new StartSdk<Manifest>(manifest)
|
||||
}
|
||||
@@ -88,6 +112,14 @@ export class StartSdk<Manifest extends T.SDKManifest> {
|
||||
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>) {
|
||||
type NestedEffects = 'subcontainer' | 'store' | 'action' | 'plugin'
|
||||
type InterfaceEffects =
|
||||
@@ -137,13 +169,19 @@ export class StartSdk<Manifest extends T.SDKManifest> {
|
||||
}
|
||||
|
||||
return {
|
||||
/** The bound service manifest */
|
||||
manifest: this.manifest,
|
||||
/** Volume path helpers derived from the manifest volume definitions */
|
||||
volumes: createVolumes(this.manifest),
|
||||
...startSdkEffectWrapper,
|
||||
/** Persist the current data version to the StartOS effect system */
|
||||
setDataVersion,
|
||||
/** Retrieve the current data version from the StartOS effect system */
|
||||
getDataVersion,
|
||||
action: {
|
||||
/** Execute an action by its ID, optionally providing input */
|
||||
run: actions.runAction,
|
||||
/** Create a task notification for a specific package's action */
|
||||
createTask: <T extends ActionInfo<T.ActionId, any>>(
|
||||
effects: T.Effects,
|
||||
packageId: T.PackageId,
|
||||
@@ -158,6 +196,7 @@ export class StartSdk<Manifest extends T.SDKManifest> {
|
||||
severity,
|
||||
options: options,
|
||||
}),
|
||||
/** Create a task notification for this service's own action (uses manifest.id automatically) */
|
||||
createOwnTask: <T extends ActionInfo<T.ActionId, any>>(
|
||||
effects: T.Effects,
|
||||
action: T,
|
||||
@@ -171,9 +210,20 @@ export class StartSdk<Manifest extends T.SDKManifest> {
|
||||
severity,
|
||||
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[]) =>
|
||||
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 <
|
||||
DependencyId extends keyof Manifest['dependencies'] &
|
||||
T.PackageId = keyof Manifest['dependencies'] & T.PackageId,
|
||||
@@ -182,11 +232,25 @@ export class StartSdk<Manifest extends T.SDKManifest> {
|
||||
packageIds?: DependencyId[],
|
||||
) => Promise<CheckDependencies<DependencyId>>,
|
||||
serviceInterface: {
|
||||
/** Retrieve a single service interface belonging to this package by its ID */
|
||||
getOwn: getOwnServiceInterface,
|
||||
/** Retrieve a single service interface from any package */
|
||||
get: getServiceInterface,
|
||||
/** Retrieve all service interfaces belonging to this package */
|
||||
getAllOwn: getOwnServiceInterfaces,
|
||||
/** Retrieve all service interfaces, optionally filtering by package */
|
||||
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: (
|
||||
effects: T.Effects,
|
||||
options: Omit<
|
||||
@@ -279,9 +343,22 @@ export class StartSdk<Manifest extends T.SDKManifest> {
|
||||
},
|
||||
|
||||
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 }),
|
||||
},
|
||||
/**
|
||||
* Return `null` if the given string is empty, otherwise return the string unchanged.
|
||||
* Useful for converting empty user input into explicit null values.
|
||||
*/
|
||||
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[]) =>
|
||||
new T.UseEntrypoint(overrideCmd),
|
||||
/**
|
||||
@@ -396,7 +473,12 @@ export class StartSdk<Manifest extends T.SDKManifest> {
|
||||
run: Run<{}>,
|
||||
) => Action.withoutInput(id, metadata, run),
|
||||
},
|
||||
inputSpecConstants: { smtpInputSpec },
|
||||
inputSpecConstants: {
|
||||
smtpInputSpec,
|
||||
systemSmtpSpec,
|
||||
customSmtp,
|
||||
smtpProviderVariants,
|
||||
},
|
||||
/**
|
||||
* @description Use this function to create a service interface.
|
||||
* @param effects
|
||||
@@ -444,21 +526,37 @@ export class StartSdk<Manifest extends T.SDKManifest> {
|
||||
masked: boolean
|
||||
},
|
||||
) => new ServiceInterfaceBuilder({ ...options, effects }),
|
||||
/**
|
||||
* Get the system SMTP configuration with reactive subscription support.
|
||||
* @param effects - The effects context
|
||||
*/
|
||||
getSystemSmtp: <E extends Effects>(effects: E) =>
|
||||
new GetSystemSmtp(effects),
|
||||
/**
|
||||
* Get the outbound network gateway address with reactive subscription support.
|
||||
* @param effects - The effects context
|
||||
*/
|
||||
getOutboundGateway: <E extends Effects>(effects: E) =>
|
||||
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>(
|
||||
effects: E,
|
||||
hostnames: string[],
|
||||
algorithm?: T.Algorithm,
|
||||
) => new GetSslCertificate(effects, hostnames, algorithm),
|
||||
/** Retrieve the manifest of any installed service package by its ID */
|
||||
getServiceManifest,
|
||||
healthCheck: {
|
||||
checkPortListening,
|
||||
checkWebUrl,
|
||||
runHealthScript,
|
||||
},
|
||||
/** Common utility patterns (e.g. hostname regex, port validators) */
|
||||
patterns,
|
||||
/**
|
||||
* @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,
|
||||
/**
|
||||
* 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: (
|
||||
fn: (o: { effects: Effects }) => Promise<Daemons<Manifest, any>>,
|
||||
) => setupMain<Manifest>(fn),
|
||||
/** Built-in trigger strategies for controlling health-check polling intervals */
|
||||
trigger: {
|
||||
/** Default trigger: polls at a fixed interval */
|
||||
defaultTrigger,
|
||||
/** Trigger with a cooldown period between checks */
|
||||
cooldownTrigger,
|
||||
/** Switches to a different interval after the first successful check */
|
||||
changeOnFirstSuccess,
|
||||
/** Uses different intervals based on success vs failure results */
|
||||
successFailure,
|
||||
},
|
||||
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>,
|
||||
},
|
||||
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>,
|
||||
/**
|
||||
* Create a Backups configuration from explicit sync path pairs.
|
||||
* @param syncs - Array of `{ dataPath, backupPath }` objects
|
||||
*/
|
||||
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>,
|
||||
},
|
||||
InputSpec: {
|
||||
@@ -687,11 +811,20 @@ export class StartSdk<Manifest extends T.SDKManifest> {
|
||||
InputSpec.of<Spec>(spec),
|
||||
},
|
||||
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() {
|
||||
return Daemon.of<Manifest>()
|
||||
},
|
||||
},
|
||||
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) {
|
||||
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>(
|
||||
effects: Effects,
|
||||
image: { imageId: keyof Manifest['images'] & T.ImageId; sharedRun?: boolean },
|
||||
|
||||
@@ -5,10 +5,12 @@ import { Affine, asError } from '../util'
|
||||
import { ExtendedVersion, VersionRange } from '../../../base/lib'
|
||||
import { InitKind, InitScript } from '../../../base/lib/inits'
|
||||
|
||||
/** Default rsync options used for backup and restore operations */
|
||||
export const DEFAULT_OPTIONS: T.SyncOptions = {
|
||||
delete: true,
|
||||
exclude: [],
|
||||
}
|
||||
/** A single source-to-destination sync pair for backup and restore */
|
||||
export type BackupSync<Volumes extends string> = {
|
||||
dataPath: `/media/startos/volumes/${Volumes}/${string}`
|
||||
backupPath: `/media/startos/backup/${string}`
|
||||
@@ -17,8 +19,18 @@ export type BackupSync<Volumes extends string> = {
|
||||
restoreOptions?: Partial<T.SyncOptions>
|
||||
}
|
||||
|
||||
/** Effects type narrowed for backup/restore contexts, preventing reuse outside that scope */
|
||||
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 {
|
||||
private constructor(
|
||||
private options = DEFAULT_OPTIONS,
|
||||
@@ -31,6 +43,11 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
|
||||
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>(
|
||||
...volumeNames: Array<M['volumes'][number]>
|
||||
): 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>(
|
||||
...syncs: BackupSync<M['volumes'][number]>[]
|
||||
) {
|
||||
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>(
|
||||
options?: Partial<T.SyncOptions>,
|
||||
) {
|
||||
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>) {
|
||||
this.options = {
|
||||
...this.options,
|
||||
@@ -62,6 +92,10 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
|
||||
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>) {
|
||||
this.backupOptions = {
|
||||
...this.backupOptions,
|
||||
@@ -70,6 +104,10 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
|
||||
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>) {
|
||||
this.restoreOptions = {
|
||||
...this.restoreOptions,
|
||||
@@ -78,26 +116,47 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
|
||||
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>) {
|
||||
this.preBackup = fn
|
||||
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>) {
|
||||
this.postBackup = fn
|
||||
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>) {
|
||||
this.preRestore = fn
|
||||
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>) {
|
||||
this.postRestore = fn
|
||||
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(
|
||||
volume: M['volumes'][number],
|
||||
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]>) {
|
||||
this.backupSet.push(sync)
|
||||
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) {
|
||||
await this.preBackup(effects as BackupEffects)
|
||||
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) {
|
||||
this.preRestore(effects as BackupEffects)
|
||||
|
||||
|
||||
@@ -3,6 +3,11 @@ import * as T from '../../../base/lib/types'
|
||||
import { _ } from '../util'
|
||||
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> =
|
||||
| M['volumes'][number][]
|
||||
| ((_: { effects: T.Effects }) => Promise<Backups<M>>)
|
||||
@@ -12,6 +17,15 @@ type SetupBackupsRes = {
|
||||
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>(
|
||||
options: SetupBackupsParams<M>,
|
||||
) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { TriggerInput } from '../trigger/TriggerInput'
|
||||
import { defaultTrigger } from '../trigger/defaultTrigger'
|
||||
import { once, asError, Drop } from '../util'
|
||||
|
||||
/** Parameters for creating a health check */
|
||||
export type HealthCheckParams = {
|
||||
id: HealthCheckId
|
||||
name: string
|
||||
@@ -13,6 +14,13 @@ export type HealthCheckParams = {
|
||||
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 {
|
||||
private started: number | null = 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 {
|
||||
return new HealthCheck(effects, options)
|
||||
}
|
||||
/** Signal that the daemon is running, enabling health check polling */
|
||||
start() {
|
||||
if (this.started) return
|
||||
this.setStarted(performance.now())
|
||||
}
|
||||
/** Signal that the daemon has stopped, pausing health check polling */
|
||||
stop() {
|
||||
if (!this.started) return
|
||||
this.setStarted(null)
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
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'>
|
||||
|
||||
@@ -3,6 +3,14 @@ export { checkPortListening } from './checkPortListening'
|
||||
export { HealthCheckResult } from './HealthCheckResult'
|
||||
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' } = {}) {
|
||||
return new Promise<never>((resolve, reject) =>
|
||||
setTimeout(() => reject(new Error(message)), ms),
|
||||
|
||||
@@ -8,6 +8,15 @@ import * as cp from 'child_process'
|
||||
import * as fs from 'node:fs/promises'
|
||||
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<
|
||||
Manifest extends T.SDKManifest,
|
||||
C extends SubContainer<Manifest> | null,
|
||||
@@ -21,6 +30,13 @@ export class CommandController<
|
||||
) {
|
||||
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<
|
||||
Manifest extends T.SDKManifest,
|
||||
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 } = {}) {
|
||||
if (timeout > 0)
|
||||
setTimeout(() => {
|
||||
@@ -156,6 +176,15 @@ export class CommandController<
|
||||
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 } = {}) {
|
||||
try {
|
||||
if (!this.state.exited) {
|
||||
|
||||
@@ -13,10 +13,15 @@ import { Oneshot } from './Oneshot'
|
||||
const TIMEOUT_INCREMENT_MS = 1000
|
||||
const MAX_TIMEOUT_MS = 30000
|
||||
/**
|
||||
* This is a wrapper around CommandController that has a state of off, where the command shouldn't be running
|
||||
* and the others state of running, where it will keep a living running command
|
||||
* A managed long-running process wrapper around {@link CommandController}.
|
||||
*
|
||||
* 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<
|
||||
Manifest extends T.SDKManifest,
|
||||
C extends SubContainer<Manifest> | null = SubContainer<Manifest> | null,
|
||||
@@ -33,9 +38,16 @@ export class Daemon<
|
||||
) {
|
||||
super()
|
||||
}
|
||||
/** Returns true if this daemon is a one-shot process (exits after success) */
|
||||
isOneshot(): this is Oneshot<Manifest> {
|
||||
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>() {
|
||||
return <C extends SubContainer<Manifest> | null>(
|
||||
effects: T.Effects,
|
||||
@@ -57,6 +69,12 @@ export class Daemon<
|
||||
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() {
|
||||
if (this.commandController) {
|
||||
return
|
||||
@@ -105,6 +123,17 @@ export class Daemon<
|
||||
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?: {
|
||||
signal?: NodeJS.Signals | undefined
|
||||
timeout?: number | undefined
|
||||
@@ -125,14 +154,20 @@ export class Daemon<
|
||||
this.exiting = null
|
||||
}
|
||||
}
|
||||
/** Get a reference-counted handle to the daemon's subcontainer, or null if there is none */
|
||||
subcontainerRc(): SubContainerRc<Manifest> | null {
|
||||
return this.subcontainer?.rc() ?? null
|
||||
}
|
||||
/** Check whether this daemon shares the same subcontainer as another daemon */
|
||||
sharesSubcontainerWith(
|
||||
other: Daemon<Manifest, SubContainer<Manifest> | null>,
|
||||
): boolean {
|
||||
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) {
|
||||
this.onExitFns.push(fn)
|
||||
}
|
||||
|
||||
@@ -16,8 +16,15 @@ import { Daemon } from './Daemon'
|
||||
import { CommandController } from './CommandController'
|
||||
import { Oneshot } from './Oneshot'
|
||||
|
||||
/** Promisified version of `child_process.exec` */
|
||||
export const cpExec = promisify(CP.exec)
|
||||
/** Promisified version of `child_process.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 = {
|
||||
/** A human-readable display name for the health check. If null, the health check itself will be from the UI */
|
||||
display: string | null
|
||||
@@ -45,6 +52,10 @@ export type Ready = {
|
||||
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 = {
|
||||
command: T.CommandType
|
||||
// Defaults to the DEFAULT_SIGTERM_TIMEOUT = 30_000ms
|
||||
@@ -61,6 +72,11 @@ export type ExecCommandOptions = {
|
||||
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<
|
||||
Manifest extends T.SDKManifest,
|
||||
C extends SubContainer<Manifest> | null,
|
||||
@@ -73,6 +89,10 @@ export type ExecFnOptions<
|
||||
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<
|
||||
Manifest extends T.SDKManifest,
|
||||
C extends SubContainer<Manifest> | null,
|
||||
@@ -385,6 +405,13 @@ export class Daemons<Manifest extends T.SDKManifest, Ids extends string>
|
||||
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() {
|
||||
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() {
|
||||
for (const daemon of this.healthDaemons) {
|
||||
await daemon.updateStatus()
|
||||
|
||||
@@ -49,6 +49,15 @@ type DependencyOpts<Manifest extends T.SDKManifest> = {
|
||||
readonly: boolean
|
||||
} & 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<
|
||||
Manifest extends T.SDKManifest,
|
||||
Backups extends SharedOptions = never,
|
||||
@@ -60,10 +69,19 @@ export class Mounts<
|
||||
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>() {
|
||||
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>) {
|
||||
return new Mounts<Manifest, Backups>(
|
||||
[...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) {
|
||||
return new Mounts<Manifest, Backups>(
|
||||
[...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>(
|
||||
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) {
|
||||
return new Mounts<
|
||||
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 {
|
||||
const mountpoints = new Set()
|
||||
for (let mountpoint of this.volumes
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Daemons } from './Daemons'
|
||||
import '../../../base/lib/interfaces/ServiceInterfaceBuilder'
|
||||
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
|
||||
/**
|
||||
* Used to ensure that the main function is running with the valid proofs.
|
||||
|
||||
@@ -24,6 +24,15 @@ export function setupManifest<
|
||||
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<
|
||||
Id extends string,
|
||||
Version extends string,
|
||||
|
||||
@@ -69,6 +69,14 @@ async function bind(
|
||||
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<
|
||||
Manifest extends T.SDKManifest,
|
||||
Effects extends T.Effects = T.Effects,
|
||||
@@ -84,6 +92,11 @@ export interface SubContainer<
|
||||
*/
|
||||
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(
|
||||
mounts: Effects extends BackupEffects
|
||||
? Mounts<
|
||||
@@ -96,6 +109,7 @@ export interface SubContainer<
|
||||
: Mounts<Manifest, never>,
|
||||
): Promise<this>
|
||||
|
||||
/** Destroy this subcontainer and clean up its filesystem */
|
||||
destroy: () => Promise<null>
|
||||
|
||||
/**
|
||||
@@ -136,11 +150,22 @@ export interface SubContainer<
|
||||
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(
|
||||
command: string[],
|
||||
options?: CommandOptions,
|
||||
): 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(
|
||||
command: string[],
|
||||
options?: CommandOptions & StdioOptions,
|
||||
@@ -162,8 +187,13 @@ export interface SubContainer<
|
||||
options?: Parameters<typeof fs.writeFile>[2],
|
||||
): 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>
|
||||
|
||||
/** Returns true if this is an owned subcontainer (not a reference-counted handle) */
|
||||
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<
|
||||
Manifest extends T.SDKManifest,
|
||||
Effects extends T.Effects = T.Effects,
|
||||
@@ -901,14 +937,17 @@ export type StdioOptions = {
|
||||
stdio?: cp.IOType
|
||||
}
|
||||
|
||||
/** UID/GID mapping for mount id-remapping (see kernel idmappings docs) */
|
||||
export type IdMap = { fromId: number; toId: number; range: number }
|
||||
|
||||
/** Union of all mount option types supported by the subcontainer runtime */
|
||||
export type MountOptions =
|
||||
| MountOptionsVolume
|
||||
| MountOptionsAssets
|
||||
| MountOptionsPointer
|
||||
| MountOptionsBackup
|
||||
|
||||
/** Mount options for binding a service volume into a subcontainer */
|
||||
export type MountOptionsVolume = {
|
||||
type: 'volume'
|
||||
volumeId: string
|
||||
@@ -918,6 +957,7 @@ export type MountOptionsVolume = {
|
||||
idmap: IdMap[]
|
||||
}
|
||||
|
||||
/** Mount options for binding packaged static assets into a subcontainer */
|
||||
export type MountOptionsAssets = {
|
||||
type: 'assets'
|
||||
subpath: string | null
|
||||
@@ -925,6 +965,7 @@ export type MountOptionsAssets = {
|
||||
idmap: { fromId: number; toId: number; range: number }[]
|
||||
}
|
||||
|
||||
/** Mount options for binding a dependency package's volume into a subcontainer */
|
||||
export type MountOptionsPointer = {
|
||||
type: 'pointer'
|
||||
packageId: string
|
||||
@@ -934,6 +975,7 @@ export type MountOptionsPointer = {
|
||||
idmap: { fromId: number; toId: number; range: number }[]
|
||||
}
|
||||
|
||||
/** Mount options for binding the backup directory into a subcontainer */
|
||||
export type MountOptionsBackup = {
|
||||
type: 'backup'
|
||||
subpath: string | null
|
||||
@@ -944,6 +986,10 @@ function wait(time: number) {
|
||||
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 {
|
||||
constructor(
|
||||
readonly command: string,
|
||||
|
||||
@@ -84,8 +84,17 @@ function filterUndefined<A>(a: A): 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> = {
|
||||
/** Transform raw parsed data into the application type */
|
||||
onRead: (value: Raw) => Transformed
|
||||
/** Transform application data back into the raw format for writing */
|
||||
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<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>>(
|
||||
path: ToPath,
|
||||
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>>(
|
||||
path: ToPath,
|
||||
shape: Validator<Record<string, string>, A>,
|
||||
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
import { Graph, Vertex, once } from '../util'
|
||||
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) {
|
||||
const versionStr = await effects.getDataVersion()
|
||||
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(
|
||||
effects: T.Effects,
|
||||
version: ExtendedVersion | VersionRange | null,
|
||||
@@ -37,6 +47,14 @@ function isRange(v: ExtendedVersion | VersionRange): v is VersionRange {
|
||||
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(
|
||||
a: 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>
|
||||
implements InitScript, UninitScript
|
||||
{
|
||||
@@ -58,6 +86,7 @@ export class VersionGraph<CurrentVersion extends string>
|
||||
ExtendedVersion | VersionRange,
|
||||
((opts: { effects: T.Effects }) => Promise<void>) | undefined
|
||||
>
|
||||
/** Dump the version graph as a human-readable string for debugging */
|
||||
dump(): string {
|
||||
return this.graph().dump((metadata) => metadata?.toString())
|
||||
}
|
||||
@@ -168,6 +197,18 @@ export class VersionGraph<CurrentVersion extends string>
|
||||
>(options: { current: VersionInfo<CurrentVersion>; other: OtherVersions }) {
|
||||
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({
|
||||
effects,
|
||||
from,
|
||||
@@ -217,6 +258,10 @@ export class VersionGraph<CurrentVersion extends string>
|
||||
`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(() =>
|
||||
Array.from(
|
||||
this.graph().reverseBreadthFirstSearch((v) =>
|
||||
@@ -234,6 +279,10 @@ export class VersionGraph<CurrentVersion extends string>
|
||||
)
|
||||
.normalize(),
|
||||
)
|
||||
/**
|
||||
* Compute the version range that the current version can migrate to.
|
||||
* Uses forward breadth-first search from the current version vertex.
|
||||
*/
|
||||
canMigrateTo = once(() =>
|
||||
Array.from(
|
||||
this.graph().breadthFirstSearch((v) =>
|
||||
@@ -252,6 +301,11 @@ export class VersionGraph<CurrentVersion extends string>
|
||||
.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> {
|
||||
const from = await getDataVersion(effects)
|
||||
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(
|
||||
effects: T.Effects,
|
||||
target: VersionRange | ExtendedVersion | null,
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { ValidateExVer } from '../../../base/lib/exver'
|
||||
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')
|
||||
|
||||
/**
|
||||
* Configuration options for a single service version definition.
|
||||
*
|
||||
* @typeParam Version - The string literal exver version number
|
||||
*/
|
||||
export type VersionOptions<Version extends string> = {
|
||||
/** The exver-compliant version number */
|
||||
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> {
|
||||
private _version: null | Version = null
|
||||
private constructor(
|
||||
|
||||
4
sdk/package/package-lock.json
generated
4
sdk/package/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@start9labs/start-sdk",
|
||||
"version": "0.4.0-beta.51",
|
||||
"version": "0.4.0-beta.52",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@start9labs/start-sdk",
|
||||
"version": "0.4.0-beta.51",
|
||||
"version": "0.4.0-beta.52",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@iarna/toml": "^3.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
"main": "./package/lib/index.js",
|
||||
"types": "./package/lib/index.d.ts",
|
||||
|
||||
@@ -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',
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
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 { RouterLink } from '@angular/router'
|
||||
import {
|
||||
@@ -10,11 +15,11 @@ import {
|
||||
i18nPipe,
|
||||
LoadingService,
|
||||
} 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 { TuiHeader } from '@taiga-ui/layout'
|
||||
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 { ApiService } from 'src/app/services/api/embassy-api.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 { 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({
|
||||
template: `
|
||||
<ng-container *title>
|
||||
@@ -52,6 +83,9 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
@if (spec | async; as resolved) {
|
||||
<form-group [spec]="resolved" />
|
||||
}
|
||||
@if (providerHint()) {
|
||||
<p class="provider-hint">{{ providerHint() }}</p>
|
||||
}
|
||||
<footer>
|
||||
@if (isSaved) {
|
||||
<button
|
||||
@@ -116,6 +150,12 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
footer {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.provider-hint {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
@@ -142,27 +182,45 @@ export default class SystemEmailComponent {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly i18n = inject(i18nPipe)
|
||||
|
||||
readonly providerHint = signal('')
|
||||
private providerSub: Subscription | null = null
|
||||
|
||||
testAddress = ''
|
||||
isSaved = false
|
||||
|
||||
readonly spec: Promise<IST.InputSpec> = configBuilderToSpec(
|
||||
inputSpec.constants.customSmtp,
|
||||
)
|
||||
readonly spec = configBuilderToSpec(inputSpec.constants.systemSmtpSpec)
|
||||
|
||||
readonly form$ = this.patch.watch$('serverInfo', 'smtp').pipe(
|
||||
tap(value => (this.isSaved = !!value)),
|
||||
switchMap(async value =>
|
||||
this.formService.createForm(await this.spec, value),
|
||||
),
|
||||
tap(value => {
|
||||
this.isSaved = !!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(
|
||||
value: typeof inputSpec.constants.customSmtp._TYPE | null,
|
||||
): Promise<void> {
|
||||
async save(formValue: Record<string, any> | null): Promise<void> {
|
||||
const loader = this.loader.open('Saving').subscribe()
|
||||
|
||||
try {
|
||||
if (value) {
|
||||
await this.api.setSmtp(value)
|
||||
if (formValue) {
|
||||
await this.api.setSmtp(formValue['provider'].value)
|
||||
this.isSaved = true
|
||||
} else {
|
||||
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 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
|
||||
|
||||
try {
|
||||
await this.api.testSmtp({
|
||||
...value,
|
||||
password: value.password || '',
|
||||
...smtpValue,
|
||||
password: smtpValue.password || '',
|
||||
to: this.testAddress,
|
||||
})
|
||||
this.dialog
|
||||
|
||||
Reference in New Issue
Block a user