diff --git a/cli/ARD.md b/cli/ARD.md index de7a4b359f..11284ae577 100644 --- a/cli/ARD.md +++ b/cli/ARD.md @@ -47,13 +47,12 @@ Examples: `get/app/`, `auth/devices/revoke/`, `describe/app/`. **3. Optional files — add as needed** -| File | Purpose | -| ------------------ | --------------------------------------------------- | -| `handlers.ts` | Output format handlers (text, table, etc.) | -| `print-flags.ts` | `--output` flag → printer resolution | -| `payload-shape.ts` | Response type narrowing/transformation | -| `run.test.ts` | Behavior tests against `run.ts` | -| `guide.ts` | Agent onboarding text — exports `agentGuide` string | +| File | Purpose | +| ------------------ | ------------------------------------------------------------------ | +| `handlers.ts` | Output types implementing `FormattedPrintable` or `TablePrintable` | +| `payload-shape.ts` | Response type narrowing/transformation | +| `run.test.ts` | Behavior tests against `run.ts` | +| `guide.ts` | Agent onboarding text — exports `agentGuide` string | **4. Checklist** @@ -163,21 +162,28 @@ Only override `enabled` for intentional suppression (e.g., tests using `bufferSt --- -## Printer chain +## Output protocol -Output rendering separated from data fetching. +Output rendering separated from data fetching via protocol objects. -1. `run.ts` returns string — rendered result. -1. `handlers.ts` defines format handlers (`TextHandler`, `TableHandler`, etc.). -1. `print-flags.ts` maps `--output` value to correct handler. +- Data classes implement `TablePrintable` or `FormattedPrintable` from `src/framework/output`. +- Streaming commands implement `StreamPrinter` from `src/framework/stream`. +- `index.ts` wraps the result with `table({format, data})` or `formatted({format, data})` and returns it; the base class calls `stringifyOutput()`. +- Commands that write incrementally (streaming) write directly from the strategy via `deps.io.out.write(stringifyOutput(...))`. ```typescript -// run.ts -const printer = new AppPrintFlags().toPrinter(format) -return printer.print(data) +// handlers.ts — implement the protocol on the data object +export class MyListOutput implements TablePrintable { + tableColumns() { return COLUMNS } + tableRows() { return this.rows.map(r => r.tableRow()) } + json() { return { items: this.rows.map(r => r.json()) } } +} + +// index.ts — wrap and return +return table({ format: flags.output, data: result }) ``` -New output format: implement handler interface, register in `print-flags.ts`. Never add `if (format === 'json')` branches in `run.ts`. +New output format: add to `OutputFormat` in `framework/output.ts` and handle in `stringifyOutput`. Never add `if (format === 'json')` branches in `run.ts` or handlers. --- @@ -190,14 +196,11 @@ export type RunStrategy = { execute: (ctx: RunContext) => Promise } -const blocking = new BlockingStrategy() const streamingText = new StreamingTextStrategy() const streamingStructured = new StreamingStructuredStrategy() -export function pickStrategy(useStream: boolean, isText: boolean): RunStrategy { - if (!useStream) - return blocking - return isText ? streamingText : streamingStructured +export function pickStrategy(isText: boolean, livePrint: boolean): RunStrategy { + return isText && livePrint ? streamingText : streamingStructured } ``` @@ -329,17 +332,17 @@ Repo runs `@antfu/eslint-config` + perfectionist + unicorn. ## Anti-patterns -| Pattern | Do instead | -| -------------------------------------------------------------------- | ------------------------------------------------------- | -| `if (format === 'json') { ... }` in `run.ts` | Printer handler per format | -| `try { ... } catch (e) { if (isBaseError(e)) ... }` in every command | Throw `BaseError`; `DifyCommand.catch()` handles | -| Raw string error codes `'not_logged_in'` | `ErrorCode.NotLoggedIn` | -| `enabled: !isHuman` in `runWithSpinner` | Set `outputFormat` on `IOStreams`; spinner auto-detects | -| Long positional arg lists | Options struct | -| `Record` dispatch map | Named singletons + picker function | -| `src/framework/` import in `run.ts` | Keep framework imports in `index.ts` only | -| `buildAuthedContext(this, opts)` in command body | `this.authedCtx(opts)` | -| `console.log` in `src/` | Return string from `run.ts`; write in `index.ts` | -| New dependency without approval | Check first | +| Pattern | Do instead | +| -------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `if (format === 'json') { ... }` in `run.ts` | Printer handler per format | +| `try { ... } catch (e) { if (isBaseError(e)) ... }` in every command | Throw `BaseError`; `DifyCommand.catch()` handles | +| Raw string error codes `'not_logged_in'` | `ErrorCode.NotLoggedIn` | +| `enabled: !isHuman` in `runWithSpinner` | Set `outputFormat` on `IOStreams`; spinner auto-detects | +| Long positional arg lists | Options struct | +| `Record` dispatch map | Named singletons + picker function | +| `src/framework/` import in `run.ts`, `api/`, or `auth/` | Framework imports belong in `index.ts`, `handlers.ts`, and strategies only | +| `buildAuthedContext(this, opts)` in command body | `this.authedCtx(opts)` | +| `console.log` in `src/` | Return string from `run.ts`; write in `index.ts` | +| New dependency without approval | Check first | [`docs/specs/`]: docs/specs/ diff --git a/cli/eslint.config.mjs b/cli/eslint.config.mjs index b87632a120..697368ac5c 100644 --- a/cli/eslint.config.mjs +++ b/cli/eslint.config.mjs @@ -58,6 +58,12 @@ export default antfu( 'node/prefer-global/process': 'off', }, }, + { + files: ['bin/**'], + rules: { + 'antfu/no-top-level-await': 'off', + }, + }, { files: ['src/**/*.ts'], rules: { diff --git a/cli/src/auth/hosts.test.ts b/cli/src/auth/hosts.test.ts index 9630b309b6..e4083cf001 100644 --- a/cli/src/auth/hosts.test.ts +++ b/cli/src/auth/hosts.test.ts @@ -1,131 +1,202 @@ +import type { AccountContext } from './hosts' +import type { Key, Store } from '@/store/store' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { ENV_CONFIG_DIR } from '@/store/dir' -import { HostsBundleSchema, loadHosts, saveHosts } from './hosts' +import { AccountContextSchema, notLoggedInError, Registry, RegistrySchema } from './hosts' -describe('HostsBundleSchema', () => { - it('parses a minimal logged-out bundle', () => { - const parsed = HostsBundleSchema.parse({}) - expect(parsed.current_host).toBe('') - expect(parsed.token_storage).toBe('file') +describe('RegistrySchema', () => { + it('parses an empty registry with defaults', () => { + const reg = RegistrySchema.parse({}) + expect(reg.token_storage).toBe('file') + expect(reg.current_host).toBeUndefined() + expect(reg.hosts).toEqual({}) }) - it('parses a logged-in keychain bundle', () => { - const parsed = HostsBundleSchema.parse({ - current_host: 'cloud.dify.ai', - account: { id: 'acct-1', email: 'a@b.c', name: 'A' }, - workspace: { id: 'ws-1', name: 'My Space', role: 'owner' }, + it('parses a populated multi-host registry', () => { + const reg = RegistrySchema.parse({ token_storage: 'keychain', - token_id: 'tok_xyz', + current_host: 'cloud.dify.ai', + hosts: { + 'cloud.dify.ai': { + current_account: 'bob@corp.com', + accounts: { + 'bob@corp.com': { + account: { id: 'acct-1', email: 'bob@corp.com', name: 'Bob' }, + workspace: { id: 'ws-1', name: 'Space', role: 'owner' }, + token_id: 'tok_1', + }, + }, + }, + }, }) - expect(parsed.token_storage).toBe('keychain') - expect(parsed.tokens).toBeUndefined() + expect(reg.current_host).toBe('cloud.dify.ai') + expect(reg.hosts['cloud.dify.ai']?.current_account).toBe('bob@corp.com') + expect(reg.hosts['cloud.dify.ai']?.accounts['bob@corp.com']?.account.name).toBe('Bob') }) - it('parses a logged-in file bundle with bearer', () => { - const parsed = HostsBundleSchema.parse({ - current_host: 'cloud.dify.ai', - token_storage: 'file', - tokens: { bearer: 'dfoa_xxx' }, - }) - expect(parsed.tokens?.bearer).toBe('dfoa_xxx') + it('defaults a host entry accounts map to {}', () => { + const reg = RegistrySchema.parse({ hosts: { h: { current_account: 'x' } } }) + expect(reg.hosts.h?.accounts).toEqual({}) }) it('rejects unknown token_storage values', () => { - expect(() => HostsBundleSchema.parse({ token_storage: 'cloud' })).toThrow() + expect(() => RegistrySchema.parse({ token_storage: 'cloud' })).toThrow() }) - it('keeps available_workspaces when provided', () => { - const parsed = HostsBundleSchema.parse({ - available_workspaces: [ - { id: 'a', name: 'A', role: 'owner' }, - { id: 'b', name: 'B', role: 'member' }, - ], + it('AccountContextSchema keeps optional external_subject', () => { + const ctx = AccountContextSchema.parse({ + account: { id: '', email: 'sso@x.io', name: '' }, + external_subject: { email: 'sso@x.io', issuer: 'https://issuer' }, }) - expect(parsed.available_workspaces).toHaveLength(2) - }) - - it('drops unknown top-level fields on parse', () => { - const parsed = HostsBundleSchema.parse({ - current_host: 'cloud.dify.ai', - future_field: 42, - token_storage: 'file', - }) - expect(parsed.current_host).toBe('cloud.dify.ai') - expect((parsed as Record).future_field).toBeUndefined() + expect(ctx.external_subject?.issuer).toBe('https://issuer') }) }) -describe('loadHosts/saveHosts', () => { - let dir: string - let prevConfigDir: string | undefined +describe('notLoggedInError', () => { + it('carries the default hint', () => { + expect(notLoggedInError().toString()).toMatch(/auth login/) + }) + it('accepts a custom hint', () => { + expect(notLoggedInError('run \'difyctl use host\'').toString()).toMatch(/use host/) + }) +}) - beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), 'difyctl-hosts-')) - prevConfigDir = process.env[ENV_CONFIG_DIR] - process.env[ENV_CONFIG_DIR] = dir +describe('Registry (pure)', () => { + const baseReg = (): Registry => Registry.empty('file') + const ctx = (email: string): AccountContext => ({ account: { id: `id-${email}`, email, name: email } }) + + it('upsert creates host + account; remove drops them', () => { + const reg = baseReg() + reg.upsert('h1', 'a@x', ctx('a@x')) + reg.upsert('h1', 'b@x', ctx('b@x')) + expect(reg.hosts.h1?.accounts['a@x']?.account.email).toBe('a@x') + reg.remove('h1', 'a@x') + expect(reg.hosts.h1?.accounts['a@x']).toBeUndefined() + expect(reg.hosts.h1?.accounts['b@x']).toBeDefined() + reg.remove('h1', 'b@x') + expect(reg.hosts.h1).toBeUndefined() }) + it('setHost / setAccount set pointers', () => { + const reg = baseReg() + reg.upsert('h1', 'a@x', ctx('a@x')) + reg.setHost('h1') + reg.setAccount('a@x') + expect(reg.current_host).toBe('h1') + expect(reg.hosts.h1?.current_account).toBe('a@x') + }) + + it('resolveActive returns the active context with scheme', () => { + const reg = baseReg() + reg.upsert('h1', 'a@x', ctx('a@x')) + reg.setScheme('h1', 'http') + reg.setHost('h1') + reg.setAccount('a@x') + const active = reg.resolveActive() + expect(active?.host).toBe('h1') + expect(active?.email).toBe('a@x') + expect(active?.scheme).toBe('http') + expect(active?.ctx.account.email).toBe('a@x') + }) + + it('resolveActive returns undefined for each missing pointer', () => { + const reg = baseReg() + expect(reg.resolveActive()).toBeUndefined() + reg.upsert('h1', 'a@x', ctx('a@x')) + reg.setHost('missing') + expect(reg.resolveActive()).toBeUndefined() + reg.setHost('h1') + expect(reg.resolveActive()).toBeUndefined() + reg.setAccount('missing@x') + expect(reg.resolveActive()).toBeUndefined() + }) + + it('remove unsets pointers when removing the active account', () => { + const reg = baseReg() + reg.upsert('h1', 'a@x', ctx('a@x')) + reg.setHost('h1') + reg.setAccount('a@x') + reg.remove('h1', 'a@x') + expect(reg.current_host).toBeUndefined() + expect(reg.resolveActive()).toBeUndefined() + }) +}) + +describe('Registry.load / Registry.save', () => { + let dir: string + let prev: string | undefined + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'difyctl-reg-')) + prev = process.env[ENV_CONFIG_DIR] + process.env[ENV_CONFIG_DIR] = dir + }) afterEach(async () => { - if (prevConfigDir === undefined) + if (prev === undefined) delete process.env[ENV_CONFIG_DIR] - else - process.env[ENV_CONFIG_DIR] = prevConfigDir + else process.env[ENV_CONFIG_DIR] = prev await rm(dir, { recursive: true, force: true }) }) - it('returns undefined when nothing was saved', () => { - expect(loadHosts()).toBeUndefined() + it('returns an empty registry when nothing saved', () => { + const reg = Registry.load() + expect(reg.current_host).toBeUndefined() + expect(Object.keys(reg.hosts)).toHaveLength(0) }) - it('round-trips a fully-populated bundle', () => { - saveHosts({ - current_host: 'cloud.dify.ai', - scheme: 'https', - account: { id: 'acct-1', email: 'a@b.c', name: 'A' }, - workspace: { id: 'ws-1', name: 'My Space', role: 'owner' }, - available_workspaces: [ - { id: 'ws-1', name: 'My Space', role: 'owner' }, - { id: 'ws-2', name: 'Other', role: 'normal' }, - ], - token_storage: 'keychain', - token_id: 'tok_xyz', - }) - const loaded = loadHosts() + it('round-trips a populated registry', () => { + const reg = Registry.empty('keychain') + reg.upsert('cloud.dify.ai', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } }) + reg.setHost('cloud.dify.ai') + reg.setAccount('a@x') + reg.save() + const loaded = Registry.load() expect(loaded?.current_host).toBe('cloud.dify.ai') - expect(loaded?.scheme).toBe('https') - expect(loaded?.account?.email).toBe('a@b.c') - expect(loaded?.workspace?.id).toBe('ws-1') - expect(loaded?.available_workspaces).toHaveLength(2) - expect(loaded?.token_storage).toBe('keychain') - expect(loaded?.token_id).toBe('tok_xyz') - }) - - it('round-trips a file-mode bundle with bearer token', () => { - saveHosts({ - current_host: 'self.example.com', - token_storage: 'file', - tokens: { bearer: 'dfoa_test' }, - }) - const loaded = loadHosts() - expect(loaded?.tokens?.bearer).toBe('dfoa_test') - expect(loaded?.token_storage).toBe('file') - }) - - it('overwrites previous bundle on save', () => { - saveHosts({ current_host: 'old.example.com', token_storage: 'file' }) - saveHosts({ current_host: 'new.example.com', token_storage: 'keychain' }) - const loaded = loadHosts() - expect(loaded?.current_host).toBe('new.example.com') - expect(loaded?.token_storage).toBe('keychain') - }) - - it('rejects invalid input at save time', () => { - expect(() => saveHosts({ - current_host: 'cloud.dify.ai', - token_storage: 'cloud', - } as never)).toThrow() + expect(loaded?.hosts['cloud.dify.ai']?.accounts['a@x']?.account.email).toBe('a@x') + }) +}) + +class MemStore implements Store { + readonly entries = new Map() + get(key: Key): T { return (this.entries.get(key.key) as T | undefined) ?? key.default } + set(key: Key, value: T): void { this.entries.set(key.key, value) } + unset(key: Key): void { this.entries.delete(key.key) } +} + +describe('Registry.forget', () => { + let dir: string + let prev: string | undefined + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'difyctl-forget-')) + prev = process.env[ENV_CONFIG_DIR] + process.env[ENV_CONFIG_DIR] = dir + }) + afterEach(async () => { + if (prev === undefined) + delete process.env[ENV_CONFIG_DIR] + else process.env[ENV_CONFIG_DIR] = prev + await rm(dir, { recursive: true, force: true }) + }) + + it('drops token + active context, keeps siblings, unsets pointers', () => { + const store = new MemStore() + const reg = Registry.empty('file') + reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } }) + reg.upsert('h1', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } }) + reg.setHost('h1') + reg.setAccount('a@x') + reg.save() + store.set({ key: 'tokens.h1.a@x', default: '' }, 'dfoa_a') + + const active = reg.resolveActive()! + reg.forget(active, store) + + expect(store.get({ key: 'tokens.h1.a@x', default: '' })).toBe('') + const after = Registry.load() + expect(after?.hosts.h1?.accounts['a@x']).toBeUndefined() + expect(after?.hosts.h1?.accounts['b@x']).toBeDefined() + expect(after?.current_host).toBeUndefined() }) }) diff --git a/cli/src/auth/hosts.ts b/cli/src/auth/hosts.ts index f61cc513f3..34c8c4d782 100644 --- a/cli/src/auth/hosts.ts +++ b/cli/src/auth/hosts.ts @@ -1,5 +1,7 @@ import type { Store } from '@/store/store' import { z } from 'zod' +import { BaseError } from '@/errors/base' +import { ErrorCode } from '@/errors/codes' import { getHostStore, tokenKey } from '@/store/manager' const StorageModeSchema = z.enum(['keychain', 'file']) @@ -25,42 +27,152 @@ export const ExternalSubjectSchema = z.object({ }) export type ExternalSubject = z.infer -export const TokensSchema = z.object({ - bearer: z.string(), -}) -export type Tokens = z.infer - -export const HostsBundleSchema = z.object({ - current_host: z.string().default(''), - scheme: z.string().optional(), - account: AccountSchema.optional(), +export const AccountContextSchema = z.object({ + account: AccountSchema, workspace: WorkspaceSchema.optional(), available_workspaces: z.array(WorkspaceSchema).optional(), - token_storage: StorageModeSchema.default('file'), token_id: z.string().optional(), token_expires_at: z.string().optional(), - tokens: TokensSchema.optional(), external_subject: ExternalSubjectSchema.optional(), }) -export type HostsBundle = z.infer +export type AccountContext = z.infer -export function loadHosts(): HostsBundle | undefined { - const raw = getHostStore().getTyped>() - if (raw === null) - return undefined - return HostsBundleSchema.parse(raw) +export const HostEntrySchema = z.object({ + scheme: z.string().optional(), + current_account: z.string().optional(), + accounts: z.record(z.string(), AccountContextSchema).default({}), +}) +export type HostEntry = z.infer + +export const RegistrySchema = z.object({ + token_storage: StorageModeSchema.default('file'), + current_host: z.string().optional(), + hosts: z.record(z.string(), HostEntrySchema).default({}), +}) +export type RegistryData = z.infer + +export type ActiveContext = { + readonly host: string + readonly email: string + readonly ctx: AccountContext + readonly scheme?: string } -export function saveHosts(bundle: HostsBundle): void { - const validated = HostsBundleSchema.parse(bundle) - getHostStore().setTyped(validated) +export function notLoggedInError(hint = 'run \'difyctl auth login\''): BaseError { + return new BaseError({ code: ErrorCode.NotLoggedIn, message: 'not logged in', hint }) } -export function clearLocal(bundle: HostsBundle, store: Store): void { - const accountId = bundle.account?.id ?? bundle.external_subject?.email ?? 'default' - try { - store.unset(tokenKey(bundle.current_host, accountId)) +export class Registry { + private readonly data: RegistryData + + private constructor(data: RegistryData) { + this.data = data + } + + static load(): Registry { + const raw = getHostStore().getTyped>() + if (raw === null) + return Registry.empty() + return new Registry(RegistrySchema.parse(raw)) + } + + static empty(mode: StorageMode = 'file'): Registry { + return new Registry(RegistrySchema.parse({ token_storage: mode, hosts: {} })) + } + + static from(data: RegistryData): Registry { + return new Registry(data) + } + + get hosts(): RegistryData['hosts'] { return this.data.hosts } + get current_host(): string | undefined { return this.data.current_host } + get token_storage(): StorageMode { return this.data.token_storage } + set token_storage(mode: StorageMode) { this.data.token_storage = mode } + + resolveActive(): ActiveContext | undefined { + const host = this.data.current_host + if (host === undefined || host === '') + return undefined + const entry = this.data.hosts[host] + if (entry === undefined) + return undefined + const email = entry?.current_account + if (!email) + return undefined + const ctx = entry.accounts[email] + if (ctx === undefined) + return undefined + return { host, email, ctx, scheme: entry.scheme } + } + + requireActive(hint?: string): ActiveContext { + const active = this.resolveActive() + if (active === undefined) + throw notLoggedInError(hint) + return active + } + + upsert(host: string, email: string, ctx: AccountContext): void { + const entry = this.data.hosts[host] ?? { accounts: {} } + entry.accounts[email] = ctx + this.data.hosts[host] = entry + } + + remove(host: string, email: string): void { + const entry = this.data.hosts[host] + if (entry === undefined) + return + const wasActive = entry.current_account === email + delete entry.accounts[email] + if (wasActive) + entry.current_account = undefined + if (Object.keys(entry.accounts).length === 0) { + delete this.data.hosts[host] + if (this.data.current_host === host) + this.data.current_host = undefined + } + else if (wasActive && this.data.current_host === host) { + this.data.current_host = undefined + } + } + + setHost(host: string): void { + this.data.current_host = host + } + + setAccount(email: string): void { + const host = this.data.current_host + if (host === undefined) + return + const entry = this.data.hosts[host] + if (entry !== undefined) + entry.current_account = email + } + + setScheme(host: string, scheme: string): void { + const entry = this.data.hosts[host] + if (entry !== undefined) + entry.scheme = scheme + } + + activate(host: string, email: string, ctx: AccountContext): void { + this.upsert(host, email, ctx) + this.setHost(host) + this.setAccount(email) + } + + // Teardown for "this credential is gone": drop the token, drop the context + // (unsets pointers when active), persist. Logout + self-revoke share it. + forget(active: ActiveContext, store: Store): void { + try { + store.unset(tokenKey(active.host, active.email)) + } + catch { /* best-effort */ } + this.remove(active.host, active.email) + this.save() + } + + save(): void { + getHostStore().setTyped(RegistrySchema.parse(this.data)) } - catch { /* best-effort */ } - getHostStore().rm() } diff --git a/cli/src/commands/_shared/authed-command.ts b/cli/src/commands/_shared/authed-command.ts index 1d3db5afc7..68bd2de018 100644 --- a/cli/src/commands/_shared/authed-command.ts +++ b/cli/src/commands/_shared/authed-command.ts @@ -1,17 +1,17 @@ -import type { HostsBundle } from '@/auth/hosts' +import type { ActiveContext } from '@/auth/hosts' import type { AppInfoCache } from '@/cache/app-info' import type { Command } from '@/framework/command' import type { HttpClient } from '@/http/types' +import type { Store } from '@/store/store' import type { IOStreams } from '@/sys/io/streams' import { META_PROBE_TIMEOUT_MS, MetaClient } from '@/api/meta' -import { loadHosts } from '@/auth/hosts' +import { notLoggedInError, Registry } from '@/auth/hosts' import { loadAppInfoCache } from '@/cache/app-info' import { loadNudgeStore } from '@/cache/nudge-store' import { getEnv } from '@/env/registry' -import { BaseError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' import { formatErrorForCli } from '@/errors/format' import { createHttpClient } from '@/http/client' +import { getTokenStore, tokenKey } from '@/store/manager' import { realStreams } from '@/sys/io/streams' import { hostWithScheme, openAPIBase } from '@/util/host' import { versionInfo } from '@/version/info' @@ -19,7 +19,9 @@ import { maybeNudgeCompat } from '@/version/nudge' import { resolveRetryAttempts } from './global-flags.js' export type AuthedContext = { - readonly bundle: HostsBundle + readonly reg: Registry + readonly active: ActiveContext + readonly store: Store readonly http: HttpClient readonly host: string readonly io: IOStreams @@ -37,28 +39,30 @@ export async function buildAuthedContext( opts: AuthedContextOptions, ): Promise { const io = realStreams(opts.format ?? '') - const bundle = loadHosts() - if (bundle === undefined || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') { - const err = new BaseError({ - code: ErrorCode.NotLoggedIn, - message: 'not logged in', - hint: 'run \'difyctl auth login\'', - }) - cmd.error(formatErrorForCli(err, { format: opts.format, isErrTTY: io.isErrTTY }), { exit: err.exit() }) - } + const reg = Registry.load() + const active = reg.resolveActive() + if (active === undefined) + fail(cmd, opts, io) - const host = hostWithScheme(bundle.current_host, bundle.scheme) - const retryAttempts = resolveRetryAttempts({ - flag: opts.retryFlag, - env: getEnv, - }) - const http = createHttpClient({ baseURL: openAPIBase(host), bearer: bundle.tokens.bearer, retryAttempts }) + const { store } = getTokenStore() + const bearer = store.get(tokenKey(active.host, active.email)) + if (bearer === '') + fail(cmd, opts, io) + + const host = hostWithScheme(active.host, active.scheme) + const retryAttempts = resolveRetryAttempts({ flag: opts.retryFlag, env: getEnv }) + const http = createHttpClient({ baseURL: openAPIBase(host), bearer, retryAttempts }) const cache = opts.withCache === true ? await loadAppInfoCache() : undefined await runCompatNudge({ host, io }) - return { bundle, http, host, io, cache } + return { reg, active, store, http, host, io, cache } +} + +function fail(cmd: Pick, opts: AuthedContextOptions, io: IOStreams): never { + const err = notLoggedInError() + cmd.error(formatErrorForCli(err, { format: opts.format, isErrTTY: io.isErrTTY }), { exit: err.exit() }) } // Best-effort nudge: never throws, never blocks. Lives here so every authed diff --git a/cli/src/commands/auth/devices/_shared/devices.test.ts b/cli/src/commands/auth/devices/_shared/devices.test.ts index bef574f26c..28a4901022 100644 --- a/cli/src/commands/auth/devices/_shared/devices.test.ts +++ b/cli/src/commands/auth/devices/_shared/devices.test.ts @@ -1,16 +1,16 @@ import type { SessionListResponse, SessionRow } from '@dify/contracts/api/openapi/types.gen' import type { DifyMock } from '@test/fixtures/dify-mock/server' import type { AccountSessionsClient } from '@/api/account-sessions' -import type { HostsBundle } from '@/auth/hosts' +import type { ActiveContext } from '@/auth/hosts' import type { Key, Store } from '@/store/store' -import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { startMock } from '@test/fixtures/dify-mock/server' import { testHttpClient } from '@test/fixtures/http-client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { saveHosts } from '@/auth/hosts' -import { ENV_CONFIG_DIR, resolveConfigDir } from '@/store/dir' +import { Registry } from '@/auth/hosts' +import { ENV_CONFIG_DIR } from '@/store/dir' import { tokenKey } from '@/store/manager' import { bufferStreams } from '@/sys/io/streams' import { listAllSessions, runDevicesList, runDevicesRevoke } from './devices.js' @@ -30,20 +30,21 @@ class MemStore implements Store { } } -function bundleFor(host: string, tokenId = 'tok-1'): HostsBundle { - return { - current_host: host, - scheme: 'http', - token_storage: 'file', - token_id: tokenId, - tokens: { bearer: 'dfoa_test' }, - account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' }, +function buildRegistry(host: string, email: string, tokenId: string): { reg: Registry, active: ActiveContext } { + const reg = Registry.empty('file') + reg.upsert(host, email, { + account: { id: 'acct-1', email, name: 'Test Tester' }, workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, available_workspaces: [ { id: 'ws-1', name: 'Default', role: 'owner' }, { id: 'ws-2', name: 'Other', role: 'normal' }, ], - } + token_id: tokenId, + }) + reg.setHost(host) + reg.setAccount(email) + const active = reg.resolveActive()! + return { reg, active } } describe('runDevicesList', () => { @@ -58,7 +59,7 @@ describe('runDevicesList', () => { it('table: marks current with *', async () => { const io = bufferStreams() const http = testHttpClient(mock.url, 'dfoa_test') - await runDevicesList({ io, bundle: bundleFor(mock.url, 'tok-1'), http }) + await runDevicesList({ io, tokenId: 'tok-1', http }) const out = io.outBuf() expect(out).toContain('DEVICE') expect(out).toContain('difyctl on laptop') @@ -71,20 +72,12 @@ describe('runDevicesList', () => { it('json: emits PaginationEnvelope unchanged', async () => { const io = bufferStreams() const http = testHttpClient(mock.url, 'dfoa_test') - await runDevicesList({ io, bundle: bundleFor(mock.url), http, json: true }) + await runDevicesList({ io, tokenId: 'tok-1', http, json: true }) const parsed = JSON.parse(io.outBuf()) as Record expect(parsed.page).toBe(1) expect(Array.isArray(parsed.data)).toBe(true) expect((parsed.data as unknown[]).length).toBe(3) }) - - it('not-logged-in: throws NotLoggedIn', async () => { - const io = bufferStreams() - const http = testHttpClient(mock.url, 'dfoa_test') - await expect(runDevicesList({ io, bundle: undefined, http })) - .rejects - .toThrow(/not logged in/) - }) }) describe('runDevicesRevoke', () => { @@ -109,12 +102,12 @@ describe('runDevicesRevoke', () => { it('exact device_label: revokes one + leaves local creds', async () => { const io = bufferStreams() const store = new MemStore() - const b = bundleFor(mock.url, 'tok-1') - store.set(tokenKey(b.current_host, 'acct-1'), 'dfoa_test') - saveHosts(b) + const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1') + store.set(tokenKey(mock.url, 'tester@dify.ai'), 'dfoa_test') + reg.save() const http = testHttpClient(mock.url, 'dfoa_test') - await runDevicesRevoke({ io, bundle: b, http, store, target: 'difyctl on desktop', all: false }) + await runDevicesRevoke({ io, reg, active, store, http, target: 'difyctl on desktop', all: false }) expect(io.outBuf()).toContain('Revoked 1 session(s)') expect(store.entries.size).toBe(1) }) @@ -122,30 +115,30 @@ describe('runDevicesRevoke', () => { it('exact id: revokes one', async () => { const io = bufferStreams() const store = new MemStore() - const b = bundleFor(mock.url, 'tok-1') + const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1') const http = testHttpClient(mock.url, 'dfoa_test') - await runDevicesRevoke({ io, bundle: b, http, store, target: 'tok-2', all: false }) + await runDevicesRevoke({ io, reg, active, store, http, target: 'tok-2', all: false }) expect(io.outBuf()).toContain('Revoked 1 session(s)') }) it('substring: unique match revokes', async () => { const io = bufferStreams() const store = new MemStore() - const b = bundleFor(mock.url, 'tok-1') + const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1') const http = testHttpClient(mock.url, 'dfoa_test') - await runDevicesRevoke({ io, bundle: b, http, store, target: 'web', all: false }) + await runDevicesRevoke({ io, reg, active, store, http, target: 'web', all: false }) expect(io.outBuf()).toContain('Revoked 1 session(s)') }) it('substring: ambiguous throws', async () => { const io = bufferStreams() const store = new MemStore() - const b = bundleFor(mock.url, 'tok-1') + const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1') const http = testHttpClient(mock.url, 'dfoa_test') - await expect(runDevicesRevoke({ io, bundle: b, http, store, target: 'difyctl', all: false })) + await expect(runDevicesRevoke({ io, reg, active, store, http, target: 'difyctl', all: false })) .rejects .toThrow(/matches multiple/) }) @@ -153,10 +146,10 @@ describe('runDevicesRevoke', () => { it('no match throws', async () => { const io = bufferStreams() const store = new MemStore() - const b = bundleFor(mock.url, 'tok-1') + const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1') const http = testHttpClient(mock.url, 'dfoa_test') - await expect(runDevicesRevoke({ io, bundle: b, http, store, target: 'nonexistent', all: false })) + await expect(runDevicesRevoke({ io, reg, active, store, http, target: 'nonexistent', all: false })) .rejects .toThrow(/no session matches/) }) @@ -164,31 +157,33 @@ describe('runDevicesRevoke', () => { it('--all: revokes everything except current', async () => { const io = bufferStreams() const store = new MemStore() - const b = bundleFor(mock.url, 'tok-1') + const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1') const http = testHttpClient(mock.url, 'dfoa_test') - await runDevicesRevoke({ io, bundle: b, http, store, all: true }) + await runDevicesRevoke({ io, reg, active, store, http, all: true }) expect(io.outBuf()).toContain('Revoked 2 session(s)') }) - it('revoking current id clears local creds', async () => { + it('revoking current session clears token and removes context from registry', async () => { const io = bufferStreams() const store = new MemStore() - const b = bundleFor(mock.url, 'tok-1') - store.set(tokenKey(b.current_host, 'acct-1'), 'dfoa_test') - saveHosts(b) + const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1') + store.set(tokenKey(mock.url, 'tester@dify.ai'), 'dfoa_test') + reg.save() const http = testHttpClient(mock.url, 'dfoa_test') - await runDevicesRevoke({ io, bundle: b, http, store, target: 'tok-1', all: false }) + await runDevicesRevoke({ io, reg, active, store, http, target: 'tok-1', all: false }) expect(store.entries.size).toBe(0) - await expect(readFile(join(resolveConfigDir(), 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/) + const saved = Registry.load() + expect(saved?.hosts[mock.url]).toBeUndefined() }) it('no target + no --all: throws UsageMissingArg', async () => { const io = bufferStreams() const store = new MemStore() + const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1') const http = testHttpClient(mock.url, 'dfoa_test') - await expect(runDevicesRevoke({ io, bundle: bundleFor(mock.url), http, store, all: false })) + await expect(runDevicesRevoke({ io, reg, active, store, http, all: false })) .rejects .toThrow(/specify a device label/) }) diff --git a/cli/src/commands/auth/devices/_shared/devices.ts b/cli/src/commands/auth/devices/_shared/devices.ts index 9fc596c40c..83d41d8fd1 100644 --- a/cli/src/commands/auth/devices/_shared/devices.ts +++ b/cli/src/commands/auth/devices/_shared/devices.ts @@ -1,20 +1,18 @@ import type { SessionRow } from '@dify/contracts/api/openapi/types.gen' -import type { HostsBundle } from '@/auth/hosts' +import type { ActiveContext, Registry } from '@/auth/hosts' import type { HttpClient } from '@/http/types' import type { Store } from '@/store/store' import type { IOStreams } from '@/sys/io/streams' import { AccountSessionsClient } from '@/api/account-sessions' -import { clearLocal } from '@/auth/hosts' import { BaseError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' import { LIMIT_DEFAULT, LIMIT_MAX, parseLimit } from '@/limit/limit' -import { getTokenStore } from '@/store/manager' import { colorEnabled, colorScheme } from '@/sys/io/color' import { runWithSpinner } from '@/sys/io/spinner' export type DevicesListOptions = { readonly io: IOStreams - readonly bundle: HostsBundle | undefined + readonly tokenId: string readonly http: HttpClient readonly json?: boolean readonly page?: number @@ -23,7 +21,6 @@ export type DevicesListOptions = { } export async function runDevicesList(opts: DevicesListOptions): Promise { - const b = requireLogin(opts.bundle) const sessions = new AccountSessionsClient(opts.http) const env = opts.envLookup ?? ((k: string) => process.env[k]) const limit = resolveLimit(opts.limitRaw, env) @@ -38,7 +35,7 @@ export async function runDevicesList(opts: DevicesListOptions): Promise { return } - opts.io.out.write(renderTable(envelope.data, b.token_id ?? '')) + opts.io.out.write(renderTable(envelope.data, opts.tokenId)) } function resolveLimit(raw: string | undefined, env: (k: string) => string | undefined): number { @@ -72,10 +69,10 @@ export async function listAllSessions(client: AccountSessionsClient): Promise { const cs = colorScheme(colorEnabled(opts.io.isErrTTY)) - const b = requireLogin(opts.bundle) if (!opts.all && (opts.target === undefined || opts.target === '')) { throw new BaseError({ code: ErrorCode.UsageMissingArg, @@ -94,7 +90,7 @@ export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise r.tableRow()) + } + + name(): string { + return this.rows.map(r => r.name()).join('\n') + } + + json() { + return { contexts: this.rows.map(r => r.json()) } + } +} diff --git a/cli/src/commands/auth/list/index.ts b/cli/src/commands/auth/list/index.ts new file mode 100644 index 0000000000..6620d7abba --- /dev/null +++ b/cli/src/commands/auth/list/index.ts @@ -0,0 +1,26 @@ +import { Registry } from '@/auth/hosts' +import { DifyCommand } from '@/commands/_shared/dify-command' +import { Flags } from '@/framework/flags' +import { OutputFormat, table } from '@/framework/output' +import { runAuthList } from './list' + +export default class AuthList extends DifyCommand { + static override description = 'List all authenticated contexts (host + account pairs)' + + static override examples = [ + '<%= config.bin %> auth list', + '<%= config.bin %> auth list -o json', + '<%= config.bin %> auth list -o name', + ] + + static override flags = { + output: Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME], default: '' }), + } + + async run(argv: string[]) { + const { flags } = this.parse(AuthList, argv) + const reg = Registry.load() + const result = runAuthList(reg) + return table({ format: flags.output, data: result }) + } +} diff --git a/cli/src/commands/auth/list/list.test.ts b/cli/src/commands/auth/list/list.test.ts new file mode 100644 index 0000000000..cc4d188839 --- /dev/null +++ b/cli/src/commands/auth/list/list.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import { Registry } from '@/auth/hosts' +import { stringifyOutput, table } from '@/framework/output' +import { runAuthList } from './list' + +function twoHostReg(): Registry { + return Registry.from({ + token_storage: 'keychain', + current_host: 'cloud.dify.ai', + hosts: { + 'cloud.dify.ai': { + current_account: 'alice@corp.com', + accounts: { + 'alice@corp.com': { + account: { id: 'acct-1', email: 'alice@corp.com', name: 'Alice' }, + }, + 'bob@corp.com': { + account: { id: 'acct-2', email: 'bob@corp.com', name: 'Bob' }, + }, + }, + }, + 'other.dify.ai': { + current_account: 'admin@other.com', + accounts: { + 'admin@other.com': { + account: { id: 'acct-3', email: 'admin@other.com', name: 'Admin' }, + }, + }, + }, + }, + }) +} + +describe('runAuthList', () => { + it('returns all host+account pairs', () => { + const result = runAuthList(twoHostReg()) + expect(result.rows).toHaveLength(3) + }) + + it('marks only the active context', () => { + const result = runAuthList(twoHostReg()) + const active = result.rows.filter(r => r.active) + expect(active).toHaveLength(1) + expect(active[0]!.host).toBe('cloud.dify.ai') + expect(active[0]!.account).toBe('alice@corp.com') + }) + + it('table: renders HOST ACCOUNT ACTIVE header', () => { + const out = stringifyOutput(table({ format: '', data: runAuthList(twoHostReg()) })) + expect(out).toMatch(/HOST\s+ACCOUNT\s+ACTIVE/) + expect(out).toContain('cloud.dify.ai') + expect(out).toContain('alice@corp.com') + expect(out).toContain('other.dify.ai') + }) + + it('table: marks active row with *', () => { + const out = stringifyOutput(table({ format: '', data: runAuthList(twoHostReg()) })) + const lines = out.trim().split('\n') + const activeLine = lines.find(l => l.includes('alice@corp.com'))! + expect(activeLine).toContain('*') + const inactiveLine = lines.find(l => l.includes('bob@corp.com'))! + expect(inactiveLine).not.toContain('*') + }) + + it('json: emits { contexts: [...] }', () => { + const out = stringifyOutput(table({ format: 'json', data: runAuthList(twoHostReg()) })) + const parsed = JSON.parse(out) as { contexts: Array<{ host: string, account: string, active: boolean }> } + expect(parsed.contexts).toHaveLength(3) + const activeCtx = parsed.contexts.find(c => c.active)! + expect(activeCtx.host).toBe('cloud.dify.ai') + expect(activeCtx.account).toBe('alice@corp.com') + }) + + it('name: emits account emails one per line', () => { + const out = stringifyOutput(table({ format: 'name', data: runAuthList(twoHostReg()) })) + const lines = out.trim().split('\n').sort() + expect(lines).toContain('alice@corp.com') + expect(lines).toContain('admin@other.com') + expect(lines).toContain('bob@corp.com') + }) + + it('table: shows email (Name) when display name present', () => { + const out = stringifyOutput(table({ format: '', data: runAuthList(twoHostReg()) })) + expect(out).toContain('alice@corp.com (Alice)') + }) + + it('table: shows email only when display name absent', () => { + const reg = Registry.from({ + token_storage: 'file', + current_host: 'cloud.dify.ai', + hosts: { + 'cloud.dify.ai': { + current_account: 'anon@corp.com', + accounts: { + 'anon@corp.com': { account: { id: 'x', email: 'anon@corp.com', name: '' } }, + }, + }, + }, + }) + const out = stringifyOutput(table({ format: '', data: runAuthList(reg) })) + expect(out).toContain('anon@corp.com') + expect(out).not.toContain('anon@corp.com (') + }) + + it('empty registry: returns zero rows', () => { + const result = runAuthList(Registry.empty()) + expect(result.rows).toHaveLength(0) + }) +}) diff --git a/cli/src/commands/auth/list/list.ts b/cli/src/commands/auth/list/list.ts new file mode 100644 index 0000000000..7aa0654f89 --- /dev/null +++ b/cli/src/commands/auth/list/list.ts @@ -0,0 +1,13 @@ +import type { Registry } from '@/auth/hosts' +import { ContextListOutput, ContextRow } from './handlers' + +export function runAuthList(reg: Registry): ContextListOutput { + const rows: ContextRow[] = [] + for (const [host, entry] of Object.entries(reg.hosts)) { + for (const [email, ctx] of Object.entries(entry.accounts)) { + const isActive = reg.current_host === host && entry.current_account === email + rows.push(new ContextRow(host, email, ctx.account.name, isActive)) + } + } + return new ContextListOutput(rows) +} diff --git a/cli/src/commands/auth/login/login.test.ts b/cli/src/commands/auth/login/login.test.ts index 32adca7458..705a34e255 100644 --- a/cli/src/commands/auth/login/login.test.ts +++ b/cli/src/commands/auth/login/login.test.ts @@ -8,9 +8,11 @@ import { startMock } from '@test/fixtures/dify-mock/server' import { testHttpClient } from '@test/fixtures/http-client' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { DeviceFlowApi } from '@/api/oauth-device' +import { createHttpClient } from '@/http/client' import { ENV_CONFIG_DIR } from '@/store/dir' import { tokenKey } from '@/store/manager' import { bufferStreams } from '@/sys/io/streams' +import { openAPIBase } from '@/util/host' import { runLogin } from './login.js' const noopClock: Clock = { @@ -59,7 +61,7 @@ describe('runLogin', () => { it('happy: stores bearer + writes hosts.yml + greets account user', async () => { const io = bufferStreams() const store = new MemStore() - const bundle = await runLogin({ + const reg = await runLogin({ io, host: mock.url, noBrowser: true, @@ -70,16 +72,17 @@ describe('runLogin', () => { clock: noopClock, browserOpener: noopBrowser, }) - expect(bundle.tokens?.bearer).toBe('dfoa_test') - expect(bundle.account?.email).toBe('tester@dify.ai') - expect(bundle.workspace?.id).toBe('ws-1') - expect(bundle.available_workspaces).toHaveLength(2) - const stored = store.get(tokenKey(bundle.current_host, 'acct-1')) - expect(stored).toBe('dfoa_test') + const active = reg.resolveActive() + expect(active?.ctx.account.email).toBe('tester@dify.ai') + expect(active?.ctx.workspace?.id).toBe('ws-1') + expect(active?.ctx.available_workspaces).toHaveLength(2) + expect(store.get(tokenKey(active!.host, 'tester@dify.ai'))).toBe('dfoa_test') const hostsRaw = await readFile(join(configDir, 'hosts.yml'), 'utf8') expect(hostsRaw).toContain('current_host:') expect(hostsRaw).toContain('tester@dify.ai') + expect(hostsRaw).not.toContain('dfoa_test') + expect(hostsRaw).not.toContain('bearer') expect(io.outBuf()).toContain('Logged in to') expect(io.outBuf()).toContain('tester@dify.ai') @@ -91,7 +94,7 @@ describe('runLogin', () => { mock.setScenario('sso') const io = bufferStreams() const store = new MemStore() - const bundle = await runLogin({ + const reg = await runLogin({ io, host: mock.url, noBrowser: true, @@ -102,12 +105,11 @@ describe('runLogin', () => { clock: noopClock, browserOpener: noopBrowser, }) - expect(bundle.tokens?.bearer).toBe('dfoe_test') - expect(bundle.account).toBeUndefined() - expect(bundle.external_subject?.email).toBe('sso@dify.ai') - expect(bundle.external_subject?.issuer).toBe('https://issuer.example') - const stored = store.get(tokenKey(bundle.current_host, 'sso@dify.ai')) - expect(stored).toBe('dfoe_test') + const active = reg.resolveActive() + expect(active?.ctx.external_subject?.email).toBe('sso@dify.ai') + expect(active?.ctx.external_subject?.issuer).toBe('https://issuer.example') + expect(active?.ctx.account.email).toBe('') + expect(store.get(tokenKey(active!.host, 'sso@dify.ai'))).toBe('dfoe_test') expect(io.outBuf()).toContain('external SSO') expect(io.outBuf()).toContain('sso@dify.ai') }) @@ -148,6 +150,24 @@ describe('runLogin', () => { })).rejects.toThrow(/expired/) }) + it('rejects login when the account has no email', async () => { + mock.setScenario('no-email') + const io = bufferStreams() + const store = new MemStore() + await expect(runLogin({ + io, + host: mock.url, + noBrowser: true, + insecure: true, + deviceLabel: 'difyctl on test', + api: new DeviceFlowApi(createHttpClient({ baseURL: openAPIBase(mock.url) })), + store: { store, mode: 'file' }, + clock: noopClock, + browserOpener: noopBrowser, + })).rejects.toThrow(/no email/i) + expect(store.entries.size).toBe(0) + }) + it('rejects http:// host without --insecure', async () => { const io = bufferStreams() const store = new MemStore() diff --git a/cli/src/commands/auth/login/login.ts b/cli/src/commands/auth/login/login.ts index 74769ef468..f1b3abc94d 100644 --- a/cli/src/commands/auth/login/login.ts +++ b/cli/src/commands/auth/login/login.ts @@ -1,16 +1,19 @@ import type { Clock } from './device-flow.js' import type { CodeResponse, PollSuccess } from '@/api/oauth-device' -import type { HostsBundle, Workspace } from '@/auth/hosts' +import type { AccountContext, Workspace } from '@/auth/hosts' import type { StorageMode, Store } from '@/store/store' import type { IOStreams } from '@/sys/io/streams' import type { BrowserEnv, BrowserOpener } from '@/util/browser' import * as os from 'node:os' import * as readline from 'node:readline' import { DeviceFlowApi } from '@/api/oauth-device' -import { saveHosts } from '@/auth/hosts' +import { Registry } from '@/auth/hosts' +import { BaseError } from '@/errors/base' +import { ErrorCode } from '@/errors/codes' import { createHttpClient } from '@/http/client' import { getTokenStore, tokenKey } from '@/store/manager' import { colorEnabled, colorScheme } from '@/sys/io/color' +import { startSpinner } from '@/sys/io/spinner' import { decideOpen, OpenDecision, openUrl, realEnv } from '@/util/browser' import { bareHost, DEFAULT_HOST, openAPIBase, resolveHost, validateVerificationURI } from '@/util/host' import { awaitAuthorization, realClock } from './device-flow.js' @@ -28,7 +31,7 @@ export type LoginOptions = { readonly clock?: Clock } -export async function runLogin(opts: LoginOptions): Promise { +export async function runLogin(opts: LoginOptions): Promise { const cs = colorScheme(colorEnabled(opts.io.isErrTTY)) const insecure = opts.insecure ?? false @@ -56,22 +59,44 @@ export async function runLogin(opts: LoginOptions): Promise { opts.io.err.write(`${cs.warningIcon()} ${decision} — open the URL above manually\n`) } - const success = await awaitAuthorization(api, code, { clock: opts.clock ?? realClock() }) + const spinner = startSpinner({ io: opts.io, label: 'Waiting for authorization', style: 'dify' }) + let success: PollSuccess + try { + success = await awaitAuthorization(api, code, { clock: opts.clock ?? realClock() }) + } + finally { + spinner.stop() + } const storeBundle = opts.store ?? getTokenStore() - const bundle = bundleFromSuccess(host, success, storeBundle.mode) + const display = bareHost(host) + const email = accountEmail(success) + const ctx = contextFromSuccess(success) - storeBundle.store.set(tokenKey(bundle.current_host, accountKey(bundle)), success.token) - saveHosts(bundle) + storeBundle.store.set(tokenKey(display, email), success.token) + + const reg = Registry.load() + reg.token_storage = storeBundle.mode + reg.activate(display, email, ctx) + applyScheme(reg, display, host) + reg.save() renderLoggedIn(opts.io.out, cs, host, success) - return bundle + return reg } async function resolveLoginHost(opts: LoginOptions, insecure: boolean): Promise { let raw = opts.host?.trim() ?? '' - if (raw === '') + if (raw === '') { + if (!opts.io.isErrTTY) { + throw new BaseError({ + code: ErrorCode.UsageMissingArg, + message: '--host is required (no TTY)', + hint: 'pass the host explicitly, e.g. \'difyctl auth login --host cloud.dify.ai\'', + }) + } raw = await promptHost(opts.io) + } return resolveHost({ raw, insecure }) } @@ -122,50 +147,43 @@ function findDefaultWorkspace(s: PollSuccess): { id: string, name: string, role: return s.workspaces?.find(w => w.id === s.default_workspace_id) } -function bundleFromSuccess(host: string, s: PollSuccess, mode: StorageMode): HostsBundle { - const display = bareHost(host) - let scheme: string | undefined - try { - const u = new URL(host) - if (u.protocol !== 'https:') - scheme = u.protocol.replace(':', '') +function accountEmail(s: PollSuccess): string { + const email = (s.account?.email ?? '') !== '' ? s.account!.email : (s.subject_email ?? '') + if (email === '') { + throw new BaseError({ + code: ErrorCode.NotLoggedIn, + message: 'account has no email; cannot store credential', + hint: 'this Dify instance returned no email for the signed-in subject', + }) } - catch { /* keep undefined */ } + return email +} - const bundle: HostsBundle = { - current_host: display, - scheme, - token_storage: mode, +function contextFromSuccess(s: PollSuccess): AccountContext { + const ctx: AccountContext = { + account: s.account + ? { id: s.account.id, email: s.account.email, name: s.account.name } + : { id: '', email: '', name: '' }, token_id: s.token_id, - tokens: { bearer: s.token }, - } - if (s.account) { - bundle.account = { id: s.account.id, email: s.account.email, name: s.account.name } } if (s.subject_email !== undefined && s.subject_email !== '' && (!s.account || s.account.id === '')) { - bundle.external_subject = { - email: s.subject_email, - issuer: s.subject_issuer ?? '', - } + ctx.external_subject = { email: s.subject_email, issuer: s.subject_issuer ?? '' } } const def = findDefaultWorkspace(s) if (def !== undefined) - bundle.workspace = def + ctx.workspace = def if (s.workspaces !== undefined && s.workspaces.length > 0) { - bundle.available_workspaces = s.workspaces.map(w => ({ - id: w.id, - name: w.name, - role: w.role, - })) + ctx.available_workspaces = s.workspaces.map(w => ({ id: w.id, name: w.name, role: w.role })) } - return bundle + return ctx } -function accountKey(b: HostsBundle): string { - if (b.account?.id !== undefined && b.account.id !== '') - return b.account.id - if (b.external_subject?.email !== undefined && b.external_subject.email !== '') - return b.external_subject.email - return 'default' +function applyScheme(reg: Registry, display: string, host: string): void { + try { + const u = new URL(host) + if (u.protocol !== 'https:') + reg.setScheme(display, u.protocol.replace(':', '')) + } + catch { /* keep scheme unset */ } } diff --git a/cli/src/commands/auth/logout/index.ts b/cli/src/commands/auth/logout/index.ts index 809ac5d79f..478ab79936 100644 --- a/cli/src/commands/auth/logout/index.ts +++ b/cli/src/commands/auth/logout/index.ts @@ -1,7 +1,8 @@ import type { HttpClient } from '@/http/types' -import { loadHosts } from '@/auth/hosts' +import { Registry } from '@/auth/hosts' import { DifyCommand } from '@/commands/_shared/dify-command' import { createHttpClient } from '@/http/client' +import { getTokenStore, tokenKey } from '@/store/manager' import { runWithSpinner } from '@/sys/io/spinner' import { realStreams } from '@/sys/io/streams' import { hostWithScheme, openAPIBase } from '@/util/host' @@ -16,21 +17,21 @@ export default class Logout extends DifyCommand { async run(argv: string[]): Promise { this.parse(Logout, argv) - const bundle = loadHosts() + const io = realStreams() + const reg = Registry.load() + const active = reg.resolveActive() let http: HttpClient | undefined - if (bundle !== undefined && bundle.current_host !== '' && bundle.tokens?.bearer !== undefined && bundle.tokens.bearer !== '') { - http = createHttpClient({ - baseURL: openAPIBase(hostWithScheme(bundle.current_host, bundle.scheme)), - bearer: bundle.tokens.bearer, - retryAttempts: 0, - }) + if (active !== undefined) { + const bearer = getTokenStore().store.get(tokenKey(active.host, active.email)) + if (bearer !== '') { + http = createHttpClient({ baseURL: openAPIBase(hostWithScheme(active.host, active.scheme)), bearer, retryAttempts: 0 }) + } } - const io = realStreams() await runWithSpinner( { io, label: 'Signing out', enabled: true, style: 'dify-dim' }, - () => runLogout({ io, bundle, http }), + () => runLogout({ io, reg, http }), ) } } diff --git a/cli/src/commands/auth/logout/logout.test.ts b/cli/src/commands/auth/logout/logout.test.ts index 495a18f607..9144c2ef6c 100644 --- a/cli/src/commands/auth/logout/logout.test.ts +++ b/cli/src/commands/auth/logout/logout.test.ts @@ -1,145 +1,64 @@ -import type { DifyMock } from '@test/fixtures/dify-mock/server' -import type { HostsBundle } from '@/auth/hosts' import type { Key, Store } from '@/store/store' -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { startMock } from '@test/fixtures/dify-mock/server' -import { testHttpClient } from '@test/fixtures/http-client' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { saveHosts } from '@/auth/hosts' +import { Registry } from '@/auth/hosts' import { ENV_CONFIG_DIR } from '@/store/dir' -import { tokenKey } from '@/store/manager' import { bufferStreams } from '@/sys/io/streams' import { runLogout } from './logout.js' class MemStore implements Store { readonly entries = new Map() - get(key: Key): T { - return (this.entries.get(key.key) as T | undefined) ?? key.default - } - - set(key: Key, value: T): void { - this.entries.set(key.key, value) - } - - unset(key: Key): void { - this.entries.delete(key.key) - } -} - -function fixtureBundle(host: string): HostsBundle { - return { - current_host: host, - scheme: 'http', - token_storage: 'file', - token_id: 'tok-1', - tokens: { bearer: 'dfoa_test' }, - account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' }, - workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, - available_workspaces: [ - { id: 'ws-1', name: 'Default', role: 'owner' }, - { id: 'ws-2', name: 'Other', role: 'normal' }, - ], - } + get(key: Key): T { return (this.entries.get(key.key) as T | undefined) ?? key.default } + set(key: Key, value: T): void { this.entries.set(key.key, value) } + unset(key: Key): void { this.entries.delete(key.key) } } describe('runLogout', () => { - let mock: DifyMock - let configDir: string - let prevConfigDir: string | undefined - + let dir: string + let prev: string | undefined beforeEach(async () => { - mock = await startMock({ scenario: 'happy' }) - configDir = await mkdtemp(join(tmpdir(), 'difyctl-logout-')) - prevConfigDir = process.env[ENV_CONFIG_DIR] - process.env[ENV_CONFIG_DIR] = configDir + dir = await mkdtemp(join(tmpdir(), 'difyctl-logout-')) + prev = process.env[ENV_CONFIG_DIR] + process.env[ENV_CONFIG_DIR] = dir }) - afterEach(async () => { - if (prevConfigDir === undefined) + if (prev === undefined) delete process.env[ENV_CONFIG_DIR] - else - process.env[ENV_CONFIG_DIR] = prevConfigDir - await mock.stop() - await rm(configDir, { recursive: true, force: true }) + else process.env[ENV_CONFIG_DIR] = prev + await rm(dir, { recursive: true, force: true }) }) - it('happy: revokes server side, clears local store + hosts.yml', async () => { - const io = bufferStreams() + function seed(store: MemStore) { + const reg = Registry.empty('file') + reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } }) + reg.upsert('h1', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } }) + reg.setHost('h1') + reg.setAccount('a@x') + reg.save() + store.set({ key: 'tokens.h1.a@x', default: '' }, 'dfoa_a') + store.set({ key: 'tokens.h1.b@x', default: '' }, 'dfoa_b') + } + + it('removes only the active context, keeps others, unsets pointers, file survives', async () => { const store = new MemStore() - const bundle = fixtureBundle(mock.url) - store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfoa_test') - saveHosts(bundle) - const http = testHttpClient(mock.url, 'dfoa_test') - - await runLogout({ io, bundle, http, store }) - - expect(store.entries.size).toBe(0) - await expect(readFile(join(configDir, 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/) - expect(io.outBuf()).toContain('Logged out of') - expect(io.errBuf()).toBe('') + seed(store) + await runLogout({ io: bufferStreams(), reg: Registry.load(), store }) + const after = Registry.load() + expect(after?.hosts.h1?.accounts['a@x']).toBeUndefined() + expect(after?.hosts.h1?.accounts['b@x']).toBeDefined() + expect(after?.current_host).toBeUndefined() + expect(store.get({ key: 'tokens.h1.a@x', default: '' })).toBe('') + expect(store.get({ key: 'tokens.h1.b@x', default: '' })).toBe('dfoa_b') + const raw = await readFile(join(dir, 'hosts.yml'), 'utf8') + expect(raw).toContain('b@x') }) - it('not-logged-in: throws BaseError', async () => { - const io = bufferStreams() - const store = new MemStore() - await expect(runLogout({ io, bundle: undefined, store })).rejects.toThrow(/not logged in/) - }) - - it('hosts.yml absent: still completes locally + emits success', async () => { - const io = bufferStreams() - const store = new MemStore() - const bundle = fixtureBundle(mock.url) - const http = testHttpClient(mock.url, 'dfoa_test') - - await runLogout({ io, bundle, http, store }) - - expect(io.outBuf()).toContain('Logged out of') - }) - - it('server revoke fails: warns to stderr but still clears local + exits 0', async () => { - const io = bufferStreams() - const store = new MemStore() - const bundle = fixtureBundle(mock.url) - store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfoa_test') - saveHosts(bundle) - mock.setScenario('server-5xx') - const http = testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }) - - await runLogout({ io, bundle, http, store }) - - expect(store.entries.size).toBe(0) - expect(io.errBuf()).toContain('server revoke failed') - expect(io.outBuf()).toContain('Logged out of') - }) - - it('skips server revoke for non-OAuth bearer (e.g. dfp_)', async () => { - const io = bufferStreams() - const store = new MemStore() - const bundle = fixtureBundle(mock.url) - bundle.tokens = { bearer: 'dfp_personal_token' } - store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfp_personal_token') - saveHosts(bundle) - const http = testHttpClient(mock.url, 'dfp_personal_token') - - await runLogout({ io, bundle, http, store }) - - expect(io.errBuf()).toBe('') - expect(store.entries.size).toBe(0) - }) - - it('preserves unrelated files in configDir', async () => { - const io = bufferStreams() - const store = new MemStore() - const bundle = fixtureBundle(mock.url) - saveHosts(bundle) - await writeFile(join(configDir, 'config.yml'), 'foo: bar\n', 'utf8') - const http = testHttpClient(mock.url, 'dfoa_test') - - await runLogout({ io, bundle, http, store }) - - const cfg = await readFile(join(configDir, 'config.yml'), 'utf8') - expect(cfg).toContain('foo: bar') + it('throws NotLoggedIn when no active context', async () => { + Registry.empty('file').save() + await expect(runLogout({ io: bufferStreams(), reg: Registry.load(), store: new MemStore() })) + .rejects + .toThrow(/not logged in/i) }) }) diff --git a/cli/src/commands/auth/logout/logout.ts b/cli/src/commands/auth/logout/logout.ts index fc99d460d1..7ca414f786 100644 --- a/cli/src/commands/auth/logout/logout.ts +++ b/cli/src/commands/auth/logout/logout.ts @@ -1,54 +1,46 @@ -import type { HostsBundle } from '@/auth/hosts' +import type { Registry } from '@/auth/hosts' import type { HttpClient } from '@/http/types' import type { Store } from '@/store/store' import type { IOStreams } from '@/sys/io/streams' import { AccountSessionsClient } from '@/api/account-sessions' -import { clearLocal } from '@/auth/hosts' -import { BaseError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' -import { getTokenStore } from '@/store/manager' +import { getTokenStore, tokenKey } from '@/store/manager' import { colorEnabled, colorScheme } from '@/sys/io/color' export type LogoutOptions = { readonly io: IOStreams - readonly bundle: HostsBundle | undefined + readonly reg: Registry readonly http?: HttpClient - /** Optional override for tests; production code resolves via `getTokenStore`. */ + /** Optional override for tests; production resolves via `getTokenStore`. */ readonly store?: Store } +const REVOCABLE_PREFIXES = ['dfoa_', 'dfoe_'] as const + export async function runLogout(opts: LogoutOptions): Promise { const cs = colorScheme(colorEnabled(opts.io.isErrTTY)) - const bundle = opts.bundle - if (bundle === undefined || bundle.current_host === '' || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') { - throw new BaseError({ - code: ErrorCode.NotLoggedIn, - message: 'not logged in', - hint: 'run \'difyctl auth login\'', - }) - } + const reg = opts.reg + const active = reg.requireActive() + + const store = opts.store ?? getTokenStore().store + const bearer = store.get(tokenKey(active.host, active.email)) let revokeWarning = '' - if (revokeAllowed(bundle.tokens.bearer) && opts.http !== undefined) { + if (bearer !== '' && revokeAllowed(bearer) && opts.http !== undefined) { try { - const sessions = new AccountSessionsClient(opts.http) - await sessions.revokeSelf() + await new AccountSessionsClient(opts.http).revokeSelf() } catch (err) { revokeWarning = `${cs.warningIcon()} server revoke failed (${(err as Error).message}); local credentials cleared anyway\n` } } - const tokens = opts.store ?? getTokenStore().store - clearLocal(bundle, tokens) + reg.forget(active, store) if (revokeWarning !== '') opts.io.err.write(revokeWarning) - opts.io.out.write(`${cs.successIcon()} Logged out of ${bundle.current_host}\n`) + opts.io.out.write(`${cs.successIcon()} Logged out of ${active.host}\n`) } -const REVOCABLE_PREFIXES = ['dfoa_', 'dfoe_'] as const - function revokeAllowed(bearer: string): boolean { return REVOCABLE_PREFIXES.some(p => bearer.startsWith(p)) } diff --git a/cli/src/commands/auth/status/index.ts b/cli/src/commands/auth/status/index.ts deleted file mode 100644 index 46e9e849d7..0000000000 --- a/cli/src/commands/auth/status/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { loadHosts } from '@/auth/hosts' -import { DifyCommand } from '@/commands/_shared/dify-command' -import { Flags } from '@/framework/flags' -import { realStreams } from '@/sys/io/streams' -import { runStatus } from './status' - -export default class Status extends DifyCommand { - static override description = 'Show authentication status for the active host' - - static override examples = [ - '<%= config.bin %> auth status', - '<%= config.bin %> auth status -v', - '<%= config.bin %> auth status --json', - ] - - static override flags = { - verbose: Flags.boolean({ char: 'v', description: 'show account/workspace ids and storage mode', default: false }), - json: Flags.boolean({ description: 'emit JSON', default: false }), - } - - async run(argv: string[]): Promise { - const { flags } = this.parse(Status, argv) - const bundle = loadHosts() - await runStatus({ io: realStreams(), bundle, verbose: flags.verbose, json: flags.json }) - } -} diff --git a/cli/src/commands/auth/status/status.test.ts b/cli/src/commands/auth/status/status.test.ts deleted file mode 100644 index 9df4173b39..0000000000 --- a/cli/src/commands/auth/status/status.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { HostsBundle } from '@/auth/hosts' -import { describe, expect, it } from 'vitest' -import { bufferStreams } from '@/sys/io/streams' -import { runStatus } from './status' - -function accountBundle(): HostsBundle { - return { - current_host: 'cloud.dify.ai', - token_storage: 'keychain', - token_id: 'tok-1', - tokens: { bearer: 'dfoa_test' }, - account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' }, - workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, - available_workspaces: [ - { id: 'ws-1', name: 'Default', role: 'owner' }, - { id: 'ws-2', name: 'Other', role: 'normal' }, - ], - } -} - -function ssoBundle(): HostsBundle { - return { - current_host: 'cloud.dify.ai', - token_storage: 'file', - token_id: 'tok-sso-1', - tokens: { bearer: 'dfoe_test' }, - external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' }, - } -} - -describe('runStatus', () => { - it('logged-out: prints message + throws NotLoggedIn', async () => { - const io = bufferStreams() - await expect(runStatus({ io, bundle: undefined })).rejects.toThrow(/not logged in/) - expect(io.outBuf()).toContain('Not logged in') - }) - - it('logged-out json: emits {logged_in: false}', async () => { - const io = bufferStreams() - await expect(runStatus({ io, bundle: undefined, json: true })).rejects.toThrow(/not logged in/) - expect(JSON.parse(io.outBuf())).toEqual({ host: null, logged_in: false }) - }) - - it('account: human compact', async () => { - const io = bufferStreams() - await runStatus({ io, bundle: accountBundle() }) - const out = io.outBuf() - expect(out).toContain('Logged in to cloud.dify.ai as tester@dify.ai (Test Tester)') - expect(out).toContain('Workspace: Default') - expect(out).toContain('full access') - }) - - it('account verbose: shows ids + storage + workspace count', async () => { - const io = bufferStreams() - await runStatus({ io, bundle: accountBundle(), verbose: true }) - const out = io.outBuf() - expect(out).toContain('cloud.dify.ai') - expect(out).toContain('Account:') - expect(out).toContain('acct-1') - expect(out).toContain('Workspace: Default (ws-1, role: owner)') - expect(out).toContain('Available: 2 workspaces') - expect(out).toContain('Storage: keychain') - }) - - it('sso: human compact mentions issuer', async () => { - const io = bufferStreams() - await runStatus({ io, bundle: ssoBundle() }) - const out = io.outBuf() - expect(out).toContain('sso@dify.ai (via https://issuer.example)') - expect(out).toContain('apps:run') - }) - - it('account json: matches schema with workspace + workspace count', async () => { - const io = bufferStreams() - await runStatus({ io, bundle: accountBundle(), json: true }) - const parsed = JSON.parse(io.outBuf()) as Record - expect(parsed.host).toBe('cloud.dify.ai') - expect(parsed.logged_in).toBe(true) - expect(parsed.storage).toBe('keychain') - expect(parsed.account).toEqual({ id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' }) - expect(parsed.workspace).toEqual({ id: 'ws-1', name: 'Default', role: 'owner' }) - expect(parsed.available_workspaces_count).toBe(2) - }) - - it('sso json: subject_type external_sso + email + issuer, no account', async () => { - const io = bufferStreams() - await runStatus({ io, bundle: ssoBundle(), json: true }) - const parsed = JSON.parse(io.outBuf()) as Record - expect(parsed.subject_type).toBe('external_sso') - expect(parsed.subject_email).toBe('sso@dify.ai') - expect(parsed.subject_issuer).toBe('https://issuer.example') - expect(parsed.account).toBeUndefined() - }) -}) diff --git a/cli/src/commands/auth/status/status.ts b/cli/src/commands/auth/status/status.ts deleted file mode 100644 index 054b351a21..0000000000 --- a/cli/src/commands/auth/status/status.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { HostsBundle } from '@/auth/hosts' -import type { IOStreams } from '@/sys/io/streams' -import { BaseError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' - -export type StatusOptions = { - readonly io: IOStreams - readonly bundle: HostsBundle | undefined - readonly verbose?: boolean - readonly json?: boolean -} - -export async function runStatus(opts: StatusOptions): Promise { - const bundle = opts.bundle - if (bundle === undefined || bundle.current_host === '' || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') { - if (opts.json === true) { - opts.io.out.write(`${JSON.stringify({ host: null, logged_in: false })}\n`) - } - else { - opts.io.out.write('Not logged in. Run \'difyctl auth login\' to sign in.\n') - } - throw new BaseError({ code: ErrorCode.NotLoggedIn, message: 'not logged in' }) - } - - if (opts.json === true) { - opts.io.out.write(`${renderJson(bundle)}\n`) - return - } - opts.io.out.write(renderHuman(bundle, opts.verbose ?? false)) -} - -function renderHuman(b: HostsBundle, verbose: boolean): string { - const lines: string[] = [] - if (!verbose) { - if (b.external_subject !== undefined) { - const sub = b.external_subject - lines.push(sub.issuer !== '' - ? `Logged in to ${b.current_host} as ${sub.email} (via ${sub.issuer})` - : `Logged in to ${b.current_host} as ${sub.email} (via SSO)`) - lines.push(' Scope: apps:run') - return `${lines.join('\n')}\n` - } - const acc = b.account ?? { id: '', email: '', name: '' } - lines.push(`Logged in to ${b.current_host} as ${acc.email} (${acc.name})`) - if (b.workspace?.name !== undefined && b.workspace.name !== '') - lines.push(` Workspace: ${b.workspace.name}`) - lines.push(' Session: Dify account — full access') - return `${lines.join('\n')}\n` - } - - if (b.external_subject !== undefined) { - const sub = b.external_subject - lines.push(b.current_host) - lines.push(sub.issuer !== '' - ? ` Subject: ${sub.email} (external SSO, issuer: ${sub.issuer})` - : ` Subject: ${sub.email} (external SSO)`) - lines.push(' Session: External SSO — can run apps, cannot manage workspace resources (scope: apps:run)') - lines.push(` Storage: ${b.token_storage}`) - return `${lines.join('\n')}\n` - } - const acc = b.account ?? { id: '', email: '', name: '' } - lines.push(b.current_host) - lines.push(` Account: ${acc.email} (${acc.name}, ${acc.id ?? ''})`) - if (b.workspace?.id !== undefined && b.workspace.id !== '') - lines.push(` Workspace: ${b.workspace.name} (${b.workspace.id}, role: ${b.workspace.role})`) - lines.push(` Available: ${b.available_workspaces?.length ?? 0} workspaces`) - lines.push(' Session: Dify account — full access (scope: full)') - lines.push(` Storage: ${b.token_storage}`) - return `${lines.join('\n')}\n` -} - -function renderJson(b: HostsBundle): string { - const out: Record = { - host: b.current_host, - logged_in: true, - storage: b.token_storage, - } - if (b.external_subject !== undefined) { - out.subject_type = 'external_sso' - out.subject_email = b.external_subject.email - out.subject_issuer = b.external_subject.issuer - } - else if (b.account !== undefined) { - out.account = { id: b.account.id ?? '', email: b.account.email, name: b.account.name } - if (b.workspace?.id !== undefined && b.workspace.id !== '') { - out.workspace = { id: b.workspace.id, name: b.workspace.name, role: b.workspace.role } - } - out.available_workspaces_count = b.available_workspaces?.length ?? 0 - } - return JSON.stringify(out, null, 2) -} diff --git a/cli/src/commands/auth/whoami/index.ts b/cli/src/commands/auth/whoami/index.ts index 1c10ea50eb..5f3ce89e1e 100644 --- a/cli/src/commands/auth/whoami/index.ts +++ b/cli/src/commands/auth/whoami/index.ts @@ -1,4 +1,4 @@ -import { loadHosts } from '@/auth/hosts' +import { Registry } from '@/auth/hosts' import { DifyCommand } from '@/commands/_shared/dify-command' import { Flags } from '@/framework/flags' import { realStreams } from '@/sys/io/streams' @@ -18,7 +18,7 @@ export default class Whoami extends DifyCommand { async run(argv: string[]): Promise { const { flags } = this.parse(Whoami, argv) - const bundle = loadHosts() - await runWhoami({ io: realStreams(), bundle, json: flags.json }) + const reg = Registry.load() + await runWhoami({ io: realStreams(), reg, json: flags.json }) } } diff --git a/cli/src/commands/auth/whoami/whoami.test.ts b/cli/src/commands/auth/whoami/whoami.test.ts index dde6d480e7..e82e946c67 100644 --- a/cli/src/commands/auth/whoami/whoami.test.ts +++ b/cli/src/commands/auth/whoami/whoami.test.ts @@ -1,68 +1,82 @@ -import type { HostsBundle } from '@/auth/hosts' import { describe, expect, it } from 'vitest' +import { Registry } from '@/auth/hosts' import { bufferStreams } from '@/sys/io/streams' import { runWhoami } from './whoami' -function accountBundle(): HostsBundle { - return { +function accountReg(): Registry { + return Registry.from({ + token_storage: 'file', current_host: 'cloud.dify.ai', - token_storage: 'keychain', - tokens: { bearer: 'dfoa_test' }, - account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' }, - } + hosts: { 'cloud.dify.ai': { current_account: 'a@b.c', accounts: { + 'a@b.c': { account: { id: 'acct-1', email: 'a@b.c', name: 'Ann' } }, + } } }, + }) +} + +function ssoReg(): Registry { + return Registry.from({ + token_storage: 'file', + current_host: 'cloud.dify.ai', + hosts: { 'cloud.dify.ai': { current_account: 'sso@dify.ai', accounts: { + 'sso@dify.ai': { + account: { email: 'sso@dify.ai', name: '' }, + external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' }, + }, + } } }, + }) } describe('runWhoami', () => { - it('logged-out: throws NotLoggedIn', async () => { + it('throws NotLoggedIn when no active context', async () => { + await expect(runWhoami({ io: bufferStreams(), reg: Registry.empty() })).rejects.toThrow(/not logged in/i) + }) + + it('prints email + name for an account', async () => { const io = bufferStreams() - await expect(runWhoami({ io, bundle: undefined })).rejects.toThrow(/not logged in/) + await runWhoami({ io, reg: accountReg() }) + expect(io.outBuf()).toContain('a@b.c') + expect(io.outBuf()).toContain('Ann') }) it('account human: emits "email (name)"', async () => { const io = bufferStreams() - await runWhoami({ io, bundle: accountBundle() }) - expect(io.outBuf()).toBe('tester@dify.ai (Test Tester)\n') + await runWhoami({ io, reg: accountReg() }) + expect(io.outBuf()).toBe('a@b.c (Ann)\n') }) it('account human, no name: emits email only', async () => { const io = bufferStreams() - const b = accountBundle() - b.account!.name = '' - await runWhoami({ io, bundle: b }) - expect(io.outBuf()).toBe('tester@dify.ai\n') + const reg = accountReg() + reg.hosts['cloud.dify.ai']!.accounts['a@b.c']!.account.name = '' + await runWhoami({ io, reg }) + expect(io.outBuf()).toBe('a@b.c\n') + }) + + it('emits JSON when --json', async () => { + const io = bufferStreams() + await runWhoami({ io, reg: accountReg(), json: true }) + expect(JSON.parse(io.outBuf())).toMatchObject({ email: 'a@b.c', id: 'acct-1' }) }) it('account json: emits {id, email, name}', async () => { const io = bufferStreams() - await runWhoami({ io, bundle: accountBundle(), json: true }) + await runWhoami({ io, reg: accountReg(), json: true }) expect(JSON.parse(io.outBuf())).toEqual({ id: 'acct-1', - email: 'tester@dify.ai', - name: 'Test Tester', + email: 'a@b.c', + name: 'Ann', }) }) it('sso human: emits email + issuer', async () => { const io = bufferStreams() - const b: HostsBundle = { - current_host: 'cloud.dify.ai', - token_storage: 'file', - tokens: { bearer: 'dfoe_test' }, - external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' }, - } - await runWhoami({ io, bundle: b }) + await runWhoami({ io, reg: ssoReg() }) expect(io.outBuf()).toBe('sso@dify.ai (external SSO, issuer: https://issuer.example)\n') }) it('sso json: emits {subject_type, email, issuer}', async () => { const io = bufferStreams() - const b: HostsBundle = { - current_host: 'cloud.dify.ai', - token_storage: 'file', - tokens: { bearer: 'dfoe_test' }, - external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' }, - } - await runWhoami({ io, bundle: b, json: true }) + await runWhoami({ io, reg: ssoReg(), json: true }) expect(JSON.parse(io.outBuf())).toEqual({ subject_type: 'external_sso', email: 'sso@dify.ai', diff --git a/cli/src/commands/auth/whoami/whoami.ts b/cli/src/commands/auth/whoami/whoami.ts index e40d56787f..e51969b1ee 100644 --- a/cli/src/commands/auth/whoami/whoami.ts +++ b/cli/src/commands/auth/whoami/whoami.ts @@ -1,46 +1,31 @@ -import type { HostsBundle } from '@/auth/hosts' +import type { Registry } from '@/auth/hosts' import type { IOStreams } from '@/sys/io/streams' -import { BaseError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' export type WhoamiOptions = { readonly io: IOStreams - readonly bundle: HostsBundle | undefined + readonly reg: Registry readonly json?: boolean } export async function runWhoami(opts: WhoamiOptions): Promise { - const b = opts.bundle - if (b === undefined || b.tokens?.bearer === undefined || b.tokens.bearer === '') { - throw new BaseError({ - code: ErrorCode.NotLoggedIn, - message: 'not logged in', - hint: 'run \'difyctl auth login\'', - }) - } + const active = opts.reg.requireActive() - if (b.external_subject !== undefined) { + const sub = active.ctx.external_subject + if (sub !== undefined) { if (opts.json === true) { - opts.io.out.write(`${JSON.stringify({ - subject_type: 'external_sso', - email: b.external_subject.email, - issuer: b.external_subject.issuer, - })}\n`) + opts.io.out.write(`${JSON.stringify({ subject_type: 'external_sso', email: sub.email, issuer: sub.issuer })}\n`) return } - const sub = b.external_subject opts.io.out.write(sub.issuer !== '' ? `${sub.email} (external SSO, issuer: ${sub.issuer})\n` : `${sub.email} (external SSO)\n`) return } - const acc = b.account ?? { id: '', email: '', name: '' } + const acc = active.ctx.account if (opts.json === true) { opts.io.out.write(`${JSON.stringify({ id: acc.id ?? '', email: acc.email, name: acc.name })}\n`) return } - opts.io.out.write(acc.name !== '' - ? `${acc.email} (${acc.name})\n` - : `${acc.email}\n`) + opts.io.out.write(acc.name !== '' ? `${acc.email} (${acc.name})\n` : `${acc.email}\n`) } diff --git a/cli/src/commands/create/member/index.ts b/cli/src/commands/create/member/index.ts index 9253c86fcd..7140c3a133 100644 --- a/cli/src/commands/create/member/index.ts +++ b/cli/src/commands/create/member/index.ts @@ -33,7 +33,7 @@ export default class CreateMember extends DifyCommand { const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format }) const result = await runCreateMember( { email: flags.email, role: flags.role, workspace: flags.workspace, format }, - { bundle: ctx.bundle, http: ctx.http, io: ctx.io }, + { active: ctx.active, http: ctx.http, io: ctx.io }, ) return formatted({ format, data: result.data }) } diff --git a/cli/src/commands/create/member/run.test.ts b/cli/src/commands/create/member/run.test.ts index d796f507eb..911319aeb9 100644 --- a/cli/src/commands/create/member/run.test.ts +++ b/cli/src/commands/create/member/run.test.ts @@ -1,17 +1,18 @@ -import type { HostsBundle } from '@/auth/hosts' +import type { ActiveContext } from '@/auth/hosts' import type { HttpClient } from '@/http/types' import { describe, expect, it, vi } from 'vitest' import { bufferStreams } from '@/sys/io/streams' import { runCreateMember } from './run.js' -function bundle(): HostsBundle { +function active(): ActiveContext { return { - current_host: 'cloud.dify.ai', - token_storage: 'file', - tokens: { bearer: 'dfoa_test' }, - account: { id: 'acct-1', email: 'inviter@example.com', name: 'Inviter' }, - workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, - available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }], + host: 'cloud.dify.ai', + email: 'inviter@example.com', + ctx: { + account: { id: 'acct-1', email: 'inviter@example.com', name: 'Inviter' }, + workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, + available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }], + }, } } @@ -35,7 +36,7 @@ describe('runCreateMember', () => { const result = await runCreateMember( { email: 'new@example.com', role: 'normal' }, { - bundle: bundle(), + active: active(), http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, @@ -60,7 +61,7 @@ describe('runCreateMember', () => { runCreateMember( { email: 'new@example.com', role: 'owner' }, { - bundle: bundle(), + active: active(), http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, @@ -76,7 +77,7 @@ describe('runCreateMember', () => { runCreateMember( { email: '', role: 'normal' }, { - bundle: bundle(), + active: active(), http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, @@ -91,7 +92,7 @@ describe('runCreateMember', () => { await runCreateMember( { email: 'new@example.com', role: 'admin', workspace: 'ws-9' }, { - bundle: bundle(), + active: active(), http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, diff --git a/cli/src/commands/create/member/run.ts b/cli/src/commands/create/member/run.ts index bc4c15ab9c..df2cee7efd 100644 --- a/cli/src/commands/create/member/run.ts +++ b/cli/src/commands/create/member/run.ts @@ -1,4 +1,4 @@ -import type { HostsBundle } from '@/auth/hosts' +import type { ActiveContext } from '@/auth/hosts' import type { HttpClient } from '@/http/types' import type { IOStreams } from '@/sys/io/streams' import { MembersClient } from '@/api/members' @@ -18,7 +18,7 @@ export type CreateMemberOptions = { } export type CreateMemberDeps = { - readonly bundle: HostsBundle + readonly active: ActiveContext readonly http: HttpClient readonly io?: IOStreams readonly envLookup?: (k: string) => string | undefined @@ -59,7 +59,7 @@ export async function runCreateMember( const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), - bundle: deps.bundle, + active: deps.active, }) const response = await runWithSpinner( diff --git a/cli/src/commands/delete/member/index.ts b/cli/src/commands/delete/member/index.ts index 2e4d89b4be..426738c87c 100644 --- a/cli/src/commands/delete/member/index.ts +++ b/cli/src/commands/delete/member/index.ts @@ -33,7 +33,7 @@ export default class DeleteMember extends DifyCommand { const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format }) const result = await runDeleteMember( { memberId: args.memberId, workspace: flags.workspace, format, yes: flags.yes }, - { bundle: ctx.bundle, http: ctx.http, io: ctx.io }, + { active: ctx.active, http: ctx.http, io: ctx.io }, ) return formatted({ format, data: result.data }) } diff --git a/cli/src/commands/delete/member/run.test.ts b/cli/src/commands/delete/member/run.test.ts index e63329d140..696414798c 100644 --- a/cli/src/commands/delete/member/run.test.ts +++ b/cli/src/commands/delete/member/run.test.ts @@ -1,17 +1,18 @@ -import type { HostsBundle } from '@/auth/hosts' +import type { ActiveContext } from '@/auth/hosts' import type { HttpClient } from '@/http/types' import { describe, expect, it, vi } from 'vitest' import { bufferStreams } from '@/sys/io/streams' import { runDeleteMember } from './run.js' -function bundle(): HostsBundle { +function active(): ActiveContext { return { - current_host: 'cloud.dify.ai', - token_storage: 'file', - tokens: { bearer: 'dfoa_test' }, - account: { id: 'acct-1', email: 'me@example.com', name: 'Me' }, - workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, - available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }], + host: 'cloud.dify.ai', + email: 'me@example.com', + ctx: { + account: { id: 'acct-1', email: 'me@example.com', name: 'Me' }, + workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, + available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }], + }, } } @@ -27,7 +28,7 @@ describe('runDeleteMember', () => { const result = await runDeleteMember( { memberId: 'acct-2' }, { - bundle: bundle(), + active: active(), http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, @@ -45,7 +46,7 @@ describe('runDeleteMember', () => { await runDeleteMember( { memberId: 'acct-2', workspace: 'ws-9' }, { - bundle: bundle(), + active: active(), http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, @@ -60,7 +61,7 @@ describe('runDeleteMember', () => { runDeleteMember( { memberId: '' }, { - bundle: bundle(), + active: active(), http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, diff --git a/cli/src/commands/delete/member/run.ts b/cli/src/commands/delete/member/run.ts index 0476524f1e..07ec3ad772 100644 --- a/cli/src/commands/delete/member/run.ts +++ b/cli/src/commands/delete/member/run.ts @@ -1,4 +1,4 @@ -import type { HostsBundle } from '@/auth/hosts' +import type { ActiveContext } from '@/auth/hosts' import type { HttpClient } from '@/http/types' import type { IOStreams } from '@/sys/io/streams' import * as readline from 'node:readline' @@ -19,7 +19,7 @@ export type DeleteMemberOptions = { } export type DeleteMemberDeps = { - readonly bundle: HostsBundle + readonly active: ActiveContext readonly http: HttpClient readonly io?: IOStreams readonly envLookup?: (k: string) => string | undefined @@ -51,7 +51,7 @@ export async function runDeleteMember( const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), - bundle: deps.bundle, + active: deps.active, }) if (!opts.yes && io.isErrTTY) { diff --git a/cli/src/commands/describe/app/index.ts b/cli/src/commands/describe/app/index.ts index ce09af76d2..ea9500cec1 100644 --- a/cli/src/commands/describe/app/index.ts +++ b/cli/src/commands/describe/app/index.ts @@ -32,7 +32,7 @@ export default class DescribeApp extends DifyCommand { format, data: await runDescribeApp( { appId: args.id, workspace: flags.workspace, format, refresh: flags.refresh }, - { bundle: ctx.bundle, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache }, + { active: ctx.active, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache }, ), }) } diff --git a/cli/src/commands/describe/app/run.test.ts b/cli/src/commands/describe/app/run.test.ts index 785b6f9a0b..d99519bbe3 100644 --- a/cli/src/commands/describe/app/run.test.ts +++ b/cli/src/commands/describe/app/run.test.ts @@ -1,5 +1,5 @@ import type { DifyMock } from '@test/fixtures/dify-mock/server' -import type { HostsBundle } from '@/auth/hosts' +import type { ActiveContext } from '@/auth/hosts' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -12,17 +12,18 @@ import { ENV_CACHE_DIR } from '@/store/dir' import { CACHE_APP_INFO, getCache } from '@/store/manager' import { runDescribeApp } from './run.js' -function bundle(): HostsBundle { +function active(): ActiveContext { return { - current_host: 'http://localhost', - token_storage: 'file', - tokens: { bearer: 'dfoa_test' }, - account: { id: 'acct-1', email: 't@d.ai', name: 'T' }, - workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, - available_workspaces: [ - { id: 'ws-1', name: 'Default', role: 'owner' }, - { id: 'ws-2', name: 'Other', role: 'normal' }, - ], + host: 'http://localhost', + email: 't@d.ai', + ctx: { + account: { id: 'acct-1', email: 't@d.ai', name: 'T' }, + workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, + available_workspaces: [ + { id: 'ws-1', name: 'Default', role: 'owner' }, + { id: 'ws-2', name: 'Other', role: 'normal' }, + ], + }, } } @@ -49,7 +50,7 @@ describe('runDescribeApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) const data = await runDescribeApp( opts, - { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, cache }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, cache }, ) return stringifyOutput(formatted({ format: opts.format ?? '', data })) } @@ -92,13 +93,13 @@ describe('runDescribeApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runDescribeApp( { appId: 'app-1' }, - { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, cache }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, cache }, ) const before = cache.get(mock.url, 'app-1') expect(before).toBeDefined() await runDescribeApp( { appId: 'app-1', refresh: true }, - { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, cache }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, cache }, ) const after = cache.get(mock.url, 'app-1') expect(after?.fetchedAt).not.toBe(before?.fetchedAt ?? '') @@ -112,7 +113,7 @@ describe('runDescribeApp', () => { await expect(runDescribeApp( { appId: 'nope' }, { - bundle: bundle(), + active: active(), http: testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }), host: mock.url, }, diff --git a/cli/src/commands/describe/app/run.ts b/cli/src/commands/describe/app/run.ts index d68d04ba7f..7eebf63cbf 100644 --- a/cli/src/commands/describe/app/run.ts +++ b/cli/src/commands/describe/app/run.ts @@ -1,4 +1,4 @@ -import type { HostsBundle } from '@/auth/hosts' +import type { ActiveContext } from '@/auth/hosts' import type { AppInfoCache } from '@/cache/app-info' import type { HttpClient } from '@/http/types' import type { IOStreams } from '@/sys/io/streams' @@ -19,7 +19,7 @@ export type DescribeAppOptions = { } export type DescribeAppDeps = { - readonly bundle: HostsBundle + readonly active: ActiveContext readonly http: HttpClient readonly host: string readonly io?: IOStreams @@ -29,7 +29,7 @@ export type DescribeAppDeps = { export async function runDescribeApp(opts: DescribeAppOptions, deps: DescribeAppDeps): Promise { const env = deps.envLookup ?? getEnv - const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle }) + const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active }) const apps = new AppsClient(deps.http) const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache }) const io = deps.io ?? nullStreams() diff --git a/cli/src/commands/get/app/handlers.ts b/cli/src/commands/get/app/handlers.ts index 18acd1fdd7..ac7008fa53 100644 --- a/cli/src/commands/get/app/handlers.ts +++ b/cli/src/commands/get/app/handlers.ts @@ -1,6 +1,5 @@ import type { AppListResponse, AppListRow, TagItem } from '@dify/contracts/api/openapi/types.gen' -import type { TableCell } from '@/framework/output' -import type { TableColumn } from '@/printers/format-table' +import type { TableCell, TableColumn } from '@/framework/output' export const APP_MODE_KEY = 'app' diff --git a/cli/src/commands/get/app/index.ts b/cli/src/commands/get/app/index.ts index 1c71428c37..aac6e83696 100644 --- a/cli/src/commands/get/app/index.ts +++ b/cli/src/commands/get/app/index.ts @@ -59,7 +59,7 @@ export default class GetApp extends DifyCommand { name: flags.name, tag: flags.tag, format, - }, { bundle: ctx.bundle, http: ctx.http, io: ctx.io }) + }, { active: ctx.active, http: ctx.http, io: ctx.io }) return table({ format, data: result.data, diff --git a/cli/src/commands/get/app/payload-shape.ts b/cli/src/commands/get/app/payload-shape.ts deleted file mode 100644 index 53f638eb86..0000000000 --- a/cli/src/commands/get/app/payload-shape.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function isPayloadShape(value: unknown, requiredKey: keyof T): value is T { - return typeof value === 'object' - && value !== null - && requiredKey in value -} diff --git a/cli/src/commands/get/app/run.test.ts b/cli/src/commands/get/app/run.test.ts index 3682665962..f99eca3db4 100644 --- a/cli/src/commands/get/app/run.test.ts +++ b/cli/src/commands/get/app/run.test.ts @@ -1,5 +1,5 @@ import type { DifyMock } from '@test/fixtures/dify-mock/server' -import type { HostsBundle } from '@/auth/hosts' +import type { ActiveContext } from '@/auth/hosts' import { startMock } from '@test/fixtures/dify-mock/server' import { testHttpClient } from '@test/fixtures/http-client' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -7,17 +7,18 @@ import { stringifyOutput, table } from '@/framework/output' import { AppListOutput } from './handlers.js' import { runGetApp } from './run.js' -const baseBundle: HostsBundle = { - current_host: '127.0.0.1', +const baseActive: ActiveContext = { + host: '127.0.0.1', + email: 'tester@dify.ai', + ctx: { + account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' }, + workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, + available_workspaces: [ + { id: 'ws-1', name: 'Default', role: 'owner' }, + { id: 'ws-2', name: 'Other', role: 'normal' }, + ], + }, scheme: 'http', - account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' }, - workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, - available_workspaces: [ - { id: 'ws-1', name: 'Default', role: 'owner' }, - { id: 'ws-2', name: 'Other', role: 'normal' }, - ], - token_storage: 'file', - tokens: { bearer: 'dfoa_test' }, } describe('runGetApp', () => { @@ -36,7 +37,7 @@ describe('runGetApp', () => { } async function render(opts: Parameters[0] = {}): Promise { - const result = await runGetApp(opts, { bundle: baseBundle, http: http() }) + const result = await runGetApp(opts, { active: baseActive, http: http() }) return stringifyOutput(table({ format: opts.format ?? '', data: result.data, @@ -134,7 +135,11 @@ describe('runGetApp', () => { }) it('throws NotLoggedIn-equivalent when no workspace can be resolved', async () => { - const minimal: HostsBundle = { current_host: 'h', token_storage: 'file' } - await expect(runGetApp({}, { bundle: minimal, http: http() })).rejects.toThrow(/no workspace/) + const minimal: ActiveContext = { + host: 'h', + email: 'x@x.com', + ctx: { account: { email: 'x@x.com', name: 'X' } }, + } + await expect(runGetApp({}, { active: minimal, http: http() })).rejects.toThrow(/no workspace/) }) }) diff --git a/cli/src/commands/get/app/run.ts b/cli/src/commands/get/app/run.ts index 3cd84f4ec8..ba27c55f14 100644 --- a/cli/src/commands/get/app/run.ts +++ b/cli/src/commands/get/app/run.ts @@ -1,5 +1,5 @@ import type { AppDescribeResponse, AppListResponse, AppMode } from '@dify/contracts/api/openapi/types.gen' -import type { HostsBundle } from '@/auth/hosts' +import type { ActiveContext } from '@/auth/hosts' import type { HttpClient } from '@/http/types' import type { IOStreams } from '@/sys/io/streams' import { AppsClient } from '@/api/apps' @@ -24,7 +24,7 @@ export type GetAppOptions = { } export type GetAppDeps = { - readonly bundle: HostsBundle + readonly active: ActiveContext readonly http: HttpClient readonly io?: IOStreams readonly envLookup?: (k: string) => string | undefined @@ -57,12 +57,12 @@ export async function runGetApp(opts: GetAppOptions, deps: GetAppDeps): Promise< return runAllWorkspaces(apps, ws, opts, page, pageSize) } if (opts.appId !== undefined && opts.appId !== '') { - const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle }) - const wsName = workspaceNameForId(deps.bundle, wsId) + const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active }) + const wsName = workspaceNameForId(deps.active, wsId) const desc = await apps.describe(opts.appId, wsId, ['info']) return describeToEnvelope(desc, wsId, wsName) } - const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle }) + const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active }) return apps.list({ workspaceId: wsId, page, @@ -111,12 +111,13 @@ function describeToEnvelope(desc: AppDescribeResponse, wsId: string, wsName: str } } -function workspaceNameForId(b: HostsBundle, id: string): string { +function workspaceNameForId(active: ActiveContext, id: string): string { if (id === '') return '' - if (b.workspace?.id === id) - return b.workspace.name - for (const w of b.available_workspaces ?? []) { + const ctx = active.ctx + if (ctx.workspace?.id === id) + return ctx.workspace.name + for (const w of ctx.available_workspaces ?? []) { if (w.id === id) return w.name } diff --git a/cli/src/commands/get/member/handlers.ts b/cli/src/commands/get/member/handlers.ts index 9d2bc1af17..bd38f94637 100644 --- a/cli/src/commands/get/member/handlers.ts +++ b/cli/src/commands/get/member/handlers.ts @@ -1,6 +1,5 @@ import type { MemberListResponse, MemberResponse } from '@dify/contracts/api/openapi/types.gen' -import type { TableCell } from '@/framework/output' -import type { TableColumn } from '@/printers/format-table' +import type { TableCell, TableColumn } from '@/framework/output' export const MEMBER_MODE_KEY = 'member' const CURRENT_MARKER = '*' diff --git a/cli/src/commands/get/member/index.ts b/cli/src/commands/get/member/index.ts index 2e5752c46f..0012459533 100644 --- a/cli/src/commands/get/member/index.ts +++ b/cli/src/commands/get/member/index.ts @@ -37,7 +37,7 @@ export default class GetMember extends DifyCommand { limitRaw: flags.limit, format, }, - { bundle: ctx.bundle, http: ctx.http, io: ctx.io }, + { active: ctx.active, http: ctx.http, io: ctx.io }, ) return table({ format, data: result.data }) } diff --git a/cli/src/commands/get/member/run.test.ts b/cli/src/commands/get/member/run.test.ts index e40a1cd3eb..883cf6fafd 100644 --- a/cli/src/commands/get/member/run.test.ts +++ b/cli/src/commands/get/member/run.test.ts @@ -1,18 +1,19 @@ import type { MemberListResponse } from '@dify/contracts/api/openapi/types.gen' -import type { HostsBundle } from '@/auth/hosts' +import type { ActiveContext } from '@/auth/hosts' import type { HttpClient } from '@/http/types' import { describe, expect, it, vi } from 'vitest' import { bufferStreams } from '@/sys/io/streams' import { runGetMember } from './run.js' -function bundle(): HostsBundle { +function active(): ActiveContext { return { - current_host: 'cloud.dify.ai', - token_storage: 'file', - tokens: { bearer: 'dfoa_test' }, - account: { id: 'acct-1', email: 'me@example.com', name: 'Me' }, - workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, - available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }], + host: 'cloud.dify.ai', + email: 'me@example.com', + ctx: { + account: { id: 'acct-1', email: 'me@example.com', name: 'Me' }, + workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, + available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }], + }, } } @@ -37,7 +38,7 @@ describe('runGetMember', () => { const r = await runGetMember( {}, { - bundle: bundle(), + active: active(), http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, @@ -54,7 +55,7 @@ describe('runGetMember', () => { const r = await runGetMember( { workspace: 'ws-9' }, { - bundle: bundle(), + active: active(), http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, @@ -69,7 +70,7 @@ describe('runGetMember', () => { await runGetMember( { page: 3, limitRaw: '50' }, { - bundle: bundle(), + active: active(), http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, @@ -78,14 +79,20 @@ describe('runGetMember', () => { expect(client.list).toHaveBeenCalledWith('ws-1', { page: 3, limit: 50 }) }) - it('marks no row when bundle has no account id', async () => { + it('marks no row when active context has no account id', async () => { const client = fakeClient(env) - const b = bundle() - b.account = { id: '', email: '', name: '' } + const a: ActiveContext = { + host: 'cloud.dify.ai', + email: 'me@example.com', + ctx: { + account: { id: '', email: '', name: '' }, + workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, + }, + } const r = await runGetMember( {}, { - bundle: b, + active: a, http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, @@ -96,16 +103,16 @@ describe('runGetMember', () => { it('throws when no workspace can be resolved', async () => { const client = fakeClient(env) + const noWs: ActiveContext = { + host: 'cloud.dify.ai', + email: 'me@example.com', + ctx: { account: { id: 'acct-1', email: 'me@example.com', name: 'Me' } }, + } await expect( runGetMember( {}, { - bundle: { - current_host: '', - token_storage: 'file', - tokens: { bearer: 'dfoa_test' }, - account: { id: 'acct-1', email: '', name: '' }, - }, + active: noWs, http: {} as HttpClient, io: bufferStreams(), envLookup: () => undefined, @@ -132,7 +139,7 @@ describe('MemberListOutput shape', () => { const r = await runGetMember( {}, { - bundle: bundle(), + active: active(), http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, diff --git a/cli/src/commands/get/member/run.ts b/cli/src/commands/get/member/run.ts index b9c30cbdcb..201df51b00 100644 --- a/cli/src/commands/get/member/run.ts +++ b/cli/src/commands/get/member/run.ts @@ -1,4 +1,4 @@ -import type { HostsBundle } from '@/auth/hosts' +import type { ActiveContext } from '@/auth/hosts' import type { HttpClient } from '@/http/types' import type { IOStreams } from '@/sys/io/streams' import { MembersClient } from '@/api/members' @@ -16,7 +16,7 @@ export type GetMemberOptions = { } export type GetMemberDeps = { - readonly bundle: HostsBundle + readonly active: ActiveContext readonly http: HttpClient readonly io?: IOStreams readonly envLookup?: (k: string) => string | undefined @@ -39,7 +39,7 @@ export async function runGetMember( const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), - bundle: deps.bundle, + active: deps.active, }) const limit = resolveLimit(opts.limitRaw, env) @@ -50,7 +50,7 @@ export async function runGetMember( () => factory(deps.http).list(wsId, { page, limit }), ) - const callerId = deps.bundle.account?.id ?? '' + const callerId = deps.active.ctx.account?.id ?? '' const rows = envelope.data.map(m => new MemberRow(m, callerId !== '' && m.id === callerId)) return { data: new MemberListOutput(rows, envelope), workspaceId: wsId } } diff --git a/cli/src/commands/get/workspace/handlers.test.ts b/cli/src/commands/get/workspace/handlers.test.ts index 2132388493..8d96363257 100644 --- a/cli/src/commands/get/workspace/handlers.test.ts +++ b/cli/src/commands/get/workspace/handlers.test.ts @@ -1,6 +1,6 @@ import type { WorkspaceListResponse } from '@dify/contracts/api/openapi/types.gen' import { describe, expect, it } from 'vitest' -import { newWorkspaceObject, WORKSPACE_MODE_KEY, WorkspaceListOutput, WorkspaceRow } from './handlers' +import { WorkspaceListOutput, WorkspaceRow } from './handlers' function env(): WorkspaceListResponse { return { @@ -12,12 +12,6 @@ function env(): WorkspaceListResponse { } describe('get/workspace handlers', () => { - it('newWorkspaceObject mode = workspace + raw passthrough', () => { - const obj = newWorkspaceObject(env()) - expect(obj.mode()).toBe(WORKSPACE_MODE_KEY) - expect(obj.raw().workspaces[0]?.id).toBe('ws-1') - }) - it('WorkspaceRow defines table, name, and json print shapes', () => { const row = new WorkspaceRow('ws-1', 'Default', 'owner', 'normal', true) expect(row.tableRow()).toEqual(['ws-1', 'Default', 'owner', 'normal', '*']) diff --git a/cli/src/commands/get/workspace/handlers.ts b/cli/src/commands/get/workspace/handlers.ts index b7d3b12155..64e34eee5d 100644 --- a/cli/src/commands/get/workspace/handlers.ts +++ b/cli/src/commands/get/workspace/handlers.ts @@ -1,23 +1,8 @@ import type { WorkspaceListResponse } from '@dify/contracts/api/openapi/types.gen' -import type { TableCell } from '@/framework/output' -import type { TableColumn, TableHandler, TableRow } from '@/printers/format-table' -import { isPayloadShape } from '@/commands/get/app/payload-shape' +import type { TableCell, TableColumn } from '@/framework/output' -export const WORKSPACE_MODE_KEY = 'workspace' const CURRENT_MARKER = '*' -export type WorkspaceObject = { - mode: () => string - raw: () => WorkspaceListResponse -} - -export function newWorkspaceObject(env: WorkspaceListResponse): WorkspaceObject { - return { - mode: () => WORKSPACE_MODE_KEY, - raw: () => env, - } -} - export const WORKSPACE_COLUMNS: readonly TableColumn[] = [ { name: 'ID', priority: 0 }, { name: 'NAME', priority: 0 }, @@ -101,20 +86,3 @@ export class WorkspaceListOutput { return this.envelope } } - -export function workspaceTableHandler(currentId: string): TableHandler { - return { - columns: () => WORKSPACE_COLUMNS, - rows: (raw): readonly TableRow[] => { - if (!isPayloadShape(raw, 'workspaces')) - throw new Error('get/workspace table: unexpected payload shape') - return raw.workspaces.map(w => [ - w.id, - w.name, - w.role, - w.status, - w.current || (currentId !== '' && w.id === currentId) ? CURRENT_MARKER : '', - ]) - }, - } -} diff --git a/cli/src/commands/get/workspace/index.ts b/cli/src/commands/get/workspace/index.ts index 3bbc3d3f54..3f6f52e692 100644 --- a/cli/src/commands/get/workspace/index.ts +++ b/cli/src/commands/get/workspace/index.ts @@ -22,7 +22,7 @@ export default class GetWorkspace extends DifyCommand { const { flags } = this.parse(GetWorkspace, argv) const format = flags.output const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format }) - const result = await runGetWorkspace({ format }, { bundle: ctx.bundle, http: ctx.http, io: ctx.io }) + const result = await runGetWorkspace({ format }, { active: ctx.active, http: ctx.http, io: ctx.io }) if (result.kind === 'empty') return raw(result.message) return table({ diff --git a/cli/src/commands/get/workspace/run.test.ts b/cli/src/commands/get/workspace/run.test.ts index 65c8c4d755..659888f1f4 100644 --- a/cli/src/commands/get/workspace/run.test.ts +++ b/cli/src/commands/get/workspace/run.test.ts @@ -1,5 +1,5 @@ import type { DifyMock } from '@test/fixtures/dify-mock/server' -import type { HostsBundle } from '@/auth/hosts' +import type { ActiveContext } from '@/auth/hosts' import { startMock } from '@test/fixtures/dify-mock/server' import { testHttpClient } from '@test/fixtures/http-client' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -7,17 +7,18 @@ import { stringifyOutput, table } from '@/framework/output' import { WorkspaceListOutput } from './handlers.js' import { EMPTY_WORKSPACES_MESSAGE, runGetWorkspace } from './run.js' -const baseBundle: HostsBundle = { - current_host: '127.0.0.1', +const baseActive: ActiveContext = { + host: '127.0.0.1', + email: 'tester@dify.ai', + ctx: { + account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' }, + workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, + available_workspaces: [ + { id: 'ws-1', name: 'Default', role: 'owner' }, + { id: 'ws-2', name: 'Other', role: 'normal' }, + ], + }, scheme: 'http', - account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' }, - workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, - available_workspaces: [ - { id: 'ws-1', name: 'Default', role: 'owner' }, - { id: 'ws-2', name: 'Other', role: 'normal' }, - ], - token_storage: 'file', - tokens: { bearer: 'dfoa_test' }, } describe('runGetWorkspace', () => { @@ -35,8 +36,8 @@ describe('runGetWorkspace', () => { return testHttpClient(mock.url, 'dfoa_test') } - async function render(format = '', bundle = baseBundle): Promise { - const result = await runGetWorkspace({ format }, { bundle, http: http() }) + async function render(format = '', activeCtx = baseActive): Promise { + const result = await runGetWorkspace({ format }, { active: activeCtx, http: http() }) if (result.kind === 'empty') return result.message return stringifyOutput(table({ @@ -75,8 +76,8 @@ describe('runGetWorkspace', () => { } }) - it('falls back to bundle workspace.id when server current=false', async () => { - const overridden: HostsBundle = { ...baseBundle, workspace: { id: 'ws-2', name: 'Other', role: 'normal' } } + it('falls back to active context workspace.id when server current=false', async () => { + const overridden: ActiveContext = { ...baseActive, ctx: { ...baseActive.ctx, workspace: { id: 'ws-2', name: 'Other', role: 'normal' } } } const out = await render('', overridden) for (const line of out.split('\n')) { if (line.includes('ws-2')) diff --git a/cli/src/commands/get/workspace/run.ts b/cli/src/commands/get/workspace/run.ts index 847aed7856..2d678bf0da 100644 --- a/cli/src/commands/get/workspace/run.ts +++ b/cli/src/commands/get/workspace/run.ts @@ -1,4 +1,4 @@ -import type { HostsBundle } from '@/auth/hosts' +import type { ActiveContext } from '@/auth/hosts' import type { HttpClient } from '@/http/types' import type { IOStreams } from '@/sys/io/streams' import { WorkspacesClient } from '@/api/workspaces' @@ -14,7 +14,7 @@ export type GetWorkspaceOptions = { } export type GetWorkspaceDeps = { - readonly bundle: HostsBundle + readonly active: ActiveContext readonly http: HttpClient readonly io?: IOStreams readonly workspacesFactory?: (http: HttpClient) => WorkspacesClient @@ -33,7 +33,7 @@ export async function runGetWorkspace(opts: GetWorkspaceOptions, deps: GetWorksp ) if (env.workspaces.length === 0) return { kind: 'empty', message: EMPTY_WORKSPACES_MESSAGE } - const currentId = deps.bundle.workspace?.id ?? '' + const currentId = deps.active.ctx.workspace?.id ?? '' return { kind: 'output', data: new WorkspaceListOutput(env.workspaces.map(w => new WorkspaceRow( diff --git a/cli/src/commands/help/account/account.ts b/cli/src/commands/help/account/account.ts index 8cbf5e28e0..ab5334682e 100644 --- a/cli/src/commands/help/account/account.ts +++ b/cli/src/commands/help/account/account.ts @@ -14,7 +14,10 @@ export const ACCOUNT_HELP_TEXT = `difyctl: account-bearer onboarding Tips: * Pass --workspace when you need to target a non-default workspace. - * Use --stream for long-running workflow calls (post-v1.0 milestone). + * Use --stream for long-running workflow calls. + * 'difyctl auth list' shows all authenticated hosts and accounts. + * 'difyctl use host [--domain ]' switches the active Dify instance. + * 'difyctl use account [--email ]' switches accounts on the current host. * 'difyctl env list' shows every env var difyctl reads. ` diff --git a/cli/src/commands/resume/app/index.ts b/cli/src/commands/resume/app/index.ts index a0cb17500d..3446c62824 100644 --- a/cli/src/commands/resume/app/index.ts +++ b/cli/src/commands/resume/app/index.ts @@ -49,7 +49,7 @@ export default class ResumeApp extends DifyCommand { stream: flags.stream, think: flags.think, }, - { bundle: ctx.bundle, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache }, + { active: ctx.active, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache }, ) } } diff --git a/cli/src/commands/resume/app/run.ts b/cli/src/commands/resume/app/run.ts index b850147f08..5385d50f77 100644 --- a/cli/src/commands/resume/app/run.ts +++ b/cli/src/commands/resume/app/run.ts @@ -1,4 +1,4 @@ -import type { HostsBundle } from '@/auth/hosts' +import type { ActiveContext } from '@/auth/hosts' import type { AppInfoCache } from '@/cache/app-info' import type { RunContext } from '@/commands/run/app/_strategies/index' import type { HttpClient } from '@/http/types' @@ -8,7 +8,6 @@ import { AppRunClient } from '@/api/app-run' import { AppsClient } from '@/api/apps' import { pickStrategy } from '@/commands/run/app/_strategies/index' import { RUN_MODES } from '@/commands/run/app/handlers' -import { AppRunPrintFlags } from '@/commands/run/app/print-flags' import { getEnv, processExit } from '@/sys/index' import { colorEnabled, colorScheme } from '@/sys/io/color' import { FieldInfo } from '@/types/app-meta' @@ -30,7 +29,7 @@ export type ResumeAppOptions = { } export type ResumeAppDeps = { - readonly bundle: HostsBundle + readonly active: ActiveContext readonly http: HttpClient readonly host: string readonly io: IOStreams @@ -78,7 +77,7 @@ async function resolveInputs( export async function resumeApp(opts: ResumeAppOptions, deps: ResumeAppDeps): Promise { const env = deps.envLookup ?? getEnv - const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle }) + const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active }) const apps = new AppsClient(deps.http) const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache }) @@ -116,7 +115,6 @@ export async function resumeApp(opts: ResumeAppOptions, deps: ResumeAppDeps): Pr deps.io.err.write(` ${cs.dim('workflow execution resumed')}\n`) } const livePrint = opts.stream === true - const printFlags = new AppRunPrintFlags() const adaptedRunClient = { runStream: (_appId: string, _body: unknown, streamOpts?: { signal?: AbortSignal }) => @@ -146,7 +144,6 @@ export async function resumeApp(opts: ResumeAppOptions, deps: ResumeAppDeps): Pr isText, livePrint, runClient: adaptedRunClient as unknown as AppRunClient, - printFlags, exit, think: opts.think ?? false, } diff --git a/cli/src/commands/run/app/_strategies/index.ts b/cli/src/commands/run/app/_strategies/index.ts index dc4b2326e6..f9a1f4a544 100644 --- a/cli/src/commands/run/app/_strategies/index.ts +++ b/cli/src/commands/run/app/_strategies/index.ts @@ -1,5 +1,4 @@ import type { AppRunClient } from '@/api/app-run' -import type { AppRunPrintFlags } from '@/commands/run/app/print-flags' import type { RunAppDeps, RunAppOptions } from '@/commands/run/app/run' import { StreamingStructuredStrategy } from './streaming-structured' import { StreamingTextStrategy } from './streaming-text' @@ -12,7 +11,6 @@ export type RunContext = { readonly isText: boolean readonly livePrint: boolean readonly runClient: AppRunClient - readonly printFlags: AppRunPrintFlags readonly exit: (code: number) => never readonly think: boolean } diff --git a/cli/src/commands/run/app/_strategies/streaming-structured.ts b/cli/src/commands/run/app/_strategies/streaming-structured.ts index d19884cdf3..182aa89d08 100644 --- a/cli/src/commands/run/app/_strategies/streaming-structured.ts +++ b/cli/src/commands/run/app/_strategies/streaming-structured.ts @@ -4,6 +4,8 @@ import { buildRunBody } from '@/api/app-run' import { chatConversationHint, newAppRunObject, RUN_MODES } from '@/commands/run/app/handlers' import { renderHitlHint, renderHitlOutput } from '@/commands/run/app/hitl-render' import { collect, HitlPauseError } from '@/commands/run/app/sse-collector' +import { formatted, stringifyOutput } from '@/framework/output' +import { handle, unhandle } from '@/sys/index' import { colorEnabled, colorScheme } from '@/sys/io/color' import { startSpinner } from '@/sys/io/spinner' import { extractThinkBlocks, stripThinkBlocks } from '@/sys/io/think-filter' @@ -30,7 +32,7 @@ async function* captureTaskId( export class StreamingStructuredStrategy implements RunStrategy { async execute(ctx: RunContext): Promise { - const { opts, deps, mode, format, isText, printFlags, exit } = ctx + const { opts, deps, mode, format, isText, exit } = ctx const ctrl = new AbortController() const body = buildRunBody({ message: opts.message, @@ -50,7 +52,7 @@ export class StreamingStructuredStrategy implements RunStrategy { ctrl.abort() exit(1) } - process.once('SIGINT', cleanup) + handle('SIGINT', cleanup) let resp: Record try { @@ -72,7 +74,7 @@ export class StreamingStructuredStrategy implements RunStrategy { } finally { spinner.stop() - process.off('SIGINT', cleanup) + unhandle('SIGINT', cleanup) } let processedResp = resp if (typeof processedResp.answer === 'string') { @@ -88,7 +90,7 @@ export class StreamingStructuredStrategy implements RunStrategy { } const respMode = typeof processedResp.mode === 'string' && processedResp.mode !== '' ? processedResp.mode : mode - deps.io.out.write(printFlags.toPrinter(format).print(newAppRunObject(respMode, processedResp))) + deps.io.out.write(stringifyOutput(formatted({ format, data: newAppRunObject(respMode, processedResp) }))) if (isText && CHAT_MODES.has(respMode)) { const cs = colorScheme(colorEnabled(deps.io.isErrTTY)) const hint = chatConversationHint(processedResp, cs) diff --git a/cli/src/commands/run/app/_strategies/streaming-text.ts b/cli/src/commands/run/app/_strategies/streaming-text.ts index 49bf31eee6..872acc785b 100644 --- a/cli/src/commands/run/app/_strategies/streaming-text.ts +++ b/cli/src/commands/run/app/_strategies/streaming-text.ts @@ -2,11 +2,12 @@ import type { RunContext, RunStrategy } from './index' import { buildRunBody } from '@/api/app-run' import { renderHitlHint, renderHitlOutput } from '@/commands/run/app/hitl-render' import { decodeStreamError, HitlPauseError } from '@/commands/run/app/sse-collector' +import { streamPrinterFor } from '@/commands/run/app/stream-handlers' import { handle, unhandle } from '@/sys/index' export class StreamingTextStrategy implements RunStrategy { async execute(ctx: RunContext): Promise { - const { opts, deps, mode, printFlags, exit } = ctx + const { opts, deps, mode, exit } = ctx const ctrl = new AbortController() const body = buildRunBody({ message: opts.message, @@ -28,7 +29,7 @@ export class StreamingTextStrategy implements RunStrategy { try { const events = await ctx.runClient.runStream(opts.appId, body, { signal: ctrl.signal }) - const sp = printFlags.toStreamPrinter(mode, ctx.think, deps.io.isErrTTY) + const sp = streamPrinterFor(mode, ctx.think, deps.io.isErrTTY) const dec = new TextDecoder() for await (const ev of events) { if (ev.name === 'ping') diff --git a/cli/src/commands/run/app/handlers.ts b/cli/src/commands/run/app/handlers.ts index c8c2055433..3d3d75ec08 100644 --- a/cli/src/commands/run/app/handlers.ts +++ b/cli/src/commands/run/app/handlers.ts @@ -1,4 +1,4 @@ -import type { TextHandler } from '@/printers/format-text' +import type { FormattedPrintable } from '@/framework/output' import type { ColorScheme } from '@/sys/io/color' export const RUN_MODES = { @@ -11,53 +11,58 @@ export const RUN_MODES = { export type RunMode = typeof RUN_MODES[keyof typeof RUN_MODES] -export type AppRunObject = { - mode: () => string - raw: () => Record -} +export type AppRunObject = FormattedPrintable export function newAppRunObject(mode: string, resp: Record): AppRunObject { const filled = resp.mode === undefined || resp.mode === '' ? { ...resp, mode } : resp - return { mode: () => mode, raw: () => filled } + return { + text: () => textForMode(mode, filled), + json: () => filled, + } } -export const chatTextHandler: TextHandler = { - render(raw): string { - const resp = raw as Record - const out: string[] = [] - const answer = pickString(resp, 'answer') - if (answer !== undefined) - out.push(answer) - out.push('') - return out.join('\n') - }, +function textForMode(mode: string, raw: Record): string { + switch (mode) { + case RUN_MODES.Chat: + case RUN_MODES.AgentChat: + case RUN_MODES.AdvancedChat: + return renderChat(raw) + case RUN_MODES.Completion: + return renderCompletion(raw) + case RUN_MODES.Workflow: + return renderWorkflow(raw) + default: + return `${JSON.stringify(raw)}\n` + } } -export const completionTextHandler: TextHandler = { - render(raw): string { - const resp = raw as Record - const answer = pickString(resp, 'answer') - return `${answer ?? ''}\n` - }, +function renderChat(raw: Record): string { + const out: string[] = [] + const answer = pickString(raw, 'answer') + if (answer !== undefined) + out.push(answer) + out.push('') + return out.join('\n') } -export const workflowTextHandler: TextHandler = { - render(raw): string { - const resp = raw as Record - const data = resp.data - if (data !== null && typeof data === 'object' && 'outputs' in data) { - const { outputs } = data as { outputs: unknown } - if (outputs !== undefined) { - if (typeof outputs === 'object' && outputs !== null) { - const entries = Object.entries(outputs as Record) - if (entries.length === 1 && typeof entries[0]![1] === 'string') - return `${entries[0]![1]}\n` - } - return `${JSON.stringify(outputs)}\n` +function renderCompletion(raw: Record): string { + return `${pickString(raw, 'answer') ?? ''}\n` +} + +function renderWorkflow(raw: Record): string { + const data = raw.data + if (data !== null && typeof data === 'object' && 'outputs' in data) { + const { outputs } = data as { outputs: unknown } + if (outputs !== undefined) { + if (typeof outputs === 'object' && outputs !== null) { + const entries = Object.entries(outputs as Record) + if (entries.length === 1 && typeof entries[0]![1] === 'string') + return `${entries[0]![1]}\n` } + return `${JSON.stringify(outputs)}\n` } - return `${JSON.stringify(resp)}\n` - }, + } + return `${JSON.stringify(raw)}\n` } export function chatConversationHint(resp: Record, cs: ColorScheme): string | undefined { diff --git a/cli/src/commands/run/app/index.ts b/cli/src/commands/run/app/index.ts index 6466bf16cf..3c72ea394f 100644 --- a/cli/src/commands/run/app/index.ts +++ b/cli/src/commands/run/app/index.ts @@ -54,7 +54,7 @@ export default class RunApp extends DifyCommand { stream: flags.stream, think: flags.think, }, - { bundle: ctx.bundle, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache }, + { active: ctx.active, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache }, ) } diff --git a/cli/src/commands/run/app/print-flags.ts b/cli/src/commands/run/app/print-flags.ts deleted file mode 100644 index a3712ca980..0000000000 --- a/cli/src/commands/run/app/print-flags.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { PrintFlags } from '@/printers/printer' -import type { StreamPrinter } from '@/printers/stream-printer' -import { JsonYamlPrintFlags } from '@/printers/format-json-yaml' -import { TextPrintFlags } from '@/printers/format-text' -import { CompositePrintFlags } from '@/printers/printer' -import { chatTextHandler, completionTextHandler, RUN_MODES, workflowTextHandler } from './handlers' -import { streamPrinterFor } from './stream-handlers' - -export class AppRunPrintFlags extends CompositePrintFlags { - private readonly jsonYaml = new JsonYamlPrintFlags() - private readonly text = new TextPrintFlags() - - constructor() { - super() - this.text.register(chatTextHandler, RUN_MODES.Chat, RUN_MODES.AgentChat, RUN_MODES.AdvancedChat) - this.text.register(completionTextHandler, RUN_MODES.Completion) - this.text.register(workflowTextHandler, RUN_MODES.Workflow) - } - - protected families(): readonly PrintFlags[] { - return [this.jsonYaml, this.text] - } - - toStreamPrinter(mode: string, think = false, isTTY = false): StreamPrinter { - return streamPrinterFor(mode, think, isTTY) - } -} diff --git a/cli/src/commands/run/app/run.test.ts b/cli/src/commands/run/app/run.test.ts index e97a1244bc..efe6a07e80 100644 --- a/cli/src/commands/run/app/run.test.ts +++ b/cli/src/commands/run/app/run.test.ts @@ -1,5 +1,5 @@ import type { DifyMock } from '@test/fixtures/dify-mock/server' -import type { HostsBundle } from '@/auth/hosts' +import type { ActiveContext } from '@/auth/hosts' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -13,17 +13,18 @@ import { CACHE_APP_INFO, getCache } from '@/store/manager' import { bufferStreams } from '@/sys/io/streams' import { runApp } from './run.js' -function bundle(): HostsBundle { +function active(): ActiveContext { return { - current_host: 'http://localhost', - token_storage: 'file', - tokens: { bearer: 'dfoa_test' }, - account: { id: 'acct-1', email: 't@d.ai', name: 'T' }, - workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, - available_workspaces: [ - { id: 'ws-1', name: 'Default', role: 'owner' }, - { id: 'ws-2', name: 'Other', role: 'normal' }, - ], + host: 'http://localhost', + email: 't@d.ai', + ctx: { + account: { id: 'acct-1', email: 't@d.ai', name: 'T' }, + workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, + available_workspaces: [ + { id: 'ws-1', name: 'Default', role: 'owner' }, + { id: 'ws-2', name: 'Other', role: 'normal' }, + ], + }, } } @@ -51,7 +52,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-1', message: 'hi' }, - { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(io.outBuf()).toBe('echo: hi\n') expect(io.errBuf()).toContain('--conversation conv-1') @@ -62,7 +63,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await expect(runApp( { appId: 'app-2', message: 'hi' }, - { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, )).rejects.toMatchObject({ code: 'usage_invalid_flag' }) }) @@ -71,7 +72,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-2', inputs: { x: '1' } }, - { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(io.outBuf()).toBe('echo: \n') }) @@ -81,7 +82,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-1', message: 'hi', format: 'json' }, - { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) const parsed = JSON.parse(io.outBuf()) as { mode: string, answer: string } expect(parsed.mode).toBe('chat') @@ -92,7 +93,7 @@ describe('runApp', () => { const io = bufferStreams() await expect(runApp( { appId: 'app-1', format: 'bogus' }, - { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io }, )).rejects.toThrow(/not supported/) }) @@ -101,7 +102,7 @@ describe('runApp', () => { await expect(runApp( { appId: 'nope', message: 'hi' }, { - bundle: bundle(), + active: active(), http: testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }), host: mock.url, io, @@ -114,7 +115,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-1', message: 'hi', stream: true }, - { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(io.outBuf()).toContain('echo: ') expect(io.outBuf()).toContain('hi') @@ -126,7 +127,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-1', message: 'hi', stream: true, format: 'json' }, - { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) const parsed = JSON.parse(io.outBuf()) as { mode: string, answer: string, conversation_id: string } expect(parsed.mode).toBe('chat') @@ -139,7 +140,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-4', workspace: 'ws-2', message: 'do research' }, - { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(io.outBuf()).toContain('do research') expect(io.errBuf()).toContain('--conversation conv-1') @@ -150,7 +151,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-4', workspace: 'ws-2', message: 'go', stream: true }, - { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(io.outBuf()).toContain('go') expect(io.errBuf()).toContain('thought:') @@ -161,7 +162,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-2', inputs: { x: '1' }, stream: true, format: 'json' }, - { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) const parsed = JSON.parse(io.outBuf()) as { mode: string, data: { status: string } } expect(parsed.mode).toBe('workflow') @@ -174,7 +175,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await expect(runApp( { appId: 'app-1', message: 'hi', stream: true }, - { bundle: bundle(), http: testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }), host: mock.url, io, cache }, + { active: active(), http: testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }), host: mock.url, io, cache }, )).rejects.toMatchObject({ code: 'server_5xx' }) }) @@ -186,7 +187,7 @@ describe('runApp', () => { await writeFile(inputsFile, JSON.stringify({ x: 'from-file' })) await runApp( { appId: 'app-2', inputsFile }, - { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(io.outBuf()).toBe('echo: \n') }) @@ -198,7 +199,7 @@ describe('runApp', () => { await writeFile(inputsFile, JSON.stringify([1, 2, 3])) await expect(runApp( { appId: 'app-2', inputsFile }, - { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io }, )).rejects.toThrow(/must be a JSON object/) }) @@ -207,7 +208,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-2', inputsJson: '{"x":"hello"}' }, - { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(io.outBuf()).toBe('echo: \n') }) @@ -219,7 +220,7 @@ describe('runApp', () => { await writeFile(inputsFile, '{}') await expect(runApp( { appId: 'app-2', inputsJson: '{}', inputsFile }, - { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io }, )).rejects.toThrow(/mutually exclusive/) }) @@ -231,7 +232,7 @@ describe('runApp', () => { await expect(runApp( { appId: 'app-2', inputs: {} }, { - bundle: bundle(), + active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, @@ -260,7 +261,7 @@ describe('runApp', () => { await expect(runApp( { appId: 'app-2', inputs: {}, format: 'json' }, { - bundle: bundle(), + active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, @@ -284,7 +285,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await resumeApp( { appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, withHistory: false }, - { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(io.outBuf()).toBe('echo: resumed\n') }) @@ -295,7 +296,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await resumeApp( { appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {} }, - { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(io.outBuf()).toBe('echo: resumed\n') }) @@ -306,7 +307,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await resumeApp( { appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, stream: true }, - { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) // stream mode for workflow: node_started → "→ " on stderr expect(io.errBuf()).toContain('After Resume') @@ -317,7 +318,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-2', files: ['doc=https://example.com/report.pdf'] }, - { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(io.outBuf()).toBe('echo: \n') expect(mock.uploadCallCount).toBe(0) @@ -338,7 +339,7 @@ describe('runApp', () => { await writeFile(filePath, 'fake pdf content') await runApp( { appId: 'app-2', files: [`doc=@${filePath}`] }, - { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(io.outBuf()).toBe('echo: \n') expect(mock.uploadCallCount).toBe(1) @@ -355,7 +356,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-2', inputs: { doc: 'old-value' }, files: ['doc=https://example.com/override.pdf'] }, - { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(io.outBuf()).toBe('echo: \n') const runInputs = mock.lastRunBody?.inputs as Record<string, unknown> diff --git a/cli/src/commands/run/app/run.ts b/cli/src/commands/run/app/run.ts index 468eac826e..64bee92f3b 100644 --- a/cli/src/commands/run/app/run.ts +++ b/cli/src/commands/run/app/run.ts @@ -1,4 +1,4 @@ -import type { HostsBundle } from '@/auth/hosts' +import type { ActiveContext } from '@/auth/hosts' import type { AppInfoCache } from '@/cache/app-info' import type { HttpClient } from '@/http/types' import type { IOStreams } from '@/sys/io/streams' @@ -14,7 +14,6 @@ import { FieldInfo } from '@/types/app-meta' import { resolveWorkspaceId } from '@/workspace/resolver' import { resolveFileInputs } from './file-flags.js' import { RUN_MODES } from './handlers.js' -import { AppRunPrintFlags } from './print-flags.js' export type RunAppOptions = { readonly appId: string @@ -32,7 +31,7 @@ export type RunAppOptions = { } export type RunAppDeps = { - readonly bundle: HostsBundle + readonly active: ActiveContext readonly http: HttpClient readonly host: string readonly io: IOStreams @@ -80,7 +79,7 @@ async function resolveInputs( export async function runApp(opts: RunAppOptions, deps: RunAppDeps): Promise<void> { const env = deps.envLookup ?? getEnv - const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle }) + const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active }) const apps = new AppsClient(deps.http) const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache }) const m = await meta.get(opts.appId, wsId, [FieldInfo]) @@ -110,9 +109,8 @@ export async function runApp(opts: RunAppOptions, deps: RunAppDeps): Promise<voi const isText = TEXT_FORMATS.has(format) const livePrint = opts.stream === true const runClient = new AppRunClient(deps.http) - const printFlags = new AppRunPrintFlags() const exit = deps.exit ?? processExit - const ctx = { opts: { ...opts, inputs }, deps, mode, format, isText, livePrint, runClient, printFlags, exit, think: opts.think ?? false } + const ctx = { opts: { ...opts, inputs }, deps, mode, format, isText, livePrint, runClient, 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 799bcf7fcd..355813d82b 100644 --- a/cli/src/commands/run/app/stream-handlers.ts +++ b/cli/src/commands/run/app/stream-handlers.ts @@ -1,6 +1,6 @@ import type { HitlPausePayload } from './sse-collector' +import type { StreamPrinter } from '@/framework/stream' import type { SseEvent } from '@/http/sse' -import type { StreamPrinter } from '@/printers/stream-printer' import { newError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' import { colorEnabled, colorScheme } from '@/sys/io/color' diff --git a/cli/src/commands/set/member/index.ts b/cli/src/commands/set/member/index.ts index 5fe4782359..75e992d985 100644 --- a/cli/src/commands/set/member/index.ts +++ b/cli/src/commands/set/member/index.ts @@ -36,7 +36,7 @@ export default class SetMember extends DifyCommand { const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format }) const result = await runSetMember( { memberId: args.memberId, role: flags.role, workspace: flags.workspace, format }, - { bundle: ctx.bundle, http: ctx.http, io: ctx.io }, + { active: ctx.active, http: ctx.http, io: ctx.io }, ) return formatted({ format, data: result.data }) } diff --git a/cli/src/commands/set/member/run.test.ts b/cli/src/commands/set/member/run.test.ts index 3f6b988fe2..eeba54c1ce 100644 --- a/cli/src/commands/set/member/run.test.ts +++ b/cli/src/commands/set/member/run.test.ts @@ -1,17 +1,18 @@ -import type { HostsBundle } from '@/auth/hosts' +import type { ActiveContext } from '@/auth/hosts' import type { HttpClient } from '@/http/types' import { describe, expect, it, vi } from 'vitest' import { bufferStreams } from '@/sys/io/streams' import { runSetMember } from './run.js' -function bundle(): HostsBundle { +function active(): ActiveContext { return { - current_host: 'cloud.dify.ai', - token_storage: 'file', - tokens: { bearer: 'dfoa_test' }, - account: { id: 'acct-1', email: 'me@example.com', name: 'Me' }, - workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, - available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }], + host: 'cloud.dify.ai', + email: 'me@example.com', + ctx: { + account: { id: 'acct-1', email: 'me@example.com', name: 'Me' }, + workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, + available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }], + }, } } @@ -27,7 +28,7 @@ describe('runSetMember', () => { const result = await runSetMember( { memberId: 'acct-2', role: 'admin' }, { - bundle: bundle(), + active: active(), http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, @@ -46,7 +47,7 @@ describe('runSetMember', () => { runSetMember( { memberId: 'acct-2', role: 'owner' }, { - bundle: bundle(), + active: active(), http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, @@ -62,7 +63,7 @@ describe('runSetMember', () => { runSetMember( { memberId: '', role: 'admin' }, { - bundle: bundle(), + active: active(), http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, @@ -76,7 +77,7 @@ describe('runSetMember', () => { await runSetMember( { memberId: 'acct-2', role: 'normal', workspace: 'ws-9' }, { - bundle: bundle(), + active: active(), http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, diff --git a/cli/src/commands/set/member/run.ts b/cli/src/commands/set/member/run.ts index 714fc5d3ad..b627ef27f8 100644 --- a/cli/src/commands/set/member/run.ts +++ b/cli/src/commands/set/member/run.ts @@ -1,4 +1,4 @@ -import type { HostsBundle } from '@/auth/hosts' +import type { ActiveContext } from '@/auth/hosts' import type { HttpClient } from '@/http/types' import type { IOStreams } from '@/sys/io/streams' import { MembersClient } from '@/api/members' @@ -18,7 +18,7 @@ export type SetMemberOptions = { } export type SetMemberDeps = { - readonly bundle: HostsBundle + readonly active: ActiveContext readonly http: HttpClient readonly io?: IOStreams readonly envLookup?: (k: string) => string | undefined @@ -59,7 +59,7 @@ export async function runSetMember( const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), - bundle: deps.bundle, + active: deps.active, }) await runWithSpinner( diff --git a/cli/src/commands/tree.generated.ts b/cli/src/commands/tree.generated.ts index 9ba4fe1192..6e85da428a 100644 --- a/cli/src/commands/tree.generated.ts +++ b/cli/src/commands/tree.generated.ts @@ -4,9 +4,9 @@ import type { CommandTree } from '@/framework/registry' import AuthDevicesList from '@/commands/auth/devices/list/index' import AuthDevicesRevoke from '@/commands/auth/devices/revoke/index' +import AuthList from '@/commands/auth/list/index' import AuthLogin from '@/commands/auth/login/index' import AuthLogout from '@/commands/auth/logout/index' -import AuthStatus from '@/commands/auth/status/index' import AuthWhoami from '@/commands/auth/whoami/index' import ConfigGet from '@/commands/config/get/index' import ConfigPath from '@/commands/config/path/index' @@ -26,6 +26,8 @@ import HelpExternal from '@/commands/help/external/index' import ResumeApp from '@/commands/resume/app/index' import RunApp from '@/commands/run/app/index' import SetMember from '@/commands/set/member/index' +import UseAccount from '@/commands/use/account/index' +import UseHost from '@/commands/use/host/index' import UseWorkspace from '@/commands/use/workspace/index' import Version from '@/commands/version/index' @@ -38,9 +40,9 @@ export const commandTree: CommandTree = { revoke: { command: AuthDevicesRevoke, subcommands: {} }, }, }, + list: { command: AuthList, subcommands: {} }, login: { command: AuthLogin, subcommands: {} }, logout: { command: AuthLogout, subcommands: {} }, - status: { command: AuthStatus, subcommands: {} }, whoami: { command: AuthWhoami, subcommands: {} }, }, }, @@ -104,6 +106,8 @@ export const commandTree: CommandTree = { }, use: { subcommands: { + account: { command: UseAccount, subcommands: {} }, + host: { command: UseHost, subcommands: {} }, workspace: { command: UseWorkspace, subcommands: {} }, }, }, diff --git a/cli/src/commands/use/account/index.ts b/cli/src/commands/use/account/index.ts new file mode 100644 index 0000000000..1f62ffb614 --- /dev/null +++ b/cli/src/commands/use/account/index.ts @@ -0,0 +1,22 @@ +import { DifyCommand } from '@/commands/_shared/dify-command' +import { Flags } from '@/framework/flags' +import { realStreams } from '@/sys/io/streams' +import { runUseAccount } from './use-account' + +export default class UseAccount extends DifyCommand { + static override description = 'Switch the active account on the current host' + + static override examples = [ + '<%= config.bin %> use account', + '<%= config.bin %> use account --email bob@corp.com', + ] + + static override flags = { + email: Flags.string({ description: 'email of the account to switch to (interactive picker shown when omitted in TTY)', default: '' }), + } + + async run(argv: string[]): Promise<void> { + const { flags } = this.parse(UseAccount, argv) + await runUseAccount({ io: realStreams(), email: flags.email !== '' ? flags.email : undefined }) + } +} diff --git a/cli/src/commands/use/account/use-account.test.ts b/cli/src/commands/use/account/use-account.test.ts new file mode 100644 index 0000000000..9fa6d506d1 --- /dev/null +++ b/cli/src/commands/use/account/use-account.test.ts @@ -0,0 +1,63 @@ +import type { Key, Store } from '@/store/store' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { Registry } from '@/auth/hosts' +import { ENV_CONFIG_DIR } from '@/store/dir' +import { bufferStreams } from '@/sys/io/streams' +import { runUseAccount } from './use-account' + +function memStore(seed: Record<string, string>): Store { + const m = new Map<string, unknown>(Object.entries(seed)) + return { + get<T>(k: Key<T>): T { return (m.get(k.key) as T | undefined) ?? k.default }, + set<T>(k: Key<T>, v: T): void { m.set(k.key, v) }, + unset<T>(k: Key<T>): void { m.delete(k.key) }, + } +} + +describe('runUseAccount', () => { + let dir: string + let prev: string | undefined + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'difyctl-useacct-')) + prev = process.env[ENV_CONFIG_DIR] + process.env[ENV_CONFIG_DIR] = dir + const reg = Registry.empty('file') + reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } }) + reg.upsert('h1', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } }) + reg.setHost('h1') + reg.setAccount('a@x') + reg.save() + }) + afterEach(async () => { + if (prev === undefined) + delete process.env[ENV_CONFIG_DIR] + else process.env[ENV_CONFIG_DIR] = prev + await rm(dir, { recursive: true, force: true }) + }) + + it('switches current_account when email valid + token present', async () => { + await runUseAccount({ io: bufferStreams(), email: 'b@x', store: memStore({ 'tokens.h1.b@x': 'dfoa_b' }) }) + expect(Registry.load().hosts.h1?.current_account).toBe('b@x') + }) + + it('errors when the account has no stored token', async () => { + await expect(runUseAccount({ io: bufferStreams(), email: 'b@x', store: memStore({}) })) + .rejects + .toThrow(/log in|no credential/i) + }) + + it('errors when the email is unknown on the current host', async () => { + await expect(runUseAccount({ io: bufferStreams(), email: 'z@x', store: memStore({ 'tokens.h1.z@x': 'x' }) })) + .rejects + .toThrow(/unknown account|no account/i) + }) + + it('errors in non-TTY when email omitted', async () => { + const io = bufferStreams() + ;(io as { isErrTTY: boolean }).isErrTTY = false + await expect(runUseAccount({ io, email: undefined, store: memStore({}) })).rejects.toThrow(/--email/i) + }) +}) diff --git a/cli/src/commands/use/account/use-account.ts b/cli/src/commands/use/account/use-account.ts new file mode 100644 index 0000000000..3fdb8d455b --- /dev/null +++ b/cli/src/commands/use/account/use-account.ts @@ -0,0 +1,76 @@ +import type { HostEntry } from '@/auth/hosts' +import type { Store } from '@/store/store' +import type { IOStreams } from '@/sys/io/streams' +import { notLoggedInError, Registry } from '@/auth/hosts' +import { BaseError } from '@/errors/base' +import { ErrorCode } from '@/errors/codes' +import { getTokenStore, tokenKey } from '@/store/manager' +import { colorEnabled, colorScheme } from '@/sys/io/color' +import { selectFromList } from '@/sys/io/select' + +export type UseAccountOptions = { + readonly io: IOStreams + readonly email: string | undefined + /** Optional override for tests; production resolves via `getTokenStore`. */ + readonly store?: Store +} + +type AccountChoice = { email: string, name: string, sso: boolean, active: boolean } + +const USE_HOST_HINT = 'run \'difyctl use host\' or \'difyctl auth login\'' + +export async function runUseAccount(opts: UseAccountOptions): Promise<void> { + const cs = colorScheme(colorEnabled(opts.io.isErrTTY)) + const reg = Registry.load() + if (reg.current_host === undefined) + throw notLoggedInError(USE_HOST_HINT) + const host = reg.current_host + const entry = reg.hosts[host] + if (entry === undefined) + throw notLoggedInError(USE_HOST_HINT) + + const emails = Object.keys(entry.accounts) + const target = opts.email ?? await pickAccount(opts, entry, host) + if (!emails.includes(target)) { + throw new BaseError({ + code: ErrorCode.UsageInvalidFlag, + message: `unknown account "${target}" on ${host}; known: ${emails.join(', ')}`, + }) + } + + const store = opts.store ?? getTokenStore().store + if (store.get(tokenKey(host, target)) === '') { + throw new BaseError({ + code: ErrorCode.NotLoggedIn, + message: `no credential stored for ${target} on ${host}`, + hint: `run 'difyctl auth login --host ${host}'`, + }) + } + + reg.setAccount(target) + reg.save() + opts.io.out.write(`${cs.successIcon()} Active account on ${host} is now ${target}\n`) +} + +async function pickAccount(opts: UseAccountOptions, entry: HostEntry, host: string): Promise<string> { + const emails = Object.keys(entry.accounts) + if (!opts.io.isErrTTY) { + throw new BaseError({ + code: ErrorCode.UsageMissingArg, + message: `--email is required (no TTY); known accounts on ${host}: ${emails.join(', ')}`, + }) + } + const choices: AccountChoice[] = Object.entries(entry.accounts).map(([email, ctx]) => ({ + email, + name: ctx.account.name, + sso: ctx.external_subject !== undefined, + active: entry.current_account === email, + })) + const picked = await selectFromList<AccountChoice>({ + io: opts.io, + items: choices, + header: `Select an account on ${host}`, + render: c => `${c.active ? '* ' : ' '}${c.email} ${c.sso ? '(SSO)' : c.name !== '' ? `(${c.name})` : ''}`.trimEnd(), + }) + return picked.email +} diff --git a/cli/src/commands/use/host/index.ts b/cli/src/commands/use/host/index.ts new file mode 100644 index 0000000000..ade54647db --- /dev/null +++ b/cli/src/commands/use/host/index.ts @@ -0,0 +1,22 @@ +import { DifyCommand } from '@/commands/_shared/dify-command' +import { Flags } from '@/framework/flags' +import { realStreams } from '@/sys/io/streams' +import { runUseHost } from './use-host' + +export default class UseHost extends DifyCommand { + static override description = 'Switch the active Dify host' + + static override examples = [ + '<%= config.bin %> use host', + '<%= config.bin %> use host --domain cloud.dify.ai', + ] + + static override flags = { + domain: Flags.string({ description: 'host domain to switch to, e.g. cloud.dify.ai (interactive picker shown when omitted in TTY)', default: '' }), + } + + async run(argv: string[]): Promise<void> { + const { flags } = this.parse(UseHost, argv) + await runUseHost({ io: realStreams(), host: flags.domain !== '' ? flags.domain : undefined }) + } +} diff --git a/cli/src/commands/use/host/use-host.test.ts b/cli/src/commands/use/host/use-host.test.ts new file mode 100644 index 0000000000..5796d5b4a0 --- /dev/null +++ b/cli/src/commands/use/host/use-host.test.ts @@ -0,0 +1,50 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { Registry } from '@/auth/hosts' +import { ENV_CONFIG_DIR } from '@/store/dir' +import { bufferStreams } from '@/sys/io/streams' +import { runUseHost } from './use-host' + +describe('runUseHost', () => { + let dir: string + let prev: string | undefined + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'difyctl-usehost-')) + prev = process.env[ENV_CONFIG_DIR] + process.env[ENV_CONFIG_DIR] = dir + const reg = Registry.empty('file') + reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } }) + reg.upsert('h2', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } }) + reg.setHost('h1') + reg.setAccount('a@x') + reg.save() + }) + afterEach(async () => { + if (prev === undefined) + delete process.env[ENV_CONFIG_DIR] + else process.env[ENV_CONFIG_DIR] = prev + await rm(dir, { recursive: true, force: true }) + }) + + it('switches current_host when host is valid', async () => { + await runUseHost({ io: bufferStreams(), host: 'h2' }) + expect(Registry.load().current_host).toBe('h2') + }) + + it('errors when host is unknown, listing valid hosts', async () => { + await expect(runUseHost({ io: bufferStreams(), host: 'nope' })).rejects.toThrow(/h1.*h2|unknown host/i) + }) + + it('errors in non-TTY when host omitted', async () => { + const io = bufferStreams() + ;(io as { isErrTTY: boolean }).isErrTTY = false + await expect(runUseHost({ io, host: undefined })).rejects.toThrow(/--domain/i) + }) + + it('errors when no hosts exist', async () => { + Registry.empty('file').save() + await expect(runUseHost({ io: bufferStreams(), host: 'h1' })).rejects.toThrow(/no hosts|not logged in/i) + }) +}) diff --git a/cli/src/commands/use/host/use-host.ts b/cli/src/commands/use/host/use-host.ts new file mode 100644 index 0000000000..21a2c85d72 --- /dev/null +++ b/cli/src/commands/use/host/use-host.ts @@ -0,0 +1,54 @@ +import type { IOStreams } from '@/sys/io/streams' +import { notLoggedInError, Registry } from '@/auth/hosts' +import { BaseError } from '@/errors/base' +import { ErrorCode } from '@/errors/codes' +import { colorEnabled, colorScheme } from '@/sys/io/color' +import { selectFromList } from '@/sys/io/select' + +export type UseHostOptions = { + readonly io: IOStreams + readonly host: string | undefined +} + +type HostChoice = { host: string, accounts: number, active: boolean } + +export async function runUseHost(opts: UseHostOptions): Promise<void> { + const cs = colorScheme(colorEnabled(opts.io.isErrTTY)) + const reg = Registry.load() + const hosts = Object.keys(reg.hosts) + if (hosts.length === 0) + throw notLoggedInError() + + const target = opts.host ?? await pickHost(opts, reg, hosts) + if (!hosts.includes(target)) { + throw new BaseError({ + code: ErrorCode.UsageInvalidFlag, + message: `unknown host "${target}"; known hosts: ${hosts.join(', ')}`, + }) + } + + reg.setHost(target) + reg.save() + opts.io.out.write(`${cs.successIcon()} Active host is now ${target}\n`) +} + +async function pickHost(opts: UseHostOptions, reg: Registry, hosts: readonly string[]): Promise<string> { + if (!opts.io.isErrTTY) { + throw new BaseError({ + code: ErrorCode.UsageMissingArg, + message: `--domain is required (no TTY); known hosts: ${hosts.join(', ')}`, + }) + } + const choices: HostChoice[] = hosts.map(h => ({ + host: h, + accounts: Object.keys(reg.hosts[h]?.accounts ?? {}).length, + active: reg.current_host === h, + })) + const picked = await selectFromList<HostChoice>({ + io: opts.io, + items: choices, + header: 'Select a host', + render: c => `${c.active ? '* ' : ' '}${c.host} (${c.accounts} account${c.accounts === 1 ? '' : 's'})`, + }) + return picked.host +} diff --git a/cli/src/commands/use/workspace/index.ts b/cli/src/commands/use/workspace/index.ts index b55fc6179f..1f882d10b7 100644 --- a/cli/src/commands/use/workspace/index.ts +++ b/cli/src/commands/use/workspace/index.ts @@ -22,7 +22,8 @@ export default class UseWorkspace extends DifyCommand { const { args, flags } = this.parse(UseWorkspace, argv) const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] }) await runUseWorkspace({ workspaceId: args.workspaceId }, { - bundle: ctx.bundle, + reg: ctx.reg, + active: ctx.active, http: ctx.http, io: ctx.io, }) diff --git a/cli/src/commands/use/workspace/use.test.ts b/cli/src/commands/use/workspace/use.test.ts index f8b177495e..33c629d83d 100644 --- a/cli/src/commands/use/workspace/use.test.ts +++ b/cli/src/commands/use/workspace/use.test.ts @@ -2,29 +2,37 @@ import type { WorkspaceDetailResponse, WorkspaceListResponse, } from '@dify/contracts/api/openapi/types.gen' -import type { HostsBundle } from '@/auth/hosts' +import type { ActiveContext } from '@/auth/hosts' import type { HttpClient } from '@/http/types' import { mkdtemp, rm } from 'node:fs/promises' 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' +import { Registry } from '@/auth/hosts' import { ENV_CONFIG_DIR } from '@/store/dir' import { bufferStreams } from '@/sys/io/streams' import { runUseWorkspace } from './use.js' -function bundle(): HostsBundle { - return { - current_host: 'cloud.dify.ai', - token_storage: 'file', - tokens: { bearer: 'dfoa_test' }, +function makeRegistry(): Registry { + const reg = Registry.empty('file') + reg.upsert('cloud.dify.ai', 'tester@dify.ai', { account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Tester' }, workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, available_workspaces: [ { id: 'ws-1', name: 'Default', role: 'owner' }, { id: 'ws-2', name: 'Stale Name', role: 'normal' }, ], - } + }) + reg.setHost('cloud.dify.ai') + reg.setAccount('tester@dify.ai') + return reg +} + +function makeActive(reg: Registry): ActiveContext { + const active = reg.resolveActive() + if (active === undefined) + throw new Error('resolveActive returned undefined in test setup') + return active } function fakeClient(opts: { @@ -68,14 +76,16 @@ describe('runUseWorkspace', () => { it('happy path: POST /switch → GET /workspaces → write hosts.yml', async () => { const io = bufferStreams() - const b = bundle() - saveHosts(b) + const reg = makeRegistry() + reg.save() + const active = makeActive(reg) const client = fakeClient({}) const next = await runUseWorkspace( { workspaceId: 'ws-2' }, { - bundle: b, + reg, + active, http: {} as HttpClient, io, workspacesFactory: () => client as never, @@ -84,40 +94,65 @@ describe('runUseWorkspace', () => { expect(client.switch).toHaveBeenCalledExactlyOnceWith('ws-2') expect(client.list).toHaveBeenCalledOnce() - expect(next.workspace).toEqual({ id: 'ws-2', name: 'Switched', role: 'normal' }) - expect(next.available_workspaces).toEqual([ + + const activeCtx = next.resolveActive() + expect(activeCtx?.ctx.workspace).toEqual({ id: 'ws-2', name: 'Switched', role: 'normal' }) + expect(activeCtx?.ctx.available_workspaces).toEqual([ { id: 'ws-1', name: 'Default', role: 'owner' }, { id: 'ws-2', name: 'Switched', role: 'normal' }, ]) - const reloaded = loadHosts() - expect(reloaded?.workspace?.id).toBe('ws-2') - expect(reloaded?.workspace?.name).toBe('Switched') + + const reloaded = Registry.load() + const reloadedActive = reloaded?.resolveActive() + expect(reloadedActive?.ctx.workspace?.id).toBe('ws-2') + expect(reloadedActive?.ctx.workspace?.name).toBe('Switched') + expect(io.outBuf()).toMatch(/Switched to Switched \(ws-2\)/) }) - it('refreshes stale workspace name from server', async () => { - // bundle has ws-2 named "Stale Name"; server returns "Switched". - // We expect saveHosts to record the fresh name from the server. + it('hosts.yml contains no bearer after switch', async () => { const io = bufferStreams() - const b = bundle() - saveHosts(b) + const reg = makeRegistry() + reg.save() + const active = makeActive(reg) const client = fakeClient({}) await runUseWorkspace( { workspaceId: 'ws-2' }, - { bundle: b, http: {} as HttpClient, io, workspacesFactory: () => client as never }, + { reg, active, http: {} as HttpClient, io, workspacesFactory: () => client as never }, ) - const reloaded = loadHosts() - expect(reloaded?.workspace?.name).toBe('Switched') - expect(reloaded?.available_workspaces?.find(w => w.id === 'ws-2')?.name).toBe('Switched') + const reloaded = Registry.load() + const raw = JSON.stringify(reloaded) + expect(raw).not.toMatch(/bearer/) + }) + + it('refreshes stale workspace name from server', async () => { + // registry has ws-2 named "Stale Name"; server returns "Switched". + // We expect saveRegistry to record the fresh name from the server. + const io = bufferStreams() + const reg = makeRegistry() + reg.save() + const active = makeActive(reg) + const client = fakeClient({}) + + await runUseWorkspace( + { workspaceId: 'ws-2' }, + { reg, active, http: {} as HttpClient, io, workspacesFactory: () => client as never }, + ) + + const reloaded = Registry.load() + const reloadedActive = reloaded?.resolveActive() + expect(reloadedActive?.ctx.workspace?.name).toBe('Switched') + expect(reloadedActive?.ctx.available_workspaces?.find(w => w.id === 'ws-2')?.name).toBe('Switched') }) it('does NOT mutate hosts.yml when POST /switch fails', async () => { const io = bufferStreams() - const b = bundle() - saveHosts(b) - const before = loadHosts() + const reg = makeRegistry() + reg.save() + const active = makeActive(reg) + const before = Registry.load() const client = fakeClient({ switch: () => Promise.reject(new Error('forbidden')), @@ -127,7 +162,8 @@ describe('runUseWorkspace', () => { runUseWorkspace( { workspaceId: 'ws-2' }, { - bundle: b, + reg, + active, http: {} as HttpClient, io, workspacesFactory: () => client as never, @@ -136,16 +172,18 @@ describe('runUseWorkspace', () => { ).rejects.toThrow(/forbidden/) expect(client.list).not.toHaveBeenCalled() - const after = loadHosts() + const after = Registry.load() expect(after).toEqual(before) - expect(after?.workspace?.id).toBe('ws-1') + const afterActive = after?.resolveActive() + expect(afterActive?.ctx.workspace?.id).toBe('ws-1') }) it('does NOT mutate hosts.yml when GET /workspaces fails after switch', async () => { const io = bufferStreams() - const b = bundle() - saveHosts(b) - const before = loadHosts() + const reg = makeRegistry() + reg.save() + const active = makeActive(reg) + const before = Registry.load() const client = fakeClient({ list: () => Promise.reject(new Error('transient list failure')), @@ -155,7 +193,8 @@ describe('runUseWorkspace', () => { runUseWorkspace( { workspaceId: 'ws-2' }, { - bundle: b, + reg, + active, http: {} as HttpClient, io, workspacesFactory: () => client as never, @@ -163,14 +202,15 @@ describe('runUseWorkspace', () => { ), ).rejects.toThrow(/transient list failure/) - const after = loadHosts() + const after = Registry.load() expect(after).toEqual(before) }) it('throws when server returns switch=<id> but id is missing from /workspaces list', async () => { const io = bufferStreams() - const b = bundle() - saveHosts(b) + const reg = makeRegistry() + reg.save() + const active = makeActive(reg) const client = fakeClient({ switch: () => Promise.resolve({ @@ -192,7 +232,8 @@ describe('runUseWorkspace', () => { runUseWorkspace( { workspaceId: 'ws-7' }, { - bundle: b, + reg, + active, http: {} as HttpClient, io, workspacesFactory: () => client as never, diff --git a/cli/src/commands/use/workspace/use.ts b/cli/src/commands/use/workspace/use.ts index ed979f80f1..daf0bb7e29 100644 --- a/cli/src/commands/use/workspace/use.ts +++ b/cli/src/commands/use/workspace/use.ts @@ -1,8 +1,7 @@ -import type { HostsBundle, Workspace } from '@/auth/hosts' +import type { ActiveContext, Registry, Workspace } from '@/auth/hosts' import type { HttpClient } from '@/http/types' import type { IOStreams } from '@/sys/io/streams' import { WorkspacesClient } from '@/api/workspaces' -import { saveHosts } from '@/auth/hosts' import { BaseError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' import { colorEnabled, colorScheme } from '@/sys/io/color' @@ -13,7 +12,8 @@ export type UseWorkspaceOptions = { } export type UseWorkspaceDeps = { - readonly bundle: HostsBundle + readonly reg: Registry + readonly active: ActiveContext readonly http: HttpClient readonly io: IOStreams readonly workspacesFactory?: (http: HttpClient) => WorkspacesClient @@ -31,12 +31,12 @@ export type UseWorkspaceDeps = { * stays in sync. Failure here also aborts; the server-side current has * already moved, but the local file is left untouched. A follow-up * `difyctl get workspace` will reconcile. - * 3. Persist `workspace` + `available_workspaces` atomically via `saveHosts`. + * 3. Persist `workspace` + `available_workspaces` atomically via `saveRegistry`. */ export async function runUseWorkspace( opts: UseWorkspaceOptions, deps: UseWorkspaceDeps, -): Promise<HostsBundle> { +): Promise<Registry> { const cs = colorScheme(colorEnabled(deps.io.isErrTTY)) const factory = deps.workspacesFactory ?? ((h: HttpClient) => new WorkspacesClient(h)) const client = factory(deps.http) @@ -60,16 +60,13 @@ export async function runUseWorkspace( }) } - const next: HostsBundle = { - ...deps.bundle, + const nextCtx = { + ...deps.active.ctx, workspace: { id: matched.id, name: matched.name, role: matched.role }, - available_workspaces: list.workspaces.map<Workspace>(w => ({ - id: w.id, - name: w.name, - role: w.role, - })), + available_workspaces: list.workspaces.map<Workspace>(w => ({ id: w.id, name: w.name, role: w.role })), } - saveHosts(next) + deps.reg.upsert(deps.active.host, deps.active.email, nextCtx) + deps.reg.save() deps.io.out.write(`${cs.successIcon()} Switched to ${matched.name} (${matched.id})\n`) - return next + return deps.reg } diff --git a/cli/src/printers/stream-printer.ts b/cli/src/framework/stream.ts similarity index 100% rename from cli/src/printers/stream-printer.ts rename to cli/src/framework/stream.ts diff --git a/cli/src/printers/format-json-yaml.test.ts b/cli/src/printers/format-json-yaml.test.ts deleted file mode 100644 index e780a1b614..0000000000 --- a/cli/src/printers/format-json-yaml.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { JsonYamlPrintFlags } from './format-json-yaml' -import { isNoCompatiblePrinter } from './printer' - -describe('JsonYamlPrintFlags.allowedFormats', () => { - it('returns json + yaml', () => { - expect(new JsonYamlPrintFlags().allowedFormats()).toEqual(['json', 'yaml']) - }) -}) - -describe('JsonYamlPrintFlags.toPrinter', () => { - it('throws NoCompatiblePrinterError for unsupported formats', () => { - const pf = new JsonYamlPrintFlags() - for (const f of ['', 'text', 'wide', 'name', 'xml']) { - let caught: unknown - try { - pf.toPrinter(f) - } - catch (e) { - caught = e - } - expect(isNoCompatiblePrinter(caught)).toBe(true) - } - }) - - it('returns a json printer that encodes raw payload with 2-space indent', () => { - const p = new JsonYamlPrintFlags().toPrinter('json') - const out = p.print({ raw: () => ({ answer: 'hi' }) }) - expect(out).toContain('"answer"') - expect(out).toContain('"hi"') - expect(out).toContain(' "answer"') - expect(out.endsWith('\n')).toBe(true) - }) - - it('json printer round-trips a plain object with no Raw()', () => { - const p = new JsonYamlPrintFlags().toPrinter('json') - const out = p.print({ k: 'v', n: 1 }) - expect(JSON.parse(out)).toEqual({ k: 'v', n: 1 }) - }) - - it('json printer is lossless for nested arrays', () => { - const data = { items: [{ id: 'a' }, { id: 'b' }] } - const out = new JsonYamlPrintFlags().toPrinter('json').print(data) - expect(JSON.parse(out)).toEqual(data) - }) - - it('returns a yaml printer that emits scalar pairs', () => { - const p = new JsonYamlPrintFlags().toPrinter('yaml') - const out = p.print({ raw: () => ({ answer: 'hi' }) }) - expect(out).toMatch(/answer:\s*['"]?hi['"]?\n?/) - }) - - it('yaml printer round-trips structured data', async () => { - const yaml = await import('js-yaml') - const data = { items: [{ id: 'a', mode: 'chat' }, { id: 'b', mode: 'workflow' }] } - const out = new JsonYamlPrintFlags().toPrinter('yaml').print(data) - expect(yaml.load(out)).toEqual(data) - }) -}) diff --git a/cli/src/printers/format-json-yaml.ts b/cli/src/printers/format-json-yaml.ts deleted file mode 100644 index e72e9dc545..0000000000 --- a/cli/src/printers/format-json-yaml.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { Printer, PrintFlags } from './printer' -import yaml from 'js-yaml' -import { NoCompatiblePrinterError, payload } from './printer' - -const ALLOWED = ['json', 'yaml'] as const - -const jsonPrinter: Printer = { - print(obj) { - return `${JSON.stringify(payload(obj), null, 2)}\n` - }, -} - -const yamlPrinter: Printer = { - print(obj) { - return yaml.dump(payload(obj), { indent: 2, lineWidth: -1 }) - }, -} - -export class JsonYamlPrintFlags implements PrintFlags { - allowedFormats(): readonly string[] { - return ALLOWED - } - - toPrinter(format: string): Printer { - switch (format) { - case 'json': return jsonPrinter - case 'yaml': return yamlPrinter - default: throw new NoCompatiblePrinterError(format, ALLOWED) - } - } -} diff --git a/cli/src/printers/format-name.test.ts b/cli/src/printers/format-name.test.ts deleted file mode 100644 index 0b7eb71984..0000000000 --- a/cli/src/printers/format-name.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { NamePrintFlags } from './format-name' -import { isNoCompatiblePrinter } from './printer' - -const fakeMode = (m: string) => ({ mode: () => m }) - -describe('NamePrintFlags.allowedFormats', () => { - it('returns ["name"]', () => { - expect(new NamePrintFlags().allowedFormats()).toEqual(['name']) - }) -}) - -describe('NamePrintFlags.toPrinter', () => { - it('throws NoCompatiblePrinterError for non-name formats', () => { - const pf = new NamePrintFlags() - let caught: unknown - try { - pf.toPrinter('json') - } - catch (e) { - caught = e - } - expect(isNoCompatiblePrinter(caught)).toBe(true) - }) - - it('prints id + newline for the registered mode', () => { - const pf = new NamePrintFlags() - pf.register({ id: () => 'abc-123' }, 'thing') - expect(pf.toPrinter('name').print(fakeMode('thing'))).toBe('abc-123\n') - }) - - it('appends operation suffix when set', () => { - const pf = new NamePrintFlags() - pf.operation = 'created' - pf.register({ id: () => 'abc' }, 'thing') - expect(pf.toPrinter('name').print(fakeMode('thing'))).toBe('abc created\n') - }) - - it('throws when payload mode has no registered handler', () => { - const pf = new NamePrintFlags() - pf.register({ id: () => 'abc' }, 'thing') - const printer = pf.toPrinter('name') - expect(() => printer.print(fakeMode('other'))).toThrow(/no handler for mode/) - }) - - it('throws when payload does not implement Moder', () => { - const pf = new NamePrintFlags() - pf.register({ id: () => 'abc' }, 'thing') - const printer = pf.toPrinter('name') - expect(() => printer.print({ no: 'mode' })).toThrow(/does not implement Moder/i) - }) - - it('register accepts multiple keys for the same handler', () => { - const pf = new NamePrintFlags() - pf.register({ id: () => 'shared' }, 'a', 'b') - const printer = pf.toPrinter('name') - expect(printer.print(fakeMode('a'))).toBe('shared\n') - expect(printer.print(fakeMode('b'))).toBe('shared\n') - }) - - it('unwraps RawObject before passing payload to handler', () => { - const pf = new NamePrintFlags() - let received: unknown - pf.register({ - id: (p) => { - received = p - return 'ok' - }, - }, 'thing') - pf.toPrinter('name').print({ - mode: () => 'thing', - raw: () => ({ id: 'unwrapped' }), - }) - expect(received).toEqual({ id: 'unwrapped' }) - }) -}) diff --git a/cli/src/printers/format-name.ts b/cli/src/printers/format-name.ts deleted file mode 100644 index ef8f3ee112..0000000000 --- a/cli/src/printers/format-name.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { Printer, PrintFlags } from './printer' -import { isModer, NoCompatiblePrinterError, payload } from './printer' - -const ALLOWED = ['name'] as const - -export type NameHandler = { - id: (raw: unknown) => string -} - -export class NamePrintFlags implements PrintFlags { - operation = '' - private readonly handlers = new Map<string, NameHandler>() - - register(handler: NameHandler, ...keys: string[]): void { - for (const k of keys) this.handlers.set(k, handler) - } - - allowedFormats(): readonly string[] { - return ALLOWED - } - - toPrinter(format: string): Printer { - if (format !== 'name') - throw new NoCompatiblePrinterError(format, ALLOWED) - const handlers = this.handlers - const operation = this.operation - return { - print(obj) { - if (!isModer(obj)) - throw new Error(`name printer: payload does not implement Moder`) - const mode = obj.mode() - const h = handlers.get(mode) - if (h === undefined) { - const known = [...handlers.keys()].sort().join(', ') - throw new Error(`name printer: no handler for mode "${mode}" (registered: ${known})`) - } - const id = h.id(payload(obj)) - return operation === '' ? `${id}\n` : `${id} ${operation}\n` - }, - } - } -} diff --git a/cli/src/printers/format-table.test.ts b/cli/src/printers/format-table.test.ts deleted file mode 100644 index 78df30b614..0000000000 --- a/cli/src/printers/format-table.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import type { TableColumn, TableHandler } from './format-table' -import { describe, expect, it } from 'vitest' -import { TablePrintFlags } from './format-table' -import { isNoCompatiblePrinter } from './printer' - -const fakeMode = (m: string) => ({ mode: () => m }) - -const handler: TableHandler = { - columns(): readonly TableColumn[] { - return [ - { name: 'NAME', priority: 0 }, - { name: 'AGE', priority: 0 }, - { name: 'DETAILS', priority: 1 }, - ] - }, - rows() { - return [['alpha', '1d', 'extra']] - }, -} - -describe('TablePrintFlags.allowedFormats', () => { - it('returns ["", "wide"]', () => { - expect(new TablePrintFlags().allowedFormats()).toEqual(['', 'wide']) - }) -}) - -describe('TablePrintFlags default format', () => { - it('hides priority>0 columns and their cells', () => { - const pf = new TablePrintFlags() - pf.register(handler, 'thing') - const out = pf.toPrinter('').print(fakeMode('thing')) - expect(out).toContain('NAME') - expect(out).toContain('AGE') - expect(out).not.toContain('DETAILS') - expect(out).not.toContain('extra') - expect(out).toContain('alpha') - }) - - it('column-aligns cells with two-space padding', () => { - const pf = new TablePrintFlags() - pf.register({ - columns: () => [ - { name: 'NAME', priority: 0 }, - { name: 'AGE', priority: 0 }, - ], - rows: () => [ - ['alpha', '1d'], - ['beta-long', '999d'], - ], - }, 'thing') - const out = pf.toPrinter('').print(fakeMode('thing')) - const lines = out.trimEnd().split('\n') - expect(lines).toHaveLength(3) - expect(lines[0]).toBe('NAME AGE') - expect(lines[1]).toBe('alpha 1d') - expect(lines[2]).toBe('beta-long 999d') - }) -}) - -describe('TablePrintFlags wide format', () => { - it('shows all columns including priority>0', () => { - const pf = new TablePrintFlags() - pf.register(handler, 'thing') - const out = pf.toPrinter('wide').print(fakeMode('thing')) - expect(out).toContain('DETAILS') - expect(out).toContain('extra') - }) -}) - -describe('TablePrintFlags noHeaders', () => { - it('omits header row when noHeaders=true', () => { - const pf = new TablePrintFlags({ noHeaders: true }) - pf.register(handler, 'thing') - const out = pf.toPrinter('').print(fakeMode('thing')) - expect(out).not.toContain('NAME') - expect(out).toContain('alpha') - }) -}) - -describe('TablePrintFlags errors', () => { - it('throws NoCompatiblePrinterError for unsupported formats', () => { - let caught: unknown - try { - new TablePrintFlags().toPrinter('json') - } - catch (e) { - caught = e - } - expect(isNoCompatiblePrinter(caught)).toBe(true) - }) - - it('throws on unregistered mode', () => { - const pf = new TablePrintFlags() - pf.register(handler, 'thing') - const printer = pf.toPrinter('') - expect(() => printer.print(fakeMode('other'))).toThrow(/other/) - }) - - it('throws when payload does not implement Moder', () => { - const pf = new TablePrintFlags() - pf.register(handler, 'thing') - expect(() => pf.toPrinter('').print({})).toThrow(/Moder/i) - }) - - it('handler rows() can return null/undefined cells safely (rendered empty)', () => { - const pf = new TablePrintFlags() - pf.register({ - columns: () => [{ name: 'A', priority: 0 }, { name: 'B', priority: 0 }], - rows: () => [['x', undefined], [null, 'y']], - }, 'thing') - const out = pf.toPrinter('').print(fakeMode('thing')) - const lines = out.trimEnd().split('\n') - expect(lines[0]).toBe('A B') - expect(lines[1]).toBe('x ') - expect(lines[2]).toBe(' y') - }) -}) - -describe('TablePrintFlags raw unwrap', () => { - it('passes unwrapped payload to handler.rows()', () => { - let received: unknown - const pf = new TablePrintFlags() - pf.register({ - columns: () => [{ name: 'X', priority: 0 }], - rows: (p) => { - received = p - return [['ok']] - }, - }, 'thing') - pf.toPrinter('').print({ - mode: () => 'thing', - raw: () => ({ items: [{ id: 'x' }] }), - }) - expect(received).toEqual({ items: [{ id: 'x' }] }) - }) -}) diff --git a/cli/src/printers/format-table.ts b/cli/src/printers/format-table.ts deleted file mode 100644 index 9a72729b61..0000000000 --- a/cli/src/printers/format-table.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { Printer, PrintFlags } from './printer' -import { isModer, NoCompatiblePrinterError, payload } from './printer' - -const ALLOWED = ['', 'wide'] as const -const COLUMN_PADDING = 2 - -export type TableColumn = { - name: string - priority: number -} - -export type TableCell = string | null | undefined - -export type TableRow = readonly TableCell[] - -export type TableHandler = { - columns: () => readonly TableColumn[] - rows: (raw: unknown) => readonly TableRow[] -} - -export type TablePrintFlagsOptions = { - noHeaders?: boolean -} - -export class TablePrintFlags implements PrintFlags { - private readonly handlers = new Map<string, TableHandler>() - private readonly noHeaders: boolean - - constructor(opts: TablePrintFlagsOptions = {}) { - this.noHeaders = opts.noHeaders ?? false - } - - register(handler: TableHandler, ...keys: string[]): void { - for (const k of keys) this.handlers.set(k, handler) - } - - allowedFormats(): readonly string[] { - return ALLOWED - } - - toPrinter(format: string): Printer { - if (format !== '' && format !== 'wide') - throw new NoCompatiblePrinterError(format, ALLOWED) - const wide = format === 'wide' - const handlers = this.handlers - const noHeaders = this.noHeaders - return { - print(obj) { - if (!isModer(obj)) - throw new Error('table printer: payload does not implement Moder') - const mode = obj.mode() - const handler = handlers.get(mode) - if (handler === undefined) { - const known = [...handlers.keys()].sort().join(', ') - throw new Error(`table printer: no handler for mode "${mode}" (registered: ${known})`) - } - const cols = handler.columns() - const keep: number[] = [] - for (let i = 0; i < cols.length; i++) { - const col = cols[i] - if (col !== undefined && (col.priority === 0 || wide)) - keep.push(i) - } - const rows = handler.rows(payload(obj)) - const stringRows: string[][] = rows.map(row => - keep.map((idx) => { - const cell = row[idx] - return cell === null || cell === undefined ? '' : String(cell) - }), - ) - const allRows: string[][] = noHeaders - ? stringRows - : [keep.map(i => cols[i]?.name ?? ''), ...stringRows] - return formatTable(allRows) - }, - } - } -} - -function formatTable(rows: readonly string[][]): string { - if (rows.length === 0) - return '' - const colCount = rows[0]?.length ?? 0 - const widths: number[] = Array.from({ length: colCount }, () => 0) - for (const row of rows) { - for (let i = 0; i < colCount; i++) { - const cell = row[i] ?? '' - if (cell.length > (widths[i] ?? 0)) - widths[i] = cell.length - } - } - const lines = rows.map((row) => { - const cells: string[] = [] - for (let i = 0; i < colCount; i++) { - const cell = row[i] ?? '' - const isLast = i === colCount - 1 - if (isLast) { - cells.push(cell) - } - else { - const pad = (widths[i] ?? 0) - cell.length + COLUMN_PADDING - cells.push(cell + ' '.repeat(pad)) - } - } - return cells.join('') - }) - return `${lines.join('\n')}\n` -} diff --git a/cli/src/printers/format-text.test.ts b/cli/src/printers/format-text.test.ts deleted file mode 100644 index dc0f812336..0000000000 --- a/cli/src/printers/format-text.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { TextPrintFlags } from './format-text' - -describe('TextPrintFlags', () => { - it('routes to handler by mode', () => { - const f = new TextPrintFlags() - f.register({ render: v => `chat:${(v as { x: string }).x}\n` }, 'chat') - f.register({ render: v => `wf:${(v as { y: string }).y}\n` }, 'workflow') - expect(f.toPrinter('').print({ mode: () => 'chat', raw: () => ({ x: '1' }) })).toBe('chat:1\n') - expect(f.toPrinter('text').print({ mode: () => 'workflow', raw: () => ({ y: '2' }) })).toBe('wf:2\n') - }) - - it('rejects unknown formats', () => { - expect(() => new TextPrintFlags().toPrinter('json')).toThrow(/not supported/) - }) - - it('errors on unregistered mode', () => { - const f = new TextPrintFlags() - expect(() => f.toPrinter('').print({ mode: () => 'agent', raw: () => ({}) })).toThrow(/no handler for mode/) - }) -}) diff --git a/cli/src/printers/format-text.ts b/cli/src/printers/format-text.ts deleted file mode 100644 index ec8dd08a5e..0000000000 --- a/cli/src/printers/format-text.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { Printer, PrintFlags } from './printer' -import { isModer, NoCompatiblePrinterError, payload } from './printer' - -const ALLOWED = ['', 'text'] as const - -export type TextHandler = { - render: (raw: unknown) => string -} - -export class TextPrintFlags implements PrintFlags { - private readonly handlers = new Map<string, TextHandler>() - - register(handler: TextHandler, ...keys: string[]): void { - for (const k of keys) this.handlers.set(k, handler) - } - - allowedFormats(): readonly string[] { - return ALLOWED - } - - toPrinter(format: string): Printer { - if (format !== '' && format !== 'text') - throw new NoCompatiblePrinterError(format, ALLOWED) - const handlers = this.handlers - return { - print(obj) { - if (!isModer(obj)) - throw new Error('text printer: payload does not implement Moder') - const mode = obj.mode() - const h = handlers.get(mode) - if (h === undefined) { - const known = [...handlers.keys()].sort().join(', ') - throw new Error(`text printer: no handler for mode "${mode}" (registered: ${known})`) - } - return h.render(payload(obj)) - }, - } - } -} diff --git a/cli/src/printers/printer.test.ts b/cli/src/printers/printer.test.ts deleted file mode 100644 index 25535f7373..0000000000 --- a/cli/src/printers/printer.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - isModer, - isNoCompatiblePrinter, - isRawObject, - NoCompatiblePrinterError, - payload, -} from './printer' - -describe('NoCompatiblePrinterError', () => { - it('mentions format and allowed list when allowed is non-empty', () => { - const err = new NoCompatiblePrinterError('xml', ['json', 'yaml']) - expect(err.message).toContain('xml') - expect(err.message).toContain('json') - expect(err.message).toContain('yaml') - }) - - it('mentions only format when allowed list is empty', () => { - const err = new NoCompatiblePrinterError('xml', []) - expect(err.message).toContain('xml') - expect(err.message).toContain('not supported') - expect(err.message).not.toContain('allowed') - }) - - it('exposes format and allowed publicly for callers that branch on them', () => { - const err = new NoCompatiblePrinterError('xml', ['json']) - expect(err.format).toBe('xml') - expect(err.allowed).toEqual(['json']) - }) - - it('has a stable name for serialization', () => { - const err = new NoCompatiblePrinterError('xml', []) - expect(err.name).toBe('NoCompatiblePrinterError') - }) -}) - -describe('isNoCompatiblePrinter', () => { - it('matches NoCompatiblePrinterError instances', () => { - expect(isNoCompatiblePrinter(new NoCompatiblePrinterError('xml', ['json']))).toBe(true) - }) - - it('does not match plain Error', () => { - expect(isNoCompatiblePrinter(new Error('other'))).toBe(false) - }) - - it('does not match a wrapped error message', () => { - expect(isNoCompatiblePrinter(new Error('wrapped: output format "xml" not supported'))).toBe(false) - }) - - it('does not match null/undefined/primitives', () => { - expect(isNoCompatiblePrinter(null)).toBe(false) - expect(isNoCompatiblePrinter(undefined)).toBe(false) - expect(isNoCompatiblePrinter('string')).toBe(false) - expect(isNoCompatiblePrinter(42)).toBe(false) - }) -}) - -describe('isRawObject', () => { - it('detects objects exposing raw()', () => { - expect(isRawObject({ raw: () => 42 })).toBe(true) - }) - - it('rejects values without raw()', () => { - expect(isRawObject({})).toBe(false) - expect(isRawObject(null)).toBe(false) - expect(isRawObject(undefined)).toBe(false) - expect(isRawObject(42)).toBe(false) - }) - - it('rejects objects where raw is not callable', () => { - expect(isRawObject({ raw: 42 })).toBe(false) - }) -}) - -describe('isModer', () => { - it('detects objects exposing mode()', () => { - expect(isModer({ mode: () => 'chat' })).toBe(true) - }) - - it('rejects values without mode()', () => { - expect(isModer({})).toBe(false) - expect(isModer(null)).toBe(false) - expect(isModer({ mode: 'chat' })).toBe(false) - }) -}) - -describe('payload', () => { - it('unwraps RawObject via raw()', () => { - expect(payload({ raw: () => ({ id: 'a' }) })).toEqual({ id: 'a' }) - }) - - it('returns the value as-is when it is not a RawObject', () => { - const obj = { id: 'a' } - expect(payload(obj)).toBe(obj) - }) - - it('returns primitives untouched', () => { - expect(payload(42)).toBe(42) - expect(payload(null)).toBeNull() - }) -}) diff --git a/cli/src/printers/printer.ts b/cli/src/printers/printer.ts deleted file mode 100644 index 02b47db7fa..0000000000 --- a/cli/src/printers/printer.ts +++ /dev/null @@ -1,82 +0,0 @@ -export type Format = '' | 'wide' | 'json' | 'yaml' | 'name' - -export type Printer = { - print: (obj: unknown) => string -} - -export type RawObject = { - raw: () => unknown -} - -export type Moder = { - mode: () => string -} - -export type PrintFlags = { - allowedFormats: () => readonly string[] - toPrinter: (format: string) => Printer -} - -export class NoCompatiblePrinterError extends Error { - override readonly name = 'NoCompatiblePrinterError' - readonly format: string - readonly allowed: readonly string[] - - constructor(format: string, allowed: readonly string[]) { - super( - allowed.length === 0 - ? `output format ${JSON.stringify(format)} not supported` - : `output format ${JSON.stringify(format)} not supported, allowed: ${allowed.join(', ')}`, - ) - this.format = format - this.allowed = allowed - } -} - -export function isNoCompatiblePrinter(err: unknown): err is NoCompatiblePrinterError { - return err instanceof NoCompatiblePrinterError -} - -export abstract class CompositePrintFlags implements PrintFlags { - protected abstract families(): readonly PrintFlags[] - - allowedFormats(): readonly string[] { - const seen = new Set<string>() - for (const fam of this.families()) { - for (const f of fam.allowedFormats()) { - if (f !== '') - seen.add(f) - } - } - return [...seen].sort() - } - - toPrinter(format: string): Printer { - for (const fam of this.families()) { - try { - return fam.toPrinter(format) - } - catch (err) { - if (!isNoCompatiblePrinter(err)) - throw err - } - } - throw new NoCompatiblePrinterError(format, this.allowedFormats()) - } -} - -export function isRawObject(v: unknown): v is RawObject { - return typeof v === 'object' - && v !== null - && typeof (v as { raw?: unknown }).raw === 'function' -} - -export function isModer(v: unknown): v is Moder { - return typeof v === 'object' - && v !== null - && typeof (v as { mode?: unknown }).mode === 'function' -} - -export function payload(obj: unknown): unknown { - return isRawObject(obj) ? obj.raw() : obj -} diff --git a/cli/src/printers/width.test.ts b/cli/src/printers/width.test.ts deleted file mode 100644 index def5f93ae6..0000000000 --- a/cli/src/printers/width.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { TERMINAL_WIDTH_FALLBACK, terminalWidth, truncate } from './width' - -describe('truncate', () => { - it('returns the input unchanged when shorter than max', () => { - expect(truncate('hi', 5)).toBe('hi') - }) - - it('returns the input unchanged when exactly at max', () => { - expect(truncate('hello', 5)).toBe('hello') - }) - - it('truncates to max with single ellipsis char when longer', () => { - expect(truncate('hello world', 5)).toBe('hell…') - }) - - it('returns empty for empty input regardless of max', () => { - expect(truncate('', 5)).toBe('') - }) - - it('returns just the ellipsis when max is 1', () => { - expect(truncate('hello', 1)).toBe('…') - }) - - it('returns empty when max is 0', () => { - expect(truncate('hello', 0)).toBe('') - }) - - it('handles negative max gracefully', () => { - expect(truncate('hello', -3)).toBe('') - }) -}) - -describe('terminalWidth', () => { - let originalColumns: number | undefined - - beforeEach(() => { - originalColumns = process.stdout.columns - }) - - afterEach(() => { - Object.defineProperty(process.stdout, 'columns', { - value: originalColumns, - configurable: true, - writable: true, - }) - }) - - it('returns process.stdout.columns when present', () => { - Object.defineProperty(process.stdout, 'columns', { - value: 120, - configurable: true, - writable: true, - }) - expect(terminalWidth()).toBe(120) - }) - - it('falls back to 80 when columns is undefined', () => { - Object.defineProperty(process.stdout, 'columns', { - value: undefined, - configurable: true, - writable: true, - }) - expect(terminalWidth()).toBe(TERMINAL_WIDTH_FALLBACK) - expect(TERMINAL_WIDTH_FALLBACK).toBe(80) - }) - - it('falls back to 80 when columns is 0', () => { - Object.defineProperty(process.stdout, 'columns', { - value: 0, - configurable: true, - writable: true, - }) - expect(terminalWidth()).toBe(TERMINAL_WIDTH_FALLBACK) - }) -}) diff --git a/cli/src/printers/width.ts b/cli/src/printers/width.ts deleted file mode 100644 index e48af55a58..0000000000 --- a/cli/src/printers/width.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const TERMINAL_WIDTH_FALLBACK = 80 -const ELLIPSIS = '…' - -export function terminalWidth(): number { - const cols = process.stdout.columns - return typeof cols === 'number' && cols > 0 ? cols : TERMINAL_WIDTH_FALLBACK -} - -export function truncate(s: string, max: number): string { - if (s === '' || max <= 0) - return '' - if (s.length <= max) - return s - if (max === 1) - return ELLIPSIS - return s.slice(0, max - 1) + ELLIPSIS -} diff --git a/cli/src/sys/io/select.test.ts b/cli/src/sys/io/select.test.ts new file mode 100644 index 0000000000..7b858e7070 --- /dev/null +++ b/cli/src/sys/io/select.test.ts @@ -0,0 +1,95 @@ +import { PassThrough } from 'node:stream' +import { describe, expect, it } from 'vitest' +import { selectFromList } from './select' +import { bufferStreams } from './streams' + +type Row = { id: string, label: string } +const rows: Row[] = [ + { id: '1', label: 'alpha' }, + { id: '2', label: 'beta' }, + { id: '3', label: 'gamma' }, +] + +const SHOW_CURSOR = '\x1B[?25h' + +type FakeTTYIn = PassThrough & { isTTY: boolean, isRaw: boolean, setRawMode: (mode: boolean) => unknown } + +function ttyInput(opts: { failRawMode?: boolean } = {}): FakeTTYIn { + const stream = new PassThrough() as unknown as FakeTTYIn + stream.isTTY = true + stream.isRaw = false + stream.setRawMode = (mode: boolean): unknown => { + if (opts.failRawMode === true && mode) + throw new Error('raw mode unavailable') + stream.isRaw = mode + return stream + } + return stream +} + +function ttyStreams(input: FakeTTYIn): ReturnType<typeof bufferStreams> { + const io = bufferStreams() + ;(io as { in: NodeJS.ReadableStream }).in = input + ;(io as { isErrTTY: boolean }).isErrTTY = true + return io +} + +describe('selectFromList (non-TTY numbered fallback)', () => { + it('returns the item matching the typed number', async () => { + const io = bufferStreams('2\n') + ;(io as { isErrTTY: boolean }).isErrTTY = false + const picked = await selectFromList({ io, items: rows, header: 'Pick one', render: r => r.label }) + expect(picked.id).toBe('2') + expect(io.errBuf()).toContain('1) alpha') + expect(io.errBuf()).toContain('Pick one') + }) + + it('rejects an out-of-range selection', async () => { + const io = bufferStreams('9\n') + ;(io as { isErrTTY: boolean }).isErrTTY = false + await expect(selectFromList({ io, items: rows, header: 'Pick', render: r => r.label })) + .rejects + .toThrow(/invalid selection/i) + }) + + it('throws when the list is empty', async () => { + const io = bufferStreams('1\n') + ;(io as { isErrTTY: boolean }).isErrTTY = false + await expect(selectFromList({ io, items: [] as Row[], header: 'Pick', render: r => (r as Row).label })) + .rejects + .toThrow(/nothing to select/i) + }) +}) + +describe('selectFromList (interactive TTY picker)', () => { + it('moves with arrow keys and resolves on enter, restoring raw mode', async () => { + const input = ttyInput() + const io = ttyStreams(input) + const pick = selectFromList({ io, items: rows, header: 'Pick', render: r => r.label }) + input.write('\x1B[B') + input.write('\r') + const picked = await pick + expect(picked.id).toBe('2') + expect(input.isRaw).toBe(false) + expect(io.errBuf()).toContain(SHOW_CURSOR) + }) + + it('cancels on escape', async () => { + const input = ttyInput() + const io = ttyStreams(input) + const pick = selectFromList({ io, items: rows, header: 'Pick', render: r => r.label }) + input.write('\x1B') + await expect(pick).rejects.toThrow(/cancelled/i) + expect(input.isRaw).toBe(false) + }) + + it('rejects and restores the terminal when raw-mode setup fails', async () => { + const input = ttyInput({ failRawMode: true }) + const io = ttyStreams(input) + await expect(selectFromList({ io, items: rows, header: 'Pick', render: r => r.label })) + .rejects + .toThrow(/raw mode unavailable/i) + expect(input.isRaw).toBe(false) + expect(io.errBuf()).toContain(SHOW_CURSOR) + }) +}) diff --git a/cli/src/sys/io/select.ts b/cli/src/sys/io/select.ts new file mode 100644 index 0000000000..549b0bd340 --- /dev/null +++ b/cli/src/sys/io/select.ts @@ -0,0 +1,153 @@ +import type { Key } from 'node:readline' +import type { IOStreams } from './streams' +import * as readline from 'node:readline' +import { BaseError } from '@/errors/base' +import { ErrorCode } from '@/errors/codes' +import { colorEnabled, colorScheme } from './color' + +export type SelectOptions<T> = { + readonly io: IOStreams + readonly items: readonly T[] + readonly header: string + /** Single rich line shown per option. */ + readonly render: (item: T) => string + /** Optional second line shown only for the focused option in the TTY picker. */ + readonly describe?: (item: T) => string +} + +const HIDE_CURSOR = '\x1B[?25l' +const SHOW_CURSOR = '\x1B[?25h' +const CLEAR_DOWN = '\x1B[0J' +const cursorUp = (n: number): string => `\x1B[${n}A` + +export async function selectFromList<T>(opts: SelectOptions<T>): Promise<T> { + if (opts.items.length === 0) + throw new BaseError({ code: ErrorCode.UsageMissingArg, message: 'nothing to select' }) + return opts.io.isErrTTY ? pickInteractive(opts) : pickNumbered(opts) +} + +/** + * Arrow-key picker built on Node's readline keypress events — no third-party + * prompt library, so it bundles cleanly into the compiled binary. Renders to + * the err stream, redrawing in place on each keystroke and erasing itself on + * exit so the caller's own output starts on a clean row. + */ +async function pickInteractive<T>(opts: SelectOptions<T>): Promise<T> { + const input = opts.io.in as NodeJS.ReadStream + const out = opts.io.err + const cs = colorScheme(colorEnabled(opts.io.isErrTTY)) + const count = opts.items.length + + return new Promise<T>((resolve, reject) => { + let active = 0 + let rendered = 0 + + const frame = (): readonly string[] => { + const lines = [opts.header] + opts.items.forEach((item, i) => { + const focused = i === active + const pointer = focused ? cs.cyan('❯') : ' ' + const label = focused ? cs.bold(opts.render(item)) : opts.render(item) + lines.push(`${pointer} ${label}`) + }) + const desc = opts.describe?.(opts.items[active] as T) + if (desc !== undefined && desc !== '') + lines.push(cs.dim(` ${desc}`)) + return lines + } + + const render = (): void => { + if (rendered > 0) + out.write(cursorUp(rendered)) + const lines = frame() + out.write(`${CLEAR_DOWN}${lines.join('\n')}\n`) + rendered = lines.length + } + + const wasRaw = input.isTTY ? input.isRaw : false + const cleanup = (): void => { + input.off('keypress', onKey) + if (input.isTTY) + input.setRawMode(wasRaw) + input.pause() + if (rendered > 0) + out.write(`${cursorUp(rendered)}${CLEAR_DOWN}`) + out.write(SHOW_CURSOR) + } + + function onKey(_str: string | undefined, key: Key): void { + if (key.ctrl && key.name === 'c') { + cleanup() + reject(cancelled()) + return + } + switch (key.name) { + case 'up': + case 'k': + active = (active - 1 + count) % count + render() + break + case 'down': + case 'j': + active = (active + 1) % count + render() + break + case 'return': + case 'enter': { + const chosen = opts.items[active] + cleanup() + if (chosen === undefined) + reject(new BaseError({ code: ErrorCode.UsageInvalidFlag, message: 'invalid selection' })) + else + resolve(chosen) + break + } + case 'escape': + cleanup() + reject(cancelled()) + break + default: + break + } + } + + try { + readline.emitKeypressEvents(input) + if (input.isTTY) + input.setRawMode(true) + out.write(HIDE_CURSOR) + input.on('keypress', onKey) + input.resume() + render() + } + catch (err) { + cleanup() + reject(err) + } + }) +} + +function cancelled(): BaseError { + return new BaseError({ code: ErrorCode.UsageMissingArg, message: 'selection cancelled' }) +} + +async function pickNumbered<T>(opts: SelectOptions<T>): Promise<T> { + opts.io.err.write(`${opts.header}\n`) + opts.items.forEach((item, idx) => { + opts.io.err.write(` ${idx + 1}) ${opts.render(item)}\n`) + }) + opts.io.err.write('Enter number: ') + + const rl = readline.createInterface({ input: opts.io.in, output: opts.io.err, terminal: false }) + try { + const line: string = await new Promise(resolve => rl.once('line', resolve)) + const n = Number(line.trim()) + const chosen = Number.isInteger(n) ? opts.items[n - 1] : undefined + if (chosen === undefined) + throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: `invalid selection: ${line.trim()}` }) + return chosen + } + finally { + rl.close() + } +} diff --git a/cli/src/version/probe.test.ts b/cli/src/version/probe.test.ts index e8eb997b54..8b84d24704 100644 --- a/cli/src/version/probe.test.ts +++ b/cli/src/version/probe.test.ts @@ -1,30 +1,30 @@ import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen' -import type { HostsBundle } from '@/auth/hosts' +import type { ActiveContext } from '@/auth/hosts' import { mkdtemp, rm } from 'node:fs/promises' import { platform, tmpdir } from 'node:os' import { join } from 'node:path' import { startMock } from '@test/fixtures/dify-mock/server' import { describe, expect, it } from 'vitest' -import { saveHosts } from '@/auth/hosts' +import { Registry } from '@/auth/hosts' import { ENV_CONFIG_DIR } from '@/store/dir' import { arch } from '@/sys/index' import { runVersionProbe } from './probe' -function bundle(overrides: Partial<HostsBundle> = {}): HostsBundle { +function active(overrides: Partial<ActiveContext> = {}): ActiveContext { return { - current_host: 'cloud.dify.ai', + host: 'cloud.dify.ai', + email: 'test@dify.ai', + ctx: { account: { id: 'acct-1', email: 'test@dify.ai', name: 'Test' } }, scheme: 'https', - token_storage: 'file', - tokens: { bearer: 'dfoa_test' }, ...overrides, - } as HostsBundle + } } describe('runVersionProbe', () => { it('returns skipped server + unknown compat when skipServer=true', async () => { const report = await runVersionProbe({ skipServer: true, - loadBundle: async () => bundle(), + loadActive: async () => active(), probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }), }) @@ -38,7 +38,7 @@ describe('runVersionProbe', () => { let observed: string | undefined const report = await runVersionProbe({ skipServer: false, - loadBundle: async () => bundle({ tokens: { bearer: 'should-not-be-used' } as HostsBundle['tokens'] }), + loadActive: async () => active(), probe: async (endpoint) => { observed = endpoint return { version: '1.6.4', edition: 'CLOUD' } @@ -49,10 +49,10 @@ describe('runVersionProbe', () => { expect(report.compat.status).toBe('compatible') }) - it('returns no-host + unknown compat when bundle is missing', async () => { + it('returns no-host + unknown compat when active context is missing', async () => { const report = await runVersionProbe({ skipServer: false, - loadBundle: async () => undefined, + loadActive: async () => undefined, probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }), }) @@ -61,10 +61,10 @@ describe('runVersionProbe', () => { expect(report.compat.detail).toContain('no host') }) - it('returns no-host when bundle has empty current_host', async () => { + it('returns no-host when active context has empty host', async () => { const report = await runVersionProbe({ skipServer: false, - loadBundle: async () => bundle({ current_host: '' }), + loadActive: async () => active({ host: '' }), probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }), }) @@ -72,10 +72,10 @@ describe('runVersionProbe', () => { expect(report.compat.status).toBe('unknown') }) - it('distinguishes loadBundle disk failure from no-host configured in the detail', async () => { + it('distinguishes loadActive disk failure from no-host configured in the detail', async () => { const errReport = await runVersionProbe({ skipServer: false, - loadBundle: async () => { throw new Error('disk-explode') }, + loadActive: async () => { throw new Error('disk-explode') }, probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }), }) expect(errReport.server.reachable).toBe(false) @@ -84,7 +84,7 @@ describe('runVersionProbe', () => { const noHostReport = await runVersionProbe({ skipServer: false, - loadBundle: async () => undefined, + loadActive: async () => undefined, probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }), }) expect(noHostReport.compat.detail).toContain('no host') @@ -94,7 +94,7 @@ describe('runVersionProbe', () => { it('returns compatible report when server is reachable and in range', async () => { const report = await runVersionProbe({ skipServer: false, - loadBundle: async () => bundle(), + loadActive: async () => active(), probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }), }) @@ -108,7 +108,7 @@ describe('runVersionProbe', () => { it('returns unsupported when server version is out of range', async () => { const report = await runVersionProbe({ skipServer: false, - loadBundle: async () => bundle(), + loadActive: async () => active(), probe: async () => ({ version: '99.0.0', edition: 'SELF_HOSTED' }), }) @@ -119,7 +119,7 @@ describe('runVersionProbe', () => { it('returns unknown when server returns an empty version string', async () => { const report = await runVersionProbe({ skipServer: false, - loadBundle: async () => bundle(), + loadActive: async () => active(), probe: async (): Promise<ServerVersionResponse> => ({ version: '', edition: 'SELF_HOSTED' }), }) @@ -130,7 +130,7 @@ describe('runVersionProbe', () => { it('treats probe rejection as unreachable + unknown compat', async () => { const report = await runVersionProbe({ skipServer: false, - loadBundle: async () => bundle(), + loadActive: async () => active(), probe: async () => { throw new Error('timeout') }, }) @@ -141,10 +141,10 @@ describe('runVersionProbe', () => { expect(report.compat.detail).toContain('unreachable') }) - it('builds endpoint using bundle scheme when host has no scheme', async () => { + it('builds endpoint using active scheme when host has no scheme', async () => { const report = await runVersionProbe({ skipServer: false, - loadBundle: async () => bundle({ current_host: 'localhost:5001', scheme: 'http' }), + loadActive: async () => active({ host: 'localhost:5001', scheme: 'http' }), probe: async () => ({ version: '1.6.4', edition: 'SELF_HOSTED' }), }) @@ -161,12 +161,12 @@ describe('runVersionProbe', () => { const prevConfig = process.env[ENV_CONFIG_DIR] try { process.env[ENV_CONFIG_DIR] = configDir - saveHosts({ - current_host: url.host, - scheme: url.protocol.replace(':', ''), - token_storage: 'file', - tokens: { bearer: 'dfoa_test' }, - }) + const reg = Registry.empty('file') + reg.upsert(url.host, 'test@dify.ai', { account: { id: 'acct-1', email: 'test@dify.ai', name: 'Test' } }) + reg.setHost(url.host) + reg.setAccount('test@dify.ai') + reg.setScheme(url.host, url.protocol.replace(':', '')) + reg.save() process.env[ENV_CONFIG_DIR] = configDir const report = await runVersionProbe({ skipServer: false }) @@ -190,7 +190,7 @@ describe('runVersionProbe', () => { it('always includes client metadata in the report', async () => { const report = await runVersionProbe({ skipServer: true, - loadBundle: async () => undefined, + loadActive: async () => undefined, probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }), }) diff --git a/cli/src/version/probe.ts b/cli/src/version/probe.ts index 03ec018b8a..bfccaa77e4 100644 --- a/cli/src/version/probe.ts +++ b/cli/src/version/probe.ts @@ -1,9 +1,9 @@ import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen' import type { CompatVerdict } from './compat.js' import type { Channel } from './info.js' -import type { HostsBundle } from '@/auth/hosts' +import type { ActiveContext } from '@/auth/hosts' import { META_PROBE_TIMEOUT_MS, MetaClient } from '@/api/meta' -import { loadHosts } from '@/auth/hosts' +import { Registry } from '@/auth/hosts' import { createHttpClient } from '@/http/client' import { arch, platform } from '@/sys/index' import { hostWithScheme, openAPIBase } from '@/util/host' @@ -43,11 +43,13 @@ export type MetaProbe = (endpoint: string) => Promise<ServerVersionResponse> export type RunVersionProbeOptions = { readonly skipServer: boolean - readonly loadBundle?: () => Promise<HostsBundle | undefined> + readonly loadActive?: () => Promise<ActiveContext | undefined> readonly probe?: MetaProbe } -const defaultLoadBundle = async (): Promise<HostsBundle | undefined> => loadHosts() +const defaultLoadActive = async (): Promise<ActiveContext | undefined> => { + return Registry.load().resolveActive() +} const defaultProbe: MetaProbe = async (endpoint) => { const http = createHttpClient({ baseURL: openAPIBase(endpoint), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 }) @@ -89,19 +91,19 @@ export async function runVersionProbe(opts: RunVersionProbeOptions): Promise<Ver } } - const loadBundle = opts.loadBundle ?? defaultLoadBundle + const loadActive = opts.loadActive ?? defaultLoadActive const probe = opts.probe ?? defaultProbe - let bundle: HostsBundle | undefined + let active: ActiveContext | undefined let loadFailed = false try { - bundle = await loadBundle() + active = await loadActive() } catch { loadFailed = true } - if (bundle === undefined || bundle.current_host === '') { + if (active === undefined || active.host === '') { const detail = loadFailed ? 'hosts file unreadable' : 'no host configured' return { client, @@ -110,7 +112,7 @@ export async function runVersionProbe(opts: RunVersionProbeOptions): Promise<Ver } } - const endpoint = hostWithScheme(bundle.current_host, bundle.scheme) + const endpoint = hostWithScheme(active.host, active.scheme) let serverInfo: ServerVersionResponse | undefined try { diff --git a/cli/src/workspace/resolver.ts b/cli/src/workspace/resolver.ts index e7aba41a61..2f2ad7a212 100644 --- a/cli/src/workspace/resolver.ts +++ b/cli/src/workspace/resolver.ts @@ -1,11 +1,11 @@ -import type { HostsBundle } from '@/auth/hosts' +import type { ActiveContext } from '@/auth/hosts' import { BaseError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' export type WorkspaceResolveInputs = { readonly flag?: string readonly env?: string - readonly bundle?: HostsBundle + readonly active?: ActiveContext } export function resolveWorkspaceId(inputs: WorkspaceResolveInputs): string { @@ -13,13 +13,13 @@ export function resolveWorkspaceId(inputs: WorkspaceResolveInputs): string { return inputs.flag if (truthy(inputs.env)) return inputs.env - const b = inputs.bundle - if (b !== undefined) { - if (truthy(b.workspace?.id)) - return b.workspace.id - if (b.available_workspaces !== undefined && b.available_workspaces.length > 0 - && truthy(b.available_workspaces[0]?.id)) { - return b.available_workspaces[0].id + const ctx = inputs.active?.ctx + if (ctx !== undefined) { + if (truthy(ctx.workspace?.id)) + return ctx.workspace.id + if (ctx.available_workspaces !== undefined && ctx.available_workspaces.length > 0 + && truthy(ctx.available_workspaces[0]?.id)) { + return ctx.available_workspaces[0].id } } throw new BaseError({ diff --git a/cli/test/fixtures/dify-mock/scenarios.ts b/cli/test/fixtures/dify-mock/scenarios.ts index 39a0564db2..cfd55f438c 100644 --- a/cli/test/fixtures/dify-mock/scenarios.ts +++ b/cli/test/fixtures/dify-mock/scenarios.ts @@ -1,6 +1,7 @@ export type Scenario = | 'happy' | 'sso' + | 'no-email' | 'denied' | 'expired' | 'auth-expired' diff --git a/cli/test/fixtures/dify-mock/server.ts b/cli/test/fixtures/dify-mock/server.ts index 628272224b..59f318e092 100644 --- a/cli/test/fixtures/dify-mock/server.ts +++ b/cli/test/fixtures/dify-mock/server.ts @@ -362,6 +362,16 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { token_id: 'tok-sso-1', }) } + if (scenario === 'no-email') { + return c.json({ + token: 'dfoa_test', + subject_type: 'account', + account: { id: ACCOUNT.id, email: '', name: '' }, + workspaces: WORKSPACES.map(w => ({ id: w.id, name: w.name, role: w.role })), + default_workspace_id: 'ws-1', + token_id: 'tok-1', + }) + } return c.json({ token: 'dfoa_test', subject_type: 'account',