diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9ffebe4 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..927e1fc --- /dev/null +++ b/Makefile @@ -0,0 +1,3 @@ +ARCHES := x86 +# overrides to s9pk.mk must precede the include statement +include s9pk.mk diff --git a/README.md b/README.md new file mode 100644 index 0000000..d5bf9a1 --- /dev/null +++ b/README.md @@ -0,0 +1,192 @@ +

+ Spaces +

+ +# Spaces on StartOS + +> **Upstream docs:** +> +> 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:` 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:` | +| 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 ` -- it expands to + `space-cli --chain mainnet --rpc-cookie /data/mainnet/.cookie `. + +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 +``` diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..8e51a5d Binary files /dev/null and b/assets/icon.png differ diff --git a/instructions.md b/instructions.md new file mode 100644 index 0000000..3a6ea7e --- /dev/null +++ b/instructions.md @@ -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: +- Upstream docs: +- Source: +- Package source: diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..62fa0aa --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c9dfbbb --- /dev/null +++ b/package.json @@ -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 + } +} diff --git a/s9pk.mk b/s9pk.mk new file mode 100644 index 0000000..978a059 --- /dev/null +++ b/s9pk.mk @@ -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
/.git/worktrees/ 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 diff --git a/startos/actions/index.ts b/startos/actions/index.ts new file mode 100644 index 0000000..7c4c832 --- /dev/null +++ b/startos/actions/index.ts @@ -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) diff --git a/startos/actions/resetPassword.ts b/startos/actions/resetPassword.ts new file mode 100644 index 0000000..1d4169a --- /dev/null +++ b/startos/actions/resetPassword.ts @@ -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, + }, + ], + }, + } + }, +) diff --git a/startos/actions/resetSpacedState.ts b/startos/actions/resetSpacedState.ts new file mode 100644 index 0000000..d1f9f0e --- /dev/null +++ b/startos/actions/resetSpacedState.ts @@ -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, + } + }, +) diff --git a/startos/actions/setBitcoinRpc.ts b/startos/actions/setBitcoinRpc.ts new file mode 100644 index 0000000..a03ffa7 --- /dev/null +++ b/startos/actions/setBitcoinRpc.ts @@ -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, + } + }, +) diff --git a/startos/actions/showCredentials.ts b/startos/actions/showCredentials.ts new file mode 100644 index 0000000..4cb9d80 --- /dev/null +++ b/startos/actions/showCredentials.ts @@ -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, + }, + ], + }, + } + }, +) diff --git a/startos/actions/syncStatus.ts b/startos/actions/syncStatus.ts new file mode 100644 index 0000000..c256a93 --- /dev/null +++ b/startos/actions/syncStatus.ts @@ -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, + }, + } + }, +) diff --git a/startos/backups.ts b/startos/backups.ts new file mode 100644 index 0000000..0a90b1e --- /dev/null +++ b/startos/backups.ts @@ -0,0 +1,5 @@ +import { sdk } from './sdk' + +export const { createBackup, restoreInit } = sdk.setupBackups( + async ({ effects }) => sdk.Backups.ofVolumes('main'), +) diff --git a/startos/dependencies.ts b/startos/dependencies.ts new file mode 100644 index 0000000..9d90098 --- /dev/null +++ b/startos/dependencies.ts @@ -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'], + }, +})) diff --git a/startos/fileModels/storeJson.ts b/startos/fileModels/storeJson.ts new file mode 100644 index 0000000..b5e3ba0 --- /dev/null +++ b/startos/fileModels/storeJson.ts @@ -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, +) diff --git a/startos/i18n/dictionaries/default.ts b/startos/i18n/dictionaries/default.ts new file mode 100644 index 0000000..83a9abf --- /dev/null +++ b/startos/i18n/dictionaries/default.ts @@ -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 diff --git a/startos/i18n/dictionaries/translations.ts b/startos/i18n/dictionaries/translations.ts new file mode 100644 index 0000000..98f9348 --- /dev/null +++ b/startos/i18n/dictionaries/translations.ts @@ -0,0 +1,3 @@ +import { LangDict } from './default' + +export default {} satisfies Record diff --git a/startos/i18n/index.ts b/startos/i18n/index.ts new file mode 100644 index 0000000..04cea20 --- /dev/null +++ b/startos/i18n/index.ts @@ -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) diff --git a/startos/index.ts b/startos/index.ts new file mode 100644 index 0000000..7af589b --- /dev/null +++ b/startos/index.ts @@ -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) diff --git a/startos/init/index.ts b/startos/init/index.ts new file mode 100644 index 0000000..3dd81a2 --- /dev/null +++ b/startos/init/index.ts @@ -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) diff --git a/startos/init/taskBtcAuth.ts b/startos/init/taskBtcAuth.ts new file mode 100644 index 0000000..2615a95 --- /dev/null +++ b/startos/init/taskBtcAuth.ts @@ -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 }, + ) +}) diff --git a/startos/init/taskSetPassword.ts b/startos/init/taskSetPassword.ts new file mode 100644 index 0000000..1933311 --- /dev/null +++ b/startos/init/taskSetPassword.ts @@ -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'), + }) +}) diff --git a/startos/interfaces.ts b/startos/interfaces.ts new file mode 100644 index 0000000..79a9719 --- /dev/null +++ b/startos/interfaces.ts @@ -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] +}) diff --git a/startos/main.ts b/startos/main.ts new file mode 100644 index 0000000..94a3cc4 --- /dev/null +++ b/startos/main.ts @@ -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 < /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'], + }) +}) diff --git a/startos/manifest/i18n.ts b/startos/manifest/i18n.ts new file mode 100644 index 0000000..a7d75a5 --- /dev/null +++ b/startos/manifest/i18n.ts @@ -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.', +} diff --git a/startos/manifest/index.ts b/startos/manifest/index.ts new file mode 100644 index 0000000..cd73a3e --- /dev/null +++ b/startos/manifest/index.ts @@ -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', + }, + }, + }, +}) diff --git a/startos/sdk.ts b/startos/sdk.ts new file mode 100644 index 0000000..04ae4b1 --- /dev/null +++ b/startos/sdk.ts @@ -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) diff --git a/startos/utils.ts b/startos/utils.ts new file mode 100644 index 0000000..8466b84 --- /dev/null +++ b/startos/utils.ts @@ -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, + }) +} diff --git a/startos/versions/index.ts b/startos/versions/index.ts new file mode 100644 index 0000000..7893a04 --- /dev/null +++ b/startos/versions/index.ts @@ -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: [], +}) diff --git a/startos/versions/v0.0.9.0.a1.ts b/startos/versions/v0.0.9.0.a1.ts new file mode 100644 index 0000000..eb301e0 --- /dev/null +++ b/startos/versions/v0.0.9.0.a1.ts @@ -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, + }, +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a2945a5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "include": ["startos/**/*.ts", "node_modules/**/startos"], + "compilerOptions": { + "target": "ES2018", + "module": "CommonJS", + "moduleResolution": "node", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true + } +}