diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 84f47488d..bb5e3f42d 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -909,6 +909,25 @@ export function buildAutoEmbedArgs(slugs: string[], sourceId?: string): string[] return sourceId ? ['--source', sourceId, '--slugs', ...slugs] : ['--slugs', ...slugs]; } +/** + * Resolve sync's effective no-embed mode from CLI args + config. + * + * The deferred-setup sentinel (`embedding_disabled: true`, written by + * `gbrain init --no-embedding`) is an implicit `--no-embed`: without this, + * the embed credential preflight demands provider credentials the user + * deliberately deferred at init, and every `gbrain sync` on a keyless + * brain exits 1. See embed-preflight.ts's skip protocol — the sentinel is + * meant to be honored before the credential check ever runs. + * + * Exported for `test/sync-no-embed-sentinel.test.ts`. + */ +export function resolveNoEmbed( + args: string[], + cfg: { embedding_disabled?: boolean } | null, +): boolean { + return args.includes('--no-embed') || cfg?.embedding_disabled === true; +} + /** * Shell out to git with a generous maxBuffer. * @@ -4023,7 +4042,7 @@ See also: const dryRun = args.includes('--dry-run'); const full = args.includes('--full'); const noPull = args.includes('--no-pull'); - const noEmbed = args.includes('--no-embed'); + const noEmbed = resolveNoEmbed(args, loadConfig()); const noExtract = args.includes('--no-extract'); // v0.42.7 #1696 const skipFailed = args.includes('--skip-failed'); const retryFailed = args.includes('--retry-failed'); diff --git a/test/sync-no-embed-sentinel.test.ts b/test/sync-no-embed-sentinel.test.ts new file mode 100644 index 000000000..ece713e2f --- /dev/null +++ b/test/sync-no-embed-sentinel.test.ts @@ -0,0 +1,39 @@ +/** + * Deferred-setup sentinel → implicit --no-embed for `gbrain sync`. + * + * `gbrain init --no-embedding` writes `embedding_disabled: true` to + * config.json. init, import, and embed honor that sentinel via + * `assertEmbeddingEnabled`, but sync's embed credential preflight + * (v0.41.6.0 D1) only checked the `--no-embed` CLI flag — so every + * `gbrain sync` on a keyless deferred-setup brain exited 1 with + * "Embedding model ... requires _API_KEY", even when nothing + * needed embedding. embed-preflight.ts's own skip protocol says the + * sentinel is owned upstream of the credential check. + * + * Pure-function tests; no DB, no gateway state. + */ +import { describe, test, expect } from 'bun:test'; +import { resolveNoEmbed } from '../src/commands/sync.ts'; + +describe('resolveNoEmbed', () => { + test('--no-embed flag opts out regardless of config', () => { + expect(resolveNoEmbed(['--no-embed'], null)).toBe(true); + expect(resolveNoEmbed(['--source', 's1', '--no-embed'], { embedding_disabled: false })).toBe(true); + }); + + test('embedding_disabled: true (deferred setup) is an implicit --no-embed', () => { + expect(resolveNoEmbed([], { embedding_disabled: true })).toBe(true); + expect(resolveNoEmbed(['--strategy', 'code', '--source', 's1'], { embedding_disabled: true })).toBe(true); + }); + + test('embedding-enabled brains still embed by default', () => { + expect(resolveNoEmbed([], null)).toBe(false); + expect(resolveNoEmbed([], {})).toBe(false); + expect(resolveNoEmbed([], { embedding_disabled: false })).toBe(false); + }); + + test('sentinel must be strictly true — junk config values do not disable embedding', () => { + expect(resolveNoEmbed([], { embedding_disabled: 'yes' as unknown as boolean })).toBe(false); + expect(resolveNoEmbed([], { embedding_disabled: 1 as unknown as boolean })).toBe(false); + }); +});