Merge branch 'next/minor' of github.com:Start9Labs/start-os into next/major

This commit is contained in:
Matt Hill
2024-11-25 19:02:07 -07:00
712 changed files with 83068 additions and 9240 deletions

5
sdk/package/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.vscode
dist/
node_modules/
lib/coverage
lib/test/output.ts

1
sdk/package/.npmignore Normal file
View File

@@ -0,0 +1 @@
node_modules/

View File

@@ -0,0 +1 @@
/lib/exver/exver.ts

21
sdk/package/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Start9 Labs
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

18
sdk/package/README.md Normal file
View File

@@ -0,0 +1,18 @@
# Start SDK
## Config Conversion
- Copy the old config json (from the getConfig.ts)
- Install the start-sdk with `npm i`
- paste the config into makeOutput.ts::oldSpecToBuilder (second param)
- Make the third param
```ts
{
StartSdk: "start-sdk/lib",
}
```
- run the script `npm run buildOutput` to make the output.ts
- Copy this whole file into startos/procedures/config/spec.ts
- Fix all the TODO

View File

@@ -0,0 +1,8 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: "ts-jest",
automock: false,
testEnvironment: "node",
rootDir: "./lib/",
modulePathIgnorePatterns: ["./dist/"],
}

1369
sdk/package/lib/StartSdk.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,208 @@
import * as T from "../../../base/lib/types"
import * as child_process from "child_process"
import { asError } from "../util"
export const DEFAULT_OPTIONS: T.SyncOptions = {
delete: true,
exclude: [],
}
export type BackupSync<Volumes extends string> = {
dataPath: `/media/startos/volumes/${Volumes}/${string}`
backupPath: `/media/startos/backup/${string}`
options?: Partial<T.SyncOptions>
backupOptions?: Partial<T.SyncOptions>
restoreOptions?: Partial<T.SyncOptions>
}
/**
* This utility simplifies the volume backup process.
* ```ts
* export const { createBackup, restoreBackup } = Backups.volumes("main").build();
* ```
*
* Changing the options of the rsync, (ie excludes) use either
* ```ts
* Backups.volumes("main").set_options({exclude: ['bigdata/']}).volumes('excludedVolume').build()
* // or
* Backups.with_options({exclude: ['bigdata/']}).volumes('excludedVolume').build()
* ```
*
* Using the more fine control, using the addSets for more control
* ```ts
* Backups.addSets({
* srcVolume: 'main', srcPath:'smallData/', dstPath: 'main/smallData/', dstVolume: : Backups.BACKUP
* }, {
* srcVolume: 'main', srcPath:'bigData/', dstPath: 'main/bigData/', dstVolume: : Backups.BACKUP, options: {exclude:['bigData/excludeThis']}}
* ).build()q
* ```
*/
export class Backups<M extends T.SDKManifest> {
private constructor(
private options = DEFAULT_OPTIONS,
private restoreOptions: Partial<T.SyncOptions> = {},
private backupOptions: Partial<T.SyncOptions> = {},
private backupSet = [] as BackupSync<M["volumes"][number]>[],
) {}
static withVolumes<M extends T.SDKManifest = never>(
...volumeNames: Array<M["volumes"][number]>
): Backups<M> {
return Backups.withSyncs(
...volumeNames.map((srcVolume) => ({
dataPath: `/media/startos/volumes/${srcVolume}/` as const,
backupPath: `/media/startos/backup/${srcVolume}/` as const,
})),
)
}
static withSyncs<M extends T.SDKManifest = never>(
...syncs: BackupSync<M["volumes"][number]>[]
) {
return syncs.reduce((acc, x) => acc.addSync(x), new Backups<M>())
}
static withOptions<M extends T.SDKManifest = never>(
options?: Partial<T.SyncOptions>,
) {
return new Backups<M>({ ...DEFAULT_OPTIONS, ...options })
}
setOptions(options?: Partial<T.SyncOptions>) {
this.options = {
...this.options,
...options,
}
return this
}
setBackupOptions(options?: Partial<T.SyncOptions>) {
this.backupOptions = {
...this.backupOptions,
...options,
}
return this
}
setRestoreOptions(options?: Partial<T.SyncOptions>) {
this.restoreOptions = {
...this.restoreOptions,
...options,
}
return this
}
addVolume(
volume: M["volumes"][number],
options?: Partial<{
options: T.SyncOptions
backupOptions: T.SyncOptions
restoreOptions: T.SyncOptions
}>,
) {
return this.addSync({
dataPath: `/media/startos/volumes/${volume}/` as const,
backupPath: `/media/startos/backup/${volume}/` as const,
...options,
})
}
addSync(sync: BackupSync<M["volumes"][0]>) {
this.backupSet.push({
...sync,
options: { ...this.options, ...sync.options },
})
return this
}
async createBackup() {
for (const item of this.backupSet) {
const rsyncResults = await runRsync({
srcPath: item.dataPath,
dstPath: item.backupPath,
options: {
...this.options,
...this.backupOptions,
...item.options,
...item.backupOptions,
},
})
await rsyncResults.wait()
}
return
}
async restoreBackup() {
for (const item of this.backupSet) {
const rsyncResults = await runRsync({
srcPath: item.backupPath,
dstPath: item.dataPath,
options: {
...this.options,
...this.backupOptions,
...item.options,
...item.backupOptions,
},
})
await rsyncResults.wait()
}
return
}
}
async function runRsync(rsyncOptions: {
srcPath: string
dstPath: string
options: T.SyncOptions
}): Promise<{
id: () => Promise<string>
wait: () => Promise<null>
progress: () => Promise<number>
}> {
const { srcPath, dstPath, options } = rsyncOptions
const command = "rsync"
const args: string[] = []
if (options.delete) {
args.push("--delete")
}
for (const exclude of options.exclude) {
args.push(`--exclude=${exclude}`)
}
args.push("-actAXH")
args.push("--info=progress2")
args.push("--no-inc-recursive")
args.push(srcPath)
args.push(dstPath)
const spawned = child_process.spawn(command, args, { detached: true })
let percentage = 0.0
spawned.stdout.on("data", (data: unknown) => {
const lines = String(data).replace("\r", "\n").split("\n")
for (const line of lines) {
const parsed = /$([0-9.]+)%/.exec(line)?.[1]
if (!parsed) continue
percentage = Number.parseFloat(parsed)
}
})
spawned.stderr.on("data", (data: unknown) => {
console.error(`Backups.runAsync`, asError(data))
})
const id = async () => {
const pid = spawned.pid
if (pid === undefined) {
throw new Error("rsync process has no pid")
}
return String(pid)
}
const waitPromise = new Promise<null>((resolve, reject) => {
spawned.on("exit", (code: any) => {
if (code === 0) {
resolve(null)
} else {
reject(new Error(`rsync exited with code ${code}`))
}
})
})
const wait = () => waitPromise
const progress = () => Promise.resolve(percentage)
return { id, wait, progress }
}

View File

@@ -0,0 +1,2 @@
import "./Backups"
import "./setupBackups"

View File

@@ -0,0 +1,39 @@
import { Backups } from "./Backups"
import * as T from "../../../base/lib/types"
import { _ } from "../util"
export type SetupBackupsParams<M extends T.SDKManifest> =
| M["volumes"][number][]
| ((_: { effects: T.Effects }) => Promise<Backups<M>>)
type SetupBackupsRes = {
createBackup: T.ExpectedExports.createBackup
restoreBackup: T.ExpectedExports.restoreBackup
}
export function setupBackups<M extends T.SDKManifest>(
options: SetupBackupsParams<M>,
) {
let backupsFactory: (_: { effects: T.Effects }) => Promise<Backups<M>>
if (options instanceof Function) {
backupsFactory = options
} else {
backupsFactory = async () => Backups.withVolumes(...options)
}
const answer: {
createBackup: T.ExpectedExports.createBackup
restoreBackup: T.ExpectedExports.restoreBackup
} = {
get createBackup() {
return (async (options) => {
return (await backupsFactory(options)).createBackup()
}) as T.ExpectedExports.createBackup
},
get restoreBackup() {
return (async (options) => {
return (await backupsFactory(options)).restoreBackup()
}) as T.ExpectedExports.restoreBackup
},
}
return answer
}

View File

@@ -0,0 +1,64 @@
import { Effects, HealthReceipt } from "../../../base/lib/types"
import { HealthCheckResult } from "./checkFns/HealthCheckResult"
import { Trigger } from "../trigger"
import { TriggerInput } from "../trigger/TriggerInput"
import { defaultTrigger } from "../trigger/defaultTrigger"
import { once, asError } from "../util"
import { object, unknown } from "ts-matches"
export type HealthCheckParams = {
effects: Effects
name: string
trigger?: Trigger
fn(): Promise<HealthCheckResult> | HealthCheckResult
onFirstSuccess?: () => unknown | Promise<unknown>
}
export function healthCheck(o: HealthCheckParams) {
new Promise(async () => {
let currentValue: TriggerInput = {}
const getCurrentValue = () => currentValue
const trigger = (o.trigger ?? defaultTrigger)(getCurrentValue)
const triggerFirstSuccess = once(() =>
Promise.resolve(
"onFirstSuccess" in o && o.onFirstSuccess
? o.onFirstSuccess()
: undefined,
),
)
for (
let res = await trigger.next();
!res.done;
res = await trigger.next()
) {
try {
const { result, message } = await o.fn()
await o.effects.setHealth({
name: o.name,
id: o.name,
result,
message: message || "",
})
currentValue.lastResult = result
await triggerFirstSuccess().catch((err) => {
console.error(asError(err))
})
} catch (e) {
await o.effects.setHealth({
name: o.name,
id: o.name,
result: "failure",
message: asMessage(e) || "",
})
currentValue.lastResult = "failure"
}
}
})
return {} as HealthReceipt
}
function asMessage(e: unknown) {
if (object({ message: unknown }).test(e)) return String(e.message)
const value = String(e)
if (value.length == null) return null
return value
}

View File

@@ -0,0 +1,3 @@
import { T } from "../../../../base/lib"
export type HealthCheckResult = Omit<T.NamedHealthCheckResult, "name">

View File

@@ -0,0 +1,67 @@
import { Effects } from "../../../../base/lib/types"
import { stringFromStdErrOut } from "../../util"
import { HealthCheckResult } from "./HealthCheckResult"
import { promisify } from "node:util"
import * as CP from "node:child_process"
const cpExec = promisify(CP.exec)
const cpExecFile = promisify(CP.execFile)
export function containsAddress(x: string, port: number) {
const readPorts = x
.split("\n")
.filter(Boolean)
.splice(1)
.map((x) => x.split(" ").filter(Boolean)[1]?.split(":")?.[1])
.filter(Boolean)
.map((x) => Number.parseInt(x, 16))
.filter(Number.isFinite)
return readPorts.indexOf(port) >= 0
}
/**
* This is used to check if a port is listening on the system.
* Used during the health check fn or the check main fn.
*/
export async function checkPortListening(
effects: Effects,
port: number,
options: {
errorMessage: string
successMessage: string
timeoutMessage?: string
timeout?: number
},
): Promise<HealthCheckResult> {
return Promise.race<HealthCheckResult>([
Promise.resolve().then(async () => {
const hasAddress =
containsAddress(
await cpExec(`cat /proc/net/tcp`, {}).then(stringFromStdErrOut),
port,
) ||
containsAddress(
await cpExec("cat /proc/net/udp", {}).then(stringFromStdErrOut),
port,
)
if (hasAddress) {
return { result: "success", message: options.successMessage }
}
return {
result: "failure",
message: options.errorMessage,
}
}),
new Promise((resolve) => {
setTimeout(
() =>
resolve({
result: "failure",
message:
options.timeoutMessage || `Timeout trying to check port ${port}`,
}),
options.timeout ?? 1_000,
)
}),
])
}

View File

@@ -0,0 +1,36 @@
import { Effects } from "../../../../base/lib/types"
import { asError } from "../../util"
import { HealthCheckResult } from "./HealthCheckResult"
import { timeoutPromise } from "./index"
import "isomorphic-fetch"
/**
* This is a helper function to check if a web url is reachable.
* @param url
* @param createSuccess
* @returns
*/
export const checkWebUrl = async (
effects: Effects,
url: string,
{
timeout = 1000,
successMessage = `Reached ${url}`,
errorMessage = `Error while fetching URL: ${url}`,
} = {},
): Promise<HealthCheckResult> => {
return Promise.race([fetch(url), timeoutPromise(timeout)])
.then(
(x) =>
({
result: "success",
message: successMessage,
}) as const,
)
.catch((e) => {
console.warn(`Error while fetching URL: ${url}`)
console.error(JSON.stringify(e))
console.error(asError(e))
return { result: "failure" as const, message: errorMessage }
})
}

View File

@@ -0,0 +1,11 @@
import { runHealthScript } from "./runHealthScript"
export { checkPortListening } from "./checkPortListening"
export { HealthCheckResult } from "./HealthCheckResult"
export { checkWebUrl } from "./checkWebUrl"
export function timeoutPromise(ms: number, { message = "Timed out" } = {}) {
return new Promise<never>((resolve, reject) =>
setTimeout(() => reject(new Error(message)), ms),
)
}
export { runHealthScript }

View File

@@ -0,0 +1,35 @@
import { HealthCheckResult } from "./HealthCheckResult"
import { timeoutPromise } from "./index"
import { SubContainer } from "../../util/SubContainer"
/**
* Running a health script, is used when we want to have a simple
* script in bash or something like that. It should return something that is useful
* in {result: string} else it is considered an error
* @param param0
* @returns
*/
export const runHealthScript = async (
runCommand: string[],
subcontainer: SubContainer,
{
timeout = 30000,
errorMessage = `Error while running command: ${runCommand}`,
message = (res: string) =>
`Have ran script ${runCommand} and the result: ${res}`,
} = {},
): Promise<HealthCheckResult> => {
const res = await Promise.race([
subcontainer.exec(runCommand),
timeoutPromise(timeout),
]).catch((e) => {
console.warn(errorMessage)
console.warn(JSON.stringify(e))
console.warn(e.toString())
throw { result: "failure", message: errorMessage } as HealthCheckResult
})
return {
result: "success",
message: message(res.stdout.toString()),
} as HealthCheckResult
}

View File

@@ -0,0 +1 @@
import "./checkFns"

48
sdk/package/lib/index.ts Normal file
View File

@@ -0,0 +1,48 @@
import {
S9pk,
Version,
VersionRange,
ExtendedVersion,
inputSpec,
ISB,
IST,
types,
T,
matches,
utils,
} from "../../base/lib"
export {
S9pk,
Version,
VersionRange,
ExtendedVersion,
inputSpec,
ISB,
IST,
types,
T,
matches,
utils,
}
export { Daemons } from "./mainFn/Daemons"
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"
export * as daemons from "./mainFn/Daemons"
export * as health from "./health"
export * as healthFns from "./health/checkFns"
export * as inits from "./inits"
export * as mainFn from "./mainFn"
export * as toml from "@iarna/toml"
export * as yaml from "yaml"
export * as startSdk from "./StartSdk"
export * as YAML from "yaml"
export * as TOML from "@iarna/toml"
export * from "./version"

View File

@@ -0,0 +1,3 @@
import "./setupInit"
import "./setupUninstall"
import "./setupInstall"

View File

@@ -0,0 +1,64 @@
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 { VersionGraph } from "../version/VersionGraph"
import { Install } from "./setupInstall"
import { Uninstall } from "./setupUninstall"
export function setupInit<Manifest extends T.SDKManifest, Store>(
versions: VersionGraph<string>,
install: Install<Manifest, Store>,
uninstall: Uninstall<Manifest, Store>,
setServiceInterfaces: UpdateServiceInterfaces<any>,
setDependencies: (options: {
effects: T.Effects
}) => Promise<null | void | undefined>,
actions: Actions<Store, any>,
exposedStore: ExposedStorePaths,
): {
packageInit: T.ExpectedExports.packageInit
packageUninit: T.ExpectedExports.packageUninit
containerInit: T.ExpectedExports.containerInit
} {
return {
packageInit: async (opts) => {
const prev = await opts.effects.getDataVersion()
if (prev) {
await versions.migrate({
effects: opts.effects,
from: ExtendedVersion.parse(prev),
to: versions.currentVersion(),
})
} else {
await install.install(opts)
await opts.effects.setDataVersion({
version: versions.current.options.version,
})
}
},
packageUninit: async (opts) => {
if (opts.nextVersion) {
const prev = await opts.effects.getDataVersion()
if (prev) {
await versions.migrate({
effects: opts.effects,
from: ExtendedVersion.parse(prev),
to: ExtendedVersion.parse(opts.nextVersion),
})
}
} else {
await uninstall.uninstall(opts)
}
},
containerInit: async (opts) => {
await setServiceInterfaces({
...opts,
})
await actions.update({ effects: opts.effects })
await opts.effects.exposeForDependents({ paths: exposedStore })
await setDependencies({ effects: opts.effects })
},
}
}

View File

@@ -0,0 +1,25 @@
import * as T from "../../../base/lib/types"
export type InstallFn<Manifest extends T.SDKManifest, Store> = (opts: {
effects: T.Effects
}) => Promise<null | void | undefined>
export class Install<Manifest extends T.SDKManifest, Store> {
private constructor(readonly fn: InstallFn<Manifest, Store>) {}
static of<Manifest extends T.SDKManifest, Store>(
fn: InstallFn<Manifest, Store>,
) {
return new Install(fn)
}
async install({ effects }: Parameters<T.ExpectedExports.packageInit>[0]) {
await this.fn({
effects,
})
}
}
export function setupInstall<Manifest extends T.SDKManifest, Store>(
fn: InstallFn<Manifest, Store>,
) {
return Install.of(fn)
}

View File

@@ -0,0 +1,29 @@
import * as T from "../../../base/lib/types"
export type UninstallFn<Manifest extends T.SDKManifest, Store> = (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>,
) {
return new Uninstall(fn)
}
async uninstall({
effects,
nextVersion,
}: Parameters<T.ExpectedExports.packageUninit>[0]) {
if (!nextVersion)
await this.fn({
effects,
})
}
}
export function setupUninstall<Manifest extends T.SDKManifest, Store>(
fn: UninstallFn<Manifest, Store>,
) {
return Uninstall.of(fn)
}

View File

@@ -0,0 +1,149 @@
import { DEFAULT_SIGTERM_TIMEOUT } from "."
import { NO_TIMEOUT, SIGKILL, SIGTERM } from "../../../base/lib/types"
import * as T from "../../../base/lib/types"
import { asError } from "../../../base/lib/util/asError"
import {
ExecSpawnable,
MountOptions,
SubContainerHandle,
SubContainer,
} from "../util/SubContainer"
import { splitCommand } from "../util"
import * as cp from "child_process"
export class CommandController {
private constructor(
readonly runningAnswer: Promise<unknown>,
private state: { exited: boolean },
private readonly subcontainer: SubContainer,
private process: cp.ChildProcess,
readonly sigtermTimeout: number = DEFAULT_SIGTERM_TIMEOUT,
) {}
static of<Manifest extends T.SDKManifest>() {
return async <A extends string>(
effects: T.Effects,
subcontainer:
| {
id: keyof Manifest["images"] & T.ImageId
sharedRun?: boolean
}
| SubContainer,
command: T.CommandType,
options: {
subcontainerName?: string
// Defaults to the DEFAULT_SIGTERM_TIMEOUT = 30_000ms
sigtermTimeout?: number
mounts?: { path: string; options: MountOptions }[]
runAsInit?: boolean
env?:
| {
[variable: string]: string
}
| undefined
cwd?: string | undefined
user?: string | undefined
onStdout?: (chunk: Buffer | string | any) => void
onStderr?: (chunk: Buffer | string | any) => void
},
) => {
const commands = splitCommand(command)
const subc =
subcontainer instanceof SubContainer
? subcontainer
: await (async () => {
const subc = await SubContainer.of(
effects,
subcontainer,
options?.subcontainerName || commands.join(" "),
)
for (let mount of options.mounts || []) {
await subc.mount(mount.options, mount.path)
}
return subc
})()
let childProcess: cp.ChildProcess
if (options.runAsInit) {
childProcess = await subc.launch(commands, {
env: options.env,
})
} else {
childProcess = await subc.spawn(commands, {
env: options.env,
stdio: options.onStdout || options.onStderr ? "pipe" : "inherit",
})
}
if (options.onStdout) childProcess.stdout?.on("data", options.onStdout)
if (options.onStderr) childProcess.stderr?.on("data", options.onStderr)
const state = { exited: false }
const answer = new Promise<null>((resolve, reject) => {
childProcess.on("exit", (code) => {
state.exited = true
if (
code === 0 ||
code === 143 ||
(code === null && childProcess.signalCode == "SIGTERM")
) {
return resolve(null)
}
if (code) {
return reject(new Error(`${commands[0]} exited with code ${code}`))
} else {
return reject(
new Error(
`${commands[0]} exited with signal ${childProcess.signalCode}`,
),
)
}
})
})
return new CommandController(
answer,
state,
subc,
childProcess,
options.sigtermTimeout,
)
}
}
get subContainerHandle() {
return new SubContainerHandle(this.subcontainer)
}
async wait({ timeout = NO_TIMEOUT } = {}) {
if (timeout > 0)
setTimeout(() => {
this.term()
}, timeout)
try {
return await this.runningAnswer
} finally {
if (!this.state.exited) {
this.process.kill("SIGKILL")
}
await this.subcontainer.destroy?.().catch((_) => {})
}
}
async term({ signal = SIGTERM, timeout = this.sigtermTimeout } = {}) {
try {
if (!this.state.exited) {
if (signal !== "SIGKILL") {
setTimeout(() => {
if (!this.state.exited) this.process.kill("SIGKILL")
}, timeout)
}
if (!this.process.kill(signal)) {
console.error(
`failed to send signal ${signal} to pid ${this.process.pid}`,
)
}
}
await this.runningAnswer
} finally {
await this.subcontainer.destroy?.()
}
}
}

View File

@@ -0,0 +1,89 @@
import * as T from "../../../base/lib/types"
import { asError } from "../../../base/lib/util/asError"
import { ExecSpawnable, MountOptions, SubContainer } from "../util/SubContainer"
import { CommandController } from "./CommandController"
const TIMEOUT_INCREMENT_MS = 1000
const MAX_TIMEOUT_MS = 30000
/**
* This is a wrapper around CommandController that has a state of off, where the command shouldn't be running
* and the others state of running, where it will keep a living running command
*/
export class Daemon {
private commandController: CommandController | null = null
private shouldBeRunning = false
constructor(private startCommand: () => Promise<CommandController>) {}
get subContainerHandle(): undefined | ExecSpawnable {
return this.commandController?.subContainerHandle
}
static of<Manifest extends T.SDKManifest>() {
return async <A extends string>(
effects: T.Effects,
subcontainer:
| {
id: keyof Manifest["images"] & T.ImageId
sharedRun?: boolean
}
| SubContainer,
command: T.CommandType,
options: {
subcontainerName?: string
mounts?: { path: string; options: MountOptions }[]
env?:
| {
[variable: string]: string
}
| undefined
cwd?: string | undefined
user?: string | undefined
onStdout?: (chunk: Buffer | string | any) => void
onStderr?: (chunk: Buffer | string | any) => void
sigtermTimeout?: number
},
) => {
const startCommand = () =>
CommandController.of<Manifest>()(
effects,
subcontainer,
command,
options,
)
return new Daemon(startCommand)
}
}
async start() {
if (this.commandController) {
return
}
this.shouldBeRunning = true
let timeoutCounter = 0
new Promise(async () => {
while (this.shouldBeRunning) {
this.commandController = await this.startCommand()
await this.commandController.wait().catch((err) => console.error(err))
await new Promise((resolve) => setTimeout(resolve, timeoutCounter))
timeoutCounter += TIMEOUT_INCREMENT_MS
timeoutCounter = Math.max(MAX_TIMEOUT_MS, timeoutCounter)
}
}).catch((err) => {
console.error(asError(err))
})
}
async term(termOptions?: {
signal?: NodeJS.Signals | undefined
timeout?: number | undefined
}) {
return this.stop(termOptions)
}
async stop(termOptions?: {
signal?: NodeJS.Signals | undefined
timeout?: number | undefined
}) {
this.shouldBeRunning = false
await this.commandController
?.term({ ...termOptions })
.catch((e) => console.error(asError(e)))
this.commandController = null
}
}

View File

@@ -0,0 +1,174 @@
import { HealthReceipt, Signals } from "../../../base/lib/types"
import { HealthCheckResult } from "../health/checkFns"
import { Trigger } from "../trigger"
import * as T from "../../../base/lib/types"
import { Mounts } from "./Mounts"
import { ExecSpawnable, MountOptions } from "../util/SubContainer"
import { promisify } from "node:util"
import * as CP from "node:child_process"
export { Daemon } from "./Daemon"
export { CommandController } from "./CommandController"
import { HealthDaemon } from "./HealthDaemon"
import { Daemon } from "./Daemon"
import { CommandController } from "./CommandController"
export const cpExec = promisify(CP.exec)
export const cpExecFile = promisify(CP.execFile)
export type Ready = {
display: string | null
fn: (
spawnable: ExecSpawnable,
) => Promise<HealthCheckResult> | HealthCheckResult
trigger?: Trigger
}
type DaemonsParams<
Manifest extends T.SDKManifest,
Ids extends string,
Command extends string,
Id extends string,
> = {
command: T.CommandType
image: { id: keyof Manifest["images"] & T.ImageId; sharedRun?: boolean }
mounts: Mounts<Manifest>
env?: Record<string, string>
ready: Ready
requires: Exclude<Ids, Id>[]
sigtermTimeout?: number
onStdout?: (chunk: Buffer | string | any) => void
onStderr?: (chunk: Buffer | string | any) => void
}
type ErrorDuplicateId<Id extends string> = `The id '${Id}' is already used`
export const runCommand = <Manifest extends T.SDKManifest>() =>
CommandController.of<Manifest>()
/**
* A class for defining and controlling the service daemons
```ts
Daemons.of({
effects,
started,
interfaceReceipt, // Provide the interfaceReceipt to prove it was completed
healthReceipts, // Provide the healthReceipts or [] to prove they were at least considered
}).addDaemon('webui', {
command: 'hello-world', // The command to start the daemon
ready: {
display: 'Web Interface',
// The function to run to determine the health status of the daemon
fn: () =>
checkPortListening(effects, 80, {
successMessage: 'The web interface is ready',
errorMessage: 'The web interface is not ready',
}),
},
requires: [],
})
```
*/
export class Daemons<Manifest extends T.SDKManifest, Ids extends string>
implements T.DaemonBuildable
{
private constructor(
readonly effects: T.Effects,
readonly started: (onTerm: () => PromiseLike<void>) => PromiseLike<null>,
readonly daemons: Promise<Daemon>[],
readonly ids: Ids[],
readonly healthDaemons: HealthDaemon[],
) {}
/**
* Returns an empty new Daemons class with the provided inputSpec.
*
* Call .addDaemon() on the returned class to add a daemon.
*
* Daemons run in the order they are defined, with latter daemons being capable of
* depending on prior daemons
* @param options
* @returns
*/
static of<Manifest extends T.SDKManifest>(options: {
effects: T.Effects
started: (onTerm: () => PromiseLike<void>) => PromiseLike<null>
healthReceipts: HealthReceipt[]
}) {
return new Daemons<Manifest, never>(
options.effects,
options.started,
[],
[],
[],
)
}
/**
* Returns the complete list of daemons, including the one defined here
* @param id
* @param newDaemon
* @returns
*/
addDaemon<Id extends string, Command extends string>(
// prettier-ignore
id:
"" extends Id ? never :
ErrorDuplicateId<Id> extends Id ? never :
Id extends Ids ? ErrorDuplicateId<Id> :
Id,
options: DaemonsParams<Manifest, Ids, Command, Id>,
) {
const daemonIndex = this.daemons.length
const daemon = Daemon.of()(this.effects, options.image, options.command, {
...options,
mounts: options.mounts.build(),
subcontainerName: id,
})
const healthDaemon = new HealthDaemon(
daemon,
daemonIndex,
options.requires
.map((x) => this.ids.indexOf(id as any))
.filter((x) => x >= 0)
.map((id) => this.healthDaemons[id]),
id,
this.ids,
options.ready,
this.effects,
options.sigtermTimeout,
)
const daemons = this.daemons.concat(daemon)
const ids = [...this.ids, id] as (Ids | Id)[]
const healthDaemons = [...this.healthDaemons, healthDaemon]
return new Daemons<Manifest, Ids | Id>(
this.effects,
this.started,
daemons,
ids,
healthDaemons,
)
}
async build() {
this.updateMainHealth()
this.healthDaemons.forEach((x) =>
x.addWatcher(() => this.updateMainHealth()),
)
const built = {
term: async (options?: { signal?: Signals; timeout?: number }) => {
try {
await Promise.all(this.healthDaemons.map((x) => x.term(options)))
} finally {
this.effects.setMainStatus({ status: "stopped" })
}
},
}
this.started(() => built.term())
return built
}
private updateMainHealth() {
this.effects.setMainStatus({ status: "running" })
}
}

View File

@@ -0,0 +1,151 @@
import { HealthCheckResult } from "../health/checkFns"
import { defaultTrigger } from "../trigger/defaultTrigger"
import { Ready } from "./Daemons"
import { Daemon } from "./Daemon"
import { SetHealth, Effects } from "../../../base/lib/types"
import { DEFAULT_SIGTERM_TIMEOUT } from "."
import { asError } from "../../../base/lib/util/asError"
const oncePromise = <T>() => {
let resolve: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return { resolve: resolve!, promise }
}
/**
* Wanted a structure that deals with controlling daemons by their health status
* States:
* -- Waiting for dependencies to be success
* -- Running: Daemon is running and the status is in the health
*
*/
export class HealthDaemon {
private _health: HealthCheckResult = { result: "starting", message: null }
private healthWatchers: Array<() => unknown> = []
private running = false
constructor(
private readonly daemon: Promise<Daemon>,
readonly daemonIndex: number,
private readonly dependencies: HealthDaemon[],
readonly id: string,
readonly ids: string[],
readonly ready: Ready,
readonly effects: Effects,
readonly sigtermTimeout: number = DEFAULT_SIGTERM_TIMEOUT,
) {
this.updateStatus()
this.dependencies.forEach((d) => d.addWatcher(() => this.updateStatus()))
}
/** Run after we want to do cleanup */
async term(termOptions?: {
signal?: NodeJS.Signals | undefined
timeout?: number | undefined
}) {
this.healthWatchers = []
this.running = false
this.healthCheckCleanup?.()
await this.daemon.then((d) =>
d.term({
timeout: this.sigtermTimeout,
...termOptions,
}),
)
}
/** Want to add another notifier that the health might have changed */
addWatcher(watcher: () => unknown) {
this.healthWatchers.push(watcher)
}
get health() {
return Object.freeze(this._health)
}
private async changeRunning(newStatus: boolean) {
if (this.running === newStatus) return
this.running = newStatus
if (newStatus) {
;(await this.daemon).start()
this.setupHealthCheck()
} else {
;(await this.daemon).stop()
this.turnOffHealthCheck()
this.setHealth({ result: "starting", message: null })
}
}
private healthCheckCleanup: (() => null) | null = null
private turnOffHealthCheck() {
this.healthCheckCleanup?.()
}
private async setupHealthCheck() {
if (this.healthCheckCleanup) return
const trigger = (this.ready.trigger ?? defaultTrigger)(() => ({
lastResult: this._health.result,
}))
const { promise: status, resolve: setStatus } = oncePromise<{
done: true
}>()
new Promise(async () => {
for (
let res = await Promise.race([status, trigger.next()]);
!res.done;
res = await Promise.race([status, trigger.next()])
) {
const handle = (await this.daemon).subContainerHandle
if (handle) {
const response: HealthCheckResult = await Promise.resolve(
this.ready.fn(handle),
).catch((err) => {
console.error(asError(err))
return {
result: "failure",
message: "message" in err ? err.message : String(err),
}
})
await this.setHealth(response)
} else {
await this.setHealth({
result: "failure",
message: "Daemon not running",
})
}
}
}).catch((err) => console.error(`Daemon ${this.id} failed: ${err}`))
this.healthCheckCleanup = () => {
setStatus({ done: true })
this.healthCheckCleanup = null
return null
}
}
private async setHealth(health: HealthCheckResult) {
this._health = health
this.healthWatchers.forEach((watcher) => watcher())
const display = this.ready.display
const result = health.result
if (!display) {
return
}
await this.effects.setHealth({
...health,
id: this.id,
name: display,
} as SetHealth)
}
private async updateStatus() {
const healths = this.dependencies.map((d) => d._health)
this.changeRunning(healths.every((x) => x.result === "success"))
}
}

View File

@@ -0,0 +1,125 @@
import * as T from "../../../base/lib/types"
import { MountOptions } from "../util/SubContainer"
type MountArray = { path: string; options: MountOptions }[]
export class Mounts<Manifest extends T.SDKManifest> {
private constructor(
readonly volumes: {
id: Manifest["volumes"][number]
subpath: string | null
mountpoint: string
readonly: boolean
}[],
readonly assets: {
id: Manifest["assets"][number]
subpath: string | null
mountpoint: string
}[],
readonly dependencies: {
dependencyId: string
volumeId: string
subpath: string | null
mountpoint: string
readonly: boolean
}[],
) {}
static of<Manifest extends T.SDKManifest>() {
return new Mounts<Manifest>([], [], [])
}
addVolume(
id: Manifest["volumes"][number],
subpath: string | null,
mountpoint: string,
readonly: boolean,
) {
this.volumes.push({
id,
subpath,
mountpoint,
readonly,
})
return this
}
addAssets(
id: Manifest["assets"][number],
subpath: string | null,
mountpoint: string,
) {
this.assets.push({
id,
subpath,
mountpoint,
})
return this
}
addDependency<DependencyManifest extends T.SDKManifest>(
dependencyId: keyof Manifest["dependencies"] & string,
volumeId: DependencyManifest["volumes"][number],
subpath: string | null,
mountpoint: string,
readonly: boolean,
) {
this.dependencies.push({
dependencyId,
volumeId,
subpath,
mountpoint,
readonly,
})
return this
}
build(): MountArray {
const mountpoints = new Set()
for (let mountpoint of this.volumes
.map((v) => v.mountpoint)
.concat(this.assets.map((a) => a.mountpoint))
.concat(this.dependencies.map((d) => d.mountpoint))) {
if (mountpoints.has(mountpoint)) {
throw new Error(
`cannot mount more than once to mountpoint ${mountpoint}`,
)
}
mountpoints.add(mountpoint)
}
return ([] as MountArray)
.concat(
this.volumes.map((v) => ({
path: v.mountpoint,
options: {
type: "volume",
id: v.id,
subpath: v.subpath,
readonly: v.readonly,
},
})),
)
.concat(
this.assets.map((a) => ({
path: a.mountpoint,
options: {
type: "assets",
id: a.id,
subpath: a.subpath,
},
})),
)
.concat(
this.dependencies.map((d) => ({
path: d.mountpoint,
options: {
type: "pointer",
packageId: d.dependencyId,
volumeId: d.volumeId,
subpath: d.subpath,
readonly: d.readonly,
},
})),
)
}
}

View File

@@ -0,0 +1,27 @@
import * as T from "../../../base/lib/types"
import { Daemons } from "./Daemons"
import "../../../base/lib/interfaces/ServiceInterfaceBuilder"
import "../../../base/lib/interfaces/Origin"
export const DEFAULT_SIGTERM_TIMEOUT = 30_000
/**
* Used to ensure that the main function is running with the valid proofs.
* We first do the folowing order of things
* 1. We get the interfaces
* 2. We setup all the commands to setup the system
* 3. We create the health checks
* 4. We setup the daemons init system
* @param fn
* @returns
*/
export const setupMain = <Manifest extends T.SDKManifest, Store>(
fn: (o: {
effects: T.Effects
started(onTerm: () => PromiseLike<void>): PromiseLike<null>
}) => Promise<Daemons<Manifest, any>>,
): T.ExpectedExports.main => {
return async (options) => {
const result = await fn(options)
return result
}
}

View File

@@ -0,0 +1,108 @@
import * as T from "../../../base/lib/types"
import { ImageConfig, ImageId, VolumeId } from "../../../base/lib/types"
import {
SDKManifest,
SDKImageInputSpec,
} from "../../../base/lib/types/ManifestTypes"
import { SDKVersion } from "../StartSdk"
import { VersionGraph } from "../version/VersionGraph"
import { execSync } from "child_process"
/**
* @description Use this function to define critical information about your package
*
* @param versions Every version of the package, imported from ./versions
* @param manifest Static properties of the package
*/
export function setupManifest<
Id extends string,
VolumesTypes extends VolumeId,
AssetTypes extends VolumeId,
Manifest extends {
id: Id
assets: AssetTypes[]
volumes: VolumesTypes[]
} & SDKManifest,
>(manifest: Manifest): Manifest {
return manifest
}
function gitHash(): string {
const hash = execSync("git rev-parse HEAD").toString().trim()
try {
execSync("git diff-index --quiet HEAD --")
return hash
} catch (e) {
return hash + "-modified"
}
}
export function buildManifest<
Id extends string,
Version extends string,
Dependencies extends Record<string, unknown>,
VolumesTypes extends VolumeId,
AssetTypes extends VolumeId,
ImagesTypes extends ImageId,
Manifest extends {
dependencies: Dependencies
id: Id
assets: AssetTypes[]
images: Record<ImagesTypes, SDKImageInputSpec>
volumes: VolumesTypes[]
},
>(
versions: VersionGraph<Version>,
manifest: SDKManifest & Manifest,
): Manifest & T.Manifest {
const images = Object.entries(manifest.images).reduce(
(images, [k, v]) => {
v.arch = v.arch || ["aarch64", "x86_64"]
if (v.emulateMissingAs === undefined)
v.emulateMissingAs = (v.arch as string[]).includes("aarch64")
? "aarch64"
: v.arch[0] || null
images[k] = v as ImageConfig
return images
},
{} as { [k: string]: ImageConfig },
)
return {
...manifest,
gitHash: gitHash(),
osVersion: SDKVersion,
version: versions.current.options.version,
releaseNotes: versions.current.options.releaseNotes,
satisfies: versions.current.options.satisfies || [],
canMigrateTo: versions.canMigrateTo().toString(),
canMigrateFrom: versions.canMigrateFrom().toString(),
images,
alerts: {
install: manifest.alerts?.install || null,
update: manifest.alerts?.update || null,
uninstall: manifest.alerts?.uninstall || null,
restore: manifest.alerts?.restore || null,
start: manifest.alerts?.start || null,
stop: manifest.alerts?.stop || null,
},
hardwareRequirements: {
device: manifest.hardwareRequirements?.device || [],
ram: manifest.hardwareRequirements?.ram || null,
arch:
manifest.hardwareRequirements?.arch === undefined
? Object.values(images).reduce(
(arch, inputSpec) => {
if (inputSpec.emulateMissingAs) {
return arch
}
if (arch === null) {
return inputSpec.arch
}
return arch.filter((a) => inputSpec.arch.includes(a))
},
null as string[] | null,
)
: manifest.hardwareRequirements?.arch,
},
}
}

View File

@@ -0,0 +1,61 @@
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. Restart the service if the value changes
*/
const() {
return this.effects.store.get<Store, StoreValue>({
...this.options,
path: extractJsonPath(this.path),
callback: () => 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. Takes a custom callback function to run whenever the value changes
*/
async *watch() {
while (true) {
let callback: () => void
const waitForNext = new Promise<void>((resolve) => {
callback = resolve
})
yield await this.effects.store.get<Store, StoreValue>({
...this.options,
path: extractJsonPath(this.path),
callback: () => callback(),
})
await waitForNext
}
}
}
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

@@ -0,0 +1,28 @@
import { ExposedStorePaths } from "../../../base/lib/types"
import { Affine, _ } from "../util"
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

@@ -0,0 +1,17 @@
import { containsAddress } from "../health/checkFns/checkPortListening"
describe("Health ready check", () => {
it("Should be able to parse an example information", () => {
let input = `
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
0: 00000000:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 21634478 1 0000000000000000 100 0 0 10 0
1: 00000000:0050 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 21634477 1 0000000000000000 100 0 0 10 0
2: 0B00007F:9671 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 21635458 1 0000000000000000 100 0 0 10 0
3: 00000000:0D73 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 21634479 1 0000000000000000 100 0 0 10 0
`
expect(containsAddress(input, 80)).toBe(true)
expect(containsAddress(input, 1234)).toBe(false)
})
})

View File

@@ -0,0 +1,30 @@
import { ServiceInterfaceBuilder } from "../../../base/lib/interfaces/ServiceInterfaceBuilder"
import { Effects } from "../../../base/lib/Effects"
import { sdk } from "../test/output.sdk"
describe("host", () => {
test("Testing that the types work", () => {
async function test(effects: Effects) {
const foo = sdk.host.multi(effects, "foo")
const fooOrigin = await foo.bindPort(80, {
protocol: "http" as const,
preferredExternalPort: 80,
})
const fooInterface = new ServiceInterfaceBuilder({
effects,
name: "Foo",
id: "foo",
description: "A Foo",
hasPrimary: false,
type: "ui",
username: "bar",
path: "/baz",
search: { qux: "yes" },
schemeOverride: null,
masked: false,
})
await fooOrigin.export([fooInterface])
}
})
})

View File

@@ -0,0 +1,892 @@
import { testOutput } from "./output.test"
import { InputSpec } from "../../../base/lib/actions/input/builder/inputSpec"
import { List } from "../../../base/lib/actions/input/builder/list"
import { Value } from "../../../base/lib/actions/input/builder/value"
import { Variants } from "../../../base/lib/actions/input/builder/variants"
import { ValueSpec } from "../../../base/lib/actions/input/inputSpecTypes"
import { setupManifest } from "../manifest/setupManifest"
import { StartSdk } from "../StartSdk"
import { VersionGraph } from "../version/VersionGraph"
import { VersionInfo } from "../version/VersionInfo"
describe("builder tests", () => {
test("text", async () => {
const bitcoinPropertiesBuilt: {
"peer-tor-address": ValueSpec
} = await InputSpec.of({
"peer-tor-address": Value.text({
name: "Peer tor address",
description: "The Tor address of the peer interface",
required: true,
default: null,
}),
}).build({} as any)
expect(bitcoinPropertiesBuilt).toMatchObject({
"peer-tor-address": {
type: "text",
description: "The Tor address of the peer interface",
warning: null,
masked: false,
placeholder: null,
minLength: null,
maxLength: null,
patterns: [],
disabled: false,
inputmode: "text",
name: "Peer tor address",
required: true,
default: null,
},
})
})
})
describe("values", () => {
test("toggle", async () => {
const value = Value.toggle({
name: "Testing",
description: null,
warning: null,
default: false,
})
const validator = value.validator
validator.unsafeCast(false)
testOutput<typeof validator._TYPE, boolean>()(null)
})
test("text", async () => {
const value = Value.text({
name: "Testing",
required: true,
default: null,
})
const validator = value.validator
const rawIs = await value.build({} as any)
validator.unsafeCast("test text")
expect(() => validator.unsafeCast(null)).toThrowError()
testOutput<typeof validator._TYPE, string>()(null)
})
test("text with default", async () => {
const value = Value.text({
name: "Testing",
required: true,
default: "this is a default value",
})
const validator = value.validator
const rawIs = await value.build({} as any)
validator.unsafeCast("test text")
expect(() => validator.unsafeCast(null)).toThrowError()
testOutput<typeof validator._TYPE, string>()(null)
})
test("optional text", async () => {
const value = Value.text({
name: "Testing",
required: false,
default: null,
})
const validator = value.validator
const rawIs = await value.build({} as any)
validator.unsafeCast("test text")
validator.unsafeCast(null)
testOutput<typeof validator._TYPE, string | null>()(null)
})
test("color", async () => {
const value = Value.color({
name: "Testing",
required: false,
default: null,
description: null,
warning: null,
})
const validator = value.validator
validator.unsafeCast("#000000")
testOutput<typeof validator._TYPE, string | null>()(null)
})
test("datetime", async () => {
const value = Value.datetime({
name: "Testing",
required: true,
default: null,
description: null,
warning: null,
inputmode: "date",
min: null,
max: null,
})
const validator = value.validator
validator.unsafeCast("2021-01-01")
testOutput<typeof validator._TYPE, string>()(null)
})
test("optional datetime", async () => {
const value = Value.datetime({
name: "Testing",
required: false,
default: null,
description: null,
warning: null,
inputmode: "date",
min: null,
max: null,
})
const validator = value.validator
validator.unsafeCast("2021-01-01")
testOutput<typeof validator._TYPE, string | null>()(null)
})
test("textarea", async () => {
const value = Value.textarea({
name: "Testing",
required: false,
default: null,
description: null,
warning: null,
minLength: null,
maxLength: null,
placeholder: null,
})
const validator = value.validator
validator.unsafeCast("test text")
testOutput<typeof validator._TYPE, string | null>()(null)
})
test("number", async () => {
const value = Value.number({
name: "Testing",
required: true,
default: null,
integer: false,
description: null,
warning: null,
min: null,
max: null,
step: null,
units: null,
placeholder: null,
})
const validator = value.validator
validator.unsafeCast(2)
testOutput<typeof validator._TYPE, number>()(null)
})
test("optional number", async () => {
const value = Value.number({
name: "Testing",
required: false,
default: null,
integer: false,
description: null,
warning: null,
min: null,
max: null,
step: null,
units: null,
placeholder: null,
})
const validator = value.validator
validator.unsafeCast(2)
testOutput<typeof validator._TYPE, number | null>()(null)
})
test("select", async () => {
const value = Value.select({
name: "Testing",
default: "a",
values: {
a: "A",
b: "B",
},
description: null,
warning: null,
})
const validator = value.validator
validator.unsafeCast("a")
validator.unsafeCast("b")
expect(() => validator.unsafeCast("c")).toThrowError()
testOutput<typeof validator._TYPE, "a" | "b">()(null)
})
test("nullable select", async () => {
const value = Value.select({
name: "Testing",
default: "a",
values: {
a: "A",
b: "B",
},
description: null,
warning: null,
})
const validator = value.validator
validator.unsafeCast("a")
validator.unsafeCast("b")
testOutput<typeof validator._TYPE, "a" | "b">()(null)
})
test("multiselect", async () => {
const value = Value.multiselect({
name: "Testing",
values: {
a: "A",
b: "B",
},
default: [],
description: null,
warning: null,
minLength: null,
maxLength: null,
})
const validator = value.validator
validator.unsafeCast([])
validator.unsafeCast(["a", "b"])
expect(() => validator.unsafeCast(["e"])).toThrowError()
expect(() => validator.unsafeCast([4])).toThrowError()
testOutput<typeof validator._TYPE, Array<"a" | "b">>()(null)
})
test("object", async () => {
const value = Value.object(
{
name: "Testing",
description: null,
},
InputSpec.of({
a: Value.toggle({
name: "test",
description: null,
warning: null,
default: false,
}),
}),
)
const validator = value.validator
validator.unsafeCast({ a: true })
testOutput<typeof validator._TYPE, { a: boolean }>()(null)
})
test("union", async () => {
const value = Value.union(
{
name: "Testing",
default: "a",
description: null,
warning: null,
},
Variants.of({
a: {
name: "a",
spec: InputSpec.of({
b: Value.toggle({
name: "b",
description: null,
warning: null,
default: false,
}),
}),
},
}),
)
const validator = value.validator
validator.unsafeCast({ selection: "a", value: { b: false } })
type Test = typeof validator._TYPE
testOutput<
Test,
{
selection: "a"
value: {
b: boolean
}
other?: {}
}
>()(null)
})
describe("dynamic", () => {
const fakeOptions = {
inputSpec: "inputSpec",
effects: "effects",
utils: "utils",
} as any
test("toggle", async () => {
const value = Value.dynamicToggle(async () => ({
name: "Testing",
description: null,
warning: null,
default: false,
}))
const validator = value.validator
validator.unsafeCast(false)
expect(() => validator.unsafeCast(null)).toThrowError()
testOutput<typeof validator._TYPE, boolean>()(null)
expect(await value.build(fakeOptions)).toMatchObject({
name: "Testing",
description: null,
warning: null,
default: false,
})
})
test("text", async () => {
const value = Value.dynamicText(async () => ({
name: "Testing",
required: false,
default: null,
}))
const validator = value.validator
const rawIs = await value.build({} as any)
validator.unsafeCast("test text")
validator.unsafeCast(null)
testOutput<typeof validator._TYPE, string | null>()(null)
expect(await value.build(fakeOptions)).toMatchObject({
name: "Testing",
required: false,
default: null,
})
})
test("text with default", async () => {
const value = Value.dynamicText(async () => ({
name: "Testing",
required: false,
default: "this is a default value",
}))
const validator = value.validator
validator.unsafeCast("test text")
validator.unsafeCast(null)
testOutput<typeof validator._TYPE, string | null>()(null)
expect(await value.build(fakeOptions)).toMatchObject({
name: "Testing",
required: false,
default: "this is a default value",
})
})
test("optional text", async () => {
const value = Value.dynamicText(async () => ({
name: "Testing",
required: false,
default: null,
}))
const validator = value.validator
const rawIs = await value.build({} as any)
validator.unsafeCast("test text")
validator.unsafeCast(null)
testOutput<typeof validator._TYPE, string | null>()(null)
expect(await value.build(fakeOptions)).toMatchObject({
name: "Testing",
required: false,
default: null,
})
})
test("color", async () => {
const value = Value.dynamicColor(async () => ({
name: "Testing",
required: false,
default: null,
description: null,
warning: null,
}))
const validator = value.validator
validator.unsafeCast("#000000")
validator.unsafeCast(null)
testOutput<typeof validator._TYPE, string | null>()(null)
expect(await value.build(fakeOptions)).toMatchObject({
name: "Testing",
required: false,
default: null,
description: null,
warning: null,
})
})
test("datetime", async () => {
const sdk = StartSdk.of()
.withManifest(
setupManifest({
id: "testOutput",
title: "",
license: "",
wrapperRepo: "",
upstreamRepo: "",
supportSite: "",
marketingSite: "",
donationUrl: null,
description: {
short: "",
long: "",
},
containers: {},
images: {},
volumes: [],
assets: [],
alerts: {
install: null,
update: null,
uninstall: null,
restore: null,
start: null,
stop: null,
},
dependencies: {
"remote-test": {
description: "",
optional: true,
s9pk: "https://example.com/remote-test.s9pk",
},
},
}),
)
.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 validator = value.validator
validator.unsafeCast("2021-01-01")
validator.unsafeCast(null)
testOutput<typeof validator._TYPE, string | null>()(null)
expect(await value.build(fakeOptions)).toMatchObject({
name: "Testing",
required: true,
default: null,
description: null,
warning: null,
inputmode: "date",
})
})
test("textarea", async () => {
const value = Value.dynamicTextarea(async () => ({
name: "Testing",
required: false,
default: null,
description: null,
warning: null,
minLength: null,
maxLength: null,
placeholder: null,
}))
const validator = value.validator
validator.unsafeCast("test text")
testOutput<typeof validator._TYPE, string | null>()(null)
expect(await value.build(fakeOptions)).toMatchObject({
name: "Testing",
required: false,
})
})
test("number", async () => {
const value = Value.dynamicNumber(() => ({
name: "Testing",
required: true,
default: null,
integer: false,
description: null,
warning: null,
min: null,
max: null,
step: null,
units: null,
placeholder: null,
}))
const validator = value.validator
validator.unsafeCast(2)
validator.unsafeCast(null)
expect(() => validator.unsafeCast("null")).toThrowError()
testOutput<typeof validator._TYPE, number | null>()(null)
expect(await value.build(fakeOptions)).toMatchObject({
name: "Testing",
required: true,
})
})
test("select", async () => {
const value = Value.dynamicSelect(() => ({
name: "Testing",
default: "a",
values: {
a: "A",
b: "B",
},
description: null,
warning: null,
}))
const validator = value.validator
validator.unsafeCast("a")
validator.unsafeCast("b")
validator.unsafeCast("c")
testOutput<typeof validator._TYPE, string>()(null)
expect(await value.build(fakeOptions)).toMatchObject({
name: "Testing",
})
})
test("multiselect", async () => {
const value = Value.dynamicMultiselect(() => ({
name: "Testing",
values: {
a: "A",
b: "B",
},
default: [],
description: null,
warning: null,
minLength: null,
maxLength: null,
}))
const validator = value.validator
validator.unsafeCast([])
validator.unsafeCast(["a", "b"])
validator.unsafeCast(["c"])
expect(() => validator.unsafeCast([4])).toThrowError()
expect(() => validator.unsafeCast(null)).toThrowError()
testOutput<typeof validator._TYPE, Array<string>>()(null)
expect(await value.build(fakeOptions)).toMatchObject({
name: "Testing",
default: [],
})
})
})
describe("filtering", () => {
test("union", async () => {
const value = Value.filteredUnion(
() => ["a", "c"],
{
name: "Testing",
default: "a",
description: null,
warning: null,
},
Variants.of({
a: {
name: "a",
spec: InputSpec.of({
b: Value.toggle({
name: "b",
description: null,
warning: null,
default: false,
}),
}),
},
b: {
name: "b",
spec: InputSpec.of({
b: Value.toggle({
name: "b",
description: null,
warning: null,
default: false,
}),
}),
},
}),
)
const validator = value.validator
validator.unsafeCast({ selection: "a", value: { b: false } })
type Test = typeof validator._TYPE
testOutput<
Test,
| {
selection: "a"
value: {
b: boolean
}
other?: {
b?: {
b?: boolean
}
}
}
| {
selection: "b"
value: {
b: boolean
}
other?: {
a?: {
b?: boolean
}
}
}
>()(null)
const built = await value.build({} as any)
expect(built).toMatchObject({
name: "Testing",
variants: {
b: {},
},
})
expect(built).toMatchObject({
name: "Testing",
variants: {
a: {},
b: {},
},
})
expect(built).toMatchObject({
name: "Testing",
variants: {
a: {},
b: {},
},
disabled: ["a", "c"],
})
})
})
test("dynamic union", async () => {
const value = Value.dynamicUnion(
() => ({
disabled: ["a", "c"],
name: "Testing",
default: "b",
description: null,
warning: null,
}),
Variants.of({
a: {
name: "a",
spec: InputSpec.of({
b: Value.toggle({
name: "b",
description: null,
warning: null,
default: false,
}),
}),
},
b: {
name: "b",
spec: InputSpec.of({
b: Value.toggle({
name: "b",
description: null,
warning: null,
default: false,
}),
}),
},
}),
)
const validator = value.validator
validator.unsafeCast({ selection: "a", value: { b: false } })
type Test = typeof validator._TYPE
testOutput<
Test,
| {
selection: "a"
value: {
b: boolean
}
other?: {
b?: {
b?: boolean
}
}
}
| {
selection: "b"
value: {
b: boolean
}
other?: {
a?: {
b?: boolean
}
}
}
>()(null)
const built = await value.build({} as any)
expect(built).toMatchObject({
name: "Testing",
variants: {
b: {},
},
})
expect(built).toMatchObject({
name: "Testing",
variants: {
a: {},
b: {},
},
})
expect(built).toMatchObject({
name: "Testing",
variants: {
a: {},
b: {},
},
disabled: ["a", "c"],
})
})
})
describe("Builder List", () => {
test("obj", async () => {
const value = Value.list(
List.obj(
{
name: "test",
},
{
spec: InputSpec.of({
test: Value.toggle({
name: "test",
description: null,
warning: null,
default: false,
}),
}),
},
),
)
const validator = value.validator
validator.unsafeCast([{ test: true }])
testOutput<typeof validator._TYPE, { test: boolean }[]>()(null)
})
test("text", async () => {
const value = Value.list(
List.text(
{
name: "test",
},
{
patterns: [],
},
),
)
const validator = value.validator
validator.unsafeCast(["test", "text"])
testOutput<typeof validator._TYPE, string[]>()(null)
})
describe("dynamic", () => {
test("text", async () => {
const value = Value.list(
List.dynamicText(() => ({
name: "test",
spec: { patterns: [] },
})),
)
const validator = value.validator
validator.unsafeCast(["test", "text"])
expect(() => validator.unsafeCast([3, 4])).toThrowError()
expect(() => validator.unsafeCast(null)).toThrowError()
testOutput<typeof validator._TYPE, string[]>()(null)
expect(await value.build({} as any)).toMatchObject({
name: "test",
spec: { patterns: [] },
})
})
})
})
describe("Nested nullable values", () => {
test("Testing text", async () => {
const value = InputSpec.of({
a: Value.text({
name: "Temp Name",
description:
"If no name is provided, the name from inputSpec will be used",
required: false,
default: null,
}),
})
const validator = value.validator
validator.unsafeCast({ a: null })
validator.unsafeCast({ a: "test" })
expect(() => validator.unsafeCast({ a: 4 })).toThrowError()
testOutput<typeof validator._TYPE, { a: string | null }>()(null)
})
test("Testing number", async () => {
const value = InputSpec.of({
a: Value.number({
name: "Temp Name",
description:
"If no name is provided, the name from inputSpec will be used",
required: false,
default: null,
warning: null,
placeholder: null,
integer: false,
min: null,
max: null,
step: null,
units: null,
}),
})
const validator = value.validator
validator.unsafeCast({ a: null })
validator.unsafeCast({ a: 5 })
expect(() => validator.unsafeCast({ a: "4" })).toThrowError()
testOutput<typeof validator._TYPE, { a: number | null }>()(null)
})
test("Testing color", async () => {
const value = InputSpec.of({
a: Value.color({
name: "Temp Name",
description:
"If no name is provided, the name from inputSpec will be used",
required: false,
default: null,
warning: null,
}),
})
const validator = value.validator
validator.unsafeCast({ a: null })
validator.unsafeCast({ a: "5" })
expect(() => validator.unsafeCast({ a: 4 })).toThrowError()
testOutput<typeof validator._TYPE, { a: string | null }>()(null)
})
test("Testing select", async () => {
const value = InputSpec.of({
a: Value.select({
name: "Temp Name",
description:
"If no name is provided, the name from inputSpec will be used",
default: "a",
warning: null,
values: {
a: "A",
},
}),
})
const higher = await Value.select({
name: "Temp Name",
description:
"If no name is provided, the name from inputSpec will be used",
default: "a",
warning: null,
values: {
a: "A",
},
}).build({} as any)
const validator = value.validator
validator.unsafeCast({ a: "a" })
expect(() => validator.unsafeCast({ a: "4" })).toThrowError()
testOutput<typeof validator._TYPE, { a: "a" }>()(null)
})
test("Testing multiselect", async () => {
const value = InputSpec.of({
a: Value.multiselect({
name: "Temp Name",
description:
"If no name is provided, the name from inputSpec will be used",
warning: null,
default: [],
values: {
a: "A",
},
minLength: null,
maxLength: null,
}),
})
const validator = value.validator
validator.unsafeCast({ a: [] })
validator.unsafeCast({ a: ["a"] })
expect(() => validator.unsafeCast({ a: ["4"] })).toThrowError()
expect(() => validator.unsafeCast({ a: "4" })).toThrowError()
testOutput<typeof validator._TYPE, { a: "a"[] }>()(null)
})
})

View File

@@ -0,0 +1,428 @@
import { oldSpecToBuilder } from "../../scripts/oldSpecToBuilder"
oldSpecToBuilder(
// Make the location
"./lib/test/output.ts",
// Put the inputSpec here
{
mediasources: {
type: "list",
subtype: "enum",
name: "Media Sources",
description: "List of Media Sources to use with Jellyfin",
range: "[1,*)",
default: ["nextcloud"],
spec: {
values: ["nextcloud", "filebrowser"],
"value-names": {
nextcloud: "NextCloud",
filebrowser: "File Browser",
},
},
},
testListUnion: {
type: "list",
subtype: "union",
name: "Lightning Nodes",
description: "List of Lightning Network node instances to manage",
range: "[1,*)",
default: ["lnd"],
spec: {
type: "string",
"display-as": "{{name}}",
"unique-by": "name",
name: "Node Implementation",
tag: {
id: "type",
name: "Type",
description:
"- LND: Lightning Network Daemon from Lightning Labs\n- CLN: Core Lightning from Blockstream\n",
"variant-names": {
lnd: "Lightning Network Daemon (LND)",
"c-lightning": "Core Lightning (CLN)",
},
},
default: "lnd",
variants: {
lnd: {
name: {
type: "string",
name: "Node Name",
description: "Name of this node in the list",
default: "LND Wrapper",
nullable: false,
},
},
},
},
},
rpc: {
type: "object",
name: "RPC Settings",
description: "RPC configuration options.",
spec: {
enable: {
type: "boolean",
name: "Enable",
description: "Allow remote RPC requests.",
default: true,
},
username: {
type: "string",
nullable: false,
name: "Username",
description: "The username for connecting to Bitcoin over RPC.",
default: "bitcoin",
masked: true,
pattern: "^[a-zA-Z0-9_]+$",
"pattern-description":
"Must be alphanumeric (can contain underscore).",
},
password: {
type: "string",
nullable: false,
name: "RPC Password",
description: "The password for connecting to Bitcoin over RPC.",
default: {
charset: "a-z,2-7",
len: 20,
},
pattern: '^[^\\n"]*$',
"pattern-description":
"Must not contain newline or quote characters.",
copyable: true,
masked: true,
},
bio: {
type: "string",
nullable: false,
name: "Username",
description: "The username for connecting to Bitcoin over RPC.",
default: "bitcoin",
masked: true,
pattern: "^[a-zA-Z0-9_]+$",
"pattern-description":
"Must be alphanumeric (can contain underscore).",
textarea: true,
},
advanced: {
type: "object",
name: "Advanced",
description: "Advanced RPC Settings",
spec: {
auth: {
name: "Authorization",
description:
"Username and hashed password for JSON-RPC connections. RPC clients connect using the usual http basic authentication.",
type: "list",
subtype: "string",
default: [],
spec: {
pattern:
"^[a-zA-Z0-9_-]+:([0-9a-fA-F]{2})+\\$([0-9a-fA-F]{2})+$",
"pattern-description":
'Each item must be of the form "<USERNAME>:<SALT>$<HASH>".',
masked: false,
},
range: "[0,*)",
},
serialversion: {
name: "Serialization Version",
description:
"Return raw transaction or block hex with Segwit or non-SegWit serialization.",
type: "enum",
values: ["non-segwit", "segwit"],
"value-names": {},
default: "segwit",
},
servertimeout: {
name: "Rpc Server Timeout",
description:
"Number of seconds after which an uncompleted RPC call will time out.",
type: "number",
nullable: false,
range: "[5,300]",
integral: true,
units: "seconds",
default: 30,
},
threads: {
name: "Threads",
description:
"Set the number of threads for handling RPC calls. You may wish to increase this if you are making lots of calls via an integration.",
type: "number",
nullable: false,
default: 16,
range: "[1,64]",
integral: true,
},
workqueue: {
name: "Work Queue",
description:
"Set the depth of the work queue to service RPC calls. Determines how long the backlog of RPC requests can get before it just rejects new ones.",
type: "number",
nullable: false,
default: 128,
range: "[8,256]",
integral: true,
units: "requests",
},
},
},
},
},
"zmq-enabled": {
type: "boolean",
name: "ZeroMQ Enabled",
description: "Enable the ZeroMQ interface",
default: true,
},
txindex: {
type: "boolean",
name: "Transaction Index",
description: "Enable the Transaction Index (txindex)",
default: true,
},
wallet: {
type: "object",
name: "Wallet",
description: "Wallet Settings",
spec: {
enable: {
name: "Enable Wallet",
description: "Load the wallet and enable wallet RPC calls.",
type: "boolean",
default: true,
},
avoidpartialspends: {
name: "Avoid Partial Spends",
description:
"Group outputs by address, selecting all or none, instead of selecting on a per-output basis. This improves privacy at the expense of higher transaction fees.",
type: "boolean",
default: true,
},
discardfee: {
name: "Discard Change Tolerance",
description:
"The fee rate (in BTC/kB) that indicates your tolerance for discarding change by adding it to the fee.",
type: "number",
nullable: false,
default: 0.0001,
range: "[0,.01]",
integral: false,
units: "BTC/kB",
},
},
},
advanced: {
type: "object",
name: "Advanced",
description: "Advanced Settings",
spec: {
mempool: {
type: "object",
name: "Mempool",
description: "Mempool Settings",
spec: {
mempoolfullrbf: {
name: "Enable Full RBF",
description:
"Policy for your node to use for relaying and mining unconfirmed transactions. For details, see https://github.com/bitcoin/bitcoin/blob/master/doc/release-notes/release-notes-24.0.md#notice-of-new-option-for-transaction-replacement-policies",
type: "boolean",
default: false,
},
persistmempool: {
type: "boolean",
name: "Persist Mempool",
description: "Save the mempool on shutdown and load on restart.",
default: true,
},
maxmempool: {
type: "number",
nullable: false,
name: "Max Mempool Size",
description:
"Keep the transaction memory pool below <n> megabytes.",
range: "[1,*)",
integral: true,
units: "MiB",
default: 300,
},
mempoolexpiry: {
type: "number",
nullable: false,
name: "Mempool Expiration",
description:
"Do not keep transactions in the mempool longer than <n> hours.",
range: "[1,*)",
integral: true,
units: "Hr",
default: 336,
},
},
},
peers: {
type: "object",
name: "Peers",
description: "Peer Connection Settings",
spec: {
listen: {
type: "boolean",
name: "Make Public",
description:
"Allow other nodes to find your server on the network.",
default: true,
},
onlyconnect: {
type: "boolean",
name: "Disable Peer Discovery",
description: "Only connect to specified peers.",
default: false,
},
onlyonion: {
type: "boolean",
name: "Disable Clearnet",
description: "Only connect to peers over Tor.",
default: false,
},
addnode: {
name: "Add Nodes",
description: "Add addresses of nodes to connect to.",
type: "list",
subtype: "object",
range: "[0,*)",
default: [],
spec: {
"unique-by": null,
spec: {
hostname: {
type: "string",
nullable: true,
name: "Hostname",
description: "Domain or IP address of bitcoin peer",
pattern:
"(^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$)|((^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$)|(^[a-z2-7]{16}\\.onion$)|(^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$))",
"pattern-description":
"Must be either a domain name, or an IPv4 or IPv6 address. Do not include protocol scheme (eg 'http://') or port.",
masked: false,
},
port: {
type: "number",
nullable: true,
name: "Port",
description:
"Port that peer is listening on for inbound p2p connections",
range: "[0,65535]",
integral: true,
},
},
},
},
},
},
dbcache: {
type: "number",
nullable: true,
name: "Database Cache",
description:
"How much RAM to allocate for caching the TXO set. Higher values improve syncing performance, but increase your chance of using up all your system's memory or corrupting your database in the event of an ungraceful shutdown. Set this high but comfortably below your system's total RAM during IBD, then turn down to 450 (or leave blank) once the sync completes.",
warning:
"WARNING: Increasing this value results in a higher chance of ungraceful shutdowns, which can leave your node unusable if it happens during the initial block download. Use this setting with caution. Be sure to set this back to the default (450 or leave blank) once your node is synced. DO NOT press the STOP button if your dbcache is large. Instead, set this number back to the default, hit save, and wait for bitcoind to restart on its own.",
range: "(0,*)",
integral: true,
units: "MiB",
},
pruning: {
type: "union",
name: "Pruning Settings",
description:
"Blockchain Pruning Options\nReduce the blockchain size on disk\n",
warning:
"If you set pruning to Manual and your disk is smaller than the total size of the blockchain, you MUST have something running that prunes these blocks or you may overfill your disk!\nDisabling pruning will convert your node into a full archival node. This requires a resync of the entire blockchain, a process that may take several days. Make sure you have enough free disk space or you may fill up your disk.\n",
tag: {
id: "mode",
name: "Pruning Mode",
description:
'- Disabled: Disable pruning\n- Automatic: Limit blockchain size on disk to a certain number of megabytes\n- Manual: Prune blockchain with the "pruneblockchain" RPC\n',
"variant-names": {
disabled: "Disabled",
automatic: "Automatic",
manual: "Manual",
},
},
variants: {
disabled: {},
automatic: {
size: {
type: "number",
nullable: false,
name: "Max Chain Size",
description: "Limit of blockchain size on disk.",
warning:
"Increasing this value will require re-syncing your node.",
default: 550,
range: "[550,1000000)",
integral: true,
units: "MiB",
},
},
manual: {
size: {
type: "number",
nullable: false,
name: "Failsafe Chain Size",
description: "Prune blockchain if size expands beyond this.",
default: 65536,
range: "[550,1000000)",
integral: true,
units: "MiB",
},
},
},
default: "disabled",
},
blockfilters: {
type: "object",
name: "Block Filters",
description: "Settings for storing and serving compact block filters",
spec: {
blockfilterindex: {
type: "boolean",
name: "Compute Compact Block Filters (BIP158)",
description:
"Generate Compact Block Filters during initial sync (IBD) to enable 'getblockfilter' RPC. This is useful if dependent services need block filters to efficiently scan for addresses/transactions etc.",
default: true,
},
peerblockfilters: {
type: "boolean",
name: "Serve Compact Block Filters to Peers (BIP157)",
description:
"Serve Compact Block Filters as a peer service to other nodes on the network. This is useful if you wish to connect an SPV client to your node to make it efficient to scan transactions without having to download all block data. 'Compute Compact Block Filters (BIP158)' is required.",
default: false,
},
},
},
bloomfilters: {
type: "object",
name: "Bloom Filters (BIP37)",
description: "Setting for serving Bloom Filters",
spec: {
peerbloomfilters: {
type: "boolean",
name: "Serve Bloom Filters to Peers",
description:
"Peers have the option of setting filters on each connection they make after the version handshake has completed. Bloom filters are for clients implementing SPV (Simplified Payment Verification) that want to check that block headers connect together correctly, without needing to verify the full blockchain. The client must trust that the transactions in the chain are in fact valid. It is highly recommended AGAINST using for anything except Bisq integration.",
warning:
"This is ONLY for use with Bisq integration, please use Block Filters for all other applications.",
default: false,
},
},
},
},
},
},
{
// convert this to `start-sdk/lib` for conversions
StartSdk: "./output.sdk",
},
)

View File

@@ -0,0 +1,53 @@
import { CurrentDependenciesResult } from "../../../base/lib/dependencies/setupDependencies"
import { StartSdk } from "../StartSdk"
import { setupManifest } from "../manifest/setupManifest"
import { VersionGraph } from "../version/VersionGraph"
export type Manifest = any
export const sdk = StartSdk.of()
.withManifest(
setupManifest({
id: "testOutput",
title: "",
license: "",
replaces: [],
wrapperRepo: "",
upstreamRepo: "",
supportSite: "",
marketingSite: "",
donationUrl: null,
description: {
short: "",
long: "",
},
containers: {},
images: {
main: {
source: {
dockerTag: "start9/hello-world",
},
arch: ["aarch64", "x86_64"],
emulateMissingAs: "aarch64",
},
},
volumes: [],
assets: [],
alerts: {
install: null,
update: null,
uninstall: null,
restore: null,
start: null,
stop: null,
},
dependencies: {
"remote-test": {
description: "",
optional: false,
s9pk: "https://example.com/remote-test.s9pk",
},
},
}),
)
.withStore<{ storeRoot: { storeLeaf: "value" } }>()
.build(true)

View File

@@ -0,0 +1,146 @@
import { InputSpecSpec, matchInputSpecSpec } from "./output"
import * as _I from "../index"
import { camelCase } from "../../scripts/oldSpecToBuilder"
import { deepMerge } from "../../../base/lib/util"
export type IfEquals<T, U, Y = unknown, N = never> =
(<G>() => G extends T ? 1 : 2) extends <G>() => G extends U ? 1 : 2 ? Y : N
export function testOutput<A, B>(): (c: IfEquals<A, B>) => null {
return () => null
}
/// Testing the types of the input spec
testOutput<InputSpecSpec["rpc"]["enable"], boolean>()(null)
testOutput<InputSpecSpec["rpc"]["username"], string>()(null)
testOutput<InputSpecSpec["rpc"]["username"], string>()(null)
testOutput<InputSpecSpec["rpc"]["advanced"]["auth"], string[]>()(null)
testOutput<
InputSpecSpec["rpc"]["advanced"]["serialversion"],
"segwit" | "non-segwit"
>()(null)
testOutput<InputSpecSpec["rpc"]["advanced"]["servertimeout"], number>()(null)
testOutput<
InputSpecSpec["advanced"]["peers"]["addnode"][0]["hostname"],
string | null
>()(null)
testOutput<
InputSpecSpec["testListUnion"][0]["union"]["value"]["name"],
string
>()(null)
testOutput<InputSpecSpec["testListUnion"][0]["union"]["selection"], "lnd">()(
null,
)
testOutput<InputSpecSpec["mediasources"], Array<"filebrowser" | "nextcloud">>()(
null,
)
// @ts-expect-error Because enable should be a boolean
testOutput<InputSpecSpec["rpc"]["enable"], string>()(null)
// prettier-ignore
// @ts-expect-error Expect that the string is the one above
testOutput<InputSpecSpec["testListUnion"][0]['selection']['selection'], "selection">()(null);
/// Here we test the output of the matchInputSpecSpec function
describe("Inputs", () => {
const validInput: InputSpecSpec = {
mediasources: ["filebrowser"],
testListUnion: [
{
union: { selection: "lnd", value: { name: "string" } },
},
],
rpc: {
enable: true,
bio: "This is a bio",
username: "test",
password: "test",
advanced: {
auth: ["test"],
serialversion: "segwit",
servertimeout: 6,
threads: 3,
workqueue: 9,
},
},
"zmq-enabled": false,
txindex: false,
wallet: { enable: false, avoidpartialspends: false, discardfee: 0.0001 },
advanced: {
mempool: {
maxmempool: 1,
persistmempool: true,
mempoolexpiry: 23,
mempoolfullrbf: true,
},
peers: {
listen: true,
onlyconnect: true,
onlyonion: true,
addnode: [
{
hostname: "test",
port: 1,
},
],
},
dbcache: 5,
pruning: {
selection: "disabled",
value: { disabled: {} },
},
blockfilters: {
blockfilterindex: false,
peerblockfilters: false,
},
bloomfilters: { peerbloomfilters: false },
},
}
test("test valid input", () => {
const output = matchInputSpecSpec.unsafeCast(validInput)
expect(output).toEqual(validInput)
})
test("test no longer care about the conversion of min/max and validating", () => {
matchInputSpecSpec.unsafeCast(
deepMerge({}, validInput, { rpc: { advanced: { threads: 0 } } }),
)
})
test("test errors should throw for number in string", () => {
expect(() =>
matchInputSpecSpec.unsafeCast(
deepMerge({}, validInput, { rpc: { enable: 2 } }),
),
).toThrowError()
})
test("Test that we set serialversion to something not segwit or non-segwit", () => {
expect(() =>
matchInputSpecSpec.unsafeCast(
deepMerge({}, validInput, {
rpc: { advanced: { serialversion: "testing" } },
}),
),
).toThrowError()
})
})
describe("camelCase", () => {
test("'EquipmentClass name'", () => {
expect(camelCase("EquipmentClass name")).toEqual("equipmentClassName")
})
test("'Equipment className'", () => {
expect(camelCase("Equipment className")).toEqual("equipmentClassName")
})
test("'equipment class name'", () => {
expect(camelCase("equipment class name")).toEqual("equipmentClassName")
})
test("'Equipment Class Name'", () => {
expect(camelCase("Equipment Class Name")).toEqual("equipmentClassName")
})
test("'hyphen-name-format'", () => {
expect(camelCase("hyphen-name-format")).toEqual("hyphenNameFormat")
})
test("'underscore_name_format'", () => {
expect(camelCase("underscore_name_format")).toEqual("underscoreNameFormat")
})
})

View File

@@ -0,0 +1,111 @@
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

@@ -0,0 +1,5 @@
import { HealthStatus } from "../../../base/lib/types"
export type TriggerInput = {
lastResult?: HealthStatus
}

View File

@@ -0,0 +1,32 @@
import { Trigger } from "./index"
export function changeOnFirstSuccess(o: {
beforeFirstSuccess: Trigger
afterFirstSuccess: Trigger
}): Trigger {
return async function* (getInput) {
let currentValue = getInput()
while (!currentValue.lastResult) {
yield
currentValue = getInput()
}
const beforeFirstSuccess = o.beforeFirstSuccess(getInput)
for (
let res = await beforeFirstSuccess.next();
currentValue?.lastResult !== "success" && !res.done;
res = await beforeFirstSuccess.next()
) {
yield
currentValue = getInput()
}
const afterFirstSuccess = o.afterFirstSuccess(getInput)
for (
let res = await afterFirstSuccess.next();
!res.done;
res = await afterFirstSuccess.next()
) {
yield
currentValue = getInput()
}
}
}

View File

@@ -0,0 +1,8 @@
export function cooldownTrigger(timeMs: number) {
return async function* () {
while (true) {
await new Promise((resolve) => setTimeout(resolve, timeMs))
yield
}
}
}

View File

@@ -0,0 +1,8 @@
import { cooldownTrigger } from "./cooldownTrigger"
import { changeOnFirstSuccess } from "./changeOnFirstSuccess"
import { successFailure } from "./successFailure"
export const defaultTrigger = changeOnFirstSuccess({
beforeFirstSuccess: cooldownTrigger(1000),
afterFirstSuccess: cooldownTrigger(30000),
})

View File

@@ -0,0 +1,7 @@
import { TriggerInput } from "./TriggerInput"
export { changeOnFirstSuccess } from "./changeOnFirstSuccess"
export { cooldownTrigger } from "./cooldownTrigger"
export type Trigger = (
getInput: () => TriggerInput,
) => AsyncIterator<unknown, unknown, never>

View File

@@ -0,0 +1,33 @@
import { Trigger } from "."
import { HealthStatus } from "../../../base/lib/types"
export type LastStatusTriggerParams = { [k in HealthStatus]?: Trigger } & {
default: Trigger
}
export function lastStatus(o: LastStatusTriggerParams): Trigger {
return async function* (getInput) {
let trigger = o.default(getInput)
const triggers: {
[k in HealthStatus]?: AsyncIterator<unknown, unknown, never>
} & { default: AsyncIterator<unknown, unknown, never> } = {
default: trigger,
}
while (true) {
let currentValue = getInput()
let prev: HealthStatus | "default" | undefined = currentValue.lastResult
if (!prev) {
yield
continue
}
if (!(prev in o)) {
prev = "default"
}
if (!triggers[prev]) {
triggers[prev] = o[prev]!(getInput)
}
await triggers[prev]?.next()
yield
}
}
}

View File

@@ -0,0 +1,7 @@
import { Trigger } from "."
import { lastStatus } from "./lastStatus"
export const successFailure = (o: {
duringSuccess: Trigger
duringError: Trigger
}) => lastStatus({ success: o.duringSuccess, default: o.duringError })

View File

@@ -0,0 +1,47 @@
import { T } from ".."
import { Effects } from "../../../base/lib/Effects"
export class GetSslCertificate {
constructor(
readonly effects: Effects,
readonly hostnames: string[],
readonly algorithm?: T.Algorithm,
) {}
/**
* Returns the system SMTP credentials. Restarts the service if the credentials change
*/
const() {
return this.effects.getSslCertificate({
hostnames: this.hostnames,
algorithm: this.algorithm,
callback: () => this.effects.constRetry(),
})
}
/**
* Returns the system SMTP credentials. Does nothing if the credentials change
*/
once() {
return this.effects.getSslCertificate({
hostnames: this.hostnames,
algorithm: this.algorithm,
})
}
/**
* Watches the system SMTP credentials. Takes a custom callback function to run whenever the credentials change
*/
async *watch() {
while (true) {
let callback: () => void
const waitForNext = new Promise<void>((resolve) => {
callback = resolve
})
yield await this.effects.getSslCertificate({
hostnames: this.hostnames,
algorithm: this.algorithm,
callback: () => callback(),
})
await waitForNext
}
}
}

View File

@@ -0,0 +1,439 @@
import * as fs from "fs/promises"
import * as T from "../../../base/lib/types"
import * as cp from "child_process"
import { promisify } from "util"
import { Buffer } from "node:buffer"
import { once } from "../../../base/lib/util/once"
export const execFile = promisify(cp.execFile)
const WORKDIR = (imageId: string) => `/media/startos/images/${imageId}/`
const False = () => false
type ExecResults = {
exitCode: number | null
exitSignal: NodeJS.Signals | null
stdout: string | Buffer
stderr: string | Buffer
}
export type ExecOptions = {
input?: string | Buffer
}
const TIMES_TO_WAIT_FOR_PROC = 100
/**
* This is the type that is going to describe what an subcontainer could do. The main point of the
* subcontainer is to have commands that run in a chrooted environment. This is useful for running
* commands in a containerized environment. But, I wanted the destroy to sometimes be doable, for example the
* case where the subcontainer isn't owned by the process, the subcontainer shouldn't be destroyed.
*/
export interface ExecSpawnable {
get destroy(): undefined | (() => Promise<null>)
exec(
command: string[],
options?: CommandOptions & ExecOptions,
timeoutMs?: number | null,
): Promise<ExecResults>
spawn(
command: string[],
options?: CommandOptions & StdioOptions,
): Promise<cp.ChildProcess>
}
/**
* Want to limit what we can do in a container, so we want to launch a container with a specific image and the mounts.
*
* Implements:
* @see {@link ExecSpawnable}
*/
export class SubContainer implements ExecSpawnable {
private leader: cp.ChildProcess
private leaderExited: boolean = false
private waitProc: () => Promise<null>
private constructor(
readonly effects: T.Effects,
readonly imageId: T.ImageId,
readonly rootfs: string,
readonly guid: T.Guid,
) {
this.leaderExited = false
this.leader = cp.spawn("start-cli", ["subcontainer", "launch", rootfs], {
killSignal: "SIGKILL",
stdio: "inherit",
})
this.leader.on("exit", () => {
this.leaderExited = true
})
this.waitProc = once(
() =>
new Promise(async (resolve, reject) => {
let count = 0
while (
!(await fs.stat(`${this.rootfs}/proc/1`).then((x) => !!x, False))
) {
if (count++ > TIMES_TO_WAIT_FOR_PROC) {
console.debug("Failed to start subcontainer", {
guid: this.guid,
imageId: this.imageId,
rootfs: this.rootfs,
})
reject(new Error(`Failed to start subcontainer ${this.imageId}`))
}
await wait(1)
}
resolve(null)
}),
)
}
static async of(
effects: T.Effects,
image: { id: T.ImageId; sharedRun?: boolean },
name: string,
) {
const { id, sharedRun } = image
const [rootfs, guid] = await effects.subcontainer.createFs({
imageId: id as string,
name,
})
const shared = ["dev", "sys"]
if (!!sharedRun) {
shared.push("run")
}
await fs.mkdir(`${rootfs}/etc`, { recursive: true })
await fs.copyFile("/etc/resolv.conf", `${rootfs}/etc/resolv.conf`)
for (const dirPart of shared) {
const from = `/${dirPart}`
const to = `${rootfs}/${dirPart}`
await fs.mkdir(from, { recursive: true })
await fs.mkdir(to, { recursive: true })
await execFile("mount", ["--rbind", from, to])
}
return new SubContainer(effects, id, rootfs, guid)
}
static async with<T>(
effects: T.Effects,
image: { id: T.ImageId; sharedRun?: boolean },
mounts: { options: MountOptions; path: string }[],
name: string,
fn: (subContainer: SubContainer) => Promise<T>,
): Promise<T> {
const subContainer = await SubContainer.of(effects, image, name)
try {
for (let mount of mounts) {
await subContainer.mount(mount.options, mount.path)
}
return await fn(subContainer)
} finally {
await subContainer.destroy()
}
}
async mount(options: MountOptions, path: string): Promise<SubContainer> {
path = path.startsWith("/")
? `${this.rootfs}${path}`
: `${this.rootfs}/${path}`
if (options.type === "volume") {
const subpath = options.subpath
? options.subpath.startsWith("/")
? options.subpath
: `/${options.subpath}`
: "/"
const from = `/media/startos/volumes/${options.id}${subpath}`
await fs.mkdir(from, { recursive: true })
await fs.mkdir(path, { recursive: true })
await execFile("mount", ["--bind", from, path])
} else if (options.type === "assets") {
const subpath = options.subpath
? options.subpath.startsWith("/")
? options.subpath
: `/${options.subpath}`
: "/"
const from = `/media/startos/assets/${options.id}${subpath}`
await fs.mkdir(from, { recursive: true })
await fs.mkdir(path, { recursive: true })
await execFile("mount", ["--bind", from, path])
} else if (options.type === "pointer") {
await this.effects.mount({ location: path, target: options })
} else if (options.type === "backup") {
const subpath = options.subpath
? options.subpath.startsWith("/")
? options.subpath
: `/${options.subpath}`
: "/"
const from = `/media/startos/backup${subpath}`
await fs.mkdir(from, { recursive: true })
await fs.mkdir(path, { recursive: true })
await execFile("mount", ["--bind", from, path])
} else {
throw new Error(`unknown type ${(options as any).type}`)
}
return this
}
private async killLeader() {
if (this.leaderExited) {
return
}
return new Promise<null>((resolve, reject) => {
try {
let timeout = setTimeout(() => this.leader.kill("SIGKILL"), 30000)
this.leader.on("exit", () => {
clearTimeout(timeout)
resolve(null)
})
if (!this.leader.kill("SIGTERM")) {
reject(new Error("kill(2) failed"))
}
} catch (e) {
reject(e)
}
})
}
get destroy() {
return async () => {
const guid = this.guid
await this.killLeader()
await this.effects.subcontainer.destroyFs({ guid })
return null
}
}
async exec(
command: string[],
options?: CommandOptions & ExecOptions,
timeoutMs: number | null = 30000,
): Promise<{
exitCode: number | null
exitSignal: NodeJS.Signals | null
stdout: string | Buffer
stderr: string | Buffer
}> {
await this.waitProc()
const imageMeta: T.ImageMetadata = await fs
.readFile(`/media/startos/images/${this.imageId}.json`, {
encoding: "utf8",
})
.catch(() => "{}")
.then(JSON.parse)
let extra: string[] = []
if (options?.user) {
extra.push(`--user=${options.user}`)
delete options.user
}
let workdir = imageMeta.workdir || "/"
if (options?.cwd) {
workdir = options.cwd
delete options.cwd
}
const child = cp.spawn(
"start-cli",
[
"subcontainer",
"exec",
`--env=/media/startos/images/${this.imageId}.env`,
`--workdir=${workdir}`,
...extra,
this.rootfs,
...command,
],
options || {},
)
if (options?.input) {
await new Promise<null>((resolve, reject) =>
child.stdin.write(options.input, (e) => {
if (e) {
reject(e)
} else {
resolve(null)
}
}),
)
await new Promise<null>((resolve) => child.stdin.end(resolve))
}
const pid = child.pid
const stdout = { data: "" as string }
const stderr = { data: "" as string }
const appendData =
(appendTo: { data: string }) => (chunk: string | Buffer | any) => {
if (typeof chunk === "string" || chunk instanceof Buffer) {
appendTo.data += chunk.toString()
} else {
console.error("received unexpected chunk", chunk)
}
}
return new Promise((resolve, reject) => {
child.on("error", reject)
let killTimeout: NodeJS.Timeout | undefined
if (timeoutMs !== null && child.pid) {
killTimeout = setTimeout(() => child.kill("SIGKILL"), timeoutMs)
}
child.stdout.on("data", appendData(stdout))
child.stderr.on("data", appendData(stderr))
child.on("exit", (code, signal) => {
clearTimeout(killTimeout)
resolve({
exitCode: code,
exitSignal: signal,
stdout: stdout.data,
stderr: stderr.data,
})
})
})
}
async launch(
command: string[],
options?: CommandOptions,
): Promise<cp.ChildProcessWithoutNullStreams> {
await this.waitProc()
const imageMeta: any = await fs
.readFile(`/media/startos/images/${this.imageId}.json`, {
encoding: "utf8",
})
.catch(() => "{}")
.then(JSON.parse)
let extra: string[] = []
if (options?.user) {
extra.push(`--user=${options.user}`)
delete options.user
}
let workdir = imageMeta.workdir || "/"
if (options?.cwd) {
workdir = options.cwd
delete options.cwd
}
await this.killLeader()
this.leaderExited = false
this.leader = cp.spawn(
"start-cli",
[
"subcontainer",
"launch",
`--env=/media/startos/images/${this.imageId}.env`,
`--workdir=${workdir}`,
...extra,
this.rootfs,
...command,
],
{ ...options, stdio: "inherit" },
)
this.leader.on("exit", () => {
this.leaderExited = true
})
return this.leader as cp.ChildProcessWithoutNullStreams
}
async spawn(
command: string[],
options: CommandOptions & StdioOptions = { stdio: "inherit" },
): Promise<cp.ChildProcess> {
await this.waitProc()
const imageMeta: any = await fs
.readFile(`/media/startos/images/${this.imageId}.json`, {
encoding: "utf8",
})
.catch(() => "{}")
.then(JSON.parse)
let extra: string[] = []
if (options.user) {
extra.push(`--user=${options.user}`)
delete options.user
}
let workdir = imageMeta.workdir || "/"
if (options.cwd) {
workdir = options.cwd
delete options.cwd
}
return cp.spawn(
"start-cli",
[
"subcontainer",
"exec",
`--env=/media/startos/images/${this.imageId}.env`,
`--workdir=${workdir}`,
...extra,
this.rootfs,
...command,
],
options,
)
}
}
/**
* Take an subcontainer but remove the ability to add the mounts and the destroy function.
* Lets other functions, like health checks, to not destroy the parents.
*
*/
export class SubContainerHandle implements ExecSpawnable {
constructor(private subContainer: ExecSpawnable) {}
get destroy() {
return undefined
}
exec(
command: string[],
options?: CommandOptions,
timeoutMs?: number | null,
): Promise<ExecResults> {
return this.subContainer.exec(command, options, timeoutMs)
}
spawn(
command: string[],
options: CommandOptions & StdioOptions = { stdio: "inherit" },
): Promise<cp.ChildProcess> {
return this.subContainer.spawn(command, options)
}
}
export type CommandOptions = {
env?: { [variable: string]: string }
cwd?: string
user?: string
}
export type StdioOptions = {
stdio?: cp.IOType
}
export type MountOptions =
| MountOptionsVolume
| MountOptionsAssets
| MountOptionsPointer
| MountOptionsBackup
export type MountOptionsVolume = {
type: "volume"
id: string
subpath: string | null
readonly: boolean
}
export type MountOptionsAssets = {
type: "assets"
id: string
subpath: string | null
}
export type MountOptionsPointer = {
type: "pointer"
packageId: string
volumeId: string
subpath: string | null
readonly: boolean
}
export type MountOptionsBackup = {
type: "backup"
subpath: string | null
}
function wait(time: number) {
return new Promise((resolve) => setTimeout(resolve, time))
}

View File

@@ -0,0 +1,246 @@
import * as matches from "ts-matches"
import * as YAML from "yaml"
import * as TOML from "@iarna/toml"
import merge from "lodash.merge"
import * as T from "../../../base/lib/types"
import * as fs from "node:fs/promises"
import { asError } from "../../../base/lib/util"
const previousPath = /(.+?)\/([^/]*)$/
const exists = (path: string) =>
fs.access(path).then(
() => true,
() => false,
)
async function onCreated(path: string) {
if (path === "/") return
if (!path.startsWith("/")) path = `${process.cwd()}/${path}`
if (await exists(path)) {
return
}
const split = path.split("/")
const filename = split.pop()
const parent = split.join("/")
await onCreated(parent)
const ctrl = new AbortController()
const watch = fs.watch(parent, { persistent: false, signal: ctrl.signal })
if (
await fs.access(path).then(
() => true,
() => false,
)
) {
ctrl.abort("finished")
return
}
for await (let event of watch) {
if (event.filename === filename) {
ctrl.abort("finished")
return
}
}
}
/**
* @description Use this class to read/write an underlying configuration file belonging to the upstream service.
*
* Using the static functions, choose between officially supported file formats (json, yaml, toml), or a custom format (raw).
* @example
* Below are a few examples
*
* ```
* import { matches, FileHelper } from '@start9labs/start-sdk'
* const { arrayOf, boolean, literal, literals, object, oneOf, natural, string } = matches
*
* export const jsonFile = FileHelper.json('./inputSpec.json', object({
* passwords: arrayOf(string)
* type: oneOf(literals('private', 'public'))
* }))
*
* export const tomlFile = FileHelper.toml('./inputSpec.toml', object({
* url: literal('https://start9.com')
* public: boolean
* }))
*
* export const yamlFile = FileHelper.yaml('./inputSpec.yml', object({
* name: string
* age: natural
* }))
*
* export const bitcoinConfFile = FileHelper.raw(
* './service.conf',
* (obj: CustomType) => customConvertObjToFormattedString(obj),
* (str) => customParseStringToTypedObj(str),
* )
* ```
*/
export class FileHelper<A> {
protected constructor(
readonly path: string,
readonly writeData: (dataIn: A) => string,
readonly readData: (stringValue: string) => unknown,
readonly validate: (value: unknown) => A,
) {}
/**
* Accepts structured data and overwrites the existing file on disk.
*/
private async writeFile(data: A): Promise<null> {
const parent = previousPath.exec(this.path)
if (parent) {
await fs.mkdir(parent[1], { recursive: true })
}
await fs.writeFile(this.path, this.writeData(data))
return null
}
private async readFile(): Promise<unknown> {
if (!(await exists(this.path))) {
return null
}
return this.readData(
await fs.readFile(this.path).then((data) => data.toString("utf-8")),
)
}
/**
* Reads the file from disk and converts it to structured data.
*/
private async readOnce(): Promise<A | null> {
const data = await this.readFile()
if (!data) return null
return this.validate(data)
}
private async readConst(effects: T.Effects): Promise<A | null> {
const watch = this.readWatch()
const res = await watch.next()
watch.next().then(effects.constRetry)
return res.value
}
private async *readWatch() {
let res
while (true) {
if (await exists(this.path)) {
const ctrl = new AbortController()
const watch = fs.watch(this.path, {
persistent: false,
signal: ctrl.signal,
})
res = await this.readOnce()
const listen = Promise.resolve()
.then(async () => {
for await (const _ of watch) {
ctrl.abort("finished")
return null
}
})
.catch((e) => console.error(asError(e)))
yield res
await listen
} else {
yield null
await onCreated(this.path).catch((e) => console.error(asError(e)))
}
}
return null
}
get read() {
return {
once: () => this.readOnce(),
const: (effects: T.Effects) => this.readConst(effects),
watch: () => this.readWatch(),
}
}
/**
* Accepts full structured data and performs a merge with the existing file on disk if it exists.
*/
async write(data: A) {
const fileData = (await this.readFile()) || {}
const mergeData = merge({}, fileData, data)
return await this.writeFile(this.validate(mergeData))
}
/**
* Accepts partial structured data and performs a merge with the existing file on disk.
*/
async merge(data: T.DeepPartial<A>) {
const fileData =
(await this.readFile()) ||
(() => {
throw new Error(`${this.path}: does not exist`)
})()
const mergeData = merge({}, fileData, data)
return await this.writeFile(this.validate(mergeData))
}
/**
* We wanted to be able to have a fileHelper, and just modify the path later in time.
* Like one behaviour of another dependency or something similar.
*/
withPath(path: string) {
return new FileHelper<A>(path, this.writeData, this.readData, this.validate)
}
/**
* Create a File Helper for an arbitrary file type.
*
* Provide custom functions for translating data to/from the file format.
*/
static raw<A>(
path: string,
toFile: (dataIn: A) => string,
fromFile: (rawData: string) => unknown,
validate: (data: unknown) => A,
) {
return new FileHelper<A>(path, toFile, fromFile, validate)
}
/**
* Create a File Helper for a .json file.
*/
static json<A>(path: string, shape: matches.Validator<unknown, A>) {
return new FileHelper<A>(
path,
(inData) => JSON.stringify(inData, null, 2),
(inString) => JSON.parse(inString),
(data) => shape.unsafeCast(data),
)
}
/**
* Create a File Helper for a .toml file
*/
static toml<A extends Record<string, unknown>>(
path: string,
shape: matches.Validator<unknown, A>,
) {
return new FileHelper<A>(
path,
(inData) => TOML.stringify(inData as any),
(inString) => TOML.parse(inString),
(data) => shape.unsafeCast(data),
)
}
/**
* Create a File Helper for a .yaml file
*/
static yaml<A extends Record<string, unknown>>(
path: string,
shape: matches.Validator<unknown, A>,
) {
return new FileHelper<A>(
path,
(inData) => YAML.stringify(inData, null, 2),
(inString) => YAML.parse(inString),
(data) => shape.unsafeCast(data),
)
}
}
export default FileHelper

View File

@@ -0,0 +1,4 @@
export * from "../../../base/lib/util"
export { GetSslCertificate } from "./GetSslCertificate"
export { hostnameInfoToAddress } from "../../../base/lib/util/Hostname"

View File

@@ -0,0 +1,203 @@
import { ExtendedVersion, VersionRange } from "../../../base/lib/exver"
import * as T from "../../../base/lib/types"
import { Graph, Vertex, once } from "../util"
import { IMPOSSIBLE, VersionInfo } from "./VersionInfo"
export class VersionGraph<CurrentVersion extends string> {
private readonly graph: () => Graph<
ExtendedVersion | VersionRange,
((opts: { effects: T.Effects }) => Promise<void>) | undefined
>
private constructor(
readonly current: VersionInfo<CurrentVersion>,
versions: Array<VersionInfo<any>>,
) {
this.graph = once(() => {
const graph = new Graph<
ExtendedVersion | VersionRange,
((opts: { effects: T.Effects }) => Promise<void>) | undefined
>()
const flavorMap: Record<
string,
[
ExtendedVersion,
VersionInfo<any>,
Vertex<
ExtendedVersion | VersionRange,
((opts: { effects: T.Effects }) => Promise<void>) | undefined
>,
][]
> = {}
for (let version of [current, ...versions]) {
const v = ExtendedVersion.parse(version.options.version)
const vertex = graph.addVertex(v, [], [])
const flavor = v.flavor || ""
if (!flavorMap[flavor]) {
flavorMap[flavor] = []
}
flavorMap[flavor].push([v, version, vertex])
}
for (let flavor in flavorMap) {
flavorMap[flavor].sort((a, b) => a[0].compareForSort(b[0]))
let prev:
| [
ExtendedVersion,
VersionInfo<any>,
Vertex<
ExtendedVersion | VersionRange,
(opts: { effects: T.Effects }) => Promise<void>
>,
]
| undefined = undefined
for (let [v, version, vertex] of flavorMap[flavor]) {
if (version.options.migrations.up !== IMPOSSIBLE) {
let range
if (prev) {
graph.addEdge(version.options.migrations.up, prev[2], vertex)
range = VersionRange.anchor(">=", prev[0]).and(
VersionRange.anchor("<", v),
)
} else {
range = VersionRange.anchor("<", v)
}
const vRange = graph.addVertex(range, [], [])
graph.addEdge(version.options.migrations.up, vRange, vertex)
}
if (version.options.migrations.down !== IMPOSSIBLE) {
let range
if (prev) {
graph.addEdge(version.options.migrations.down, vertex, prev[2])
range = VersionRange.anchor(">=", prev[0]).and(
VersionRange.anchor("<", v),
)
} else {
range = VersionRange.anchor("<", v)
}
const vRange = graph.addVertex(range, [], [])
graph.addEdge(version.options.migrations.down, vertex, vRange)
}
if (version.options.migrations.other) {
for (let rangeStr in version.options.migrations.other) {
const range = VersionRange.parse(rangeStr)
const vRange = graph.addVertex(range, [], [])
graph.addEdge(
version.options.migrations.other[rangeStr],
vRange,
vertex,
)
for (let matching of graph.findVertex(
(v) =>
v.metadata instanceof ExtendedVersion &&
v.metadata.satisfies(range),
)) {
graph.addEdge(
version.options.migrations.other[rangeStr],
matching,
vertex,
)
}
}
}
}
}
return graph
})
}
currentVersion = once(() =>
ExtendedVersion.parse(this.current.options.version),
)
/**
* Each exported `VersionInfo.of()` should be imported and provided as an argument to this function.
*
* ** The current version must be the FIRST argument. **
*/
static of<
CurrentVersion extends string,
OtherVersions extends Array<VersionInfo<any>>,
>(
currentVersion: VersionInfo<CurrentVersion>,
...other: EnsureUniqueId<OtherVersions, OtherVersions, CurrentVersion>
) {
return new VersionGraph(currentVersion, other as Array<VersionInfo<any>>)
}
async migrate({
effects,
from,
to,
}: {
effects: T.Effects
from: ExtendedVersion
to: ExtendedVersion
}) {
const graph = this.graph()
if (from && to) {
const path = graph.shortestPath(
(v) =>
(v.metadata instanceof VersionRange &&
v.metadata.satisfiedBy(from)) ||
(v.metadata instanceof ExtendedVersion && v.metadata.equals(from)),
(v) =>
(v.metadata instanceof VersionRange && v.metadata.satisfiedBy(to)) ||
(v.metadata instanceof ExtendedVersion && v.metadata.equals(to)),
)
if (path) {
for (let edge of path) {
if (edge.metadata) {
await edge.metadata({ effects })
}
await effects.setDataVersion({ version: edge.to.metadata.toString() })
}
return
}
}
throw new Error()
}
canMigrateFrom = once(() =>
Array.from(
this.graph().reverseBreadthFirstSearch(
(v) =>
(v.metadata instanceof VersionRange &&
v.metadata.satisfiedBy(this.currentVersion())) ||
(v.metadata instanceof ExtendedVersion &&
v.metadata.equals(this.currentVersion())),
),
).reduce(
(acc, x) =>
acc.or(
x.metadata instanceof VersionRange
? x.metadata
: VersionRange.anchor("=", x.metadata),
),
VersionRange.none(),
),
)
canMigrateTo = once(() =>
Array.from(
this.graph().breadthFirstSearch(
(v) =>
(v.metadata instanceof VersionRange &&
v.metadata.satisfiedBy(this.currentVersion())) ||
(v.metadata instanceof ExtendedVersion &&
v.metadata.equals(this.currentVersion())),
),
).reduce(
(acc, x) =>
acc.or(
x.metadata instanceof VersionRange
? x.metadata
: VersionRange.anchor("=", x.metadata),
),
VersionRange.none(),
),
)
}
// prettier-ignore
export type EnsureUniqueId<A, B = A, OtherVersions = never> =
B extends [] ? A :
B extends [VersionInfo<infer Version>, ...infer Rest] ? (
Version extends OtherVersions ? "One or more versions are not unique"[] :
EnsureUniqueId<A, Rest, Version | OtherVersions>
) : "There exists a migration that is not a Migration"[]

View File

@@ -0,0 +1,83 @@
import { ValidateExVer } from "../../../base/lib/exver"
import * as T from "../../../base/lib/types"
export const IMPOSSIBLE = Symbol("IMPOSSIBLE")
export type VersionOptions<Version extends string> = {
/** The exver-compliant version number */
version: Version & ValidateExVer<Version>
/** The release notes for this version */
releaseNotes: string
/** Data migrations for this version */
migrations: {
/**
* A migration from the previous version. Leave empty to indicate no migration is necessary.
* Set to `IMPOSSIBLE` to indicate migrating from the previous version is not possible.
*/
up?: ((opts: { effects: T.Effects }) => Promise<void>) | typeof IMPOSSIBLE
/**
* A migration to the previous version. Leave blank to indicate no migration is necessary.
* Set to `IMPOSSIBLE` to indicate downgrades are prohibited
*/
down?: ((opts: { effects: T.Effects }) => Promise<void>) | typeof IMPOSSIBLE
/**
* Additional migrations, such as fast-forward migrations, or migrations from other flavors.
*/
other?: Record<string, (opts: { effects: T.Effects }) => Promise<void>>
}
}
export class VersionInfo<Version extends string> {
private _version: null | Version = null
private constructor(
readonly options: VersionOptions<Version> & { satisfies: string[] },
) {}
/**
* @description Use this function to define a new version of the service. By convention, each version should receive its own file.
* @property {string} version
* @property {string} releaseNotes
* @property {object} migrations
* @returns A VersionInfo class instance that is exported, then imported into versions/index.ts.
*/
static of<Version extends string>(options: VersionOptions<Version>) {
return new VersionInfo<Version>({ ...options, satisfies: [] })
}
/** Specify a version that this version is 100% backwards compatible to */
satisfies<V extends string>(
version: V & ValidateExVer<V>,
): VersionInfo<Version> {
return new VersionInfo({
...this.options,
satisfies: [...this.options.satisfies, version],
})
}
}
function __type_tests() {
const version: VersionInfo<"1.0.0:0"> = VersionInfo.of({
version: "1.0.0:0",
releaseNotes: "",
migrations: {},
})
.satisfies("#other:1.0.0:0")
.satisfies("#other:2.0.0:0")
// @ts-expect-error
.satisfies("#other:2.f.0:0")
let a: VersionInfo<"1.0.0:0"> = version
// @ts-expect-error
let b: VersionInfo<"1.0.0:3"> = version
VersionInfo.of({
// @ts-expect-error
version: "test",
releaseNotes: "",
migrations: {},
})
VersionInfo.of({
// @ts-expect-error
version: "test" as string,
releaseNotes: "",
migrations: {},
})
}

View File

@@ -0,0 +1,2 @@
export * from "./VersionGraph"
export * from "./VersionInfo"

4714
sdk/package/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

64
sdk/package/package.json Normal file
View File

@@ -0,0 +1,64 @@
{
"name": "@start9labs/start-sdk",
"version": "0.3.6-alpha.21",
"description": "Software development kit to facilitate packaging services for StartOS",
"main": "./package/lib/index.js",
"types": "./package/lib/index.d.ts",
"sideEffects": true,
"typesVersion": {
">=3.1": {
"*": [
"package/lib/*",
"base/lib/*"
]
}
},
"scripts": {
"build": "tsc && tsc --project tsconfig-cjs.json && copyfiles package.json dist",
"test": "jest -c ./jest.config.js --coverage",
"buildOutput": "ts-node ./lib/test/makeOutput.ts && npx prettier --write \"**/*.ts\"",
"check": "tsc --noEmit",
"tsc": "tsc"
},
"repository": {
"type": "git",
"url": "git+https://github.com/Start9Labs/start-sdk.git"
},
"author": "Start9 Labs",
"license": "MIT",
"bugs": {
"url": "https://github.com/Start9Labs/start-sdk/issues"
},
"homepage": "https://github.com/Start9Labs/start-sdk#readme",
"dependencies": {
"isomorphic-fetch": "^3.0.0",
"lodash.merge": "^4.6.2",
"mime-types": "^2.1.35",
"ts-matches": "^6.1.0",
"yaml": "^2.2.2",
"@iarna/toml": "^2.2.5",
"@noble/curves": "^1.4.0",
"@noble/hashes": "^1.4.0"
},
"prettier": {
"trailingComma": "all",
"tabWidth": 2,
"semi": false,
"singleQuote": false
},
"devDependencies": {
"@iarna/toml": "^2.2.5",
"@types/jest": "^29.4.0",
"@types/lodash.merge": "^4.6.2",
"copyfiles": "^2.4.1",
"jest": "^29.4.3",
"peggy": "^3.0.2",
"prettier": "^3.2.5",
"ts-jest": "^29.0.5",
"ts-node": "^10.9.1",
"ts-pegjs": "^4.2.1",
"tsx": "^4.7.1",
"typescript": "^5.0.4",
"yaml": "^2.2.2"
}
}

View File

@@ -0,0 +1,386 @@
import * as fs from "fs"
// https://stackoverflow.com/questions/2970525/converting-any-string-into-camel-case
export function camelCase(value: string) {
return value
.replace(/([\(\)\[\]])/g, "")
.replace(/^([A-Z])|[\s-_](\w)/g, function (match, p1, p2, offset) {
if (p2) return p2.toUpperCase()
return p1.toLowerCase()
})
}
export async function oldSpecToBuilder(
file: string,
inputData: Promise<any> | any,
options?: Parameters<typeof makeFileContentFromOld>[1],
) {
await fs.writeFile(
file,
await makeFileContentFromOld(inputData, options),
(err) => console.error(err),
)
}
function isString(x: unknown): x is string {
return typeof x === "string"
}
export default async function makeFileContentFromOld(
inputData: Promise<any> | any,
{ StartSdk = "start-sdk", nested = true } = {},
) {
const outputLines: string[] = []
outputLines.push(`
import { sdk } from "${StartSdk}"
const {InputSpec, List, Value, Variants} = sdk
`)
const data = await inputData
const namedConsts = new Set(["InputSpec", "Value", "List"])
const inputSpecName = newConst("inputSpecSpec", convertInputSpec(data))
const inputSpecMatcherName = newConst(
"matchInputSpecSpec",
`${inputSpecName}.validator`,
)
outputLines.push(
`export type InputSpecSpec = typeof ${inputSpecMatcherName}._TYPE;`,
)
return outputLines.join("\n")
function newConst(key: string, data: string, type?: string) {
const variableName = getNextConstName(camelCase(key))
outputLines.push(
`export const ${variableName}${!type ? "" : `: ${type}`} = ${data};`,
)
return variableName
}
function maybeNewConst(key: string, data: string) {
if (nested) return data
return newConst(key, data)
}
function convertInputSpecInner(data: any) {
let answer = "{"
for (const [key, value] of Object.entries(data)) {
const variableName = maybeNewConst(key, convertValueSpec(value))
answer += `${JSON.stringify(key)}: ${variableName},`
}
return `${answer}}`
}
function convertInputSpec(data: any) {
return `InputSpec.of(${convertInputSpecInner(data)})`
}
function convertValueSpec(value: any): string {
switch (value.type) {
case "string": {
if (value.textarea) {
return `${rangeToTodoComment(
value?.range,
)}Value.textarea(${JSON.stringify(
{
name: value.name || null,
description: value.description || null,
warning: value.warning || null,
required: !(value.nullable || false),
default: value.default,
placeholder: value.placeholder || null,
maxLength: null,
minLength: null,
},
null,
2,
)})`
}
return `${rangeToTodoComment(value?.range)}Value.text(${JSON.stringify(
{
name: value.name || null,
default: value.default || null,
required: !value.nullable,
description: value.description || null,
warning: value.warning || null,
masked: value.masked || false,
placeholder: value.placeholder || null,
inputmode: "text",
patterns: value.pattern
? [
{
regex: value.pattern,
description: value["pattern-description"],
},
]
: [],
minLength: null,
maxLength: null,
},
null,
2,
)})`
}
case "number": {
return `${rangeToTodoComment(
value?.range,
)}Value.number(${JSON.stringify(
{
name: value.name || null,
description: value.description || null,
warning: value.warning || null,
default: value.default || null,
required: !value.nullable,
min: null,
max: null,
step: null,
integer: value.integral || false,
units: value.units || null,
placeholder: value.placeholder || null,
},
null,
2,
)})`
}
case "boolean": {
return `Value.toggle(${JSON.stringify(
{
name: value.name || null,
default: value.default || false,
description: value.description || null,
warning: value.warning || null,
},
null,
2,
)})`
}
case "enum": {
const allValueNames = new Set([
...(value?.["values"] || []),
...Object.keys(value?.["value-names"] || {}),
])
const values = Object.fromEntries(
Array.from(allValueNames)
.filter(isString)
.map((key) => [key, value?.spec?.["value-names"]?.[key] || key]),
)
return `Value.select(${JSON.stringify(
{
name: value.name || null,
description: value.description || null,
warning: value.warning || null,
default: value.default,
values,
},
null,
2,
)} as const)`
}
case "object": {
const specName = maybeNewConst(
value.name + "_spec",
convertInputSpec(value.spec),
)
return `Value.object({
name: ${JSON.stringify(value.name || null)},
description: ${JSON.stringify(value.description || null)},
}, ${specName})`
}
case "union": {
const variants = maybeNewConst(
value.name + "_variants",
convertVariants(value.variants, value.tag["variant-names"] || {}),
)
return `Value.union({
name: ${JSON.stringify(value.name || null)},
description: ${JSON.stringify(value.tag.description || null)},
warning: ${JSON.stringify(value.tag.warning || null)},
default: ${JSON.stringify(value.default)},
}, ${variants})`
}
case "list": {
if (value.subtype === "enum") {
const allValueNames = new Set([
...(value?.spec?.["values"] || []),
...Object.keys(value?.spec?.["value-names"] || {}),
])
const values = Object.fromEntries(
Array.from(allValueNames)
.filter(isString)
.map((key: string) => [
key,
value?.spec?.["value-names"]?.[key] ?? key,
]),
)
return `Value.multiselect(${JSON.stringify(
{
name: value.name || null,
minLength: null,
maxLength: null,
default: value.default ?? null,
description: value.description || null,
warning: value.warning || null,
values,
},
null,
2,
)})`
}
const list = maybeNewConst(value.name + "_list", convertList(value))
return `Value.list(${list})`
}
case "pointer": {
return `/* TODO deal with point removed point "${value.name}" */null as any`
}
}
throw Error(`Unknown type "${value.type}"`)
}
function convertList(value: any) {
switch (value.subtype) {
case "string": {
return `${rangeToTodoComment(value?.range)}List.text(${JSON.stringify(
{
name: value.name || null,
minLength: null,
maxLength: null,
default: value.default || null,
description: value.description || null,
warning: value.warning || null,
},
null,
2,
)}, ${JSON.stringify({
masked: value?.spec?.masked || false,
placeholder: value?.spec?.placeholder || null,
patterns: value?.spec?.pattern
? [
{
regex: value.spec.pattern,
description: value?.spec?.["pattern-description"],
},
]
: [],
minLength: null,
maxLength: null,
})})`
}
// case "number": {
// return `${rangeToTodoComment(value?.range)}List.number(${JSON.stringify(
// {
// name: value.name || null,
// minLength: null,
// maxLength: null,
// default: value.default || null,
// description: value.description || null,
// warning: value.warning || null,
// },
// null,
// 2,
// )}, ${JSON.stringify({
// integer: value?.spec?.integral || false,
// min: null,
// max: null,
// units: value?.spec?.units || null,
// placeholder: value?.spec?.placeholder || null,
// })})`
// }
case "enum": {
return "/* error!! list.enum */"
}
case "object": {
const specName = maybeNewConst(
value.name + "_spec",
convertInputSpec(value.spec.spec),
)
return `${rangeToTodoComment(value?.range)}List.obj({
name: ${JSON.stringify(value.name || null)},
minLength: ${JSON.stringify(null)},
maxLength: ${JSON.stringify(null)},
default: ${JSON.stringify(value.default || null)},
description: ${JSON.stringify(value.description || null)},
}, {
spec: ${specName},
displayAs: ${JSON.stringify(value?.spec?.["display-as"] || null)},
uniqueBy: ${JSON.stringify(value?.spec?.["unique-by"] || null)},
})`
}
case "union": {
const variants = maybeNewConst(
value.name + "_variants",
convertVariants(
value.spec.variants,
value.spec["variant-names"] || {},
),
)
const unionValueName = maybeNewConst(
value.name + "_union",
`${rangeToTodoComment(value?.range)}
Value.union({
name: ${JSON.stringify(value?.spec?.tag?.name || null)},
description: ${JSON.stringify(
value?.spec?.tag?.description || null,
)},
warning: ${JSON.stringify(value?.spec?.tag?.warning || null)},
default: ${JSON.stringify(value?.spec?.default || null)},
}, ${variants})
`,
)
const listInputSpec = maybeNewConst(
value.name + "_list_inputSpec",
`
InputSpec.of({
"union": ${unionValueName}
})
`,
)
return `${rangeToTodoComment(value?.range)}List.obj({
name:${JSON.stringify(value.name || null)},
minLength:${JSON.stringify(null)},
maxLength:${JSON.stringify(null)},
default: [],
description: ${JSON.stringify(value.description || null)},
warning: ${JSON.stringify(value.warning || null)},
}, {
spec: ${listInputSpec},
displayAs: ${JSON.stringify(value?.spec?.["display-as"] || null)},
uniqueBy: ${JSON.stringify(value?.spec?.["unique-by"] || null)},
})`
}
}
throw new Error(`Unknown subtype "${value.subtype}"`)
}
function convertVariants(
variants: Record<string, unknown>,
variantNames: Record<string, string>,
): string {
let answer = "Variants.of({"
for (const [key, value] of Object.entries(variants)) {
const variantSpec = maybeNewConst(key, convertInputSpec(value))
answer += `"${key}": {name: "${
variantNames[key] || key
}", spec: ${variantSpec}},`
}
return `${answer}})`
}
function getNextConstName(name: string, i = 0): string {
const newName = !i ? name : name + i
if (namedConsts.has(newName)) {
return getNextConstName(name, i + 1)
}
namedConsts.add(newName)
return newName
}
}
function rangeToTodoComment(range: string | undefined) {
if (!range) return ""
return `/* TODO: Convert range for this value (${range})*/`
}
// oldSpecToBuilder(
// "./inputSpec.ts",
// // Put inputSpec here
// {},
// )

19
sdk/package/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"strict": true,
"preserveConstEnums": true,
"sourceMap": true,
"pretty": true,
"declaration": true,
"noImplicitAny": true,
"esModuleInterop": true,
"types": ["node", "jest"],
"moduleResolution": "node",
"skipLibCheck": true,
"module": "commonjs",
"outDir": "../dist",
"target": "es2018"
},
"include": ["lib/**/*", "../base/lib/util/Hostname.ts"],
"exclude": ["lib/**/*.spec.ts", "lib/**/*.gen.ts", "list", "node_modules"]
}