diff --git a/package.json b/package.json index 571949f..992d2ff 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@types/ws": "^8.5.12", "@typescript-eslint/eslint-plugin": "^6.7.4", "@typescript-eslint/parser": "^6.7.4", + "@types/yargs": "^17.0.33", "dprint": "^0.41.0", "eslint": "^8.50.0", "typescript": "^5.5.4" @@ -46,7 +47,8 @@ "@noble/hashes": "^1.5.0", "fastify": "^4.28.1", "prompts": "^2.4.2", - "uint8arrays": "^5.1.0" + "uint8arrays": "^5.1.0", + "yargs": "^18.0.0" }, "files": [ "dist" diff --git a/src/LabelerServer.ts b/src/LabelerServer.ts index 25b35ad..767f4fd 100644 --- a/src/LabelerServer.ts +++ b/src/LabelerServer.ts @@ -235,7 +235,7 @@ export class LabelerServer { const id = Number(result.rows[0].id); this.emitLabel(id, signed); - return { id, ...signed }; + return { id, ...signed, sig: signed.sig.buffer as ArrayBuffer }; } /** @@ -422,7 +422,7 @@ export class LabelerServer { cts: row.cts as string, ...(row.cid ? { cid: row.cid as string } : {}), ...(row.exp ? { exp: row.exp as string } : {}), - ...(row.sig ? { sig: row.sig as Uint8Array } : {}), + ...(row.sig ? { sig: new Uint8Array(row.sig as ArrayBuffer) } : {}), })); const labels = rows.map(formatLabel); @@ -473,7 +473,7 @@ export class LabelerServer { cts: cts as string, ...(cid ? { cid: cid as string } : {}), ...(exp ? { exp: exp as string } : {}), - ...(sig ? { sig: sig as Uint8Array } : {}), + ...(sig ? { sig: new Uint8Array(sig as ArrayBuffer) } : {}), }; const bytes = frameToBytes("message", { seq: Number(seq), diff --git a/src/bin.ts b/src/bin.ts index ff147a2..4be2aee 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -6,6 +6,8 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import prompt from "prompts"; +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; import { declareLabeler, deleteLabelerDeclaration, @@ -19,11 +21,56 @@ import { import { loginAgent } from "./scripts/util.js"; import { resolveHandle } from "./util/resolveHandle.js"; -const argv = process.argv.slice(2); -const [command, subcommand, ...args] = argv; +// Parse command line arguments +const argv = await yargs(hideBin(process.argv)).command( + "setup", + "Initialize an account as a labeler", + (yargs) => { + return yargs.option("did", { type: "string", description: "DID of the labeler account" }) + .option("password", { type: "string", description: "Account password" }).option("pds", { + type: "string", + description: "PDS URL", + default: "https://bsky.social", + }).option("endpoint", { + type: "string", + description: "HTTPS URL where labeler will be hosted", + }).option("signing-key", { + type: "string", + description: "Private signing key (hex or base64)", + }).option("labels-config", { + type: "string", + description: "Path to labels configuration JSON file", + }); + }, +).command("clear", "Restore a labeler account to normal", (yargs) => { + return yargs.option("did", { type: "string", description: "DID of the labeler account" }) + .option("password", { type: "string", description: "Account password" }).option("pds", { + type: "string", + description: "PDS URL", + default: "https://bsky.social", + }); +}).command( + "recreate", + "Recreate the labeler declaration", + (yargs) => { + return yargs.option("did", { type: "string", description: "DID of the labeler account" }) + .option("password", { type: "string", description: "Account password" }).option("pds", { + type: "string", + description: "PDS URL", + default: "https://bsky.social", + }); + }, +).command("label", "Manage label definitions", (yargs) => { + return yargs.command("add", "Add new label declarations").command( + "delete [identifiers..]", + "Remove label declarations", + ).command("edit", "Bulk edit label definitions"); +}).help().argv; + +const [command, subcommand, ...args] = argv._; if (command === "setup" || command === "clear") { - const credentials = await promptCredentials(); + const credentials = await promptCredentials(argv); await plcRequestToken(credentials); @@ -33,33 +80,41 @@ if (command === "setup" || command === "clear") { message: "You will receive a confirmation code via email. Code:", }, { onCancel: () => process.exit(1) }); + // Output PLC token for external processing + if (command === "setup") { + console.log(`PLC_TOKEN=${plcToken}`); + } + if (command === "setup") { try { - const { endpoint, privateKey } = await prompt([{ - type: "text", - name: "endpoint", - message: "URL where the labeler will be hosted:", - validate: (value) => value.startsWith("https://") || "Must be a valid HTTPS URL.", - }, { - type: "text", - name: "privateKey", - message: "Enter a signing key to use, or leave blank to generate a new one:", - - validate: (value) => { - if (!value) return true; - if (/^[0-9a-f]*$/.test(value)) return true; - if (/^[A-Za-z0-9+/=]+$/.test(value)) return true; - return "Must be a hex or base64-encoded string."; - }, - }], { onCancel: () => process.exit(1) }); + // Get setup parameters from CLI args or prompt + const endpoint = argv.endpoint as string + || await promptForValue( + "URL where the labeler will be hosted:", + (value) => value.startsWith("https://") || "Must be a valid HTTPS URL.", + ); + + const privateKey = argv.signingKey as string + || await promptForValue( + "Enter a signing key to use, or leave blank to generate a new one:", + (value) => { + if (!value) return true; + if (/^[0-9a-f]*$/.test(value)) return true; + if (/^[A-Za-z0-9+/=]+$/.test(value)) return true; + return "Must be a hex or base64-encoded string."; + }, + ); - const operation = await plcSetupLabeler({ + const setupOptions: any = { ...credentials, plcToken, endpoint, - privateKey, overwriteExistingKey: true, - }); + }; + if (privateKey) { + setupOptions.privateKey = privateKey; + } + const operation = await plcSetupLabeler(setupOptions); // If a new key was generated and a verification method was added, // plcSetupLabeler logged the private key to the console. @@ -69,10 +124,40 @@ if (command === "setup" || command === "clear") { ); } - console.log( - "Next, you will need to define a name, description, and settings for each of the labels you want this labeler to apply.", - ); - const labelDefinitions = await promptLabelDefinitions(); + // Handle labels from config file or prompt + let labelDefinitions: Array = []; + + if (argv.labelsConfig) { + try { + const configContent = await fs.readFile(argv.labelsConfig as string, "utf8"); + const labelsConfig = JSON.parse(configContent); + labelDefinitions = labelsConfig.map((config: any) => ({ + identifier: config.identifier, + adultOnly: config.adultOnly, + severity: config.severity, + blurs: config.blurs, + defaultSetting: config.defaultSetting, + locales: [{ + lang: "en", + name: config.name, + description: config.description, + }], + })); + const labelsConfigPath = argv.labelsConfig as string; + console.log( + `Loaded ${labelDefinitions.length} label definitions from ${labelsConfigPath}`, + ); + } catch (error) { + console.error(`Error reading labels config file: ${error}`); + process.exit(1); + } + } else { + console.log( + "Next, you will need to define a name, description, and settings for each of the labels you want this labeler to apply.", + ); + labelDefinitions = await promptLabelDefinitions(); + } + if (labelDefinitions.length) { await declareLabeler(credentials, labelDefinitions, true); } else { @@ -95,7 +180,7 @@ if (command === "setup" || command === "clear") { } } } else if (command === "recreate") { - const credentials = await promptCredentials(); + const credentials = await promptCredentials(argv); const definitions = await getLabelerLabelDefinitions(credentials); if (!definitions) { @@ -114,7 +199,7 @@ if (command === "setup" || command === "clear") { command === "label" && (subcommand === "add" || subcommand === "delete" || subcommand === "edit") ) { - const credentials = await promptCredentials(); + const credentials = await promptCredentials(argv); const labelDefinitions = await getLabelerLabelDefinitions(credentials) ?? []; if (subcommand === "add") { @@ -143,7 +228,7 @@ if (command === "setup" || command === "clear") { } const identifiers = args.length - ? args + ? args as string[] : (await prompt({ type: "multiselect", name: "identifiers", @@ -204,35 +289,48 @@ if (command === "setup" || command === "clear") { console.log(" label edit - Bulk edit label definitions."); } -async function promptCredentials(): Promise { - let did: string | undefined; - while (!did) { - const { did: didOrHandle } = await prompt({ - type: "text", - name: "did", - message: "DID or handle of the account to use:", - validate: (value) => - value.startsWith("did:") || value.includes(".") || "Invalid DID or handle.", - format: (value) => value.startsWith("@") ? value.slice(1) : value, - }, { onCancel: () => process.exit(1) }); - if (!didOrHandle) continue; - did = didOrHandle.startsWith("did:") ? didOrHandle : await resolveHandle(didOrHandle); - if (!did) { - console.log(`Could not resolve "${didOrHandle}" to a valid account. Please try again.`); +async function promptCredentials(argv: any): Promise { + // Use CLI args if provided, otherwise prompt + let did: string | undefined = argv.did; + + if (!did) { + while (!did) { + const { did: didOrHandle } = await prompt({ + type: "text", + name: "did", + message: "DID or handle of the account to use:", + validate: (value) => + value.startsWith("did:") || value.includes(".") || "Invalid DID or handle.", + format: (value) => value.startsWith("@") ? value.slice(1) : value, + }, { onCancel: () => process.exit(1) }); + if (!didOrHandle) continue; + did = didOrHandle.startsWith("did:") ? didOrHandle : await resolveHandle(didOrHandle); + if (!did) { + console.log( + `Could not resolve "${didOrHandle}" to a valid account. Please try again.`, + ); + } } } - const { password, pds } = await prompt([{ - type: "password", - name: "password", - message: "Account password (cannot be an app password):", - }, { - type: "text", - name: "pds", - message: "URL of the PDS where the account is located:", - initial: "https://bsky.social", - validate: (value) => value.startsWith("https://") || "Must be a valid HTTPS URL.", - }], { onCancel: () => process.exit(1) }); + let password: string = argv.password; + let pds: string = argv.pds || "https://bsky.social"; + + if (!password) { + const result = await prompt([{ + type: "password", + name: "password", + message: "Account password (cannot be an app password):", + }, { + type: "text", + name: "pds", + message: "URL of the PDS where the account is located:", + initial: "https://bsky.social", + validate: (value) => value.startsWith("https://") || "Must be a valid HTTPS URL.", + }], { onCancel: () => process.exit(1) }); + password = result.password; + pds = result.pds; + } const credentials: LoginCredentials = { identifier: did, password, pds }; @@ -255,6 +353,16 @@ async function promptCredentials(): Promise { return credentials; } +async function promptForValue( + message: string, + validate?: (value: string) => boolean | string, +): Promise { + const { value } = await prompt({ type: "text", name: "value", message, validate }, { + onCancel: () => process.exit(1), + }); + return value || ""; +} + async function confirm(message: string) { let confirmed = false; while (!confirmed) {