Compare commits

..

34 Commits

Author SHA1 Message Date
Aiden McClelland
a81c01b232 fix grub config 2026-01-27 15:27:49 -07:00
Aiden McClelland
c96a5b7754 fix install over 0.3.5.1 2026-01-26 14:16:11 -07:00
Matt Hill
b39760d9d7 add i18n helper to sdk 2026-01-22 16:07:18 -07:00
Aiden McClelland
2f4bb1e35e fix device migration 2026-01-22 10:27:49 -07:00
Aiden McClelland
0534b5813b ignore missing package archive on 035 migration 2026-01-21 15:58:46 -07:00
Aiden McClelland
3333416331 omit live medium from disk list and better space management 2026-01-21 13:33:52 -07:00
Aiden McClelland
50540e4847 version bump 2026-01-21 13:02:46 -07:00
Aiden McClelland
35545056e7 (mostly) redundant localization on frontend 2026-01-21 12:46:32 -07:00
Aiden McClelland
3828b03790 working setup flow + manifest localization 2026-01-20 18:28:28 -07:00
Matt Hill
6a1c1fde06 revert mock 2026-01-20 17:09:01 -07:00
Matt Hill
2e5cd4b8ca ability to shutdown after install 2026-01-20 17:08:40 -07:00
Matt Hill
99727e132c keyboard keymap also 2026-01-20 14:24:45 -07:00
Matt Hill
0a0f0850d7 fix dns selection 2026-01-19 17:23:29 -07:00
Alex Inkin
65fc3e5c52 feat: add "Add new gateway" option (#3098)
* feat: add "Add new gateway" option

* Update web/projects/ui/src/app/routes/portal/components/form/controls/select.component.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* add translation

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Matt Hill <mattnine@protonmail.com>
2026-01-17 21:37:30 -07:00
Aiden McClelland
0d4ddc3451 help text for args 2026-01-16 19:09:41 -07:00
Aiden McClelland
4ee72d560a fix missing about text 2026-01-16 17:13:33 -07:00
Aiden McClelland
9d364b0691 Merge branch 'feature/consolidate-setup' of github.com:Start9Labs/start-os into feature/consolidate-setup 2026-01-16 17:03:36 -07:00
Aiden McClelland
d786424353 translate backend strings 2026-01-16 17:03:34 -07:00
Matt Hill
5ecb230bcc revert mock 2026-01-16 16:25:36 -07:00
Matt Hill
fee03ef407 switch to posix strings for language internal 2026-01-16 16:25:08 -07:00
Matt Hill
8ca3d56aa9 remove start-tunnel readme 2026-01-16 15:41:28 -07:00
Aiden McClelland
763c7d9f87 wip: localization 2026-01-16 11:49:06 -07:00
Matt Hill
ea86117e5f fix typo 2026-01-16 01:36:16 -07:00
Matt Hill
708b273b42 finish setup wizard and ui language-keyboard feature 2026-01-15 23:49:24 -07:00
Matt Hill
db344386ef only warn on update if breakages (#3097) 2026-01-15 13:33:42 -07:00
Matt Hill
d3048c59e8 better ST messaging on setup 2026-01-15 13:14:49 -07:00
Matt Hill
5e5aa5d830 use dialogservice wrapper 2026-01-15 13:14:49 -07:00
Matt Hill
880aa8040d translations 2026-01-15 13:14:49 -07:00
Matt Hill
93fda28393 fix translation 2026-01-15 13:13:33 -07:00
Matt Hill
3fba55a54d undo mock 2026-01-15 13:03:53 -07:00
Matt Hill
075ed97c96 use http 2026-01-15 13:02:21 -07:00
Matt Hill
42ef2bdf7e combine install and setup and refactor all 2026-01-15 13:02:21 -07:00
Aiden McClelland
645083913c add start-cli flash-os 2026-01-15 13:02:21 -07:00
Aiden McClelland
02bce4ed61 start consolidating 2026-01-15 13:02:21 -07:00
371 changed files with 6333 additions and 8773 deletions

View File

@@ -1,5 +0,0 @@
{
"attribution": {
"commit": ""
}
}

View File

@@ -1,81 +0,0 @@
name: Setup Build Environment
description: Common build environment setup steps
inputs:
nodejs-version:
description: Node.js version
required: true
setup-python:
description: Set up Python
required: false
default: "false"
setup-docker:
description: Set up Docker QEMU and Buildx
required: false
default: "true"
setup-sccache:
description: Configure sccache for GitHub Actions
required: false
default: "true"
free-space:
description: Remove unnecessary packages to free disk space
required: false
default: "true"
runs:
using: composite
steps:
- name: Free disk space
if: inputs.free-space == 'true'
shell: bash
run: |
sudo apt-get remove --purge -y azure-cli || true
sudo apt-get remove --purge -y firefox || true
sudo apt-get remove --purge -y ghc-* || true
sudo apt-get remove --purge -y google-cloud-sdk || true
sudo apt-get remove --purge -y google-chrome-stable || true
sudo apt-get remove --purge -y powershell || true
sudo apt-get remove --purge -y php* || true
sudo apt-get remove --purge -y ruby* || true
sudo apt-get remove --purge -y mono-* || true
sudo apt-get autoremove -y
sudo apt-get clean
sudo rm -rf /usr/lib/jvm
sudo rm -rf /usr/local/.ghcup
sudo rm -rf /usr/local/lib/android
sudo rm -rf /usr/share/dotnet
sudo rm -rf /usr/share/swift
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
# BuildJet runners lack /opt/hostedtoolcache, which setup-python and setup-qemu expect
- name: Ensure hostedtoolcache exists
shell: bash
run: sudo mkdir -p /opt/hostedtoolcache && sudo chown $USER:$USER /opt/hostedtoolcache
- name: Set up Python
if: inputs.setup-python == 'true'
uses: actions/setup-python@v5
with:
python-version: "3.x"
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.nodejs-version }}
cache: npm
cache-dependency-path: "**/package-lock.json"
- name: Set up Docker QEMU
if: inputs.setup-docker == 'true'
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
if: inputs.setup-docker == 'true'
uses: docker/setup-buildx-action@v3
- name: Configure sccache
if: inputs.setup-sccache == 'true'
uses: actions/github-script@v7
with:
script: |
core.exportVariable('ACTIONS_RESULTS_URL', process.env.ACTIONS_RESULTS_URL || '');
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');

View File

@@ -37,10 +37,6 @@ on:
- master
- next/*
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
env:
NODEJS_VERSION: "24.11.0"
ENVIRONMENT: '${{ fromJson(format(''["{0}", ""]'', github.event.inputs.environment || ''dev''))[github.event.inputs.environment == ''NONE''] }}'
@@ -48,7 +44,6 @@ env:
jobs:
compile:
name: Build Debian Package
if: github.event.pull_request.draft != true
strategy:
fail-fast: true
matrix:
@@ -65,15 +60,50 @@ jobs:
}}
runs-on: ${{ fromJson('["ubuntu-latest", "buildjet-32vcpu-ubuntu-2204"]')[github.event.inputs.runner == 'fast'] }}
steps:
- name: Mount tmpfs
- name: Cleaning up unnecessary files
run: |
sudo apt-get remove --purge -y mono-* \
ghc* cabal-install* \
dotnet* \
php* \
ruby* \
mysql-* \
postgresql-* \
azure-cli \
powershell \
google-cloud-sdk \
msodbcsql* mssql-tools* \
imagemagick* \
libgl1-mesa-dri \
google-chrome-stable \
firefox
sudo apt-get autoremove -y
sudo apt-get clean
- run: |
sudo mount -t tmpfs tmpfs .
if: ${{ github.event.inputs.runner == 'fast' }}
run: sudo mount -t tmpfs tmpfs .
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: ./.github/actions/setup-build
- uses: actions/setup-node@v4
with:
nodejs-version: ${{ env.NODEJS_VERSION }}
node-version: ${{ env.NODEJS_VERSION }}
- name: Set up docker QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Configure sccache
uses: actions/github-script@v7
with:
script: |
core.exportVariable('ACTIONS_RESULTS_URL', process.env.ACTIONS_RESULTS_URL || '');
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
- name: Make
run: TARGET=${{ matrix.triple }} make cli

View File

@@ -1,4 +1,4 @@
name: start-registry
name: Start-Registry
on:
workflow_call:
@@ -35,10 +35,6 @@ on:
- master
- next/*
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
env:
NODEJS_VERSION: "24.11.0"
ENVIRONMENT: '${{ fromJson(format(''["{0}", ""]'', github.event.inputs.environment || ''dev''))[github.event.inputs.environment == ''NONE''] }}'
@@ -46,7 +42,6 @@ env:
jobs:
compile:
name: Build Debian Package
if: github.event.pull_request.draft != true
strategy:
fail-fast: true
matrix:
@@ -61,15 +56,50 @@ jobs:
}}
runs-on: ${{ fromJson('["ubuntu-latest", "buildjet-32vcpu-ubuntu-2204"]')[github.event.inputs.runner == 'fast'] }}
steps:
- name: Mount tmpfs
- name: Cleaning up unnecessary files
run: |
sudo apt-get remove --purge -y mono-* \
ghc* cabal-install* \
dotnet* \
php* \
ruby* \
mysql-* \
postgresql-* \
azure-cli \
powershell \
google-cloud-sdk \
msodbcsql* mssql-tools* \
imagemagick* \
libgl1-mesa-dri \
google-chrome-stable \
firefox
sudo apt-get autoremove -y
sudo apt-get clean
- run: |
sudo mount -t tmpfs tmpfs .
if: ${{ github.event.inputs.runner == 'fast' }}
run: sudo mount -t tmpfs tmpfs .
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: ./.github/actions/setup-build
- uses: actions/setup-node@v4
with:
nodejs-version: ${{ env.NODEJS_VERSION }}
node-version: ${{ env.NODEJS_VERSION }}
- name: Set up docker QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Configure sccache
uses: actions/github-script@v7
with:
script: |
core.exportVariable('ACTIONS_RESULTS_URL', process.env.ACTIONS_RESULTS_URL || '');
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
- name: Make
run: make registry-deb

View File

@@ -1,4 +1,4 @@
name: start-tunnel
name: Start-Tunnel
on:
workflow_call:
@@ -35,10 +35,6 @@ on:
- master
- next/*
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
env:
NODEJS_VERSION: "24.11.0"
ENVIRONMENT: '${{ fromJson(format(''["{0}", ""]'', github.event.inputs.environment || ''dev''))[github.event.inputs.environment == ''NONE''] }}'
@@ -46,7 +42,6 @@ env:
jobs:
compile:
name: Build Debian Package
if: github.event.pull_request.draft != true
strategy:
fail-fast: true
matrix:
@@ -61,15 +56,50 @@ jobs:
}}
runs-on: ${{ fromJson('["ubuntu-latest", "buildjet-32vcpu-ubuntu-2204"]')[github.event.inputs.runner == 'fast'] }}
steps:
- name: Mount tmpfs
- name: Cleaning up unnecessary files
run: |
sudo apt-get remove --purge -y mono-* \
ghc* cabal-install* \
dotnet* \
php* \
ruby* \
mysql-* \
postgresql-* \
azure-cli \
powershell \
google-cloud-sdk \
msodbcsql* mssql-tools* \
imagemagick* \
libgl1-mesa-dri \
google-chrome-stable \
firefox
sudo apt-get autoremove -y
sudo apt-get clean
- run: |
sudo mount -t tmpfs tmpfs .
if: ${{ github.event.inputs.runner == 'fast' }}
run: sudo mount -t tmpfs tmpfs .
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: ./.github/actions/setup-build
- uses: actions/setup-node@v4
with:
nodejs-version: ${{ env.NODEJS_VERSION }}
node-version: ${{ env.NODEJS_VERSION }}
- name: Set up docker QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Configure sccache
uses: actions/github-script@v7
with:
script: |
core.exportVariable('ACTIONS_RESULTS_URL', process.env.ACTIONS_RESULTS_URL || '');
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
- name: Make
run: make tunnel-deb

View File

@@ -27,7 +27,7 @@ on:
- x86_64-nonfree
- aarch64
- aarch64-nonfree
# - raspberrypi
- raspberrypi
- riscv64
deploy:
type: choice
@@ -45,10 +45,6 @@ on:
- master
- next/*
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
env:
NODEJS_VERSION: "24.11.0"
ENVIRONMENT: '${{ fromJson(format(''["{0}", ""]'', github.event.inputs.environment || ''dev''))[github.event.inputs.environment == ''NONE''] }}'
@@ -56,7 +52,6 @@ env:
jobs:
compile:
name: Compile Base Binaries
if: github.event.pull_request.draft != true
strategy:
fail-fast: true
matrix:
@@ -91,16 +86,54 @@ jobs:
)[github.event.inputs.runner == 'fast']
}}
steps:
- name: Mount tmpfs
- name: Cleaning up unnecessary files
run: |
sudo apt-get remove --purge -y azure-cli || true
sudo apt-get remove --purge -y firefox || true
sudo apt-get remove --purge -y ghc-* || true
sudo apt-get remove --purge -y google-cloud-sdk || true
sudo apt-get remove --purge -y google-chrome-stable || true
sudo apt-get remove --purge -y powershell || true
sudo apt-get remove --purge -y php* || true
sudo apt-get remove --purge -y ruby* || true
sudo apt-get remove --purge -y mono-* || true
sudo apt-get autoremove -y
sudo apt-get clean
sudo rm -rf /usr/lib/jvm # All JDKs
sudo rm -rf /usr/local/.ghcup # Haskell toolchain
sudo rm -rf /usr/local/lib/android # Android SDK/NDK, emulator
sudo rm -rf /usr/share/dotnet # .NET SDKs
sudo rm -rf /usr/share/swift # Swift toolchain (if present)
sudo rm -rf "$AGENT_TOOLSDIRECTORY" # Pre-cached tool cache (Go, Node, etc.)
- run: |
sudo mount -t tmpfs tmpfs .
if: ${{ github.event.inputs.runner == 'fast' }}
run: sudo mount -t tmpfs tmpfs .
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: ./.github/actions/setup-build
- name: Set up Python
uses: actions/setup-python@v5
with:
nodejs-version: ${{ env.NODEJS_VERSION }}
setup-python: "true"
python-version: "3.x"
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODEJS_VERSION }}
- name: Set up docker QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Configure sccache
uses: actions/github-script@v7
with:
script: |
core.exportVariable('ACTIONS_RESULTS_URL', process.env.ACTIONS_RESULTS_URL || '');
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
- name: Make
run: make ARCH=${{ matrix.arch }} compiled-${{ matrix.arch }}.tar
@@ -118,14 +151,13 @@ jobs:
strategy:
fail-fast: false
matrix:
# TODO: re-add "raspberrypi" to the platform list below
platform: >-
${{
fromJson(
format(
'[
["{0}"],
["x86_64", "x86_64-nonfree", "aarch64", "aarch64-nonfree", "riscv64"]
["x86_64", "x86_64-nonfree", "aarch64", "aarch64-nonfree", "riscv64", "raspberrypi"]
]',
github.event.inputs.platform || 'ALL'
)
@@ -189,10 +221,6 @@ jobs:
sudo rm -rf "$AGENT_TOOLSDIRECTORY" # Pre-cached tool cache (Go, Node, etc.)
if: ${{ github.event.inputs.runner != 'fast' }}
# BuildJet runners lack /opt/hostedtoolcache, which setup-qemu expects
- name: Ensure hostedtoolcache exists
run: sudo mkdir -p /opt/hostedtoolcache && sudo chown $USER:$USER /opt/hostedtoolcache
- name: Set up docker QEMU
uses: docker/setup-qemu-action@v3

View File

@@ -10,10 +10,6 @@ on:
- master
- next/*
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
env:
NODEJS_VERSION: "24.11.0"
ENVIRONMENT: dev-unstable
@@ -21,18 +17,15 @@ env:
jobs:
test:
name: Run Automated Tests
if: github.event.pull_request.draft != true
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: ./.github/actions/setup-build
- uses: actions/setup-node@v4
with:
nodejs-version: ${{ env.NODEJS_VERSION }}
free-space: "false"
setup-docker: "false"
setup-sccache: "false"
node-version: ${{ env.NODEJS_VERSION }}
- name: Build And Run Tests
run: make test

4
.gitignore vendored
View File

@@ -19,6 +19,4 @@ secrets.db
/compiled.tar
/compiled-*.tar
/build/lib/firmware
tmp
web/.i18n-checked
agents/USER.md
tmp

146
CLAUDE.md
View File

@@ -1,146 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
StartOS is an open-source Linux distribution for running personal servers. It manages discovery, installation, network configuration, backups, and health monitoring of self-hosted services.
**Tech Stack:**
- Backend: Rust (async/Tokio, Axum web framework)
- Frontend: Angular 20 + TypeScript + TaigaUI
- Container runtime: Node.js/TypeScript with LXC
- Database/State: Patch-DB (git submodule) - storage layer with reactive frontend sync
- API: JSON-RPC via rpc-toolkit (see `agents/rpc-toolkit.md`)
- Auth: Password + session cookie, public/private key signatures, local authcookie (see `core/src/middleware/auth/`)
## Build & Development
See [CONTRIBUTING.md](CONTRIBUTING.md) for:
- Environment setup and requirements
- Build commands and make targets
- Testing and formatting commands
- Environment variables
**Quick reference:**
```bash
. ./devmode.sh # Enable dev mode
make update-startbox REMOTE=start9@<ip> # Fastest iteration (binary + UI)
make test-core # Run Rust tests
```
## Architecture
### Core (`/core`)
The Rust backend daemon. Main binaries:
- `startbox` - Main daemon (runs as `startd`)
- `start-cli` - CLI interface
- `start-container` - Runs inside LXC containers; communicates with host and manages subcontainers
- `registrybox` - Registry daemon
- `tunnelbox` - VPN/tunnel daemon
**Key modules:**
- `src/context/` - Context types (RpcContext, CliContext, InitContext, DiagnosticContext)
- `src/service/` - Service lifecycle management with actor pattern (`service_actor.rs`)
- `src/db/model/` - Patch-DB models (`public.rs` synced to frontend, `private.rs` backend-only)
- `src/net/` - Networking (DNS, ACME, WiFi, Tor via Arti, WireGuard)
- `src/s9pk/` - S9PK package format (merkle archive)
- `src/registry/` - Package registry management
**RPC Pattern:** See `agents/rpc-toolkit.md`
### Web (`/web`)
Angular projects sharing common code:
- `projects/ui/` - Main admin interface
- `projects/setup-wizard/` - Initial setup
- `projects/start-tunnel/` - VPN management UI
- `projects/shared/` - Common library (API clients, components)
- `projects/marketplace/` - Service discovery
**Development:**
```bash
cd web
npm ci
npm run start:ui # Dev server with mocks
npm run build:ui # Production build
npm run check # Type check all projects
```
### Container Runtime (`/container-runtime`)
Node.js runtime that manages service containers via RPC. See `RPCSpec.md` for protocol.
**Container Architecture:**
```
LXC Container (uniform base for all services)
└── systemd
└── container-runtime.service
└── Loads /usr/lib/startos/package/index.js (from s9pk javascript.squashfs)
└── Package JS launches subcontainers (from images in s9pk)
```
The container runtime communicates with the host via JSON-RPC over Unix socket. Package JavaScript must export functions conforming to the `ABI` type defined in `sdk/base/lib/types.ts`.
**`/media/startos/` directory (mounted by host into container):**
| Path | Description |
|------|-------------|
| `volumes/<name>/` | Package data volumes (id-mapped, persistent) |
| `assets/` | Read-only assets from s9pk `assets.squashfs` |
| `images/<name>/` | Container images (squashfs, used for subcontainers) |
| `images/<name>.env` | Environment variables for image |
| `images/<name>.json` | Image metadata |
| `backup/` | Backup mount point (mounted during backup operations) |
| `rpc/service.sock` | RPC socket (container runtime listens here) |
| `rpc/host.sock` | Host RPC socket (for effects callbacks to host) |
**S9PK Structure:** See `agents/s9pk-structure.md`
### SDK (`/sdk`)
TypeScript SDK for packaging services (`@start9labs/start-sdk`).
- `base/` - Core types, ABI definitions, effects interface (`@start9labs/start-sdk-base`)
- `package/` - Full SDK for package developers, re-exports base
### Patch-DB (`/patch-db`)
Git submodule providing diff-based state synchronization. Changes to `db/model/public.rs` automatically sync to the frontend.
**Key patterns:**
- `db.peek().await` - Get a read-only snapshot of the database state
- `db.mutate(|db| { ... }).await` - Apply mutations atomically, returns `MutateResult`
- `#[derive(HasModel)]` - Derive macro for types stored in the database, generates typed accessors
**Generated accessor types** (from `HasModel` derive):
- `as_field()` - Immutable reference: `&Model<T>`
- `as_field_mut()` - Mutable reference: `&mut Model<T>`
- `into_field()` - Owned value: `Model<T>`
**`Model<T>` APIs** (from `db/prelude.rs`):
- `.de()` - Deserialize to `T`
- `.ser(&value)` - Serialize from `T`
- `.mutate(|v| ...)` - Deserialize, mutate, reserialize
- For maps: `.keys()`, `.as_idx(&key)`, `.as_idx_mut(&key)`, `.insert()`, `.remove()`, `.contains_key()`
## Supplementary Documentation
The `agents/` directory contains detailed documentation for AI assistants:
- `TODO.md` - Pending tasks for AI agents (check this first, remove items when completed)
- `USER.md` - Current user identifier (gitignored, see below)
- `rpc-toolkit.md` - JSON-RPC patterns and handler configuration
- `core-rust-patterns.md` - Common utilities and patterns for Rust code in `/core` (guard pattern, mount guards, etc.)
- `s9pk-structure.md` - S9PK package format structure
- `i18n-patterns.md` - Internationalization key conventions and usage in `/core`
### Session Startup
On startup:
1. **Check for `agents/USER.md`** - If it doesn't exist, prompt the user for their name/identifier and create it. This file is gitignored since it varies per developer.
2. **Check `agents/TODO.md` for relevant tasks** - Show TODOs that either:
- Have no `@username` tag (relevant to everyone)
- Are tagged with the current user's identifier
Skip TODOs tagged with a different user.
3. **Ask "What would you like to do today?"** - Offer options for each relevant TODO item, plus "Something else" for other requests.

View File

@@ -11,190 +11,123 @@ This guide is for contributing to the StartOS. If you are interested in packagin
```bash
/
├── assets/ # Screenshots for README
├── build/ # Auxiliary files and scripts for deployed images
├── container-runtime/ # Node.js program managing package containers
├── core/ # Rust backend: API, daemon (startd), CLI (start-cli)
├── debian/ # Debian package maintainer scripts
├── image-recipe/ # Scripts for building StartOS images
├── patch-db/ # (submodule) Diff-based data store for frontend sync
├── sdk/ # TypeScript SDK for building StartOS packages
└── web/ # Web UIs (Angular)
├── assets/
├── container-runtime/
├── core/
├── build/
├── debian/
├── web/
├── image-recipe/
├── patch-db
└── sdk/
```
See component READMEs for details:
- [`core`](core/README.md)
- [`web`](web/README.md)
- [`build`](build/README.md)
- [`patch-db`](https://github.com/Start9Labs/patch-db)
#### assets
screenshots for the StartOS README
#### container-runtime
A NodeJS program that dynamically loads maintainer scripts and communicates with the OS to manage packages
#### core
An API, daemon (startd), and CLI (start-cli) that together provide the core functionality of StartOS.
#### build
Auxiliary files and scripts to include in deployed StartOS images
#### debian
Maintainer scripts for the StartOS Debian package
#### web
Web UIs served under various conditions and used to interact with StartOS APIs.
#### image-recipe
Scripts for building StartOS images
#### patch-db (submodule)
A diff based data store used to synchronize data between the web interfaces and server.
#### sdk
A typescript sdk for building start-os packages
## Environment Setup
#### Clone the StartOS repository
```sh
git clone https://github.com/Start9Labs/start-os.git --recurse-submodules
cd start-os
```
### Development Mode
#### Continue to your project of interest for additional instructions:
For faster iteration during development:
```sh
. ./devmode.sh
```
This sets `ENVIRONMENT=dev` and `GIT_BRANCH_AS_HASH=1` to prevent rebuilds on every commit.
- [`core`](core/README.md)
- [`web-interfaces`](web-interfaces/README.md)
- [`build`](build/README.md)
- [`patch-db`](https://github.com/Start9Labs/patch-db)
## Building
All builds can be performed on any operating system that can run Docker.
This project uses [GNU Make](https://www.gnu.org/software/make/) to build its components.
This project uses [GNU Make](https://www.gnu.org/software/make/) to build its components. To build any specific component, simply run `make <TARGET>` replacing `<TARGET>` with the name of the target you'd like to build
### Requirements
- [GNU Make](https://www.gnu.org/software/make/)
- [Docker](https://docs.docker.com/get-docker/) or [Podman](https://podman.io/)
- [Docker](https://docs.docker.com/get-docker/)
- [NodeJS v20.16.0](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm)
- [Rust](https://rustup.rs/) (nightly for formatting)
- [sed](https://www.gnu.org/software/sed/), [grep](https://www.gnu.org/software/grep/), [awk](https://www.gnu.org/software/gawk/)
- [sed](https://www.gnu.org/software/sed/)
- [grep](https://www.gnu.org/software/grep/)
- [awk](https://www.gnu.org/software/gawk/)
- [jq](https://jqlang.github.io/jq/)
- [gzip](https://www.gnu.org/software/gzip/), [brotli](https://github.com/google/brotli)
- [gzip](https://www.gnu.org/software/gzip/)
- [brotli](https://github.com/google/brotli)
### Environment Variables
### Environment variables
| Variable | Description |
|----------|-------------|
| `PLATFORM` | Target platform: `x86_64`, `x86_64-nonfree`, `aarch64`, `aarch64-nonfree`, `riscv64`, `raspberrypi` |
| `ENVIRONMENT` | Hyphen-separated feature flags (see below) |
| `PROFILE` | Build profile: `release` (default) or `dev` |
| `GIT_BRANCH_AS_HASH` | Set to `1` to use git branch name as version hash (avoids rebuilds) |
- `PLATFORM`: which platform you would like to build for. Must be one of `x86_64`, `x86_64-nonfree`, `aarch64`, `aarch64-nonfree`, `raspberrypi`
- NOTE: `nonfree` images are for including `nonfree` firmware packages in the built ISO
- `ENVIRONMENT`: a hyphen separated set of feature flags to enable
- `dev`: enables password ssh (INSECURE!) and does not compress frontends
- `unstable`: enables assertions that will cause errors on unexpected inconsistencies that are undesirable in production use either for performance or reliability reasons
- `docker`: use `docker` instead of `podman`
- `GIT_BRANCH_AS_HASH`: set to `1` to use the current git branch name as the git hash so that the project does not need to be rebuilt on each commit
**ENVIRONMENT flags:**
- `dev` - Enables password SSH before setup, skips frontend compression
- `unstable` - Enables assertions and debugging with performance penalty
- `console` - Enables tokio-console for async debugging
**Platform notes:**
- `-nonfree` variants include proprietary firmware and drivers
- `raspberrypi` includes non-free components by necessity
- Platform is remembered between builds if not specified
### Make Targets
#### Building
| Target | Description |
|--------|-------------|
| `iso` | Create full `.iso` image (not for raspberrypi) |
| `img` | Create full `.img` image (raspberrypi only) |
| `deb` | Build Debian package |
| `all` | Build all Rust binaries |
| `uis` | Build all web UIs |
| `ui` | Build main UI only |
| `ts-bindings` | Generate TypeScript bindings from Rust types |
#### Deploying to Device
For devices on the same network:
| Target | Description |
|--------|-------------|
| `update-startbox REMOTE=start9@<ip>` | Deploy binary + UI only (fastest) |
| `update-deb REMOTE=start9@<ip>` | Deploy full Debian package |
| `update REMOTE=start9@<ip>` | OTA-style update |
| `reflash REMOTE=start9@<ip>` | Reflash as if using live ISO |
| `update-overlay REMOTE=start9@<ip>` | Deploy to in-memory overlay (reverts on reboot) |
For devices on different networks (uses [magic-wormhole](https://github.com/magic-wormhole/magic-wormhole)):
| Target | Description |
|--------|-------------|
| `wormhole` | Send startbox binary |
| `wormhole-deb` | Send Debian package |
| `wormhole-squashfs` | Send squashfs image |
#### Other
| Target | Description |
|--------|-------------|
| `format` | Run code formatting (Rust nightly required) |
| `test` | Run all automated tests |
| `test-core` | Run Rust tests |
| `test-sdk` | Run SDK tests |
| `test-container-runtime` | Run container runtime tests |
| `clean` | Delete all compiled artifacts |
## Testing
```bash
make test # All tests
make test-core # Rust tests (via ./core/run-tests.sh)
make test-sdk # SDK tests
make test-container-runtime # Container runtime tests
# Run specific Rust test
cd core && cargo test <test_name> --features=test
```
## Code Formatting
```bash
# Rust (requires nightly)
make format
# TypeScript/HTML/SCSS (web)
cd web && npm run format
```
## Code Style Guidelines
### Formatting
Run the formatters before committing. Configuration is handled by `rustfmt.toml` (Rust) and prettier configs (TypeScript).
### Documentation & Comments
**Rust:**
- Add doc comments (`///`) to public APIs, structs, and non-obvious functions
- Use `//` comments sparingly for complex logic that isn't self-evident
- Prefer self-documenting code (clear naming, small functions) over comments
**TypeScript:**
- Document exported functions and complex types with JSDoc
- Keep comments focused on "why" rather than "what"
**General:**
- Don't add comments that just restate the code
- Update or remove comments when code changes
- TODOs should include context: `// TODO(username): reason`
### Commit Messages
Use [Conventional Commits](https://www.conventionalcommits.org/):
```
<type>(<scope>): <description>
[optional body]
[optional footer]
```
**Types:**
- `feat` - New feature
- `fix` - Bug fix
- `docs` - Documentation only
- `style` - Formatting, no code change
- `refactor` - Code change that neither fixes a bug nor adds a feature
- `test` - Adding or updating tests
- `chore` - Build process, dependencies, etc.
**Examples:**
```
feat(web): add dark mode toggle
fix(core): resolve race condition in service startup
docs: update CONTRIBUTING.md with style guidelines
refactor(sdk): simplify package validation logic
```
### Useful Make Targets
- `iso`: Create a full `.iso` image
- Only possible from Debian
- Not available for `PLATFORM=raspberrypi`
- Additional Requirements:
- [debspawn](https://github.com/lkhq/debspawn)
- `img`: Create a full `.img` image
- Only possible from Debian
- Only available for `PLATFORM=raspberrypi`
- Additional Requirements:
- [debspawn](https://github.com/lkhq/debspawn)
- `format`: Run automatic code formatting for the project
- Additional Requirements:
- [rust](https://rustup.rs/)
- `test`: Run automated tests for the project
- Additional Requirements:
- [rust](https://rustup.rs/)
- `update`: Deploy the current working project to a device over ssh as if through an over-the-air update
- Requires an argument `REMOTE` which is the ssh address of the device, i.e. `start9@192.168.122.2`
- `reflash`: Deploy the current working project to a device over ssh as if using a live `iso` image to reflash it
- Requires an argument `REMOTE` which is the ssh address of the device, i.e. `start9@192.168.122.2`
- `update-overlay`: Deploy the current working project to a device over ssh to the in-memory overlay without restarting it
- WARNING: changes will be reverted after the device is rebooted
- WARNING: changes to `init` will not take effect as the device is already initialized
- Requires an argument `REMOTE` which is the ssh address of the device, i.e. `start9@192.168.122.2`
- `wormhole`: Deploy the `startbox` to a device using [magic-wormhole](https://github.com/magic-wormhole/magic-wormhole)
- When the build it complete will emit a command to paste into the shell of the device to upgrade it
- Additional Requirements:
- [magic-wormhole](https://github.com/magic-wormhole/magic-wormhole)
- `clean`: Delete all compiled artifacts

View File

@@ -324,19 +324,15 @@ web/.angular/.updated: patch-db/client/dist/index.js sdk/baseDist/package.json w
mkdir -p web/.angular
touch web/.angular/.updated
web/.i18n-checked: $(WEB_SHARED_SRC) $(WEB_UI_SRC) $(WEB_SETUP_WIZARD_SRC) $(WEB_START_TUNNEL_SRC)
npm --prefix web run check:i18n
touch web/.i18n-checked
web/dist/raw/ui/index.html: $(WEB_UI_SRC) $(WEB_SHARED_SRC) web/.angular/.updated web/.i18n-checked
web/dist/raw/ui/index.html: $(WEB_UI_SRC) $(WEB_SHARED_SRC) web/.angular/.updated
npm --prefix web run build:ui
touch web/dist/raw/ui/index.html
web/dist/raw/setup-wizard/index.html: $(WEB_SETUP_WIZARD_SRC) $(WEB_SHARED_SRC) web/.angular/.updated web/.i18n-checked
web/dist/raw/setup-wizard/index.html: $(WEB_SETUP_WIZARD_SRC) $(WEB_SHARED_SRC) web/.angular/.updated
npm --prefix web run build:setup
touch web/dist/raw/setup-wizard/index.html
web/dist/raw/start-tunnel/index.html: $(WEB_START_TUNNEL_SRC) $(WEB_SHARED_SRC) web/.angular/.updated web/.i18n-checked
web/dist/raw/start-tunnel/index.html: $(WEB_START_TUNNEL_SRC) $(WEB_SHARED_SRC) web/.angular/.updated
npm --prefix web run build:tunnel
touch web/dist/raw/start-tunnel/index.html

View File

@@ -1,9 +0,0 @@
# AI Agent TODOs
Pending tasks for AI agents. Remove items when completed.
## Unreviewed CLAUDE.md Sections
- [ ] Architecture - Web (`/web`) - @MattDHill

View File

@@ -1,249 +0,0 @@
# Utilities & Patterns
This document covers common utilities and patterns used throughout the StartOS codebase.
## Util Module (`core/src/util/`)
The `util` module contains reusable utilities. Key submodules:
| Module | Purpose |
|--------|---------|
| `actor/` | Actor pattern implementation for concurrent state management |
| `collections/` | Custom collection types |
| `crypto.rs` | Cryptographic utilities (encryption, hashing) |
| `future.rs` | Future/async utilities |
| `io.rs` | File I/O helpers (create_file, canonicalize, etc.) |
| `iter.rs` | Iterator extensions |
| `net.rs` | Network utilities |
| `rpc.rs` | RPC helpers |
| `rpc_client.rs` | RPC client utilities |
| `serde.rs` | Serialization helpers (Base64, display/fromstr, etc.) |
| `sync.rs` | Synchronization primitives (SyncMutex, etc.) |
## Command Invocation (`Invoke` trait)
The `Invoke` trait provides a clean way to run external commands with error handling:
```rust
use crate::util::Invoke;
// Simple invocation
tokio::process::Command::new("ls")
.arg("-la")
.invoke(ErrorKind::Filesystem)
.await?;
// With timeout
tokio::process::Command::new("slow-command")
.timeout(Some(Duration::from_secs(30)))
.invoke(ErrorKind::Timeout)
.await?;
// With input
let mut input = Cursor::new(b"input data");
tokio::process::Command::new("cat")
.input(Some(&mut input))
.invoke(ErrorKind::Filesystem)
.await?;
// Piped commands
tokio::process::Command::new("cat")
.arg("file.txt")
.pipe(&mut tokio::process::Command::new("grep").arg("pattern"))
.invoke(ErrorKind::Filesystem)
.await?;
```
## Guard Pattern
Guards ensure cleanup happens when they go out of scope.
### `GeneralGuard` / `GeneralBoxedGuard`
For arbitrary cleanup actions:
```rust
use crate::util::GeneralGuard;
let guard = GeneralGuard::new(|| {
println!("Cleanup runs on drop");
});
// Do work...
// Explicit drop with action
guard.drop();
// Or skip the action
// guard.drop_without_action();
```
### `FileLock`
File-based locking with automatic unlock:
```rust
use crate::util::FileLock;
let lock = FileLock::new("/path/to/lockfile", true).await?; // blocking=true
// Lock held until dropped or explicitly unlocked
lock.unlock().await?;
```
## Mount Guard Pattern (`core/src/disk/mount/guard.rs`)
RAII guards for filesystem mounts. Ensures filesystems are unmounted when guards are dropped.
### `MountGuard`
Basic mount guard:
```rust
use crate::disk::mount::guard::MountGuard;
use crate::disk::mount::filesystem::{MountType, ReadOnly};
let guard = MountGuard::mount(&filesystem, "/mnt/target", ReadOnly).await?;
// Use the mounted filesystem at guard.path()
do_something(guard.path()).await?;
// Explicit unmount (or auto-unmounts on drop)
guard.unmount(false).await?; // false = don't delete mountpoint
```
### `TmpMountGuard`
Reference-counted temporary mount (mounts to `/media/startos/tmp/`):
```rust
use crate::disk::mount::guard::TmpMountGuard;
use crate::disk::mount::filesystem::ReadOnly;
// Multiple clones share the same mount
let guard1 = TmpMountGuard::mount(&filesystem, ReadOnly).await?;
let guard2 = guard1.clone();
// Mount stays alive while any guard exists
// Auto-unmounts when last guard is dropped
```
### `GenericMountGuard` trait
All mount guards implement this trait:
```rust
pub trait GenericMountGuard: std::fmt::Debug + Send + Sync + 'static {
fn path(&self) -> &Path;
fn unmount(self) -> impl Future<Output = Result<(), Error>> + Send;
}
```
### `SubPath`
Wraps a mount guard to point to a subdirectory:
```rust
use crate::disk::mount::guard::SubPath;
let mount = TmpMountGuard::mount(&filesystem, ReadOnly).await?;
let subdir = SubPath::new(mount, "data/subdir");
// subdir.path() returns the full path including subdirectory
```
## FileSystem Implementations (`core/src/disk/mount/filesystem/`)
Various filesystem types that can be mounted:
| Type | Description |
|------|-------------|
| `bind.rs` | Bind mounts |
| `block_dev.rs` | Block device mounts |
| `cifs.rs` | CIFS/SMB network shares |
| `ecryptfs.rs` | Encrypted filesystem |
| `efivarfs.rs` | EFI variables |
| `httpdirfs.rs` | HTTP directory as filesystem |
| `idmapped.rs` | ID-mapped mounts |
| `label.rs` | Mount by label |
| `loop_dev.rs` | Loop device mounts |
| `overlayfs.rs` | Overlay filesystem |
## Other Useful Utilities
### `Apply` / `ApplyRef` traits
Fluent method chaining:
```rust
use crate::util::Apply;
let result = some_value
.apply(|v| transform(v))
.apply(|v| another_transform(v));
```
### `Container<T>`
Async-safe optional container:
```rust
use crate::util::Container;
let container = Container::new(None);
container.set(value).await;
let taken = container.take().await;
```
### `HashWriter<H, W>`
Write data while computing hash:
```rust
use crate::util::HashWriter;
use sha2::Sha256;
let writer = HashWriter::new(Sha256::new(), file);
// Write data...
let (hasher, file) = writer.finish();
let hash = hasher.finalize();
```
### `Never` type
Uninhabited type for impossible cases:
```rust
use crate::util::Never;
fn impossible() -> Never {
// This function can never return
}
let never: Never = impossible();
never.absurd::<String>() // Can convert to any type
```
### `MaybeOwned<'a, T>`
Either borrowed or owned data:
```rust
use crate::util::MaybeOwned;
fn accept_either(data: MaybeOwned<'_, String>) {
// Use &*data to access the value
}
accept_either(MaybeOwned::from(&existing_string));
accept_either(MaybeOwned::from(owned_string));
```
### `new_guid()`
Generate a random GUID:
```rust
use crate::util::new_guid;
let guid = new_guid(); // Returns InternedString
```

View File

@@ -1,301 +0,0 @@
# exver — Extended Versioning
Extended semver supporting **downstream versioning** (wrapper updates independent of upstream) and **flavors** (package fork variants).
Two implementations exist:
- **Rust crate** (`exver`) — used in `core/`. Source: https://github.com/Start9Labs/exver-rs
- **TypeScript** (`sdk/base/lib/exver/index.ts`) — used in `sdk/` and `web/`
Both parse the same string format and agree on `satisfies` semantics.
## Version Format
An **ExtendedVersion** string looks like:
```
[#flavor:]upstream:downstream
```
- **upstream** — the original package version (semver-style: `1.2.3`, `1.2.3-beta.1`)
- **downstream** — the StartOS wrapper version (incremented independently)
- **flavor** — optional lowercase ASCII prefix for fork variants
Examples:
- `1.2.3:0` — upstream 1.2.3, first downstream release
- `1.2.3:2` — upstream 1.2.3, third downstream release
- `#bitcoin:21.0:1` — bitcoin flavor, upstream 21.0, downstream 1
- `1.0.0-rc.1:0` — upstream with prerelease tag
## Core Types
### `Version`
A semver-style version with arbitrary digit segments and optional prerelease.
**Rust:**
```rust
use exver::Version;
let v = Version::new([1, 2, 3], []); // 1.2.3
let v = Version::new([1, 0], ["beta".into()]); // 1.0-beta
let v: Version = "1.2.3".parse().unwrap();
v.number() // &[1, 2, 3]
v.prerelease() // &[]
```
**TypeScript:**
```typescript
const v = new Version([1, 2, 3], [])
const v = Version.parse("1.2.3")
v.number // number[]
v.prerelease // (string | number)[]
v.compare(other) // 'greater' | 'equal' | 'less'
v.compareForSort(other) // -1 | 0 | 1
```
Default: `0`
### `ExtendedVersion`
The primary version type. Wraps upstream + downstream `Version` plus an optional flavor.
**Rust:**
```rust
use exver::ExtendedVersion;
let ev = ExtendedVersion::new(
Version::new([1, 2, 3], []),
Version::default(), // downstream = 0
);
let ev: ExtendedVersion = "1.2.3:0".parse().unwrap();
ev.flavor() // Option<&str>
ev.upstream() // &Version
ev.downstream() // &Version
// Builder methods (consuming):
ev.with_flavor("bitcoin")
ev.without_flavor()
ev.map_upstream(|v| ...)
ev.map_downstream(|v| ...)
```
**TypeScript:**
```typescript
const ev = new ExtendedVersion(null, upstream, downstream)
const ev = ExtendedVersion.parse("1.2.3:0")
const ev = ExtendedVersion.parseEmver("1.2.3.4") // emver compat
ev.flavor // string | null
ev.upstream // Version
ev.downstream // Version
ev.compare(other) // 'greater' | 'equal' | 'less' | null
ev.equals(other) // boolean
ev.greaterThan(other) // boolean
ev.lessThan(other) // boolean
ev.incrementMajor() // new ExtendedVersion
ev.incrementMinor() // new ExtendedVersion
```
**Ordering:** Versions with different flavors are **not comparable** (`PartialOrd`/`compare` returns `None`/`null`).
Default: `0:0`
### `VersionString` (Rust only, StartOS wrapper)
Defined in `core/src/util/version.rs`. Caches the original string representation alongside the parsed `ExtendedVersion`. Used as the key type in registry version maps.
```rust
use crate::util::VersionString;
let vs: VersionString = "1.2.3:0".parse().unwrap();
let vs = VersionString::from(extended_version);
// Deref to ExtendedVersion:
vs.satisfies(&range);
vs.upstream();
// String access:
vs.as_str(); // &str
AsRef::<str>::as_ref(&vs);
```
`Ord` is implemented with a total ordering — versions with different flavors are ordered by flavor name (unflavored sorts last).
### `VersionRange`
A predicate over `ExtendedVersion`. Supports comparison operators, boolean logic, and flavor constraints.
**Rust:**
```rust
use exver::VersionRange;
// Constructors:
VersionRange::any() // matches everything
VersionRange::none() // matches nothing
VersionRange::exactly(ev) // = ev
VersionRange::anchor(GTE, ev) // >= ev
VersionRange::caret(ev) // ^ev (compatible changes)
VersionRange::tilde(ev) // ~ev (patch-level changes)
// Combinators (smart — eagerly simplify):
VersionRange::and(a, b) // a && b
VersionRange::or(a, b) // a || b
VersionRange::not(a) // !a
// Parsing:
let r: VersionRange = ">=1.0.0:0".parse().unwrap();
let r: VersionRange = "^1.2.3:0".parse().unwrap();
let r: VersionRange = ">=1.0.0 <2.0.0".parse().unwrap(); // implicit AND
let r: VersionRange = ">=1.0.0 || >=2.0.0".parse().unwrap();
let r: VersionRange = "#bitcoin".parse().unwrap(); // flavor match
let r: VersionRange = "*".parse().unwrap(); // any
// Monoid wrappers for folding:
AnyRange // fold with or, empty = None
AllRange // fold with and, empty = Any
```
**TypeScript:**
```typescript
// Constructors:
VersionRange.any()
VersionRange.none()
VersionRange.anchor('=', ev)
VersionRange.anchor('>=', ev)
VersionRange.anchor('^', ev) // ^ and ~ are first-class operators
VersionRange.anchor('~', ev)
VersionRange.flavor(null) // match unflavored versions
VersionRange.flavor("bitcoin") // match #bitcoin versions
// Combinators — static (smart, variadic):
VersionRange.and(a, b, c, ...)
VersionRange.or(a, b, c, ...)
// Combinators — instance (not smart, just wrap):
range.and(other)
range.or(other)
range.not()
// Parsing:
VersionRange.parse(">=1.0.0:0")
VersionRange.parseEmver(">=1.2.3.4") // emver compat
// Analysis (TS only):
range.normalize() // canonical form (see below)
range.satisfiable() // boolean
range.intersects(other) // boolean
```
**Checking satisfaction:**
```rust
// Rust:
version.satisfies(&range) // bool
```
```typescript
// TypeScript:
version.satisfies(range) // boolean
range.satisfiedBy(version) // boolean (convenience)
```
Also available on `Version` (wraps in `ExtendedVersion` with downstream=0).
When no operator is specified in a range string, `^` (caret) is the default.
## Operators
| Syntax | Rust | TS | Meaning |
|--------|------|----|---------|
| `=` | `EQ` | `'='` | Equal |
| `!=` | `NEQ` | `'!='` | Not equal |
| `>` | `GT` | `'>'` | Greater than |
| `>=` | `GTE` | `'>='` | Greater than or equal |
| `<` | `LT` | `'<'` | Less than |
| `<=` | `LTE` | `'<='` | Less than or equal |
| `^` | expanded to `And(GTE, LT)` | `'^'` | Compatible (first non-zero digit unchanged) |
| `~` | expanded to `And(GTE, LT)` | `'~'` | Patch-level (minor unchanged) |
## Flavor Rules
- Versions with **different flavors** never satisfy comparison operators (except `!=`, which returns true)
- `VersionRange::Flavor(Some("bitcoin"))` matches only `#bitcoin:*` versions
- `VersionRange::Flavor(None)` matches only unflavored versions
- Flavor constraints compose with `and`/`or`/`not` like any other range
## Reduction and Normalization
### Rust: `reduce()` (shallow)
`VersionRange::reduce(self) -> Self` re-applies smart constructor rules to one level of the AST. Useful for simplifying a node that was constructed directly (e.g. deserialized) rather than through the smart constructors.
**Smart constructor rules applied by `and`, `or`, `not`, and `reduce`:**
`and`:
- `and(Any, b) → b`, `and(a, Any) → a`
- `and(None, _) → None`, `and(_, None) → None`
`or`:
- `or(Any, _) → Any`, `or(_, Any) → Any`
- `or(None, b) → b`, `or(a, None) → a`
`not`:
- `not(=v) → !=v`, `not(!=v) → =v`
- `not(and(a, b)) → or(not(a), not(b))` (De Morgan)
- `not(or(a, b)) → and(not(a), not(b))` (De Morgan)
- `not(not(a)) → a`
- `not(Any) → None`, `not(None) → Any`
### TypeScript: `normalize()` (deep, canonical)
`VersionRange.normalize(): VersionRange` in `sdk/base/lib/exver/index.ts` performs full normalization by converting the range AST into a canonical form. This is a deep operation that produces a semantically equivalent but simplified range.
**How it works:**
1. **`tables()`** — Converts the VersionRange AST into truth tables (`VersionRangeTable`). Each table is a number line split at version boundary points, with boolean values for each segment indicating whether versions in that segment satisfy the range. Separate tables are maintained per flavor (and for flavor negations).
2. **`VersionRangeTable.zip(a, b, func)`** — Merges two tables by walking their boundary points in sorted order and applying a boolean function (`&&` or `||`) to combine segment values. Adjacent segments with the same boolean value are collapsed automatically.
3. **`VersionRangeTable.and/or/not`** — Table-level boolean operations. `and` computes the cross-product of flavor tables (since `#a && #b` for different flavors is unsatisfiable). `not` inverts all segment values.
4. **`VersionRangeTable.collapse()`** — Checks if a table is uniformly true or false across all flavors and segments. Returns `true`, `false`, or `null` (mixed).
5. **`VersionRangeTable.minterms()`** — Converts truth tables back into a VersionRange AST in [sum-of-products](https://en.wikipedia.org/wiki/Canonical_normal_form#Minterms) canonical form. Each `true` segment becomes a product term (conjunction of boundary constraints), and all terms are joined with `or`. Adjacent boundary points collapse into `=` anchors.
**Example:** `normalize` can simplify:
- `>=1.0.0:0 && <=1.0.0:0``=1.0.0:0`
- `>=2.0.0:0 || >=1.0.0:0``>=1.0.0:0`
- `!(!>=1.0.0:0)``>=1.0.0:0`
**Also exposes:**
- `satisfiable(): boolean` — returns `true` if there exists any version satisfying the range (checks if `collapse(tables())` is not `false`)
- `intersects(other): boolean` — returns `true` if `and(this, other)` is satisfiable
## API Differences Between Rust and TypeScript
| | Rust | TypeScript |
|-|------|------------|
| **`^` / `~`** | Expanded at construction to `And(GTE, LT)` | First-class operator on `Anchor` |
| **`not()`** | Static, eagerly simplifies (De Morgan, double negation) | Instance method, just wraps |
| **`and()`/`or()`** | Binary static | Both binary instance and variadic static |
| **Normalization** | `reduce()` — shallow, one AST level | `normalize()` — deep canonical form via truth tables |
| **Satisfiability** | Not available | `satisfiable()` and `intersects(other)` |
| **ExtendedVersion helpers** | `with_flavor()`, `without_flavor()`, `map_upstream()`, `map_downstream()` | `incrementMajor()`, `incrementMinor()`, `greaterThan()`, `lessThan()`, `equals()`, etc. |
| **Monoid wrappers** | `AnyRange` (fold with `or`) and `AllRange` (fold with `and`) | Not present — use variadic static methods |
| **`VersionString`** | Wrapper caching parsed + string form | Not present |
| **Emver compat** | `From<emver::Version>` for `ExtendedVersion` | `ExtendedVersion.parseEmver()`, `VersionRange.parseEmver()` |
## Serde
All types serialize/deserialize as strings (requires `serde` feature, enabled in StartOS):
```json
{
"version": "1.2.3:0",
"targetVersion": ">=1.0.0:0 <2.0.0:0",
"sourceVersion": "^0.3.0:0"
}
```

View File

@@ -1,100 +0,0 @@
# i18n Patterns in `core/`
## Library & Setup
**Crate:** [`rust-i18n`](https://crates.io/crates/rust-i18n) v3.1.5 (`core/Cargo.toml`)
**Initialization** (`core/src/lib.rs:3`):
```rust
rust_i18n::i18n!("locales", fallback = ["en_US"]);
```
This macro scans `core/locales/` at compile time and embeds all translations as constants.
**Prelude re-export** (`core/src/prelude.rs:4`):
```rust
pub use rust_i18n::t;
```
Most modules import `t!` via the prelude.
## Translation File
**Location:** `core/locales/i18n.yaml`
**Format:** YAML v2 (~755 keys)
**Supported languages:** `en_US`, `de_DE`, `es_ES`, `fr_FR`, `pl_PL`
**Entry structure:**
```yaml
namespace.sub.key-name:
en_US: "English text with %{param}"
de_DE: "German text with %{param}"
# ...
```
## Using `t!()`
```rust
// Simple key
t!("error.unknown")
// With parameter interpolation (%{name} in YAML)
t!("bins.deprecated.renamed", old = old_name, new = new_name)
```
## Key Naming Conventions
Keys use **dot-separated hierarchical namespaces** with **kebab-case** for multi-word segments:
```
<module>.<submodule>.<descriptive-name>
```
Examples:
- `error.incorrect-password` — error kind label
- `bins.start-init.updating-firmware` — startup phase message
- `backup.bulk.complete-title` — backup notification title
- `help.arg.acme-contact` — CLI help text for an argument
- `context.diagnostic.starting-diagnostic-ui` — diagnostic context status
### Top-Level Namespaces
| Namespace | Purpose |
|-----------|---------|
| `error.*` | `ErrorKind` display strings (see `src/error.rs`) |
| `bins.*` | CLI binary messages (deprecated, start-init, startd, etc.) |
| `init.*` | Initialization phase labels |
| `setup.*` | First-run setup messages |
| `context.*` | Context startup messages (diagnostic, setup, CLI) |
| `service.*` | Service lifecycle messages |
| `backup.*` | Backup/restore operation messages |
| `registry.*` | Package registry messages |
| `net.*` | Network-related messages |
| `middleware.*` | Request middleware messages (auth, etc.) |
| `disk.*` | Disk operation messages |
| `lxc.*` | Container management messages |
| `system.*` | System monitoring/metrics messages |
| `notifications.*` | User-facing notification messages |
| `update.*` | OS update messages |
| `util.*` | Utility messages (TUI, RPC) |
| `ssh.*` | SSH operation messages |
| `shutdown.*` | Shutdown-related messages |
| `logs.*` | Log-related messages |
| `auth.*` | Authentication messages |
| `help.*` | CLI help text (`help.arg.<arg-name>`) |
| `about.*` | CLI command descriptions |
## Locale Selection
`core/src/bins/mod.rs:15-36``set_locale_from_env()`:
1. Reads `LANG` environment variable
2. Strips `.UTF-8` suffix
3. Exact-matches against available locales, falls back to language-prefix match (e.g. `en_GB` matches `en_US`)
## Adding New Keys
1. Add the key to `core/locales/i18n.yaml` with all 5 language translations
2. Use the `t!("your.key.name")` macro in Rust code
3. Follow existing namespace conventions — match the module path where the key is used
4. Use kebab-case for multi-word segments
5. Translations are validated at compile time

View File

@@ -1,226 +0,0 @@
# rpc-toolkit
StartOS uses [rpc-toolkit](https://github.com/Start9Labs/rpc-toolkit) for its JSON-RPC API. This document covers the patterns used in this codebase.
## Overview
The API is JSON-RPC (not REST). All endpoints are RPC methods organized in a hierarchical command structure.
## Handler Functions
There are four types of handler functions, chosen based on the function's characteristics:
### `from_fn_async` - Async handlers
For standard async functions. Most handlers use this.
```rust
pub async fn my_handler(ctx: RpcContext, params: MyParams) -> Result<MyResponse, Error> {
// Can use .await
}
from_fn_async(my_handler)
```
### `from_fn_async_local` - Non-thread-safe async handlers
For async functions that are not `Send` (cannot be safely moved between threads). Use when working with non-thread-safe types.
```rust
pub async fn cli_download(ctx: CliContext, params: Params) -> Result<(), Error> {
// Non-Send async operations
}
from_fn_async_local(cli_download)
```
### `from_fn_blocking` - Sync blocking handlers
For synchronous functions that perform blocking I/O or long computations.
```rust
pub fn query_dns(ctx: RpcContext, params: DnsParams) -> Result<DnsResponse, Error> {
// Blocking operations (file I/O, DNS lookup, etc.)
}
from_fn_blocking(query_dns)
```
### `from_fn` - Sync non-blocking handlers
For pure functions or quick synchronous operations with no I/O.
```rust
pub fn echo(ctx: RpcContext, params: EchoParams) -> Result<String, Error> {
Ok(params.message)
}
from_fn(echo)
```
## ParentHandler
Groups related RPC methods into a hierarchy:
```rust
use rpc_toolkit::{Context, HandlerExt, ParentHandler, from_fn_async};
pub fn my_api<C: Context>() -> ParentHandler<C> {
ParentHandler::new()
.subcommand("list", from_fn_async(list_handler).with_call_remote::<CliContext>())
.subcommand("create", from_fn_async(create_handler).with_call_remote::<CliContext>())
}
```
## Handler Extensions
Chain methods to configure handler behavior.
**Ordering rules:**
1. `with_about()` must come AFTER other CLI modifiers (`no_display()`, `with_custom_display_fn()`, etc.)
2. `with_call_remote()` must be the LAST adapter in the chain
| Method | Purpose |
|--------|---------|
| `.with_metadata("key", Value)` | Attach metadata for middleware |
| `.no_cli()` | RPC-only, not available via CLI |
| `.no_display()` | No CLI output |
| `.with_display_serializable()` | Default JSON/YAML output for CLI |
| `.with_custom_display_fn(\|_, res\| ...)` | Custom CLI output formatting |
| `.with_about("about.description")` | Add help text (i18n key) - **after CLI modifiers** |
| `.with_call_remote::<CliContext>()` | Enable CLI to call remotely - **must be last** |
### Correct ordering example:
```rust
from_fn_async(my_handler)
.with_metadata("sync_db", Value::Bool(true)) // metadata early
.no_display() // CLI modifier
.with_about("about.my-handler") // after CLI modifiers
.with_call_remote::<CliContext>() // always last
```
## Metadata by Middleware
Metadata tags are processed by different middleware. Group them logically:
### Auth Middleware (`middleware/auth/mod.rs`)
| Metadata | Default | Description |
|----------|---------|-------------|
| `authenticated` | `true` | Whether endpoint requires authentication. Set to `false` for public endpoints. |
### Session Auth Middleware (`middleware/auth/session.rs`)
| Metadata | Default | Description |
|----------|---------|-------------|
| `login` | `false` | Special handling for login endpoints (rate limiting, cookie setting) |
| `get_session` | `false` | Inject session ID into params as `__Auth_session` |
### Signature Auth Middleware (`middleware/auth/signature.rs`)
| Metadata | Default | Description |
|----------|---------|-------------|
| `get_signer` | `false` | Inject signer public key into params as `__Auth_signer` |
### Registry Auth (extends Signature Auth)
| Metadata | Default | Description |
|----------|---------|-------------|
| `admin` | `false` | Require admin privileges (signer must be in admin list) |
| `get_device_info` | `false` | Inject device info header for hardware filtering |
### Database Middleware (`middleware/db.rs`)
| Metadata | Default | Description |
|----------|---------|-------------|
| `sync_db` | `false` | Sync database after mutation, add `X-Patch-Sequence` header |
## Context Types
Different contexts for different execution environments:
- `RpcContext` - Web/RPC requests with full service access
- `CliContext` - CLI operations, calls remote RPC
- `InitContext` - During system initialization
- `DiagnosticContext` - Diagnostic/recovery mode
- `RegistryContext` - Registry daemon context
- `EffectContext` - Service effects context (container-to-host calls)
## Parameter Structs
Parameters use derive macros for JSON-RPC, CLI parsing, and TypeScript generation:
```rust
#[derive(Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")] // JSON-RPC uses camelCase
#[command(rename_all = "kebab-case")] // CLI uses kebab-case
#[ts(export)] // Generate TypeScript types
pub struct MyParams {
pub package_id: PackageId,
}
```
### Middleware Injection
Auth middleware can inject values into params using special field names:
```rust
#[derive(Deserialize, Serialize, Parser, TS)]
pub struct MyParams {
#[ts(skip)]
#[serde(rename = "__Auth_session")] // Injected by session auth
session: InternedString,
#[ts(skip)]
#[serde(rename = "__Auth_signer")] // Injected by signature auth
signer: AnyVerifyingKey,
#[ts(skip)]
#[serde(rename = "__Auth_userAgent")] // Injected during login
user_agent: Option<String>,
}
```
## Common Patterns
### Adding a New RPC Endpoint
1. Define params struct with `Deserialize, Serialize, Parser, TS`
2. Choose handler type based on sync/async and thread-safety
3. Write handler function taking `(Context, Params) -> Result<Response, Error>`
4. Add to parent handler with appropriate extensions (display modifiers before `with_about`)
5. TypeScript types auto-generated via `make ts-bindings`
### Public (Unauthenticated) Endpoint
```rust
from_fn_async(get_info)
.with_metadata("authenticated", Value::Bool(false))
.with_display_serializable()
.with_about("about.get-info")
.with_call_remote::<CliContext>() // last
```
### Mutating Endpoint with DB Sync
```rust
from_fn_async(update_config)
.with_metadata("sync_db", Value::Bool(true))
.no_display()
.with_about("about.update-config")
.with_call_remote::<CliContext>() // last
```
### Session-Aware Endpoint
```rust
from_fn_async(logout)
.with_metadata("get_session", Value::Bool(true))
.no_display()
.with_about("about.logout")
.with_call_remote::<CliContext>() // last
```
## File Locations
- Handler definitions: Throughout `core/src/` modules
- Main API tree: `core/src/lib.rs` (`main_api()`, `server()`, `package()`)
- Auth middleware: `core/src/middleware/auth/`
- DB middleware: `core/src/middleware/db.rs`
- Context types: `core/src/context/`

View File

@@ -1,122 +0,0 @@
# S9PK Package Format
S9PK is the package format for StartOS services. Version 2 uses a merkle archive structure for efficient downloading and cryptographic verification.
## File Format
S9PK files begin with a 3-byte header: `0x3b 0x3b 0x02` (magic bytes + version 2).
The archive is cryptographically signed using Ed25519 with prehashed content (SHA-512 over blake3 merkle root hash).
## Archive Structure
```
/
├── manifest.json # Package metadata (required)
├── icon.<ext> # Package icon - any image/* format (required)
├── LICENSE.md # License text (required)
├── dependencies/ # Dependency metadata (optional)
│ └── <package-id>/
│ ├── metadata.json # DependencyMetadata
│ └── icon.<ext> # Dependency icon
├── javascript.squashfs # Package JavaScript code (required)
├── assets.squashfs # Static assets (optional, legacy: assets/ directory)
└── images/ # Container images by architecture
└── <arch>/ # e.g., x86_64, aarch64, riscv64
├── <image-id>.squashfs # Container filesystem
├── <image-id>.json # Image metadata
└── <image-id>.env # Environment variables
```
## Components
### manifest.json
The package manifest contains all metadata:
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Package identifier (e.g., `bitcoind`) |
| `title` | string | Display name |
| `version` | string | Extended version string |
| `satisfies` | string[] | Version ranges this version satisfies |
| `releaseNotes` | string/object | Release notes (localized) |
| `canMigrateTo` | string | Version range for forward migration |
| `canMigrateFrom` | string | Version range for backward migration |
| `license` | string | License type |
| `wrapperRepo` | string | StartOS wrapper repository URL |
| `upstreamRepo` | string | Upstream project URL |
| `supportSite` | string | Support site URL |
| `marketingSite` | string | Marketing site URL |
| `donationUrl` | string? | Optional donation URL |
| `docsUrl` | string? | Optional documentation URL |
| `description` | object | Short and long descriptions (localized) |
| `images` | object | Image configurations by image ID |
| `volumes` | string[] | Volume IDs for persistent data |
| `alerts` | object | User alerts for lifecycle events |
| `dependencies` | object | Package dependencies |
| `hardwareRequirements` | object | Hardware requirements (arch, RAM, devices) |
| `hardwareAcceleration` | boolean | Whether package uses hardware acceleration |
| `gitHash` | string? | Git commit hash |
| `osVersion` | string | Minimum StartOS version |
| `sdkVersion` | string? | SDK version used to build |
### javascript.squashfs
Contains the package JavaScript that implements the `ABI` interface from `@start9labs/start-sdk-base`. This code runs in the container runtime and manages the package lifecycle.
The squashfs is mounted at `/usr/lib/startos/package/` and the runtime loads `index.js`.
### images/
Container images organized by architecture:
- **`<image-id>.squashfs`** - Container root filesystem
- **`<image-id>.json`** - Image metadata (entrypoint, user, workdir, etc.)
- **`<image-id>.env`** - Environment variables for the container
Images are built from Docker/Podman and converted to squashfs. The `ImageConfig` in manifest specifies:
- `arch` - Supported architectures
- `emulateMissingAs` - Fallback architecture for emulation
- `nvidiaContainer` - Whether to enable NVIDIA container support
### assets.squashfs
Static assets accessible to the package, mounted read-only at `/media/startos/assets/` in the container.
### dependencies/
Metadata for dependencies displayed in the UI:
- `metadata.json` - Just title for now
- `icon.<ext>` - Icon for the dependency
## Merkle Archive
The S9PK uses a merkle tree structure where each file and directory has a blake3 hash. This enables:
1. **Partial downloads** - Download and verify individual files
2. **Integrity verification** - Verify any subset of the archive
3. **Efficient updates** - Only download changed portions
4. **DOS protection** - Size limits enforced before downloading content
Files are sorted by priority for streaming (manifest first, then icon, license, dependencies, javascript, assets, images).
## Building S9PK
Use `start-cli s9pk pack` to build packages:
```bash
start-cli s9pk pack <manifest-path> -o <output.s9pk>
```
Images can be sourced from:
- Docker/Podman build (`--docker-build`)
- Existing Docker tag (`--docker-tag`)
- Pre-built squashfs files
## Related Code
- `core/src/s9pk/v2/mod.rs` - S9pk struct and serialization
- `core/src/s9pk/v2/manifest.rs` - Manifest types
- `core/src/s9pk/v2/pack.rs` - Packing logic
- `core/src/s9pk/merkle_archive/` - Merkle archive implementation

View File

@@ -46,7 +46,6 @@ openssh-server
podman
psmisc
qemu-guest-agent
qemu-user-static
rfkill
rsync
samba-common-bin

View File

@@ -111,6 +111,6 @@ if [ "$CHROOT_RES" -eq 0 ]; then
reboot
fi
umount /media/startos/next
umount -R /media/startos/next
umount /media/startos/upper
rm -rf /media/startos/upper /media/startos/next

View File

@@ -15,12 +15,13 @@ if [ "$SKIP_DL" != "1" ]; then
fi
if [ -n "$RUN_ID" ]; then
for arch in aarch64 aarch64-nonfree riscv64 x86_64 x86_64-nonfree; do
for arch in aarch64 aarch64-nonfree riscv64 riscv64-nonfree x86_64 x86_64-nonfree raspberrypi; do
while ! gh run download -R Start9Labs/start-os $RUN_ID -n $arch.squashfs -D $(pwd); do sleep 1; done
done
for arch in aarch64 aarch64-nonfree riscv64 x86_64 x86_64-nonfree; do
for arch in aarch64 aarch64-nonfree riscv64 riscv64-nonfree x86_64 x86_64-nonfree; do
while ! gh run download -R Start9Labs/start-os $RUN_ID -n $arch.iso -D $(pwd); do sleep 1; done
done
while ! gh run download -R Start9Labs/start-os $RUN_ID -n raspberrypi.img -D $(pwd); do sleep 1; done
fi
if [ -n "$ST_RUN_ID" ]; then
@@ -56,23 +57,31 @@ start-cli --registry=https://alpha-registry-x.start9.com registry os version add
if [ "$SKIP_UL" = "2" ]; then
exit 2
elif [ "$SKIP_UL" != "1" ]; then
for file in *.deb start-cli_*; do
for file in *.squashfs *.iso *.deb start-cli_*; do
gh release upload -R Start9Labs/start-os v$VERSION $file
done
for file in *.iso *.squashfs; do
s3cmd put -P $file s3://startos-images/v$VERSION/$file
for file in *.img; do
if ! [ -f $file.gz ]; then
cat $file | pigz > $file.gz
fi
gh release upload -R Start9Labs/start-os v$VERSION $file.gz
done
fi
if [ "$SKIP_INDEX" != "1" ]; then
for arch in aarch64 aarch64-nonfree riscv64 x86_64 x86_64-nonfree; do
for arch in aarch64 aarch64-nonfree riscv64 riscv64-nonfree x86_64 x86_64-nonfree; do
for file in *_$arch.squashfs *_$arch.iso; do
start-cli --registry=https://alpha-registry-x.start9.com registry os asset add --platform=$arch --version=$VERSION $file https://startos-images.nyc3.cdn.digitaloceanspaces.com/v$VERSION/$file
start-cli --registry=https://alpha-registry-x.start9.com registry os asset add --platform=$arch --version=$VERSION $file https://github.com/Start9Labs/start-os/releases/download/v$VERSION/$(echo -n "$file" | sed 's/~/./g')
done
done
for arch in raspberrypi; do
for file in *_$arch.squashfs; do
start-cli --registry=https://alpha-registry-x.start9.com registry os asset add --platform=$arch --version=$VERSION $file https://github.com/Start9Labs/start-os/releases/download/v$VERSION/$(echo -n "$file" | sed 's/~/./g')
done
done
fi
for file in *.iso *.squashfs *.deb start-cli_*; do
for file in *.iso *.img *.img.gz *.squashfs *.deb start-cli_*; do
gpg -u 7CFFDA41CA66056A --detach-sign --armor -o "${file}.asc" "$file"
done
@@ -81,30 +90,20 @@ tar -czvf signatures.tar.gz *.asc
gh release upload -R Start9Labs/start-os v$VERSION signatures.tar.gz
cat << EOF
# ISO Downloads
- [x86_64/AMD64](https://startos-images.nyc3.cdn.digitaloceanspaces.com/v$VERSION/$(ls *_x86_64-nonfree.iso))
- [x86_64/AMD64-slim (FOSS-only)](https://startos-images.nyc3.cdn.digitaloceanspaces.com/v$VERSION/$(ls *_x86_64.iso) "Without proprietary software or drivers")
- [aarch64/ARM64](https://startos-images.nyc3.cdn.digitaloceanspaces.com/v$VERSION/$(ls *_aarch64-nonfree.iso))
- [aarch64/ARM64-slim (FOSS-Only)](https://startos-images.nyc3.cdn.digitaloceanspaces.com/v$VERSION/$(ls *_aarch64.iso) "Without proprietary software or drivers")
- [RISCV64 (RVA23)](https://startos-images.nyc3.cdn.digitaloceanspaces.com/v$VERSION/$(ls *_riscv64.iso))
EOF
cat << 'EOF'
# StartOS Checksums
## SHA-256
```
EOF
sha256sum *.iso *.squashfs
sha256sum *.iso *.img *img.gz *.squashfs
cat << 'EOF'
```
## BLAKE-3
```
EOF
b3sum *.iso *.squashfs
b3sum *.iso *.img *.img.gz *.squashfs
cat << 'EOF'
```
@@ -139,4 +138,5 @@ EOF
b3sum start-cli_*
cat << 'EOF'
```
EOF
EOF

View File

@@ -1,21 +1,16 @@
# Container RPC Server Specification
The container runtime exposes a JSON-RPC server over a Unix socket at `/media/startos/rpc/service.sock`.
# Container RPC SERVER Specification
## Methods
### init
Initialize the runtime and system.
initialize runtime (mount `/proc`, `/sys`, `/dev`, and `/run` to each image in `/media/images`)
#### params
called after os has mounted js and images to the container
```ts
{
id: string,
kind: "install" | "update" | "restore" | null,
}
```
#### args
`[]`
#### response
@@ -23,16 +18,11 @@ Initialize the runtime and system.
### exit
Shutdown runtime and optionally run exit hooks for a target version.
shutdown runtime
#### params
#### args
```ts
{
id: string,
target: string | null, // ExtendedVersion or VersionRange
}
```
`[]`
#### response
@@ -40,11 +30,11 @@ Shutdown runtime and optionally run exit hooks for a target version.
### start
Run main method if not already running.
run main method if not already running
#### params
#### args
None
`[]`
#### response
@@ -52,11 +42,11 @@ None
### stop
Stop main method by sending SIGTERM to child processes, and SIGKILL after timeout.
stop main method by sending SIGTERM to child processes, and SIGKILL after timeout
#### params
#### args
None
`{ timeout: millis }`
#### response
@@ -64,16 +54,15 @@ None
### execute
Run a specific package procedure.
run a specific package procedure
#### params
#### args
```ts
{
id: string, // event ID
procedure: string, // JSON path (e.g., "/backup/create", "/actions/{name}/run")
input: any,
timeout: number | null,
procedure: JsonPath,
input: any,
timeout: millis,
}
```
@@ -83,64 +72,18 @@ Run a specific package procedure.
### sandbox
Run a specific package procedure in sandbox mode. Same interface as `execute`.
run a specific package procedure in sandbox mode
UNIMPLEMENTED: this feature is planned but does not exist
#### params
#### args
```ts
{
id: string,
procedure: string,
input: any,
timeout: number | null,
procedure: JsonPath,
input: any,
timeout: millis,
}
```
#### response
`any`
### callback
Handle a callback from an effect.
#### params
```ts
{
id: number,
args: any[],
}
```
#### response
`null` (no response sent)
### eval
Evaluate a script in the runtime context. Used for debugging.
#### params
```ts
{
script: string,
}
```
#### response
`any`
## Procedures
The `execute` and `sandbox` methods route to procedures based on the `procedure` path:
| Procedure | Description |
|-----------|-------------|
| `/backup/create` | Create a backup |
| `/actions/{name}/getInput` | Get input spec for an action |
| `/actions/{name}/run` | Run an action with input |

2
core/Cargo.lock generated
View File

@@ -7817,7 +7817,7 @@ dependencies = [
[[package]]
name = "start-os"
version = "0.4.0-alpha.19"
version = "0.4.0-alpha.18"
dependencies = [
"aes 0.7.5",
"arti-client",

View File

@@ -15,7 +15,7 @@ license = "MIT"
name = "start-os"
readme = "README.md"
repository = "https://github.com/Start9Labs/start-os"
version = "0.4.0-alpha.19" # VERSION_BUMP
version = "0.4.0-alpha.18" # VERSION_BUMP
[lib]
name = "startos"
@@ -176,7 +176,6 @@ mio = "1"
new_mime_guess = "4"
nix = { version = "0.30.1", features = [
"fs",
"hostname",
"mount",
"net",
"process",

View File

@@ -1843,18 +1843,18 @@ service.mod.failed-to-parse-package-data-entry:
pl_PL: "Nie udało się przeanalizować PackageDataEntry, znaleziono: %{error}"
service.mod.no-matching-subcontainers:
en_US: "no matching subcontainers are running for %{id}; some possible choices are:"
de_DE: "keine passenden Subcontainer laufen für %{id}; einige mögliche Optionen sind:"
es_ES: "no hay subcontenedores coincidentes ejecutándose para %{id}; algunas opciones posibles son:"
fr_FR: "aucun sous-conteneur correspondant n'est en cours d'exécution pour %{id} ; voici quelques choix possibles :"
pl_PL: "nie działają pasujące podkontenery dla %{id}; niektóre możliwe wybory to:"
en_US: "no matching subcontainers are running for %{id}; some possible choices are:\n%{subcontainers}"
de_DE: "keine passenden Subcontainer laufen für %{id}; einige mögliche Optionen sind:\n%{subcontainers}"
es_ES: "no hay subcontenedores coincidentes ejecutándose para %{id}; algunas opciones posibles son:\n%{subcontainers}"
fr_FR: "aucun sous-conteneur correspondant n'est en cours d'exécution pour %{id} ; voici quelques choix possibles :\n%{subcontainers}"
pl_PL: "nie działają pasujące podkontenery dla %{id}; niektóre możliwe wybory to:\n%{subcontainers}"
service.mod.multiple-subcontainers-found:
en_US: "multiple subcontainers found for %{id}"
de_DE: "mehrere Subcontainer für %{id} gefunden"
es_ES: "se encontraron múltiples subcontenedores para %{id}"
fr_FR: "plusieurs sous-conteneurs trouvés pour %{id}"
pl_PL: "znaleziono wiele podkontenerów dla %{id}"
en_US: "multiple subcontainers found for %{id}: \n%{subcontainer_ids}"
de_DE: "mehrere Subcontainer für %{id} gefunden: \n%{subcontainer_ids}"
es_ES: "se encontraron múltiples subcontenedores para %{id}: \n%{subcontainer_ids}"
fr_FR: "plusieurs sous-conteneurs trouvés pour %{id} : \n%{subcontainer_ids}"
pl_PL: "znaleziono wiele podkontenerów dla %{id}: \n%{subcontainer_ids}"
service.mod.invalid-byte-length-for-signal:
en_US: "invalid byte length for signal: %{length}"
@@ -3703,20 +3703,6 @@ help.arg.wireguard-config:
fr_FR: "Configuration WireGuard"
pl_PL: "Konfiguracja WireGuard"
help.s9pk-s3base:
en_US: "Base URL for publishing s9pks"
de_DE: "Basis-URL für die Veröffentlichung von s9pks"
es_ES: "URL base para publicar s9pks"
fr_FR: "URL de base pour publier les s9pks"
pl_PL: "Bazowy URL do publikowania s9pks"
help.s9pk-s3bucket:
en_US: "S3 bucket to publish s9pks to (should correspond to s3base)"
de_DE: "S3-Bucket zum Veröffentlichen von s9pks (sollte mit s3base übereinstimmen)"
es_ES: "Bucket S3 para publicar s9pks (debe corresponder con s3base)"
fr_FR: "Bucket S3 pour publier les s9pks (doit correspondre à s3base)"
pl_PL: "Bucket S3 do publikowania s9pks (powinien odpowiadać s3base)"
# CLI command descriptions (about.*)
about.add-address-to-host:
en_US: "Add an address to this host"
@@ -4880,13 +4866,6 @@ about.persist-new-notification:
fr_FR: "Persister une nouvelle notification"
pl_PL: "Utrwal nowe powiadomienie"
about.publish-s9pk:
en_US: "Publish s9pk to S3 bucket and index on registry"
de_DE: "S9pk in S3-Bucket veröffentlichen und in Registry indizieren"
es_ES: "Publicar s9pk en bucket S3 e indexar en el registro"
fr_FR: "Publier s9pk dans le bucket S3 et indexer dans le registre"
pl_PL: "Opublikuj s9pk do bucketu S3 i zindeksuj w rejestrze"
about.rebuild-service-container:
en_US: "Rebuild service container"
de_DE: "Dienst-Container neu erstellen"

View File

@@ -180,13 +180,7 @@ pub async fn update(
.as_idx_mut(&id)
.ok_or_else(|| {
Error::new(
eyre!(
"{}",
t!(
"backup.target.cifs.target-not-found",
id = BackupTargetId::Cifs { id }
)
),
eyre!("{}", t!("backup.target.cifs.target-not-found", id = BackupTargetId::Cifs { id })),
ErrorKind::NotFound,
)
})?

View File

@@ -1,7 +1,10 @@
use rust_i18n::t;
pub fn renamed(old: &str, new: &str) -> ! {
eprintln!("{}", t!("bins.deprecated.renamed", old = old, new = new));
eprintln!(
"{}",
t!("bins.deprecated.renamed", old = old, new = new)
);
std::process::exit(1)
}

View File

@@ -4,8 +4,8 @@ use std::time::Duration;
use clap::Parser;
use color_eyre::eyre::eyre;
use futures::{FutureExt, TryFutureExt};
use rust_i18n::t;
use futures::{FutureExt, TryFutureExt};
use tokio::signal::unix::signal;
use tracing::instrument;

View File

@@ -38,8 +38,6 @@ pub struct CliContextSeed {
pub registry_url: Option<Url>,
pub registry_hostname: Vec<InternedString>,
pub registry_listen: Option<SocketAddr>,
pub s9pk_s3base: Option<Url>,
pub s9pk_s3bucket: Option<InternedString>,
pub tunnel_addr: Option<SocketAddr>,
pub tunnel_listen: Option<SocketAddr>,
pub client: Client,
@@ -131,8 +129,6 @@ impl CliContext {
.transpose()?,
registry_hostname: config.registry_hostname.unwrap_or_default(),
registry_listen: config.registry_listen,
s9pk_s3base: config.s9pk_s3base,
s9pk_s3bucket: config.s9pk_s3bucket,
tunnel_addr: config.tunnel,
tunnel_listen: config.tunnel_listen,
client: {
@@ -164,23 +160,21 @@ impl CliContext {
if !path.exists() {
continue;
}
let pair =
<ed25519::KeypairBytes as ed25519::pkcs8::DecodePrivateKey>::from_pkcs8_pem(
&std::fs::read_to_string(path)?,
let pair = <ed25519::KeypairBytes as ed25519::pkcs8::DecodePrivateKey>::from_pkcs8_pem(
&std::fs::read_to_string(path)?,
)
.with_kind(crate::ErrorKind::Pem)?;
let secret = ed25519_dalek::SecretKey::try_from(&pair.secret_key[..]).map_err(|_| {
Error::new(
eyre!("{}", t!("context.cli.pkcs8-key-incorrect-length")),
ErrorKind::OpenSsl,
)
.with_kind(crate::ErrorKind::Pem)?;
let secret =
ed25519_dalek::SecretKey::try_from(&pair.secret_key[..]).map_err(|_| {
Error::new(
eyre!("{}", t!("context.cli.pkcs8-key-incorrect-length")),
ErrorKind::OpenSsl,
)
})?;
return Ok(secret.into());
})?;
return Ok(secret.into())
}
Err(Error::new(
eyre!("{}", t!("context.cli.developer-key-does-not-exist")),
crate::ErrorKind::Uninitialized,
crate::ErrorKind::Uninitialized
))
})
}
@@ -201,12 +195,8 @@ impl CliContext {
.into());
}
};
url.set_scheme(ws_scheme).map_err(|_| {
Error::new(
eyre!("{}", t!("context.cli.cannot-set-url-scheme")),
crate::ErrorKind::ParseUrl,
)
})?;
url.set_scheme(ws_scheme)
.map_err(|_| Error::new(eyre!("{}", t!("context.cli.cannot-set-url-scheme")), crate::ErrorKind::ParseUrl))?;
url.path_segments_mut()
.map_err(|_| eyre!("Url cannot be base"))
.with_kind(crate::ErrorKind::ParseUrl)?

View File

@@ -68,10 +68,6 @@ pub struct ClientConfig {
pub registry_hostname: Option<Vec<InternedString>>,
#[arg(skip)]
pub registry_listen: Option<SocketAddr>,
#[arg(long, help = "help.s9pk-s3base")]
pub s9pk_s3base: Option<Url>,
#[arg(long, help = "help.s9pk-s3bucket")]
pub s9pk_s3bucket: Option<InternedString>,
#[arg(short = 't', long, help = "help.arg.tunnel-address")]
pub tunnel: Option<SocketAddr>,
#[arg(skip)]
@@ -93,13 +89,8 @@ impl ContextConfig for ClientConfig {
self.host = self.host.take().or(other.host);
self.registry = self.registry.take().or(other.registry);
self.registry_hostname = self.registry_hostname.take().or(other.registry_hostname);
self.registry_listen = self.registry_listen.take().or(other.registry_listen);
self.s9pk_s3base = self.s9pk_s3base.take().or(other.s9pk_s3base);
self.s9pk_s3bucket = self.s9pk_s3bucket.take().or(other.s9pk_s3bucket);
self.tunnel = self.tunnel.take().or(other.tunnel);
self.tunnel_listen = self.tunnel_listen.take().or(other.tunnel_listen);
self.proxy = self.proxy.take().or(other.proxy);
self.socks_listen = self.socks_listen.take().or(other.socks_listen);
self.cookie_path = self.cookie_path.take().or(other.cookie_path);
self.developer_key_path = self.developer_key_path.take().or(other.developer_key_path);
}

View File

@@ -27,10 +27,7 @@ impl DiagnosticContext {
disk_guid: Option<InternedString>,
error: Error,
) -> Result<Self, Error> {
tracing::error!(
"{}",
t!("context.diagnostic.starting-diagnostic-ui", error = error)
);
tracing::error!("{}", t!("context.diagnostic.starting-diagnostic-ui", error = error));
tracing::debug!("{:?}", error);
let (shutdown, _) = tokio::sync::broadcast::channel(1);

View File

@@ -463,10 +463,7 @@ impl RpcContext {
.await
.result
{
tracing::error!(
"{}",
t!("context.rpc.error-in-session-cleanup-cron", error = e)
);
tracing::error!("{}", t!("context.rpc.error-in-session-cleanup-cron", error = e));
tracing::debug!("{e:?}");
}
}
@@ -579,7 +576,6 @@ impl RpcContext {
pub async fn call_remote<RemoteContext>(
&self,
method: &str,
metadata: OrdMap<&'static str, Value>,
params: Value,
) -> Result<Value, RpcError>
where
@@ -588,7 +584,7 @@ impl RpcContext {
<Self as CallRemote<RemoteContext, Empty>>::call_remote(
&self,
method,
metadata,
OrdMap::new(),
params,
Empty {},
)
@@ -597,15 +593,20 @@ impl RpcContext {
pub async fn call_remote_with<RemoteContext, T>(
&self,
method: &str,
metadata: OrdMap<&'static str, Value>,
params: Value,
extra: T,
) -> Result<Value, RpcError>
where
Self: CallRemote<RemoteContext, T>,
{
<Self as CallRemote<RemoteContext, T>>::call_remote(&self, method, metadata, params, extra)
.await
<Self as CallRemote<RemoteContext, T>>::call_remote(
&self,
method,
OrdMap::new(),
params,
extra,
)
.await
}
}
impl AsRef<Client> for RpcContext {

View File

@@ -87,11 +87,7 @@ pub enum RevisionsRes {
#[serde(rename_all = "camelCase")]
#[command(rename_all = "kebab-case")]
pub struct CliDumpParams {
#[arg(
long = "include-private",
short = 'p',
help = "help.arg.include-private-data"
)]
#[arg(long = "include-private", short = 'p', help = "help.arg.include-private-data")]
#[serde(default)]
include_private: bool,
#[arg(help = "help.arg.db-path")]

View File

@@ -70,20 +70,12 @@ async fn e2fsck_runner(
if code & 4 != 0 {
tracing::error!(
"{}",
t!(
"disk.fsck.errors-not-corrected",
device = logicalname.as_ref().display(),
stderr = e2fsck_stderr
),
t!("disk.fsck.errors-not-corrected", device = logicalname.as_ref().display(), stderr = e2fsck_stderr),
);
} else if code & 1 != 0 {
tracing::warn!(
"{}",
t!(
"disk.fsck.errors-corrected",
device = logicalname.as_ref().display(),
stderr = e2fsck_stderr
),
t!("disk.fsck.errors-corrected", device = logicalname.as_ref().display(), stderr = e2fsck_stderr),
);
}
if code < 8 {

View File

@@ -29,31 +29,25 @@ impl Default for FileType {
pub struct Bind<Src: AsRef<Path>> {
src: Src,
filetype: FileType,
recursive: bool,
}
impl<Src: AsRef<Path>> Bind<Src> {
pub fn new(src: Src) -> Self {
Self {
src,
filetype: FileType::Directory,
recursive: false,
}
}
pub fn with_type(mut self, filetype: FileType) -> Self {
self.filetype = filetype;
self
}
pub fn recursive(mut self, recursive: bool) -> Self {
self.recursive = recursive;
self
}
}
impl<Src: AsRef<Path> + Send + Sync> FileSystem for Bind<Src> {
async fn source(&self) -> Result<Option<impl AsRef<Path>>, Error> {
Ok(Some(&self.src))
}
fn extra_args(&self) -> impl IntoIterator<Item = impl AsRef<std::ffi::OsStr>> {
[if self.recursive { "--rbind" } else { "--bind" }]
["--bind"]
}
async fn pre_mount(&self, mountpoint: &Path, mount_type: MountType) -> Result<(), Error> {
let from_meta = tokio::fs::metadata(&self.src).await.ok();

View File

@@ -24,11 +24,7 @@ pub async fn bind<P0: AsRef<Path>, P1: AsRef<Path>>(
) -> Result<(), Error> {
tracing::info!(
"{}",
t!(
"disk.mount.binding",
src = src.as_ref().display(),
dst = dst.as_ref().display()
)
t!("disk.mount.binding", src = src.as_ref().display(), dst = dst.as_ref().display())
);
if is_mountpoint(&dst).await? {
unmount(dst.as_ref(), true).await?;
@@ -61,24 +57,6 @@ pub async fn unmount<P: AsRef<Path>>(mountpoint: P, lazy: bool) -> Result<(), Er
Ok(())
}
/// Returns true if any mountpoints exist under (or at) the given path.
pub async fn has_mounts_under<P: AsRef<Path>>(path: P) -> Result<bool, Error> {
let path = path.as_ref();
let canonical_path = tokio::fs::canonicalize(path)
.await
.with_ctx(|_| (ErrorKind::Filesystem, lazy_format!("canonicalize {path:?}")))?;
let mounts_content = tokio::fs::read_to_string("/proc/mounts")
.await
.with_ctx(|_| (ErrorKind::Filesystem, "read /proc/mounts"))?;
Ok(mounts_content.lines().any(|line| {
line.split_whitespace()
.nth(1)
.map_or(false, |mp| Path::new(mp).starts_with(&canonical_path))
}))
}
/// Unmounts all mountpoints under (and including) the given path, in reverse
/// depth order so that nested mounts are unmounted before their parents.
#[instrument(skip_all)]

View File

@@ -4,7 +4,7 @@ use axum::http::StatusCode;
use axum::http::uri::InvalidUri;
use color_eyre::eyre::eyre;
use num_enum::TryFromPrimitive;
use patch_db::Value;
use patch_db::Revision;
use rpc_toolkit::reqwest;
use rpc_toolkit::yajrc::{
INVALID_PARAMS_ERROR, INVALID_REQUEST_ERROR, METHOD_NOT_FOUND_ERROR, PARSE_ERROR, RpcError,
@@ -16,7 +16,6 @@ use tokio_rustls::rustls;
use ts_rs::TS;
use crate::InvalidId;
use crate::prelude::to_value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, TryFromPrimitive)]
#[repr(i32)]
@@ -184,8 +183,7 @@ impl ErrorKind {
UpdateFailed => t!("error.update-failed"),
Smtp => t!("error.smtp"),
SetSysInfo => t!("error.set-sys-info"),
}
.to_string()
}.to_string()
}
}
impl Display for ErrorKind {
@@ -198,7 +196,7 @@ pub struct Error {
pub source: color_eyre::eyre::Error,
pub debug: Option<color_eyre::eyre::Error>,
pub kind: ErrorKind,
pub info: Value,
pub revision: Option<Revision>,
pub task: Option<JoinHandle<()>>,
}
@@ -229,7 +227,7 @@ impl Error {
source: source.into(),
debug,
kind,
info: Value::Null,
revision: None,
task: None,
}
}
@@ -238,7 +236,7 @@ impl Error {
source: eyre!("{}", self.source),
debug: self.debug.as_ref().map(|e| eyre!("{e}")),
kind: self.kind,
info: self.info.clone(),
revision: self.revision.clone(),
task: None,
}
}
@@ -246,10 +244,6 @@ impl Error {
self.task = Some(task);
self
}
pub fn with_info(mut self, info: Value) -> Self {
self.info = info;
self
}
pub async fn wait(mut self) -> Self {
if let Some(task) = &mut self.task {
task.await.log_err();
@@ -428,8 +422,6 @@ impl From<patch_db::value::Error> for Error {
pub struct ErrorData {
pub details: String,
pub debug: String,
#[serde(default)]
pub info: Value,
}
impl Display for ErrorData {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@@ -447,7 +439,6 @@ impl From<Error> for ErrorData {
Self {
details: value.to_string(),
debug: format!("{:?}", value),
info: value.info,
}
}
}
@@ -478,40 +469,47 @@ impl From<&RpcError> for ErrorData {
.or_else(|| d.as_str().map(|s| s.to_owned()))
})
.unwrap_or_else(|| value.message.clone().into_owned()),
info: to_value(
&value
.data
.as_ref()
.and_then(|d| d.as_object().and_then(|d| d.get("info"))),
)
.unwrap_or_default(),
}
}
}
impl From<Error> for RpcError {
fn from(e: Error) -> Self {
let kind = e.kind;
let data = ErrorData::from(e);
RpcError {
code: kind as i32,
message: kind.as_str().into(),
data: Some(match serde_json::to_value(&data) {
let mut data_object = serde_json::Map::with_capacity(3);
data_object.insert("details".to_owned(), format!("{}", e.source).into());
data_object.insert("debug".to_owned(), format!("{:?}", e.source).into());
data_object.insert(
"revision".to_owned(),
match serde_json::to_value(&e.revision) {
Ok(a) => a,
Err(e) => {
tracing::warn!("Error serializing ErrorData object: {}", e);
tracing::warn!("Error serializing revision for Error object: {}", e);
serde_json::Value::Null
}
}),
},
);
RpcError {
code: e.kind as i32,
message: e.kind.as_str().into(),
data: Some(
match serde_json::to_value(&ErrorData {
details: format!("{}", e.source),
debug: format!("{:?}", e.source),
}) {
Ok(a) => a,
Err(e) => {
tracing::warn!("Error serializing revision for Error object: {}", e);
serde_json::Value::Null
}
},
),
}
}
}
impl From<RpcError> for Error {
fn from(e: RpcError) -> Self {
let data = ErrorData::from(&e);
let info = data.info.clone();
Error::new(
data,
ErrorData::from(&e),
if let Ok(kind) = e.code.try_into() {
kind
} else if e.code == METHOD_NOT_FOUND_ERROR.code {
@@ -525,7 +523,6 @@ impl From<RpcError> for Error {
ErrorKind::Unknown
},
)
.with_info(info)
}
}
@@ -608,7 +605,7 @@ where
kind,
source,
debug,
info: Value::Null,
revision: None,
task: None,
}
})

View File

@@ -131,13 +131,9 @@ pub async fn install(
let package: GetPackageResponse = from_value(
ctx.call_remote_with::<RegistryContext, _>(
"package.get",
[("get_device_info", Value::Bool(true))]
.into_iter()
.collect(),
json!({
"id": id,
"targetVersion": VersionRange::exactly(version.deref().clone()),
"otherVersions": "none",
}),
RegistryUrlParams {
registry: registry.clone(),
@@ -485,7 +481,7 @@ pub async fn cli_install(
let mut packages: GetPackageResponse = from_value(
ctx.call_remote::<RegistryContext>(
"package.get",
json!({ "id": &id, "targetVersion": version, "sourceVersion": source_version, "otherVersions": "none" }),
json!({ "id": &id, "targetVersion": version, "sourceVersion": source_version }),
)
.await?,
)?;

View File

@@ -540,10 +540,7 @@ pub fn package<C: Context>() -> ParentHandler<C> {
.with_about("about.execute-commands-container")
.no_cli(),
)
.subcommand(
"attach",
from_fn_async_local(service::cli_attach).no_display(),
)
.subcommand("attach", from_fn_async(service::cli_attach).no_display())
.subcommand(
"host",
net::host::host_api::<C>().with_about("about.manage-network-hosts-package"),

View File

@@ -6,6 +6,7 @@ use std::str::FromStr;
use std::time::{Duration, UNIX_EPOCH};
use axum::extract::ws;
use crate::util::net::WebSocket;
use chrono::{DateTime, Utc};
use clap::builder::ValueParserFactory;
use clap::{Args, FromArgMatches, Parser};
@@ -30,7 +31,6 @@ use crate::context::{CliContext, RpcContext};
use crate::error::ResultExt;
use crate::prelude::*;
use crate::rpc_continuations::{Guid, RpcContinuation, RpcContinuations};
use crate::util::net::WebSocket;
use crate::util::serde::Reversible;
use crate::util::{FromStrParser, Invoke};
@@ -330,22 +330,12 @@ pub struct LogsParams<Extra: FromArgMatches + Args = Empty> {
extra: Extra,
#[arg(short = 'l', long = "limit", help = "help.arg.log-limit")]
limit: Option<usize>,
#[arg(
short = 'c',
long = "cursor",
conflicts_with = "follow",
help = "help.arg.log-cursor"
)]
#[arg(short = 'c', long = "cursor", conflicts_with = "follow", help = "help.arg.log-cursor")]
cursor: Option<String>,
#[arg(short = 'b', long = "boot", help = "help.arg.log-boot")]
#[serde(default)]
boot: Option<BootIdentifier>,
#[arg(
short = 'B',
long = "before",
conflicts_with = "follow",
help = "help.arg.log-before"
)]
#[arg(short = 'B', long = "before", conflicts_with = "follow", help = "help.arg.log-before")]
#[serde(default)]
before: bool,
}
@@ -563,12 +553,10 @@ pub async fn journalctl(
follow_cmd.arg("--lines=0");
}
let mut child = follow_cmd.stdout(Stdio::piped()).spawn()?;
let out = BufReader::new(child.stdout.take().ok_or_else(|| {
Error::new(
eyre!("{}", t!("logs.no-stdout-available")),
crate::ErrorKind::Journald,
)
})?);
let out =
BufReader::new(child.stdout.take().ok_or_else(|| {
Error::new(eyre!("{}", t!("logs.no-stdout-available")), crate::ErrorKind::Journald)
})?);
let journalctl_entries = LinesStream::new(out.lines());
@@ -713,10 +701,7 @@ pub async fn follow_logs<Context: AsRef<RpcContinuations>>(
RpcContinuation::ws(
move |socket| async move {
if let Err(e) = ws_handler(first_entry, stream, socket).await {
tracing::error!(
"{}",
t!("logs.error-in-log-stream", error = e.to_string())
);
tracing::error!("{}", t!("logs.error-in-log-stream", error = e.to_string()));
}
},
Duration::from_secs(30),

View File

@@ -40,10 +40,7 @@ impl LocalAuthContext for RpcContext {
}
fn unauthorized() -> Error {
Error::new(
eyre!("{}", t!("middleware.auth.unauthorized")),
crate::ErrorKind::Authorization,
)
Error::new(eyre!("{}", t!("middleware.auth.unauthorized")), crate::ErrorKind::Authorization)
}
async fn check_from_header<C: LocalAuthContext>(header: Option<&HeaderValue>) -> Result<(), Error> {

View File

@@ -244,10 +244,7 @@ impl ValidSessionToken {
C::access_sessions(db)
.as_idx_mut(session_hash)
.ok_or_else(|| {
Error::new(
eyre!("{}", t!("middleware.auth.unauthorized")),
crate::ErrorKind::Authorization,
)
Error::new(eyre!("{}", t!("middleware.auth.unauthorized")), crate::ErrorKind::Authorization)
})?
.mutate(|s| {
s.last_active = Utc::now();

View File

@@ -347,10 +347,6 @@ pub async fn call_remote<Ctx: SigningContext + AsRef<Client>>(
.with_kind(ErrorKind::Deserialization)?
.result
}
_ => Err(Error::new(
eyre!("{}", t!("middleware.auth.unknown-content-type")),
ErrorKind::Network,
)
.into()),
_ => Err(Error::new(eyre!("{}", t!("middleware.auth.unknown-content-type")), ErrorKind::Network).into()),
}
}

View File

@@ -47,13 +47,7 @@ impl Middleware<RpcContext> for SyncDb {
}
.await
{
tracing::error!(
"{}",
t!(
"middleware.db.error-writing-patch-sequence-header",
error = e
)
);
tracing::error!("{}", t!("middleware.db.error-writing-patch-sequence-header", error = e));
tracing::debug!("{e:?}");
}
}

View File

@@ -240,13 +240,7 @@ impl PortForwardController {
}
.await
{
tracing::error!(
"{}",
t!(
"net.forward.error-initializing-controller",
error = format!("{e:#}")
)
);
tracing::error!("{}", t!("net.forward.error-initializing-controller", error = format!("{e:#}")));
tracing::debug!("{e:?}");
tokio::time::sleep(Duration::from_secs(5)).await;
}

View File

@@ -171,13 +171,16 @@ where
let mut tls_handler = self.tls_handler.clone();
let mut fut = async move {
let res = async {
let mut acceptor =
LazyConfigAcceptor::new(Acceptor::default(), BackTrackingIO::new(stream));
let mut acceptor = LazyConfigAcceptor::new(
Acceptor::default(),
BackTrackingIO::new(stream),
);
let mut mid: tokio_rustls::StartHandshake<BackTrackingIO<AcceptStream>> =
match (&mut acceptor).await {
Ok(a) => a,
Err(e) => {
let mut stream = acceptor.take_io().or_not_found("acceptor io")?;
let mut stream =
acceptor.take_io().or_not_found("acceptor io")?;
let (_, buf) = stream.rewind();
if std::str::from_utf8(buf)
.ok()

View File

@@ -324,12 +324,7 @@ pub async fn list_keys(ctx: RpcContext) -> Result<BTreeSet<OnionAddress>, Error>
#[serde(rename_all = "camelCase")]
#[command(rename_all = "kebab-case")]
pub struct ResetParams {
#[arg(
name = "wipe-state",
short = 'w',
long = "wipe-state",
help = "help.arg.wipe-tor-state"
)]
#[arg(name = "wipe-state", short = 'w', long = "wipe-state", help = "help.arg.wipe-tor-state")]
wipe_state: bool,
}

View File

@@ -351,12 +351,7 @@ pub async fn list_keys(ctx: RpcContext) -> Result<BTreeSet<OnionAddress>, Error>
#[serde(rename_all = "camelCase")]
#[command(rename_all = "kebab-case")]
pub struct ResetParams {
#[arg(
name = "wipe-state",
short = 'w',
long = "wipe-state",
help = "help.arg.wipe-tor-state"
)]
#[arg(name = "wipe-state", short = 'w', long = "wipe-state", help = "help.arg.wipe-tor-state")]
wipe_state: bool,
#[arg(help = "help.arg.reset-reason")]
reason: String,

View File

@@ -94,12 +94,7 @@ impl Model<BTreeMap<Guid, SignerInfo>> {
.next()
.transpose()?
.map(|(a, _)| a)
.ok_or_else(|| {
Error::new(
eyre!("{}", t!("registry.admin.unknown-signer")),
ErrorKind::Authorization,
)
})
.ok_or_else(|| Error::new(eyre!("{}", t!("registry.admin.unknown-signer")), ErrorKind::Authorization))
}
pub fn get_signer_info(&self, key: &AnyVerifyingKey) -> Result<(Guid, SignerInfo), Error> {
@@ -109,12 +104,7 @@ impl Model<BTreeMap<Guid, SignerInfo>> {
.filter_ok(|(_, s)| s.keys.contains(key))
.next()
.transpose()?
.ok_or_else(|| {
Error::new(
eyre!("{}", t!("registry.admin.unknown-signer")),
ErrorKind::Authorization,
)
})
.ok_or_else(|| Error::new(eyre!("{}", t!("registry.admin.unknown-signer")), ErrorKind::Authorization))
}
pub fn add_signer(&mut self, signer: &SignerInfo) -> Result<Guid, Error> {
@@ -129,11 +119,7 @@ impl Model<BTreeMap<Guid, SignerInfo>> {
return Err(Error::new(
eyre!(
"{}",
t!(
"registry.admin.signer-already-exists",
guid = guid,
name = s.name
)
t!("registry.admin.signer-already-exists", guid = guid, name = s.name)
),
ErrorKind::InvalidRequest,
));

View File

@@ -44,11 +44,7 @@ const DEFAULT_REGISTRY_LISTEN: SocketAddr =
pub struct RegistryConfig {
#[arg(short = 'c', long = "config", help = "help.arg.config-file-path")]
pub config: Option<PathBuf>,
#[arg(
short = 'l',
long = "listen",
help = "help.arg.registry-listen-address"
)]
#[arg(short = 'l', long = "listen", help = "help.arg.registry-listen-address")]
pub registry_listen: Option<SocketAddr>,
#[arg(short = 'H', long = "hostname", help = "help.arg.registry-hostname")]
pub registry_hostname: Vec<InternedString>,
@@ -56,11 +52,7 @@ pub struct RegistryConfig {
pub tor_proxy: Option<Url>,
#[arg(short = 'd', long = "datadir", help = "help.arg.data-directory")]
pub datadir: Option<PathBuf>,
#[arg(
short = 'u',
long = "pg-connection-url",
help = "help.arg.postgres-connection-url"
)]
#[arg(short = 'u', long = "pg-connection-url", help = "help.arg.postgres-connection-url")]
pub pg_connection_url: Option<String>,
}
impl ContextConfig for RegistryConfig {
@@ -203,11 +195,9 @@ impl CallRemote<RegistryContext> for CliContext {
.push("v0");
url
} else {
return Err(Error::new(
eyre!("{}", t!("registry.context.registry-required")),
ErrorKind::InvalidRequest,
)
.into());
return Err(
Error::new(eyre!("{}", t!("registry.context.registry-required")), ErrorKind::InvalidRequest).into(),
);
};
if let Ok(local) = cookie {
@@ -341,10 +331,7 @@ impl SignatureAuthContext for RegistryContext {
}
}
Err(Error::new(
eyre!("{}", t!("registry.context.unauthorized")),
ErrorKind::Authorization,
))
Err(Error::new(eyre!("{}", t!("registry.context.unauthorized")), ErrorKind::Authorization))
}
async fn post_auth_hook(
&self,

View File

@@ -154,10 +154,7 @@ async fn add_asset(
})?;
Ok(())
} else {
Err(Error::new(
eyre!("{}", t!("registry.os.asset.unauthorized")),
ErrorKind::Authorization,
))
Err(Error::new(eyre!("{}", t!("registry.os.asset.unauthorized")), ErrorKind::Authorization))
}
})
.await
@@ -234,12 +231,10 @@ pub async fn cli_add_asset(
sign_phase.start();
let blake3 = file.blake3_mmap().await?;
let size = file.size().await.ok_or_else(|| {
Error::new(
eyre!("{}", t!("registry.os.asset.failed-read-metadata")),
ErrorKind::Filesystem,
)
})?;
let size = file
.size()
.await
.ok_or_else(|| Error::new(eyre!("{}", t!("registry.os.asset.failed-read-metadata")), ErrorKind::Filesystem))?;
let commitment = Blake3Commitment {
hash: Base64(*blake3.as_bytes()),
size,
@@ -341,10 +336,7 @@ async fn remove_asset(
.remove(&platform)?;
Ok(())
} else {
Err(Error::new(
eyre!("{}", t!("registry.os.asset.unauthorized")),
ErrorKind::Authorization,
))
Err(Error::new(eyre!("{}", t!("registry.os.asset.unauthorized")), ErrorKind::Authorization))
}
})
.await

View File

@@ -125,9 +125,17 @@ pub struct CliGetOsAssetParams {
pub version: Version,
#[arg(help = "help.arg.platform")]
pub platform: InternedString,
#[arg(long = "download", short = 'd', help = "help.arg.download-directory")]
#[arg(
long = "download",
short = 'd',
help = "help.arg.download-directory"
)]
pub download: Option<PathBuf>,
#[arg(long = "reverify", short = 'r', help = "help.arg.reverify-hash")]
#[arg(
long = "reverify",
short = 'r',
help = "help.arg.reverify-hash"
)]
pub reverify: bool,
}

View File

@@ -89,10 +89,7 @@ async fn sign_asset(
.contains(&guid)
{
return Err(Error::new(
eyre!(
"{}",
t!("registry.os.asset.signer-not-authorized", guid = guid)
),
eyre!("{}", t!("registry.os.asset.signer-not-authorized", guid = guid)),
ErrorKind::Authorization,
));
}
@@ -187,12 +184,10 @@ pub async fn cli_sign_asset(
sign_phase.start();
let blake3 = file.blake3_mmap().await?;
let size = file.size().await.ok_or_else(|| {
Error::new(
eyre!("{}", t!("registry.os.asset.failed-read-metadata")),
ErrorKind::Filesystem,
)
})?;
let size = file
.size()
.await
.ok_or_else(|| Error::new(eyre!("{}", t!("registry.os.asset.failed-read-metadata")), ErrorKind::Filesystem))?;
let commitment = Blake3Commitment {
hash: Base64(*blake3.as_bytes()),
size,

View File

@@ -26,6 +26,7 @@ pub fn os_api<C: Context>() -> ParentHandler<C> {
)
.subcommand(
"version",
version::version_api::<C>().with_about("about.commands-add-remove-list-versions"),
version::version_api::<C>()
.with_about("about.commands-add-remove-list-versions"),
)
}

View File

@@ -95,14 +95,7 @@ pub async fn remove_version_signer(
.mutate(|s| Ok(s.remove(&signer)))?
{
return Err(Error::new(
eyre!(
"{}",
t!(
"registry.os.version.signer-not-authorized",
signer = signer,
version = version
)
),
eyre!("{}", t!("registry.os.version.signer-not-authorized", signer = signer, version = version)),
ErrorKind::NotFound,
));
}

View File

@@ -112,10 +112,7 @@ pub async fn add_package(
Ok(())
} else {
Err(Error::new(
eyre!("{}", t!("registry.package.add.unauthorized")),
ErrorKind::Authorization,
))
Err(Error::new(eyre!("{}", t!("registry.package.add.unauthorized")), ErrorKind::Authorization))
}
})
.await
@@ -135,24 +132,20 @@ pub struct CliAddPackageParams {
}
pub async fn cli_add_package(
ctx: CliContext,
CliAddPackageParams {
file,
url,
no_verify,
}: CliAddPackageParams,
HandlerArgs {
context: ctx,
parent_method,
method,
params:
CliAddPackageParams {
file,
url,
no_verify,
},
..
}: HandlerArgs<CliContext, CliAddPackageParams>,
) -> Result<(), Error> {
let s9pk = S9pk::open(&file, None).await?;
cli_add_package_impl(ctx, s9pk, url, no_verify).await
}
pub async fn cli_add_package_impl(
ctx: CliContext,
s9pk: S9pk,
url: Vec<Url>,
no_verify: bool,
) -> Result<(), Error> {
let manifest = s9pk.as_manifest();
let progress = FullProgressTracker::new();
let mut sign_phase = progress.add_phase(InternedString::intern("Signing File"), Some(1));
@@ -174,16 +167,8 @@ pub async fn cli_add_package_impl(
Some(1),
);
let progress_task = progress.progress_bar_task(&format!(
"Adding {}@{}{} to registry...",
manifest.id,
manifest.version,
manifest
.hardware_requirements
.arch
.as_ref()
.map_or(String::new(), |a| format!(" ({})", a.iter().join("/")))
));
let progress_task =
progress.progress_bar_task(&format!("Adding {} to registry...", file.display()));
sign_phase.start();
let commitment = s9pk.as_archive().commitment().await?;
@@ -200,7 +185,7 @@ pub async fn cli_add_package_impl(
index_phase.start();
ctx.call_remote::<RegistryContext>(
"package.add",
&parent_method.into_iter().chain(method).join("."),
imbl_value::json!({
"urls": &url,
"signature": AnySignature::Ed25519(signature),
@@ -243,12 +228,8 @@ pub async fn remove_package(
}: RemovePackageParams,
) -> Result<bool, Error> {
let peek = ctx.db.peek().await;
let signer = signer.ok_or_else(|| {
Error::new(
eyre!("{}", t!("registry.package.missing-signer")),
ErrorKind::InvalidRequest,
)
})?;
let signer =
signer.ok_or_else(|| Error::new(eyre!("{}", t!("registry.package.missing-signer")), ErrorKind::InvalidRequest))?;
let signer_guid = peek.as_index().as_signers().get_signer(&signer)?;
let rev = ctx
@@ -289,10 +270,7 @@ pub async fn remove_package(
}
Ok(())
} else {
Err(Error::new(
eyre!("{}", t!("registry.package.unauthorized")),
ErrorKind::Authorization,
))
Err(Error::new(eyre!("{}", t!("registry.package.unauthorized")), ErrorKind::Authorization))
}
})
.await;
@@ -367,10 +345,7 @@ pub async fn add_mirror(
Ok(())
} else {
Err(Error::new(
eyre!("{}", t!("registry.package.add-mirror.unauthorized")),
ErrorKind::Authorization,
))
Err(Error::new(eyre!("{}", t!("registry.package.add-mirror.unauthorized")), ErrorKind::Authorization))
}
})
.await
@@ -486,12 +461,8 @@ pub async fn remove_mirror(
}: RemoveMirrorParams,
) -> Result<(), Error> {
let peek = ctx.db.peek().await;
let signer = signer.ok_or_else(|| {
Error::new(
eyre!("{}", t!("registry.package.missing-signer")),
ErrorKind::InvalidRequest,
)
})?;
let signer =
signer.ok_or_else(|| Error::new(eyre!("{}", t!("registry.package.missing-signer")), ErrorKind::InvalidRequest))?;
let signer_guid = peek.as_index().as_signers().get_signer(&signer)?;
ctx.db
@@ -530,10 +501,7 @@ pub async fn remove_mirror(
}
Ok(())
} else {
Err(Error::new(
eyre!("{}", t!("registry.package.remove-mirror.unauthorized")),
ErrorKind::Authorization,
))
Err(Error::new(eyre!("{}", t!("registry.package.remove-mirror.unauthorized")), ErrorKind::Authorization))
}
})
.await

View File

@@ -15,7 +15,6 @@ use crate::progress::{FullProgressTracker, ProgressUnits};
use crate::registry::context::RegistryContext;
use crate::registry::device_info::DeviceInfo;
use crate::registry::package::index::{PackageIndex, PackageVersionInfo};
use crate::s9pk::manifest::LocaleString;
use crate::s9pk::merkle_archive::source::ArchiveSource;
use crate::s9pk::v2::SIG_CONTEXT;
use crate::util::VersionString;
@@ -39,11 +38,11 @@ impl Default for PackageDetailLevel {
}
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[derive(Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct PackageInfoShort {
pub release_notes: LocaleString,
pub release_notes: String,
}
#[derive(Debug, Deserialize, Serialize, TS, Parser, HasModel)]
@@ -90,20 +89,17 @@ impl GetPackageResponse {
let lesser_versions: BTreeMap<_, _> = self
.other_versions
.clone()
.as_ref()
.into_iter()
.flatten()
.filter(|(v, _)| **v < *version)
.filter(|(v, _)| ***v < *version)
.collect();
if !lesser_versions.is_empty() {
table.add_row(row![bc => "OLDER VERSIONS"]);
table.add_row(row![bc => "VERSION", "RELEASE NOTES"]);
for (version, info) in lesser_versions {
table.add_row(row![
AsRef::<str>::as_ref(&version),
&info.release_notes.localized()
]);
table.add_row(row![AsRef::<str>::as_ref(version), &info.release_notes]);
}
}
@@ -151,7 +147,6 @@ fn get_matching_models(
id,
source_version,
device_info,
target_version,
..
}: &GetPackageParams,
) -> Result<Vec<(PackageId, ExtendedVersion, Model<PackageVersionInfo>)>, Error> {
@@ -170,29 +165,26 @@ fn get_matching_models(
.as_entries()?
.into_iter()
.map(|(v, info)| {
let ev = ExtendedVersion::from(v);
Ok::<_, Error>(
if target_version.as_ref().map_or(true, |tv| ev.satisfies(tv))
&& source_version.as_ref().map_or(Ok(true), |source_version| {
Ok::<_, Error>(
source_version.satisfies(
&info
.as_source_version()
.de()?
.unwrap_or(VersionRange::any()),
),
)
})?
{
if source_version.as_ref().map_or(Ok(true), |source_version| {
Ok::<_, Error>(
source_version.satisfies(
&info
.as_source_version()
.de()?
.unwrap_or(VersionRange::any()),
),
)
})? {
let mut info = info.clone();
if let Some(device_info) = &device_info {
if info.for_device(device_info)? {
Some((k.clone(), ev, info))
Some((k.clone(), ExtendedVersion::from(v), info))
} else {
None
}
} else {
Some((k.clone(), ev, info))
Some((k.clone(), ExtendedVersion::from(v), info))
}
} else {
None
@@ -215,7 +207,12 @@ pub async fn get_package(ctx: RegistryContext, params: GetPackageParams) -> Resu
for (id, version, info) in get_matching_models(&peek.as_index().as_package(), &params)? {
let package_best = best.entry(id.clone()).or_default();
let package_other = other.entry(id.clone()).or_default();
if package_best.keys().all(|k| !(**k > version)) {
if params
.target_version
.as_ref()
.map_or(true, |v| version.satisfies(v))
&& package_best.keys().all(|k| !(**k > version))
{
for worse_version in package_best
.keys()
.filter(|k| ***k < version)
@@ -572,42 +569,3 @@ pub async fn cli_download(
Ok(())
}
#[test]
fn check_matching_info_short() {
use crate::registry::package::index::PackageMetadata;
use crate::s9pk::manifest::{Alerts, Description};
use crate::util::DataUrl;
let lang_map = |s: &str| {
LocaleString::LanguageMap([("en".into(), s.into())].into_iter().collect())
};
let info = PackageVersionInfo {
metadata: PackageMetadata {
title: "Test Package".into(),
icon: DataUrl::from_vec("image/png", vec![]),
description: Description {
short: lang_map("A short description"),
long: lang_map("A longer description of the test package"),
},
release_notes: lang_map("Initial release"),
git_hash: None,
license: "MIT".into(),
wrapper_repo: "https://github.com/example/wrapper".parse().unwrap(),
upstream_repo: "https://github.com/example/upstream".parse().unwrap(),
support_site: "https://example.com/support".parse().unwrap(),
marketing_site: "https://example.com".parse().unwrap(),
donation_url: None,
docs_url: None,
alerts: Alerts::default(),
dependency_metadata: BTreeMap::new(),
os_version: exver::Version::new([0, 3, 6], []),
sdk_version: None,
hardware_acceleration: false,
},
source_version: None,
s9pks: Vec::new(),
};
from_value::<PackageInfoShort>(to_value(&info).unwrap()).unwrap();
}

View File

@@ -52,14 +52,10 @@ pub fn package_api<C: Context>() -> ParentHandler<C> {
if !changed {
tracing::warn!(
"{}",
t!(
"registry.package.remove-not-exist",
t!("registry.package.remove-not-exist",
id = args.params.id,
version = args.params.version,
sighash = args
.params
.sighash
.map_or(String::new(), |h| format!("#{h}"))
sighash = args.params.sighash.map_or(String::new(), |h| format!("#{h}"))
)
);
}
@@ -100,6 +96,7 @@ pub fn package_api<C: Context>() -> ParentHandler<C> {
)
.subcommand(
"category",
category::category_api::<C>().with_about("about.update-categories-registry"),
category::category_api::<C>()
.with_about("about.update-categories-registry"),
)
}

View File

@@ -118,14 +118,7 @@ pub async fn remove_package_signer(
.is_some()
{
return Err(Error::new(
eyre!(
"{}",
t!(
"registry.package.signer.not-authorized",
signer = signer,
id = id
)
),
eyre!("{}", t!("registry.package.signer.not-authorized", signer = signer, id = id)),
ErrorKind::NotFound,
));
}

View File

@@ -1,13 +1,10 @@
use std::ops::Deref;
use std::path::PathBuf;
use std::sync::Arc;
use clap::Parser;
use rpc_toolkit::{Empty, HandlerExt, ParentHandler, from_fn_async};
use serde::{Deserialize, Serialize};
use tokio::process::Command;
use ts_rs::TS;
use url::Url;
use crate::ImageId;
use crate::context::CliContext;
@@ -16,9 +13,9 @@ use crate::s9pk::manifest::Manifest;
use crate::s9pk::merkle_archive::source::multi_cursor_file::MultiCursorFile;
use crate::s9pk::v2::SIG_CONTEXT;
use crate::s9pk::v2::pack::ImageConfig;
use crate::util::Apply;
use crate::util::io::{TmpDir, create_file, open_file};
use crate::util::serde::{HandlerExtSerde, apply_expr};
use crate::util::{Apply, Invoke};
pub const SKIP_ENV: &[&str] = &["TERM", "container", "HOME", "HOSTNAME"];
@@ -64,12 +61,6 @@ pub fn s9pk() -> ParentHandler<CliContext> {
.no_display()
.with_about("about.convert-s9pk-v1-to-v2"),
)
.subcommand(
"publish",
from_fn_async(publish)
.no_display()
.with_about("about.publish-s9pk"),
)
}
#[derive(Deserialize, Serialize, Parser)]
@@ -265,61 +256,3 @@ async fn convert(ctx: CliContext, S9pkPath { s9pk: s9pk_path }: S9pkPath) -> Res
tokio::fs::rename(tmp_path, s9pk_path).await?;
Ok(())
}
async fn publish(ctx: CliContext, S9pkPath { s9pk: s9pk_path }: S9pkPath) -> Result<(), Error> {
let filename = s9pk_path.file_name().unwrap().to_string_lossy();
let s9pk = super::S9pk::open(&s9pk_path, None).await?;
let manifest = s9pk.as_manifest();
let path = [
manifest.id.deref(),
manifest.version.as_str(),
filename.deref(),
];
let mut s3url = ctx
.s9pk_s3base
.as_ref()
.ok_or_else(|| Error::new(eyre!("--s9pk-s3base required"), ErrorKind::InvalidRequest))?
.clone();
s3url
.path_segments_mut()
.map_err(|_| {
Error::new(
eyre!("s9pk-s3base is invalid (missing protocol?)"),
ErrorKind::ParseUrl,
)
})?
.pop_if_empty()
.extend(path);
let mut s3dest = format!(
"s3://{}",
ctx.s9pk_s3bucket
.as_deref()
.or_else(|| s3url
.host_str()
.and_then(|h| h.split_once(".").map(|h| h.0)))
.ok_or_else(|| {
Error::new(eyre!("--s9pk-s3bucket required"), ErrorKind::InvalidRequest)
})?,
)
.parse::<Url>()?;
s3dest
.path_segments_mut()
.map_err(|_| {
Error::new(
eyre!("s9pk-s3base is invalid (missing protocol?)"),
ErrorKind::ParseUrl,
)
})?
.pop_if_empty()
.extend(path);
Command::new("s3cmd")
.arg("put")
.arg("-P")
.arg(s9pk_path)
.arg(s3dest.as_str())
.capture(false)
.invoke(ErrorKind::Network)
.await?;
crate::registry::package::add::cli_add_package_impl(ctx, s9pk, vec![s3url], false).await
}

View File

@@ -7,7 +7,6 @@ use clap::Parser;
use futures::future::{BoxFuture, ready};
use futures::{FutureExt, TryStreamExt};
use imbl_value::InternedString;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use tokio::process::Command;
use tokio::sync::OnceCell;
@@ -386,17 +385,13 @@ impl ImageSource {
pub fn ingredients(&self) -> Vec<PathBuf> {
match self {
Self::Packed => Vec::new(),
Self::DockerBuild {
dockerfile,
workdir,
..
} => {
vec![dockerfile.clone().unwrap_or_else(|| {
workdir
Self::DockerBuild { dockerfile, .. } => {
vec![
dockerfile
.as_deref()
.unwrap_or(Path::new("."))
.join("Dockerfile")
})]
.unwrap_or(Path::new("Dockerfile"))
.to_owned(),
]
}
Self::DockerTag(_) => Vec::new(),
}
@@ -687,7 +682,7 @@ pub async fn pack(ctx: CliContext, params: PackParams) -> Result<(), Error> {
let manifest = s9pk.as_manifest_mut();
manifest.git_hash = Some(GitHash::from_path(params.path()).await?);
if !params.arch.is_empty() {
let arches: BTreeSet<InternedString> = match manifest.hardware_requirements.arch.take() {
let arches = match manifest.hardware_requirements.arch.take() {
Some(a) => params
.arch
.iter()
@@ -696,41 +691,10 @@ pub async fn pack(ctx: CliContext, params: PackParams) -> Result<(), Error> {
.collect(),
None => params.arch.iter().cloned().collect(),
};
if arches.is_empty() {
return Err(Error::new(
eyre!(
"none of the requested architectures ({:?}) are supported by this package",
params.arch
),
ErrorKind::InvalidRequest,
));
}
manifest.images.iter_mut().for_each(|(id, c)| {
let filtered = c
.arch
.intersection(&arches)
.cloned()
.collect::<BTreeSet<_>>();
if filtered.is_empty() {
if let Some(arch) = &c.emulate_missing_as {
tracing::warn!(
"ImageId {} is not available for {}, emulating as {}",
id,
arches.iter().join("/"),
arch
);
c.arch = [arch.clone()].into_iter().collect();
} else {
tracing::error!(
"ImageId {} is not available for {}",
id,
arches.iter().join("/"),
);
}
} else {
c.arch = filtered;
}
});
manifest
.images
.values_mut()
.for_each(|c| c.arch = c.arch.intersection(&arches).cloned().collect());
manifest.hardware_requirements.arch = Some(arches);
}

View File

@@ -102,13 +102,7 @@ pub fn update_tasks(
}
}
None => {
tracing::error!(
"{}",
t!(
"service.action.action-request-invalid-state",
task = format!("{:?}", v.task)
)
);
tracing::error!("{}", t!("service.action.action-request-invalid-state", task = format!("{:?}", v.task)));
}
},
}
@@ -157,10 +151,7 @@ impl Handler<RunAction> for ServiceActor {
.de()?;
if matches!(&action.visibility, ActionVisibility::Disabled(_)) {
return Err(Error::new(
eyre!(
"{}",
t!("service.action.action-is-disabled", action_id = action_id)
),
eyre!("{}", t!("service.action.action-is-disabled", action_id = action_id)),
ErrorKind::Action,
));
}
@@ -171,13 +162,7 @@ impl Handler<RunAction> for ServiceActor {
_ => false,
} {
return Err(Error::new(
eyre!(
"{}",
t!(
"service.action.service-not-in-allowed-status",
action_id = action_id
)
),
eyre!("{}", t!("service.action.service-not-in-allowed-status", action_id = action_id)),
ErrorKind::Action,
));
}

View File

@@ -181,10 +181,7 @@ async fn run_action(
if package_id != &context.seed.id {
return Err(Error::new(
eyre!(
"{}",
t!("service.effects.action.calling-actions-on-other-packages-unsupported")
),
eyre!("{}", t!("service.effects.action.calling-actions-on-other-packages-unsupported")),
ErrorKind::InvalidRequest,
));
context
@@ -229,10 +226,7 @@ async fn create_task(
TaskCondition::InputNotMatches => {
let Some(input) = task.input.as_ref() else {
return Err(Error::new(
eyre!(
"{}",
t!("service.effects.action.input-not-matches-requires-input")
),
eyre!("{}", t!("service.effects.action.input-not-matches-requires-input")),
ErrorKind::InvalidRequest,
));
};
@@ -250,12 +244,7 @@ async fn create_task(
else {
return Err(Error::new(
eyre!(
"{}",
t!(
"service.effects.action.action-has-no-input",
action_id = task.action_id,
package_id = task.package_id
)
"{}", t!("service.effects.action.action-has-no-input", action_id = task.action_id, package_id = task.package_id)
),
ErrorKind::InvalidRequest,
));

View File

@@ -6,8 +6,6 @@ use clap::builder::ValueParserFactory;
use exver::VersionRange;
use rust_i18n::t;
use tokio::process::Command;
use crate::db::model::package::{
CurrentDependencies, CurrentDependencyInfo, CurrentDependencyKind, ManifestPreference,
TaskEntry,
@@ -21,7 +19,7 @@ use crate::service::effects::callbacks::CallbackHandler;
use crate::service::effects::prelude::*;
use crate::service::rpc::CallbackId;
use crate::status::health_check::NamedHealthCheckResult;
use crate::util::{FromStrParser, Invoke, VersionString};
use crate::util::{FromStrParser, VersionString};
use crate::volume::data_dir;
use crate::{DATA_DIR, HealthCheckId, PackageId, ReplayId, VolumeId};
@@ -81,7 +79,7 @@ pub async fn mount(
}
IdMapped::new(
Bind::new(source).with_type(filetype).recursive(true),
Bind::new(source).with_type(filetype),
IdMap::stack(
vec![IdMap {
from_id: 0,
@@ -92,7 +90,7 @@ pub async fn mount(
),
)
.mount(
&mountpoint,
mountpoint,
if readonly {
MountType::ReadOnly
} else {
@@ -101,15 +99,6 @@ pub async fn mount(
)
.await?;
// Make the dependency mount a slave so it receives propagated mounts
// (e.g. NAS mounts from the source service) but cannot propagate
// mounts back to the source service's volume.
Command::new("mount")
.arg("--make-rslave")
.arg(&mountpoint)
.invoke(ErrorKind::Filesystem)
.await?;
Ok(())
}

View File

@@ -10,7 +10,6 @@ use crate::rpc_continuations::Guid;
use crate::service::effects::prelude::*;
use crate::service::persistent_container::Subcontainer;
use crate::util::Invoke;
use crate::util::io::write_file_owned_atomic;
pub const NVIDIA_OVERLAY_PATH: &str = "/var/tmp/startos/nvidia-overlay";
pub const NVIDIA_OVERLAY_DEBIAN: &str = "/var/tmp/startos/nvidia-overlay/debian";
@@ -95,7 +94,7 @@ pub async fn create_subcontainer_fs(
.cloned()
{
let guid = Guid::new();
let lxc_container = context
let rootfs_dir = context
.seed
.persistent_container
.lxc_container
@@ -105,9 +104,8 @@ pub async fn create_subcontainer_fs(
eyre!("PersistentContainer has been destroyed"),
ErrorKind::Incoherent,
)
})?;
let container_guid = &lxc_container.guid;
let rootfs_dir = lxc_container.rootfs_dir();
})?
.rootfs_dir();
let mountpoint = rootfs_dir
.join("media/startos/subcontainers")
.join(guid.as_ref());
@@ -156,20 +154,6 @@ pub async fn create_subcontainer_fs(
.arg(&mountpoint)
.invoke(ErrorKind::Filesystem)
.await?;
write_file_owned_atomic(
mountpoint.join("etc/hostname"),
format!("{container_guid}\n"),
100000,
100000,
)
.await?;
write_file_owned_atomic(
mountpoint.join("etc/hosts"),
format!("127.0.0.1\tlocalhost\n127.0.1.1\t{container_guid}\n::1\tlocalhost ip6-localhost ip6-loopback\n"),
100000,
100000,
)
.await?;
tracing::info!("Mounted overlay {guid} for {image_id}");
context
.seed

View File

@@ -1,6 +1,7 @@
use std::collections::BTreeMap;
use std::ffi::{OsStr, OsString, c_int};
use std::fs::File;
use std::io::{BufRead, BufReader, IsTerminal, Read};
use std::io::{IsTerminal, Read};
use std::os::unix::process::{CommandExt, ExitStatusExt};
use std::path::{Path, PathBuf};
use std::process::{Command as StdCommand, Stdio};
@@ -145,160 +146,95 @@ impl ExecParams {
let mut cmd = StdCommand::new(command);
let mut uid = Err(None);
let mut gid = Err(None);
let mut needs_home = true;
let passwd = std::fs::read_to_string(chroot.join("etc/passwd"))
.with_ctx(|_| (ErrorKind::Filesystem, "read /etc/passwd"))
.log_err()
.unwrap_or_default();
let mut home = None;
if let Some(user) = user {
if let Some((u, g)) = user.split_once(":") {
uid = Err(Some(u));
gid = Err(Some(g));
if let Some((uid, gid)) =
if let Some(uid) = user.as_deref().and_then(|u| u.parse::<u32>().ok()) {
Some((uid, uid))
} else if let Some((uid, gid)) = user
.as_deref()
.and_then(|u| u.split_once(":"))
.and_then(|(u, g)| Some((u.parse::<u32>().ok()?, g.parse::<u32>().ok()?)))
{
Some((uid, gid))
} else if let Some(user) = user {
Some(
if let Some((uid, gid)) = passwd.lines().find_map(|l| {
let l = l.trim();
let mut split = l.split(":");
if user != split.next()? {
return None;
}
split.next(); // throw away x
let uid = split.next()?.parse().ok()?;
let gid = split.next()?.parse().ok()?;
split.next(); // throw away group name
home = split.next();
Some((uid, gid))
// uid gid
}) {
(uid, gid)
} else if user == "root" {
(0, 0)
} else {
None.or_not_found(lazy_format!("{user} in /etc/passwd"))?
},
)
} else {
uid = Err(Some(user));
None
}
}
{
if home.is_none() {
home = passwd.lines().find_map(|l| {
let l = l.trim();
let mut split = l.split(":");
if let Some(u) = uid.err().flatten().and_then(|u| u.parse::<u32>().ok()) {
uid = Ok(u);
}
if let Some(g) = gid.err().flatten().and_then(|g| g.parse::<u32>().ok()) {
gid = Ok(g);
}
split.next(); // throw away user name
split.next(); // throw away x
if split.next()?.parse::<u32>().ok()? != uid {
return None;
}
split.next(); // throw away gid
split.next(); // throw away group name
let mut update_env = |line: &str| {
if let Some((k, v)) = line.split_once("=") {
needs_home &= k != "HOME";
cmd.env(k, v);
} else {
tracing::warn!("Invalid line in env: {line}");
}
split.next()
})
};
std::os::unix::fs::chown("/proc/self/fd/0", Some(uid), Some(gid)).ok();
std::os::unix::fs::chown("/proc/self/fd/1", Some(uid), Some(gid)).ok();
std::os::unix::fs::chown("/proc/self/fd/2", Some(uid), Some(gid)).ok();
cmd.uid(uid);
cmd.gid(gid);
} else {
home = Some("/root");
}
cmd.env("HOME", home.unwrap_or("/"));
let env_string = if let Some(env_file) = &env_file {
std::fs::read_to_string(env_file)
.with_ctx(|_| (ErrorKind::Filesystem, lazy_format!("read {env:?}")))?
} else {
Default::default()
};
if let Some(f) = env_file {
let mut lines = BufReader::new(
File::open(&f).with_ctx(|_| (ErrorKind::Filesystem, format!("open r {f:?}")))?,
)
.lines();
while let Some(line) = lines.next().transpose()? {
update_env(&line);
}
}
for line in env {
update_env(&line);
}
let needs_gid = Err(None) == gid;
let mut username = InternedString::intern("root");
let mut handle_passwd_line = |line: &str| -> Option<()> {
let l = line.trim();
let mut split = l.split(":");
let user = split.next()?;
match uid {
Err(Some(u)) if u != user => return None,
_ => (),
}
split.next(); // throw away x
let u: u32 = split.next()?.parse().ok()?;
match uid {
Err(Some(_)) => uid = Ok(u),
Err(None) if u == 0 => uid = Ok(u),
Ok(uid) if uid != u => return None,
_ => (),
}
username = user.into();
if !needs_gid && !needs_home {
return Some(());
}
let g = split.next()?;
if needs_gid {
gid = Ok(g.parse().ok()?);
}
if needs_home {
split.next(); // throw away group name
let home = split.next()?;
cmd.env("HOME", home);
}
Some(())
};
let mut lines = BufReader::new(
File::open(chroot.join("etc/passwd"))
.with_ctx(|_| (ErrorKind::Filesystem, format!("open r /etc/passwd")))?,
)
.lines();
while let Some(line) = lines.next().transpose()? {
if handle_passwd_line(&line).is_some() {
break;
}
}
let mut groups = Vec::new();
let mut handle_group_line = |line: &str| -> Option<()> {
let l = line.trim();
let mut split = l.split(":");
let name = split.next()?;
split.next()?; // throw away x
let g = split.next()?.parse::<u32>().ok()?;
match gid {
Err(Some(n)) if n == name => gid = Ok(g),
_ => (),
}
let users = split.next()?;
if users.split(",").any(|u| u == &*username) {
groups.push(nix::unistd::Gid::from_raw(g));
}
Some(())
};
let mut lines = BufReader::new(
File::open(chroot.join("etc/group"))
.with_ctx(|_| (ErrorKind::Filesystem, format!("open r /etc/group")))?,
)
.lines();
while let Some(line) = lines.next().transpose()? {
if handle_group_line(&line).is_none() {
tracing::warn!("Invalid /etc/group line: {line}");
}
}
let env = env_string
.lines()
.chain(env.iter().map(|l| l.as_str()))
.map(|l| l.trim())
.filter_map(|l| l.split_once("="))
.collect::<BTreeMap<_, _>>();
std::os::unix::fs::chroot(chroot)
.with_ctx(|_| (ErrorKind::Filesystem, lazy_format!("chroot {chroot:?}")))?;
if let Ok(uid) = uid {
if uid != 0 {
std::os::unix::fs::chown("/proc/self/fd/0", Some(uid), gid.ok()).ok();
std::os::unix::fs::chown("/proc/self/fd/1", Some(uid), gid.ok()).ok();
std::os::unix::fs::chown("/proc/self/fd/2", Some(uid), gid.ok()).ok();
}
}
// Handle credential changes in pre_exec to control the order:
// setgroups must happen before setgid/setuid (requires CAP_SETGID)
{
let set_uid = uid.ok();
let set_gid = gid.ok();
unsafe {
cmd.pre_exec(move || {
if !groups.is_empty() {
nix::unistd::setgroups(&groups)
.map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
}
if let Some(gid) = set_gid {
nix::unistd::setgid(nix::unistd::Gid::from_raw(gid))
.map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
}
if let Some(uid) = set_uid {
nix::unistd::setuid(nix::unistd::Uid::from_raw(uid))
.map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
}
Ok(())
});
}
}
cmd.args(args);
for (k, v) in env {
cmd.env(k, v);
}
if let Some(workdir) = workdir {
cmd.current_dir(workdir);

View File

@@ -28,6 +28,7 @@ use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
use ts_rs::TS;
use url::Url;
use crate::context::{CliContext, RpcContext};
use crate::db::model::package::{
InstalledState, ManifestPreference, PackageState, PackageStateMatchModelRef, TaskSeverity,
@@ -50,7 +51,6 @@ use crate::util::io::{AsyncReadStream, AtomicFile, TermSize, delete_file};
use crate::util::net::WebSocket;
use crate::util::serde::Pem;
use crate::util::sync::SyncMutex;
use crate::util::tui::choose;
use crate::volume::data_dir;
use crate::{ActionId, CAP_1_KiB, DATA_DIR, HostId, ImageId, PackageId};
@@ -184,10 +184,7 @@ impl ServiceRef {
Arc::try_unwrap(service.seed)
.map_err(|_| {
Error::new(
eyre!(
"{}",
t!("service.mod.service-actor-seed-held-after-shutdown")
),
eyre!("{}", t!("service.mod.service-actor-seed-held-after-shutdown")),
ErrorKind::Unknown,
)
})?
@@ -379,16 +376,12 @@ impl Service {
{
Ok(PackageState::Installed(InstalledState { manifest }))
} else {
Err(Error::new(
eyre!("{}", t!("service.mod.race-condition-detected")),
ErrorKind::Database,
))
Err(Error::new(eyre!("{}", t!("service.mod.race-condition-detected")), ErrorKind::Database))
}
})
}
})
.await
.result?;
.await.result?;
handle_installed(s9pk).await
}
PackageStateMatchModelRef::Removing(_) | PackageStateMatchModelRef::Restoring(_) => {
@@ -454,13 +447,7 @@ impl Service {
handle_installed(S9pk::open(s9pk_path, Some(id)).await?).await
}
PackageStateMatchModelRef::Error(e) => Err(Error::new(
eyre!(
"{}",
t!(
"service.mod.failed-to-parse-package-data-entry",
error = format!("{e:?}")
)
),
eyre!("{}", t!("service.mod.failed-to-parse-package-data-entry", error = format!("{e:?}"))),
ErrorKind::Deserialization,
)),
}
@@ -566,11 +553,7 @@ impl Service {
true
} else {
tracing::warn!(
"{}",
t!(
"service.mod.deleting-task-action-no-longer-exists",
id = id
)
"{}", t!("service.mod.deleting-task-action-no-longer-exists", id = id)
);
false
}
@@ -710,19 +693,6 @@ pub async fn rebuild(ctx: RpcContext, RebuildParams { id }: RebuildParams) -> Re
Ok(())
}
#[derive(Debug, Deserialize, Serialize)]
pub struct SubcontainerInfo {
pub id: Guid,
pub name: InternedString,
pub image_id: ImageId,
}
impl std::fmt::Display for SubcontainerInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let SubcontainerInfo { id, name, image_id } = self;
write!(f, "{id} => Name: {name}; Image: {image_id}")
}
}
#[derive(Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct AttachParams {
@@ -736,7 +706,7 @@ pub struct AttachParams {
#[serde(rename = "__Auth_session")]
session: Option<InternedString>,
#[ts(type = "string | null")]
subcontainer: Option<Guid>,
subcontainer: Option<InternedString>,
#[ts(type = "string | null")]
name: Option<InternedString>,
#[ts(type = "string | null")]
@@ -759,7 +729,7 @@ pub async fn attach(
user,
}: AttachParams,
) -> Result<Guid, Error> {
let (container_id, subcontainer_id, image_id, user, workdir, root_command) = {
let (container_id, subcontainer_id, image_id, workdir, root_command) = {
let id = &id;
let service = ctx.services.get(id).await;
@@ -800,6 +770,13 @@ pub async fn attach(
}
})
.collect();
let format_subcontainer_pair = |(guid, wrapper): (&Guid, &Subcontainer)| {
format!(
"{guid} imageId: {image_id} name: \"{name}\"",
name = &wrapper.name,
image_id = &wrapper.image_id
)
};
let Some((subcontainer_id, image_id)) = subcontainer_ids
.first()
.map::<(Guid, ImageId), _>(|&x| (x.0.clone(), x.1.image_id.clone()))
@@ -810,17 +787,14 @@ pub async fn attach(
.lock()
.await
.iter()
.map(|(g, s)| SubcontainerInfo {
id: g.clone(),
name: s.name.clone(),
image_id: s.image_id.clone(),
})
.collect::<Vec<_>>();
.map(format_subcontainer_pair)
.join("\n");
return Err(Error::new(
eyre!("{}", t!("service.mod.no-matching-subcontainers", id = id)),
eyre!(
"{}", t!("service.mod.no-matching-subcontainers", id = id, subcontainers = subcontainers)
),
ErrorKind::NotFound,
)
.with_info(to_value(&subcontainers)?));
));
};
let passwd = root_dir
@@ -840,39 +814,31 @@ pub async fn attach(
)
.with_kind(ErrorKind::Deserialization)?;
let user = user
.clone()
.or_else(|| image_meta["user"].as_str().map(InternedString::intern))
.unwrap_or_else(|| InternedString::intern("root"));
let root_command = get_passwd_command(passwd, &*user).await;
let root_command = get_passwd_command(
passwd,
user.as_deref()
.or_else(|| image_meta["user"].as_str())
.unwrap_or("root"),
)
.await;
let workdir = image_meta["workdir"].as_str().map(|s| s.to_owned());
if subcontainer_ids.len() > 1 {
let subcontainers = subcontainer_ids
let subcontainer_ids = subcontainer_ids
.into_iter()
.map(|(g, s)| SubcontainerInfo {
id: g.clone(),
name: s.name.clone(),
image_id: s.image_id.clone(),
})
.collect::<Vec<_>>();
.map(format_subcontainer_pair)
.join("\n");
return Err(Error::new(
eyre!(
"{}",
t!("service.mod.multiple-subcontainers-found", id = id,)
),
eyre!("{}", t!("service.mod.multiple-subcontainers-found", id = id, subcontainer_ids = subcontainer_ids)),
ErrorKind::InvalidRequest,
)
.with_info(to_value(&subcontainers)?));
));
}
(
service_ref.container_id()?,
subcontainer_id,
image_id,
user.into(),
workdir,
root_command,
)
@@ -889,7 +855,7 @@ pub async fn attach(
pty_size: Option<TermSize>,
image_id: ImageId,
workdir: Option<String>,
user: InternedString,
user: Option<InternedString>,
root_command: &RootCommand,
) -> Result<(), Error> {
use axum::extract::ws::Message;
@@ -910,9 +876,11 @@ pub async fn attach(
Path::new("/media/startos/images")
.join(image_id)
.with_extension("env"),
)
.arg("--user")
.arg(&*user);
);
if let Some(user) = user {
cmd.arg("--user").arg(&*user);
}
if let Some(workdir) = workdir {
cmd.arg("--workdir").arg(workdir);
@@ -1095,6 +1063,45 @@ pub async fn attach(
Ok(guid)
}
#[derive(Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct ListSubcontainersParams {
pub id: PackageId,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct SubcontainerInfo {
pub name: InternedString,
pub image_id: ImageId,
}
pub async fn list_subcontainers(
ctx: RpcContext,
ListSubcontainersParams { id }: ListSubcontainersParams,
) -> Result<BTreeMap<Guid, SubcontainerInfo>, Error> {
let service = ctx.services.get(&id).await;
let service_ref = service.as_ref().or_not_found(&id)?;
let container = &service_ref.seed.persistent_container;
let subcontainers = container.subcontainers.lock().await;
let result: BTreeMap<Guid, SubcontainerInfo> = subcontainers
.iter()
.map(|(guid, subcontainer)| {
(
guid.clone(),
SubcontainerInfo {
name: subcontainer.name.clone(),
image_id: subcontainer.image_id.clone(),
},
)
})
.collect();
Ok(result)
}
async fn get_passwd_command(etc_passwd_path: PathBuf, user: &str) -> RootCommand {
async {
let mut file = tokio::fs::File::open(etc_passwd_path).await?;
@@ -1113,13 +1120,7 @@ async fn get_passwd_command(etc_passwd_path: PathBuf, user: &str) -> RootCommand
}
}
Err(Error::new(
eyre!(
"{}",
t!(
"service.mod.could-not-parse-etc-passwd",
contents = contents
)
),
eyre!("{}", t!("service.mod.could-not-parse-etc-passwd", contents = contents)),
ErrorKind::Filesystem,
))
}
@@ -1175,34 +1176,23 @@ pub async fn cli_attach(
None
};
let method = parent_method.into_iter().chain(method).join(".");
let mut params = json!({
"id": params.id,
"command": params.command,
"tty": tty,
"stderrTty": stderr.is_terminal(),
"ptySize": if tty { TermSize::get_current() } else { None },
"subcontainer": params.subcontainer,
"imageId": params.image_id,
"name": params.name,
"user": params.user,
});
let guid: Guid = from_value(
match context
.call_remote::<RpcContext>(&method, params.clone())
.await
{
Ok(a) => a,
Err(e) => {
let prompt = e.to_string();
let options: Vec<SubcontainerInfo> = from_value(e.info)?;
let choice = choose(&prompt, &options).await?;
params["subcontainer"] = to_value(&choice.id)?;
context
.call_remote::<RpcContext>(&method, params.clone())
.await?
}
},
context
.call_remote::<RpcContext>(
&parent_method.into_iter().chain(method).join("."),
json!({
"id": params.id,
"command": params.command,
"tty": tty,
"stderrTty": stderr.is_terminal(),
"ptySize": if tty { TermSize::get_current() } else { None },
"subcontainer": params.subcontainer,
"imageId": params.image_id,
"name": params.name,
"user": params.user,
}),
)
.await?,
)?;
let mut ws = context.ws_continuation(guid).await?;

View File

@@ -20,7 +20,6 @@ use crate::disk::mount::filesystem::loop_dev::LoopDev;
use crate::disk::mount::filesystem::overlayfs::OverlayGuard;
use crate::disk::mount::filesystem::{MountType, ReadOnly};
use crate::disk::mount::guard::{GenericMountGuard, MountGuard};
use crate::disk::mount::util::{is_mountpoint, unmount};
use crate::lxc::{HOST_RPC_SERVER_SOCKET, LxcConfig, LxcContainer};
use crate::net::net_controller::NetService;
use crate::prelude::*;
@@ -77,7 +76,6 @@ pub struct PersistentContainer {
pub(super) rpc_client: UnixRpcClient,
pub(super) rpc_server: watch::Sender<Option<(NonDetachingJoinHandle<()>, ShutdownHandle)>>,
js_mount: MountGuard,
host_volume_binds: BTreeMap<VolumeId, MountGuard>,
volumes: BTreeMap<VolumeId, MountGuard>,
assets: Vec<MountGuard>,
pub(super) images: BTreeMap<ImageId, Arc<MountGuard>>,
@@ -122,7 +120,6 @@ impl PersistentContainer {
.is_ok();
let mut volumes = BTreeMap::new();
let mut host_volume_binds = BTreeMap::new();
// TODO: remove once packages are reconverted
let added = if is_compat {
@@ -131,35 +128,13 @@ impl PersistentContainer {
BTreeSet::default()
};
for volume in s9pk.as_manifest().volumes.union(&added) {
let host_volume_dir = data_dir(DATA_DIR, &s9pk.as_manifest().id, volume);
// Self-bind the host volume directory and mark it rshared so that
// mounts created inside the container (e.g. NAS mounts from
// postinit.sh) propagate back to the host path and are visible to
// dependent services that bind-mount the same volume.
if is_mountpoint(&host_volume_dir).await? {
unmount(&host_volume_dir, true).await?;
}
let host_bind = MountGuard::mount(
&Bind::new(&host_volume_dir),
&host_volume_dir,
MountType::ReadWrite,
)
.await?;
Command::new("mount")
.arg("--make-rshared")
.arg(&host_volume_dir)
.invoke(ErrorKind::Filesystem)
.await?;
host_volume_binds.insert(volume.clone(), host_bind);
let mountpoint = lxc_container
.rootfs_dir()
.join("media/startos/volumes")
.join(volume);
let mount = MountGuard::mount(
&IdMapped::new(
Bind::new(&host_volume_dir),
Bind::new(data_dir(DATA_DIR, &s9pk.as_manifest().id, volume)),
vec![IdMap {
from_id: 0,
to_id: 100000,
@@ -321,7 +296,6 @@ impl PersistentContainer {
rpc_server: watch::channel(None).0,
// procedures: Default::default(),
js_mount,
host_volume_binds,
volumes,
assets,
images,
@@ -390,14 +364,7 @@ impl PersistentContainer {
let handle = NonDetachingJoinHandle::from(tokio::spawn(async move {
let chown_status = async {
let res = server.run_unix(&path, |err| {
tracing::error!(
"{}",
t!(
"service.persistent-container.error-on-unix-socket",
path = path.display(),
error = err
)
)
tracing::error!("{}", t!("service.persistent-container.error-on-unix-socket", path = path.display(), error = err))
})?;
Command::new("chown")
.arg("100000:100000")
@@ -419,10 +386,7 @@ impl PersistentContainer {
}));
let shutdown = recv.await.map_err(|_| {
Error::new(
eyre!(
"{}",
t!("service.persistent-container.unix-socket-server-panicked")
),
eyre!("{}", t!("service.persistent-container.unix-socket-server-panicked")),
ErrorKind::Unknown,
)
})??;
@@ -465,7 +429,6 @@ impl PersistentContainer {
let rpc_server = self.rpc_server.send_replace(None);
let js_mount = self.js_mount.take();
let volumes = std::mem::take(&mut self.volumes);
let host_volume_binds = std::mem::take(&mut self.host_volume_binds);
let assets = std::mem::take(&mut self.assets);
let images = std::mem::take(&mut self.images);
let subcontainers = self.subcontainers.clone();
@@ -488,11 +451,6 @@ impl PersistentContainer {
for (_, volume) in volumes {
errs.handle(volume.unmount(true).await);
}
// Unmount host-side shared binds after the rootfs-side volume
// mounts. Use delete_mountpoint=false to preserve the data dirs.
for (_, host_bind) in host_volume_binds {
errs.handle(host_bind.unmount(false).await);
}
for assets in assets {
errs.handle(assets.unmount(true).await);
}
@@ -515,13 +473,7 @@ impl PersistentContainer {
if let Some(destroy) = self.destroy(uninit) {
destroy.await?;
}
tracing::info!(
"{}",
t!(
"service.persistent-container.service-exited",
id = self.s9pk.as_manifest().id
)
);
tracing::info!("{}", t!("service.persistent-container.service-exited", id = self.s9pk.as_manifest().id));
Ok(())
}

View File

@@ -47,18 +47,9 @@ impl Actor for ServiceActor {
}
.await
{
tracing::error!(
"{}",
t!("service.service-actor.error-synchronizing-state", error = e)
);
tracing::error!("{}", t!("service.service-actor.error-synchronizing-state", error = e));
tracing::debug!("{e:?}");
tracing::error!(
"{}",
t!(
"service.service-actor.retrying-in-seconds",
seconds = SYNC_RETRY_COOLDOWN_SECONDS
)
);
tracing::error!("{}", t!("service.service-actor.retrying-in-seconds", seconds = SYNC_RETRY_COOLDOWN_SECONDS));
tokio::time::timeout(
Duration::from_secs(SYNC_RETRY_COOLDOWN_SECONDS),
async {

View File

@@ -4,7 +4,6 @@ use imbl::vector;
use crate::context::RpcContext;
use crate::db::model::package::{InstalledState, InstallingInfo, InstallingState, PackageState};
use crate::disk::mount::util::{has_mounts_under, unmount_all_under};
use crate::prelude::*;
use crate::volume::PKG_VOLUME_DIR;
use crate::{DATA_DIR, PACKAGE_DATA, PackageId};
@@ -63,13 +62,7 @@ pub async fn cleanup(ctx: &RpcContext, id: &PackageId, soft: bool) -> Result<(),
| PackageState::Removing(InstalledState { manifest }) => manifest,
s => {
return Err(Error::new(
eyre!(
"{}",
t!(
"service.uninstall.invalid-package-state-for-cleanup",
state = format!("{s:?}")
)
),
eyre!("{}", t!("service.uninstall.invalid-package-state-for-cleanup", state = format!("{s:?}"))),
ErrorKind::InvalidRequest,
));
}
@@ -82,22 +75,6 @@ pub async fn cleanup(ctx: &RpcContext, id: &PackageId, soft: bool) -> Result<(),
if !soft {
let path = Path::new(DATA_DIR).join(PKG_VOLUME_DIR).join(&manifest.id);
if tokio::fs::metadata(&path).await.is_ok() {
// Best-effort cleanup of any propagated mounts (e.g. NAS)
// that survived container destroy or were never cleaned up
// (force-uninstall skips destroy entirely).
unmount_all_under(&path, true).await.log_err();
// Hard check: refuse to delete if mounts are still active,
// to avoid traversing into a live NFS/NAS mount.
if has_mounts_under(&path).await? {
return Err(Error::new(
eyre!(
"Refusing to remove {}: active mounts remain under this path. \
Unmount them manually and retry.",
path.display()
),
ErrorKind::Filesystem,
));
}
tokio::fs::remove_dir_all(&path).await?;
}
let logs_dir = Path::new(PACKAGE_DATA).join("logs").join(&manifest.id);

View File

@@ -1,3 +1,4 @@
use crate::PLATFORM;
use crate::context::RpcContext;
use crate::disk::main::export;
@@ -35,33 +36,18 @@ impl Shutdown {
.invoke(crate::ErrorKind::Journald)
.await
{
tracing::error!(
"{}",
t!("shutdown.error-stopping-journald", error = e.to_string())
);
tracing::error!("{}", t!("shutdown.error-stopping-journald", error = e.to_string()));
tracing::debug!("{:?}", e);
}
if let Some(guid) = &self.disk_guid {
if let Err(e) = export(guid, crate::DATA_DIR).await {
tracing::error!(
"{}",
t!(
"shutdown.error-exporting-volume-group",
error = e.to_string()
)
);
tracing::error!("{}", t!("shutdown.error-exporting-volume-group", error = e.to_string()));
tracing::debug!("{:?}", e);
}
}
if &*PLATFORM != "raspberrypi" || self.restart {
if let Err(e) = SHUTDOWN.play().await {
tracing::error!(
"{}",
t!(
"shutdown.error-playing-shutdown-song",
error = e.to_string()
)
);
tracing::error!("{}", t!("shutdown.error-playing-shutdown-song", error = e.to_string()));
tracing::debug!("{:?}", e);
}
}

View File

@@ -19,7 +19,8 @@ pub fn tunnel_api<C: Context>() -> ParentHandler<C> {
.subcommand("web", super::web::web_api::<C>())
.subcommand(
"db",
super::db::db_api::<C>().with_about("about.commands-interact-with-db-dump-apply"),
super::db::db_api::<C>()
.with_about("about.commands-interact-with-db-dump-apply"),
)
.subcommand(
"auth",

View File

@@ -524,26 +524,26 @@ pub async fn init_web(ctx: CliContext) -> Result<(), Error> {
"To access your Web URL securely, trust your Root CA (displayed above) on your client device(s):\n",
" - MacOS\n",
" 1. Open the Terminal app\n",
" 2. Paste the following command (**DO NOT** click Return): pbcopy < ~/Desktop/ca.crt\n",
" 2. Paste the following command (**DO NOTt** click Return): pbcopy < ~/Desktop/ca.crt\n",
" 3. Copy your Root CA (including -----BEGIN CERTIFICATE----- and -----END CERTIFICATE-----)\n",
" 4. Back in Terminal, click Return. ca.crt is saved to your Desktop\n",
" 5. Complete by trusting your Root CA: https://staging.docs.start9.com/device-guides/mac/ca.html\n",
" 5. Complete by trusting your Root CA: https://https://staging.docs.start9.com/device-guides/mac/ca.html\n",
" - Linux\n",
" 1. Open gedit, nano, or any editor\n",
" 2. Copy/paste your Root CA (including -----BEGIN CERTIFICATE----- and -----END CERTIFICATE-----)\n",
" 3. Name the file ca.crt and save as plaintext\n",
" 5. Complete by trusting your Root CA: https://staging.docs.start9.com/device-guides/linux/ca.html\n",
" 5. Complete by trusting your Root CA: https://https://staging.docs.start9.com/device-guides/linux/ca.html\n",
" - Windows\n",
" 1. Open the Notepad app\n",
" 2. Copy/paste your Root CA (including -----BEGIN CERTIFICATE----- and -----END CERTIFICATE-----)\n",
" 3. Name the file ca.crt and save as plaintext\n",
" 5. Complete by trusting your Root CA: https://staging.docs.start9.com/device-guides/windows/ca.html\n",
" 5. Complete by trusting your Root CA: https://https://staging.docs.start9.com/device-guides/windows/ca.html\n",
" - Android/Graphene\n",
" 1. Send the ca.crt file (created above) to yourself\n",
" 2. Complete by trusting your Root CA: https://staging.docs.start9.com/device-guides/android/ca.html\n",
" 2. Complete by trusting your Root CA: https://https://staging.docs.start9.com/device-guides/android/ca.html\n",
" - iOS\n",
" 1. Send the ca.crt file (created above) to yourself\n",
" 2. Complete by trusting your Root CA: https://staging.docs.start9.com/device-guides/ios/ca.html\n",
" 2. Complete by trusting your Root CA: https://https://staging.docs.start9.com/device-guides/ios/ca.html\n",
));
return Ok(());

View File

@@ -6,7 +6,6 @@ use clap::{ArgAction, Parser};
use color_eyre::eyre::{Result, eyre};
use exver::{Version, VersionRange};
use futures::TryStreamExt;
use imbl::OrdMap;
use imbl_value::json;
use itertools::Itertools;
use patch_db::json_ptr::JsonPointer;
@@ -180,10 +179,7 @@ pub async fn cli_update_system(
Some(v) => {
if let Some(progress) = res.progress {
let mut ws = context.ws_continuation(progress).await?;
let mut progress = PhasedProgressBar::new(&t!(
"update.updating-to-version",
version = v.to_string()
));
let mut progress = PhasedProgressBar::new(&t!("update.updating-to-version", version = v.to_string()));
let mut prev = None;
while let Some(msg) = ws.try_next().await.with_kind(ErrorKind::Network)? {
if let tokio_tungstenite::tungstenite::Message::Text(msg) = msg {
@@ -206,10 +202,7 @@ pub async fn cli_update_system(
}
println!("{}", t!("update.complete-restart-to-apply"))
} else {
println!(
"{}",
t!("update.updating-to-version", version = v.to_string())
)
println!("{}", t!("update.updating-to-version", version = v.to_string()))
}
}
}
@@ -246,7 +239,6 @@ async fn maybe_do_update(
let mut available = from_value::<BTreeMap<Version, OsVersionInfo>>(
ctx.call_remote_with::<RegistryContext, _>(
"os.version.get",
OrdMap::new(),
json!({
"source": current_version,
"target": target,

View File

@@ -248,7 +248,7 @@ impl<'a> Invoke<'a> for ExtendedCommand<'a> {
.or(Some(&res.stdout))
.filter(|a| !a.is_empty())
.and_then(|a| std::str::from_utf8(a).ok())
.unwrap_or(&format!("{} exited with {}", cmd_str, res.status))
.unwrap_or(&format!("{} exited with code {}", cmd_str, res.status))
);
Ok(res.stdout)
} else {
@@ -309,7 +309,7 @@ impl<'a> Invoke<'a> for ExtendedCommand<'a> {
.filter(|a| !a.is_empty())
.and_then(|a| std::str::from_utf8(a).ok())
.unwrap_or(&format!(
"{} exited with {}",
"{} exited with code {}",
cmd.as_std().get_program().to_string_lossy(),
res.status
))

View File

@@ -97,11 +97,7 @@ impl WebSocket {
if self.ping_state.is_some() {
self.fused = true;
break Poll::Ready(Some(Err(axum::Error::new(eyre!(
"{}",
t!(
"util.net.websocket-ping-timeout",
timeout = format!("{PING_TIMEOUT:?}")
)
"{}", t!("util.net.websocket-ping-timeout", timeout = format!("{PING_TIMEOUT:?}"))
)))));
}
self.ping_state = Some((false, rand::random()));

View File

@@ -1151,13 +1151,7 @@ pub fn apply_expr(input: jaq_core::Val, expr: &str) -> Result<jaq_core::Val, Err
let Some(expr) = expr else {
return Err(Error::new(
eyre!(
"{}",
t!(
"util.serde.failed-to-parse-expression",
errors = format!("{:?}", errs)
)
),
eyre!("{}", t!("util.serde.failed-to-parse-expression", errors = format!("{:?}", errs))),
crate::ErrorKind::InvalidRequest,
));
};
@@ -1173,13 +1167,7 @@ pub fn apply_expr(input: jaq_core::Val, expr: &str) -> Result<jaq_core::Val, Err
if !errs.is_empty() {
return Err(Error::new(
eyre!(
"{}",
t!(
"util.serde.failed-to-compile-expression",
errors = format!("{:?}", errs)
)
),
eyre!("{}", t!("util.serde.failed-to-compile-expression", errors = format!("{:?}", errs))),
crate::ErrorKind::InvalidRequest,
));
};

View File

@@ -50,10 +50,7 @@ pub async fn prompt<T, E: std::fmt::Display, Parse: FnMut(&str) -> Result<T, E>>
}
}
ReadlineEvent::Eof | ReadlineEvent::Interrupted => {
return Err(Error::new(
eyre!("{}", t!("util.tui.aborted")),
ErrorKind::Cancelled,
));
return Err(Error::new(eyre!("{}", t!("util.tui.aborted")), ErrorKind::Cancelled));
}
_ => (),
}
@@ -86,10 +83,7 @@ pub async fn prompt_multiline<
Err(e) => writeln!(&mut rl_ctx.shared_writer, "{e}")?,
},
ReadlineEvent::Eof | ReadlineEvent::Interrupted => {
return Err(Error::new(
eyre!("{}", t!("util.tui.aborted")),
ErrorKind::Cancelled,
));
return Err(Error::new(eyre!("{}", t!("util.tui.aborted")), ErrorKind::Cancelled));
}
_ => (),
}
@@ -125,10 +119,7 @@ pub async fn choose_custom_display<'t, T>(
.await
.map_err(map_miette)?;
if choice.len() < 1 {
return Err(Error::new(
eyre!("{}", t!("util.tui.aborted")),
ErrorKind::Cancelled,
));
return Err(Error::new(eyre!("{}", t!("util.tui.aborted")), ErrorKind::Cancelled));
}
let (idx, choice_str) = string_choices
.iter()

View File

@@ -58,9 +58,8 @@ mod v0_4_0_alpha_15;
mod v0_4_0_alpha_16;
mod v0_4_0_alpha_17;
mod v0_4_0_alpha_18;
mod v0_4_0_alpha_19;
pub type Current = v0_4_0_alpha_19::Version; // VERSION_BUMP
pub type Current = v0_4_0_alpha_18::Version; // VERSION_BUMP
impl Current {
#[instrument(skip(self, db))]
@@ -180,8 +179,7 @@ enum Version {
V0_4_0_alpha_15(Wrapper<v0_4_0_alpha_15::Version>),
V0_4_0_alpha_16(Wrapper<v0_4_0_alpha_16::Version>),
V0_4_0_alpha_17(Wrapper<v0_4_0_alpha_17::Version>),
V0_4_0_alpha_18(Wrapper<v0_4_0_alpha_18::Version>),
V0_4_0_alpha_19(Wrapper<v0_4_0_alpha_19::Version>), // VERSION_BUMP
V0_4_0_alpha_18(Wrapper<v0_4_0_alpha_18::Version>), // VERSION_BUMP
Other(exver::Version),
}
@@ -242,8 +240,7 @@ impl Version {
Self::V0_4_0_alpha_15(v) => DynVersion(Box::new(v.0)),
Self::V0_4_0_alpha_16(v) => DynVersion(Box::new(v.0)),
Self::V0_4_0_alpha_17(v) => DynVersion(Box::new(v.0)),
Self::V0_4_0_alpha_18(v) => DynVersion(Box::new(v.0)),
Self::V0_4_0_alpha_19(v) => DynVersion(Box::new(v.0)), // VERSION_BUMP
Self::V0_4_0_alpha_18(v) => DynVersion(Box::new(v.0)), // VERSION_BUMP
Self::Other(v) => {
return Err(Error::new(
eyre!("unknown version {v}"),
@@ -296,8 +293,7 @@ impl Version {
Version::V0_4_0_alpha_15(Wrapper(x)) => x.semver(),
Version::V0_4_0_alpha_16(Wrapper(x)) => x.semver(),
Version::V0_4_0_alpha_17(Wrapper(x)) => x.semver(),
Version::V0_4_0_alpha_18(Wrapper(x)) => x.semver(),
Version::V0_4_0_alpha_19(Wrapper(x)) => x.semver(), // VERSION_BUMP
Version::V0_4_0_alpha_18(Wrapper(x)) => x.semver(), // VERSION_BUMP
Version::Other(x) => x.clone(),
}
}

View File

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

View File

@@ -16,14 +16,14 @@ import {
MountParams,
StatusInfo,
Manifest,
} from './osBindings'
} from "./osBindings"
import {
PackageId,
Dependencies,
ServiceInterfaceId,
SmtpValue,
ActionResult,
} from './types'
} from "./types"
/** Used to reach out from the pure js runtime */
@@ -155,13 +155,13 @@ export type Effects = {
/** Returns a PEM encoded fullchain for the hostnames specified */
getSslCertificate: (options: {
hostnames: string[]
algorithm?: 'ecdsa' | 'ed25519'
algorithm?: "ecdsa" | "ed25519"
callback?: () => void
}) => Promise<[string, string, string]>
/** Returns a PEM encoded private key corresponding to the certificate for the hostnames specified */
getSslKey: (options: {
hostnames: string[]
algorithm?: 'ecdsa' | 'ed25519'
algorithm?: "ecdsa" | "ed25519"
}) => Promise<string>
/** sets the version that this service's data has been migrated to */

View File

@@ -1,7 +1,7 @@
import * as T from '../types'
import * as IST from '../actions/input/inputSpecTypes'
import { Action, ActionInfo } from './setupActions'
import { ExtractInputSpecType } from './input/builder/inputSpec'
import * as T from "../types"
import * as IST from "../actions/input/inputSpecTypes"
import { Action, ActionInfo } from "./setupActions"
import { ExtractInputSpecType } from "./input/builder/inputSpec"
export type RunActionInput<Input> =
| Input
@@ -53,17 +53,17 @@ type TaskBase = {
replayId?: string
}
type TaskInput<T extends ActionInfo<T.ActionId, any>> = {
kind: 'partial'
kind: "partial"
value: T.DeepPartial<GetActionInputType<T>>
}
export type TaskOptions<T extends ActionInfo<T.ActionId, any>> = TaskBase &
(
| {
when?: Exclude<T.TaskTrigger, { condition: 'input-not-matches' }>
when?: Exclude<T.TaskTrigger, { condition: "input-not-matches" }>
input?: TaskInput<T>
}
| {
when: T.TaskTrigger & { condition: 'input-not-matches' }
when: T.TaskTrigger & { condition: "input-not-matches" }
input: TaskInput<T>
}
)

View File

@@ -1,6 +1,6 @@
import { InputSpec } from './inputSpec'
import { List } from './list'
import { Value } from './value'
import { Variants } from './variants'
import { InputSpec } from "./inputSpec"
import { List } from "./list"
import { Value } from "./value"
import { Variants } from "./variants"
export { InputSpec as InputSpec, List, Value, Variants }

View File

@@ -1,9 +1,9 @@
import { ValueSpec } from '../inputSpecTypes'
import { Value } from './value'
import { _ } from '../../../util'
import { Effects } from '../../../Effects'
import { Parser, object } from 'ts-matches'
import { DeepPartial } from '../../../types'
import { ValueSpec } from "../inputSpecTypes"
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

View File

@@ -1,4 +1,4 @@
import { InputSpec, LazyBuild } from './inputSpec'
import { InputSpec, LazyBuild } from "./inputSpec"
import {
ListValueSpecText,
Pattern,
@@ -6,8 +6,8 @@ import {
UniqueBy,
ValueSpecList,
ValueSpecListOf,
} from '../inputSpecTypes'
import { Parser, arrayOf, string } from 'ts-matches'
} from "../inputSpecTypes"
import { Parser, arrayOf, string } from "ts-matches"
export class List<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
private constructor(
@@ -55,7 +55,7 @@ export class List<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
* @description Informs the browser how to behave and which keyboard to display on mobile
* @default "text"
*/
inputmode?: ListValueSpecText['inputmode']
inputmode?: ListValueSpecText["inputmode"]
/**
* @description Displays a button that will generate a random string according to the provided charset and len attributes.
*/
@@ -65,21 +65,21 @@ export class List<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
const validator = arrayOf(string)
return new List<string[]>(() => {
const spec = {
type: 'text' as const,
type: "text" as const,
placeholder: null,
minLength: null,
maxLength: null,
masked: false,
inputmode: 'text' as const,
inputmode: "text" as const,
generate: null,
patterns: aSpec.patterns || [],
...aSpec,
}
const built: ValueSpecListOf<'text'> = {
const built: ValueSpecListOf<"text"> = {
description: null,
warning: null,
default: [],
type: 'list' as const,
type: "list" as const,
minLength: null,
maxLength: null,
disabled: false,
@@ -106,7 +106,7 @@ export class List<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
minLength?: number | null
maxLength?: number | null
patterns?: Pattern[]
inputmode?: ListValueSpecText['inputmode']
inputmode?: ListValueSpecText["inputmode"]
}
}>,
) {
@@ -114,21 +114,21 @@ export class List<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
return new List<string[]>(async (options) => {
const { spec: aSpec, ...a } = await getA(options)
const spec = {
type: 'text' as const,
type: "text" as const,
placeholder: null,
minLength: null,
maxLength: null,
masked: false,
inputmode: 'text' as const,
inputmode: "text" as const,
generate: null,
patterns: aSpec.patterns || [],
...aSpec,
}
const built: ValueSpecListOf<'text'> = {
const built: ValueSpecListOf<"text"> = {
description: null,
warning: null,
default: [],
type: 'list' as const,
type: "list" as const,
minLength: null,
maxLength: null,
disabled: false,
@@ -162,7 +162,7 @@ export class List<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
const { spec: previousSpecSpec, ...restSpec } = aSpec
const built = await previousSpecSpec.build(options)
const spec = {
type: 'object' as const,
type: "object" as const,
displayAs: null,
uniqueBy: null,
...restSpec,
@@ -179,7 +179,7 @@ export class List<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
warning: null,
minLength: null,
maxLength: null,
type: 'list' as const,
type: "list" as const,
disabled: false,
...value,
},

View File

@@ -1,6 +1,6 @@
import { InputSpec, LazyBuild } from './inputSpec'
import { List } from './list'
import { UnionRes, UnionResStaticValidatedAs, Variants } from './variants'
import { InputSpec, LazyBuild } from "./inputSpec"
import { List } from "./list"
import { UnionRes, UnionResStaticValidatedAs, Variants } from "./variants"
import {
Pattern,
RandomString,
@@ -9,9 +9,9 @@ import {
ValueSpecHidden,
ValueSpecText,
ValueSpecTextarea,
} from '../inputSpecTypes'
import { DefaultString } from '../inputSpecTypes'
import { _, once } from '../../../util'
} from "../inputSpecTypes"
import { DefaultString } from "../inputSpecTypes"
import { _, once } from "../../../util"
import {
Parser,
any,
@@ -23,8 +23,8 @@ import {
number,
object,
string,
} from 'ts-matches'
import { DeepPartial } from '../../../types'
} from "ts-matches"
import { DeepPartial } from "../../../types"
export const fileInfoParser = object({
path: string,
@@ -42,7 +42,7 @@ const testForAsRequiredParser = once(
function asRequiredParser<Type, Input extends { required: boolean }>(
parser: Parser<unknown, Type>,
input: Input,
): Parser<unknown, AsRequired<Type, Input['required']>> {
): Parser<unknown, AsRequired<Type, Input["required"]>> {
if (testForAsRequiredParser()(input)) return parser as any
return parser.nullable() as any
}
@@ -92,7 +92,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
spec: {
description: null,
warning: null,
type: 'toggle' as const,
type: "toggle" as const,
disabled: false,
immutable: a.immutable ?? false,
...a,
@@ -117,7 +117,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
spec: {
description: null,
warning: null,
type: 'toggle' as const,
type: "toggle" as const,
disabled: false,
immutable: false,
...(await a(options)),
@@ -191,7 +191,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
* @description Informs the browser how to behave and which keyboard to display on mobile
* @default "text"
*/
inputmode?: ValueSpecText['inputmode']
inputmode?: ValueSpecText["inputmode"]
/**
* @description Once set, the value can never be changed.
* @default false
@@ -206,7 +206,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
return new Value<AsRequired<string, Required>>(
async () => ({
spec: {
type: 'text' as const,
type: "text" as const,
description: null,
warning: null,
masked: false,
@@ -214,7 +214,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
minLength: null,
maxLength: null,
patterns: [],
inputmode: 'text',
inputmode: "text",
disabled: false,
immutable: a.immutable ?? false,
generate: a.generate ?? null,
@@ -237,7 +237,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
minLength?: number | null
maxLength?: number | null
patterns?: Pattern[]
inputmode?: ValueSpecText['inputmode']
inputmode?: ValueSpecText["inputmode"]
disabled?: string | false
generate?: null | RandomString
}>,
@@ -247,7 +247,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
const a = await getA(options)
return {
spec: {
type: 'text' as const,
type: "text" as const,
description: null,
warning: null,
masked: false,
@@ -255,7 +255,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
minLength: null,
maxLength: null,
patterns: [],
inputmode: 'text',
inputmode: "text",
disabled: false,
immutable: false,
generate: a.generate ?? null,
@@ -334,7 +334,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
minRows: 3,
maxRows: 6,
placeholder: null,
type: 'textarea' as const,
type: "textarea" as const,
disabled: false,
immutable: a.immutable ?? false,
...a,
@@ -371,7 +371,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
minRows: 3,
maxRows: 6,
placeholder: null,
type: 'textarea' as const,
type: "textarea" as const,
disabled: false,
immutable: false,
...a,
@@ -444,7 +444,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
return new Value<AsRequired<number, Required>>(
() => ({
spec: {
type: 'number' as const,
type: "number" as const,
description: null,
warning: null,
min: null,
@@ -482,7 +482,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
const a = await getA(options)
return {
spec: {
type: 'number' as const,
type: "number" as const,
description: null,
warning: null,
min: null,
@@ -540,7 +540,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
return new Value<AsRequired<string, Required>>(
() => ({
spec: {
type: 'color' as const,
type: "color" as const,
description: null,
warning: null,
disabled: false,
@@ -568,7 +568,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
const a = await getA(options)
return {
spec: {
type: 'color' as const,
type: "color" as const,
description: null,
warning: null,
disabled: false,
@@ -618,7 +618,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
* @description Informs the browser how to behave and which date/time component to display.
* @default "datetime-local"
*/
inputmode?: ValueSpecDatetime['inputmode']
inputmode?: ValueSpecDatetime["inputmode"]
min?: string | null
max?: string | null
/**
@@ -631,10 +631,10 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
return new Value<AsRequired<string, Required>>(
() => ({
spec: {
type: 'datetime' as const,
type: "datetime" as const,
description: null,
warning: null,
inputmode: 'datetime-local',
inputmode: "datetime-local",
min: null,
max: null,
step: null,
@@ -654,7 +654,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
warning?: string | null
default: string | null
required: Required
inputmode?: ValueSpecDatetime['inputmode']
inputmode?: ValueSpecDatetime["inputmode"]
min?: string | null
max?: string | null
disabled?: false | string
@@ -665,10 +665,10 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
const a = await getA(options)
return {
spec: {
type: 'datetime' as const,
type: "datetime" as const,
description: null,
warning: null,
inputmode: 'datetime-local',
inputmode: "datetime-local",
min: null,
max: null,
disabled: false,
@@ -740,7 +740,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
spec: {
description: null,
warning: null,
type: 'select' as const,
type: "select" as const,
disabled: false,
immutable: a.immutable ?? false,
...a,
@@ -766,7 +766,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
spec: {
description: null,
warning: null,
type: 'select' as const,
type: "select" as const,
disabled: false,
immutable: false,
...a,
@@ -837,7 +837,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
return new Value<(keyof Values & string)[]>(
() => ({
spec: {
type: 'multiselect' as const,
type: "multiselect" as const,
minLength: null,
maxLength: null,
warning: null,
@@ -867,7 +867,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
const a = await getA(options)
return {
spec: {
type: 'multiselect' as const,
type: "multiselect" as const,
minLength: null,
maxLength: null,
warning: null,
@@ -915,7 +915,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
const built = await spec.build(options as any)
return {
spec: {
type: 'object' as const,
type: "object" as const,
description: null,
warning: null,
...a,
@@ -933,7 +933,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
required: Required
}) {
const buildValue = {
type: 'file' as const,
type: "file" as const,
description: null,
warning: null,
...a,
@@ -960,7 +960,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
return new Value<AsRequired<FileInfo, Required>, FileInfo | null>(
async (options) => {
const spec = {
type: 'file' as const,
type: "file" as const,
description: null,
warning: null,
...(await a(options)),
@@ -1034,7 +1034,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
const built = await a.variants.build(options as any)
return {
spec: {
type: 'union' as const,
type: "union" as const,
description: null,
warning: null,
disabled: false,
@@ -1109,7 +1109,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
const built = await newValues.variants.build(options as any)
return {
spec: {
type: 'union' as const,
type: "union" as const,
description: null,
warning: null,
...newValues,
@@ -1202,7 +1202,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
return new Value<T, typeof parser._TYPE>(async () => {
return {
spec: {
type: 'hidden' as const,
type: "hidden" as const,
} as ValueSpecHidden,
validator: parser,
}
@@ -1221,7 +1221,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
const validator = await getParser(options)
return {
spec: {
type: 'hidden' as const,
type: "hidden" as const,
} as ValueSpecHidden,
validator,
}

View File

@@ -1,12 +1,12 @@
import { DeepPartial } from '../../../types'
import { ValueSpec, ValueSpecUnion } from '../inputSpecTypes'
import { DeepPartial } from "../../../types"
import { ValueSpec, ValueSpecUnion } from "../inputSpecTypes"
import {
LazyBuild,
InputSpec,
ExtractInputSpecType,
ExtractInputSpecStaticValidatedAs,
} from './inputSpec'
import { Parser, any, anyOf, literal, object } from 'ts-matches'
} from "./inputSpec"
import { Parser, any, anyOf, literal, object } from "ts-matches"
export type UnionRes<
VariantValues extends {
@@ -19,10 +19,10 @@ export type UnionRes<
> = {
[key in keyof VariantValues]: {
selection: key
value: ExtractInputSpecType<VariantValues[key]['spec']>
value: ExtractInputSpecType<VariantValues[key]["spec"]>
other?: {
[key2 in Exclude<keyof VariantValues & string, key>]?: DeepPartial<
ExtractInputSpecType<VariantValues[key2]['spec']>
ExtractInputSpecType<VariantValues[key2]["spec"]>
>
}
}
@@ -39,10 +39,10 @@ export type UnionResStaticValidatedAs<
> = {
[key in keyof VariantValues]: {
selection: key
value: ExtractInputSpecStaticValidatedAs<VariantValues[key]['spec']>
value: ExtractInputSpecStaticValidatedAs<VariantValues[key]["spec"]>
other?: {
[key2 in Exclude<keyof VariantValues & string, key>]?: DeepPartial<
ExtractInputSpecStaticValidatedAs<VariantValues[key2]['spec']>
ExtractInputSpecStaticValidatedAs<VariantValues[key2]["spec"]>
>
}
}
@@ -106,7 +106,7 @@ export class Variants<
> {
private constructor(
public build: LazyBuild<{
spec: ValueSpecUnion['variants']
spec: ValueSpecUnion["variants"]
validator: Parser<unknown, UnionRes<VariantValues>>
}>,
public readonly validator: Parser<
@@ -126,7 +126,7 @@ export class Variants<
const staticValidators = {} as {
[K in keyof VariantValues]: Parser<
unknown,
ExtractInputSpecStaticValidatedAs<VariantValues[K]['spec']>
ExtractInputSpecStaticValidatedAs<VariantValues[K]["spec"]>
>
}
for (const key in a) {
@@ -143,7 +143,7 @@ export class Variants<
const validators = {} as {
[K in keyof VariantValues]: Parser<
unknown,
ExtractInputSpecType<VariantValues[K]['spec']>
ExtractInputSpecType<VariantValues[K]["spec"]>
>
}
const variants = {} as {

View File

@@ -1,3 +1,3 @@
export * as constants from './inputSpecConstants'
export * as types from './inputSpecTypes'
export * as builder from './builder'
export * as constants from "./inputSpecConstants"
export * as types from "./inputSpecTypes"
export * as builder from "./builder"

View File

@@ -1,8 +1,8 @@
import { SmtpValue } from '../../types'
import { GetSystemSmtp, Patterns } from '../../util'
import { InputSpec, InputSpecOf } from './builder/inputSpec'
import { Value } from './builder/value'
import { Variants } from './builder/variants'
import { SmtpValue } from "../../types"
import { GetSystemSmtp, Patterns } from "../../util"
import { InputSpec, InputSpecOf } from "./builder/inputSpec"
import { Value } from "./builder/value"
import { Variants } from "./builder/variants"
/**
* Base SMTP settings, to be used by StartOS for system wide SMTP
@@ -11,12 +11,12 @@ export const customSmtp: InputSpec<SmtpValue> = InputSpec.of<
InputSpecOf<SmtpValue>
>({
server: Value.text({
name: 'SMTP Server',
name: "SMTP Server",
required: true,
default: null,
}),
port: Value.number({
name: 'Port',
name: "Port",
required: true,
default: 587,
min: 1,
@@ -24,20 +24,20 @@ export const customSmtp: InputSpec<SmtpValue> = InputSpec.of<
integer: true,
}),
from: Value.text({
name: 'From Address',
name: "From Address",
required: true,
default: null,
placeholder: 'Example Name <test@example.com>',
inputmode: 'email',
placeholder: "Example Name <test@example.com>",
inputmode: "email",
patterns: [Patterns.emailWithName],
}),
login: Value.text({
name: 'Login',
name: "Login",
required: true,
default: null,
}),
password: Value.text({
name: 'Password',
name: "Password",
required: false,
default: null,
masked: true,
@@ -45,24 +45,24 @@ export const customSmtp: InputSpec<SmtpValue> = InputSpec.of<
})
const smtpVariants = Variants.of({
disabled: { name: 'Disabled', spec: InputSpec.of({}) },
disabled: { name: "Disabled", spec: InputSpec.of({}) },
system: {
name: 'System Credentials',
name: "System Credentials",
spec: InputSpec.of({
customFrom: Value.text({
name: 'Custom From Address',
name: "Custom From Address",
description:
'A custom from address for this service. If not provided, the system from address will be used.',
"A custom from address for this service. If not provided, the system from address will be used.",
required: false,
default: null,
placeholder: '<name>test@example.com',
inputmode: 'email',
placeholder: "<name>test@example.com",
inputmode: "email",
patterns: [Patterns.email],
}),
}),
},
custom: {
name: 'Custom Credentials',
name: "Custom Credentials",
spec: customSmtp,
},
})
@@ -71,11 +71,11 @@ const smtpVariants = Variants.of({
*/
export const smtpInputSpec = Value.dynamicUnion(async ({ effects }) => {
const smtp = await new GetSystemSmtp(effects).once()
const disabled = smtp ? [] : ['system']
const disabled = smtp ? [] : ["system"]
return {
name: 'SMTP',
description: 'Optionally provide an SMTP server for sending emails',
default: 'disabled',
name: "SMTP",
description: "Optionally provide an SMTP server for sending emails",
default: "disabled",
disabled,
variants: smtpVariants,
}

View File

@@ -1,18 +1,18 @@
export type InputSpec = Record<string, ValueSpec>
export type ValueType =
| 'text'
| 'textarea'
| 'number'
| 'color'
| 'datetime'
| 'toggle'
| 'select'
| 'multiselect'
| 'list'
| 'object'
| 'file'
| 'union'
| 'hidden'
| "text"
| "textarea"
| "number"
| "color"
| "datetime"
| "toggle"
| "select"
| "multiselect"
| "list"
| "object"
| "file"
| "union"
| "hidden"
export type ValueSpec = ValueSpecOf<ValueType>
/** core spec types. These types provide the metadata for performing validations */
// prettier-ignore
@@ -37,13 +37,13 @@ export type ValueSpecText = {
description: string | null
warning: string | null
type: 'text'
type: "text"
patterns: Pattern[]
minLength: number | null
maxLength: number | null
masked: boolean
inputmode: 'text' | 'email' | 'tel' | 'url'
inputmode: "text" | "email" | "tel" | "url"
placeholder: string | null
required: boolean
@@ -57,7 +57,7 @@ export type ValueSpecTextarea = {
description: string | null
warning: string | null
type: 'textarea'
type: "textarea"
patterns: Pattern[]
placeholder: string | null
minLength: number | null
@@ -71,7 +71,7 @@ export type ValueSpecTextarea = {
}
export type ValueSpecNumber = {
type: 'number'
type: "number"
min: number | null
max: number | null
integer: boolean
@@ -91,7 +91,7 @@ export type ValueSpecColor = {
description: string | null
warning: string | null
type: 'color'
type: "color"
required: boolean
default: string | null
disabled: false | string
@@ -101,9 +101,9 @@ export type ValueSpecDatetime = {
name: string
description: string | null
warning: string | null
type: 'datetime'
type: "datetime"
required: boolean
inputmode: 'date' | 'time' | 'datetime-local'
inputmode: "date" | "time" | "datetime-local"
min: string | null
max: string | null
default: string | null
@@ -115,7 +115,7 @@ export type ValueSpecSelect = {
name: string
description: string | null
warning: string | null
type: 'select'
type: "select"
default: string | null
disabled: false | string | string[]
immutable: boolean
@@ -127,7 +127,7 @@ export type ValueSpecMultiselect = {
description: string | null
warning: string | null
type: 'multiselect'
type: "multiselect"
minLength: number | null
maxLength: number | null
disabled: false | string | string[]
@@ -139,7 +139,7 @@ export type ValueSpecToggle = {
description: string | null
warning: string | null
type: 'toggle'
type: "toggle"
default: boolean | null
disabled: false | string
immutable: boolean
@@ -149,7 +149,7 @@ export type ValueSpecUnion = {
description: string | null
warning: string | null
type: 'union'
type: "union"
variants: Record<
string,
{
@@ -165,7 +165,7 @@ export type ValueSpecFile = {
name: string
description: string | null
warning: string | null
type: 'file'
type: "file"
extensions: string[]
required: boolean
}
@@ -173,13 +173,13 @@ export type ValueSpecObject = {
name: string
description: string | null
warning: string | null
type: 'object'
type: "object"
spec: InputSpec
}
export type ValueSpecHidden = {
type: 'hidden'
type: "hidden"
}
export type ListValueSpecType = 'text' | 'object'
export type ListValueSpecType = "text" | "object"
// prettier-ignore
export type ListValueSpecOf<T extends ListValueSpecType> =
T extends "text" ? ListValueSpecText :
@@ -190,7 +190,7 @@ export type ValueSpecListOf<T extends ListValueSpecType> = {
name: string
description: string | null
warning: string | null
type: 'list'
type: "list"
spec: ListValueSpecOf<T>
minLength: number | null
maxLength: number | null
@@ -208,18 +208,18 @@ export type Pattern = {
description: string
}
export type ListValueSpecText = {
type: 'text'
type: "text"
patterns: Pattern[]
minLength: number | null
maxLength: number | null
masked: boolean
generate: null | RandomString
inputmode: 'text' | 'email' | 'tel' | 'url'
inputmode: "text" | "email" | "tel" | "url"
placeholder: string | null
}
export type ListValueSpecObject = {
type: 'object'
type: "object"
spec: InputSpec
uniqueBy: UniqueBy
displayAs: string | null
@@ -244,5 +244,5 @@ export function isValueSpecListOf<S extends ListValueSpecType>(
t: ValueSpec,
s: S,
): t is ValueSpecListOf<S> & { spec: ListValueSpecOf<S> } {
return 'spec' in t && t.spec.type === s
return "spec" in t && t.spec.type === s
}

View File

@@ -1,16 +1,16 @@
import { InputSpec } from './input/builder'
import { ExtractInputSpecType } from './input/builder/inputSpec'
import * as T from '../types'
import { once } from '../util'
import { InitScript } from '../inits'
import { Parser } from 'ts-matches'
import { InputSpec } from "./input/builder"
import { ExtractInputSpecType } from "./input/builder/inputSpec"
import * as T from "../types"
import { once } from "../util"
import { InitScript } from "../inits"
import { Parser } from "ts-matches"
type MaybeInputSpec<Type> = {} extends Type ? null : InputSpec<Type>
export type Run<A extends Record<string, any>> = (options: {
effects: T.Effects
input: A
spec: T.inputSpecTypes.InputSpec
}) => Promise<(T.ActionResult & { version: '1' }) | null | void | undefined>
}) => Promise<(T.ActionResult & { version: "1" }) | null | void | undefined>
export type GetInput<A extends Record<string, any>> = (options: {
effects: T.Effects
}) => Promise<null | void | undefined | T.DeepPartial<A>>
@@ -65,7 +65,7 @@ export class Action<Id extends T.ActionId, Type extends Record<string, any>>
InputSpecType extends InputSpec<Record<string, any>>,
>(
id: Id,
metadata: MaybeFn<Omit<T.ActionMetadata, 'hasInput'>>,
metadata: MaybeFn<Omit<T.ActionMetadata, "hasInput">>,
inputSpec: InputSpecType,
getInput: GetInput<ExtractInputSpecType<InputSpecType>>,
run: Run<ExtractInputSpecType<InputSpecType>>,
@@ -80,7 +80,7 @@ export class Action<Id extends T.ActionId, Type extends Record<string, any>>
}
static withoutInput<Id extends T.ActionId>(
id: Id,
metadata: MaybeFn<Omit<T.ActionMetadata, 'hasInput'>>,
metadata: MaybeFn<Omit<T.ActionMetadata, "hasInput">>,
run: Run<{}>,
): Action<Id, {}> {
return new Action(
@@ -156,7 +156,7 @@ export class Actions<
}
addAction<A extends Action<T.ActionId, any>>(
action: A, // TODO: prevent duplicates
): Actions<AllActions & { [id in A['id']]: A }> {
): Actions<AllActions & { [id in A["id"]]: A }> {
return new Actions({ ...this.actions, [action.id]: action })
}
async init(effects: T.Effects): Promise<void> {

View File

@@ -1,11 +1,11 @@
import { ExtendedVersion, VersionRange } from '../exver'
import { ExtendedVersion, VersionRange } from "../exver"
import {
PackageId,
HealthCheckId,
DependencyRequirement,
CheckDependenciesResult,
} from '../types'
import { Effects } from '../Effects'
} from "../types"
import { Effects } from "../Effects"
export type CheckDependencies<DependencyId extends PackageId = PackageId> = {
infoFor: (packageId: DependencyId) => {
@@ -73,11 +73,11 @@ export async function checkDependencies<
}
const runningSatisfied = (packageId: DependencyId) => {
const dep = infoFor(packageId)
return dep.requirement.kind !== 'running' || dep.result.isRunning
return dep.requirement.kind !== "running" || dep.result.isRunning
}
const tasksSatisfied = (packageId: DependencyId) =>
Object.entries(infoFor(packageId).result.tasks).filter(
([_, t]) => t?.active && t.task.severity === 'critical',
([_, t]) => t?.active && t.task.severity === "critical",
).length === 0
const healthCheckSatisfied = (
packageId: DependencyId,
@@ -86,17 +86,17 @@ export async function checkDependencies<
const dep = infoFor(packageId)
if (
healthCheckId &&
(dep.requirement.kind !== 'running' ||
(dep.requirement.kind !== "running" ||
!dep.requirement.healthChecks.includes(healthCheckId))
) {
throw new Error(`Unknown HealthCheckId ${healthCheckId}`)
}
const errors =
dep.requirement.kind === 'running'
dep.requirement.kind === "running"
? dep.requirement.healthChecks
.map((id) => [id, dep.result.healthChecks[id] ?? null] as const)
.filter(([id, _]) => (healthCheckId ? id === healthCheckId : true))
.filter(([_, res]) => res?.result !== 'success')
.filter(([_, res]) => res?.result !== "success")
: []
return errors.length === 0
}
@@ -138,7 +138,7 @@ export async function checkDependencies<
}
const throwIfRunningNotSatisfied = (packageId: DependencyId) => {
const dep = infoFor(packageId)
if (dep.requirement.kind === 'running' && !dep.result.isRunning) {
if (dep.requirement.kind === "running" && !dep.result.isRunning) {
throw new Error(`${dep.result.title || packageId} is not running`)
}
return null
@@ -146,11 +146,11 @@ export async function checkDependencies<
const throwIfTasksNotSatisfied = (packageId: DependencyId) => {
const dep = infoFor(packageId)
const reqs = Object.entries(dep.result.tasks)
.filter(([_, t]) => t?.active && t.task.severity === 'critical')
.filter(([_, t]) => t?.active && t.task.severity === "critical")
.map(([id, _]) => id)
if (reqs.length) {
throw new Error(
`The following action requests have not been fulfilled: ${reqs.join(', ')}`,
`The following action requests have not been fulfilled: ${reqs.join(", ")}`,
)
}
return null
@@ -162,27 +162,27 @@ export async function checkDependencies<
const dep = infoFor(packageId)
if (
healthCheckId &&
(dep.requirement.kind !== 'running' ||
(dep.requirement.kind !== "running" ||
!dep.requirement.healthChecks.includes(healthCheckId))
) {
throw new Error(`Unknown HealthCheckId ${healthCheckId}`)
}
const errors =
dep.requirement.kind === 'running'
dep.requirement.kind === "running"
? dep.requirement.healthChecks
.map((id) => [id, dep.result.healthChecks[id] ?? null] as const)
.filter(([id, _]) => (healthCheckId ? id === healthCheckId : true))
.filter(([_, res]) => res?.result !== 'success')
.filter(([_, res]) => res?.result !== "success")
: []
if (errors.length) {
throw new Error(
errors
.map(([id, e]) =>
e
? `Health Check ${e.name} of ${dep.result.title || packageId} failed with status ${e.result}${e.message ? `: ${e.message}` : ''}`
? `Health Check ${e.name} of ${dep.result.title || packageId} failed with status ${e.result}${e.message ? `: ${e.message}` : ""}`
: `Health Check ${id} of ${dep.result.title} does not exist`,
)
.join('; '),
.join("; "),
)
}
return null
@@ -209,7 +209,7 @@ export async function checkDependencies<
return []
})
if (err.length) {
throw new Error(err.join('; '))
throw new Error(err.join("; "))
}
return null
})()

View File

@@ -1,27 +1,27 @@
import * as T from '../types'
import { once } from '../util'
import * as T from "../types"
import { once } from "../util"
export type RequiredDependenciesOf<Manifest extends T.SDKManifest> = {
[K in keyof Manifest['dependencies']]: Exclude<
Manifest['dependencies'][K],
[K in keyof Manifest["dependencies"]]: Exclude<
Manifest["dependencies"][K],
undefined
>['optional'] extends false
>["optional"] extends false
? K
: never
}[keyof Manifest['dependencies']]
}[keyof Manifest["dependencies"]]
export type OptionalDependenciesOf<Manifest extends T.SDKManifest> = Exclude<
keyof Manifest['dependencies'],
keyof Manifest["dependencies"],
RequiredDependenciesOf<Manifest>
>
type DependencyRequirement =
| {
kind: 'running'
kind: "running"
healthChecks: Array<T.HealthCheckId>
versionRange: string
}
| {
kind: 'exists'
kind: "exists"
versionRange: string
}
type Matches<T, U> = T extends U ? (U extends T ? null : never) : never

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { DeepMap } from 'deep-equality-data-structures'
import * as P from './exver'
import { DeepMap } from "deep-equality-data-structures"
import * as P from "./exver"
// prettier-ignore
export type ValidateVersion<T extends String> =
@@ -22,35 +22,35 @@ export type ValidateExVers<T> =
never[]
type Anchor = {
type: 'Anchor'
type: "Anchor"
operator: P.CmpOp
version: ExtendedVersion
}
type And = {
type: 'And'
type: "And"
left: VersionRange
right: VersionRange
}
type Or = {
type: 'Or'
type: "Or"
left: VersionRange
right: VersionRange
}
type Not = {
type: 'Not'
type: "Not"
value: VersionRange
}
type Flavor = {
type: 'Flavor'
type: "Flavor"
flavor: string | null
}
type FlavorNot = {
type: 'FlavorNot'
type: "FlavorNot"
flavors: Set<string | null>
}
@@ -107,8 +107,8 @@ function adjacentVersionRangePoints(
}
function flavorAnd(a: FlavorAtom, b: FlavorAtom): FlavorAtom | null {
if (a.type == 'Flavor') {
if (b.type == 'Flavor') {
if (a.type == "Flavor") {
if (b.type == "Flavor") {
if (a.flavor == b.flavor) {
return a
} else {
@@ -122,7 +122,7 @@ function flavorAnd(a: FlavorAtom, b: FlavorAtom): FlavorAtom | null {
}
}
} else {
if (b.type == 'Flavor') {
if (b.type == "Flavor") {
if (a.flavors.has(b.flavor)) {
return null
} else {
@@ -131,7 +131,7 @@ function flavorAnd(a: FlavorAtom, b: FlavorAtom): FlavorAtom | null {
} else {
// TODO: use Set.union if targeting esnext or later
return {
type: 'FlavorNot',
type: "FlavorNot",
flavors: new Set([...a.flavors, ...b.flavors]),
}
}
@@ -218,12 +218,12 @@ class VersionRangeTable {
static eqFlavor(flavor: string | null): VersionRangeTables {
return new DeepMap([
[
{ type: 'Flavor', flavor } as FlavorAtom,
{ type: "Flavor", flavor } as FlavorAtom,
new VersionRangeTable([], [true]),
],
// make sure the truth table is exhaustive, or `not` will not work properly.
[
{ type: 'FlavorNot', flavors: new Set([flavor]) } as FlavorAtom,
{ type: "FlavorNot", flavors: new Set([flavor]) } as FlavorAtom,
new VersionRangeTable([], [false]),
],
])
@@ -241,12 +241,12 @@ class VersionRangeTable {
): VersionRangeTables {
return new DeepMap([
[
{ type: 'Flavor', flavor } as FlavorAtom,
{ type: "Flavor", flavor } as FlavorAtom,
new VersionRangeTable([point], [left, right]),
],
// make sure the truth table is exhaustive, or `not` will not work properly.
[
{ type: 'FlavorNot', flavors: new Set([flavor]) } as FlavorAtom,
{ type: "FlavorNot", flavors: new Set([flavor]) } as FlavorAtom,
new VersionRangeTable([], [false]),
],
])
@@ -383,7 +383,7 @@ class VersionRangeTable {
let sum_terms: VersionRange[] = []
for (let [flavor, table] of tables) {
let cmp_flavor = null
if (flavor.type == 'Flavor') {
if (flavor.type == "Flavor") {
cmp_flavor = flavor.flavor
}
for (let i = 0; i < table.values.length; i++) {
@@ -392,7 +392,7 @@ class VersionRangeTable {
continue
}
if (flavor.type == 'FlavorNot') {
if (flavor.type == "FlavorNot") {
for (let not_flavor of flavor.flavors) {
term.push(VersionRange.flavor(not_flavor).not())
}
@@ -410,7 +410,7 @@ class VersionRangeTable {
if (p != null && q != null && adjacentVersionRangePoints(p, q)) {
term.push(
VersionRange.anchor(
'=',
"=",
new ExtendedVersion(cmp_flavor, p.upstream, p.downstream),
),
)
@@ -418,7 +418,7 @@ class VersionRangeTable {
if (p != null && p.side < 0) {
term.push(
VersionRange.anchor(
'>=',
">=",
new ExtendedVersion(cmp_flavor, p.upstream, p.downstream),
),
)
@@ -426,7 +426,7 @@ class VersionRangeTable {
if (p != null && p.side >= 0) {
term.push(
VersionRange.anchor(
'>',
">",
new ExtendedVersion(cmp_flavor, p.upstream, p.downstream),
),
)
@@ -434,7 +434,7 @@ class VersionRangeTable {
if (q != null && q.side < 0) {
term.push(
VersionRange.anchor(
'<',
"<",
new ExtendedVersion(cmp_flavor, q.upstream, q.downstream),
),
)
@@ -442,7 +442,7 @@ class VersionRangeTable {
if (q != null && q.side >= 0) {
term.push(
VersionRange.anchor(
'<=',
"<=",
new ExtendedVersion(cmp_flavor, q.upstream, q.downstream),
),
)
@@ -463,26 +463,26 @@ class VersionRangeTable {
export class VersionRange {
constructor(public atom: Anchor | And | Or | Not | P.Any | P.None | Flavor) {}
toStringParens(parent: 'And' | 'Or' | 'Not') {
toStringParens(parent: "And" | "Or" | "Not") {
let needs = true
switch (this.atom.type) {
case 'And':
case 'Or':
case "And":
case "Or":
needs = parent != this.atom.type
break
case 'Anchor':
case 'Any':
case 'None':
needs = parent == 'Not'
case "Anchor":
case "Any":
case "None":
needs = parent == "Not"
break
case 'Not':
case 'Flavor':
case "Not":
case "Flavor":
needs = false
break
}
if (needs) {
return '(' + this.toString() + ')'
return "(" + this.toString() + ")"
} else {
return this.toString()
}
@@ -490,36 +490,36 @@ export class VersionRange {
toString(): string {
switch (this.atom.type) {
case 'Anchor':
case "Anchor":
return `${this.atom.operator}${this.atom.version}`
case 'And':
case "And":
return `${this.atom.left.toStringParens(this.atom.type)} && ${this.atom.right.toStringParens(this.atom.type)}`
case 'Or':
case "Or":
return `${this.atom.left.toStringParens(this.atom.type)} || ${this.atom.right.toStringParens(this.atom.type)}`
case 'Not':
case "Not":
return `!${this.atom.value.toStringParens(this.atom.type)}`
case 'Flavor':
case "Flavor":
return this.atom.flavor == null ? `#` : `#${this.atom.flavor}`
case 'Any':
return '*'
case 'None':
return '!'
case "Any":
return "*"
case "None":
return "!"
}
}
private static parseAtom(atom: P.VersionRangeAtom): VersionRange {
switch (atom.type) {
case 'Not':
case "Not":
return new VersionRange({
type: 'Not',
type: "Not",
value: VersionRange.parseAtom(atom.value),
})
case 'Parens':
case "Parens":
return VersionRange.parseRange(atom.expr)
case 'Anchor':
case "Anchor":
return new VersionRange({
type: 'Anchor',
operator: atom.operator || '^',
type: "Anchor",
operator: atom.operator || "^",
version: new ExtendedVersion(
atom.version.flavor,
new Version(
@@ -532,7 +532,7 @@ export class VersionRange {
),
),
})
case 'Flavor':
case "Flavor":
return VersionRange.flavor(atom.flavor)
default:
return new VersionRange(atom)
@@ -543,17 +543,17 @@ export class VersionRange {
let result = VersionRange.parseAtom(range[0])
for (const next of range[1]) {
switch (next[1]?.[0]) {
case '||':
case "||":
result = new VersionRange({
type: 'Or',
type: "Or",
left: result,
right: VersionRange.parseAtom(next[2]),
})
break
case '&&':
case "&&":
default:
result = new VersionRange({
type: 'And',
type: "And",
left: result,
right: VersionRange.parseAtom(next[2]),
})
@@ -565,49 +565,49 @@ export class VersionRange {
static parse(range: string): VersionRange {
return VersionRange.parseRange(
P.parse(range, { startRule: 'VersionRange' }),
P.parse(range, { startRule: "VersionRange" }),
)
}
static anchor(operator: P.CmpOp, version: ExtendedVersion) {
return new VersionRange({ type: 'Anchor', operator, version })
return new VersionRange({ type: "Anchor", operator, version })
}
static flavor(flavor: string | null) {
return new VersionRange({ type: 'Flavor', flavor })
return new VersionRange({ type: "Flavor", flavor })
}
static parseEmver(range: string): VersionRange {
return VersionRange.parseRange(
P.parse(range, { startRule: 'EmverVersionRange' }),
P.parse(range, { startRule: "EmverVersionRange" }),
)
}
and(right: VersionRange) {
return new VersionRange({ type: 'And', left: this, right })
return new VersionRange({ type: "And", left: this, right })
}
or(right: VersionRange) {
return new VersionRange({ type: 'Or', left: this, right })
return new VersionRange({ type: "Or", left: this, right })
}
not() {
return new VersionRange({ type: 'Not', value: this })
return new VersionRange({ type: "Not", value: this })
}
static and(...xs: Array<VersionRange>) {
let y = VersionRange.any()
for (let x of xs) {
if (x.atom.type == 'Any') {
if (x.atom.type == "Any") {
continue
}
if (x.atom.type == 'None') {
if (x.atom.type == "None") {
return x
}
if (y.atom.type == 'Any') {
if (y.atom.type == "Any") {
y = x
} else {
y = new VersionRange({ type: 'And', left: y, right: x })
y = new VersionRange({ type: "And", left: y, right: x })
}
}
return y
@@ -616,27 +616,27 @@ export class VersionRange {
static or(...xs: Array<VersionRange>) {
let y = VersionRange.none()
for (let x of xs) {
if (x.atom.type == 'None') {
if (x.atom.type == "None") {
continue
}
if (x.atom.type == 'Any') {
if (x.atom.type == "Any") {
return x
}
if (y.atom.type == 'None') {
if (y.atom.type == "None") {
y = x
} else {
y = new VersionRange({ type: 'Or', left: y, right: x })
y = new VersionRange({ type: "Or", left: y, right: x })
}
}
return y
}
static any() {
return new VersionRange({ type: 'Any' })
return new VersionRange({ type: "Any" })
}
static none() {
return new VersionRange({ type: 'None' })
return new VersionRange({ type: "None" })
}
satisfiedBy(version: Version | ExtendedVersion) {
@@ -645,23 +645,23 @@ export class VersionRange {
tables(): VersionRangeTables {
switch (this.atom.type) {
case 'Anchor':
case "Anchor":
switch (this.atom.operator) {
case '=':
case "=":
// `=1.2.3` is equivalent to `>=1.2.3 && <=1.2.4 && #flavor`
return VersionRangeTable.and(
VersionRangeTable.cmp(this.atom.version, -1, false, true),
VersionRangeTable.cmp(this.atom.version, 1, true, false),
)
case '>':
case ">":
return VersionRangeTable.cmp(this.atom.version, 1, false, true)
case '<':
case "<":
return VersionRangeTable.cmp(this.atom.version, -1, true, false)
case '>=':
case ">=":
return VersionRangeTable.cmp(this.atom.version, -1, false, true)
case '<=':
case "<=":
return VersionRangeTable.cmp(this.atom.version, 1, true, false)
case '!=':
case "!=":
// `!=1.2.3` is equivalent to `!(>=1.2.3 && <=1.2.3 && #flavor)`
// **not** equivalent to `(<1.2.3 || >1.2.3) && #flavor`
return VersionRangeTable.not(
@@ -670,7 +670,7 @@ export class VersionRange {
VersionRangeTable.cmp(this.atom.version, 1, true, false),
),
)
case '^':
case "^":
// `^1.2.3` is equivalent to `>=1.2.3 && <2.0.0 && #flavor`
return VersionRangeTable.and(
VersionRangeTable.cmp(this.atom.version, -1, false, true),
@@ -681,7 +681,7 @@ export class VersionRange {
false,
),
)
case '~':
case "~":
// `~1.2.3` is equivalent to `>=1.2.3 && <1.3.0 && #flavor`
return VersionRangeTable.and(
VersionRangeTable.cmp(this.atom.version, -1, false, true),
@@ -693,23 +693,23 @@ export class VersionRange {
),
)
}
case 'Flavor':
case "Flavor":
return VersionRangeTable.eqFlavor(this.atom.flavor)
case 'Not':
case "Not":
return VersionRangeTable.not(this.atom.value.tables())
case 'And':
case "And":
return VersionRangeTable.and(
this.atom.left.tables(),
this.atom.right.tables(),
)
case 'Or':
case "Or":
return VersionRangeTable.or(
this.atom.left.tables(),
this.atom.right.tables(),
)
case 'Any':
case "Any":
return true
case 'None':
case "None":
return false
}
}
@@ -734,23 +734,23 @@ export class Version {
) {}
toString(): string {
return `${this.number.join('.')}${this.prerelease.length > 0 ? `-${this.prerelease.join('.')}` : ''}`
return `${this.number.join(".")}${this.prerelease.length > 0 ? `-${this.prerelease.join(".")}` : ""}`
}
compare(other: Version): 'greater' | 'equal' | 'less' {
compare(other: Version): "greater" | "equal" | "less" {
const numLen = Math.max(this.number.length, other.number.length)
for (let i = 0; i < numLen; i++) {
if ((this.number[i] || 0) > (other.number[i] || 0)) {
return 'greater'
return "greater"
} else if ((this.number[i] || 0) < (other.number[i] || 0)) {
return 'less'
return "less"
}
}
if (this.prerelease.length === 0 && other.prerelease.length !== 0) {
return 'greater'
return "greater"
} else if (this.prerelease.length !== 0 && other.prerelease.length === 0) {
return 'less'
return "less"
}
const prereleaseLen = Math.max(
@@ -760,42 +760,42 @@ export class Version {
for (let i = 0; i < prereleaseLen; i++) {
if (typeof this.prerelease[i] === typeof other.prerelease[i]) {
if (this.prerelease[i] > other.prerelease[i]) {
return 'greater'
return "greater"
} else if (this.prerelease[i] < other.prerelease[i]) {
return 'less'
return "less"
}
} else {
switch (`${typeof this.prerelease[1]}:${typeof other.prerelease[i]}`) {
case 'number:string':
return 'less'
case 'string:number':
return 'greater'
case 'number:undefined':
case 'string:undefined':
return 'greater'
case 'undefined:number':
case 'undefined:string':
return 'less'
case "number:string":
return "less"
case "string:number":
return "greater"
case "number:undefined":
case "string:undefined":
return "greater"
case "undefined:number":
case "undefined:string":
return "less"
}
}
}
return 'equal'
return "equal"
}
compareForSort(other: Version): -1 | 0 | 1 {
switch (this.compare(other)) {
case 'greater':
case "greater":
return 1
case 'equal':
case "equal":
return 0
case 'less':
case "less":
return -1
}
}
static parse(version: string): Version {
const parsed = P.parse(version, { startRule: 'Version' })
const parsed = P.parse(version, { startRule: "Version" })
return new Version(parsed.number, parsed.prerelease)
}
@@ -815,25 +815,25 @@ export class ExtendedVersion {
) {}
toString(): string {
return `${this.flavor ? `#${this.flavor}:` : ''}${this.upstream.toString()}:${this.downstream.toString()}`
return `${this.flavor ? `#${this.flavor}:` : ""}${this.upstream.toString()}:${this.downstream.toString()}`
}
compare(other: ExtendedVersion): 'greater' | 'equal' | 'less' | null {
compare(other: ExtendedVersion): "greater" | "equal" | "less" | null {
if (this.flavor !== other.flavor) {
return null
}
const upstreamCmp = this.upstream.compare(other.upstream)
if (upstreamCmp !== 'equal') {
if (upstreamCmp !== "equal") {
return upstreamCmp
}
return this.downstream.compare(other.downstream)
}
compareLexicographic(other: ExtendedVersion): 'greater' | 'equal' | 'less' {
if ((this.flavor || '') > (other.flavor || '')) {
return 'greater'
} else if ((this.flavor || '') > (other.flavor || '')) {
return 'less'
compareLexicographic(other: ExtendedVersion): "greater" | "equal" | "less" {
if ((this.flavor || "") > (other.flavor || "")) {
return "greater"
} else if ((this.flavor || "") > (other.flavor || "")) {
return "less"
} else {
return this.compare(other)!
}
@@ -841,37 +841,37 @@ export class ExtendedVersion {
compareForSort(other: ExtendedVersion): 1 | 0 | -1 {
switch (this.compareLexicographic(other)) {
case 'greater':
case "greater":
return 1
case 'equal':
case "equal":
return 0
case 'less':
case "less":
return -1
}
}
greaterThan(other: ExtendedVersion): boolean {
return this.compare(other) === 'greater'
return this.compare(other) === "greater"
}
greaterThanOrEqual(other: ExtendedVersion): boolean {
return ['greater', 'equal'].includes(this.compare(other) as string)
return ["greater", "equal"].includes(this.compare(other) as string)
}
equals(other: ExtendedVersion): boolean {
return this.compare(other) === 'equal'
return this.compare(other) === "equal"
}
lessThan(other: ExtendedVersion): boolean {
return this.compare(other) === 'less'
return this.compare(other) === "less"
}
lessThanOrEqual(other: ExtendedVersion): boolean {
return ['less', 'equal'].includes(this.compare(other) as string)
return ["less", "equal"].includes(this.compare(other) as string)
}
static parse(extendedVersion: string): ExtendedVersion {
const parsed = P.parse(extendedVersion, { startRule: 'ExtendedVersion' })
const parsed = P.parse(extendedVersion, { startRule: "ExtendedVersion" })
return new ExtendedVersion(
parsed.flavor || null,
new Version(parsed.upstream.number, parsed.upstream.prerelease),
@@ -881,7 +881,7 @@ export class ExtendedVersion {
static parseEmver(extendedVersion: string): ExtendedVersion {
try {
const parsed = P.parse(extendedVersion, { startRule: 'Emver' })
const parsed = P.parse(extendedVersion, { startRule: "Emver" })
return new ExtendedVersion(
parsed.flavor || null,
new Version(parsed.upstream.number, parsed.upstream.prerelease),
@@ -956,22 +956,22 @@ export class ExtendedVersion {
*/
satisfies(versionRange: VersionRange): boolean {
switch (versionRange.atom.type) {
case 'Anchor':
case "Anchor":
const otherVersion = versionRange.atom.version
switch (versionRange.atom.operator) {
case '=':
case "=":
return this.equals(otherVersion)
case '>':
case ">":
return this.greaterThan(otherVersion)
case '<':
case "<":
return this.lessThan(otherVersion)
case '>=':
case ">=":
return this.greaterThanOrEqual(otherVersion)
case '<=':
case "<=":
return this.lessThanOrEqual(otherVersion)
case '!=':
case "!=":
return !this.equals(otherVersion)
case '^':
case "^":
const nextMajor = versionRange.atom.version.incrementMajor()
if (
this.greaterThanOrEqual(otherVersion) &&
@@ -981,7 +981,7 @@ export class ExtendedVersion {
} else {
return false
}
case '~':
case "~":
const nextMinor = versionRange.atom.version.incrementMinor()
if (
this.greaterThanOrEqual(otherVersion) &&
@@ -992,23 +992,23 @@ export class ExtendedVersion {
return false
}
}
case 'Flavor':
case "Flavor":
return versionRange.atom.flavor == this.flavor
case 'And':
case "And":
return (
this.satisfies(versionRange.atom.left) &&
this.satisfies(versionRange.atom.right)
)
case 'Or':
case "Or":
return (
this.satisfies(versionRange.atom.left) ||
this.satisfies(versionRange.atom.right)
)
case 'Not':
case "Not":
return !this.satisfies(versionRange.atom.value)
case 'Any':
case "Any":
return true
case 'None':
case "None":
return false
}
}
@@ -1020,34 +1020,34 @@ export const testTypeVersion = <T extends string>(t: T & ValidateVersion<T>) =>
t
function tests() {
testTypeVersion('1.2.3')
testTypeVersion('1')
testTypeVersion('12.34.56')
testTypeVersion('1.2-3')
testTypeVersion('1-3')
testTypeVersion('1-alpha')
testTypeVersion("1.2.3")
testTypeVersion("1")
testTypeVersion("12.34.56")
testTypeVersion("1.2-3")
testTypeVersion("1-3")
testTypeVersion("1-alpha")
// @ts-expect-error
testTypeVersion('-3')
testTypeVersion("-3")
// @ts-expect-error
testTypeVersion('1.2.3:1')
testTypeVersion("1.2.3:1")
// @ts-expect-error
testTypeVersion('#cat:1:1')
testTypeVersion("#cat:1:1")
testTypeExVer('1.2.3:1.2.3')
testTypeExVer('1.2.3.4.5.6.7.8.9.0:1')
testTypeExVer('100:1')
testTypeExVer('#cat:1:1')
testTypeExVer('1.2.3.4.5.6.7.8.9.11.22.33:1')
testTypeExVer('1-0:1')
testTypeExVer('1-0:1')
testTypeExVer("1.2.3:1.2.3")
testTypeExVer("1.2.3.4.5.6.7.8.9.0:1")
testTypeExVer("100:1")
testTypeExVer("#cat:1:1")
testTypeExVer("1.2.3.4.5.6.7.8.9.11.22.33:1")
testTypeExVer("1-0:1")
testTypeExVer("1-0:1")
// @ts-expect-error
testTypeExVer('1.2-3')
testTypeExVer("1.2-3")
// @ts-expect-error
testTypeExVer('1-3')
testTypeExVer("1-3")
// @ts-expect-error
testTypeExVer('1.2.3.4.5.6.7.8.9.0.10:1' as string)
testTypeExVer("1.2.3.4.5.6.7.8.9.0.10:1" as string)
// @ts-expect-error
testTypeExVer('1.-2:1')
testTypeExVer("1.-2:1")
// @ts-expect-error
testTypeExVer('1..2.3:3')
testTypeExVer("1..2.3:3")
}

View File

@@ -1,13 +1,13 @@
export { S9pk } from './s9pk'
export { VersionRange, ExtendedVersion, Version } from './exver'
export { S9pk } from "./s9pk"
export { VersionRange, ExtendedVersion, Version } from "./exver"
export * as inputSpec from './actions/input'
export * as ISB from './actions/input/builder'
export * as IST from './actions/input/inputSpecTypes'
export * as types from './types'
export * as T from './types'
export * as yaml from 'yaml'
export * as inits from './inits'
export * as matches from 'ts-matches'
export * as inputSpec from "./actions/input"
export * as ISB from "./actions/input/builder"
export * as IST from "./actions/input/inputSpecTypes"
export * as types from "./types"
export * as T from "./types"
export * as yaml from "yaml"
export * as inits from "./inits"
export * as matches from "ts-matches"
export * as utils from './util'
export * as utils from "./util"

View File

@@ -1,2 +1,2 @@
export * from './setupInit'
export * from './setupUninit'
export * from "./setupInit"
export * from "./setupUninit"

View File

@@ -1,8 +1,8 @@
import { VersionRange } from '../../../base/lib/exver'
import * as T from '../../../base/lib/types'
import { once } from '../util'
import { VersionRange } from "../../../base/lib/exver"
import * as T from "../../../base/lib/types"
import { once } from "../util"
export type InitKind = 'install' | 'update' | 'restore' | null
export type InitKind = "install" | "update" | "restore" | null
export type InitFn<Kind extends InitKind = InitKind> = (
effects: T.Effects,
@@ -31,7 +31,7 @@ export function setupInit(...inits: InitScriptOrFn[]): T.ExpectedExports.init {
complete.then(() => fn()).catch(console.error),
)
try {
if ('init' in init) await init.init(e, opts.kind)
if ("init" in init) await init.init(e, opts.kind)
else await init(e, opts.kind)
} finally {
res()
@@ -43,7 +43,7 @@ export function setupInit(...inits: InitScriptOrFn[]): T.ExpectedExports.init {
}
export function setupOnInit(onInit: InitScriptOrFn): InitScript {
return 'init' in onInit
return "init" in onInit
? onInit
: {
init: async (effects, kind) => {

View File

@@ -1,5 +1,5 @@
import { ExtendedVersion, VersionRange } from '../../../base/lib/exver'
import * as T from '../../../base/lib/types'
import { ExtendedVersion, VersionRange } from "../../../base/lib/exver"
import * as T from "../../../base/lib/types"
export type UninitFn = (
effects: T.Effects,
@@ -34,14 +34,14 @@ export function setupUninit(
): T.ExpectedExports.uninit {
return async (opts) => {
for (const uninit of uninits) {
if ('uninit' in uninit) await uninit.uninit(opts.effects, opts.target)
if ("uninit" in uninit) await uninit.uninit(opts.effects, opts.target)
else await uninit(opts.effects, opts.target)
}
}
}
export function setupOnUninit(onUninit: UninitScriptOrFn): UninitScript {
return 'uninit' in onUninit
return "uninit" in onUninit
? onUninit
: {
uninit: async (effects, target) => {

View File

@@ -1,10 +1,10 @@
import { object, string } from 'ts-matches'
import { Effects } from '../Effects'
import { Origin } from './Origin'
import { AddSslOptions, BindParams } from '../osBindings'
import { Security } from '../osBindings'
import { BindOptions } from '../osBindings'
import { AlpnInfo } from '../osBindings'
import { object, string } from "ts-matches"
import { Effects } from "../Effects"
import { Origin } from "./Origin"
import { AddSslOptions, BindParams } from "../osBindings"
import { Security } from "../osBindings"
import { BindOptions } from "../osBindings"
import { AlpnInfo } from "../osBindings"
export { AddSslOptions, Security, BindOptions }
@@ -12,8 +12,8 @@ export const knownProtocols = {
http: {
secure: null,
defaultPort: 80,
withSsl: 'https',
alpn: { specified: ['http/1.1'] } as AlpnInfo,
withSsl: "https",
alpn: { specified: ["http/1.1"] } as AlpnInfo,
},
https: {
secure: { ssl: true },
@@ -22,8 +22,8 @@ export const knownProtocols = {
ws: {
secure: null,
defaultPort: 80,
withSsl: 'wss',
alpn: { specified: ['http/1.1'] } as AlpnInfo,
withSsl: "wss",
alpn: { specified: ["http/1.1"] } as AlpnInfo,
},
wss: {
secure: { ssl: true },
@@ -140,8 +140,8 @@ export class MultiHost {
addXForwardedHeaders: false,
preferredExternalPort: knownProtocols[sslProto].defaultPort,
scheme: sslProto,
alpn: 'alpn' in protoInfo ? protoInfo.alpn : null,
...('addSsl' in options ? options.addSsl : null),
alpn: "alpn" in protoInfo ? protoInfo.alpn : null,
...("addSsl" in options ? options.addSsl : null),
}
: options.addSsl
? {
@@ -149,7 +149,7 @@ export class MultiHost {
preferredExternalPort: 443,
scheme: sslProto,
alpn: null,
...('addSsl' in options ? options.addSsl : null),
...("addSsl" in options ? options.addSsl : null),
}
: null
@@ -169,8 +169,8 @@ export class MultiHost {
private getSslProto(options: BindOptionsByKnownProtocol) {
const proto = options.protocol
const protoInfo = knownProtocols[proto]
if (inObject('noAddSsl', options) && options.noAddSsl) return null
if ('withSsl' in protoInfo && protoInfo.withSsl) return protoInfo.withSsl
if (inObject("noAddSsl", options) && options.noAddSsl) return null
if ("withSsl" in protoInfo && protoInfo.withSsl) return protoInfo.withSsl
if (protoInfo.secure?.ssl) return proto
return null
}

View File

@@ -1,7 +1,7 @@
import { AddressInfo } from '../types'
import { AddressReceipt } from './AddressReceipt'
import { MultiHost, Scheme } from './Host'
import { ServiceInterfaceBuilder } from './ServiceInterfaceBuilder'
import { AddressInfo } from "../types"
import { AddressReceipt } from "./AddressReceipt"
import { MultiHost, Scheme } from "./Host"
import { ServiceInterfaceBuilder } from "./ServiceInterfaceBuilder"
export class Origin {
constructor(
@@ -21,9 +21,9 @@ export class Origin {
.map(
([key, val]) => `${encodeURIComponent(key)}=${encodeURIComponent(val)}`,
)
.join('&')
.join("&")
const qp = qpEntries.length ? `?${qpEntries}` : ''
const qp = qpEntries.length ? `?${qpEntries}` : ""
return {
hostId: this.host.options.id,

Some files were not shown because too many files have changed in this diff Show More