zentao-api 0.2.0-beta.2 → 0.2.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.
- package/README.md +1 -11
- package/dist/browser/zentao-api.global.js +2 -2
- package/dist/browser-global.d.ts +1 -0
- package/dist/browser-global.js +12 -0
- package/dist/browser.d.ts +1 -1
- package/dist/browser.js +4 -1
- package/dist/client/index.d.ts +118 -17
- package/dist/client/index.js +106 -19
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/misc/environment.js +5 -3
- package/dist/misc/errors.d.ts +25 -4
- package/dist/misc/errors.js +24 -4
- package/dist/misc/global-options.d.ts +15 -2
- package/dist/misc/global-options.js +15 -2
- package/dist/modules/registry.d.ts +70 -7
- package/dist/modules/registry.js +118 -39
- package/dist/modules/resolve.js +14 -11
- package/dist/profiles/index.d.ts +68 -6
- package/dist/profiles/index.js +142 -53
- package/dist/request/index.d.ts +10 -1
- package/dist/request/index.js +38 -9
- package/dist/types/index.d.ts +10 -5
- package/dist/utils/index.js +12 -1
- package/dist/version.d.ts +10 -0
- package/dist/version.js +12 -2
- package/package.json +12 -7
- package/dist/misc/browser-global.d.ts +0 -1
- package/dist/misc/browser-global.js +0 -8
package/dist/modules/registry.js
CHANGED
|
@@ -3,55 +3,70 @@ import { asArray } from '../utils/index.js';
|
|
|
3
3
|
import { BUILTIN_MODULES } from './generated.js';
|
|
4
4
|
export { BUILTIN_MODULES };
|
|
5
5
|
export const MODULES = BUILTIN_MODULES;
|
|
6
|
-
//
|
|
7
|
-
|
|
6
|
+
// 运行时注册表存放「深克隆 + 深冻结」后的模块定义:
|
|
7
|
+
// - 深克隆:避免用户后续修改自己的输入对象时污染注册表;
|
|
8
|
+
// - 深冻结:让 getModule / getModuleAction 可以零拷贝返回引用,
|
|
9
|
+
// 外部尝试改写会在严格模式下抛 TypeError,开销也降到 O(1) 查询。
|
|
10
|
+
let modules = freezeModules(deepClone(BUILTIN_MODULES));
|
|
8
11
|
let moduleMap = buildModuleMap(modules);
|
|
9
|
-
function
|
|
12
|
+
function deepClone(value) {
|
|
10
13
|
if (Array.isArray(value)) {
|
|
11
|
-
return value.map(
|
|
14
|
+
return value.map((item) => deepClone(item));
|
|
12
15
|
}
|
|
13
|
-
if (value && typeof value === 'object') {
|
|
16
|
+
if (value && typeof value === 'object' && !(value instanceof Function)) {
|
|
14
17
|
const result = {};
|
|
15
18
|
for (const [key, nestedValue] of Object.entries(value)) {
|
|
16
|
-
result[key] =
|
|
19
|
+
result[key] = deepClone(nestedValue);
|
|
17
20
|
}
|
|
18
21
|
return result;
|
|
19
22
|
}
|
|
20
23
|
return value;
|
|
21
24
|
}
|
|
22
|
-
function
|
|
23
|
-
|
|
25
|
+
function deepFreeze(value) {
|
|
26
|
+
if (value === null || typeof value !== 'object')
|
|
27
|
+
return value;
|
|
28
|
+
if (Object.isFrozen(value))
|
|
29
|
+
return value;
|
|
30
|
+
for (const key of Object.keys(value)) {
|
|
31
|
+
deepFreeze(value[key]);
|
|
32
|
+
}
|
|
33
|
+
return Object.freeze(value);
|
|
34
|
+
}
|
|
35
|
+
function freezeAction(action) {
|
|
36
|
+
return deepFreeze(action);
|
|
37
|
+
}
|
|
38
|
+
function freezeModule(module) {
|
|
39
|
+
module.actions.forEach(freezeAction);
|
|
40
|
+
return deepFreeze(module);
|
|
24
41
|
}
|
|
25
|
-
function
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
actions: cloneActions(module.actions),
|
|
29
|
-
}));
|
|
42
|
+
function freezeModules(source) {
|
|
43
|
+
source.forEach(freezeModule);
|
|
44
|
+
return source;
|
|
30
45
|
}
|
|
31
46
|
function findActionIndex(source, actionName) {
|
|
32
47
|
const key = actionName.toLowerCase();
|
|
33
48
|
return source.findIndex((action) => String(action.name).toLowerCase() === key);
|
|
34
49
|
}
|
|
35
50
|
function mergeActions(base, extension) {
|
|
36
|
-
const next =
|
|
51
|
+
const next = base.slice();
|
|
37
52
|
for (const action of extension) {
|
|
38
53
|
const index = findActionIndex(next, String(action.name));
|
|
39
|
-
const
|
|
54
|
+
const frozen = freezeAction(deepClone(action));
|
|
40
55
|
if (index >= 0) {
|
|
41
|
-
next[index] =
|
|
56
|
+
next[index] = frozen;
|
|
42
57
|
}
|
|
43
58
|
else {
|
|
44
|
-
next.push(
|
|
59
|
+
next.push(frozen);
|
|
45
60
|
}
|
|
46
61
|
}
|
|
47
62
|
return next;
|
|
48
63
|
}
|
|
49
64
|
function mergeModule(base, extension) {
|
|
50
|
-
return {
|
|
65
|
+
return freezeModule({
|
|
51
66
|
...base,
|
|
52
|
-
...extension,
|
|
67
|
+
...deepClone(extension),
|
|
53
68
|
actions: mergeActions(base.actions, extension.actions),
|
|
54
|
-
};
|
|
69
|
+
});
|
|
55
70
|
}
|
|
56
71
|
function buildModuleMap(source) {
|
|
57
72
|
return new Map(source.map((module) => [module.name.toLowerCase(), module]));
|
|
@@ -69,74 +84,138 @@ function validateAction(action) {
|
|
|
69
84
|
throw new ZentaoError('E_INVALID_ACTION_DEFINITION');
|
|
70
85
|
}
|
|
71
86
|
}
|
|
72
|
-
/**
|
|
87
|
+
/**
|
|
88
|
+
* 注册或扩展模块定义。
|
|
89
|
+
*
|
|
90
|
+
* 行为细节:
|
|
91
|
+
* - 模块名匹配大小写不敏感。
|
|
92
|
+
* - 未知模块直接追加到注册表末尾。
|
|
93
|
+
* - 已存在的模块默认按 `mergeModule` 合并:模块元数据浅合并、动作按名同名替换/未知追加;
|
|
94
|
+
* `options.replace` 为 `true` 时整体替换。
|
|
95
|
+
* - 所有写入都会做深克隆 + 深冻结:调用方后续修改自己的对象不会污染注册表,注册表也不可被外部改写。
|
|
96
|
+
*
|
|
97
|
+
* @param input - 单个或一组模块定义。
|
|
98
|
+
* @param options - 写入策略,参见 {@link DefineModulesOptions}。
|
|
99
|
+
* @throws {ZentaoError} `E_INVALID_MODULE_DEFINITION` —— 缺少 `name` 或 `actions` 字段。
|
|
100
|
+
*/
|
|
73
101
|
export function defineModules(input, options = {}) {
|
|
74
102
|
for (const module of asArray(input)) {
|
|
75
103
|
validateModule(module);
|
|
76
104
|
const key = module.name.toLowerCase();
|
|
77
105
|
const index = modules.findIndex((item) => item.name.toLowerCase() === key);
|
|
78
|
-
const next = { ...cloneValue(module), actions: cloneActions(module.actions) };
|
|
79
106
|
if (index >= 0) {
|
|
80
|
-
modules[index] = options.replace
|
|
107
|
+
modules[index] = options.replace
|
|
108
|
+
? freezeModule(deepClone(module))
|
|
109
|
+
: mergeModule(modules[index], module);
|
|
81
110
|
}
|
|
82
111
|
else {
|
|
83
|
-
modules.push(
|
|
112
|
+
modules.push(freezeModule(deepClone(module)));
|
|
84
113
|
}
|
|
85
114
|
}
|
|
86
115
|
rebuildMap();
|
|
87
116
|
}
|
|
88
|
-
/**
|
|
117
|
+
/**
|
|
118
|
+
* 为已存在的模块追加或覆盖动作。
|
|
119
|
+
*
|
|
120
|
+
* 不做深度合并:同名动作整体替换,未知动作追加。这避免在 schema、参数数组等字段上出现隐式合并规则。
|
|
121
|
+
*
|
|
122
|
+
* @param moduleName - 目标模块名(大小写不敏感)。
|
|
123
|
+
* @param input - 单个或一组动作定义。
|
|
124
|
+
* @throws {ZentaoError} `E_INVALID_MODULE`(模块未注册)或 `E_INVALID_ACTION_DEFINITION`
|
|
125
|
+
* (动作缺少 `name` / `path` / `method` 等必填字段)。
|
|
126
|
+
*/
|
|
89
127
|
export function defineModuleActions(moduleName, input) {
|
|
90
|
-
const
|
|
128
|
+
const key = moduleName.toLowerCase();
|
|
129
|
+
const module = moduleMap.get(key);
|
|
91
130
|
if (!module) {
|
|
92
131
|
throw new ZentaoError('E_INVALID_MODULE', { module: moduleName });
|
|
93
132
|
}
|
|
133
|
+
const actions = module.actions.slice();
|
|
94
134
|
for (const action of asArray(input)) {
|
|
95
135
|
validateAction(action);
|
|
96
|
-
const index = findActionIndex(
|
|
97
|
-
const
|
|
136
|
+
const index = findActionIndex(actions, String(action.name));
|
|
137
|
+
const frozen = freezeAction(deepClone(action));
|
|
98
138
|
// 同名动作替换,未知动作追加;不做深度合并,避免 schema/数组字段出现隐式规则。
|
|
99
139
|
if (index >= 0) {
|
|
100
|
-
|
|
140
|
+
actions[index] = frozen;
|
|
101
141
|
}
|
|
102
142
|
else {
|
|
103
|
-
|
|
143
|
+
actions.push(frozen);
|
|
104
144
|
}
|
|
105
145
|
}
|
|
146
|
+
const nextModule = freezeModule({ ...module, actions });
|
|
147
|
+
const index = modules.findIndex((item) => item.name.toLowerCase() === key);
|
|
148
|
+
modules[index] = nextModule;
|
|
149
|
+
rebuildMap();
|
|
106
150
|
}
|
|
107
|
-
/**
|
|
151
|
+
/**
|
|
152
|
+
* 获取模块定义。
|
|
153
|
+
*
|
|
154
|
+
* 模块名匹配大小写不敏感。返回值是注册表内部的已深冻结引用(O(1) 查询、零拷贝),
|
|
155
|
+
* 任何写入尝试在严格模式下会抛 `TypeError`;如需修改请使用 {@link defineModules}。
|
|
156
|
+
*
|
|
157
|
+
* @param moduleName - 模块名。
|
|
158
|
+
* @returns 已注册的模块定义。
|
|
159
|
+
* @throws {ZentaoError} `E_INVALID_MODULE` —— 模块未注册。
|
|
160
|
+
*/
|
|
108
161
|
export function getModule(moduleName) {
|
|
109
162
|
const module = moduleMap.get(moduleName.toLowerCase());
|
|
110
163
|
if (!module) {
|
|
111
164
|
throw new ZentaoError('E_INVALID_MODULE', { module: moduleName });
|
|
112
165
|
}
|
|
113
|
-
return
|
|
114
|
-
}
|
|
115
|
-
/**
|
|
166
|
+
return module;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* 获取指定模块下的某个动作。
|
|
170
|
+
*
|
|
171
|
+
* 解析顺序:
|
|
172
|
+
* 1. `actionName === 'ls'` 时映射为 `list`(仅作为别名,不会修改注册表)。
|
|
173
|
+
* 2. 在该模块的动作中按名称大小写不敏感匹配。
|
|
174
|
+
* 3. 当请求的动作不是基础 CRUD(`list`/`get`/`create`/`update`/`delete`)时,
|
|
175
|
+
* 额外允许命中 `type === 'action'` 的自定义动作(即使名字不在基础 CRUD 中)。
|
|
176
|
+
*
|
|
177
|
+
* 返回值同样是已深冻结的引用,请勿尝试修改。
|
|
178
|
+
*
|
|
179
|
+
* @param moduleName - 模块名(大小写不敏感)。
|
|
180
|
+
* @param actionName - 动作名(大小写不敏感);支持 `ls` 作为 `list` 的别名。
|
|
181
|
+
* @returns 匹配到的动作定义。
|
|
182
|
+
* @throws {ZentaoError} `E_INVALID_MODULE`(模块未注册)或 `E_INVALID_ACTION`(动作不存在)。
|
|
183
|
+
*/
|
|
116
184
|
export function getModuleAction(moduleName, actionName) {
|
|
117
185
|
const module = getModule(moduleName);
|
|
118
186
|
const normalized = actionName === 'ls' ? 'list' : actionName;
|
|
119
187
|
const direct = module.actions.find((action) => String(action.name).toLowerCase() === normalized.toLowerCase());
|
|
120
188
|
if (direct)
|
|
121
|
-
return
|
|
189
|
+
return direct;
|
|
122
190
|
const crud = new Set(['list', 'get', 'create', 'update', 'delete']);
|
|
123
191
|
if (!crud.has(normalized)) {
|
|
124
192
|
const custom = module.actions.find((action) => action.type === 'action' && String(action.name).toLowerCase() === normalized.toLowerCase());
|
|
125
193
|
if (custom)
|
|
126
|
-
return
|
|
194
|
+
return custom;
|
|
127
195
|
}
|
|
128
196
|
throw new ZentaoError('E_INVALID_ACTION', { module: moduleName, action: actionName });
|
|
129
197
|
}
|
|
130
|
-
/**
|
|
198
|
+
/**
|
|
199
|
+
* 返回当前运行时注册表中的所有模块名。
|
|
200
|
+
*
|
|
201
|
+
* 顺序与模块写入注册表的顺序一致;包括内置模块和通过 {@link defineModules} 追加的用户模块。
|
|
202
|
+
*
|
|
203
|
+
* @returns 模块名数组(保留原始大小写)。
|
|
204
|
+
*/
|
|
131
205
|
export function getModuleNames() {
|
|
132
206
|
return modules.map((module) => module.name);
|
|
133
207
|
}
|
|
134
|
-
/**
|
|
208
|
+
/**
|
|
209
|
+
* 判断模块名是否已注册。
|
|
210
|
+
*
|
|
211
|
+
* @param moduleName - 模块名;匹配大小写不敏感。
|
|
212
|
+
* @returns 已注册返回 `true`,否则 `false`。
|
|
213
|
+
*/
|
|
135
214
|
export function isModuleName(moduleName) {
|
|
136
215
|
return moduleMap.has(moduleName.toLowerCase());
|
|
137
216
|
}
|
|
138
217
|
/** @internal */
|
|
139
218
|
export function resetModuleDefinitions() {
|
|
140
|
-
modules =
|
|
219
|
+
modules = freezeModules(deepClone(BUILTIN_MODULES));
|
|
141
220
|
rebuildMap();
|
|
142
221
|
}
|
package/dist/modules/resolve.js
CHANGED
|
@@ -45,10 +45,12 @@ function parseData(value) {
|
|
|
45
45
|
}
|
|
46
46
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
47
47
|
}
|
|
48
|
+
const TRUTHY_STRINGS = new Set(['true', '1', 'yes', 'on']);
|
|
49
|
+
const FALSY_STRINGS = new Set(['false', '0', 'no', 'off']);
|
|
48
50
|
/** 按 OpenAPI schema 的基础类型对参数做轻量转换。 */
|
|
49
|
-
function coerceValue(value, type) {
|
|
50
|
-
if (value === undefined)
|
|
51
|
-
return
|
|
51
|
+
function coerceValue(value, type, paramName) {
|
|
52
|
+
if (value === undefined || value === null)
|
|
53
|
+
return value;
|
|
52
54
|
if (type === 'number' || type === 'integer') {
|
|
53
55
|
const numberValue = Number(value);
|
|
54
56
|
return Number.isNaN(numberValue) ? value : numberValue;
|
|
@@ -61,17 +63,17 @@ function coerceValue(value, type) {
|
|
|
61
63
|
return true;
|
|
62
64
|
if (value === 0)
|
|
63
65
|
return false;
|
|
64
|
-
|
|
66
|
+
throw new ZentaoError('E_INVALID_PARAM', { param: paramName, value: String(value) });
|
|
65
67
|
}
|
|
66
68
|
if (typeof value === 'string') {
|
|
67
69
|
const normalized = value.trim().toLowerCase();
|
|
68
|
-
if (
|
|
70
|
+
if (TRUTHY_STRINGS.has(normalized))
|
|
69
71
|
return true;
|
|
70
|
-
if (
|
|
72
|
+
if (FALSY_STRINGS.has(normalized))
|
|
71
73
|
return false;
|
|
72
|
-
|
|
74
|
+
throw new ZentaoError('E_INVALID_PARAM', { param: paramName, value });
|
|
73
75
|
}
|
|
74
|
-
|
|
76
|
+
throw new ZentaoError('E_INVALID_PARAM', { param: paramName, value: String(value) });
|
|
75
77
|
}
|
|
76
78
|
return value;
|
|
77
79
|
}
|
|
@@ -141,12 +143,13 @@ export function resolveModuleCommand(module, actionName, params = {}) {
|
|
|
141
143
|
for (const [key, property] of Object.entries(schema.properties ?? {})) {
|
|
142
144
|
// body 字段优先级:params.data 中的字段 > 平铺 params 字段 > schema 默认值。
|
|
143
145
|
const hasDataValue = Object.prototype.hasOwnProperty.call(data, key);
|
|
144
|
-
|
|
146
|
+
const hasParamValue = Object.prototype.hasOwnProperty.call(params, key);
|
|
147
|
+
let value = hasDataValue ? data[key] : hasParamValue ? params[key] : property.defaultValue;
|
|
145
148
|
if (value === undefined && (property.required || required.has(key))) {
|
|
146
149
|
throw new ZentaoError('E_MISSING_PARAM', { param: key });
|
|
147
150
|
}
|
|
148
|
-
value = coerceValue(value, property.type);
|
|
149
|
-
if (property.type === 'array' && value !== undefined && !Array.isArray(value)) {
|
|
151
|
+
value = coerceValue(value, property.type, key);
|
|
152
|
+
if (property.type === 'array' && value !== undefined && value !== null && !Array.isArray(value)) {
|
|
150
153
|
if (typeof value === 'string') {
|
|
151
154
|
value = value.split(',');
|
|
152
155
|
}
|
package/dist/profiles/index.d.ts
CHANGED
|
@@ -1,14 +1,76 @@
|
|
|
1
1
|
import type { ZentaoProfile, ZentaoProfileRecord } from '../types/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* 浏览器环境下用于在 `localStorage` 中保存 profile 数据的 key。
|
|
4
|
+
*
|
|
5
|
+
* Node.js 环境会改用文件 `~/.config/zentao/zentao.json`,与此常量无关。
|
|
6
|
+
*/
|
|
2
7
|
export declare const ZENTAO_PROFILES_STORAGE_KEY = "ZENTAO_PROFILES";
|
|
3
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* 根据 profile 的账号和禅道站点地址生成稳定 key。
|
|
10
|
+
*
|
|
11
|
+
* Key 格式为 `account@server`,其中 `server` 会经过 {@link normalizeSiteUrl} 规范化,
|
|
12
|
+
* 因此即使传入末尾带 `/` 或 `/api.php/v2` 的地址,也会得到一致的结果。
|
|
13
|
+
*
|
|
14
|
+
* @param profile - 只需要包含 `account` 和 `server` 两个字段。
|
|
15
|
+
* @returns 形如 `admin@https://zentao.example.com` 的 profile key。
|
|
16
|
+
* @throws {ZentaoError} `E_INVALID_PROFILE`(账号为空白)或 `E_INVALID_BASE_URL`(`server` 不合法)。
|
|
17
|
+
*/
|
|
4
18
|
export declare function getProfileKey(profile: Pick<ZentaoProfile, 'account' | 'server'>): string;
|
|
5
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* 列出本地保存的所有 profile。
|
|
21
|
+
*
|
|
22
|
+
* Node.js 下从 `~/.config/zentao/zentao.json` 读取;浏览器下从 `localStorage` 读取。
|
|
23
|
+
* 读取过程不会写回存储;存储中无法解析的条目会被静默忽略,不会影响其余 profile。
|
|
24
|
+
*
|
|
25
|
+
* @returns 当前存储中的所有 profile(带 `key` 字段),文件不存在时返回空数组。
|
|
26
|
+
* @throws {ZentaoError} `E_PROFILE_STORAGE_INVALID`(存储内容不是合法 JSON)或
|
|
27
|
+
* `E_PROFILE_STORAGE_UNAVAILABLE`(运行时无法访问存储)。
|
|
28
|
+
*/
|
|
6
29
|
export declare function getAllProfiles(): Promise<ZentaoProfileRecord[]>;
|
|
7
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* 获取指定 profile。
|
|
32
|
+
*
|
|
33
|
+
* @param profileKey - 可选的 profile key(`account@server`);不传时返回当前(最近一次切换的)profile。
|
|
34
|
+
* @returns 命中的 profile(带 `key` 字段);当 key 不存在或尚未配置当前 profile 时返回 `undefined`。
|
|
35
|
+
* @throws {ZentaoError} `E_PROFILE_STORAGE_INVALID` / `E_PROFILE_STORAGE_UNAVAILABLE`。
|
|
36
|
+
*/
|
|
8
37
|
export declare function getProfile(profileKey?: string): Promise<ZentaoProfileRecord | undefined>;
|
|
9
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* 添加或覆盖一个本地 profile,并把它设置为当前使用的 profile。
|
|
40
|
+
*
|
|
41
|
+
* 行为细节:
|
|
42
|
+
* - 同 key(`account@server`)已存在时会**整体覆盖**而非合并字段。
|
|
43
|
+
* - 写入时会自动补齐 `loginTime` 与 `lastUsedTime`(若调用方未提供则使用当前 ISO 时间)。
|
|
44
|
+
* - 操作通过进程内串行锁保护 read-modify-write,避免并发调用导致的 lost update;跨进程并发不在保证范围。
|
|
45
|
+
* - 实际写入使用临时文件 + `rename` 的原子方式,并将文件与目录权限收紧到 `0600`/`0700`(Node.js 下)。
|
|
46
|
+
*
|
|
47
|
+
* @param profile - 要写入的 profile,必须至少包含 `server`、`account`、`token`。
|
|
48
|
+
* @returns 实际写入并附带 `key` 字段的 profile 记录。
|
|
49
|
+
* @throws {ZentaoError} `E_INVALID_PROFILE`(必填字段缺失或 token 为空白)、
|
|
50
|
+
* `E_INVALID_BASE_URL`、`E_PROFILE_STORAGE_INVALID`、`E_PROFILE_STORAGE_UNAVAILABLE`。
|
|
51
|
+
*/
|
|
10
52
|
export declare function addProfile(profile: ZentaoProfile): Promise<ZentaoProfileRecord>;
|
|
11
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* 删除指定 profile。
|
|
55
|
+
*
|
|
56
|
+
* 若被删除的是当前 profile,会回退为列表中最近一次写入的 profile;若已无任何 profile,
|
|
57
|
+
* 当前 profile 会被清空。操作同样通过进程内串行锁保护。
|
|
58
|
+
*
|
|
59
|
+
* @param profileKey - 要删除的 profile key。
|
|
60
|
+
* @returns 当且仅当确实删除了某条记录时返回 `true`;key 不存在时返回 `false` 且不会写盘。
|
|
61
|
+
* @throws {ZentaoError} `E_PROFILE_STORAGE_INVALID` / `E_PROFILE_STORAGE_UNAVAILABLE`。
|
|
62
|
+
*/
|
|
12
63
|
export declare function deleteProfile(profileKey: string): Promise<boolean>;
|
|
13
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* 切换当前使用的 profile,并刷新其 `lastUsedTime`。
|
|
66
|
+
*
|
|
67
|
+
* 不传 `profileKey` 时使用当前 profile(相当于把当前 profile 的 `lastUsedTime` 刷新一遍)。
|
|
68
|
+
* 切换成功后会立即写回存储,由进程内串行锁保护。
|
|
69
|
+
*
|
|
70
|
+
* @param profileKey - 可选的目标 profile key;不传则使用当前 profile。
|
|
71
|
+
* @returns 切换后生效的 profile 记录(带 `key` 字段)。
|
|
72
|
+
* @throws {ZentaoError} `E_NO_PROFILE`(未配置任何当前 profile 且未传 key)、
|
|
73
|
+
* `E_PROFILE_NOT_FOUND`(目标 key 不存在)、`E_PROFILE_STORAGE_INVALID` /
|
|
74
|
+
* `E_PROFILE_STORAGE_UNAVAILABLE`。
|
|
75
|
+
*/
|
|
14
76
|
export declare function switchProfile(profileKey?: string): Promise<ZentaoProfileRecord>;
|
package/dist/profiles/index.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { ZentaoError } from '../misc/errors.js';
|
|
2
2
|
import { isNodeRuntime } from '../misc/environment.js';
|
|
3
3
|
import { normalizeSiteUrl } from '../utils/index.js';
|
|
4
|
+
/**
|
|
5
|
+
* 浏览器环境下用于在 `localStorage` 中保存 profile 数据的 key。
|
|
6
|
+
*
|
|
7
|
+
* Node.js 环境会改用文件 `~/.config/zentao/zentao.json`,与此常量无关。
|
|
8
|
+
*/
|
|
4
9
|
export const ZENTAO_PROFILES_STORAGE_KEY = 'ZENTAO_PROFILES';
|
|
5
10
|
const PROFILE_FILE_PARTS = ['.config', 'zentao', 'zentao.json'];
|
|
6
11
|
function isRecord(value) {
|
|
@@ -14,9 +19,19 @@ function cloneJson(value) {
|
|
|
14
19
|
function nowString() {
|
|
15
20
|
return new Date().toISOString();
|
|
16
21
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
// 通过函数参数间接化 `import(specifier)`,避免打包器把 Node 内置模块拉进
|
|
23
|
+
// 浏览器 bundle;同时不依赖 `new Function`/`eval`,对严格 CSP 友好。
|
|
24
|
+
function importNodeModule(specifier) {
|
|
25
|
+
return import(specifier);
|
|
26
|
+
}
|
|
27
|
+
// 进程内串行锁:所有 read-modify-write 类的 profile 操作都通过这个队列,
|
|
28
|
+
// 避免并发 `addProfile`/`switchProfile` 出现 lost update(写文件本身是原子
|
|
29
|
+
// rename,但 read→modify→write 之间没有跨步保护)。跨进程并发不在保证范围内。
|
|
30
|
+
let storeMutex = Promise.resolve();
|
|
31
|
+
function withStoreMutex(operation) {
|
|
32
|
+
const next = storeMutex.then(operation, operation);
|
|
33
|
+
storeMutex = next.catch(() => undefined);
|
|
34
|
+
return next;
|
|
20
35
|
}
|
|
21
36
|
function getBrowserStorage() {
|
|
22
37
|
try {
|
|
@@ -111,8 +126,19 @@ async function writeStore(store) {
|
|
|
111
126
|
const fs = await importNodeModule('node:fs/promises');
|
|
112
127
|
const path = await importNodeModule('node:path');
|
|
113
128
|
const file = await getProfileFilePath();
|
|
114
|
-
|
|
115
|
-
|
|
129
|
+
const dir = path.dirname(file);
|
|
130
|
+
const tempFile = path.join(dir, `.zentao.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`);
|
|
131
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
132
|
+
await fs.chmod(dir, 0o700).catch(() => undefined);
|
|
133
|
+
try {
|
|
134
|
+
await fs.writeFile(tempFile, text, { encoding: 'utf8', mode: 0o600 });
|
|
135
|
+
await fs.rename(tempFile, file);
|
|
136
|
+
await fs.chmod(file, 0o600).catch(() => undefined);
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
await fs.rm(tempFile, { force: true }).catch(() => undefined);
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
116
142
|
return;
|
|
117
143
|
}
|
|
118
144
|
const storage = getBrowserStorage();
|
|
@@ -137,16 +163,40 @@ function setFallbackCurrentProfile(store) {
|
|
|
137
163
|
store.currentProfile = fallback ? getProfileKey(fallback) : undefined;
|
|
138
164
|
}
|
|
139
165
|
}
|
|
140
|
-
/**
|
|
166
|
+
/**
|
|
167
|
+
* 根据 profile 的账号和禅道站点地址生成稳定 key。
|
|
168
|
+
*
|
|
169
|
+
* Key 格式为 `account@server`,其中 `server` 会经过 {@link normalizeSiteUrl} 规范化,
|
|
170
|
+
* 因此即使传入末尾带 `/` 或 `/api.php/v2` 的地址,也会得到一致的结果。
|
|
171
|
+
*
|
|
172
|
+
* @param profile - 只需要包含 `account` 和 `server` 两个字段。
|
|
173
|
+
* @returns 形如 `admin@https://zentao.example.com` 的 profile key。
|
|
174
|
+
* @throws {ZentaoError} `E_INVALID_PROFILE`(账号为空白)或 `E_INVALID_BASE_URL`(`server` 不合法)。
|
|
175
|
+
*/
|
|
141
176
|
export function getProfileKey(profile) {
|
|
142
177
|
return profileKeyFromParts(profile.account, profile.server);
|
|
143
178
|
}
|
|
144
|
-
/**
|
|
179
|
+
/**
|
|
180
|
+
* 列出本地保存的所有 profile。
|
|
181
|
+
*
|
|
182
|
+
* Node.js 下从 `~/.config/zentao/zentao.json` 读取;浏览器下从 `localStorage` 读取。
|
|
183
|
+
* 读取过程不会写回存储;存储中无法解析的条目会被静默忽略,不会影响其余 profile。
|
|
184
|
+
*
|
|
185
|
+
* @returns 当前存储中的所有 profile(带 `key` 字段),文件不存在时返回空数组。
|
|
186
|
+
* @throws {ZentaoError} `E_PROFILE_STORAGE_INVALID`(存储内容不是合法 JSON)或
|
|
187
|
+
* `E_PROFILE_STORAGE_UNAVAILABLE`(运行时无法访问存储)。
|
|
188
|
+
*/
|
|
145
189
|
export async function getAllProfiles() {
|
|
146
190
|
const store = await readStore();
|
|
147
191
|
return store.profiles.map(toRecord);
|
|
148
192
|
}
|
|
149
|
-
/**
|
|
193
|
+
/**
|
|
194
|
+
* 获取指定 profile。
|
|
195
|
+
*
|
|
196
|
+
* @param profileKey - 可选的 profile key(`account@server`);不传时返回当前(最近一次切换的)profile。
|
|
197
|
+
* @returns 命中的 profile(带 `key` 字段);当 key 不存在或尚未配置当前 profile 时返回 `undefined`。
|
|
198
|
+
* @throws {ZentaoError} `E_PROFILE_STORAGE_INVALID` / `E_PROFILE_STORAGE_UNAVAILABLE`。
|
|
199
|
+
*/
|
|
150
200
|
export async function getProfile(profileKey) {
|
|
151
201
|
const store = await readStore();
|
|
152
202
|
const key = profileKey ?? store.currentProfile;
|
|
@@ -155,51 +205,90 @@ export async function getProfile(profileKey) {
|
|
|
155
205
|
const profile = findProfile(store, key);
|
|
156
206
|
return profile ? toRecord(profile) : undefined;
|
|
157
207
|
}
|
|
158
|
-
/**
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
208
|
+
/**
|
|
209
|
+
* 添加或覆盖一个本地 profile,并把它设置为当前使用的 profile。
|
|
210
|
+
*
|
|
211
|
+
* 行为细节:
|
|
212
|
+
* - 同 key(`account@server`)已存在时会**整体覆盖**而非合并字段。
|
|
213
|
+
* - 写入时会自动补齐 `loginTime` 与 `lastUsedTime`(若调用方未提供则使用当前 ISO 时间)。
|
|
214
|
+
* - 操作通过进程内串行锁保护 read-modify-write,避免并发调用导致的 lost update;跨进程并发不在保证范围。
|
|
215
|
+
* - 实际写入使用临时文件 + `rename` 的原子方式,并将文件与目录权限收紧到 `0600`/`0700`(Node.js 下)。
|
|
216
|
+
*
|
|
217
|
+
* @param profile - 要写入的 profile,必须至少包含 `server`、`account`、`token`。
|
|
218
|
+
* @returns 实际写入并附带 `key` 字段的 profile 记录。
|
|
219
|
+
* @throws {ZentaoError} `E_INVALID_PROFILE`(必填字段缺失或 token 为空白)、
|
|
220
|
+
* `E_INVALID_BASE_URL`、`E_PROFILE_STORAGE_INVALID`、`E_PROFILE_STORAGE_UNAVAILABLE`。
|
|
221
|
+
*/
|
|
222
|
+
export function addProfile(profile) {
|
|
223
|
+
return withStoreMutex(async () => {
|
|
224
|
+
const store = await readStore();
|
|
225
|
+
const timestamp = nowString();
|
|
226
|
+
const normalized = normalizeProfile({
|
|
227
|
+
...profile,
|
|
228
|
+
loginTime: profile.loginTime ?? timestamp,
|
|
229
|
+
lastUsedTime: profile.lastUsedTime ?? timestamp,
|
|
230
|
+
});
|
|
231
|
+
const profileKey = getProfileKey(normalized);
|
|
232
|
+
const index = store.profiles.findIndex((item) => getProfileKey(item) === profileKey);
|
|
233
|
+
if (index >= 0) {
|
|
234
|
+
store.profiles[index] = normalized;
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
store.profiles.push(normalized);
|
|
238
|
+
}
|
|
239
|
+
store.currentProfile = profileKey;
|
|
240
|
+
await writeStore(store);
|
|
241
|
+
return toRecord(normalized);
|
|
166
242
|
});
|
|
167
|
-
const profileKey = getProfileKey(normalized);
|
|
168
|
-
const index = store.profiles.findIndex((item) => getProfileKey(item) === profileKey);
|
|
169
|
-
if (index >= 0) {
|
|
170
|
-
store.profiles[index] = normalized;
|
|
171
|
-
}
|
|
172
|
-
else {
|
|
173
|
-
store.profiles.push(normalized);
|
|
174
|
-
}
|
|
175
|
-
store.currentProfile = profileKey;
|
|
176
|
-
await writeStore(store);
|
|
177
|
-
return toRecord(normalized);
|
|
178
243
|
}
|
|
179
|
-
/**
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
244
|
+
/**
|
|
245
|
+
* 删除指定 profile。
|
|
246
|
+
*
|
|
247
|
+
* 若被删除的是当前 profile,会回退为列表中最近一次写入的 profile;若已无任何 profile,
|
|
248
|
+
* 当前 profile 会被清空。操作同样通过进程内串行锁保护。
|
|
249
|
+
*
|
|
250
|
+
* @param profileKey - 要删除的 profile key。
|
|
251
|
+
* @returns 当且仅当确实删除了某条记录时返回 `true`;key 不存在时返回 `false` 且不会写盘。
|
|
252
|
+
* @throws {ZentaoError} `E_PROFILE_STORAGE_INVALID` / `E_PROFILE_STORAGE_UNAVAILABLE`。
|
|
253
|
+
*/
|
|
254
|
+
export function deleteProfile(profileKey) {
|
|
255
|
+
return withStoreMutex(async () => {
|
|
256
|
+
const store = await readStore();
|
|
257
|
+
const nextProfiles = store.profiles.filter((profile) => getProfileKey(profile) !== profileKey);
|
|
258
|
+
if (nextProfiles.length === store.profiles.length)
|
|
259
|
+
return false;
|
|
260
|
+
store.profiles = nextProfiles;
|
|
261
|
+
setFallbackCurrentProfile(store);
|
|
262
|
+
await writeStore(store);
|
|
263
|
+
return true;
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* 切换当前使用的 profile,并刷新其 `lastUsedTime`。
|
|
268
|
+
*
|
|
269
|
+
* 不传 `profileKey` 时使用当前 profile(相当于把当前 profile 的 `lastUsedTime` 刷新一遍)。
|
|
270
|
+
* 切换成功后会立即写回存储,由进程内串行锁保护。
|
|
271
|
+
*
|
|
272
|
+
* @param profileKey - 可选的目标 profile key;不传则使用当前 profile。
|
|
273
|
+
* @returns 切换后生效的 profile 记录(带 `key` 字段)。
|
|
274
|
+
* @throws {ZentaoError} `E_NO_PROFILE`(未配置任何当前 profile 且未传 key)、
|
|
275
|
+
* `E_PROFILE_NOT_FOUND`(目标 key 不存在)、`E_PROFILE_STORAGE_INVALID` /
|
|
276
|
+
* `E_PROFILE_STORAGE_UNAVAILABLE`。
|
|
277
|
+
*/
|
|
278
|
+
export function switchProfile(profileKey) {
|
|
279
|
+
return withStoreMutex(async () => {
|
|
280
|
+
const store = await readStore();
|
|
281
|
+
const key = profileKey ?? store.currentProfile;
|
|
282
|
+
if (!key) {
|
|
283
|
+
throw new ZentaoError('E_NO_PROFILE');
|
|
284
|
+
}
|
|
285
|
+
const profile = findProfile(store, key);
|
|
286
|
+
if (!profile) {
|
|
287
|
+
throw new ZentaoError('E_PROFILE_NOT_FOUND', { profileKey: key });
|
|
288
|
+
}
|
|
289
|
+
profile.lastUsedTime = nowString();
|
|
290
|
+
store.currentProfile = key;
|
|
291
|
+
await writeStore(store);
|
|
292
|
+
return toRecord(profile);
|
|
293
|
+
});
|
|
205
294
|
}
|
package/dist/request/index.d.ts
CHANGED
|
@@ -3,5 +3,14 @@ import type { RequestOptions, ResponseData } from '../types/index.js';
|
|
|
3
3
|
* 按模块动作名请求禅道 API。
|
|
4
4
|
*
|
|
5
5
|
* 选项优先级为:本次调用 options > 全局 options > 客户端默认值。
|
|
6
|
+
* 当响应 `status` 为 `"fail"` 时,默认按原样返回;若 `options.throwOnFail`
|
|
7
|
+
* 或全局 `throwOnFail` 为真,则改为抛出 `E_API_FAILED`。
|
|
8
|
+
*
|
|
9
|
+
* @typeParam T 期望的 `data` 字段类型;不传时为 `unknown`,调用方需要自行收窄。
|
|
10
|
+
* @param name - 模块动作名,例如 `product/list`。
|
|
11
|
+
* @param params - 请求参数。
|
|
12
|
+
* @param options - 请求选项。
|
|
13
|
+
* @returns 归一化后的禅道 API 响应。
|
|
14
|
+
* @throws {ZentaoError} 传输层错误、参数缺失或 `throwOnFail` 启用时的业务失败。
|
|
6
15
|
*/
|
|
7
|
-
export declare function request(name: `${string}/${string}`, params?: Record<string, unknown>, options?: RequestOptions): Promise<ResponseData
|
|
16
|
+
export declare function request<T = unknown>(name: `${string}/${string}`, params?: Record<string, unknown>, options?: RequestOptions): Promise<ResponseData<T>>;
|