From 3596d12e4c89e678e88b3b2bb5a3f369b2ada08f Mon Sep 17 00:00:00 2001 From: Yunlu Wen Date: Thu, 28 May 2026 18:02:51 +0800 Subject: [PATCH] refactor(cli): use Store interface as token storage (#36726) --- cli/src/api/app-meta.test.ts | 19 +- cli/src/auth/file-backend.test.ts | 101 ----------- cli/src/auth/file-backend.ts | 99 ----------- cli/src/auth/hosts.test.ts | 114 ++++++------ cli/src/auth/hosts.ts | 64 ++----- cli/src/auth/keyring-backend.test.ts | 111 ------------ cli/src/auth/keyring-backend.ts | 35 ---- cli/src/auth/store.test.ts | 75 -------- cli/src/auth/store.ts | 40 ----- cli/src/cache/app-info.test.ts | 31 ++-- cli/src/cache/nudge-store.test.ts | 31 ++-- cli/src/commands/_shared/authed-command.ts | 7 +- .../auth/devices/_shared/devices.test.ts | 58 +++--- .../commands/auth/devices/_shared/devices.ts | 32 +--- cli/src/commands/auth/devices/revoke/index.ts | 4 - cli/src/commands/auth/login/index.ts | 2 - cli/src/commands/auth/login/login.test.ts | 42 ++--- cli/src/commands/auth/login/login.ts | 15 +- cli/src/commands/auth/logout/index.ts | 8 +- cli/src/commands/auth/logout/logout.test.ts | 60 ++++--- cli/src/commands/auth/logout/logout.ts | 30 +--- cli/src/commands/auth/status/index.ts | 4 +- cli/src/commands/auth/whoami/index.ts | 4 +- cli/src/commands/config/get/run.test.ts | 53 +++--- cli/src/commands/config/path/index.ts | 7 +- cli/src/commands/config/path/run.test.ts | 14 -- cli/src/commands/config/path/run.ts | 10 -- cli/src/commands/config/set/run.test.ts | 63 ++++--- cli/src/commands/config/unset/run.test.ts | 61 ++++--- cli/src/commands/config/view/run.test.ts | 66 +++---- cli/src/commands/describe/app/run.test.ts | 15 +- cli/src/commands/run/app/run.test.ts | 51 +++--- cli/src/commands/use/workspace/index.ts | 1 - cli/src/commands/use/workspace/use.test.ts | 36 ++-- cli/src/commands/use/workspace/use.ts | 3 +- cli/src/config/config-loader.test.ts | 86 +++++---- cli/src/config/schema.test.ts | 6 +- cli/src/config/schema.ts | 1 - cli/src/errors/codes.test.ts | 5 +- cli/src/errors/codes.ts | 2 + cli/src/store/config-writer.test.ts | 91 +++++----- cli/src/store/errors.ts | 64 +++++++ cli/src/store/keyring-based-store.test.ts | 109 ++++++++++++ cli/src/store/manager.test.ts | 78 +++++++++ cli/src/store/manager.ts | 65 ++++++- cli/src/store/store.test.ts | 81 ++++++--- cli/src/store/store.ts | 165 +++++++++++++++--- cli/src/version/nudge.test.ts | 17 +- cli/src/version/probe.test.ts | 3 +- cli/src/version/probe.ts | 3 +- 50 files changed, 1057 insertions(+), 1085 deletions(-) delete mode 100644 cli/src/auth/file-backend.test.ts delete mode 100644 cli/src/auth/file-backend.ts delete mode 100644 cli/src/auth/keyring-backend.test.ts delete mode 100644 cli/src/auth/keyring-backend.ts delete mode 100644 cli/src/auth/store.test.ts delete mode 100644 cli/src/auth/store.ts delete mode 100644 cli/src/commands/config/path/run.test.ts delete mode 100644 cli/src/commands/config/path/run.ts create mode 100644 cli/src/store/errors.ts create mode 100644 cli/src/store/keyring-based-store.test.ts create mode 100644 cli/src/store/manager.test.ts diff --git a/cli/src/api/app-meta.test.ts b/cli/src/api/app-meta.test.ts index 1ec9e4b698..4885a135b7 100644 --- a/cli/src/api/app-meta.test.ts +++ b/cli/src/api/app-meta.test.ts @@ -6,8 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { startMock } from '../../test/fixtures/dify-mock/server.js' import { loadAppInfoCache } from '../cache/app-info.js' import { createClient } from '../http/client.js' -import { CACHE_APP_INFO, cachePath } from '../store/manager.js' -import { YamlStore } from '../store/store.js' +import { ENV_CACHE_DIR } from '../store/dir.js' +import { CACHE_APP_INFO, getCache } from '../store/manager.js' import { FieldInfo, FieldParameters } from '../types/app-meta.js' import { AppMetaClient } from './app-meta.js' import { AppsClient } from './apps.js' @@ -15,17 +15,24 @@ import { AppsClient } from './apps.js' describe('AppMetaClient', () => { let mock: DifyMock let dir: string + let prevCacheDir: string | undefined beforeEach(async () => { mock = await startMock({ scenario: 'happy' }) dir = await mkdtemp(join(tmpdir(), 'difyctl-meta-')) + prevCacheDir = process.env[ENV_CACHE_DIR] + process.env[ENV_CACHE_DIR] = dir }) afterEach(async () => { + if (prevCacheDir === undefined) + delete process.env[ENV_CACHE_DIR] + else + process.env[ENV_CACHE_DIR] = prevCacheDir await mock.stop() await rm(dir, { recursive: true, force: true }) }) it('cache miss → fetch → populate; warm hit skips network', async () => { - const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) const apps = new AppsClient(createClient({ host: mock.url, bearer: 'dfoa_test' })) const spy = vi.spyOn(apps, 'describe') const client = new AppMetaClient({ apps, host: mock.url, cache }) @@ -40,7 +47,7 @@ describe('AppMetaClient', () => { }) it('slim hit + full request triggers fresh fetch + merges', async () => { - const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) const apps = new AppsClient(createClient({ host: mock.url, bearer: 'dfoa_test' })) const spy = vi.spyOn(apps, 'describe') const client = new AppMetaClient({ apps, host: mock.url, cache }) @@ -54,7 +61,7 @@ describe('AppMetaClient', () => { }) it('expired cache entry refetches', async () => { - const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)), ttlMs: 100, now: () => new Date('2026-05-09T00:00:00Z') }) + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO), ttlMs: 100, now: () => new Date('2026-05-09T00:00:00Z') }) const apps = new AppsClient(createClient({ host: mock.url, bearer: 'dfoa_test' })) const spy = vi.spyOn(apps, 'describe') const client = new AppMetaClient({ apps, host: mock.url, cache, now: () => new Date('2026-05-09T00:00:00Z') }) @@ -68,7 +75,7 @@ describe('AppMetaClient', () => { }) it('invalidate forces next get to fetch', async () => { - const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) const apps = new AppsClient(createClient({ host: mock.url, bearer: 'dfoa_test' })) const spy = vi.spyOn(apps, 'describe') const client = new AppMetaClient({ apps, host: mock.url, cache }) diff --git a/cli/src/auth/file-backend.test.ts b/cli/src/auth/file-backend.test.ts deleted file mode 100644 index e633d1d724..0000000000 --- a/cli/src/auth/file-backend.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { FILE_PERM } from '../store/dir.js' -import { FileBackend, TOKENS_FILE_NAME } from './file-backend.js' - -describe('FileBackend', () => { - let dir: string - let backend: FileBackend - - beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), 'difyctl-tokens-')) - backend = new FileBackend(dir) - }) - - afterEach(async () => { - await rm(dir, { recursive: true, force: true }) - }) - - it('returns undefined when file is missing', async () => { - expect(await backend.get('cloud.dify.ai', 'acct-1')).toBeUndefined() - }) - - it('returns empty list when file is missing', async () => { - expect(await backend.list('cloud.dify.ai')).toEqual([]) - }) - - it('round-trips put/get for a single token', async () => { - await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_abc') - expect(await backend.get('cloud.dify.ai', 'acct-1')).toBe('dfoa_abc') - }) - - it('list returns accountIds for the given host', async () => { - await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a') - await backend.put('cloud.dify.ai', 'acct-2', 'dfoa_b') - await backend.put('self.example.com', 'acct-3', 'dfoa_c') - const ids = await backend.list('cloud.dify.ai') - expect([...ids].sort()).toEqual(['acct-1', 'acct-2']) - }) - - it('list returns empty array for unknown host', async () => { - await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a') - expect(await backend.list('other.example.com')).toEqual([]) - }) - - it('delete removes the entry', async () => { - await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a') - await backend.delete('cloud.dify.ai', 'acct-1') - expect(await backend.get('cloud.dify.ai', 'acct-1')).toBeUndefined() - }) - - it('delete is a no-op for missing entries', async () => { - await expect(backend.delete('cloud.dify.ai', 'missing')).resolves.toBeUndefined() - }) - - it('delete prunes empty host entries', async () => { - await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a') - await backend.delete('cloud.dify.ai', 'acct-1') - expect(await backend.list('cloud.dify.ai')).toEqual([]) - }) - - it('overwrites existing token for same host+accountId', async () => { - await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_old') - await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_new') - expect(await backend.get('cloud.dify.ai', 'acct-1')).toBe('dfoa_new') - }) - - it('writes file with mode 0600', async () => { - await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a') - const info = await stat(join(dir, TOKENS_FILE_NAME)) - expect(info.mode & 0o777).toBe(FILE_PERM) - }) - - it('rewrites existing file with mode 0600 even if previously permissive', async () => { - const path = join(dir, TOKENS_FILE_NAME) - await writeFile(path, 'hosts: {}\n', { mode: 0o644 }) - await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a') - const info = await stat(path) - expect(info.mode & 0o777).toBe(FILE_PERM) - }) - - it('writes valid YAML readable by a fresh backend', async () => { - await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a') - const fresh = new FileBackend(dir) - expect(await fresh.get('cloud.dify.ai', 'acct-1')).toBe('dfoa_a') - }) - - it('persists multiple hosts simultaneously', async () => { - await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a') - await backend.put('self.example.com', 'acct-2', 'dfoa_b') - expect(await backend.get('cloud.dify.ai', 'acct-1')).toBe('dfoa_a') - expect(await backend.get('self.example.com', 'acct-2')).toBe('dfoa_b') - }) - - it('treats malformed YAML as empty', async () => { - const path = join(dir, TOKENS_FILE_NAME) - await writeFile(path, 'not: valid: yaml: [\n', { mode: FILE_PERM }) - expect(await backend.get('cloud.dify.ai', 'acct-1')).toBeUndefined() - }) -}) diff --git a/cli/src/auth/file-backend.ts b/cli/src/auth/file-backend.ts deleted file mode 100644 index 0f8c2280c9..0000000000 --- a/cli/src/auth/file-backend.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { TokenStore } from './store.js' -import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises' -import { join } from 'node:path' -import yaml from 'js-yaml' -import { DIR_PERM, FILE_PERM } from '../store/dir.js' - -export const TOKENS_FILE_NAME = 'tokens.yml' - -type AccountMap = Record -type HostMap = Record -type TokensFile = { hosts?: HostMap } - -export class FileBackend implements TokenStore { - private readonly dir: string - private readonly path: string - - constructor(dir: string) { - this.dir = dir - this.path = join(dir, TOKENS_FILE_NAME) - } - - async put(host: string, accountId: string, token: string): Promise { - const file = await this.read() - const hosts = file.hosts ?? {} - const accounts = hosts[host] ?? {} - accounts[accountId] = token - hosts[host] = accounts - await this.write({ hosts }) - } - - async get(host: string, accountId: string): Promise { - const file = await this.read() - return file.hosts?.[host]?.[accountId] - } - - async delete(host: string, accountId: string): Promise { - const file = await this.read() - const accounts = file.hosts?.[host] - if (accounts === undefined || !(accountId in accounts)) - return - delete accounts[accountId] - if (Object.keys(accounts).length === 0 && file.hosts !== undefined) - delete file.hosts[host] - await this.write(file) - } - - async list(host: string): Promise { - const file = await this.read() - const accounts = file.hosts?.[host] - return accounts === undefined ? [] : Object.keys(accounts) - } - - private async read(): Promise { - let raw: string - try { - raw = await readFile(this.path, 'utf8') - } - catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') - return {} - throw err - } - let parsed: unknown - try { - parsed = yaml.load(raw) - } - catch { - return {} - } - if (parsed === null || typeof parsed !== 'object') - return {} - return parsed as TokensFile - } - - private async write(file: TokensFile): Promise { - await mkdir(this.dir, { recursive: true, mode: DIR_PERM }) - const body = yaml.dump(file, { lineWidth: -1, noRefs: true }) - const tmp = `${this.path}.tmp.${process.pid}.${Date.now()}` - try { - await writeFile(tmp, body, { mode: FILE_PERM }) - await rename(tmp, this.path) - } - catch (err) { - try { - await unlink(tmp) - } - catch { /* tmp may not exist */ } - throw err - } - try { - const info = await stat(this.path) - if ((info.mode & 0o777) !== FILE_PERM) { - const { chmod } = await import('node:fs/promises') - await chmod(this.path, FILE_PERM) - } - } - catch { /* best-effort permission tighten */ } - } -} diff --git a/cli/src/auth/hosts.test.ts b/cli/src/auth/hosts.test.ts index 9f1c50fb25..5c5b254c0a 100644 --- a/cli/src/auth/hosts.test.ts +++ b/cli/src/auth/hosts.test.ts @@ -1,9 +1,9 @@ -import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { FILE_PERM } from '../store/dir.js' -import { HOSTS_FILE_NAME, HostsBundleSchema, loadHosts, saveHosts } from './hosts.js' +import { ENV_CONFIG_DIR } from '../store/dir.js' +import { HostsBundleSchema, loadHosts, saveHosts } from './hosts.js' describe('HostsBundleSchema', () => { it('parses a minimal logged-out bundle', () => { @@ -46,86 +46,86 @@ describe('HostsBundleSchema', () => { }) expect(parsed.available_workspaces).toHaveLength(2) }) + + it('drops unknown top-level fields on parse', () => { + const parsed = HostsBundleSchema.parse({ + current_host: 'cloud.dify.ai', + future_field: 42, + token_storage: 'file', + }) + expect(parsed.current_host).toBe('cloud.dify.ai') + expect((parsed as Record).future_field).toBeUndefined() + }) }) describe('loadHosts/saveHosts', () => { let dir: string + let prevConfigDir: string | undefined beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'difyctl-hosts-')) + prevConfigDir = process.env[ENV_CONFIG_DIR] + process.env[ENV_CONFIG_DIR] = dir }) afterEach(async () => { + if (prevConfigDir === undefined) + delete process.env[ENV_CONFIG_DIR] + else + process.env[ENV_CONFIG_DIR] = prevConfigDir await rm(dir, { recursive: true, force: true }) }) - it('returns undefined when file is missing', async () => { - expect(await loadHosts(dir)).toBeUndefined() + it('returns undefined when nothing was saved', () => { + expect(loadHosts()).toBeUndefined() }) - it('round-trips bundle through YAML', async () => { - await saveHosts(dir, { + it('round-trips a fully-populated bundle', () => { + saveHosts({ current_host: 'cloud.dify.ai', + scheme: 'https', account: { id: 'acct-1', email: 'a@b.c', name: 'A' }, workspace: { id: 'ws-1', name: 'My Space', role: 'owner' }, + available_workspaces: [ + { id: 'ws-1', name: 'My Space', role: 'owner' }, + { id: 'ws-2', name: 'Other', role: 'normal' }, + ], token_storage: 'keychain', token_id: 'tok_xyz', }) - const loaded = await loadHosts(dir) + const loaded = loadHosts() expect(loaded?.current_host).toBe('cloud.dify.ai') + expect(loaded?.scheme).toBe('https') expect(loaded?.account?.email).toBe('a@b.c') + expect(loaded?.workspace?.id).toBe('ws-1') + expect(loaded?.available_workspaces).toHaveLength(2) + expect(loaded?.token_storage).toBe('keychain') + expect(loaded?.token_id).toBe('tok_xyz') + }) + + it('round-trips a file-mode bundle with bearer token', () => { + saveHosts({ + current_host: 'self.example.com', + token_storage: 'file', + tokens: { bearer: 'dfoa_test' }, + }) + const loaded = loadHosts() + expect(loaded?.tokens?.bearer).toBe('dfoa_test') + expect(loaded?.token_storage).toBe('file') + }) + + it('overwrites previous bundle on save', () => { + saveHosts({ current_host: 'old.example.com', token_storage: 'file' }) + saveHosts({ current_host: 'new.example.com', token_storage: 'keychain' }) + const loaded = loadHosts() + expect(loaded?.current_host).toBe('new.example.com') expect(loaded?.token_storage).toBe('keychain') }) - it('writes file with mode 0600', async () => { - await saveHosts(dir, { current_host: 'cloud.dify.ai', token_storage: 'file' }) - const info = await stat(join(dir, HOSTS_FILE_NAME)) - expect(info.mode & 0o777).toBe(FILE_PERM) - }) - - it('rewrites permissive existing file with mode 0600', async () => { - const path = join(dir, HOSTS_FILE_NAME) - await writeFile(path, 'current_host: ""\ntoken_storage: file\n', { mode: 0o644 }) - await saveHosts(dir, { current_host: 'cloud.dify.ai', token_storage: 'file' }) - const info = await stat(path) - expect(info.mode & 0o777).toBe(FILE_PERM) - }) - - it('atomic write: temp file does not survive on success', async () => { - await saveHosts(dir, { current_host: 'cloud.dify.ai', token_storage: 'file' }) - const { readdir } = await import('node:fs/promises') - const entries = await readdir(dir) - expect(entries.filter(n => n.includes('.tmp.'))).toHaveLength(0) - }) - - it('drops unknown top-level fields', async () => { - const path = join(dir, HOSTS_FILE_NAME) - await writeFile(path, 'current_host: cloud.dify.ai\nfuture_field: 42\ntoken_storage: file\n', { mode: FILE_PERM }) - const loaded = await loadHosts(dir) - expect(loaded?.current_host).toBe('cloud.dify.ai') - expect((loaded as Record | undefined)?.future_field).toBeUndefined() - }) - - it('throws on malformed YAML', async () => { - const path = join(dir, HOSTS_FILE_NAME) - await writeFile(path, ': : :\n', { mode: FILE_PERM }) - await expect(loadHosts(dir)).rejects.toThrow() - }) - - it('throws when YAML contradicts schema', async () => { - const path = join(dir, HOSTS_FILE_NAME) - await writeFile(path, 'token_storage: cloud\n', { mode: FILE_PERM }) - await expect(loadHosts(dir)).rejects.toThrow() - }) - - it('produces YAML with stable keys', async () => { - await saveHosts(dir, { + it('rejects invalid input at save time', () => { + expect(() => saveHosts({ current_host: 'cloud.dify.ai', - token_storage: 'file', - tokens: { bearer: 'dfoa_x' }, - }) - const raw = await readFile(join(dir, HOSTS_FILE_NAME), 'utf8') - expect(raw).toContain('current_host: cloud.dify.ai') - expect(raw).toContain('bearer: dfoa_x') + token_storage: 'cloud', + } as never)).toThrow() }) }) diff --git a/cli/src/auth/hosts.ts b/cli/src/auth/hosts.ts index f6504dd06c..a7016d6714 100644 --- a/cli/src/auth/hosts.ts +++ b/cli/src/auth/hosts.ts @@ -1,10 +1,6 @@ -import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises' -import { join } from 'node:path' -import yaml from 'js-yaml' +import type { Store } from '../store/store.js' import { z } from 'zod' -import { DIR_PERM, FILE_PERM } from '../store/dir.js' - -export const HOSTS_FILE_NAME = 'hosts.yml' +import { getHostStore, tokenKey } from '../store/manager.js' const StorageModeSchema = z.enum(['keychain', 'file']) export type StorageMode = z.infer @@ -48,53 +44,23 @@ export const HostsBundleSchema = z.object({ }) export type HostsBundle = z.infer -export async function loadHosts(dir: string): Promise { - const path = join(dir, HOSTS_FILE_NAME) - let raw: string - try { - raw = await readFile(path, 'utf8') - } - catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') - return undefined - throw err - } - const parsed = yaml.load(raw) - return HostsBundleSchema.parse(parsed ?? {}) +export function loadHosts(): HostsBundle | undefined { + const raw = getHostStore().getTyped>() + if (raw === null) + return undefined + return HostsBundleSchema.parse(raw) } -export async function saveHosts(dir: string, bundle: HostsBundle): Promise { - await mkdir(dir, { recursive: true, mode: DIR_PERM }) +export function saveHosts(bundle: HostsBundle): void { const validated = HostsBundleSchema.parse(bundle) - const body = yaml.dump(stripUndefined(validated), { lineWidth: -1, noRefs: true, sortKeys: false }) - const target = join(dir, HOSTS_FILE_NAME) - const tmp = `${target}.tmp.${process.pid}.${Date.now()}` + getHostStore().setTyped(validated) +} + +export function clearLocal(bundle: HostsBundle, store: Store): void { + const accountId = bundle.account?.id ?? bundle.external_subject?.email ?? 'default' try { - await writeFile(tmp, body, { mode: FILE_PERM }) - await rename(tmp, target) - } - catch (err) { - try { - await unlink(tmp) - } - catch { /* tmp may not exist */ } - throw err - } - const { chmod, stat } = await import('node:fs/promises') - try { - const info = await stat(target) - if ((info.mode & 0o777) !== FILE_PERM) - await chmod(target, FILE_PERM) + store.unset(tokenKey(bundle.current_host, accountId)) } catch { /* best-effort */ } -} - -function stripUndefined>(input: T): Record { - const out: Record = {} - for (const [k, v] of Object.entries(input)) { - if (v === undefined) - continue - out[k] = v - } - return out + getHostStore().rm() } diff --git a/cli/src/auth/keyring-backend.test.ts b/cli/src/auth/keyring-backend.test.ts deleted file mode 100644 index 19e0153916..0000000000 --- a/cli/src/auth/keyring-backend.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const passwords = new Map() -const setPassword = vi.fn() -const getPassword = vi.fn() -const deletePassword = vi.fn() - -class FakeAsyncEntry { - private readonly key: string - constructor(service: string, username: string) { - this.key = `${service}::${username}` - } - - async setPassword(value: string): Promise { - setPassword(this.key, value) - passwords.set(this.key, value) - } - - async getPassword(): Promise { - getPassword(this.key) - return passwords.get(this.key) - } - - async deletePassword(): Promise { - deletePassword(this.key) - if (!passwords.has(this.key)) - return false - passwords.delete(this.key) - return true - } -} - -vi.mock('@napi-rs/keyring', () => ({ - AsyncEntry: FakeAsyncEntry, -})) - -const { KEYRING_SERVICE, KeyringBackend } = await import('./keyring-backend.js') - -beforeEach(() => { - passwords.clear() - setPassword.mockClear() - getPassword.mockClear() - deletePassword.mockClear() -}) - -describe('KeyringBackend', () => { - it('uses service name "difyctl"', () => { - expect(KEYRING_SERVICE).toBe('difyctl') - }) - - it('returns undefined when no password is stored', async () => { - const k = new KeyringBackend() - expect(await k.get('cloud.dify.ai', 'acct-1')).toBeUndefined() - }) - - it('round-trips put/get', async () => { - const k = new KeyringBackend() - await k.put('cloud.dify.ai', 'acct-1', 'dfoa_x') - expect(await k.get('cloud.dify.ai', 'acct-1')).toBe('dfoa_x') - }) - - it('keys by host::accountId', async () => { - const k = new KeyringBackend() - await k.put('cloud.dify.ai', 'acct-1', 'A') - await k.put('cloud.dify.ai', 'acct-2', 'B') - expect(await k.get('cloud.dify.ai', 'acct-1')).toBe('A') - expect(await k.get('cloud.dify.ai', 'acct-2')).toBe('B') - }) - - it('delete removes the entry', async () => { - const k = new KeyringBackend() - await k.put('cloud.dify.ai', 'acct-1', 'A') - await k.delete('cloud.dify.ai', 'acct-1') - expect(await k.get('cloud.dify.ai', 'acct-1')).toBeUndefined() - }) - - it('delete is a no-op for missing entries', async () => { - const k = new KeyringBackend() - await expect(k.delete('cloud.dify.ai', 'gone')).resolves.toBeUndefined() - }) - - it('list returns empty array (keyring does not enumerate)', async () => { - const k = new KeyringBackend() - await k.put('cloud.dify.ai', 'acct-1', 'A') - expect(await k.list('cloud.dify.ai')).toEqual([]) - }) - - it('swallows getPassword exceptions and returns undefined', async () => { - const k = new KeyringBackend() - getPassword.mockImplementationOnce(() => { - throw new Error('NoEntry') - }) - expect(await k.get('cloud.dify.ai', 'acct-1')).toBeUndefined() - }) - - it('swallows delete exceptions', async () => { - const k = new KeyringBackend() - deletePassword.mockImplementationOnce(() => { - throw new Error('NoEntry') - }) - await expect(k.delete('cloud.dify.ai', 'acct-1')).resolves.toBeUndefined() - }) - - it('lets put propagate exceptions (caller decides fallback)', async () => { - const k = new KeyringBackend() - setPassword.mockImplementationOnce(() => { - throw new Error('keyring locked') - }) - await expect(k.put('cloud.dify.ai', 'acct-1', 'tok')).rejects.toThrow(/keyring locked/) - }) -}) diff --git a/cli/src/auth/keyring-backend.ts b/cli/src/auth/keyring-backend.ts deleted file mode 100644 index 8e3dc75ab2..0000000000 --- a/cli/src/auth/keyring-backend.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { TokenStore } from './store.js' -import { AsyncEntry } from '@napi-rs/keyring' - -export const KEYRING_SERVICE = 'difyctl' - -function username(host: string, accountId: string): string { - return `${host}::${accountId}` -} - -export class KeyringBackend implements TokenStore { - async put(host: string, accountId: string, token: string): Promise { - await new AsyncEntry(KEYRING_SERVICE, username(host, accountId)).setPassword(token) - } - - async get(host: string, accountId: string): Promise { - try { - const v = await new AsyncEntry(KEYRING_SERVICE, username(host, accountId)).getPassword() - return v ?? undefined - } - catch { - return undefined - } - } - - async delete(host: string, accountId: string): Promise { - try { - await new AsyncEntry(KEYRING_SERVICE, username(host, accountId)).deletePassword() - } - catch { /* missing entry is fine */ } - } - - async list(_host: string): Promise { - return [] - } -} diff --git a/cli/src/auth/store.test.ts b/cli/src/auth/store.test.ts deleted file mode 100644 index 21498ae9c0..0000000000 --- a/cli/src/auth/store.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { TokenStore } from './store.js' -import { describe, expect, it, vi } from 'vitest' -import { selectStore } from './store.js' - -function memBackend(label: string): TokenStore & { _label: string } { - const map = new Map() - const k = (h: string, a: string) => `${h}::${a}` - return { - _label: label, - async put(h, a, t) { map.set(k(h, a), t) }, - async get(h, a) { return map.get(k(h, a)) }, - async delete(h, a) { map.delete(k(h, a)) }, - async list() { return [] }, - } -} - -describe('selectStore', () => { - it('returns keychain when probe succeeds', async () => { - const k = memBackend('keyring') - const f = memBackend('file') - const result = await selectStore({ - configDir: '/tmp/x', - factory: { keyring: () => k, file: () => f }, - }) - expect(result.mode).toBe('keychain') - expect(result.store).toBe(k) - }) - - it('falls back to file when keyring put throws', async () => { - const k = memBackend('keyring') - const f = memBackend('file') - k.put = vi.fn().mockRejectedValue(new Error('locked')) - const result = await selectStore({ - configDir: '/tmp/x', - 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', async () => { - const k = memBackend('keyring') - const f = memBackend('file') - k.get = vi.fn().mockResolvedValue('something-else') - const result = await selectStore({ - configDir: '/tmp/x', - factory: { keyring: () => k, file: () => f }, - }) - expect(result.mode).toBe('file') - expect(result.store).toBe(f) - }) - - it('falls back to file when keyring constructor throws', async () => { - const f = memBackend('file') - const result = await selectStore({ - configDir: '/tmp/x', - factory: { - keyring: () => { throw new Error('no backend') }, - file: () => f, - }, - }) - expect(result.mode).toBe('file') - expect(result.store).toBe(f) - }) - - it('cleans up probe entry after successful probe', async () => { - const k = memBackend('keyring') - const f = memBackend('file') - await selectStore({ - configDir: '/tmp/x', - factory: { keyring: () => k, file: () => f }, - }) - expect(await k.get('__difyctl_probe__', '__probe__')).toBeUndefined() - }) -}) diff --git a/cli/src/auth/store.ts b/cli/src/auth/store.ts deleted file mode 100644 index 1be2a0606c..0000000000 --- a/cli/src/auth/store.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { FileBackend } from './file-backend.js' -import { KeyringBackend } from './keyring-backend.js' - -export type TokenStore = { - put: (host: string, accountId: string, token: string) => Promise - get: (host: string, accountId: string) => Promise - delete: (host: string, accountId: string) => Promise - list: (host: string) => Promise -} - -export type StorageMode = 'keychain' | 'file' - -export type SelectStoreOptions = { - readonly configDir: string - readonly factory?: { - readonly keyring?: () => TokenStore - readonly file?: (dir: string) => TokenStore - } -} - -const PROBE_HOST = '__difyctl_probe__' -const PROBE_ACCOUNT = '__probe__' -const PROBE_VALUE = 'probe-v1' - -export async function selectStore(opts: SelectStoreOptions): Promise<{ store: TokenStore, mode: StorageMode }> { - const fileFactory = opts.factory?.file ?? ((dir: string) => new FileBackend(dir)) - const keyringFactory = opts.factory?.keyring ?? (() => new KeyringBackend()) - try { - const k = keyringFactory() - await k.put(PROBE_HOST, PROBE_ACCOUNT, PROBE_VALUE) - const got = await k.get(PROBE_HOST, PROBE_ACCOUNT) - await k.delete(PROBE_HOST, PROBE_ACCOUNT) - if (got !== PROBE_VALUE) - throw new Error('keyring round-trip mismatch') - return { store: k, mode: 'keychain' } - } - catch { - return { store: fileFactory(opts.configDir), mode: 'file' } - } -} diff --git a/cli/src/cache/app-info.test.ts b/cli/src/cache/app-info.test.ts index 6fcf53cc2f..8604382c5b 100644 --- a/cli/src/cache/app-info.test.ts +++ b/cli/src/cache/app-info.test.ts @@ -4,8 +4,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import yaml from 'js-yaml' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { CACHE_APP_INFO, cachePath } from '../store/manager.js' -import { YamlStore } from '../store/store.js' +import { ENV_CACHE_DIR } from '../store/dir.js' +import { CACHE_APP_INFO, cachePath, getCache } from '../store/manager.js' import { platform } from '../sys/index.js' import { FieldInfo, FieldParameters } from '../types/app-meta.js' import { APP_INFO_TTL_MS, loadAppInfoCache } from './app-info.js' @@ -35,18 +35,25 @@ function metaInfoOnly(): AppMeta { describe('app-info disk cache', () => { let dir: string + let prevCacheDir: string | undefined beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'difyctl-cache-')) + prevCacheDir = process.env[ENV_CACHE_DIR] + process.env[ENV_CACHE_DIR] = dir }) afterEach(async () => { + if (prevCacheDir === undefined) + delete process.env[ENV_CACHE_DIR] + else + process.env[ENV_CACHE_DIR] = prevCacheDir await rm(dir, { recursive: true, force: true }) }) it('round-trips an entry across reloads', async () => { - const c1 = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const c1 = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await c1.set('http://localhost:9999', 'app-1', metaInfoOnly()) - const c2 = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const c2 = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) const got = c2.get('http://localhost:9999', 'app-1') expect(got).toBeDefined() expect(got?.meta.info?.id).toBe('app-1') @@ -55,7 +62,7 @@ describe('app-info disk cache', () => { it('isFresh respects TTL', async () => { const now = new Date('2026-05-09T00:00:00Z') - const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)), now: () => now }) + const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO), now: () => now }) await c.set('h', 'app-1', metaInfoOnly()) const r = c.get('h', 'app-1') expect(r).toBeDefined() @@ -66,23 +73,23 @@ describe('app-info disk cache', () => { }) it('keys by (host, app_id) — different hosts isolate', async () => { - const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await c.set('h1', 'app-1', metaInfoOnly()) expect(c.get('h2', 'app-1')).toBeUndefined() expect(c.get('h1', 'app-1')).toBeDefined() }) it('delete removes entry from disk', async () => { - const c1 = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const c1 = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await c1.set('h', 'app-1', metaInfoOnly()) await c1.delete('h', 'app-1') - const c2 = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const c2 = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) expect(c2.get('h', 'app-1')).toBeUndefined() }) it('writes file with 0600 permission', async () => { - const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await c.set('h', 'app-1', metaInfoOnly()) const { stat } = await import('node:fs/promises') const s = await stat(appInfoPath(dir)) @@ -91,19 +98,19 @@ describe('app-info disk cache', () => { }) it('missing cache file is not an error', async () => { - const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) expect(c.get('h', 'app-1')).toBeUndefined() }) it('corrupt cache file is treated as empty', async () => { const { writeFile } = await import('node:fs/promises') await writeFile(appInfoPath(dir), ': : not valid yaml', 'utf8') - const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) expect(c.get('h', 'app-1')).toBeUndefined() }) it('updates same key in place (no growth)', async () => { - const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await c.set('h', 'app-1', metaInfoOnly()) const slim: AppMeta = { ...metaInfoOnly(), diff --git a/cli/src/cache/nudge-store.test.ts b/cli/src/cache/nudge-store.test.ts index 974b094620..d899260f88 100644 --- a/cli/src/cache/nudge-store.test.ts +++ b/cli/src/cache/nudge-store.test.ts @@ -3,8 +3,8 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import yaml from 'js-yaml' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { CACHE_NUDGE, cachePath } from '../store/manager.js' -import { YamlStore } from '../store/store.js' +import { ENV_CACHE_DIR } from '../store/dir.js' +import { CACHE_NUDGE, cachePath, getCache } from '../store/manager.js' import { loadNudgeStore, WARN_INTERVAL_MS } from './nudge-store.js' function nudgeStorePath(dir: string): string { @@ -15,21 +15,28 @@ const HOST = 'https://cloud.dify.ai' describe('NudgeStore', () => { let dir: string + let prevCacheDir: string | undefined beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'difyctl-nudge-')) + prevCacheDir = process.env[ENV_CACHE_DIR] + process.env[ENV_CACHE_DIR] = dir }) afterEach(async () => { + if (prevCacheDir === undefined) + delete process.env[ENV_CACHE_DIR] + else + process.env[ENV_CACHE_DIR] = prevCacheDir await rm(dir, { recursive: true, force: true }) }) it('canWarn=true when no prior record exists', async () => { - const store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)) }) + const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE) }) expect(store.canWarn(HOST)).toBe(true) }) it('canWarn=false within the silence window, true past it', async () => { const t0 = new Date('2026-05-19T12:00:00.000Z') - const store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t0 }) + const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t0 }) await store.markWarned(HOST) expect(store.canWarn(HOST, new Date('2026-05-19T18:00:00.000Z'))).toBe(false) expect(store.canWarn(HOST, new Date('2026-05-20T12:00:00.000Z'))).toBe(true) @@ -37,7 +44,7 @@ describe('NudgeStore', () => { it('canWarn clamps negative elapsed under clock skew (treats as still in window)', async () => { const t0 = new Date('2026-05-19T12:00:00.000Z') - const store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t0 }) + const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t0 }) await store.markWarned(HOST) const pastClock = new Date('2026-05-19T11:00:00.000Z') // clock moved backwards 1h expect(store.canWarn(HOST, pastClock)).toBe(false) @@ -45,22 +52,22 @@ describe('NudgeStore', () => { it('markWarned persists across store reloads', async () => { const t0 = new Date('2026-05-19T12:00:00.000Z') - const s1 = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t0 }) + const s1 = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t0 }) await s1.markWarned(HOST) - const s2 = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t0 }) + const s2 = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t0 }) expect(s2.canWarn(HOST)).toBe(false) }) it('treats a corrupt cache file as empty', async () => { const path = nudgeStorePath(dir) await writeCacheFile(path, '{ not valid json') - const store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)) }) + const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE) }) expect(store.canWarn(HOST)).toBe(true) }) it('writes ISO timestamps under warned/ on disk', async () => { const t = new Date('2026-05-19T12:00:00.000Z') - const store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t }) + const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t }) await store.markWarned(HOST) const raw = await readFile(nudgeStorePath(dir), 'utf8') const parsed = yaml.load(raw) as Record @@ -72,11 +79,11 @@ describe('NudgeStore', () => { // warns about a different host. Without merge-on-write the second writer // would clobber the first. const t = new Date('2026-05-19T12:00:00.000Z') - const a = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t }) - const b = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t }) + const a = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t }) + const b = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t }) await a.markWarned('https://a.example') await b.markWarned('https://b.example') - const reread = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t }) + const reread = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t }) expect(reread.canWarn('https://a.example')).toBe(false) expect(reread.canWarn('https://b.example')).toBe(false) }) diff --git a/cli/src/commands/_shared/authed-command.ts b/cli/src/commands/_shared/authed-command.ts index 67c1378f24..44aea53c2b 100644 --- a/cli/src/commands/_shared/authed-command.ts +++ b/cli/src/commands/_shared/authed-command.ts @@ -12,7 +12,6 @@ import { BaseError } from '../../errors/base.js' import { ErrorCode } from '../../errors/codes.js' import { formatErrorForCli } from '../../errors/format.js' import { createClient } from '../../http/client.js' -import { resolveConfigDir } from '../../store/dir.js' import { realStreams } from '../../sys/io/streams' import { hostWithScheme } from '../../util/host.js' import { versionInfo } from '../../version/info.js' @@ -24,7 +23,6 @@ export type AuthedContext = { readonly http: KyInstance readonly host: string readonly io: IOStreams - readonly configDir: string readonly cache?: AppInfoCache } @@ -38,9 +36,8 @@ export async function buildAuthedContext( cmd: Pick, opts: AuthedContextOptions, ): Promise { - const configDir = resolveConfigDir() const io = realStreams(opts.format ?? '') - const bundle = await loadHosts(configDir) + const bundle = loadHosts() if (bundle === undefined || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') { const err = new BaseError({ code: ErrorCode.NotLoggedIn, @@ -61,7 +58,7 @@ export async function buildAuthedContext( await runCompatNudge({ host, io }) - return { bundle, http, host, io, configDir, cache } + return { bundle, http, host, io, cache } } // Best-effort nudge: never throws, never blocks. Lives here so every authed diff --git a/cli/src/commands/auth/devices/_shared/devices.test.ts b/cli/src/commands/auth/devices/_shared/devices.test.ts index 5d96a6ca64..6c66ef53c8 100644 --- a/cli/src/commands/auth/devices/_shared/devices.test.ts +++ b/cli/src/commands/auth/devices/_shared/devices.test.ts @@ -2,7 +2,7 @@ import type { SessionListResponse, SessionRow } from '@dify/contracts/api/openap import type { DifyMock } from '../../../../../test/fixtures/dify-mock/server.js' import type { AccountSessionsClient } from '../../../../api/account-sessions.js' import type { HostsBundle } from '../../../../auth/hosts.js' -import type { TokenStore } from '../../../../auth/store.js' +import type { Key, Store } from '../../../../store/store.js' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -10,26 +10,23 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { startMock } from '../../../../../test/fixtures/dify-mock/server.js' import { saveHosts } from '../../../../auth/hosts.js' import { createClient } from '../../../../http/client.js' +import { ENV_CONFIG_DIR, resolveConfigDir } from '../../../../store/dir.js' +import { tokenKey } from '../../../../store/manager.js' import { bufferStreams } from '../../../../sys/io/streams' import { listAllSessions, runDevicesList, runDevicesRevoke } from './devices.js' -class MemStore implements TokenStore { - readonly entries = new Map() - async put(host: string, accountId: string, token: string): Promise { - this.entries.set(`${host}::${accountId}`, token) +class MemStore implements Store { + readonly entries = new Map() + get(key: Key): T { + return (this.entries.get(key.key) as T | undefined) ?? key.default } - async get(host: string, accountId: string): Promise { - return this.entries.get(`${host}::${accountId}`) + set(key: Key, value: T): void { + this.entries.set(key.key, value) } - async delete(host: string, accountId: string): Promise { - this.entries.delete(`${host}::${accountId}`) - } - - async list(host: string): Promise { - const prefix = `${host}::` - return Array.from(this.entries.keys()).filter(k => k.startsWith(prefix)) + unset(key: Key): void { + this.entries.delete(key.key) } } @@ -93,11 +90,18 @@ describe('runDevicesList', () => { describe('runDevicesRevoke', () => { let mock: DifyMock let configDir: string + let prevConfigDir: string | undefined beforeEach(async () => { mock = await startMock({ scenario: 'happy' }) configDir = await mkdtemp(join(tmpdir(), 'difyctl-devrevoke-')) + prevConfigDir = process.env[ENV_CONFIG_DIR] + process.env[ENV_CONFIG_DIR] = configDir }) afterEach(async () => { + if (prevConfigDir === undefined) + delete process.env[ENV_CONFIG_DIR] + else + process.env[ENV_CONFIG_DIR] = prevConfigDir await mock.stop() await rm(configDir, { recursive: true, force: true }) }) @@ -106,11 +110,11 @@ describe('runDevicesRevoke', () => { const io = bufferStreams() const store = new MemStore() const b = bundleFor(mock.url, 'tok-1') - await store.put(b.current_host, 'acct-1', 'dfoa_test') - await saveHosts(configDir, b) + store.set(tokenKey(b.current_host, 'acct-1'), 'dfoa_test') + saveHosts(b) const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) - await runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'difyctl on desktop', all: false }) + await runDevicesRevoke({ io, bundle: b, http, store, target: 'difyctl on desktop', all: false }) expect(io.outBuf()).toContain('Revoked 1 session(s)') expect(store.entries.size).toBe(1) }) @@ -121,7 +125,7 @@ describe('runDevicesRevoke', () => { const b = bundleFor(mock.url, 'tok-1') const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) - await runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'tok-2', all: false }) + await runDevicesRevoke({ io, bundle: b, http, store, target: 'tok-2', all: false }) expect(io.outBuf()).toContain('Revoked 1 session(s)') }) @@ -131,7 +135,7 @@ describe('runDevicesRevoke', () => { const b = bundleFor(mock.url, 'tok-1') const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) - await runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'web', all: false }) + await runDevicesRevoke({ io, bundle: b, http, store, target: 'web', all: false }) expect(io.outBuf()).toContain('Revoked 1 session(s)') }) @@ -141,7 +145,7 @@ describe('runDevicesRevoke', () => { const b = bundleFor(mock.url, 'tok-1') const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) - await expect(runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'difyctl', all: false })) + await expect(runDevicesRevoke({ io, bundle: b, http, store, target: 'difyctl', all: false })) .rejects .toThrow(/matches multiple/) }) @@ -152,7 +156,7 @@ describe('runDevicesRevoke', () => { const b = bundleFor(mock.url, 'tok-1') const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) - await expect(runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'nonexistent', all: false })) + await expect(runDevicesRevoke({ io, bundle: b, http, store, target: 'nonexistent', all: false })) .rejects .toThrow(/no session matches/) }) @@ -163,7 +167,7 @@ describe('runDevicesRevoke', () => { const b = bundleFor(mock.url, 'tok-1') const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) - await runDevicesRevoke({ configDir, io, bundle: b, http, store, all: true }) + await runDevicesRevoke({ io, bundle: b, http, store, all: true }) expect(io.outBuf()).toContain('Revoked 2 session(s)') }) @@ -171,20 +175,20 @@ describe('runDevicesRevoke', () => { const io = bufferStreams() const store = new MemStore() const b = bundleFor(mock.url, 'tok-1') - await store.put(b.current_host, 'acct-1', 'dfoa_test') - await saveHosts(configDir, b) + store.set(tokenKey(b.current_host, 'acct-1'), 'dfoa_test') + saveHosts(b) const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) - await runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'tok-1', all: false }) + await runDevicesRevoke({ io, bundle: b, http, store, target: 'tok-1', all: false }) expect(store.entries.size).toBe(0) - await expect(readFile(join(configDir, 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/) + await expect(readFile(join(resolveConfigDir(), 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/) }) it('no target + no --all: throws UsageMissingArg', async () => { const io = bufferStreams() const store = new MemStore() const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) - await expect(runDevicesRevoke({ configDir, io, bundle: bundleFor(mock.url), http, store, all: false })) + await expect(runDevicesRevoke({ io, bundle: bundleFor(mock.url), http, store, all: false })) .rejects .toThrow(/specify a device label/) }) diff --git a/cli/src/commands/auth/devices/_shared/devices.ts b/cli/src/commands/auth/devices/_shared/devices.ts index 78a0cba73b..6734a2a9e6 100644 --- a/cli/src/commands/auth/devices/_shared/devices.ts +++ b/cli/src/commands/auth/devices/_shared/devices.ts @@ -1,15 +1,14 @@ import type { SessionRow } from '@dify/contracts/api/openapi/types.gen' import type { KyInstance } from 'ky' import type { HostsBundle } from '../../../../auth/hosts.js' -import type { TokenStore } from '../../../../auth/store.js' +import type { Store } from '../../../../store/store.js' import type { IOStreams } from '../../../../sys/io/streams' -import { unlink } from 'node:fs/promises' -import { join } from 'node:path' import { AccountSessionsClient } from '../../../../api/account-sessions.js' -import { HOSTS_FILE_NAME } from '../../../../auth/hosts.js' +import { clearLocal } from '../../../../auth/hosts.js' import { BaseError } from '../../../../errors/base.js' import { ErrorCode } from '../../../../errors/codes.js' import { LIMIT_DEFAULT, LIMIT_MAX, parseLimit } from '../../../../limit/limit.js' +import { getTokenStore } from '../../../../store/manager.js' import { colorEnabled, colorScheme } from '../../../../sys/io/color.js' import { runWithSpinner } from '../../../../sys/io/spinner.js' @@ -72,11 +71,11 @@ export async function listAllSessions(client: AccountSessionsClient): Promise c.padEnd(widths[i] ?? 0)).join(' ').trimEnd() return body.length === 0 ? `${fmt(header)}\n` : `${[fmt(header), ...body.map(fmt)].join('\n')}\n` } - -async function clearLocal(configDir: string, bundle: HostsBundle, store: TokenStore): Promise { - const accountId = bundle.account?.id ?? bundle.external_subject?.email ?? 'default' - try { - await store.delete(bundle.current_host, accountId) - } - catch { /* best-effort */ } - try { - await unlink(join(configDir, HOSTS_FILE_NAME)) - } - catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') - throw err - } -} diff --git a/cli/src/commands/auth/devices/revoke/index.ts b/cli/src/commands/auth/devices/revoke/index.ts index 1d4f1b0db4..709bb2ed69 100644 --- a/cli/src/commands/auth/devices/revoke/index.ts +++ b/cli/src/commands/auth/devices/revoke/index.ts @@ -1,4 +1,3 @@ -import { selectStore } from '../../../../auth/store.js' import { Args, Flags } from '../../../../framework/flags.js' import { DifyCommand } from '../../../_shared/dify-command.js' import { httpRetryFlag } from '../../../_shared/global-flags.js' @@ -25,13 +24,10 @@ export default class DevicesRevoke extends DifyCommand { async run(argv: string[]): Promise { const { args, flags } = this.parse(DevicesRevoke, argv) const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] }) - const { store } = await selectStore({ configDir: ctx.configDir }) await runDevicesRevoke({ - configDir: ctx.configDir, io: ctx.io, bundle: ctx.bundle, http: ctx.http, - store, target: args.target, all: flags.all, yes: flags.yes, diff --git a/cli/src/commands/auth/login/index.ts b/cli/src/commands/auth/login/index.ts index dadce6f990..0e7cd3f88e 100644 --- a/cli/src/commands/auth/login/index.ts +++ b/cli/src/commands/auth/login/index.ts @@ -1,5 +1,4 @@ import { Flags } from '../../../framework/flags.js' -import { resolveConfigDir } from '../../../store/dir.js' import { realStreams } from '../../../sys/io/streams' import { DifyCommand } from '../../_shared/dify-command.js' import { runLogin } from './login.js' @@ -31,7 +30,6 @@ export default class Login extends DifyCommand { async run(argv: string[]): Promise { const { flags } = this.parse(Login, argv) await runLogin({ - configDir: resolveConfigDir(), io: realStreams(), host: flags.host, noBrowser: flags['no-browser'], diff --git a/cli/src/commands/auth/login/login.test.ts b/cli/src/commands/auth/login/login.test.ts index c2e4749a3f..c93a2a824f 100644 --- a/cli/src/commands/auth/login/login.test.ts +++ b/cli/src/commands/auth/login/login.test.ts @@ -1,5 +1,5 @@ import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js' -import type { TokenStore } from '../../../auth/store.js' +import type { Key, Store } from '../../../store/store.js' import type { Clock } from './device-flow.js' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -8,6 +8,8 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { startMock } from '../../../../test/fixtures/dify-mock/server.js' import { DeviceFlowApi } from '../../../api/oauth-device.js' import { createClient } from '../../../http/client.js' +import { ENV_CONFIG_DIR } from '../../../store/dir.js' +import { tokenKey } from '../../../store/manager.js' import { bufferStreams } from '../../../sys/io/streams' import { runLogin } from './login.js' @@ -18,38 +20,38 @@ const noopClock: Clock = { const noopBrowser = async (): Promise => { /* skip OS open */ } -class MemStore implements TokenStore { - readonly entries = new Map() - async put(host: string, accountId: string, token: string): Promise { - this.entries.set(`${host}::${accountId}`, token) +class MemStore implements Store { + readonly entries = new Map() + get(key: Key): T { + return (this.entries.get(key.key) as T | undefined) ?? key.default } - async get(host: string, accountId: string): Promise { - return this.entries.get(`${host}::${accountId}`) + set(key: Key, value: T): void { + this.entries.set(key.key, value) } - async delete(host: string, accountId: string): Promise { - this.entries.delete(`${host}::${accountId}`) - } - - async list(host: string): Promise { - const prefix = `${host}::` - return Array.from(this.entries.keys()) - .filter(k => k.startsWith(prefix)) - .map(k => k.slice(prefix.length)) + unset(key: Key): void { + this.entries.delete(key.key) } } describe('runLogin', () => { let mock: DifyMock let configDir: string + let prevConfigDir: string | undefined beforeEach(async () => { mock = await startMock({ scenario: 'happy' }) configDir = await mkdtemp(join(tmpdir(), 'difyctl-login-')) + prevConfigDir = process.env[ENV_CONFIG_DIR] + process.env[ENV_CONFIG_DIR] = configDir }) afterEach(async () => { + if (prevConfigDir === undefined) + delete process.env[ENV_CONFIG_DIR] + else + process.env[ENV_CONFIG_DIR] = prevConfigDir await mock.stop() await rm(configDir, { recursive: true, force: true }) }) @@ -58,7 +60,6 @@ describe('runLogin', () => { const io = bufferStreams() const store = new MemStore() const bundle = await runLogin({ - configDir, io, host: mock.url, noBrowser: true, @@ -73,7 +74,7 @@ describe('runLogin', () => { expect(bundle.account?.email).toBe('tester@dify.ai') expect(bundle.workspace?.id).toBe('ws-1') expect(bundle.available_workspaces).toHaveLength(2) - const stored = await store.get(bundle.current_host, 'acct-1') + const stored = store.get(tokenKey(bundle.current_host, 'acct-1')) expect(stored).toBe('dfoa_test') const hostsRaw = await readFile(join(configDir, 'hosts.yml'), 'utf8') @@ -91,7 +92,6 @@ describe('runLogin', () => { const io = bufferStreams() const store = new MemStore() const bundle = await runLogin({ - configDir, io, host: mock.url, noBrowser: true, @@ -115,7 +115,6 @@ describe('runLogin', () => { const io = bufferStreams() const store = new MemStore() await expect(runLogin({ - configDir, io, host: mock.url, noBrowser: true, @@ -135,7 +134,6 @@ describe('runLogin', () => { const io = bufferStreams() const store = new MemStore() await expect(runLogin({ - configDir, io, host: mock.url, noBrowser: true, @@ -152,7 +150,6 @@ describe('runLogin', () => { const io = bufferStreams() const store = new MemStore() await expect(runLogin({ - configDir, io, host: mock.url, noBrowser: true, @@ -169,7 +166,6 @@ describe('runLogin', () => { const io = bufferStreams() const store = new MemStore() await runLogin({ - configDir, io, host: mock.url, noBrowser: true, diff --git a/cli/src/commands/auth/login/login.ts b/cli/src/commands/auth/login/login.ts index 77a5c00b94..b06dca24f3 100644 --- a/cli/src/commands/auth/login/login.ts +++ b/cli/src/commands/auth/login/login.ts @@ -1,6 +1,6 @@ import type { CodeResponse, PollSuccess } from '../../../api/oauth-device.js' -import type { HostsBundle, StorageMode, Workspace } from '../../../auth/hosts.js' -import type { TokenStore } from '../../../auth/store.js' +import type { HostsBundle, Workspace } from '../../../auth/hosts.js' +import type { StorageMode, Store } from '../../../store/store.js' import type { IOStreams } from '../../../sys/io/streams' import type { BrowserEnv, BrowserOpener } from '../../../util/browser.js' import type { Clock } from './device-flow.js' @@ -8,21 +8,20 @@ import * as os from 'node:os' import * as readline from 'node:readline' import { DeviceFlowApi } from '../../../api/oauth-device.js' import { saveHosts } from '../../../auth/hosts.js' -import { selectStore } from '../../../auth/store.js' import { createClient } from '../../../http/client.js' +import { getTokenStore, tokenKey } from '../../../store/manager.js' import { colorEnabled, colorScheme } from '../../../sys/io/color.js' import { decideOpen, OpenDecision, openUrl, realEnv } from '../../../util/browser.js' import { bareHost, DEFAULT_HOST, resolveHost, validateVerificationURI } from '../../../util/host.js' import { awaitAuthorization, realClock } from './device-flow.js' export type LoginOptions = { - readonly configDir: string readonly io: IOStreams readonly host?: string readonly noBrowser?: boolean readonly insecure?: boolean readonly deviceLabel?: string - readonly store?: { readonly store: TokenStore, readonly mode: StorageMode } + readonly store?: { readonly store: Store, readonly mode: StorageMode } readonly api?: DeviceFlowApi readonly browserEnv?: BrowserEnv readonly browserOpener?: BrowserOpener @@ -59,11 +58,11 @@ export async function runLogin(opts: LoginOptions): Promise { const success = await awaitAuthorization(api, code, { clock: opts.clock ?? realClock() }) - const storeBundle = opts.store ?? await selectStore({ configDir: opts.configDir }) + const storeBundle = opts.store ?? getTokenStore() const bundle = bundleFromSuccess(host, success, storeBundle.mode) - await storeBundle.store.put(bundle.current_host, accountKey(bundle), success.token) - await saveHosts(opts.configDir, bundle) + storeBundle.store.set(tokenKey(bundle.current_host, accountKey(bundle)), success.token) + saveHosts(bundle) renderLoggedIn(opts.io.out, cs, host, success) return bundle diff --git a/cli/src/commands/auth/logout/index.ts b/cli/src/commands/auth/logout/index.ts index 7915abb242..f80f2d3fc9 100644 --- a/cli/src/commands/auth/logout/index.ts +++ b/cli/src/commands/auth/logout/index.ts @@ -1,8 +1,6 @@ import type { KyInstance } from 'ky' import { loadHosts } from '../../../auth/hosts.js' -import { selectStore } from '../../../auth/store.js' import { createClient } from '../../../http/client.js' -import { resolveConfigDir } from '../../../store/dir.js' import { runWithSpinner } from '../../../sys/io/spinner.js' import { realStreams } from '../../../sys/io/streams' import { hostWithScheme } from '../../../util/host.js' @@ -18,9 +16,7 @@ export default class Logout extends DifyCommand { async run(argv: string[]): Promise { this.parse(Logout, argv) - const configDir = resolveConfigDir() - const bundle = await loadHosts(configDir) - const { store } = await selectStore({ configDir }) + const bundle = loadHosts() let http: KyInstance | undefined if (bundle !== undefined && bundle.current_host !== '' && bundle.tokens?.bearer !== undefined && bundle.tokens.bearer !== '') { @@ -34,7 +30,7 @@ export default class Logout extends DifyCommand { const io = realStreams() await runWithSpinner( { io, label: 'Signing out', enabled: true, style: 'dify-dim' }, - () => runLogout({ configDir, io, bundle, http, store }), + () => runLogout({ io, bundle, http }), ) } } diff --git a/cli/src/commands/auth/logout/logout.test.ts b/cli/src/commands/auth/logout/logout.test.ts index 73bd8429bb..621cf1555e 100644 --- a/cli/src/commands/auth/logout/logout.test.ts +++ b/cli/src/commands/auth/logout/logout.test.ts @@ -1,6 +1,6 @@ import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js' import type { HostsBundle } from '../../../auth/hosts.js' -import type { TokenStore } from '../../../auth/store.js' +import type { Key, Store } from '../../../store/store.js' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -8,28 +8,23 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { startMock } from '../../../../test/fixtures/dify-mock/server.js' import { saveHosts } from '../../../auth/hosts.js' import { createClient } from '../../../http/client.js' +import { ENV_CONFIG_DIR } from '../../../store/dir.js' +import { tokenKey } from '../../../store/manager.js' import { bufferStreams } from '../../../sys/io/streams' import { runLogout } from './logout.js' -class MemStore implements TokenStore { - readonly entries = new Map() - async put(host: string, accountId: string, token: string): Promise { - this.entries.set(`${host}::${accountId}`, token) +class MemStore implements Store { + readonly entries = new Map() + get(key: Key): T { + return (this.entries.get(key.key) as T | undefined) ?? key.default } - async get(host: string, accountId: string): Promise { - return this.entries.get(`${host}::${accountId}`) + set(key: Key, value: T): void { + this.entries.set(key.key, value) } - async delete(host: string, accountId: string): Promise { - this.entries.delete(`${host}::${accountId}`) - } - - async list(host: string): Promise { - const prefix = `${host}::` - return Array.from(this.entries.keys()) - .filter(k => k.startsWith(prefix)) - .map(k => k.slice(prefix.length)) + unset(key: Key): void { + this.entries.delete(key.key) } } @@ -52,13 +47,20 @@ function fixtureBundle(host: string): HostsBundle { describe('runLogout', () => { let mock: DifyMock let configDir: string + let prevConfigDir: string | undefined beforeEach(async () => { mock = await startMock({ scenario: 'happy' }) configDir = await mkdtemp(join(tmpdir(), 'difyctl-logout-')) + prevConfigDir = process.env[ENV_CONFIG_DIR] + process.env[ENV_CONFIG_DIR] = configDir }) afterEach(async () => { + if (prevConfigDir === undefined) + delete process.env[ENV_CONFIG_DIR] + else + process.env[ENV_CONFIG_DIR] = prevConfigDir await mock.stop() await rm(configDir, { recursive: true, force: true }) }) @@ -67,11 +69,11 @@ describe('runLogout', () => { const io = bufferStreams() const store = new MemStore() const bundle = fixtureBundle(mock.url) - await store.put(bundle.current_host, 'acct-1', 'dfoa_test') - await saveHosts(configDir, bundle) + store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfoa_test') + saveHosts(bundle) const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) - await runLogout({ configDir, io, bundle, http, store }) + await runLogout({ io, bundle, http, store }) expect(store.entries.size).toBe(0) await expect(readFile(join(configDir, 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/) @@ -82,7 +84,7 @@ describe('runLogout', () => { it('not-logged-in: throws BaseError', async () => { const io = bufferStreams() const store = new MemStore() - await expect(runLogout({ configDir, io, bundle: undefined, store })).rejects.toThrow(/not logged in/) + await expect(runLogout({ io, bundle: undefined, store })).rejects.toThrow(/not logged in/) }) it('hosts.yml absent: still completes locally + emits success', async () => { @@ -91,7 +93,7 @@ describe('runLogout', () => { const bundle = fixtureBundle(mock.url) const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) - await runLogout({ configDir, io, bundle, http, store }) + await runLogout({ io, bundle, http, store }) expect(io.outBuf()).toContain('Logged out of') }) @@ -100,12 +102,12 @@ describe('runLogout', () => { const io = bufferStreams() const store = new MemStore() const bundle = fixtureBundle(mock.url) - await store.put(bundle.current_host, 'acct-1', 'dfoa_test') - await saveHosts(configDir, bundle) + store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfoa_test') + saveHosts(bundle) mock.setScenario('server-5xx') const http = createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }) - await runLogout({ configDir, io, bundle, http, store }) + await runLogout({ io, bundle, http, store }) expect(store.entries.size).toBe(0) expect(io.errBuf()).toContain('server revoke failed') @@ -117,11 +119,11 @@ describe('runLogout', () => { const store = new MemStore() const bundle = fixtureBundle(mock.url) bundle.tokens = { bearer: 'dfp_personal_token' } - await store.put(bundle.current_host, 'acct-1', 'dfp_personal_token') - await saveHosts(configDir, bundle) + store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfp_personal_token') + saveHosts(bundle) const http = createClient({ host: mock.url, bearer: 'dfp_personal_token' }) - await runLogout({ configDir, io, bundle, http, store }) + await runLogout({ io, bundle, http, store }) expect(io.errBuf()).toBe('') expect(store.entries.size).toBe(0) @@ -131,11 +133,11 @@ describe('runLogout', () => { const io = bufferStreams() const store = new MemStore() const bundle = fixtureBundle(mock.url) - await saveHosts(configDir, bundle) + saveHosts(bundle) await writeFile(join(configDir, 'config.yml'), 'foo: bar\n', 'utf8') const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) - await runLogout({ configDir, io, bundle, http, store }) + await runLogout({ io, bundle, http, store }) const cfg = await readFile(join(configDir, 'config.yml'), 'utf8') expect(cfg).toContain('foo: bar') diff --git a/cli/src/commands/auth/logout/logout.ts b/cli/src/commands/auth/logout/logout.ts index ddcee3b5d4..3090ed09a7 100644 --- a/cli/src/commands/auth/logout/logout.ts +++ b/cli/src/commands/auth/logout/logout.ts @@ -1,21 +1,20 @@ import type { KyInstance } from 'ky' import type { HostsBundle } from '../../../auth/hosts.js' -import type { TokenStore } from '../../../auth/store.js' +import type { Store } from '../../../store/store.js' import type { IOStreams } from '../../../sys/io/streams' -import { unlink } from 'node:fs/promises' -import { join } from 'node:path' import { AccountSessionsClient } from '../../../api/account-sessions.js' -import { HOSTS_FILE_NAME } from '../../../auth/hosts.js' +import { clearLocal } from '../../../auth/hosts.js' import { BaseError } from '../../../errors/base.js' import { ErrorCode } from '../../../errors/codes.js' +import { getTokenStore } from '../../../store/manager.js' import { colorEnabled, colorScheme } from '../../../sys/io/color.js' export type LogoutOptions = { - readonly configDir: string readonly io: IOStreams readonly bundle: HostsBundle | undefined readonly http?: KyInstance - readonly store: TokenStore + /** Optional override for tests; production code resolves via `getTokenStore`. */ + readonly store?: Store } export async function runLogout(opts: LogoutOptions): Promise { @@ -40,7 +39,8 @@ export async function runLogout(opts: LogoutOptions): Promise { } } - await clearLocal(opts.configDir, bundle, opts.store) + const tokens = opts.store ?? getTokenStore().store + clearLocal(bundle, tokens) if (revokeWarning !== '') opts.io.err.write(revokeWarning) @@ -52,19 +52,3 @@ const REVOCABLE_PREFIXES = ['dfoa_', 'dfoe_'] as const function revokeAllowed(bearer: string): boolean { return REVOCABLE_PREFIXES.some(p => bearer.startsWith(p)) } - -async function clearLocal(configDir: string, bundle: HostsBundle, store: TokenStore): Promise { - const accountId = bundle.account?.id ?? bundle.external_subject?.email ?? 'default' - try { - await store.delete(bundle.current_host, accountId) - } - catch { /* best-effort */ } - const hostsPath = join(configDir, HOSTS_FILE_NAME) - try { - await unlink(hostsPath) - } - catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') - throw err - } -} diff --git a/cli/src/commands/auth/status/index.ts b/cli/src/commands/auth/status/index.ts index 57208b93f4..3e95374767 100644 --- a/cli/src/commands/auth/status/index.ts +++ b/cli/src/commands/auth/status/index.ts @@ -1,6 +1,5 @@ import { loadHosts } from '../../../auth/hosts.js' import { Flags } from '../../../framework/flags.js' -import { resolveConfigDir } from '../../../store/dir.js' import { realStreams } from '../../../sys/io/streams' import { DifyCommand } from '../../_shared/dify-command.js' import { runStatus } from './status.js' @@ -21,8 +20,7 @@ export default class Status extends DifyCommand { async run(argv: string[]): Promise { const { flags } = this.parse(Status, argv) - const configDir = resolveConfigDir() - const bundle = await loadHosts(configDir) + const bundle = loadHosts() await runStatus({ io: realStreams(), bundle, verbose: flags.verbose, json: flags.json }) } } diff --git a/cli/src/commands/auth/whoami/index.ts b/cli/src/commands/auth/whoami/index.ts index a89a6e76bf..e59db2d303 100644 --- a/cli/src/commands/auth/whoami/index.ts +++ b/cli/src/commands/auth/whoami/index.ts @@ -1,6 +1,5 @@ import { loadHosts } from '../../../auth/hosts.js' import { Flags } from '../../../framework/flags.js' -import { resolveConfigDir } from '../../../store/dir.js' import { realStreams } from '../../../sys/io/streams' import { DifyCommand } from '../../_shared/dify-command.js' import { runWhoami } from './whoami.js' @@ -19,8 +18,7 @@ export default class Whoami extends DifyCommand { async run(argv: string[]): Promise { const { flags } = this.parse(Whoami, argv) - const configDir = resolveConfigDir() - const bundle = await loadHosts(configDir) + const bundle = loadHosts() await runWhoami({ io: realStreams(), bundle, json: flags.json }) } } diff --git a/cli/src/commands/config/get/run.test.ts b/cli/src/commands/config/get/run.test.ts index 5f594bd7de..cfe7d12779 100644 --- a/cli/src/commands/config/get/run.test.ts +++ b/cli/src/commands/config/get/run.test.ts @@ -1,43 +1,49 @@ -import { mkdtemp, writeFile } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { beforeEach, describe, expect, it } from 'vitest' -import { FILE_NAME } from '../../../config/schema.js' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { isBaseError } from '../../../errors/base.js' import { ErrorCode } from '../../../errors/codes.js' -import { YamlStore } from '../../../store/store.js' +import { ENV_CONFIG_DIR } from '../../../store/dir.js' +import { getConfigurationStore } from '../../../store/manager.js' import { runConfigGet } from './run.js' -function makeStore(dir: string): YamlStore { - return new YamlStore(join(dir, FILE_NAME)) -} - describe('runConfigGet', () => { let dir: string + let prevConfigDir: string | undefined beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'difyctl-get-')) + prevConfigDir = process.env[ENV_CONFIG_DIR] + process.env[ENV_CONFIG_DIR] = dir }) - it('returns set value with trailing newline', async () => { - await writeFile( - join(dir, FILE_NAME), - 'schema_version: 1\ndefaults:\n format: yaml\n', - 'utf8', - ) - const out = runConfigGet({ store: makeStore(dir), key: 'defaults.format' }) + afterEach(async () => { + if (prevConfigDir === undefined) + delete process.env[ENV_CONFIG_DIR] + else + process.env[ENV_CONFIG_DIR] = prevConfigDir + await rm(dir, { recursive: true, force: true }) + }) + + it('returns set value with trailing newline', () => { + getConfigurationStore().setTyped({ + schema_version: 1, + defaults: { format: 'yaml' }, + }) + const out = 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: makeStore(dir), key: 'defaults.format' }) + const out = runConfigGet({ store: getConfigurationStore(), key: 'defaults.format' }) expect(out).toBe('\n') }) it('throws BaseError(config_invalid_key) on unknown key', () => { let caught: unknown try { - runConfigGet({ store: makeStore(dir), key: 'bogus.key' }) + runConfigGet({ store: getConfigurationStore(), key: 'bogus.key' }) } catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) @@ -45,13 +51,12 @@ describe('runConfigGet', () => { expect(caught.code).toBe(ErrorCode.ConfigInvalidKey) }) - it('returns numeric limit as string', async () => { - await writeFile( - join(dir, FILE_NAME), - 'schema_version: 1\ndefaults:\n limit: 75\n', - 'utf8', - ) - const out = runConfigGet({ store: makeStore(dir), key: 'defaults.limit' }) + it('returns numeric limit as string', () => { + getConfigurationStore().setTyped({ + schema_version: 1, + defaults: { limit: 75 }, + }) + const out = runConfigGet({ store: getConfigurationStore(), key: 'defaults.limit' }) expect(out).toBe('75\n') }) }) diff --git a/cli/src/commands/config/path/index.ts b/cli/src/commands/config/path/index.ts index 466aa6a6db..2bf22c8988 100644 --- a/cli/src/commands/config/path/index.ts +++ b/cli/src/commands/config/path/index.ts @@ -1,7 +1,8 @@ +import { join } from 'node:path' import { raw } from '../../../framework/output.js' import { resolveConfigDir } from '../../../store/dir.js' +import { CONFIG_FILE_NAME } from '../../../store/manager.js' import { DifyCommand } from '../../_shared/dify-command.js' -import { runConfigPath } from './run.js' export default class ConfigPath extends DifyCommand { static override description = 'Print the resolved config.yml path' @@ -12,6 +13,8 @@ export default class ConfigPath extends DifyCommand { async run(argv: string[]) { this.parse(ConfigPath, argv) - return raw(runConfigPath({ dir: resolveConfigDir() })) + return raw( + join(resolveConfigDir(), CONFIG_FILE_NAME), + ) } } diff --git a/cli/src/commands/config/path/run.test.ts b/cli/src/commands/config/path/run.test.ts deleted file mode 100644 index e9df22f85a..0000000000 --- a/cli/src/commands/config/path/run.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { runConfigPath } from './run.js' - -describe('runConfigPath', () => { - it('joins dir and config.yml with trailing newline', () => { - const out = runConfigPath({ dir: '/tmp/x' }) - expect(out).toBe('/tmp/x/config.yml\n') - }) - - it('handles trailing slash on dir', () => { - const out = runConfigPath({ dir: '/tmp/x/' }) - expect(out).toBe('/tmp/x/config.yml\n') - }) -}) diff --git a/cli/src/commands/config/path/run.ts b/cli/src/commands/config/path/run.ts deleted file mode 100644 index 88c03bc14d..0000000000 --- a/cli/src/commands/config/path/run.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { join } from 'node:path' -import { FILE_NAME } from '../../../config/schema.js' - -export type RunConfigPathOptions = { - readonly dir: string -} - -export function runConfigPath(opts: RunConfigPathOptions): string { - return `${join(opts.dir, FILE_NAME)}\n` -} diff --git a/cli/src/commands/config/set/run.test.ts b/cli/src/commands/config/set/run.test.ts index 54be290271..dcb72a40d5 100644 --- a/cli/src/commands/config/set/run.test.ts +++ b/cli/src/commands/config/set/run.test.ts @@ -1,35 +1,46 @@ -import { mkdtemp, readFile } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { beforeEach, describe, expect, it } from 'vitest' -import { FILE_NAME } from '../../../config/schema.js' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { loadConfig } from '../../../config/config-loader.js' import { isBaseError } from '../../../errors/base.js' import { ErrorCode, ExitCode } from '../../../errors/codes.js' -import { YamlStore } from '../../../store/store.js' +import { ENV_CONFIG_DIR } from '../../../store/dir.js' +import { getConfigurationStore } from '../../../store/manager.js' import { runConfigSet } from './run.js' -function makeStore(dir: string): YamlStore { - return new YamlStore(join(dir, FILE_NAME)) -} - describe('runConfigSet', () => { let dir: string + let prevConfigDir: string | undefined beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'difyctl-set-')) + prevConfigDir = process.env[ENV_CONFIG_DIR] + process.env[ENV_CONFIG_DIR] = dir }) - it('writes config.yml and returns "set k = v\\n"', async () => { - const out = runConfigSet({ store: makeStore(dir), key: 'defaults.format', value: 'json' }) + afterEach(async () => { + if (prevConfigDir === undefined) + delete process.env[ENV_CONFIG_DIR] + else + process.env[ENV_CONFIG_DIR] = prevConfigDir + await rm(dir, { recursive: true, force: true }) + }) + + it('persists the value and returns "set k = v\\n"', () => { + const out = runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'json' }) expect(out).toBe('set defaults.format = json\n') - const raw = await readFile(join(dir, FILE_NAME), 'utf8') - expect(raw).toContain('format: json') + + const r = 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', async () => { + it('rejects invalid format value with config_invalid_value', () => { let caught: unknown try { - runConfigSet({ store: makeStore(dir), key: 'defaults.format', value: 'csv' }) + runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'csv' }) } catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) @@ -40,7 +51,7 @@ describe('runConfigSet', () => { it('rejects unknown key with config_invalid_key', () => { let caught: unknown try { - runConfigSet({ store: makeStore(dir), key: 'bogus', value: 'x' }) + runConfigSet({ store: getConfigurationStore(), key: 'bogus', value: 'x' }) } catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) @@ -48,18 +59,22 @@ describe('runConfigSet', () => { expect(caught.code).toBe(ErrorCode.ConfigInvalidKey) }) - it('preserves prior keys when setting a new one', async () => { - runConfigSet({ store: makeStore(dir), key: 'defaults.format', value: 'yaml' }) - runConfigSet({ store: makeStore(dir), key: 'defaults.limit', value: '40' }) - const raw = await readFile(join(dir, FILE_NAME), 'utf8') - expect(raw).toContain('format: yaml') - expect(raw).toContain('limit: 40') + 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' }) + + const r = loadConfig(getConfigurationStore()) + expect(r.found).toBe(true) + if (r.found) { + expect(r.config.defaults.format).toBe('yaml') + expect(r.config.defaults.limit).toBe(40) + } }) it('exit code for invalid value is Usage (2)', () => { let caught: unknown try { - runConfigSet({ store: makeStore(dir), key: 'defaults.format', value: 'csv' }) + runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'csv' }) } catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) @@ -70,7 +85,7 @@ describe('runConfigSet', () => { it('exit code for unknown key is Usage (2)', () => { let caught: unknown try { - runConfigSet({ store: makeStore(dir), key: 'bogus', value: 'x' }) + runConfigSet({ store: getConfigurationStore(), key: 'bogus', value: 'x' }) } catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) @@ -81,7 +96,7 @@ describe('runConfigSet', () => { it('typed wrap chain: invalid defaults.limit surfaces ConfigInvalidValue (not UsageInvalidFlag)', () => { let caught: unknown try { - runConfigSet({ store: makeStore(dir), key: 'defaults.limit', value: 'abc' }) + runConfigSet({ store: getConfigurationStore(), key: 'defaults.limit', value: 'abc' }) } catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) diff --git a/cli/src/commands/config/unset/run.test.ts b/cli/src/commands/config/unset/run.test.ts index 53fbce6735..85a343d256 100644 --- a/cli/src/commands/config/unset/run.test.ts +++ b/cli/src/commands/config/unset/run.test.ts @@ -1,48 +1,61 @@ -import { mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { beforeEach, describe, expect, it } from 'vitest' -import { FILE_NAME } from '../../../config/schema.js' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { loadConfig } from '../../../config/config-loader.js' import { isBaseError } from '../../../errors/base.js' import { ErrorCode } from '../../../errors/codes.js' -import { YamlStore } from '../../../store/store.js' +import { ENV_CONFIG_DIR } from '../../../store/dir.js' +import { getConfigurationStore } from '../../../store/manager.js' import { runConfigUnset } from './run.js' -function makeStore(dir: string): YamlStore { - return new YamlStore(join(dir, FILE_NAME)) -} - describe('runConfigUnset', () => { let dir: string + let prevConfigDir: string | undefined beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'difyctl-unset-')) + prevConfigDir = process.env[ENV_CONFIG_DIR] + process.env[ENV_CONFIG_DIR] = dir }) - it('clears the requested key, leaves others intact', async () => { - await writeFile( - join(dir, FILE_NAME), - 'schema_version: 1\ndefaults:\n format: json\n limit: 25\n', - 'utf8', - ) - const out = runConfigUnset({ store: makeStore(dir), key: 'defaults.format' }) - expect(out).toBe('unset defaults.format\n') - const raw = await readFile(join(dir, FILE_NAME), 'utf8') - expect(raw).not.toContain('format:') - expect(raw).toContain('limit: 25') + afterEach(async () => { + if (prevConfigDir === undefined) + delete process.env[ENV_CONFIG_DIR] + else + process.env[ENV_CONFIG_DIR] = prevConfigDir + await rm(dir, { recursive: true, force: true }) }) - it('is a no-op (writes empty config) when key was already unset', async () => { - const out = runConfigUnset({ store: makeStore(dir), key: 'defaults.format' }) + it('clears the requested key, leaves others intact', () => { + getConfigurationStore().setTyped({ + schema_version: 1, + defaults: { format: 'json', limit: 25 }, + }) + const out = runConfigUnset({ store: getConfigurationStore(), key: 'defaults.format' }) expect(out).toBe('unset defaults.format\n') - const raw = await readFile(join(dir, FILE_NAME), 'utf8') - expect(raw).toContain('schema_version: 1') + + const r = loadConfig(getConfigurationStore()) + expect(r.found).toBe(true) + if (r.found) { + expect(r.config.defaults.format).not.toBe('json') + expect(r.config.defaults.limit).toBe(25) + } + }) + + it('is a no-op (writes empty config) when key was already unset', () => { + const out = runConfigUnset({ store: getConfigurationStore(), key: 'defaults.format' }) + expect(out).toBe('unset defaults.format\n') + const r = loadConfig(getConfigurationStore()) + expect(r.found).toBe(true) + if (r.found) + expect(r.config.schema_version).toBe(1) }) it('rejects unknown key', () => { let caught: unknown try { - runConfigUnset({ store: makeStore(dir), key: 'bogus' }) + runConfigUnset({ store: getConfigurationStore(), key: 'bogus' }) } catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) diff --git a/cli/src/commands/config/view/run.test.ts b/cli/src/commands/config/view/run.test.ts index 4716aad2f4..336f468796 100644 --- a/cli/src/commands/config/view/run.test.ts +++ b/cli/src/commands/config/view/run.test.ts @@ -1,67 +1,69 @@ -import { mkdtemp, writeFile } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { FILE_NAME } from '../../../config/schema.js' -import { YamlStore } from '../../../store/store.js' +import { ENV_CONFIG_DIR } from '../../../store/dir.js' +import { getConfigurationStore } from '../../../store/manager.js' import { runConfigView } from './run.js' -function makeStore(dir: string): YamlStore { - return new YamlStore(join(dir, FILE_NAME)) -} - describe('runConfigView', () => { let dir: string + let prevConfigDir: string | undefined beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'difyctl-view-')) + prevConfigDir = process.env[ENV_CONFIG_DIR] + process.env[ENV_CONFIG_DIR] = dir }) afterEach(async () => { - // tmpdir cleanup is best-effort + if (prevConfigDir === undefined) + delete process.env[ENV_CONFIG_DIR] + else + process.env[ENV_CONFIG_DIR] = prevConfigDir + await rm(dir, { recursive: true, force: true }) }) it('text format: empty config returns empty string', () => { - const out = runConfigView({ store: makeStore(dir) }) + const out = runConfigView({ store: getConfigurationStore() }) expect(out).toBe('') }) - it('text format: emits "key = value" lines for set keys only', async () => { - await writeFile( - join(dir, FILE_NAME), - 'schema_version: 1\ndefaults:\n format: json\n limit: 50\nstate:\n current_app: app-1\n', - 'utf8', - ) - const out = runConfigView({ store: makeStore(dir) }) + it('text format: emits "key = value" lines for set keys only', () => { + getConfigurationStore().setTyped({ + schema_version: 1, + defaults: { format: 'json', limit: 50 }, + state: { current_app: 'app-1' }, + }) + const out = runConfigView({ store: getConfigurationStore() }) expect(out).toBe( 'defaults.format = json\ndefaults.limit = 50\nstate.current_app = app-1\n', ) }) - it('text format: skips unset keys', async () => { - await writeFile( - join(dir, FILE_NAME), - 'schema_version: 1\ndefaults:\n format: yaml\n', - 'utf8', - ) - const out = runConfigView({ store: makeStore(dir) }) + it('text format: skips unset keys', () => { + getConfigurationStore().setTyped({ + schema_version: 1, + defaults: { format: 'yaml' }, + }) + const out = 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: makeStore(dir), json: true }) + const out = runConfigView({ store: getConfigurationStore(), json: true }) expect(out).toBe('{}\n') }) - it('json format: defaults.limit is numeric, others are strings', async () => { - await writeFile( - join(dir, FILE_NAME), - 'schema_version: 1\ndefaults:\n format: table\n limit: 100\nstate:\n current_app: app-x\n', - 'utf8', - ) - const out = runConfigView({ store: makeStore(dir), json: true }) + it('json format: defaults.limit is numeric, others are strings', () => { + getConfigurationStore().setTyped({ + schema_version: 1, + defaults: { format: 'table', limit: 100 }, + state: { current_app: 'app-x' }, + }) + const out = runConfigView({ store: getConfigurationStore(), json: true }) const parsed = JSON.parse(out) as Record expect(parsed['defaults.format']).toBe('table') expect(parsed['defaults.limit']).toBe(100) @@ -69,7 +71,7 @@ describe('runConfigView', () => { }) it('json format: trailing newline matches Go encoder.Encode', () => { - const out = runConfigView({ store: makeStore(dir), json: true }) + const out = runConfigView({ store: getConfigurationStore(), json: true }) expect(out.endsWith('\n')).toBe(true) }) }) diff --git a/cli/src/commands/describe/app/run.test.ts b/cli/src/commands/describe/app/run.test.ts index d769d5db3f..e599ee0bbf 100644 --- a/cli/src/commands/describe/app/run.test.ts +++ b/cli/src/commands/describe/app/run.test.ts @@ -8,8 +8,8 @@ import { startMock } from '../../../../test/fixtures/dify-mock/server.js' import { loadAppInfoCache } from '../../../cache/app-info.js' import { formatted, stringifyOutput } from '../../../framework/output.js' import { createClient } from '../../../http/client.js' -import { CACHE_APP_INFO, cachePath } from '../../../store/manager.js' -import { YamlStore } from '../../../store/store.js' +import { ENV_CACHE_DIR } from '../../../store/dir.js' +import { CACHE_APP_INFO, getCache } from '../../../store/manager.js' import { runDescribeApp } from './run.js' function bundle(): HostsBundle { @@ -29,17 +29,24 @@ function bundle(): HostsBundle { describe('runDescribeApp', () => { let mock: DifyMock let dir: string + let prevCacheDir: string | undefined beforeEach(async () => { mock = await startMock({ scenario: 'happy' }) dir = await mkdtemp(join(tmpdir(), 'difyctl-desc-')) + prevCacheDir = process.env[ENV_CACHE_DIR] + process.env[ENV_CACHE_DIR] = dir }) afterEach(async () => { + if (prevCacheDir === undefined) + delete process.env[ENV_CACHE_DIR] + else + process.env[ENV_CACHE_DIR] = prevCacheDir await mock.stop() await rm(dir, { recursive: true, force: true }) }) async function render(opts: Parameters[0]): Promise { - const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) const data = await runDescribeApp( opts, { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache }, @@ -82,7 +89,7 @@ describe('runDescribeApp', () => { }) it('refresh: bypasses cache', async () => { - const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runDescribeApp( { appId: 'app-1' }, { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache }, diff --git a/cli/src/commands/run/app/run.test.ts b/cli/src/commands/run/app/run.test.ts index 5af12e2a41..55bfb6448f 100644 --- a/cli/src/commands/run/app/run.test.ts +++ b/cli/src/commands/run/app/run.test.ts @@ -7,8 +7,8 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { startMock } from '../../../../test/fixtures/dify-mock/server.js' import { loadAppInfoCache } from '../../../cache/app-info.js' import { createClient } from '../../../http/client.js' -import { CACHE_APP_INFO, cachePath } from '../../../store/manager.js' -import { YamlStore } from '../../../store/store.js' +import { ENV_CACHE_DIR } from '../../../store/dir.js' +import { CACHE_APP_INFO, getCache } from '../../../store/manager.js' import { bufferStreams } from '../../../sys/io/streams' import { resumeApp } from '../../resume/app/run.js' import { runApp } from './run.js' @@ -30,18 +30,25 @@ function bundle(): HostsBundle { describe('runApp', () => { let mock: DifyMock let dir: string + let prevCacheDir: string | undefined beforeEach(async () => { mock = await startMock({ scenario: 'happy' }) dir = await mkdtemp(join(tmpdir(), 'difyctl-runapp-')) + prevCacheDir = process.env[ENV_CACHE_DIR] + process.env[ENV_CACHE_DIR] = dir }) afterEach(async () => { + if (prevCacheDir === undefined) + delete process.env[ENV_CACHE_DIR] + else + process.env[ENV_CACHE_DIR] = prevCacheDir await mock.stop() await rm(dir, { recursive: true, force: true }) }) it('chat: prints answer + conversation hint to stderr', async () => { const io = bufferStreams() - const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-1', message: 'hi' }, { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, @@ -52,7 +59,7 @@ describe('runApp', () => { it('workflow: rejects positional message with usage error', async () => { const io = bufferStreams() - const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await expect(runApp( { appId: 'app-2', message: 'hi' }, { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, @@ -61,7 +68,7 @@ describe('runApp', () => { it('workflow: prints single-string output as plain text', async () => { const io = bufferStreams() - const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-2', inputs: { x: '1' } }, { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, @@ -71,7 +78,7 @@ describe('runApp', () => { it('json: passes through full envelope', async () => { const io = bufferStreams() - const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-1', message: 'hi', format: 'json' }, { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, @@ -104,7 +111,7 @@ describe('runApp', () => { it('--stream chat: streams answer to stdout and hint to stderr', async () => { const io = bufferStreams() - const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-1', message: 'hi', stream: true }, { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, @@ -116,7 +123,7 @@ describe('runApp', () => { it('--stream -o json chat: aggregates into blocking-shape envelope', async () => { const io = bufferStreams() - const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-1', message: 'hi', stream: true, format: 'json' }, { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, @@ -129,7 +136,7 @@ describe('runApp', () => { it('agent-chat without --stream: collects and prints answer', async () => { const io = bufferStreams() - const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-4', workspace: 'ws-2', message: 'do research' }, { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, @@ -140,7 +147,7 @@ describe('runApp', () => { it('agent-chat with --stream: live-prints answer and thoughts to stderr', async () => { const io = bufferStreams() - const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-4', workspace: 'ws-2', message: 'go', stream: true }, { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, @@ -151,7 +158,7 @@ describe('runApp', () => { it('--stream workflow -o json: aggregates from workflow_finished', async () => { const io = bufferStreams() - const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-2', inputs: { x: '1' }, stream: true, format: 'json' }, { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, @@ -164,7 +171,7 @@ describe('runApp', () => { it('stream-error scenario: error event surfaces typed BaseError', async () => { mock.setScenario('stream-error') const io = bufferStreams() - const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await expect(runApp( { appId: 'app-1', message: 'hi', stream: true }, { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }), host: mock.url, io, cache }, @@ -173,7 +180,7 @@ describe('runApp', () => { it('--inputs-file: reads inputs from file', async () => { const io = bufferStreams() - const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) const inputsFile = join(dir, 'inputs.json') const { writeFile } = await import('node:fs/promises') await writeFile(inputsFile, JSON.stringify({ x: 'from-file' })) @@ -197,7 +204,7 @@ describe('runApp', () => { it('--inputs: accepts JSON object string', async () => { const io = bufferStreams() - const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-2', inputsJson: '{"x":"hello"}' }, { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, @@ -219,7 +226,7 @@ describe('runApp', () => { it('hitl pause (text): writes readable block to stdout, hint to stderr, exits 0', async () => { mock.setScenario('hitl-pause') const io = bufferStreams() - const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) let exitCode = -1 await expect(runApp( { appId: 'app-2', inputs: {} }, @@ -248,7 +255,7 @@ describe('runApp', () => { it('hitl pause (json): writes JSON envelope to stdout, exits 0', async () => { mock.setScenario('hitl-pause') const io = bufferStreams() - const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) let exitCode = -1 await expect(runApp( { appId: 'app-2', inputs: {}, format: 'json' }, @@ -274,7 +281,7 @@ describe('runApp', () => { it('resume: withHistory: false completes successfully', async () => { mock.setScenario('hitl-resume') const io = bufferStreams() - const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await resumeApp( { appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, withHistory: false }, { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, @@ -285,7 +292,7 @@ describe('runApp', () => { it('resume: submits form and streams workflow to completion', async () => { mock.setScenario('hitl-resume') const io = bufferStreams() - const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await resumeApp( { appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {} }, { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, @@ -296,7 +303,7 @@ describe('runApp', () => { it('resume --stream: live-prints workflow node progress to stderr', async () => { mock.setScenario('hitl-resume') const io = bufferStreams() - const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await resumeApp( { appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, stream: true }, { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, @@ -307,7 +314,7 @@ describe('runApp', () => { it('workflow: --file remote URL is passed as remote_url input variable', async () => { const io = bufferStreams() - const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-2', files: ['doc=https://example.com/report.pdf'] }, { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, @@ -326,7 +333,7 @@ describe('runApp', () => { it('workflow: --file @path uploads file and passes local_file input variable', async () => { const { writeFile } = await import('node:fs/promises') const io = bufferStreams() - const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) const filePath = join(dir, 'test.pdf') await writeFile(filePath, 'fake pdf content') await runApp( @@ -345,7 +352,7 @@ describe('runApp', () => { it('workflow: --file overrides same-named key from --inputs (file wins)', async () => { const io = bufferStreams() - const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) }) + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-2', inputs: { doc: 'old-value' }, files: ['doc=https://example.com/override.pdf'] }, { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, diff --git a/cli/src/commands/use/workspace/index.ts b/cli/src/commands/use/workspace/index.ts index 239ac9a44f..4b44472f88 100644 --- a/cli/src/commands/use/workspace/index.ts +++ b/cli/src/commands/use/workspace/index.ts @@ -22,7 +22,6 @@ export default class UseWorkspace extends DifyCommand { const { args, flags } = this.parse(UseWorkspace, argv) const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] }) await runUseWorkspace({ workspaceId: args.workspaceId }, { - configDir: ctx.configDir, bundle: ctx.bundle, http: ctx.http, io: ctx.io, diff --git a/cli/src/commands/use/workspace/use.test.ts b/cli/src/commands/use/workspace/use.test.ts index b3c78988f4..125d1a18fd 100644 --- a/cli/src/commands/use/workspace/use.test.ts +++ b/cli/src/commands/use/workspace/use.test.ts @@ -9,6 +9,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { loadHosts, saveHosts } from '../../../auth/hosts.js' +import { ENV_CONFIG_DIR } from '../../../store/dir.js' import { bufferStreams } from '../../../sys/io/streams.js' import { runUseWorkspace } from './use.js' @@ -51,23 +52,29 @@ function fakeClient(opts: { describe('runUseWorkspace', () => { let configDir: string + let prevConfigDir: string | undefined beforeEach(async () => { configDir = await mkdtemp(join(tmpdir(), 'difyctl-use-workspace-')) + prevConfigDir = process.env[ENV_CONFIG_DIR] + process.env[ENV_CONFIG_DIR] = configDir }) afterEach(async () => { + if (prevConfigDir === undefined) + delete process.env[ENV_CONFIG_DIR] + else + process.env[ENV_CONFIG_DIR] = prevConfigDir await rm(configDir, { recursive: true, force: true }) }) it('happy path: POST /switch → GET /workspaces → write hosts.yml', async () => { const io = bufferStreams() const b = bundle() - await saveHosts(configDir, b) + saveHosts(b) const client = fakeClient({}) const next = await runUseWorkspace( { workspaceId: 'ws-2' }, { - configDir, bundle: b, http: {} as KyInstance, io, @@ -82,7 +89,7 @@ describe('runUseWorkspace', () => { { id: 'ws-1', name: 'Default', role: 'owner' }, { id: 'ws-2', name: 'Switched', role: 'normal' }, ]) - const reloaded = await loadHosts(configDir) + const reloaded = loadHosts() expect(reloaded?.workspace?.id).toBe('ws-2') expect(reloaded?.workspace?.name).toBe('Switched') expect(io.outBuf()).toMatch(/Switched to Switched \(ws-2\)/) @@ -93,15 +100,15 @@ describe('runUseWorkspace', () => { // We expect saveHosts to record the fresh name from the server. const io = bufferStreams() const b = bundle() - await saveHosts(configDir, b) + saveHosts(b) const client = fakeClient({}) await runUseWorkspace( { workspaceId: 'ws-2' }, - { configDir, bundle: b, http: {} as KyInstance, io, workspacesFactory: () => client as never }, + { bundle: b, http: {} as KyInstance, io, workspacesFactory: () => client as never }, ) - const reloaded = await loadHosts(configDir) + const reloaded = loadHosts() expect(reloaded?.workspace?.name).toBe('Switched') expect(reloaded?.available_workspaces?.find(w => w.id === 'ws-2')?.name).toBe('Switched') }) @@ -109,8 +116,8 @@ describe('runUseWorkspace', () => { it('does NOT mutate hosts.yml when POST /switch fails', async () => { const io = bufferStreams() const b = bundle() - await saveHosts(configDir, b) - const before = await loadHosts(configDir) + saveHosts(b) + const before = loadHosts() const client = fakeClient({ switch: () => Promise.reject(new Error('forbidden')), @@ -120,7 +127,6 @@ describe('runUseWorkspace', () => { runUseWorkspace( { workspaceId: 'ws-2' }, { - configDir, bundle: b, http: {} as KyInstance, io, @@ -130,7 +136,7 @@ describe('runUseWorkspace', () => { ).rejects.toThrow(/forbidden/) expect(client.list).not.toHaveBeenCalled() - const after = await loadHosts(configDir) + const after = loadHosts() expect(after).toEqual(before) expect(after?.workspace?.id).toBe('ws-1') }) @@ -138,8 +144,8 @@ describe('runUseWorkspace', () => { it('does NOT mutate hosts.yml when GET /workspaces fails after switch', async () => { const io = bufferStreams() const b = bundle() - await saveHosts(configDir, b) - const before = await loadHosts(configDir) + saveHosts(b) + const before = loadHosts() const client = fakeClient({ list: () => Promise.reject(new Error('transient list failure')), @@ -149,7 +155,6 @@ describe('runUseWorkspace', () => { runUseWorkspace( { workspaceId: 'ws-2' }, { - configDir, bundle: b, http: {} as KyInstance, io, @@ -158,14 +163,14 @@ describe('runUseWorkspace', () => { ), ).rejects.toThrow(/transient list failure/) - const after = await loadHosts(configDir) + const after = loadHosts() expect(after).toEqual(before) }) it('throws when server returns switch= but id is missing from /workspaces list', async () => { const io = bufferStreams() const b = bundle() - await saveHosts(configDir, b) + saveHosts(b) const client = fakeClient({ switch: () => Promise.resolve({ @@ -187,7 +192,6 @@ describe('runUseWorkspace', () => { runUseWorkspace( { workspaceId: 'ws-7' }, { - configDir, bundle: b, http: {} as KyInstance, io, diff --git a/cli/src/commands/use/workspace/use.ts b/cli/src/commands/use/workspace/use.ts index b97b9dd224..db7a539a38 100644 --- a/cli/src/commands/use/workspace/use.ts +++ b/cli/src/commands/use/workspace/use.ts @@ -13,7 +13,6 @@ export type UseWorkspaceOptions = { } export type UseWorkspaceDeps = { - readonly configDir: string readonly bundle: HostsBundle readonly http: KyInstance readonly io: IOStreams @@ -70,7 +69,7 @@ export async function runUseWorkspace( role: w.role, })), } - await saveHosts(deps.configDir, next) + saveHosts(next) deps.io.out.write(`${cs.successIcon()} Switched to ${matched.name} (${matched.id})\n`) return next } diff --git a/cli/src/config/config-loader.test.ts b/cli/src/config/config-loader.test.ts index 54e3d5d027..53c63fe0b8 100644 --- a/cli/src/config/config-loader.test.ts +++ b/cli/src/config/config-loader.test.ts @@ -1,48 +1,52 @@ -import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import type { YamlStore } from '../store/store' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { isBaseError } from '../errors/base' import { ErrorCode } from '../errors/codes' -import { YamlStore } from '../store/store' +import { ENV_CONFIG_DIR } from '../store/dir' +import { getConfigurationStore } from '../store/manager' import { loadConfig } from './config-loader' -import { FILE_NAME } from './schema' - -function makeStore(dir: string): YamlStore { - return new YamlStore(join(dir, FILE_NAME)) -} describe('loadConfig', () => { let dir: string + let prevConfigDir: string | undefined beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'difyctl-cfg-')) + prevConfigDir = process.env[ENV_CONFIG_DIR] + process.env[ENV_CONFIG_DIR] = dir }) afterEach(async () => { - await mkdir(dir, { recursive: true }).catch(() => {}) + if (prevConfigDir === undefined) + delete process.env[ENV_CONFIG_DIR] + else + process.env[ENV_CONFIG_DIR] = prevConfigDir + await rm(dir, { recursive: true, force: true }) }) - it('returns found:false when config.yml is missing', () => { - const r = loadConfig(makeStore(dir)) + it('returns found:false when config is missing', () => { + const r = loadConfig(getConfigurationStore()) expect(r.found).toBe(false) }) - it('parses a minimal valid config.yml', async () => { - await writeFile(join(dir, FILE_NAME), 'schema_version: 1\n', 'utf8') - const r = loadConfig(makeStore(dir)) + it('parses a minimal valid config', () => { + getConfigurationStore().setTyped({ schema_version: 1 }) + const r = loadConfig(getConfigurationStore()) expect(r.found).toBe(true) if (r.found) expect(r.config.schema_version).toBe(1) }) - it('parses defaults + state', async () => { - await writeFile( - join(dir, FILE_NAME), - 'schema_version: 1\ndefaults:\n format: json\n limit: 100\nstate:\n current_app: app-1\n', - 'utf8', - ) - const r = loadConfig(makeStore(dir)) + it('parses defaults + state', () => { + getConfigurationStore().setTyped({ + schema_version: 1, + defaults: { format: 'json', limit: 100 }, + state: { current_app: 'app-1' }, + }) + const r = loadConfig(getConfigurationStore()) expect(r.found).toBe(true) if (r.found) { expect(r.config.defaults.format).toBe('json') @@ -51,11 +55,29 @@ describe('loadConfig', () => { } }) - it('throws BaseError(config_schema_unsupported) when YAML is malformed', async () => { - await writeFile(join(dir, FILE_NAME), '::not yaml::: {{[', 'utf8') + it('throws BaseError(config_schema_unsupported) when the store fails to parse the file', () => { + // Simulate a corrupt on-disk file via a fake store; loadConfig must wrap + // the underlying error as ConfigSchemaUnsupported. + const throwingStore = { + getTyped: () => { throw new Error('YAML parse failure') }, + } as unknown as YamlStore let caught: unknown try { - loadConfig(makeStore(dir)) + loadConfig(throwingStore) + } + catch (err) { caught = err } + expect(isBaseError(caught)).toBe(true) + if (isBaseError(caught)) { + expect(caught.code).toBe(ErrorCode.ConfigSchemaUnsupported) + expect(caught.hint).toMatch(/not valid YAML/) + } + }) + + it('throws BaseError(config_schema_unsupported) when zod validation fails', () => { + getConfigurationStore().setTyped({ defaults: { limit: 9999 } }) + let caught: unknown + try { + loadConfig(getConfigurationStore()) } catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) @@ -63,23 +85,11 @@ describe('loadConfig', () => { expect(caught.code).toBe(ErrorCode.ConfigSchemaUnsupported) }) - it('throws BaseError(config_schema_unsupported) when zod validation fails', async () => { - await writeFile(join(dir, FILE_NAME), 'defaults:\n limit: 9999\n', 'utf8') + it('throws BaseError(config_schema_unsupported) when schema_version > 1 (forward-refuse)', () => { + getConfigurationStore().setTyped({ schema_version: 2 }) let caught: unknown try { - loadConfig(makeStore(dir)) - } - catch (err) { caught = err } - expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) - expect(caught.code).toBe(ErrorCode.ConfigSchemaUnsupported) - }) - - it('throws BaseError(config_schema_unsupported) when schema_version > 1 (forward-refuse)', async () => { - await writeFile(join(dir, FILE_NAME), 'schema_version: 2\n', 'utf8') - let caught: unknown - try { - loadConfig(makeStore(dir)) + loadConfig(getConfigurationStore()) } catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) diff --git a/cli/src/config/schema.test.ts b/cli/src/config/schema.test.ts index 8fec34dec5..549fab3622 100644 --- a/cli/src/config/schema.test.ts +++ b/cli/src/config/schema.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest' +import { CONFIG_FILE_NAME } from '../store/manager.js' import { ALLOWED_FORMATS, ConfigFileSchema, CURRENT_SCHEMA_VERSION, emptyConfig, - FILE_NAME, } from './schema.js' describe('config schema', () => { @@ -12,8 +12,8 @@ describe('config schema', () => { expect(CURRENT_SCHEMA_VERSION).toBe(1) }) - it('FILE_NAME is config.yml', () => { - expect(FILE_NAME).toBe('config.yml') + it('CONFIG_FILE_NAME is config.yml', () => { + expect(CONFIG_FILE_NAME).toBe('config.yml') }) it('ALLOWED_FORMATS matches Go set (json/yaml/table/wide/name/text)', () => { diff --git a/cli/src/config/schema.ts b/cli/src/config/schema.ts index f5946bfc31..28eed53d41 100644 --- a/cli/src/config/schema.ts +++ b/cli/src/config/schema.ts @@ -1,7 +1,6 @@ import { z } from 'zod' export const CURRENT_SCHEMA_VERSION = 1 -export const FILE_NAME = 'config.yml' export const ALLOWED_FORMATS = ['json', 'yaml', 'table', 'wide', 'name', 'text'] as const export type AllowedFormat = (typeof ALLOWED_FORMATS)[number] diff --git a/cli/src/errors/codes.test.ts b/cli/src/errors/codes.test.ts index c89aa41d50..101eb2eead 100644 --- a/cli/src/errors/codes.test.ts +++ b/cli/src/errors/codes.test.ts @@ -8,8 +8,8 @@ import { } from './codes.js' describe('error codes', () => { - it('has 17 codes (parity with internal/api/errors)', () => { - expect(ALL_ERROR_CODES).toHaveLength(17) + it('has 18 codes (parity with internal/api/errors)', () => { + expect(ALL_ERROR_CODES).toHaveLength(18) }) it('has the expected ExitCode buckets', () => { @@ -46,6 +46,7 @@ describe('error codes', () => { [ErrorCode.NetworkDns, ExitCode.Generic], [ErrorCode.Server5xx, ExitCode.Generic], [ErrorCode.Server4xxOther, ExitCode.Generic], + [ErrorCode.ClientError, ExitCode.Generic], [ErrorCode.Unknown, ExitCode.Generic], ])('exitFor(%s) -> %d', (code, want) => { expect(exitFor(code)).toBe(want) diff --git a/cli/src/errors/codes.ts b/cli/src/errors/codes.ts index ad2a1089ce..f435476812 100644 --- a/cli/src/errors/codes.ts +++ b/cli/src/errors/codes.ts @@ -15,6 +15,7 @@ export const ErrorCode = { NetworkDns: 'network_dns', Server5xx: 'server_5xx', Server4xxOther: 'server_4xx_other', + ClientError: 'client_error', Unknown: 'unknown', } as const @@ -47,6 +48,7 @@ const CODE_TO_EXIT: Readonly> = { network_dns: ExitCode.Generic, server_5xx: ExitCode.Generic, server_4xx_other: ExitCode.Generic, + client_error: ExitCode.Generic, unknown: ExitCode.Generic, } diff --git a/cli/src/store/config-writer.test.ts b/cli/src/store/config-writer.test.ts index 1463699193..9635032877 100644 --- a/cli/src/store/config-writer.test.ts +++ b/cli/src/store/config-writer.test.ts @@ -1,45 +1,57 @@ -import { mkdtemp, readdir, readFile, stat } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { loadConfig } from '../config/config-loader' -import { emptyConfig, FILE_NAME } from '../config/schema' -import { platform } from '../sys' +import { emptyConfig } from '../config/schema' import { saveConfig } from './config-writer' -import { YamlStore } from './store' - -function makeStore(dir: string): YamlStore { - return new YamlStore(join(dir, FILE_NAME)) -} +import { ENV_CONFIG_DIR } from './dir' +import { getConfigurationStore } from './manager' describe('saveConfig', () => { let dir: string + let prevConfigDir: string | undefined beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'difyctl-w-')) + prevConfigDir = process.env[ENV_CONFIG_DIR] + process.env[ENV_CONFIG_DIR] = dir }) - it('writes config.yml in the target dir', async () => { - saveConfig(makeStore(dir), { ...emptyConfig(), schema_version: 1 }) - const stats = await stat(join(dir, FILE_NAME)) - expect(stats.isFile()).toBe(true) + afterEach(async () => { + if (prevConfigDir === undefined) + delete process.env[ENV_CONFIG_DIR] + else + process.env[ENV_CONFIG_DIR] = prevConfigDir + await rm(dir, { recursive: true, force: true }) }) it('stamps schema_version=1 even if caller passed 0', () => { - saveConfig(makeStore(dir), { ...emptyConfig() }) - const r = loadConfig(makeStore(dir)) + saveConfig(getConfigurationStore(), { ...emptyConfig() }) + const r = loadConfig(getConfigurationStore()) expect(r.found).toBe(true) if (r.found) expect(r.config.schema_version).toBe(1) }) - it('round-trips defaults + state through YAML', () => { - saveConfig(makeStore(dir), { + it('overrides a stale schema_version on save', () => { + saveConfig(getConfigurationStore(), { + ...emptyConfig(), + schema_version: 999 as never, + }) + const r = loadConfig(getConfigurationStore()) + expect(r.found).toBe(true) + if (r.found) + expect(r.config.schema_version).toBe(1) + }) + + it('round-trips defaults + state', () => { + saveConfig(getConfigurationStore(), { schema_version: 1, defaults: { format: 'wide', limit: 75 }, state: { current_app: 'app-xyz' }, }) - const r = loadConfig(makeStore(dir)) + const r = loadConfig(getConfigurationStore()) expect(r.found).toBe(true) if (r.found) { expect(r.config.defaults.format).toBe('wide') @@ -48,39 +60,22 @@ describe('saveConfig', () => { } }) - it('writes file with mode 0o600 (POSIX)', async () => { - if (platform() === 'win32') - return - saveConfig(makeStore(dir), emptyConfig()) - const s = await stat(join(dir, FILE_NAME)) - expect(s.mode & 0o777).toBe(0o600) - }) - - it('does not leave a tmp file on success', async () => { - saveConfig(makeStore(dir), emptyConfig()) - const entries = await readdir(dir) - expect(entries.filter(f => f.endsWith('.tmp'))).toHaveLength(0) - expect(entries.filter(f => f.includes('.tmp.'))).toHaveLength(0) - }) - - it('creates parent dir at 0o700 if absent', async () => { - if (platform() === 'win32') - return - const nested = join(dir, 'nested', 'sub') - saveConfig(makeStore(nested), emptyConfig()) - const s = await stat(nested) - expect(s.isDirectory()).toBe(true) - expect(s.mode & 0o777).toBe(0o700) - }) - - it('emits parseable YAML (round-trip via fs.readFile + js-yaml)', async () => { - saveConfig(makeStore(dir), { + it('overwrites the previous config on resave', () => { + saveConfig(getConfigurationStore(), { schema_version: 1, defaults: { format: 'json' }, state: {}, }) - const raw = await readFile(join(dir, FILE_NAME), 'utf8') - expect(raw).toMatch(/^schema_version:/m) - expect(raw).toMatch(/format: json/) + saveConfig(getConfigurationStore(), { + schema_version: 1, + defaults: { format: 'table' }, + state: { current_app: 'app-2' }, + }) + const r = loadConfig(getConfigurationStore()) + expect(r.found).toBe(true) + if (r.found) { + expect(r.config.defaults.format).toBe('table') + expect(r.config.state.current_app).toBe('app-2') + } }) }) diff --git a/cli/src/store/errors.ts b/cli/src/store/errors.ts new file mode 100644 index 0000000000..1082d6fdf5 --- /dev/null +++ b/cli/src/store/errors.ts @@ -0,0 +1,64 @@ +import { BaseError } from '../errors/base' +import { ErrorCode } from '../errors/codes' + +export class ConcurrentAccessError extends BaseError { + constructor(filePath: string) { + const msg = `Another process is modifying the file ${filePath}. remove ${filePath}.lock to reset lock.` + + super({ + code: ErrorCode.ClientError, + message: msg, + hint: `remove ${filePath}.lock to reset lock.`, + }) + } +} + +type YamlMark = { + line: number + column: number + snippet?: string +} + +type YamlParseError = { + reason?: string + mark?: YamlMark + message?: string +} + +export class BadYamlFormatError extends BaseError { + constructor(path: string, raw: string, cause: YamlParseError) { + const reason = cause.reason ?? cause.message ?? 'invalid YAML' + const mark = cause.mark + const where = mark ? ` at line ${mark.line + 1}, column ${mark.column + 1}` : '' + const snippet = mark?.snippet ?? excerpt(raw, mark) + const header = `Failed to parse YAML file ${path}: ${reason}${where}.` + const body = snippet ? `\n\n${snippet}` : '' + + super({ + code: ErrorCode.ClientError, + message: `${header}${body}`, + hint: `Fix the YAML syntax in ${path} or remove the file to reset it.`, + }) + } +} + +function excerpt(raw: string, mark: YamlMark | undefined): string { + if (mark === undefined) + return '' + const lines = raw.split('\n') + const target = mark.line + if (target < 0 || target >= lines.length) + return '' + const start = Math.max(0, target - 2) + const end = Math.min(lines.length, target + 3) + const width = String(end).length + const out: string[] = [] + for (let i = start; i < end; i++) { + const marker = i === target ? '>' : ' ' + const num = String(i + 1).padStart(width, ' ') + out.push(`${marker} ${num} | ${lines[i]}`) + if (i === target) + out.push(`${' '.repeat(width + 4)}${' '.repeat(mark.column)}^`) + } + return out.join('\n') +} diff --git a/cli/src/store/keyring-based-store.test.ts b/cli/src/store/keyring-based-store.test.ts new file mode 100644 index 0000000000..53315eec7f --- /dev/null +++ b/cli/src/store/keyring-based-store.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const passwords = new Map() +const setPassword = vi.fn() +const getPassword = vi.fn() +const deletePassword = vi.fn() + +class FakeEntry { + private readonly key: string + constructor(service: string, username: string) { + this.key = `${service}::${username}` + } + + setPassword(value: string): void { + setPassword(this.key, value) + passwords.set(this.key, value) + } + + getPassword(): string | null { + getPassword(this.key) + return passwords.get(this.key) ?? null + } + + deletePassword(): boolean { + deletePassword(this.key) + if (!passwords.has(this.key)) + return false + passwords.delete(this.key) + return true + } +} + +vi.mock('@napi-rs/keyring', () => ({ + Entry: FakeEntry, +})) + +const { KeyringBasedStore } = await import('./store.js') + +const SERVICE = 'difyctl-test' + +beforeEach(() => { + passwords.clear() + setPassword.mockClear() + getPassword.mockClear() + deletePassword.mockClear() +}) + +describe('KeyringBasedStore', () => { + it('returns default when entry missing', () => { + const s = new KeyringBasedStore(SERVICE) + expect(s.get({ key: 'k', default: 'fallback' })).toBe('fallback') + }) + + it('round-trips strings via JSON encoding', () => { + const s = new KeyringBasedStore(SERVICE) + s.set({ key: 'k', default: '' }, 'tok-abc') + expect(s.get({ key: 'k', default: '' })).toBe('tok-abc') + }) + + it('isolates entries by key', () => { + 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') + }) + + it('unset removes the entry', () => { + const s = new KeyringBasedStore(SERVICE) + s.set({ key: 'k', default: '' }, 'v') + s.unset({ key: 'k', default: '' }) + expect(s.get({ key: 'k', default: '' })).toBe('') + }) + + it('unset is a no-op when entry missing', () => { + const s = new KeyringBasedStore(SERVICE) + expect(() => s.unset({ key: 'gone', default: '' })).not.toThrow() + }) + + it('swallows getPassword exceptions and returns default', () => { + const s = new KeyringBasedStore(SERVICE) + getPassword.mockImplementationOnce( + () => { + throw new Error('NoEntry') + }, + ) + expect(s.get({ key: 'k', default: 'd' })).toBe('d') + }) + + it('swallows unset exceptions', () => { + const s = new KeyringBasedStore(SERVICE) + deletePassword.mockImplementationOnce( + () => { + throw new Error('NoEntry') + }, + ) + expect(() => s.unset({ key: 'k', default: '' })).not.toThrow() + }) + + it('lets set propagate exceptions (caller decides fallback)', () => { + const s = new KeyringBasedStore(SERVICE) + setPassword.mockImplementationOnce( + () => { + throw new Error('keyring locked') + }, + ) + expect(() => s.set({ key: 'k', default: '' }, 'v')).toThrow(/keyring locked/) + }) +}) diff --git a/cli/src/store/manager.test.ts b/cli/src/store/manager.test.ts new file mode 100644 index 0000000000..53479b305c --- /dev/null +++ b/cli/src/store/manager.test.ts @@ -0,0 +1,78 @@ +import type { Key, Store } from './store.js' +import { describe, expect, it, vi } from 'vitest' +import { getTokenStore } from './manager.js' + +function memStore(label: string): Store & { _label: string } { + const map = new Map() + return { + _label: label, + get(key: Key): T { + return (map.get(key.key) as T | undefined) ?? key.default + }, + set(key: Key, value: T): void { + map.set(key.key, value) + }, + unset(key: Key): void { + map.delete(key.key) + }, + } +} + +describe('getTokenStore', () => { + it('returns keychain store when probe succeeds', () => { + const k = memStore('keyring') + const f = memStore('file') + const result = getTokenStore({ + factory: { keyring: () => k, file: () => f }, + }) + expect(result.mode).toBe('keychain') + expect(result.store).toBe(k) + }) + + it('falls back to file when keyring set throws', () => { + const k = memStore('keyring') + const f = memStore('file') + k.set = vi.fn( + () => { + throw new Error('locked') + }, + ) + const result = getTokenStore({ + 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', () => { + const k = memStore('keyring') + const f = memStore('file') + k.get = vi.fn(() => 'something-else') + const result = getTokenStore({ + factory: { keyring: () => k, file: () => f }, + }) + expect(result.mode).toBe('file') + expect(result.store).toBe(f) + }) + + it('falls back to file when keyring constructor throws', () => { + const f = memStore('file') + const result = getTokenStore({ + factory: { + keyring: () => { throw new Error('no backend') }, + file: () => f, + }, + }) + expect(result.mode).toBe('file') + expect(result.store).toBe(f) + }) + + it('cleans up probe entry after successful probe', () => { + const k = memStore('keyring') + const f = memStore('file') + getTokenStore({ + factory: { keyring: () => k, file: () => f }, + }) + expect(k.get({ key: '__difyctl_probe__', default: '' })).toBe('') + }) +}) diff --git a/cli/src/store/manager.ts b/cli/src/store/manager.ts index 76e116b917..abdbcf1298 100644 --- a/cli/src/store/manager.ts +++ b/cli/src/store/manager.ts @@ -1,28 +1,77 @@ -import type { Store } from './store' +import type { Key, StorageMode, Store } from './store' import { join } from 'node:path' -import { FILE_NAME } from '../config/schema' import { resolveCacheDir, resolveConfigDir } from './dir' -import { YamlStore } from './store' +import { KeyringBasedStore, YamlStore } from './store' export const CACHE_APP_INFO = 'app-info' export const CACHE_NUDGE = 'nudge' +const HOSTS_FILE = 'hosts.yml' +const TOKENS_FILE = 'tokens.yml' +export const CONFIG_FILE_NAME = 'config.yml' + +const KEYRING_SERVICE = 'difyctl' function getStore(filePath: string): YamlStore { return new YamlStore(filePath) } -function resolveConfigurationPath(): string { - return join(resolveConfigDir(), FILE_NAME) -} - export function cachePath(cacheDir: string, name: string): string { return join(cacheDir, `${name}.yml`) } export function getConfigurationStore(): YamlStore { - return getStore(resolveConfigurationPath()) + return getStore(join(resolveConfigDir(), CONFIG_FILE_NAME)) } export function getCache(cacheName: string): Store { return getStore(cachePath(resolveCacheDir(), cacheName)) } + +export function getHostStore(): YamlStore { + return getStore(join(resolveConfigDir(), HOSTS_FILE)) +} + +const PROBE_KEY: Key = { key: '__difyctl_probe__', default: '' } +const PROBE_VALUE = 'probe-v1' + +export type GetTokenStoreOptions = { + readonly factory?: { + readonly keyring?: () => Store + readonly file?: () => Store + } +} + +/** + * Single entry point for the credential store. Probes the OS keyring; if it + * round-trips a value, returns the keychain-backed store. Otherwise falls + * back to the YAML file at `/tokens.yml`. Both implementations + * satisfy the `Store` interface, so callers interact uniformly. + * + * Business logic should always obtain the token store through this factory + * rather than constructing one directly. + */ +export function getTokenStore(opts: GetTokenStoreOptions = {}): { store: Store, mode: StorageMode } { + const fileFactory = opts.factory?.file ?? (() => getStore(join(resolveConfigDir(), TOKENS_FILE))) + const keyringFactory = opts.factory?.keyring ?? (() => new KeyringBasedStore(KEYRING_SERVICE)) + try { + const k = keyringFactory() + k.set(PROBE_KEY, PROBE_VALUE) + const got = k.get(PROBE_KEY) + k.unset(PROBE_KEY) + if (got !== PROBE_VALUE) + throw new Error('keyring round-trip mismatch') + return { store: k, mode: 'keychain' } + } + catch { + return { store: fileFactory(), mode: 'file' } + } +} + +/** + * Maps an auth identity (host + accountId) to a `Store` key. All token store + * reads/writes in business logic go through this helper so the on-disk / + * keyring layout stays consistent. + */ +export function tokenKey(host: string, accountId: string): Key { + return { key: `tokens.${host}.${accountId}`, default: '' } +} diff --git a/cli/src/store/store.test.ts b/cli/src/store/store.test.ts index 3f0c3de1e7..f2694accf8 100644 --- a/cli/src/store/store.test.ts +++ b/cli/src/store/store.test.ts @@ -1,9 +1,10 @@ -import { readFileSync, writeFileSync } from 'node:fs' +import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { ConcurrentAccessError, YamlStore } from './store' +import { BadYamlFormatError, ConcurrentAccessError } from './errors' +import { YamlStore } from './store' describe('YamlStore.doGet', () => { it('returns default when content is undefined', () => { @@ -13,33 +14,51 @@ describe('YamlStore.doGet', () => { it('reads a flat key', () => { const store = new YamlStore('/irrelevant') - store.raw_content = 'name: alice\n' + store.setRawContent('name: alice\n') expect(store.doGet({ key: 'name', default: '' })).toBe('alice') }) it('reads a nested key via dot notation', () => { const store = new YamlStore('/irrelevant') - store.raw_content = 'user:\n id: 42\n' + store.setRawContent('user:\n id: 42\n') expect(store.doGet({ key: 'user.id', default: 0 })).toBe(42) }) it('returns default for a missing flat key', () => { const store = new YamlStore('/irrelevant') - store.raw_content = 'name: alice\n' + store.setRawContent('name: alice\n') expect(store.doGet({ key: 'age', default: -1 })).toBe(-1) }) it('returns default when an intermediate path segment is absent', () => { const store = new YamlStore('/irrelevant') - store.raw_content = 'user:\n name: bob\n' + store.setRawContent('user:\n name: bob\n') expect(store.doGet({ key: 'user.address.city', default: 'unknown' })).toBe('unknown') }) it('returns default when an intermediate path segment is a scalar', () => { const store = new YamlStore('/irrelevant') - store.raw_content = 'user: scalar\n' + store.setRawContent('user: scalar\n') expect(store.doGet({ key: 'user.id', default: 0 })).toBe(0) }) + + it('throws BadYamlFormatError with file path, location, and snippet for malformed YAML', () => { + const path = '/irrelevant' + const store = new YamlStore(path) + store.setRawContent('name: alice\nuser:\n id: 42\n bad: indent\n') + let caught: unknown + try { + store.doGet({ key: 'name', default: '' }) + } + catch (err) { + caught = err + } + expect(caught).toBeInstanceOf(BadYamlFormatError) + const msg = (caught as BadYamlFormatError).message + expect(msg).toContain(path) + expect(msg).toMatch(/line \d+, column \d+/) + expect(msg).toContain('bad: indent') + }) }) describe('YamlStore.doSet', () => { @@ -57,7 +76,7 @@ describe('YamlStore.doSet', () => { it('overwrites an existing key without disturbing siblings', () => { const store = new YamlStore('/irrelevant') - store.raw_content = 'name: alice\nage: 30\n' + store.setRawContent('name: alice\nage: 30\n') store.doSet({ key: 'name', default: '' }, 'bob') expect(store.doGet({ key: 'name', default: '' })).toBe('bob') expect(store.doGet({ key: 'age', default: 0 })).toBe(30) @@ -65,7 +84,7 @@ describe('YamlStore.doSet', () => { it('replaces a scalar intermediate with an object when path deepens', () => { const store = new YamlStore('/irrelevant') - store.raw_content = 'user: scalar\n' + store.setRawContent('user: scalar\n') store.doSet({ key: 'user.id', default: 0 }, 99) expect(store.doGet({ key: 'user.id', default: 0 })).toBe(99) }) @@ -132,12 +151,12 @@ describe('YamlStore persistence', () => { await writeFile(path, '') const s1 = new YamlStore(path) - s1.raw_content = '' + s1.setRawContent('') s1.doSet({ key: 'workspace', default: '' }, 'ws-123') - writeFileSync(path, s1.raw_content ?? '') + writeFileSync(path, s1.getRawContent() ?? '') const s2 = new YamlStore(path) - s2.raw_content = readFileSync(path, 'utf8') + s2.setRawContent(readFileSync(path, 'utf8')) expect(s2.doGet({ key: 'workspace', default: '' })).toBe('ws-123') }) @@ -146,12 +165,12 @@ describe('YamlStore persistence', () => { await writeFile(path, '') const s1 = new YamlStore(path) - s1.raw_content = '' + s1.setRawContent('') s1.doSet({ key: 'a.b.c', default: '' }, 'deep') - writeFileSync(path, s1.raw_content ?? '') + writeFileSync(path, s1.getRawContent() ?? '') const s2 = new YamlStore(path) - s2.raw_content = readFileSync(path, 'utf8') + s2.setRawContent(readFileSync(path, 'utf8')) expect(s2.doGet({ key: 'a.b.c', default: '' })).toBe('deep') }) @@ -160,17 +179,17 @@ describe('YamlStore persistence', () => { await writeFile(path, '') const s1 = new YamlStore(path) - s1.raw_content = '' + s1.setRawContent('') s1.doSet({ key: 'x', default: '' }, 'first') - writeFileSync(path, s1.raw_content ?? '') + writeFileSync(path, s1.getRawContent() ?? '') const s2 = new YamlStore(path) - s2.raw_content = readFileSync(path, 'utf8') + s2.setRawContent(readFileSync(path, 'utf8')) s2.doSet({ key: 'y', default: '' }, 'second') - writeFileSync(path, s2.raw_content ?? '') + writeFileSync(path, s2.getRawContent() ?? '') const s3 = new YamlStore(path) - s3.raw_content = readFileSync(path, 'utf8') + s3.setRawContent(readFileSync(path, 'utf8')) expect(s3.doGet({ key: 'x', default: '' })).toBe('first') expect(s3.doGet({ key: 'y', default: '' })).toBe('second') }) @@ -186,8 +205,28 @@ describe('YamlStore persistence', () => { const raw = readFileSync(path, 'utf8') const store2 = new YamlStore(path) - store2.raw_content = raw + store2.setRawContent(raw) expect(store2.doGet({ key: 'token', default: '' })).toBe('abc-123') expect(store2.doGet({ key: 'existing', default: '' })).toBe('value') }) + + it('flush writes file when dirty (content changed from undefined)', () => { + const path = join(dir, 'config.yml') + const store = new YamlStore(path) + store.setRawContent('key: value\n') + store.flush() + expect(existsSync(path)).toBe(true) + expect(readFileSync(path, 'utf8')).toBe('key: value\n') + }) + + it('flush is a no-op when loaded content is set back unchanged', async () => { + const path = join(dir, 'config.yml') + await writeFile(path, 'key: value\n') + const store = new YamlStore(path) + store.load() + const mtime = statSync(path).mtimeMs + store.setRawContent('key: value\n') + store.flush() + expect(statSync(path).mtimeMs).toBe(mtime) + }) }) diff --git a/cli/src/store/store.ts b/cli/src/store/store.ts index f1cb8a2302..c7201fe292 100644 --- a/cli/src/store/store.ts +++ b/cli/src/store/store.ts @@ -1,14 +1,16 @@ import type { Platform } from '../sys' import fs from 'node:fs' import { dirname } from 'node:path' +import { Entry } from '@napi-rs/keyring' import yaml from 'js-yaml' import lockfile from 'lockfile' import { pid, resolvePlatform } from '../sys' +import { BadYamlFormatError, ConcurrentAccessError } from './errors' const FILE_PERM = 0o600 const DIR_PERM = 0o700 -type Key = { +export type Key = { default: T key: string } @@ -16,38 +18,43 @@ type Key = { export type Store = { get: (key: Key) => T set: (key: Key, value: T) => void + unset: (key: Key) => void } -export class ConcurrentAccessError extends Error { - constructor(filePath: string) { - super(`Another process is modifying the file ${filePath}. remove ${filePath}.lock to reset lock.`) - } -} +export type StorageMode = 'keychain' | 'file' abstract class FileBasedStore implements Store { - file_path: string - raw_content: string | undefined + filePath: string + private rawContent: string | undefined private readonly platform: Platform + private dirty: boolean = false - constructor(file_path: string) { - this.file_path = file_path + constructor(filePath: string) { + this.filePath = filePath this.platform = resolvePlatform() - fs.mkdirSync(dirname(this.file_path), { recursive: true, mode: DIR_PERM }) } unlock(): void { - lockfile.unlockSync(`${this.file_path}.lock`) + lockfile.unlockSync(`${this.filePath}.lock`) } /** * atomically write raw_content (if any) */ flush(): void { - if (this.raw_content !== undefined) { - const tmp = `${this.file_path}.tmp.${pid()}.${Date.now()}` + fs.mkdirSync(dirname(this.filePath), { recursive: true, mode: DIR_PERM }) + + // we don't handle A-B-A scenario, + // which is not likely to happen in cli + if (!this.dirty) { + return + } + + if (this.rawContent !== undefined) { + const tmp = `${this.filePath}.tmp.${pid()}.${Date.now()}` try { - fs.writeFileSync(tmp, this.raw_content, { mode: FILE_PERM }) - this.platform.atomicReplace(tmp, this.file_path) + fs.writeFileSync(tmp, this.rawContent, { mode: FILE_PERM }) + this.platform.atomicReplace(tmp, this.filePath) } catch (err) { try { @@ -57,16 +64,20 @@ abstract class FileBasedStore implements Store { throw err } } + + this.dirty = false } lock(): void { try { - lockfile.lockSync(`${this.file_path}.lock`) + lockfile.lockSync(`${this.filePath}.lock`, { + stale: 30_000, + }) } catch (err) { const code = (err as NodeJS.ErrnoException).code if (code === 'EEXIST') { - throw new ConcurrentAccessError(this.file_path) + throw new ConcurrentAccessError(this.filePath) } throw err } @@ -74,7 +85,8 @@ abstract class FileBasedStore implements Store { load(): void { try { - this.raw_content = fs.readFileSync(this.file_path, 'utf8') + this.rawContent = fs.readFileSync(this.filePath, 'utf8') + this.dirty = false } catch (err) { const code = (err as NodeJS.ErrnoException).code @@ -84,10 +96,18 @@ abstract class FileBasedStore implements Store { } } + public setRawContent(content: string): void { + this.dirty = (content !== this.getRawContent()) + this.rawContent = content + } + + public getRawContent(): string | undefined { + return this.rawContent + } + protected withLock(body: () => R): R { this.lock() try { - this.load() return body() } finally { @@ -96,18 +116,44 @@ abstract class FileBasedStore implements Store { } get(key: Key): T { - return this.withLock(() => this.doGet(key)) + return this.withLock(() => { + this.load() + return this.doGet(key) + }) } set(key: Key, value: T) { this.withLock(() => { + this.load() this.doSet(key, value) this.flush() }) } + unset(key: Key): void { + this.withLock(() => { + this.load() + this.doUnset(key) + this.flush() + }) + } + + /** + * Remove the underlying file of the store. No-op if file doesn't exist. + */ + rm(): void { + try { + fs.unlinkSync(this.filePath) + } + catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') + throw err + } + } + abstract doGet(key: Key): T abstract doSet(key: Key, value: T): void + abstract doUnset(key: Key): void } export class YamlStore extends FileBasedStore { @@ -116,7 +162,7 @@ export class YamlStore extends FileBasedStore { } doGet(key: Key): T { - const data = loadYaml(this.raw_content) + const data = loadYaml(this.getRawContent(), this.filePath) const parts = key.key.split('.') let current: unknown = data for (const part of parts) { @@ -130,19 +176,20 @@ export class YamlStore extends FileBasedStore { getTyped(): T | null { return this.withLock(() => { this.load() - return loadYaml(this.raw_content) as T + return loadYaml(this.getRawContent(), this.filePath) as T }) } setTyped(data: T): void { this.withLock(() => { - this.raw_content = yaml.dump(data, { lineWidth: -1, noRefs: true }) + this.load() + this.setRawContent(yaml.dump(data, { lineWidth: -1, noRefs: true })) this.flush() }) } doSet(key: Key, value: T): void { - const data = loadYaml(this.raw_content) || {} + const data = loadYaml(this.getRawContent(), this.filePath) || {} const parts = key.key.split('.') const lastKey = parts.pop() if (lastKey === undefined) @@ -154,12 +201,74 @@ export class YamlStore extends FileBasedStore { current = current[part] as Record } current[lastKey] = value - this.raw_content = yaml.dump(data, { lineWidth: -1, noRefs: true }) + this.setRawContent(yaml.dump(data, { lineWidth: -1, noRefs: true })) + } + + doUnset(key: Key): void { + const data = loadYaml(this.getRawContent(), this.filePath) || {} + const parts = key.key.split('.') + const lastKey = parts.pop() + if (lastKey === undefined) + return + let current: Record = data + for (const part of parts) { + const next = current[part] + if (next === null || next === undefined || typeof next !== 'object') + return + current = next as Record + } + if (!(lastKey in current)) + return + delete current[lastKey] + this.setRawContent(yaml.dump(data, { lineWidth: -1, noRefs: true })) } } -function loadYaml(raw: string | undefined): Record | null { +function loadYaml(raw: string | undefined, file_path: string): Record | null { if (raw === undefined) return null - return (yaml.load(raw) ?? {}) as Record + try { + return (yaml.load(raw) ?? {}) as Record + } + catch (err) { + if (err instanceof yaml.YAMLException) + throw new BadYamlFormatError(file_path, raw, err) + throw err + } +} + +/** + * OS-keyring-based storage primitive. Sits at the same layer as + * `FileBasedStore`: implements `Store` with each `Key` corresponding to a + * single keyring entry under the configured service. Values are JSON-encoded. + */ +export class KeyringBasedStore implements Store { + private readonly service: string + + constructor(service: string) { + this.service = service + } + + get(key: Key): T { + try { + const v = new Entry(this.service, key.key).getPassword() + if (v === null || v === undefined || v === '') + return key.default + return JSON.parse(v) as T + } + catch { + return key.default + } + } + + set(key: Key, value: T): void { + new Entry(this.service, key.key).setPassword(JSON.stringify(value)) + } + + unset(key: Key): void { + try { + new Entry(this.service, key.key).deletePassword() + } + catch { /* missing entry is fine */ } + } } diff --git a/cli/src/version/nudge.test.ts b/cli/src/version/nudge.test.ts index a581d40700..3e250a5040 100644 --- a/cli/src/version/nudge.test.ts +++ b/cli/src/version/nudge.test.ts @@ -5,8 +5,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { loadNudgeStore } from '../cache/nudge-store.js' -import { CACHE_NUDGE, cachePath } from '../store/manager.js' -import { YamlStore } from '../store/store.js' +import { ENV_CACHE_DIR } from '../store/dir.js' +import { CACHE_NUDGE, getCache } from '../store/manager.js' import { maybeNudgeCompat } from './nudge.js' const HOST = 'https://cloud.dify.ai' @@ -44,11 +44,18 @@ describe('maybeNudgeCompat', () => { let dir: string let store: NudgeStore + let prevCacheDir: string | undefined beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'difyctl-nudge-')) - store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: fixedNow }) + prevCacheDir = process.env[ENV_CACHE_DIR] + process.env[ENV_CACHE_DIR] = dir + store = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: fixedNow }) }) afterEach(async () => { + if (prevCacheDir === undefined) + delete process.env[ENV_CACHE_DIR] + else + process.env[ENV_CACHE_DIR] = prevCacheDir await rm(dir, { recursive: true, force: true }) }) @@ -78,12 +85,12 @@ describe('maybeNudgeCompat', () => { it('warns again after the silence window has elapsed', async () => { const yesterday = new Date(NOW.getTime() - 25 * 60 * 60 * 1000) - const tStore = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => yesterday }) + const tStore = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => yesterday }) await tStore.markWarned(HOST) const probe = vi.fn(async () => UNSUPPORTED) const { emit, lines } = emitterSpy() - const freshStore = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: fixedNow }) + const freshStore = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: fixedNow }) await maybeNudgeCompat(HOST, baseDeps({ store: freshStore, probe, emit })) expect(probe).toHaveBeenCalledOnce() diff --git a/cli/src/version/probe.test.ts b/cli/src/version/probe.test.ts index 77a20ef2e4..833ebb3c83 100644 --- a/cli/src/version/probe.test.ts +++ b/cli/src/version/probe.test.ts @@ -160,7 +160,8 @@ describe('runVersionProbe', () => { const url = new URL(mock.url) const prevConfig = process.env[ENV_CONFIG_DIR] try { - await saveHosts(configDir, { + process.env[ENV_CONFIG_DIR] = configDir + saveHosts({ current_host: url.host, scheme: url.protocol.replace(':', ''), token_storage: 'file', diff --git a/cli/src/version/probe.ts b/cli/src/version/probe.ts index 09fc373661..6cf136571f 100644 --- a/cli/src/version/probe.ts +++ b/cli/src/version/probe.ts @@ -5,7 +5,6 @@ import type { Channel } from './info.js' import { META_PROBE_TIMEOUT_MS, MetaClient } from '../api/meta.js' import { loadHosts } from '../auth/hosts.js' import { createClient } from '../http/client.js' -import { resolveConfigDir } from '../store/dir.js' import { arch, platform } from '../sys/index.js' import { hostWithScheme } from '../util/host.js' import { difyCompat, evaluateCompat } from './compat.js' @@ -48,7 +47,7 @@ export type RunVersionProbeOptions = { readonly probe?: MetaProbe } -const defaultLoadBundle = async (): Promise => loadHosts(resolveConfigDir()) +const defaultLoadBundle = async (): Promise => loadHosts() const defaultProbe: MetaProbe = async (endpoint) => { const http = createClient({ host: endpoint, timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 })