From 98ddfb8bf971e123188ea6257dedad5586bf5260 Mon Sep 17 00:00:00 2001 From: Daithi Hearn Date: Fri, 5 Jun 2026 15:05:11 +0200 Subject: [PATCH] feat: per-project services opt-out + bump thor/explorer images Adds an optional `services` field to vechain-dev.config.mjs so a consumer can opt out of parts of the shared stack it doesn't need (e.g. a client app that only talks to thor-solo directly). Default is the full set (`thor`, `indexer`, `explorer`); `thor` is required. When neither `indexer` nor `explorer` is selected, `deploy`/`profiles` become optional since there's no address book to write, and `up` exits immediately after ensuring thor-solo. Also bumps the default thor image to vechain/thor:v2.4.3 and the block-explorer to vechain/block-explorer:2.42. Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 6 ++- README.md | 28 +++++++++++++ bin/vechain-dev.mjs | 97 ++++++++++++++++++++++++++++++++----------- compose/base.yaml | 2 +- compose/explorer.yaml | 2 +- lib/config.mjs | 33 +++++++++++++-- package.json | 2 +- 7 files changed, 138 insertions(+), 32 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1eed7f9..815d0b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,12 +18,14 @@ Shared infra (one set per machine, kept up across project switches): Per-project state: -- `vechain-dev.config.mjs` at the consumer project's root (declares `project`, `profiles`, `deploy`, `dev`, optional `overlay`) +- `vechain-dev.config.mjs` at the consumer project's root (declares `project`, optional `services`, `profiles`, `deploy`, `dev`, optional `overlay`) - After deploy, the consumer calls `registerAddresses(...)` which writes `~/.vechain-dev/config/.json` - The CLI merges all registered projects' addresses + profiles and writes env files into `~/.vechain-dev/generated/` which the indexer and explorer containers env-file-mount The point: each consumer deploys its own contracts to thor-solo and registers their addresses; the indexer/explorer see the **union** of every project's addresses + Spring profiles. +Per-consumer opt-out: `services` (default `['thor', 'indexer', 'explorer']`) lets a consumer disable parts of the stack they don't need — e.g. a frontend or backend that talks to thor directly via `THOR_NODE_URL` can declare `services: ['thor']` and the CLI will skip mongo/indexer/explorer entirely. `'thor'` is required; `deploy` + `profiles` are only required when `'indexer'` or `'explorer'` is in the list (since they're the only services that consume the merged address book). + ## Repository layout ``` @@ -45,7 +47,7 @@ genesis/solo.default.json Default genesis used by thor-solo + indexer Two things consumers depend on. Any change here is a breaking change for every downstream project. 1. **`registerAddresses({ project, profiles, addresses })`** — exported from package main. Signature defined in `lib/register.d.ts`. Validates and atomically writes `~/.vechain-dev/config/.json`. -2. **`vechain-dev` CLI** — commands `up`, `down`, `reset`, `sync`, `status`. The `up` flow is load-config → ensure network → start thor+mongo → run consumer `deploy` → merge address book → recreate indexer+explorer → exec consumer `dev` (the dev process becomes the foreground; signals are forwarded). +2. **`vechain-dev` CLI** — commands `up`, `down`, `reset`, `sync`, `status`. The `up` flow is load-config → ensure network → start thor+mongo → run consumer `deploy` → merge address book → recreate indexer+explorer → exec consumer `dev` (the dev process becomes the foreground; signals are forwarded). When `services` opts out of `indexer`/`explorer`, the address-book merge and the deploy-then-verify cycle are skipped (the deploy command is still run if declared, but its registration isn't required). Thor-only consumers exit immediately after `ensureThor()`. ## Conventions to respect diff --git a/README.md b/README.md index b316438..325db6d 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ export default { profiles: ['safe', 'accounts', 'transactions'], deploy: 'yarn contracts:deploy:solo', // optional: + // services: ['thor', 'indexer', 'explorer'], // default — see "Selecting services" // overlay: 'docker/overlay.yaml', } ``` @@ -43,6 +44,33 @@ await registerAddresses({ This writes `~/.vechain-dev/config/my-project.json`. +### Selecting services + +`services` controls which parts of the stack `vechain-dev up` brings up. The +default is the full set: + +```js +services: ['thor', 'indexer', 'explorer'] +``` + +- `'thor'` — required; the thor-solo node on `:8669`. +- `'indexer'` — mongo + vechain-indexer + vechain-indexer-api on `:8089`. +- `'explorer'` — block-explorer on `:8088`. + +A client app that only needs the chain can opt out of the rest: + +```js +export default { + project: 'my-client-app', + services: ['thor'], +} +``` + +When neither `'indexer'` nor `'explorer'` is selected, `deploy` and `profiles` +become optional (there's no address book to write). `vechain-dev down` and +`vechain-dev clean` still tear down the full stack so leftover containers from a +previous config are cleaned up. + ## Commands Project lifecycle — requires `vechain-dev.config.mjs`: diff --git a/bin/vechain-dev.mjs b/bin/vechain-dev.mjs index 6e51dd0..d9ed7c4 100755 --- a/bin/vechain-dev.mjs +++ b/bin/vechain-dev.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { spawn } from 'node:child_process' import { rm } from 'node:fs/promises' -import { loadConfig } from '../lib/config.mjs' +import { loadConfig, needsAddressBook } from '../lib/config.mjs' import { composeDown, composeLogs, @@ -31,6 +31,28 @@ const INFRA_SERVICES = [ const INDEXER_SERVICES = ['mongo-node1', 'mongo-setup', 'vechain-indexer', 'vechain-indexer-api'] const INDEXER_LOG_SERVICES = ['vechain-indexer', 'vechain-indexer-api'] +const SERVICE_FILE = { + thor: 'base.yaml', + indexer: 'indexer.yaml', + explorer: 'explorer.yaml', +} + +const SERVICE_CONTAINERS = { + indexer: INDEXER_SERVICES, + explorer: ['block-explorer'], +} + +function planFor(services) { + const files = services.map((s) => SERVICE_FILE[s]) + const infraServices = services.flatMap((s) => SERVICE_CONTAINERS[s] ?? []) + return { + files, + infraServices, + wantsIndexer: services.includes('indexer'), + wantsExplorer: services.includes('explorer'), + } +} + async function shellExec(cmd, { exec = false } = {}) { return new Promise((resolve, reject) => { const shell = process.env.SHELL || '/bin/bash' @@ -110,50 +132,72 @@ async function verifyDeployed(cfg) { ) } -function printEndpoints() { - info('shared stack ready') - info(' thor-solo → http://localhost:8669') - info(' indexer-api → http://localhost:8089') - info(' block-explorer → http://localhost:8088') +async function waitForInfra({ indexer = true, explorer = true } = {}) { + if (indexer) { + step('waiting for mongo + indexer-api to be ready') + await waitHealthy('mongo-node1') + await waitForIndexerApi() + } + if (explorer) await waitHealthy('block-explorer') } -async function waitForInfra({ explorer = true } = {}) { - step('waiting for mongo + indexer-api to be ready') - await waitHealthy('mongo-node1') - await waitForIndexerApi() - if (explorer) await waitHealthy('block-explorer') +function printEndpointsFor({ wantsIndexer, wantsExplorer }) { + info('shared stack ready') + info(' thor-solo → http://localhost:8669') + if (wantsIndexer) info(' indexer-api → http://localhost:8089') + if (wantsExplorer) info(' block-explorer → http://localhost:8088') } async function up({ force = false, skip = false } = {}) { const cfg = await loadConfig() + const plan = planFor(cfg.services) step(`project: ${cfg.project}`) + step(`services: ${cfg.services.join(', ')}`) await ensureThor() - step('clearing ephemeral services (mongo + indexer + explorer)') - await composeRm(SHARED_FILES, INFRA_SERVICES) + if (plan.infraServices.length === 0) { + printEndpointsFor(plan) + return + } - await runDeployIfNeeded(cfg, { force, skip }) + step('clearing ephemeral services') + await composeRm(plan.files, plan.infraServices) - await mergeAddressBook(cfg) - step('starting mongo + indexer + explorer (fresh state)') - await composeUp(SHARED_FILES, INFRA_SERVICES) - await waitForInfra() + if (needsAddressBook(cfg.services)) { + await runDeployIfNeeded(cfg, { force, skip }) + await mergeAddressBook(cfg) + } else if (!skip && cfg.deploy) { + step(`running deploy: ${cfg.deploy}`) + await shellExec(cfg.deploy) + } - printEndpoints() + step('starting infra (fresh state)') + await composeUp(plan.files, plan.infraServices) + await waitForInfra({ indexer: plan.wantsIndexer, explorer: plan.wantsExplorer }) + + printEndpointsFor(plan) } async function deploy() { const cfg = await loadConfig() + if (!cfg.deploy) { + throw new Error(`no 'deploy' command in vechain-dev.config.mjs — nothing to run`) + } + const plan = planFor(cfg.services) step(`project: ${cfg.project}`) await waitForThor() step(`running deploy: ${cfg.deploy}`) await shellExec(cfg.deploy) - await verifyDeployed(cfg) - await mergeAddressBook(cfg) - step('recreating indexer') - await composeRecreate(SHARED_FILES, INDEXER_LOG_SERVICES) - await waitForInfra({ explorer: false }) + if (needsAddressBook(cfg.services)) { + await verifyDeployed(cfg) + await mergeAddressBook(cfg) + } + if (plan.wantsIndexer) { + step('recreating indexer') + await composeRecreate(plan.files, INDEXER_LOG_SERVICES) + await waitForInfra({ indexer: true, explorer: false }) + } info('deploy complete') } @@ -263,6 +307,7 @@ Project lifecycle (requires vechain-dev.config.mjs): up [--redeploy] [--skip-deploy] Ensure shared infra and run deploy if needed. Exits when infra is ready — start your frontend in a separate terminal (e.g. yarn frontend:dev). + Only the services listed in cfg.services are started (default: all). --redeploy force the deploy command even if contracts are already on-chain --skip-deploy bring infra up without running the deploy command @@ -271,6 +316,10 @@ Project lifecycle (requires vechain-dev.config.mjs): Always runs — no on-chain check. Use when you've changed contracts but the rest of the stack is already up. +Config services (vechain-dev.config.mjs): + services: ['thor', 'indexer', 'explorer'] // default — opt out by omitting entries + // 'thor' is required; 'deploy' + 'profiles' are required when 'indexer' or 'explorer' is enabled + down Stop the full stack (thor state preserved; mongo is ephemeral). diff --git a/compose/base.yaml b/compose/base.yaml index 278e4f8..673d013 100644 --- a/compose/base.yaml +++ b/compose/base.yaml @@ -1,6 +1,6 @@ services: thor-solo: - image: ${VECHAIN_DEV_THOR_IMAGE:-ghcr.io/vechain/thor:latest} + image: ${VECHAIN_DEV_THOR_IMAGE:-vechain/thor:v2.4.3} container_name: thor-solo hostname: thor-solo command: diff --git a/compose/explorer.yaml b/compose/explorer.yaml index 2b2b518..acc291c 100644 --- a/compose/explorer.yaml +++ b/compose/explorer.yaml @@ -1,6 +1,6 @@ services: block-explorer: - image: ${VECHAIN_DEV_EXPLORER_IMAGE:-ghcr.io/vechain/block-explorer:2.41.0} + image: ${VECHAIN_DEV_EXPLORER_IMAGE:-vechain/block-explorer:2.42} container_name: block-explorer hostname: block-explorer env_file: diff --git a/lib/config.mjs b/lib/config.mjs index 9da99f0..0d398ea 100644 --- a/lib/config.mjs +++ b/lib/config.mjs @@ -1,9 +1,15 @@ import { access } from 'node:fs/promises' import { pathToFileURL } from 'node:url' -import { join, resolve } from 'node:path' +import { resolve } from 'node:path' const CONFIG_FILE = 'vechain-dev.config.mjs' +export const ALL_SERVICES = ['thor', 'indexer', 'explorer'] + +export function needsAddressBook(services) { + return services.includes('indexer') || services.includes('explorer') +} + export async function loadConfig(cwd = process.cwd()) { const path = resolve(cwd, CONFIG_FILE) try { @@ -23,6 +29,27 @@ export async function loadConfig(cwd = process.cwd()) { const cfg = mod.default if (!cfg || typeof cfg !== 'object') throw new Error(`${CONFIG_FILE} must default-export an object`) if (!cfg.project) throw new Error(`${CONFIG_FILE}: 'project' required`) - if (!cfg.deploy) throw new Error(`${CONFIG_FILE}: 'deploy' command required`) - return cfg + + const services = cfg.services ?? ALL_SERVICES + if (!Array.isArray(services) || services.length === 0) { + throw new Error(`${CONFIG_FILE}: 'services' must be a non-empty array`) + } + if (!services.includes('thor')) { + throw new Error(`${CONFIG_FILE}: 'services' must include 'thor'`) + } + for (const s of services) { + if (!ALL_SERVICES.includes(s)) { + throw new Error( + `${CONFIG_FILE}: unknown service '${s}' — must be one of ${ALL_SERVICES.join(', ')}`, + ) + } + } + + if (needsAddressBook(services) && !cfg.deploy) { + throw new Error( + `${CONFIG_FILE}: 'deploy' command required when 'services' includes 'indexer' or 'explorer'`, + ) + } + + return { ...cfg, services: [...new Set(services)] } } diff --git a/package.json b/package.json index 96cc009..e227bf3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vechain/dev-stack", - "version": "0.2.0", + "version": "0.3.0", "description": "Shared local dev environment for VeChain projects: thor-solo + indexer + block-explorer, with per-project address registration.", "license": "MIT", "author": "VeChain",