browse
Version:
Unified Browserbase CLI for browser automation and cloud APIs.
210 lines (209 loc) • 8.37 kB
JavaScript
import { distance } from "fastest-levenshtein";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { resolveConfigDir } from "../identity.js";
/**
* Local name -> Browserbase context-id map.
*
* Browserbase contexts are identified only by an opaque id and the platform has
* no server-side list endpoint, so to give contexts memorable names (e.g.
* `github`, `gmail`) we keep a small map on the local device. It lives next to
* the CLI's other state at `(XDG_CONFIG_HOME||~/.config)/browserbase/contexts.json`
* (honoring `BROWSERBASE_CONFIG_DIR`). This is purely a client-side convenience:
* the ids it stores are the same ids the API already returns, and a missing or
* corrupt file degrades to "no saved contexts" rather than an error.
*/
const STORE_VERSION = 1;
const MAX_NAME_LENGTH = 64;
// A name must start with an alphanumeric and may then contain letters, digits,
// dots, dashes, and underscores. Keeps names shell- and filename-friendly and
// unambiguous against opaque context ids.
const NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
// Browserbase context ids are UUIDs. A name must not be UUID-shaped, otherwise a
// saved alias could shadow a real id and break raw-id passthrough in resolution.
const CONTEXT_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function looksLikeContextId(value) {
return CONTEXT_ID_PATTERN.test(value);
}
export function isValidContextName(name) {
return (name.length > 0 &&
name.length <= MAX_NAME_LENGTH &&
NAME_PATTERN.test(name) &&
// Disallow id-shaped names so a name can never shadow a raw context id.
!looksLikeContextId(name));
}
export function contextNameRequirement() {
return `Context names must be 1-${MAX_NAME_LENGTH} characters, start with a letter or number, contain only letters, numbers, dots, dashes, or underscores, and not look like a context ID.`;
}
export function contextsStorePath(env = process.env) {
return join(resolveConfigDir(env), "contexts.json");
}
function emptyStore() {
// Null-prototype map so a ref like "toString" or "constructor" can never read
// an inherited Object.prototype member as if it were a saved alias.
return {
version: STORE_VERSION,
contexts: Object.create(null),
};
}
async function readStore(env = process.env) {
let raw;
try {
raw = await readFile(contextsStorePath(env), "utf8");
}
catch {
return emptyStore();
}
try {
const parsed = JSON.parse(raw);
if (!parsed ||
typeof parsed !== "object" ||
typeof parsed.contexts !== "object" ||
parsed.contexts === null) {
return emptyStore();
}
return {
version: STORE_VERSION,
contexts: sanitizeContexts(parsed.contexts),
};
}
catch {
// Corrupt file: treat as empty rather than crashing the command.
return emptyStore();
}
}
/**
* Keep only well-formed entries so a hand-edited or partially-written
* `contexts.json` degrades gracefully (a malformed entry is dropped rather than
* trusted as a `ContextAlias` and breaking resolution or cleanup).
*/
function sanitizeContexts(raw) {
// Null-prototype so prototype keys can't be read back as aliases (see emptyStore).
const result = Object.create(null);
if (!raw || typeof raw !== "object") {
return result;
}
for (const [name, value] of Object.entries(raw)) {
// Validate the key too: a hand-edited file could carry an id-shaped or
// otherwise invalid name that would shadow a raw id, so hold disk entries to
// the same rule as `create --name`.
if (!isValidContextName(name)) {
continue;
}
if (!value || typeof value !== "object") {
continue;
}
const entry = value;
if (typeof entry.id !== "string" || entry.id.length === 0) {
continue;
}
result[name] = {
id: entry.id,
createdAt: typeof entry.createdAt === "string" ? entry.createdAt : "",
};
}
return result;
}
async function writeStore(store, env = process.env) {
const path = contextsStorePath(env);
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
// Write to a temp file at 0600 then atomically rename over the target. The
// rename means a reader never sees a half-written file, and the final file
// always inherits the temp file's 0600 perms even if an older, more permissive
// contexts.json already existed (writeFile's `mode` only applies on create).
// For this single-user local config, simultaneous writers remain
// last-writer-wins; cross-process locking isn't warranted here.
const tempPath = `${path}.${process.pid}.tmp`;
await writeFile(tempPath, `${JSON.stringify(store, null, 2)}\n`, {
encoding: "utf8",
mode: 0o600,
});
await rename(tempPath, path);
}
export async function listContextAliases(env = process.env) {
const store = await readStore(env);
return Object.entries(store.contexts)
.map(([name, alias]) => ({ name, ...alias }))
.sort((a, b) => a.name.localeCompare(b.name));
}
export async function getContextAlias(name, env = process.env) {
const store = await readStore(env);
return store.contexts[name];
}
export async function saveContextAlias(name, alias, env = process.env) {
const store = await readStore(env);
store.contexts[name] = alias;
await writeStore(store, env);
}
export async function removeContextAlias(name, env = process.env) {
const store = await readStore(env);
if (!(name in store.contexts)) {
return false;
}
delete store.contexts[name];
await writeStore(store, env);
return true;
}
/**
* Drop any saved aliases that point at a given context id. Used after a delete
* so the local map never references a context that no longer exists, regardless
* of whether the user deleted it by name or by raw id. Returns the names pruned.
*/
export async function removeContextAliasesById(id, env = process.env) {
const store = await readStore(env);
const removed = Object.entries(store.contexts)
.filter(([, alias]) => alias.id === id)
.map(([name]) => name);
if (removed.length === 0) {
return [];
}
for (const name of removed) {
delete store.contexts[name];
}
await writeStore(store, env);
return removed;
}
/**
* Resolve a context reference that may be a locally-saved name or a raw
* Browserbase context id. If `ref` matches a saved name, returns its id;
* otherwise returns `ref` unchanged (assumed to already be a context id). Never
* throws — an unknown ref simply passes through to the API.
*/
export async function resolveContextRef(ref, env = process.env) {
const alias = await getContextAlias(ref, env);
return alias ? alias.id : ref;
}
/**
* Like `resolveContextRef`, but distinguishes "this is an unknown name" from a
* real id so callers can show a friendly error instead of letting a typo'd name
* hit the API as a bogus id. A ref resolves when it matches a saved name or is
* shaped like a context id (UUID); anything else returns `id: null` plus the
* closest saved names.
*/
export async function resolveContextRefDetailed(ref, env = process.env) {
const store = await readStore(env);
const alias = store.contexts[ref];
if (alias) {
return { id: alias.id, suggestions: [] };
}
if (looksLikeContextId(ref)) {
return { id: ref, suggestions: [] };
}
return {
id: null,
suggestions: closeContextNameMatches(ref, Object.keys(store.contexts)),
};
}
/**
* Saved names within a small edit distance of `ref`, nearest first, for typo
* hints. The threshold scales with name length so short names aren't matched too
* loosely.
*/
export function closeContextNameMatches(ref, names, limit = 3) {
return names
.map((name) => ({ name, d: distance(ref, name) }))
.filter(({ name, d }) => d <= Math.max(2, Math.floor(name.length / 3)))
.sort((a, b) => a.d - b.d || a.name.localeCompare(b.name))
.slice(0, limit)
.map((match) => match.name);
}