sdk comments

This commit is contained in:
Matt Hill
2026-02-05 08:10:53 -07:00
parent 58e0b166cb
commit 9519684185
37 changed files with 3603 additions and 150 deletions

View File

@@ -1,4 +1,40 @@
/**
* @module inputSpecTypes
*
* This module defines the type specifications for action input form fields.
* These types describe the shape of form fields that appear in the StartOS UI
* when users interact with service actions.
*
* Developers typically don't create these types directly - instead, use the
* `Value` class methods (e.g., `Value.text()`, `Value.select()`) which generate
* these specifications with proper defaults and validation.
*
* @see {@link Value} for the builder API
*/
/**
* A complete input specification - a record mapping field names to their specifications.
* This is the top-level type for an action's input form.
*/
export type InputSpec = Record<string, ValueSpec>
/**
* All available input field types.
*
* - `text` - Single-line text input
* - `textarea` - Multi-line text input
* - `number` - Numeric input with optional min/max/step
* - `color` - Color picker
* - `datetime` - Date and/or time picker
* - `toggle` - Boolean on/off switch
* - `select` - Single-selection dropdown/radio
* - `multiselect` - Multiple-selection checkboxes
* - `list` - Dynamic list of items (text or objects)
* - `object` - Nested group of fields (sub-form)
* - `file` - File upload
* - `union` - Conditional fields based on selection (discriminated union)
* - `hidden` - Hidden field (not displayed to user)
*/
export type ValueType =
| "text"
| "textarea"
@@ -13,188 +49,369 @@ export type ValueType =
| "file"
| "union"
| "hidden"
/** Union type of all possible value specifications */
export type ValueSpec = ValueSpecOf<ValueType>
/** core spec types. These types provide the metadata for performing validations */
/**
* Maps a ValueType to its corresponding specification type.
* Core spec types that provide metadata for validation and UI rendering.
*/
// prettier-ignore
export type ValueSpecOf<T extends ValueType> =
T extends "text" ? ValueSpecText :
T extends "textarea" ? ValueSpecTextarea :
T extends "number" ? ValueSpecNumber :
T extends "color" ? ValueSpecColor :
T extends "datetime" ? ValueSpecDatetime :
T extends "toggle" ? ValueSpecToggle :
T extends "select" ? ValueSpecSelect :
T extends "multiselect" ? ValueSpecMultiselect :
T extends "list" ? ValueSpecList :
T extends "object" ? ValueSpecObject :
T extends "file" ? ValueSpecFile :
T extends "union" ? ValueSpecUnion :
export type ValueSpecOf<T extends ValueType> =
T extends "text" ? ValueSpecText :
T extends "textarea" ? ValueSpecTextarea :
T extends "number" ? ValueSpecNumber :
T extends "color" ? ValueSpecColor :
T extends "datetime" ? ValueSpecDatetime :
T extends "toggle" ? ValueSpecToggle :
T extends "select" ? ValueSpecSelect :
T extends "multiselect" ? ValueSpecMultiselect :
T extends "list" ? ValueSpecList :
T extends "object" ? ValueSpecObject :
T extends "file" ? ValueSpecFile :
T extends "union" ? ValueSpecUnion :
T extends "hidden" ? ValueSpecHidden :
never
/**
* Specification for a single-line text input field.
* Use `Value.text()` to create this specification.
*/
export type ValueSpecText = {
/** Display label for the field */
name: string
/** Help text shown below the field */
description: string | null
/** Warning message shown when the value changes (requires user confirmation) */
warning: string | null
type: "text"
/** Regex patterns the value must match, with descriptions for validation errors */
patterns: Pattern[]
/** Minimum character length */
minLength: number | null
/** Maximum character length */
maxLength: number | null
/** If true, displays input as dots (●●●) for sensitive data like passwords */
masked: boolean
/** Browser input mode hint for mobile keyboards */
inputmode: "text" | "email" | "tel" | "url"
/** Placeholder text shown when the field is empty */
placeholder: string | null
/** If true, the field cannot be left empty */
required: boolean
/** Default value (can be a string or random string generator) */
default: DefaultString | null
/** If string, the field is disabled with this message explaining why */
disabled: false | string
/** Configuration for "Generate" button that creates random strings */
generate: null | RandomString
/** If true, the value cannot be changed after initial set */
immutable: boolean
}
/**
* Specification for a multi-line text area input field.
* Use `Value.textarea()` to create this specification.
*/
export type ValueSpecTextarea = {
/** Display label for the field */
name: string
/** Help text shown below the field */
description: string | null
/** Warning message shown when the value changes */
warning: string | null
type: "textarea"
/** Regex patterns the value must match */
patterns: Pattern[]
/** Placeholder text shown when the field is empty */
placeholder: string | null
/** Minimum character length */
minLength: number | null
/** Maximum character length */
maxLength: number | null
/** Minimum visible rows before scrolling */
minRows: number
/** Maximum visible rows before scrolling */
maxRows: number
/** If true, the field cannot be left empty */
required: boolean
/** Default value */
default: string | null
/** If string, the field is disabled with this message */
disabled: false | string
/** If true, the value cannot be changed after initial set */
immutable: boolean
}
/**
* Specification for a numeric input field.
* Use `Value.number()` to create this specification.
*/
export type ValueSpecNumber = {
type: "number"
/** Minimum allowed value */
min: number | null
/** Maximum allowed value */
max: number | null
/** If true, only whole numbers are allowed */
integer: boolean
/** Increment/decrement step for arrow controls */
step: number | null
/** Unit label displayed after the input (e.g., "MB", "seconds") */
units: string | null
/** Placeholder text shown when the field is empty */
placeholder: string | null
/** Display label for the field */
name: string
/** Help text shown below the field */
description: string | null
/** Warning message shown when the value changes */
warning: string | null
/** If true, the field cannot be left empty */
required: boolean
/** Default value */
default: number | null
/** If string, the field is disabled with this message */
disabled: false | string
/** If true, the value cannot be changed after initial set */
immutable: boolean
}
/**
* Specification for a color picker field.
* Use `Value.color()` to create this specification.
*/
export type ValueSpecColor = {
/** Display label for the field */
name: string
/** Help text shown below the field */
description: string | null
/** Warning message shown when the value changes */
warning: string | null
type: "color"
/** If true, a color must be selected */
required: boolean
/** Default color value (hex format, e.g., "ffffff") */
default: string | null
/** If string, the field is disabled with this message */
disabled: false | string
/** If true, the value cannot be changed after initial set */
immutable: boolean
}
/**
* Specification for a date/time picker field.
* Use `Value.datetime()` to create this specification.
*/
export type ValueSpecDatetime = {
/** Display label for the field */
name: string
/** Help text shown below the field */
description: string | null
/** Warning message shown when the value changes */
warning: string | null
type: "datetime"
/** If true, the field cannot be left empty */
required: boolean
/** Type of datetime picker to display */
inputmode: "date" | "time" | "datetime-local"
/** Minimum allowed date/time */
min: string | null
/** Maximum allowed date/time */
max: string | null
/** Default value */
default: string | null
/** If string, the field is disabled with this message */
disabled: false | string
/** If true, the value cannot be changed after initial set */
immutable: boolean
}
/**
* Specification for a single-selection dropdown or radio button group.
* Use `Value.select()` to create this specification.
*/
export type ValueSpecSelect = {
/** Map of option values to their display labels */
values: Record<string, string>
/** Display label for the field */
name: string
/** Help text shown below the field */
description: string | null
/** Warning message shown when the value changes */
warning: string | null
type: "select"
/** Default selected option key */
default: string | null
/** Disabled state: false=enabled, string=disabled with message, string[]=specific options disabled */
disabled: false | string | string[]
/** If true, the value cannot be changed after initial set */
immutable: boolean
}
/**
* Specification for a multiple-selection checkbox group.
* Use `Value.multiselect()` to create this specification.
*/
export type ValueSpecMultiselect = {
/** Map of option values to their display labels */
values: Record<string, string>
/** Display label for the field */
name: string
/** Help text shown below the field */
description: string | null
/** Warning message shown when the value changes */
warning: string | null
type: "multiselect"
/** Minimum number of selections required */
minLength: number | null
/** Maximum number of selections allowed */
maxLength: number | null
/** Disabled state: false=enabled, string=disabled with message, string[]=specific options disabled */
disabled: false | string | string[]
/** Default selected option keys */
default: string[]
/** If true, the value cannot be changed after initial set */
immutable: boolean
}
/**
* Specification for a boolean toggle switch.
* Use `Value.toggle()` to create this specification.
*/
export type ValueSpecToggle = {
/** Display label for the field */
name: string
/** Help text shown below the field */
description: string | null
/** Warning message shown when the value changes */
warning: string | null
type: "toggle"
/** Default value (on/off) */
default: boolean | null
/** If string, the field is disabled with this message */
disabled: false | string
/** If true, the value cannot be changed after initial set */
immutable: boolean
}
/**
* Specification for a discriminated union field (conditional sub-forms).
* Shows different fields based on which variant is selected.
* Use `Value.union()` with `Variants.of()` to create this specification.
*/
export type ValueSpecUnion = {
/** Display label for the field */
name: string
/** Help text shown below the field */
description: string | null
/** Warning message shown when the value changes */
warning: string | null
type: "union"
/** Map of variant keys to their display names and nested field specifications */
variants: Record<
string,
{
/** Display name for this variant option */
name: string
/** Fields to show when this variant is selected */
spec: InputSpec
}
>
/** Disabled state: false=enabled, string=disabled with message, string[]=specific variants disabled */
disabled: false | string | string[]
/** Default selected variant key */
default: string | null
/** If true, the value cannot be changed after initial set */
immutable: boolean
}
/**
* Specification for a file upload field.
* Use `Value.file()` to create this specification.
*/
export type ValueSpecFile = {
/** Display label for the field */
name: string
/** Help text shown below the field */
description: string | null
/** Warning message shown when the value changes */
warning: string | null
type: "file"
/** Allowed file extensions (e.g., [".json", ".yaml"]) */
extensions: string[]
/** If true, a file must be uploaded */
required: boolean
}
/**
* Specification for a nested object (sub-form / field group).
* Use `Value.object()` to create this specification.
*/
export type ValueSpecObject = {
/** Display label for the field group */
name: string
/** Help text shown below the field group */
description: string | null
/** Warning message (not typically used for objects) */
warning: string | null
type: "object"
/** Nested field specifications */
spec: InputSpec
}
/**
* Specification for a hidden field (not displayed in the UI).
* Use `Value.hidden()` to create this specification.
* Useful for storing internal state that shouldn't be user-editable.
*/
export type ValueSpecHidden = {
type: "hidden"
}
/** Types of items that can appear in a list */
export type ListValueSpecType = "text" | "object"
/** Maps a list item type to its specification */
// prettier-ignore
export type ListValueSpecOf<T extends ListValueSpecType> =
export type ListValueSpecOf<T extends ListValueSpecType> =
T extends "text" ? ListValueSpecText :
T extends "object" ? ListValueSpecObject :
never
/** Union of all list specification types */
export type ValueSpecList = ValueSpecListOf<ListValueSpecType>
/**
* Specification for a dynamic list of items.
* Use `Value.list()` with `List.text()` or `List.obj()` to create this specification.
*/
export type ValueSpecListOf<T extends ListValueSpecType> = {
/** Display label for the list field */
name: string
/** Help text shown below the list */
description: string | null
/** Warning message shown when items change */
warning: string | null
type: "list"
/** Specification for individual list items */
spec: ListValueSpecOf<T>
/** Minimum number of items required */
minLength: number | null
/** Maximum number of items allowed */
maxLength: number | null
/** If string, the list is disabled with this message */
disabled: false | string
/** Default list items */
default:
| string[]
| DefaultString[]
@@ -203,28 +420,62 @@ export type ValueSpecListOf<T extends ListValueSpecType> = {
| readonly DefaultString[]
| readonly Record<string, unknown>[]
}
/**
* A validation pattern with a regex and human-readable description.
* Used to validate text input and provide meaningful error messages.
*/
export type Pattern = {
/** Regular expression pattern (as a string) */
regex: string
/** Human-readable description shown when validation fails */
description: string
}
/**
* Specification for text items within a list.
* Created via `List.text()`.
*/
export type ListValueSpecText = {
type: "text"
/** Regex patterns each item must match */
patterns: Pattern[]
/** Minimum character length per item */
minLength: number | null
/** Maximum character length per item */
maxLength: number | null
/** If true, displays items as dots (●●●) */
masked: boolean
/** Configuration for "Generate" button */
generate: null | RandomString
/** Browser input mode hint */
inputmode: "text" | "email" | "tel" | "url"
/** Placeholder text for each item */
placeholder: string | null
}
/**
* Specification for object items within a list.
* Created via `List.obj()`.
*/
export type ListValueSpecObject = {
type: "object"
/** Field specification for each object in the list */
spec: InputSpec
/** Constraint for ensuring unique items in the list */
uniqueBy: UniqueBy
/** Template string for how to display each item in the list (e.g., "{name} - {email}") */
displayAs: string | null
}
/**
* Defines how to determine uniqueness for list items.
* - `null` - No uniqueness constraint
* - `string` - Field name that must be unique (e.g., "email")
* - `{ any: UniqueBy[] }` - Any of the specified constraints must be unique
* - `{ all: UniqueBy[] }` - All of the specified constraints combined must be unique
*/
export type UniqueBy =
| null
| string
@@ -234,12 +485,30 @@ export type UniqueBy =
| {
all: readonly UniqueBy[] | UniqueBy[]
}
/**
* Default value for a text field - either a literal string or a random string generator.
*/
export type DefaultString = string | RandomString
/**
* Configuration for generating random strings (e.g., passwords, tokens).
* Used with `Value.text({ generate: ... })` to show a "Generate" button.
*/
export type RandomString = {
/** Characters to use when generating (e.g., "abcdefghijklmnopqrstuvwxyz0123456789") */
charset: string
/** Length of the generated string */
len: number
}
// sometimes the type checker needs just a little bit of help
/**
* Type guard to check if a ValueSpec is a list of a specific item type.
*
* @param t - The value specification to check
* @param s - The expected list item type ("text" or "object")
* @returns True if the spec is a list of the specified type
*/
export function isValueSpecListOf<S extends ListValueSpecType>(
t: ValueSpec,
s: S,

View File

@@ -1,3 +1,31 @@
/**
* @module setupActions
*
* This module provides the Action and Actions classes for defining user-callable
* operations in StartOS services. Actions appear in the StartOS UI and can be
* triggered by users or programmatically by other services.
*
* @example
* ```typescript
* import { Action, Actions, InputSpec, Value } from '@start9labs/start-sdk'
*
* const resetPasswordAction = Action.withInput(
* 'reset-password',
* { name: 'Reset Password', description: 'Reset the admin password' },
* InputSpec.of({
* username: Value.text({ name: 'Username', required: true, default: null })
* }),
* async ({ effects }) => ({ username: 'admin' }), // Pre-fill form
* async ({ effects, input }) => {
* // Perform the password reset
* return { result: { type: 'single', value: 'Password reset successfully' } }
* }
* )
*
* export const actions = Actions.of().addAction(resetPasswordAction)
* ```
*/
import { InputSpec } from "./input/builder"
import { ExtractInputSpecType } from "./input/builder/inputSpec"
import * as T from "../types"
@@ -5,16 +33,54 @@ import { once } from "../util"
import { InitScript } from "../inits"
import { Parser } from "ts-matches"
/** @internal Input spec type or null if the action has no input */
type MaybeInputSpec<Type> = {} extends Type ? null : InputSpec<Type>
/**
* Function signature for executing an action.
*
* @typeParam A - The type of the validated input object
* @param options.effects - Effects instance for system operations
* @param options.input - The validated user input
* @param options.spec - The input specification used to generate the form
* @returns Promise resolving to an ActionResult to display to the user, or null/void for no result
*/
export type Run<A extends Record<string, any>> = (options: {
effects: T.Effects
input: A
spec: T.inputSpecTypes.InputSpec
}) => Promise<(T.ActionResult & { version: "1" }) | null | void | undefined>
/**
* Function signature for pre-filling action input forms.
* Called before displaying the input form to populate default values.
*
* @typeParam A - The type of the input object
* @param options.effects - Effects instance for system operations
* @returns Promise resolving to partial input values to pre-fill, or null for no pre-fill
*/
export type GetInput<A extends Record<string, any>> = (options: {
effects: T.Effects
}) => Promise<null | void | undefined | T.DeepPartial<A>>
/**
* A value that can either be static or computed dynamically from Effects.
* Used for action metadata that may need to change based on service state.
*
* @typeParam T - The type of the value
*
* @example
* ```typescript
* // Static metadata
* const metadata: MaybeFn<ActionMetadata> = { name: 'My Action' }
*
* // Dynamic metadata based on service state
* const dynamicMetadata: MaybeFn<ActionMetadata> = async ({ effects }) => {
* const isEnabled = await checkSomething(effects)
* return { name: isEnabled ? 'Disable Feature' : 'Enable Feature' }
* }
* ```
*/
export type MaybeFn<T> = T | ((options: { effects: T.Effects }) => Promise<T>)
function callMaybeFn<T>(
maybeFn: MaybeFn<T>,
@@ -37,29 +103,76 @@ function mapMaybeFn<T, U>(
}
}
/**
* Type information interface for an Action.
* Used for type inference in the Actions collection.
*
* @typeParam Id - The action's unique identifier type
* @typeParam Type - The action's input type
*/
export interface ActionInfo<
Id extends T.ActionId,
Type extends Record<string, any>,
> {
/** The unique identifier for this action */
readonly id: Id
/** @internal Type brand for input type inference */
readonly _INPUT: Type
}
/**
* Represents a user-callable action in a StartOS service.
*
* Exposed via `sdk.Action`. Actions are operations that users can trigger
* from the StartOS UI or that can be invoked programmatically. Each action has:
* - A unique ID
* - Metadata (name, description, visibility, etc.)
* - Optional input specification (form fields)
* - A run function that executes the action
*
* Use `sdk.Action.withInput()` for actions that require user input, or
* `sdk.Action.withoutInput()` for actions that run immediately.
*
* See the SDK documentation for detailed examples.
*
* @typeParam Id - The action's unique identifier type
* @typeParam Type - The action's input type (empty object {} for no input)
*/
export class Action<Id extends T.ActionId, Type extends Record<string, any>>
implements ActionInfo<Id, Type>
{
/** @internal Type brand for input type inference */
readonly _INPUT: Type = null as any as Type
/** @internal Cache of built input specs by event ID */
private prevInputSpec: Record<
string,
{ spec: T.inputSpecTypes.InputSpec; validator: Parser<unknown, Type> }
> = {}
private constructor(
/** The unique identifier for this action */
readonly id: Id,
private readonly metadataFn: MaybeFn<T.ActionMetadata>,
private readonly inputSpec: MaybeInputSpec<Type>,
private readonly getInputFn: GetInput<Type>,
private readonly runFn: Run<Type>,
) {}
/**
* Creates an action that requires user input before execution.
* The input form is defined by an InputSpec.
*
* @typeParam Id - The action ID type
* @typeParam InputSpecType - The input specification type
*
* @param id - Unique identifier for the action (used in URLs and API calls)
* @param metadata - Action metadata (name, description, visibility, etc.) - can be static or dynamic
* @param inputSpec - Specification for the input form fields
* @param getInput - Function to pre-populate the form with default/previous values
* @param run - Function to execute when the action is submitted
* @returns A new Action instance
*/
static withInput<
Id extends T.ActionId,
InputSpecType extends InputSpec<Record<string, any>>,
@@ -78,6 +191,18 @@ export class Action<Id extends T.ActionId, Type extends Record<string, any>>
run,
)
}
/**
* Creates an action that executes immediately without requiring user input.
* Use this for simple operations like toggles, restarts, or status checks.
*
* @typeParam Id - The action ID type
*
* @param id - Unique identifier for the action
* @param metadata - Action metadata (name, description, visibility, etc.) - can be static or dynamic
* @param run - Function to execute when the action is triggered
* @returns A new Action instance with no input
*/
static withoutInput<Id extends T.ActionId>(
id: Id,
metadata: MaybeFn<Omit<T.ActionMetadata, "hasInput">>,
@@ -91,6 +216,14 @@ export class Action<Id extends T.ActionId, Type extends Record<string, any>>
run,
)
}
/**
* Exports the action's metadata to StartOS, making it visible in the UI.
* Called automatically during initialization by the Actions collection.
*
* @param options.effects - Effects instance for system operations
* @returns Promise resolving to the exported metadata
* @internal
*/
async exportMetadata(options: {
effects: T.Effects
}): Promise<T.ActionMetadata> {
@@ -104,6 +237,15 @@ export class Action<Id extends T.ActionId, Type extends Record<string, any>>
await options.effects.action.export({ id: this.id, metadata })
return metadata
}
/**
* Builds and returns the input specification and pre-filled values for this action.
* Called by StartOS when a user clicks on the action to display the input form.
*
* @param options.effects - Effects instance for system operations
* @returns Promise resolving to the input specification and pre-filled values
* @internal
*/
async getInput(options: { effects: T.Effects }): Promise<T.ActionInput> {
let spec = {}
if (this.inputSpec) {
@@ -121,6 +263,16 @@ export class Action<Id extends T.ActionId, Type extends Record<string, any>>
| undefined) || null,
}
}
/**
* Executes the action with the provided input.
* Called by StartOS when a user submits the action form.
*
* @param options.effects - Effects instance for system operations
* @param options.input - The user-provided input (validated against the input spec)
* @returns Promise resolving to the action result to display, or null for no result
* @internal
*/
async run(options: {
effects: T.Effects
input: Type
@@ -146,19 +298,77 @@ export class Action<Id extends T.ActionId, Type extends Record<string, any>>
}
}
/**
* A collection of actions for a StartOS service.
*
* Exposed via `sdk.Actions`. The Actions class manages the registration and
* lifecycle of all actions in a service. It implements InitScript so it can
* be included in the initialization pipeline to automatically register actions
* with StartOS.
*
* @typeParam AllActions - Record type mapping action IDs to Action instances
*
* @example
* ```typescript
* // Create an actions collection
* export const actions = sdk.Actions.of()
* .addAction(createUserAction)
* .addAction(resetPasswordAction)
* .addAction(restartAction)
*
* // Include in init pipeline
* export const init = sdk.setupInit(
* versionGraph,
* setInterfaces,
* actions, // Actions are registered here
* )
* ```
*/
export class Actions<
AllActions extends Record<T.ActionId, Action<T.ActionId, any>>,
> implements InitScript
{
private constructor(private readonly actions: AllActions) {}
/**
* Creates a new empty Actions collection.
* Use `addAction()` to add actions to the collection.
*
* @returns A new empty Actions instance
*/
static of(): Actions<{}> {
return new Actions({})
}
/**
* Adds an action to the collection.
* Returns a new Actions instance with the action included (immutable pattern).
*
* @typeParam A - The action type being added
* @param action - The action to add
* @returns A new Actions instance containing all previous actions plus the new one
*
* @example
* ```typescript
* const actions = Actions.of()
* .addAction(action1)
* .addAction(action2)
* ```
*/
addAction<A extends Action<T.ActionId, any>>(
action: A, // TODO: prevent duplicates
): Actions<AllActions & { [id in A["id"]]: A }> {
return new Actions({ ...this.actions, [action.id]: action })
}
/**
* Initializes all actions by exporting their metadata to StartOS.
* Called automatically when included in the init pipeline.
* Also clears any previously registered actions that are no longer in the collection.
*
* @param effects - Effects instance for system operations
* @internal
*/
async init(effects: T.Effects): Promise<void> {
for (let action of Object.values(this.actions)) {
const fn = async () => {
@@ -180,6 +390,15 @@ export class Actions<
}
await effects.action.clear({ except: Object.keys(this.actions) })
}
/**
* Retrieves an action from the collection by its ID.
* Useful for programmatically invoking actions or inspecting their configuration.
*
* @typeParam Id - The action ID type
* @param actionId - The ID of the action to retrieve
* @returns The action instance
*/
get<Id extends T.ActionId>(actionId: Id): AllActions[Id] {
return this.actions[actionId]
}