zentao-api 0.6.6 → 0.6.8

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.
@@ -20,6 +20,7 @@
20
20
  * ```ts
21
21
  * defineModuleActions('bug', {
22
22
  * name: 'assignTo',
23
+ * minVersion: ['22.0', 'biz13.0', 'max8.0', 'ipd5.0'],
23
24
  * display: '指派 Bug',
24
25
  * type: 'action',
25
26
  * method: 'put',
@@ -44,6 +45,7 @@
44
45
  * actions: [
45
46
  * {
46
47
  * name: 'list',
48
+ * minVersion: ['22.0', 'biz13.0', 'max8.0', 'ipd5.0'],
47
49
  * type: 'list',
48
50
  * method: 'get',
49
51
  * path: '/customs',
@@ -24,6 +24,7 @@ import { isRecord } from '../utils/index.js';
24
24
  * ```ts
25
25
  * defineModuleActions('bug', {
26
26
  * name: 'assignTo',
27
+ * minVersion: ['22.0', 'biz13.0', 'max8.0', 'ipd5.0'],
27
28
  * display: '指派 Bug',
28
29
  * type: 'action',
29
30
  * method: 'put',
@@ -48,6 +49,7 @@ import { isRecord } from '../utils/index.js';
48
49
  * actions: [
49
50
  * {
50
51
  * name: 'list',
52
+ * minVersion: ['22.0', 'biz13.0', 'max8.0', 'ipd5.0'],
51
53
  * type: 'list',
52
54
  * method: 'get',
53
55
  * path: '/customs',
@@ -178,6 +180,7 @@ export function applyBuiltinOverrides() {
178
180
  // 定义获取需求层级操作
179
181
  defineModuleActions('story', {
180
182
  name: 'getGrades',
183
+ minVersion: ['22.5', 'biz13.5', 'max8.5', 'ipd5.5'],
181
184
  display: '获取需求层级选项',
182
185
  type: 'list',
183
186
  method: 'get',
@@ -1,14 +1,15 @@
1
- import type { ModuleAction, ModuleActionParam, ModuleActionParamRole, ModuleDefinition } from '../types/index.js';
1
+ import type { ModuleAction, ModuleActionParam, ModuleActionParamRole, ModuleDefinition, ModuleQueryOptions } from '../types/index.js';
2
2
  /**
3
3
  * 获取模块定义。
4
4
  *
5
- * 模块名匹配大小写不敏感。返回值是注册表内部的已深冻结引用(O(1) 查询、零拷贝),
5
+ * 模块名匹配大小写不敏感。未传版本时返回注册表内部的已深冻结引用(O(1) 查询、零拷贝),
6
6
  * 任何写入尝试在严格模式下会抛 `TypeError`;如需修改请使用 {@link defineModules}。
7
7
  *
8
8
  * @param moduleName - 模块名。
9
- * @returns 已注册的模块定义;模块未注册时返回 `undefined`。
9
+ * @param options - 可选版本过滤;不传时保留完整注册表引用,传入时返回冻结的过滤视图。
10
+ * @returns 已注册的模块定义;模块未注册或过滤后没有动作时返回 `undefined`。
10
11
  */
11
- export declare function getModule(moduleName: string): ModuleDefinition | undefined;
12
+ export declare function getModule(moduleName: string, options?: ModuleQueryOptions): ModuleDefinition | undefined;
12
13
  /**
13
14
  * 获取指定模块下的某个动作。
14
15
  *
@@ -20,9 +21,10 @@ export declare function getModule(moduleName: string): ModuleDefinition | undefi
20
21
  *
21
22
  * @param moduleName - 模块名(大小写不敏感)。
22
23
  * @param actionName - 动作名(大小写不敏感);支持 `ls` 作为 `list` 的别名。
24
+ * @param options - 可选版本过滤。
23
25
  * @returns 匹配到的动作定义;模块未注册或动作不存在时返回 `undefined`。
24
26
  */
25
- export declare function getModuleAction(moduleName: string, actionName: string): ModuleAction | undefined;
27
+ export declare function getModuleAction(moduleName: string, actionName: string, options?: ModuleQueryOptions): ModuleAction | undefined;
26
28
  /**
27
29
  * 获取指定模块下的某个动作的参数。
28
30
  *
@@ -30,9 +32,10 @@ export declare function getModuleAction(moduleName: string, actionName: string):
30
32
  * @param actionName - 动作名(大小写不敏感);支持 `ls` 作为 `list` 的别名。
31
33
  * @param options - 选项。
32
34
  * @param options.roles - 角色,可选 `path`、`query`、`body`。
35
+ * @param options.version - 可选禅道版本;不支持该动作时返回空数组,不做参数级版本过滤。
33
36
  * @returns 动作参数。
34
37
  */
35
- export declare function getModuleActionParams(moduleName: string, actionName: string, options?: {
38
+ export declare function getModuleActionParams(moduleName: string, actionName: string, options?: ModuleQueryOptions & {
36
39
  roles?: ModuleActionParamRole[];
37
40
  }): ModuleActionParam[];
38
41
  /**
@@ -41,15 +44,17 @@ export declare function getModuleActionParams(moduleName: string, actionName: st
41
44
  * 顺序与模块写入注册表的顺序一致;包括内置模块和通过 {@link defineModules} 追加的用户模块。
42
45
  *
43
46
  * @returns 模块名数组(保留原始大小写)。
47
+ * @param options - 可选版本过滤,仅保留含有支持动作的模块。
44
48
  */
45
- export declare function getModuleNames(): string[];
49
+ export declare function getModuleNames(options?: ModuleQueryOptions): string[];
46
50
  /**
47
51
  * 判断模块名是否已注册。
48
52
  *
49
53
  * @param moduleName - 模块名;匹配大小写不敏感。
54
+ * @param options - 可选版本过滤。
50
55
  * @returns 已注册返回 `true`,否则 `false`。
51
56
  */
52
- export declare function isModuleName(moduleName: string): boolean;
57
+ export declare function isModuleName(moduleName: string, options?: ModuleQueryOptions): boolean;
53
58
  /**
54
59
  * 获取对象属性。
55
60
  *
@@ -1,16 +1,23 @@
1
+ import { parseZentaoVersion, supportsZentaoVersion } from '../misc/zentao-version.js';
1
2
  import { objectProps } from './object-props.js';
2
3
  import { getModuleMapState, getModulesState } from './registry-store.js';
3
4
  /**
4
5
  * 获取模块定义。
5
6
  *
6
- * 模块名匹配大小写不敏感。返回值是注册表内部的已深冻结引用(O(1) 查询、零拷贝),
7
+ * 模块名匹配大小写不敏感。未传版本时返回注册表内部的已深冻结引用(O(1) 查询、零拷贝),
7
8
  * 任何写入尝试在严格模式下会抛 `TypeError`;如需修改请使用 {@link defineModules}。
8
9
  *
9
10
  * @param moduleName - 模块名。
10
- * @returns 已注册的模块定义;模块未注册时返回 `undefined`。
11
+ * @param options - 可选版本过滤;不传时保留完整注册表引用,传入时返回冻结的过滤视图。
12
+ * @returns 已注册的模块定义;模块未注册或过滤后没有动作时返回 `undefined`。
11
13
  */
12
- export function getModule(moduleName) {
13
- return getModuleMapState().get(moduleName.toLowerCase());
14
+ export function getModule(moduleName, options = {}) {
15
+ const version = options.version === undefined ? undefined : parseZentaoVersion(options.version);
16
+ const module = getModuleMapState().get(moduleName.toLowerCase());
17
+ if (!version || !module)
18
+ return module;
19
+ const actions = module.actions.filter(action => supportsZentaoVersion(version, action.minVersion));
20
+ return actions.length ? Object.freeze({ ...module, actions: Object.freeze(actions) }) : undefined;
14
21
  }
15
22
  /**
16
23
  * 获取指定模块下的某个动作。
@@ -23,10 +30,11 @@ export function getModule(moduleName) {
23
30
  *
24
31
  * @param moduleName - 模块名(大小写不敏感)。
25
32
  * @param actionName - 动作名(大小写不敏感);支持 `ls` 作为 `list` 的别名。
33
+ * @param options - 可选版本过滤。
26
34
  * @returns 匹配到的动作定义;模块未注册或动作不存在时返回 `undefined`。
27
35
  */
28
- export function getModuleAction(moduleName, actionName) {
29
- const module = getModule(moduleName);
36
+ export function getModuleAction(moduleName, actionName, options) {
37
+ const module = getModule(moduleName, options);
30
38
  if (!module)
31
39
  return undefined;
32
40
  const normalized = actionName === 'ls' ? 'list' : actionName;
@@ -39,12 +47,13 @@ export function getModuleAction(moduleName, actionName) {
39
47
  * @param actionName - 动作名(大小写不敏感);支持 `ls` 作为 `list` 的别名。
40
48
  * @param options - 选项。
41
49
  * @param options.roles - 角色,可选 `path`、`query`、`body`。
50
+ * @param options.version - 可选禅道版本;不支持该动作时返回空数组,不做参数级版本过滤。
42
51
  * @returns 动作参数。
43
52
  */
44
53
  export function getModuleActionParams(moduleName, actionName, options) {
45
54
  const { roles } = options ?? {};
46
55
  const params = [];
47
- const action = getModuleAction(moduleName, actionName);
56
+ const action = getModuleAction(moduleName, actionName, options);
48
57
  if (!action) {
49
58
  return [];
50
59
  }
@@ -98,18 +107,23 @@ export function getModuleActionParams(moduleName, actionName, options) {
98
107
  * 顺序与模块写入注册表的顺序一致;包括内置模块和通过 {@link defineModules} 追加的用户模块。
99
108
  *
100
109
  * @returns 模块名数组(保留原始大小写)。
110
+ * @param options - 可选版本过滤,仅保留含有支持动作的模块。
101
111
  */
102
- export function getModuleNames() {
103
- return getModulesState().map((module) => module.name);
112
+ export function getModuleNames(options = {}) {
113
+ const version = options.version === undefined ? undefined : parseZentaoVersion(options.version);
114
+ return getModulesState()
115
+ .filter(module => !version || module.actions.some(action => supportsZentaoVersion(version, action.minVersion)))
116
+ .map(module => module.name);
104
117
  }
105
118
  /**
106
119
  * 判断模块名是否已注册。
107
120
  *
108
121
  * @param moduleName - 模块名;匹配大小写不敏感。
122
+ * @param options - 可选版本过滤。
109
123
  * @returns 已注册返回 `true`,否则 `false`。
110
124
  */
111
- export function isModuleName(moduleName) {
112
- return getModuleMapState().has(moduleName.toLowerCase());
125
+ export function isModuleName(moduleName, options) {
126
+ return getModule(moduleName, options) !== undefined;
113
127
  }
114
128
  /**
115
129
  * 获取对象属性。
@@ -1,4 +1,5 @@
1
1
  import { ZentaoError } from '../misc/errors.js';
2
+ import { validateMinVersion } from '../misc/zentao-version.js';
2
3
  import { isRecord } from '../utils/object.js';
3
4
  import { BUILTIN_MODULES } from './generated.js';
4
5
  // 动作类型到 HTTP 方法 / 结果形态的默认推导表:当动作未显式声明 method / resultType 时按 type 补齐。
@@ -99,6 +100,7 @@ export function normalizeAction(action) {
99
100
  return action;
100
101
  }
101
102
  export function freezeAction(action) {
103
+ validateAction(action);
102
104
  return deepFreeze(normalizeAction(action));
103
105
  }
104
106
  export function freezeModule(module) {
@@ -146,6 +148,7 @@ export function validateAction(action) {
146
148
  if (!action || typeof action.name !== 'string' || typeof action.path !== 'string') {
147
149
  throw new ZentaoError('E_INVALID_ACTION_DEFINITION');
148
150
  }
151
+ validateMinVersion(action.minVersion);
149
152
  // method / resultType 可省略(由 normalizeAction 按 type 推导),但显式给出时必须是字符串。
150
153
  if (action.method !== undefined && typeof action.method !== 'string') {
151
154
  throw new ZentaoError('E_INVALID_ACTION_DEFINITION');
@@ -153,6 +156,9 @@ export function validateAction(action) {
153
156
  if (action.resultType !== undefined && typeof action.resultType !== 'string') {
154
157
  throw new ZentaoError('E_INVALID_ACTION_DEFINITION');
155
158
  }
159
+ if (action.request !== undefined && typeof action.request !== 'function') {
160
+ throw new ZentaoError('E_INVALID_ACTION_DEFINITION');
161
+ }
156
162
  }
157
163
  /** 当前运行时注册表中的模块数组(define 侧原地修改,query 侧只读)。 */
158
164
  export function getModulesState() {
@@ -0,0 +1,6 @@
1
+ /**
2
+ * 在同一主机的本地文件系统上保护 profile 的 read-modify-write。
3
+ * 仅回收已确认退出的本机进程;不以锁的年龄判断进程是否仍在写入。
4
+ * @internal
5
+ */
6
+ export declare function withProfileFileLock<T>(file: string, operation: () => Promise<T>, timeoutMs?: number): Promise<T>;
@@ -0,0 +1,127 @@
1
+ import { ZentaoError } from '../misc/errors.js';
2
+ // 间接导入,避免浏览器打包器解析 Node 内置模块。
3
+ function importNodeModule(specifier) {
4
+ return import(specifier);
5
+ }
6
+ /**
7
+ * 在同一主机的本地文件系统上保护 profile 的 read-modify-write。
8
+ * 仅回收已确认退出的本机进程;不以锁的年龄判断进程是否仍在写入。
9
+ * @internal
10
+ */
11
+ export async function withProfileFileLock(file, operation, timeoutMs = 5000) {
12
+ const [fs, path, os, crypto] = await Promise.all([
13
+ importNodeModule('node:fs/promises'),
14
+ importNodeModule('node:path'),
15
+ importNodeModule('node:os'),
16
+ importNodeModule('node:crypto'),
17
+ ]);
18
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
19
+ const directory = await fs.realpath(path.dirname(file));
20
+ const lock = path.join(directory, `${path.basename(file)}.lock`);
21
+ const ownerName = `${process.pid}-${crypto.randomUUID()}.json`;
22
+ const candidate = `${lock}.${ownerName}`;
23
+ const hostname = os.hostname();
24
+ async function removeOwner(name) {
25
+ try {
26
+ await fs.unlink(path.join(lock, name));
27
+ }
28
+ catch (error) {
29
+ if (error.code === 'ENOENT')
30
+ return;
31
+ throw error;
32
+ }
33
+ try {
34
+ // 迟到的释放者/回收者不能删除后继持有者的非空目录。
35
+ await fs.rmdir(lock);
36
+ }
37
+ catch (error) {
38
+ if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code ?? ''))
39
+ throw error;
40
+ }
41
+ }
42
+ async function reclaimDeadOwner() {
43
+ try {
44
+ const entries = await fs.readdir(lock, { withFileTypes: true });
45
+ if (entries.length !== 1 || !entries[0].isFile())
46
+ return;
47
+ const name = entries[0].name;
48
+ if (!/^[1-9]\d*-[\da-f]{8}-(?:[\da-f]{4}-){3}[\da-f]{12}\.json$/.test(name))
49
+ return;
50
+ let owner;
51
+ try {
52
+ owner = JSON.parse(await fs.readFile(path.join(lock, name), 'utf8'));
53
+ }
54
+ catch (error) {
55
+ if (error instanceof SyntaxError)
56
+ return;
57
+ throw error;
58
+ }
59
+ if (!owner || typeof owner.pid !== 'number' || !Number.isSafeInteger(owner.pid)
60
+ || owner.pid <= 0 || !name.startsWith(`${owner.pid}-`) || owner.hostname !== hostname)
61
+ return;
62
+ try {
63
+ process.kill(owner.pid, 0);
64
+ }
65
+ catch (error) {
66
+ if (error.code === 'ESRCH')
67
+ await removeOwner(name);
68
+ // EPERM 或未知错误都不能证明进程已退出。
69
+ }
70
+ }
71
+ catch (error) {
72
+ if (error.code !== 'ENOENT')
73
+ throw error;
74
+ }
75
+ }
76
+ await fs.mkdir(candidate, { mode: 0o700 });
77
+ let acquired = false;
78
+ try {
79
+ await fs.writeFile(path.join(candidate, ownerName), JSON.stringify({ pid: process.pid, hostname }), {
80
+ flag: 'wx', mode: 0o600,
81
+ });
82
+ const started = performance.now();
83
+ while (performance.now() - started < timeoutMs) {
84
+ try {
85
+ // 先准备非空目录再原子 rename,不留下“已有锁但尚无 owner”的窗口。
86
+ await fs.rename(candidate, lock);
87
+ acquired = true;
88
+ break;
89
+ }
90
+ catch (error) {
91
+ const code = error.code;
92
+ if (code === 'EPERM') {
93
+ // Windows 对已有目录可能返回 EPERM;没有目标目录时保留真实权限错误。
94
+ const existing = await fs.stat(lock).catch(statError => {
95
+ if (statError.code === 'ENOENT')
96
+ return undefined;
97
+ throw statError;
98
+ });
99
+ if (!existing?.isDirectory())
100
+ throw error;
101
+ }
102
+ else if (code !== 'EEXIST' && code !== 'ENOTEMPTY') {
103
+ throw error;
104
+ }
105
+ await reclaimDeadOwner();
106
+ const remaining = timeoutMs - (performance.now() - started);
107
+ if (remaining > 0) {
108
+ await new Promise(resolve => setTimeout(resolve, Math.min(remaining, 25 + Math.random() * 25)));
109
+ }
110
+ }
111
+ }
112
+ if (!acquired) {
113
+ throw new ZentaoError('E_PROFILE_STORAGE_UNAVAILABLE', undefined, new Error('Timed out waiting for the profile file lock.'));
114
+ }
115
+ try {
116
+ return await operation();
117
+ }
118
+ finally {
119
+ await removeOwner(ownerName);
120
+ }
121
+ }
122
+ finally {
123
+ // 此候选目录只属于本次调用;固定锁目录绝不能递归删除。
124
+ if (!acquired)
125
+ await fs.rm(candidate, { recursive: true, force: true });
126
+ }
127
+ }
@@ -1,4 +1,4 @@
1
- import type { ZentaoProfile, ZentaoProfileRecord } from '../types/index.js';
1
+ import type { ServerConfig, ZentaoProfile, ZentaoProfileRecord } from '../types/index.js';
2
2
  /**
3
3
  * 浏览器环境下用于在 `localStorage` 中保存 profile 数据的 key。
4
4
  *
@@ -23,7 +23,7 @@ export declare function getProfileKey(profile: Pick<ZentaoProfile, 'account' | '
23
23
  * 读取过程不会写回存储;存储中无法解析的条目会被静默忽略,不会影响其余 profile。
24
24
  *
25
25
  * @returns 当前存储中的所有 profile(带 `key` 字段),文件不存在时返回空数组。
26
- * @throws {ZentaoError} `E_PROFILE_STORAGE_INVALID`(存储内容不是合法 JSON)或
26
+ * @throws {ZentaoError} `E_PROFILE_STORAGE_INVALID`(存储内容不是合法 JSON 或根结构不合法)或
27
27
  * `E_PROFILE_STORAGE_UNAVAILABLE`(运行时无法访问存储)。
28
28
  */
29
29
  export declare function getAllProfiles(): Promise<ZentaoProfileRecord[]>;
@@ -35,13 +35,16 @@ export declare function getAllProfiles(): Promise<ZentaoProfileRecord[]>;
35
35
  * @throws {ZentaoError} `E_PROFILE_STORAGE_INVALID` / `E_PROFILE_STORAGE_UNAVAILABLE`。
36
36
  */
37
37
  export declare function getProfile(profileKey?: string): Promise<ZentaoProfileRecord | undefined>;
38
+ /** 只读恢复与账号切换共用相同的 key 解析和错误语义。 @internal */
39
+ export declare function getProfileOrThrow(profileKey?: string): Promise<ZentaoProfileRecord>;
38
40
  /**
39
41
  * 添加或覆盖一个本地 profile,并把它设置为当前使用的 profile。
40
42
  *
41
43
  * 行为细节:
42
44
  * - 同 key(`account@server`)已存在时会**整体覆盖**而非合并字段。
43
45
  * - 写入时会自动补齐 `loginTime` 与 `lastUsedTime`(若调用方未提供则使用当前 ISO 时间)。
44
- * - 操作通过进程内串行锁保护 read-modify-write,避免并发调用导致的 lost update;跨进程并发不在保证范围。
46
+ * - Node.js 通过文件锁保护同主机本地文件系统的 read-modify-write;浏览器在支持 Web Locks 时保护同源上下文,其他环境仅保证实例内串行。
47
+ * - 跨进程或上下文等待锁超过约 5 秒时抛出存储不可用错误,不修改 profile 数据。
45
48
  * - 实际写入使用临时文件 + `rename` 的原子方式,并将文件与目录权限收紧到 `0600`/`0700`(Node.js 下)。
46
49
  *
47
50
  * @param profile - 要写入的 profile,必须至少包含 `server`、`account`、`token`。
@@ -50,10 +53,14 @@ export declare function getProfile(profileKey?: string): Promise<ZentaoProfileRe
50
53
  * `E_INVALID_BASE_URL`、`E_PROFILE_STORAGE_INVALID`、`E_PROFILE_STORAGE_UNAVAILABLE`。
51
54
  */
52
55
  export declare function addProfile(profile: ZentaoProfile): Promise<ZentaoProfileRecord>;
56
+ /** 登录只更新会话字段和显式偏好,保留同账号的应用数据。 @internal */
57
+ export declare function saveLoginProfile(profile: ZentaoProfile): Promise<ZentaoProfileRecord>;
58
+ /** 只刷新已有 profile 的服务器配置,不切换当前账号或重建已删除的记录。 @internal */
59
+ export declare function updateProfileServerConfig(profileKey: string, serverConfig: ServerConfig, fetchedAt: string): Promise<void>;
53
60
  /**
54
61
  * 删除指定 profile。
55
62
  *
56
- * 若被删除的是当前 profile,会回退为列表中最近一次写入的 profile;若已无任何 profile,
63
+ * 若被删除的是当前 profile,会回退为最近添加且仍保留的 profile;覆盖或切换已有记录不改变添加顺序。若已无任何 profile,
57
64
  * 当前 profile 会被清空。操作同样通过进程内串行锁保护。
58
65
  *
59
66
  * @param profileKey - 要删除的 profile key。