From 59bbd58cb3917867aa44e81af7b8bf12d2c07b67 Mon Sep 17 00:00:00 2001 From: alexcos20 Date: Thu, 6 Aug 2026 12:10:56 +0300 Subject: [PATCH 1/4] allow user to setNode --- CLAUDE.md | 27 +++-- README.md | 50 +++++++++- src/cli.ts | 227 ++++++++++++++++++++++++++++-------------- src/index.ts | 34 ++++++- src/nodeConnection.ts | 224 +++++++++++++++++++++++++++++++++++++++++ test/replMenu.test.ts | 50 +--------- test/setNode.test.ts | 135 +++++++++++++++++++++++++ test/util.ts | 63 +++++++++++- 8 files changed, 679 insertions(+), 131 deletions(-) create mode 100644 src/nodeConnection.ts create mode 100644 test/setNode.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 0645bd5..eba2756 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,14 +61,15 @@ Validated at startup in `createCLI()` (`src/cli.ts`), which `process.exit(1)`s w - `PRIVATE_KEY` **or** `MNEMONIC` — signer credentials (private key preferred; mnemonic via `ethers.Wallet.fromPhrase`). - `RPC` — JSON-RPC endpoint; chainId is read from `provider.getNetwork()`, not configured manually. -- `NODE_URL` — the Ocean Node. Can be an `http(s)://` URL, a raw libp2p peer id, or a full `/dns4/.../p2p/...` multiaddr (triggers P2P mode, see below). ### Optional environment variables +- `NODE_URL` — the **initial** Ocean Node. An `http(s)://` URL, a raw libp2p peer id, or a full `/dns4/.../p2p/...` multiaddr. **Not required to start:** without it the CLI runs in a node-less state where the `preAction` gate in `createCLI()` refuses every command except `setNode` / `getNode` / `help` (see "Node selection"). Switchable at runtime with `setNode`. +- `DISABLE_P2P` — `true` skips starting libp2p entirely. Combined with a P2P `NODE_URL` it is a fatal contradiction (`exit(1)` at startup). - `ADDRESS_FILE` — path to a contracts `address.json`. Defaults to `${homedir}/.ocean/ocean-contracts/artifacts/address.json`. Needed by escrow / mint / access-list commands (see "Config & chain selection"). - `INDEXING_MAX_RETRIES` / `INDEXING_RETRY_INTERVAL` — how long to wait for an asset to be indexed. **Code defaults are 120 retries × 4000 ms** (`getIndexingWaitSettings()` in `helpers.ts`); the README's "100 / 3000" figures are stale. - `AVOID_LOOP_RUN` — `true` = one-shot (no REPL loop). Unset/`false` = interactive loop. -- `BOOTSTRAP_PEERS` — comma-separated extra libp2p multiaddrs, only used in P2P mode. +- `BOOTSTRAP_PEERS` — comma-separated extra libp2p multiaddrs, added to the bootstrap list built in `nodeConnection.ts`. ## CLI commands exposed @@ -81,6 +82,7 @@ All registered in `src/cli.ts` via Commander (`commander` v13). Every command su - Access lists: `createAccessList`, `addToAccessList`, `checkAccessList`, `removeFromAccessList`. - Persistent storage buckets: `createBucket`, `addFileToBucket`, `listBuckets`, `listFilesInBucket`, `getFileObject`, `deleteFile`. - Admin: `downloadNodeLogs`. +- Node selection: `setNode` (alias `useNode`), `getNode` (alias `currentNode`). - `help` / `h`. Per-command flags and examples are exhaustively documented in `README.md` ("Command Usage" / "Available Named Options Per Command"). A few load-bearing notes: @@ -93,9 +95,9 @@ Per-command flags and examples are exhaustively documented in `README.md` ("Comm ### Entry point and dispatch (`src/index.ts` → `src/cli.ts`) -`main()` in `index.ts` calls `createCLI()` (in `cli.ts`) to build the Commander `program`, records supported command names/aliases, prints the REPL banner, runs the initial argv command once, then loops on stdin (`waitForCommands`) unless `AVOID_LOOP_RUN=true`. It uses `program.exitOverride()` so Commander errors don't kill the loop. +`main()` in `index.ts` calls `createCLI()` (in `cli.ts`) to build the Commander `program`, records supported command names/aliases, prints the REPL banner, runs the initial argv command once, then loops on stdin (`runLoop`) unless `AVOID_LOOP_RUN=true`. It uses `program.exitOverride()` so Commander errors don't kill the loop. -`createCLI()` does three things: (1) validates the required env vars — **unless** the invocation is a pure help/version one (`--help`/`-h`/`--version`/`-V`/`h`/`help`), which is detected from `process.argv` and skips both validation and P2P so those work with no config, (2) if `NODE_URL` is a P2P URI, sets up libp2p (see "Transport"), (3) registers every command. Each command's `.action(...)`: +`createCLI()` does four things: (1) validates `PRIVATE_KEY`/`MNEMONIC` and `RPC` — **unless** the invocation is a pure help/version one (`--help`/`-h`/`--version`/`-V`/`h`/`help`), which is detected from `process.argv` and skips both validation and P2P so those work with no config, (2) starts libp2p and health-checks `NODE_URL` if set (see "Transport" and "Node selection"), (3) registers the `preAction` gate that refuses non-`NODE_FREE_COMMANDS` while no node is set, (4) registers every command. Each command's `.action(...)`: 1. merges positional + option values, 2. calls the local `initializeSigner()` — builds a `JsonRpcProvider(RPC)`, a `Wallet` from `PRIVATE_KEY` (or `Wallet.fromPhrase(MNEMONIC)`), and reads `chainId` from the network, @@ -157,9 +159,20 @@ The `startCompute` **action in `cli.ts`** orchestrates a two-phase flow (not a s - **Auth tokens**: `generateAuthToken` / `invalidateAuthToken` via `ProviderInstance`. - **downloadNodeLogs** (admin): time-range or `--last N` hours; writes `/logs.json`. -### Transport: HTTP vs P2P +### Transport: HTTP vs P2P, and node selection (`src/nodeConnection.ts`) -If `NODE_URL` passes `isP2pUri()`, `createCLI()` boots a libp2p node via `ProviderInstance.setupP2P`, seeding bootstrap peers = local peer (derived from the peer id or full multiaddr) + `BOOTSTRAP_PEERS` + four hard-coded Ocean bootstrap nodes, and then **waits up to 20 s for the specific target peer** (from `NODE_URL`) to connect before proceeding, because signed commands fail if only bootstrap peers are connected. CI exercises both transports (matrix `[http, p2p]`). +All node lifecycle logic lives in `nodeConnection.ts`; `cli.ts` only calls into it. + +**libp2p is transport, not a connection to one node.** Every ocean.js P2P call takes a `nodeUri` and dials that peer on demand (direct dial for a full multiaddr, DHT lookup for a bare peer id), so one libp2p node serves any number of Ocean nodes and switching between them never restarts or stops it. + +- `startP2P(initialNodeUrl?)` — called **once at startup for every invocation** (not lazily, and not only for P2P `NODE_URL`s), because bootstrap dials + DHT warm-up take seconds and should overlap with the user reading the prompt. It is **deliberately not awaited**; the stored promise swallows its own rejection (an unhandled rejection on a fire-and-forget promise would kill the process) and remembers the failure for `ensureP2PReady()`. No-op when `DISABLE_P2P=true` or when `ProviderInstance.getLibp2pNode()` is already non-null. Bootstrap peers = the initial node if it is a P2P URI (bare peer ids get the `/ip4/127.0.0.1/tcp/9001/ws/p2p/` localhost convention) + `BOOTSTRAP_PEERS` + four hard-coded Ocean bootstrap nodes (passing `bootstrapPeers` **replaces** the lib's defaults, so they must be listed explicitly). +- `ensureP2PReady()` — awaited by every P2P-bound path; throws a clear reason instead of hanging when P2P is unavailable. +- `validateNode(url)` — non-destructive health check via `ProviderInstance.getNodeStatus` under an `AbortSignal.timeout` (10 s HTTP, 30 s P2P since a bare peer id may need a DHT lookup). Over P2P the on-demand dial *is* the reachability check, which is what the old 20 s wait-for-target-peer polling loop did — that loop is gone. +- `getCurrentNodeUrl()` / `setCurrentNodeUrl()` / `hasNode()` — `process.env.NODE_URL` stays the **single source of truth**, so switching node is just mutating it: `Commands`' constructor and `getMetadataURI()` re-read it per use. + +`setNode` validates first and only then mutates the env var, so a failed switch leaves everything untouched — there is nothing to roll back. The switch itself never touches the RPC/signer (node selection is independent of them and must work when the RPC is slow); `setNode` calls `initializeSigner()` only *after* committing, under a 5 s `Promise.race` timeout, purely to warn when the node does not serve the RPC's chain. CI exercises both transports (matrix `[http, p2p]`). + +**libp2p keeps the process alive.** A started libp2p node holds the event loop open, and even a clean `stop()` leaves a `MessagePort` behind (confirmed with `process.getActiveResourcesInfo()`). So `index.ts` ends with `if (await stopP2P()) { await flushOutput(); process.exit(...) }` — the flush matters because a piped stdout can still hold buffered output that `process.exit()` would discard. For the same reason the eager `startP2P` is **skipped in one-shot mode** (`AVOID_LOOP_RUN=true`): a one-shot run has no later command to warm up for, and would only pay startup + shutdown cost. One-shot runs that target a P2P node still get libp2p on demand via `validateNode` → `ensureP2PReady`. ### Interactive publish wizard (currently unwired) @@ -190,3 +203,5 @@ CI (`.github/workflows/ci.yml`) has three jobs: `build`, `lint`, and `test_syste - The 1-indexed vs 0-indexed args-array split between `Commands` methods is easy to get wrong when adding/renaming commands. - Running the CLI without `AVOID_LOOP_RUN=true` drops into a stdin REPL after the first command — surprising in scripts. - `fixAndParseProviderFees` is a regex JSON patcher for the initialize→start round trip; prefer fixing the data shape over extending the regex. +- A new command is refused when no node is set unless its canonical name is added to `NODE_FREE_COMMANDS` in `cli.ts`. The gate is a single root-level `preAction` hook keyed on `actionCommand.name()` (canonical, so aliases resolve for free) and throws a **plain `Error`, not a `CommanderError`** — that is what makes `index.ts` report it in red and keep the REPL alive, while one-shot mode exits 1. +- `supportedCommands` in `index.ts` uses `command.aliases()` (plural). The old `alias()` returned only the first alias, silently making extra aliases unreachable in the REPL. diff --git a/README.md b/README.md index c2eb4f4..4248f0e 100644 --- a/README.md +++ b/README.md @@ -85,10 +85,26 @@ export MNEMONIC="XXXX" export RPC='XXXX' ``` -- Mandatory, Set an Ocean Node URL. Ocean Nodes infrastructure is responsible for handling assets indexing and metadata caching. It replaced old Provider and Aquarius standalone apps. +- Optional (but recommended), set an Ocean Node URL. Ocean Nodes infrastructure is responsible for handling assets indexing and metadata caching. It replaced old Provider and Aquarius standalone apps. ``` export NODE_URL='XXXX' +``` + + `NODE_URL` is the **initial** node only. If it is not set the CLI still starts, but **only `setNode`, `getNode` and `help` are available** — every other command is refused with `No Ocean Node set` until you pick a node: + +```bash +npm run cli # starts with no node +# > setNode http://127.0.0.1:8001 +# > getComputeEnvironments # now works +``` + + You can switch node at any time with [`setNode`](#setnode) without restarting the CLI. See [`getNode`](#getnode) to check which node is active. + +- Optional, set DISABLE_P2P to `'true'` to skip starting the libp2p transport. By default the CLI starts libp2p at startup (in the background, so it does not delay the prompt) even when `NODE_URL` is an HTTP URL, so that a later switch to a P2P node does not have to wait for bootstrap peers and DHT warm-up. Set this when you only ever use HTTP nodes and do not want the CLI dialing the public Ocean bootstrap nodes. + +```bash +export DISABLE_P2P='true' ``` - Optional, set ADDRESS_FILE if you want to use a custom set of smart contract address @@ -172,6 +188,32 @@ npm run cli [options] #### Examples +**Choosing the Ocean Node:** + + + +- **Switch node (works inside the interactive loop, no restart needed):** + `npm run cli setNode http://127.0.0.1:8001` + Also accepts a peer id or a full multiaddr, and `--node`: + `npm run cli setNode --node /dns4/node.example/tcp/9001/ws/p2p/16Uiu2HAm...` + Alias: `useNode`. + + The node is health-checked before the switch: if it cannot be reached, the current node is kept and nothing changes. + + + +- **Show the node in use:** + `npm run cli getNode` (alias `currentNode`) — prints the active node plus its version and the chain(s) it serves. + +Notes when switching nodes: + +- **Compute jobs live on the node that started them.** After a switch, `getJobStatus` / `downloadJobResults` query the *new* node — switch back to look up older jobs. +- **For a node on your own machine, prefer the full multiaddr** (`/ip4/127.0.0.1/tcp/9001/ws/p2p/`) over a bare peer id: a bare id has to be found via DHT, which may not advertise localhost addresses. +- **In one-shot mode** (`AVOID_LOOP_RUN='true'`) `setNode` only validates the node and prints the result — the switch dies with the process. Use `NODE_URL` for one-shot runs. +- `chainId` still comes from `RPC`, never from the node. `setNode` warns when the node does not serve the chain your RPC is on. + +--- + **Get DDO:** - **Positional:** @@ -613,6 +655,12 @@ Notes: #### Available Named Options Per Command +- **setNode** (alias `useNode`)**:** + `` (Positional. HTTP(S) URL, peer id or full multiaddr) + `-n, --node ` (Same as the positional) + +- **getNode** (alias `currentNode`)**:** no arguments + - **getDDO:** `-d, --did ` diff --git a/src/cli.ts b/src/cli.ts index 799c70c..faa1271 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,12 +8,24 @@ import { stdin as input, stdout } from "node:process"; import { createInterface } from "readline/promises"; import { unitsToAmount, - ProviderInstance, isP2pUri, ServiceStatusNumber, ServiceRestartParams, } from "@oceanprotocol/lib"; import { toBoolean } from "./helpers.js"; +import { + getCurrentNodeUrl, + hasNode, + nodeChainIds, + setCurrentNodeUrl, + startP2P, + validateNode, +} from "./nodeConnection.js"; + +// Commands usable before any Ocean Node is selected. Everything else is refused by the +// preAction gate below until `setNode` succeeds. Canonical names only — aliases +// (useNode, currentNode, h) resolve to these. +const NODE_FREE_COMMANDS = new Set(["setNode", "getNode", "help"]); // Single source of truth for the CLI version: read it from package.json instead // of hardcoding, so it can't drift. `../package.json` resolves from both src/ @@ -87,90 +99,54 @@ export async function createCLI() { console.error(chalk.red("Have you forgot to set env RPC?")); process.exit(1); } + } - if (!process.env.NODE_URL) { - console.error(chalk.red("Have you forgot to set env NODE_URL?")); + // NODE_URL is optional: without it the CLI still starts, but only the commands in + // NODE_FREE_COMMANDS are accepted until `setNode` picks a node (see the gate below). + if (!isHelpOrVersion) { + if (process.env.DISABLE_P2P === "true" && isP2pUri(getCurrentNodeUrl())) { + console.error( + chalk.red( + "NODE_URL is a P2P URI but DISABLE_P2P=true — no command could reach it." + ) + ); process.exit(1); } - } - if (!isHelpOrVersion && process.env.NODE_URL && isP2pUri(process.env.NODE_URL)) { - const extra = process.env.BOOTSTRAP_PEERS?.split(",").filter(Boolean) || []; - - // Default Ocean bootstrap nodes (must be included explicitly since passing - // bootstrapPeers to setupP2P replaces the built-in defaults) - const oceanDefaults = [ - "/dns4/bootstrap1.oncompute.ai/tcp/9001/ws/p2p/16Uiu2HAmLhRDqfufZiQnxvQs2XHhd6hwkLSPfjAQg1gH8wgRixiP", - "/dns4/bootstrap2.oncompute.ai/tcp/9001/ws/p2p/16Uiu2HAmHwzeVw7RpGopjZe6qNBJbzDDBdqtrSk7Gcx1emYsfgL4", - "/dns4/bootstrap3.oncompute.ai/tcp/9001/ws/p2p/16Uiu2HAmBKSeEP3v4tYEPsZsZv9VELinyMCsrVTJW9BvQeFXx28U", - "/dns4/bootstrap4.oncompute.ai/tcp/9001/ws/p2p/16Uiu2HAmSTVTArioKm2wVcyeASHYEsnx2ZNq467Z4GMDU4ErEPom", - ]; - - const nodeUrl = process.env.NODE_URL; - const isFullMultiaddr = - nodeUrl.startsWith("/") && nodeUrl.includes("/p2p/"); - const localPeer = isFullMultiaddr - ? [nodeUrl] - : [`/ip4/127.0.0.1/tcp/9001/ws/p2p/${nodeUrl}`]; - const bootstrapPeers = [...localPeer, ...extra, ...oceanDefaults]; - console.log(chalk.cyan("P2P mode detected. Initializing libp2p...")); - console.log(chalk.cyan(`Bootstrap peers: ${bootstrapPeers.length}`)); - - for (const peer of localPeer) { - console.log(chalk.cyan(` Local: ${peer}`)); + // Eager, non-blocking: libp2p warms up (bootstrap dials + DHT) while the user reads + // the prompt, so a later switch to a P2P node doesn't pay that cost interactively. + // + // Only in loop mode. A one-shot run has no "later command" to warm up for, and + // starting a libp2p node it never uses would just delay its exit — the process + // cannot end until the node is up and stopped again. One-shot runs that *do* target + // a P2P node still get it: validateNode() -> ensureP2PReady() starts it on demand. + if (process.env.AVOID_LOOP_RUN !== "true") { + startP2P(process.env.NODE_URL); } - // Allow localhost connections / local nodes - await ProviderInstance.setupP2P({ - bootstrapPeers, - libp2p: { - connectionGater: { - denyDialMultiaddr: () => false, - }, - }, - } as any); - console.log( - chalk.cyan("libp2p node started. Waiting for peer connections...") - ); - // Wait for the TARGET peer (the one in NODE_URL) to be connected, - // not just any bootstrap peer — otherwise signed commands fail with - // "Cannot reach peer ...". - const targetPeerId = isFullMultiaddr - ? nodeUrl.split("/p2p/").pop()! - : nodeUrl; - const maxWait = 20_000; - const interval = 500; - let waited = 0; - const libp2p = (ProviderInstance as any).p2pProvider?.libp2pNode; - const isTargetConnected = () => - (libp2p?.getPeers() ?? []).some( - (p: { toString(): string }) => p.toString() === targetPeerId - ); - while (waited < maxWait) { - if (isTargetConnected()) { - const total = libp2p?.getConnections()?.length ?? 0; + if (hasNode()) { + // Confirms the startup node is reachable before the user types anything. For a + // P2P node the status call dials the peer, which is what the old wait-for-peer + // polling loop used to do. + const status = await validateNode(getCurrentNodeUrl()); + if (status) { console.log( chalk.green( - `Connected to target peer ${targetPeerId.slice(0, 12)}… in ${waited}ms (total peers: ${total})` + `Using node ${getCurrentNodeUrl()} (version ${status.version})` ) ); - break; - } - await new Promise((r) => setTimeout(r, interval)); - waited += interval; - if (waited % 3000 === 0) { - const total = libp2p?.getConnections()?.length ?? 0; - console.log( - chalk.yellow( - ` Waiting for target peer ${targetPeerId.slice(0, 12)}… (${waited / 1000}s, ${total} other peer(s))` + } else { + console.error( + chalk.red( + `Node ${getCurrentNodeUrl()} is not reachable. Commands may fail.` ) ); } - } - if (!isTargetConnected()) { - console.error( - chalk.red( - `Target peer ${targetPeerId} not reachable after ${maxWait / 1000}s. Commands will fail.` + } else { + console.log( + chalk.yellow( + "No Ocean Node configured. Run `setNode ` to choose one — " + + `only ${[...NODE_FREE_COMMANDS].join(", ")} are available until then.` ) ); } @@ -184,6 +160,23 @@ export async function createCLI() { .version(pkg.version) .helpOption("-h, --help", "Display help for command"); + // Every command except the node-free ones needs an Ocean Node. A single preAction + // hook on the root program runs before *any* subcommand action, so this covers both + // the REPL loop and one-shot mode without touching 40 action bodies. + // + // A plain Error (not a CommanderError) is thrown on purpose: in loop mode index.ts + // reports it in red and keeps the REPL alive; in one-shot mode main() reports it and + // exits 1, so scripts see a non-zero status. + program.hook("preAction", (_thisCommand, actionCommand) => { + // actionCommand.name() is the canonical name, so aliases resolve for free. + if (!hasNode() && !NODE_FREE_COMMANDS.has(actionCommand.name())) { + throw new Error( + `No Ocean Node set. Run \`setNode \` first ` + + `(available now: ${[...NODE_FREE_COMMANDS].join(", ")}).` + ); + } + }); + // Custom help command to support legacy "h" invocation. // Note: We use console.log(program.helpInformation()) to print the full help output. program @@ -194,6 +187,96 @@ export async function createCLI() { console.log(program.helpInformation()); }); + // setNode command. The switch itself never touches the RPC/signer: choosing a node is + // independent of them and must work even when the RPC is slow or wrong. The RPC is + // consulted only afterwards, under a timeout, to warn about a chain mismatch. + program + .command("setNode") + .alias("useNode") + .description( + "Sets / switches the Ocean Node used by subsequent commands, without restarting" + ) + .argument("", "HTTP(S) URL, peer id or full multiaddr of the node") + .option("-n, --node ", "Ocean Node to use") + .action(async (nodeUrl, options) => { + const target = options.node || nodeUrl; + const previous = getCurrentNodeUrl(); + if (target === previous) { + console.log(chalk.green(`Node ${target} is already the active one.`)); + return; + } + + const status = await validateNode(target); + if (!status) { + console.error( + chalk.red( + previous + ? `Cannot reach ${target}. Keeping current node: ${previous}` + : `Cannot reach ${target}. Still no node set.` + ) + ); + return; + } + + setCurrentNodeUrl(target); + console.log( + chalk.green(`Using node: ${target} (version ${status.version})`) + ); + + // chainId comes from RPC, not from the node, so the two can disagree. The switch + // is already committed at this point, so this is a courtesy warning only — bound + // it so an unresponsive RPC cannot leave the command hanging. + const chainIds = nodeChainIds(status); + if (chainIds.length > 0) { + let timer: NodeJS.Timeout | undefined; + try { + const { chainId } = await Promise.race([ + initializeSigner(), + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error("RPC timeout")), 5000); + }), + ]); + if (!chainIds.includes(String(chainId))) { + console.log( + chalk.yellow( + `Warning: this node serves chain(s) ${chainIds.join(", ")} but RPC is on chain ${chainId}. Commands may fail.` + ) + ); + } + } catch { + // A bad/slow RPC must not make a successful node switch look like a failure. + } finally { + // Leaving the loser pending would keep the event loop alive for 5s. + clearTimeout(timer); + } + } + }); + + // getNode command + program + .command("getNode") + .alias("currentNode") + .description("Shows the Ocean Node currently in use") + .action(async () => { + const current = getCurrentNodeUrl(); + if (!current) { + console.log( + chalk.yellow("No Ocean Node set. Run `setNode ` to pick one.") + ); + return; + } + console.log(`Current Ocean Node: ${current}`); + // Best effort: a node that is down must not fail the command. + const status = await validateNode(current); + if (status) { + console.log( + `Version: ${status.version}, chain(s): ${nodeChainIds(status).join(", ") || "none"}` + ); + } else { + console.log(chalk.yellow("Node is not reachable right now.")); + } + }); + // getDDO command program .command("getDDO") diff --git a/src/index.ts b/src/index.ts index c26bf75..32dc421 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import chalk from "chalk"; import { stdin as input, stdout as output } from "node:process"; import { createInterface } from "readline/promises"; import { createCLI } from './cli.js'; +import { stopP2P } from './nodeConnection.js'; let program: Command const supportedCommands: string[] = [] @@ -173,13 +174,31 @@ async function runLoop(): Promise { } } +/** + * Wait until everything written to stdout/stderr has actually been handed over, so a + * forced process.exit() cannot truncate it. Writing an empty chunk queues the callback + * behind any pending writes on the stream. + */ +async function flushOutput(): Promise { + await Promise.all( + [process.stdout, process.stderr].map( + (stream) => + new Promise((resolve) => { + if (stream.writableLength === 0) return resolve() + stream.write("", () => resolve()) + }) + ) + ) +} + async function main(): Promise { try { program = await createCLI(); for (const command of program.commands) { supportedCommands.push(command.name()) - const alias = command.alias() - if (alias) supportedCommands.push(alias) + // aliases() (plural): alias() would only ever return the first one. + const aliases = command.aliases() + supportedCommands.push(...aliases) } // Handle help/version flags without initializing a signer, and exit so @@ -226,6 +245,17 @@ async function main(): Promise { } catch (error) { console.error(chalk.red(`Program Error: ${error.message}`)); process.exit(1); + } finally { + // Once libp2p has started the process can no longer end on its own: stopping + // it cleanly still leaves a MessagePort holding the event loop open. So stop + // it and, if it had been running, exit explicitly — after draining stdout, + // since a piped stdout (tests, scripts) can still hold buffered output that + // process.exit() would discard. Covers every path out of the try above; the + // process.exit(1) in the catch terminates immediately and needs no cleanup. + if (await stopP2P()) { + await flushOutput() + process.exit(process.exitCode ?? 0) + } } } diff --git a/src/nodeConnection.ts b/src/nodeConnection.ts new file mode 100644 index 0000000..fcf93ed --- /dev/null +++ b/src/nodeConnection.ts @@ -0,0 +1,224 @@ +import chalk from "chalk"; +import { ProviderInstance, isP2pUri, NodeStatus } from "@oceanprotocol/lib"; + +/** + * Ocean Node selection and libp2p transport lifecycle. + * + * The active node lives in `process.env.NODE_URL`, which stays the single source of + * truth: `Commands` (its constructor) and `getMetadataURI()` re-read that variable on + * every use, so mutating it switches every subsequent command with no other wiring. + * + * libp2p is *transport*, not a connection to one node: every P2P call in ocean.js takes + * a `nodeUri` and dials that peer on demand (direct dial for a full multiaddr, DHT + * lookup for a bare peer id). So one libp2p node serves any number of Ocean nodes and + * switching between them never restarts or stops it. + */ + +// Default Ocean bootstrap nodes (must be included explicitly since passing +// bootstrapPeers to setupP2P replaces the built-in defaults) +const OCEAN_BOOTSTRAP_PEERS = [ + "/dns4/bootstrap1.oncompute.ai/tcp/9001/ws/p2p/16Uiu2HAmLhRDqfufZiQnxvQs2XHhd6hwkLSPfjAQg1gH8wgRixiP", + "/dns4/bootstrap2.oncompute.ai/tcp/9001/ws/p2p/16Uiu2HAmHwzeVw7RpGopjZe6qNBJbzDDBdqtrSk7Gcx1emYsfgL4", + "/dns4/bootstrap3.oncompute.ai/tcp/9001/ws/p2p/16Uiu2HAmBKSeEP3v4tYEPsZsZv9VELinyMCsrVTJW9BvQeFXx28U", + "/dns4/bootstrap4.oncompute.ai/tcp/9001/ws/p2p/16Uiu2HAmSTVTArioKm2wVcyeASHYEsnx2ZNq467Z4GMDU4ErEPom", +]; + +// A plain HTTP request to a node is quick; a P2P dial may need a DHT lookup first +// (ocean.js defaults dhtLookupTimeout to 60s), so it gets a longer leash. +const HTTP_STATUS_TIMEOUT_MS = 10_000; +const P2P_STATUS_TIMEOUT_MS = 30_000; + +let p2pReady: Promise | null = null; +let p2pFailure: Error | null = null; + +function p2pDisabled(): boolean { + return process.env.DISABLE_P2P === "true"; +} + +/** True when the given URI is a full multiaddr (as opposed to a bare peer id). */ +function isFullMultiaddr(nodeUrl: string): boolean { + return nodeUrl.startsWith("/") && nodeUrl.includes("/p2p/"); +} + +/** + * Bootstrap peers for the libp2p node: the initial node (so a local node given as a + * bare peer id is dialable via the localhost convention), any BOOTSTRAP_PEERS, and the + * Ocean defaults. + */ +function buildBootstrapPeers(initialNodeUrl?: string): string[] { + const extra = process.env.BOOTSTRAP_PEERS?.split(",").filter(Boolean) || []; + const localPeer = + initialNodeUrl && isP2pUri(initialNodeUrl) + ? isFullMultiaddr(initialNodeUrl) + ? [initialNodeUrl] + : [`/ip4/127.0.0.1/tcp/9001/ws/p2p/${initialNodeUrl}`] + : []; + return [...localPeer, ...extra, ...OCEAN_BOOTSTRAP_PEERS]; +} + +/** + * Start the shared libp2p node. Called once at startup and deliberately *not* awaited: + * connecting to bootstrap peers and warming the DHT takes seconds, and that should + * happen while the user reads the prompt rather than on their first P2P command. Any + * P2P-bound path awaits `ensureP2PReady()` before dialing. + * + * No-op when DISABLE_P2P=true or when libp2p is already up. + */ +export function startP2P(initialNodeUrl?: string): void { + if (p2pDisabled() || p2pReady) return; + if (ProviderInstance.getLibp2pNode()) { + p2pReady = Promise.resolve(); + return; + } + + const bootstrapPeers = buildBootstrapPeers(initialNodeUrl); + console.log( + chalk.cyan(`Starting libp2p (${bootstrapPeers.length} bootstrap peers)...`) + ); + + // The promise is stored, not awaited, so it MUST swallow its own rejection here: + // an unhandled rejection on a fire-and-forget promise would take the process down. + // The failure is remembered and re-surfaced to whoever awaits ensureP2PReady(). + p2pReady = ProviderInstance.setupP2P({ + bootstrapPeers, + libp2p: { + connectionGater: { + // Allow localhost connections / local nodes + denyDialMultiaddr: () => false, + }, + }, + } as any).then( + () => { + console.log(chalk.cyan("libp2p node started.")); + }, + (error) => { + p2pFailure = error instanceof Error ? error : new Error(String(error)); + console.error( + chalk.yellow(`libp2p failed to start: ${p2pFailure.message}`) + ); + } + ); +} + +/** + * Await the shared libp2p node before making a P2P call. Throws with a clear reason + * when P2P is unavailable, so callers can report it instead of timing out. + */ +export async function ensureP2PReady(): Promise { + if (p2pDisabled()) { + throw new Error("P2P transport is disabled (DISABLE_P2P=true)"); + } + // Pass the active node so a lazy start (one-shot run against a P2P node) still gets + // the localhost multiaddr for a bare peer id in its bootstrap list — without it a + // node on this machine could only be found through the DHT. + if (!p2pReady) startP2P(getCurrentNodeUrl()); + await p2pReady; + if (p2pFailure) { + throw new Error(`libp2p is not running: ${p2pFailure.message}`); + } +} + +/** + * Stop the shared libp2p node when the CLI is finished (see index.ts). + * + * Returns true when libp2p had been started, because stopping it is *not* enough to + * let the process end: it leaves a `MessagePort` behind that keeps the event loop + * alive even after a clean `stop()` (verified with `process.getActiveResourcesInfo()` + * — `stop()` itself completes in ~2ms and reports status "stopped"). The caller must + * therefore exit explicitly when this returns true. + */ +export async function stopP2P(waitForStartMs = 15_000): Promise { + const pending = p2pReady; + p2pReady = null; + if (!pending && !ProviderInstance.getLibp2pNode()) return false; + + if (pending) { + // A start still in flight has no node to stop yet, and stopping "nothing" would + // leave it to come up *after* cleanup and hold the process open forever. So wait + // for it — but boundedly, since a start dialing unreachable bootstrap peers must + // not stall exit. (startP2P's promise handles its own rejection, so this is safe + // to await.) + let timer: NodeJS.Timeout | undefined; + await Promise.race([ + pending, + new Promise((resolve) => { + timer = setTimeout(resolve, waitForStartMs); + }), + ]); + clearTimeout(timer); + // Belt and braces for the timed-out case: stop whatever the start eventually + // produces, so a slow start delays exit instead of preventing it. + pending + .then(() => ProviderInstance.getLibp2pNode()?.stop()) + .catch(() => undefined); + } + + const node = ProviderInstance.getLibp2pNode(); + if (node) { + try { + await node.stop(); + } catch (error) { + // Shutting down is best effort — never turn it into a command failure. + console.error( + chalk.yellow(`libp2p did not stop cleanly: ${error?.message ?? error}`) + ); + } + } + return true; +} + +/** The active Ocean Node, or "" when none has been set yet. */ +export function getCurrentNodeUrl(): string { + return process.env.NODE_URL || ""; +} + +/** Make `nodeUrl` the active Ocean Node for every subsequent command. */ +export function setCurrentNodeUrl(nodeUrl: string): void { + process.env.NODE_URL = nodeUrl; +} + +/** Whether a node is currently selected (gates most commands, see cli.ts). */ +export function hasNode(): boolean { + return getCurrentNodeUrl().length > 0; +} + +/** + * Health-check a candidate node without touching any existing state. Over HTTP this is + * a plain status request; over P2P the on-demand dial *is* the reachability check. + * Returns the node status (for display), or null when the node cannot be reached. + */ +export async function validateNode( + nodeUrl: string +): Promise { + try { + let timeout = HTTP_STATUS_TIMEOUT_MS; + if (isP2pUri(nodeUrl)) { + await ensureP2PReady(); + timeout = P2P_STATUS_TIMEOUT_MS; + if (!isFullMultiaddr(nodeUrl)) { + console.log(chalk.cyan(`Looking up peer ${nodeUrl.slice(0, 12)}...`)); + } + } + const status = await ProviderInstance.getNodeStatus( + nodeUrl, + AbortSignal.timeout(timeout) + ); + return status || null; + } catch (error) { + console.error( + chalk.yellow( + `Could not get status of ${nodeUrl}: ${error?.message ?? error}` + ) + ); + return null; + } +} + +/** Chain ids the node serves, as reported by its status (provider + indexer). */ +export function nodeChainIds(status: NodeStatus): string[] { + const ids = [ + ...(status.provider || []).map((p) => String(p.chainId)), + ...(status.indexer || []).map((i) => String(i.chainId)), + ]; + return [...new Set(ids)]; +} diff --git a/test/replMenu.test.ts b/test/replMenu.test.ts index decfc75..28dcf57 100644 --- a/test/replMenu.test.ts +++ b/test/replMenu.test.ts @@ -1,53 +1,5 @@ import { expect } from "chai"; -import { spawn } from "child_process"; -import path from "path"; -import { dirname } from "path"; -import { fileURLToPath } from "url"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const projectRoot = path.resolve(__dirname, ".."); - -// Recurring prompt string emitted by the REPL (keep in sync with src/index.ts). -const PROMPT = "Enter command ('exit' | 'quit' | ESC or CTRL-C to terminate):\n"; - -/** - * Drive the interactive REPL (menu mode) with piped stdin. - * - * These tests are infra-free: PRIVATE_KEY/RPC/NODE_URL point at an unreachable - * port, so a command that actually parses and runs surfaces a "Command error" - * (connection refused) while a command that is dropped or rejected at parse time - * does not. AVOID_LOOP_RUN is left unset so the process enters the REPL loop. - */ -function runRepl( - inputLines: string[], - extraArgs: string[] = [] -): Promise<{ output: string; code: number | null }> { - return new Promise((resolve, reject) => { - const env = { ...process.env }; - delete env.AVOID_LOOP_RUN; - env.PRIVATE_KEY = - "0x1d751ded5a32226054cd2e71261039b65afb9ee1c746d055dd699b1150a5befc"; - env.RPC = "http://127.0.0.1:1"; - env.NODE_URL = "http://127.0.0.1:1"; - - const child = spawn("npx", ["tsx", "src/index.ts", ...extraArgs], { - cwd: projectRoot, - env, - }); - - let output = ""; - child.stdout.on("data", (d) => (output += d.toString())); - child.stderr.on("data", (d) => (output += d.toString())); - child.on("error", reject); - child.on("close", (code) => resolve({ output, code })); - - for (const line of inputLines) { - child.stdin.write(line + "\n"); - } - child.stdin.end(); - }); -} +import { REPL_PROMPT as PROMPT, runRepl } from "./util.js"; describe("Ocean CLI interactive menu (REPL)", function () { this.timeout(60000); diff --git a/test/setNode.test.ts b/test/setNode.test.ts new file mode 100644 index 0000000..91bb267 --- /dev/null +++ b/test/setNode.test.ts @@ -0,0 +1,135 @@ +import { expect } from "chai"; +import { runRepl } from "./util.js"; + +// The Ocean Node exposed by Barge over HTTP. Hardcoded (as in http.test.ts) so these +// tests behave identically on both CI transport legs: the p2p leg only changes the +// NODE_URL env, the node's HTTP interface is up either way. +const LIVE_NODE = "http://127.0.0.1:8001"; + +describe("Ocean CLI node selection", function () { + this.timeout(120000); + + describe("with no NODE_URL set (no infra needed)", function () { + it("starts anyway and says which commands are available", async function () { + const { output, code } = await runRepl(["exit"], { + env: { NODE_URL: undefined }, + }); + expect(output).to.contain("No Ocean Node configured"); + expect(output).to.contain("setNode"); + expect(code).to.equal(0); + }); + + it("refuses a command that needs a node, without running it", async function () { + const { output } = await runRepl(["getComputeEnvironments", "exit"], { + env: { NODE_URL: undefined }, + }); + expect(output).to.contain("No Ocean Node set"); + // The Commands constructor logs this line, so its absence proves the gate + // fired before the action body ran (rather than the action failing later). + expect(output).to.not.contain("Using Ocean Node URL"); + expect(output).to.not.contain("Invalid option"); + }); + + it("still allows help and getNode", async function () { + const { output } = await runRepl(["help", "getNode", "exit"], { + env: { NODE_URL: undefined }, + }); + expect(output).to.contain("Usage: ocean-cli"); + // Both new commands must be discoverable from the menu. + expect(output).to.contain("setNode"); + expect(output).to.contain("getNode"); + expect(output).to.contain("No Ocean Node set"); + }); + + it("keeps the CLI node-less when setNode cannot reach the node", async function () { + const { output } = await runRepl( + ["setNode http://127.0.0.1:1", "getComputeEnvironments", "exit"], + { env: { NODE_URL: undefined } } + ); + expect(output).to.contain("Still no node set"); + // The gate is still closed: no half-switch. + expect(output).to.contain("No Ocean Node set"); + expect(output).to.not.contain("Using Ocean Node URL"); + }); + + it("accepts the useNode alias", async function () { + const { output } = await runRepl(["useNode http://127.0.0.1:1", "exit"], { + env: { NODE_URL: undefined }, + }); + expect(output).to.not.contain("Invalid option"); + expect(output).to.contain("Cannot reach"); + }); + }); + + describe("with an unreachable NODE_URL set (no infra needed)", function () { + it("does not gate commands — they run and fail at the network", async function () { + const { output } = await runRepl(["getComputeEnvironments", "exit"]); + expect(output).to.not.contain("No Ocean Node set"); + expect(output).to.contain("Command error"); + }); + }); + + describe("libp2p lifecycle (no infra needed)", function () { + it("still exits when libp2p has been started", async function () { + // Regression guard: libp2p is started eagerly in loop mode, and a started + // libp2p node keeps the event loop alive — even after a clean stop() it leaves + // a MessagePort behind. Without the explicit shutdown+exit in index.ts the CLI + // hangs forever here instead of returning to the shell. + const { code } = await runRepl(["exit"], { + env: { DISABLE_P2P: undefined }, + }); + expect(code).to.equal(0); + }); + + it("does not start libp2p for a one-shot HTTP run", async function () { + // One-shot has no later command to warm up for, so paying the libp2p + // startup/shutdown cost would only slow every scripted invocation down. + const { output } = await runRepl([], { + extraArgs: ["getNode"], + env: { DISABLE_P2P: undefined, AVOID_LOOP_RUN: "true" }, + }); + expect(output).to.not.contain("Starting libp2p"); + }); + }); + + describe("against a running node (requires Barge)", function () { + it("reports the startup node and its version", async function () { + const { output } = await runRepl(["getNode", "exit"], { + env: { NODE_URL: LIVE_NODE }, + }); + expect(output).to.contain(`Current Ocean Node: ${LIVE_NODE}`); + expect(output).to.contain("Version:"); + }); + + it("switches from no node to a live node, opening the gate", async function () { + const { output } = await runRepl( + [`setNode ${LIVE_NODE}`, "getNode", "getComputeEnvironments", "exit"], + { env: { NODE_URL: undefined } } + ); + expect(output).to.contain(`Using node: ${LIVE_NODE}`); + expect(output).to.contain(`Current Ocean Node: ${LIVE_NODE}`); + // The gate opened: the action ran (its constructor logged the node) and the + // command was not refused. + expect(output).to.contain("Using Ocean Node URL"); + expect(output).to.not.contain("No Ocean Node set"); + }); + + it("recognises a switch to the node already in use", async function () { + const { output } = await runRepl([`setNode ${LIVE_NODE}`, "exit"], { + env: { NODE_URL: LIVE_NODE }, + }); + expect(output).to.contain("already the active one"); + }); + + it("keeps the current node when the new one is unreachable", async function () { + const { output } = await runRepl( + ["setNode http://127.0.0.1:9999", "getNode", "getComputeEnvironments", "exit"], + { env: { NODE_URL: LIVE_NODE } } + ); + expect(output).to.contain(`Keeping current node: ${LIVE_NODE}`); + expect(output).to.contain(`Current Ocean Node: ${LIVE_NODE}`); + // A real command still works against the original node. + expect(output).to.contain(`Using Ocean Node URL : ${LIVE_NODE}`); + }); + }); +}); diff --git a/test/util.ts b/test/util.ts index d830ecb..d61350d 100644 --- a/test/util.ts +++ b/test/util.ts @@ -1,4 +1,4 @@ -import { exec } from "child_process"; +import { exec, spawn } from "child_process"; import path from "path"; import util from "util"; @@ -41,4 +41,65 @@ export const runCommandAs = async ( console.error(`[ERROR]:\n${error.stderr || error.message}`); throw error; } +}; + +/** Recurring prompt string emitted by the REPL (keep in sync with src/index.ts). */ +export const REPL_PROMPT = + "Enter command ('exit' | 'quit' | ESC or CTRL-C to terminate):\n"; + +export interface RunReplOptions { + /** Extra argv for the initial command run before the loop starts. */ + extraArgs?: string[]; + /** + * Env overrides applied on top of the defaults. A key set to undefined is + * deleted, which is how a test starts the CLI with no NODE_URL at all. + */ + env?: Record; +} + +/** + * Drive the interactive REPL (menu mode) with piped stdin. + * + * The defaults are infra-free: PRIVATE_KEY/RPC/NODE_URL point at an unreachable + * port, so a command that actually parses and runs surfaces a "Command error" + * (connection refused) while a command that is dropped, rejected at parse time or + * refused by the node gate does not. AVOID_LOOP_RUN is unset so the process enters + * the REPL loop. + */ +export const runRepl = ( + inputLines: string[], + options: RunReplOptions = {} +): Promise<{ output: string; code: number | null }> => { + return new Promise((resolve, reject) => { + const env: Record = { ...process.env }; + delete env.AVOID_LOOP_RUN; + env.PRIVATE_KEY = + "0x1d751ded5a32226054cd2e71261039b65afb9ee1c746d055dd699b1150a5befc"; + env.RPC = "http://127.0.0.1:1"; + env.NODE_URL = "http://127.0.0.1:1"; + // These tests must not dial the public Ocean bootstrap nodes. + env.DISABLE_P2P = "true"; + + for (const [key, value] of Object.entries(options.env || {})) { + if (value === undefined) delete env[key]; + else env[key] = value; + } + + const child = spawn( + "npx", + ["tsx", "src/index.ts", ...(options.extraArgs || [])], + { cwd: projectRoot, env } + ); + + let output = ""; + child.stdout.on("data", (d) => (output += d.toString())); + child.stderr.on("data", (d) => (output += d.toString())); + child.on("error", reject); + child.on("close", (code) => resolve({ output, code })); + + for (const line of inputLines) { + child.stdin.write(line + "\n"); + } + child.stdin.end(); + }); }; \ No newline at end of file From d37b2129fd383b16170068588b355fa489d6b4d8 Mon Sep 17 00:00:00 2001 From: alexcos20 Date: Thu, 6 Aug 2026 13:02:17 +0300 Subject: [PATCH 2/4] fix --- test/setNode.test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/test/setNode.test.ts b/test/setNode.test.ts index 91bb267..3e5d8e1 100644 --- a/test/setNode.test.ts +++ b/test/setNode.test.ts @@ -24,10 +24,12 @@ describe("Ocean CLI node selection", function () { env: { NODE_URL: undefined }, }); expect(output).to.contain("No Ocean Node set"); - // The Commands constructor logs this line, so its absence proves the gate - // fired before the action body ran (rather than the action failing later). - expect(output).to.not.contain("Using Ocean Node URL"); + // The refusal must come from the gate, not from the command being unknown. expect(output).to.not.contain("Invalid option"); + // "Using Ocean Node URL" is logged by the Commands constructor, so the action + // body clearly never ran. (Weak on its own — runRepl's RPC is unreachable, so a + // command that got past the gate would die before that log too.) + expect(output).to.not.contain("Using Ocean Node URL"); }); it("still allows help and getNode", async function () { @@ -108,9 +110,9 @@ describe("Ocean CLI node selection", function () { ); expect(output).to.contain(`Using node: ${LIVE_NODE}`); expect(output).to.contain(`Current Ocean Node: ${LIVE_NODE}`); - // The gate opened: the action ran (its constructor logged the node) and the - // command was not refused. - expect(output).to.contain("Using Ocean Node URL"); + // The gate opened: the command that follows the switch is no longer refused. + // (It still fails further on — runRepl points RPC at an unreachable port — so + // this cannot assert anything the action itself would print.) expect(output).to.not.contain("No Ocean Node set"); }); @@ -123,13 +125,11 @@ describe("Ocean CLI node selection", function () { it("keeps the current node when the new one is unreachable", async function () { const { output } = await runRepl( - ["setNode http://127.0.0.1:9999", "getNode", "getComputeEnvironments", "exit"], + ["setNode http://127.0.0.1:9999", "getNode", "exit"], { env: { NODE_URL: LIVE_NODE } } ); expect(output).to.contain(`Keeping current node: ${LIVE_NODE}`); expect(output).to.contain(`Current Ocean Node: ${LIVE_NODE}`); - // A real command still works against the original node. - expect(output).to.contain(`Using Ocean Node URL : ${LIVE_NODE}`); }); }); }); From e40a4cb8f73c7a8d43431a72a92e1f6a871feb9a Mon Sep 17 00:00:00 2001 From: alexcos20 Date: Thu, 6 Aug 2026 13:16:07 +0300 Subject: [PATCH 3/4] fix review --- README.md | 2 +- src/index.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4248f0e..dda87c1 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ npm run cli # starts with no node You can switch node at any time with [`setNode`](#setnode) without restarting the CLI. See [`getNode`](#getnode) to check which node is active. -- Optional, set DISABLE_P2P to `'true'` to skip starting the libp2p transport. By default the CLI starts libp2p at startup (in the background, so it does not delay the prompt) even when `NODE_URL` is an HTTP URL, so that a later switch to a P2P node does not have to wait for bootstrap peers and DHT warm-up. Set this when you only ever use HTTP nodes and do not want the CLI dialing the public Ocean bootstrap nodes. +- Optional, set DISABLE_P2P to `'true'` to skip starting the libp2p transport. In interactive mode the CLI starts libp2p at startup (in the background, so it does not delay the prompt) even when `NODE_URL` is an HTTP URL, so that a later switch to a P2P node does not have to wait for bootstrap peers and DHT warm-up. One-shot runs (`AVOID_LOOP_RUN='true'`) skip that warm-up — they have no later command to benefit from it — and start libp2p only when the node they target is a P2P one. Set this when you only ever use HTTP nodes and do not want the CLI dialing the public Ocean bootstrap nodes. ```bash export DISABLE_P2P='true' diff --git a/src/index.ts b/src/index.ts index 32dc421..aca3ece 100644 --- a/src/index.ts +++ b/src/index.ts @@ -244,14 +244,20 @@ async function main(): Promise { } catch (error) { console.error(chalk.red(`Program Error: ${error.message}`)); + // Flush before exiting: process.exit() discards whatever a piped stdout/stderr + // still has buffered, which could swallow the message just written. Exiting + // here (rather than falling through to the finally) keeps failures immediate — + // the process is going away, so libp2p needs no orderly shutdown. + await flushOutput() process.exit(1); } finally { // Once libp2p has started the process can no longer end on its own: stopping // it cleanly still leaves a MessagePort holding the event loop open. So stop // it and, if it had been running, exit explicitly — after draining stdout, // since a piped stdout (tests, scripts) can still hold buffered output that - // process.exit() would discard. Covers every path out of the try above; the - // process.exit(1) in the catch terminates immediately and needs no cleanup. + // process.exit() would discard. Reached on every non-throwing path out of the + // try above; when nothing was started, Node exits on its own and drains the + // streams as part of that. if (await stopP2P()) { await flushOutput() process.exit(process.exitCode ?? 0) From df66ae33ac671574a8761703b443a1ae018e5f50 Mon Sep 17 00:00:00 2001 From: alexcos20 Date: Thu, 6 Aug 2026 13:28:49 +0300 Subject: [PATCH 4/4] fix review --- CLAUDE.md | 2 +- src/nodeConnection.ts | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index eba2756..7091aac 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -165,7 +165,7 @@ All node lifecycle logic lives in `nodeConnection.ts`; `cli.ts` only calls into **libp2p is transport, not a connection to one node.** Every ocean.js P2P call takes a `nodeUri` and dials that peer on demand (direct dial for a full multiaddr, DHT lookup for a bare peer id), so one libp2p node serves any number of Ocean nodes and switching between them never restarts or stops it. -- `startP2P(initialNodeUrl?)` — called **once at startup for every invocation** (not lazily, and not only for P2P `NODE_URL`s), because bootstrap dials + DHT warm-up take seconds and should overlap with the user reading the prompt. It is **deliberately not awaited**; the stored promise swallows its own rejection (an unhandled rejection on a fire-and-forget promise would kill the process) and remembers the failure for `ensureP2PReady()`. No-op when `DISABLE_P2P=true` or when `ProviderInstance.getLibp2pNode()` is already non-null. Bootstrap peers = the initial node if it is a P2P URI (bare peer ids get the `/ip4/127.0.0.1/tcp/9001/ws/p2p/` localhost convention) + `BOOTSTRAP_PEERS` + four hard-coded Ocean bootstrap nodes (passing `bootstrapPeers` **replaces** the lib's defaults, so they must be listed explicitly). +- `startP2P(initialNodeUrl?)` — called **once at startup**, not lazily and not only for P2P `NODE_URL`s, because bootstrap dials + DHT warm-up take seconds and should overlap with the user reading the prompt. The one exception is one-shot mode (`AVOID_LOOP_RUN="true"`), where `cli.ts` skips it — there is no later command to warm up for (see the exit note at the end of this section). It is **deliberately not awaited**; the stored promise swallows its own rejection (an unhandled rejection on a fire-and-forget promise would kill the process) and remembers the failure for `ensureP2PReady()`. No-op when `DISABLE_P2P=true` or when `ProviderInstance.getLibp2pNode()` is already non-null. Bootstrap peers = the initial node if it is a P2P URI (bare peer ids get the `/ip4/127.0.0.1/tcp/9001/ws/p2p/` localhost convention) + `BOOTSTRAP_PEERS` + four hard-coded Ocean bootstrap nodes (passing `bootstrapPeers` **replaces** the lib's defaults, so they must be listed explicitly). - `ensureP2PReady()` — awaited by every P2P-bound path; throws a clear reason instead of hanging when P2P is unavailable. - `validateNode(url)` — non-destructive health check via `ProviderInstance.getNodeStatus` under an `AbortSignal.timeout` (10 s HTTP, 30 s P2P since a bare peer id may need a DHT lookup). Over P2P the on-demand dial *is* the reachability check, which is what the old 20 s wait-for-target-peer polling loop did — that loop is gone. - `getCurrentNodeUrl()` / `setCurrentNodeUrl()` / `hasNode()` — `process.env.NODE_URL` stays the **single source of truth**, so switching node is just mutating it: `Commands`' constructor and `getMetadataURI()` re-read it per use. diff --git a/src/nodeConnection.ts b/src/nodeConnection.ts index fcf93ed..0321798 100644 --- a/src/nodeConnection.ts +++ b/src/nodeConnection.ts @@ -216,9 +216,18 @@ export async function validateNode( /** Chain ids the node serves, as reported by its status (provider + indexer). */ export function nodeChainIds(status: NodeStatus): string[] { + // Drop entries with no chainId before stringifying: String(undefined) would put the + // literal "undefined" in the list, which then shows up in getNode output and in the + // mismatch warning — and would make the list look non-empty when it holds no real ids. const ids = [ - ...(status.provider || []).map((p) => String(p.chainId)), - ...(status.indexer || []).map((i) => String(i.chainId)), + ...(status.provider || []) + .map((p) => p.chainId) + .filter(Boolean) + .map(String), + ...(status.indexer || []) + .map((i) => i.chainId) + .filter(Boolean) + .map(String), ]; return [...new Set(ids)]; }