diff --git a/sdk/base/lib/Effects.ts b/sdk/base/lib/Effects.ts index db543f7bc..48fe6c19f 100644 --- a/sdk/base/lib/Effects.ts +++ b/sdk/base/lib/Effects.ts @@ -25,151 +25,560 @@ import { ActionResult, } from "./types" -/** Used to reach out from the pure js runtime */ - +/** + * The Effects interface is the primary mechanism for a StartOS service to interact + * with the host operating system. All system operations—file I/O, networking, + * health reporting, dependency management, and more—are performed through Effects. + * + * Effects are passed to all lifecycle functions (main, init, uninit, actions, etc.) + * and provide a controlled, sandboxed API for service operations. + * + * @example + * ```typescript + * export const main = sdk.setupMain(async ({ effects }) => { + * // Use effects to interact with the system + * const smtp = await effects.getSystemSmtp({}) + * await effects.setHealth({ name: 'server', result: { result: 'success' } }) + * }) + * ``` + */ export type Effects = { + /** + * A unique identifier for the current event/request context. + * Returns null when not executing within an event context. + * Useful for correlating logs and tracking request flows. + */ readonly eventId: string | null + + /** + * Creates a child Effects context with a namespaced identifier. + * Child contexts inherit the parent's capabilities but have their own + * event tracking namespace, useful for organizing complex operations. + * + * @param name - The name to append to the context namespace + * @returns A new Effects instance scoped to the child context + */ child: (name: string) => Effects + + /** + * Internal retry mechanism for `.const()` operations. + * Called automatically when a const operation needs to be retried + * due to dependency changes. Not typically used directly by service developers. + */ constRetry?: () => void + + /** + * Indicates whether the Effects instance is currently within a valid execution context. + * Returns false if the context has been destroyed or left. + */ isInContext: boolean + + /** + * Registers a cleanup callback to be invoked when leaving the current context. + * Use this to clean up resources, close connections, or perform other teardown + * operations when the service or action completes. + * + * @param fn - Cleanup function to execute on context exit. Can return void, null, or undefined. + * + * @example + * ```typescript + * effects.onLeaveContext(() => { + * socket.close() + * clearInterval(healthCheckInterval) + * }) + * ``` + */ onLeaveContext: (fn: () => void | null | undefined) => void + + /** + * Clears registered callbacks by their internal IDs. + * Used for cleanup when callbacks are no longer needed. + * + * @param options - Either `{ only: number[] }` to clear specific callbacks, + * or `{ except: number[] }` to clear all except the specified ones + * @returns Promise resolving to null on completion + */ clearCallbacks: ( options: { only: number[] } | { except: number[] }, ) => Promise - // action + /** + * Action-related methods for defining, invoking, and managing user-callable operations. + * Actions appear in the StartOS UI and can be triggered by users or other services. + */ action: { - /** Define an action that can be invoked by a user or service */ + /** + * Exports an action to make it available in the StartOS UI. + * Call this during initialization to register actions that users can invoke. + * + * @param options.id - Unique identifier for the action + * @param options.metadata - Action configuration including name, description, input spec, and visibility + * @returns Promise resolving to null on success + */ export(options: { id: ActionId; metadata: ActionMetadata }): Promise - /** Remove all exported actions */ + + /** + * Removes all exported actions except those specified. + * Typically called during initialization before re-registering current actions. + * + * @param options.except - Array of action IDs to keep (not remove) + * @returns Promise resolving to null on success + */ clear(options: { except: ActionId[] }): Promise + + /** + * Retrieves the previously submitted input for an action. + * Useful for pre-filling forms with the last-used values. + * + * @param options.packageId - Package ID (defaults to current package if omitted) + * @param options.actionId - The action whose input to retrieve + * @returns Promise resolving to the stored input, or null if none exists + */ getInput(options: { packageId?: PackageId actionId: ActionId }): Promise + + /** + * Programmatically invokes an action on this or another service. + * Enables service-to-service communication and automation. + * + * @param options.packageId - Target package ID (defaults to current package if omitted) + * @param options.actionId - The action to invoke + * @param options.input - Input data matching the action's input specification + * @returns Promise resolving to the action result, or null if the action doesn't exist + */ run>(options: { packageId?: PackageId actionId: ActionId input?: Input }): Promise + + /** + * Creates a task that appears in the StartOS UI task list. + * Tasks are used for long-running operations or required setup steps + * that need user attention (e.g., "Create admin user", "Configure backup"). + * + * @param options - Task configuration including ID, name, description, and completion criteria + * @returns Promise resolving to null on success + */ createTask(options: CreateTaskParams): Promise + + /** + * Removes tasks from the UI task list. + * + * @param options - Either `{ only: string[] }` to remove specific tasks, + * or `{ except: string[] }` to remove all except specified tasks + * @returns Promise resolving to null on success + */ clearTasks( options: { only: string[] } | { except: string[] }, ): Promise } - // control - /** restart this service's main function */ + // ───────────────────────────────────────────────────────────────────────────── + // Control Methods - Manage service lifecycle + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Restarts this service's main function. + * The current main process will be terminated and a new one started. + * Use this after configuration changes that require a restart to take effect. + * + * @returns Promise resolving to null when the restart has been initiated + */ restart(): Promise - /** stop this service's main function */ + + /** + * Gracefully stops this service's main function. + * The daemon will receive a termination signal and be given time to clean up. + * + * @returns Promise resolving to null when shutdown has been initiated + */ shutdown(): Promise - /** ask the host os what the service's current status is */ + + /** + * Queries the current status of a service from the host OS. + * + * @param options.packageId - Package to query (defaults to current package if omitted) + * @param options.callback - Optional callback invoked when status changes (for reactive updates) + * @returns Promise resolving to the service's current status information + */ getStatus(options: { packageId?: PackageId callback?: () => void }): Promise - /** DEPRECATED: indicate to the host os what runstate the service is in */ + + /** + * @deprecated This method is deprecated and should not be used. + * Health status is now managed through the health check system. + * + * Previously used to manually indicate the service's run state to the host OS. + */ setMainStatus(options: SetMainStatus): Promise - // dependency - /** Set the dependencies of what the service needs, usually run during the inputSpec action as a best practice */ + // ───────────────────────────────────────────────────────────────────────────── + // Dependency Methods - Manage service dependencies and inter-service communication + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Declares the runtime dependencies this service requires. + * Dependencies can be marked as "running" (must be actively running) or "exists" (must be installed). + * Call this during initialization to ensure dependencies are satisfied before the service starts. + * + * @param options.dependencies - Array of dependency requirements with package IDs, version ranges, and dependency kind + * @returns Promise resolving to null on success + * + * @example + * ```typescript + * await effects.setDependencies({ + * dependencies: [ + * { packageId: 'bitcoind', versionRange: '>=25.0.0', kind: 'running' }, + * { packageId: 'lnd', versionRange: '>=0.16.0', kind: 'exists' } + * ] + * }) + * ``` + */ setDependencies(options: { dependencies: Dependencies }): Promise - /** Get the list of the dependencies, both the dynamic set by the effect of setDependencies and the end result any required in the manifest */ + + /** + * Retrieves the complete list of dependencies for this service. + * Includes both statically declared dependencies from the manifest + * and dynamically set dependencies from setDependencies(). + * + * @returns Promise resolving to array of all dependency requirements + */ getDependencies(): Promise - /** Test whether current dependency requirements are satisfied */ + + /** + * Tests whether the specified or all dependencies are currently satisfied. + * Use this to verify dependencies are met before performing operations that require them. + * + * @param options.packageIds - Specific packages to check (checks all dependencies if omitted) + * @returns Promise resolving to array of check results indicating satisfaction status + */ checkDependencies(options: { packageIds?: PackageId[] }): Promise - /** mount a volume of a dependency */ + + /** + * Mounts a volume from a dependency service into this service's filesystem. + * Enables read-only or read-write access to another service's data. + * + * @param options - Mount configuration including dependency ID, volume ID, mountpoint, and access mode + * @returns Promise resolving to the mount path + * + * @example + * ```typescript + * // Mount bitcoind's data directory for read access + * const mountPath = await effects.mount({ + * dependencyId: 'bitcoind', + * volumeId: 'main', + * mountpoint: '/mnt/bitcoin', + * readonly: true + * }) + * ``` + */ mount(options: MountParams): Promise - /** Returns a list of the ids of all installed packages */ + + /** + * Returns the package IDs of all services currently installed on the system. + * Useful for discovering available services for optional integrations. + * + * @returns Promise resolving to array of installed package IDs + */ getInstalledPackages(): Promise - /** Returns the manifest of a service */ + + /** + * Retrieves the manifest of another installed service. + * Use this to inspect another service's metadata, version, or capabilities. + * + * @param options.packageId - The package ID to retrieve the manifest for + * @param options.callback - Optional callback invoked when the manifest changes (for reactive updates) + * @returns Promise resolving to the service's manifest + */ getServiceManifest(options: { packageId: PackageId callback?: () => void }): Promise - // health - /** sets the result of a health check */ + // ───────────────────────────────────────────────────────────────────────────── + // Health Methods - Report service health status + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Reports the result of a health check to the StartOS UI. + * Health checks appear in the service's status panel and indicate operational status. + * + * @param o - Health check result including the check name and result status (success/failure/starting) + * @returns Promise resolving to null on success + * + * @example + * ```typescript + * await effects.setHealth({ + * name: 'web-interface', + * result: { result: 'success', message: 'Web UI is accessible' } + * }) + * ``` + */ setHealth(o: SetHealth): Promise - // subcontainer + // ───────────────────────────────────────────────────────────────────────────── + // Subcontainer Methods - Low-level container filesystem management + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Low-level APIs for managing subcontainer filesystems. + * These are typically used internally by the SubContainer class. + * Service developers should use `sdk.SubContainer.of()` instead. + */ subcontainer: { - /** A low level api used by SubContainer */ + /** + * Creates a new container filesystem from a Docker image. + * This is a low-level API - prefer using `sdk.SubContainer.of()` for most use cases. + * + * @param options.imageId - The Docker image ID to create the filesystem from + * @param options.name - Optional name for the container (null for anonymous) + * @returns Promise resolving to a tuple of [guid, rootPath] for the created filesystem + */ createFs(options: { imageId: string name: string | null }): Promise<[string, string]> - /** A low level api used by SubContainer */ + + /** + * Destroys a container filesystem and cleans up its resources. + * This is a low-level API - SubContainer handles cleanup automatically. + * + * @param options.guid - The unique identifier of the filesystem to destroy + * @returns Promise resolving to null on success + */ destroyFs(options: { guid: string }): Promise } - // net - // bind - /** Creates a host connected to the specified port with the provided options */ + // ───────────────────────────────────────────────────────────────────────────── + // Network Methods - Port binding, host info, and network configuration + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Binds a network port and creates a host entry for the service. + * This makes the port accessible via the StartOS networking layer (Tor, LAN, etc.). + * + * @param options - Binding configuration including host ID, port, protocol, and network options + * @returns Promise resolving to null on success + * + * @example + * ```typescript + * await effects.bind({ + * id: 'webui', + * internalPort: 8080, + * protocol: 'http' + * }) + * ``` + */ bind(options: BindParams): Promise - /** Get the port address for a service */ + + /** + * Gets the network address information for accessing a service's port. + * Use this to discover how to connect to another service's exposed port. + * + * @param options.packageId - Target package (defaults to current package if omitted) + * @param options.hostId - The host identifier for the binding + * @param options.internalPort - The internal port number + * @returns Promise resolving to network info including addresses and ports + */ getServicePortForward(options: { packageId?: PackageId hostId: HostId internalPort: number }): Promise - /** Removes all network bindings, called in the setupInputSpec */ + + /** + * Removes all network bindings except those specified. + * Typically called during initialization to clean up stale bindings before re-registering. + * + * @param options.except - Array of bindings to preserve (by host ID and port) + * @returns Promise resolving to null on success + */ clearBindings(options: { except: { id: HostId; internalPort: number }[] }): Promise - // host - /** Returns information about the specified host, if it exists */ + + // ───────────────────────────────────────────────────────────────────────────── + // Host Info Methods - Query network host and address information + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Retrieves detailed information about a network host binding. + * + * @param options.packageId - Target package (defaults to current package if omitted) + * @param options.hostId - The host identifier to query + * @param options.callback - Optional callback invoked when host info changes (for reactive updates) + * @returns Promise resolving to host information, or null if the host doesn't exist + */ getHostInfo(options: { packageId?: PackageId hostId: HostId callback?: () => void }): Promise - /** Returns the IP address of the container */ + + /** + * Returns the internal IP address of the service's container. + * Useful for configuring services that need to know their own network address. + * + * @param options.packageId - Target package (defaults to current package if omitted) + * @param options.callback - Optional callback invoked when the IP changes + * @returns Promise resolving to the container's IP address string + */ getContainerIp(options: { packageId?: PackageId callback?: () => void }): Promise - /** Returns the IP address of StartOS */ + + /** + * Returns the IP address of the StartOS host system. + * Useful for services that need to communicate with the host or other system services. + * + * @returns Promise resolving to the StartOS IP address string + */ getOsIp(): Promise - // interface - /** Creates an interface bound to a specific host and port to show to the user */ + + // ───────────────────────────────────────────────────────────────────────────── + // Service Interface Methods - Expose and discover service endpoints + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Exports a service interface that appears in the StartOS UI. + * Service interfaces are the user-visible endpoints (web UIs, APIs, etc.) that users + * can click to access the service. + * + * @param options - Interface configuration including ID, name, description, type, and associated host/port + * @returns Promise resolving to null on success + * + * @example + * ```typescript + * await effects.exportServiceInterface({ + * id: 'webui', + * name: 'Web Interface', + * description: 'Access the web dashboard', + * type: 'ui', + * hostId: 'main', + * internalPort: 8080 + * }) + * ``` + */ exportServiceInterface(options: ExportServiceInterfaceParams): Promise - /** Returns an exported service interface */ + + /** + * Retrieves information about an exported service interface. + * + * @param options.packageId - Target package (defaults to current package if omitted) + * @param options.serviceInterfaceId - The interface identifier to query + * @param options.callback - Optional callback invoked when the interface changes + * @returns Promise resolving to the interface info, or null if it doesn't exist + */ getServiceInterface(options: { packageId?: PackageId serviceInterfaceId: ServiceInterfaceId callback?: () => void }): Promise - /** Returns all exported service interfaces for a package */ + + /** + * Lists all exported service interfaces for a package. + * Useful for discovering what endpoints another service exposes. + * + * @param options.packageId - Target package (defaults to current package if omitted) + * @param options.callback - Optional callback invoked when any interface changes + * @returns Promise resolving to a record mapping interface IDs to their configurations + */ listServiceInterfaces(options: { packageId?: PackageId callback?: () => void }): Promise> - /** Removes all service interfaces */ + + /** + * Removes all service interfaces except those specified. + * Typically called during initialization to clean up stale interfaces before re-registering. + * + * @param options.except - Array of interface IDs to preserve + * @returns Promise resolving to null on success + */ clearServiceInterfaces(options: { except: ServiceInterfaceId[] }): Promise - // ssl - /** Returns a PEM encoded fullchain for the hostnames specified */ + + // ───────────────────────────────────────────────────────────────────────────── + // SSL Methods - Manage TLS certificates + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Retrieves a PEM-encoded SSL certificate chain for the specified hostnames. + * StartOS automatically manages certificate generation and renewal. + * + * @param options.hostnames - Array of hostnames the certificate should cover + * @param options.algorithm - Signing algorithm: "ecdsa" (default) or "ed25519" + * @param options.callback - Optional callback invoked when the certificate is renewed + * @returns Promise resolving to a tuple of [certificate, chain, fullchain] PEM strings + */ getSslCertificate: (options: { hostnames: string[] algorithm?: "ecdsa" | "ed25519" callback?: () => void }) => Promise<[string, string, string]> - /** Returns a PEM encoded private key corresponding to the certificate for the hostnames specified */ + + /** + * Retrieves the PEM-encoded private key corresponding to the SSL certificate. + * + * @param options.hostnames - Array of hostnames (must match a previous getSslCertificate call) + * @param options.algorithm - Signing algorithm: "ecdsa" (default) or "ed25519" + * @returns Promise resolving to the private key PEM string + */ getSslKey: (options: { hostnames: string[] algorithm?: "ecdsa" | "ed25519" }) => Promise - /** sets the version that this service's data has been migrated to */ + // ───────────────────────────────────────────────────────────────────────────── + // Data Version Methods - Track data migration state + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Sets the version that this service's data has been migrated to. + * Used by the version migration system to track which migrations have been applied. + * Service developers typically don't call this directly - it's managed by the version graph. + * + * @param options.version - The version string to record, or null to clear + * @returns Promise resolving to null on success + */ setDataVersion(options: { version: string | null }): Promise - /** returns the version that this service's data has been migrated to */ + + /** + * Returns the version that this service's data has been migrated to. + * Used to determine which migrations need to be applied during updates. + * + * @returns Promise resolving to the current data version string, or null if not set + */ getDataVersion(): Promise - // system - /** Returns globally configured SMTP settings, if they exist */ + // ───────────────────────────────────────────────────────────────────────────── + // System Methods - Access system-wide configuration + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Retrieves the globally configured SMTP settings from StartOS. + * Users can configure SMTP in the StartOS settings for services to use for sending emails. + * + * @param options.callback - Optional callback invoked when SMTP settings change (for reactive updates) + * @returns Promise resolving to SMTP configuration, or null if not configured + * + * @example + * ```typescript + * const smtp = await effects.getSystemSmtp({}) + * if (smtp) { + * console.log(`SMTP server: ${smtp.server}:${smtp.port}`) + * console.log(`From address: ${smtp.from}`) + * } + * ``` + */ getSystemSmtp(options: { callback?: () => void }): Promise } diff --git a/sdk/base/lib/actions/input/inputSpecTypes.ts b/sdk/base/lib/actions/input/inputSpecTypes.ts index e6e267d5d..a09e37737 100644 --- a/sdk/base/lib/actions/input/inputSpecTypes.ts +++ b/sdk/base/lib/actions/input/inputSpecTypes.ts @@ -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 + +/** + * 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 -/** 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 "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 "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 + /** 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 + /** 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 = +export type ListValueSpecOf = T extends "text" ? ListValueSpecText : T extends "object" ? ListValueSpecObject : never + +/** Union of all list specification types */ export type ValueSpecList = ValueSpecListOf + +/** + * Specification for a dynamic list of items. + * Use `Value.list()` with `List.text()` or `List.obj()` to create this specification. + */ export type ValueSpecListOf = { + /** 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 + /** 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 = { | readonly DefaultString[] | readonly Record[] } + +/** + * 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( t: ValueSpec, s: S, diff --git a/sdk/base/lib/actions/setupActions.ts b/sdk/base/lib/actions/setupActions.ts index 26a72a117..2abdc47ac 100644 --- a/sdk/base/lib/actions/setupActions.ts +++ b/sdk/base/lib/actions/setupActions.ts @@ -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 = {} extends Type ? null : InputSpec + +/** + * 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> = (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> = (options: { effects: T.Effects }) => Promise> +/** + * 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 = { name: 'My Action' } + * + * // Dynamic metadata based on service state + * const dynamicMetadata: MaybeFn = async ({ effects }) => { + * const isEnabled = await checkSomething(effects) + * return { name: isEnabled ? 'Disable Feature' : 'Enable Feature' } + * } + * ``` + */ export type MaybeFn = T | ((options: { effects: T.Effects }) => Promise) function callMaybeFn( maybeFn: MaybeFn, @@ -37,29 +103,76 @@ function mapMaybeFn( } } +/** + * 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, > { + /** 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> implements ActionInfo { + /** @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 } > = {} + private constructor( + /** The unique identifier for this action */ readonly id: Id, private readonly metadataFn: MaybeFn, private readonly inputSpec: MaybeInputSpec, private readonly getInputFn: GetInput, private readonly runFn: Run, ) {} + + /** + * 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>, @@ -78,6 +191,18 @@ export class Action> 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: Id, metadata: MaybeFn>, @@ -91,6 +216,14 @@ export class Action> 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 { @@ -104,6 +237,15 @@ export class Action> 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 { let spec = {} if (this.inputSpec) { @@ -121,6 +263,16 @@ export class Action> | 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> } } +/** + * 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>, > 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>( action: A, // TODO: prevent duplicates ): Actions { 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 { 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(actionId: Id): AllActions[Id] { return this.actions[actionId] } diff --git a/sdk/base/lib/dependencies/dependencies.ts b/sdk/base/lib/dependencies/dependencies.ts index 23bd1f1c8..a9a24999a 100644 --- a/sdk/base/lib/dependencies/dependencies.ts +++ b/sdk/base/lib/dependencies/dependencies.ts @@ -1,3 +1,29 @@ +/** + * @module dependencies + * + * This module provides utilities for checking whether service dependencies are satisfied. + * Use `checkDependencies()` to get a helper object with methods for querying and + * validating dependency status. + * + * @example + * ```typescript + * const deps = await checkDependencies(effects, ['bitcoind', 'lnd']) + * + * // Check if all dependencies are satisfied + * if (deps.satisfied()) { + * // All good, proceed + * } + * + * // Or throw an error with details if not satisfied + * deps.throwIfNotSatisfied() + * + * // Check specific aspects + * if (deps.installedSatisfied('bitcoind') && deps.runningSatisfied('bitcoind')) { + * // bitcoind is installed and running + * } + * ``` + */ + import { ExtendedVersion, VersionRange } from "../exver" import { PackageId, @@ -7,32 +33,149 @@ import { } from "../types" import { Effects } from "../Effects" +/** + * Interface providing methods to check and validate dependency satisfaction. + * Returned by `checkDependencies()`. + * + * @typeParam DependencyId - The type of package IDs being checked + */ export type CheckDependencies = { + /** + * Gets the requirement and current result for a specific dependency. + * @param packageId - The package ID to query + * @returns Object containing the requirement spec and check result + * @throws Error if the packageId is not a known dependency + */ infoFor: (packageId: DependencyId) => { requirement: DependencyRequirement result: CheckDependenciesResult } + /** + * Checks if a dependency is installed (regardless of version). + * @param packageId - The package ID to check + * @returns True if the package is installed + */ installedSatisfied: (packageId: DependencyId) => boolean + + /** + * Checks if a dependency is installed with a satisfying version. + * @param packageId - The package ID to check + * @returns True if the installed version satisfies the version range requirement + */ installedVersionSatisfied: (packageId: DependencyId) => boolean + + /** + * Checks if a "running" dependency is actually running. + * Always returns true for "exists" dependencies. + * @param packageId - The package ID to check + * @returns True if the dependency is running (or only needs to exist) + */ runningSatisfied: (packageId: DependencyId) => boolean + + /** + * Checks if all critical tasks for a dependency have been completed. + * @param packageId - The package ID to check + * @returns True if no critical tasks are pending + */ tasksSatisfied: (packageId: DependencyId) => boolean + + /** + * Checks if specified health checks are passing for a dependency. + * @param packageId - The package ID to check + * @param healthCheckId - Specific health check to verify (optional - checks all if omitted) + * @returns True if the health check(s) are passing + */ healthCheckSatisfied: ( packageId: DependencyId, healthCheckId: HealthCheckId, ) => boolean + + /** + * Checks if all dependencies are fully satisfied. + * @returns True if all dependencies meet all requirements + */ satisfied: () => boolean + /** + * Throws an error if the dependency is not installed. + * @param packageId - The package ID to check + * @throws Error with message if not installed + */ throwIfInstalledNotSatisfied: (packageId: DependencyId) => null + + /** + * Throws an error if the installed version doesn't satisfy requirements. + * @param packageId - The package ID to check + * @throws Error with version mismatch details if not satisfied + */ throwIfInstalledVersionNotSatisfied: (packageId: DependencyId) => null + + /** + * Throws an error if a "running" dependency is not running. + * @param packageId - The package ID to check + * @throws Error if the dependency should be running but isn't + */ throwIfRunningNotSatisfied: (packageId: DependencyId) => null + + /** + * Throws an error if critical tasks are pending for the dependency. + * @param packageId - The package ID to check + * @throws Error listing pending critical tasks + */ throwIfTasksNotSatisfied: (packageId: DependencyId) => null + + /** + * Throws an error if health checks are failing for the dependency. + * @param packageId - The package ID to check + * @param healthCheckId - Specific health check (optional - checks all if omitted) + * @throws Error with health check failure details + */ throwIfHealthNotSatisfied: ( packageId: DependencyId, healthCheckId?: HealthCheckId, ) => null + + /** + * Throws an error if any requirements are not satisfied. + * @param packageId - Specific package to check (optional - checks all if omitted) + * @throws Error with detailed message about what's not satisfied + */ throwIfNotSatisfied: (packageId?: DependencyId) => null } +/** + * Checks the satisfaction status of service dependencies. + * Returns a helper object with methods to query and validate dependency status. + * + * This is useful for: + * - Verifying dependencies before starting operations that require them + * - Providing detailed error messages about unsatisfied dependencies + * - Conditionally enabling features based on dependency availability + * + * @typeParam DependencyId - The type of package IDs (defaults to string) + * + * @param effects - Effects instance for system operations + * @param packageIds - Optional array of specific dependencies to check (checks all if omitted) + * @returns Promise resolving to a CheckDependencies helper object + * + * @example + * ```typescript + * // Check all dependencies + * const deps = await checkDependencies(effects) + * deps.throwIfNotSatisfied() // Throws if any dependency isn't met + * + * // Check specific dependencies + * const deps = await checkDependencies(effects, ['bitcoind']) + * if (deps.runningSatisfied('bitcoind') && deps.healthCheckSatisfied('bitcoind', 'rpc')) { + * // Safe to make RPC calls to bitcoind + * } + * + * // Get detailed info + * const info = deps.infoFor('bitcoind') + * console.log(`Installed: ${info.result.installedVersion}`) + * console.log(`Running: ${info.result.isRunning}`) + * ``` + */ export async function checkDependencies< DependencyId extends PackageId = PackageId, >( diff --git a/sdk/base/lib/dependencies/setupDependencies.ts b/sdk/base/lib/dependencies/setupDependencies.ts index 85b88fa66..0dd1419be 100644 --- a/sdk/base/lib/dependencies/setupDependencies.ts +++ b/sdk/base/lib/dependencies/setupDependencies.ts @@ -1,6 +1,41 @@ +/** + * @module setupDependencies + * + * This module provides utilities for declaring and managing service dependencies. + * Dependencies allow services to declare that they require other services to be + * installed and/or running before they can function properly. + * + * @example + * ```typescript + * // In dependencies.ts + * export const setDependencies = sdk.setupDependencies(async ({ effects }) => { + * const config = await store.read(s => s.mediaSource).const(effects) + * + * return { + * // Required dependency - must be running with passing health checks + * bitcoind: { + * kind: 'running', + * versionRange: '>=25.0.0', + * healthChecks: ['rpc'] + * }, + * // Optional dependency - only required if feature is enabled + * ...(config === 'nextcloud' ? { + * nextcloud: { kind: 'exists', versionRange: '>=28.0.0' } + * } : {}) + * } + * }) + * ``` + */ + import * as T from "../types" import { once } from "../util" +/** + * Extracts the package IDs of required (non-optional) dependencies from a manifest. + * Used for type-safe dependency declarations. + * + * @typeParam Manifest - The service manifest type + */ export type RequiredDependenciesOf = { [K in keyof Manifest["dependencies"]]: Exclude< Manifest["dependencies"][K], @@ -9,33 +44,75 @@ export type RequiredDependenciesOf = { ? K : never }[keyof Manifest["dependencies"]] + +/** + * Extracts the package IDs of optional dependencies from a manifest. + * These dependencies are declared in the manifest but marked as optional. + * + * @typeParam Manifest - The service manifest type + */ export type OptionalDependenciesOf = Exclude< keyof Manifest["dependencies"], RequiredDependenciesOf > +/** + * Specifies the requirements for a single dependency. + * + * - `kind: "running"` - The dependency must be installed AND actively running + * with the specified health checks passing + * - `kind: "exists"` - The dependency only needs to be installed (not necessarily running) + */ type DependencyRequirement = | { + /** The dependency must be running */ kind: "running" + /** Health check IDs that must be passing */ healthChecks: Array + /** Semantic version range the dependency must satisfy (e.g., ">=1.0.0") */ versionRange: string } | { + /** The dependency only needs to be installed */ kind: "exists" + /** Semantic version range the dependency must satisfy */ versionRange: string } + +/** @internal Type checking helper */ type Matches = T extends U ? (U extends T ? null : never) : never const _checkType: Matches< DependencyRequirement & { id: T.PackageId }, T.DependencyRequirement > = null +/** + * The return type for dependency declarations. + * Required dependencies must always be specified; optional dependencies may be omitted. + * + * @typeParam Manifest - The service manifest type + */ export type CurrentDependenciesResult = { [K in RequiredDependenciesOf]: DependencyRequirement } & { [K in OptionalDependenciesOf]?: DependencyRequirement } +/** + * Creates a dependency setup function for use in the initialization pipeline. + * + * **Note:** This is exposed via `sdk.setupDependencies`. See the SDK documentation + * for usage examples. + * + * The function you provide will be called during init to determine the current + * dependency requirements, which may vary based on service configuration. + * + * @typeParam Manifest - The service manifest type (inferred from SDK) + * @param fn - Async function that returns the current dependency requirements + * @returns An init-compatible function that sets dependencies via Effects + * + * @see sdk.setupDependencies for usage documentation + */ export function setupDependencies( fn: (options: { effects: T.Effects diff --git a/sdk/base/lib/interfaces/Host.ts b/sdk/base/lib/interfaces/Host.ts index ac693a3b6..24e7c3f5e 100644 --- a/sdk/base/lib/interfaces/Host.ts +++ b/sdk/base/lib/interfaces/Host.ts @@ -1,3 +1,15 @@ +/** + * @module Host + * + * This module provides the MultiHost class for binding network ports and + * exposing service interfaces through the StartOS networking layer. + * + * MultiHost handles the complexity of: + * - Port binding with protocol-specific defaults + * - Automatic SSL/TLS setup for secure protocols + * - Integration with Tor and LAN networking + */ + import { object, string } from "ts-matches" import { Effects } from "../Effects" import { Origin } from "./Origin" @@ -8,37 +20,53 @@ import { AlpnInfo } from "../osBindings" export { AddSslOptions, Security, BindOptions } +/** + * Known protocol definitions with their default ports and SSL variants. + * + * Each protocol includes: + * - `secure` - Whether the protocol is inherently secure (SSL/TLS) + * - `defaultPort` - Standard port for this protocol + * - `withSsl` - The SSL variant of the protocol (if applicable) + * - `alpn` - ALPN negotiation info for TLS + */ export const knownProtocols = { + /** HTTP - plain text web traffic, auto-upgrades to HTTPS */ http: { secure: null, defaultPort: 80, withSsl: "https", alpn: { specified: ["http/1.1"] } as AlpnInfo, }, + /** HTTPS - encrypted web traffic */ https: { secure: { ssl: true }, defaultPort: 443, }, + /** WebSocket - plain text, auto-upgrades to WSS */ ws: { secure: null, defaultPort: 80, withSsl: "wss", alpn: { specified: ["http/1.1"] } as AlpnInfo, }, + /** Secure WebSocket */ wss: { secure: { ssl: true }, defaultPort: 443, }, + /** SSH - inherently secure (no SSL wrapper needed) */ ssh: { secure: { ssl: false }, defaultPort: 22, }, + /** DNS - domain name service */ dns: { secure: { ssl: false }, defaultPort: 53, }, } as const +/** Protocol scheme string or null for no scheme */ export type Scheme = string | null type KnownProtocols = typeof knownProtocols @@ -69,14 +97,47 @@ export type BindOptionsByProtocol = | BindOptionsByKnownProtocol | (BindOptions & { protocol: null }) +/** @internal Helper to detect if protocol is a known protocol string */ const hasStringProtocol = object({ protocol: string, }).test +/** + * Manages network bindings for a service interface. + * + * MultiHost is the primary way to expose your service's ports to users. + * It handles: + * - Port binding with the StartOS networking layer + * - Protocol-aware defaults (HTTP uses port 80, HTTPS uses 443, etc.) + * - Automatic SSL certificate provisioning for secure protocols + * - Creation of Origin objects for exporting service interfaces + * + * @example + * ```typescript + * // Create a host for the web UI + * const webHost = sdk.MultiHost.of(effects, 'webui') + * + * // Bind port 3000 with HTTP (automatically adds HTTPS variant) + * const webOrigin = await webHost.bindPort(3000, { protocol: 'http' }) + * + * // Export the interface + * await webOrigin.export([ + * sdk.ServiceInterfaceBuilder.of({ + * effects, + * name: 'Web Interface', + * id: 'webui', + * description: 'Access the dashboard', + * type: 'ui', + * }) + * ]) + * ``` + */ export class MultiHost { constructor( readonly options: { + /** Effects instance for system operations */ effects: Effects + /** Unique identifier for this host binding */ id: string }, ) {} diff --git a/sdk/base/lib/interfaces/Origin.ts b/sdk/base/lib/interfaces/Origin.ts index e749e48f1..dbf613954 100644 --- a/sdk/base/lib/interfaces/Origin.ts +++ b/sdk/base/lib/interfaces/Origin.ts @@ -1,16 +1,66 @@ +/** + * @module Origin + * + * The Origin class represents a bound network origin (protocol + host + port) + * that can be used to export service interfaces. + */ + import { AddressInfo } from "../types" import { AddressReceipt } from "./AddressReceipt" import { MultiHost, Scheme } from "./Host" import { ServiceInterfaceBuilder } from "./ServiceInterfaceBuilder" +/** + * Represents a network origin (protocol://host:port) created from a MultiHost binding. + * + * Origins are created by calling `MultiHost.bindPort()` and can be used to + * export one or more service interfaces that share the same underlying connection. + * + * @example + * ```typescript + * // Create origin from host binding + * const origin = await webHost.bindPort(8080, { protocol: 'http' }) + * + * // Export multiple interfaces sharing this origin + * await origin.export([ + * sdk.ServiceInterfaceBuilder.of({ + * effects, + * name: 'Web UI', + * id: 'ui', + * type: 'ui', + * description: 'Main web interface', + * }), + * sdk.ServiceInterfaceBuilder.of({ + * effects, + * name: 'REST API', + * id: 'api', + * type: 'api', + * description: 'JSON API endpoint', + * path: '/api/v1', + * }), + * ]) + * ``` + */ export class Origin { constructor( + /** The MultiHost this origin was created from */ readonly host: MultiHost, + /** The internal port this origin is bound to */ readonly internalPort: number, + /** The protocol scheme (e.g., "http", "ssh") or null */ readonly scheme: string | null, + /** The SSL variant scheme (e.g., "https", "wss") or null */ readonly sslScheme: string | null, ) {} + /** + * Builds an AddressInfo object for this origin with the specified options. + * Used internally by `export()` but can be called directly for custom use cases. + * + * @param options - Build options including path, query params, username, and scheme overrides + * @returns AddressInfo object describing the complete address + * @internal + */ build({ username, path, @@ -36,12 +86,28 @@ export class Origin { } /** - * @description A function to register a group of origins ( :// : ) with StartOS + * Exports one or more service interfaces for this origin. * - * The returned addressReceipt serves as proof that the addresses were registered + * Each service interface becomes a clickable link in the StartOS UI. + * Multiple interfaces can share the same origin but have different + * names, descriptions, types, paths, or query parameters. * - * @param addressInfo - * @returns + * @param serviceInterfaces - Array of ServiceInterfaceBuilder objects to export + * @returns Promise resolving to array of AddressInfo with an AddressReceipt + * + * @example + * ```typescript + * await origin.export([ + * sdk.ServiceInterfaceBuilder.of({ + * effects, + * name: 'Admin Panel', + * id: 'admin', + * type: 'ui', + * description: 'Administrator dashboard', + * path: '/admin', + * }) + * ]) + * ``` */ async export( serviceInterfaces: ServiceInterfaceBuilder[], @@ -83,9 +149,17 @@ export class Origin { } } +/** + * Options for building an address from an Origin. + * @internal + */ type BuildOptions = { + /** Override the default schemes for SSL and non-SSL connections */ schemeOverride: { ssl: Scheme; noSsl: Scheme } | null + /** Optional username for basic auth URLs */ username: string | null + /** URL path (e.g., "/api/v1") */ path: string + /** Query parameters to append to the URL */ query: Record } diff --git a/sdk/base/lib/interfaces/ServiceInterfaceBuilder.ts b/sdk/base/lib/interfaces/ServiceInterfaceBuilder.ts index d8cd8e38e..e06f4a378 100644 --- a/sdk/base/lib/interfaces/ServiceInterfaceBuilder.ts +++ b/sdk/base/lib/interfaces/ServiceInterfaceBuilder.ts @@ -1,30 +1,86 @@ +/** + * @module ServiceInterfaceBuilder + * + * Provides the ServiceInterfaceBuilder class for creating service interface + * configurations that are exported to the StartOS UI. + */ + import { ServiceInterfaceType } from "../types" import { Effects } from "../Effects" import { Scheme } from "./Host" /** - * A helper class for creating a Network Interface + * Builder class for creating service interface configurations. * - * Network Interfaces are collections of web addresses that expose the same API or other resource, - * display to the user with under a common name and description. + * A service interface represents a user-visible endpoint in the StartOS UI. + * It appears as a clickable link that users can use to access your service's + * web UI, API, or other network resources. * - * All URIs on an interface inherit the same ui: bool, basic auth credentials, path, and search (query) params + * Interfaces are created with this builder and then exported via `Origin.export()`. * - * @param options - * @returns + * @example + * ```typescript + * // Create a basic web UI interface + * const webInterface = sdk.ServiceInterfaceBuilder.of({ + * effects, + * name: 'Web Interface', + * id: 'webui', + * description: 'Access the main web dashboard', + * type: 'ui', + * }) + * + * // Create an API interface with a specific path + * const apiInterface = sdk.ServiceInterfaceBuilder.of({ + * effects, + * name: 'REST API', + * id: 'api', + * description: 'JSON API for programmatic access', + * type: 'api', + * path: '/api/v1', + * }) + * + * // Create an interface with basic auth + * const protectedInterface = sdk.ServiceInterfaceBuilder.of({ + * effects, + * name: 'Admin Panel', + * id: 'admin', + * description: 'Protected admin area', + * type: 'ui', + * username: 'admin', + * masked: true, // Hide the URL from casual viewing + * }) + * + * // Export all interfaces on the same origin + * await origin.export([webInterface, apiInterface, protectedInterface]) + * ``` */ export class ServiceInterfaceBuilder { constructor( readonly options: { + /** Effects instance for system operations */ effects: Effects + /** Display name shown in the StartOS UI */ name: string + /** Unique identifier for this interface */ id: string + /** Description shown below the interface name */ description: string + /** + * Type of interface: + * - `"ui"` - Web interface (opens in browser) + * - `"api"` - API endpoint (for programmatic access) + * - `"p2p"` - Peer-to-peer endpoint (e.g., Bitcoin P2P) + */ type: ServiceInterfaceType + /** Username for basic auth URLs (null for no auth) */ username: string | null + /** URL path to append (e.g., "/admin", "/api/v1") */ path: string + /** Query parameters to append to the URL */ query: Record + /** Override default protocol schemes */ schemeOverride: { ssl: Scheme; noSsl: Scheme } | null + /** If true, the URL is hidden/masked in the UI (for sensitive endpoints) */ masked: boolean }, ) {} diff --git a/sdk/base/lib/interfaces/setupInterfaces.ts b/sdk/base/lib/interfaces/setupInterfaces.ts index 8e9c4b61a..5263feff5 100644 --- a/sdk/base/lib/interfaces/setupInterfaces.ts +++ b/sdk/base/lib/interfaces/setupInterfaces.ts @@ -1,20 +1,84 @@ +/** + * @module setupInterfaces + * + * This module provides utilities for setting up network interfaces (service endpoints) + * that are exposed to users through the StartOS UI. + * + * Service interfaces are the clickable links users see to access your service's + * web UI, API endpoints, or peer-to-peer connections. + * + * @example + * ```typescript + * export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => { + * const webHost = sdk.MultiHost.of(effects, 'webui') + * const webOrigin = await webHost.bindPort(8080, { protocol: 'http' }) + * + * await webOrigin.export([ + * sdk.ServiceInterfaceBuilder.of({ + * effects, + * name: 'Web Interface', + * id: 'webui', + * description: 'Access the web dashboard', + * type: 'ui', + * }) + * ]) + * + * return [webOrigin] + * }) + * ``` + */ + import * as T from "../types" import { once } from "../util" import { AddressReceipt } from "./AddressReceipt" +/** @internal Type brand for interface update receipt */ declare const UpdateServiceInterfacesProof: unique symbol + +/** + * Receipt type proving that service interfaces have been updated. + * @internal + */ export type UpdateServiceInterfacesReceipt = { [UpdateServiceInterfacesProof]: never } +/** Array of address info arrays with receipts, representing all exported interfaces */ export type ServiceInterfacesReceipt = Array + +/** + * Function type for setting up service interfaces. + * @typeParam Output - The specific receipt type returned + */ export type SetServiceInterfaces = (opts: { effects: T.Effects }) => Promise + +/** Function type for the init-compatible interface updater */ export type UpdateServiceInterfaces = (effects: T.Effects) => Promise + +/** Function type for the setupServiceInterfaces helper */ export type SetupServiceInterfaces = ( fn: SetServiceInterfaces, ) => UpdateServiceInterfaces + +/** + * Constant indicating no interface changes are needed. + * Use this as a return value when interfaces don't need to be updated. + */ export const NO_INTERFACE_CHANGES = {} as UpdateServiceInterfacesReceipt + +/** + * Creates an interface setup function for use in the initialization pipeline. + * + * **Note:** This is exposed via `sdk.setupInterfaces`. See the SDK documentation + * for full usage examples and parameter descriptions. + * + * Internally, this wrapper: + * - Tracks all bindings and interfaces created during setup + * - Automatically cleans up stale bindings/interfaces that weren't recreated + * + * @see sdk.setupInterfaces for usage documentation + */ export const setupServiceInterfaces: SetupServiceInterfaces = < Output extends ServiceInterfacesReceipt, >( diff --git a/sdk/base/lib/types.ts b/sdk/base/lib/types.ts index c86743696..9eda975e4 100644 --- a/sdk/base/lib/types.ts +++ b/sdk/base/lib/types.ts @@ -1,3 +1,7 @@ +/** + * Re-exports input specification types for building action forms. + * @see {@link inputSpecTypes} for available input field types (text, number, select, etc.) + */ export * as inputSpecTypes from "./actions/input/inputSpecTypes" import { @@ -20,107 +24,339 @@ export { CurrentDependenciesResult, } from "./dependencies/setupDependencies" +/** + * Represents an object that can build a daemon (long-running process). + * Returned by the `main` export to define how the service's primary process runs. + * + * The `build()` method is called to start the daemon, and returns an object + * with a `term()` method for graceful termination. + * + * @example + * ```typescript + * export const main = sdk.setupMain(async ({ effects }) => { + * // Return a DaemonBuildable + * return sdk.Daemons.of(effects).addDaemon('primary', { ... }) + * }) + * ``` + */ export type DaemonBuildable = { + /** Builds and starts the daemon, returning a handle for termination */ build(): Promise<{ + /** Gracefully terminates the daemon */ term(): Promise }> } +/** + * The type of service interface, determining how it appears in the StartOS UI. + * - `"ui"` - A web interface the user can visit (opens in browser) + * - `"p2p"` - A peer-to-peer network endpoint (e.g., Bitcoin P2P port) + * - `"api"` - An API endpoint for programmatic access (e.g., REST, RPC) + */ export type ServiceInterfaceType = "ui" | "p2p" | "api" + +/** + * Unix process signals that can be sent to terminate or control processes. + * Common signals include SIGTERM (graceful termination) and SIGKILL (forced termination). + */ export type Signals = NodeJS.Signals + +/** Signal for graceful termination - allows the process to clean up before exiting */ export const SIGTERM: Signals = "SIGTERM" + +/** Signal for forced termination - immediately kills the process without cleanup */ export const SIGKILL: Signals = "SIGKILL" + +/** Constant indicating no timeout should be applied (wait indefinitely) */ export const NO_TIMEOUT = -1 +/** + * Function type for constructing file paths from volume and path components. + * Used internally for path resolution across volumes. + */ export type PathMaker = (options: { volume: string; path: string }) => string + +/** + * Utility type representing a value that may or may not be wrapped in a Promise. + * Useful for functions that can accept both synchronous and asynchronous values. + */ export type MaybePromise = Promise | A + +/** + * Namespace defining the expected exports from a StartOS service package. + * Every service must export these functions and values to integrate with StartOS. + * + * @example + * ```typescript + * // In your package's index.ts: + * export { main } from './main' + * export { init, uninit } from './init' + * export { createBackup } from './backups' + * export { actions } from './actions' + * export const manifest = buildManifest(versionGraph, sdkManifest) + * ``` + */ export namespace ExpectedExports { version: 1 - /** For backing up service data though the startOS UI */ + /** + * Function for backing up service data through the StartOS UI. + * Called when the user initiates a backup or during scheduled backups. + * + * @param options.effects - Effects instance for system operations + * @returns Promise that resolves when backup is complete + */ export type createBackup = (options: { effects: Effects }) => Promise /** - * This is the entrypoint for the main container. Used to start up something like the service that the - * package represents, like running a bitcoind in a bitcoind-wrapper. + * The main entrypoint for the service container. + * This function starts the primary service process (e.g., a database server, web app). + * It should return a DaemonBuildable that manages the process lifecycle. + * + * @param options.effects - Effects instance for system operations + * @returns Promise resolving to a DaemonBuildable for process management + * + * @example + * ```typescript + * export const main = sdk.setupMain(async ({ effects }) => { + * return sdk.Daemons.of(effects) + * .addDaemon('primary', { + * subcontainer, + * exec: { command: sdk.useEntrypoint() }, + * ready: { display: 'Server', fn: healthCheck } + * }) + * }) + * ``` */ export type main = (options: { effects: Effects }) => Promise /** - * Every time a service launches (both on startup, and on install) this function is called before packageInit - * Can be used to register callbacks + * Initialization function called every time a service starts. + * Runs before the main function during install, update, restore, and regular startup. + * Use this to set up interfaces, register actions, apply migrations, etc. + * + * @param options.effects - Effects instance for system operations + * @param options.kind - The reason for initialization: + * - `"install"` - Fresh installation of the service + * - `"update"` - Updating from a previous version + * - `"restore"` - Restoring from a backup + * - `null` - Normal startup (service was already installed) + * @returns Promise that resolves when initialization is complete */ export type init = (options: { effects: Effects kind: "install" | "update" | "restore" | null }) => Promise - /** This will be ran during any time a package is uninstalled, for example during a update - * this will be called. + + /** + * Cleanup function called when a service is being uninstalled or updated. + * Use this to clean up resources, deregister callbacks, or perform other teardown. + * + * @param options.effects - Effects instance for system operations + * @param options.target - The version being transitioned to: + * - `ExtendedVersion` - Specific version (during update) + * - `VersionRange` - Version range constraint + * - `null` - Complete uninstall (no target version) + * @returns Promise that resolves when cleanup is complete */ export type uninit = (options: { effects: Effects target: ExtendedVersion | VersionRange | null }) => Promise + /** The service manifest containing metadata, images, volumes, and dependencies */ export type manifest = Manifest + /** The actions registry containing all user-callable actions for the service */ export type actions = Actions>> } + +/** + * The Application Binary Interface (ABI) defining all required exports from a StartOS package. + * This type ensures type-safety for the complete service interface. + */ export type ABI = { + /** Backup creation function */ createBackup: ExpectedExports.createBackup + /** Main service entrypoint */ main: ExpectedExports.main + /** Initialization function */ init: ExpectedExports.init + /** Cleanup function */ uninit: ExpectedExports.uninit + /** Service manifest */ manifest: ExpectedExports.manifest + /** User-callable actions */ actions: ExpectedExports.actions } +/** Time duration in milliseconds */ export type TimeMs = number + +/** A semantic version string (e.g., "1.0.0", "2.3.1-beta.0") */ export type VersionString = string +/** @internal Unique symbol for type branding daemon objects */ declare const DaemonProof: unique symbol + +/** + * A receipt proving a daemon was properly created. + * Used internally to ensure type safety in daemon management. + * @internal + */ export type DaemonReceipt = { [DaemonProof]: never } + +/** + * Represents a running daemon process with methods to wait for completion or terminate. + */ export type Daemon = { + /** + * Waits for the daemon to exit naturally. + * @returns Promise resolving to the exit reason/message + */ wait(): Promise + + /** + * Terminates the daemon, optionally with a specific signal. + * @returns Promise resolving to null when termination is complete + */ term(): Promise + + /** @internal Type brand */ [DaemonProof]: never } +/** + * The result status of a health check. + * - `"success"` - The service is healthy + * - `"failure"` - The service is unhealthy + * - `"starting"` - The service is still starting up + */ export type HealthStatus = NamedHealthCheckResult["result"] + +/** + * SMTP (email) server configuration for sending emails from services. + * Retrieved via `effects.getSystemSmtp()` when the user has configured system-wide SMTP. + */ export type SmtpValue = { + /** SMTP server hostname or IP address */ server: string + /** SMTP server port (typically 25, 465, or 587) */ port: number + /** The "From" email address for outgoing emails */ from: string + /** SMTP authentication username */ login: string + /** SMTP authentication password (null if no auth required) */ password: string | null | undefined } +/** + * Marker class indicating that the container's default entrypoint should be used. + * Use `sdk.useEntrypoint()` to create instances of this class. + * + * When passed as a command, StartOS will use the Docker image's ENTRYPOINT + * rather than a custom command. + * + * @example + * ```typescript + * // Use the container's built-in entrypoint + * exec: { command: sdk.useEntrypoint() } + * + * // Use entrypoint with additional arguments + * exec: { command: new UseEntrypoint(['--config', '/etc/myapp.conf']) } + * ``` + */ export class UseEntrypoint { + /** @internal Marker property for type identification */ readonly USE_ENTRYPOINT = "USE_ENTRYPOINT" + + /** + * @param overridCmd - Optional command arguments to append to the entrypoint + */ constructor(readonly overridCmd?: string[]) {} } + +/** + * Type guard to check if a command is a UseEntrypoint instance. + * + * @param command - The command to check + * @returns True if the command indicates entrypoint usage + */ export function isUseEntrypoint( command: CommandType, ): command is UseEntrypoint { return typeof command === "object" && "USE_ENTRYPOINT" in command } +/** + * Represents a command to execute in a container. + * - `string` - A single command string (will be shell-parsed) + * - `[string, ...string[]]` - Command with arguments as array (no shell parsing) + * - `UseEntrypoint` - Use the container's default ENTRYPOINT + * + * @example + * ```typescript + * // String command (shell-parsed) + * command: 'nginx -g "daemon off;"' + * + * // Array command (recommended - no shell parsing issues) + * command: ['nginx', '-g', 'daemon off;'] + * + * // Use container entrypoint + * command: sdk.useEntrypoint() + * ``` + */ export type CommandType = string | [string, ...string[]] | UseEntrypoint +/** + * Interface returned when a daemon is started. + * Provides methods to wait for natural exit or force termination. + */ export type DaemonReturned = { + /** + * Waits for the daemon process to exit naturally. + * @returns Promise that resolves when the process exits + */ wait(): Promise + + /** + * Terminates the daemon process. + * @param options.signal - The signal to send (default: SIGTERM) + * @param options.timeout - Milliseconds to wait before force-killing (default: 30000) + * @returns Promise resolving to null when termination is complete + */ term(options?: { signal?: Signals; timeout?: number }): Promise } +/** @internal Unique symbol for hostname type branding */ export declare const hostName: unique symbol -// asdflkjadsf.onion | 1.2.3.4 + +/** + * A network hostname string (e.g., "abc123.onion", "192.168.1.1", "myservice.local"). + * Type-branded for additional type safety. + */ export type Hostname = string & { [hostName]: never } +/** + * Unique identifier for a service interface. + * Used to reference specific interfaces when exporting or querying. + * + * @example + * ```typescript + * const interfaceId: ServiceInterfaceId = 'webui' + * ``` + */ export type ServiceInterfaceId = string export { ServiceInterface } +/** + * Utility type that extracts all Effect method names in kebab-case format. + * Used internally for method routing and serialization. + * @internal + */ export type EffectMethod = { [K in keyof T]-?: K extends string ? T[K] extends Function @@ -131,74 +367,172 @@ export type EffectMethod = { : never }[keyof T] +/** + * Options for file/directory synchronization operations (e.g., backups). + */ export type SyncOptions = { - /** delete files that exist in the target directory, but not in the source directory */ + /** + * If true, delete files in the target directory that don't exist in the source. + * Use with caution - this enables true mirroring but can cause data loss. + */ delete: boolean - /** do not sync files with paths that match these patterns */ + + /** + * Glob patterns for files/directories to exclude from synchronization. + * @example ['*.tmp', 'node_modules/', '.git/'] + */ exclude: string[] } /** - * This is the metadata that is returned from the metadata call. + * File or directory metadata returned from filesystem operations. + * Contains information about file type, size, timestamps, and permissions. */ export type Metadata = { + /** MIME type or file type description */ fileType: string + /** True if this is a directory */ isDir: boolean + /** True if this is a regular file */ isFile: boolean + /** True if this is a symbolic link */ isSymlink: boolean + /** File size in bytes (0 for directories) */ len: number + /** Last modification timestamp */ modified?: Date + /** Last access timestamp */ accessed?: Date + /** Creation timestamp */ created?: Date + /** True if the file is read-only */ readonly: boolean + /** Owner user ID */ uid: number + /** Owner group ID */ gid: number + /** Unix file mode (permissions) as octal number */ mode: number } +/** + * Configuration for setting up process termination behavior. + * @internal + */ export type SetResult = { + /** Map of package IDs to their dependency health check names */ dependsOn: DependsOn + /** Signal to send when terminating */ signal: Signals } +/** + * Unique identifier for a StartOS package/service. + * @example "bitcoind", "gitea", "jellyfin" + */ export type PackageId = string + +/** A string message, typically for user display or logging */ export type Message = string + +/** + * The kind of dependency relationship. + * - `"running"` - The dependency must be actively running + * - `"exists"` - The dependency must be installed (but doesn't need to be running) + */ export type DependencyKind = "running" | "exists" +/** + * Map of package IDs to arrays of health check names that must pass. + * Used to specify which health checks a service depends on from other packages. + */ export type DependsOn = { [packageId: string]: string[] | readonly string[] } +/** + * Standardized error types for service operations. + * - `{ error: string }` - Human-readable error message + * - `{ errorCode: [number, string] }` - Numeric code with message for programmatic handling + */ export type KnownError = | { error: string } | { errorCode: [number, string] | readonly [number, string] } +/** + * Array of dependency requirements for a service. + * Each requirement specifies a package ID, version range, and dependency kind. + */ export type Dependencies = Array +/** + * Recursively makes all properties of a type optional. + * Useful for partial updates or configuration overrides. + * + * @example + * ```typescript + * type Config = { server: { host: string; port: number } } + * type PartialConfig = DeepPartial + * // { server?: { host?: string; port?: number } } + * ``` + */ export type DeepPartial = T extends [infer A, ...infer Rest] ? [DeepPartial, ...DeepPartial] : T extends {} ? { [P in keyof T]?: DeepPartial } : T +/** + * Recursively removes readonly modifiers from all properties. + * Useful when you need to modify a readonly object. + */ export type DeepWritable = { -readonly [K in keyof T]: T[K] } +/** + * Casts a value to a deeply writable version. + * This is a type-only operation - the value is returned unchanged. + * + * @param value - The value to cast + * @returns The same value with writable type + */ export function writable(value: T): DeepWritable { return value } +/** + * Recursively adds readonly modifiers to all properties. + * Useful for ensuring immutability at the type level. + */ export type DeepReadonly = { readonly [P in keyof T]: DeepReadonly } +/** + * Casts a value to a deeply readonly version. + * This is a type-only operation - the value is returned unchanged. + * + * @param value - The value to cast + * @returns The same value with readonly type + */ export function readonly(value: T): DeepReadonly { return value } +/** + * Utility type that accepts both mutable and readonly versions of a type. + * Useful for function parameters that should accept either. + * + * @example + * ```typescript + * function process(items: AllowReadonly) { ... } + * process(['a', 'b']) // Works + * process(['a', 'b'] as const) // Also works + * ``` + */ export type AllowReadonly = | T | { diff --git a/sdk/base/lib/types/ManifestTypes.ts b/sdk/base/lib/types/ManifestTypes.ts index b255a6393..6a88023bb 100644 --- a/sdk/base/lib/types/ManifestTypes.ts +++ b/sdk/base/lib/types/ManifestTypes.ts @@ -1,6 +1,40 @@ +/** + * @module ManifestTypes + * + * Defines the type for the service manifest, which contains all metadata about + * a StartOS package including its name, description, images, volumes, dependencies, + * and other configuration. + * + * The manifest is defined in your package and exported as the `manifest` constant. + * It's used by the SDK for type checking and by StartOS for package management. + * + * @example + * ```typescript + * import { sdk } from './sdk' + * + * export const manifest = sdk.Manifest({ + * id: 'myservice', + * title: 'My Service', + * license: 'MIT', + * images: { main: { source: { dockerTag: 'myimage:latest' } } }, + * volumes: ['main'], + * dependencies: {}, + * // ... other required fields + * }) + * ``` + */ import { T } from ".." import { ImageId, ImageSource } from "../types" +/** + * The manifest type for StartOS service packages. + * + * This is the primary type used to describe a service package. All fields provide + * metadata used by StartOS for installation, marketplace display, and runtime configuration. + * + * Required fields include package identification (id, title), licensing info, + * repository URLs, descriptions, and technical specifications (images, volumes). + */ export type SDKManifest = { /** * The package identifier used by StartOS. This must be unique amongst all other known packages. @@ -154,7 +188,11 @@ export type SDKManifest = { readonly hardwareAcceleration?: boolean } -// this is hacky but idk a more elegant way +/** + * @internal + * Helper type for generating all valid architecture combinations. + * Allows specifying one, two, or three target architectures in any order. + */ type ArchOptions = { 0: ["x86_64", "aarch64", "riscv64"] 1: ["aarch64", "x86_64", "riscv64"] @@ -172,13 +210,55 @@ type ArchOptions = { 13: ["aarch64"] 14: ["riscv64"] } + +/** + * Configuration for a Docker image used by the service. + * + * Specifies where to get the image (Docker Hub, local build) and + * which CPU architectures it supports. + * + * @example + * ```typescript + * // Using a pre-built Docker Hub image + * { + * source: { dockerTag: 'nginx:latest' }, + * arch: ['x86_64', 'aarch64'] + * } + * + * // Building from a local Dockerfile + * { + * source: { + * dockerBuild: { + * dockerFile: './Dockerfile', + * workdir: '.' + * } + * }, + * arch: ['x86_64'] + * } + * + * // With NVIDIA GPU support + * { + * source: { dockerTag: 'tensorflow/tensorflow:latest-gpu' }, + * arch: ['x86_64'], + * nvidiaContainer: true + * } + * ``` + */ export type SDKImageInputSpec = { [A in keyof ArchOptions]: { + /** Where to get the image (Docker tag or local build) */ source: Exclude + /** CPU architectures this image supports */ arch?: ArchOptions[A] + /** If architecture is missing, use this architecture with emulation */ emulateMissingAs?: ArchOptions[A][number] | null + /** Enable NVIDIA container runtime for GPU acceleration */ nvidiaContainer?: boolean } }[keyof ArchOptions] +/** + * Configuration for a service dependency. + * Extracted from the main Manifest type for dependency declarations. + */ export type ManifestDependency = T.Manifest["dependencies"][string] diff --git a/sdk/base/lib/util/Drop.ts b/sdk/base/lib/util/Drop.ts index 62dd61f0f..2437bc6bc 100644 --- a/sdk/base/lib/util/Drop.ts +++ b/sdk/base/lib/util/Drop.ts @@ -1,6 +1,50 @@ +/** + * @module Drop + * + * Provides RAII-style resource management for JavaScript using FinalizationRegistry. + * Classes extending Drop get automatic cleanup when garbage collected, ensuring + * resources are released even if explicitly dropped. + * + * This is used for managing long-lived resources like health checks, daemons, + * and other objects that need cleanup when no longer referenced. + */ + +/** @internal Unique symbol for drop reference identification */ const dropId: unique symbol = Symbol("id") + +/** @internal Reference type for tracking droppable resources */ export type DropRef = { [dropId]: number } +/** + * Abstract base class for objects that need cleanup when garbage collected. + * + * Subclasses must implement `onDrop()` to define cleanup behavior. + * The cleanup is automatically triggered when the object is garbage collected, + * or can be triggered manually by calling `drop()`. + * + * @example + * ```typescript + * class ResourceHolder extends Drop { + * private handle: Handle + * + * constructor() { + * super() + * this.handle = acquireResource() + * } + * + * onDrop(): void { + * releaseResource(this.handle) + * } + * } + * + * // Resource is automatically released when holder is garbage collected + * let holder = new ResourceHolder() + * holder = null // Eventually triggers onDrop() + * + * // Or manually release + * holder.drop() + * ``` + */ export abstract class Drop { private static weak: { [id: number]: Drop } = {} private static registry = new FinalizationRegistry((id: number) => { diff --git a/sdk/base/lib/util/asError.ts b/sdk/base/lib/util/asError.ts index c3454e0e4..9771083bf 100644 --- a/sdk/base/lib/util/asError.ts +++ b/sdk/base/lib/util/asError.ts @@ -1,3 +1,29 @@ +/** + * Converts an unknown value to an Error instance. + * + * Handles various error formats commonly encountered in JavaScript: + * - Error instances are returned as-is (re-wrapped) + * - Strings become Error messages + * - Other values are JSON-stringified into the Error message + * + * @param e - The unknown value to convert + * @returns An Error instance representing the input + * + * @example + * ```typescript + * try { + * await someOperation() + * } catch (e) { + * const error = asError(e) + * console.error(error.message) + * } + * + * // Works with any thrown value + * asError(new Error('oops')) // Error: oops + * asError('string error') // Error: string error + * asError({ code: 500 }) // Error: {"code":500} + * ``` + */ export const asError = (e: unknown) => { if (e instanceof Error) { return new Error(e as any) diff --git a/sdk/base/lib/util/deepMerge.ts b/sdk/base/lib/util/deepMerge.ts index 452a8549d..1c15d1bdb 100644 --- a/sdk/base/lib/util/deepMerge.ts +++ b/sdk/base/lib/util/deepMerge.ts @@ -1,3 +1,24 @@ +/** + * Computes a partial diff between two values. + * Returns undefined if values are equal, otherwise returns the differences. + * + * For objects, recursively compares properties. For arrays, finds new items + * not present in the previous array. + * + * @typeParam T - The type of values being compared + * @param prev - The previous value + * @param next - The next value + * @returns Object containing diff, or undefined if equal + * + * @example + * ```typescript + * partialDiff({ a: 1, b: 2 }, { a: 1, b: 3 }) + * // Returns: { diff: { b: 3 } } + * + * partialDiff({ a: 1 }, { a: 1 }) + * // Returns: undefined + * ``` + */ export function partialDiff( prev: T, next: T, @@ -46,6 +67,28 @@ export function partialDiff( } } +/** + * Deeply merges multiple objects or arrays into one. + * + * For objects: Recursively merges properties from all input objects. + * For arrays: Combines unique items from all input arrays. + * Primitives: Returns the last non-object value. + * + * @param args - Values to merge (objects, arrays, or primitives) + * @returns The merged result + * + * @example + * ```typescript + * deepMerge({ a: 1 }, { b: 2 }) + * // Returns: { a: 1, b: 2 } + * + * deepMerge({ a: { x: 1 } }, { a: { y: 2 } }) + * // Returns: { a: { x: 1, y: 2 } } + * + * deepMerge([1, 2], [2, 3]) + * // Returns: [1, 2, 3] + * ``` + */ export function deepMerge(...args: unknown[]): unknown { const lastItem = (args as any)[args.length - 1] if (typeof lastItem !== "object" || !lastItem) return lastItem diff --git a/sdk/base/lib/util/inMs.ts b/sdk/base/lib/util/inMs.ts index 548eb14bf..9c42931a5 100644 --- a/sdk/base/lib/util/inMs.ts +++ b/sdk/base/lib/util/inMs.ts @@ -1,5 +1,16 @@ +/** + * @module inMs + * + * Parses human-readable time strings into milliseconds. + */ + +/** @internal Regex for parsing time strings */ const matchTimeRegex = /^\s*(\d+)?(\.\d+)?\s*(ms|s|m|h|d)/ +/** + * Gets the millisecond multiplier for a time unit. + * @internal + */ const unitMultiplier = (unit?: string) => { if (!unit) return 1 if (unit === "ms") return 1 @@ -9,12 +20,44 @@ const unitMultiplier = (unit?: string) => { if (unit === "d") return 1000 * 60 * 60 * 24 throw new Error(`Invalid unit: ${unit}`) } + +/** + * Converts decimal digits to milliseconds. + * @internal + */ const digitsMs = (digits: string | null, multiplier: number) => { if (!digits) return 0 const value = parseInt(digits.slice(1)) const divideBy = multiplier / Math.pow(10, digits.length - 1) return Math.round(value * divideBy) } + +/** + * Parses a time value (string or number) into milliseconds. + * + * Accepts human-readable time strings with units: + * - `ms` - milliseconds + * - `s` - seconds + * - `m` - minutes + * - `h` - hours + * - `d` - days + * + * Numbers are returned unchanged (assumed to be in milliseconds). + * + * @param time - Time value as string with unit or number in milliseconds + * @returns Time in milliseconds, or undefined if input is undefined + * @throws Error if string format is invalid + * + * @example + * ```typescript + * inMs('5s') // 5000 + * inMs('1.5m') // 90000 + * inMs('2h') // 7200000 + * inMs('1d') // 86400000 + * inMs(3000) // 3000 + * inMs('500ms') // 500 + * ``` + */ export const inMs = (time?: string | number) => { if (typeof time === "number") return time if (!time) return undefined diff --git a/sdk/base/lib/util/once.ts b/sdk/base/lib/util/once.ts index 5f689b0e1..b7696b9c2 100644 --- a/sdk/base/lib/util/once.ts +++ b/sdk/base/lib/util/once.ts @@ -1,3 +1,26 @@ +/** + * Creates a memoized version of a function that only executes once. + * Subsequent calls return the cached result from the first invocation. + * + * Useful for lazy initialization where you want to defer computation + * until first use, but then cache the result. + * + * @typeParam B - The return type of the function + * @param fn - The function to execute once and cache + * @returns A function that returns the cached result + * + * @example + * ```typescript + * const getExpensiveValue = once(() => { + * console.log('Computing...') + * return computeExpensiveValue() + * }) + * + * getExpensiveValue() // Logs "Computing...", returns value + * getExpensiveValue() // Returns cached value, no log + * getExpensiveValue() // Returns cached value, no log + * ``` + */ export function once(fn: () => B): () => B { let result: [B] | [] = [] return () => { diff --git a/sdk/base/lib/util/patterns.ts b/sdk/base/lib/util/patterns.ts index a61e4269c..810de58ac 100644 --- a/sdk/base/lib/util/patterns.ts +++ b/sdk/base/lib/util/patterns.ts @@ -1,67 +1,106 @@ +/** + * @module patterns + * + * Pre-built validation patterns for common input types. + * Use these with text inputs to validate user-entered values. + * + * Each pattern includes a regex and a human-readable description + * that's shown when validation fails. + * + * @example + * ```typescript + * import { Patterns } from '@start9labs/sdk' + * + * // Validate an email field + * Value.text({ + * name: 'Email', + * patterns: [Patterns.email] + * }) + * + * // Validate a Tor hostname + * Value.text({ + * name: 'Onion Address', + * patterns: [Patterns.torHostname] + * }) + * ``` + */ import { Pattern } from "../actions/input/inputSpecTypes" import * as regexes from "./regexes" +/** Validates IPv6 addresses */ export const ipv6: Pattern = { regex: regexes.ipv6.matches(), description: "Must be a valid IPv6 address", } +/** Validates IPv4 addresses (e.g., "192.168.1.1") */ export const ipv4: Pattern = { regex: regexes.ipv4.matches(), description: "Must be a valid IPv4 address", } +/** Validates general hostnames */ export const hostname: Pattern = { regex: regexes.hostname.matches(), description: "Must be a valid hostname", } +/** Validates .local mDNS hostnames (e.g., "mydevice.local") */ export const localHostname: Pattern = { regex: regexes.localHostname.matches(), description: 'Must be a valid ".local" hostname', } +/** Validates Tor .onion hostnames */ export const torHostname: Pattern = { regex: regexes.torHostname.matches(), description: 'Must be a valid Tor (".onion") hostname', } +/** Validates general URLs */ export const url: Pattern = { regex: regexes.url.matches(), description: "Must be a valid URL", } +/** Validates .local mDNS URLs */ export const localUrl: Pattern = { regex: regexes.localUrl.matches(), description: 'Must be a valid ".local" URL', } +/** Validates Tor .onion URLs */ export const torUrl: Pattern = { regex: regexes.torUrl.matches(), description: 'Must be a valid Tor (".onion") URL', } +/** Validates ASCII-only text (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", } +/** Validates fully qualified domain names (FQDNs) */ export const domain: Pattern = { regex: regexes.domain.matches(), description: "Must be a valid Fully Qualified Domain Name", } +/** Validates email addresses (e.g., "user@example.com") */ export const email: Pattern = { regex: regexes.email.matches(), description: "Must be a valid email address", } +/** Validates email addresses with optional display name (e.g., "John Doe ") */ export const emailWithName: Pattern = { regex: regexes.emailWithName.matches(), description: "Must be a valid email address, optionally with a name", } +/** Validates base64-encoded strings */ export const base64: Pattern = { regex: regexes.base64.matches(), description: diff --git a/sdk/base/lib/util/splitCommand.ts b/sdk/base/lib/util/splitCommand.ts index ac1237574..9dc5a7e1a 100644 --- a/sdk/base/lib/util/splitCommand.ts +++ b/sdk/base/lib/util/splitCommand.ts @@ -1,5 +1,23 @@ import { arrayOf, string } from "ts-matches" +/** + * Normalizes a command into an array format suitable for execution. + * + * If the command is already an array, it's returned as-is. + * If it's a string, it's wrapped in `sh -c` for shell interpretation. + * + * @param command - Command as string or array of strings + * @returns Command as array suitable for process execution + * + * @example + * ```typescript + * splitCommand(['nginx', '-g', 'daemon off;']) + * // Returns: ['nginx', '-g', 'daemon off;'] + * + * splitCommand('nginx -g "daemon off;"') + * // Returns: ['sh', '-c', 'nginx -g "daemon off;"'] + * ``` + */ export const splitCommand = ( command: string | [string, ...string[]], ): string[] => { diff --git a/sdk/base/lib/util/typeHelpers.ts b/sdk/base/lib/util/typeHelpers.ts index d29d5c986..cf7785b7e 100644 --- a/sdk/base/lib/util/typeHelpers.ts +++ b/sdk/base/lib/util/typeHelpers.ts @@ -1,18 +1,60 @@ +/** + * @module typeHelpers + * + * Utility types and type guards used throughout the SDK for type manipulation + * and runtime type checking. + */ import * as T from "../types" +/** + * Flattens an intersection type into a single object type. + * Makes hover information more readable by expanding intersections. + * + * @example + * ```typescript + * type A = { foo: string } + * type B = { bar: number } + * type AB = A & B // Shows as "A & B" + * type Flat = FlattenIntersection // Shows as { foo: string; bar: number } + * ``` + */ // prettier-ignore -export type FlattenIntersection = +export type FlattenIntersection = T extends ArrayLike ? T : T extends object ? {} & {[P in keyof T]: T[P]} : T; +/** + * Alias for FlattenIntersection for shorter usage. + * @see FlattenIntersection + */ export type _ = FlattenIntersection +/** + * Type guard to check if a value is a KnownError. + * KnownError is the standard error format for service operations. + * + * @param e - The value to check + * @returns True if the value is a KnownError object + */ export const isKnownError = (e: unknown): e is T.KnownError => e instanceof Object && ("error" in e || "error-code" in e) +/** @internal Symbol for affine type branding */ declare const affine: unique symbol +/** + * Type brand for creating affine types (types that can only be used in specific contexts). + * Used to prevent values from being used outside their intended context. + * + * @typeParam A - The context identifier + * + * @example + * ```typescript + * type BackupEffects = Effects & Affine<"Backups"> + * // BackupEffects can only be created in backup context + * ``` + */ export type Affine = { [affine]: A } type NeverPossible = { [affine]: string } diff --git a/sdk/package/lib/StartSdk.ts b/sdk/package/lib/StartSdk.ts index 98ea17045..b1df1273f 100644 --- a/sdk/package/lib/StartSdk.ts +++ b/sdk/package/lib/StartSdk.ts @@ -67,24 +67,83 @@ import { import { getOwnServiceInterfaces } from "../../base/lib/util/getServiceInterfaces" import { Volumes, createVolumes } from "./util/Volume" +/** + * The minimum StartOS version this SDK is compatible with. + * Used internally for version checking. + */ export const OSVersion = testTypeVersion("0.4.0-alpha.19") +/** @internal Helper type for conditional type resolution */ // prettier-ignore -type AnyNeverCond = +type AnyNeverCond = T extends [] ? Else : T extends [never, ...Array] ? Then : T extends [any, ...infer U] ? AnyNeverCond : never +/** + * The main SDK class for building StartOS service packages. + * + * StartSdk provides a fluent API for creating services with type-safe access to: + * - Service manifest and volumes + * - Actions (user-callable operations) + * - Health checks and daemon management + * - Network interfaces + * - Dependency management + * - Backup/restore functionality + * - Container management (SubContainer) + * + * @typeParam Manifest - The service manifest type, providing type safety for volumes, images, and dependencies + * + * @example + * ```typescript + * // In sdk.ts - create and export the SDK instance + * import { StartSdk } from '@start9labs/start-sdk' + * import { manifest } from './manifest' + * + * export const sdk = StartSdk.of().withManifest(manifest).build(true) + * + * // Now use sdk throughout your package: + * // sdk.volumes.main - type-safe access to volumes + * // sdk.Action.withInput(...) - create actions + * // sdk.Daemons.of(effects) - create daemon managers + * // sdk.SubContainer.of(...) - create containers + * ``` + */ export class StartSdk { private constructor(readonly manifest: Manifest) {} + + /** + * Creates a new StartSdk builder instance. + * Call `.withManifest()` next to attach your service manifest. + * + * @returns A new StartSdk instance (uninitialized) + */ static of() { return new StartSdk(null as never) } + + /** + * Attaches a manifest to the SDK, enabling type-safe access to + * volumes, images, and dependencies defined in the manifest. + * + * @typeParam Manifest - The manifest type + * @param manifest - The service manifest object + * @returns A new StartSdk instance with the manifest attached + */ withManifest(manifest: Manifest) { return new StartSdk(manifest) } + /** + * Builds the final SDK instance with all utilities and helpers. + * + * This must be called after `.withManifest()` to get the usable SDK object. + * The `isReady` parameter is a type-level check that ensures a manifest was provided. + * + * @param isReady - Pass `true` (only compiles if manifest was provided) + * @returns The complete SDK object with all methods and utilities + */ build(isReady: AnyNeverCond<[Manifest], "Build not ready", true>) { type NestedEffects = "subcontainer" | "store" | "action" type InterfaceEffects = @@ -741,6 +800,45 @@ export class StartSdk { } } +/** + * Runs a command in a temporary container and returns the output. + * + * This is a convenience function for one-off command execution. + * For long-running processes or multiple commands, use `sdk.SubContainer` instead. + * + * @typeParam Manifest - The service manifest type + * + * @param effects - Effects instance for system operations + * @param image - The container image to use + * @param image.imageId - Image ID from the manifest's images + * @param image.sharedRun - Whether to share the run directory with other containers + * @param command - The command to execute (string, array, or UseEntrypoint) + * @param options - Execution options including mounts and environment + * @param options.mounts - Volume mounts for the container (or null for none) + * @param name - Optional name for debugging/logging + * @returns Promise resolving to stdout and stderr from the command + * @throws ExitError if the command exits with a non-zero code or signal + * + * @example + * ```typescript + * // Run a simple command + * const result = await runCommand( + * effects, + * { imageId: 'main' }, + * ['echo', 'Hello, World!'], + * { mounts: null } + * ) + * console.log(result.stdout) // "Hello, World!\n" + * + * // Run with volume mounts + * const result = await runCommand( + * effects, + * { imageId: 'main' }, + * ['cat', '/data/config.json'], + * { mounts: sdk.Mounts.of().mountVolume({ volumeId: 'main', mountpoint: '/data' }) } + * ) + * ``` + */ export async function runCommand( effects: Effects, image: { imageId: keyof Manifest["images"] & T.ImageId; sharedRun?: boolean }, diff --git a/sdk/package/lib/backup/Backups.ts b/sdk/package/lib/backup/Backups.ts index 8594f3f8f..19b5ce0c0 100644 --- a/sdk/package/lib/backup/Backups.ts +++ b/sdk/package/lib/backup/Backups.ts @@ -1,3 +1,27 @@ +/** + * @module Backups + * + * Provides backup and restore functionality for StartOS services. + * The Backups class uses rsync to efficiently synchronize service data + * to and from backup destinations. + * + * @example + * ```typescript + * // Simple backup of all volumes + * export const createBackup = Backups.ofVolumes('main', 'config') + * + * // Advanced backup with hooks + * export const createBackup = Backups.ofVolumes('main') + * .setPreBackup(async (effects) => { + * // Stop accepting writes before backup + * await stopService() + * }) + * .setPostBackup(async (effects) => { + * // Resume after backup + * await startService() + * }) + * ``` + */ import * as T from "../../../base/lib/types" import * as child_process from "child_process" import * as fs from "fs/promises" @@ -5,32 +29,113 @@ import { Affine, asError } from "../util" import { ExtendedVersion, VersionRange } from "../../../base/lib" import { InitKind, InitScript } from "../../../base/lib/inits" +/** + * Default sync options for backup/restore operations. + * - `delete: true` - Remove files in destination that don't exist in source + * - `exclude: []` - No exclusions by default + */ export const DEFAULT_OPTIONS: T.SyncOptions = { delete: true, exclude: [], } + +/** + * Configuration for a single backup synchronization operation. + * Maps a source data path to a backup destination path. + * + * @typeParam Volumes - The volume ID type from the manifest + */ export type BackupSync = { + /** Source path on the data volume (e.g., "/media/startos/volumes/main/data") */ dataPath: `/media/startos/volumes/${Volumes}/${string}` + /** Destination path in the backup (e.g., "/media/startos/backup/volumes/main/") */ backupPath: `/media/startos/backup/${string}` + /** Sync options applied to both backup and restore */ options?: Partial + /** Sync options applied only during backup (merged with options) */ backupOptions?: Partial + /** Sync options applied only during restore (merged with options) */ restoreOptions?: Partial } +/** + * Effects type with backup context marker. + * Provides type safety to prevent backup operations in non-backup contexts. + */ export type BackupEffects = T.Effects & Affine<"Backups"> +/** + * Manages backup and restore operations for a StartOS service. + * + * Exposed via `sdk.Backups`. The Backups class provides a fluent API for + * configuring which volumes to back up and optional hooks for pre/post + * backup/restore operations. It uses rsync for efficient incremental backups. + * + * Common usage patterns: + * - Simple: `sdk.Backups.ofVolumes('main')` - Back up the main volume + * - Multiple volumes: `sdk.Backups.ofVolumes('main', 'config', 'logs')` + * - With hooks: Add pre/post callbacks for database dumps, service stops, etc. + * - Custom paths: Use `addSync()` for non-standard backup mappings + * + * @typeParam M - The service manifest type for type-safe volume names + * + * @example + * ```typescript + * // In your package's exports: + * export const createBackup = Backups.ofVolumes('main', 'config') + * + * // With database dump before backup + * export const createBackup = Backups.ofVolumes('main') + * .setPreBackup(async (effects) => { + * // Create a database dump before backing up files + * await subcontainer.exec(['pg_dump', '-f', '/data/backup.sql']) + * }) + * + * // Exclude temporary files + * export const createBackup = Backups.withOptions({ exclude: ['*.tmp', 'cache/'] }) + * .addVolume('main') + * ``` + */ export class Backups implements InitScript { private constructor( + /** @internal Default sync options */ private options = DEFAULT_OPTIONS, + /** @internal Options specific to restore operations */ private restoreOptions: Partial = {}, + /** @internal Options specific to backup operations */ private backupOptions: Partial = {}, + /** @internal Set of sync configurations */ private backupSet = [] as BackupSync[], + /** @internal Hook called before backup starts */ private preBackup = async (effects: BackupEffects) => {}, + /** @internal Hook called after backup completes */ private postBackup = async (effects: BackupEffects) => {}, + /** @internal Hook called before restore starts */ private preRestore = async (effects: BackupEffects) => {}, + /** @internal Hook called after restore completes */ private postRestore = async (effects: BackupEffects) => {}, ) {} + /** + * Creates a Backups instance configured to back up the specified volumes. + * This is the most common way to create a backup configuration. + * + * Each volume is backed up to a corresponding path in the backup destination + * using the volume's name as the subdirectory. + * + * @typeParam M - The manifest type (inferred from volume names) + * @param volumeNames - Volume IDs to include in backups (from manifest.volumes) + * @returns A configured Backups instance + * + * @example + * ```typescript + * // Back up a single volume + * export const createBackup = Backups.ofVolumes('main') + * + * // Back up multiple volumes + * export const createBackup = Backups.ofVolumes('main', 'config', 'logs') + * ``` + */ static ofVolumes( ...volumeNames: Array ): Backups { @@ -42,18 +147,56 @@ export class Backups implements InitScript { ) } + /** + * Creates a Backups instance from explicit sync configurations. + * Use this for custom source/destination path mappings. + * + * @typeParam M - The manifest type + * @param syncs - Array of sync configurations + * @returns A configured Backups instance + * + * @example + * ```typescript + * const backups = Backups.ofSyncs( + * { dataPath: '/media/startos/volumes/main/data', backupPath: '/media/startos/backup/data' }, + * { dataPath: '/media/startos/volumes/main/config', backupPath: '/media/startos/backup/config' } + * ) + * ``` + */ static ofSyncs( ...syncs: BackupSync[] ) { return syncs.reduce((acc, x) => acc.addSync(x), new Backups()) } + /** + * Creates a Backups instance with custom default sync options. + * Call `addVolume()` or `addSync()` to add volumes after setting options. + * + * @typeParam M - The manifest type + * @param options - Default sync options (merged with DEFAULT_OPTIONS) + * @returns An empty Backups instance with the specified options + * + * @example + * ```typescript + * // Exclude cache and temp files from all backups + * export const createBackup = Backups.withOptions({ + * exclude: ['cache/', '*.tmp', '*.log'] + * }).addVolume('main') + * ``` + */ static withOptions( options?: Partial, ) { return new Backups({ ...DEFAULT_OPTIONS, ...options }) } + /** + * Sets default sync options for both backup and restore operations. + * + * @param options - Sync options to merge with current defaults + * @returns This instance for chaining + */ setOptions(options?: Partial) { this.options = { ...this.options, @@ -62,6 +205,13 @@ export class Backups implements InitScript { return this } + /** + * Sets sync options applied only during backup operations. + * These are merged with the default options. + * + * @param options - Backup-specific sync options + * @returns This instance for chaining + */ setBackupOptions(options?: Partial) { this.backupOptions = { ...this.backupOptions, @@ -70,6 +220,13 @@ export class Backups implements InitScript { return this } + /** + * Sets sync options applied only during restore operations. + * These are merged with the default options. + * + * @param options - Restore-specific sync options + * @returns This instance for chaining + */ setRestoreOptions(options?: Partial) { this.restoreOptions = { ...this.restoreOptions, @@ -78,26 +235,88 @@ export class Backups implements InitScript { return this } + /** + * Registers a function to run before backup starts. + * Use this to prepare the service for backup (e.g., flush caches, + * create database dumps, pause writes). + * + * @param fn - Async function to run before backup + * @returns This instance for chaining + * + * @example + * ```typescript + * Backups.ofVolumes('main') + * .setPreBackup(async (effects) => { + * // Flush database to disk + * await db.checkpoint() + * }) + * ``` + */ setPreBackup(fn: (effects: BackupEffects) => Promise) { this.preBackup = fn return this } + /** + * Registers a function to run after backup completes. + * Use this to resume normal operations after backup. + * + * @param fn - Async function to run after backup + * @returns This instance for chaining + */ setPostBackup(fn: (effects: BackupEffects) => Promise) { this.postBackup = fn return this } + /** + * Registers a function to run before restore starts. + * Use this to prepare for incoming data (e.g., stop services, + * clear existing data). + * + * @param fn - Async function to run before restore + * @returns This instance for chaining + */ setPreRestore(fn: (effects: BackupEffects) => Promise) { this.preRestore = fn return this } + /** + * Registers a function to run after restore completes. + * Use this to finalize restore (e.g., run migrations, rebuild indexes). + * + * @param fn - Async function to run after restore + * @returns This instance for chaining + * + * @example + * ```typescript + * Backups.ofVolumes('main') + * .setPostRestore(async (effects) => { + * // Rebuild search indexes after restore + * await rebuildIndexes() + * }) + * ``` + */ setPostRestore(fn: (effects: BackupEffects) => Promise) { this.postRestore = fn return this } + /** + * Adds a volume to the backup configuration. + * + * @param volume - Volume ID from the manifest + * @param options - Optional sync options for this specific volume + * @returns This instance for chaining + * + * @example + * ```typescript + * Backups.withOptions({ exclude: ['*.tmp'] }) + * .addVolume('main') + * .addVolume('logs', { backupOptions: { exclude: ['*.log'] } }) + * ``` + */ addVolume( volume: M["volumes"][number], options?: Partial<{ @@ -113,11 +332,30 @@ export class Backups implements InitScript { }) } + /** + * Adds a custom sync configuration to the backup. + * Use this for non-standard path mappings. + * + * @param sync - Sync configuration with source and destination paths + * @returns This instance for chaining + */ addSync(sync: BackupSync) { this.backupSet.push(sync) return this } + /** + * Creates a backup by syncing all configured volumes to the backup destination. + * Called by StartOS when the user initiates a backup. + * + * Execution order: + * 1. Runs preBackup hook + * 2. Syncs each volume using rsync + * 3. Saves the data version to the backup + * 4. Runs postBackup hook + * + * @param effects - Effects instance for system operations + */ async createBackup(effects: T.Effects) { await this.preBackup(effects as BackupEffects) for (const item of this.backupSet) { @@ -143,12 +381,30 @@ export class Backups implements InitScript { return } + /** + * InitScript implementation - handles restore during initialization. + * Called automatically during the init phase when kind is "restore". + * + * @param effects - Effects instance + * @param kind - The initialization kind (only acts on "restore") + */ async init(effects: T.Effects, kind: InitKind): Promise { if (kind === "restore") { await this.restoreBackup(effects) } } + /** + * Restores data from a backup by syncing from backup destination to volumes. + * + * Execution order: + * 1. Runs preRestore hook + * 2. Syncs each volume from backup using rsync + * 3. Restores the data version from the backup + * 4. Runs postRestore hook + * + * @param effects - Effects instance for system operations + */ async restoreBackup(effects: T.Effects) { this.preRestore(effects as BackupEffects) @@ -176,6 +432,13 @@ export class Backups implements InitScript { } } +/** + * Executes rsync to synchronize files between source and destination. + * + * @param rsyncOptions - Configuration for the rsync operation + * @returns Object with methods to get process ID, wait for completion, and check progress + * @internal + */ async function runRsync(rsyncOptions: { srcPath: string dstPath: string diff --git a/sdk/package/lib/health/HealthCheck.ts b/sdk/package/lib/health/HealthCheck.ts index 786565b8d..55fdb6ab2 100644 --- a/sdk/package/lib/health/HealthCheck.ts +++ b/sdk/package/lib/health/HealthCheck.ts @@ -1,3 +1,9 @@ +/** + * @module HealthCheck + * + * Provides the core health check management class that runs periodic health checks + * and reports results to the StartOS host. + */ import { Effects, HealthCheckId } from "../../../base/lib/types" import { HealthCheckResult } from "./checkFns/HealthCheckResult" import { Trigger } from "../trigger" @@ -6,24 +12,102 @@ import { defaultTrigger } from "../trigger/defaultTrigger" import { once, asError, Drop } from "../util" import { object, unknown } from "ts-matches" +/** + * Configuration options for creating a health check. + * + * @example + * ```typescript + * const params: HealthCheckParams = { + * id: 'main', + * name: 'Main Service', + * gracePeriod: 30000, // 30s grace period + * trigger: cooldownTrigger(5000), // Check every 5s + * fn: async () => { + * const isHealthy = await checkService() + * return { + * result: isHealthy ? 'success' : 'failure', + * message: isHealthy ? 'Service running' : 'Service not responding' + * } + * } + * } + * ``` + */ export type HealthCheckParams = { + /** Unique identifier for this health check (e.g., 'main', 'rpc', 'database') */ id: HealthCheckId + /** Human-readable name displayed in the StartOS UI */ name: string + /** + * Trigger controlling when the health check runs. + * @default defaultTrigger (1s before first success, 30s after) + */ trigger?: Trigger + /** + * Time in milliseconds during which failures are reported as "starting" instead. + * This prevents false failure alerts during normal service startup. + * @default 10000 (10 seconds) + */ gracePeriod?: number + /** + * The health check function. Called periodically according to the trigger. + * Should return (or resolve to) a HealthCheckResult with result and message. + */ fn(): Promise | HealthCheckResult } +/** + * Manages periodic health check execution for a service. + * + * HealthCheck runs a check function according to a trigger schedule and reports + * results to StartOS. It handles: + * - Grace period logic (failures during startup report as "starting") + * - Trigger-based scheduling (adjustable check frequency) + * - Error handling (exceptions become failure results) + * - Start/stop lifecycle management + * + * Usually created indirectly via `Daemons.addDaemon()` or `Daemons.addHealthCheck()`, + * but can be created directly with `HealthCheck.of()` for advanced use cases. + * + * @example + * ```typescript + * // Direct creation (advanced usage) + * const check = HealthCheck.of(effects, { + * id: 'database', + * name: 'Database Connection', + * gracePeriod: 20000, + * trigger: cooldownTrigger(10000), + * fn: async () => { + * const connected = await db.ping() + * return { + * result: connected ? 'success' : 'failure', + * message: connected ? 'Connected' : 'Cannot reach database' + * } + * } + * }) + * + * // Start checking (usually tied to daemon start) + * check.start() + * + * // Stop checking (usually tied to daemon stop) + * check.stop() + * ``` + */ export class HealthCheck extends Drop { + /** @internal Timestamp when the service was started (null if stopped) */ private started: number | null = null + /** @internal Callback to update started state and wake the check loop */ private setStarted = (started: number | null) => { this.started = started } + /** @internal Flag indicating the check loop should exit */ private exited = false + /** @internal Callback to signal the check loop to exit */ private exit = () => { this.exited = true } + /** @internal Current trigger input state */ private currentValue: TriggerInput = {} + /** @internal Promise representing the running check loop */ private promise: Promise private constructor(effects: Effects, o: HealthCheckParams) { super() @@ -92,22 +176,60 @@ export class HealthCheck extends Drop { } }) } + /** + * Creates a new HealthCheck instance. + * + * @param effects - Effects instance for communicating with StartOS + * @param options - Health check configuration + * @returns A new HealthCheck instance (initially stopped) + * + * @example + * ```typescript + * const check = HealthCheck.of(effects, { + * id: 'main', + * name: 'Main', + * fn: () => ({ result: 'success', message: 'OK' }) + * }) + * ``` + */ static of(effects: Effects, options: HealthCheckParams): HealthCheck { return new HealthCheck(effects, options) } + + /** + * Starts the health check loop. + * The check function will begin executing according to the trigger schedule. + * Has no effect if already started. + */ start() { if (this.started) return this.setStarted(performance.now()) } + + /** + * Stops the health check loop. + * The check function will stop executing until `start()` is called again. + * Has no effect if already stopped. + */ stop() { if (!this.started) return this.setStarted(null) } + + /** + * Called when the HealthCheck is being disposed. + * Signals the check loop to exit permanently. + * @internal + */ onDrop(): void { this.exit() } } +/** + * Extracts an error message from an unknown error value. + * @internal + */ function asMessage(e: unknown) { if (object({ message: unknown }).test(e)) return String(e.message) const value = String(e) diff --git a/sdk/package/lib/health/checkFns/HealthCheckResult.ts b/sdk/package/lib/health/checkFns/HealthCheckResult.ts index 92d4afddf..d84a3ec81 100644 --- a/sdk/package/lib/health/checkFns/HealthCheckResult.ts +++ b/sdk/package/lib/health/checkFns/HealthCheckResult.ts @@ -1,3 +1,30 @@ import { T } from "../../../../base/lib" +/** + * The result returned by a health check function. + * + * Contains the status result and a message describing the current state. + * The `name` field is added automatically by the health check system. + * + * @example + * ```typescript + * // Success result + * const healthy: HealthCheckResult = { + * result: 'success', + * message: 'Server responding on port 8080' + * } + * + * // Failure result + * const unhealthy: HealthCheckResult = { + * result: 'failure', + * message: 'Connection refused on port 8080' + * } + * + * // Starting result (usually set automatically by grace period) + * const starting: HealthCheckResult = { + * result: 'starting', + * message: 'Waiting for server to initialize...' + * } + * ``` + */ export type HealthCheckResult = Omit diff --git a/sdk/package/lib/health/checkFns/checkPortListening.ts b/sdk/package/lib/health/checkFns/checkPortListening.ts index 59cd9717f..52911343b 100644 --- a/sdk/package/lib/health/checkFns/checkPortListening.ts +++ b/sdk/package/lib/health/checkFns/checkPortListening.ts @@ -6,6 +6,15 @@ import * as CP from "node:child_process" const cpExec = promisify(CP.exec) +/** + * Parses /proc/net/tcp* or /proc/net/udp* output to check if a port is listening. + * + * @param x - Raw content from /proc/net/tcp or similar file + * @param port - Port number to look for + * @param address - Optional specific address to match (undefined matches any) + * @returns True if the port is found in the listening sockets + * @internal + */ export function containsAddress(x: string, port: number, address?: bigint) { const readPorts = x .split("\n") @@ -20,8 +29,42 @@ export function containsAddress(x: string, port: number, address?: bigint) { } /** - * This is used to check if a port is listening on the system. - * Used during the health check fn or the check main fn. + * Checks if a specific port is listening on the local system. + * + * This is a low-level health check that reads from /proc/net/ to determine + * if a service is listening on a port. It checks both TCP and UDP, on both + * IPv4 and IPv6 interfaces. + * + * This is useful for services where you want to verify the server process + * has started and is accepting connections, even if it's not yet responding + * to application-level requests. + * + * @param effects - Effects instance (currently unused but included for API consistency) + * @param port - The port number to check + * @param options.successMessage - Message to include when the port is listening + * @param options.errorMessage - Message to include when the port is not listening + * @param options.timeoutMessage - Message when the check times out (default: auto-generated) + * @param options.timeout - Maximum time to wait for the check in milliseconds (default: 1000) + * @returns Promise resolving to a HealthCheckResult + * + * @example + * ```typescript + * // Check if PostgreSQL is listening on port 5432 + * const check = () => checkPortListening(effects, 5432, { + * successMessage: 'PostgreSQL is accepting connections', + * errorMessage: 'PostgreSQL is not listening on port 5432' + * }) + * + * // Use in health check config + * daemons.addHealthCheck({ + * id: 'database', + * name: 'Database Port', + * fn: () => checkPortListening(effects, 5432, { + * successMessage: 'Database listening', + * errorMessage: 'Database not responding' + * }) + * }) + * ``` */ export async function checkPortListening( effects: Effects, diff --git a/sdk/package/lib/health/checkFns/checkWebUrl.ts b/sdk/package/lib/health/checkFns/checkWebUrl.ts index e04ee7531..49f7bbf49 100644 --- a/sdk/package/lib/health/checkFns/checkWebUrl.ts +++ b/sdk/package/lib/health/checkFns/checkWebUrl.ts @@ -5,10 +5,41 @@ import { timeoutPromise } from "./index" import "isomorphic-fetch" /** - * This is a helper function to check if a web url is reachable. - * @param url - * @param createSuccess - * @returns + * Checks if a web URL is reachable by making an HTTP request. + * + * This is useful for services that expose an HTTP health endpoint + * or for checking if a web UI is responding. + * + * Note: This only checks if the request completes without network errors. + * It does NOT check the HTTP status code - a 500 error response would + * still be considered "success" since the server responded. + * + * @param effects - Effects instance (currently unused but included for API consistency) + * @param url - The URL to fetch (e.g., "http://localhost:8080/health") + * @param options.timeout - Maximum time to wait for a response in milliseconds (default: 1000) + * @param options.successMessage - Message to include when check succeeds + * @param options.errorMessage - Message to include when check fails + * @returns Promise resolving to a HealthCheckResult + * + * @example + * ```typescript + * // Basic usage + * const check = () => checkWebUrl(effects, 'http://localhost:8080/health') + * + * // With custom options + * const check = () => checkWebUrl(effects, 'http://localhost:3000', { + * timeout: 5000, + * successMessage: 'Web UI is responding', + * errorMessage: 'Cannot reach web UI' + * }) + * + * // Use in health check config + * daemons.addHealthCheck({ + * id: 'web', + * name: 'Web Interface', + * fn: () => checkWebUrl(effects, 'http://localhost:8080') + * }) + * ``` */ export const checkWebUrl = async ( effects: Effects, diff --git a/sdk/package/lib/health/checkFns/index.ts b/sdk/package/lib/health/checkFns/index.ts index 2de37e38c..3f121f7cb 100644 --- a/sdk/package/lib/health/checkFns/index.ts +++ b/sdk/package/lib/health/checkFns/index.ts @@ -1,8 +1,55 @@ +/** + * @module checkFns + * + * Provides pre-built health check functions for common use cases. + * These can be used directly in health check configurations or as building blocks + * for custom health checks. + * + * Available functions: + * - `checkPortListening` - Check if a port is open and listening + * - `checkWebUrl` - Check if a web URL is reachable + * - `runHealthScript` - Run a shell script and check its exit code + * - `timeoutPromise` - Helper to add timeouts to async operations + * + * @example + * ```typescript + * import { checkPortListening, checkWebUrl } from '@start9labs/sdk' + * + * // Check if port 8080 is listening + * const portCheck = () => checkPortListening(effects, 8080, { + * successMessage: 'Server is listening', + * errorMessage: 'Server not responding' + * }) + * + * // Check if web UI is reachable + * const webCheck = () => checkWebUrl(effects, 'http://localhost:8080/health', { + * timeout: 5000, + * successMessage: 'Web UI is up' + * }) + * ``` + */ import { runHealthScript } from "./runHealthScript" export { checkPortListening } from "./checkPortListening" export { HealthCheckResult } from "./HealthCheckResult" export { checkWebUrl } from "./checkWebUrl" +/** + * Creates a promise that rejects after the specified timeout. + * Useful for adding timeouts to async operations using `Promise.race()`. + * + * @param ms - Timeout duration in milliseconds + * @param options.message - Error message when timeout occurs + * @returns A promise that rejects after `ms` milliseconds + * + * @example + * ```typescript + * // Add a 5-second timeout to an async operation + * const result = await Promise.race([ + * someAsyncOperation(), + * timeoutPromise(5000, { message: 'Operation timed out' }) + * ]) + * ``` + */ export function timeoutPromise(ms: number, { message = "Timed out" } = {}) { return new Promise((resolve, reject) => setTimeout(() => reject(new Error(message)), ms), diff --git a/sdk/package/lib/health/checkFns/runHealthScript.ts b/sdk/package/lib/health/checkFns/runHealthScript.ts index 14b8569dd..00202f4c7 100644 --- a/sdk/package/lib/health/checkFns/runHealthScript.ts +++ b/sdk/package/lib/health/checkFns/runHealthScript.ts @@ -4,11 +4,47 @@ import { SubContainer } from "../../util/SubContainer" import { SDKManifest } from "../../types" /** - * Running a health script, is used when we want to have a simple - * script in bash or something like that. It should return something that is useful - * in {result: string} else it is considered an error - * @param param0 - * @returns + * Runs a command inside a subcontainer and uses the exit code for health status. + * + * This is useful when the service provides a CLI health check command or when + * you want to run a custom bash script to determine health status. The command + * must exit with code 0 for success; any other exit code is treated as failure. + * + * @typeParam Manifest - The service manifest type + * @param runCommand - Command and arguments to execute (e.g., ['pg_isready', '-U', 'postgres']) + * @param subcontainer - The SubContainer to run the command in + * @param options.timeout - Maximum time to wait for the command in milliseconds (default: 30000) + * @param options.errorMessage - Message to include when the command fails + * @param options.message - Function to generate success message from stdout + * @returns Promise resolving to a HealthCheckResult + * @throws HealthCheckResult with result: "failure" if the command fails or times out + * + * @example + * ```typescript + * // Check PostgreSQL readiness using pg_isready + * const check = () => runHealthScript( + * ['pg_isready', '-U', 'postgres'], + * subcontainer, + * { + * timeout: 5000, + * errorMessage: 'PostgreSQL is not ready' + * } + * ) + * + * // Custom bash health check + * const check = () => runHealthScript( + * ['bash', '-c', 'curl -sf http://localhost:8080/health || exit 1'], + * subcontainer, + * { errorMessage: 'Health endpoint check failed' } + * ) + * + * // Use in health check config + * daemons.addHealthCheck({ + * id: 'cli', + * name: 'CLI Health Check', + * fn: () => runHealthScript(['myapp', 'healthcheck'], subcontainer) + * }) + * ``` */ export const runHealthScript = async ( runCommand: string[], diff --git a/sdk/package/lib/mainFn/Daemons.ts b/sdk/package/lib/mainFn/Daemons.ts index 0caa18551..9bc643a22 100644 --- a/sdk/package/lib/mainFn/Daemons.ts +++ b/sdk/package/lib/mainFn/Daemons.ts @@ -1,3 +1,34 @@ +/** + * @module Daemons + * + * This module provides the Daemons class for managing service processes (daemons) + * and their health checks. Daemons are long-running processes that make up the + * core functionality of a StartOS service. + * + * @example + * ```typescript + * // Basic daemon setup + * export const main = sdk.setupMain(async ({ effects }) => { + * const container = await sdk.SubContainer.of(effects, { imageId: 'main' }, mounts, 'main') + * + * return sdk.Daemons.of(effects) + * .addDaemon('primary', { + * subcontainer: container, + * exec: { command: ['my-server', '--config', '/data/config.json'] }, + * ready: { + * display: 'Server', + * fn: () => sdk.healthCheck.checkPortListening(effects, 8080, { + * successMessage: 'Server is ready', + * errorMessage: 'Server is not responding', + * }), + * gracePeriod: 30000, // 30 second startup grace period + * }, + * requires: [], + * }) + * }) + * ``` + */ + import { Signals } from "../../../base/lib/types" import { HealthCheckResult } from "../health/checkFns" @@ -16,48 +47,124 @@ import { Daemon } from "./Daemon" import { CommandController } from "./CommandController" import { Oneshot } from "./Oneshot" +/** @internal Promisified child process exec */ export const cpExec = promisify(CP.exec) +/** @internal Promisified child process execFile */ export const cpExecFile = promisify(CP.execFile) + +/** + * Configuration for a daemon's readiness/health check. + * + * Health checks determine when a daemon is considered "ready" and report + * status to the StartOS UI. They run periodically and can be customized + * with grace periods and triggers. + */ export type Ready = { - /** A human-readable display name for the health check. If null, the health check itself will be from the UI */ - display: string | null /** - * @description The function to determine the health status of the daemon - * - * The SDK provides some built-in health checks. To see them, type sdk.healthCheck. - * - * @example - * ``` - fn: () => - sdk.healthCheck.checkPortListening(effects, 80, { - successMessage: 'service listening on port 80', - errorMessage: 'service is unreachable', - }) - * ``` - */ - fn: () => Promise | HealthCheckResult - /** - * A duration in milliseconds to treat a failing health check as "starting" + * Human-readable display name for the health check shown in the UI. + * If null, the health check will not be visible in the UI. * - * defaults to 5000 + * @example "Web Interface" + * @example "Database Connection" + */ + display: string | null + + /** + * Function that determines the health status of the daemon. + * + * The SDK provides built-in health checks: + * - `sdk.healthCheck.checkPortListening()` - Check if a port is listening + * - `sdk.healthCheck.checkWebUrl()` - Check if an HTTP endpoint responds + * - `sdk.healthCheck.runHealthScript()` - Run a custom health check script + * + * @returns HealthCheckResult with status ("success", "failure", or "starting") and message + * + * @example + * ```typescript + * fn: () => sdk.healthCheck.checkPortListening(effects, 8080, { + * successMessage: 'Server is ready', + * errorMessage: 'Server is not responding', + * }) + * ``` + * + * @example + * ```typescript + * // Custom health check + * fn: async () => { + * const result = await container.exec(['my-health-check']) + * return result.exitCode === 0 + * ? { result: 'success', message: 'Healthy' } + * : { result: 'failure', message: 'Unhealthy' } + * } + * ``` + */ + fn: () => Promise | HealthCheckResult + + /** + * Duration in milliseconds to treat a failing health check as "starting" instead of "failure". + * + * This gives the daemon time to initialize before health check failures are reported. + * After the grace period expires, failures will be reported normally. + * + * @default 5000 (5 seconds) + * + * @example 30000 // 30 second startup time + * @example 120000 // 2 minutes for slow-starting services */ gracePeriod?: number + + /** + * Optional trigger configuration for when to run the health check. + * If not specified, uses the default trigger (periodic checks). + * + * @see defaultTrigger, cooldownTrigger, changeOnFirstSuccess, successFailure + */ trigger?: Trigger } +/** + * Options for executing a command as a daemon. + */ export type ExecCommandOptions = { + /** The command to execute (string, array, or UseEntrypoint) */ command: T.CommandType - // Defaults to the DEFAULT_SIGTERM_TIMEOUT = 30_000ms + + /** + * Timeout in milliseconds to wait for graceful shutdown after sending SIGTERM. + * After this timeout, SIGKILL will be sent. + * @default 30000 (30 seconds) + */ sigtermTimeout?: number + + /** + * If true, run the command as PID 1 (init process). + * This affects signal handling and zombie process reaping. + */ runAsInit?: boolean + + /** Environment variables to set for the process */ env?: | { [variable in string]?: string } | undefined + + /** Working directory for the process */ cwd?: string | undefined + + /** User to run the process as (e.g., "root", "nobody") */ user?: string | undefined + + /** + * Callback invoked for each chunk of stdout output. + * Useful for logging or monitoring process output. + */ onStdout?: (chunk: Buffer | string | any) => void + + /** + * Callback invoked for each chunk of stderr output. + * Useful for logging or monitoring process errors. + */ onStderr?: (chunk: Buffer | string | any) => void } @@ -127,31 +234,61 @@ type AddHealthCheckParams = { type ErrorDuplicateId = `The id '${Id}' is already used` +/** @internal Helper to create a CommandController */ export const runCommand = () => CommandController.of>() /** - * A class for defining and controlling the service daemons -```ts -Daemons.of({ - effects, - started, - interfaceReceipt, // Provide the interfaceReceipt to prove it was completed - healthReceipts, // Provide the healthReceipts or [] to prove they were at least considered -}).addDaemon('webui', { - command: 'hello-world', // The command to start the daemon - ready: { - display: 'Web Interface', - // The function to run to determine the health status of the daemon - fn: () => - checkPortListening(effects, 80, { - successMessage: 'The web interface is ready', - errorMessage: 'The web interface is not ready', - }), - }, - requires: [], -}) -``` + * Manager class for defining and controlling service daemons. + * + * Exposed via `sdk.Daemons`. Daemons are long-running processes that make up + * your service. The Daemons class provides a fluent API for: + * - Defining multiple daemons with dependencies between them + * - Configuring health checks for each daemon + * - Managing startup order based on dependency requirements + * - Handling graceful shutdown in reverse dependency order + * + * @typeParam Manifest - The service manifest type + * @typeParam Ids - Union type of all daemon IDs (accumulates as daemons are added) + * + * @example + * ```typescript + * // Single daemon service + * return sdk.Daemons.of(effects) + * .addDaemon('primary', { + * subcontainer, + * exec: { command: sdk.useEntrypoint() }, + * ready: { + * display: 'Server', + * fn: () => sdk.healthCheck.checkPortListening(effects, 8080, { ... }), + * }, + * requires: [], + * }) + * ``` + * + * @example + * ```typescript + * // Multi-daemon service with dependencies + * return sdk.Daemons.of(effects) + * .addDaemon('database', { + * subcontainer: dbContainer, + * exec: { command: ['postgres', '-D', '/data'] }, + * ready: { display: 'Database', fn: checkDbReady }, + * requires: [], // No dependencies + * }) + * .addDaemon('api', { + * subcontainer: apiContainer, + * exec: { command: ['node', 'server.js'] }, + * ready: { display: 'API Server', fn: checkApiReady }, + * requires: ['database'], // Waits for database to be ready + * }) + * .addDaemon('worker', { + * subcontainer: workerContainer, + * exec: { command: ['node', 'worker.js'] }, + * ready: { display: 'Background Worker', fn: checkWorkerReady }, + * requires: ['database', 'api'], // Waits for both + * }) + * ``` */ export class Daemons implements T.DaemonBuildable diff --git a/sdk/package/lib/mainFn/Mounts.ts b/sdk/package/lib/mainFn/Mounts.ts index 5a2a5ec8a..45bbc60f3 100644 --- a/sdk/package/lib/mainFn/Mounts.ts +++ b/sdk/package/lib/mainFn/Mounts.ts @@ -1,18 +1,65 @@ +/** + * @module Mounts + * + * This module provides a fluent API for configuring volume mounts for SubContainers. + * The Mounts class uses a builder pattern to accumulate mount configurations that + * are then applied when a container starts. + * + * Mount types supported: + * - **Volumes** - Service-owned data directories defined in the manifest + * - **Assets** - Static files bundled with the service package + * - **Dependencies** - Volumes from other services this service depends on + * - **Backups** - Special mount for backup operations + * + * @example + * ```typescript + * const mounts = Mounts.of() + * .mountVolume({ + * volumeId: 'main', + * mountpoint: '/data', + * readonly: false, + * subpath: null + * }) + * .mountAssets({ + * mountpoint: '/config', + * subpath: 'default-config' + * }) + * .mountDependency({ + * dependencyId: 'bitcoind', + * volumeId: 'data', + * mountpoint: '/bitcoin', + * readonly: true, + * subpath: null + * }) + * .build() + * ``` + */ import * as T from "../../../base/lib/types" import { IdMap, MountOptions } from "../util/SubContainer" +/** + * Array of mount configurations ready to be applied to a container. + * Each entry maps a mountpoint path to its mount options. + */ type MountArray = { mountpoint: string; options: MountOptions }[] +/** + * Common options shared across all mount types. + * These options control where and how a resource is mounted into a container. + */ type SharedOptions = { /** The path within the resource to mount. Use `null` to mount the entire resource */ subpath: string | null - /** Where to mount the resource. e.g. /data */ + /** The absolute path inside the container where the resource will be accessible (e.g., "/data") */ mountpoint: string /** - * Whether to mount this as a file or directory + * Whether to mount this as a file or directory. + * - `"file"` - Mount a single file + * - `"directory"` - Mount a directory (default) + * - `"infer"` - Automatically detect based on the source * - * defaults to "directory" - * */ + * @default "directory" + */ type?: "file" | "directory" | "infer" // /** // * Whether to map uids/gids for the mount @@ -33,37 +80,142 @@ type SharedOptions = { // }[] } +/** + * Options for mounting one of the service's own volumes. + * Volumes are persistent storage areas defined in the service manifest. + * + * @typeParam Manifest - The service manifest type, used for type-safe volume ID validation + * + * @example + * ```typescript + * { + * volumeId: 'main', // Must match a volume defined in manifest + * mountpoint: '/data', // Where it appears in the container + * readonly: false, // Allow writes + * subpath: null // Mount the entire volume + * } + * ``` + */ type VolumeOpts = { /** The ID of the volume to mount. Must be one of the volume IDs defined in the manifest */ volumeId: Manifest["volumes"][number] - /** Whether or not the resource should be readonly for this subcontainer */ + /** If true, the volume will be mounted read-only (writes will fail) */ readonly: boolean } & SharedOptions +/** + * Options for mounting a volume from a dependency service. + * This allows accessing data from services that this service depends on. + * + * @typeParam Manifest - The dependency's manifest type, used for type-safe volume ID validation + * + * @example + * ```typescript + * { + * dependencyId: 'bitcoind', // The dependency's package ID + * volumeId: 'data', // A volume from the dependency's manifest + * mountpoint: '/bitcoin-data', // Where it appears in this container + * readonly: true, // Usually read-only for safety + * subpath: 'blocks' // Optionally mount only a subdirectory + * } + * ``` + */ type DependencyOpts = { - /** The ID of the dependency */ + /** The package ID of the dependency service */ dependencyId: Manifest["id"] - /** The ID of the volume to mount. Must be one of the volume IDs defined in the manifest of the dependency */ + /** The ID of the volume to mount from the dependency. Must be defined in the dependency's manifest */ volumeId: Manifest["volumes"][number] - /** Whether or not the resource should be readonly for this subcontainer */ + /** If true, the volume will be mounted read-only (writes will fail) */ readonly: boolean } & SharedOptions +/** + * Fluent builder for configuring container volume mounts. + * + * Exposed via `sdk.Mounts`. The Mounts class uses an immutable builder pattern - + * each method returns a new Mounts instance with the additional configuration, + * leaving the original unchanged. Call `build()` at the end to get the final mount array. + * + * @typeParam Manifest - The service manifest type for volume ID validation + * @typeParam Backups - Type tracking whether backup mounts have been added + * + * @example + * ```typescript + * // Basic usage with a single volume + * const mounts = Mounts.of() + * .mountVolume({ + * volumeId: 'main', + * mountpoint: '/data', + * readonly: false, + * subpath: null + * }) + * .build() + * + * // Complex setup with multiple mount types + * const mounts = Mounts.of() + * .mountVolume({ volumeId: 'main', mountpoint: '/data', readonly: false, subpath: null }) + * .mountVolume({ volumeId: 'logs', mountpoint: '/var/log/app', readonly: false, subpath: null }) + * .mountAssets({ mountpoint: '/etc/app', subpath: 'config' }) + * .mountDependency({ + * dependencyId: 'bitcoind', + * volumeId: 'data', + * mountpoint: '/bitcoin', + * readonly: true, + * subpath: null + * }) + * .build() + * ``` + */ export class Mounts< Manifest extends T.SDKManifest, Backups extends SharedOptions = never, > { private constructor( + /** @internal Accumulated volume mount configurations */ readonly volumes: VolumeOpts[], + /** @internal Accumulated asset mount configurations */ readonly assets: SharedOptions[], + /** @internal Accumulated dependency mount configurations */ readonly dependencies: DependencyOpts[], + /** @internal Accumulated backup mount configurations */ readonly backups: Backups[], ) {} + /** + * Creates a new empty Mounts builder. + * This is the starting point for building mount configurations. + * + * @typeParam Manifest - The service manifest type for volume ID validation + * @returns A new empty Mounts builder instance + * + * @example + * ```typescript + * const mounts = Mounts.of() + * .mountVolume({ ... }) + * .build() + * ``` + */ static of() { return new Mounts([], [], [], []) } + /** + * Adds a volume mount to the configuration. + * Volumes are persistent storage areas owned by this service. + * + * @param options - Volume mount configuration + * @returns A new Mounts instance with the volume added + * + * @example + * ```typescript + * mounts.mountVolume({ + * volumeId: 'main', // Must exist in manifest.volumes + * mountpoint: '/data', // Container path + * readonly: false, // Allow writes + * subpath: null // Mount entire volume + * }) + * ``` + */ mountVolume(options: VolumeOpts) { return new Mounts( [...this.volumes, options], @@ -73,6 +225,21 @@ export class Mounts< ) } + /** + * Adds an assets mount to the configuration. + * Assets are static files bundled with the service package (read-only). + * + * @param options - Asset mount configuration + * @returns A new Mounts instance with the asset mount added + * + * @example + * ```typescript + * mounts.mountAssets({ + * mountpoint: '/etc/myapp', // Where to mount in container + * subpath: 'default-config' // Subdirectory within assets + * }) + * ``` + */ mountAssets(options: SharedOptions) { return new Mounts( [...this.volumes], @@ -82,6 +249,27 @@ export class Mounts< ) } + /** + * Adds a dependency volume mount to the configuration. + * This mounts a volume from another service that this service depends on. + * + * @typeParam DependencyManifest - The manifest type of the dependency service + * @param options - Dependency mount configuration + * @returns A new Mounts instance with the dependency mount added + * + * @example + * ```typescript + * import { manifest as bitcoinManifest } from 'bitcoind-startos' + * + * mounts.mountDependency({ + * dependencyId: 'bitcoind', + * volumeId: 'data', + * mountpoint: '/bitcoin', + * readonly: true, // Usually read-only for safety + * subpath: null + * }) + * ``` + */ mountDependency( options: DependencyOpts, ) { @@ -93,6 +281,21 @@ export class Mounts< ) } + /** + * Adds a backup mount to the configuration. + * This is used during backup operations to provide access to the backup destination. + * + * @param options - Backup mount configuration + * @returns A new Mounts instance with the backup mount added + * + * @example + * ```typescript + * mounts.mountBackups({ + * mountpoint: '/backup', + * subpath: null + * }) + * ``` + */ mountBackups(options: SharedOptions) { return new Mounts< Manifest, @@ -108,6 +311,24 @@ export class Mounts< ) } + /** + * Finalizes the mount configuration and returns the mount array. + * Validates that no two mounts use the same mountpoint. + * + * @returns Array of mount configurations ready to apply to a container + * @throws Error if the same mountpoint is used more than once + * + * @example + * ```typescript + * const mountArray = Mounts.of() + * .mountVolume({ volumeId: 'main', mountpoint: '/data', readonly: false, subpath: null }) + * .mountAssets({ mountpoint: '/config', subpath: null }) + * .build() + * + * // Use with SubContainer + * subcontainer.exec({ command: 'myapp', mounts: mountArray }) + * ``` + */ build(): MountArray { const mountpoints = new Set() for (let mountpoint of this.volumes diff --git a/sdk/package/lib/trigger/TriggerInput.ts b/sdk/package/lib/trigger/TriggerInput.ts index e15cca9b7..5e3ab7ccf 100644 --- a/sdk/package/lib/trigger/TriggerInput.ts +++ b/sdk/package/lib/trigger/TriggerInput.ts @@ -1,5 +1,26 @@ import { HealthStatus } from "../../../base/lib/types" +/** + * Input state provided to trigger functions. + * Contains information about the health check's current state + * that triggers can use to adjust their timing behavior. + * + * @example + * ```typescript + * const myTrigger: Trigger = (getInput) => { + * return (async function* () { + * while (true) { + * const input: TriggerInput = getInput() + * // Check more frequently if last result was failure + * const delay = input.lastResult === 'failure' ? 1000 : 30000 + * await new Promise(r => setTimeout(r, delay)) + * yield + * } + * })() + * } + * ``` + */ export type TriggerInput = { + /** The result of the most recent health check execution, if any */ lastResult?: HealthStatus } diff --git a/sdk/package/lib/trigger/changeOnFirstSuccess.ts b/sdk/package/lib/trigger/changeOnFirstSuccess.ts index 3da7284df..953db74a8 100644 --- a/sdk/package/lib/trigger/changeOnFirstSuccess.ts +++ b/sdk/package/lib/trigger/changeOnFirstSuccess.ts @@ -1,5 +1,35 @@ import { Trigger } from "./index" +/** + * Creates a trigger that uses different timing before and after the first successful health check. + * + * This is useful for services that need frequent checks during startup (to quickly report + * when they become healthy) but can reduce check frequency once they're running stably. + * + * The trigger switches permanently to `afterFirstSuccess` timing once a success is seen. + * It does NOT switch back even if the service later becomes unhealthy. + * + * @param o.beforeFirstSuccess - Trigger to use until the first successful health check + * @param o.afterFirstSuccess - Trigger to use after the first successful health check + * @returns A composite trigger that switches behavior after first success + * + * @example + * ```typescript + * // Check every second while starting, every 30 seconds once healthy + * const trigger = changeOnFirstSuccess({ + * beforeFirstSuccess: cooldownTrigger(1000), // 1s while starting + * afterFirstSuccess: cooldownTrigger(30000) // 30s after healthy + * }) + * + * // Use in a health check + * daemons.addHealthCheck({ + * id: 'main', + * name: 'Main Health', + * trigger, + * fn: checkServiceHealth + * }) + * ``` + */ export function changeOnFirstSuccess(o: { beforeFirstSuccess: Trigger afterFirstSuccess: Trigger diff --git a/sdk/package/lib/trigger/cooldownTrigger.ts b/sdk/package/lib/trigger/cooldownTrigger.ts index 991e81054..cc5a371ac 100644 --- a/sdk/package/lib/trigger/cooldownTrigger.ts +++ b/sdk/package/lib/trigger/cooldownTrigger.ts @@ -1,3 +1,25 @@ +/** + * Creates a simple timer-based trigger that fires at regular intervals. + * This is the most basic trigger type - it just waits the specified + * time between each health check. + * + * @param timeMs - Interval between health checks in milliseconds + * @returns A trigger factory function + * + * @example + * ```typescript + * // Check health every 5 seconds + * const trigger = cooldownTrigger(5000) + * + * // Use in a health check + * daemons.addHealthCheck({ + * id: 'main', + * name: 'Main Check', + * trigger: cooldownTrigger(10000), // Every 10 seconds + * fn: async () => ({ result: 'success', message: 'OK' }) + * }) + * ``` + */ export function cooldownTrigger(timeMs: number) { return async function* () { while (true) { diff --git a/sdk/package/lib/trigger/defaultTrigger.ts b/sdk/package/lib/trigger/defaultTrigger.ts index 647695fb2..b1c069ad8 100644 --- a/sdk/package/lib/trigger/defaultTrigger.ts +++ b/sdk/package/lib/trigger/defaultTrigger.ts @@ -1,6 +1,35 @@ import { cooldownTrigger } from "./cooldownTrigger" import { changeOnFirstSuccess } from "./changeOnFirstSuccess" +/** + * The default trigger used when no custom trigger is specified for a health check. + * + * Provides sensible defaults for most services: + * - **Before first success**: Checks every 1 second (rapid during startup) + * - **After first success**: Checks every 30 seconds (stable once healthy) + * + * This trigger is automatically used by `Daemons.addDaemon()` and `Daemons.addHealthCheck()` + * when no `trigger` option is provided. + * + * @example + * ```typescript + * // These are equivalent - both use defaultTrigger + * daemons.addHealthCheck({ + * id: 'main', + * name: 'Main', + * fn: checkHealth + * // trigger: defaultTrigger // implicit + * }) + * + * // Custom trigger overrides the default + * daemons.addHealthCheck({ + * id: 'main', + * name: 'Main', + * trigger: cooldownTrigger(5000), // Check every 5s instead + * fn: checkHealth + * }) + * ``` + */ export const defaultTrigger = changeOnFirstSuccess({ beforeFirstSuccess: cooldownTrigger(1000), afterFirstSuccess: cooldownTrigger(30000), diff --git a/sdk/package/lib/trigger/index.ts b/sdk/package/lib/trigger/index.ts index 6da034262..b73bc88c3 100644 --- a/sdk/package/lib/trigger/index.ts +++ b/sdk/package/lib/trigger/index.ts @@ -1,7 +1,57 @@ +/** + * @module trigger + * + * Triggers control when health checks are executed. They are async generators + * that yield when a health check should run. This allows fine-grained control + * over check frequency based on the service's current state. + * + * Built-in triggers: + * - `cooldownTrigger(ms)` - Simple timer-based trigger + * - `changeOnFirstSuccess` - Different timing before/after first successful check + * - `successFailure` - Different timing based on success/failure state + * - `lastStatus` - Configurable timing per health status + * + * @example + * ```typescript + * // Check every 5 seconds + * const trigger = cooldownTrigger(5000) + * + * // Fast checks until healthy, then slow down + * const adaptiveTrigger = changeOnFirstSuccess({ + * beforeFirstSuccess: cooldownTrigger(1000), // 1s while starting + * afterFirstSuccess: cooldownTrigger(30000) // 30s once healthy + * }) + * ``` + */ import { TriggerInput } from "./TriggerInput" export { changeOnFirstSuccess } from "./changeOnFirstSuccess" export { cooldownTrigger } from "./cooldownTrigger" +/** + * A trigger function that controls when health checks execute. + * + * Triggers are async generator factories. Given a function to get the current + * input state (e.g., last health result), they return an async iterator that + * yields when a health check should run. + * + * @param getInput - Function returning the current trigger input state + * @returns An async iterator that yields when a check should run + * + * @example + * ```typescript + * // Custom trigger that checks every 10s during success, 2s during failure + * const myTrigger: Trigger = (getInput) => { + * return (async function* () { + * while (true) { + * const { lastResult } = getInput() + * const delay = lastResult === 'success' ? 10000 : 2000 + * await new Promise(r => setTimeout(r, delay)) + * yield + * } + * })() + * } + * ``` + */ export type Trigger = ( getInput: () => TriggerInput, ) => AsyncIterator diff --git a/sdk/package/lib/trigger/lastStatus.ts b/sdk/package/lib/trigger/lastStatus.ts index 01e737314..8085111ff 100644 --- a/sdk/package/lib/trigger/lastStatus.ts +++ b/sdk/package/lib/trigger/lastStatus.ts @@ -1,10 +1,39 @@ import { Trigger } from "." import { HealthStatus } from "../../../base/lib/types" +/** + * Configuration for status-based trigger selection. + * Maps health statuses to triggers, with a required default fallback. + */ export type LastStatusTriggerParams = { [k in HealthStatus]?: Trigger } & { + /** Trigger to use when no status-specific trigger is defined */ default: Trigger } +/** + * Creates a trigger that dynamically selects timing based on the last health check result. + * + * This allows different check frequencies for each health status: + * - `success` - When the service is healthy + * - `failure` - When the service is unhealthy + * - `starting` - When the service is still starting up + * + * Uses the `default` trigger for any status not explicitly configured. + * + * @param o - Map of health statuses to triggers, plus a required default + * @returns A composite trigger that adapts to the current health status + * + * @example + * ```typescript + * // Check frequently during failure, rarely during success + * const adaptiveTrigger = lastStatus({ + * success: cooldownTrigger(60000), // 60s when healthy + * failure: cooldownTrigger(5000), // 5s when unhealthy + * starting: cooldownTrigger(2000), // 2s while starting + * default: cooldownTrigger(10000) // 10s fallback + * }) + * ``` + */ export function lastStatus(o: LastStatusTriggerParams): Trigger { return async function* (getInput) { let trigger = o.default(getInput) diff --git a/sdk/package/lib/trigger/successFailure.ts b/sdk/package/lib/trigger/successFailure.ts index 7febcd356..fae086254 100644 --- a/sdk/package/lib/trigger/successFailure.ts +++ b/sdk/package/lib/trigger/successFailure.ts @@ -1,6 +1,26 @@ import { Trigger } from "." import { lastStatus } from "./lastStatus" +/** + * Creates a trigger with different timing for success vs failure/starting states. + * + * This is a simplified wrapper around `lastStatus` for the common case + * where you want one timing during healthy operation and another during + * any error condition (failure or starting). + * + * @param o.duringSuccess - Trigger to use when the last check succeeded + * @param o.duringError - Trigger to use for failure, starting, or unknown states + * @returns A composite trigger that adapts to success/failure state + * + * @example + * ```typescript + * // Check every minute when healthy, every 5 seconds when unhealthy + * const trigger = successFailure({ + * duringSuccess: cooldownTrigger(60000), // 1 minute + * duringError: cooldownTrigger(5000) // 5 seconds + * }) + * ``` + */ export const successFailure = (o: { duringSuccess: Trigger duringError: Trigger diff --git a/sdk/package/lib/util/SubContainer.ts b/sdk/package/lib/util/SubContainer.ts index 842ef43d9..47f721b75 100644 --- a/sdk/package/lib/util/SubContainer.ts +++ b/sdk/package/lib/util/SubContainer.ts @@ -1,3 +1,51 @@ +/** + * @module SubContainer + * + * This module provides the SubContainer class for running containerized processes. + * SubContainers are isolated environments created from Docker images where your + * service's main processes run. + * + * SubContainers provide: + * - Isolated filesystem from a Docker image + * - Volume mounting for persistent data + * - Command execution (exec, execFail, spawn, launch) + * - File system operations within the container + * + * @example + * ```typescript + * // Create a subcontainer with volume mounts + * const container = await sdk.SubContainer.of( + * effects, + * { imageId: 'main' }, + * sdk.Mounts.of() + * .mountVolume({ volumeId: 'main', mountpoint: '/data' }), + * 'my-container' + * ) + * + * // Execute a command + * const result = await container.exec(['cat', '/data/config.json']) + * console.log(result.stdout) + * + * // Run as a daemon + * const process = await container.launch(['my-server', '--config', '/data/config.json']) + * ``` + * + * @example + * ```typescript + * // Use withTemp for one-off commands + * const output = await sdk.SubContainer.withTemp( + * effects, + * { imageId: 'main' }, + * mounts, + * 'generate-password', + * async (container) => { + * const result = await container.execFail(['openssl', 'rand', '-hex', '16']) + * return result.stdout.toString().trim() + * } + * ) + * ``` + */ + import * as fs from "fs/promises" import * as T from "../../../base/lib/types" import * as cp from "child_process" @@ -9,17 +57,29 @@ import { Mounts } from "../mainFn/Mounts" import { BackupEffects } from "../backup/Backups" import { PathBase } from "./Volume" +/** @internal Promisified execFile */ export const execFile = promisify(cp.execFile) const False = () => false +/** + * Results from executing a command in a SubContainer. + */ type ExecResults = { + /** Exit code (null if terminated by signal) */ exitCode: number | null + /** Signal that terminated the process (null if exited normally) */ exitSignal: NodeJS.Signals | null + /** Standard output from the command */ stdout: string | Buffer + /** Standard error from the command */ stderr: string | Buffer } +/** + * Options for exec operations. + */ export type ExecOptions = { + /** Input to write to the command's stdin */ input?: string | Buffer } @@ -69,18 +129,38 @@ async function bind( await execFile("mount", [...args, from, to]) } +/** + * Interface for a SubContainer - an isolated container environment for running processes. + * + * SubContainers provide a sandboxed filesystem from a Docker image with mounted + * volumes for persistent data. Use `sdk.SubContainer.of()` to create one. + * + * @typeParam Manifest - The service manifest type (for type-safe image/volume references) + * @typeParam Effects - The Effects type (usually T.Effects) + */ export interface SubContainer< Manifest extends T.SDKManifest, Effects extends T.Effects = T.Effects, > extends Drop, PathBase { + /** The image ID this container was created from */ readonly imageId: keyof Manifest["images"] & T.ImageId + /** The root filesystem path of this container */ readonly rootfs: string + /** Unique identifier for this container instance */ readonly guid: T.Guid /** - * Get the absolute path to a file or directory within this subcontainer's rootfs - * @param path Path relative to the rootfs + * Gets the absolute path to a file or directory within this container's rootfs. + * + * @param path - Path relative to the rootfs (e.g., "/data/config.json") + * @returns The absolute path on the host filesystem + * + * @example + * ```typescript + * const configPath = container.subpath('/data/config.json') + * // Returns something like "/media/startos/containers//data/config.json" + * ``` */ subpath(path: string): string @@ -168,7 +248,30 @@ export interface SubContainer< } /** - * Want to limit what we can do in a container, so we want to launch a container with a specific image and the mounts. + * An owned SubContainer that manages its own lifecycle. + * + * This is the primary implementation of SubContainer. When destroyed, it cleans up + * the container filesystem. Use `sdk.SubContainer.of()` which returns a reference-counted + * wrapper for easier lifecycle management. + * + * @typeParam Manifest - The service manifest type + * @typeParam Effects - The Effects type + * + * @example + * ```typescript + * // Direct usage (manual cleanup required) + * const container = await SubContainerOwned.of(effects, { imageId: 'main' }, mounts, 'name') + * try { + * await container.exec(['my-command']) + * } finally { + * await container.destroy() + * } + * + * // Or use withTemp for automatic cleanup + * await SubContainerOwned.withTemp(effects, { imageId: 'main' }, mounts, 'name', async (c) => { + * await c.exec(['my-command']) + * }) + * ``` */ export class SubContainerOwned< Manifest extends T.SDKManifest, @@ -882,71 +985,131 @@ export class SubContainerRc< } } +/** + * Options for command execution in a SubContainer. + */ export type CommandOptions = { /** - * Environment variables to set for this command + * Environment variables to set for this command. + * Variables with undefined values are ignored. + * + * @example + * ```typescript + * env: { NODE_ENV: 'production', DEBUG: 'app:*' } + * ``` */ env?: { [variable in string]?: string } + /** - * the working directory to run this command in + * The working directory to run this command in. + * Defaults to the image's WORKDIR or "/" if not specified. */ cwd?: string + /** - * the user to run this command as + * The user to run this command as. + * Defaults to the image's USER or "root" if not specified. + * + * @example "root", "nobody", "app" */ user?: string } +/** + * Options for process stdio handling. + */ export type StdioOptions = { + /** How to handle stdio streams */ stdio?: cp.IOType } -export type IdMap = { fromId: number; toId: number; range: number } +/** + * User/group ID mapping for volume mounts. + * Used for mapping container UIDs to host UIDs. + */ +export type IdMap = { + /** Source ID in the host namespace */ + fromId: number + /** Target ID in the container namespace */ + toId: number + /** Number of IDs to map (contiguous range) */ + range: number +} +/** Union of all mount option types */ export type MountOptions = | MountOptionsVolume | MountOptionsAssets | MountOptionsPointer | MountOptionsBackup +/** Mount options for a service volume */ export type MountOptionsVolume = { type: "volume" + /** ID of the volume to mount */ volumeId: string + /** Subpath within the volume (null for root) */ subpath: string | null + /** Whether the mount is read-only */ readonly: boolean + /** How to treat the mount target (file, directory, or auto-detect) */ filetype: "file" | "directory" | "infer" + /** UID/GID mappings */ idmap: IdMap[] } +/** Mount options for service assets (read-only resources bundled with the package) */ export type MountOptionsAssets = { type: "assets" + /** Subpath within the assets directory */ subpath: string | null + /** How to treat the mount target */ filetype: "file" | "directory" | "infer" + /** UID/GID mappings */ idmap: { fromId: number; toId: number; range: number }[] } +/** Mount options for a volume from another service (dependency) */ export type MountOptionsPointer = { type: "pointer" + /** Package ID of the dependency */ packageId: string + /** Volume ID within the dependency */ volumeId: string + /** Subpath within the volume */ subpath: string | null + /** Whether the mount is read-only */ readonly: boolean + /** UID/GID mappings */ idmap: { fromId: number; toId: number; range: number }[] } +/** Mount options for backup data (during backup/restore operations) */ export type MountOptionsBackup = { type: "backup" + /** Subpath within the backup */ subpath: string | null + /** How to treat the mount target */ filetype: "file" | "directory" | "infer" + /** UID/GID mappings */ idmap: { fromId: number; toId: number; range: number }[] } + +/** @internal Helper to wait for a specified time */ function wait(time: number) { return new Promise((resolve) => setTimeout(resolve, time)) } +/** + * Error thrown when a command exits with a non-zero code or signal. + * + * Contains the full execution result including stdout/stderr for debugging. + */ export class ExitError extends Error { constructor( + /** The command that was executed */ readonly command: string, + /** The execution result */ readonly result: { exitCode: number | null exitSignal: T.Signals | null