* help ios downlaod .crt and add begin add masked for addresses

* only require and show CA for public domain if addSsl

* fix type and revert i18n const

* feat: add address masking and adjust design (#3088)

* feat: add address masking and adjust design

* update lockfile

* chore: move eye button to actions

* chore: refresh notifications and handle action error

* static width for health check name

---------

Co-authored-by: Matt Hill <mattnine@protonmail.com>

* hide certificate authorities tab

* alpha.17

* add waiting health check status

* remove "on" from waiting message

* reject on abort in `.watch`

* id migration: nostr -> nostr-rs-relay

* health check waiting state

* use interface type for launch button

* better wording for masked

* cleaner

* sdk improvements

* fix type error

* fix notification badge issue

---------

Co-authored-by: Alex Inkin <alexander@inkin.ru>
Co-authored-by: Aiden McClelland <me@drbonez.dev>
This commit is contained in:
Matt Hill
2025-12-31 11:30:57 -07:00
committed by GitHub
parent 96ae532879
commit c9a7f519b9
99 changed files with 1535 additions and 1120 deletions

View File

@@ -224,8 +224,6 @@ export type ListValueSpecObject = {
uniqueBy: UniqueBy
displayAs: string | null
}
// TODO Aiden do we really want this expressivity? Why not the below. Also what's with the "readonly" portion?
// export type UniqueBy = null | string | { any: string[] } | { all: string[] }
export type UniqueBy =
| null

View File

@@ -0,0 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Guid } from "./Guid"
import type { PackageId } from "./PackageId"
export type AddPackageSignerParams = {
id: PackageId
signer: Guid
versions: string | null
}

View File

@@ -4,6 +4,7 @@ export type NamedHealthCheckResult = { name: string } & (
| { result: "success"; message: string | null }
| { result: "disabled"; message: string | null }
| { result: "starting"; message: string | null }
| { result: "waiting"; message: string | null }
| { result: "loading"; message: string }
| { result: "failure"; message: string }
)

View File

@@ -4,7 +4,7 @@ import type { PackageVersionInfo } from "./PackageVersionInfo"
import type { Version } from "./Version"
export type PackageInfo = {
authorized: Array<Guid>
authorized: { [key: Guid]: string }
versions: { [key: Version]: PackageVersionInfo }
categories: string[]
}

View File

@@ -2,4 +2,4 @@
import type { Guid } from "./Guid"
import type { PackageId } from "./PackageId"
export type PackageSignerParams = { id: PackageId; signer: Guid }
export type RemovePackageSignerParams = { id: PackageId; signer: Guid }

View File

@@ -5,6 +5,7 @@ export type SetHealth = { id: HealthCheckId; name: string } & (
| { result: "success"; message: string | null }
| { result: "disabled"; message: string | null }
| { result: "starting"; message: string | null }
| { result: "waiting"; message: string | null }
| { result: "loading"; message: string }
| { result: "failure"; message: string }
)

View File

@@ -14,6 +14,7 @@ export { AddAdminParams } from "./AddAdminParams"
export { AddAssetParams } from "./AddAssetParams"
export { AddCategoryParams } from "./AddCategoryParams"
export { AddPackageParams } from "./AddPackageParams"
export { AddPackageSignerParams } from "./AddPackageSignerParams"
export { AddPackageToCategoryParams } from "./AddPackageToCategoryParams"
export { AddressInfo } from "./AddressInfo"
export { AddSslOptions } from "./AddSslOptions"
@@ -154,7 +155,6 @@ export { PackageId } from "./PackageId"
export { PackageIndex } from "./PackageIndex"
export { PackageInfoShort } from "./PackageInfoShort"
export { PackageInfo } from "./PackageInfo"
export { PackageSignerParams } from "./PackageSignerParams"
export { PackageState } from "./PackageState"
export { PackageVersionInfo } from "./PackageVersionInfo"
export { PasswordType } from "./PasswordType"
@@ -172,6 +172,7 @@ export { RemoveAssetParams } from "./RemoveAssetParams"
export { RemoveCategoryParams } from "./RemoveCategoryParams"
export { RemovePackageFromCategoryParams } from "./RemovePackageFromCategoryParams"
export { RemovePackageParams } from "./RemovePackageParams"
export { RemovePackageSignerParams } from "./RemovePackageSignerParams"
export { RemoveTunnelParams } from "./RemoveTunnelParams"
export { RemoveVersionParams } from "./RemoveVersionParams"
export { ReplayId } from "./ReplayId"

View File

@@ -39,6 +39,7 @@ export class GetSystemSmtp {
})
await waitForNext
}
return new Promise<never>((_, rej) => rej(new Error("aborted")))
}
/**
@@ -46,7 +47,7 @@ export class GetSystemSmtp {
*/
watch(
abort?: AbortSignal,
): AsyncGenerator<T.SmtpValue | null, void, unknown> {
): AsyncGenerator<T.SmtpValue | null, never, unknown> {
const ctrl = new AbortController()
abort?.addEventListener("abort", () => ctrl.abort())
return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort())

View File

@@ -393,12 +393,13 @@ export class GetServiceInterface<Mapped = ServiceInterfaceFilled | null> {
}
await waitForNext
}
return new Promise<never>((_, rej) => rej(new Error("aborted")))
}
/**
* Watches the requested service interface. Returns an async iterator that yields whenever the value changes
*/
watch(abort?: AbortSignal): AsyncGenerator<Mapped, void, unknown> {
watch(abort?: AbortSignal): AsyncGenerator<Mapped, never, unknown> {
const ctrl = new AbortController()
abort?.addEventListener("abort", () => ctrl.abort())
return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort())

View File

@@ -2,11 +2,7 @@ import { Effects } from "../Effects"
import { PackageId } from "../osBindings"
import { deepEqual } from "./deepEqual"
import { DropGenerator, DropPromise } from "./Drop"
import {
ServiceInterfaceFilled,
filledAddress,
getHostname,
} from "./getServiceInterface"
import { ServiceInterfaceFilled, filledAddress } from "./getServiceInterface"
const makeManyInterfaceFilled = async ({
effects,
@@ -106,12 +102,13 @@ export class GetServiceInterfaces<Mapped = ServiceInterfaceFilled[]> {
}
await waitForNext
}
return new Promise<never>((_, rej) => rej(new Error("aborted")))
}
/**
* Watches the service interfaces for the package. Returns an async iterator that yields whenever the value changes
*/
watch(abort?: AbortSignal): AsyncGenerator<Mapped, void, unknown> {
watch(abort?: AbortSignal): AsyncGenerator<Mapped, never, unknown> {
const ctrl = new AbortController()
abort?.addEventListener("abort", () => ctrl.abort())
return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort())

View File

@@ -65,8 +65,9 @@ import {
ServiceInterfaceFilled,
} from "../../base/lib/util/getServiceInterface"
import { getOwnServiceInterfaces } from "../../base/lib/util/getServiceInterfaces"
import { Volumes, createVolumes } from "./util/Volume"
export const OSVersion = testTypeVersion("0.4.0-alpha.16")
export const OSVersion = testTypeVersion("0.4.0-alpha.17")
// prettier-ignore
type AnyNeverCond<T extends any[], Then, Else> =
@@ -132,6 +133,7 @@ export class StartSdk<Manifest extends T.SDKManifest> {
return {
manifest: this.manifest,
volumes: createVolumes(this.manifest),
...startSdkEffectWrapper,
setDataVersion,
getDataVersion,
@@ -430,10 +432,10 @@ export class StartSdk<Manifest extends T.SDKManifest> {
query: Record<string, string>
/** (optional) overrides the protocol prefix provided by the bind function.
*
* @example `ftp://`
* @example `{ ssl: 'ftps', noSsl: 'ftp' }`
*/
schemeOverride: { ssl: Scheme; noSsl: Scheme } | null
/** TODO Aiden how would someone include a password in the URL? Whether or not to mask the URLs on the screen, for example, when they contain a password */
/** mask the url (recommended if it contains credentials such as an API key or password) */
masked: boolean
},
) => new ServiceInterfaceBuilder({ ...options, effects }),

View File

@@ -14,10 +14,7 @@ export { CommandController } from "./CommandController"
import { EXIT_SUCCESS, HealthDaemon } from "./HealthDaemon"
import { Daemon } from "./Daemon"
import { CommandController } from "./CommandController"
import { HealthCheck } from "../health/HealthCheck"
import { Oneshot } from "./Oneshot"
import { Manifest } from "../test/output.sdk"
import { asError } from "../util"
export const cpExec = promisify(CP.exec)
export const cpExecFile = promisify(CP.execFile)
@@ -432,7 +429,7 @@ export class Daemons<Manifest extends T.SDKManifest, Ids extends string>
async build() {
for (const daemon of this.healthDaemons) {
await daemon.init()
await daemon.updateStatus()
}
return this
}

View File

@@ -3,10 +3,6 @@ import { defaultTrigger } from "../trigger/defaultTrigger"
import { Ready } from "./Daemons"
import { Daemon } from "./Daemon"
import { SetHealth, Effects, SDKManifest } from "../../../base/lib/types"
import { DEFAULT_SIGTERM_TIMEOUT } from "."
import { asError } from "../../../base/lib/util/asError"
import { Oneshot } from "./Oneshot"
import { SubContainer } from "../util/SubContainer"
const oncePromise = <T>() => {
let resolve: (value: T) => void
@@ -26,7 +22,7 @@ export const EXIT_SUCCESS = "EXIT_SUCCESS" as const
*
*/
export class HealthDaemon<Manifest extends SDKManifest> {
private _health: HealthCheckResult = { result: "starting", message: null }
private _health: HealthCheckResult = { result: "waiting", message: null }
private healthWatchers: Array<() => unknown> = []
private running = false
private started?: number
@@ -82,20 +78,18 @@ export class HealthDaemon<Manifest extends SDKManifest> {
if (newStatus) {
console.debug(`Launching ${this.id}...`)
this.setupHealthCheck()
;(await this.daemon)?.start()
this.daemon?.start()
this.started = performance.now()
} else {
console.debug(`Stopping ${this.id}...`)
;(await this.daemon)?.term()
this.turnOffHealthCheck()
this.setHealth({ result: "starting", message: null })
this.daemon?.term()
await this.turnOffHealthCheck()
}
}
private healthCheckCleanup: (() => null) | null = null
private turnOffHealthCheck() {
this.healthCheckCleanup?.()
private healthCheckCleanup: (() => Promise<null>) | null = null
private async turnOffHealthCheck() {
await this.healthCheckCleanup?.()
this.resolvedReady = false
this.readyPromise = new Promise(
@@ -107,8 +101,7 @@ export class HealthDaemon<Manifest extends SDKManifest> {
)
}
private async setupHealthCheck() {
const daemon = await this.daemon
daemon?.onExit((success) => {
this.daemon?.onExit((success) => {
if (success && this.ready === "EXIT_SUCCESS") {
this.setHealth({ result: "success", message: null })
} else if (!success) {
@@ -116,7 +109,7 @@ export class HealthDaemon<Manifest extends SDKManifest> {
result: "failure",
message: `${this.id} daemon crashed`,
})
} else if (!daemon.isOneshot()) {
} else if (!this.daemon?.isOneshot()) {
this.setHealth({
result: "failure",
message: `${this.id} daemon exited`,
@@ -132,6 +125,7 @@ export class HealthDaemon<Manifest extends SDKManifest> {
const { promise: status, resolve: setStatus } = oncePromise<{
done: true
}>()
const { promise: exited, resolve: setExited } = oncePromise<null>()
new Promise(async () => {
if (this.ready === "EXIT_SUCCESS") return
for (
@@ -150,10 +144,12 @@ export class HealthDaemon<Manifest extends SDKManifest> {
await this.setHealth(response)
}
setExited(null)
}).catch((err) => console.error(`Daemon ${this.id} failed: ${err}`))
this.healthCheckCleanup = () => {
this.healthCheckCleanup = async () => {
setStatus({ done: true })
await exited
this.healthCheckCleanup = null
return null
}
@@ -201,6 +197,7 @@ export class HealthDaemon<Manifest extends SDKManifest> {
const healths = this.dependencies.map((d) => ({
health: d.running && d._health,
id: d.id,
display: typeof d.ready === "object" ? d.ready.display : null,
}))
const waitingOn = healths.filter(
(h) => !h.health || h.health.result !== "success",
@@ -209,18 +206,15 @@ export class HealthDaemon<Manifest extends SDKManifest> {
console.debug(
`daemon ${this.id} waiting on ${waitingOn.map((w) => w.id)}`,
)
this.changeRunning(!waitingOn.length)
}
async init() {
if (this.ready !== "EXIT_SUCCESS" && this.ready.display) {
this.effects.setHealth({
id: this.id,
message: null,
name: this.ready.display,
result: "starting",
})
if (waitingOn.length) {
const waitingOnNames = waitingOn.flatMap((w) =>
w.display ? [w.display] : [],
)
const message = waitingOnNames.length ? waitingOnNames.join(", ") : null
await this.setHealth({ result: "waiting", message })
} else {
await this.setHealth({ result: "starting", message: null })
}
await this.updateStatus()
await this.changeRunning(!waitingOn.length)
}
}

View File

@@ -50,6 +50,7 @@ export class GetSslCertificate {
})
await waitForNext
}
return new Promise<never>((_, rej) => rej(new Error("aborted")))
}
/**
@@ -57,7 +58,7 @@ export class GetSslCertificate {
*/
watch(
abort?: AbortSignal,
): AsyncGenerator<[string, string, string], void, unknown> {
): AsyncGenerator<[string, string, string], never, unknown> {
const ctrl = new AbortController()
abort?.addEventListener("abort", () => ctrl.abort())
return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort())

View File

@@ -7,6 +7,7 @@ import { once } from "../../../base/lib/util/once"
import { Drop } from "../../../base/lib/util/Drop"
import { Mounts } from "../mainFn/Mounts"
import { BackupEffects } from "../backup/Backups"
import { PathBase } from "./Volume"
export const execFile = promisify(cp.execFile)
const False = () => false
@@ -71,10 +72,18 @@ async function bind(
export interface SubContainer<
Manifest extends T.SDKManifest,
Effects extends T.Effects = T.Effects,
> extends Drop {
> extends Drop,
PathBase {
readonly imageId: keyof Manifest["images"] & T.ImageId
readonly rootfs: string
readonly guid: T.Guid
/**
* Get the absolute path to a file or directory within this subcontainer's rootfs
* @param path Path relative to the rootfs
*/
subpath(path: string): string
mount(
mounts: Effects extends BackupEffects
? Mounts<
@@ -137,6 +146,22 @@ export interface SubContainer<
options?: CommandOptions & StdioOptions,
): Promise<cp.ChildProcess>
/**
* @description Write a file to the subcontainer's filesystem
* @param path Path relative to the subcontainer rootfs (e.g. "/etc/config.json")
* @param data The data to write
* @param options Optional write options (same as node:fs/promises writeFile)
*/
writeFile(
path: string,
data:
| string
| NodeJS.ArrayBufferView
| Iterable<string | NodeJS.ArrayBufferView>
| AsyncIterable<string | NodeJS.ArrayBufferView>,
options?: Parameters<typeof fs.writeFile>[2],
): Promise<void>
rc(): SubContainerRc<Manifest, Effects>
isOwned(): this is SubContainerOwned<Manifest, Effects>
@@ -291,6 +316,12 @@ export class SubContainerOwned<
}
}
subpath(path: string): string {
return path.startsWith("/")
? `${this.rootfs}${path}`
: `${this.rootfs}/${path}`
}
async mount(
mounts: Effects extends BackupEffects
? Mounts<
@@ -618,6 +649,27 @@ export class SubContainerOwned<
)
}
/**
* @description Write a file to the subcontainer's filesystem
* @param path Path relative to the subcontainer rootfs (e.g. "/etc/config.json")
* @param data The data to write
* @param options Optional write options (same as node:fs/promises writeFile)
*/
async writeFile(
path: string,
data:
| string
| NodeJS.ArrayBufferView
| Iterable<string | NodeJS.ArrayBufferView>
| AsyncIterable<string | NodeJS.ArrayBufferView>,
options?: Parameters<typeof fs.writeFile>[2],
): Promise<void> {
const fullPath = this.subpath(path)
const dir = fullPath.replace(/\/[^/]*\/?$/, "")
await fs.mkdir(dir, { recursive: true })
return fs.writeFile(fullPath, data, options)
}
rc(): SubContainerRc<Manifest, Effects> {
return new SubContainerRc(this)
}
@@ -643,6 +695,9 @@ export class SubContainerRc<
get guid() {
return this.subcontainer.guid
}
subpath(path: string): string {
return this.subcontainer.subpath(path)
}
private destroyed = false
private destroying: Promise<null> | null = null
public constructor(
@@ -800,6 +855,24 @@ export class SubContainerRc<
return this.subcontainer.spawn(command, options)
}
/**
* @description Write a file to the subcontainer's filesystem
* @param path Path relative to the subcontainer rootfs (e.g. "/etc/config.json")
* @param data The data to write
* @param options Optional write options (same as node:fs/promises writeFile)
*/
async writeFile(
path: string,
data:
| string
| NodeJS.ArrayBufferView
| Iterable<string | NodeJS.ArrayBufferView>
| AsyncIterable<string | NodeJS.ArrayBufferView>,
options?: Parameters<typeof fs.writeFile>[2],
): Promise<void> {
return this.subcontainer.writeFile(path, data, options)
}
rc(): SubContainerRc<Manifest, Effects> {
return this.subcontainer.rc()
}

View File

@@ -0,0 +1,88 @@
import * as fs from "node:fs/promises"
import * as T from "../../../base/lib/types"
/**
* Common interface for objects that have a subpath method (Volume, SubContainer, etc.)
*/
export interface PathBase {
subpath(path: string): string
}
/**
* @description Represents a volume in the StartOS filesystem.
* Provides utilities for reading and writing files within the volume.
*/
export class Volume<Id extends string = string> implements PathBase {
/**
* The absolute path to this volume's root directory
*/
readonly path: string
constructor(readonly id: Id) {
this.path = `/media/startos/volumes/${id}`
}
/**
* Get the absolute path to a file or directory within this volume
* @param subpath Path relative to the volume root
*/
subpath(subpath: string): string {
return subpath.startsWith("/")
? `${this.path}${subpath}`
: `${this.path}/${subpath}`
}
/**
* @description Read a file from this volume
* @param subpath Path relative to the volume root (e.g. "config.json" or "/data/file.txt")
* @param options Optional read options (same as node:fs/promises readFile)
*/
async readFile(
subpath: string,
options?: Parameters<typeof fs.readFile>[1],
): Promise<Buffer | string> {
const fullPath = this.subpath(subpath)
return fs.readFile(fullPath, options)
}
/**
* @description Write a file to this volume
* @param subpath Path relative to the volume root (e.g. "config.json" or "/data/file.txt")
* @param data The data to write
* @param options Optional write options (same as node:fs/promises writeFile)
*/
async writeFile(
subpath: string,
data:
| string
| NodeJS.ArrayBufferView
| Iterable<string | NodeJS.ArrayBufferView>
| AsyncIterable<string | NodeJS.ArrayBufferView>,
options?: Parameters<typeof fs.writeFile>[2],
): Promise<void> {
const fullPath = this.subpath(subpath)
const dir = fullPath.replace(/\/[^/]*\/?$/, "")
await fs.mkdir(dir, { recursive: true })
return fs.writeFile(fullPath, data, options)
}
}
/**
* Type-safe volumes object that provides Volume instances for each volume defined in the manifest
*/
export type Volumes<Manifest extends T.SDKManifest> = {
[K in Manifest["volumes"][number]]: Volume<K>
}
/**
* Creates a type-safe volumes object from a manifest
*/
export function createVolumes<Manifest extends T.SDKManifest>(
manifest: Manifest,
): Volumes<Manifest> {
const volumes = {} as Volumes<Manifest>
for (const volumeId of manifest.volumes) {
;(volumes as any)[volumeId] = new Volume(volumeId)
}
return volumes
}

View File

@@ -6,6 +6,7 @@ import * as T from "../../../base/lib/types"
import * as fs from "node:fs/promises"
import { asError, deepEqual } from "../../../base/lib/util"
import { DropGenerator, DropPromise } from "../../../base/lib/util/Drop"
import { PathBase } from "./Volume"
const previousPath = /(.+?)\/([^/]*)$/
@@ -88,11 +89,12 @@ export type Transformers<Raw = unknown, Transformed = unknown> = {
onWrite: (value: Transformed) => Raw
}
type ToPath = string | { volumeId: T.VolumeId; subpath: string }
type ToPath = string | { base: PathBase; subpath: string }
function toPath(path: ToPath): string {
return typeof path === "string"
? path
: `/media/startos/volumes/${path.volumeId}/${path.subpath}`
if (typeof path === "string") {
return path
}
return path.base.subpath(path.subpath)
}
type Validator<T, U> = matches.Validator<T, U> | matches.Validator<unknown, U>
@@ -103,7 +105,7 @@ type ReadType<A> = {
watch: (
effects: T.Effects,
abort?: AbortSignal,
) => AsyncGenerator<A | null, null, unknown>
) => AsyncGenerator<A | null, never, unknown>
onChange: (
effects: T.Effects,
callback: (
@@ -270,7 +272,7 @@ export class FileHelper<A> {
await onCreated(this.path).catch((e) => console.error(asError(e)))
}
}
return null
return new Promise<never>((_, rej) => rej(new Error("aborted")))
}
private readOnChange<B>(

View File

@@ -2,3 +2,4 @@ export * from "../../../base/lib/util"
export { GetSslCertificate } from "./GetSslCertificate"
export { Drop } from "../../../base/lib/util/Drop"
export { Volume, Volumes } from "./Volume"

View File

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

View File

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