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

@@ -18,7 +18,7 @@
"jsonpath": "^1.1.1",
"lodash.merge": "^4.6.2",
"node-fetch": "^3.1.0",
"ts-matches": "^5.5.1",
"ts-matches": "^6.3.2",
"tslib": "^2.5.3",
"typescript": "^5.1.3",
"yaml": "^2.3.1"
@@ -37,12 +37,13 @@
},
"../sdk/dist": {
"name": "@start9labs/start-sdk",
"version": "0.4.0-beta.18",
"version": "0.4.0-beta.20",
"license": "MIT",
"dependencies": {
"@iarna/toml": "^3.0.0",
"@noble/curves": "^1.8.2",
"@noble/hashes": "^1.7.2",
"@types/ini": "^4.1.1",
"deep-equality-data-structures": "^2.0.0",
"ini": "^5.0.0",
"isomorphic-fetch": "^3.0.0",
@@ -51,8 +52,6 @@
"yaml": "^2.7.1"
},
"devDependencies": {
"@iarna/toml": "^2.2.5",
"@types/ini": "^4.1.1",
"@types/jest": "^29.4.0",
"copyfiles": "^2.4.1",
"jest": "^29.4.3",
@@ -6476,9 +6475,9 @@
}
},
"node_modules/ts-matches": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/ts-matches/-/ts-matches-5.6.1.tgz",
"integrity": "sha512-1QXWQUa14MCgbz7vMg7i7eVPhMKB/5w8808nkN2sfnDkbG9nWYr9IwuTxX+h99yyawHYS53DewShA2RYCbSW4Q==",
"version": "6.3.2",
"resolved": "https://registry.npmjs.org/ts-matches/-/ts-matches-6.3.2.tgz",
"integrity": "sha512-UhSgJymF8cLd4y0vV29qlKVCkQpUtekAaujXbQVc729FezS8HwqzepqvtjzQ3HboatIqN/Idor85O2RMwT7lIQ==",
"license": "MIT"
},
"node_modules/tslib": {

View File

@@ -27,7 +27,7 @@
"jsonpath": "^1.1.1",
"lodash.merge": "^4.6.2",
"node-fetch": "^3.1.0",
"ts-matches": "^5.5.1",
"ts-matches": "^6.3.2",
"tslib": "^2.5.3",
"typescript": "^5.1.3",
"yaml": "^2.3.1"

View File

@@ -6,23 +6,19 @@ import { Effects } from "../Models/Effects"
import { CallbackHolder } from "../Models/CallbackHolder"
import { asError } from "@start9labs/start-sdk/base/lib/util"
const matchRpcError = object({
error: object(
{
code: number,
message: string,
data: some(
string,
object(
{
details: string,
debug: string,
},
["debug"],
),
),
},
["data"],
),
error: object({
code: number,
message: string,
data: some(
string,
object({
details: string,
debug: string.nullable().optional(),
}),
)
.nullable()
.optional(),
}),
})
const testRpcError = matchRpcError.test
const testRpcResult = object({
@@ -197,13 +193,6 @@ export function makeEffects(context: EffectContext): Effects {
T.Effects["exportServiceInterface"]
>
}) as Effects["exportServiceInterface"],
exposeForDependents(
...[options]: Parameters<T.Effects["exposeForDependents"]>
) {
return rpcRound("expose-for-dependents", options) as ReturnType<
T.Effects["exposeForDependents"]
>
},
getContainerIp(...[options]: Parameters<T.Effects["getContainerIp"]>) {
return rpcRound("get-container-ip", options) as ReturnType<
T.Effects["getContainerIp"]
@@ -305,15 +294,6 @@ export function makeEffects(context: EffectContext): Effects {
shutdown(...[]: Parameters<T.Effects["shutdown"]>) {
return rpcRound("shutdown", {}) as ReturnType<T.Effects["shutdown"]>
},
store: {
get: async (options: any) =>
rpcRound("store.get", {
...options,
callback: context.callbacks?.addCallback(options.callback) || null,
}) as any,
set: async (options: any) =>
rpcRound("store.set", options) as ReturnType<T.Effects["store"]["set"]>,
} as T.Effects["store"],
getDataVersion() {
return rpcRound("get-data-version", {}) as ReturnType<
T.Effects["getDataVersion"]

View File

@@ -26,20 +26,16 @@ type MaybePromise<T> = T | Promise<T>
export const matchRpcResult = anyOf(
object({ result: any }),
object({
error: object(
{
code: number,
message: string,
data: object(
{
details: string,
debug: any,
},
["details", "debug"],
),
},
["data"],
),
error: object({
code: number,
message: string,
data: object({
details: string.optional(),
debug: any.optional(),
})
.nullable()
.optional(),
}),
}),
)
@@ -54,38 +50,26 @@ const isResult = object({ result: any }).test
const idType = some(string, number, literal(null))
type IdType = null | string | number | undefined
const runType = object(
{
id: idType,
method: literal("execute"),
params: object(
{
id: string,
procedure: string,
input: any,
timeout: number,
},
["timeout"],
),
},
["id"],
)
const sandboxRunType = object(
{
id: idType,
method: literal("sandbox"),
params: object(
{
id: string,
procedure: string,
input: any,
timeout: number,
},
["timeout"],
),
},
["id"],
)
const runType = object({
id: idType.optional(),
method: literal("execute"),
params: object({
id: string,
procedure: string,
input: any,
timeout: number.nullable().optional(),
}),
})
const sandboxRunType = object({
id: idType.optional(),
method: literal("sandbox"),
params: object({
id: string,
procedure: string,
input: any,
timeout: number.nullable().optional(),
}),
})
const callbackType = object({
method: literal("callback"),
params: object({
@@ -93,44 +77,29 @@ const callbackType = object({
args: array,
}),
})
const initType = object(
{
id: idType,
method: literal("init"),
},
["id"],
)
const startType = object(
{
id: idType,
method: literal("start"),
},
["id"],
)
const stopType = object(
{
id: idType,
method: literal("stop"),
},
["id"],
)
const exitType = object(
{
id: idType,
method: literal("exit"),
},
["id"],
)
const evalType = object(
{
id: idType,
method: literal("eval"),
params: object({
script: string,
}),
},
["id"],
)
const initType = object({
id: idType.optional(),
method: literal("init"),
})
const startType = object({
id: idType.optional(),
method: literal("start"),
})
const stopType = object({
id: idType.optional(),
method: literal("stop"),
})
const exitType = object({
id: idType.optional(),
method: literal("exit"),
})
const evalType = object({
id: idType.optional(),
method: literal("eval"),
params: object({
script: string,
}),
})
const jsonParse = (x: string) => JSON.parse(x)
@@ -365,7 +334,7 @@ export class RpcListener {
)
})
.when(
shape({ id: idType, method: string }, ["id"]),
shape({ id: idType.optional(), method: string }),
({ id, method }) => ({
jsonrpc,
id,
@@ -400,7 +369,7 @@ export class RpcListener {
procedure: typeof jsonPath._TYPE,
system: System,
procedureId: string,
timeout: number | undefined,
timeout: number | null | undefined,
input: any,
) {
const ensureResultTypeShape = (
@@ -449,14 +418,10 @@ export class RpcListener {
})().then(ensureResultTypeShape, (error) =>
matches(error)
.when(
object(
{
error: string,
code: number,
},
["code"],
{ code: 0 },
),
object({
error: string,
code: number.defaultTo(0),
}),
(error) => ({
error: {
code: error.code,

View File

@@ -64,7 +64,7 @@ export class DockerProcedureContainer extends Drop {
const volumeMount = volumes[mount]
if (volumeMount.type === "data") {
await subcontainer.mount(
Mounts.of().addVolume({
Mounts.of().mountVolume({
volumeId: mount,
subpath: null,
mountpoint: mounts[mount],
@@ -73,7 +73,7 @@ export class DockerProcedureContainer extends Drop {
)
} else if (volumeMount.type === "assets") {
await subcontainer.mount(
Mounts.of().addAssets({
Mounts.of().mountAssets({
subpath: mount,
mountpoint: mounts[mount],
}),
@@ -119,7 +119,7 @@ export class DockerProcedureContainer extends Drop {
})
} else if (volumeMount.type === "backup") {
await subcontainer.mount(
Mounts.of().addBackups({
Mounts.of().mountBackups({
subpath: null,
mountpoint: mounts[mount],
}),

View File

@@ -1,5 +1,6 @@
import {
ExtendedVersion,
FileHelper,
types as T,
utils,
VersionRange,
@@ -46,7 +47,7 @@ import {
transformNewConfigToOld,
transformOldConfigToNew,
} from "./transformConfigSpec"
import { partialDiff, StorePath } from "@start9labs/start-sdk/base/lib/util"
import { partialDiff } from "@start9labs/start-sdk/base/lib/util"
type Optional<A> = A | undefined | null
function todo(): never {
@@ -55,8 +56,21 @@ function todo(): never {
const MANIFEST_LOCATION = "/usr/lib/startos/package/embassyManifest.json"
export const EMBASSY_JS_LOCATION = "/usr/lib/startos/package/embassy.js"
const EMBASSY_POINTER_PATH_PREFIX = "/embassyConfig" as utils.StorePath
const EMBASSY_DEPENDS_ON_PATH_PREFIX = "/embassyDependsOn" as utils.StorePath
const configFile = FileHelper.json(
{
volumeId: "embassy",
subpath: "config.json",
},
matches.any,
)
const dependsOnFile = FileHelper.json(
{
volumeId: "embassy",
subpath: "dependsOn.json",
},
dictionary([string, array(string)]),
)
const matchResult = object({
result: any,
@@ -94,47 +108,48 @@ const fromReturnType = <A>(a: U.ResultType<A>): A => {
return assertNever(a)
}
const matchSetResult = object(
{
"depends-on": dictionary([string, array(string)]),
dependsOn: dictionary([string, array(string)]),
signal: literals(
"SIGTERM",
"SIGHUP",
"SIGINT",
"SIGQUIT",
"SIGILL",
"SIGTRAP",
"SIGABRT",
"SIGBUS",
"SIGFPE",
"SIGKILL",
"SIGUSR1",
"SIGSEGV",
"SIGUSR2",
"SIGPIPE",
"SIGALRM",
"SIGSTKFLT",
"SIGCHLD",
"SIGCONT",
"SIGSTOP",
"SIGTSTP",
"SIGTTIN",
"SIGTTOU",
"SIGURG",
"SIGXCPU",
"SIGXFSZ",
"SIGVTALRM",
"SIGPROF",
"SIGWINCH",
"SIGIO",
"SIGPWR",
"SIGSYS",
"SIGINFO",
),
},
["depends-on", "dependsOn"],
)
const matchSetResult = object({
"depends-on": dictionary([string, array(string)])
.nullable()
.optional(),
dependsOn: dictionary([string, array(string)])
.nullable()
.optional(),
signal: literals(
"SIGTERM",
"SIGHUP",
"SIGINT",
"SIGQUIT",
"SIGILL",
"SIGTRAP",
"SIGABRT",
"SIGBUS",
"SIGFPE",
"SIGKILL",
"SIGUSR1",
"SIGSEGV",
"SIGUSR2",
"SIGPIPE",
"SIGALRM",
"SIGSTKFLT",
"SIGCHLD",
"SIGCONT",
"SIGSTOP",
"SIGTSTP",
"SIGTTIN",
"SIGTTOU",
"SIGURG",
"SIGXCPU",
"SIGXFSZ",
"SIGVTALRM",
"SIGPROF",
"SIGWINCH",
"SIGIO",
"SIGPWR",
"SIGSYS",
"SIGINFO",
),
})
type OldGetConfigRes = {
config?: null | Record<string, unknown>
@@ -174,14 +189,14 @@ export type PackagePropertiesV2 = {
}
export type PackagePropertyString = {
type: "string"
description?: string
description?: string | null
value: string
/** Let's the ui make this copyable button */
copyable?: boolean
copyable?: boolean | null
/** Let the ui create a qr for this field */
qr?: boolean
qr?: boolean | null
/** Hiding the value unless toggled off for field */
masked?: boolean
masked?: boolean | null
}
export type PackagePropertyObject = {
value: PackagePropertiesV2
@@ -225,17 +240,14 @@ const matchPackagePropertyObject: Parser<unknown, PackagePropertyObject> =
})
const matchPackagePropertyString: Parser<unknown, PackagePropertyString> =
object(
{
type: literal("string"),
description: string,
value: string,
copyable: boolean,
qr: boolean,
masked: boolean,
},
["copyable", "description", "qr", "masked"],
)
object({
type: literal("string"),
description: string.nullable().optional(),
value: string,
copyable: boolean.nullable().optional(),
qr: boolean.nullable().optional(),
masked: boolean.nullable().optional(),
})
setMatchPackageProperties(
dictionary([
string,
@@ -300,7 +312,7 @@ export class SystemForEmbassy implements System {
async containerInit(effects: Effects): Promise<void> {
for (let depId in this.manifest.dependencies) {
if (this.manifest.dependencies[depId].config) {
if (this.manifest.dependencies[depId]?.config) {
await this.dependenciesAutoconfig(effects, depId, null)
}
}
@@ -355,10 +367,7 @@ export class SystemForEmbassy implements System {
) {
await effects.action.clearRequests({ only: ["needs-config"] })
}
await effects.store.set({
path: EMBASSY_POINTER_PATH_PREFIX,
value: this.getConfig(effects, timeoutMs),
})
await configFile.write(effects, await this.getConfig(effects, timeoutMs))
} else if (this.manifest.config) {
await effects.action.request({
packageId: this.manifest.id,
@@ -587,11 +596,6 @@ export class SystemForEmbassy implements System {
const moduleCode = await this.moduleCode
await moduleCode.createBackup?.(polyfillEffects(effects, this.manifest))
}
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, {
@@ -607,11 +611,6 @@ export class SystemForEmbassy implements System {
encoding: "utf-8",
})
.catch((_) => null)
if (store)
await effects.store.set({
path: "" as StorePath,
value: JSON.parse(store),
})
const restoreBackup = this.manifest.backup.restore
if (restoreBackup.type === "docker") {
const commands = [restoreBackup.entrypoint, ...restoreBackup.args]
@@ -686,10 +685,7 @@ export class SystemForEmbassy implements System {
structuredClone(newConfigWithoutPointers as Record<string, unknown>),
)
await updateConfig(effects, this.manifest, spec, newConfig)
await effects.store.set({
path: EMBASSY_POINTER_PATH_PREFIX,
value: newConfig,
})
await configFile.write(effects, newConfig)
const setConfigValue = this.manifest.config?.set
if (!setConfigValue) return
if (setConfigValue.type === "docker") {
@@ -743,15 +739,11 @@ export class SystemForEmbassy implements System {
rawDepends: { [x: string]: readonly string[] },
configuring: boolean,
) {
const storedDependsOn = (await effects.store.get({
packageId: this.manifest.id,
path: EMBASSY_DEPENDS_ON_PATH_PREFIX,
})) as Record<string, readonly string[]>
const storedDependsOn = await dependsOnFile.read().once()
const requiredDeps = {
...Object.fromEntries(
Object.entries(this.manifest.dependencies || {})
?.filter((x) => x[1].requirement.type === "required")
Object.entries(this.manifest.dependencies ?? {})
.filter(([k, v]) => v?.requirement.type === "required")
.map((x) => [x[0], []]) || [],
),
}
@@ -765,10 +757,7 @@ export class SystemForEmbassy implements System {
? storedDependsOn
: requiredDeps
await effects.store.set({
path: EMBASSY_DEPENDS_ON_PATH_PREFIX,
value: dependsOn,
})
await dependsOnFile.write(effects, dependsOn)
await effects.setDependencies({
dependencies: Object.entries(dependsOn).flatMap(
@@ -1006,43 +995,50 @@ export class SystemForEmbassy implements System {
timeoutMs: number | null,
): Promise<void> {
// TODO: docker
const oldConfig = (await effects.store.get({
packageId: id,
path: EMBASSY_POINTER_PATH_PREFIX,
callback: () => {
this.dependenciesAutoconfig(effects, id, timeoutMs)
},
})) as U.Config
if (!oldConfig) return
const moduleCode = await this.moduleCode
const method = moduleCode?.dependencies?.[id]?.autoConfigure
if (!method) return
const newConfig = (await method(
polyfillEffects(effects, this.manifest),
JSON.parse(JSON.stringify(oldConfig)),
).then((x) => {
if ("result" in x) return x.result
if ("error" in x) throw new Error("Error getting config: " + x.error)
throw new Error("Error getting config: " + x["error-code"][1])
})) as any
const diff = partialDiff(oldConfig, newConfig)
if (diff) {
await effects.action.request({
actionId: "config",
await effects.mount({
location: `/media/embassy/${id}`,
target: {
packageId: id,
replayId: `${id}/config`,
severity: "important",
reason: `Configure this dependency for the needs of ${this.manifest.title}`,
input: {
kind: "partial",
value: diff.diff,
},
when: {
condition: "input-not-matches",
once: false,
},
volumeId: "embassy",
subpath: null,
readonly: true,
},
})
configFile
.withPath(`/media/embassy/${id}/config.json`)
.read()
.onChange(effects, async (oldConfig: U.Config) => {
if (!oldConfig) return
const moduleCode = await this.moduleCode
const method = moduleCode?.dependencies?.[id]?.autoConfigure
if (!method) return
const newConfig = (await method(
polyfillEffects(effects, this.manifest),
JSON.parse(JSON.stringify(oldConfig)),
).then((x) => {
if ("result" in x) return x.result
if ("error" in x) throw new Error("Error getting config: " + x.error)
throw new Error("Error getting config: " + x["error-code"][1])
})) as any
const diff = partialDiff(oldConfig, newConfig)
if (diff) {
await effects.action.request({
actionId: "config",
packageId: id,
replayId: `${id}/config`,
severity: "important",
reason: `Configure this dependency for the needs of ${this.manifest.title}`,
input: {
kind: "partial",
value: diff.diff,
},
when: {
condition: "input-not-matches",
once: false,
},
})
}
})
}
}
}
@@ -1144,11 +1140,20 @@ async function updateConfig(
) {
if (specValue.target === "config") {
const jp = require("jsonpath")
const remoteConfig = await effects.store.get({
packageId: specValue["package-id"],
callback: () => effects.restart(),
path: EMBASSY_POINTER_PATH_PREFIX,
const depId = specValue["package-id"]
await effects.mount({
location: `/media/embassy/${depId}`,
target: {
packageId: depId,
volumeId: "embassy",
subpath: null,
readonly: true,
},
})
const remoteConfig = configFile
.withPath(`/media/embassy/${depId}/config.json`)
.read()
.once()
console.debug(remoteConfig)
const configValue = specValue.multi
? jp.query(remoteConfig, specValue.selector)

View File

@@ -14,123 +14,113 @@ import {
import { matchVolume } from "./matchVolume"
import { matchDockerProcedure } from "../../../Models/DockerProcedure"
const matchJsProcedure = object(
{
type: literal("script"),
args: array(unknown),
},
["args"],
{
args: [],
},
)
const matchJsProcedure = object({
type: literal("script"),
args: array(unknown).nullable().optional().defaultTo([]),
})
const matchProcedure = some(matchDockerProcedure, matchJsProcedure)
export type Procedure = typeof matchProcedure._TYPE
const matchAction = object(
{
name: string,
description: string,
warning: string,
implementation: matchProcedure,
"allowed-statuses": array(literals("running", "stopped")),
"input-spec": unknown,
},
["warning", "input-spec", "input-spec"],
)
export const matchManifest = object(
{
id: string,
title: string,
version: string,
main: matchDockerProcedure,
assets: object(
{
assets: string,
scripts: string,
},
["assets", "scripts"],
const matchAction = object({
name: string,
description: string,
warning: string.nullable().optional(),
implementation: matchProcedure,
"allowed-statuses": array(literals("running", "stopped")),
"input-spec": unknown.nullable().optional(),
})
export const matchManifest = object({
id: string,
title: string,
version: string,
main: matchDockerProcedure,
assets: object({
assets: string.nullable().optional(),
scripts: string.nullable().optional(),
})
.nullable()
.optional(),
"health-checks": dictionary([
string,
every(
matchProcedure,
object({
name: string,
["success-message"]: string.nullable().optional(),
}),
),
"health-checks": dictionary([
string,
every(
matchProcedure,
object(
{
name: string,
["success-message"]: string,
},
["success-message"],
),
),
]),
config: object({
get: matchProcedure,
set: matchProcedure,
]),
config: object({
get: matchProcedure,
set: matchProcedure,
})
.nullable()
.optional(),
properties: matchProcedure.nullable().optional(),
volumes: dictionary([string, matchVolume]),
interfaces: dictionary([
string,
object({
name: string,
description: string,
"tor-config": object({
"port-mapping": dictionary([string, string]),
})
.nullable()
.optional(),
"lan-config": dictionary([
string,
object({
ssl: boolean,
internal: number,
}),
])
.nullable()
.optional(),
ui: boolean,
protocols: array(string),
}),
properties: matchProcedure,
volumes: dictionary([string, matchVolume]),
interfaces: dictionary([
string,
object(
{
name: string,
description: string,
"tor-config": object({
"port-mapping": dictionary([string, string]),
}),
"lan-config": dictionary([
string,
object({
ssl: boolean,
internal: number,
}),
]),
ui: boolean,
protocols: array(string),
},
["lan-config", "tor-config"],
]),
backup: object({
create: matchProcedure,
restore: matchProcedure,
}),
migrations: object({
to: dictionary([string, matchProcedure]),
from: dictionary([string, matchProcedure]),
})
.nullable()
.optional(),
dependencies: dictionary([
string,
object({
version: string,
requirement: some(
object({
type: literal("opt-in"),
how: string,
}),
object({
type: literal("opt-out"),
how: string,
}),
object({
type: literal("required"),
}),
),
]),
backup: object({
create: matchProcedure,
restore: matchProcedure,
}),
migrations: object({
to: dictionary([string, matchProcedure]),
from: dictionary([string, matchProcedure]),
}),
dependencies: dictionary([
string,
object(
{
version: string,
requirement: some(
object({
type: literal("opt-in"),
how: string,
}),
object({
type: literal("opt-out"),
how: string,
}),
object({
type: literal("required"),
}),
),
description: string,
config: object({
check: matchProcedure,
"auto-configure": matchProcedure,
}),
},
["description", "config"],
),
]),
description: string.nullable().optional(),
config: object({
check: matchProcedure,
"auto-configure": matchProcedure,
})
.nullable()
.optional(),
})
.nullable()
.optional(),
]),
actions: dictionary([string, matchAction]),
},
["config", "actions", "properties", "migrations", "dependencies"],
)
actions: dictionary([string, matchAction]),
})
export type Manifest = typeof matchManifest._TYPE

View File

@@ -1,12 +1,9 @@
import { object, literal, string, boolean, some } from "ts-matches"
const matchDataVolume = object(
{
type: literal("data"),
readonly: boolean,
},
["readonly"],
)
const matchDataVolume = object({
type: literal("data"),
readonly: boolean.optional(),
})
const matchAssetVolume = object({
type: literal("assets"),
})

View File

@@ -169,7 +169,7 @@ export const polyfillEffects = (
{ imageId: manifest.main.image },
commands,
{
mounts: Mounts.of().addVolume({
mounts: Mounts.of().mountVolume({
volumeId: input.volumeId,
subpath: null,
mountpoint: "/drive",
@@ -206,7 +206,7 @@ export const polyfillEffects = (
{ imageId: manifest.main.image },
commands,
{
mounts: Mounts.of().addVolume({
mounts: Mounts.of().mountVolume({
volumeId: input.volumeId,
subpath: null,
mountpoint: "/drive",

View File

@@ -203,6 +203,7 @@ export function transformNewConfigToOld(
spec: OldConfigSpec,
config: Record<string, any>,
): Record<string, any> {
if (!config) return config
return Object.entries(spec).reduce((obj, [key, val]) => {
let newVal = config[key]
@@ -396,100 +397,71 @@ export const matchOldDefaultString = anyOf(
)
type OldDefaultString = typeof matchOldDefaultString._TYPE
export const matchOldValueSpecString = object(
{
type: literals("string"),
name: string,
masked: boolean,
copyable: boolean,
nullable: boolean,
placeholder: string,
pattern: string,
"pattern-description": string,
default: matchOldDefaultString,
textarea: boolean,
description: string,
warning: string,
},
[
"masked",
"copyable",
"nullable",
"placeholder",
"pattern",
"pattern-description",
"default",
"textarea",
"description",
"warning",
],
)
export const matchOldValueSpecString = object({
type: literals("string"),
name: string,
masked: boolean.nullable().optional(),
copyable: boolean.nullable().optional(),
nullable: boolean.nullable().optional(),
placeholder: string.nullable().optional(),
pattern: string.nullable().optional(),
"pattern-description": string.nullable().optional(),
default: matchOldDefaultString.nullable().optional(),
textarea: boolean.nullable().optional(),
description: string.nullable().optional(),
warning: string.nullable().optional(),
})
export const matchOldValueSpecNumber = object(
{
type: literals("number"),
nullable: boolean,
name: string,
range: string,
integral: boolean,
default: number,
description: string,
warning: string,
units: string,
placeholder: anyOf(number, string),
},
["default", "description", "warning", "units", "placeholder"],
)
export const matchOldValueSpecNumber = object({
type: literals("number"),
nullable: boolean,
name: string,
range: string,
integral: boolean,
default: number.nullable().optional(),
description: string.nullable().optional(),
warning: string.nullable().optional(),
units: string.nullable().optional(),
placeholder: anyOf(number, string).nullable().optional(),
})
type OldValueSpecNumber = typeof matchOldValueSpecNumber._TYPE
export const matchOldValueSpecBoolean = object(
{
type: literals("boolean"),
default: boolean,
name: string,
description: string,
warning: string,
},
["description", "warning"],
)
export const matchOldValueSpecBoolean = object({
type: literals("boolean"),
default: boolean,
name: string,
description: string.nullable().optional(),
warning: string.nullable().optional(),
})
type OldValueSpecBoolean = typeof matchOldValueSpecBoolean._TYPE
const matchOldValueSpecObject = object(
{
type: literals("object"),
spec: _matchOldConfigSpec,
name: string,
description: string,
warning: string,
},
["description", "warning"],
)
const matchOldValueSpecObject = object({
type: literals("object"),
spec: _matchOldConfigSpec,
name: string,
description: string.nullable().optional(),
warning: string.nullable().optional(),
})
type OldValueSpecObject = typeof matchOldValueSpecObject._TYPE
const matchOldValueSpecEnum = object(
{
values: array(string),
"value-names": dictionary([string, string]),
type: literals("enum"),
default: string,
name: string,
description: string,
warning: string,
},
["description", "warning"],
)
const matchOldValueSpecEnum = object({
values: array(string),
"value-names": dictionary([string, string]),
type: literals("enum"),
default: string,
name: string,
description: string.nullable().optional(),
warning: string.nullable().optional(),
})
type OldValueSpecEnum = typeof matchOldValueSpecEnum._TYPE
const matchOldUnionTagSpec = object(
{
id: string, // The name of the field containing one of the union variants
"variant-names": dictionary([string, string]), // The name of each variant
name: string,
description: string,
warning: string,
},
["description", "warning"],
)
const matchOldUnionTagSpec = object({
id: string, // The name of the field containing one of the union variants
"variant-names": dictionary([string, string]), // The name of each variant
name: string,
description: string.nullable().optional(),
warning: string.nullable().optional(),
})
const matchOldValueSpecUnion = object({
type: literals("union"),
tag: matchOldUnionTagSpec,
@@ -514,57 +486,45 @@ setOldUniqueBy(
),
)
const matchOldListValueSpecObject = object(
{
spec: _matchOldConfigSpec, // this is a mapped type of the config object at this level, replacing the object's values with specs on those values
"unique-by": matchOldUniqueBy, // indicates whether duplicates can be permitted in the list
"display-as": string, // this should be a handlebars template which can make use of the entire config which corresponds to 'spec'
},
["display-as", "unique-by"],
)
const matchOldListValueSpecString = object(
{
masked: boolean,
copyable: boolean,
pattern: string,
"pattern-description": string,
placeholder: string,
},
["pattern", "pattern-description", "placeholder", "copyable", "masked"],
)
const matchOldListValueSpecObject = object({
spec: _matchOldConfigSpec, // this is a mapped type of the config object at this level, replacing the object's values with specs on those values
"unique-by": matchOldUniqueBy.nullable().optional(), // indicates whether duplicates can be permitted in the list
"display-as": string.nullable().optional(), // this should be a handlebars template which can make use of the entire config which corresponds to 'spec'
})
const matchOldListValueSpecString = object({
masked: boolean.nullable().optional(),
copyable: boolean.nullable().optional(),
pattern: string.nullable().optional(),
"pattern-description": string.nullable().optional(),
placeholder: string.nullable().optional(),
})
const matchOldListValueSpecEnum = object({
values: array(string),
"value-names": dictionary([string, string]),
})
const matchOldListValueSpecNumber = object(
{
range: string,
integral: boolean,
units: string,
placeholder: anyOf(number, string),
},
["units", "placeholder"],
)
const matchOldListValueSpecNumber = object({
range: string,
integral: boolean,
units: string.nullable().optional(),
placeholder: anyOf(number, string).nullable().optional(),
})
// represents a spec for a list
const matchOldValueSpecList = every(
object(
{
type: literals("list"),
range: string, // '[0,1]' (inclusive) OR '[0,*)' (right unbounded), normal math rules
default: anyOf(
array(string),
array(number),
array(matchOldDefaultString),
array(object),
),
name: string,
description: string,
warning: string,
},
["description", "warning"],
),
object({
type: literals("list"),
range: string, // '[0,1]' (inclusive) OR '[0,*)' (right unbounded), normal math rules
default: anyOf(
array(string),
array(number),
array(matchOldDefaultString),
array(object),
),
name: string,
description: string.nullable().optional(),
warning: string.nullable().optional(),
}),
anyOf(
object({
subtype: literals("string"),

View File

@@ -1,7 +1,6 @@
import { System } from "../../Interfaces/System"
import { Effects } from "../../Models/Effects"
import { T, utils } from "@start9labs/start-sdk"
import { Optional } from "ts-matches/lib/parsers/interfaces"
export const STARTOS_JS_LOCATION = "/usr/lib/startos/package/index.js"
@@ -30,7 +29,7 @@ export class SystemForStartOs implements System {
}
async packageUninit(
effects: Effects,
nextVersion: Optional<string> = null,
nextVersion: string | null = null,
timeoutMs: number | null = null,
): Promise<void> {
return void (await this.abi.packageUninit({ effects, nextVersion }))

View File

@@ -17,31 +17,25 @@ const Path = string
export type VolumeId = string
export type Path = string
export const matchDockerProcedure = object(
{
type: literal("docker"),
image: string,
system: boolean,
entrypoint: string,
args: array(string),
mounts: dictionary([VolumeId, Path]),
"io-format": literals(
"json",
"json-pretty",
"yaml",
"cbor",
"toml",
"toml-pretty",
),
"sigterm-timeout": some(number, matchDuration),
inject: boolean,
},
["io-format", "sigterm-timeout", "system", "args", "inject", "mounts"],
{
"sigterm-timeout": 30,
inject: false,
args: [],
},
)
export const matchDockerProcedure = object({
type: literal("docker"),
image: string,
system: boolean.optional(),
entrypoint: string,
args: array(string).defaultTo([]),
mounts: dictionary([VolumeId, Path]).optional(),
"io-format": literals(
"json",
"json-pretty",
"yaml",
"cbor",
"toml",
"toml-pretty",
)
.nullable()
.optional(),
"sigterm-timeout": some(number, matchDuration).onMismatch(30),
inject: boolean.defaultTo(false),
})
export type DockerProcedure = typeof matchDockerProcedure._TYPE

View File

@@ -10,6 +10,7 @@ use rpc_toolkit::yajrc::{
RpcError, INVALID_PARAMS_ERROR, INVALID_REQUEST_ERROR, METHOD_NOT_FOUND_ERROR, PARSE_ERROR,
};
use serde::{Deserialize, Serialize};
use tokio::task::JoinHandle;
use crate::InvalidId;
@@ -189,6 +190,7 @@ pub struct Error {
pub source: color_eyre::eyre::Error,
pub kind: ErrorKind,
pub revision: Option<Revision>,
pub task: Option<JoinHandle<()>>,
}
impl Display for Error {
@@ -202,6 +204,7 @@ impl Error {
source: source.into(),
kind,
revision: None,
task: None,
}
}
pub fn clone_output(&self) -> Self {
@@ -213,8 +216,20 @@ impl Error {
.into(),
kind: self.kind,
revision: self.revision.clone(),
task: None,
}
}
pub fn with_task(mut self, task: JoinHandle<()>) -> Self {
self.task = Some(task);
self
}
pub async fn wait(mut self) -> Self {
if let Some(task) = &mut self.task {
task.await.log_err();
}
self.task.take();
self
}
}
impl axum::response::IntoResponse for Error {
fn into_response(self) -> axum::response::Response {
@@ -530,6 +545,7 @@ where
source: e.into(),
kind,
revision: None,
task: None,
})
}
@@ -543,6 +559,7 @@ where
kind,
source,
revision: None,
task: None,
}
})
}
@@ -565,6 +582,7 @@ impl<T> ResultExt<T, Error> for Result<T, Error> {
source: e.source,
kind,
revision: e.revision,
task: e.task,
})
}
@@ -578,6 +596,7 @@ impl<T> ResultExt<T, Error> for Result<T, Error> {
kind,
source,
revision: e.revision,
task: e.task,
}
})
}

View File

@@ -1,10 +1,11 @@
use std::borrow::Borrow;
use std::path::Path;
use std::str::FromStr;
use serde::{Deserialize, Deserializer, Serialize};
use ts_rs::TS;
use crate::Id;
use crate::{Id, InvalidId};
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, TS)]
#[ts(type = "string")]
@@ -12,6 +13,15 @@ pub enum VolumeId {
Backup,
Custom(Id),
}
impl FromStr for VolumeId {
type Err = InvalidId;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s {
"BACKUP" => VolumeId::Backup,
s => VolumeId::Custom(Id::try_from(s.to_owned())?),
})
}
}
impl std::fmt::Display for VolumeId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {

View File

@@ -550,33 +550,18 @@ pub struct UninstallParams {
pub async fn uninstall(
ctx: RpcContext,
UninstallParams { id, soft, force }: UninstallParams,
) -> Result<PackageId, Error> {
ctx.db
.mutate(|db| {
let entry = db
.as_public_mut()
.as_package_data_mut()
.as_idx_mut(&id)
.or_not_found(&id)?;
entry.as_state_info_mut().map_mutate(|s| match s {
PackageState::Installed(s) => Ok(PackageState::Removing(s)),
_ => Err(Error::new(
eyre!("Package {id} is not installed."),
crate::ErrorKind::NotFound,
)),
})
})
.await
.result?;
let return_id = id.clone();
) -> Result<(), Error> {
let fut = ctx
.services
.uninstall(ctx.clone(), id.clone(), soft, force)
.await?;
tokio::spawn(async move {
if let Err(e) = ctx.services.uninstall(&ctx, &id, soft, force).await {
if let Err(e) = fut.await {
tracing::error!("Error uninstalling service {id}: {e}");
tracing::debug!("{e:?}");
}
});
Ok(return_id)
Ok(())
}

View File

@@ -4,7 +4,7 @@ use std::str::FromStr;
use std::sync::Arc;
use exver::{ExtendedVersion, VersionRange};
use models::ImageId;
use models::{Id, ImageId, VolumeId};
use tokio::io::{AsyncRead, AsyncSeek, AsyncWriteExt};
use tokio::process::Command;
@@ -213,6 +213,7 @@ impl TryFrom<ManifestV1> for Manifest {
.iter()
.filter(|(_, v)| v.get("type").and_then(|v| v.as_str()) == Some("data"))
.map(|(id, _)| id.clone())
.chain([VolumeId::from_str("embassy").unwrap()])
.collect(),
alerts: value.alerts,
dependencies: Dependencies(

View File

@@ -8,10 +8,7 @@ use futures::future::join_all;
use helpers::NonDetachingJoinHandle;
use imbl::{vector, Vector};
use imbl_value::InternedString;
use lazy_static::lazy_static;
use models::{HostId, PackageId, ServiceInterfaceId};
use patch_db::json_ptr::JsonPointer;
use patch_db::Revision;
use serde::{Deserialize, Serialize};
use tracing::warn;
use ts_rs::TS;
@@ -37,7 +34,6 @@ struct ServiceCallbackMap {
(BTreeSet<InternedString>, FullchainCertData, Algorithm),
(NonDetachingJoinHandle<()>, Vec<CallbackHandler>),
>,
get_store: BTreeMap<PackageId, BTreeMap<JsonPointer, Vec<CallbackHandler>>>,
get_status: BTreeMap<PackageId, Vec<CallbackHandler>>,
get_container_ip: BTreeMap<PackageId, Vec<CallbackHandler>>,
}
@@ -68,13 +64,6 @@ impl ServiceCallbacks {
v.retain(|h| h.handle.is_active() && h.seed.strong_count() > 0);
!v.is_empty()
});
this.get_store.retain(|_, v| {
v.retain(|_, v| {
v.retain(|h| h.handle.is_active() && h.seed.strong_count() > 0);
!v.is_empty()
});
!v.is_empty()
});
this.get_status.retain(|_, v| {
v.retain(|h| h.handle.is_active() && h.seed.strong_count() > 0);
!v.is_empty()
@@ -243,53 +232,6 @@ impl ServiceCallbacks {
})
}
pub(super) fn add_get_store(
&self,
package_id: PackageId,
path: JsonPointer,
handler: CallbackHandler,
) {
self.mutate(|this| {
this.get_store
.entry(package_id)
.or_default()
.entry(path)
.or_default()
.push(handler)
})
}
#[must_use]
pub fn get_store(
&self,
package_id: &PackageId,
revision: &Revision,
) -> Option<CallbackHandlers> {
lazy_static! {
static ref BASE: JsonPointer = "/private/packageStores".parse().unwrap();
}
let for_pkg = BASE.clone().join_end(&**package_id);
self.mutate(|this| {
if let Some(watched) = this.get_store.get_mut(package_id) {
let mut res = Vec::new();
watched.retain(|ptr, cbs| {
let mut full_ptr = for_pkg.clone();
full_ptr.append(ptr);
if revision.patch.affects_path(&full_ptr) {
res.append(cbs);
false
} else {
true
}
});
Some(CallbackHandlers(res))
} else {
None
}
.filter(|cb| !cb.0.is_empty())
})
}
pub(super) fn add_get_container_ip(&self, package_id: PackageId, handler: CallbackHandler) {
self.mutate(|this| {
this.get_container_ip

View File

@@ -7,7 +7,6 @@ use exver::VersionRange;
use imbl::OrdMap;
use imbl_value::InternedString;
use models::{FromStrParser, HealthCheckId, PackageId, ReplayId, VersionString, VolumeId};
use patch_db::json_ptr::JsonPointer;
use tokio::process::Command;
use crate::db::model::package::{
@@ -17,6 +16,7 @@ use crate::db::model::package::{
use crate::disk::mount::filesystem::bind::Bind;
use crate::disk::mount::filesystem::idmapped::IdMapped;
use crate::disk::mount::filesystem::{FileSystem, MountType};
use crate::disk::mount::util::{is_mountpoint, unmount};
use crate::service::effects::prelude::*;
use crate::status::health_check::NamedHealthCheckResult;
use crate::util::Invoke;
@@ -110,6 +110,9 @@ pub async fn mount(
}
tokio::fs::create_dir_all(&mountpoint).await?;
if is_mountpoint(&mountpoint).await? {
unmount(&mountpoint, true).await?;
}
Command::new("chown")
.arg("100000:100000")
.arg(&mountpoint)
@@ -142,21 +145,6 @@ pub async fn get_installed_packages(context: EffectContext) -> Result<BTreeSet<P
.keys()
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct ExposeForDependentsParams {
#[ts(type = "string[]")]
paths: Vec<JsonPointer>,
}
pub async fn expose_for_dependents(
context: EffectContext,
ExposeForDependentsParams { paths }: ExposeForDependentsParams,
) -> Result<(), Error> {
// TODO
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]

View File

@@ -15,9 +15,9 @@ mod dependency;
mod health;
mod net;
mod prelude;
mod store;
mod subcontainer;
mod system;
mod version;
pub fn handler<C: Context>() -> ParentHandler<C> {
ParentHandler::new()
@@ -88,10 +88,6 @@ pub fn handler<C: Context>() -> ParentHandler<C> {
"get-installed-packages",
from_fn_async(dependency::get_installed_packages).no_cli(),
)
.subcommand(
"expose-for-dependents",
from_fn_async(dependency::expose_for_dependents).no_cli(),
)
// health
.subcommand("set-health", from_fn_async(health::set_health).no_cli())
// subcontainer
@@ -167,22 +163,15 @@ pub fn handler<C: Context>() -> ParentHandler<C> {
from_fn_async(net::ssl::get_ssl_certificate).no_cli(),
)
.subcommand("get-ssl-key", from_fn_async(net::ssl::get_ssl_key).no_cli())
// store
.subcommand(
"store",
ParentHandler::<C>::new()
.subcommand("get", from_fn_async(store::get_store).no_cli())
.subcommand("set", from_fn_async(store::set_store).no_cli()),
)
.subcommand(
"set-data-version",
from_fn_async(store::set_data_version)
from_fn_async(version::set_data_version)
.no_display()
.with_call_remote::<ContainerCliContext>(),
)
.subcommand(
"get-data-version",
from_fn_async(store::get_data_version)
from_fn_async(version::get_data_version)
.with_custom_display_fn(|_, v| {
if let Some(v) = v {
println!("{v}")

View File

@@ -1,143 +0,0 @@
use imbl::vector;
use imbl_value::json;
use models::{PackageId, VersionString};
use patch_db::json_ptr::JsonPointer;
use crate::service::effects::callbacks::CallbackHandler;
use crate::service::effects::prelude::*;
use crate::service::rpc::CallbackId;
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct GetStoreParams {
#[ts(optional)]
package_id: Option<PackageId>,
#[ts(type = "string")]
path: JsonPointer,
#[ts(optional)]
callback: Option<CallbackId>,
}
pub async fn get_store(
context: EffectContext,
GetStoreParams {
package_id,
path,
callback,
}: GetStoreParams,
) -> Result<Value, Error> {
crate::dbg!(&callback);
let context = context.deref()?;
let peeked = context.seed.ctx.db.peek().await;
let package_id = package_id.unwrap_or(context.seed.id.clone());
let value = peeked
.as_private()
.as_package_stores()
.as_idx(&package_id)
.map(|s| s.de())
.transpose()?
.unwrap_or_default();
if let Some(callback) = callback {
let callback = callback.register(&context.seed.persistent_container);
context.seed.ctx.callbacks.add_get_store(
package_id,
path.clone(),
CallbackHandler::new(&context, callback),
);
}
Ok(path.get(&value).cloned().unwrap_or_default())
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct SetStoreParams {
#[ts(type = "any")]
value: Value,
#[ts(type = "string")]
path: JsonPointer,
}
pub async fn set_store(
context: EffectContext,
SetStoreParams { value, path }: SetStoreParams,
) -> Result<(), Error> {
let context = context.deref()?;
let package_id = &context.seed.id;
let res = context
.seed
.ctx
.db
.mutate(|db| {
let model = db
.as_private_mut()
.as_package_stores_mut()
.upsert(package_id, || Ok(json!({})))?;
let mut model_value = model.de()?;
if model_value.is_null() {
model_value = json!({});
}
path.set(&mut model_value, value, true)
.with_kind(ErrorKind::ParseDbField)?;
model.ser(&model_value)
})
.await;
res.result?;
if let Some(revision) = res.revision {
if let Some(callbacks) = context.seed.ctx.callbacks.get_store(package_id, &revision) {
callbacks.call(vector![]).await?;
}
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize, TS, Parser)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct SetDataVersionParams {
#[ts(type = "string")]
version: VersionString,
}
pub async fn set_data_version(
context: EffectContext,
SetDataVersionParams { version }: SetDataVersionParams,
) -> Result<(), Error> {
let context = context.deref()?;
let package_id = &context.seed.id;
context
.seed
.ctx
.db
.mutate(|db| {
db.as_public_mut()
.as_package_data_mut()
.as_idx_mut(package_id)
.or_not_found(package_id)?
.as_data_version_mut()
.ser(&Some(version))
})
.await
.result?;
Ok(())
}
pub async fn get_data_version(context: EffectContext) -> Result<Option<VersionString>, Error> {
let context = context.deref()?;
let package_id = &context.seed.id;
context
.seed
.ctx
.db
.peek()
.await
.as_public()
.as_package_data()
.as_idx(package_id)
.or_not_found(package_id)?
.as_data_version()
.de()
}

View File

@@ -0,0 +1,51 @@
use models::VersionString;
use crate::service::effects::prelude::*;
#[derive(Debug, Clone, Serialize, Deserialize, TS, Parser)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct SetDataVersionParams {
#[ts(type = "string")]
version: VersionString,
}
pub async fn set_data_version(
context: EffectContext,
SetDataVersionParams { version }: SetDataVersionParams,
) -> Result<(), Error> {
let context = context.deref()?;
let package_id = &context.seed.id;
context
.seed
.ctx
.db
.mutate(|db| {
db.as_public_mut()
.as_package_data_mut()
.as_idx_mut(package_id)
.or_not_found(package_id)?
.as_data_version_mut()
.ser(&Some(version))
})
.await
.result?;
Ok(())
}
pub async fn get_data_version(context: EffectContext) -> Result<Option<VersionString>, Error> {
let context = context.deref()?;
let package_id = &context.seed.id;
context
.seed
.ctx
.db
.peek()
.await
.as_public()
.as_package_data()
.as_idx(package_id)
.or_not_found(package_id)?
.as_data_version()
.de()
}

View File

@@ -5,7 +5,7 @@ use std::time::Duration;
use color_eyre::eyre::eyre;
use futures::future::{BoxFuture, Fuse};
use futures::stream::FuturesUnordered;
use futures::{Future, FutureExt, StreamExt};
use futures::{Future, FutureExt, StreamExt, TryFutureExt};
use helpers::NonDetachingJoinHandle;
use imbl::OrdMap;
use imbl_value::InternedString;
@@ -332,22 +332,48 @@ impl ServiceMap {
#[instrument(skip_all)]
pub async fn uninstall(
&self,
ctx: &RpcContext,
id: &PackageId,
ctx: RpcContext,
id: PackageId,
soft: bool,
force: bool,
) -> Result<(), Error> {
let mut guard = self.get_mut(id).await;
if let Some(service) = guard.take() {
) -> Result<impl Future<Output = Result<(), Error>> + Send, Error> {
let mut guard = self.get_mut(&id).await;
ctx.db
.mutate(|db| {
let entry = db
.as_public_mut()
.as_package_data_mut()
.as_idx_mut(&id)
.or_not_found(&id)?;
entry.as_state_info_mut().map_mutate(|s| match s {
PackageState::Installed(s) => Ok(PackageState::Removing(s)),
_ => Err(Error::new(
eyre!("Package {id} is not installed."),
crate::ErrorKind::NotFound,
)),
})
})
.await
.result?;
Ok(async move {
ServiceRefReloadCancelGuard::new(ctx.clone(), id.clone(), "Uninstall", None)
.handle_last(async move {
let res = service.uninstall(None, soft, force).await;
drop(guard);
res
if let Some(service) = guard.take() {
let res = service.uninstall(None, soft, force).await;
drop(guard);
res
} else {
Err(Error::new(
eyre!("service {id} failed to initialize - cannot remove gracefully"),
ErrorKind::Uninitialized,
))
}
})
.await?;
Ok(())
}
Ok(())
.or_else(|e: Error| e.wait().map(Err)))
}
pub async fn shutdown_all(&self) -> Result<(), Error> {
@@ -412,9 +438,13 @@ impl ServiceRefReloadCancelGuard {
Ok(a) => Ok(a),
Err(e) => {
if let Some(info) = self.0.take() {
tokio::spawn(info.reload(Some(e.clone_output())));
let task_e = e.clone_output();
Err(e.with_task(tokio::spawn(async move {
info.reload(Some(task_e)).await.log_err();
})))
} else {
Err(e)
}
Err(e)
}
}
}

View File

@@ -80,11 +80,9 @@ impl std::fmt::Display for SshKeyResponse {
impl std::str::FromStr for SshPubKey {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.parse().map(|pk| SshPubKey(pk)).map_err(|e| Error {
source: e.into(),
kind: crate::ErrorKind::ParseSshKey,
revision: None,
})
s.parse()
.map(|pk| SshPubKey(pk))
.with_kind(ErrorKind::ParseSshKey)
}
}
@@ -171,11 +169,7 @@ pub async fn remove(
if keys_ref.remove(&fingerprint)?.is_some() {
keys_ref.de()
} else {
Err(Error {
source: color_eyre::eyre::eyre!("SSH Key Not Found"),
kind: crate::error::ErrorKind::NotFound,
revision: None,
})
Err(Error::new(eyre!("SSH Key Not Found"), ErrorKind::NotFound))
}
})
.await

View File

@@ -740,14 +740,13 @@ async fn get_proc_stat() -> Result<ProcStat, Error> {
.collect::<Result<Vec<u64>, Error>>()?;
if stats.len() < 10 {
Err(Error {
source: color_eyre::eyre::eyre!(
Err(Error::new(
eyre!(
"Columns missing from /proc/stat. Need 10, found {}",
stats.len()
),
kind: ErrorKind::ParseSysInfo,
revision: None,
})
ErrorKind::ParseSysInfo,
))
} else {
Ok(ProcStat {
user: stats[0],

View File

@@ -15,7 +15,6 @@ import {
RequestActionParams,
MainStatus,
} from "./osBindings"
import { StorePath } from "./util/PathBuilder"
import {
PackageId,
Dependencies,
@@ -93,8 +92,6 @@ export type Effects = {
}): Promise<string>
/** Returns a list of the ids of all installed packages */
getInstalledPackages(): Promise<string[]>
/** grants access to certain paths in the store to dependents */
exposeForDependents(options: { paths: string[] }): Promise<null>
// health
/** sets the result of a health check */
@@ -170,23 +167,6 @@ export type Effects = {
algorithm?: "ecdsa" | "ed25519"
}) => Promise<string>
// store
store: {
/** Get a value in a json like data, can be observed and subscribed */
get<Store = never, ExtractStore = unknown>(options: {
/** If there is no packageId it is assumed the current package */
packageId?: string
/** The path defaults to root level, using the [JsonPath](https://jsonpath.com/) */
path: StorePath
callback?: () => void
}): Promise<ExtractStore>
/** Used to store values that can be accessed and subscribed to */
set<Store = never, ExtractStore = unknown>(options: {
/** Sets the value for the wrapper at the path, it will override, using the [JsonPath](https://jsonpath.com/) */
path: StorePath
value: ExtractStore
}): Promise<null>
}
/** sets the version that this service's data has been migrated to */
setDataVersion(options: { version: string }): Promise<null>
/** returns the version that this service's data has been migrated to */

View File

@@ -45,18 +45,18 @@ export const runAction = async <
})
}
}
type GetActionInputType<A extends Action<T.ActionId, any, any>> =
A extends Action<T.ActionId, any, infer I> ? ExtractInputSpecType<I> : never
type GetActionInputType<A extends Action<T.ActionId, any>> =
A extends Action<T.ActionId, infer I> ? ExtractInputSpecType<I> : never
type ActionRequestBase = {
reason?: string
replayId?: string
}
type ActionRequestInput<T extends Action<T.ActionId, any, any>> = {
type ActionRequestInput<T extends Action<T.ActionId, any>> = {
kind: "partial"
value: T.DeepPartial<GetActionInputType<T>>
}
export type ActionRequestOptions<T extends Action<T.ActionId, any, any>> =
export type ActionRequestOptions<T extends Action<T.ActionId, any>> =
ActionRequestBase &
(
| {
@@ -78,7 +78,7 @@ const _validate: T.ActionRequest = {} as ActionRequestOptions<any> & {
severity: T.ActionSeverity
}
export const requestAction = <T extends Action<T.ActionId, any, any>>(options: {
export const requestAction = <T extends Action<T.ActionId, any>>(options: {
effects: T.Effects
packageId: T.PackageId
action: T

View File

@@ -5,32 +5,27 @@ import { Effects } from "../../../Effects"
import { Parser, object } from "ts-matches"
import { DeepPartial } from "../../../types"
export type LazyBuildOptions<Store> = {
export type LazyBuildOptions = {
effects: Effects
}
export type LazyBuild<Store, ExpectedOut> = (
options: LazyBuildOptions<Store>,
export type LazyBuild<ExpectedOut> = (
options: LazyBuildOptions,
) => Promise<ExpectedOut> | ExpectedOut
// prettier-ignore
export type ExtractInputSpecType<A extends Record<string, any> | InputSpec<Record<string, any>, any> | InputSpec<Record<string, any>, never>> =
A extends InputSpec<infer B, any> | InputSpec<infer B, never> ? B :
export type ExtractInputSpecType<A extends Record<string, any> | InputSpec<Record<string, any>>> =
A extends InputSpec<infer B> ? B :
A
export type ExtractPartialInputSpecType<
A extends
| Record<string, any>
| InputSpec<Record<string, any>, any>
| InputSpec<Record<string, any>, never>,
> = A extends InputSpec<infer B, any> | InputSpec<infer B, never>
? DeepPartial<B>
: DeepPartial<A>
A extends Record<string, any> | InputSpec<Record<string, any>>,
> = A extends InputSpec<infer B> ? DeepPartial<B> : DeepPartial<A>
export type InputSpecOf<A extends Record<string, any>, Store = never> = {
[K in keyof A]: Value<A[K], Store>
export type InputSpecOf<A extends Record<string, any>> = {
[K in keyof A]: Value<A[K]>
}
export type MaybeLazyValues<A> = LazyBuild<any, A> | A
export type MaybeLazyValues<A> = LazyBuild<A> | A
/**
* InputSpecs are the specs that are used by the os input specification form for this service.
* Here is an example of a simple input specification
@@ -87,16 +82,16 @@ export const addNodesSpec = InputSpec.of({ hostname: hostname, port: port });
```
*/
export class InputSpec<Type extends Record<string, any>, Store = never> {
export class InputSpec<Type extends Record<string, any>> {
private constructor(
private readonly spec: {
[K in keyof Type]: Value<Type[K], Store> | Value<Type[K], never>
[K in keyof Type]: Value<Type[K]>
},
public validator: Parser<unknown, Type>,
) {}
public _TYPE: Type = null as any as Type
public _PARTIAL: DeepPartial<Type> = null as any as DeepPartial<Type>
async build(options: LazyBuildOptions<Store>) {
async build(options: LazyBuildOptions) {
const answer = {} as {
[K in keyof Type]: ValueSpec
}
@@ -106,10 +101,7 @@ export class InputSpec<Type extends Record<string, any>, Store = never> {
return answer
}
static of<
Spec extends Record<string, Value<any, Store> | Value<any, never>>,
Store = never,
>(spec: Spec) {
static of<Spec extends Record<string, Value<any>>>(spec: Spec) {
const validatorObj = {} as {
[K in keyof Spec]: Parser<unknown, any>
}
@@ -117,33 +109,8 @@ export class InputSpec<Type extends Record<string, any>, Store = never> {
validatorObj[key] = spec[key].validator
}
const validator = object(validatorObj)
return new InputSpec<
{
[K in keyof Spec]: Spec[K] extends
| Value<infer T, Store>
| Value<infer T, never>
? T
: never
},
Store
>(spec, validator as any)
}
/**
* Use this during the times that the input needs a more specific type.
* Used in types that the value/ variant/ list/ inputSpec is constructed somewhere else.
```ts
const a = InputSpec.text({
name: "a",
required: false,
})
return InputSpec.of<Store>()({
myValue: a.withStore(),
})
```
*/
withStore<NewStore extends Store extends never ? any : Store>() {
return this as any as InputSpec<Type, NewStore>
return new InputSpec<{
[K in keyof Spec]: Spec[K] extends Value<infer T> ? T : never
}>(spec, validator as any)
}
}

View File

@@ -9,9 +9,9 @@ import {
} from "../inputSpecTypes"
import { Parser, arrayOf, string } from "ts-matches"
export class List<Type, Store> {
export class List<Type> {
private constructor(
public build: LazyBuild<Store, ValueSpecList>,
public build: LazyBuild<ValueSpecList>,
public validator: Parser<unknown, Type>,
) {}
@@ -58,7 +58,7 @@ export class List<Type, Store> {
generate?: null | RandomString
},
) {
return new List<string[], never>(() => {
return new List<string[]>(() => {
const spec = {
type: "text" as const,
placeholder: null,
@@ -85,30 +85,27 @@ export class List<Type, Store> {
}, arrayOf(string))
}
static dynamicText<Store = never>(
getA: LazyBuild<
Store,
{
name: string
description?: string | null
warning?: string | null
default?: string[]
static dynamicText(
getA: LazyBuild<{
name: string
description?: string | null
warning?: string | null
default?: string[]
minLength?: number | null
maxLength?: number | null
disabled?: false | string
generate?: null | RandomString
spec: {
masked?: boolean
placeholder?: string | null
minLength?: number | null
maxLength?: number | null
disabled?: false | string
generate?: null | RandomString
spec: {
masked?: boolean
placeholder?: string | null
minLength?: number | null
maxLength?: number | null
patterns?: Pattern[]
inputmode?: ListValueSpecText["inputmode"]
}
patterns?: Pattern[]
inputmode?: ListValueSpecText["inputmode"]
}
>,
}>,
) {
return new List<string[], Store>(async (options) => {
return new List<string[]>(async (options) => {
const { spec: aSpec, ...a } = await getA(options)
const spec = {
type: "text" as const,
@@ -136,7 +133,7 @@ export class List<Type, Store> {
}, arrayOf(string))
}
static obj<Type extends Record<string, any>, Store>(
static obj<Type extends Record<string, any>>(
a: {
name: string
description?: string | null
@@ -146,12 +143,12 @@ export class List<Type, Store> {
maxLength?: number | null
},
aSpec: {
spec: InputSpec<Type, Store>
spec: InputSpec<Type>
displayAs?: null | string
uniqueBy?: null | UniqueBy
},
) {
return new List<Type[], Store>(async (options) => {
return new List<Type[]>(async (options) => {
const { spec: previousSpecSpec, ...restSpec } = aSpec
const specSpec = await previousSpecSpec.build(options)
const spec = {
@@ -177,22 +174,4 @@ export class List<Type, Store> {
}
}, arrayOf(aSpec.spec.validator))
}
/**
* Use this during the times that the input needs a more specific type.
* Used in types that the value/ variant/ list/ inputSpec is constructed somewhere else.
```ts
const a = InputSpec.text({
name: "a",
required: false,
})
return InputSpec.of<Store>()({
myValue: a.withStore(),
})
```
*/
withStore<NewStore extends Store extends never ? any : Store>() {
return this as any as List<Type, NewStore>
}
}

View File

@@ -24,7 +24,6 @@ import {
number,
object,
string,
unknown,
} from "ts-matches"
import { DeepPartial } from "../../../types"
@@ -44,14 +43,30 @@ function asRequiredParser<
return parser.nullable() as any
}
export class Value<Type, Store> {
export class Value<Type> {
protected constructor(
public build: LazyBuild<Store, ValueSpec>,
public build: LazyBuild<ValueSpec>,
public validator: Parser<unknown, Type>,
) {}
public _TYPE: Type = null as any as Type
public _PARTIAL: DeepPartial<Type> = null as any as DeepPartial<Type>
/**
* @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,
}),
* ```
*/
static toggle(a: {
name: string
description?: string | null
@@ -64,7 +79,7 @@ export class Value<Type, Store> {
*/
immutable?: boolean
}) {
return new Value<boolean, never>(
return new Value<boolean>(
async () => ({
description: null,
warning: null,
@@ -76,19 +91,16 @@ export class Value<Type, Store> {
boolean,
)
}
static dynamicToggle<Store = never>(
a: LazyBuild<
Store,
{
name: string
description?: string | null
warning?: string | null
default: boolean
disabled?: false | string
}
>,
static dynamicToggle(
a: LazyBuild<{
name: string
description?: string | null
warning?: string | null
default: boolean
disabled?: false | string
}>,
) {
return new Value<boolean, Store>(
return new Value<boolean>(
async (options) => ({
description: null,
warning: null,
@@ -100,6 +112,30 @@ export class Value<Type, Store> {
boolean,
)
}
/**
* @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,
}),
* ```
*/
static text<Required extends boolean>(a: {
name: string
description?: string | null
@@ -151,7 +187,7 @@ export class Value<Type, Store> {
*/
generate?: RandomString | null
}) {
return new Value<AsRequired<string, Required>, never>(
return new Value<AsRequired<string, Required>>(
async () => ({
type: "text" as const,
description: null,
@@ -170,27 +206,24 @@ export class Value<Type, Store> {
asRequiredParser(string, a),
)
}
static dynamicText<Store = never>(
getA: LazyBuild<
Store,
{
name: string
description?: string | null
warning?: string | null
default: DefaultString | null
required: boolean
masked?: boolean
placeholder?: string | null
minLength?: number | null
maxLength?: number | null
patterns?: Pattern[]
inputmode?: ValueSpecText["inputmode"]
disabled?: string | false
generate?: null | RandomString
}
>,
static dynamicText(
getA: LazyBuild<{
name: string
description?: string | null
warning?: string | null
default: DefaultString | null
required: boolean
masked?: boolean
placeholder?: string | null
minLength?: number | null
maxLength?: number | null
patterns?: Pattern[]
inputmode?: ValueSpecText["inputmode"]
disabled?: string | false
generate?: null | RandomString
}>,
) {
return new Value<string | null, Store>(async (options) => {
return new Value<string | null>(async (options) => {
const a = await getA(options)
return {
type: "text" as const,
@@ -209,6 +242,26 @@ export class Value<Type, Store> {
}
}, string.nullable())
}
/**
* @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,
}),
* ```
*/
static textarea<Required extends boolean>(a: {
name: string
description?: string | null
@@ -225,7 +278,7 @@ export class Value<Type, Store> {
*/
immutable?: boolean
}) {
return new Value<AsRequired<string, Required>, never>(
return new Value<AsRequired<string, Required>>(
async () => {
const built: ValueSpecTextarea = {
description: null,
@@ -243,23 +296,20 @@ export class Value<Type, Store> {
asRequiredParser(string, a),
)
}
static dynamicTextarea<Store = never>(
getA: LazyBuild<
Store,
{
name: string
description?: string | null
warning?: string | null
default: string | null
required: boolean
minLength?: number | null
maxLength?: number | null
placeholder?: string | null
disabled?: false | string
}
>,
static dynamicTextarea(
getA: LazyBuild<{
name: string
description?: string | null
warning?: string | null
default: string | null
required: boolean
minLength?: number | null
maxLength?: number | null
placeholder?: string | null
disabled?: false | string
}>,
) {
return new Value<string | null, Store>(async (options) => {
return new Value<string | null>(async (options) => {
const a = await getA(options)
return {
description: null,
@@ -274,6 +324,29 @@ export class Value<Type, Store> {
}
}, string.nullable())
}
/**
* @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,
}),
* ```
*/
static number<Required extends boolean>(a: {
name: string
description?: string | null
@@ -309,7 +382,7 @@ export class Value<Type, Store> {
*/
immutable?: boolean
}) {
return new Value<AsRequired<number, Required>, never>(
return new Value<AsRequired<number, Required>>(
() => ({
type: "number" as const,
description: null,
@@ -326,26 +399,23 @@ export class Value<Type, Store> {
asRequiredParser(number, a),
)
}
static dynamicNumber<Store = never>(
getA: LazyBuild<
Store,
{
name: string
description?: string | null
warning?: string | null
default: number | null
required: boolean
min?: number | null
max?: number | null
step?: number | null
integer: boolean
units?: string | null
placeholder?: string | null
disabled?: false | string
}
>,
static dynamicNumber(
getA: LazyBuild<{
name: string
description?: string | null
warning?: string | null
default: number | null
required: boolean
min?: number | null
max?: number | null
step?: number | null
integer: boolean
units?: string | null
placeholder?: string | null
disabled?: false | string
}>,
) {
return new Value<number | null, Store>(async (options) => {
return new Value<number | null>(async (options) => {
const a = await getA(options)
return {
type: "number" as const,
@@ -362,6 +432,23 @@ export class Value<Type, Store> {
}
}, number.nullable())
}
/**
* @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,
}),
* ```
*/
static color<Required extends boolean>(a: {
name: string
description?: string | null
@@ -381,7 +468,7 @@ export class Value<Type, Store> {
*/
immutable?: boolean
}) {
return new Value<AsRequired<string, Required>, never>(
return new Value<AsRequired<string, Required>>(
() => ({
type: "color" as const,
description: null,
@@ -394,20 +481,17 @@ export class Value<Type, Store> {
)
}
static dynamicColor<Store = never>(
getA: LazyBuild<
Store,
{
name: string
description?: string | null
warning?: string | null
default: string | null
required: boolean
disabled?: false | string
}
>,
static dynamicColor(
getA: LazyBuild<{
name: string
description?: string | null
warning?: string | null
default: string | null
required: boolean
disabled?: false | string
}>,
) {
return new Value<string | null, Store>(async (options) => {
return new Value<string | null>(async (options) => {
const a = await getA(options)
return {
type: "color" as const,
@@ -419,6 +503,26 @@ export class Value<Type, Store> {
}
}, string.nullable())
}
/**
* @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,
}),
* ```
*/
static datetime<Required extends boolean>(a: {
name: string
description?: string | null
@@ -445,7 +549,7 @@ export class Value<Type, Store> {
*/
immutable?: boolean
}) {
return new Value<AsRequired<string, Required>, never>(
return new Value<AsRequired<string, Required>>(
() => ({
type: "datetime" as const,
description: null,
@@ -461,23 +565,20 @@ export class Value<Type, Store> {
asRequiredParser(string, a),
)
}
static dynamicDatetime<Store = never>(
getA: LazyBuild<
Store,
{
name: string
description?: string | null
warning?: string | null
default: string | null
required: boolean
inputmode?: ValueSpecDatetime["inputmode"]
min?: string | null
max?: string | null
disabled?: false | string
}
>,
static dynamicDatetime(
getA: LazyBuild<{
name: string
description?: string | null
warning?: string | null
default: string | null
required: boolean
inputmode?: ValueSpecDatetime["inputmode"]
min?: string | null
max?: string | null
disabled?: false | string
}>,
) {
return new Value<string | null, Store>(async (options) => {
return new Value<string | null>(async (options) => {
const a = await getA(options)
return {
type: "datetime" as const,
@@ -492,6 +593,27 @@ export class Value<Type, Store> {
}
}, string.nullable())
}
/**
* @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,
}),
* ```
*/
static select<Values extends Record<string, string>>(a: {
name: string
description?: string | null
@@ -522,7 +644,7 @@ export class Value<Type, Store> {
*/
immutable?: boolean
}) {
return new Value<keyof Values & string, never>(
return new Value<keyof Values & string>(
() => ({
description: null,
warning: null,
@@ -536,20 +658,17 @@ export class Value<Type, Store> {
),
)
}
static dynamicSelect<Store = never>(
getA: LazyBuild<
Store,
{
name: string
description?: string | null
warning?: string | null
default: string
values: Record<string, string>
disabled?: false | string | string[]
}
>,
static dynamicSelect(
getA: LazyBuild<{
name: string
description?: string | null
warning?: string | null
default: string
values: Record<string, string>
disabled?: false | string | string[]
}>,
) {
return new Value<string, Store>(async (options) => {
return new Value<string>(async (options) => {
const a = await getA(options)
return {
description: null,
@@ -561,6 +680,29 @@ export class Value<Type, Store> {
}
}, string)
}
/**
* @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,
}),
* ```
*/
static multiselect<Values extends Record<string, string>>(a: {
name: string
description?: string | null
@@ -590,7 +732,7 @@ export class Value<Type, Store> {
*/
immutable?: boolean
}) {
return new Value<(keyof Values)[], never>(
return new Value<(keyof Values)[]>(
() => ({
type: "multiselect" as const,
minLength: null,
@@ -606,22 +748,19 @@ export class Value<Type, Store> {
),
)
}
static dynamicMultiselect<Store = never>(
getA: LazyBuild<
Store,
{
name: string
description?: string | null
warning?: string | null
default: string[]
values: Record<string, string>
minLength?: number | null
maxLength?: number | null
disabled?: false | string | string[]
}
>,
static dynamicMultiselect(
getA: LazyBuild<{
name: string
description?: string | null
warning?: string | null
default: string[]
values: Record<string, string>
minLength?: number | null
maxLength?: number | null
disabled?: false | string | string[]
}>,
) {
return new Value<string[], Store>(async (options) => {
return new Value<string[]>(async (options) => {
const a = await getA(options)
return {
type: "multiselect" as const,
@@ -635,14 +774,31 @@ export class Value<Type, Store> {
}
}, arrayOf(string))
}
static object<Type extends Record<string, any>, Store>(
/**
* @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({}),
),
* ```
*/
static object<Type extends Record<string, any>>(
a: {
name: string
description?: string | null
},
spec: InputSpec<Type, Store>,
spec: InputSpec<Type>,
) {
return new Value<Type, Store>(async (options) => {
return new Value<Type>(async (options) => {
const built = await spec.build(options as any)
return {
type: "object" as const,
@@ -694,14 +850,42 @@ export class Value<Type, Store> {
// object({ filePath: string }).nullable(),
// )
// }
/**
* @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({}),
},
}),
),
* ```
*/
static union<
VariantValues extends {
[K in string]: {
name: string
spec: InputSpec<any, Store> | InputSpec<any, never>
spec: InputSpec<any>
}
},
Store,
>(
a: {
name: string
@@ -720,9 +904,9 @@ export class Value<Type, Store> {
*/
immutable?: boolean
},
aVariants: Variants<VariantValues, Store>,
aVariants: Variants<VariantValues>,
) {
return new Value<typeof aVariants.validator._TYPE, Store>(
return new Value<typeof aVariants.validator._TYPE>(
async (options) => ({
type: "union" as const,
description: null,
@@ -739,21 +923,20 @@ export class Value<Type, Store> {
VariantValues extends {
[K in string]: {
name: string
spec: InputSpec<any, Store> | InputSpec<any, never>
spec: InputSpec<any>
}
},
Store,
>(
getDisabledFn: LazyBuild<Store, string[] | false | string>,
getDisabledFn: LazyBuild<string[] | false | string>,
a: {
name: string
description?: string | null
warning?: string | null
default: keyof VariantValues & string
},
aVariants: Variants<VariantValues, Store> | Variants<VariantValues, never>,
aVariants: Variants<VariantValues>,
) {
return new Value<typeof aVariants.validator._TYPE, Store>(
return new Value<typeof aVariants.validator._TYPE>(
async (options) => ({
type: "union" as const,
description: null,
@@ -770,45 +953,107 @@ export class Value<Type, Store> {
VariantValues extends {
[K in string]: {
name: string
spec: InputSpec<any, Store> | InputSpec<any, never>
spec: InputSpec<any>
}
},
Store,
>(
getA: LazyBuild<
Store,
{
name: string
description?: string | null
warning?: string | null
default: keyof VariantValues & string
disabled: string[] | false | string
}
>,
aVariants: Variants<VariantValues, Store> | Variants<VariantValues, never>,
getA: LazyBuild<{
name: string
description?: string | null
warning?: string | null
default: keyof VariantValues & string
disabled: string[] | false | string
}>,
aVariants: Variants<VariantValues>,
) {
return new Value<typeof aVariants.validator._TYPE, Store>(
async (options) => {
const newValues = await getA(options)
return {
type: "union" as const,
return new Value<typeof aVariants.validator._TYPE>(async (options) => {
const newValues = await getA(options)
return {
type: "union" as const,
description: null,
warning: null,
...newValues,
variants: await aVariants.build(options as any),
immutable: false,
}
}, aVariants.validator)
}
/**
* @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,
...newValues,
variants: await aVariants.build(options as any),
immutable: false,
}
},
aVariants.validator,
)
}
static list<Type, Store>(a: List<Type, Store>) {
return new Value<Type, Store>((options) => a.build(options), a.validator)
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,
},
),
),
* ```
*/
static list<Type>(a: List<Type>) {
return new Value<Type>((options) => a.build(options), a.validator)
}
/**
* @description Provides a way to define a hidden field with a static value. Useful for tracking
* @example
* ```
hiddenExample: Value.hidden(),
* ```
*/
static hidden<T>(parser: Parser<unknown, T> = any) {
return new Value<T, never>(async () => {
return new Value<T>(async () => {
const built: ValueSpecHidden = {
type: "hidden" as const,
}
@@ -816,25 +1061,7 @@ export class Value<Type, Store> {
}, parser)
}
map<U>(fn: (value: Type) => U): Value<U, Store> {
map<U>(fn: (value: Type) => U): Value<U> {
return new Value(this.build, this.validator.map(fn))
}
/**
* Use this during the times that the input needs a more specific type.
* Used in types that the value/ variant/ list/ inputSpec is constructed somewhere else.
```ts
const a = InputSpec.text({
name: "a",
required: false,
})
return InputSpec.of<Store>()({
myValue: a.withStore(),
})
```
*/
withStore<NewStore extends Store extends never ? any : Store>() {
return this as any as Value<Type, NewStore>
}
}

View File

@@ -9,11 +9,10 @@ import {
import { Parser, anyOf, literal, object } from "ts-matches"
export type UnionRes<
Store,
VariantValues extends {
[K in string]: {
name: string
spec: InputSpec<any, Store> | InputSpec<any, never>
spec: InputSpec<any>
}
},
K extends keyof VariantValues & string = keyof VariantValues & string,
@@ -81,23 +80,21 @@ export class Variants<
VariantValues extends {
[K in string]: {
name: string
spec: InputSpec<any, Store> | InputSpec<any, never>
spec: InputSpec<any>
}
},
Store,
> {
private constructor(
public build: LazyBuild<Store, ValueSpecUnion["variants"]>,
public validator: Parser<unknown, UnionRes<Store, VariantValues>>,
public build: LazyBuild<ValueSpecUnion["variants"]>,
public validator: Parser<unknown, UnionRes<VariantValues>>,
) {}
static of<
VariantValues extends {
[K in string]: {
name: string
spec: InputSpec<any, Store> | InputSpec<any, never>
spec: InputSpec<any>
}
},
Store = never,
>(a: VariantValues) {
const validator = anyOf(
...Object.entries(a).map(([id, { spec }]) =>
@@ -108,7 +105,7 @@ export class Variants<
),
) as Parser<unknown, any>
return new Variants<VariantValues, Store>(async (options) => {
return new Variants<VariantValues>(async (options) => {
const variants = {} as {
[K in keyof VariantValues]: {
name: string
@@ -125,21 +122,4 @@ export class Variants<
return variants
}, validator)
}
/**
* Use this during the times that the input needs a more specific type.
* Used in types that the value/ variant/ list/ inputSpec is constructed somewhere else.
```ts
const a = InputSpec.text({
name: "a",
required: false,
})
return InputSpec.of<Store>()({
myValue: a.withStore(),
})
```
*/
withStore<NewStore extends Store extends never ? any : Store>() {
return this as any as Variants<VariantValues, NewStore>
}
}

View File

@@ -7,7 +7,7 @@ import { Variants } from "./builder/variants"
/**
* Base SMTP settings, to be used by StartOS for system wide SMTP
*/
export const customSmtp = InputSpec.of<InputSpecOf<SmtpValue>, never>({
export const customSmtp = InputSpec.of<InputSpecOf<SmtpValue>>({
server: Value.text({
name: "SMTP Server",
required: true,

View File

@@ -7,19 +7,13 @@ import * as T from "../types"
import { once } from "../util"
export type Run<
A extends
| Record<string, any>
| InputSpec<Record<string, any>, any>
| InputSpec<Record<string, never>, never>,
A extends Record<string, any> | InputSpec<Record<string, any>>,
> = (options: {
effects: T.Effects
input: ExtractInputSpecType<A>
}) => Promise<(T.ActionResult & { version: "1" }) | null | void | undefined>
export type GetInput<
A extends
| Record<string, any>
| InputSpec<Record<string, any>, any>
| InputSpec<Record<string, any>, never>,
A extends Record<string, any> | InputSpec<Record<string, any>>,
> = (options: {
effects: T.Effects
}) => Promise<null | void | undefined | ExtractPartialInputSpecType<A>>
@@ -48,11 +42,7 @@ function mapMaybeFn<T, U>(
export class Action<
Id extends T.ActionId,
Store,
InputSpecType extends
| Record<string, any>
| InputSpec<any, Store>
| InputSpec<any, never>,
InputSpecType extends Record<string, any> | InputSpec<any>,
> {
private constructor(
readonly id: Id,
@@ -63,18 +53,14 @@ export class Action<
) {}
static withInput<
Id extends T.ActionId,
Store,
InputSpecType extends
| Record<string, any>
| InputSpec<any, Store>
| InputSpec<any, never>,
InputSpecType extends Record<string, any> | InputSpec<any>,
>(
id: Id,
metadata: MaybeFn<Omit<T.ActionMetadata, "hasInput">>,
inputSpec: InputSpecType,
getInput: GetInput<InputSpecType>,
run: Run<InputSpecType>,
): Action<Id, Store, InputSpecType> {
): Action<Id, InputSpecType> {
return new Action(
id,
mapMaybeFn(metadata, (m) => ({ ...m, hasInput: true })),
@@ -83,11 +69,11 @@ export class Action<
run,
)
}
static withoutInput<Id extends T.ActionId, Store>(
static withoutInput<Id extends T.ActionId>(
id: Id,
metadata: MaybeFn<Omit<T.ActionMetadata, "hasInput">>,
run: Run<{}>,
): Action<Id, Store, {}> {
): Action<Id, {}> {
return new Action(
id,
mapMaybeFn(metadata, (m) => ({ ...m, hasInput: false })),
@@ -124,16 +110,15 @@ export class Action<
}
export class Actions<
Store,
AllActions extends Record<T.ActionId, Action<T.ActionId, Store, any>>,
AllActions extends Record<T.ActionId, Action<T.ActionId, any>>,
> {
private constructor(private readonly actions: AllActions) {}
static of<Store>(): Actions<Store, {}> {
static of(): Actions<{}> {
return new Actions({})
}
addAction<A extends Action<T.ActionId, Store, any>>(
action: A,
): Actions<Store, AllActions & { [id in A["id"]]: A }> {
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 })
}
async update(options: { effects: T.Effects }): Promise<null> {

View File

@@ -90,7 +90,7 @@ export class Backups<M extends T.Manifest> {
return this
}
addVolume(
mountVolume(
volume: M["volumes"][number],
options?: Partial<{
options: T.SyncOptions

View File

@@ -1,3 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ExposeForDependentsParams = { paths: string[] }

View File

@@ -1,9 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CallbackId } from "./CallbackId"
import type { PackageId } from "./PackageId"
export type GetStoreParams = {
packageId?: PackageId
path: string
callback?: CallbackId
}

View File

@@ -1,3 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type SetStoreParams = { value: any; path: string }

View File

@@ -77,7 +77,6 @@ export { EditSignerParams } from "./EditSignerParams"
export { EncryptedWire } from "./EncryptedWire"
export { ExportActionParams } from "./ExportActionParams"
export { ExportServiceInterfaceParams } from "./ExportServiceInterfaceParams"
export { ExposeForDependentsParams } from "./ExposeForDependentsParams"
export { FileType } from "./FileType"
export { ForgetInterfaceParams } from "./ForgetInterfaceParams"
export { FullIndex } from "./FullIndex"
@@ -95,7 +94,6 @@ export { GetServicePortForwardParams } from "./GetServicePortForwardParams"
export { GetSslCertificateParams } from "./GetSslCertificateParams"
export { GetSslKeyParams } from "./GetSslKeyParams"
export { GetStatusParams } from "./GetStatusParams"
export { GetStoreParams } from "./GetStoreParams"
export { GetSystemSmtpParams } from "./GetSystemSmtpParams"
export { GigaBytes } from "./GigaBytes"
export { GitHash } from "./GitHash"
@@ -195,7 +193,6 @@ export { SetIconParams } from "./SetIconParams"
export { SetMainStatusStatus } from "./SetMainStatusStatus"
export { SetMainStatus } from "./SetMainStatus"
export { SetNameParams } from "./SetNameParams"
export { SetStoreParams } from "./SetStoreParams"
export { SetupExecuteParams } from "./SetupExecuteParams"
export { SetupProgress } from "./SetupProgress"
export { SetupResult } from "./SetupResult"

View File

@@ -19,7 +19,6 @@ import { DestroySubcontainerFsParams } from ".././osBindings"
import { BindParams } from ".././osBindings"
import { GetHostInfoParams } from ".././osBindings"
import { SetHealth } from ".././osBindings"
import { ExposeForDependentsParams } from ".././osBindings"
import { GetSslCertificateParams } from ".././osBindings"
import { GetSslKeyParams } from ".././osBindings"
import { GetServiceInterfaceParams } from ".././osBindings"
@@ -71,15 +70,10 @@ describe("startosTypeValidation ", () => {
setDataVersion: {} as SetDataVersionParams,
getDataVersion: undefined,
setHealth: {} as SetHealth,
exposeForDependents: {} as ExposeForDependentsParams,
getSslCertificate: {} as WithCallback<GetSslCertificateParams>,
getSslKey: {} as GetSslKeyParams,
getServiceInterface: {} as WithCallback<GetServiceInterfaceParams>,
setDependencies: {} as SetDependenciesParams,
store: {
get: {} as any, // as GetStoreParams,
set: {} as any, // as SetStoreParams,
},
getSystemSmtp: {} as WithCallback<GetSystemSmtpParams>,
getContainerIp: {} as WithCallback<GetContainerIpParams>,
getOsIp: undefined,

View File

@@ -19,8 +19,6 @@ export {
CurrentDependenciesResult,
} from "./dependencies/setupDependencies"
export type ExposedStorePaths = string[] & Affine<"ExposedStorePaths">
export type DaemonBuildable = {
build(): Promise<{
term(): Promise<void>
@@ -85,10 +83,7 @@ export namespace ExpectedExports {
export type manifest = Manifest
export type actions = Actions<
any,
Record<ActionId, Action<ActionId, any, any>>
>
export type actions = Actions<Record<ActionId, Action<ActionId, any>>>
}
export type ABI = {
createBackup: ExpectedExports.createBackup
@@ -141,10 +136,6 @@ export type Hostname = string & { [hostName]: never }
export type ServiceInterfaceId = string
export { ServiceInterface }
export type ExposeServicePaths<Store = never> = {
/** The path to the value in the Store. [JsonPath](https://jsonpath.com/) */
paths: ExposedStorePaths
}
export type EffectMethod<T extends StringObject = Effects> = {
[K in keyof T]-?: K extends string

View File

@@ -1,38 +0,0 @@
import { Affine } from "../util"
const pathValue = Symbol("pathValue")
export type PathValue = typeof pathValue
export type PathBuilderStored<AllStore, Store> = {
[K in PathValue]: [AllStore, Store]
}
export type PathBuilder<AllStore, Store = AllStore> = (Store extends Record<
string,
unknown
>
? {
[K in keyof Store]: PathBuilder<AllStore, Store[K]>
}
: {}) &
PathBuilderStored<AllStore, Store>
export type StorePath = string & Affine<"StorePath">
const privateSymbol = Symbol("jsonPath")
export const extractJsonPath = (builder: PathBuilder<unknown>) => {
return (builder as any)[privateSymbol] as StorePath
}
export const pathBuilder = <Store, StorePath = Store>(
paths: string[] = [],
): PathBuilder<Store, StorePath> => {
return new Proxy({} as PathBuilder<Store, StorePath>, {
get(target, prop) {
if (prop === privateSymbol) {
if (paths.length === 0) return ""
return `/${paths.join("/")}`
}
return pathBuilder<any>([...paths, prop as string])
},
}) as PathBuilder<Store, StorePath>
}

View File

@@ -17,6 +17,5 @@ export { nullIfEmpty } from "./nullIfEmpty"
export { deepMerge, partialDiff } from "./deepMerge"
export { deepEqual } from "./deepEqual"
export { hostnameInfoToAddress } from "./Hostname"
export { PathBuilder, extractJsonPath, StorePath } from "./PathBuilder"
export * as regexes from "./regexes"
export { stringFromStdErrOut } from "./stringFromStdErrOut"

View File

@@ -340,7 +340,7 @@ export class Mounts&lt;Manifest extends T.Manifest&gt; {
<span class="cstat-no" title="statement not covered" > return new Mounts&lt;Manifest&gt;([], [], [])</span>
}
&nbsp;
<span class="fstat-no" title="function not covered" > addVolume(</span>
<span class="fstat-no" title="function not covered" > mountVolume(</span>
id: Manifest["volumes"][number],
subpath: string | null,
mountpoint: string,
@@ -355,7 +355,7 @@ export class Mounts&lt;Manifest extends T.Manifest&gt; {
<span class="cstat-no" title="statement not covered" > return this</span>
}
&nbsp;
<span class="fstat-no" title="function not covered" > addAssets(</span>
<span class="fstat-no" title="function not covered" > mountAssets(</span>
id: Manifest["assets"][number],
subpath: string | null,
mountpoint: string,
@@ -368,7 +368,7 @@ export class Mounts&lt;Manifest extends T.Manifest&gt; {
<span class="cstat-no" title="statement not covered" > return this</span>
}
&nbsp;
<span class="fstat-no" title="function not covered" > addDependency&lt;</span>DependencyManifest extends T.Manifest&gt;(
<span class="fstat-no" title="function not covered" > mountDependency&lt;</span>DependencyManifest extends T.Manifest&gt;(
dependencyId: keyof Manifest["dependencies"] &amp; string,
volumeId: DependencyManifest["volumes"][number],
subpath: string | null,

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

View File

@@ -1,12 +1,12 @@
{
"name": "@start9labs/start-sdk",
"version": "0.4.0-beta.18",
"version": "0.4.0-beta.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@start9labs/start-sdk",
"version": "0.4.0-beta.18",
"version": "0.4.0-beta.20",
"license": "MIT",
"dependencies": {
"@iarna/toml": "^3.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@start9labs/start-sdk",
"version": "0.4.0-beta.18",
"version": "0.4.0-beta.20",
"description": "Software development kit to facilitate packaging services for StartOS",
"main": "./package/lib/index.js",
"types": "./package/lib/index.d.ts",

View File

@@ -1,9 +1,7 @@
import { ISB } from '@start9labs/start-sdk'
export async function configBuilderToSpec(
builder:
| ISB.InputSpec<Record<string, unknown>, unknown>
| ISB.InputSpec<Record<string, unknown>, never>,
builder: ISB.InputSpec<Record<string, unknown>>,
) {
return builder.build({} as any)
}