mirror of https://github.com/garrytan/gstack.git
588 lines
26 KiB
Plaintext
Executable File
588 lines
26 KiB
Plaintext
Executable File
#!/usr/bin/env bun
|
|
/**
|
|
* gstack-brain-cache — three-tier cache for brain-aware planning skills.
|
|
*
|
|
* Subcommands:
|
|
* get <entity-name> [--project <slug>] — return digest content; refresh if stale
|
|
* refresh [--full] [--entity X] [--project <slug>] — force refresh one or all
|
|
* invalidate <entity-name> [--project <slug>] — mark stale; next get triggers cold
|
|
* digest <entity-slug> — compress a brain page slug to digest
|
|
* meta [--project <slug>] — print _meta.json
|
|
*
|
|
* (Later commits add: bootstrap [T2b], list [T18], purge [T18], retention sweep [T18].)
|
|
*
|
|
* Cache layout:
|
|
* ~/.gstack/brain-cache/ ← cross-project (user-profile only)
|
|
* ~/.gstack/projects/<slug>/brain-cache/ ← per-project (everything else)
|
|
*
|
|
* Atomic writes via .tmp + rename. Stale-but-usable fallback when brain
|
|
* unreachable. Concurrent-refresh dedup is a follow-up commit (T15).
|
|
*/
|
|
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, statSync, unlinkSync, readdirSync } from 'fs';
|
|
import { join, dirname } from 'path';
|
|
import { homedir } from 'os';
|
|
import { execGbrainJson, spawnGbrain } from '../lib/gbrain-exec';
|
|
import {
|
|
BRAIN_CACHE_ENTITIES,
|
|
GSTACK_SCHEMA_PACK_NAME,
|
|
GSTACK_SCHEMA_PACK_VERSION,
|
|
type BrainCacheEntity,
|
|
} from '../scripts/brain-cache-spec';
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
// Paths + meta
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
|
|
const GSTACK_HOME = process.env.GSTACK_HOME || join(homedir(), '.gstack');
|
|
|
|
interface CacheMeta {
|
|
/** Version of the schema pack the cache was built against. Mismatch → full rebuild. */
|
|
schema_version: string;
|
|
/** SHA8 hash of the brain MCP endpoint URL (or 'local' for on-disk engines). */
|
|
endpoint_hash: string;
|
|
/** Per-entity last-refresh epoch ms. Absent → never refreshed. */
|
|
last_refresh: Record<string, number>;
|
|
/** Per-entity last-attempt epoch ms (even if attempt failed). For stale-but-usable diagnostics. */
|
|
last_attempt?: Record<string, number>;
|
|
}
|
|
|
|
/** Returns the directory holding a given entity's cache file. */
|
|
export function entityDir(entity: BrainCacheEntity, projectSlug: string | null): string {
|
|
if (entity.scope === 'cross-project') {
|
|
return join(GSTACK_HOME, 'brain-cache');
|
|
}
|
|
if (!projectSlug) {
|
|
throw new Error(`Per-project entity needs a project slug: ${entity.file}`);
|
|
}
|
|
return join(GSTACK_HOME, 'projects', projectSlug, 'brain-cache');
|
|
}
|
|
|
|
/** Returns the path to the cache file for a given entity. */
|
|
export function entityPath(entityName: string, projectSlug: string | null): string {
|
|
const entity = BRAIN_CACHE_ENTITIES[entityName];
|
|
if (!entity) throw new Error(`Unknown brain cache entity: ${entityName}`);
|
|
return join(entityDir(entity, projectSlug), entity.file);
|
|
}
|
|
|
|
/** Returns the path to the _meta.json for a given scope. */
|
|
export function metaPath(scope: 'cross-project' | 'per-project', projectSlug: string | null): string {
|
|
if (scope === 'cross-project') {
|
|
return join(GSTACK_HOME, 'brain-cache', '_meta.json');
|
|
}
|
|
if (!projectSlug) throw new Error('Per-project meta needs a project slug');
|
|
return join(GSTACK_HOME, 'projects', projectSlug, 'brain-cache', '_meta.json');
|
|
}
|
|
|
|
function loadMeta(scope: 'cross-project' | 'per-project', projectSlug: string | null): CacheMeta {
|
|
const path = metaPath(scope, projectSlug);
|
|
if (!existsSync(path)) {
|
|
return { schema_version: GSTACK_SCHEMA_PACK_VERSION, endpoint_hash: detectEndpointHash(), last_refresh: {}, last_attempt: {} };
|
|
}
|
|
try {
|
|
return JSON.parse(readFileSync(path, 'utf-8')) as CacheMeta;
|
|
} catch {
|
|
// Corrupt _meta — start fresh (entries will refresh on next access).
|
|
return { schema_version: GSTACK_SCHEMA_PACK_VERSION, endpoint_hash: detectEndpointHash(), last_refresh: {}, last_attempt: {} };
|
|
}
|
|
}
|
|
|
|
function saveMeta(scope: 'cross-project' | 'per-project', projectSlug: string | null, meta: CacheMeta): void {
|
|
const path = metaPath(scope, projectSlug);
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
atomicWrite(path, JSON.stringify(meta, null, 2));
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
// Endpoint hash detection
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
|
|
import { createHash } from 'crypto';
|
|
|
|
function sha8(input: string): string {
|
|
return createHash('sha256').update(input).digest('hex').slice(0, 8);
|
|
}
|
|
|
|
/**
|
|
* Detects the active brain endpoint (MCP URL or 'local') and returns its
|
|
* stable identity hash. Used to detect when the user switches brains
|
|
* (different endpoint → different cache).
|
|
*/
|
|
export function detectEndpointHash(): string {
|
|
const claudeJsonPath = join(homedir(), '.claude.json');
|
|
if (existsSync(claudeJsonPath)) {
|
|
try {
|
|
const cfg = JSON.parse(readFileSync(claudeJsonPath, 'utf-8'));
|
|
const gbrainServer = cfg?.mcpServers?.gbrain;
|
|
const url = gbrainServer?.url || gbrainServer?.transport?.url;
|
|
if (typeof url === 'string' && url.length > 0) {
|
|
return sha8(url);
|
|
}
|
|
} catch { /* fall through to local */ }
|
|
}
|
|
// Local engine — no endpoint URL; use a stable literal hash.
|
|
return 'local';
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
// Atomic write (tmp + rename)
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
|
|
function atomicWrite(path: string, content: string): void {
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
|
|
writeFileSync(tmp, content, 'utf-8');
|
|
renameSync(tmp, path);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
// Staleness + refresh logic
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
|
|
/** Returns true if the cached digest is past its TTL. */
|
|
function isStale(entityName: string, meta: CacheMeta): boolean {
|
|
const entity = BRAIN_CACHE_ENTITIES[entityName];
|
|
if (!entity) return true;
|
|
const last = meta.last_refresh[entityName];
|
|
if (!last) return true;
|
|
return Date.now() - last > entity.ttl_ms;
|
|
}
|
|
|
|
/** Returns true if the cache file exists on disk. */
|
|
function hasFile(entityName: string, projectSlug: string | null): boolean {
|
|
return existsSync(entityPath(entityName, projectSlug));
|
|
}
|
|
|
|
/** Returns true if schema version recorded in meta differs from current pack version. */
|
|
function schemaVersionMismatch(meta: CacheMeta): boolean {
|
|
return meta.schema_version !== GSTACK_SCHEMA_PACK_VERSION;
|
|
}
|
|
|
|
/** Returns true if endpoint hash recorded in meta differs from current detected endpoint. */
|
|
function endpointSwitched(meta: CacheMeta): boolean {
|
|
return meta.endpoint_hash !== detectEndpointHash();
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
// Subcommand: get
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
|
|
interface GetResult {
|
|
/** Path to the digest file. */
|
|
path: string;
|
|
/** Cache state: 'warm' (fresh + valid), 'cold-refreshed' (was stale, refreshed inline), 'stale-fallback' (used stale because refresh failed), 'missing' (no cache and no refresh). */
|
|
state: 'warm' | 'cold-refreshed' | 'stale-fallback' | 'missing';
|
|
/** Optional message for diagnostics. */
|
|
message?: string;
|
|
}
|
|
|
|
export function cmdGet(entityName: string, projectSlug: string | null): GetResult {
|
|
const entity = BRAIN_CACHE_ENTITIES[entityName];
|
|
if (!entity) throw new Error(`Unknown entity: ${entityName}`);
|
|
const scope = entity.scope;
|
|
const meta = loadMeta(scope, projectSlug);
|
|
|
|
// Schema-version mismatch → full rebuild (D4 A4).
|
|
if (schemaVersionMismatch(meta) || endpointSwitched(meta)) {
|
|
rebuildAllForScope(scope, projectSlug);
|
|
// After rebuild, meta is fresh; fall through to warm path.
|
|
const newMeta = loadMeta(scope, projectSlug);
|
|
if (hasFile(entityName, projectSlug) && !isStale(entityName, newMeta)) {
|
|
return { path: entityPath(entityName, projectSlug), state: 'warm' };
|
|
}
|
|
// Rebuild may have failed for this entity specifically.
|
|
return { path: entityPath(entityName, projectSlug), state: 'missing', message: 'rebuild after schema/endpoint change' };
|
|
}
|
|
|
|
if (hasFile(entityName, projectSlug) && !isStale(entityName, meta)) {
|
|
return { path: entityPath(entityName, projectSlug), state: 'warm' };
|
|
}
|
|
|
|
// Stale or missing — try cold refresh.
|
|
const refreshed = refreshEntity(entityName, projectSlug);
|
|
if (refreshed) {
|
|
return { path: entityPath(entityName, projectSlug), state: 'cold-refreshed' };
|
|
}
|
|
// Refresh failed. Use stale-but-usable if file exists.
|
|
if (hasFile(entityName, projectSlug)) {
|
|
return { path: entityPath(entityName, projectSlug), state: 'stale-fallback', message: 'brain unreachable; using stale cache' };
|
|
}
|
|
// No cache and no refresh = missing.
|
|
return { path: entityPath(entityName, projectSlug), state: 'missing', message: 'brain unreachable; no cache available' };
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
// Subcommand: refresh
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
|
|
/** Refreshes one entity from the brain. Returns true on success. */
|
|
export function refreshEntity(entityName: string, projectSlug: string | null): boolean {
|
|
const entity = BRAIN_CACHE_ENTITIES[entityName];
|
|
if (!entity) return false;
|
|
|
|
// Mark attempt
|
|
const meta = loadMeta(entity.scope, projectSlug);
|
|
meta.last_attempt = meta.last_attempt || {};
|
|
meta.last_attempt[entityName] = Date.now();
|
|
|
|
// Fetch from brain. The actual fetch logic varies per entity — derived digests
|
|
// (recent-decisions, salience) need different queries from direct page reads.
|
|
// For T2a we implement the direct-page path; derived digests get filled in by
|
|
// the resolver / write-back paths in later commits.
|
|
const digestContent = fetchAndCompressEntity(entityName, projectSlug);
|
|
if (digestContent === null) {
|
|
saveMeta(entity.scope, projectSlug, meta);
|
|
return false;
|
|
}
|
|
|
|
// Enforce per-entity budget by truncating from end (oldest items live there
|
|
// by convention in our compressor). The per-skill budget is separately
|
|
// enforced at preflight injection time.
|
|
let final = digestContent;
|
|
if (Buffer.byteLength(final, 'utf-8') > entity.budget_bytes) {
|
|
final = truncateToBudget(final, entity.budget_bytes);
|
|
}
|
|
|
|
atomicWrite(entityPath(entityName, projectSlug), final);
|
|
meta.last_refresh[entityName] = Date.now();
|
|
// Keep schema/endpoint identity fresh.
|
|
meta.schema_version = GSTACK_SCHEMA_PACK_VERSION;
|
|
meta.endpoint_hash = detectEndpointHash();
|
|
saveMeta(entity.scope, projectSlug, meta);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Refresh all entities for a scope (per-project or cross-project).
|
|
* Used by --full and by schema/endpoint-change rebuilds.
|
|
*/
|
|
export function refreshAll(projectSlug: string | null): { success: number; failed: number } {
|
|
let success = 0;
|
|
let failed = 0;
|
|
for (const [name, entity] of Object.entries(BRAIN_CACHE_ENTITIES)) {
|
|
// Cross-project entities only refresh when explicitly targeted via no-slug calls
|
|
if (entity.scope === 'cross-project' && projectSlug) continue;
|
|
if (entity.scope === 'per-project' && !projectSlug) continue;
|
|
if (refreshEntity(name, projectSlug)) success++; else failed++;
|
|
}
|
|
return { success, failed };
|
|
}
|
|
|
|
/** Rebuild on schema-version mismatch or endpoint switch. Wipes affected scope first. */
|
|
function rebuildAllForScope(scope: 'cross-project' | 'per-project', projectSlug: string | null): void {
|
|
// Wipe files but preserve dir; meta gets fully rewritten by refreshes below.
|
|
for (const [name, entity] of Object.entries(BRAIN_CACHE_ENTITIES)) {
|
|
if (entity.scope !== scope) continue;
|
|
const p = entityPath(name, projectSlug);
|
|
if (existsSync(p)) {
|
|
try { unlinkSync(p); } catch { /* best effort */ }
|
|
}
|
|
}
|
|
// Fresh meta starts here
|
|
const fresh: CacheMeta = {
|
|
schema_version: GSTACK_SCHEMA_PACK_VERSION,
|
|
endpoint_hash: detectEndpointHash(),
|
|
last_refresh: {},
|
|
last_attempt: {},
|
|
};
|
|
saveMeta(scope, projectSlug, fresh);
|
|
// Refresh all entities in this scope
|
|
for (const [name, entity] of Object.entries(BRAIN_CACHE_ENTITIES)) {
|
|
if (entity.scope !== scope) continue;
|
|
refreshEntity(name, projectSlug);
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
// Subcommand: invalidate
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
|
|
export function cmdInvalidate(entityName: string, projectSlug: string | null): void {
|
|
const entity = BRAIN_CACHE_ENTITIES[entityName];
|
|
if (!entity) throw new Error(`Unknown entity: ${entityName}`);
|
|
const meta = loadMeta(entity.scope, projectSlug);
|
|
delete meta.last_refresh[entityName];
|
|
saveMeta(entity.scope, projectSlug, meta);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
// Fetch + compress per-entity
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Returns the digest markdown content for an entity, or null if the brain is
|
|
* unreachable / the source page doesn't exist.
|
|
*
|
|
* For T2a we implement the entity → page-slug mapping for the simple cases.
|
|
* Derived digests (recent-decisions, salience) get specialized paths.
|
|
*/
|
|
function fetchAndCompressEntity(entityName: string, projectSlug: string | null): string | null {
|
|
switch (entityName) {
|
|
case 'user-profile':
|
|
return fetchUserProfile();
|
|
case 'product':
|
|
return fetchProduct(projectSlug);
|
|
case 'goals':
|
|
return fetchGoals(projectSlug);
|
|
case 'developer-persona':
|
|
return fetchSimplePage(`gstack/developer-persona/${projectSlug}`);
|
|
case 'brand':
|
|
return fetchSimplePage(`gstack/brand/${projectSlug}`);
|
|
case 'competitive-intel':
|
|
return fetchSimplePage(`gstack/competitive-intel/${projectSlug}`);
|
|
case 'recent-decisions':
|
|
return fetchRecentDecisions(projectSlug);
|
|
case 'salience':
|
|
// D9 salience allowlist applied in T17 commit; T2a returns raw output for now.
|
|
return fetchSalience(projectSlug);
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Generic single-page fetch via `gbrain get`. Returns null on miss/unreachable. */
|
|
function fetchSimplePage(slug: string): string | null {
|
|
const result = spawnGbrain(['get', slug, '--json'], { timeout: 10_000 });
|
|
if (result.status !== 0) return null;
|
|
try {
|
|
const page = JSON.parse(result.stdout) as { body?: string; title?: string };
|
|
if (!page?.body) return null;
|
|
return compressPage(slug, page.title || slug, page.body);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function fetchUserProfile(): string | null {
|
|
// The user-slug discovery is implemented in T16 (D4 A3). For T2a we accept
|
|
// env GSTACK_USER_SLUG as override, fallback to $USER for direct calls.
|
|
const slug = process.env.GSTACK_USER_SLUG || process.env.USER || 'unknown';
|
|
return fetchSimplePage(`gstack/user-profile/${slug}`);
|
|
}
|
|
|
|
function fetchProduct(projectSlug: string | null): string | null {
|
|
if (!projectSlug) return null;
|
|
return fetchSimplePage(`gstack/product/${projectSlug}`);
|
|
}
|
|
|
|
/**
|
|
* Goals are LIST queries: all gstack/goal/<project>/* pages.
|
|
* Compress the top N by recency.
|
|
*/
|
|
function fetchGoals(projectSlug: string | null): string | null {
|
|
if (!projectSlug) return null;
|
|
const result = execGbrainJson<{ pages?: Array<{ slug: string; title?: string; body?: string }> }>([
|
|
'list-pages',
|
|
'--type', 'gstack/goal',
|
|
'--limit', '10',
|
|
'--json',
|
|
]);
|
|
if (!result?.pages) return null;
|
|
const goals = result.pages.filter((p) => p.slug?.startsWith(`gstack/goal/${projectSlug}/`));
|
|
if (goals.length === 0) {
|
|
// Empty digest is valid (just header + 'no active goals' line)
|
|
return `# Active goals (project: ${projectSlug})\n\n_No active goals recorded yet._\n`;
|
|
}
|
|
const lines = goals.map((g) => `- [[${g.slug}]] — ${g.title || '(untitled)'}`);
|
|
return `# Active goals (project: ${projectSlug})\n\n${lines.join('\n')}\n`;
|
|
}
|
|
|
|
/**
|
|
* recent-decisions: last 5 gstack/skill-run pages for this project, compressed
|
|
* to one-line summaries.
|
|
*/
|
|
function fetchRecentDecisions(projectSlug: string | null): string | null {
|
|
if (!projectSlug) return null;
|
|
const result = execGbrainJson<{ pages?: Array<{ slug: string; title?: string }> }>([
|
|
'list-pages',
|
|
'--type', 'gstack/skill-run',
|
|
'--limit', '5',
|
|
'--sort', 'updated_desc',
|
|
'--json',
|
|
]);
|
|
if (!result?.pages) {
|
|
return `# Recent decisions (project: ${projectSlug})\n\n_No prior skill runs recorded._\n`;
|
|
}
|
|
const lines = result.pages.map((p) => `- ${p.title || p.slug}`);
|
|
return `# Recent decisions (project: ${projectSlug})\n\n${lines.join('\n')}\n`;
|
|
}
|
|
|
|
function fetchSalience(projectSlug: string | null): string | null {
|
|
// get-recent-salience is a gbrain CLI sub-shape; we use the MCP-shape JSON
|
|
const result = execGbrainJson<{ pages?: Array<{ slug: string; title?: string; emotional_weight?: number }> }>([
|
|
'get-recent-salience',
|
|
'--days', '14',
|
|
'--limit', '10',
|
|
'--json',
|
|
]);
|
|
if (!result?.pages) return `# Recent salience\n\n_No salient pages in last 14d._\n`;
|
|
// T17 will add allowlist filtering here. For T2a we return raw output.
|
|
const lines = result.pages.map((p) => `- [[${p.slug}]] — ${p.title || ''} (weight: ${p.emotional_weight?.toFixed(2) ?? 'n/a'})`);
|
|
return `# Recent salience (last 14d)\n\n${lines.join('\n')}\n`;
|
|
}
|
|
|
|
/**
|
|
* Compress a brain page body into a digest. The compressor keeps frontmatter
|
|
* out, trims body to the first H2/H3 sections, and prepends a slug header.
|
|
* Per-entity budget enforcement happens at the caller (refreshEntity).
|
|
*/
|
|
function compressPage(slug: string, title: string, body: string): string {
|
|
const trimmed = body
|
|
.replace(/^---[\s\S]*?---\s*\n/m, '') // strip frontmatter
|
|
.trim();
|
|
return `# ${title}\nslug: ${slug}\n\n${trimmed}\n`;
|
|
}
|
|
|
|
/**
|
|
* Truncate a digest to a byte budget. Tries to cut at the last newline before
|
|
* the budget so the digest stays readable.
|
|
*/
|
|
function truncateToBudget(content: string, budgetBytes: number): string {
|
|
const buf = Buffer.from(content, 'utf-8');
|
|
if (buf.byteLength <= budgetBytes) return content;
|
|
const truncated = buf.slice(0, budgetBytes).toString('utf-8');
|
|
const lastNewline = truncated.lastIndexOf('\n');
|
|
const cleanCut = lastNewline > budgetBytes * 0.8 ? truncated.slice(0, lastNewline) : truncated;
|
|
return `${cleanCut}\n\n_(digest truncated to ${budgetBytes}-byte budget)_\n`;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
// Subcommand: digest
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Public: compress a brain page slug to digest format. Used by callers that
|
|
* want to know what the digest WOULD look like without writing to cache.
|
|
*/
|
|
export function cmdDigest(slug: string): string | null {
|
|
return fetchSimplePage(slug);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
// Subcommand: meta
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
|
|
export function cmdMeta(projectSlug: string | null): CacheMeta {
|
|
if (projectSlug) return loadMeta('per-project', projectSlug);
|
|
return loadMeta('cross-project', null);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
// CLI dispatch
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
|
|
function parseArgs(argv: string[]): { cmd: string; positional: string[]; flags: Record<string, string | boolean> } {
|
|
const cmd = argv[2] || '';
|
|
const rest = argv.slice(3);
|
|
const positional: string[] = [];
|
|
const flags: Record<string, string | boolean> = {};
|
|
for (let i = 0; i < rest.length; i++) {
|
|
const arg = rest[i];
|
|
if (arg.startsWith('--')) {
|
|
const key = arg.slice(2);
|
|
const next = rest[i + 1];
|
|
if (next && !next.startsWith('--')) {
|
|
flags[key] = next;
|
|
i++;
|
|
} else {
|
|
flags[key] = true;
|
|
}
|
|
} else {
|
|
positional.push(arg);
|
|
}
|
|
}
|
|
return { cmd, positional, flags };
|
|
}
|
|
|
|
function projectSlugFromFlag(flags: Record<string, string | boolean>): string | null {
|
|
const v = flags.project;
|
|
return typeof v === 'string' ? v : null;
|
|
}
|
|
|
|
function printUsage(): void {
|
|
process.stderr.write(`Usage: gstack-brain-cache <subcommand>
|
|
|
|
Subcommands:
|
|
get <entity-name> [--project <slug>]
|
|
refresh [--full] [--entity X] [--project <slug>]
|
|
invalidate <entity-name> [--project <slug>]
|
|
digest <entity-slug>
|
|
meta [--project <slug>]
|
|
`);
|
|
}
|
|
|
|
async function main(): Promise<number> {
|
|
const { cmd, positional, flags } = parseArgs(process.argv);
|
|
const projectSlug = projectSlugFromFlag(flags);
|
|
|
|
try {
|
|
switch (cmd) {
|
|
case 'get': {
|
|
const entityName = positional[0];
|
|
if (!entityName) { printUsage(); return 1; }
|
|
const result = cmdGet(entityName, projectSlug);
|
|
if (result.state === 'missing') {
|
|
process.stderr.write(`(${result.state}: ${result.message ?? 'no cache'})\n`);
|
|
return 2;
|
|
}
|
|
if (result.state !== 'warm') {
|
|
process.stderr.write(`(${result.state}${result.message ? ': ' + result.message : ''})\n`);
|
|
}
|
|
process.stdout.write(readFileSync(result.path, 'utf-8'));
|
|
return 0;
|
|
}
|
|
case 'refresh': {
|
|
if (flags.entity) {
|
|
const ok = refreshEntity(String(flags.entity), projectSlug);
|
|
process.stdout.write(ok ? `refreshed ${flags.entity}\n` : `failed to refresh ${flags.entity}\n`);
|
|
return ok ? 0 : 1;
|
|
}
|
|
const { success, failed } = refreshAll(projectSlug);
|
|
process.stdout.write(`refreshed=${success} failed=${failed}\n`);
|
|
return failed > 0 ? 1 : 0;
|
|
}
|
|
case 'invalidate': {
|
|
const entityName = positional[0];
|
|
if (!entityName) { printUsage(); return 1; }
|
|
cmdInvalidate(entityName, projectSlug);
|
|
process.stdout.write(`invalidated ${entityName}\n`);
|
|
return 0;
|
|
}
|
|
case 'digest': {
|
|
const slug = positional[0];
|
|
if (!slug) { printUsage(); return 1; }
|
|
const content = cmdDigest(slug);
|
|
if (content === null) {
|
|
process.stderr.write('brain unreachable or page not found\n');
|
|
return 2;
|
|
}
|
|
process.stdout.write(content);
|
|
return 0;
|
|
}
|
|
case 'meta': {
|
|
const meta = cmdMeta(projectSlug);
|
|
process.stdout.write(JSON.stringify(meta, null, 2) + '\n');
|
|
return 0;
|
|
}
|
|
case '':
|
|
case 'help':
|
|
case '--help':
|
|
case '-h':
|
|
printUsage();
|
|
return 0;
|
|
default:
|
|
process.stderr.write(`unknown subcommand: ${cmd}\n`);
|
|
printUsage();
|
|
return 1;
|
|
}
|
|
} catch (err) {
|
|
process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
// Only run main when invoked as a script (not when imported by tests)
|
|
if (import.meta.main) {
|
|
main().then((code) => process.exit(code));
|
|
}
|