Compare commits

..

1 Commits

Author SHA1 Message Date
4a2f90e7ec fix(device): display sso_error query param on /device page 2026-05-28 02:05:15 -07:00
54 changed files with 1099 additions and 1126 deletions

View File

@ -505,7 +505,7 @@ def _truncate_container_database(app: Flask) -> None:
session_factory-created sessions. Truncating after each test gives the suite
a central DB isolation contract that does not depend on which session a test used.
This only covers SQLAlchemy application tables in db.metadata for now;
object storage and custom ad hoc metadata still need their own cleanup.
Redis, object storage, and custom ad hoc metadata still need their own cleanup.
"""
with app.app_context():
db.session.remove()
@ -524,27 +524,13 @@ def _truncate_container_database(app: Flask) -> None:
db.session.remove()
def _flush_container_redis(app: Flask) -> None:
"""
Reset Redis after a container integration test.
Tests in this package share one Redis container for performance. Application
code stores temporary tokens, rate-limit counters, locks, and cache entries
there, so flushing after each test gives Redis-backed state the same
isolation contract as the PostgreSQL container.
"""
with app.app_context():
app.extensions["redis"].flushdb()
@pytest.fixture(autouse=True)
def isolate_container_database(request: pytest.FixtureRequest) -> Generator[None, None, None]:
"""
Clean DB and Redis state after tests that use the containerized Flask app.
Clean DB state after tests that use the containerized Flask app.
This fixture intentionally does not depend on flask_app_with_containers so
tests under this package do not start the full app/container stack just to
run state cleanup.
non-DB tests under this package do not start the full app/container stack.
"""
yield
@ -552,10 +538,7 @@ def isolate_container_database(request: pytest.FixtureRequest) -> Generator[None
return
app = request.getfixturevalue("flask_app_with_containers")
try:
_truncate_container_database(app)
finally:
_flush_container_redis(app)
_truncate_container_database(app)
@pytest.fixture(scope="package", autouse=True)

View File

@ -1,39 +0,0 @@
from __future__ import annotations
from uuid import uuid4
from extensions.ext_redis import redis_client
from models.account import Account
ACCOUNT_EMAIL = f"container-state-isolation-{uuid4()}@example.com"
REDIS_KEY = f"container-state-isolation:{uuid4()}"
def test_1_container_state_can_be_written(
flask_app_with_containers,
db_session_with_containers,
) -> None:
account = Account(
name="Container State Isolation",
email=ACCOUNT_EMAIL,
password="hashed-password",
password_salt="salt",
interface_language="en-US",
timezone="UTC",
)
db_session_with_containers.add(account)
db_session_with_containers.commit()
with flask_app_with_containers.app_context():
redis_client.set(REDIS_KEY, "leaked")
assert redis_client.get(REDIS_KEY) == b"leaked"
def test_2_container_state_is_flushed_between_tests(
flask_app_with_containers,
db_session_with_containers,
) -> None:
assert db_session_with_containers.query(Account).filter_by(email=ACCOUNT_EMAIL).one_or_none() is None
with flask_app_with_containers.app_context():
assert redis_client.get(REDIS_KEY) is None

View File

@ -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 { ENV_CACHE_DIR } from '../store/dir.js'
import { CACHE_APP_INFO, getCache } from '../store/manager.js'
import { CACHE_APP_INFO, cachePath } from '../store/manager.js'
import { YamlStore } from '../store/store.js'
import { FieldInfo, FieldParameters } from '../types/app-meta.js'
import { AppMetaClient } from './app-meta.js'
import { AppsClient } from './apps.js'
@ -15,24 +15,17 @@ 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: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, 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 })
@ -47,7 +40,7 @@ describe('AppMetaClient', () => {
})
it('slim hit + full request triggers fresh fetch + merges', async () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, 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 })
@ -61,7 +54,7 @@ describe('AppMetaClient', () => {
})
it('expired cache entry refetches', async () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO), ttlMs: 100, now: () => new Date('2026-05-09T00:00:00Z') })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, 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') })
@ -75,7 +68,7 @@ describe('AppMetaClient', () => {
})
it('invalidate forces next get to fetch', async () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, 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 })

View File

@ -0,0 +1,101 @@
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()
})
})

View File

@ -0,0 +1,99 @@
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<string, string>
type HostMap = Record<string, AccountMap>
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<void> {
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<string | undefined> {
const file = await this.read()
return file.hosts?.[host]?.[accountId]
}
async delete(host: string, accountId: string): Promise<void> {
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<readonly string[]> {
const file = await this.read()
const accounts = file.hosts?.[host]
return accounts === undefined ? [] : Object.keys(accounts)
}
private async read(): Promise<TokensFile> {
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<void> {
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 */ }
}
}

View File

@ -1,9 +1,9 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdtemp, readFile, 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 { ENV_CONFIG_DIR } from '../store/dir.js'
import { HostsBundleSchema, loadHosts, saveHosts } from './hosts.js'
import { FILE_PERM } from '../store/dir.js'
import { HOSTS_FILE_NAME, 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<string, unknown>).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 nothing was saved', () => {
expect(loadHosts()).toBeUndefined()
it('returns undefined when file is missing', async () => {
expect(await loadHosts(dir)).toBeUndefined()
})
it('round-trips a fully-populated bundle', () => {
saveHosts({
it('round-trips bundle through YAML', async () => {
await saveHosts(dir, {
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 = loadHosts()
const loaded = await loadHosts(dir)
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('rejects invalid input at save time', () => {
expect(() => saveHosts({
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<string, unknown> | 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, {
current_host: 'cloud.dify.ai',
token_storage: 'cloud',
} as never)).toThrow()
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')
})
})

View File

@ -1,6 +1,10 @@
import type { Store } from '../store/store.js'
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import yaml from 'js-yaml'
import { z } from 'zod'
import { getHostStore, tokenKey } from '../store/manager.js'
import { DIR_PERM, FILE_PERM } from '../store/dir.js'
export const HOSTS_FILE_NAME = 'hosts.yml'
const StorageModeSchema = z.enum(['keychain', 'file'])
export type StorageMode = z.infer<typeof StorageModeSchema>
@ -44,23 +48,53 @@ export const HostsBundleSchema = z.object({
})
export type HostsBundle = z.infer<typeof HostsBundleSchema>
export function loadHosts(): HostsBundle | undefined {
const raw = getHostStore().getTyped<Record<string, unknown>>()
if (raw === null)
return undefined
return HostsBundleSchema.parse(raw)
}
export function saveHosts(bundle: HostsBundle): void {
const validated = HostsBundleSchema.parse(bundle)
getHostStore().setTyped(validated)
}
export function clearLocal(bundle: HostsBundle, store: Store): void {
const accountId = bundle.account?.id ?? bundle.external_subject?.email ?? 'default'
export async function loadHosts(dir: string): Promise<HostsBundle | undefined> {
const path = join(dir, HOSTS_FILE_NAME)
let raw: string
try {
store.unset(tokenKey(bundle.current_host, accountId))
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 async function saveHosts(dir: string, bundle: HostsBundle): Promise<void> {
await mkdir(dir, { recursive: true, mode: DIR_PERM })
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()}`
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)
}
catch { /* best-effort */ }
getHostStore().rm()
}
function stripUndefined<T extends Record<string, unknown>>(input: T): Record<string, unknown> {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(input)) {
if (v === undefined)
continue
out[k] = v
}
return out
}

View File

@ -0,0 +1,111 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const passwords = new Map<string, string>()
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<void> {
setPassword(this.key, value)
passwords.set(this.key, value)
}
async getPassword(): Promise<string | undefined> {
getPassword(this.key)
return passwords.get(this.key)
}
async deletePassword(): Promise<boolean> {
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/)
})
})

View File

@ -0,0 +1,35 @@
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<void> {
await new AsyncEntry(KEYRING_SERVICE, username(host, accountId)).setPassword(token)
}
async get(host: string, accountId: string): Promise<string | undefined> {
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<void> {
try {
await new AsyncEntry(KEYRING_SERVICE, username(host, accountId)).deletePassword()
}
catch { /* missing entry is fine */ }
}
async list(_host: string): Promise<readonly string[]> {
return []
}
}

View File

@ -0,0 +1,75 @@
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<string, string>()
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()
})
})

40
cli/src/auth/store.ts Normal file
View File

@ -0,0 +1,40 @@
import { FileBackend } from './file-backend.js'
import { KeyringBackend } from './keyring-backend.js'
export type TokenStore = {
put: (host: string, accountId: string, token: string) => Promise<void>
get: (host: string, accountId: string) => Promise<string | undefined>
delete: (host: string, accountId: string) => Promise<void>
list: (host: string) => Promise<readonly string[]>
}
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' }
}
}

View File

@ -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 { ENV_CACHE_DIR } from '../store/dir.js'
import { CACHE_APP_INFO, cachePath, getCache } from '../store/manager.js'
import { CACHE_APP_INFO, cachePath } from '../store/manager.js'
import { YamlStore } from '../store/store.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,25 +35,18 @@ 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: getCache(CACHE_APP_INFO) })
const c1 = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await c1.set('http://localhost:9999', 'app-1', metaInfoOnly())
const c2 = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const c2 = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const got = c2.get('http://localhost:9999', 'app-1')
expect(got).toBeDefined()
expect(got?.meta.info?.id).toBe('app-1')
@ -62,7 +55,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: getCache(CACHE_APP_INFO), now: () => now })
const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)), now: () => now })
await c.set('h', 'app-1', metaInfoOnly())
const r = c.get('h', 'app-1')
expect(r).toBeDefined()
@ -73,23 +66,23 @@ describe('app-info disk cache', () => {
})
it('keys by (host, app_id) — different hosts isolate', async () => {
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, 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: getCache(CACHE_APP_INFO) })
const c1 = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await c1.set('h', 'app-1', metaInfoOnly())
await c1.delete('h', 'app-1')
const c2 = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const c2 = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
expect(c2.get('h', 'app-1')).toBeUndefined()
})
it('writes file with 0600 permission', async () => {
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await c.set('h', 'app-1', metaInfoOnly())
const { stat } = await import('node:fs/promises')
const s = await stat(appInfoPath(dir))
@ -98,19 +91,19 @@ describe('app-info disk cache', () => {
})
it('missing cache file is not an error', async () => {
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, 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: getCache(CACHE_APP_INFO) })
const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
expect(c.get('h', 'app-1')).toBeUndefined()
})
it('updates same key in place (no growth)', async () => {
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await c.set('h', 'app-1', metaInfoOnly())
const slim: AppMeta = {
...metaInfoOnly(),

View File

@ -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 { ENV_CACHE_DIR } from '../store/dir.js'
import { CACHE_NUDGE, cachePath, getCache } from '../store/manager.js'
import { CACHE_NUDGE, cachePath } from '../store/manager.js'
import { YamlStore } from '../store/store.js'
import { loadNudgeStore, WARN_INTERVAL_MS } from './nudge-store.js'
function nudgeStorePath(dir: string): string {
@ -15,28 +15,21 @@ 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: getCache(CACHE_NUDGE) })
const store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, 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: getCache(CACHE_NUDGE), now: () => t0 })
const store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, 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)
@ -44,7 +37,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: getCache(CACHE_NUDGE), now: () => t0 })
const store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, 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)
@ -52,22 +45,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: getCache(CACHE_NUDGE), now: () => t0 })
const s1 = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t0 })
await s1.markWarned(HOST)
const s2 = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t0 })
const s2 = await loadNudgeStore({ store: new YamlStore(cachePath(dir, 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: getCache(CACHE_NUDGE) })
const store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)) })
expect(store.canWarn(HOST)).toBe(true)
})
it('writes ISO timestamps under warned/<host> on disk', async () => {
const t = new Date('2026-05-19T12:00:00.000Z')
const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t })
const store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t })
await store.markWarned(HOST)
const raw = await readFile(nudgeStorePath(dir), 'utf8')
const parsed = yaml.load(raw) as Record<string, unknown>
@ -79,11 +72,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: getCache(CACHE_NUDGE), now: () => t })
const b = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t })
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 })
await a.markWarned('https://a.example')
await b.markWarned('https://b.example')
const reread = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t })
const reread = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t })
expect(reread.canWarn('https://a.example')).toBe(false)
expect(reread.canWarn('https://b.example')).toBe(false)
})

View File

@ -12,6 +12,7 @@ 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'
@ -23,6 +24,7 @@ export type AuthedContext = {
readonly http: KyInstance
readonly host: string
readonly io: IOStreams
readonly configDir: string
readonly cache?: AppInfoCache
}
@ -36,8 +38,9 @@ export async function buildAuthedContext(
cmd: Pick<Command, 'error'>,
opts: AuthedContextOptions,
): Promise<AuthedContext> {
const configDir = resolveConfigDir()
const io = realStreams(opts.format ?? '')
const bundle = loadHosts()
const bundle = await loadHosts(configDir)
if (bundle === undefined || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') {
const err = new BaseError({
code: ErrorCode.NotLoggedIn,
@ -58,7 +61,7 @@ export async function buildAuthedContext(
await runCompatNudge({ host, io })
return { bundle, http, host, io, cache }
return { bundle, http, host, io, configDir, cache }
}
// Best-effort nudge: never throws, never blocks. Lives here so every authed

View File

@ -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 { Key, Store } from '../../../../store/store.js'
import type { TokenStore } from '../../../../auth/store.js'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@ -10,23 +10,26 @@ 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 Store {
readonly entries = new Map<string, unknown>()
get<T>(key: Key<T>): T {
return (this.entries.get(key.key) as T | undefined) ?? key.default
class MemStore implements TokenStore {
readonly entries = new Map<string, string>()
async put(host: string, accountId: string, token: string): Promise<void> {
this.entries.set(`${host}::${accountId}`, token)
}
set<T>(key: Key<T>, value: T): void {
this.entries.set(key.key, value)
async get(host: string, accountId: string): Promise<string | undefined> {
return this.entries.get(`${host}::${accountId}`)
}
unset<T>(key: Key<T>): void {
this.entries.delete(key.key)
async delete(host: string, accountId: string): Promise<void> {
this.entries.delete(`${host}::${accountId}`)
}
async list(host: string): Promise<readonly string[]> {
const prefix = `${host}::`
return Array.from(this.entries.keys()).filter(k => k.startsWith(prefix))
}
}
@ -90,18 +93,11 @@ 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 })
})
@ -110,11 +106,11 @@ describe('runDevicesRevoke', () => {
const io = bufferStreams()
const store = new MemStore()
const b = bundleFor(mock.url, 'tok-1')
store.set(tokenKey(b.current_host, 'acct-1'), 'dfoa_test')
saveHosts(b)
await store.put(b.current_host, 'acct-1', 'dfoa_test')
await saveHosts(configDir, b)
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesRevoke({ io, bundle: b, http, store, target: 'difyctl on desktop', all: false })
await runDevicesRevoke({ configDir, 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)
})
@ -125,7 +121,7 @@ describe('runDevicesRevoke', () => {
const b = bundleFor(mock.url, 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesRevoke({ io, bundle: b, http, store, target: 'tok-2', all: false })
await runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'tok-2', all: false })
expect(io.outBuf()).toContain('Revoked 1 session(s)')
})
@ -135,7 +131,7 @@ describe('runDevicesRevoke', () => {
const b = bundleFor(mock.url, 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesRevoke({ io, bundle: b, http, store, target: 'web', all: false })
await runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'web', all: false })
expect(io.outBuf()).toContain('Revoked 1 session(s)')
})
@ -145,7 +141,7 @@ describe('runDevicesRevoke', () => {
const b = bundleFor(mock.url, 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await expect(runDevicesRevoke({ io, bundle: b, http, store, target: 'difyctl', all: false }))
await expect(runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'difyctl', all: false }))
.rejects
.toThrow(/matches multiple/)
})
@ -156,7 +152,7 @@ describe('runDevicesRevoke', () => {
const b = bundleFor(mock.url, 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await expect(runDevicesRevoke({ io, bundle: b, http, store, target: 'nonexistent', all: false }))
await expect(runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'nonexistent', all: false }))
.rejects
.toThrow(/no session matches/)
})
@ -167,7 +163,7 @@ describe('runDevicesRevoke', () => {
const b = bundleFor(mock.url, 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesRevoke({ io, bundle: b, http, store, all: true })
await runDevicesRevoke({ configDir, io, bundle: b, http, store, all: true })
expect(io.outBuf()).toContain('Revoked 2 session(s)')
})
@ -175,20 +171,20 @@ describe('runDevicesRevoke', () => {
const io = bufferStreams()
const store = new MemStore()
const b = bundleFor(mock.url, 'tok-1')
store.set(tokenKey(b.current_host, 'acct-1'), 'dfoa_test')
saveHosts(b)
await store.put(b.current_host, 'acct-1', 'dfoa_test')
await saveHosts(configDir, b)
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesRevoke({ io, bundle: b, http, store, target: 'tok-1', all: false })
await runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'tok-1', all: false })
expect(store.entries.size).toBe(0)
await expect(readFile(join(resolveConfigDir(), 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/)
await expect(readFile(join(configDir, '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({ io, bundle: bundleFor(mock.url), http, store, all: false }))
await expect(runDevicesRevoke({ configDir, io, bundle: bundleFor(mock.url), http, store, all: false }))
.rejects
.toThrow(/specify a device label/)
})

View File

@ -1,14 +1,15 @@
import type { SessionRow } from '@dify/contracts/api/openapi/types.gen'
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../../auth/hosts.js'
import type { Store } from '../../../../store/store.js'
import type { TokenStore } from '../../../../auth/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 { clearLocal } from '../../../../auth/hosts.js'
import { HOSTS_FILE_NAME } 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'
@ -71,11 +72,11 @@ export async function listAllSessions(client: AccountSessionsClient): Promise<re
}
export type DevicesRevokeOptions = {
readonly configDir: string
readonly io: IOStreams
readonly bundle: HostsBundle | undefined
readonly http: KyInstance
/** Optional override for tests; production code resolves via `getTokenStore`. */
readonly store?: Store
readonly store: TokenStore
readonly target?: string
readonly all: boolean
readonly yes?: boolean
@ -103,10 +104,8 @@ export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise<void
for (const id of ids)
await sessions.revoke(id)
if (selfHit) {
const tokens = opts.store ?? getTokenStore().store
clearLocal(b, tokens)
}
if (selfHit)
await clearLocal(opts.configDir, b, opts.store)
opts.io.out.write(`${cs.successIcon()} Revoked ${ids.length} session(s)\n`)
}
@ -179,3 +178,18 @@ function renderTable(rows: readonly SessionRow[], currentId: string): string {
cells.map((c, i) => 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<void> {
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
}
}

View File

@ -1,3 +1,4 @@
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'
@ -24,10 +25,13 @@ export default class DevicesRevoke extends DifyCommand {
async run(argv: string[]): Promise<void> {
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,

View File

@ -1,4 +1,5 @@
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'
@ -30,6 +31,7 @@ export default class Login extends DifyCommand {
async run(argv: string[]): Promise<void> {
const { flags } = this.parse(Login, argv)
await runLogin({
configDir: resolveConfigDir(),
io: realStreams(),
host: flags.host,
noBrowser: flags['no-browser'],

View File

@ -1,5 +1,5 @@
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
import type { Key, Store } from '../../../store/store.js'
import type { TokenStore } from '../../../auth/store.js'
import type { Clock } from './device-flow.js'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
@ -8,8 +8,6 @@ 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'
@ -20,38 +18,38 @@ const noopClock: Clock = {
const noopBrowser = async (): Promise<void> => { /* skip OS open */ }
class MemStore implements Store {
readonly entries = new Map<string, unknown>()
get<T>(key: Key<T>): T {
return (this.entries.get(key.key) as T | undefined) ?? key.default
class MemStore implements TokenStore {
readonly entries = new Map<string, string>()
async put(host: string, accountId: string, token: string): Promise<void> {
this.entries.set(`${host}::${accountId}`, token)
}
set<T>(key: Key<T>, value: T): void {
this.entries.set(key.key, value)
async get(host: string, accountId: string): Promise<string | undefined> {
return this.entries.get(`${host}::${accountId}`)
}
unset<T>(key: Key<T>): void {
this.entries.delete(key.key)
async delete(host: string, accountId: string): Promise<void> {
this.entries.delete(`${host}::${accountId}`)
}
async list(host: string): Promise<readonly string[]> {
const prefix = `${host}::`
return Array.from(this.entries.keys())
.filter(k => k.startsWith(prefix))
.map(k => k.slice(prefix.length))
}
}
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 })
})
@ -60,6 +58,7 @@ describe('runLogin', () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = await runLogin({
configDir,
io,
host: mock.url,
noBrowser: true,
@ -74,7 +73,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 = store.get(tokenKey(bundle.current_host, 'acct-1'))
const stored = await store.get(bundle.current_host, 'acct-1')
expect(stored).toBe('dfoa_test')
const hostsRaw = await readFile(join(configDir, 'hosts.yml'), 'utf8')
@ -92,6 +91,7 @@ describe('runLogin', () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = await runLogin({
configDir,
io,
host: mock.url,
noBrowser: true,
@ -115,6 +115,7 @@ describe('runLogin', () => {
const io = bufferStreams()
const store = new MemStore()
await expect(runLogin({
configDir,
io,
host: mock.url,
noBrowser: true,
@ -134,6 +135,7 @@ describe('runLogin', () => {
const io = bufferStreams()
const store = new MemStore()
await expect(runLogin({
configDir,
io,
host: mock.url,
noBrowser: true,
@ -150,6 +152,7 @@ describe('runLogin', () => {
const io = bufferStreams()
const store = new MemStore()
await expect(runLogin({
configDir,
io,
host: mock.url,
noBrowser: true,
@ -166,6 +169,7 @@ describe('runLogin', () => {
const io = bufferStreams()
const store = new MemStore()
await runLogin({
configDir,
io,
host: mock.url,
noBrowser: true,

View File

@ -1,6 +1,6 @@
import type { CodeResponse, PollSuccess } from '../../../api/oauth-device.js'
import type { HostsBundle, Workspace } from '../../../auth/hosts.js'
import type { StorageMode, Store } from '../../../store/store.js'
import type { HostsBundle, StorageMode, Workspace } from '../../../auth/hosts.js'
import type { TokenStore } from '../../../auth/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,20 +8,21 @@ 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: Store, readonly mode: StorageMode }
readonly store?: { readonly store: TokenStore, readonly mode: StorageMode }
readonly api?: DeviceFlowApi
readonly browserEnv?: BrowserEnv
readonly browserOpener?: BrowserOpener
@ -58,11 +59,11 @@ export async function runLogin(opts: LoginOptions): Promise<HostsBundle> {
const success = await awaitAuthorization(api, code, { clock: opts.clock ?? realClock() })
const storeBundle = opts.store ?? getTokenStore()
const storeBundle = opts.store ?? await selectStore({ configDir: opts.configDir })
const bundle = bundleFromSuccess(host, success, storeBundle.mode)
storeBundle.store.set(tokenKey(bundle.current_host, accountKey(bundle)), success.token)
saveHosts(bundle)
await storeBundle.store.put(bundle.current_host, accountKey(bundle), success.token)
await saveHosts(opts.configDir, bundle)
renderLoggedIn(opts.io.out, cs, host, success)
return bundle

View File

@ -1,6 +1,8 @@
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'
@ -16,7 +18,9 @@ export default class Logout extends DifyCommand {
async run(argv: string[]): Promise<void> {
this.parse(Logout, argv)
const bundle = loadHosts()
const configDir = resolveConfigDir()
const bundle = await loadHosts(configDir)
const { store } = await selectStore({ configDir })
let http: KyInstance | undefined
if (bundle !== undefined && bundle.current_host !== '' && bundle.tokens?.bearer !== undefined && bundle.tokens.bearer !== '') {
@ -30,7 +34,7 @@ export default class Logout extends DifyCommand {
const io = realStreams()
await runWithSpinner(
{ io, label: 'Signing out', enabled: true, style: 'dify-dim' },
() => runLogout({ io, bundle, http }),
() => runLogout({ configDir, io, bundle, http, store }),
)
}
}

View File

@ -1,6 +1,6 @@
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { Key, Store } from '../../../store/store.js'
import type { TokenStore } from '../../../auth/store.js'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@ -8,23 +8,28 @@ 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 Store {
readonly entries = new Map<string, unknown>()
get<T>(key: Key<T>): T {
return (this.entries.get(key.key) as T | undefined) ?? key.default
class MemStore implements TokenStore {
readonly entries = new Map<string, string>()
async put(host: string, accountId: string, token: string): Promise<void> {
this.entries.set(`${host}::${accountId}`, token)
}
set<T>(key: Key<T>, value: T): void {
this.entries.set(key.key, value)
async get(host: string, accountId: string): Promise<string | undefined> {
return this.entries.get(`${host}::${accountId}`)
}
unset<T>(key: Key<T>): void {
this.entries.delete(key.key)
async delete(host: string, accountId: string): Promise<void> {
this.entries.delete(`${host}::${accountId}`)
}
async list(host: string): Promise<readonly string[]> {
const prefix = `${host}::`
return Array.from(this.entries.keys())
.filter(k => k.startsWith(prefix))
.map(k => k.slice(prefix.length))
}
}
@ -47,20 +52,13 @@ 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 })
})
@ -69,11 +67,11 @@ describe('runLogout', () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = fixtureBundle(mock.url)
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfoa_test')
saveHosts(bundle)
await store.put(bundle.current_host, 'acct-1', 'dfoa_test')
await saveHosts(configDir, bundle)
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runLogout({ io, bundle, http, store })
await runLogout({ configDir, io, bundle, http, store })
expect(store.entries.size).toBe(0)
await expect(readFile(join(configDir, 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/)
@ -84,7 +82,7 @@ describe('runLogout', () => {
it('not-logged-in: throws BaseError', async () => {
const io = bufferStreams()
const store = new MemStore()
await expect(runLogout({ io, bundle: undefined, store })).rejects.toThrow(/not logged in/)
await expect(runLogout({ configDir, io, bundle: undefined, store })).rejects.toThrow(/not logged in/)
})
it('hosts.yml absent: still completes locally + emits success', async () => {
@ -93,7 +91,7 @@ describe('runLogout', () => {
const bundle = fixtureBundle(mock.url)
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runLogout({ io, bundle, http, store })
await runLogout({ configDir, io, bundle, http, store })
expect(io.outBuf()).toContain('Logged out of')
})
@ -102,12 +100,12 @@ describe('runLogout', () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = fixtureBundle(mock.url)
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfoa_test')
saveHosts(bundle)
await store.put(bundle.current_host, 'acct-1', 'dfoa_test')
await saveHosts(configDir, bundle)
mock.setScenario('server-5xx')
const http = createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 })
await runLogout({ io, bundle, http, store })
await runLogout({ configDir, io, bundle, http, store })
expect(store.entries.size).toBe(0)
expect(io.errBuf()).toContain('server revoke failed')
@ -119,11 +117,11 @@ describe('runLogout', () => {
const store = new MemStore()
const bundle = fixtureBundle(mock.url)
bundle.tokens = { bearer: 'dfp_personal_token' }
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfp_personal_token')
saveHosts(bundle)
await store.put(bundle.current_host, 'acct-1', 'dfp_personal_token')
await saveHosts(configDir, bundle)
const http = createClient({ host: mock.url, bearer: 'dfp_personal_token' })
await runLogout({ io, bundle, http, store })
await runLogout({ configDir, io, bundle, http, store })
expect(io.errBuf()).toBe('')
expect(store.entries.size).toBe(0)
@ -133,11 +131,11 @@ describe('runLogout', () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = fixtureBundle(mock.url)
saveHosts(bundle)
await saveHosts(configDir, bundle)
await writeFile(join(configDir, 'config.yml'), 'foo: bar\n', 'utf8')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runLogout({ io, bundle, http, store })
await runLogout({ configDir, io, bundle, http, store })
const cfg = await readFile(join(configDir, 'config.yml'), 'utf8')
expect(cfg).toContain('foo: bar')

View File

@ -1,20 +1,21 @@
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { Store } from '../../../store/store.js'
import type { TokenStore } from '../../../auth/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 { clearLocal } from '../../../auth/hosts.js'
import { HOSTS_FILE_NAME } 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
/** Optional override for tests; production code resolves via `getTokenStore`. */
readonly store?: Store
readonly store: TokenStore
}
export async function runLogout(opts: LogoutOptions): Promise<void> {
@ -39,8 +40,7 @@ export async function runLogout(opts: LogoutOptions): Promise<void> {
}
}
const tokens = opts.store ?? getTokenStore().store
clearLocal(bundle, tokens)
await clearLocal(opts.configDir, bundle, opts.store)
if (revokeWarning !== '')
opts.io.err.write(revokeWarning)
@ -52,3 +52,19 @@ 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<void> {
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
}
}

View File

@ -1,5 +1,6 @@
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'
@ -20,7 +21,8 @@ export default class Status extends DifyCommand {
async run(argv: string[]): Promise<void> {
const { flags } = this.parse(Status, argv)
const bundle = loadHosts()
const configDir = resolveConfigDir()
const bundle = await loadHosts(configDir)
await runStatus({ io: realStreams(), bundle, verbose: flags.verbose, json: flags.json })
}
}

View File

@ -1,5 +1,6 @@
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'
@ -18,7 +19,8 @@ export default class Whoami extends DifyCommand {
async run(argv: string[]): Promise<void> {
const { flags } = this.parse(Whoami, argv)
const bundle = loadHosts()
const configDir = resolveConfigDir()
const bundle = await loadHosts(configDir)
await runWhoami({ io: realStreams(), bundle, json: flags.json })
}
}

View File

@ -1,49 +1,43 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it } from 'vitest'
import { FILE_NAME } from '../../../config/schema.js'
import { isBaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
import { getConfigurationStore } from '../../../store/manager.js'
import { YamlStore } from '../../../store/store.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
})
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' })
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' })
expect(out).toBe('yaml\n')
})
it('returns empty line when key is unset (matches Go fmt.Fprintln)', () => {
const out = runConfigGet({ store: getConfigurationStore(), key: 'defaults.format' })
const out = runConfigGet({ store: makeStore(dir), key: 'defaults.format' })
expect(out).toBe('\n')
})
it('throws BaseError(config_invalid_key) on unknown key', () => {
let caught: unknown
try {
runConfigGet({ store: getConfigurationStore(), key: 'bogus.key' })
runConfigGet({ store: makeStore(dir), key: 'bogus.key' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@ -51,12 +45,13 @@ describe('runConfigGet', () => {
expect(caught.code).toBe(ErrorCode.ConfigInvalidKey)
})
it('returns numeric limit as string', () => {
getConfigurationStore().setTyped({
schema_version: 1,
defaults: { limit: 75 },
})
const out = runConfigGet({ store: getConfigurationStore(), key: 'defaults.limit' })
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' })
expect(out).toBe('75\n')
})
})

View File

@ -1,8 +1,7 @@
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'
@ -13,8 +12,6 @@ export default class ConfigPath extends DifyCommand {
async run(argv: string[]) {
this.parse(ConfigPath, argv)
return raw(
join(resolveConfigDir(), CONFIG_FILE_NAME),
)
return raw(runConfigPath({ dir: resolveConfigDir() }))
}
}

View File

@ -0,0 +1,14 @@
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')
})
})

View File

@ -0,0 +1,10 @@
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`
}

View File

@ -1,46 +1,35 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdtemp, readFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { loadConfig } from '../../../config/config-loader.js'
import { beforeEach, describe, expect, it } from 'vitest'
import { FILE_NAME } from '../../../config/schema.js'
import { isBaseError } from '../../../errors/base.js'
import { ErrorCode, ExitCode } from '../../../errors/codes.js'
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
import { getConfigurationStore } from '../../../store/manager.js'
import { YamlStore } from '../../../store/store.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
})
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' })
it('writes config.yml and returns "set k = v\\n"', async () => {
const out = runConfigSet({ store: makeStore(dir), key: 'defaults.format', value: 'json' })
expect(out).toBe('set defaults.format = json\n')
const r = loadConfig(getConfigurationStore())
expect(r.found).toBe(true)
if (r.found)
expect(r.config.defaults.format).toBe('json')
const raw = await readFile(join(dir, FILE_NAME), 'utf8')
expect(raw).toContain('format: json')
})
it('rejects invalid format value with config_invalid_value', () => {
it('rejects invalid format value with config_invalid_value', async () => {
let caught: unknown
try {
runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'csv' })
runConfigSet({ store: makeStore(dir), key: 'defaults.format', value: 'csv' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@ -51,7 +40,7 @@ describe('runConfigSet', () => {
it('rejects unknown key with config_invalid_key', () => {
let caught: unknown
try {
runConfigSet({ store: getConfigurationStore(), key: 'bogus', value: 'x' })
runConfigSet({ store: makeStore(dir), key: 'bogus', value: 'x' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@ -59,22 +48,18 @@ describe('runConfigSet', () => {
expect(caught.code).toBe(ErrorCode.ConfigInvalidKey)
})
it('preserves prior keys when setting a new one', () => {
runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'yaml' })
runConfigSet({ store: getConfigurationStore(), key: 'defaults.limit', value: '40' })
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('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('exit code for invalid value is Usage (2)', () => {
let caught: unknown
try {
runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'csv' })
runConfigSet({ store: makeStore(dir), key: 'defaults.format', value: 'csv' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@ -85,7 +70,7 @@ describe('runConfigSet', () => {
it('exit code for unknown key is Usage (2)', () => {
let caught: unknown
try {
runConfigSet({ store: getConfigurationStore(), key: 'bogus', value: 'x' })
runConfigSet({ store: makeStore(dir), key: 'bogus', value: 'x' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@ -96,7 +81,7 @@ describe('runConfigSet', () => {
it('typed wrap chain: invalid defaults.limit surfaces ConfigInvalidValue (not UsageInvalidFlag)', () => {
let caught: unknown
try {
runConfigSet({ store: getConfigurationStore(), key: 'defaults.limit', value: 'abc' })
runConfigSet({ store: makeStore(dir), key: 'defaults.limit', value: 'abc' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)

View File

@ -1,61 +1,48 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { loadConfig } from '../../../config/config-loader.js'
import { beforeEach, describe, expect, it } from 'vitest'
import { FILE_NAME } from '../../../config/schema.js'
import { isBaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
import { getConfigurationStore } from '../../../store/manager.js'
import { YamlStore } from '../../../store/store.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
})
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('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' })
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 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)
}
const raw = await readFile(join(dir, FILE_NAME), 'utf8')
expect(raw).not.toContain('format:')
expect(raw).toContain('limit: 25')
})
it('is a no-op (writes empty config) when key was already unset', () => {
const out = runConfigUnset({ store: getConfigurationStore(), key: 'defaults.format' })
it('is a no-op (writes empty config) when key was already unset', async () => {
const out = runConfigUnset({ store: makeStore(dir), 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)
const raw = await readFile(join(dir, FILE_NAME), 'utf8')
expect(raw).toContain('schema_version: 1')
})
it('rejects unknown key', () => {
let caught: unknown
try {
runConfigUnset({ store: getConfigurationStore(), key: 'bogus' })
runConfigUnset({ store: makeStore(dir), key: 'bogus' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)

View File

@ -1,69 +1,67 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
import { getConfigurationStore } from '../../../store/manager.js'
import { FILE_NAME } from '../../../config/schema.js'
import { YamlStore } from '../../../store/store.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 () => {
if (prevConfigDir === undefined)
delete process.env[ENV_CONFIG_DIR]
else
process.env[ENV_CONFIG_DIR] = prevConfigDir
await rm(dir, { recursive: true, force: true })
// tmpdir cleanup is best-effort
})
it('text format: empty config returns empty string', () => {
const out = runConfigView({ store: getConfigurationStore() })
const out = runConfigView({ store: makeStore(dir) })
expect(out).toBe('')
})
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() })
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) })
expect(out).toBe(
'defaults.format = json\ndefaults.limit = 50\nstate.current_app = app-1\n',
)
})
it('text format: skips unset keys', () => {
getConfigurationStore().setTyped({
schema_version: 1,
defaults: { format: 'yaml' },
})
const out = runConfigView({ store: getConfigurationStore() })
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) })
expect(out).toBe('defaults.format = yaml\n')
expect(out).not.toContain('defaults.limit')
expect(out).not.toContain('state.current_app')
})
it('json format: empty config returns "{}\\n"', () => {
const out = runConfigView({ store: getConfigurationStore(), json: true })
const out = runConfigView({ store: makeStore(dir), json: true })
expect(out).toBe('{}\n')
})
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 })
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 })
const parsed = JSON.parse(out) as Record<string, unknown>
expect(parsed['defaults.format']).toBe('table')
expect(parsed['defaults.limit']).toBe(100)
@ -71,7 +69,7 @@ describe('runConfigView', () => {
})
it('json format: trailing newline matches Go encoder.Encode', () => {
const out = runConfigView({ store: getConfigurationStore(), json: true })
const out = runConfigView({ store: makeStore(dir), json: true })
expect(out.endsWith('\n')).toBe(true)
})
})

View File

@ -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 { ENV_CACHE_DIR } from '../../../store/dir.js'
import { CACHE_APP_INFO, getCache } from '../../../store/manager.js'
import { CACHE_APP_INFO, cachePath } from '../../../store/manager.js'
import { YamlStore } from '../../../store/store.js'
import { runDescribeApp } from './run.js'
function bundle(): HostsBundle {
@ -29,24 +29,17 @@ 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<typeof runDescribeApp>[0]): Promise<string> {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const data = await runDescribeApp(
opts,
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
@ -89,7 +82,7 @@ describe('runDescribeApp', () => {
})
it('refresh: bypasses cache', async () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await runDescribeApp(
{ appId: 'app-1' },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },

View File

@ -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 { ENV_CACHE_DIR } from '../../../store/dir.js'
import { CACHE_APP_INFO, getCache } from '../../../store/manager.js'
import { CACHE_APP_INFO, cachePath } from '../../../store/manager.js'
import { YamlStore } from '../../../store/store.js'
import { bufferStreams } from '../../../sys/io/streams'
import { resumeApp } from '../../resume/app/run.js'
import { runApp } from './run.js'
@ -30,25 +30,18 @@ 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: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, 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 },
@ -59,7 +52,7 @@ describe('runApp', () => {
it('workflow: rejects positional message with usage error', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, 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 },
@ -68,7 +61,7 @@ describe('runApp', () => {
it('workflow: prints single-string output as plain text', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, 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 },
@ -78,7 +71,7 @@ describe('runApp', () => {
it('json: passes through full envelope', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, 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 },
@ -111,7 +104,7 @@ describe('runApp', () => {
it('--stream chat: streams answer to stdout and hint to stderr', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, 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 },
@ -123,7 +116,7 @@ describe('runApp', () => {
it('--stream -o json chat: aggregates into blocking-shape envelope', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, 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 },
@ -136,7 +129,7 @@ describe('runApp', () => {
it('agent-chat without --stream: collects and prints answer', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, 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 },
@ -147,7 +140,7 @@ describe('runApp', () => {
it('agent-chat with --stream: live-prints answer and thoughts to stderr', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, 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 },
@ -158,7 +151,7 @@ describe('runApp', () => {
it('--stream workflow -o json: aggregates from workflow_finished', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, 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 },
@ -171,7 +164,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: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, 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 },
@ -180,7 +173,7 @@ describe('runApp', () => {
it('--inputs-file: reads inputs from file', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const inputsFile = join(dir, 'inputs.json')
const { writeFile } = await import('node:fs/promises')
await writeFile(inputsFile, JSON.stringify({ x: 'from-file' }))
@ -204,7 +197,7 @@ describe('runApp', () => {
it('--inputs: accepts JSON object string', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, 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 },
@ -226,7 +219,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: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
let exitCode = -1
await expect(runApp(
{ appId: 'app-2', inputs: {} },
@ -255,7 +248,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: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
let exitCode = -1
await expect(runApp(
{ appId: 'app-2', inputs: {}, format: 'json' },
@ -281,7 +274,7 @@ describe('runApp', () => {
it('resume: withHistory: false completes successfully', async () => {
mock.setScenario('hitl-resume')
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, 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 },
@ -292,7 +285,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: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, 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 },
@ -303,7 +296,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: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, 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 },
@ -314,7 +307,7 @@ describe('runApp', () => {
it('workflow: --file remote URL is passed as remote_url input variable', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, 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 },
@ -333,7 +326,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: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const filePath = join(dir, 'test.pdf')
await writeFile(filePath, 'fake pdf content')
await runApp(
@ -352,7 +345,7 @@ describe('runApp', () => {
it('workflow: --file overrides same-named key from --inputs (file wins)', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, 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 },

View File

@ -22,6 +22,7 @@ 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,

View File

@ -9,7 +9,6 @@ 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'
@ -52,29 +51,23 @@ 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()
saveHosts(b)
await saveHosts(configDir, b)
const client = fakeClient({})
const next = await runUseWorkspace(
{ workspaceId: 'ws-2' },
{
configDir,
bundle: b,
http: {} as KyInstance,
io,
@ -89,7 +82,7 @@ describe('runUseWorkspace', () => {
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Switched', role: 'normal' },
])
const reloaded = loadHosts()
const reloaded = await loadHosts(configDir)
expect(reloaded?.workspace?.id).toBe('ws-2')
expect(reloaded?.workspace?.name).toBe('Switched')
expect(io.outBuf()).toMatch(/Switched to Switched \(ws-2\)/)
@ -100,15 +93,15 @@ describe('runUseWorkspace', () => {
// We expect saveHosts to record the fresh name from the server.
const io = bufferStreams()
const b = bundle()
saveHosts(b)
await saveHosts(configDir, b)
const client = fakeClient({})
await runUseWorkspace(
{ workspaceId: 'ws-2' },
{ bundle: b, http: {} as KyInstance, io, workspacesFactory: () => client as never },
{ configDir, bundle: b, http: {} as KyInstance, io, workspacesFactory: () => client as never },
)
const reloaded = loadHosts()
const reloaded = await loadHosts(configDir)
expect(reloaded?.workspace?.name).toBe('Switched')
expect(reloaded?.available_workspaces?.find(w => w.id === 'ws-2')?.name).toBe('Switched')
})
@ -116,8 +109,8 @@ describe('runUseWorkspace', () => {
it('does NOT mutate hosts.yml when POST /switch fails', async () => {
const io = bufferStreams()
const b = bundle()
saveHosts(b)
const before = loadHosts()
await saveHosts(configDir, b)
const before = await loadHosts(configDir)
const client = fakeClient({
switch: () => Promise.reject(new Error('forbidden')),
@ -127,6 +120,7 @@ describe('runUseWorkspace', () => {
runUseWorkspace(
{ workspaceId: 'ws-2' },
{
configDir,
bundle: b,
http: {} as KyInstance,
io,
@ -136,7 +130,7 @@ describe('runUseWorkspace', () => {
).rejects.toThrow(/forbidden/)
expect(client.list).not.toHaveBeenCalled()
const after = loadHosts()
const after = await loadHosts(configDir)
expect(after).toEqual(before)
expect(after?.workspace?.id).toBe('ws-1')
})
@ -144,8 +138,8 @@ describe('runUseWorkspace', () => {
it('does NOT mutate hosts.yml when GET /workspaces fails after switch', async () => {
const io = bufferStreams()
const b = bundle()
saveHosts(b)
const before = loadHosts()
await saveHosts(configDir, b)
const before = await loadHosts(configDir)
const client = fakeClient({
list: () => Promise.reject(new Error('transient list failure')),
@ -155,6 +149,7 @@ describe('runUseWorkspace', () => {
runUseWorkspace(
{ workspaceId: 'ws-2' },
{
configDir,
bundle: b,
http: {} as KyInstance,
io,
@ -163,14 +158,14 @@ describe('runUseWorkspace', () => {
),
).rejects.toThrow(/transient list failure/)
const after = loadHosts()
const after = await loadHosts(configDir)
expect(after).toEqual(before)
})
it('throws when server returns switch=<id> but id is missing from /workspaces list', async () => {
const io = bufferStreams()
const b = bundle()
saveHosts(b)
await saveHosts(configDir, b)
const client = fakeClient({
switch: () => Promise.resolve({
@ -192,6 +187,7 @@ describe('runUseWorkspace', () => {
runUseWorkspace(
{ workspaceId: 'ws-7' },
{
configDir,
bundle: b,
http: {} as KyInstance,
io,

View File

@ -13,6 +13,7 @@ export type UseWorkspaceOptions = {
}
export type UseWorkspaceDeps = {
readonly configDir: string
readonly bundle: HostsBundle
readonly http: KyInstance
readonly io: IOStreams
@ -69,7 +70,7 @@ export async function runUseWorkspace(
role: w.role,
})),
}
saveHosts(next)
await saveHosts(deps.configDir, next)
deps.io.out.write(`${cs.successIcon()} Switched to ${matched.name} (${matched.id})\n`)
return next
}

View File

@ -1,52 +1,48 @@
import type { YamlStore } from '../store/store'
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdir, mkdtemp, writeFile } 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 { ENV_CONFIG_DIR } from '../store/dir'
import { getConfigurationStore } from '../store/manager'
import { YamlStore } from '../store/store'
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 () => {
if (prevConfigDir === undefined)
delete process.env[ENV_CONFIG_DIR]
else
process.env[ENV_CONFIG_DIR] = prevConfigDir
await rm(dir, { recursive: true, force: true })
await mkdir(dir, { recursive: true }).catch(() => {})
})
it('returns found:false when config is missing', () => {
const r = loadConfig(getConfigurationStore())
it('returns found:false when config.yml is missing', () => {
const r = loadConfig(makeStore(dir))
expect(r.found).toBe(false)
})
it('parses a minimal valid config', () => {
getConfigurationStore().setTyped({ schema_version: 1 })
const r = loadConfig(getConfigurationStore())
it('parses a minimal valid config.yml', async () => {
await writeFile(join(dir, FILE_NAME), 'schema_version: 1\n', 'utf8')
const r = loadConfig(makeStore(dir))
expect(r.found).toBe(true)
if (r.found)
expect(r.config.schema_version).toBe(1)
})
it('parses defaults + state', () => {
getConfigurationStore().setTyped({
schema_version: 1,
defaults: { format: 'json', limit: 100 },
state: { current_app: 'app-1' },
})
const r = loadConfig(getConfigurationStore())
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))
expect(r.found).toBe(true)
if (r.found) {
expect(r.config.defaults.format).toBe('json')
@ -55,29 +51,11 @@ describe('loadConfig', () => {
}
})
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
it('throws BaseError(config_schema_unsupported) when YAML is malformed', async () => {
await writeFile(join(dir, FILE_NAME), '::not yaml::: {{[', 'utf8')
let caught: unknown
try {
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())
loadConfig(makeStore(dir))
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@ -85,11 +63,23 @@ describe('loadConfig', () => {
expect(caught.code).toBe(ErrorCode.ConfigSchemaUnsupported)
})
it('throws BaseError(config_schema_unsupported) when schema_version > 1 (forward-refuse)', () => {
getConfigurationStore().setTyped({ schema_version: 2 })
it('throws BaseError(config_schema_unsupported) when zod validation fails', async () => {
await writeFile(join(dir, FILE_NAME), 'defaults:\n limit: 9999\n', 'utf8')
let caught: unknown
try {
loadConfig(getConfigurationStore())
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))
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)

View File

@ -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('CONFIG_FILE_NAME is config.yml', () => {
expect(CONFIG_FILE_NAME).toBe('config.yml')
it('FILE_NAME is config.yml', () => {
expect(FILE_NAME).toBe('config.yml')
})
it('ALLOWED_FORMATS matches Go set (json/yaml/table/wide/name/text)', () => {

View File

@ -1,6 +1,7 @@
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]

View File

@ -8,8 +8,8 @@ import {
} from './codes.js'
describe('error codes', () => {
it('has 18 codes (parity with internal/api/errors)', () => {
expect(ALL_ERROR_CODES).toHaveLength(18)
it('has 17 codes (parity with internal/api/errors)', () => {
expect(ALL_ERROR_CODES).toHaveLength(17)
})
it('has the expected ExitCode buckets', () => {
@ -46,7 +46,6 @@ 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)

View File

@ -15,7 +15,6 @@ export const ErrorCode = {
NetworkDns: 'network_dns',
Server5xx: 'server_5xx',
Server4xxOther: 'server_4xx_other',
ClientError: 'client_error',
Unknown: 'unknown',
} as const
@ -48,7 +47,6 @@ const CODE_TO_EXIT: Readonly<Record<ErrorCodeValue, ExitCodeValue>> = {
network_dns: ExitCode.Generic,
server_5xx: ExitCode.Generic,
server_4xx_other: ExitCode.Generic,
client_error: ExitCode.Generic,
unknown: ExitCode.Generic,
}

View File

@ -1,57 +1,45 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdtemp, readdir, readFile, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it } from 'vitest'
import { loadConfig } from '../config/config-loader'
import { emptyConfig } from '../config/schema'
import { emptyConfig, FILE_NAME } from '../config/schema'
import { platform } from '../sys'
import { saveConfig } from './config-writer'
import { ENV_CONFIG_DIR } from './dir'
import { getConfigurationStore } from './manager'
import { YamlStore } from './store'
function makeStore(dir: string): YamlStore {
return new YamlStore(join(dir, FILE_NAME))
}
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
})
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('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)
})
it('stamps schema_version=1 even if caller passed 0', () => {
saveConfig(getConfigurationStore(), { ...emptyConfig() })
const r = loadConfig(getConfigurationStore())
saveConfig(makeStore(dir), { ...emptyConfig() })
const r = loadConfig(makeStore(dir))
expect(r.found).toBe(true)
if (r.found)
expect(r.config.schema_version).toBe(1)
})
it('overrides a stale schema_version on save', () => {
saveConfig(getConfigurationStore(), {
...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(), {
it('round-trips defaults + state through YAML', () => {
saveConfig(makeStore(dir), {
schema_version: 1,
defaults: { format: 'wide', limit: 75 },
state: { current_app: 'app-xyz' },
})
const r = loadConfig(getConfigurationStore())
const r = loadConfig(makeStore(dir))
expect(r.found).toBe(true)
if (r.found) {
expect(r.config.defaults.format).toBe('wide')
@ -60,22 +48,39 @@ describe('saveConfig', () => {
}
})
it('overwrites the previous config on resave', () => {
saveConfig(getConfigurationStore(), {
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), {
schema_version: 1,
defaults: { format: 'json' },
state: {},
})
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')
}
const raw = await readFile(join(dir, FILE_NAME), 'utf8')
expect(raw).toMatch(/^schema_version:/m)
expect(raw).toMatch(/format: json/)
})
})

View File

@ -1,64 +0,0 @@
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')
}

View File

@ -1,109 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const passwords = new Map<string, string>()
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/)
})
})

View File

@ -1,78 +0,0 @@
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<string, unknown>()
return {
_label: label,
get<T>(key: Key<T>): T {
return (map.get(key.key) as T | undefined) ?? key.default
},
set<T>(key: Key<T>, value: T): void {
map.set(key.key, value)
},
unset<T>(key: Key<T>): 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('')
})
})

View File

@ -1,77 +1,28 @@
import type { Key, StorageMode, Store } from './store'
import type { Store } from './store'
import { join } from 'node:path'
import { FILE_NAME } from '../config/schema'
import { resolveCacheDir, resolveConfigDir } from './dir'
import { KeyringBasedStore, YamlStore } from './store'
import { 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(join(resolveConfigDir(), CONFIG_FILE_NAME))
return getStore(resolveConfigurationPath())
}
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<string> = { 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 `<configDir>/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<string> {
return { key: `tokens.${host}.${accountId}`, default: '' }
}

View File

@ -1,10 +1,9 @@
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import { readFileSync, 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 { BadYamlFormatError, ConcurrentAccessError } from './errors'
import { YamlStore } from './store'
import { ConcurrentAccessError, YamlStore } from './store'
describe('YamlStore.doGet', () => {
it('returns default when content is undefined', () => {
@ -14,51 +13,33 @@ describe('YamlStore.doGet', () => {
it('reads a flat key', () => {
const store = new YamlStore('/irrelevant')
store.setRawContent('name: alice\n')
store.raw_content = '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.setRawContent('user:\n id: 42\n')
store.raw_content = '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.setRawContent('name: alice\n')
store.raw_content = '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.setRawContent('user:\n name: bob\n')
store.raw_content = '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.setRawContent('user: scalar\n')
store.raw_content = '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', () => {
@ -76,7 +57,7 @@ describe('YamlStore.doSet', () => {
it('overwrites an existing key without disturbing siblings', () => {
const store = new YamlStore('/irrelevant')
store.setRawContent('name: alice\nage: 30\n')
store.raw_content = '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)
@ -84,7 +65,7 @@ describe('YamlStore.doSet', () => {
it('replaces a scalar intermediate with an object when path deepens', () => {
const store = new YamlStore('/irrelevant')
store.setRawContent('user: scalar\n')
store.raw_content = 'user: scalar\n'
store.doSet({ key: 'user.id', default: 0 }, 99)
expect(store.doGet({ key: 'user.id', default: 0 })).toBe(99)
})
@ -151,12 +132,12 @@ describe('YamlStore persistence', () => {
await writeFile(path, '')
const s1 = new YamlStore(path)
s1.setRawContent('')
s1.raw_content = ''
s1.doSet({ key: 'workspace', default: '' }, 'ws-123')
writeFileSync(path, s1.getRawContent() ?? '')
writeFileSync(path, s1.raw_content ?? '')
const s2 = new YamlStore(path)
s2.setRawContent(readFileSync(path, 'utf8'))
s2.raw_content = readFileSync(path, 'utf8')
expect(s2.doGet({ key: 'workspace', default: '' })).toBe('ws-123')
})
@ -165,12 +146,12 @@ describe('YamlStore persistence', () => {
await writeFile(path, '')
const s1 = new YamlStore(path)
s1.setRawContent('')
s1.raw_content = ''
s1.doSet({ key: 'a.b.c', default: '' }, 'deep')
writeFileSync(path, s1.getRawContent() ?? '')
writeFileSync(path, s1.raw_content ?? '')
const s2 = new YamlStore(path)
s2.setRawContent(readFileSync(path, 'utf8'))
s2.raw_content = readFileSync(path, 'utf8')
expect(s2.doGet({ key: 'a.b.c', default: '' })).toBe('deep')
})
@ -179,17 +160,17 @@ describe('YamlStore persistence', () => {
await writeFile(path, '')
const s1 = new YamlStore(path)
s1.setRawContent('')
s1.raw_content = ''
s1.doSet({ key: 'x', default: '' }, 'first')
writeFileSync(path, s1.getRawContent() ?? '')
writeFileSync(path, s1.raw_content ?? '')
const s2 = new YamlStore(path)
s2.setRawContent(readFileSync(path, 'utf8'))
s2.raw_content = readFileSync(path, 'utf8')
s2.doSet({ key: 'y', default: '' }, 'second')
writeFileSync(path, s2.getRawContent() ?? '')
writeFileSync(path, s2.raw_content ?? '')
const s3 = new YamlStore(path)
s3.setRawContent(readFileSync(path, 'utf8'))
s3.raw_content = readFileSync(path, 'utf8')
expect(s3.doGet({ key: 'x', default: '' })).toBe('first')
expect(s3.doGet({ key: 'y', default: '' })).toBe('second')
})
@ -205,28 +186,8 @@ describe('YamlStore persistence', () => {
const raw = readFileSync(path, 'utf8')
const store2 = new YamlStore(path)
store2.setRawContent(raw)
store2.raw_content = 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)
})
})

View File

@ -1,16 +1,14 @@
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
export type Key<T> = {
type Key<T> = {
default: T
key: string
}
@ -18,43 +16,38 @@ export type Key<T> = {
export type Store = {
get: <T>(key: Key<T>) => T
set: <T>(key: Key<T>, value: T) => void
unset: <T>(key: Key<T>) => void
}
export type StorageMode = 'keychain' | 'file'
export class ConcurrentAccessError extends Error {
constructor(filePath: string) {
super(`Another process is modifying the file ${filePath}. remove ${filePath}.lock to reset lock.`)
}
}
abstract class FileBasedStore implements Store {
filePath: string
private rawContent: string | undefined
file_path: string
raw_content: string | undefined
private readonly platform: Platform
private dirty: boolean = false
constructor(filePath: string) {
this.filePath = filePath
constructor(file_path: string) {
this.file_path = file_path
this.platform = resolvePlatform()
fs.mkdirSync(dirname(this.file_path), { recursive: true, mode: DIR_PERM })
}
unlock(): void {
lockfile.unlockSync(`${this.filePath}.lock`)
lockfile.unlockSync(`${this.file_path}.lock`)
}
/**
* atomically write raw_content (if any)
*/
flush(): void {
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()}`
if (this.raw_content !== undefined) {
const tmp = `${this.file_path}.tmp.${pid()}.${Date.now()}`
try {
fs.writeFileSync(tmp, this.rawContent, { mode: FILE_PERM })
this.platform.atomicReplace(tmp, this.filePath)
fs.writeFileSync(tmp, this.raw_content, { mode: FILE_PERM })
this.platform.atomicReplace(tmp, this.file_path)
}
catch (err) {
try {
@ -64,20 +57,16 @@ abstract class FileBasedStore implements Store {
throw err
}
}
this.dirty = false
}
lock(): void {
try {
lockfile.lockSync(`${this.filePath}.lock`, {
stale: 30_000,
})
lockfile.lockSync(`${this.file_path}.lock`)
}
catch (err) {
const code = (err as NodeJS.ErrnoException).code
if (code === 'EEXIST') {
throw new ConcurrentAccessError(this.filePath)
throw new ConcurrentAccessError(this.file_path)
}
throw err
}
@ -85,8 +74,7 @@ abstract class FileBasedStore implements Store {
load(): void {
try {
this.rawContent = fs.readFileSync(this.filePath, 'utf8')
this.dirty = false
this.raw_content = fs.readFileSync(this.file_path, 'utf8')
}
catch (err) {
const code = (err as NodeJS.ErrnoException).code
@ -96,18 +84,10 @@ 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<R>(body: () => R): R {
this.lock()
try {
this.load()
return body()
}
finally {
@ -116,44 +96,18 @@ abstract class FileBasedStore implements Store {
}
get<T>(key: Key<T>): T {
return this.withLock(() => {
this.load()
return this.doGet(key)
})
return this.withLock(() => this.doGet(key))
}
set<T>(key: Key<T>, value: T) {
this.withLock(() => {
this.load()
this.doSet(key, value)
this.flush()
})
}
unset<T>(key: Key<T>): 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<T>(key: Key<T>): T
abstract doSet<T>(key: Key<T>, value: T): void
abstract doUnset<T>(key: Key<T>): void
}
export class YamlStore extends FileBasedStore {
@ -162,7 +116,7 @@ export class YamlStore extends FileBasedStore {
}
doGet<T>(key: Key<T>): T {
const data = loadYaml(this.getRawContent(), this.filePath)
const data = loadYaml(this.raw_content)
const parts = key.key.split('.')
let current: unknown = data
for (const part of parts) {
@ -176,20 +130,19 @@ export class YamlStore extends FileBasedStore {
getTyped<T>(): T | null {
return this.withLock(() => {
this.load()
return loadYaml(this.getRawContent(), this.filePath) as T
return loadYaml(this.raw_content) as T
})
}
setTyped<T>(data: T): void {
this.withLock(() => {
this.load()
this.setRawContent(yaml.dump(data, { lineWidth: -1, noRefs: true }))
this.raw_content = yaml.dump(data, { lineWidth: -1, noRefs: true })
this.flush()
})
}
doSet<T>(key: Key<T>, value: T): void {
const data = loadYaml(this.getRawContent(), this.filePath) || {}
const data = loadYaml(this.raw_content) || {}
const parts = key.key.split('.')
const lastKey = parts.pop()
if (lastKey === undefined)
@ -201,74 +154,12 @@ export class YamlStore extends FileBasedStore {
current = current[part] as Record<string, unknown>
}
current[lastKey] = value
this.setRawContent(yaml.dump(data, { lineWidth: -1, noRefs: true }))
}
doUnset<T>(key: Key<T>): void {
const data = loadYaml(this.getRawContent(), this.filePath) || {}
const parts = key.key.split('.')
const lastKey = parts.pop()
if (lastKey === undefined)
return
let current: Record<string, unknown> = data
for (const part of parts) {
const next = current[part]
if (next === null || next === undefined || typeof next !== 'object')
return
current = next as Record<string, unknown>
}
if (!(lastKey in current))
return
delete current[lastKey]
this.setRawContent(yaml.dump(data, { lineWidth: -1, noRefs: true }))
this.raw_content = yaml.dump(data, { lineWidth: -1, noRefs: true })
}
}
function loadYaml(raw: string | undefined, file_path: string): Record<string, unknown> | null {
function loadYaml(raw: string | undefined): Record<string, unknown> | null {
if (raw === undefined)
return null
try {
return (yaml.load(raw) ?? {}) as Record<string, unknown>
}
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<T>` 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<T>(key: Key<T>): 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<T>(key: Key<T>, value: T): void {
new Entry(this.service, key.key).setPassword(JSON.stringify(value))
}
unset<T>(key: Key<T>): void {
try {
new Entry(this.service, key.key).deletePassword()
}
catch { /* missing entry is fine */ }
}
return (yaml.load(raw) ?? {}) as Record<string, unknown>
}

View File

@ -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 { ENV_CACHE_DIR } from '../store/dir.js'
import { CACHE_NUDGE, getCache } from '../store/manager.js'
import { CACHE_NUDGE, cachePath } from '../store/manager.js'
import { YamlStore } from '../store/store.js'
import { maybeNudgeCompat } from './nudge.js'
const HOST = 'https://cloud.dify.ai'
@ -44,18 +44,11 @@ describe('maybeNudgeCompat', () => {
let dir: string
let store: NudgeStore
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
store = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: fixedNow })
store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, 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 })
})
@ -85,12 +78,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: getCache(CACHE_NUDGE), now: () => yesterday })
const tStore = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => yesterday })
await tStore.markWarned(HOST)
const probe = vi.fn(async () => UNSUPPORTED)
const { emit, lines } = emitterSpy()
const freshStore = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: fixedNow })
const freshStore = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: fixedNow })
await maybeNudgeCompat(HOST, baseDeps({ store: freshStore, probe, emit }))
expect(probe).toHaveBeenCalledOnce()

View File

@ -160,8 +160,7 @@ describe('runVersionProbe', () => {
const url = new URL(mock.url)
const prevConfig = process.env[ENV_CONFIG_DIR]
try {
process.env[ENV_CONFIG_DIR] = configDir
saveHosts({
await saveHosts(configDir, {
current_host: url.host,
scheme: url.protocol.replace(':', ''),
token_storage: 'file',

View File

@ -5,6 +5,7 @@ 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'
@ -47,7 +48,7 @@ export type RunVersionProbeOptions = {
readonly probe?: MetaProbe
}
const defaultLoadBundle = async (): Promise<HostsBundle | undefined> => loadHosts()
const defaultLoadBundle = async (): Promise<HostsBundle | undefined> => loadHosts(resolveConfigDir())
const defaultProbe: MetaProbe = async (endpoint) => {
const http = createClient({ host: endpoint, timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 })

View File

@ -222,10 +222,6 @@ QUEUE_MONITOR_INTERVAL=30
SWAGGER_UI_ENABLED=false
SWAGGER_UI_PATH=/swagger-ui.html
OPENAPI_ENABLED=false
OPENAPI_CORS_ALLOW_ORIGINS=
OPENAPI_KNOWN_CLIENT_IDS=difyctl
OPENAPI_RATE_LIMIT_PER_TOKEN=60
ENABLE_OAUTH_BEARER=false
DSL_EXPORT_ENCRYPT_DATASET_ID=true
DATASET_MAX_SEGMENTS_PER_REQUEST=0
ENABLE_CLEAN_EMBEDDING_CACHE_TASK=false

View File

@ -33,6 +33,7 @@ export default function DevicePage() {
const pathname = usePathname()
const urlUserCode = (searchParams.get('user_code') || '').trim().toUpperCase()
const ssoVerified = searchParams.get('sso_verified') === '1'
const ssoError = searchParams.get('sso_error') || ''
const [typed, setTyped] = useState('')
const [view, setView] = useState<View>({ kind: 'code_entry' })
@ -81,7 +82,11 @@ export default function DevicePage() {
return
}
let consumed = false
if (ssoVerified) {
if (ssoError) {
setErrMsg(ssoError) // eslint-disable-line react/set-state-in-effect
consumed = true
}
else if (ssoVerified) {
setView({ kind: 'authorize_sso' }) // eslint-disable-line react/set-state-in-effect
consumed = true
}
@ -92,9 +97,9 @@ export default function DevicePage() {
setView({ kind: 'chooser', userCode: urlUserCode }) // eslint-disable-line react/set-state-in-effect
consumed = true
}
if (consumed && (urlUserCode || ssoVerified))
if (consumed && (urlUserCode || ssoVerified || ssoError))
router.replace(pathname)
}, [urlUserCode, ssoVerified, account, view, router, pathname])
}, [urlUserCode, ssoVerified, ssoError, account, view, router, pathname])
const onContinue = async () => {
if (!isValidUserCode(typed))