diff --git a/bin.js b/bin.js index 1670ea7..0d81d6a 100755 --- a/bin.js +++ b/bin.js @@ -14,6 +14,7 @@ import { addProof, listProofs, upload, + remove, list, whoami } from './index.js' @@ -48,6 +49,12 @@ cli.command('ls') .option('--shards', 'Pretty print with shards in output') .action(list) +cli.command('rm ') + .example('rm bafy...') + .describe('Remove an upload from the uploads listing. Pass --shards to delete the actual data if you are sure no other uploads need them') + .option('--shards', 'Remove all shards referenced by the upload from the store. Use with caution and ensure other uploads do not reference the same shards.') + .action(remove) + cli.command('whoami') .describe('Print information about the current agent.') .action(whoami) diff --git a/index.js b/index.js index 4f1fb27..5dcca5b 100644 --- a/index.js +++ b/index.js @@ -1,7 +1,8 @@ import fs from 'fs' -import ora from 'ora' +import ora, { oraPromise } from 'ora' import tree from 'pretty-tree' import { Readable } from 'stream' +import { CID } from 'multiformats/cid' import * as DID from '@ipld/dag-ucan/did' import { CarWriter } from '@ipld/car' import { getClient, checkPathsExist, filesize, readProof, filesFromPaths } from './lib.js' @@ -75,6 +76,55 @@ export async function list (opts) { console.log('⁂ Try out `w3 up ` 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 (err) { + console.error(`Error: ${rootCid} is not a CID`) + process.exit(1) + } + const client = await getClient() + let upload + try { + upload = await client.capability.upload.remove(root) + } catch (err) { + console.error(`Remove failed: ${err.message ?? err}`) + console.error(err) + process.exit(1) + } + if (!opts.shards) { + return + } + if (!upload) { + return console.log('⁂ upload not found. could not determine shards to remove.') + } + if (!upload.shards || !upload.shards.length) { + return console.log('⁂ no shards to remove.') + } + + const { shards } = upload + console.log(`⁂ removing ${shards.length} shard${shards.length === 1 ? '' : 's'}`) + + function removeShard (shard) { + return oraPromise(client.capability.store.remove(shard), { + text: `${shard}`, + successText: `${shard} removed`, + failText: `${shard} failed` + }) + } + + const results = await Promise.allSettled(shards.map(removeShard)) + + if (results.some(res => res.status === 'rejected')) { + process.exit(1) + } +} /** * @param {string} name diff --git a/test/bin.spec.js b/test/bin.spec.js index e09de67..e0c8d79 100644 --- a/test/bin.spec.js +++ b/test/bin.spec.js @@ -167,6 +167,102 @@ test('w3 ls', async (t) => { t.notThrows(() => CID.parse(JSON.parse(list1.stdout).root)) }) +test('w3 remove', async t => { + const env = t.context.env.alice + + await execa('./bin.js', ['space', 'create'], { env }) + + const service = mockService({ + upload: { + remove: provide(UploadCapabilities.remove, ({ invocation }) => { + const { nb } = invocation.capabilities[0] + return { root: nb.root } + }) + } + }) + t.context.setService(service) + + t.throwsAsync(() => execa('./bin.js', ['rm', 'nope'], { env }), { message: /not a CID/ }) + + const rm = await execa('./bin.js', ['rm', 'bafybeih2k7ughhfwedltjviunmn3esueijz34snyay77zmsml5w24tqamm'], { env }) + t.is(rm.exitCode, 0) + t.is(service.upload.remove.callCount, 1) + t.is(service.store.remove.callCount, 0) + t.is(rm.stdout, '') +}) + +test('w3 remove - no such upload', async t => { + const env = t.context.env.alice + + await execa('./bin.js', ['space', 'create'], { env }) + + const service = mockService({ + upload: { + remove: provide(UploadCapabilities.remove, () => {}) + } + }) + t.context.setService(service) + + const rm = await execa('./bin.js', ['rm', 'bafybeih2k7ughhfwedltjviunmn3esueijz34snyay77zmsml5w24tqamm', '--shards'], { env }) + t.is(rm.exitCode, 0) + t.is(rm.stdout, '⁂ upload not found. could not determine shards to remove.') +}) + +test('w3 remove --shards', async t => { + const env = t.context.env.alice + + await execa('./bin.js', ['space', 'create'], { env }) + + const service = mockService({ + store: { + remove: provide(StoreCapabilities.remove, () => {}) + }, + upload: { + remove: provide(UploadCapabilities.remove, ({ invocation }) => { + const { nb } = invocation.capabilities[0] + return { + root: nb.root, + shards: [ + CID.parse('bagbaiera7ciaeifwrn7oo35gxdalocfj23vkvqus2eup27wt2qcxlvta2wya'), + CID.parse('bagbaiera7ciaeifwrn7oo35gxdalocfj23vkvqus2eup27wt2qcxlvta2wya') + ] + } + }) + } + }) + t.context.setService(service) + + const rm = await execa('./bin.js', ['rm', 'bafybeih2k7ughhfwedltjviunmn3esueijz34snyay77zmsml5w24tqamm', '--shards'], { env }) + t.is(rm.exitCode, 0) + t.is(service.upload.remove.callCount, 1) + t.is(service.store.remove.callCount, 2) +}) + +test('w3 remove --shards - no shards to remove', async t => { + const env = t.context.env.alice + + await execa('./bin.js', ['space', 'create'], { env }) + + const service = mockService({ + store: { + remove: provide(StoreCapabilities.remove, () => {}) + }, + upload: { + remove: provide(UploadCapabilities.remove, ({ invocation }) => { + const { nb } = invocation.capabilities[0] + return { root: nb.root } + }) + } + }) + t.context.setService(service) + + const rm = await execa('./bin.js', ['rm', 'bafybeih2k7ughhfwedltjviunmn3esueijz34snyay77zmsml5w24tqamm', '--shards'], { env }) + t.is(rm.exitCode, 0) + t.is(service.upload.remove.callCount, 1) + t.is(service.store.remove.callCount, 0) + t.is(rm.stdout, '⁂ no shards to remove.') +}) + test('w3 delegation create', async t => { const env = t.context.env.alice