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

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,
},
})