-
Notifications
You must be signed in to change notification settings - Fork 4
feat(community): add bitsocial community export command
#65
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
b246b8b
chore(deps): upgrade @pkcprotocol/pkc-js to 0.0.41
Rinse12 53821e4
feat(community): add `bitsocial community export` command (#64)
Rinse12 779a85f
fix(community): address CodeRabbit review on export command (#65)
Rinse12 52c5ac1
feat(community): default export destination to <dataPath>/exports/<ad…
Rinse12 0e40b4f
fix(daemon): stop express's catch-all 404 from clobbering /exports do…
Rinse12 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,249 @@ | ||
| import { Args, Flags } from "@oclif/core"; | ||
| import { BaseCommand } from "../../base-command.js"; | ||
| import defaults from "../../../common-utils/defaults.js"; | ||
| import { PKCLogger } from "../../../util.js"; | ||
|
|
||
| import { createHash } from "node:crypto"; | ||
| import { createWriteStream } from "node:fs"; | ||
| import fs from "node:fs/promises"; | ||
| import path from "node:path"; | ||
| import { Readable } from "node:stream"; | ||
| import { pipeline } from "node:stream/promises"; | ||
|
|
||
| // Minimal shape of the export records emitted by pkc-js over RPC (CommunityExportRecord). | ||
| // Kept local so we don't deep-import from pkc-js dist paths (see daemon.ts's @ts-expect-error imports). | ||
| type ExportRecord = { | ||
| exportId: string; | ||
| name?: string; | ||
| publicKey: string; | ||
| includePrivateKey: boolean; | ||
| progress: number; | ||
| size?: number; | ||
| sha256?: string; | ||
| url?: string; | ||
| error?: { code: string; message: string }; | ||
| }; | ||
|
|
||
| type ExportableCommunity = { | ||
| address: string; | ||
| exports: ExportRecord[]; | ||
| export: (options?: { includePrivateKey?: boolean; signal?: AbortSignal }) => Promise<{ exportId: string }>; | ||
| on: (event: string, listener: (records: ExportRecord[]) => void) => void; | ||
| removeListener: (event: string, listener: (records: ExportRecord[]) => void) => void; | ||
| }; | ||
|
|
||
| export default class Export extends BaseCommand { | ||
| static override description = | ||
| "Export a local community to a SQLite snapshot file. The export runs on the RPC server (daemon); once finished the snapshot is downloaded and its sha256 checksum is verified. Pass --includePrivateKey to produce a restorable backup that keeps the community's address."; | ||
|
|
||
| static override examples = [ | ||
| "bitsocial community export plebmusic.bso", | ||
| "bitsocial community export plebmusic.bso --includePrivateKey -o ./backups/plebmusic.sqlite", | ||
| "bitsocial community export --name my-community", | ||
| "bitsocial community export --publicKey 12D3KooWG3XbzoVyAE6Y9vHZKF64Yuuu4TjdgQKedk14iYmTEPWu" | ||
| ]; | ||
|
|
||
| static override args = { | ||
| address: Args.string({ | ||
| name: "address", | ||
| required: false, | ||
| description: "Address of the community to export" | ||
| }) | ||
| }; | ||
|
|
||
| static override flags = { | ||
| name: Flags.string({ | ||
| description: "Name of the community to export" | ||
| }), | ||
| publicKey: Flags.string({ | ||
| description: "Public key of the community to export" | ||
| }), | ||
| path: Flags.string({ | ||
| char: "o", | ||
| description: "Destination file for the downloaded snapshot (default: <dataPath>/exports/<address>_<datetime>.sqlite)" | ||
| }), | ||
| includePrivateKey: Flags.boolean({ | ||
| default: false, | ||
| description: | ||
| "Ask the RPC server to include the community signer's private key in the export. Required for a restorable backup that keeps the same community address. The daemon may refuse (see `bitsocial daemon --no-allowPrivateKeyExport`)" | ||
| }), | ||
| force: Flags.boolean({ | ||
| default: false, | ||
| description: "Overwrite the destination file if it already exists" | ||
| }), | ||
| quiet: Flags.boolean({ | ||
| char: "q", | ||
| default: false, | ||
| description: "Suppress progress output; only print the path of the downloaded snapshot" | ||
| }) | ||
| }; | ||
|
|
||
| private _printProgress(quiet: boolean, message: string) { | ||
| if (!quiet) process.stderr.write(message); | ||
| } | ||
|
|
||
| async run(): Promise<void> { | ||
| const { args, flags } = await this.parse(Export); | ||
|
|
||
| const log = PKCLogger("bitsocial-cli:commands:community:export"); | ||
| log(`args: `, args); | ||
| log(`flags: `, flags); | ||
|
|
||
| const lookupParam: Record<string, string> = {}; | ||
| if (args.address) lookupParam.address = args.address; | ||
| if (flags.name) lookupParam.name = flags.name; | ||
| if (flags.publicKey) lookupParam.publicKey = flags.publicKey; | ||
|
|
||
| if (Object.keys(lookupParam).length === 0) { | ||
| this.error("At least one of address argument, --name, or --publicKey must be provided"); | ||
| } | ||
|
|
||
| const pkc = await this._connectToPkcRpc(flags.pkcRpcUrl.toString()); | ||
|
|
||
| // Cancel the in-flight export server-side on Ctrl+C. A second Ctrl+C force-exits | ||
| // (the handler is registered with `once`, so the default SIGINT behavior is restored). | ||
| const abortController = new AbortController(); | ||
| const onSigint = () => { | ||
| this._printProgress(flags.quiet, "\nCancelling export... (Ctrl+C again to force exit)\n"); | ||
| abortController.abort(); | ||
| }; | ||
| process.once("SIGINT", onSigint); | ||
|
|
||
| try { | ||
| const community = (await pkc.createCommunity(lookupParam)) as unknown as Partial<ExportableCommunity>; | ||
| if (typeof community.export !== "function") { | ||
| this.error( | ||
| `Community is not local to the RPC server at ${flags.pkcRpcUrl}. Only communities created on this daemon can be exported` | ||
| ); | ||
| } | ||
| const exportableCommunity = community as ExportableCommunity; | ||
|
|
||
| // Datetime in the filename matches the daemon log convention (ISO 8601 with ':' → '-') | ||
| // so repeated exports never collide and snapshots sort chronologically | ||
| const defaultFilename = `${exportableCommunity.address}_${new Date().toISOString().replace(/:/g, "-")}.sqlite`; | ||
| const destPath = path.resolve(flags.path ?? path.join(defaults.PKC_DATA_PATH, "exports", defaultFilename)); | ||
| const destExists = await fs | ||
| .stat(destPath) | ||
| .then(() => true) | ||
| .catch(() => false); | ||
| if (destExists && !flags.force) { | ||
| this.error(`Destination file already exists: ${destPath}. Use --force to overwrite it`); | ||
| } | ||
|
|
||
| const finishedRecord = await this._runExport(exportableCommunity, flags.includePrivateKey, abortController.signal, flags.quiet); | ||
| log("Export finished on the RPC server", finishedRecord); | ||
|
|
||
| if (!finishedRecord.url) { | ||
| this.error(`Export ${finishedRecord.exportId} finished but the RPC server did not provide a download URL`); | ||
| } | ||
|
|
||
| // Mirrors pkc-js's rpcHttpOrigin: the ws[s]:// RPC URL with the protocol swapped to http[s]:// | ||
| const parsedRpcUrl = new URL(flags.pkcRpcUrl.toString()); | ||
| const expectedDownloadOrigin = `${parsedRpcUrl.protocol === "wss:" ? "https:" : "http:"}//${parsedRpcUrl.host}`; | ||
|
|
||
| await this._downloadAndVerify(finishedRecord, destPath, abortController.signal, flags.quiet, expectedDownloadOrigin); | ||
|
|
||
| this.log(destPath); | ||
| } catch (e) { | ||
| console.error(e); | ||
| await pkc.destroy(); | ||
| this.exit(1); | ||
| } finally { | ||
| process.removeListener("SIGINT", onSigint); | ||
| } | ||
| await pkc.destroy(); | ||
| } | ||
|
|
||
| /** Start the export on the RPC server and resolve with the terminal record (progress === 1). */ | ||
| private async _runExport( | ||
| community: ExportableCommunity, | ||
| includePrivateKey: boolean, | ||
| signal: AbortSignal, | ||
| quiet: boolean | ||
| ): Promise<ExportRecord> { | ||
| const { exportId } = await community.export({ includePrivateKey, signal }); | ||
|
|
||
| return new Promise<ExportRecord>((resolve, reject) => { | ||
| let lastPrintedPercent = -1; | ||
| const cleanup = () => { | ||
| community.removeListener("exportschange", checkRecords); | ||
| signal.removeEventListener("abort", onAbort); | ||
| }; | ||
| // Don't wait for the server's terminal ERR_EXPORT_CANCELLED record — it never arrives if the | ||
| // daemon died or the connection dropped. pkc-js's own abort listener (registered inside | ||
| // community.export(), before this one) already dispatched cancelExport() to the server. | ||
| const onAbort = () => { | ||
| cleanup(); | ||
| reject(new Error("Export cancelled")); | ||
| }; | ||
| const checkRecords = (records: ExportRecord[]) => { | ||
| const record = records.find((rec) => rec.exportId === exportId); | ||
| if (!record) return; | ||
| if (record.error) { | ||
| cleanup(); | ||
| reject(new Error(`Export failed (${record.error.code}): ${record.error.message}`)); | ||
| } else if (record.progress === 1) { | ||
| cleanup(); | ||
| this._printProgress(quiet, `\rExporting ${community.address}: 100%\n`); | ||
| resolve(record); | ||
| } else { | ||
| const percent = Math.floor(record.progress * 100); | ||
| if (percent !== lastPrintedPercent) { | ||
| lastPrintedPercent = percent; | ||
| this._printProgress(quiet, `\rExporting ${community.address}: ${percent}%`); | ||
| } | ||
| } | ||
| }; | ||
| community.on("exportschange", checkRecords); | ||
| signal.addEventListener("abort", onAbort, { once: true }); | ||
| if (signal.aborted) return onAbort(); | ||
| // The terminal notification may have arrived before the listener was attached | ||
| checkRecords(community.exports); | ||
| }); | ||
| } | ||
|
|
||
| /** Download the finished snapshot to destPath, verifying its sha256 against the export record. */ | ||
| private async _downloadAndVerify(record: ExportRecord, destPath: string, signal: AbortSignal, quiet: boolean, expectedOrigin: string) { | ||
| // The export download contract is GET <rpc-http-origin>/exports/<exportId> — refuse anything else | ||
| // so a misconfigured/compromised RPC server can't use the CLI to fetch arbitrary URLs | ||
| const downloadUrl = new URL(record.url!); | ||
| if (downloadUrl.origin !== expectedOrigin || !downloadUrl.pathname.startsWith("/exports/")) { | ||
| this.error( | ||
| `Refusing to download export from unexpected URL ${record.url} (expected ${expectedOrigin}/exports/<exportId>)` | ||
| ); | ||
| } | ||
| this._printProgress(quiet, `Downloading snapshot from ${downloadUrl}\n`); | ||
| const response = await fetch(downloadUrl, { signal }); | ||
| if (!response.ok || !response.body) { | ||
| this.error(`Failed to download export from ${record.url}: HTTP ${response.status}`); | ||
| } | ||
|
|
||
| await fs.mkdir(path.dirname(destPath), { recursive: true }); | ||
| // Download to a .partial file so an interrupted/corrupted download never clobbers destPath | ||
| const partialPath = destPath + ".partial"; | ||
| const hash = createHash("sha256"); | ||
| try { | ||
| await pipeline( | ||
| Readable.fromWeb(response.body as import("node:stream/web").ReadableStream), | ||
| async function* (source) { | ||
| for await (const chunk of source) { | ||
| hash.update(chunk as Buffer); | ||
| yield chunk; | ||
| } | ||
| }, | ||
| createWriteStream(partialPath) | ||
| ); | ||
| const downloadedSha256 = hash.digest("hex"); | ||
| if (record.sha256 && downloadedSha256 !== record.sha256) { | ||
| throw new Error( | ||
| `sha256 mismatch for downloaded export: expected ${record.sha256} but downloaded file hashes to ${downloadedSha256}` | ||
| ); | ||
| } | ||
| await fs.rename(partialPath, destPath); | ||
| } catch (e) { | ||
| await fs.rm(partialPath, { force: true }).catch(() => {}); | ||
| throw e; | ||
| } | ||
| this._printProgress(quiet, `Verified sha256 (${record.sha256}) and saved snapshot${record.size ? ` (${record.size} bytes)` : ""}\n`); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.