working
Some checks are pending
Build Service / BuildPackage (push) Waiting to run

This commit is contained in:
2026-05-14 05:39:56 -04:00
parent 5b7cd13dc0
commit 172e3f63ac
33 changed files with 1633 additions and 0 deletions

21
LICENSE Normal file
View File

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

3
Makefile Normal file
View File

@@ -0,0 +1,3 @@
ARCHES := x86
# overrides to s9pk.mk must precede the include statement
include s9pk.mk

192
README.md Normal file
View File

@@ -0,0 +1,192 @@
<p align="center">
<img src="icon.png" alt="Spaces" width="180" />
</p>
# Spaces on StartOS
> **Upstream docs:** <https://docs.spacesprotocol.org/>
>
> Everything not listed in this document should behave the same as upstream
> `spaced` / `space-cli`. If a feature, setting, or behavior is not mentioned
> here, the upstream documentation is accurate and fully applicable.
Spaces is a permissionless protocol for sovereign Bitcoin-anchored identities.
This package runs the [`spaced`](https://github.com/spacesops/spaced) daemon
against Bitcoin **mainnet** and exposes [`space-cli`](https://github.com/spacesops/spaced)
through a browser-based terminal.
---
## Table of Contents
- [Image and Container Runtime](#image-and-container-runtime)
- [Volume and Data Layout](#volume-and-data-layout)
- [Installation and First-Run Flow](#installation-and-first-run-flow)
- [Configuration Management](#configuration-management)
- [Network Access and Interfaces](#network-access-and-interfaces)
- [Actions](#actions)
- [Backups and Restore](#backups-and-restore)
- [Health Checks](#health-checks)
- [Limitations and Differences](#limitations-and-differences)
- [What Is Unchanged from Upstream](#what-is-unchanged-from-upstream)
- [Quick Reference for AI Consumers](#quick-reference-for-ai-consumers)
---
## Image and Container Runtime
| Field | Value |
| --- | --- |
| Image | `docker.io/horologger/spaces` |
| Architectures | `linux/amd64`, `linux/arm64` |
| Entrypoint | StartOS-managed (image entrypoint is **not** used) |
The image bundles `spaced`, `space-cli`, `bitcoin-cli`, `gotty`, `node`, `npm`,
`screen`, and a small shell environment. StartOS ignores the image's
`docker_entrypoint.sh`; daemons are defined in `startos/main.ts`.
## Volume and Data Layout
| Path | Volume | Purpose |
| --- | --- | --- |
| `/data` | `main` | Spaces data directory (`SPACED_DATA_DIR`), wallets, indexes, and `store.json` |
| `/data/mainnet/.cookie` | `main` | Spaced RPC cookie (auto-generated by `spaced` at startup) |
| `/data/store.json` | `main` | StartOS-managed credentials (web-UI password + bitcoind RPC user/password) |
## Installation and First-Run Flow
On the first install, StartOS:
1. Seeds `store.json.password` with a random 32-char alphanumeric admin password
and creates a critical task that points the user at **Show Web UI Credentials**
so the password can be copied before login.
2. Seeds `store.json.btcAuth` with a random `spaces:<random>` RPC credential
pair and creates a critical cross-service task on **Bitcoin** that runs
bitcoind's `generate-rpc-dependent` action to register the credentials in
`bitcoin.conf`.
3. Launches `spaced` as a managed daemon (no `screen`, no shell auto-start).
4. Launches the `gotty` web terminal once `spaced` is up.
The user is **not** prompted to choose a chain or RPC mode -- this package is
mainnet-only.
## Configuration Management
| StartOS-Managed | Upstream-Managed |
| --- | --- |
| Web-UI username / password (`admin` + generated password) | `spaced` runtime tuning via `SPACED_*` env vars in the image |
| Bitcoin RPC username / password (registered on bitcoind) | Wallet creation, bidding, and registration -- all driven via `space-cli` inside the terminal |
| Chain selection (locked to `mainnet`) | `space-cli` flags and subcommands |
| Spaced data directory and RPC bind | |
## Network Access and Interfaces
| Interface | Port | Protocol | Exposure | Notes |
| --- | --- | --- | --- | --- |
| Web UI (gotty terminal) | 8080 | HTTP | LAN / `.local` / Tor / clearnet (via StartOS) | Basic auth: `admin:<store.password>` |
| Spaced RPC | 7225 | HTTP JSON-RPC | **Loopback only** | Internal use, cookie-authenticated |
## Actions
| ID | Name | Visibility | Availability | Purpose |
| --- | --- | --- | --- | --- |
| `reset-password` | Reset Web UI Password | Enabled | Any | Regenerates the web-UI password and restarts the terminal daemon |
| `show-credentials` | Show Web UI Credentials | Hidden | Any | Surfaces the current `admin` username + masked password (launched by the first-install task) |
| `set-bitcoin-rpc` | Set up Bitcoin RPC | Enabled | Any | Re-invokes bitcoind's `generate-rpc-dependent` with the stored credentials. Safe to call repeatedly. |
| `sync-status` | Sync Status | Enabled | Only running | Runs `space-cli getserverinfo` inside the daemon container and returns the JSON output |
| `reset-spaced-state` | Reset Spaced State | Enabled | Any | Deletes `/data/mainnet/` so spaced resyncs its index from spaces' anchor. Preserves `store.json` (passwords + RPC credentials). Use when spaced crash-loops on a stale or corrupt index. |
## Backups and Restore
`sdk.Backups.ofVolumes('main')` -- the entire `/data` volume is backed up,
including spaced state, wallets, the block index, and `store.json`. On restore,
the same idempotent init logic runs and reuses the existing credentials in
`store.json`.
## Health Checks
| ID | Display | Grace period | Behaviour |
| --- | --- | --- | --- |
| `spaced` (daemon `ready`) | Spaced RPC | 120 s | TCP listen on `127.0.0.1:7225` |
| `web-terminal` (daemon `ready`) | Web Interface | default | TCP listen on `0.0.0.0:8080` |
| `sync` (standalone) | Spaced Sync | 300 s | Exec `space-cli getserverinfo`; reports `success` when `ready=true`, otherwise `loading` with progress percentage |
## Limitations and Differences
1. **Mainnet only.** Testnet, testnet4, signet, and regtest are not exposed.
2. **The image's `docker_entrypoint.sh` is not used.** Its auto-start of
`spaced` inside `screen` would conflict with the StartOS-managed daemon.
3. **Web UI is a terminal, not a graphical app.** All Spaces operations happen
via `space-cli` (aliased as `spaces` inside the shell).
4. **Only port 8080 (gotty) is exposed.** Spaced RPC, and any other ports
present in the image (e.g. 22253, 3000, 5173, 8081) are not bound.
5. **Bitcoin Core 31.x is the only supported dependency.** Earlier majors are
not allowed by the manifest version range.
6. **The web terminal is independent of spaced.** Gotty stays reachable even
when `spaced` is crash-looping, so you can always shell in to diagnose.
## What Is Unchanged from Upstream
- `space-cli` subcommands and flags work exactly as documented upstream.
- `spaced` honours all `SPACED_*` environment variables not otherwise set by
StartOS.
- Wallet files, the spaces database, and the block index are managed by
`spaced` itself; StartOS only provides the volume.
- bitcoind connectivity follows the standard StartOS dependency-service model
(`bitcoind.startos:8332`).
## Using the Web Terminal
After install:
1. Run the **Show Web UI Credentials** action (the install task surfaces it).
2. Open the Web UI from the StartOS dashboard.
3. Log in with `admin` and the displayed password.
4. Use `spaces <subcommand>` -- it expands to
`space-cli --chain mainnet --rpc-cookie /data/mainnet/.cookie <subcommand>`.
Examples:
```bash
spaces getserverinfo
spaces walletcreate default
spaces walletbalance default
```
## Quick Reference for AI Consumers
```yaml
package_id: spaces
upstream_version: subspacesplus
image: docker.io/horologger/spaces:v0.0.9s
architectures: [x86_64, aarch64]
volumes:
main: /data
ports:
ui: 8080
spaced_rpc: 7225 # loopback only
dependencies:
- bitcoind
startos_managed_env_vars:
- SPACED_CHAIN
- SPACED_DATA_DIR
- SPACED_RPC_BIND
- SPACED_RPC_PORT
- SPACED_RPC_URL
- SPACED_BLOCK_INDEX
- SPACED_BITCOIN_RPC_URL
- SPACED_BITCOIN_RPC_USER
- SPACED_BITCOIN_RPC_PASSWORD
- BTC_RPC_HOST
- BTC_RPC_PORT
- BTC_RPC_USER
- BTC_RPC_PASSWORD
- APP_USER
- APP_PASSWORD
actions:
- reset-password
- show-credentials
- set-bitcoin-rpc
- sync-status
- reset-spaced-state
```

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

64
instructions.md Normal file
View File

@@ -0,0 +1,64 @@
# Spaces
Spaces is a permissionless protocol for sovereign Bitcoin-anchored identities.
This package runs the [`spaced`](https://github.com/spacesops/spaced) daemon
against Bitcoin **mainnet** and exposes [`space-cli`](https://github.com/spacesops/spaced)
through a browser-based terminal.
## Getting set up
On first install, StartOS will:
1. Prompt you with a critical task — **Show Web UI Credentials** — to copy the
`admin` username and the auto-generated password for the web terminal.
2. Prompt you with a second critical task on **Bitcoin** to run
`generate-rpc-dependent` and register Spaces' RPC user in `bitcoin.conf`.
Approve the form (it is pre-filled) and Bitcoin will restart with the new
credentials.
Once both tasks are complete:
1. Open the **Web UI** from Spaces' dashboard.
2. Log in with `admin` and the displayed password.
3. Inside the terminal, run `spaces getserverinfo` to confirm that `spaced` is
talking to Bitcoin and has begun indexing.
## Using `space-cli`
Inside the web terminal, the `spaces` alias expands to
`space-cli --chain mainnet --rpc-cookie /data/mainnet/.cookie`. So:
```bash
spaces getserverinfo
spaces walletcreate default
spaces walletbalance default
```
## Actions
- **Reset Web UI Password** — rotates the admin password and restarts the
terminal daemon.
- **Show Web UI Credentials** — surfaces the current admin password (hidden;
reachable through the install task).
- **Set up Bitcoin RPC** — re-runs the bitcoind credential setup if state ever
drifts. Safe to call repeatedly.
- **Sync Status** — runs `space-cli getserverinfo` against the local daemon
and returns the JSON output.
- **Reset Spaced State** — wipes `/data/mainnet/` so spaced resyncs its index
from spaces' anchor. Use this if spaced is crash-looping on a stale or
corrupt index. `store.json` (passwords + RPC credentials) is preserved.
## Limitations
- **Mainnet only.** No testnet, signet, or regtest.
- The image's `docker_entrypoint.sh` is not used. `spaced` is managed by
StartOS, not started inside a `screen` session by login.
- The only externally bound port is `8080` (gotty). Spaced RPC stays on
loopback.
## Links
- Project home: <https://spacesprotocol.org>
- Upstream docs: <https://docs.spacesprotocol.org/>
- Source: <https://github.com/spacesops/spaced>
- Package source: <https://github.com/horologger/spaces-startos>

359
package-lock.json generated Normal file
View File

@@ -0,0 +1,359 @@
{
"name": "spaces-startos",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "spaces-startos",
"dependencies": {
"@start9labs/start-sdk": "1.5.1"
},
"devDependencies": {
"@types/node": "^22.19.0",
"@vercel/ncc": "^0.38.4",
"prettier": "^3.6.2",
"typescript": "^5.9.3"
}
},
"node_modules/@iarna/toml": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-3.0.0.tgz",
"integrity": "sha512-td6ZUkz2oS3VeleBcN+m//Q6HlCFCPrnI0FZhrt/h4XqLEdOyYp2u21nd8MdsR+WJy5r9PTDaHTDDfhf4H4l6Q==",
"license": "ISC"
},
"node_modules/@noble/curves": {
"version": "1.9.7",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.8.0"
},
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodable/entities": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",
"integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/nodable"
}
],
"license": "MIT"
},
"node_modules/@start9labs/start-sdk": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-1.5.1.tgz",
"integrity": "sha512-iztLiOCtHuTfUCd2JOWio4OvBk5qFGa0NI+G+ZB/dQ1sWtunYEnzqMcF6N/Ss4L6+7bBOMAMU4VuhyxeZoHyIw==",
"license": "MIT",
"dependencies": {
"@iarna/toml": "^3.0.0",
"@noble/curves": "^1.9.7",
"@noble/hashes": "^1.8.0",
"@types/ini": "^4.1.1",
"deep-equality-data-structures": "^2.0.0",
"fast-xml-parser": "~5.7.0",
"ini": "^5.0.0",
"isomorphic-fetch": "^3.0.0",
"mime": "^4.1.0",
"yaml": "^2.8.3",
"zod": "4.3.6",
"zod-deep-partial": "^1.2.0"
}
},
"node_modules/@types/ini": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@types/ini/-/ini-4.1.1.tgz",
"integrity": "sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@vercel/ncc": {
"version": "0.38.4",
"resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.38.4.tgz",
"integrity": "sha512-8LwjnlP39s08C08J5NstzriPvW1SP8Zfpp1BvC2sI35kPeZnHfxVkCwu4/+Wodgnd60UtT1n8K8zw+Mp7J9JmQ==",
"dev": true,
"license": "MIT",
"bin": {
"ncc": "dist/ncc/cli.js"
}
},
"node_modules/deep-equality-data-structures": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/deep-equality-data-structures/-/deep-equality-data-structures-2.0.0.tgz",
"integrity": "sha512-qgrUr7MKXq7VRN+WUpQ48QlXVGL0KdibAoTX8KRg18lgOgqbEKMAW1WZsVCtakY4+XX42pbAJzTz/DlXEFM2Fg==",
"license": "MIT",
"dependencies": {
"object-hash": "^3.0.0"
}
},
"node_modules/fast-xml-builder": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
"integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"path-expression-matcher": "^1.5.0",
"xml-naming": "^0.1.0"
}
},
"node_modules/fast-xml-parser": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz",
"integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"@nodable/entities": "^2.1.0",
"fast-xml-builder": "^1.1.7",
"path-expression-matcher": "^1.5.0",
"strnum": "^2.2.3"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/ini": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz",
"integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==",
"license": "ISC",
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/isomorphic-fetch": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz",
"integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.6.1",
"whatwg-fetch": "^3.4.1"
}
},
"node_modules/mime": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz",
"integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==",
"funding": [
"https://github.com/sponsors/broofa"
],
"license": "MIT",
"bin": {
"mime": "bin/cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/object-hash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/path-expression-matcher": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
"integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/prettier": {
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/strnum": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz",
"integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-fetch": {
"version": "3.6.20",
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==",
"license": "MIT"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/xml-naming": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",
"integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-deep-partial": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/zod-deep-partial/-/zod-deep-partial-1.4.4.tgz",
"integrity": "sha512-aWkPl7hVStgE01WzbbSxCgX4O+sSpgt8JOjvFUtMTF75VgL6MhWQbiZi+AWGN85SfSTtI9gsOtL1vInoqfDVaA==",
"license": "MIT",
"peerDependencies": {
"zod": "^4.1.13"
}
}
}
}

23
package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "spaces-startos",
"scripts": {
"build": "rm -rf ./javascript && ncc build startos/index.ts -o ./javascript",
"prettier": "prettier --write startos",
"check": "tsc --noEmit"
},
"dependencies": {
"@start9labs/start-sdk": "1.5.1"
},
"devDependencies": {
"@types/node": "^22.19.0",
"@vercel/ncc": "^0.38.4",
"prettier": "^3.6.2",
"typescript": "^5.9.3"
},
"prettier": {
"trailingComma": "all",
"tabWidth": 2,
"semi": false,
"singleQuote": true
}
}

132
s9pk.mk Normal file
View File

@@ -0,0 +1,132 @@
# ** Plumbing. DO NOT EDIT **.
# This file is imported by ./Makefile. Make edits there
PACKAGE_ID := $(shell awk -F"'" '/id:/ {print $$2}' startos/manifest/index.ts)
INGREDIENTS := $(shell start-cli s9pk list-ingredients 2>/dev/null)
# Resolve the actual git dir so this works inside git worktrees, where .git
# is a file pointing at <main>/.git/worktrees/<name> rather than a directory.
GIT_DIR := $(shell git rev-parse --git-dir 2>/dev/null)
GIT_DEPS := $(if $(GIT_DIR),$(GIT_DIR)/HEAD $(GIT_DIR)/index)
ARCHES ?= x86 arm riscv
TARGETS ?= arches
ifdef VARIANT
BASE_NAME := $(PACKAGE_ID)_$(VARIANT)
else
BASE_NAME := $(PACKAGE_ID)
endif
.PHONY: all arches aarch64 x86_64 riscv64 arm arm64 x86 riscv arch/* clean install check-deps check-init package ingredients
.DELETE_ON_ERROR:
.SECONDARY:
define SUMMARY
@manifest=$$(start-cli s9pk inspect $(1) manifest); \
size=$$(du -h $(1) | awk '{print $$1}'); \
title=$$(printf '%s' "$$manifest" | jq -r .title); \
version=$$(printf '%s' "$$manifest" | jq -r .version); \
arches=$$(printf '%s' "$$manifest" | jq -r '[.images[].arch // []] | flatten | unique | join(", ")'); \
sdkv=$$(printf '%s' "$$manifest" | jq -r .sdkVersion); \
gitHash=$$(printf '%s' "$$manifest" | jq -r .gitHash | sed -E 's/(.*-modified)$$/\x1b[0;31m\1\x1b[0m/'); \
printf "\n"; \
printf "\033[1;32m✅ Build Complete!\033[0m\n"; \
printf "\n"; \
printf "\033[1;37m📦 $$title\033[0m \033[36mv$$version\033[0m\n"; \
printf "───────────────────────────────\n"; \
printf " \033[1;36mFilename:\033[0m %s\n" "$(1)"; \
printf " \033[1;36mSize:\033[0m %s\n" "$$size"; \
printf " \033[1;36mArch:\033[0m %s\n" "$$arches"; \
printf " \033[1;36mSDK:\033[0m %s\n" "$$sdkv"; \
printf " \033[1;36mGit:\033[0m %s\n" "$$gitHash"; \
echo ""
endef
all: $(TARGETS)
arches: $(ARCHES)
universal: $(BASE_NAME).s9pk
$(call SUMMARY,$<)
arch/%: $(BASE_NAME)_%.s9pk
$(call SUMMARY,$<)
x86 x86_64: arch/x86_64
arm arm64 aarch64: arch/aarch64
riscv riscv64: arch/riscv64
$(BASE_NAME).s9pk: $(INGREDIENTS) $(GIT_DEPS)
@$(MAKE) --no-print-directory ingredients
@echo " Packing '$@'..."
start-cli s9pk pack -o $@
$(BASE_NAME)_%.s9pk: $(INGREDIENTS) $(GIT_DEPS)
@$(MAKE) --no-print-directory ingredients
@echo " Packing '$@'..."
start-cli s9pk pack --arch=$* -o $@
ingredients: $(INGREDIENTS)
@echo " Re-evaluating ingredients..."
install: | check-deps check-init
@HOST=$$(awk -F'/' '/^host:/ {print $$3}' ~/.startos/config.yaml); \
if [ -z "$$HOST" ]; then \
echo "Error: You must define \"host: http://server-name.local\" in ~/.startos/config.yaml"; \
exit 1; \
fi; \
S9PK=$$(ls -t *.s9pk 2>/dev/null | head -1); \
if [ -z "$$S9PK" ]; then \
echo "Error: No .s9pk file found. Run 'make' first."; \
exit 1; \
fi; \
printf "\n🚀 Installing %s to %s ...\n" "$$S9PK" "$$HOST"; \
start-cli package install -s "$$S9PK"
publish: | all
@REGISTRY=$$(awk -F'/' '/^registry:/ {print $$3}' ~/.startos/config.yaml); \
if [ -z "$$REGISTRY" ]; then \
echo "Error: You must define \"registry: https://my-registry.tld\" in ~/.startos/config.yaml"; \
exit 1; \
fi; \
S3BASE=$$(awk -F'/' '/^s9pk-s3base:/ {print $$3}' ~/.startos/config.yaml); \
if [ -z "$$S3BASE" ]; then \
echo "Error: You must define \"s3base: https://s9pks.my-s3-bucket.tld\" in ~/.startos/config.yaml"; \
exit 1; \
fi; \
command -v s3cmd >/dev/null || \
(echo "Error: s3cmd not found. It must be installed to publish using s3." && exit 1); \
printf "\n🚀 Publishing to %s; indexing on %s ...\n" "$$S3BASE" "$$REGISTRY"; \
for s9pk in *.s9pk; do \
age=$$(( $$(date +%s) - $$(stat -c %Y "$$s9pk") )); \
if [ "$$age" -gt 3600 ]; then \
printf "\033[1;33m⚠ %s is %d minutes old. Publish anyway? [y/N] \033[0m" "$$s9pk" "$$((age / 60))"; \
read -r ans; \
case "$$ans" in [yY]*) ;; *) echo "Skipping $$s9pk"; continue ;; esac; \
fi; \
start-cli s9pk publish "$$s9pk"; \
done
check-deps:
@command -v start-cli >/dev/null || \
(echo "Error: start-cli not found. Please see https://docs.start9.com/latest/developer-guide/sdk/installing-the-sdk" && exit 1)
@command -v npm >/dev/null || \
(echo "Error: npm not found. Please install Node.js and npm." && exit 1)
check-init:
@if [ ! -f ~/.startos/developer.key.pem ]; then \
echo "Initializing StartOS developer environment..."; \
start-cli init-key; \
fi
javascript/index.js: $(shell find startos -type f) tsconfig.json node_modules
npm run check
npm run build
node_modules: package-lock.json
npm ci
package-lock.json: package.json
npm i
clean:
@echo "Cleaning up build artifacts..."
@rm -rf $(PACKAGE_ID).s9pk $(PACKAGE_ID)_x86_64.s9pk $(PACKAGE_ID)_aarch64.s9pk $(PACKAGE_ID)_riscv64.s9pk javascript node_modules

13
startos/actions/index.ts Normal file
View File

@@ -0,0 +1,13 @@
import { sdk } from '../sdk'
import { resetPassword } from './resetPassword'
import { resetSpacedState } from './resetSpacedState'
import { setBitcoinRpc } from './setBitcoinRpc'
import { showCredentials } from './showCredentials'
import { syncStatus } from './syncStatus'
export const actions = sdk.Actions.of()
.addAction(resetPassword)
.addAction(showCredentials)
.addAction(setBitcoinRpc)
.addAction(syncStatus)
.addAction(resetSpacedState)

View File

@@ -0,0 +1,58 @@
import { storeJson } from '../fileModels/storeJson'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { APP_USER, randomPassword } from '../utils'
export const resetPassword = sdk.Action.withoutInput(
// id
'reset-password',
// metadata
async ({ effects }) => ({
name: i18n('Reset Web UI Password'),
description: i18n(
'Generate a new admin password for the Spaces web terminal',
),
warning: null,
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
// run
async ({ effects }) => {
const password = randomPassword()
await storeJson.merge(effects, { password })
return {
version: '1',
title: i18n('Success'),
message: i18n(
'Use these credentials to log in to the Spaces web terminal.',
),
result: {
type: 'group',
value: [
{
type: 'single',
name: i18n('Username'),
description: null,
value: APP_USER,
masked: false,
copyable: true,
qr: false,
},
{
type: 'single',
name: i18n('Password'),
description: null,
value: password,
masked: true,
copyable: true,
qr: false,
},
],
},
}
},
)

View File

@@ -0,0 +1,59 @@
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { dataDir, SPACED_CHAIN } from '../utils'
export const resetSpacedState = sdk.Action.withoutInput(
// id
'reset-spaced-state',
// metadata
async ({ effects }) => ({
name: i18n('Reset Spaced State'),
description: i18n(
"Wipe /data/mainnet/ so spaced resyncs its index from spaces' anchor.",
),
warning: i18n(
"This deletes spaced's on-disk index. The next start will resync from spaces' anchor and can take a while. store.json (passwords + RPC credentials) is preserved.",
),
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
// run
async ({ effects }) => {
const res = await sdk.SubContainer.withTemp(
effects,
{ imageId: 'spaces' },
sdk.Mounts.of().mountVolume({
volumeId: 'main',
subpath: null,
mountpoint: dataDir,
readonly: false,
}),
'spaces-reset-state',
(subc) =>
subc.exec(['rm', '-rf', `${dataDir}/${SPACED_CHAIN}`], { user: 'root' }),
)
if (res.exitCode !== 0) {
return {
version: '1',
title: i18n('Failure'),
message: i18n('Could not wipe spaced state: ${error}', {
error: (res.stderr ?? '').toString() || `exit ${res.exitCode}`,
}),
result: null,
}
}
return {
version: '1',
title: i18n('Success'),
message: i18n(
'Spaced state has been wiped. Start (or restart) the service to resync.',
),
result: null,
}
},
)

View File

@@ -0,0 +1,60 @@
import { storeJson } from '../fileModels/storeJson'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import {
BITCOIN_RPC_USER,
BITCOIND_PACKAGE_ID,
randomPassword,
} from '../utils'
export const setBitcoinRpc = sdk.Action.withoutInput(
// id
'set-bitcoin-rpc',
// metadata
async ({ effects }) => ({
name: i18n('Set up Bitcoin RPC'),
description: i18n('Re-run the bitcoind RPC credential setup for Spaces'),
warning: null,
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
// run
async ({ effects }) => {
const existing = await storeJson.read((s) => s.btcAuth).once()
const username = existing?.username ?? BITCOIN_RPC_USER
const password = existing?.password ?? randomPassword()
try {
await effects.action.run({
packageId: BITCOIND_PACKAGE_ID,
actionId: 'generate-rpc-dependent',
input: { username, password },
})
} catch (err) {
return {
version: '1',
title: i18n('Failure'),
message: i18n('Could not call bitcoind. Is Bitcoin installed and running?'),
result: null,
}
}
if (!existing) {
await storeJson.merge(
effects,
{ btcAuth: { username, password } },
{ allowWriteAfterConst: true },
)
}
return {
version: '1',
title: i18n('Success'),
message: i18n('Bitcoin RPC credentials have been re-sent to bitcoind.'),
result: null,
}
},
)

View File

@@ -0,0 +1,57 @@
import { storeJson } from '../fileModels/storeJson'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { APP_USER } from '../utils'
export const showCredentials = sdk.Action.withoutInput(
// id
'show-credentials',
// metadata
async ({ effects }) => ({
name: i18n('Show Web UI Credentials'),
description: i18n(
'Display the username and password for the Spaces web terminal',
),
warning: null,
allowedStatuses: 'any',
group: null,
visibility: 'hidden',
}),
// run
async ({ effects }) => {
const password = await storeJson.read((s) => s.password).once()
return {
version: '1',
title: i18n('Show Web UI Credentials'),
message: i18n(
'Use these credentials to log in to the Spaces web terminal.',
),
result: {
type: 'group',
value: [
{
type: 'single',
name: i18n('Username'),
description: null,
value: APP_USER,
masked: false,
copyable: true,
qr: false,
},
{
type: 'single',
name: i18n('Password'),
description: null,
value: password ?? '',
masked: true,
copyable: true,
qr: false,
},
],
},
}
},
)

View File

@@ -0,0 +1,69 @@
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { dataDir, SPACED_CHAIN } from '../utils'
export const syncStatus = sdk.Action.withoutInput(
// id
'sync-status',
// metadata
async ({ effects }) => ({
name: i18n('Sync Status'),
description: i18n(
"Query spaced's getserverinfo RPC and report sync progress",
),
warning: null,
allowedStatuses: 'only-running',
group: null,
visibility: 'enabled',
}),
// run
async ({ effects }) => {
const res = await sdk.SubContainer.withTemp(
effects,
{ imageId: 'spaces' },
sdk.Mounts.of().mountVolume({
volumeId: 'main',
subpath: null,
mountpoint: dataDir,
readonly: false,
}),
'spaces-sync-status',
(subc) =>
subc.exec([
'/root/.cargo/bin/space-cli',
'--chain',
SPACED_CHAIN,
'--rpc-cookie',
`${dataDir}/${SPACED_CHAIN}/.cookie`,
'getserverinfo',
]),
)
if (res.exitCode !== 0) {
return {
version: '1',
title: i18n('Failure'),
message: i18n('Could not query spaced. Is the service running?'),
result: null,
}
}
const stdout = (res.stdout ?? '').toString().trim()
return {
version: '1',
title: i18n('Sync Status'),
message: stdout || i18n('spaced is fully synced.'),
result: {
type: 'single',
name: 'getserverinfo',
description: null,
value: stdout,
masked: false,
copyable: true,
qr: false,
},
}
},
)

5
startos/backups.ts Normal file
View File

@@ -0,0 +1,5 @@
import { sdk } from './sdk'
export const { createBackup, restoreInit } = sdk.setupBackups(
async ({ effects }) => sdk.Backups.ofVolumes('main'),
)

9
startos/dependencies.ts Normal file
View File

@@ -0,0 +1,9 @@
import { sdk } from './sdk'
export const setDependencies = sdk.setupDependencies(async ({ effects }) => ({
bitcoind: {
kind: 'running',
versionRange: '>=31.0:0 <32.0:0',
healthChecks: ['bitcoind'],
},
}))

View File

@@ -0,0 +1,18 @@
import { FileHelper, z } from '@start9labs/start-sdk'
import { sdk } from '../sdk'
const shape = z.object({
password: z.string().nullable().catch(null),
btcAuth: z
.object({
username: z.string(),
password: z.string(),
})
.nullable()
.catch(null),
})
export const storeJson = FileHelper.json(
{ base: sdk.volumes.main, subpath: 'store.json' },
shape,
)

View File

@@ -0,0 +1,63 @@
export const DEFAULT_LANG = 'en_US'
const dict = {
// main.ts
'Starting Spaces!': 0,
'Web Interface': 1,
'The web terminal is ready': 2,
'The web terminal is not ready': 3,
'Spaced RPC': 4,
'spaced RPC is ready': 5,
'spaced RPC is not ready': 6,
'Spaced Sync': 7,
'spaced is querying Bitcoin and indexing — this can take a while on first run.':
8,
'spaced is fully synced.': 9,
'spaced is indexing. Progress: ${pct}%': 10,
'spaced RPC did not respond.': 11,
// interfaces.ts
'Web UI': 12,
'Browser terminal that exposes space-cli inside the Spaces container.': 13,
// dependencies.ts / tasks
'Spaces needs RPC credentials in Bitcoin': 14,
'Spaces needs an admin password for the web terminal': 15,
// actions
'Reset Web UI Password': 16,
'Generate a new admin password for the Spaces web terminal': 17,
'Show Web UI Credentials': 18,
'Display the username and password for the Spaces web terminal': 19,
'Set up Bitcoin RPC': 20,
'Re-run the bitcoind RPC credential setup for Spaces': 21,
'Sync Status': 22,
"Query spaced's getserverinfo RPC and report sync progress": 23,
Username: 24,
Password: 25,
Success: 26,
Failure: 27,
'Bitcoin RPC credentials have been re-sent to bitcoind.': 28,
'Could not call bitcoind. Is Bitcoin installed and running?': 29,
'Could not query spaced. Is the service running?': 30,
'Use these credentials to log in to the Spaces web terminal.': 31,
// manifest description fields
'Sovereign Bitcoin identities.': 32,
// misc
'Configure your Spaces web terminal password': 33,
'Reset Spaced State': 34,
"Wipe /data/mainnet/ so spaced resyncs its index from spaces' anchor.": 35,
"This deletes spaced's on-disk index. The next start will resync from spaces' anchor and can take a while. store.json (passwords + RPC credentials) is preserved.":
36,
'Spaced state has been wiped. Start (or restart) the service to resync.': 37,
'Could not wipe spaced state: ${error}': 38,
} as const
/**
* Plumbing. DO NOT EDIT.
*/
export type I18nKey = keyof typeof dict
export type LangDict = Record<(typeof dict)[I18nKey], string>
export default dict

View File

@@ -0,0 +1,3 @@
import { LangDict } from './default'
export default {} satisfies Record<string, LangDict>

8
startos/i18n/index.ts Normal file
View File

@@ -0,0 +1,8 @@
/**
* Plumbing. DO NOT EDIT this file.
*/
import { setupI18n } from '@start9labs/start-sdk'
import defaultDict, { DEFAULT_LANG } from './dictionaries/default'
import translations from './dictionaries/translations'
export const i18n = setupI18n(defaultDict, translations, DEFAULT_LANG)

11
startos/index.ts Normal file
View File

@@ -0,0 +1,11 @@
/**
* Plumbing. DO NOT EDIT.
*/
export { createBackup } from './backups'
export { main } from './main'
export { init, uninit } from './init'
export { actions } from './actions'
import { buildManifest } from '@start9labs/start-sdk'
import { manifest as sdkManifest } from './manifest'
import { versionGraph } from './versions'
export const manifest = buildManifest(versionGraph, sdkManifest)

20
startos/init/index.ts Normal file
View File

@@ -0,0 +1,20 @@
import { actions } from '../actions'
import { restoreInit } from '../backups'
import { setDependencies } from '../dependencies'
import { setInterfaces } from '../interfaces'
import { sdk } from '../sdk'
import { versionGraph } from '../versions'
import { taskBtcAuth } from './taskBtcAuth'
import { taskSetPassword } from './taskSetPassword'
export const init = sdk.setupInit(
restoreInit,
versionGraph,
setInterfaces,
setDependencies,
actions,
taskBtcAuth,
taskSetPassword,
)
export const uninit = sdk.setupUninit(versionGraph)

View File

@@ -0,0 +1,34 @@
import { storeJson } from '../fileModels/storeJson'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import {
BITCOIN_RPC_USER,
BITCOIND_PACKAGE_ID,
randomPassword,
} from '../utils'
export const taskBtcAuth = sdk.setupOnInit(async (effects) => {
const existing = await storeJson.read((s) => s.btcAuth).once()
if (existing) return
const username = BITCOIN_RPC_USER
const password = randomPassword()
await effects.action.createTask({
replayId: 'spaces-rpc-auth',
packageId: BITCOIND_PACKAGE_ID,
actionId: 'generate-rpc-dependent',
severity: 'critical',
reason: i18n('Spaces needs RPC credentials in Bitcoin'),
input: {
kind: 'partial',
value: { username, password },
},
})
await storeJson.merge(
effects,
{ btcAuth: { username, password } },
{ allowWriteAfterConst: true },
)
})

View File

@@ -0,0 +1,20 @@
import { showCredentials } from '../actions/showCredentials'
import { storeJson } from '../fileModels/storeJson'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { randomPassword } from '../utils'
export const taskSetPassword = sdk.setupOnInit(async (effects) => {
const existing = await storeJson.read((s) => s.password).once()
if (existing) return
await storeJson.merge(
effects,
{ password: randomPassword() },
{ allowWriteAfterConst: true },
)
await sdk.action.createOwnTask(effects, showCredentials, 'critical', {
reason: i18n('Spaces needs an admin password for the web terminal'),
})
})

27
startos/interfaces.ts Normal file
View File

@@ -0,0 +1,27 @@
import { i18n } from './i18n'
import { sdk } from './sdk'
import { uiPort } from './utils'
export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
const uiMulti = sdk.MultiHost.of(effects, 'ui-multi')
const uiMultiOrigin = await uiMulti.bindPort(uiPort, {
protocol: 'http',
})
const ui = sdk.createInterface(effects, {
name: i18n('Web UI'),
id: 'ui',
description: i18n(
'Browser terminal that exposes space-cli inside the Spaces container.',
),
type: 'ui',
masked: false,
schemeOverride: null,
username: null,
path: '',
query: {},
})
const uiReceipt = await uiMultiOrigin.export([ui])
return [uiReceipt]
})

187
startos/main.ts Normal file
View File

@@ -0,0 +1,187 @@
import { storeJson } from './fileModels/storeJson'
import { i18n } from './i18n'
import { sdk } from './sdk'
import {
APP_USER,
BITCOIND_RPC_HOSTNAME,
BITCOIND_RPC_PORT,
dataDir,
SPACED_CHAIN,
spacedRpcPort,
uiPort,
} from './utils'
export const main = sdk.setupMain(async ({ effects }) => {
console.info(i18n('Starting Spaces!'))
const store = await storeJson.read().const(effects)
if (!store?.password || !store?.btcAuth) {
// taskSetPassword + taskBtcAuth both seed these in init; if they aren't
// populated yet, init hasn't finished — let StartOS restart us.
throw new Error('Spaces store.json is not yet populated.')
}
const { password: APP_PASSWORD, btcAuth } = store
const spacedEnv = {
SPACED_CHAIN,
SPACED_DATA_DIR: dataDir,
SPACED_RPC_BIND: '127.0.0.1',
SPACED_RPC_PORT: String(spacedRpcPort),
SPACED_RPC_URL: `http://127.0.0.1:${spacedRpcPort}`,
SPACED_BLOCK_INDEX: 'true',
SPACED_BITCOIN_RPC_URL: `http://${BITCOIND_RPC_HOSTNAME}:${BITCOIND_RPC_PORT}`,
SPACED_BITCOIN_RPC_USER: btcAuth.username,
SPACED_BITCOIN_RPC_PASSWORD: btcAuth.password,
// legacy aliases for `bitcoin-cli` / shell helpers that read these names
BTC_RPC_HOST: BITCOIND_RPC_HOSTNAME,
BTC_RPC_PORT: String(BITCOIND_RPC_PORT),
BTC_RPC_USER: btcAuth.username,
BTC_RPC_PASSWORD: btcAuth.password,
APP_USER,
APP_PASSWORD,
}
const mounts = sdk.Mounts.of().mountVolume({
volumeId: 'main',
subpath: null,
mountpoint: dataDir,
readonly: false,
})
const spacedSub = await sdk.SubContainer.of(
effects,
{ imageId: 'spaces' },
mounts,
'spaced-sub',
)
const termSub = await sdk.SubContainer.of(
effects,
{ imageId: 'spaces' },
mounts,
'terminal-sub',
)
const bashrc = [
'export PATH=/root/.cargo/bin:/data/bin:/usr/local/bin:/usr/bin:/bin',
"export PS1='spaces:\\w$ '",
`alias spaces='space-cli --chain ${SPACED_CHAIN} --rpc-cookie ${dataDir}/${SPACED_CHAIN}/.cookie '`,
'cat <<EOF',
'',
'┌─ Spaces ─────────────────────────────────────────────────┐',
'│ spaced is managed by StartOS — do not run it manually. │',
'│ Use the `spaces` alias to call space-cli, e.g. │',
'│ spaces getserverinfo │',
'│ Docs: https://docs.spacesprotocol.org/ │',
'└──────────────────────────────────────────────────────────┘',
'',
'EOF',
].join('\n')
return sdk.Daemons.of(effects)
.addOneshot('bashrc', {
subcontainer: termSub,
exec: {
command: ['bash', '-c', `cat > /root/.bashrc <<'SPACES_BASHRC_EOF'
${bashrc}
SPACES_BASHRC_EOF`],
user: 'root',
},
requires: [],
})
.addDaemon('spaced', {
subcontainer: spacedSub,
exec: {
command: ['/root/.cargo/bin/spaced'],
env: spacedEnv,
},
ready: {
display: i18n('Spaced RPC'),
fn: () =>
sdk.healthCheck.checkPortListening(effects, spacedRpcPort, {
successMessage: i18n('spaced RPC is ready'),
errorMessage: i18n('spaced RPC is not ready'),
}),
gracePeriod: 120_000,
},
requires: [],
})
.addDaemon('web-terminal', {
subcontainer: termSub,
exec: {
command: [
'gotty',
'--port',
String(uiPort),
'-c',
`${APP_USER}:${APP_PASSWORD}`,
'--permit-write',
'--reconnect',
'/bin/bash',
],
env: spacedEnv,
},
ready: {
display: i18n('Web Interface'),
fn: () =>
sdk.healthCheck.checkPortListening(effects, uiPort, {
successMessage: i18n('The web terminal is ready'),
errorMessage: i18n('The web terminal is not ready'),
}),
},
requires: ['bashrc'],
})
.addHealthCheck('sync', {
ready: {
display: i18n('Spaced Sync'),
fn: async () => {
const probe = await spacedSub.exec(
[
'bash',
'-c',
`/root/.cargo/bin/space-cli --chain ${SPACED_CHAIN} --rpc-cookie ${dataDir}/${SPACED_CHAIN}/.cookie getserverinfo`,
],
{},
)
if (probe.exitCode !== 0) {
return {
result: 'starting',
message: i18n(
'spaced is querying Bitcoin and indexing — this can take a while on first run.',
),
}
}
const stdout = (probe.stdout ?? '').toString()
let progress = 0
try {
const parsed = JSON.parse(stdout) as {
ready?: boolean
progress?: number
chain?: { blocks?: number; headers?: number }
}
if (parsed.ready === true) {
return {
result: 'success',
message: i18n('spaced is fully synced.'),
}
}
progress = Math.floor((parsed.progress ?? 0) * 100)
} catch {
// fall through to loading
}
return {
result: 'loading',
message: i18n('spaced is indexing. Progress: ${pct}%', {
pct: String(progress),
}),
}
},
gracePeriod: 300_000,
},
requires: ['spaced'],
})
})

12
startos/manifest/i18n.ts Normal file
View File

@@ -0,0 +1,12 @@
export const short = {
en_US: 'Sovereign Bitcoin identities.',
}
export const long = {
en_US:
'Spaces is a permissionless protocol for sovereign Bitcoin-anchored identities. This package runs the spaced daemon against Bitcoin mainnet and exposes space-cli through a browser-based terminal.',
}
export const depBitcoindDescription = {
en_US: 'Provides the Bitcoin block source spaced indexes.',
}

39
startos/manifest/index.ts Normal file
View File

@@ -0,0 +1,39 @@
import { setupManifest } from '@start9labs/start-sdk'
import { depBitcoindDescription, long, short } from './i18n'
export const manifest = setupManifest({
id: 'spaces',
title: 'Spaces',
license: 'MIT',
packageRepo: 'https://github.com/horologger/spaces-startos',
upstreamRepo: 'https://github.com/spacesops/spaced',
marketingUrl: 'https://spacesprotocol.org',
donationUrl: null,
docsUrls: ['https://docs.spacesprotocol.org/'],
description: { short, long },
volumes: ['main'],
images: {
spaces: {
source: { dockerTag: 'horologger/spaces:v0.0.9s' },
arch: ['x86_64', 'aarch64'],
},
},
alerts: {
install: null,
update: null,
uninstall: null,
restore: null,
start: null,
stop: null,
},
dependencies: {
bitcoind: {
description: depBitcoindDescription,
optional: false,
metadata: {
title: 'Bitcoin',
icon: 'https://raw.githubusercontent.com/Start9Labs/bitcoin-core-startos/feec0b1dae42961a257948fe39b40caf8672fce1/dep-icon.svg',
},
},
},
})

9
startos/sdk.ts Normal file
View File

@@ -0,0 +1,9 @@
import { StartSdk } from '@start9labs/start-sdk'
import { manifest } from './manifest'
/**
* Plumbing. DO NOT EDIT.
*
* The exported "sdk" const is used throughout this package codebase.
*/
export const sdk = StartSdk.of().withManifest(manifest).build(true)

26
startos/utils.ts Normal file
View File

@@ -0,0 +1,26 @@
import { utils } from '@start9labs/start-sdk'
export const uiPort = 8080
export const spacedRpcPort = 7225
export const dataDir = '/data'
export const APP_USER = 'admin'
export const BITCOIN_RPC_USER = 'spaces'
export const BITCOIND_PACKAGE_ID = 'bitcoind'
export const BITCOIND_RPC_HOSTNAME = 'bitcoind.startos'
export const BITCOIND_RPC_PORT = 8332
export const SPACED_CHAIN = 'mainnet'
export function randomPassword() {
// bitcoind's generate-rpc-dependent action validates the password against
// /^[A-Za-z0-9_-]+$/, so the charset must stay in that set.
return utils.getDefaultString({
charset: 'a-z,A-Z,1-9',
len: 32,
})
}

View File

@@ -0,0 +1,7 @@
import { VersionGraph } from '@start9labs/start-sdk'
import { v_0_0_9_0_a1 } from './v0.0.9.0.a1'
export const versionGraph = VersionGraph.of({
current: v_0_0_9_0_a1,
other: [],
})

View File

@@ -0,0 +1,14 @@
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
export const v_0_0_9_0_a1 = VersionInfo.of({
version: '0.0.9:0-alpha.1',
releaseNotes: {
en_US: `- Add "Reset Spaced State" action so a corrupt /data/mainnet/ index can be wiped from the UI.
- Web terminal no longer requires spaced to be healthy, so gotty stays reachable when spaced crash-loops.
- Initial build bundles spaced + space-cli from horologger/spaces:v0.0.9s, managed spaced daemon (mainnet only), gotty browser terminal with admin basic auth, and the Bitcoin Core 31.x dependency.`,
},
migrations: {
up: async ({ effects }) => {},
down: IMPOSSIBLE,
},
})

11
tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"include": ["startos/**/*.ts", "node_modules/**/startos"],
"compilerOptions": {
"target": "ES2018",
"module": "CommonJS",
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true
}
}