UNPKG

@web3-storage/w3cli

Version:

💾 w3 command line interface

694 lines (653 loc) 20.1 kB
import fs from 'node:fs' import { pipeline } from 'node:stream/promises' import { Readable } from 'node:stream' import ora from 'ora' import { CID } from 'multiformats/cid' import { base64 } from 'multiformats/bases/base64' import { identity } from 'multiformats/hashes/identity' import * as DID from '@ipld/dag-ucan/did' import * as dagJSON from '@ipld/dag-json' import { CarWriter } from '@ipld/car' import { filesFromPaths } from 'files-from-path' import * as Account from './account.js' import { spaceAccess } from '@web3-storage/w3up-client/capability/access' import { AgentData } from '@web3-storage/access' import * as Space from './space.js' import { getClient, getStore, checkPathsExist, filesize, filesizeMB, readProof, readProofFromBytes, uploadListResponseToString, startOfLastMonth, pieceHasher, } from './lib.js' import * as ucanto from '@ucanto/core' import { ed25519 } from '@ucanto/principal' import chalk from 'chalk' export * as Coupon from './coupon.js' export * as Bridge from './bridge.js' export { Account, Space } import ago from 's-ago' /** * */ export async function accessClaim() { const client = await getClient() await client.capability.access.claim() } /** * @param {string} email */ export const getPlan = async (email = '') => { const client = await getClient() const account = email === '' ? await Space.selectAccount(client) : await Space.useAccount(client, { email }) if (account) { const { ok: plan, error } = await account.plan.get() if (plan) { console.log(`⁂ ${plan.product}`) } else if (error?.name === 'PlanNotFound') { console.log('⁂ no plan has been selected yet') } else { console.error(`Failed to get plan - ${error.message}`) process.exit(1) } } else { process.exit(1) } } /** * @param {`${string}@${string}`} email * @param {object} [opts] * @param {import('@ucanto/interface').Ability[]|import('@ucanto/interface').Ability} [opts.can] */ export async function authorize(email, opts = {}) { const client = await getClient() const capabilities = opts.can != null ? [opts.can].flat().map((can) => ({ can })) : undefined /** @type {import('ora').Ora|undefined} */ let spinner setTimeout(() => { spinner = ora( `🔗 please click the link we sent to ${email} to authorize this agent` ).start() }, 1000) try { await client.authorize(email, { capabilities }) } catch (err) { if (spinner) spinner.stop() console.error(err) process.exit(1) } if (spinner) spinner.stop() console.log(`⁂ agent authorized to use capabilities delegated to ${email}`) } /** * @param {string} firstPath * @param {{ * _: string[], * car?: boolean * hidden?: boolean * json?: boolean * verbose?: boolean * wrap?: boolean * 'shard-size'?: number * 'concurrent-requests'?: number * }} [opts] */ export async function upload(firstPath, opts) { /** @type {import('@web3-storage/w3up-client/types').FileLike[]} */ let files /** @type {number} */ let totalSize // -1 when unknown size (input from stdin) /** @type {import('ora').Ora} */ let spinner const client = await getClient() if (firstPath) { const paths = checkPathsExist([firstPath, ...(opts?._ ?? [])]) const hidden = !!opts?.hidden spinner = ora({ text: 'Reading files', isSilent: opts?.json }).start() const localFiles = await filesFromPaths(paths, { hidden }) totalSize = localFiles.reduce((total, f) => total + f.size, 0) files = localFiles spinner.stopAndPersist({ text: `${files.length} file${files.length === 1 ? '' : 's'} ${chalk.dim( filesize(totalSize) )}`, }) if (opts?.car && files.length > 1) { console.error('Error: multiple CAR files not supported') process.exit(1) } } else { spinner = ora({ text: 'Reading from stdin', isSilent: opts?.json }).start() files = [{ name: 'stdin', stream: () => /** @type {ReadableStream} */ (Readable.toWeb(process.stdin)) }] totalSize = -1 opts = opts ?? { _: [] } opts.wrap = false } spinner.start('Storing') /** @type {(o?: import('@web3-storage/w3up-client/src/types').UploadOptions) => Promise<import('@web3-storage/w3up-client/src/types').AnyLink>} */ const uploadFn = opts?.car ? client.uploadCAR.bind(client, files[0]) : files.length === 1 && opts?.wrap === false ? client.uploadFile.bind(client, files[0]) : client.uploadDirectory.bind(client, files) let totalSent = 0 const getStoringMessage = () => totalSize == -1 // for unknown size, display the amount sent so far ? `Storing ${filesizeMB(totalSent)}` // for known size, display percentage of total size that has been sent : `Storing ${Math.min(Math.round((totalSent / totalSize) * 100), 100)}%` const root = await uploadFn({ pieceHasher, onShardStored: ({ cid, size, piece }) => { totalSent += size if (opts?.verbose) { spinner.stopAndPersist({ text: `${cid} ${chalk.dim(filesizeMB(size))}\n${chalk.dim( ' └── ' )}Piece CID: ${piece}`, }) spinner.start(getStoringMessage()) } else { spinner.text = getStoringMessage() } opts?.json && opts?.verbose && console.log(dagJSON.stringify({ shard: cid, size, piece })) }, shardSize: opts?.['shard-size'] && parseInt(String(opts?.['shard-size'])), concurrentRequests: opts?.['concurrent-requests'] && parseInt(String(opts?.['concurrent-requests'])), receiptsEndpoint: client._receiptsEndpoint.toString() }) spinner.stopAndPersist({ symbol: '⁂', text: `Stored ${files.length} file${files.length === 1 ? '' : 's'}`, }) console.log( opts?.json ? dagJSON.stringify({ root }) : `⁂ https://w3s.link/ipfs/${root}` ) } /** * Print out all the uploads in the current space. * * @param {object} opts * @param {boolean} [opts.json] * @param {boolean} [opts.shards] */ export async function list(opts = {}) { const client = await getClient() let count = 0 /** @type {import('@web3-storage/w3up-client/types').UploadListSuccess|undefined} */ let res do { res = await client.capability.upload.list({ cursor: res?.cursor }) if (!res) throw new Error('missing upload list response') count += res.results.length if (res.results.length) { console.log(uploadListResponseToString(res, opts)) } } while (res.cursor && res.results.length) if (count === 0 && !opts.json) { console.log('⁂ No uploads in space') console.log('⁂ Try out `w3 up <path to files>` to upload some') } } /** * @param {string} rootCid * @param {object} opts * @param {boolean} [opts.shards] */ export async function remove(rootCid, opts) { let root try { root = CID.parse(rootCid.trim()) } catch (/** @type {any} */ err) { console.error(`Error: ${rootCid} is not a CID`) process.exit(1) } const client = await getClient() try { await client.remove(root, opts) } catch (/** @type {any} */ err) { console.error(`Remove failed: ${err.message ?? err}`) console.error(err) process.exit(1) } } /** * @param {string} name */ export async function createSpace(name) { const client = await getClient() const space = await client.createSpace(name, { skipGatewayAuthorization: true }) await client.setCurrentSpace(space.did()) console.log(space.did()) } /** * @param {string} proofPathOrCid */ export async function addSpace(proofPathOrCid) { const client = await getClient() let cid try { cid = CID.parse(proofPathOrCid, base64) } catch (/** @type {any} */ err) { if (err?.message?.includes('Unexpected end of data')) { console.error(`Error: failed to read proof. The string has been truncated.`) process.exit(1) } /* otherwise, try as path */ } let delegation if (cid) { if (cid.multihash.code !== identity.code) { console.error(`Error: failed to read proof. Must be identity CID. Fetching of remote proof CARs not supported by this command yet`) process.exit(1) } delegation = await readProofFromBytes(cid.multihash.digest) } else { delegation = await readProof(proofPathOrCid) } const space = await client.addSpace(delegation) console.log(space.did()) } /** * */ export async function listSpaces() { const client = await getClient() const current = client.currentSpace() for (const space of client.spaces()) { const prefix = current && current.did() === space.did() ? '* ' : ' ' console.log(`${prefix}${space.did()} ${space.name ?? ''}`) } } /** * @param {string} did */ export async function useSpace(did) { const client = await getClient() const spaces = client.spaces() const space = spaces.find((s) => s.did() === did) ?? spaces.find((s) => s.name === did) if (!space) { console.error(`Error: space not found: ${did}`) process.exit(1) } await client.setCurrentSpace(space.did()) console.log(space.did()) } /** * @param {object} opts * @param {import('@web3-storage/w3up-client/types').DID} [opts.space] * @param {string} [opts.json] */ export async function spaceInfo(opts) { const client = await getClient() const spaceDID = opts.space ?? client.currentSpace()?.did() if (!spaceDID) { throw new Error( 'no current space and no space given: please use --space to specify a space or select one using "space use"' ) } /** @type {import('@web3-storage/access/types').SpaceInfoResult} */ let info try { info = await client.capability.space.info(spaceDID) } catch (/** @type {any} */ err) { // if the space was not known to the service then that's ok, there's just // no info to print about it. Don't make it look like something is wrong, // just print the space DID since that's all we know. if (err.name === 'SpaceUnknown') { // @ts-expect-error spaceDID should be a did:key info = { did: spaceDID } } else { return console.log(`Error getting info about ${spaceDID}: ${err.message}`) } } const space = client.spaces().find((s) => s.did() === spaceDID) const name = space ? space.name : undefined if (opts.json) { console.log(JSON.stringify({ ...info, name }, null, 4)) } else { const providers = info.providers?.join(', ') ?? '' console.log(` DID: ${info.did} Providers: ${providers || chalk.dim('none')} Name: ${name ?? chalk.dim('none')}`) } } /** * @param {string} audienceDID * @param {object} opts * @param {string[]|string} opts.can * @param {string} [opts.name] * @param {string} [opts.type] * @param {number} [opts.expiration] * @param {string} [opts.output] * @param {string} [opts.with] * @param {boolean} [opts.base64] */ export async function createDelegation(audienceDID, opts) { const client = await getClient() if (client.currentSpace() == null) { throw new Error('no current space, use `w3 space create` to create one.') } const audience = DID.parse(audienceDID) const abilities = opts.can ? [opts.can].flat() : Object.keys(spaceAccess) if (!abilities.length) { console.error('Error: missing capabilities for delegation') process.exit(1) } const audienceMeta = {} if (opts.name) audienceMeta.name = opts.name if (opts.type) audienceMeta.type = opts.type const expiration = opts.expiration || Infinity // @ts-expect-error createDelegation should validate abilities const delegation = await client.createDelegation(audience, abilities, { expiration, audienceMeta, }) const { writer, out } = CarWriter.create() const dest = opts.output ? fs.createWriteStream(opts.output) : process.stdout pipeline( out, async function* maybeBaseEncode(src) { const chunks = [] for await (const chunk of src) { if (!opts.base64) { yield chunk } else { chunks.push(chunk) } } if (!opts.base64) return const blob = new Blob(chunks) const bytes = new Uint8Array(await blob.arrayBuffer()) const idCid = CID.createV1(ucanto.CAR.code, identity.digest(bytes)) yield idCid.toString(base64) }, dest ) for (const block of delegation.export()) { // @ts-expect-error await writer.put(block) } await writer.close() } /** * @param {object} opts * @param {boolean} [opts.json] */ export async function listDelegations(opts) { const client = await getClient() const delegations = client.delegations() if (opts.json) { for (const delegation of delegations) { console.log( JSON.stringify({ cid: delegation.cid.toString(), audience: delegation.audience.did(), capabilities: delegation.capabilities.map((c) => ({ with: c.with, can: c.can, })), }) ) } } else { for (const delegation of delegations) { console.log(delegation.cid.toString()) console.log(` audience: ${delegation.audience.did()}`) for (const capability of delegation.capabilities) { console.log(` with: ${capability.with}`) console.log(` can: ${capability.can}`) } } } } /** * @param {string} delegationCid * @param {object} opts * @param {string} [opts.proof] */ export async function revokeDelegation(delegationCid, opts) { const client = await getClient() let proof try { if (opts.proof) { proof = await readProof(opts.proof) } } catch (/** @type {any} */ err) { console.log(`Error: reading proof: ${err.message}`) process.exit(1) } let cid try { // TODO: we should validate that this is a UCANLink cid = ucanto.parseLink(delegationCid.trim()) } catch (/** @type {any} */ err) { console.error(`Error: invalid CID: ${delegationCid}: ${err.message}`) process.exit(1) } const result = await client.revokeDelegation( /** @type {import('@ucanto/interface').UCANLink} */ (cid), { proofs: proof ? [proof] : [] } ) if (result.ok) { console.log(`⁂ delegation ${delegationCid} revoked`) } else { console.error(`Error: revoking ${delegationCid}: ${result.error?.message}`) process.exit(1) } } /** * @param {string} proofPath * @param {{ json?: boolean, 'dry-run'?: boolean }} [opts] */ export async function addProof(proofPath, opts) { const client = await getClient() let proof try { proof = await readProof(proofPath) if (!opts?.['dry-run']) { await client.addProof(proof) } } catch (/** @type {any} */ err) { console.log(`Error: ${err.message}`) process.exit(1) } if (opts?.json) { console.log(JSON.stringify(proof.toJSON())) } else { console.log(proof.cid.toString()) console.log(` issuer: ${proof.issuer.did()}`) for (const capability of proof.capabilities) { console.log(` with: ${capability.with}`) console.log(` can: ${capability.can}`) } } } /** * @param {object} opts * @param {boolean} [opts.json] */ export async function listProofs(opts) { const client = await getClient() const proofs = client.proofs() if (opts.json) { for (const proof of proofs) { console.log(JSON.stringify(proof)) } } else { for (const proof of proofs) { console.log(chalk.dim(`# ${proof.cid.toString()}`)) console.log(`iss: ${chalk.cyanBright(proof.issuer.did())}`) console.log(`aud: ${chalk.cyanBright(proof.audience.did())}`) if (proof.expiration !== Infinity) { console.log( `exp: ${chalk.yellow(proof.expiration)} ${chalk.dim( ` # expires ${ago(new Date(proof.expiration * 1000))}` )}` ) } console.log('att:') for (const capability of proof.capabilities) { console.log(` - can: ${chalk.magentaBright(capability.can)}`) console.log(` with: ${chalk.green(capability.with)}`) if (capability.nb) { console.log(` nb: ${JSON.stringify(capability.nb)}`) } } if (proof.facts.length > 0) { console.log('fct:') } for (const fact of proof.facts) { console.log(` - ${JSON.stringify(fact)}`) } console.log('') } console.log( chalk.dim( `# ${proofs.length} proof${ proofs.length === 1 ? '' : 's' } for ${client.agent.did()}` ) ) } } /** * */ export async function whoami() { const client = await getClient() console.log(client.did()) } /** * @param {object} [opts] * @param {boolean} [opts.human] * @param {boolean} [opts.json] */ export async function usageReport(opts) { const client = await getClient() const now = new Date() const period = { // we may not have done a snapshot for this month _yet_, so get report from last month -> now from: startOfLastMonth(now), to: now, } const failures = [] let total = 0 for await (const result of getSpaceUsageReports( client, period )) { if ('error' in result) { failures.push(result) } else { if (opts?.json) { const { account, provider, space, size } = result console.log( dagJSON.stringify({ account, provider, space, size, reportedAt: now.toISOString(), }) ) } else { const { account, provider, space, size } = result console.log(` Account: ${account}`) console.log(`Provider: ${provider}`) console.log(` Space: ${space}`) console.log( ` Size: ${opts?.human ? filesize(size.final) : size.final}\n` ) } total += result.size.final } } if (!opts?.json) { console.log(` Total: ${opts?.human ? filesize(total) : total}`) if (failures.length) { console.warn(``) console.warn(` WARNING: there were ${failures.length} errors getting usage reports for some spaces.`) console.warn(` This may happen if your agent does not have usage/report authorization for a space.`) console.warn(` These spaces were not included in the usage report total:`) for (const fail of failures) { console.warn(` * space: ${fail.space}`) // @ts-expect-error error is unknown console.warn(` error: ${fail.error?.message}`) console.warn(` account: ${fail.account}`) } } } } /** * @param {import('@web3-storage/w3up-client').Client} client * @param {{ from: Date, to: Date }} period */ async function* getSpaceUsageReports(client, period) { for (const account of Object.values(client.accounts())) { const subscriptions = await client.capability.subscription.list( account.did() ) for (const { consumers } of subscriptions.results) { for (const space of consumers) { /** @type {import('@web3-storage/upload-client/types').UsageReportSuccess} */ let result try { result = await client.capability.usage.report(space, period) } catch (error) { yield { error, space, period, consumers, account: account.did() } continue } for (const [, report] of Object.entries(result)) { yield { account: account.did(), ...report } } } } } } /** * @param {{ json: boolean }} options */ export async function createKey({ json }) { const signer = await ed25519.generate() const key = ed25519.format(signer) if (json) { console.log(JSON.stringify({ did: signer.did(), key }, null, 2)) } else { console.log(`# ${signer.did()}`) console.log(key) } } export const reset = async () => { const store = getStore() const exportData = await store.load() if (exportData) { let data = AgentData.fromExport(exportData) // do not reset the principal data = await AgentData.create({ principal: data.principal, meta: data.meta }) await store.save(data.export()) } console.log('⁂ Agent reset.') }