From e4a2af6ae7363a2c5124397f6f3616fcf2509e79 Mon Sep 17 00:00:00 2001 From: Mariusz Kogen Date: Sat, 23 Nov 2024 12:32:52 +0100 Subject: [PATCH 1/3] Add serial console support for headless operation (#2790) * implement serial console support * customize local and remote login prompt --- debian/postinst | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/debian/postinst b/debian/postinst index 0db121c56..d20f778a4 100755 --- a/debian/postinst +++ b/debian/postinst @@ -20,10 +20,15 @@ fi update-initramfs -u -k all if [ -f /etc/default/grub ]; then - sed -i '/\(^\|#\)GRUB_CMDLINE_LINUX=/c\GRUB_CMDLINE_LINUX="boot=startos"' /etc/default/grub + sed -i '/\(^\|#\)GRUB_CMDLINE_LINUX=/c\GRUB_CMDLINE_LINUX="boot=startos console=ttyS0,115200n8"' /etc/default/grub sed -i '/\(^\|#\)GRUB_DISTRIBUTOR=/c\GRUB_DISTRIBUTOR="StartOS v$(cat /usr/lib/startos/VERSION.txt)"' /etc/default/grub + sed -i '/\(^\|#\)GRUB_TERMINAL=/c\GRUB_TERMINAL="serial"\nGRUB_SERIAL_COMMAND="serial --unit=0 --speed=115200 --word=8 --parity=no --stop=1"' /etc/default/grub fi +# set local and remote login prompt +echo "StartOS v$(cat /usr/lib/startos/VERSION.txt) [\m] on \n.local (\l)" > /etc/issue +echo "StartOS v$(cat /usr/lib/startos/VERSION.txt)" > /etc/issue.net + # change timezone rm -f /etc/localtime ln -s /usr/share/zoneinfo/Etc/UTC /etc/localtime From 504f1a8e97585b8275133ec12446548320e8dbee Mon Sep 17 00:00:00 2001 From: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com> Date: Mon, 25 Nov 2024 11:49:11 -0700 Subject: [PATCH 2/3] sdk tweaks (#2791) * sdk tweaks * switch back to deeppartial --- core/startos/src/s9pk/v2/pack.rs | 13 +++++- core/startos/src/util/mod.rs | 10 +++-- .../lib/actions/input/builder/inputSpec.ts | 9 ++-- sdk/base/lib/actions/input/builder/value.ts | 45 +++++++++---------- .../lib/actions/input/builder/variants.ts | 21 --------- .../lib/dependencies/setupDependencies.ts | 40 ++++++++++++++--- sdk/base/lib/osBindings/ImageSource.ts | 4 +- sdk/base/lib/types.ts | 13 ++++-- sdk/base/lib/types/ManifestTypes.ts | 17 +++++-- sdk/base/package-lock.json | 8 ++-- sdk/base/package.json | 2 +- sdk/package/lib/StartSdk.ts | 1 + sdk/package/lib/manifest/setupManifest.ts | 4 +- sdk/package/lib/test/inputSpecBuilder.test.ts | 38 ++++++++-------- sdk/package/lib/test/output.sdk.ts | 12 ++++- sdk/package/lib/test/output.test.ts | 2 +- sdk/package/package-lock.json | 12 ++--- sdk/package/package.json | 4 +- 18 files changed, 149 insertions(+), 106 deletions(-) diff --git a/core/startos/src/s9pk/v2/pack.rs b/core/startos/src/s9pk/v2/pack.rs index 67be558f1..be81d9e78 100644 --- a/core/startos/src/s9pk/v2/pack.rs +++ b/core/startos/src/s9pk/v2/pack.rs @@ -354,7 +354,9 @@ pub enum ImageSource { Packed, #[serde(rename_all = "camelCase")] DockerBuild { + #[ts(optional)] workdir: Option, + #[ts(optional)] dockerfile: Option, #[serde(skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -366,8 +368,15 @@ impl ImageSource { pub fn ingredients(&self) -> Vec { match self { Self::Packed => Vec::new(), - Self::DockerBuild { dockerfile, .. } => { - vec![dockerfile.clone().unwrap_or_else(|| "Dockerfile".into())] + Self::DockerBuild { + dockerfile, + workdir, + .. + } => { + vec![workdir + .as_deref() + .unwrap_or(Path::new(".")) + .join(dockerfile.as_deref().unwrap_or(Path::new("Dockerfile")))] } Self::DockerTag(_) => Vec::new(), } diff --git a/core/startos/src/util/mod.rs b/core/startos/src/util/mod.rs index 0f3563018..d6f426135 100644 --- a/core/startos/src/util/mod.rs +++ b/core/startos/src/util/mod.rs @@ -122,7 +122,8 @@ impl<'a> std::ops::DerefMut for ExtendedCommand<'a> { } impl<'a> Invoke<'a> for tokio::process::Command { - type Extended<'ext> = ExtendedCommand<'ext> + type Extended<'ext> + = ExtendedCommand<'ext> where Self: 'ext, 'ext: 'a; @@ -162,7 +163,8 @@ impl<'a> Invoke<'a> for tokio::process::Command { } impl<'a> Invoke<'a> for ExtendedCommand<'a> { - type Extended<'ext> = &'ext mut ExtendedCommand<'ext> + type Extended<'ext> + = &'ext mut ExtendedCommand<'ext> where Self: 'ext, 'ext: 'a; @@ -663,8 +665,8 @@ impl FromStr for PathOrUrl { type Err = ::Err; fn from_str(s: &str) -> Result { if let Ok(url) = s.parse::() { - if url.scheme() == "file" { - Ok(Self::Path(url.path().parse()?)) + if let Some(path) = s.strip_prefix("file://") { + Ok(Self::Path(path.parse()?)) } else { Ok(Self::Url(url)) } diff --git a/sdk/base/lib/actions/input/builder/inputSpec.ts b/sdk/base/lib/actions/input/builder/inputSpec.ts index 611ad8a48..31e06df4f 100644 --- a/sdk/base/lib/actions/input/builder/inputSpec.ts +++ b/sdk/base/lib/actions/input/builder/inputSpec.ts @@ -1,8 +1,9 @@ import { ValueSpec } from "../inputSpecTypes" -import { PartialValue, Value } from "./value" +import { Value } from "./value" import { _ } from "../../../util" import { Effects } from "../../../Effects" import { Parser, object } from "ts-matches" +import { DeepPartial } from "../../../types" export type LazyBuildOptions = { effects: Effects @@ -22,8 +23,8 @@ export type ExtractPartialInputSpecType< | InputSpec, any> | InputSpec, never>, > = A extends InputSpec | InputSpec - ? PartialValue - : PartialValue + ? DeepPartial + : DeepPartial export type InputSpecOf, Store = never> = { [K in keyof A]: Value @@ -94,7 +95,7 @@ export class InputSpec, Store = never> { public validator: Parser, ) {} _TYPE: Type = null as any as Type - _PARTIAL: PartialValue = null as any as PartialValue + _PARTIAL: DeepPartial = null as any as DeepPartial async build(options: LazyBuildOptions) { const answer = {} as { [K in keyof Type]: ValueSpec diff --git a/sdk/base/lib/actions/input/builder/value.ts b/sdk/base/lib/actions/input/builder/value.ts index 053bd0596..676c4aac1 100644 --- a/sdk/base/lib/actions/input/builder/value.ts +++ b/sdk/base/lib/actions/input/builder/value.ts @@ -1,6 +1,6 @@ import { InputSpec, LazyBuild } from "./inputSpec" import { List } from "./list" -import { PartialUnionRes, UnionRes, Variants } from "./variants" +import { Variants } from "./variants" import { FilePath, Pattern, @@ -30,7 +30,7 @@ import { DeepPartial } from "../../../types" type AsRequired = Required extends true ? T - : T | null | undefined + : T | null const testForAsRequiredParser = once( () => object({ required: literal(true) }).test, @@ -38,21 +38,12 @@ const testForAsRequiredParser = once( function asRequiredParser< Type, Input, - Return extends - | Parser - | Parser, + Return extends Parser | Parser, >(parser: Parser, input: Input): Return { if (testForAsRequiredParser()(input)) return parser as any - return parser.optional() as any + return parser.nullable() as any } -export type PartialValue = - T extends UnionRes - ? PartialUnionRes - : T extends {} - ? { [P in keyof T]?: PartialValue } - : T - export class Value { protected constructor( public build: LazyBuild, @@ -196,7 +187,7 @@ export class Value { } >, ) { - return new Value(async (options) => { + return new Value(async (options) => { const a = await getA(options) return { type: "text" as const, @@ -213,7 +204,7 @@ export class Value { generate: a.generate ?? null, ...a, } - }, string.optional()) + }, string.nullable()) } static textarea(a: { name: string @@ -265,7 +256,7 @@ export class Value { } >, ) { - return new Value(async (options) => { + return new Value(async (options) => { const a = await getA(options) return { description: null, @@ -278,7 +269,7 @@ export class Value { immutable: false, ...a, } - }, string.optional()) + }, string.nullable()) } static number(a: { name: string @@ -351,7 +342,7 @@ export class Value { } >, ) { - return new Value(async (options) => { + return new Value(async (options) => { const a = await getA(options) return { type: "number" as const, @@ -366,7 +357,7 @@ export class Value { immutable: false, ...a, } - }, number.optional()) + }, number.nullable()) } static color(a: { name: string @@ -413,7 +404,7 @@ export class Value { } >, ) { - return new Value(async (options) => { + return new Value(async (options) => { const a = await getA(options) return { type: "color" as const, @@ -423,7 +414,7 @@ export class Value { immutable: false, ...a, } - }, string.optional()) + }, string.nullable()) } static datetime(a: { name: string @@ -483,7 +474,7 @@ export class Value { } >, ) { - return new Value(async (options) => { + return new Value(async (options) => { const a = await getA(options) return { type: "datetime" as const, @@ -496,7 +487,7 @@ export class Value { immutable: false, ...a, } - }, string.optional()) + }, string.nullable()) } static select>(a: { name: string @@ -690,14 +681,14 @@ export class Value { // } // >, // ) { - // return new Value( + // return new Value( // async (options) => ({ // type: "file" as const, // description: null, // warning: null, // ...(await a(options)), // }), - // object({ filePath: string }).optional(), + // object({ filePath: string }).nullable(), // ) // } static union< @@ -822,6 +813,10 @@ export class Value { }, parser) } + map(fn: (value: Type) => U): Value { + return new Value(this.build, this.validator.map(fn)) + } + /** * Use this during the times that the input needs a more specific type. * Used in types that the value/ variant/ list/ inputSpec is constructed somewhere else. diff --git a/sdk/base/lib/actions/input/builder/variants.ts b/sdk/base/lib/actions/input/builder/variants.ts index 05124f45a..93453d73c 100644 --- a/sdk/base/lib/actions/input/builder/variants.ts +++ b/sdk/base/lib/actions/input/builder/variants.ts @@ -29,27 +29,6 @@ export type UnionRes< } }[K] -export type PartialUnionRes< - Store, - VariantValues extends { - [K in string]: { - name: string - spec: InputSpec | InputSpec - } - }, - K extends keyof VariantValues & string = keyof VariantValues & string, -> = { - [key in keyof VariantValues]: { - selection?: key - value?: ExtractPartialInputSpecType - other?: { - [key2 in Exclude]?: DeepPartial< - ExtractInputSpecType - > - } - } -}[K] - /** * Used in the the Value.select { @link './value.ts' } * to indicate the type of select variants that are available. The key for the record passed in will be the diff --git a/sdk/base/lib/dependencies/setupDependencies.ts b/sdk/base/lib/dependencies/setupDependencies.ts index f694c042c..6b15ef0d1 100644 --- a/sdk/base/lib/dependencies/setupDependencies.ts +++ b/sdk/base/lib/dependencies/setupDependencies.ts @@ -1,12 +1,42 @@ import * as T from "../types" import { once } from "../util" -type DependencyType = { - [K in keyof Manifest["dependencies"]]: Omit -} +export type RequiredDependenciesOf = { + [K in keyof Manifest["dependencies"]]: Manifest["dependencies"][K]["optional"] extends false + ? K + : never +}[keyof Manifest["dependencies"]] +export type OptionalDependenciesOf = Exclude< + keyof Manifest["dependencies"], + RequiredDependenciesOf +> + +type DependencyRequirement = + | { + kind: "running" + healthChecks: Array + versionRange: string + } + | { + kind: "exists" + versionRange: string + } +type Matches = T extends U ? (U extends T ? null : never) : never +const _checkType: Matches< + DependencyRequirement & { id: T.PackageId }, + T.DependencyRequirement +> = null + +export type CurrentDependenciesResult = { + [K in RequiredDependenciesOf]: DependencyRequirement +} & { + [K in OptionalDependenciesOf]?: DependencyRequirement +} & Record export function setupDependencies( - fn: (options: { effects: T.Effects }) => Promise>, + fn: (options: { + effects: T.Effects + }) => Promise>, ): (options: { effects: T.Effects }) => Promise { const cell = { updater: async (_: { effects: T.Effects }) => null } cell.updater = async (options: { effects: T.Effects }) => { @@ -21,7 +51,7 @@ export function setupDependencies( dependencies: Object.entries(dependencyType).map( ([id, { versionRange, ...x }, ,]) => ({ - id, + // id, ...x, versionRange: versionRange.toString(), }) as T.DependencyRequirement, diff --git a/sdk/base/lib/osBindings/ImageSource.ts b/sdk/base/lib/osBindings/ImageSource.ts index 58d2c2a79..d8f876aef 100644 --- a/sdk/base/lib/osBindings/ImageSource.ts +++ b/sdk/base/lib/osBindings/ImageSource.ts @@ -5,8 +5,8 @@ export type ImageSource = | "packed" | { dockerBuild: { - workdir: string | null - dockerfile: string | null + workdir?: string + dockerfile?: string buildArgs?: { [key: string]: BuildArg } } } diff --git a/sdk/base/lib/types.ts b/sdk/base/lib/types.ts index ab1acaa87..36d4bd293 100644 --- a/sdk/base/lib/types.ts +++ b/sdk/base/lib/types.ts @@ -13,6 +13,11 @@ import { Effects } from "./Effects" export { Effects } export * from "./osBindings" export { SDKManifest } from "./types/ManifestTypes" +export { + RequiredDependenciesOf as RequiredDependencies, + OptionalDependenciesOf as OptionalDependencies, + CurrentDependenciesResult, +} from "./dependencies/setupDependencies" export type ExposedStorePaths = string[] & Affine<"ExposedStorePaths"> declare const HealthProof: unique symbol @@ -224,6 +229,8 @@ export type KnownError = export type Dependencies = Array -export type DeepPartial = T extends {} - ? { [P in keyof T]?: DeepPartial } - : T +export type DeepPartial = T extends unknown[] + ? T + : T extends {} + ? { [P in keyof T]?: DeepPartial } + : T diff --git a/sdk/base/lib/types/ManifestTypes.ts b/sdk/base/lib/types/ManifestTypes.ts index 86ecf9140..17defebcd 100644 --- a/sdk/base/lib/types/ManifestTypes.ts +++ b/sdk/base/lib/types/ManifestTypes.ts @@ -150,10 +150,19 @@ export type SDKManifest = { } } -export type SDKImageInputSpec = { - source: Exclude - arch?: string[] - emulateMissingAs?: string | null +// this is hacky but idk a more elegant way +type ArchOptions = { + 0: ["x86_64", "aarch64"] + 1: ["aarch64", "x86_64"] + 2: ["x86_64"] + 3: ["aarch64"] } +export type SDKImageInputSpec = { + [A in keyof ArchOptions]: { + source: Exclude + arch?: ArchOptions[A] + emulateMissingAs?: ArchOptions[A][number] | null + } +}[keyof ArchOptions] export type ManifestDependency = T.Manifest["dependencies"][string] diff --git a/sdk/base/package-lock.json b/sdk/base/package-lock.json index 4c8f58364..d7b491303 100644 --- a/sdk/base/package-lock.json +++ b/sdk/base/package-lock.json @@ -14,7 +14,7 @@ "isomorphic-fetch": "^3.0.0", "lodash.merge": "^4.6.2", "mime-types": "^2.1.35", - "ts-matches": "^6.0.0", + "ts-matches": "^6.1.0", "yaml": "^2.2.2" }, "devDependencies": { @@ -3897,9 +3897,9 @@ "dev": true }, "node_modules/ts-matches": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ts-matches/-/ts-matches-6.0.0.tgz", - "integrity": "sha512-vR4hhz9bYMW30qIJUuLaeAWlsR54vse6ZI2riVhVLMBE6/vss43jwrOvbHheiyU7e26ssT/yWx69aJHD2REJSA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ts-matches/-/ts-matches-6.1.0.tgz", + "integrity": "sha512-01qvbIpOiKdbzzXDH84JeHunvCwBGFdZw94jS6kOGLSN5ms+1nBZtfe8WSuYMIPb1xPA+qyAiVgznFi2VCQ6UQ==", "license": "MIT" }, "node_modules/ts-morph": { diff --git a/sdk/base/package.json b/sdk/base/package.json index 509e621de..4cc2fc7ca 100644 --- a/sdk/base/package.json +++ b/sdk/base/package.json @@ -27,7 +27,7 @@ "isomorphic-fetch": "^3.0.0", "lodash.merge": "^4.6.2", "mime-types": "^2.1.35", - "ts-matches": "^6.0.0", + "ts-matches": "^6.1.0", "yaml": "^2.2.2" }, "prettier": { diff --git a/sdk/package/lib/StartSdk.ts b/sdk/package/lib/StartSdk.ts index 28a14e1e5..634af249d 100644 --- a/sdk/package/lib/StartSdk.ts +++ b/sdk/package/lib/StartSdk.ts @@ -135,6 +135,7 @@ export class StartSdk { } return { + manifest: this.manifest, ...startSdkEffectWrapper, action: { run: actions.runAction, diff --git a/sdk/package/lib/manifest/setupManifest.ts b/sdk/package/lib/manifest/setupManifest.ts index c529f1ab7..5dfdb2451 100644 --- a/sdk/package/lib/manifest/setupManifest.ts +++ b/sdk/package/lib/manifest/setupManifest.ts @@ -59,7 +59,9 @@ export function buildManifest< (images, [k, v]) => { v.arch = v.arch || ["aarch64", "x86_64"] if (v.emulateMissingAs === undefined) - v.emulateMissingAs = v.arch[0] || null + v.emulateMissingAs = (v.arch as string[]).includes("aarch64") + ? "aarch64" + : v.arch[0] || null images[k] = v as ImageConfig return images }, diff --git a/sdk/package/lib/test/inputSpecBuilder.test.ts b/sdk/package/lib/test/inputSpecBuilder.test.ts index 195acb40a..27869067d 100644 --- a/sdk/package/lib/test/inputSpecBuilder.test.ts +++ b/sdk/package/lib/test/inputSpecBuilder.test.ts @@ -87,7 +87,7 @@ describe("values", () => { const rawIs = await value.build({} as any) validator.unsafeCast("test text") validator.unsafeCast(null) - testOutput()(null) + testOutput()(null) }) test("color", async () => { const value = Value.color({ @@ -99,7 +99,7 @@ describe("values", () => { }) const validator = value.validator validator.unsafeCast("#000000") - testOutput()(null) + testOutput()(null) }) test("datetime", async () => { const value = Value.datetime({ @@ -129,7 +129,7 @@ describe("values", () => { }) const validator = value.validator validator.unsafeCast("2021-01-01") - testOutput()(null) + testOutput()(null) }) test("textarea", async () => { const value = Value.textarea({ @@ -144,7 +144,7 @@ describe("values", () => { }) const validator = value.validator validator.unsafeCast("test text") - testOutput()(null) + testOutput()(null) }) test("number", async () => { const value = Value.number({ @@ -180,7 +180,7 @@ describe("values", () => { }) const validator = value.validator validator.unsafeCast(2) - testOutput()(null) + testOutput()(null) }) test("select", async () => { const value = Value.select({ @@ -319,33 +319,33 @@ describe("values", () => { test("text", async () => { const value = Value.dynamicText(async () => ({ name: "Testing", - required: true, + required: false, default: null, })) const validator = value.validator const rawIs = await value.build({} as any) validator.unsafeCast("test text") validator.unsafeCast(null) - testOutput()(null) + testOutput()(null) expect(await value.build(fakeOptions)).toMatchObject({ name: "Testing", - required: true, + required: false, default: null, }) }) test("text with default", async () => { const value = Value.dynamicText(async () => ({ name: "Testing", - required: true, + required: false, default: "this is a default value", })) const validator = value.validator validator.unsafeCast("test text") validator.unsafeCast(null) - testOutput()(null) + testOutput()(null) expect(await value.build(fakeOptions)).toMatchObject({ name: "Testing", - required: true, + required: false, default: "this is a default value", }) }) @@ -359,7 +359,7 @@ describe("values", () => { const rawIs = await value.build({} as any) validator.unsafeCast("test text") validator.unsafeCast(null) - testOutput()(null) + testOutput()(null) expect(await value.build(fakeOptions)).toMatchObject({ name: "Testing", required: false, @@ -377,7 +377,7 @@ describe("values", () => { const validator = value.validator validator.unsafeCast("#000000") validator.unsafeCast(null) - testOutput()(null) + testOutput()(null) expect(await value.build(fakeOptions)).toMatchObject({ name: "Testing", required: false, @@ -445,7 +445,7 @@ describe("values", () => { const validator = value.validator validator.unsafeCast("2021-01-01") validator.unsafeCast(null) - testOutput()(null) + testOutput()(null) expect(await value.build(fakeOptions)).toMatchObject({ name: "Testing", required: true, @@ -468,7 +468,7 @@ describe("values", () => { })) const validator = value.validator validator.unsafeCast("test text") - testOutput()(null) + testOutput()(null) expect(await value.build(fakeOptions)).toMatchObject({ name: "Testing", required: false, @@ -492,7 +492,7 @@ describe("values", () => { validator.unsafeCast(2) validator.unsafeCast(null) expect(() => validator.unsafeCast("null")).toThrowError() - testOutput()(null) + testOutput()(null) expect(await value.build(fakeOptions)).toMatchObject({ name: "Testing", required: true, @@ -795,7 +795,7 @@ describe("Nested nullable values", () => { validator.unsafeCast({ a: null }) validator.unsafeCast({ a: "test" }) expect(() => validator.unsafeCast({ a: 4 })).toThrowError() - testOutput()(null) + testOutput()(null) }) test("Testing number", async () => { const value = InputSpec.of({ @@ -818,7 +818,7 @@ describe("Nested nullable values", () => { validator.unsafeCast({ a: null }) validator.unsafeCast({ a: 5 }) expect(() => validator.unsafeCast({ a: "4" })).toThrowError() - testOutput()(null) + testOutput()(null) }) test("Testing color", async () => { const value = InputSpec.of({ @@ -835,7 +835,7 @@ describe("Nested nullable values", () => { validator.unsafeCast({ a: null }) validator.unsafeCast({ a: "5" }) expect(() => validator.unsafeCast({ a: 4 })).toThrowError() - testOutput()(null) + testOutput()(null) }) test("Testing select", async () => { const value = InputSpec.of({ diff --git a/sdk/package/lib/test/output.sdk.ts b/sdk/package/lib/test/output.sdk.ts index d87446585..3f3bb5411 100644 --- a/sdk/package/lib/test/output.sdk.ts +++ b/sdk/package/lib/test/output.sdk.ts @@ -1,6 +1,6 @@ +import { CurrentDependenciesResult } from "../../../base/lib/dependencies/setupDependencies" import { StartSdk } from "../StartSdk" import { setupManifest } from "../manifest/setupManifest" -import { VersionInfo } from "../version/VersionInfo" import { VersionGraph } from "../version/VersionGraph" export type Manifest = any @@ -21,7 +21,15 @@ export const sdk = StartSdk.of() long: "", }, containers: {}, - images: {}, + images: { + main: { + source: { + dockerTag: "start9/hello-world", + }, + arch: ["aarch64", "x86_64"], + emulateMissingAs: "aarch64", + }, + }, volumes: [], assets: [], alerts: { diff --git a/sdk/package/lib/test/output.test.ts b/sdk/package/lib/test/output.test.ts index 53d006274..a2e9c35d5 100644 --- a/sdk/package/lib/test/output.test.ts +++ b/sdk/package/lib/test/output.test.ts @@ -22,7 +22,7 @@ testOutput< testOutput()(null) testOutput< InputSpecSpec["advanced"]["peers"]["addnode"][0]["hostname"], - string | null | undefined + string | null >()(null) testOutput< InputSpecSpec["testListUnion"][0]["union"]["value"]["name"], diff --git a/sdk/package/package-lock.json b/sdk/package/package-lock.json index 0b06e79db..1f5715258 100644 --- a/sdk/package/package-lock.json +++ b/sdk/package/package-lock.json @@ -1,12 +1,12 @@ { "name": "@start9labs/start-sdk", - "version": "0.3.6-alpha.16", + "version": "0.3.6-alpha.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@start9labs/start-sdk", - "version": "0.3.6-alpha.16", + "version": "0.3.6-alpha.21", "license": "MIT", "dependencies": { "@iarna/toml": "^2.2.5", @@ -15,7 +15,7 @@ "isomorphic-fetch": "^3.0.0", "lodash.merge": "^4.6.2", "mime-types": "^2.1.35", - "ts-matches": "^6.0.0", + "ts-matches": "^6.1.0", "yaml": "^2.2.2" }, "devDependencies": { @@ -3918,9 +3918,9 @@ "dev": true }, "node_modules/ts-matches": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ts-matches/-/ts-matches-6.0.0.tgz", - "integrity": "sha512-vR4hhz9bYMW30qIJUuLaeAWlsR54vse6ZI2riVhVLMBE6/vss43jwrOvbHheiyU7e26ssT/yWx69aJHD2REJSA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ts-matches/-/ts-matches-6.1.0.tgz", + "integrity": "sha512-01qvbIpOiKdbzzXDH84JeHunvCwBGFdZw94jS6kOGLSN5ms+1nBZtfe8WSuYMIPb1xPA+qyAiVgznFi2VCQ6UQ==", "license": "MIT" }, "node_modules/ts-morph": { diff --git a/sdk/package/package.json b/sdk/package/package.json index bbcab9830..2bf4b71f5 100644 --- a/sdk/package/package.json +++ b/sdk/package/package.json @@ -1,6 +1,6 @@ { "name": "@start9labs/start-sdk", - "version": "0.3.6-alpha.17", + "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", @@ -33,7 +33,7 @@ "isomorphic-fetch": "^3.0.0", "lodash.merge": "^4.6.2", "mime-types": "^2.1.35", - "ts-matches": "^6.0.0", + "ts-matches": "^6.1.0", "yaml": "^2.2.2", "@iarna/toml": "^2.2.5", "@noble/curves": "^1.4.0", From 12dec676db9d1b8ddf3986f579744cc5d07e5d73 Mon Sep 17 00:00:00 2001 From: Matt Hill Date: Tue, 26 Nov 2024 23:54:05 -0700 Subject: [PATCH 3/3] Update sdk comments (#2793) * sdk tweaks * switch back to deeppartial * WIP, update comments * reinstall chesterton's fence --------- Co-authored-by: Aiden McClelland --- sdk/base/lib/Effects.ts | 1 - sdk/base/lib/actions/setupActions.ts | 2 +- sdk/package/lib/StartSdk.ts | 229 +++++++++++------- sdk/package/lib/backup/Backups.ts | 23 +- .../lib/health/checkFns/checkPortListening.ts | 3 +- sdk/package/lib/mainFn/CommandController.ts | 4 +- sdk/package/lib/manifest/setupManifest.ts | 3 +- sdk/package/lib/store/setupExposeStore.ts | 1 - sdk/package/lib/trigger/defaultTrigger.ts | 1 - sdk/package/lib/util/SubContainer.ts | 3 +- sdk/package/lib/util/fileHelper.ts | 25 +- 11 files changed, 167 insertions(+), 128 deletions(-) diff --git a/sdk/base/lib/Effects.ts b/sdk/base/lib/Effects.ts index 00d56cfba..e4424fafb 100644 --- a/sdk/base/lib/Effects.ts +++ b/sdk/base/lib/Effects.ts @@ -12,7 +12,6 @@ import { Host, ExportServiceInterfaceParams, ServiceInterface, - ActionRequest, RequestActionParams, MainStatus, } from "./osBindings" diff --git a/sdk/base/lib/actions/setupActions.ts b/sdk/base/lib/actions/setupActions.ts index 081225569..203f81a32 100644 --- a/sdk/base/lib/actions/setupActions.ts +++ b/sdk/base/lib/actions/setupActions.ts @@ -14,7 +14,7 @@ export type Run< > = (options: { effects: T.Effects input: ExtractInputSpecType & Record -}) => Promise +}) => Promise<(T.ActionResult & { version: "1" }) | null | void | undefined> export type GetInput< A extends | Record diff --git a/sdk/package/lib/StartSdk.ts b/sdk/package/lib/StartSdk.ts index 634af249d..e7e87f963 100644 --- a/sdk/package/lib/StartSdk.ts +++ b/sdk/package/lib/StartSdk.ts @@ -33,12 +33,11 @@ import { checkWebUrl, runHealthScript } from "./health/checkFns" import { List } from "../../base/lib/actions/input/builder/list" import { Install, InstallFn } from "./inits/setupInstall" import { SetupBackupsParams, setupBackups } from "./backup/setupBackups" -import { Uninstall, UninstallFn, setupUninstall } from "./inits/setupUninstall" +import { UninstallFn, setupUninstall } from "./inits/setupUninstall" import { setupMain } from "./mainFn" import { defaultTrigger } from "./trigger/defaultTrigger" import { changeOnFirstSuccess, cooldownTrigger } from "./trigger" import { - ServiceInterfacesReceipt, UpdateServiceInterfaces, setupServiceInterfaces, } from "../../base/lib/interfaces/setupInterfaces" @@ -240,68 +239,67 @@ export class StartSdk { return runCommand(effects, image, command, options, name) }, /** - * TODO: rewrite this - * @description Use this function to create a static Action, including optional form input. + * @description Use this class to create an Action. By convention, each Action should receive its own file. * - * By convention, each Action should receive its own file. - * - * @param id - * @param metaData - * @param fn - * @returns - * @example - * In this example, we create an Action that prints a name to the console. We present a user - * with a form for optionally entering a temp name. If no temp name is provided, we use the name - * from the underlying `inputSpec.yaml` file. If no name is there, we use "Unknown". Then, we return - * a message to the user informing them what happened. - * - * ``` - import { sdk } from '../sdk' - const { InputSpec, Value } = sdk - import { yamlFile } from '../file-models/inputSpec.yml' + */ + Action: { + /** + * @description Use this function to create an action that accepts form input + * @param id - a unique ID for this action + * @param metadata - information describing the action and its availability + * @param inputSpec - define the form input using the InputSpec and Value classes + * @param prefillFn - optionally fetch data from the file system to pre-fill the input form. Must returns a deep partial of the input spec + * @param executionFn - execute the action. Optionally return data for the user to view. Must be in the structure of an ActionResult, version "1" + * @example + * In this example, we create an action for a user to provide their name. + * We prefill the input form with their existing name from the service's yaml file. + * The new name is saved to the yaml file, and we return nothing to the user, which + * means they will receive a generic success message. + * + * ``` + import { sdk } from '../sdk' + import { yamlFile } from '../file-models/config.yml' - const input = InputSpec.of({ - nameToPrint: Value.text({ - name: 'Temp Name', - description: 'If no name is provided, the name from inputSpec will be used', - required: false, - }), - }) + const { InputSpec, Value } = sdk - export const nameToLog = sdk.createAction( - // id - 'nameToLogs', + export const inputSpec = InputSpec.of({ + name: Value.text({ + name: 'Name', + description: + 'When you launch the Hello World UI, it will display "Hello [Name]"', + required: true, + default: 'World', + }), + }) - // metadata - { - name: 'Name to Logs', - description: 'Prints "Hello [Name]" to the service logs.', - warning: null, - disabled: false, - input, - allowedStatuses: 'onlyRunning', - group: null, - }, + export const setName = sdk.Action.withInput( + // id + 'set-name', - // the execution function - async ({ effects, input }) => { - const name = - input.nameToPrint || (await yamlFile.read(effects))?.name || 'Unknown' + // metadata + async ({ effects }) => ({ + name: 'Set Name', + description: 'Set your name so Hello World can say hello to you', + warning: null, + allowedStatuses: 'any', + group: null, + visibility: 'enabled', + }), - console.info(`Hello ${name}`) + // form input specification + inputSpec, - return { - version: '0', - message: `"Hello ${name}" has been written to the service logs. Open your logs to view it.`, - value: name, - copyable: true, - qr: false, - } - }, - ) - * ``` - */ - Action: { + // optionally pre-fill the input form + async ({ effects }) => { + const name = await yamlFile.read.const(effects)?.name + return { name } + }, + + // the execution function + async ({ effects, input }) => yamlFile.merge(input) + ) + * ``` + */ withInput: < Id extends T.ActionId, InputSpecType extends @@ -317,6 +315,50 @@ export class StartSdk { getInput: GetInput, run: Run, ) => Action.withInput(id, metadata, inputSpec, getInput, run), + /** + * @description Use this function to create an action that does not accept form input + * @param id - a unique ID for this action + * @param metadata - information describing the action and its availability + * @param executionFn - execute the action. Optionally return data for the user to view. Must be in the structure of an ActionResult, version "1" + * @example + * In this example, we create an action that returns a secret phrase for the user to see. + * + * ``` + import { sdk } from '../sdk' + + export const showSecretPhrase = sdk.Action.withoutInput( + // id + 'show-secret-phrase', + + // metadata + async ({ effects }) => ({ + name: 'Show Secret Phrase', + description: 'Reveal the secret phrase for Hello World', + warning: null, + allowedStatuses: 'any', + group: null, + visibility: 'enabled', + }), + + // the execution function + async ({ effects }) => ({ + version: '1', + title: 'Secret Phrase', + message: + 'Below is your secret phrase. Use it to gain access to extraordinary places', + result: { + type: 'single', + value: await sdk.store + .getOwn(effects, sdk.StorePath.secretPhrase) + .const(), + copyable: true, + qr: true, + masked: true, + }, + }), + ) + * ``` + */ withoutInput: ( id: Id, metadata: MaybeFn>, @@ -355,9 +397,9 @@ export class StartSdk { id: string /** The human readable description. */ description: string - /** Not available until StartOS v0.4.0. If true, forces the user to select one URL (i.e. .onion, .local, or IP address) as the primary URL. This is needed by some services to function properly. */ + /** No effect until StartOS v0.4.0. If true, forces the user to select one URL (i.e. .onion, .local, or IP address) as the primary URL. This is needed by some services to function properly. */ hasPrimary: boolean - /** Affects how the interface appears to the user. One of: 'ui', 'api', 'p2p'. */ + /** Affects how the interface appears to the user. One of: 'ui', 'api', 'p2p'. If 'ui', the user will see a "Launch UI" button */ type: ServiceInterfaceType /** (optional) prepends the provided username to all URLs. */ username: null | string @@ -413,15 +455,22 @@ export class StartSdk { * In this example, we back up the entire "main" volume and nothing else. * * ``` - export const { createBackup, restoreBackup } = sdk.setupBackups(sdk.Backups.addVolume('main')) + import { sdk } from './sdk' + + export const { createBackup, restoreBackup } = sdk.setupBackups( + async ({ effects }) => sdk.Backups.volumes('main'), + ) * ``` * @example - * In this example, we back up the "main" and the "other" volume, but exclude hypothetical directory "excludedDir" from the "other". + * In this example, we back up the "main" volume, but exclude hypothetical directory "excludedDir". * * ``` - export const { createBackup, restoreBackup } = sdk.setupBackups(sdk.Backups - .addVolume('main') - .addVolume('other', { exclude: ['path/to/excludedDir'] }) + import { sdk } from './sdk' + + export const { createBackup, restoreBackup } = sdk.setupBackups(async () => + sdk.Backups.volumes('main').setOptions({ + exclude: ['excludedDir'], + }), ) * ``` */ @@ -429,37 +478,36 @@ export class StartSdk { setupBackups(options), /** * @description Use this function to set dependency information. - * - * The function executes on service install, update, and inputSpec save. "input" will be of type `Input` for inputSpec save. It will be `null` for install and update. * @example - * In this example, we create a static dependency on Hello World >=1.0.0:0, where Hello World must be running and passing its "webui" health check. + * In this example, we create a perpetual dependency on Hello World >=1.0.0:0, where Hello World must be running and passing its "primary" health check. * * ``` export const setDependencies = sdk.setupDependencies( async ({ effects, input }) => { return { - 'hello-world': sdk.Dependency.of({ - type: 'running', - versionRange: VersionRange.parse('>=1.0.0:0'), - healthChecks: ['webui'], - }), + 'hello-world': { + kind: 'running', + versionRange: '>=1.0.0', + healthChecks: ['primary'], + }, } }, ) * ``` * @example - * In this example, we create a conditional dependency on Hello World based on a hypothetical "needsWorld" boolean in the store. + * In this example, we create a conditional dependency on Hello World based on a hypothetical "needsWorld" boolean in our Store. + * Using .const() ensures that if the "needsWorld" boolean changes, setupDependencies will re-run. * * ``` export const setDependencies = sdk.setupDependencies( async ({ effects }) => { if (sdk.store.getOwn(sdk.StorePath.needsWorld).const()) { return { - 'hello-world': sdk.Dependency.of({ - type: 'running', - versionRange: VersionRange.parse('>=1.0.0:0'), - healthChecks: ['webui'], - }), + 'hello-world': { + kind: 'running', + versionRange: '>=1.0.0', + healthChecks: ['primary'], + }, } } return {} @@ -614,7 +662,8 @@ export class StartSdk { name: 'Name', description: 'When you launch the Hello World UI, it will display "Hello [Name]"', - required: { default: 'World' }, + required: true, + default: 'World' }), makePublic: Value.toggle({ name: 'Make Public', @@ -673,6 +722,7 @@ export class StartSdk { label: Value.text({ name: 'Label', required: false, + default: null, }) }) displayAs: 'label', @@ -690,11 +740,13 @@ export class StartSdk { spec: InputSpec.of({ label: Value.text({ name: 'Label', - required: { default: null }, + required: true, + default: null, }) pubkey: Value.text({ name: 'Pubkey', - required: { default: null }, + required: true, + default: null, }) }) displayAs: 'label', @@ -707,11 +759,13 @@ export class StartSdk { spec: InputSpec.of({ label: Value.text({ name: 'Label', - required: { default: null }, + required: true, + default: null, }) pubkey: Value.text({ name: 'Pubkey', - required: { default: null }, + required: true, + default: null, }) }) displayAs: 'label', @@ -777,6 +831,7 @@ export class StartSdk { // required name: 'Text Example', required: false, + default: null, // optional description: null, @@ -801,6 +856,7 @@ export class StartSdk { // required name: 'Textarea Example', required: false, + default: null, // optional description: null, @@ -821,6 +877,7 @@ export class StartSdk { // required name: 'Number Example', required: false, + default: null, integer: true, // optional @@ -844,6 +901,7 @@ export class StartSdk { // required name: 'Color Example', required: false, + default: null, // optional description: null, @@ -861,6 +919,7 @@ export class StartSdk { // required name: 'Datetime Example', required: false, + default: null, // optional description: null, @@ -880,7 +939,7 @@ export class StartSdk { selectExample: Value.select({ // required name: 'Select Example', - required: false, + default: 'radio1', values: { radio1: 'Radio 1', radio2: 'Radio 2', @@ -945,7 +1004,7 @@ export class StartSdk { { // required name: 'Union Example', - required: false, + default: 'option1', // optional description: null, diff --git a/sdk/package/lib/backup/Backups.ts b/sdk/package/lib/backup/Backups.ts index c27f2be72..c7c6301f5 100644 --- a/sdk/package/lib/backup/Backups.ts +++ b/sdk/package/lib/backup/Backups.ts @@ -13,28 +13,7 @@ export type BackupSync = { backupOptions?: Partial restoreOptions?: Partial } -/** - * 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 { private constructor( private options = DEFAULT_OPTIONS, diff --git a/sdk/package/lib/health/checkFns/checkPortListening.ts b/sdk/package/lib/health/checkFns/checkPortListening.ts index e745bce4f..0d6792b86 100644 --- a/sdk/package/lib/health/checkFns/checkPortListening.ts +++ b/sdk/package/lib/health/checkFns/checkPortListening.ts @@ -1,12 +1,11 @@ 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") diff --git a/sdk/package/lib/mainFn/CommandController.ts b/sdk/package/lib/mainFn/CommandController.ts index 1aefc854f..3b2285adb 100644 --- a/sdk/package/lib/mainFn/CommandController.ts +++ b/sdk/package/lib/mainFn/CommandController.ts @@ -1,10 +1,8 @@ import { DEFAULT_SIGTERM_TIMEOUT } from "." -import { NO_TIMEOUT, SIGKILL, SIGTERM } from "../../../base/lib/types" +import { NO_TIMEOUT, SIGTERM } from "../../../base/lib/types" import * as T from "../../../base/lib/types" -import { asError } from "../../../base/lib/util/asError" import { - ExecSpawnable, MountOptions, SubContainerHandle, SubContainer, diff --git a/sdk/package/lib/manifest/setupManifest.ts b/sdk/package/lib/manifest/setupManifest.ts index 5dfdb2451..1a78c062c 100644 --- a/sdk/package/lib/manifest/setupManifest.ts +++ b/sdk/package/lib/manifest/setupManifest.ts @@ -11,7 +11,6 @@ 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< @@ -23,7 +22,7 @@ export function setupManifest< assets: AssetTypes[] volumes: VolumesTypes[] } & SDKManifest, ->(manifest: Manifest): Manifest { +>(manifest: Manifest & SDKManifest): Manifest { return manifest } diff --git a/sdk/package/lib/store/setupExposeStore.ts b/sdk/package/lib/store/setupExposeStore.ts index 1ae0bf13f..7f5415bd7 100644 --- a/sdk/package/lib/store/setupExposeStore.ts +++ b/sdk/package/lib/store/setupExposeStore.ts @@ -1,5 +1,4 @@ import { ExposedStorePaths } from "../../../base/lib/types" -import { Affine, _ } from "../util" import { PathBuilder, extractJsonPath, diff --git a/sdk/package/lib/trigger/defaultTrigger.ts b/sdk/package/lib/trigger/defaultTrigger.ts index 69cac2773..647695fb2 100644 --- a/sdk/package/lib/trigger/defaultTrigger.ts +++ b/sdk/package/lib/trigger/defaultTrigger.ts @@ -1,6 +1,5 @@ import { cooldownTrigger } from "./cooldownTrigger" import { changeOnFirstSuccess } from "./changeOnFirstSuccess" -import { successFailure } from "./successFailure" export const defaultTrigger = changeOnFirstSuccess({ beforeFirstSuccess: cooldownTrigger(1000), diff --git a/sdk/package/lib/util/SubContainer.ts b/sdk/package/lib/util/SubContainer.ts index 7274e22d4..f9b5a1084 100644 --- a/sdk/package/lib/util/SubContainer.ts +++ b/sdk/package/lib/util/SubContainer.ts @@ -4,9 +4,10 @@ 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 diff --git a/sdk/package/lib/util/fileHelper.ts b/sdk/package/lib/util/fileHelper.ts index 80e5b3564..d47af510c 100644 --- a/sdk/package/lib/util/fileHelper.ts +++ b/sdk/package/lib/util/fileHelper.ts @@ -46,27 +46,34 @@ async function onCreated(path: string) { /** * @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). + * These type definitions should reflect the underlying file as closely as possible. For example, if the service does not require a particular value, it should be marked as optional(), even if your package requires it. + * + * It is recommended to use onMismatch() whenever possible. This provides an escape hatch in case the user edits the file manually and accidentally sets a value to an unsupported type. + * + * Officially supported file types are json, yaml, and toml. Other files types can use "raw" + * + * Choose between officially supported file formats (), 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 + * const { arrayOf, boolean, literal, literals, object, natural, string } = matches * * export const jsonFile = FileHelper.json('./inputSpec.json', object({ - * passwords: arrayOf(string) - * type: oneOf(literals('private', 'public')) + * passwords: arrayOf(string).onMismatch([]) + * type: literals('private', 'public').optional().onMismatch(undefined) * })) * * export const tomlFile = FileHelper.toml('./inputSpec.toml', object({ - * url: literal('https://start9.com') - * public: boolean + * url: literal('https://start9.com').onMismatch('https://start9.com') + * public: boolean.onMismatch(true) * })) * * export const yamlFile = FileHelper.yaml('./inputSpec.yml', object({ - * name: string - * age: natural + * name: string.optional().onMismatch(undefined) + * age: natural.optional().onMismatch(undefined) * })) * * export const bitcoinConfFile = FileHelper.raw( @@ -183,7 +190,7 @@ export class FileHelper { /** * 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. + * Like one behavior of another dependency or something similar. */ withPath(path: string) { return new FileHelper(path, this.writeData, this.readData, this.validate)