From a8d380bcaf16639eb5bfcab3cd1b51a8f880055d Mon Sep 17 00:00:00 2001 From: Yunlu Wen Date: Wed, 27 May 2026 13:30:12 +0800 Subject: [PATCH] refactor(cli): add kvstore and platform interface (#36687) --- .github/workflows/cli-tests.yml | 10 +- cli/AGENTS.md | 2 +- cli/package.json | 2 + cli/src/api/app-meta.test.ts | 10 +- cli/src/auth/file-backend.test.ts | 2 +- cli/src/auth/file-backend.ts | 2 +- cli/src/auth/hosts.test.ts | 2 +- cli/src/auth/hosts.ts | 2 +- cli/src/cache/app-info.test.ts | 43 ++-- cli/src/cache/app-info.ts | 68 +++--- cli/src/cache/nudge-store.test.ts | 41 ++-- cli/src/cache/nudge-store.ts | 67 ++---- cli/src/commands/_shared/authed-command.ts | 20 +- .../auth/devices/_shared/devices.test.ts | 2 +- .../commands/auth/devices/_shared/devices.ts | 6 +- cli/src/commands/auth/login/index.ts | 4 +- cli/src/commands/auth/login/login.test.ts | 2 +- cli/src/commands/auth/login/login.ts | 4 +- cli/src/commands/auth/logout/index.ts | 6 +- cli/src/commands/auth/logout/logout.test.ts | 2 +- cli/src/commands/auth/logout/logout.ts | 4 +- cli/src/commands/auth/status/index.ts | 4 +- cli/src/commands/auth/status/status.test.ts | 2 +- cli/src/commands/auth/status/status.ts | 2 +- cli/src/commands/auth/whoami/index.ts | 4 +- cli/src/commands/auth/whoami/whoami.test.ts | 2 +- cli/src/commands/auth/whoami/whoami.ts | 2 +- cli/src/commands/config/get/index.ts | 4 +- cli/src/commands/config/get/run.test.ts | 17 +- cli/src/commands/config/get/run.ts | 9 +- cli/src/commands/config/path/index.ts | 2 +- cli/src/commands/config/set/index.ts | 4 +- cli/src/commands/config/set/run.test.ts | 29 +-- cli/src/commands/config/set/run.ts | 13 +- cli/src/commands/config/unset/index.ts | 4 +- cli/src/commands/config/unset/run.test.ts | 13 +- cli/src/commands/config/unset/run.ts | 13 +- cli/src/commands/config/view/index.ts | 4 +- cli/src/commands/config/view/run.test.ts | 23 ++- cli/src/commands/config/view/run.ts | 9 +- cli/src/commands/create/member/run.test.ts | 2 +- cli/src/commands/create/member/run.ts | 8 +- cli/src/commands/delete/member/run.test.ts | 2 +- cli/src/commands/delete/member/run.ts | 8 +- cli/src/commands/describe/app/run.test.ts | 6 +- cli/src/commands/describe/app/run.ts | 9 +- cli/src/commands/env/list/run-list.ts | 7 +- cli/src/commands/get/app/run.ts | 9 +- cli/src/commands/get/member/run.test.ts | 2 +- cli/src/commands/get/member/run.ts | 6 +- cli/src/commands/get/workspace/run.ts | 6 +- cli/src/commands/resume/app/run.ts | 9 +- .../app/_strategies/streaming-structured.ts | 6 +- .../run/app/_strategies/streaming-text.ts | 6 +- cli/src/commands/run/app/handlers.ts | 2 +- cli/src/commands/run/app/hitl-render.ts | 2 +- cli/src/commands/run/app/run.test.ts | 44 ++-- cli/src/commands/run/app/run.ts | 7 +- cli/src/commands/run/app/stream-handlers.ts | 4 +- cli/src/commands/set/member/run.test.ts | 2 +- cli/src/commands/set/member/run.ts | 8 +- cli/src/commands/use/workspace/use.test.ts | 2 +- cli/src/commands/use/workspace/use.ts | 6 +- cli/src/commands/version/index.ts | 6 +- .../{loader.test.ts => config-loader.test.ts} | 27 ++- cli/src/config/config-loader.ts | 42 ++++ cli/src/config/dir.test.ts | 71 ------- cli/src/config/dir.ts | 45 ---- cli/src/config/loader.ts | 58 ------ cli/src/config/writer.ts | 39 ---- cli/src/env/registry.ts | 5 +- cli/src/errors/format.ts | 2 +- .../config-writer.test.ts} | 38 ++-- cli/src/store/config-writer.ts | 8 + cli/src/store/dir.ts | 20 ++ cli/src/store/manager.ts | 28 +++ cli/src/store/store.test.ts | 193 ++++++++++++++++++ cli/src/store/store.ts | 165 +++++++++++++++ cli/src/sys/index.test.ts | 37 ++++ cli/src/sys/index.ts | 122 +++++++++++ cli/src/{ => sys}/io/color.ts | 0 cli/src/{ => sys}/io/spinner.ts | 0 cli/src/{ => sys}/io/streams.ts | 7 +- cli/src/{ => sys}/io/think-filter.test.ts | 0 cli/src/{ => sys}/io/think-filter.ts | 0 cli/src/util/browser.ts | 3 +- cli/src/version/info.ts | 3 +- cli/src/version/nudge.test.ts | 8 +- cli/src/version/nudge.ts | 2 +- cli/src/version/probe.test.ts | 9 +- cli/src/version/probe.ts | 7 +- cli/src/version/render.ts | 2 +- cli/tsconfig.json | 10 +- pnpm-lock.yaml | 31 +++ pnpm-workspace.yaml | 2 + 95 files changed, 1035 insertions(+), 579 deletions(-) rename cli/src/config/{loader.test.ts => config-loader.test.ts} (80%) create mode 100644 cli/src/config/config-loader.ts delete mode 100644 cli/src/config/dir.test.ts delete mode 100644 cli/src/config/dir.ts delete mode 100644 cli/src/config/loader.ts delete mode 100644 cli/src/config/writer.ts rename cli/src/{config/writer.test.ts => store/config-writer.test.ts} (67%) create mode 100644 cli/src/store/config-writer.ts create mode 100644 cli/src/store/dir.ts create mode 100644 cli/src/store/manager.ts create mode 100644 cli/src/store/store.test.ts create mode 100644 cli/src/store/store.ts create mode 100644 cli/src/sys/index.test.ts create mode 100644 cli/src/sys/index.ts rename cli/src/{ => sys}/io/color.ts (100%) rename cli/src/{ => sys}/io/spinner.ts (100%) rename cli/src/{ => sys}/io/streams.ts (89%) rename cli/src/{ => sys}/io/think-filter.test.ts (100%) rename cli/src/{ => sys}/io/think-filter.ts (100%) diff --git a/.github/workflows/cli-tests.yml b/.github/workflows/cli-tests.yml index 8cd053651a..4498afa416 100644 --- a/.github/workflows/cli-tests.yml +++ b/.github/workflows/cli-tests.yml @@ -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 diff --git a/cli/AGENTS.md b/cli/AGENTS.md index 0d579af2c7..96df6f2bdc 100644 --- a/cli/AGENTS.md +++ b/cli/AGENTS.md @@ -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 diff --git a/cli/package.json b/cli/package.json index 1b10986d7f..59286c2880 100644 --- a/cli/package.json +++ b/cli/package.json @@ -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:", diff --git a/cli/src/api/app-meta.test.ts b/cli/src/api/app-meta.test.ts index b1b2cf00ab..1ec9e4b698 100644 --- a/cli/src/api/app-meta.test.ts +++ b/cli/src/api/app-meta.test.ts @@ -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 }) diff --git a/cli/src/auth/file-backend.test.ts b/cli/src/auth/file-backend.test.ts index 65ee66f6a9..e633d1d724 100644 --- a/cli/src/auth/file-backend.test.ts +++ b/cli/src/auth/file-backend.test.ts @@ -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', () => { diff --git a/cli/src/auth/file-backend.ts b/cli/src/auth/file-backend.ts index 49bf4d44ed..0f8c2280c9 100644 --- a/cli/src/auth/file-backend.ts +++ b/cli/src/auth/file-backend.ts @@ -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' diff --git a/cli/src/auth/hosts.test.ts b/cli/src/auth/hosts.test.ts index 2bc1b2fea9..9f1c50fb25 100644 --- a/cli/src/auth/hosts.test.ts +++ b/cli/src/auth/hosts.test.ts @@ -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', () => { diff --git a/cli/src/auth/hosts.ts b/cli/src/auth/hosts.ts index fc90b3238c..f6504dd06c 100644 --- a/cli/src/auth/hosts.ts +++ b/cli/src/auth/hosts.ts @@ -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' diff --git a/cli/src/cache/app-info.test.ts b/cli/src/cache/app-info.test.ts index c562519790..6fcf53cc2f 100644 --- a/cli/src/cache/app-info.test.ts +++ b/cli/src/cache/app-info.test.ts @@ -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 } + const raw = await readFile(appInfoPath(dir), 'utf8') + const parsed = yaml.load(raw) as { entries: Record } expect(Object.keys(parsed.entries)).toHaveLength(1) }) }) diff --git a/cli/src/cache/app-info.ts b/cli/src/cache/app-info.ts index e6aef5a168..5d8ea27642 100644 --- a/cli/src/cache/app-info.ts +++ b/cli/src/cache/app-info.ts @@ -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 -} +// 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 } 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 { - const path = cachePath(opts.configDir) +export async function loadAppInfoCache(opts: AppInfoCacheOptions = {}): Promise { + const store = opts.store ?? getCache(CACHE_APP_INFO) const ttlMs = opts.ttlMs ?? APP_INFO_TTL_MS - const state: State = { entries: 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 { - let raw: string +function readEntries(store: Store): Map { + const out = new Map() + let raw: Record 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 { - 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): void { + const out: Record = {} + for (const [k, v] of entries) out[k] = serialize(v) + store.set(ENTRIES_KEY, out) } diff --git a/cli/src/cache/nudge-store.test.ts b/cli/src/cache/nudge-store.test.ts index 90068a1821..974b094620 100644 --- a/cli/src/cache/nudge-store.test.ts +++ b/cli/src/cache/nudge-store.test.ts @@ -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/ 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 - expect(parsed.schema).toBe(1) + const parsed = yaml.load(raw) as Record expect((parsed.warned as Record)[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) }) diff --git a/cli/src/cache/nudge-store.ts b/cli/src/cache/nudge-store.ts index 2a0d0ab994..e16c4a435f 100644 --- a/cli/src/cache/nudge-store.ts +++ b/cli/src/cache/nudge-store.ts @@ -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 } as const + export type NudgeStore = { readonly canWarn: (host: string, now?: Date) => boolean readonly markWarned: (host: string, now?: Date) => Promise } export type NudgeStoreOptions = { - readonly configDir: string + readonly store?: Store readonly now?: () => Date readonly intervalMs?: number } -type DiskShape = { - schema?: number - warned?: Record -} - -export function nudgeStorePath(configDir: string): string { - return join(configDir, 'cache', CACHE_FILE) -} - -export async function loadNudgeStore(opts: NudgeStoreOptions): Promise { - const path = nudgeStorePath(opts.configDir) +export async function loadNudgeStore(opts: NudgeStoreOptions = {}): Promise { + 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> { +function readWarned(store: Store): Map { const out = new Map() - let raw: string + let raw: Record 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> { return out } -async function persist(path: string, state: Map): Promise { - 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): void { + const warned: Record = {} 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) } diff --git a/cli/src/commands/_shared/authed-command.ts b/cli/src/commands/_shared/authed-command.ts index 97d8648f3f..67c1378f24 100644 --- a/cli/src/commands/_shared/authed-command.ts +++ b/cli/src/commands/_shared/authed-command.ts @@ -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 { 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 { try { - const store = await loadNudgeStore({ configDir: opts.configDir }) + const store = await loadNudgeStore() await maybeNudgeCompat(opts.host, { store, probe: async (host) => { diff --git a/cli/src/commands/auth/devices/_shared/devices.test.ts b/cli/src/commands/auth/devices/_shared/devices.test.ts index c7968268e6..5d96a6ca64 100644 --- a/cli/src/commands/auth/devices/_shared/devices.test.ts +++ b/cli/src/commands/auth/devices/_shared/devices.test.ts @@ -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 { diff --git a/cli/src/commands/auth/devices/_shared/devices.ts b/cli/src/commands/auth/devices/_shared/devices.ts index de2407e388..78a0cba73b 100644 --- a/cli/src/commands/auth/devices/_shared/devices.ts +++ b/cli/src/commands/auth/devices/_shared/devices.ts @@ -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 diff --git a/cli/src/commands/auth/login/index.ts b/cli/src/commands/auth/login/index.ts index ab8c32cd74..dadce6f990 100644 --- a/cli/src/commands/auth/login/index.ts +++ b/cli/src/commands/auth/login/index.ts @@ -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' diff --git a/cli/src/commands/auth/login/login.test.ts b/cli/src/commands/auth/login/login.test.ts index 522623982b..c2e4749a3f 100644 --- a/cli/src/commands/auth/login/login.test.ts +++ b/cli/src/commands/auth/login/login.test.ts @@ -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 = { diff --git a/cli/src/commands/auth/login/login.ts b/cli/src/commands/auth/login/login.ts index de05f52997..77a5c00b94 100644 --- a/cli/src/commands/auth/login/login.ts +++ b/cli/src/commands/auth/login/login.ts @@ -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' diff --git a/cli/src/commands/auth/logout/index.ts b/cli/src/commands/auth/logout/index.ts index c11ca97284..7915abb242 100644 --- a/cli/src/commands/auth/logout/index.ts +++ b/cli/src/commands/auth/logout/index.ts @@ -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' diff --git a/cli/src/commands/auth/logout/logout.test.ts b/cli/src/commands/auth/logout/logout.test.ts index 4fd3f53e8b..73bd8429bb 100644 --- a/cli/src/commands/auth/logout/logout.test.ts +++ b/cli/src/commands/auth/logout/logout.test.ts @@ -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 { diff --git a/cli/src/commands/auth/logout/logout.ts b/cli/src/commands/auth/logout/logout.ts index 48660b6b35..ddcee3b5d4 100644 --- a/cli/src/commands/auth/logout/logout.ts +++ b/cli/src/commands/auth/logout/logout.ts @@ -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 diff --git a/cli/src/commands/auth/status/index.ts b/cli/src/commands/auth/status/index.ts index c779595d1f..57208b93f4 100644 --- a/cli/src/commands/auth/status/index.ts +++ b/cli/src/commands/auth/status/index.ts @@ -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' diff --git a/cli/src/commands/auth/status/status.test.ts b/cli/src/commands/auth/status/status.test.ts index 0000e9cd59..f039d54866 100644 --- a/cli/src/commands/auth/status/status.test.ts +++ b/cli/src/commands/auth/status/status.test.ts @@ -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 { diff --git a/cli/src/commands/auth/status/status.ts b/cli/src/commands/auth/status/status.ts index c666b08b0a..83ca626827 100644 --- a/cli/src/commands/auth/status/status.ts +++ b/cli/src/commands/auth/status/status.ts @@ -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' diff --git a/cli/src/commands/auth/whoami/index.ts b/cli/src/commands/auth/whoami/index.ts index dbf51fb1e3..a89a6e76bf 100644 --- a/cli/src/commands/auth/whoami/index.ts +++ b/cli/src/commands/auth/whoami/index.ts @@ -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' diff --git a/cli/src/commands/auth/whoami/whoami.test.ts b/cli/src/commands/auth/whoami/whoami.test.ts index f38a4b634f..98ea0a9bcb 100644 --- a/cli/src/commands/auth/whoami/whoami.test.ts +++ b/cli/src/commands/auth/whoami/whoami.test.ts @@ -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 { diff --git a/cli/src/commands/auth/whoami/whoami.ts b/cli/src/commands/auth/whoami/whoami.ts index fca750ae86..908daaddec 100644 --- a/cli/src/commands/auth/whoami/whoami.ts +++ b/cli/src/commands/auth/whoami/whoami.ts @@ -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' diff --git a/cli/src/commands/config/get/index.ts b/cli/src/commands/config/get/index.ts index 1505077f98..d02fcdb18d 100644 --- a/cli/src/commands/config/get/index.ts +++ b/cli/src/commands/config/get/index.ts @@ -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 })) } } diff --git a/cli/src/commands/config/get/run.test.ts b/cli/src/commands/config/get/run.test.ts index 7274a6e624..5f594bd7de 100644 --- a/cli/src/commands/config/get/run.test.ts +++ b/cli/src/commands/config/get/run.test.ts @@ -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') }) }) diff --git a/cli/src/commands/config/get/run.ts b/cli/src/commands/config/get/run.ts index 0f43213318..8fb486e60b 100644 --- a/cli/src/commands/config/get/run.ts +++ b/cli/src/commands/config/get/run.ts @@ -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 { - 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` } diff --git a/cli/src/commands/config/path/index.ts b/cli/src/commands/config/path/index.ts index 1f529ec385..466aa6a6db 100644 --- a/cli/src/commands/config/path/index.ts +++ b/cli/src/commands/config/path/index.ts @@ -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' diff --git a/cli/src/commands/config/set/index.ts b/cli/src/commands/config/set/index.ts index b8f22eed2b..d747a8783b 100644 --- a/cli/src/commands/config/set/index.ts +++ b/cli/src/commands/config/set/index.ts @@ -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 })) } } diff --git a/cli/src/commands/config/set/run.test.ts b/cli/src/commands/config/set/run.test.ts index 959b331344..54be290271 100644 --- a/cli/src/commands/config/set/run.test.ts +++ b/cli/src/commands/config/set/run.test.ts @@ -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) diff --git a/cli/src/commands/config/set/run.ts b/cli/src/commands/config/set/run.ts index d59b065a4d..c7a1e752b5 100644 --- a/cli/src/commands/config/set/run.ts +++ b/cli/src/commands/config/set/run.ts @@ -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 { - 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` } diff --git a/cli/src/commands/config/unset/index.ts b/cli/src/commands/config/unset/index.ts index f1e9a48be3..a7e7d08096 100644 --- a/cli/src/commands/config/unset/index.ts +++ b/cli/src/commands/config/unset/index.ts @@ -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 })) } } diff --git a/cli/src/commands/config/unset/run.test.ts b/cli/src/commands/config/unset/run.test.ts index e67753149d..53fbce6735 100644 --- a/cli/src/commands/config/unset/run.test.ts +++ b/cli/src/commands/config/unset/run.test.ts @@ -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) diff --git a/cli/src/commands/config/unset/run.ts b/cli/src/commands/config/unset/run.ts index 8bd0a512a5..377013ff0f 100644 --- a/cli/src/commands/config/unset/run.ts +++ b/cli/src/commands/config/unset/run.ts @@ -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 { - 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` } diff --git a/cli/src/commands/config/view/index.ts b/cli/src/commands/config/view/index.ts index 89401f4497..f9e216ade1 100644 --- a/cli/src/commands/config/view/index.ts +++ b/cli/src/commands/config/view/index.ts @@ -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 })) } } diff --git a/cli/src/commands/config/view/run.test.ts b/cli/src/commands/config/view/run.test.ts index b3bc93115e..4716aad2f4 100644 --- a/cli/src/commands/config/view/run.test.ts +++ b/cli/src/commands/config/view/run.test.ts @@ -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 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) }) }) diff --git a/cli/src/commands/config/view/run.ts b/cli/src/commands/config/view/run.ts index bda070ef46..78b9e1ca52 100644 --- a/cli/src/commands/config/view/run.ts +++ b/cli/src/commands/config/view/run.ts @@ -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 -export async function runConfigView(opts: RunConfigViewOptions): Promise { - 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) diff --git a/cli/src/commands/create/member/run.test.ts b/cli/src/commands/create/member/run.test.ts index 6086797d11..5c739f5a2a 100644 --- a/cli/src/commands/create/member/run.test.ts +++ b/cli/src/commands/create/member/run.test.ts @@ -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 { diff --git a/cli/src/commands/create/member/run.ts b/cli/src/commands/create/member/run.ts index f766124412..0608b2fb7b 100644 --- a/cli/src/commands/create/member/run.ts +++ b/cli/src/commands/create/member/run.ts @@ -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' diff --git a/cli/src/commands/delete/member/run.test.ts b/cli/src/commands/delete/member/run.test.ts index 27cdd347cf..15a4f66db2 100644 --- a/cli/src/commands/delete/member/run.test.ts +++ b/cli/src/commands/delete/member/run.test.ts @@ -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 { diff --git a/cli/src/commands/delete/member/run.ts b/cli/src/commands/delete/member/run.ts index 834eff0526..f89afe86c7 100644 --- a/cli/src/commands/delete/member/run.ts +++ b/cli/src/commands/delete/member/run.ts @@ -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' diff --git a/cli/src/commands/describe/app/run.test.ts b/cli/src/commands/describe/app/run.test.ts index 5e35a0986f..d769d5db3f 100644 --- a/cli/src/commands/describe/app/run.test.ts +++ b/cli/src/commands/describe/app/run.test.ts @@ -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[0]): Promise { - 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 }, diff --git a/cli/src/commands/describe/app/run.ts b/cli/src/commands/describe/app/run.ts index 089274b8f1..f332cc992a 100644 --- a/cli/src/commands/describe/app/run.ts +++ b/cli/src/commands/describe/app/run.ts @@ -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 { - 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 }) diff --git a/cli/src/commands/env/list/run-list.ts b/cli/src/commands/env/list/run-list.ts index 2fce948fc5..379c81d4af 100644 --- a/cli/src/commands/env/list/run-list.ts +++ b/cli/src/commands/env/list/run-list.ts @@ -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] -} diff --git a/cli/src/commands/get/app/run.ts b/cli/src/commands/get/app/run.ts index 4bfb300fb1..ccb091db57 100644 --- a/cli/src/commands/get/app/run.ts +++ b/cli/src/commands/get/app/run.ts @@ -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 { - 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)) diff --git a/cli/src/commands/get/member/run.test.ts b/cli/src/commands/get/member/run.test.ts index 72ae4c45b0..d32b172eb9 100644 --- a/cli/src/commands/get/member/run.test.ts +++ b/cli/src/commands/get/member/run.test.ts @@ -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 { diff --git a/cli/src/commands/get/member/run.ts b/cli/src/commands/get/member/run.ts index 844ac098ef..011cdb1572 100644 --- a/cli/src/commands/get/member/run.ts +++ b/cli/src/commands/get/member/run.ts @@ -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' diff --git a/cli/src/commands/get/workspace/run.ts b/cli/src/commands/get/workspace/run.ts index f2015f4817..f3b86f3c1d 100644 --- a/cli/src/commands/get/workspace/run.ts +++ b/cli/src/commands/get/workspace/run.ts @@ -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 diff --git a/cli/src/commands/resume/app/run.ts b/cli/src/commands/resume/app/run.ts index 06280ebaad..bcd109b21f 100644 --- a/cli/src/commands/resume/app/run.ts +++ b/cli/src/commands/resume/app/run.ts @@ -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 { - 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) { diff --git a/cli/src/commands/run/app/_strategies/streaming-structured.ts b/cli/src/commands/run/app/_strategies/streaming-structured.ts index b85db1d808..b59550ca6c 100644 --- a/cli/src/commands/run/app/_strategies/streaming-structured.ts +++ b/cli/src/commands/run/app/_strategies/streaming-structured.ts @@ -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' diff --git a/cli/src/commands/run/app/_strategies/streaming-text.ts b/cli/src/commands/run/app/_strategies/streaming-text.ts index 6a5405a918..6f88e394dd 100644 --- a/cli/src/commands/run/app/_strategies/streaming-text.ts +++ b/cli/src/commands/run/app/_strategies/streaming-text.ts @@ -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) } } } diff --git a/cli/src/commands/run/app/handlers.ts b/cli/src/commands/run/app/handlers.ts index 2cc11b026c..b22bfec58d 100644 --- a/cli/src/commands/run/app/handlers.ts +++ b/cli/src/commands/run/app/handlers.ts @@ -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', diff --git a/cli/src/commands/run/app/hitl-render.ts b/cli/src/commands/run/app/hitl-render.ts index f9c3ed6ac9..da02ecf5cb 100644 --- a/cli/src/commands/run/app/hitl-render.ts +++ b/cli/src/commands/run/app/hitl-render.ts @@ -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' diff --git a/cli/src/commands/run/app/run.test.ts b/cli/src/commands/run/app/run.test.ts index a778abc078..5af12e2a41 100644 --- a/cli/src/commands/run/app/run.test.ts +++ b/cli/src/commands/run/app/run.test.ts @@ -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 }, diff --git a/cli/src/commands/run/app/run.ts b/cli/src/commands/run/app/run.ts index d63787090e..eb9a2e4d53 100644 --- a/cli/src/commands/run/app/run.ts +++ b/cli/src/commands/run/app/run.ts @@ -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 { - 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 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) } diff --git a/cli/src/commands/run/app/stream-handlers.ts b/cli/src/commands/run/app/stream-handlers.ts index a54dbfe54b..53dc746602 100644 --- a/cli/src/commands/run/app/stream-handlers.ts +++ b/cli/src/commands/run/app/stream-handlers.ts @@ -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' diff --git a/cli/src/commands/set/member/run.test.ts b/cli/src/commands/set/member/run.test.ts index 4835e558c8..ff987b815f 100644 --- a/cli/src/commands/set/member/run.test.ts +++ b/cli/src/commands/set/member/run.test.ts @@ -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 { diff --git a/cli/src/commands/set/member/run.ts b/cli/src/commands/set/member/run.ts index 7f4558506c..f77e09d4e6 100644 --- a/cli/src/commands/set/member/run.ts +++ b/cli/src/commands/set/member/run.ts @@ -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' diff --git a/cli/src/commands/use/workspace/use.test.ts b/cli/src/commands/use/workspace/use.test.ts index 56199b76ef..b3c78988f4 100644 --- a/cli/src/commands/use/workspace/use.test.ts +++ b/cli/src/commands/use/workspace/use.test.ts @@ -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 { diff --git a/cli/src/commands/use/workspace/use.ts b/cli/src/commands/use/workspace/use.ts index 27aa001174..b97b9dd224 100644 --- a/cli/src/commands/use/workspace/use.ts +++ b/cli/src/commands/use/workspace/use.ts @@ -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 diff --git a/cli/src/commands/version/index.ts b/cli/src/commands/version/index.ts index b91169b1d4..01d398a001 100644 --- a/cli/src/commands/version/index.ts +++ b/cli/src/commands/version/index.ts @@ -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 }) } diff --git a/cli/src/config/loader.test.ts b/cli/src/config/config-loader.test.ts similarity index 80% rename from cli/src/config/loader.test.ts rename to cli/src/config/config-loader.test.ts index da7bac2c1f..54e3d5d027 100644 --- a/cli/src/config/loader.test.ts +++ b/cli/src/config/config-loader.test.ts @@ -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) diff --git a/cli/src/config/config-loader.ts b/cli/src/config/config-loader.ts new file mode 100644 index 0000000000..c260d49d8e --- /dev/null +++ b/cli/src/config/config-loader.ts @@ -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 | null + try { + raw = store.getTyped>() + } + 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 } +} diff --git a/cli/src/config/dir.test.ts b/cli/src/config/dir.test.ts deleted file mode 100644 index 24ecde3986..0000000000 --- a/cli/src/config/dir.test.ts +++ /dev/null @@ -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') - }) -}) diff --git a/cli/src/config/dir.ts b/cli/src/config/dir.ts deleted file mode 100644 index 6d92953769..0000000000 --- a/cli/src/config/dir.ts +++ /dev/null @@ -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) -} diff --git a/cli/src/config/loader.ts b/cli/src/config/loader.ts deleted file mode 100644 index 8ff00b3631..0000000000 --- a/cli/src/config/loader.ts +++ /dev/null @@ -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 { - 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 } -} diff --git a/cli/src/config/writer.ts b/cli/src/config/writer.ts deleted file mode 100644 index 8362ebf884..0000000000 --- a/cli/src/config/writer.ts +++ /dev/null @@ -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 { - 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) - } -} diff --git a/cli/src/env/registry.ts b/cli/src/env/registry.ts index 5a7938e01b..7e257364ee 100644 --- a/cli/src/env/registry.ts +++ b/cli/src/env/registry.ts @@ -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) diff --git a/cli/src/errors/format.ts b/cli/src/errors/format.ts index a65b466f56..4b80f08900 100644 --- a/cli/src/errors/format.ts +++ b/cli/src/errors/format.ts @@ -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 = { diff --git a/cli/src/config/writer.test.ts b/cli/src/store/config-writer.test.ts similarity index 67% rename from cli/src/config/writer.test.ts rename to cli/src/store/config-writer.test.ts index 0fb08f70de..1463699193 100644 --- a/cli/src/config/writer.test.ts +++ b/cli/src/store/config-writer.test.ts @@ -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: {}, diff --git a/cli/src/store/config-writer.ts b/cli/src/store/config-writer.ts new file mode 100644 index 0000000000..79b8a23d65 --- /dev/null +++ b/cli/src/store/config-writer.ts @@ -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) +} diff --git a/cli/src/store/dir.ts b/cli/src/store/dir.ts new file mode 100644 index 0000000000..c75e1dbdfd --- /dev/null +++ b/cli/src/store/dir.ts @@ -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() +} diff --git a/cli/src/store/manager.ts b/cli/src/store/manager.ts new file mode 100644 index 0000000000..76e116b917 --- /dev/null +++ b/cli/src/store/manager.ts @@ -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)) +} diff --git a/cli/src/store/store.test.ts b/cli/src/store/store.test.ts new file mode 100644 index 0000000000..3f0c3de1e7 --- /dev/null +++ b/cli/src/store/store.test.ts @@ -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') + }) +}) diff --git a/cli/src/store/store.ts b/cli/src/store/store.ts new file mode 100644 index 0000000000..f1cb8a2302 --- /dev/null +++ b/cli/src/store/store.ts @@ -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 = { + default: T + key: string +} + +export type Store = { + get: (key: Key) => T + set: (key: Key, 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(body: () => R): R { + this.lock() + try { + this.load() + return body() + } + finally { + this.unlock() + } + } + + get(key: Key): T { + return this.withLock(() => this.doGet(key)) + } + + set(key: Key, value: T) { + this.withLock(() => { + this.doSet(key, value) + this.flush() + }) + } + + abstract doGet(key: Key): T + abstract doSet(key: Key, value: T): void +} + +export class YamlStore extends FileBasedStore { + constructor(file_path: string) { + super(file_path) + } + + doGet(key: Key): 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)[part] + } + return (current as T) ?? key.default + } + + getTyped(): T | null { + return this.withLock(() => { + this.load() + return loadYaml(this.raw_content) as T + }) + } + + setTyped(data: T): void { + this.withLock(() => { + this.raw_content = yaml.dump(data, { lineWidth: -1, noRefs: true }) + this.flush() + }) + } + + doSet(key: Key, 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 = data + for (const part of parts) { + if (current[part] === null || current[part] === undefined || typeof current[part] !== 'object') + current[part] = {} + current = current[part] as Record + } + current[lastKey] = value + this.raw_content = yaml.dump(data, { lineWidth: -1, noRefs: true }) + } +} + +function loadYaml(raw: string | undefined): Record | null { + if (raw === undefined) + return null + return (yaml.load(raw) ?? {}) as Record +} diff --git a/cli/src/sys/index.test.ts b/cli/src/sys/index.test.ts new file mode 100644 index 0000000000..6aeaff19cf --- /dev/null +++ b/cli/src/sys/index.test.ts @@ -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') + }) +}) diff --git a/cli/src/sys/index.ts b/cli/src/sys/index.ts new file mode 100644 index 0000000000..3faeaf15d9 --- /dev/null +++ b/cli/src/sys/index.ts @@ -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> = { + 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)() +} diff --git a/cli/src/io/color.ts b/cli/src/sys/io/color.ts similarity index 100% rename from cli/src/io/color.ts rename to cli/src/sys/io/color.ts diff --git a/cli/src/io/spinner.ts b/cli/src/sys/io/spinner.ts similarity index 100% rename from cli/src/io/spinner.ts rename to cli/src/sys/io/spinner.ts diff --git a/cli/src/io/streams.ts b/cli/src/sys/io/streams.ts similarity index 89% rename from cli/src/io/streams.ts rename to cli/src/sys/io/streams.ts index a51f630f62..62f215f31c 100644 --- a/cli/src/io/streams.ts +++ b/cli/src/sys/io/streams.ts @@ -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(), } } diff --git a/cli/src/io/think-filter.test.ts b/cli/src/sys/io/think-filter.test.ts similarity index 100% rename from cli/src/io/think-filter.test.ts rename to cli/src/sys/io/think-filter.test.ts diff --git a/cli/src/io/think-filter.ts b/cli/src/sys/io/think-filter.ts similarity index 100% rename from cli/src/io/think-filter.ts rename to cli/src/sys/io/think-filter.ts diff --git a/cli/src/util/browser.ts b/cli/src/util/browser.ts index 3a272cc77a..5ec813dd77 100644 --- a/cli/src/util/browser.ts +++ b/cli/src/util/browser.ts @@ -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), } diff --git a/cli/src/version/info.ts b/cli/src/version/info.ts index 5f4b6245e9..c40484bbc3 100644 --- a/cli/src/version/info.ts +++ b/cli/src/version/info.ts @@ -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})` } diff --git a/cli/src/version/nudge.test.ts b/cli/src/version/nudge.test.ts index 2eeaa32050..a581d40700 100644 --- a/cli/src/version/nudge.test.ts +++ b/cli/src/version/nudge.test.ts @@ -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() diff --git a/cli/src/version/nudge.ts b/cli/src/version/nudge.ts index f5c9866f92..ad4c0fdde0 100644 --- a/cli/src/version/nudge.ts +++ b/cli/src/version/nudge.ts @@ -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 — diff --git a/cli/src/version/probe.test.ts b/cli/src/version/probe.test.ts index 28e4335b12..77a20ef2e4 100644 --- a/cli/src/version/probe.test.ts +++ b/cli/src/version/probe.test.ts @@ -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 { @@ -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()) }) }) diff --git a/cli/src/version/probe.ts b/cli/src/version/probe.ts index 25af8b3d61..09fc373661 100644 --- a/cli/src/version/probe.ts +++ b/cli/src/version/probe.ts @@ -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(), } } diff --git a/cli/src/version/render.ts b/cli/src/version/render.ts index 1398622df2..2777eca1d3 100644 --- a/cli/src/version/render.ts +++ b/cli/src/version/render.ts @@ -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,', diff --git a/cli/tsconfig.json b/cli/tsconfig.json index dc04c33f30..41b24e8690 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -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"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d075b8335f..c0aae05e1a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5795e55a81..d88119c370 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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