refactor(cli): add kvstore and platform interface (#36687)

This commit is contained in:
Yunlu Wen
2026-05-27 13:30:12 +08:00
committed by GitHub
parent bee21c9f86
commit a8d380bcaf
95 changed files with 1035 additions and 579 deletions

View File

@@ -15,8 +15,12 @@ concurrency:
jobs:
test:
name: CLI Tests
runs-on: depot-ubuntu-24.04
name: CLI Tests (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [depot-ubuntu-24.04, windows-latest, macos-latest]
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
defaults:
@@ -37,7 +41,7 @@ jobs:
run: pnpm ci
- name: Report coverage
if: ${{ env.CODECOV_TOKEN != '' }}
if: ${{ env.CODECOV_TOKEN != '' && matrix.os == 'depot-ubuntu-24.04' }}
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with:
directory: cli/coverage

View File

@@ -47,7 +47,7 @@ Layer rules:
- Commands thin shells. Use `this.authedCtx(opts)` for bearer context; delegate to domain function.
- Domain receives deps via options; never imports `src/framework/`.
- Only `src/http/client.ts` and `src/api/*` import ky at runtime; elsewhere use `import type { KyInstance }`.
- `process.*` lives in `src/io/`, `src/config/dir.ts`, `src/util/browser.ts`. Nowhere else.
- `process.*` lives in `src/io/`, `src/store/dir.ts`, `src/util/browser.ts`. Nowhere else.
- No circular imports. `types/` pure leaf.
## Dev commands

View File

@@ -50,6 +50,7 @@
"eventsource-parser": "catalog:",
"js-yaml": "catalog:",
"ky": "catalog:",
"lockfile": "catalog:",
"open": "catalog:",
"ora": "catalog:",
"picocolors": "catalog:",
@@ -60,6 +61,7 @@
"@dify/tsconfig": "workspace:*",
"@hono/node-server": "catalog:",
"@types/js-yaml": "catalog:",
"@types/lockfile": "catalog:",
"@types/node": "catalog:",
"@vitest/coverage-v8": "catalog:",
"eslint": "catalog:",

View File

@@ -6,6 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { startMock } from '../../test/fixtures/dify-mock/server.js'
import { loadAppInfoCache } from '../cache/app-info.js'
import { createClient } from '../http/client.js'
import { CACHE_APP_INFO, cachePath } from '../store/manager.js'
import { YamlStore } from '../store/store.js'
import { FieldInfo, FieldParameters } from '../types/app-meta.js'
import { AppMetaClient } from './app-meta.js'
import { AppsClient } from './apps.js'
@@ -23,7 +25,7 @@ describe('AppMetaClient', () => {
})
it('cache miss → fetch → populate; warm hit skips network', async () => {
const cache = await loadAppInfoCache({ configDir: dir })
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 })
@@ -38,7 +40,7 @@ describe('AppMetaClient', () => {
})
it('slim hit + full request triggers fresh fetch + merges', async () => {
const cache = await loadAppInfoCache({ configDir: dir })
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 })
@@ -52,7 +54,7 @@ describe('AppMetaClient', () => {
})
it('expired cache entry refetches', async () => {
const cache = await loadAppInfoCache({ configDir: dir, 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') })
@@ -66,7 +68,7 @@ describe('AppMetaClient', () => {
})
it('invalidate forces next get to fetch', async () => {
const cache = await loadAppInfoCache({ configDir: dir })
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

@@ -2,7 +2,7 @@ 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 '../config/dir.js'
import { FILE_PERM } from '../store/dir.js'
import { FileBackend, TOKENS_FILE_NAME } from './file-backend.js'
describe('FileBackend', () => {

View File

@@ -2,7 +2,7 @@ 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 '../config/dir.js'
import { DIR_PERM, FILE_PERM } from '../store/dir.js'
export const TOKENS_FILE_NAME = 'tokens.yml'

View File

@@ -2,7 +2,7 @@ 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 { FILE_PERM } from '../config/dir.js'
import { FILE_PERM } from '../store/dir.js'
import { HOSTS_FILE_NAME, HostsBundleSchema, loadHosts, saveHosts } from './hosts.js'
describe('HostsBundleSchema', () => {

View File

@@ -2,7 +2,7 @@ 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 { DIR_PERM, FILE_PERM } from '../config/dir.js'
import { DIR_PERM, FILE_PERM } from '../store/dir.js'
export const HOSTS_FILE_NAME = 'hosts.yml'

View File

@@ -2,9 +2,17 @@ import type { AppMeta } from '../types/app-meta.js'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import yaml from 'js-yaml'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { CACHE_APP_INFO, cachePath } from '../store/manager.js'
import { YamlStore } from '../store/store.js'
import { platform } from '../sys/index.js'
import { FieldInfo, FieldParameters } from '../types/app-meta.js'
import { APP_INFO_TTL_MS, cachePath, loadAppInfoCache } from './app-info.js'
import { APP_INFO_TTL_MS, loadAppInfoCache } from './app-info.js'
function appInfoPath(dir: string): string {
return cachePath(dir, CACHE_APP_INFO)
}
function metaInfoOnly(): AppMeta {
return {
@@ -35,10 +43,10 @@ describe('app-info disk cache', () => {
})
it('round-trips an entry across reloads', async () => {
const c1 = await loadAppInfoCache({ configDir: dir })
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({ configDir: dir })
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')
@@ -47,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({ configDir: dir, 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()
@@ -58,45 +66,44 @@ describe('app-info disk cache', () => {
})
it('keys by (host, app_id) — different hosts isolate', async () => {
const c = await loadAppInfoCache({ configDir: dir })
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({ configDir: dir })
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({ configDir: dir })
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({ configDir: dir })
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(cachePath(dir))
if (process.platform !== 'win32')
const s = await stat(appInfoPath(dir))
if (platform() !== 'win32')
expect(s.mode & 0o777).toBe(0o600)
})
it('missing cache file is not an error', async () => {
const c = await loadAppInfoCache({ configDir: dir })
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 { mkdir, writeFile } = await import('node:fs/promises')
await mkdir(join(dir, 'cache'), { recursive: true })
await writeFile(cachePath(dir), '{not json', 'utf8')
const c = await loadAppInfoCache({ configDir: dir })
const { writeFile } = await import('node:fs/promises')
await writeFile(appInfoPath(dir), ': : not valid yaml', 'utf8')
const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
expect(c.get('h', 'app-1')).toBeUndefined()
})
it('updates same key in place (no growth)', async () => {
const c = await loadAppInfoCache({ configDir: dir })
const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await c.set('h', 'app-1', metaInfoOnly())
const slim: AppMeta = {
...metaInfoOnly(),
@@ -104,8 +111,8 @@ describe('app-info disk cache', () => {
parameters: { opening_statement: 'hi' },
}
await c.set('h', 'app-1', slim)
const raw = await readFile(cachePath(dir), 'utf8')
const parsed = JSON.parse(raw) as { entries: Record<string, unknown> }
const raw = await readFile(appInfoPath(dir), 'utf8')
const parsed = yaml.load(raw) as { entries: Record<string, unknown> }
expect(Object.keys(parsed.entries)).toHaveLength(1)
})
})

View File

@@ -1,15 +1,14 @@
import type { Store } from '../store/store.js'
import type { AppMeta, AppMetaCacheRecord, AppMetaFieldKey } from '../types/app-meta.js'
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { DIR_PERM, FILE_PERM } from '../config/dir.js'
import { CACHE_APP_INFO, getCache } from '../store/manager.js'
import { FieldInfo, FieldInputSchema, FieldParameters } from '../types/app-meta.js'
const CACHE_FILE = 'app-info.json'
export const APP_INFO_TTL_MS = 60 * 60 * 1000
type DiskShape = {
entries: Record<string, DiskEntry>
}
// All entries live under one top-level key; the inner record uses
// `host::appId` composites that contain `::` (never `.`), so they're
// safe as map keys without colliding with Store's dot-path semantics.
const ENTRIES_KEY = { key: 'entries', default: {} as Record<string, DiskEntry> } as const
type DiskEntry = {
meta: SerializedMeta
@@ -35,26 +34,25 @@ type State = {
}
export type AppInfoCacheOptions = {
readonly configDir: string
readonly store?: Store
readonly ttlMs?: number
readonly now?: () => Date
}
export async function loadAppInfoCache(opts: AppInfoCacheOptions): Promise<AppInfoCache> {
const path = cachePath(opts.configDir)
export async function loadAppInfoCache(opts: AppInfoCacheOptions = {}): Promise<AppInfoCache> {
const store = opts.store ?? getCache(CACHE_APP_INFO)
const ttlMs = opts.ttlMs ?? APP_INFO_TTL_MS
const state: State = { entries: new Map() }
await readDisk(path, state)
const state: State = { entries: readEntries(store) }
return {
get: (host, appId) => state.entries.get(key(host, appId)),
set: async (host, appId, meta) => {
const record: AppMetaCacheRecord = { meta, fetchedAt: (opts.now ?? (() => new Date()))().toISOString() }
state.entries.set(key(host, appId), record)
await persist(path, state)
writeEntries(store, state.entries)
},
delete: async (host, appId) => {
state.entries.delete(key(host, appId))
await persist(path, state)
writeEntries(store, state.entries)
},
isFresh: (record, now) => {
const t = (now ?? new Date()).getTime() - new Date(record.fetchedAt).getTime()
@@ -63,36 +61,22 @@ export async function loadAppInfoCache(opts: AppInfoCacheOptions): Promise<AppIn
}
}
export function cachePath(configDir: string): string {
return join(configDir, 'cache', CACHE_FILE)
}
function key(host: string, appId: string): string {
return `${host}::${appId}`
}
async function readDisk(path: string, state: State): Promise<void> {
let raw: string
function readEntries(store: Store): Map<string, AppMetaCacheRecord> {
const out = new Map<string, AppMetaCacheRecord>()
let raw: Record<string, DiskEntry>
try {
raw = await readFile(path, 'utf8')
}
catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT')
return
throw err
}
let parsed: DiskShape
try {
parsed = JSON.parse(raw) as DiskShape
raw = store.get(ENTRIES_KEY)
}
catch {
return
}
if (parsed.entries === undefined)
return
for (const [k, e] of Object.entries(parsed.entries)) {
state.entries.set(k, deserialize(e))
return out
}
for (const [k, e] of Object.entries(raw))
out.set(k, deserialize(e))
return out
}
function deserialize(e: DiskEntry): AppMetaCacheRecord {
@@ -127,12 +111,8 @@ function serialize(record: AppMetaCacheRecord): DiskEntry {
}
}
async function persist(path: string, state: State): Promise<void> {
const dir = dirname(path)
await mkdir(dir, { recursive: true, mode: DIR_PERM })
const disk: DiskShape = { entries: {} }
for (const [k, v] of state.entries) disk.entries[k] = serialize(v)
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
await writeFile(tmp, JSON.stringify(disk), { mode: FILE_PERM })
await rename(tmp, path)
function writeEntries(store: Store, entries: Map<string, AppMetaCacheRecord>): void {
const out: Record<string, DiskEntry> = {}
for (const [k, v] of entries) out[k] = serialize(v)
store.set(ENTRIES_KEY, out)
}

View File

@@ -1,8 +1,15 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
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 { loadNudgeStore, nudgeStorePath, WARN_INTERVAL_MS } from './nudge-store.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 {
return cachePath(dir, CACHE_NUDGE)
}
const HOST = 'https://cloud.dify.ai'
@@ -16,13 +23,13 @@ describe('NudgeStore', () => {
})
it('canWarn=true when no prior record exists', async () => {
const store = await loadNudgeStore({ configDir: dir })
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({ configDir: dir, 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)
@@ -30,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({ configDir: dir, 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)
@@ -38,33 +45,25 @@ describe('NudgeStore', () => {
it('markWarned persists across store reloads', async () => {
const t0 = new Date('2026-05-19T12:00:00.000Z')
const s1 = await loadNudgeStore({ configDir: dir, now: () => t0 })
const s1 = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t0 })
await s1.markWarned(HOST)
const s2 = await loadNudgeStore({ configDir: dir, 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({ configDir: dir })
const store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)) })
expect(store.canWarn(HOST)).toBe(true)
})
it('ignores file with mismatched schema', async () => {
const path = nudgeStorePath(dir)
await writeCacheFile(path, JSON.stringify({ schema: 99, warned: { [HOST]: '2026-05-19T12:00:00.000Z' } }))
const store = await loadNudgeStore({ configDir: dir })
expect(store.canWarn(HOST)).toBe(true)
})
it('writes ISO timestamps under schema:1/warned on disk', async () => {
it('writes ISO timestamps under warned/<host> on disk', async () => {
const t = new Date('2026-05-19T12:00:00.000Z')
const store = await loadNudgeStore({ configDir: dir, 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 = JSON.parse(raw) as Record<string, unknown>
expect(parsed.schema).toBe(1)
const parsed = yaml.load(raw) as Record<string, unknown>
expect((parsed.warned as Record<string, string>)[HOST]).toBe(t.toISOString())
})
@@ -73,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({ configDir: dir, now: () => t })
const b = await loadNudgeStore({ configDir: dir, 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({ configDir: dir, 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

@@ -1,37 +1,29 @@
import { randomUUID } from 'node:crypto'
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { DIR_PERM, FILE_PERM } from '../config/dir.js'
import type { Store } from '../store/store.js'
import { CACHE_NUDGE, getCache } from '../store/manager.js'
const CACHE_FILE = 'nudge.json'
const DISK_SCHEMA = 1
export const WARN_INTERVAL_MS = 24 * 60 * 60 * 1000
// Single top-level key holding host→ISO map. Hosts contain dots
// (cloud.dify.ai), so we cannot use them as Store paths directly —
// `doSet` would split on dots and create nested objects.
const WARNED_KEY = { key: 'warned', default: {} as Record<string, string> } as const
export type NudgeStore = {
readonly canWarn: (host: string, now?: Date) => boolean
readonly markWarned: (host: string, now?: Date) => Promise<void>
}
export type NudgeStoreOptions = {
readonly configDir: string
readonly store?: Store
readonly now?: () => Date
readonly intervalMs?: number
}
type DiskShape = {
schema?: number
warned?: Record<string, string>
}
export function nudgeStorePath(configDir: string): string {
return join(configDir, 'cache', CACHE_FILE)
}
export async function loadNudgeStore(opts: NudgeStoreOptions): Promise<NudgeStore> {
const path = nudgeStorePath(opts.configDir)
export async function loadNudgeStore(opts: NudgeStoreOptions = {}): Promise<NudgeStore> {
const store = opts.store ?? getCache(CACHE_NUDGE)
const intervalMs = opts.intervalMs ?? WARN_INTERVAL_MS
const clock = opts.now ?? (() => new Date())
const memory = await readDisk(path)
const memory = readWarned(store)
return {
canWarn: (host, now) => {
@@ -47,34 +39,23 @@ export async function loadNudgeStore(opts: NudgeStoreOptions): Promise<NudgeStor
// Re-read disk inside the write cycle so concurrent processes touching
// different hosts don't clobber each other's stamps. Same-host writers
// converge on a near-identical timestamp, so order doesn't matter.
const onDisk = await readDisk(path)
const onDisk = readWarned(store)
onDisk.set(host, stamp)
await persist(path, onDisk)
writeWarned(store, onDisk)
},
}
}
async function readDisk(path: string): Promise<Map<string, number>> {
function readWarned(store: Store): Map<string, number> {
const out = new Map<string, number>()
let raw: string
let raw: Record<string, string>
try {
raw = await readFile(path, 'utf8')
}
catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT')
return out
throw err
}
let parsed: DiskShape
try {
parsed = JSON.parse(raw) as DiskShape
raw = store.get(WARNED_KEY)
}
catch {
return out
}
if (parsed.schema !== DISK_SCHEMA || parsed.warned === undefined)
return out
for (const [host, iso] of Object.entries(parsed.warned)) {
for (const [host, iso] of Object.entries(raw)) {
const t = Date.parse(iso)
if (!Number.isNaN(t))
out.set(host, t)
@@ -82,15 +63,9 @@ async function readDisk(path: string): Promise<Map<string, number>> {
return out
}
async function persist(path: string, state: Map<string, number>): Promise<void> {
const dir = dirname(path)
await mkdir(dir, { recursive: true, mode: DIR_PERM })
const disk: DiskShape = { schema: DISK_SCHEMA, warned: {} }
function writeWarned(store: Store, state: Map<string, number>): void {
const warned: Record<string, string> = {}
for (const [host, t] of state)
disk.warned![host] = new Date(t).toISOString()
// randomUUID is collision-proof even when two writers stamp the same
// millisecond — pid+timestamp alone can still collide under tight loops.
const tmp = `${path}.${randomUUID()}.tmp`
await writeFile(tmp, JSON.stringify(disk), { mode: FILE_PERM })
await rename(tmp, path)
warned[host] = new Date(t).toISOString()
store.set(WARNED_KEY, warned)
}

View File

@@ -2,17 +2,18 @@ import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../auth/hosts.js'
import type { AppInfoCache } from '../../cache/app-info.js'
import type { Command } from '../../framework/command.js'
import type { IOStreams } from '../../io/streams.js'
import type { IOStreams } from '../../sys/io/streams'
import { META_PROBE_TIMEOUT_MS, MetaClient } from '../../api/meta.js'
import { loadHosts } from '../../auth/hosts.js'
import { loadAppInfoCache } from '../../cache/app-info.js'
import { loadNudgeStore } from '../../cache/nudge-store.js'
import { resolveConfigDir } from '../../config/dir.js'
import { getEnv } from '../../env/registry.js'
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 { realStreams } from '../../io/streams.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'
import { maybeNudgeCompat } from '../../version/nudge.js'
@@ -38,6 +39,7 @@ export async function buildAuthedContext(
opts: AuthedContextOptions,
): Promise<AuthedContext> {
const configDir = resolveConfigDir()
const io = realStreams(opts.format ?? '')
const bundle = await loadHosts(configDir)
if (bundle === undefined || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') {
const err = new BaseError({
@@ -45,20 +47,19 @@ export async function buildAuthedContext(
message: 'not logged in',
hint: 'run \'difyctl auth login\'',
})
cmd.error(formatErrorForCli(err, { format: opts.format, isErrTTY: process.stderr.isTTY }), { exit: err.exit() })
cmd.error(formatErrorForCli(err, { format: opts.format, isErrTTY: io.isErrTTY }), { exit: err.exit() })
}
const host = hostWithScheme(bundle.current_host, bundle.scheme)
const retryAttempts = resolveRetryAttempts({
flag: opts.retryFlag,
env: (k: string) => process.env[k],
env: getEnv,
})
const http = createClient({ host, bearer: bundle.tokens.bearer, retryAttempts })
const io = realStreams(opts.format ?? '')
const cache = opts.withCache === true ? await loadAppInfoCache({ configDir }) : undefined
const cache = opts.withCache === true ? await loadAppInfoCache() : undefined
await runCompatNudge({ configDir, host, io })
await runCompatNudge({ host, io })
return { bundle, http, host, io, configDir, cache }
}
@@ -66,12 +67,11 @@ export async function buildAuthedContext(
// Best-effort nudge: never throws, never blocks. Lives here so every authed
// command flows through it without per-command wiring.
async function runCompatNudge(opts: {
readonly configDir: string
readonly host: string
readonly io: IOStreams
}): Promise<void> {
try {
const store = await loadNudgeStore({ configDir: opts.configDir })
const store = await loadNudgeStore()
await maybeNudgeCompat(opts.host, {
store,
probe: async (host) => {

View File

@@ -10,7 +10,7 @@ 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 { bufferStreams } from '../../../../io/streams.js'
import { bufferStreams } from '../../../../sys/io/streams'
import { listAllSessions, runDevicesList, runDevicesRevoke } from './devices.js'
class MemStore implements TokenStore {

View File

@@ -2,16 +2,16 @@ import type { SessionRow } from '@dify/contracts/api/openapi/types.gen'
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../../auth/hosts.js'
import type { TokenStore } from '../../../../auth/store.js'
import type { IOStreams } from '../../../../io/streams.js'
import type { IOStreams } from '../../../../sys/io/streams'
import { unlink } from 'node:fs/promises'
import { join } from 'node:path'
import { AccountSessionsClient } from '../../../../api/account-sessions.js'
import { HOSTS_FILE_NAME } from '../../../../auth/hosts.js'
import { BaseError } from '../../../../errors/base.js'
import { ErrorCode } from '../../../../errors/codes.js'
import { colorEnabled, colorScheme } from '../../../../io/color.js'
import { runWithSpinner } from '../../../../io/spinner.js'
import { LIMIT_DEFAULT, LIMIT_MAX, parseLimit } from '../../../../limit/limit.js'
import { colorEnabled, colorScheme } from '../../../../sys/io/color.js'
import { runWithSpinner } from '../../../../sys/io/spinner.js'
export type DevicesListOptions = {
readonly io: IOStreams

View File

@@ -1,6 +1,6 @@
import { resolveConfigDir } from '../../../config/dir.js'
import { Flags } from '../../../framework/flags.js'
import { realStreams } from '../../../io/streams.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'

View File

@@ -8,7 +8,7 @@ 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 { bufferStreams } from '../../../io/streams.js'
import { bufferStreams } from '../../../sys/io/streams'
import { runLogin } from './login.js'
const noopClock: Clock = {

View File

@@ -1,7 +1,7 @@
import type { CodeResponse, PollSuccess } from '../../../api/oauth-device.js'
import type { HostsBundle, StorageMode, Workspace } from '../../../auth/hosts.js'
import type { TokenStore } from '../../../auth/store.js'
import type { IOStreams } from '../../../io/streams.js'
import type { IOStreams } from '../../../sys/io/streams'
import type { BrowserEnv, BrowserOpener } from '../../../util/browser.js'
import type { Clock } from './device-flow.js'
import * as os from 'node:os'
@@ -10,7 +10,7 @@ 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 { colorEnabled, colorScheme } from '../../../io/color.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'

View File

@@ -1,10 +1,10 @@
import type { KyInstance } from 'ky'
import { loadHosts } from '../../../auth/hosts.js'
import { selectStore } from '../../../auth/store.js'
import { resolveConfigDir } from '../../../config/dir.js'
import { createClient } from '../../../http/client.js'
import { runWithSpinner } from '../../../io/spinner.js'
import { realStreams } from '../../../io/streams.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'
import { DifyCommand } from '../../_shared/dify-command.js'
import { runLogout } from './logout.js'

View File

@@ -8,7 +8,7 @@ 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 { bufferStreams } from '../../../io/streams.js'
import { bufferStreams } from '../../../sys/io/streams'
import { runLogout } from './logout.js'
class MemStore implements TokenStore {

View File

@@ -1,14 +1,14 @@
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { TokenStore } from '../../../auth/store.js'
import type { IOStreams } from '../../../io/streams.js'
import type { IOStreams } from '../../../sys/io/streams'
import { unlink } from 'node:fs/promises'
import { join } from 'node:path'
import { AccountSessionsClient } from '../../../api/account-sessions.js'
import { HOSTS_FILE_NAME } from '../../../auth/hosts.js'
import { BaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
import { colorEnabled, colorScheme } from '../../../io/color.js'
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
export type LogoutOptions = {
readonly configDir: string

View File

@@ -1,7 +1,7 @@
import { loadHosts } from '../../../auth/hosts.js'
import { resolveConfigDir } from '../../../config/dir.js'
import { Flags } from '../../../framework/flags.js'
import { realStreams } from '../../../io/streams.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'

View File

@@ -1,6 +1,6 @@
import type { HostsBundle } from '../../../auth/hosts.js'
import { describe, expect, it } from 'vitest'
import { bufferStreams } from '../../../io/streams.js'
import { bufferStreams } from '../../../sys/io/streams'
import { runStatus } from './status.js'
function accountBundle(): HostsBundle {

View File

@@ -1,5 +1,5 @@
import type { HostsBundle } from '../../../auth/hosts.js'
import type { IOStreams } from '../../../io/streams.js'
import type { IOStreams } from '../../../sys/io/streams'
import { BaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'

View File

@@ -1,7 +1,7 @@
import { loadHosts } from '../../../auth/hosts.js'
import { resolveConfigDir } from '../../../config/dir.js'
import { Flags } from '../../../framework/flags.js'
import { realStreams } from '../../../io/streams.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'

View File

@@ -1,6 +1,6 @@
import type { HostsBundle } from '../../../auth/hosts.js'
import { describe, expect, it } from 'vitest'
import { bufferStreams } from '../../../io/streams.js'
import { bufferStreams } from '../../../sys/io/streams'
import { runWhoami } from './whoami.js'
function accountBundle(): HostsBundle {

View File

@@ -1,5 +1,5 @@
import type { HostsBundle } from '../../../auth/hosts.js'
import type { IOStreams } from '../../../io/streams.js'
import type { IOStreams } from '../../../sys/io/streams'
import { BaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'

View File

@@ -1,6 +1,6 @@
import { resolveConfigDir } from '../../../config/dir.js'
import { Args } from '../../../framework/flags.js'
import { raw } from '../../../framework/output.js'
import { getConfigurationStore } from '../../../store/manager.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { runConfigGet } from './run.js'
@@ -17,6 +17,6 @@ export default class ConfigGet extends DifyCommand {
async run(argv: string[]) {
const { args } = this.parse(ConfigGet, argv)
return raw(await runConfigGet({ dir: resolveConfigDir(), key: args.key }))
return raw(runConfigGet({ store: getConfigurationStore(), key: args.key }))
}
}

View File

@@ -5,8 +5,13 @@ 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 { 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
@@ -20,19 +25,19 @@ describe('runConfigGet', () => {
'schema_version: 1\ndefaults:\n format: yaml\n',
'utf8',
)
const out = await runConfigGet({ dir, key: 'defaults.format' })
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)', async () => {
const out = await runConfigGet({ dir, key: 'defaults.format' })
it('returns empty line when key is unset (matches Go fmt.Fprintln)', () => {
const out = runConfigGet({ store: makeStore(dir), key: 'defaults.format' })
expect(out).toBe('\n')
})
it('throws BaseError(config_invalid_key) on unknown key', async () => {
it('throws BaseError(config_invalid_key) on unknown key', () => {
let caught: unknown
try {
await runConfigGet({ dir, key: 'bogus.key' })
runConfigGet({ store: makeStore(dir), key: 'bogus.key' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@@ -46,7 +51,7 @@ describe('runConfigGet', () => {
'schema_version: 1\ndefaults:\n limit: 75\n',
'utf8',
)
const out = await runConfigGet({ dir, key: 'defaults.limit' })
const out = runConfigGet({ store: makeStore(dir), key: 'defaults.limit' })
expect(out).toBe('75\n')
})
})

View File

@@ -1,15 +1,16 @@
import type { ConfigFile } from '../../../config/schema.js'
import type { YamlStore } from '../../../store/store.js'
import { loadConfig } from '../../../config/config-loader.js'
import { getKey } from '../../../config/keys.js'
import { loadConfig } from '../../../config/loader.js'
import { emptyConfig } from '../../../config/schema.js'
export type RunConfigGetOptions = {
readonly key: string
readonly dir: string
readonly store: YamlStore
}
export async function runConfigGet(opts: RunConfigGetOptions): Promise<string> {
const loaded = await loadConfig(opts.dir)
export function runConfigGet(opts: RunConfigGetOptions): string {
const loaded = loadConfig(opts.store)
const config: ConfigFile = loaded.found ? loaded.config : emptyConfig()
return `${getKey(config, opts.key)}\n`
}

View File

@@ -1,5 +1,5 @@
import { resolveConfigDir } from '../../../config/dir.js'
import { raw } from '../../../framework/output.js'
import { resolveConfigDir } from '../../../store/dir.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { runConfigPath } from './run.js'

View File

@@ -1,6 +1,6 @@
import { resolveConfigDir } from '../../../config/dir.js'
import { Args } from '../../../framework/flags.js'
import { raw } from '../../../framework/output.js'
import { getConfigurationStore } from '../../../store/manager.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { runConfigSet } from './run.js'
@@ -19,6 +19,6 @@ export default class ConfigSet extends DifyCommand {
async run(argv: string[]) {
const { args } = this.parse(ConfigSet, argv)
return raw(await runConfigSet({ dir: resolveConfigDir(), key: args.key, value: args.value }))
return raw(runConfigSet({ store: getConfigurationStore(), key: args.key, value: args.value }))
}
}

View File

@@ -5,8 +5,13 @@ 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 { 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
@@ -15,7 +20,7 @@ describe('runConfigSet', () => {
})
it('writes config.yml and returns "set k = v\\n"', async () => {
const out = await runConfigSet({ dir, key: 'defaults.format', value: 'json' })
const out = runConfigSet({ store: makeStore(dir), key: 'defaults.format', value: 'json' })
expect(out).toBe('set defaults.format = json\n')
const raw = await readFile(join(dir, FILE_NAME), 'utf8')
expect(raw).toContain('format: json')
@@ -24,7 +29,7 @@ describe('runConfigSet', () => {
it('rejects invalid format value with config_invalid_value', async () => {
let caught: unknown
try {
await runConfigSet({ dir, key: 'defaults.format', value: 'csv' })
runConfigSet({ store: makeStore(dir), key: 'defaults.format', value: 'csv' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@@ -32,10 +37,10 @@ describe('runConfigSet', () => {
expect(caught.code).toBe(ErrorCode.ConfigInvalidValue)
})
it('rejects unknown key with config_invalid_key', async () => {
it('rejects unknown key with config_invalid_key', () => {
let caught: unknown
try {
await runConfigSet({ dir, key: 'bogus', value: 'x' })
runConfigSet({ store: makeStore(dir), key: 'bogus', value: 'x' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@@ -44,17 +49,17 @@ describe('runConfigSet', () => {
})
it('preserves prior keys when setting a new one', async () => {
await runConfigSet({ dir, key: 'defaults.format', value: 'yaml' })
await runConfigSet({ dir, key: 'defaults.limit', value: '40' })
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)', async () => {
it('exit code for invalid value is Usage (2)', () => {
let caught: unknown
try {
await runConfigSet({ dir, key: 'defaults.format', value: 'csv' })
runConfigSet({ store: makeStore(dir), key: 'defaults.format', value: 'csv' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@@ -62,10 +67,10 @@ describe('runConfigSet', () => {
expect(caught.exit()).toBe(ExitCode.Usage)
})
it('exit code for unknown key is Usage (2)', async () => {
it('exit code for unknown key is Usage (2)', () => {
let caught: unknown
try {
await runConfigSet({ dir, key: 'bogus', value: 'x' })
runConfigSet({ store: makeStore(dir), key: 'bogus', value: 'x' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@@ -73,10 +78,10 @@ describe('runConfigSet', () => {
expect(caught.exit()).toBe(ExitCode.Usage)
})
it('typed wrap chain: invalid defaults.limit surfaces ConfigInvalidValue (not UsageInvalidFlag)', async () => {
it('typed wrap chain: invalid defaults.limit surfaces ConfigInvalidValue (not UsageInvalidFlag)', () => {
let caught: unknown
try {
await runConfigSet({ dir, 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,19 +1,20 @@
import type { ConfigFile } from '../../../config/schema.js'
import type { YamlStore } from '../../../store/store.js'
import { loadConfig } from '../../../config/config-loader.js'
import { setKey } from '../../../config/keys.js'
import { loadConfig } from '../../../config/loader.js'
import { emptyConfig } from '../../../config/schema.js'
import { saveConfig } from '../../../config/writer.js'
import { saveConfig } from '../../../store/config-writer.js'
export type RunConfigSetOptions = {
readonly key: string
readonly value: string
readonly dir: string
readonly store: YamlStore
}
export async function runConfigSet(opts: RunConfigSetOptions): Promise<string> {
const loaded = await loadConfig(opts.dir)
export function runConfigSet(opts: RunConfigSetOptions): string {
const loaded = loadConfig(opts.store)
const config: ConfigFile = loaded.found ? loaded.config : emptyConfig()
const next = setKey(config, opts.key, opts.value)
await saveConfig(opts.dir, next)
saveConfig(opts.store, next)
return `set ${opts.key} = ${opts.value}\n`
}

View File

@@ -1,6 +1,6 @@
import { resolveConfigDir } from '../../../config/dir.js'
import { Args } from '../../../framework/flags.js'
import { raw } from '../../../framework/output.js'
import { getConfigurationStore } from '../../../store/manager.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { runConfigUnset } from './run.js'
@@ -17,6 +17,6 @@ export default class ConfigUnset extends DifyCommand {
async run(argv: string[]) {
const { args } = this.parse(ConfigUnset, argv)
return raw(await runConfigUnset({ dir: resolveConfigDir(), key: args.key }))
return raw(runConfigUnset({ store: getConfigurationStore(), key: args.key }))
}
}

View File

@@ -5,8 +5,13 @@ 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 { 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
@@ -20,7 +25,7 @@ describe('runConfigUnset', () => {
'schema_version: 1\ndefaults:\n format: json\n limit: 25\n',
'utf8',
)
const out = await runConfigUnset({ dir, key: 'defaults.format' })
const out = runConfigUnset({ store: makeStore(dir), key: 'defaults.format' })
expect(out).toBe('unset defaults.format\n')
const raw = await readFile(join(dir, FILE_NAME), 'utf8')
expect(raw).not.toContain('format:')
@@ -28,16 +33,16 @@ describe('runConfigUnset', () => {
})
it('is a no-op (writes empty config) when key was already unset', async () => {
const out = await runConfigUnset({ dir, key: 'defaults.format' })
const out = runConfigUnset({ store: makeStore(dir), key: 'defaults.format' })
expect(out).toBe('unset defaults.format\n')
const raw = await readFile(join(dir, FILE_NAME), 'utf8')
expect(raw).toContain('schema_version: 1')
})
it('rejects unknown key', async () => {
it('rejects unknown key', () => {
let caught: unknown
try {
await runConfigUnset({ dir, key: 'bogus' })
runConfigUnset({ store: makeStore(dir), key: 'bogus' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)

View File

@@ -1,18 +1,19 @@
import type { ConfigFile } from '../../../config/schema.js'
import type { YamlStore } from '../../../store/store.js'
import { loadConfig } from '../../../config/config-loader.js'
import { unsetKey } from '../../../config/keys.js'
import { loadConfig } from '../../../config/loader.js'
import { emptyConfig } from '../../../config/schema.js'
import { saveConfig } from '../../../config/writer.js'
import { saveConfig } from '../../../store/config-writer.js'
export type RunConfigUnsetOptions = {
readonly key: string
readonly dir: string
readonly store: YamlStore
}
export async function runConfigUnset(opts: RunConfigUnsetOptions): Promise<string> {
const loaded = await loadConfig(opts.dir)
export function runConfigUnset(opts: RunConfigUnsetOptions): string {
const loaded = loadConfig(opts.store)
const config: ConfigFile = loaded.found ? loaded.config : emptyConfig()
const next = unsetKey(config, opts.key)
await saveConfig(opts.dir, next)
saveConfig(opts.store, next)
return `unset ${opts.key}\n`
}

View File

@@ -1,6 +1,6 @@
import { resolveConfigDir } from '../../../config/dir.js'
import { Flags } from '../../../framework/flags.js'
import { raw } from '../../../framework/output.js'
import { getConfigurationStore } from '../../../store/manager.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { runConfigView } from './run.js'
@@ -18,6 +18,6 @@ export default class ConfigView extends DifyCommand {
async run(argv: string[]) {
const { flags } = this.parse(ConfigView, argv)
return raw(await runConfigView({ dir: resolveConfigDir(), json: flags.json }))
return raw(runConfigView({ store: getConfigurationStore(), json: flags.json }))
}
}

View File

@@ -3,8 +3,13 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { FILE_NAME } from '../../../config/schema.js'
import { YamlStore } from '../../../store/store.js'
import { runConfigView } from './run.js'
function makeStore(dir: string): YamlStore {
return new YamlStore(join(dir, FILE_NAME))
}
describe('runConfigView', () => {
let dir: string
@@ -16,8 +21,8 @@ describe('runConfigView', () => {
// tmpdir cleanup is best-effort
})
it('text format: empty config returns empty string', async () => {
const out = await runConfigView({ dir })
it('text format: empty config returns empty string', () => {
const out = runConfigView({ store: makeStore(dir) })
expect(out).toBe('')
})
@@ -27,7 +32,7 @@ describe('runConfigView', () => {
'schema_version: 1\ndefaults:\n format: json\n limit: 50\nstate:\n current_app: app-1\n',
'utf8',
)
const out = await runConfigView({ dir })
const out = runConfigView({ store: makeStore(dir) })
expect(out).toBe(
'defaults.format = json\ndefaults.limit = 50\nstate.current_app = app-1\n',
)
@@ -39,14 +44,14 @@ describe('runConfigView', () => {
'schema_version: 1\ndefaults:\n format: yaml\n',
'utf8',
)
const out = await runConfigView({ dir })
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"', async () => {
const out = await runConfigView({ dir, json: true })
it('json format: empty config returns "{}\\n"', () => {
const out = runConfigView({ store: makeStore(dir), json: true })
expect(out).toBe('{}\n')
})
@@ -56,15 +61,15 @@ describe('runConfigView', () => {
'schema_version: 1\ndefaults:\n format: table\n limit: 100\nstate:\n current_app: app-x\n',
'utf8',
)
const out = await runConfigView({ dir, json: true })
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)
expect(parsed['state.current_app']).toBe('app-x')
})
it('json format: trailing newline matches Go encoder.Encode', async () => {
const out = await runConfigView({ dir, json: true })
it('json format: trailing newline matches Go encoder.Encode', () => {
const out = runConfigView({ store: makeStore(dir), json: true })
expect(out.endsWith('\n')).toBe(true)
})
})

View File

@@ -1,17 +1,18 @@
import type { ConfigFile } from '../../../config/schema.js'
import type { YamlStore } from '../../../store/store.js'
import { loadConfig } from '../../../config/config-loader.js'
import { knownKeyNames, lookupKey } from '../../../config/keys.js'
import { loadConfig } from '../../../config/loader.js'
import { emptyConfig } from '../../../config/schema.js'
export type RunConfigViewOptions = {
readonly json?: boolean
readonly dir: string
readonly store: YamlStore
}
type ViewOut = Record<string, number | string>
export async function runConfigView(opts: RunConfigViewOptions): Promise<string> {
const loaded = await loadConfig(opts.dir)
export function runConfigView(opts: RunConfigViewOptions): string {
const loaded = loadConfig(opts.store)
const config: ConfigFile = loaded.found ? loaded.config : emptyConfig()
const out = collect(config)
if (opts.json)

View File

@@ -1,7 +1,7 @@
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../auth/hosts.js'
import { describe, expect, it, vi } from 'vitest'
import { bufferStreams } from '../../../io/streams.js'
import { bufferStreams } from '../../../sys/io/streams.js'
import { runCreateMember } from './run.js'
function bundle(): HostsBundle {

View File

@@ -1,12 +1,12 @@
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { IOStreams } from '../../../io/streams.js'
import type { IOStreams } from '../../../sys/io/streams.js'
import { MembersClient } from '../../../api/members.js'
import { BaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
import { colorEnabled, colorScheme } from '../../../io/color.js'
import { runWithSpinner } from '../../../io/spinner.js'
import { nullStreams } from '../../../io/streams.js'
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
import { runWithSpinner } from '../../../sys/io/spinner.js'
import { nullStreams } from '../../../sys/io/streams.js'
import { resolveWorkspaceId } from '../../../workspace/resolver.js'
import { InviteOutput } from './handlers.js'

View File

@@ -1,7 +1,7 @@
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../auth/hosts.js'
import { describe, expect, it, vi } from 'vitest'
import { bufferStreams } from '../../../io/streams.js'
import { bufferStreams } from '../../../sys/io/streams.js'
import { runDeleteMember } from './run.js'
function bundle(): HostsBundle {

View File

@@ -1,13 +1,13 @@
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { IOStreams } from '../../../io/streams.js'
import type { IOStreams } from '../../../sys/io/streams.js'
import * as readline from 'node:readline'
import { MembersClient } from '../../../api/members.js'
import { BaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
import { colorEnabled, colorScheme } from '../../../io/color.js'
import { runWithSpinner } from '../../../io/spinner.js'
import { nullStreams } from '../../../io/streams.js'
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
import { runWithSpinner } from '../../../sys/io/spinner.js'
import { nullStreams } from '../../../sys/io/streams.js'
import { resolveWorkspaceId } from '../../../workspace/resolver.js'
import { DeleteMemberOutput } from './handlers.js'

View File

@@ -8,6 +8,8 @@ import { startMock } from '../../../../test/fixtures/dify-mock/server.js'
import { loadAppInfoCache } from '../../../cache/app-info.js'
import { formatted, stringifyOutput } from '../../../framework/output.js'
import { createClient } from '../../../http/client.js'
import { CACHE_APP_INFO, cachePath } from '../../../store/manager.js'
import { YamlStore } from '../../../store/store.js'
import { runDescribeApp } from './run.js'
function bundle(): HostsBundle {
@@ -37,7 +39,7 @@ describe('runDescribeApp', () => {
})
async function render(opts: Parameters<typeof runDescribeApp>[0]): Promise<string> {
const cache = await loadAppInfoCache({ configDir: dir })
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 },
@@ -80,7 +82,7 @@ describe('runDescribeApp', () => {
})
it('refresh: bypasses cache', async () => {
const cache = await loadAppInfoCache({ configDir: dir })
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

@@ -1,11 +1,12 @@
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { AppInfoCache } from '../../../cache/app-info.js'
import type { IOStreams } from '../../../io/streams.js'
import type { IOStreams } from '../../../sys/io/streams'
import { AppMetaClient } from '../../../api/app-meta.js'
import { AppsClient } from '../../../api/apps.js'
import { runWithSpinner } from '../../../io/spinner.js'
import { nullStreams } from '../../../io/streams.js'
import { getEnv } from '../../../sys/index.js'
import { runWithSpinner } from '../../../sys/io/spinner.js'
import { nullStreams } from '../../../sys/io/streams'
import { FieldInfo, FieldInputSchema, FieldParameters } from '../../../types/app-meta.js'
import { resolveWorkspaceId } from '../../../workspace/resolver.js'
import { AppDescribeOutput } from './handlers.js'
@@ -27,7 +28,7 @@ export type DescribeAppDeps = {
}
export async function runDescribeApp(opts: DescribeAppOptions, deps: DescribeAppDeps): Promise<AppDescribeOutput> {
const env = deps.envLookup ?? ((k: string) => process.env[k])
const env = deps.envLookup ?? getEnv
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
const apps = new AppsClient(deps.http)
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })

View File

@@ -1,4 +1,5 @@
import { ENV_REGISTRY } from '../../../env/registry.js'
import { getEnv } from '../../../sys/index.js'
export type EnvLookup = (name: string) => string | undefined
@@ -17,7 +18,7 @@ export type EnvListJsonRow = {
const COLUMN_PADDING = 2
export function runEnvList(opts: RunEnvListOptions = {}): string {
const lookup = opts.lookup ?? defaultLookup
const lookup = opts.lookup ?? getEnv
if (opts.json) {
const rows: EnvListJsonRow[] = ENV_REGISTRY.map(v => ({
name: v.name,
@@ -67,7 +68,3 @@ function renderTable(rows: readonly (readonly string[])[]): string {
}
return out
}
function defaultLookup(name: string): string | undefined {
return process.env[name]
}

View File

@@ -1,12 +1,13 @@
import type { AppDescribeResponse, AppListResponse, AppMode } from '@dify/contracts/api/openapi/types.gen'
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { IOStreams } from '../../../io/streams.js'
import type { IOStreams } from '../../../sys/io/streams'
import { AppsClient } from '../../../api/apps.js'
import { WorkspacesClient } from '../../../api/workspaces.js'
import { runWithSpinner } from '../../../io/spinner.js'
import { nullStreams } from '../../../io/streams.js'
import { LIMIT_DEFAULT, parseLimit } from '../../../limit/limit.js'
import { getEnv } from '../../../sys/index.js'
import { runWithSpinner } from '../../../sys/io/spinner.js'
import { nullStreams } from '../../../sys/io/streams'
import { resolveWorkspaceId } from '../../../workspace/resolver.js'
import { AppListOutput, AppRow } from './handlers.js'
@@ -38,7 +39,7 @@ export type GetAppResult = {
}
export async function runGetApp(opts: GetAppOptions, deps: GetAppDeps): Promise<GetAppResult> {
const env = deps.envLookup ?? ((k: string) => process.env[k])
const env = deps.envLookup ?? getEnv
const appsFactory = deps.appsFactory ?? ((h: KyInstance) => new AppsClient(h))
const wsFactory = deps.workspacesFactory ?? ((h: KyInstance) => new WorkspacesClient(h))

View File

@@ -2,7 +2,7 @@ import type { MemberListResponse } from '@dify/contracts/api/openapi/types.gen'
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../auth/hosts.js'
import { describe, expect, it, vi } from 'vitest'
import { bufferStreams } from '../../../io/streams.js'
import { bufferStreams } from '../../../sys/io/streams.js'
import { runGetMember } from './run.js'
function bundle(): HostsBundle {

View File

@@ -1,10 +1,10 @@
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { IOStreams } from '../../../io/streams.js'
import type { IOStreams } from '../../../sys/io/streams.js'
import { MembersClient } from '../../../api/members.js'
import { runWithSpinner } from '../../../io/spinner.js'
import { nullStreams } from '../../../io/streams.js'
import { LIMIT_DEFAULT, parseLimit } from '../../../limit/limit.js'
import { runWithSpinner } from '../../../sys/io/spinner.js'
import { nullStreams } from '../../../sys/io/streams.js'
import { resolveWorkspaceId } from '../../../workspace/resolver.js'
import { MemberListOutput, MemberRow } from './handlers.js'

View File

@@ -1,9 +1,9 @@
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { IOStreams } from '../../../io/streams.js'
import type { IOStreams } from '../../../sys/io/streams'
import { WorkspacesClient } from '../../../api/workspaces.js'
import { runWithSpinner } from '../../../io/spinner.js'
import { nullStreams } from '../../../io/streams.js'
import { runWithSpinner } from '../../../sys/io/spinner.js'
import { nullStreams } from '../../../sys/io/streams'
import { WorkspaceListOutput, WorkspaceRow } from './handlers.js'
export const EMPTY_WORKSPACES_MESSAGE

View File

@@ -1,12 +1,13 @@
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { AppInfoCache } from '../../../cache/app-info.js'
import type { IOStreams } from '../../../io/streams.js'
import type { IOStreams } from '../../../sys/io/streams'
import type { RunContext } from '../../run/app/_strategies/index.js'
import { AppMetaClient } from '../../../api/app-meta.js'
import { AppRunClient } from '../../../api/app-run.js'
import { AppsClient } from '../../../api/apps.js'
import { colorEnabled, colorScheme } from '../../../io/color.js'
import { getEnv, processExit } from '../../../sys/index.js'
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
import { FieldInfo } from '../../../types/app-meta.js'
import { resolveWorkspaceId } from '../../../workspace/resolver.js'
import { pickStrategy } from '../../run/app/_strategies/index.js'
@@ -76,7 +77,7 @@ async function resolveInputs(
}
export async function resumeApp(opts: ResumeAppOptions, deps: ResumeAppDeps): Promise<void> {
const env = deps.envLookup ?? ((k: string) => process.env[k])
const env = deps.envLookup ?? getEnv
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
const apps = new AppsClient(deps.http)
@@ -85,7 +86,7 @@ export async function resumeApp(opts: ResumeAppOptions, deps: ResumeAppDeps): Pr
const mode = m.info?.mode ?? RUN_MODES.Workflow
const runClient = new AppRunClient(deps.http)
const exit = deps.exit ?? ((code: number) => process.exit(code) as never)
const exit = deps.exit ?? processExit
let action = opts.action
if (action === undefined) {

View File

@@ -1,9 +1,9 @@
import type { SseEvent } from '../../../../http/sse.js'
import type { RunContext, RunStrategy } from './index.js'
import { buildRunBody } from '../../../../api/app-run.js'
import { colorEnabled, colorScheme } from '../../../../io/color.js'
import { startSpinner } from '../../../../io/spinner.js'
import { extractThinkBlocks, stripThinkBlocks } from '../../../../io/think-filter.js'
import { colorEnabled, colorScheme } from '../../../../sys/io/color.js'
import { startSpinner } from '../../../../sys/io/spinner.js'
import { extractThinkBlocks, stripThinkBlocks } from '../../../../sys/io/think-filter.js'
import { chatConversationHint, newAppRunObject, RUN_MODES } from '../handlers.js'
import { renderHitlHint, renderHitlOutput } from '../hitl-render.js'
import { collect, HitlPauseError } from '../sse-collector.js'

View File

@@ -1,5 +1,6 @@
import type { RunContext, RunStrategy } from './index.js'
import { buildRunBody } from '../../../../api/app-run.js'
import { handle, unhandle } from '../../../../sys/index.js'
import { renderHitlHint, renderHitlOutput } from '../hitl-render.js'
import { decodeStreamError, HitlPauseError } from '../sse-collector.js'
@@ -22,7 +23,8 @@ export class StreamingTextStrategy implements RunStrategy {
ctrl.abort()
exit(1)
}
process.once('SIGINT', cleanup)
handle('SIGINT', cleanup)
try {
const events = await ctx.runClient.runStream(opts.appId, body, { signal: ctrl.signal })
@@ -60,7 +62,7 @@ export class StreamingTextStrategy implements RunStrategy {
throw err
}
finally {
process.off('SIGINT', cleanup)
unhandle('SIGINT', cleanup)
}
}
}

View File

@@ -1,5 +1,5 @@
import type { ColorScheme } from '../../../io/color.js'
import type { TextHandler } from '../../../printers/format-text.js'
import type { ColorScheme } from '../../../sys/io/color.js'
export const RUN_MODES = {
Chat: 'chat',

View File

@@ -1,5 +1,5 @@
import type { HitlPausePayload } from './sse-collector.js'
import { colorEnabled, colorScheme } from '../../../io/color.js'
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
export type HitlExitObject = {
status: 'paused'

View File

@@ -7,7 +7,9 @@ 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 { bufferStreams } from '../../../io/streams.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'
@@ -39,7 +41,7 @@ describe('runApp', () => {
it('chat: prints answer + conversation hint to stderr', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ configDir: dir })
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 },
@@ -50,7 +52,7 @@ describe('runApp', () => {
it('workflow: rejects positional message with usage error', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ configDir: dir })
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 },
@@ -59,7 +61,7 @@ describe('runApp', () => {
it('workflow: prints single-string output as plain text', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ configDir: dir })
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 },
@@ -69,7 +71,7 @@ describe('runApp', () => {
it('json: passes through full envelope', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ configDir: dir })
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 },
@@ -102,7 +104,7 @@ describe('runApp', () => {
it('--stream chat: streams answer to stdout and hint to stderr', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ configDir: dir })
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 },
@@ -114,7 +116,7 @@ describe('runApp', () => {
it('--stream -o json chat: aggregates into blocking-shape envelope', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ configDir: dir })
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 },
@@ -127,7 +129,7 @@ describe('runApp', () => {
it('agent-chat without --stream: collects and prints answer', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ configDir: dir })
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 },
@@ -138,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({ configDir: dir })
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 },
@@ -149,7 +151,7 @@ describe('runApp', () => {
it('--stream workflow -o json: aggregates from workflow_finished', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ configDir: dir })
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 },
@@ -162,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({ configDir: dir })
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 },
@@ -171,7 +173,7 @@ describe('runApp', () => {
it('--inputs-file: reads inputs from file', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ configDir: dir })
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' }))
@@ -195,7 +197,7 @@ describe('runApp', () => {
it('--inputs: accepts JSON object string', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ configDir: dir })
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 },
@@ -217,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({ configDir: dir })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
let exitCode = -1
await expect(runApp(
{ appId: 'app-2', inputs: {} },
@@ -246,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({ configDir: dir })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
let exitCode = -1
await expect(runApp(
{ appId: 'app-2', inputs: {}, format: 'json' },
@@ -272,7 +274,7 @@ describe('runApp', () => {
it('resume: withHistory: false completes successfully', async () => {
mock.setScenario('hitl-resume')
const io = bufferStreams()
const cache = await loadAppInfoCache({ configDir: dir })
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 },
@@ -283,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({ configDir: dir })
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 },
@@ -294,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({ configDir: dir })
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 },
@@ -305,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({ configDir: dir })
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 },
@@ -324,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({ configDir: dir })
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(
@@ -343,7 +345,7 @@ describe('runApp', () => {
it('workflow: --file overrides same-named key from --inputs (file wins)', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ configDir: dir })
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

@@ -1,13 +1,14 @@
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { AppInfoCache } from '../../../cache/app-info.js'
import type { IOStreams } from '../../../io/streams.js'
import type { IOStreams } from '../../../sys/io/streams'
import { AppMetaClient } from '../../../api/app-meta.js'
import { AppRunClient } from '../../../api/app-run.js'
import { AppsClient } from '../../../api/apps.js'
import { FileUploadClient } from '../../../api/file-upload.js'
import { BaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
import { getEnv, processExit } from '../../../sys/index.js'
import { FieldInfo } from '../../../types/app-meta.js'
import { resolveWorkspaceId } from '../../../workspace/resolver.js'
import { pickStrategy } from './_strategies/index.js'
@@ -78,7 +79,7 @@ async function resolveInputs(
}
export async function runApp(opts: RunAppOptions, deps: RunAppDeps): Promise<void> {
const env = deps.envLookup ?? ((k: string) => process.env[k])
const env = deps.envLookup ?? getEnv
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
const apps = new AppsClient(deps.http)
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
@@ -111,7 +112,7 @@ export async function runApp(opts: RunAppOptions, deps: RunAppDeps): Promise<voi
const runClient = new AppRunClient(deps.http)
const printFlags = new AppRunPrintFlags()
const exit = deps.exit ?? ((code: number) => process.exit(code) as never)
const exit = deps.exit ?? processExit
const ctx = { opts: { ...opts, inputs }, deps, mode, format, isText, livePrint, runClient, printFlags, exit, think: opts.think ?? false }
await pickStrategy(isText, livePrint).execute(ctx)
}

View File

@@ -3,8 +3,8 @@ import type { StreamPrinter } from '../../../printers/stream-printer.js'
import type { HitlPausePayload } from './sse-collector.js'
import { newError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
import { colorEnabled, colorScheme } from '../../../io/color.js'
import { ThinkChunkFilter } from '../../../io/think-filter.js'
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
import { ThinkChunkFilter } from '../../../sys/io/think-filter.js'
import { RUN_MODES } from './handlers.js'
import { HitlPauseError } from './sse-collector.js'

View File

@@ -1,7 +1,7 @@
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../auth/hosts.js'
import { describe, expect, it, vi } from 'vitest'
import { bufferStreams } from '../../../io/streams.js'
import { bufferStreams } from '../../../sys/io/streams'
import { runSetMember } from './run.js'
function bundle(): HostsBundle {

View File

@@ -1,12 +1,12 @@
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { IOStreams } from '../../../io/streams.js'
import type { IOStreams } from '../../../sys/io/streams.js'
import { MembersClient } from '../../../api/members.js'
import { BaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
import { colorEnabled, colorScheme } from '../../../io/color.js'
import { runWithSpinner } from '../../../io/spinner.js'
import { nullStreams } from '../../../io/streams.js'
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
import { runWithSpinner } from '../../../sys/io/spinner.js'
import { nullStreams } from '../../../sys/io/streams.js'
import { resolveWorkspaceId } from '../../../workspace/resolver.js'
import { SetMemberOutput } from './handlers.js'

View File

@@ -9,7 +9,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { loadHosts, saveHosts } from '../../../auth/hosts.js'
import { bufferStreams } from '../../../io/streams.js'
import { bufferStreams } from '../../../sys/io/streams.js'
import { runUseWorkspace } from './use.js'
function bundle(): HostsBundle {

View File

@@ -1,12 +1,12 @@
import type { KyInstance } from 'ky'
import type { HostsBundle, Workspace } from '../../../auth/hosts.js'
import type { IOStreams } from '../../../io/streams.js'
import type { IOStreams } from '../../../sys/io/streams.js'
import { WorkspacesClient } from '../../../api/workspaces.js'
import { saveHosts } from '../../../auth/hosts.js'
import { BaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
import { colorEnabled, colorScheme } from '../../../io/color.js'
import { runWithSpinner } from '../../../io/spinner.js'
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
import { runWithSpinner } from '../../../sys/io/spinner.js'
export type UseWorkspaceOptions = {
readonly workspaceId: string

View File

@@ -1,7 +1,7 @@
import { Flags } from '../../framework/flags.js'
import { formatted, raw, stringifyOutput } from '../../framework/output.js'
import { colorEnabled } from '../../io/color.js'
import { realStreams } from '../../io/streams.js'
import { colorEnabled } from '../../sys/io/color.js'
import { realStreams } from '../../sys/io/streams'
import { versionInfo } from '../../version/info.js'
import { runVersionProbe } from '../../version/probe.js'
import { renderVersionText } from '../../version/render.js'
@@ -54,7 +54,7 @@ export default class Version extends DifyCommand {
// Emit the full report first so `difyctl version -o json --check-compat | jq`
// works exactly like the success path: stdout gets the canonical envelope,
// stderr gets the one-line failure reason, exit code signals the verdict.
process.stdout.write(stringifyOutput(output))
io.out.write(stringifyOutput(output))
this.error(report.compat.detail, { exit: COMPAT_FAIL_EXIT_CODE })
}

View File

@@ -2,10 +2,15 @@ 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.js'
import { ErrorCode } from '../errors/codes.js'
import { loadConfig } from './loader.js'
import { FILE_NAME } from './schema.js'
import { isBaseError } from '../errors/base'
import { ErrorCode } from '../errors/codes'
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
@@ -18,14 +23,14 @@ describe('loadConfig', () => {
await mkdir(dir, { recursive: true }).catch(() => {})
})
it('returns found:false when config.yml is missing', async () => {
const r = await loadConfig(dir)
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.yml', async () => {
await writeFile(join(dir, FILE_NAME), 'schema_version: 1\n', 'utf8')
const r = await loadConfig(dir)
const r = loadConfig(makeStore(dir))
expect(r.found).toBe(true)
if (r.found)
expect(r.config.schema_version).toBe(1)
@@ -37,7 +42,7 @@ describe('loadConfig', () => {
'schema_version: 1\ndefaults:\n format: json\n limit: 100\nstate:\n current_app: app-1\n',
'utf8',
)
const r = await loadConfig(dir)
const r = loadConfig(makeStore(dir))
expect(r.found).toBe(true)
if (r.found) {
expect(r.config.defaults.format).toBe('json')
@@ -50,7 +55,7 @@ describe('loadConfig', () => {
await writeFile(join(dir, FILE_NAME), '::not yaml::: {{[', 'utf8')
let caught: unknown
try {
await loadConfig(dir)
loadConfig(makeStore(dir))
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@@ -62,7 +67,7 @@ describe('loadConfig', () => {
await writeFile(join(dir, FILE_NAME), 'defaults:\n limit: 9999\n', 'utf8')
let caught: unknown
try {
await loadConfig(dir)
loadConfig(makeStore(dir))
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@@ -74,7 +79,7 @@ describe('loadConfig', () => {
await writeFile(join(dir, FILE_NAME), 'schema_version: 2\n', 'utf8')
let caught: unknown
try {
await loadConfig(dir)
loadConfig(makeStore(dir))
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)

View File

@@ -0,0 +1,42 @@
import type { YamlStore } from '../store/store'
import type { ConfigFile } from './schema'
import { newError } from '../errors/base'
import { ErrorCode } from '../errors/codes'
import { ConfigFileSchema, CURRENT_SCHEMA_VERSION } from './schema'
export type LoadResult
= | { found: false }
| { found: true, config: ConfigFile }
export function loadConfig(store: YamlStore): LoadResult {
let raw: Record<string, unknown> | null
try {
raw = store.getTyped<Record<string, unknown>>()
}
catch (err) {
throw newError(
ErrorCode.ConfigSchemaUnsupported,
`parse config.yml: ${(err as Error).message}`,
).wrap(err).withHint('config.yml is not valid YAML')
}
if (raw === null)
return { found: false }
const result = ConfigFileSchema.safeParse(raw)
if (!result.success) {
throw newError(
ErrorCode.ConfigSchemaUnsupported,
`validate config.yml: ${result.error.issues.map(i => i.message).join('; ')}`,
).withHint('config.yml does not match the v1 schema')
}
if (result.data.schema_version > CURRENT_SCHEMA_VERSION) {
throw newError(
ErrorCode.ConfigSchemaUnsupported,
`config.yml schema_version=${result.data.schema_version} is newer than this binary supports (max=${CURRENT_SCHEMA_VERSION})`,
).withHint('upgrade difyctl, or remove config.yml')
}
return { found: true, config: result.data }
}

View File

@@ -1,71 +0,0 @@
import { describe, expect, it } from 'vitest'
import { DIR_PERM, FILE_PERM, resolveConfigDir } from './dir.js'
function fakeEnv(opts: {
override?: string
xdg?: string
home?: string
appData?: string
platform: NodeJS.Platform
}) {
return {
getEnv: (name: string) => {
if (name === 'DIFY_CONFIG_DIR')
return opts.override
if (name === 'XDG_CONFIG_HOME')
return opts.xdg
return undefined
},
homeDir: () => opts.home ?? '/home/u',
platform: () => opts.platform,
appData: () => opts.appData,
}
}
describe('config dir', () => {
it('FILE_PERM is 0o600 + DIR_PERM is 0o700 (POSIX defaults)', () => {
expect(FILE_PERM).toBe(0o600)
expect(DIR_PERM).toBe(0o700)
})
it('DIFY_CONFIG_DIR override wins on every platform', () => {
for (const platform of ['linux', 'darwin', 'win32'] as const) {
expect(resolveConfigDir(fakeEnv({ override: '/tmp/x', platform })))
.toBe('/tmp/x')
}
})
it('linux uses XDG_CONFIG_HOME when set', () => {
expect(resolveConfigDir(fakeEnv({ xdg: '/x', platform: 'linux' })))
.toBe('/x/difyctl')
})
it('linux falls back to ~/.config when XDG unset', () => {
expect(resolveConfigDir(fakeEnv({ home: '/h', platform: 'linux' })))
.toBe('/h/.config/difyctl')
})
it('linux ignores empty XDG_CONFIG_HOME', () => {
expect(resolveConfigDir(fakeEnv({ xdg: '', home: '/h', platform: 'linux' })))
.toBe('/h/.config/difyctl')
})
it('macos uses ~/.config (not XDG, matches gh/kubectl)', () => {
expect(resolveConfigDir(fakeEnv({ xdg: '/ignored', home: '/h', platform: 'darwin' })))
.toBe('/h/.config/difyctl')
})
it('windows uses APPDATA', () => {
expect(resolveConfigDir(fakeEnv({ appData: 'C:\\Users\\u\\AppData\\Roaming', platform: 'win32' })))
.toMatch(/difyctl$/)
})
it('windows throws if APPDATA unresolvable', () => {
expect(() => resolveConfigDir(fakeEnv({ platform: 'win32' }))).toThrow(/APPDATA/)
})
it('unknown platform falls back to ~/.config', () => {
expect(resolveConfigDir(fakeEnv({ home: '/h', platform: 'freebsd' as NodeJS.Platform })))
.toBe('/h/.config/difyctl')
})
})

View File

@@ -1,45 +0,0 @@
import { homedir } from 'node:os'
import { join } from 'node:path'
export const ENV_CONFIG_DIR = 'DIFY_CONFIG_DIR'
export const ENV_XDG_CONFIG_HOME = 'XDG_CONFIG_HOME'
export const SUBDIR = 'difyctl'
export const FILE_PERM = 0o600
export const DIR_PERM = 0o700
export type ConfigEnvironment = {
readonly getEnv: (name: string) => string | undefined
readonly homeDir: () => string
readonly platform: () => NodeJS.Platform
readonly appData: () => string | undefined
}
export const realEnvironment: ConfigEnvironment = {
getEnv: name => process.env[name],
homeDir: () => homedir(),
platform: () => process.platform,
appData: () => process.env.APPDATA ?? process.env.LOCALAPPDATA,
}
export function resolveConfigDir(env: ConfigEnvironment = realEnvironment): string {
const override = env.getEnv(ENV_CONFIG_DIR)
if (override !== undefined && override !== '')
return override
const platform = env.platform()
if (platform === 'linux') {
const xdg = env.getEnv(ENV_XDG_CONFIG_HOME)
if (xdg !== undefined && xdg !== '')
return join(xdg, SUBDIR)
return join(env.homeDir(), '.config', SUBDIR)
}
if (platform === 'darwin')
return join(env.homeDir(), '.config', SUBDIR)
if (platform === 'win32') {
const appData = env.appData()
if (appData === undefined || appData === '')
throw new Error('cannot resolve %APPDATA% on Windows')
return join(appData, SUBDIR)
}
return join(env.homeDir(), '.config', SUBDIR)
}

View File

@@ -1,58 +0,0 @@
import type { ConfigFile } from './schema.js'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { load as parseYaml } from 'js-yaml'
import { newError } from '../errors/base.js'
import { ErrorCode } from '../errors/codes.js'
import {
ConfigFileSchema,
CURRENT_SCHEMA_VERSION,
FILE_NAME,
} from './schema.js'
export type LoadResult
= | { found: false }
| { found: true, config: ConfigFile }
export async function loadConfig(dir: string): Promise<LoadResult> {
const path = join(dir, FILE_NAME)
let raw: string
try {
raw = await readFile(path, 'utf8')
}
catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT')
return { found: false }
throw newError(ErrorCode.Unknown, `read ${path}: ${(err as Error).message}`)
.wrap(err)
}
let parsed: unknown
try {
parsed = parseYaml(raw)
}
catch (err) {
throw newError(
ErrorCode.ConfigSchemaUnsupported,
`parse ${path}: ${(err as Error).message}`,
).wrap(err).withHint('config.yml is not valid YAML')
}
const result = ConfigFileSchema.safeParse(parsed ?? {})
if (!result.success) {
throw newError(
ErrorCode.ConfigSchemaUnsupported,
`validate ${path}: ${result.error.issues.map(i => i.message).join('; ')}`,
).withHint('config.yml does not match the v1 schema')
}
if (result.data.schema_version > CURRENT_SCHEMA_VERSION) {
throw newError(
ErrorCode.ConfigSchemaUnsupported,
`config.yml schema_version=${result.data.schema_version} is newer than this binary supports (max=${CURRENT_SCHEMA_VERSION})`,
).withHint('upgrade difyctl, or remove config.yml')
}
return { found: true, config: result.data }
}

View File

@@ -1,39 +0,0 @@
import type { ConfigFile } from './schema.js'
import { mkdir, rename, unlink, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { dump as dumpYaml } from 'js-yaml'
import { newError } from '../errors/base.js'
import { ErrorCode } from '../errors/codes.js'
import { DIR_PERM, FILE_PERM } from './dir.js'
import {
CURRENT_SCHEMA_VERSION,
FILE_NAME,
} from './schema.js'
export async function saveConfig(dir: string, config: ConfigFile): Promise<void> {
await mkdir(dir, { recursive: true, mode: DIR_PERM })
const stamped: ConfigFile = { ...config, schema_version: CURRENT_SCHEMA_VERSION }
const yaml = dumpYaml(stamped, { lineWidth: -1, noRefs: true })
const target = join(dir, FILE_NAME)
const tmp = `${target}.tmp.${process.pid}.${Date.now()}`
try {
await writeFile(tmp, yaml, { mode: FILE_PERM })
await rename(tmp, target)
}
catch (err) {
try {
await unlink(tmp)
}
catch {
// tmp may not exist if writeFile failed before creating it
}
throw newError(
ErrorCode.Unknown,
`save ${target}: ${(err as Error).message}`,
).wrap(err)
}
}

View File

@@ -1,4 +1,5 @@
import { parseLimit } from '../limit/limit.js'
import { getEnv } from '../sys/index.js'
export type EnvVar = {
readonly name: string
@@ -55,9 +56,7 @@ export function lookupEnv(name: string): EnvVar | undefined {
return BY_NAME.get(name)
}
export function getEnv(name: string): string | undefined {
return process.env[name]
}
export { getEnv }
export function resolveEnv(name: string): unknown {
const entry = lookupEnv(name)

View File

@@ -1,5 +1,5 @@
import type { BaseError } from './base.js'
import { colorEnabled, colorScheme } from '../io/color.js'
import { colorEnabled, colorScheme } from '../sys/io/color.js'
import { renderEnvelope } from './envelope.js'
export type FormatErrorOptions = {

View File

@@ -2,9 +2,15 @@ import { mkdtemp, readdir, readFile, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { beforeEach, describe, expect, it } from 'vitest'
import { loadConfig } from './loader.js'
import { emptyConfig, FILE_NAME } from './schema.js'
import { saveConfig } from './writer.js'
import { loadConfig } from '../config/config-loader'
import { emptyConfig, FILE_NAME } from '../config/schema'
import { platform } from '../sys'
import { saveConfig } from './config-writer'
import { YamlStore } from './store'
function makeStore(dir: string): YamlStore {
return new YamlStore(join(dir, FILE_NAME))
}
describe('saveConfig', () => {
let dir: string
@@ -14,26 +20,26 @@ describe('saveConfig', () => {
})
it('writes config.yml in the target dir', async () => {
await saveConfig(dir, { ...emptyConfig(), schema_version: 1 })
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', async () => {
await saveConfig(dir, { ...emptyConfig() })
const r = await loadConfig(dir)
it('stamps schema_version=1 even if caller passed 0', () => {
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('round-trips defaults + state through YAML', async () => {
await saveConfig(dir, {
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 = await loadConfig(dir)
const r = loadConfig(makeStore(dir))
expect(r.found).toBe(true)
if (r.found) {
expect(r.config.defaults.format).toBe('wide')
@@ -43,32 +49,32 @@ describe('saveConfig', () => {
})
it('writes file with mode 0o600 (POSIX)', async () => {
if (process.platform === 'win32')
if (platform() === 'win32')
return
await saveConfig(dir, emptyConfig())
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 () => {
await saveConfig(dir, emptyConfig())
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 (process.platform === 'win32')
if (platform() === 'win32')
return
const nested = join(dir, 'nested', 'sub')
await saveConfig(nested, emptyConfig())
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 () => {
await saveConfig(dir, {
saveConfig(makeStore(dir), {
schema_version: 1,
defaults: { format: 'json' },
state: {},

View File

@@ -0,0 +1,8 @@
import type { ConfigFile } from '../config/schema'
import type { YamlStore } from './store'
import { CURRENT_SCHEMA_VERSION } from '../config/schema'
export function saveConfig(store: YamlStore, config: ConfigFile): void {
const stamped: ConfigFile = { ...config, schema_version: CURRENT_SCHEMA_VERSION }
store.setTyped(stamped)
}

20
cli/src/store/dir.ts Normal file
View File

@@ -0,0 +1,20 @@
import { getEnv, resolvePlatform } from '../sys'
export const ENV_CONFIG_DIR = 'DIFY_CONFIG_DIR'
export const ENV_CACHE_DIR = 'DIFY_CACHE_DIR'
export const FILE_PERM = 0o600
export const DIR_PERM = 0o700
export function resolveCacheDir(): string {
const override = getEnv(ENV_CACHE_DIR)
if (override !== undefined && override !== '')
return override
return resolvePlatform().cacheDir()
}
export function resolveConfigDir(): string {
const override = getEnv(ENV_CONFIG_DIR)
if (override !== undefined && override !== '')
return override
return resolvePlatform().configDir()
}

28
cli/src/store/manager.ts Normal file
View File

@@ -0,0 +1,28 @@
import type { Store } from './store'
import { join } from 'node:path'
import { FILE_NAME } from '../config/schema'
import { resolveCacheDir, resolveConfigDir } from './dir'
import { YamlStore } from './store'
export const CACHE_APP_INFO = 'app-info'
export const CACHE_NUDGE = 'nudge'
function getStore(filePath: string): YamlStore {
return new YamlStore(filePath)
}
function resolveConfigurationPath(): string {
return join(resolveConfigDir(), FILE_NAME)
}
export function cachePath(cacheDir: string, name: string): string {
return join(cacheDir, `${name}.yml`)
}
export function getConfigurationStore(): YamlStore {
return getStore(resolveConfigurationPath())
}
export function getCache(cacheName: string): Store {
return getStore(cachePath(resolveCacheDir(), cacheName))
}

193
cli/src/store/store.test.ts Normal file
View File

@@ -0,0 +1,193 @@
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 { ConcurrentAccessError, YamlStore } from './store'
describe('YamlStore.doGet', () => {
it('returns default when content is undefined', () => {
const store = new YamlStore('/irrelevant')
expect(store.doGet({ key: 'name', default: 'fallback' })).toBe('fallback')
})
it('reads a flat key', () => {
const store = new YamlStore('/irrelevant')
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.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.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.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.raw_content = 'user: scalar\n'
expect(store.doGet({ key: 'user.id', default: 0 })).toBe(0)
})
})
describe('YamlStore.doSet', () => {
it('sets a flat key from empty content', () => {
const store = new YamlStore('/irrelevant')
store.doSet({ key: 'name', default: '' }, 'alice')
expect(store.doGet({ key: 'name', default: '' })).toBe('alice')
})
it('sets a nested key, creating intermediate objects', () => {
const store = new YamlStore('/irrelevant')
store.doSet({ key: 'user.id', default: 0 }, 42)
expect(store.doGet({ key: 'user.id', default: 0 })).toBe(42)
})
it('overwrites an existing key without disturbing siblings', () => {
const store = new YamlStore('/irrelevant')
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)
})
it('replaces a scalar intermediate with an object when path deepens', () => {
const store = new YamlStore('/irrelevant')
store.raw_content = 'user: scalar\n'
store.doSet({ key: 'user.id', default: 0 }, 99)
expect(store.doGet({ key: 'user.id', default: 0 })).toBe(99)
})
})
describe('FileBasedStore.withLock concurrency', () => {
let dir: string
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'difyctl-yaml-store-'))
})
afterEach(async () => {
await rm(dir, { recursive: true, force: true })
})
it('second get throws while first holds the lock, succeeds after release', async () => {
const path = join(dir, 'config.yml')
await writeFile(path, 'key: value\n')
const s1 = new YamlStore(path)
const s2 = new YamlStore(path)
s1.lock()
expect(() => s2.get({ key: 'key', default: '' })).toThrow(ConcurrentAccessError)
s1.unlock()
expect(s2.get({ key: 'key', default: '' })).toBe('value')
})
it('second set throws while first holds the lock, succeeds after release', async () => {
const path = join(dir, 'config.yml')
await writeFile(path, 'key: original\n')
const s1 = new YamlStore(path)
const s2 = new YamlStore(path)
s1.lock()
expect(() => s2.set({ key: 'key', default: '' }, 'blocked')).toThrow(ConcurrentAccessError)
s1.unlock()
s2.set({ key: 'key', default: '' }, 'written')
expect(s2.get({ key: 'key', default: '' })).toBe('written')
})
})
describe('YamlStore persistence', () => {
let dir: string
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'difyctl-yaml-store-'))
})
afterEach(async () => {
await rm(dir, { recursive: true, force: true })
})
it('round-trips a flat value through disk', async () => {
const path = join(dir, 'config.yml')
await writeFile(path, '')
const s1 = new YamlStore(path)
s1.raw_content = ''
s1.doSet({ key: 'workspace', default: '' }, 'ws-123')
writeFileSync(path, s1.raw_content ?? '')
const s2 = new YamlStore(path)
s2.raw_content = readFileSync(path, 'utf8')
expect(s2.doGet({ key: 'workspace', default: '' })).toBe('ws-123')
})
it('round-trips a deep nested value through disk', async () => {
const path = join(dir, 'config.yml')
await writeFile(path, '')
const s1 = new YamlStore(path)
s1.raw_content = ''
s1.doSet({ key: 'a.b.c', default: '' }, 'deep')
writeFileSync(path, s1.raw_content ?? '')
const s2 = new YamlStore(path)
s2.raw_content = readFileSync(path, 'utf8')
expect(s2.doGet({ key: 'a.b.c', default: '' })).toBe('deep')
})
it('second doSet on a reloaded store does not clobber the first key', async () => {
const path = join(dir, 'config.yml')
await writeFile(path, '')
const s1 = new YamlStore(path)
s1.raw_content = ''
s1.doSet({ key: 'x', default: '' }, 'first')
writeFileSync(path, s1.raw_content ?? '')
const s2 = new YamlStore(path)
s2.raw_content = readFileSync(path, 'utf8')
s2.doSet({ key: 'y', default: '' }, 'second')
writeFileSync(path, s2.raw_content ?? '')
const s3 = new YamlStore(path)
s3.raw_content = readFileSync(path, 'utf8')
expect(s3.doGet({ key: 'x', default: '' })).toBe('first')
expect(s3.doGet({ key: 'y', default: '' })).toBe('second')
})
it('load → doSet → flush writes the value to disk', async () => {
const path = join(dir, 'config.yml')
await writeFile(path, 'existing: value\n')
const store = new YamlStore(path)
store.load()
store.doSet({ key: 'token', default: '' }, 'abc-123')
store.flush()
const raw = readFileSync(path, 'utf8')
const store2 = new YamlStore(path)
store2.raw_content = raw
expect(store2.doGet({ key: 'token', default: '' })).toBe('abc-123')
expect(store2.doGet({ key: 'existing', default: '' })).toBe('value')
})
})

165
cli/src/store/store.ts Normal file
View File

@@ -0,0 +1,165 @@
import type { Platform } from '../sys'
import fs from 'node:fs'
import { dirname } from 'node:path'
import yaml from 'js-yaml'
import lockfile from 'lockfile'
import { pid, resolvePlatform } from '../sys'
const FILE_PERM = 0o600
const DIR_PERM = 0o700
type Key<T> = {
default: T
key: string
}
export type Store = {
get: <T>(key: Key<T>) => T
set: <T>(key: Key<T>, value: T) => void
}
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 {
file_path: string
raw_content: string | undefined
private readonly platform: Platform
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.file_path}.lock`)
}
/**
* atomically write raw_content (if any)
*/
flush(): void {
if (this.raw_content !== undefined) {
const tmp = `${this.file_path}.tmp.${pid()}.${Date.now()}`
try {
fs.writeFileSync(tmp, this.raw_content, { mode: FILE_PERM })
this.platform.atomicReplace(tmp, this.file_path)
}
catch (err) {
try {
fs.unlinkSync(tmp)
}
catch { /* tmp may not exist */ }
throw err
}
}
}
lock(): void {
try {
lockfile.lockSync(`${this.file_path}.lock`)
}
catch (err) {
const code = (err as NodeJS.ErrnoException).code
if (code === 'EEXIST') {
throw new ConcurrentAccessError(this.file_path)
}
throw err
}
}
load(): void {
try {
this.raw_content = fs.readFileSync(this.file_path, 'utf8')
}
catch (err) {
const code = (err as NodeJS.ErrnoException).code
if (code !== 'ENOENT') {
throw err
}
}
}
protected withLock<R>(body: () => R): R {
this.lock()
try {
this.load()
return body()
}
finally {
this.unlock()
}
}
get<T>(key: Key<T>): T {
return this.withLock(() => this.doGet(key))
}
set<T>(key: Key<T>, value: T) {
this.withLock(() => {
this.doSet(key, value)
this.flush()
})
}
abstract doGet<T>(key: Key<T>): T
abstract doSet<T>(key: Key<T>, value: T): void
}
export class YamlStore extends FileBasedStore {
constructor(file_path: string) {
super(file_path)
}
doGet<T>(key: Key<T>): T {
const data = loadYaml(this.raw_content)
const parts = key.key.split('.')
let current: unknown = data
for (const part of parts) {
if (current === null || current === undefined || typeof current !== 'object')
return key.default
current = (current as Record<string, unknown>)[part]
}
return (current as T) ?? key.default
}
getTyped<T>(): T | null {
return this.withLock(() => {
this.load()
return loadYaml(this.raw_content) as T
})
}
setTyped<T>(data: T): void {
this.withLock(() => {
this.raw_content = yaml.dump(data, { lineWidth: -1, noRefs: true })
this.flush()
})
}
doSet<T>(key: Key<T>, value: T): void {
const data = loadYaml(this.raw_content) || {}
const parts = key.key.split('.')
const lastKey = parts.pop()
if (lastKey === undefined)
return
let current: Record<string, unknown> = data
for (const part of parts) {
if (current[part] === null || current[part] === undefined || typeof current[part] !== 'object')
current[part] = {}
current = current[part] as Record<string, unknown>
}
current[lastKey] = value
this.raw_content = yaml.dump(data, { lineWidth: -1, noRefs: true })
}
}
function loadYaml(raw: string | undefined): Record<string, unknown> | null {
if (raw === undefined)
return null
return (yaml.load(raw) ?? {}) as Record<string, unknown>
}

37
cli/src/sys/index.test.ts Normal file
View File

@@ -0,0 +1,37 @@
import { homedir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { resolvePlatform, SUBDIR } from './index.js'
describe('resolvePlatform', () => {
it('id matches process.platform', () => {
expect(resolvePlatform().id()).toBe(process.platform)
})
it('configDir ends with the difyctl subdir', () => {
const p = resolvePlatform()
if (p.id() === 'win32') {
expect(p.configDir()).toMatch(/difyctl$/)
}
else {
expect(p.configDir()).toBe(join(homedir(), '.config', SUBDIR))
}
})
it('cacheDir ends with the difyctl subdir', () => {
const p = resolvePlatform()
if (p.id() === 'win32') {
expect(p.cacheDir()).toMatch(/difyctl$/)
}
else if (p.id() === 'darwin') {
expect(p.cacheDir()).toBe(join(homedir(), 'Library', 'Caches', SUBDIR))
}
else {
expect(p.cacheDir()).toBe(join(homedir(), '.cache', SUBDIR))
}
})
it('atomicReplace is a function', () => {
expect(resolvePlatform().atomicReplace).toBeTypeOf('function')
})
})

122
cli/src/sys/index.ts Normal file
View File

@@ -0,0 +1,122 @@
import fs from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
export function getEnv(name: string): string | undefined {
return process.env[name]
}
export function env(): NodeJS.ProcessEnv {
return process.env
}
export function processExit(code: number): never {
return process.exit(code) as never
}
export function io() {
return {
out: process.stdout,
err: process.stderr,
in: process.stdin,
isOutTTY: Boolean(process.stdout.isTTY),
isErrTTY: Boolean(process.stderr.isTTY),
}
}
export function handle(sig: string, handler: () => void) {
process.once(sig, handler)
}
export function unhandle(sig: string, handler: () => void) {
process.off(sig, handler)
}
export function platform(): NodeJS.Platform {
return process.platform
}
export function arch(): string {
return process.arch
}
export function pid(): number {
return Number(process.pid)
}
export type Platform = {
id: () => NodeJS.Platform
configDir: () => string
cacheDir: () => string
atomicReplace: (src: string, dst: string) => void
}
export const SUBDIR = 'difyctl'
export const ENV_XDG_CONFIG_HOME = 'XDG_CONFIG_HOME'
export const ENV_XDG_CACHE_HOME = 'XDG_CACHE_HOME'
function appDataDir(): string | undefined {
return getEnv('APPDATA') ?? getEnv('LOCALAPPDATA')
}
type PlatformFactory = () => Platform
function posixAtomicReplace(src: string, dst: string): void {
fs.renameSync(src, dst)
}
function win32AtomicReplace(src: string, dst: string): void {
try {
fs.unlinkSync(dst)
}
catch { }
fs.renameSync(src, dst)
}
const platformImpls: Partial<Record<NodeJS.Platform, PlatformFactory>> = {
linux: () => ({
id: () => 'linux',
configDir: () => {
const xdg = getEnv(ENV_XDG_CONFIG_HOME)
return (xdg !== undefined && xdg !== '') ? join(xdg, SUBDIR) : join(homedir(), '.config', SUBDIR)
},
cacheDir: () => {
const xdg = getEnv(ENV_XDG_CACHE_HOME)
return (xdg !== undefined && xdg !== '') ? join(xdg, SUBDIR) : join(homedir(), '.cache', SUBDIR)
},
atomicReplace: posixAtomicReplace,
}),
darwin: () => ({
id: () => 'darwin',
configDir: () => join(homedir(), '.config', SUBDIR),
cacheDir: () => join(homedir(), 'Library', 'Caches', SUBDIR),
atomicReplace: posixAtomicReplace,
}),
win32: () => ({
id: () => 'win32',
configDir: () => {
const appData = appDataDir()
if (appData === undefined || appData === '')
throw new Error('cannot resolve %APPDATA% on Windows')
return join(appData, SUBDIR)
},
cacheDir: () => {
const appData = appDataDir()
if (appData === undefined || appData === '')
throw new Error('cannot resolve %LOCALAPPDATA% on Windows')
return join(appData, SUBDIR)
},
atomicReplace: win32AtomicReplace,
}),
}
const defaultPlatformFactory: PlatformFactory = () => ({
id: () => platform(),
configDir: () => join(homedir(), '.config', SUBDIR),
cacheDir: () => join(homedir(), '.cache', SUBDIR),
atomicReplace: posixAtomicReplace,
})
export function resolvePlatform(): Platform {
return (platformImpls[platform()] ?? defaultPlatformFactory)()
}

View File

@@ -1,5 +1,6 @@
import { Buffer } from 'node:buffer'
import { PassThrough, Readable, Writable } from 'node:stream'
import { io } from '..'
export type IOStreams = {
out: NodeJS.WritableStream
@@ -16,12 +17,8 @@ export function nullStreams(): IOStreams {
export function realStreams(outputFormat = ''): IOStreams {
return {
out: process.stdout,
err: process.stderr,
in: process.stdin,
isOutTTY: Boolean(process.stdout.isTTY),
isErrTTY: Boolean(process.stderr.isTTY),
outputFormat,
...io(),
}
}

View File

@@ -1,4 +1,5 @@
import openModule from 'open'
import { platform } from '../sys'
export const OpenDecision = {
Auto: 'auto-open',
@@ -19,7 +20,7 @@ export type BrowserEnv = {
export function realEnv(): BrowserEnv {
return {
getEnv: k => process.env[k],
platform: process.platform,
platform: platform(),
isOutTTY: Boolean(process.stdout.isTTY),
isErrTTY: Boolean(process.stderr.isTTY),
}

View File

@@ -1,3 +1,4 @@
import { arch, platform } from '../sys/index.js'
import { compatString } from './compat.js'
export type Channel = 'dev' | 'rc' | 'stable'
@@ -27,5 +28,5 @@ export function longVersion(): string {
}
export function userAgent(): string {
return `difyctl/${versionInfo.version} (${process.platform}; ${process.arch}; ${versionInfo.channel})`
return `difyctl/${versionInfo.version} (${platform()}; ${arch()}; ${versionInfo.channel})`
}

View File

@@ -5,6 +5,8 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { loadNudgeStore } from '../cache/nudge-store.js'
import { CACHE_NUDGE, cachePath } from '../store/manager.js'
import { YamlStore } from '../store/store.js'
import { maybeNudgeCompat } from './nudge.js'
const HOST = 'https://cloud.dify.ai'
@@ -44,7 +46,7 @@ describe('maybeNudgeCompat', () => {
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'difyctl-nudge-'))
store = await loadNudgeStore({ configDir: dir, now: fixedNow })
store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: fixedNow })
})
afterEach(async () => {
await rm(dir, { recursive: true, force: true })
@@ -76,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({ configDir: dir, 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({ configDir: dir, 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

@@ -1,6 +1,6 @@
import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen'
import type { NudgeStore } from '../cache/nudge-store.js'
import { colorScheme } from '../io/color.js'
import { colorScheme } from '../sys/io/color.js'
import { difyCompat, evaluateCompat } from './compat.js'
// Formats whose stdout is structured data (json/yaml) or a single name token —

View File

@@ -1,12 +1,13 @@
import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen'
import type { HostsBundle } from '../auth/hosts.js'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { platform, tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { startMock } from '../../test/fixtures/dify-mock/server.js'
import { saveHosts } from '../auth/hosts.js'
import { ENV_CONFIG_DIR } from '../config/dir.js'
import { ENV_CONFIG_DIR } from '../store/dir.js'
import { arch } from '../sys/index.js'
import { runVersionProbe } from './probe.js'
function bundle(overrides: Partial<HostsBundle> = {}): HostsBundle {
@@ -195,7 +196,7 @@ describe('runVersionProbe', () => {
expect(report.client.version).toBeTypeOf('string')
expect(report.client.commit).toBeTypeOf('string')
expect(report.client.channel).toMatch(/^(dev|rc|stable)$/)
expect(report.client.platform).toBe(process.platform)
expect(report.client.arch).toBe(process.arch)
expect(report.client.platform).toBe(platform())
expect(report.client.arch).toBe(arch())
})
})

View File

@@ -4,8 +4,9 @@ import type { CompatVerdict } from './compat.js'
import type { Channel } from './info.js'
import { META_PROBE_TIMEOUT_MS, MetaClient } from '../api/meta.js'
import { loadHosts } from '../auth/hosts.js'
import { resolveConfigDir } from '../config/dir.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'
import { versionInfo } from './info.js'
@@ -60,8 +61,8 @@ function buildClientBlock(): ClientBlock {
commit: versionInfo.commit,
buildDate: versionInfo.buildDate,
channel: versionInfo.channel,
platform: process.platform,
arch: process.arch,
platform: platform(),
arch: arch(),
}
}

View File

@@ -1,5 +1,5 @@
import type { VersionReport } from './probe.js'
import { colorScheme } from '../io/color.js'
import { colorScheme } from '../sys/io/color.js'
const RC_WARNING_LINES = [
'WARNING: This build is a release candidate. It is in beta test, not stable,',

View File

@@ -2,6 +2,14 @@
"extends": "@dify/tsconfig/node.json",
"compilerOptions": {
"rootDir": "src",
"paths": {
"@/*": [
"./*"
],
"~@/*": [
"./*"
]
},
"types": ["node"],
"declaration": true,
"declarationMap": true,
@@ -10,5 +18,5 @@
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "test", "node_modules", "**/*.test.ts"]
"exclude": ["node_modules", "**/*.test.ts"]
}

31
pnpm-lock.yaml generated
View File

@@ -210,6 +210,9 @@ catalogs:
'@types/js-yaml':
specifier: 4.0.9
version: 4.0.9
'@types/lockfile':
specifier: 1.0.4
version: 1.0.4
'@types/negotiator':
specifier: 0.6.4
version: 0.6.4
@@ -414,6 +417,9 @@ catalogs:
lexical:
specifier: 0.44.0
version: 0.44.0
lockfile:
specifier: 1.0.4
version: 1.0.4
loro-crdt:
specifier: 1.12.1
version: 1.12.1
@@ -653,6 +659,9 @@ importers:
ky:
specifier: 'catalog:'
version: 2.0.2
lockfile:
specifier: 'catalog:'
version: 1.0.4
open:
specifier: 'catalog:'
version: 10.1.0
@@ -678,6 +687,9 @@ importers:
'@types/js-yaml':
specifier: 'catalog:'
version: 4.0.9
'@types/lockfile':
specifier: 'catalog:'
version: 1.0.4
'@types/node':
specifier: 'catalog:'
version: 25.9.1
@@ -4782,6 +4794,9 @@ packages:
'@types/katex@0.16.8':
resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==}
'@types/lockfile@1.0.4':
resolution: {integrity: sha512-Q8oFIHJHr+htLrTXN2FuZfg+WXVHQRwU/hC2GpUu+Q8e3FUM9EDkS2pE3R2AO1ZGu56f479ybdMCNF1DAu8cAQ==}
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
@@ -7133,6 +7148,9 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
lockfile@1.0.4:
resolution: {integrity: sha512-cvbTwETRfsFh4nHsL1eGWapU1XFi5Ot9E85sWAwia7Y7EgB7vfqcZhTKZ+l7hCGxSPoushMv5GKhT5PdLv03WA==}
lodash-es@4.18.0:
resolution: {integrity: sha512-koAgswPPA+UTaPN64Etp+PGP+WT6oqOS2NMi5yDkMaiGw9qY4VxQbQF0mtKMyr4BlTznWyzePV5UpECTJQmSUA==}
deprecated: Bad release. Please use lodash-es@4.17.23 instead.
@@ -8424,6 +8442,9 @@ packages:
resolution: {integrity: sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q==}
engines: {node: '>=20'}
signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
signal-exit@4.1.0:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
@@ -12392,6 +12413,8 @@ snapshots:
'@types/katex@0.16.8': {}
'@types/lockfile@1.0.4': {}
'@types/mdast@4.0.4':
dependencies:
'@types/unist': 3.0.3
@@ -14967,6 +14990,10 @@ snapshots:
dependencies:
p-locate: 5.0.0
lockfile@1.0.4:
dependencies:
signal-exit: 3.0.7
lodash-es@4.18.0: {}
lodash.merge@4.6.2: {}
@@ -16678,6 +16705,8 @@ snapshots:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
signal-exit@3.0.7: {}
signal-exit@4.1.0: {}
simple-concat@1.0.1:
@@ -17640,6 +17669,7 @@ time:
'@tsslint/config@3.1.3': '2026-05-16T14:17:56.687Z'
'@types/js-cookie@3.0.6': '2023-11-07T08:41:16.889Z'
'@types/js-yaml@4.0.9': '2023-11-07T20:20:13.264Z'
'@types/lockfile@1.0.4': '2023-11-07T20:23:21.070Z'
'@types/negotiator@0.6.4': '2025-06-07T02:18:17.532Z'
'@types/node@25.9.1': '2026-05-19T17:49:12.417Z'
'@types/qs@6.15.1': '2026-05-06T23:46:01.024Z'
@@ -17711,6 +17741,7 @@ time:
lamejs@1.2.1: '2021-12-02T15:44:40.036Z'
lexical-code-no-prism@0.41.0: '2026-03-08T16:50:40.266Z'
lexical@0.44.0: '2026-04-27T14:47:00.970Z'
lockfile@1.0.4: '2018-04-17T00:36:12.565Z'
loro-crdt@1.12.1: '2026-04-29T20:11:51.397Z'
mermaid@11.15.0: '2026-05-11T11:15:09.824Z'
mime@4.1.0: '2025-09-12T17:53:01.376Z'

View File

@@ -113,6 +113,7 @@ catalog:
'@tsslint/config': 3.1.3
'@types/js-cookie': 3.0.6
'@types/js-yaml': 4.0.9
'@types/lockfile': 1.0.4
'@types/negotiator': 0.6.4
'@types/node': 25.9.1
'@types/qs': 6.15.1
@@ -181,6 +182,7 @@ catalog:
ky: 2.0.2
lamejs: 1.2.1
lexical: 0.44.0
lockfile: 1.0.4
loro-crdt: 1.12.1
mermaid: 11.15.0
mime: 4.1.0