Skip to content

Commit ed50ca6

Browse files
authored
CLI hardening: provider health checks, readable errors, incur 0.4.8 (#22)
* chore(deps): upgrade incur to 0.4.8, drop @remix-run/fs, require node >=22 incur 0.4.x requires Node >=22; also removes the unused @remix-run/fs dependency. * fix(cli): read version from package.json The hardcoded version had drifted (0.0.4 vs 0.1.1), so --version reported the wrong number. * chore: remove dead link helpers from utils datasetLink, pieceLink, and dealbotLink were unused (flagged by knip); commands use the *ScannerUrl helpers. * test: extend command mocks and unexport unused fixtures Add mocks for provider-selection input + fetch health checks, piece hasMore, costs needsFwssMaxApproval, and createDataSet statusUrl (asserting statusUrl is forwarded). Drop the export keyword from fixtures only used in-file (flagged by knip). * fix(output): render the real error code and message on failure OutputContext.fail passed a nested { error } object, but incur's c.error reads code/message off the top level, so every failure printed 'code: null, message: null'. Pass them at the top level; incur rebuilds the envelope. (incur's error path has no slot for processLog, so the step trail is surfaced on success only.) * fix(wallet): drop duplicate monthly rate and report approval need summary emitted monthlyAccountRate and monthlyStorageRate with identical values; keep a single account rate. costs now surfaces needsFwssMaxApproval so callers can tell 'needs deposit' from 'needs approval only'. * feat(dataset,piece): paginate piece listings with a next-page CTA Add --offset/--limit to dataset details and piece list; return hasMore/nextOffset and a CTA with the next-page command when more pieces remain (previously capped silently at 100). * feat(upload): health-check providers before context selection The SDK's smartSelect requires an endorsed primary and pings with a 1s budget that excludes healthy-but-slow providers, so uploads failed even when providers were reachable. Pre-select reachable providers ourselves (endorsed first, falling back to a healthy approved provider; reduce copies if fewer are reachable) and pass explicit providerIds, bypassing smartSelect. * docs(skills): refresh foc-cli and foc-docs skills Document the pagination flags and needsFwssMaxApproval, replace the stale pricing minimum with the v1 model, sharpen triggering, and clarify the foc-cli vs foc-docs boundary. * refactor(cli): centralize Synapse construction in synapseClient Replace the seven duplicated `new Synapse({ client, source })` sites with a single `synapseClient(chainId)` helper returning { client, chain, synapse }. The source tag is resolved in one place — from the config store, defaulting to "foc-cli". * feat(wallet): set the Synapse source tag via init Persist `source` in the config store; `wallet init --source <name>` writes it (defaults to "foc-cli"). Document the setting in the README and skill. * chore(lint): format tests with biome and lint the tests dir The lint script only formatted src/, so test files drifted from biome's style and CI's `biome check src tests` failed. Format them and add tests/ to the lint script so local matches CI.
1 parent 0b7a677 commit ed50ca6

25 files changed

Lines changed: 633 additions & 315 deletions

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,14 @@ npx foc-cli docs --url <url> # Fetch specific page
121121
| `--format <fmt>` | `toon` | Output format: `toon`, `json`, `yaml`, `md` |
122122
| `--json` | | Shorthand for `--format json` |
123123

124+
### Source tag
125+
126+
The `source` string the CLI reports to Synapse/Warm Storage (telemetry & attribution) is stored in your config. Set it to identify your app or integration (defaults to `foc-cli`):
127+
128+
```bash
129+
npx foc-cli wallet init --source my-app
130+
```
131+
124132
## How FOC Works
125133

126134
FOC transforms Filecoin into a **programmable cloud storage layer**:

cli/bun.lock

Lines changed: 6 additions & 207 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/package.json

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
"prepublishOnly": "rm -rf dist && tsc && cp ../README.md ../LICENSE . && cp -r ../skills .",
2222
"postpublish": "rm -f README.md LICENSE && rm -rf skills",
2323
"test": "bun test",
24-
"lint": "tsc --noEmit && biome check --fix src/"
24+
"lint": "tsc --noEmit && biome check --fix src/ tests/"
2525
},
2626
"keywords": [
2727
"filecoin",
@@ -46,15 +46,14 @@
4646
},
4747
"homepage": "https://github.com/FIL-Builders/foc-cli#readme",
4848
"engines": {
49-
"node": ">=18"
49+
"node": ">=22"
5050
},
5151
"dependencies": {
5252
"@clack/prompts": "^1.0.0",
5353
"@filoz/synapse-core": "^0.7.0",
5454
"@filoz/synapse-sdk": "^1.0.1",
55-
"@remix-run/fs": "^0.4.1",
5655
"conf": "^15.0.2",
57-
"incur": "^0.3.1",
56+
"incur": "^0.4.8",
5857
"terminal-link": "^5.0.0",
5958
"viem": "^2.47.1"
6059
},

cli/src/commands/dataset/details.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ export const detailsCommand = {
1313
.number()
1414
.default(314159)
1515
.describe('Chain ID. 314159 = Calibration, 314 = Mainnet'),
16+
offset: z.coerce
17+
.number()
18+
.default(0)
19+
.describe('Piece offset to start from (for pagination)'),
20+
limit: z.coerce
21+
.number()
22+
.default(100)
23+
.describe('Max pieces per page (defaults to 100)'),
1624
debug: z.boolean().optional().describe('Enable debug mode'),
1725
}),
1826
alias: { chain: 'c', dataSetId: 'd' },
@@ -38,6 +46,8 @@ export const detailsCommand = {
3846
metadata: z.record(z.string(), z.string()),
3947
})
4048
),
49+
hasMore: z.boolean(),
50+
nextOffset: z.number().optional(),
4151
}),
4252
async run(c: any) {
4353
const out = new OutputContext(c)
@@ -56,10 +66,15 @@ export const detailsCommand = {
5666
)
5767
}
5868

69+
const offset = c.options.offset ?? 0
70+
const limit = c.options.limit ?? 100
71+
5972
out.step('Fetching pieces and metadata')
60-
const { pieces } = await getPiecesWithMetadata(client, {
73+
const { pieces, hasMore } = await getPiecesWithMetadata(client, {
6174
dataSet: ds,
6275
address: client.account.address,
76+
offset: BigInt(offset),
77+
limit: BigInt(limit),
6378
})
6479

6580
const dataset = {
@@ -86,11 +101,32 @@ export const detailsCommand = {
86101
}
87102
})
88103

104+
const nextOffset = offset + piecesList.length
105+
const nextPage = hasMore
106+
? [
107+
{
108+
command: 'dataset details',
109+
options: {
110+
dataSetId: c.options.dataSetId,
111+
offset: nextOffset,
112+
limit,
113+
},
114+
description: `Show the next page of pieces (offset ${nextOffset})`,
115+
},
116+
]
117+
: []
118+
89119
return out.done(
90-
{ dataset, pieces: piecesList },
120+
{
121+
dataset,
122+
pieces: piecesList,
123+
hasMore,
124+
...(hasMore ? { nextOffset } : {}),
125+
},
91126
{
92127
cta: {
93128
commands: [
129+
...nextPage,
94130
{
95131
command: 'piece remove',
96132
description: 'Remove a piece from this dataset',

cli/src/commands/multi-upload.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { readFile } from 'node:fs/promises'
22
import path from 'node:path'
3-
import { Synapse } from '@filoz/synapse-sdk'
43
import type { StorageContext } from '@filoz/synapse-sdk/storage'
54
import { z } from 'incur'
65
import type { Hex } from 'viem'
7-
import { privateKeyClient } from '../client.ts'
86
import { OutputContext } from '../output.ts'
7+
import { selectHealthyProviders } from '../provider-selection.ts'
8+
import { synapseClient } from '../synapse.ts'
99
import {
1010
datasetScannerUrl,
1111
hashLink,
@@ -89,7 +89,7 @@ export const multiUploadCommand = {
8989
],
9090
async run(c: any) {
9191
const out = new OutputContext(c)
92-
const { client, chain } = privateKeyClient(c.options.chain)
92+
const { client, chain, synapse } = synapseClient(c.options.chain)
9393

9494
try {
9595
out.step('Reading files')
@@ -132,11 +132,25 @@ export const multiUploadCommand = {
132132
})
133133
)
134134

135-
const synapse = new Synapse({ client, source: 'foc-cli' })
135+
out.step('Checking provider health')
136+
const selection = await selectHealthyProviders(
137+
client,
138+
c.options.copies ?? 2
139+
)
140+
if (selection.usedUnendorsedPrimary) {
141+
out.info(
142+
`No endorsed provider reachable — using approved provider ${selection.primaryName} for the primary copy.`
143+
)
144+
}
145+
if (selection.reducedCopies) {
146+
out.info(
147+
`Storing ${selection.selectedCopies} of ${selection.requestedCopies} requested copies (${selection.reachableCount} of ${selection.approvedCount} providers reachable).`
148+
)
149+
}
136150

137151
out.step('Creating storage contexts')
138152
const contexts = await synapse.storage.createContexts({
139-
copies: c.options.copies,
153+
providerIds: selection.providerIds,
140154
withCDN: c.options.withCDN,
141155
})
142156

cli/src/commands/piece/list.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ export const listCommand = {
1515
.number()
1616
.default(314159)
1717
.describe('Chain ID. 314159 = Calibration, 314 = Mainnet'),
18+
offset: z.coerce
19+
.number()
20+
.default(0)
21+
.describe('Piece offset to start from (for pagination)'),
22+
limit: z.coerce
23+
.number()
24+
.default(100)
25+
.describe('Max pieces per page (defaults to 100)'),
1826
debug: z.boolean().optional().describe('Enable debug mode'),
1927
}),
2028
alias: { chain: 'c' },
@@ -29,6 +37,8 @@ export const listCommand = {
2937
metadata: z.record(z.string(), z.string()),
3038
})
3139
),
40+
hasMore: z.boolean(),
41+
nextOffset: z.number().optional(),
3242
}),
3343
examples: [
3444
{ args: { dataSetId: 42 }, description: 'List pieces in dataset #42' },
@@ -45,10 +55,15 @@ export const listCommand = {
4555
if (!dataSet)
4656
return out.fail('NOT_FOUND', `Dataset ${c.args.dataSetId} not found`)
4757

58+
const offset = c.options.offset ?? 0
59+
const limit = c.options.limit ?? 100
60+
4861
out.step('Fetching pieces')
49-
const { pieces } = await getPiecesWithMetadata(client, {
62+
const { pieces, hasMore } = await getPiecesWithMetadata(client, {
5063
dataSet,
5164
address: client.account.address,
65+
offset: BigInt(offset),
66+
limit: BigInt(limit),
5267
})
5368

5469
const piecesList = pieces.map((piece: any) => {
@@ -61,15 +76,30 @@ export const listCommand = {
6176
}
6277
})
6378

79+
const nextOffset = offset + piecesList.length
80+
const nextPage = hasMore
81+
? [
82+
{
83+
command: 'piece list',
84+
args: { dataSetId: c.args.dataSetId },
85+
options: { offset: nextOffset, limit },
86+
description: `Show the next page of pieces (offset ${nextOffset})`,
87+
},
88+
]
89+
: []
90+
6491
return out.done(
6592
{
6693
dataSetId: c.args.dataSetId.toString(),
6794
datasetScannerUrl: datasetScannerUrl(c.args.dataSetId, chain),
6895
pieces: piecesList,
96+
hasMore,
97+
...(hasMore ? { nextOffset } : {}),
6998
},
7099
{
71100
cta: {
72101
commands: [
102+
...nextPage,
73103
{
74104
command: 'piece remove',
75105
args: { dataSetId: c.args.dataSetId },

cli/src/commands/upload.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { readFile } from 'node:fs/promises'
22
import path from 'node:path'
33
import type { FailedAttempt } from '@filoz/synapse-sdk'
4-
import { Synapse } from '@filoz/synapse-sdk'
54
import { z } from 'incur'
6-
import { privateKeyClient } from '../client.ts'
75
import { OutputContext } from '../output.ts'
6+
import { selectHealthyProviders } from '../provider-selection.ts'
7+
import { synapseClient } from '../synapse.ts'
88
import { datasetScannerUrl, hashLink, pieceScannerUrl } from '../utils.ts'
99

1010
export const uploadCommand = {
@@ -75,7 +75,7 @@ export const uploadCommand = {
7575
],
7676
async run(c: any) {
7777
const out = new OutputContext(c)
78-
const { client, chain } = privateKeyClient(c.options.chain)
78+
const { client, chain, synapse } = synapseClient(c.options.chain)
7979

8080
try {
8181
out.step('Reading file')
@@ -88,11 +88,25 @@ export const uploadCommand = {
8888
},
8989
})
9090

91-
const synapse = new Synapse({ client, source: 'foc-cli' })
91+
out.step('Checking provider health')
92+
const selection = await selectHealthyProviders(
93+
client,
94+
c.options.copies ?? 2
95+
)
96+
if (selection.usedUnendorsedPrimary) {
97+
out.info(
98+
`No endorsed provider reachable — using approved provider ${selection.primaryName} for the primary copy.`
99+
)
100+
}
101+
if (selection.reducedCopies) {
102+
out.info(
103+
`Storing ${selection.selectedCopies} of ${selection.requestedCopies} requested copies (${selection.reachableCount} of ${selection.approvedCount} providers reachable).`
104+
)
105+
}
92106

93107
out.step('Creating storage contexts')
94108
const contexts = await synapse.storage.createContexts({
95-
copies: c.options.copies,
109+
providerIds: selection.providerIds,
96110
withCDN: c.options.withCDN,
97111
})
98112

cli/src/commands/wallet/balance.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { formatBalance } from '@filoz/synapse-core/utils'
2-
import { Synapse, TOKENS } from '@filoz/synapse-sdk'
2+
import { TOKENS } from '@filoz/synapse-sdk'
33
import { z } from 'incur'
4-
import { privateKeyClient } from '../../client.ts'
54
import { OutputContext } from '../../output.ts'
5+
import { synapseClient } from '../../synapse.ts'
66

77
export const balanceCommand = {
88
description: 'Check FIL and USDFC wallet balances and payment account info',
@@ -29,11 +29,11 @@ export const balanceCommand = {
2929
],
3030
async run(c: any) {
3131
const out = new OutputContext(c)
32-
const { client } = privateKeyClient(c.options.chain)
32+
const { client, synapse } = synapseClient(c.options.chain)
3333

3434
try {
3535
out.step('Checking wallet balance')
36-
const result = await fetchBalances(client)
36+
const result = await fetchBalances(client, synapse)
3737

3838
return out.done(result)
3939
} catch (error) {
@@ -42,8 +42,7 @@ export const balanceCommand = {
4242
},
4343
}
4444

45-
async function fetchBalances(client: any) {
46-
const synapse = new Synapse({ client, source: 'foc-cli' })
45+
async function fetchBalances(client: any, synapse: any) {
4746
const filBalance = await synapse.payments.walletBalance()
4847
const usdfcBalance = await synapse.payments.walletBalance({
4948
token: TOKENS.USDFC,

cli/src/commands/wallet/costs.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { formatBalance } from '@filoz/synapse-core/utils'
2-
import { Synapse } from '@filoz/synapse-sdk'
32
import { z } from 'incur'
4-
import { privateKeyClient } from '../../client.ts'
53
import { OutputContext } from '../../output.ts'
4+
import { synapseClient } from '../../synapse.ts'
65

76
export const costsCommand = {
87
description: 'Get costs for uploading a file to Filecoin warm storage',
@@ -20,6 +19,7 @@ export const costsCommand = {
2019
newPerMonthRate: z.string(),
2120
depositNeeded: z.string(),
2221
alreadyCovered: z.boolean(),
22+
needsFwssMaxApproval: z.boolean(),
2323
}),
2424
examples: [
2525
{
@@ -33,13 +33,11 @@ export const costsCommand = {
3333
],
3434
async run(c: any) {
3535
const out = new OutputContext(c)
36-
const { client } = privateKeyClient(c.options.chain)
36+
const { synapse } = synapseClient(c.options.chain)
3737

3838
try {
3939
out.step('Getting costs')
4040

41-
const synapse = new Synapse({ client, source: 'foc-cli' })
42-
4341
const prep = await synapse.storage.prepare({
4442
dataSize: BigInt(c.options.extraBytes),
4543
extraRunwayEpochs: BigInt(c.options.extraRunway * 30 * 24 * 60 * 2),
@@ -50,8 +48,14 @@ export const costsCommand = {
5048
})
5149
const depositNeeded = formatBalance({ value: prep.costs.depositNeeded })
5250
const alreadyCovered = prep.costs.ready
51+
const needsFwssMaxApproval = prep.costs.needsFwssMaxApproval
5352

54-
return out.done({ newPerMonthRate, depositNeeded, alreadyCovered })
53+
return out.done({
54+
newPerMonthRate,
55+
depositNeeded,
56+
alreadyCovered,
57+
needsFwssMaxApproval,
58+
})
5559
} catch (error) {
5660
if (c.options.debug) console.error(error)
5761
return out.fail('COSTS_FAILED', (error as Error).message)

cli/src/commands/wallet/deposit.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { parseUnits, Synapse } from '@filoz/synapse-sdk'
1+
import { parseUnits } from '@filoz/synapse-sdk'
22
import { z } from 'incur'
3-
import { privateKeyClient } from '../../client.ts'
43
import { OutputContext } from '../../output.ts'
4+
import { synapseClient } from '../../synapse.ts'
55
import { hashLink, txExplorerUrl } from '../../utils.ts'
66

77
export const depositCommand = {
@@ -32,8 +32,7 @@ export const depositCommand = {
3232
],
3333
async run(c: any) {
3434
const out = new OutputContext(c)
35-
const { client, chain } = privateKeyClient(c.options.chain)
36-
const synapse = new Synapse({ client, source: 'foc-cli' })
35+
const { chain, synapse } = synapseClient(c.options.chain)
3736

3837
try {
3938
out.step('Depositing funds')

0 commit comments

Comments
 (0)