diff --git a/cli/src/auth/hosts.test.ts b/cli/src/auth/hosts.test.ts index fc1c275691..112538ccbb 100644 --- a/cli/src/auth/hosts.test.ts +++ b/cli/src/auth/hosts.test.ts @@ -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() diff --git a/cli/src/auth/hosts.ts b/cli/src/auth/hosts.ts index 69066131d4..29305db951 100644 --- a/cli/src/auth/hosts.ts +++ b/cli/src/auth/hosts.ts @@ -71,8 +71,8 @@ export class Registry { this.data = data } - static load(): Registry { - const raw = getHostStore().getTyped>() + static async load(): Promise { + const raw = await getHostStore().getTyped>() 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 { 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 { + await getHostStore().setTyped(RegistrySchema.parse(this.data)) } } diff --git a/cli/src/cache/app-info.ts b/cli/src/cache/app-info.ts index f6f360405a..052de019fe 100644 --- a/cli/src/cache/app-info.ts +++ b/cli/src/cache/app-info.ts @@ -42,17 +42,17 @@ export type AppInfoCacheOptions = { export async function loadAppInfoCache(opts: AppInfoCacheOptions = {}): Promise { 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 { +async function readEntries(store: Store): Promise> { const out = new Map() let raw: Record 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): void { +async function writeEntries(store: Store, entries: Map): Promise { const out: Record = {} for (const [k, v] of entries) out[k] = serialize(v) - store.set(ENTRIES_KEY, out) + await store.set(ENTRIES_KEY, out) } diff --git a/cli/src/cache/nudge-store.ts b/cli/src/cache/nudge-store.ts index 61846b89dd..3621b3b41f 100644 --- a/cli/src/cache/nudge-store.ts +++ b/cli/src/cache/nudge-store.ts @@ -23,7 +23,7 @@ export async function loadNudgeStore(opts: NudgeStoreOptions = {}): Promise 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 { +async function readWarned(store: Store): Promise> { const out = new Map() let raw: Record try { - raw = store.get(WARNED_KEY) + raw = await store.get(WARNED_KEY) } catch { return out @@ -63,9 +63,9 @@ function readWarned(store: Store): Map { return out } -function writeWarned(store: Store, state: Map): void { +async function writeWarned(store: Store, state: Map): Promise { const warned: Record = {} for (const [host, t] of state) warned[host] = new Date(t).toISOString() - store.set(WARNED_KEY, warned) + await store.set(WARNED_KEY, warned) } diff --git a/cli/src/commands/_shared/authed-command.ts b/cli/src/commands/_shared/authed-command.ts index 3f57887af4..8ef0b381e9 100644 --- a/cli/src/commands/_shared/authed-command.ts +++ b/cli/src/commands/_shared/authed-command.ts @@ -39,13 +39,13 @@ export async function buildAuthedContext( opts: AuthedContextOptions, ): Promise { 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) diff --git a/cli/src/commands/auth/devices/_shared/devices.test.ts b/cli/src/commands/auth/devices/_shared/devices.test.ts index 6636e2d27d..fb510ef1af 100644 --- a/cli/src/commands/auth/devices/_shared/devices.test.ts +++ b/cli/src/commands/auth/devices/_shared/devices.test.ts @@ -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() }) diff --git a/cli/src/commands/auth/devices/_shared/devices.ts b/cli/src/commands/auth/devices/_shared/devices.ts index 78d191e192..15f17e0a3e 100644 --- a/cli/src/commands/auth/devices/_shared/devices.ts +++ b/cli/src/commands/auth/devices/_shared/devices.ts @@ -100,7 +100,7 @@ export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise { 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') }) diff --git a/cli/src/commands/auth/login/login.ts b/cli/src/commands/auth/login/login.ts index 2dfb5dc0a9..2c1ba5b95a 100644 --- a/cli/src/commands/auth/login/login.ts +++ b/cli/src/commands/auth/login/login.ts @@ -70,18 +70,18 @@ export async function runLogin(opts: LoginOptions): Promise { 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 diff --git a/cli/src/commands/auth/logout/index.ts b/cli/src/commands/auth/logout/index.ts index 80dc1142cc..6476b1726e 100644 --- a/cli/src/commands/auth/logout/index.ts +++ b/cli/src/commands/auth/logout/index.ts @@ -21,14 +21,14 @@ export default class Logout extends DifyCommand { async run(argv: string[]): Promise { 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 !== '') { diff --git a/cli/src/commands/auth/logout/logout.test.ts b/cli/src/commands/auth/logout/logout.test.ts index e1f7d84d0b..2d1ea109e5 100644 --- a/cli/src/commands/auth/logout/logout.test.ts +++ b/cli/src/commands/auth/logout/logout.test.ts @@ -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) }) diff --git a/cli/src/commands/auth/logout/logout.ts b/cli/src/commands/auth/logout/logout.ts index 584c528f97..6724a5feef 100644 --- a/cli/src/commands/auth/logout/logout.ts +++ b/cli/src/commands/auth/logout/logout.ts @@ -24,7 +24,7 @@ export async function runLogout(opts: LogoutOptions): Promise { 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 { } } - reg.forget(active, store) + await reg.forget(active, store) if (revokeWarning !== '') opts.io.err.write(revokeWarning) diff --git a/cli/src/commands/auth/whoami/index.ts b/cli/src/commands/auth/whoami/index.ts index 5f3ce89e1e..26b0065047 100644 --- a/cli/src/commands/auth/whoami/index.ts +++ b/cli/src/commands/auth/whoami/index.ts @@ -18,7 +18,7 @@ export default class Whoami extends DifyCommand { async run(argv: string[]): Promise { const { flags } = this.parse(Whoami, argv) - const reg = Registry.load() + const reg = await Registry.load() await runWhoami({ io: realStreams(), reg, json: flags.json }) } } diff --git a/cli/src/commands/config/get/index.ts b/cli/src/commands/config/get/index.ts index 907672c577..65506d4def 100644 --- a/cli/src/commands/config/get/index.ts +++ b/cli/src/commands/config/get/index.ts @@ -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 })) } } diff --git a/cli/src/commands/config/get/run.test.ts b/cli/src/commands/config/get/run.test.ts index 6199706cb2..482edc78af 100644 --- a/cli/src/commands/config/get/run.test.ts +++ b/cli/src/commands/config/get/run.test.ts @@ -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') }) }) diff --git a/cli/src/commands/config/get/run.ts b/cli/src/commands/config/get/run.ts index b4713fa84d..b0e7e93b9f 100644 --- a/cli/src/commands/config/get/run.ts +++ b/cli/src/commands/config/get/run.ts @@ -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 { + const loaded = await loadConfig(opts.store) const config: ConfigFile = loaded.found ? loaded.config : emptyConfig() return `${getKey(config, opts.key)}\n` } diff --git a/cli/src/commands/config/set/index.ts b/cli/src/commands/config/set/index.ts index 099ab559ca..805a02e888 100644 --- a/cli/src/commands/config/set/index.ts +++ b/cli/src/commands/config/set/index.ts @@ -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 })) } } diff --git a/cli/src/commands/config/set/run.test.ts b/cli/src/commands/config/set/run.test.ts index 404218d156..a0c11bb4f1 100644 --- a/cli/src/commands/config/set/run.test.ts +++ b/cli/src/commands/config/set/run.test.ts @@ -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) diff --git a/cli/src/commands/config/set/run.ts b/cli/src/commands/config/set/run.ts index 2794427e1a..47789ae981 100644 --- a/cli/src/commands/config/set/run.ts +++ b/cli/src/commands/config/set/run.ts @@ -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 { + 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` } diff --git a/cli/src/commands/config/unset/index.ts b/cli/src/commands/config/unset/index.ts index 3888aef500..ec9e0d0758 100644 --- a/cli/src/commands/config/unset/index.ts +++ b/cli/src/commands/config/unset/index.ts @@ -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 })) } } diff --git a/cli/src/commands/config/unset/run.test.ts b/cli/src/commands/config/unset/run.test.ts index 22bb736c92..d757b7df21 100644 --- a/cli/src/commands/config/unset/run.test.ts +++ b/cli/src/commands/config/unset/run.test.ts @@ -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) diff --git a/cli/src/commands/config/unset/run.ts b/cli/src/commands/config/unset/run.ts index 4b34e34788..acb9a473bf 100644 --- a/cli/src/commands/config/unset/run.ts +++ b/cli/src/commands/config/unset/run.ts @@ -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 { + 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` } diff --git a/cli/src/commands/config/view/index.ts b/cli/src/commands/config/view/index.ts index b165fea1fc..d6b43e8336 100644 --- a/cli/src/commands/config/view/index.ts +++ b/cli/src/commands/config/view/index.ts @@ -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 })) } } diff --git a/cli/src/commands/config/view/run.test.ts b/cli/src/commands/config/view/run.test.ts index 017b0376ef..22d16975ec 100644 --- a/cli/src/commands/config/view/run.test.ts +++ b/cli/src/commands/config/view/run.test.ts @@ -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 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) }) }) diff --git a/cli/src/commands/config/view/run.ts b/cli/src/commands/config/view/run.ts index 667cb903d5..50dcdcaf1b 100644 --- a/cli/src/commands/config/view/run.ts +++ b/cli/src/commands/config/view/run.ts @@ -11,8 +11,8 @@ export type RunConfigViewOptions = { type ViewOut = Record -export function runConfigView(opts: RunConfigViewOptions): string { - const loaded = loadConfig(opts.store) +export async function runConfigView(opts: RunConfigViewOptions): Promise { + const loaded = await loadConfig(opts.store) const config: ConfigFile = loaded.found ? loaded.config : emptyConfig() const out = collect(config) if (opts.json) diff --git a/cli/src/commands/use/account/use-account.test.ts b/cli/src/commands/use/account/use-account.test.ts index 2af64e1727..d6fa4124a4 100644 --- a/cli/src/commands/use/account/use-account.test.ts +++ b/cli/src/commands/use/account/use-account.test.ts @@ -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 () => { diff --git a/cli/src/commands/use/account/use-account.ts b/cli/src/commands/use/account/use-account.ts index 63af4c20df..ca53333e2c 100644 --- a/cli/src/commands/use/account/use-account.ts +++ b/cli/src/commands/use/account/use-account.ts @@ -21,7 +21,7 @@ const USE_HOST_HINT = 'run \'difyctl use host\' or \'difyctl auth login\'' export async function runUseAccount(opts: UseAccountOptions): Promise { 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 { } 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 { } reg.setAccount(target) - reg.save() + await reg.save() opts.io.out.write(`${cs.successIcon()} Active account on ${host} is now ${target}\n`) } diff --git a/cli/src/commands/use/host/use-host.test.ts b/cli/src/commands/use/host/use-host.test.ts index e40a34e811..259347faeb 100644 --- a/cli/src/commands/use/host/use-host.test.ts +++ b/cli/src/commands/use/host/use-host.test.ts @@ -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) }) }) diff --git a/cli/src/commands/use/host/use-host.ts b/cli/src/commands/use/host/use-host.ts index 21a2c85d72..fb442001de 100644 --- a/cli/src/commands/use/host/use-host.ts +++ b/cli/src/commands/use/host/use-host.ts @@ -14,7 +14,7 @@ type HostChoice = { host: string, accounts: number, active: boolean } export async function runUseHost(opts: UseHostOptions): Promise { 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 { } reg.setHost(target) - reg.save() + await reg.save() opts.io.out.write(`${cs.successIcon()} Active host is now ${target}\n`) } diff --git a/cli/src/commands/use/workspace/use.test.ts b/cli/src/commands/use/workspace/use.test.ts index 2e2ad6b8a8..7feeb4e0fe 100644 --- a/cli/src/commands/use/workspace/use.test.ts +++ b/cli/src/commands/use/workspace/use.test.ts @@ -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 | 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') }) }) diff --git a/cli/src/commands/use/workspace/use.ts b/cli/src/commands/use/workspace/use.ts index 5371ace8eb..3070a76aa3 100644 --- a/cli/src/commands/use/workspace/use.ts +++ b/cli/src/commands/use/workspace/use.ts @@ -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 } diff --git a/cli/src/config/config-loader.test.ts b/cli/src/config/config-loader.test.ts index e3e5ae0053..83d954db68 100644 --- a/cli/src/config/config-loader.test.ts +++ b/cli/src/config/config-loader.test.ts @@ -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) diff --git a/cli/src/config/config-loader.ts b/cli/src/config/config-loader.ts index cd2d360105..d8438c2f04 100644 --- a/cli/src/config/config-loader.ts +++ b/cli/src/config/config-loader.ts @@ -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 { let raw: Record | null try { - raw = store.getTyped>() + raw = await store.getTyped>() } catch (err) { throw newError( diff --git a/cli/src/store/config-writer.test.ts b/cli/src/store/config-writer.test.ts index 23e9b350c7..2c7a8e2d9e 100644 --- a/cli/src/store/config-writer.test.ts +++ b/cli/src/store/config-writer.test.ts @@ -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') diff --git a/cli/src/store/config-writer.ts b/cli/src/store/config-writer.ts index 483cabcb0c..fd86f59714 100644 --- a/cli/src/store/config-writer.ts +++ b/cli/src/store/config-writer.ts @@ -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 { const stamped: ConfigFile = { ...config, schema_version: CURRENT_SCHEMA_VERSION } - store.setTyped(stamped) + await store.setTyped(stamped) } diff --git a/cli/src/store/keychain-token-store.test.ts b/cli/src/store/keychain-token-store.test.ts index 66eca13257..57f4b099df 100644 --- a/cli/src/store/keychain-token-store.test.ts +++ b/cli/src/store/keychain-token-store.test.ts @@ -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.. (back-compat)', () => { + it('uses the legacy entry name tokens.. (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 diff --git a/cli/src/store/keyring-based-store.test.ts b/cli/src/store/keyring-based-store.test.ts index 92adaface9..58502a3bb2 100644 --- a/cli/src/store/keyring-based-store.test.ts +++ b/cli/src/store/keyring-based-store.test.ts @@ -11,17 +11,17 @@ class FakeEntry { this.key = `${service}::${username}` } - setPassword(value: string): void { + async setPassword(value: string): Promise { setPassword(this.key, value) passwords.set(this.key, value) } - getPassword(): string | null { + async getPassword(): Promise { getPassword(this.key) - return passwords.get(this.key) ?? null + return passwords.get(this.key) ?? undefined } - deletePassword(): boolean { + async deletePassword(): Promise { 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/) }) }) diff --git a/cli/src/store/manager.test.ts b/cli/src/store/manager.test.ts index 0a499793e2..9da86cda63 100644 --- a/cli/src/store/manager.test.ts +++ b/cli/src/store/manager.test.ts @@ -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 { return map.get(k(host, email)) ?? '' }, - write(host: string, email: string, bearer: string): void { + async write(host: string, email: string, bearer: string): Promise { map.set(k(host, email), bearer) }, - remove(host: string, email: string): void { + async remove(host: string, email: string): Promise { 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 }, }) diff --git a/cli/src/store/manager.ts b/cli/src/store/manager.ts index 4b9cea376e..37962681ce 100644 --- a/cli/src/store/manager.ts +++ b/cli/src/store/manager.ts @@ -54,20 +54,20 @@ const TOKEN_STORE_OPENERS: Record 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' } diff --git a/cli/src/store/store.test.ts b/cli/src/store/store.test.ts index f2694accf8..bc1d9242c7 100644 --- a/cli/src/store/store.test.ts +++ b/cli/src/store/store.test.ts @@ -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) }) }) diff --git a/cli/src/store/store.ts b/cli/src/store/store.ts index 97052da0ef..758c5a5b8b 100644 --- a/cli/src/store/store.ts +++ b/cli/src/store/store.ts @@ -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 { + return new Promise((resolve, reject) => { + lockfile.lock(path, opts, err => (err ? reject(err) : resolve())) + }) +} + +function unlockAsync(path: string): Promise { + return new Promise((resolve, reject) => { + lockfile.unlock(path, err => (err ? reject(err) : resolve())) + }) +} export type Key = { default: T @@ -16,9 +30,9 @@ export type Key = { } export type Store = { - get: (key: Key) => T - set: (key: Key, value: T) => void - unset: (key: Key) => void + get: (key: Key) => Promise + set: (key: Key, value: T) => Promise + unset: (key: Key) => Promise } 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 { + await fsp.mkdir(dirname(this.filePath), { recursive: true, mode: DIR_PERM }) } - unlock(): void { - lockfile.unlockSync(`${this.filePath}.lock`) + async unlock(): Promise { + await unlockAsync(`${this.filePath}.lock`) } /** * atomically write raw_content (if any) */ - flush(): void { + async flush(): Promise { // 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 { + 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 { 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(body: () => R): R { - this.lock() + protected async withLock(body: () => R | Promise): Promise { + await this.lock() try { - return body() + return await body() } finally { - this.unlock() + await this.unlock() } } - get(key: Key): T { - return this.withLock(() => { - this.load() + async get(key: Key): Promise { + return this.withLock(async () => { + await this.load() return this.doGet(key) }) } - set(key: Key, value: T) { - this.withLock(() => { - this.load() + async set(key: Key, value: T): Promise { + await this.withLock(async () => { + await this.load() this.doSet(key, value) - this.flush() + await this.flush() }) } - unset(key: Key): void { - this.withLock(() => { - this.load() + async unset(key: Key): Promise { + 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 { 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 | null { - return this.withLock(() => { - this.load() + async getTyped(): Promise { + return this.withLock(async () => { + await this.load() return loadYaml(this.getRawContent(), this.filePath) as T }) } - setTyped(data: T): void { - this.withLock(() => { - this.load() + async setTyped(data: T): Promise { + 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(key: Key): T { + async get(key: Key): Promise { 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(key: Key, value: T): void { - new Entry(this.service, key.key).setPassword(JSON.stringify(value)) + async set(key: Key, value: T): Promise { + await new AsyncEntry(this.service, key.key).setPassword(JSON.stringify(value)) } - unset(key: Key): void { + async unset(key: Key): Promise { try { - new Entry(this.service, key.key).deletePassword() + await new AsyncEntry(this.service, key.key).deletePassword() } catch { /* missing entry is fine */ } } diff --git a/cli/src/store/token-store.test.ts b/cli/src/store/token-store.test.ts index b076267234..5771e1c841 100644 --- a/cli/src/store/token-store.test.ts +++ b/cli/src/store/token-store.test.ts @@ -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() }) }) diff --git a/cli/src/store/token-store.ts b/cli/src/store/token-store.ts index e6d6f29599..d04a8a3d6b 100644 --- a/cli/src/store/token-store.ts +++ b/cli/src/store/token-store.ts @@ -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 + write: (host: string, email: string, bearer: string) => Promise + remove: (host: string, email: string) => Promise } 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() + async read(host: string, email: string): Promise { + const doc = await this.store.getTyped() 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 { + 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() + async remove(host: string, email: string): Promise { + const doc = await this.store.getTyped() 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> } { - const doc = this.store.getTyped() + private async load(): Promise<{ version: number, tokens: Record> }> { + const doc = await this.store.getTyped() 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 { + 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 { 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 { try { - new Entry(this.service, entryName(host, email)).deletePassword() + await new AsyncEntry(this.service, entryName(host, email)).deletePassword() } catch { /* missing entry is fine */ } } diff --git a/cli/src/version/probe.test.ts b/cli/src/version/probe.test.ts index 8b84d24704..87c7e55838 100644 --- a/cli/src/version/probe.test.ts +++ b/cli/src/version/probe.test.ts @@ -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 }) diff --git a/cli/src/version/probe.ts b/cli/src/version/probe.ts index bfccaa77e4..266910e7c0 100644 --- a/cli/src/version/probe.ts +++ b/cli/src/version/probe.ts @@ -48,7 +48,7 @@ export type RunVersionProbeOptions = { } const defaultLoadActive = async (): Promise => { - return Registry.load().resolveActive() + return (await Registry.load()).resolveActive() } const defaultProbe: MetaProbe = async (endpoint) => { diff --git a/cli/test/e2e/setup/env.ts b/cli/test/e2e/setup/env.ts index 8312935700..3f71b51442 100644 --- a/cli/test/e2e/setup/env.ts +++ b/cli/test/e2e/setup/env.ts @@ -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, diff --git a/cli/test/fixtures/mem-store.ts b/cli/test/fixtures/mem-store.ts index 23ab779f83..879477b749 100644 --- a/cli/test/fixtures/mem-store.ts +++ b/cli/test/fixtures/mem-store.ts @@ -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 { 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 { this.entries.set(this.k(host, email), bearer) } - remove(host: string, email: string): void { + async remove(host: string, email: string): Promise { this.entries.delete(this.k(host, email)) } }