From 27462385bdd954f805423e0194bef6272a5c172e Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Sun, 5 Apr 2026 15:58:48 +0700 Subject: [PATCH 01/10] feat: [ENG-1929] Temporarily Remove brv init --- src/oclif/commands/init.ts | 5 +++++ src/oclif/hooks/init/validate-brv-config.ts | 1 - 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/oclif/commands/init.ts b/src/oclif/commands/init.ts index 5bea47f81..2c32e6194 100644 --- a/src/oclif/commands/init.ts +++ b/src/oclif/commands/init.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-unreachable */ import {Command, Flags} from '@oclif/core' import {InitEvents, type InitLocalResponse} from '../../shared/transport/events/init-events.js' @@ -14,12 +15,16 @@ export default class Init extends Command { description: 'Force re-initialization even if already initialized', }), } + public static hidden = true protected getDaemonOptions(): DaemonClientOptions { return {projectPath: process.cwd()} } public async run(): Promise { + this.log('The init command is not available. Use: brv vc init') + return + const {flags} = await this.parse(Init) const daemonOptions = this.getDaemonOptions() diff --git a/src/oclif/hooks/init/validate-brv-config.ts b/src/oclif/hooks/init/validate-brv-config.ts index 6808c8fe0..e10d92185 100644 --- a/src/oclif/hooks/init/validate-brv-config.ts +++ b/src/oclif/hooks/init/validate-brv-config.ts @@ -17,7 +17,6 @@ import {getProjectDataDir} from '../../../server/utils/path-utils.js' export const SKIP_COMMANDS = new Set([ '--help', 'help', - 'init', 'login', 'logout', 'main', From 99f44d8250c95533c397e6fa3680416bc7fb5b21 Mon Sep 17 00:00:00 2001 From: Cuong Date: Sun, 5 Apr 2026 16:24:38 +0700 Subject: [PATCH 02/10] fix: [ENG-1948] treat identical OIDs as ancestor in branch -d merge check When deleting a branch that points to the same commit as HEAD, `isAncestor` incorrectly returned false because `isDescendent` uses a strict proper-ancestor test. A commit is trivially an ancestor of itself, so short-circuit to true when OIDs match. --- src/server/infra/git/isomorphic-git-service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/server/infra/git/isomorphic-git-service.ts b/src/server/infra/git/isomorphic-git-service.ts index 6ad2a6e8e..78639dc21 100644 --- a/src/server/infra/git/isomorphic-git-service.ts +++ b/src/server/infra/git/isomorphic-git-service.ts @@ -424,6 +424,7 @@ export class IsomorphicGitService implements IGitService { const dir = this.requireDirectory(params) const commitOid = await git.resolveRef({dir, fs, ref: params.commit}) const ancestorOid = await git.resolveRef({dir, fs, ref: params.ancestor}) + if (commitOid === ancestorOid) return true return git.isDescendent({ancestor: ancestorOid, depth: -1, dir, fs, oid: commitOid}) } From 1136707f73de6e6ba26837dfe17261e839ac468d Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Sun, 5 Apr 2026 16:33:38 +0700 Subject: [PATCH 03/10] feat: [ENG-1929] skip test init --- test/commands/init.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/commands/init.test.ts b/test/commands/init.test.ts index cd6f78ab0..aac12a4e1 100644 --- a/test/commands/init.test.ts +++ b/test/commands/init.test.ts @@ -40,7 +40,7 @@ class TestableInitCommand extends Init { } } -describe('Init Command', () => { +describe.skip('Init Command', () => { let config: Config let loggedMessages: string[] let mockClient: sinon.SinonStubbedInstance From d1c3583de1054cb647cd2de13d6459c06b07b0c6 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Sun, 5 Apr 2026 16:47:01 +0700 Subject: [PATCH 04/10] feat: [ENG-1905] Store and use name-based URLs instead of UUIDs --- .env.example | 1 - src/server/config/environment.ts | 2 - src/server/infra/git/cogit-url.ts | 51 +-------- src/server/infra/process/feature-handlers.ts | 1 - .../infra/transport/handlers/vc-handler.ts | 46 +++----- src/tui/lib/environment.ts | 2 +- .../infra/git/isomorphic-git-service.test.ts | 8 +- test/unit/config/auth.config.test.ts | 1 - test/unit/config/environment.test.ts | 1 - test/unit/infra/git/git-remote-url.test.ts | 71 ++----------- .../transport/handlers/vc-handler.test.ts | 100 +++++++++++------- 11 files changed, 87 insertions(+), 197 deletions(-) diff --git a/.env.example b/.env.example index bcdad8724..af08f44e0 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,6 @@ BRV_API_BASE_URL=http://localhost:3000/api/v1 BRV_AUTHORIZATION_URL=http://localhost:3000/api/v1/oidc/authorize BRV_COGIT_API_BASE_URL=http://localhost:3001/api/v1 -BRV_GIT_API_BASE_URL=http://localhost:3001 BRV_GIT_REMOTE_BASE_URL=http://localhost:8080 BRV_ISSUER_URL=http://localhost:3000/api/v1/oidc BRV_LLM_API_BASE_URL=http://localhost:3002 diff --git a/src/server/config/environment.ts b/src/server/config/environment.ts index 136fe6cfa..e16160db6 100644 --- a/src/server/config/environment.ts +++ b/src/server/config/environment.ts @@ -21,7 +21,6 @@ type EnvironmentConfig = { authorizationUrl: string clientId: string cogitApiBaseUrl: string - gitApiBaseUrl: string gitRemoteBaseUrl: string hubRegistryUrl: string issuerUrl: string @@ -57,7 +56,6 @@ export const getCurrentConfig = (): EnvironmentConfig => ({ authorizationUrl: readRequiredEnv('BRV_AUTHORIZATION_URL'), clientId: DEFAULTS.clientId, cogitApiBaseUrl: readRequiredEnv('BRV_COGIT_API_BASE_URL'), - gitApiBaseUrl: readRequiredEnv('BRV_GIT_API_BASE_URL'), gitRemoteBaseUrl: readRequiredEnv('BRV_GIT_REMOTE_BASE_URL'), hubRegistryUrl: DEFAULTS.hubRegistryUrl, issuerUrl: readRequiredEnv('BRV_ISSUER_URL'), diff --git a/src/server/infra/git/cogit-url.ts b/src/server/infra/git/cogit-url.ts index 9d6047662..7008f5803 100644 --- a/src/server/infra/git/cogit-url.ts +++ b/src/server/infra/git/cogit-url.ts @@ -1,50 +1,15 @@ /** * Build the CoGit Git remote URL for a given team and space. - * Format: {gitApiBaseUrl}/git/{teamId}/{spaceId}.git + * Format: {baseUrl}/{teamName}/{spaceName}.git */ -export function buildCogitRemoteUrl(baseUrl: string, teamId: string, spaceId: string): string { +export function buildCogitRemoteUrl(baseUrl: string, teamName: string, spaceName: string): string { const base = baseUrl.replace(/\/$/, '') - return `${base}/git/${teamId}/${spaceId}.git` + return `${base}/${teamName}/${spaceName}.git` } /** - * Remove credentials from a URL, returning only the host + path. - */ -export function stripCredentialsFromUrl(url: string): string { - try { - const parsed = new URL(url) - parsed.username = '' - parsed.password = '' - return parsed.toString() - } catch { - return url - } -} - -/** - * Parse a URL that contains /git/{segment1}/{segment2}.git - * Returns the two segments and whether they look like UUIDs. - */ -export function parseGitPathUrl(url: string): null | { - areUuids: boolean - segment1: string - segment2: string -} { - try { - const parsed = new URL(url) - const match = parsed.pathname.match(/^\/git\/([^/]+)\/([^/]+?)\.git$/) - if (!match) return null - const segment1 = match[1] - const segment2 = match[2] - return {areUuids: isUuid(segment1) && isUuid(segment2), segment1, segment2} - } catch { - return null - } -} - -/** - * Parse a user-facing .git URL to extract team and space names. - * Expected path: /{teamName}/{spaceName}.git (no /git/ prefix) + * Parse a .git URL to extract team and space names. + * Expected path: /{teamName}/{spaceName}.git */ export function parseUserFacingUrl(url: string): null | {spaceName: string; teamName: string} { try { @@ -69,9 +34,3 @@ export function isValidBranchName(name: string): boolean { return !/\.\.|[~^:?*[\\\u0000-\u001F\u007F]/.test(name) } -/** - * Check if a string looks like a UUID (v1-v7, with hyphens). - */ -function isUuid(value: string): boolean { - return /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i.test(value) -} diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index ae9deec97..934f845dd 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -250,7 +250,6 @@ export async function setupFeatureHandlers({ new VcHandler({ broadcastToProject, contextTreeService, - gitApiBaseUrl: envConfig.gitApiBaseUrl, gitRemoteBaseUrl: envConfig.gitRemoteBaseUrl, gitService, projectConfigStore, diff --git a/src/server/infra/transport/handlers/vc-handler.ts b/src/server/infra/transport/handlers/vc-handler.ts index 82fe4e400..9ff3f2d2f 100644 --- a/src/server/infra/transport/handlers/vc-handler.ts +++ b/src/server/infra/transport/handlers/vc-handler.ts @@ -52,7 +52,7 @@ import {GitAuthError, GitError} from '../../../core/domain/errors/git-error.js' import {NotAuthenticatedError} from '../../../core/domain/errors/task-error.js' import {VcError} from '../../../core/domain/errors/vc-error.js' import {ensureGitignoreEntries} from '../../../utils/gitignore.js' -import {buildCogitRemoteUrl, isValidBranchName, parseGitPathUrl, parseUserFacingUrl} from '../../git/cogit-url.js' +import {buildCogitRemoteUrl, isValidBranchName, parseUserFacingUrl} from '../../git/cogit-url.js' import {type ProjectBroadcaster, type ProjectPathResolver, resolveRequiredProjectPath} from './handler-types.js' /** @@ -85,7 +85,6 @@ const FIELD_MAP: Record = { export interface IVcHandlerDeps { broadcastToProject: ProjectBroadcaster contextTreeService: IContextTreeService - gitApiBaseUrl: string gitRemoteBaseUrl: string gitService: IGitService projectConfigStore: IProjectConfigStore @@ -104,7 +103,6 @@ export interface IVcHandlerDeps { export class VcHandler { private readonly broadcastToProject: ProjectBroadcaster private readonly contextTreeService: IContextTreeService - private readonly gitApiBaseUrl: string private readonly gitRemoteBaseUrl: string private readonly gitService: IGitService private readonly projectConfigStore: IProjectConfigStore @@ -118,7 +116,6 @@ export class VcHandler { constructor(deps: IVcHandlerDeps) { this.broadcastToProject = deps.broadcastToProject - this.gitApiBaseUrl = deps.gitApiBaseUrl this.gitRemoteBaseUrl = deps.gitRemoteBaseUrl this.contextTreeService = deps.contextTreeService this.gitService = deps.gitService @@ -1154,7 +1151,7 @@ export class VcHandler { /** * Resolve clone request data into a clean cogit URL + team/space info. - * Accepts either a URL (any format) or explicit teamId/spaceId. + * Accepts either a URL or explicit teamName/spaceName. * Auth is handled by IsomorphicGitService via headers, not URL credentials. */ private async resolveCloneInput(data: IVcCloneRequest): Promise<{ @@ -1175,13 +1172,13 @@ export class VcHandler { } } - if (data.teamId && data.spaceId) { + if (data.teamName && data.spaceName) { return { spaceId: data.spaceId, spaceName: data.spaceName, teamId: data.teamId, teamName: data.teamName, - url: buildCogitRemoteUrl(this.gitApiBaseUrl, data.teamId, data.spaceId), + url: buildCogitRemoteUrl(this.gitRemoteBaseUrl, data.teamName, data.spaceName), } } @@ -1189,12 +1186,9 @@ export class VcHandler { } /** - * Resolve any URL format to a clean cogit URL + team/space info. - * Supports: - * 1. Cogit URL with UUIDs (/git/{uuid}/{uuid}.git) → strip credentials, return clean - * 2. Cogit URL with names (/git/{name}/{name}.git) → resolve names to IDs - * 3. User-facing .git URL (/{name}/{name}.git) → resolve names to IDs - * 4. Unknown format → reject (no credential leaking to arbitrary URLs) + * Resolve a remote URL to a clean cogit URL + team/space info. + * Expected format: {domain}/{teamName}/{spaceName}.git + * Resolves names to IDs via API; rejects unknown formats. * * Auth is handled by IsomorphicGitService via headers, not URL credentials. */ @@ -1205,29 +1199,13 @@ export class VcHandler { teamName?: string url: string }> { - // Validate the URL domain against known hosts this.validateRemoteUrlDomain(url) - // /git/{segment1}/{segment2}.git - const gitPath = parseGitPathUrl(url) - if (gitPath) { - if (gitPath.areUuids) { - // UUIDs — build clean URL (strip any credentials that may be in the URL) - const cleanUrl = buildCogitRemoteUrl(this.gitApiBaseUrl, gitPath.segment1, gitPath.segment2) - return {spaceId: gitPath.segment2, teamId: gitPath.segment1, url: cleanUrl} - } - - // Names — resolve to IDs via API - return this.resolveTeamSpaceNames(gitPath.segment1, gitPath.segment2) - } - - // User-facing .git URL (/{teamName}/{spaceName}.git, no /git/ prefix) - const userFacingParts = parseUserFacingUrl(url) - if (userFacingParts) { - return this.resolveTeamSpaceNames(userFacingParts.teamName, userFacingParts.spaceName) + const parsed = parseUserFacingUrl(url) + if (parsed) { + return this.resolveTeamSpaceNames(parsed.teamName, parsed.spaceName) } - // Unknown format — reject to prevent credential leaking to arbitrary URLs throw new VcError( `Invalid URL format. Use: ${this.gitRemoteBaseUrl}//.git`, VcErrorCode.INVALID_REMOTE_URL, @@ -1349,7 +1327,7 @@ export class VcHandler { spaceName: space.name, teamId: space.teamId, teamName: team.name, - url: buildCogitRemoteUrl(this.gitApiBaseUrl, space.teamId, space.id), + url: buildCogitRemoteUrl(this.gitRemoteBaseUrl, team.name, space.name), } } @@ -1366,7 +1344,7 @@ export class VcHandler { private validateRemoteUrlDomain(url: string): void { try { const parsed = new URL(url) - const allowedHosts = [this.gitRemoteBaseUrl, this.gitApiBaseUrl, this.webAppUrl].map((u) => new URL(u).host) + const allowedHosts = [this.gitRemoteBaseUrl].map((u) => new URL(u).host) if (!allowedHosts.includes(parsed.host)) { throw new VcError( `Invalid remote URL. Use: ${this.gitRemoteBaseUrl}//.git`, diff --git a/src/tui/lib/environment.ts b/src/tui/lib/environment.ts index 343d34767..cad0bb8f0 100644 --- a/src/tui/lib/environment.ts +++ b/src/tui/lib/environment.ts @@ -30,4 +30,4 @@ export const getWebAppUrl = (): string => * Git remote base URL for the current environment. */ export const getGitRemoteBaseUrl = (): string => - process.env.BRV_GIT_REMOTE_BASE_URL ?? (isDevelopment() ? 'https://dev-beta-app.byterover.dev' : 'https://byterover.dev') + process.env.BRV_GIT_REMOTE_BASE_URL ?? (isDevelopment() ? 'https://dev-beta.byterover.dev' : 'https://byterover.dev') diff --git a/test/integration/infra/git/isomorphic-git-service.test.ts b/test/integration/infra/git/isomorphic-git-service.test.ts index 63e7bf610..6b1b3e07d 100644 --- a/test/integration/infra/git/isomorphic-git-service.test.ts +++ b/test/integration/infra/git/isomorphic-git-service.test.ts @@ -948,12 +948,12 @@ describe('IsomorphicGitService', () => { }) it('addRemote + listRemotes', async () => { - await service.addRemote({directory: testDir, remote: 'origin', url: `${COGIT_BASE}/git/team-1/space-1.git`}) + await service.addRemote({directory: testDir, remote: 'origin', url: `${COGIT_BASE}/team-1/space-1.git`}) const remotes = await service.listRemotes({directory: testDir}) expect(remotes).to.have.length(1) expect(remotes[0].remote).to.equal('origin') - expect(remotes[0].url).to.equal(`${COGIT_BASE}/git/team-1/space-1.git`) + expect(remotes[0].url).to.equal(`${COGIT_BASE}/team-1/space-1.git`) }) it('listRemotes returns empty array when no remotes', async () => { @@ -962,7 +962,7 @@ describe('IsomorphicGitService', () => { }) it('getRemoteUrl returns URL for existing remote', async () => { - const url = `${COGIT_BASE}/git/team-1/space-1.git` + const url = `${COGIT_BASE}/team-1/space-1.git` await service.addRemote({directory: testDir, remote: 'origin', url}) expect(await service.getRemoteUrl({directory: testDir, remote: 'origin'})).to.equal(url) }) @@ -995,7 +995,7 @@ describe('IsomorphicGitService', () => { await service.init({directory: testDir}) // Remote is required — onAuth is only invoked when isomorphic-git has a URL to connect to - await service.addRemote({directory: testDir, remote: 'origin', url: `${COGIT_BASE}/git/team-1/space-1.git`}) + await service.addRemote({directory: testDir, remote: 'origin', url: `${COGIT_BASE}/team-1/space-1.git`}) await writeFile(join(testDir, 'f.md'), 'x') await service.add({directory: testDir, filePaths: ['f.md']}) await service.commit({directory: testDir, message: 'init'}) diff --git a/test/unit/config/auth.config.test.ts b/test/unit/config/auth.config.test.ts index 656c2e2e9..1935479ea 100644 --- a/test/unit/config/auth.config.test.ts +++ b/test/unit/config/auth.config.test.ts @@ -12,7 +12,6 @@ describe('Auth Configuration', () => { BRV_API_BASE_URL: 'https://api.test', BRV_AUTHORIZATION_URL: 'https://auth.test/authorize', BRV_COGIT_API_BASE_URL: 'https://cogit.test', - BRV_GIT_API_BASE_URL: 'https://cogit-git.test', BRV_GIT_REMOTE_BASE_URL: 'https://cogit-git.test', BRV_ISSUER_URL: 'https://issuer.test', BRV_LLM_API_BASE_URL: 'https://llm.test', diff --git a/test/unit/config/environment.test.ts b/test/unit/config/environment.test.ts index e8a84c0c3..89ad09154 100644 --- a/test/unit/config/environment.test.ts +++ b/test/unit/config/environment.test.ts @@ -5,7 +5,6 @@ describe('Environment Configuration', () => { BRV_API_BASE_URL: 'https://api.test', BRV_AUTHORIZATION_URL: 'https://auth.test/authorize', BRV_COGIT_API_BASE_URL: 'https://cogit.test', - BRV_GIT_API_BASE_URL: 'https://cogit-git.test', BRV_GIT_REMOTE_BASE_URL: 'https://cogit-git.test', BRV_ISSUER_URL: 'https://issuer.test', BRV_LLM_API_BASE_URL: 'https://llm.test', diff --git a/test/unit/infra/git/git-remote-url.test.ts b/test/unit/infra/git/git-remote-url.test.ts index 882fe832c..c8105d2cd 100644 --- a/test/unit/infra/git/git-remote-url.test.ts +++ b/test/unit/infra/git/git-remote-url.test.ts @@ -1,30 +1,25 @@ import {expect} from 'chai' -import { - buildCogitRemoteUrl, - parseGitPathUrl, - parseUserFacingUrl, - stripCredentialsFromUrl, -} from '../../../../src/server/infra/git/cogit-url.js' +import {buildCogitRemoteUrl, parseUserFacingUrl} from '../../../../src/server/infra/git/cogit-url.js' const FAKE_BASE = 'https://fake-cgit.example.com' describe('cogit-url', () => { describe('buildCogitRemoteUrl', () => { - it('should build correct URL from base + teamId + spaceId', () => { + it('should build correct URL from base + teamName + spaceName', () => { const url = buildCogitRemoteUrl(FAKE_BASE, 'team-123', 'space-456') - expect(url).to.equal(`${FAKE_BASE}/git/team-123/space-456.git`) + expect(url).to.equal(`${FAKE_BASE}/team-123/space-456.git`) }) it('should trim trailing slash from base URL', () => { const url = buildCogitRemoteUrl(`${FAKE_BASE}/`, 'team-123', 'space-456') - expect(url).to.equal(`${FAKE_BASE}/git/team-123/space-456.git`) + expect(url).to.equal(`${FAKE_BASE}/team-123/space-456.git`) expect(url.replace('https://', '')).to.not.include('//') }) - it('should handle teamId and spaceId with hyphens and numbers', () => { + it('should handle teamName and spaceName with hyphens and numbers', () => { const url = buildCogitRemoteUrl(FAKE_BASE, 'team-abc-123', 'space-xyz-789') - expect(url).to.equal(`${FAKE_BASE}/git/team-abc-123/space-xyz-789.git`) + expect(url).to.equal(`${FAKE_BASE}/team-abc-123/space-xyz-789.git`) }) it('should always end with .git', () => { @@ -33,56 +28,6 @@ describe('cogit-url', () => { }) }) - describe('stripCredentialsFromUrl', () => { - it('should remove credentials from URL', () => { - const result = stripCredentialsFromUrl('https://user:pass@example.com/git/team/space.git') - expect(result).to.equal('https://example.com/git/team/space.git') - }) - - it('should return unchanged URL if no credentials', () => { - const url = 'https://example.com/git/team/space.git' - expect(stripCredentialsFromUrl(url)).to.equal(url) - }) - - it('should return unchanged string for invalid URL', () => { - expect(stripCredentialsFromUrl('not-a-url')).to.equal('not-a-url') - }) - }) - - describe('parseGitPathUrl', () => { - it('should extract segments from .git URL and detect non-UUID names', () => { - const result = parseGitPathUrl('https://dev-beta-cgit.byterover.dev/git/team-123/space-456.git') - expect(result).to.deep.equal({areUuids: false, segment1: 'team-123', segment2: 'space-456'}) - }) - - it('should detect UUID-style IDs', () => { - const result = parseGitPathUrl( - 'https://dev-beta-cgit.byterover.dev/git/019b6b1f-38b4-7932-868c-3fa137fd4327/019b6b1f-62d7-7464-b654-72c69c71746b.git', - ) - expect(result).to.not.be.null - expect(result!.areUuids).to.be.true - expect(result!.segment1).to.equal('019b6b1f-38b4-7932-868c-3fa137fd4327') - expect(result!.segment2).to.equal('019b6b1f-62d7-7464-b654-72c69c71746b') - }) - - it('should work with credentials in URL', () => { - const result = parseGitPathUrl('https://user:pass@dev-beta-cgit.byterover.dev/git/team-1/space-2.git') - expect(result).to.deep.equal({areUuids: false, segment1: 'team-1', segment2: 'space-2'}) - }) - - it('should return null for .brv extension in /git/ path', () => { - expect(parseGitPathUrl('https://dev-beta-cgit.byterover.dev/git/Team2/test-git.brv')).to.be.null - }) - - it('should return null for non-cogit URL', () => { - expect(parseGitPathUrl('https://example.com/repo.git')).to.be.null - }) - - it('should return null for invalid URL', () => { - expect(parseGitPathUrl('not-a-url')).to.be.null - }) - }) - describe('parseUserFacingUrl', () => { it('should extract teamName and spaceName from valid .git URL', () => { const result = parseUserFacingUrl('https://byterover.dev/acme/project.git') @@ -94,10 +39,6 @@ describe('cogit-url', () => { expect(result).to.deep.equal({spaceName: 'my-space', teamName: 'my-team'}) }) - it('should return null for cogit URL with /git/ prefix', () => { - expect(parseUserFacingUrl('https://example.com/git/team/space.git')).to.be.null - }) - it('should return null for URL without .git extension', () => { expect(parseUserFacingUrl('https://byterover.dev/acme/project')).to.be.null }) diff --git a/test/unit/infra/transport/handlers/vc-handler.test.ts b/test/unit/infra/transport/handlers/vc-handler.test.ts index 413481468..9e38bfcec 100644 --- a/test/unit/infra/transport/handlers/vc-handler.test.ts +++ b/test/unit/infra/transport/handlers/vc-handler.test.ts @@ -189,7 +189,6 @@ function makeVcHandler(deps: TestDeps): VcHandler { return new VcHandler({ broadcastToProject: deps.broadcastToProject, contextTreeService: deps.contextTreeService, - gitApiBaseUrl: 'https://test-cogit.byterover.dev', gitRemoteBaseUrl: 'https://byterover.dev', gitService: deps.gitService, projectConfigStore: deps.projectConfigStore, @@ -203,6 +202,17 @@ function makeVcHandler(deps: TestDeps): VcHandler { }) } +function stubDefaultTeamSpace(deps: TestDeps): void { + deps.teamService.getTeams.resolves({ + teams: [{displayName: 'Teambao1', id: 'tid-1', isActive: true, isDefault: false, name: 'teambao1'}], + total: 1, + }) + deps.spaceService.getSpaces.resolves({ + spaces: [{id: 'sid-1', isDefault: false, name: 'test-space', teamId: 'tid-1', teamName: 'teambao1'}], + total: 1, + }) +} + const projectPath = '/fake/brv/project' /** @@ -1710,42 +1720,44 @@ describe('VcHandler', () => { userId: 'u1', }) - it('should clone with cogit URL and inject credentials when missing', async () => { + it('should clone with name-based URL', async () => { const deps = makeDeps(sandbox, projectPath) deps.gitService.isInitialized.resolves(false) deps.tokenStore.load.resolves(validToken) + stubDefaultTeamSpace(deps) makeVcHandler(deps).setup() const result = await invoke<{gitDir: string}>(deps, VcEvents.CLONE, { - url: 'https://test-cogit.byterover.dev/git/019b0001-0000-0000-0000-000000000001/019b0002-0000-0000-0000-000000000002.git', + url: 'https://byterover.dev/teambao1/test-space.git', }) expect(result.gitDir).to.include('.git') expect(deps.gitService.clone.calledOnce).to.be.true const cloneArgs = deps.gitService.clone.firstCall.args[0] expect(cloneArgs.url).to.equal( - 'https://test-cogit.byterover.dev/git/019b0001-0000-0000-0000-000000000001/019b0002-0000-0000-0000-000000000002.git', + 'https://byterover.dev/teambao1/test-space.git', ) }) - it('should clone with full URL that already has credentials', async () => { + it('should strip credentials from URL when cloning', async () => { const deps = makeDeps(sandbox, projectPath) deps.gitService.isInitialized.resolves(false) deps.tokenStore.load.resolves(validToken) + stubDefaultTeamSpace(deps) makeVcHandler(deps).setup() const fullUrl = - 'https://uid:key@test-cogit.byterover.dev/git/019b0001-0000-0000-0000-000000000001/019b0002-0000-0000-0000-000000000002.git' + 'https://uid:key@byterover.dev/teambao1/test-space.git' await invoke(deps, VcEvents.CLONE, {url: fullUrl}) // Credentials stripped — clean URL used for clone (auth via headers) const cloneArgs = deps.gitService.clone.firstCall.args[0] expect(cloneArgs.url).to.equal( - 'https://test-cogit.byterover.dev/git/019b0001-0000-0000-0000-000000000001/019b0002-0000-0000-0000-000000000002.git', + 'https://byterover.dev/teambao1/test-space.git', ) }) - it('should clone with names in cogit-style URL by resolving to IDs', async () => { + it('should clone with names in URL by resolving team/space names', async () => { const deps = makeDeps(sandbox, projectPath) deps.gitService.isInitialized.resolves(false) deps.tokenStore.load.resolves(validToken) @@ -1760,17 +1772,17 @@ describe('VcHandler', () => { makeVcHandler(deps).setup() const result = await invoke<{gitDir: string; spaceName?: string; teamName?: string}>(deps, VcEvents.CLONE, { - url: 'https://test-cogit.byterover.dev/git/Teambao1/test-git.git', + url: 'https://byterover.dev/Teambao1/test-git.git', }) expect(result.gitDir).to.include('.git') expect(result.teamName).to.equal('Teambao1') expect(result.spaceName).to.equal('test-git') const cloneArgs = deps.gitService.clone.firstCall.args[0] - expect(cloneArgs.url).to.equal('https://test-cogit.byterover.dev/git/tid-1/sid-1.git') + expect(cloneArgs.url).to.equal('https://byterover.dev/Teambao1/test-git.git') }) - it('should clone with user-facing .git URL by resolving team/space names to IDs', async () => { + it('should clone with user-facing .git URL by resolving team/space names', async () => { const deps = makeDeps(sandbox, projectPath) deps.gitService.isInitialized.resolves(false) deps.tokenStore.load.resolves(validToken) @@ -1792,7 +1804,7 @@ describe('VcHandler', () => { expect(result.teamName).to.equal('acme') expect(result.spaceName).to.equal('project') const cloneArgs = deps.gitService.clone.firstCall.args[0] - expect(cloneArgs.url).to.equal('https://test-cogit.byterover.dev/git/tid-1/sid-1.git') + expect(cloneArgs.url).to.equal('https://byterover.dev/acme/project.git') }) it('should resolve team name case-insensitively in cogit-style URL', async () => { @@ -1810,7 +1822,7 @@ describe('VcHandler', () => { makeVcHandler(deps).setup() const result = await invoke<{gitDir: string; spaceName?: string; teamName?: string}>(deps, VcEvents.CLONE, { - url: 'https://test-cogit.byterover.dev/git/teambao1/TEST-GIT.git', + url: 'https://byterover.dev/teambao1/TEST-GIT.git', }) expect(result.teamName).to.equal('Teambao1') @@ -1889,7 +1901,7 @@ describe('VcHandler', () => { try { await invoke(deps, VcEvents.CLONE, { - url: 'https://test-cogit.byterover.dev/git/TeamName/space-name.git', + url: 'https://byterover.dev/TeamName/space-name.git', }) expect.fail('Expected error') } catch (error) { @@ -1940,7 +1952,7 @@ describe('VcHandler', () => { try { await invoke(deps, VcEvents.CLONE, { - url: 'https://test-cogit.byterover.dev/git/019b0001-0000-0000-0000-000000000001/019b0002-0000-0000-0000-000000000002.git', + url: 'https://byterover.dev/teambao1/test-space.git', }) expect.fail('Expected error') } catch (error) { @@ -1956,11 +1968,12 @@ describe('VcHandler', () => { deps.gitService.isInitialized.resolves(true) deps.gitService.isEmptyRepository.resolves(true) deps.tokenStore.load.resolves(validToken) + stubDefaultTeamSpace(deps) const rmStub = sandbox.stub(fs.promises, 'rm').resolves() makeVcHandler(deps).setup() await invoke(deps, VcEvents.CLONE, { - url: 'https://test-cogit.byterover.dev/git/019b0001-0000-0000-0000-000000000001/019b0002-0000-0000-0000-000000000002.git', + url: 'https://byterover.dev/teambao1/test-space.git', }) expect(rmStub.calledWith(join(deps.contextTreeDirPath, '.git'))).to.be.true @@ -1971,10 +1984,11 @@ describe('VcHandler', () => { const deps = makeDeps(sandbox, projectPath) deps.gitService.isInitialized.resolves(false) deps.tokenStore.load.resolves(validToken) + stubDefaultTeamSpace(deps) makeVcHandler(deps).setup() await invoke(deps, VcEvents.CLONE, { - url: 'https://test-cogit.byterover.dev/git/019b0001-0000-0000-0000-000000000001/019b0002-0000-0000-0000-000000000002.git', + url: 'https://byterover.dev/teambao1/test-space.git', }) expect(deps.gitService.isEmptyRepository.called).to.be.false @@ -1984,10 +1998,11 @@ describe('VcHandler', () => { const deps = makeDeps(sandbox, projectPath) deps.gitService.isInitialized.resolves(false) deps.tokenStore.load.resolves(validToken) + stubDefaultTeamSpace(deps) makeVcHandler(deps).setup() await invoke(deps, VcEvents.CLONE, { - url: 'https://test-cogit.byterover.dev/git/019b0001-0000-0000-0000-000000000001/019b0002-0000-0000-0000-000000000002.git', + url: 'https://byterover.dev/teambao1/test-space.git', }) const writeFileStub = fs.promises.writeFile as SinonStub @@ -2000,12 +2015,13 @@ describe('VcHandler', () => { const deps = makeDeps(sandbox, projectPath) deps.gitService.isInitialized.resolves(false) deps.tokenStore.load.resolves(validToken) + stubDefaultTeamSpace(deps) deps.gitService.clone.rejects(new Error('network error')) makeVcHandler(deps).setup() try { await invoke(deps, VcEvents.CLONE, { - url: 'https://test-cogit.byterover.dev/git/019b0001-0000-0000-0000-000000000001/019b0002-0000-0000-0000-000000000002.git', + url: 'https://byterover.dev/teambao1/test-space.git', }) expect.fail('Expected error') } catch { @@ -2018,13 +2034,14 @@ describe('VcHandler', () => { const deps = makeDeps(sandbox, projectPath) deps.gitService.isInitialized.resolves(false) deps.tokenStore.load.resolves(validToken) + stubDefaultTeamSpace(deps) const httpError = Object.assign(new Error('HTTP Error: 404 Not Found'), {code: 'HttpError'}) deps.gitService.clone.rejects(httpError) makeVcHandler(deps).setup() try { await invoke(deps, VcEvents.CLONE, { - url: 'https://test-cogit.byterover.dev/git/019b0001-0000-0000-0000-000000000001/019b0002-0000-0000-0000-000000000002.git', + url: 'https://byterover.dev/teambao1/test-space.git', }) expect.fail('Expected error') } catch (error) { @@ -2040,13 +2057,14 @@ describe('VcHandler', () => { const deps = makeDeps(sandbox, projectPath) deps.gitService.isInitialized.resolves(false) deps.tokenStore.load.resolves(validToken) + stubDefaultTeamSpace(deps) const notFoundError = Object.assign(new Error('Could not find repository'), {code: 'NotFoundError'}) deps.gitService.clone.rejects(notFoundError) makeVcHandler(deps).setup() try { await invoke(deps, VcEvents.CLONE, { - url: 'https://test-cogit.byterover.dev/git/019b0001-0000-0000-0000-000000000001/019b0002-0000-0000-0000-000000000002.git', + url: 'https://byterover.dev/teambao1/test-space.git', }) expect.fail('Expected error') } catch (error) { @@ -2099,19 +2117,20 @@ describe('VcHandler', () => { userId: 'u1', }) deps.tokenStore.load.resolves(mockToken) + stubDefaultTeamSpace(deps) makeVcHandler(deps).setup() const url = - 'https://user:token@test-cogit.byterover.dev/git/019b0001-0000-0000-0000-000000000001/019b0002-0000-0000-0000-000000000002.git' + 'https://user:token@byterover.dev/teambao1/test-space.git' const expectedCleanUrl = - 'https://test-cogit.byterover.dev/git/019b0001-0000-0000-0000-000000000001/019b0002-0000-0000-0000-000000000002.git' + 'https://byterover.dev/teambao1/test-space.git' const result = await deps.requestHandlers[VcEvents.REMOTE]({subcommand: 'add', url}, CLIENT_ID) expect(result).to.deep.equal({action: 'add', url: expectedCleanUrl}) expect(deps.gitService.addRemote.calledOnce).to.be.true expect(deps.gitService.addRemote.firstCall.args[0]).to.include({remote: 'origin', url: expectedCleanUrl}) }) - it('should inject credentials when adding remote with clean cogit URL', async () => { + it('should resolve name-based URL and store clean URL on add', async () => { const deps = makeDeps(sandbox, projectPath) deps.gitService.isInitialized.resolves(true) deps.gitService.getRemoteUrl.resolves() @@ -2124,10 +2143,11 @@ describe('VcHandler', () => { userId: 'u1', }) deps.tokenStore.load.resolves(mockToken) + stubDefaultTeamSpace(deps) makeVcHandler(deps).setup() const cleanUrl = - 'https://test-cogit.byterover.dev/git/019b0001-0000-0000-0000-000000000001/019b0002-0000-0000-0000-000000000002.git' + 'https://byterover.dev/teambao1/test-space.git' const result = await invoke<{action: string; url: string}>( deps, VcEvents.REMOTE, @@ -2175,7 +2195,7 @@ describe('VcHandler', () => { expect(result.action).to.equal('add') const storedUrl = deps.gitService.addRemote.firstCall.args[0].url - expect(storedUrl).to.equal('https://test-cogit.byterover.dev/git/tid-1/sid-1.git') + expect(storedUrl).to.equal('https://byterover.dev/acme/project.git') }) it('should throw NotAuthenticatedError when adding remote without auth (name resolution)', async () => { @@ -2187,7 +2207,7 @@ describe('VcHandler', () => { try { await deps.requestHandlers[VcEvents.REMOTE]( - {subcommand: 'add', url: 'https://test-cogit.byterover.dev/git/TeamName/space-name.git'}, + {subcommand: 'add', url: 'https://byterover.dev/TeamName/space-name.git'}, CLIENT_ID, ) expect.fail('Expected error') @@ -2215,7 +2235,7 @@ describe('VcHandler', () => { await deps.requestHandlers[VcEvents.REMOTE]( { subcommand: 'add', - url: 'https://test-cogit.byterover.dev/git/019b0001-0000-0000-0000-000000000001/019b0002-0000-0000-0000-000000000002.git', + url: 'https://byterover.dev/teambao1/test-space.git', }, CLIENT_ID, ) @@ -2231,10 +2251,20 @@ describe('VcHandler', () => { it('should call removeRemote + addRemote on set-url with clean URL', async () => { const deps = makeDeps(sandbox, projectPath) deps.gitService.isInitialized.resolves(true) + const mockToken = new AuthToken({ + accessToken: 'test-acc', + expiresAt: new Date(Date.now() + 3_600_000), + refreshToken: 'test-ref', + sessionKey: 'sess-123', + userEmail: 'test@example.com', + userId: 'u1', + }) + deps.tokenStore.load.resolves(mockToken) + stubDefaultTeamSpace(deps) makeVcHandler(deps).setup() const url = - 'https://test-cogit.byterover.dev/git/019b0001-0000-0000-0000-000000000001/019b0002-0000-0000-0000-000000000002.git' + 'https://byterover.dev/teambao1/test-space.git' const result = await deps.requestHandlers[VcEvents.REMOTE]({subcommand: 'set-url', url}, CLIENT_ID) expect(result).to.deep.equal({action: 'set-url', url}) expect(deps.gitService.removeRemote.calledOnce).to.be.true @@ -2346,18 +2376,6 @@ describe('VcHandler', () => { expect(writtenConfig.teamName).to.equal('acme') }) - it('should not write config when URL has UUIDs only (no space/team names)', async () => { - const deps = makeDeps(sandbox, projectPath) - deps.gitService.isInitialized.resolves(true) - deps.gitService.getRemoteUrl.resolves() - makeVcHandler(deps).setup() - - const url = - 'https://test-cogit.byterover.dev/git/019b0001-0000-0000-0000-000000000001/019b0002-0000-0000-0000-000000000002.git' - await deps.requestHandlers[VcEvents.REMOTE]({subcommand: 'add', url}, CLIENT_ID) - - expect(deps.projectConfigStore.write.called).to.be.false - }) }) // ---- handleBranch ---- From 620c89bf3e0e2c65ac37405f1d111d1c964f603d Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Sun, 5 Apr 2026 20:28:03 +0700 Subject: [PATCH 05/10] feat: [ENG-1905] fix issue remote url slug special char --- src/server/core/domain/entities/space.ts | 10 ++ src/server/core/domain/entities/team.ts | 5 + src/server/infra/space/http-space-service.ts | 4 + src/server/infra/team/http-team-service.ts | 1 + .../infra/transport/handlers/vc-handler.ts | 30 +++-- .../vc/clone/components/vc-clone-flow.tsx | 9 ++ .../core/domain/entities/brv-config.test.ts | 1 + test/unit/core/domain/entities/space.test.ts | 111 ++++++++++++++++++ test/unit/core/domain/entities/team.test.ts | 40 +++++++ .../infra/space/http-space-service.test.ts | 69 +++++++++++ .../transport/handlers/space-handler.test.ts | 2 + .../transport/handlers/vc-handler.test.ts | 83 ++++++++++--- 12 files changed, 338 insertions(+), 27 deletions(-) create mode 100644 test/unit/core/domain/entities/space.test.ts diff --git a/src/server/core/domain/entities/space.ts b/src/server/core/domain/entities/space.ts index b3bcacf17..82382dece 100644 --- a/src/server/core/domain/entities/space.ts +++ b/src/server/core/domain/entities/space.ts @@ -5,8 +5,10 @@ interface SpaceParams { id: string isDefault: boolean name: string + slug?: string teamId: string teamName: string + teamSlug?: string } /** @@ -17,8 +19,10 @@ export class Space { public readonly id: string public readonly isDefault: boolean public readonly name: string + public readonly slug: string public readonly teamId: string public readonly teamName: string + public readonly teamSlug: string public constructor(params: SpaceParams) { if (params.id.trim().length === 0) { @@ -40,8 +44,10 @@ export class Space { this.id = params.id this.isDefault = params.isDefault this.name = params.name + this.slug = params.slug ?? params.name this.teamId = params.teamId this.teamName = params.teamName + this.teamSlug = params.teamSlug ?? params.teamName } /** @@ -72,8 +78,10 @@ export class Space { id: json.id, isDefault: json.is_default, name: json.name, + slug: typeof json.slug === 'string' ? json.slug : json.name, teamId: json.team_id, teamName: json.team_name, + teamSlug: typeof json.team_slug === 'string' ? json.team_slug : json.team_name, }) } @@ -93,8 +101,10 @@ export class Space { id: this.id, isDefault: this.isDefault, name: this.name, + slug: this.slug, teamId: this.teamId, teamName: this.teamName, + teamSlug: this.teamSlug, } } } diff --git a/src/server/core/domain/entities/team.ts b/src/server/core/domain/entities/team.ts index d134c80bd..03ff85472 100644 --- a/src/server/core/domain/entities/team.ts +++ b/src/server/core/domain/entities/team.ts @@ -10,6 +10,7 @@ interface TeamParams { isActive: boolean isDefault: boolean name: string + slug: string updatedAt: Date } @@ -26,6 +27,7 @@ export class Team { public readonly isActive: boolean public readonly isDefault: boolean public readonly name: string + public readonly slug: string public readonly updatedAt: Date public constructor(params: TeamParams) { @@ -49,6 +51,7 @@ export class Team { this.isActive = params.isActive this.isDefault = params.isDefault this.name = params.name + this.slug = params.slug this.updatedAt = params.updatedAt } @@ -94,6 +97,7 @@ export class Team { isActive: json.is_active, isDefault: json.is_default, name: json.name, + slug: typeof json.slug === 'string' ? json.slug : json.name, updatedAt: new Date(json.updated_at), }) } @@ -119,6 +123,7 @@ export class Team { isActive: this.isActive, isDefault: this.isDefault, name: this.name, + slug: this.slug, updatedAt: this.updatedAt.toISOString(), } } diff --git a/src/server/infra/space/http-space-service.ts b/src/server/infra/space/http-space-service.ts index b3bf5bba0..ec49d9303 100644 --- a/src/server/infra/space/http-space-service.ts +++ b/src/server/infra/space/http-space-service.ts @@ -12,6 +12,7 @@ export type SpaceServiceConfig = { type Team = { name: string + slug?: string } type SpaceApiResponse = { @@ -23,6 +24,7 @@ type SpaceApiResponse = { is_default: boolean name: string size: number + slug?: string status: string storage_path: string team: Team @@ -130,8 +132,10 @@ export class HttpSpaceService implements ISpaceService { id: spaceData.id, isDefault: spaceData.is_default, name: spaceData.name, + slug: spaceData.slug, teamId: spaceData.team_id, teamName: spaceData.team.name, + teamSlug: spaceData.team.slug, }) } } diff --git a/src/server/infra/team/http-team-service.ts b/src/server/infra/team/http-team-service.ts index 32e8df9bd..8056fbd44 100644 --- a/src/server/infra/team/http-team-service.ts +++ b/src/server/infra/team/http-team-service.ts @@ -18,6 +18,7 @@ type TeamApiResponse = { is_active: boolean is_default: boolean name: string + slug?: string updated_at: string } diff --git a/src/server/infra/transport/handlers/vc-handler.ts b/src/server/infra/transport/handlers/vc-handler.ts index 9ff3f2d2f..50d78dbe4 100644 --- a/src/server/infra/transport/handlers/vc-handler.ts +++ b/src/server/infra/transport/handlers/vc-handler.ts @@ -458,7 +458,7 @@ export class VcHandler { await fs.promises.rm(join(contextTreeDir, '.gitignore'), {force: true}).catch(() => {}) } - const {spaceId, spaceName, teamId, teamName, url: cloneUrl} = await this.resolveCloneInput(data) + const {spaceId, spaceName, spaceSlug, teamId, teamName, teamSlug, url: cloneUrl} = await this.resolveCloneInput(data) const label = teamName && spaceName ? `${teamName}/${spaceName}` : 'repository' try { @@ -499,8 +499,10 @@ export class VcHandler { id: spaceId, isDefault: false, name: spaceName, + slug: spaceSlug, teamId, teamName, + teamSlug, }) const existing = await this.projectConfigStore.read(projectPath) const updated = existing ? existing.withSpace(space) : BrvConfig.partialFromSpace({space}) @@ -992,8 +994,10 @@ export class VcHandler { id: resolved.spaceId, isDefault: false, name: resolved.spaceName, + slug: resolved.spaceSlug, teamId: resolved.teamId, teamName: resolved.teamName, + teamSlug: resolved.teamSlug, }) const existing = await this.projectConfigStore.read(projectPath) const updated = existing ? existing.withSpace(space) : BrvConfig.partialFromSpace({space}) @@ -1157,8 +1161,10 @@ export class VcHandler { private async resolveCloneInput(data: IVcCloneRequest): Promise<{ spaceId?: string spaceName?: string + spaceSlug?: string teamId?: string teamName?: string + teamSlug?: string url: string }> { if (data.url) { @@ -1166,8 +1172,10 @@ export class VcHandler { return { spaceId: resolved.spaceId ?? data.spaceId, spaceName: resolved.spaceName ?? data.spaceName, + spaceSlug: resolved.spaceSlug, teamId: resolved.teamId ?? data.teamId, teamName: resolved.teamName ?? data.teamName, + teamSlug: resolved.teamSlug, url: resolved.url, } } @@ -1195,8 +1203,10 @@ export class VcHandler { private async resolveFullCogitUrl(url: string): Promise<{ spaceId?: string spaceName?: string + spaceSlug?: string teamId?: string teamName?: string + teamSlug?: string url: string }> { this.validateRemoteUrlDomain(url) @@ -1298,26 +1308,26 @@ export class VcHandler { * Resolve team/space names to IDs via API, build clean cogit URL. */ private async resolveTeamSpaceNames( - teamName: string, - spaceName: string, - ): Promise<{spaceId: string; spaceName: string; teamId: string; teamName: string; url: string}> { + teamSlug: string, + spaceSlug: string, + ): Promise<{spaceId: string; spaceName: string; spaceSlug: string; teamId: string; teamName: string; teamSlug: string; url: string}> { const token = await this.tokenStore.load() if (!token?.isValid()) throw new NotAuthenticatedError() const {teams} = await this.teamService.getTeams(token.sessionKey, {fetchAll: true}) - const team = teams.find((t) => t.name.toLowerCase() === teamName.toLowerCase()) + const team = teams.find((t) => t.slug.toLowerCase() === teamSlug.toLowerCase()) if (!team) { throw new VcError( - `Team "${teamName}" not found. Check the URL and your access permissions.`, + `Team "${teamSlug}" not found. Check the URL and your access permissions.`, VcErrorCode.INVALID_REMOTE_URL, ) } const {spaces} = await this.spaceService.getSpaces(token.sessionKey, team.id, {fetchAll: true}) - const space = spaces.find((s) => s.name.toLowerCase() === spaceName.toLowerCase()) + const space = spaces.find((s) => s.slug.toLowerCase() === spaceSlug.toLowerCase()) if (!space) { throw new VcError( - `Space "${spaceName}" not found in team "${team.name}". Check the URL and your access permissions.`, + `Space "${spaceSlug}" not found in team "${team.name}". Check the URL and your access permissions.`, VcErrorCode.INVALID_REMOTE_URL, ) } @@ -1325,9 +1335,11 @@ export class VcHandler { return { spaceId: space.id, spaceName: space.name, + spaceSlug: space.slug, teamId: space.teamId, teamName: team.name, - url: buildCogitRemoteUrl(this.gitRemoteBaseUrl, team.name, space.name), + teamSlug: team.slug, + url: buildCogitRemoteUrl(this.gitRemoteBaseUrl, team.slug, space.slug), } } diff --git a/src/tui/features/vc/clone/components/vc-clone-flow.tsx b/src/tui/features/vc/clone/components/vc-clone-flow.tsx index b5dda2795..e558c8193 100644 --- a/src/tui/features/vc/clone/components/vc-clone-flow.tsx +++ b/src/tui/features/vc/clone/components/vc-clone-flow.tsx @@ -7,6 +7,7 @@ import type {CustomDialogCallbacks} from '../../../../types/commands.js' import {VcEvents} from '../../../../../shared/transport/events/vc-events.js' import {InlineInput} from '../../../../components/inline-prompts/inline-input.js' +import {useTheme} from '../../../../hooks/index.js' import {getWebAppUrl} from '../../../../lib/environment.js' import {useTransportStore} from '../../../../stores/transport-store.js' import {formatTransportError} from '../../../../utils/error-messages.js' @@ -33,8 +34,12 @@ function validateRemoteUrl(value: string): boolean | string { } export function VcCloneFlow({onCancel, onComplete, url}: VcCloneFlowProps): React.ReactNode { + const { + theme: {colors}, + } = useTheme() const [step, setStep] = useState(url ? 'cloning' : 'entering_url') const [cloneUrl, setCloneUrl] = useState(url ?? null) + const [cloneError, setCloneError] = useState(null) const [progressMessages, setProgressMessages] = useState([]) const mutatedRef = useRef(false) @@ -64,6 +69,7 @@ export function VcCloneFlow({onCancel, onComplete, url}: VcCloneFlowProps): Reac if (url) { onComplete(formatTransportError(err)) } else { + setCloneError(formatTransportError(err)) setStep('entering_url') } }, @@ -82,6 +88,8 @@ export function VcCloneFlow({onCancel, onComplete, url}: VcCloneFlowProps): Reac }, [onComplete, step, cloneUrl, url]) const handleUrlSubmit = useCallback((submittedUrl: string) => { + setCloneError(null) + setProgressMessages([]) setCloneUrl(submittedUrl) setStep('cloning') }, []) @@ -101,6 +109,7 @@ export function VcCloneFlow({onCancel, onComplete, url}: VcCloneFlowProps): Reac return ( + {cloneError && {cloneError}} To clone a space: diff --git a/test/unit/core/domain/entities/brv-config.test.ts b/test/unit/core/domain/entities/brv-config.test.ts index 289d36bf7..91de67cd0 100644 --- a/test/unit/core/domain/entities/brv-config.test.ts +++ b/test/unit/core/domain/entities/brv-config.test.ts @@ -203,5 +203,6 @@ describe('BrvConfig', () => { expect(config.teamName).to.equal('my-team') expect(config.createdAt).to.be.a('string') }) + }) }) diff --git a/test/unit/core/domain/entities/space.test.ts b/test/unit/core/domain/entities/space.test.ts new file mode 100644 index 000000000..4a935d64f --- /dev/null +++ b/test/unit/core/domain/entities/space.test.ts @@ -0,0 +1,111 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import {Space} from '../../../../../src/server/core/domain/entities/space.js' + +describe('Space', () => { + const validSpaceParams = { + id: 'space-123', + isDefault: false, + name: 'test-space', + teamId: 'team-456', + teamName: 'test-team', + } + + describe('constructor', () => { + it('should create a space with slug and teamSlug when provided', () => { + const space = new Space({...validSpaceParams, slug: 'test-space-slug', teamSlug: 'team-slug'}) + + expect(space.slug).to.equal('test-space-slug') + expect(space.teamSlug).to.equal('team-slug') + }) + + it('should fall back slug to name and teamSlug to teamName when not provided', () => { + const space = new Space(validSpaceParams) + + expect(space.slug).to.equal('test-space') + expect(space.teamSlug).to.equal('test-team') + }) + + it('should throw error when id is empty', () => { + expect(() => new Space({...validSpaceParams, id: ''})) + .to.throw('Space ID cannot be empty') + }) + + it('should throw error when name is empty', () => { + expect(() => new Space({...validSpaceParams, name: ''})) + .to.throw('Space name cannot be empty') + }) + + it('should throw error when teamId is empty', () => { + expect(() => new Space({...validSpaceParams, teamId: ''})) + .to.throw('Team ID cannot be empty') + }) + + it('should throw error when teamName is empty', () => { + expect(() => new Space({...validSpaceParams, teamName: ''})) + .to.throw('Team name cannot be empty') + }) + }) + + describe('fromJson', () => { + it('should deserialize slug and team_slug from JSON when present', () => { + const json = { + id: 'space-123', + is_default: false, + name: 'my-space-v2.0', + slug: 'my-space-v2-0', + team_id: 'team-456', + team_name: 'Test Release 2.0.0', + team_slug: 'test-release-2-0-0', + } + + const space = Space.fromJson(json) + + expect(space.slug).to.equal('my-space-v2-0') + expect(space.teamSlug).to.equal('test-release-2-0-0') + expect(space.name).to.equal('my-space-v2.0') + expect(space.teamName).to.equal('Test Release 2.0.0') + }) + + it('should fall back to name and teamName when slug fields are missing from JSON', () => { + const json = { + id: 'space-123', + is_default: false, + name: 'test-space', + team_id: 'team-456', + team_name: 'test-team', + } + + const space = Space.fromJson(json) + + expect(space.slug).to.equal('test-space') + expect(space.teamSlug).to.equal('test-team') + }) + }) + + describe('toJson', () => { + it('should include slug and teamSlug in serialized JSON', () => { + const space = new Space({...validSpaceParams, slug: 'sp-slug', teamSlug: 'tm-slug'}) + const json = space.toJson() + + expect(json).to.deep.equal({ + id: 'space-123', + isDefault: false, + name: 'test-space', + slug: 'sp-slug', + teamId: 'team-456', + teamName: 'test-team', + teamSlug: 'tm-slug', + }) + }) + }) + + describe('getDisplayName', () => { + it('should return teamName/name format', () => { + const space = new Space(validSpaceParams) + + expect(space.getDisplayName()).to.equal('test-team/test-space') + }) + }) +}) diff --git a/test/unit/core/domain/entities/team.test.ts b/test/unit/core/domain/entities/team.test.ts index 050d6f9f4..c56aec0d2 100644 --- a/test/unit/core/domain/entities/team.test.ts +++ b/test/unit/core/domain/entities/team.test.ts @@ -13,6 +13,7 @@ describe('Team', () => { isActive: true, isDefault: false, name: 'test-team', + slug: 'test-team', updatedAt: new Date('2024-01-02T00:00:00Z'), } @@ -22,6 +23,7 @@ describe('Team', () => { expect(team.id).to.equal(validTeamParams.id) expect(team.name).to.equal(validTeamParams.name) + expect(team.slug).to.equal(validTeamParams.slug) expect(team.displayName).to.equal(validTeamParams.displayName) expect(team.description).to.equal(validTeamParams.description) expect(team.avatarUrl).to.equal(validTeamParams.avatarUrl) @@ -87,6 +89,43 @@ describe('Team', () => { expect(team.updatedAt).to.deep.equal(new Date(json.updated_at)) }) + it('should deserialize slug from JSON when present', () => { + const json = { + avatar_url: 'https://example.com/avatar.png', + created_at: '2024-01-01T00:00:00Z', + description: 'A test team', + display_name: 'Test Release 2.0.0', + id: '123e4567-e89b-12d3-a456-426614174000', + is_active: true, + is_default: false, + name: 'test-release-2.0.0', + slug: 'test-release-2-0-0', + updated_at: '2024-01-02T00:00:00Z', + } + + const team = Team.fromJson(json) + + expect(team.slug).to.equal('test-release-2-0-0') + expect(team.name).to.equal('test-release-2.0.0') + }) + + it('should fall back to name when slug is missing from JSON', () => { + const json = { + avatar_url: 'https://example.com/avatar.png', + created_at: '2024-01-01T00:00:00Z', + display_name: 'Test Team', + id: '123e4567-e89b-12d3-a456-426614174000', + is_active: true, + is_default: false, + name: 'test-team', + updated_at: '2024-01-02T00:00:00Z', + } + + const team = Team.fromJson(json) + + expect(team.slug).to.equal('test-team') + }) + it('should handle optional description field', () => { const json = { avatar_url: 'https://example.com/avatar.png', @@ -234,6 +273,7 @@ describe('Team', () => { isActive: validTeamParams.isActive, isDefault: validTeamParams.isDefault, name: validTeamParams.name, + slug: validTeamParams.slug, updatedAt: validTeamParams.updatedAt.toISOString(), }) }) diff --git a/test/unit/infra/space/http-space-service.test.ts b/test/unit/infra/space/http-space-service.test.ts index fd8e74763..780653582 100644 --- a/test/unit/infra/space/http-space-service.test.ts +++ b/test/unit/infra/space/http-space-service.test.ts @@ -164,6 +164,75 @@ describe('HttpSpaceService', () => { } }) + it('should map slug fields from API response when present', async () => { + const mockResponse = { + code: 200, + data: { + spaces: [ + { + created_at: '2024-01-01T00:00:00Z', + id: 'space-1', + name: 'my-space-v2.0', + slug: 'my-space-v2-0', + status: 'active', + team: {name: 'test-release-2.0.0', slug: 'test-release-2-0-0'}, + team_id: 'team-1', + updated_at: '2024-01-01T00:00:00Z', + visibility: 'private', + }, + ], + total: 1, + }, + message: 'success', + } + + nock(apiBaseUrl) + .get('/spaces') + .query({team_id: teamId}) + .matchHeader('x-byterover-session-id', sessionKey) + .reply(200, mockResponse) + + const result = await service.getSpaces(sessionKey, teamId) + + expect(result.spaces[0].slug).to.equal('my-space-v2-0') + expect(result.spaces[0].teamSlug).to.equal('test-release-2-0-0') + expect(result.spaces[0].name).to.equal('my-space-v2.0') + expect(result.spaces[0].teamName).to.equal('test-release-2.0.0') + }) + + it('should fall back slug to name when slug is absent from API response', async () => { + const mockResponse = { + code: 200, + data: { + spaces: [ + { + created_at: '2024-01-01T00:00:00Z', + id: 'space-1', + name: 'frontend-app', + status: 'active', + team: {name: 'acme-corp'}, + team_id: 'team-1', + updated_at: '2024-01-01T00:00:00Z', + visibility: 'private', + }, + ], + total: 1, + }, + message: 'success', + } + + nock(apiBaseUrl) + .get('/spaces') + .query({team_id: teamId}) + .matchHeader('x-byterover-session-id', sessionKey) + .reply(200, mockResponse) + + const result = await service.getSpaces(sessionKey, teamId) + + expect(result.spaces[0].slug).to.equal('frontend-app') + expect(result.spaces[0].teamSlug).to.equal('acme-corp') + }) + describe('pagination', () => { it('should fetch spaces with limit parameter', async () => { const mockResponse = { diff --git a/test/unit/infra/transport/handlers/space-handler.test.ts b/test/unit/infra/transport/handlers/space-handler.test.ts index fcde371e4..39970f0de 100644 --- a/test/unit/infra/transport/handlers/space-handler.test.ts +++ b/test/unit/infra/transport/handlers/space-handler.test.ts @@ -111,6 +111,7 @@ const createMockTeams = (): Team[] => [ isActive: true, isDefault: true, name: 'acme-corp', + slug: 'acme-corp', updatedAt: new Date(), }), new Team({ @@ -122,6 +123,7 @@ const createMockTeams = (): Team[] => [ isActive: true, isDefault: false, name: 'other-team', + slug: 'other-team', updatedAt: new Date(), }), ] diff --git a/test/unit/infra/transport/handlers/vc-handler.test.ts b/test/unit/infra/transport/handlers/vc-handler.test.ts index 9e38bfcec..821564bd0 100644 --- a/test/unit/infra/transport/handlers/vc-handler.test.ts +++ b/test/unit/infra/transport/handlers/vc-handler.test.ts @@ -204,11 +204,11 @@ function makeVcHandler(deps: TestDeps): VcHandler { function stubDefaultTeamSpace(deps: TestDeps): void { deps.teamService.getTeams.resolves({ - teams: [{displayName: 'Teambao1', id: 'tid-1', isActive: true, isDefault: false, name: 'teambao1'}], + teams: [{displayName: 'Teambao1', id: 'tid-1', isActive: true, isDefault: false, name: 'teambao1', slug: 'teambao1'}], total: 1, }) deps.spaceService.getSpaces.resolves({ - spaces: [{id: 'sid-1', isDefault: false, name: 'test-space', teamId: 'tid-1', teamName: 'teambao1'}], + spaces: [{id: 'sid-1', isDefault: false, name: 'test-space', slug: 'test-space', teamId: 'tid-1', teamName: 'teambao1', teamSlug: 'teambao1'}], total: 1, }) } @@ -1762,11 +1762,11 @@ describe('VcHandler', () => { deps.gitService.isInitialized.resolves(false) deps.tokenStore.load.resolves(validToken) deps.teamService.getTeams.resolves({ - teams: [{displayName: 'Teambao1', id: 'tid-1', isActive: true, isDefault: false, name: 'Teambao1'}], + teams: [{displayName: 'Teambao1', id: 'tid-1', isActive: true, isDefault: false, name: 'Teambao1', slug: 'teambao1'}], total: 1, }) deps.spaceService.getSpaces.resolves({ - spaces: [{id: 'sid-1', isDefault: false, name: 'test-git', teamId: 'tid-1', teamName: 'Teambao1'}], + spaces: [{id: 'sid-1', isDefault: false, name: 'test-git', slug: 'test-git', teamId: 'tid-1', teamName: 'Teambao1', teamSlug: 'teambao1'}], total: 1, }) makeVcHandler(deps).setup() @@ -1779,7 +1779,7 @@ describe('VcHandler', () => { expect(result.teamName).to.equal('Teambao1') expect(result.spaceName).to.equal('test-git') const cloneArgs = deps.gitService.clone.firstCall.args[0] - expect(cloneArgs.url).to.equal('https://byterover.dev/Teambao1/test-git.git') + expect(cloneArgs.url).to.equal('https://byterover.dev/teambao1/test-git.git') }) it('should clone with user-facing .git URL by resolving team/space names', async () => { @@ -1787,11 +1787,11 @@ describe('VcHandler', () => { deps.gitService.isInitialized.resolves(false) deps.tokenStore.load.resolves(validToken) deps.teamService.getTeams.resolves({ - teams: [{displayName: 'Acme', id: 'tid-1', isActive: true, isDefault: false, name: 'acme'}], + teams: [{displayName: 'Acme', id: 'tid-1', isActive: true, isDefault: false, name: 'acme', slug: 'acme'}], total: 1, }) deps.spaceService.getSpaces.resolves({ - spaces: [{id: 'sid-1', isDefault: false, name: 'project', teamId: 'tid-1', teamName: 'acme'}], + spaces: [{id: 'sid-1', isDefault: false, name: 'project', slug: 'project', teamId: 'tid-1', teamName: 'acme', teamSlug: 'acme'}], total: 1, }) makeVcHandler(deps).setup() @@ -1812,11 +1812,11 @@ describe('VcHandler', () => { deps.gitService.isInitialized.resolves(false) deps.tokenStore.load.resolves(validToken) deps.teamService.getTeams.resolves({ - teams: [{displayName: 'Teambao1', id: 'tid-1', isActive: true, isDefault: false, name: 'Teambao1'}], + teams: [{displayName: 'Teambao1', id: 'tid-1', isActive: true, isDefault: false, name: 'Teambao1', slug: 'teambao1'}], total: 1, }) deps.spaceService.getSpaces.resolves({ - spaces: [{id: 'sid-1', isDefault: false, name: 'test-git', teamId: 'tid-1', teamName: 'Teambao1'}], + spaces: [{id: 'sid-1', isDefault: false, name: 'test-git', slug: 'test-git', teamId: 'tid-1', teamName: 'Teambao1', teamSlug: 'teambao1'}], total: 1, }) makeVcHandler(deps).setup() @@ -1834,11 +1834,11 @@ describe('VcHandler', () => { deps.gitService.isInitialized.resolves(false) deps.tokenStore.load.resolves(validToken) deps.teamService.getTeams.resolves({ - teams: [{displayName: 'Acme', id: 'tid-1', isActive: true, isDefault: false, name: 'acme'}], + teams: [{displayName: 'Acme', id: 'tid-1', isActive: true, isDefault: false, name: 'acme', slug: 'acme'}], total: 1, }) deps.spaceService.getSpaces.resolves({ - spaces: [{id: 'sid-1', isDefault: false, name: 'project', teamId: 'tid-1', teamName: 'acme'}], + spaces: [{id: 'sid-1', isDefault: false, name: 'project', slug: 'project', teamId: 'tid-1', teamName: 'acme', teamSlug: 'acme'}], total: 1, }) makeVcHandler(deps).setup() @@ -1875,7 +1875,7 @@ describe('VcHandler', () => { deps.gitService.isInitialized.resolves(false) deps.tokenStore.load.resolves(validToken) deps.teamService.getTeams.resolves({ - teams: [{displayName: 'Acme', id: 'tid-1', isActive: true, isDefault: false, name: 'acme'}], + teams: [{displayName: 'Acme', id: 'tid-1', isActive: true, isDefault: false, name: 'acme', slug: 'acme'}], total: 1, }) deps.spaceService.getSpaces.resolves({spaces: [], total: 0}) @@ -1893,6 +1893,53 @@ describe('VcHandler', () => { } }) + it('should match team by slug when name differs from URL segment', async () => { + const deps = makeDeps(sandbox, projectPath) + deps.gitService.isInitialized.resolves(false) + deps.tokenStore.load.resolves(validToken) + deps.teamService.getTeams.resolves({ + teams: [{displayName: 'Test Release 2.0.0', id: 'tid-1', isActive: true, isDefault: false, name: 'test-release-2.0.0', slug: 'test-release-2-0-0'}], + total: 1, + }) + deps.spaceService.getSpaces.resolves({ + spaces: [{id: 'sid-1', isDefault: false, name: 'normal-space', slug: 'normal-space', teamId: 'tid-1', teamName: 'test-release-2.0.0', teamSlug: 'test-release-2-0-0'}], + total: 1, + }) + makeVcHandler(deps).setup() + + const result = await invoke<{gitDir: string; spaceName?: string; teamName?: string}>(deps, VcEvents.CLONE, { + url: 'https://byterover.dev/test-release-2-0-0/normal-space.git', + }) + + expect(result.teamName).to.equal('test-release-2.0.0') + expect(result.spaceName).to.equal('normal-space') + const cloneArgs = deps.gitService.clone.firstCall.args[0] + expect(cloneArgs.url).to.equal('https://byterover.dev/test-release-2-0-0/normal-space.git') + }) + + it('should match space by slug when name differs from URL segment', async () => { + const deps = makeDeps(sandbox, projectPath) + deps.gitService.isInitialized.resolves(false) + deps.tokenStore.load.resolves(validToken) + deps.teamService.getTeams.resolves({ + teams: [{displayName: 'Acme', id: 'tid-1', isActive: true, isDefault: false, name: 'acme', slug: 'acme'}], + total: 1, + }) + deps.spaceService.getSpaces.resolves({ + spaces: [{id: 'sid-1', isDefault: false, name: 'my-space-v2.0', slug: 'my-space-v2-0', teamId: 'tid-1', teamName: 'acme', teamSlug: 'acme'}], + total: 1, + }) + makeVcHandler(deps).setup() + + const result = await invoke<{gitDir: string; spaceName?: string; teamName?: string}>(deps, VcEvents.CLONE, { + url: 'https://byterover.dev/acme/my-space-v2-0.git', + }) + + expect(result.spaceName).to.equal('my-space-v2.0') + const cloneArgs = deps.gitService.clone.firstCall.args[0] + expect(cloneArgs.url).to.equal('https://byterover.dev/acme/my-space-v2-0.git') + }) + it('should throw NotAuthenticatedError when URL clone without auth (name resolution)', async () => { const deps = makeDeps(sandbox, projectPath) deps.gitService.isInitialized.resolves(false) @@ -2177,11 +2224,11 @@ describe('VcHandler', () => { }) deps.tokenStore.load.resolves(mockToken) deps.teamService.getTeams.resolves({ - teams: [{displayName: 'Acme', id: 'tid-1', isActive: true, isDefault: false, name: 'acme'}], + teams: [{displayName: 'Acme', id: 'tid-1', isActive: true, isDefault: false, name: 'acme', slug: 'acme'}], total: 1, }) deps.spaceService.getSpaces.resolves({ - spaces: [{id: 'sid-1', isDefault: false, name: 'project', teamId: 'tid-1', teamName: 'acme'}], + spaces: [{id: 'sid-1', isDefault: false, name: 'project', slug: 'project', teamId: 'tid-1', teamName: 'acme', teamSlug: 'acme'}], total: 1, }) makeVcHandler(deps).setup() @@ -2319,11 +2366,11 @@ describe('VcHandler', () => { }) deps.tokenStore.load.resolves(mockToken) deps.teamService.getTeams.resolves({ - teams: [{displayName: 'Acme', id: 'tid-1', isActive: true, isDefault: false, name: 'acme'}], + teams: [{displayName: 'Acme', id: 'tid-1', isActive: true, isDefault: false, name: 'acme', slug: 'acme'}], total: 1, }) deps.spaceService.getSpaces.resolves({ - spaces: [{id: 'sid-1', isDefault: false, name: 'project', teamId: 'tid-1', teamName: 'acme'}], + spaces: [{id: 'sid-1', isDefault: false, name: 'project', slug: 'project', teamId: 'tid-1', teamName: 'acme', teamSlug: 'acme'}], total: 1, }) makeVcHandler(deps).setup() @@ -2354,11 +2401,11 @@ describe('VcHandler', () => { }) deps.tokenStore.load.resolves(mockToken) deps.teamService.getTeams.resolves({ - teams: [{displayName: 'Acme', id: 'tid-1', isActive: true, isDefault: false, name: 'acme'}], + teams: [{displayName: 'Acme', id: 'tid-1', isActive: true, isDefault: false, name: 'acme', slug: 'acme'}], total: 1, }) deps.spaceService.getSpaces.resolves({ - spaces: [{id: 'sid-1', isDefault: false, name: 'project', teamId: 'tid-1', teamName: 'acme'}], + spaces: [{id: 'sid-1', isDefault: false, name: 'project', slug: 'project', teamId: 'tid-1', teamName: 'acme', teamSlug: 'acme'}], total: 1, }) makeVcHandler(deps).setup() From c6c5e9bcd8593585110788c29c3dbe9d75d3ee31 Mon Sep 17 00:00:00 2001 From: Cuong Date: Sun, 5 Apr 2026 20:32:55 +0700 Subject: [PATCH 06/10] fix:[ENG-1951] TUI help flow and vc commit message parsing - Intercept --help/-h/help in TUI slash command processor to show TUI-style help instead of falling through to oclif Parser - Fix /vc commit -m stripping multi-word messages to first word by joining remaining argv with the parsed flag value - Apply same fix to oclif vc commit command (strict: false + argv join) - Rewrite server "brv vc" hints to "/vc" in TUI formatTransportError --- src/oclif/commands/vc/commit.ts | 9 +++++++-- src/tui/features/commands/definitions/vc-commit.ts | 8 +++++++- src/tui/utils/error-messages.ts | 3 ++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/oclif/commands/vc/commit.ts b/src/oclif/commands/vc/commit.ts index 268bb9b48..6dd225c5e 100644 --- a/src/oclif/commands/vc/commit.ts +++ b/src/oclif/commands/vc/commit.ts @@ -12,11 +12,16 @@ export default class VcCommit extends Command { description: 'Commit message', }), } +public static strict = false public async run(): Promise { - const {flags} = await this.parse(VcCommit) + const {argv, flags} = await this.parse(VcCommit) - const {message} = flags + // Support unquoted multi-word messages: brv vc commit -m hello world + const extra = (argv as string[]).join(' ') + const message = flags.message + ? (extra ? `${flags.message} ${extra}` : flags.message) + : (extra || undefined) if (!message) { this.error('Usage: brv vc commit -m ""') } diff --git a/src/tui/features/commands/definitions/vc-commit.ts b/src/tui/features/commands/definitions/vc-commit.ts index 1a1fdb353..82223153c 100644 --- a/src/tui/features/commands/definitions/vc-commit.ts +++ b/src/tui/features/commands/definitions/vc-commit.ts @@ -12,7 +12,13 @@ const vcCommitFlags = { export const vcCommitSubCommand: SlashCommand = { async action(_context, rawArgs) { const parsed = await parseReplArgs(rawArgs, {flags: vcCommitFlags, strict: false}) - const message = parsed.flags.message ?? parsed.argv[0] + // Join remaining argv with the flag value to support unquoted multi-word messages + // e.g. /vc commit -m hello world → message = "hello world" + // e.g. /vc commit hello world → message = "hello world" + const extra = parsed.argv.join(' ') + const message = parsed.flags.message + ? (extra ? `${parsed.flags.message} ${extra}` : parsed.flags.message) + : (extra || undefined) if (!message) { const errorMsg: MessageActionReturn = { diff --git a/src/tui/utils/error-messages.ts b/src/tui/utils/error-messages.ts index e305553f6..589bba81e 100644 --- a/src/tui/utils/error-messages.ts +++ b/src/tui/utils/error-messages.ts @@ -81,5 +81,6 @@ export function formatTransportError(error: unknown): string { return 'Request timed out. Please try again.' } - return error.message.replace(/ for event '[^']+'(?: after \d+ms)?$/, '') + // Strip transport suffix and rewrite CLI-style hints (brv vc ...) to TUI slash commands (/vc ...) + return error.message.replace(/ for event '[^']+'(?: after \d+ms)?$/, '').replaceAll(/\bbrv\s+/g, '/') } From a685b70d518679c7b5d1ad10258417c840e56436 Mon Sep 17 00:00:00 2001 From: Cuong Date: Sun, 5 Apr 2026 20:57:49 +0700 Subject: [PATCH 07/10] test: update error-messages tests for brv-to-slash rewriting --- test/unit/tui/utils/error-messages.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/unit/tui/utils/error-messages.test.ts b/test/unit/tui/utils/error-messages.test.ts index a4e6cc0df..37294a665 100644 --- a/test/unit/tui/utils/error-messages.test.ts +++ b/test/unit/tui/utils/error-messages.test.ts @@ -24,7 +24,7 @@ describe('error-messages', () => { {code: 'ERR_VC_USER_NOT_CONFIGURED'}, ) expect(formatTransportError(err)).to.equal( - 'Commit author not configured. Run: brv vc config user.name "bao@b.dev" and brv vc config user.email "bao@b.dev".', + 'Commit author not configured. Run: /vc config user.name "bao@b.dev" and /vc config user.email "bao@b.dev".', ) }) @@ -36,15 +36,16 @@ describe('error-messages', () => { {code: 'ERR_VC_USER_NOT_CONFIGURED'}, ) expect(formatTransportError(err)).to.equal( - 'Commit author not configured. Run: brv vc config user.name and brv vc config user.email .', + 'Commit author not configured. Run: /vc config user.name and /vc config user.email .', ) }) - it('falls through to server message for ERR_VC_NO_REMOTE', () => { + it('falls through to server message for ERR_VC_NO_REMOTE and rewrites brv to slash commands', () => { const serverMessage = 'No remote configured.\n\nTo connect to cloud:\n 1. Go to https://app.byterover.dev\n 2. Copy the remote URL\n 3. Run: brv vc remote add origin \n 4. Then: brv vc push -u origin main' const err = Object.assign(new Error(serverMessage), {code: 'ERR_VC_NO_REMOTE'}) const result = formatTransportError(err) - expect(result).to.equal(serverMessage) + const expected = 'No remote configured.\n\nTo connect to cloud:\n 1. Go to https://app.byterover.dev\n 2. Copy the remote URL\n 3. Run: /vc remote add origin \n 4. Then: /vc push -u origin main' + expect(result).to.equal(expected) }) it('returns /vc pull hint for ERR_VC_NON_FAST_FORWARD', () => { From 4a9bc0008fb833f3b89725b64b54427b852909be Mon Sep 17 00:00:00 2001 From: Cuong Date: Sun, 5 Apr 2026 21:13:31 +0700 Subject: [PATCH 08/10] fix: [ENG-1951] minor changes --- src/oclif/commands/vc/commit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/oclif/commands/vc/commit.ts b/src/oclif/commands/vc/commit.ts index 6dd225c5e..3977a2308 100644 --- a/src/oclif/commands/vc/commit.ts +++ b/src/oclif/commands/vc/commit.ts @@ -12,7 +12,7 @@ export default class VcCommit extends Command { description: 'Commit message', }), } -public static strict = false + public static strict = false public async run(): Promise { const {argv, flags} = await this.parse(VcCommit) From efb6396bf759fdc37461244529f734cf58865061 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Mon, 6 Apr 2026 10:08:07 +0700 Subject: [PATCH 09/10] feat: [ENG-1954] brv vc should run even when .brv is not present --- src/oclif/commands/vc/init.ts | 2 +- src/oclif/hooks/init/validate-brv-config.ts | 7 + .../features/commands/definitions/vc-init.ts | 2 +- test/hooks/init/validate-brv-config.test.ts | 318 ++++++++++-------- 4 files changed, 184 insertions(+), 145 deletions(-) diff --git a/src/oclif/commands/vc/init.ts b/src/oclif/commands/vc/init.ts index 21495ed77..d60377e41 100644 --- a/src/oclif/commands/vc/init.ts +++ b/src/oclif/commands/vc/init.ts @@ -5,7 +5,7 @@ import {type IVcInitResponse, VcEvents} from '../../../shared/transport/events/v import {formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' export default class VcInit extends Command { - public static description = 'Initialize ByteRover version control' + public static description = 'Initialize ByteRover version control for context tree' public static examples = ['<%= config.bin %> <%= command.id %>'] public async run(): Promise { diff --git a/src/oclif/hooks/init/validate-brv-config.ts b/src/oclif/hooks/init/validate-brv-config.ts index e10d92185..602d7a9e2 100644 --- a/src/oclif/hooks/init/validate-brv-config.ts +++ b/src/oclif/hooks/init/validate-brv-config.ts @@ -25,6 +25,9 @@ export const SKIP_COMMANDS = new Set([ 'vc:init', ]) +export const isVcHelpRequest = (commandId: string | undefined, argv: string[]): boolean => + commandId === 'vc' && (argv.includes('--help') || argv.includes('-h')) + /** * Dependencies for the curate-view patch marker, injected for testability. * The marker file lives in the XDG/global data dir scoped to the current project, @@ -113,6 +116,10 @@ export const validateBrvConfigVersion = async ( * so commands like `init` can safely delegate to sub-commands. */ const hook: Hook<'init'> = async function (options): Promise { + if (isVcHelpRequest(options.id, options.argv)) { + return + } + try { await validateBrvConfigVersion(options.id ?? '', new ProjectConfigStore()) } catch (error) { diff --git a/src/tui/features/commands/definitions/vc-init.ts b/src/tui/features/commands/definitions/vc-init.ts index 7db660e2d..d9129d56c 100644 --- a/src/tui/features/commands/definitions/vc-init.ts +++ b/src/tui/features/commands/definitions/vc-init.ts @@ -10,6 +10,6 @@ export const vcInitSubCommand: SlashCommand = { render: ({onCancel, onComplete}) => React.createElement(VcInitFlow, {onCancel, onComplete}), } }, - description: 'Initialize ByteRover version control', + description: 'Initialize ByteRover version control for context tree', name: 'init', } diff --git a/test/hooks/init/validate-brv-config.test.ts b/test/hooks/init/validate-brv-config.test.ts index 576051373..c98ab9b16 100644 --- a/test/hooks/init/validate-brv-config.test.ts +++ b/test/hooks/init/validate-brv-config.test.ts @@ -6,184 +6,216 @@ import {stub} from 'sinon' import type {PatchMarkerDeps} from '../../../src/oclif/hooks/init/validate-brv-config.js' import type {IProjectConfigStore} from '../../../src/server/core/interfaces/storage/i-project-config-store.js' -import {SKIP_COMMANDS, validateBrvConfigVersion} from '../../../src/oclif/hooks/init/validate-brv-config.js' +import { + isVcHelpRequest, + SKIP_COMMANDS, + validateBrvConfigVersion, +} from '../../../src/oclif/hooks/init/validate-brv-config.js' import {BRV_CONFIG_VERSION} from '../../../src/server/constants.js' import {BrvConfig, BrvConfigParams} from '../../../src/server/core/domain/entities/brv-config.js' -describe('validateBrvConfigVersion', () => { - let existsStub: SinonStub - let readStub: SinonStub - let writeStub: SinonStub - let mockConfigStore: IProjectConfigStore - let mockPatchMarkerDeps: PatchMarkerDeps - - const validConfigParams: BrvConfigParams = { - chatLogPath: '/path/to/chat.log', - createdAt: '2025-01-01T00:00:00.000Z', - cwd: '/path/to/project', - ide: 'Claude Code', - spaceId: 'space-123', - spaceName: 'test-space', - teamId: 'team-456', - teamName: 'test-team', - version: BRV_CONFIG_VERSION, - } - - beforeEach(() => { - existsStub = stub() - readStub = stub() - writeStub = stub().resolves() - mockConfigStore = { - exists: existsStub, - getModifiedTime: stub(), - read: readStub, - write: writeStub, - } - // Default: already patched — keeps existing tests focused on version migration - mockPatchMarkerDeps = { - isPatched: stub().resolves(true), - markPatched: stub().resolves(), - } - }) +describe('validate-brv-config', () => { + describe('isVcHelpRequest', () => { + it('returns true for brv vc --help', () => { + expect(isVcHelpRequest('vc', ['--help'])).to.be.true + }) - describe('should skip validation for excluded commands', () => { - for (const commandId of SKIP_COMMANDS) { - it(`skips validation for '${commandId}' command`, async () => { - await validateBrvConfigVersion(commandId, mockConfigStore, mockPatchMarkerDeps) + it('returns true for brv vc -h', () => { + expect(isVcHelpRequest('vc', ['-h'])).to.be.true + }) - expect(existsStub.called).to.be.false - expect(readStub.called).to.be.false - }) - } + it('returns false for normal vc subcommands', () => { + expect(isVcHelpRequest('vc:status', ['--help'])).to.be.false + expect(isVcHelpRequest('vc:commit', ['-m', 'msg'])).to.be.false + }) + + it('returns false for non-vc commands with --help', () => { + expect(isVcHelpRequest('connectors', ['--help'])).to.be.false + expect(isVcHelpRequest('status', ['--help'])).to.be.false + }) + + it('returns false when argv has no help flag', () => { + expect(isVcHelpRequest('vc', [])).to.be.false + }) }) - describe('should throw when project not initialized', () => { - it('throws instructive error when config does not exist', async () => { - existsStub.resolves(false) + describe('validateBrvConfigVersion', () => { + let existsStub: SinonStub + let readStub: SinonStub + let writeStub: SinonStub + let mockConfigStore: IProjectConfigStore + let mockPatchMarkerDeps: PatchMarkerDeps + + const validConfigParams: BrvConfigParams = { + chatLogPath: '/path/to/chat.log', + createdAt: '2025-01-01T00:00:00.000Z', + cwd: '/path/to/project', + ide: 'Claude Code', + spaceId: 'space-123', + spaceName: 'test-space', + teamId: 'team-456', + teamName: 'test-team', + version: BRV_CONFIG_VERSION, + } - try { - await validateBrvConfigVersion('status', mockConfigStore, mockPatchMarkerDeps) - expect.fail('Expected error to be thrown') - } catch (error) { - expect(error).to.be.instanceof(Error) - expect((error as Error).message).to.include('not a brv project') + beforeEach(() => { + existsStub = stub() + readStub = stub() + writeStub = stub().resolves() + mockConfigStore = { + exists: existsStub, + getModifiedTime: stub(), + read: readStub, + write: writeStub, + } + // Default: already patched — keeps existing tests focused on version migration + mockPatchMarkerDeps = { + isPatched: stub().resolves(true), + markPatched: stub().resolves(), } }) - it('throws user-friendly message for vc commands when config does not exist', async () => { - existsStub.resolves(false) + describe('should skip validation for excluded commands', () => { + for (const commandId of SKIP_COMMANDS) { + it(`skips validation for '${commandId}' command`, async () => { + await validateBrvConfigVersion(commandId, mockConfigStore, mockPatchMarkerDeps) + + expect(existsStub.called).to.be.false + expect(readStub.called).to.be.false + }) + } + }) + + describe('should throw when project not initialized', () => { + it('throws instructive error when config does not exist', async () => { + existsStub.resolves(false) - await Promise.all(['vc:status', 'vc:commit', 'vc:push', 'vc:add'].map(async (vcCommand) => { try { - await validateBrvConfigVersion(vcCommand, mockConfigStore, mockPatchMarkerDeps) - expect.fail(`Expected error for ${vcCommand}`) + await validateBrvConfigVersion('status', mockConfigStore, mockPatchMarkerDeps) + expect.fail('Expected error to be thrown') } catch (error) { expect(error).to.be.instanceof(Error) - const {message} = error as Error - expect(message).to.include('ByteRover version control not initialized') - expect(message).to.include('brv vc init') - expect(message).to.not.include('fatal:') + expect((error as Error).message).to.include('not a brv project') } - })) - }) - }) + }) - describe('should allow commands when config has valid version', () => { - it('allows command to proceed when config version matches', async () => { - existsStub.resolves(true) - readStub.resolves(new BrvConfig(validConfigParams)) + it('throws user-friendly message for vc commands when config does not exist', async () => { + existsStub.resolves(false) + + await Promise.all( + ['vc:status', 'vc:commit', 'vc:push', 'vc:add'].map(async (vcCommand) => { + try { + await validateBrvConfigVersion(vcCommand, mockConfigStore, mockPatchMarkerDeps) + expect.fail(`Expected error for ${vcCommand}`) + } catch (error) { + expect(error).to.be.instanceof(Error) + const {message} = error as Error + expect(message).to.include('ByteRover version control not initialized') + expect(message).to.include('brv vc init') + expect(message).to.not.include('fatal:') + } + }), + ) + }) + }) - await validateBrvConfigVersion('status', mockConfigStore, mockPatchMarkerDeps) + describe('should allow commands when config has valid version', () => { + it('allows command to proceed when config version matches', async () => { + existsStub.resolves(true) + readStub.resolves(new BrvConfig(validConfigParams)) - expect(existsStub.called).to.be.true - expect(readStub.called).to.be.true - expect(writeStub.called).to.be.false - }) - }) + await validateBrvConfigVersion('status', mockConfigStore, mockPatchMarkerDeps) - describe('should migrate config when version is outdated', () => { - it('migrates config when version is missing (empty string)', async () => { - existsStub.resolves(true) - const oldConfig = new BrvConfig({ - ...validConfigParams, - version: '', + expect(existsStub.called).to.be.true + expect(readStub.called).to.be.true + expect(writeStub.called).to.be.false }) - readStub.resolves(oldConfig) - - await validateBrvConfigVersion('status', mockConfigStore, mockPatchMarkerDeps) - - expect(writeStub.called).to.be.true - const writtenConfig = writeStub.firstCall.args[0] as BrvConfig - expect(writtenConfig.version).to.equal(BRV_CONFIG_VERSION) - // Preserves existing cloud fields - expect(writtenConfig.spaceId).to.equal('space-123') - expect(writtenConfig.spaceName).to.equal('test-space') - expect(writtenConfig.teamId).to.equal('team-456') - expect(writtenConfig.teamName).to.equal('test-team') }) - it('migrates config when version is mismatched', async () => { - existsStub.resolves(true) - const oldConfig = new BrvConfig({ - ...validConfigParams, - version: '0.0.0', - }) - readStub.resolves(oldConfig) + describe('should migrate config when version is outdated', () => { + it('migrates config when version is missing (empty string)', async () => { + existsStub.resolves(true) + const oldConfig = new BrvConfig({ + ...validConfigParams, + version: '', + }) + readStub.resolves(oldConfig) - await validateBrvConfigVersion('push', mockConfigStore, mockPatchMarkerDeps) + await validateBrvConfigVersion('status', mockConfigStore, mockPatchMarkerDeps) + + expect(writeStub.called).to.be.true + const writtenConfig = writeStub.firstCall.args[0] as BrvConfig + expect(writtenConfig.version).to.equal(BRV_CONFIG_VERSION) + // Preserves existing cloud fields + expect(writtenConfig.spaceId).to.equal('space-123') + expect(writtenConfig.spaceName).to.equal('test-space') + expect(writtenConfig.teamId).to.equal('team-456') + expect(writtenConfig.teamName).to.equal('test-team') + }) - expect(writeStub.called).to.be.true - const writtenConfig = writeStub.firstCall.args[0] as BrvConfig - expect(writtenConfig.version).to.equal(BRV_CONFIG_VERSION) - // Preserves existing cloud fields - expect(writtenConfig.spaceId).to.equal('space-123') - expect(writtenConfig.teamId).to.equal('team-456') + it('migrates config when version is mismatched', async () => { + existsStub.resolves(true) + const oldConfig = new BrvConfig({ + ...validConfigParams, + version: '0.0.0', + }) + readStub.resolves(oldConfig) + + await validateBrvConfigVersion('push', mockConfigStore, mockPatchMarkerDeps) + + expect(writeStub.called).to.be.true + const writtenConfig = writeStub.firstCall.args[0] as BrvConfig + expect(writtenConfig.version).to.equal(BRV_CONFIG_VERSION) + // Preserves existing cloud fields + expect(writtenConfig.spaceId).to.equal('space-123') + expect(writtenConfig.teamId).to.equal('team-456') + }) }) - }) - describe('should apply curate-view patch when marker is absent', () => { - it('calls markPatched after patching when not yet patched', async () => { - existsStub.resolves(true) - readStub.resolves(new BrvConfig(validConfigParams)) - const isPatchedStub = stub().resolves(false) - const markPatchedStub = stub().resolves() - - await validateBrvConfigVersion('status', mockConfigStore, { - isPatched: isPatchedStub, - markPatched: markPatchedStub, - patchFn: stub().resolves(), + describe('should apply curate-view patch when marker is absent', () => { + it('calls markPatched after patching when not yet patched', async () => { + existsStub.resolves(true) + readStub.resolves(new BrvConfig(validConfigParams)) + const isPatchedStub = stub().resolves(false) + const markPatchedStub = stub().resolves() + + await validateBrvConfigVersion('status', mockConfigStore, { + isPatched: isPatchedStub, + markPatched: markPatchedStub, + patchFn: stub().resolves(), + }) + + expect(isPatchedStub.calledOnce).to.be.true + expect(markPatchedStub.calledOnce).to.be.true }) - expect(isPatchedStub.calledOnce).to.be.true - expect(markPatchedStub.calledOnce).to.be.true - }) + it('skips patch when marker already exists', async () => { + existsStub.resolves(true) + readStub.resolves(new BrvConfig(validConfigParams)) + const markPatchedStub = stub().resolves() - it('skips patch when marker already exists', async () => { - existsStub.resolves(true) - readStub.resolves(new BrvConfig(validConfigParams)) - const markPatchedStub = stub().resolves() + await validateBrvConfigVersion('status', mockConfigStore, { + isPatched: stub().resolves(true), + markPatched: markPatchedStub, + }) - await validateBrvConfigVersion('status', mockConfigStore, { - isPatched: stub().resolves(true), - markPatched: markPatchedStub, + expect(markPatchedStub.called).to.be.false }) - - expect(markPatchedStub.called).to.be.false }) - }) - describe('should re-throw errors from read', () => { - it('re-throws errors', async () => { - existsStub.resolves(true) - readStub.rejects(new Error('Corrupted JSON')) + describe('should re-throw errors from read', () => { + it('re-throws errors', async () => { + existsStub.resolves(true) + readStub.rejects(new Error('Corrupted JSON')) - try { - await validateBrvConfigVersion('status', mockConfigStore, mockPatchMarkerDeps) - expect.fail('Expected error to be thrown') - } catch (error) { - expect(error).to.be.instanceof(Error) - expect((error as Error).message).to.equal('Corrupted JSON') - } + try { + await validateBrvConfigVersion('status', mockConfigStore, mockPatchMarkerDeps) + expect.fail('Expected error to be thrown') + } catch (error) { + expect(error).to.be.instanceof(Error) + expect((error as Error).message).to.equal('Corrupted JSON') + } + }) }) }) }) From 69f8465848aae04d8a50679f2a4ab89312cd6a4c Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Mon, 6 Apr 2026 11:43:50 +0700 Subject: [PATCH 10/10] feat: [ENG-1958] brv --help must show vc in COMMANDS sections --- package.json | 3 --- src/oclif/commands/vc/index.ts | 10 +++++++ src/oclif/hooks/init/validate-brv-config.ts | 8 +----- test/hooks/init/validate-brv-config.test.ts | 30 +-------------------- 4 files changed, 12 insertions(+), 39 deletions(-) create mode 100644 src/oclif/commands/vc/index.ts diff --git a/package.json b/package.json index 8e51d4126..52fc21d8f 100644 --- a/package.json +++ b/package.json @@ -147,9 +147,6 @@ ], "topicSeparator": " ", "topics": { - "vc": { - "description": "Version control commands for the context tree" - }, "hub": { "description": "Browse and manage skills & bundles registry", "subtopics": { diff --git a/src/oclif/commands/vc/index.ts b/src/oclif/commands/vc/index.ts new file mode 100644 index 000000000..80756a2ce --- /dev/null +++ b/src/oclif/commands/vc/index.ts @@ -0,0 +1,10 @@ +import {Command} from '@oclif/core' + +export default class Vc extends Command { + public static description = 'Version control commands for the context tree' + public static examples = ['<%= config.bin %> <%= command.id %> --help'] + + public async run(): Promise { + await this.config.runCommand('help', ['vc']) + } +} diff --git a/src/oclif/hooks/init/validate-brv-config.ts b/src/oclif/hooks/init/validate-brv-config.ts index 602d7a9e2..1ad87872c 100644 --- a/src/oclif/hooks/init/validate-brv-config.ts +++ b/src/oclif/hooks/init/validate-brv-config.ts @@ -21,13 +21,11 @@ export const SKIP_COMMANDS = new Set([ 'logout', 'main', 'restart', + 'vc', 'vc:clone', 'vc:init', ]) -export const isVcHelpRequest = (commandId: string | undefined, argv: string[]): boolean => - commandId === 'vc' && (argv.includes('--help') || argv.includes('-h')) - /** * Dependencies for the curate-view patch marker, injected for testability. * The marker file lives in the XDG/global data dir scoped to the current project, @@ -116,10 +114,6 @@ export const validateBrvConfigVersion = async ( * so commands like `init` can safely delegate to sub-commands. */ const hook: Hook<'init'> = async function (options): Promise { - if (isVcHelpRequest(options.id, options.argv)) { - return - } - try { await validateBrvConfigVersion(options.id ?? '', new ProjectConfigStore()) } catch (error) { diff --git a/test/hooks/init/validate-brv-config.test.ts b/test/hooks/init/validate-brv-config.test.ts index c98ab9b16..cd96a7096 100644 --- a/test/hooks/init/validate-brv-config.test.ts +++ b/test/hooks/init/validate-brv-config.test.ts @@ -6,39 +6,11 @@ import {stub} from 'sinon' import type {PatchMarkerDeps} from '../../../src/oclif/hooks/init/validate-brv-config.js' import type {IProjectConfigStore} from '../../../src/server/core/interfaces/storage/i-project-config-store.js' -import { - isVcHelpRequest, - SKIP_COMMANDS, - validateBrvConfigVersion, -} from '../../../src/oclif/hooks/init/validate-brv-config.js' +import {SKIP_COMMANDS, validateBrvConfigVersion} from '../../../src/oclif/hooks/init/validate-brv-config.js' import {BRV_CONFIG_VERSION} from '../../../src/server/constants.js' import {BrvConfig, BrvConfigParams} from '../../../src/server/core/domain/entities/brv-config.js' describe('validate-brv-config', () => { - describe('isVcHelpRequest', () => { - it('returns true for brv vc --help', () => { - expect(isVcHelpRequest('vc', ['--help'])).to.be.true - }) - - it('returns true for brv vc -h', () => { - expect(isVcHelpRequest('vc', ['-h'])).to.be.true - }) - - it('returns false for normal vc subcommands', () => { - expect(isVcHelpRequest('vc:status', ['--help'])).to.be.false - expect(isVcHelpRequest('vc:commit', ['-m', 'msg'])).to.be.false - }) - - it('returns false for non-vc commands with --help', () => { - expect(isVcHelpRequest('connectors', ['--help'])).to.be.false - expect(isVcHelpRequest('status', ['--help'])).to.be.false - }) - - it('returns false when argv has no help flag', () => { - expect(isVcHelpRequest('vc', [])).to.be.false - }) - }) - describe('validateBrvConfigVersion', () => { let existsStub: SinonStub let readStub: SinonStub