dsh-codex-port 0.2.0 → 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.en.md CHANGED
@@ -1,5 +1,7 @@
1
1
  [中文](README.md)
2
2
 
3
+ ![npm](https://img.shields.io/npm/v/dsh-codex-port) ![downloads](https://img.shields.io/npm/dm/dsh-codex-port) ![license](https://img.shields.io/github/license/STARDUSTLC666/dsh-codex-port) ![stars](https://img.shields.io/github/stars/STARDUSTLC666/dsh-codex-port?style=social)
4
+
3
5
  # dsh-codex-port
4
6
 
5
7
  [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
@@ -10,7 +12,7 @@ Move the whole **official Codex plugin family** into DSH: scan `~/.codex` unpack
10
12
 
11
13
  ## Compatibility
12
14
 
13
- Verified against `@deepseek-ai/dsh@0.1.1-rc.2` on 2026-08-26. Built for the cordis patch-bundle plugin model (`cordis.patch.yml` + `dsh.bundle.patch`). No runtime imports of `@deepseek-ai/*` internals.
15
+ Verified against `@deepseek-ai/dsh@0.1.2-alpha.2` on 2026-08-31. Built for the cordis patch-bundle plugin model (`cordis.patch.yml` + `dsh.bundle.patch`). No runtime imports of `@deepseek-ai/*` internals.
14
16
 
15
17
  ## Installation
16
18
 
@@ -68,16 +70,19 @@ Ported skills are immediately usable from the DSH skills directory; the agent tr
68
70
  - **Frontmatter conversion**: Codex `name/description/metadata` → DSH `name/description/compatibility/allowed-tools`, multi-line descriptions preserved
69
71
  - **Codex-only files stripped**: `agents/*.yaml` and friends stay behind
70
72
  - **Name sanitization**: invalid characters become underscores; `..` traversal rejected outright
73
+ - **Host loadability checks**: before replacement, the converted name must follow DSH's lowercase kebab-case grammar, exactly match its target directory, and have a nonempty description. Names with uppercase letters, underscores, dots, or a different sanitized directory name cannot replace an existing skill.
71
74
  - **Idempotent**: same-name skills are skipped by default; `overwrite=true` to replace
75
+ - **Recoverable replacement**: copying, conversion, and read-back validation finish inside a unique `.dsh-port-<skill>-*` directory under the target root before the old skill moves to `previous/` and the new skill takes its place. Copy/conversion failures leave the old skill untouched. A failed switch attempts rollback; if rollback also fails, both copies remain and the error includes their recovery location. Content created by another writer is never deleted.
76
+ - **Backups and path checks**: successful replacements retain `previous/` and `recovery.json`, using additional disk space; archive them manually when recovery is no longer needed. No `SKILL.md` sits at the transaction directory's top level, so current DSH one-level skill discovery does not register backups. Overlapping source/target paths, target symlinks/junctions, paths outside the target root, and Windows device names are rejected. Multiple renames are not one atomic Windows transaction; after process or system interruption, inspect `recovery.json` to restore the appropriate directory.
72
77
  - **Safe**: pure filesystem operations, zero runtime dependencies (yaml parsing only)
73
78
 
74
79
  ## Development
75
80
 
76
81
  ```bash
77
82
  pnpm install
78
- pnpm test # build + 33 tests
83
+ pnpm test # build + offline tests; fixtures stay under workspace .harness-validation/codex-port-tests
79
84
  ```
80
85
 
81
86
  ## License
82
87
 
83
- MIT
88
+ MIT
package/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  ## 兼容性
17
17
 
18
- 在 `@deepseek-ai/dsh@0.1.1-rc.2` 上验证(2026-08-26)。遵循 cordis 组合包补丁模型(`cordis.patch.yml` + `dsh.bundle.patch`),运行时不 import 任何 `@deepseek-ai/*` 内部模块。
18
+ 在 `@deepseek-ai/dsh@0.1.2-alpha.2` 上验证(2026-08-31)。遵循 cordis 组合包补丁模型(`cordis.patch.yml` + `dsh.bundle.patch`),运行时不 import 任何 `@deepseek-ai/*` 内部模块。
19
19
 
20
20
  ## 安装
21
21
 
@@ -89,14 +89,17 @@ codex_port { targetDir: ~/.claude/skills }
89
89
  - **frontmatter 转换**:Codex 的 `name/description/metadata` → DSH 的 `name/description/compatibility/allowed-tools`,多行描述完整保留
90
90
  - **剔除 codex 专属文件**:`agents/*.yaml` 等子代理描述不带走
91
91
  - **名称清洗**:非法字符替换为下划线,`..` 路径穿越直接拒绝
92
+ - **宿主可加载性校验**:覆盖前要求转换后的技能名符合 DSH 的小写短横线命名规则、与目标目录同名,且描述为非空字符串;大写、下划线、点号或清洗后不同名的技能不会替换旧版。
92
93
  - **幂等**:同名技能默认跳过,`overwrite=true` 才覆盖
94
+ - **覆盖失败可恢复**:先在目标根内的唯一 `.dsh-port-<技能名>-*` 目录中复制、转换并回读校验;成功后才把旧技能移到事务目录的 `previous/`,再切换新技能。复制或转换失败不会改动旧技能,切换失败会尝试恢复旧技能;恢复失败时保留两份资料,并在失败原因中给出恢复路径。不会删除已存在的其他写入。
95
+ - **备份与目录边界**:成功覆盖也保留 `previous/` 及 `recovery.json`,会额外占用磁盘空间;确认不需要恢复后可自行归档。事务目录顶层不含 `SKILL.md`,不会被当前 DSH 的单层技能发现重复注册。拒绝源/目标重叠、目标符号链接或目录联接、路径越界及 Windows 设备名称。多次目录重命名不构成 Windows 上全程原子的事务;进程或系统中断后可按 `recovery.json` 检查和恢复。
93
96
  - **安全**:纯文件系统操作,零运行时依赖(仅 yaml 解析)
94
97
 
95
98
  ## 开发
96
99
 
97
100
  ```bash
98
101
  pnpm install
99
- pnpm test # 构建 + 33 个测试
102
+ pnpm test # 构建 + 离线测试;fixture 只写入工作区 .harness-validation/codex-port-tests
100
103
  ```
101
104
 
102
105
  ## License
package/lib/port.d.ts CHANGED
@@ -8,6 +8,7 @@ export interface PortResult {
8
8
  files: number;
9
9
  }
10
10
  /**
11
- * 移植单个技能到目标目录。
11
+ * Port one skill without deleting its previous installation. Each rename is a
12
+ * filesystem operation; the complete replacement is not atomic on Windows.
12
13
  */
13
14
  export declare function portSkill(skill: CodexSkillSource, plugin: CodexPluginInfo, targetDir: string, overwrite: boolean): PortResult;
package/lib/port.js CHANGED
@@ -1,59 +1,198 @@
1
1
  /**
2
- * 移植执行:目录拷贝(剔除 agents codex 专属文件)+ SKILL.md 转换 + 幂等跳过。
3
- *
2
+ * Stage and validate skill copies before replacing an installation; retain backups for recovery.
4
3
  * @module dsh-codex-port/port
5
4
  */
6
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, rmSync } from 'node:fs';
7
- import { join } from 'node:path';
8
- import { convertSkillFile, sanitizeSkillName } from './frontmatter.js';
9
- /** codex 专属目录,移植时剔除。 */
5
+ import fs from 'node:fs';
6
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
7
+ import { convertSkillFile, parseSkillFile, sanitizeSkillName } from './frontmatter.js';
10
8
  const EXCLUDED_DIRS = new Set(['agents']);
11
- /** 递归拷贝,跳过排除目录;返回拷贝文件数。 */
12
- function copyTree(source, target) {
13
- mkdirSync(target, { recursive: true });
9
+ // Matches Harness skill.isSkillName; keep the public filename sanitizer unchanged.
10
+ const DSH_SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
11
+ function isWithin(root, path) {
12
+ const child = relative(root, path);
13
+ return child !== '' && child !== '..' && !child.startsWith('..' + sep) && !isAbsolute(child);
14
+ }
15
+ function errorText(error) {
16
+ return error instanceof Error ? error.message : String(error);
17
+ }
18
+ /** Anchor every move to the resolved target root, rejecting symlinks and changed parents. */
19
+ class InstallRoot {
20
+ path;
21
+ constructor(targetDir) {
22
+ if (targetDir.trim() === '')
23
+ throw new Error('目标技能目录不能为空');
24
+ const requested = resolve(targetDir);
25
+ if (dirname(requested) === requested)
26
+ throw new Error('不能把文件系统根目录作为技能目录');
27
+ const existing = fs.lstatSync(requested, { throwIfNoEntry: false });
28
+ if (existing?.isSymbolicLink())
29
+ throw new Error('目标技能根目录不能是符号链接或目录联接');
30
+ // Resolve absent target roots through their existing ancestor without creating
31
+ // anything yet: overlap with the source must fail before filesystem writes.
32
+ let ancestor = requested;
33
+ while (fs.lstatSync(ancestor, { throwIfNoEntry: false }) === undefined)
34
+ ancestor = dirname(ancestor);
35
+ this.path = resolve(fs.realpathSync(ancestor), relative(ancestor, requested));
36
+ }
37
+ check(path) {
38
+ const absolute = resolve(path);
39
+ if (!isWithin(this.path, absolute))
40
+ throw new Error('安装路径越出目标技能根目录:' + absolute);
41
+ const root = fs.lstatSync(this.path);
42
+ if (!root.isDirectory() || root.isSymbolicLink() || fs.realpathSync(this.path) !== this.path) {
43
+ throw new Error('目标技能根目录在安装期间发生变化');
44
+ }
45
+ const parent = fs.realpathSync(dirname(absolute));
46
+ if (parent !== this.path && !isWithin(this.path, parent))
47
+ throw new Error('安装路径的父目录越界:' + absolute);
48
+ if (parent !== dirname(absolute))
49
+ throw new Error('安装路径的父目录包含符号链接或目录联接:' + absolute);
50
+ const entry = fs.lstatSync(absolute, { throwIfNoEntry: false });
51
+ if (entry?.isSymbolicLink())
52
+ throw new Error('安装路径不能是符号链接或目录联接:' + absolute);
53
+ if (entry !== undefined && !isWithin(this.path, fs.realpathSync(absolute))) {
54
+ throw new Error('安装路径解析后越界:' + absolute);
55
+ }
56
+ return absolute;
57
+ }
58
+ move(source, target) {
59
+ const from = this.check(source);
60
+ const to = this.check(target);
61
+ if (!fs.lstatSync(from).isDirectory())
62
+ throw new Error('只能切换技能目录:' + from);
63
+ if (fs.lstatSync(to, { throwIfNoEntry: false }) !== undefined)
64
+ throw new Error('切换目标已存在,保留现场:' + to);
65
+ fs.renameSync(from, to);
66
+ }
67
+ }
68
+ /** Copy only regular files/directories; the destination is a new private staging tree. */
69
+ function copyTree(source, target, sourceRoot, root) {
70
+ if (source !== sourceRoot && !isWithin(sourceRoot, fs.realpathSync(source)))
71
+ throw new Error('技能源目录越界');
72
+ if (fs.lstatSync(source).isSymbolicLink())
73
+ throw new Error('技能源目录不能是符号链接');
74
+ fs.mkdirSync(root.check(target));
14
75
  let count = 0;
15
- for (const entry of readdirSync(source, { withFileTypes: true })) {
16
- if (entry.isSymbolicLink())
76
+ for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
77
+ const from = join(source, entry.name);
78
+ const to = root.check(join(target, entry.name));
79
+ const stat = fs.lstatSync(from);
80
+ if (stat.isSymbolicLink())
17
81
  continue;
18
- if (entry.isDirectory() && EXCLUDED_DIRS.has(entry.name))
82
+ if (stat.isDirectory() && EXCLUDED_DIRS.has(entry.name))
19
83
  continue;
20
- const from = join(source, entry.name);
21
- const to = join(target, entry.name);
22
- if (entry.isDirectory()) {
23
- count += copyTree(from, to);
24
- }
25
- else {
26
- writeFileSync(to, readFileSync(from));
84
+ if (!isWithin(sourceRoot, fs.realpathSync(from)))
85
+ throw new Error('技能源文件解析后越界:' + from);
86
+ if (stat.isDirectory())
87
+ count += copyTree(from, to, sourceRoot, root);
88
+ else if (stat.isFile()) {
89
+ fs.copyFileSync(from, to, fs.constants.COPYFILE_EXCL);
27
90
  count += 1;
28
91
  }
92
+ else
93
+ throw new Error('技能目录含非普通文件:' + from);
29
94
  }
30
95
  return count;
31
96
  }
32
97
  /**
33
- * 移植单个技能到目标目录。
98
+ * Port one skill without deleting its previous installation. Each rename is a
99
+ * filesystem operation; the complete replacement is not atomic on Windows.
34
100
  */
35
101
  export function portSkill(skill, plugin, targetDir, overwrite) {
36
102
  const safeName = sanitizeSkillName(skill.skillName !== '' ? skill.skillName : skill.skillDirName);
37
- if (safeName === null) {
103
+ if (safeName === null || safeName.endsWith('.') || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(safeName)) {
38
104
  return { skill: skill.skillDirName, plugin: plugin.name, status: 'failed', reason: '技能名清洗后为空或不合法,已跳过(可能是纯中文或含危险字符的名称)', files: 0 };
39
105
  }
40
- const target = join(targetDir, safeName);
41
- if (existsSync(target) && !overwrite) {
42
- return { skill: safeName, plugin: plugin.name, status: 'skipped', reason: '目标技能已存在(overwrite=false),跳过以免覆盖', files: 0 };
43
- }
106
+ let root;
107
+ let target = '';
108
+ let transaction = '';
109
+ let incoming = '';
110
+ let previous = '';
111
+ let backedUp = false;
112
+ let installed = false;
113
+ const record = (phase, error) => {
114
+ if (root === undefined || transaction === '')
115
+ return;
116
+ const file = root.check(join(transaction, 'recovery.json'));
117
+ fs.writeFileSync(file, JSON.stringify({ version: 1, skill: safeName, target, incoming, previous, phase, ...(error === undefined ? {} : { error }) }, null, 2) + '\n', 'utf8');
118
+ };
44
119
  try {
45
- if (existsSync(target))
46
- rmSync(target, { recursive: true, force: true });
47
- let files = copyTree(skill.sourceDir, target);
48
- // 重写 SKILL.md 的 frontmatter
49
- const skillFile = join(target, 'SKILL.md');
50
- const original = readFileSync(skillFile, 'utf8');
51
- const converted = convertSkillFile(original, { pluginName: plugin.name, homepage: plugin.homepage, license: plugin.license }, safeName);
52
- writeFileSync(skillFile, converted, 'utf8');
53
- files = Math.max(files, 1);
54
- return { skill: safeName, plugin: plugin.name, status: 'ported', reason: '', files };
120
+ root = new InstallRoot(targetDir);
121
+ target = join(root.path, safeName);
122
+ if (fs.lstatSync(root.path, { throwIfNoEntry: false }) !== undefined)
123
+ root.check(target);
124
+ const existing = fs.lstatSync(target, { throwIfNoEntry: false });
125
+ if (existing !== undefined && !overwrite) {
126
+ return { skill: safeName, plugin: plugin.name, status: 'skipped', reason: '目标技能已存在(overwrite=false),跳过以免覆盖', files: 0 };
127
+ }
128
+ if (existing !== undefined && !existing.isDirectory())
129
+ throw new Error('目标技能不是目录,拒绝覆盖:' + target);
130
+ const sourceEntry = fs.lstatSync(skill.sourceDir);
131
+ if (!sourceEntry.isDirectory() || sourceEntry.isSymbolicLink())
132
+ throw new Error('技能源必须是普通目录');
133
+ const source = fs.realpathSync(skill.sourceDir);
134
+ if (source === root.path || isWithin(source, root.path) || source === target || isWithin(target, source)) {
135
+ throw new Error('技能源与安装目标重叠,拒绝递归复制或移动源目录');
136
+ }
137
+ fs.mkdirSync(root.path, { recursive: true });
138
+ root.check(target);
139
+ transaction = fs.mkdtempSync(root.check(join(root.path, '.dsh-port-' + safeName + '-')));
140
+ incoming = root.check(join(transaction, 'incoming'));
141
+ previous = root.check(join(transaction, 'previous'));
142
+ record('preparing');
143
+ const files = copyTree(source, incoming, source, root);
144
+ const skillFile = root.check(join(incoming, 'SKILL.md'));
145
+ const converted = convertSkillFile(fs.readFileSync(skillFile, 'utf8'), {
146
+ pluginName: plugin.name, homepage: plugin.homepage, license: plugin.license,
147
+ }, safeName);
148
+ fs.writeFileSync(skillFile, converted, 'utf8');
149
+ // Validate the bytes that were actually written, before moving the old skill.
150
+ const parsed = parseSkillFile(fs.readFileSync(skillFile, 'utf8'));
151
+ if (!parsed.hasFrontmatter || typeof parsed.frontmatter.name !== 'string'
152
+ || !DSH_SKILL_NAME.test(parsed.frontmatter.name) || parsed.frontmatter.name !== safeName
153
+ || typeof parsed.frontmatter.description !== 'string' || parsed.frontmatter.description.length === 0) {
154
+ throw new Error('转换后的 SKILL.md 未通过 DSH 技能名称、目录一致性或非空描述校验');
155
+ }
156
+ record('prepared');
157
+ if (existing !== undefined) {
158
+ root.move(target, previous);
159
+ backedUp = true;
160
+ record('previous-backed-up');
161
+ }
162
+ root.move(incoming, target);
163
+ installed = true;
164
+ let reason = '';
165
+ try {
166
+ record('installed');
167
+ }
168
+ catch (error) {
169
+ reason = '技能已安装,但恢复记录更新失败:' + errorText(error) + ';恢复目录:' + transaction;
170
+ }
171
+ return { skill: safeName, plugin: plugin.name, status: 'ported', reason, files: Math.max(files, 1) };
55
172
  }
56
173
  catch (error) {
57
- return { skill: safeName, plugin: plugin.name, status: 'failed', reason: error instanceof Error ? error.message : String(error), files: 0 };
174
+ let reason = errorText(error);
175
+ let phase = 'failed-before-switch';
176
+ if (backedUp && !installed) {
177
+ try {
178
+ root.move(previous, target);
179
+ phase = 'rolled-back';
180
+ reason += ';已恢复原技能';
181
+ }
182
+ catch (rollbackError) {
183
+ phase = 'rollback-failed';
184
+ reason += ';自动恢复失败:' + errorText(rollbackError) + ';原技能备份:' + previous;
185
+ }
186
+ }
187
+ if (transaction !== '') {
188
+ try {
189
+ record(phase, reason);
190
+ }
191
+ catch (recordError) {
192
+ reason += ';恢复记录写入失败:' + errorText(recordError);
193
+ }
194
+ reason += ';恢复资料保留在:' + transaction;
195
+ }
196
+ return { skill: safeName, plugin: plugin.name, status: 'failed', reason, files: 0 };
58
197
  }
59
198
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-codex-port",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "DSH 技能移植工具插件:把 ~/.codex 里的 Codex 官方插件(170+ 个)一键移植为 DSH 技能(codex_list / codex_port / codex_status),frontmatter 自动转换、名称清洗、幂等跳过。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",