Compare commits

...

1 Commits

Author SHA1 Message Date
Matt Hill
9519684185 sdk comments 2026-02-05 08:10:53 -07:00
37 changed files with 3603 additions and 150 deletions

View File

@@ -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<null>
// 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<null>
/** 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<null>
/**
* 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<ActionInput | null>
/**
* 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<Input extends Record<string, unknown>>(options: {
packageId?: PackageId
actionId: ActionId
input?: Input
}): Promise<ActionResult | null>
/**
* 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<null>
/**
* 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<null>
}
// 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<null>
/** 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<null>
/** 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<StatusInfo>
/** 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<null>
// 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<null>
/** 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<DependencyRequirement[]>
/** 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<CheckDependenciesResult[]>
/** 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<string>
/** 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<string[]>
/** 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<Manifest>
// 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<null>
// 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<null>
}
// 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<null>
/** 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<NetInfo>
/** 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<null>
// 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<Host | null>
/** 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<string>
/** 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<string>
// 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<null>
/** 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<ServiceInterface | null>
/** 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<Record<ServiceInterfaceId, ServiceInterface>>
/** 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<null>
// 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<string>
/** 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<null>
/** 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<string | null>
// 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<SmtpValue | null>
}

View File

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

View File

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

View File

@@ -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<DependencyId extends PackageId = PackageId> = {
/**
* 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,
>(

View File

@@ -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<Manifest extends T.SDKManifest> = {
[K in keyof Manifest["dependencies"]]: Exclude<
Manifest["dependencies"][K],
@@ -9,33 +44,75 @@ export type RequiredDependenciesOf<Manifest extends T.SDKManifest> = {
? 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<Manifest extends T.SDKManifest> = Exclude<
keyof Manifest["dependencies"],
RequiredDependenciesOf<Manifest>
>
/**
* 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<T.HealthCheckId>
/** 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, U> = 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<Manifest extends T.SDKManifest> = {
[K in RequiredDependenciesOf<Manifest>]: DependencyRequirement
} & {
[K in OptionalDependenciesOf<Manifest>]?: 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<Manifest extends T.SDKManifest>(
fn: (options: {
effects: T.Effects

View File

@@ -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
},
) {}

View File

@@ -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 (<PROTOCOL> :// <HOSTNAME> : <PORT>) 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<string, string>
}

View File

@@ -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<string, string>
/** 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
},
) {}

View File

@@ -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<T.AddressInfo[] & AddressReceipt>
/**
* Function type for setting up service interfaces.
* @typeParam Output - The specific receipt type returned
*/
export type SetServiceInterfaces<Output extends ServiceInterfacesReceipt> =
(opts: { effects: T.Effects }) => Promise<Output>
/** Function type for the init-compatible interface updater */
export type UpdateServiceInterfaces = (effects: T.Effects) => Promise<null>
/** Function type for the setupServiceInterfaces helper */
export type SetupServiceInterfaces = <Output extends ServiceInterfacesReceipt>(
fn: SetServiceInterfaces<Output>,
) => 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,
>(

View File

@@ -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<void>
}>
}
/**
* 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<A> = Promise<A> | 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<unknown>
/**
* 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<DaemonBuildable>
/**
* 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<unknown>
/** 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<unknown>
/** 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<Record<ActionId, Action<ActionId, any>>>
}
/**
* 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<string>
/**
* Terminates the daemon, optionally with a specific signal.
* @returns Promise resolving to null when termination is complete
*/
term(): Promise<null>
/** @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<unknown>
/**
* 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<null>
}
/** @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<T extends StringObject = Effects> = {
[K in keyof T]-?: K extends string
? T[K] extends Function
@@ -131,74 +367,172 @@ export type EffectMethod<T extends StringObject = Effects> = {
: 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<DependencyRequirement>
/**
* 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<Config>
* // { server?: { host?: string; port?: number } }
* ```
*/
export type DeepPartial<T> = T extends [infer A, ...infer Rest]
? [DeepPartial<A>, ...DeepPartial<Rest>]
: T extends {}
? { [P in keyof T]?: DeepPartial<T[P]> }
: T
/**
* Recursively removes readonly modifiers from all properties.
* Useful when you need to modify a readonly object.
*/
export type DeepWritable<T> = {
-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<T>(value: T): DeepWritable<T> {
return value
}
/**
* Recursively adds readonly modifiers to all properties.
* Useful for ensuring immutability at the type level.
*/
export type DeepReadonly<T> = {
readonly [P in keyof T]: DeepReadonly<T[P]>
}
/**
* 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<T>(value: T): DeepReadonly<T> {
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<string[]>) { ... }
* process(['a', 'b']) // Works
* process(['a', 'b'] as const) // Also works
* ```
*/
export type AllowReadonly<T> =
| T
| {

View File

@@ -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<ImageSource, "packed">
/** 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]

View File

@@ -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) => {

View File

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

View File

@@ -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<T>(
prev: T,
next: T,
@@ -46,6 +67,28 @@ export function partialDiff<T>(
}
}
/**
* 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

View File

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

View File

@@ -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<B>(fn: () => B): () => B {
let result: [B] | [] = []
return () => {

View File

@@ -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 <john@example.com>") */
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:

View File

@@ -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[] => {

View File

@@ -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<A & B> // Shows as { foo: string; bar: number }
* ```
*/
// prettier-ignore
export type FlattenIntersection<T> =
export type FlattenIntersection<T> =
T extends ArrayLike<any> ? T :
T extends object ? {} & {[P in keyof T]: T[P]} :
T;
/**
* Alias for FlattenIntersection for shorter usage.
* @see FlattenIntersection
*/
export type _<T> = FlattenIntersection<T>
/**
* 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<A> = { [affine]: A }
type NeverPossible = { [affine]: string }

View File

@@ -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<T extends any[], Then, Else> =
type AnyNeverCond<T extends any[], Then, Else> =
T extends [] ? Else :
T extends [never, ...Array<any>] ? Then :
T extends [any, ...infer U] ? AnyNeverCond<U,Then, Else> :
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<Manifest extends T.SDKManifest> {
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<never>(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 extends T.SDKManifest = never>(manifest: Manifest) {
return new StartSdk<Manifest>(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<Manifest extends T.SDKManifest> {
}
}
/**
* 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<Manifest extends T.SDKManifest>(
effects: Effects,
image: { imageId: keyof Manifest["images"] & T.ImageId; sharedRun?: boolean },

View File

@@ -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<Manifest>('main', 'config')
*
* // Advanced backup with hooks
* export const createBackup = Backups.ofVolumes<Manifest>('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<Volumes extends string> = {
/** 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<T.SyncOptions>
/** Sync options applied only during backup (merged with options) */
backupOptions?: Partial<T.SyncOptions>
/** Sync options applied only during restore (merged with options) */
restoreOptions?: Partial<T.SyncOptions>
}
/**
* 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<Manifest>('main', 'config')
*
* // With database dump before backup
* export const createBackup = Backups.ofVolumes<Manifest>('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<M extends T.SDKManifest> implements InitScript {
private constructor(
/** @internal Default sync options */
private options = DEFAULT_OPTIONS,
/** @internal Options specific to restore operations */
private restoreOptions: Partial<T.SyncOptions> = {},
/** @internal Options specific to backup operations */
private backupOptions: Partial<T.SyncOptions> = {},
/** @internal Set of sync configurations */
private backupSet = [] as BackupSync<M["volumes"][number]>[],
/** @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<Manifest>('main')
*
* // Back up multiple volumes
* export const createBackup = Backups.ofVolumes<Manifest>('main', 'config', 'logs')
* ```
*/
static ofVolumes<M extends T.SDKManifest = never>(
...volumeNames: Array<M["volumes"][number]>
): Backups<M> {
@@ -42,18 +147,56 @@ export class Backups<M extends T.SDKManifest> 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<Manifest>(
* { dataPath: '/media/startos/volumes/main/data', backupPath: '/media/startos/backup/data' },
* { dataPath: '/media/startos/volumes/main/config', backupPath: '/media/startos/backup/config' }
* )
* ```
*/
static ofSyncs<M extends T.SDKManifest = never>(
...syncs: BackupSync<M["volumes"][number]>[]
) {
return syncs.reduce((acc, x) => acc.addSync(x), new Backups<M>())
}
/**
* 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<Manifest>({
* exclude: ['cache/', '*.tmp', '*.log']
* }).addVolume('main')
* ```
*/
static withOptions<M extends T.SDKManifest = never>(
options?: Partial<T.SyncOptions>,
) {
return new Backups<M>({ ...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<T.SyncOptions>) {
this.options = {
...this.options,
@@ -62,6 +205,13 @@ export class Backups<M extends T.SDKManifest> 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<T.SyncOptions>) {
this.backupOptions = {
...this.backupOptions,
@@ -70,6 +220,13 @@ export class Backups<M extends T.SDKManifest> 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<T.SyncOptions>) {
this.restoreOptions = {
...this.restoreOptions,
@@ -78,26 +235,88 @@ export class Backups<M extends T.SDKManifest> 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<Manifest>('main')
* .setPreBackup(async (effects) => {
* // Flush database to disk
* await db.checkpoint()
* })
* ```
*/
setPreBackup(fn: (effects: BackupEffects) => Promise<void>) {
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<void>) {
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<void>) {
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<Manifest>('main')
* .setPostRestore(async (effects) => {
* // Rebuild search indexes after restore
* await rebuildIndexes()
* })
* ```
*/
setPostRestore(fn: (effects: BackupEffects) => Promise<void>) {
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<Manifest>({ exclude: ['*.tmp'] })
* .addVolume('main')
* .addVolume('logs', { backupOptions: { exclude: ['*.log'] } })
* ```
*/
addVolume(
volume: M["volumes"][number],
options?: Partial<{
@@ -113,11 +332,30 @@ export class Backups<M extends T.SDKManifest> 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<M["volumes"][0]>) {
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<M extends T.SDKManifest> 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<void> {
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<M extends T.SDKManifest> 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

View File

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

View File

@@ -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<T.NamedHealthCheckResult, "name">

View File

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

View File

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

View File

@@ -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<never>((resolve, reject) =>
setTimeout(() => reject(new Error(message)), ms),

View File

@@ -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 <Manifest extends SDKManifest>(
runCommand: string[],

View File

@@ -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> | 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> | 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<Ids extends string, Id extends string> = {
type ErrorDuplicateId<Id extends string> = `The id '${Id}' is already used`
/** @internal Helper to create a CommandController */
export const runCommand = <Manifest extends T.SDKManifest>() =>
CommandController.of<Manifest, SubContainer<Manifest>>()
/**
* 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<Manifest extends T.SDKManifest, Ids extends string>
implements T.DaemonBuildable

View File

@@ -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<Manifest>()
* .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<Manifest extends T.SDKManifest> = {
/** 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<Manifest extends T.SDKManifest> = {
/** 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<Manifest>()
* .mountVolume({
* volumeId: 'main',
* mountpoint: '/data',
* readonly: false,
* subpath: null
* })
* .build()
*
* // Complex setup with multiple mount types
* const mounts = Mounts.of<Manifest>()
* .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<BitcoinManifest>({
* 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<Manifest>[],
/** @internal Accumulated asset mount configurations */
readonly assets: SharedOptions[],
/** @internal Accumulated dependency mount configurations */
readonly dependencies: DependencyOpts<T.SDKManifest>[],
/** @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<MyManifest>()
* .mountVolume({ ... })
* .build()
* ```
*/
static of<Manifest extends T.SDKManifest>() {
return new Mounts<Manifest>([], [], [], [])
}
/**
* 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<Manifest>) {
return new Mounts<Manifest, Backups>(
[...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<Manifest, Backups>(
[...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<typeof bitcoinManifest>({
* dependencyId: 'bitcoind',
* volumeId: 'data',
* mountpoint: '/bitcoin',
* readonly: true, // Usually read-only for safety
* subpath: null
* })
* ```
*/
mountDependency<DependencyManifest extends T.SDKManifest>(
options: DependencyOpts<DependencyManifest>,
) {
@@ -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<Manifest>()
* .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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<unknown, unknown, never>

View File

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

View File

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

View File

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