@skyware/labeler
Version:
A lightweight alternative to Ozone for operating an atproto labeler.
380 lines (379 loc) • 15.5 kB
JavaScript
import { XRPCError } from "@atcute/client";
import { spawn } from "node:child_process";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import prompt from "prompts";
import { declareLabeler, deleteLabelerDeclaration, getLabelerLabelDefinitions, plcClearLabeler, plcRequestToken, plcSetupLabeler, setLabelerLabelDefinitions, } from "./scripts/index.js";
import { loginAgent } from "./scripts/util.js";
import { resolveHandle } from "./util/resolveHandle.js";
const argv = process.argv.slice(2);
const [command, subcommand, ...args] = argv;
if (command === "setup" || command === "clear") {
const credentials = await promptCredentials();
await plcRequestToken(credentials);
const { plcToken } = await prompt({
type: "text",
name: "plcToken",
message: "You will receive a confirmation code via email. Code:",
}, { onCancel: () => process.exit(1) });
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) });
const operation = await plcSetupLabeler({
...credentials,
plcToken,
endpoint,
privateKey,
overwriteExistingKey: true,
});
// If a new key was generated and a verification method was added,
// plcSetupLabeler logged the private key to the console.
if (!privateKey && operation?.verificationMethods) {
await confirm("Have you saved the signing key and are you ready to begin defining labels?");
}
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();
if (labelDefinitions.length) {
await declareLabeler(credentials, labelDefinitions, true);
}
else {
console.log("No labels were defined. You can use the `label add` command later to define new labels.");
}
console.log("Labeler setup complete!");
}
catch (error) {
console.error("Error setting up labeler:", error);
}
}
else {
try {
await plcClearLabeler({ ...credentials, plcToken });
await deleteLabelerDeclaration(credentials);
console.log("Labeler data cleared.");
}
catch (error) {
console.error("Error setting up labeler:", error);
}
}
}
else if (command === "recreate") {
const credentials = await promptCredentials();
const definitions = await getLabelerLabelDefinitions(credentials);
if (!definitions) {
console.log("No label definitions found.");
process.exit(0);
}
try {
await deleteLabelerDeclaration(credentials);
await declareLabeler(credentials, definitions);
console.log("Labeler declaration recreated.");
}
catch (error) {
console.error("Error recreating labeler declaration:", error);
}
}
else if (command === "label"
&& (subcommand === "add" || subcommand === "delete" || subcommand === "edit")) {
const credentials = await promptCredentials();
const labelDefinitions = await getLabelerLabelDefinitions(credentials) ?? [];
if (subcommand === "add") {
console.log("Now define a name, description, and settings for each of the labels you want to add.");
const newDefinitions = await promptLabelDefinitions();
if (newDefinitions.length) {
const definitions = [...labelDefinitions, ...newDefinitions];
try {
await setLabelerLabelDefinitions(credentials, definitions);
console.log("Declared label(s):", definitions.map((d) => d.identifier).join(", "));
}
catch (error) {
console.error("Error adding label(s):", error);
}
}
else {
console.log("No labels were defined.");
}
}
else if (subcommand === "delete") {
if (!labelDefinitions.length) {
console.log("No labels are currently declared. Use the `label add` command to define new labels.");
process.exit(0);
}
const identifiers = args.length
? args
: (await prompt({
type: "multiselect",
name: "identifiers",
message: "Select the labels to remove",
min: 1,
choices: labelDefinitions.map((def) => ({
title: def.locales[0].name,
value: def.identifier,
})),
}, { onCancel: () => process.exit(1) })).identifiers;
const [newDefinitions, removedIdentifiers] = labelDefinitions.reduce(([newDefs, removed], def) => {
if (!identifiers.includes(def.identifier)) {
newDefs.push(def);
}
else {
removed.push(def.identifier);
}
return [newDefs, removed];
}, [[], []]);
try {
if (removedIdentifiers.length) {
await setLabelerLabelDefinitions(credentials, newDefinitions);
console.log("Deleted label(s):", removedIdentifiers.join(", "));
}
else {
console.log("No labels were selected. Nothing to delete.");
}
}
catch (error) {
console.error("Failed to delete labels:", error);
}
}
else if (subcommand === "edit") {
const labelDefinitions = await getLabelerLabelDefinitions(credentials) ?? [];
try {
const newDefinitions = await editLabelDefinitions(labelDefinitions);
if (newDefinitions.length) {
await setLabelerLabelDefinitions(credentials, newDefinitions);
console.log("Label definitions updated.");
}
else {
console.log("No changes were made.");
}
}
catch (error) {
console.error("Error updating label definitions:", error);
}
}
}
else {
console.log("Usage: npx @skyware/labeler [command]");
console.log("Commands:");
console.log(" setup - Initialize an account as a labeler.");
console.log(" clear - Restore a labeler account to normal.");
console.log(" recreate - Recreate the labeler declaration (recommended if labels are not showing up).");
console.log(" label add - Add new label declarations to a labeler account.");
console.log(" label delete - Remove label declarations from a labeler account.");
console.log(" label edit - Bulk edit label definitions.");
}
async function promptCredentials() {
let 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) });
const credentials = { identifier: did, password, pds };
try {
await loginAgent(credentials);
}
catch (error) {
if (error instanceof XRPCError && error.kind === "AuthFactorTokenRequired") {
const { code } = await prompt({
type: "text",
name: "code",
message: "You will receive a 2FA code via email. Code:",
initial: "",
}, { onCancel: () => process.exit(1) });
credentials.code = code;
}
else {
console.error("Error occurred while trying to log in:", error);
process.exit(1);
}
}
return credentials;
}
async function confirm(message) {
let confirmed = false;
while (!confirmed) {
const { confirm } = await prompt({ type: "confirm", name: "confirm", message });
confirmed = confirm;
}
}
async function promptLabelDefinition(existing) {
let canceled = false;
const { identifier, name, description, adultOnly, severity, blurs, defaultSetting } = await prompt([{
type: "text",
name: "identifier",
message: "Identifier (non-user-facing, must be unique, 100 characters max):",
validate: (value) => {
if (!value)
return "Required.";
if (value.length > 100)
return "Must be <= 100 characters.";
if (existing?.some((id) => id === value))
return "Must be unique.";
if (/[^a-z-]/.test(value))
return "Must be lowercase letters and hyphens only.";
return true;
},
format: (value) => value.toLowerCase(),
}, {
type: "text",
name: "name",
message: "Name (user-facing, 64 characters max):",
validate: (value) => value.length <= 64 || "Must be <= 64 characters.",
}, {
type: "text",
name: "description",
message: "Description (user-facing):",
validate: (value) => value.length <= 10_000 || "10,000 characters max.",
}, {
type: "confirm",
name: "adultOnly",
message: "Does the user need to have adult content enabled to configure this label?",
initial: false,
}, {
type: "select",
name: "severity",
message: "Label severity:",
choices: [{ title: "None", description: "(no label)", value: "none" }, {
title: "Informational",
description: "(neutral)",
value: "inform",
}, { title: "Alert", description: "(warning)", value: "alert" }],
}, {
type: "select",
name: "blurs",
message: "Should this label hide content?",
choices: [{ title: "None", description: "(no hiding)", value: "none" }, {
title: "Media",
description: "(hide media only)",
value: "media",
}, { title: "Content", description: "(hide all labeled content)", value: "content" }],
}, {
type: "select",
name: "defaultSetting",
message: "What should the default setting be for a new subscriber?",
choices: [
{ title: "Ignore", description: "(don't show this label)", value: "ignore" },
{
title: "Warn",
description: "(display labeled content with warning)",
value: "warn",
},
{ title: "Hide", description: "(hide labeled content from feed)", value: "hide" },
],
}], {
onCancel: () => {
canceled = true;
},
});
return canceled
? null
: {
identifier,
adultOnly,
severity,
blurs,
defaultSetting,
locales: [{ lang: "en", name, description }],
};
}
async function promptLabelDefinitions() {
const definitions = [];
let addAnother = true;
while (addAnother) {
console.log("Enter the details for the next label you would like this labeler to apply.");
console.log("Press Esc or Ctrl+C to exit at any time with the labels defined so far.");
const definition = await promptLabelDefinition(definitions.map((d) => d.identifier));
if (!definition)
break;
definitions.push(definition);
({ addAnother } = await prompt({
type: "confirm",
name: "addAnother",
message: "Add another label definition?",
initial: true,
}));
}
return definitions;
}
async function editLabelDefinitions(labelDefinitions) {
// os.tmpdir() returns a symlink on macOS
const tmpdir = await fs.realpath(os.tmpdir());
const tmpFile = path.join(tmpdir, "labels.json");
await fs.writeFile(tmpFile, JSON.stringify(labelDefinitions, null, 4));
await fs.chmod(tmpFile, 0o600);
const editor = process.env.VISUAL || process.env.EDITOR || "vi";
await new Promise((resolve, reject) => {
const child = spawn(editor, [tmpFile], { stdio: "inherit" });
child.on("error", reject);
child.on("exit", (code) => {
if (code === 0) {
resolve();
}
else {
reject(new Error(`Code ${code}`));
}
});
});
const definitionsText = await fs.readFile(tmpFile, "utf8");
let newDefinitions;
try {
newDefinitions = JSON.parse(definitionsText);
}
catch (error) {
throw new Error(`Error parsing JSON: ${error}` + "\n\nFull definitions:\n" + definitionsText);
}
finally {
await fs.unlink(tmpFile);
}
if (!Array.isArray(newDefinitions)) {
throw new Error("Definitions must be an array.\n\nFull definitions:\n" + definitionsText);
}
for (const definition of newDefinitions) {
if (!definition || typeof definition !== "object" || !definition.identifier
|| !definition.locales.length || !definition.blurs || !definition.severity) {
throw new Error("Invalid label definition: " + JSON.stringify(definition)
+ "\n\nFull definitions:\n"
+ definitionsText);
}
}
return newDefinitions;
}