misc sdk changes (#2934)

* misc sdk changes

* delete the store ☠️

* port comments

* fix build

* fix removing

* fix tests

* beta.20

---------

Co-authored-by: Matt Hill <mattnine@protonmail.com>
This commit is contained in:
Aiden McClelland
2025-05-09 15:10:51 -06:00
committed by GitHub
parent d2c4741f0b
commit 7750e33f82
62 changed files with 1255 additions and 2130 deletions

View File

@@ -1,18 +1,5 @@
import { Value } from "../../base/lib/actions/input/builder/value"
import {
InputSpec,
ExtractInputSpecType,
LazyBuild,
} from "../../base/lib/actions/input/builder/inputSpec"
import {
DefaultString,
ListValueSpecText,
Pattern,
RandomString,
UniqueBy,
ValueSpecDatetime,
ValueSpecText,
} from "../../base/lib/actions/input/inputSpecTypes"
import { InputSpec } from "../../base/lib/actions/input/builder/inputSpec"
import { Variants } from "../../base/lib/actions/input/builder/variants"
import { Action, Actions } from "../../base/lib/actions/setupActions"
import {
@@ -25,17 +12,12 @@ import {
import * as patterns from "../../base/lib/util/patterns"
import { BackupSync, Backups } from "./backup/Backups"
import { smtpInputSpec } from "../../base/lib/actions/input/inputSpecConstants"
import { CommandController, Daemon, Daemons } from "./mainFn/Daemons"
import { Daemon, Daemons } from "./mainFn/Daemons"
import { HealthCheck } from "./health/HealthCheck"
import { checkPortListening } from "./health/checkFns/checkPortListening"
import { checkWebUrl, runHealthScript } from "./health/checkFns"
import { List } from "../../base/lib/actions/input/builder/list"
import {
Install,
InstallFn,
PostInstall,
PreInstall,
} from "./inits/setupInstall"
import { InstallFn, PostInstall, PreInstall } from "./inits/setupInstall"
import { SetupBackupsParams, setupBackups } from "./backup/setupBackups"
import { UninstallFn, setupUninstall } from "./inits/setupUninstall"
import { setupMain } from "./mainFn"
@@ -51,24 +33,12 @@ import { ServiceInterfaceBuilder } from "../../base/lib/interfaces/ServiceInterf
import { GetSystemSmtp } from "./util"
import { nullIfEmpty } from "./util"
import { getServiceInterface, getServiceInterfaces } from "./util"
import { getStore } from "./store/getStore"
import {
CommandOptions,
ExitError,
MountOptions,
SubContainer,
} from "./util/SubContainer"
import { CommandOptions, ExitError, SubContainer } from "./util/SubContainer"
import { splitCommand } from "./util"
import { Mounts } from "./mainFn/Mounts"
import { setupDependencies } from "../../base/lib/dependencies/setupDependencies"
import * as T from "../../base/lib/types"
import { testTypeVersion } from "../../base/lib/exver"
import { ExposedStorePaths } from "./store/setupExposeStore"
import {
PathBuilder,
extractJsonPath,
pathBuilder,
} from "../../base/lib/util/PathBuilder"
import {
CheckDependencies,
checkDependencies,
@@ -91,19 +61,16 @@ type AnyNeverCond<T extends any[], Then, Else> =
T extends [any, ...infer U] ? AnyNeverCond<U,Then, Else> :
never
export class StartSdk<Manifest extends T.SDKManifest, Store> {
export class StartSdk<Manifest extends T.SDKManifest> {
private constructor(readonly manifest: Manifest) {}
static of() {
return new StartSdk<never, never>(null as never)
return new StartSdk<never>(null as never)
}
withManifest<Manifest extends T.SDKManifest = never>(manifest: Manifest) {
return new StartSdk<Manifest, Store>(manifest)
}
withStore<Store extends Record<string, any>>() {
return new StartSdk<Manifest, Store>(this.manifest)
return new StartSdk<Manifest>(manifest)
}
build(isReady: AnyNeverCond<[Manifest, Store], "Build not ready", true>) {
build(isReady: AnyNeverCond<[Manifest], "Build not ready", true>) {
type NestedEffects = "subcontainer" | "store" | "action"
type InterfaceEffects =
| "getServiceInterface"
@@ -136,8 +103,6 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
mount: (effects, ...args) => effects.mount(...args),
getInstalledPackages: (effects, ...args) =>
effects.getInstalledPackages(...args),
exposeForDependents: (effects, ...args) =>
effects.exposeForDependents(...args),
getServicePortForward: (effects, ...args) =>
effects.getServicePortForward(...args),
clearBindings: (effects, ...args) => effects.clearBindings(...args),
@@ -155,7 +120,7 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
...startSdkEffectWrapper,
action: {
run: actions.runAction,
request: <T extends Action<T.ActionId, any, any>>(
request: <T extends Action<T.ActionId, any>>(
effects: T.Effects,
packageId: T.PackageId,
action: T,
@@ -169,7 +134,7 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
severity,
options: options,
}),
requestOwn: <T extends Action<T.ActionId, Store, any>>(
requestOwn: <T extends Action<T.ActionId, any>>(
effects: T.Effects,
action: T,
severity: T.ActionSeverity,
@@ -268,29 +233,6 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
},
}
},
store: {
get: <E extends Effects, StoreValue = unknown>(
effects: E,
packageId: string,
path: PathBuilder<Store, StoreValue>,
) =>
getStore<Store, StoreValue>(effects, path, {
packageId,
}),
getOwn: <E extends Effects, StoreValue = unknown>(
effects: E,
path: PathBuilder<Store, StoreValue>,
) => getStore<Store, StoreValue>(effects, path),
setOwn: <E extends Effects, Path extends PathBuilder<Store, unknown>>(
effects: E,
path: Path,
value: Path extends PathBuilder<Store, infer Value> ? Value : never,
) =>
effects.store.set<Store>({
value,
path: extractJsonPath(path),
}),
},
MultiHost: {
of: (effects: Effects, id: string) => new MultiHost({ id, effects }),
@@ -362,10 +304,7 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
*/
withInput: <
Id extends T.ActionId,
InputSpecType extends
| Record<string, any>
| InputSpec<any, any>
| InputSpec<any, never>,
InputSpecType extends Record<string, any> | InputSpec<any>,
>(
id: Id,
metadata: MaybeFn<Omit<T.ActionMetadata, "hasInput">>,
@@ -382,6 +321,7 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
* In this example, we create an action that returns a secret phrase for the user to see.
*
* ```
import { store } from '../file-models/store.json'
import { sdk } from '../sdk'
export const showSecretPhrase = sdk.Action.withoutInput(
@@ -406,9 +346,7 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
'Below is your secret phrase. Use it to gain access to extraordinary places',
result: {
type: 'single',
value: await sdk.store
.getOwn(effects, sdk.StorePath.secretPhrase)
.const(),
value: (await store.read.once())?.secretPhrase,
copyable: true,
qr: true,
masked: true,
@@ -499,7 +437,7 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
export const actions = sdk.Actions.of().addAction(config).addAction(nameToLogs)
* ```
*/
Actions: Actions<Store, {}>,
Actions: Actions<{}>,
/**
* @description Use this function to determine which volumes are backed up when a user creates a backup, including advanced options.
* @example
@@ -530,11 +468,11 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
/**
* @description Use this function to set dependency information.
* @example
* In this example, we create a perpetual dependency on Hello World >=1.0.0:0, where Hello World must be running and passing its "primary" health check.
* In this example, we create a dependency on Hello World >=1.0.0:0, where Hello World must be running and passing its "primary" health check.
*
* ```
export const setDependencies = sdk.setupDependencies(
async ({ effects, input }) => {
async ({ effects }) => {
return {
'hello-world': {
kind: 'running',
@@ -545,29 +483,9 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
},
)
* ```
* @example
* In this example, we create a conditional dependency on Hello World based on a hypothetical "needsWorld" boolean in our Store.
* Using .const() ensures that if the "needsWorld" boolean changes, setupDependencies will re-run.
*
* ```
export const setDependencies = sdk.setupDependencies(
async ({ effects }) => {
if (sdk.store.getOwn(sdk.StorePath.needsWorld).const()) {
return {
'hello-world': {
kind: 'running',
versionRange: '>=1.0.0',
healthChecks: ['primary'],
},
}
}
return {}
},
)
* ```
*/
setupDependencies: setupDependencies<Manifest>,
setupInit: setupInit<Manifest, Store>,
setupInit: setupInit<Manifest>,
/**
* @description Use this function to execute arbitrary logic *once*, on initial install *before* interfaces, actions, and dependencies are updated.
* @example
@@ -579,26 +497,21 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
})
* ```
*/
setupPreInstall: (fn: InstallFn<Manifest, Store>) => PreInstall.of(fn),
setupPreInstall: (fn: InstallFn<Manifest>) => PreInstall.of(fn),
/**
* @description Use this function to execute arbitrary logic *once*, on initial install *after* interfaces, actions, and dependencies are updated.
* @example
* In the this example, we bootstrap our Store with a random, 16-char admin password.
* In the this example, we create a task for the user to perform.
*
* ```
const postInstall = sdk.setupPostInstall(async ({ effects }) => {
await sdk.store.setOwn(
effects,
sdk.StorePath.adminPassword,
utils.getDefaultString({
charset: 'a-z,A-Z,1-9,!,@,$,%,&,',
len: 16,
}),
)
await sdk.action.requestOwn(effects, showSecretPhrase, 'important', {
reason: 'Check out your secret phrase!',
})
})
* ```
*/
setupPostInstall: (fn: InstallFn<Manifest, Store>) => PostInstall.of(fn),
setupPostInstall: (fn: InstallFn<Manifest>) => PostInstall.of(fn),
/**
* @description Use this function to determine how this service will be hosted and served. The function executes on service install, service update, and inputSpec save.
* @param inputSpec - The inputSpec spec of this service as exported from /inputSpec/spec.
@@ -673,12 +586,12 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
effects: Effects
started(onTerm: () => PromiseLike<void>): PromiseLike<null>
}) => Promise<Daemons<Manifest, any>>,
) => setupMain<Manifest, Store>(fn),
) => setupMain<Manifest>(fn),
/**
* Use this function to execute arbitrary logic *once*, on uninstall only. Most services will not use this.
*/
setupUninstall: (fn: UninstallFn<Manifest, Store>) =>
setupUninstall<Manifest, Store>(fn),
setupUninstall: (fn: UninstallFn<Manifest>) =>
setupUninstall<Manifest>(fn),
trigger: {
defaultTrigger,
cooldownTrigger,
@@ -728,11 +641,8 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
})
* ```
*/
of: <
Spec extends Record<string, Value<any, Store> | Value<any, never>>,
>(
spec: Spec,
) => InputSpec.of<Spec, Store>(spec),
of: <Spec extends Record<string, Value<any>>>(spec: Spec) =>
InputSpec.of<Spec>(spec),
},
Daemon: {
get of() {
@@ -787,372 +697,9 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
return SubContainer.withTemp(effects, image, mounts, name, fn)
},
},
List: {
/**
* @description Create a list of text inputs.
* @param a - attributes of the list itself.
* @param aSpec - attributes describing each member of the list.
*/
text: List.text,
/**
* @description Create a list of objects.
* @param a - attributes of the list itself.
* @param aSpec - attributes describing each member of the list.
*/
obj: <Type extends Record<string, any>>(
a: Parameters<typeof List.obj<Type, Store>>[0],
aSpec: Parameters<typeof List.obj<Type, Store>>[1],
) => List.obj<Type, Store>(a, aSpec),
/**
* @description Create a list of dynamic text inputs.
* @param a - attributes of the list itself.
* @param aSpec - attributes describing each member of the list.
*/
dynamicText: List.dynamicText<Store>,
},
StorePath: pathBuilder<Store>(),
Value: {
/**
* @description Displays a boolean toggle to enable/disable
* @example
* ```
toggleExample: Value.toggle({
// required
name: 'Toggle Example',
default: true,
// optional
description: null,
warning: null,
immutable: false,
}),
* ```
*/
toggle: Value.toggle,
/**
* @description Displays a text input field
* @example
* ```
textExample: Value.text({
// required
name: 'Text Example',
required: false,
default: null,
// optional
description: null,
placeholder: null,
warning: null,
generate: null,
inputmode: 'text',
masked: false,
minLength: null,
maxLength: null,
patterns: [],
immutable: false,
}),
* ```
*/
text: Value.text,
/**
* @description Displays a large textarea field for long form entry.
* @example
* ```
textareaExample: Value.textarea({
// required
name: 'Textarea Example',
required: false,
default: null,
// optional
description: null,
placeholder: null,
warning: null,
minLength: null,
maxLength: null,
immutable: false,
}),
* ```
*/
textarea: Value.textarea,
/**
* @description Displays a number input field
* @example
* ```
numberExample: Value.number({
// required
name: 'Number Example',
required: false,
default: null,
integer: true,
// optional
description: null,
placeholder: null,
warning: null,
min: null,
max: null,
immutable: false,
step: null,
units: null,
}),
* ```
*/
number: Value.number,
/**
* @description Displays a browser-native color selector.
* @example
* ```
colorExample: Value.color({
// required
name: 'Color Example',
required: false,
default: null,
// optional
description: null,
warning: null,
immutable: false,
}),
* ```
*/
color: Value.color,
/**
* @description Displays a browser-native date/time selector.
* @example
* ```
datetimeExample: Value.datetime({
// required
name: 'Datetime Example',
required: false,
default: null,
// optional
description: null,
warning: null,
immutable: false,
inputmode: 'datetime-local',
min: null,
max: null,
}),
* ```
*/
datetime: Value.datetime,
/**
* @description Displays a select modal with radio buttons, allowing for a single selection.
* @example
* ```
selectExample: Value.select({
// required
name: 'Select Example',
default: 'radio1',
values: {
radio1: 'Radio 1',
radio2: 'Radio 2',
},
// optional
description: null,
warning: null,
immutable: false,
disabled: false,
}),
* ```
*/
select: Value.select,
/**
* @description Displays a select modal with checkboxes, allowing for multiple selections.
* @example
* ```
multiselectExample: Value.multiselect({
// required
name: 'Multiselect Example',
values: {
option1: 'Option 1',
option2: 'Option 2',
},
default: [],
// optional
description: null,
warning: null,
immutable: false,
disabled: false,
minlength: null,
maxLength: null,
}),
* ```
*/
multiselect: Value.multiselect,
/**
* @description Display a collapsable grouping of additional fields, a "sub form". The second value is the inputSpec spec for the sub form.
* @example
* ```
objectExample: Value.object(
{
// required
name: 'Object Example',
// optional
description: null,
warning: null,
},
InputSpec.of({}),
),
* ```
*/
object: Value.object,
/**
* @description Displays a dropdown, allowing for a single selection. Depending on the selection, a different object ("sub form") is presented.
* @example
* ```
unionExample: Value.union(
{
// required
name: 'Union Example',
default: 'option1',
// optional
description: null,
warning: null,
disabled: false,
immutable: false,
},
Variants.of({
option1: {
name: 'Option 1',
spec: InputSpec.of({}),
},
option2: {
name: 'Option 2',
spec: InputSpec.of({}),
},
}),
),
* ```
*/
union: Value.union,
/**
* @description Presents an interface to add/remove/edit items in a list.
* @example
* In this example, we create a list of text inputs.
*
* ```
listExampleText: Value.list(
List.text(
{
// required
name: 'Text List',
// optional
description: null,
warning: null,
default: [],
minLength: null,
maxLength: null,
},
{
// required
patterns: [],
// optional
placeholder: null,
generate: null,
inputmode: 'url',
masked: false,
minLength: null,
maxLength: null,
},
),
),
* ```
* @example
* In this example, we create a list of objects.
*
* ```
listExampleObject: Value.list(
List.obj(
{
// required
name: 'Object List',
// optional
description: null,
warning: null,
default: [],
minLength: null,
maxLength: null,
},
{
// required
spec: InputSpec.of({}),
// optional
displayAs: null,
uniqueBy: null,
},
),
),
* ```
*/
list: Value.list,
hidden: Value.hidden,
dynamicToggle: Value.dynamicToggle<Store>,
dynamicText: Value.dynamicText<Store>,
dynamicTextarea: Value.dynamicTextarea<Store>,
dynamicNumber: Value.dynamicNumber<Store>,
dynamicColor: Value.dynamicColor<Store>,
dynamicDatetime: Value.dynamicDatetime<Store>,
dynamicSelect: Value.dynamicSelect<Store>,
dynamicMultiselect: Value.dynamicMultiselect<Store>,
filteredUnion: <
VariantValues extends {
[K in string]: {
name: string
spec: InputSpec<any, Store> | InputSpec<any, never>
}
},
>(
getDisabledFn: Parameters<
typeof Value.filteredUnion<VariantValues, Store>
>[0],
a: Parameters<typeof Value.filteredUnion<VariantValues, Store>>[1],
aVariants: Parameters<
typeof Value.filteredUnion<VariantValues, Store>
>[2],
) =>
Value.filteredUnion<VariantValues, Store>(
getDisabledFn,
a,
aVariants,
),
dynamicUnion: <
VariantValues extends {
[K in string]: {
name: string
spec: InputSpec<any, Store> | InputSpec<any, never>
}
},
>(
getA: Parameters<typeof Value.dynamicUnion<VariantValues, Store>>[0],
aVariants: Parameters<
typeof Value.dynamicUnion<VariantValues, Store>
>[1],
) => Value.dynamicUnion<VariantValues, Store>(getA, aVariants),
},
Variants: {
of: <
VariantValues extends {
[K in string]: {
name: string
spec: InputSpec<any, Store>
}
},
>(
a: VariantValues,
) => Variants.of<VariantValues, Store>(a),
},
List,
Value,
Variants,
}
}
}

View File

@@ -1,7 +1,7 @@
import * as T from "../../../base/lib/types"
import * as child_process from "child_process"
import * as fs from "fs/promises"
import { Affine, asError, StorePath } from "../util"
import { Affine, asError } from "../util"
export const DEFAULT_OPTIONS: T.SyncOptions = {
delete: true,
@@ -96,7 +96,7 @@ export class Backups<M extends T.SDKManifest> {
return this
}
addVolume(
mountVolume(
volume: M["volumes"][number],
options?: Partial<{
options: T.SyncOptions
@@ -133,11 +133,7 @@ export class Backups<M extends T.SDKManifest> {
})
await rsyncResults.wait()
}
await fs.writeFile(
"/media/startos/backup/store.json",
JSON.stringify(await effects.store.get({ path: "" as StorePath })),
{ encoding: "utf-8" },
)
const dataVersion = await effects.getDataVersion()
if (dataVersion)
await fs.writeFile("/media/startos/backup/dataVersion.txt", dataVersion, {
@@ -149,16 +145,7 @@ export class Backups<M extends T.SDKManifest> {
async restoreBackup(effects: T.Effects) {
this.preRestore(effects as BackupEffects)
const store = await fs
.readFile("/media/startos/backup/store.json", {
encoding: "utf-8",
})
.catch((_) => null)
if (store)
await effects.store.set({
path: "" as StorePath,
value: JSON.parse(store),
})
for (const item of this.backupSet) {
const rsyncResults = await runRsync({
srcPath: item.backupPath,

View File

@@ -30,7 +30,7 @@ export class HealthCheck extends Drop {
super()
this.promise = Promise.resolve().then(async () => {
const getCurrentValue = () => this.currentValue
const gracePeriod = o.gracePeriod ?? 5000
const gracePeriod = o.gracePeriod ?? 10_000
const trigger = (o.trigger ?? defaultTrigger)(getCurrentValue)
const triggerFirstSuccess = once(() =>
Promise.resolve(

View File

@@ -29,8 +29,6 @@ export { SubContainer } from "./util/SubContainer"
export { StartSdk } from "./StartSdk"
export { setupManifest, buildManifest } from "./manifest/setupManifest"
export { FileHelper } from "./util/fileHelper"
export { setupExposeStore } from "./store/setupExposeStore"
export { pathBuilder } from "../../base/lib/util/PathBuilder"
export * as actions from "../../base/lib/actions"
export * as backup from "./backup"

View File

@@ -1,25 +1,21 @@
import { Actions } from "../../../base/lib/actions/setupActions"
import { ExtendedVersion } from "../../../base/lib/exver"
import { UpdateServiceInterfaces } from "../../../base/lib/interfaces/setupInterfaces"
import { ExposedStorePaths } from "../../../base/lib/types"
import * as T from "../../../base/lib/types"
import { StorePath } from "../util"
import { VersionGraph } from "../version/VersionGraph"
import { PostInstall, PreInstall } from "./setupInstall"
import { Uninstall } from "./setupUninstall"
export function setupInit<Manifest extends T.SDKManifest, Store>(
export function setupInit<Manifest extends T.SDKManifest>(
versions: VersionGraph<string>,
preInstall: PreInstall<Manifest, Store>,
postInstall: PostInstall<Manifest, Store>,
uninstall: Uninstall<Manifest, Store>,
preInstall: PreInstall<Manifest>,
postInstall: PostInstall<Manifest>,
uninstall: Uninstall<Manifest>,
setServiceInterfaces: UpdateServiceInterfaces<any>,
setDependencies: (options: {
effects: T.Effects
}) => Promise<null | void | undefined>,
actions: Actions<Store, any>,
initStore: Store,
exposedStore: ExposedStorePaths,
actions: Actions<any>,
): {
packageInit: T.ExpectedExports.packageInit
packageUninit: T.ExpectedExports.packageUninit
@@ -58,17 +54,12 @@ export function setupInit<Manifest extends T.SDKManifest, Store>(
containerInit: async (opts) => {
const prev = await opts.effects.getDataVersion()
if (!prev) {
await opts.effects.store.set({
path: "" as StorePath,
value: initStore,
})
await preInstall.preInstall(opts)
}
await setServiceInterfaces({
...opts,
})
await actions.update({ effects: opts.effects })
await opts.effects.exposeForDependents({ paths: exposedStore })
await setDependencies({ effects: opts.effects })
},
}

View File

@@ -1,22 +1,19 @@
import * as T from "../../../base/lib/types"
export type InstallFn<Manifest extends T.SDKManifest, Store> = (opts: {
export type InstallFn<Manifest extends T.SDKManifest> = (opts: {
effects: T.Effects
}) => Promise<null | void | undefined>
export class Install<Manifest extends T.SDKManifest, Store> {
protected constructor(readonly fn: InstallFn<Manifest, Store>) {}
export class Install<Manifest extends T.SDKManifest> {
protected constructor(readonly fn: InstallFn<Manifest>) {}
}
export class PreInstall<Manifest extends T.SDKManifest, Store> extends Install<
Manifest,
Store
> {
private constructor(fn: InstallFn<Manifest, Store>) {
export class PreInstall<
Manifest extends T.SDKManifest,
> extends Install<Manifest> {
private constructor(fn: InstallFn<Manifest>) {
super(fn)
}
static of<Manifest extends T.SDKManifest, Store>(
fn: InstallFn<Manifest, Store>,
) {
static of<Manifest extends T.SDKManifest>(fn: InstallFn<Manifest>) {
return new PreInstall(fn)
}
@@ -27,22 +24,19 @@ export class PreInstall<Manifest extends T.SDKManifest, Store> extends Install<
}
}
export function setupPreInstall<Manifest extends T.SDKManifest, Store>(
fn: InstallFn<Manifest, Store>,
export function setupPreInstall<Manifest extends T.SDKManifest>(
fn: InstallFn<Manifest>,
) {
return PreInstall.of(fn)
}
export class PostInstall<Manifest extends T.SDKManifest, Store> extends Install<
Manifest,
Store
> {
private constructor(fn: InstallFn<Manifest, Store>) {
export class PostInstall<
Manifest extends T.SDKManifest,
> extends Install<Manifest> {
private constructor(fn: InstallFn<Manifest>) {
super(fn)
}
static of<Manifest extends T.SDKManifest, Store>(
fn: InstallFn<Manifest, Store>,
) {
static of<Manifest extends T.SDKManifest>(fn: InstallFn<Manifest>) {
return new PostInstall(fn)
}
@@ -53,8 +47,8 @@ export class PostInstall<Manifest extends T.SDKManifest, Store> extends Install<
}
}
export function setupPostInstall<Manifest extends T.SDKManifest, Store>(
fn: InstallFn<Manifest, Store>,
export function setupPostInstall<Manifest extends T.SDKManifest>(
fn: InstallFn<Manifest>,
) {
return PostInstall.of(fn)
}

View File

@@ -1,13 +1,11 @@
import * as T from "../../../base/lib/types"
export type UninstallFn<Manifest extends T.SDKManifest, Store> = (opts: {
export type UninstallFn<Manifest extends T.SDKManifest> = (opts: {
effects: T.Effects
}) => Promise<null | void | undefined>
export class Uninstall<Manifest extends T.SDKManifest, Store> {
private constructor(readonly fn: UninstallFn<Manifest, Store>) {}
static of<Manifest extends T.SDKManifest, Store>(
fn: UninstallFn<Manifest, Store>,
) {
export class Uninstall<Manifest extends T.SDKManifest> {
private constructor(readonly fn: UninstallFn<Manifest>) {}
static of<Manifest extends T.SDKManifest>(fn: UninstallFn<Manifest>) {
return new Uninstall(fn)
}
@@ -22,8 +20,8 @@ export class Uninstall<Manifest extends T.SDKManifest, Store> {
}
}
export function setupUninstall<Manifest extends T.SDKManifest, Store>(
fn: UninstallFn<Manifest, Store>,
export function setupUninstall<Manifest extends T.SDKManifest>(
fn: UninstallFn<Manifest>,
) {
return Uninstall.of(fn)
}

View File

@@ -56,6 +56,8 @@ type NewDaemonParams<Manifest extends T.SDKManifest> = {
subcontainer: SubContainer<Manifest>
runAsInit?: boolean
env?: Record<string, string>
cwd?: string
user?: string
sigtermTimeout?: number
onStdout?: (chunk: Buffer | string | any) => void
onStderr?: (chunk: Buffer | string | any) => void

View File

@@ -8,8 +8,12 @@ type SharedOptions = {
subpath: string | null
/** Where to mount the resource. e.g. /data */
mountpoint: string
/** Whether to mount this as a file or directory */
type?: "file" | "directory"
/**
* Whether to mount this as a file or directory
*
* defaults to "directory"
* */
type?: "file" | "directory" | "infer"
}
type VolumeOpts<Manifest extends T.SDKManifest> = {
@@ -43,7 +47,7 @@ export class Mounts<
return new Mounts<Manifest>([], [], [], [])
}
addVolume(options: VolumeOpts<Manifest>) {
mountVolume(options: VolumeOpts<Manifest>) {
return new Mounts<Manifest, Backups>(
[...this.volumes, options],
[...this.assets],
@@ -52,7 +56,7 @@ export class Mounts<
)
}
addAssets(options: SharedOptions) {
mountAssets(options: SharedOptions) {
return new Mounts<Manifest, Backups>(
[...this.volumes],
[...this.assets, options],
@@ -61,7 +65,7 @@ export class Mounts<
)
}
addDependency<DependencyManifest extends T.SDKManifest>(
mountDependency<DependencyManifest extends T.SDKManifest>(
options: DependencyOpts<DependencyManifest>,
) {
return new Mounts<Manifest, Backups>(
@@ -72,7 +76,7 @@ export class Mounts<
)
}
addBackups(options: SharedOptions) {
mountBackups(options: SharedOptions) {
return new Mounts<
Manifest,
{
@@ -109,7 +113,7 @@ export class Mounts<
volumeId: v.volumeId,
subpath: v.subpath,
readonly: v.readonly,
filetype: v.type,
filetype: v.type ?? "directory",
},
})),
)
@@ -119,7 +123,7 @@ export class Mounts<
options: {
type: "assets",
subpath: a.subpath,
filetype: a.type,
filetype: a.type ?? "directory",
},
})),
)
@@ -132,13 +136,13 @@ export class Mounts<
volumeId: d.volumeId,
subpath: d.subpath,
readonly: d.readonly,
filetype: d.type,
filetype: d.type ?? "directory",
},
})),
)
}
}
const a = Mounts.of().addBackups({ subpath: null, mountpoint: "" })
const a = Mounts.of().mountBackups({ subpath: null, mountpoint: "" })
// @ts-expect-error
const m: Mounts<T.SDKManifest, never> = a

View File

@@ -14,7 +14,7 @@ export const DEFAULT_SIGTERM_TIMEOUT = 30_000
* @param fn
* @returns
*/
export const setupMain = <Manifest extends T.SDKManifest, Store>(
export const setupMain = <Manifest extends T.SDKManifest>(
fn: (o: {
effects: T.Effects
started(onTerm: () => PromiseLike<void>): PromiseLike<null>

View File

@@ -1,95 +0,0 @@
import { Effects } from "../../../base/lib/Effects"
import { PathBuilder, extractJsonPath } from "../util"
export class GetStore<Store, StoreValue> {
constructor(
readonly effects: Effects,
readonly path: PathBuilder<Store, StoreValue>,
readonly options: {
/** Defaults to what ever the package currently in */
packageId?: string | undefined
} = {},
) {}
/**
* Returns the value of Store at the provided path. Reruns the context from which it has been called if the underlying value changes
*/
const() {
return this.effects.store.get<Store, StoreValue>({
...this.options,
path: extractJsonPath(this.path),
callback:
this.effects.constRetry &&
(() => this.effects.constRetry && this.effects.constRetry()),
})
}
/**
* Returns the value of Store at the provided path. Does nothing if the value changes
*/
once() {
return this.effects.store.get<Store, StoreValue>({
...this.options,
path: extractJsonPath(this.path),
})
}
/**
* Watches the value of Store at the provided path. Returns an async iterator that yields whenever the value changes
*/
async *watch() {
const resolveCell = { resolve: () => {} }
this.effects.onLeaveContext(() => {
resolveCell.resolve()
})
while (this.effects.isInContext) {
let callback: () => void = () => {}
const waitForNext = new Promise<void>((resolve) => {
callback = resolve
resolveCell.resolve = resolve
})
yield await this.effects.store.get<Store, StoreValue>({
...this.options,
path: extractJsonPath(this.path),
callback: () => callback(),
})
await waitForNext
}
}
/**
* Watches the value of Store at the provided path. Takes a custom callback function to run whenever the value changes
*/
onChange(
callback: (value: StoreValue | null, error?: Error) => void | Promise<void>,
) {
;(async () => {
for await (const value of this.watch()) {
try {
await callback(value)
} catch (e) {
console.error(
"callback function threw an error @ GetStore.onChange",
e,
)
}
}
})()
.catch((e) => callback(null, e))
.catch((e) =>
console.error(
"callback function threw an error @ GetStore.onChange",
e,
),
)
}
}
export function getStore<Store, StoreValue>(
effects: Effects,
path: PathBuilder<Store, StoreValue>,
options: {
/** Defaults to what ever the package currently in */
packageId?: string | undefined
} = {},
) {
return new GetStore<Store, StoreValue>(effects, path, options)
}

View File

@@ -1,27 +0,0 @@
import { ExposedStorePaths } from "../../../base/lib/types"
import {
PathBuilder,
extractJsonPath,
pathBuilder,
} from "../../../base/lib/util/PathBuilder"
/**
* @description Use this function to determine which Store values to expose and make available to other services running on StartOS. Store values not exposed here will be kept private. Use the type safe pathBuilder to traverse your Store's structure.
* @example
* In this example, we expose the hypothetical Store values "adminPassword" and "nameLastUpdatedAt".
*
* ```
export const exposedStore = setupExposeStore<Store>((pathBuilder) => [
pathBuilder.adminPassword
pathBuilder.nameLastUpdatedAt,
])
* ```
*/
export const setupExposeStore = <Store extends Record<string, any>>(
fn: (pathBuilder: PathBuilder<Store>) => PathBuilder<Store, any>[],
) => {
return fn(pathBuilder<Store>()).map(
(x) => extractJsonPath(x) as string,
) as ExposedStorePaths
}
export { ExposedStorePaths }

View File

@@ -421,25 +421,16 @@ describe("values", () => {
},
}),
)
.withStore<{ test: "a" }>()
.build(true)
const value = Value.dynamicDatetime<{ test: "a" }>(
async ({ effects }) => {
;async () => {
;(await sdk.store
.getOwn(effects, sdk.StorePath.test)
.once()) satisfies "a"
}
return {
name: "Testing",
required: true,
default: null,
inputmode: "date",
}
},
)
const value = Value.dynamicDatetime(async ({ effects }) => {
return {
name: "Testing",
required: true,
default: null,
inputmode: "date",
}
})
const validator = value.validator
validator.unsafeCast("2021-01-01")
validator.unsafeCast(null)

View File

@@ -1,4 +1,3 @@
import { CurrentDependenciesResult } from "../../../base/lib/dependencies/setupDependencies"
import { StartSdk } from "../StartSdk"
import { setupManifest } from "../manifest/setupManifest"
import { VersionGraph } from "../version/VersionGraph"
@@ -49,5 +48,4 @@ export const sdk = StartSdk.of()
},
}),
)
.withStore<{ storeRoot: { storeLeaf: "value" } }>()
.build(true)

View File

@@ -1,111 +0,0 @@
import { Effects } from "../../../base/lib/types"
import { extractJsonPath } from "../../../base/lib/util/PathBuilder"
import { StartSdk } from "../StartSdk"
type Store = {
inputSpec: {
someValue: "a" | "b"
}
}
type Manifest = any
const todo = <A>(): A => {
throw new Error("not implemented")
}
const noop = () => {}
const sdk = StartSdk.of()
.withManifest({} as Manifest)
.withStore<Store>()
.build(true)
const storePath = sdk.StorePath
describe("Store", () => {
test("types", async () => {
;async () => {
sdk.store.setOwn(todo<Effects>(), storePath.inputSpec, {
someValue: "a",
})
sdk.store.setOwn(todo<Effects>(), storePath.inputSpec.someValue, "b")
sdk.store.setOwn(todo<Effects>(), storePath, {
inputSpec: { someValue: "b" },
})
sdk.store.setOwn(
todo<Effects>(),
storePath.inputSpec.someValue,
// @ts-expect-error Type is wrong for the setting value
5,
)
sdk.store.setOwn(
todo<Effects>(),
// @ts-expect-error Path is wrong
"/inputSpec/someVae3lue",
"someValue",
)
todo<Effects>().store.set<Store>({
path: extractJsonPath(storePath.inputSpec.someValue),
value: "b",
})
todo<Effects>().store.set<Store, "/inputSpec/some2Value">({
path: extractJsonPath(storePath.inputSpec.someValue),
//@ts-expect-error Path is wrong
value: "someValueIn",
})
;(await sdk.store
.getOwn(todo<Effects>(), storePath.inputSpec.someValue)
.const()) satisfies string
;(await sdk.store
.getOwn(todo<Effects>(), storePath.inputSpec)
.const()) satisfies Store["inputSpec"]
await sdk.store // @ts-expect-error Path is wrong
.getOwn(todo<Effects>(), "/inputSpec/somdsfeValue")
.const()
/// ----------------- ERRORS -----------------
sdk.store.setOwn(todo<Effects>(), storePath, {
// @ts-expect-error Type is wrong for the setting value
inputSpec: { someValue: "notInAOrB" },
})
sdk.store.setOwn(
todo<Effects>(),
sdk.StorePath.inputSpec.someValue,
// @ts-expect-error Type is wrong for the setting value
"notInAOrB",
)
;(await sdk.store
.getOwn(todo<Effects>(), storePath.inputSpec.someValue)
.const()) satisfies string
;(await sdk.store
.getOwn(todo<Effects>(), storePath.inputSpec)
.const()) satisfies Store["inputSpec"]
await sdk.store // @ts-expect-error Path is wrong
.getOwn("/inputSpec/somdsfeValue")
.const()
///
;(await sdk.store
.getOwn(todo<Effects>(), storePath.inputSpec.someValue)
// @ts-expect-error satisfies type is wrong
.const()) satisfies number
await sdk.store // @ts-expect-error Path is wrong
.getOwn(todo<Effects>(), extractJsonPath(storePath.inputSpec))
.const()
;(await todo<Effects>().store.get({
path: extractJsonPath(storePath.inputSpec.someValue),
callback: noop,
})) satisfies string
await todo<Effects>().store.get<Store, "/inputSpec/someValue">({
// @ts-expect-error Path is wrong as in it doesn't match above
path: "/inputSpec/someV2alue",
callback: noop,
})
await todo<Effects>().store.get<Store, "/inputSpec/someV2alue">({
// @ts-expect-error Path is wrong as in it doesn't exists in wrapper type
path: "/inputSpec/someV2alue",
callback: noop,
})
}
})
})

View File

@@ -27,12 +27,12 @@ const TIMES_TO_WAIT_FOR_PROC = 100
async function prepBind(
from: string | null,
to: string,
type?: "file" | "directory",
type: "file" | "directory" | "infer",
) {
const fromMeta = from ? await fs.stat(from).catch((_) => null) : null
const toMeta = await fs.stat(to).catch((_) => null)
if (type === "file" || (!type && from && fromMeta?.isFile())) {
if (type === "file" || (type === "infer" && from && fromMeta?.isFile())) {
if (toMeta && toMeta.isDirectory()) await fs.rmdir(to, { recursive: false })
if (from && !fromMeta) {
await fs.mkdir(from.replace(/\/[^\/]*\/?$/, ""), { recursive: true })
@@ -49,7 +49,11 @@ async function prepBind(
}
}
async function bind(from: string, to: string, type?: "file" | "directory") {
async function bind(
from: string,
to: string,
type: "file" | "directory" | "infer",
) {
await prepBind(from, to, type)
await execFile("mount", ["--bind", from, to])
@@ -589,13 +593,13 @@ export type MountOptionsVolume = {
volumeId: string
subpath: string | null
readonly: boolean
filetype?: "file" | "directory"
filetype: "file" | "directory" | "infer"
}
export type MountOptionsAssets = {
type: "assets"
subpath: string | null
filetype?: "file" | "directory"
filetype: "file" | "directory" | "infer"
}
export type MountOptionsPointer = {
@@ -604,13 +608,13 @@ export type MountOptionsPointer = {
volumeId: string
subpath: string | null
readonly: boolean
filetype?: "file" | "directory"
filetype: "file" | "directory" | "infer"
}
export type MountOptionsBackup = {
type: "backup"
subpath: string | null
filetype?: "file" | "directory"
filetype: "file" | "directory" | "infer"
}
function wait(time: number) {
return new Promise((resolve) => setTimeout(resolve, time))

View File

@@ -48,6 +48,8 @@ function fileMerge(...args: any[]): any {
for (const arg of args) {
if (res === arg) continue
else if (
res &&
arg &&
typeof res === "object" &&
typeof arg === "object" &&
!Array.isArray(res) &&
@@ -81,8 +83,25 @@ export type Transformers<Raw = unknown, Transformed = unknown> = {
onWrite: (value: Transformed) => Raw
}
type ToPath = string | { volumeId: T.VolumeId; subpath: string }
function toPath(path: ToPath): string {
return typeof path === "string"
? path
: `/media/startos/volumes/${path.volumeId}/${path.subpath}`
}
type Validator<T, U> = matches.Validator<T, U> | matches.Validator<unknown, U>
type ReadType<A> = {
once: () => Promise<A | null>
const: (effects: T.Effects) => Promise<A | null>
watch: (effects: T.Effects) => AsyncGenerator<A | null, null, unknown>
onChange: (
effects: T.Effects,
callback: (value: A | null, error?: Error) => void | Promise<void>,
) => void
}
/**
* @description Use this class to read/write an underlying configuration file belonging to the upstream service.
*
@@ -174,8 +193,12 @@ export class FileHelper<A> {
return this.validate(data)
}
private async readConst(effects: T.Effects): Promise<A | null> {
const watch = this.readWatch(effects)
private async readConst<B>(
effects: T.Effects,
map: (value: A) => B,
eq: (left: B | null | undefined, right: B | null) => boolean,
): Promise<B | null> {
const watch = this.readWatch(effects, map, eq)
const res = await watch.next()
if (effects.constRetry) {
if (!this.consts.includes(effects.constRetry))
@@ -188,7 +211,11 @@ export class FileHelper<A> {
return res.value
}
private async *readWatch(effects: T.Effects) {
private async *readWatch<B>(
effects: T.Effects,
map: (value: A) => B,
eq: (left: B | null | undefined, right: B | null) => boolean,
) {
let res
while (effects.isInContext) {
if (await exists(this.path)) {
@@ -197,7 +224,8 @@ export class FileHelper<A> {
persistent: false,
signal: ctrl.signal,
})
res = await this.readOnce()
const newResFull = await this.readOnce()
const newRes = newResFull ? map(newResFull) : null
const listen = Promise.resolve()
.then(async () => {
for await (const _ of watch) {
@@ -206,7 +234,8 @@ export class FileHelper<A> {
}
})
.catch((e) => console.error(asError(e)))
yield res
if (!eq(res, newRes)) yield newRes
res = newRes
await listen
} else {
yield null
@@ -216,12 +245,14 @@ export class FileHelper<A> {
return null
}
private readOnChange(
private readOnChange<B>(
effects: T.Effects,
callback: (value: A | null, error?: Error) => void | Promise<void>,
callback: (value: B | null, error?: Error) => void | Promise<void>,
map: (value: A) => B,
eq: (left: B | null | undefined, right: B | null) => boolean,
) {
;(async () => {
for await (const value of this.readWatch(effects)) {
for await (const value of this.readWatch(effects, map, eq)) {
try {
await callback(value)
} catch (e) {
@@ -241,15 +272,25 @@ export class FileHelper<A> {
)
}
get read() {
read(): ReadType<A>
read<B>(
map: (value: A) => B,
eq?: (left: B | null | undefined, right: B | null) => boolean,
): ReadType<B>
read(
map?: (value: A) => any,
eq?: (left: any, right: any) => boolean,
): ReadType<any> {
map = map ?? ((a: A) => a)
eq = eq ?? ((left: any, right: any) => !partialDiff(left, right))
return {
once: () => this.readOnce(),
const: (effects: T.Effects) => this.readConst(effects),
watch: (effects: T.Effects) => this.readWatch(effects),
const: (effects: T.Effects) => this.readConst(effects, map, eq),
watch: (effects: T.Effects) => this.readWatch(effects, map, eq),
onChange: (
effects: T.Effects,
callback: (value: A | null, error?: Error) => void | Promise<void>,
) => this.readOnChange(effects, callback),
) => this.readOnChange(effects, callback, map, eq),
}
}
@@ -291,8 +332,13 @@ export class FileHelper<A> {
* We wanted to be able to have a fileHelper, and just modify the path later in time.
* Like one behavior of another dependency or something similar.
*/
withPath(path: string) {
return new FileHelper<A>(path, this.writeData, this.readData, this.validate)
withPath(path: ToPath) {
return new FileHelper<A>(
toPath(path),
this.writeData,
this.readData,
this.validate,
)
}
/**
@@ -301,22 +347,22 @@ export class FileHelper<A> {
* Provide custom functions for translating data to/from the file format.
*/
static raw<A>(
path: string,
path: ToPath,
toFile: (dataIn: A) => string,
fromFile: (rawData: string) => unknown,
validate: (data: unknown) => A,
) {
return new FileHelper<A>(path, toFile, fromFile, validate)
return new FileHelper<A>(toPath(path), toFile, fromFile, validate)
}
private static rawTransformed<A extends Transformed, Raw, Transformed>(
path: string,
path: ToPath,
toFile: (dataIn: Raw) => string,
fromFile: (rawData: string) => Raw,
validate: (data: Transformed) => A,
transformers: Transformers<Raw, Transformed> | undefined,
) {
return new FileHelper<A>(
return FileHelper.raw<A>(
path,
(inData) => {
if (transformers) {
@@ -332,18 +378,18 @@ export class FileHelper<A> {
/**
* Create a File Helper for a text file
*/
static string(path: string): FileHelper<string>
static string(path: ToPath): FileHelper<string>
static string<A extends string>(
path: string,
path: ToPath,
shape: Validator<string, A>,
): FileHelper<A>
static string<A extends Transformed, Transformed = string>(
path: string,
path: ToPath,
shape: Validator<Transformed, A>,
transformers: Transformers<string, Transformed>,
): FileHelper<A>
static string<A extends Transformed, Transformed = string>(
path: string,
path: ToPath,
shape?: Validator<Transformed, A>,
transformers?: Transformers<string, Transformed>,
) {
@@ -363,7 +409,7 @@ export class FileHelper<A> {
* Create a File Helper for a .json file.
*/
static json<A>(
path: string,
path: ToPath,
shape: Validator<unknown, A>,
transformers?: Transformers,
) {
@@ -380,16 +426,16 @@ export class FileHelper<A> {
* Create a File Helper for a .yaml file
*/
static yaml<A extends Record<string, unknown>>(
path: string,
path: ToPath,
shape: Validator<Record<string, unknown>, A>,
): FileHelper<A>
static yaml<A extends Transformed, Transformed = Record<string, unknown>>(
path: string,
path: ToPath,
shape: Validator<Transformed, A>,
transformers: Transformers<Record<string, unknown>, Transformed>,
): FileHelper<A>
static yaml<A extends Transformed, Transformed = Record<string, unknown>>(
path: string,
path: ToPath,
shape: Validator<Transformed, A>,
transformers?: Transformers<Record<string, unknown>, Transformed>,
) {
@@ -406,16 +452,16 @@ export class FileHelper<A> {
* Create a File Helper for a .toml file
*/
static toml<A extends TOML.JsonMap>(
path: string,
path: ToPath,
shape: Validator<TOML.JsonMap, A>,
): FileHelper<A>
static toml<A extends Transformed, Transformed = TOML.JsonMap>(
path: string,
path: ToPath,
shape: Validator<Transformed, A>,
transformers: Transformers<TOML.JsonMap, Transformed>,
): FileHelper<A>
static toml<A extends Transformed, Transformed = TOML.JsonMap>(
path: string,
path: ToPath,
shape: Validator<Transformed, A>,
transformers?: Transformers<TOML.JsonMap, Transformed>,
) {
@@ -429,18 +475,18 @@ export class FileHelper<A> {
}
static ini<A extends Record<string, unknown>>(
path: string,
path: ToPath,
shape: Validator<Record<string, unknown>, A>,
options?: INI.EncodeOptions & INI.DecodeOptions,
): FileHelper<A>
static ini<A extends Transformed, Transformed = Record<string, unknown>>(
path: string,
path: ToPath,
shape: Validator<Transformed, A>,
options: INI.EncodeOptions & INI.DecodeOptions,
transformers: Transformers<Record<string, unknown>, Transformed>,
): FileHelper<A>
static ini<A extends Transformed, Transformed = Record<string, unknown>>(
path: string,
path: ToPath,
shape: Validator<Transformed, A>,
options?: INI.EncodeOptions & INI.DecodeOptions,
transformers?: Transformers<Record<string, unknown>, Transformed>,
@@ -455,16 +501,16 @@ export class FileHelper<A> {
}
static env<A extends Record<string, string>>(
path: string,
path: ToPath,
shape: Validator<Record<string, string>, A>,
): FileHelper<A>
static env<A extends Transformed, Transformed = Record<string, string>>(
path: string,
path: ToPath,
shape: Validator<Transformed, A>,
transformers: Transformers<Record<string, string>, Transformed>,
): FileHelper<A>
static env<A extends Transformed, Transformed = Record<string, string>>(
path: string,
path: ToPath,
shape: Validator<Transformed, A>,
transformers?: Transformers<Record<string, string>, Transformed>,
) {