mirror of
https://github.com/langgenius/dify.git
synced 2026-06-16 22:11:09 +08:00
fix: change store apis to async (#37329)
This commit is contained in:
parent
e0773c4d8f
commit
d315ae3b80
@ -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()
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
14
cli/src/cache/app-info.ts
vendored
14
cli/src/cache/app-info.ts
vendored
@ -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)
|
||||
}
|
||||
|
||||
14
cli/src/cache/nudge-store.ts
vendored
14
cli/src/cache/nudge-store.ts
vendored
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
@ -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`)
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 !== '') {
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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`
|
||||
}
|
||||
|
||||
@ -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 }))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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`
|
||||
}
|
||||
|
||||
@ -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 }))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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`
|
||||
}
|
||||
|
||||
@ -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 }))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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`)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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`)
|
||||
}
|
||||
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 },
|
||||
})
|
||||
|
||||
@ -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' }
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 */ }
|
||||
}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 */ }
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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,
|
||||
|
||||
6
cli/test/fixtures/mem-store.ts
vendored
6
cli/test/fixtures/mem-store.ts
vendored
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user