mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
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:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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))
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+162
@@ -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}>
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({})
|
||||
},
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user