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.
@@ -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
- let modules = cloneModules(BUILTIN_MODULES);
6
+ // 运行时注册表存放「深克隆 + 深冻结」后的模块定义:
7
+ // - 深克隆:避免用户后续修改自己的输入对象时污染注册表;
8
+ // - 深冻结:让 getModule / getModuleAction 可以零拷贝返回引用,
9
+ // 外部尝试改写会在严格模式下抛 TypeError,开销也降到 O(1) 查询。
10
+ let modules = freezeModules(deepClone(BUILTIN_MODULES));
8
11
  let moduleMap = buildModuleMap(modules);
9
- function cloneValue(value) {
12
+ function deepClone(value) {
10
13
  if (Array.isArray(value)) {
11
- return value.map(cloneValue);
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] = cloneValue(nestedValue);
19
+ result[key] = deepClone(nestedValue);
17
20
  }
18
21
  return result;
19
22
  }
20
23
  return value;
21
24
  }
22
- function cloneActions(source) {
23
- return source.map((action) => cloneValue(action));
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 cloneModules(source) {
26
- return source.map((module) => ({
27
- ...cloneValue(module),
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 = cloneActions(base);
51
+ const next = base.slice();
37
52
  for (const action of extension) {
38
53
  const index = findActionIndex(next, String(action.name));
39
- const clone = cloneValue(action);
54
+ const frozen = freezeAction(deepClone(action));
40
55
  if (index >= 0) {
41
- next[index] = clone;
56
+ next[index] = frozen;
42
57
  }
43
58
  else {
44
- next.push(clone);
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
- /** 定义或扩展模块;同名模块默认合并动作,`replace` 为真时整体替换,未知模块追加。 */
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 ? next : mergeModule(modules[index], module);
107
+ modules[index] = options.replace
108
+ ? freezeModule(deepClone(module))
109
+ : mergeModule(modules[index], module);
81
110
  }
82
111
  else {
83
- modules.push(next);
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 module = moduleMap.get(moduleName.toLowerCase());
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(module.actions, String(action.name));
97
- const next = cloneValue(action);
136
+ const index = findActionIndex(actions, String(action.name));
137
+ const frozen = freezeAction(deepClone(action));
98
138
  // 同名动作替换,未知动作追加;不做深度合并,避免 schema/数组字段出现隐式规则。
99
139
  if (index >= 0) {
100
- module.actions[index] = next;
140
+ actions[index] = frozen;
101
141
  }
102
142
  else {
103
- module.actions.push(next);
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
- /** 获取模块定义;模块不存在时抛出 {@link ZentaoError}。 */
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 cloneModules([module])[0];
114
- }
115
- /** 获取指定模块动作;`ls` 会作为 `list` 的别名处理。 */
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 cloneValue(direct);
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 cloneValue(custom);
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 = cloneModules(BUILTIN_MODULES);
219
+ modules = freezeModules(deepClone(BUILTIN_MODULES));
141
220
  rebuildMap();
142
221
  }
@@ -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 undefined;
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
- return value;
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 (['true', '1', 'yes', 'on'].includes(normalized))
70
+ if (TRUTHY_STRINGS.has(normalized))
69
71
  return true;
70
- if (['false', '0', 'no', 'off'].includes(normalized))
72
+ if (FALSY_STRINGS.has(normalized))
71
73
  return false;
72
- return value;
74
+ throw new ZentaoError('E_INVALID_PARAM', { param: paramName, value });
73
75
  }
74
- return value;
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
- let value = data[key] ?? params[key] ?? property.defaultValue;
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
  }
@@ -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
- /** 根据 profile 的账号和禅道地址生成稳定 key。 */
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
- /** 列出所有保存的本地 profile。 */
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
- /** 获取指定 profile;不传 key 时返回上次使用的 profile。 */
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
- /** 添加或覆盖一个本地 profile,并把它设置为当前使用的 profile。 */
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
- /** 删除指定 profile;返回是否实际删除了记录。 */
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
- /** 切换当前使用的 profile,并刷新最后使用时间;不传 key 时使用当前 profile。 */
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>;
@@ -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
- async function importNodeModule(specifier) {
18
- const dynamicImport = new Function('specifier', 'return import(specifier)');
19
- return dynamicImport(specifier);
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
- await fs.mkdir(path.dirname(file), { recursive: true });
115
- await fs.writeFile(file, text, 'utf8');
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
- /** 根据 profile 的账号和禅道地址生成稳定 key。 */
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
- /** 列出所有保存的本地 profile。 */
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
- /** 获取指定 profile;不传 key 时返回上次使用的 profile。 */
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
- /** 添加或覆盖一个本地 profile,并把它设置为当前使用的 profile。 */
159
- export async function addProfile(profile) {
160
- const store = await readStore();
161
- const timestamp = nowString();
162
- const normalized = normalizeProfile({
163
- ...profile,
164
- loginTime: profile.loginTime ?? timestamp,
165
- lastUsedTime: profile.lastUsedTime ?? timestamp,
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
- /** 删除指定 profile;返回是否实际删除了记录。 */
180
- export async function deleteProfile(profileKey) {
181
- const store = await readStore();
182
- const nextProfiles = store.profiles.filter((profile) => getProfileKey(profile) !== profileKey);
183
- if (nextProfiles.length === store.profiles.length)
184
- return false;
185
- store.profiles = nextProfiles;
186
- setFallbackCurrentProfile(store);
187
- await writeStore(store);
188
- return true;
189
- }
190
- /** 切换当前使用的 profile,并刷新最后使用时间;不传 key 时使用当前 profile。 */
191
- export async function switchProfile(profileKey) {
192
- const store = await readStore();
193
- const key = profileKey ?? store.currentProfile;
194
- if (!key) {
195
- throw new ZentaoError('E_NO_PROFILE');
196
- }
197
- const profile = findProfile(store, key);
198
- if (!profile) {
199
- throw new ZentaoError('E_PROFILE_NOT_FOUND', { profileKey: key });
200
- }
201
- profile.lastUsedTime = nowString();
202
- store.currentProfile = key;
203
- await writeStore(store);
204
- return toRecord(profile);
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
  }
@@ -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>>;