feat(T-1.3): auth tokens, pairing, TLS, CLI (pair/auth list/revoke/name)
This commit is contained in:
parent
17c32e7e93
commit
6f106d2411
|
|
@ -0,0 +1,85 @@
|
||||||
|
/**
|
||||||
|
* QR-based pairing.
|
||||||
|
*
|
||||||
|
* Implements IC-3 — the `pi-remote://` URL scheme and `POST /pair` exchange.
|
||||||
|
*
|
||||||
|
* Pairing flow:
|
||||||
|
* 1. Server generates a one-time pairingToken (short-lived, 10 min).
|
||||||
|
* 2. `pi-remote pair` CLI prints the QR code containing the URL.
|
||||||
|
* 3. iOS app scans the QR, extracts host/port/pairingToken/fingerprint.
|
||||||
|
* 4. iOS app calls `POST /pair` with { pairingToken, deviceToken?, environment?, deviceName? }.
|
||||||
|
* 5. Server validates pairingToken, creates a bearer token, returns { bearerToken, sidecarId }.
|
||||||
|
*
|
||||||
|
* Owner: T-1.3
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
export interface PairingToken {
|
||||||
|
token: string;
|
||||||
|
expiresAt: number; // unix ms
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory store of active pairing tokens (cleared on server restart)
|
||||||
|
const _pairingTokens = new Map<string, PairingToken>();
|
||||||
|
|
||||||
|
const PAIRING_TOKEN_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
|
/** Generate a new pairing token. Invalidates any existing one. */
|
||||||
|
export function generatePairingToken(): PairingToken {
|
||||||
|
const token = randomBytes(16).toString("base64url");
|
||||||
|
const entry: PairingToken = {
|
||||||
|
token,
|
||||||
|
expiresAt: Date.now() + PAIRING_TOKEN_TTL_MS,
|
||||||
|
};
|
||||||
|
_pairingTokens.set(token, entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate and consume a pairing token. Returns true if valid. */
|
||||||
|
export function consumePairingToken(token: string): boolean {
|
||||||
|
const entry = _pairingTokens.get(token);
|
||||||
|
if (!entry) return false;
|
||||||
|
_pairingTokens.delete(token);
|
||||||
|
if (Date.now() > entry.expiresAt) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the IC-3 pairing URL.
|
||||||
|
*
|
||||||
|
* pi-remote://<host>:<port>?pair=<pairingToken>&fp=<sha256-hex>&name=<sidecarName>
|
||||||
|
*/
|
||||||
|
export function buildPairingUrl(opts: {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
pairingToken: string;
|
||||||
|
fingerprint: string;
|
||||||
|
sidecarName?: string;
|
||||||
|
}): string {
|
||||||
|
const {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
pairingToken,
|
||||||
|
fingerprint,
|
||||||
|
sidecarName = "pi-remote",
|
||||||
|
} = opts;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
pair: pairingToken,
|
||||||
|
fp: fingerprint,
|
||||||
|
name: sidecarName,
|
||||||
|
});
|
||||||
|
return `pi-remote://${host}:${port}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the pairing URL as a QR code to the terminal.
|
||||||
|
* Uses the `qrcode` package bundled as a dependency.
|
||||||
|
*/
|
||||||
|
export async function printPairingQr(url: string): Promise<void> {
|
||||||
|
// Dynamic import so this file loads even if qrcode isn't installed
|
||||||
|
const qrcode = await import("qrcode");
|
||||||
|
const qr = await qrcode.toString(url, { type: "terminal", small: true });
|
||||||
|
console.log(qr);
|
||||||
|
console.log(url);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
/**
|
||||||
|
* Self-signed TLS certificate generation + fingerprint.
|
||||||
|
*
|
||||||
|
* Generates a self-signed cert for the sidecar server. The SHA-256 fingerprint
|
||||||
|
* is included in the QR pairing URL (IC-3 `fp` field) so the iOS app can pin.
|
||||||
|
*
|
||||||
|
* Uses openssl CLI (available on macOS/Linux). Falls back to plain HTTP if
|
||||||
|
* openssl is not available.
|
||||||
|
*
|
||||||
|
* Owner: T-1.3
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
export interface TlsCert {
|
||||||
|
certPath: string;
|
||||||
|
keyPath: string;
|
||||||
|
/** SHA-256 hex fingerprint of the DER-encoded cert */
|
||||||
|
fingerprint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TlsCertOptions {
|
||||||
|
stateDir?: string;
|
||||||
|
/** Common name for the cert (default: "pi-remote") */
|
||||||
|
cn?: string;
|
||||||
|
/** Days the cert is valid (default: 3650 — 10 years) */
|
||||||
|
days?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function certDir(stateDir?: string): string {
|
||||||
|
return path.join(
|
||||||
|
stateDir ?? path.join(os.homedir(), ".local", "share", "pi-remote"),
|
||||||
|
"tls",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load existing cert or generate a new one.
|
||||||
|
* Returns paths + fingerprint.
|
||||||
|
*/
|
||||||
|
export async function loadOrCreateCert(
|
||||||
|
opts: TlsCertOptions = {},
|
||||||
|
): Promise<TlsCert> {
|
||||||
|
const { stateDir, cn = "pi-remote", days = 3650 } = opts;
|
||||||
|
const dir = certDir(stateDir);
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
|
||||||
|
const certPath = path.join(dir, "cert.pem");
|
||||||
|
const keyPath = path.join(dir, "key.pem");
|
||||||
|
|
||||||
|
// Reuse existing cert if present
|
||||||
|
try {
|
||||||
|
await fs.access(certPath);
|
||||||
|
await fs.access(keyPath);
|
||||||
|
const fingerprint = await computeFingerprint(certPath);
|
||||||
|
return { certPath, keyPath, fingerprint };
|
||||||
|
} catch {
|
||||||
|
// generate new
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate via openssl
|
||||||
|
await execFileAsync("openssl", [
|
||||||
|
"req",
|
||||||
|
"-x509",
|
||||||
|
"-newkey",
|
||||||
|
"rsa:2048",
|
||||||
|
"-keyout",
|
||||||
|
keyPath,
|
||||||
|
"-out",
|
||||||
|
certPath,
|
||||||
|
"-days",
|
||||||
|
String(days),
|
||||||
|
"-nodes",
|
||||||
|
"-subj",
|
||||||
|
`/CN=${cn}`,
|
||||||
|
]);
|
||||||
|
await fs.chmod(keyPath, 0o600);
|
||||||
|
|
||||||
|
const fingerprint = await computeFingerprint(certPath);
|
||||||
|
return { certPath, keyPath, fingerprint };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute SHA-256 fingerprint of a PEM cert.
|
||||||
|
* Returns hex string (no colons), e.g. "a1b2c3...".
|
||||||
|
*/
|
||||||
|
export async function computeFingerprint(certPath: string): Promise<string> {
|
||||||
|
// Use openssl to get DER bytes, then hash
|
||||||
|
const { stdout } = await execFileAsync("openssl", [
|
||||||
|
"x509",
|
||||||
|
"-in",
|
||||||
|
certPath,
|
||||||
|
"-outform",
|
||||||
|
"DER",
|
||||||
|
]);
|
||||||
|
return createHash("sha256").update(stdout).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if openssl is available on PATH.
|
||||||
|
*/
|
||||||
|
export async function isOpensslAvailable(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await execFileAsync("openssl", ["version"]);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
/**
|
||||||
|
* Bearer-token CRUD.
|
||||||
|
*
|
||||||
|
* Extends the minimal token support in auth.ts (legacy single-token) with
|
||||||
|
* named multi-token management. Each token entry has:
|
||||||
|
* - id: short random identifier
|
||||||
|
* - token: the bearer secret (base64url, 32 bytes)
|
||||||
|
* - name: human label (e.g. "Jay's iPhone")
|
||||||
|
* - createdAt: ISO timestamp
|
||||||
|
* - deviceToken?: APNs device token (set when device pairs in Phase 2)
|
||||||
|
* - environment?: "production" | "sandbox"
|
||||||
|
*
|
||||||
|
* Stored as JSON in $state_dir/auth/tokens.json (mode 0o600).
|
||||||
|
*
|
||||||
|
* Owner: T-1.3
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export interface TokenEntry {
|
||||||
|
id: string;
|
||||||
|
token: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
deviceToken?: string; // APNs — optional pre-Phase-2, mandatory Phase-2+
|
||||||
|
environment?: "production" | "sandbox";
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokensPath(stateDir?: string): string {
|
||||||
|
const base =
|
||||||
|
stateDir ?? path.join(os.homedir(), ".local", "share", "pi-remote");
|
||||||
|
return path.join(base, "auth", "tokens.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTokens(stateDir?: string): Promise<TokenEntry[]> {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(tokensPath(stateDir), "utf8");
|
||||||
|
return JSON.parse(raw) as TokenEntry[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTokens(
|
||||||
|
entries: TokenEntry[],
|
||||||
|
stateDir?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const fp = tokensPath(stateDir);
|
||||||
|
await fs.mkdir(path.dirname(fp), { recursive: true });
|
||||||
|
await fs.writeFile(fp, JSON.stringify(entries, null, 2), {
|
||||||
|
encoding: "utf8",
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a new named bearer token. Returns the new entry (token visible once). */
|
||||||
|
export async function createToken(
|
||||||
|
name: string,
|
||||||
|
stateDir?: string,
|
||||||
|
): Promise<TokenEntry> {
|
||||||
|
const entries = await loadTokens(stateDir);
|
||||||
|
const entry: TokenEntry = {
|
||||||
|
id: randomBytes(6).toString("base64url"),
|
||||||
|
token: randomBytes(32).toString("base64url"),
|
||||||
|
name,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
entries.push(entry);
|
||||||
|
await saveTokens(entries, stateDir);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List all tokens (token field is included — protect at the API layer). */
|
||||||
|
export async function listTokens(stateDir?: string): Promise<TokenEntry[]> {
|
||||||
|
return loadTokens(stateDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Revoke a token by id. Returns true if found and removed. */
|
||||||
|
export async function revokeToken(
|
||||||
|
id: string,
|
||||||
|
stateDir?: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const entries = await loadTokens(stateDir);
|
||||||
|
const before = entries.length;
|
||||||
|
const filtered = entries.filter((e) => e.id !== id);
|
||||||
|
if (filtered.length === before) return false;
|
||||||
|
await saveTokens(filtered, stateDir);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rename a token. Returns true if found. */
|
||||||
|
export async function renameToken(
|
||||||
|
id: string,
|
||||||
|
newName: string,
|
||||||
|
stateDir?: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const entries = await loadTokens(stateDir);
|
||||||
|
const entry = entries.find((e) => e.id === id);
|
||||||
|
if (!entry) return false;
|
||||||
|
entry.name = newName;
|
||||||
|
await saveTokens(entries, stateDir);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update device token (called during Phase-2 pairing). */
|
||||||
|
export async function setDeviceToken(
|
||||||
|
id: string,
|
||||||
|
deviceToken: string,
|
||||||
|
environment: "production" | "sandbox",
|
||||||
|
stateDir?: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const entries = await loadTokens(stateDir);
|
||||||
|
const entry = entries.find((e) => e.id === id);
|
||||||
|
if (!entry) return false;
|
||||||
|
entry.deviceToken = deviceToken;
|
||||||
|
entry.environment = environment;
|
||||||
|
await saveTokens(entries, stateDir);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a bearer token string against the store.
|
||||||
|
* Returns the matching entry or null.
|
||||||
|
*/
|
||||||
|
export async function validateBearer(
|
||||||
|
bearer: string,
|
||||||
|
stateDir?: string,
|
||||||
|
): Promise<TokenEntry | null> {
|
||||||
|
const entries = await loadTokens(stateDir);
|
||||||
|
const b = Buffer.from(bearer);
|
||||||
|
for (const entry of entries) {
|
||||||
|
const a = Buffer.from(entry.token);
|
||||||
|
if (a.length === b.length && timingSafeEqual(a, b)) return entry;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,223 @@
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue