diff --git a/AGENTS.md b/AGENTS.md index b9c62813..43f4a2b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Bridges IPFS content to Filecoin storage providers with cryptographic guarantees **Stack**: filecoin-pin → synapse-sdk → FOC contracts (FWSS, FilecoinPay, PDPVerifier, SPRegistry) + Curio. -**Status**: Calibration testnet only. Not production-ready. +**Status**: Supports Mainnet, Calibration testnet, and local devnet (foc-devnet). CLI defaults to Calibration. ## Design Philosophy @@ -25,10 +25,12 @@ Bridges IPFS content to Filecoin storage providers with cryptographic guarantees ``` src/ ├── cli.ts, server.ts # Commander.js CLI + Fastify server -├── add/, data-set/ # Command implementations +├── add/, import/, data-set/ # Command implementations ├── core/ # Published library (see package.json exports) │ ├── car/ # CAR file handling (CARv1 streaming) │ ├── payments/ # Payment setup/status +│ ├── metadata/ # Metadata normalization +│ ├── piece/ # Piece status queries │ ├── synapse/ # SDK initialization patterns │ ├── upload/ # Upload workflows │ ├── unixfs/ # Helia integration, browser/node variants @@ -50,13 +52,13 @@ src/ ## Key Patterns -**Synapse SDK**: Initialize with callbacks (onProviderSelected, onDataSetResolved, onPieceAdded), upload returns {pieceCid, pieceId, provider}. See `src/core/synapse/index.ts`, `src/core/upload/synapse.ts`. +**Synapse SDK**: Initialize via `initializeSynapse()` in `src/core/synapse/index.ts`. Upload via `executeUpload()` in `src/core/upload/index.ts` with progress events (`onStored`, `onPullProgress`, `onCopyComplete`, `onPiecesAdded`, `onPiecesConfirmed`). Returns `{pieceCid, size, copies, failures}`. **CAR files**: CARv1 streaming, handle 3 root cases (single/multiple/none), use zero CID for no roots. See `src/core/car/car-blockstore.ts`. **UnixFS**: Helia for directory imports, chunking, CID calculation. See `src/core/unixfs/`. -**Payments**: `checkPaymentStatus()`, `setupPayments()` in `src/core/payments/index.ts`. +**Payments**: `getPaymentStatus()`, `setMaxAllowances()`, `validatePaymentCapacity()` in `src/core/payments/index.ts`. ## Biome Linting (Critical) @@ -84,11 +86,13 @@ src/ ## CLI & Environment -**Commands**: `payments setup --auto`, `add `, `payments status`, `data-set `, `server` +**Commands**: `payments setup --auto`, `add `, `import `, `payments status`, `data-set `, `server` -**Required env**: `PRIVATE_KEY=0x...` (with USDFC tokens) +**Network**: `--network mainnet|calibration|devnet` (default: `calibration`). Devnet reads config from foc-devnet's `devnet-info.json` and auto-resolves private key and RPC URL. -**Optional**: `RPC_URL` (default: Calibration), `PORT`, `HOST`, `DATABASE_PATH`, `CAR_STORAGE_PATH`, `LOG_LEVEL` +**Required env**: `PRIVATE_KEY=0x...` (with USDFC tokens; not needed for devnet) + +**Optional**: `NETWORK`, `RPC_URL`, `FOC_DEVNET_BASEDIR`, `DEVNET_INFO_PATH`, `DEVNET_USER_INDEX`, `PORT`, `HOST`, `DATABASE_PATH`, `CAR_STORAGE_PATH`, `LOG_LEVEL` **Default data dirs for pinning server**: Linux `~/.local/share/filecoin-pin/`, macOS `~/Library/Application Support/filecoin-pin/`, Windows `%APPDATA%/filecoin-pin/` diff --git a/README.md b/README.md index 228219f1..0e8614d9 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ The Pinning Server requires the use of environment variables, as detailed below. ### Network Selection -Filecoin Pin supports both **Mainnet** and **Calibration testnet**. By default, the CLI uses Calibration testnet during development. +Filecoin Pin supports **Mainnet**, **Calibration testnet**, and local **devnet** networks. By default, the CLI uses Calibration testnet. **Using the CLI:** ```bash @@ -177,6 +177,9 @@ filecoin-pin add myfile.txt --network mainnet # Explicitly specify Calibration filecoin-pin add myfile.txt --network calibration + +# Use a local foc-devnet (reads config from devnet-info.json, details below) +filecoin-pin add myfile.txt --network devnet ``` **Using environment variables:** @@ -196,13 +199,30 @@ filecoin-pin add myfile.txt 3. `--network` flag or `NETWORK` environment variable 4. Default to Calibration testnet +### Local Development with foc-devnet + +When using `--network devnet`, Filecoin Pin reads connection details from a running [foc-devnet](https://github.com/filecoin-project/foc-devnet) instance: + +- **Private key**: Automatically resolved from `devnet-info.json` (no `PRIVATE_KEY` needed) +- **RPC URL**: Read from the devnet chain configuration +- **Contract addresses**: Resolved from the devnet chain definition +- **IPNI verification**: Automatically skipped (no IPNI infrastructure on devnet) + +**Environment variables for devnet:** + +| Variable | Description | Default | +|----------|-------------|---------| +| `FOC_DEVNET_BASEDIR` | Override the foc-devnet base directory | `~/.foc-devnet` | +| `DEVNET_INFO_PATH` | Explicit path to `devnet-info.json` (overrides basedir) | `/state/latest/devnet-info.json` | +| `DEVNET_USER_INDEX` | Which user from `devnet-info.json` to use | `0` | + ### Common CLI Arguments * `-h`, `--help`: Display help information for each command * `-V`, `--version`: Output the version number * `-v`, `--verbose`: Verbose output * `--private-key`: Ethereum-style (`0x`) private key, funded with USDFC (required) -* `--network`: Filecoin network to use: `mainnet` or `calibration` (default: `calibration`) +* `--network`: Filecoin network to use: `mainnet`, `calibration`, or `devnet` (default: `calibration`) * `--rpc-url`: Filecoin RPC endpoint (overrides `--network` if specified) Other arguments are possible for individual commands, use `--help` to find out more. @@ -214,10 +234,10 @@ Other arguments are possible for individual commands, use `--help` to find out m PRIVATE_KEY=0x... # Ethereum private key with USDFC tokens # Optional - Network Configuration -NETWORK=mainnet # Network to use: mainnet or calibration (default: calibration) +NETWORK=mainnet # Network to use: mainnet, calibration, or devnet (default: calibration) RPC_URL=wss://... # Filecoin RPC endpoint (overrides NETWORK if specified) - # Mainnet: wss://wss.node.glif.io/apigw/lotus/rpc/v1 - # Calibration: wss://wss.calibration.node.glif.io/apigw/lotus/rpc/v1 + # Mainnet: wss://wss.node.glif.io/apigw/lotus/rpc/v1 + # Calibration: wss://wss.calibration.node.glif.io/apigw/lotus/rpc/v1 # Optional for Pinning Server Daemon PORT=3456 # Daemon server port diff --git a/src/add/add.ts b/src/add/add.ts index eb96bb3f..a6e3746e 100644 --- a/src/add/add.ts +++ b/src/add/add.ts @@ -9,10 +9,12 @@ import { readFile, stat } from 'node:fs/promises' import pc from 'picocolors' import pino from 'pino' import { warnAboutCDNPricingLimitations } from '../common/cdn-warning.js' +import { DEVNET_CHAIN_ID } from '../common/get-rpc-url.js' import { displayUploadResults, performAutoFunding, performUpload, validatePaymentSetup } from '../common/upload-flow.js' import { normalizeMetadataConfig } from '../core/metadata/index.js' import { initializeSynapse } from '../core/synapse/index.js' import { cleanupTempCar, createCarFromPath } from '../core/unixfs/index.js' +import { getNetworkSlug } from '../core/upload/index.js' import { parseCLIAuth, parseContextSelectionOptions } from '../utils/cli-auth.js' import { cancel, createSpinner, formatFileSize, intro, outro } from '../utils/cli-helpers.js' import { log } from '../utils/cli-logger.js' @@ -123,6 +125,7 @@ export async function runAdd(options: AddOptions): Promise { if (withCDN) config.withCDN = true const synapse = await initializeSynapse(config, logger) + const networkSlug = getNetworkSlug(synapse.chain) const network = synapse.chain.name spinner.stop(`${pc.green('✓')} Connected to ${pc.bold(network)}`) @@ -164,11 +167,15 @@ export async function runAdd(options: AddOptions): Promise { await validatePaymentSetup(synapse, carSize, spinner) } + // Auto-skip IPNI on devnet (no IPNI infrastructure available) + const skipIpniVerification = options.skipIpniVerification || synapse.chain.id === DEVNET_CHAIN_ID + const uploadOptions: Parameters[3] = { contextType: 'add', fileSize: carSize, logger, spinner, + skipIpniVerification, ...(pieceMetadata && { pieceMetadata }), ...(dataSetMetadata && { metadata: dataSetMetadata }), ...(options.count != null && { count: options.count }), @@ -200,7 +207,7 @@ export async function runAdd(options: AddOptions): Promise { failures: uploadResult.failures, } - displayUploadResults(result, 'Add', network) + displayUploadResults(result, 'Add', network, networkSlug) if (uploadResult.copies.length < requestedCopies) { log.line('') diff --git a/src/add/types.ts b/src/add/types.ts index 86437bf9..f4f61e39 100644 --- a/src/add/types.ts +++ b/src/add/types.ts @@ -12,6 +12,8 @@ export interface AddOptions extends CLIAuthOptions { pieceMetadata?: Record /** Data set metadata applied when creating or updating the storage context */ dataSetMetadata?: Record + /** Skip IPNI advertisement verification after upload */ + skipIpniVerification?: boolean } export interface AddResult { diff --git a/src/commands/add.ts b/src/commands/add.ts index 18c4f99a..f602f7ae 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -2,7 +2,7 @@ import { Command } from 'commander' import { runAdd } from '../add/add.js' import type { AddOptions } from '../add/types.js' import { MIN_RUNWAY_DAYS } from '../common/constants.js' -import { addAuthOptions, addContextSelectionOptions } from '../utils/cli-options.js' +import { addAuthOptions, addContextSelectionOptions, addUploadOptions } from '../utils/cli-options.js' import { addMetadataOptions, resolveMetadataOptions } from '../utils/cli-options-metadata.js' export const addCommand = new Command('add') @@ -39,4 +39,5 @@ addCommand.action(async (path: string, options: any) => { addAuthOptions(addCommand) addContextSelectionOptions(addCommand) +addUploadOptions(addCommand) addMetadataOptions(addCommand, { includePieceMetadata: true, includeDataSetMetadata: true, includeErc8004: true }) diff --git a/src/commands/import.ts b/src/commands/import.ts index 4c7590c3..642fec9e 100644 --- a/src/commands/import.ts +++ b/src/commands/import.ts @@ -2,7 +2,7 @@ import { Command } from 'commander' import { MIN_RUNWAY_DAYS } from '../common/constants.js' import { runCarImport } from '../import/import.js' import type { ImportOptions } from '../import/types.js' -import { addAuthOptions, addContextSelectionOptions } from '../utils/cli-options.js' +import { addAuthOptions, addContextSelectionOptions, addUploadOptions } from '../utils/cli-options.js' import { addMetadataOptions, resolveMetadataOptions } from '../utils/cli-options-metadata.js' export const importCommand = new Command('import') @@ -37,4 +37,5 @@ export const importCommand = new Command('import') addAuthOptions(importCommand) addContextSelectionOptions(importCommand) +addUploadOptions(importCommand) addMetadataOptions(importCommand, { includePieceMetadata: true, includeDataSetMetadata: true, includeErc8004: true }) diff --git a/src/common/constants.ts b/src/common/constants.ts index 2132fd45..857088a6 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -2,3 +2,9 @@ * Minimum runway in days to ensure WarmStorage can cover costs. Used when `--auto-fund` is passed to import or add commands */ export const MIN_RUNWAY_DAYS = 30 + +/** + * Chain ID for foc-devnet local Filecoin networks. + * Defined by the devnet's genesis configuration (matching synapse-core's toChain output). + */ +export const DEVNET_CHAIN_ID = 31415926 diff --git a/src/common/get-rpc-url.ts b/src/common/get-rpc-url.ts index 01c63995..ff223085 100644 --- a/src/common/get-rpc-url.ts +++ b/src/common/get-rpc-url.ts @@ -1,10 +1,70 @@ +import { readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { toChain, validateDevnetInfo } from '@filoz/synapse-core/devnet' +import type { Chain } from '@filoz/synapse-sdk' import { calibration, mainnet } from '@filoz/synapse-sdk' import type { CLIAuthOptions } from '../utils/cli-auth.js' +import { DEVNET_CHAIN_ID } from './constants.js' const NETWORK_CHAINS = { mainnet, calibration, } as const +function getDefaultDevnetInfoPath(): string { + const baseDir = process.env.FOC_DEVNET_BASEDIR?.trim() || join(homedir(), '.foc-devnet') + return join(baseDir, 'state', 'latest', 'devnet-info.json') +} + +interface DevnetConfig { + chain: Chain + privateKey: string | undefined +} + +let cachedDevnetConfig: DevnetConfig | undefined + +/** + * Load and cache devnet configuration from devnet-info.json. + * + * Reads the devnet info file, validates it, and builds a Chain via synapse-core's + * toChain(). The result is cached for the lifetime of the process so that + * getRpcUrl() and parseCLIAuth() share the same chain object. + */ +export function resolveDevnetConfig(): DevnetConfig { + if (cachedDevnetConfig) { + return cachedDevnetConfig + } + + const devnetInfoPath = process.env.DEVNET_INFO_PATH || getDefaultDevnetInfoPath() + const userIndex = Number(process.env.DEVNET_USER_INDEX || '0') + + let rawData: unknown + try { + rawData = JSON.parse(readFileSync(devnetInfoPath, 'utf8')) + } catch (error) { + throw new Error( + `Failed to read devnet info from ${devnetInfoPath}: ${error instanceof Error ? error.message : String(error)}. ` + + 'Set DEVNET_INFO_PATH to the correct path, or ensure foc-devnet is running.' + ) + } + + const devnetInfo = validateDevnetInfo(rawData) + const { info } = devnetInfo + + if (userIndex >= info.users.length) { + throw new Error( + `DEVNET_USER_INDEX=${userIndex} out of range (${info.users.length} user(s) available in devnet-info.json)` + ) + } + + const user = info.users[userIndex] + + cachedDevnetConfig = { + chain: toChain(devnetInfo), + privateKey: user?.private_key_hex, + } + return cachedDevnetConfig +} /** * Get the RPC URL from the CLI options. @@ -22,11 +82,19 @@ export function getRpcUrl(options: CLIAuthOptions): string { return options.rpcUrl } - // Try to use network flag const network = options.network?.toLowerCase().trim() if (network) { + if (network === 'devnet') { + const devnet = resolveDevnetConfig() + const rpcUrl = devnet.chain.rpcUrls.default.http[0] + if (!rpcUrl) { + throw new Error('No RPC URL available in devnet-info.json') + } + return rpcUrl + } + if (network !== 'mainnet' && network !== 'calibration') { - throw new Error(`Invalid network: "${network}". Must be "mainnet" or "calibration"`) + throw new Error(`Invalid network: "${network}". Must be "mainnet", "calibration", or "devnet"`) } const chain = NETWORK_CHAINS[network] const wsUrl = chain.rpcUrls.default.webSocket?.[0] @@ -43,4 +111,4 @@ export function getRpcUrl(options: CLIAuthOptions): string { return defaultUrl } -export { NETWORK_CHAINS } +export { DEVNET_CHAIN_ID, NETWORK_CHAINS } diff --git a/src/common/upload-flow.ts b/src/common/upload-flow.ts index 56d0476f..8eeb9baa 100644 --- a/src/common/upload-flow.ts +++ b/src/common/upload-flow.ts @@ -59,6 +59,9 @@ export interface UploadFlowOptions { /** Data set metadata applied when creating or matching contexts. */ metadata?: Record + + /** Skip IPNI advertisement verification after upload */ + skipIpniVerification?: boolean } export interface UploadFlowResult extends SynapseUploadResult { @@ -315,6 +318,7 @@ export async function performUpload( ...(options.dataSetIds != null && { dataSetIds: options.dataSetIds }), ...(options.excludeProviderIds != null && { excludeProviderIds: options.excludeProviderIds }), ...(options.metadata != null && { metadata: options.metadata }), + ...(options.skipIpniVerification && { ipniValidation: { enabled: false } }), onProgress(event) { switch (event.type) { case 'onStored': { @@ -357,8 +361,12 @@ export async function performUpload( // Show per-SP transaction URL as indented line under the "added" message const afterLines: string[] = [] if (event.data.txHash) { - const filfoxBase = network === 'mainnet' ? 'https://filfox.info' : `https://${network}.filfox.info` - afterLines.push(pc.gray(`Tx: ${filfoxBase}/en/message/${event.data.txHash}`)) + if (network === 'devnet') { + afterLines.push(pc.gray(`Tx: ${event.data.txHash}`)) + } else { + const filfoxBase = network === 'mainnet' ? 'https://filfox.info' : `https://${network}.filfox.info` + afterLines.push(pc.gray(`Tx: ${filfoxBase}/en/message/${event.data.txHash}`)) + } } flow.completeOperation(commitId, `${roleLabel(role)} Piece added to Data Set (unconfirmed on-chain)`, { type: 'success', @@ -447,9 +455,10 @@ export function displayUploadResults( failures: FailedCopy[] }, operation: string, - network: string + networkDisplay: string, + networkSlug: string ): void { - log.line(`Network: ${pc.bold(network)}`) + log.line(`Network: ${pc.bold(networkDisplay)}`) log.line('') log.line(pc.bold(`${operation} Details`)) @@ -463,7 +472,9 @@ export function displayUploadResults( if (result.size != null) { log.indent(`Piece Size: ${formatFileSize(result.size)}`) } - log.indent(`Explorer: ${pc.gray(`https://pdp.vxb.ai/${encodeURIComponent(network)}/piece/${result.pieceCid}`)}`) + if (networkSlug !== 'devnet') { + log.indent(`Explorer: ${pc.gray(`https://pdp.vxb.ai/${encodeURIComponent(networkSlug)}/piece/${result.pieceCid}`)}`) + } log.line('') if (result.copies.length > 0) { diff --git a/src/core/upload/index.ts b/src/core/upload/index.ts index 95db65f1..ef12423d 100644 --- a/src/core/upload/index.ts +++ b/src/core/upload/index.ts @@ -1,6 +1,8 @@ import type { Chain, PDPProvider, Synapse } from '@filoz/synapse-sdk' +import { calibration, mainnet } from '@filoz/synapse-sdk' import type { CID } from 'multiformats/cid' import type { Logger } from 'pino' +import { DEVNET_CHAIN_ID } from '../../common/constants.js' import { checkAllowances, checkFILBalance, @@ -28,11 +30,11 @@ export { getDownloadURL, getServiceURL, uploadToSynapse } from './synapse.js' */ export function getNetworkSlug(chain: Chain): string { switch (chain.id) { - case 314: + case mainnet.id: return 'mainnet' - case 314159: + case calibration.id: return 'calibration' - case 31415926: + case DEVNET_CHAIN_ID: return 'devnet' default: return chain.name diff --git a/src/import/import.ts b/src/import/import.ts index 8a6843f9..1ac7d82a 100644 --- a/src/import/import.ts +++ b/src/import/import.ts @@ -12,9 +12,11 @@ import { CID } from 'multiformats/cid' import pc from 'picocolors' import pino from 'pino' import { warnAboutCDNPricingLimitations } from '../common/cdn-warning.js' +import { DEVNET_CHAIN_ID } from '../common/get-rpc-url.js' import { displayUploadResults, performAutoFunding, performUpload, validatePaymentSetup } from '../common/upload-flow.js' import { normalizeMetadataConfig } from '../core/metadata/index.js' import { initializeSynapse } from '../core/synapse/index.js' +import { getNetworkSlug } from '../core/upload/index.js' import { parseCLIAuth, parseContextSelectionOptions } from '../utils/cli-auth.js' import { cancel, createSpinner, formatFileSize, intro, outro } from '../utils/cli-helpers.js' import { log } from '../utils/cli-logger.js' @@ -194,6 +196,7 @@ export async function runCarImport(options: ImportOptions): Promise[3] = { contextType: 'import', fileSize: fileStat.size, logger, spinner, + skipIpniVerification, ...(pieceMetadata && { pieceMetadata }), ...(dataSetMetadata && { metadata: dataSetMetadata }), ...(options.count != null && { count: options.count }), @@ -244,7 +251,7 @@ export async function runCarImport(options: ImportOptions): Promise /** Data set metadata applied when creating or updating the storage context */ dataSetMetadata?: Record + /** Skip IPNI advertisement verification after upload */ + skipIpniVerification?: boolean } export interface ImportResult { diff --git a/src/test/unit/get-rpc-url.test.ts b/src/test/unit/get-rpc-url.test.ts index 97a422d7..d1d8f3fc 100644 --- a/src/test/unit/get-rpc-url.test.ts +++ b/src/test/unit/get-rpc-url.test.ts @@ -56,7 +56,7 @@ describe('getRpcUrl', () => { it('throws for unsupported networks', () => { expect(() => getRpcUrl({ network: 'invalid' })).toThrow( - 'Invalid network: "invalid". Must be "mainnet" or "calibration"' + 'Invalid network: "invalid". Must be "mainnet", "calibration", or "devnet"' ) }) }) diff --git a/src/utils/cli-auth.ts b/src/utils/cli-auth.ts index 3ddb364a..af7c813f 100644 --- a/src/utils/cli-auth.ts +++ b/src/utils/cli-auth.ts @@ -6,7 +6,7 @@ */ import type { Chain, Synapse } from '@filoz/synapse-sdk' -import { getRpcUrl, NETWORK_CHAINS } from '../common/get-rpc-url.js' +import { getRpcUrl, NETWORK_CHAINS, resolveDevnetConfig } from '../common/get-rpc-url.js' import type { SynapseSetupConfig } from '../core/synapse/index.js' import { initializeSynapse } from '../core/synapse/index.js' import { createLogger } from '../logger.js' @@ -46,14 +46,23 @@ export interface CLIAuthOptions { * @returns Synapse setup config (validation happens in initializeSynapse) */ export function parseCLIAuth(options: CLIAuthOptions): SynapseSetupConfig { - // Read from CLI options or environment variables - const privateKey = options.privateKey || process.env.PRIVATE_KEY + const network = options.network?.toLowerCase().trim() + const isDevnet = network === 'devnet' + + // For devnet, fall back to the devnet user's private key if none provided + const privateKey = + options.privateKey || process.env.PRIVATE_KEY || (isDevnet ? resolveDevnetConfig().privateKey : undefined) const walletAddress = options.walletAddress || process.env.WALLET_ADDRESS const sessionKey = options.sessionKey || process.env.SESSION_KEY const viewAddress = options.viewAddress || process.env.VIEW_ADDRESS const rpcUrl = getRpcUrl(options) - const network = options.network?.toLowerCase().trim() as keyof typeof NETWORK_CHAINS | undefined - const chain: Chain | undefined = network ? NETWORK_CHAINS[network] : undefined + + let chain: Chain | undefined + if (isDevnet) { + chain = resolveDevnetConfig().chain + } else if (network) { + chain = NETWORK_CHAINS[network as keyof typeof NETWORK_CHAINS] + } // Build config incrementally; initializeSynapse() validates the final shape const config: { diff --git a/src/utils/cli-options.ts b/src/utils/cli-options.ts index 54e73b23..f4765d18 100644 --- a/src/utils/cli-options.ts +++ b/src/utils/cli-options.ts @@ -82,11 +82,29 @@ export function addContextSelectionOptions(command: Command): Command { export function addNetworkOptions(command: Command): Command { command .addOption( - new Option('--network ', 'Filecoin network to use') - .choices(['mainnet', 'calibration']) + new Option( + '--network ', + 'Filecoin network to use. "devnet" reads config from foc-devnet ' + + '(https://github.com/filecoin-project/foc-devnet, ' + + 'env: FOC_DEVNET_BASEDIR or DEVNET_INFO_PATH, DEVNET_USER_INDEX)' + ) + .choices(['mainnet', 'calibration', 'devnet']) .env('NETWORK') .default('calibration') ) .addOption(new Option('--mainnet', 'Use mainnet (shorthand for --network mainnet)').implies({ network: 'mainnet' })) return command } + +/** + * Add upload-specific options to a command. + * Used by `add` and `import` commands. + */ +export function addUploadOptions(command: Command): Command { + return command.addOption( + new Option( + '--skip-ipni-verification', + 'Skip IPNI advertisement verification after upload (automatic for devnet)' + ).env('SKIP_IPNI_VERIFICATION') + ) +}