fix: change store apis to async (#37329)

This commit is contained in:
Yunlu Wen 2026-06-15 10:24:51 +08:00 committed by GitHub
parent e0773c4d8f
commit d315ae3b80
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 398 additions and 379 deletions

View File

@ -139,19 +139,19 @@ describe('Registry (pure)', () => {
describe('Registry.load / Registry.save', () => {
useTempConfigDir('difyctl-reg-')
it('returns an empty registry when nothing saved', () => {
const reg = Registry.load()
it('returns an empty registry when nothing saved', async () => {
const reg = await Registry.load()
expect(reg.current_host).toBeUndefined()
expect(Object.keys(reg.hosts)).toHaveLength(0)
})
it('round-trips a populated registry', () => {
it('round-trips a populated registry', async () => {
const reg = Registry.empty('keychain')
reg.upsert('cloud.dify.ai', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
reg.setHost('cloud.dify.ai')
reg.setAccount('a@x')
reg.save()
const loaded = Registry.load()
await reg.save()
const loaded = await Registry.load()
expect(loaded?.current_host).toBe('cloud.dify.ai')
expect(loaded?.hosts['cloud.dify.ai']?.accounts['a@x']?.account.email).toBe('a@x')
})
@ -160,21 +160,21 @@ describe('Registry.load / Registry.save', () => {
describe('Registry.forget', () => {
useTempConfigDir('difyctl-forget-')
it('drops token + active context, keeps siblings, unsets pointers', () => {
it('drops token + active context, keeps siblings, unsets pointers', async () => {
const store = new MemStore()
const reg = Registry.empty('file')
reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
reg.upsert('h1', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } })
reg.setHost('h1')
reg.setAccount('a@x')
reg.save()
store.write('h1', 'a@x', 'dfoa_a')
await reg.save()
await store.write('h1', 'a@x', 'dfoa_a')
const active = reg.resolveActive()!
reg.forget(active, store)
await reg.forget(active, store)
expect(store.read('h1', 'a@x')).toBe('')
const after = Registry.load()
expect(await store.read('h1', 'a@x')).toBe('')
const after = await Registry.load()
expect(after?.hosts.h1?.accounts['a@x']).toBeUndefined()
expect(after?.hosts.h1?.accounts['b@x']).toBeDefined()
expect(after?.current_host).toBeUndefined()

View File

@ -71,8 +71,8 @@ export class Registry {
this.data = data
}
static load(): Registry {
const raw = getHostStore().getTyped<Record<string, unknown>>()
static async load(): Promise<Registry> {
const raw = await getHostStore().getTyped<Record<string, unknown>>()
if (raw === null)
return Registry.empty()
return new Registry(RegistrySchema.parse(raw))
@ -165,16 +165,16 @@ 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: TokenStore): void {
async forget(active: ActiveContext, store: TokenStore): Promise<void> {
try {
store.remove(active.host, active.email)
await store.remove(active.host, active.email)
}
catch { /* best-effort */ }
this.remove(active.host, active.email)
this.save()
await this.save()
}
save(): void {
getHostStore().setTyped(RegistrySchema.parse(this.data))
async save(): Promise<void> {
await getHostStore().setTyped(RegistrySchema.parse(this.data))
}
}

View File

@ -42,17 +42,17 @@ export type AppInfoCacheOptions = {
export async function loadAppInfoCache(opts: AppInfoCacheOptions = {}): Promise<AppInfoCache> {
const store = opts.store ?? getCache(CACHE_APP_INFO)
const ttlMs = opts.ttlMs ?? APP_INFO_TTL_MS
const state: State = { entries: readEntries(store) }
const state: State = { entries: await readEntries(store) }
return {
get: (host, appId) => state.entries.get(key(host, appId)),
set: async (host, appId, meta) => {
const record: AppMetaCacheRecord = { meta, fetchedAt: (opts.now ?? (() => new Date()))().toISOString() }
state.entries.set(key(host, appId), record)
writeEntries(store, state.entries)
await writeEntries(store, state.entries)
},
delete: async (host, appId) => {
state.entries.delete(key(host, appId))
writeEntries(store, state.entries)
await writeEntries(store, state.entries)
},
isFresh: (record, now) => {
const t = (now ?? new Date()).getTime() - new Date(record.fetchedAt).getTime()
@ -65,11 +65,11 @@ function key(host: string, appId: string): string {
return `${host}::${appId}`
}
function readEntries(store: Store): Map<string, AppMetaCacheRecord> {
async function readEntries(store: Store): Promise<Map<string, AppMetaCacheRecord>> {
const out = new Map<string, AppMetaCacheRecord>()
let raw: Record<string, DiskEntry>
try {
raw = store.get(ENTRIES_KEY)
raw = await store.get(ENTRIES_KEY)
}
catch {
return out
@ -111,8 +111,8 @@ function serialize(record: AppMetaCacheRecord): DiskEntry {
}
}
function writeEntries(store: Store, entries: Map<string, AppMetaCacheRecord>): void {
async function writeEntries(store: Store, entries: Map<string, AppMetaCacheRecord>): Promise<void> {
const out: Record<string, DiskEntry> = {}
for (const [k, v] of entries) out[k] = serialize(v)
store.set(ENTRIES_KEY, out)
await store.set(ENTRIES_KEY, out)
}

View File

@ -23,7 +23,7 @@ export async function loadNudgeStore(opts: NudgeStoreOptions = {}): Promise<Nudg
const store = opts.store ?? getCache(CACHE_NUDGE)
const intervalMs = opts.intervalMs ?? WARN_INTERVAL_MS
const clock = opts.now ?? (() => new Date())
const memory = readWarned(store)
const memory = await readWarned(store)
return {
canWarn: (host, now) => {
@ -39,18 +39,18 @@ export async function loadNudgeStore(opts: NudgeStoreOptions = {}): Promise<Nudg
// Re-read disk inside the write cycle so concurrent processes touching
// different hosts don't clobber each other's stamps. Same-host writers
// converge on a near-identical timestamp, so order doesn't matter.
const onDisk = readWarned(store)
const onDisk = await readWarned(store)
onDisk.set(host, stamp)
writeWarned(store, onDisk)
await writeWarned(store, onDisk)
},
}
}
function readWarned(store: Store): Map<string, number> {
async function readWarned(store: Store): Promise<Map<string, number>> {
const out = new Map<string, number>()
let raw: Record<string, string>
try {
raw = store.get(WARNED_KEY)
raw = await store.get(WARNED_KEY)
}
catch {
return out
@ -63,9 +63,9 @@ function readWarned(store: Store): Map<string, number> {
return out
}
function writeWarned(store: Store, state: Map<string, number>): void {
async function writeWarned(store: Store, state: Map<string, number>): Promise<void> {
const warned: Record<string, string> = {}
for (const [host, t] of state)
warned[host] = new Date(t).toISOString()
store.set(WARNED_KEY, warned)
await store.set(WARNED_KEY, warned)
}

View File

@ -39,13 +39,13 @@ export async function buildAuthedContext(
opts: AuthedContextOptions,
): Promise<AuthedContext> {
const io = realStreams(opts.format ?? '')
const reg = Registry.load()
const reg = await Registry.load()
const active = reg.resolveActive()
if (active === undefined)
fail(cmd, opts, io)
const store = getTokenStore(reg.token_storage)
const bearer = store.read(active.host, active.email)
const bearer = await store.read(active.host, active.email)
if (bearer === '')
fail(cmd, opts, io)

View File

@ -71,8 +71,8 @@ describe('runDevicesRevoke', () => {
const io = bufferStreams()
const store = new MemStore()
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
store.write(mock.url, 'tester@dify.ai', 'dfoa_test')
reg.save()
await store.write(mock.url, 'tester@dify.ai', 'dfoa_test')
await reg.save()
const http = testHttpClient(mock.url, 'dfoa_test')
await runDevicesRevoke({ io, reg, active, store, http, target: 'difyctl on desktop', all: false })
@ -136,13 +136,13 @@ describe('runDevicesRevoke', () => {
const io = bufferStreams()
const store = new MemStore()
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
store.write(mock.url, 'tester@dify.ai', 'dfoa_test')
reg.save()
await store.write(mock.url, 'tester@dify.ai', 'dfoa_test')
await reg.save()
const http = testHttpClient(mock.url, 'dfoa_test')
await runDevicesRevoke({ io, reg, active, store, http, target: 'tok-1', all: false })
expect(store.entries.size).toBe(0)
const saved = Registry.load()
const saved = await Registry.load()
expect(saved?.hosts[mock.url]).toBeUndefined()
})

View File

@ -100,7 +100,7 @@ export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise<void
await sessions.revoke(id)
if (selfHit)
opts.reg.forget(opts.active, opts.store)
await opts.reg.forget(opts.active, opts.store)
opts.io.out.write(`${cs.successIcon()} Revoked ${ids.length} session(s)\n`)
}

View File

@ -19,7 +19,7 @@ export default class AuthList extends DifyCommand {
async run(argv: string[]) {
const { flags } = this.parse(AuthList, argv)
const reg = Registry.load()
const reg = await Registry.load()
const result = runAuthList(reg)
return table({ format: flags.output, data: result })
}

View File

@ -49,7 +49,7 @@ describe('runLogin', () => {
const active = reg.resolveActive()
expect(active?.ctx.account.email).toBe('tester@dify.ai')
expect(active?.ctx.workspace?.id).toBe('550e8400-e29b-41d4-a716-446655440000')
expect(store.read(active!.host, 'tester@dify.ai')).toBe('dfoa_test')
expect(await store.read(active!.host, 'tester@dify.ai')).toBe('dfoa_test')
const hostsRaw = await readFile(join(configDir(), 'hosts.yml'), 'utf8')
expect(hostsRaw).toContain('current_host:')
@ -82,7 +82,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.read(active!.host, 'sso@dify.ai')).toBe('dfoe_test')
expect(await store.read(active!.host, 'sso@dify.ai')).toBe('dfoe_test')
expect(io.outBuf()).toContain('external SSO')
expect(io.outBuf()).toContain('sso@dify.ai')
})

View File

@ -70,18 +70,18 @@ export async function runLogin(opts: LoginOptions): Promise<Registry> {
spinner.stop()
}
const storeBundle = opts.store ?? detectTokenStore()
const storeBundle = opts.store ?? await detectTokenStore()
const display = bareHost(host)
const email = accountEmail(success)
const ctx = contextFromSuccess(success)
storeBundle.store.write(display, email, success.token)
await storeBundle.store.write(display, email, success.token)
const reg = Registry.load()
const reg = await Registry.load()
reg.token_storage = storeBundle.mode
reg.activate(display, email, ctx)
applyScheme(reg, display, host)
reg.save()
await reg.save()
renderLoggedIn(opts.io.out, cs, host, success)
return reg

View File

@ -21,14 +21,14 @@ export default class Logout extends DifyCommand {
async run(argv: string[]): Promise<void> {
this.parse(Logout, argv)
const io = realStreams()
const reg = Registry.load()
const reg = await Registry.load()
const active = reg.resolveActive()
let http: HttpClient | undefined
if (active !== undefined) {
let bearer = ''
try {
bearer = getTokenStore(reg.token_storage).read(active.host, active.email)
bearer = await getTokenStore(reg.token_storage).read(active.host, active.email)
}
catch { /* keyring locked — skip remote revocation, local cleanup still runs */ }
if (bearer !== '') {

View File

@ -10,45 +10,45 @@ import { runLogout } from './logout.js'
describe('runLogout', () => {
const dir = useTempConfigDir('difyctl-logout-')
function seed(store: MemStore) {
async function seed(store: MemStore) {
const reg = Registry.empty('file')
reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
reg.upsert('h1', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } })
reg.setHost('h1')
reg.setAccount('a@x')
reg.save()
store.write('h1', 'a@x', 'dfoa_a')
store.write('h1', 'b@x', 'dfoa_b')
await reg.save()
await store.write('h1', 'a@x', 'dfoa_a')
await store.write('h1', 'b@x', 'dfoa_b')
}
it('removes only the active context, keeps others, unsets pointers, file survives', async () => {
const store = new MemStore()
seed(store)
await runLogout({ io: bufferStreams(), reg: Registry.load(), store })
const after = Registry.load()
await seed(store)
await runLogout({ io: bufferStreams(), reg: await Registry.load(), store })
const after = await Registry.load()
expect(after?.hosts.h1?.accounts['a@x']).toBeUndefined()
expect(after?.hosts.h1?.accounts['b@x']).toBeDefined()
expect(after?.current_host).toBeUndefined()
expect(store.read('h1', 'a@x')).toBe('')
expect(store.read('h1', 'b@x')).toBe('dfoa_b')
expect(await store.read('h1', 'a@x')).toBe('')
expect(await store.read('h1', 'b@x')).toBe('dfoa_b')
const raw = await readFile(join(dir(), 'hosts.yml'), 'utf8')
expect(raw).toContain('b@x')
})
it('clears local credentials even when the store.read throws (e.g. keyring locked)', async () => {
const store = new MemStore()
seed(store)
await seed(store)
store.read = () => {
throw new Error('keyring locked')
}
await runLogout({ io: bufferStreams(), reg: Registry.load(), store })
const after = Registry.load()
await runLogout({ io: bufferStreams(), reg: await Registry.load(), store })
const after = await Registry.load()
expect(after?.hosts.h1?.accounts['a@x']).toBeUndefined()
})
it('throws NotLoggedIn when no active context', async () => {
Registry.empty('file').save()
await expect(runLogout({ io: bufferStreams(), reg: Registry.load(), store: new MemStore() }))
await Registry.empty('file').save()
await expect(runLogout({ io: bufferStreams(), reg: await Registry.load(), store: new MemStore() }))
.rejects
.toThrow(/not logged in/i)
})

View File

@ -24,7 +24,7 @@ export async function runLogout(opts: LogoutOptions): Promise<void> {
const store = opts.store ?? getTokenStore(reg.token_storage)
let bearer = ''
try {
bearer = store.read(active.host, active.email)
bearer = await store.read(active.host, active.email)
}
catch { /* keyring locked — skip remote revocation, local cleanup still runs */ }
@ -38,7 +38,7 @@ export async function runLogout(opts: LogoutOptions): Promise<void> {
}
}
reg.forget(active, store)
await reg.forget(active, store)
if (revokeWarning !== '')
opts.io.err.write(revokeWarning)

View File

@ -18,7 +18,7 @@ export default class Whoami extends DifyCommand {
async run(argv: string[]): Promise<void> {
const { flags } = this.parse(Whoami, argv)
const reg = Registry.load()
const reg = await Registry.load()
await runWhoami({ io: realStreams(), reg, json: flags.json })
}
}

View File

@ -17,6 +17,6 @@ export default class ConfigGet extends DifyCommand {
async run(argv: string[]) {
const { args } = this.parse(ConfigGet, argv)
return raw(runConfigGet({ store: getConfigurationStore(), key: args.key }))
return raw(await runConfigGet({ store: getConfigurationStore(), key: args.key }))
}
}

View File

@ -8,24 +8,24 @@ import { runConfigGet } from './run'
describe('runConfigGet', () => {
useTempConfigDir('difyctl-get-')
it('returns set value with trailing newline', () => {
getConfigurationStore().setTyped({
it('returns set value with trailing newline', async () => {
await getConfigurationStore().setTyped({
schema_version: 1,
defaults: { format: 'yaml' },
})
const out = runConfigGet({ store: getConfigurationStore(), key: 'defaults.format' })
const out = await runConfigGet({ store: getConfigurationStore(), key: 'defaults.format' })
expect(out).toBe('yaml\n')
})
it('returns empty line when key is unset (matches Go fmt.Fprintln)', () => {
const out = runConfigGet({ store: getConfigurationStore(), key: 'defaults.format' })
it('returns empty line when key is unset (matches Go fmt.Fprintln)', async () => {
const out = await runConfigGet({ store: getConfigurationStore(), key: 'defaults.format' })
expect(out).toBe('\n')
})
it('throws BaseError(config_invalid_key) on unknown key', () => {
it('throws BaseError(config_invalid_key) on unknown key', async () => {
let caught: unknown
try {
runConfigGet({ store: getConfigurationStore(), key: 'bogus.key' })
await runConfigGet({ store: getConfigurationStore(), key: 'bogus.key' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@ -33,12 +33,12 @@ describe('runConfigGet', () => {
expect(caught.code).toBe(ErrorCode.ConfigInvalidKey)
})
it('returns numeric limit as string', () => {
getConfigurationStore().setTyped({
it('returns numeric limit as string', async () => {
await getConfigurationStore().setTyped({
schema_version: 1,
defaults: { limit: 75 },
})
const out = runConfigGet({ store: getConfigurationStore(), key: 'defaults.limit' })
const out = await runConfigGet({ store: getConfigurationStore(), key: 'defaults.limit' })
expect(out).toBe('75\n')
})
})

View File

@ -9,8 +9,8 @@ export type RunConfigGetOptions = {
readonly store: YamlStore
}
export function runConfigGet(opts: RunConfigGetOptions): string {
const loaded = loadConfig(opts.store)
export async function runConfigGet(opts: RunConfigGetOptions): Promise<string> {
const loaded = await loadConfig(opts.store)
const config: ConfigFile = loaded.found ? loaded.config : emptyConfig()
return `${getKey(config, opts.key)}\n`
}

View File

@ -22,6 +22,6 @@ export default class ConfigSet extends DifyCommand {
async run(argv: string[]) {
const { args } = this.parse(ConfigSet, argv)
return raw(runConfigSet({ store: getConfigurationStore(), key: args.key, value: args.value }))
return raw(await runConfigSet({ store: getConfigurationStore(), key: args.key, value: args.value }))
}
}

View File

@ -9,20 +9,20 @@ import { runConfigSet } from './run'
describe('runConfigSet', () => {
useTempConfigDir('difyctl-set-')
it('persists the value and returns "set k = v\\n"', () => {
const out = runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'json' })
it('persists the value and returns "set k = v\\n"', async () => {
const out = await runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'json' })
expect(out).toBe('set defaults.format = json\n')
const r = loadConfig(getConfigurationStore())
const r = await loadConfig(getConfigurationStore())
expect(r.found).toBe(true)
if (r.found)
expect(r.config.defaults.format).toBe('json')
})
it('rejects invalid format value with config_invalid_value', () => {
it('rejects invalid format value with config_invalid_value', async () => {
let caught: unknown
try {
runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'csv' })
await runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'csv' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@ -30,10 +30,10 @@ describe('runConfigSet', () => {
expect(caught.code).toBe(ErrorCode.ConfigInvalidValue)
})
it('rejects unknown key with config_invalid_key', () => {
it('rejects unknown key with config_invalid_key', async () => {
let caught: unknown
try {
runConfigSet({ store: getConfigurationStore(), key: 'bogus', value: 'x' })
await runConfigSet({ store: getConfigurationStore(), key: 'bogus', value: 'x' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@ -41,11 +41,11 @@ describe('runConfigSet', () => {
expect(caught.code).toBe(ErrorCode.ConfigInvalidKey)
})
it('preserves prior keys when setting a new one', () => {
runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'yaml' })
runConfigSet({ store: getConfigurationStore(), key: 'defaults.limit', value: '40' })
it('preserves prior keys when setting a new one', async () => {
await runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'yaml' })
await runConfigSet({ store: getConfigurationStore(), key: 'defaults.limit', value: '40' })
const r = loadConfig(getConfigurationStore())
const r = await loadConfig(getConfigurationStore())
expect(r.found).toBe(true)
if (r.found) {
expect(r.config.defaults.format).toBe('yaml')
@ -53,10 +53,10 @@ describe('runConfigSet', () => {
}
})
it('exit code for invalid value is Usage (2)', () => {
it('exit code for invalid value is Usage (2)', async () => {
let caught: unknown
try {
runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'csv' })
await runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'csv' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@ -64,10 +64,10 @@ describe('runConfigSet', () => {
expect(caught.exit()).toBe(ExitCode.Usage)
})
it('exit code for unknown key is Usage (2)', () => {
it('exit code for unknown key is Usage (2)', async () => {
let caught: unknown
try {
runConfigSet({ store: getConfigurationStore(), key: 'bogus', value: 'x' })
await runConfigSet({ store: getConfigurationStore(), key: 'bogus', value: 'x' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@ -75,10 +75,10 @@ describe('runConfigSet', () => {
expect(caught.exit()).toBe(ExitCode.Usage)
})
it('typed wrap chain: invalid defaults.limit surfaces ConfigInvalidValue (not UsageInvalidFlag)', () => {
it('typed wrap chain: invalid defaults.limit surfaces ConfigInvalidValue (not UsageInvalidFlag)', async () => {
let caught: unknown
try {
runConfigSet({ store: getConfigurationStore(), key: 'defaults.limit', value: 'abc' })
await runConfigSet({ store: getConfigurationStore(), key: 'defaults.limit', value: 'abc' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)

View File

@ -11,10 +11,10 @@ export type RunConfigSetOptions = {
readonly store: YamlStore
}
export function runConfigSet(opts: RunConfigSetOptions): string {
const loaded = loadConfig(opts.store)
export async function runConfigSet(opts: RunConfigSetOptions): Promise<string> {
const loaded = await loadConfig(opts.store)
const config: ConfigFile = loaded.found ? loaded.config : emptyConfig()
const next = setKey(config, opts.key, opts.value)
saveConfig(opts.store, next)
await saveConfig(opts.store, next)
return `set ${opts.key} = ${opts.value}\n`
}

View File

@ -20,6 +20,6 @@ export default class ConfigUnset extends DifyCommand {
async run(argv: string[]) {
const { args } = this.parse(ConfigUnset, argv)
return raw(runConfigUnset({ store: getConfigurationStore(), key: args.key }))
return raw(await runConfigUnset({ store: getConfigurationStore(), key: args.key }))
}
}

View File

@ -9,15 +9,15 @@ import { runConfigUnset } from './run'
describe('runConfigUnset', () => {
useTempConfigDir('difyctl-unset-')
it('clears the requested key, leaves others intact', () => {
getConfigurationStore().setTyped({
it('clears the requested key, leaves others intact', async () => {
await getConfigurationStore().setTyped({
schema_version: 1,
defaults: { format: 'json', limit: 25 },
})
const out = runConfigUnset({ store: getConfigurationStore(), key: 'defaults.format' })
const out = await runConfigUnset({ store: getConfigurationStore(), key: 'defaults.format' })
expect(out).toBe('unset defaults.format\n')
const r = loadConfig(getConfigurationStore())
const r = await loadConfig(getConfigurationStore())
expect(r.found).toBe(true)
if (r.found) {
expect(r.config.defaults.format).not.toBe('json')
@ -25,19 +25,19 @@ describe('runConfigUnset', () => {
}
})
it('is a no-op (writes empty config) when key was already unset', () => {
const out = runConfigUnset({ store: getConfigurationStore(), key: 'defaults.format' })
it('is a no-op (writes empty config) when key was already unset', async () => {
const out = await runConfigUnset({ store: getConfigurationStore(), key: 'defaults.format' })
expect(out).toBe('unset defaults.format\n')
const r = loadConfig(getConfigurationStore())
const r = await loadConfig(getConfigurationStore())
expect(r.found).toBe(true)
if (r.found)
expect(r.config.schema_version).toBe(1)
})
it('rejects unknown key', () => {
it('rejects unknown key', async () => {
let caught: unknown
try {
runConfigUnset({ store: getConfigurationStore(), key: 'bogus' })
await runConfigUnset({ store: getConfigurationStore(), key: 'bogus' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)

View File

@ -10,10 +10,10 @@ export type RunConfigUnsetOptions = {
readonly store: YamlStore
}
export function runConfigUnset(opts: RunConfigUnsetOptions): string {
const loaded = loadConfig(opts.store)
export async function runConfigUnset(opts: RunConfigUnsetOptions): Promise<string> {
const loaded = await loadConfig(opts.store)
const config: ConfigFile = loaded.found ? loaded.config : emptyConfig()
const next = unsetKey(config, opts.key)
saveConfig(opts.store, next)
await saveConfig(opts.store, next)
return `unset ${opts.key}\n`
}

View File

@ -18,6 +18,6 @@ export default class ConfigView extends DifyCommand {
async run(argv: string[]) {
const { flags } = this.parse(ConfigView, argv)
return raw(runConfigView({ store: getConfigurationStore(), json: flags.json }))
return raw(await runConfigView({ store: getConfigurationStore(), json: flags.json }))
}
}

View File

@ -6,54 +6,54 @@ import { runConfigView } from './run'
describe('runConfigView', () => {
useTempConfigDir('difyctl-view-')
it('text format: empty config returns empty string', () => {
const out = runConfigView({ store: getConfigurationStore() })
it('text format: empty config returns empty string', async () => {
const out = await runConfigView({ store: getConfigurationStore() })
expect(out).toBe('')
})
it('text format: emits "key = value" lines for set keys only', () => {
getConfigurationStore().setTyped({
it('text format: emits "key = value" lines for set keys only', async () => {
await getConfigurationStore().setTyped({
schema_version: 1,
defaults: { format: 'json', limit: 50 },
state: { current_app: 'app-1' },
})
const out = runConfigView({ store: getConfigurationStore() })
const out = await runConfigView({ store: getConfigurationStore() })
expect(out).toBe(
'defaults.format = json\ndefaults.limit = 50\nstate.current_app = app-1\n',
)
})
it('text format: skips unset keys', () => {
getConfigurationStore().setTyped({
it('text format: skips unset keys', async () => {
await getConfigurationStore().setTyped({
schema_version: 1,
defaults: { format: 'yaml' },
})
const out = runConfigView({ store: getConfigurationStore() })
const out = await runConfigView({ store: getConfigurationStore() })
expect(out).toBe('defaults.format = yaml\n')
expect(out).not.toContain('defaults.limit')
expect(out).not.toContain('state.current_app')
})
it('json format: empty config returns "{}\\n"', () => {
const out = runConfigView({ store: getConfigurationStore(), json: true })
it('json format: empty config returns "{}\\n"', async () => {
const out = await runConfigView({ store: getConfigurationStore(), json: true })
expect(out).toBe('{}\n')
})
it('json format: defaults.limit is numeric, others are strings', () => {
getConfigurationStore().setTyped({
it('json format: defaults.limit is numeric, others are strings', async () => {
await getConfigurationStore().setTyped({
schema_version: 1,
defaults: { format: 'table', limit: 100 },
state: { current_app: 'app-x' },
})
const out = runConfigView({ store: getConfigurationStore(), json: true })
const out = await runConfigView({ store: getConfigurationStore(), json: true })
const parsed = JSON.parse(out) as Record<string, unknown>
expect(parsed['defaults.format']).toBe('table')
expect(parsed['defaults.limit']).toBe(100)
expect(parsed['state.current_app']).toBe('app-x')
})
it('json format: trailing newline matches Go encoder.Encode', () => {
const out = runConfigView({ store: getConfigurationStore(), json: true })
it('json format: trailing newline matches Go encoder.Encode', async () => {
const out = await runConfigView({ store: getConfigurationStore(), json: true })
expect(out.endsWith('\n')).toBe(true)
})
})

View File

@ -11,8 +11,8 @@ export type RunConfigViewOptions = {
type ViewOut = Record<string, number | string>
export function runConfigView(opts: RunConfigViewOptions): string {
const loaded = loadConfig(opts.store)
export async function runConfigView(opts: RunConfigViewOptions): Promise<string> {
const loaded = await loadConfig(opts.store)
const config: ConfigFile = loaded.found ? loaded.config : emptyConfig()
const out = collect(config)
if (opts.json)

View File

@ -7,18 +7,18 @@ import { runUseAccount } from './use-account'
describe('runUseAccount', () => {
useTempConfigDir('difyctl-useacct-')
beforeEach(() => {
beforeEach(async () => {
const reg = Registry.empty('file')
reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
reg.upsert('h1', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } })
reg.setHost('h1')
reg.setAccount('a@x')
reg.save()
await reg.save()
})
it('switches current_account when email valid + token present', async () => {
await runUseAccount({ io: bufferStreams(), email: 'b@x', store: new MemStore({ 'h1 b@x': 'dfoa_b' }) })
expect(Registry.load().hosts.h1?.current_account).toBe('b@x')
expect((await Registry.load()).hosts.h1?.current_account).toBe('b@x')
})
it('errors when the account has no stored token', async () => {

View File

@ -21,7 +21,7 @@ const USE_HOST_HINT = 'run \'difyctl use host\' or \'difyctl auth login\''
export async function runUseAccount(opts: UseAccountOptions): Promise<void> {
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
const reg = Registry.load()
const reg = await Registry.load()
if (reg.current_host === undefined)
throw notLoggedInError(USE_HOST_HINT)
const host = reg.current_host
@ -39,7 +39,7 @@ export async function runUseAccount(opts: UseAccountOptions): Promise<void> {
}
const store = opts.store ?? getTokenStore(reg.token_storage)
if (store.read(host, target) === '') {
if (await store.read(host, target) === '') {
throw new BaseError({
code: ErrorCode.NotLoggedIn,
message: `no credential stored for ${target} on ${host}`,
@ -48,7 +48,7 @@ export async function runUseAccount(opts: UseAccountOptions): Promise<void> {
}
reg.setAccount(target)
reg.save()
await reg.save()
opts.io.out.write(`${cs.successIcon()} Active account on ${host} is now ${target}\n`)
}

View File

@ -6,18 +6,18 @@ import { runUseHost } from './use-host'
describe('runUseHost', () => {
useTempConfigDir('difyctl-usehost-')
beforeEach(() => {
beforeEach(async () => {
const reg = Registry.empty('file')
reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
reg.upsert('h2', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } })
reg.setHost('h1')
reg.setAccount('a@x')
reg.save()
await reg.save()
})
it('switches current_host when host is valid', async () => {
await runUseHost({ io: bufferStreams(), host: 'h2' })
expect(Registry.load().current_host).toBe('h2')
expect((await Registry.load()).current_host).toBe('h2')
})
it('errors when host is unknown, listing valid hosts', async () => {
@ -31,7 +31,7 @@ describe('runUseHost', () => {
})
it('errors when no hosts exist', async () => {
Registry.empty('file').save()
await Registry.empty('file').save()
await expect(runUseHost({ io: bufferStreams(), host: 'h1' })).rejects.toThrow(/no hosts|not logged in/i)
})
})

View File

@ -14,7 +14,7 @@ type HostChoice = { host: string, accounts: number, active: boolean }
export async function runUseHost(opts: UseHostOptions): Promise<void> {
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
const reg = Registry.load()
const reg = await Registry.load()
const hosts = Object.keys(reg.hosts)
if (hosts.length === 0)
throw notLoggedInError()
@ -28,7 +28,7 @@ export async function runUseHost(opts: UseHostOptions): Promise<void> {
}
reg.setHost(target)
reg.save()
await reg.save()
opts.io.out.write(`${cs.successIcon()} Active host is now ${target}\n`)
}

View File

@ -72,7 +72,7 @@ describe('runUseWorkspace', () => {
it('arg path: switches directly without listing and persists only the active workspace', async () => {
const io = bufferStreams()
const reg = makeRegistry()
reg.save()
await reg.save()
const active = makeActive(reg)
const client = fakeClient({})
@ -94,7 +94,7 @@ describe('runUseWorkspace', () => {
expect(activeCtx?.ctx.workspace).toEqual({ id: '00000000-0000-0000-0000-000000000002', name: 'Two', role: 'owner' })
expect((activeCtx?.ctx as Record<string, unknown> | undefined)?.available_workspaces).toBeUndefined()
const reloaded = Registry.load()
const reloaded = await Registry.load()
const reloadedActive = reloaded?.resolveActive()
expect(reloadedActive?.ctx.workspace?.id).toBe('00000000-0000-0000-0000-000000000002')
expect(reloadedActive?.ctx.workspace?.name).toBe('Two')
@ -107,7 +107,7 @@ describe('runUseWorkspace', () => {
const io = bufferStreams()
io.isErrTTY = false
const reg = makeRegistry()
reg.save()
await reg.save()
const active = makeActive(reg)
const client = fakeClient({})
@ -125,9 +125,9 @@ describe('runUseWorkspace', () => {
it('switch failure: rejects and leaves the active workspace untouched', async () => {
const io = bufferStreams()
const reg = makeRegistry()
reg.save()
await reg.save()
const active = makeActive(reg)
const before = Registry.load()
const before = await Registry.load()
const client = fakeClient({
switch: () => Promise.reject(new Error('forbidden')),
@ -140,7 +140,7 @@ describe('runUseWorkspace', () => {
),
).rejects.toThrow(/forbidden/)
const after = Registry.load()
const after = await Registry.load()
expect(after).toEqual(before)
expect(after?.resolveActive()?.ctx.workspace?.id).toBe('ws-1')
})
@ -149,7 +149,7 @@ describe('runUseWorkspace', () => {
const io = bufferStreams()
io.isErrTTY = true
const reg = makeRegistry()
reg.save()
await reg.save()
const active = makeActive(reg)
const client = fakeClient({})
@ -169,7 +169,7 @@ describe('runUseWorkspace', () => {
])
expect(client.switch).toHaveBeenCalledExactlyOnceWith('00000000-0000-0000-0000-000000000002')
const reloadedActive = Registry.load()?.resolveActive()
const reloadedActive = (await Registry.load())?.resolveActive()
expect(reloadedActive?.ctx.workspace?.id).toBe('00000000-0000-0000-0000-000000000002')
})
})

View File

@ -51,7 +51,7 @@ export async function runUseWorkspace(
workspace: { id: detail.id, name: detail.name, role: detail.role },
}
deps.reg.upsert(deps.active.host, deps.active.email, nextCtx)
deps.reg.save()
await deps.reg.save()
deps.io.out.write(`${cs.successIcon()} Switched to ${detail.name} (${detail.id})\n`)
return deps.reg
}

View File

@ -9,26 +9,26 @@ import { loadConfig } from './config-loader'
describe('loadConfig', () => {
useTempConfigDir('difyctl-cfg-')
it('returns found:false when config is missing', () => {
const r = loadConfig(getConfigurationStore())
it('returns found:false when config is missing', async () => {
const r = await loadConfig(getConfigurationStore())
expect(r.found).toBe(false)
})
it('parses a minimal valid config', () => {
getConfigurationStore().setTyped({ schema_version: 1 })
const r = loadConfig(getConfigurationStore())
it('parses a minimal valid config', async () => {
await getConfigurationStore().setTyped({ schema_version: 1 })
const r = await loadConfig(getConfigurationStore())
expect(r.found).toBe(true)
if (r.found)
expect(r.config.schema_version).toBe(1)
})
it('parses defaults + state', () => {
getConfigurationStore().setTyped({
it('parses defaults + state', async () => {
await getConfigurationStore().setTyped({
schema_version: 1,
defaults: { format: 'json', limit: 100 },
state: { current_app: 'app-1' },
})
const r = loadConfig(getConfigurationStore())
const r = await loadConfig(getConfigurationStore())
expect(r.found).toBe(true)
if (r.found) {
expect(r.config.defaults.format).toBe('json')
@ -37,7 +37,7 @@ describe('loadConfig', () => {
}
})
it('throws BaseError(config_schema_unsupported) when the store fails to parse the file', () => {
it('throws BaseError(config_schema_unsupported) when the store fails to parse the file', async () => {
// Simulate a corrupt on-disk file via a fake store; loadConfig must wrap
// the underlying error as ConfigSchemaUnsupported.
const throwingStore = {
@ -45,7 +45,7 @@ describe('loadConfig', () => {
} as unknown as YamlStore
let caught: unknown
try {
loadConfig(throwingStore)
await loadConfig(throwingStore)
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@ -55,11 +55,11 @@ describe('loadConfig', () => {
}
})
it('throws BaseError(config_schema_unsupported) when zod validation fails', () => {
getConfigurationStore().setTyped({ defaults: { limit: 9999 } })
it('throws BaseError(config_schema_unsupported) when zod validation fails', async () => {
await getConfigurationStore().setTyped({ defaults: { limit: 9999 } })
let caught: unknown
try {
loadConfig(getConfigurationStore())
await loadConfig(getConfigurationStore())
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@ -67,11 +67,11 @@ describe('loadConfig', () => {
expect(caught.code).toBe(ErrorCode.ConfigSchemaUnsupported)
})
it('throws BaseError(config_schema_unsupported) when schema_version > 1 (forward-refuse)', () => {
getConfigurationStore().setTyped({ schema_version: 2 })
it('throws BaseError(config_schema_unsupported) when schema_version > 1 (forward-refuse)', async () => {
await getConfigurationStore().setTyped({ schema_version: 2 })
let caught: unknown
try {
loadConfig(getConfigurationStore())
await loadConfig(getConfigurationStore())
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)

View File

@ -8,10 +8,10 @@ export type LoadResult
= | { found: false }
| { found: true, config: ConfigFile }
export function loadConfig(store: YamlStore): LoadResult {
export async function loadConfig(store: YamlStore): Promise<LoadResult> {
let raw: Record<string, unknown> | null
try {
raw = store.getTyped<Record<string, unknown>>()
raw = await store.getTyped<Record<string, unknown>>()
}
catch (err) {
throw newError(

View File

@ -8,32 +8,32 @@ import { getConfigurationStore } from './manager'
describe('saveConfig', () => {
useTempConfigDir('difyctl-w-')
it('stamps schema_version=1 even if caller passed 0', () => {
saveConfig(getConfigurationStore(), { ...emptyConfig() })
const r = loadConfig(getConfigurationStore())
it('stamps schema_version=1 even if caller passed 0', async () => {
await saveConfig(getConfigurationStore(), { ...emptyConfig() })
const r = await loadConfig(getConfigurationStore())
expect(r.found).toBe(true)
if (r.found)
expect(r.config.schema_version).toBe(1)
})
it('overrides a stale schema_version on save', () => {
saveConfig(getConfigurationStore(), {
it('overrides a stale schema_version on save', async () => {
await saveConfig(getConfigurationStore(), {
...emptyConfig(),
schema_version: 999 as never,
})
const r = loadConfig(getConfigurationStore())
const r = await loadConfig(getConfigurationStore())
expect(r.found).toBe(true)
if (r.found)
expect(r.config.schema_version).toBe(1)
})
it('round-trips defaults + state', () => {
saveConfig(getConfigurationStore(), {
it('round-trips defaults + state', async () => {
await saveConfig(getConfigurationStore(), {
schema_version: 1,
defaults: { format: 'wide', limit: 75 },
state: { current_app: 'app-xyz' },
})
const r = loadConfig(getConfigurationStore())
const r = await loadConfig(getConfigurationStore())
expect(r.found).toBe(true)
if (r.found) {
expect(r.config.defaults.format).toBe('wide')
@ -42,18 +42,18 @@ describe('saveConfig', () => {
}
})
it('overwrites the previous config on resave', () => {
saveConfig(getConfigurationStore(), {
it('overwrites the previous config on resave', async () => {
await saveConfig(getConfigurationStore(), {
schema_version: 1,
defaults: { format: 'json' },
state: {},
})
saveConfig(getConfigurationStore(), {
await saveConfig(getConfigurationStore(), {
schema_version: 1,
defaults: { format: 'table' },
state: { current_app: 'app-2' },
})
const r = loadConfig(getConfigurationStore())
const r = await loadConfig(getConfigurationStore())
expect(r.found).toBe(true)
if (r.found) {
expect(r.config.defaults.format).toBe('table')

View File

@ -2,7 +2,7 @@ import type { YamlStore } from './store'
import type { ConfigFile } from '@/config/schema'
import { CURRENT_SCHEMA_VERSION } from '@/config/schema'
export function saveConfig(store: YamlStore, config: ConfigFile): void {
export async function saveConfig(store: YamlStore, config: ConfigFile): Promise<void> {
const stamped: ConfigFile = { ...config, schema_version: CURRENT_SCHEMA_VERSION }
store.setTyped(stamped)
await store.setTyped(stamped)
}

View File

@ -37,7 +37,7 @@ class FakeEntry {
}
vi.mock('@napi-rs/keyring', () => ({
Entry: FakeEntry,
AsyncEntry: FakeEntry,
}))
const { KeychainTokenStore } = await import('./token-store')
@ -52,68 +52,68 @@ beforeEach(() => {
})
describe('KeychainTokenStore', () => {
it('round-trips a bearer through write/read', () => {
it('round-trips a bearer through write/read', async () => {
const store = new KeychainTokenStore(SERVICE)
store.write('https://cloud.dify.ai', 'a@x.com', 'dfoa_secret')
expect(store.read('https://cloud.dify.ai', 'a@x.com')).toBe('dfoa_secret')
await store.write('https://cloud.dify.ai', 'a@x.com', 'dfoa_secret')
expect(await store.read('https://cloud.dify.ai', 'a@x.com')).toBe('dfoa_secret')
})
it('returns empty string for an absent credential', () => {
it('returns empty string for an absent credential', async () => {
const store = new KeychainTokenStore(SERVICE)
expect(store.read('https://cloud.dify.ai', 'missing@x.com')).toBe('')
expect(await store.read('https://cloud.dify.ai', 'missing@x.com')).toBe('')
})
it('removes a credential, after which read returns empty string', () => {
it('removes a credential, after which read returns empty string', async () => {
const store = new KeychainTokenStore(SERVICE)
store.write('h', 'e', 'dfoa_secret')
store.remove('h', 'e')
expect(store.read('h', 'e')).toBe('')
await store.write('h', 'e', 'dfoa_secret')
await store.remove('h', 'e')
expect(await store.read('h', 'e')).toBe('')
})
it('treats remove of an absent credential as a no-op', () => {
it('treats remove of an absent credential as a no-op', async () => {
const store = new KeychainTokenStore(SERVICE)
expect(() => store.remove('h', 'absent')).not.toThrow()
await expect(store.remove('h', 'absent')).resolves.not.toThrow()
})
it('uses the legacy entry name tokens.<host>.<email> (back-compat)', () => {
it('uses the legacy entry name tokens.<host>.<email> (back-compat)', async () => {
const store = new KeychainTokenStore(SERVICE)
store.write('https://cloud.dify.ai', 'a@x.com', 'dfoa_secret')
await store.write('https://cloud.dify.ai', 'a@x.com', 'dfoa_secret')
expect(constructed).toContainEqual({
service: SERVICE,
username: 'tokens.https://cloud.dify.ai.a@x.com',
})
})
it('keeps host and email literal — dots, colons, and @ are never split', () => {
it('keeps host and email literal — dots, colons, and @ are never split', async () => {
const store = new KeychainTokenStore(SERVICE)
const host = 'https://my.dify.example.com:8443'
const email = 'first.last@sub.example.com'
store.write(host, email, 'dfoa_literal')
expect(store.read(host, email)).toBe('dfoa_literal')
await store.write(host, email, 'dfoa_literal')
expect(await store.read(host, email)).toBe('dfoa_literal')
expect(constructed).toContainEqual({
service: SERVICE,
username: `tokens.${host}.${email}`,
})
})
it('returns empty string when the stored value decodes to a non-string', () => {
it('returns empty string when the stored value decodes to a non-string', async () => {
const store = new KeychainTokenStore(SERVICE)
passwords.set(`${SERVICE}::tokens.h.e`, '123')
expect(store.read('h', 'e')).toBe('')
expect(await store.read('h', 'e')).toBe('')
})
it('returns empty string when the stored value is not valid JSON', () => {
it('returns empty string when the stored value is not valid JSON', async () => {
const store = new KeychainTokenStore(SERVICE)
passwords.set(`${SERVICE}::tokens.h.e`, 'not-json')
expect(store.read('h', 'e')).toBe('')
expect(await store.read('h', 'e')).toBe('')
})
it('throws KeyringUnavailable (not empty string) when keyring access fails on read', () => {
it('throws KeyringUnavailable (not empty string) when keyring access fails on read', async () => {
getPasswordError = new Error('keyring locked')
const store = new KeychainTokenStore(SERVICE)
let caught: unknown
try {
store.read('h', 'e')
await store.read('h', 'e')
}
catch (err) {
caught = err
@ -122,12 +122,12 @@ describe('KeychainTokenStore', () => {
expect((caught as BaseError).code).toBe(ErrorCode.KeyringUnavailable)
})
it('throws KeyringUnavailable when keyring access fails on write', () => {
it('throws KeyringUnavailable when keyring access fails on write', async () => {
setPasswordError = new Error('keyring locked')
const store = new KeychainTokenStore(SERVICE)
let caught: unknown
try {
store.write('h', 'e', 'dfoa_secret')
await store.write('h', 'e', 'dfoa_secret')
}
catch (err) {
caught = err

View File

@ -11,17 +11,17 @@ class FakeEntry {
this.key = `${service}::${username}`
}
setPassword(value: string): void {
async setPassword(value: string): Promise<void> {
setPassword(this.key, value)
passwords.set(this.key, value)
}
getPassword(): string | null {
async getPassword(): Promise<string | undefined> {
getPassword(this.key)
return passwords.get(this.key) ?? null
return passwords.get(this.key) ?? undefined
}
deletePassword(): boolean {
async deletePassword(): Promise<boolean> {
deletePassword(this.key)
if (!passwords.has(this.key))
return false
@ -31,7 +31,7 @@ class FakeEntry {
}
vi.mock('@napi-rs/keyring', () => ({
Entry: FakeEntry,
AsyncEntry: FakeEntry,
}))
const { KeyringBasedStore } = await import('./store')
@ -46,64 +46,64 @@ beforeEach(() => {
})
describe('KeyringBasedStore', () => {
it('returns default when entry missing', () => {
it('returns default when entry missing', async () => {
const s = new KeyringBasedStore(SERVICE)
expect(s.get({ key: 'k', default: 'fallback' })).toBe('fallback')
expect(await s.get({ key: 'k', default: 'fallback' })).toBe('fallback')
})
it('round-trips strings via JSON encoding', () => {
it('round-trips strings via JSON encoding', async () => {
const s = new KeyringBasedStore(SERVICE)
s.set({ key: 'k', default: '' }, 'tok-abc')
expect(s.get({ key: 'k', default: '' })).toBe('tok-abc')
await s.set({ key: 'k', default: '' }, 'tok-abc')
expect(await s.get({ key: 'k', default: '' })).toBe('tok-abc')
})
it('isolates entries by key', () => {
it('isolates entries by key', async () => {
const s = new KeyringBasedStore(SERVICE)
s.set({ key: 'a', default: '' }, 'A')
s.set({ key: 'b', default: '' }, 'B')
expect(s.get({ key: 'a', default: '' })).toBe('A')
expect(s.get({ key: 'b', default: '' })).toBe('B')
await s.set({ key: 'a', default: '' }, 'A')
await s.set({ key: 'b', default: '' }, 'B')
expect(await s.get({ key: 'a', default: '' })).toBe('A')
expect(await s.get({ key: 'b', default: '' })).toBe('B')
})
it('unset removes the entry', () => {
it('unset removes the entry', async () => {
const s = new KeyringBasedStore(SERVICE)
s.set({ key: 'k', default: '' }, 'v')
s.unset({ key: 'k', default: '' })
expect(s.get({ key: 'k', default: '' })).toBe('')
await s.set({ key: 'k', default: '' }, 'v')
await s.unset({ key: 'k', default: '' })
expect(await s.get({ key: 'k', default: '' })).toBe('')
})
it('unset is a no-op when entry missing', () => {
it('unset is a no-op when entry missing', async () => {
const s = new KeyringBasedStore(SERVICE)
expect(() => s.unset({ key: 'gone', default: '' })).not.toThrow()
await expect(s.unset({ key: 'gone', default: '' })).resolves.not.toThrow()
})
it('swallows getPassword exceptions and returns default', () => {
it('swallows getPassword exceptions and returns default', async () => {
const s = new KeyringBasedStore(SERVICE)
getPassword.mockImplementationOnce(
() => {
throw new Error('NoEntry')
},
)
expect(s.get({ key: 'k', default: 'd' })).toBe('d')
expect(await s.get({ key: 'k', default: 'd' })).toBe('d')
})
it('swallows unset exceptions', () => {
it('swallows unset exceptions', async () => {
const s = new KeyringBasedStore(SERVICE)
deletePassword.mockImplementationOnce(
() => {
throw new Error('NoEntry')
},
)
expect(() => s.unset({ key: 'k', default: '' })).not.toThrow()
await expect(s.unset({ key: 'k', default: '' })).resolves.not.toThrow()
})
it('lets set propagate exceptions (caller decides fallback)', () => {
it('lets set propagate exceptions (caller decides fallback)', async () => {
const s = new KeyringBasedStore(SERVICE)
setPassword.mockImplementationOnce(
() => {
throw new Error('keyring locked')
},
)
expect(() => s.set({ key: 'k', default: '' }, 'v')).toThrow(/keyring locked/)
await expect(s.set({ key: 'k', default: '' }, 'v')).rejects.toThrow(/keyring locked/)
})
})

View File

@ -7,56 +7,56 @@ function memStore(label: string): TokenStore & { _label: string } {
const k = (h: string, e: string): string => `${h} ${e}`
return {
_label: label,
read(host: string, email: string): string {
async read(host: string, email: string): Promise<string> {
return map.get(k(host, email)) ?? ''
},
write(host: string, email: string, bearer: string): void {
async write(host: string, email: string, bearer: string): Promise<void> {
map.set(k(host, email), bearer)
},
remove(host: string, email: string): void {
async remove(host: string, email: string): Promise<void> {
map.delete(k(host, email))
},
}
}
describe('detectTokenStore', () => {
it('returns keychain store when probe succeeds', () => {
it('returns keychain store when probe succeeds', async () => {
const k = memStore('keyring')
const f = memStore('file')
const result = detectTokenStore({
const result = await detectTokenStore({
factory: { keyring: () => k, file: () => f },
})
expect(result.mode).toBe('keychain')
expect(result.store).toBe(k)
})
it('falls back to file when keyring set throws', () => {
it('falls back to file when keyring set throws', async () => {
const k = memStore('keyring')
const f = memStore('file')
k.write = vi.fn(() => {
throw new Error('locked')
})
const result = detectTokenStore({
}) as TokenStore['write']
const result = await detectTokenStore({
factory: { keyring: () => k, file: () => f },
})
expect(result.mode).toBe('file')
expect(result.store).toBe(f)
})
it('falls back to file when probe round-trip mismatches', () => {
it('falls back to file when probe round-trip mismatches', async () => {
const k = memStore('keyring')
const f = memStore('file')
k.read = vi.fn(() => 'something-else') as TokenStore['read']
const result = detectTokenStore({
k.read = vi.fn(async () => 'something-else') as TokenStore['read']
const result = await detectTokenStore({
factory: { keyring: () => k, file: () => f },
})
expect(result.mode).toBe('file')
expect(result.store).toBe(f)
})
it('falls back to file when keyring constructor throws', () => {
it('falls back to file when keyring constructor throws', async () => {
const f = memStore('file')
const result = detectTokenStore({
const result = await detectTokenStore({
factory: {
keyring: () => { throw new Error('no backend') },
file: () => f,
@ -66,23 +66,23 @@ describe('detectTokenStore', () => {
expect(result.store).toBe(f)
})
it('cleans up probe entry after successful probe', () => {
it('cleans up probe entry after successful probe', async () => {
const k = memStore('keyring')
const f = memStore('file')
detectTokenStore({
await detectTokenStore({
factory: { keyring: () => k, file: () => f },
})
expect(k.read('__difyctl_probe__', '__difyctl_probe__')).toBe('')
expect(await k.read('__difyctl_probe__', '__difyctl_probe__')).toBe('')
})
it('removes the probe entry even when the probe read throws', () => {
it('removes the probe entry even when the probe read throws', async () => {
const k = memStore('keyring')
const f = memStore('file')
const removeSpy = vi.spyOn(k, 'remove')
k.read = vi.fn(() => {
throw new Error('read boom')
}) as TokenStore['read']
const result = detectTokenStore({
const result = await detectTokenStore({
factory: { keyring: () => k, file: () => f },
})
expect(removeSpy).toHaveBeenCalledWith('__difyctl_probe__', '__difyctl_probe__')
@ -97,7 +97,7 @@ describe('getTokenStore', () => {
const f = memStore('file')
k.write = vi.fn(() => {
throw new Error('probe must never run on the read path')
})
}) as TokenStore['write']
const store = getTokenStore('keychain', {
factory: { keyring: () => k, file: () => f },
})

View File

@ -54,20 +54,20 @@ const TOKEN_STORE_OPENERS: Record<StorageMode, (opts: GetTokenStoreOptions) => T
* write/read/remove round-trip. The probe MUTATES the keyring, so call this
* only where a credential is about to be written anyway (login).
*/
export function detectTokenStore(opts: GetTokenStoreOptions = {}): { store: TokenStore, mode: StorageMode } {
export async function detectTokenStore(opts: GetTokenStoreOptions = {}): Promise<{ store: TokenStore, mode: StorageMode }> {
// DIFY_E2E_NO_KEYRING=1 forces file-based storage in E2E tests to avoid
// macOS keychain UI prompts blocking child processes spawned by vitest.
if (process.env.DIFY_E2E_NO_KEYRING === '1')
return { store: TOKEN_STORE_OPENERS.file(opts), mode: 'file' }
try {
const k = TOKEN_STORE_OPENERS.keychain(opts)
k.write(PROBE_HOST, PROBE_EMAIL, PROBE_VALUE)
await k.write(PROBE_HOST, PROBE_EMAIL, PROBE_VALUE)
let got = ''
try {
got = k.read(PROBE_HOST, PROBE_EMAIL)
got = await k.read(PROBE_HOST, PROBE_EMAIL)
}
finally {
k.remove(PROBE_HOST, PROBE_EMAIL)
await k.remove(PROBE_HOST, PROBE_EMAIL)
}
if (got === PROBE_VALUE)
return { store: k, mode: 'keychain' }

View File

@ -108,13 +108,13 @@ describe('FileBasedStore.withLock concurrency', () => {
const s1 = new YamlStore(path)
const s2 = new YamlStore(path)
s1.lock()
await s1.lock()
expect(() => s2.get({ key: 'key', default: '' })).toThrow(ConcurrentAccessError)
await expect(s2.get({ key: 'key', default: '' })).rejects.toThrow(ConcurrentAccessError)
s1.unlock()
await s1.unlock()
expect(s2.get({ key: 'key', default: '' })).toBe('value')
expect(await s2.get({ key: 'key', default: '' })).toBe('value')
})
it('second set throws while first holds the lock, succeeds after release', async () => {
@ -124,14 +124,14 @@ describe('FileBasedStore.withLock concurrency', () => {
const s1 = new YamlStore(path)
const s2 = new YamlStore(path)
s1.lock()
await s1.lock()
expect(() => s2.set({ key: 'key', default: '' }, 'blocked')).toThrow(ConcurrentAccessError)
await expect(s2.set({ key: 'key', default: '' }, 'blocked')).rejects.toThrow(ConcurrentAccessError)
s1.unlock()
await s1.unlock()
s2.set({ key: 'key', default: '' }, 'written')
expect(s2.get({ key: 'key', default: '' })).toBe('written')
await s2.set({ key: 'key', default: '' }, 'written')
expect(await s2.get({ key: 'key', default: '' })).toBe('written')
})
})
@ -199,9 +199,9 @@ describe('YamlStore persistence', () => {
await writeFile(path, 'existing: value\n')
const store = new YamlStore(path)
store.load()
await store.load()
store.doSet({ key: 'token', default: '' }, 'abc-123')
store.flush()
await store.flush()
const raw = readFileSync(path, 'utf8')
const store2 = new YamlStore(path)
@ -210,11 +210,11 @@ describe('YamlStore persistence', () => {
expect(store2.doGet({ key: 'existing', default: '' })).toBe('value')
})
it('flush writes file when dirty (content changed from undefined)', () => {
it('flush writes file when dirty (content changed from undefined)', async () => {
const path = join(dir, 'config.yml')
const store = new YamlStore(path)
store.setRawContent('key: value\n')
store.flush()
await store.flush()
expect(existsSync(path)).toBe(true)
expect(readFileSync(path, 'utf8')).toBe('key: value\n')
})
@ -223,10 +223,10 @@ describe('YamlStore persistence', () => {
const path = join(dir, 'config.yml')
await writeFile(path, 'key: value\n')
const store = new YamlStore(path)
store.load()
await store.load()
const mtime = statSync(path).mtimeMs
store.setRawContent('key: value\n')
store.flush()
await store.flush()
expect(statSync(path).mtimeMs).toBe(mtime)
})
})

View File

@ -1,7 +1,8 @@
import type { Options as LockOptions } from 'lockfile'
import type { Platform } from '@/sys'
import fs from 'node:fs'
import { promises as fsp } from 'node:fs'
import { dirname } from 'node:path'
import { Entry } from '@napi-rs/keyring'
import { AsyncEntry } from '@napi-rs/keyring'
import yaml from 'js-yaml'
import lockfile from 'lockfile'
import { pid, resolvePlatform } from '@/sys'
@ -9,6 +10,19 @@ import { BadYamlFormatError, ConcurrentAccessError } from './errors'
const FILE_PERM = 0o600
const DIR_PERM = 0o700
const LOCK_STALE_MS = 30_000
function lockAsync(path: string, opts: LockOptions): Promise<void> {
return new Promise((resolve, reject) => {
lockfile.lock(path, opts, err => (err ? reject(err) : resolve()))
})
}
function unlockAsync(path: string): Promise<void> {
return new Promise((resolve, reject) => {
lockfile.unlock(path, err => (err ? reject(err) : resolve()))
})
}
export type Key<T> = {
default: T
@ -16,9 +30,9 @@ export type Key<T> = {
}
export type Store = {
get: <T>(key: Key<T>) => T
set: <T>(key: Key<T>, value: T) => void
unset: <T>(key: Key<T>) => void
get: <T>(key: Key<T>) => Promise<T>
set: <T>(key: Key<T>, value: T) => Promise<void>
unset: <T>(key: Key<T>) => Promise<void>
}
export const STORAGE_MODES = ['keychain', 'file'] as const
@ -35,18 +49,18 @@ abstract class FileBasedStore implements Store {
this.platform = resolvePlatform()
}
private ensureDir(): void {
fs.mkdirSync(dirname(this.filePath), { recursive: true, mode: DIR_PERM })
private async ensureDir(): Promise<void> {
await fsp.mkdir(dirname(this.filePath), { recursive: true, mode: DIR_PERM })
}
unlock(): void {
lockfile.unlockSync(`${this.filePath}.lock`)
async unlock(): Promise<void> {
await unlockAsync(`${this.filePath}.lock`)
}
/**
* atomically write raw_content (if any)
*/
flush(): void {
async flush(): Promise<void> {
// we don't handle A-B-A scenario,
// which is not likely to happen in cli
if (!this.dirty) {
@ -54,15 +68,15 @@ abstract class FileBasedStore implements Store {
}
if (this.rawContent !== undefined) {
this.ensureDir()
await this.ensureDir()
const tmp = `${this.filePath}.tmp.${pid()}.${Date.now()}`
try {
fs.writeFileSync(tmp, this.rawContent, { mode: FILE_PERM })
await fsp.writeFile(tmp, this.rawContent, { mode: FILE_PERM })
this.platform.atomicReplace(tmp, this.filePath)
}
catch (err) {
try {
fs.unlinkSync(tmp)
await fsp.unlink(tmp)
}
catch { /* tmp may not exist */ }
throw err
@ -72,11 +86,11 @@ abstract class FileBasedStore implements Store {
this.dirty = false
}
lock(): void {
this.ensureDir()
async lock(): Promise<void> {
await this.ensureDir()
try {
lockfile.lockSync(`${this.filePath}.lock`, {
stale: 30_000,
await lockAsync(`${this.filePath}.lock`, {
stale: LOCK_STALE_MS,
})
}
catch (err) {
@ -88,9 +102,9 @@ abstract class FileBasedStore implements Store {
}
}
load(): void {
async load(): Promise<void> {
try {
this.rawContent = fs.readFileSync(this.filePath, 'utf8')
this.rawContent = await fsp.readFile(this.filePath, 'utf8')
this.dirty = false
}
catch (err) {
@ -110,45 +124,45 @@ abstract class FileBasedStore implements Store {
return this.rawContent
}
protected withLock<R>(body: () => R): R {
this.lock()
protected async withLock<R>(body: () => R | Promise<R>): Promise<R> {
await this.lock()
try {
return body()
return await body()
}
finally {
this.unlock()
await this.unlock()
}
}
get<T>(key: Key<T>): T {
return this.withLock(() => {
this.load()
async get<T>(key: Key<T>): Promise<T> {
return this.withLock(async () => {
await this.load()
return this.doGet(key)
})
}
set<T>(key: Key<T>, value: T) {
this.withLock(() => {
this.load()
async set<T>(key: Key<T>, value: T): Promise<void> {
await this.withLock(async () => {
await this.load()
this.doSet(key, value)
this.flush()
await this.flush()
})
}
unset<T>(key: Key<T>): void {
this.withLock(() => {
this.load()
async unset<T>(key: Key<T>): Promise<void> {
await this.withLock(async () => {
await this.load()
this.doUnset(key)
this.flush()
await this.flush()
})
}
/**
* Remove the underlying file of the store. No-op if file doesn't exist.
*/
rm(): void {
async rm(): Promise<void> {
try {
fs.unlinkSync(this.filePath)
await fsp.unlink(this.filePath)
}
catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT')
@ -178,18 +192,18 @@ export class YamlStore extends FileBasedStore {
return (current as T) ?? key.default
}
getTyped<T>(): T | null {
return this.withLock(() => {
this.load()
async getTyped<T>(): Promise<T | null> {
return this.withLock(async () => {
await this.load()
return loadYaml(this.getRawContent(), this.filePath) as T
})
}
setTyped<T>(data: T): void {
this.withLock(() => {
this.load()
async setTyped<T>(data: T): Promise<void> {
await this.withLock(async () => {
await this.load()
this.setRawContent(yaml.dump(data, { lineWidth: -1, noRefs: true }))
this.flush()
await this.flush()
})
}
@ -254,9 +268,9 @@ export class KeyringBasedStore implements Store {
this.service = service
}
get<T>(key: Key<T>): T {
async get<T>(key: Key<T>): Promise<T> {
try {
const v = new Entry(this.service, key.key).getPassword()
const v = await new AsyncEntry(this.service, key.key).getPassword()
if (v === null || v === undefined || v === '')
return key.default
return JSON.parse(v) as T
@ -266,13 +280,13 @@ export class KeyringBasedStore implements Store {
}
}
set<T>(key: Key<T>, value: T): void {
new Entry(this.service, key.key).setPassword(JSON.stringify(value))
async set<T>(key: Key<T>, value: T): Promise<void> {
await new AsyncEntry(this.service, key.key).setPassword(JSON.stringify(value))
}
unset<T>(key: Key<T>): void {
async unset<T>(key: Key<T>): Promise<void> {
try {
new Entry(this.service, key.key).deletePassword()
await new AsyncEntry(this.service, key.key).deletePassword()
}
catch { /* missing entry is fine */ }
}

View File

@ -14,68 +14,68 @@ describe('FileTokenStore', () => {
})
afterEach(() => rmSync(dir, { recursive: true, force: true }))
it('returns empty string for a missing credential', () => {
it('returns empty string for a missing credential', async () => {
const s = new FileTokenStore(file)
expect(s.read('https://cloud.dify.ai', 'a@x.com')).toBe('')
expect(await s.read('https://cloud.dify.ai', 'a@x.com')).toBe('')
})
it('round-trips a bearer with dots and @ kept literal', () => {
it('round-trips a bearer with dots and @ kept literal', async () => {
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')
await s.write('https://cloud.dify.ai', 'a.b@x.com', 'dfoa_secret')
expect(await s.read('https://cloud.dify.ai', 'a.b@x.com')).toBe('dfoa_secret')
})
it('keeps multiple accounts under one host and isolates hosts', () => {
it('keeps multiple accounts under one host and isolates hosts', async () => {
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')
await s.write('https://cloud.dify.ai', 'a@x.com', 'A')
await s.write('https://cloud.dify.ai', 'b@x.com', 'B')
await s.write('https://self.example.com', 'a@x.com', 'C')
expect(await s.read('https://cloud.dify.ai', 'a@x.com')).toBe('A')
expect(await s.read('https://cloud.dify.ai', 'b@x.com')).toBe('B')
expect(await s.read('https://self.example.com', 'a@x.com')).toBe('C')
})
it('persists the versioned nested shape on disk', () => {
it('persists the versioned nested shape on disk', async () => {
const s = new FileTokenStore(file)
s.write('https://cloud.dify.ai', 'a@x.com', 'A')
await 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 an unknown future version', () => {
it('reads empty when the document version is an unknown future version', async () => {
writeFileSync(file, 'version: 999\ntokens:\n "h":\n "e": "x"\n')
const s = new FileTokenStore(file)
expect(s.read('h', 'e')).toBe('')
expect(await s.read('h', 'e')).toBe('')
})
it('reads tokens from legacy format (no version field) for transparent migration', () => {
it('reads tokens from legacy format (no version field) for transparent migration', async () => {
writeFileSync(file, 'tokens:\n "h":\n "e": "dfoa_legacy"\n')
const s = new FileTokenStore(file)
expect(s.read('h', 'e')).toBe('dfoa_legacy')
expect(await s.read('h', 'e')).toBe('dfoa_legacy')
})
it('preserves existing tokens and stamps version when writing to a legacy file', () => {
it('preserves existing tokens and stamps version when writing to a legacy file', async () => {
writeFileSync(file, 'tokens:\n "h":\n "existing@x": "dfoa_existing"\n')
const s = new FileTokenStore(file)
s.write('h', 'new@x', 'dfoa_new')
expect(s.read('h', 'existing@x')).toBe('dfoa_existing')
expect(s.read('h', 'new@x')).toBe('dfoa_new')
await s.write('h', 'new@x', 'dfoa_new')
expect(await s.read('h', 'existing@x')).toBe('dfoa_existing')
expect(await s.read('h', 'new@x')).toBe('dfoa_new')
expect(readFileSync(file, 'utf8')).toContain('version: 1')
})
it('remove deletes the credential and prunes the empty host map', () => {
it('remove deletes the credential and prunes the empty host map', async () => {
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('')
await s.write('https://cloud.dify.ai', 'a@x.com', 'A')
await s.remove('https://cloud.dify.ai', 'a@x.com')
expect(await 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', () => {
it('remove is a no-op for an absent credential', async () => {
const s = new FileTokenStore(file)
expect(() => s.remove('h', 'e')).not.toThrow()
await expect(s.remove('h', 'e')).resolves.not.toThrow()
})
})

View File

@ -1,4 +1,4 @@
import { Entry } from '@napi-rs/keyring'
import { AsyncEntry } from '@napi-rs/keyring'
import { BaseError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
import { YamlStore } from './store'
@ -7,9 +7,9 @@ import { YamlStore } from './store'
* Credential store keyed by an opaque (host, email) pair.
*/
export type TokenStore = {
read: (host: string, email: string) => string
write: (host: string, email: string, bearer: string) => void
remove: (host: string, email: string) => void
read: (host: string, email: string) => Promise<string>
write: (host: string, email: string, bearer: string) => Promise<void>
remove: (host: string, email: string) => Promise<void>
}
const DOC_VERSION = 1
@ -26,8 +26,8 @@ export class FileTokenStore implements TokenStore {
this.store = new YamlStore(filePath)
}
read(host: string, email: string): string {
const doc = this.store.getTyped<TokenDoc>()
async read(host: string, email: string): Promise<string> {
const doc = await this.store.getTyped<TokenDoc>()
if (doc === null)
return ''
// missing version = legacy pre-v1 format (same data shape); future unknown versions are rejected
@ -36,16 +36,16 @@ export class FileTokenStore implements TokenStore {
return doc.tokens?.[host]?.[email] ?? ''
}
write(host: string, email: string, bearer: string): void {
const doc = this.load()
async write(host: string, email: string, bearer: string): Promise<void> {
const doc = await this.load()
const hostMap = doc.tokens[host] ?? {}
hostMap[email] = bearer
doc.tokens[host] = hostMap
this.store.setTyped(doc)
await this.store.setTyped(doc)
}
remove(host: string, email: string): void {
const doc = this.store.getTyped<TokenDoc>()
async remove(host: string, email: string): Promise<void> {
const doc = await this.store.getTyped<TokenDoc>()
if (doc === null)
return
if (doc.version !== undefined && doc.version !== DOC_VERSION)
@ -57,11 +57,11 @@ export class FileTokenStore implements TokenStore {
delete hostMap[email]
if (Object.keys(hostMap).length === 0)
delete tokens[host]
this.store.setTyped({ version: DOC_VERSION, tokens })
await this.store.setTyped({ version: DOC_VERSION, tokens })
}
private load(): { version: number, tokens: Record<string, Record<string, string>> } {
const doc = this.store.getTyped<TokenDoc>()
private async load(): Promise<{ version: number, tokens: Record<string, Record<string, string>> }> {
const doc = await this.store.getTyped<TokenDoc>()
if (doc === null)
return { version: DOC_VERSION, tokens: {} }
if (doc.version !== undefined && doc.version !== DOC_VERSION)
@ -80,10 +80,10 @@ export class KeychainTokenStore implements TokenStore {
this.service = service
}
read(host: string, email: string): string {
let raw: string | null
async read(host: string, email: string): Promise<string> {
let raw: string | null | undefined
try {
raw = new Entry(this.service, entryName(host, email)).getPassword()
raw = await new AsyncEntry(this.service, entryName(host, email)).getPassword()
}
catch (err) {
throw keyringUnavailableError(err)
@ -99,18 +99,18 @@ export class KeychainTokenStore implements TokenStore {
}
}
write(host: string, email: string, bearer: string): void {
async write(host: string, email: string, bearer: string): Promise<void> {
try {
new Entry(this.service, entryName(host, email)).setPassword(JSON.stringify(bearer))
await new AsyncEntry(this.service, entryName(host, email)).setPassword(JSON.stringify(bearer))
}
catch (err) {
throw keyringUnavailableError(err)
}
}
remove(host: string, email: string): void {
async remove(host: string, email: string): Promise<void> {
try {
new Entry(this.service, entryName(host, email)).deletePassword()
await new AsyncEntry(this.service, entryName(host, email)).deletePassword()
}
catch { /* missing entry is fine */ }
}

View File

@ -166,7 +166,7 @@ describe('runVersionProbe', () => {
reg.setHost(url.host)
reg.setAccount('test@dify.ai')
reg.setScheme(url.host, url.protocol.replace(':', ''))
reg.save()
await reg.save()
process.env[ENV_CONFIG_DIR] = configDir
const report = await runVersionProbe({ skipServer: false })

View File

@ -48,7 +48,7 @@ export type RunVersionProbeOptions = {
}
const defaultLoadActive = async (): Promise<ActiveContext | undefined> => {
return Registry.load().resolveActive()
return (await Registry.load()).resolveActive()
}
const defaultProbe: MetaProbe = async (endpoint) => {

View File

@ -187,8 +187,13 @@ export function isE2ELocalMode(): boolean {
/**
* Resolve the E2E environment, merging capabilities (from global-setup) on top
* of the optional env-var overrides. Capabilities always take priority.
*
* `caps` may be undefined in local mode (DIFY_E2E_MODE=local), where
* global-setup returns early without calling project.provide().
*/
export function resolveEnv(caps: E2ECapabilities): E2EEnv {
export function resolveEnv(caps: E2ECapabilities | undefined): E2EEnv {
if (!caps)
return loadE2EEnv()
const env = loadE2EEnv()
return {
...env,

View File

@ -13,15 +13,15 @@ export class MemStore implements TokenStore {
return `${host} ${email}`
}
read(host: string, email: string): string {
async read(host: string, email: string): Promise<string> {
return this.entries.get(this.k(host, email)) ?? ''
}
write(host: string, email: string, bearer: string): void {
async write(host: string, email: string, bearer: string): Promise<void> {
this.entries.set(this.k(host, email), bearer)
}
remove(host: string, email: string): void {
async remove(host: string, email: string): Promise<void> {
this.entries.delete(this.k(host, email))
}
}