diff --git a/bin/gstack-gbrain-sync.ts b/bin/gstack-gbrain-sync.ts index 61d9e677f7..b64bbad3ab 100644 --- a/bin/gstack-gbrain-sync.ts +++ b/bin/gstack-gbrain-sync.ts @@ -289,11 +289,17 @@ function gbrainSupportsSourcesRename(env?: NodeJS.ProcessEnv): boolean { * helper can be exercised without a real gbrain CLI. */ export function sourceLocalPath(sourceId: string, env?: NodeJS.ProcessEnv): string | null { - const list = execGbrainJson>( - ["sources", "list", "--json"], - { baseEnv: env }, - ); - if (!list) return null; + // gbrain v0.30+ returns {"sources":[...]}; older releases returned a bare + // array. Handle both shapes so this works across the compat window. The + // sibling helper in lib/gbrain-sources.ts (statusForSourceId) already does + // this; the v1.40 hostname-fold migration was the lone holdout and crashed + // with `list.find is not a function` on every gbrain v0.30+ install. + const raw = execGbrainJson< + | Array<{ id: string; local_path?: string }> + | { sources?: Array<{ id: string; local_path?: string }> } + >(["sources", "list", "--json"], { baseEnv: env }); + if (!raw) return null; + const list = Array.isArray(raw) ? raw : raw.sources || []; const found = list.find((s) => s.id === sourceId); return found?.local_path ?? null; } diff --git a/test/gstack-gbrain-sync.test.ts b/test/gstack-gbrain-sync.test.ts index 0f1edec214..d3feed12eb 100644 --- a/test/gstack-gbrain-sync.test.ts +++ b/test/gstack-gbrain-sync.test.ts @@ -837,4 +837,40 @@ describe("sourceLocalPath", () => { }); expect(sourceLocalPath("any-id", envWithBindir(bindir))).toBeNull(); }); + + // Regression: gbrain v0.30+ wraps the list as {"sources":[...]} instead of + // returning a bare array. The previous implementation crashed with + // `list.find is not a function` on every current gbrain install, which + // broke `runCodeImport`'s hostname-fold migration on first sync after + // upgrading to gstack v1.40 + gbrain v0.30+. Both shapes must work to + // keep the compat window intact. + it("returns local_path when gbrain wraps the list as {sources: [...]} (v0.30+ shape)", () => { + makeShim(bindir, { + "sources list --json": { + stdout: JSON.stringify({ + sources: [ + { id: "other-source", local_path: "/x" }, + { id: "target-id", local_path: "/repo/wrapped" }, + ], + }), + }, + }); + expect(sourceLocalPath("target-id", envWithBindir(bindir))).toBe("/repo/wrapped"); + }); + + it("returns null when the wrapped {sources: [...]} shape has no matching id", () => { + makeShim(bindir, { + "sources list --json": { + stdout: JSON.stringify({ sources: [{ id: "other", local_path: "/x" }] }), + }, + }); + expect(sourceLocalPath("missing-id", envWithBindir(bindir))).toBeNull(); + }); + + it("treats {sources: undefined} the same as an empty list", () => { + makeShim(bindir, { + "sources list --json": { stdout: JSON.stringify({}) }, + }); + expect(sourceLocalPath("any-id", envWithBindir(bindir))).toBeNull(); + }); });