gstack/scripts/lenses/bundle.ts

120 lines
4.7 KiB
TypeScript

import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
export const DEFAULT_MAX_BUNDLE_BYTES = 200 * 1024;
const SAFE_ID_RE = /^[a-zA-Z0-9._-]{1,128}$/;
export interface BundleManifestEntry {
name: string;
source: 'diff' | 'repository' | 'context' | 'artifact' | 'missing';
path?: string;
truncated?: boolean;
note?: string;
}
export interface LensEvidenceBundle {
schema_version: 1;
run_id: string;
lens: string;
created_at: string;
manifest: BundleManifestEntry[];
context: Record<string, unknown>;
required_missing: string[];
evidence: Array<{
name: string;
source: BundleManifestEntry['source'];
content: string;
path?: string;
}>;
omissions?: string[];
}
export interface BundleOptions {
gstackHome?: string;
maxBytes?: number;
now?: Date;
}
function assertSafeId(value: string, label: string): void {
if (!SAFE_ID_RE.test(value)) throw new Error(`${label} must match ${SAFE_ID_RE}`);
}
export function lensBundleRoot(options: BundleOptions = {}): string {
const home = options.gstackHome ?? process.env.GSTACK_HOME ?? path.join(os.homedir(), '.gstack');
return path.join(home, 'lens-bundles');
}
export function lensBundlePath(runId: string, lens: string, options: BundleOptions = {}): string {
assertSafeId(runId, 'run_id');
assertSafeId(lens, 'lens');
return path.join(lensBundleRoot(options), runId, lens, 'bundle.json');
}
function ensureSecureDirectory(dirPath: string): void {
fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
fs.chmodSync(dirPath, 0o700);
}
function validateBundle(bundle: LensEvidenceBundle): void {
if (!bundle || typeof bundle !== 'object' || Array.isArray(bundle)) throw new Error('Bundle must be a JSON object');
if (bundle.schema_version !== 1) throw new Error('Bundle schema_version must be 1');
assertSafeId(bundle.run_id, 'run_id');
assertSafeId(bundle.lens, 'lens');
if (!Array.isArray(bundle.manifest)) throw new Error('Bundle manifest must be an array');
if (!bundle.context || typeof bundle.context !== 'object' || Array.isArray(bundle.context)) throw new Error('Bundle context must be an object');
if (!Array.isArray(bundle.required_missing) || bundle.required_missing.some((item) => typeof item !== 'string')) {
throw new Error('Bundle required_missing must be an array of strings');
}
if (!Array.isArray(bundle.evidence)) throw new Error('Bundle evidence must be an array');
for (const item of bundle.evidence) {
if (!item || typeof item !== 'object' || typeof item.name !== 'string' || typeof item.content !== 'string') {
throw new Error('Each bundle evidence entry requires name and content strings');
}
}
}
export function createBundle(input: Omit<LensEvidenceBundle, 'schema_version' | 'created_at'> & Partial<Pick<LensEvidenceBundle, 'schema_version' | 'created_at'>>, options: BundleOptions = {}): LensEvidenceBundle {
const bundle: LensEvidenceBundle = {
...input,
schema_version: 1,
created_at: input.created_at ?? (options.now ?? new Date()).toISOString(),
} as LensEvidenceBundle;
validateBundle(bundle);
return bundle;
}
export function writeLensBundle(bundle: LensEvidenceBundle, options: BundleOptions = {}): { path: string; bytes: number } {
validateBundle(bundle);
const serialized = `${JSON.stringify(bundle, null, 2)}\n`;
const bytes = Buffer.byteLength(serialized, 'utf8');
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BUNDLE_BYTES;
if (bytes > maxBytes) throw new Error(`Evidence bundle is ${bytes} bytes, exceeding the ${maxBytes}-byte limit`);
const filePath = lensBundlePath(bundle.run_id, bundle.lens, options);
ensureSecureDirectory(path.dirname(filePath));
const temp = `${filePath}.tmp-${process.pid}`;
fs.writeFileSync(temp, serialized, { encoding: 'utf8', mode: 0o600 });
fs.chmodSync(temp, 0o600);
fs.renameSync(temp, filePath);
fs.chmodSync(filePath, 0o600);
return { path: filePath, bytes };
}
export function readLensBundle(runId: string, lens: string, options: BundleOptions = {}): LensEvidenceBundle {
const filePath = lensBundlePath(runId, lens, options);
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as LensEvidenceBundle;
validateBundle(parsed);
if (parsed.run_id !== runId || parsed.lens !== lens) throw new Error('Bundle identity does not match requested run and lens');
return parsed;
}
export function purgeLensBundleRun(runId: string, options: BundleOptions = {}): string {
assertSafeId(runId, 'run_id');
const root = lensBundleRoot(options);
const target = path.join(root, runId);
const relative = path.relative(root, target);
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('Refusing unsafe bundle purge path');
fs.rmSync(target, { recursive: true, force: true });
return target;
}