feat(v0.18.2.fork.1): sources CLI manifest editing — runUpdate + --slug-prefix
Adds the CLI surface needed to populate config.slug_prefix_rules per source so the Phase 2b manifest priority chain has data to act on. - runAdd extension: --slug-prefix '<rule>,<rule>' flag at source-create time. Comma-separated, each rule validated via parseSlugPrefixFlag. - runUpdate (NEW subcommand): replace manifest rules in-place on an existing source. --slug-prefix '' clears all rules. Preserves other config keys (federated, etc.). - Prefix grammar validator (Issue #6 from /plan-eng-review): fail-fast at write time. Rejects underscores, uppercase, mid-string '*', multi-level '**', empty after split, whitespace, oversize. Accepts literal prefix, trailing single '*', '/'-separated segments, hyphens. A typo'd rule never silently lands in jsonb — surfaces as CLI error. - runList output: human + JSON variants both surface slug_prefix_rules when present. - printHelp: full grammar reference + examples. - Dispatcher: case 'update' routes to runUpdate. Tests: - test/sources-update-slug-prefix.test.ts (new): runAdd --slug-prefix persistence, runUpdate replace + clear + preserve-other-keys, validator rejects (7 negative cases) + accepts (3 positive cases) bun test: 2204 pass / 0 fail / 250 skip (1 flaky cycle.test.ts timeout during full-sweep contention; 18/18 pass when run in isolation). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,58 @@ function validateSourceId(id: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
// v0.18.2.fork.1 — manifest prefix grammar.
|
||||
//
|
||||
// A slug-prefix rule is one of:
|
||||
// - Literal prefix: 'memory-dashboard/' (most common)
|
||||
// - Single-level glob: 'wedding-planning/*' (cosmetic; same as literal)
|
||||
//
|
||||
// Rules:
|
||||
// - Allowed chars: lowercase a-z, 0-9, '-' (hyphen), '/' (slash)
|
||||
// - Optional single trailing '*' (no other position)
|
||||
// - Length 1..64 (excluding the trailing '*')
|
||||
// - Reject underscores ('_'), uppercase, whitespace, mid-string '*',
|
||||
// multi-level globs ('**'), any other punctuation
|
||||
//
|
||||
// Why fail-fast at write time: a typo'd rule (`memory_dashboard/`) writes
|
||||
// successfully into config_jsonb but never matches anything at routing
|
||||
// time — the put_page silently falls to brain-default. Catching at
|
||||
// CLI-write moment surfaces "this is a typo" before bad data lands.
|
||||
const SLUG_PREFIX_RULE_RE = /^[a-z0-9](?:[a-z0-9-/]{0,62}[a-z0-9-/])?\*?$/;
|
||||
function validateSlugPrefix(rule: string): void {
|
||||
if (!rule || rule.length === 0) {
|
||||
throw new Error('Empty slug-prefix rule. Each --slug-prefix entry must be a non-empty string.');
|
||||
}
|
||||
if (rule.length > 64) {
|
||||
throw new Error(`Slug-prefix rule too long (${rule.length} chars, max 64): "${rule}"`);
|
||||
}
|
||||
if (rule.includes('**')) {
|
||||
throw new Error(`Multi-level glob ('**') not supported: "${rule}". Use a literal prefix or trailing single '*'.`);
|
||||
}
|
||||
// '*' permitted only as the final character.
|
||||
const starIdx = rule.indexOf('*');
|
||||
if (starIdx !== -1 && starIdx !== rule.length - 1) {
|
||||
throw new Error(`'*' may only appear as the final character: "${rule}". For a literal prefix, drop the '*'.`);
|
||||
}
|
||||
if (!SLUG_PREFIX_RULE_RE.test(rule)) {
|
||||
throw new Error(
|
||||
`Invalid slug-prefix rule "${rule}". ` +
|
||||
`Must be lowercase a-z, 0-9, '-', '/', optionally ending in '*'. ` +
|
||||
`Reject: underscores, uppercase, whitespace, other punctuation.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseSlugPrefixFlag(value: string): string[] {
|
||||
// Comma-separated list. Each item validated independently.
|
||||
const parts = value.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
||||
if (parts.length === 0) {
|
||||
throw new Error('--slug-prefix value is empty after parsing. Provide one or more comma-separated rules.');
|
||||
}
|
||||
for (const p of parts) validateSlugPrefix(p);
|
||||
return parts;
|
||||
}
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────
|
||||
|
||||
interface SourceRow {
|
||||
@@ -59,6 +111,8 @@ interface SourceListEntry {
|
||||
federated: boolean;
|
||||
page_count: number;
|
||||
last_sync_at: string | null;
|
||||
/** v0.18.2.fork.1 — surfaces config.slug_prefix_rules when set. */
|
||||
slug_prefix_rules?: string[];
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────
|
||||
@@ -98,7 +152,7 @@ async function countPages(engine: BrainEngine, sourceId: string): Promise<number
|
||||
async function runAdd(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
const id = args[0];
|
||||
if (!id) {
|
||||
console.error('Usage: gbrain sources add <id> --path <path> [--name <display>] [--federated|--no-federated]');
|
||||
console.error('Usage: gbrain sources add <id> [--path <path>] [--name <display>] [--federated|--no-federated] [--slug-prefix \'<rule>,<rule>\']');
|
||||
process.exit(2);
|
||||
}
|
||||
validateSourceId(id);
|
||||
@@ -106,6 +160,7 @@ async function runAdd(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
let localPath: string | null = null;
|
||||
let displayName = id;
|
||||
let federated: boolean | null = null; // null = default (false for new, opt-in via --federated)
|
||||
let slugPrefixRules: string[] | null = null;
|
||||
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
@@ -113,6 +168,7 @@ async function runAdd(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
if (a === '--name') { displayName = args[++i]; continue; }
|
||||
if (a === '--federated') { federated = true; continue; }
|
||||
if (a === '--no-federated') { federated = false; continue; }
|
||||
if (a === '--slug-prefix') { slugPrefixRules = parseSlugPrefixFlag(args[++i]); continue; }
|
||||
console.error(`Unknown flag: ${a}`);
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -138,7 +194,10 @@ async function runAdd(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const config = federated === null ? {} : { federated };
|
||||
const config: Record<string, unknown> = {};
|
||||
if (federated !== null) config.federated = federated;
|
||||
if (slugPrefixRules) config.slug_prefix_rules = slugPrefixRules;
|
||||
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO sources (id, name, local_path, config)
|
||||
VALUES ($1, $2, $3, $4::jsonb)
|
||||
@@ -154,6 +213,9 @@ async function runAdd(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
const fed = isFederated(created.config);
|
||||
console.log(`Created source "${id}"${displayName !== id ? ` (name: ${displayName})` : ''}${localPath ? ` → ${localPath}` : ''}`);
|
||||
console.log(` federated: ${fed}${fed ? ' — appears in cross-source default search' : ' — only searched when explicitly named via --source'}`);
|
||||
if (slugPrefixRules) {
|
||||
console.log(` slug_prefix_rules: ${slugPrefixRules.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Subcommand: list ────────────────────────────────────────
|
||||
@@ -169,6 +231,10 @@ async function runList(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
const entries: SourceListEntry[] = [];
|
||||
for (const r of rows) {
|
||||
const pageCount = await countPages(engine, r.id);
|
||||
const cfg = parseConfig(r.config);
|
||||
const rules = Array.isArray(cfg.slug_prefix_rules)
|
||||
? (cfg.slug_prefix_rules as unknown[]).filter(x => typeof x === 'string') as string[]
|
||||
: undefined;
|
||||
entries.push({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
@@ -176,6 +242,7 @@ async function runList(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
federated: isFederated(r.config),
|
||||
page_count: pageCount,
|
||||
last_sync_at: r.last_sync_at ? new Date(r.last_sync_at).toISOString() : null,
|
||||
...(rules && rules.length > 0 ? { slug_prefix_rules: rules } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -193,6 +260,9 @@ async function runList(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
const sync = e.last_sync_at ? `last sync ${e.last_sync_at}` : 'never synced';
|
||||
console.log(` ${e.id.padEnd(20)} ${fedMark.padEnd(10)} ${String(e.page_count).padStart(6)} pages ${sync}`);
|
||||
if (e.local_path) console.log(` ${' '.repeat(22)}${pathStr}`);
|
||||
if (e.slug_prefix_rules && e.slug_prefix_rules.length > 0) {
|
||||
console.log(` ${' '.repeat(22)}slug-prefix: ${e.slug_prefix_rules.join(', ')}`);
|
||||
}
|
||||
}
|
||||
if (entries.length === 0) console.log(' (no sources registered)');
|
||||
}
|
||||
@@ -324,6 +394,80 @@ async function runFederate(engine: BrainEngine, args: string[], value: boolean):
|
||||
console.log(`Source "${id}" is now ${value ? 'federated (appears in cross-source default search)' : 'isolated (only searched when explicitly named)'}.`);
|
||||
}
|
||||
|
||||
// ── Subcommand: update (v0.18.2.fork.1) ─────────────────────
|
||||
//
|
||||
// Mutates fields on an existing source in-place. Currently supports:
|
||||
// --slug-prefix '<rule>,<rule>' Replace config.slug_prefix_rules
|
||||
// --slug-prefix '' Clear all prefix rules
|
||||
//
|
||||
// Future flags can be slotted in (e.g. --read-boost / --preprocessor)
|
||||
// once the manifest projection model is implemented (PR #2+ per design
|
||||
// doc Open Q #4). Update is intentionally additive on the config_jsonb
|
||||
// blob, not destructive on other keys (federated, etc. remain).
|
||||
|
||||
async function runUpdate(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
const id = args[0];
|
||||
if (!id) {
|
||||
console.error("Usage: gbrain sources update <id> --slug-prefix '<rule>,<rule>'");
|
||||
process.exit(2);
|
||||
}
|
||||
validateSourceId(id);
|
||||
|
||||
const existing = await fetchSource(engine, id);
|
||||
if (!existing) {
|
||||
console.error(`Source "${id}" does not exist. Run 'gbrain sources add ${id}' first.`);
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
let slugPrefixRules: string[] | null = null;
|
||||
let slugPrefixSet = false;
|
||||
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === '--slug-prefix') {
|
||||
const value = args[++i];
|
||||
if (value === '') {
|
||||
// Explicit clear.
|
||||
slugPrefixRules = [];
|
||||
} else {
|
||||
slugPrefixRules = parseSlugPrefixFlag(value);
|
||||
}
|
||||
slugPrefixSet = true;
|
||||
continue;
|
||||
}
|
||||
console.error(`Unknown flag: ${a}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (!slugPrefixSet) {
|
||||
console.error('Nothing to update. Pass --slug-prefix to set/clear manifest rules.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const config = parseConfig(existing.config);
|
||||
if (slugPrefixRules && slugPrefixRules.length > 0) {
|
||||
config.slug_prefix_rules = slugPrefixRules;
|
||||
} else {
|
||||
delete config.slug_prefix_rules;
|
||||
}
|
||||
|
||||
await engine.executeRaw(
|
||||
`UPDATE sources SET config = $1::jsonb WHERE id = $2`,
|
||||
[JSON.stringify(config), id],
|
||||
);
|
||||
|
||||
if (slugPrefixRules && slugPrefixRules.length > 0) {
|
||||
console.log(`Updated source "${id}": slug_prefix_rules = ${slugPrefixRules.join(', ')}`);
|
||||
} else {
|
||||
console.log(`Updated source "${id}": slug_prefix_rules cleared`);
|
||||
}
|
||||
console.log(
|
||||
' Note: cache TTL ~60s — other gbrain processes (MCP container, sync cron) ' +
|
||||
'see the new rules within 60s. Restart with `docker compose restart gbrain-mcp` ' +
|
||||
'for immediate effect.',
|
||||
);
|
||||
}
|
||||
|
||||
// ── Dispatcher ──────────────────────────────────────────────
|
||||
|
||||
export async function runSources(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
@@ -332,6 +476,7 @@ export async function runSources(engine: BrainEngine, args: string[]): Promise<v
|
||||
|
||||
switch (sub) {
|
||||
case 'add': return runAdd(engine, rest);
|
||||
case 'update': return runUpdate(engine, rest);
|
||||
case 'list': return runList(engine, rest);
|
||||
case 'remove': return runRemove(engine, rest);
|
||||
case 'rename': return runRename(engine, rest);
|
||||
@@ -353,12 +498,23 @@ export async function runSources(engine: BrainEngine, args: string[]): Promise<v
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`gbrain sources — manage multi-source brain configuration (v0.18.0)
|
||||
console.log(`gbrain sources — manage multi-source brain configuration (v0.18.0 + v0.18.2.fork.1 manifest)
|
||||
|
||||
Subcommands:
|
||||
add <id> --path <p> [--name <n>] [--federated|--no-federated]
|
||||
Register a new source.
|
||||
list [--json] List registered sources with page counts.
|
||||
add <id> [--path <p>] [--name <n>] [--federated|--no-federated]
|
||||
[--slug-prefix '<rule>,<rule>']
|
||||
Register a new source. --slug-prefix
|
||||
enables manifest auto-routing: a put_page
|
||||
whose slug starts with one of these rules
|
||||
routes here automatically (priority 5 in
|
||||
the resolveSourceId chain).
|
||||
update <id> --slug-prefix '<rule>,<rule>'
|
||||
Replace manifest rules on an existing
|
||||
source. Pass empty string to clear.
|
||||
Other gbrain processes pick up the change
|
||||
within ~60s (cache TTL).
|
||||
list [--json] List registered sources with page counts
|
||||
and slug_prefix_rules.
|
||||
remove <id> [--yes] [--dry-run] Cascade-delete a source and its pages.
|
||||
rename <id> <new-name> Rename display name (id is immutable).
|
||||
default <id> Set the brain-level default source.
|
||||
@@ -368,5 +524,12 @@ Subcommands:
|
||||
unfederate <id> Isolate source from default search.
|
||||
|
||||
Source id: [a-z0-9-]{1,32}. Immutable citation key.
|
||||
|
||||
Slug-prefix rule grammar:
|
||||
- lowercase a-z, 0-9, '-', '/' allowed
|
||||
- optional single trailing '*'
|
||||
- max 64 chars
|
||||
- reject: underscores, uppercase, whitespace, mid-string '*', '**'
|
||||
examples: memory-dashboard/ projects/ wedding-planning/*
|
||||
`);
|
||||
}
|
||||
|
||||
154
test/sources-update-slug-prefix.test.ts
Normal file
154
test/sources-update-slug-prefix.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* v0.18.2.fork.1 — `gbrain sources add --slug-prefix` + new `update` subcommand.
|
||||
*
|
||||
* Verifies:
|
||||
* - runAdd accepts --slug-prefix '<rule>,<rule>' and writes config.slug_prefix_rules
|
||||
* - runUpdate replaces config.slug_prefix_rules in-place
|
||||
* - runUpdate '' clears the rules
|
||||
* - runUpdate on missing source errors out (exit 3)
|
||||
* - prefix validator rejects: underscores, uppercase, mid-string '*',
|
||||
* multi-level glob '**', empty, whitespace, oversize
|
||||
* - validator accepts: literal prefix, trailing '*', '/'-separated paths
|
||||
* - runUpdate preserves other config keys (federated stays put)
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { runSources } from '../src/commands/sources.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({ type: 'pglite' } as never);
|
||||
await engine.initSchema();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset fixture sources between tests so write-then-update doesn't leak across cases.
|
||||
await engine.executeRaw(
|
||||
`DELETE FROM sources WHERE id LIKE 'test-%'`,
|
||||
);
|
||||
});
|
||||
|
||||
async function readConfig(id: string): Promise<Record<string, unknown>> {
|
||||
const rows = await engine.executeRaw<{ config: string | Record<string, unknown> }>(
|
||||
`SELECT config FROM sources WHERE id = $1`,
|
||||
[id],
|
||||
);
|
||||
if (rows.length === 0) return {};
|
||||
const cfg = rows[0].config;
|
||||
return typeof cfg === 'string' ? JSON.parse(cfg) : cfg;
|
||||
}
|
||||
|
||||
describe('runAdd --slug-prefix', () => {
|
||||
test('accepts comma-separated rules and persists to config.slug_prefix_rules', async () => {
|
||||
await runSources(engine, ['add', 'test-md', '--slug-prefix', 'memory-dashboard/,builder-journey']);
|
||||
const cfg = await readConfig('test-md');
|
||||
expect(cfg.slug_prefix_rules).toEqual(['memory-dashboard/', 'builder-journey']);
|
||||
});
|
||||
|
||||
test('accepts trailing-glob form', async () => {
|
||||
await runSources(engine, ['add', 'test-glob', '--slug-prefix', 'wedding-planning/*']);
|
||||
const cfg = await readConfig('test-glob');
|
||||
expect(cfg.slug_prefix_rules).toEqual(['wedding-planning/*']);
|
||||
});
|
||||
|
||||
test('add without --slug-prefix leaves config without the key', async () => {
|
||||
await runSources(engine, ['add', 'test-bare']);
|
||||
const cfg = await readConfig('test-bare');
|
||||
expect(cfg.slug_prefix_rules).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('runUpdate --slug-prefix', () => {
|
||||
test('replaces rules in-place on existing source', async () => {
|
||||
await runSources(engine, ['add', 'test-update', '--slug-prefix', 'old-prefix/']);
|
||||
await runSources(engine, ['update', 'test-update', '--slug-prefix', 'new-a/,new-b/']);
|
||||
const cfg = await readConfig('test-update');
|
||||
expect(cfg.slug_prefix_rules).toEqual(['new-a/', 'new-b/']);
|
||||
});
|
||||
|
||||
test("update --slug-prefix '' clears rules entirely", async () => {
|
||||
await runSources(engine, ['add', 'test-clear', '--slug-prefix', 'foo/']);
|
||||
await runSources(engine, ['update', 'test-clear', '--slug-prefix', '']);
|
||||
const cfg = await readConfig('test-clear');
|
||||
expect(cfg.slug_prefix_rules).toBeUndefined();
|
||||
});
|
||||
|
||||
test('preserves other config keys (federated)', async () => {
|
||||
await runSources(engine, ['add', 'test-fed', '--federated', '--slug-prefix', 'a/']);
|
||||
await runSources(engine, ['update', 'test-fed', '--slug-prefix', 'b/']);
|
||||
const cfg = await readConfig('test-fed');
|
||||
expect(cfg.federated).toBe(true);
|
||||
expect(cfg.slug_prefix_rules).toEqual(['b/']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Prefix grammar validator (Issue #6 — fail-fast at write time)', () => {
|
||||
// Validator runs both at runAdd write time and runUpdate write time.
|
||||
// We test via runAdd since it's the canonical surface; same code path.
|
||||
|
||||
const expectReject = async (rule: string, hint: string) => {
|
||||
let threw = false;
|
||||
let msg = '';
|
||||
try {
|
||||
await runSources(engine, ['add', `test-reject-${Math.random().toString(36).slice(2, 8)}`, '--slug-prefix', rule]);
|
||||
} catch (e) {
|
||||
threw = true;
|
||||
msg = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
expect(threw).toBe(true);
|
||||
if (hint) expect(msg).toContain(hint);
|
||||
};
|
||||
|
||||
test('reject underscore', async () => {
|
||||
await expectReject('memory_dashboard/', 'Invalid slug-prefix');
|
||||
});
|
||||
test('reject uppercase', async () => {
|
||||
await expectReject('MemoryDashboard/', 'Invalid slug-prefix');
|
||||
});
|
||||
test('reject mid-string glob', async () => {
|
||||
await expectReject('foo*bar/', "'*' may only appear as the final character");
|
||||
});
|
||||
test('reject multi-level glob', async () => {
|
||||
await expectReject('a/**', "Multi-level glob");
|
||||
});
|
||||
test('reject empty after split', async () => {
|
||||
let threw = false;
|
||||
try {
|
||||
await runSources(engine, ['add', 'test-reject-empty', '--slug-prefix', ',,,']);
|
||||
} catch (e) {
|
||||
threw = true;
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
expect(msg).toContain('--slug-prefix value is empty');
|
||||
}
|
||||
expect(threw).toBe(true);
|
||||
});
|
||||
test('reject whitespace inside rule', async () => {
|
||||
await expectReject('foo bar/', 'Invalid slug-prefix');
|
||||
});
|
||||
test('reject oversized rule', async () => {
|
||||
await expectReject('a'.repeat(65) + '/', 'too long');
|
||||
});
|
||||
|
||||
test('accept literal prefix', async () => {
|
||||
await runSources(engine, ['add', 'test-accept-lit', '--slug-prefix', 'memory-dashboard/']);
|
||||
const cfg = await readConfig('test-accept-lit');
|
||||
expect(cfg.slug_prefix_rules).toEqual(['memory-dashboard/']);
|
||||
});
|
||||
test('accept trailing star', async () => {
|
||||
await runSources(engine, ['add', 'test-accept-star', '--slug-prefix', 'projects/*']);
|
||||
const cfg = await readConfig('test-accept-star');
|
||||
expect(cfg.slug_prefix_rules).toEqual(['projects/*']);
|
||||
});
|
||||
test('accept hyphen-segments and slashes', async () => {
|
||||
await runSources(engine, ['add', 'test-accept-segments', '--slug-prefix', 'design/memory-dashboard/internal/']);
|
||||
const cfg = await readConfig('test-accept-segments');
|
||||
expect(cfg.slug_prefix_rules).toEqual(['design/memory-dashboard/internal/']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user