@xiaoyuanhao/yunzhi-cli 2.1.1 → 2.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,135 @@
1
+ // Conversation authorization state is local to the command working directory.
2
+ const fs = require('node:fs/promises');
3
+ const path = require('node:path');
4
+ const { createHash } = require('node:crypto');
5
+ const { root, loadConfig } = require('./storage.cjs');
6
+ const { gatewayURL } = require('./gateway.cjs');
7
+ const { Failure, invalid } = require('./result.cjs');
8
+ const hash = value => createHash('sha256').update(value).digest('hex');
9
+ const operation = args => hash(JSON.stringify(args));
10
+ function stopped(message) { return new Failure(3, 'authentication', 'authorization_stopped', message); }
11
+ async function withState(action, create = false) {
12
+ const directory = path.join(root(), 'authorization');
13
+ if (!create) {
14
+ try { await fs.access(path.join(directory, hash(process.cwd()) + '.json')); }
15
+ catch (error) { if (error.code === 'ENOENT') return action(null, async () => {}); throw error; }
16
+ }
17
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
18
+ const info = await fs.lstat(directory);
19
+ if (!info.isDirectory() || info.isSymbolicLink()) throw stopped('授权上下文目录无效。');
20
+ const file = path.join(directory, hash(process.cwd()) + '.json');
21
+ let lock;
22
+ try { lock = await fs.open(file + '.lock', 'wx', 0o600); }
23
+ catch (error) { if (error.code === 'EEXIST') throw stopped('本工作目录的授权操作正在进行或上次被中断,请先检查该操作。'); throw error; }
24
+ try {
25
+ let state = null;
26
+ try {
27
+ const stat = await fs.lstat(file);
28
+ if (!stat.isFile() || stat.isSymbolicLink()) throw stopped('授权上下文文件无效。');
29
+ state = JSON.parse(await fs.readFile(file, 'utf8'));
30
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
31
+ const save = async value => {
32
+ await fs.writeFile(file + '.tmp', JSON.stringify(value), { mode: 0o600, flag: 'wx' });
33
+ await fs.rename(file + '.tmp', file);
34
+ };
35
+ return await action(state, save);
36
+ } finally { await lock.close(); await fs.unlink(file + '.lock'); }
37
+ }
38
+ async function binding() {
39
+ const config = await loadConfig();
40
+ return { gateway: gatewayURL(config), profile: config.current_profile?.credential_ref || null };
41
+ }
42
+ const sameBinding = (s, b) => s.gateway === b.gateway && s.profile === b.profile;
43
+ async function remember(args, result) {
44
+ const error = result.value?.error;
45
+ if (!error || !['auth_required', 'missing_scope'].includes(error.subtype)
46
+ || !['authentication', 'authorization'].includes(error.type)
47
+ || error.operation_completed || error.cleanup_failed) return;
48
+ const scopes = error.required_scopes;
49
+ if (!Array.isArray(scopes) || !scopes.length || scopes.some(s => typeof s !== 'string' || !s.trim())) return;
50
+ const bound = await binding();
51
+ await withState(async (state, save) => {
52
+ const id = operation(args);
53
+ if (state && sameBinding(state, bound) && state.operation === id && state.phase !== 'required') {
54
+ error.hint = '本次业务的授权恢复已停止,请报告当前错误;不要重新创建授权或重复办理。';
55
+ return;
56
+ }
57
+ if (state && sameBinding(state, bound) && ['creating', 'pending', 'exchanging'].includes(state.phase)) {
58
+ error.hint = '本工作目录已有另一项待完成的授权,请先完成或明确取消它。';
59
+ return;
60
+ }
61
+ await save({ ...bound, operation: id, scopes, phase: 'required' });
62
+ error.hint = 'CLI 已保存本次所需权限。执行 auth login 获取授权链接;等待用户确认后执行 auth login --continue。';
63
+ }, true);
64
+ }
65
+ async function beforeBusiness(args) {
66
+ const bound = await binding();
67
+ return withState(async (state, save) => {
68
+ if (!state || !sameBinding(state, bound) || state.operation !== operation(args)) return;
69
+ if (['creating', 'pending', 'exchanging', 'stopped', 'retried'].includes(state.phase))
70
+ throw stopped('本次业务已有授权恢复记录,尚未完成授权或恢复次数已用完;本次未执行业务。');
71
+ if (state.phase === 'complete') await save({ ...state, phase: 'retried' });
72
+ });
73
+ }
74
+ async function afterBusiness(args, result) {
75
+ if (!result.value?.ok) return;
76
+ await withState(async (state, save) => {
77
+ if (state?.operation === operation(args) && state.phase === 'retried') await save(null);
78
+ });
79
+ }
80
+ async function login(args) {
81
+ if (args.length && !(args.length === 1 && args[0] === '--continue')) throw invalid('用法:auth login 或 auth login --continue。');
82
+ const continuing = args.length === 1;
83
+ const auth = require('./auth.cjs');
84
+ const bound = await binding();
85
+ return withState(async (state, save) => {
86
+ if (!state || !sameBinding(state, bound)) throw stopped('没有与当前账号及服务地址匹配的待授权业务,请先执行需要办理的业务。');
87
+ if (!continuing) {
88
+ if (state.phase === 'pending' && Date.parse(state.expires_at) > Date.now()) return receipt(state);
89
+ if (state.phase !== 'required') throw stopped('本次授权已结束或状态不确定,不再自动创建新会话。');
90
+ await save({ ...state, phase: 'creating' });
91
+ try {
92
+ const explicit = state.scopes.flatMap(s => ['--scope', s]);
93
+ let result;
94
+ try { result = await auth.login(explicit); }
95
+ catch (error) {
96
+ if (error.result?.value.error.subtype !== 'stale_session_requires_logout') throw error;
97
+ await auth.logout([]);
98
+ state = { ...state, ...await binding() };
99
+ result = await auth.login(explicit);
100
+ }
101
+ const data = result.value.data;
102
+ state = { ...state, phase: 'pending', device_code: data.device_code,
103
+ authorization_url: data.authorization_url, expires_at: data.expires_at, attempts: 0 };
104
+ await save(state);
105
+ return receipt(state);
106
+ } catch (error) { await save({ ...state, phase: 'stopped' }); throw error; }
107
+ }
108
+ if (state.phase !== 'pending' || Date.parse(state.expires_at) <= Date.now()) {
109
+ await save({ ...state, device_code: undefined, phase: 'stopped' });
110
+ throw stopped('授权已过期或本次流程已停止。');
111
+ }
112
+ state.attempts++;
113
+ await save({ ...state, phase: 'exchanging' });
114
+ try {
115
+ const result = await auth.login(['--device-code', state.device_code]);
116
+ await save({ ...state, ...await binding(), device_code: undefined, phase: 'complete' });
117
+ result.value.data.message = '授权完成。可以继续原业务一次。';
118
+ return result;
119
+ } catch (error) {
120
+ const pending = error.result?.value.error.subtype === 'authorization_pending' && state.attempts === 1;
121
+ await save({ ...state, device_code: pending ? state.device_code : undefined, phase: pending ? 'pending' : 'stopped' });
122
+ if (error.result) error.result.value.error.hint = pending
123
+ ? '网页授权尚未完成。请等待用户再次明确确认,然后执行 auth login --continue;不要轮询。'
124
+ : '本次授权恢复已停止。请报告错误,不再兑换或自动重新创建授权。';
125
+ throw error;
126
+ }
127
+ });
128
+ }
129
+ function receipt(state) {
130
+ return { code: 0, value: { ok: true, data: { event: 'authorization_pending', authorization_url: state.authorization_url,
131
+ expires_at: state.expires_at, requested_scopes: state.scopes,
132
+ message: '请用业务名称说明所需权限,展示可点击的完整授权链接。等待用户明确确认网页授权完成后执行 auth login --continue;不要轮询。' } } };
133
+ }
134
+ async function clear() { await withState(async (_, save) => save(null)); }
135
+ module.exports = { login, remember, beforeBusiness, afterBusiness, clear };
package/lib/cli.cjs CHANGED
@@ -127,7 +127,7 @@ const usage = {
127
127
  'leave remind': '--id <id>',
128
128
  'leave withdraw': '--id <id>',
129
129
  'leave list': '[--scope mine|managed] [--start-date YYYY-MM-DD --end-date YYYY-MM-DD] [--leave-type <type>] [--status draft|processing|completed|returned|rejected|withdrawn|unknown] [--reason <text>] [--page <n>] [--page-size <1-100>]',
130
- 'auth login': '--scope <scope> [--scope <scope>...]\n 或 auth login --device-code <device_code>',
130
+ 'auth login': '[--continue]',
131
131
  'auth connect': '--scope <scope> [--scope <scope>...]',
132
132
  'auth status': '[--verify]', 'auth logout': '',
133
133
  'lesson-prep ls': '[path] [--limit 1..200] [--cursor <cursor>]',
@@ -302,37 +302,22 @@ const summaries = {
302
302
  'review approve': '通过上报并创建目标资源', 'review reject': '拒绝上报并记录原因',
303
303
  };
304
304
 
305
- const loginHelp = `登录与授权:创建授权会话,或在网页授权完成后兑换登录态。
305
+ const loginHelp = `登录与授权:根据刚才业务保存的权限生成链接,用户确认后继续授权。
306
306
 
307
- 1. 创建授权会话(首次登录或追加权限)
308
- 用法:yunzhi-cli auth login --scope <scope> [--scope <scope>...]
307
+ 用法:yunzhi-cli auth login [--continue]
309
308
  参数:
310
- --scope 必填,可重复,无默认值。使用原业务错误返回的 error.required_scopes,每个权限分别传入一次。
311
- 仅要求登录或切换账号、组织且尚未明确业务时,先询问要办理的业务,不自行选择默认权限。
309
+ 无参数:使用本工作目录内刚才业务保存的权限创建授权链接;已有待授权链接时复用,不重复创建。
310
+ --continue:仅在用户明确确认网页授权完成后使用;CLI 读取保存的授权码并完成兑换。
312
311
  示例:
313
- yunzhi-cli auth login --scope school-library:read
312
+ yunzhi-cli auth login
313
+ yunzhi-cli auth login --continue
314
314
  返回:
315
- ok=true、data.event=authorization_pending 表示会话创建成功,尚未完成登录。
316
- data.authorization_url 是完整授权地址;data.device_code 用于后续兑换。
317
- data.expires_at 是会话过期时间;data.requested_scopes 是本次权限;data.message 提示下一步。
318
- 下一步:
319
- 将 authorization_url 展示为可点击的 Markdown 链接:[完整 URL](完整 URL),两处保留完整原始地址,不使用行内代码或代码块。
320
- 用用户的语言和业务名称说明所需权限,不直接展示权限代码。等待用户明确确认网页授权完成后再兑换,不轮询。
321
-
322
- 2. 兑换登录态
323
- 用法:yunzhi-cli auth login --device-code <device_code>
324
- 参数:
325
- --device-code 必填,使用创建会话时返回的 data.device_code,不能与 --scope 同时使用。
326
- 示例:
327
- yunzhi-cli auth login --device-code <device_code>
328
- 返回:
329
- ok=true、data.event=authorization_complete 表示登录授权完成,可以恢复原业务命令。
330
- data.user、data.tenant 是当前用户和组织;data.granted_scopes 是已授权权限;data.expires_at 是登录过期时间。
331
- 下一步:
332
- 成功后重试原业务命令。
333
-
334
- 除 --scope 外,参数不能重复传入。
335
- 默认输出 JSON。失败时阅读 error.message 及返回的引导字段,并按认证指南处理。
315
+ authorization_pending:尚未完成授权,展示 authorization_url 为可点击的完整链接,并用业务名称说明权限。
316
+ authorization_complete:授权完成,可以继续原业务一次。
317
+ 限制:
318
+ 先执行业务命令获取所需权限,不需要手动传递权限或授权码。后续调用保持同一工作目录、账号和服务地址。
319
+ 用户确认后才继续,不轮询。CLI 处理失效登录清理和恢复次数;根据返回提示等待确认或停止。
320
+ CLI 不自动同意授权,不自动重放业务写操作。失败原因与下一步见 error.message/error.hint。
336
321
  `;
337
322
 
338
323
  async function execute(args) {
@@ -415,6 +400,13 @@ async function execute(args) {
415
400
  if ((args[1] === 'reference' && args[0] !== 'lesson-prep') || (command === 'web' && args[0] !== 'school-library')) throw invalid('未知命令。');
416
401
  return require('./business.cjs').execute(args[0], command, args.slice(grouped ? 3 : 2));
417
402
  }
403
+ if (args[0] === 'auth' && args[1] === 'login' && (args.length === 2 || args.includes('--continue')))
404
+ return require('./auth-context.cjs').login(args.slice(2));
405
+ if (args[0] === 'auth' && args[1] === 'logout') {
406
+ const result = await require('./auth.cjs').logout(args.slice(2));
407
+ await require('./auth-context.cjs').clear();
408
+ return result;
409
+ }
418
410
  if (args[0] === 'auth' && ['login', 'connect', 'status', 'logout'].includes(args[1])) return require('./auth.cjs')[args[1]](args.slice(2));
419
411
  if (args[0] === 'config' && args[1] === 'set') {
420
412
  const filtered = args.slice(2);
@@ -440,7 +432,11 @@ async function run(args) {
440
432
  const command = Object.keys(usage).filter(item => item.split(' ').every((word, index) => args[index] === word))
441
433
  .sort((left, right) => right.length - left.length)[0];
442
434
  let result;
443
- try { result = await execute(args); }
435
+ const managedBusiness = command && !['auth', 'config', 'version', 'help'].includes(args[0]) && !args.some(a => ['--help', '-h'].includes(a));
436
+ try {
437
+ if (managedBusiness) await require('./auth-context.cjs').beforeBusiness(args);
438
+ result = await execute(args);
439
+ }
444
440
  catch (error) { if (error instanceof Failure) result = error.result; else throw error; }
445
441
  if (command && result.value?.ok && !guidance.validResult(command, result.value.data)) {
446
442
  const failure = new Failure(1, 'api', 'invalid_response', guidance.resultProblem(command, result.value.data));
@@ -471,6 +467,19 @@ async function run(args) {
471
467
  }
472
468
  }
473
469
  if (command && result.value) require('./attendance-operation-result.cjs').project(command, result.value);
470
+ if (managedBusiness) {
471
+ try {
472
+ await require('./auth-context.cjs').remember(args, result);
473
+ await require('./auth-context.cjs').afterBusiness(args, result);
474
+ } catch (error) {
475
+ const problem = error instanceof Failure ? error.result.value.error : { type: 'internal', message: '无法保存授权上下文。' };
476
+ if (result.value?.ok) { result.value.data.authorization_cleanup_error = problem; }
477
+ else {
478
+ result.value.error.authorization_context_error = problem;
479
+ result.value.error.hint = '无法保存本次授权上下文,请报告错误并停止;不要继续创建授权。';
480
+ }
481
+ }
482
+ }
474
483
  return result;
475
484
  }
476
485
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaoyuanhao/yunzhi-cli",
3
- "version": "2.1.1",
3
+ "version": "2.1.2",
4
4
  "description": "Yunzhi Campus CLI for lesson preparation and school library workflows",
5
5
  "bin": {
6
6
  "yunzhi-cli": "yunzhi-cli.cjs"