add comments to everything potentially consumer facing

This commit is contained in:
Matt Hill
2026-02-21 20:40:45 -07:00
parent 31352a72c3
commit 804560d43c
39 changed files with 1463 additions and 9 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,20 +20,32 @@ export {
CurrentDependenciesResult,
} from './dependencies/setupDependencies'
/** An object that can be built into a terminable daemon process. */
export type DaemonBuildable = {
build(): Promise<{
term(): Promise<void>
}>
}
/** The three categories of service network interfaces. */
export type ServiceInterfaceType = 'ui' | 'p2p' | 'api'
/** A Node.js signal name (e.g. `"SIGTERM"`, `"SIGKILL"`). */
export type Signals = NodeJS.Signals
/** The SIGTERM signal — used for graceful daemon termination. */
export const SIGTERM: Signals = 'SIGTERM'
/** The SIGKILL signal — used for forceful daemon termination. */
export const SIGKILL: Signals = 'SIGKILL'
/** Sentinel value (`-1`) indicating that no timeout should be applied. */
export const NO_TIMEOUT = -1
/** A function that builds an absolute file path from a volume name and relative path. */
export type PathMaker = (options: { volume: string; path: string }) => string
/** A value that may or may not be wrapped in a `Promise`. */
export type MaybePromise<A> = Promise<A> | A
/**
* Namespace defining the required exports for a StartOS service package.
* Every package must export implementations matching these types.
*/
export namespace ExpectedExports {
version: 1
@@ -62,10 +74,16 @@ export namespace ExpectedExports {
target: ExtendedVersion | VersionRange | null
}) => Promise<unknown>
/** The package manifest describing the service's metadata, dependencies, and interfaces. */
export type manifest = Manifest
/** The map of user-invocable actions defined by this service. */
export type actions = Actions<Record<ActionId, Action<ActionId, any>>>
}
/**
* The complete ABI (Application Binary Interface) for a StartOS service package.
* Maps all required exports to their expected types.
*/
export type ABI = {
createBackup: ExpectedExports.createBackup
main: ExpectedExports.main
@@ -74,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<string>
/** Terminates the daemon. */
term(): Promise<null>
[DaemonProof]: never
}
/** The result status of a health check (extracted from `NamedHealthCheckResult`). */
export type HealthStatus = NamedHealthCheckResult['result']
/** SMTP mail server configuration values. */
export type SmtpValue = {
server: string
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<unknown>
/** Sends a signal to terminate the daemon. If it doesn't exit within `timeout` ms, sends SIGKILL. */
term(options?: { signal?: Signals; timeout?: number }): Promise<null>
}
export declare const hostName: unique symbol
// asdflkjadsf.onion | 1.2.3.4
/** A branded string type for hostnames (e.g. `.onion` addresses or IP addresses). */
export type Hostname = string & { [hostName]: never }
/** A string identifier for a service network interface. */
export type ServiceInterfaceId = string
export { ServiceInterface }
/** Maps effect method names to their kebab-case RPC equivalents. */
export type EffectMethod<T extends StringObject = Effects> = {
[K in keyof T]-?: K extends string
? T[K] extends Function
@@ -131,6 +173,7 @@ export type EffectMethod<T extends StringObject = Effects> = {
: never
}[keyof T]
/** Options for rsync-based file synchronization (used in backup/restore). */
export type SyncOptions = {
/** delete files that exist in the target directory, but not in the source directory */
delete: boolean
@@ -156,49 +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<DependencyRequirement>
/** Recursively makes all properties of `T` optional. */
export type DeepPartial<T> = T extends [infer A, ...infer Rest]
? [DeepPartial<A>, ...DeepPartial<Rest>]
: T extends {}
? { [P in keyof T]?: DeepPartial<T[P]> }
: T
/** Recursively removes all `readonly` modifiers from `T`. */
export type DeepWritable<T> = {
-readonly [K in keyof T]: T[K]
}
/** Casts a value to {@link DeepWritable} (identity at runtime, removes `readonly` at the type level). */
export function writable<T>(value: T): DeepWritable<T> {
return value
}
/** Recursively makes all properties of `T` readonly. */
export type DeepReadonly<T> = {
readonly [P in keyof T]: DeepReadonly<T[P]>
}
/** Casts a value to {@link DeepReadonly} (identity at runtime, adds `readonly` at the type level). */
export function readonly<T>(value: T): DeepReadonly<T> {
return value
}
/** Accepts either a mutable or deeply-readonly version of `T`. */
export type AllowReadonly<T> =
| T
| {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,14 @@
/**
* Represents an IPv4 or IPv6 address as raw octets with arithmetic and comparison operations.
*
* IPv4 addresses have 4 octets, IPv6 addresses have 16 octets.
*
* @example
* ```ts
* const ip = IpAddress.parse("192.168.1.1")
* const next = ip.add(1) // 192.168.1.2
* ```
*/
export class IpAddress {
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')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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