weaver-work-cli 0.1.0 → 0.1.1

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.
@@ -1,6 +1,7 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { createServer } from 'node:http';
3
3
  import { CliError, ExitCode } from '../../../core/errors.js';
4
+ import { clearInvoiceTokenForSession } from '../../../shortcuts/invoice/host.js';
4
5
  import { buildE10CookieHeader, E10AuthSession, clearProfileAuthFile, extractRawSetCookie, findAuthFile, getActiveProfile, getProfileAuthPath, getProfileDir, listProfiles, profileNameFromUrl, readProfileConfig, readTeamsCheckUserInfo, saveAuthFile, saveProfileConfig, setActiveProfile, } from './session.js';
5
6
  import { getE10AuthRoot } from './paths.js';
6
7
  const DEFAULT_AGENT_TYPE = 'weaver-work-cli';
@@ -83,6 +84,15 @@ function formatLoginResult(result) {
83
84
  'authFile: ' + result.authFile,
84
85
  ].join('\n');
85
86
  }
87
+ async function clearInvoiceTokenCache(profile, authData) {
88
+ if (!authData.baseUrl || !authData.userId)
89
+ return;
90
+ await clearInvoiceTokenForSession({
91
+ baseUrl: normalizeUrl(authData.baseUrl),
92
+ profile,
93
+ userId: authData.userId,
94
+ }).catch(() => false);
95
+ }
86
96
  function shellQuote(value) {
87
97
  return `'${value.replace(/'/g, "'\\''")}'`;
88
98
  }
@@ -201,6 +211,7 @@ async function oidcExchange(code, redirectUri, baseUrl, tenantKeyFallback, agent
201
211
  };
202
212
  const profile = profileHint || profileNameFromUrl(base);
203
213
  setActiveProfile(profile);
214
+ await clearInvoiceTokenCache(profile, authData);
204
215
  const authFile = saveAuthFile(authData, profile);
205
216
  saveProfileConfig(base, base, profile, tenantKey);
206
217
  return {
@@ -455,6 +466,7 @@ export function registerE10AuthCommands(parent, action) {
455
466
  passportUrl,
456
467
  cookieSavedAt: Date.now(),
457
468
  };
469
+ await clearInvoiceTokenCache(profile, authData);
458
470
  const authFile = saveAuthFile(authData, profile);
459
471
  saveProfileConfig(baseUrl, passportUrl, profile, tenantKey);
460
472
  const result = {
@@ -475,9 +487,18 @@ export function registerE10AuthCommands(parent, action) {
475
487
  auth.command('logout')
476
488
  .alias('clear')
477
489
  .description('Clear saved E10 auth for the current or selected profile')
478
- .action(action((ctx) => {
490
+ .action(action(async (ctx) => {
479
491
  const opts = ctx.getGlobalOptions();
480
- const result = clearProfileAuthFile(opts.profile || getActiveProfile());
492
+ const profile = opts.profile || getActiveProfile();
493
+ const authFile = getProfileAuthPath(profile);
494
+ const session = E10AuthSession.fromFile(authFile, opts.baseUrl, opts.passportUrl);
495
+ if (session) {
496
+ await clearInvoiceTokenCache(profile, {
497
+ baseUrl: session.getBaseUrl(),
498
+ userId: session.userId,
499
+ });
500
+ }
501
+ const result = clearProfileAuthFile(profile);
481
502
  const text = result.cleared
482
503
  ? 'Cleared E10 auth.\nprofile: ' + result.profile + '\nauthFile: ' + result.authFile
483
504
  : 'No E10 auth to clear.\nprofile: ' + result.profile + '\nauthFile: ' + result.authFile;
@@ -5,6 +5,7 @@ import { getActiveProfile } from '../../internal/e10/auth/session.js';
5
5
  import { InvoiceCliError, invoiceAuthError, invoiceNetworkError, invoiceValidationError, } from './errors.js';
6
6
  const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024;
7
7
  const TOKEN_SERVICE = 'cn.weaver.weaver-work-cli.invoice-token';
8
+ const INVOICE_SESSION_EXPIRED_CODE = '-2';
8
9
  function assertPath(requestPath) {
9
10
  if (typeof requestPath !== 'string' || !requestPath.startsWith('/') || requestPath.startsWith('//')) {
10
11
  throw invoiceValidationError('path_invalid', '请求路径必须是相对绝对路径');
@@ -63,6 +64,10 @@ function responseCode(value) {
63
64
  const root = responseRoot(value);
64
65
  return root?.ret ?? root?.code;
65
66
  }
67
+ function isInvoiceSessionExpired(value) {
68
+ const code = responseCode(value);
69
+ return code !== undefined && String(code) === INVOICE_SESSION_EXPIRED_CODE;
70
+ }
66
71
  function tokenAccount(session) {
67
72
  return createHash('sha256')
68
73
  .update(`${session.profile}\n${session.baseUrl}\n${session.userId}`)
@@ -109,6 +114,9 @@ function createInvoiceTokenStore(session) {
109
114
  },
110
115
  };
111
116
  }
117
+ export async function clearInvoiceTokenForSession(session) {
118
+ return createInvoiceTokenStore(session).clear();
119
+ }
112
120
  function inferProfile(ctx) {
113
121
  const options = ctx.getGlobalOptions();
114
122
  return options.profile || getActiveProfile();
@@ -125,14 +133,16 @@ function requestHeaders(session, invoiceToken) {
125
133
  headers.token = invoiceToken;
126
134
  return headers;
127
135
  }
128
- export function createInvoiceHost(ctx) {
136
+ export function createInvoiceHost(ctx, dependencies = {}) {
129
137
  let e10Session = null;
130
138
  let sessionContext = null;
131
139
  let invoiceToken = null;
132
140
  let tokenStore = null;
141
+ const requireSession = dependencies.requireSession || requireE10Session;
142
+ const tokenStoreFactory = dependencies.tokenStoreFactory || createInvoiceTokenStore;
133
143
  async function ensureSession() {
134
144
  if (!e10Session) {
135
- e10Session = await requireE10Session(ctx);
145
+ e10Session = await requireSession(ctx);
136
146
  sessionContext = {
137
147
  baseUrl: e10Session.getBaseUrl(),
138
148
  profile: inferProfile(ctx),
@@ -148,7 +158,7 @@ export function createInvoiceHost(ctx) {
148
158
  async function ensureInvoiceToken(timeoutMs = 30_000) {
149
159
  await ensureSession();
150
160
  const context = await getSession();
151
- tokenStore ||= createInvoiceTokenStore(context);
161
+ tokenStore ||= tokenStoreFactory(context);
152
162
  if (invoiceToken)
153
163
  return invoiceToken;
154
164
  invoiceToken = await tokenStore.load();
@@ -182,7 +192,31 @@ export function createInvoiceHost(ctx) {
182
192
  }
183
193
  return ensureInvoiceToken(timeoutMs);
184
194
  }
185
- async function invoiceRequest(request) {
195
+ async function clearCachedInvoiceToken() {
196
+ invoiceToken = null;
197
+ await tokenStore?.clear().catch(() => false);
198
+ }
199
+ function businessError(value) {
200
+ const code = responseCode(value);
201
+ return new InvoiceCliError('api', 'business_error', responseMessage(value), {
202
+ code: String(code),
203
+ details: { ret: code },
204
+ exitCode: 1,
205
+ });
206
+ }
207
+ async function handleBusinessResponse(value, retry, canRetry) {
208
+ const code = responseCode(value);
209
+ if (code === undefined || code === 0 || code === 200)
210
+ return value;
211
+ if (isInvoiceSessionExpired(value)) {
212
+ await clearCachedInvoiceToken();
213
+ if (canRetry)
214
+ return retry();
215
+ throw invoiceAuthError('invoice_session_expired', '发票业务登录态已失效,已清理本地发票 Token;请重新运行 weaver-work-cli auth login --base-url <域名> 后再试', { ret: code });
216
+ }
217
+ throw businessError(value);
218
+ }
219
+ async function invoiceRequest(request, canRetryExpiredToken = true) {
186
220
  const { method = 'POST', path, query, body, needsInvoiceToken = true, timeoutMs = 30_000, } = request;
187
221
  assertPath(path);
188
222
  const token = await ensureContext(needsInvoiceToken, timeoutMs, path);
@@ -220,118 +254,110 @@ export function createInvoiceHost(ctx) {
220
254
  exitCode: 1,
221
255
  });
222
256
  }
223
- const code = responseCode(value);
224
- if (code !== undefined && code !== 0 && code !== 200) {
225
- throw new InvoiceCliError('api', 'business_error', responseMessage(value), {
226
- code: String(code),
227
- details: { ret: code },
257
+ return handleBusinessResponse(value, () => invoiceRequest(request, false), canRetryExpiredToken && Boolean(token));
258
+ }
259
+ async function invoiceMultipartRequest(request, canRetryExpiredToken = true) {
260
+ const { method = 'POST', path, query, fields = {}, file, needsInvoiceToken = true, timeoutMs = 30_000, } = request;
261
+ if (!file && !fields.url) {
262
+ throw invoiceValidationError('input_invalid', 'multipart 请求需要文件或 URL');
263
+ }
264
+ assertPath(path);
265
+ const token = await ensureContext(needsInvoiceToken, timeoutMs, path);
266
+ const session = await ensureSession();
267
+ const form = new FormData();
268
+ for (const [key, value] of Object.entries(fields)) {
269
+ if (value === undefined || value === null)
270
+ continue;
271
+ form.append(key, typeof value === 'string' ? value : JSON.stringify(value));
272
+ }
273
+ if (file) {
274
+ const content = await readFile(file.path);
275
+ form.append('file', new Blob([content], { type: file.mimeType || 'application/octet-stream' }), file.name);
276
+ }
277
+ const response = await fetchWithTimeout(withQuery(session.getBaseUrl(), path, query), {
278
+ method,
279
+ headers: requestHeaders(session, token),
280
+ body: form,
281
+ redirect: 'manual',
282
+ }, timeoutMs);
283
+ const text = await response.text();
284
+ if (response.status === 401 || response.status === 302) {
285
+ throw invoiceAuthError('session_expired', 'E10 登录态已失效,请先向用户确认 E10 登录域名,再运行 weaver-work-cli auth login --base-url <域名> 完成登录', { httpStatus: response.status });
286
+ }
287
+ let value = {};
288
+ try {
289
+ value = text ? JSON.parse(text) : {};
290
+ }
291
+ catch {
292
+ throw invoiceNetworkError('protocol', `接口返回不是合法 JSON(HTTP ${response.status})`, { retryable: false });
293
+ }
294
+ if (response.status < 200 || response.status >= 300) {
295
+ throw new InvoiceCliError('api', 'http_error', `泛微接口 HTTP ${response.status}`, {
296
+ code: response.status,
297
+ details: { status: response.status, message: responseMessage(value) },
228
298
  exitCode: 1,
229
299
  });
230
300
  }
231
- return value;
301
+ return handleBusinessResponse(value, () => invoiceMultipartRequest(request, false), canRetryExpiredToken && Boolean(token));
232
302
  }
233
- return {
234
- invoiceRequest,
235
- async invoiceMultipartRequest(request) {
236
- const { method = 'POST', path, query, fields = {}, file, needsInvoiceToken = true, timeoutMs = 30_000, } = request;
237
- if (!file && !fields.url) {
238
- throw invoiceValidationError('input_invalid', 'multipart 请求需要文件或 URL');
239
- }
240
- assertPath(path);
241
- const token = await ensureContext(needsInvoiceToken, timeoutMs, path);
242
- const session = await ensureSession();
243
- const form = new FormData();
244
- for (const [key, value] of Object.entries(fields)) {
245
- if (value === undefined || value === null)
246
- continue;
247
- form.append(key, typeof value === 'string' ? value : JSON.stringify(value));
248
- }
249
- if (file) {
250
- const content = await readFile(file.path);
251
- form.append('file', new Blob([content], { type: file.mimeType || 'application/octet-stream' }), file.name);
252
- }
253
- const response = await fetchWithTimeout(withQuery(session.getBaseUrl(), path, query), {
254
- method,
255
- headers: requestHeaders(session, token),
256
- body: form,
257
- redirect: 'manual',
258
- }, timeoutMs);
259
- const text = await response.text();
260
- if (response.status === 401 || response.status === 302) {
261
- throw invoiceAuthError('session_expired', 'E10 登录态已失效,请先向用户确认 E10 登录域名,再运行 weaver-work-cli auth login --base-url <域名> 完成登录', { httpStatus: response.status });
262
- }
303
+ async function invoiceDownload({ id, fid, needLog = false, timeoutMs = 30_000 }, canRetryExpiredToken = true) {
304
+ const token = await ensureContext(true, timeoutMs, '/api/inc/file/download');
305
+ const session = await ensureSession();
306
+ const url = withQuery(session.getBaseUrl(), '/api/inc/file/download', { id, fid, needLog });
307
+ const response = await fetchWithTimeout(url, {
308
+ method: 'GET',
309
+ headers: {
310
+ ...requestHeaders(session, token),
311
+ Accept: 'application/octet-stream, application/pdf, */*',
312
+ },
313
+ redirect: 'manual',
314
+ }, timeoutMs);
315
+ if (response.status === 401 || response.status === 302) {
316
+ throw invoiceAuthError('session_expired', 'E10 登录态已失效,请先向用户确认 E10 登录域名,再运行 weaver-work-cli auth login --base-url <域名> 完成登录', { httpStatus: response.status });
317
+ }
318
+ const contentType = response.headers.get('content-type') || 'application/octet-stream';
319
+ const declaredLength = Number(response.headers.get('content-length'));
320
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_DOWNLOAD_BYTES) {
321
+ throw invoiceNetworkError('response_too_large', `下载文件超过 ${MAX_DOWNLOAD_BYTES} 字节限制`, { retryable: false });
322
+ }
323
+ const content = Buffer.from(await response.arrayBuffer());
324
+ const looksLikeJson = /^[\s\r\n]*[\[{]/u.test(content.subarray(0, 64).toString('utf8'));
325
+ if (response.status < 200 || response.status >= 300 || contentType.includes('json') || looksLikeJson) {
263
326
  let value = {};
264
327
  try {
265
- value = text ? JSON.parse(text) : {};
328
+ value = JSON.parse(content.toString('utf8'));
266
329
  }
267
330
  catch {
268
- throw invoiceNetworkError('protocol', `接口返回不是合法 JSON(HTTP ${response.status})`, { retryable: false });
269
- }
270
- if (response.status < 200 || response.status >= 300) {
271
- throw new InvoiceCliError('api', 'http_error', `泛微接口 HTTP ${response.status}`, {
272
- code: response.status,
273
- details: { status: response.status, message: responseMessage(value) },
274
- exitCode: 1,
275
- });
276
- }
277
- const code = responseCode(value);
278
- if (code !== undefined && code !== 0 && code !== 200) {
279
- throw new InvoiceCliError('api', 'business_error', responseMessage(value), {
280
- code: String(code),
281
- details: { ret: code },
282
- exitCode: 1,
283
- });
284
- }
285
- return value;
286
- },
287
- async invoiceDownload({ id, fid, needLog = false, timeoutMs = 30_000 }) {
288
- const token = await ensureContext(true, timeoutMs, '/api/inc/file/download');
289
- const session = await ensureSession();
290
- const url = withQuery(session.getBaseUrl(), '/api/inc/file/download', { id, fid, needLog });
291
- const response = await fetchWithTimeout(url, {
292
- method: 'GET',
293
- headers: {
294
- ...requestHeaders(session, token),
295
- Accept: 'application/octet-stream, application/pdf, */*',
296
- },
297
- redirect: 'manual',
298
- }, timeoutMs);
299
- if (response.status === 401 || response.status === 302) {
300
- throw invoiceAuthError('session_expired', 'E10 登录态已失效,请先向用户确认 E10 登录域名,再运行 weaver-work-cli auth login --base-url <域名> 完成登录', { httpStatus: response.status });
331
+ value = {};
301
332
  }
302
- const contentType = response.headers.get('content-type') || 'application/octet-stream';
303
- const declaredLength = Number(response.headers.get('content-length'));
304
- if (Number.isFinite(declaredLength) && declaredLength > MAX_DOWNLOAD_BYTES) {
305
- throw invoiceNetworkError('response_too_large', `下载文件超过 ${MAX_DOWNLOAD_BYTES} 字节限制`, { retryable: false });
333
+ if (isInvoiceSessionExpired(value)) {
334
+ await clearCachedInvoiceToken();
335
+ if (canRetryExpiredToken && token)
336
+ return invoiceDownload({ id, fid, needLog, timeoutMs }, false);
337
+ throw invoiceAuthError('invoice_session_expired', '发票业务登录态已失效,已清理本地发票 Token;请重新运行 weaver-work-cli auth login --base-url <域名> 后再试', { ret: responseCode(value), status: response.status });
306
338
  }
307
- const content = Buffer.from(await response.arrayBuffer());
308
- const looksLikeJson = /^[\s\r\n]*[\[{]/u.test(content.subarray(0, 64).toString('utf8'));
309
- if (response.status < 200 || response.status >= 300 || contentType.includes('json') || looksLikeJson) {
310
- let value = {};
311
- try {
312
- value = JSON.parse(content.toString('utf8'));
313
- }
314
- catch {
315
- value = {};
316
- }
317
- throw new InvoiceCliError('api', 'download_failed', responseMessage(value) || `下载失败(HTTP ${response.status})`, {
318
- code: response.status,
319
- details: { status: response.status, ret: responseCode(value) },
320
- exitCode: 1,
321
- });
322
- }
323
- if (!content.length) {
324
- throw invoiceNetworkError('protocol', '下载接口返回空文件', { retryable: false });
325
- }
326
- if (content.length > MAX_DOWNLOAD_BYTES) {
327
- throw invoiceNetworkError('response_too_large', `下载文件超过 ${MAX_DOWNLOAD_BYTES} 字节限制`, { retryable: false });
328
- }
329
- return { content, contentType };
330
- },
339
+ throw new InvoiceCliError('api', 'download_failed', responseMessage(value) || `下载失败(HTTP ${response.status})`, {
340
+ code: response.status,
341
+ details: { status: response.status, ret: responseCode(value) },
342
+ exitCode: 1,
343
+ });
344
+ }
345
+ if (!content.length) {
346
+ throw invoiceNetworkError('protocol', '下载接口返回空文件', { retryable: false });
347
+ }
348
+ if (content.length > MAX_DOWNLOAD_BYTES) {
349
+ throw invoiceNetworkError('response_too_large', `下载文件超过 ${MAX_DOWNLOAD_BYTES} 字节限制`, { retryable: false });
350
+ }
351
+ return { content, contentType };
352
+ }
353
+ return {
354
+ invoiceRequest,
355
+ invoiceMultipartRequest,
356
+ invoiceDownload,
331
357
  getSession,
332
358
  invalidateToken() {
333
359
  invoiceToken = null;
334
- void tokenStore?.clear();
360
+ void clearCachedInvoiceToken();
335
361
  },
336
362
  };
337
363
  }
package/docs/e10-auth.md CHANGED
@@ -8,6 +8,7 @@ E10 适配模块刻意与核心 CLI 框架隔离,避免平台逻辑污染通
8
8
  - Profile 目录:`~/.weaver-work-cli/e10/profiles/<profile>`
9
9
  - 当前激活 profile:`~/.weaver-work-cli/e10/profile`
10
10
  - Keychain service/account:`weaver-work-cli` / `auth-key`
11
+ - 发票业务 Token Keychain service:`cn.weaver.weaver-work-cli.invoice-token`
11
12
 
12
13
  `weaver-work-cli` 默认不读取、不迁移 `e10-login` / `e10-cli` 的 `~/.e10-cli`
13
14
  登录态,避免用户安装本插件后因为历史登录状态被自动授权。确需复用外部 auth
@@ -42,9 +43,12 @@ E10 兼容的 auth 文件。
42
43
  `WEAVER_BASE_URL`、`WEAVER_ETEAMSID`、`WEAVER_COOKIE`、`WEAVER_USER_AGENT` 和
43
44
  `WEAVER_AGENT_TYPE`,可通过下面方式注入到当前 shell:
44
45
 
45
- `auth logout` 会清理当前 profile 的本地登录凭据;也可配合
46
- `--profile <name>` 清理指定 profile。该命令不删除 profile 配置和 keychain
47
- 密钥。
46
+ `auth login`、`auth oidc` `auth set` 保存新登录态前,会清理同一
47
+ profile/baseUrl/userId 下缓存的发票业务 Token,避免重登后继续复用旧 Token。
48
+
49
+ `auth logout` 会清理当前 profile 的本地登录凭据和对应的发票业务 Token;也可配合
50
+ `--profile <name>` 清理指定 profile。该命令不删除 profile 配置和登录加密用
51
+ keychain 密钥。
48
52
 
49
53
  ```bash
50
54
  eval "$(weaver-work-cli auth export-weaver-env)"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weaver-work-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "面向泛微工作工具链的可扩展 TypeScript CLI 基础框架(E10 业票通等能力)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",