ultrastiching-cli 0.1.1 → 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/README.md +22 -6
- package/package.json +1 -1
- package/src/auth.js +62 -0
- package/src/config.js +5 -45
- package/src/credentials.js +117 -0
- package/src/environment.js +127 -0
- package/src/main.js +27 -0
- package/src/prompt.js +42 -0
package/README.md
CHANGED
|
@@ -2,20 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
UltraStiching 数据 API 的 Node.js 命令行入口。要求 Node.js 22.12 或更高版本。实现 `doctor`、`list`、`info`、`call`、`arrow`、`ws newsflash`、`datasets` 和 `db`。本地数据库与 Python SDK 共用 DuckDB 文件,同步与校验行为见下文。14 个预设的数据完整同步仍待服务端验证,现阶段不把单次成功响应当作完整历史。
|
|
4
4
|
|
|
5
|
-
发行包名为 `ultrastiching-cli`,安装后的命令为 `ultrastiching
|
|
5
|
+
发行包名为 `ultrastiching-cli`,安装后的命令为 `ultrastiching`。统一 Key 变量为 `ULTRASTICHING_API_KEY`,CLI、Python SDK 和 Claude Code 请求头助手其次读取 `~/.ultrastiching/credentials.env`。安装器自动把已有文件 Key 同步到持久环境,供 Codex 原生 HTTP MCP 使用;数据库路径与地址变量保持既有约定。
|
|
6
|
+
|
|
7
|
+
## 凭据与统一安装
|
|
8
|
+
|
|
9
|
+
通过 [统一安装指导](../docs/agent-setup.md) 安装时,已有 CLI 只检查更新,覆盖前确认。以下新增命令不发起业务请求:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
ultrastiching --version
|
|
13
|
+
ultrastiching auth status
|
|
14
|
+
ultrastiching auth sync
|
|
15
|
+
ultrastiching auth configure
|
|
16
|
+
# 主动替换 Key,需确认:ultrastiching auth configure --replace
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`auth status` 只输出是否配置及来源。`auth sync` 由安装器自动调用:环境变量没有值时,从已有凭据文件同步到当前进程和持久环境。`auth configure` 已有 Key 时不重复输入;缺失时在本机终端隐藏输入一次,同时保存私有文件和持久环境。`--replace` 确认一次后更换两处的 Key。macOS 更新 Shell、launchd 环境和登录恢复任务;Windows 更新用户环境;Linux 更新 Shell 和用户 environment.d,有会话总线时同步服务启动环境。含 Key 的 POSIX 配置使用 0600,Windows 凭据文件使用私有 ACL。保存后完全退出并重启客户端。不要在 Agent 对话中填写 Key。`ultrastiching mcp headers` 只供 MCP 客户端调用,stdout 含鉴权头,Agent 不应执行或打印该命令的输出。
|
|
20
|
+
|
|
21
|
+
本版为 0.2.0,支持共享凭据配置和安装器自动同步。
|
|
6
22
|
|
|
7
23
|
## 安装与离线发现
|
|
8
24
|
|
|
9
25
|
从 npm 安装指定版本:
|
|
10
26
|
|
|
11
27
|
```bash
|
|
12
|
-
npm install -g ultrastiching-cli@0.
|
|
28
|
+
npm install -g ultrastiching-cli@0.2.0
|
|
13
29
|
ultrastiching --help
|
|
14
30
|
ultrastiching list --search 交易日历
|
|
15
31
|
ultrastiching info plasma_trade_cal
|
|
16
32
|
```
|
|
17
33
|
|
|
18
|
-
`list` 和 `info` 使用随包携带的目录,不需要 API Key,不发送业务请求。也可安装维护者提供的包:`npm install -g ./ultrastiching-cli-0.
|
|
34
|
+
`list` 和 `info` 使用随包携带的目录,不需要 API Key,不发送业务请求。也可安装维护者提供的包:`npm install -g ./ultrastiching-cli-0.2.0.tgz`。源码环境使用下面的安装方式:
|
|
19
35
|
|
|
20
36
|
```bash
|
|
21
37
|
cd meridian-cli
|
|
@@ -38,7 +54,7 @@ ultrastiching db export --table concept_index_daily --path concept.csv
|
|
|
38
54
|
|
|
39
55
|
`call` 按仓库根目录 `registry/endpoints.json` 中的真实路径和 payload 模板发起请求。参数名称与 Python SDK 方法一致:`--start-date` 和 `--start_date` 均映射到 `start_date`。`--args` 接收参数名到值的 JSON 对象;同名命令行参数覆盖 `--args` 中的值。对于请求模板中的 `symbols`,单个字符串会成为单元素数组,逗号分隔的字符串会拆为数组。其他参数依注册表中的类型解析;复杂值可直接在 `--args` 中使用 JSON 数组或对象。`info` 输出完整的字段、来源与验证标记,使用前请核对该端点的实际请求形状。
|
|
40
56
|
|
|
41
|
-
API Key 优先级为 `--key`、`
|
|
57
|
+
API Key 优先级为 `--key`、`ULTRASTICHING_API_KEY`、`~/.ultrastiching/credentials.env`。文件回退要求用户所有、权限为 `0600` 或更严,且 `~/.ultrastiching` 不可由其他用户写入。HTTP 地址优先级为 `--base-url`、`MERIDIAN_BASE_URL`、`https://api.ultrastiching.com`。WebSocket 默认使用注册表中的 `wss://ws.ultrastiching.com`,可用 `--ws-base-url` 覆盖。`doctor` 默认只查本地 Key、注册表及 DuckDB 依赖/版本;`doctor --probe` 在有 Key 时调用一个已登记的示例端点,因而会产生真实 API 请求。
|
|
42
58
|
|
|
43
59
|
HTTP 结果按服务端 `Content-Type` 处理:JSON 输出到终端,已知数组超过 500 行时自动写入 `meridian-out/`;Arrow IPC 原始字节写入 `--out` 指定文件,未指定时写入 `meridian-out/`。`call` 和 `arrow` 保留原始 IPC,不解码。`db sync` 使用 `apache-arrow` 解码表格,并使用 `@duckdb/node-api` 写入与 Python 相同的数据库结构;WebSocket 使用 `ws` 在握手时发送 Bearer 头;Node 内建 `fetch` 负责 HTTP。三者是包的直接依赖。`npm pack` 时 `prepack` 从仓库根复制端点及数据集注册表快照到包内;发布前必须先运行生成链并核对快照。
|
|
44
60
|
|
|
@@ -46,10 +62,10 @@ HTTP 结果按服务端 `Content-Type` 处理:JSON 输出到终端,已知数
|
|
|
46
62
|
|
|
47
63
|
默认不自动重试。HTTP 非 2xx 错误保留状态、服务端 `error`、`X-Meridian-Request-ID` 和原始响应体;终端输出仅替换当前 API Key 的字面值。不要把含敏感信息的完整原始错误对象转存到公开日志。离线测试运行 `npm test`;设置 `MERIDIAN_TEST_PYTHON` 和可选的 `MERIDIAN_TEST_PYTHON39` 为装有本仓库 Python SDK 及 DuckDB 的解释器路径,可额外执行跨语言同文件测试。未配置时只跳过跨语言用例。尚未用测试或正式环境 Key 验收真实端点、WebSocket 和服务限制。
|
|
48
64
|
|
|
49
|
-
环境变量与私有凭据文件任选一种;CLI 不会自动加载项目 `.env`,`.env.example` 只是模板。用户在 CLI 执行环境配置现有 `
|
|
65
|
+
环境变量与私有凭据文件任选一种;CLI 不会自动加载项目 `.env`,`.env.example` 只是模板。用户在 CLI 执行环境配置现有 `ULTRASTICHING_API_KEY`,或使用前文说明的私有凭据文件。直接调用业务接口前需要配置有效 Key:
|
|
50
66
|
|
|
51
67
|
```bash
|
|
52
|
-
export
|
|
68
|
+
export ULTRASTICHING_API_KEY='<your-api-key>'
|
|
53
69
|
ultrastiching call tracer_newsflash --limit 20
|
|
54
70
|
```
|
|
55
71
|
|
package/package.json
CHANGED
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
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { closeSync, fstatSync, lstatSync, openSync, readFileSync, constants } from 'node:fs';
|
|
2
1
|
import { homedir } from 'node:os';
|
|
3
|
-
import {
|
|
2
|
+
import { requireKey, resolveCredential } from './credentials.js';
|
|
4
3
|
|
|
5
4
|
export const DEFAULT_BASE_URL = 'https://api.ultrastiching.com';
|
|
6
5
|
|
|
@@ -25,49 +24,10 @@ export function resolveBaseUrl(explicit, env = process.env) {
|
|
|
25
24
|
return normalizeOrigin(explicit !== undefined ? explicit : env.MERIDIAN_BASE_URL !== undefined ? env.MERIDIAN_BASE_URL : DEFAULT_BASE_URL);
|
|
26
25
|
}
|
|
27
26
|
|
|
28
|
-
|
|
29
|
-
if (process.platform === 'win32' || typeof process.getuid !== 'function' || constants.O_NOFOLLOW === undefined) {
|
|
30
|
-
throw new Error('Credential file fallback requires POSIX ownership and no-follow file access; use --key or MERIDIAN_API_KEY');
|
|
31
|
-
}
|
|
32
|
-
const dir = join(home, '.meridian');
|
|
33
|
-
let dirInfo;
|
|
34
|
-
try { dirInfo = lstatSync(dir); } catch (error) { if (error.code === 'ENOENT') return undefined; throw error; }
|
|
35
|
-
if (!dirInfo.isDirectory() || dirInfo.uid !== process.getuid() || (dirInfo.mode & 0o022)) {
|
|
36
|
-
throw new Error('~/.meridian must be an owned directory not writable by others');
|
|
37
|
-
}
|
|
38
|
-
let fd;
|
|
39
|
-
try {
|
|
40
|
-
fd = openSync(join(dir, 'credentials.env'), constants.O_RDONLY | constants.O_NOFOLLOW | (constants.O_NONBLOCK ?? 0));
|
|
41
|
-
} catch (error) { if (error.code === 'ENOENT') return undefined; throw error; }
|
|
42
|
-
try {
|
|
43
|
-
const info = fstatSync(fd);
|
|
44
|
-
if (!info.isFile() || info.uid !== process.getuid() || (info.mode & 0o077) || info.size > 65536) {
|
|
45
|
-
throw new Error('~/.meridian/credentials.env must be an owned regular file with mode 0600 or stricter, at most 64 KiB');
|
|
46
|
-
}
|
|
47
|
-
const content = readFileSync(fd, 'utf8');
|
|
48
|
-
let found;
|
|
49
|
-
for (const source of content.split(/\r?\n/)) {
|
|
50
|
-
let line = source.trim();
|
|
51
|
-
if (!line || line.startsWith('#')) continue;
|
|
52
|
-
if (line.startsWith('export ')) line = line.slice(7).trimStart();
|
|
53
|
-
const separator = line.indexOf('=');
|
|
54
|
-
const name = separator < 0 ? line : line.slice(0, separator).trim();
|
|
55
|
-
if (name !== 'MERIDIAN_API_KEY') continue;
|
|
56
|
-
if (separator < 0 || found !== undefined) throw new Error('Invalid or duplicate MERIDIAN_API_KEY in credentials.env');
|
|
57
|
-
let value = line.slice(separator + 1).trim();
|
|
58
|
-
if (value.startsWith('"') || value.startsWith("'")) {
|
|
59
|
-
if (value.length < 2 || value.at(-1) !== value[0]) throw new Error('Invalid quoted MERIDIAN_API_KEY in credentials.env');
|
|
60
|
-
value = value.slice(1, -1);
|
|
61
|
-
}
|
|
62
|
-
found = value;
|
|
63
|
-
}
|
|
64
|
-
return found;
|
|
65
|
-
} finally { closeSync(fd); }
|
|
66
|
-
}
|
|
67
|
-
|
|
27
|
+
/** 显式参数只影响本次调用;自动读取统一走共享凭据模块。 */
|
|
68
28
|
export function resolveKey(explicit, { env = process.env, home = homedir(), optional = false } = {}) {
|
|
69
|
-
const key = explicit !== undefined ? explicit :
|
|
29
|
+
const key = explicit !== undefined ? requireKey(explicit) : resolveCredential({ env, home }).key;
|
|
70
30
|
if (key === undefined && optional) return undefined;
|
|
71
|
-
if (key === undefined) throw new Error('API Key required:
|
|
72
|
-
return
|
|
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;
|
|
73
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
|
+
}
|
|
@@ -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
|
+
}
|
package/src/main.js
CHANGED
|
@@ -5,9 +5,18 @@ import { resolveBaseUrl, resolveKey } from './config.js';
|
|
|
5
5
|
import { callByName, redactKey } from './http.js';
|
|
6
6
|
import { buildPayload, endpoint, loadRegistry, parseParameter, searchEndpoints } from './registry.js';
|
|
7
7
|
import { streamNewsflash } from './websocket.js';
|
|
8
|
+
import { credentialStatus, configureCredential, syncCredentialEnvironment, mcpHeaders } from './auth.js';
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
|
|
11
|
+
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
8
12
|
|
|
9
13
|
const HELP = `ultrastiching — UltraStiching data API CLI
|
|
10
14
|
|
|
15
|
+
ultrastiching --version
|
|
16
|
+
ultrastiching auth status
|
|
17
|
+
ultrastiching auth sync [--from-file]
|
|
18
|
+
ultrastiching auth configure [--replace]
|
|
19
|
+
ultrastiching mcp headers (MCP client only; stdout contains credentials)
|
|
11
20
|
ultrastiching doctor [--probe]
|
|
12
21
|
ultrastiching list [--search text] [--binary] [--verbose]
|
|
13
22
|
ultrastiching info <endpoint>
|
|
@@ -70,7 +79,25 @@ function rowCount(data) {
|
|
|
70
79
|
export async function main(argv, { write = (line) => process.stdout.write(line), fetchImpl, socketFactory } = {}) {
|
|
71
80
|
const { flags, positional } = parseArgs(argv);
|
|
72
81
|
const [command, name] = positional;
|
|
82
|
+
if (flags.version && !command) { assertFlags(flags, new Set(['version'])); write(`${VERSION}\n`); return; }
|
|
73
83
|
if (!command || flags.help) { write(HELP); return; }
|
|
84
|
+
if (command === 'auth') {
|
|
85
|
+
if (positional.length !== 2) throw new Error('Use ultrastiching auth status|sync|configure');
|
|
86
|
+
assertFlags(flags, new Set(name === 'configure' ? ['replace', 'compact'] : name === 'sync' ? ['from_file', 'compact'] : ['compact']));
|
|
87
|
+
if (name === 'configure') {
|
|
88
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error('请在交互式终端运行 ultrastiching auth configure;Key 不能从参数或管道传入。');
|
|
89
|
+
emit(write, await configureCredential({ replace: !!flags.replace }), flags.compact);
|
|
90
|
+
} else if (name === 'status') emit(write, credentialStatus(), flags.compact);
|
|
91
|
+
else if (name === 'sync') emit(write, syncCredentialEnvironment({ fromFile: !!flags.from_file }), flags.compact);
|
|
92
|
+
else throw new Error('Use ultrastiching auth status|sync|configure');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (command === 'mcp') {
|
|
96
|
+
assertFlags(flags, new Set());
|
|
97
|
+
if (name !== 'headers' || positional.length !== 2) throw new Error('Use ultrastiching mcp headers (MCP client only)');
|
|
98
|
+
write(`${JSON.stringify(mcpHeaders())}\n`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
74
101
|
if (command === 'list') {
|
|
75
102
|
assertFlags(flags, new Set(['search', 'binary', 'verbose', 'compact']));
|
|
76
103
|
const items = searchEndpoints({ search: flags.search === undefined ? '' : requireValue(flags, 'search'), binary: !!flags.binary });
|
package/src/prompt.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { emitKeypressEvents } from "node:readline";
|
|
2
|
+
|
|
3
|
+
/** 本机输入隐藏 Key;取消时恢复终端状态,凭据不进入参数或 Shell 历史。 */
|
|
4
|
+
export function hiddenKey(input = process.stdin, output = process.stdout) {
|
|
5
|
+
emitKeypressEvents(input);
|
|
6
|
+
const previousRaw = input.isRaw;
|
|
7
|
+
input.setRawMode(true);
|
|
8
|
+
input.resume();
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
let value = "";
|
|
11
|
+
const finish = (error) => {
|
|
12
|
+
input.off("keypress", onKey); input.setRawMode(Boolean(previousRaw)); input.pause(); output.write("\n");
|
|
13
|
+
if (error) reject(error); else resolve(value.trim());
|
|
14
|
+
};
|
|
15
|
+
const onKey = (text, key = {}) => {
|
|
16
|
+
if (key.ctrl && key.name === "c") return finish(new Error("已取消,未保存。"));
|
|
17
|
+
if (key.name === "return" || key.name === "enter") return finish();
|
|
18
|
+
if (key.name === "backspace") value = Array.from(value).slice(0, -1).join("");
|
|
19
|
+
else if (text && !key.ctrl && !key.meta && !/[\x00-\x1f\x7f]/u.test(text)) value += text;
|
|
20
|
+
};
|
|
21
|
+
input.on("keypress", onKey);
|
|
22
|
+
output.write("粘贴 ultrastiching 数据 API Key(输入隐藏,Enter 确认,Ctrl+C 取消):");
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 拒绝占位、控制字符和误贴的空白;不臆测服务端 Key 的格式或长度。 */
|
|
27
|
+
export function validateKey(key) {
|
|
28
|
+
if (!key || /[\s\x00-\x1f\x7f]/u.test(key) || key === "YOUR_ULTRASTICHING_DATA_API_KEY") throw new Error("请填写完整的数据 API Key,不能包含空格或换行;未保存。");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 覆盖前展示用途;默认拒绝,取消或非交互环境不会隐式接受。 */
|
|
32
|
+
export async function confirmChange(message, input = process.stdin, output = process.stdout) {
|
|
33
|
+
if (!input.isTTY || !output.isTTY) throw new Error('请在交互式终端确认覆盖;Agent 应先向用户展示变更并取得确认。');
|
|
34
|
+
const { createInterface } = await import('node:readline');
|
|
35
|
+
const prompt = createInterface({ input, output });
|
|
36
|
+
const reply = await new Promise(resolve => {
|
|
37
|
+
prompt.question(message + ' [y/N] ', resolve);
|
|
38
|
+
prompt.once('SIGINT', () => resolve(''));
|
|
39
|
+
});
|
|
40
|
+
prompt.close();
|
|
41
|
+
return /^(y|yes)$/iu.test(reply.trim());
|
|
42
|
+
}
|