From 1b1ed32523635ed04511799f8565adb5b29b0929 Mon Sep 17 00:00:00 2001 From: Mikers Date: Wed, 29 Apr 2026 08:48:19 -1000 Subject: [PATCH 1/4] add synapse command coverage --- README.md | 2 +- cli/package.json | 1 + cli/src/commands/dataset/create.ts | 3 +- cli/src/commands/piece/index.ts | 3 +- cli/src/commands/wallet/costs.ts | 4 - cli/tests/command-mocks.ts | 380 ++++++++++++++++ cli/tests/output.test.ts | 45 +- cli/tests/synapse-commands.test.ts | 669 +++++++++++++++++++++++++++++ skills/foc-cli/SKILL.md | 4 +- 9 files changed, 1088 insertions(+), 23 deletions(-) create mode 100644 cli/tests/command-mocks.ts create mode 100644 cli/tests/synapse-commands.test.ts diff --git a/README.md b/README.md index eab6df9..1ca7de8 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ npx foc-cli wallet costs --extraBytes --extraRunway ```bash npx foc-cli dataset list # List all datasets npx foc-cli dataset details -d # Metadata + pieces -npx foc-cli dataset create [providerId] [--cdn] # Create dataset +npx foc-cli dataset create [--cdn] # Create dataset npx foc-cli dataset upload # Create + upload npx foc-cli dataset terminate # Terminate dataset ``` diff --git a/cli/package.json b/cli/package.json index 2967f5e..3879e15 100644 --- a/cli/package.json +++ b/cli/package.json @@ -20,6 +20,7 @@ "build": "rm -rf dist && tsc", "prepublishOnly": "rm -rf dist && tsc && cp ../README.md ../LICENSE . && cp -r ../skills .", "postpublish": "rm -f README.md LICENSE && rm -rf skills", + "test": "bun test", "lint": "tsc --noEmit && biome check --fix src/" }, "keywords": [ diff --git a/cli/src/commands/dataset/create.ts b/cli/src/commands/dataset/create.ts index fe08804..2441af7 100644 --- a/cli/src/commands/dataset/create.ts +++ b/cli/src/commands/dataset/create.ts @@ -11,7 +11,7 @@ export const createCommand = { providerId: z.coerce .number() .optional() - .describe('Provider ID (interactive selection if omitted)'), + .describe('Provider ID. Use provider list to choose one.'), }), options: z.object({ chain: z @@ -28,7 +28,6 @@ export const createCommand = { providerId: z.string(), }), examples: [ - { description: 'Create dataset with interactive provider selection' }, { args: { providerId: 1 }, description: 'Create dataset with provider #1' }, { args: { providerId: 1 }, diff --git a/cli/src/commands/piece/index.ts b/cli/src/commands/piece/index.ts index e3293a0..9d0e028 100644 --- a/cli/src/commands/piece/index.ts +++ b/cli/src/commands/piece/index.ts @@ -3,8 +3,7 @@ import { listCommand } from './list.ts' import { removeCommand } from './remove.ts' export const piece = Cli.create('piece', { - description: - 'Piece management — upload, browse, and remove pieces from datasets', + description: 'Piece management — browse and remove pieces from datasets', }) piece.command('list', listCommand) diff --git a/cli/src/commands/wallet/costs.ts b/cli/src/commands/wallet/costs.ts index 6b29c60..dedae6c 100644 --- a/cli/src/commands/wallet/costs.ts +++ b/cli/src/commands/wallet/costs.ts @@ -22,10 +22,6 @@ export const costsCommand = { alreadyCovered: z.boolean(), }), examples: [ - { - options: { extraBytes: 1000000 }, - description: 'Get costs for uploading 1MB of data', - }, { options: { extraBytes: 1000000, extraRunway: 1 }, description: 'Get costs for uploading 1MB with 1 month runway', diff --git a/cli/tests/command-mocks.ts b/cli/tests/command-mocks.ts new file mode 100644 index 0000000..a86e41c --- /dev/null +++ b/cli/tests/command-mocks.ts @@ -0,0 +1,380 @@ +import { mock } from 'bun:test' + +export function cid(value: string) { + return { + toString: () => value, + } +} + +export const fakeChain = { + id: 314159, + blockExplorers: { + default: { + url: 'https://calibration.filfox.info/en', + }, + }, +} + +export const fakeWalletClient = { + account: { + address: '0x0000000000000000000000000000000000000123', + }, +} + +export const fakePublicClient = { + name: 'public-client', +} + +export const synapseWaitForTransactionReceipt = mock(async () => ({ + status: 'success', +})) + +export const synapsePayments = { + walletBalance: mock(async (options?: { token?: string }) => + options?.token ? 2000n : 1000n + ), + accountInfo: mock(async () => ({ + availableFunds: 3000n, + lockupCurrent: 4000n, + lockupRate: 5000n, + lockupLastSettledAt: 6000n, + funds: 7000n, + })), + depositWithPermitAndApproveOperator: mock(async () => '0xdeposit'), + withdraw: mock(async () => '0xwithdraw'), +} + +export const synapseStorage = { + createContexts: mock(async () => []), + prepare: mock(async () => ({ + transaction: null, + costs: { + rate: { + perMonth: 111n, + }, + depositNeeded: 222n, + ready: true, + }, + })), + upload: mock(async () => ({ + pieceCid: cid('baga-upload'), + size: 4, + copies: [], + failedAttempts: [], + })), +} + +export const synapseConstructorArgs: any[] = [] +export class Synapse { + client: { waitForTransactionReceipt: typeof synapseWaitForTransactionReceipt } + payments: typeof synapsePayments + storage: typeof synapseStorage + + constructor(options: any) { + synapseConstructorArgs.push(options) + this.client = { + waitForTransactionReceipt: synapseWaitForTransactionReceipt, + } + this.payments = synapsePayments + this.storage = synapseStorage + } +} + +export const parseUnits = mock((value: string) => BigInt(value) * 1_000_000n) + +export const privateKeyClient = mock(() => ({ + client: fakeWalletClient, + chain: fakeChain, +})) + +export const publicClient = mock(() => fakePublicClient) + +export const getChain = mock((chainId: number) => ({ + ...fakeChain, + id: chainId, +})) + +export const formatBalance = mock(({ value }: { value: bigint }) => { + return `formatted:${value.toString()}` +}) + +export const claimTokens = mock(async () => [{ tx_hash: '0xfaucet' }]) + +export const fakeProvider = { + id: 77n, + name: 'Provider 77', + description: 'Fast provider', + serviceProvider: 'f077', + payee: '0x0000000000000000000000000000000000000777', + isActive: true, + pdp: { + serviceURL: 'https://provider.example', + location: 'Earth', + minPieceSizeInBytes: 1024n, + maxPieceSizeInBytes: 1024n * 1024n, + storagePricePerTibPerDay: 99n, + minProvingPeriodInEpochs: 2880n, + paymentTokenAddress: '0x0000000000000000000000000000000000000abc', + ipniPiece: true, + ipniIpfs: false, + ipniPeerId: '12D3KooWProvider', + }, +} + +export const getPDPProvider = mock(async () => fakeProvider) +export const getApprovedPDPProviders = mock(async () => [fakeProvider]) + +export const fakeDataSet = { + dataSetId: 42n, + clientDataSetId: 100n, + provider: fakeProvider, + cdn: true, + live: true, + managed: false, + pdpEndEpoch: 0n, + activePieceCount: 2n, + metadata: { + label: 'dataset', + }, +} + +export const getPdpDataSets = mock(async () => [fakeDataSet]) +export const getPdpDataSet = mock(async () => fakeDataSet) + +export const fakePiece = { + id: 7n, + cid: cid('baga-piece'), + url: 'https://provider.example/piece/baga-piece', + metadata: { + name: 'file.txt', + }, +} + +export const getPiecesWithMetadata = mock(async () => ({ + pieces: [fakePiece], +})) + +export const createDataSet = mock(async () => ({ + txHash: '0xcreate', +})) + +export const waitForCreateDataSet = mock(async () => ({ + dataSetId: 42n, +})) + +export const uploadPiece = mock(async () => undefined) +export const findPiece = mock(async () => undefined) +export const calculate = mock(() => cid('baga-calculated')) + +export const createDataSetAndAddPieces = mock(async () => ({ + txHash: '0xdatasetupload', + statusUrl: 'https://provider.example/status', +})) + +export const waitForCreateDataSetAddPieces = mock(async () => ({ + dataSetId: 43n, + piecesIds: [8n], +})) + +export const schedulePieceDeletion = mock(async () => ({ + hash: '0xremove', +})) + +export const terminateServiceSync = mock(async (_client: any, options: any) => { + options.onHash?.('0xterminate') + return { + event: { + args: { + dataSetId: options.dataSetId, + }, + }, + } +}) + +export const getAccountSummary = mock(async () => ({ + availableFunds: 1n, + totalLockup: 2n, + totalRateBasedLockup: 3n, + lockupRatePerMonth: 4n, + funds: 5n, + epoch: 100n, + fundedUntilEpoch: 220n, +})) + +export const getBlockNumber = mock(async () => 123n) +export const waitForTransactionReceipt = mock(async () => ({ + status: 'success', +})) + +mock.module('../src/client.ts', () => ({ + privateKeyClient, + publicClient, +})) + +mock.module('@filoz/synapse-sdk', () => ({ + Synapse, + TOKENS: { + USDFC: 'USDFC', + }, + parseUnits, +})) + +mock.module('@filoz/synapse-core/chains', () => ({ + getChain, +})) + +mock.module('@filoz/synapse-core/utils', () => ({ + claimTokens, + formatBalance, +})) + +mock.module('@filoz/synapse-core/sp-registry', () => ({ + getApprovedPDPProviders, + getPDPProvider, +})) + +mock.module('@filoz/synapse-core/warm-storage', () => ({ + getPdpDataSet, + getPdpDataSets, + terminateServiceSync, +})) + +mock.module('@filoz/synapse-core/pdp-verifier', () => ({ + getPiecesWithMetadata, +})) + +mock.module('@filoz/synapse-core/sp', () => ({ + createDataSet, + createDataSetAndAddPieces, + findPiece, + schedulePieceDeletion, + uploadPiece, + waitForCreateDataSet, + waitForCreateDataSetAddPieces, +})) + +mock.module('@filoz/synapse-core/piece', () => ({ + calculate, +})) + +mock.module('@filoz/synapse-core/pay', () => ({ + getAccountSummary, +})) + +mock.module('viem/actions', () => ({ + getBlockNumber, + waitForTransactionReceipt, +})) + +export function resetCommandMocks() { + mock.clearAllMocks() + synapseConstructorArgs.length = 0 + + privateKeyClient.mockImplementation(() => ({ + client: fakeWalletClient, + chain: fakeChain, + })) + publicClient.mockImplementation(() => fakePublicClient) + getChain.mockImplementation((chainId: number) => ({ + ...fakeChain, + id: chainId, + })) + + formatBalance.mockImplementation(({ value }: { value: bigint }) => { + return `formatted:${value.toString()}` + }) + claimTokens.mockImplementation(async () => [{ tx_hash: '0xfaucet' }]) + + synapsePayments.walletBalance.mockImplementation( + async (options?: { token?: string }) => (options?.token ? 2000n : 1000n) + ) + synapsePayments.accountInfo.mockImplementation(async () => ({ + availableFunds: 3000n, + lockupCurrent: 4000n, + lockupRate: 5000n, + lockupLastSettledAt: 6000n, + funds: 7000n, + })) + synapsePayments.depositWithPermitAndApproveOperator.mockImplementation( + async () => '0xdeposit' + ) + synapsePayments.withdraw.mockImplementation(async () => '0xwithdraw') + synapseWaitForTransactionReceipt.mockImplementation(async () => ({ + status: 'success', + })) + + synapseStorage.createContexts.mockImplementation(async () => []) + synapseStorage.prepare.mockImplementation(async () => ({ + transaction: null, + costs: { + rate: { + perMonth: 111n, + }, + depositNeeded: 222n, + ready: true, + }, + })) + synapseStorage.upload.mockImplementation(async () => ({ + pieceCid: cid('baga-upload'), + size: 4, + copies: [], + failedAttempts: [], + })) + parseUnits.mockImplementation((value: string) => BigInt(value) * 1_000_000n) + + getPDPProvider.mockImplementation(async () => fakeProvider) + getApprovedPDPProviders.mockImplementation(async () => [fakeProvider]) + getPdpDataSets.mockImplementation(async () => [fakeDataSet]) + getPdpDataSet.mockImplementation(async () => fakeDataSet) + getPiecesWithMetadata.mockImplementation(async () => ({ + pieces: [fakePiece], + })) + + createDataSet.mockImplementation(async () => ({ + txHash: '0xcreate', + })) + waitForCreateDataSet.mockImplementation(async () => ({ + dataSetId: 42n, + })) + uploadPiece.mockImplementation(async () => undefined) + findPiece.mockImplementation(async () => undefined) + calculate.mockImplementation(() => cid('baga-calculated')) + createDataSetAndAddPieces.mockImplementation(async () => ({ + txHash: '0xdatasetupload', + statusUrl: 'https://provider.example/status', + })) + waitForCreateDataSetAddPieces.mockImplementation(async () => ({ + dataSetId: 43n, + piecesIds: [8n], + })) + schedulePieceDeletion.mockImplementation(async () => ({ + hash: '0xremove', + })) + terminateServiceSync.mockImplementation( + async (_client: any, options: any) => { + options.onHash?.('0xterminate') + return { + event: { + args: { + dataSetId: options.dataSetId, + }, + }, + } + } + ) + getAccountSummary.mockImplementation(async () => ({ + availableFunds: 1n, + totalLockup: 2n, + totalRateBasedLockup: 3n, + lockupRatePerMonth: 4n, + funds: 5n, + epoch: 100n, + fundedUntilEpoch: 220n, + })) + getBlockNumber.mockImplementation(async () => 123n) + waitForTransactionReceipt.mockImplementation(async () => ({ + status: 'success', + })) +} + +resetCommandMocks() diff --git a/cli/tests/output.test.ts b/cli/tests/output.test.ts index 4ccf87e..7cb2e4b 100644 --- a/cli/tests/output.test.ts +++ b/cli/tests/output.test.ts @@ -1,13 +1,15 @@ -import { describe, test, expect } from 'bun:test' -import { deepSerialize } from '../src/output.ts' -import { OutputContext } from '../src/output.ts' +import { describe, expect, test } from 'bun:test' +import { deepSerialize, OutputContext } from '../src/output.ts' describe('deepSerialize', () => { test('converts bigint to string', () => { expect(deepSerialize(42n)).toBe('42') }) test('converts bigint in object', () => { - expect(deepSerialize({ id: 42n, name: 'test' })).toEqual({ id: '42', name: 'test' }) + expect(deepSerialize({ id: 42n, name: 'test' })).toEqual({ + id: '42', + name: 'test', + }) }) test('converts bigint in nested object', () => { expect(deepSerialize({ a: { b: 100n } })).toEqual({ a: { b: '100' } }) @@ -16,7 +18,10 @@ describe('deepSerialize', () => { expect(deepSerialize([1n, 2n, 3n])).toEqual(['1', '2', '3']) }) test('converts bigint in array of objects', () => { - expect(deepSerialize([{ id: 1n }, { id: 2n }])).toEqual([{ id: '1' }, { id: '2' }]) + expect(deepSerialize([{ id: 1n }, { id: 2n }])).toEqual([ + { id: '1' }, + { id: '2' }, + ]) }) test('passes through primitives', () => { expect(deepSerialize('hello')).toBe('hello') @@ -35,7 +40,7 @@ describe('OutputContext', () => { function mockContext(agent: boolean) { return { agent, - ok: (data: any, opts?: any) => opts ? { ...data, ...opts } : data, + ok: (data: any, opts?: any) => (opts ? { ...data, ...opts } : data), error: (err: any) => err, } } @@ -58,9 +63,16 @@ describe('OutputContext', () => { const c = mockContext(true) const out = new OutputContext(c) out.step('Depositing') - const result = out.done({ status: 'ok' }, { - cta: { commands: [{ command: 'wallet balance', description: 'Check balance' }] }, - }) + const result = out.done( + { status: 'ok' }, + { + cta: { + commands: [ + { command: 'wallet balance', description: 'Check balance' }, + ], + }, + } + ) expect(result.cta).toBeDefined() expect(result.cta.commands[0].command).toBe('wallet balance') }) @@ -71,11 +83,20 @@ describe('OutputContext', () => { out.step('Connecting') out.step('Submitting') const result = out.fail('TX_FAILED', 'insufficient funds', { - cta: { commands: [{ command: 'wallet fund', description: 'Get tokens' }] }, + cta: { + commands: [{ command: 'wallet fund', description: 'Get tokens' }], + }, }) expect(result.error.code).toBe('TX_FAILED') - expect(result.processLog[0]).toEqual({ step: 'Connecting', status: 'done' }) - expect(result.processLog[1]).toEqual({ step: 'Submitting', status: 'failed', error: 'insufficient funds' }) + expect(result.processLog[0]).toEqual({ + step: 'Connecting', + status: 'done', + }) + expect(result.processLog[1]).toEqual({ + step: 'Submitting', + status: 'failed', + error: 'insufficient funds', + }) }) test('fail with retryable flag', () => { diff --git a/cli/tests/synapse-commands.test.ts b/cli/tests/synapse-commands.test.ts new file mode 100644 index 0000000..3c89776 --- /dev/null +++ b/cli/tests/synapse-commands.test.ts @@ -0,0 +1,669 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { + calculate, + cid, + claimTokens, + createDataSet, + createDataSetAndAddPieces, + fakeProvider, + fakeWalletClient, + findPiece, + formatBalance, + getAccountSummary, + getApprovedPDPProviders, + getBlockNumber, + getPDPProvider, + getPdpDataSet, + getPdpDataSets, + getPiecesWithMetadata, + parseUnits, + privateKeyClient, + publicClient, + resetCommandMocks, + schedulePieceDeletion, + synapseConstructorArgs, + synapsePayments, + synapseStorage, + synapseWaitForTransactionReceipt, + terminateServiceSync, + uploadPiece, + waitForCreateDataSet, + waitForCreateDataSetAddPieces, + waitForTransactionReceipt, +} from './command-mocks.ts' + +const { uploadCommand } = await import('../src/commands/upload.ts') +const { multiUploadCommand } = await import('../src/commands/multi-upload.ts') +const { balanceCommand } = await import('../src/commands/wallet/balance.ts') +const { costsCommand } = await import('../src/commands/wallet/costs.ts') +const { depositCommand } = await import('../src/commands/wallet/deposit.ts') +const { fundCommand } = await import('../src/commands/wallet/fund.ts') +const { summaryCommand } = await import('../src/commands/wallet/summary.ts') +const { withdrawCommand } = await import('../src/commands/wallet/withdraw.ts') +const { listCommand: providerListCommand } = await import( + '../src/commands/provider/list.ts' +) +const { createCommand: datasetCreateCommand } = await import( + '../src/commands/dataset/create.ts' +) +const { detailsCommand: datasetDetailsCommand } = await import( + '../src/commands/dataset/details.ts' +) +const { listCommand: datasetListCommand } = await import( + '../src/commands/dataset/list.ts' +) +const { terminateCommand: datasetTerminateCommand } = await import( + '../src/commands/dataset/terminate.ts' +) +const { uploadCommand: datasetUploadCommand } = await import( + '../src/commands/dataset/upload.ts' +) +const { listCommand: pieceListCommand } = await import( + '../src/commands/piece/list.ts' +) +const { removeCommand: pieceRemoveCommand } = await import( + '../src/commands/piece/remove.ts' +) + +const tempDirs: string[] = [] + +function commandContext({ + args = {}, + options = {}, +}: { + args?: Record + options?: Record +} = {}) { + return { + agent: true, + args, + options: { + chain: 314159, + ...options, + }, + ok(data: any) { + return data + }, + error(data: any) { + return data + }, + } +} + +async function tempFile(name: string, contents: string) { + const dir = await mkdtemp(path.join(tmpdir(), 'foc-cli-test-')) + tempDirs.push(dir) + const filePath = path.join(dir, name) + await writeFile(filePath, contents) + return filePath +} + +beforeEach(() => { + resetCommandMocks() +}) + +afterEach(async () => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop() + if (dir) await rm(dir, { recursive: true, force: true }) + } +}) + +describe('top-level upload commands', () => { + test('upload prepares storage, executes funding, uploads, and maps copy results', async () => { + const filePath = await tempFile('upload.txt', 'data') + const contexts = [{ id: 'ctx-primary' }] + const execute = mock(async () => ({ hash: '0xprepare' })) + + synapseStorage.createContexts.mockImplementation(async () => contexts) + synapseStorage.prepare.mockImplementation(async () => ({ + transaction: { execute }, + })) + synapseStorage.upload.mockImplementation(async () => ({ + pieceCid: cid('baga-upload'), + size: 4, + copies: [ + { + dataSetId: 42n, + retrievalUrl: 'https://provider.example/piece/baga-upload', + pieceId: 7n, + providerId: 77n, + isNewDataSet: true, + role: 'primary', + }, + ], + failedAttempts: [ + { + providerId: 78n, + role: 'secondary', + error: 'temporarily unavailable', + explicit: false, + toString() { + return this.error + }, + }, + ], + })) + + const result = await uploadCommand.run( + commandContext({ + args: { path: filePath }, + options: { copies: 3, withCDN: true }, + }) + ) + + expect(privateKeyClient).toHaveBeenCalledWith(314159) + expect(synapseConstructorArgs).toEqual([ + { client: fakeWalletClient, source: 'foc-cli' }, + ]) + expect(synapseStorage.createContexts).toHaveBeenCalledWith({ + copies: 3, + withCDN: true, + }) + expect(synapseStorage.prepare).toHaveBeenCalledWith({ + context: contexts, + dataSize: 4n, + }) + expect(execute).toHaveBeenCalled() + expect(synapseStorage.upload).toHaveBeenCalledWith(expect.anything(), { + contexts, + withCDN: true, + }) + expect(result.status).toBe('uploaded') + expect(result.result).toEqual({ + pieceCid: 'baga-upload', + pieceScannerUrl: 'https://pdp.vxb.ai/calibration/piece/baga-upload', + size: 4, + copyResults: [ + { + dataSetId: '42', + datasetScannerUrl: 'https://pdp.vxb.ai/calibration/dataset/42', + url: 'https://provider.example/piece/baga-upload', + pieceId: '7', + providerId: '77', + isNewDataSet: true, + providerRole: 'primary', + }, + ], + copyFailures: [ + { + providerId: '78', + role: 'secondary', + error: 'temporarily unavailable', + explicit: false, + }, + ], + }) + }) + + test('multi-upload stores pieces with the primary provider, pulls to secondaries, and commits every context', async () => { + const first = await tempFile('first.txt', 'one') + const second = await tempFile('second.txt', 'two') + const pieceCids = [cid('baga-one'), cid('baga-two')] + const primary = { + provider: { + name: 'Primary', + pdp: { serviceURL: 'https://primary.example' }, + }, + store: mock(async () => ({ pieceCid: pieceCids.shift() })), + pull: mock(async () => undefined), + commit: mock(async ({ onSubmitted }: any) => { + onSubmitted('0xprimary') + return { txHash: '0xprimary', pieceIds: [1n, 2n], dataSetId: 11n } + }), + getPieceUrl: (pieceCid: any) => + `https://primary.example/piece/${pieceCid.toString()}`, + } + const secondary = { + provider: { + name: 'Secondary', + pdp: { serviceURL: 'https://secondary.example' }, + }, + store: mock(async () => undefined), + pull: mock(async () => undefined), + commit: mock(async ({ onSubmitted }: any) => { + onSubmitted('0xsecondary') + return { txHash: '0xsecondary', pieceIds: [3n, 4n], dataSetId: 12n } + }), + getPieceUrl: (pieceCid: any) => + `https://secondary.example/piece/${pieceCid.toString()}`, + } + + synapseStorage.createContexts.mockImplementation(async () => [ + primary, + secondary, + ]) + + const result = await multiUploadCommand.run( + commandContext({ + args: { paths: [first, second] }, + options: { copies: 2, withCDN: false }, + }) + ) + + expect(synapseStorage.prepare).toHaveBeenCalledWith({ + context: [primary, secondary], + dataSize: 6n, + }) + expect(primary.store).toHaveBeenCalledTimes(2) + expect(secondary.pull).toHaveBeenCalledWith({ + pieces: [expect.anything(), expect.anything()], + from: 'https://primary.example', + }) + expect(primary.commit).toHaveBeenCalled() + expect(secondary.commit).toHaveBeenCalled() + expect(result.status).toBe('uploaded') + expect(result.results).toEqual([ + { + pieceCids: [ + { + pieceCid: 'baga-one', + pieceScannerUrl: 'https://pdp.vxb.ai/calibration/piece/baga-one', + url: 'https://primary.example/piece/baga-one', + }, + { + pieceCid: 'baga-two', + pieceScannerUrl: 'https://pdp.vxb.ai/calibration/piece/baga-two', + url: 'https://primary.example/piece/baga-two', + }, + ], + pieceIds: ['1', '2'], + providerName: 'Primary', + dataSetId: '11', + datasetScannerUrl: 'https://pdp.vxb.ai/calibration/dataset/11', + txHash: '0xprimary', + txExplorerUrl: 'https://calibration.filfox.info/en/tx/0xprimary', + }, + { + pieceCids: [ + { + pieceCid: 'baga-one', + pieceScannerUrl: 'https://pdp.vxb.ai/calibration/piece/baga-one', + url: 'https://secondary.example/piece/baga-one', + }, + { + pieceCid: 'baga-two', + pieceScannerUrl: 'https://pdp.vxb.ai/calibration/piece/baga-two', + url: 'https://secondary.example/piece/baga-two', + }, + ], + pieceIds: ['3', '4'], + providerName: 'Secondary', + dataSetId: '12', + datasetScannerUrl: 'https://pdp.vxb.ai/calibration/dataset/12', + txHash: '0xsecondary', + txExplorerUrl: 'https://calibration.filfox.info/en/tx/0xsecondary', + }, + ]) + }) + + test.todo( + 'multi-upload should fail when any requested file cannot be read instead of silently uploading the readable subset' + ) +}) + +describe('wallet commands', () => { + test('wallet balance reads FIL, USDFC, and payment account balances', async () => { + const result = await balanceCommand.run(commandContext()) + + expect(synapsePayments.walletBalance).toHaveBeenNthCalledWith(1) + expect(synapsePayments.walletBalance).toHaveBeenNthCalledWith(2, { + token: 'USDFC', + }) + expect(synapsePayments.accountInfo).toHaveBeenCalled() + expect(result).toMatchObject({ + address: fakeWalletClient.account.address, + fil: 'formatted:1000', + usdfc: 'formatted:2000', + availableFunds: 'formatted:3000', + lockupCurrent: 'formatted:4000', + lockupRate: 'formatted:5000', + lockupLastSettledAt: 'formatted:6000', + funds: 'formatted:7000', + }) + }) + + test('wallet deposit parses the amount, deposits with permit, and waits for the transaction', async () => { + const result = await depositCommand.run( + commandContext({ args: { amount: '5' } }) + ) + + expect(parseUnits).toHaveBeenCalledWith('5') + expect( + synapsePayments.depositWithPermitAndApproveOperator + ).toHaveBeenCalledWith({ amount: 5_000_000n }) + expect(synapseWaitForTransactionReceipt).toHaveBeenCalledWith({ + hash: '0xdeposit', + }) + expect(result).toMatchObject({ + status: 'deposited', + txHash: '0xdeposit', + txExplorerUrl: 'https://calibration.filfox.info/en/tx/0xdeposit', + }) + }) + + test('wallet withdraw parses the amount, withdraws, and waits for the transaction', async () => { + const result = await withdrawCommand.run( + commandContext({ args: { amount: '3' } }) + ) + + expect(parseUnits).toHaveBeenCalledWith('3') + expect(synapsePayments.withdraw).toHaveBeenCalledWith({ + amount: 3_000_000n, + }) + expect(synapseWaitForTransactionReceipt).toHaveBeenCalledWith({ + hash: '0xwithdraw', + }) + expect(result).toMatchObject({ + status: 'withdrawn', + txHash: '0xwithdraw', + txExplorerUrl: 'https://calibration.filfox.info/en/tx/0xwithdraw', + }) + }) + + test('wallet costs prepares storage for the requested bytes and runway', async () => { + const result = await costsCommand.run( + commandContext({ + options: { extraBytes: 1024, extraRunway: 2 }, + }) + ) + + expect(synapseStorage.prepare).toHaveBeenCalledWith({ + dataSize: 1024n, + extraRunwayEpochs: 172800n, + }) + expect(result).toEqual({ + newPerMonthRate: 'formatted:111', + depositNeeded: 'formatted:222', + alreadyCovered: true, + processLog: [{ step: 'Getting costs', status: 'done' }], + }) + }) + + test('wallet fund claims faucet tokens, waits for FIL, and returns updated balances', async () => { + const result = await fundCommand.run(commandContext()) + + expect(claimTokens).toHaveBeenCalledWith({ + address: fakeWalletClient.account.address, + }) + expect(waitForTransactionReceipt).toHaveBeenCalledWith(fakeWalletClient, { + hash: '0xfaucet', + }) + expect(synapsePayments.walletBalance).toHaveBeenNthCalledWith(1) + expect(synapsePayments.walletBalance).toHaveBeenNthCalledWith(2, { + token: 'USDFC', + }) + expect(result).toMatchObject({ + fil: 'formatted:1000', + usdfc: 'formatted:2000', + }) + }) + + test('wallet summary maps account summary balances and funding timeline', async () => { + const result = await summaryCommand.run(commandContext()) + + expect(getAccountSummary).toHaveBeenCalledWith(fakeWalletClient, { + address: fakeWalletClient.account.address, + }) + expect(result).toMatchObject({ + availableFunds: 'formatted:1', + timeRemaining: '1h 0d 0w 0m 0y', + totalLockup: 'formatted:2', + monthlyAccountRate: 'formatted:3', + monthlyStorageRate: 'formatted:4', + funds: 'formatted:5', + }) + }) +}) + +describe('provider command', () => { + test('provider list uses a public client and maps approved PDP provider details', async () => { + const result = await providerListCommand.run(commandContext()) + + expect(publicClient).toHaveBeenCalledWith(314159) + expect(getApprovedPDPProviders).toHaveBeenCalledWith({ + name: 'public-client', + }) + expect(formatBalance).toHaveBeenCalledWith({ value: 99n }) + expect(result.providers).toEqual([ + { + providerId: 77, + name: 'Provider 77', + description: 'Fast provider', + serviceProvider: 'f077', + payee: fakeProvider.payee, + isActive: true, + serviceURL: 'https://provider.example', + location: 'Earth', + minPieceSize: '1 KiB', + maxPieceSize: '1 MiB', + storagePricePerTibPerDay: 'formatted:99', + minProvingPeriodInEpochs: '2880', + paymentTokenAddress: '0x0000000000000000000000000000000000000abc', + ipniPiece: true, + ipniIpfs: false, + ipniPeerId: '12D3KooWProvider', + }, + ]) + expect(result.dealbotDashboard).toBe('https://staging.dealbot.filoz.org') + }) +}) + +describe('dataset commands', () => { + test('dataset create creates a data set for an explicit provider', async () => { + const result = await datasetCreateCommand.run( + commandContext({ + args: { providerId: 77 }, + options: { cdn: true }, + }) + ) + + expect(getPDPProvider).toHaveBeenCalledWith(fakeWalletClient, { + providerId: 77n, + }) + expect(createDataSet).toHaveBeenCalledWith(fakeWalletClient, { + payee: fakeProvider.payee, + payer: fakeWalletClient.account.address, + serviceURL: 'https://provider.example', + cdn: true, + }) + expect(waitForCreateDataSet).toHaveBeenCalledWith({ txHash: '0xcreate' }) + expect(result).toMatchObject({ + dataSetId: '42', + scannerUrl: 'https://pdp.vxb.ai/calibration/dataset/42', + providerId: '77', + }) + }) + + test('dataset create documents current behavior: providerId is required', async () => { + const result = await datasetCreateCommand.run(commandContext()) + + expect(result.error).toEqual({ + code: 'PROVIDER_REQUIRED', + message: 'providerId argument required in non-interactive mode', + retryable: true, + }) + expect(getPDPProvider).not.toHaveBeenCalled() + expect(createDataSet).not.toHaveBeenCalled() + }) + + test('dataset upload calculates the piece, uploads to the provider, and creates the dataset with metadata', async () => { + const filePath = await tempFile('dataset-file.txt', 'piece') + + const result = await datasetUploadCommand.run( + commandContext({ + args: { path: filePath, providerId: 77 }, + options: { cdn: false }, + }) + ) + + expect(calculate).toHaveBeenCalled() + expect(uploadPiece).toHaveBeenCalledWith({ + data: expect.any(Buffer), + serviceURL: 'https://provider.example', + pieceCid: expect.anything(), + }) + expect(findPiece).toHaveBeenCalledWith({ + pieceCid: expect.anything(), + serviceURL: 'https://provider.example', + retry: true, + }) + expect(createDataSetAndAddPieces).toHaveBeenCalledWith(fakeWalletClient, { + serviceURL: 'https://provider.example', + payee: fakeProvider.payee, + cdn: false, + pieces: [ + { + pieceCid: expect.anything(), + metadata: { name: 'dataset-file.txt' }, + }, + ], + }) + expect(waitForCreateDataSetAddPieces).toHaveBeenCalledWith({ + statusUrl: 'https://provider.example/status', + }) + expect(result).toMatchObject({ + pieceCid: 'baga-calculated', + pieceScannerUrl: 'https://pdp.vxb.ai/calibration/piece/baga-calculated', + dataSetId: '43', + datasetScannerUrl: 'https://pdp.vxb.ai/calibration/dataset/43', + pieceIds: ['8'], + }) + }) + + test('dataset list maps datasets and current block number', async () => { + const result = await datasetListCommand.run(commandContext()) + + expect(getPdpDataSets).toHaveBeenCalledWith(fakeWalletClient, { + address: fakeWalletClient.account.address, + }) + expect(getBlockNumber).toHaveBeenCalledWith(fakeWalletClient) + expect(result).toMatchObject({ + blockNumber: '123', + datasets: [ + { + dataSetId: '42', + scannerUrl: 'https://pdp.vxb.ai/calibration/dataset/42', + provider: fakeProvider.payee, + serviceURL: 'https://provider.example', + cdn: true, + live: true, + managed: false, + terminating: false, + }, + ], + }) + }) + + test('dataset details maps dataset fields and piece metadata', async () => { + const result = await datasetDetailsCommand.run( + commandContext({ options: { dataSetId: 42 } }) + ) + + expect(getPdpDataSet).toHaveBeenCalledWith(fakeWalletClient, { + dataSetId: 42n, + }) + expect(getPiecesWithMetadata).toHaveBeenCalledWith(fakeWalletClient, { + dataSet: expect.anything(), + address: fakeWalletClient.account.address, + }) + expect(result.dataset).toMatchObject({ + dataSetId: '42', + scannerUrl: 'https://pdp.vxb.ai/calibration/dataset/42', + provider: fakeProvider.payee, + serviceURL: 'https://provider.example', + cdn: true, + live: true, + managed: false, + terminating: false, + activePieceCount: '2', + metadata: { label: 'dataset' }, + }) + expect(result.pieces).toEqual([ + { + id: '7', + cid: 'baga-piece', + scannerUrl: 'https://pdp.vxb.ai/calibration/piece/baga-piece', + url: 'https://provider.example/piece/baga-piece', + metadata: { name: 'file.txt' }, + }, + ]) + }) + + test('dataset terminate calls Synapse Core and maps the termination event', async () => { + const result = await datasetTerminateCommand.run( + commandContext({ args: { dataSetId: 42 } }) + ) + + expect(terminateServiceSync).toHaveBeenCalledWith(fakeWalletClient, { + dataSetId: 42n, + onHash: expect.any(Function), + }) + expect(result).toMatchObject({ + dataSetId: '42', + scannerUrl: 'https://pdp.vxb.ai/calibration/dataset/42', + status: 'terminated', + }) + }) + + test.todo( + 'dataset details should return an object for empty piece metadata instead of the string "No metadata"' + ) +}) + +describe('piece commands', () => { + test('piece list maps pieces for a data set', async () => { + const result = await pieceListCommand.run( + commandContext({ args: { dataSetId: 42 } }) + ) + + expect(getPdpDataSet).toHaveBeenCalledWith(fakeWalletClient, { + dataSetId: 42n, + }) + expect(getPiecesWithMetadata).toHaveBeenCalledWith(fakeWalletClient, { + dataSet: expect.anything(), + address: fakeWalletClient.account.address, + }) + expect(result).toMatchObject({ + dataSetId: 42, + datasetScannerUrl: 'https://pdp.vxb.ai/calibration/dataset/42', + pieces: [ + { + id: '7', + cid: 'baga-piece', + scannerUrl: 'https://pdp.vxb.ai/calibration/piece/baga-piece', + metadata: { name: 'file.txt' }, + }, + ], + }) + }) + + test('piece remove schedules deletion and waits for the transaction', async () => { + const result = await pieceRemoveCommand.run( + commandContext({ args: { dataSetId: 42, pieceId: 7 } }) + ) + + expect(schedulePieceDeletion).toHaveBeenCalledWith(fakeWalletClient, { + dataSetId: 42n, + clientDataSetId: 100n, + pieceId: 7n, + serviceURL: 'https://provider.example', + }) + expect(waitForTransactionReceipt).toHaveBeenCalledWith(fakeWalletClient, { + hash: '0xremove', + }) + expect(result).toMatchObject({ + status: 'removed', + dataSetId: '42', + datasetScannerUrl: 'https://pdp.vxb.ai/calibration/dataset/42', + pieceId: '7', + }) + }) + + test.todo( + 'piece list should return dataSetId as a string to match its schema' + ) +}) diff --git a/skills/foc-cli/SKILL.md b/skills/foc-cli/SKILL.md index 42b3df1..d2364f6 100644 --- a/skills/foc-cli/SKILL.md +++ b/skills/foc-cli/SKILL.md @@ -81,7 +81,7 @@ npx foc-cli multi-upload ./a.pdf,./b.pdf | `wallet deposit ` | Deposit USDFC into payment account | | `wallet withdraw ` | Withdraw USDFC from payment account | | `wallet summary` | Account summary with funding timeline | -| `wallet costs [--extraBytes N] [--extraRunway N]` | Calculate upload costs + deposit needed | +| `wallet costs --extraBytes N --extraRunway N` | Calculate upload costs + deposit needed | ### Dataset Management @@ -89,7 +89,7 @@ npx foc-cli multi-upload ./a.pdf,./b.pdf |---------|-------------| | `dataset list` | All datasets with provider, CDN status, state | | `dataset details -d ` | Dataset metadata + all pieces | -| `dataset create [providerId] [--cdn]` | Create dataset (interactive provider selection if omitted) | +| `dataset create [--cdn]` | Create dataset with a provider from `provider list` | | `dataset upload [--cdn]` | Create dataset + upload in one step | | `dataset terminate ` | Stop PDP service for a dataset | From f36d770fe74b8cf17fd6fe2fdb371cf3a45e410c Mon Sep 17 00:00:00 2001 From: Mikers Date: Wed, 29 Apr 2026 08:53:09 -1000 Subject: [PATCH 2/4] upgrade synapse sdk and core --- README.md | 2 +- cli/bun.lock | 20 ++++++---- cli/package.json | 4 +- cli/src/commands/dataset/details.ts | 5 +-- cli/src/commands/multi-upload.ts | 25 ++++++++++-- cli/src/commands/piece/list.ts | 2 +- cli/tests/synapse-commands.test.ts | 59 ++++++++++++++++++++++++----- skills/foc-cli/SKILL.md | 6 +-- 8 files changed, 92 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 1ca7de8..d99e888 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Every command supports `-h` for full usage details. ```bash npx foc-cli upload # Upload with auto provider/dataset npx foc-cli upload --withCDN --copies 3 # CDN + 3 redundant copies -npx foc-cli multi-upload ./a.pdf,./b.pdf # Batch upload +npx foc-cli multi-upload ./a.pdf,./b.pdf # Batch upload; all paths must be readable ``` ### Wallet diff --git a/cli/bun.lock b/cli/bun.lock index a8c7f96..a015564 100644 --- a/cli/bun.lock +++ b/cli/bun.lock @@ -6,11 +6,11 @@ "name": "synapse-cli", "dependencies": { "@clack/prompts": "^1.0.0", - "@filoz/synapse-core": "^0.3.1", - "@filoz/synapse-sdk": "^0.40.0", + "@filoz/synapse-core": "^0.4.1", + "@filoz/synapse-sdk": "^0.40.4", "@remix-run/fs": "^0.4.1", "conf": "^15.0.2", - "incur": "latest", + "incur": "^0.3.1", "terminal-link": "^5.0.0", "viem": "^2.47.1", }, @@ -54,9 +54,9 @@ "@clack/prompts": ["@clack/prompts@1.1.0", "", { "dependencies": { "@clack/core": "1.1.0", "sisteransi": "^1.0.5" } }, "sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g=="], - "@filoz/synapse-core": ["@filoz/synapse-core@0.3.1", "", { "dependencies": { "@web3-storage/data-segment": "^5.3.0", "dnum": "^2.15.0", "iso-web": "^2.1.0", "multiformats": "^13.4.1", "ox": "^0.14.0", "p-locate": "^7.0.0", "p-retry": "^7.1.0", "p-some": "^7.0.0", "zod": "^4.3.5" }, "peerDependencies": { "viem": "2.x" } }, "sha512-ulcuWFK7FbA6u4cAdJtNzqjxnIjCXm0OX2Zdr2erv6pD2TbuGW/Af9NpPW232Q1MlNUuVG1YSY/NPuo7P5cyIw=="], + "@filoz/synapse-core": ["@filoz/synapse-core@0.4.1", "", { "dependencies": { "@web3-storage/data-segment": "^5.3.0", "dnum": "^2.15.0", "iso-web": "^2.2.0", "multiformats": "^13.4.1", "ox": "^0.14.0", "p-locate": "^7.0.0", "p-queue": "^9.1.2", "p-some": "^7.0.0", "zod": "^4.3.5" }, "peerDependencies": { "viem": "2.x" } }, "sha512-Psj2YpIxNh+nxJN0wQdYMBTQRRhq1gR/C9kosI39Kx6y+lV8ppw02c6mPeHEaa47AG3KfUqQyMb3xqurOlwraQ=="], - "@filoz/synapse-sdk": ["@filoz/synapse-sdk@0.40.0", "", { "dependencies": { "@filoz/synapse-core": "^0.3.1", "multiformats": "^13.4.1" }, "peerDependencies": { "viem": "2.x" } }, "sha512-CJDkER9LGlWi34F01SzQyKrYU10OAx1WSyaWygs4jlxX8J2X3tV83iVa5kJwCkuymvhbjVMd1SiBEmb3Pdjk0g=="], + "@filoz/synapse-sdk": ["@filoz/synapse-sdk@0.40.4", "", { "dependencies": { "@filoz/synapse-core": "^0.4.1", "multiformats": "^13.4.1" }, "peerDependencies": { "viem": "2.x" } }, "sha512-MRofQ3EixagTglo3nqWPvTyw15LtlPIFryCWN+1swgZk7JTSuFIqVunBphF5R5yFiMC0bbGohBCqrI4CIw+eCQ=="], "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], @@ -230,7 +230,7 @@ "iso-kv": ["iso-kv@3.1.1", "", { "dependencies": { "conf": "^15.0.2", "idb-keyval": "^6.2.1", "kysely": "^0.28.8" } }, "sha512-yKTLmUCc8gl0MXJs3ZaTaNDgfG/2ROasZERKPa5aYY7Ks/eb8BvGfLHrC+t1cHxiRkogvaXulDP77ovwLKgLPg=="], - "iso-web": ["iso-web@2.1.0", "", { "dependencies": { "delay": "^7.0.0", "iso-kv": "^3.1.1", "p-retry": "^7.1.0" } }, "sha512-8l+JF8vtcr1CGRU4yXiltoZzT5HTYQkXNfXSs8n64VqbjCnTBW/yv1xEP5G7VTes7DTQJfbIwt/cLoMOSIAWng=="], + "iso-web": ["iso-web@2.2.1", "", { "dependencies": { "delay": "^7.0.0", "is-network-error": "^1.3.1", "iso-kv": "^3.1.1", "p-retry": "^8.0.0" } }, "sha512-4pkaxMAK089Gt+ua046Y0vGu7V7V7+P/2fEzlxYK0ssMvQFufaqkETHhoid+422L/kjU7aw9j1BxorpG6jmtXw=="], "isows": ["isows@1.0.7", "", { "peerDependencies": { "ws": "*" } }, "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg=="], @@ -284,10 +284,14 @@ "p-locate": ["p-locate@7.0.0", "", { "dependencies": { "p-limit": "^7.2.0" } }, "sha512-FRPW2lT1b/B8/CNkCOZ/Xl4mz52CWzwb+/dLa0GcCrH7u7djFf36VftuRJ5w/eCr1YXtbTGPuGoEDVSk14EwNQ=="], - "p-retry": ["p-retry@7.1.1", "", { "dependencies": { "is-network-error": "^1.1.0" } }, "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w=="], + "p-queue": ["p-queue@9.2.0", "", { "dependencies": { "eventemitter3": "^5.0.4", "p-timeout": "^7.0.0" } }, "sha512-dWgLE8AH0HjQ9fe74pUkKkvzzYT18Inp4zra3lKHnnwqGvcfcUBrvF2EAVX+envufDNBOzpPq/IBUONDbI7+3g=="], + + "p-retry": ["p-retry@8.0.0", "", { "dependencies": { "is-network-error": "^1.3.0" } }, "sha512-kFVqH1HxOHp8LupNsOys7bSV09VYTRLxarH/mokO4Rqhk6wGi70E0jh4VzvVGXfEVNggHoHLAMWsQqHyU1Ey9A=="], "p-some": ["p-some@7.0.0", "", {}, "sha512-9ldWF6puBzuchsUq7M1THjwwmoiXesqRdpB4WH0D7urKXdGkIaDqVhQS2BSfRRYZ970j9gm3U4/h9hHQx2G1Ug=="], + "p-timeout": ["p-timeout@7.0.1", "", {}, "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -389,5 +393,7 @@ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], + + "p-queue/eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], } } diff --git a/cli/package.json b/cli/package.json index 3879e15..9e3fa7b 100644 --- a/cli/package.json +++ b/cli/package.json @@ -50,8 +50,8 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@filoz/synapse-core": "^0.3.1", - "@filoz/synapse-sdk": "^0.40.0", + "@filoz/synapse-core": "^0.4.1", + "@filoz/synapse-sdk": "^0.40.4", "@remix-run/fs": "^0.4.1", "conf": "^15.0.2", "incur": "^0.3.1", diff --git a/cli/src/commands/dataset/details.ts b/cli/src/commands/dataset/details.ts index 257e2cc..8e0b181 100644 --- a/cli/src/commands/dataset/details.ts +++ b/cli/src/commands/dataset/details.ts @@ -82,10 +82,7 @@ export const detailsCommand = { cid, scannerUrl: pieceScannerUrl(cid, chain), url: piece.url, - metadata: - Object.keys(piece.metadata).length > 0 - ? piece.metadata - : 'No metadata', + metadata: piece.metadata, } }) diff --git a/cli/src/commands/multi-upload.ts b/cli/src/commands/multi-upload.ts index e6b4c0c..394e622 100644 --- a/cli/src/commands/multi-upload.ts +++ b/cli/src/commands/multi-upload.ts @@ -25,14 +25,14 @@ type CopyResult = { export const multiUploadCommand = { description: - 'Upload multiple files to Filecoin warm storage (high-level, recommended)', + 'Upload multiple readable files to Filecoin warm storage (high-level, recommended)', args: z.object({ paths: z .preprocess( (val) => (typeof val === 'string' ? val.split(',') : val), z.array(z.string()) ) - .describe('File paths to upload (comma-separated for CLI)'), + .describe('File paths to upload. All paths must be readable.'), }), options: z.object({ chain: z @@ -72,7 +72,7 @@ export const multiUploadCommand = { { args: { paths: ['./myfile.pdf', './myfile2.pdf'] }, options: { copies: 3, withCDN: true }, - description: 'Upload with auto provider/dataset selection', + description: 'Upload readable files with auto provider/dataset selection', }, { args: { paths: ['./data.bin', './data2.bin'] }, @@ -99,6 +99,25 @@ export const multiUploadCommand = { const fileResultsSettled = await Promise.allSettled( absolutePaths.map((filePath: string) => readFile(filePath)) ) + const fileReadRejected = fileResultsSettled + .map((result, index) => ({ result, path: absolutePaths[index] })) + .filter(({ result }) => result.status === 'rejected') + + if (fileReadRejected.length > 0) { + return out.fail( + 'FILE_READ_FAILED', + fileReadRejected + .map(({ result, path }) => { + const reason = + result.status === 'rejected' ? result.reason : undefined + return `${path}: ${ + reason instanceof Error ? reason.message : String(reason) + }` + }) + .join(', ') + ) + } + const fileResults = fileResultsSettled .filter((result) => result.status === 'fulfilled') .map((result) => result.value) diff --git a/cli/src/commands/piece/list.ts b/cli/src/commands/piece/list.ts index 226a841..7d1b525 100644 --- a/cli/src/commands/piece/list.ts +++ b/cli/src/commands/piece/list.ts @@ -63,7 +63,7 @@ export const listCommand = { return out.done( { - dataSetId: c.args.dataSetId, + dataSetId: c.args.dataSetId.toString(), datasetScannerUrl: datasetScannerUrl(c.args.dataSetId, chain), pieces: piecesList, }, diff --git a/cli/tests/synapse-commands.test.ts b/cli/tests/synapse-commands.test.ts index 3c89776..846a533 100644 --- a/cli/tests/synapse-commands.test.ts +++ b/cli/tests/synapse-commands.test.ts @@ -300,9 +300,21 @@ describe('top-level upload commands', () => { ]) }) - test.todo( - 'multi-upload should fail when any requested file cannot be read instead of silently uploading the readable subset' - ) + test('multi-upload fails when any requested file cannot be read instead of silently uploading the readable subset', async () => { + const readable = await tempFile('readable.txt', 'ok') + const missing = path.join(path.dirname(readable), 'missing.txt') + + const result = await multiUploadCommand.run( + commandContext({ + args: { paths: [readable, missing] }, + }) + ) + + expect(result.error.code).toBe('FILE_READ_FAILED') + expect(result.error.message).toContain(missing) + expect(synapseStorage.createContexts).not.toHaveBeenCalled() + expect(synapseStorage.upload).not.toHaveBeenCalled() + }) }) describe('wallet commands', () => { @@ -609,9 +621,32 @@ describe('dataset commands', () => { }) }) - test.todo( - 'dataset details should return an object for empty piece metadata instead of the string "No metadata"' - ) + test('dataset details returns an object for empty piece metadata', async () => { + getPiecesWithMetadata.mockImplementationOnce(async () => ({ + pieces: [ + { + id: 8n, + cid: cid('baga-empty-metadata'), + url: 'https://provider.example/piece/baga-empty-metadata', + metadata: {}, + }, + ], + })) + + const result = await datasetDetailsCommand.run( + commandContext({ options: { dataSetId: 42 } }) + ) + + expect(result.pieces).toEqual([ + { + id: '8', + cid: 'baga-empty-metadata', + scannerUrl: 'https://pdp.vxb.ai/calibration/piece/baga-empty-metadata', + url: 'https://provider.example/piece/baga-empty-metadata', + metadata: {}, + }, + ]) + }) }) describe('piece commands', () => { @@ -628,7 +663,7 @@ describe('piece commands', () => { address: fakeWalletClient.account.address, }) expect(result).toMatchObject({ - dataSetId: 42, + dataSetId: '42', datasetScannerUrl: 'https://pdp.vxb.ai/calibration/dataset/42', pieces: [ { @@ -663,7 +698,11 @@ describe('piece commands', () => { }) }) - test.todo( - 'piece list should return dataSetId as a string to match its schema' - ) + test('piece list returns dataSetId as a string to match its schema', async () => { + const result = await pieceListCommand.run( + commandContext({ args: { dataSetId: 42 } }) + ) + + expect(result.dataSetId).toBe('42') + }) }) diff --git a/skills/foc-cli/SKILL.md b/skills/foc-cli/SKILL.md index d2364f6..8aa48df 100644 --- a/skills/foc-cli/SKILL.md +++ b/skills/foc-cli/SKILL.md @@ -63,12 +63,12 @@ All commands accept these — not repeated per-command below: | Command | Description | |---------|-------------| | `upload [--copies N] [--withCDN]` | Upload file. Auto-selects provider, creates dataset. Default 2 copies. | -| `multi-upload [--copies N] [--withCDN]` | Batch upload. Comma-separated paths. | +| `multi-upload [--copies N] [--withCDN]` | Batch upload. Comma-separated paths; all paths must be readable. | ```bash npx foc-cli upload ./file.pdf # simplest npx foc-cli upload ./file.pdf --withCDN --copies 3 -npx foc-cli multi-upload ./a.pdf,./b.pdf +npx foc-cli multi-upload ./a.pdf,./b.pdf # all paths must be readable ``` ### Wallet & Payments @@ -122,7 +122,7 @@ npx foc-cli wallet balance ```bash npx foc-cli upload ./myfile.pdf # auto everything npx foc-cli upload ./myfile.pdf --withCDN # with CDN -npx foc-cli multi-upload ./a.pdf,./b.pdf --copies 3 # batch, 3 copies +npx foc-cli multi-upload ./a.pdf,./b.pdf --copies 3 # batch, 3 copies; all paths must be readable npx foc-cli wallet costs --extraBytes 1000000 --extraRunway 1 # check costs first ``` From 725bdd1383ab522b0acecbc228f4a21cae1d5b67 Mon Sep 17 00:00:00 2001 From: Mikers Date: Wed, 29 Apr 2026 08:56:20 -1000 Subject: [PATCH 3/4] add cli ci workflow --- .github/workflows/ci.yml | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9964741 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + cli: + name: CLI + runs-on: ubuntu-latest + + defaults: + run: + working-directory: cli + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.13 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run tests + run: bun run test + + - name: Check formatting and lint + run: bunx biome check src tests + + - name: Typecheck + run: bunx tsc --noEmit + + - name: Build + run: bun run build From f50706ce923d9289d4949d42920bb3fb8d15d253 Mon Sep 17 00:00:00 2001 From: Mikers Date: Wed, 29 Apr 2026 12:36:01 -1000 Subject: [PATCH 4/4] require dataset provider id in schema --- cli/src/commands/dataset/create.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/cli/src/commands/dataset/create.ts b/cli/src/commands/dataset/create.ts index 2441af7..da1f05b 100644 --- a/cli/src/commands/dataset/create.ts +++ b/cli/src/commands/dataset/create.ts @@ -10,7 +10,6 @@ export const createCommand = { args: z.object({ providerId: z.coerce .number() - .optional() .describe('Provider ID. Use provider list to choose one.'), }), options: z.object({