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 ```