import type { SessionRow } from '@dify/contracts/api/openapi/types.gen' import type { ActiveContext, Registry } from '@/auth/hosts' import type { HttpClient } from '@/http/types' import type { TokenStore } from '@/store/token-store' import type { IOStreams } from '@/sys/io/streams' import { AccountSessionsClient } from '@/api/account-sessions' import { BaseError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' import { LIMIT_DEFAULT, LIMIT_MAX, parseLimit } from '@/limit/limit' import { colorEnabled, colorScheme } from '@/sys/io/color' import { runWithSpinner } from '@/sys/io/spinner' export type DevicesListOptions = { readonly io: IOStreams readonly tokenId: string readonly http: HttpClient readonly json?: boolean readonly page?: number readonly limitRaw?: string readonly envLookup?: (k: string) => string | undefined } export async function runDevicesList(opts: DevicesListOptions): Promise { const sessions = new AccountSessionsClient(opts.http) const env = opts.envLookup ?? ((k: string) => process.env[k]) const limit = resolveLimit(opts.limitRaw, env) const page = opts.page === undefined || opts.page <= 0 ? 1 : opts.page const envelope = await runWithSpinner( { io: opts.io, label: 'Fetching devices' }, () => sessions.list({ page, limit }), ) if (opts.json === true) { opts.io.out.write(`${JSON.stringify(envelope)}\n`) return } opts.io.out.write(renderTable(envelope.data, opts.tokenId)) } function resolveLimit(raw: string | undefined, env: (k: string) => string | undefined): number { if (raw !== undefined && raw !== '') return parseLimit(raw, '--limit') const envValue = env('DIFY_LIMIT') if (envValue !== undefined && envValue !== '') return parseLimit(envValue, 'DIFY_LIMIT') return LIMIT_DEFAULT } /** * Fetches every session across all pages. Used by revoke paths so that a * session sitting on page 2+ is still findable / revocable. Uses the max * page size (LIMIT_MAX) to minimize round-trips. */ export async function listAllSessions(client: AccountSessionsClient): Promise { const out: SessionRow[] = [] let page = 1 // Hard guard against a misbehaving server that lies about has_more. const MAX_PAGES = 100 while (page <= MAX_PAGES) { const env = await client.list({ page, limit: LIMIT_MAX }) out.push(...env.data) if (!env.has_more) return out page++ } return out } export type DevicesRevokeOptions = { readonly io: IOStreams readonly reg: Registry readonly active: ActiveContext readonly store: TokenStore readonly http: HttpClient readonly target?: string readonly all: boolean readonly yes?: boolean } export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise { const cs = colorScheme(colorEnabled(opts.io.isErrTTY)) if (!opts.all && (opts.target === undefined || opts.target === '')) { throw new BaseError({ code: ErrorCode.UsageMissingArg, message: 'specify a device label / id, or pass --all', hint: 'see \'difyctl auth devices list\'', }) } const sessions = new AccountSessionsClient(opts.http) const rows = await listAllSessions(sessions) const { ids, selfHit } = pickTargets(rows, opts, opts.active.ctx.token_id ?? '') if (ids.length === 0) { opts.io.out.write('no sessions to revoke\n') return } for (const id of ids) await sessions.revoke(id) if (selfHit) opts.reg.forget(opts.active, opts.store) opts.io.out.write(`${cs.successIcon()} Revoked ${ids.length} session(s)\n`) } export type PickResult = { ids: readonly string[] selfHit: boolean } export function pickTargets(rows: readonly SessionRow[], opts: { target?: string, all: boolean }, currentId: string): PickResult { if (opts.all) { const ids = rows.filter(r => r.id !== currentId).map(r => r.id) return { ids, selfHit: false } } const target = opts.target ?? '' const byLabel = rows.filter(r => r.device_label === target) if (byLabel.length > 1) throw ambiguous(target, byLabel) const onlyLabel = byLabel[0] if (onlyLabel !== undefined) return { ids: [onlyLabel.id], selfHit: onlyLabel.id === currentId } const byId = rows.find(r => r.id === target) if (byId !== undefined) return { ids: [byId.id], selfHit: byId.id === currentId } const needle = target.toLowerCase() const bySub = rows.filter(r => r.device_label.toLowerCase().includes(needle)) if (bySub.length > 1) throw ambiguous(target, bySub) const onlySub = bySub[0] if (onlySub !== undefined) return { ids: [onlySub.id], selfHit: onlySub.id === currentId } throw new BaseError({ code: ErrorCode.UsageMissingArg, message: `no session matches "${target}"`, }) } function ambiguous(target: string, rows: readonly SessionRow[]): BaseError { const labels = rows.map(r => `${r.device_label} (${r.id})`).join(', ') return new BaseError({ code: ErrorCode.UsageInvalidFlag, message: `"${target}" matches multiple sessions: ${labels}; pass an exact id to disambiguate`, }) } function renderTable(rows: readonly SessionRow[], currentId: string): string { const header = ['DEVICE', 'CREATED', 'LAST USED', 'CURRENT'] const body = rows.map(r => [ r.device_label !== '' ? r.device_label : r.id, r.created_at ?? '', r.last_used_at ?? '', r.id === currentId ? '*' : '', ]) const widths = header.map((h, i) => Math.max(h.length, ...body.map(row => (row[i] ?? '').length))) const fmt = (cells: readonly string[]): string => cells.map((c, i) => c.padEnd(widths[i] ?? 0)).join(' ').trimEnd() return body.length === 0 ? `${fmt(header)}\n` : `${[fmt(header), ...body.map(fmt)].join('\n')}\n` }