framework
Version:
The (AI) Framework: turnkey, zero-config AI orchestration that wraps a coding-agent CLI (Claude Code) as a black box and takes you from an idea to a running app. Vite for AI.
443 lines • 22.3 kB
JavaScript
import { isAgentLocation } from './agent-location.js';
import { isHandoffLevel } from './handoff-level.js';
import { basename, dirname, join, resolve } from 'node:path';
import { randomBytes } from 'node:crypto';
import { isDriverName } from './driver-names.js';
import { nodeFs } from './node-fs.js';
import { MAX_SPEND_OFFSET, MAX_AUTO_PM_CONCURRENCY } from './preference-defaults.js';
/** The cap on saved custom presets, and the per-field lengths — enough for real prompts, bounded
* so a hand-edited or hostile registry can't bloat the home file. */
const CUSTOM_PRESET_LIMITS = { count: 30, label: 80, prompt: 20_000 };
// The bounds the browser's controls and this file's sanitizer both need live in the leaf
// `preference-defaults.ts`; re-exported so this stays the import site for everything that
// already reads them beside `Preferences`.
export { MAX_SPEND_OFFSET, DEFAULT_SPEND_OFFSET, DEFAULT_AUTO_PM_CONCURRENCY, MAX_AUTO_PM_CONCURRENCY, } from './preference-defaults.js';
/** The {@link RegistrySecrets} keys, as a `Record` so the compiler enforces completeness both
* ways — the same shape (and the same #944 lesson) as the preference tables below. */
const SECRET_KEYS = {
discordWebhook: true,
};
/** A bot token is ~70 chars and a webhook URL ~120; bounded so a hostile write can't bloat the file. */
const MAX_SECRET_LENGTH = 500;
/** The registry file name: a single file under `$XDG_CONFIG_HOME` (dotted under `$HOME`). */
export const REGISTRY_FILE = 'the-framework.json';
/** Owner read/write only: the file holds the daemon token (#1051) and the Discord credentials (#1095). */
export const REGISTRY_FILE_MODE = 0o600;
/**
* Deterministic, URL-safe id for a project path: the sanitized basename plus a
* short hash of the full path, so two repos named alike still get distinct ids.
* Pure; same path always yields the same id.
*/
export function projectId(path) {
// djb2, rendered as base36: short, stable, URL-safe. Not cryptographic.
let hash = 5381;
for (let i = 0; i < path.length; i++) {
hash = ((hash * 33) ^ path.charCodeAt(i)) >>> 0;
}
const name = basename(path)
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-');
return `${name}-${hash.toString(36)}`;
}
/**
* The registry file path, resolved from `env` (injectable so tests never touch
* the real home): `$XDG_CONFIG_HOME/the-framework.json` when set, else the
* dotted `$HOME/.the-framework.json`. A single file, not a directory (#390).
*/
export function registryPath(env) {
if (env.XDG_CONFIG_HOME)
return join(env.XDG_CONFIG_HOME, REGISTRY_FILE);
return join(env.HOME ?? '', '.' + REGISTRY_FILE);
}
/** A {@link RegistryFs} backed by `node:fs/promises`. See {@link nodeFs}. */
export function nodeRegistryFs() {
const { read, write, mkdir, rename, chmod } = nodeFs();
return { read, write, mkdir, rename, chmod };
}
/** True when `value` is a well-formed {@link ProjectRecord}. */
function isRecord(value) {
if (typeof value !== 'object' || value === null)
return false;
const record = value;
return typeof record.id === 'string' && typeof record.path === 'string' && typeof record.addedAt === 'string';
}
/** Keep well-formed records, deduped by resolved path (first wins). */
function dedupeProjects(values) {
const seen = new Set();
const projects = [];
for (const value of values) {
if (!isRecord(value))
continue;
const key = resolve(value.path);
if (seen.has(key))
continue;
seen.add(key);
projects.push(value);
}
return projects;
}
/**
* Every boolean preference, as a `Record` over {@link BooleanPreferenceKey} so the compiler
* enforces completeness in both directions (#944): a typo fails as an unknown property, and
* omitting a newly added boolean preference fails as a missing one. A plain `as const` array
* only caught the first — an omission made {@link sanitizePreferences} silently drop the new
* preference on every save, the write-then-vanish failure shape for a settings file.
*/
const BOOLEAN_PREFERENCES = {
vanilla: true,
onBeforeMergeableQuality: true,
browser: true,
transparent: true,
notifyBrowser: true,
notifyDiscord: true,
notifyNewActivity: true,
notifyHumanIntervention: true,
autoPm: true,
bridge: true,
onboardingDismissed: true,
};
const PREFERENCE_KEYS = Object.keys(BOOLEAN_PREFERENCES);
/** Keep only the known preference fields, so a hand-edited or browser-supplied
* object never lands junk (or the wrong type) in the user's home file. */
/** The color themes the dashboard offers (#725); anything else means the default `system`. */
const KNOWN_THEMES = ['system', 'light', 'dark'];
/** The agent targets the dashboard offers (#1050/#610); anything else means the default `local`. */
function sanitizePreferences(value) {
if (typeof value !== 'object' || value === null)
return {};
const input = value;
const preferences = {};
for (const key of PREFERENCE_KEYS) {
if (typeof input[key] === 'boolean')
preferences[key] = input[key];
}
// `model` (#628) is a free-form string preference; the rest are booleans. A blank string is "no
// choice", same as absent, so it is dropped rather than persisted. So is the literal word
// "Default": that was a picker *label* whose stored value was empty (#1143), and a file carrying
// it as the value — hand-edited, or written by a build that mistook the two — would otherwise be
// handed to the CLI as `--model Default` and fail the turn on a word nobody chose.
const model = typeof input['model'] === 'string' ? input['model'].trim() : '';
if (model && model.toLowerCase() !== 'default')
preferences.model = model;
// `driver` (#650) is constrained to the known set so junk never reaches the agent; the set is the
// shared node-free vocabulary (agent-names.ts). Default = claude.
if (isDriverName(input['driver']))
preferences.driver = input['driver'];
// `editor` (#727) is a free-form CLI name, trimmed and length-capped so junk / a huge string
// never lands in the file. A blank string is "no choice" (fall back to env / `code`), so dropped.
if (typeof input['editor'] === 'string' && input['editor'].trim())
preferences.editor = input['editor'].trim().slice(0, 100);
// `theme` (#725) is constrained to the known set; anything else (incl. absent) means the default
// `system`, so it is simply dropped rather than persisted.
if (typeof input['theme'] === 'string' && KNOWN_THEMES.includes(input['theme']))
preferences.theme = input['theme'];
// `target` (#1050) is a string, so the boolean-only PREFERENCE_KEYS loop would silently eat it;
// it gets its own branch like `theme`, constrained to the known set (anything else = default `local`).
if (isAgentLocation(input['target']))
preferences.target = input['target'];
// `handoff` (B5) is the one ordinal the three booleans it replaced could never be: a rung, not a
// combination. Constrained to the ladder, so anything else means the default `pr`.
if (isHandoffLevel(input['handoff']))
preferences.handoff = input['handoff'];
// `autoSpendOffset` (#960) is the one numeric preference: a slider position in percentage
// points, clamped so a hand-edited file cannot push the limit somewhere the slider could not.
const offset = input['autoSpendOffset'];
if (typeof offset === 'number' && Number.isFinite(offset))
preferences.autoSpendOffset = Math.round(Math.min(Math.max(offset, -MAX_SPEND_OFFSET), MAX_SPEND_OFFSET));
const customPresets = sanitizeCustomPresets(input['customPresets']);
if (customPresets.length)
preferences.customPresets = customPresets;
// `autoPmOptOut` (#1209) is a list of routine names, kept as free-form strings rather than
// checked against the catalog: this module is the storage layer and the catalog lives above it,
// and a name from a newer version must survive a downgrade rather than be erased by it. Empty
// is dropped like every other empty list — nothing opted out is exactly what absent means.
const optOut = sanitizeNameList(input['autoPmOptOut']);
if (optOut.length)
preferences.autoPmOptOut = optOut;
// `autoPmConcurrency` (#1204) is a count of agents, so it is clamped like `autoSpendOffset` and
// additionally floored at one: zero concurrent agents is what the `autoPm` switch already spells,
// and a hand-edited nought would otherwise wedge the routine with the switch still reading on.
const concurrency = input['autoPmConcurrency'];
if (typeof concurrency === 'number' && Number.isFinite(concurrency))
preferences.autoPmConcurrency = Math.min(Math.max(Math.round(concurrency), 1), MAX_AUTO_PM_CONCURRENCY);
return preferences;
}
/** Trimmed, de-duplicated, and bounded in both directions, so a hand-edited file cannot grow the object without limit. */
function sanitizeNameList(value) {
if (!Array.isArray(value))
return [];
const names = value
.filter((entry) => typeof entry === 'string')
.map(entry => entry.trim().slice(0, 100))
.filter(Boolean);
return [...new Set(names)].slice(0, 50);
}
/**
* Keep only well-formed custom presets (#626): each needs a non-empty id, label, and prompt;
* label/prompt are trimmed and length-capped, the list capped at {@link CUSTOM_PRESET_LIMITS.count},
* and duplicate ids dropped. A malformed entry is skipped, not thrown — a bad registry never breaks the read.
*/
export function sanitizeCustomPresets(value) {
if (!Array.isArray(value))
return [];
const out = [];
const seen = new Set();
for (const raw of value) {
if (out.length >= CUSTOM_PRESET_LIMITS.count)
break;
if (typeof raw !== 'object' || raw === null)
continue;
const { id, label, prompt } = raw;
if (typeof id !== 'string' || typeof label !== 'string' || typeof prompt !== 'string')
continue;
const trimmedId = id.trim();
const trimmedLabel = label.trim().slice(0, CUSTOM_PRESET_LIMITS.label);
const trimmedPrompt = prompt.trim().slice(0, CUSTOM_PRESET_LIMITS.prompt);
if (!trimmedId || !trimmedLabel || !trimmedPrompt || seen.has(trimmedId))
continue;
seen.add(trimmedId);
out.push({ id: trimmedId, label: trimmedLabel, prompt: trimmedPrompt });
}
return out;
}
/**
* The known secrets, kept only as non-empty trimmed strings (#1095) — the same "a hand-edited
* file can't smuggle junk in" rule the daemon token gets. An unknown key is dropped, so the
* block cannot become a scratch space for whatever a caller passes.
*/
function sanitizeSecrets(value) {
if (typeof value !== 'object' || value === null)
return undefined;
const raw = value;
const secrets = {};
for (const key of Object.keys(SECRET_KEYS)) {
const entry = raw[key];
if (typeof entry !== 'string')
continue;
const trimmed = entry.trim().slice(0, MAX_SECRET_LENGTH);
if (trimmed)
secrets[key] = trimmed;
}
return Object.keys(secrets).length ? secrets : undefined;
}
/**
* Read the whole registry. Forgiving: a missing / unreadable / malformed file — or one in a shape
* this no longer writes — yields an empty registry, never throws. Projects are deduped by resolved
* path and unknown preference fields are dropped.
*/
export async function readRegistry(fs = nodeRegistryFs(), env = process.env) {
const empty = { projects: [], preferences: {} };
let parsed;
try {
parsed = JSON.parse(await fs.read(registryPath(env)));
}
catch {
return empty;
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
return empty;
const obj = parsed;
const projects = Array.isArray(obj.projects) ? dedupeProjects(obj.projects) : [];
const secrets = sanitizeSecrets(obj.secrets);
return {
projects,
preferences: sanitizePreferences(obj.preferences),
// #1051: kept only as a non-empty string, so a hand-edited registry can't smuggle a junk token.
...(typeof obj.daemonToken === 'string' && obj.daemonToken ? { daemonToken: obj.daemonToken } : {}),
...(secrets ? { secrets } : {}),
};
}
/**
* Write the registry back as pretty object-form JSON, creating the parent dir.
*
* Atomic (#991): the JSON goes to a temp file beside the real one and is then renamed over it,
* the same shape #922 gave the daemon state file. A direct write truncates first, so a crash, a
* kill or a full disk mid-write left a half file — and {@link readRegistry} reports a malformed
* file as an empty registry, so every project and preference vanished silently. A failed write
* now only ever damages the temp file. The temp is left behind on failure rather than swept up:
* one stray file is the cheaper half of that trade.
*
* Written owner-only (#1095): the file carries the daemon token and the Discord credentials, so
* a default-umask 0644 in a shared home would hand them to every other account on the machine.
* The mode is set on the temp file, before the rename — narrowing after it would leave a window
* where the real path is readable. Best-effort: a filesystem with no permission bits still writes.
*/
async function writeRegistry(registry, fs, env) {
const file = registryPath(env);
const { projects, preferences, daemonToken, secrets } = registry;
const contents = {
projects,
preferences,
...(daemonToken ? { daemonToken } : {}),
...(secrets && Object.keys(secrets).length ? { secrets } : {}),
};
const json = JSON.stringify(contents, null, 2);
await fs.mkdir(dirname(file));
const restrict = (path) => fs.chmod?.(path, REGISTRY_FILE_MODE).catch(() => { });
if (!fs.rename) {
await fs.write(file, json);
await restrict(file);
return;
}
const temp = `${file}.${process.pid}.tmp`;
await fs.write(temp, json);
await restrict(temp);
await fs.rename(temp, file);
}
/**
* Serializes the read-modify-write mutators below (#991). Each reads the whole registry, edits it
* and writes it back, and one daemon runs several concurrently: `daemon.ts` and `daemon-runtime.ts`
* both call {@link addProject} while the dashboard's savePreferences RPC writes through
* {@link registryPreferencesStore}. Interleaved, the later write was computed from a read taken
* before the earlier one landed, so it silently dropped it. One tail promise for the module, not
* one per file: the writes are small, and the registry is a single file per machine anyway.
*/
let mutations = Promise.resolve();
function serialize(mutate) {
const result = mutations.then(mutate);
// A rejected mutation must not poison the queue, and must not surface as an unhandled rejection
// here — the caller still gets `result`, which carries the error.
mutations = result.then(() => { }, () => { });
return result;
}
/**
* Read the registry's project list. Forgiving: a missing / unreadable / malformed
* file yields `[]`, never throws. Deduped by resolved path, first wins.
*/
export async function listProjects(fs = nodeRegistryFs(), env = process.env) {
return (await readRegistry(fs, env)).projects;
}
/**
* Register a project. Idempotent by resolved path: when the path is already
* registered, the existing record is returned untouched (addedAt survives);
* otherwise the new record is appended and the file written back (preferences preserved).
*/
export async function addProject(path, addedAt, fs = nodeRegistryFs(), env = process.env) {
return serialize(async () => {
const absolute = resolve(path);
const registry = await readRegistry(fs, env);
const existing = registry.projects.find(project => resolve(project.path) === absolute);
if (existing)
return existing;
const record = { id: projectId(absolute), path: absolute, addedAt };
registry.projects.push(record);
await writeRegistry(registry, fs, env);
return record;
});
}
/** The user's dashboard preferences (#410), or `{}` when none are stored. */
export async function readPreferences(fs = nodeRegistryFs(), env = process.env) {
return (await readRegistry(fs, env)).preferences;
}
/** Persist the dashboard preferences (#410), sanitized, preserving the project list. */
export async function writePreferences(preferences, fs = nodeRegistryFs(), env = process.env) {
return serialize(async () => {
const registry = await readRegistry(fs, env);
await writeRegistry({ ...registry, preferences: sanitizePreferences(preferences) }, fs, env);
});
}
/**
* Merge `patch` over the stored preferences (#1148) and return the result.
*
* The counterpart to {@link writePreferences}, which replaces the whole block: a client that
* sends its entire snapshot replays every value it happens to hold, so a dashboard tab opened
* before someone else's change silently reverted it on the tab's next write, whatever key that
* write was actually about. Sending only the changed keys makes a write touch only what it names.
*
* Clearing needs no sentinel: {@link sanitizePreferences} already drops blank strings and empty
* lists, so `{ editor: '' }` merges in as blank and comes out absent, which is how the dashboard
* clears the editor today.
*/
export async function patchPreferences(patch, fs = nodeRegistryFs(), env = process.env) {
return serialize(async () => {
const registry = await readRegistry(fs, env);
const preferences = sanitizePreferences({ ...registry.preferences, ...patch });
await writeRegistry({ ...registry, preferences }, fs, env);
return preferences;
});
}
/**
* The shared daemon token (#1051): read the persisted one, or generate + persist it now. Called
* only on a non-loopback bind, so a loopback-only machine never grows one. Serialized with the
* other mutators so two concurrent binds can't each write a different token. `base64url` of 32
* random bytes: URL-safe, so it drops straight into a `?token=` without encoding.
*/
export async function ensureDaemonToken(fs = nodeRegistryFs(), env = process.env) {
return serialize(async () => {
const registry = await readRegistry(fs, env);
if (registry.daemonToken)
return registry.daemonToken;
const daemonToken = randomBytes(32).toString('base64url');
await writeRegistry({ ...registry, daemonToken }, fs, env);
return daemonToken;
});
}
/**
* The stored third-party credentials (#1095), or `{}` when none are set. Daemon-side only —
* every caller is a service that needs the value itself, never a client read: what the dashboard
* gets told is presence, in {@link RegistrySecrets}'s doc sense.
*/
export async function readSecrets(fs = nodeRegistryFs(), env = process.env) {
return (await readRegistry(fs, env)).secrets ?? {};
}
/**
* Merge a patch into the stored credentials (#1095), leaving everything else in the file alone.
*
* A patch, not a whole-object write, because the caller is a UI that edits one field: the bot
* dialog must not clear the webhook by not knowing it. An explicit `null` (or a blank string)
* clears a key — that is the Clear button — while `undefined` leaves it as it was, so "not
* mentioned" and "removed" stay different things. Serialized with the other mutators.
*/
export async function writeSecrets(patch, fs = nodeRegistryFs(), env = process.env) {
return serialize(async () => {
const registry = await readRegistry(fs, env);
const next = { ...registry.secrets };
for (const key of Object.keys(SECRET_KEYS)) {
const value = patch[key];
if (value === undefined)
continue;
const trimmed = (value ?? '').trim();
if (trimmed)
next[key] = trimmed;
else
delete next[key];
}
// Destructured off rather than overwritten: clearing the last credential must drop the key,
// and `exactOptionalPropertyTypes` will not let an explicit `undefined` stand in for absent.
const { secrets: _cleared, ...rest } = registry;
const secrets = sanitizeSecrets(next);
await writeRegistry(secrets ? { ...rest, secrets } : rest, fs, env);
});
}
/** The persisted daemon token (#1051), or `undefined` when none exists. A pure read, so a process
* that only prints the reachable URL never generates one. */
export async function readDaemonToken(fs = nodeRegistryFs(), env = process.env) {
return (await readRegistry(fs, env)).daemonToken;
}
/** A {@link PreferencesStore} bound to the real registry file, wired by the daemon so the
* dashboard's preferences RPCs read/write the user's home file.
*
* `onChange` is handed **the keys the caller wrote**, not the merged result, so a listener can
* tell "this write switched the setting on" from "it was already on and something else changed"
* (#1161). It runs after the write has landed, and its failure is swallowed: the save succeeded,
* and a listener must not be able to report otherwise. Same shape as the Discord store's
* `onChange` (#1095), for the same reason — a setting saved in the browser has to reach the
* daemon's own services without a restart.
*/
export function registryPreferencesStore(fs = nodeRegistryFs(), env = process.env, onChange) {
const changed = (written, result) => {
try {
onChange?.(written);
}
catch {
// The write landed; a listener that throws is not the writer's problem.
}
return result;
};
return {
read: () => readPreferences(fs, env),
save: async (preferences) => changed(preferences, await writePreferences(preferences, fs, env)),
patch: async (patch) => changed(patch, await patchPreferences(patch, fs, env)),
};
}
//# sourceMappingURL=registry.js.map