pi-remote-control/extensions/remote-control/cli/index.ts

224 lines
5.9 KiB
TypeScript

/**
* pi-remote CLI entrypoints.
*
* Subcommands:
* pi-remote pair — generate QR code for device pairing
* pi-remote auth list — list bearer tokens
* pi-remote auth revoke <id> — revoke a token
* pi-remote auth name <id> <name> — rename a token
*
* Invoked by the extension via pi's flag registration or as a standalone
* script: `node cli/index.js <args>`
*
* Owner: T-1.3
*/
import os from "node:os";
import path from "node:path";
import {
buildPairingUrl,
generatePairingToken,
printPairingQr,
} from "../auth/pairing.js";
import { loadOrCreateCert } from "../auth/tls.js";
import {
createToken,
listTokens,
renameToken,
revokeToken,
} from "../auth/tokens.js";
import { readRemoteControlConfig } from "../config.js";
const DEFAULT_STATE_DIR = path.join(
os.homedir(),
".local",
"share",
"pi-remote",
);
export async function runCli(
argv: string[] = process.argv.slice(2),
): Promise<void> {
const [cmd, sub, ...rest] = argv;
switch (cmd) {
case "pair":
await cmdPair();
break;
case "auth":
await cmdAuth(sub, rest);
break;
case "help":
case "--help":
case "-h":
printHelp();
break;
default:
console.error(`Unknown command: ${cmd ?? "(none)"}`);
printHelp();
process.exitCode = 1;
}
}
// ---------------------------------------------------------------------------
// pair
// ---------------------------------------------------------------------------
async function cmdPair(): Promise<void> {
const config = await readRemoteControlConfig();
const stateDir = DEFAULT_STATE_DIR;
// Try to get TLS fingerprint; fall back to empty string if openssl unavailable
let fingerprint = "";
try {
const cert = await loadOrCreateCert({ stateDir });
fingerprint = cert.fingerprint;
} catch {
console.warn(
"[pi-remote] Warning: openssl not available; fingerprint will be empty.",
);
}
const pairingTokenEntry = generatePairingToken();
// Determine host for QR — use advertised URL or fall back to hostname
const bindAddress = config.bindAddress ?? "0.0.0.0:7777";
const portMatch = bindAddress.match(/:(\d+)$/);
const port = portMatch ? parseInt(portMatch[1], 10) : 7777;
const host =
(config.publicBaseUrl ?? config.advertisedBaseUrl)?.replace(
/^https?:\/\//,
"",
) ?? os.hostname();
const url = buildPairingUrl({
host,
port,
pairingToken: pairingTokenEntry.token,
fingerprint,
sidecarName: "pi-remote",
});
console.log("\nScan this QR code with the pi-remote iOS app:\n");
await printPairingQr(url);
console.log(
`\nPairing token expires in 10 minutes. Run "pi-remote pair" again to refresh.`,
);
}
// ---------------------------------------------------------------------------
// auth
// ---------------------------------------------------------------------------
async function cmdAuth(sub: string | undefined, args: string[]): Promise<void> {
const stateDir = DEFAULT_STATE_DIR;
switch (sub) {
case "list": {
const tokens = await listTokens(stateDir);
if (tokens.length === 0) {
console.log(
"No tokens. Use `pi-remote auth create <name>` to create one.",
);
return;
}
console.log("ID NAME CREATED");
for (const t of tokens) {
const created = new Date(t.createdAt).toLocaleDateString();
console.log(
`${t.id.padEnd(12)}${t.name.padEnd(22)}${created}${t.deviceToken ? " [device paired]" : ""}`,
);
}
break;
}
case "create": {
const name = args[0] ?? "unnamed";
const entry = await createToken(name, stateDir);
console.log(`Created token "${entry.name}" (id: ${entry.id})`);
console.log(`Bearer token: ${entry.token}`);
console.log("Save this token — it won't be shown again.");
break;
}
case "revoke": {
const id = args[0];
if (!id) {
console.error("Usage: pi-remote auth revoke <id>");
process.exitCode = 1;
return;
}
const ok = await revokeToken(id, stateDir);
if (ok) {
console.log(`Revoked token ${id}.`);
} else {
console.error(`Token ${id} not found.`);
process.exitCode = 1;
}
break;
}
case "name": {
const [id, ...nameParts] = args;
const newName = nameParts.join(" ");
if (!id || !newName) {
console.error("Usage: pi-remote auth name <id> <new-name>");
process.exitCode = 1;
return;
}
const ok = await renameToken(id, newName, stateDir);
if (ok) {
console.log(`Renamed token ${id} to "${newName}".`);
} else {
console.error(`Token ${id} not found.`);
process.exitCode = 1;
}
break;
}
default:
console.error(`Unknown auth subcommand: ${sub ?? "(none)"}`);
console.error("Available: list, create, revoke, name");
process.exitCode = 1;
}
}
// ---------------------------------------------------------------------------
// help
// ---------------------------------------------------------------------------
function printHelp(): void {
console.log(`
pi-remote — CLI for the pi-remote-control sidecar
Commands:
pair Generate a QR code to pair the iOS app
auth list List all bearer tokens
auth create <name> Create a new named bearer token
auth revoke <id> Revoke a token by id
auth name <id> <name> Rename a token
Options:
--help, -h Show this help
`);
}
// ---------------------------------------------------------------------------
// Standalone entrypoint
// ---------------------------------------------------------------------------
// Run when invoked as: node cli/index.js <args>
if (
process.argv[1] &&
(process.argv[1].endsWith("cli/index.js") ||
process.argv[1].endsWith("cli/index.ts"))
) {
runCli().catch((err) => {
console.error(err);
process.exit(1);
});
}