Add optional Orca account sign-in (#7515)

* auth v1

* fable review

* lint

* Account menu with org membership management

Default UX is a compact account menu (sign in, organization selection, sign
out) that renders only when cloud auth is configured; adds an organization
members dialog (invite, role, remove) gated on server-side role checks. The
multi-profile switcher UI is preserved behind ORCA_MULTI_PROFILE_UI=1.

Co-authored-by: Orca <help@stably.ai>

* Gate the optional account sign-in UI to dev builds

The account switcher stays hidden in packaged builds while the feature is
in progress. Dev builds still show it when the client env vars are set, and
a dev-only Settings > Dev Tools > Orca Cloud section mirrors it.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-07-09 02:13:13 -07:00
committed by GitHub
co-authored by Orca
parent 24d7f6b790
commit 06afbc4a4b
98 changed files with 12898 additions and 209 deletions
@@ -157,6 +157,29 @@ describe('BrowserSessionRegistry persistence', () => {
expect(fsState.present.has('/user-data/Partitions/orca-browser/Cookies')).toBe(true)
})
it('persists new browser session profiles under the active Orca profile directory', async () => {
const fsState = createFsState()
const profileMetaPath = '/user-data/profiles/local-work/browser-session-meta.json'
installModuleMocks(fsState)
const { browserSessionRegistry } = await import('./browser-session-registry')
browserSessionRegistry.configureForOrcaProfile({
orcaProfileId: 'local-work',
profileDirectory: '/user-data/profiles/local-work'
})
const profile = browserSessionRegistry.createProfile('isolated', 'Work Browser')
expect(profile).not.toBeNull()
expect(fsState.files.has(profileMetaPath)).toBe(true)
expect(fsState.files.has(META_PATH)).toBe(false)
expect(JSON.parse(fsState.files.get(profileMetaPath) ?? '{}').profiles[0]).toMatchObject({
id: profile!.id,
partition: profile!.partition,
label: 'Work Browser'
})
})
it('merges partition-keyed pending entries without clobbering unrelated entries', async () => {
const fsState = createFsState()
seedMeta(fsState, {
@@ -28,6 +28,11 @@ vi.mock('./browser-manager', () => ({
import { browserSessionRegistry } from './browser-session-registry'
import { setupClientHintsOverride } from './browser-session-ua'
import { ORCA_BROWSER_PARTITION } from '../../shared/constants'
import {
DEFAULT_LOCAL_ORCA_PROFILE_ID,
getOrcaProfileBrowserDefaultPartition,
getOrcaProfileBrowserSessionPartition
} from '../../shared/orca-profiles'
describe('BrowserSessionRegistry', () => {
beforeEach(() => {
@@ -280,6 +285,30 @@ describe('BrowserSessionRegistry', () => {
expect(webAuthnCallback).toHaveBeenCalledWith('credential-1')
})
it('uses profile-owned partitions for non-default Orca profiles', () => {
const orcaProfileId = 'local-work'
browserSessionRegistry.configureForOrcaProfile({
orcaProfileId,
profileDirectory: '/profiles/local-work'
})
expect(browserSessionRegistry.getDefaultProfile().partition).toBe(
getOrcaProfileBrowserDefaultPartition(orcaProfileId)
)
expect(browserSessionRegistry.isAllowedPartition(ORCA_BROWSER_PARTITION)).toBe(false)
const profile = browserSessionRegistry.createProfile('isolated', 'Work Browser')
expect(profile).not.toBeNull()
expect(profile!.partition).toBe(
getOrcaProfileBrowserSessionPartition(orcaProfileId, profile!.id)
)
browserSessionRegistry.configureForOrcaProfile({
orcaProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID,
profileDirectory: '/profiles/local-default'
})
})
describe('setupClientHintsOverride', () => {
it('overrides sec-ch-ua headers for Edge UA', () => {
const onBeforeSendHeaders = vi.fn()
+82 -31
View File
@@ -14,8 +14,14 @@ import {
unlinkSync,
writeFileSync
} from 'node:fs'
import { join } from 'node:path'
import { dirname, join } from 'node:path'
import { ORCA_BROWSER_PARTITION } from '../../shared/constants'
import {
DEFAULT_LOCAL_ORCA_PROFILE_ID,
getOrcaProfileBrowserDefaultPartition,
getOrcaProfileBrowserPartitionSegment,
getOrcaProfileBrowserSessionPartition
} from '../../shared/orca-profiles'
import type { BrowserSessionProfile, BrowserSessionProfileScope } from '../../shared/types'
import { browserManager } from './browser-manager'
import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access'
@@ -36,6 +42,15 @@ type BrowserSessionMeta = {
profiles: BrowserSessionProfile[]
}
export type BrowserSessionRegistryProfileOptions = {
orcaProfileId: string
profileDirectory: string
}
const BROWSER_SESSION_META_FILE_NAME = 'browser-session-meta.json'
const LEGACY_BROWSER_SESSION_PARTITION_RE =
/^persist:orca-browser-session-[\da-f-]{8}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{12}$/
// Why: the registry is the single source of truth for which Electron partitions
// are valid. will-attach-webview consults it to decide whether a guest's
// requested partition is allowed. This prevents a compromised renderer from
@@ -43,13 +58,28 @@ type BrowserSessionMeta = {
class BrowserSessionRegistry {
private readonly profiles = new Map<string, BrowserSessionProfile>()
private activeOrcaProfileId = DEFAULT_LOCAL_ORCA_PROFILE_ID
private metadataPathOverride: string | null = null
private defaultPartition = ORCA_BROWSER_PARTITION
constructor() {
this.resetDefaultProfile()
}
configureForOrcaProfile(options: BrowserSessionRegistryProfileOptions): void {
this.activeOrcaProfileId = options.orcaProfileId
this.metadataPathOverride = join(options.profileDirectory, BROWSER_SESSION_META_FILE_NAME)
this.defaultPartition = getOrcaProfileBrowserDefaultPartition(options.orcaProfileId)
this.profiles.clear()
this.resetDefaultProfile()
}
private resetDefaultProfile(): void {
const persisted = this.loadPersistedSource()
this.profiles.set('default', {
id: 'default',
scope: 'default',
partition: ORCA_BROWSER_PARTITION,
partition: this.defaultPartition,
label: 'Default',
source: persisted
})
@@ -60,7 +90,9 @@ class BrowserSessionRegistry {
// status. Cookies themselves persist in the Electron partition's SQLite DB,
// but the registry is in-memory only.
private get metadataPath(): string {
return join(app.getPath('userData'), 'browser-session-meta.json')
return (
this.metadataPathOverride ?? join(app.getPath('userData'), BROWSER_SESSION_META_FILE_NAME)
)
}
private loadPersistedSource(): BrowserSessionProfile['source'] {
@@ -78,6 +110,7 @@ class BrowserSessionRegistry {
try {
const existing = this.loadPersistedMeta()
const tmpPath = `${this.metadataPath}.tmp`
mkdirSync(dirname(this.metadataPath), { recursive: true })
writeFileSync(tmpPath, JSON.stringify({ ...existing, ...updates }))
renameSync(tmpPath, this.metadataPath)
} catch {
@@ -108,8 +141,8 @@ class BrowserSessionRegistry {
data && typeof data.userAgentByPartition === 'object' && data.userAgentByPartition
? { ...data.userAgentByPartition }
: {}
if (legacyUserAgent && !userAgentByPartition[ORCA_BROWSER_PARTITION]) {
userAgentByPartition[ORCA_BROWSER_PARTITION] = legacyUserAgent
if (legacyUserAgent && !userAgentByPartition[this.defaultPartition]) {
userAgentByPartition[this.defaultPartition] = legacyUserAgent
}
const legacyPendingCookieDbPath =
@@ -118,8 +151,8 @@ class BrowserSessionRegistry {
data && typeof data.pendingCookieImports === 'object' && data.pendingCookieImports
? { ...data.pendingCookieImports }
: {}
if (legacyPendingCookieDbPath && !pendingCookieImports[ORCA_BROWSER_PARTITION]) {
pendingCookieImports[ORCA_BROWSER_PARTITION] = legacyPendingCookieDbPath
if (legacyPendingCookieDbPath && !pendingCookieImports[this.defaultPartition]) {
pendingCookieImports[this.defaultPartition] = legacyPendingCookieDbPath
}
return {
defaultSource: data?.defaultSource ?? null,
@@ -168,10 +201,10 @@ class BrowserSessionRegistry {
// hydrateFromPersisted skips the default partition and createProfile never
// targets it. Without this, clipboard permissions and other guest policies
// are denied by default in the default browser partition.
this.setupSessionPolicies(ORCA_BROWSER_PARTITION)
this.setupSessionPolicies(this.defaultPartition)
const partitions = new Set([
ORCA_BROWSER_PARTITION,
this.defaultPartition,
...this.listProfiles().map((p) => p.partition)
])
for (const partition of partitions) {
@@ -208,9 +241,9 @@ class BrowserSessionRegistry {
}
// Why: replay writes to partition-derived file paths, so corrupted
// metadata must pass the same validation as the webview allowlist.
const knownPartitions = new Set([ORCA_BROWSER_PARTITION])
const knownPartitions = new Set([this.defaultPartition])
for (const profile of meta.profiles) {
if (BrowserSessionRegistry.isValidPersistedProfile(profile)) {
if (this.isValidPersistedProfile(profile)) {
knownPartitions.add(profile.partition)
}
}
@@ -270,7 +303,7 @@ class BrowserSessionRegistry {
}
this.persistMeta({
pendingCookieImports: remainingEntries,
pendingCookieDbPath: remainingEntries[ORCA_BROWSER_PARTITION] ?? null
pendingCookieDbPath: remainingEntries[this.defaultPartition] ?? null
})
} catch {
// best-effort — if this fails, CookieMonster loads the old DB
@@ -282,7 +315,7 @@ class BrowserSessionRegistry {
const pendingCookieImports = { ...meta.pendingCookieImports, [partition]: stagingDbPath }
this.persistMeta({
pendingCookieImports,
pendingCookieDbPath: pendingCookieImports[ORCA_BROWSER_PARTITION] ?? null
pendingCookieDbPath: pendingCookieImports[this.defaultPartition] ?? null
})
}
@@ -296,7 +329,7 @@ class BrowserSessionRegistry {
}
this.persistMeta({
userAgentByPartition,
userAgent: userAgentByPartition[ORCA_BROWSER_PARTITION] ?? null
userAgent: userAgentByPartition[this.defaultPartition] ?? null
})
}
@@ -313,7 +346,7 @@ class BrowserSessionRegistry {
}
isAllowedPartition(partition: string): boolean {
if (partition === ORCA_BROWSER_PARTITION) {
if (partition === this.defaultPartition) {
return true
}
return [...this.profiles.values()].some((p) => p.partition === partition)
@@ -321,14 +354,17 @@ class BrowserSessionRegistry {
resolvePartition(profileId: string | null | undefined): string {
if (!profileId) {
return ORCA_BROWSER_PARTITION
return this.defaultPartition
}
return this.profiles.get(profileId)?.partition ?? ORCA_BROWSER_PARTITION
return this.profiles.get(profileId)?.partition ?? this.defaultPartition
}
resolveKnownPartition(profileId: string | null | undefined): string | null {
if (!profileId) {
return ORCA_BROWSER_PARTITION
// Why: must track the active Orca profile's default partition, not the
// legacy constant, or non-default profiles would resolve local-default's
// cookie jar.
return this.defaultPartition
}
return this.profiles.get(profileId)?.partition ?? null
}
@@ -336,7 +372,7 @@ class BrowserSessionRegistry {
createProfile(scope: BrowserSessionProfileScope, label: string): BrowserSessionProfile | null {
// Why: only the constructor may create the default profile. Allowing the
// renderer to pass scope:'default' would create a second profile sharing
// ORCA_BROWSER_PARTITION, causing confusion on delete (clearing storage
// the active default partition, causing confusion on delete (clearing storage
// for the shared partition).
if (scope === 'default') {
return null
@@ -345,7 +381,7 @@ class BrowserSessionRegistry {
// Why: partition names are deterministic from the profile id so main can
// reconstruct the allowlist on restart from persisted profile metadata
// without needing a separate partition→profile mapping.
const partition = `persist:orca-browser-session-${id}`
const partition = getOrcaProfileBrowserSessionPartition(this.activeOrcaProfileId, id)
const profile: BrowserSessionProfile = {
id,
scope,
@@ -391,9 +427,9 @@ class BrowserSessionRegistry {
delete userAgentByPartition[profile.partition]
this.persistMeta({
pendingCookieImports,
pendingCookieDbPath: pendingCookieImports[ORCA_BROWSER_PARTITION] ?? null,
pendingCookieDbPath: pendingCookieImports[this.defaultPartition] ?? null,
userAgentByPartition,
userAgent: userAgentByPartition[ORCA_BROWSER_PARTITION] ?? null
userAgent: userAgentByPartition[this.defaultPartition] ?? null
})
// Why: clearing the partition's storage prevents orphaned cookies/cache from
@@ -423,9 +459,9 @@ class BrowserSessionRegistry {
}
const meta = this.loadPersistedMeta()
const pendingCookieImports = { ...meta.pendingCookieImports }
delete pendingCookieImports[ORCA_BROWSER_PARTITION]
delete pendingCookieImports[this.defaultPartition]
const userAgentByPartition = { ...meta.userAgentByPartition }
delete userAgentByPartition[ORCA_BROWSER_PARTITION]
delete userAgentByPartition[this.defaultPartition]
this.persistMeta({
defaultSource: null,
userAgent: null,
@@ -434,7 +470,7 @@ class BrowserSessionRegistry {
pendingCookieImports
})
const sess = session.fromPartition(ORCA_BROWSER_PARTITION)
const sess = session.fromPartition(this.defaultPartition)
await sess.clearStorageData({ storages: ['cookies'] })
return true
} catch {
@@ -449,9 +485,7 @@ class BrowserSessionRegistry {
// tampered file could inject an arbitrary partition into the allowlist that
// will-attach-webview trusts, so we validate the expected shape before
// registering anything.
private static readonly PARTITION_RE = /^persist:orca-browser-session-[\da-f-]{36}$/
private static isValidPersistedProfile(profile: unknown): profile is BrowserSessionProfile {
private isValidPersistedProfile(profile: unknown): profile is BrowserSessionProfile {
if (!profile || typeof profile !== 'object') {
return false
}
@@ -462,17 +496,34 @@ class BrowserSessionRegistry {
typeof candidate.id === 'string' &&
typeof candidate.partition === 'string' &&
typeof candidate.label === 'string' &&
BrowserSessionRegistry.PARTITION_RE.test(candidate.partition)
this.isProfileOwnedSessionPartition(candidate.partition)
)
}
private isProfileOwnedSessionPartition(partition: string): boolean {
if (
this.activeOrcaProfileId === DEFAULT_LOCAL_ORCA_PROFILE_ID &&
LEGACY_BROWSER_SESSION_PARTITION_RE.test(partition)
) {
return true
}
const segment = getOrcaProfileBrowserPartitionSegment(this.activeOrcaProfileId)
const prefix = `persist:orca-profile-${segment}-browser-session-`
if (!partition.startsWith(prefix)) {
return false
}
const profileId = partition.slice(prefix.length)
return /^[\da-f-]{8}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{12}$/.test(profileId)
}
hydrateFromPersisted(profiles: BrowserSessionProfile[]): void {
for (const profile of profiles) {
if (!BrowserSessionRegistry.isValidPersistedProfile(profile)) {
if (!this.isValidPersistedProfile(profile)) {
continue
}
this.profiles.set(profile.id, profile)
if (profile.partition !== ORCA_BROWSER_PARTITION) {
if (profile.partition !== this.defaultPartition) {
this.setupSessionPolicies(profile.partition)
}
}
@@ -1,20 +1,24 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
function installRegistryMock(): {
configureForOrcaProfileMock: ReturnType<typeof vi.fn>
applyPendingCookieImportMock: ReturnType<typeof vi.fn>
initializeBrowserSessionsFromPersistedStateMock: ReturnType<typeof vi.fn>
} {
const configureForOrcaProfileMock = vi.fn()
const applyPendingCookieImportMock = vi.fn()
const initializeBrowserSessionsFromPersistedStateMock = vi.fn()
vi.doMock('./browser-session-registry', () => ({
browserSessionRegistry: {
configureForOrcaProfile: configureForOrcaProfileMock,
applyPendingCookieImport: applyPendingCookieImportMock,
initializeBrowserSessionsFromPersistedState: initializeBrowserSessionsFromPersistedStateMock
}
}))
return {
configureForOrcaProfileMock,
applyPendingCookieImportMock,
initializeBrowserSessionsFromPersistedStateMock
}
@@ -40,6 +44,31 @@ describe('initializeBrowserSessionsForApp', () => {
)
})
it('configures the active Orca profile before replaying browser sessions', async () => {
const {
configureForOrcaProfileMock,
applyPendingCookieImportMock,
initializeBrowserSessionsFromPersistedStateMock
} = installRegistryMock()
const { initializeBrowserSessionsForApp } = await import('./browser-session-startup')
initializeBrowserSessionsForApp({
orcaProfileId: 'local-work',
profileDirectory: '/profiles/local-work'
})
expect(configureForOrcaProfileMock).toHaveBeenCalledWith({
orcaProfileId: 'local-work',
profileDirectory: '/profiles/local-work'
})
expect(configureForOrcaProfileMock.mock.invocationCallOrder[0]).toBeLessThan(
applyPendingCookieImportMock.mock.invocationCallOrder[0]
)
expect(applyPendingCookieImportMock.mock.invocationCallOrder[0]).toBeLessThan(
initializeBrowserSessionsFromPersistedStateMock.mock.invocationCallOrder[0]
)
})
it('initializes browser sessions once per app process', async () => {
const { applyPendingCookieImportMock, initializeBrowserSessionsFromPersistedStateMock } =
installRegistryMock()
+8 -1
View File
@@ -1,12 +1,19 @@
import { browserSessionRegistry } from './browser-session-registry'
import type { BrowserSessionRegistryProfileOptions } from './browser-session-registry'
let initialized = false
export function initializeBrowserSessionsForApp(): void {
export function initializeBrowserSessionsForApp(
activeProfile?: BrowserSessionRegistryProfileOptions
): void {
if (initialized) {
return
}
if (activeProfile) {
browserSessionRegistry.configureForOrcaProfile(activeProfile)
}
// Why: cookie replay must happen before the first session.fromPartition()
// call, otherwise Chromium opens the stale live cookie DB before import.
browserSessionRegistry.applyPendingCookieImport()
+8 -2
View File
@@ -14,6 +14,7 @@ import {
getCanonicalUserDataPath,
migrateMobilePairingDataToCanonicalUserDataPath
} from './persistence'
import { ensureActiveOrcaProfile, initOrcaProfilePaths } from './orca-profiles/profile-index-store'
import { applyAppIcon } from './app-icon'
import { StatsCollector, initStatsPath } from './stats/collector'
import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store'
@@ -582,6 +583,7 @@ if (hasSingleInstanceLock) {
// orca-dev in dev mode) but before app.setName('Orca') inside whenReady
// (which would change the resolved path on case-sensitive filesystems).
initDataPath()
initOrcaProfilePaths()
// Why: same timing constraint as initDataPath — capture the userData path
// before app.setName changes it. See persistence.ts:20-28.
initStatsPath()
@@ -1646,7 +1648,8 @@ app.whenReady().then(async () => {
electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId)
app.setName(devInstanceIdentity.name)
store = new Store()
const activeOrcaProfile = ensureActiveOrcaProfile()
store = new Store({ dataFile: activeOrcaProfile.dataFile })
logStartupMilestone('store-loaded')
// Why: must run before ClaudeRuntimeAuthService's constructor sync — a Claude
// CLI that survived the restart inside the daemon still holds the current
@@ -1674,7 +1677,10 @@ app.whenReady().then(async () => {
}
// Why: browser sessions are used by desktop webviews and runtime profile
// commands, so initialize them at app startup instead of a renderer IPC path.
initializeBrowserSessionsForApp()
initializeBrowserSessionsForApp({
orcaProfileId: activeOrcaProfile.profile.id,
profileDirectory: activeOrcaProfile.profileDirectory
})
unsubscribeSystemResumeBroadcast = registerSystemResumeBroadcast()
agentAwakeService = new AgentAwakeService()
agentAwakeService.setEnabled(store.getSettings().keepComputerAwakeWhileAgentsRun)
@@ -0,0 +1,171 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
handlers,
createCloudLinkedOrcaProfileMock,
connectCurrentOrcaProfileMock,
getCurrentOrcaProfileAuthStatusMock,
refreshCurrentOrcaProfileAuthMock,
selectCurrentOrcaProfileOrgMock,
signOutCurrentOrcaProfileMock
} = vi.hoisted(() => ({
handlers: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
createCloudLinkedOrcaProfileMock: vi.fn(),
connectCurrentOrcaProfileMock: vi.fn(),
getCurrentOrcaProfileAuthStatusMock: vi.fn(),
refreshCurrentOrcaProfileAuthMock: vi.fn(),
selectCurrentOrcaProfileOrgMock: vi.fn(),
signOutCurrentOrcaProfileMock: vi.fn()
}))
vi.mock('electron', () => ({
app: {
exit: vi.fn(),
getPath: () => '/tmp/orca-user-data',
relaunch: vi.fn()
},
ipcMain: {
handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => {
handlers.set(channel, handler)
})
}
}))
vi.mock('../tray/system-tray', () => ({
destroySystemTray: vi.fn()
}))
vi.mock('../orca-profiles/profile-index-store', () => ({
createLocalOrcaProfile: vi.fn(),
getOrcaProfileListState: vi.fn(),
seedNewOrcaProfileTelemetryConsent: vi.fn(),
setActiveOrcaProfile: vi.fn()
}))
vi.mock('../orca-profiles/profile-project-transfer', () => ({
transferOrcaProfileProject: vi.fn()
}))
vi.mock('../orca-profiles/profile-cloud-service', () => ({
createCloudLinkedOrcaProfile: createCloudLinkedOrcaProfileMock,
connectCurrentOrcaProfile: connectCurrentOrcaProfileMock,
getCurrentOrcaProfileAuthStatus: getCurrentOrcaProfileAuthStatusMock,
refreshCurrentOrcaProfileAuth: refreshCurrentOrcaProfileAuthMock,
selectCurrentOrcaProfileOrg: selectCurrentOrcaProfileOrgMock,
signOutCurrentOrcaProfile: signOutCurrentOrcaProfileMock
}))
import { registerOrcaProfileHandlers } from './orca-profiles'
describe('registerOrcaProfileHandlers auth channels', () => {
beforeEach(() => {
handlers.clear()
createCloudLinkedOrcaProfileMock.mockReset()
connectCurrentOrcaProfileMock.mockReset()
getCurrentOrcaProfileAuthStatusMock.mockReset()
refreshCurrentOrcaProfileAuthMock.mockReset()
selectCurrentOrcaProfileOrgMock.mockReset()
signOutCurrentOrcaProfileMock.mockReset()
})
it('returns auth status for the current profile', async () => {
const status = {
activeProfileId: 'local-default',
configured: false,
state: 'unconfigured',
persistence: 'none'
}
getCurrentOrcaProfileAuthStatusMock.mockReturnValue(status)
registerOrcaProfileHandlers({
flush: vi.fn(),
freezeWrites: vi.fn(),
getSettings: () => ({})
} as never)
await expect(Promise.resolve(handlers.get('orcaProfiles:authStatus')?.(null))).resolves.toBe(
status
)
expect(getCurrentOrcaProfileAuthStatusMock).toHaveBeenCalledWith('/tmp/orca-user-data')
})
it('connects and signs out the current profile through the cloud service', async () => {
const connectResult = { status: 'unconfigured', auth: { activeProfileId: 'local-default' } }
const signOutResult = { status: 'signed-out', auth: { activeProfileId: 'local-default' } }
connectCurrentOrcaProfileMock.mockResolvedValue(connectResult)
signOutCurrentOrcaProfileMock.mockResolvedValue(signOutResult)
registerOrcaProfileHandlers({
flush: vi.fn(),
freezeWrites: vi.fn(),
getSettings: () => ({})
} as never)
await expect(
Promise.resolve(handlers.get('orcaProfiles:connectCurrent')?.(null))
).resolves.toBe(connectResult)
await expect(
Promise.resolve(handlers.get('orcaProfiles:signOutCurrent')?.(null))
).resolves.toBe(signOutResult)
expect(connectCurrentOrcaProfileMock).toHaveBeenCalledWith('/tmp/orca-user-data')
expect(signOutCurrentOrcaProfileMock).toHaveBeenCalledWith('/tmp/orca-user-data')
})
it('refreshes profile auth through the cloud service', async () => {
const refreshResult = { status: 'refreshed', auth: { activeProfileId: 'local-default' } }
refreshCurrentOrcaProfileAuthMock.mockResolvedValue(refreshResult)
registerOrcaProfileHandlers({
flush: vi.fn(),
freezeWrites: vi.fn(),
getSettings: () => ({})
} as never)
await expect(Promise.resolve(handlers.get('orcaProfiles:refreshAuth')?.(null))).resolves.toBe(
refreshResult
)
expect(refreshCurrentOrcaProfileAuthMock).toHaveBeenCalledWith('/tmp/orca-user-data')
})
it('validates organization selection before calling the cloud service', async () => {
const selectResult = { status: 'selected', auth: { activeProfileId: 'local-default' } }
selectCurrentOrcaProfileOrgMock.mockResolvedValue(selectResult)
registerOrcaProfileHandlers({
flush: vi.fn(),
freezeWrites: vi.fn(),
getSettings: () => ({})
} as never)
await expect(
Promise.resolve(handlers.get('orcaProfiles:selectOrg')?.(null, { orgId: ' org-1 ' }))
).resolves.toBe(selectResult)
expect(selectCurrentOrcaProfileOrgMock).toHaveBeenCalledWith('/tmp/orca-user-data', 'org-1')
await expect(
Promise.resolve(handlers.get('orcaProfiles:selectOrg')?.(null, { orgId: ' ' }))
).rejects.toThrow('invalid_orca_profile_org_selection')
})
it('creates cloud-linked profiles with trimmed optional args', async () => {
const createResult = {
status: 'created',
auth: { activeProfileId: 'local-default' },
activeProfileId: 'local-default',
profiles: [],
profile: { id: 'cloud-1' }
}
createCloudLinkedOrcaProfileMock.mockResolvedValue(createResult)
registerOrcaProfileHandlers({
flush: vi.fn(),
freezeWrites: vi.fn(),
getSettings: () => ({})
} as never)
await expect(
Promise.resolve(
handlers.get('orcaProfiles:createCloudLinked')?.(null, { orgId: ' org-1 ', name: ' Acme ' })
)
).resolves.toBe(createResult)
expect(createCloudLinkedOrcaProfileMock).toHaveBeenCalledWith('/tmp/orca-user-data', {
orgId: 'org-1',
name: 'Acme'
})
})
})
@@ -0,0 +1,116 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
handlers,
listOrcaProfileOrgMembersMock,
inviteOrcaProfileOrgMemberMock,
revokeOrcaProfileOrgInviteMock,
changeOrcaProfileOrgMemberRoleMock,
removeOrcaProfileOrgMemberMock
} = vi.hoisted(() => ({
handlers: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
listOrcaProfileOrgMembersMock: vi.fn(),
inviteOrcaProfileOrgMemberMock: vi.fn(),
revokeOrcaProfileOrgInviteMock: vi.fn(),
changeOrcaProfileOrgMemberRoleMock: vi.fn(),
removeOrcaProfileOrgMemberMock: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: {
handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => {
handlers.set(channel, handler)
})
}
}))
vi.mock('../orca-profiles/profile-storage-paths', () => ({
getProfileUserDataPath: () => '/tmp/orca-user-data'
}))
vi.mock('../orca-profiles/profile-cloud-org-members-service', () => ({
listOrcaProfileOrgMembers: listOrcaProfileOrgMembersMock,
inviteOrcaProfileOrgMember: inviteOrcaProfileOrgMemberMock,
revokeOrcaProfileOrgInvite: revokeOrcaProfileOrgInviteMock,
changeOrcaProfileOrgMemberRole: changeOrcaProfileOrgMemberRoleMock,
removeOrcaProfileOrgMember: removeOrcaProfileOrgMemberMock
}))
import { registerOrcaProfileOrgMemberHandlers } from './orca-profile-org-members-handlers'
function invoke(channel: string, args?: unknown): unknown {
const handler = handlers.get(channel)
if (!handler) {
throw new Error(`No handler for ${channel}`)
}
return handler({}, args)
}
describe('registerOrcaProfileOrgMemberHandlers', () => {
beforeEach(() => {
handlers.clear()
listOrcaProfileOrgMembersMock.mockReset().mockResolvedValue({ status: 'ok', roster: {} })
inviteOrcaProfileOrgMemberMock.mockReset().mockResolvedValue({ status: 'ok' })
revokeOrcaProfileOrgInviteMock.mockReset().mockResolvedValue({ status: 'ok' })
changeOrcaProfileOrgMemberRoleMock.mockReset().mockResolvedValue({ status: 'ok' })
removeOrcaProfileOrgMemberMock.mockReset().mockResolvedValue({ status: 'ok' })
registerOrcaProfileOrgMemberHandlers()
})
it('registers all five org-member channels', () => {
expect([...handlers.keys()].sort()).toEqual(
[
'orcaProfiles:orgInviteRevoke',
'orcaProfiles:orgMemberChangeRole',
'orcaProfiles:orgMemberInvite',
'orcaProfiles:orgMemberRemove',
'orcaProfiles:orgMembersList'
].sort()
)
})
it('forwards a valid invite to the service with a trimmed email', async () => {
await invoke('orcaProfiles:orgMemberInvite', {
orgId: 'org-1',
email: ' new@example.com ',
role: 'admin'
})
expect(inviteOrcaProfileOrgMemberMock).toHaveBeenCalledWith('/tmp/orca-user-data', {
orgId: 'org-1',
email: 'new@example.com',
role: 'admin'
})
})
it('rejects an invite with a missing org id', async () => {
await expect(
invoke('orcaProfiles:orgMemberInvite', { email: 'a@b.com', role: 'member' })
).rejects.toThrow('invalid_orca_profile_org_selection')
expect(inviteOrcaProfileOrgMemberMock).not.toHaveBeenCalled()
})
it('rejects an invite with an unknown role', async () => {
await expect(
invoke('orcaProfiles:orgMemberInvite', { orgId: 'org-1', email: 'a@b.com', role: 'root' })
).rejects.toThrow('invalid_orca_org_role')
})
it('rejects a role change with a blank user id', async () => {
await expect(
invoke('orcaProfiles:orgMemberChangeRole', { orgId: 'org-1', userId: ' ', role: 'admin' })
).rejects.toThrow('invalid_orca_org_member_user')
})
it('forwards remove and revoke with validated args', async () => {
await invoke('orcaProfiles:orgMemberRemove', { orgId: 'org-1', userId: 'user-2' })
expect(removeOrcaProfileOrgMemberMock).toHaveBeenCalledWith('/tmp/orca-user-data', {
orgId: 'org-1',
userId: 'user-2'
})
await invoke('orcaProfiles:orgInviteRevoke', { orgId: 'org-1', email: 'gone@b.com' })
expect(revokeOrcaProfileOrgInviteMock).toHaveBeenCalledWith('/tmp/orca-user-data', {
orgId: 'org-1',
email: 'gone@b.com'
})
})
})
@@ -0,0 +1,128 @@
import { ipcMain } from 'electron'
import type {
OrcaOrgRole,
OrcaProfileOrgInviteRevokeArgs,
OrcaProfileOrgMemberChangeRoleArgs,
OrcaProfileOrgMemberInviteArgs,
OrcaProfileOrgMemberMutationResult,
OrcaProfileOrgMemberRemoveArgs,
OrcaProfileOrgMembersListArgs,
OrcaProfileOrgMembersListResult
} from '../../shared/orca-profiles'
import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths'
import {
changeOrcaProfileOrgMemberRole,
inviteOrcaProfileOrgMember,
listOrcaProfileOrgMembers,
removeOrcaProfileOrgMember,
revokeOrcaProfileOrgInvite
} from '../orca-profiles/profile-cloud-org-members-service'
function orgMembersScopedArgs(args: unknown): { orgId: string; record: Record<string, unknown> } {
if (!args || typeof args !== 'object') {
throw new Error('invalid_orca_profile_org_selection')
}
const record = args as Record<string, unknown>
const orgId = typeof record.orgId === 'string' ? record.orgId.trim() : ''
if (!orgId) {
throw new Error('invalid_orca_profile_org_selection')
}
return { orgId, record }
}
function orgRoleFromUnknown(value: unknown): OrcaOrgRole {
if (value === 'owner' || value === 'admin' || value === 'member') {
return value
}
throw new Error('invalid_orca_org_role')
}
function orgEmailFromUnknown(value: unknown): string {
const email = typeof value === 'string' ? value.trim() : ''
if (!email) {
throw new Error('invalid_orca_org_member_email')
}
return email
}
function orgUserIdFromUnknown(value: unknown): string {
const userId = typeof value === 'string' ? value.trim() : ''
if (!userId) {
throw new Error('invalid_orca_org_member_user')
}
return userId
}
function orgMemberInviteArgsFromUnknown(args: unknown): OrcaProfileOrgMemberInviteArgs {
const { orgId, record } = orgMembersScopedArgs(args)
return { orgId, email: orgEmailFromUnknown(record.email), role: orgRoleFromUnknown(record.role) }
}
function orgInviteRevokeArgsFromUnknown(args: unknown): OrcaProfileOrgInviteRevokeArgs {
const { orgId, record } = orgMembersScopedArgs(args)
return { orgId, email: orgEmailFromUnknown(record.email) }
}
function orgMemberChangeRoleArgsFromUnknown(args: unknown): OrcaProfileOrgMemberChangeRoleArgs {
const { orgId, record } = orgMembersScopedArgs(args)
return {
orgId,
userId: orgUserIdFromUnknown(record.userId),
role: orgRoleFromUnknown(record.role)
}
}
function orgMemberRemoveArgsFromUnknown(args: unknown): OrcaProfileOrgMemberRemoveArgs {
const { orgId, record } = orgMembersScopedArgs(args)
return { orgId, userId: orgUserIdFromUnknown(record.userId) }
}
export function registerOrcaProfileOrgMemberHandlers(): void {
ipcMain.handle(
'orcaProfiles:orgMembersList',
async (
_event,
rawArgs: OrcaProfileOrgMembersListArgs
): Promise<OrcaProfileOrgMembersListResult> =>
listOrcaProfileOrgMembers(getProfileUserDataPath(), orgMembersScopedArgs(rawArgs).orgId)
)
ipcMain.handle(
'orcaProfiles:orgMemberInvite',
async (
_event,
rawArgs: OrcaProfileOrgMemberInviteArgs
): Promise<OrcaProfileOrgMemberMutationResult> =>
inviteOrcaProfileOrgMember(getProfileUserDataPath(), orgMemberInviteArgsFromUnknown(rawArgs))
)
ipcMain.handle(
'orcaProfiles:orgInviteRevoke',
async (
_event,
rawArgs: OrcaProfileOrgInviteRevokeArgs
): Promise<OrcaProfileOrgMemberMutationResult> =>
revokeOrcaProfileOrgInvite(getProfileUserDataPath(), orgInviteRevokeArgsFromUnknown(rawArgs))
)
ipcMain.handle(
'orcaProfiles:orgMemberChangeRole',
async (
_event,
rawArgs: OrcaProfileOrgMemberChangeRoleArgs
): Promise<OrcaProfileOrgMemberMutationResult> =>
changeOrcaProfileOrgMemberRole(
getProfileUserDataPath(),
orgMemberChangeRoleArgsFromUnknown(rawArgs)
)
)
ipcMain.handle(
'orcaProfiles:orgMemberRemove',
async (
_event,
rawArgs: OrcaProfileOrgMemberRemoveArgs
): Promise<OrcaProfileOrgMemberMutationResult> =>
removeOrcaProfileOrgMember(getProfileUserDataPath(), orgMemberRemoveArgsFromUnknown(rawArgs))
)
}
+321
View File
@@ -0,0 +1,321 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
handlers,
appExitMock,
appQuitMock,
appRelaunchMock,
destroySystemTrayMock,
createLocalOrcaProfileMock,
getOrcaProfileListStateMock,
seedNewOrcaProfileTelemetryConsentMock,
setActiveOrcaProfileMock,
transferOrcaProfileProjectMock
} = vi.hoisted(() => ({
handlers: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
appExitMock: vi.fn(),
appQuitMock: vi.fn(),
appRelaunchMock: vi.fn(),
destroySystemTrayMock: vi.fn(),
createLocalOrcaProfileMock: vi.fn(),
getOrcaProfileListStateMock: vi.fn(),
seedNewOrcaProfileTelemetryConsentMock: vi.fn(),
setActiveOrcaProfileMock: vi.fn(),
transferOrcaProfileProjectMock: vi.fn()
}))
vi.mock('electron', () => ({
app: {
exit: appExitMock,
quit: appQuitMock,
relaunch: appRelaunchMock,
getPath: () => '/tmp/orca-user-data'
},
ipcMain: {
handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => {
handlers.set(channel, handler)
})
}
}))
vi.mock('../tray/system-tray', () => ({
destroySystemTray: destroySystemTrayMock
}))
vi.mock('../orca-profiles/profile-index-store', () => ({
createLocalOrcaProfile: createLocalOrcaProfileMock,
getOrcaProfileListState: getOrcaProfileListStateMock,
seedNewOrcaProfileTelemetryConsent: seedNewOrcaProfileTelemetryConsentMock,
setActiveOrcaProfile: setActiveOrcaProfileMock
}))
function makeStoreMock(flush = vi.fn()): {
flush: typeof flush
freezeWrites: ReturnType<typeof vi.fn>
getSettings: () => Record<string, never>
} {
return { flush, freezeWrites: vi.fn(), getSettings: () => ({}) }
}
vi.mock('../orca-profiles/profile-project-transfer', () => ({
transferOrcaProfileProject: transferOrcaProfileProjectMock
}))
import { registerOrcaProfileHandlers } from './orca-profiles'
describe('registerOrcaProfileHandlers', () => {
beforeEach(() => {
vi.useFakeTimers()
handlers.clear()
appExitMock.mockReset()
appQuitMock.mockReset()
appRelaunchMock.mockReset()
destroySystemTrayMock.mockReset()
createLocalOrcaProfileMock.mockReset()
getOrcaProfileListStateMock.mockReset()
seedNewOrcaProfileTelemetryConsentMock.mockReset()
setActiveOrcaProfileMock.mockReset()
transferOrcaProfileProjectMock.mockReset()
})
afterEach(() => {
vi.useRealTimers()
})
it('registers list and create handlers', async () => {
const listState = {
activeProfileId: 'local-default',
profiles: [{ id: 'local-default', name: 'Personal' }]
}
const createState = {
...listState,
profile: { id: 'local-work', name: 'Work' }
}
getOrcaProfileListStateMock.mockReturnValue(listState)
createLocalOrcaProfileMock.mockReturnValue(createState)
registerOrcaProfileHandlers(makeStoreMock() as never)
await expect(Promise.resolve(handlers.get('orcaProfiles:list')?.(null))).resolves.toEqual({
...listState,
multiProfileUi: false
})
await expect(
Promise.resolve(handlers.get('orcaProfiles:createLocal')?.(null, { name: 'Work' }))
).resolves.toBe(createState)
expect(createLocalOrcaProfileMock).toHaveBeenCalledWith({ name: 'Work' })
})
it('reports multiProfileUi when the env flag is set', async () => {
const previous = process.env.ORCA_MULTI_PROFILE_UI
process.env.ORCA_MULTI_PROFILE_UI = '1'
try {
getOrcaProfileListStateMock.mockReturnValue({
activeProfileId: 'local-default',
profiles: []
})
registerOrcaProfileHandlers(makeStoreMock() as never)
await expect(Promise.resolve(handlers.get('orcaProfiles:list')?.(null))).resolves.toEqual({
activeProfileId: 'local-default',
profiles: [],
multiProfileUi: true
})
} finally {
if (previous === undefined) {
delete process.env.ORCA_MULTI_PROFILE_UI
} else {
process.env.ORCA_MULTI_PROFILE_UI = previous
}
}
})
it('marks the target profile active, flushes, and relaunches', async () => {
const flush = vi.fn()
const onBeforeRelaunch = vi.fn()
getOrcaProfileListStateMock.mockReturnValue({
activeProfileId: 'local-default',
profiles: []
})
setActiveOrcaProfileMock.mockReturnValue({
activeProfileId: 'local-work',
profiles: []
})
registerOrcaProfileHandlers(makeStoreMock(flush) as never, { onBeforeRelaunch })
const resultPromise = Promise.resolve(
handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-work' })
)
await expect(resultPromise).resolves.toEqual({ status: 'relaunching' })
expect(setActiveOrcaProfileMock).toHaveBeenCalledWith('local-work')
expect(flush).toHaveBeenCalledOnce()
expect(onBeforeRelaunch).toHaveBeenCalledOnce()
expect(flush.mock.invocationCallOrder[0]).toBeLessThan(
setActiveOrcaProfileMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY
)
expect(appRelaunchMock).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(150)
expect(appRelaunchMock).toHaveBeenCalledOnce()
// Why quit, not exit: before-quit/will-quit teardown (scrollback capture,
// PTY kill, daemon checkpoints) must run on a profile switch.
expect(appQuitMock).toHaveBeenCalledOnce()
expect(appExitMock).not.toHaveBeenCalled()
})
it('does not mark a profile active when current profile flush fails', async () => {
const flush = vi.fn(() => {
throw new Error('flush_failed')
})
getOrcaProfileListStateMock.mockReturnValue({
activeProfileId: 'local-default',
profiles: []
})
registerOrcaProfileHandlers(makeStoreMock(flush) as never)
await expect(
Promise.resolve(handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-work' }))
).rejects.toThrow('flush_failed')
expect(setActiveOrcaProfileMock).not.toHaveBeenCalled()
expect(appRelaunchMock).not.toHaveBeenCalled()
})
it('does not relaunch when switching to the active profile', async () => {
getOrcaProfileListStateMock.mockReturnValue({
activeProfileId: 'local-default',
profiles: []
})
registerOrcaProfileHandlers(makeStoreMock() as never)
await expect(
Promise.resolve(handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-default' }))
).resolves.toEqual({ status: 'already-active' })
expect(setActiveOrcaProfileMock).not.toHaveBeenCalled()
expect(appRelaunchMock).not.toHaveBeenCalled()
})
it('rejects invalid profile ids', async () => {
registerOrcaProfileHandlers(makeStoreMock() as never)
await expect(
Promise.resolve(handlers.get('orcaProfiles:switch')?.(null, { profileId: ' ' }))
).rejects.toThrow('invalid_orca_profile_id')
})
it('transfers projects between inactive profiles after flushing active state', async () => {
const flush = vi.fn()
const result = {
status: 'transferred',
mode: 'copy',
sourceProfileId: 'personal',
targetProfileId: 'work',
sourceRepoId: 'repo-1',
targetRepoId: 'repo-2',
targetProjectId: 'repo:repo-2'
}
getOrcaProfileListStateMock.mockReturnValue({
activeProfileId: 'personal',
profiles: []
})
transferOrcaProfileProjectMock.mockReturnValue(result)
registerOrcaProfileHandlers(makeStoreMock(flush) as never)
await expect(
Promise.resolve(
handlers.get('orcaProfiles:transferProject')?.(null, {
sourceProfileId: ' personal ',
targetProfileId: ' work ',
repoId: ' repo-1 ',
mode: 'copy'
})
)
).resolves.toBe(result)
expect(flush).toHaveBeenCalledOnce()
expect(transferOrcaProfileProjectMock).toHaveBeenCalledWith(
{
sourceProfileId: 'personal',
targetProfileId: 'work',
repoId: 'repo-1',
mode: 'copy'
},
'/tmp/orca-user-data'
)
})
it('moves a project out of the active profile and relaunches into the target profile', async () => {
const flush = vi.fn()
const onBeforeRelaunch = vi.fn()
const result = {
status: 'transferred',
mode: 'move',
sourceProfileId: 'personal',
targetProfileId: 'work',
sourceRepoId: 'repo-1',
targetRepoId: 'repo-1',
targetProjectId: 'repo:repo-1'
}
getOrcaProfileListStateMock.mockReturnValue({
activeProfileId: 'personal',
profiles: []
})
transferOrcaProfileProjectMock.mockReturnValue(result)
registerOrcaProfileHandlers(makeStoreMock(flush) as never, { onBeforeRelaunch })
await expect(
Promise.resolve(
handlers.get('orcaProfiles:transferProject')?.(null, {
sourceProfileId: 'personal',
targetProfileId: 'work',
repoId: 'repo-1',
mode: 'move'
})
)
).resolves.toEqual({ ...result, willRelaunch: true })
expect(onBeforeRelaunch).toHaveBeenCalledOnce()
expect(flush).toHaveBeenCalledOnce()
expect(transferOrcaProfileProjectMock).toHaveBeenCalledWith(
{
sourceProfileId: 'personal',
targetProfileId: 'work',
repoId: 'repo-1',
mode: 'move'
},
'/tmp/orca-user-data'
)
expect(setActiveOrcaProfileMock).toHaveBeenCalledWith('work')
expect(appRelaunchMock).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(150)
expect(appRelaunchMock).toHaveBeenCalledOnce()
expect(appQuitMock).toHaveBeenCalledOnce()
expect(appExitMock).not.toHaveBeenCalled()
})
it('rejects transfers that would mutate the active target profile offline', async () => {
getOrcaProfileListStateMock.mockReturnValue({
activeProfileId: 'work',
profiles: []
})
registerOrcaProfileHandlers(makeStoreMock() as never)
await expect(
Promise.resolve(
handlers.get('orcaProfiles:transferProject')?.(null, {
sourceProfileId: 'personal',
targetProfileId: 'work',
repoId: 'repo-1',
mode: 'copy'
})
)
).rejects.toThrow('active_target_orca_profile_transfer_requires_relaunch')
expect(transferOrcaProfileProjectMock).not.toHaveBeenCalled()
})
})
+291
View File
@@ -0,0 +1,291 @@
import { app, ipcMain } from 'electron'
import type { Store } from '../persistence'
import type {
CreateLocalOrcaProfileArgs,
CreateLocalOrcaProfileResult,
CreateCloudLinkedOrcaProfileArgs,
CreateCloudLinkedOrcaProfileResult,
FindOrcaProfileProjectsByPathArgs,
FindOrcaProfileProjectsByPathResult,
OrcaProfileListResult,
RefreshCurrentOrcaProfileAuthResult,
SwitchOrcaProfileArgs,
SwitchOrcaProfileResult,
TransferOrcaProfileProjectArgs,
TransferOrcaProfileProjectResult,
ConnectCurrentOrcaProfileResult,
OrcaProfileAuthStatus,
SelectOrcaProfileOrgArgs,
SelectOrcaProfileOrgResult,
SignOutCurrentOrcaProfileResult
} from '../../shared/orca-profiles'
import {
createLocalOrcaProfile,
getOrcaProfileListState,
seedNewOrcaProfileTelemetryConsent,
setActiveOrcaProfile
} from '../orca-profiles/profile-index-store'
import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths'
import { isMultiProfileUiEnabled } from '../orca-profiles/profile-ui-scope'
import { transferOrcaProfileProject } from '../orca-profiles/profile-project-transfer'
import { findOrcaProfileProjectsByPath } from '../orca-profiles/profile-project-presence'
import { normalizeExecutionHostId } from '../../shared/execution-host'
import {
createCloudLinkedOrcaProfile,
connectCurrentOrcaProfile,
getCurrentOrcaProfileAuthStatus,
refreshCurrentOrcaProfileAuth,
selectCurrentOrcaProfileOrg,
signOutCurrentOrcaProfile
} from '../orca-profiles/profile-cloud-service'
import { registerOrcaProfileOrgMemberHandlers } from './orca-profile-org-members-handlers'
type RegisterOrcaProfileHandlersOptions = {
onBeforeRelaunch?: () => void | Promise<void>
}
function profileIdFromArgs(args: unknown): string {
if (
!args ||
typeof args !== 'object' ||
typeof (args as SwitchOrcaProfileArgs).profileId !== 'string'
) {
throw new Error('invalid_orca_profile_id')
}
const profileId = (args as SwitchOrcaProfileArgs).profileId.trim()
if (!profileId) {
throw new Error('invalid_orca_profile_id')
}
return profileId
}
function transferProjectArgsFromUnknown(args: unknown): TransferOrcaProfileProjectArgs {
if (!args || typeof args !== 'object') {
throw new Error('invalid_orca_profile_project_transfer')
}
const candidate = args as TransferOrcaProfileProjectArgs
const sourceProfileId = candidate.sourceProfileId?.trim()
const targetProfileId = candidate.targetProfileId?.trim()
const repoId = candidate.repoId?.trim()
const mode = candidate.mode
if (!sourceProfileId || !targetProfileId || !repoId || (mode !== 'move' && mode !== 'copy')) {
throw new Error('invalid_orca_profile_project_transfer')
}
return {
sourceProfileId,
targetProfileId,
repoId,
mode
}
}
function findProjectsByPathArgsFromUnknown(args: unknown): FindOrcaProfileProjectsByPathArgs {
if (!args || typeof args !== 'object') {
throw new Error('invalid_orca_profile_project_path')
}
const candidate = args as FindOrcaProfileProjectsByPathArgs
const path = typeof candidate.path === 'string' ? candidate.path.trim() : ''
if (!path) {
throw new Error('invalid_orca_profile_project_path')
}
let executionHostId: FindOrcaProfileProjectsByPathArgs['executionHostId'] = null
if (candidate.executionHostId !== null && candidate.executionHostId !== undefined) {
if (typeof candidate.executionHostId !== 'string') {
throw new Error('invalid_orca_profile_project_path')
}
executionHostId = normalizeExecutionHostId(candidate.executionHostId)
if (!executionHostId) {
throw new Error('invalid_orca_profile_project_path')
}
}
return {
path,
connectionId:
typeof candidate.connectionId === 'string' ? candidate.connectionId.trim() || null : null,
executionHostId,
excludeProfileId:
typeof candidate.excludeProfileId === 'string'
? candidate.excludeProfileId.trim() || null
: null
}
}
function orgIdFromUnknown(args: unknown): string {
if (!args || typeof args !== 'object') {
throw new Error('invalid_orca_profile_org_selection')
}
const orgId = (args as SelectOrcaProfileOrgArgs).orgId?.trim()
if (!orgId) {
throw new Error('invalid_orca_profile_org_selection')
}
return orgId
}
function createCloudLinkedProfileArgsFromUnknown(args: unknown): CreateCloudLinkedOrcaProfileArgs {
if (!args || typeof args !== 'object') {
return {}
}
const candidate = args as CreateCloudLinkedOrcaProfileArgs
const orgId = typeof candidate.orgId === 'string' ? candidate.orgId.trim() : undefined
const name = typeof candidate.name === 'string' ? candidate.name.trim() : undefined
return {
...(orgId ? { orgId } : {}),
...(name ? { name } : {})
}
}
async function runBeforeProfileRelaunch(
onBeforeRelaunch?: () => void | Promise<void>
): Promise<void> {
try {
await onBeforeRelaunch?.()
} catch (error) {
console.warn(
'[orca-profiles] Pre-relaunch cleanup failed; continuing profile switch:',
error instanceof Error ? error.name : typeof error
)
}
}
function scheduleProfileRelaunch(): void {
setTimeout(() => {
app.relaunch()
// Why: app.quit() (not app.exit) so before-quit/will-quit still run —
// renderer scrollback capture, PTY kill, stats flush, and daemon final
// checkpoints must not be skipped on a profile switch.
app.quit()
}, 150)
}
export function registerOrcaProfileHandlers(
store: Store,
options: RegisterOrcaProfileHandlersOptions = {}
): void {
ipcMain.handle(
'orcaProfiles:list',
(): OrcaProfileListResult => ({
...getOrcaProfileListState(),
multiProfileUi: isMultiProfileUiEnabled()
})
)
ipcMain.handle(
'orcaProfiles:authStatus',
(): OrcaProfileAuthStatus => getCurrentOrcaProfileAuthStatus(getProfileUserDataPath())
)
ipcMain.handle(
'orcaProfiles:createLocal',
(_event, args?: CreateLocalOrcaProfileArgs): CreateLocalOrcaProfileResult => {
const result = createLocalOrcaProfile(args)
seedNewOrcaProfileTelemetryConsent(result.profile.id, store.getSettings().telemetry)
return result
}
)
ipcMain.handle(
'orcaProfiles:switch',
async (_event, args: SwitchOrcaProfileArgs): Promise<SwitchOrcaProfileResult> => {
const profileId = profileIdFromArgs(args)
const current = getOrcaProfileListState()
if (profileId === current.activeProfileId) {
return { status: 'already-active' }
}
// Why: the current profile must be persisted before the global index
// points startup at the target profile.
await runBeforeProfileRelaunch(options.onBeforeRelaunch)
store.flush()
setActiveOrcaProfile(profileId)
scheduleProfileRelaunch()
return { status: 'relaunching' }
}
)
ipcMain.handle(
'orcaProfiles:transferProject',
async (
_event,
rawArgs: TransferOrcaProfileProjectArgs
): Promise<TransferOrcaProfileProjectResult> => {
const args = transferProjectArgsFromUnknown(rawArgs)
const current = getOrcaProfileListState()
if (args.targetProfileId === current.activeProfileId) {
throw new Error('active_target_orca_profile_transfer_requires_relaunch')
}
if (args.mode === 'move' && args.sourceProfileId === current.activeProfileId) {
// Why: transfer before any relaunch side effect so a duplicate-target
// or validation failure cannot strand the app in a quitting state.
// flush→transfer→freeze runs synchronously with no interleaving, and
// the freeze keeps late sync saves from resurrecting the moved
// project from stale memory before the relaunch.
store.flush()
const result = transferOrcaProfileProject(args, getProfileUserDataPath())
if (result.status === 'transferred') {
store.freezeWrites()
await runBeforeProfileRelaunch(options.onBeforeRelaunch)
setActiveOrcaProfile(args.targetProfileId)
scheduleProfileRelaunch()
return { ...result, willRelaunch: true }
}
return result
}
store.flush()
return transferOrcaProfileProject(args, getProfileUserDataPath())
}
)
ipcMain.handle(
'orcaProfiles:findProjectProfiles',
(_event, rawArgs: FindOrcaProfileProjectsByPathArgs): FindOrcaProfileProjectsByPathResult =>
findOrcaProfileProjectsByPath(
findProjectsByPathArgsFromUnknown(rawArgs),
getProfileUserDataPath()
)
)
ipcMain.handle(
'orcaProfiles:connectCurrent',
async (): Promise<ConnectCurrentOrcaProfileResult> =>
connectCurrentOrcaProfile(getProfileUserDataPath())
)
ipcMain.handle(
'orcaProfiles:createCloudLinked',
async (
_event,
rawArgs?: CreateCloudLinkedOrcaProfileArgs
): Promise<CreateCloudLinkedOrcaProfileResult> => {
const result = await createCloudLinkedOrcaProfile(
getProfileUserDataPath(),
createCloudLinkedProfileArgsFromUnknown(rawArgs)
)
if (result.status === 'created') {
seedNewOrcaProfileTelemetryConsent(result.profile.id, store.getSettings().telemetry)
}
return result
}
)
ipcMain.handle(
'orcaProfiles:refreshAuth',
async (): Promise<RefreshCurrentOrcaProfileAuthResult> =>
refreshCurrentOrcaProfileAuth(getProfileUserDataPath())
)
ipcMain.handle(
'orcaProfiles:signOutCurrent',
async (): Promise<SignOutCurrentOrcaProfileResult> =>
signOutCurrentOrcaProfile(getProfileUserDataPath())
)
ipcMain.handle(
'orcaProfiles:selectOrg',
async (_event, rawArgs: SelectOrcaProfileOrgArgs): Promise<SelectOrcaProfileOrgResult> =>
selectCurrentOrcaProfileOrg(getProfileUserDataPath(), orgIdFromUnknown(rawArgs))
)
registerOrcaProfileOrgMemberHandlers()
}
@@ -31,6 +31,7 @@ const {
registerRuntimeEnvironmentHandlersMock,
registerEphemeralVmHandlersMock,
registerAiVaultHandlersMock,
registerOrcaProfileHandlersMock,
registerCodexAccountHandlersMock,
registerAgentHookHandlersMock,
registerAgentTrustHandlersMock,
@@ -90,6 +91,7 @@ const {
registerRuntimeEnvironmentHandlersMock: vi.fn(),
registerEphemeralVmHandlersMock: vi.fn(),
registerAiVaultHandlersMock: vi.fn(),
registerOrcaProfileHandlersMock: vi.fn(),
registerCodexAccountHandlersMock: vi.fn(),
registerAgentHookHandlersMock: vi.fn(),
registerAgentTrustHandlersMock: vi.fn(),
@@ -283,6 +285,10 @@ vi.mock('./ai-vault', () => ({
registerAiVaultHandlers: registerAiVaultHandlersMock
}))
vi.mock('./orca-profiles', () => ({
registerOrcaProfileHandlers: registerOrcaProfileHandlersMock
}))
vi.mock('./codex-accounts', () => ({
registerCodexAccountHandlers: registerCodexAccountHandlersMock
}))
@@ -378,6 +384,7 @@ describe('registerCoreHandlers', () => {
registerRuntimeEnvironmentHandlersMock.mockReset()
registerEphemeralVmHandlersMock.mockReset()
registerAiVaultHandlersMock.mockReset()
registerOrcaProfileHandlersMock.mockReset()
registerCodexAccountHandlersMock.mockReset()
registerAgentHookHandlersMock.mockReset()
registerAgentTrustHandlersMock.mockReset()
@@ -476,6 +483,7 @@ describe('registerCoreHandlers', () => {
expect(registerWorkspacePortHandlersMock).toHaveBeenCalledWith(store)
expect(registerLocalhostWorktreeLabelHandlersMock).toHaveBeenCalledWith(store)
expect(registerTelemetryHandlersMock).toHaveBeenCalledWith(store)
expect(registerOrcaProfileHandlersMock).toHaveBeenCalledWith(store, { onBeforeRelaunch })
expect(registerSessionHandlersMock).toHaveBeenCalledWith(store)
expect(registerUIHandlersMock).toHaveBeenCalledWith(store)
expect(registerEmulatorFrameStreamHandlersMock).toHaveBeenCalled()
+4
View File
@@ -50,6 +50,7 @@ import { registerUIHandlers, setTrustedUIRendererWebContentsId } from './ui'
import { registerEmulatorFrameStreamHandlers } from './emulator-frame-stream'
import { registerEmulatorVideoStreamHandlers } from './emulator-video-stream'
import { registerSpeechHandlers } from './speech'
import { registerOrcaProfileHandlers } from './orca-profiles'
import { registerCodexAccountHandlers } from './codex-accounts'
import { registerAgentHookHandlers } from './agent-hooks'
import { registerAgentTrustHandlers } from './agent-trust'
@@ -156,6 +157,9 @@ export function registerCoreHandlers(
registerKeybindingHandlers(keybindings)
}
registerTelemetryHandlers(store)
registerOrcaProfileHandlers(store, {
onBeforeRelaunch: lifecycleOptions.onBeforeRelaunch
})
registerBrowserHandlers()
registerShellHandlers()
registerPetHandlers()
@@ -0,0 +1,122 @@
import { describe, expect, it, vi } from 'vitest'
import {
allowsPlaintextOrcaCloudSession,
getOrcaCloudAuthConfig,
isOrcaCloudDevAuthEnabled
} from './profile-cloud-auth-config'
vi.mock('electron', () => ({
app: {
isPackaged: false
}
}))
describe('Orca cloud auth config', () => {
it('reports unconfigured without both API URL and client ID', () => {
expect(getOrcaCloudAuthConfig({})).toEqual({
configured: false,
setupMessage: 'Orca Cloud sign-in is not configured for this build.'
})
})
it('builds default desktop auth endpoints from the API URL', () => {
const state = getOrcaCloudAuthConfig({
ORCA_CLOUD_API_URL: 'https://orca-cloud.example/',
ORCA_CLOUD_CLIENT_ID: 'desktop-client'
})
expect(state).toEqual({
configured: true,
config: {
apiBaseUrl: 'https://orca-cloud.example',
authorizeEndpoint: 'https://orca-cloud.example/v1/desktop/auth/authorize',
sessionEndpoint: 'https://orca-cloud.example/v1/desktop/auth/session',
refreshEndpoint: 'https://orca-cloud.example/v1/desktop/auth/refresh',
capabilitiesEndpoint: 'https://orca-cloud.example/v1/desktop/auth/capabilities',
profileEndpoint: 'https://orca-cloud.example/v1/desktop/auth/profile',
orgEndpoint: 'https://orca-cloud.example/v1/desktop/auth/org',
logoutEndpoint: 'https://orca-cloud.example/v1/desktop/auth/logout',
clientId: 'desktop-client',
scope: 'openid profile email offline_access'
}
})
})
it('allows loopback HTTP endpoints for local desktop auth development', () => {
const state = getOrcaCloudAuthConfig({
ORCA_CLOUD_API_URL: 'http://localhost:4100',
ORCA_CLOUD_CLIENT_ID: 'desktop-client'
})
expect(state.configured).toBe(true)
})
it('rejects loopback HTTP endpoints in packaged builds', () => {
expect(
getOrcaCloudAuthConfig(
{
ORCA_CLOUD_API_URL: 'http://localhost:4100',
ORCA_CLOUD_CLIENT_ID: 'desktop-client'
},
true
)
).toMatchObject({ configured: false })
const httpsState = getOrcaCloudAuthConfig(
{
ORCA_CLOUD_API_URL: 'https://orca-cloud.example',
ORCA_CLOUD_CLIENT_ID: 'desktop-client'
},
true
)
expect(httpsState.configured).toBe(true)
})
it('rejects non-HTTPS non-loopback API URLs', () => {
expect(
getOrcaCloudAuthConfig({
ORCA_CLOUD_API_URL: 'http://orca-cloud.example',
ORCA_CLOUD_CLIENT_ID: 'desktop-client'
})
).toMatchObject({ configured: false })
})
it('allows dev plaintext sessions only outside production', () => {
expect(
allowsPlaintextOrcaCloudSession({
ORCA_CLOUD_ALLOW_PLAINTEXT_SESSION: '1',
NODE_ENV: 'development'
})
).toBe(true)
expect(
allowsPlaintextOrcaCloudSession({
ORCA_CLOUD_ALLOW_PLAINTEXT_SESSION: '1',
NODE_ENV: 'production'
})
).toBe(false)
})
it('ignores dev flags in packaged builds even without NODE_ENV', () => {
// Why: packaged main bundles never define NODE_ENV, so packaged-ness must
// gate the escape hatches on its own.
expect(allowsPlaintextOrcaCloudSession({ ORCA_CLOUD_ALLOW_PLAINTEXT_SESSION: '1' }, true)).toBe(
false
)
expect(isOrcaCloudDevAuthEnabled({ ORCA_CLOUD_DEV_AUTH: '1' }, true)).toBe(false)
})
it('allows local dev auth only outside production', () => {
expect(
isOrcaCloudDevAuthEnabled({
ORCA_CLOUD_DEV_AUTH: '1',
NODE_ENV: 'development'
})
).toBe(true)
expect(
isOrcaCloudDevAuthEnabled({
ORCA_CLOUD_DEV_AUTH: '1',
NODE_ENV: 'production'
})
).toBe(false)
})
})
@@ -0,0 +1,115 @@
import { app } from 'electron'
export type OrcaCloudAuthConfig = {
apiBaseUrl: string
authorizeEndpoint: string
sessionEndpoint: string
refreshEndpoint: string
capabilitiesEndpoint: string
profileEndpoint: string
orgEndpoint: string
logoutEndpoint: string
clientId: string
scope: string
}
const DEFAULT_SCOPE = 'openid profile email offline_access'
// Why: packaged main bundles never define NODE_ENV, so packaged-ness is the
// only reliable production signal for gating dev-only auth escape hatches.
function isPackagedOrcaBuild(): boolean {
try {
return app?.isPackaged === true
} catch {
return false
}
}
function cleanUrl(value: string | undefined, allowLoopbackHttp: boolean): string | null {
const trimmed = value?.trim()
if (!trimmed) {
return null
}
try {
const parsed = new URL(trimmed)
const loopbackHost =
parsed.hostname === '127.0.0.1' ||
parsed.hostname === 'localhost' ||
parsed.hostname === '[::1]'
if (parsed.protocol !== 'https:' && !(loopbackHost && allowLoopbackHttp)) {
return null
}
return parsed.toString().replace(/\/$/, '')
} catch {
return null
}
}
function endpoint(baseUrl: string, path: string): string {
return new URL(path, `${baseUrl}/`).toString()
}
export function getOrcaCloudAuthConfig(
env: NodeJS.ProcessEnv = process.env,
packaged: boolean = isPackagedOrcaBuild()
): { configured: true; config: OrcaCloudAuthConfig } | { configured: false; setupMessage: string } {
// Why: loopback HTTP endpoints are a local-development convenience only;
// packaged builds must not accept plain-HTTP token endpoints via env vars.
const allowLoopbackHttp = !packaged
const cleanEndpointUrl = (value: string | undefined): string | null =>
cleanUrl(value, allowLoopbackHttp)
const apiBaseUrl = cleanEndpointUrl(env.ORCA_CLOUD_API_URL)
const clientId = env.ORCA_CLOUD_CLIENT_ID?.trim()
if (!apiBaseUrl || !clientId) {
return {
configured: false,
setupMessage: 'Orca Cloud sign-in is not configured for this build.'
}
}
const authBaseUrl = cleanEndpointUrl(env.ORCA_CLOUD_AUTH_URL) ?? apiBaseUrl
return {
configured: true,
config: {
apiBaseUrl,
authorizeEndpoint:
cleanEndpointUrl(env.ORCA_CLOUD_AUTHORIZE_URL) ??
endpoint(authBaseUrl, '/v1/desktop/auth/authorize'),
sessionEndpoint:
cleanEndpointUrl(env.ORCA_CLOUD_SESSION_URL) ??
endpoint(apiBaseUrl, '/v1/desktop/auth/session'),
refreshEndpoint:
cleanEndpointUrl(env.ORCA_CLOUD_REFRESH_URL) ??
endpoint(apiBaseUrl, '/v1/desktop/auth/refresh'),
capabilitiesEndpoint:
cleanEndpointUrl(env.ORCA_CLOUD_CAPABILITIES_URL) ??
endpoint(apiBaseUrl, '/v1/desktop/auth/capabilities'),
profileEndpoint:
cleanEndpointUrl(env.ORCA_CLOUD_PROFILE_URL) ??
endpoint(apiBaseUrl, '/v1/desktop/auth/profile'),
orgEndpoint:
cleanEndpointUrl(env.ORCA_CLOUD_ORG_URL) ?? endpoint(apiBaseUrl, '/v1/desktop/auth/org'),
logoutEndpoint:
cleanEndpointUrl(env.ORCA_CLOUD_LOGOUT_URL) ??
endpoint(apiBaseUrl, '/v1/desktop/auth/logout'),
clientId,
scope: env.ORCA_CLOUD_AUTH_SCOPE?.trim() || DEFAULT_SCOPE
}
}
}
export function allowsPlaintextOrcaCloudSession(
env: NodeJS.ProcessEnv = process.env,
packaged: boolean = isPackagedOrcaBuild()
): boolean {
return (
env.ORCA_CLOUD_ALLOW_PLAINTEXT_SESSION === '1' && env.NODE_ENV !== 'production' && !packaged
)
}
export function isOrcaCloudDevAuthEnabled(
env: NodeJS.ProcessEnv = process.env,
packaged: boolean = isPackagedOrcaBuild()
): boolean {
return env.ORCA_CLOUD_DEV_AUTH === '1' && env.NODE_ENV !== 'production' && !packaged
}
@@ -0,0 +1,56 @@
import type { OrcaProfileAuthStatus } from '../../shared/orca-profiles'
import type { ActiveOrcaProfileState } from './profile-index-store'
import { getOrcaCloudAuthConfig, isOrcaCloudDevAuthEnabled } from './profile-cloud-auth-config'
import { readOrcaCloudSession } from './profile-cloud-session-store'
export function getOrcaProfileAuthStatusFromProfile(
active: ActiveOrcaProfileState,
userDataPath: string
): OrcaProfileAuthStatus {
const configState = getOrcaCloudAuthConfig()
const devAuthEnabled = isOrcaCloudDevAuthEnabled()
const configured = configState.configured || devAuthEnabled
const cloud = active.profile.cloud
if (!cloud) {
return {
activeProfileId: active.profile.id,
configured,
state: configured ? 'local' : 'unconfigured',
persistence: 'none',
setupMessage: configured ? undefined : configState.setupMessage
}
}
const session = readOrcaCloudSession(active.profile.id, userDataPath)
if (!configured) {
return {
activeProfileId: active.profile.id,
configured: false,
state: 'unconfigured',
persistence: session.status === 'found' ? session.persistence : 'none',
cloud,
credentialError: session.status === 'decrypt-failed' ? session.error : undefined,
setupMessage: configState.setupMessage
}
}
if (session.status === 'found') {
return {
activeProfileId: active.profile.id,
configured,
state: 'connected',
persistence: session.persistence,
cloud,
organizations: session.session.organizations,
capabilities: session.session.capabilities
}
}
return {
activeProfileId: active.profile.id,
configured,
state: 'reconnect-required',
persistence: 'none',
cloud,
credentialError: session.status === 'decrypt-failed' ? session.error : undefined
}
}
@@ -0,0 +1,249 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config'
import type { OrcaCloudSession } from './profile-cloud-session-store'
import {
createOrcaCloudProfile,
exchangeOrcaCloudAuthCode,
refreshOrcaCloudCapabilities,
refreshOrcaCloudSession,
selectOrcaCloudOrg
} from './profile-cloud-client'
const fetchMock = vi.fn()
const config: OrcaCloudAuthConfig = {
apiBaseUrl: 'https://orca-cloud.example',
authorizeEndpoint: 'https://orca-cloud.example/v1/desktop/auth/authorize',
sessionEndpoint: 'https://orca-cloud.example/v1/desktop/auth/session',
refreshEndpoint: 'https://orca-cloud.example/v1/desktop/auth/refresh',
capabilitiesEndpoint: 'https://orca-cloud.example/v1/desktop/auth/capabilities',
profileEndpoint: 'https://orca-cloud.example/v1/desktop/auth/profile',
orgEndpoint: 'https://orca-cloud.example/v1/desktop/auth/org',
logoutEndpoint: 'https://orca-cloud.example/v1/desktop/auth/logout',
clientId: 'desktop-client',
scope: 'openid profile email offline_access'
}
const session: OrcaCloudSession = {
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresAt: 999,
capabilities: { flags: { share: true }, refreshedAt: 1 }
}
function mockFetchJson(value: unknown): void {
fetchMock.mockResolvedValue({
ok: true,
json: async () => value
})
}
describe('Orca cloud client', () => {
beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
})
it('normalizes session exchange organizations', async () => {
mockFetchJson({
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresAt: 999,
cloud: {
cloudProfileId: 'cloud-profile-1',
userId: 'user-1',
email: 'nina@example.com'
},
organizations: [
{ orgId: 'org-1', name: 'Acme', role: 'Admin' },
{ orgId: '', name: 'Ignored' }
],
capabilities: {
flags: { share: true },
refreshedAt: 123
}
})
await expect(
exchangeOrcaCloudAuthCode(config, {
code: 'code',
codeVerifier: 'verifier',
nonce: 'nonce',
redirectUri: 'http://127.0.0.1:4100/auth/callback',
state: 'state',
localProfileId: 'local-default'
})
).resolves.toMatchObject({
organizations: [{ orgId: 'org-1', name: 'Acme', role: 'Admin' }]
})
expect(fetchMock).toHaveBeenCalledWith(
config.sessionEndpoint,
expect.objectContaining({
body: JSON.stringify({
code: 'code',
codeVerifier: 'verifier',
nonce: 'nonce',
redirectUri: 'http://127.0.0.1:4100/auth/callback',
state: 'state',
localProfileId: 'local-default'
})
})
)
})
it('normalizes organization selection response metadata', async () => {
mockFetchJson({
cloud: {
cloudProfileId: 'cloud-profile-1',
userId: 'user-1',
email: 'nina@example.com',
activeOrgId: 'org-2',
activeOrgName: 'Personal'
},
organizations: [
{ orgId: 'org-1', name: 'Acme' },
{ orgId: 'org-2', name: 'Personal' }
],
capabilities: {
flags: { share: false, sso: true },
refreshedAt: 456
}
})
await expect(selectOrcaCloudOrg(config, session, 'org-2')).resolves.toEqual({
cloud: expect.objectContaining({ activeOrgId: 'org-2', activeOrgName: 'Personal' }),
organizations: [
{ orgId: 'org-1', name: 'Acme', role: undefined },
{ orgId: 'org-2', name: 'Personal', role: undefined }
],
capabilities: { flags: { share: false, sso: true }, refreshedAt: 456 }
})
expect(fetchMock).toHaveBeenCalledWith(
config.orgEndpoint,
expect.objectContaining({
headers: expect.objectContaining({ authorization: 'Bearer access-token' })
})
)
})
it('creates cloud profiles with a profile-scoped session response', async () => {
mockFetchJson({
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
expiresAt: 1000,
cloud: {
cloudProfileId: 'cloud-profile-2',
userId: 'user-1',
email: 'nina@example.com',
activeOrgId: 'org-1',
activeOrgName: 'Acme'
},
organizations: [{ orgId: 'org-1', name: 'Acme' }],
capabilities: {
flags: { share: true },
refreshedAt: 789
}
})
await expect(
createOrcaCloudProfile(config, session, { orgId: 'org-1', name: 'Acme' })
).resolves.toMatchObject({
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
cloud: expect.objectContaining({ cloudProfileId: 'cloud-profile-2' }),
organizations: [{ orgId: 'org-1', name: 'Acme', role: undefined }]
})
expect(fetchMock).toHaveBeenCalledWith(
config.profileEndpoint,
expect.objectContaining({
body: JSON.stringify({ orgId: 'org-1', name: 'Acme' })
})
)
})
it('refreshes session material without exposing refresh tokens in URLs', async () => {
mockFetchJson({
accessToken: 'rotated-access-token',
refreshToken: 'rotated-refresh-token',
expiresAt: 2000,
cloud: {
cloudProfileId: 'cloud-profile-1',
userId: 'user-1',
email: 'nina@example.com'
},
capabilities: {
flags: { share: true },
refreshedAt: 999
}
})
await expect(refreshOrcaCloudSession(config, session)).resolves.toMatchObject({
accessToken: 'rotated-access-token',
refreshToken: 'rotated-refresh-token'
})
expect(fetchMock).toHaveBeenCalledWith(
config.refreshEndpoint,
expect.objectContaining({
body: JSON.stringify({ refreshToken: 'refresh-token' })
})
)
})
it('refreshes capability flags and optional org metadata with the current access token', async () => {
mockFetchJson({
cloud: {
cloudProfileId: 'cloud-profile-1',
userId: 'user-1',
email: 'nina@example.com'
},
organizations: [],
capabilities: {
flags: { share: false, team: true },
refreshedAt: 1001
}
})
await expect(refreshOrcaCloudCapabilities(config, session)).resolves.toEqual({
cloud: expect.objectContaining({ cloudProfileId: 'cloud-profile-1' }),
organizations: [],
capabilities: {
flags: { share: false, team: true },
refreshedAt: 1001
}
})
expect(fetchMock).toHaveBeenCalledWith(
config.capabilitiesEndpoint,
expect.objectContaining({
headers: expect.objectContaining({ authorization: 'Bearer access-token' })
})
)
})
it('trims cloud metadata and drops blank active org fields', async () => {
mockFetchJson({
cloud: {
cloudProfileId: ' cloud-profile-1 ',
userId: ' user-1 ',
email: ' nina@example.com ',
displayName: ' Nina ',
activeOrgId: ' ',
activeOrgName: ''
},
capabilities: {
flags: {},
refreshedAt: 1002
}
})
await expect(refreshOrcaCloudCapabilities(config, session)).resolves.toMatchObject({
cloud: {
cloudProfileId: 'cloud-profile-1',
userId: 'user-1',
email: 'nina@example.com',
displayName: 'Nina',
activeOrgId: undefined,
activeOrgName: undefined
}
})
})
})
@@ -0,0 +1,263 @@
import type {
OrcaCloudCapabilities,
OrcaCloudOrgSummary,
OrcaProfileCloudSummary
} from '../../shared/orca-profiles'
import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config'
import type { OrcaCloudSession } from './profile-cloud-session-store'
import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange'
type ExchangeCodeArgs = {
code: string
codeVerifier: string
nonce: string
redirectUri: string
state: string
localProfileId: string
}
type CreateCloudProfileArgs = {
orgId?: string
name?: string
}
type SelectOrgResponse = {
cloud: OrcaProfileCloudSummary
organizations?: OrcaCloudOrgSummary[]
capabilities: OrcaCloudCapabilities
}
type CapabilityRefreshResponse = {
cloud?: OrcaProfileCloudSummary
organizations?: OrcaCloudOrgSummary[]
capabilities: OrcaCloudCapabilities
}
export class OrcaCloudRequestError extends Error {
// Why: `errorCode` carries the server's JSON `{error}` discriminator (e.g.
// 'already_member', 'cannot_remove_self') so callers can distinguish the
// precise 4xx cause without re-reading the response body.
constructor(
public readonly statusCode: number,
public readonly errorCode?: string
) {
super(`orca_cloud_request_failed_${statusCode}`)
this.name = 'OrcaCloudRequestError'
}
}
function assertString(value: unknown, field: string): string {
if (typeof value !== 'string') {
throw new Error(`invalid_orca_cloud_${field}`)
}
const trimmed = value.trim()
if (!trimmed) {
throw new Error(`invalid_orca_cloud_${field}`)
}
return trimmed
}
function optionalString(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined
}
const trimmed = value.trim()
return trimmed || undefined
}
function assertNumber(value: unknown, field: string): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new Error(`invalid_orca_cloud_${field}`)
}
return value
}
function normalizeCapabilities(value: unknown): OrcaCloudCapabilities {
if (!value || typeof value !== 'object') {
return { flags: {}, refreshedAt: Date.now() }
}
const record = value as Record<string, unknown>
const rawFlags = record.flags
const flags: Record<string, boolean> = {}
if (rawFlags && typeof rawFlags === 'object' && !Array.isArray(rawFlags)) {
for (const [key, flag] of Object.entries(rawFlags)) {
if (typeof flag === 'boolean') {
flags[key] = flag
}
}
}
return {
flags,
refreshedAt:
typeof record.refreshedAt === 'number' && Number.isFinite(record.refreshedAt)
? record.refreshedAt
: Date.now()
}
}
function normalizeOrganizations(value: unknown): OrcaCloudOrgSummary[] | undefined {
if (!Array.isArray(value)) {
return undefined
}
const organizations: OrcaCloudOrgSummary[] = []
for (const item of value) {
if (!item || typeof item !== 'object') {
continue
}
const record = item as Record<string, unknown>
if (typeof record.orgId !== 'string' || typeof record.name !== 'string') {
continue
}
const orgId = record.orgId.trim()
const name = record.name.trim()
if (!orgId || !name) {
continue
}
organizations.push({
orgId,
name,
role: typeof record.role === 'string' && record.role.trim() ? record.role.trim() : undefined
})
}
return organizations
}
function normalizeCloudSummary(value: unknown): OrcaProfileCloudSummary {
if (!value || typeof value !== 'object') {
throw new Error('invalid_orca_cloud_profile')
}
const record = value as Record<string, unknown>
return {
cloudProfileId: assertString(record.cloudProfileId, 'profile_id'),
userId: assertString(record.userId, 'user_id'),
email: assertString(record.email, 'email'),
displayName: optionalString(record.displayName),
activeOrgId: optionalString(record.activeOrgId),
activeOrgName: optionalString(record.activeOrgName),
linkedAt:
typeof record.linkedAt === 'number' && Number.isFinite(record.linkedAt)
? record.linkedAt
: Date.now()
}
}
function normalizeSessionResponse(value: unknown): OrcaCloudSessionExchangeResponse {
if (!value || typeof value !== 'object') {
throw new Error('invalid_orca_cloud_session')
}
const record = value as Record<string, unknown>
return {
accessToken: assertString(record.accessToken, 'access_token'),
refreshToken: assertString(record.refreshToken, 'refresh_token'),
expiresAt: assertNumber(record.expiresAt, 'expires_at'),
cloud: normalizeCloudSummary(record.cloud),
organizations: normalizeOrganizations(record.organizations),
capabilities: normalizeCapabilities(record.capabilities)
}
}
const CLOUD_REQUEST_TIMEOUT_MS = 30_000
async function postJson<T>(url: string, body: unknown, accessToken?: string): Promise<T> {
const response = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
...(accessToken ? { authorization: `Bearer ${accessToken}` } : {})
},
body: JSON.stringify(body),
// Why: these are fixed first-party token endpoints; following a redirect
// would re-send refresh tokens/code verifiers to another origin, and a
// stalled server must not hang the renderer's awaited IPC call forever.
redirect: 'error',
signal: AbortSignal.timeout(CLOUD_REQUEST_TIMEOUT_MS)
})
if (!response.ok) {
throw new OrcaCloudRequestError(response.status)
}
return (await response.json()) as T
}
export async function exchangeOrcaCloudAuthCode(
config: OrcaCloudAuthConfig,
args: ExchangeCodeArgs
): Promise<OrcaCloudSessionExchangeResponse> {
return normalizeSessionResponse(
await postJson(config.sessionEndpoint, {
code: args.code,
codeVerifier: args.codeVerifier,
nonce: args.nonce,
redirectUri: args.redirectUri,
state: args.state,
localProfileId: args.localProfileId
})
)
}
export async function refreshOrcaCloudCapabilities(
config: OrcaCloudAuthConfig,
session: OrcaCloudSession
): Promise<CapabilityRefreshResponse> {
const response = await postJson<{
cloud?: unknown
organizations?: unknown
capabilities: unknown
}>(config.capabilitiesEndpoint, {}, session.accessToken)
return {
cloud: response.cloud === undefined ? undefined : normalizeCloudSummary(response.cloud),
organizations: normalizeOrganizations(response.organizations),
capabilities: normalizeCapabilities(response.capabilities)
}
}
export async function refreshOrcaCloudSession(
config: OrcaCloudAuthConfig,
session: OrcaCloudSession
): Promise<OrcaCloudSessionExchangeResponse> {
return normalizeSessionResponse(
await postJson(config.refreshEndpoint, {
refreshToken: session.refreshToken
})
)
}
export async function createOrcaCloudProfile(
config: OrcaCloudAuthConfig,
session: OrcaCloudSession,
args: CreateCloudProfileArgs
): Promise<OrcaCloudSessionExchangeResponse> {
return normalizeSessionResponse(
await postJson(
config.profileEndpoint,
{
orgId: args.orgId,
name: args.name
},
session.accessToken
)
)
}
export async function selectOrcaCloudOrg(
config: OrcaCloudAuthConfig,
session: OrcaCloudSession,
orgId: string
): Promise<SelectOrgResponse> {
const response = await postJson<{
cloud: unknown
organizations?: unknown
capabilities: unknown
}>(config.orgEndpoint, { orgId }, session.accessToken)
return {
cloud: normalizeCloudSummary(response.cloud),
organizations: normalizeOrganizations(response.organizations),
capabilities: normalizeCapabilities(response.capabilities)
}
}
export async function revokeOrcaCloudSession(
config: OrcaCloudAuthConfig,
session: OrcaCloudSession
): Promise<void> {
await postJson(config.logoutEndpoint, { refreshToken: session.refreshToken }, session.accessToken)
}
@@ -0,0 +1,70 @@
import { randomUUID } from 'node:crypto'
import type {
OrcaCloudCapabilities,
OrcaCloudOrgSummary
} from '../../shared/orca-profiles'
import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange'
const DEV_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
function cleanEnvString(value: string | undefined, fallback: string): string {
const trimmed = value?.trim()
return trimmed || fallback
}
function defaultDevOrganizations(): OrcaCloudOrgSummary[] {
return [
{ orgId: 'dev-personal', name: 'Personal', role: 'Owner' },
{ orgId: 'dev-acme', name: 'Acme Dev', role: 'Admin' }
]
}
function devCapabilities(): OrcaCloudCapabilities {
return {
flags: {
share: true,
team: true,
'share.create': true,
'share.manage': true,
'relay.use': true,
'team.member': true,
'enterprise.sso': true
},
refreshedAt: Date.now()
}
}
function devToken(prefix: string): string {
return `${prefix}-${randomUUID()}`
}
export function createDevOrcaCloudSession(
args: {
localProfileId?: string
cloudProfileId?: string
orgId?: string
} = {}
): OrcaCloudSessionExchangeResponse {
const organizations = defaultDevOrganizations()
const selectedOrg = organizations.find((organization) => organization.orgId === args.orgId)
const cloudProfileId =
args.cloudProfileId ??
(args.localProfileId ? `dev-cloud-${args.localProfileId}` : `dev-cloud-${randomUUID()}`)
return {
accessToken: devToken('dev-access'),
refreshToken: devToken('dev-refresh'),
expiresAt: Date.now() + DEV_SESSION_TTL_MS,
cloud: {
cloudProfileId,
userId: cleanEnvString(process.env.ORCA_CLOUD_DEV_USER_ID, 'dev-user'),
email: cleanEnvString(process.env.ORCA_CLOUD_DEV_EMAIL, 'dev@orca.local'),
displayName: cleanEnvString(process.env.ORCA_CLOUD_DEV_DISPLAY_NAME, 'Orca Dev'),
activeOrgId: selectedOrg?.orgId,
activeOrgName: selectedOrg?.name,
linkedAt: Date.now()
},
organizations,
capabilities: devCapabilities()
}
}
@@ -0,0 +1,130 @@
import type {
OrcaOrgMember,
OrcaOrgMembersRoster,
OrcaOrgPendingInvite,
OrcaProfileOrgMemberChangeRoleArgs,
OrcaProfileOrgMemberInviteArgs,
OrcaProfileOrgMemberMutationResult,
OrcaProfileOrgMemberRemoveArgs,
OrcaProfileOrgInviteRevokeArgs
} from '../../shared/orca-profiles'
// Why: dev-auth mode has no server, so the whole teammate UI is exercised
// against this in-memory per-org roster. It mirrors the shape the real client
// returns (self as owner, one signed-in teammate, one never-signed-in teammate,
// one pending invite) and the mutation endpoints' status semantics.
type DevOrgRoster = {
members: OrcaOrgMember[]
pendingInvites: OrcaOrgPendingInvite[]
}
const devRostersByOrg = new Map<string, DevOrgRoster>()
function cleanEnvString(value: string | undefined, fallback: string): string {
const trimmed = value?.trim()
return trimmed || fallback
}
function devSelf(): OrcaOrgMember {
return {
userId: cleanEnvString(process.env.ORCA_CLOUD_DEV_USER_ID, 'dev-user'),
email: cleanEnvString(process.env.ORCA_CLOUD_DEV_EMAIL, 'dev@orca.local'),
displayName: cleanEnvString(process.env.ORCA_CLOUD_DEV_DISPLAY_NAME, 'Orca Dev'),
role: 'owner'
}
}
function seedDevRoster(): DevOrgRoster {
return {
members: [
devSelf(),
{
userId: 'dev-teammate-1',
email: 'teammate@orca.local',
displayName: 'Dev Teammate',
role: 'admin'
},
// Why: userId null exercises the "hasn't signed in to Orca yet" disabled row.
{ userId: null, email: 'invited-member@orca.local', displayName: undefined, role: 'member' }
],
pendingInvites: [{ email: 'pending@orca.local', role: 'member', createdAt: Date.now() }]
}
}
function getDevRoster(orgId: string): DevOrgRoster {
const existing = devRostersByOrg.get(orgId)
if (existing) {
return existing
}
const seeded = seedDevRoster()
devRostersByOrg.set(orgId, seeded)
return seeded
}
export function listDevOrcaCloudOrgMembers(orgId: string): OrcaOrgMembersRoster {
const roster = getDevRoster(orgId)
return {
members: roster.members.map((member) => ({ ...member })),
pendingInvites: roster.pendingInvites.map((invite) => ({ ...invite })),
viewerRole: 'owner',
canManageMembers: true
}
}
export function inviteDevOrcaCloudOrgMember(
args: OrcaProfileOrgMemberInviteArgs
): OrcaProfileOrgMemberMutationResult {
const roster = getDevRoster(args.orgId)
const email = args.email.toLowerCase()
if (roster.members.some((member) => member.email.toLowerCase() === email)) {
return { status: 'conflict', reason: 'already_member' }
}
if (roster.pendingInvites.some((invite) => invite.email.toLowerCase() === email)) {
return { status: 'conflict', reason: 'already_invited' }
}
roster.pendingInvites.push({ email: args.email, role: args.role, createdAt: Date.now() })
return { status: 'ok' }
}
export function revokeDevOrcaCloudOrgInvite(
args: OrcaProfileOrgInviteRevokeArgs
): OrcaProfileOrgMemberMutationResult {
const roster = getDevRoster(args.orgId)
const email = args.email.toLowerCase()
const index = roster.pendingInvites.findIndex((invite) => invite.email.toLowerCase() === email)
if (index === -1) {
return { status: 'not-found' }
}
roster.pendingInvites.splice(index, 1)
return { status: 'ok' }
}
export function changeDevOrcaCloudOrgMemberRole(
args: OrcaProfileOrgMemberChangeRoleArgs
): OrcaProfileOrgMemberMutationResult {
const roster = getDevRoster(args.orgId)
if (args.userId === devSelf().userId) {
return { status: 'invalid', reason: 'cannot_change_own_role' }
}
const member = roster.members.find((candidate) => candidate.userId === args.userId)
if (!member) {
return { status: 'not-found' }
}
member.role = args.role
return { status: 'ok' }
}
export function removeDevOrcaCloudOrgMember(
args: OrcaProfileOrgMemberRemoveArgs
): OrcaProfileOrgMemberMutationResult {
const roster = getDevRoster(args.orgId)
if (args.userId === devSelf().userId) {
return { status: 'invalid', reason: 'cannot_remove_self' }
}
const index = roster.members.findIndex((candidate) => candidate.userId === args.userId)
if (index === -1) {
return { status: 'not-found' }
}
roster.members.splice(index, 1)
return { status: 'ok' }
}
@@ -0,0 +1,142 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const {
beginOrcaCloudPkceFlowMock,
exchangeOrcaCloudAuthCodeMock,
revokeOrcaCloudSessionMock,
safeStorageMock
} = vi.hoisted(() => ({
beginOrcaCloudPkceFlowMock: vi.fn(),
exchangeOrcaCloudAuthCodeMock: vi.fn(),
revokeOrcaCloudSessionMock: vi.fn(),
safeStorageMock: {
decryptString: vi.fn((value: Buffer) => value.toString('utf-8')),
encryptString: vi.fn((value: string) => Buffer.from(value, 'utf-8')),
isEncryptionAvailable: vi.fn(() => true)
}
}))
let userDataPath = ''
vi.mock('electron', () => ({
app: {
getPath: () => userDataPath
},
safeStorage: safeStorageMock
}))
vi.mock('./profile-cloud-pkce', () => ({
beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock
}))
vi.mock('./profile-cloud-client', () => ({
createOrcaCloudProfile: vi.fn(),
exchangeOrcaCloudAuthCode: exchangeOrcaCloudAuthCodeMock,
refreshOrcaCloudCapabilities: vi.fn(),
refreshOrcaCloudSession: vi.fn(),
revokeOrcaCloudSession: revokeOrcaCloudSessionMock,
selectOrcaCloudOrg: vi.fn()
}))
import {
connectCurrentOrcaProfile,
createCloudLinkedOrcaProfile,
getCurrentOrcaProfileAuthStatus,
selectCurrentOrcaProfileOrg,
signOutCurrentOrcaProfile
} from './profile-cloud-service'
describe('Orca cloud dev auth service', () => {
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'orca-cloud-dev-auth-'))
beginOrcaCloudPkceFlowMock.mockReset()
exchangeOrcaCloudAuthCodeMock.mockReset()
revokeOrcaCloudSessionMock.mockReset()
safeStorageMock.decryptString.mockReset()
safeStorageMock.encryptString.mockReset()
safeStorageMock.isEncryptionAvailable.mockReset()
safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf-8'))
safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(value, 'utf-8'))
safeStorageMock.isEncryptionAvailable.mockReturnValue(true)
vi.unstubAllEnvs()
vi.stubEnv('NODE_ENV', 'development')
vi.stubEnv('ORCA_CLOUD_DEV_AUTH', '1')
vi.stubEnv('ORCA_CLOUD_API_URL', '')
vi.stubEnv('ORCA_CLOUD_CLIENT_ID', '')
})
afterEach(() => {
rmSync(userDataPath, { recursive: true, force: true })
vi.unstubAllEnvs()
})
it('connects the active profile without PKCE or cloud endpoints', async () => {
expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({
configured: true,
state: 'local'
})
const result = await connectCurrentOrcaProfile(userDataPath)
expect(result.status).toBe('connected')
expect(beginOrcaCloudPkceFlowMock).not.toHaveBeenCalled()
expect(exchangeOrcaCloudAuthCodeMock).not.toHaveBeenCalled()
expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({
configured: true,
state: 'connected',
persistence: 'encrypted',
cloud: {
cloudProfileId: 'dev-cloud-local-default',
email: 'dev@orca.local'
},
capabilities: {
flags: expect.objectContaining({ 'share.create': true })
}
})
expect(getCurrentOrcaProfileAuthStatus(userDataPath).organizations).toHaveLength(2)
})
it('selects dev organizations and creates org-scoped cloud profiles locally', async () => {
await connectCurrentOrcaProfile(userDataPath)
const selected = await selectCurrentOrcaProfileOrg(userDataPath, 'dev-acme')
const created = await createCloudLinkedOrcaProfile(userDataPath, {
orgId: 'dev-acme',
name: 'Acme Dev'
})
expect(selected.status).toBe('selected')
expect(getCurrentOrcaProfileAuthStatus(userDataPath).cloud).toMatchObject({
activeOrgId: 'dev-acme',
activeOrgName: 'Acme Dev'
})
expect(created.status).toBe('created')
if (created.status === 'created') {
expect(created.profile).toMatchObject({
name: 'Acme Dev',
kind: 'cloud-linked',
cloud: expect.objectContaining({
activeOrgId: 'dev-acme',
activeOrgName: 'Acme Dev'
})
})
}
})
it('signs out locally without calling the cloud logout endpoint', async () => {
await connectCurrentOrcaProfile(userDataPath)
const result = await signOutCurrentOrcaProfile(userDataPath)
expect(result.status).toBe('signed-out')
expect(revokeOrcaCloudSessionMock).not.toHaveBeenCalled()
expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({
configured: true,
state: 'local',
persistence: 'none'
})
})
})
@@ -0,0 +1,92 @@
import type {
CreateCloudLinkedOrcaProfileArgs,
OrcaProfileListState
} from '../../shared/orca-profiles'
import type { ActiveOrcaProfileState } from './profile-index-store'
import { createCloudLinkedOrcaProfileRecord, linkOrcaProfileToCloud } from './profile-cloud-index'
import { readOrcaCloudSession, saveOrcaCloudSessionExchange } from './profile-cloud-session-store'
import { createDevOrcaCloudSession } from './profile-cloud-dev-auth'
type DevProfileListResult = OrcaProfileListState
type DevCreateProfileResult =
| {
status: 'created'
list: ReturnType<typeof createCloudLinkedOrcaProfileRecord>
}
| { status: 'reconnect-required' }
type DevMutationResult =
| {
status: 'updated'
list: DevProfileListResult
}
| { status: 'reconnect-required' }
export function connectDevOrcaCloudProfile(
active: ActiveOrcaProfileState,
userDataPath: string
): DevProfileListResult {
const session = createDevOrcaCloudSession({ localProfileId: active.profile.id })
saveOrcaCloudSessionExchange(active.profile.id, userDataPath, session)
return linkOrcaProfileToCloud(active.profile.id, session.cloud, userDataPath)
}
export function createDevCloudLinkedOrcaProfile(
active: ActiveOrcaProfileState,
userDataPath: string,
args: CreateCloudLinkedOrcaProfileArgs
): DevCreateProfileResult {
if (readOrcaCloudSession(active.profile.id, userDataPath).status !== 'found') {
return { status: 'reconnect-required' }
}
const session = createDevOrcaCloudSession({ orgId: args.orgId })
const list = createCloudLinkedOrcaProfileRecord(session.cloud, { name: args.name }, userDataPath)
saveOrcaCloudSessionExchange(list.profile.id, userDataPath, session)
return { status: 'created', list }
}
export function refreshDevOrcaCloudProfile(
active: ActiveOrcaProfileState,
userDataPath: string
): DevMutationResult {
if (
!active.profile.cloud ||
readOrcaCloudSession(active.profile.id, userDataPath).status !== 'found'
) {
return { status: 'reconnect-required' }
}
const session = createDevOrcaCloudSession({
localProfileId: active.profile.id,
cloudProfileId: active.profile.cloud.cloudProfileId,
orgId: active.profile.cloud.activeOrgId
})
saveOrcaCloudSessionExchange(active.profile.id, userDataPath, session)
return {
status: 'updated',
list: linkOrcaProfileToCloud(active.profile.id, session.cloud, userDataPath)
}
}
export function selectDevOrcaCloudOrg(
active: ActiveOrcaProfileState,
userDataPath: string,
orgId: string
): DevMutationResult {
if (
!active.profile.cloud ||
readOrcaCloudSession(active.profile.id, userDataPath).status !== 'found'
) {
return { status: 'reconnect-required' }
}
const session = createDevOrcaCloudSession({
localProfileId: active.profile.id,
cloudProfileId: active.profile.cloud.cloudProfileId,
orgId
})
saveOrcaCloudSessionExchange(active.profile.id, userDataPath, session)
return {
status: 'updated',
list: linkOrcaProfileToCloud(active.profile.id, session.cloud, userDataPath)
}
}
@@ -0,0 +1,143 @@
import { randomUUID } from 'node:crypto'
import { mkdirSync } from 'node:fs'
import type {
OrcaProfileCloudSummary,
OrcaProfileListState,
OrcaProfileSummary
} from '../../shared/orca-profiles'
import {
getOrcaProfileDirectory,
getOrcaProfileIndexPath,
loadOrCreateProfileIndex,
writeProfileIndex
} from './profile-index-store'
export type CreateCloudLinkedOrcaProfileRecordResult = OrcaProfileListState & {
profile: OrcaProfileSummary
}
function sanitizeProfileName(value: unknown, fallback: string): string {
const trimmed = typeof value === 'string' ? value.trim() : ''
return (trimmed || fallback).slice(0, 80)
}
function profileInitial(name: string): string {
return (name.match(/[A-Za-z0-9]/)?.[0] ?? 'C').toUpperCase()
}
function toCloudLinkedProfile(
profile: OrcaProfileSummary,
cloud: OrcaProfileCloudSummary,
now: number
): OrcaProfileSummary {
return {
...profile,
kind: 'cloud-linked',
cloud,
updatedAt: now,
lastOpenedAt: now
}
}
function toLocalProfile(profile: OrcaProfileSummary, now: number): OrcaProfileSummary {
const { cloud: _cloud, ...localProfile } = profile
return {
...localProfile,
kind: 'local',
updatedAt: now,
lastOpenedAt: now
}
}
export function createCloudLinkedOrcaProfileRecord(
cloud: OrcaProfileCloudSummary,
args: { name?: string },
userDataPath: string
): CreateCloudLinkedOrcaProfileRecordResult {
const index = loadOrCreateProfileIndex(userDataPath)
const now = Date.now()
const fallbackName = cloud.activeOrgName ?? cloud.displayName ?? cloud.email
const name = sanitizeProfileName(args.name, fallbackName)
const profile: OrcaProfileSummary = {
id: `cloud-${randomUUID()}`,
name,
avatar: {
kind: 'initials',
initials: profileInitial(name),
color: 'neutral'
},
kind: 'cloud-linked',
createdAt: now,
updatedAt: now,
lastOpenedAt: now,
cloud
}
const nextIndex = {
...index,
profiles: [...index.profiles, profile]
}
mkdirSync(getOrcaProfileDirectory(profile.id, userDataPath), { recursive: true })
writeProfileIndex(getOrcaProfileIndexPath(userDataPath), nextIndex)
return {
activeProfileId: nextIndex.activeProfileId,
profiles: nextIndex.profiles,
profile
}
}
export function linkOrcaProfileToCloud(
profileId: string,
cloud: OrcaProfileCloudSummary,
userDataPath: string
): OrcaProfileListState {
const index = loadOrCreateProfileIndex(userDataPath)
const now = Date.now()
let found = false
const profiles = index.profiles.map((profile) => {
if (profile.id !== profileId) {
return profile
}
found = true
return toCloudLinkedProfile(profile, cloud, now)
})
if (!found) {
throw new Error('unknown_orca_profile')
}
const nextIndex = {
...index,
profiles
}
writeProfileIndex(getOrcaProfileIndexPath(userDataPath), nextIndex)
return {
activeProfileId: nextIndex.activeProfileId,
profiles: nextIndex.profiles
}
}
export function unlinkOrcaProfileFromCloud(
profileId: string,
userDataPath: string
): OrcaProfileListState {
const index = loadOrCreateProfileIndex(userDataPath)
const now = Date.now()
let found = false
const profiles = index.profiles.map((profile) => {
if (profile.id !== profileId) {
return profile
}
found = true
return toLocalProfile(profile, now)
})
if (!found) {
throw new Error('unknown_orca_profile')
}
const nextIndex = {
...index,
profiles
}
writeProfileIndex(getOrcaProfileIndexPath(userDataPath), nextIndex)
return {
activeProfileId: nextIndex.activeProfileId,
profiles: nextIndex.profiles
}
}
@@ -0,0 +1,150 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config'
import type { OrcaCloudSession } from './profile-cloud-session-store'
import { OrcaCloudRequestError } from './profile-cloud-client'
import {
changeOrcaCloudOrgMemberRole,
inviteOrcaCloudOrgMember,
listOrcaCloudOrgMembers,
removeOrcaCloudOrgMember,
revokeOrcaCloudOrgInvite
} from './profile-cloud-org-members-client'
const fetchMock = vi.fn()
const config: OrcaCloudAuthConfig = {
apiBaseUrl: 'https://orca-cloud.example',
authorizeEndpoint: 'https://orca-cloud.example/v1/desktop/auth/authorize',
sessionEndpoint: 'https://orca-cloud.example/v1/desktop/auth/session',
refreshEndpoint: 'https://orca-cloud.example/v1/desktop/auth/refresh',
capabilitiesEndpoint: 'https://orca-cloud.example/v1/desktop/auth/capabilities',
profileEndpoint: 'https://orca-cloud.example/v1/desktop/auth/profile',
orgEndpoint: 'https://orca-cloud.example/v1/desktop/auth/org',
logoutEndpoint: 'https://orca-cloud.example/v1/desktop/auth/logout',
clientId: 'desktop-client',
scope: 'openid profile email offline_access'
}
const session: OrcaCloudSession = {
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresAt: 999,
capabilities: { flags: {}, refreshedAt: 1 }
}
function mockJsonResponse(value: unknown, init: { ok?: boolean; status?: number } = {}): void {
fetchMock.mockResolvedValue({
ok: init.ok ?? true,
status: init.status ?? 200,
json: async () => value
})
}
describe('Orca cloud org members client', () => {
beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
})
it('normalizes the roster, dropping malformed rows and defaulting the viewer role', async () => {
mockJsonResponse({
members: [
{ userId: 'user-1', email: 'nina@example.com', displayName: 'Nina', role: 'admin' },
{ userId: null, email: 'pending-user@example.com', role: 'member' },
{ userId: 'user-3', email: 'weird@example.com', role: 'superadmin' },
{ userId: 'user-4' }
],
pendingInvites: [{ email: 'invitee@example.com', role: 'member', createdAt: 1712000000000 }],
viewerRole: 'not-a-role',
canManageMembers: true
})
await expect(listOrcaCloudOrgMembers(config, session, 'org-1')).resolves.toEqual({
members: [
{ userId: 'user-1', email: 'nina@example.com', displayName: 'Nina', role: 'admin' },
{ userId: null, email: 'pending-user@example.com', displayName: undefined, role: 'member' },
{ userId: 'user-3', email: 'weird@example.com', displayName: undefined, role: 'member' }
],
pendingInvites: [{ email: 'invitee@example.com', role: 'member', createdAt: 1712000000000 }],
viewerRole: 'member',
canManageMembers: true
})
expect(fetchMock).toHaveBeenCalledWith(
'https://orca-cloud.example/v1/desktop/orgs/org-1/members',
expect.objectContaining({
method: 'GET',
headers: expect.objectContaining({ authorization: 'Bearer access-token' })
})
)
})
it('percent-encodes the org id in the request URL', async () => {
mockJsonResponse({
members: [],
pendingInvites: [],
viewerRole: 'owner',
canManageMembers: true
})
await listOrcaCloudOrgMembers(config, session, 'org/with space')
expect(fetchMock).toHaveBeenCalledWith(
'https://orca-cloud.example/v1/desktop/orgs/org%2Fwith%20space/members',
expect.any(Object)
)
})
it('posts invites with an email and role body', async () => {
mockJsonResponse({ ok: true })
await inviteOrcaCloudOrgMember(config, session, {
orgId: 'org-1',
email: 'new@example.com',
role: 'admin'
})
expect(fetchMock).toHaveBeenCalledWith(
'https://orca-cloud.example/v1/desktop/orgs/org-1/invites',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ email: 'new@example.com', role: 'admin' })
})
)
})
it('carries the server error code on a 409 conflict', async () => {
mockJsonResponse({ error: 'already_invited' }, { ok: false, status: 409 })
await expect(
inviteOrcaCloudOrgMember(config, session, {
orgId: 'org-1',
email: 'dupe@example.com',
role: 'member'
})
).rejects.toMatchObject({ statusCode: 409, errorCode: 'already_invited' })
})
it('surfaces a 403 as an OrcaCloudRequestError without an error code when the body is empty', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 403,
json: async () => {
throw new Error('no body')
}
})
await expect(
changeOrcaCloudOrgMemberRole(config, session, {
orgId: 'org-1',
userId: 'user-2',
role: 'admin'
})
).rejects.toBeInstanceOf(OrcaCloudRequestError)
await expect(
removeOrcaCloudOrgMember(config, session, { orgId: 'org-1', userId: 'user-2' })
).rejects.toMatchObject({ statusCode: 403, errorCode: undefined })
})
it('posts invite revocations by email', async () => {
mockJsonResponse({ ok: true })
await revokeOrcaCloudOrgInvite(config, session, { orgId: 'org-1', email: 'gone@example.com' })
expect(fetchMock).toHaveBeenCalledWith(
'https://orca-cloud.example/v1/desktop/orgs/org-1/invites/revoke',
expect.objectContaining({ body: JSON.stringify({ email: 'gone@example.com' }) })
)
})
})
@@ -0,0 +1,209 @@
import type {
OrcaOrgMember,
OrcaOrgMembersRoster,
OrcaOrgPendingInvite,
OrcaOrgRole
} from '../../shared/orca-profiles'
import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config'
import type { OrcaCloudSession } from './profile-cloud-session-store'
import { OrcaCloudRequestError } from './profile-cloud-client'
const CLOUD_REQUEST_TIMEOUT_MS = 30_000
const ORG_ROLES: readonly OrcaOrgRole[] = ['owner', 'admin', 'member']
function isOrgRole(value: unknown): value is OrcaOrgRole {
return typeof value === 'string' && (ORG_ROLES as readonly string[]).includes(value)
}
function normalizeRole(value: unknown, fallback: OrcaOrgRole): OrcaOrgRole {
return isOrgRole(value) ? value : fallback
}
function optionalString(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined
}
const trimmed = value.trim()
return trimmed || undefined
}
function normalizeTimestamp(value: unknown): number {
if (typeof value === 'number' && Number.isFinite(value)) {
return value
}
if (typeof value === 'string') {
const parsed = Date.parse(value)
if (Number.isFinite(parsed)) {
return parsed
}
}
return Date.now()
}
function normalizeMember(value: unknown): OrcaOrgMember | null {
if (!value || typeof value !== 'object') {
return null
}
const record = value as Record<string, unknown>
const email = optionalString(record.email)
if (!email) {
return null
}
const userId = optionalString(record.userId)
return {
userId: userId ?? null,
email,
displayName: optionalString(record.displayName),
role: normalizeRole(record.role, 'member')
}
}
function normalizePendingInvite(value: unknown): OrcaOrgPendingInvite | null {
if (!value || typeof value !== 'object') {
return null
}
const record = value as Record<string, unknown>
const email = optionalString(record.email)
if (!email) {
return null
}
return {
email,
role: normalizeRole(record.role, 'member'),
createdAt: normalizeTimestamp(record.createdAt)
}
}
function normalizeRoster(value: unknown): OrcaOrgMembersRoster {
if (!value || typeof value !== 'object') {
throw new Error('invalid_orca_org_members_roster')
}
const record = value as Record<string, unknown>
const members = Array.isArray(record.members)
? record.members
.map(normalizeMember)
.filter((member): member is OrcaOrgMember => member !== null)
: []
const pendingInvites = Array.isArray(record.pendingInvites)
? record.pendingInvites
.map(normalizePendingInvite)
.filter((invite): invite is OrcaOrgPendingInvite => invite !== null)
: []
return {
members,
pendingInvites,
// Why: default to the least-privileged role so a malformed viewerRole can
// never widen the client-side management affordance; the server still
// enforces authorization on every mutation.
viewerRole: normalizeRole(record.viewerRole, 'member'),
canManageMembers: record.canManageMembers === true
}
}
function orgMembersUrl(config: OrcaCloudAuthConfig, orgId: string, path: string): string {
return `${config.apiBaseUrl}/v1/desktop/orgs/${encodeURIComponent(orgId)}${path}`
}
async function extractErrorCode(response: Response): Promise<string | undefined> {
try {
const body = (await response.json()) as unknown
if (
body &&
typeof body === 'object' &&
typeof (body as { error?: unknown }).error === 'string'
) {
return (body as { error: string }).error.trim() || undefined
}
} catch {
// Non-JSON error body; the status code alone drives the caller's mapping.
}
return undefined
}
// Why: these are fixed first-party endpoints bearing the profile's access token;
// following a redirect would leak that token to another origin, and a stalled
// server must not hang the renderer's awaited IPC call forever.
function requestInit(method: 'GET' | 'POST', accessToken: string, body?: unknown): RequestInit {
return {
method,
headers: {
...(body === undefined ? {} : { 'content-type': 'application/json' }),
authorization: `Bearer ${accessToken}`
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
redirect: 'error',
signal: AbortSignal.timeout(CLOUD_REQUEST_TIMEOUT_MS)
}
}
async function requestOrgMembers<T>(
url: string,
init: RequestInit,
parse: (value: unknown) => T
): Promise<T> {
const response = await fetch(url, init)
if (!response.ok) {
throw new OrcaCloudRequestError(response.status, await extractErrorCode(response))
}
return parse((await response.json()) as unknown)
}
export async function listOrcaCloudOrgMembers(
config: OrcaCloudAuthConfig,
session: OrcaCloudSession,
orgId: string
): Promise<OrcaOrgMembersRoster> {
return requestOrgMembers(
orgMembersUrl(config, orgId, '/members'),
requestInit('GET', session.accessToken),
normalizeRoster
)
}
export async function inviteOrcaCloudOrgMember(
config: OrcaCloudAuthConfig,
session: OrcaCloudSession,
args: { orgId: string; email: string; role: OrcaOrgRole }
): Promise<void> {
await requestOrgMembers(
orgMembersUrl(config, args.orgId, '/invites'),
requestInit('POST', session.accessToken, { email: args.email, role: args.role }),
() => undefined
)
}
export async function revokeOrcaCloudOrgInvite(
config: OrcaCloudAuthConfig,
session: OrcaCloudSession,
args: { orgId: string; email: string }
): Promise<void> {
await requestOrgMembers(
orgMembersUrl(config, args.orgId, '/invites/revoke'),
requestInit('POST', session.accessToken, { email: args.email }),
() => undefined
)
}
export async function changeOrcaCloudOrgMemberRole(
config: OrcaCloudAuthConfig,
session: OrcaCloudSession,
args: { orgId: string; userId: string; role: OrcaOrgRole }
): Promise<void> {
await requestOrgMembers(
orgMembersUrl(config, args.orgId, '/members/role'),
requestInit('POST', session.accessToken, { userId: args.userId, role: args.role }),
() => undefined
)
}
export async function removeOrcaCloudOrgMember(
config: OrcaCloudAuthConfig,
session: OrcaCloudSession,
args: { orgId: string; userId: string }
): Promise<void> {
await requestOrgMembers(
orgMembersUrl(config, args.orgId, '/members/remove'),
requestInit('POST', session.accessToken, { userId: args.userId }),
() => undefined
)
}
@@ -0,0 +1,259 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { OrcaOrgMembersRoster } from '../../shared/orca-profiles'
import { OrcaCloudRequestError } from './profile-cloud-client'
const {
runWithFreshOrcaCloudSessionMock,
listOrcaCloudOrgMembersMock,
inviteOrcaCloudOrgMemberMock,
revokeOrcaCloudOrgInviteMock,
changeOrcaCloudOrgMemberRoleMock,
removeOrcaCloudOrgMemberMock
} = vi.hoisted(() => ({
runWithFreshOrcaCloudSessionMock: vi.fn(),
listOrcaCloudOrgMembersMock: vi.fn(),
inviteOrcaCloudOrgMemberMock: vi.fn(),
revokeOrcaCloudOrgInviteMock: vi.fn(),
changeOrcaCloudOrgMemberRoleMock: vi.fn(),
removeOrcaCloudOrgMemberMock: vi.fn()
}))
let userDataPath = ''
vi.mock('electron', () => ({
app: { getPath: () => userDataPath }
}))
vi.mock('./profile-cloud-session-refresh', () => ({
runWithFreshOrcaCloudSessionMock,
runWithFreshOrcaCloudSession: runWithFreshOrcaCloudSessionMock
}))
vi.mock('./profile-cloud-org-members-client', () => ({
listOrcaCloudOrgMembers: listOrcaCloudOrgMembersMock,
inviteOrcaCloudOrgMember: inviteOrcaCloudOrgMemberMock,
revokeOrcaCloudOrgInvite: revokeOrcaCloudOrgInviteMock,
changeOrcaCloudOrgMemberRole: changeOrcaCloudOrgMemberRoleMock,
removeOrcaCloudOrgMember: removeOrcaCloudOrgMemberMock
}))
import {
changeOrcaProfileOrgMemberRole,
inviteOrcaProfileOrgMember,
listOrcaProfileOrgMembers,
removeOrcaProfileOrgMember,
revokeOrcaProfileOrgInvite
} from './profile-cloud-org-members-service'
const fakeSession = {
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresAt: Date.now() + 3_600_000,
capabilities: { flags: {}, refreshedAt: 1 }
}
// Why: mirror the real contract — invoke the operation with a live session and
// surface its resolved value; business 4xx are returned by the operation as
// values, never thrown, so the session layer never sees them.
function runOperationDirectly(): void {
runWithFreshOrcaCloudSessionMock.mockImplementation(
async (
_config: unknown,
_active: unknown,
_path: unknown,
op: (session: unknown) => unknown
) => ({
status: 'ok',
value: await op(fakeSession)
})
)
}
function configureCloudEnv(): void {
vi.stubEnv('ORCA_CLOUD_API_URL', 'https://orca-cloud.example')
vi.stubEnv('ORCA_CLOUD_CLIENT_ID', 'desktop-client')
}
const roster: OrcaOrgMembersRoster = {
members: [{ userId: 'user-1', email: 'nina@example.com', role: 'owner' }],
pendingInvites: [],
viewerRole: 'owner',
canManageMembers: true
}
describe('Orca cloud org members service (configured)', () => {
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'orca-org-members-'))
runWithFreshOrcaCloudSessionMock.mockReset()
listOrcaCloudOrgMembersMock.mockReset()
inviteOrcaCloudOrgMemberMock.mockReset()
revokeOrcaCloudOrgInviteMock.mockReset()
changeOrcaCloudOrgMemberRoleMock.mockReset()
removeOrcaCloudOrgMemberMock.mockReset()
vi.unstubAllEnvs()
vi.stubEnv('ORCA_CLOUD_DEV_AUTH', '')
vi.stubEnv('ORCA_CLOUD_API_URL', '')
vi.stubEnv('ORCA_CLOUD_CLIENT_ID', '')
})
afterEach(() => {
rmSync(userDataPath, { recursive: true, force: true })
vi.unstubAllEnvs()
})
it('reports unconfigured when cloud sign-in is not set up', async () => {
await expect(listOrcaProfileOrgMembers(userDataPath, 'org-1')).resolves.toEqual({
status: 'unconfigured'
})
expect(runWithFreshOrcaCloudSessionMock).not.toHaveBeenCalled()
})
it('returns the roster from the client', async () => {
configureCloudEnv()
runOperationDirectly()
listOrcaCloudOrgMembersMock.mockResolvedValue(roster)
await expect(listOrcaProfileOrgMembers(userDataPath, 'org-1')).resolves.toEqual({
status: 'ok',
roster
})
expect(listOrcaCloudOrgMembersMock).toHaveBeenCalledWith(
expect.any(Object),
fakeSession,
'org-1'
)
})
it('maps a 409 already_member invite conflict', async () => {
configureCloudEnv()
runOperationDirectly()
inviteOrcaCloudOrgMemberMock.mockRejectedValue(new OrcaCloudRequestError(409, 'already_member'))
await expect(
inviteOrcaProfileOrgMember(userDataPath, { orgId: 'org-1', email: 'a@b.com', role: 'member' })
).resolves.toEqual({ status: 'conflict', reason: 'already_member' })
})
it('maps a 403 role change to forbidden', async () => {
configureCloudEnv()
runOperationDirectly()
changeOrcaCloudOrgMemberRoleMock.mockRejectedValue(new OrcaCloudRequestError(403))
await expect(
changeOrcaProfileOrgMemberRole(userDataPath, {
orgId: 'org-1',
userId: 'user-2',
role: 'admin'
})
).resolves.toEqual({ status: 'forbidden' })
})
it('maps a 400 cannot_remove_self to an invalid result', async () => {
configureCloudEnv()
runOperationDirectly()
removeOrcaCloudOrgMemberMock.mockRejectedValue(
new OrcaCloudRequestError(400, 'cannot_remove_self')
)
await expect(
removeOrcaProfileOrgMember(userDataPath, { orgId: 'org-1', userId: 'user-1' })
).resolves.toEqual({ status: 'invalid', reason: 'cannot_remove_self' })
})
it('maps a 404 revoke to not-found', async () => {
configureCloudEnv()
runOperationDirectly()
revokeOrcaCloudOrgInviteMock.mockRejectedValue(new OrcaCloudRequestError(404))
await expect(
revokeOrcaProfileOrgInvite(userDataPath, { orgId: 'org-1', email: 'gone@b.com' })
).resolves.toEqual({ status: 'not-found' })
})
it('reports reconnect-required when the session layer cannot refresh', async () => {
configureCloudEnv()
runWithFreshOrcaCloudSessionMock.mockResolvedValue({ status: 'reconnect-required' })
await expect(listOrcaProfileOrgMembers(userDataPath, 'org-1')).resolves.toEqual({
status: 'reconnect-required'
})
})
})
describe('Orca cloud org members service (dev auth)', () => {
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'orca-org-members-dev-'))
runWithFreshOrcaCloudSessionMock.mockReset()
vi.unstubAllEnvs()
vi.stubEnv('ORCA_CLOUD_DEV_AUTH', '1')
})
afterEach(() => {
rmSync(userDataPath, { recursive: true, force: true })
vi.unstubAllEnvs()
})
it('serves an in-memory roster the caller can manage', async () => {
const result = await listOrcaProfileOrgMembers(userDataPath, 'dev-list-org')
if (result.status !== 'ok') {
throw new Error(`Expected ok, got ${result.status}`)
}
expect(result.roster.canManageMembers).toBe(true)
expect(result.roster.viewerRole).toBe('owner')
expect(result.roster.members[0]).toMatchObject({ role: 'owner' })
expect(result.roster.members.some((member) => member.userId === null)).toBe(true)
expect(result.roster.pendingInvites.length).toBeGreaterThan(0)
expect(runWithFreshOrcaCloudSessionMock).not.toHaveBeenCalled()
})
it('mutates the dev roster across invite and revoke', async () => {
const orgId = 'dev-mutate-org'
await expect(
inviteOrcaProfileOrgMember(userDataPath, {
orgId,
email: 'fresh@orca.local',
role: 'member'
})
).resolves.toEqual({ status: 'ok' })
const afterInvite = await listOrcaProfileOrgMembers(userDataPath, orgId)
if (afterInvite.status !== 'ok') {
throw new Error('expected ok')
}
expect(afterInvite.roster.pendingInvites.some((i) => i.email === 'fresh@orca.local')).toBe(true)
await expect(
inviteOrcaProfileOrgMember(userDataPath, {
orgId,
email: 'fresh@orca.local',
role: 'member'
})
).resolves.toEqual({ status: 'conflict', reason: 'already_invited' })
await expect(
revokeOrcaProfileOrgInvite(userDataPath, { orgId, email: 'fresh@orca.local' })
).resolves.toEqual({ status: 'ok' })
await expect(
revokeOrcaProfileOrgInvite(userDataPath, { orgId, email: 'fresh@orca.local' })
).resolves.toEqual({ status: 'not-found' })
})
it('blocks changing the dev owner (self) role', async () => {
const orgId = 'dev-self-org'
const list = await listOrcaProfileOrgMembers(userDataPath, orgId)
if (list.status !== 'ok') {
throw new Error('expected ok')
}
const self = list.roster.members.find((member) => member.role === 'owner')
await expect(
changeOrcaProfileOrgMemberRole(userDataPath, {
orgId,
userId: self?.userId ?? 'dev-user',
role: 'member'
})
).resolves.toEqual({ status: 'invalid', reason: 'cannot_change_own_role' })
})
})
@@ -0,0 +1,212 @@
import type {
OrcaProfileOrgInviteRevokeArgs,
OrcaProfileOrgMemberChangeRoleArgs,
OrcaProfileOrgMemberInviteArgs,
OrcaProfileOrgMemberMutationResult,
OrcaProfileOrgMemberRemoveArgs,
OrcaProfileOrgMembersListResult
} from '../../shared/orca-profiles'
import type { ActiveOrcaProfileState } from './profile-index-store'
import { ensureActiveOrcaProfile } from './profile-index-store'
import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config'
import { getOrcaCloudAuthConfig, isOrcaCloudDevAuthEnabled } from './profile-cloud-auth-config'
import type { OrcaCloudSession } from './profile-cloud-session-store'
import { OrcaCloudRequestError } from './profile-cloud-client'
import { runWithFreshOrcaCloudSession } from './profile-cloud-session-refresh'
import {
changeOrcaCloudOrgMemberRole,
inviteOrcaCloudOrgMember,
listOrcaCloudOrgMembers,
removeOrcaCloudOrgMember,
revokeOrcaCloudOrgInvite
} from './profile-cloud-org-members-client'
import {
changeDevOrcaCloudOrgMemberRole,
inviteDevOrcaCloudOrgMember,
listDevOrcaCloudOrgMembers,
removeDevOrcaCloudOrgMember,
revokeDevOrcaCloudOrgInvite
} from './profile-cloud-dev-org-members'
type OrgCallResult<T> =
| { status: 'ok'; value: T }
| { status: 'reconnect-required' }
| { status: 'request-error'; error: OrcaCloudRequestError }
| { status: 'failed'; error: string }
// Why: only a 401 means the token itself is stale and should drive a session
// refresh/reconnect. 403/404/409/400 are business or permission outcomes the UI
// must interpret, so they are surfaced as values rather than thrown — otherwise
// runWithFreshOrcaCloudSession would treat a 403 as an auth failure and burn a
// pointless token refresh + retry before giving up.
async function runOrgMemberCall<T>(
config: OrcaCloudAuthConfig,
active: ActiveOrcaProfileState,
userDataPath: string,
call: (session: OrcaCloudSession) => Promise<T>
): Promise<OrgCallResult<T>> {
try {
const operation = await runWithFreshOrcaCloudSession(
config,
active,
userDataPath,
async (session) => {
try {
return { ok: true as const, value: await call(session) }
} catch (error) {
if (error instanceof OrcaCloudRequestError && error.statusCode !== 401) {
return { ok: false as const, error }
}
throw error
}
}
)
if (operation.status !== 'ok') {
return { status: 'reconnect-required' }
}
const outcome = operation.value
return outcome.ok
? { status: 'ok', value: outcome.value }
: { status: 'request-error', error: outcome.error }
} catch (error) {
return { status: 'failed', error: error instanceof Error ? error.message : String(error) }
}
}
function mapMutationRequestError(error: OrcaCloudRequestError): OrcaProfileOrgMemberMutationResult {
switch (error.statusCode) {
case 403:
return { status: 'forbidden' }
case 404:
return { status: 'not-found' }
case 409:
return {
status: 'conflict',
reason: error.errorCode === 'already_member' ? 'already_member' : 'already_invited'
}
case 400:
return {
status: 'invalid',
reason:
error.errorCode === 'cannot_remove_self' ? 'cannot_remove_self' : 'cannot_change_own_role'
}
default:
return { status: 'failed', error: error.message }
}
}
function mapMutationResult(result: OrgCallResult<void>): OrcaProfileOrgMemberMutationResult {
switch (result.status) {
case 'ok':
return { status: 'ok' }
case 'reconnect-required':
return { status: 'reconnect-required' }
case 'request-error':
return mapMutationRequestError(result.error)
case 'failed':
return { status: 'failed', error: result.error }
}
}
export async function listOrcaProfileOrgMembers(
userDataPath: string,
orgId: string
): Promise<OrcaProfileOrgMembersListResult> {
const active = ensureActiveOrcaProfile(userDataPath)
if (isOrcaCloudDevAuthEnabled()) {
return { status: 'ok', roster: listDevOrcaCloudOrgMembers(orgId) }
}
const configState = getOrcaCloudAuthConfig()
if (!configState.configured) {
return { status: 'unconfigured' }
}
const result = await runOrgMemberCall(configState.config, active, userDataPath, (session) =>
listOrcaCloudOrgMembers(configState.config, session, orgId)
)
switch (result.status) {
case 'ok':
return { status: 'ok', roster: result.value }
case 'reconnect-required':
return { status: 'reconnect-required' }
case 'request-error':
return { status: 'failed', error: result.error.message }
case 'failed':
return { status: 'failed', error: result.error }
}
}
export async function inviteOrcaProfileOrgMember(
userDataPath: string,
args: OrcaProfileOrgMemberInviteArgs
): Promise<OrcaProfileOrgMemberMutationResult> {
const active = ensureActiveOrcaProfile(userDataPath)
if (isOrcaCloudDevAuthEnabled()) {
return inviteDevOrcaCloudOrgMember(args)
}
const configState = getOrcaCloudAuthConfig()
if (!configState.configured) {
return { status: 'unconfigured' }
}
return mapMutationResult(
await runOrgMemberCall(configState.config, active, userDataPath, (session) =>
inviteOrcaCloudOrgMember(configState.config, session, args)
)
)
}
export async function revokeOrcaProfileOrgInvite(
userDataPath: string,
args: OrcaProfileOrgInviteRevokeArgs
): Promise<OrcaProfileOrgMemberMutationResult> {
const active = ensureActiveOrcaProfile(userDataPath)
if (isOrcaCloudDevAuthEnabled()) {
return revokeDevOrcaCloudOrgInvite(args)
}
const configState = getOrcaCloudAuthConfig()
if (!configState.configured) {
return { status: 'unconfigured' }
}
return mapMutationResult(
await runOrgMemberCall(configState.config, active, userDataPath, (session) =>
revokeOrcaCloudOrgInvite(configState.config, session, args)
)
)
}
export async function changeOrcaProfileOrgMemberRole(
userDataPath: string,
args: OrcaProfileOrgMemberChangeRoleArgs
): Promise<OrcaProfileOrgMemberMutationResult> {
const active = ensureActiveOrcaProfile(userDataPath)
if (isOrcaCloudDevAuthEnabled()) {
return changeDevOrcaCloudOrgMemberRole(args)
}
const configState = getOrcaCloudAuthConfig()
if (!configState.configured) {
return { status: 'unconfigured' }
}
return mapMutationResult(
await runOrgMemberCall(configState.config, active, userDataPath, (session) =>
changeOrcaCloudOrgMemberRole(configState.config, session, args)
)
)
}
export async function removeOrcaProfileOrgMember(
userDataPath: string,
args: OrcaProfileOrgMemberRemoveArgs
): Promise<OrcaProfileOrgMemberMutationResult> {
const active = ensureActiveOrcaProfile(userDataPath)
if (isOrcaCloudDevAuthEnabled()) {
return removeDevOrcaCloudOrgMember(args)
}
const configState = getOrcaCloudAuthConfig()
if (!configState.configured) {
return { status: 'unconfigured' }
}
return mapMutationResult(
await runOrgMemberCall(configState.config, active, userDataPath, (session) =>
removeOrcaCloudOrgMember(configState.config, session, args)
)
)
}
@@ -0,0 +1,127 @@
import { get } from 'node:http'
import { describe, expect, it, beforeEach, vi } from 'vitest'
import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config'
const { openExternalMock } = vi.hoisted(() => ({
openExternalMock: vi.fn()
}))
vi.mock('electron', () => ({
shell: {
openExternal: openExternalMock
}
}))
import { beginOrcaCloudPkceFlow } from './profile-cloud-pkce'
type HttpResponse = {
body: string
statusCode: number | undefined
}
const config: OrcaCloudAuthConfig = {
apiBaseUrl: 'https://orca-cloud.example',
authorizeEndpoint: 'https://orca-cloud.example/v1/desktop/auth/authorize',
sessionEndpoint: 'https://orca-cloud.example/v1/desktop/auth/session',
refreshEndpoint: 'https://orca-cloud.example/v1/desktop/auth/refresh',
capabilitiesEndpoint: 'https://orca-cloud.example/v1/desktop/auth/capabilities',
profileEndpoint: 'https://orca-cloud.example/v1/desktop/auth/profile',
orgEndpoint: 'https://orca-cloud.example/v1/desktop/auth/org',
logoutEndpoint: 'https://orca-cloud.example/v1/desktop/auth/logout',
clientId: 'desktop-client',
scope: 'openid profile email offline_access'
}
function readHttp(url: string): Promise<HttpResponse> {
return new Promise((resolve, reject) => {
const request = get(url, (response) => {
response.setEncoding('utf-8')
let body = ''
response.on('data', (chunk: string) => {
body += chunk
})
response.on('end', () => {
resolve({ body, statusCode: response.statusCode })
})
})
request.on('error', reject)
})
}
function callbackUrl(redirectUri: string, params: Record<string, string>): string {
const url = new URL(redirectUri)
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value)
}
return url.toString()
}
async function startedFlow(): Promise<{
authUrl: URL
flow: ReturnType<typeof beginOrcaCloudPkceFlow>
nonce: string
redirectUri: string
state: string
}> {
const flow = beginOrcaCloudPkceFlow(config, 'local-default')
await vi.waitFor(() => expect(openExternalMock).toHaveBeenCalledTimes(1))
const authUrl = new URL(String(openExternalMock.mock.calls[0]?.[0]))
const nonce = authUrl.searchParams.get('nonce')
const redirectUri = authUrl.searchParams.get('redirect_uri')
const state = authUrl.searchParams.get('state')
if (!nonce || !redirectUri || !state) {
throw new Error('Expected PKCE flow to create nonce, redirect_uri, and state')
}
return { authUrl, flow, nonce, redirectUri, state }
}
describe('Orca cloud PKCE flow', () => {
beforeEach(() => {
openExternalMock.mockReset()
openExternalMock.mockResolvedValue(undefined)
})
it('keeps the loopback listener alive after an invalid callback', async () => {
const { flow, redirectUri, state } = await startedFlow()
const invalidResponse = await readHttp(
callbackUrl(redirectUri, { code: 'wrong-code', state: 'wrong-state' })
)
expect(invalidResponse.statusCode).toBe(400)
const validResponse = await readHttp(callbackUrl(redirectUri, { code: 'real-code', state }))
expect(validResponse.statusCode).toBe(200)
await expect(flow).resolves.toMatchObject({
code: 'real-code',
redirectUri,
state
})
})
it('rejects a provider error that matches the flow state', async () => {
const { flow, redirectUri, state } = await startedFlow()
const observedFlow = flow.catch((error: unknown) => error)
const response = await readHttp(callbackUrl(redirectUri, { error: 'access_denied', state }))
expect(response.statusCode).toBe(400)
await expect(observedFlow).resolves.toMatchObject({ message: 'orca_cloud_auth_denied' })
})
it('adds desktop PKCE parameters to the authorize URL', async () => {
const { authUrl, flow, nonce, redirectUri, state } = await startedFlow()
expect(authUrl.searchParams.get('client_id')).toBe('desktop-client')
expect(authUrl.searchParams.get('response_type')).toBe('code')
expect(authUrl.searchParams.get('redirect_uri')).toBe(redirectUri)
expect(authUrl.searchParams.get('scope')).toBe('openid profile email offline_access')
expect(authUrl.searchParams.get('nonce')).toBe(nonce)
expect(authUrl.searchParams.get('state')).toBe(state)
expect(authUrl.searchParams.get('code_challenge')).toMatch(/^[A-Za-z0-9_-]+$/)
expect(authUrl.searchParams.get('code_challenge_method')).toBe('S256')
expect(authUrl.searchParams.get('local_profile_id')).toBe('local-default')
await readHttp(callbackUrl(redirectUri, { code: 'real-code', state }))
await expect(flow).resolves.toMatchObject({ code: 'real-code', nonce })
})
})
@@ -0,0 +1,142 @@
import { createHash, randomBytes } from 'node:crypto'
import { createServer, type Server, type ServerResponse } from 'node:http'
import { shell } from 'electron'
import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config'
export type OrcaCloudAuthorizationCode = {
code: string
codeVerifier: string
nonce: string
redirectUri: string
state: string
}
const AUTH_TIMEOUT_MS = 5 * 60 * 1000
function base64Url(buffer: Buffer): string {
return buffer.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '')
}
function createCodeVerifier(): string {
return base64Url(randomBytes(32))
}
function createCodeChallenge(verifier: string): string {
return base64Url(createHash('sha256').update(verifier).digest())
}
function closeServer(server: Server): void {
try {
// Why: keep-alive sockets from the browser can delay 'close' (and the
// timeout cleanup) until the browser drops the connection.
server.closeAllConnections?.()
server.close()
} catch {
// Already closed.
}
}
export function beginOrcaCloudPkceFlow(
config: OrcaCloudAuthConfig,
localProfileId: string
): Promise<OrcaCloudAuthorizationCode> {
const codeVerifier = createCodeVerifier()
const nonce = base64Url(randomBytes(32))
const state = base64Url(randomBytes(32))
return new Promise((resolve, reject) => {
let settled = false
let redirectUri = ''
function rejectFlow(error: Error): void {
if (settled) {
return
}
settled = true
reject(error)
closeServer(server)
}
function resolveFlow(code: string): void {
if (settled) {
return
}
settled = true
resolve({
code,
codeVerifier,
nonce,
redirectUri,
state
})
closeServer(server)
}
function writeInvalidCallback(response: ServerResponse): void {
response.writeHead(400)
response.end('Invalid Orca sign-in response.')
}
const server = createServer((request, response) => {
try {
const url = new URL(request.url ?? '/', 'http://127.0.0.1')
if (url.pathname !== '/auth/callback') {
response.writeHead(404)
response.end('Not found')
return
}
const code = url.searchParams.get('code')
const returnedState = url.searchParams.get('state')
if (returnedState !== state) {
// Why: stray loopback probes must not be able to cancel the user's login.
writeInvalidCallback(response)
return
}
if (url.searchParams.has('error')) {
response.writeHead(400)
response.end('Orca sign-in was cancelled.')
rejectFlow(new Error('orca_cloud_auth_denied'))
return
}
if (!code) {
writeInvalidCallback(response)
return
}
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
response.end('<!doctype html><title>Orca</title><p>You can return to Orca.</p>')
resolveFlow(code)
} catch (error) {
rejectFlow(error instanceof Error ? error : new Error('orca_cloud_auth_callback_failed'))
}
})
const timeout = setTimeout(() => {
rejectFlow(new Error('orca_cloud_auth_timeout'))
}, AUTH_TIMEOUT_MS)
server.once('close', () => clearTimeout(timeout))
server.once('error', rejectFlow)
server.listen(0, '127.0.0.1', () => {
const address = server.address()
if (!address || typeof address === 'string') {
rejectFlow(new Error('orca_cloud_auth_loopback_unavailable'))
return
}
redirectUri = `http://127.0.0.1:${address.port}/auth/callback`
const authorizeUrl = new URL(config.authorizeEndpoint)
authorizeUrl.searchParams.set('client_id', config.clientId)
authorizeUrl.searchParams.set('response_type', 'code')
authorizeUrl.searchParams.set('redirect_uri', redirectUri)
authorizeUrl.searchParams.set('scope', config.scope)
authorizeUrl.searchParams.set('nonce', nonce)
authorizeUrl.searchParams.set('state', state)
authorizeUrl.searchParams.set('code_challenge', createCodeChallenge(codeVerifier))
authorizeUrl.searchParams.set('code_challenge_method', 'S256')
authorizeUrl.searchParams.set('local_profile_id', localProfileId)
void shell.openExternal(authorizeUrl.toString()).catch((error) => {
rejectFlow(
error instanceof Error ? error : new Error('orca_cloud_auth_browser_open_failed')
)
})
})
})
}
@@ -0,0 +1,249 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type {
OrcaCloudCapabilities,
OrcaCloudOrgSummary,
OrcaProfileCloudSummary
} from '../../shared/orca-profiles'
import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange'
const {
beginOrcaCloudPkceFlowMock,
createOrcaCloudProfileMock,
exchangeOrcaCloudAuthCodeMock,
refreshOrcaCloudCapabilitiesMock,
refreshOrcaCloudSessionMock,
selectOrcaCloudOrgMock,
OrcaCloudRequestErrorMock,
safeStorageMock
} = vi.hoisted(() => ({
beginOrcaCloudPkceFlowMock: vi.fn(),
createOrcaCloudProfileMock: vi.fn(),
exchangeOrcaCloudAuthCodeMock: vi.fn(),
refreshOrcaCloudCapabilitiesMock: vi.fn(),
refreshOrcaCloudSessionMock: vi.fn(),
selectOrcaCloudOrgMock: vi.fn(),
OrcaCloudRequestErrorMock: class OrcaCloudRequestError extends Error {
constructor(public readonly statusCode: number) {
super(`orca_cloud_request_failed_${statusCode}`)
this.name = 'OrcaCloudRequestError'
}
},
safeStorageMock: {
decryptString: vi.fn((value: Buffer) => value.toString('utf-8')),
encryptString: vi.fn((value: string) => Buffer.from(value, 'utf-8')),
isEncryptionAvailable: vi.fn(() => true)
}
}))
let userDataPath = ''
vi.mock('electron', () => ({
app: {
getPath: () => userDataPath
},
safeStorage: safeStorageMock
}))
vi.mock('./profile-cloud-pkce', () => ({
beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock
}))
vi.mock('./profile-cloud-client', () => ({
OrcaCloudRequestError: OrcaCloudRequestErrorMock,
createOrcaCloudProfile: createOrcaCloudProfileMock,
exchangeOrcaCloudAuthCode: exchangeOrcaCloudAuthCodeMock,
refreshOrcaCloudCapabilities: refreshOrcaCloudCapabilitiesMock,
refreshOrcaCloudSession: refreshOrcaCloudSessionMock,
revokeOrcaCloudSession: vi.fn(),
selectOrcaCloudOrg: selectOrcaCloudOrgMock
}))
import {
connectCurrentOrcaProfile,
createCloudLinkedOrcaProfile,
getCurrentOrcaProfileAuthStatus,
refreshCurrentOrcaProfileAuth,
selectCurrentOrcaProfileOrg
} from './profile-cloud-service'
const cloudSummary: OrcaProfileCloudSummary = {
cloudProfileId: 'cloud-profile-1',
userId: 'user-1',
email: 'nina@example.com',
displayName: 'Nina',
linkedAt: 10
}
const capabilities: OrcaCloudCapabilities = {
flags: { share: true },
refreshedAt: 11
}
const organizations: OrcaCloudOrgSummary[] = [
{ orgId: 'org-1', name: 'Acme', role: 'Admin' },
{ orgId: 'org-2', name: 'Personal' }
]
function futureExpiresAt(): number {
return Date.now() + 3_600_000
}
function configureCloudEnv(): void {
vi.stubEnv('ORCA_CLOUD_API_URL', 'https://orca-cloud.example')
vi.stubEnv('ORCA_CLOUD_CLIENT_ID', 'desktop-client')
}
function mockSuccessfulConnect(): void {
beginOrcaCloudPkceFlowMock.mockResolvedValue({
code: 'auth-code',
codeVerifier: 'code-verifier',
nonce: 'nonce',
redirectUri: 'http://127.0.0.1:4100/auth/callback',
state: 'state'
})
exchangeOrcaCloudAuthCodeMock.mockResolvedValue({
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresAt: futureExpiresAt(),
cloud: cloudSummary,
organizations,
capabilities
} satisfies OrcaCloudSessionExchangeResponse)
}
function mockSuccessfulSessionRefresh(): void {
refreshOrcaCloudSessionMock.mockResolvedValue({
accessToken: 'rotated-access-token',
refreshToken: 'rotated-refresh-token',
expiresAt: futureExpiresAt(),
cloud: cloudSummary,
organizations,
capabilities
} satisfies OrcaCloudSessionExchangeResponse)
}
describe('Orca cloud profile auth-failure retry', () => {
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'orca-cloud-service-auth-retry-'))
beginOrcaCloudPkceFlowMock.mockReset()
createOrcaCloudProfileMock.mockReset()
exchangeOrcaCloudAuthCodeMock.mockReset()
refreshOrcaCloudCapabilitiesMock.mockReset()
refreshOrcaCloudSessionMock.mockReset()
selectOrcaCloudOrgMock.mockReset()
safeStorageMock.decryptString.mockReset()
safeStorageMock.encryptString.mockReset()
safeStorageMock.isEncryptionAvailable.mockReset()
safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf-8'))
safeStorageMock.encryptString.mockImplementation((value: string) =>
Buffer.from(value, 'utf-8')
)
safeStorageMock.isEncryptionAvailable.mockReturnValue(true)
vi.unstubAllEnvs()
vi.stubEnv('ORCA_CLOUD_API_URL', '')
vi.stubEnv('ORCA_CLOUD_CLIENT_ID', '')
})
afterEach(() => {
rmSync(userDataPath, { recursive: true, force: true })
vi.unstubAllEnvs()
})
it('refreshes and retries cloud profile creation after an auth failure', async () => {
configureCloudEnv()
mockSuccessfulConnect()
mockSuccessfulSessionRefresh()
await connectCurrentOrcaProfile(userDataPath)
createOrcaCloudProfileMock
.mockRejectedValueOnce(new OrcaCloudRequestErrorMock(401))
.mockResolvedValue({
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
expiresAt: futureExpiresAt(),
cloud: { ...cloudSummary, cloudProfileId: 'cloud-profile-2' },
organizations,
capabilities
} satisfies OrcaCloudSessionExchangeResponse)
const result = await createCloudLinkedOrcaProfile(userDataPath, { name: 'Acme' })
expect(result.status).toBe('created')
expect(createOrcaCloudProfileMock).toHaveBeenNthCalledWith(
2,
expect.any(Object),
expect.objectContaining({ accessToken: 'rotated-access-token' }),
{ name: 'Acme' }
)
})
it('refreshes and retries capability refresh after an auth failure', async () => {
configureCloudEnv()
mockSuccessfulConnect()
mockSuccessfulSessionRefresh()
await connectCurrentOrcaProfile(userDataPath)
refreshOrcaCloudCapabilitiesMock
.mockRejectedValueOnce(new OrcaCloudRequestErrorMock(403))
.mockResolvedValue({
capabilities: { flags: { share: false }, refreshedAt: 26 } satisfies OrcaCloudCapabilities
})
const result = await refreshCurrentOrcaProfileAuth(userDataPath)
expect(result.status).toBe('refreshed')
expect(refreshOrcaCloudCapabilitiesMock).toHaveBeenNthCalledWith(
2,
expect.any(Object),
expect.objectContaining({ accessToken: 'rotated-access-token' })
)
expect(getCurrentOrcaProfileAuthStatus(userDataPath).capabilities).toEqual({
flags: { share: false },
refreshedAt: 26
})
})
it('requires reconnect when a retried capability refresh is still unauthorized', async () => {
configureCloudEnv()
mockSuccessfulConnect()
mockSuccessfulSessionRefresh()
await connectCurrentOrcaProfile(userDataPath)
refreshOrcaCloudCapabilitiesMock
.mockRejectedValueOnce(new OrcaCloudRequestErrorMock(401))
.mockRejectedValueOnce(new OrcaCloudRequestErrorMock(401))
const result = await refreshCurrentOrcaProfileAuth(userDataPath)
expect(result.status).toBe('reconnect-required')
expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({
state: 'reconnect-required',
persistence: 'none',
cloud: cloudSummary
})
})
it('refreshes and retries organization selection after an auth failure', async () => {
configureCloudEnv()
mockSuccessfulConnect()
mockSuccessfulSessionRefresh()
await connectCurrentOrcaProfile(userDataPath)
selectOrcaCloudOrgMock
.mockRejectedValueOnce(new OrcaCloudRequestErrorMock(401))
.mockResolvedValue({
cloud: { ...cloudSummary, activeOrgId: 'org-1', activeOrgName: 'Acme' },
organizations,
capabilities
})
const result = await selectCurrentOrcaProfileOrg(userDataPath, 'org-1')
expect(result.status).toBe('selected')
expect(selectOrcaCloudOrgMock).toHaveBeenNthCalledWith(
2,
expect.any(Object),
expect.objectContaining({ accessToken: 'rotated-access-token' }),
'org-1'
)
})
})
@@ -0,0 +1,256 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type {
OrcaCloudCapabilities,
OrcaCloudOrgSummary,
OrcaProfileCloudSummary
} from '../../shared/orca-profiles'
import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange'
const {
beginOrcaCloudPkceFlowMock,
createOrcaCloudProfileMock,
exchangeOrcaCloudAuthCodeMock,
refreshOrcaCloudCapabilitiesMock,
refreshOrcaCloudSessionMock,
OrcaCloudRequestErrorMock,
safeStorageMock
} = vi.hoisted(() => ({
beginOrcaCloudPkceFlowMock: vi.fn(),
createOrcaCloudProfileMock: vi.fn(),
exchangeOrcaCloudAuthCodeMock: vi.fn(),
refreshOrcaCloudCapabilitiesMock: vi.fn(),
refreshOrcaCloudSessionMock: vi.fn(),
OrcaCloudRequestErrorMock: class OrcaCloudRequestError extends Error {
constructor(public readonly statusCode: number) {
super(`orca_cloud_request_failed_${statusCode}`)
this.name = 'OrcaCloudRequestError'
}
},
safeStorageMock: {
decryptString: vi.fn((value: Buffer) => value.toString('utf-8')),
encryptString: vi.fn((value: string) => Buffer.from(value, 'utf-8')),
isEncryptionAvailable: vi.fn(() => true)
}
}))
let userDataPath = ''
vi.mock('electron', () => ({
app: {
getPath: () => userDataPath
},
safeStorage: safeStorageMock
}))
vi.mock('./profile-cloud-pkce', () => ({
beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock
}))
vi.mock('./profile-cloud-client', () => ({
OrcaCloudRequestError: OrcaCloudRequestErrorMock,
createOrcaCloudProfile: createOrcaCloudProfileMock,
exchangeOrcaCloudAuthCode: exchangeOrcaCloudAuthCodeMock,
refreshOrcaCloudCapabilities: refreshOrcaCloudCapabilitiesMock,
refreshOrcaCloudSession: refreshOrcaCloudSessionMock,
revokeOrcaCloudSession: vi.fn(),
selectOrcaCloudOrg: vi.fn()
}))
import {
connectCurrentOrcaProfile,
createCloudLinkedOrcaProfile,
getCurrentOrcaProfileAuthStatus,
refreshCurrentOrcaProfileAuth
} from './profile-cloud-service'
const cloudSummary: OrcaProfileCloudSummary = {
cloudProfileId: 'cloud-profile-1',
userId: 'user-1',
email: 'nina@example.com',
displayName: 'Nina',
linkedAt: 10
}
const capabilities: OrcaCloudCapabilities = {
flags: { share: true },
refreshedAt: 11
}
const organizations: OrcaCloudOrgSummary[] = [
{ orgId: 'org-1', name: 'Acme', role: 'Admin' },
{ orgId: 'org-2', name: 'Personal' }
]
function futureExpiresAt(): number {
return Date.now() + 3_600_000
}
function configureCloudEnv(): void {
vi.stubEnv('ORCA_CLOUD_API_URL', 'https://orca-cloud.example')
vi.stubEnv('ORCA_CLOUD_CLIENT_ID', 'desktop-client')
}
function mockSuccessfulConnect(expiresAt = futureExpiresAt()): void {
beginOrcaCloudPkceFlowMock.mockResolvedValue({
code: 'auth-code',
codeVerifier: 'code-verifier',
nonce: 'nonce',
redirectUri: 'http://127.0.0.1:4100/auth/callback',
state: 'state'
})
exchangeOrcaCloudAuthCodeMock.mockResolvedValue({
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresAt,
cloud: cloudSummary,
organizations,
capabilities
} satisfies OrcaCloudSessionExchangeResponse)
}
describe('Orca cloud profile service session refresh', () => {
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'orca-cloud-service-refresh-'))
beginOrcaCloudPkceFlowMock.mockReset()
createOrcaCloudProfileMock.mockReset()
exchangeOrcaCloudAuthCodeMock.mockReset()
refreshOrcaCloudCapabilitiesMock.mockReset()
refreshOrcaCloudSessionMock.mockReset()
safeStorageMock.decryptString.mockReset()
safeStorageMock.encryptString.mockReset()
safeStorageMock.isEncryptionAvailable.mockReset()
safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf-8'))
safeStorageMock.encryptString.mockImplementation((value: string) =>
Buffer.from(value, 'utf-8')
)
safeStorageMock.isEncryptionAvailable.mockReturnValue(true)
vi.unstubAllEnvs()
vi.stubEnv('ORCA_CLOUD_API_URL', '')
vi.stubEnv('ORCA_CLOUD_CLIENT_ID', '')
})
afterEach(() => {
rmSync(userDataPath, { recursive: true, force: true })
vi.unstubAllEnvs()
})
it('refreshes an expired access token before creating cloud profiles', async () => {
configureCloudEnv()
mockSuccessfulConnect(Date.now() - 1_000)
await connectCurrentOrcaProfile(userDataPath)
refreshOrcaCloudSessionMock.mockResolvedValue({
accessToken: 'rotated-access-token',
refreshToken: 'rotated-refresh-token',
expiresAt: futureExpiresAt(),
cloud: cloudSummary,
organizations,
capabilities
} satisfies OrcaCloudSessionExchangeResponse)
createOrcaCloudProfileMock.mockResolvedValue({
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
expiresAt: futureExpiresAt(),
cloud: {
...cloudSummary,
cloudProfileId: 'cloud-profile-2',
activeOrgId: 'org-1',
activeOrgName: 'Acme'
},
organizations,
capabilities
} satisfies OrcaCloudSessionExchangeResponse)
const result = await createCloudLinkedOrcaProfile(userDataPath, {
orgId: 'org-1',
name: 'Acme'
})
expect(result.status).toBe('created')
expect(refreshOrcaCloudSessionMock).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ refreshToken: 'refresh-token' })
)
expect(createOrcaCloudProfileMock).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ accessToken: 'rotated-access-token' }),
{ orgId: 'org-1', name: 'Acme' }
)
})
it('refreshes capability flags for the connected profile', async () => {
configureCloudEnv()
mockSuccessfulConnect()
await connectCurrentOrcaProfile(userDataPath)
refreshOrcaCloudCapabilitiesMock.mockResolvedValue({
capabilities: {
flags: { share: false, team: true },
refreshedAt: 25
}
})
const result = await refreshCurrentOrcaProfileAuth(userDataPath)
expect(result.status).toBe('refreshed')
expect(refreshOrcaCloudCapabilitiesMock).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ accessToken: 'access-token' })
)
expect(getCurrentOrcaProfileAuthStatus(userDataPath).capabilities).toEqual({
flags: { share: false, team: true },
refreshedAt: 25
})
})
it('clears stale active org metadata when capability refresh returns no active org', async () => {
configureCloudEnv()
mockSuccessfulConnect()
exchangeOrcaCloudAuthCodeMock.mockResolvedValue({
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresAt: futureExpiresAt(),
cloud: { ...cloudSummary, activeOrgId: 'org-1', activeOrgName: 'Acme' },
organizations,
capabilities
} satisfies OrcaCloudSessionExchangeResponse)
await connectCurrentOrcaProfile(userDataPath)
refreshOrcaCloudCapabilitiesMock.mockResolvedValue({
cloud: cloudSummary,
organizations: [],
capabilities: {
flags: { share: false },
refreshedAt: 31
}
})
const result = await refreshCurrentOrcaProfileAuth(userDataPath)
const status = getCurrentOrcaProfileAuthStatus(userDataPath)
expect(result.status).toBe('refreshed')
expect(status.cloud?.activeOrgId).toBeUndefined()
expect(status.cloud?.activeOrgName).toBeUndefined()
expect(status.organizations).toEqual([])
expect(status.capabilities).toEqual({
flags: { share: false },
refreshedAt: 31
})
})
it('requires reconnect when an expired refresh token is rejected', async () => {
configureCloudEnv()
mockSuccessfulConnect(Date.now() - 1_000)
await connectCurrentOrcaProfile(userDataPath)
refreshOrcaCloudSessionMock.mockRejectedValue(new OrcaCloudRequestErrorMock(401))
const result = await refreshCurrentOrcaProfileAuth(userDataPath)
expect(result.status).toBe('reconnect-required')
expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({
state: 'reconnect-required',
persistence: 'none',
cloud: cloudSummary
})
})
})
@@ -0,0 +1,287 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type {
OrcaCloudCapabilities,
OrcaCloudOrgSummary,
OrcaProfileCloudSummary
} from '../../shared/orca-profiles'
import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange'
const {
beginOrcaCloudPkceFlowMock,
createOrcaCloudProfileMock,
exchangeOrcaCloudAuthCodeMock,
revokeOrcaCloudSessionMock,
selectOrcaCloudOrgMock,
safeStorageMock
} = vi.hoisted(() => ({
beginOrcaCloudPkceFlowMock: vi.fn(),
createOrcaCloudProfileMock: vi.fn(),
exchangeOrcaCloudAuthCodeMock: vi.fn(),
revokeOrcaCloudSessionMock: vi.fn(),
selectOrcaCloudOrgMock: vi.fn(),
safeStorageMock: {
decryptString: vi.fn((value: Buffer) => value.toString('utf-8')),
encryptString: vi.fn((value: string) => Buffer.from(value, 'utf-8')),
isEncryptionAvailable: vi.fn(() => true)
}
}))
let userDataPath = ''
vi.mock('electron', () => ({
app: {
getPath: () => userDataPath
},
safeStorage: safeStorageMock
}))
vi.mock('./profile-cloud-pkce', () => ({
beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock
}))
vi.mock('./profile-cloud-client', () => ({
createOrcaCloudProfile: createOrcaCloudProfileMock,
exchangeOrcaCloudAuthCode: exchangeOrcaCloudAuthCodeMock,
revokeOrcaCloudSession: revokeOrcaCloudSessionMock,
selectOrcaCloudOrg: selectOrcaCloudOrgMock
}))
import {
connectCurrentOrcaProfile,
createCloudLinkedOrcaProfile,
getCurrentOrcaProfileAuthStatus,
selectCurrentOrcaProfileOrg,
signOutCurrentOrcaProfile
} from './profile-cloud-service'
const cloudSummary: OrcaProfileCloudSummary = {
cloudProfileId: 'cloud-profile-1',
userId: 'user-1',
email: 'nina@example.com',
displayName: 'Nina',
linkedAt: 10
}
const capabilities: OrcaCloudCapabilities = {
flags: { share: true },
refreshedAt: 11
}
const organizations: OrcaCloudOrgSummary[] = [
{ orgId: 'org-1', name: 'Acme', role: 'Admin' },
{ orgId: 'org-2', name: 'Personal' }
]
function configureCloudEnv(): void {
vi.stubEnv('ORCA_CLOUD_API_URL', 'https://orca-cloud.example')
vi.stubEnv('ORCA_CLOUD_CLIENT_ID', 'desktop-client')
}
function futureExpiresAt(): number {
return Date.now() + 3_600_000
}
function mockSuccessfulConnect(expiresAt = futureExpiresAt()): void {
beginOrcaCloudPkceFlowMock.mockResolvedValue({
code: 'auth-code',
codeVerifier: 'code-verifier',
nonce: 'nonce',
redirectUri: 'http://127.0.0.1:4100/auth/callback',
state: 'state'
})
exchangeOrcaCloudAuthCodeMock.mockResolvedValue({
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresAt,
cloud: cloudSummary,
organizations,
capabilities
} satisfies OrcaCloudSessionExchangeResponse)
}
describe('Orca cloud profile service', () => {
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'orca-cloud-service-'))
beginOrcaCloudPkceFlowMock.mockReset()
createOrcaCloudProfileMock.mockReset()
exchangeOrcaCloudAuthCodeMock.mockReset()
revokeOrcaCloudSessionMock.mockReset()
selectOrcaCloudOrgMock.mockReset()
safeStorageMock.decryptString.mockReset()
safeStorageMock.encryptString.mockReset()
safeStorageMock.isEncryptionAvailable.mockReset()
safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf-8'))
safeStorageMock.encryptString.mockImplementation((value: string) =>
Buffer.from(value, 'utf-8')
)
safeStorageMock.isEncryptionAvailable.mockReturnValue(true)
revokeOrcaCloudSessionMock.mockResolvedValue(undefined)
vi.unstubAllEnvs()
vi.stubEnv('ORCA_CLOUD_API_URL', '')
vi.stubEnv('ORCA_CLOUD_CLIENT_ID', '')
})
afterEach(() => {
rmSync(userDataPath, { recursive: true, force: true })
vi.unstubAllEnvs()
})
it('reports local unconfigured auth without cloud setup', () => {
expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({
activeProfileId: 'local-default',
configured: false,
state: 'unconfigured',
persistence: 'none'
})
})
it('connects the active local profile without replacing its local profile ID', async () => {
configureCloudEnv()
mockSuccessfulConnect()
const result = await connectCurrentOrcaProfile(userDataPath)
if (result.status !== 'connected') {
throw new Error(`Expected connected result, got ${result.status}`)
}
expect(result.activeProfileId).toBe('local-default')
expect(result.profiles[0]).toMatchObject({
id: 'local-default',
kind: 'cloud-linked',
cloud: cloudSummary
})
expect(exchangeOrcaCloudAuthCodeMock).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ localProfileId: 'local-default', nonce: 'nonce' })
)
expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({
state: 'connected',
persistence: 'encrypted',
cloud: cloudSummary,
organizations,
capabilities
})
})
it('treats provider-denied sign-in as a cancelled connect attempt', async () => {
configureCloudEnv()
beginOrcaCloudPkceFlowMock.mockRejectedValue(new Error('orca_cloud_auth_denied'))
const result = await connectCurrentOrcaProfile(userDataPath)
expect(result.status).toBe('cancelled')
expect(exchangeOrcaCloudAuthCodeMock).not.toHaveBeenCalled()
expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({
state: 'local',
persistence: 'none'
})
})
it('does not report a saved cloud session as connected when cloud config is unavailable', async () => {
configureCloudEnv()
mockSuccessfulConnect()
await connectCurrentOrcaProfile(userDataPath)
vi.stubEnv('ORCA_CLOUD_API_URL', '')
vi.stubEnv('ORCA_CLOUD_CLIENT_ID', '')
expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({
configured: false,
state: 'unconfigured',
persistence: 'encrypted',
cloud: cloudSummary,
setupMessage: 'Orca Cloud sign-in is not configured for this build.'
})
expect(getCurrentOrcaProfileAuthStatus(userDataPath).organizations).toBeUndefined()
expect(getCurrentOrcaProfileAuthStatus(userDataPath).capabilities).toBeUndefined()
})
it('signs out by removing cloud metadata while keeping the local profile', async () => {
configureCloudEnv()
mockSuccessfulConnect()
await connectCurrentOrcaProfile(userDataPath)
const result = await signOutCurrentOrcaProfile(userDataPath)
expect(result.status).toBe('signed-out')
expect(result.activeProfileId).toBe('local-default')
expect(result.profiles[0]).toMatchObject({ id: 'local-default', kind: 'local' })
expect(result.profiles[0]?.cloud).toBeUndefined()
expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({
state: 'local',
persistence: 'none'
})
expect(revokeOrcaCloudSessionMock).toHaveBeenCalledOnce()
})
it('creates a new empty cloud-linked profile with its own cloud session', async () => {
configureCloudEnv()
mockSuccessfulConnect()
await connectCurrentOrcaProfile(userDataPath)
createOrcaCloudProfileMock.mockResolvedValue({
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
expiresAt: 1000,
cloud: {
...cloudSummary,
cloudProfileId: 'cloud-profile-2',
activeOrgId: 'org-1',
activeOrgName: 'Acme'
},
organizations,
capabilities: { flags: { share: true, team: true }, refreshedAt: 13 }
} satisfies OrcaCloudSessionExchangeResponse)
const result = await createCloudLinkedOrcaProfile(userDataPath, {
orgId: 'org-1',
name: 'Acme'
})
if (result.status !== 'created') {
throw new Error(`Expected created result, got ${result.status}`)
}
expect(result.profile).toMatchObject({
id: expect.stringMatching(/^cloud-/),
name: 'Acme',
kind: 'cloud-linked',
cloud: expect.objectContaining({ cloudProfileId: 'cloud-profile-2' })
})
expect(createOrcaCloudProfileMock).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ accessToken: 'access-token' }),
{ orgId: 'org-1', name: 'Acme' }
)
})
it('selects an organization for a connected profile', async () => {
configureCloudEnv()
mockSuccessfulConnect()
await connectCurrentOrcaProfile(userDataPath)
const orgCloudSummary = {
...cloudSummary,
activeOrgId: 'org-1',
activeOrgName: 'Acme'
}
selectOrcaCloudOrgMock.mockResolvedValue({
cloud: orgCloudSummary,
organizations,
capabilities: { flags: { share: true, sso: true }, refreshedAt: 12 }
})
const result = await selectCurrentOrcaProfileOrg(userDataPath, 'org-1')
expect(result.status).toBe('selected')
expect(selectOrcaCloudOrgMock).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ accessToken: 'access-token' }),
'org-1'
)
expect(getCurrentOrcaProfileAuthStatus(userDataPath).cloud).toMatchObject({
activeOrgId: 'org-1',
activeOrgName: 'Acme'
})
expect(getCurrentOrcaProfileAuthStatus(userDataPath).organizations).toEqual(organizations)
})
})
@@ -0,0 +1,300 @@
import type {
ConnectCurrentOrcaProfileResult,
CreateCloudLinkedOrcaProfileArgs,
CreateCloudLinkedOrcaProfileResult,
OrcaProfileAuthStatus,
RefreshCurrentOrcaProfileAuthResult,
SelectOrcaProfileOrgResult,
SignOutCurrentOrcaProfileResult
} from '../../shared/orca-profiles'
import { ensureActiveOrcaProfile, getOrcaProfileListState } from './profile-index-store'
import { getOrcaCloudAuthConfig, isOrcaCloudDevAuthEnabled } from './profile-cloud-auth-config'
import {
clearOrcaCloudSession,
readOrcaCloudSession,
saveOrcaCloudSession,
saveOrcaCloudSessionExchange
} from './profile-cloud-session-store'
import {
createOrcaCloudProfile,
exchangeOrcaCloudAuthCode,
refreshOrcaCloudCapabilities,
revokeOrcaCloudSession,
selectOrcaCloudOrg
} from './profile-cloud-client'
import { beginOrcaCloudPkceFlow } from './profile-cloud-pkce'
import {
createCloudLinkedOrcaProfileRecord,
linkOrcaProfileToCloud,
unlinkOrcaProfileFromCloud
} from './profile-cloud-index'
import { runWithFreshOrcaCloudSession } from './profile-cloud-session-refresh'
import {
connectDevOrcaCloudProfile,
createDevCloudLinkedOrcaProfile,
refreshDevOrcaCloudProfile,
selectDevOrcaCloudOrg
} from './profile-cloud-dev-service'
import { getOrcaProfileAuthStatusFromProfile } from './profile-cloud-auth-status'
function isUserCancelledAuthError(message: string): boolean {
return message === 'orca_cloud_auth_timeout' || message === 'orca_cloud_auth_denied'
}
function activeAuth(
active: ReturnType<typeof ensureActiveOrcaProfile>,
userDataPath: string
): OrcaProfileAuthStatus {
return getOrcaProfileAuthStatusFromProfile(active, userDataPath)
}
export function getCurrentOrcaProfileAuthStatus(userDataPath: string): OrcaProfileAuthStatus {
return getOrcaProfileAuthStatusFromProfile(ensureActiveOrcaProfile(userDataPath), userDataPath)
}
export async function connectCurrentOrcaProfile(
userDataPath: string
): Promise<ConnectCurrentOrcaProfileResult> {
const active = ensureActiveOrcaProfile(userDataPath)
if (isOrcaCloudDevAuthEnabled()) {
const list = connectDevOrcaCloudProfile(active, userDataPath)
return {
status: 'connected',
auth: getCurrentOrcaProfileAuthStatus(userDataPath),
activeProfileId: list.activeProfileId,
profiles: list.profiles
}
}
const configState = getOrcaCloudAuthConfig()
if (!configState.configured) {
return {
status: 'unconfigured',
auth: activeAuth(active, userDataPath)
}
}
try {
const code = await beginOrcaCloudPkceFlow(configState.config, active.profile.id)
const exchange = await exchangeOrcaCloudAuthCode(configState.config, {
...code,
localProfileId: active.profile.id
})
saveOrcaCloudSessionExchange(active.profile.id, userDataPath, exchange)
const list = linkOrcaProfileToCloud(active.profile.id, exchange.cloud, userDataPath)
return {
status: 'connected',
auth: getCurrentOrcaProfileAuthStatus(userDataPath),
activeProfileId: list.activeProfileId,
profiles: list.profiles
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (isUserCancelledAuthError(message)) {
return {
status: 'cancelled',
auth: getCurrentOrcaProfileAuthStatus(userDataPath)
}
}
return {
status: 'failed',
auth: getCurrentOrcaProfileAuthStatus(userDataPath),
error: message
}
}
}
export async function signOutCurrentOrcaProfile(
userDataPath: string
): Promise<SignOutCurrentOrcaProfileResult> {
const active = ensureActiveOrcaProfile(userDataPath)
const configState = getOrcaCloudAuthConfig()
const session = readOrcaCloudSession(active.profile.id, userDataPath)
if (!isOrcaCloudDevAuthEnabled() && configState.configured && session.status === 'found') {
await revokeOrcaCloudSession(configState.config, session.session).catch(() => undefined)
}
clearOrcaCloudSession(active.profile.id, userDataPath)
const list = unlinkOrcaProfileFromCloud(active.profile.id, userDataPath)
return {
status: 'signed-out',
auth: getCurrentOrcaProfileAuthStatus(userDataPath),
activeProfileId: list.activeProfileId,
profiles: list.profiles
}
}
export async function createCloudLinkedOrcaProfile(
userDataPath: string,
args: CreateCloudLinkedOrcaProfileArgs
): Promise<CreateCloudLinkedOrcaProfileResult> {
const active = ensureActiveOrcaProfile(userDataPath)
if (isOrcaCloudDevAuthEnabled()) {
const result = createDevCloudLinkedOrcaProfile(active, userDataPath, args)
if (result.status !== 'created') {
return { status: 'reconnect-required', auth: activeAuth(active, userDataPath) }
}
return {
status: 'created',
auth: getCurrentOrcaProfileAuthStatus(userDataPath),
activeProfileId: result.list.activeProfileId,
profiles: result.list.profiles,
profile: result.list.profile
}
}
const configState = getOrcaCloudAuthConfig()
if (!configState.configured) {
return { status: 'unconfigured', auth: activeAuth(active, userDataPath) }
}
try {
const operation = await runWithFreshOrcaCloudSession(
configState.config,
active,
userDataPath,
(session) => createOrcaCloudProfile(configState.config, session, args)
)
if (operation.status !== 'ok') {
return { status: 'reconnect-required', auth: activeAuth(active, userDataPath) }
}
const created = operation.value
const list = createCloudLinkedOrcaProfileRecord(
created.cloud,
{ name: args.name },
userDataPath
)
saveOrcaCloudSessionExchange(list.profile.id, userDataPath, created)
return {
status: 'created',
auth: getCurrentOrcaProfileAuthStatus(userDataPath),
activeProfileId: list.activeProfileId,
profiles: list.profiles,
profile: list.profile
}
} catch (error) {
return {
status: 'failed',
auth: getCurrentOrcaProfileAuthStatus(userDataPath),
error: error instanceof Error ? error.message : String(error)
}
}
}
export async function refreshCurrentOrcaProfileAuth(
userDataPath: string
): Promise<RefreshCurrentOrcaProfileAuthResult> {
const active = ensureActiveOrcaProfile(userDataPath)
if (!active.profile.cloud) {
return { status: 'local', auth: activeAuth(active, userDataPath) }
}
if (isOrcaCloudDevAuthEnabled()) {
const result = refreshDevOrcaCloudProfile(active, userDataPath)
if (result.status !== 'updated') {
return { status: 'reconnect-required', auth: getCurrentOrcaProfileAuthStatus(userDataPath) }
}
return {
status: 'refreshed',
auth: getCurrentOrcaProfileAuthStatus(userDataPath),
activeProfileId: result.list.activeProfileId,
profiles: result.list.profiles
}
}
const configState = getOrcaCloudAuthConfig()
if (!configState.configured) {
return { status: 'unconfigured', auth: activeAuth(active, userDataPath) }
}
try {
const operation = await runWithFreshOrcaCloudSession(
configState.config,
active,
userDataPath,
(session) => refreshOrcaCloudCapabilities(configState.config, session)
)
if (operation.status !== 'ok') {
return { status: 'reconnect-required', auth: getCurrentOrcaProfileAuthStatus(userDataPath) }
}
const refresh = operation.value
const session = readOrcaCloudSession(active.profile.id, userDataPath)
if (session.status !== 'found') {
return { status: 'reconnect-required', auth: getCurrentOrcaProfileAuthStatus(userDataPath) }
}
saveOrcaCloudSession(active.profile.id, userDataPath, {
...session.session,
organizations: refresh.organizations ?? session.session.organizations,
capabilities: refresh.capabilities
})
const list = refresh.cloud
? linkOrcaProfileToCloud(active.profile.id, refresh.cloud, userDataPath)
: getOrcaProfileListState(userDataPath)
return {
status: 'refreshed',
auth: getCurrentOrcaProfileAuthStatus(userDataPath),
activeProfileId: list.activeProfileId,
profiles: list.profiles
}
} catch (error) {
return {
status: 'failed',
auth: getCurrentOrcaProfileAuthStatus(userDataPath),
error: error instanceof Error ? error.message : String(error)
}
}
}
export async function selectCurrentOrcaProfileOrg(
userDataPath: string,
orgId: string
): Promise<SelectOrcaProfileOrgResult> {
const active = ensureActiveOrcaProfile(userDataPath)
if (isOrcaCloudDevAuthEnabled()) {
const result = selectDevOrcaCloudOrg(active, userDataPath, orgId)
if (result.status !== 'updated') {
return { status: 'reconnect-required', auth: activeAuth(active, userDataPath) }
}
return {
status: 'selected',
auth: getCurrentOrcaProfileAuthStatus(userDataPath),
activeProfileId: result.list.activeProfileId,
profiles: result.list.profiles
}
}
const configState = getOrcaCloudAuthConfig()
if (!configState.configured) {
return { status: 'unconfigured', auth: activeAuth(active, userDataPath) }
}
try {
const operation = await runWithFreshOrcaCloudSession(
configState.config,
active,
userDataPath,
(session) => selectOrcaCloudOrg(configState.config, session, orgId)
)
if (operation.status !== 'ok') {
return { status: 'reconnect-required', auth: activeAuth(active, userDataPath) }
}
const selected = operation.value
const session = readOrcaCloudSession(active.profile.id, userDataPath)
if (session.status !== 'found') {
return { status: 'reconnect-required', auth: activeAuth(active, userDataPath) }
}
saveOrcaCloudSession(active.profile.id, userDataPath, {
...session.session,
organizations: selected.organizations ?? session.session.organizations,
capabilities: selected.capabilities
})
const list = linkOrcaProfileToCloud(active.profile.id, selected.cloud, userDataPath)
return {
status: 'selected',
auth: getCurrentOrcaProfileAuthStatus(userDataPath),
activeProfileId: list.activeProfileId,
profiles: list.profiles
}
} catch (error) {
return {
status: 'failed',
auth: getCurrentOrcaProfileAuthStatus(userDataPath),
error: error instanceof Error ? error.message : String(error)
}
}
}
@@ -0,0 +1,14 @@
import type {
OrcaCloudCapabilities,
OrcaCloudOrgSummary,
OrcaProfileCloudSummary
} from '../../shared/orca-profiles'
export type OrcaCloudSessionExchangeResponse = {
accessToken: string
refreshToken: string
expiresAt: number
cloud: OrcaProfileCloudSummary
organizations?: OrcaCloudOrgSummary[]
capabilities: OrcaCloudCapabilities
}
@@ -0,0 +1,178 @@
import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config'
import type { ActiveOrcaProfileState } from './profile-index-store'
import {
clearOrcaCloudSession,
type OrcaCloudSession,
readOrcaCloudSession,
saveOrcaCloudSession
} from './profile-cloud-session-store'
import { OrcaCloudRequestError, refreshOrcaCloudSession } from './profile-cloud-client'
import { linkOrcaProfileToCloud } from './profile-cloud-index'
const CLOUD_SESSION_REFRESH_SKEW_MS = 60_000
export type FreshCloudSessionResult =
| { status: 'found'; session: OrcaCloudSession }
| { status: 'reconnect-required' }
export type CloudSessionOperationResult<T> =
| { status: 'ok'; value: T }
| { status: 'reconnect-required' }
function shouldRefreshCloudSession(session: OrcaCloudSession, now = Date.now()): boolean {
return session.expiresAt <= now + CLOUD_SESSION_REFRESH_SKEW_MS
}
export function isOrcaCloudAuthFailure(error: unknown): boolean {
return (
error instanceof OrcaCloudRequestError && (error.statusCode === 401 || error.statusCode === 403)
)
}
const inflightCloudSessionRefreshes = new Map<string, Promise<OrcaCloudSession>>()
function cloudSessionRefreshKey(profileId: string, userDataPath: string): string {
return `${userDataPath}\0${profileId}`
}
// Why: with refresh-token rotation, only the session that actually failed may
// clear the store; otherwise a loser of a concurrent refresh race would wipe
// the winner's freshly rotated session.
function clearCloudSessionIfUnchanged(
profileId: string,
userDataPath: string,
failed: OrcaCloudSession
): void {
const current = readOrcaCloudSession(profileId, userDataPath)
if (current.status === 'found' && current.session.refreshToken !== failed.refreshToken) {
return
}
clearOrcaCloudSession(profileId, userDataPath)
}
async function refreshStoredCloudSession(
config: OrcaCloudAuthConfig,
active: ActiveOrcaProfileState,
userDataPath: string,
session: OrcaCloudSession
): Promise<OrcaCloudSession> {
// Why: refresh tokens rotate, so concurrent refreshes must single-flight;
// a second POST with the same refresh token can trip server reuse detection
// and revoke the whole token family.
const key = cloudSessionRefreshKey(active.profile.id, userDataPath)
const inflight = inflightCloudSessionRefreshes.get(key)
if (inflight) {
return inflight
}
const task = (async () => {
const current = readOrcaCloudSession(active.profile.id, userDataPath)
if (current.status === 'found' && current.session.refreshToken !== session.refreshToken) {
// Another caller already rotated this session; reuse its result.
return current.session
}
const refreshed = await refreshOrcaCloudSession(config, session)
const nextSession = {
accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken,
expiresAt: refreshed.expiresAt,
organizations: refreshed.organizations,
capabilities: refreshed.capabilities
}
saveOrcaCloudSession(active.profile.id, userDataPath, nextSession)
linkOrcaProfileToCloud(active.profile.id, refreshed.cloud, userDataPath)
return nextSession
})()
inflightCloudSessionRefreshes.set(key, task)
try {
return await task
} finally {
inflightCloudSessionRefreshes.delete(key)
}
}
export async function readFreshOrcaCloudSession(
config: OrcaCloudAuthConfig,
active: ActiveOrcaProfileState,
userDataPath: string
): Promise<FreshCloudSessionResult> {
const session = readOrcaCloudSession(active.profile.id, userDataPath)
if (session.status !== 'found') {
return { status: 'reconnect-required' }
}
if (!shouldRefreshCloudSession(session.session)) {
return { status: 'found', session: session.session }
}
try {
return {
status: 'found',
session: await refreshStoredCloudSession(config, active, userDataPath, session.session)
}
} catch (error) {
if (isOrcaCloudAuthFailure(error)) {
clearCloudSessionIfUnchanged(active.profile.id, userDataPath, session.session)
return { status: 'reconnect-required' }
}
throw error
}
}
export async function forceRefreshOrcaCloudSession(
config: OrcaCloudAuthConfig,
active: ActiveOrcaProfileState,
userDataPath: string,
session: OrcaCloudSession
): Promise<FreshCloudSessionResult> {
try {
return {
status: 'found',
session: await refreshStoredCloudSession(config, active, userDataPath, session)
}
} catch (error) {
if (isOrcaCloudAuthFailure(error)) {
clearCloudSessionIfUnchanged(active.profile.id, userDataPath, session)
return { status: 'reconnect-required' }
}
throw error
}
}
export async function runWithFreshOrcaCloudSession<T>(
config: OrcaCloudAuthConfig,
active: ActiveOrcaProfileState,
userDataPath: string,
operation: (session: OrcaCloudSession) => Promise<T>
): Promise<CloudSessionOperationResult<T>> {
const session = await readFreshOrcaCloudSession(config, active, userDataPath)
if (session.status !== 'found') {
return { status: 'reconnect-required' }
}
try {
return { status: 'ok', value: await operation(session.session) }
} catch (error) {
if (!isOrcaCloudAuthFailure(error)) {
throw error
}
const refreshed = await forceRefreshOrcaCloudSession(
config,
active,
userDataPath,
session.session
)
if (refreshed.status !== 'found') {
return { status: 'reconnect-required' }
}
try {
return { status: 'ok', value: await operation(refreshed.session) }
} catch (retryError) {
// Why: a 401 after a successful refresh means the session itself is
// rejected. A 403 is an authorization (permission) failure — signing
// the user out for it would destroy a valid session, so let it surface
// as a failed operation instead.
if (retryError instanceof OrcaCloudRequestError && retryError.statusCode === 401) {
clearCloudSessionIfUnchanged(active.profile.id, userDataPath, refreshed.session)
return { status: 'reconnect-required' }
}
throw retryError
}
}
}
@@ -0,0 +1,172 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { OrcaCloudSession } from './profile-cloud-session-store'
const safeStorageMock = vi.hoisted(() => ({
decryptString: vi.fn((value: Buffer) => value.toString('utf-8')),
encryptString: vi.fn((value: string) => Buffer.from(value, 'utf-8')),
isEncryptionAvailable: vi.fn(() => true)
}))
let userDataPath = ''
vi.mock('electron', () => ({
app: {
getPath: () => userDataPath
},
safeStorage: safeStorageMock
}))
async function loadSessionStore() {
vi.resetModules()
return import('./profile-cloud-session-store')
}
function makeSession(): OrcaCloudSession {
return {
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresAt: 9_999,
organizations: [
{ orgId: 'org-1', name: 'Acme', role: 'Admin' },
{ orgId: 'org-2', name: 'Personal' }
],
capabilities: {
flags: { share: true },
refreshedAt: 123
}
}
}
function writePlaintextSessionFile(profileId: string, session: OrcaCloudSession): void {
const profileDirectory = join(userDataPath, 'profiles', profileId)
mkdirSync(profileDirectory, { recursive: true })
writeFileSync(
join(profileDirectory, 'account-session.json.enc'),
JSON.stringify(
{
version: 1,
format: 'dev-plaintext-v1',
savedAt: 1,
session
},
null,
2
),
'utf-8'
)
}
describe('Orca cloud session store', () => {
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'orca-cloud-session-'))
vi.unstubAllEnvs()
safeStorageMock.decryptString.mockClear()
safeStorageMock.encryptString.mockClear()
safeStorageMock.isEncryptionAvailable.mockClear()
safeStorageMock.isEncryptionAvailable.mockReturnValue(true)
})
afterEach(() => {
rmSync(userDataPath, { recursive: true, force: true })
vi.unstubAllEnvs()
})
it('persists encrypted sessions and reports encrypted persistence from memory and disk', async () => {
const store = await loadSessionStore()
const session = makeSession()
expect(store.saveOrcaCloudSession('profile-1', userDataPath, session)).toBe('encrypted')
expect(store.readOrcaCloudSession('profile-1', userDataPath)).toEqual({
status: 'found',
session,
persistence: 'encrypted'
})
const reloaded = await loadSessionStore()
expect(reloaded.readOrcaCloudSession('profile-1', userDataPath)).toEqual({
status: 'found',
session,
persistence: 'encrypted'
})
})
it('falls back to memory-only when encryption is unavailable and plaintext is not allowed', async () => {
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
const store = await loadSessionStore()
const session = makeSession()
expect(store.saveOrcaCloudSession('profile-1', userDataPath, session)).toBe('memory-only')
expect(store.readOrcaCloudSession('profile-1', userDataPath)).toEqual({
status: 'found',
session,
persistence: 'memory-only'
})
const reloaded = await loadSessionStore()
expect(reloaded.readOrcaCloudSession('profile-1', userDataPath)).toEqual({
status: 'missing',
persistence: 'none'
})
})
it('scopes memory-only sessions by user-data path and profile ID', async () => {
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
const otherUserDataPath = mkdtempSync(join(tmpdir(), 'orca-cloud-session-other-'))
const store = await loadSessionStore()
const session = makeSession()
const otherSession = { ...session, accessToken: 'other-access-token' }
try {
store.saveOrcaCloudSession('local-default', userDataPath, session)
store.saveOrcaCloudSession('local-default', otherUserDataPath, otherSession)
expect(store.readOrcaCloudSession('local-default', userDataPath)).toMatchObject({
status: 'found',
session
})
expect(store.readOrcaCloudSession('local-default', otherUserDataPath)).toMatchObject({
status: 'found',
session: otherSession
})
} finally {
rmSync(otherUserDataPath, { recursive: true, force: true })
}
})
it('writes explicit dev plaintext only when the dev escape hatch is enabled', async () => {
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
vi.stubEnv('ORCA_CLOUD_ALLOW_PLAINTEXT_SESSION', '1')
vi.stubEnv('NODE_ENV', 'development')
const store = await loadSessionStore()
const session = makeSession()
expect(store.saveOrcaCloudSession('profile-1', userDataPath, session)).toBe('dev-plaintext')
const saved = JSON.parse(
readFileSync(store.getOrcaCloudSessionPath('profile-1', userDataPath), 'utf-8')
) as { format: string }
expect(saved.format).toBe('dev-plaintext-v1')
const reloaded = await loadSessionStore()
expect(reloaded.readOrcaCloudSession('profile-1', userDataPath)).toEqual({
status: 'found',
session,
persistence: 'dev-plaintext'
})
})
it('rejects dev plaintext files when the escape hatch is disabled', async () => {
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
writePlaintextSessionFile('profile-1', makeSession())
const store = await loadSessionStore()
expect(store.readOrcaCloudSession('profile-1', userDataPath)).toEqual({
status: 'decrypt-failed',
persistence: 'none',
error: 'Unsafe session format.'
})
})
})
@@ -0,0 +1,209 @@
import { existsSync, readFileSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { safeStorage } from 'electron'
import { writeSecureJsonFile } from '../../shared/secure-file'
import type {
OrcaCloudCapabilities,
OrcaCloudOrgSummary,
OrcaCloudSessionPersistence
} from '../../shared/orca-profiles'
import { getOrcaProfileDirectory } from './profile-storage-paths'
import { allowsPlaintextOrcaCloudSession } from './profile-cloud-auth-config'
import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange'
export type OrcaCloudSession = {
accessToken: string
refreshToken: string
expiresAt: number
capabilities: OrcaCloudCapabilities
organizations?: OrcaCloudOrgSummary[]
}
export type OrcaCloudSessionReadResult =
| { status: 'found'; session: OrcaCloudSession; persistence: OrcaCloudSessionPersistence }
| { status: 'missing'; persistence: 'none' }
| { status: 'decrypt-failed'; persistence: 'none'; error: string }
type PersistedEncryptedSession = {
version: 1
format: 'electron-safe-storage-v1'
savedAt: number
ciphertext: string
}
type PersistedPlaintextSession = {
version: 1
format: 'dev-plaintext-v1'
savedAt: number
session: OrcaCloudSession
}
type CachedOrcaCloudSession = {
session: OrcaCloudSession
persistence: Exclude<OrcaCloudSessionPersistence, 'none'>
}
const memorySessions = new Map<string, CachedOrcaCloudSession>()
function sessionCacheKey(profileId: string, userDataPath: string): string {
return `${userDataPath}\0${profileId}`
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isOrcaCloudSession(value: unknown): value is OrcaCloudSession {
if (!isObject(value) || !isObject(value.capabilities) || !isObject(value.capabilities.flags)) {
return false
}
if (value.organizations !== undefined && !isOrcaCloudOrganizations(value.organizations)) {
return false
}
return (
typeof value.accessToken === 'string' &&
value.accessToken.length > 0 &&
typeof value.refreshToken === 'string' &&
value.refreshToken.length > 0 &&
typeof value.expiresAt === 'number' &&
Number.isFinite(value.expiresAt) &&
typeof value.capabilities.refreshedAt === 'number' &&
Number.isFinite(value.capabilities.refreshedAt)
)
}
function isOrcaCloudOrganizations(value: unknown): value is OrcaCloudOrgSummary[] {
if (!Array.isArray(value)) {
return false
}
return value.every((organization) => {
if (!isObject(organization)) {
return false
}
return (
typeof organization.orgId === 'string' &&
organization.orgId.length > 0 &&
typeof organization.name === 'string' &&
organization.name.length > 0 &&
(organization.role === undefined || typeof organization.role === 'string')
)
})
}
export function getOrcaCloudSessionPath(profileId: string, userDataPath: string): string {
return join(getOrcaProfileDirectory(profileId, userDataPath), 'account-session.json.enc')
}
export function saveOrcaCloudSession(
profileId: string,
userDataPath: string,
session: OrcaCloudSession
): OrcaCloudSessionPersistence {
const cacheKey = sessionCacheKey(profileId, userDataPath)
if (safeStorage.isEncryptionAvailable()) {
const encrypted: PersistedEncryptedSession = {
version: 1,
format: 'electron-safe-storage-v1',
savedAt: Date.now(),
ciphertext: safeStorage.encryptString(JSON.stringify(session)).toString('base64')
}
writeSecureJsonFile(getOrcaCloudSessionPath(profileId, userDataPath), encrypted)
memorySessions.set(cacheKey, { session, persistence: 'encrypted' })
return 'encrypted'
}
if (allowsPlaintextOrcaCloudSession()) {
const plaintext: PersistedPlaintextSession = {
version: 1,
format: 'dev-plaintext-v1',
savedAt: Date.now(),
session
}
writeSecureJsonFile(getOrcaCloudSessionPath(profileId, userDataPath), plaintext)
memorySessions.set(cacheKey, { session, persistence: 'dev-plaintext' })
return 'dev-plaintext'
}
// Why: Orca account refresh tokens must not silently fall back to plaintext
// in production. Memory-only keeps cloud features usable until restart.
memorySessions.set(cacheKey, { session, persistence: 'memory-only' })
return 'memory-only'
}
export function saveOrcaCloudSessionExchange(
profileId: string,
userDataPath: string,
exchange: OrcaCloudSessionExchangeResponse
): OrcaCloudSessionPersistence {
return saveOrcaCloudSession(profileId, userDataPath, {
accessToken: exchange.accessToken,
refreshToken: exchange.refreshToken,
expiresAt: exchange.expiresAt,
organizations: exchange.organizations,
capabilities: exchange.capabilities
})
}
export function readOrcaCloudSession(
profileId: string,
userDataPath: string
): OrcaCloudSessionReadResult {
const cacheKey = sessionCacheKey(profileId, userDataPath)
const memorySession = memorySessions.get(cacheKey)
if (memorySession) {
return {
status: 'found',
session: memorySession.session,
persistence: memorySession.persistence
}
}
const path = getOrcaCloudSessionPath(profileId, userDataPath)
if (!existsSync(path)) {
return { status: 'missing', persistence: 'none' }
}
try {
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as
| PersistedEncryptedSession
| PersistedPlaintextSession
if (parsed.version !== 1) {
return { status: 'decrypt-failed', persistence: 'none', error: 'Unsupported session format.' }
}
if (parsed.format === 'electron-safe-storage-v1') {
if (!safeStorage.isEncryptionAvailable()) {
return {
status: 'decrypt-failed',
persistence: 'none',
error: 'OS-backed encryption is unavailable.'
}
}
const decrypted = safeStorage.decryptString(Buffer.from(parsed.ciphertext, 'base64'))
const session = JSON.parse(decrypted) as OrcaCloudSession
if (!isOrcaCloudSession(session)) {
return { status: 'decrypt-failed', persistence: 'none', error: 'Invalid saved session.' }
}
memorySessions.set(cacheKey, { session, persistence: 'encrypted' })
return { status: 'found', session, persistence: 'encrypted' }
}
if (parsed.format === 'dev-plaintext-v1' && allowsPlaintextOrcaCloudSession()) {
if (!isOrcaCloudSession(parsed.session)) {
return { status: 'decrypt-failed', persistence: 'none', error: 'Invalid saved session.' }
}
memorySessions.set(cacheKey, { session: parsed.session, persistence: 'dev-plaintext' })
return { status: 'found', session: parsed.session, persistence: 'dev-plaintext' }
}
return { status: 'decrypt-failed', persistence: 'none', error: 'Unsafe session format.' }
} catch {
return {
status: 'decrypt-failed',
persistence: 'none',
error: 'Could not decrypt saved Orca account session.'
}
}
}
export function clearOrcaCloudSession(profileId: string, userDataPath: string): void {
memorySessions.delete(sessionCacheKey(profileId, userDataPath))
rmSync(getOrcaCloudSessionPath(profileId, userDataPath), { force: true })
}
@@ -0,0 +1,198 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import {
DEFAULT_LOCAL_ORCA_PROFILE_ID,
DEFAULT_LOCAL_ORCA_PROFILE_NAME,
ORCA_PROFILE_INDEX_SCHEMA_VERSION,
type OrcaProfileIndex
} from '../../shared/orca-profiles'
const testState = { dir: '' }
vi.mock('electron', () => ({
app: {
getPath: () => testState.dir
}
}))
async function loadProfileIndexStore() {
vi.resetModules()
return import('./profile-index-store')
}
function readJson(path: string): unknown {
return JSON.parse(readFileSync(path, 'utf-8'))
}
describe('profile index store', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-profile-test-'))
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
})
it('creates the default local profile and copies legacy state without deleting it', async () => {
const legacyState = { schemaVersion: 1, repos: [{ id: 'repo-1' }] }
const legacyBackup = { schemaVersion: 1, repos: [{ id: 'backup-repo' }] }
const legacyBrowserSessionMeta = {
defaultSource: { browserFamily: 'chrome', importedAt: 1 },
profiles: []
}
writeFileSync(join(testState.dir, 'orca-data.json'), JSON.stringify(legacyState), 'utf-8')
writeFileSync(
join(testState.dir, 'orca-data.json.bak.0'),
JSON.stringify(legacyBackup),
'utf-8'
)
writeFileSync(
join(testState.dir, 'browser-session-meta.json'),
JSON.stringify(legacyBrowserSessionMeta),
'utf-8'
)
const { ensureActiveOrcaProfile, getOrcaProfileIndexPath } = await loadProfileIndexStore()
const activeProfile = ensureActiveOrcaProfile()
expect(activeProfile.profile.id).toBe(DEFAULT_LOCAL_ORCA_PROFILE_ID)
expect(activeProfile.profile.name).toBe(DEFAULT_LOCAL_ORCA_PROFILE_NAME)
expect(activeProfile.dataFile).toBe(
join(testState.dir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json')
)
expect(readJson(activeProfile.dataFile)).toEqual(legacyState)
expect(readJson(`${activeProfile.dataFile}.bak.0`)).toEqual(legacyBackup)
expect(
readJson(
join(testState.dir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'browser-session-meta.json')
)
).toEqual(legacyBrowserSessionMeta)
expect(existsSync(join(testState.dir, 'orca-data.json'))).toBe(true)
expect(readJson(getOrcaProfileIndexPath())).toMatchObject({
schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION,
activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID,
profiles: [expect.objectContaining({ id: DEFAULT_LOCAL_ORCA_PROFILE_ID, kind: 'local' })]
})
})
it('uses an existing active profile data file without overwriting it from legacy state', async () => {
const profileId = 'work-profile'
const profileDirectory = join(testState.dir, 'profiles', profileId)
const profileData = { schemaVersion: 1, repos: [{ id: 'profile-repo' }] }
mkdirSync(profileDirectory, { recursive: true })
writeFileSync(join(profileDirectory, 'orca-data.json'), JSON.stringify(profileData), 'utf-8')
writeFileSync(
join(testState.dir, 'orca-data.json'),
JSON.stringify({ schemaVersion: 1, repos: [{ id: 'legacy-repo' }] }),
'utf-8'
)
const index: OrcaProfileIndex = {
schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION,
activeProfileId: profileId,
profiles: [
{
id: profileId,
name: 'Work',
avatar: { kind: 'initials', initials: 'W', color: 'neutral' },
kind: 'local',
createdAt: 1,
updatedAt: 1,
lastOpenedAt: 1
}
]
}
writeFileSync(join(testState.dir, 'orca-profile-index.json'), JSON.stringify(index), 'utf-8')
const { ensureActiveOrcaProfile } = await loadProfileIndexStore()
const activeProfile = ensureActiveOrcaProfile()
expect(activeProfile.profile.id).toBe(profileId)
expect(activeProfile.dataFile).toBe(join(profileDirectory, 'orca-data.json'))
expect(readJson(activeProfile.dataFile)).toEqual(profileData)
})
it('creates an empty local profile without copying legacy state into it', async () => {
writeFileSync(
join(testState.dir, 'orca-data.json'),
JSON.stringify({ schemaVersion: 1, repos: [{ id: 'legacy-repo' }] }),
'utf-8'
)
const { createLocalOrcaProfile, getOrcaProfileDataFile, getOrcaProfileListState } =
await loadProfileIndexStore()
const created = createLocalOrcaProfile({ name: ' Work ' })
expect(created.profile.name).toBe('Work')
expect(created.profile.id).toMatch(/^local-/)
expect(created.activeProfileId).toBe(DEFAULT_LOCAL_ORCA_PROFILE_ID)
expect(created.profiles.map((profile) => profile.id)).toContain(created.profile.id)
expect(existsSync(getOrcaProfileDataFile(created.profile.id))).toBe(false)
expect(getOrcaProfileListState().profiles.map((profile) => profile.id)).toContain(
created.profile.id
)
})
it('switches the active profile and updates last-opened metadata', async () => {
const { createLocalOrcaProfile, setActiveOrcaProfile } = await loadProfileIndexStore()
const created = createLocalOrcaProfile({ name: 'Work' })
const switched = setActiveOrcaProfile(created.profile.id)
expect(switched.activeProfileId).toBe(created.profile.id)
expect(switched.profiles.find((profile) => profile.id === created.profile.id)).toMatchObject({
id: created.profile.id,
lastOpenedAt: expect.any(Number)
})
})
it('rejects switching to an unknown profile', async () => {
const { setActiveOrcaProfile } = await loadProfileIndexStore()
expect(() => setActiveOrcaProfile('missing-profile')).toThrow('unknown_orca_profile')
})
it('recovers a corrupted profile index from the backup copy', async () => {
const store = await loadProfileIndexStore()
store.ensureActiveOrcaProfile()
const created = store.createLocalOrcaProfile({ name: 'Work' })
// Trigger one more write so the backup captures the two-profile index.
store.setActiveOrcaProfile(created.profile.id)
const indexPath = store.getOrcaProfileIndexPath()
expect(existsSync(`${indexPath}.bak`)).toBe(true)
writeFileSync(indexPath, '{ not json', 'utf-8')
const recovered = store.getOrcaProfileListState()
expect(recovered.profiles.map((profile) => profile.id)).toContain(created.profile.id)
expect(recovered.profiles.length).toBeGreaterThanOrEqual(2)
})
it('rejects profile ids that are not safe path segments', async () => {
const store = await loadProfileIndexStore()
const indexPath = store.getOrcaProfileIndexPath()
const index: OrcaProfileIndex = {
schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION,
activeProfileId: '../../escape',
profiles: [
{
id: '../../escape',
name: 'Evil',
avatar: { kind: 'initials', initials: 'E', color: 'neutral' },
kind: 'local',
createdAt: 1,
updatedAt: 1,
lastOpenedAt: 1
}
]
}
mkdirSync(testState.dir, { recursive: true })
writeFileSync(indexPath, JSON.stringify(index), 'utf-8')
// The tampered entry is filtered; startup falls back to a fresh default.
const state = store.ensureActiveOrcaProfile()
expect(state.profile.id).toBe(DEFAULT_LOCAL_ORCA_PROFILE_ID)
})
})
@@ -0,0 +1,327 @@
import {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
renameSync,
writeFileSync
} from 'node:fs'
import { randomUUID } from 'node:crypto'
import { dirname } from 'node:path'
import type { GlobalSettings } from '../../shared/types'
import {
createDefaultLocalOrcaProfile,
DEFAULT_LOCAL_ORCA_PROFILE_ID,
DEFAULT_LOCAL_ORCA_PROFILE_NAME,
ORCA_PROFILE_INDEX_SCHEMA_VERSION,
type CreateLocalOrcaProfileArgs,
type CreateLocalOrcaProfileResult,
type OrcaProfileIndex,
type OrcaProfileListState,
type OrcaProfileSummary
} from '../../shared/orca-profiles'
import {
getOrcaProfileBrowserSessionMetaFile,
getOrcaProfileDataFile,
getOrcaProfileDirectory,
getOrcaProfileIndexPath,
getProfileUserDataPath,
LEGACY_BACKUP_COUNT,
legacyBackupPath,
legacyBrowserSessionMetaPath,
legacyDataFilePath,
profileBackupPath
} from './profile-storage-paths'
export {
getOrcaProfileBrowserSessionMetaFile,
getOrcaProfileDataFile,
getOrcaProfileDirectory,
getOrcaProfileIndexPath,
getOrcaProfilesDirectory,
initOrcaProfilePaths
} from './profile-storage-paths'
export type ActiveOrcaProfileState = {
index: OrcaProfileIndex
profile: OrcaProfileSummary
dataFile: string
profileDirectory: string
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isProfileSummary(value: unknown): value is OrcaProfileSummary {
if (!isObject(value)) {
return false
}
const avatar = value.avatar
const cloud = value.cloud
return (
typeof value.id === 'string' &&
// Why: IDs from the on-disk index become filesystem path segments; a
// tampered index must not be able to escape the profiles directory.
/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value.id) &&
typeof value.name === 'string' &&
value.name.length > 0 &&
(value.kind === 'local' || value.kind === 'cloud-linked') &&
typeof value.createdAt === 'number' &&
typeof value.updatedAt === 'number' &&
typeof value.lastOpenedAt === 'number' &&
isObject(avatar) &&
avatar.kind === 'initials' &&
typeof avatar.initials === 'string' &&
avatar.color === 'neutral' &&
(cloud === undefined || isObject(cloud))
)
}
function normalizeProfileIndex(raw: unknown): OrcaProfileIndex | null {
if (!isObject(raw) || !Array.isArray(raw.profiles)) {
return null
}
const profiles = raw.profiles.filter(isProfileSummary)
const activeProfileId =
typeof raw.activeProfileId === 'string' &&
profiles.some((profile) => profile.id === raw.activeProfileId)
? raw.activeProfileId
: profiles[0]?.id
if (!activeProfileId) {
return null
}
return {
schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION,
activeProfileId,
profiles
}
}
function sanitizeProfileName(value: unknown): string {
const trimmed = typeof value === 'string' ? value.trim() : ''
return trimmed.length > 0 ? trimmed.slice(0, 80) : 'New Profile'
}
function readProfileIndexFile(indexPath: string): OrcaProfileIndex | null {
try {
return normalizeProfileIndex(JSON.parse(readFileSync(indexPath, 'utf-8')))
} catch {
return null
}
}
export function readProfileIndex(indexPath: string): OrcaProfileIndex | null {
// Why: a torn/corrupt index must not silently reset the app to a single
// default profile — that would orphan every other profile's data directory.
return readProfileIndexFile(indexPath) ?? readProfileIndexFile(`${indexPath}.bak`)
}
export function writeProfileIndex(indexPath: string, index: OrcaProfileIndex): void {
mkdirSync(dirname(indexPath), { recursive: true })
// Why: only a still-parseable current index may refresh the backup;
// copying a corrupt file over the backup would destroy the recovery copy.
if (existsSync(indexPath) && readProfileIndexFile(indexPath)) {
try {
copyFileSync(indexPath, `${indexPath}.bak`)
} catch {
// Best-effort backup; the primary write below still proceeds.
}
}
const tmpPath = `${indexPath}.tmp`
writeFileSync(tmpPath, JSON.stringify(index, null, 2), 'utf-8')
renameSync(tmpPath, indexPath)
}
function copyIfPresent(source: string, target: string): void {
if (!existsSync(source) || existsSync(target)) {
return
}
mkdirSync(dirname(target), { recursive: true })
// Why: tmp+rename so a crash mid-copy cannot leave a truncated target that
// the exists() guard above would then treat as a completed migration.
const tmpTarget = `${target}.tmp`
copyFileSync(source, tmpTarget)
renameSync(tmpTarget, target)
}
function copyLegacyStateToProfile(userDataPath: string, profileId: string): void {
const profileDataFile = getOrcaProfileDataFile(profileId, userDataPath)
copyIfPresent(legacyDataFilePath(userDataPath), profileDataFile)
copyIfPresent(
legacyBrowserSessionMetaPath(userDataPath),
getOrcaProfileBrowserSessionMetaFile(profileId, userDataPath)
)
for (let i = 0; i < LEGACY_BACKUP_COUNT; i++) {
copyIfPresent(legacyBackupPath(userDataPath, i), profileBackupPath(profileDataFile, i))
}
}
// Why: a brand-new profile has no data file, which the telemetry cohort
// migration reads as a fresh install and defaults to opted-in. Copying the
// active profile's consent block keeps an opted-out user opted out (and keeps
// one installId per install) when they create additional profiles.
export function seedNewOrcaProfileTelemetryConsent(
profileId: string,
telemetry: GlobalSettings['telemetry'],
userDataPath = getProfileUserDataPath()
): void {
if (!telemetry) {
return
}
const dataFile = getOrcaProfileDataFile(profileId, userDataPath)
if (existsSync(dataFile)) {
return
}
mkdirSync(dirname(dataFile), { recursive: true })
const tmpPath = `${dataFile}.tmp`
writeFileSync(tmpPath, JSON.stringify({ settings: { telemetry } }, null, 2), 'utf-8')
renameSync(tmpPath, dataFile)
}
function createInitialProfileIndex(now = Date.now()): OrcaProfileIndex {
const profile = createDefaultLocalOrcaProfile(now)
return {
schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION,
activeProfileId: profile.id,
profiles: [profile]
}
}
export function loadOrCreateProfileIndex(userDataPath: string): OrcaProfileIndex {
const indexPath = getOrcaProfileIndexPath(userDataPath)
const index = existsSync(indexPath) ? readProfileIndex(indexPath) : null
if (index) {
return index
}
const nextIndex = createInitialProfileIndex()
writeProfileIndex(indexPath, nextIndex)
return nextIndex
}
function getActiveProfile(index: OrcaProfileIndex): OrcaProfileSummary {
return (
index.profiles.find((profile) => profile.id === index.activeProfileId) ??
index.profiles[0] ??
createDefaultLocalOrcaProfile(Date.now())
)
}
export function ensureActiveOrcaProfile(
userDataPath = getProfileUserDataPath()
): ActiveOrcaProfileState {
const indexPath = getOrcaProfileIndexPath(userDataPath)
let index = existsSync(indexPath) ? readProfileIndex(indexPath) : null
let shouldWriteIndex = false
if (!index) {
index = createInitialProfileIndex()
shouldWriteIndex = true
}
const activeProfile = getActiveProfile(index)
if (activeProfile.id !== index.activeProfileId) {
index = { ...index, activeProfileId: activeProfile.id }
shouldWriteIndex = true
}
const profileDirectory = getOrcaProfileDirectory(activeProfile.id, userDataPath)
mkdirSync(profileDirectory, { recursive: true })
if (activeProfile.id === DEFAULT_LOCAL_ORCA_PROFILE_ID) {
copyLegacyStateToProfile(userDataPath, activeProfile.id)
}
if (shouldWriteIndex) {
writeProfileIndex(indexPath, index)
}
return {
index,
profile: activeProfile,
dataFile: getOrcaProfileDataFile(activeProfile.id, userDataPath),
profileDirectory
}
}
export function isDefaultLocalOrcaProfileId(profileId: string): boolean {
return profileId === DEFAULT_LOCAL_ORCA_PROFILE_ID
}
export function getOrcaProfileListState(
userDataPath = getProfileUserDataPath()
): OrcaProfileListState {
const { index } = ensureActiveOrcaProfile(userDataPath)
return {
activeProfileId: index.activeProfileId,
profiles: index.profiles
}
}
export function createLocalOrcaProfile(
args: CreateLocalOrcaProfileArgs = {},
userDataPath = getProfileUserDataPath()
): CreateLocalOrcaProfileResult {
const index = loadOrCreateProfileIndex(userDataPath)
const now = Date.now()
const name = sanitizeProfileName(args.name)
const profile: OrcaProfileSummary = {
id: `local-${randomUUID()}`,
name,
avatar: {
kind: 'initials',
initials: (
name.match(/[A-Za-z0-9]/)?.[0] ?? DEFAULT_LOCAL_ORCA_PROFILE_NAME[0]
).toUpperCase(),
color: 'neutral'
},
kind: 'local',
createdAt: now,
updatedAt: now,
lastOpenedAt: now
}
const nextIndex: OrcaProfileIndex = {
...index,
profiles: [...index.profiles, profile]
}
mkdirSync(getOrcaProfileDirectory(profile.id, userDataPath), { recursive: true })
writeProfileIndex(getOrcaProfileIndexPath(userDataPath), nextIndex)
return {
activeProfileId: nextIndex.activeProfileId,
profiles: nextIndex.profiles,
profile
}
}
export function setActiveOrcaProfile(
profileId: string,
userDataPath = getProfileUserDataPath()
): OrcaProfileListState {
const index = loadOrCreateProfileIndex(userDataPath)
const now = Date.now()
let found = false
const profiles = index.profiles.map((profile) => {
if (profile.id !== profileId) {
return profile
}
found = true
return {
...profile,
updatedAt: now,
lastOpenedAt: now
}
})
if (!found) {
throw new Error('unknown_orca_profile')
}
const nextIndex: OrcaProfileIndex = {
...index,
activeProfileId: profileId,
profiles
}
mkdirSync(getOrcaProfileDirectory(profileId, userDataPath), { recursive: true })
writeProfileIndex(getOrcaProfileIndexPath(userDataPath), nextIndex)
return {
activeProfileId: nextIndex.activeProfileId,
profiles: nextIndex.profiles
}
}
@@ -0,0 +1,145 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultPersistedState } from '../../shared/constants'
import {
ORCA_PROFILE_INDEX_SCHEMA_VERSION,
type OrcaProfileIndex,
type OrcaProfileKind
} from '../../shared/orca-profiles'
import type { PersistedState, Repo } from '../../shared/types'
const testState = { dir: '' }
vi.mock('electron', () => ({
app: {
getPath: () => testState.dir
}
}))
async function loadPresenceModule() {
vi.resetModules()
return import('./profile-project-presence')
}
function profile(
id: string,
name: string,
kind: OrcaProfileKind = 'local'
): OrcaProfileIndex['profiles'][number] {
return {
id,
name,
avatar: { kind: 'initials', initials: name[0], color: 'neutral' },
kind,
createdAt: 1,
updatedAt: 1,
lastOpenedAt: 1
}
}
function writeIndex(activeProfileId = 'personal'): void {
const index: OrcaProfileIndex = {
schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION,
activeProfileId,
profiles: [profile('personal', 'Personal'), profile('work', 'Work')]
}
writeFileSync(join(testState.dir, 'orca-profile-index.json'), JSON.stringify(index), 'utf-8')
}
function writeProfileState(profileId: string, repos: Repo[]): void {
const state: PersistedState = {
...getDefaultPersistedState('/Users/tester'),
repos
}
const dataFile = join(testState.dir, 'profiles', profileId, 'orca-data.json')
mkdirSync(dirname(dataFile), { recursive: true })
writeFileSync(dataFile, JSON.stringify(state), 'utf-8')
}
function makeRepo(overrides: Partial<Repo> = {}): Repo {
return {
id: 'repo-1',
path: '/workspace/orca',
displayName: 'Orca',
badgeColor: '#33aa99',
addedAt: 100,
kind: 'git',
connectionId: null,
...overrides
}
}
describe('profile project presence', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-profile-presence-'))
writeIndex()
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
})
it('finds matching projects in other profiles while excluding the active profile', async () => {
writeProfileState('personal', [
makeRepo({ id: 'personal-repo', path: 'C:\\Code\\Orca', displayName: 'Personal Orca' })
])
writeProfileState('work', [
makeRepo({ id: 'work-repo', path: 'C:\\Code\\Orca', displayName: 'Work Orca' })
])
const { findOrcaProfileProjectsByPath } = await loadPresenceModule()
const result = findOrcaProfileProjectsByPath(
{
path: 'c:/code/orca/',
executionHostId: 'local',
excludeProfileId: 'personal'
},
testState.dir
)
expect(result.projects).toEqual([
{
profileId: 'work',
profileName: 'Work',
profileKind: 'local',
repoId: 'work-repo',
repoName: 'Work Orca'
}
])
})
it('keeps SSH projects separate from local projects with the same path', async () => {
writeProfileState('personal', [
makeRepo({ id: 'local-repo', path: '/srv/orca', displayName: 'Local Orca' })
])
writeProfileState('work', [
makeRepo({
id: 'ssh-repo',
path: '/srv/orca',
displayName: 'SSH Orca',
connectionId: 'builder',
executionHostId: 'ssh:builder'
})
])
const { findOrcaProfileProjectsByPath } = await loadPresenceModule()
const result = findOrcaProfileProjectsByPath(
{
path: '/srv/orca',
connectionId: 'builder',
executionHostId: 'ssh:builder'
},
testState.dir
)
expect(result.projects).toEqual([
expect.objectContaining({
profileId: 'work',
repoId: 'ssh-repo',
repoName: 'SSH Orca'
})
])
})
})
@@ -0,0 +1,51 @@
import type {
FindOrcaProfileProjectsByPathArgs,
FindOrcaProfileProjectsByPathResult
} from '../../shared/orca-profiles'
import { getOrcaProfileListState } from './profile-index-store'
import { readProfileState } from './profile-project-state-file'
import { repoPhysicalKey } from './profile-project-worktree-identity'
function cleanOptionalString(value: string | null | undefined): string | null {
const trimmed = value?.trim()
return trimmed ? trimmed : null
}
export function findOrcaProfileProjectsByPath(
args: FindOrcaProfileProjectsByPathArgs,
userDataPath: string
): FindOrcaProfileProjectsByPathResult {
const path = args.path.trim()
if (!path) {
throw new Error('invalid_orca_profile_project_path')
}
const excludeProfileId = cleanOptionalString(args.excludeProfileId)
const candidateKey = repoPhysicalKey({
path,
connectionId: cleanOptionalString(args.connectionId),
executionHostId: args.executionHostId ?? undefined
})
const projects: FindOrcaProfileProjectsByPathResult['projects'] = []
for (const profile of getOrcaProfileListState(userDataPath).profiles) {
if (profile.id === excludeProfileId) {
continue
}
const state = readProfileState(profile.id, userDataPath)
for (const repo of state.repos) {
if (repoPhysicalKey(repo) !== candidateKey) {
continue
}
projects.push({
profileId: profile.id,
profileName: profile.name,
profileKind: profile.kind,
repoId: repo.id,
repoName: repo.displayName || repo.path
})
}
}
return { projects }
}
@@ -0,0 +1,155 @@
import { getDefaultWorkspaceSession } from '../../shared/constants'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { WorkspaceSessionState } from '../../shared/types'
import { parseWorkspaceKey } from '../../shared/workspace-scope'
import {
isRepoWorktreeId,
ownerKeyBelongsToRepo,
removeRepoWorktreeRecord
} from './profile-project-worktree-identity'
export function mergeHostWorkspaceSessions(
existing: Partial<Record<ExecutionHostId, WorkspaceSessionState>> | undefined,
incoming: Partial<Record<ExecutionHostId, WorkspaceSessionState>>
): Partial<Record<ExecutionHostId, WorkspaceSessionState>> {
const next: Partial<Record<ExecutionHostId, WorkspaceSessionState>> = { ...existing }
for (const [hostId, session] of Object.entries(incoming)) {
if (!session) {
continue
}
next[hostId as ExecutionHostId] = mergeWorkspaceSessions(
next[hostId as ExecutionHostId],
session
)
}
return next
}
export function mergeWorkspaceSessions(
existing: WorkspaceSessionState | undefined,
incoming: WorkspaceSessionState
): WorkspaceSessionState {
const base = existing ?? getDefaultWorkspaceSession()
return {
...base,
tabsByWorktree: { ...base.tabsByWorktree, ...incoming.tabsByWorktree },
terminalLayoutsByTabId: {
...base.terminalLayoutsByTabId,
...incoming.terminalLayoutsByTabId
},
openFilesByWorktree: { ...base.openFilesByWorktree, ...incoming.openFilesByWorktree },
browserTabsByWorktree: {
...base.browserTabsByWorktree,
...incoming.browserTabsByWorktree
},
browserPagesByWorkspace: {
...base.browserPagesByWorkspace,
...incoming.browserPagesByWorkspace
},
activeBrowserTabIdByWorktree: {
...base.activeBrowserTabIdByWorktree,
...incoming.activeBrowserTabIdByWorktree
},
activeFileIdByWorktree: {
...base.activeFileIdByWorktree,
...incoming.activeFileIdByWorktree
},
activeTabTypeByWorktree: {
...base.activeTabTypeByWorktree,
...incoming.activeTabTypeByWorktree
},
activeTabIdByWorktree: { ...base.activeTabIdByWorktree, ...incoming.activeTabIdByWorktree },
unifiedTabs: { ...base.unifiedTabs, ...incoming.unifiedTabs },
tabGroups: { ...base.tabGroups, ...incoming.tabGroups },
tabGroupLayouts: { ...base.tabGroupLayouts, ...incoming.tabGroupLayouts },
activeGroupIdByWorktree: {
...base.activeGroupIdByWorktree,
...incoming.activeGroupIdByWorktree
},
lastVisitedAtByWorktreeId: {
...base.lastVisitedAtByWorktreeId,
...incoming.lastVisitedAtByWorktreeId
},
defaultTerminalTabsAppliedByWorktreeId: {
...base.defaultTerminalTabsAppliedByWorktreeId,
...incoming.defaultTerminalTabsAppliedByWorktreeId
},
activeWorktreeIdsOnShutdown: [
...(base.activeWorktreeIdsOnShutdown ?? []),
...(incoming.activeWorktreeIdsOnShutdown ?? [])
],
activeWorktreeId: base.activeWorktreeId ?? incoming.activeWorktreeId,
activeWorkspaceKey: base.activeWorkspaceKey ?? incoming.activeWorkspaceKey,
activeTabId: base.activeTabId ?? incoming.activeTabId
}
}
export function removeRepoFromHostWorkspaceSessions(
sessions: Partial<Record<ExecutionHostId, WorkspaceSessionState>> | undefined,
repoId: string
): Partial<Record<ExecutionHostId, WorkspaceSessionState>> {
const next: Partial<Record<ExecutionHostId, WorkspaceSessionState>> = {}
for (const [hostId, session] of Object.entries(sessions ?? {})) {
next[hostId as ExecutionHostId] = removeRepoFromWorkspaceSession(session, repoId)
}
return next
}
export function removeRepoFromWorkspaceSession(
session: WorkspaceSessionState | undefined,
repoId: string
): WorkspaceSessionState {
const next = structuredClone(session ?? getDefaultWorkspaceSession())
const removedTerminalTabIds = new Set<string>()
for (const [ownerKey, tabs] of Object.entries(next.tabsByWorktree)) {
if (!ownerKeyBelongsToRepo(ownerKey, repoId)) {
continue
}
tabs.forEach((tab) => removedTerminalTabIds.add(tab.id))
delete next.tabsByWorktree[ownerKey]
}
for (const tabId of removedTerminalTabIds) {
delete next.terminalLayoutsByTabId[tabId]
}
next.openFilesByWorktree = removeRepoWorktreeRecord(next.openFilesByWorktree, repoId)
next.activeFileIdByWorktree = removeRepoWorktreeRecord(next.activeFileIdByWorktree, repoId)
const removedBrowserWorkspaceIds = new Set<string>()
for (const [ownerKey, workspaces] of Object.entries(next.browserTabsByWorktree ?? {})) {
if (!ownerKeyBelongsToRepo(ownerKey, repoId)) {
continue
}
workspaces.forEach((workspace) => removedBrowserWorkspaceIds.add(workspace.id))
delete next.browserTabsByWorktree![ownerKey]
}
if (next.browserPagesByWorkspace) {
for (const workspaceId of removedBrowserWorkspaceIds) {
delete next.browserPagesByWorkspace[workspaceId]
}
}
next.activeBrowserTabIdByWorktree = removeRepoWorktreeRecord(
next.activeBrowserTabIdByWorktree,
repoId
)
next.activeTabTypeByWorktree = removeRepoWorktreeRecord(next.activeTabTypeByWorktree, repoId)
next.activeTabIdByWorktree = removeRepoWorktreeRecord(next.activeTabIdByWorktree, repoId)
next.unifiedTabs = removeRepoWorktreeRecord(next.unifiedTabs, repoId)
next.tabGroups = removeRepoWorktreeRecord(next.tabGroups, repoId)
next.tabGroupLayouts = removeRepoWorktreeRecord(next.tabGroupLayouts, repoId)
next.activeGroupIdByWorktree = removeRepoWorktreeRecord(next.activeGroupIdByWorktree, repoId)
next.lastVisitedAtByWorktreeId = removeRepoWorktreeRecord(next.lastVisitedAtByWorktreeId, repoId)
next.defaultTerminalTabsAppliedByWorktreeId = removeRepoWorktreeRecord(
next.defaultTerminalTabsAppliedByWorktreeId,
repoId
)
if (next.activeWorktreeId && isRepoWorktreeId(repoId, next.activeWorktreeId)) {
next.activeWorktreeId = null
}
const activeScope = next.activeWorkspaceKey ? parseWorkspaceKey(next.activeWorkspaceKey) : null
if (activeScope?.type === 'worktree' && isRepoWorktreeId(repoId, activeScope.worktreeId)) {
next.activeWorkspaceKey = null
}
next.activeWorktreeIdsOnShutdown = next.activeWorktreeIdsOnShutdown?.filter(
(worktreeId) => !isRepoWorktreeId(repoId, worktreeId)
)
return next
}
@@ -0,0 +1,215 @@
import { getDefaultWorkspaceSession } from '../../shared/constants'
import type { ExecutionHostId } from '../../shared/execution-host'
import type {
BrowserPage,
BrowserWorkspace,
PersistedOpenFile,
Tab,
TabGroup,
TerminalTab,
WorkspaceSessionState
} from '../../shared/types'
import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope'
import {
isRepoWorktreeId,
rekeyOwnerKey,
rekeyWorktreeId
} from './profile-project-worktree-identity'
export function extractHostSessionsForTransfer(
sessions: Partial<Record<ExecutionHostId, WorkspaceSessionState>> | undefined,
oldRepoId: string,
newRepoId: string
): Partial<Record<ExecutionHostId, WorkspaceSessionState>> {
const next: Partial<Record<ExecutionHostId, WorkspaceSessionState>> = {}
for (const [hostId, session] of Object.entries(sessions ?? {})) {
if (!session) {
continue
}
const transferred = extractSessionForTransfer(session, oldRepoId, newRepoId)
if (hasTransferredSessionState(transferred)) {
next[hostId as ExecutionHostId] = transferred
}
}
return next
}
function hasTransferredSessionState(session: WorkspaceSessionState): boolean {
return (
Object.keys(session.tabsByWorktree).length > 0 ||
Object.keys(session.openFilesByWorktree ?? {}).length > 0 ||
Object.keys(session.browserTabsByWorktree ?? {}).length > 0 ||
Object.keys(session.unifiedTabs ?? {}).length > 0 ||
Object.keys(session.tabGroups ?? {}).length > 0
)
}
export function extractSessionForTransfer(
session: WorkspaceSessionState | undefined,
oldRepoId: string,
newRepoId: string
): WorkspaceSessionState {
const source = session ?? getDefaultWorkspaceSession()
const transferred = getDefaultWorkspaceSession()
const copiedTerminalTabIds = new Set<string>()
const copiedBrowserWorkspaceIds = new Set<string>()
const mapOwnerRecord = <T>(
record: Record<string, T> | undefined,
mapValue: (value: T) => T
): Record<string, T> => {
const next: Record<string, T> = {}
for (const [ownerKey, value] of Object.entries(record ?? {})) {
const nextOwnerKey = rekeyOwnerKey(oldRepoId, newRepoId, ownerKey)
if (nextOwnerKey) {
next[nextOwnerKey] = mapValue(value)
}
}
return next
}
transferred.tabsByWorktree = mapOwnerRecord(source.tabsByWorktree, (tabs) =>
tabs.map((tab) => {
copiedTerminalTabIds.add(tab.id)
return rekeyTerminalTab(tab, oldRepoId, newRepoId)
})
)
transferred.openFilesByWorktree = mapOwnerRecord(source.openFilesByWorktree, (files) =>
files.map((file) => rekeyOpenFile(file, oldRepoId, newRepoId))
)
transferred.activeFileIdByWorktree = mapOwnerRecord(source.activeFileIdByWorktree, (value) =>
structuredClone(value)
)
transferred.browserTabsByWorktree = mapOwnerRecord(source.browserTabsByWorktree, (tabs) =>
tabs.map((tab) => {
copiedBrowserWorkspaceIds.add(tab.id)
return rekeyBrowserWorkspace(tab, oldRepoId, newRepoId)
})
)
transferred.browserPagesByWorkspace = copyBrowserPages(
source.browserPagesByWorkspace,
copiedBrowserWorkspaceIds,
oldRepoId,
newRepoId
)
transferred.activeBrowserTabIdByWorktree = mapOwnerRecord(
source.activeBrowserTabIdByWorktree,
(value) => structuredClone(value)
)
transferred.activeTabTypeByWorktree = mapOwnerRecord(source.activeTabTypeByWorktree, (value) =>
structuredClone(value)
)
transferred.activeTabIdByWorktree = mapOwnerRecord(source.activeTabIdByWorktree, (value) =>
structuredClone(value)
)
transferred.unifiedTabs = mapOwnerRecord(source.unifiedTabs, (tabs) =>
tabs.map((tab) => rekeyUnifiedTab(tab, oldRepoId, newRepoId))
)
transferred.tabGroups = mapOwnerRecord(source.tabGroups, (groups) =>
groups.map((group) => rekeyTabGroup(group, oldRepoId, newRepoId))
)
transferred.tabGroupLayouts = mapOwnerRecord(source.tabGroupLayouts, (value) =>
structuredClone(value)
)
transferred.activeGroupIdByWorktree = mapOwnerRecord(source.activeGroupIdByWorktree, (value) =>
structuredClone(value)
)
transferred.lastVisitedAtByWorktreeId = mapOwnerRecord(
source.lastVisitedAtByWorktreeId,
(value) => structuredClone(value)
)
transferred.defaultTerminalTabsAppliedByWorktreeId = mapOwnerRecord(
source.defaultTerminalTabsAppliedByWorktreeId,
(value) => structuredClone(value)
)
transferred.terminalLayoutsByTabId = {}
for (const tabId of copiedTerminalTabIds) {
const layout = source.terminalLayoutsByTabId[tabId]
if (layout) {
transferred.terminalLayoutsByTabId[tabId] = structuredClone(layout)
}
}
transferred.activeWorktreeIdsOnShutdown = source.activeWorktreeIdsOnShutdown
?.filter((worktreeId) => isRepoWorktreeId(oldRepoId, worktreeId))
.map((worktreeId) => rekeyWorktreeId(oldRepoId, newRepoId, worktreeId))
if (source.activeWorktreeId && isRepoWorktreeId(oldRepoId, source.activeWorktreeId)) {
transferred.activeWorktreeId = rekeyWorktreeId(oldRepoId, newRepoId, source.activeWorktreeId)
}
const activeScope = source.activeWorkspaceKey
? parseWorkspaceKey(source.activeWorkspaceKey)
: null
if (activeScope?.type === 'worktree' && isRepoWorktreeId(oldRepoId, activeScope.worktreeId)) {
transferred.activeWorkspaceKey = worktreeWorkspaceKey(
rekeyWorktreeId(oldRepoId, newRepoId, activeScope.worktreeId)
)
}
return transferred
}
function rekeyTerminalTab(tab: TerminalTab, oldRepoId: string, newRepoId: string): TerminalTab {
return {
...structuredClone(tab),
worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, tab.worktreeId)
}
}
function rekeyOpenFile(
file: PersistedOpenFile,
oldRepoId: string,
newRepoId: string
): PersistedOpenFile {
return {
...structuredClone(file),
worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, file.worktreeId)
}
}
function rekeyBrowserWorkspace(
workspace: BrowserWorkspace,
oldRepoId: string,
newRepoId: string
): BrowserWorkspace {
return {
...structuredClone(workspace),
worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, workspace.worktreeId),
// Why: both the session profile and the resolved partition string are
// source-profile-scoped; carrying either across would point the restored
// pane at a partition the target profile's allowlist rejects.
sessionProfileId: null,
sessionPartition: null
}
}
function rekeyBrowserPage(page: BrowserPage, oldRepoId: string, newRepoId: string): BrowserPage {
return {
...structuredClone(page),
worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, page.worktreeId)
}
}
function copyBrowserPages(
pagesByWorkspace: Record<string, BrowserPage[]> | undefined,
workspaceIds: ReadonlySet<string>,
oldRepoId: string,
newRepoId: string
): Record<string, BrowserPage[]> {
const next: Record<string, BrowserPage[]> = {}
for (const [workspaceId, pages] of Object.entries(pagesByWorkspace ?? {})) {
if (workspaceIds.has(workspaceId)) {
next[workspaceId] = pages.map((page) => rekeyBrowserPage(page, oldRepoId, newRepoId))
}
}
return next
}
function rekeyUnifiedTab(tab: Tab, oldRepoId: string, newRepoId: string): Tab {
return {
...structuredClone(tab),
worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, tab.worktreeId)
}
}
function rekeyTabGroup(group: TabGroup, oldRepoId: string, newRepoId: string): TabGroup {
return {
...structuredClone(group),
worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, group.worktreeId)
}
}
@@ -0,0 +1,66 @@
import type { WorkspaceKey } from '../../shared/types'
import { parseWorkspaceKey } from '../../shared/workspace-scope'
import {
rebuildRepoBackedProjectState,
type TransferProfileState
} from './profile-project-state-file'
import {
removeRepoFromHostWorkspaceSessions,
removeRepoFromWorkspaceSession
} from './profile-project-session-state'
import { isRepoWorktreeId, removeRepoWorktreeRecord } from './profile-project-worktree-identity'
export function removeSourceRepo(
state: TransferProfileState,
repoId: string
): TransferProfileState {
const next: TransferProfileState = {
...state,
repos: state.repos.filter((repo) => repo.id !== repoId),
sparsePresetsByRepo: { ...state.sparsePresetsByRepo },
worktreeMeta: { ...state.worktreeMeta },
worktreeLineageById: { ...state.worktreeLineageById },
workspaceLineageByChildKey: { ...state.workspaceLineageByChildKey },
workspaceSession: removeRepoFromWorkspaceSession(state.workspaceSession, repoId),
workspaceSessionsByHostId: removeRepoFromHostWorkspaceSessions(
state.workspaceSessionsByHostId,
repoId
),
ui: {
...state.ui,
lastActiveRepoId: state.ui.lastActiveRepoId === repoId ? null : state.ui.lastActiveRepoId,
lastActiveWorktreeId:
state.ui.lastActiveWorktreeId && isRepoWorktreeId(repoId, state.ui.lastActiveWorktreeId)
? null
: state.ui.lastActiveWorktreeId,
filterRepoIds: state.ui.filterRepoIds?.filter((id) => id !== repoId) ?? [],
showDotfilesByWorktree: removeRepoWorktreeRecord(state.ui.showDotfilesByWorktree, repoId)
}
}
delete next.sparsePresetsByRepo[repoId]
removeRepoWorktreeMetadata(next, repoId)
return rebuildRepoBackedProjectState(next)
}
function removeRepoWorktreeMetadata(state: TransferProfileState, repoId: string): void {
for (const key of Object.keys(state.worktreeMeta)) {
if (isRepoWorktreeId(repoId, key)) {
delete state.worktreeMeta[key]
}
}
for (const [key, lineage] of Object.entries(state.worktreeLineageById)) {
if (isRepoWorktreeId(repoId, key) || isRepoWorktreeId(repoId, lineage.parentWorktreeId)) {
delete state.worktreeLineageById[key]
}
}
for (const [key, lineage] of Object.entries(state.workspaceLineageByChildKey)) {
const child = parseWorkspaceKey(key)
const parent = parseWorkspaceKey(lineage.parentWorkspaceKey)
if (
(child?.type === 'worktree' && isRepoWorktreeId(repoId, child.worktreeId)) ||
(parent?.type === 'worktree' && isRepoWorktreeId(repoId, parent.worktreeId))
) {
delete state.workspaceLineageByChildKey[key as WorkspaceKey]
}
}
}
@@ -0,0 +1,139 @@
import { randomUUID } from 'node:crypto'
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname } from 'node:path'
import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../shared/constants'
import type { ExecutionHostId } from '../../shared/execution-host'
import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection'
import type {
PersistedState,
Project,
ProjectHostSetup,
Repo,
SparsePreset,
WorkspaceSessionState
} from '../../shared/types'
import { getOrcaProfileDataFile } from './profile-index-store'
export type TransferProfileState = PersistedState
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function arrayOrEmpty<T>(value: unknown): T[] {
return Array.isArray(value) ? (value as T[]) : []
}
function recordOrEmpty<T>(value: unknown): Record<string, T> {
return isRecord(value) ? (value as Record<string, T>) : {}
}
export function readProfileState(profileId: string, userDataPath: string): TransferProfileState {
const defaults = getDefaultPersistedState(homedir())
const dataFile = getOrcaProfileDataFile(profileId, userDataPath)
if (!existsSync(dataFile)) {
return structuredClone(defaults)
}
const parsed = JSON.parse(readFileSync(dataFile, 'utf-8')) as Partial<PersistedState>
return rebuildRepoBackedProjectState({
...defaults,
...parsed,
repos: arrayOrEmpty<Repo>(parsed.repos),
projects: arrayOrEmpty<Project>(parsed.projects),
projectHostSetups: arrayOrEmpty<ProjectHostSetup>(parsed.projectHostSetups),
projectGroups: arrayOrEmpty(parsed.projectGroups),
folderWorkspaces: arrayOrEmpty(parsed.folderWorkspaces),
sparsePresetsByRepo: recordOrEmpty<SparsePreset[]>(parsed.sparsePresetsByRepo),
worktreeMeta: recordOrEmpty(parsed.worktreeMeta),
worktreeLineageById: recordOrEmpty(parsed.worktreeLineageById),
workspaceLineageByChildKey: recordOrEmpty(parsed.workspaceLineageByChildKey),
settings: isRecord(parsed.settings)
? { ...defaults.settings, ...parsed.settings }
: defaults.settings,
ui: isRecord(parsed.ui) ? { ...defaults.ui, ...parsed.ui } : defaults.ui,
githubCache: isRecord(parsed.githubCache)
? {
pr: recordOrEmpty((parsed.githubCache as PersistedState['githubCache']).pr),
issue: recordOrEmpty((parsed.githubCache as PersistedState['githubCache']).issue)
}
: defaults.githubCache,
workspaceSession: isRecord(parsed.workspaceSession)
? { ...getDefaultWorkspaceSession(), ...parsed.workspaceSession }
: defaults.workspaceSession,
workspaceSessionsByHostId: isRecord(parsed.workspaceSessionsByHostId)
? (parsed.workspaceSessionsByHostId as Partial<
Record<ExecutionHostId, WorkspaceSessionState>
>)
: {},
sshTargets: arrayOrEmpty(parsed.sshTargets),
sshRemotePtyLeases: arrayOrEmpty(parsed.sshRemotePtyLeases),
migrationUnsupportedPtyEntries: arrayOrEmpty(parsed.migrationUnsupportedPtyEntries),
legacyPaneKeyAliasEntries: arrayOrEmpty(parsed.legacyPaneKeyAliasEntries),
automations: arrayOrEmpty(parsed.automations),
automationRuns: arrayOrEmpty(parsed.automationRuns),
onboarding: isRecord(parsed.onboarding)
? { ...defaults.onboarding, ...parsed.onboarding }
: defaults.onboarding,
featureInteractionTelemetryBuckets: isRecord(parsed.featureInteractionTelemetryBuckets)
? parsed.featureInteractionTelemetryBuckets
: defaults.featureInteractionTelemetryBuckets
})
}
export function writeProfileState(
profileId: string,
userDataPath: string,
state: TransferProfileState
): void {
const dataFile = getOrcaProfileDataFile(profileId, userDataPath)
mkdirSync(dirname(dataFile), { recursive: true })
const tmpPath = `${dataFile}.${process.pid}.${randomUUID()}.tmp`
writeFileSync(tmpPath, JSON.stringify(state, null, 2), 'utf-8')
renameSync(tmpPath, dataFile)
}
function isRepoBackedProjectHostSetup(
setup: ProjectHostSetup,
currentRepoIds: ReadonlySet<string>
): boolean {
return Boolean(setup.repoId && currentRepoIds.has(setup.repoId))
}
export function rebuildRepoBackedProjectState(state: TransferProfileState): TransferProfileState {
const projection = projectHostSetupProjectionFromRepos(state.repos)
const existingProjectsById = new Map(state.projects.map((project) => [project.id, project]))
const currentRepoIds = new Set(state.repos.map((repo) => repo.id))
const projectedProjectIds = new Set(projection.projects.map((project) => project.id))
const projectedSetupIds = new Set(projection.setups.map((setup) => setup.id))
const independentSetups = state.projectHostSetups.filter((setup) => {
if (projectedSetupIds.has(setup.id)) {
return false
}
return !isRepoBackedProjectHostSetup(setup, currentRepoIds)
})
const independentProjectIds = new Set(independentSetups.map((setup) => setup.projectId))
const independentProjects = state.projects
.filter(
(project) => independentProjectIds.has(project.id) && !projectedProjectIds.has(project.id)
)
.map((project) => ({
...project,
sourceRepoIds: project.sourceRepoIds.filter((repoId) => currentRepoIds.has(repoId))
}))
const projectedProjects = projection.projects.map((project) => {
const existingProject = existingProjectsById.get(project.id)
return existingProject?.localWindowsRuntimePreference
? {
...project,
localWindowsRuntimePreference: existingProject.localWindowsRuntimePreference,
updatedAt: Math.max(project.updatedAt, existingProject.updatedAt)
}
: project
})
return {
...state,
projects: [...projectedProjects, ...independentProjects],
projectHostSetups: [...projection.setups, ...independentSetups]
}
}
@@ -0,0 +1,294 @@
import { randomUUID } from 'node:crypto'
import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection'
import type { SshTarget } from '../../shared/ssh-types'
import type { PersistedState, Repo, SparsePreset, WorkspaceKey } from '../../shared/types'
import { parseWorkspaceKey } from '../../shared/workspace-scope'
import type { TransferProfileState } from './profile-project-state-file'
import { rebuildRepoBackedProjectState } from './profile-project-state-file'
import { mergeHostWorkspaceSessions, mergeWorkspaceSessions } from './profile-project-session-state'
import {
extractHostSessionsForTransfer,
extractSessionForTransfer
} from './profile-project-session-transfer'
import {
isRepoWorktreeId,
rekeyWorktreeId,
rekeyWorkspaceKey
} from './profile-project-worktree-identity'
export type TransferPayload = {
repo: Repo
sparsePresets: SparsePreset[]
worktreeMeta: PersistedState['worktreeMeta']
worktreeLineageById: PersistedState['worktreeLineageById']
workspaceLineageByChildKey: PersistedState['workspaceLineageByChildKey']
workspaceSession?: PersistedState['workspaceSession']
workspaceSessionsByHostId?: Partial<Record<ExecutionHostId, PersistedState['workspaceSession']>>
sshTargets: SshTarget[]
targetProjectId: string | null
}
export function createTargetRepo(
sourceRepo: Repo,
targetState: TransferProfileState,
copy: boolean
): Repo {
const targetRepoId =
!copy && !targetState.repos.some((repo) => repo.id === sourceRepo.id)
? sourceRepo.id
: createUniqueRepoId(targetState)
const repo: Repo = {
...sourceRepo,
id: targetRepoId,
projectGroupId: null,
addedAt: copy ? Date.now() : sourceRepo.addedAt
}
delete repo.projectGroupOrder
return repo
}
function createUniqueRepoId(state: TransferProfileState): string {
const existingRepoIds = new Set(state.repos.map((repo) => repo.id))
let candidate = randomUUID()
while (existingRepoIds.has(candidate)) {
candidate = randomUUID()
}
return candidate
}
function collectTransferWorktreeIds(state: TransferProfileState, repoId: string): Set<string> {
const ids = new Set<string>()
const add = (value: string | null | undefined): void => {
if (value && isRepoWorktreeId(repoId, value)) {
ids.add(value)
}
}
Object.keys(state.worktreeMeta).forEach(add)
for (const lineage of Object.values(state.worktreeLineageById)) {
add(lineage.worktreeId)
add(lineage.parentWorktreeId)
}
for (const [key, lineage] of Object.entries(state.workspaceLineageByChildKey)) {
const child = parseWorkspaceKey(key)
const parent = parseWorkspaceKey(lineage.parentWorkspaceKey)
if (child?.type === 'worktree') {
add(child.worktreeId)
}
if (parent?.type === 'worktree') {
add(parent.worktreeId)
}
}
collectSessionWorktreeIds(state.workspaceSession, repoId, ids)
for (const session of Object.values(state.workspaceSessionsByHostId ?? {})) {
collectSessionWorktreeIds(session, repoId, ids)
}
Object.keys(state.ui?.showDotfilesByWorktree ?? {}).forEach(add)
return ids
}
function collectSessionWorktreeIds(
session: PersistedState['workspaceSession'] | undefined,
repoId: string,
ids: Set<string>
): void {
if (!session) {
return
}
const add = (value: string | null | undefined): void => {
if (value && isRepoWorktreeId(repoId, value)) {
ids.add(value)
}
}
const addOwnerKeys = (record: Record<string, unknown> | undefined): void => {
for (const key of Object.keys(record ?? {})) {
if (isRepoWorktreeId(repoId, key)) {
ids.add(key)
}
const parsed = parseWorkspaceKey(key)
if (parsed?.type === 'worktree' && isRepoWorktreeId(repoId, parsed.worktreeId)) {
ids.add(parsed.worktreeId)
}
}
}
addOwnerKeys(session.tabsByWorktree)
addOwnerKeys(session.openFilesByWorktree)
addOwnerKeys(session.browserTabsByWorktree)
addOwnerKeys(session.activeBrowserTabIdByWorktree)
addOwnerKeys(session.activeTabTypeByWorktree)
addOwnerKeys(session.activeTabIdByWorktree)
addOwnerKeys(session.unifiedTabs)
addOwnerKeys(session.tabGroups)
addOwnerKeys(session.tabGroupLayouts)
addOwnerKeys(session.activeGroupIdByWorktree)
addOwnerKeys(session.lastVisitedAtByWorktreeId)
addOwnerKeys(session.defaultTerminalTabsAppliedByWorktreeId)
addOwnerKeys(session.activeFileIdByWorktree)
add(session.activeWorktreeId)
const activeScope = session.activeWorkspaceKey
? parseWorkspaceKey(session.activeWorkspaceKey)
: null
if (activeScope?.type === 'worktree') {
add(activeScope.worktreeId)
}
}
function rekeyWorktreeIdRecord<T>(
record: Record<string, T>,
worktreeIds: ReadonlySet<string>,
oldRepoId: string,
newRepoId: string,
mapValue: (value: T) => T = (value) => structuredClone(value)
): Record<string, T> {
const next: Record<string, T> = {}
for (const [oldKey, value] of Object.entries(record)) {
if (worktreeIds.has(oldKey)) {
next[rekeyWorktreeId(oldRepoId, newRepoId, oldKey)] = mapValue(value)
}
}
return next
}
function rekeyLineageRecord(
record: PersistedState['worktreeLineageById'],
worktreeIds: ReadonlySet<string>,
oldRepoId: string,
newRepoId: string
): PersistedState['worktreeLineageById'] {
const next: PersistedState['worktreeLineageById'] = {}
for (const [oldKey, lineage] of Object.entries(record)) {
if (!worktreeIds.has(oldKey) && !worktreeIds.has(lineage.parentWorktreeId)) {
continue
}
const newKey = rekeyWorktreeId(oldRepoId, newRepoId, oldKey)
next[newKey] = {
...structuredClone(lineage),
worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, lineage.worktreeId),
parentWorktreeId: rekeyWorktreeId(oldRepoId, newRepoId, lineage.parentWorktreeId)
}
}
return next
}
function rekeyWorkspaceLineageRecord(
record: PersistedState['workspaceLineageByChildKey'],
oldRepoId: string,
newRepoId: string
): PersistedState['workspaceLineageByChildKey'] {
const next: PersistedState['workspaceLineageByChildKey'] = {}
for (const [oldKey, lineage] of Object.entries(record)) {
const newChildKey = rekeyWorkspaceKey(oldRepoId, newRepoId, oldKey as WorkspaceKey)
const newParentKey = rekeyWorkspaceKey(oldRepoId, newRepoId, lineage.parentWorkspaceKey)
if (newChildKey === oldKey && newParentKey === lineage.parentWorkspaceKey) {
continue
}
next[newChildKey] = {
...structuredClone(lineage),
childWorkspaceKey: newChildKey,
parentWorkspaceKey: newParentKey
}
}
return next
}
export function createTransferPayload(args: {
sourceState: TransferProfileState
sourceRepo: Repo
targetRepo: Repo
includeSessions: boolean
}): TransferPayload {
const { sourceState, sourceRepo, targetRepo, includeSessions } = args
const oldRepoId = sourceRepo.id
const newRepoId = targetRepo.id
const worktreeIds = collectTransferWorktreeIds(sourceState, oldRepoId)
const targetProjection = projectHostSetupProjectionFromRepos([targetRepo])
const targetProjectId =
targetProjection.setups[0]?.projectId ?? targetProjection.projects[0]?.id ?? null
return {
repo: targetRepo,
sparsePresets: (sourceState.sparsePresetsByRepo[oldRepoId] ?? []).map((preset) => ({
...structuredClone(preset),
repoId: newRepoId
})),
worktreeMeta: rekeyWorktreeIdRecord(
sourceState.worktreeMeta,
worktreeIds,
oldRepoId,
newRepoId,
(meta) => ({
...structuredClone(meta),
...(targetProjectId ? { projectId: targetProjectId } : {}),
hostId: getRepoExecutionHostId(targetRepo),
projectHostSetupId: targetRepo.id
})
),
worktreeLineageById: rekeyLineageRecord(
sourceState.worktreeLineageById,
worktreeIds,
oldRepoId,
newRepoId
),
workspaceLineageByChildKey: rekeyWorkspaceLineageRecord(
sourceState.workspaceLineageByChildKey,
oldRepoId,
newRepoId
),
...(includeSessions
? {
workspaceSession: extractSessionForTransfer(
sourceState.workspaceSession,
oldRepoId,
newRepoId
),
workspaceSessionsByHostId: extractHostSessionsForTransfer(
sourceState.workspaceSessionsByHostId,
oldRepoId,
newRepoId
)
}
: {}),
sshTargets: sourceRepo.connectionId
? sourceState.sshTargets.filter((target) => target.id === sourceRepo.connectionId)
: [],
targetProjectId
}
}
export function applyPayloadToTarget(
targetState: TransferProfileState,
payload: TransferPayload
): TransferProfileState {
const next: TransferProfileState = {
...targetState,
repos: [...targetState.repos, payload.repo],
sparsePresetsByRepo: {
...targetState.sparsePresetsByRepo,
...(payload.sparsePresets.length > 0 ? { [payload.repo.id]: payload.sparsePresets } : {})
},
worktreeMeta: { ...targetState.worktreeMeta, ...payload.worktreeMeta },
worktreeLineageById: { ...targetState.worktreeLineageById, ...payload.worktreeLineageById },
workspaceLineageByChildKey: {
...targetState.workspaceLineageByChildKey,
...payload.workspaceLineageByChildKey
},
sshTargets: mergeSshTargets(targetState.sshTargets, payload.sshTargets)
}
if (payload.workspaceSession) {
next.workspaceSession = mergeWorkspaceSessions(
targetState.workspaceSession,
payload.workspaceSession
)
}
if (payload.workspaceSessionsByHostId) {
next.workspaceSessionsByHostId = mergeHostWorkspaceSessions(
targetState.workspaceSessionsByHostId,
payload.workspaceSessionsByHostId
)
}
return rebuildRepoBackedProjectState(next)
}
function mergeSshTargets(existing: SshTarget[], incoming: SshTarget[]): SshTarget[] {
const existingIds = new Set(existing.map((target) => target.id))
return [...existing, ...incoming.filter((target) => !existingIds.has(target.id))]
}
@@ -0,0 +1,301 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
import { getDefaultPersistedState } from '../../shared/constants'
import {
ORCA_PROFILE_INDEX_SCHEMA_VERSION,
type OrcaProfileIndex
} from '../../shared/orca-profiles'
import type { PersistedState, Repo, WorktreeMeta } from '../../shared/types'
import type { SshTarget } from '../../shared/ssh-types'
const testState = { dir: '' }
vi.mock('electron', () => ({
app: {
getPath: () => testState.dir
}
}))
async function loadTransferModule() {
vi.resetModules()
return import('./profile-project-transfer')
}
function profile(id: string, name: string): OrcaProfileIndex['profiles'][number] {
return {
id,
name,
avatar: { kind: 'initials', initials: name[0], color: 'neutral' },
kind: 'local',
createdAt: 1,
updatedAt: 1,
lastOpenedAt: 1
}
}
function writeIndex(activeProfileId = 'personal'): void {
const index: OrcaProfileIndex = {
schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION,
activeProfileId,
profiles: [profile('personal', 'Personal'), profile('work', 'Work')]
}
writeFileSync(join(testState.dir, 'orca-profile-index.json'), JSON.stringify(index), 'utf-8')
}
function profileDataPath(profileId: string): string {
return join(testState.dir, 'profiles', profileId, 'orca-data.json')
}
function writeProfileState(profileId: string, state: PersistedState): void {
const dataFile = profileDataPath(profileId)
mkdirSync(join(dataFile, '..'), { recursive: true })
writeFileSync(dataFile, JSON.stringify(state, null, 2), 'utf-8')
}
function readProfileState(profileId: string): PersistedState {
return JSON.parse(readFileSync(profileDataPath(profileId), 'utf-8')) as PersistedState
}
function makeRepo(overrides: Partial<Repo> = {}): Repo {
return {
id: 'repo-1',
path: '/workspace/orca',
displayName: 'Orca',
badgeColor: '#33aa99',
addedAt: 100,
kind: 'git',
connectionId: null,
...overrides
}
}
function makeWorktreeMeta(overrides: Partial<WorktreeMeta> = {}): WorktreeMeta {
return {
displayName: 'Feature',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 10,
lastActivityAt: 123,
...overrides
}
}
function makeState(overrides: Partial<PersistedState> = {}): PersistedState {
const defaults = getDefaultPersistedState('/Users/tester')
return {
...defaults,
...overrides,
settings: { ...defaults.settings, ...overrides.settings },
ui: { ...defaults.ui, ...overrides.ui },
workspaceSession: { ...defaults.workspaceSession, ...overrides.workspaceSession }
}
}
describe('profile project transfer', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-profile-transfer-'))
writeIndex()
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
})
it('copies a project into another profile with a new repo id and re-keyed metadata', async () => {
const sourceWorktreeId = 'repo-1::/workspace/orca-feature'
writeProfileState(
'personal',
makeState({
repos: [makeRepo()],
sparsePresetsByRepo: {
'repo-1': [
{
id: 'preset-1',
repoId: 'repo-1',
name: 'UI',
directories: ['src/renderer'],
createdAt: 1,
updatedAt: 1
}
]
},
worktreeMeta: {
[sourceWorktreeId]: makeWorktreeMeta({ projectHostSetupId: 'repo-1' })
},
workspaceSession: {
...getDefaultPersistedState('/Users/tester').workspaceSession,
tabsByWorktree: {
[sourceWorktreeId]: [
{
id: 'tab-1',
ptyId: 'pty-1',
worktreeId: sourceWorktreeId,
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
}
}
})
)
writeProfileState('work', makeState())
const { transferOrcaProfileProject } = await loadTransferModule()
const result = transferOrcaProfileProject(
{
sourceProfileId: 'personal',
targetProfileId: 'work',
repoId: 'repo-1',
mode: 'copy'
},
testState.dir
)
expect(result.status).toBe('transferred')
expect(result.status === 'transferred' ? result.targetRepoId : '').not.toBe('repo-1')
const targetRepoId = result.status === 'transferred' ? result.targetRepoId : ''
const target = readProfileState('work')
const targetWorktreeId = `${targetRepoId}::/workspace/orca-feature`
expect(target.repos).toEqual([
expect.objectContaining({ id: targetRepoId, path: '/workspace/orca' })
])
expect(target.worktreeMeta[targetWorktreeId]).toMatchObject({
displayName: 'Feature',
projectHostSetupId: targetRepoId,
hostId: 'local'
})
expect(target.sparsePresetsByRepo[targetRepoId]).toEqual([
expect.objectContaining({ id: 'preset-1', repoId: targetRepoId })
])
expect(target.workspaceSession.tabsByWorktree).toEqual({})
expect(readProfileState('personal').repos.map((repo) => repo.id)).toEqual(['repo-1'])
})
it('moves a project, preserving SSH identity and restorable workspace session state', async () => {
const sourceWorktreeId = 'repo-ssh::/srv/orca-feature'
const sshTarget: SshTarget = {
id: 'ssh-1',
label: 'Builder',
host: 'builder.example.com',
port: 22,
username: 'dev'
}
writeProfileState(
'personal',
makeState({
repos: [
makeRepo({
id: 'repo-ssh',
path: '/srv/orca',
connectionId: 'ssh-1',
executionHostId: 'ssh:ssh-1'
})
],
sshTargets: [sshTarget],
worktreeMeta: {
[sourceWorktreeId]: makeWorktreeMeta({ projectHostSetupId: 'repo-ssh' })
},
workspaceSession: {
...getDefaultPersistedState('/Users/tester').workspaceSession,
browserTabsByWorktree: {
[sourceWorktreeId]: [
{
id: 'browser-1',
worktreeId: sourceWorktreeId,
sessionProfileId: 'source-browser-profile',
sessionPartition: 'persist:orca-profile-personal-deadbeef-browser-default',
url: 'https://example.com',
title: 'Example',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 1
}
]
}
}
})
)
writeProfileState('work', makeState())
const { transferOrcaProfileProject } = await loadTransferModule()
const result = transferOrcaProfileProject(
{
sourceProfileId: 'personal',
targetProfileId: 'work',
repoId: 'repo-ssh',
mode: 'move'
},
testState.dir
)
expect(result).toMatchObject({
status: 'transferred',
sourceRepoId: 'repo-ssh',
targetRepoId: 'repo-ssh'
})
const source = readProfileState('personal')
const target = readProfileState('work')
expect(source.repos).toEqual([])
expect(source.worktreeMeta).toEqual({})
expect(target.repos[0]).toMatchObject({
id: 'repo-ssh',
path: '/srv/orca',
connectionId: 'ssh-1',
executionHostId: 'ssh:ssh-1'
})
expect(target.sshTargets).toEqual([sshTarget])
expect(target.workspaceSession.browserTabsByWorktree?.[sourceWorktreeId]?.[0]).toMatchObject({
worktreeId: sourceWorktreeId,
sessionProfileId: null,
sessionPartition: null
})
})
it('rejects a duplicate physical project inside the target profile', async () => {
writeProfileState(
'personal',
makeState({
repos: [makeRepo({ path: 'C:\\Work\\Orca\\' })]
})
)
writeProfileState(
'work',
makeState({
repos: [makeRepo({ id: 'repo-existing', path: 'c:/work/orca' })]
})
)
const { transferOrcaProfileProject } = await loadTransferModule()
const result = transferOrcaProfileProject(
{
sourceProfileId: 'personal',
targetProfileId: 'work',
repoId: 'repo-1',
mode: 'copy'
},
testState.dir
)
expect(result).toEqual({
status: 'duplicate-target',
sourceProfileId: 'personal',
targetProfileId: 'work',
sourceRepoId: 'repo-1',
duplicateRepoId: 'repo-existing'
})
expect(readProfileState('work').repos.map((repo) => repo.id)).toEqual(['repo-existing'])
})
})
@@ -0,0 +1,77 @@
import type {
TransferOrcaProfileProjectArgs,
TransferOrcaProfileProjectResult
} from '../../shared/orca-profiles'
import { getOrcaProfileListState } from './profile-index-store'
import { readProfileState, writeProfileState } from './profile-project-state-file'
import { removeSourceRepo } from './profile-project-source-removal'
import {
applyPayloadToTarget,
createTargetRepo,
createTransferPayload
} from './profile-project-transfer-payload'
import { repoPhysicalKey } from './profile-project-worktree-identity'
function assertKnownProfiles(args: TransferOrcaProfileProjectArgs, userDataPath: string): void {
const profiles = getOrcaProfileListState(userDataPath).profiles
const ids = new Set(profiles.map((profile) => profile.id))
if (!ids.has(args.sourceProfileId)) {
throw new Error('unknown_source_orca_profile')
}
if (!ids.has(args.targetProfileId)) {
throw new Error('unknown_target_orca_profile')
}
if (args.sourceProfileId === args.targetProfileId) {
throw new Error('matching_orca_profile_transfer')
}
}
export function transferOrcaProfileProject(
args: TransferOrcaProfileProjectArgs,
userDataPath: string
): TransferOrcaProfileProjectResult {
assertKnownProfiles(args, userDataPath)
const sourceState = readProfileState(args.sourceProfileId, userDataPath)
const targetState = readProfileState(args.targetProfileId, userDataPath)
const sourceRepo = sourceState.repos.find((repo) => repo.id === args.repoId)
if (!sourceRepo) {
throw new Error('unknown_source_repo')
}
const duplicate = targetState.repos.find(
(repo) => repoPhysicalKey(repo) === repoPhysicalKey(sourceRepo)
)
if (duplicate) {
return {
status: 'duplicate-target',
sourceProfileId: args.sourceProfileId,
targetProfileId: args.targetProfileId,
sourceRepoId: sourceRepo.id,
duplicateRepoId: duplicate.id
}
}
const targetRepo = createTargetRepo(sourceRepo, targetState, args.mode === 'copy')
const payload = createTransferPayload({
sourceState,
sourceRepo,
targetRepo,
includeSessions: args.mode === 'move'
})
writeProfileState(args.targetProfileId, userDataPath, applyPayloadToTarget(targetState, payload))
if (args.mode === 'move') {
writeProfileState(
args.sourceProfileId,
userDataPath,
removeSourceRepo(sourceState, sourceRepo.id)
)
}
return {
status: 'transferred',
mode: args.mode,
sourceProfileId: args.sourceProfileId,
targetProfileId: args.targetProfileId,
sourceRepoId: sourceRepo.id,
targetRepoId: targetRepo.id,
targetProjectId: payload.targetProjectId
}
}
@@ -0,0 +1,77 @@
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import { getRepoExecutionHostId } from '../../shared/execution-host'
import type { Repo, WorkspaceKey } from '../../shared/types'
import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope'
import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree-id'
export function repoPhysicalKey(
repo: Pick<Repo, 'path' | 'connectionId' | 'executionHostId'>
): string {
return [
getRepoExecutionHostId(repo),
repo.connectionId?.trim() ?? '',
normalizeRuntimePathForComparison(repo.path)
].join('\0')
}
export function isRepoWorktreeId(repoId: string, worktreeId: string): boolean {
return worktreeId === repoId || worktreeId.startsWith(`${repoId}${WORKTREE_ID_SEPARATOR}`)
}
export function rekeyWorktreeId(oldRepoId: string, newRepoId: string, worktreeId: string): string {
if (worktreeId === oldRepoId) {
return newRepoId
}
const prefix = `${oldRepoId}${WORKTREE_ID_SEPARATOR}`
return worktreeId.startsWith(prefix)
? `${newRepoId}${WORKTREE_ID_SEPARATOR}${worktreeId.slice(prefix.length)}`
: worktreeId
}
export function rekeyWorkspaceKey(
oldRepoId: string,
newRepoId: string,
workspaceKey: WorkspaceKey
): WorkspaceKey {
const parsed = parseWorkspaceKey(workspaceKey)
if (parsed?.type !== 'worktree' || !isRepoWorktreeId(oldRepoId, parsed.worktreeId)) {
return workspaceKey
}
return worktreeWorkspaceKey(rekeyWorktreeId(oldRepoId, newRepoId, parsed.worktreeId))
}
export function rekeyOwnerKey(
oldRepoId: string,
newRepoId: string,
ownerKey: string
): string | null {
if (isRepoWorktreeId(oldRepoId, ownerKey)) {
return rekeyWorktreeId(oldRepoId, newRepoId, ownerKey)
}
const parsed = parseWorkspaceKey(ownerKey)
if (parsed?.type === 'worktree' && isRepoWorktreeId(oldRepoId, parsed.worktreeId)) {
return worktreeWorkspaceKey(rekeyWorktreeId(oldRepoId, newRepoId, parsed.worktreeId))
}
return null
}
export function ownerKeyBelongsToRepo(ownerKey: string, repoId: string): boolean {
if (isRepoWorktreeId(repoId, ownerKey)) {
return true
}
const parsed = parseWorkspaceKey(ownerKey)
return parsed?.type === 'worktree' && isRepoWorktreeId(repoId, parsed.worktreeId)
}
export function removeRepoWorktreeRecord<T>(
record: Record<string, T> | undefined,
repoId: string
): Record<string, T> {
const next = { ...record }
for (const key of Object.keys(next)) {
if (ownerKeyBelongsToRepo(key, repoId)) {
delete next[key]
}
}
return next
}
@@ -0,0 +1,72 @@
import { app } from 'electron'
import { join } from 'node:path'
const LEGACY_DATA_FILE_NAME = 'orca-data.json'
const LEGACY_BROWSER_SESSION_META_FILE_NAME = 'browser-session-meta.json'
const PROFILE_INDEX_FILE_NAME = 'orca-profile-index.json'
const PROFILE_DATA_FILE_NAME = 'orca-data.json'
const PROFILE_BROWSER_SESSION_META_FILE_NAME = 'browser-session-meta.json'
const PROFILE_DIRECTORY_NAME = 'profiles'
export const LEGACY_BACKUP_COUNT = 5
let profileUserDataPath: string | null = null
export function initOrcaProfilePaths(): void {
profileUserDataPath = app.getPath('userData')
}
export function getProfileUserDataPath(): string {
if (!profileUserDataPath) {
profileUserDataPath = app.getPath('userData')
}
return profileUserDataPath
}
export function getOrcaProfileIndexPath(userDataPath = getProfileUserDataPath()): string {
return join(userDataPath, PROFILE_INDEX_FILE_NAME)
}
export function getOrcaProfilesDirectory(userDataPath = getProfileUserDataPath()): string {
return join(userDataPath, PROFILE_DIRECTORY_NAME)
}
export function getOrcaProfileDirectory(
profileId: string,
userDataPath = getProfileUserDataPath()
): string {
return join(getOrcaProfilesDirectory(userDataPath), profileId)
}
export function getOrcaProfileDataFile(
profileId: string,
userDataPath = getProfileUserDataPath()
): string {
return join(getOrcaProfileDirectory(profileId, userDataPath), PROFILE_DATA_FILE_NAME)
}
export function getOrcaProfileBrowserSessionMetaFile(
profileId: string,
userDataPath = getProfileUserDataPath()
): string {
return join(
getOrcaProfileDirectory(profileId, userDataPath),
PROFILE_BROWSER_SESSION_META_FILE_NAME
)
}
export function legacyDataFilePath(userDataPath: string): string {
return join(userDataPath, LEGACY_DATA_FILE_NAME)
}
export function legacyBrowserSessionMetaPath(userDataPath: string): string {
return join(userDataPath, LEGACY_BROWSER_SESSION_META_FILE_NAME)
}
export function legacyBackupPath(userDataPath: string, index: number): string {
return `${legacyDataFilePath(userDataPath)}.bak.${index}`
}
export function profileBackupPath(profileDataFile: string, index: number): string {
return `${profileDataFile}.bak.${index}`
}
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { isMultiProfileUiEnabled } from './profile-ui-scope'
describe('isMultiProfileUiEnabled', () => {
it('enables multi-profile UI only when the flag is exactly "1"', () => {
expect(isMultiProfileUiEnabled({ ORCA_MULTI_PROFILE_UI: '1' })).toBe(true)
})
it('defaults to the single-profile account menu', () => {
expect(isMultiProfileUiEnabled({})).toBe(false)
expect(isMultiProfileUiEnabled({ ORCA_MULTI_PROFILE_UI: '0' })).toBe(false)
expect(isMultiProfileUiEnabled({ ORCA_MULTI_PROFILE_UI: 'true' })).toBe(false)
})
})
@@ -0,0 +1,6 @@
// Why: multi-profile management is downscoped to an accounts-first UX, not
// removed — the full switcher stays reachable behind this product-scope toggle
// (safe in packaged builds; it is not a security escape hatch).
export function isMultiProfileUiEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
return env.ORCA_MULTI_PROFILE_UI === '1'
}
+95
View File
@@ -337,6 +337,31 @@ describe('Store', () => {
expect(store.getRepos()).toEqual([])
}, 15_000)
it('loads state from an explicit profile data file path', async () => {
const profileDataDirectory = join(testState.dir, 'profiles', 'local-default')
const profileDataFile = join(profileDataDirectory, 'orca-data.json')
mkdirSync(profileDataDirectory, { recursive: true })
writeDataFile({
schemaVersion: 1,
repos: [makeRepo({ id: 'legacy-root-repo', path: '/legacy' })]
})
writeFileSync(
profileDataFile,
JSON.stringify({
schemaVersion: 1,
repos: [makeRepo({ id: 'profile-repo', path: '/profile' })]
}),
'utf-8'
)
vi.resetModules()
const { Store, initDataPath } = await import('./persistence')
initDataPath()
const store = new Store({ dataFile: profileDataFile })
expect(store.getRepos().map((repo) => repo.id)).toEqual(['profile-repo'])
}, 15_000)
it('backfills project host setup compatibility records from legacy repos on load', async () => {
writeDataFile({
schemaVersion: 1,
@@ -5009,6 +5034,33 @@ describe('Store', () => {
expect(restarted.getGitHubCache().pr['o/r#7']).toEqual({ fetchedAt: 7 })
})
it('keeps GitHub cache sidecars scoped to explicit profile data files', async () => {
const profileADir = join(testState.dir, 'profiles', 'a')
const profileBDir = join(testState.dir, 'profiles', 'b')
const profileADataFile = join(profileADir, 'orca-data.json')
const profileBDataFile = join(profileBDir, 'orca-data.json')
mkdirSync(profileADir, { recursive: true })
mkdirSync(profileBDir, { recursive: true })
vi.resetModules()
const { Store, initDataPath } = await import('./persistence')
initDataPath()
const profileAStore = new Store({ dataFile: profileADataFile })
profileAStore.setGitHubCache({ pr: { 'o/r#a': { fetchedAt: 10 } as never }, issue: {} })
profileAStore.flush()
const profileBStore = new Store({ dataFile: profileBDataFile })
expect(profileBStore.getGitHubCache().pr['o/r#a']).toBeUndefined()
profileBStore.setGitHubCache({ pr: { 'o/r#b': { fetchedAt: 20 } as never }, issue: {} })
profileBStore.flush()
const restartedProfileA = new Store({ dataFile: profileADataFile })
const restartedProfileB = new Store({ dataFile: profileBDataFile })
expect(restartedProfileA.getGitHubCache().pr['o/r#a']).toEqual({ fetchedAt: 10 })
expect(restartedProfileA.getGitHubCache().pr['o/r#b']).toBeUndefined()
expect(restartedProfileB.getGitHubCache().pr['o/r#b']).toEqual({ fetchedAt: 20 })
})
it('keeps a legacy in-file cache as the seed and strips it from disk', async () => {
writeDataFile({ githubCache: { pr: { legacy: { fetchedAt: 1 } }, issue: {} } })
@@ -6380,6 +6432,49 @@ describe('Store', () => {
expect(ref ? store.readTerminalScrollbackSnapshot(ref) : null).toBe('remote-scrollback')
})
it('stores terminal scrollback snapshots beside explicit profile data files', async () => {
const profileDataDirectory = join(testState.dir, 'profiles', 'local-default')
const profileDataFile = join(profileDataDirectory, 'orca-data.json')
mkdirSync(profileDataDirectory, { recursive: true })
vi.resetModules()
const { Store, initDataPath } = await import('./persistence')
initDataPath()
const store = new Store({ dataFile: profileDataFile })
store.addRepo(makeRepo({ id: 'remote-repo', connectionId: 'ssh-target-1' }))
const session = makeSessionWithTerminalBuffers()
store.setWorkspaceSession({
...session,
tabsByWorktree: { 'remote-repo::/remote': session.tabsByWorktree['remote-repo::/remote'] },
terminalLayoutsByTabId: { 'remote-tab': session.terminalLayoutsByTabId['remote-tab'] }
})
const ref =
store.getWorkspaceSession().terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId?.[
TEST_LEAF_2
]
expect(ref).toEqual(expect.stringMatching(/^v1-[0-9a-f]{32}$/))
expect(existsSync(join(profileDataDirectory, 'terminal-scrollback', `${ref}.bin`))).toBe(true)
expect(existsSync(join(testState.dir, 'terminal-scrollback', `${ref}.bin`))).toBe(false)
})
it('reads legacy terminal scrollback snapshots for explicit profile data files', async () => {
const profileDataDirectory = join(testState.dir, 'profiles', 'local-default')
const profileDataFile = join(profileDataDirectory, 'orca-data.json')
const ref = 'v1-11111111111111111111111111111111'
const legacySnapshotDir = join(testState.dir, 'terminal-scrollback')
mkdirSync(profileDataDirectory, { recursive: true })
mkdirSync(legacySnapshotDir, { recursive: true })
writeFileSync(join(legacySnapshotDir, `${ref}.bin`), 'legacy-scrollback', 'utf-8')
vi.resetModules()
const { Store, initDataPath } = await import('./persistence')
initDataPath()
const store = new Store({ dataFile: profileDataFile })
expect(store.readTerminalScrollbackSnapshot(ref)).toBe('legacy-scrollback')
})
it('caps oversized browser history when setting workspace session', async () => {
const store = await createStore()
const oversizedSession = makeSessionWithBrowserHistory(500)
+62 -17
View File
@@ -219,8 +219,10 @@ import {
import {
collectTerminalScrollbackSnapshotRefs,
deleteTerminalScrollbackSnapshotSync,
getProfileTerminalScrollbackSnapshotRoot,
migrateWorkspaceSessionTerminalScrollbackSnapshots,
readTerminalScrollbackSnapshotSync
readTerminalScrollbackSnapshotSync,
type TerminalScrollbackSnapshotStorage
} from './terminal-scrollback-snapshots'
import { track } from './telemetry/client'
import { getCohortAtEmit } from './telemetry/cohort-classifier'
@@ -342,8 +344,8 @@ function getDataFile(): string {
// the content-hash guard by design). It lives in memory during the session
// and is snapshotted here best-effort at quit so PR/issue badges still paint
// instantly on the next launch. Loss of this file costs nothing.
function getGithubCacheFile(): string {
return join(dirname(getDataFile()), 'orca-github-cache.json')
function getGithubCacheFile(dataFile = getDataFile()): string {
return join(dirname(dataFile), 'orca-github-cache.json')
}
// Why: worktrees deleted outside Orca (git CLI worktree remove, rm -rf,
@@ -422,9 +424,9 @@ function gcStaleWorktreeMeta(state: PersistedState): number {
return removed
}
function readGithubCacheSnapshot(): PersistedState['githubCache'] | null {
function readGithubCacheSnapshot(dataFile: string): PersistedState['githubCache'] | null {
try {
const parsed = JSON.parse(readFileSync(getGithubCacheFile(), 'utf-8')) as unknown
const parsed = JSON.parse(readFileSync(getGithubCacheFile(dataFile), 'utf-8')) as unknown
const isPlainRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
if (
@@ -2574,7 +2576,8 @@ function backfillFolderScopeConnectionIds(state: PersistedState): {
function deleteRemovedTerminalScrollbackSnapshots(
prior: WorkspaceSessionState | undefined,
next: WorkspaceSessionState
next: WorkspaceSessionState,
storage?: TerminalScrollbackSnapshotStorage
): void {
if (!prior) {
return
@@ -2582,16 +2585,26 @@ function deleteRemovedTerminalScrollbackSnapshots(
const nextRefs = collectTerminalScrollbackSnapshotRefs(next)
for (const ref of collectTerminalScrollbackSnapshotRefs(prior)) {
if (!nextRefs.has(ref)) {
deleteTerminalScrollbackSnapshotSync(ref)
deleteTerminalScrollbackSnapshotSync(ref, storage)
}
}
}
export type StoreOptions = {
dataFile?: string
}
export class Store {
private state: PersistedState
private readonly dataFile: string
private readonly terminalScrollbackSnapshotStorage: TerminalScrollbackSnapshotStorage
private writeTimer: ReturnType<typeof setTimeout> | null = null
private pendingWrite: Promise<void> | null = null
private writeGeneration = 0
// Why: after a profile transfer rewrites this store's file on disk behind
// its back, the stale in-memory state must never be persisted again — a
// late sync flush before the relaunch would resurrect the moved project.
private writesFrozen = false
// Why: hash of the plaintext state as of the last successful write. Saves
// triggered by mutations that net out to identical state skip the full
// 1.6MB pretty-print + tmp write + rename. Hashing plaintext (not the
@@ -2611,7 +2624,16 @@ export class Store {
>()
private uiChangeListeners = new Set<(ui: PersistedState['ui']) => void>()
constructor() {
constructor(options: StoreOptions = {}) {
// Why: profile switching creates more than one possible state path. Capture
// the path per Store instance so late async writes cannot follow a global path.
this.dataFile = options.dataFile ?? getDataFile()
const profileSnapshotRoot = getProfileTerminalScrollbackSnapshotRoot(this.dataFile)
const legacySnapshotRoot = getProfileTerminalScrollbackSnapshotRoot(getDataFile())
this.terminalScrollbackSnapshotStorage = {
snapshotRoot: profileSnapshotRoot,
fallbackSnapshotRoot: legacySnapshotRoot === profileSnapshotRoot ? null : legacySnapshotRoot
}
const loaded = this.load()
const normalized = normalizePersistedPaneIdentityState(loaded)
this.state = normalized.state
@@ -2820,7 +2842,7 @@ export class Store {
// would be absent on every pre-telemetry install and misclassify existing
// users as fresh, flipping them to default-on in violation of the
// social contract we installed them under.
const dataFile = getDataFile()
const dataFile = this.dataFile
const fileExistedOnLoad = existsSync(dataFile)
logPersistenceStartupMilestone('persistence-load-start', {
fileExists: fileExistedOnLoad
@@ -3412,7 +3434,10 @@ export class Store {
const workspaceSession = pruneWorkspaceSessionBrowserHistory(
pruneLocalTerminalScrollbackBuffers(result.workspaceSession, result.repos)
)
const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots(workspaceSession)
const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots(
workspaceSession,
this.terminalScrollbackSnapshotStorage
)
if (migratedScrollback.changed) {
this.loadNeedsSave = true
}
@@ -3468,7 +3493,7 @@ export class Store {
// poll refresh happens this session — the seed survives the migration.
this.githubCacheDirty = true
} else {
migrated.githubCache = readGithubCacheSnapshot() ?? migrated.githubCache
migrated.githubCache = readGithubCacheSnapshot(this.dataFile) ?? migrated.githubCache
}
logPersistenceStartupMilestone('persistence-load-done', {
@@ -3616,6 +3641,9 @@ export class Store {
// Why: async writes avoid blocking the main Electron thread on every
// debounced save during active use.
private async writeToDiskAsync(): Promise<void> {
if (this.writesFrozen) {
return
}
const gen = this.writeGeneration
const stateHash = this.computeStateHash()
// Why: a mutation burst that nets out to already-persisted state (or a
@@ -3624,7 +3652,7 @@ export class Store {
return
}
const payload = this.buildStateToSave()
const dataFile = getDataFile()
const dataFile = this.dataFile
const dir = dirname(dataFile)
await mkdir(dir, { recursive: true }).catch(() => {})
const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
@@ -3669,6 +3697,9 @@ export class Store {
// Why: synchronous variant kept only for flush() at shutdown, where the
// process may exit before an async write completes.
private writeToDiskSync(opts: { force?: boolean } = {}): void {
if (this.writesFrozen) {
return
}
const stateHash = this.computeStateHash()
// Why: skipping is safe under flushOrThrow's durability contract — a
// matching hash means this exact state is already the file's content.
@@ -3678,7 +3709,7 @@ export class Store {
if (!opts.force && stateHash === this.lastWrittenStateHash) {
return
}
const dataFile = getDataFile()
const dataFile = this.dataFile
const dir = dirname(dataFile)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
@@ -5533,7 +5564,7 @@ export class Store {
}
readTerminalScrollbackSnapshot(ref: string): string | null {
return readTerminalScrollbackSnapshotSync(ref)
return readTerminalScrollbackSnapshotSync(ref, this.terminalScrollbackSnapshotStorage)
}
/** Resolve the worktree a terminal tab belongs to, from the session's
@@ -5707,9 +5738,12 @@ export class Store {
}
}
session = pruneLocalTerminalScrollbackBuffers(session, this.state.repos)
const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots(session)
const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots(
session,
this.terminalScrollbackSnapshotStorage
)
session = migratedScrollback.session
deleteRemovedTerminalScrollbackSnapshots(prior, session)
deleteRemovedTerminalScrollbackSnapshots(prior, session, this.terminalScrollbackSnapshotStorage)
this.state.workspaceSession = session
this.scheduleSave()
}
@@ -6414,13 +6448,24 @@ export class Store {
this.writeGithubCacheSnapshotSync()
}
// Why: called after a project move rewrote this store's data file directly.
// From that point until relaunch, the in-memory state is stale and any
// write (debounced, sync, or shutdown flush) would undo the transfer.
freezeWrites(): void {
this.writesFrozen = true
if (this.writeTimer) {
clearTimeout(this.writeTimer)
this.writeTimer = null
}
}
// Why best-effort: the sidecar is a refetchable cache — a failed write only
// costs a cold badge paint on next launch, never data.
private writeGithubCacheSnapshotSync(): void {
if (!this.githubCacheDirty) {
return
}
const cacheFile = getGithubCacheFile()
const cacheFile = getGithubCacheFile(this.dataFile)
const tmpFile = `${cacheFile}.${process.pid}.tmp`
try {
writeFileSync(tmpFile, JSON.stringify(this.state.githubCache), 'utf-8')
+59 -26
View File
@@ -9,7 +9,7 @@ import {
statSync,
writeFileSync
} from 'node:fs'
import { join } from 'node:path'
import { dirname, join } from 'node:path'
import { app } from 'electron'
import type { WorkspaceSessionState } from '../shared/types'
import {
@@ -20,20 +20,47 @@ import {
const SNAPSHOT_DIR_NAME = 'terminal-scrollback'
const REF_PREFIX = 'v1'
function getSnapshotRoot(): string {
export type TerminalScrollbackSnapshotStorage = {
snapshotRoot?: string
fallbackSnapshotRoot?: string | null
}
function getLegacySnapshotRoot(): string {
return join(app.getPath('userData'), SNAPSHOT_DIR_NAME)
}
export function getProfileTerminalScrollbackSnapshotRoot(dataFile: string): string {
return join(dirname(dataFile), SNAPSHOT_DIR_NAME)
}
function getSnapshotRoot(storage?: TerminalScrollbackSnapshotStorage): string {
return storage?.snapshotRoot ?? getLegacySnapshotRoot()
}
export function makeTerminalScrollbackSnapshotRef(tabId: string, leafId: string): string {
const hash = createHash('sha256').update(`${tabId}\0${leafId}`).digest('hex').slice(0, 32)
return `${REF_PREFIX}-${hash}`
}
function snapshotPath(ref: string): string | null {
function snapshotPath(ref: string, snapshotRoot: string): string | null {
if (!/^v1-[0-9a-f]{32}$/.test(ref)) {
return null
}
return join(getSnapshotRoot(), `${ref}.bin`)
return join(snapshotRoot, `${ref}.bin`)
}
function snapshotReadPaths(ref: string, storage?: TerminalScrollbackSnapshotStorage): string[] {
const primaryRoot = getSnapshotRoot(storage)
const primaryPath = snapshotPath(ref, primaryRoot)
if (!primaryPath) {
return []
}
const fallbackRoot = storage?.fallbackSnapshotRoot ?? null
if (!fallbackRoot || fallbackRoot === primaryRoot) {
return [primaryPath]
}
const fallbackPath = snapshotPath(ref, fallbackRoot)
return fallbackPath ? [primaryPath, fallbackPath] : [primaryPath]
}
function trailingUtf8Bytes(value: string, maxBytes: number): Buffer {
@@ -72,17 +99,19 @@ export function writeTerminalScrollbackSnapshotSync(args: {
tabId: string
leafId: string
buffer: string
storage?: TerminalScrollbackSnapshotStorage
}): string | null {
if (!args.buffer) {
return null
}
const ref = makeTerminalScrollbackSnapshotRef(args.tabId, args.leafId)
const path = snapshotPath(ref)
const snapshotRoot = getSnapshotRoot(args.storage)
const path = snapshotPath(ref, snapshotRoot)
if (!path) {
return null
}
try {
mkdirSync(getSnapshotRoot(), { recursive: true, mode: 0o700 })
mkdirSync(snapshotRoot, { recursive: true, mode: 0o700 })
const tmpPath = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
const bytes = trailingUtf8Bytes(args.buffer, TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT)
let renamed = false
@@ -104,27 +133,30 @@ export function writeTerminalScrollbackSnapshotSync(args: {
}
}
export function readTerminalScrollbackSnapshotSync(ref: string): string | null {
const path = snapshotPath(ref)
if (!path) {
return null
}
try {
return readTrailingUtf8(path, TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT)
} catch {
return null
export function readTerminalScrollbackSnapshotSync(
ref: string,
storage?: TerminalScrollbackSnapshotStorage
): string | null {
for (const path of snapshotReadPaths(ref, storage)) {
try {
return readTrailingUtf8(path, TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT)
} catch {
// Try the legacy/global fallback when a profile-local snapshot is absent.
}
}
return null
}
export function deleteTerminalScrollbackSnapshotSync(ref: string): void {
const path = snapshotPath(ref)
if (!path) {
return
}
try {
rmSync(path, { force: true })
} catch {
// Best-effort cleanup; stale refs are harmless and bounded by per-file caps.
export function deleteTerminalScrollbackSnapshotSync(
ref: string,
storage?: TerminalScrollbackSnapshotStorage
): void {
for (const path of snapshotReadPaths(ref, storage)) {
try {
rmSync(path, { force: true })
} catch {
// Best-effort cleanup; stale refs are harmless and bounded by per-file caps.
}
}
}
@@ -139,7 +171,8 @@ export function collectTerminalScrollbackSnapshotRefs(session: WorkspaceSessionS
}
export function migrateWorkspaceSessionTerminalScrollbackSnapshots(
session: WorkspaceSessionState
session: WorkspaceSessionState,
storage?: TerminalScrollbackSnapshotStorage
): { session: WorkspaceSessionState; changed: boolean } {
let terminalLayoutsByTabId: WorkspaceSessionState['terminalLayoutsByTabId'] | null = null
for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId ?? {})) {
@@ -151,7 +184,7 @@ export function migrateWorkspaceSessionTerminalScrollbackSnapshots(
const remainingBuffers: Record<string, string> = {}
let layoutChanged = false
for (const [leafId, buffer] of Object.entries(buffers)) {
const ref = writeTerminalScrollbackSnapshotSync({ tabId, leafId, buffer })
const ref = writeTerminalScrollbackSnapshotSync({ tabId, leafId, buffer, storage })
if (ref) {
refs[leafId] = ref
layoutChanged = true
+60
View File
@@ -11,6 +11,32 @@ import type {
import type { NativeFileDropPayload } from '../shared/native-file-drop'
import type { ReadClipboardTextOptions } from '../shared/clipboard-text'
import type { AppIdentity } from '../shared/app-identity'
import type {
CreateLocalOrcaProfileArgs,
CreateLocalOrcaProfileResult,
CreateCloudLinkedOrcaProfileArgs,
CreateCloudLinkedOrcaProfileResult,
ConnectCurrentOrcaProfileResult,
FindOrcaProfileProjectsByPathArgs,
FindOrcaProfileProjectsByPathResult,
OrcaProfileListResult,
OrcaProfileAuthStatus,
RefreshCurrentOrcaProfileAuthResult,
SelectOrcaProfileOrgArgs,
SelectOrcaProfileOrgResult,
SignOutCurrentOrcaProfileResult,
SwitchOrcaProfileArgs,
SwitchOrcaProfileResult,
TransferOrcaProfileProjectArgs,
TransferOrcaProfileProjectResult,
OrcaProfileOrgInviteRevokeArgs,
OrcaProfileOrgMemberChangeRoleArgs,
OrcaProfileOrgMemberInviteArgs,
OrcaProfileOrgMemberMutationResult,
OrcaProfileOrgMemberRemoveArgs,
OrcaProfileOrgMembersListArgs,
OrcaProfileOrgMembersListResult
} from '../shared/orca-profiles'
import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry'
import type { TaskSourceContext } from '../shared/task-source-context'
import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime'
@@ -842,6 +868,40 @@ export type AppApi = {
export type PreloadApi = {
app: AppApi
orcaProfiles: {
list: () => Promise<OrcaProfileListResult>
authStatus: () => Promise<OrcaProfileAuthStatus>
createLocal: (args?: CreateLocalOrcaProfileArgs) => Promise<CreateLocalOrcaProfileResult>
createCloudLinked: (
args?: CreateCloudLinkedOrcaProfileArgs
) => Promise<CreateCloudLinkedOrcaProfileResult>
switchProfile: (args: SwitchOrcaProfileArgs) => Promise<SwitchOrcaProfileResult>
transferProject: (
args: TransferOrcaProfileProjectArgs
) => Promise<TransferOrcaProfileProjectResult>
findProjectProfiles: (
args: FindOrcaProfileProjectsByPathArgs
) => Promise<FindOrcaProfileProjectsByPathResult>
connectCurrent: () => Promise<ConnectCurrentOrcaProfileResult>
refreshAuth: () => Promise<RefreshCurrentOrcaProfileAuthResult>
signOutCurrent: () => Promise<SignOutCurrentOrcaProfileResult>
selectOrg: (args: SelectOrcaProfileOrgArgs) => Promise<SelectOrcaProfileOrgResult>
orgMembersList: (
args: OrcaProfileOrgMembersListArgs
) => Promise<OrcaProfileOrgMembersListResult>
orgMemberInvite: (
args: OrcaProfileOrgMemberInviteArgs
) => Promise<OrcaProfileOrgMemberMutationResult>
orgInviteRevoke: (
args: OrcaProfileOrgInviteRevokeArgs
) => Promise<OrcaProfileOrgMemberMutationResult>
orgMemberChangeRole: (
args: OrcaProfileOrgMemberChangeRoleArgs
) => Promise<OrcaProfileOrgMemberMutationResult>
orgMemberRemove: (
args: OrcaProfileOrgMemberRemoveArgs
) => Promise<OrcaProfileOrgMemberMutationResult>
}
platform: {
get: () => {
platform: NodeJS.Platform
+19
View File
@@ -482,6 +482,25 @@ const api = {
ipcRenderer.invoke('app:pickFloatingWorkspaceDirectory')
},
orcaProfiles: {
list: () => ipcRenderer.invoke('orcaProfiles:list'),
authStatus: () => ipcRenderer.invoke('orcaProfiles:authStatus'),
createLocal: (args) => ipcRenderer.invoke('orcaProfiles:createLocal', args),
createCloudLinked: (args) => ipcRenderer.invoke('orcaProfiles:createCloudLinked', args),
switchProfile: (args) => ipcRenderer.invoke('orcaProfiles:switch', args),
transferProject: (args) => ipcRenderer.invoke('orcaProfiles:transferProject', args),
findProjectProfiles: (args) => ipcRenderer.invoke('orcaProfiles:findProjectProfiles', args),
connectCurrent: () => ipcRenderer.invoke('orcaProfiles:connectCurrent'),
refreshAuth: () => ipcRenderer.invoke('orcaProfiles:refreshAuth'),
signOutCurrent: () => ipcRenderer.invoke('orcaProfiles:signOutCurrent'),
selectOrg: (args) => ipcRenderer.invoke('orcaProfiles:selectOrg', args),
orgMembersList: (args) => ipcRenderer.invoke('orcaProfiles:orgMembersList', args),
orgMemberInvite: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberInvite', args),
orgInviteRevoke: (args) => ipcRenderer.invoke('orcaProfiles:orgInviteRevoke', args),
orgMemberChangeRole: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberChangeRole', args),
orgMemberRemove: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberRemove', args)
} satisfies PreloadApi['orcaProfiles'],
platform: {
get: () => ({
platform: process.platform,
+29
View File
@@ -64,6 +64,7 @@ import { onOnboardingReopened } from './components/onboarding/show-onboarding-ev
import { shouldShowOnboarding } from './components/onboarding/should-show-onboarding'
import { MarkdownTemplatePicker } from './components/editor/MarkdownTemplatePicker'
import { FloatingTerminalToggleButton } from './components/floating-terminal/FloatingTerminalToggleButton'
import { OrcaProfileSwitcher } from './components/orca-profiles/OrcaProfileSwitcher'
import {
TOGGLE_FLOATING_TERMINAL_EVENT,
requestFloatingTerminalOpenMaximized
@@ -418,6 +419,7 @@ function App(): React.JSX.Element {
fetchFolderWorkspacesForAllHosts: s.fetchFolderWorkspacesForAllHosts,
fetchAllWorktrees: s.fetchAllWorktrees,
fetchWorktreeLineage: s.fetchWorktreeLineage,
fetchOrcaProfiles: s.fetchOrcaProfiles,
fetchSettings: s.fetchSettings,
fetchKeybindings: s.fetchKeybindings,
initGitHubCache: s.initGitHubCache,
@@ -864,6 +866,10 @@ function App(): React.JSX.Element {
const startupStartedAt = performance.now()
logRendererStartupDiagnostic('startup-chain-start')
try {
// Why: profile state only feeds the switcher and the add-project
// advisory — nothing in the hydration chain reads it synchronously,
// so it must not add a serial IPC round-trip before fetchSettings.
void actions.fetchOrcaProfiles()
// Why: repo/worktree hydration routes through settings.activeRuntimeEnvironmentId.
// Load settings first so a persisted remote runtime does not boot against
// the local filesystem and then hydrate stale local workspace state.
@@ -1494,6 +1500,8 @@ function App(): React.JSX.Element {
// Why: suppress right sidebar controls on full-page navigation surfaces
// since those surfaces intentionally own the full content area.
const showRightSidebarControls = !creationLayoutActive && canShowRightSidebarForView(activeView)
const showProfileSwitcherInSidebarFooter = showSidebar && sidebarOpen
const showProfileSwitcherInTopRight = !showProfileSwitcherInSidebarFooter
const handleToggleExpand = (): void => {
if (!effectiveActiveTabId) {
@@ -2135,6 +2143,7 @@ function App(): React.JSX.Element {
</TooltipContent>
</Tooltip>
)}
{showProfileSwitcherInTopRight ? <OrcaProfileSwitcher /> : null}
{/* Why: when the right sidebar is open, its own header renders
an identical close button — hide this copy so only one is
visible at a time. */}
@@ -2144,6 +2153,25 @@ function App(): React.JSX.Element {
{hasCustomTitleBar && <div className="window-controls-titlebar-spacer" />}
</>
)
const workspaceProfileSwitcher =
showProfileSwitcherInTopRight &&
workspaceChromeActive &&
leftTitlebarChromeLayout.shouldMount &&
!stackedSidebarOpen ? (
<div
className="absolute top-0 z-10 flex h-[36px] items-center"
style={
{
right: showRightSidebarControls
? 'calc(var(--window-controls-width) + 42px)'
: 'var(--window-controls-width)',
WebkitAppRegion: 'no-drag'
} as React.CSSProperties
}
>
<OrcaProfileSwitcher />
</div>
) : null
return (
<div
@@ -2321,6 +2349,7 @@ function App(): React.JSX.Element {
{rightSidebarToggle}
</div>
)}
{workspaceProfileSwitcher}
<div className="flex flex-1 min-w-0 min-h-0 flex-col">
{shouldMountTerminalWorkbench ? (
<div
+5
View File
@@ -957,6 +957,11 @@
color: var(--muted-foreground);
}
.titlebar-profile-switcher {
-webkit-app-region: no-drag;
flex-shrink: 0;
}
.editor-header {
display: flex;
align-items: center;
@@ -60,6 +60,7 @@ import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { useAppStore } from '@/store'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { ORCA_BROWSER_BLANK_URL, ORCA_BROWSER_PARTITION } from '../../../../shared/constants'
import { getOrcaProfileBrowserDefaultPartition } from '../../../../shared/orca-profiles'
import type {
BrowserLoadError,
BrowserPage as BrowserPageState,
@@ -2842,10 +2843,20 @@ function BrowserPagePane({
const createBrowserTab = useAppStore((s) => s.createBrowserTab)
const consumeAddressBarFocusRequest = useAppStore((s) => s.consumeAddressBarFocusRequest)
const browserSessionProfiles = useAppStore((s) => s.browserSessionProfiles)
const activeOrcaProfileId = useAppStore((s) => s.activeOrcaProfileId)
const fallbackBrowserPartition = activeOrcaProfileId
? getOrcaProfileBrowserDefaultPartition(activeOrcaProfileId)
: null
const defaultSessionProfile = browserSessionProfiles.find((p) => p.id === 'default') ?? null
const sessionProfile = sessionProfileId
? (browserSessionProfiles.find((p) => p.id === sessionProfileId) ?? null)
: null
const webviewPartition = sessionPartition ?? sessionProfile?.partition ?? ORCA_BROWSER_PARTITION
: defaultSessionProfile
const webviewPartition =
sessionPartition ??
sessionProfile?.partition ??
defaultSessionProfile?.partition ??
fallbackBrowserPartition ??
ORCA_BROWSER_PARTITION
const browserSessionImportState = useAppStore((s) => s.browserSessionImportState)
const clearBrowserSessionImportState = useAppStore((s) => s.clearBrowserSessionImportState)
const showBrowserZoomFeedback = useCallback((level: number): void => {
@@ -0,0 +1,22 @@
import { cn } from '@/lib/utils'
import type { OrcaProfileSummary } from '../../../../shared/orca-profiles'
export function OrcaProfileAvatar({
profile,
className
}: {
profile: OrcaProfileSummary
className?: string
}): React.JSX.Element {
return (
<span
className={cn(
'inline-flex size-5 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-[11px] font-semibold text-muted-foreground',
className
)}
aria-hidden
>
{profile.avatar.initials.slice(0, 2).toUpperCase()}
</span>
)
}
@@ -0,0 +1,126 @@
import { Check, Cloud, Loader2, LogIn, LogOut, Plus } from 'lucide-react'
import {
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger
} from '@/components/ui/dropdown-menu'
import { translate } from '@/i18n/i18n'
import type {
OrcaCloudOrgSummary,
OrcaProfileAuthStatus,
OrcaProfileSummary
} from '../../../../shared/orca-profiles'
function getConnectLabel(authStatus: OrcaProfileAuthStatus | null): string {
if (authStatus?.configured !== true) {
return translate(
'auto.components.orca.profiles.switcher.cloud.unavailable',
'Cloud sign-in unavailable'
)
}
if (authStatus.state === 'connected' || authStatus.state === 'reconnect-required') {
return translate('auto.components.orca.profiles.switcher.reconnect', 'Reconnect profile')
}
return translate('auto.components.orca.profiles.switcher.connect', 'Connect profile')
}
export function OrcaProfileCloudMenuItems({
activeProfile,
authStatus,
connecting,
profileActionDisabled,
allowProfileCreation,
onConnect,
onCreateProfileForOrg,
onSelectOrg,
onRequestSignOut
}: {
activeProfile: OrcaProfileSummary
authStatus: OrcaProfileAuthStatus | null
connecting: boolean
profileActionDisabled: boolean
allowProfileCreation: boolean
onConnect: () => void
onCreateProfileForOrg: (organization: OrcaCloudOrgSummary) => void
onSelectOrg: (orgId: string) => void
onRequestSignOut: () => void
}): React.JSX.Element {
const cloudConfigured = authStatus?.configured === true
const organizations = authStatus?.organizations ?? []
const showOrganizationChoices = activeProfile.kind === 'cloud-linked' && organizations.length > 1
// Why: profile creation is hidden in the downscoped account menu, so the
// "Create profile for org" submenu only appears when multi-profile UI is on.
const showCloudProfileCreation =
allowProfileCreation && activeProfile.kind === 'cloud-linked' && organizations.length > 0
const orgActionDisabled = profileActionDisabled || authStatus?.state !== 'connected'
const activeOrgId = activeProfile.cloud?.activeOrgId
return (
<>
{showOrganizationChoices || showCloudProfileCreation ? (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>
{translate('auto.components.orca.profiles.switcher.organization', 'Organization')}
</DropdownMenuLabel>
{showOrganizationChoices
? organizations.map((organization) => (
<DropdownMenuItem
key={organization.orgId}
disabled={orgActionDisabled}
onSelect={() => {
if (organization.orgId !== activeOrgId) {
onSelectOrg(organization.orgId)
}
}}
className="min-w-0"
>
<Cloud />
<span className="min-w-0 flex-1 truncate">{organization.name}</span>
{organization.orgId === activeOrgId ? <Check className="size-3.5" /> : null}
</DropdownMenuItem>
))
: null}
{showCloudProfileCreation ? (
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={orgActionDisabled}>
<Plus />
{translate(
'auto.components.orca.profiles.switcher.create.profile.for.org',
'Create profile for org'
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-48">
{organizations.map((organization) => (
<DropdownMenuItem
key={organization.orgId}
onSelect={() => onCreateProfileForOrg(organization)}
className="min-w-0"
>
<Cloud />
<span className="min-w-0 flex-1 truncate">{organization.name}</span>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
) : null}
</>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem disabled={profileActionDisabled || !cloudConfigured} onSelect={onConnect}>
{connecting ? <Loader2 className="size-4 animate-spin" /> : <LogIn />}
{getConnectLabel(authStatus)}
</DropdownMenuItem>
{activeProfile.kind === 'cloud-linked' ? (
<DropdownMenuItem disabled={profileActionDisabled} onSelect={onRequestSignOut}>
<LogOut />
{translate('auto.components.orca.profiles.switcher.signout', 'Sign out')}
</DropdownMenuItem>
) : null}
</>
)
}
@@ -0,0 +1,66 @@
import { Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { translate } from '@/i18n/i18n'
export function OrcaProfileCreateDialog({
open,
onOpenChange,
name,
onNameChange,
creating,
switching,
onSubmit
}: {
open: boolean
onOpenChange: (open: boolean) => void
name: string
onNameChange: (name: string) => void
creating: boolean
switching: boolean
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void
}): React.JSX.Element {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[380px]">
<form onSubmit={onSubmit} className="space-y-4">
<DialogHeader>
<DialogTitle>
{translate('auto.components.orca.profiles.switcher.16e3681072', 'New local profile')}
</DialogTitle>
<DialogDescription>
{translate(
'auto.components.orca.profiles.switcher.e3b91a3d90',
'Create an empty profile for separate projects and worktrees.'
)}
</DialogDescription>
</DialogHeader>
<Input
autoFocus
value={name}
onChange={(event) => onNameChange(event.target.value)}
placeholder={translate(
'auto.components.orca.profiles.switcher.f322e1f4d6',
'Profile name'
)}
maxLength={80}
/>
<DialogFooter>
<Button type="submit" size="sm" disabled={creating || switching}>
{creating || switching ? <Loader2 className="size-4 animate-spin" /> : null}
{translate('auto.components.orca.profiles.switcher.cfa59f8ad1', 'Create and Switch')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,272 @@
import { useMemo, useState } from 'react'
import { ArrowRightLeft, Copy, FolderGit2, Loader2, MoveRight } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { ScrollArea } from '@/components/ui/scroll-area'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import type {
OrcaProfileSummary,
TransferOrcaProfileProjectMode
} from '../../../../shared/orca-profiles'
import type { Repo } from '../../../../shared/types'
import { OrcaProfileAvatar } from './OrcaProfileAvatar'
import {
OrcaProfileProjectTransferConfirmDialog,
type PendingProjectTransfer
} from './OrcaProfileProjectTransferConfirmDialog'
import { getOrcaProfileProjectLiveWorkSummary } from './orca-profile-switch-liveness'
type PendingTransfer = {
repoId: string
targetProfileId: string
mode: TransferOrcaProfileProjectMode
}
function pendingKey(value: PendingTransfer): string {
return `${value.mode}:${value.repoId}:${value.targetProfileId}`
}
function getRepoPath(repo: Repo): string {
return repo.path || repo.displayName
}
function ProjectTransferMenu({
repo,
sourceProfileId,
targetProfiles,
pending,
onTransfer
}: {
repo: Repo
sourceProfileId: string
targetProfiles: OrcaProfileSummary[]
pending: PendingTransfer | null
onTransfer: (
repo: Repo,
targetProfile: OrcaProfileSummary,
mode: TransferOrcaProfileProjectMode
) => void
}): React.JSX.Element {
const disabled = targetProfiles.length === 0 || Boolean(pending)
const repoPending = pending?.repoId === repo.id
const renderTargetItems = (mode: TransferOrcaProfileProjectMode): React.JSX.Element[] =>
targetProfiles.map((profile) => {
const targetPending =
pending &&
pendingKey(pending) === pendingKey({ repoId: repo.id, targetProfileId: profile.id, mode })
return (
<DropdownMenuItem
key={`${mode}:${profile.id}`}
disabled={Boolean(pending) || profile.id === sourceProfileId}
onSelect={() => onTransfer(repo, profile, mode)}
>
{mode === 'move' ? <MoveRight /> : <Copy />}
<OrcaProfileAvatar profile={profile} />
<span className="min-w-0 truncate">{profile.name}</span>
{targetPending ? <Loader2 className="ml-auto size-3.5 animate-spin" /> : null}
</DropdownMenuItem>
)
})
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="xs" disabled={disabled}>
{repoPending ? <Loader2 className="size-3.5 animate-spin" /> : <ArrowRightLeft />}
{translate('auto.components.orca.profiles.management.04e7bd2a23', 'Transfer')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
{translate('auto.components.orca.profiles.management.128c7dfe64', 'Copy to')}
</DropdownMenuLabel>
{renderTargetItems('copy')}
<DropdownMenuSeparator />
<DropdownMenuLabel>
{translate('auto.components.orca.profiles.management.df8b7d876b', 'Move to')}
</DropdownMenuLabel>
{renderTargetItems('move')}
</DropdownMenuContent>
</DropdownMenu>
)
}
export function OrcaProfileManagementDialog({
open,
onOpenChange,
activeProfile,
profiles
}: {
open: boolean
onOpenChange: (open: boolean) => void
activeProfile: OrcaProfileSummary
profiles: OrcaProfileSummary[]
}): React.JSX.Element {
const repos = useAppStore((s) => s.repos)
const transferProject = useAppStore((s) => s.transferOrcaProfileProject)
const [pending, setPending] = useState<PendingTransfer | null>(null)
const [pendingConfirmation, setPendingConfirmation] = useState<PendingProjectTransfer | null>(
null
)
const targetProfiles = useMemo(
() => profiles.filter((profile) => profile.id !== activeProfile.id),
[activeProfile.id, profiles]
)
const runTransfer = async (
repo: Repo,
targetProfile: OrcaProfileSummary,
mode: TransferOrcaProfileProjectMode
): Promise<void> => {
if (pending) {
return
}
const nextPending = { repoId: repo.id, targetProfileId: targetProfile.id, mode }
setPending(nextPending)
const result = await transferProject({
sourceProfileId: activeProfile.id,
targetProfileId: targetProfile.id,
repoId: repo.id,
mode
})
setPending(null)
if (result?.status === 'transferred') {
toast.success(
mode === 'move'
? translate('auto.components.orca.profiles.management.9aa26347b3', 'Project moved')
: translate('auto.components.orca.profiles.management.816ce624b6', 'Project copied'),
{
description: targetProfile.name
}
)
}
}
const handleTransfer = (
repo: Repo,
targetProfile: OrcaProfileSummary,
mode: TransferOrcaProfileProjectMode
): void => {
if (pending) {
return
}
const liveWorkSummary = getOrcaProfileProjectLiveWorkSummary(useAppStore.getState(), repo.id)
if (mode === 'move' || liveWorkSummary.hasLiveWork) {
setPendingConfirmation({ repo, targetProfile, mode, liveWorkSummary })
return
}
void runTransfer(repo, targetProfile, mode)
}
const confirmTransfer = async (): Promise<void> => {
if (!pendingConfirmation) {
return
}
const next = pendingConfirmation
await runTransfer(next.repo, next.targetProfile, next.mode)
setPendingConfirmation(null)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="gap-4 sm:max-w-[640px]">
<DialogHeader>
<DialogTitle>
{translate('auto.components.orca.profiles.management.2c45bda8d3', 'Manage profiles')}
</DialogTitle>
<DialogDescription>
{translate(
'auto.components.orca.profiles.management.2db945e4a0',
'Copy or move projects from the active profile to another local profile.'
)}
</DialogDescription>
</DialogHeader>
<div className="rounded-md border border-border">
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<OrcaProfileAvatar profile={activeProfile} />
<div className="min-w-0">
<div className="truncate text-sm font-medium">{activeProfile.name}</div>
<div className="truncate text-xs text-muted-foreground">
{translate(
repos.length === 1
? 'auto.components.orca.profiles.management.projectCountSingular'
: 'auto.components.orca.profiles.management.projectCountPlural',
repos.length === 1 ? '{{count}} project' : '{{count}} projects',
{ count: repos.length }
)}
</div>
</div>
</div>
<ScrollArea className="max-h-[360px]">
{repos.length === 0 ? (
<div className="px-3 py-8 text-center text-sm text-muted-foreground">
{translate(
'auto.components.orca.profiles.management.8668cb2946',
'No projects in this profile.'
)}
</div>
) : (
<div className="divide-y divide-border">
{repos.map((repo) => (
<div key={repo.id} className="flex min-w-0 items-center gap-3 px-3 py-2.5">
<FolderGit2 className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{repo.displayName}</div>
<div className="truncate font-mono text-[11px] text-muted-foreground">
{getRepoPath(repo)}
</div>
</div>
<ProjectTransferMenu
repo={repo}
sourceProfileId={activeProfile.id}
targetProfiles={targetProfiles}
pending={pending}
onTransfer={(selectedRepo, targetProfile, mode) => {
handleTransfer(selectedRepo, targetProfile, mode)
}}
/>
</div>
))}
</div>
)}
</ScrollArea>
</div>
{targetProfiles.length === 0 ? (
<div className="text-xs text-muted-foreground">
{translate(
'auto.components.orca.profiles.management.93034915ab',
'Create another profile before copying projects.'
)}
</div>
) : null}
<OrcaProfileProjectTransferConfirmDialog
activeProfileName={activeProfile.name}
pendingTransfer={pendingConfirmation}
pending={Boolean(pending)}
onCancel={() => setPendingConfirmation(null)}
onConfirm={() => {
void confirmTransfer()
}}
/>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,135 @@
import { useState } from 'react'
import { Loader2, UserMinus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import type { OrcaOrgMember, OrcaOrgRole } from '../../../../shared/orca-profiles'
import {
ORG_ROLE_OPTIONS,
orgMemberInitials,
orgRoleLabel
} from './orca-profile-org-members-labels'
export function OrcaProfileOrgMemberRow({
member,
canManage,
isSelf,
busy,
onChangeRole,
onRemove
}: {
member: OrcaOrgMember
canManage: boolean
isSelf: boolean
busy: boolean
onChangeRole: (role: OrcaOrgRole) => void
onRemove: () => void
}): React.JSX.Element {
const [confirmingRemove, setConfirmingRemove] = useState(false)
// Why: teammates provisioned server-side who never signed into Orca have no
// userId, so the API cannot target them for role/remove mutations.
const neverSignedIn = member.userId === null
const actionsDisabled = !canManage || isSelf || neverSignedIn || busy
const displayName = member.displayName?.trim() || member.email
const roleControl = canManage ? (
<Select
value={member.role}
disabled={actionsDisabled}
onValueChange={(value) => onChangeRole(value as OrcaOrgRole)}
>
<SelectTrigger
size="sm"
className="w-28"
aria-label={translate('auto.components.orca.profiles.org.members.role.label', 'Role')}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ORG_ROLE_OPTIONS.map((role) => (
<SelectItem key={role} value={role}>
{orgRoleLabel(role)}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className="text-xs font-medium text-muted-foreground">{orgRoleLabel(member.role)}</span>
)
return (
<div className="flex items-center gap-2.5 py-1.5">
<span
className="inline-flex size-7 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-[11px] font-semibold text-muted-foreground"
aria-hidden
>
{orgMemberInitials(member.displayName, member.email)}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-foreground">{displayName}</div>
<div className="truncate text-[11px] text-muted-foreground">{member.email}</div>
</div>
{neverSignedIn && canManage ? (
<Tooltip>
<TooltipTrigger asChild>
<span className={cn('cursor-default')}>{roleControl}</span>
</TooltipTrigger>
<TooltipContent side="top">
{translate(
'auto.components.orca.profiles.org.members.not.signed.in',
"They haven't signed in to Orca yet."
)}
</TooltipContent>
</Tooltip>
) : (
roleControl
)}
{canManage ? (
confirmingRemove && !actionsDisabled ? (
<div className="flex items-center gap-1">
<Button
variant="destructive"
size="xs"
onClick={() => {
setConfirmingRemove(false)
onRemove()
}}
>
{translate('auto.components.orca.profiles.org.members.remove.confirm', 'Remove')}
</Button>
<Button variant="ghost" size="xs" onClick={() => setConfirmingRemove(false)}>
{translate('auto.components.orca.profiles.org.members.remove.cancel', 'Cancel')}
</Button>
</div>
) : (
<Button
variant="ghost"
size="icon-xs"
disabled={actionsDisabled}
onClick={() => setConfirmingRemove(true)}
aria-label={translate(
'auto.components.orca.profiles.org.members.remove.label',
'Remove teammate'
)}
>
{busy ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<UserMinus className="size-3.5" />
)}
</Button>
)
) : null}
</div>
)
}
@@ -0,0 +1,151 @@
// @vitest-environment happy-dom
import type { ReactNode } from 'react'
import { render, screen, cleanup } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { OrcaOrgMembersRoster } from '../../../../shared/orca-profiles'
import { OrcaProfileOrgMembersDialog } from './OrcaProfileOrgMembersDialog'
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogDescription: ({ children }: { children: ReactNode }) => <div>{children}</div>
}))
vi.mock('@/components/ui/scroll-area', () => ({
ScrollArea: ({ children }: { children: ReactNode }) => <div>{children}</div>
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <span>{children}</span>
}))
vi.mock('@/components/ui/button', () => ({
Button: ({
children,
disabled,
'aria-label': ariaLabel,
type
}: {
children?: ReactNode
disabled?: boolean
'aria-label'?: string
type?: 'button' | 'submit'
}) => (
<button aria-label={ariaLabel} disabled={disabled} type={type}>
{children}
</button>
)
}))
vi.mock('@/components/ui/input', () => ({
Input: ({ placeholder, disabled }: { placeholder?: string; disabled?: boolean }) => (
<input placeholder={placeholder} disabled={disabled} />
)
}))
vi.mock('@/components/ui/select', () => ({
Select: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectValue: () => null,
SelectTrigger: ({
disabled,
'aria-label': ariaLabel,
children
}: {
disabled?: boolean
'aria-label'?: string
children?: ReactNode
}) => (
<button aria-label={ariaLabel} disabled={disabled}>
{children}
</button>
)
}))
const managerRoster: OrcaOrgMembersRoster = {
members: [
{ userId: 'user-viewer', email: 'me@example.com', displayName: 'Me', role: 'admin' },
{ userId: 'user-2', email: 'other@example.com', displayName: 'Other', role: 'member' },
{ userId: null, email: 'never@example.com', role: 'member' }
],
pendingInvites: [{ email: 'pending@example.com', role: 'member', createdAt: 1 }],
viewerRole: 'owner',
canManageMembers: true
}
const memberRoster: OrcaOrgMembersRoster = {
members: [
{ userId: 'user-1', email: 'boss@example.com', displayName: 'Boss', role: 'owner' },
{ userId: 'user-2', email: 'peer@example.com', displayName: 'Peer', role: 'member' }
],
pendingInvites: [],
viewerRole: 'member',
canManageMembers: false
}
function stubOrgMembersApi(roster: OrcaOrgMembersRoster): void {
;(window as unknown as { api: unknown }).api = {
orcaProfiles: {
orgMembersList: vi.fn().mockResolvedValue({ status: 'ok', roster })
}
}
}
describe('OrcaProfileOrgMembersDialog', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('shows the invite form and a role select per member for managers', async () => {
stubOrgMembersApi(managerRoster)
render(
<OrcaProfileOrgMembersDialog
open
onOpenChange={() => {}}
orgId="org-1"
orgName="Acme"
viewerUserId="user-viewer"
/>
)
expect(await screen.findByText('other@example.com')).toBeTruthy()
// Invite form present.
expect(screen.getByPlaceholderText('teammate@example.com')).toBeTruthy()
// Pending invite listed with a revoke action.
expect(screen.getByText('pending@example.com')).toBeTruthy()
// One role select per member.
expect(screen.getAllByLabelText('Role')).toHaveLength(3)
// Self and the never-signed-in row disable their remove action; the peer's stays enabled.
const removeButtons = screen.getAllByLabelText('Remove teammate') as HTMLButtonElement[]
expect(removeButtons).toHaveLength(3)
expect(removeButtons.filter((button) => button.disabled)).toHaveLength(2)
// Never-signed-in explanation is present.
expect(screen.getByText("They haven't signed in to Orca yet.")).toBeTruthy()
})
it('renders a read-only roster for non-managers', async () => {
stubOrgMembersApi(memberRoster)
render(
<OrcaProfileOrgMembersDialog
open
onOpenChange={() => {}}
orgId="org-1"
viewerUserId="user-2"
/>
)
expect(await screen.findByText('boss@example.com')).toBeTruthy()
expect(screen.getByText('peer@example.com')).toBeTruthy()
// No management affordances.
expect(screen.queryByPlaceholderText('teammate@example.com')).toBeNull()
expect(screen.queryAllByLabelText('Role')).toHaveLength(0)
expect(screen.queryAllByLabelText('Remove teammate')).toHaveLength(0)
})
})
@@ -0,0 +1,300 @@
import { useCallback, useEffect, useState } from 'react'
import { Loader2, Mail, RefreshCw, Users } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { translate } from '@/i18n/i18n'
import type {
OrcaOrgMember,
OrcaOrgMembersRoster,
OrcaOrgRole,
OrcaProfileOrgMemberMutationResult
} from '../../../../shared/orca-profiles'
import { OrcaProfileOrgMemberRow } from './OrcaProfileOrgMemberRow'
import {
describeOrgMutationError,
ORG_ROLE_OPTIONS,
orgRoleLabel
} from './orca-profile-org-members-labels'
type DialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
orgId: string
orgName?: string
viewerUserId?: string
}
function isOk(result: OrcaProfileOrgMemberMutationResult): result is { status: 'ok' } {
return result.status === 'ok'
}
export function OrcaProfileOrgMembersDialog({
open,
onOpenChange,
orgId,
orgName,
viewerUserId
}: DialogProps): React.JSX.Element {
const [roster, setRoster] = useState<OrcaOrgMembersRoster | null>(null)
const [loading, setLoading] = useState(false)
const [loadError, setLoadError] = useState(false)
const [actionError, setActionError] = useState<string | null>(null)
const [pendingMemberId, setPendingMemberId] = useState<string | null>(null)
const [pendingInviteEmail, setPendingInviteEmail] = useState<string | null>(null)
const [inviteEmail, setInviteEmail] = useState('')
const [inviteRole, setInviteRole] = useState<OrcaOrgRole>('member')
const [inviting, setInviting] = useState(false)
const refresh = useCallback(async (): Promise<void> => {
setLoading(true)
setLoadError(false)
setActionError(null)
const result = await window.api.orcaProfiles.orgMembersList({ orgId })
if (result.status === 'ok') {
setRoster(result.roster)
} else {
setRoster(null)
setLoadError(true)
}
setLoading(false)
}, [orgId])
// Why: reload every time the dialog opens so a manager always acts on the
// current roster, and clear transient dialog state when it closes.
useEffect(() => {
if (open) {
void refresh()
} else {
setActionError(null)
setInviteEmail('')
setInviteRole('member')
}
}, [open, refresh])
const canManage = roster?.canManageMembers === true
const runMutation = async (
mutation: Promise<OrcaProfileOrgMemberMutationResult>
): Promise<boolean> => {
setActionError(null)
const result = await mutation
if (isOk(result)) {
await refresh()
return true
}
setActionError(describeOrgMutationError(result))
return false
}
const handleChangeRole = async (member: OrcaOrgMember, role: OrcaOrgRole): Promise<void> => {
if (member.userId === null || role === member.role) {
return
}
setPendingMemberId(member.userId)
await runMutation(
window.api.orcaProfiles.orgMemberChangeRole({ orgId, userId: member.userId, role })
)
setPendingMemberId(null)
}
const handleRemove = async (member: OrcaOrgMember): Promise<void> => {
if (member.userId === null) {
return
}
setPendingMemberId(member.userId)
await runMutation(window.api.orcaProfiles.orgMemberRemove({ orgId, userId: member.userId }))
setPendingMemberId(null)
}
const handleRevoke = async (email: string): Promise<void> => {
setPendingInviteEmail(email)
await runMutation(window.api.orcaProfiles.orgInviteRevoke({ orgId, email }))
setPendingInviteEmail(null)
}
const handleInvite = async (event: React.FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault()
const email = inviteEmail.trim()
if (!email || inviting) {
return
}
setInviting(true)
const ok = await runMutation(
window.api.orcaProfiles.orgMemberInvite({ orgId, email, role: inviteRole })
)
setInviting(false)
if (ok) {
setInviteEmail('')
setInviteRole('member')
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Users className="size-4 text-muted-foreground" />
{translate('auto.components.orca.profiles.org.members.title', 'Organization members')}
</DialogTitle>
<DialogDescription>
{orgName
? translate(
'auto.components.orca.profiles.org.members.subtitle.named',
'People in {{orgName}} who can collaborate on Orca.',
{ orgName }
)
: translate(
'auto.components.orca.profiles.org.members.subtitle.default',
'People in your organization who can collaborate on Orca.'
)}
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-10 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
) : loadError ? (
<div className="flex flex-col items-center gap-3 py-8 text-center">
<p className="text-sm text-muted-foreground">
{translate(
'auto.components.orca.profiles.org.members.load.error',
"Couldn't load organization members."
)}
</p>
<Button variant="outline" size="sm" onClick={() => void refresh()}>
<RefreshCw className="size-3.5" />
{translate('auto.components.orca.profiles.org.members.retry', 'Try again')}
</Button>
</div>
) : roster ? (
<div className="flex flex-col gap-4">
{actionError ? (
<p className="rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
{actionError}
</p>
) : null}
<ScrollArea className="max-h-[280px] pr-2">
<div className="divide-y divide-border/60">
{roster.members.map((member) => (
<OrcaProfileOrgMemberRow
key={member.userId ?? member.email}
member={member}
canManage={canManage}
isSelf={member.userId !== null && member.userId === viewerUserId}
busy={pendingMemberId === member.userId}
onChangeRole={(role) => void handleChangeRole(member, role)}
onRemove={() => void handleRemove(member)}
/>
))}
</div>
</ScrollArea>
{canManage && roster.pendingInvites.length > 0 ? (
<div className="flex flex-col gap-1.5">
<p className="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
{translate(
'auto.components.orca.profiles.org.members.pending.title',
'Pending invites'
)}
</p>
{roster.pendingInvites.map((invite) => (
<div key={invite.email} className="flex items-center gap-2.5 py-1">
<Mail className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-[13px] text-foreground">
{invite.email}
</span>
<span className="text-[11px] font-medium text-muted-foreground">
{orgRoleLabel(invite.role)}
</span>
<Button
variant="ghost"
size="xs"
disabled={pendingInviteEmail === invite.email}
onClick={() => void handleRevoke(invite.email)}
>
{pendingInviteEmail === invite.email ? (
<Loader2 className="size-3 animate-spin" />
) : null}
{translate(
'auto.components.orca.profiles.org.members.pending.revoke',
'Revoke'
)}
</Button>
</div>
))}
</div>
) : null}
{canManage ? (
<form onSubmit={(event) => void handleInvite(event)} className="flex items-end gap-2">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<label
htmlFor="orca-org-invite-email"
className="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
>
{translate('auto.components.orca.profiles.org.members.invite.title', 'Invite')}
</label>
<Input
id="orca-org-invite-email"
type="email"
value={inviteEmail}
onChange={(event) => setInviteEmail(event.target.value)}
disabled={inviting}
placeholder={translate(
'auto.components.orca.profiles.org.members.invite.placeholder',
'teammate@example.com'
)}
/>
</div>
<Select
value={inviteRole}
disabled={inviting}
onValueChange={(value) => setInviteRole(value as OrcaOrgRole)}
>
<SelectTrigger
className="w-28"
aria-label={translate(
'auto.components.orca.profiles.org.members.invite.role',
'Invite role'
)}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ORG_ROLE_OPTIONS.map((role) => (
<SelectItem key={role} value={role}>
{orgRoleLabel(role)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button type="submit" size="sm" disabled={inviting || !inviteEmail.trim()}>
{inviting ? <Loader2 className="size-4 animate-spin" /> : null}
{translate('auto.components.orca.profiles.org.members.invite.submit', 'Send')}
</Button>
</form>
) : null}
</div>
) : null}
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,162 @@
import { ArrowRightLeft, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { translate } from '@/i18n/i18n'
import type {
OrcaProfileSummary,
TransferOrcaProfileProjectMode
} from '../../../../shared/orca-profiles'
import type { Repo } from '../../../../shared/types'
import type { OrcaProfileSwitchLiveWorkSummary } from './orca-profile-switch-liveness'
type PendingProjectTransfer = {
repo: Repo
targetProfile: OrcaProfileSummary
mode: TransferOrcaProfileProjectMode
liveWorkSummary: OrcaProfileSwitchLiveWorkSummary
}
export type { PendingProjectTransfer }
function liveWorkLines(summary: OrcaProfileSwitchLiveWorkSummary): string[] {
const lines: string[] = []
if (summary.liveTerminalTabCount > 0) {
lines.push(
translate(
summary.liveTerminalTabCount === 1
? 'auto.components.orca.profiles.project.transfer.confirm.terminalSingular'
: 'auto.components.orca.profiles.project.transfer.confirm.terminalPlural',
summary.liveTerminalTabCount === 1
? '{{count}} live terminal tab'
: '{{count}} live terminal tabs',
{ count: summary.liveTerminalTabCount }
)
)
}
if (summary.liveAgentCount > 0) {
lines.push(
translate(
summary.liveAgentCount === 1
? 'auto.components.orca.profiles.project.transfer.confirm.agentSingular'
: 'auto.components.orca.profiles.project.transfer.confirm.agentPlural',
summary.liveAgentCount === 1 ? '{{count}} active agent' : '{{count}} active agents',
{ count: summary.liveAgentCount }
)
)
}
if (summary.browserWorkspaceCount > 0) {
lines.push(
translate(
summary.browserWorkspaceCount === 1
? 'auto.components.orca.profiles.project.transfer.confirm.browserSingular'
: 'auto.components.orca.profiles.project.transfer.confirm.browserPlural',
summary.browserWorkspaceCount === 1
? '{{count}} browser workspace'
: '{{count}} browser workspaces',
{ count: summary.browserWorkspaceCount }
)
)
}
return lines
}
export function OrcaProfileProjectTransferConfirmDialog({
activeProfileName,
pendingTransfer,
pending,
onCancel,
onConfirm
}: {
activeProfileName: string
pendingTransfer: PendingProjectTransfer | null
pending: boolean
onCancel: () => void
onConfirm: () => void
}): React.JSX.Element {
const mode = pendingTransfer?.mode ?? 'copy'
const repoName = pendingTransfer?.repo.displayName ?? ''
const targetName = pendingTransfer?.targetProfile.name ?? ''
const lines = pendingTransfer ? liveWorkLines(pendingTransfer.liveWorkSummary) : []
return (
<Dialog
open={Boolean(pendingTransfer)}
onOpenChange={(open) => {
if (!open && !pending) {
onCancel()
}
}}
>
<DialogContent className="sm:max-w-[440px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ArrowRightLeft className="size-4 text-muted-foreground" />
{mode === 'move'
? translate(
'auto.components.orca.profiles.project.transfer.confirm.move.title',
'Move project?'
)
: translate(
'auto.components.orca.profiles.project.transfer.confirm.copy.title',
'Copy project?'
)}
</DialogTitle>
<DialogDescription>
{mode === 'move'
? translate(
'auto.components.orca.profiles.project.transfer.confirm.move.description',
'Move {{repoName}} to {{targetName}}. Orca removes it from {{activeProfileName}}, keeps files in place, and relaunches into {{targetName}}.',
{ activeProfileName, repoName, targetName }
)
: translate(
'auto.components.orca.profiles.project.transfer.confirm.copy.description',
'Copy {{repoName}} to {{targetName}}. Both profiles will point at the same files with separate Orca metadata.',
{ repoName, targetName }
)}
</DialogDescription>
</DialogHeader>
{lines.length > 0 ? (
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm">
<div className="mb-1 font-medium text-foreground">
{translate(
'auto.components.orca.profiles.project.transfer.confirm.live.work',
'Live work in this project'
)}
</div>
<ul className="space-y-1 text-xs text-muted-foreground">
{lines.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</div>
) : null}
<DialogFooter>
<Button variant="ghost" size="sm" onClick={onCancel} disabled={pending}>
{translate('auto.components.orca.profiles.project.transfer.confirm.cancel', 'Cancel')}
</Button>
<Button size="sm" onClick={onConfirm} disabled={pending || !pendingTransfer}>
{pending ? <Loader2 className="size-4 animate-spin" /> : null}
{mode === 'move'
? translate(
'auto.components.orca.profiles.project.transfer.confirm.move.action',
'Move project'
)
: translate(
'auto.components.orca.profiles.project.transfer.confirm.copy.action',
'Copy project'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,60 @@
import { AlertTriangle, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { translate } from '@/i18n/i18n'
export function OrcaProfileSignOutConfirmDialog({
open,
onOpenChange,
onConfirm,
profileName,
signingOut
}: {
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
profileName: string
signingOut: boolean
}): React.JSX.Element {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="size-4 text-muted-foreground" />
{translate('auto.components.orca.profiles.signout.confirm.title', 'Sign out?')}
</DialogTitle>
<DialogDescription>
{translate(
'auto.components.orca.profiles.signout.confirm.description',
'Sign out of {{profileName}} and keep its projects, worktrees, and local metadata on this device.',
{ profileName }
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
size="sm"
onClick={() => onOpenChange(false)}
disabled={signingOut}
>
{translate('auto.components.orca.profiles.signout.confirm.cancel', 'Cancel')}
</Button>
<Button size="sm" onClick={onConfirm} disabled={signingOut}>
{signingOut ? <Loader2 className="size-4 animate-spin" /> : null}
{translate('auto.components.orca.profiles.signout.confirm.action', 'Sign out')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,129 @@
import { AlertTriangle, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { translate } from '@/i18n/i18n'
import type { OrcaProfileSummary } from '../../../../shared/orca-profiles'
import type { OrcaProfileSwitchLiveWorkSummary } from './orca-profile-switch-liveness'
function liveWorkLines(summary: OrcaProfileSwitchLiveWorkSummary): string[] {
const lines: string[] = []
if (summary.liveTerminalTabCount > 0) {
lines.push(
translate(
summary.liveTerminalTabCount === 1
? 'auto.components.orca.profiles.switch.confirm.terminalSingular'
: 'auto.components.orca.profiles.switch.confirm.terminalPlural',
summary.liveTerminalTabCount === 1
? '{{count}} live terminal tab'
: '{{count}} live terminal tabs',
{ count: summary.liveTerminalTabCount }
)
)
}
if (summary.liveAgentCount > 0) {
lines.push(
translate(
summary.liveAgentCount === 1
? 'auto.components.orca.profiles.switch.confirm.agentSingular'
: 'auto.components.orca.profiles.switch.confirm.agentPlural',
summary.liveAgentCount === 1 ? '{{count}} active agent' : '{{count}} active agents',
{ count: summary.liveAgentCount }
)
)
}
if (summary.browserWorkspaceCount > 0) {
lines.push(
translate(
summary.browserWorkspaceCount === 1
? 'auto.components.orca.profiles.switch.confirm.browserSingular'
: 'auto.components.orca.profiles.switch.confirm.browserPlural',
summary.browserWorkspaceCount === 1
? '{{count}} browser workspace'
: '{{count}} browser workspaces',
{ count: summary.browserWorkspaceCount }
)
)
}
return lines
}
export function OrcaProfileSwitchConfirmDialog({
open,
onOpenChange,
onConfirm,
activeProfileName,
targetProfile,
liveWorkSummary,
switching
}: {
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
activeProfileName: string
targetProfile: OrcaProfileSummary | null
liveWorkSummary: OrcaProfileSwitchLiveWorkSummary
switching: boolean
}): React.JSX.Element {
const targetName =
targetProfile?.name ??
translate('auto.components.orca.profiles.switch.confirm.target', 'the selected profile')
const lines = liveWorkLines(liveWorkSummary)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="size-4 text-muted-foreground" />
{translate('auto.components.orca.profiles.switch.confirm.title', 'Switch profiles?')}
</DialogTitle>
<DialogDescription>
{translate(
'auto.components.orca.profiles.switch.confirm.description',
'Switching to {{targetName}} will relaunch Orca and reload the workspace for {{activeProfileName}}.',
{ activeProfileName, targetName }
)}
</DialogDescription>
</DialogHeader>
{lines.length > 0 ? (
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm">
<div className="mb-1 font-medium text-foreground">
{translate(
'auto.components.orca.profiles.switch.confirm.live.work',
'Live work in this profile'
)}
</div>
<ul className="space-y-1 text-xs text-muted-foreground">
{lines.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</div>
) : null}
<DialogFooter>
<Button
variant="ghost"
size="sm"
onClick={() => onOpenChange(false)}
disabled={switching}
>
{translate('auto.components.orca.profiles.switch.confirm.cancel', 'Cancel')}
</Button>
<Button size="sm" onClick={onConfirm} disabled={switching}>
{switching ? <Loader2 className="size-4 animate-spin" /> : null}
{translate('auto.components.orca.profiles.switch.confirm.switch', 'Switch profile')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,172 @@
// @vitest-environment happy-dom
import type { ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AppState } from '@/store'
import type { OrcaProfileAuthStatus, OrcaProfileSummary } from '../../../../shared/orca-profiles'
import { OrcaProfileSwitcher } from './OrcaProfileSwitcher'
const mocks = vi.hoisted(() => ({
state: {} as Partial<AppState>
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: Partial<AppState>) => unknown) => selector(mocks.state)
}))
vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}</>,
DropdownMenuContent: ({ children }: { children: ReactNode }) => <>{children}</>,
DropdownMenuItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuLabel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuSeparator: () => <hr />,
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}</>,
DropdownMenuSubContent: ({ children }: { children: ReactNode }) => <>{children}</>,
DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <span>{children}</span>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
vi.mock('@/components/ui/button', () => ({
Button: ({
children,
'aria-label': ariaLabel
}: {
children: ReactNode
'aria-label'?: string
}) => <button aria-label={ariaLabel}>{children}</button>
}))
vi.mock('./OrcaProfileAvatar', () => ({
OrcaProfileAvatar: () => <span data-testid="avatar" />
}))
vi.mock('./OrcaProfileCreateDialog', () => ({
OrcaProfileCreateDialog: () => <div data-testid="create-dialog" />
}))
vi.mock('./OrcaProfileManagementDialog', () => ({
OrcaProfileManagementDialog: () => <div data-testid="management-dialog" />
}))
vi.mock('./OrcaProfileSwitchConfirmDialog', () => ({
OrcaProfileSwitchConfirmDialog: () => <div data-testid="switch-confirm-dialog" />
}))
vi.mock('./OrcaProfileSignOutConfirmDialog', () => ({
OrcaProfileSignOutConfirmDialog: () => <div data-testid="signout-confirm-dialog" />
}))
vi.mock('./orca-profile-switch-liveness', () => ({
getOrcaProfileSwitchLiveWorkSummary: () => ({ hasLiveWork: false })
}))
const cloudProfile: OrcaProfileSummary = {
id: 'local-default',
name: 'Personal',
avatar: { kind: 'initials', initials: 'P', color: 'neutral' },
kind: 'cloud-linked',
createdAt: 1,
updatedAt: 1,
lastOpenedAt: 1,
cloud: {
cloudProfileId: 'cloud-1',
userId: 'user-1',
email: 'nina@example.com',
activeOrgId: 'org-1',
activeOrgName: 'Acme',
linkedAt: 2
}
}
const connectedAuthStatus: OrcaProfileAuthStatus = {
activeProfileId: 'local-default',
configured: true,
state: 'connected',
persistence: 'encrypted',
cloud: cloudProfile.cloud,
organizations: [
{ orgId: 'org-1', name: 'Acme' },
{ orgId: 'org-2', name: 'Globex' }
]
}
const unconfiguredAuthStatus: OrcaProfileAuthStatus = {
activeProfileId: 'local-default',
configured: false,
state: 'unconfigured',
persistence: 'none'
}
function baseState(overrides: Partial<AppState>): Partial<AppState> {
return {
orcaProfiles: [cloudProfile],
activeOrcaProfileId: 'local-default',
orcaProfilesLoading: false,
orcaProfileSwitching: false,
orcaProfileConnecting: false,
orcaProfileAuthStatus: connectedAuthStatus,
orcaProfilesMultiProfileUi: false,
fetchOrcaProfiles: vi.fn(),
createLocalOrcaProfile: vi.fn(),
createCloudLinkedOrcaProfile: vi.fn(),
connectCurrentOrcaProfile: vi.fn(),
signOutCurrentOrcaProfile: vi.fn(),
selectOrcaProfileOrg: vi.fn(),
switchOrcaProfile: vi.fn(),
...overrides
}
}
describe('OrcaProfileSwitcher', () => {
beforeEach(() => {
mocks.state = baseState({})
})
it('renders an account menu without profile management when the flag is off and cloud is configured', () => {
mocks.state = baseState({ orcaProfilesMultiProfileUi: false })
const html = renderToStaticMarkup(<OrcaProfileSwitcher />)
expect(html).toContain('aria-label="Account"')
// Cloud actions stay reachable in the downscoped account menu.
expect(html).toContain('Sign out')
// Profile management surfaces are gone.
expect(html).not.toContain('Manage profiles')
expect(html).not.toContain('New local profile')
expect(html).not.toContain('Create profile for org')
expect(html).not.toContain('data-testid="create-dialog"')
expect(html).not.toContain('data-testid="management-dialog"')
expect(html).not.toContain('data-testid="switch-confirm-dialog"')
// Sign-out remains mounted.
expect(html).toContain('data-testid="signout-confirm-dialog"')
})
it('renders nothing when the flag is off and cloud is unconfigured', () => {
mocks.state = baseState({
orcaProfilesMultiProfileUi: false,
orcaProfileAuthStatus: unconfiguredAuthStatus
})
const html = renderToStaticMarkup(<OrcaProfileSwitcher />)
expect(html).toBe('')
})
it('renders the full multi-profile menu when the flag is on', () => {
mocks.state = baseState({ orcaProfilesMultiProfileUi: true })
const html = renderToStaticMarkup(<OrcaProfileSwitcher />)
expect(html).toContain('aria-label="Switch profile"')
expect(html).toContain('Manage profiles')
expect(html).toContain('New local profile')
expect(html).toContain('Create profile for org')
expect(html).toContain('data-testid="create-dialog"')
expect(html).toContain('data-testid="management-dialog"')
expect(html).toContain('data-testid="switch-confirm-dialog"')
})
})
@@ -0,0 +1,403 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Check, ChevronDown, Cloud, Laptop, Loader2, Plus, Settings2, Users } from 'lucide-react'
import { useShallow } from 'zustand/react/shallow'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { useAppStore } from '@/store'
import type { OrcaCloudOrgSummary, OrcaProfileSummary } from '../../../../shared/orca-profiles'
import { OrcaProfileAvatar } from './OrcaProfileAvatar'
import { OrcaProfileCloudMenuItems } from './OrcaProfileCloudMenuItems'
import { OrcaProfileCreateDialog } from './OrcaProfileCreateDialog'
import { OrcaProfileOrgMembersDialog } from './OrcaProfileOrgMembersDialog'
import { OrcaProfileManagementDialog } from './OrcaProfileManagementDialog'
import { OrcaProfileSignOutConfirmDialog } from './OrcaProfileSignOutConfirmDialog'
import { OrcaProfileSwitchConfirmDialog } from './OrcaProfileSwitchConfirmDialog'
import { getOrcaProfileSwitchLiveWorkSummary } from './orca-profile-switch-liveness'
function isWebClient(): boolean {
return Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__)
}
function getProfileSubtitle(profile: OrcaProfileSummary): string {
if (profile.cloud?.activeOrgName) {
return profile.cloud.activeOrgName
}
if (profile.cloud?.email) {
return profile.cloud.email
}
return translate('auto.components.orca.profiles.switcher.b4f9d1125d', 'Local')
}
export function OrcaProfileSwitcher({
placement = 'titlebar'
}: {
placement?: 'titlebar' | 'sidebar'
}): React.JSX.Element | null {
const profiles = useAppStore((s) => s.orcaProfiles)
const activeProfileId = useAppStore((s) => s.activeOrcaProfileId)
const loading = useAppStore((s) => s.orcaProfilesLoading)
const switching = useAppStore((s) => s.orcaProfileSwitching)
const connecting = useAppStore((s) => s.orcaProfileConnecting)
const authStatus = useAppStore((s) => s.orcaProfileAuthStatus)
const multiProfileUi = useAppStore((s) => s.orcaProfilesMultiProfileUi)
const fetchProfiles = useAppStore((s) => s.fetchOrcaProfiles)
const createLocalProfile = useAppStore((s) => s.createLocalOrcaProfile)
const createCloudLinkedProfile = useAppStore((s) => s.createCloudLinkedOrcaProfile)
const connectCurrentProfile = useAppStore((s) => s.connectCurrentOrcaProfile)
const signOutCurrentProfile = useAppStore((s) => s.signOutCurrentOrcaProfile)
const selectOrg = useAppStore((s) => s.selectOrcaProfileOrg)
const switchProfile = useAppStore((s) => s.switchOrcaProfile)
const liveWorkSummary = useAppStore(useShallow((s) => getOrcaProfileSwitchLiveWorkSummary(s)))
const [dialogOpen, setDialogOpen] = useState(false)
const [managementOpen, setManagementOpen] = useState(false)
const [newProfileName, setNewProfileName] = useState('')
const [creating, setCreating] = useState(false)
const [creatingCloudProfile, setCreatingCloudProfile] = useState(false)
const [signOutConfirmOpen, setSignOutConfirmOpen] = useState(false)
const [signingOut, setSigningOut] = useState(false)
const [orgMembersOpen, setOrgMembersOpen] = useState(false)
const [pendingSwitchProfileId, setPendingSwitchProfileId] = useState<string | null>(null)
const activeProfile = useMemo(
() => profiles.find((profile) => profile.id === activeProfileId) ?? profiles[0] ?? null,
[activeProfileId, profiles]
)
const pendingSwitchProfile = useMemo(
() => profiles.find((profile) => profile.id === pendingSwitchProfileId) ?? null,
[pendingSwitchProfileId, profiles]
)
// Why: one attempt per mount — retrying on every loading toggle would spin
// an unbounded IPC loop when the list call persistently fails.
const fetchAttemptedRef = useRef(false)
useEffect(() => {
if (profiles.length === 0 && !loading && !fetchAttemptedRef.current) {
fetchAttemptedRef.current = true
void fetchProfiles()
}
}, [fetchProfiles, loading, profiles.length])
// Why: the Orca Cloud account UX isn't ready for production users yet, so the
// trigger stays hidden in packaged builds. Dev builds still show it when
// cloud auth is configured, and a dev-only Settings > Dev Tools > Orca Cloud
// section mirrors it. import.meta.env.PROD is true only in packaged builds
// (false in dev and under vitest), so tests still exercise the render path.
if (import.meta.env.PROD) {
return null
}
// Why: paired web/mobile clients only see the desktop stub's fabricated
// profile list; showing a switcher there would misreport the active profile
// and none of its actions can work remotely.
if (isWebClient() || !activeProfile) {
return null
}
// Why: with multi-profile UI downscoped, local-only builds (no cloud
// configured) have nothing to offer in an account menu — show no trigger.
if (!multiProfileUi && authStatus?.configured !== true) {
return null
}
const handleCreateProfile = async (event: React.FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault()
if (creating || switching) {
return
}
setCreating(true)
const profile = await createLocalProfile(newProfileName)
setCreating(false)
if (!profile) {
return
}
setNewProfileName('')
setDialogOpen(false)
if (liveWorkSummary.hasLiveWork) {
setPendingSwitchProfileId(profile.id)
return
}
await switchProfile(profile.id)
}
const handleSwitchProfile = (profileId: string): void => {
if (profileId === activeProfileId || switching) {
return
}
if (liveWorkSummary.hasLiveWork) {
setPendingSwitchProfileId(profileId)
return
}
void switchProfile(profileId)
}
const handleConfirmSwitchProfile = (): void => {
if (!pendingSwitchProfileId || switching) {
return
}
void switchProfile(pendingSwitchProfileId)
}
const handleCreateCloudProfileForOrg = async (
organization: OrcaCloudOrgSummary
): Promise<void> => {
if (creatingCloudProfile || switching) {
return
}
setCreatingCloudProfile(true)
const result = await createCloudLinkedProfile({
orgId: organization.orgId,
name: organization.name
})
setCreatingCloudProfile(false)
if (result?.status !== 'created') {
return
}
if (liveWorkSummary.hasLiveWork) {
setPendingSwitchProfileId(result.profile.id)
return
}
await switchProfile(result.profile.id)
}
const handleConfirmSignOut = async (): Promise<void> => {
if (signingOut) {
return
}
setSigningOut(true)
const result = await signOutCurrentProfile()
setSigningOut(false)
if (result) {
setSignOutConfirmOpen(false)
}
}
const profileActionDisabled =
switching || creating || creatingCloudProfile || connecting || signingOut
// Why: teammate management needs a connected cloud profile scoped to an org;
// the server enforces role permissions, and the dialog adapts via
// canManageMembers, so cloud-linked + org + connected is enough to reveal it.
const activeOrgId = activeProfile.cloud?.activeOrgId
const showOrgMembers =
activeProfile.kind === 'cloud-linked' &&
Boolean(activeOrgId) &&
authStatus?.state === 'connected'
const sidebarPlacement = placement === 'sidebar'
const triggerLabel = multiProfileUi
? translate('auto.components.orca.profiles.switcher.4815f7d163', 'Switch profile')
: translate('auto.components.orca.profiles.switcher.account', 'Account')
return (
<>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size={sidebarPlacement ? 'icon-xs' : 'xs'}
className={cn(
'text-muted-foreground titlebar-profile-switcher',
sidebarPlacement ? 'px-0' : 'mr-2 max-w-[180px] gap-1.5 px-1.5'
)}
disabled={profileActionDisabled}
aria-label={triggerLabel}
>
{sidebarPlacement && switching ? (
<Loader2 className="size-3 animate-spin" />
) : (
<OrcaProfileAvatar
profile={activeProfile}
className={
sidebarPlacement
? 'size-4 border-worktree-sidebar-border bg-worktree-sidebar-accent text-[10px] text-worktree-sidebar-accent-foreground'
: undefined
}
/>
)}
{!sidebarPlacement ? (
<>
<span className="hidden max-w-[108px] truncate text-xs font-medium sm:inline">
{activeProfile.name}
</span>
{switching ? <Loader2 className="size-3 animate-spin" /> : <ChevronDown />}
</>
) : null}
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side={sidebarPlacement ? 'top' : 'bottom'} sideOffset={6}>
{triggerLabel}
</TooltipContent>
</Tooltip>
<DropdownMenuContent
align={sidebarPlacement ? 'start' : 'end'}
side={sidebarPlacement ? 'top' : 'bottom'}
sideOffset={sidebarPlacement ? 8 : 6}
className="w-64"
>
<DropdownMenuLabel className="px-2 py-1.5">
<div className="flex min-w-0 items-center gap-2">
<OrcaProfileAvatar profile={activeProfile} className="size-7 text-xs" />
<div className="min-w-0">
<div className="truncate text-[13px] font-semibold text-foreground">
{activeProfile.name}
</div>
<div className="truncate text-[11px] font-medium text-muted-foreground">
{getProfileSubtitle(activeProfile)}
</div>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{multiProfileUi
? profiles.map((profile) => {
const active = profile.id === activeProfileId
return (
<DropdownMenuItem
key={profile.id}
disabled={profileActionDisabled}
onSelect={() => handleSwitchProfile(profile.id)}
className="min-w-0"
>
<OrcaProfileAvatar profile={profile} />
<span className="min-w-0 flex-1">
<span className="block truncate">{profile.name}</span>
<span className="block truncate text-[11px] font-normal text-muted-foreground">
{getProfileSubtitle(profile)}
</span>
</span>
{profile.kind === 'cloud-linked' ? <Cloud className="size-3.5" /> : <Laptop />}
{active && <Check className="size-3.5 text-foreground" />}
</DropdownMenuItem>
)
})
: null}
{showOrgMembers ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={profileActionDisabled}
onSelect={() => setOrgMembersOpen(true)}
>
<Users />
{translate(
'auto.components.orca.profiles.switcher.org.members',
'Organization members'
)}
</DropdownMenuItem>
</>
) : null}
<OrcaProfileCloudMenuItems
activeProfile={activeProfile}
authStatus={authStatus}
connecting={connecting}
profileActionDisabled={profileActionDisabled}
allowProfileCreation={multiProfileUi}
onConnect={() => {
void connectCurrentProfile()
}}
onCreateProfileForOrg={(organization) => {
void handleCreateCloudProfileForOrg(organization)
}}
onSelectOrg={(orgId) => {
void selectOrg(orgId)
}}
onRequestSignOut={() => setSignOutConfirmOpen(true)}
/>
{multiProfileUi ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={profileActionDisabled}
onSelect={() => {
setManagementOpen(true)
}}
>
<Settings2 />
{translate('auto.components.orca.profiles.switcher.d00d853e2a', 'Manage profiles')}
</DropdownMenuItem>
<DropdownMenuItem
disabled={profileActionDisabled}
onSelect={() => {
setDialogOpen(true)
}}
>
<Plus />
{translate(
'auto.components.orca.profiles.switcher.c106c674fe',
'New local profile'
)}
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
{multiProfileUi ? (
<>
<OrcaProfileCreateDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
name={newProfileName}
onNameChange={setNewProfileName}
creating={creating}
switching={switching}
onSubmit={handleCreateProfile}
/>
<OrcaProfileManagementDialog
open={managementOpen}
onOpenChange={setManagementOpen}
activeProfile={activeProfile}
profiles={profiles}
/>
</>
) : null}
{showOrgMembers && activeOrgId ? (
<OrcaProfileOrgMembersDialog
open={orgMembersOpen}
onOpenChange={setOrgMembersOpen}
orgId={activeOrgId}
orgName={activeProfile.cloud?.activeOrgName}
viewerUserId={activeProfile.cloud?.userId}
/>
) : null}
<OrcaProfileSignOutConfirmDialog
open={signOutConfirmOpen}
onOpenChange={(open) => {
if (!signingOut) {
setSignOutConfirmOpen(open)
}
}}
onConfirm={() => {
void handleConfirmSignOut()
}}
profileName={activeProfile.name}
signingOut={signingOut}
/>
{multiProfileUi ? (
<OrcaProfileSwitchConfirmDialog
open={Boolean(pendingSwitchProfileId)}
onOpenChange={(open) => {
if (!open && !switching) {
setPendingSwitchProfileId(null)
}
}}
onConfirm={handleConfirmSwitchProfile}
activeProfileName={activeProfile.name}
targetProfile={pendingSwitchProfile}
liveWorkSummary={liveWorkSummary}
switching={switching}
/>
) : null}
</>
)
}
@@ -0,0 +1,79 @@
import { translate } from '@/i18n/i18n'
import type {
OrcaOrgRole,
OrcaProfileOrgMemberMutationResult
} from '../../../../shared/orca-profiles'
export const ORG_ROLE_OPTIONS: readonly OrcaOrgRole[] = ['owner', 'admin', 'member']
export function orgRoleLabel(role: OrcaOrgRole): string {
switch (role) {
case 'owner':
return translate('auto.components.orca.profiles.org.members.role.owner', 'Owner')
case 'admin':
return translate('auto.components.orca.profiles.org.members.role.admin', 'Admin')
case 'member':
return translate('auto.components.orca.profiles.org.members.role.member', 'Member')
}
}
export function orgMemberInitials(displayName: string | undefined, email: string): string {
const source = displayName?.trim() || email.trim()
if (!source) {
return '?'
}
const words = source.split(/\s+/).filter(Boolean)
if (words.length >= 2) {
return `${words[0][0]}${words[1][0]}`.toUpperCase()
}
return source.slice(0, 2).toUpperCase()
}
// Why: the dialog maps every non-ok mutation status to a precise, translated
// message so managers see why an action was rejected instead of a raw code.
export function describeOrgMutationError(
result: Exclude<OrcaProfileOrgMemberMutationResult, { status: 'ok' }>
): string {
switch (result.status) {
case 'forbidden':
return translate(
'auto.components.orca.profiles.org.members.error.forbidden',
"Your role can't do that."
)
case 'conflict':
return translate(
'auto.components.orca.profiles.org.members.error.conflict',
'Already a member or invited.'
)
case 'invalid':
return result.reason === 'cannot_remove_self'
? translate(
'auto.components.orca.profiles.org.members.error.remove.self',
"You can't remove yourself."
)
: translate(
'auto.components.orca.profiles.org.members.error.own.role',
"You can't change your own role."
)
case 'not-found':
return translate(
'auto.components.orca.profiles.org.members.error.not.found',
'That teammate is no longer in this organization.'
)
case 'reconnect-required':
return translate(
'auto.components.orca.profiles.org.members.error.reconnect',
'Reconnect your profile to manage members.'
)
case 'unconfigured':
return translate(
'auto.components.orca.profiles.org.members.error.unconfigured',
'Organization management is unavailable in this build.'
)
case 'failed':
return translate(
'auto.components.orca.profiles.org.members.error.failed',
'Something went wrong. Please try again.'
)
}
}
@@ -0,0 +1,194 @@
import { describe, expect, it } from 'vitest'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import type { BrowserWorkspace, TerminalTab } from '../../../../shared/types'
import {
getOrcaProfileProjectLiveWorkSummary,
getOrcaProfileSwitchLiveWorkSummary,
type OrcaProfileSwitchLiveWorkState
} from './orca-profile-switch-liveness'
const NOW = 1_000_000
function makeState(
overrides: Partial<OrcaProfileSwitchLiveWorkState> = {}
): OrcaProfileSwitchLiveWorkState {
return {
agentStatusByPaneKey: {},
browserTabsByWorktree: {},
ptyIdsByTabId: {},
runtimePaneTitlesByTabId: {},
tabsByWorktree: {},
...overrides
}
}
function makeTab(
overrides: Partial<Omit<TerminalTab, 'id' | 'worktreeId'>> & {
id: string
worktreeId?: string
}
): TerminalTab {
const { id, worktreeId = 'worktree-1', ...rest } = overrides
return {
id,
color: null,
createdAt: NOW,
customTitle: null,
ptyId: null,
sortOrder: 0,
title: 'zsh',
worktreeId,
...rest
}
}
function makeAgentEntry(overrides: Partial<AgentStatusEntry> = {}): AgentStatusEntry {
return {
paneKey: 'tab-1:0',
prompt: '',
state: 'working',
stateHistory: [],
stateStartedAt: NOW,
updatedAt: NOW,
...overrides
}
}
function makeBrowserWorkspace(id: string): BrowserWorkspace {
return {
id,
activePageId: null,
canGoBack: false,
canGoForward: false,
createdAt: NOW,
faviconUrl: null,
label: 'Browser',
loadError: null,
loading: false,
pageIds: [],
title: 'Browser',
url: 'about:blank',
worktreeId: 'worktree-1'
}
}
describe('getOrcaProfileSwitchLiveWorkSummary', () => {
it('reports quiet profiles as safe to switch without confirmation', () => {
expect(getOrcaProfileSwitchLiveWorkSummary(makeState(), NOW)).toEqual({
browserWorkspaceCount: 0,
hasLiveWork: false,
liveAgentCount: 0,
livePtyCount: 0,
liveTerminalTabCount: 0
})
})
it('counts live PTYs as live terminal work', () => {
const summary = getOrcaProfileSwitchLiveWorkSummary(
makeState({
ptyIdsByTabId: {
'tab-1': ['pty-1', 'pty-2'],
'tab-2': []
}
}),
NOW
)
expect(summary).toMatchObject({
hasLiveWork: true,
livePtyCount: 2,
liveTerminalTabCount: 1
})
})
it('counts fresh working explicit agent status', () => {
const summary = getOrcaProfileSwitchLiveWorkSummary(
makeState({
agentStatusByPaneKey: {
'tab-1:0': makeAgentEntry({ paneKey: 'tab-1:0', state: 'working' }),
'tab-2:0': makeAgentEntry({ paneKey: 'tab-2:0', state: 'done' }),
'tab-3:0': makeAgentEntry({
paneKey: 'tab-3:0',
state: 'waiting',
updatedAt: -999_999_999
})
}
}),
NOW
)
expect(summary.liveAgentCount).toBe(1)
expect(summary.hasLiveWork).toBe(true)
})
it('counts title-detected agents only when the tab has a live PTY', () => {
const summary = getOrcaProfileSwitchLiveWorkSummary(
makeState({
ptyIdsByTabId: {
'tab-live': ['pty-1']
},
tabsByWorktree: {
'worktree-1': [
makeTab({ id: 'tab-live', title: 'Codex working' }),
makeTab({ id: 'tab-slept', title: 'Codex working' })
]
}
}),
NOW
)
expect(summary.liveAgentCount).toBe(1)
expect(summary.hasLiveWork).toBe(true)
})
it('counts browser workspaces as live browser work', () => {
const summary = getOrcaProfileSwitchLiveWorkSummary(
makeState({
browserTabsByWorktree: {
'worktree-1': [makeBrowserWorkspace('browser-1'), makeBrowserWorkspace('browser-2')]
}
}),
NOW
)
expect(summary).toMatchObject({
browserWorkspaceCount: 2,
hasLiveWork: true
})
})
it('filters live work to the selected project', () => {
const summary = getOrcaProfileProjectLiveWorkSummary(
makeState({
agentStatusByPaneKey: {
'other-tab:0': makeAgentEntry({
paneKey: 'other-tab:0',
worktreeId: 'repo-other::/workspace/other'
})
},
browserTabsByWorktree: {
'repo-1::/workspace/orca': [makeBrowserWorkspace('browser-1')],
'repo-other::/workspace/other': [makeBrowserWorkspace('browser-2')]
},
ptyIdsByTabId: {
'tab-live': ['pty-1'],
'other-tab': ['pty-2']
},
tabsByWorktree: {
'repo-1::/workspace/orca': [makeTab({ id: 'tab-live', title: 'Codex working' })],
'repo-other::/workspace/other': [makeTab({ id: 'other-tab', title: 'Codex working' })]
}
}),
'repo-1',
NOW
)
expect(summary).toMatchObject({
browserWorkspaceCount: 1,
hasLiveWork: true,
liveAgentCount: 1,
livePtyCount: 1,
liveTerminalTabCount: 1
})
})
})
@@ -0,0 +1,180 @@
import type { AppState } from '@/store/types'
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry
} from '../../../../shared/agent-status-types'
import type { TerminalTab } from '../../../../shared/types'
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id'
import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
export type OrcaProfileSwitchLiveWorkState = Pick<
AppState,
| 'agentStatusByPaneKey'
| 'browserTabsByWorktree'
| 'ptyIdsByTabId'
| 'runtimePaneTitlesByTabId'
| 'tabsByWorktree'
>
export type OrcaProfileSwitchLiveWorkSummary = {
hasLiveWork: boolean
liveAgentCount: number
livePtyCount: number
liveTerminalTabCount: number
browserWorkspaceCount: number
}
const LIVE_AGENT_STATES = new Set<AgentStatusEntry['state']>(['working', 'blocked', 'waiting'])
export function getOrcaProfileSwitchLiveWorkSummary(
state: OrcaProfileSwitchLiveWorkState,
now = Date.now()
): OrcaProfileSwitchLiveWorkSummary {
const terminalSummary = getLiveTerminalSummary(state)
const liveAgentCount = getLiveAgentKeys(state, now).size
const browserWorkspaceCount = Object.values(state.browserTabsByWorktree).reduce(
(count, workspaces) => count + workspaces.length,
0
)
return {
hasLiveWork:
terminalSummary.livePtyCount > 0 || liveAgentCount > 0 || browserWorkspaceCount > 0,
liveAgentCount,
livePtyCount: terminalSummary.livePtyCount,
liveTerminalTabCount: terminalSummary.liveTerminalTabCount,
browserWorkspaceCount
}
}
export function getOrcaProfileProjectLiveWorkSummary(
state: OrcaProfileSwitchLiveWorkState,
repoId: string,
now = Date.now()
): OrcaProfileSwitchLiveWorkSummary {
const tabsByWorktree = Object.fromEntries(
Object.entries(state.tabsByWorktree).filter(([worktreeId]) =>
worktreeBelongsToRepo(worktreeId, repoId)
)
)
const tabIds = new Set(Object.values(tabsByWorktree).flatMap((tabs) => tabs.map((tab) => tab.id)))
const browserTabsByWorktree = Object.fromEntries(
Object.entries(state.browserTabsByWorktree).filter(([worktreeId]) =>
worktreeBelongsToRepo(worktreeId, repoId)
)
)
const ptyIdsByTabId = Object.fromEntries(
Object.entries(state.ptyIdsByTabId).filter(([tabId]) => tabIds.has(tabId))
)
const runtimePaneTitlesByTabId = Object.fromEntries(
Object.entries(state.runtimePaneTitlesByTabId).filter(([tabId]) => tabIds.has(tabId))
)
const agentStatusByPaneKey = Object.fromEntries(
Object.entries(state.agentStatusByPaneKey).filter(([paneKey, entry]) =>
agentEntryBelongsToProject(paneKey, entry, repoId, tabIds)
)
)
return getOrcaProfileSwitchLiveWorkSummary(
{
agentStatusByPaneKey,
browserTabsByWorktree,
ptyIdsByTabId,
runtimePaneTitlesByTabId,
tabsByWorktree
},
now
)
}
function getLiveTerminalSummary(state: OrcaProfileSwitchLiveWorkState): {
livePtyCount: number
liveTerminalTabCount: number
} {
const liveTabIds = new Set<string>()
let livePtyCount = 0
for (const [tabId, ptyIds] of Object.entries(state.ptyIdsByTabId)) {
if (ptyIds.length === 0) {
continue
}
livePtyCount += ptyIds.length
liveTabIds.add(tabId)
}
return {
livePtyCount,
liveTerminalTabCount: liveTabIds.size
}
}
function getLiveAgentKeys(state: OrcaProfileSwitchLiveWorkState, now: number): Set<string> {
const keys = new Set<string>()
for (const entry of Object.values(state.agentStatusByPaneKey)) {
if (
LIVE_AGENT_STATES.has(entry.state) &&
isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)
) {
keys.add(entry.paneKey)
}
}
for (const tabs of Object.values(state.tabsByWorktree)) {
for (const tab of tabs) {
addTitleDetectedLiveAgentKeys(keys, state, tab)
}
}
return keys
}
function addTitleDetectedLiveAgentKeys(
keys: Set<string>,
state: OrcaProfileSwitchLiveWorkState,
tab: TerminalTab
): void {
if (!tabHasLivePty(state.ptyIdsByTabId, tab.id)) {
return
}
const paneTitles = state.runtimePaneTitlesByTabId[tab.id]
if (paneTitles && Object.keys(paneTitles).length > 0) {
for (const [paneId, title] of Object.entries(paneTitles)) {
if (isLiveAgentTitle(title)) {
keys.add(`${tab.id}:${paneId}`)
}
}
return
}
if (isLiveAgentTitle(tab.title)) {
keys.add(`${tab.id}:title`)
}
}
function isLiveAgentTitle(title: string): boolean {
const status = detectAgentStatusFromTitle(title)
return status === 'working' || status === 'permission'
}
function agentEntryBelongsToProject(
paneKey: string,
entry: AgentStatusEntry,
repoId: string,
tabIds: Set<string>
): boolean {
if (entry.worktreeId && worktreeBelongsToRepo(entry.worktreeId, repoId)) {
return true
}
return tabIds.has(getPaneKeyTabId(paneKey))
}
function getPaneKeyTabId(paneKey: string): string {
const separatorIndex = paneKey.lastIndexOf(':')
return separatorIndex === -1 ? paneKey : paneKey.slice(0, separatorIndex)
}
function worktreeBelongsToRepo(worktreeId: string, repoId: string): boolean {
return getRepoIdFromWorktreeId(worktreeId) === repoId
}
@@ -6,6 +6,7 @@ import { SettingsSubsectionHeader } from './SettingsFormControls'
import { showDeleteWorktreeFailureToast } from '../sidebar/delete-worktree-failure-toast'
import { showLocalBaseRefUpdateSuggestionToast } from '../sidebar/local-base-ref-suggestion-toast'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
const LONG_WORKSPACE_NAME = 'feature/dev-toast-layout-with-a-long-workspace-name'
@@ -124,6 +125,85 @@ function showDeleteFailureToast(): void {
})
}
// Dev-only preview of the first-party Orca Cloud sign-in. The sidebar/titlebar
// account switcher is hidden in packaged builds while the feature is in
// progress; this surfaces it (and its status) in dev when the env vars are set.
function OrcaCloudDevSubsection(): React.JSX.Element {
const authStatus = useAppStore((s) => s.orcaProfileAuthStatus)
const connecting = useAppStore((s) => s.orcaProfileConnecting)
const connect = useAppStore((s) => s.connectCurrentOrcaProfile)
const signOut = useAppStore((s) => s.signOutCurrentOrcaProfile)
const refresh = useAppStore((s) => s.fetchOrcaProfileAuthStatus)
const configured = authStatus?.configured === true
const connected = authStatus?.state === 'connected'
return (
<section className="space-y-3">
<div className="flex items-start justify-between gap-3">
<SettingsSubsectionHeader
title={translate('auto.components.settings.DevToolsPane.orcaCloud', 'Orca Cloud')}
description={translate(
'auto.components.settings.DevToolsPane.orcaCloudDescription',
'Dev-only preview of first-party cloud sign-in. Hidden in production; in dev it also appears in the sidebar account switcher once ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID are set.'
)}
/>
<Badge variant="outline" className="mt-0.5">
{translate('auto.components.settings.DevToolsPane.devOnly', 'Dev only')}
</Badge>
</div>
{configured ? (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
{translate('auto.components.settings.DevToolsPane.orcaCloudStatus', 'Status')}:{' '}
<span className="font-medium text-foreground">{authStatus?.state}</span>
</p>
<div className="flex flex-wrap gap-2">
{connected ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={connecting}
onClick={() => void signOut()}
>
{translate('auto.components.settings.DevToolsPane.orcaCloudSignOut', 'Sign out')}
</Button>
) : (
<Button
type="button"
variant="outline"
size="sm"
disabled={connecting}
onClick={() => void connect()}
>
{translate(
'auto.components.settings.DevToolsPane.orcaCloudConnect',
'Connect profile'
)}
</Button>
)}
<Button type="button" variant="ghost" size="sm" onClick={() => void refresh()}>
{translate(
'auto.components.settings.DevToolsPane.orcaCloudRefresh',
'Refresh status'
)}
</Button>
</div>
</div>
) : (
<p className="text-xs text-muted-foreground">
{authStatus?.setupMessage ??
translate(
'auto.components.settings.DevToolsPane.orcaCloudNotConfigured',
'Set ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID to preview Orca Cloud sign-in in this dev build.'
)}
</p>
)}
</section>
)
}
export function DevToolsPane(): React.JSX.Element {
const actions: DevToastAction[] = [
{
@@ -221,28 +301,32 @@ export function DevToolsPane(): React.JSX.Element {
]
return (
<section className="space-y-3">
<div className="flex items-start justify-between gap-3">
<SettingsSubsectionHeader
title={translate(
'auto.components.settings.DevToolsPane.notificationPlayground',
'Notification playground'
)}
description={translate(
'auto.components.settings.DevToolsPane.notificationPlaygroundDescription',
'Dev-only triggers for checking toast layout, recovery actions, and long-copy wrapping.'
)}
/>
<Badge variant="outline" className="mt-0.5">
{translate('auto.components.settings.DevToolsPane.devOnly', 'Dev only')}
</Badge>
</div>
<div className="space-y-8">
<section className="space-y-3">
<div className="flex items-start justify-between gap-3">
<SettingsSubsectionHeader
title={translate(
'auto.components.settings.DevToolsPane.notificationPlayground',
'Notification playground'
)}
description={translate(
'auto.components.settings.DevToolsPane.notificationPlaygroundDescription',
'Dev-only triggers for checking toast layout, recovery actions, and long-copy wrapping.'
)}
/>
<Badge variant="outline" className="mt-0.5">
{translate('auto.components.settings.DevToolsPane.devOnly', 'Dev only')}
</Badge>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{actions.map((action) => (
<DevToastActionButton key={action.title} action={action} />
))}
</div>
</section>
<div className="grid gap-2 sm:grid-cols-2">
{actions.map((action) => (
<DevToastActionButton key={action.title} action={action} />
))}
</div>
</section>
<OrcaCloudDevSubsection />
</div>
)
}
@@ -33,6 +33,9 @@ function getRecipeOverrideFieldLabel(field: SourceControlActionRecipeOverrideFie
'Command template'
)
}
// Fail at compile time if the override-field union grows a new variant.
const _exhaustive: never = field
return _exhaustive
}
function getRecipeOverrideFieldSummary(fields: SourceControlActionRecipeOverrideField[]): string {
@@ -36,6 +36,14 @@ vi.mock('./SidebarSettingsHelpMenu', () => ({
SidebarSettingsHelpMenu: () => <button type="button">Settings</button>
}))
vi.mock('../orca-profiles/OrcaProfileSwitcher', () => ({
OrcaProfileSwitcher: ({ placement }: { placement?: string }) => (
<button type="button" data-placement={placement}>
Profile
</button>
)
}))
const roots: Root[] = []
async function renderToolbar(onWorkspaceBoardToggle = vi.fn()): Promise<{
@@ -122,4 +130,12 @@ describe('SidebarToolbar moved workspace board hint', () => {
expect(container.textContent).toContain('Workspace board moved to the bottom bar')
expect(window.localStorage.getItem('orca.workspaceBoardMovedHintSeen.v1')).toBe('true')
})
it('renders the profile switcher before settings in the footer controls', async () => {
const { container } = await renderToolbar()
const html = container.innerHTML
expect(html).toContain('data-placement="sidebar"')
expect(html.indexOf('Profile')).toBeLessThan(html.indexOf('Settings'))
})
})
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { ScrollToCurrentWorkspaceToolbarButton } from './ScrollToCurrentWorkspaceToolbarButton'
import { SidebarSettingsHelpMenu } from './SidebarSettingsHelpMenu'
import { OrcaProfileSwitcher } from '../orca-profiles/OrcaProfileSwitcher'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { hasFeatureInteraction } from '../../../../shared/feature-interactions'
@@ -65,7 +66,10 @@ const SidebarToolbar = React.memo(function SidebarToolbar({
return (
<div className="mt-auto shrink-0">
<div className="flex items-center justify-between border-t border-worktree-sidebar-border px-2 py-1.5">
<SidebarSettingsHelpMenu />
<div className="flex min-w-0 items-center gap-1">
<OrcaProfileSwitcher placement="sidebar" />
<SidebarSettingsHelpMenu />
</div>
<div className="flex items-center gap-1">
<ScrollToCurrentWorkspaceToolbarButton />
<Tooltip open={workspaceBoardMovedHintOpen ? true : undefined}>
+181 -2
View File
@@ -247,7 +247,9 @@
"a8e4b3af5b": "Project already added",
"6d3318e813": "Failed to import repositories",
"3be0f7df04": "Cannot open folder on selected runtime",
"15cf5319ec": "{{path}} was checked on {{hostName}}, but that host did not report a usable folder."
"15cf5319ec": "{{path}} was checked on {{hostName}}, but that host did not report a usable folder.",
"2dcd706774": "Project also exists in another profile",
"presenceProfileOverflow": "{{names}} +{{count}} more"
},
"settings": {
"e12dab333b": "Failed to switch servers",
@@ -262,6 +264,24 @@
},
"linear": {
"37d36984d0": "Linear connection was superseded by a newer request."
},
"orca": {
"profiles": {
"612f7f6861": "Failed to create profile",
"319d7cf39b": "Cloud profile created",
"d6e764e7db": "Reconnect this profile",
"f0c9e11a6d": "Failed to create cloud profile",
"8b8fa73174": "Orca Cloud sign-in is not configured",
"33290e88ed": "Failed to connect profile",
"9fcb07a796": "Profile connected",
"2f6c78a039": "Failed to refresh profile auth",
"a37b5e6d37": "Signed out of profile",
"83600521e7": "Failed to sign out",
"76deec8f58": "Failed to switch organization",
"7d4bc516ee": "Failed to switch profile",
"f518e89aa5": "Project already exists in that profile",
"f03ae7f27b": "Failed to transfer project"
}
}
}
},
@@ -8747,7 +8767,14 @@
"behindBaseRefToastDescription": "Persistent prompt with an inline Settings link and footer action.",
"notificationPlayground": "Notification playground",
"notificationPlaygroundDescription": "Dev-only triggers for checking toast layout, recovery actions, and long-copy wrapping.",
"devOnly": "Dev only"
"devOnly": "Dev only",
"orcaCloud": "Orca Cloud",
"orcaCloudDescription": "Dev-only preview of first-party cloud sign-in. Hidden in production; in dev it also appears in the sidebar account switcher once ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID are set.",
"orcaCloudStatus": "Status",
"orcaCloudSignOut": "Sign out",
"orcaCloudConnect": "Connect profile",
"orcaCloudRefresh": "Refresh status",
"orcaCloudNotConfigured": "Set ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID to preview Orca Cloud sign-in in this dev build."
},
"EphemeralVmRecipeRow": {
"useInWorkspace": "Use in workspace"
@@ -12492,6 +12519,158 @@
"contextMenu": {
"copy": "Copy"
}
},
"orca": {
"profiles": {
"switcher": {
"cloud": {
"unavailable": "Cloud sign-in unavailable"
},
"account": "Account",
"reconnect": "Reconnect profile",
"connect": "Connect profile",
"organization": "Organization",
"create": {
"profile": {
"for": {
"org": "Create profile for org"
}
}
},
"signout": "Sign out",
"16e3681072": "New local profile",
"e3b91a3d90": "Create an empty profile for separate projects and worktrees.",
"f322e1f4d6": "Profile name",
"cfa59f8ad1": "Create and Switch",
"b4f9d1125d": "Local",
"4815f7d163": "Switch profile",
"d00d853e2a": "Manage profiles",
"c106c674fe": "New local profile",
"org": {
"members": "Organization members"
}
},
"management": {
"04e7bd2a23": "Transfer",
"128c7dfe64": "Copy to",
"df8b7d876b": "Move to",
"9aa26347b3": "Project moved",
"816ce624b6": "Project copied",
"2c45bda8d3": "Manage profiles",
"2db945e4a0": "Copy or move projects from the active profile to another local profile.",
"8668cb2946": "No projects in this profile.",
"93034915ab": "Create another profile before copying projects.",
"projectCountSingular": "{{count}} project",
"projectCountPlural": "{{count}} projects"
},
"project": {
"transfer": {
"confirm": {
"move": {
"title": "Move project?",
"description": "Move {{repoName}} to {{targetName}}. Orca removes it from {{activeProfileName}}, keeps files in place, and relaunches into {{targetName}}.",
"action": "Move project"
},
"copy": {
"title": "Copy project?",
"description": "Copy {{repoName}} to {{targetName}}. Both profiles will point at the same files with separate Orca metadata.",
"action": "Copy project"
},
"live": {
"work": "Live work in this project"
},
"cancel": "Cancel",
"terminalSingular": "{{count}} live terminal tab",
"terminalPlural": "{{count}} live terminal tabs",
"agentSingular": "{{count}} active agent",
"agentPlural": "{{count}} active agents",
"browserSingular": "{{count}} browser workspace",
"browserPlural": "{{count}} browser workspaces"
}
}
},
"signout": {
"confirm": {
"title": "Sign out?",
"description": "Sign out of {{profileName}} and keep its projects, worktrees, and local metadata on this device.",
"cancel": "Cancel",
"action": "Sign out"
}
},
"switch": {
"confirm": {
"target": "the selected profile",
"title": "Switch profiles?",
"description": "Switching to {{targetName}} will relaunch Orca and reload the workspace for {{activeProfileName}}.",
"live": {
"work": "Live work in this profile"
},
"cancel": "Cancel",
"switch": "Switch profile",
"terminalSingular": "{{count}} live terminal tab",
"terminalPlural": "{{count}} live terminal tabs",
"agentSingular": "{{count}} active agent",
"agentPlural": "{{count}} active agents",
"browserSingular": "{{count}} browser workspace",
"browserPlural": "{{count}} browser workspaces"
}
},
"org": {
"members": {
"role": {
"label": "Role",
"owner": "Owner",
"admin": "Admin",
"member": "Member"
},
"not": {
"signed": {
"in": "They haven't signed in to Orca yet."
}
},
"remove": {
"confirm": "Remove",
"cancel": "Cancel",
"label": "Remove teammate"
},
"title": "Organization members",
"load": {
"error": "Couldn't load organization members."
},
"retry": "Try again",
"pending": {
"title": "Pending invites",
"revoke": "Revoke"
},
"invite": {
"title": "Invite",
"placeholder": "teammate@example.com",
"role": "Invite role",
"submit": "Send"
},
"error": {
"forbidden": "Your role can't do that.",
"conflict": "Already a member or invited.",
"remove": {
"self": "You can't remove yourself."
},
"own": {
"role": "You can't change your own role."
},
"not": {
"found": "That teammate is no longer in this organization."
},
"reconnect": "Reconnect your profile to manage members.",
"unconfigured": "Organization management is unavailable in this build.",
"failed": "Something went wrong. Please try again."
},
"subtitle": {
"named": "People in {{orgName}} who can collaborate on Orca.",
"default": "People in your organization who can collaborate on Orca."
}
}
}
}
}
},
"i18n": {
+181 -2
View File
@@ -247,7 +247,9 @@
"a8e4b3af5b": "Proyecto ya agregado",
"6d3318e813": "No se pudieron importar repositorios",
"3be0f7df04": "No se puede abrir la carpeta en el host seleccionado",
"15cf5319ec": "{{path}} se verificó en {{hostName}}, pero ese host no reportó ninguna carpeta utilizable."
"15cf5319ec": "{{path}} se verificó en {{hostName}}, pero ese host no reportó ninguna carpeta utilizable.",
"2dcd706774": "Project also exists in another profile",
"presenceProfileOverflow": "{{names}} +{{count}} more"
},
"settings": {
"e12dab333b": "No se pudo cambiar de servidor",
@@ -262,6 +264,24 @@
},
"linear": {
"37d36984d0": "La conexión de Linear fue reemplazada por una solicitud más reciente."
},
"orca": {
"profiles": {
"612f7f6861": "Failed to create profile",
"319d7cf39b": "Cloud profile created",
"d6e764e7db": "Reconnect this profile",
"f0c9e11a6d": "Failed to create cloud profile",
"8b8fa73174": "Orca Cloud sign-in is not configured",
"33290e88ed": "Failed to connect profile",
"9fcb07a796": "Profile connected",
"2f6c78a039": "Failed to refresh profile auth",
"a37b5e6d37": "Signed out of profile",
"83600521e7": "Failed to sign out",
"76deec8f58": "Failed to switch organization",
"7d4bc516ee": "Failed to switch profile",
"f518e89aa5": "Project already exists in that profile",
"f03ae7f27b": "Failed to transfer project"
}
}
}
},
@@ -8747,7 +8767,14 @@
"behindBaseRefToastDescription": "Aviso persistente con un enlace a Ajustes y una acción en el pie.",
"notificationPlayground": "Panel de prueba de notificaciones",
"notificationPlaygroundDescription": "Disparadores solo para desarrollo para comprobar el diseño de toasts, acciones de recuperación y ajuste de texto largo.",
"devOnly": "Solo para desarrollo"
"devOnly": "Solo para desarrollo",
"orcaCloud": "Orca Cloud",
"orcaCloudDescription": "Dev-only preview of first-party cloud sign-in. Hidden in production; in dev it also appears in the sidebar account switcher once ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID are set.",
"orcaCloudStatus": "Status",
"orcaCloudSignOut": "Sign out",
"orcaCloudConnect": "Connect profile",
"orcaCloudRefresh": "Refresh status",
"orcaCloudNotConfigured": "Set ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID to preview Orca Cloud sign-in in this dev build."
},
"EphemeralVmRecipeRow": {
"useInWorkspace": "Usar en el espacio de trabajo"
@@ -12492,6 +12519,158 @@
"contextMenu": {
"copy": "Copiar"
}
},
"orca": {
"profiles": {
"switcher": {
"cloud": {
"unavailable": "Cloud sign-in unavailable"
},
"account": "Account",
"reconnect": "Reconnect profile",
"connect": "Connect profile",
"organization": "Organization",
"create": {
"profile": {
"for": {
"org": "Create profile for org"
}
}
},
"signout": "Sign out",
"16e3681072": "New local profile",
"e3b91a3d90": "Create an empty profile for separate projects and worktrees.",
"f322e1f4d6": "Profile name",
"cfa59f8ad1": "Create and Switch",
"b4f9d1125d": "Local",
"4815f7d163": "Switch profile",
"d00d853e2a": "Manage profiles",
"c106c674fe": "New local profile",
"org": {
"members": "Organization members"
}
},
"management": {
"04e7bd2a23": "Transfer",
"128c7dfe64": "Copy to",
"df8b7d876b": "Move to",
"9aa26347b3": "Project moved",
"816ce624b6": "Project copied",
"2c45bda8d3": "Manage profiles",
"2db945e4a0": "Copy or move projects from the active profile to another local profile.",
"8668cb2946": "No projects in this profile.",
"93034915ab": "Create another profile before copying projects.",
"projectCountSingular": "{{count}} project",
"projectCountPlural": "{{count}} projects"
},
"project": {
"transfer": {
"confirm": {
"move": {
"title": "Move project?",
"description": "Move {{repoName}} to {{targetName}}. Orca removes it from {{activeProfileName}}, keeps files in place, and relaunches into {{targetName}}.",
"action": "Move project"
},
"copy": {
"title": "Copy project?",
"description": "Copy {{repoName}} to {{targetName}}. Both profiles will point at the same files with separate Orca metadata.",
"action": "Copy project"
},
"live": {
"work": "Live work in this project"
},
"cancel": "Cancel",
"terminalSingular": "{{count}} live terminal tab",
"terminalPlural": "{{count}} live terminal tabs",
"agentSingular": "{{count}} active agent",
"agentPlural": "{{count}} active agents",
"browserSingular": "{{count}} browser workspace",
"browserPlural": "{{count}} browser workspaces"
}
}
},
"signout": {
"confirm": {
"title": "Sign out?",
"description": "Sign out of {{profileName}} and keep its projects, worktrees, and local metadata on this device.",
"cancel": "Cancel",
"action": "Sign out"
}
},
"switch": {
"confirm": {
"target": "the selected profile",
"title": "Switch profiles?",
"description": "Switching to {{targetName}} will relaunch Orca and reload the workspace for {{activeProfileName}}.",
"live": {
"work": "Live work in this profile"
},
"cancel": "Cancel",
"switch": "Switch profile",
"terminalSingular": "{{count}} live terminal tab",
"terminalPlural": "{{count}} live terminal tabs",
"agentSingular": "{{count}} active agent",
"agentPlural": "{{count}} active agents",
"browserSingular": "{{count}} browser workspace",
"browserPlural": "{{count}} browser workspaces"
}
},
"org": {
"members": {
"role": {
"label": "Role",
"owner": "Owner",
"admin": "Admin",
"member": "Member"
},
"not": {
"signed": {
"in": "They haven't signed in to Orca yet."
}
},
"remove": {
"confirm": "Remove",
"cancel": "Cancel",
"label": "Remove teammate"
},
"title": "Organization members",
"load": {
"error": "Couldn't load organization members."
},
"retry": "Try again",
"pending": {
"title": "Pending invites",
"revoke": "Revoke"
},
"invite": {
"title": "Invite",
"placeholder": "teammate@example.com",
"role": "Invite role",
"submit": "Send"
},
"error": {
"forbidden": "Your role can't do that.",
"conflict": "Already a member or invited.",
"remove": {
"self": "You can't remove yourself."
},
"own": {
"role": "You can't change your own role."
},
"not": {
"found": "That teammate is no longer in this organization."
},
"reconnect": "Reconnect your profile to manage members.",
"unconfigured": "Organization management is unavailable in this build.",
"failed": "Something went wrong. Please try again."
},
"subtitle": {
"named": "People in {{orgName}} who can collaborate on Orca.",
"default": "People in your organization who can collaborate on Orca."
}
}
}
}
}
},
"i18n": {
+181 -2
View File
@@ -247,7 +247,9 @@
"a8e4b3af5b": "プロジェクトはすでに追加されています",
"6d3318e813": "repos のインポートに失敗しました",
"3be0f7df04": "選択したランタイムでフォルダーを開けません",
"15cf5319ec": "{{path}} は {{hostName}} でチェックされましたが、そのホストは使用可能なフォルダーを報告しませんでした。"
"15cf5319ec": "{{path}} は {{hostName}} でチェックされましたが、そのホストは使用可能なフォルダーを報告しませんでした。",
"2dcd706774": "プロジェクトは別のプロファイルにも存在します",
"presenceProfileOverflow": "{{names}} 他 {{count}} 件"
},
"settings": {
"e12dab333b": "サーバーの切り替えに失敗しました",
@@ -262,6 +264,24 @@
},
"linear": {
"37d36984d0": "Linear 接続は新しいリクエストに置き換えられました。"
},
"orca": {
"profiles": {
"612f7f6861": "プロファイルの作成に失敗しました",
"319d7cf39b": "クラウド プロファイルが作成されました",
"d6e764e7db": "このプロファイルを再接続してください",
"f0c9e11a6d": "クラウド プロファイルの作成に失敗しました",
"8b8fa73174": "Orca Cloud のサインインが構成されていません",
"33290e88ed": "プロファイルの接続に失敗しました",
"9fcb07a796": "プロファイルが接続されました",
"2f6c78a039": "プロファイル認証の更新に失敗しました",
"a37b5e6d37": "プロファイルからサインアウトしました",
"83600521e7": "サインアウトに失敗しました",
"76deec8f58": "組織の切り替えに失敗しました",
"7d4bc516ee": "プロファイルの切り替えに失敗しました",
"f518e89aa5": "プロジェクトはそのプロファイルに既に存在します",
"f03ae7f27b": "プロジェクトの転送に失敗しました"
}
}
}
},
@@ -8747,7 +8767,14 @@
"behindBaseRefToastDescription": "インラインの設定リンクとフッターアクション付きの永続プロンプト。",
"notificationPlayground": "Notification playground",
"notificationPlaygroundDescription": "Dev-only triggers for checking toast layout, recovery actions, and long-copy wrapping.",
"devOnly": "Dev only"
"devOnly": "Dev only",
"orcaCloud": "Orca Cloud",
"orcaCloudDescription": "Dev-only preview of first-party cloud sign-in. Hidden in production; in dev it also appears in the sidebar account switcher once ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID are set.",
"orcaCloudStatus": "Status",
"orcaCloudSignOut": "Sign out",
"orcaCloudConnect": "Connect profile",
"orcaCloudRefresh": "Refresh status",
"orcaCloudNotConfigured": "Set ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID to preview Orca Cloud sign-in in this dev build."
},
"EphemeralVmRecipeRow": {
"useInWorkspace": "ワークスペースでの使用"
@@ -12492,6 +12519,158 @@
"contextMenu": {
"copy": "コピー"
}
},
"orca": {
"profiles": {
"switcher": {
"cloud": {
"unavailable": "クラウド サインインは利用できません"
},
"reconnect": "プロファイルを再接続",
"connect": "プロファイルを接続",
"organization": "組織",
"create": {
"profile": {
"for": {
"org": "組織用のプロファイルを作成"
}
}
},
"signout": "サインアウト",
"16e3681072": "新しいローカル プロファイル",
"e3b91a3d90": "プロジェクトとワークツリーを分けるための空のプロファイルを作成します。",
"f322e1f4d6": "プロファイル名",
"cfa59f8ad1": "作成して切り替え",
"b4f9d1125d": "ローカル",
"4815f7d163": "プロファイルを切り替え",
"d00d853e2a": "プロファイルを管理",
"c106c674fe": "新しいローカル プロファイル",
"account": "アカウント",
"org": {
"members": "組織のメンバー"
}
},
"management": {
"04e7bd2a23": "転送",
"128c7dfe64": "コピー先",
"df8b7d876b": "移動先",
"9aa26347b3": "プロジェクトを移動しました",
"816ce624b6": "プロジェクトをコピーしました",
"2c45bda8d3": "プロファイルを管理",
"2db945e4a0": "アクティブなプロファイルから別のローカル プロファイルへプロジェクトをコピーまたは移動します。",
"8668cb2946": "このプロファイルにはプロジェクトがありません。",
"93034915ab": "プロジェクトをコピーする前に、別のプロファイルを作成してください。",
"projectCountSingular": "{{count}} 件のプロジェクト",
"projectCountPlural": "{{count}} 件のプロジェクト"
},
"project": {
"transfer": {
"confirm": {
"move": {
"title": "プロジェクトを移動しますか?",
"description": "{{repoName}} を {{targetName}} に移動します。Orca は {{activeProfileName}} から削除し、ファイルはそのまま残して {{targetName}} で再起動します。",
"action": "プロジェクトを移動"
},
"copy": {
"title": "プロジェクトをコピーしますか?",
"description": "{{repoName}} を {{targetName}} にコピーします。両方のプロファイルは同じファイルを参照し、Orca のメタデータは別々に保持されます。",
"action": "プロジェクトをコピー"
},
"live": {
"work": "このプロジェクトで実行中の作業"
},
"cancel": "キャンセル",
"terminalSingular": "{{count}} 個の実行中ターミナル タブ",
"terminalPlural": "{{count}} 個の実行中ターミナル タブ",
"agentSingular": "{{count}} 個のアクティブなエージェント",
"agentPlural": "{{count}} 個のアクティブなエージェント",
"browserSingular": "{{count}} 個のブラウザ ワークスペース",
"browserPlural": "{{count}} 個のブラウザ ワークスペース"
}
}
},
"signout": {
"confirm": {
"title": "サインアウトしますか?",
"description": "{{profileName}} からサインアウトし、そのプロジェクト、ワークツリー、ローカル メタデータはこのデバイスに保持します。",
"cancel": "キャンセル",
"action": "サインアウト"
}
},
"switch": {
"confirm": {
"target": "選択したプロファイル",
"title": "プロファイルを切り替えますか?",
"description": "{{targetName}} に切り替えると、Orca が再起動し、{{activeProfileName}} のワークスペースが再読み込みされます。",
"live": {
"work": "このプロファイルで実行中の作業"
},
"cancel": "キャンセル",
"switch": "プロファイルを切り替え",
"terminalSingular": "{{count}} 個の実行中ターミナル タブ",
"terminalPlural": "{{count}} 個の実行中ターミナル タブ",
"agentSingular": "{{count}} 個のアクティブなエージェント",
"agentPlural": "{{count}} 個のアクティブなエージェント",
"browserSingular": "{{count}} 個のブラウザ ワークスペース",
"browserPlural": "{{count}} 個のブラウザ ワークスペース"
}
},
"org": {
"members": {
"role": {
"label": "ロール",
"owner": "オーナー",
"admin": "管理者",
"member": "メンバー"
},
"not": {
"signed": {
"in": "まだ Orca にサインインしていません。"
}
},
"remove": {
"confirm": "削除",
"cancel": "キャンセル",
"label": "メンバーを削除"
},
"title": "組織のメンバー",
"load": {
"error": "組織のメンバーを読み込めませんでした。"
},
"retry": "再試行",
"pending": {
"title": "保留中の招待",
"revoke": "取り消す"
},
"invite": {
"title": "招待",
"placeholder": "teammate@example.com",
"role": "招待するロール",
"submit": "送信"
},
"error": {
"forbidden": "あなたのロールではこの操作を実行できません。",
"conflict": "すでにメンバーまたは招待済みです。",
"remove": {
"self": "自分自身を削除することはできません。"
},
"own": {
"role": "自分のロールは変更できません。"
},
"not": {
"found": "そのメンバーはこの組織に存在しません。"
},
"reconnect": "メンバーを管理するにはプロファイルを再接続してください。",
"unconfigured": "このビルドでは組織の管理を利用できません。",
"failed": "問題が発生しました。もう一度お試しください。"
},
"subtitle": {
"named": "{{orgName}} で Orca を共同利用できるメンバーです。",
"default": "組織内で Orca を共同利用できるメンバーです。"
}
}
}
}
}
},
"i18n": {
+181 -2
View File
@@ -247,7 +247,9 @@
"a8e4b3af5b": "프로젝트가 이미 추가되었습니다.",
"6d3318e813": "repos를 가져오지 못했습니다.",
"3be0f7df04": "선택한 런타임에서 폴더를 열 수 없습니다.",
"15cf5319ec": "{{path}}이(가) {{hostName}}에서 확인되었지만 해당 호스트가 사용 가능한 폴더를 보고하지 않았습니다."
"15cf5319ec": "{{path}}이(가) {{hostName}}에서 확인되었지만 해당 호스트가 사용 가능한 폴더를 보고하지 않았습니다.",
"2dcd706774": "프로젝트가 다른 프로필에도 있습니다",
"presenceProfileOverflow": "{{names}} 외 {{count}}개"
},
"settings": {
"e12dab333b": "서버를 전환하지 못했습니다.",
@@ -262,6 +264,24 @@
},
"linear": {
"37d36984d0": "더 새로운 요청이 있어 Linear 연결 요청을 건너뛰었습니다."
},
"orca": {
"profiles": {
"612f7f6861": "프로필을 생성하지 못했습니다",
"319d7cf39b": "클라우드 프로필이 생성되었습니다",
"d6e764e7db": "이 프로필을 다시 연결하세요",
"f0c9e11a6d": "클라우드 프로필을 생성하지 못했습니다",
"8b8fa73174": "Orca Cloud 로그인이 구성되어 있지 않습니다",
"33290e88ed": "프로필을 연결하지 못했습니다",
"9fcb07a796": "프로필이 연결되었습니다",
"2f6c78a039": "프로필 인증을 새로 고치지 못했습니다",
"a37b5e6d37": "프로필에서 로그아웃했습니다",
"83600521e7": "로그아웃하지 못했습니다",
"76deec8f58": "조직을 전환하지 못했습니다",
"7d4bc516ee": "프로필을 전환하지 못했습니다",
"f518e89aa5": "프로젝트가 해당 프로필에 이미 있습니다",
"f03ae7f27b": "프로젝트를 전송하지 못했습니다"
}
}
}
},
@@ -8747,7 +8767,14 @@
"behindBaseRefToastDescription": "인라인 설정 링크와 바닥글 작업이 있는 영구 프롬프트입니다.",
"notificationPlayground": "Notification playground",
"notificationPlaygroundDescription": "Dev-only triggers for checking toast layout, recovery actions, and long-copy wrapping.",
"devOnly": "Dev only"
"devOnly": "Dev only",
"orcaCloud": "Orca Cloud",
"orcaCloudDescription": "Dev-only preview of first-party cloud sign-in. Hidden in production; in dev it also appears in the sidebar account switcher once ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID are set.",
"orcaCloudStatus": "Status",
"orcaCloudSignOut": "Sign out",
"orcaCloudConnect": "Connect profile",
"orcaCloudRefresh": "Refresh status",
"orcaCloudNotConfigured": "Set ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID to preview Orca Cloud sign-in in this dev build."
},
"EphemeralVmRecipeRow": {
"useInWorkspace": "워크스페이스에서 사용"
@@ -12492,6 +12519,158 @@
"contextMenu": {
"copy": "복사"
}
},
"orca": {
"profiles": {
"switcher": {
"cloud": {
"unavailable": "클라우드 로그인을 사용할 수 없습니다"
},
"reconnect": "프로필 다시 연결",
"connect": "프로필 연결",
"organization": "조직",
"create": {
"profile": {
"for": {
"org": "조직용 프로필 만들기"
}
}
},
"signout": "로그아웃",
"16e3681072": "새 로컬 프로필",
"e3b91a3d90": "프로젝트와 워크트리를 분리할 빈 프로필을 만듭니다.",
"f322e1f4d6": "프로필 이름",
"cfa59f8ad1": "만들고 전환",
"b4f9d1125d": "로컬",
"4815f7d163": "프로필 전환",
"d00d853e2a": "프로필 관리",
"c106c674fe": "새 로컬 프로필",
"account": "계정",
"org": {
"members": "조직 구성원"
}
},
"management": {
"04e7bd2a23": "전송",
"128c7dfe64": "복사 대상",
"df8b7d876b": "이동 대상",
"9aa26347b3": "프로젝트를 이동했습니다",
"816ce624b6": "프로젝트를 복사했습니다",
"2c45bda8d3": "프로필 관리",
"2db945e4a0": "활성 프로필의 프로젝트를 다른 로컬 프로필로 복사하거나 이동합니다.",
"8668cb2946": "이 프로필에는 프로젝트가 없습니다.",
"93034915ab": "프로젝트를 복사하려면 먼저 다른 프로필을 만드세요.",
"projectCountSingular": "{{count}}개의 프로젝트",
"projectCountPlural": "{{count}}개의 프로젝트"
},
"project": {
"transfer": {
"confirm": {
"move": {
"title": "프로젝트를 이동하시겠습니까?",
"description": "{{repoName}}을(를) {{targetName}}(으)로 이동합니다. Orca가 {{activeProfileName}}에서 제거하고 파일은 그대로 유지한 채 {{targetName}}(으)로 다시 시작합니다.",
"action": "프로젝트 이동"
},
"copy": {
"title": "프로젝트를 복사하시겠습니까?",
"description": "{{repoName}}을(를) {{targetName}}(으)로 복사합니다. 두 프로필은 같은 파일을 가리키며 Orca 메타데이터는 별도로 유지됩니다.",
"action": "프로젝트 복사"
},
"live": {
"work": "이 프로젝트에서 진행 중인 작업"
},
"cancel": "취소",
"terminalSingular": "{{count}}개의 실행 중인 터미널 탭",
"terminalPlural": "{{count}}개의 실행 중인 터미널 탭",
"agentSingular": "{{count}}개의 활성 에이전트",
"agentPlural": "{{count}}개의 활성 에이전트",
"browserSingular": "{{count}}개의 브라우저 워크스페이스",
"browserPlural": "{{count}}개의 브라우저 워크스페이스"
}
}
},
"signout": {
"confirm": {
"title": "로그아웃하시겠습니까?",
"description": "{{profileName}}에서 로그아웃하고 해당 프로젝트, 워크트리 및 로컬 메타데이터는 이 기기에 유지합니다.",
"cancel": "취소",
"action": "로그아웃"
}
},
"switch": {
"confirm": {
"target": "선택한 프로필",
"title": "프로필을 전환하시겠습니까?",
"description": "{{targetName}}(으)로 전환하면 Orca가 다시 시작되고 {{activeProfileName}}의 워크스페이스가 다시 로드됩니다.",
"live": {
"work": "이 프로필에서 진행 중인 작업"
},
"cancel": "취소",
"switch": "프로필 전환",
"terminalSingular": "{{count}}개의 실행 중인 터미널 탭",
"terminalPlural": "{{count}}개의 실행 중인 터미널 탭",
"agentSingular": "{{count}}개의 활성 에이전트",
"agentPlural": "{{count}}개의 활성 에이전트",
"browserSingular": "{{count}}개의 브라우저 워크스페이스",
"browserPlural": "{{count}}개의 브라우저 워크스페이스"
}
},
"org": {
"members": {
"role": {
"label": "역할",
"owner": "소유자",
"admin": "관리자",
"member": "구성원"
},
"not": {
"signed": {
"in": "아직 Orca에 로그인하지 않았습니다."
}
},
"remove": {
"confirm": "삭제",
"cancel": "취소",
"label": "구성원 삭제"
},
"title": "조직 구성원",
"load": {
"error": "조직 구성원을 불러오지 못했습니다."
},
"retry": "다시 시도",
"pending": {
"title": "대기 중인 초대",
"revoke": "철회"
},
"invite": {
"title": "초대",
"placeholder": "teammate@example.com",
"role": "초대 역할",
"submit": "보내기"
},
"error": {
"forbidden": "현재 역할로는 이 작업을 수행할 수 없습니다.",
"conflict": "이미 구성원이거나 초대되었습니다.",
"remove": {
"self": "자신은 삭제할 수 없습니다."
},
"own": {
"role": "자신의 역할은 변경할 수 없습니다."
},
"not": {
"found": "해당 구성원이 더 이상 이 조직에 없습니다."
},
"reconnect": "구성원을 관리하려면 프로필을 다시 연결하세요.",
"unconfigured": "이 빌드에서는 조직 관리를 사용할 수 없습니다.",
"failed": "문제가 발생했습니다. 다시 시도하세요."
},
"subtitle": {
"named": "{{orgName}}에서 Orca로 협업할 수 있는 사용자입니다.",
"default": "조직에서 Orca로 협업할 수 있는 사용자입니다."
}
}
}
}
}
},
"i18n": {
+181 -2
View File
@@ -247,7 +247,9 @@
"a8e4b3af5b": "项目已添加过了",
"6d3318e813": "导入存储库失败",
"3be0f7df04": "无法在所选运行时打开文件夹",
"15cf5319ec": "已在 {{hostName}} 上检查 {{path}},但该主机未报告可用文件夹。"
"15cf5319ec": "已在 {{hostName}} 上检查 {{path}},但该主机未报告可用文件夹。",
"2dcd706774": "该项目也存在于另一个配置文件中",
"presenceProfileOverflow": "{{names}} 及另外 {{count}} 个"
},
"settings": {
"e12dab333b": "切换服务器失败",
@@ -262,6 +264,24 @@
},
"linear": {
"37d36984d0": "Linear 连接已被新的请求取代。"
},
"orca": {
"profiles": {
"612f7f6861": "创建配置文件失败",
"319d7cf39b": "已创建云配置文件",
"d6e764e7db": "请重新连接此配置文件",
"f0c9e11a6d": "创建云配置文件失败",
"8b8fa73174": "未配置 Orca Cloud 登录",
"33290e88ed": "连接配置文件失败",
"9fcb07a796": "配置文件已连接",
"2f6c78a039": "刷新配置文件身份验证失败",
"a37b5e6d37": "已退出配置文件",
"83600521e7": "退出登录失败",
"76deec8f58": "切换组织失败",
"7d4bc516ee": "切换配置文件失败",
"f518e89aa5": "该项目已存在于那个配置文件中",
"f03ae7f27b": "转移项目失败"
}
}
}
},
@@ -8747,7 +8767,14 @@
"behindBaseRefToastDescription": "带有内联设置链接和底部操作按钮的持久提示。",
"notificationPlayground": "通知测试面板",
"notificationPlaygroundDescription": "用于检查提示布局、恢复操作和长文案换行的开发触发器。",
"devOnly": "仅开发"
"devOnly": "仅开发",
"orcaCloud": "Orca Cloud",
"orcaCloudDescription": "Dev-only preview of first-party cloud sign-in. Hidden in production; in dev it also appears in the sidebar account switcher once ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID are set.",
"orcaCloudStatus": "Status",
"orcaCloudSignOut": "Sign out",
"orcaCloudConnect": "Connect profile",
"orcaCloudRefresh": "Refresh status",
"orcaCloudNotConfigured": "Set ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID to preview Orca Cloud sign-in in this dev build."
},
"EphemeralVmRecipeRow": {
"useInWorkspace": "在工作区中使用"
@@ -12492,6 +12519,158 @@
"contextMenu": {
"copy": "复制"
}
},
"orca": {
"profiles": {
"switcher": {
"cloud": {
"unavailable": "云登录不可用"
},
"reconnect": "重新连接配置文件",
"connect": "连接配置文件",
"organization": "组织",
"create": {
"profile": {
"for": {
"org": "为组织创建配置文件"
}
}
},
"signout": "退出登录",
"16e3681072": "新建本地配置文件",
"e3b91a3d90": "创建一个空配置文件,用于分开管理项目和工作树。",
"f322e1f4d6": "配置文件名称",
"cfa59f8ad1": "创建并切换",
"b4f9d1125d": "本地",
"4815f7d163": "切换配置文件",
"d00d853e2a": "管理配置文件",
"c106c674fe": "新建本地配置文件",
"account": "帐户",
"org": {
"members": "组织成员"
}
},
"management": {
"04e7bd2a23": "转移",
"128c7dfe64": "复制到",
"df8b7d876b": "移动到",
"9aa26347b3": "项目已移动",
"816ce624b6": "项目已复制",
"2c45bda8d3": "管理配置文件",
"2db945e4a0": "将项目从当前配置文件复制或移动到另一个本地配置文件。",
"8668cb2946": "此配置文件中没有项目。",
"93034915ab": "复制项目前,请先创建另一个配置文件。",
"projectCountSingular": "{{count}} 个项目",
"projectCountPlural": "{{count}} 个项目"
},
"project": {
"transfer": {
"confirm": {
"move": {
"title": "移动项目?",
"description": "将 {{repoName}} 移动到 {{targetName}}。Orca 会将其从 {{activeProfileName}} 中移除,保留文件原位,并重新启动进入 {{targetName}}。",
"action": "移动项目"
},
"copy": {
"title": "复制项目?",
"description": "将 {{repoName}} 复制到 {{targetName}}。两个配置文件将指向相同的文件,但使用各自独立的 Orca 元数据。",
"action": "复制项目"
},
"live": {
"work": "此项目中正在进行的工作"
},
"cancel": "取消",
"terminalSingular": "{{count}} 个正在运行的终端选项卡",
"terminalPlural": "{{count}} 个正在运行的终端选项卡",
"agentSingular": "{{count}} 个活动的智能体",
"agentPlural": "{{count}} 个活动的智能体",
"browserSingular": "{{count}} 个浏览器工作区",
"browserPlural": "{{count}} 个浏览器工作区"
}
}
},
"signout": {
"confirm": {
"title": "退出登录?",
"description": "退出 {{profileName}},并在此设备上保留其项目、工作树和本地元数据。",
"cancel": "取消",
"action": "退出登录"
}
},
"switch": {
"confirm": {
"target": "所选配置文件",
"title": "切换配置文件?",
"description": "切换到 {{targetName}} 将重新启动 Orca 并重新加载 {{activeProfileName}} 的工作区。",
"live": {
"work": "此配置文件中正在进行的工作"
},
"cancel": "取消",
"switch": "切换配置文件",
"terminalSingular": "{{count}} 个正在运行的终端选项卡",
"terminalPlural": "{{count}} 个正在运行的终端选项卡",
"agentSingular": "{{count}} 个活动的智能体",
"agentPlural": "{{count}} 个活动的智能体",
"browserSingular": "{{count}} 个浏览器工作区",
"browserPlural": "{{count}} 个浏览器工作区"
}
},
"org": {
"members": {
"role": {
"label": "角色",
"owner": "所有者",
"admin": "管理员",
"member": "成员"
},
"not": {
"signed": {
"in": "他们尚未登录 Orca。"
}
},
"remove": {
"confirm": "移除",
"cancel": "取消",
"label": "移除成员"
},
"title": "组织成员",
"load": {
"error": "无法加载组织成员。"
},
"retry": "重试",
"pending": {
"title": "待处理的邀请",
"revoke": "撤销"
},
"invite": {
"title": "邀请",
"placeholder": "teammate@example.com",
"role": "邀请角色",
"submit": "发送"
},
"error": {
"forbidden": "你的角色无法执行此操作。",
"conflict": "已是成员或已被邀请。",
"remove": {
"self": "无法移除你自己。"
},
"own": {
"role": "无法更改你自己的角色。"
},
"not": {
"found": "该成员已不在此组织中。"
},
"reconnect": "请重新连接你的配置文件以管理成员。",
"unconfigured": "此版本不支持组织管理。",
"failed": "出现问题。请重试。"
},
"subtitle": {
"named": "{{orgName}} 中可以协作使用 Orca 的成员。",
"default": "你的组织中可以协作使用 Orca 的成员。"
}
}
}
}
}
},
"i18n": {
+2
View File
@@ -35,6 +35,7 @@ import { createRuntimeStatusSlice } from './slices/runtime-status'
import { createPullRequestGenerationSlice } from './slices/pull-request-generation'
import { createCommitMessageGenerationSlice } from './slices/commit-message-generation'
import { createPinnedTabCloseConfirmSlice } from './slices/pinned-tab-close-confirm'
import { createOrcaProfilesSlice } from './slices/orca-profiles'
import { createNewIssueDraftSlice } from './slices/new-issue-draft'
import { e2eConfig } from '@/lib/e2e-config'
import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing'
@@ -75,6 +76,7 @@ export const useAppStore = create<AppState>()((...a) => ({
...createPullRequestGenerationSlice(...a),
...createCommitMessageGenerationSlice(...a),
...createPinnedTabCloseConfirmSlice(...a),
...createOrcaProfilesSlice(...a),
...createNewIssueDraftSlice(...a)
}))
@@ -140,6 +140,7 @@ import { createRuntimeStatusSlice } from './runtime-status'
import { createPullRequestGenerationSlice } from './pull-request-generation'
import { createCommitMessageGenerationSlice } from './commit-message-generation'
import { createPinnedTabCloseConfirmSlice } from './pinned-tab-close-confirm'
import { createOrcaProfilesSlice } from './orca-profiles'
import { createNewIssueDraftSlice } from './new-issue-draft'
function createTestStore() {
@@ -179,6 +180,7 @@ function createTestStore() {
...createPullRequestGenerationSlice(...a),
...createCommitMessageGenerationSlice(...a),
...createPinnedTabCloseConfirmSlice(...a),
...createOrcaProfilesSlice(...a),
...createNewIssueDraftSlice(...a)
}))
}
@@ -0,0 +1,219 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createTestStore } from './store-test-helpers'
import type {
ConnectCurrentOrcaProfileResult,
CreateCloudLinkedOrcaProfileResult,
OrcaProfileAuthStatus,
OrcaProfileListState,
RefreshCurrentOrcaProfileAuthResult,
SelectOrcaProfileOrgResult,
SignOutCurrentOrcaProfileResult
} from '../../../../shared/orca-profiles'
const listState: OrcaProfileListState = {
activeProfileId: 'local-default',
profiles: [
{
id: 'local-default',
name: 'Personal',
avatar: { kind: 'initials', initials: 'P', color: 'neutral' },
kind: 'local',
createdAt: 1,
updatedAt: 1,
lastOpenedAt: 1
}
]
}
const localAuthStatus: OrcaProfileAuthStatus = {
activeProfileId: 'local-default',
configured: false,
state: 'unconfigured',
persistence: 'none'
}
const connectedCloud = {
cloudProfileId: 'cloud-profile-1',
userId: 'user-1',
email: 'nina@example.com',
linkedAt: 3
}
const connectedOrganizations = [
{ orgId: 'org-1', name: 'Acme', role: 'Admin' },
{ orgId: 'org-2', name: 'Personal' }
]
const connectedAuthStatus: OrcaProfileAuthStatus = {
activeProfileId: 'local-default',
configured: true,
state: 'connected',
persistence: 'encrypted',
cloud: connectedCloud,
organizations: connectedOrganizations,
capabilities: {
flags: { share: true },
refreshedAt: 4
}
}
const orcaProfilesApi = {
list: vi.fn(),
authStatus: vi.fn(),
createLocal: vi.fn(),
createCloudLinked: vi.fn(),
connectCurrent: vi.fn(),
refreshAuth: vi.fn(),
signOutCurrent: vi.fn(),
selectOrg: vi.fn(),
switchProfile: vi.fn(),
transferProject: vi.fn()
}
describe('orca profile auth actions slice', () => {
beforeEach(() => {
vi.resetAllMocks()
orcaProfilesApi.authStatus.mockResolvedValue(localAuthStatus)
vi.stubGlobal('window', {
api: {
orcaProfiles: orcaProfilesApi
}
})
})
it('connects the current profile and stores returned cloud metadata', async () => {
const connectedProfiles = [
{
...listState.profiles[0],
kind: 'cloud-linked' as const,
cloud: connectedAuthStatus.cloud
}
]
const result: ConnectCurrentOrcaProfileResult = {
status: 'connected',
auth: connectedAuthStatus,
activeProfileId: 'local-default',
profiles: connectedProfiles
}
orcaProfilesApi.connectCurrent.mockResolvedValue(result)
const store = createTestStore()
const pending = store.getState().connectCurrentOrcaProfile()
expect(store.getState().orcaProfileConnecting).toBe(true)
await expect(pending).resolves.toEqual(result)
expect(store.getState().orcaProfileConnecting).toBe(false)
expect(store.getState().orcaProfileAuthStatus).toEqual(connectedAuthStatus)
expect(store.getState().orcaProfiles).toEqual(connectedProfiles)
})
it('refreshes current profile auth and stores fresh capability flags', async () => {
const refreshedAuthStatus: OrcaProfileAuthStatus = {
...connectedAuthStatus,
capabilities: {
flags: { share: false, team: true },
refreshedAt: 8
}
}
const result: RefreshCurrentOrcaProfileAuthResult = {
status: 'refreshed',
auth: refreshedAuthStatus,
activeProfileId: 'local-default',
profiles: [
{
...listState.profiles[0],
kind: 'cloud-linked',
cloud: refreshedAuthStatus.cloud
}
]
}
orcaProfilesApi.refreshAuth.mockResolvedValue(result)
const store = createTestStore()
await expect(store.getState().refreshCurrentOrcaProfileAuth()).resolves.toEqual(result)
expect(orcaProfilesApi.refreshAuth).toHaveBeenCalledOnce()
expect(store.getState().orcaProfileAuthStatus).toEqual(refreshedAuthStatus)
expect(store.getState().orcaProfiles).toEqual(result.profiles)
})
it('creates a cloud-linked profile and stores the returned profile list', async () => {
const cloudProfile = {
id: 'cloud-acme',
name: 'Acme',
avatar: { kind: 'initials' as const, initials: 'A', color: 'neutral' as const },
kind: 'cloud-linked' as const,
createdAt: 5,
updatedAt: 5,
lastOpenedAt: 5,
cloud: {
...connectedCloud,
cloudProfileId: 'cloud-profile-2',
activeOrgId: 'org-1',
activeOrgName: 'Acme'
}
}
const result: CreateCloudLinkedOrcaProfileResult = {
status: 'created',
auth: connectedAuthStatus,
activeProfileId: 'local-default',
profiles: [...listState.profiles, cloudProfile],
profile: cloudProfile
}
orcaProfilesApi.createCloudLinked.mockResolvedValue(result)
const store = createTestStore()
await expect(
store.getState().createCloudLinkedOrcaProfile({ orgId: 'org-1', name: 'Acme' })
).resolves.toEqual(result)
expect(orcaProfilesApi.createCloudLinked).toHaveBeenCalledWith({
orgId: 'org-1',
name: 'Acme'
})
expect(store.getState().orcaProfiles).toEqual(result.profiles)
})
it('signs out the current profile without dropping local profile data', async () => {
const result: SignOutCurrentOrcaProfileResult = {
status: 'signed-out',
auth: localAuthStatus,
activeProfileId: 'local-default',
profiles: listState.profiles
}
orcaProfilesApi.signOutCurrent.mockResolvedValue(result)
const store = createTestStore()
await expect(store.getState().signOutCurrentOrcaProfile()).resolves.toEqual(result)
expect(store.getState().orcaProfileAuthStatus).toEqual(localAuthStatus)
expect(store.getState().orcaProfiles).toEqual(listState.profiles)
})
it('selects a cloud organization and refreshes auth state', async () => {
const selectedAuthStatus: OrcaProfileAuthStatus = {
...connectedAuthStatus,
cloud: {
...connectedCloud,
activeOrgId: 'org-1',
activeOrgName: 'Acme'
}
}
const result: SelectOrcaProfileOrgResult = {
status: 'selected',
auth: selectedAuthStatus,
activeProfileId: 'local-default',
profiles: [
{
...listState.profiles[0],
kind: 'cloud-linked',
cloud: selectedAuthStatus.cloud
}
]
}
orcaProfilesApi.selectOrg.mockResolvedValue(result)
const store = createTestStore()
await expect(store.getState().selectOrcaProfileOrg('org-1')).resolves.toEqual(result)
expect(orcaProfilesApi.selectOrg).toHaveBeenCalledWith({ orgId: 'org-1' })
expect(store.getState().orcaProfileAuthStatus).toEqual(selectedAuthStatus)
expect(store.getState().orcaProfileAuthStatus?.organizations).toEqual(connectedOrganizations)
})
})
@@ -0,0 +1,211 @@
import type { StateCreator } from 'zustand'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import type {
ConnectCurrentOrcaProfileResult,
CreateCloudLinkedOrcaProfileResult,
RefreshCurrentOrcaProfileAuthResult,
SelectOrcaProfileOrgResult,
SignOutCurrentOrcaProfileResult
} from '../../../../shared/orca-profiles'
import type { AppState } from '../types'
export type OrcaProfilesAuthActions = {
createCloudLinkedOrcaProfile: (args: {
orgId?: string
name?: string
}) => Promise<CreateCloudLinkedOrcaProfileResult | null>
connectCurrentOrcaProfile: () => Promise<ConnectCurrentOrcaProfileResult | null>
refreshCurrentOrcaProfileAuth: () => Promise<RefreshCurrentOrcaProfileAuthResult | null>
signOutCurrentOrcaProfile: () => Promise<SignOutCurrentOrcaProfileResult | null>
selectOrcaProfileOrg: (orgId: string) => Promise<SelectOrcaProfileOrgResult | null>
}
// Why a separate module: the cloud-auth actions share the profiles slice's
// state keys but form their own cohesive surface (connect/refresh/sign-out/
// org selection), and the combined slice file exceeded the repo line budget.
export const createOrcaProfilesAuthActions: StateCreator<
AppState,
[],
[],
OrcaProfilesAuthActions
> = (set, get) => ({
createCloudLinkedOrcaProfile: async (args) => {
try {
const result = await window.api.orcaProfiles.createCloudLinked(args)
set({
orcaProfileAuthStatus: result.auth,
...(result.status === 'created'
? {
activeOrcaProfileId: result.activeProfileId,
orcaProfiles: result.profiles
}
: {})
})
if (result.status === 'created') {
toast.success(
translate('auto.store.slices.orca.profiles.319d7cf39b', 'Cloud profile created')
)
} else if (result.status === 'reconnect-required') {
toast.error(
translate('auto.store.slices.orca.profiles.d6e764e7db', 'Reconnect this profile')
)
} else if (result.status === 'failed') {
toast.error(
translate('auto.store.slices.orca.profiles.f0c9e11a6d', 'Failed to create cloud profile'),
{ description: result.error }
)
}
return result
} catch (err) {
console.error('Failed to create Orca cloud profile:', err)
toast.error(
translate('auto.store.slices.orca.profiles.f0c9e11a6d', 'Failed to create cloud profile'),
{
description: err instanceof Error ? err.message : String(err)
}
)
return null
}
},
connectCurrentOrcaProfile: async () => {
if (get().orcaProfileConnecting) {
return null
}
set({ orcaProfileConnecting: true })
try {
const result = await window.api.orcaProfiles.connectCurrent()
set({
orcaProfileConnecting: false,
orcaProfileAuthStatus: result.auth,
...(result.status === 'connected'
? {
activeOrcaProfileId: result.activeProfileId,
orcaProfiles: result.profiles
}
: {})
})
if (result.status === 'unconfigured') {
toast.error(
translate(
'auto.store.slices.orca.profiles.8b8fa73174',
'Orca Cloud sign-in is not configured'
),
{
description: result.auth.setupMessage
}
)
} else if (result.status === 'failed') {
toast.error(
translate('auto.store.slices.orca.profiles.33290e88ed', 'Failed to connect profile'),
{ description: result.error }
)
} else if (result.status === 'connected') {
toast.success(translate('auto.store.slices.orca.profiles.9fcb07a796', 'Profile connected'))
}
return result
} catch (err) {
console.error('Failed to connect Orca profile:', err)
set({ orcaProfileConnecting: false })
toast.error(
translate('auto.store.slices.orca.profiles.33290e88ed', 'Failed to connect profile'),
{
description: err instanceof Error ? err.message : String(err)
}
)
return null
}
},
refreshCurrentOrcaProfileAuth: async () => {
try {
const result = await window.api.orcaProfiles.refreshAuth()
set({
orcaProfileAuthStatus: result.auth,
...(result.status === 'refreshed'
? {
activeOrcaProfileId: result.activeProfileId,
orcaProfiles: result.profiles
}
: {})
})
if (result.status === 'reconnect-required') {
toast.error(
translate('auto.store.slices.orca.profiles.d6e764e7db', 'Reconnect this profile')
)
} else if (result.status === 'failed') {
toast.error(
translate('auto.store.slices.orca.profiles.2f6c78a039', 'Failed to refresh profile auth'),
{ description: result.error }
)
}
return result
} catch (err) {
console.error('Failed to refresh Orca profile auth:', err)
toast.error(
translate('auto.store.slices.orca.profiles.2f6c78a039', 'Failed to refresh profile auth'),
{
description: err instanceof Error ? err.message : String(err)
}
)
return null
}
},
signOutCurrentOrcaProfile: async () => {
try {
const result = await window.api.orcaProfiles.signOutCurrent()
set({
activeOrcaProfileId: result.activeProfileId,
orcaProfiles: result.profiles,
orcaProfileAuthStatus: result.auth
})
toast.success(
translate('auto.store.slices.orca.profiles.a37b5e6d37', 'Signed out of profile')
)
return result
} catch (err) {
console.error('Failed to sign out of Orca profile:', err)
toast.error(translate('auto.store.slices.orca.profiles.83600521e7', 'Failed to sign out'), {
description: err instanceof Error ? err.message : String(err)
})
return null
}
},
selectOrcaProfileOrg: async (orgId) => {
try {
const result = await window.api.orcaProfiles.selectOrg({ orgId })
set({
orcaProfileAuthStatus: result.auth,
...(result.status === 'selected'
? {
activeOrcaProfileId: result.activeProfileId,
orcaProfiles: result.profiles
}
: {})
})
if (result.status === 'reconnect-required') {
toast.error(
translate('auto.store.slices.orca.profiles.d6e764e7db', 'Reconnect this profile')
)
} else if (result.status === 'failed') {
toast.error(
translate('auto.store.slices.orca.profiles.76deec8f58', 'Failed to switch organization'),
{ description: result.error }
)
}
return result
} catch (err) {
console.error('Failed to switch Orca profile org:', err)
toast.error(
translate('auto.store.slices.orca.profiles.76deec8f58', 'Failed to switch organization'),
{
description: err instanceof Error ? err.message : String(err)
}
)
return null
}
}
})
@@ -0,0 +1,265 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { createTestStore } from './store-test-helpers'
import type {
CreateLocalOrcaProfileResult,
OrcaProfileAuthStatus,
OrcaProfileListResult,
TransferOrcaProfileProjectResult
} from '../../../../shared/orca-profiles'
const { toastErrorMock } = vi.hoisted(() => ({
toastErrorMock: vi.fn()
}))
vi.mock('sonner', () => ({
toast: {
error: toastErrorMock,
info: vi.fn(),
success: vi.fn(),
warning: vi.fn()
}
}))
const listState: OrcaProfileListResult = {
activeProfileId: 'local-default',
multiProfileUi: false,
profiles: [
{
id: 'local-default',
name: 'Personal',
avatar: { kind: 'initials', initials: 'P', color: 'neutral' },
kind: 'local',
createdAt: 1,
updatedAt: 1,
lastOpenedAt: 1
}
]
}
const createdState: CreateLocalOrcaProfileResult = {
activeProfileId: 'local-default',
profiles: [
...listState.profiles,
{
id: 'local-work',
name: 'Work',
avatar: { kind: 'initials', initials: 'W', color: 'neutral' },
kind: 'local',
createdAt: 2,
updatedAt: 2,
lastOpenedAt: 2
}
],
profile: {
id: 'local-work',
name: 'Work',
avatar: { kind: 'initials', initials: 'W', color: 'neutral' },
kind: 'local',
createdAt: 2,
updatedAt: 2,
lastOpenedAt: 2
}
}
const localAuthStatus: OrcaProfileAuthStatus = {
activeProfileId: 'local-default',
configured: false,
state: 'unconfigured',
persistence: 'none'
}
const connectedAuthStatus: OrcaProfileAuthStatus = {
activeProfileId: 'local-default',
configured: true,
state: 'connected',
persistence: 'encrypted',
cloud: {
cloudProfileId: 'cloud-profile-1',
userId: 'user-1',
email: 'nina@example.com',
linkedAt: 3
},
capabilities: {
flags: { share: true },
refreshedAt: 4
}
}
const orcaProfilesApi = {
list: vi.fn(),
authStatus: vi.fn(),
createLocal: vi.fn(),
createCloudLinked: vi.fn(),
connectCurrent: vi.fn(),
refreshAuth: vi.fn(),
signOutCurrent: vi.fn(),
selectOrg: vi.fn(),
switchProfile: vi.fn(),
transferProject: vi.fn()
}
describe('orca profile slice', () => {
beforeEach(() => {
vi.resetAllMocks()
toastErrorMock.mockReset()
orcaProfilesApi.authStatus.mockResolvedValue(localAuthStatus)
vi.stubGlobal('window', {
api: {
orcaProfiles: orcaProfilesApi
}
})
})
it('fetches profiles into store state', async () => {
orcaProfilesApi.list.mockResolvedValue(listState)
const store = createTestStore()
await store.getState().fetchOrcaProfiles()
expect(store.getState().activeOrcaProfileId).toBe('local-default')
expect(store.getState().orcaProfiles).toEqual(listState.profiles)
expect(store.getState().orcaProfileAuthStatus).toEqual(localAuthStatus)
expect(store.getState().orcaProfilesMultiProfileUi).toBe(false)
expect(store.getState().orcaProfilesLoading).toBe(false)
})
it('stores the multi-profile UI flag from the list result', async () => {
orcaProfilesApi.list.mockResolvedValue({ ...listState, multiProfileUi: true })
const store = createTestStore()
await store.getState().fetchOrcaProfiles()
expect(store.getState().orcaProfilesMultiProfileUi).toBe(true)
})
it('creates a local profile and returns the created summary', async () => {
orcaProfilesApi.createLocal.mockResolvedValue(createdState)
const store = createTestStore()
const profile = await store.getState().createLocalOrcaProfile('Work')
expect(profile).toEqual(createdState.profile)
expect(orcaProfilesApi.createLocal).toHaveBeenCalledWith({ name: 'Work' })
expect(store.getState().orcaProfiles).toEqual(createdState.profiles)
})
it('fetches auth status independently', async () => {
orcaProfilesApi.authStatus.mockResolvedValue(connectedAuthStatus)
const store = createTestStore()
await expect(store.getState().fetchOrcaProfileAuthStatus()).resolves.toEqual(
connectedAuthStatus
)
expect(store.getState().orcaProfileAuthStatus).toEqual(connectedAuthStatus)
})
it('sets switching state while requesting a profile switch', async () => {
orcaProfilesApi.switchProfile.mockResolvedValue({ status: 'relaunching' })
const store = createTestStore()
store.setState({ activeOrcaProfileId: 'local-default' })
const result = await store.getState().switchOrcaProfile('local-work')
expect(result).toEqual({ status: 'relaunching' })
expect(orcaProfilesApi.switchProfile).toHaveBeenCalledWith({ profileId: 'local-work' })
expect(store.getState().orcaProfileSwitching).toBe(true)
})
it('releases switching state when main reports the profile is already active', async () => {
// Why: a stale renderer activeOrcaProfileId must not lock the switcher
// forever when no relaunch is actually coming.
orcaProfilesApi.switchProfile.mockResolvedValue({ status: 'already-active' })
const store = createTestStore()
store.setState({ activeOrcaProfileId: 'local-default' })
const result = await store.getState().switchOrcaProfile('local-work')
expect(result).toEqual({ status: 'already-active' })
expect(store.getState().orcaProfileSwitching).toBe(false)
})
it('does not call main when switching to the active profile', async () => {
const store = createTestStore()
store.setState({ activeOrcaProfileId: 'local-default' })
const result = await store.getState().switchOrcaProfile('local-default')
expect(result).toEqual({ status: 'already-active' })
expect(orcaProfilesApi.switchProfile).not.toHaveBeenCalled()
})
it('transfers projects through the profile API', async () => {
const transferResult: TransferOrcaProfileProjectResult = {
status: 'transferred',
mode: 'copy',
sourceProfileId: 'local-default',
targetProfileId: 'local-work',
sourceRepoId: 'repo-1',
targetRepoId: 'repo-2',
targetProjectId: 'repo:repo-2'
}
orcaProfilesApi.transferProject.mockResolvedValue(transferResult)
const store = createTestStore()
const result = await store.getState().transferOrcaProfileProject({
sourceProfileId: 'local-default',
targetProfileId: 'local-work',
repoId: 'repo-1',
mode: 'copy'
})
expect(result).toEqual(transferResult)
expect(orcaProfilesApi.transferProject).toHaveBeenCalledWith({
sourceProfileId: 'local-default',
targetProfileId: 'local-work',
repoId: 'repo-1',
mode: 'copy'
})
})
it('marks profile switching when a project transfer relaunches the app', async () => {
const transferResult: TransferOrcaProfileProjectResult = {
status: 'transferred',
mode: 'move',
sourceProfileId: 'local-default',
targetProfileId: 'local-work',
sourceRepoId: 'repo-1',
targetRepoId: 'repo-1',
targetProjectId: 'repo:repo-1',
willRelaunch: true
}
orcaProfilesApi.transferProject.mockResolvedValue(transferResult)
const store = createTestStore()
await store.getState().transferOrcaProfileProject({
sourceProfileId: 'local-default',
targetProfileId: 'local-work',
repoId: 'repo-1',
mode: 'move'
})
expect(store.getState().orcaProfileSwitching).toBe(true)
})
it('warns when a project already exists in the target profile', async () => {
const transferResult: TransferOrcaProfileProjectResult = {
status: 'duplicate-target',
sourceProfileId: 'local-default',
targetProfileId: 'local-work',
sourceRepoId: 'repo-1',
duplicateRepoId: 'repo-existing'
}
orcaProfilesApi.transferProject.mockResolvedValue(transferResult)
const store = createTestStore()
await store.getState().transferOrcaProfileProject({
sourceProfileId: 'local-default',
targetProfileId: 'local-work',
repoId: 'repo-1',
mode: 'copy'
})
expect(toastErrorMock).toHaveBeenCalledWith('Project already exists in that profile')
expect(store.getState().orcaProfileSwitching).toBe(false)
})
})
@@ -0,0 +1,153 @@
import type { StateCreator } from 'zustand'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import type {
OrcaProfileAuthStatus,
OrcaProfileSummary,
SwitchOrcaProfileResult,
TransferOrcaProfileProjectArgs,
TransferOrcaProfileProjectResult
} from '../../../../shared/orca-profiles'
import type { AppState } from '../types'
import {
createOrcaProfilesAuthActions,
type OrcaProfilesAuthActions
} from './orca-profiles-auth-actions'
export type OrcaProfilesSlice = OrcaProfilesAuthActions & {
orcaProfiles: OrcaProfileSummary[]
activeOrcaProfileId: string | null
orcaProfileAuthStatus: OrcaProfileAuthStatus | null
orcaProfilesMultiProfileUi: boolean
orcaProfilesLoading: boolean
orcaProfileSwitching: boolean
orcaProfileConnecting: boolean
fetchOrcaProfiles: () => Promise<void>
fetchOrcaProfileAuthStatus: () => Promise<OrcaProfileAuthStatus | null>
createLocalOrcaProfile: (name?: string) => Promise<OrcaProfileSummary | null>
switchOrcaProfile: (profileId: string) => Promise<SwitchOrcaProfileResult | null>
transferOrcaProfileProject: (
args: TransferOrcaProfileProjectArgs
) => Promise<TransferOrcaProfileProjectResult | null>
}
export const createOrcaProfilesSlice: StateCreator<AppState, [], [], OrcaProfilesSlice> = (
set,
get,
api
) => ({
orcaProfiles: [],
activeOrcaProfileId: null,
orcaProfileAuthStatus: null,
orcaProfilesMultiProfileUi: false,
orcaProfilesLoading: false,
orcaProfileSwitching: false,
orcaProfileConnecting: false,
fetchOrcaProfiles: async () => {
set({ orcaProfilesLoading: true })
try {
const [state, authStatus] = await Promise.all([
window.api.orcaProfiles.list(),
window.api.orcaProfiles.authStatus()
])
set({
activeOrcaProfileId: state.activeProfileId,
orcaProfiles: state.profiles,
orcaProfilesMultiProfileUi: state.multiProfileUi,
orcaProfileAuthStatus: authStatus,
orcaProfilesLoading: false
})
} catch (err) {
console.error('Failed to fetch Orca profiles:', err)
set({ orcaProfilesLoading: false })
}
},
fetchOrcaProfileAuthStatus: async () => {
try {
const authStatus = await window.api.orcaProfiles.authStatus()
set({ orcaProfileAuthStatus: authStatus })
return authStatus
} catch (err) {
console.error('Failed to fetch Orca profile auth status:', err)
return null
}
},
createLocalOrcaProfile: async (name) => {
try {
const state = await window.api.orcaProfiles.createLocal({ name })
set({
activeOrcaProfileId: state.activeProfileId,
orcaProfiles: state.profiles
})
void get().fetchOrcaProfileAuthStatus()
return state.profile
} catch (err) {
console.error('Failed to create Orca profile:', err)
toast.error(
translate('auto.store.slices.orca.profiles.612f7f6861', 'Failed to create profile'),
{
description: err instanceof Error ? err.message : String(err)
}
)
return null
}
},
...createOrcaProfilesAuthActions(set, get, api),
switchOrcaProfile: async (profileId) => {
if (!profileId || profileId === get().activeOrcaProfileId) {
return { status: 'already-active' }
}
set({ orcaProfileSwitching: true })
try {
const result = await window.api.orcaProfiles.switchProfile({ profileId })
if (result?.status !== 'relaunching') {
// Why: only a relaunch may keep the switcher locked; a stale
// "already-active" answer would otherwise disable it forever.
set({ orcaProfileSwitching: false })
}
return result
} catch (err) {
console.error('Failed to switch Orca profile:', err)
set({ orcaProfileSwitching: false })
toast.error(
translate('auto.store.slices.orca.profiles.7d4bc516ee', 'Failed to switch profile'),
{
description: err instanceof Error ? err.message : String(err)
}
)
return null
}
},
transferOrcaProfileProject: async (args) => {
try {
const result = await window.api.orcaProfiles.transferProject(args)
if (result.status === 'duplicate-target') {
toast.error(
translate(
'auto.store.slices.orca.profiles.f518e89aa5',
'Project already exists in that profile'
)
)
}
if (result.status === 'transferred' && result.willRelaunch) {
set({ orcaProfileSwitching: true })
}
return result
} catch (err) {
console.error('Failed to transfer Orca profile project:', err)
toast.error(
translate('auto.store.slices.orca.profiles.f03ae7f27b', 'Failed to transfer project'),
{
description: err instanceof Error ? err.message : String(err)
}
)
return null
}
}
})
@@ -0,0 +1,117 @@
import { beforeEach, vi, type Mock } from 'vitest'
import { toast } from 'sonner'
import type { Repo } from '../../../../shared/types'
import {
createCompatibleRuntimeStatusResponseIfNeeded,
type RuntimeEnvironmentCallRequest
} from '../../runtime/runtime-compatibility-test-fixture'
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
// Shared harness for the repo-slice runtime-routing suite: sample repos, IPC/runtime
// mocks, and the window stub reset between tests. Extracted to keep the test file itself
// under the max-lines limit.
export const localRepo: Repo = {
id: 'local-repo',
path: '/local',
displayName: 'Local',
badgeColor: '#000',
addedAt: 1
}
export const remoteRepo: Repo = {
id: 'remote-repo',
path: '/remote',
displayName: 'Remote',
badgeColor: '#111',
addedAt: 2
}
export const sshRepo: Repo = {
id: 'ssh-repo',
path: '/home/orca/project',
displayName: 'SSH',
badgeColor: '#222',
addedAt: 3,
connectionId: 'ssh-1'
}
export const reposList: Mock = vi.fn()
export const reposAdd: Mock = vi.fn()
export const reposPickFolder: Mock = vi.fn()
export const reposClone: Mock = vi.fn()
export const reposCloneRemote: Mock = vi.fn()
export const reposRemove: Mock = vi.fn()
export const reposUpdate: Mock = vi.fn()
export const reposReorder: Mock = vi.fn()
export const projectsCreateHostSetup: Mock = vi.fn()
export const projectsSetupExistingFolder: Mock = vi.fn()
export const projectsUpdateHostSetup: Mock = vi.fn()
export const projectsDeleteHostSetup: Mock = vi.fn()
export const projectsUpdate: Mock = vi.fn()
export const projectGroupsMoveProject: Mock = vi.fn()
export const ptyKill: Mock = vi.fn()
export const runtimeEnvironmentCall: Mock = vi.fn()
export const runtimeEnvironmentTransportCall: Mock = vi.fn()
export const orcaProfileFindProjectProfiles: Mock = vi.fn()
// Registers the per-test reset + window stub. Call once inside the suite's module scope.
export function installReposRuntimeRoutingHarness(): void {
beforeEach(() => {
clearRuntimeCompatibilityCacheForTests()
vi.mocked(toast.error).mockReset()
vi.mocked(toast.info).mockReset()
vi.mocked(toast.success).mockReset()
vi.mocked(toast.warning).mockReset()
reposList.mockReset()
reposAdd.mockReset()
reposPickFolder.mockReset()
reposClone.mockReset()
reposCloneRemote.mockReset()
reposRemove.mockReset()
reposUpdate.mockReset()
reposReorder.mockReset()
projectsCreateHostSetup.mockReset()
projectsSetupExistingFolder.mockReset()
projectsUpdateHostSetup.mockReset()
projectsDeleteHostSetup.mockReset()
projectsUpdate.mockReset()
projectGroupsMoveProject.mockReset()
ptyKill.mockReset()
orcaProfileFindProjectProfiles.mockReset()
runtimeEnvironmentCall.mockReset()
runtimeEnvironmentTransportCall.mockReset()
runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args)
})
vi.stubGlobal('window', {
api: {
repos: {
list: reposList,
add: reposAdd,
clone: reposClone,
cloneRemote: reposCloneRemote,
pickFolder: reposPickFolder,
remove: reposRemove,
update: reposUpdate,
reorder: reposReorder
},
projects: {
update: projectsUpdate,
createHostSetup: projectsCreateHostSetup,
setupExistingFolder: projectsSetupExistingFolder,
updateHostSetup: projectsUpdateHostSetup,
deleteHostSetup: projectsDeleteHostSetup
},
projectGroups: {
moveProject: projectGroupsMoveProject
},
orcaProfiles: {
findProjectProfiles: orcaProfileFindProjectProfiles
},
pty: { kill: ptyKill },
runtimeEnvironments: { call: runtimeEnvironmentTransportCall }
}
})
})
}
+63 -97
View File
@@ -1,105 +1,38 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { createTestStore, makeWorktree } from './store-test-helpers'
import { workItemsCacheKey } from './github'
import type { Project, ProjectHostSetup, Repo } from '../../../../shared/types'
import type { Project, ProjectHostSetup } from '../../../../shared/types'
import { toast } from 'sonner'
import {
createCompatibleRuntimeStatusResponseIfNeeded,
type RuntimeEnvironmentCallRequest
} from '../../runtime/runtime-compatibility-test-fixture'
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
installReposRuntimeRoutingHarness,
localRepo,
orcaProfileFindProjectProfiles,
projectGroupsMoveProject,
projectsSetupExistingFolder,
ptyKill,
remoteRepo,
reposAdd,
reposClone,
reposCloneRemote,
reposList,
reposPickFolder,
reposRemove,
reposReorder,
reposUpdate,
runtimeEnvironmentCall,
sshRepo
} from './repos-runtime-routing-fixture'
const localRepo: Repo = {
id: 'local-repo',
path: '/local',
displayName: 'Local',
badgeColor: '#000',
addedAt: 1
}
vi.mock('sonner', () => ({
toast: {
error: vi.fn(),
info: vi.fn(),
success: vi.fn(),
warning: vi.fn()
}
}))
const remoteRepo: Repo = {
id: 'remote-repo',
path: '/remote',
displayName: 'Remote',
badgeColor: '#111',
addedAt: 2
}
const sshRepo: Repo = {
id: 'ssh-repo',
path: '/home/orca/project',
displayName: 'SSH',
badgeColor: '#222',
addedAt: 3,
connectionId: 'ssh-1'
}
const reposList = vi.fn()
const reposAdd = vi.fn()
const reposPickFolder = vi.fn()
const reposClone = vi.fn()
const reposCloneRemote = vi.fn()
const reposRemove = vi.fn()
const reposUpdate = vi.fn()
const reposReorder = vi.fn()
const projectsCreateHostSetup = vi.fn()
const projectsSetupExistingFolder = vi.fn()
const projectsUpdateHostSetup = vi.fn()
const projectsDeleteHostSetup = vi.fn()
const projectsUpdate = vi.fn()
const projectGroupsMoveProject = vi.fn()
const ptyKill = vi.fn()
const runtimeEnvironmentCall = vi.fn()
const runtimeEnvironmentTransportCall = vi.fn()
beforeEach(() => {
clearRuntimeCompatibilityCacheForTests()
reposList.mockReset()
reposAdd.mockReset()
reposPickFolder.mockReset()
reposClone.mockReset()
reposCloneRemote.mockReset()
reposRemove.mockReset()
reposUpdate.mockReset()
reposReorder.mockReset()
projectsCreateHostSetup.mockReset()
projectsSetupExistingFolder.mockReset()
projectsUpdateHostSetup.mockReset()
projectsDeleteHostSetup.mockReset()
projectsUpdate.mockReset()
projectGroupsMoveProject.mockReset()
ptyKill.mockReset()
runtimeEnvironmentCall.mockReset()
runtimeEnvironmentTransportCall.mockReset()
runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args)
})
vi.stubGlobal('window', {
api: {
repos: {
list: reposList,
add: reposAdd,
clone: reposClone,
cloneRemote: reposCloneRemote,
pickFolder: reposPickFolder,
remove: reposRemove,
update: reposUpdate,
reorder: reposReorder
},
projects: {
update: projectsUpdate,
createHostSetup: projectsCreateHostSetup,
setupExistingFolder: projectsSetupExistingFolder,
updateHostSetup: projectsUpdateHostSetup,
deleteHostSetup: projectsDeleteHostSetup
},
projectGroups: {
moveProject: projectGroupsMoveProject
},
pty: { kill: ptyKill },
runtimeEnvironments: { call: runtimeEnvironmentTransportCall }
}
})
})
installReposRuntimeRoutingHarness()
describe('repo slice runtime routing', () => {
it('fetches repos from local IPC when no remote environment is active', async () => {
@@ -240,6 +173,39 @@ describe('repo slice runtime routing', () => {
})
expect(reposAdd).not.toHaveBeenCalled()
expect(reposPickFolder).not.toHaveBeenCalled()
expect(orcaProfileFindProjectProfiles).not.toHaveBeenCalled()
})
it('warns when a local project is already present in another profile', async () => {
reposAdd.mockResolvedValue({ repo: localRepo })
orcaProfileFindProjectProfiles.mockResolvedValue({
projects: [
{
profileId: 'work',
profileName: 'Work',
profileKind: 'local',
repoId: 'work-repo',
repoName: 'Local'
}
]
})
const store = createTestStore()
store.setState({ activeOrcaProfileId: 'local-default' })
await expect(store.getState().addRepoPath('/local')).resolves.toEqual({
...localRepo,
executionHostId: 'local'
})
expect(orcaProfileFindProjectProfiles).toHaveBeenCalledWith({
path: '/local',
connectionId: null,
executionHostId: 'local',
excludeProfileId: 'local-default'
})
expect(toast.warning).toHaveBeenCalledWith('Project also exists in another profile', {
description: 'Work'
})
})
it('sets up a project on a local host through the project setup API', async () => {
+48
View File
@@ -264,6 +264,51 @@ function getSafeAutoForkSyncKey(repo: Repo): string {
return `${getRepoExecutionHostId(repo)}:${repo.id}:${repo.path}`
}
function formatProjectPresenceProfileNames(profileNames: readonly string[]): string {
const names = [...new Set(profileNames.map((name) => name.trim()).filter(Boolean))]
if (names.length <= 3) {
return names.join(', ')
}
// Why: the "+N more" overflow suffix is user-visible toast copy and must localize.
return translate('auto.store.slices.repos.presenceProfileOverflow', '{{names}} +{{count}} more', {
names: names.slice(0, 3).join(', '),
count: names.length - 3
})
}
async function warnIfProjectKnownInAnotherProfile(
repo: Repo,
activeOrcaProfileId: string | null
): Promise<void> {
const findProjectProfiles = window.api.orcaProfiles?.findProjectProfiles
// Why: without a loaded active profile ID the scan cannot exclude the
// current profile and would false-positive on the project just added.
if (!findProjectProfiles || !activeOrcaProfileId) {
return
}
try {
const result = await findProjectProfiles({
path: repo.path,
connectionId: repo.connectionId ?? null,
executionHostId: getRepoExecutionHostId(repo),
excludeProfileId: activeOrcaProfileId
})
const description = formatProjectPresenceProfileNames(
result.projects.map((project) => project.profileName)
)
if (!description) {
return
}
toast.warning(
translate('auto.store.slices.repos.2dcd706774', 'Project also exists in another profile'),
{ description }
)
} catch (err) {
// Why: adding a project should not fail because an advisory profile scan failed.
console.warn('Failed to check project presence in other profiles:', err)
}
}
function scheduleSafeAutoForkSync(get: () => AppState, repos: readonly Repo[]): void {
for (const repo of repos) {
if (repo.kind === 'folder' || repo.forkSyncMode !== 'safe-auto' || !repo.upstream) {
@@ -2238,6 +2283,9 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
description: repo.displayName
}
)
// Why: the design requires the cross-profile advisory for SSH-added
// projects too — the presence lookup already keys on connection/host.
await warnIfProjectKnownInAnotherProfile(repo, get().activeOrcaProfileId)
}
return repo
} catch (err) {
@@ -43,6 +43,7 @@ import { createRuntimeStatusSlice } from './runtime-status'
import { createPullRequestGenerationSlice } from './pull-request-generation'
import { createCommitMessageGenerationSlice } from './commit-message-generation'
import { createPinnedTabCloseConfirmSlice } from './pinned-tab-close-confirm'
import { createOrcaProfilesSlice } from './orca-profiles'
import { createNewIssueDraftSlice } from './new-issue-draft'
import { translate } from '@/i18n/i18n'
@@ -91,6 +92,7 @@ export function createTestStore() {
...createPullRequestGenerationSlice(...a),
...createCommitMessageGenerationSlice(...a),
...createPinnedTabCloseConfirmSlice(...a),
...createOrcaProfilesSlice(...a),
...createNewIssueDraftSlice(...a)
}))
}
+2
View File
@@ -33,6 +33,7 @@ import type { RuntimeStatusSlice } from './slices/runtime-status'
import type { PullRequestGenerationSlice } from './slices/pull-request-generation'
import type { CommitMessageGenerationSlice } from './slices/commit-message-generation'
import type { PinnedTabCloseConfirmSlice } from './slices/pinned-tab-close-confirm'
import type { OrcaProfilesSlice } from './slices/orca-profiles'
import type { NewIssueDraftSlice } from './slices/new-issue-draft'
export type AppState = RepoSlice &
@@ -70,4 +71,5 @@ export type AppState = RepoSlice &
PullRequestGenerationSlice &
CommitMessageGenerationSlice &
PinnedTabCloseConfirmSlice &
OrcaProfilesSlice &
NewIssueDraftSlice
+65
View File
@@ -46,6 +46,10 @@ import {
normalizeWorktreeCardProperties,
ONBOARDING_FLOW_VERSION
} from '../../../shared/constants'
import {
createDefaultLocalOrcaProfile,
DEFAULT_LOCAL_ORCA_PROFILE_ID
} from '../../../shared/orca-profiles'
import { legacyBaseRefSearchResult } from '../../../shared/base-ref-search-result'
import { createE2EConfig } from '../../../shared/e2e-config'
import { relativePathInsideRoot } from '../../../shared/cross-platform-path'
@@ -455,6 +459,15 @@ export function installWebPreloadApi(): void {
}
function createWebPreloadApi(): Partial<PreloadApi> {
const webOrcaProfileAuthStatus = () =>
Promise.resolve({
activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID,
configured: false,
state: 'unconfigured' as const,
persistence: 'none' as const,
setupMessage: 'Orca Cloud sign-in is not available in the browser fallback.'
})
return {
app: {
getIdentity: () =>
@@ -501,6 +514,58 @@ function createWebPreloadApi(): Partial<PreloadApi> {
displayServer: null
})
},
orcaProfiles: {
list: () =>
Promise.resolve({
activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID,
profiles: [createDefaultLocalOrcaProfile(0)],
multiProfileUi: false
}),
authStatus: webOrcaProfileAuthStatus,
createLocal: () =>
Promise.resolve({
activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID,
profiles: [createDefaultLocalOrcaProfile(0)],
profile: createDefaultLocalOrcaProfile(0)
}),
createCloudLinked: async () => ({
status: 'unconfigured',
auth: await webOrcaProfileAuthStatus()
}),
switchProfile: () => Promise.resolve({ status: 'already-active' }),
transferProject: (args) =>
Promise.resolve({
status: 'duplicate-target',
sourceProfileId: args.sourceProfileId,
targetProfileId: args.targetProfileId,
sourceRepoId: args.repoId,
duplicateRepoId: args.repoId
}),
findProjectProfiles: async () => ({ projects: [] }),
connectCurrent: async () => ({
status: 'unconfigured',
auth: await webOrcaProfileAuthStatus()
}),
refreshAuth: async () => ({
status: 'unconfigured',
auth: await webOrcaProfileAuthStatus()
}),
signOutCurrent: async () => ({
status: 'signed-out',
auth: await webOrcaProfileAuthStatus(),
activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID,
profiles: [createDefaultLocalOrcaProfile(0)]
}),
selectOrg: async () => ({
status: 'unconfigured',
auth: await webOrcaProfileAuthStatus()
}),
orgMembersList: async () => ({ status: 'unconfigured' }),
orgMemberInvite: async () => ({ status: 'unconfigured' }),
orgInviteRevoke: async () => ({ status: 'unconfigured' }),
orgMemberChangeRole: async () => ({ status: 'unconfigured' }),
orgMemberRemove: async () => ({ status: 'unconfigured' })
},
e2e: {
getConfig: () => createE2EConfig({})
},
+346
View File
@@ -0,0 +1,346 @@
import { ORCA_BROWSER_PARTITION } from './constants'
import type { ExecutionHostId } from './execution-host'
export const ORCA_PROFILE_INDEX_SCHEMA_VERSION = 1
export const DEFAULT_LOCAL_ORCA_PROFILE_ID = 'local-default'
export const DEFAULT_LOCAL_ORCA_PROFILE_NAME = 'Personal'
const LEGACY_ORCA_BROWSER_SESSION_PARTITION_PREFIX = 'persist:orca-browser-session-'
export type OrcaProfileAvatar = {
kind: 'initials'
initials: string
color: 'neutral'
}
export type OrcaProfileKind = 'local' | 'cloud-linked'
export type OrcaProfileCloudSummary = {
cloudProfileId: string
userId: string
email: string
displayName?: string
activeOrgId?: string
activeOrgName?: string
linkedAt: number
}
export type OrcaCloudOrgSummary = {
orgId: string
name: string
role?: string
}
export type OrcaCloudCapabilityFlags = Record<string, boolean>
export type OrcaCloudCapabilities = {
flags: OrcaCloudCapabilityFlags
refreshedAt: number
}
export type OrcaCloudSessionPersistence = 'none' | 'encrypted' | 'memory-only' | 'dev-plaintext'
export type OrcaProfileAuthState = 'local' | 'unconfigured' | 'connected' | 'reconnect-required'
export type OrcaProfileAuthStatus = {
activeProfileId: string
configured: boolean
state: OrcaProfileAuthState
persistence: OrcaCloudSessionPersistence
cloud?: OrcaProfileCloudSummary
organizations?: OrcaCloudOrgSummary[]
capabilities?: OrcaCloudCapabilities
credentialError?: string
setupMessage?: string
}
export type OrcaProfileSummary = {
id: string
name: string
avatar: OrcaProfileAvatar
kind: OrcaProfileKind
createdAt: number
updatedAt: number
lastOpenedAt: number
cloud?: OrcaProfileCloudSummary
}
export type OrcaProfileIndex = {
schemaVersion: number
activeProfileId: string
profiles: OrcaProfileSummary[]
}
export type OrcaProfileListState = {
activeProfileId: string
profiles: OrcaProfileSummary[]
}
export type OrcaProfileListResult = OrcaProfileListState & {
// Why: gates the full multi-profile switcher UI; default builds show a
// single-profile account menu instead.
multiProfileUi: boolean
}
export type CreateLocalOrcaProfileArgs = {
name?: string
}
export type CreateLocalOrcaProfileResult = OrcaProfileListState & {
profile: OrcaProfileSummary
}
export type CreateCloudLinkedOrcaProfileArgs = {
orgId?: string
name?: string
}
export type SwitchOrcaProfileArgs = {
profileId: string
}
export type SwitchOrcaProfileResult = {
status: 'already-active' | 'relaunching'
}
export type TransferOrcaProfileProjectMode = 'move' | 'copy'
export type TransferOrcaProfileProjectArgs = {
sourceProfileId: string
targetProfileId: string
repoId: string
mode: TransferOrcaProfileProjectMode
}
export type FindOrcaProfileProjectsByPathArgs = {
path: string
connectionId?: string | null
executionHostId?: ExecutionHostId | null
excludeProfileId?: string | null
}
export type OrcaProfileProjectPresence = {
profileId: string
profileName: string
profileKind: OrcaProfileKind
repoId: string
repoName: string
}
export type FindOrcaProfileProjectsByPathResult = {
projects: OrcaProfileProjectPresence[]
}
export type TransferOrcaProfileProjectResult =
| {
status: 'transferred'
mode: TransferOrcaProfileProjectMode
sourceProfileId: string
targetProfileId: string
sourceRepoId: string
targetRepoId: string
targetProjectId: string | null
willRelaunch?: boolean
}
| {
status: 'duplicate-target'
sourceProfileId: string
targetProfileId: string
sourceRepoId: string
duplicateRepoId: string
}
export type ConnectCurrentOrcaProfileResult =
| {
status: 'connected'
auth: OrcaProfileAuthStatus
activeProfileId: string
profiles: OrcaProfileSummary[]
}
| {
status: 'unconfigured'
auth: OrcaProfileAuthStatus
}
| {
status: 'cancelled'
auth: OrcaProfileAuthStatus
}
| {
status: 'failed'
auth: OrcaProfileAuthStatus
error: string
}
export type CreateCloudLinkedOrcaProfileResult =
| {
status: 'created'
auth: OrcaProfileAuthStatus
activeProfileId: string
profiles: OrcaProfileSummary[]
profile: OrcaProfileSummary
}
| {
status: 'unconfigured' | 'reconnect-required'
auth: OrcaProfileAuthStatus
}
| {
status: 'failed'
auth: OrcaProfileAuthStatus
error: string
}
export type SignOutCurrentOrcaProfileResult = {
status: 'signed-out'
auth: OrcaProfileAuthStatus
activeProfileId: string
profiles: OrcaProfileSummary[]
}
export type SelectOrcaProfileOrgArgs = {
orgId: string
}
export type SelectOrcaProfileOrgResult =
| {
status: 'selected'
auth: OrcaProfileAuthStatus
activeProfileId: string
profiles: OrcaProfileSummary[]
}
| {
status: 'unconfigured' | 'reconnect-required'
auth: OrcaProfileAuthStatus
}
| {
status: 'failed'
auth: OrcaProfileAuthStatus
error: string
}
export type RefreshCurrentOrcaProfileAuthResult =
| {
status: 'refreshed'
auth: OrcaProfileAuthStatus
activeProfileId: string
profiles: OrcaProfileSummary[]
}
| {
status: 'local' | 'unconfigured' | 'reconnect-required'
auth: OrcaProfileAuthStatus
}
| {
status: 'failed'
auth: OrcaProfileAuthStatus
error: string
}
// Why: organization roles are a fixed server-side enum; the desktop UI mirrors
// exactly these three so role selects can't drift from what the API accepts.
export type OrcaOrgRole = 'owner' | 'admin' | 'member'
export type OrcaOrgMember = {
// Why: null for teammates provisioned server-side who never signed into Orca;
// mutation actions are disabled for them since the API keys on a real userId.
userId: string | null
email: string
displayName?: string
role: OrcaOrgRole
}
export type OrcaOrgPendingInvite = {
email: string
role: OrcaOrgRole
createdAt: number
}
export type OrcaOrgMembersRoster = {
members: OrcaOrgMember[]
pendingInvites: OrcaOrgPendingInvite[]
viewerRole: OrcaOrgRole
canManageMembers: boolean
}
export type OrcaProfileOrgMembersListArgs = {
orgId: string
}
export type OrcaProfileOrgMemberInviteArgs = {
orgId: string
email: string
role: OrcaOrgRole
}
export type OrcaProfileOrgInviteRevokeArgs = {
orgId: string
email: string
}
export type OrcaProfileOrgMemberChangeRoleArgs = {
orgId: string
userId: string
role: OrcaOrgRole
}
export type OrcaProfileOrgMemberRemoveArgs = {
orgId: string
userId: string
}
export type OrcaProfileOrgMembersListResult =
| { status: 'ok'; roster: OrcaOrgMembersRoster }
| { status: 'unconfigured' | 'reconnect-required' }
| { status: 'failed'; error: string }
export type OrcaOrgInviteConflictReason = 'already_member' | 'already_invited'
export type OrcaOrgMutationInvalidReason = 'cannot_change_own_role' | 'cannot_remove_self'
export type OrcaProfileOrgMemberMutationResult =
| { status: 'ok' }
| { status: 'unconfigured' | 'reconnect-required' | 'forbidden' | 'not-found' }
| { status: 'conflict'; reason: OrcaOrgInviteConflictReason }
| { status: 'invalid'; reason: OrcaOrgMutationInvalidReason }
| { status: 'failed'; error: string }
export function createDefaultLocalOrcaProfile(now: number): OrcaProfileSummary {
return {
id: DEFAULT_LOCAL_ORCA_PROFILE_ID,
name: DEFAULT_LOCAL_ORCA_PROFILE_NAME,
avatar: { kind: 'initials', initials: 'P', color: 'neutral' },
kind: 'local',
createdAt: now,
updatedAt: now,
lastOpenedAt: now
}
}
function profilePartitionHash(value: string): string {
let hash = 2166136261
for (let i = 0; i < value.length; i++) {
hash ^= value.charCodeAt(i)
hash = Math.imul(hash, 16777619)
}
return (hash >>> 0).toString(16).padStart(8, '0')
}
export function getOrcaProfileBrowserPartitionSegment(profileId: string): string {
const safe = profileId.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 48) || 'profile'
return `${safe}-${profilePartitionHash(profileId)}`
}
export function getOrcaProfileBrowserDefaultPartition(profileId: string): string {
if (profileId === DEFAULT_LOCAL_ORCA_PROFILE_ID) {
return ORCA_BROWSER_PARTITION
}
return `persist:orca-profile-${getOrcaProfileBrowserPartitionSegment(profileId)}-browser-default`
}
export function getOrcaProfileBrowserSessionPartition(
profileId: string,
browserSessionProfileId: string
): string {
if (profileId === DEFAULT_LOCAL_ORCA_PROFILE_ID) {
return `${LEGACY_ORCA_BROWSER_SESSION_PARTITION_PREFIX}${browserSessionProfileId}`
}
return `persist:orca-profile-${getOrcaProfileBrowserPartitionSegment(
profileId
)}-browser-session-${browserSessionProfileId}`
}
+74
View File
@@ -0,0 +1,74 @@
import { test, expect } from './helpers/orca-app'
// Why: the multi-profile switcher UI is downscoped behind ORCA_MULTI_PROFILE_UI;
// these specs exercise that full UI, so opt the whole file into the flag.
test.use({ launchEnv: { ORCA_MULTI_PROFILE_UI: '1' } })
test('opens the profile switcher and profile dialogs', async ({ orcaPage }) => {
const switcher = orcaPage.getByRole('button', { name: /^Switch profile$/ })
await expect(switcher).toBeVisible()
await switcher.click()
await expect(orcaPage.getByText('Personal', { exact: true }).first()).toBeVisible()
const manageProfiles = orcaPage.getByRole('menuitem', { name: /Manage profiles/i })
await expect(manageProfiles).toBeVisible()
await expect(orcaPage.getByRole('menuitem', { name: /New local profile/i })).toBeVisible()
await manageProfiles.click()
const managementDialog = orcaPage.getByRole('dialog', { name: /Manage profiles/i })
await expect(managementDialog).toBeVisible()
await expect(managementDialog.getByText(/projects/i).first()).toBeVisible()
await orcaPage.keyboard.press('Escape')
await expect(managementDialog).toBeHidden()
await switcher.click()
await orcaPage.getByRole('menuitem', { name: /New local profile/i }).click()
const createDialog = orcaPage.getByRole('dialog', { name: /New local profile/i })
await expect(createDialog).toBeVisible()
await expect(createDialog.getByPlaceholder(/Profile name/i)).toBeVisible()
await expect(createDialog.getByRole('button', { name: /Create and Switch/i })).toBeVisible()
})
test('places the profile switcher in sidebar footer and full-page titlebar', async ({
orcaPage
}) => {
const switcher = orcaPage.getByRole('button', { name: /^Switch profile$/ })
const settingsButton = orcaPage.getByRole('button', { name: /^Settings$/ })
await expect(switcher).toBeVisible()
await expect(settingsButton).toBeVisible()
const sidebarSwitchBox = await switcher.boundingBox()
const settingsBox = await settingsButton.boundingBox()
const viewport = await orcaPage.evaluate(() => ({
width: window.innerWidth,
height: window.innerHeight
}))
expect(sidebarSwitchBox).not.toBeNull()
expect(settingsBox).not.toBeNull()
expect(sidebarSwitchBox!.x + sidebarSwitchBox!.width).toBeLessThanOrEqual(settingsBox!.x + 2)
expect(Math.abs(sidebarSwitchBox!.y - settingsBox!.y)).toBeLessThanOrEqual(2)
expect(sidebarSwitchBox!.y).toBeGreaterThan(viewport.height - 64)
await settingsButton.click()
await expect
.poll(() => orcaPage.evaluate(() => window.__store?.getState().activeView))
.toBe('settings')
const titlebarSwitchBox = await switcher.boundingBox()
expect(titlebarSwitchBox).not.toBeNull()
expect(titlebarSwitchBox!.x).toBeGreaterThan(viewport.width - 260)
expect(titlebarSwitchBox!.y).toBeLessThan(48)
})
test.describe('default single-profile mode', () => {
// Why: no flag — the default build shows no account trigger on a local-only
// (cloud-unconfigured) install.
test.use({ launchEnv: {} })
test('hides the account trigger when cloud is unconfigured', async ({ orcaPage }) => {
await expect(orcaPage.getByRole('button', { name: /^Switch profile$/ })).toHaveCount(0)
await expect(orcaPage.getByRole('button', { name: /^Account$/ })).toHaveCount(0)
})
})