diff --git a/packages/ccdb-cli/src/help.ts b/packages/ccdb-cli/src/help.ts new file mode 100644 index 0000000..f253051 --- /dev/null +++ b/packages/ccdb-cli/src/help.ts @@ -0,0 +1,101 @@ +const common = `通用选项 + --profile <环境> 默认 production,可选 local / test / pre / 自定义 + --json 机器可读输出;登录进度为 JSON 行 + --timeout <毫秒> 单次请求超时 + -h, --help 查看帮助 + --version 查看版本 +`; + +export function helpText(parts: string[]): string { + const topic = parts.slice(0, 2).join(' '); + const pages: Record = { + 'auth login': `登录 CCDB + +用法 + ccdb-cli auth login [选项] + +登录选项 + --method <方式> device(默认)/ pkce / api-key + --no-browser 不自动打开浏览器,手动访问授权链接 + +示例 + ccdb-cli auth login --profile test + +设备码和授权链接会显示在终端,由你在浏览器完成授权。 +API Key 通过隐藏输入或 stdin 提供,不要放进命令参数。 +环境 CCDB_API_KEY 优先于保存的凭证;OAuth 失败不会自动改用 Key。 +`, + 'auth status': `查看本地凭证状态 + + ccdb-cli auth status [--profile <环境>] [--json] + +状态不代表远程权限有效;不会输出 Token 或完整 API Key。 +`, + 'auth logout': `退出登录 + + ccdb-cli auth logout [--profile <环境>] [--revoke] + +默认只清理本地凭证。 +--revoke 撤销整条应用授权,可能影响共用授权的其他工具。 +不会删除环境 CCDB_API_KEY,也不会在服务端停用 API Key。 +`, + 'factor search': `搜索排放因子 + +用法 + ccdb-cli factor search <关键词> [选项] + +查询选项 + --country <地区> 地区筛选,可重复 + --year <年份> 年份筛选,可重复 + --source-level <来源> 来源级别筛选,可重复 + --limit <数量> 返回 1~10 条,默认 5 + --language <语言> zh / en + --accounting-type <类> product / enterprise + +示例 + ccdb-cli factor search "电力" --profile test --limit 5 + +返回的是候选因子;推荐前请核对详情、单位及适用范围。 +`, + 'factor detail': `查看因子详情 + + ccdb-cli factor detail [--language zh|en] [选项] + +factorId 必须原样使用搜索结果中的字符串 ID,不使用示例 ID。 +详情与搜索须保持相同 profile;受限值不是 0,不能用于计算。 +`, + doctor: `检查服务配置 + + ccdb-cli doctor [--profile <环境>] [--json] + +检查发现端点和本地凭证状态,不查询因子、不消耗查询配额。 +诊断通过不代表业务权限有效。 +`, + }; + if (pages[topic]) return pages[topic] + '\n' + common; + return `ccdb-cli — CCDB 排放因子查询 + +用法 + ccdb-cli <命令> [选项] + +账号 + auth login 登录(默认设备码授权) + auth status 查看本地凭证状态 + auth logout 退出登录 + +因子 + factor search <关键词> 搜索候选因子 + factor detail 查看因子详情 + +诊断 + doctor 检查服务配置 + +快速开始(测试环境) + ccdb-cli auth login --profile test + ccdb-cli factor search "电力" --profile test --limit 5 + +${common} +更多参数:ccdb-cli factor search --help +登录选项:ccdb-cli auth login --help +`; +} diff --git a/packages/ccdb-cli/src/output.ts b/packages/ccdb-cli/src/output.ts index 712ca61..36cd38a 100644 --- a/packages/ccdb-cli/src/output.ts +++ b/packages/ccdb-cli/src/output.ts @@ -1,3 +1,28 @@ +/** Human login progress only; --json retains the original event objects. */ +export function loginProgress(event: Record): string { + const text = (value: unknown) => + typeof value === 'string' ? value.replace(/[\x00-\x1f\x7f-\x9f]/g, '') : ''; + if (event.event === 'credential_storage') return '凭证存储\n ' + text(event.message); + if (event.event === 'authorization_pending') { + const url = text(event.verificationUri || event.authorizationUri); + const code = text(event.userCode); + const expires = + typeof event.expiresIn === 'number' && event.expiresIn > 0 + ? '\n 有效期:' + Math.ceil(event.expiresIn / 60) + ' 分钟' + : ''; + return ( + '等待浏览器授权\n' + + (code ? ' 设备码:' + code + '\n' : '') + + ' 授权链接:' + + url + + expires + + '\n\n 请在浏览器完成授权,终端会继续等待。' + ); + } + if (event.event === 'browser_unavailable') return '浏览器未能自动打开,请手动访问上方授权链接。'; + return '登录提示:' + (text(event.message) || '正在处理,请稍候。'); +} + /** Localized fields remain untouched in JSON; only the terminal view selects a label. */ export function label(value: unknown, language = 'zh'): string { if (value === null || value === undefined) return '—'; diff --git a/packages/ccdb-cli/src/runner.ts b/packages/ccdb-cli/src/runner.ts index 89f9789..11db690 100644 --- a/packages/ccdb-cli/src/runner.ts +++ b/packages/ccdb-cli/src/runner.ts @@ -2,24 +2,9 @@ import { parseArgs } from 'node:util'; import { CcdbClient, CcdbError, asError, exitCode, type Config } from 'ccdb-client'; import { appConfig } from './config.js'; import { interactiveLogin } from 'ccdb-client/auth/interactive'; -import { humanOutput } from './output.js'; +import { humanOutput, loginProgress } from './output.js'; +import { helpText } from './help.js'; -const HELP = `ccdb-cli 0.1.2 — CCDB 因子查询工具 - -ccdb-cli auth login [--method device|pkce|api-key] [--no-browser] -ccdb-cli auth status -ccdb-cli auth logout [--revoke] -ccdb-cli factor search [--language zh|en] [--accounting-type product|enterprise] - [--country 中国] [--year 2025] [--source-level 国家排放因子] [--limit 5] -ccdb-cli factor detail [--language zh|en] -ccdb-cli doctor - -公共选项:--profile local|test|pre|production|自定义 --json --timeout <毫秒> -筛选项可重复传入;factorId 必须原样使用字符串。 -API Key 使用 CCDB_API_KEY 环境变量或 api-key 登录的隐藏输入/stdin,不接受明文命令行参数。 -auth logout --revoke 会撤销整条应用授权,可能影响共用该授权的 MCP/CLI/Skill。 ---json 成功/错误为 JSON;登录进度为不含 Token 的 JSON 行。 -`; export async function runCli( args: string[], env: NodeJS.ProcessEnv = process.env, @@ -52,7 +37,7 @@ export async function runCli( }, }); if (v.help || (!p.length && !v.version)) { - process.stdout.write(HELP); + process.stdout.write(helpText(p)); return 0; } if (v.version) { @@ -87,7 +72,7 @@ export async function runCli( if (selected === 'auth login') { const notify = (event: Record) => { if (v.json) process.stdout.write(JSON.stringify(event) + '\n'); - else process.stderr.write(JSON.stringify(event, null, 2) + '\n'); + else process.stderr.write(loginProgress(event) + '\n'); }; output = await interactiveLogin( client.auth, diff --git a/tests/help.test.ts b/tests/help.test.ts new file mode 100644 index 0000000..30b9fe3 --- /dev/null +++ b/tests/help.test.ts @@ -0,0 +1,59 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { loginProgress } from '../packages/ccdb-cli/src/output.js'; + +test('root and command help work without valid configuration or starting services', () => { + for (const args of [ + ['--help'], + ['auth', 'login', '--help'], + ['factor', 'search', '-h'], + ['factor', 'detail', '--help'], + ]) { + const result = spawnSync( + process.execPath, + [resolve('packages/ccdb-cli/dist/main.mjs'), ...args], + { + encoding: 'utf8', + timeout: 5000, + env: { + ...process.env, + CCDB_PROFILE: 'unregistered-help-fixture', + CCDB_API_BASE: 'invalid-url', + CCDB_MCP_PORT: 'invalid-port', + }, + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ''); + assert.match(result.stdout, /ccdb-cli/); + assert.ok(result.stdout.split('\n').length > 5); + } +}); + +test('device and PKCE progress are readable without rendering arbitrary secret fields', () => { + const device = loginProgress({ + event: 'authorization_pending', + verificationUri: 'https://example.com/device', + userCode: 'ABCD-EFGH', + expiresIn: 900, + accessToken: 'secret-fixture', + }); + assert.match(device, /设备码:ABCD-EFGH/); + assert.match(device, /有效期:15 分钟/); + assert.match(device, /https:\/\/example.com\/device/); + assert.ok(!device.includes('secret-fixture')); + const pkce = loginProgress({ + event: 'authorization_pending', + authorizationUri: 'https://example.com/authorize', + }); + assert.match(pkce, /授权链接:https:\/\/example.com\/authorize/); + assert.ok(!pkce.includes('设备码')); + assert.match(loginProgress({ event: 'browser_unavailable' }), /手动访问/); + assert.match( + loginProgress({ event: 'credential_storage', message: '已使用加密文件存储' }), + /加密文件存储/, + ); + assert.ok(!loginProgress({ event: 'unknown', message: '\u001bunsafe\u0007' }).includes('\u001b')); +});