ultrastiching-cli 0.0.0-stage → 0.2.0
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.
- package/LICENSE +186 -0
- package/README.md +75 -2
- package/bin/meridian.js +10 -0
- package/package.json +27 -4
- package/registry/endpoints.json +1 -0
- package/registry/marketdb-datasets.json +124 -0
- package/src/args.js +31 -0
- package/src/auth.js +62 -0
- package/src/config.js +33 -0
- package/src/credentials.js +117 -0
- package/src/db-cli.js +50 -0
- package/src/environment.js +127 -0
- package/src/http.js +129 -0
- package/src/main.js +201 -0
- package/src/marketdb.js +482 -0
- package/src/prompt.js +42 -0
- package/src/registry.js +91 -0
- package/src/websocket.js +72 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
{
|
|
2
|
+
"datasets": {
|
|
3
|
+
"a_share_daily": {
|
|
4
|
+
"endpoint": "plasma_daily",
|
|
5
|
+
"keys": [
|
|
6
|
+
"code",
|
|
7
|
+
"trade_date"
|
|
8
|
+
],
|
|
9
|
+
"kwargs": {}
|
|
10
|
+
},
|
|
11
|
+
"adj_factor": {
|
|
12
|
+
"endpoint": "plasma_adj_factor",
|
|
13
|
+
"keys": [
|
|
14
|
+
"code",
|
|
15
|
+
"trade_date"
|
|
16
|
+
],
|
|
17
|
+
"kwargs": {}
|
|
18
|
+
},
|
|
19
|
+
"bond_daily": {
|
|
20
|
+
"endpoint": "plasma_bond_daily",
|
|
21
|
+
"keys": [
|
|
22
|
+
"code",
|
|
23
|
+
"trade_date"
|
|
24
|
+
],
|
|
25
|
+
"kwargs": {}
|
|
26
|
+
},
|
|
27
|
+
"bond_dailyprice": {
|
|
28
|
+
"endpoint": "get_bond_dailyprice",
|
|
29
|
+
"keys": [
|
|
30
|
+
"code",
|
|
31
|
+
"trade_date"
|
|
32
|
+
],
|
|
33
|
+
"kwargs": {
|
|
34
|
+
"count_type": "1",
|
|
35
|
+
"fields": []
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"cb_daily": {
|
|
39
|
+
"endpoint": "plasma_cb_daily",
|
|
40
|
+
"keys": [
|
|
41
|
+
"code",
|
|
42
|
+
"trade_date"
|
|
43
|
+
],
|
|
44
|
+
"kwargs": {}
|
|
45
|
+
},
|
|
46
|
+
"concept_index_daily": {
|
|
47
|
+
"endpoint": "plasma_concept_index_daily",
|
|
48
|
+
"keys": [
|
|
49
|
+
"concept_code",
|
|
50
|
+
"trade_date"
|
|
51
|
+
],
|
|
52
|
+
"kwargs": {}
|
|
53
|
+
},
|
|
54
|
+
"fund_daily": {
|
|
55
|
+
"endpoint": "plasma_fund_daily",
|
|
56
|
+
"keys": [
|
|
57
|
+
"code",
|
|
58
|
+
"trade_date"
|
|
59
|
+
],
|
|
60
|
+
"kwargs": {}
|
|
61
|
+
},
|
|
62
|
+
"fund_dailyprice": {
|
|
63
|
+
"endpoint": "get_fund_dailyprice",
|
|
64
|
+
"keys": [
|
|
65
|
+
"code",
|
|
66
|
+
"trade_date"
|
|
67
|
+
],
|
|
68
|
+
"kwargs": {
|
|
69
|
+
"adjust_type": "N",
|
|
70
|
+
"count_type": "1",
|
|
71
|
+
"fields": []
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"fut_daily": {
|
|
75
|
+
"endpoint": "plasma_fut_daily",
|
|
76
|
+
"keys": [
|
|
77
|
+
"code",
|
|
78
|
+
"trade_date"
|
|
79
|
+
],
|
|
80
|
+
"kwargs": {}
|
|
81
|
+
},
|
|
82
|
+
"hk_daily": {
|
|
83
|
+
"endpoint": "plasma_hk_daily",
|
|
84
|
+
"keys": [
|
|
85
|
+
"code",
|
|
86
|
+
"trade_date"
|
|
87
|
+
],
|
|
88
|
+
"kwargs": {}
|
|
89
|
+
},
|
|
90
|
+
"index_daily": {
|
|
91
|
+
"endpoint": "plasma_index_daily",
|
|
92
|
+
"keys": [
|
|
93
|
+
"code",
|
|
94
|
+
"trade_date"
|
|
95
|
+
],
|
|
96
|
+
"kwargs": {}
|
|
97
|
+
},
|
|
98
|
+
"opt_daily": {
|
|
99
|
+
"endpoint": "plasma_opt_daily",
|
|
100
|
+
"keys": [
|
|
101
|
+
"code",
|
|
102
|
+
"trade_date"
|
|
103
|
+
],
|
|
104
|
+
"kwargs": {}
|
|
105
|
+
},
|
|
106
|
+
"sge_daily": {
|
|
107
|
+
"endpoint": "plasma_sge_daily",
|
|
108
|
+
"keys": [
|
|
109
|
+
"code",
|
|
110
|
+
"trade_date"
|
|
111
|
+
],
|
|
112
|
+
"kwargs": {}
|
|
113
|
+
},
|
|
114
|
+
"trade_cal": {
|
|
115
|
+
"endpoint": "plasma_trade_cal",
|
|
116
|
+
"keys": [
|
|
117
|
+
"exchange",
|
|
118
|
+
"calendar_date"
|
|
119
|
+
],
|
|
120
|
+
"kwargs": {}
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
"schema_version": 1
|
|
124
|
+
}
|
package/src/args.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function parseArgs(argv) {
|
|
2
|
+
const flags = {};
|
|
3
|
+
const positional = [];
|
|
4
|
+
for (let i = 0; i < argv.length; i++) {
|
|
5
|
+
const arg = argv[i];
|
|
6
|
+
if (arg === '--') { positional.push(...argv.slice(i + 1)); break; }
|
|
7
|
+
if (!arg.startsWith('--') || arg === '--') { positional.push(arg); continue; }
|
|
8
|
+
const equal = arg.indexOf('=');
|
|
9
|
+
const name = arg.slice(2, equal < 0 ? undefined : equal).replaceAll('-', '_');
|
|
10
|
+
if (!name) throw new Error('Empty option name');
|
|
11
|
+
if (Object.hasOwn(flags, name)) throw new Error(`Duplicate option --${name}`);
|
|
12
|
+
if (equal >= 0) flags[name] = arg.slice(equal + 1);
|
|
13
|
+
else if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) flags[name] = argv[++i];
|
|
14
|
+
else flags[name] = true;
|
|
15
|
+
}
|
|
16
|
+
return { flags, positional };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function requireValue(flags, name) {
|
|
20
|
+
if (typeof flags[name] !== 'string' || !flags[name]) throw new Error(`--${name.replaceAll('_', '-')} requires a value`);
|
|
21
|
+
return flags[name];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function jsonObject(raw, label = '--args') {
|
|
25
|
+
if (raw === undefined) return {};
|
|
26
|
+
if (typeof raw !== 'string') throw new Error(`${label} requires a JSON object`);
|
|
27
|
+
let value;
|
|
28
|
+
try { value = JSON.parse(raw); } catch { throw new Error(`${label} is not valid JSON`); }
|
|
29
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} requires a JSON object`);
|
|
30
|
+
return value;
|
|
31
|
+
}
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { resolveCredential, readCredentialFile, saveCredential, credentialsPath, ENV_KEY } from './credentials.js';
|
|
3
|
+
import { hiddenKey, confirmChange } from './prompt.js';
|
|
4
|
+
import { inspectEnvironment, persistEnvironment } from './environment.js';
|
|
5
|
+
|
|
6
|
+
/** 只返回是否配置及来源;不进行在线鉴权,不返回秘密。 */
|
|
7
|
+
export function credentialStatus(options) {
|
|
8
|
+
const credential = resolveCredential(options);
|
|
9
|
+
return { configured: !!credential.key, source: credential.source, ...(credential.path ? { path: credential.path } : {}) };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** 安装与设置时把不存在或空的变量视为待同步,查询请求仍保留空 Key 报错。 */
|
|
13
|
+
function configuredCredential(env, home) {
|
|
14
|
+
if (env[ENV_KEY]) return resolveCredential({ env, home });
|
|
15
|
+
const withoutEmpty = { ...env }; delete withoutEmpty[ENV_KEY];
|
|
16
|
+
return resolveCredential({ env: withoutEmpty, home });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 环境已配置则复用;没有值时自动从私有文件更新持久环境,不再次输入 Key。 */
|
|
20
|
+
export function syncCredentialEnvironment({ env = process.env, home = homedir(), log = console.error, environmentOptions = {}, fromFile = false } = {}) {
|
|
21
|
+
const current = fromFile ? resolveCredential({ env: {}, home }) : configuredCredential(env, home);
|
|
22
|
+
if (!current.key) return { configured: false, source: 'missing', synchronized: false };
|
|
23
|
+
if (current.source === 'environment') return { configured: true, source: current.source, synchronized: false };
|
|
24
|
+
const result = persistEnvironment(current.key, { ...environmentOptions, env, home });
|
|
25
|
+
log('已从现有凭据文件自动同步 ULTRASTICHING_API_KEY 到持久环境;请完全退出并重启客户端。');
|
|
26
|
+
return { ...credentialStatus({ env, home }), ...result };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 已配置不重复输入;新 Key 确认一次,同时保存私有文件和持久环境。 */
|
|
30
|
+
export async function configureCredential({
|
|
31
|
+
env = process.env, home = homedir(), replace = false,
|
|
32
|
+
readKey = hiddenKey, confirm = confirmChange, log = console.error, environmentOptions = {},
|
|
33
|
+
} = {}) {
|
|
34
|
+
const current = configuredCredential(env, home);
|
|
35
|
+
if (current.key && !replace) {
|
|
36
|
+
log('已配置 API Key,直接复用,无需再次输入。');
|
|
37
|
+
return syncCredentialEnvironment({ env, home, log, environmentOptions });
|
|
38
|
+
}
|
|
39
|
+
const file = readCredentialFile(home);
|
|
40
|
+
const target = credentialsPath(home);
|
|
41
|
+
const expected = inspectEnvironment({ ...environmentOptions, env, home });
|
|
42
|
+
if (current.key && !await confirm(`将替换现有 API Key,同时更新 ${target} 和用户持久环境中的 ${ENV_KEY},是否继续?`)) return { configured: true, source: current.source, cancelled: true };
|
|
43
|
+
log(`API Key 将明文保存在本机私有文件 ${target} 和用户持久环境配置;不要粘贴到 Agent 对话中。`);
|
|
44
|
+
const key = await readKey();
|
|
45
|
+
saveCredential(key, { home, expectedDigest: file?.digest });
|
|
46
|
+
let result;
|
|
47
|
+
try { result = persistEnvironment(key, { ...environmentOptions, env, home, expected }); }
|
|
48
|
+
catch (error) {
|
|
49
|
+
const diagnostic = error.toDiagnostic;
|
|
50
|
+
error.toDiagnostic = () => ({ ...(diagnostic ? diagnostic() : { message: error.message }), credentials_saved: true, credentials_path: target });
|
|
51
|
+
error.message = `凭据文件已保存,但环境同步未完成;修复下述错误后重新运行 ultrastiching auth sync --from-file,无需重新输入 Key。${error.message}`;
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
log('API Key 已同步保存到凭据文件和持久环境;请完全退出并重启客户端。');
|
|
55
|
+
return { ...credentialStatus({ env, home }), ...result };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 仅由 MCP 客户端调用:标准输出为请求头 JSON;未配置 Key 时仍可浏览免费目录。 */
|
|
59
|
+
export function mcpHeaders(options) {
|
|
60
|
+
const { key } = resolveCredential(options);
|
|
61
|
+
return key ? { Authorization: `Bearer ${key}` } : {};
|
|
62
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { requireKey, resolveCredential } from './credentials.js';
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_BASE_URL = 'https://api.ultrastiching.com';
|
|
5
|
+
|
|
6
|
+
function singleLine(label, value) {
|
|
7
|
+
if (typeof value !== 'string' || !value.trim() || value !== value.trim() || /[\r\n]/.test(value)) {
|
|
8
|
+
throw new Error(`${label} must be a non-empty single-line value without surrounding whitespace`);
|
|
9
|
+
}
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function normalizeOrigin(value, protocols = ['http:', 'https:']) {
|
|
14
|
+
const raw = singleLine('base URL', value);
|
|
15
|
+
let url;
|
|
16
|
+
try { url = new URL(raw); } catch { throw new Error('base URL must be an origin'); }
|
|
17
|
+
if (!protocols.includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
|
|
18
|
+
throw new Error(`base URL must be a ${protocols.join('/')} origin without path, query, or credentials`);
|
|
19
|
+
}
|
|
20
|
+
return url.origin;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolveBaseUrl(explicit, env = process.env) {
|
|
24
|
+
return normalizeOrigin(explicit !== undefined ? explicit : env.MERIDIAN_BASE_URL !== undefined ? env.MERIDIAN_BASE_URL : DEFAULT_BASE_URL);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 显式参数只影响本次调用;自动读取统一走共享凭据模块。 */
|
|
28
|
+
export function resolveKey(explicit, { env = process.env, home = homedir(), optional = false } = {}) {
|
|
29
|
+
const key = explicit !== undefined ? requireKey(explicit) : resolveCredential({ env, home }).key;
|
|
30
|
+
if (key === undefined && optional) return undefined;
|
|
31
|
+
if (key === undefined) throw new Error('API Key required: run ultrastiching auth configure, set ULTRASTICHING_API_KEY, or use ~/.ultrastiching/credentials.env');
|
|
32
|
+
return key;
|
|
33
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { closeSync, fstatSync, lstatSync, openSync, readFileSync, constants, mkdirSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
export const ENV_KEY = 'ULTRASTICHING_API_KEY';
|
|
8
|
+
export const credentialsPath = (home = homedir()) => join(home, '.ultrastiching', 'credentials.env');
|
|
9
|
+
|
|
10
|
+
/** 只解析单行 Key,不执行 env 文件中的 Shell 语法。 */
|
|
11
|
+
export function requireKey(value) {
|
|
12
|
+
if (typeof value !== 'string' || !value || /[\s\x00-\x1f\x7f]/u.test(value) || value === 'YOUR_ULTRASTICHING_DATA_API_KEY') {
|
|
13
|
+
throw new Error('API Key must be a non-empty single-line value without whitespace or control characters');
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseCredential(content, name = ENV_KEY) {
|
|
19
|
+
let found;
|
|
20
|
+
for (const source of content.split(/\r?\n/u)) {
|
|
21
|
+
let line = source.trim();
|
|
22
|
+
if (!line || line.startsWith('#')) continue;
|
|
23
|
+
if (line.startsWith('export ')) line = line.slice(7).trimStart();
|
|
24
|
+
const separator = line.indexOf('=');
|
|
25
|
+
if ((separator < 0 ? line : line.slice(0, separator)).trim() !== name) continue;
|
|
26
|
+
if (separator < 0 || found !== undefined) throw new Error(`Invalid or duplicate ${name} in credentials.env`);
|
|
27
|
+
let value = line.slice(separator + 1).trim();
|
|
28
|
+
if (value.startsWith('"') || value.startsWith("'")) {
|
|
29
|
+
if (value.length < 2 || value.at(-1) !== value[0]) throw new Error(`Invalid quoted ${name} in credentials.env`);
|
|
30
|
+
value = value.slice(1, -1);
|
|
31
|
+
}
|
|
32
|
+
found = requireKey(value);
|
|
33
|
+
}
|
|
34
|
+
return found;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Windows 使用当前用户的私有 ACL;仅允许用户、SYSTEM 与 Administrators 读取。 */
|
|
38
|
+
export function windowsAcl(path, restrict = false) {
|
|
39
|
+
const literalPath = "'" + path.replaceAll("'", "''") + "'";
|
|
40
|
+
const script = `$ErrorActionPreference='Stop'; $p=${literalPath}; $sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User;
|
|
41
|
+
if ($${restrict ? 'true' : 'false'}) {
|
|
42
|
+
$acl=Get-Acl -LiteralPath $p; $acl.SetAccessRuleProtection($true,$false);
|
|
43
|
+
foreach($rule in @($acl.Access)){[void]$acl.RemoveAccessRuleSpecific($rule)};
|
|
44
|
+
$acl.SetOwner($sid);
|
|
45
|
+
if ((Get-Item -LiteralPath $p).PSIsContainer) {
|
|
46
|
+
$rule=[System.Security.AccessControl.FileSystemAccessRule]::new($sid,'FullControl','ContainerInherit,ObjectInherit','None','Allow')
|
|
47
|
+
} else {$rule=[System.Security.AccessControl.FileSystemAccessRule]::new($sid,'FullControl','Allow')};
|
|
48
|
+
$acl.AddAccessRule($rule); Set-Acl -LiteralPath $p -AclObject $acl
|
|
49
|
+
};
|
|
50
|
+
$acl=Get-Acl -LiteralPath $p;
|
|
51
|
+
if ($acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $sid.Value){throw 'credentials must be owned by current user'};
|
|
52
|
+
foreach($rule in $acl.GetAccessRules($true,$true,[System.Security.Principal.SecurityIdentifier])) {
|
|
53
|
+
if ($rule.AccessControlType -eq 'Allow' -and $rule.IdentityReference.Value -notin @($sid.Value,'S-1-5-18','S-1-5-32-544') -and ([int]$rule.FileSystemRights -band 2032127)){throw 'credentials ACL grants access to other users'}
|
|
54
|
+
}`;
|
|
55
|
+
execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function privateDirectory(dir) {
|
|
59
|
+
let info;
|
|
60
|
+
try { info = lstatSync(dir); } catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
61
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`${dir} must be a directory, not a link or file`);
|
|
62
|
+
if (process.platform === 'win32') windowsAcl(dir);
|
|
63
|
+
else if (info.uid !== process.getuid() || (info.mode & 0o022)) throw new Error(`${dir} must be an owned directory not writable by others`);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 读取私有文件并验证所有权、权限和类型;不存在才返回空,不吞掉权限或格式错误。 */
|
|
68
|
+
export function readCredentialFile(home = homedir()) {
|
|
69
|
+
const dir = join(home, '.ultrastiching');
|
|
70
|
+
if (!privateDirectory(dir)) return undefined;
|
|
71
|
+
const path = join(dir, 'credentials.env');
|
|
72
|
+
let before;
|
|
73
|
+
try { before = lstatSync(path); } catch (error) { if (error.code === 'ENOENT') return undefined; throw error; }
|
|
74
|
+
if (!before.isFile() || before.isSymbolicLink()) throw new Error(`${path} must be a regular file, not a link`);
|
|
75
|
+
if (process.platform === 'win32') windowsAcl(path);
|
|
76
|
+
const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
77
|
+
try {
|
|
78
|
+
const info = fstatSync(fd);
|
|
79
|
+
if (!info.isFile() || info.dev !== before.dev || info.ino !== before.ino || info.size > 65536) throw new Error(`${path} must be an unchanged regular file, at most 64 KiB`);
|
|
80
|
+
if (process.platform !== 'win32' && (info.uid !== process.getuid() || (info.mode & 0o077))) throw new Error(`${path} must be owned by the current user with mode 0600 or stricter`);
|
|
81
|
+
const content = readFileSync(fd, 'utf8');
|
|
82
|
+
if (Buffer.byteLength(content, 'utf8') > 65536) throw new Error(`${path} exceeds 64 KiB`);
|
|
83
|
+
return { key: parseCredential(content), digest: createHash('sha256').update(content).digest('hex'), path };
|
|
84
|
+
} finally { closeSync(fd); }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** CLI、Python 和 MCP 共用环境优先约定;状态输出使用 source,不能输出 key。 */
|
|
88
|
+
export function resolveCredential({ env = process.env, home = homedir() } = {}) {
|
|
89
|
+
if (Object.hasOwn(env, ENV_KEY)) return { key: requireKey(env[ENV_KEY]), source: 'environment' };
|
|
90
|
+
const file = readCredentialFile(home);
|
|
91
|
+
return file?.key ? { ...file, source: 'credentials_file' } : { source: 'missing' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 原子保存;覆盖须携带确认前的内容摘要,并在写入前检查并发变更。 */
|
|
95
|
+
export function saveCredential(key, { home = homedir(), expectedDigest } = {}) {
|
|
96
|
+
requireKey(key);
|
|
97
|
+
// 用双引号保存并不解释其中的 Shell 字符;拒绝引号避免产生歧义。
|
|
98
|
+
if (/["']/u.test(key)) throw new Error('API Key cannot contain quotes when saved to credentials.env');
|
|
99
|
+
const dir = join(home, '.ultrastiching');
|
|
100
|
+
if (!privateDirectory(dir)) {
|
|
101
|
+
mkdirSync(dir, { mode: 0o700 });
|
|
102
|
+
if (process.platform === 'win32') windowsAcl(dir, true);
|
|
103
|
+
}
|
|
104
|
+
const current = readCredentialFile(home);
|
|
105
|
+
if (current?.digest !== expectedDigest) throw new Error('credentials.env changed since confirmation; inspect it and confirm again');
|
|
106
|
+
const path = credentialsPath(home);
|
|
107
|
+
const temporary = join(dir, `.credentials-${randomUUID()}.env`);
|
|
108
|
+
try {
|
|
109
|
+
writeFileSync(temporary, `${ENV_KEY}="${key}"\n`, { mode: 0o600, flag: 'wx' });
|
|
110
|
+
if (process.platform === 'win32') windowsAcl(temporary, true);
|
|
111
|
+
if (readCredentialFile(home)?.digest !== expectedDigest) throw new Error('credentials.env changed during save; no overwrite performed');
|
|
112
|
+
renameSync(temporary, path);
|
|
113
|
+
} finally {
|
|
114
|
+
try { unlinkSync(temporary); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
115
|
+
}
|
|
116
|
+
return { source: 'credentials_file', path };
|
|
117
|
+
}
|
package/src/db-cli.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { jsonObject, requireValue } from './args.js';
|
|
2
|
+
import { callByName } from './http.js';
|
|
3
|
+
import { endpoint, parseParameter } from './registry.js';
|
|
4
|
+
import { datasets, MarketDB } from './marketdb.js';
|
|
5
|
+
|
|
6
|
+
const COMMON = new Set(['db', 'compact']);
|
|
7
|
+
function only(flags, allowed) {
|
|
8
|
+
const extra = Object.keys(flags).filter((name) => !allowed.has(name));
|
|
9
|
+
if (extra.length) throw new Error(`Unknown db option(s): ${extra.map((x) => `--${x.replaceAll('_', '-')}`).join(', ')}`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function dbCommand(sub, flags, { fetchImpl } = {}) {
|
|
13
|
+
const path = flags.db === undefined ? undefined : requireValue(flags, 'db');
|
|
14
|
+
const requestOptions = { key: flags.key, baseUrl: flags.base_url, timeout: flags.timeout, fetchImpl };
|
|
15
|
+
const db = new MarketDB({ path, request: callByName, requestOptions });
|
|
16
|
+
if (sub === 'init') { only(flags, COMMON); return db.init(); }
|
|
17
|
+
if (sub === 'status') { only(flags, COMMON); return db.status(); }
|
|
18
|
+
if (sub === 'validate') { only(flags, COMMON); return db.validate(); }
|
|
19
|
+
if (sub === 'sync') {
|
|
20
|
+
const name = requireValue(flags, 'dataset');
|
|
21
|
+
const spec = datasets()[name];
|
|
22
|
+
if (!spec) throw new Error(`unknown dataset: ${name}`);
|
|
23
|
+
const meta = endpoint(spec.endpoint, 'http');
|
|
24
|
+
const values = jsonObject(flags.args);
|
|
25
|
+
const extra = new Set(['dataset', 'args', 'key', 'base_url', 'timeout', 'start', 'end', ...meta.parameters, ...COMMON]);
|
|
26
|
+
only(flags, extra);
|
|
27
|
+
if (flags.start !== undefined) values.start_date = requireValue(flags, 'start');
|
|
28
|
+
if (flags.end !== undefined) values.end_date = requireValue(flags, 'end');
|
|
29
|
+
for (const name of meta.parameters) {
|
|
30
|
+
if (!Object.hasOwn(flags, name)) continue;
|
|
31
|
+
const field = meta.request?.fields?.find((item) => item.name === name) ?? { name };
|
|
32
|
+
values[name] = parseParameter(requireValue(flags, name), field);
|
|
33
|
+
}
|
|
34
|
+
return db.sync(name, values);
|
|
35
|
+
}
|
|
36
|
+
if (sub === 'repair') {
|
|
37
|
+
only(flags, new Set([...COMMON, 'dataset', 'key', 'base_url', 'timeout']));
|
|
38
|
+
return db.repair(flags.dataset === undefined ? undefined : requireValue(flags, 'dataset'));
|
|
39
|
+
}
|
|
40
|
+
if (sub === 'sql') {
|
|
41
|
+
only(flags, new Set([...COMMON, 'query', 'limit']));
|
|
42
|
+
const limit = flags.limit === undefined ? undefined : Number(requireValue(flags, 'limit'));
|
|
43
|
+
return db.sql(requireValue(flags, 'query'), limit);
|
|
44
|
+
}
|
|
45
|
+
if (sub === 'export') {
|
|
46
|
+
only(flags, new Set([...COMMON, 'table', 'path', 'format']));
|
|
47
|
+
return db.export(requireValue(flags, 'table'), requireValue(flags, 'path'), flags.format === undefined ? undefined : requireValue(flags, 'format'));
|
|
48
|
+
}
|
|
49
|
+
throw new Error('db subcommands: init, sync, status, validate, repair, sql, export');
|
|
50
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { lstatSync, readFileSync, mkdirSync, writeFileSync, renameSync, unlinkSync, existsSync } from 'node:fs';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { dirname, join, isAbsolute } from 'node:path';
|
|
6
|
+
import { ENV_KEY, requireKey } from './credentials.js';
|
|
7
|
+
|
|
8
|
+
const START = '# >>> UltraStiching environment >>>';
|
|
9
|
+
const END = '# <<< UltraStiching environment <<<';
|
|
10
|
+
const digest = content => createHash('sha256').update(content).digest('hex');
|
|
11
|
+
const quote = value => "'" + value.replaceAll("'", "'\\''") + "'";
|
|
12
|
+
const xml = value => value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
|
13
|
+
|
|
14
|
+
/** 只接受当前用户拥有的普通配置文件;读取用于保留原文和复核并发修改,不执行内容。 */
|
|
15
|
+
function snapshot(path) {
|
|
16
|
+
let info;
|
|
17
|
+
try { info = lstatSync(path); } catch (error) { if (error.code === 'ENOENT') return { path, digest: null, content: '' }; throw error; }
|
|
18
|
+
if (!info.isFile() || info.isSymbolicLink() || info.uid !== process.getuid() || (info.mode & 0o022)) throw new Error(`${path} 必须是当前用户拥有且不能被其他用户写入的普通文件。`);
|
|
19
|
+
const content = readFileSync(path, 'utf8');
|
|
20
|
+
return { path, digest: digest(content), content, mode: info.mode & 0o777 };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 原子更新私有环境配置,保留配置文件中不属于本工具的内容。 */
|
|
24
|
+
function save(entry, content) {
|
|
25
|
+
if (entry.content === content && !(entry.mode & 0o077)) return false;
|
|
26
|
+
if (snapshot(entry.path).digest !== entry.digest) throw new Error(`${entry.path} 在检查后发生变化,未覆盖。`);
|
|
27
|
+
mkdirSync(dirname(entry.path), { recursive: true, mode: 0o700 });
|
|
28
|
+
const directory = lstatSync(dirname(entry.path));
|
|
29
|
+
if (!directory.isDirectory() || directory.isSymbolicLink() || directory.uid !== process.getuid() || (directory.mode & 0o022)) throw new Error(`${dirname(entry.path)} 不是当前用户可安全写入的目录。`);
|
|
30
|
+
const temporary = join(dirname(entry.path), `.ultrastiching-${randomUUID()}`);
|
|
31
|
+
try {
|
|
32
|
+
writeFileSync(temporary, content, { mode: 0o600, flag: 'wx' });
|
|
33
|
+
if (snapshot(entry.path).digest !== entry.digest) throw new Error(`${entry.path} 在保存时发生变化,未覆盖。`);
|
|
34
|
+
renameSync(temporary, entry.path);
|
|
35
|
+
} finally { try { unlinkSync(temporary); } catch (error) { if (error.code !== 'ENOENT') throw error; } }
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 本工具只替换自己的完整标记块;标记损坏或重复时报告根因,不猜测删除范围。 */
|
|
40
|
+
function shellContent(content, key) {
|
|
41
|
+
const block = `${START}\nexport ${ENV_KEY}=${quote(key)}\n${END}`;
|
|
42
|
+
const start = content.indexOf(START), end = content.indexOf(END);
|
|
43
|
+
if (start < 0 && end < 0) return content + (content && !content.endsWith('\n') ? '\n' : '') + block + '\n';
|
|
44
|
+
if (start < 0 || end < start || content.indexOf(START, start + START.length) >= 0 || content.indexOf(END, end + END.length) >= 0) throw new Error('UltraStiching 环境配置标记损坏或重复;未覆盖 Shell 文件。');
|
|
45
|
+
return content.slice(0, start) + block + content.slice(end + END.length);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 检查持久化目标,供保存前复核;返回内容只能在本地使用,不输出到日志。 */
|
|
49
|
+
export function inspectEnvironment({ home = homedir(), platform = process.platform, env = process.env } = {}) {
|
|
50
|
+
if (platform === 'win32') return [];
|
|
51
|
+
if (!['darwin', 'linux'].includes(platform)) throw new Error(`暂不支持 ${platform} 的环境变量持久化。`);
|
|
52
|
+
const shell = (env.SHELL ?? '').split('/').at(-1) || (platform === 'darwin' ? 'zsh' : 'bash');
|
|
53
|
+
if (!['bash', 'zsh', 'sh', 'dash'].includes(shell)) throw new Error(`暂不支持 ${shell} 的启动文件,请先确认该 Shell 的持久化方式。`);
|
|
54
|
+
const paths = [join(home, '.profile')];
|
|
55
|
+
if (shell === 'zsh') paths.push(join(env.ZDOTDIR && isAbsolute(env.ZDOTDIR) ? env.ZDOTDIR : home, '.zshrc'), join(env.ZDOTDIR && isAbsolute(env.ZDOTDIR) ? env.ZDOTDIR : home, '.zprofile'));
|
|
56
|
+
if (shell === 'bash') {
|
|
57
|
+
paths.push(join(home, '.bashrc'));
|
|
58
|
+
// Bash 只加载第一个存在的登录文件;不创建优先级更高的新文件来遮住用户原配置。
|
|
59
|
+
for (const name of ['.bash_profile', '.bash_login']) {
|
|
60
|
+
try { lstatSync(join(home, name)); paths.push(join(home, name)); break; }
|
|
61
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (platform === 'darwin') paths.push(join(home, 'Library', 'LaunchAgents', 'com.ultrastiching.environment.plist'));
|
|
65
|
+
else paths.push(join(env.XDG_CONFIG_HOME && isAbsolute(env.XDG_CONFIG_HOME) ? env.XDG_CONFIG_HOME : join(home, '.config'), 'environment.d', '90-ultrastiching.conf'));
|
|
66
|
+
return [...new Set(paths)].map(snapshot);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 原生命令的诊断保留状态与标准错误,仅过滤本次 Key,不携带秘密参数。 */
|
|
70
|
+
function nativeCommand(command, args, key, options, execute) {
|
|
71
|
+
try { return execute(command, args, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000, windowsHide: true, ...options }); }
|
|
72
|
+
catch (original) {
|
|
73
|
+
const redact = value => String(value ?? '').replaceAll(key, '[REDACTED]');
|
|
74
|
+
const details = { command, code: original.code, status: original.status, signal: original.signal, stderr: redact(original.stderr), message: redact(original.message) };
|
|
75
|
+
const error = new Error(`环境变量同步失败:${command},${details.stderr || details.message}`);
|
|
76
|
+
error.code = original.code; error.details = details;
|
|
77
|
+
error.toDiagnostic = () => ({ message: error.message, details });
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 更新系统启动环境与本进程;已运行的客户端需重启才能继承新值。 */
|
|
83
|
+
export function updateSessionEnvironment(key, { platform = process.platform, env = process.env, execute = execFileSync } = {}) {
|
|
84
|
+
requireKey(key);
|
|
85
|
+
const next = { ...env, [ENV_KEY]: key };
|
|
86
|
+
if (platform === 'darwin') nativeCommand('/bin/launchctl', ['setenv', ENV_KEY, key], key, { env: next }, execute);
|
|
87
|
+
else if (platform === 'win32') {
|
|
88
|
+
const script = `$ErrorActionPreference='Stop'; $key=[Console]::In.ReadToEnd(); [Environment]::SetEnvironmentVariable('${ENV_KEY}',$key,'User')`;
|
|
89
|
+
nativeCommand('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], key, { input: key, env: next }, execute);
|
|
90
|
+
} else if (platform === 'linux') {
|
|
91
|
+
// 有桌面会话总线时更新新启动服务;纯终端环境只需本进程和启动文件。
|
|
92
|
+
if (env.DBUS_SESSION_BUS_ADDRESS || env.XDG_RUNTIME_DIR && existsSync(join(env.XDG_RUNTIME_DIR, 'bus'))) nativeCommand('dbus-update-activation-environment', ['--systemd', ENV_KEY], key, { env: next }, execute);
|
|
93
|
+
} else throw new Error(`暂不支持 ${platform} 的环境变量同步。`);
|
|
94
|
+
env[ENV_KEY] = key;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 保存 Shell 与系统持久环境,macOS 登录任务只调用原生 setenv,不转接 MCP。 */
|
|
98
|
+
export function persistEnvironment(key, {
|
|
99
|
+
home = homedir(), platform = process.platform, env = process.env, execute = execFileSync,
|
|
100
|
+
expected = inspectEnvironment({ home, platform, env }),
|
|
101
|
+
} = {}) {
|
|
102
|
+
requireKey(key);
|
|
103
|
+
const prepared = expected.map(entry => {
|
|
104
|
+
let content;
|
|
105
|
+
if (entry.path.endsWith('.plist')) {
|
|
106
|
+
const args = ['/bin/launchctl', 'setenv', ENV_KEY, key].map(value => ` <string>${xml(value)}</string>`).join('\n');
|
|
107
|
+
content = `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n <key>Label</key><string>com.ultrastiching.environment</string>\n <key>ProgramArguments</key><array>\n${args}\n </array>\n <key>RunAtLoad</key><true/>\n</dict></plist>\n`;
|
|
108
|
+
} else if (entry.path.endsWith('.conf')) {
|
|
109
|
+
// systemd environment.d 用 $$ 表示字面美元符号;其他字符按双引号规则转义。
|
|
110
|
+
const value = key.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('`', '\\`').replaceAll('$', () => '$$');
|
|
111
|
+
content = `${ENV_KEY}="${value}"\n`;
|
|
112
|
+
} else content = shellContent(entry.content, key);
|
|
113
|
+
return { entry, content };
|
|
114
|
+
});
|
|
115
|
+
for (const { entry } of prepared) if (snapshot(entry.path).digest !== entry.digest) throw new Error(`${entry.path} 在检查后发生变化,未覆盖。`);
|
|
116
|
+
const updated = [];
|
|
117
|
+
try {
|
|
118
|
+
for (const { entry, content } of prepared) if (save(entry, content)) updated.push(entry.path);
|
|
119
|
+
updateSessionEnvironment(key, { platform, env, execute });
|
|
120
|
+
} catch (error) {
|
|
121
|
+
error.updated_paths = updated;
|
|
122
|
+
const diagnostic = error.toDiagnostic;
|
|
123
|
+
error.toDiagnostic = () => ({ ...(diagnostic ? diagnostic() : { message: error.message }), updated_paths: updated });
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
return { synchronized: true, updated_paths: updated, restart_required: true };
|
|
127
|
+
}
|