@toon-protocol/townhouse 0.1.0-rc5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/cli.ts","../src/cli/browser-opener.ts"],"sourcesContent":["#!/usr/bin/env node\n\n/**\n * CLI entrypoint for `@toon-protocol/townhouse` (Story 21.1).\n *\n * Subcommands: init, up, down, status, --help\n *\n * Usage:\n * townhouse init [--force]\n * townhouse up\n * townhouse down\n * townhouse status\n */\n\nimport { parseArgs } from 'node:util';\nimport { mkdirSync, writeFileSync, existsSync } from 'node:fs';\nimport { join, resolve } from 'node:path';\nimport { homedir } from 'node:os';\nimport { pathToFileURL } from 'node:url';\nimport { stringify } from 'yaml';\nimport Docker from 'dockerode';\n\nimport { getDefaultConfig } from './config/defaults.js';\nimport { loadConfig } from './config/loader.js';\nimport type { TownhouseConfig } from './config/schema.js';\nimport { DockerOrchestrator } from './docker/index.js';\nimport type { NodeType } from './docker/types.js';\nimport {\n ConnectorAdminClient,\n TransportProbe,\n DEFAULT_ATOR_PROXY,\n} from './connector/index.js';\nimport { createApiServer } from './api/server.js';\nimport { createWizardApiServer } from './api/wizard-server.js';\nimport type { ApiServer } from './api/index.js';\nimport { RealBrowserOpener } from './cli/browser-opener.js';\nimport type { BrowserOpener } from './cli/browser-opener.js';\nimport {\n WalletManager,\n encryptWallet,\n decryptWallet,\n saveWallet,\n loadWallet,\n} from './wallet/index.js';\n\n/**\n * Error thrown when `main()` is invoked with `--help`. Callers (tests) can\n * distinguish this from genuine failures; the top-level entrypoint catches\n * it and exits 0.\n */\nexport class CliHelpRequested extends Error {\n constructor() {\n super(HELP_TEXT);\n this.name = 'CliHelpRequested';\n }\n}\n\nconst HELP_TEXT = `townhouse — TOON node orchestrator\n\nUsage:\n townhouse setup [--no-browser] [--port <n>] [--config-dir <dir>] Run the first-run setup wizard\n townhouse init [--force] [--config-dir <dir>] [--password <pw>] [--preset <name>] [--yes] Initialize config + wallet\n townhouse up [--town] [--mill] [--dvm] [-c <path>] [--password <pw>] Start nodes\n townhouse down [-c <path>] Stop all nodes\n townhouse status [-c <path>] Show node status\n townhouse metrics [-c <path>] Show connector metrics\n townhouse wallet show [-c <path>] [--password <pw>] Show derived addresses\n townhouse --help Show this help\n\nFlags:\n --town Start Town (Nostr relay) node\n --mill Start Mill (swap) node\n --dvm Start DVM (compute) node\n --password Wallet password (non-interactive mode)\n --no-browser Skip opening the browser automatically (setup command)\n --port Override the API port (setup command, default 9400)\n --preset Init from a named preset (init only). Supported: demo\n --yes Non-interactive (init only); with --preset=demo uses demo password if --password absent\n If no flags given, starts all enabled nodes from config.`;\n\nconst DEFAULT_CONFIG_DIR = join(homedir(), '.townhouse');\nconst DEFAULT_CONFIG_PATH = join(DEFAULT_CONFIG_DIR, 'config.yaml');\n\nasync function handleInit(\n force: boolean,\n configDir?: string,\n password?: string,\n preset?: 'demo',\n yes?: boolean\n): Promise<void> {\n const dir = resolve(configDir ?? DEFAULT_CONFIG_DIR);\n const configPath = join(dir, 'config.yaml');\n\n if (existsSync(configPath) && !force) {\n console.error(\n `Config already exists at ${configPath}. Use --force to overwrite.`\n );\n process.exitCode = 1;\n return;\n }\n\n mkdirSync(dir, { recursive: true, mode: 0o700 });\n\n // D2 — preset path takes precedence over default config for non-interactive\n // demo init. Preset writes the same TownhouseConfig shape, so the rest of\n // the init flow (wallet generation, etc.) is unaffected.\n let configToWrite;\n if (preset === 'demo') {\n const { buildDemoConfig, DEMO_DETERMINISTIC_PASSWORD } =\n await import('./presets/demo.js');\n configToWrite = buildDemoConfig({ walletPath: join(dir, 'wallet.enc') });\n // AC-D2-6: --yes without --password under --preset=demo gets the\n // deterministic demo password. Documented as DEMO ONLY.\n if (yes && !password) {\n password = DEMO_DETERMINISTIC_PASSWORD;\n console.log(\n '[demo preset] Using deterministic demo password (insecure — demo only).'\n );\n }\n } else {\n configToWrite = getDefaultConfig();\n // Override wallet path to use the config dir, not the default home-dir path.\n // getDefaultConfig() hardcodes ~/.townhouse/wallet.enc; tests and non-default\n // config dirs need the wallet collocated with config.yaml.\n configToWrite.wallet.encrypted_path = join(dir, 'wallet.enc');\n }\n const yamlContent = stringify(configToWrite);\n writeFileSync(configPath, yamlContent, {\n encoding: 'utf-8',\n mode: 0o600,\n });\n\n console.log(`Config created at ${configPath}`);\n\n // Generate wallet — use config dir for wallet path (overrides default home dir path)\n const walletPath = join(dir, 'wallet.enc');\n if (existsSync(walletPath) && !force) {\n console.log(\n `Wallet already exists at ${walletPath}. Skipping wallet generation.`\n );\n return;\n }\n\n const walletPassword = password ?? process.env['TOWNHOUSE_WALLET_PASSWORD'];\n if (!walletPassword) {\n console.error(\n 'Wallet password required. Use --password flag or TOWNHOUSE_WALLET_PASSWORD env var.'\n );\n process.exitCode = 1;\n return;\n }\n\n const walletManager = new WalletManager({ encryptedPath: walletPath });\n const { mnemonic } = await walletManager.generate();\n\n // Display mnemonic ONCE for backup — this is the only place it ever appears\n console.log('');\n console.log('=== IMPORTANT: Back up your seed phrase ===');\n console.log('');\n console.log(` ${mnemonic}`);\n console.log('');\n console.log('This is the ONLY time your seed phrase will be shown.');\n console.log('Store it safely. You will need it to recover your node keys.');\n console.log('============================================');\n console.log('');\n\n // Encrypt and save — mnemonic reference is not stored beyond this block\n const encrypted = encryptWallet(mnemonic, walletPassword);\n await saveWallet(walletPath, encrypted);\n console.log(`Wallet saved to ${walletPath}`);\n\n // Display derived addresses\n console.log('');\n console.log('Derived Node Addresses:');\n console.log('-----------------------');\n const allKeys = walletManager.getAllKeys();\n for (const info of allKeys) {\n console.log(` ${info.nodeType.padEnd(6)} Nostr: ${info.nostrPubkey}`);\n console.log(` ${''.padEnd(6)} EVM: ${info.evmAddress}`);\n }\n\n // Zero key material\n walletManager.lock();\n}\n\nasync function handleSetup(\n configDir: string | undefined,\n port: number,\n noBrowser: boolean,\n dockerInstance?: Docker,\n browserOpener?: BrowserOpener\n): Promise<void> {\n const dir = resolve(configDir ?? DEFAULT_CONFIG_DIR);\n const configPath = join(dir, 'config.yaml');\n const walletPath = join(dir, 'wallet.enc');\n\n // Short-circuit only when BOTH the config and the wallet exist — a config\n // without a wallet would land the operator in a circular dead-end (setup\n // says \"already initialized → run `townhouse up`\", up then errors that the\n // wallet is missing). Guide them to clean up and re-run setup instead.\n if (existsSync(configPath) && existsSync(walletPath)) {\n console.log('Already initialized — run `townhouse up` to start your nodes');\n return;\n }\n if (existsSync(configPath) && !existsSync(walletPath)) {\n console.error(\n `Found ${configPath} but no wallet at ${walletPath}.\\n` +\n `Delete the orphan config and re-run \\`townhouse setup\\`, or restore the wallet from backup.`\n );\n process.exitCode = 1;\n return;\n }\n\n const docker = dockerInstance ?? new Docker();\n const opener = browserOpener ?? new RealBrowserOpener();\n\n const wizardServer = await createWizardApiServer({\n configDir: dir,\n configPath,\n walletPath,\n port,\n docker,\n });\n\n const url = `http://127.0.0.1:${port}/wizard`;\n\n try {\n await wizardServer.app.listen({ host: '127.0.0.1', port });\n } catch (err: unknown) {\n const e = err as NodeJS.ErrnoException;\n if (e.code === 'EADDRINUSE') {\n console.error(\n `Port ${port} is already in use. Pass \\`--port <n>\\` to choose a different port.`\n );\n process.exitCode = 1;\n try {\n await wizardServer.close();\n } catch {\n /* best-effort */\n }\n return;\n }\n throw err;\n }\n console.log(`Wizard ready at ${url}`);\n\n if (!noBrowser) {\n await opener.open(url);\n }\n\n // Wire signal handlers via process.once so they self-remove after firing\n // — prevents listener leaks when tests call main(['setup', ...]) repeatedly.\n // The Fastify server keeps the process alive after handleSetup returns;\n // signals trigger graceful close.\n let shuttingDown = false;\n const shutdown = async (sig: string) => {\n if (shuttingDown) return;\n shuttingDown = true;\n console.log(`\\nReceived ${sig}, shutting down...`);\n try {\n await wizardServer.close();\n } catch {\n /* best-effort */\n }\n process.exit(0);\n };\n process.once('SIGINT', () => {\n void shutdown('SIGINT');\n });\n process.once('SIGTERM', () => {\n void shutdown('SIGTERM');\n });\n}\n\nasync function handleWalletShow(\n config: TownhouseConfig,\n password?: string\n): Promise<void> {\n const walletPath = config.wallet.encrypted_path;\n const result = await loadWallet(walletPath);\n\n if (!result) {\n console.error('No wallet found. Run `townhouse init` first.');\n process.exitCode = 1;\n return;\n }\n\n if (result.permissionsWarning) {\n console.error(result.permissionsWarning);\n }\n\n const walletPassword = password ?? process.env['TOWNHOUSE_WALLET_PASSWORD'];\n if (!walletPassword) {\n console.error(\n 'Wallet password required. Use --password flag or TOWNHOUSE_WALLET_PASSWORD env var.'\n );\n process.exitCode = 1;\n return;\n }\n\n const walletManager = new WalletManager({ encryptedPath: walletPath });\n try {\n // Decrypt mnemonic in minimal scope — fromMnemonic derives keys then\n // the mnemonic string becomes unreachable (eligible for GC)\n await walletManager.fromMnemonic(\n decryptWallet(result.wallet, walletPassword)\n );\n } catch (err: unknown) {\n const msg = err instanceof Error ? err.message : String(err);\n console.error(`Failed to decrypt wallet: ${msg}`);\n process.exitCode = 1;\n return;\n }\n\n console.log(\n 'Node Type | Nostr Pubkey | EVM Address | Derivation Path'\n );\n console.log(\n '-----------|------------------------------------------------------------------|--------------------------------------------|--------------------------'\n );\n const allKeys = walletManager.getAllKeys();\n for (const info of allKeys) {\n console.log(\n `${info.nodeType.padEnd(10)} | ${info.nostrPubkey} | ${info.evmAddress} | ${info.nostrDerivationPath}`\n );\n }\n\n // Zero key material immediately after display\n walletManager.lock();\n}\n\nasync function handleStatus(\n docker: Docker,\n config: TownhouseConfig\n): Promise<void> {\n const orchestrator = new DockerOrchestrator(docker, config);\n const statuses = await orchestrator.status();\n\n console.log('Node Status:');\n console.log('------------');\n for (const s of statuses) {\n const health = s.health ? ` (${s.health})` : '';\n console.log(` ${s.name.padEnd(12)} ${s.state}${health}`);\n }\n\n const connectorHs = config.transport.hiddenService;\n const relayHs = config.transport.relayHiddenService;\n if (\n config.transport.mode === 'ator' ||\n connectorHs?.externalUrl ||\n relayHs?.externalUrl ||\n config.transport.externalUrl\n ) {\n console.log('');\n console.log('Hidden Services:');\n console.log('----------------');\n const connectorUrl =\n connectorHs?.externalUrl ?? config.transport.externalUrl;\n if (connectorUrl) {\n console.log(` Connector (BTP): ${connectorUrl}`);\n }\n if (relayHs?.externalUrl) {\n console.log(` Relay (Nostr): ${relayHs.externalUrl}`);\n }\n if (!connectorUrl && !relayHs?.externalUrl) {\n console.log(' (ator mode set but no externalUrl configured)');\n }\n }\n\n // Try to include connector metrics (graceful degradation)\n try {\n const adminClient = new ConnectorAdminClient(\n `http://127.0.0.1:${config.connector.adminPort}`\n );\n const metrics = await adminClient.getMetrics();\n const peers = await adminClient.getPeers();\n const activePeers = peers.filter((p) => p.connected).length;\n\n console.log('');\n console.log('Connector Metrics:');\n console.log('------------------');\n console.log(` Packets forwarded: ${metrics.aggregate.packetsForwarded}`);\n console.log(` Active peers: ${activePeers}/${peers.length}`);\n } catch {\n console.log('');\n console.log('Connector Metrics: unavailable');\n }\n}\n\nasync function handleMetrics(config: TownhouseConfig): Promise<void> {\n const adminClient = new ConnectorAdminClient(\n `http://127.0.0.1:${config.connector.adminPort}`\n );\n\n try {\n const metrics = await adminClient.getMetrics();\n const peers = await adminClient.getPeers();\n\n // Per-peer packet counters live on /admin/metrics.json (peers[]),\n // not /admin/peers — index by peerId so we can show counts inline.\n const peerMetrics = new Map(metrics.peers.map((p) => [p.peerId, p]));\n\n console.log('Connector Metrics:');\n console.log('------------------');\n console.log(` Packets forwarded: ${metrics.aggregate.packetsForwarded}`);\n console.log(` Packets rejected: ${metrics.aggregate.packetsRejected}`);\n console.log(` Bytes sent: ${metrics.aggregate.bytesSent}`);\n console.log('');\n console.log('Peers:');\n console.log('------');\n if (peers.length === 0) {\n console.log(' No peers connected');\n } else {\n for (const peer of peers) {\n const status = peer.connected ? 'connected' : 'disconnected';\n const packets = peerMetrics.get(peer.id)?.packetsForwarded ?? 0;\n console.log(` ${peer.id.padEnd(12)} ${status} (${packets} packets)`);\n }\n }\n } catch (error: unknown) {\n const msg = error instanceof Error ? error.message : String(error);\n console.error(`Failed to fetch connector metrics: ${msg}`);\n process.exitCode = 1;\n }\n}\n\n/**\n * Determine which node profiles to start based on CLI flags and config.\n * If explicit flags (--town, --mill, --dvm) are provided, use those.\n * Otherwise fall back to all enabled nodes from config.\n */\nfunction resolveProfiles(\n values: Record<string, unknown>,\n config: TownhouseConfig\n): NodeType[] {\n const explicitFlags: NodeType[] = [];\n if (values['town']) explicitFlags.push('town');\n if (values['mill']) explicitFlags.push('mill');\n if (values['dvm']) explicitFlags.push('dvm');\n\n if (explicitFlags.length > 0) {\n return explicitFlags;\n }\n\n // No explicit flags — start all enabled nodes from config\n const enabled: NodeType[] = [];\n if (config.nodes.town.enabled) enabled.push('town');\n if (config.nodes.mill.enabled) enabled.push('mill');\n if (config.nodes.dvm.enabled) enabled.push('dvm');\n return enabled;\n}\n\nasync function handleUp(\n configPath: string,\n config: TownhouseConfig,\n profiles: NodeType[],\n docker: Docker,\n password?: string,\n dryRun = false\n): Promise<void> {\n if (profiles.length === 0) {\n console.log(\n 'No nodes enabled in config. Enable nodes in config.yaml first.'\n );\n return;\n }\n\n // Initialize wallet (Round-2 Decision D1:c).\n // The API's GET /wallet depends on an unlocked wallet. If a wallet file\n // exists on disk it MUST be unlockable (fail-fast on bad password). If no\n // wallet file exists, we log a warning and skip API startup entirely so\n // orchestration-only callers (CI, tooling, smoke tests) still work.\n const walletPath = config.wallet.encrypted_path;\n let walletManager: WalletManager | undefined;\n if (!existsSync(walletPath)) {\n console.error(\n `Wallet not found at ${walletPath}. Run \\`townhouse setup\\` first (or restore your wallet backup).`\n );\n process.exitCode = 1;\n return;\n } else {\n const walletPassword = password ?? process.env['TOWNHOUSE_WALLET_PASSWORD'];\n if (!walletPassword) {\n throw new Error(\n 'Wallet password required to start the API. Use --password flag or TOWNHOUSE_WALLET_PASSWORD env var.'\n );\n }\n const loaded = await loadWallet(walletPath);\n if (!loaded) {\n throw new Error(`Wallet at ${walletPath} could not be read.`);\n }\n if (loaded.permissionsWarning) {\n console.error(loaded.permissionsWarning);\n }\n walletManager = new WalletManager({ encryptedPath: walletPath });\n try {\n await walletManager.fromMnemonic(\n decryptWallet(loaded.wallet, walletPassword)\n );\n } catch (err: unknown) {\n const msg = err instanceof Error ? err.message : String(err);\n throw new Error(`Failed to decrypt wallet: ${msg}`);\n }\n }\n\n const orchestrator = new DockerOrchestrator(docker, config, walletManager);\n\n // Wire up progress reporting\n orchestrator.on(\n 'containerState',\n (event: { name: string; state: string }) => {\n console.log(` ${event.name}: ${event.state}`);\n }\n );\n orchestrator.on(\n 'pullProgress',\n (event: { image: string; status: string; progress?: string }) => {\n const progress = event.progress ? ` ${event.progress}` : '';\n console.log(` [pull] ${event.image}: ${event.status}${progress}`);\n }\n );\n\n // API server reference for graceful shutdown\n let apiServer: ApiServer | undefined;\n\n // Register SIGINT handler for graceful shutdown\n const sigintHandler = async () => {\n console.log('\\nReceived SIGINT, shutting down gracefully...');\n\n // Close API server first\n if (apiServer) {\n try {\n await apiServer.close();\n } catch {\n // Best-effort\n }\n }\n\n // Then stop containers\n try {\n await orchestrator.down();\n } catch {\n // Best-effort cleanup\n }\n process.exit(0);\n };\n process.on('SIGINT', sigintHandler);\n\n // For SIGTERM\n const sigtermHandler = async () => {\n console.log('\\nReceived SIGTERM, shutting down gracefully...');\n\n if (apiServer) {\n try {\n await apiServer.close();\n } catch {\n // Best-effort\n }\n }\n\n try {\n await orchestrator.down();\n } catch {\n // Best-effort cleanup\n }\n process.exit(0);\n };\n process.on('SIGTERM', sigtermHandler);\n\n // Track if the server started successfully (handlers stay registered if true)\n let serverStarted = false;\n\n if (\n profiles.includes('dvm') &&\n config.nodes.dvm.enabled &&\n !process.env['TURBO_TOKEN']\n ) {\n console.warn(\n '[townhouse] WARN: TURBO_TOKEN is not set — Arweave DVM (kind:5094) uploads will fail at first job.'\n );\n console.warn(\n '[townhouse] Export TURBO_TOKEN=<arweave-jwk-json> before `townhouse up` to enable uploads.'\n );\n }\n\n try {\n console.log(`Starting nodes: ${profiles.join(', ')}...`);\n if (!dryRun) {\n await orchestrator.up(profiles);\n console.log('All nodes started successfully.');\n } else {\n console.log('[dry-run] Skipped orchestrator.up()');\n }\n\n // Start API server after nodes are up\n if (walletManager) {\n const connectorAdmin = new ConnectorAdminClient(\n `http://127.0.0.1:${config.connector.adminPort}`\n );\n\n const transportProbe = new TransportProbe({\n proxyUrl:\n config.transport.mode === 'ator'\n ? (config.transport.socksProxy ?? DEFAULT_ATOR_PROXY)\n : '',\n });\n if (config.transport.mode === 'ator') {\n transportProbe.start();\n }\n\n const apiDeps = {\n configPath,\n config,\n orchestrator,\n wallet: walletManager,\n connectorAdmin,\n transportProbe,\n };\n\n apiServer = await createApiServer(apiDeps);\n\n const { host, port } = config.api;\n if (!dryRun) {\n await apiServer.app.listen({\n host: host ?? '127.0.0.1',\n port: port ?? 9400,\n });\n serverStarted = true;\n\n console.log(\n `\\n[Townhouse API] listening on http://${host ?? '127.0.0.1'}:${port ?? 9400}`\n );\n console.log(\n ' GET /nodes, GET /nodes/:type, PATCH /nodes/:type/config, GET /wallet, WS /metrics'\n );\n } else {\n // Log a structured summary for the dry-run smoke test (Task 8.3).\n console.log(\n `[dry-run] API factory invoked: configPath=${configPath} host=${host ?? '127.0.0.1'} port=${port ?? 9400} connectorAdmin=http://127.0.0.1:${config.connector.adminPort} wallet=WalletManager`\n );\n await apiServer.close();\n apiServer = undefined;\n }\n }\n } catch (error: unknown) {\n const msg = error instanceof Error ? error.message : String(error);\n if (\n msg.includes('Docker is not running') ||\n msg.includes('ENOENT') ||\n msg.includes('ECONNREFUSED') ||\n msg.includes('socket')\n ) {\n throw new Error(\n `Docker is not available. Please ensure Docker is running and try again. (${msg})`\n );\n }\n throw error;\n } finally {\n // Only remove signal handlers if server never started\n // If server is running, handlers enable graceful shutdown on SIGTERM/SIGINT\n if (!serverStarted) {\n process.removeListener('SIGINT', sigintHandler);\n process.removeListener('SIGTERM', sigtermHandler);\n }\n }\n}\n\nasync function handleDown(\n config: TownhouseConfig,\n docker: Docker\n): Promise<void> {\n const orchestrator = new DockerOrchestrator(docker, config);\n\n orchestrator.on(\n 'containerState',\n (event: { name: string; state: string }) => {\n console.log(` ${event.name}: ${event.state}`);\n }\n );\n\n console.log('Stopping nodes...');\n await orchestrator.down();\n console.log('All nodes stopped.');\n}\n\n/**\n * Main CLI entry — exported for testability (same pattern as Mill CLI).\n * Accepts optional dockerode instance for dependency injection in tests.\n */\nexport async function main(\n argv: string[],\n dockerInstance?: Docker,\n browserOpener?: BrowserOpener\n): Promise<void> {\n const { values, positionals } = parseArgs({\n args: argv,\n options: {\n help: { type: 'boolean' },\n force: { type: 'boolean' },\n config: { type: 'string', short: 'c' },\n 'config-dir': { type: 'string' },\n town: { type: 'boolean' },\n mill: { type: 'boolean' },\n dvm: { type: 'boolean' },\n password: { type: 'string' },\n 'dry-run': { type: 'boolean' },\n 'no-browser': { type: 'boolean' },\n port: { type: 'string' },\n preset: { type: 'string' },\n yes: { type: 'boolean' },\n },\n strict: false,\n allowPositionals: true,\n });\n\n if (values.help) {\n console.log(HELP_TEXT);\n throw new CliHelpRequested();\n }\n\n const command = positionals[0];\n\n if (!command) {\n console.log(HELP_TEXT);\n throw new CliHelpRequested();\n }\n\n switch (command) {\n case 'setup': {\n const portStr = values['port'] as string | undefined;\n // Reject trailing junk like \"9400foo\" (parseInt would silently accept).\n const port = portStr ? Number(portStr) : 9400;\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n console.error('--port must be an integer between 1 and 65535');\n process.exitCode = 1;\n break;\n }\n await handleSetup(\n values['config-dir'] as string | undefined,\n port,\n values['no-browser'] === true,\n dockerInstance,\n browserOpener\n );\n break;\n }\n case 'init': {\n const presetVal = values.preset as string | undefined;\n if (presetVal !== undefined && presetVal !== 'demo') {\n console.error(`Unknown preset: ${presetVal}. Supported: demo`);\n process.exitCode = 1;\n break;\n }\n await handleInit(\n values.force === true,\n values['config-dir'] as string | undefined,\n values.password as string | undefined,\n presetVal,\n values.yes === true\n );\n break;\n }\n case 'wallet': {\n const subCommand = positionals[1];\n if (subCommand === 'show') {\n const configPath = (values.config as string) ?? DEFAULT_CONFIG_PATH;\n const config = loadConfig(configPath);\n await handleWalletShow(config, values.password as string | undefined);\n } else {\n console.error(\n 'Usage: townhouse wallet show [-c <path>] [--password <pw>]'\n );\n process.exitCode = 1;\n }\n break;\n }\n case 'status': {\n const configPath = (values.config as string) ?? DEFAULT_CONFIG_PATH;\n const config = loadConfig(configPath);\n const docker = dockerInstance ?? new Docker();\n await handleStatus(docker, config);\n break;\n }\n case 'up': {\n const configPath = (values.config as string) ?? DEFAULT_CONFIG_PATH;\n const config = loadConfig(configPath);\n const docker = dockerInstance ?? new Docker();\n const profiles = resolveProfiles(values, config);\n await handleUp(\n configPath,\n config,\n profiles,\n docker,\n values.password as string | undefined,\n values['dry-run'] === true\n );\n break;\n }\n case 'down': {\n const configPath = (values.config as string) ?? DEFAULT_CONFIG_PATH;\n const config = loadConfig(configPath);\n const docker = dockerInstance ?? new Docker();\n await handleDown(config, docker);\n break;\n }\n case 'metrics': {\n const configPath = (values.config as string) ?? DEFAULT_CONFIG_PATH;\n const config = loadConfig(configPath);\n await handleMetrics(config);\n break;\n }\n default: {\n // Sanitize user input to prevent log injection (CWE-117)\n // eslint-disable-next-line no-control-regex\n const sanitized = command.replace(/[\\x00-\\x1f\\x7f]/g, '');\n console.error(`Unknown command: ${sanitized}`);\n console.log(HELP_TEXT);\n process.exitCode = 1;\n }\n }\n}\n\n// Self-invoke when run as entrypoint.\nconst invokedFile = process.argv[1];\nconst invokedDirectly =\n typeof invokedFile === 'string' &&\n import.meta.url === pathToFileURL(invokedFile).href;\n\nif (invokedDirectly) {\n main(process.argv.slice(2)).catch((error: unknown) => {\n if (error instanceof CliHelpRequested) {\n process.exit(0);\n }\n console.error('[Townhouse] Error:', error);\n process.exit(1);\n });\n}\n","/**\n * Cross-platform browser opener for the wizard CLI command.\n * Uses platform-native launchers; errors are non-fatal.\n */\n\nimport { spawn } from 'node:child_process';\n\nexport interface BrowserOpener {\n open(url: string): Promise<void>;\n}\n\nexport class RealBrowserOpener implements BrowserOpener {\n async open(url: string): Promise<void> {\n let cmd: string;\n let args: string[];\n\n switch (process.platform) {\n case 'darwin':\n cmd = 'open';\n args = [url];\n break;\n case 'win32':\n cmd = 'cmd';\n args = ['/c', 'start', '', url];\n break;\n default:\n cmd = 'xdg-open';\n args = [url];\n break;\n }\n\n return new Promise<void>((resolve) => {\n let settled = false;\n const settle = () => {\n if (settled) return;\n settled = true;\n resolve();\n };\n\n try {\n const child = spawn(cmd, args, {\n stdio: ['ignore', 'ignore', 'ignore'],\n detached: true,\n });\n\n // ENOENT (e.g. xdg-open not on PATH on a minimal container/WSL2 env)\n // surfaces asynchronously via the 'error' event, NOT a synchronous\n // throw from spawn. Subscribe so we don't crash with an unhandled\n // 'error' event and so the user gets a hint about why no browser opened.\n child.once('error', (err: Error) => {\n console.warn(\n `[Townhouse] Could not open browser via ${cmd}: ${err.message}`\n );\n settle();\n });\n child.once('spawn', () => {\n child.unref();\n settle();\n });\n } catch (err: unknown) {\n // Synchronous spawn error path (rare)\n const msg = err instanceof Error ? err.message : String(err);\n console.warn(`[Townhouse] Could not open browser: ${msg}`);\n settle();\n }\n });\n }\n}\n\nexport class NoopBrowserOpener implements BrowserOpener {\n public readonly calls: string[] = [];\n\n async open(url: string): Promise<void> {\n this.calls.push(url);\n }\n}\n"],"mappings":"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","names":["resolve"]}
@@ -0,0 +1,406 @@
1
+ # Townhouse Dev Stack — Docker Compose (Story 21.8.0, D21-009)
2
+ #
3
+ # Full contributor dev topology: 1 standalone connector + 2 Town + 2 Mill + 1 DVM
4
+ # + 3 chain devnets + 1 SOCKS5 proxy.
5
+ #
6
+ # This file is intentionally separate from docker-compose-townhouse.yml.
7
+ # Production describes one operator's actual node. This file describes a
8
+ # contributor's rig with enough instances to exercise every dashboard view.
9
+ # See story 21.8.0 Dev Notes for the full rationale.
10
+ #
11
+ # Canonical path (npm tarball): packages/townhouse/compose/townhouse-dev.yml
12
+ # Ships verbatim inside @toon-protocol/townhouse dist/compose/townhouse-dev.yml
13
+ # (Story 45.2 — no digest substitution; uses local toon:* image tags).
14
+ #
15
+ # Legacy path (backward-compat): docker-compose-townhouse-dev.yml (repo root)
16
+ # Used by scripts/townhouse-dev-infra.sh and existing CI. Preserved until
17
+ # a follow-up story routes the script through the package-local copy.
18
+ #
19
+ # Usage:
20
+ # ./scripts/townhouse-dev-infra.sh up # Build, start, wait for health (reads root path)
21
+ # ./scripts/townhouse-dev-infra.sh down # Stop containers, remove .env.townhouse-dev
22
+ # ./scripts/townhouse-dev-infra.sh down-v # Same + remove volumes
23
+ # ./scripts/townhouse-dev-infra.sh status # Show container state
24
+ #
25
+ # Port allocation — 28xxx range (no collision with SDK E2E 18xxx/19xxx or
26
+ # production Townhouse 3xxx/7100/9401). See CLAUDE.md "Townhouse Dev Stack (28xxx)".
27
+ #
28
+ # All host bindings use 127.0.0.1 — never 0.0.0.0 (developer machine).
29
+ #
30
+ # Connector image tag must match DEFAULT_CONNECTOR_IMAGE in
31
+ # packages/townhouse/src/constants.ts — keep both in sync when bumping.
32
+
33
+ networks:
34
+ townhouse-dev-net:
35
+ driver: bridge
36
+
37
+ volumes:
38
+ townhouse-dev-connector-data:
39
+ townhouse-dev-town-01-data:
40
+ townhouse-dev-town-02-data:
41
+ townhouse-dev-mill-01-data:
42
+ townhouse-dev-mill-02-data:
43
+ townhouse-dev-dvm-01-data:
44
+
45
+ services:
46
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
47
+ # Connector — standalone connector for all 5 child peers
48
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
49
+ townhouse-dev-connector:
50
+ # Image tag must match DEFAULT_CONNECTOR_IMAGE in packages/townhouse/src/constants.ts
51
+ image: ghcr.io/toon-protocol/connector:3.4.1
52
+ container_name: townhouse-dev-connector
53
+ networks:
54
+ - townhouse-dev-net
55
+ ports:
56
+ - '127.0.0.1:28080:9401'
57
+ environment:
58
+ CONFIG_FILE: /config/connector.yaml
59
+ volumes:
60
+ - ./docker/configs/townhouse-dev-connector.yaml:/config/connector.yaml:ro
61
+ - townhouse-dev-connector-data:/data
62
+ restart: unless-stopped
63
+ healthcheck:
64
+ # nosemgrep: trailofbits.generic.wget-unencrypted-url.wget-unencrypted-url -- container-internal health probe
65
+ test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:9401/health']
66
+ interval: 10s
67
+ timeout: 5s
68
+ retries: 5
69
+ start_period: 15s
70
+
71
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
72
+ # Anvil — Local Ethereum (chain-id 31337) with auto-deployed contracts
73
+ # Copied from docker-compose-sdk-e2e.yml, ports shifted to 28xxx range
74
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
75
+ townhouse-dev-anvil:
76
+ image: ghcr.io/foundry-rs/foundry:latest
77
+ container_name: townhouse-dev-anvil
78
+ entrypoint: []
79
+ command:
80
+ - sh
81
+ - -c
82
+ - |
83
+ echo "Starting Anvil..."
84
+ anvil --host 0.0.0.0 --port 8545 --chain-id 31337 --accounts 10 --balance 10000 &
85
+ ANVIL_PID=$$!
86
+
87
+ echo "Waiting for Anvil to be ready..."
88
+ until cast client --rpc-url http://localhost:8545 2>/dev/null | grep -q 'anvil'; do
89
+ sleep 1
90
+ done
91
+
92
+ echo "Deploying contracts..."
93
+ cd /contracts
94
+ forge script script/DeployLocal.s.sol:DeployLocalScript --rpc-url http://localhost:8545 --broadcast --skip-simulation || echo "Deployment failed (non-fatal)"
95
+
96
+ echo "Funding test accounts with Mock USDC..."
97
+ DEPLOYER_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
98
+ TOKEN=0x5FbDB2315678afecb367f032d93F642f64180aa3
99
+ AMOUNT=10000000000000000000000
100
+ for ADDR in \
101
+ 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 \
102
+ 0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 \
103
+ 0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc \
104
+ 0x976EA74026E726554dB657fA54763abd0C3a0aa9 \
105
+ 0x14dC79964da2C08b23698B3D3cc7Ca32193d9955 \
106
+ 0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f \
107
+ 0xa0Ee7A142d267C1f36714E4a8F75612F20a79720; do
108
+ cast send --rpc-url http://localhost:8545 --private-key $$DEPLOYER_KEY \
109
+ $$TOKEN "transfer(address,uint256)" $$ADDR $$AMOUNT 2>/dev/null \
110
+ && echo "Funded $$ADDR" || echo "Fund $$ADDR failed (non-fatal)"
111
+ done
112
+
113
+ echo "Anvil ready with contracts deployed!"
114
+ wait $$ANVIL_PID
115
+ volumes:
116
+ - ./contracts/evm:/contracts
117
+ ports:
118
+ - '127.0.0.1:28545:8545'
119
+ networks:
120
+ - townhouse-dev-net
121
+ restart: unless-stopped
122
+ healthcheck:
123
+ test:
124
+ [
125
+ 'CMD-SHELL',
126
+ "cast client --rpc-url http://localhost:8545 2>&1 | grep -q 'anvil' || exit 1",
127
+ ]
128
+ interval: 10s
129
+ timeout: 5s
130
+ retries: 3
131
+ start_period: 15s
132
+
133
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
134
+ # Solana Test Validator — ports shifted to 28xxx range
135
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
136
+ townhouse-dev-solana:
137
+ image: ghcr.io/beeman/solana-test-validator:latest
138
+ container_name: townhouse-dev-solana
139
+ entrypoint: []
140
+ command:
141
+ - sh
142
+ - /entrypoint.sh
143
+ volumes:
144
+ - ./infra/solana/entrypoint.sh:/entrypoint.sh:ro
145
+ - ./contracts/solana:/programs:ro
146
+ tmpfs:
147
+ - /tmp/test-ledger
148
+ ports:
149
+ - '127.0.0.1:28899:8899' # RPC
150
+ - '127.0.0.1:28900:8900' # WebSocket
151
+ networks:
152
+ - townhouse-dev-net
153
+ security_opt:
154
+ - seccomp=unconfined
155
+ restart: unless-stopped
156
+ healthcheck:
157
+ test: ['CMD-SHELL', 'solana cluster-version --url http://localhost:8899 >/dev/null 2>&1']
158
+ interval: 5s
159
+ timeout: 5s
160
+ retries: 12
161
+ start_period: 10s
162
+
163
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
164
+ # Mina Lightnet — ports shifted to 28xxx range
165
+ # NOTE: Requires 4-8 GB RAM (see SDK E2E notes)
166
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
167
+ townhouse-dev-mina:
168
+ image: o1labs/mina-local-network:compatible-latest-lightnet
169
+ container_name: townhouse-dev-mina
170
+ environment:
171
+ NETWORK_TYPE: single-node
172
+ PROOF_LEVEL: none
173
+ LOG_LEVEL: Info
174
+ RUN_ARCHIVE_NODE: 'false'
175
+ SLOT_TIME: '20000'
176
+ ports:
177
+ - '127.0.0.1:28085:3101' # GraphQL (direct to daemon)
178
+ - '127.0.0.1:28181:8181' # Accounts Manager
179
+ networks:
180
+ - townhouse-dev-net
181
+ deploy:
182
+ resources:
183
+ limits:
184
+ memory: 4g
185
+ restart: unless-stopped
186
+ healthcheck:
187
+ test:
188
+ [
189
+ 'CMD-SHELL',
190
+ 'curl -sf -X POST -H "Content-Type: application/json" -d "{\"query\":\"{syncStatus}\"}" http://localhost:3101/graphql | grep -q SYNCED || exit 1',
191
+ ]
192
+ interval: 10s
193
+ timeout: 10s
194
+ retries: 30
195
+ start_period: 180s
196
+
197
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
198
+ # SOCKS5 Proxy — optional; required for ATOR transport testing (story 21.15)
199
+ # Connector defaults to TRANSPORT_MODE: direct; proxy only needed for ATOR mode.
200
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
201
+ townhouse-dev-socks5:
202
+ image: serjs/go-socks5-proxy:latest
203
+ container_name: townhouse-dev-socks5
204
+ ports:
205
+ - '127.0.0.1:28050:1080'
206
+ networks:
207
+ - townhouse-dev-net
208
+ restart: unless-stopped
209
+
210
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
211
+ # Town 01 — Nostr relay node (instance 1 of 2)
212
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
213
+ townhouse-dev-town-01:
214
+ image: toon:town
215
+ container_name: townhouse-dev-town-01
216
+ networks:
217
+ - townhouse-dev-net
218
+ depends_on:
219
+ townhouse-dev-connector:
220
+ condition: service_healthy
221
+ expose:
222
+ - '3000'
223
+ ports:
224
+ - '127.0.0.1:28100:3100' # BLS health
225
+ - '127.0.0.1:28700:7100' # Nostr relay WebSocket
226
+ environment:
227
+ # nosemgrep: detect-insecure-websocket -- Docker-internal, TLS unnecessary
228
+ CONNECTOR_URL: ws://townhouse-dev-connector:3000
229
+ CONNECTOR_ADMIN_URL: http://townhouse-dev-connector:9401
230
+ FEE_PER_EVENT: '0'
231
+ NODE_NOSTR_PUBKEY: ''
232
+ NODE_EVM_ADDRESS: ''
233
+ # Interpolated from TOWN_01_SECRET_KEY exported by townhouse-dev-infra.sh.
234
+ NODE_NOSTR_SECRET_KEY: '${TOWN_01_SECRET_KEY:?TOWN_01_SECRET_KEY required — source scripts/townhouse-dev-infra.sh first}'
235
+ volumes:
236
+ - townhouse-dev-town-01-data:/data
237
+ healthcheck:
238
+ # nosemgrep: trailofbits.generic.wget-unencrypted-url.wget-unencrypted-url -- container-internal health probe
239
+ test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:3100/health']
240
+ interval: 30s
241
+ timeout: 10s
242
+ retries: 3
243
+ start_period: 5s
244
+ restart: unless-stopped
245
+
246
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
247
+ # Town 02 — Nostr relay node (instance 2 of 2)
248
+ # Story 21.10 exercises degraded state via `docker pause townhouse-dev-town-02`
249
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
250
+ townhouse-dev-town-02:
251
+ image: toon:town
252
+ container_name: townhouse-dev-town-02
253
+ networks:
254
+ - townhouse-dev-net
255
+ depends_on:
256
+ townhouse-dev-connector:
257
+ condition: service_healthy
258
+ expose:
259
+ - '3000'
260
+ ports:
261
+ - '127.0.0.1:28110:3100' # BLS health
262
+ - '127.0.0.1:28710:7100' # Nostr relay WebSocket
263
+ environment:
264
+ # nosemgrep: detect-insecure-websocket -- Docker-internal, TLS unnecessary
265
+ CONNECTOR_URL: ws://townhouse-dev-connector:3000
266
+ CONNECTOR_ADMIN_URL: http://townhouse-dev-connector:9401
267
+ FEE_PER_EVENT: '0'
268
+ NODE_NOSTR_PUBKEY: ''
269
+ NODE_EVM_ADDRESS: ''
270
+ # Interpolated from TOWN_02_SECRET_KEY exported by townhouse-dev-infra.sh.
271
+ NODE_NOSTR_SECRET_KEY: '${TOWN_02_SECRET_KEY:?TOWN_02_SECRET_KEY required — source scripts/townhouse-dev-infra.sh first}'
272
+ volumes:
273
+ - townhouse-dev-town-02-data:/data
274
+ healthcheck:
275
+ # nosemgrep: trailofbits.generic.wget-unencrypted-url.wget-unencrypted-url -- container-internal health probe
276
+ test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:3100/health']
277
+ interval: 30s
278
+ timeout: 10s
279
+ retries: 3
280
+ start_period: 5s
281
+ restart: unless-stopped
282
+
283
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
284
+ # Mill 01 — EVM↔Solana swap peer (story 21.11 AC names this exact container)
285
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
286
+ townhouse-dev-mill-01:
287
+ image: toon:mill
288
+ container_name: townhouse-dev-mill-01
289
+ networks:
290
+ - townhouse-dev-net
291
+ depends_on:
292
+ townhouse-dev-connector:
293
+ condition: service_healthy
294
+ townhouse-dev-anvil:
295
+ condition: service_healthy
296
+ townhouse-dev-solana:
297
+ condition: service_healthy
298
+ expose:
299
+ - '3000'
300
+ ports:
301
+ - '127.0.0.1:28200:3200' # BLS health
302
+ environment:
303
+ # nosemgrep: detect-insecure-websocket -- Docker-internal, TLS unnecessary
304
+ CONNECTOR_URL: ws://townhouse-dev-connector:3000
305
+ FEE_BASIS_POINTS: '0'
306
+ NODE_NOSTR_PUBKEY: ''
307
+ NODE_EVM_ADDRESS: ''
308
+ # MILL_MNEMONIC takes priority over NODE_NOSTR_SECRET_KEY for BIP-32 swap key derivation.
309
+ # Interpolated from MILL_01_MNEMONIC exported by townhouse-dev-infra.sh.
310
+ MILL_MNEMONIC: '${MILL_01_MNEMONIC:?MILL_01_MNEMONIC required — source scripts/townhouse-dev-infra.sh first}'
311
+ # Kept for backward-compat; ignored when MILL_MNEMONIC is set.
312
+ NODE_NOSTR_SECRET_KEY: '${MILL_01_SECRET_KEY:?MILL_01_SECRET_KEY required — source scripts/townhouse-dev-infra.sh first}'
313
+ MILL_CONFIG_PATH: /config/mill.config.json
314
+ MILL_RELAYS: ws://townhouse-dev-town-01:7100,ws://townhouse-dev-town-02:7100
315
+ volumes:
316
+ - ./docker/dev-fixtures/mill-01.config.json:/config/mill.config.json:ro
317
+ - townhouse-dev-mill-01-data:/data
318
+ healthcheck:
319
+ # nosemgrep: trailofbits.generic.wget-unencrypted-url.wget-unencrypted-url -- container-internal health probe
320
+ test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:3200/health']
321
+ interval: 30s
322
+ timeout: 10s
323
+ retries: 3
324
+ start_period: 5s
325
+ restart: unless-stopped
326
+
327
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
328
+ # Mill 02 — EVM↔Mina swap peer (story 21.11 AC names this exact container)
329
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
330
+ townhouse-dev-mill-02:
331
+ image: toon:mill
332
+ container_name: townhouse-dev-mill-02
333
+ networks:
334
+ - townhouse-dev-net
335
+ depends_on:
336
+ townhouse-dev-connector:
337
+ condition: service_healthy
338
+ townhouse-dev-anvil:
339
+ condition: service_healthy
340
+ townhouse-dev-mina:
341
+ condition: service_healthy
342
+ expose:
343
+ - '3000'
344
+ ports:
345
+ - '127.0.0.1:28210:3200' # BLS health
346
+ environment:
347
+ # nosemgrep: detect-insecure-websocket -- Docker-internal, TLS unnecessary
348
+ CONNECTOR_URL: ws://townhouse-dev-connector:3000
349
+ FEE_BASIS_POINTS: '0'
350
+ NODE_NOSTR_PUBKEY: ''
351
+ NODE_EVM_ADDRESS: ''
352
+ # MILL_MNEMONIC takes priority over NODE_NOSTR_SECRET_KEY for BIP-32 swap key derivation.
353
+ # Interpolated from MILL_02_MNEMONIC exported by townhouse-dev-infra.sh.
354
+ MILL_MNEMONIC: '${MILL_02_MNEMONIC:?MILL_02_MNEMONIC required — source scripts/townhouse-dev-infra.sh first}'
355
+ # Kept for backward-compat; ignored when MILL_MNEMONIC is set.
356
+ NODE_NOSTR_SECRET_KEY: '${MILL_02_SECRET_KEY:?MILL_02_SECRET_KEY required — source scripts/townhouse-dev-infra.sh first}'
357
+ MILL_CONFIG_PATH: /config/mill.config.json
358
+ MILL_RELAYS: ws://townhouse-dev-town-01:7100,ws://townhouse-dev-town-02:7100
359
+ volumes:
360
+ - ./docker/dev-fixtures/mill-02.config.json:/config/mill.config.json:ro
361
+ - townhouse-dev-mill-02-data:/data
362
+ healthcheck:
363
+ # nosemgrep: trailofbits.generic.wget-unencrypted-url.wget-unencrypted-url -- container-internal health probe
364
+ test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:3200/health']
365
+ interval: 30s
366
+ timeout: 10s
367
+ retries: 3
368
+ start_period: 5s
369
+ restart: unless-stopped
370
+
371
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
372
+ # DVM 01 — data vending machine (story 21.12 AC names this exact container)
373
+ # TURBO_TOKEN: pass through from host env; absence is non-fatal (disabled-upload mode)
374
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
375
+ townhouse-dev-dvm-01:
376
+ image: toon:dvm
377
+ container_name: townhouse-dev-dvm-01
378
+ networks:
379
+ - townhouse-dev-net
380
+ depends_on:
381
+ townhouse-dev-connector:
382
+ condition: service_healthy
383
+ expose:
384
+ - '3300'
385
+ ports:
386
+ - '127.0.0.1:28400:3400' # BLS health
387
+ environment:
388
+ # nosemgrep: detect-insecure-websocket -- Docker-internal, TLS unnecessary
389
+ CONNECTOR_URL: ws://townhouse-dev-connector:3000
390
+ FEE_PER_JOB: '0'
391
+ NODE_NOSTR_PUBKEY: ''
392
+ NODE_EVM_ADDRESS: ''
393
+ # Interpolated from DVM_01_SECRET_KEY exported by townhouse-dev-infra.sh.
394
+ NODE_NOSTR_SECRET_KEY: '${DVM_01_SECRET_KEY:?DVM_01_SECRET_KEY required — source scripts/townhouse-dev-infra.sh first}'
395
+ # TURBO_TOKEN may legitimately be unset for non-Turbo dev paths — empty string is intended.
396
+ TURBO_TOKEN: '${TURBO_TOKEN:-}'
397
+ volumes:
398
+ - townhouse-dev-dvm-01-data:/data
399
+ healthcheck:
400
+ # nosemgrep: trailofbits.generic.wget-unencrypted-url.wget-unencrypted-url -- container-internal health probe
401
+ test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:3400/health']
402
+ interval: 30s
403
+ timeout: 10s
404
+ retries: 3
405
+ start_period: 5s
406
+ restart: unless-stopped