diff --git a/.dockerignore b/.dockerignore index f3b6411..1f1847c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1 +1,2 @@ -**/.git +.git +.gitmodules diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..dafe4e5 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,20 @@ +name: Build + +on: + workflow_dispatch: + pull_request: + paths-ignore: ['*.md'] + branches: ['master'] + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + build: + if: github.event.pull_request.draft == false + uses: start9labs/shared-workflows/.github/workflows/build.yml@master + # with: + # FREE_DISK_SPACE: true + secrets: + DEV_KEY: ${{ secrets.DEV_KEY }} diff --git a/.github/workflows/buildService.yml b/.github/workflows/buildService.yml deleted file mode 100644 index ab6e729..0000000 --- a/.github/workflows/buildService.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Build Service - -on: - workflow_dispatch: - pull_request: - paths-ignore: ['*.md'] - branches: ['main', 'master'] - push: - paths-ignore: ['*.md'] - branches: ['main', 'master'] - -jobs: - BuildPackage: - runs-on: ubuntu-latest - steps: - - name: Prepare StartOS SDK - uses: Start9Labs/sdk@v1 - - - name: Checkout services repository - uses: actions/checkout@v4 - - - name: Build the service package - id: build - run: | - git submodule update --init --recursive - start-sdk init - make - PACKAGE_ID=$(yq -oy ".id" manifest.*) - echo "package_id=$PACKAGE_ID" >> $GITHUB_ENV - printf "\n SHA256SUM: $(sha256sum ${PACKAGE_ID}.s9pk) \n" - shell: bash - - - name: Upload .s9pk - uses: actions/upload-artifact@v4 - with: - name: ${{ env.package_id }}.s9pk - path: ./${{ env.package_id }}.s9pk diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3b83acf --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,20 @@ +name: Release + +on: + push: + tags: + - 'v*.*' + +jobs: + release: + uses: start9labs/shared-workflows/.github/workflows/release.yml@master + with: + # FREE_DISK_SPACE: true + RELEASE_REGISTRY: ${{ vars.RELEASE_REGISTRY }} + S3_S9PKS_BASE_URL: ${{ vars.S3_S9PKS_BASE_URL }} + secrets: + DEV_KEY: ${{ secrets.DEV_KEY }} + S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }} + S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }} + permissions: + contents: write diff --git a/.github/workflows/releaseService.yml b/.github/workflows/releaseService.yml deleted file mode 100644 index 6cf91f2..0000000 --- a/.github/workflows/releaseService.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: Release Service - -on: - push: - tags: - - 'v*.*' - -jobs: - ReleasePackage: - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Prepare StartOS SDK - uses: Start9Labs/sdk@v1 - - - name: Checkout services repository - uses: actions/checkout@v4 - - - name: Build the service package - run: | - git submodule update --init --recursive - start-sdk init - make - - - name: Setting package ID and title from the manifest - id: package - run: | - echo "package_id=$(yq -oy ".id" manifest.*)" >> $GITHUB_ENV - echo "package_title=$(yq -oy ".title" manifest.*)" >> $GITHUB_ENV - shell: bash - - - name: Generate sha256 checksum - run: | - PACKAGE_ID=${{ env.package_id }} - printf "\n SHA256SUM: $(sha256sum ${PACKAGE_ID}.s9pk) \n" - sha256sum ${PACKAGE_ID}.s9pk > ${PACKAGE_ID}.s9pk.sha256 - shell: bash - - - name: Generate changelog - run: | - PACKAGE_ID=${{ env.package_id }} - echo "## What's Changed" > change-log.txt - yq -oy '.release-notes' manifest.* >> change-log.txt - echo "## SHA256 Hash" >> change-log.txt - echo '```' >> change-log.txt - sha256sum ${PACKAGE_ID}.s9pk >> change-log.txt - echo '```' >> change-log.txt - shell: bash - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ github.ref_name }} - name: ${{ env.package_title }} ${{ github.ref_name }} - prerelease: true - body_path: change-log.txt - files: | - ./${{ env.package_id }}.s9pk - ./${{ env.package_id }}.s9pk.sha256 - - - name: Publish to Registry - env: - S9USER: ${{ secrets.S9USER }} - S9PASS: ${{ secrets.S9PASS }} - S9REGISTRY: ${{ secrets.S9REGISTRY }} - run: | - if [[ -z "$S9USER" || -z "$S9PASS" || -z "$S9REGISTRY" ]]; then - echo "Publish skipped: missing registry credentials." - else - start-sdk publish https://$S9USER:$S9PASS@$S9REGISTRY ${{ env.package_id }}.s9pk - fi diff --git a/.github/workflows/tagAndRelease.yml b/.github/workflows/tagAndRelease.yml new file mode 100644 index 0000000..5690d6b --- /dev/null +++ b/.github/workflows/tagAndRelease.yml @@ -0,0 +1,25 @@ +name: Tag and Release + +on: + push: + branches: ['master'] + paths-ignore: ['*.md'] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + tag: + uses: start9labs/shared-workflows/.github/workflows/tagAndRelease.yml@master + with: + REFERENCE_REGISTRY: ${{ vars.REFERENCE_REGISTRY }} + # FREE_DISK_SPACE: true + RELEASE_REGISTRY: ${{ vars.RELEASE_REGISTRY }} + S3_S9PKS_BASE_URL: ${{ vars.S3_S9PKS_BASE_URL }} + secrets: + DEV_KEY: ${{ secrets.DEV_KEY }} + S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }} + S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }} + permissions: + contents: write diff --git a/.gitignore b/.gitignore index b36f48e..5a9c5cd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ *.s9pk -scripts/*.js +startos/*.js +node_modules/ .DS_Store .vscode/ docker-images +javascript +ncc-cache +package-lock.json diff --git a/Dockerfile b/Dockerfile index cfef181..b476fd2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -137,10 +137,12 @@ COPY ./nginx/*.conf /etc/nginx/sites-available/ RUN mkdir /etc/nginx/sites-enabled && \ ln -sf /etc/nginx/sites-available/mainnet.conf /etc/nginx/sites-enabled/dojo.conf -### Docker entrypoint +### Daemon scripts COPY ./config.env /usr/local/bin/config.env -COPY --chmod=755 ./docker_entrypoint.sh /usr/local/bin/ +COPY --chmod=755 ./db-entrypoint.sh /usr/local/bin/ +COPY --chmod=755 ./soroban-entrypoint.sh /usr/local/bin/ +COPY --chmod=755 ./backend-entrypoint.sh /usr/local/bin/ COPY --chmod=755 ./check-synced.sh /usr/local/bin/ COPY --chmod=755 ./check-api.sh /usr/local/bin/ COPY --chmod=755 ./check-mysql.sh /usr/local/bin/ @@ -148,3 +150,5 @@ COPY --chmod=755 ./check-pushtx.sh /usr/local/bin/ COPY --chmod=755 ./check-soroban.sh /usr/local/bin/ COPY --chmod=755 ./functions.sh /usr/local/bin/ COPY --chmod=755 ./samourai-dojo/docker/my-dojo/soroban/restart.sh /usr/local/bin/soroban-restart.sh + +RUN mkdir -p /run/secrets diff --git a/Makefile b/Makefile index 44e22a3..012525a 100644 --- a/Makefile +++ b/Makefile @@ -1,63 +1,10 @@ -PKG_ID := $(shell yq e ".id" < manifest.yaml) -PKG_VERSION := $(shell yq e ".version" < manifest.yaml) -TS_FILES := $(shell find ./ -name \*.ts) - -# delete the target of a rule if it has changed and its recipe exits with a nonzero exit status -.DELETE_ON_ERROR: - -all: verify - -verify: $(PKG_ID).s9pk - @start-sdk verify s9pk $(PKG_ID).s9pk - @echo " Done!" - @echo " Filesize: $(shell du -h $(PKG_ID).s9pk) is ready" - -install: - @if [ ! -f ~/.embassy/config.yaml ]; then echo "You must define \"host: http://server-name.local\" in ~/.embassy/config.yaml config file first."; exit 1; fi - @echo "\nInstalling to $$(grep -v '^#' ~/.embassy/config.yaml | cut -d'/' -f3) ...\n" - @[ -f $(PKG_ID).s9pk ] || ( $(MAKE) && echo "\nInstalling to $$(grep -v '^#' ~/.embassy/config.yaml | cut -d'/' -f3) ...\n" ) - @start-cli package install $(PKG_ID).s9pk - -clean: - rm -rf docker-images - rm -f $(PKG_ID).s9pk - rm -f scripts/*.js - -clean-manifest: - @sed -i '' '/^[[:blank:]]*#/d' manifest.yaml - @echo; echo "Comments successfully removed from manifest.yaml file."; echo - -scripts/embassy.js: $(TS_FILES) - deno run --allow-read --allow-write --allow-env --allow-net scripts/bundle.ts - -arm: - @rm -f docker-images/aarch64.tar - ARCH=aarch64 $(MAKE) - -x86: - @rm -f docker-images/x86_64.tar - ARCH=x86_64 $(MAKE) - -docker-images/aarch64.tar: Dockerfile docker_entrypoint.sh config.env nginx/mainnet.conf nginx/testnet.conf samourai-dojo check-api.sh check-mysql.sh check-pushtx.sh check-synced.sh check-soroban.sh functions.sh -ifeq ($(ARCH),x86_64) -else - mkdir -p docker-images - docker buildx build --tag start9/$(PKG_ID)/main:$(PKG_VERSION) --build-arg ARCH=aarch64 --platform=linux/arm64 -o type=docker,dest=docker-images/aarch64.tar . -endif - -docker-images/x86_64.tar: Dockerfile docker_entrypoint.sh config.env nginx/mainnet.conf nginx/testnet.conf samourai-dojo check-api.sh check-mysql.sh check-pushtx.sh check-synced.sh check-soroban.sh functions.sh -ifeq ($(ARCH),aarch64) -else - mkdir -p docker-images - docker buildx build --tag start9/$(PKG_ID)/main:$(PKG_VERSION) --build-arg ARCH=x86_64 --platform=linux/amd64 -o type=docker,dest=docker-images/x86_64.tar . -endif - -$(PKG_ID).s9pk: manifest.yaml instructions.md icon.png LICENSE.md scripts/embassy.js docker-images/aarch64.tar docker-images/x86_64.tar -ifeq ($(ARCH),aarch64) - @echo "start-sdk: Preparing aarch64 package ..." -else ifeq ($(ARCH),x86_64) - @echo "start-sdk: Preparing x86_64 package ..." -else - @echo "start-sdk: Preparing Universal Package ..." -endif - @start-sdk pack +# ARCHES := x86 arm +ARCHES := x86 +# overrides to s9pk.mk must precede the include statement +include s9pk.mk + +assets: icon.png instructions.md LICENSE.md + @mkdir -p assets + @cp icon.png assets/ + @cp instructions.md assets/ + @cp LICENSE.md assets/ diff --git a/backend-entrypoint.sh b/backend-entrypoint.sh new file mode 100644 index 0000000..0a2ae5e --- /dev/null +++ b/backend-entrypoint.sh @@ -0,0 +1,42 @@ +#!/bin/bash +set -ea + +source /usr/local/bin/config.env + +echo "[i] Dojo Tor address: $TOR_ADDRESS" +mkdir -p /var/lib/tor/hsv3dojo +echo "$TOR_ADDRESS" > /var/lib/tor/hsv3dojo/hostname + +if [ "$COMMON_BTC_NETWORK" = "testnet" ]; then + PAIRING_URL="http://$TOR_ADDRESS/test/v2" + EXPLORER_ENDPOINT="mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/testnet4" + echo "[i] Running on TESTNET" +else + PAIRING_URL="http://$TOR_ADDRESS/v2" + EXPLORER_ENDPOINT="mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion" + echo "[i] Running on MAINNET" +fi + +echo "[i] Pairing URL: $PAIRING_URL" + +# Set dojo config corresponding to current network +if [ "$COMMON_BTC_NETWORK" = "testnet" ]; then + cp /home/node/app/static/admin/conf/index-testnet.js /home/node/app/static/admin/conf/index.js + ln -sf /etc/nginx/sites-available/testnet.conf /etc/nginx/sites-enabled/dojo.conf +else + cp /home/node/app/static/admin/conf/index-mainnet.js /home/node/app/static/admin/conf/index.js + ln -sf /etc/nginx/sites-available/mainnet.conf /etc/nginx/sites-enabled/dojo.conf +fi + +mkdir -p /var/lib/tor/hsv3explorer +echo -n "$EXPLORER_ENDPOINT" > /var/lib/tor/hsv3explorer/hostname + +cat << EOF > /root/stats.json +{ + "pairingCode": '{"pairing":{"type":"dojo.api","version":"$DOJO_VERSION_TAG","apikey":"$NODE_API_KEY","url":"$PAIRING_URL"},"explorer":{"type":"explorer.btc_rpc_explorer","url":"http://$EXPLORER_ENDPOINT"}}', + "adminKey": "$NODE_ADMIN_KEY" +} +EOF + +# Start dojo +exec pm2-runtime -u node --raw /home/node/app/pm2.config.cjs \ No newline at end of file diff --git a/check-api.sh b/check-api.sh index 1c0ed01..f11be86 100755 --- a/check-api.sh +++ b/check-api.sh @@ -4,7 +4,7 @@ source /usr/local/bin/config.env source /usr/local/bin/functions.sh -admin_key=$(yq -e '.admin-key' /root/start9/config.yaml 2>/dev/null) +admin_key=$(jq -r '.dojo.adminKey' /root/store.json 2>/dev/null) access_token=$(cat /run/secrets/access_token 2>/dev/null) if [ -z "$access_token" ] || ! check_token "$access_token"; then diff --git a/check-pushtx.sh b/check-pushtx.sh index d32df37..8424707 100755 --- a/check-pushtx.sh +++ b/check-pushtx.sh @@ -4,7 +4,7 @@ source /usr/local/bin/config.env source /usr/local/bin/functions.sh -admin_key=$(yq -e '.admin-key' /root/start9/config.yaml 2>/dev/null) +admin_key=$(jq -r '.dojo.adminKey' /root/store.json 2>/dev/null) access_token=$(cat /run/secrets/access_token 2>/dev/null) if [ -z "$access_token" ] || ! check_token "$access_token"; then diff --git a/check-synced.sh b/check-synced.sh index c31a63d..40950f9 100755 --- a/check-synced.sh +++ b/check-synced.sh @@ -6,7 +6,7 @@ source /usr/local/bin/functions.sh bci_result=$(curl -sS --user "$BITCOIND_RPC_USER:$BITCOIND_RPC_PASSWORD" --data-binary '{"jsonrpc": "1.0", "id": "gbci", "method": "getblockchaininfo", "params": []}' -H 'content-type: text/plain;' http://$BITCOIND_IP:$BITCOIND_RPC_PORT/ 2>&1) bci_return=$? -bci_error=$(echo "$bci_result" | yq -e '.message' 2>/dev/null) +bci_error=$(echo "$bci_result" | jq -r '.message // "null"' 2>/dev/null) if [[ $bci_return -ne 0 ]]; then echo "Error contacting Bitcoin RPC: $bci_result" >&2 @@ -16,19 +16,19 @@ elif [ "$bci_error" != "null" ]; then exit 61 fi -bci_block_count=$(echo "$bci_result" | yq -e '.result.blocks' 2>/dev/null) -bci_block_ibd=$(echo "$bci_result" | yq -e '.result.initialblockdownload' 2>/dev/null) +bci_block_count=$(echo "$bci_result" | jq -r '.result.blocks // "null"' 2>/dev/null) +bci_block_ibd=$(echo "$bci_result" | jq -r '.result.initialblockdownload // "null"' 2>/dev/null) if [ "$bci_block_count" = "null" ]; then echo "Error ascertaining Bitcoin blockchain status: $bci_error" >&2 exit 61 elif [ "$bci_block_ibd" != "false" ] ; then - bci_block_headers=$(echo "$bci_result" | yq -e '.result.headers' 2>/dev/null) + bci_block_headers=$(echo "$bci_result" | jq -r '.result.headers // "null"' 2>/dev/null) echo -n "Bitcoin blockchain is not fully synced yet: $bci_block_count downloaded of $bci_block_headers blocks" >&2 echo " ($(expr ${bci_block_count}00 / $bci_block_headers)%)" >&2 exit 61 fi -admin_key=$(yq -e '.admin-key' /root/start9/config.yaml 2>/dev/null) +admin_key=$(jq -r '.dojo.adminKey' /root/store.json 2>/dev/null) access_token=$(cat /run/secrets/access_token 2>/dev/null) if [ -z "$access_token" ] || ! check_token "$access_token"; then @@ -42,8 +42,8 @@ fi account_status=$(get_account_status "$access_token") pushtx_status=$(get_pushtx_status "$access_token") -synced_blocks=$(echo "$account_status" | yq -e '.blocks' 2>/dev/null) -bitcoind_blocks=$(echo "$pushtx_status" | yq -e '.data.bitcoind.blocks' 2>/dev/null) +synced_blocks=$(echo "$account_status" | jq -r '.blocks // "null"' 2>/dev/null) +bitcoind_blocks=$(echo "$pushtx_status" | jq -r '.data.bitcoind.blocks // "null"' 2>/dev/null) if [[ $synced_blocks -eq $bitcoind_blocks ]]; then exit 0 else diff --git a/config.env b/config.env index db98469..1bc1d3a 100644 --- a/config.env +++ b/config.env @@ -6,10 +6,26 @@ source /home/node/app/docker/my-dojo/.env export HOST_IP=$(ip -4 route list match 0/0 | awk '{print $3}') -export BITCOIND_TYPE=$(yq e '.bitcoin-node.type' /root/start9/config.yaml) -export BITCOIND_IP="${BITCOIND_TYPE}.embassy" -export BITCOIND_RPC_USER=$(yq e '.bitcoin-node.username' /root/start9/config.yaml) -export BITCOIND_RPC_PASSWORD=$(yq e '.bitcoin-node.password' /root/start9/config.yaml) +# Config tor and explorer +TOR_ADDRESS=$(jq -r '.tor.announceAddrs[0]' /root/store.json) + +if [ -z "$TOR_ADDRESS" ] || [ "$TOR_ADDRESS" = "null" ]; then + echo "[!] Tor onion address not found, please create an onion address for the Dojo..." + exit 1 +fi + +COOKIE_FILE="/mnt/bitcoin/.cookie" +if [ -f "$COOKIE_FILE" ]; then + COOKIE=$(cat "$COOKIE_FILE") + export BITCOIND_RPC_USER="${COOKIE%%:*}" + export BITCOIND_RPC_PASSWORD="${COOKIE#*:}" + echo "[i] Loaded Bitcoin RPC credentials from .cookie (user: $BITCOIND_RPC_USER)" +else + echo "[w] Bitcoin .cookie file not found at $COOKIE_FILE" +fi + +export BITCOIND_TYPE=$(jq -r '.bitcoinNode.type' /root/store.json) +export BITCOIND_IP="${BITCOIND_TYPE}.startos" export BITCOIND_ZMQ_BLK_HASH=28332 export BITCOIND_ZMQ_RAWTXS=28333 @@ -31,34 +47,34 @@ export NET_DOJO_MYSQL_IPV4=127.0.0.1 # Keep this API key secret! # Provide a value with a high entropy! # Type: alphanumeric -export NODE_API_KEY=$(yq e '.api-key' /root/start9/config.yaml) +export NODE_API_KEY=$(jq -r '.dojo.apiKey' /root/store.json) # API key required for accessing the admin/maintenance services provided by the server # Keep this Admin key secret! # Provide a value with a high entropy! # Type: alphanumeric -export NODE_ADMIN_KEY=$(yq e '.admin-key' /root/start9/config.yaml) +export NODE_ADMIN_KEY=$(jq -r '.dojo.adminKey' /root/store.json) # BIP47 Payment Code used for admin authentication # Type: alphanumeric -export NODE_PAYMENT_CODE=$(yq e '.payment-code' /root/start9/config.yaml) +export NODE_PAYMENT_CODE=$(jq -r '.dojo.paymentCode' /root/store.json) # Secret used by the server for signing Json Web Token # Keep this value secret! # Provide a value with a high entropy! # Type: alphanumeric -export NODE_JWT_SECRET=$(yq e '.jwt-secret' /root/start9/config.yaml) +export NODE_JWT_SECRET=$(jq -r '.dojo.jwtSecret' /root/store.json) # FEE TYPE USED FOR FEES ESTIMATIONS BY BITCOIND # Allowed values are ECONOMICAL or CONSERVATIVE export NODE_FEE_TYPE=ECONOMICAL # Indexer or third-party service used for imports and rescans of addresses -export S9_INDEXER_TYPE=$(yq e '.indexer.type' /root/start9/config.yaml) +export S9_INDEXER_TYPE=$(jq -r '.indexer.type' /root/store.json) # Values: local_bitcoind | local_indexer | third_party_explorer export NODE_ACTIVE_INDEXER=local_indexer -export INDEXER_IP=${S9_INDEXER_TYPE}.embassy # fulcrum or electrs +export INDEXER_IP=${S9_INDEXER_TYPE}.startos # fulcrum or electrs export INDEXER_RPC_PORT=50001 export INDEXER_BATCH_SUPPORT=active export INDEXER_PROTOCOL=tcp @@ -72,7 +88,7 @@ export EXPLORER_INSTALL=on # Soroban configuration export SOROBAN_INSTALL=on -SOROBAN_ANNOUNCE_CONFIG=$(yq e '.soroban-announce.enabled // "disabled"' /root/start9/config.yaml) +SOROBAN_ANNOUNCE_CONFIG=$(jq -r '.dojo.sorobanAnnounce.enabled // "disabled"' /root/store.json) if [ "$SOROBAN_ANNOUNCE_CONFIG" = "enabled" ]; then export SOROBAN_ANNOUNCE=on else @@ -80,7 +96,7 @@ else fi # PandoTx Process is only available when Soroban announce is enabled -S9_PANDOTX_PROCESS=$(yq e '.soroban-announce.pandotx-process // false' /root/start9/config.yaml) +S9_PANDOTX_PROCESS=$(jq -r '.dojo.sorobanAnnounce.pandotxProcess // false' /root/store.json) if [ "$SOROBAN_ANNOUNCE_CONFIG" = "enabled" ] && [ "$S9_PANDOTX_PROCESS" = "true" ]; then export NODE_PANDOTX_PROCESS=on else @@ -88,7 +104,7 @@ else fi # Push transaction through PandoTx (Soroban network) -S9_PANDOTX_PUSH=$(yq e '.pandotx-push' /root/start9/config.yaml) +S9_PANDOTX_PUSH=$(jq -r '.dojo.pandotxPush' /root/store.json) if [ "$S9_PANDOTX_PUSH" = "true" ]; then export NODE_PANDOTX_PUSH=on else @@ -117,7 +133,7 @@ export SOROBAN_ONION_FILE=/var/lib/tor/hsv3soroban/hostname # Fallback mode # convenient: a push will ultimately be processed through the local node (soroban or bitcoind) # secure: it will fail if it can't be processed through a randomnly selected Soroban node -export NODE_PANDOTX_FALLBACK_MODE=$(yq e '.pandotx-fallback-mode' /root/start9/config.yaml) +export NODE_PANDOTX_FALLBACK_MODE=$(jq -r '.dojo.pandotxFallbackMode' /root/store.json) # Max number of retries in case of a failed push -export NODE_PANDOTX_NB_RETRIES=$(yq e '.pandotx-retries' /root/start9/config.yaml) +export NODE_PANDOTX_NB_RETRIES=$(jq -r '.dojo.pandotxRetries' /root/store.json) diff --git a/db-entrypoint.sh b/db-entrypoint.sh new file mode 100644 index 0000000..3db9d41 --- /dev/null +++ b/db-entrypoint.sh @@ -0,0 +1,87 @@ +#!/bin/bash +set -ea + +source /usr/local/bin/config.env + +# DATABASE SETUP +if [ -d "/run/mysqld" ]; then + # mysqld run directory already present, no need to create + chown -R mysql:mysql /run/mysqld +else + echo "[i] MySQL run directory not found, creating...." + mkdir -p /run/mysqld + chown -R mysql:mysql /run/mysqld +fi + +MYSQL_DATABASE=${MYSQL_DATABASE:-"samourai-main"} +MYSQL_USER=${MYSQL_USER:-"samourai"} +MYSQL_PASSWORD=${MYSQL_PASSWORD:-"samourai"} + +if [ ! -d /var/lib/mysql/mysql ]; then + echo "[i] MySQL data directory not found or not initialized, creating initial DBs" + + mkdir -p /var/lib/mysql + chown -R mysql:mysql /var/lib/mysql + touch /var/lib/mysql/.dojo_db_initialized + + mysql_install_db --user=mysql --ldata=/var/lib/mysql > /dev/null + + if [ "$MYSQL_ROOT_PASSWORD" = "" ]; then + MYSQL_ROOT_PASSWORD=$(pwgen 16 1) + echo "[i] MySQL root Password: $MYSQL_ROOT_PASSWORD" + export MYSQL_ROOT_PASSWORD + fi + + tfile=$(mktemp) + if [ ! -f "$tfile" ]; then + return 1 + fi + + cat << EOF > "$tfile" +USE mysql; +FLUSH PRIVILEGES ; +GRANT ALL ON *.* TO 'root'@'%' identified by '$MYSQL_ROOT_PASSWORD' WITH GRANT OPTION ; +GRANT ALL ON *.* TO 'root'@'localhost' identified by '$MYSQL_ROOT_PASSWORD' WITH GRANT OPTION ; +SET PASSWORD FOR 'root'@'localhost'=PASSWORD('${MYSQL_ROOT_PASSWORD}') ; +DROP DATABASE IF EXISTS test ; +FLUSH PRIVILEGES ; +EOF + + if [ "$MYSQL_DATABASE" != "" ]; then + echo "[i] Creating database: $MYSQL_DATABASE" + echo "[i] with character set: 'utf8' and collation: 'utf8_general_ci'" + echo "CREATE DATABASE IF NOT EXISTS \`$MYSQL_DATABASE\` CHARACTER SET utf8 COLLATE utf8_general_ci;" >> "$tfile" + + if [ "$MYSQL_USER" != "" ]; then + echo "[i] Creating user: $MYSQL_USER with password $MYSQL_PASSWORD" + + { + echo "GRANT ALL ON \`$MYSQL_DATABASE\`.* to '$MYSQL_USER'@'%' IDENTIFIED BY '$MYSQL_PASSWORD';" + echo "GRANT ALL ON \`$MYSQL_DATABASE\`.* to '$MYSQL_USER'@'localhost' IDENTIFIED BY '$MYSQL_PASSWORD';" + echo "FLUSH PRIVILEGES;" + } >> "$tfile" + fi + fi + + /usr/bin/mysqld --user=mysql --bootstrap --verbose=0 --skip-name-resolve --skip-networking=0 < "$tfile" + + rm -f "$tfile" + echo + echo 'MySQL init process done. Starting mysqld...' + echo +else + echo "[i] MySQL data directory already initialized, skipping initial DB creation." +fi + +# Migrate database tables +echo "[i] Running database migration..." +for f in /docker-entrypoint-initdb.d/*; do + case "$f" in + *.sql) echo "$0: running $f"; sed "1iUSE \`$MYSQL_DATABASE\`;" "$f" | /usr/bin/mysqld --user=mysql --bootstrap --verbose=0 --skip-name-resolve --skip-networking=0; echo ;; + *) echo "$0: ignoring or entrypoint initdb empty $f" ;; + esac + echo +done + +# Start mysql +exec /usr/bin/mysqld_safe --user=mysql --datadir='/var/lib/mysql' \ No newline at end of file diff --git a/functions.sh b/functions.sh index fb0a18d..3b63dda 100755 --- a/functions.sh +++ b/functions.sh @@ -4,7 +4,7 @@ do_authenticate () { local ak=$1 auth_result=$(curl -X POST -s --data-binary "apikey=$ak&at=null" -H "Content-Type: application/json" "http://localhost:8080/auth/login") - at=$(echo "$auth_result" | yq e ".authorizations.access_token" 2>/dev/null) + at=$(echo "$auth_result" | jq -r '.authorizations.access_token // empty' 2>/dev/null) if [ -n "$at" ] && [ "$at" != "null" ]; then echo "$at" > /run/secrets/access_token echo "$at" @@ -16,7 +16,7 @@ do_authenticate () { get_pushtx_status () { local access_token=$1 status=$(curl -s -H "Content-Type: application/json" "http://localhost:8081/status?at=$access_token") - maybe_error=$(echo "$status" | yq -e '.error' 2>/dev/null) + maybe_error=$(echo "$status" | jq -r '.error // empty' 2>/dev/null) if [ "$maybe_error" == 'Invalid JSON Web Token' ]; then return 1 else @@ -27,7 +27,7 @@ get_pushtx_status () { get_account_status () { local access_token=$1 status=$(curl -s -H "Content-Type: application/json" "http://localhost:8080/status?at=$access_token") - maybe_error=$(echo "$status" | yq -e '.error' 2>/dev/null) + maybe_error=$(echo "$status" | jq -r '.error // empty' 2>/dev/null) if [ "$maybe_error" == 'Invalid JSON Web Token' ]; then return 1 else @@ -38,7 +38,7 @@ get_account_status () { check_token () { local access_token=$1 status=$(curl -s -H "Content-Type: application/json" "http://localhost:8080/status?at=$access_token") - maybe_error=$(echo "$status" | yq -e '.error' 2>/dev/null) + maybe_error=$(echo "$status" | jq -r '.error // empty' 2>/dev/null) if [ "$maybe_error" == 'Invalid JSON Web Token' ]; then return 1 else diff --git a/manifest.yaml b/manifest.yaml deleted file mode 100644 index b46d99e..0000000 --- a/manifest.yaml +++ /dev/null @@ -1,182 +0,0 @@ -id: dojo -title: 'Dojo' -version: 1.28.2.0 -release-notes: Version v1.28.0 -license: AGPLv3 -wrapper-repo: 'https://github.com/ericpp/dojo-startos' -upstream-repo: 'https://github.com/Dojo-Open-Source-Project/samourai-dojo' -support-site: 'https://github.com/Dojo-Open-Source-Project/samourai-dojo' -marketing-site: 'https://github.com/Dojo-Open-Source-Project/samourai-dojo' -build: ['make'] -description: - short: Your private backend server for Ashigaru, Samourai Wallet and other light wallets. - long: - Dojo is the backend server for Ashigaru, Samourai Wallet and other light wallets. It provides HD account & loose addresses (BIP47) balances & transactions lists. - Provides unspent output lists to the wallet. PushTX endpoint broadcasts transactions through the backing bitcoind node. -assets: - license: LICENSE.md - icon: icon.png - instructions: instructions.md -main: - type: docker - image: main - entrypoint: 'docker_entrypoint.sh' - args: [] - mounts: - main: /root - db: /var/lib/mysql -hardware-requirements: - arch: - - x86_64 - - aarch64 -health-checks: - api: - name: API - success-message: Dojo API is online and ready for connections - type: docker - image: main - entrypoint: 'check-api.sh' - args: [] - inject: true - system: false - io-format: yaml - mysql: - name: MySQL - success-message: MySQL is online and ready for connections - type: docker - image: main - entrypoint: 'check-mysql.sh' - args: [] - inject: true - system: false - io-format: yaml - pushtx: - name: PushTx - success-message: Dojo PushTx API is online and ready for connections - type: docker - image: main - entrypoint: 'check-pushtx.sh' - args: [] - inject: true - system: false - io-format: yaml - synced: - name: Synced - success-message: Dojo is synced with the network - type: docker - image: main - entrypoint: 'check-synced.sh' - args: [] - inject: true - system: false - io-format: yaml - soroban: - name: Soroban - success-message: Soroban is running - type: docker - image: main - entrypoint: 'check-soroban.sh' - args: [] - inject: true - system: false - io-format: yaml -config: - get: - type: script - set: - type: script -properties: - type: script -volumes: - main: - type: data - db: - type: data -interfaces: - main: - name: Dojo Web UI - description: Specifies the interface to listen on for HTTP connections. - tor-config: - port-mapping: - 80: '9000' - ui: true - protocols: - - tcp - - http -dependencies: - bitcoind: - version: '>=0.21.1.2' - requirement: - type: 'opt-out' - how: Use the Bitcoin Core (default) - description: Used to subscribe to new block events from a full archival node - config: - check: - type: script - auto-configure: - type: script - requires-runtime-config: true - bitcoind-testnet: - version: '>=0.21.1.2' - requirement: - type: 'opt-in' - how: Use the Bitcoin Core Testnet4 - description: Used to subscribe to new block events from a full archival node (testnet) - config: - check: - type: script - auto-configure: - type: script - requires-runtime-config: true - fulcrum: - version: '>=2.0.0' - requirement: - type: 'opt-in' - how: Set Indexer to Fulcrum in the config - description: Used for fast scan of addresses and indexing for deep wallets. - electrs: - version: '>=0.10.7' - requirement: - type: 'opt-in' - how: Set Indexer to Electrs in the config - description: A more stable, but less performant indexer. -backup: - create: - type: docker - image: compat - system: true - entrypoint: compat - args: - - duplicity - - create - - /mnt/backup - - /root/data - mounts: - BACKUP: /mnt/backup - main: /root/data - db: /var/lib/mysql - io-format: yaml - restore: - type: docker - image: compat - system: true - entrypoint: compat - args: - - duplicity - - restore - - /mnt/backup - - /root/data - mounts: - BACKUP: /mnt/backup - main: /root/data - db: /var/lib/mysql - io-format: yaml -migrations: - from: - '*': - type: script - args: ['from'] - to: - '*': - type: script - args: ['to'] diff --git a/package.json b/package.json new file mode 100644 index 0000000..f417bca --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "dojo-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.3.3", + "bitcoin-core-startos": "github:Start9Labs/bitcoin-core-startos#28.x", + "bitcoind-testnet4-startos": "github:remcoros/bitcoind-testnet4-startos#v30.2_6", + "fulcrum-startos": "github:Start9Labs/fulcrum-startos#v2.1.0_10", + "electrs-startos": "github:Start9-Community/electrs-startos#v0.11.1_3", + "tor-startos": "github:Start9Labs/tor-startos#v0.4.9.5_2" + }, + "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/scripts/.gitignore b/scripts/.gitignore deleted file mode 100644 index 4c43fe6..0000000 --- a/scripts/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.js \ No newline at end of file diff --git a/scripts/bundle.ts b/scripts/bundle.ts deleted file mode 100644 index 07cbf3a..0000000 --- a/scripts/bundle.ts +++ /dev/null @@ -1,6 +0,0 @@ -// scripts/bundle.ts -import { bundle } from "https://deno.land/x/emit@0.40.0/mod.ts"; - -const result = await bundle("scripts/embassy.ts"); - -await Deno.writeTextFile("scripts/embassy.js", result.code); diff --git a/scripts/deps.ts b/scripts/deps.ts deleted file mode 100644 index 3105b54..0000000 --- a/scripts/deps.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "https://deno.land/x/embassyd_sdk@v0.3.3.0.11/mod.ts"; diff --git a/scripts/embassy.ts b/scripts/embassy.ts deleted file mode 100644 index 14d127a..0000000 --- a/scripts/embassy.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { setConfig } from "./procedures/setConfig.ts"; -export { getConfig } from "./procedures/getConfig.ts"; -export { properties } from "./procedures/properties.ts"; -export { migration } from "./procedures/migrations.ts"; -export { dependencies } from "./procedures/dependencies.ts"; diff --git a/scripts/procedures/dependencies.ts b/scripts/procedures/dependencies.ts deleted file mode 100644 index 4806b8c..0000000 --- a/scripts/procedures/dependencies.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { types as T, matches } from "../deps.ts"; - -const { shape, number, string, boolean } = matches; - -const matchBitcoindConfig = shape({ - rpc: shape({ - enable: boolean, - advanced: shape({ - threads: number, - }) - }), - advanced: shape({ - pruning: shape({ - mode: string - }) - }), - 'zmq-enabled': boolean -}); - -const matchIndexerConfig = shape({ - type: string, // "electrs" or "fulcrum" -}); - -export const dependencies: T.ExpectedExports.dependencies = { - bitcoind: { - // deno-lint-ignore require-await - async check(effects, config) { - effects.info("check bitcoind"); - - if (!matchBitcoindConfig.test(config)) { - return { error: "Bitcoind config is not the correct shape" } - } - - if (!config.rpc.enable) { - return { error: "Must have RPC enabled" }; - } - - if (!config['zmq-enabled']) { - return { error: "Must have ZeroMQ enabled" }; - } - - if (config.advanced.pruning.mode !== "disabled") { - return { error: "Pruning must be disabled (must be an archival node)" }; - } - - return { result: null }; - }, - // deno-lint-ignore require-await - async autoConfigure(effects, configInput) { - effects.info("autoconfigure bitcoind"); - - const config = matchBitcoindConfig.unsafeCast(configInput); - - config.rpc.enable = true; - - config['zmq-enabled'] = true; - - if (config.advanced.pruning.mode !== "disabled") { - config.advanced.pruning.mode = "disabled"; - } - - return { result: config }; - }, - }, - 'bitcoind-testnet': { - // deno-lint-ignore require-await - async check(effects, config) { - effects.info("check bitcoind-testnet"); - - if (!matchBitcoindConfig.test(config)) { - return { error: "Bitcoind-testnet config is not the correct shape" } - } - - if (!config.rpc.enable) { - return { error: "Must have RPC enabled" }; - } - - if (!config['zmq-enabled']) { - return { error: "Must have ZeroMQ enabled" }; - } - - if (config.advanced.pruning.mode !== "disabled") { - return { error: "Pruning must be disabled (must be an archival node)" }; - } - - return { result: null }; - }, - // deno-lint-ignore require-await - async autoConfigure(effects, configInput) { - effects.info("autoconfigure bitcoind"); - - const config = matchBitcoindConfig.unsafeCast(configInput); - - config.rpc.enable = true; - - config['zmq-enabled'] = true; - - if (config.advanced.pruning.mode !== "disabled") { - config.advanced.pruning.mode = "disabled"; - } - - return { result: config }; - }, - }, - indexer: { - // deno-lint-ignore require-await - async check(effects, config) { - effects.info("check indexer"); - if (!matchIndexerConfig.test(config)) { - return { error: "Indexer config is not the correct shape" }; - } - if (!["electrs", "fulcrum"].includes(config.type)) { - return { error: "Indexer type must be either 'electrs' or 'fulcrum'" }; - } - return { result: null }; - }, - - // deno-lint-ignore require-await - async autoConfigure(effects, configInput) { - effects.info("autoconfigure indexer"); - const config = matchIndexerConfig.unsafeCast(configInput); - - // Set default type if not specified - if (!config.type) { - config.type = "fulcrum"; // default to fulcrum - } - - return { result: config }; - }, - } -}; diff --git a/scripts/procedures/getConfig.ts b/scripts/procedures/getConfig.ts deleted file mode 100644 index 25ec492..0000000 --- a/scripts/procedures/getConfig.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { compat, types as T } from "../deps.ts"; - -export const getConfig: T.ExpectedExports.getConfig = compat.getConfig({ - "tor-address": { - "name": "Tor Address", - "description": "The Tor address of the network interface", - "type": "pointer", - "subtype": "package", - "package-id": "dojo", - "target": "tor-address", - "interface": "main", - }, - "bitcoin-node": { - "type": "union", - "name": "Bitcoin Node", - "description": - "The Bitcoin node type you would like to use for Dojo", - "tag": { - "id": "type", - "name": "Select Bitcoin Node", - "variant-names": { - "bitcoind": "Bitcoin Core", - "bitcoind-testnet": "Bitcoin Core (testnet4)", - }, - "description": - "The Bitcoin node type you would like to use for Dojo", - }, - "default": "bitcoind", - "variants": { - "bitcoind": { - "username": { - "type": "pointer", - "name": "RPC Username", - "description": "The username for Bitcoin Core's RPC interface", - "subtype": "package", - "package-id": "bitcoind", - "target": "config", - "multi": false, - "selector": "$.rpc.username", - }, - "password": { - "type": "pointer", - "name": "RPC Password", - "description": "The password for Bitcoin Core's RPC interface", - "subtype": "package", - "package-id": "bitcoind", - "target": "config", - "multi": false, - "selector": "$.rpc.password", - }, - }, - "bitcoind-testnet": { - "username": { - "type": "pointer", - "name": "RPC Username", - "description": "The username for Bitcoin Core Testnet RPC interface", - "subtype": "package", - "package-id": "bitcoind-testnet", - "target": "config", - "multi": false, - "selector": "$.rpc.username", - }, - "password": { - "type": "pointer", - "name": "RPC Password", - "description": "The password for Bitcoin Core Testnet RPC interface", - "subtype": "package", - "package-id": "bitcoind-testnet", - "target": "config", - "multi": false, - "selector": "$.rpc.password", - }, - }, - }, - }, - "indexer": { - "type": "union", - "name": "Indexer", - "description": - "The indexer you want to use for Dojo", - "tag": { - "id": "type", - "name": "Select Indexer", - "variant-names": { - "electrs": "electrs", - "fulcrum": "Fulcrum", - }, - "description": - "The indexer you want to use for Dojo", - }, - "default": "electrs", - "variants": { - "electrs": {}, - "fulcrum": {}, - }, - }, - "payment-code": { - "type": "string", - "name": "BIP47 Payment Code", - "description": "BIP47 Payment Code used for admin authentication", - "nullable": true, - "copyable": true, - "masked": false - }, - "admin-key": { - "type": "string", - "name": "Admin Key", - "description": "Key for accessing the admin/maintenance", - "nullable": false, - "copyable": true, - "masked": true, - "default": { - "charset": "a-z,A-Z,0-9", - "len": 22, - }, - }, - "api-key": { - "type": "string", - "name": "API Key", - "description": "Key for accessing the services", - "nullable": false, - "copyable": true, - "masked": true, - "default": { - "charset": "a-z,A-Z,0-9", - "len": 22, - }, - }, - "jwt-secret": { - "type": "string", - "name": "JWT Secret", - "description": "Secret used by the server for signing", - "nullable": false, - "copyable": true, - "masked": true, - "default": { - "charset": "a-z,A-Z,0-9", - "len": 22, - }, - }, - "soroban-announce": { - "type": "union", - "name": "Soroban Network Announce", - "description": "Configure Soroban network participation", - "tag": { - "id": "enabled", - "name": "Soroban Network Announce", - "variant-names": { - "disabled": "Disabled", - "enabled": "Enabled" - }, - "description": "Enable to participate in the Soroban network" - }, - "default": "disabled", - "variants": { - "disabled": {}, - "enabled": { - "pandotx-process": { - "type": "boolean", - "name": "PandoTx Process", - "description": "Process and relay transactions from other Soroban nodes", - "nullable": false, - "default": false - } - } - } - }, - "pandotx-push": { - "type": "boolean", - "name": "PandoTx Push", - "description": "Push your transactions through random Soroban nodes for enhanced privacy", - "nullable": false, - "default": true, - }, - "pandotx-retries": { - "type": "number", - "name": "PandoTx Retries", - "description": "Maximum retry attempts for failed transaction pushes", - "nullable": false, - "default": 2, - "range": "[0,10]", - "integral": true, - }, - "pandotx-fallback-mode": { - "type": "enum", - "name": "PandoTx Fallback Mode", - "description": "Behavior when Soroban push fails", - "nullable": false, - "default": "convenient", - "values": ["convenient", "secure"], - "value-names": { - "convenient": "Convenient (fallback to local node)", - "secure": "Secure (fail if Soroban unavailable)", - }, - }, -}); \ No newline at end of file diff --git a/scripts/procedures/migrations.ts b/scripts/procedures/migrations.ts deleted file mode 100644 index 630cf3b..0000000 --- a/scripts/procedures/migrations.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { compat, types as T } from '../deps.ts' - -export const migration: T.ExpectedExports.migration = compat.migrations.fromMapping( - { - '1.28.0.0': { - up: compat.migrations.updateConfig( - (config: any) => { - // Add default soroban and pandotx config for users upgrading from versions before 1.28.0 - config['soroban-announce'] = { - 'enabled': 'disabled' - } - config['pandotx-push'] = true - config['pandotx-retries'] = 2 - config['pandotx-fallback-mode'] = 'convenient' - return config - }, - true, - { version: '1.28.0.0', type: 'up' }, - ), - down: compat.migrations.updateConfig( - (config: any) => { - delete config['soroban-announce'] - delete config['pandotx-push'] - delete config['pandotx-retries'] - delete config['pandotx-fallback-mode'] - return config - }, - true, - { version: '1.28.0.0', type: 'down' }, - ), - }, - }, - '1.28.2.0', -) diff --git a/scripts/procedures/properties.ts b/scripts/procedures/properties.ts deleted file mode 100644 index dff99aa..0000000 --- a/scripts/procedures/properties.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { compat, types as T } from "../deps.ts"; - -export const properties: T.ExpectedExports.properties = compat.properties; diff --git a/scripts/procedures/setConfig.ts b/scripts/procedures/setConfig.ts deleted file mode 100644 index a385303..0000000 --- a/scripts/procedures/setConfig.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { compat, types as T } from "../deps.ts"; - -// deno-lint-ignore require-await -export const setConfig: T.ExpectedExports.setConfig = async ( - effects: T.Effects, - newConfig: T.Config, -) => { - const dependencies: { [key: string]: string[] } = {}; - - if ((newConfig as any)?.['bitcoin-node']?.type === 'bitcoind') { - dependencies['bitcoind'] = ['synced']; - } - - if ((newConfig as any)?.['bitcoin-node']?.type === 'bitcoind-testnet') { - dependencies['bitcoind-testnet'] = ['synced']; - } - - if ((newConfig as any)?.indexer?.type === 'fulcrum') { - dependencies['fulcrum'] = ['synced']; - } - - if ((newConfig as any)?.indexer?.type === 'electrs') { - dependencies['electrs'] = ['synced']; - } - - return compat.setConfig(effects, newConfig, dependencies); -}; \ No newline at end of file diff --git a/soroban-entrypoint.sh b/soroban-entrypoint.sh new file mode 100644 index 0000000..91e6351 --- /dev/null +++ b/soroban-entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/bash +set -ea + +source /usr/local/bin/config.env + +# Start Soroban if enabled +echo "[i] Checking Soroban configuration..." +echo "[i] SOROBAN_INSTALL=$SOROBAN_INSTALL" +echo "[i] SOROBAN_ANNOUNCE=$SOROBAN_ANNOUNCE" + +echo "[i] Starting Soroban process as soroban user..." +mkdir -p $(dirname $SOROBAN_ONION_FILE) +chown -R soroban:soroban $(dirname $SOROBAN_ONION_FILE) + +exec runuser -u soroban -- /usr/local/bin/soroban-restart.sh \ No newline at end of file diff --git a/startos/actions/config.ts.bak b/startos/actions/config.ts.bak new file mode 100644 index 0000000..65c04a7 --- /dev/null +++ b/startos/actions/config.ts.bak @@ -0,0 +1,267 @@ +import { store } from '../fileModels/store.json' +import { sdk } from '../sdk' +import { i18n } from '../i18n' + +const { InputSpec, Value, Variants } = sdk + +const bitcoindSpec = InputSpec.of({ + username: Value.text({ + name: i18n('RPC Username'), + required: false, + default: null, + placeholder: null, + inputmode: 'text', + patterns: [], + masked: false, + }), + password: Value.text({ + name: i18n('RPC Password'), + required: false, + default: null, + placeholder: null, + inputmode: 'text', + patterns: [], + masked: true, + }), +}) + +const bitcoinNodeVariants = Variants.of({ + bitcoind: { + name: i18n('Bitcoin Core'), + spec: bitcoindSpec, + }, + 'bitcoind-testnet': { + name: i18n('Bitcoin Core (testnet4)'), + spec: InputSpec.of({ + username: Value.text({ + name: i18n('RPC Username'), + required: false, + default: null, + placeholder: null, + inputmode: 'text', + patterns: [], + masked: false, + }), + password: Value.text({ + name: i18n('RPC Password'), + required: false, + default: null, + placeholder: null, + inputmode: 'text', + patterns: [], + masked: true, + }), + }), + }, +}) + +const indexerVariants = Variants.of({ + electrs: { + name: i18n('electrs'), + spec: InputSpec.of({}), + }, + fulcrum: { + name: i18n('Fulcrum'), + spec: InputSpec.of({}), + }, +}) + +const sorobanVariants = Variants.of({ + disabled: { + name: i18n('Disabled'), + spec: InputSpec.of({}), + }, + enabled: { + name: i18n('Enabled'), + spec: InputSpec.of({ + 'pandotx-process': Value.toggle({ + name: i18n('PandoTx Process'), + description: i18n( + 'Process and relay transactions from other Soroban nodes', + ), + default: false, + }), + }), + }, +}) + +export const inputSpec = InputSpec.of({ + 'bitcoin-node': Value.union({ + name: i18n('Bitcoin Node'), + description: i18n( + 'The Bitcoin node type you would like to use for Dojo', + ), + warning: null, + variants: bitcoinNodeVariants, + default: 'bitcoind', + }), + indexer: Value.union({ + name: i18n('Indexer'), + description: i18n('The indexer you want to use for Dojo'), + warning: null, + variants: indexerVariants, + default: 'electrs', + }), + 'payment-code': Value.text({ + name: i18n('BIP47 Payment Code'), + required: false, + default: null, + placeholder: null, + inputmode: 'text', + patterns: [], + masked: false, + }), + 'admin-key': Value.text({ + name: i18n('Admin Key'), + description: i18n('Key for accessing the admin/maintenance'), + required: true, + default: { charset: 'a-z,A-Z,0-9', len: 22 }, + placeholder: null, + inputmode: 'text', + patterns: [], + masked: true, + generate: { charset: 'a-z,A-Z,0-9', len: 22 }, + }), + 'api-key': Value.text({ + name: i18n('API Key'), + description: i18n('Key for accessing the services'), + required: true, + default: { charset: 'a-z,A-Z,0-9', len: 22 }, + placeholder: null, + inputmode: 'text', + patterns: [], + masked: true, + generate: { charset: 'a-z,A-Z,0-9', len: 22 }, + }), + 'jwt-secret': Value.text({ + name: i18n('JWT Secret'), + description: i18n('Secret used by the server for signing'), + required: true, + default: { charset: 'a-z,A-Z,0-9', len: 22 }, + placeholder: null, + inputmode: 'text', + patterns: [], + masked: true, + generate: { charset: 'a-z,A-Z,0-9', len: 22 }, + }), + 'soroban-announce': Value.union({ + name: i18n('Soroban Network Announce'), + description: i18n('Configure Soroban network participation'), + warning: null, + variants: sorobanVariants, + default: 'disabled', + }), + 'pandotx-push': Value.toggle({ + name: i18n('PandoTx Push'), + description: i18n( + 'Push your transactions through random Soroban nodes for enhanced privacy', + ), + default: true, + }), + 'pandotx-retries': Value.number({ + name: i18n('PandoTx Retries'), + description: i18n('Maximum retry attempts for failed transaction pushes'), + required: true, + default: 2, + min: 0, + max: 10, + integer: true, + units: null, + placeholder: null, + }), + 'pandotx-fallback-mode': Value.select({ + name: i18n('PandoTx Fallback Mode'), + description: i18n('Behavior when Soroban push fails'), + values: { + convenient: i18n('Convenient (fallback to local node)'), + secure: i18n('Secure (fail if Soroban unavailable)'), + }, + default: 'convenient', + }), +}) + +export const configAction = sdk.Action.withInput( + 'config', + + async ({ effects }) => ({ + name: i18n('Dojo Web UI'), + description: i18n( + 'The Bitcoin node type you would like to use for Dojo', + ), + warning: null, + allowedStatuses: 'any', + group: null, + visibility: 'enabled', + }), + + inputSpec, + + async ({ effects }) => { + const current = await store.read().const(effects) + if (!current) return {} + return { + 'bitcoin-node': { + selection: current['bitcoin-node']?.type || 'bitcoind', + value: { + username: current['bitcoin-node']?.username || '', + password: current['bitcoin-node']?.password || '', + }, + }, + indexer: { + selection: current.indexer?.type || 'electrs', + value: {}, + }, + 'payment-code': current['payment-code'] || '', + 'admin-key': current['admin-key'] || '', + 'api-key': current['api-key'] || '', + 'jwt-secret': current['jwt-secret'] || '', + 'soroban-announce': { + selection: current['soroban-announce']?.enabled || 'disabled', + value: + current['soroban-announce']?.enabled === 'enabled' + ? { + 'pandotx-process': + current['soroban-announce']?.['pandotx-process'] || false, + } + : {}, + }, + 'pandotx-push': current['pandotx-push'] ?? true, + 'pandotx-retries': current['pandotx-retries'] ?? 2, + 'pandotx-fallback-mode': + current['pandotx-fallback-mode'] || 'convenient', + } + }, + + async ({ effects, input }) => { + const bitcoinNode = input['bitcoin-node'] + const indexer = input['indexer'] + const sorobanAnnounce = input['soroban-announce'] + + await store.write(effects, { + 'bitcoin-node': { + type: bitcoinNode.selection as 'bitcoind' | 'bitcoind-testnet', + username: (bitcoinNode.value as any)?.username, + password: (bitcoinNode.value as any)?.password, + }, + indexer: { + type: indexer.selection as 'electrs' | 'fulcrum', + }, + 'payment-code': input['payment-code'] || null, + 'admin-key': input['admin-key'] as string, + 'api-key': input['api-key'] as string, + 'jwt-secret': input['jwt-secret'] as string, + 'soroban-announce': { + enabled: sorobanAnnounce.selection as 'disabled' | 'enabled', + 'pandotx-process': + sorobanAnnounce.selection === 'enabled' + ? (sorobanAnnounce.value as any)?.['pandotx-process'] || false + : undefined, + }, + 'pandotx-push': input['pandotx-push'] as boolean, + 'pandotx-retries': input['pandotx-retries'] as number, + 'pandotx-fallback-mode': input['pandotx-fallback-mode'] as + | 'convenient' + | 'secure', + }) + }, +) diff --git a/startos/actions/configureDojo.ts b/startos/actions/configureDojo.ts new file mode 100644 index 0000000..1b21aab --- /dev/null +++ b/startos/actions/configureDojo.ts @@ -0,0 +1,150 @@ +import { storeJson } from '../fileModels/store.json' +import { sdk } from '../sdk' +import { i18n } from '../i18n' + +const { InputSpec, Value, Variants } = sdk + +const sorobanVariants = Variants.of({ + disabled: { + name: i18n('Disabled'), + spec: InputSpec.of({}), + }, + enabled: { + name: i18n('Enabled'), + spec: InputSpec.of({ + 'pandotx-process': Value.toggle({ + name: i18n('PandoTx Process'), + description: i18n( + 'Process and relay transactions from other Soroban nodes', + ), + default: false, + }), + }), + }, +}) + +export const inputSpec = InputSpec.of({ + paymentCode: Value.text({ + name: i18n('BIP47 Payment Code'), + required: false, + default: null, + placeholder: null, + inputmode: 'text', + patterns: [], + masked: false, + }), + adminKey: Value.text({ + name: i18n('Admin Key'), + description: i18n('Key for accessing the admin/maintenance'), + required: true, + default: { charset: 'a-z,A-Z,0-9', len: 22 }, + placeholder: null, + inputmode: 'text', + patterns: [], + masked: true, + generate: { charset: 'a-z,A-Z,0-9', len: 22 }, + }), + apiKey: Value.text({ + name: i18n('API Key'), + description: i18n('Key for accessing the services'), + required: true, + default: { charset: 'a-z,A-Z,0-9', len: 22 }, + placeholder: null, + inputmode: 'text', + patterns: [], + masked: true, + generate: { charset: 'a-z,A-Z,0-9', len: 22 }, + }), + jwtSecret: Value.text({ + name: i18n('JWT Secret'), + description: i18n('Secret used by the server for signing'), + required: true, + default: { charset: 'a-z,A-Z,0-9', len: 22 }, + placeholder: null, + inputmode: 'text', + patterns: [], + masked: true, + generate: { charset: 'a-z,A-Z,0-9', len: 22 }, + }), + sorobanAnnounce: Value.union({ + name: i18n('Soroban Network Announce'), + description: i18n('Configure Soroban network participation'), + warning: null, + variants: sorobanVariants, + default: 'disabled', + }), + pandotxPush: Value.toggle({ + name: i18n('PandoTx Push'), + description: i18n( + 'Push your transactions through random Soroban nodes for enhanced privacy', + ), + default: true, + }), + pandotxRetries: Value.number({ + name: i18n('PandoTx Retries'), + description: i18n('Maximum retry attempts for failed transaction pushes'), + required: true, + default: 2, + min: 0, + max: 10, + integer: true, + units: null, + placeholder: null, + }), + pandotxFallbackMode: Value.select({ + name: i18n('PandoTx Fallback Mode'), + description: i18n('Behavior when Soroban push fails'), + values: { + convenient: i18n('Convenient (fallback to local node)'), + secure: i18n('Secure (fail if Soroban unavailable)'), + }, + default: 'convenient', + }), +}) + +export const configureDojoAction = sdk.Action.withInput( + 'configure-dojo', + + async ({ effects }) => ({ + name: i18n('Configure Dojo'), + description: i18n( + 'Customize your Dojo', + ), + warning: null, + allowedStatuses: 'any', + group: i18n('Configuration'), + visibility: 'enabled', + }), + + inputSpec, + + // pre-fill + async ({ effects }) => { + const dojo = await storeJson.read((s) => s.dojo).const(effects) + if (!dojo) return {} + return { + ...dojo, + sorobanAnnounce: + dojo.sorobanAnnounce.enabled === 'enabled' + ? { selection: 'enabled', value: { 'pandotx-process': dojo.sorobanAnnounce.pandotxProcess } } + : { selection: 'disabled', value: {} }, + } + }, + + // execution + async ({ effects, input }) => { + const announce = input.sorobanAnnounce + return storeJson.merge(effects, { + dojo: { + ...input, + sorobanAnnounce: { + enabled: announce.selection, + pandotxProcess: + announce.selection === 'enabled' + ? announce.value['pandotx-process'] + : false, + }, + }, + }) + }, +) diff --git a/startos/actions/index.ts b/startos/actions/index.ts new file mode 100644 index 0000000..ae7da6f --- /dev/null +++ b/startos/actions/index.ts @@ -0,0 +1,12 @@ +import { sdk } from '../sdk' +import { configureDojoAction } from './configureDojo' +import { selectBitcoinNodeAction } from './selectBitcoinNode' +import { selectIndexerAction } from './selectIndexer' +import { viewPairingCodeAction } from './viewPairingCode' + + +export const actions = sdk.Actions.of() + .addAction(selectBitcoinNodeAction) + .addAction(selectIndexerAction) + .addAction(configureDojoAction) + .addAction(viewPairingCodeAction) \ No newline at end of file diff --git a/startos/actions/selectBitcoinNode.ts b/startos/actions/selectBitcoinNode.ts new file mode 100644 index 0000000..f7d7a6e --- /dev/null +++ b/startos/actions/selectBitcoinNode.ts @@ -0,0 +1,49 @@ +import { storeJson } from '../fileModels/store.json' +import { i18n } from '../i18n' +import { sdk } from '../sdk' +const { InputSpec, Value } = sdk + +const bitcoinNodeInputSpec = InputSpec.of({ + bitcoinNodeType: Value.select({ + name: i18n('Select Bitcoin Node'), + description: i18n('The Bitcoin node type you would like to use for Dojo'), + values: { + bitcoind: i18n('Bitcoin Core'), + 'bitcoind-testnet': i18n('Bitcoin Core (testnet4)'), + }, + default: 'bitcoind', + }), +}) + +export const selectBitcoinNodeAction = sdk.Action.withInput( + 'select-bitcoin-node', + + { + name: i18n('Select Bitcoin Node'), + description: i18n( + 'The Bitcoin node type you would like to use for Dojo', + ), + warning: null, + allowedStatuses: 'any', + group: i18n('Configuration'), + visibility: 'enabled', + }, + + // form input specification + bitcoinNodeInputSpec, + + // pre-fill the form + async ({ effects }) => { + const bitcoinNodeType = await storeJson.read((s) => s.bitcoinNode.type).const(effects) + return { bitcoinNodeType: bitcoinNodeType || 'bitcoind' } + }, + + // execution function + async ({ effects, input }) => { + await storeJson.merge(effects, { + bitcoinNode: { + type: input.bitcoinNodeType, + }, + }) + }, +) \ No newline at end of file diff --git a/startos/actions/selectIndexer.ts b/startos/actions/selectIndexer.ts new file mode 100644 index 0000000..7ef3a97 --- /dev/null +++ b/startos/actions/selectIndexer.ts @@ -0,0 +1,49 @@ +import { storeJson } from '../fileModels/store.json' +import { i18n } from '../i18n' +import { sdk } from '../sdk' +const { InputSpec, Value } = sdk + +const indexerInputSpec = InputSpec.of({ + indexerType: Value.select({ + name: i18n('Select Indexer'), + description: i18n('The indexer you want to use for Dojo'), + values: { + fulcrum: i18n('Fulcrum'), + electrs: i18n('electrs'), + }, + default: 'fulcrum', + }), +}) + +export const selectIndexerAction = sdk.Action.withInput( + 'select-indexer', + + { + name: i18n('Select Indexer'), + description: i18n( + 'The indexer you want to use for Dojo', + ), + warning: null, + allowedStatuses: 'any', + group: i18n('Configuration'), + visibility: 'enabled', + }, + + // form input specification + indexerInputSpec, + + // pre-fill the form + async ({ effects }) => { + const indexerType = await storeJson.read((s) => s.indexer.type).const(effects) + return { indexerType: indexerType || 'fulcrum' } + }, + + // execution function + async ({ effects, input }) => { + await storeJson.merge(effects, { + indexer: { + type: input.indexerType, + }, + }) + }, +) \ No newline at end of file diff --git a/startos/actions/viewPairingCode.ts b/startos/actions/viewPairingCode.ts new file mode 100644 index 0000000..e97956f --- /dev/null +++ b/startos/actions/viewPairingCode.ts @@ -0,0 +1,40 @@ +import { storeJson } from '../fileModels/store.json' +import { statsJson } from '../fileModels/stats.json' +import { i18n } from '../i18n' +import { sdk } from '../sdk' +const { InputSpec, Value } = sdk + +export const viewPairingCodeAction = sdk.Action.withoutInput( + // ID + "view-pairing-code", + + // Metadata + async ({ effects }) => ({ + name: i18n("View Pairing Code"), + description: i18n("View the pairing code for Dojo"), + warning: null, + allowedStatuses: "any", // 'any', 'only-running', 'only-stopped' + group: i18n('Properties'), + visibility: "enabled", // 'enabled', 'disabled', 'hidden' + }), + + // Handler + async ({ effects }) => { + const stats = await statsJson.read((s) => s).const(effects); + + return { + version: "1", + title: "Pairing Code", + message: "Your pairing code:", + result: { + type: 'single', + name: i18n('Pairing Code'), + description: i18n('Code for pairing your wallet with this Dojo'), + value: stats?.pairingCode ?? '', + masked: true, + copyable: true, + qr: true, + } + }; + }, +); diff --git a/startos/backups.ts b/startos/backups.ts new file mode 100644 index 0000000..3e53ce5 --- /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', 'db'), +) diff --git a/startos/daemons.ts b/startos/daemons.ts new file mode 100644 index 0000000..c42caa2 --- /dev/null +++ b/startos/daemons.ts @@ -0,0 +1,165 @@ +import { T, SubContainer, Daemons, healthFns } from '@start9labs/start-sdk' +import { sdk } from './sdk' +import { i18n } from './i18n' +import { sorobanPort, backendPort, uiPort } from './utils' +import { manifest } from './manifest' +import { StoreJson } from './fileModels/store.json' + +type HealthCheckResult = healthFns.HealthCheckResult + +async function runCheckScript( + sub: SubContainer, + script: string, + successMessage: string, + defaultLoadingMessage: string, +): Promise { + try { + const res = await sub.exec([script], {}, 30_000) + if (res.exitCode === 0) { + return { result: 'success', message: successMessage } + } + const stderrMsg = res.stderr?.toString().trim() + if (res.exitCode === 60) { + return { result: 'starting', message: stderrMsg || null } + } + return { result: 'loading', message: stderrMsg || defaultLoadingMessage } + } catch { + return { result: 'failure', message: defaultLoadingMessage } + } +} + +export async function getDaemons({ effects, config, sub }: { + effects: T.Effects, + config: StoreJson, + sub: SubContainer, +}) { + const torIp = await sdk.getContainerIp(effects, { packageId: 'tor' }).const() + + // track Tor running status dynamically for health check (no restart needed) + let torRunning = false + if (torIp) { + sdk.getStatus(effects, { packageId: 'tor' }).onChange((status) => { + torRunning = status?.desired.main === 'running' + return { cancel: false } + }) + } + + const hasTorAddress = config.tor.announceAddrs?.some((ip: string) => ip?.includes('.onion')) + + return sdk.Daemons.of(effects) + .addDaemon('mariadb', { + subcontainer: sub, + exec: { + command: ['db-entrypoint.sh'], + env: {}, + }, + ready: { + gracePeriod: 120_000, + display: null, + fn: async () => { + const res = await sub.exec([ + 'mysqladmin', + '-u', + 'root', + '--socket=/run/mysqld/mysqld.sock', + 'ping', + ]) + return res.exitCode === 0 + ? { result: 'success', message: null } + : { result: 'loading', message: null } + }, + }, + requires: [], + }) + .addDaemon('soroban', { + subcontainer: sub, + exec: { + command: ['soroban-entrypoint.sh'], + env: {}, + }, + ready: { + gracePeriod: 120_000, + display: null, + fn: () => + sdk.healthCheck.checkPortListening(effects, sorobanPort, { + successMessage: i18n('Soroban is ready'), + errorMessage: i18n('Soroban is not ready'), + }), + }, + requires: [], + }) + .addDaemon('backend', { + subcontainer: sub, + exec: { + command: ['backend-entrypoint.sh'], + env: {}, + }, + ready: { + gracePeriod: 720_000, + display: i18n('Dojo API'), + fn: () => + sdk.healthCheck.checkPortListening(effects, backendPort, { + successMessage: i18n('Dojo API is ready'), + errorMessage: i18n('Dojo API is not ready'), + }), + }, + requires: ['mariadb', 'soroban'], + }) + .addDaemon('frontend', { + subcontainer: sub, + exec: { + command: ['nginx'], + env: {}, + }, + ready: { + gracePeriod: 120_000, + display: i18n('Dojo Web UI'), + fn: () => + sdk.healthCheck.checkPortListening(effects, uiPort, { + successMessage: i18n('Dojo Web UI is ready'), + errorMessage: i18n('Dojo Web UI is not ready'), + }), + }, + requires: ['backend'], + }) + .addHealthCheck('api', { + ready: { + display: i18n('Dojo API'), + fn: () => + runCheckScript( + sub, + 'check-api.sh', + i18n('Dojo API is online and ready for connections'), + i18n('Dojo API is starting...'), + ), + }, + requires: ['backend'], + }) + .addHealthCheck('pushtx', { + ready: { + display: i18n('PushTx'), + fn: () => + runCheckScript( + sub, + 'check-pushtx.sh', + i18n('Dojo PushTx API is online and ready for connections'), + i18n('PushTx is starting...'), + ), + }, + requires: ['backend'], + }) + .addHealthCheck('synced', { + ready: { + display: i18n('Synced'), + gracePeriod: 720_000, + fn: () => + runCheckScript( + sub, + 'check-synced.sh', + i18n('Dojo is synced with the network'), + i18n('Dojo is syncing...'), + ), + }, + requires: ['backend'], + }) +} \ No newline at end of file diff --git a/startos/dependencies.ts b/startos/dependencies.ts new file mode 100644 index 0000000..699029f --- /dev/null +++ b/startos/dependencies.ts @@ -0,0 +1,69 @@ +import { T } from '@start9labs/start-sdk' +import { storeJson } from './fileModels/store.json' +import { i18n } from './i18n' +import { sdk } from './sdk' +import { configureDojoAction } from './actions/configureDojo' + +export const setDependencies = sdk.setupDependencies(async ({ effects }) => { + const config = await storeJson.read((s) => s).const(effects) + + // const torAddresses = await sdk.serviceInterface + // .getOwn(effects, 'ui', (i) => + // i?.addressInfo?.public + // .filter({ exclude: { kind: 'domain' } }) + // .filter({ + // predicate: ({ metadata }) => + // metadata.kind === 'plugin' && metadata.packageId === 'tor', + // }) + // .format(), + // ) + // .const() + + // if (!(torAddresses?.length ?? 0)) { + // await sdk.action.createOwnTask(effects, configureDojoAction, 'critical', { + // reason: i18n( + // 'Tor interface is not ready. Add an onion address to enable connectivity.', + // ), + // }) + // } + + const deps: T.CurrentDependenciesResult = {} + + if (config?.bitcoinNode?.type === 'bitcoind') { + deps['bitcoind'] = { + kind: 'running', + versionRange: '>=0.21.1.2', + healthChecks: ['synced'], + } + } + + if (config?.bitcoinNode?.type === 'bitcoind-testnet') { + deps['bitcoind-testnet'] = { + kind: 'running', + versionRange: '>=0.21.1.2', + healthChecks: ['synced'], + } + } + + if (config?.indexer?.type === 'fulcrum') { + deps['fulcrum'] = { + kind: 'exists', + versionRange: '>=2.0.0', + } + } + + if (config?.indexer?.type === 'electrs') { + deps['electrs'] = { + kind: 'exists', + versionRange: '>=0.10.7', + } + } + + deps['tor'] = { + kind: 'running', + versionRange: '>=0.4.9.5:0', + healthChecks: [], + } + + return deps +}) diff --git a/startos/fileModels/stats.json.ts b/startos/fileModels/stats.json.ts new file mode 100644 index 0000000..b9e9b38 --- /dev/null +++ b/startos/fileModels/stats.json.ts @@ -0,0 +1,17 @@ +import { FileHelper, z } from '@start9labs/start-sdk' +import { sdk } from '../sdk' + +const shape = z.object({ + pairingCode: z.string(), + adminKey: z.string(), +}) + +export type StatsJson = z.infer + +export const statsJson = FileHelper.json( + { + base: sdk.volumes.main, + subpath: 'stats.json', + }, + shape, +) \ No newline at end of file diff --git a/startos/fileModels/store.json.ts b/startos/fileModels/store.json.ts new file mode 100644 index 0000000..00924e5 --- /dev/null +++ b/startos/fileModels/store.json.ts @@ -0,0 +1,58 @@ +import { FileHelper, z } from '@start9labs/start-sdk' +import { sdk } from '../sdk' +import { generateKey } from '../utils' + +const shape = z.object({ + bitcoinNode: z + .object({ + type: z.enum(['bitcoind', 'bitcoind-testnet']).catch('bitcoind'), + }) + .catch({ type: 'bitcoind' }), + indexer: z + .object({ + type: z.enum(['fulcrum', 'electrs']).catch('electrs'), + }) + .catch({ type: 'electrs' }), + dojo: z + .object({ + paymentCode: z.string().nullable().catch(null), + adminKey: z.string().catch(''), + apiKey: z.string().catch(''), + jwtSecret: z.string().catch(''), + sorobanAnnounce: z + .object({ + enabled: z.enum(['disabled', 'enabled']).catch('disabled'), + pandotxProcess: z.boolean().catch(false), + }) + .catch({ enabled: 'disabled', pandotxProcess: false }), + pandotxPush: z.boolean().catch(true), + pandotxRetries: z.number().catch(2), + pandotxFallbackMode: z.enum(['convenient', 'secure']).catch('convenient'), + }) + .catch({ + paymentCode: null, + adminKey: generateKey(22), + apiKey: generateKey(22), + jwtSecret: generateKey(22), + sorobanAnnounce: { enabled: 'disabled', pandotxProcess: false }, + pandotxPush: true, + pandotxRetries: 2, + pandotxFallbackMode: 'convenient', + }), + tor: z + .object({ + proxy: z.string().nullable().catch(null), + announceAddrs: z.array(z.string()).nullable().catch([]), + }) + .catch({ proxy: null, announceAddrs: [] }), +}) + +export type StoreJson = z.infer + +export const storeJson = FileHelper.json( + { + base: sdk.volumes.main, + subpath: 'store.json', + }, + shape, +) \ No newline at end of file diff --git a/startos/i18n/dictionaries/default.ts b/startos/i18n/dictionaries/default.ts new file mode 100644 index 0000000..d121b6f --- /dev/null +++ b/startos/i18n/dictionaries/default.ts @@ -0,0 +1,68 @@ +export const DEFAULT_LANG = 'en_US' + +const dict = { + 'Starting Dojo!': 0, + 'Dojo Web UI': 1, + 'The web interface of Dojo': 2, + 'Web UI': 3, + 'The Bitcoin node type you would like to use for Dojo': 4, + 'Bitcoin Core': 5, + 'Bitcoin Core (testnet4)': 6, + 'The indexer you want to use for Dojo': 7, + 'electrs': 8, + 'Fulcrum': 9, + 'BIP47 Payment Code': 10, + 'Admin Key': 11, + 'Key for accessing the admin/maintenance': 12, + 'API Key': 13, + 'Key for accessing the services': 14, + 'JWT Secret': 15, + 'Secret used by the server for signing': 16, + 'Soroban Network Announce': 17, + 'Configure Soroban network participation': 18, + 'Disabled': 19, + 'Enabled': 20, + 'PandoTx Process': 21, + 'Process and relay transactions from other Soroban nodes': 22, + 'PandoTx Push': 23, + 'Push your transactions through random Soroban nodes for enhanced privacy': 24, + 'PandoTx Retries': 25, + 'Maximum retry attempts for failed transaction pushes': 26, + 'PandoTx Fallback Mode': 27, + 'Behavior when Soroban push fails': 28, + 'Convenient (fallback to local node)': 29, + 'Secure (fail if Soroban unavailable)': 30, + 'Select Bitcoin Node': 31, + 'Select Indexer': 32, + 'Configuration': 33, + 'Soroban is ready': 34, + 'Soroban is not ready': 35, + 'Dojo API': 36, + 'Dojo API is ready': 37, + 'Dojo API is not ready': 38, + 'Dojo Web UI is ready': 39, + 'Dojo Web UI is not ready': 40, + 'Tor is not installed': 41, + 'Tor is not running': 42, + 'Tor interface is ready': 43, + 'Tor interface is not ready. Add an onion address to enable connectivity.': 44, + 'Configure Dojo': 45, + 'Customize your Dojo': 46, + 'Properties': 47, + 'View Pairing Code': 48, + 'View the pairing code for Dojo': 49, + 'Pairing Code': 50, + 'Code for pairing your wallet with this Dojo': 51, + 'Dojo API is online and ready for connections': 52, + 'Dojo API is starting...': 53, + 'PushTx': 54, + 'Dojo PushTx API is online and ready for connections': 55, + 'PushTx is starting...': 56, + 'Synced': 57, + 'Dojo is synced with the network': 58, + 'Dojo is syncing...': 59, +} as const + +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..1849d6d --- /dev/null +++ b/startos/i18n/index.ts @@ -0,0 +1,5 @@ +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..8607f54 --- /dev/null +++ b/startos/init/index.ts @@ -0,0 +1,18 @@ +import { sdk } from '../sdk' +import { setDependencies } from '../dependencies' +import { setInterfaces } from '../interfaces' +import { versionGraph } from '../versions' +import { actions } from '../actions' +import { restoreInit } from '../backups' +import { watchHosts } from './watchHosts' + +export const init = sdk.setupInit( + restoreInit, + versionGraph, + setInterfaces, + setDependencies, + actions, + watchHosts, +) + +export const uninit = sdk.setupUninit(versionGraph) diff --git a/startos/init/watchHosts.ts b/startos/init/watchHosts.ts new file mode 100644 index 0000000..d05c7ea --- /dev/null +++ b/startos/init/watchHosts.ts @@ -0,0 +1,38 @@ +import { storeJson } from '../fileModels/store.json' +import { sdk } from '../sdk' + +const uiInterfaceId = 'ui' + +export const watchHosts = sdk.setupOnInit(async (effects, _) => { + const proxy = await sdk.getContainerIp(effects, { + packageId: 'tor' + }).const() + + const publicInfo = await sdk.serviceInterface + .getOwn(effects, uiInterfaceId, (i) => + i?.addressInfo?.public.filter({ + exclude: { kind: 'domain' }, + }), + ) + .const() +console.log('publicInfo', publicInfo) + if (!publicInfo) return + + const announceAddrs = publicInfo + .filter({ + predicate: ({ metadata }) => + metadata.kind === 'plugin' && metadata.packageId === 'tor', + }) + .format() + + await storeJson.merge( + effects, + { + tor: { + proxy, + announceAddrs, + }, + }, + { allowWriteAfterConst: true }, + ) +}) \ No newline at end of file diff --git a/startos/interfaces.ts b/startos/interfaces.ts new file mode 100644 index 0000000..c5fd6d1 --- /dev/null +++ b/startos/interfaces.ts @@ -0,0 +1,25 @@ +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, 'main') + const uiMultiOrigin = await uiMulti.bindPort(uiPort, { + protocol: 'http', + }) + const ui = sdk.createInterface(effects, { + name: i18n('Web UI'), + id: 'ui', + description: i18n('The web interface of Dojo'), + 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..9c353c9 --- /dev/null +++ b/startos/main.ts @@ -0,0 +1,24 @@ +import { storeJson } from './fileModels/store.json' +import { i18n } from './i18n' +import { sdk } from './sdk' + +import { getMounts } from './mounts' +import { getDaemons } from './daemons' + +export const main = sdk.setupMain(async ({ effects }) => { + console.info(i18n('Starting Dojo!')) + + let config = await storeJson.read((s) => s).const(effects) + if (!config) { + throw new Error('store.json not found') + } + + const sub = await sdk.SubContainer.of( + effects, + { imageId: 'dojo' }, + getMounts({ config }), + 'dojo-sub', + ) + + return await getDaemons({ effects, config, sub }) +}) diff --git a/startos/manifest/i18n.ts b/startos/manifest/i18n.ts new file mode 100644 index 0000000..57a1d6d --- /dev/null +++ b/startos/manifest/i18n.ts @@ -0,0 +1,26 @@ +export default { + description: { + short: { + en_US: + 'Your private backend server for Ashigaru, Samourai Wallet and other light wallets.', + }, + long: { + en_US: + 'Dojo is the backend server for Ashigaru, Samourai Wallet and other light wallets. It provides HD account & loose addresses (BIP47) balances & transactions lists. Provides unspent output lists to the wallet. PushTX endpoint broadcasts transactions through the backing bitcoind node.', + }, + }, + bitcoindDescription: { + en_US: + 'Used to subscribe to new block events from a full archival node', + }, + bitcoindTestnetDescription: { + en_US: + 'Used to subscribe to new block events from a full archival node (testnet)', + }, + fulcrumDescription: { + en_US: 'Used for fast scan of addresses and indexing for deep wallets', + }, + electrsDescription: { + en_US: 'A more stable, but less performant indexer', + }, +} diff --git a/startos/manifest/index.ts b/startos/manifest/index.ts new file mode 100644 index 0000000..5849dce --- /dev/null +++ b/startos/manifest/index.ts @@ -0,0 +1,58 @@ +import { setupManifest } from '@start9labs/start-sdk' +import i18n from './i18n' + +export const manifest = setupManifest({ + id: 'dojo', + title: 'Dojo', + license: 'AGPL-3.0', + packageRepo: 'https://github.com/ericpp/dojo-startos', + upstreamRepo: 'https://github.com/Dojo-Open-Source-Project/samourai-dojo', + marketingUrl: 'https://dojo-osp.org/', + donationUrl: 'https://dojo-osp.org/donate/', + docsUrls: [ + 'https://dojo-osp.org/', + ], + description: i18n.description, + volumes: ['main', 'db'], + images: { + dojo: { + source: { + dockerBuild: { + dockerfile: './Dockerfile', + workdir: '.', + }, + }, + arch: ['x86_64', 'aarch64'], + }, + }, + alerts: { + install: null, + update: null, + uninstall: null, + restore: null, + start: null, + stop: null, + }, + dependencies: { + bitcoind: { + description: i18n.bitcoindDescription, + optional: true, + s9pk: null, + }, + 'bitcoind-testnet': { + description: i18n.bitcoindTestnetDescription, + optional: true, + s9pk: null, + }, + fulcrum: { + description: i18n.fulcrumDescription, + optional: true, + s9pk: null, + }, + electrs: { + description: i18n.electrsDescription, + optional: true, + s9pk: null, + }, + }, +}) diff --git a/startos/mounts.ts b/startos/mounts.ts new file mode 100644 index 0000000..2ff4880 --- /dev/null +++ b/startos/mounts.ts @@ -0,0 +1,45 @@ +import { sdk } from './sdk' +import { StoreJson } from './fileModels/store.json' +import { manifest as bitcoinManifest } from 'bitcoin-core-startos/startos/manifest' +import { manifest as bitcoindTestnetManifest } from 'bitcoind-testnet4-startos/startos/manifest' +import { btcMountpoint } from './utils' + +export function getMounts({ config }: { config: StoreJson }) { + + let mounts = sdk.Mounts.of() + .mountVolume({ + volumeId: 'main', + subpath: null, + mountpoint: '/root', + readonly: false, + }) + .mountVolume({ + volumeId: 'db', + subpath: null, + mountpoint: '/var/lib/mysql', + readonly: false, + }) + + if (config?.bitcoinNode?.type === 'bitcoind') { + // TODO: Add testnet somehow? + mounts = mounts.mountDependency({ + dependencyId: 'bitcoind', + volumeId: 'main', + subpath: null, + mountpoint: btcMountpoint, + readonly: true, + }) + } + + if (config?.bitcoinNode?.type === 'bitcoind-testnet') { + mounts = mounts.mountDependency({ + dependencyId: 'bitcoind-testnet', + volumeId: 'main', + subpath: 'testnet4', + mountpoint: btcMountpoint, + readonly: true, + }) + } + + return mounts +} \ No newline at end of file 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..c892647 --- /dev/null +++ b/startos/utils.ts @@ -0,0 +1,15 @@ +import { randomBytes } from 'crypto' + +const ALPHANUM = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + +export function generateKey(len: number): string { + const bytes = randomBytes(len) + return Array.from(bytes, (b) => ALPHANUM[b % ALPHANUM.length]).join('') +} + +export const uiPort = 9000 +export const backendPort = 8080 +export const sorobanPort = 4242 +export const dbPort = 3306 + +export const btcMountpoint = '/mnt/bitcoin' \ No newline at end of file diff --git a/startos/versions/index.ts b/startos/versions/index.ts new file mode 100644 index 0000000..9a265e9 --- /dev/null +++ b/startos/versions/index.ts @@ -0,0 +1,7 @@ +import { VersionGraph } from '@start9labs/start-sdk' +import { v_1_28_2_0 } from './v1.28.2.0' + +export const versionGraph = VersionGraph.of({ + current: v_1_28_2_0, + other: [], +}) diff --git a/startos/versions/v1.28.2.0.ts b/startos/versions/v1.28.2.0.ts new file mode 100644 index 0000000..c4d0cf0 --- /dev/null +++ b/startos/versions/v1.28.2.0.ts @@ -0,0 +1,96 @@ +import { IMPOSSIBLE, VersionInfo, YAML } from '@start9labs/start-sdk' +import { readFile, rm } from 'fs/promises' +import { storeJson } from '../fileModels/store.json' +import { generateKey } from '../utils' + +export const v_1_28_2_0 = VersionInfo.of({ + version: '1.28.2:0', + releaseNotes: { + en_US: 'Initial release on StartOS SDK 0.4.0', + }, + migrations: { + up: async ({ effects }) => { + const configYaml: + | { + 'tor-address'?: string + 'bitcoin-node'?: { + type: 'bitcoind' | 'bitcoind-testnet' + username?: string + password?: string + } + indexer?: { type: 'electrs' | 'fulcrum' } + 'payment-code'?: string | null + 'admin-key'?: string + 'api-key'?: string + 'jwt-secret'?: string + 'soroban-announce'?: { + enabled: 'disabled' | 'enabled' + 'pandotx-process'?: boolean + } + 'pandotx-push'?: boolean + 'pandotx-retries'?: number + 'pandotx-fallback-mode'?: 'convenient' | 'secure' + } + | undefined = await readFile( + '/media/startos/volumes/main/start9/config.yaml', + 'utf-8', + ).then(YAML.parse, () => undefined) + + if (configYaml) { + await storeJson.write(effects, { + bitcoinNode: configYaml['bitcoin-node'] || { + type: 'bitcoind', + }, + indexer: { + type: configYaml.indexer?.type || 'electrs', + }, + dojo: { + paymentCode: configYaml['payment-code'] || null, + adminKey: configYaml['admin-key'] || generateKey(22), + apiKey: configYaml['api-key'] || generateKey(22), + jwtSecret: configYaml['jwt-secret'] || generateKey(22), + sorobanAnnounce: { + enabled: configYaml['soroban-announce']?.enabled || 'disabled', + pandotxProcess: configYaml['soroban-announce']?.['pandotx-process'] || false, + }, + pandotxPush: configYaml['pandotx-push'] ?? true, + pandotxRetries: configYaml['pandotx-retries'] ?? 2, + pandotxFallbackMode: configYaml['pandotx-fallback-mode'] || 'convenient', + }, + tor: { + proxy: null, + announceAddrs: [], + }, + }) + + await rm('/media/startos/volumes/main/start9', { + recursive: true, + }).catch(console.error) + } else { + // Fresh install with no prior config to migrate — write defaults so + // docker_entrypoint.sh can source config.env without jq failing on a + // missing store.json file (jq exits 2 when the file doesn't exist, + // which kills the entrypoint immediately via `set -e`). + await storeJson.write(effects, { + bitcoinNode: { type: 'bitcoind' }, + indexer: { type: 'electrs' }, + dojo: { + paymentCode: null, + adminKey: generateKey(22), + apiKey: generateKey(22), + jwtSecret: generateKey(22), + sorobanAnnounce: { enabled: 'disabled', pandotxProcess: false }, + pandotxPush: true, + pandotxRetries: 2, + pandotxFallbackMode: 'convenient', + }, + tor: { + proxy: null, + announceAddrs: [], + }, + }) + } + }, + down: IMPOSSIBLE, + }, +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9a19690 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "include": ["startos/**/*.ts", "node_modules/**/startos"], + "compilerOptions": { + "target": "es2022", + "module": "None", + "moduleResolution": "node", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true + } +}