mirror of
https://github.com/langgenius/dify.git
synced 2026-06-01 04:00:59 -04:00
refactor(cli): add kvstore and platform interface (#36687)
This commit is contained in:
10
.github/workflows/cli-tests.yml
vendored
10
.github/workflows/cli-tests.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
43
cli/src/cache/app-info.test.ts
vendored
43
cli/src/cache/app-info.test.ts
vendored
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
68
cli/src/cache/app-info.ts
vendored
68
cli/src/cache/app-info.ts
vendored
@@ -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)
|
||||
}
|
||||
|
||||
41
cli/src/cache/nudge-store.test.ts
vendored
41
cli/src/cache/nudge-store.test.ts
vendored
@@ -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)
|
||||
})
|
||||
|
||||
67
cli/src/cache/nudge-store.ts
vendored
67
cli/src/cache/nudge-store.ts
vendored
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 })
|
||||
|
||||
7
cli/src/commands/env/list/run-list.ts
vendored
7
cli/src/commands/env/list/run-list.ts
vendored
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
42
cli/src/config/config-loader.ts
Normal file
42
cli/src/config/config-loader.ts
Normal 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 }
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
5
cli/src/env/registry.ts
vendored
5
cli/src/env/registry.ts
vendored
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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: {},
|
||||
8
cli/src/store/config-writer.ts
Normal file
8
cli/src/store/config-writer.ts
Normal 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
20
cli/src/store/dir.ts
Normal 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
28
cli/src/store/manager.ts
Normal 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
193
cli/src/store/store.test.ts
Normal 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
165
cli/src/store/store.ts
Normal 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
37
cli/src/sys/index.test.ts
Normal 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
122
cli/src/sys/index.ts
Normal 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)()
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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})`
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,',
|
||||
|
||||
@@ -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
31
pnpm-lock.yaml
generated
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user