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

This commit is contained in:
Matt Hill
2024-12-02 16:50:37 -07:00
42 changed files with 605 additions and 194 deletions

View File

@@ -0,0 +1,105 @@
#!/bin/bash
# Define the output file
OUTPUT_FILE="system_debug_info.txt"
# Check if the script is run as root, if not, restart with sudo
if [ "$(id -u)" -ne 0 ]; then
exec sudo bash "$0" "$@"
fi
# Create or clear the output file and add a header
echo "===================================================================" > "$OUTPUT_FILE"
echo " StartOS System Debug Information " >> "$OUTPUT_FILE"
echo "===================================================================" >> "$OUTPUT_FILE"
echo "Generated on: $(date)" >> "$OUTPUT_FILE"
echo "" >> "$OUTPUT_FILE"
# Function to check if a command exists
command_exists() {
command -v "$1" >/dev/null 2>&1
}
# Function to run a command if it exists and append its output to the file with headers
run_command() {
local CMD="$1"
local DESC="$2"
local CMD_NAME="${CMD%% *}" # Extract the command name (first word)
if command_exists "$CMD_NAME"; then
echo "===================================================================" >> "$OUTPUT_FILE"
echo "COMMAND: $CMD" >> "$OUTPUT_FILE"
echo "DESCRIPTION: $DESC" >> "$OUTPUT_FILE"
echo "===================================================================" >> "$OUTPUT_FILE"
echo "" >> "$OUTPUT_FILE"
eval "$CMD" >> "$OUTPUT_FILE" 2>&1
echo "" >> "$OUTPUT_FILE"
else
echo "===================================================================" >> "$OUTPUT_FILE"
echo "COMMAND: $CMD" >> "$OUTPUT_FILE"
echo "DESCRIPTION: $DESC" >> "$OUTPUT_FILE"
echo "===================================================================" >> "$OUTPUT_FILE"
echo "SKIPPED: Command not found" >> "$OUTPUT_FILE"
echo "" >> "$OUTPUT_FILE"
fi
}
# Collecting basic system information
run_command "start-cli --version; start-cli git-info" "StartOS CLI version and Git information"
run_command "hostname" "Hostname of the system"
run_command "uname -a" "Kernel version and system architecture"
# Services Info
run_command "start-cli lxc stats" "All Running Services"
# Collecting CPU information
run_command "lscpu" "CPU architecture information"
run_command "cat /proc/cpuinfo" "Detailed CPU information"
# Collecting memory information
run_command "free -h" "Available and used memory"
run_command "cat /proc/meminfo" "Detailed memory information"
# Collecting storage information
run_command "lsblk" "List of block devices"
run_command "df -h" "Disk space usage"
run_command "fdisk -l" "Detailed disk partition information"
# Collecting network information
run_command "ip a" "Network interfaces and IP addresses"
run_command "ip route" "Routing table"
run_command "netstat -i" "Network interface statistics"
# Collecting RAID information (if applicable)
run_command "cat /proc/mdstat" "List of RAID devices (if applicable)"
# Collecting virtualization information
run_command "egrep -c '(vmx|svm)' /proc/cpuinfo" "Check if CPU supports virtualization"
run_command "systemd-detect-virt" "Check if the system is running inside a virtual machine"
# Final message
echo "===================================================================" >> "$OUTPUT_FILE"
echo " End of StartOS System Debug Information " >> "$OUTPUT_FILE"
echo "===================================================================" >> "$OUTPUT_FILE"
# Prompt user to send the log file to a Start9 Technician
echo "System debug information has been collected in $OUTPUT_FILE."
echo ""
echo "Would you like to send this log file to a Start9 Technician? (yes/no)"
read SEND_LOG
if [[ "$SEND_LOG" == "yes" || "$SEND_LOG" == "y" ]]; then
if command -v wormhole >/dev/null 2>&1; then
echo ""
echo "==================================================================="
echo " Running wormhole to send the file. Please follow the "
echo " instructions and provide the code to the Start9 support team. "
echo "==================================================================="
wormhole send "$OUTPUT_FILE"
echo "==================================================================="
else
echo "Error: wormhole command not found."
fi
else
echo "Log file not sent. You can manually share $OUTPUT_FILE with the Start9 support team if needed."
fi

2
core/Cargo.lock generated
View File

@@ -5371,7 +5371,7 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
[[package]]
name = "start-os"
version = "0.3.6-alpha.8"
version = "0.3.6-alpha.9"
dependencies = [
"aes",
"async-acme",

View File

@@ -14,7 +14,7 @@ keywords = [
name = "start-os"
readme = "README.md"
repository = "https://github.com/Start9Labs/start-os"
version = "0.3.6-alpha.8"
version = "0.3.6-alpha.9"
license = "MIT"
[lib]

View File

@@ -124,15 +124,20 @@ impl fmt::Display for ActionResultV0 {
#[derive(Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct ActionResultV1 {
/// Primary text to display as the header of the response modal. e.g. "Success!", "Name Updated", or "Service Information", whatever makes sense
pub title: String,
/// (optional) A general message for the user, just under the title
pub message: Option<String>,
/// (optional) Structured data to present inside the modal
pub result: Option<ActionResultValue>,
}
#[derive(Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct ActionResultMember {
/// A human-readable name or title of the value, such as "Last Active" or "Login Password"
pub name: String,
/// (optional) A description of the value, such as an explaining why it exists or how to use it
pub description: Option<String>,
#[serde(flatten)]
#[ts(flatten)]
@@ -145,12 +150,17 @@ pub struct ActionResultMember {
#[serde(tag = "type")]
pub enum ActionResultValue {
Single {
/// The actual string value to display
value: String,
/// Whether or not to include a copy to clipboard icon to copy the value
copyable: bool,
/// Whether or not to also display the value as a QR code
qr: bool,
/// Whether or not to mask the value using ●●●●●●●, which is useful for password or other sensitive information
masked: bool,
},
Group {
/// An new group of nested values, experienced by the user as an accordion dropdown
value: Vec<ActionResultMember>,
},
}

View File

@@ -322,13 +322,25 @@ pub enum AllowedStatuses {
#[serde(rename_all = "camelCase")]
#[model = "Model<Self>"]
pub struct ActionMetadata {
/// A human-readable name
pub name: String,
/// A detailed description of what the action will do
pub description: String,
/// Presents as an alert prior to executing the action. Should be used sparingly but important if the action could have harmful, unintended consequences
pub warning: Option<String>,
#[serde(default)]
/// One of: "enabled", "hidden", or { disabled: "" }
/// - "enabled" - the action is available be run
/// - "hidden" - the action cannot be seen or run
/// - { disabled: "example explanation" } means the action is visible but cannot be run. Replace "example explanation" with a reason why the action is disable to prevent user confusion.
pub visibility: ActionVisibility,
/// One of: "only-stopped", "only-running", "all"
/// - "only-stopped" - the action can only be run when the service is stopped
/// - "only-running" - the action can only be run when the service is running
/// - "any" - the action can only be run regardless of the service's status
pub allowed_statuses: AllowedStatuses,
pub has_input: bool,
/// If provided, this action will be nested under a header of this value, along with other actions of the same group
pub group: Option<String>,
}

View File

@@ -13,11 +13,11 @@ use serde::{Deserialize, Serialize};
use tracing::instrument;
use ts_rs::TS;
use crate::backup::BackupReport;
use crate::context::{CliContext, RpcContext};
use crate::db::model::DatabaseModel;
use crate::prelude::*;
use crate::util::serde::HandlerExtSerde;
use crate::{backup::BackupReport, db::model::Database};
// #[command(subcommands(list, delete, delete_before, create))]
pub fn notification<C: Context>() -> ParentHandler<C> {
@@ -285,6 +285,9 @@ impl NotificationType for () {
impl NotificationType for BackupReport {
const CODE: u32 = 1;
}
impl NotificationType for String {
const CODE: u32 = 2;
}
#[instrument(skip(subtype, db))]
pub fn notify<T: NotificationType>(

View File

@@ -27,8 +27,9 @@ mod v0_3_6_alpha_5;
mod v0_3_6_alpha_6;
mod v0_3_6_alpha_7;
mod v0_3_6_alpha_8;
mod v0_3_6_alpha_9;
pub type Current = v0_3_6_alpha_8::Version; // VERSION_BUMP
pub type Current = v0_3_6_alpha_9::Version; // VERSION_BUMP
impl Current {
#[instrument(skip(self, db))]
@@ -106,6 +107,7 @@ enum Version {
V0_3_6_alpha_6(Wrapper<v0_3_6_alpha_6::Version>),
V0_3_6_alpha_7(Wrapper<v0_3_6_alpha_7::Version>),
V0_3_6_alpha_8(Wrapper<v0_3_6_alpha_8::Version>),
V0_3_6_alpha_9(Wrapper<v0_3_6_alpha_9::Version>),
Other(exver::Version),
}
@@ -138,6 +140,7 @@ impl Version {
Self::V0_3_6_alpha_6(v) => DynVersion(Box::new(v.0)),
Self::V0_3_6_alpha_7(v) => DynVersion(Box::new(v.0)),
Self::V0_3_6_alpha_8(v) => DynVersion(Box::new(v.0)),
Self::V0_3_6_alpha_9(v) => DynVersion(Box::new(v.0)),
Self::Other(v) => {
return Err(Error::new(
eyre!("unknown version {v}"),
@@ -162,6 +165,7 @@ impl Version {
Version::V0_3_6_alpha_6(Wrapper(x)) => x.semver(),
Version::V0_3_6_alpha_7(Wrapper(x)) => x.semver(),
Version::V0_3_6_alpha_8(Wrapper(x)) => x.semver(),
Version::V0_3_6_alpha_9(Wrapper(x)) => x.semver(),
Version::Other(x) => x.clone(),
}
}

View File

@@ -0,0 +1,83 @@
# StartOS v0.3.6
## Warning
Previous backups are incompatible with v0.3.6. It is strongly recommended that you (1) immediately update all services, then (2) create a fresh backup. See the [backups](#improved-backups) section below for more details.
## Summary
Servers are not toys. They are a critical component of the computing paradigm, and their failure can be catastrophic, resulting in downtime or loss of data. From the beginning, Start9 has taken a "security and reliability first" approach to the development of StartOS, favoring soundness over speed and prioritizing essential features such as encrypted network connections, simple backups, and a reliable container runtime over nice-to-haves like custom theming and more apps.
Start9 is paving new ground with StartOS, trying to achieve what most developers and IT professionals thought impossible; namely, giving a normal person the same independent control over their data and communications as an experienced Linux sysadmin.
A consequence of our principled approach to development, combined with the difficulty of our endeavor, is that (1) mistakes will be made and (2) they must be corrected. That means a willingness to discard bad ideas and broken parts, and if absolutely necessary, to nuke everything and start over from scratch. We did this in 2020 with StartOS v0.2.0, again in 2022 with StartOS v0.3.0, and now in 2024 with StartOS v0.3.6.
StartOS v0.3.6 is a complete rewrite of the OS internals (everything you don't see). Almost nothing survived. After nearly five years of building StartOS, we believe that we have finally arrived at the correct architecture and foundation, and that no additional rewrites will be necessary for StartOS to deliver on its promise.
## Changelog
- [Switch to lxc-based container runtime](#lxc)
- [Update s9pk archive format](#new-s9pk-archive-format)
- [Improve config](#better-config)
- [Unify Actions](#unify-actions)
- [Use squashfs images for OS updates](#squashfs-updates)
- [Introduce Typescript package API and SDK](#typescript-package-api-and-sdk)
- [Remove Postgresql](#remove-postgressql)
- [Implement detailed progress reporting](#progress-reporting)
- [Improve registry protocol](#registry-protocol)
- [Replace unique .local URLs with unique ports](#lan-port-forwarding)
- [Use start-fs Fuse module for improved backups](#improved-backups)
- [Switch to Exver for versioning](#Exver)
- [Support clearnet hosting via start-cli](#clearnet)
### LXC
StartOS now uses a nested container paradigm based on LXC for the outer container, and using linux namespaces for the inner lite containers. This replaces both Docker and Podman.
### S9PK archive format
The S9PK archive format has been overhauled to allow for signature verification of partial downloads, and allow direct mounting of container images without unpacking the s9pk.
### Better config
Expanded support for input types and a new UI makes configuring services easier and more powerful.
### Actions
Actions take arbitrary form input _and_ return arbitrary responses, thus satisfying the needs of both Config and Properties, which will be removed in a future release. This gives packages developers the ability to break up Config and Properties into smaller, more specific formats, or to exclude them entirely without polluting the UI.
### Squashfs updates
StartOS now uses squashfs images to represent OS updates. This allows for better update verification, and improved reliability over rsync updates.
### Typescript package API and SDK
StartOS now exposes a Typescript API. Package developers can take advantage in a simple, typesafe way using the new start-sdk. A barebones StartOS package (s9pk) can be produced in minutes with minimal knowledge or skill. More advanced developers can use the SDK to create highly customized user experiences with their service.
### Remove PostgresSQL
StartOS itself has miniscule data persistence needs. PostgresSQL was overkill and has been removed in favor of lightweight PatchDB.
### Progress reporting
A new progress reporting API enabled package developers to create unique phases and provide real-time progress reporting for actions such as installing, updating, or backing up a service.
### Registry protocol
The new registry protocol bifurcates package indexing (listing/validating) and package hosting (downloading). Registries are now simple indexes of packages that reference binaries hosted in arbitrary locations, locally or externally. For example, when someone visits the Start9 Registry, the currated list of packages comes from Start9. But when someone installs a listed service, the package binary is being downloaded from Github. The registry also valides the binary. This makes it much easier to host a custom registry, since it is just a currated list of services tat reference package binaries hosted on Github or elsewhere.
### LAN port forwarding
Perhaps the biggest complaint with prior version of StartOS was use of unique .local URLs for service interfaces. This has been corrected. Service interfaces are now available on unique ports, allowing for non-http traffic on the LAN as well as remote access via VPN.
### Improved Backups
The new start-fs fuse module unifies file system expectations for various platforms, enabling more reliable backups. The new system also defaults to using rsync differential backups instead of incremental backups, which is faster and saves on disk space by also deleting from the backup files that were deleted from the server.
### Exver
StartOS now uses Extended Versioning (Exver), which consists of three parts, separated by semicolons: (1) a Semver-compliant upstream version, (2) a Semver-compliant wrapper version, and (3) an optional "flavor" prefix. Flavors can be thought of as alternative implementations of services, where a user would only want one or the other installed, and data can feasibly be migrating beetween the two. Another common characteristic of flavors is that they satisfy the same API requirement of dependents, though this is not strictly necessary. A valid Exver looks something like this: `#knots:28.0.:1.0-beta.1`. This would translate to "the first beta release of StartOS wrapper version 1.0 of Bitcoin Knots version 27.0".
### Clearnet
It is now possible, and quite easy, to expose specific services interfaces to the public Internet on a standard domain using start-cli. This functionality will be expanded upon and moved into the StartOS UI in a future release.

View File

@@ -2,6 +2,7 @@ use exver::{PreReleaseSegment, VersionRange};
use super::v0_3_5::V0_3_0_COMPAT;
use super::{v0_3_6_alpha_5, VersionT};
use crate::notifications::{notify, NotificationLevel};
use crate::prelude::*;
lazy_static::lazy_static! {
@@ -11,23 +12,40 @@ lazy_static::lazy_static! {
);
}
#[derive(Clone, Copy, Debug, Default)]
#[derive(Default, Clone, Copy, Debug)]
pub struct Version;
impl VersionT for Version {
type Previous = v0_3_6_alpha_5::Version;
type PreUpRes = ();
async fn pre_up(self) -> Result<Self::PreUpRes, Error> {
Ok(())
}
fn semver(self) -> exver::Version {
V0_3_6_alpha_6.clone()
}
fn compat(self) -> &'static VersionRange {
&V0_3_0_COMPAT
}
fn up(self, _db: &mut Value, _: Self::PreUpRes) -> Result<(), Error> {
async fn pre_up(self) -> Result<Self::PreUpRes, Error> {
Ok(())
}
fn up(self, db: &mut Value, _: Self::PreUpRes) -> Result<(), Error> {
Ok(())
}
async fn post_up<'a>(self, ctx: &'a crate::context::RpcContext) -> Result<(), Error> {
let message_update = include_str!("update_details/v0_3_6.md").to_string();
ctx.db
.mutate(|db| {
notify(
db,
None,
NotificationLevel::Success,
"Welcome to StartOS 0.3.6!".to_string(),
"Click \"View Details\" to learn all about the new version".to_string(),
message_update,
)?;
Ok(())
})
.await?;
Ok(())
}
fn down(self, _db: &mut Value) -> Result<(), Error> {

View File

@@ -0,0 +1,36 @@
use exver::{PreReleaseSegment, VersionRange};
use super::v0_3_5::V0_3_0_COMPAT;
use super::{v0_3_6_alpha_8, VersionT};
use crate::prelude::*;
lazy_static::lazy_static! {
static ref V0_3_6_alpha_9: exver::Version = exver::Version::new(
[0, 3, 6],
[PreReleaseSegment::String("alpha".into()), 9.into()]
);
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Version;
impl VersionT for Version {
type Previous = v0_3_6_alpha_8::Version;
type PreUpRes = ();
async fn pre_up(self) -> Result<Self::PreUpRes, Error> {
Ok(())
}
fn semver(self) -> exver::Version {
V0_3_6_alpha_9.clone()
}
fn compat(self) -> &'static VersionRange {
&V0_3_0_COMPAT
}
fn up(self, _: &mut Value, _: Self::PreUpRes) -> Result<(), Error> {
Ok(())
}
fn down(self, _db: &mut Value) -> Result<(), Error> {
Ok(())
}
}

1
debian/postinst vendored
View File

@@ -101,6 +101,7 @@ EOF
rm -rf /var/lib/tor/*
ln -sf /usr/lib/startos/scripts/tor-check.sh /usr/bin/tor-check
ln -sf /usr/lib/startos/scripts/gather_debug_info.sh /usr/bin/gather-debug
echo "fs.inotify.max_user_watches=1048576" > /etc/sysctl.d/97-embassy.conf

View File

@@ -206,8 +206,8 @@ if [ "${IB_TARGET_PLATFORM}" = "raspberrypi" ]; then
echo "Configuring raspi kernel '\$v'"
extract-ikconfig "/usr/lib/modules/\$v/kernel/kernel/configs.ko.xz" > /boot/config-\$v
done
mkinitramfs -c gzip -o /boot/initramfs8 6.6.51-v8+
mkinitramfs -c gzip -o /boot/initramfs_2712 6.6.51-v8-16k+
mkinitramfs -c gzip -o /boot/initramfs8 6.6.62-v8+
mkinitramfs -c gzip -o /boot/initramfs_2712 6.6.62-v8-16k+
fi
useradd --shell /bin/bash -G embassy -m start9

View File

@@ -12,7 +12,6 @@ import {
Host,
ExportServiceInterfaceParams,
ServiceInterface,
ActionRequest,
RequestActionParams,
MainStatus,
} from "./osBindings"

View File

@@ -14,7 +14,7 @@ export type Run<
> = (options: {
effects: T.Effects
input: ExtractInputSpecType<A> & Record<string, any>
}) => Promise<T.ActionResult | null | void | undefined>
}) => Promise<(T.ActionResult & { version: "1" }) | null | void | undefined>
export type GetInput<
A extends
| Record<string, any>

View File

@@ -3,11 +3,35 @@ import type { ActionVisibility } from "./ActionVisibility"
import type { AllowedStatuses } from "./AllowedStatuses"
export type ActionMetadata = {
/**
* A human-readable name
*/
name: string
/**
* A detailed description of what the action will do
*/
description: string
/**
* Presents as an alert prior to executing the action. Should be used sparingly but important if the action could have harmful, unintended consequences
*/
warning: string | null
/**
* One of: "enabled", "hidden", or { disabled: "" }
* - "enabled" - the action is available be run
* - "hidden" - the action cannot be seen or run
* - { disabled: "example explanation" } means the action is visible but cannot be run. Replace "example explanation" with a reason why the action is disable to prevent user confusion.
*/
visibility: ActionVisibility
/**
* One of: "only-stopped", "only-running", "all"
* - "only-stopped" - the action can only be run when the service is stopped
* - "only-running" - the action can only be run when the service is running
* - "any" - the action can only be run regardless of the service's status
*/
allowedStatuses: AllowedStatuses
hasInput: boolean
/**
* If provided, this action will be nested under a header of this value, along with other actions of the same group
*/
group: string | null
}

View File

@@ -1,15 +1,39 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ActionResultMember = {
/**
* A human-readable name or title of the value, such as "Last Active" or "Login Password"
*/
name: string
/**
* (optional) A description of the value, such as an explaining why it exists or how to use it
*/
description: string | null
} & (
| {
type: "single"
/**
* The actual string value to display
*/
value: string
/**
* Whether or not to include a copy to clipboard icon to copy the value
*/
copyable: boolean
/**
* Whether or not to also display the value as a QR code
*/
qr: boolean
/**
* Whether or not to mask the value using ●●●●●●●, which is useful for password or other sensitive information
*/
masked: boolean
}
| { type: "group"; value: Array<ActionResultMember> }
| {
type: "group"
/**
* An new group of nested values, experienced by the user as an accordion dropdown
*/
value: Array<ActionResultMember>
}
)

View File

@@ -2,7 +2,16 @@
import type { ActionResultValue } from "./ActionResultValue"
export type ActionResultV1 = {
/**
* Primary text to display as the header of the response modal. e.g. "Success!", "Name Updated", or "Service Information", whatever makes sense
*/
title: string
/**
* (optional) A general message for the user, just under the title
*/
message: string | null
/**
* (optional) Structured data to present inside the modal
*/
result: ActionResultValue | null
}

View File

@@ -4,9 +4,27 @@ import type { ActionResultMember } from "./ActionResultMember"
export type ActionResultValue =
| {
type: "single"
/**
* The actual string value to display
*/
value: string
/**
* Whether or not to include a copy to clipboard icon to copy the value
*/
copyable: boolean
/**
* Whether or not to also display the value as a QR code
*/
qr: boolean
/**
* Whether or not to mask the value using ●●●●●●●, which is useful for password or other sensitive information
*/
masked: boolean
}
| { type: "group"; value: Array<ActionResultMember> }
| {
type: "group"
/**
* An new group of nested values, experienced by the user as an accordion dropdown
*/
value: Array<ActionResultMember>
}

View File

@@ -1,6 +0,0 @@
{
"name": "sdk",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -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<Manifest extends T.SDKManifest, Store> {
return runCommand<Manifest>(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'
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,
}),
})
export const nameToLog = sdk.createAction(
// id
'nameToLogs',
// metadata
{
name: 'Name to Logs',
description: 'Prints "Hello [Name]" to the service logs.',
warning: null,
disabled: false,
input,
allowedStatuses: 'onlyRunning',
group: null,
},
// the execution function
async ({ effects, input }) => {
const name =
input.nameToPrint || (await yamlFile.read(effects))?.name || 'Unknown'
console.info(`Hello ${name}`)
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: {
/**
* @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 { InputSpec, Value } = sdk
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',
}),
})
export const setName = sdk.Action.withInput(
// id
'set-name',
// 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',
}),
// form input specification
inputSpec,
// 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<Manifest extends T.SDKManifest, Store> {
getInput: GetInput<Type>,
run: Run<Type>,
) => 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 extends T.ActionId>(
id: Id,
metadata: MaybeFn<Omit<T.ActionMetadata, "hasInput">>,
@@ -355,9 +397,9 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
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<Manifest extends T.SDKManifest, Store> {
* 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<Manifest extends T.SDKManifest, Store> {
setupBackups<Manifest>(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<Manifest extends T.SDKManifest, Store> {
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<Manifest extends T.SDKManifest, Store> {
label: Value.text({
name: 'Label',
required: false,
default: null,
})
})
displayAs: 'label',
@@ -690,11 +740,13 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
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<Manifest extends T.SDKManifest, Store> {
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<Manifest extends T.SDKManifest, Store> {
// required
name: 'Text Example',
required: false,
default: null,
// optional
description: null,
@@ -801,6 +856,7 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
// required
name: 'Textarea Example',
required: false,
default: null,
// optional
description: null,
@@ -821,6 +877,7 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
// required
name: 'Number Example',
required: false,
default: null,
integer: true,
// optional
@@ -844,6 +901,7 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
// required
name: 'Color Example',
required: false,
default: null,
// optional
description: null,
@@ -861,6 +919,7 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
// required
name: 'Datetime Example',
required: false,
default: null,
// optional
description: null,
@@ -880,7 +939,7 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
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<Manifest extends T.SDKManifest, Store> {
{
// required
name: 'Union Example',
required: false,
default: 'option1',
// optional
description: null,

View File

@@ -13,28 +13,7 @@ export type BackupSync<Volumes extends string> = {
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,

View File

@@ -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")

View File

@@ -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,

View File

@@ -19,7 +19,22 @@ import { CommandController } from "./CommandController"
export const cpExec = promisify(CP.exec)
export const cpExecFile = promisify(CP.execFile)
export type Ready = {
/** A human-readable display name for the health check. If null, the health check itself will be from the UI */
display: string | null
/**
* @description The function to determine the health status of the daemon
*
* The SDK provides some built-in health checks. To see them, type sdk.healthCheck.
*
* @example
* ```
fn: () =>
sdk.healthCheck.checkPortListening(effects, 80, {
successMessage: 'service listening on port 80',
errorMessage: 'service is unreachable',
})
* ```
*/
fn: (
spawnable: ExecSpawnable,
) => Promise<HealthCheckResult> | HealthCheckResult
@@ -32,11 +47,23 @@ type DaemonsParams<
Command extends string,
Id extends string,
> = {
/** The command line command to start the daemon */
command: T.CommandType
image: { id: keyof Manifest["images"] & T.ImageId; sharedRun?: boolean }
/** Information about the image in which the daemon runs */
image: {
/** The ID of the image. Must be one of the image IDs declared in the manifest */
id: keyof Manifest["images"] & T.ImageId
/**
* Whether or not to share the `/run` directory with the parent container.
* This is useful if you are trying to connect to a service that exposes a unix domain socket or auth cookie via the `/run` directory
*/
sharedRun?: boolean
}
/** For mounting the necessary volumes. Syntax: sdk.Mounts.of().addVolume() */
mounts: Mounts<Manifest>
env?: Record<string, string>
ready: Ready
/** An array of IDs of prior daemons whose successful initializations are required before this daemon will initialize */
requires: Exclude<Ids, Id>[]
sigtermTimeout?: number
onStdout?: (chunk: Buffer | string | any) => void

View File

@@ -30,9 +30,13 @@ export class Mounts<Manifest extends T.SDKManifest> {
}
addVolume(
/** The ID of the volume to mount. Must be one of the volume IDs defined in the manifest */
id: Manifest["volumes"][number],
/** The path within the volume to mount. Use `null` to mount the entire volume */
subpath: string | null,
/** Where to mount the volume. e.g. /data */
mountpoint: string,
/** Whether or not the volume should be readonly for this daemon */
readonly: boolean,
) {
this.volumes.push({
@@ -45,8 +49,11 @@ export class Mounts<Manifest extends T.SDKManifest> {
}
addAssets(
/** The ID of the asset directory to mount. This is typically the same as the folder name in your assets directory */
id: Manifest["assets"][number],
/** The path within the asset directory to mount. Use `null` to mount the entire volume */
subpath: string | null,
/** Where to mount the asset. e.g. /asset */
mountpoint: string,
) {
this.assets.push({
@@ -58,10 +65,15 @@ export class Mounts<Manifest extends T.SDKManifest> {
}
addDependency<DependencyManifest extends T.SDKManifest>(
/** The ID of the dependency service */
dependencyId: keyof Manifest["dependencies"] & string,
/** The ID of the volume belonging to the dependency service to mount */
volumeId: DependencyManifest["volumes"][number],
/** The path within the dependency's volume to mount. Use `null` to mount the entire volume */
subpath: string | null,
/** Where to mount the dependency's volume. e.g. /service-id */
mountpoint: string,
/** Whether or not the volume should be readonly for this daemon */
readonly: boolean,
) {
this.dependencies.push({

View File

@@ -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
}

View File

@@ -1,5 +1,4 @@
import { ExposedStorePaths } from "../../../base/lib/types"
import { Affine, _ } from "../util"
import {
PathBuilder,
extractJsonPath,

View File

@@ -6,8 +6,6 @@ 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 () => {

View File

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

View File

@@ -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

View File

@@ -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<A> {
/**
* 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<A>(path, this.writeData, this.readData, this.validate)

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "startos-ui",
"version": "0.3.6-alpha.8",
"version": "0.3.6-alpha.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "startos-ui",
"version": "0.3.6-alpha.8",
"version": "0.3.6-alpha.9",
"license": "MIT",
"dependencies": {
"@angular/animations": "^17.3.1",

View File

@@ -1,6 +1,6 @@
{
"name": "startos-ui",
"version": "0.3.6-alpha.8",
"version": "0.3.6-alpha.9",
"author": "Start9 Labs, Inc",
"homepage": "https://start9.com/",
"license": "MIT",

View File

@@ -21,5 +21,5 @@
"ackInstructions": {},
"theme": "Dark",
"widgets": [],
"ack-welcome": "0.3.6-alpha.8"
"ack-welcome": "0.3.6-alpha.9"
}

View File

@@ -2,7 +2,10 @@ import { inject } from '@angular/core'
import { FormControlComponent } from './form-control/form-control.component'
import { IST } from '@start9labs/start-sdk'
export abstract class Control<Spec extends Exclude<IST.ValueSpec, IST.ValueSpecHidden>, Value> {
export abstract class Control<
Spec extends Exclude<IST.ValueSpec, IST.ValueSpecHidden>,
Value,
> {
private readonly control: FormControlComponent<Spec, Value> =
inject(FormControlComponent)

View File

@@ -37,4 +37,4 @@
Accept
</button>
</div>
</ng-template>
</ng-template>

View File

@@ -12,7 +12,12 @@ export const FORM_CONTROL_PROVIDERS: Provider[] = [
{
provide: TUI_VALIDATION_ERRORS,
deps: [forwardRef(() => FormControlComponent)],
useFactory: (control: FormControlComponent<Exclude<IST.ValueSpec, IST.ValueSpecHidden>, string>) => ({
useFactory: (
control: FormControlComponent<
Exclude<IST.ValueSpec, IST.ValueSpecHidden>,
string
>,
) => ({
required: 'Required',
pattern: ({ requiredPattern }: ValidatorsPatternError) =>
('patterns' in control.spec &&

View File

@@ -10,6 +10,8 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { T, ISB, IST } from '@start9labs/start-sdk'
import { GetPackagesRes } from '@start9labs/marketplace'
import markdown from 'raw-loader!../../../../../shared/assets/markdown/md-sample.md'
const mockMerkleArchiveCommitment: T.MerkleArchiveCommitment = {
rootSighash: 'fakehash',
rootMaxsize: 0,
@@ -811,6 +813,17 @@ export module Mock {
data: null,
read: false,
},
{
id: 5,
packageId: null,
createdAt: '2019-12-26T14:20:30.872Z',
code: 2,
level: 'success',
title: 'Welcome to StartOS 0.3.6!',
message: 'Click "View Details" to learn all about the new version',
data: markdown,
read: false,
},
]
export function getMetrics(): ServerMetrics {

View File

@@ -602,7 +602,9 @@ export type NotificationData<T> = T extends 0
? null
: T extends 1
? BackupReport
: any
: T extends 2
? string
: any
export type BackupReport = {
server: {

View File

@@ -4,7 +4,6 @@ import { Mock } from './api.fixures'
export const mockPatchData: DataModel = {
ui: {
name: `Matt's Server`,
ackWelcome: '1.0.0',
theme: 'Dark',
desktop: ['lnd'],
marketplace: {

View File

@@ -1,14 +1,10 @@
import { Injectable } from '@angular/core'
import { TuiDialogService } from '@taiga-ui/core'
import { filter, share, switchMap, Observable, map } from 'rxjs'
import { Observable } from 'rxjs'
import { filter, map, share, switchMap } from 'rxjs/operators'
import { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { EOSService } from 'src/app/services/eos.service'
import { WelcomeComponent } from 'src/app/components/welcome.component'
import { ConfigService } from 'src/app/services/config.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ConnectionService } from 'src/app/services/connection.service'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { LocalStorageBootstrap } from './patch-db/local-storage-bootstrap'
// Get data from PatchDb after is starts and act upon it
@@ -25,8 +21,6 @@ export class PatchDataService extends Observable<void> {
if (index === 0) {
// check for updates to StartOS and services
this.checkForUpdates()
// show eos welcome message
this.showEosWelcome(cache.ui.ackWelcome)
}
}),
share(),
@@ -35,9 +29,6 @@ export class PatchDataService extends Observable<void> {
constructor(
private readonly patch: PatchDB<DataModel>,
private readonly eosService: EOSService,
private readonly config: ConfigService,
private readonly dialogs: TuiDialogService,
private readonly embassyApi: ApiService,
private readonly connection$: ConnectionService,
private readonly bootstrapper: LocalStorageBootstrap,
) {
@@ -48,22 +39,4 @@ export class PatchDataService extends Observable<void> {
this.eosService.loadEos()
// this.marketplaceService.getMarketplace$().pipe(take(1)).subscribe()
}
private showEosWelcome(ackVersion: string) {
if (this.config.skipStartupAlerts || ackVersion === this.config.version) {
return
}
this.dialogs
.open(new PolymorpheusComponent(WelcomeComponent), {
label: 'Release Notes',
})
.subscribe({
complete: () => {
this.embassyApi
.setDbValue<string>(['ackWelcome'], this.config.version)
.catch()
},
})
}
}

View File

@@ -9,7 +9,6 @@ export type DataModel = {
export type UIData = {
name: string | null
ackWelcome: string // emver
marketplace: UIMarketplaceData
gaming: {
snake: {