vps-work 2.4.1 → 2.5.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.
Files changed (125) hide show
  1. package/.well-known/skills/index.json +11 -0
  2. package/README.md +10 -0
  3. package/dist/packages/vps-work/src/bin/vps-work.js +271 -0
  4. package/dist/packages/vps-work/src/commands/agent/index.js +264 -0
  5. package/dist/packages/vps-work/src/commands/approval/index.js +7 -0
  6. package/dist/packages/vps-work/src/commands/approval/shortcuts.js +259 -0
  7. package/dist/packages/vps-work/src/commands/attendance/applications.js +21 -0
  8. package/dist/packages/vps-work/src/commands/attendance/index.js +9 -0
  9. package/dist/packages/vps-work/src/commands/attendance/scheduling.js +183 -0
  10. package/dist/packages/vps-work/src/commands/attendance/shortcuts.js +131 -0
  11. package/dist/packages/vps-work/src/commands/auth/check.js +34 -0
  12. package/dist/packages/vps-work/src/commands/auth/index.js +21 -0
  13. package/dist/packages/vps-work/src/commands/auth/list.js +50 -0
  14. package/dist/packages/vps-work/src/commands/auth/scopes.js +17 -0
  15. package/dist/packages/vps-work/src/commands/auth/status.js +127 -0
  16. package/dist/packages/vps-work/src/commands/auth/tenants.js +35 -0
  17. package/dist/packages/vps-work/src/commands/auth/use.js +40 -0
  18. package/dist/packages/vps-work/src/commands/calendar/index.js +7 -0
  19. package/dist/packages/vps-work/src/commands/calendar/shortcuts.js +598 -0
  20. package/dist/packages/vps-work/src/commands/catalog.js +29 -0
  21. package/dist/packages/vps-work/src/commands/cli-install.js +70 -0
  22. package/dist/packages/vps-work/src/commands/collab-invoke.js +19 -0
  23. package/dist/packages/vps-work/src/commands/corporate/index.js +204 -0
  24. package/dist/packages/vps-work/src/commands/creative/index.js +369 -0
  25. package/dist/packages/vps-work/src/commands/documents/index.js +991 -0
  26. package/dist/packages/vps-work/src/commands/drive/index.js +435 -0
  27. package/dist/packages/vps-work/src/commands/exec.js +46 -0
  28. package/dist/packages/vps-work/src/commands/hr/business.js +22 -0
  29. package/dist/packages/vps-work/src/commands/hr/data-view-shortcuts.js +519 -0
  30. package/dist/packages/vps-work/src/commands/hr/index.js +15 -0
  31. package/dist/packages/vps-work/src/commands/hr/job-responsibilities.js +266 -0
  32. package/dist/packages/vps-work/src/commands/hr/payslip.js +45 -0
  33. package/dist/packages/vps-work/src/commands/hr/shortcuts.js +146 -0
  34. package/dist/packages/vps-work/src/commands/im/im-message-payload.js +150 -0
  35. package/dist/packages/vps-work/src/commands/im/index.js +7 -0
  36. package/dist/packages/vps-work/src/commands/im/shortcuts.js +856 -0
  37. package/dist/packages/vps-work/src/commands/knowledge/index.js +36 -0
  38. package/dist/packages/vps-work/src/commands/login.js +52 -0
  39. package/dist/packages/vps-work/src/commands/logout.js +12 -0
  40. package/dist/packages/vps-work/src/commands/mail/email.js +132 -0
  41. package/dist/packages/vps-work/src/commands/mail/index.js +9 -0
  42. package/dist/packages/vps-work/src/commands/mail/shortcuts.js +43 -0
  43. package/dist/packages/vps-work/src/commands/meeting/index.js +7 -0
  44. package/dist/packages/vps-work/src/commands/meeting/shortcuts.js +120 -0
  45. package/dist/packages/vps-work/src/commands/odoo-invoke.js +21 -0
  46. package/dist/packages/vps-work/src/commands/odoo-leaf-registry.js +13 -0
  47. package/dist/packages/vps-work/src/commands/office/index.js +59 -0
  48. package/dist/packages/vps-work/src/commands/okr/index.js +7 -0
  49. package/dist/packages/vps-work/src/commands/okr/shortcuts.js +75 -0
  50. package/dist/packages/vps-work/src/commands/org-structure.js +347 -0
  51. package/dist/packages/vps-work/src/commands/popup/index.js +147 -0
  52. package/dist/packages/vps-work/src/commands/product/abnormal-statistics.js +250 -0
  53. package/dist/packages/vps-work/src/commands/product/data-view-shortcuts.js +253 -0
  54. package/dist/packages/vps-work/src/commands/product/index.js +11 -0
  55. package/dist/packages/vps-work/src/commands/purchase/index.js +45 -0
  56. package/dist/packages/vps-work/src/commands/reauth.js +127 -0
  57. package/dist/packages/vps-work/src/commands/report/index.js +7 -0
  58. package/dist/packages/vps-work/src/commands/report/missing-daily-reports.js +9 -0
  59. package/dist/packages/vps-work/src/commands/report/shortcuts.js +251 -0
  60. package/dist/packages/vps-work/src/commands/sales/index.js +11 -0
  61. package/dist/packages/vps-work/src/commands/sales/period-params.js +75 -0
  62. package/dist/packages/vps-work/src/commands/sales/shortcuts-personnel.js +124 -0
  63. package/dist/packages/vps-work/src/commands/sales/shortcuts.js +800 -0
  64. package/dist/packages/vps-work/src/commands/shortcut-common.js +88 -0
  65. package/dist/packages/vps-work/src/commands/shortcut-params.js +22 -0
  66. package/dist/packages/vps-work/src/commands/skill/index.js +136 -0
  67. package/dist/packages/vps-work/src/commands/skill/personal.js +167 -0
  68. package/dist/packages/vps-work/src/commands/store/index.js +82 -0
  69. package/dist/packages/vps-work/src/commands/task/index.js +7 -0
  70. package/dist/packages/vps-work/src/commands/task/task-center.js +595 -0
  71. package/dist/packages/vps-work/src/commands/tools/index.js +127 -0
  72. package/dist/packages/vps-work/src/commands/vemory/index.js +7 -0
  73. package/dist/packages/vps-work/src/commands/vemory/shortcuts.js +53 -0
  74. package/dist/packages/vps-work/src/commands/vertu-aisales.js +30 -0
  75. package/dist/packages/vps-work/src/commands/vertu-erp.js +30 -0
  76. package/dist/packages/vps-work/src/core/aisales-mcp-client.js +35 -0
  77. package/dist/packages/vps-work/src/core/auth.js +240 -0
  78. package/dist/packages/vps-work/src/core/cli-bin.js +11 -0
  79. package/dist/packages/vps-work/src/core/cli-root-help.js +190 -0
  80. package/dist/packages/vps-work/src/core/client.js +158 -0
  81. package/dist/packages/vps-work/src/core/config.js +183 -0
  82. package/dist/packages/vps-work/src/core/corporate-client.js +52 -0
  83. package/dist/packages/vps-work/src/core/creative-attachment-download.js +48 -0
  84. package/dist/packages/vps-work/src/core/documents-client.js +57 -0
  85. package/dist/packages/vps-work/src/core/endpoints.js +46 -0
  86. package/dist/packages/vps-work/src/core/erp-mcp-client.js +11 -0
  87. package/dist/packages/vps-work/src/core/errors.js +107 -0
  88. package/dist/packages/vps-work/src/core/execute-catalog-client.js +42 -0
  89. package/dist/packages/vps-work/src/core/hermes-client.js +70 -0
  90. package/dist/packages/vps-work/src/core/im-attachment-download.js +109 -0
  91. package/dist/packages/vps-work/src/core/im-client.js +64 -0
  92. package/dist/packages/vps-work/src/core/keychain.js +58 -0
  93. package/dist/packages/vps-work/src/core/mail-client.js +82 -0
  94. package/dist/packages/vps-work/src/core/mcp-client.js +129 -0
  95. package/dist/packages/vps-work/src/core/office-client.js +25 -0
  96. package/dist/packages/vps-work/src/core/office-files.js +82 -0
  97. package/dist/packages/vps-work/src/core/paths.js +70 -0
  98. package/dist/packages/vps-work/src/core/public-result.js +43 -0
  99. package/dist/packages/vps-work/src/core/sales-invoice-attachments-client.js +75 -0
  100. package/dist/packages/vps-work/src/core/sales-query-client.js +452 -0
  101. package/dist/packages/vps-work/src/core/skill-catalog.js +158 -0
  102. package/dist/packages/vps-work/src/core/skill-key.js +19 -0
  103. package/dist/packages/vps-work/src/core/v-drive-store.js +392 -0
  104. package/dist/packages/vps-work/src/core/vertu-agent-env.js +32 -0
  105. package/dist/packages/vps-work/src/core/vps-auth-scopes.js +73 -0
  106. package/dist/packages/vps-work/src/core/vps-service-auth.js +382 -0
  107. package/dist/packages/vps-work/src/core/vps-service-config.js +83 -0
  108. package/dist/packages/vps-work/src/core/vps-service-http.js +187 -0
  109. package/dist/packages/vps-work/src/utils/at-file.js +34 -0
  110. package/dist/packages/vps-work/src/utils/cli-option-helpers.js +52 -0
  111. package/dist/packages/vps-work/src/utils/frontmatter.js +26 -0
  112. package/dist/packages/vps-work/src/utils/normalize-argv.js +51 -0
  113. package/dist/packages/vps-work/src/utils/parse-json-arg.js +85 -0
  114. package/dist/packages/vps-work/src/utils/prompt.js +46 -0
  115. package/dist/packages/vps-work/src/utils/read-text.js +44 -0
  116. package/dist/packages/vps-work/src/utils/schema-to-options.js +307 -0
  117. package/dist/packages/vps-work/src/utils/unwrap-envelope.js +31 -0
  118. package/dist/shared/im-org-tree/index.js +200 -0
  119. package/dist/vps-work.cjs +246 -44
  120. package/package.json +1 -1
  121. package/scripts/package-desktop-runtime.mjs +43 -0
  122. package/skills/README.md +1 -0
  123. package/skills/vertu-aisales/SKILL.md +18 -0
  124. package/skills/vertu-erp/SKILL.md +17 -0
  125. package/skills/vps-dealer-ops/SKILL.md +92 -0
@@ -0,0 +1,129 @@
1
+ import { VertuError } from './errors.js';
2
+ const PROTOCOL = '2025-03-26';
3
+ const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
4
+ const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
5
+ function rpcResult(value, id) {
6
+ const rpc = record(value);
7
+ if (rpc.id !== id)
8
+ return undefined;
9
+ if (rpc.error)
10
+ throw new VertuError('MCP 请求失败,请检查工具参数和服务端授权。');
11
+ if (!('result' in rpc))
12
+ throw new VertuError('MCP 返回了无效响应。');
13
+ return record(rpc.result);
14
+ }
15
+ async function readResult(response, id) {
16
+ const reader = response.body?.getReader();
17
+ if (!reader)
18
+ throw new VertuError('MCP 返回了空响应。');
19
+ const sse = response.headers.get('content-type')?.includes('text/event-stream');
20
+ const decoder = new TextDecoder();
21
+ let buffer = '';
22
+ let size = 0;
23
+ try {
24
+ while (true) {
25
+ const chunk = await reader.read();
26
+ if (chunk.done) {
27
+ buffer += decoder.decode();
28
+ break;
29
+ }
30
+ size += chunk.value.byteLength;
31
+ if (size > MAX_RESPONSE_BYTES)
32
+ throw new VertuError('MCP 结果过大,请缩小查询范围。');
33
+ buffer += decoder.decode(chunk.value, { stream: true });
34
+ if (sse) {
35
+ let boundary;
36
+ while ((boundary = /\r?\n\r?\n/.exec(buffer))) {
37
+ const frame = buffer.slice(0, boundary.index);
38
+ buffer = buffer.slice(boundary.index + boundary[0].length);
39
+ const data = frame.split(/\r?\n/).filter(line => line.startsWith('data:'))
40
+ .map(line => line.slice(5).replace(/^ /, '')).join('\n');
41
+ if (!data)
42
+ continue;
43
+ const result = rpcResult(JSON.parse(data), id);
44
+ if (result)
45
+ return result;
46
+ }
47
+ }
48
+ }
49
+ if (!sse) {
50
+ const result = rpcResult(JSON.parse(buffer), id);
51
+ if (result)
52
+ return result;
53
+ }
54
+ throw new VertuError('MCP 未返回匹配的请求结果。');
55
+ }
56
+ finally {
57
+ await reader.cancel().catch(() => { });
58
+ }
59
+ }
60
+ export async function runMcp(endpoint, authorizationHeaders, allowedTools, operation) {
61
+ let sessionId;
62
+ let protocol = PROTOCOL;
63
+ let nextId = 0;
64
+ const headers = () => ({
65
+ ...authorizationHeaders, 'content-type': 'application/json', accept: 'application/json, text/event-stream',
66
+ 'mcp-protocol-version': protocol, ...(sessionId ? { 'mcp-session-id': sessionId } : {}),
67
+ });
68
+ const send = async (method, params, notification = false) => {
69
+ const id = ++nextId;
70
+ const response = await fetch(endpoint, {
71
+ method: 'POST', headers: headers(), redirect: 'error', signal: AbortSignal.timeout(60_000),
72
+ body: JSON.stringify({ jsonrpc: '2.0', ...(notification ? {} : { id }), method, params }),
73
+ });
74
+ if (!response.ok) {
75
+ await response.body?.cancel();
76
+ throw new VertuError(`MCP 请求被拒绝或服务不可用(HTTP ${response.status})。`, { statusCode: response.status });
77
+ }
78
+ if (method === 'initialize')
79
+ sessionId = response.headers.get('mcp-session-id') || undefined;
80
+ if (notification) {
81
+ await response.body?.cancel();
82
+ return {};
83
+ }
84
+ return readResult(response, id);
85
+ };
86
+ try {
87
+ const initialized = await send('initialize', {
88
+ protocolVersion: PROTOCOL, capabilities: {}, clientInfo: { name: 'vps-work', version: '1' },
89
+ });
90
+ if (initialized.protocolVersion !== PROTOCOL)
91
+ throw new VertuError('MCP 协议版本不兼容。');
92
+ protocol = initialized.protocolVersion;
93
+ await send('notifications/initialized', undefined, true);
94
+ const tools = [];
95
+ const cursors = new Set();
96
+ let cursor;
97
+ do {
98
+ const page = await send('tools/list', cursor ? { cursor } : {});
99
+ if (!Array.isArray(page.tools))
100
+ throw new VertuError('MCP 工具目录无效。');
101
+ tools.push(...page.tools.map(record).filter(tool => allowedTools.has(String(tool.name))));
102
+ cursor = typeof page.nextCursor === 'string' && page.nextCursor ? page.nextCursor : undefined;
103
+ if (cursor && (cursors.has(cursor) || cursors.size >= 100))
104
+ throw new VertuError('MCP 工具目录分页异常。');
105
+ if (cursor)
106
+ cursors.add(cursor);
107
+ } while (cursor);
108
+ if (!operation)
109
+ return { tools };
110
+ if (!tools.some(tool => tool.name === operation.tool))
111
+ throw new VertuError('当前 MCP 未开放该工具。');
112
+ const result = await send('tools/call', { name: operation.tool, arguments: operation.arguments });
113
+ if (result.isError === true)
114
+ throw new VertuError('MCP 工具执行失败,请检查参数、SQL 和服务端授权。');
115
+ return result;
116
+ }
117
+ catch (error) {
118
+ if (error instanceof VertuError)
119
+ throw error;
120
+ // Transport/parser exceptions must not expose credentials or request bodies.
121
+ throw new VertuError('MCP 通信失败,请检查网络和服务状态。');
122
+ }
123
+ finally {
124
+ if (sessionId) {
125
+ await fetch(endpoint, { method: 'DELETE', headers: headers(), redirect: 'error', signal: AbortSignal.timeout(5_000) })
126
+ .then(response => response.body?.cancel()).catch(() => { });
127
+ }
128
+ }
129
+ }
@@ -0,0 +1,25 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { homeDir } from './paths.js';
4
+ /** Credentials stay in a mode-0600 file; never accept remote URLs for local file authority. */
5
+ export async function officeRequest(action, body = {}, connectionFile) {
6
+ const path = connectionFile ?? process.env.VPS_OFFICE_CONNECTION_FILE ?? join(homeDir(), 'office.json');
7
+ let config;
8
+ try {
9
+ config = JSON.parse(await readFile(path, 'utf8'));
10
+ }
11
+ catch {
12
+ throw new Error('未发现本机 VPS Office。请启动新版 VPS 桌面端,或设置 VPS_OFFICE_CONNECTION_FILE。');
13
+ }
14
+ const url = new URL(config.url);
15
+ if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || url.username || url.password || !config.token || config.protocolVersion !== 1)
16
+ throw new Error('Office 连接配置无效。');
17
+ const response = await fetch(new URL(`/office/${action}`, url), {
18
+ method: 'POST', redirect: 'error', signal: AbortSignal.timeout(120000),
19
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.token}` }, body: JSON.stringify(body),
20
+ });
21
+ const result = await response.json();
22
+ if (!response.ok)
23
+ throw new Error(result.error ?? `Office HTTP ${response.status}`);
24
+ return result;
25
+ }
@@ -0,0 +1,82 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { open, readFile, realpath, rename, rm, stat, link } from 'node:fs/promises';
3
+ import { createRequire } from 'node:module';
4
+ import { dirname, extname, join, resolve } from 'node:path';
5
+ import { packageRootDir } from './paths.js';
6
+ export function officeRuntime() {
7
+ const path = join(packageRootDir(), 'dist/office-runtime.cjs');
8
+ try {
9
+ return createRequire(path)(path);
10
+ }
11
+ catch (error) {
12
+ throw new Error(`Office runtime 无法加载,请重新安装完整 vps-work 包(源码开发先 npm run build)。${String(error)}`);
13
+ }
14
+ }
15
+ export function officeFormat(path) {
16
+ const format = extname(path).slice(1).toLowerCase();
17
+ if (!['docx', 'xlsx', 'pptx', 'md'].includes(format))
18
+ throw new Error('支持 .docx / .xlsx / .pptx / .md。');
19
+ return format;
20
+ }
21
+ export function officeVersion(bytes) {
22
+ return createHash('sha256').update(bytes).digest('hex');
23
+ }
24
+ export async function readOfficeFile(path) {
25
+ const file = await realpath(path);
26
+ const bytes = await readFile(file);
27
+ const format = officeFormat(file);
28
+ return { file, format, version: officeVersion(bytes), content: await officeRuntime().readDocument(format, bytes) };
29
+ }
30
+ /** Lock cooperating writers, compare immediately before rename, never truncate the source. */
31
+ async function writeAtomic(path, bytes, expected) {
32
+ const file = expected === undefined ? resolve(path) : await realpath(path);
33
+ const lock = `${file}.vps-office.lock`;
34
+ const handle = await open(lock, 'wx', 0o600).catch(() => { throw new Error(`文件正在修改或存在未清理锁:${lock}`); });
35
+ const temp = join(dirname(file), `.${randomUUID()}.office.tmp`);
36
+ try {
37
+ const mode = expected === undefined ? 0o600 : (await stat(file)).mode;
38
+ const output = await open(temp, 'wx', mode);
39
+ try {
40
+ await output.writeFile(bytes);
41
+ await output.sync();
42
+ }
43
+ finally {
44
+ await output.close();
45
+ }
46
+ if (expected !== undefined) {
47
+ if (officeVersion(await readFile(file)) !== expected)
48
+ throw new Error('文件版本冲突,请重新读取后修改。');
49
+ await rename(temp, file);
50
+ }
51
+ else {
52
+ // Hard link gives atomic create-if-absent, including races with other processes.
53
+ await link(temp, file);
54
+ }
55
+ }
56
+ finally {
57
+ await rm(temp, { force: true });
58
+ await handle.close();
59
+ await rm(lock, { force: true });
60
+ }
61
+ }
62
+ export async function createOfficeFile(path, spec) {
63
+ const format = officeFormat(path);
64
+ const bytes = await officeRuntime().createDocument(format, spec);
65
+ await officeRuntime().readDocument(format, bytes);
66
+ await writeAtomic(path, bytes);
67
+ return { file: resolve(path), format, version: officeVersion(bytes), size: bytes.length };
68
+ }
69
+ export async function applyOfficeFile(path, changes, expected, output, dryRun = false) {
70
+ const file = await realpath(path);
71
+ const format = officeFormat(file);
72
+ if (output && officeFormat(output) !== format)
73
+ throw new Error('输出文件扩展名必须与源文件相同。');
74
+ const original = await readFile(file);
75
+ if (officeVersion(original) !== expected)
76
+ throw new Error('文件版本冲突,请重新读取后修改。');
77
+ const bytes = await officeRuntime().applyDocument(format, original, changes);
78
+ const content = await officeRuntime().readDocument(format, bytes);
79
+ if (!dryRun)
80
+ await writeAtomic(output ?? file, bytes, output && resolve(output) !== file ? undefined : expected);
81
+ return { file: resolve(output ?? file), format, version: officeVersion(bytes), previousVersion: expected, dryRun, content };
82
+ }
@@ -0,0 +1,70 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { realpathSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
5
+ /** 本包独立会话目录,不共用 vertu-cli 的 `~/.vertu`。 */
6
+ const HOME_DIR_NAME = '.vps-work';
7
+ /** 与 larksuite/cli 一致:包根目录下的 skill 源目录名。 */
8
+ export const PACKAGE_SKILLS_DIR_NAME = 'skills';
9
+ /**
10
+ * 从已解析的入口脚本路径推导 npm 包根(`dist/vps-work.cjs` 或 `src/bin/vps-work.ts` 的上一级)。
11
+ */
12
+ function packageRootFromEntryScript(resolvedEntry) {
13
+ const entryBase = basename(resolvedEntry);
14
+ const entryDir = dirname(resolvedEntry);
15
+ const entryParent = basename(entryDir);
16
+ if (entryBase === 'vps-work.cjs' && entryParent === 'dist') {
17
+ return join(entryDir, '..');
18
+ }
19
+ if (entryBase === 'vps-work.ts' && entryParent === 'bin') {
20
+ return join(entryDir, '..', '..');
21
+ }
22
+ return undefined;
23
+ }
24
+ /**
25
+ * 当前运行的 `vps-work` npm 包根目录(`dist/vps-work.cjs` 的上一级)。
26
+ * 全局安装时 `argv[1]` 常为 `.../bin/vps-work` 符号链接,须 `realpath` 后再推导。
27
+ */
28
+ export function packageRootDir() {
29
+ const bin = process.argv[1];
30
+ if (bin) {
31
+ try {
32
+ const resolved = realpathSync(resolve(bin));
33
+ const fromEntry = packageRootFromEntryScript(resolved);
34
+ if (fromEntry) {
35
+ return fromEntry;
36
+ }
37
+ }
38
+ catch {
39
+ // 入口路径不可读时走下方回退
40
+ }
41
+ }
42
+ if (typeof __dirname !== 'undefined' && basename(__dirname) === 'dist') {
43
+ return join(__dirname, '..');
44
+ }
45
+ return process.cwd();
46
+ }
47
+ /** 包内 `skills/` 绝对路径(随 npm 包分发,供 Agent 只读引用)。 */
48
+ export function packageSkillsDir() {
49
+ return join(packageRootDir(), PACKAGE_SKILLS_DIR_NAME);
50
+ }
51
+ export function homeDir() {
52
+ const configured = process.env.VPS_WORK_HOME?.trim();
53
+ if (configured) {
54
+ if (!isAbsolute(configured))
55
+ throw new Error('VPS_WORK_HOME must be an absolute directory');
56
+ return configured;
57
+ }
58
+ return join(homedir(), HOME_DIR_NAME);
59
+ }
60
+ export function configFilePath() {
61
+ return join(homeDir(), 'config.json');
62
+ }
63
+ /** vps-work → 独立 vps-service 连接配置(与 Odoo login 的 config.json 分离)。 */
64
+ export function vpsServiceConfigFilePath() {
65
+ return join(homeDir(), 'vps-service.json');
66
+ }
67
+ export function hashBaseUrl(baseUrl) {
68
+ const normalized = baseUrl.replace(/\/+$/, '').toLowerCase();
69
+ return createHash('sha1').update(normalized).digest('hex').slice(0, 12);
70
+ }
@@ -0,0 +1,43 @@
1
+ /** Keep infrastructure provenance out of CLI-visible business responses. */
2
+ export function publicBusinessResult(value) {
3
+ if (Array.isArray(value))
4
+ return value.map(publicBusinessResult);
5
+ if (!value || typeof value !== 'object')
6
+ return value;
7
+ const out = {};
8
+ for (const [key, item] of Object.entries(value)) {
9
+ if (key === 'odoo_user_id') {
10
+ out.user_id ??= publicBusinessResult(item);
11
+ continue;
12
+ }
13
+ if ((key === 'database' || key === 'db') && typeof item === 'string' && /^odoo$/i.test(item))
14
+ continue;
15
+ if (key === 'source' && typeof item === 'string' && /^odoo(?:\b|[_-])/i.test(item)) {
16
+ out.source = 'vps';
17
+ continue;
18
+ }
19
+ if (key === 'source' && item && typeof item === 'object' && !Array.isArray(item)) {
20
+ const { db: _db, database: _database, login: _login, base_url: _baseUrl, ...source } = item;
21
+ out.source = publicBusinessResult(source);
22
+ continue;
23
+ }
24
+ out[key] = publicBusinessResult(item);
25
+ }
26
+ return out;
27
+ }
28
+ export function publicBusinessErrorMessage(value) {
29
+ const message = String(value ?? '');
30
+ if (!/odoo/i.test(message))
31
+ return message;
32
+ const status = /HTTP\s+(\d{3})\b/i.exec(message)?.[0];
33
+ const detail = status ? `(${status})` : '';
34
+ if (/AccessDenied|forbidden|权限|HTTP\s+403\b/i.test(message))
35
+ return `无权访问该业务数据${detail}`;
36
+ if (/session|not authenticated|unauthorized|登录|会话|HTTP\s+401\b/i.test(message))
37
+ return `VPS 登录凭证已失效,请通过宿主刷新${detail}`;
38
+ if (/RATE_LIMITED|rate.?limit|限流/i.test(message)) {
39
+ const fields = message.match(/(?:limit_type|retry_after_seconds)=[\w]+/g) ?? [];
40
+ return `业务查询触发限流${detail}${fields.length ? `:${fields.join(' ')}` : ''}`;
41
+ }
42
+ return `业务数据服务暂不可用${detail}`;
43
+ }
@@ -0,0 +1,75 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { join, resolve } from 'node:path';
3
+ import { request } from 'undici';
4
+ import { VertuError } from './errors.js';
5
+ import { resolveVpsServiceIdentity } from './sales-query-client.js';
6
+ import { vpsServiceAuthHeaders } from './vps-service-http.js';
7
+ async function errorMessage(status, text) {
8
+ try {
9
+ const payload = JSON.parse(text);
10
+ return new VertuError(payload.error ? String(payload.error) : `HTTP ${status}`);
11
+ }
12
+ catch {
13
+ return new VertuError(`Invoice attachment request failed: HTTP ${status}`);
14
+ }
15
+ }
16
+ function safeFileName(value) {
17
+ return value.replace(/[\r\n"\\/:*?<>|]/g, '_').slice(0, 180) || 'invoice-attachment';
18
+ }
19
+ function serviceHeaders(extra) {
20
+ const headers = { ...vpsServiceAuthHeaders(), ...extra };
21
+ const identity = resolveVpsServiceIdentity();
22
+ if (identity.login && !headers['x-vertu-user-login']) {
23
+ headers['x-vps-user-login'] = encodeURIComponent(identity.login);
24
+ }
25
+ return headers;
26
+ }
27
+ export async function listSalesInvoiceAttachments(baseUrl, orderNo) {
28
+ const query = new URLSearchParams({ order_no: orderNo });
29
+ const response = await request(`${baseUrl}/v1/sales/invoice-attachments?${query}`, {
30
+ method: 'GET',
31
+ headers: serviceHeaders({ accept: 'application/json' }),
32
+ });
33
+ const text = await response.body.text();
34
+ if (response.statusCode >= 400)
35
+ throw await errorMessage(response.statusCode, text);
36
+ try {
37
+ const payload = JSON.parse(text);
38
+ if (!payload.result)
39
+ throw new Error('missing result');
40
+ return payload.result;
41
+ }
42
+ catch {
43
+ throw new VertuError('Invoice attachment list returned invalid JSON.');
44
+ }
45
+ }
46
+ export async function downloadSalesInvoiceAttachments(baseUrl, items, outputDir) {
47
+ const directory = resolve(outputDir);
48
+ await mkdir(directory, { recursive: true });
49
+ const outputs = [];
50
+ for (const item of items) {
51
+ const fallback = `${item.order_no}_${item.move_id}_${item.index + 1}.${item.file_type || 'bin'}`;
52
+ const prefix = `${safeFileName(item.order_no)}_${item.move_id}_${item.index + 1}_`;
53
+ const output = join(directory, `${prefix}${safeFileName(item.name || fallback)}`);
54
+ const response = await request(`${baseUrl}/v1/sales/invoice-attachments/download`, {
55
+ method: 'POST',
56
+ headers: serviceHeaders({ 'content-type': 'application/json' }),
57
+ body: JSON.stringify({ order_no: item.order_no, move_id: item.move_id, index: item.index }),
58
+ });
59
+ const bytes = Buffer.from(await response.body.arrayBuffer());
60
+ if (response.statusCode >= 400)
61
+ throw await errorMessage(response.statusCode, bytes.toString('utf8'));
62
+ await writeFile(output, bytes, { flag: 'wx' }).catch((error) => {
63
+ if (error.code === 'EEXIST')
64
+ throw new VertuError(`Output already exists: ${output}`);
65
+ throw error;
66
+ });
67
+ outputs.push({
68
+ output,
69
+ bytes: bytes.length,
70
+ contentType: typeof response.headers['content-type'] === 'string'
71
+ ? response.headers['content-type'] : null,
72
+ });
73
+ }
74
+ return outputs;
75
+ }