From 804560d43c5358e7e53e1e5a20709a29a2908196 Mon Sep 17 00:00:00 2001 From: Matt Hill Date: Sat, 21 Feb 2026 20:40:45 -0700 Subject: [PATCH] add comments to everything potentially consumer facing --- .../lib/actions/input/builder/inputSpec.ts | 40 +++- sdk/base/lib/actions/input/builder/list.ts | 21 ++ sdk/base/lib/actions/input/builder/value.ts | 39 +++ .../lib/actions/input/builder/variants.ts | 11 + sdk/base/lib/actions/input/inputSpecTypes.ts | 102 +++++++- sdk/base/lib/exver/index.ts | 222 +++++++++++++++++- sdk/base/lib/inits/setupInit.ts | 17 ++ sdk/base/lib/inits/setupUninit.ts | 12 + sdk/base/lib/s9pk/index.ts | 66 ++++++ sdk/base/lib/types.ts | 64 ++++- sdk/base/lib/util/asError.ts | 8 + sdk/base/lib/util/deepEqual.ts | 15 ++ sdk/base/lib/util/deepMerge.ts | 18 ++ sdk/base/lib/util/getDefaultString.ts | 8 + sdk/base/lib/util/graph.ts | 57 +++++ sdk/base/lib/util/inMs.ts | 15 ++ sdk/base/lib/util/ip.ts | 81 +++++++ sdk/base/lib/util/once.ts | 13 + sdk/base/lib/util/patterns.ts | 11 + sdk/base/lib/util/regexes.ts | 38 +++ sdk/base/lib/util/splitCommand.ts | 14 ++ sdk/base/lib/util/stringFromStdErrOut.ts | 7 + sdk/base/lib/util/typeHelpers.ts | 37 ++- sdk/package/lib/StartSdk.ts | 136 +++++++++++ sdk/package/lib/backup/Backups.ts | 71 ++++++ sdk/package/lib/backup/setupBackups.ts | 14 ++ sdk/package/lib/health/HealthCheck.ts | 16 ++ .../lib/health/checkFns/HealthCheckResult.ts | 6 + sdk/package/lib/health/checkFns/index.ts | 8 + sdk/package/lib/mainFn/CommandController.ts | 29 +++ sdk/package/lib/mainFn/Daemon.ts | 41 +++- sdk/package/lib/mainFn/Daemons.ts | 31 +++ sdk/package/lib/mainFn/Mounts.ts | 38 +++ sdk/package/lib/mainFn/index.ts | 1 + sdk/package/lib/manifest/setupManifest.ts | 9 + sdk/package/lib/util/SubContainer.ts | 46 ++++ sdk/package/lib/util/fileHelper.ts | 32 +++ sdk/package/lib/version/VersionGraph.ts | 61 +++++ sdk/package/lib/version/VersionInfo.ts | 17 ++ 39 files changed, 1463 insertions(+), 9 deletions(-) diff --git a/sdk/base/lib/actions/input/builder/inputSpec.ts b/sdk/base/lib/actions/input/builder/inputSpec.ts index 944581af5..6da5bb346 100644 --- a/sdk/base/lib/actions/input/builder/inputSpec.ts +++ b/sdk/base/lib/actions/input/builder/inputSpec.ts @@ -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 = { + /** 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 | 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 = ( options: LazyBuildOptions, ) => Promise | ExpectedOut +/** Extracts the runtime type from an {@link InputSpec}. */ // prettier-ignore -export type ExtractInputSpecType, any>> = +export type ExtractInputSpecType, any>> = A extends InputSpec ? B : never +/** Extracts the static validation type from an {@link InputSpec}. */ export type ExtractInputSpecStaticValidatedAs< A extends InputSpec>, > = A extends InputSpec ? B : never @@ -27,10 +36,12 @@ export type ExtractInputSpecStaticValidatedAs< // A extends Record | InputSpec>, // > = A extends InputSpec ? DeepPartial : DeepPartial +/** Maps an object type to a record of {@link Value} entries for use with `InputSpec.of`. */ export type InputSpecOf> = { [K in keyof A]: Value } +/** A value that is either directly provided or lazily computed via a {@link LazyBuild} function. */ export type MaybeLazyValues = LazyBuild | 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 = null as any as DeepPartial + /** + * 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(options: LazyBuildOptions): 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: Key, build: V | ((tools: InputSpecTools) => 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>>( build: AddSpec | ((tools: InputSpecTools) => 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: Spec) { const validator = z.object( Object.fromEntries( diff --git a/sdk/base/lib/actions/input/builder/list.ts b/sdk/base/lib/actions/input/builder/list.ts index 61ca2f29b..92b1a1f06 100644 --- a/sdk/base/lib/actions/input/builder/list.ts +++ b/sdk/base/lib/actions/input/builder/list.ts @@ -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( 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, diff --git a/sdk/base/lib/actions/input/builder/value.ts b/sdk/base/lib/actions/input/builder/value.ts index c6b54dec1..6335754f9 100644 --- a/sdk/base/lib/actions/input/builder/value.ts +++ b/sdk/base/lib/actions/input/builder/value.ts @@ -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 +/** Conditional type: returns `T` if `Required` is `true`, otherwise `T | null`. */ export type AsRequired = Required extends true ? T : T | null @@ -37,6 +40,19 @@ function asRequiredParser( 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( 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( 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( 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( 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( 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( 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, 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, 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(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( 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, 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(fn: (value: StaticValidatedAs) => U): Value { return new Value(async (options) => { const built = await this.build(options) diff --git a/sdk/base/lib/actions/input/builder/variants.ts b/sdk/base/lib/actions/input/builder/variants.ts index 4ccfabf55..e0784c746 100644 --- a/sdk/base/lib/actions/input/builder/variants.ts +++ b/sdk/base/lib/actions/input/builder/variants.ts @@ -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 = 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]: { diff --git a/sdk/base/lib/actions/input/inputSpecTypes.ts b/sdk/base/lib/actions/input/inputSpecTypes.ts index 2fe5d7b79..d3437b370 100644 --- a/sdk/base/lib/actions/input/inputSpecTypes.ts +++ b/sdk/base/lib/actions/input/inputSpecTypes.ts @@ -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 +/** + * 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 /** core spec types. These types provide the metadata for performing validations */ // prettier-ignore @@ -32,37 +41,56 @@ export type ValueSpecOf = 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 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 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 = +export type ListValueSpecOf = 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 +/** + * 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 = { 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 + /** 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 = { | readonly DefaultString[] | readonly Record[] } +/** 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( t: ValueSpec, s: S, diff --git a/sdk/base/lib/exver/index.ts b/sdk/base/lib/exver/index.ts index 3eb818097..237de75a9 100644 --- a/sdk/base/lib/exver/index.ts +++ b/sdk/base/lib/exver/index.ts @@ -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 `-${infer A}` ? never : @@ -9,12 +20,32 @@ T extends `${infer A}-${string}` ? ValidateVersion : T extends `${bigint}.${infer A}` ? ValidateVersion : 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}:${infer A}:${infer B}` ? ValidateVersion & ValidateVersion : T extends `${infer A}:${infer B}` ? ValidateVersion & ValidateVersion : 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 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) { 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) { 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: T & ValidateExVer) => 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: T & ValidateVersion) => t diff --git a/sdk/base/lib/inits/setupInit.ts b/sdk/base/lib/inits/setupInit.ts index 25f499e42..577ca0a27 100644 --- a/sdk/base/lib/inits/setupInit.ts +++ b/sdk/base/lib/inits/setupInit.ts @@ -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 = ( effects: T.Effects, kind: Kind, ) => Promise +/** Object form of an init handler — implements an `init()` method. */ export interface InitScript { init(effects: T.Effects, kind: Kind): Promise } +/** Either an {@link InitScript} object or an {@link InitFn} function. */ export type InitScriptOrFn = | InitScript | InitFn +/** + * 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 diff --git a/sdk/base/lib/inits/setupUninit.ts b/sdk/base/lib/inits/setupUninit.ts index 52d111fa5..fee005531 100644 --- a/sdk/base/lib/inits/setupUninit.ts +++ b/sdk/base/lib/inits/setupUninit.ts @@ -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 +/** 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 } +/** 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 diff --git a/sdk/base/lib/s9pk/index.ts b/sdk/base/lib/s9pk/index.ts index 1c3b74c6d..89f13ec59 100644 --- a/sdk/base/lib/s9pk/index.ts +++ b/sdk/base/lib/s9pk/index.ts @@ -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 ``. + * @throws If no icon file is found in the archive + */ async icon(): Promise { 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 { const file = this.archive.contents.getPath(['LICENSE.md']) if (!file || !(file.contents instanceof FileContents)) diff --git a/sdk/base/lib/types.ts b/sdk/base/lib/types.ts index 006e6dc66..050942c7d 100644 --- a/sdk/base/lib/types.ts +++ b/sdk/base/lib/types.ts @@ -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 }> } +/** 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 = Promise | 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 + /** 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>> } +/** + * 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,20 +92,28 @@ 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 + /** Terminates the daemon. */ term(): Promise [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 port: number @@ -96,31 +122,47 @@ export type SmtpValue = { password: string | null | undefined } +/** + * 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 + /** Sends a signal to terminate the daemon. If it doesn't exit within `timeout` ms, sends SIGKILL. */ term(options?: { signal?: Signals; timeout?: number }): Promise } 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 = { [K in keyof T]-?: K extends string ? T[K] extends Function @@ -131,6 +173,7 @@ export type EffectMethod = { : 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 +199,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 +/** Recursively makes all properties of `T` optional. */ export type DeepPartial = T extends [infer A, ...infer Rest] ? [DeepPartial, ...DeepPartial] : T extends {} ? { [P in keyof T]?: DeepPartial } : T +/** Recursively removes all `readonly` modifiers from `T`. */ export type DeepWritable = { -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(value: T): DeepWritable { return value } +/** Recursively makes all properties of `T` readonly. */ export type DeepReadonly = { readonly [P in keyof T]: DeepReadonly } +/** Casts a value to {@link DeepReadonly} (identity at runtime, adds `readonly` at the type level). */ export function readonly(value: T): DeepReadonly { return value } +/** Accepts either a mutable or deeply-readonly version of `T`. */ export type AllowReadonly = | T | { diff --git a/sdk/base/lib/util/asError.ts b/sdk/base/lib/util/asError.ts index 5f0a3884d..dddb4aafe 100644 --- a/sdk/base/lib/util/asError.ts +++ b/sdk/base/lib/util/asError.ts @@ -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) diff --git a/sdk/base/lib/util/deepEqual.ts b/sdk/base/lib/util/deepEqual.ts index cee9c67ff..4c53807a9 100644 --- a/sdk/base/lib/util/deepEqual.ts +++ b/sdk/base/lib/util/deepEqual.ts @@ -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, diff --git a/sdk/base/lib/util/deepMerge.ts b/sdk/base/lib/util/deepMerge.ts index f64a2ef50..0742acb73 100644 --- a/sdk/base/lib/util/deepMerge.ts +++ b/sdk/base/lib/util/deepMerge.ts @@ -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( prev: T, next: T, @@ -46,6 +56,14 @@ export function partialDiff( } } +/** + * 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 diff --git a/sdk/base/lib/util/getDefaultString.ts b/sdk/base/lib/util/getDefaultString.ts index 7468c1e00..831292a09 100644 --- a/sdk/base/lib/util/getDefaultString.ts +++ b/sdk/base/lib/util/getDefaultString.ts @@ -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 diff --git a/sdk/base/lib/util/graph.ts b/sdk/base/lib/util/graph.ts index 5f6de64fd..7d2bda0d7 100644 --- a/sdk/base/lib/util/graph.ts +++ b/sdk/base/lib/util/graph.ts @@ -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 = { metadata: VMetadata edges: Array> } +/** + * 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 = { metadata: EMetadata from: Vertex to: Vertex } +/** + * 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 { private readonly vertices: Array> = [] 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 { 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, 'to'>>, @@ -60,6 +89,11 @@ export class Graph { 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) => boolean, ): Generator, null> { @@ -74,6 +108,13 @@ export class Graph { } 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, @@ -88,6 +129,11 @@ export class Graph { 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 @@ -139,6 +185,11 @@ export class Graph { 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 @@ -190,6 +241,12 @@ export class Graph { 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 diff --git a/sdk/base/lib/util/inMs.ts b/sdk/base/lib/util/inMs.ts index bf03f78d4..e7a26e509 100644 --- a/sdk/base/lib/util/inMs.ts +++ b/sdk/base/lib/util/inMs.ts @@ -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 diff --git a/sdk/base/lib/util/ip.ts b/sdk/base/lib/util/ip.ts index 894d1f08b..ae152e4b6 100644 --- a/sdk/base/lib/util/ip.ts +++ b/sdk/base/lib/util/ip.ts @@ -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') diff --git a/sdk/base/lib/util/once.ts b/sdk/base/lib/util/once.ts index 5f689b0e1..98c2d91df 100644 --- a/sdk/base/lib/util/once.ts +++ b/sdk/base/lib/util/once.ts @@ -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(fn: () => B): () => B { let result: [B] | [] = [] return () => { diff --git a/sdk/base/lib/util/patterns.ts b/sdk/base/lib/util/patterns.ts index b1f54c44d..c55a36797 100644 --- a/sdk/base/lib/util/patterns.ts +++ b/sdk/base/lib/util/patterns.ts @@ -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 "`). */ 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: diff --git a/sdk/base/lib/util/regexes.ts b/sdk/base/lib/util/regexes.ts index c5a78b2bb..3fa372d8c 100644 --- a/sdk/base/lib/util/regexes.ts +++ b/sdk/base/lib/util/regexes.ts @@ -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 "`). */ 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 = { [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 = 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 `${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 /* 'abc' */ : never +/** A generic object type with string keys and unknown values. */ export type StringObject = Record function test() { diff --git a/sdk/package/lib/StartSdk.ts b/sdk/package/lib/StartSdk.ts index 9eb0036d1..75a3d023d 100644 --- a/sdk/package/lib/StartSdk.ts +++ b/sdk/package/lib/StartSdk.ts @@ -62,6 +62,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 +72,29 @@ type AnyNeverCond = T extends [any, ...infer U] ? AnyNeverCond : 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 { 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(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: Manifest) { return new StartSdk(manifest) } @@ -88,6 +107,14 @@ export class StartSdk { 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 +164,19 @@ export class StartSdk { } 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: >( effects: T.Effects, packageId: T.PackageId, @@ -158,6 +191,7 @@ export class StartSdk { severity, options: options, }), + /** Create a task notification for this service's own action (uses manifest.id automatically) */ createOwnTask: >( effects: T.Effects, action: T, @@ -171,9 +205,20 @@ export class StartSdk { 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 +227,25 @@ export class StartSdk { packageIds?: DependencyId[], ) => Promise>, 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 +338,22 @@ export class StartSdk { }, 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), /** @@ -444,21 +516,37 @@ export class StartSdk { masked: boolean }, ) => new ServiceInterfaceBuilder({ ...options, effects }), + /** + * Get the system SMTP configuration with reactive subscription support. + * @param effects - The effects context + */ getSystemSmtp: (effects: E) => new GetSystemSmtp(effects), + /** + * Get the outbound network gateway address with reactive subscription support. + * @param effects - The effects context + */ getOutboundGateway: (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: ( 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 +726,47 @@ export class StartSdk { * ``` */ 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>, ) => setupMain(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, }, 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, + /** + * Create a Backups configuration from explicit sync path pairs. + * @param syncs - Array of `{ dataPath, backupPath }` objects + */ ofSyncs: Backups.ofSyncs, + /** + * Create a Backups configuration with custom rsync options (e.g. exclude patterns). + * @param options - Partial sync options to override defaults + */ withOptions: Backups.withOptions, }, InputSpec: { @@ -687,11 +801,20 @@ export class StartSdk { InputSpec.of(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() }, }, 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({ effects }) }, @@ -798,6 +921,19 @@ export class StartSdk { } } +/** + * 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( effects: Effects, image: { imageId: keyof Manifest['images'] & T.ImageId; sharedRun?: boolean }, diff --git a/sdk/package/lib/backup/Backups.ts b/sdk/package/lib/backup/Backups.ts index 8add5dba3..5acefcb1c 100644 --- a/sdk/package/lib/backup/Backups.ts +++ b/sdk/package/lib/backup/Backups.ts @@ -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 = { dataPath: `/media/startos/volumes/${Volumes}/${string}` backupPath: `/media/startos/backup/${string}` @@ -17,8 +19,18 @@ export type BackupSync = { restoreOptions?: Partial } +/** 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 implements InitScript { private constructor( private options = DEFAULT_OPTIONS, @@ -31,6 +43,11 @@ export class Backups 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( ...volumeNames: Array ): Backups { @@ -42,18 +59,31 @@ export class Backups 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( ...syncs: BackupSync[] ) { return syncs.reduce((acc, x) => acc.addSync(x), new Backups()) } + /** + * 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( options?: Partial, ) { return new Backups({ ...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) { this.options = { ...this.options, @@ -62,6 +92,10 @@ export class Backups 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) { this.backupOptions = { ...this.backupOptions, @@ -70,6 +104,10 @@ export class Backups 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) { this.restoreOptions = { ...this.restoreOptions, @@ -78,26 +116,47 @@ export class Backups 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) { 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) { 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) { 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) { 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 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) { 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 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) diff --git a/sdk/package/lib/backup/setupBackups.ts b/sdk/package/lib/backup/setupBackups.ts index 7c605f849..8b31a7c65 100644 --- a/sdk/package/lib/backup/setupBackups.ts +++ b/sdk/package/lib/backup/setupBackups.ts @@ -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['volumes'][number][] | ((_: { effects: T.Effects }) => Promise>) @@ -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( options: SetupBackupsParams, ) { diff --git a/sdk/package/lib/health/HealthCheck.ts b/sdk/package/lib/health/HealthCheck.ts index 191c322ff..ec1443033 100644 --- a/sdk/package/lib/health/HealthCheck.ts +++ b/sdk/package/lib/health/HealthCheck.ts @@ -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 } +/** + * 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) diff --git a/sdk/package/lib/health/checkFns/HealthCheckResult.ts b/sdk/package/lib/health/checkFns/HealthCheckResult.ts index f62eacfbc..ce610bcc7 100644 --- a/sdk/package/lib/health/checkFns/HealthCheckResult.ts +++ b/sdk/package/lib/health/checkFns/HealthCheckResult.ts @@ -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 diff --git a/sdk/package/lib/health/checkFns/index.ts b/sdk/package/lib/health/checkFns/index.ts index cfd297324..c493171d5 100644 --- a/sdk/package/lib/health/checkFns/index.ts +++ b/sdk/package/lib/health/checkFns/index.ts @@ -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((resolve, reject) => setTimeout(() => reject(new Error(message)), ms), diff --git a/sdk/package/lib/mainFn/CommandController.ts b/sdk/package/lib/mainFn/CommandController.ts index e58761a6c..d8f290aa3 100644 --- a/sdk/package/lib/mainFn/CommandController.ts +++ b/sdk/package/lib/mainFn/CommandController.ts @@ -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 | 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 | 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) { diff --git a/sdk/package/lib/mainFn/Daemon.ts b/sdk/package/lib/mainFn/Daemon.ts index a0110698d..fcbf1c9cb 100644 --- a/sdk/package/lib/mainFn/Daemon.ts +++ b/sdk/package/lib/mainFn/Daemon.ts @@ -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 | null = SubContainer | 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 { 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() { return | 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 | null { return this.subcontainer?.rc() ?? null } + /** Check whether this daemon shares the same subcontainer as another daemon */ sharesSubcontainerWith( other: Daemon | 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) } diff --git a/sdk/package/lib/mainFn/Daemons.ts b/sdk/package/lib/mainFn/Daemons.ts index 374073518..4b6bc69c0 100644 --- a/sdk/package/lib/mainFn/Daemons.ts +++ b/sdk/package/lib/mainFn/Daemons.ts @@ -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 | 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 | null, @@ -385,6 +405,13 @@ export class Daemons 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 } } + /** + * 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() diff --git a/sdk/package/lib/mainFn/Mounts.ts b/sdk/package/lib/mainFn/Mounts.ts index b3eb11945..653637fb8 100644 --- a/sdk/package/lib/mainFn/Mounts.ts +++ b/sdk/package/lib/mainFn/Mounts.ts @@ -49,6 +49,15 @@ type DependencyOpts = { 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() { return new Mounts([], [], [], []) } + /** + * 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) { return new Mounts( [...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( [...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( options: DependencyOpts, ) { @@ -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 diff --git a/sdk/package/lib/mainFn/index.ts b/sdk/package/lib/mainFn/index.ts index 279cfce29..36c951b2d 100644 --- a/sdk/package/lib/mainFn/index.ts +++ b/sdk/package/lib/mainFn/index.ts @@ -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. diff --git a/sdk/package/lib/manifest/setupManifest.ts b/sdk/package/lib/manifest/setupManifest.ts index 0add560ba..fad59950a 100644 --- a/sdk/package/lib/manifest/setupManifest.ts +++ b/sdk/package/lib/manifest/setupManifest.ts @@ -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, diff --git a/sdk/package/lib/util/SubContainer.ts b/sdk/package/lib/util/SubContainer.ts index 41b70f0c5..70c587eea 100644 --- a/sdk/package/lib/util/SubContainer.ts +++ b/sdk/package/lib/util/SubContainer.ts @@ -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, ): Promise + /** Destroy this subcontainer and clean up its filesystem */ destroy: () => Promise /** @@ -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 + /** + * 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[2], ): Promise + /** + * Create a reference-counted handle to this subcontainer. + * The underlying container is only destroyed when all handles are released. + */ rc(): SubContainerRc + /** Returns true if this is an owned subcontainer (not a reference-counted handle) */ isOwned(): this is SubContainerOwned } @@ -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, diff --git a/sdk/package/lib/util/fileHelper.ts b/sdk/package/lib/util/fileHelper.ts index 65f0e9f8f..de1934f28 100644 --- a/sdk/package/lib/util/fileHelper.ts +++ b/sdk/package/lib/util/fileHelper.ts @@ -84,8 +84,17 @@ function filterUndefined(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` for JSON) + * @typeParam Transformed - The application-level type after transformation + */ export type Transformers = { + /** 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 { ) } + /** + * 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 read( map: (value: A) => B, @@ -575,6 +597,11 @@ export class FileHelper { ) } + /** + * Create a File Helper for a .ini file. + * + * Supports optional encode/decode options and custom transformers. + */ static ini>( path: ToPath, shape: Validator, A>, @@ -601,6 +628,11 @@ export class FileHelper { ) } + /** + * 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>( path: ToPath, shape: Validator, A>, diff --git a/sdk/package/lib/version/VersionGraph.ts b/sdk/package/lib/version/VersionGraph.ts index 08109e023..84d24269e 100644 --- a/sdk/package/lib/version/VersionGraph.ts +++ b/sdk/package/lib/version/VersionGraph.ts @@ -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 implements InitScript, UninitScript { @@ -58,6 +86,7 @@ export class VersionGraph ExtendedVersion | VersionRange, ((opts: { effects: T.Effects }) => Promise) | 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 >(options: { current: VersionInfo; 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 `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 ) .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 .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 { const from = await getDataVersion(effects) if (from) { @@ -265,6 +319,13 @@ export class VersionGraph } } + /** + * 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, diff --git a/sdk/package/lib/version/VersionInfo.ts b/sdk/package/lib/version/VersionInfo.ts index 9a6cb4e78..64b837a83 100644 --- a/sdk/package/lib/version/VersionInfo.ts +++ b/sdk/package/lib/version/VersionInfo.ts @@ -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 = { /** The exver-compliant version number */ version: Version & ValidateExVer @@ -33,6 +42,14 @@ export type VersionOptions = { } } +/** + * 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 { private _version: null | Version = null private constructor(