mirror of
https://github.com/langgenius/dify.git
synced 2026-06-09 09:57:32 +08:00
Compare commits
1 Commits
deploy/dev
...
fix/wta-47
| Author | SHA1 | Date | |
|---|---|---|---|
| 446f2a3982 |
@ -1,5 +1,5 @@
|
||||
import type { AccountContext } from './hosts'
|
||||
import type { Key, Store } from '@/store/store'
|
||||
import type { TokenStore } from '@/store/token-store'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@ -53,6 +53,20 @@ describe('RegistrySchema', () => {
|
||||
})
|
||||
expect(ctx.external_subject?.issuer).toBe('https://issuer')
|
||||
})
|
||||
|
||||
it('strips a stale available_workspaces field from legacy contexts', () => {
|
||||
const raw = {
|
||||
account: { id: 'acct-1', email: 'bob@corp.com', name: 'Bob' },
|
||||
workspace: { id: 'ws-1', name: 'Space', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Space', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
} as unknown as Record<string, unknown>
|
||||
const ctx = AccountContextSchema.parse(raw)
|
||||
expect((ctx as Record<string, unknown>).available_workspaces).toBeUndefined()
|
||||
expect(ctx.workspace?.id).toBe('ws-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('notLoggedInError', () => {
|
||||
@ -158,11 +172,12 @@ describe('Registry.load / Registry.save', () => {
|
||||
})
|
||||
})
|
||||
|
||||
class MemStore implements Store {
|
||||
readonly entries = new Map<string, unknown>()
|
||||
get<T>(key: Key<T>): T { return (this.entries.get(key.key) as T | undefined) ?? key.default }
|
||||
set<T>(key: Key<T>, value: T): void { this.entries.set(key.key, value) }
|
||||
unset<T>(key: Key<T>): void { this.entries.delete(key.key) }
|
||||
class MemStore implements TokenStore {
|
||||
readonly entries = new Map<string, string>()
|
||||
private k(host: string, email: string): string { return `${host} ${email}` }
|
||||
read(host: string, email: string): string { return this.entries.get(this.k(host, email)) ?? '' }
|
||||
write(host: string, email: string, bearer: string): void { this.entries.set(this.k(host, email), bearer) }
|
||||
remove(host: string, email: string): void { this.entries.delete(this.k(host, email)) }
|
||||
}
|
||||
|
||||
describe('Registry.forget', () => {
|
||||
@ -188,12 +203,12 @@ describe('Registry.forget', () => {
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
reg.save()
|
||||
store.set({ key: 'tokens.h1.a@x', default: '' }, 'dfoa_a')
|
||||
store.write('h1', 'a@x', 'dfoa_a')
|
||||
|
||||
const active = reg.resolveActive()!
|
||||
reg.forget(active, store)
|
||||
|
||||
expect(store.get({ key: 'tokens.h1.a@x', default: '' })).toBe('')
|
||||
expect(store.read('h1', 'a@x')).toBe('')
|
||||
const after = Registry.load()
|
||||
expect(after?.hosts.h1?.accounts['a@x']).toBeUndefined()
|
||||
expect(after?.hosts.h1?.accounts['b@x']).toBeDefined()
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import type { Store } from '@/store/store'
|
||||
import type { TokenStore } from '@/store/token-store'
|
||||
import { z } from 'zod'
|
||||
import { BaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { getHostStore, tokenKey } from '@/store/manager'
|
||||
import { getHostStore } from '@/store/manager'
|
||||
|
||||
const StorageModeSchema = z.enum(['keychain', 'file'])
|
||||
export type StorageMode = z.infer<typeof StorageModeSchema>
|
||||
@ -30,7 +30,6 @@ export type ExternalSubject = z.infer<typeof ExternalSubjectSchema>
|
||||
export const AccountContextSchema = z.object({
|
||||
account: AccountSchema,
|
||||
workspace: WorkspaceSchema.optional(),
|
||||
available_workspaces: z.array(WorkspaceSchema).optional(),
|
||||
token_id: z.string().optional(),
|
||||
token_expires_at: z.string().optional(),
|
||||
external_subject: ExternalSubjectSchema.optional(),
|
||||
@ -163,9 +162,9 @@ export class Registry {
|
||||
|
||||
// Teardown for "this credential is gone": drop the token, drop the context
|
||||
// (unsets pointers when active), persist. Logout + self-revoke share it.
|
||||
forget(active: ActiveContext, store: Store): void {
|
||||
forget(active: ActiveContext, store: TokenStore): void {
|
||||
try {
|
||||
store.unset(tokenKey(active.host, active.email))
|
||||
store.remove(active.host, active.email)
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
this.remove(active.host, active.email)
|
||||
|
||||
@ -2,7 +2,7 @@ import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { AppInfoCache } from '@/cache/app-info'
|
||||
import type { Command } from '@/framework/command'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import type { Store } from '@/store/store'
|
||||
import type { TokenStore } from '@/store/token-store'
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
import { META_PROBE_TIMEOUT_MS, MetaClient } from '@/api/meta'
|
||||
import { notLoggedInError, Registry } from '@/auth/hosts'
|
||||
@ -11,7 +11,7 @@ import { loadNudgeStore } from '@/cache/nudge-store'
|
||||
import { getEnv } from '@/env/registry'
|
||||
import { formatErrorForCli } from '@/errors/format'
|
||||
import { createHttpClient } from '@/http/client'
|
||||
import { getTokenStore, tokenKey } from '@/store/manager'
|
||||
import { getTokenStore } from '@/store/manager'
|
||||
import { realStreams } from '@/sys/io/streams'
|
||||
import { hostWithScheme, openAPIBase } from '@/util/host'
|
||||
import { versionInfo } from '@/version/info'
|
||||
@ -21,7 +21,7 @@ import { resolveRetryAttempts } from './global-flags.js'
|
||||
export type AuthedContext = {
|
||||
readonly reg: Registry
|
||||
readonly active: ActiveContext
|
||||
readonly store: Store
|
||||
readonly store: TokenStore
|
||||
readonly http: HttpClient
|
||||
readonly host: string
|
||||
readonly io: IOStreams
|
||||
@ -45,7 +45,7 @@ export async function buildAuthedContext(
|
||||
fail(cmd, opts, io)
|
||||
|
||||
const { store } = getTokenStore()
|
||||
const bearer = store.get(tokenKey(active.host, active.email))
|
||||
const bearer = store.read(active.host, active.email)
|
||||
if (bearer === '')
|
||||
fail(cmd, opts, io)
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import type { SessionListResponse, SessionRow } from '@dify/contracts/api/openap
|
||||
import type { DifyMock } from '@test/fixtures/dify-mock/server'
|
||||
import type { AccountSessionsClient } from '@/api/account-sessions'
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { Key, Store } from '@/store/store'
|
||||
import type { TokenStore } from '@/store/token-store'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@ -11,22 +11,25 @@ import { testHttpClient } from '@test/fixtures/http-client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Registry } from '@/auth/hosts'
|
||||
import { ENV_CONFIG_DIR } from '@/store/dir'
|
||||
import { tokenKey } from '@/store/manager'
|
||||
import { bufferStreams } from '@/sys/io/streams'
|
||||
import { listAllSessions, runDevicesList, runDevicesRevoke } from './devices.js'
|
||||
|
||||
class MemStore implements Store {
|
||||
readonly entries = new Map<string, unknown>()
|
||||
get<T>(key: Key<T>): T {
|
||||
return (this.entries.get(key.key) as T | undefined) ?? key.default
|
||||
class MemStore implements TokenStore {
|
||||
readonly entries = new Map<string, string>()
|
||||
private k(host: string, email: string): string {
|
||||
return `${host} ${email}`
|
||||
}
|
||||
|
||||
set<T>(key: Key<T>, value: T): void {
|
||||
this.entries.set(key.key, value)
|
||||
read(host: string, email: string): string {
|
||||
return this.entries.get(this.k(host, email)) ?? ''
|
||||
}
|
||||
|
||||
unset<T>(key: Key<T>): void {
|
||||
this.entries.delete(key.key)
|
||||
write(host: string, email: string, bearer: string): void {
|
||||
this.entries.set(this.k(host, email), bearer)
|
||||
}
|
||||
|
||||
remove(host: string, email: string): void {
|
||||
this.entries.delete(this.k(host, email))
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,10 +38,6 @@ function buildRegistry(host: string, email: string, tokenId: string): { reg: Reg
|
||||
reg.upsert(host, email, {
|
||||
account: { id: 'acct-1', email, name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
token_id: tokenId,
|
||||
})
|
||||
reg.setHost(host)
|
||||
@ -103,7 +102,7 @@ describe('runDevicesRevoke', () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
store.set(tokenKey(mock.url, 'tester@dify.ai'), 'dfoa_test')
|
||||
store.write(mock.url, 'tester@dify.ai', 'dfoa_test')
|
||||
reg.save()
|
||||
const http = testHttpClient(mock.url, 'dfoa_test')
|
||||
|
||||
@ -168,7 +167,7 @@ describe('runDevicesRevoke', () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
store.set(tokenKey(mock.url, 'tester@dify.ai'), 'dfoa_test')
|
||||
store.write(mock.url, 'tester@dify.ai', 'dfoa_test')
|
||||
reg.save()
|
||||
const http = testHttpClient(mock.url, 'dfoa_test')
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { SessionRow } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { ActiveContext, Registry } from '@/auth/hosts'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import type { Store } from '@/store/store'
|
||||
import type { TokenStore } from '@/store/token-store'
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
import { AccountSessionsClient } from '@/api/account-sessions'
|
||||
import { BaseError } from '@/errors/base'
|
||||
@ -71,7 +71,7 @@ export type DevicesRevokeOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly reg: Registry
|
||||
readonly active: ActiveContext
|
||||
readonly store: Store
|
||||
readonly store: TokenStore
|
||||
readonly http: HttpClient
|
||||
readonly target?: string
|
||||
readonly all: boolean
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { DifyMock } from '@test/fixtures/dify-mock/server'
|
||||
import type { Clock } from './device-flow.js'
|
||||
import type { Key, Store } from '@/store/store'
|
||||
import type { TokenStore } from '@/store/token-store'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@ -10,7 +10,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { DeviceFlowApi } from '@/api/oauth-device'
|
||||
import { createHttpClient } from '@/http/client'
|
||||
import { ENV_CONFIG_DIR } from '@/store/dir'
|
||||
import { tokenKey } from '@/store/manager'
|
||||
import { bufferStreams } from '@/sys/io/streams'
|
||||
import { openAPIBase } from '@/util/host'
|
||||
import { runLogin } from './login.js'
|
||||
@ -22,18 +21,22 @@ const noopClock: Clock = {
|
||||
|
||||
const noopBrowser = async (): Promise<void> => { /* skip OS open */ }
|
||||
|
||||
class MemStore implements Store {
|
||||
readonly entries = new Map<string, unknown>()
|
||||
get<T>(key: Key<T>): T {
|
||||
return (this.entries.get(key.key) as T | undefined) ?? key.default
|
||||
class MemStore implements TokenStore {
|
||||
readonly entries = new Map<string, string>()
|
||||
private k(host: string, email: string): string {
|
||||
return `${host} ${email}`
|
||||
}
|
||||
|
||||
set<T>(key: Key<T>, value: T): void {
|
||||
this.entries.set(key.key, value)
|
||||
read(host: string, email: string): string {
|
||||
return this.entries.get(this.k(host, email)) ?? ''
|
||||
}
|
||||
|
||||
unset<T>(key: Key<T>): void {
|
||||
this.entries.delete(key.key)
|
||||
write(host: string, email: string, bearer: string): void {
|
||||
this.entries.set(this.k(host, email), bearer)
|
||||
}
|
||||
|
||||
remove(host: string, email: string): void {
|
||||
this.entries.delete(this.k(host, email))
|
||||
}
|
||||
}
|
||||
|
||||
@ -75,8 +78,7 @@ describe('runLogin', () => {
|
||||
const active = reg.resolveActive()
|
||||
expect(active?.ctx.account.email).toBe('tester@dify.ai')
|
||||
expect(active?.ctx.workspace?.id).toBe('ws-1')
|
||||
expect(active?.ctx.available_workspaces).toHaveLength(2)
|
||||
expect(store.get(tokenKey(active!.host, 'tester@dify.ai'))).toBe('dfoa_test')
|
||||
expect(store.read(active!.host, 'tester@dify.ai')).toBe('dfoa_test')
|
||||
|
||||
const hostsRaw = await readFile(join(configDir, 'hosts.yml'), 'utf8')
|
||||
expect(hostsRaw).toContain('current_host:')
|
||||
@ -109,7 +111,7 @@ describe('runLogin', () => {
|
||||
expect(active?.ctx.external_subject?.email).toBe('sso@dify.ai')
|
||||
expect(active?.ctx.external_subject?.issuer).toBe('https://issuer.example')
|
||||
expect(active?.ctx.account.email).toBe('')
|
||||
expect(store.get(tokenKey(active!.host, 'sso@dify.ai'))).toBe('dfoe_test')
|
||||
expect(store.read(active!.host, 'sso@dify.ai')).toBe('dfoe_test')
|
||||
expect(io.outBuf()).toContain('external SSO')
|
||||
expect(io.outBuf()).toContain('sso@dify.ai')
|
||||
})
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import type { Clock } from './device-flow.js'
|
||||
import type { CodeResponse, PollSuccess } from '@/api/oauth-device'
|
||||
import type { AccountContext, Workspace } from '@/auth/hosts'
|
||||
import type { StorageMode, Store } from '@/store/store'
|
||||
import type { AccountContext } from '@/auth/hosts'
|
||||
import type { StorageMode } from '@/store/store'
|
||||
import type { TokenStore } from '@/store/token-store'
|
||||
import type { ParseResult } from '@/sys/io/prompt'
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
import type { BrowserEnv, BrowserOpener } from '@/util/browser'
|
||||
@ -11,7 +12,7 @@ import { Registry } from '@/auth/hosts'
|
||||
import { BaseError, isBaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { createHttpClient } from '@/http/client'
|
||||
import { getTokenStore, tokenKey } from '@/store/manager'
|
||||
import { getTokenStore } from '@/store/manager'
|
||||
import { colorEnabled, colorScheme } from '@/sys/io/color'
|
||||
import { promptText } from '@/sys/io/prompt'
|
||||
import { startSpinner } from '@/sys/io/spinner'
|
||||
@ -25,7 +26,7 @@ export type LoginOptions = {
|
||||
readonly noBrowser?: boolean
|
||||
readonly insecure?: boolean
|
||||
readonly deviceLabel?: string
|
||||
readonly store?: { readonly store: Store, readonly mode: StorageMode }
|
||||
readonly store?: { readonly store: TokenStore, readonly mode: StorageMode }
|
||||
readonly api?: DeviceFlowApi
|
||||
readonly browserEnv?: BrowserEnv
|
||||
readonly browserOpener?: BrowserOpener
|
||||
@ -74,7 +75,7 @@ export async function runLogin(opts: LoginOptions): Promise<Registry> {
|
||||
const email = accountEmail(success)
|
||||
const ctx = contextFromSuccess(success)
|
||||
|
||||
storeBundle.store.set(tokenKey(display, email), success.token)
|
||||
storeBundle.store.write(display, email, success.token)
|
||||
|
||||
const reg = Registry.load()
|
||||
reg.token_storage = storeBundle.mode
|
||||
@ -187,9 +188,6 @@ function contextFromSuccess(s: PollSuccess): AccountContext {
|
||||
const def = findDefaultWorkspace(s)
|
||||
if (def !== undefined)
|
||||
ctx.workspace = def
|
||||
if (s.workspaces !== undefined && s.workspaces.length > 0) {
|
||||
ctx.available_workspaces = s.workspaces.map<Workspace>(w => ({ id: w.id, name: w.name, role: w.role }))
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ import type { HttpClient } from '@/http/types'
|
||||
import { Registry } from '@/auth/hosts'
|
||||
import { DifyCommand } from '@/commands/_shared/dify-command'
|
||||
import { createHttpClient } from '@/http/client'
|
||||
import { getTokenStore, tokenKey } from '@/store/manager'
|
||||
import { getTokenStore } from '@/store/manager'
|
||||
import { runWithSpinner } from '@/sys/io/spinner'
|
||||
import { realStreams } from '@/sys/io/streams'
|
||||
import { hostWithScheme, openAPIBase } from '@/util/host'
|
||||
@ -26,7 +26,7 @@ export default class Logout extends DifyCommand {
|
||||
|
||||
let http: HttpClient | undefined
|
||||
if (active !== undefined) {
|
||||
const bearer = getTokenStore().store.get(tokenKey(active.host, active.email))
|
||||
const bearer = getTokenStore().store.read(active.host, active.email)
|
||||
if (bearer !== '') {
|
||||
http = createHttpClient({ baseURL: openAPIBase(hostWithScheme(active.host, active.scheme)), bearer, retryAttempts: 0 })
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { Key, Store } from '@/store/store'
|
||||
import type { TokenStore } from '@/store/token-store'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@ -8,11 +8,12 @@ import { ENV_CONFIG_DIR } from '@/store/dir'
|
||||
import { bufferStreams } from '@/sys/io/streams'
|
||||
import { runLogout } from './logout.js'
|
||||
|
||||
class MemStore implements Store {
|
||||
readonly entries = new Map<string, unknown>()
|
||||
get<T>(key: Key<T>): T { return (this.entries.get(key.key) as T | undefined) ?? key.default }
|
||||
set<T>(key: Key<T>, value: T): void { this.entries.set(key.key, value) }
|
||||
unset<T>(key: Key<T>): void { this.entries.delete(key.key) }
|
||||
class MemStore implements TokenStore {
|
||||
readonly entries = new Map<string, string>()
|
||||
private k(host: string, email: string): string { return `${host} ${email}` }
|
||||
read(host: string, email: string): string { return this.entries.get(this.k(host, email)) ?? '' }
|
||||
write(host: string, email: string, bearer: string): void { this.entries.set(this.k(host, email), bearer) }
|
||||
remove(host: string, email: string): void { this.entries.delete(this.k(host, email)) }
|
||||
}
|
||||
|
||||
describe('runLogout', () => {
|
||||
@ -37,8 +38,8 @@ describe('runLogout', () => {
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
reg.save()
|
||||
store.set({ key: 'tokens.h1.a@x', default: '' }, 'dfoa_a')
|
||||
store.set({ key: 'tokens.h1.b@x', default: '' }, 'dfoa_b')
|
||||
store.write('h1', 'a@x', 'dfoa_a')
|
||||
store.write('h1', 'b@x', 'dfoa_b')
|
||||
}
|
||||
|
||||
it('removes only the active context, keeps others, unsets pointers, file survives', async () => {
|
||||
@ -49,8 +50,8 @@ describe('runLogout', () => {
|
||||
expect(after?.hosts.h1?.accounts['a@x']).toBeUndefined()
|
||||
expect(after?.hosts.h1?.accounts['b@x']).toBeDefined()
|
||||
expect(after?.current_host).toBeUndefined()
|
||||
expect(store.get({ key: 'tokens.h1.a@x', default: '' })).toBe('')
|
||||
expect(store.get({ key: 'tokens.h1.b@x', default: '' })).toBe('dfoa_b')
|
||||
expect(store.read('h1', 'a@x')).toBe('')
|
||||
expect(store.read('h1', 'b@x')).toBe('dfoa_b')
|
||||
const raw = await readFile(join(dir, 'hosts.yml'), 'utf8')
|
||||
expect(raw).toContain('b@x')
|
||||
})
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import type { Registry } from '@/auth/hosts'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import type { Store } from '@/store/store'
|
||||
import type { TokenStore } from '@/store/token-store'
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
import { AccountSessionsClient } from '@/api/account-sessions'
|
||||
import { getTokenStore, tokenKey } from '@/store/manager'
|
||||
import { getTokenStore } from '@/store/manager'
|
||||
import { colorEnabled, colorScheme } from '@/sys/io/color'
|
||||
|
||||
export type LogoutOptions = {
|
||||
@ -11,7 +11,7 @@ export type LogoutOptions = {
|
||||
readonly reg: Registry
|
||||
readonly http?: HttpClient
|
||||
/** Optional override for tests; production resolves via `getTokenStore`. */
|
||||
readonly store?: Store
|
||||
readonly store?: TokenStore
|
||||
}
|
||||
|
||||
const REVOCABLE_PREFIXES = ['dfoa_', 'dfoe_'] as const
|
||||
@ -22,7 +22,7 @@ export async function runLogout(opts: LogoutOptions): Promise<void> {
|
||||
const active = reg.requireActive()
|
||||
|
||||
const store = opts.store ?? getTokenStore().store
|
||||
const bearer = store.get(tokenKey(active.host, active.email))
|
||||
const bearer = store.read(active.host, active.email)
|
||||
|
||||
let revokeWarning = ''
|
||||
if (bearer !== '' && revokeAllowed(bearer) && opts.http !== undefined) {
|
||||
|
||||
@ -11,7 +11,6 @@ function active(): ActiveContext {
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'inviter@example.com', name: 'Inviter' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,7 +11,6 @@ function active(): ActiveContext {
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,10 +19,6 @@ function active(): ActiveContext {
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 't@d.ai', name: 'T' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,10 +13,6 @@ const baseActive: ActiveContext = {
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
},
|
||||
scheme: 'http',
|
||||
}
|
||||
|
||||
@ -114,14 +114,7 @@ function describeToEnvelope(desc: AppDescribeResponse, wsId: string, wsName: str
|
||||
function workspaceNameForId(active: ActiveContext, id: string): string {
|
||||
if (id === '')
|
||||
return ''
|
||||
const ctx = active.ctx
|
||||
if (ctx.workspace?.id === id)
|
||||
return ctx.workspace.name
|
||||
for (const w of ctx.available_workspaces ?? []) {
|
||||
if (w.id === id)
|
||||
return w.name
|
||||
}
|
||||
return ''
|
||||
return active.ctx.workspace?.id === id ? active.ctx.workspace.name : ''
|
||||
}
|
||||
|
||||
async function runAllWorkspaces(
|
||||
|
||||
@ -12,7 +12,6 @@ function active(): ActiveContext {
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,10 +13,6 @@ const baseActive: ActiveContext = {
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
},
|
||||
scheme: 'http',
|
||||
}
|
||||
|
||||
@ -20,10 +20,6 @@ function active(): ActiveContext {
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 't@d.ai', name: 'T' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,7 +11,6 @@ function active(): ActiveContext {
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { Key, Store } from '@/store/store'
|
||||
import type { TokenStore } from '@/store/token-store'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@ -8,12 +8,13 @@ import { ENV_CONFIG_DIR } from '@/store/dir'
|
||||
import { bufferStreams } from '@/sys/io/streams'
|
||||
import { runUseAccount } from './use-account'
|
||||
|
||||
function memStore(seed: Record<string, string>): Store {
|
||||
const m = new Map<string, unknown>(Object.entries(seed))
|
||||
function memStore(seed: Record<string, string>): TokenStore {
|
||||
const k = (host: string, email: string): string => `${host} ${email}`
|
||||
const m = new Map<string, string>(Object.entries(seed))
|
||||
return {
|
||||
get<T>(k: Key<T>): T { return (m.get(k.key) as T | undefined) ?? k.default },
|
||||
set<T>(k: Key<T>, v: T): void { m.set(k.key, v) },
|
||||
unset<T>(k: Key<T>): void { m.delete(k.key) },
|
||||
read(host: string, email: string): string { return m.get(k(host, email)) ?? '' },
|
||||
write(host: string, email: string, bearer: string): void { m.set(k(host, email), bearer) },
|
||||
remove(host: string, email: string): void { m.delete(k(host, email)) },
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,7 +40,7 @@ describe('runUseAccount', () => {
|
||||
})
|
||||
|
||||
it('switches current_account when email valid + token present', async () => {
|
||||
await runUseAccount({ io: bufferStreams(), email: 'b@x', store: memStore({ 'tokens.h1.b@x': 'dfoa_b' }) })
|
||||
await runUseAccount({ io: bufferStreams(), email: 'b@x', store: memStore({ 'h1 b@x': 'dfoa_b' }) })
|
||||
expect(Registry.load().hosts.h1?.current_account).toBe('b@x')
|
||||
})
|
||||
|
||||
@ -50,7 +51,7 @@ describe('runUseAccount', () => {
|
||||
})
|
||||
|
||||
it('errors when the email is unknown on the current host', async () => {
|
||||
await expect(runUseAccount({ io: bufferStreams(), email: 'z@x', store: memStore({ 'tokens.h1.z@x': 'x' }) }))
|
||||
await expect(runUseAccount({ io: bufferStreams(), email: 'z@x', store: memStore({ 'h1 z@x': 'x' }) }))
|
||||
.rejects
|
||||
.toThrow(/unknown account|no account/i)
|
||||
})
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import type { HostEntry } from '@/auth/hosts'
|
||||
import type { Store } from '@/store/store'
|
||||
import type { TokenStore } from '@/store/token-store'
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
import { notLoggedInError, Registry } from '@/auth/hosts'
|
||||
import { BaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { getTokenStore, tokenKey } from '@/store/manager'
|
||||
import { getTokenStore } from '@/store/manager'
|
||||
import { colorEnabled, colorScheme } from '@/sys/io/color'
|
||||
import { selectFromList } from '@/sys/io/select'
|
||||
|
||||
@ -12,7 +12,7 @@ export type UseAccountOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly email: string | undefined
|
||||
/** Optional override for tests; production resolves via `getTokenStore`. */
|
||||
readonly store?: Store
|
||||
readonly store?: TokenStore
|
||||
}
|
||||
|
||||
type AccountChoice = { email: string, name: string, sso: boolean, active: boolean }
|
||||
@ -39,7 +39,7 @@ export async function runUseAccount(opts: UseAccountOptions): Promise<void> {
|
||||
}
|
||||
|
||||
const store = opts.store ?? getTokenStore().store
|
||||
if (store.get(tokenKey(host, target)) === '') {
|
||||
if (store.read(host, target) === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.NotLoggedIn,
|
||||
message: `no credential stored for ${target} on ${host}`,
|
||||
|
||||
@ -5,16 +5,17 @@ import { Args } from '@/framework/flags'
|
||||
import { runUseWorkspace } from './use'
|
||||
|
||||
export default class UseWorkspace extends DifyCommand {
|
||||
static override description = 'Switch the active workspace on the server and refresh hosts.yml'
|
||||
static override description = 'Switch the active workspace on the server (omit the id to pick interactively)'
|
||||
|
||||
static override effect: CommandEffect = 'write'
|
||||
|
||||
static override examples = [
|
||||
'<%= config.bin %> use workspace ws-abc123',
|
||||
'<%= config.bin %> use workspace',
|
||||
]
|
||||
|
||||
static override args = {
|
||||
workspaceId: Args.string({ description: 'workspace id to switch to', required: true }),
|
||||
workspaceId: Args.string({ description: 'workspace id to switch to (omit to pick interactively)', required: false }),
|
||||
}
|
||||
|
||||
static override flags = {
|
||||
|
||||
@ -10,18 +10,21 @@ import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Registry } from '@/auth/hosts'
|
||||
import { ENV_CONFIG_DIR } from '@/store/dir'
|
||||
import { selectFromList } from '@/sys/io/select'
|
||||
import { bufferStreams } from '@/sys/io/streams'
|
||||
import { runUseWorkspace } from './use.js'
|
||||
|
||||
vi.mock('@/sys/io/select', () => ({
|
||||
selectFromList: vi.fn(),
|
||||
}))
|
||||
|
||||
const selectFromListMock = vi.mocked(selectFromList)
|
||||
|
||||
function makeRegistry(): Registry {
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert('cloud.dify.ai', 'tester@dify.ai', {
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Stale Name', role: 'normal' },
|
||||
],
|
||||
})
|
||||
reg.setHost('cloud.dify.ai')
|
||||
reg.setAccount('tester@dify.ai')
|
||||
@ -35,23 +38,28 @@ function makeActive(reg: Registry): ActiveContext {
|
||||
return active
|
||||
}
|
||||
|
||||
function makeDetail(over: Partial<WorkspaceDetailResponse> = {}): WorkspaceDetailResponse {
|
||||
return {
|
||||
id: 'ws-2',
|
||||
name: 'Two',
|
||||
role: 'owner',
|
||||
status: 'normal',
|
||||
current: true,
|
||||
created_at: '2026-05-18T00:00:00Z',
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function fakeClient(opts: {
|
||||
switch?: () => Promise<WorkspaceDetailResponse>
|
||||
list?: () => Promise<WorkspaceListResponse>
|
||||
}) {
|
||||
return {
|
||||
switch: vi.fn(opts.switch ?? (() => Promise.resolve({
|
||||
id: 'ws-2',
|
||||
name: 'Switched',
|
||||
role: 'normal',
|
||||
status: 'normal',
|
||||
current: true,
|
||||
created_at: '2026-05-18T00:00:00Z',
|
||||
}))),
|
||||
switch: vi.fn(opts.switch ?? (() => Promise.resolve(makeDetail()))),
|
||||
list: vi.fn(opts.list ?? (() => Promise.resolve({
|
||||
workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: false },
|
||||
{ id: 'ws-2', name: 'Switched', role: 'normal', status: 'normal', current: true },
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: true },
|
||||
{ id: 'ws-2', name: 'Two', role: 'owner', status: 'normal', current: false },
|
||||
],
|
||||
}))),
|
||||
}
|
||||
@ -59,12 +67,13 @@ function fakeClient(opts: {
|
||||
|
||||
describe('runUseWorkspace', () => {
|
||||
let configDir: string
|
||||
|
||||
let prevConfigDir: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
configDir = await mkdtemp(join(tmpdir(), 'difyctl-use-workspace-'))
|
||||
prevConfigDir = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = configDir
|
||||
selectFromListMock.mockReset()
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prevConfigDir === undefined)
|
||||
@ -74,7 +83,7 @@ describe('runUseWorkspace', () => {
|
||||
await rm(configDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('happy path: POST /switch → GET /workspaces → write hosts.yml', async () => {
|
||||
it('arg path: switches directly without listing and persists only the active workspace', async () => {
|
||||
const io = bufferStreams()
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
@ -93,61 +102,41 @@ describe('runUseWorkspace', () => {
|
||||
)
|
||||
|
||||
expect(client.switch).toHaveBeenCalledExactlyOnceWith('ws-2')
|
||||
expect(client.list).toHaveBeenCalledOnce()
|
||||
expect(client.list).not.toHaveBeenCalled()
|
||||
|
||||
const activeCtx = next.resolveActive()
|
||||
expect(activeCtx?.ctx.workspace).toEqual({ id: 'ws-2', name: 'Switched', role: 'normal' })
|
||||
expect(activeCtx?.ctx.available_workspaces).toEqual([
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Switched', role: 'normal' },
|
||||
])
|
||||
expect(activeCtx?.ctx.workspace).toEqual({ id: 'ws-2', name: 'Two', role: 'owner' })
|
||||
expect((activeCtx?.ctx as Record<string, unknown> | undefined)?.available_workspaces).toBeUndefined()
|
||||
|
||||
const reloaded = Registry.load()
|
||||
const reloadedActive = reloaded?.resolveActive()
|
||||
expect(reloadedActive?.ctx.workspace?.id).toBe('ws-2')
|
||||
expect(reloadedActive?.ctx.workspace?.name).toBe('Switched')
|
||||
expect(reloadedActive?.ctx.workspace?.name).toBe('Two')
|
||||
expect((reloadedActive?.ctx as Record<string, unknown> | undefined)?.available_workspaces).toBeUndefined()
|
||||
|
||||
expect(io.outBuf()).toMatch(/Switched to Switched \(ws-2\)/)
|
||||
expect(io.outBuf()).toMatch(/Switched to Two \(ws-2\)/)
|
||||
})
|
||||
|
||||
it('hosts.yml contains no bearer after switch', async () => {
|
||||
it('no-arg + no-TTY: rejects with usage_missing_arg and never switches', async () => {
|
||||
const io = bufferStreams()
|
||||
io.isErrTTY = false
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
const client = fakeClient({})
|
||||
|
||||
await runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{ reg, active, http: {} as HttpClient, io, workspacesFactory: () => client as never },
|
||||
)
|
||||
await expect(
|
||||
runUseWorkspace(
|
||||
{ workspaceId: undefined },
|
||||
{ reg, active, http: {} as HttpClient, io, workspacesFactory: () => client as never },
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'usage_missing_arg' })
|
||||
|
||||
const reloaded = Registry.load()
|
||||
const raw = JSON.stringify(reloaded)
|
||||
expect(raw).not.toMatch(/bearer/)
|
||||
expect(client.switch).not.toHaveBeenCalled()
|
||||
expect(client.list).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes stale workspace name from server', async () => {
|
||||
// registry has ws-2 named "Stale Name"; server returns "Switched".
|
||||
// We expect saveRegistry to record the fresh name from the server.
|
||||
const io = bufferStreams()
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
const client = fakeClient({})
|
||||
|
||||
await runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{ reg, active, http: {} as HttpClient, io, workspacesFactory: () => client as never },
|
||||
)
|
||||
|
||||
const reloaded = Registry.load()
|
||||
const reloadedActive = reloaded?.resolveActive()
|
||||
expect(reloadedActive?.ctx.workspace?.name).toBe('Switched')
|
||||
expect(reloadedActive?.ctx.available_workspaces?.find(w => w.id === 'ws-2')?.name).toBe('Switched')
|
||||
})
|
||||
|
||||
it('does NOT mutate hosts.yml when POST /switch fails', async () => {
|
||||
it('switch failure: rejects and leaves the active workspace untouched', async () => {
|
||||
const io = bufferStreams()
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
@ -161,84 +150,40 @@ describe('runUseWorkspace', () => {
|
||||
await expect(
|
||||
runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{
|
||||
reg,
|
||||
active,
|
||||
http: {} as HttpClient,
|
||||
io,
|
||||
workspacesFactory: () => client as never,
|
||||
},
|
||||
{ reg, active, http: {} as HttpClient, io, workspacesFactory: () => client as never },
|
||||
),
|
||||
).rejects.toThrow(/forbidden/)
|
||||
|
||||
expect(client.list).not.toHaveBeenCalled()
|
||||
const after = Registry.load()
|
||||
expect(after).toEqual(before)
|
||||
const afterActive = after?.resolveActive()
|
||||
expect(afterActive?.ctx.workspace?.id).toBe('ws-1')
|
||||
expect(after?.resolveActive()?.ctx.workspace?.id).toBe('ws-1')
|
||||
})
|
||||
|
||||
it('does NOT mutate hosts.yml when GET /workspaces fails after switch', async () => {
|
||||
it('picker path (TTY): lists live workspaces and switches to the selected one', async () => {
|
||||
const io = bufferStreams()
|
||||
io.isErrTTY = true
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
const before = Registry.load()
|
||||
const client = fakeClient({})
|
||||
|
||||
const client = fakeClient({
|
||||
list: () => Promise.reject(new Error('transient list failure')),
|
||||
})
|
||||
selectFromListMock.mockResolvedValue({ id: 'ws-2', name: 'Two', role: 'owner' })
|
||||
|
||||
await expect(
|
||||
runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{
|
||||
reg,
|
||||
active,
|
||||
http: {} as HttpClient,
|
||||
io,
|
||||
workspacesFactory: () => client as never,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/transient list failure/)
|
||||
await runUseWorkspace(
|
||||
{ workspaceId: undefined },
|
||||
{ reg, active, http: {} as HttpClient, io, workspacesFactory: () => client as never },
|
||||
)
|
||||
|
||||
const after = Registry.load()
|
||||
expect(after).toEqual(before)
|
||||
})
|
||||
expect(client.list).toHaveBeenCalledOnce()
|
||||
expect(selectFromListMock).toHaveBeenCalledOnce()
|
||||
const passed = selectFromListMock.mock.calls[0]![0]
|
||||
expect(passed.items).toEqual([
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Two', role: 'owner' },
|
||||
])
|
||||
expect(client.switch).toHaveBeenCalledExactlyOnceWith('ws-2')
|
||||
|
||||
it('throws when server returns switch=<id> but id is missing from /workspaces list', async () => {
|
||||
const io = bufferStreams()
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
|
||||
const client = fakeClient({
|
||||
switch: () => Promise.resolve({
|
||||
id: 'ws-7',
|
||||
name: 'Ghost',
|
||||
role: 'normal',
|
||||
status: 'normal',
|
||||
current: true,
|
||||
created_at: null as unknown as string,
|
||||
}),
|
||||
list: () => Promise.resolve({
|
||||
workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: false },
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
await expect(
|
||||
runUseWorkspace(
|
||||
{ workspaceId: 'ws-7' },
|
||||
{
|
||||
reg,
|
||||
active,
|
||||
http: {} as HttpClient,
|
||||
io,
|
||||
workspacesFactory: () => client as never,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/not visible in \/workspaces/)
|
||||
const reloadedActive = Registry.load()?.resolveActive()
|
||||
expect(reloadedActive?.ctx.workspace?.id).toBe('ws-2')
|
||||
})
|
||||
})
|
||||
|
||||
@ -5,10 +5,11 @@ import { WorkspacesClient } from '@/api/workspaces'
|
||||
import { BaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { colorEnabled, colorScheme } from '@/sys/io/color'
|
||||
import { selectFromList } from '@/sys/io/select'
|
||||
import { runWithSpinner } from '@/sys/io/spinner'
|
||||
|
||||
export type UseWorkspaceOptions = {
|
||||
readonly workspaceId: string
|
||||
readonly workspaceId?: string
|
||||
}
|
||||
|
||||
export type UseWorkspaceDeps = {
|
||||
@ -22,16 +23,13 @@ export type UseWorkspaceDeps = {
|
||||
/**
|
||||
* Switch the caller's active workspace.
|
||||
*
|
||||
* Strict ordering:
|
||||
* 1. POST /workspaces/<id>/switch — if this fails (403/404/etc.) we abort
|
||||
* with no `hosts.yml` mutation, so local state never diverges from the
|
||||
* server. Any fallback to a pure-local update is explicitly disallowed
|
||||
* (see workspace-plan.md decision D4).
|
||||
* 2. GET /workspaces — refresh the membership list so `available_workspaces`
|
||||
* stays in sync. Failure here also aborts; the server-side current has
|
||||
* already moved, but the local file is left untouched. A follow-up
|
||||
* `difyctl get workspace` will reconcile.
|
||||
* 3. Persist `workspace` + `available_workspaces` atomically via `saveRegistry`.
|
||||
* With an explicit id we switch directly; with no id we fetch the live
|
||||
* workspace list and let the caller pick one interactively (TTY only).
|
||||
*
|
||||
* The server-side switch is the source of truth: if POST
|
||||
* `/workspaces/<id>/switch` fails we abort before touching `hosts.yml`, so
|
||||
* local state never diverges from the server. Only the active `workspace`
|
||||
* (from the switch response) is persisted — never `available_workspaces`.
|
||||
*/
|
||||
export async function runUseWorkspace(
|
||||
opts: UseWorkspaceOptions,
|
||||
@ -41,32 +39,50 @@ export async function runUseWorkspace(
|
||||
const factory = deps.workspacesFactory ?? ((h: HttpClient) => new WorkspacesClient(h))
|
||||
const client = factory(deps.http)
|
||||
|
||||
const id = opts.workspaceId ?? await pickWorkspaceId(client, deps)
|
||||
|
||||
const detail = await runWithSpinner(
|
||||
{ io: deps.io, label: `Switching to ${opts.workspaceId}` },
|
||||
() => client.switch(opts.workspaceId),
|
||||
{ io: deps.io, label: `Switching to ${id}` },
|
||||
() => client.switch(id),
|
||||
)
|
||||
|
||||
const list = await runWithSpinner(
|
||||
{ io: deps.io, label: 'Refreshing workspaces' },
|
||||
() => client.list(),
|
||||
)
|
||||
|
||||
const matched = list.workspaces.find(w => w.id === detail.id)
|
||||
if (matched === undefined) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.Unknown,
|
||||
message: `server returned switch=${detail.id} but it is not visible in /workspaces`,
|
||||
hint: 'try again or contact your workspace admin',
|
||||
})
|
||||
}
|
||||
|
||||
const nextCtx = {
|
||||
...deps.active.ctx,
|
||||
workspace: { id: matched.id, name: matched.name, role: matched.role },
|
||||
available_workspaces: list.workspaces.map<Workspace>(w => ({ id: w.id, name: w.name, role: w.role })),
|
||||
workspace: { id: detail.id, name: detail.name, role: detail.role },
|
||||
}
|
||||
deps.reg.upsert(deps.active.host, deps.active.email, nextCtx)
|
||||
deps.reg.save()
|
||||
deps.io.out.write(`${cs.successIcon()} Switched to ${matched.name} (${matched.id})\n`)
|
||||
deps.io.out.write(`${cs.successIcon()} Switched to ${detail.name} (${detail.id})\n`)
|
||||
return deps.reg
|
||||
}
|
||||
|
||||
async function pickWorkspaceId(client: WorkspacesClient, deps: UseWorkspaceDeps): Promise<string> {
|
||||
if (!deps.io.isErrTTY) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: 'a workspace id is required (no TTY)',
|
||||
hint: 'pass the id: \'difyctl use workspace <id>\'',
|
||||
})
|
||||
}
|
||||
|
||||
const list = await runWithSpinner(
|
||||
{ io: deps.io, label: 'Loading workspaces' },
|
||||
() => client.list(),
|
||||
)
|
||||
const items = list.workspaces.map<Workspace>(w => ({ id: w.id, name: w.name, role: w.role }))
|
||||
if (items.length === 0) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: 'no workspaces available to switch to',
|
||||
})
|
||||
}
|
||||
|
||||
const activeId = deps.active.ctx.workspace?.id
|
||||
const picked = await selectFromList<Workspace>({
|
||||
io: deps.io,
|
||||
items,
|
||||
header: 'Select a workspace',
|
||||
render: w => `${w.id === activeId ? '* ' : ' '}${w.name} (${w.role})`,
|
||||
})
|
||||
return picked.id
|
||||
}
|
||||
|
||||
@ -1,19 +1,20 @@
|
||||
import type { Key, Store } from './store'
|
||||
import type { TokenStore } from './token-store'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getTokenStore } from './manager'
|
||||
|
||||
function memStore(label: string): Store & { _label: string } {
|
||||
const map = new Map<string, unknown>()
|
||||
function memStore(label: string): TokenStore & { _label: string } {
|
||||
const map = new Map<string, string>()
|
||||
const k = (h: string, e: string): string => `${h} ${e}`
|
||||
return {
|
||||
_label: label,
|
||||
get<T>(key: Key<T>): T {
|
||||
return (map.get(key.key) as T | undefined) ?? key.default
|
||||
read(host: string, email: string): string {
|
||||
return map.get(k(host, email)) ?? ''
|
||||
},
|
||||
set<T>(key: Key<T>, value: T): void {
|
||||
map.set(key.key, value)
|
||||
write(host: string, email: string, bearer: string): void {
|
||||
map.set(k(host, email), bearer)
|
||||
},
|
||||
unset<T>(key: Key<T>): void {
|
||||
map.delete(key.key)
|
||||
remove(host: string, email: string): void {
|
||||
map.delete(k(host, email))
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -32,11 +33,9 @@ describe('getTokenStore', () => {
|
||||
it('falls back to file when keyring set throws', () => {
|
||||
const k = memStore('keyring')
|
||||
const f = memStore('file')
|
||||
k.set = vi.fn(
|
||||
() => {
|
||||
throw new Error('locked')
|
||||
},
|
||||
)
|
||||
k.write = vi.fn(() => {
|
||||
throw new Error('locked')
|
||||
})
|
||||
const result = getTokenStore({
|
||||
factory: { keyring: () => k, file: () => f },
|
||||
})
|
||||
@ -47,7 +46,7 @@ describe('getTokenStore', () => {
|
||||
it('falls back to file when probe round-trip mismatches', () => {
|
||||
const k = memStore('keyring')
|
||||
const f = memStore('file')
|
||||
k.get = vi.fn(() => 'something-else') as Store['get']
|
||||
k.read = vi.fn(() => 'something-else') as TokenStore['read']
|
||||
const result = getTokenStore({
|
||||
factory: { keyring: () => k, file: () => f },
|
||||
})
|
||||
@ -73,6 +72,6 @@ describe('getTokenStore', () => {
|
||||
getTokenStore({
|
||||
factory: { keyring: () => k, file: () => f },
|
||||
})
|
||||
expect(k.get({ key: '__difyctl_probe__', default: '' })).toBe('')
|
||||
expect(k.read('__difyctl_probe__', '__difyctl_probe__')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import type { Key, StorageMode, Store } from './store'
|
||||
import type { StorageMode, Store } from './store'
|
||||
import type { TokenStore } from './token-store'
|
||||
import { join } from 'node:path'
|
||||
import { resolveCacheDir, resolveConfigDir } from './dir'
|
||||
import { KeyringBasedStore, YamlStore } from './store'
|
||||
import { YamlStore } from './store'
|
||||
import { FileTokenStore, KeychainTokenStore } from './token-store'
|
||||
|
||||
export const CACHE_APP_INFO = 'app-info'
|
||||
export const CACHE_NUDGE = 'nudge'
|
||||
@ -31,13 +33,14 @@ export function getHostStore(): YamlStore {
|
||||
return getStore(join(resolveConfigDir(), HOSTS_FILE))
|
||||
}
|
||||
|
||||
const PROBE_KEY: Key<string> = { key: '__difyctl_probe__', default: '' }
|
||||
const PROBE_HOST = '__difyctl_probe__'
|
||||
const PROBE_EMAIL = '__difyctl_probe__'
|
||||
const PROBE_VALUE = 'probe-v1'
|
||||
|
||||
export type GetTokenStoreOptions = {
|
||||
readonly factory?: {
|
||||
readonly keyring?: () => Store
|
||||
readonly file?: () => Store
|
||||
readonly keyring?: () => TokenStore
|
||||
readonly file?: () => TokenStore
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,19 +48,19 @@ export type GetTokenStoreOptions = {
|
||||
* Single entry point for the credential store. Probes the OS keyring; if it
|
||||
* round-trips a value, returns the keychain-backed store. Otherwise falls
|
||||
* back to the YAML file at `<configDir>/tokens.yml`. Both implementations
|
||||
* satisfy the `Store` interface, so callers interact uniformly.
|
||||
* satisfy the `TokenStore` interface, so callers interact uniformly.
|
||||
*
|
||||
* Business logic should always obtain the token store through this factory
|
||||
* rather than constructing one directly.
|
||||
*/
|
||||
export function getTokenStore(opts: GetTokenStoreOptions = {}): { store: Store, mode: StorageMode } {
|
||||
const fileFactory = opts.factory?.file ?? (() => getStore(join(resolveConfigDir(), TOKENS_FILE)))
|
||||
const keyringFactory = opts.factory?.keyring ?? (() => new KeyringBasedStore(KEYRING_SERVICE))
|
||||
export function getTokenStore(opts: GetTokenStoreOptions = {}): { store: TokenStore, mode: StorageMode } {
|
||||
const fileFactory = opts.factory?.file ?? (() => new FileTokenStore(join(resolveConfigDir(), TOKENS_FILE)))
|
||||
const keyringFactory = opts.factory?.keyring ?? (() => new KeychainTokenStore(KEYRING_SERVICE))
|
||||
try {
|
||||
const k = keyringFactory()
|
||||
k.set(PROBE_KEY, PROBE_VALUE)
|
||||
const got = k.get(PROBE_KEY)
|
||||
k.unset(PROBE_KEY)
|
||||
k.write(PROBE_HOST, PROBE_EMAIL, PROBE_VALUE)
|
||||
const got = k.read(PROBE_HOST, PROBE_EMAIL)
|
||||
k.remove(PROBE_HOST, PROBE_EMAIL)
|
||||
if (got !== PROBE_VALUE)
|
||||
throw new Error('keyring round-trip mismatch')
|
||||
return { store: k, mode: 'keychain' }
|
||||
@ -66,12 +69,3 @@ export function getTokenStore(opts: GetTokenStoreOptions = {}): { store: Store,
|
||||
return { store: fileFactory(), mode: 'file' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an auth identity (host + accountId) to a `Store` key. All token store
|
||||
* reads/writes in business logic go through this helper so the on-disk /
|
||||
* keyring layout stays consistent.
|
||||
*/
|
||||
export function tokenKey(host: string, accountId: string): Key<string> {
|
||||
return { key: `tokens.${host}.${accountId}`, default: '' }
|
||||
}
|
||||
|
||||
66
cli/src/store/token-store.test.ts
Normal file
66
cli/src/store/token-store.test.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { FileTokenStore } from './token-store'
|
||||
|
||||
describe('FileTokenStore', () => {
|
||||
let dir: string
|
||||
let file: string
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'difyctl-tok-'))
|
||||
file = join(dir, 'tokens.yml')
|
||||
})
|
||||
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
||||
|
||||
it('returns empty string for a missing credential', () => {
|
||||
const s = new FileTokenStore(file)
|
||||
expect(s.read('https://cloud.dify.ai', 'a@x.com')).toBe('')
|
||||
})
|
||||
|
||||
it('round-trips a bearer with dots and @ kept literal', () => {
|
||||
const s = new FileTokenStore(file)
|
||||
s.write('https://cloud.dify.ai', 'a.b@x.com', 'dfoa_secret')
|
||||
expect(s.read('https://cloud.dify.ai', 'a.b@x.com')).toBe('dfoa_secret')
|
||||
})
|
||||
|
||||
it('keeps multiple accounts under one host and isolates hosts', () => {
|
||||
const s = new FileTokenStore(file)
|
||||
s.write('https://cloud.dify.ai', 'a@x.com', 'A')
|
||||
s.write('https://cloud.dify.ai', 'b@x.com', 'B')
|
||||
s.write('https://self.example.com', 'a@x.com', 'C')
|
||||
expect(s.read('https://cloud.dify.ai', 'a@x.com')).toBe('A')
|
||||
expect(s.read('https://cloud.dify.ai', 'b@x.com')).toBe('B')
|
||||
expect(s.read('https://self.example.com', 'a@x.com')).toBe('C')
|
||||
})
|
||||
|
||||
it('persists the versioned nested shape on disk', () => {
|
||||
const s = new FileTokenStore(file)
|
||||
s.write('https://cloud.dify.ai', 'a@x.com', 'A')
|
||||
const raw = readFileSync(file, 'utf8')
|
||||
expect(raw).toContain('version: 1')
|
||||
expect(raw).toContain('https://cloud.dify.ai')
|
||||
expect(raw).toContain('a@x.com')
|
||||
})
|
||||
|
||||
it('reads empty when the document version is unknown', () => {
|
||||
writeFileSync(file, 'version: 999\ntokens:\n "h":\n "e": "x"\n')
|
||||
const s = new FileTokenStore(file)
|
||||
expect(s.read('h', 'e')).toBe('')
|
||||
})
|
||||
|
||||
it('remove deletes the credential and prunes the empty host map', () => {
|
||||
const s = new FileTokenStore(file)
|
||||
s.write('https://cloud.dify.ai', 'a@x.com', 'A')
|
||||
s.remove('https://cloud.dify.ai', 'a@x.com')
|
||||
expect(s.read('https://cloud.dify.ai', 'a@x.com')).toBe('')
|
||||
const raw = readFileSync(file, 'utf8')
|
||||
expect(raw).not.toContain('cloud.dify.ai')
|
||||
})
|
||||
|
||||
it('remove is a no-op for an absent credential', () => {
|
||||
const s = new FileTokenStore(file)
|
||||
expect(() => s.remove('h', 'e')).not.toThrow()
|
||||
})
|
||||
})
|
||||
106
cli/src/store/token-store.ts
Normal file
106
cli/src/store/token-store.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { Entry } from '@napi-rs/keyring'
|
||||
import { YamlStore } from './store'
|
||||
|
||||
/**
|
||||
* Credential store keyed by an opaque (host, email) pair. Unlike the generic
|
||||
* dotted-key `Store`, host and email are never split — they are literal map
|
||||
* keys (file) or part of a flat entry name (keychain). `read` returns '' when
|
||||
* a credential is absent.
|
||||
*/
|
||||
export type TokenStore = {
|
||||
read: (host: string, email: string) => string
|
||||
write: (host: string, email: string, bearer: string) => void
|
||||
remove: (host: string, email: string) => void
|
||||
}
|
||||
|
||||
const DOC_VERSION = 1
|
||||
|
||||
type TokenDoc = {
|
||||
version?: number
|
||||
tokens?: Record<string, Record<string, string>>
|
||||
}
|
||||
|
||||
export class FileTokenStore implements TokenStore {
|
||||
private readonly store: YamlStore
|
||||
|
||||
constructor(filePath: string) {
|
||||
this.store = new YamlStore(filePath)
|
||||
}
|
||||
|
||||
read(host: string, email: string): string {
|
||||
const doc = this.store.getTyped<TokenDoc>()
|
||||
if (doc === null || doc.version !== DOC_VERSION)
|
||||
return ''
|
||||
return doc.tokens?.[host]?.[email] ?? ''
|
||||
}
|
||||
|
||||
write(host: string, email: string, bearer: string): void {
|
||||
const doc = this.load()
|
||||
const hostMap = doc.tokens[host] ?? {}
|
||||
hostMap[email] = bearer
|
||||
doc.tokens[host] = hostMap
|
||||
this.store.setTyped(doc)
|
||||
}
|
||||
|
||||
remove(host: string, email: string): void {
|
||||
const doc = this.store.getTyped<TokenDoc>()
|
||||
if (doc === null || doc.version !== DOC_VERSION)
|
||||
return
|
||||
const tokens = doc.tokens ?? {}
|
||||
const hostMap = tokens[host]
|
||||
if (hostMap === undefined || !(email in hostMap))
|
||||
return
|
||||
delete hostMap[email]
|
||||
if (Object.keys(hostMap).length === 0)
|
||||
delete tokens[host]
|
||||
this.store.setTyped({ version: DOC_VERSION, tokens })
|
||||
}
|
||||
|
||||
private load(): { version: number, tokens: Record<string, Record<string, string>> } {
|
||||
const doc = this.store.getTyped<TokenDoc>()
|
||||
if (doc === null || doc.version !== DOC_VERSION)
|
||||
return { version: DOC_VERSION, tokens: {} }
|
||||
return { version: DOC_VERSION, tokens: doc.tokens ?? {} }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One OS-keyring entry per (host, email). The entry name is intentionally
|
||||
* identical to the legacy `tokens.<host>.<email>` key so existing keychain
|
||||
* credentials keep working without a re-login. The value is the bearer stored
|
||||
* exactly as the legacy `KeyringBasedStore` stored it (JSON-encoded string).
|
||||
*/
|
||||
export class KeychainTokenStore implements TokenStore {
|
||||
private readonly service: string
|
||||
|
||||
constructor(service: string) {
|
||||
this.service = service
|
||||
}
|
||||
|
||||
read(host: string, email: string): string {
|
||||
try {
|
||||
const v = new Entry(this.service, entryName(host, email)).getPassword()
|
||||
if (v === null || v === undefined || v === '')
|
||||
return ''
|
||||
return JSON.parse(v) as string
|
||||
}
|
||||
catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
write(host: string, email: string, bearer: string): void {
|
||||
new Entry(this.service, entryName(host, email)).setPassword(JSON.stringify(bearer))
|
||||
}
|
||||
|
||||
remove(host: string, email: string): void {
|
||||
try {
|
||||
new Entry(this.service, entryName(host, email)).deletePassword()
|
||||
}
|
||||
catch { /* missing entry is fine */ }
|
||||
}
|
||||
}
|
||||
|
||||
function entryName(host: string, email: string): string {
|
||||
return `tokens.${host}.${email}`
|
||||
}
|
||||
20
cli/src/workspace/resolver.test.ts
Normal file
20
cli/src/workspace/resolver.test.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveWorkspaceId } from './resolver'
|
||||
|
||||
function active(workspaceId?: string): ActiveContext {
|
||||
return { host: 'h', email: 'e', ctx: { account: { id: '', email: 'e', name: '' }, workspace: workspaceId ? { id: workspaceId, name: 'W', role: 'owner' } : undefined } }
|
||||
}
|
||||
|
||||
describe('resolveWorkspaceId', () => {
|
||||
it('prefers the flag', () => {
|
||||
expect(resolveWorkspaceId({ flag: 'ws-flag', env: 'ws-env', active: active('ws-ctx') })).toBe('ws-flag')
|
||||
})
|
||||
it('falls back to env then active workspace', () => {
|
||||
expect(resolveWorkspaceId({ env: 'ws-env', active: active('ws-ctx') })).toBe('ws-env')
|
||||
expect(resolveWorkspaceId({ active: active('ws-ctx') })).toBe('ws-ctx')
|
||||
})
|
||||
it('throws when no workspace is selected (no implicit default)', () => {
|
||||
expect(() => resolveWorkspaceId({ active: active(undefined) })).toThrow(/no workspace selected/)
|
||||
})
|
||||
})
|
||||
@ -13,15 +13,9 @@ export function resolveWorkspaceId(inputs: WorkspaceResolveInputs): string {
|
||||
return inputs.flag
|
||||
if (truthy(inputs.env))
|
||||
return inputs.env
|
||||
const ctx = inputs.active?.ctx
|
||||
if (ctx !== undefined) {
|
||||
if (truthy(ctx.workspace?.id))
|
||||
return ctx.workspace.id
|
||||
if (ctx.available_workspaces !== undefined && ctx.available_workspaces.length > 0
|
||||
&& truthy(ctx.available_workspaces[0]?.id)) {
|
||||
return ctx.available_workspaces[0].id
|
||||
}
|
||||
}
|
||||
const wsId = inputs.active?.ctx.workspace?.id
|
||||
if (truthy(wsId))
|
||||
return wsId
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: 'no workspace selected',
|
||||
|
||||
Reference in New Issue
Block a user