@harmonyos-arkts/d2h 0.1.1 → 0.1.2
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 +2 -2
- package/dist/bin/install-opencode.js +60 -0
- package/dist/src/install-opencode.js +135 -0
- package/package.json +51 -45
- package/bin/install-opencode.mjs +0 -57
- package/src/install-opencode.mjs +0 -110
package/README.md
CHANGED
|
@@ -79,9 +79,9 @@ sh ./install-opencode.sh
|
|
|
79
79
|
|
|
80
80
|
该脚本把 `skills/` 和 `agents/` 安装到当前用户的 OpenCode 全局配置目录,不读取或改写 `opencode.json`。更新已有安装时使用 `--force`;本地开发需要让全局安装实时跟随仓库时使用 `--link --force`。可通过 `--config-dir` 和 `--source-dir` 适配自定义目录,先用 `--dry-run` 检查操作。完整参数见 `sh ./install-opencode.sh --help`。
|
|
81
81
|
|
|
82
|
-
npm 安装器支持同样的参数,完整说明见 `npx --yes @harmonyos-arkts/d2h --help`。本地开发可使用 `node bin/install-opencode.
|
|
82
|
+
npm 安装器支持同样的参数,完整说明见 `npx --yes @harmonyos-arkts/d2h --help`。本地开发可使用 `npm run build && node dist/bin/install-opencode.js --source-dir /path/to/repository --link --force`。Windows 下 Agent 文件符号链接需要开发者模式或管理员权限;默认复制模式不需要。不要将 `npx` 缓存作为长期链接来源,缓存清理后链接可能失效。
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
维护者发布:安装开发依赖后运行 `npm run build`,将 TypeScript 源码编译到 `dist/`;`npm run check && npm test` 检查类型并测试编译后的 CLI,然后 `npm publish`。npm 包使用 `dist/bin/install-opencode.js` 作为入口,包含编译后的运行代码、Agent、完整 Skill 资源树及 schema,不包含 TypeScript 源码、研发文档和测试;发布时不会自动修改用户配置,只有显式执行 `npx` 安装器才会安装。
|
|
85
85
|
|
|
86
86
|
完全退出并重新启动 OpenCode 后,选择 `android-to-hmos-00-orchestrator` 执行完整迁移。
|
|
87
87
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { installOpenCode } from '../src/install-opencode.js';
|
|
6
|
+
const help = `Install this package's skills and agents into OpenCode or DevEco Code.
|
|
7
|
+
|
|
8
|
+
Usage: d2h [options]
|
|
9
|
+
|
|
10
|
+
--source-dir <path> Source root (defaults to this package)
|
|
11
|
+
--agent <name> opencode (default) or deveco
|
|
12
|
+
--config-dir <path> Override the selected agent's config root
|
|
13
|
+
OPENCODE_CONFIG_DIR / DEVECO_CONFIG_DIR, or
|
|
14
|
+
$XDG_CONFIG_HOME/<agent>, or ~/.config/<agent>
|
|
15
|
+
--link Create absolute symbolic links (development only)
|
|
16
|
+
--force Replace conflicting installed skills and agents
|
|
17
|
+
--dry-run Print operations without writing files
|
|
18
|
+
-h, --help Show this help
|
|
19
|
+
|
|
20
|
+
Only skills/*/SKILL.md trees and agents/*.md are installed.
|
|
21
|
+
Existing application configuration and AGENTS.md are not modified.
|
|
22
|
+
With npx, prefer the default copy mode: its cache can be removed.
|
|
23
|
+
`;
|
|
24
|
+
try {
|
|
25
|
+
const options = {
|
|
26
|
+
sourceDir: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'),
|
|
27
|
+
agent: 'opencode',
|
|
28
|
+
};
|
|
29
|
+
const args = process.argv.slice(2);
|
|
30
|
+
for (let index = 0; index < args.length; index++) {
|
|
31
|
+
const arg = args[index];
|
|
32
|
+
if (arg === '-h' || arg === '--help') {
|
|
33
|
+
console.log(help);
|
|
34
|
+
process.exit(0);
|
|
35
|
+
}
|
|
36
|
+
if (arg === '--source-dir' || arg === '--config-dir' || arg === '--agent') {
|
|
37
|
+
const value = args[++index];
|
|
38
|
+
if (!value || value.startsWith('--'))
|
|
39
|
+
throw new Error(`${arg} requires a value`);
|
|
40
|
+
const key = { '--source-dir': 'sourceDir', '--config-dir': 'configDir', '--agent': 'agent' }[arg];
|
|
41
|
+
options[key] = value;
|
|
42
|
+
}
|
|
43
|
+
else if (arg === '--link' || arg === '--force' || arg === '--dry-run') {
|
|
44
|
+
const key = { '--link': 'link', '--force': 'force', '--dry-run': 'dryRun' }[arg];
|
|
45
|
+
options[key] = true;
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
throw new Error(`unknown option: ${arg}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (!options.agent || !['opencode', 'deveco'].includes(options.agent)) {
|
|
52
|
+
throw new Error(`unsupported agent: ${options.agent}; choose opencode or deveco`);
|
|
53
|
+
}
|
|
54
|
+
options.configDir ??= process.env[options.agent === 'deveco' ? 'DEVECO_CONFIG_DIR' : 'OPENCODE_CONFIG_DIR'] || path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), options.agent);
|
|
55
|
+
installOpenCode(options);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
console.error(`install-opencode: ${error instanceof Error ? error.message : String(error)}`);
|
|
59
|
+
process.exitCode = 1;
|
|
60
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
function stat(file) {
|
|
5
|
+
try {
|
|
6
|
+
return fs.lstatSync(file);
|
|
7
|
+
}
|
|
8
|
+
catch (error) {
|
|
9
|
+
if (error.code === 'ENOENT')
|
|
10
|
+
return null;
|
|
11
|
+
throw error;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function sameTree(source, destination) {
|
|
15
|
+
const left = stat(source);
|
|
16
|
+
const right = stat(destination);
|
|
17
|
+
if (!left || !right)
|
|
18
|
+
return false;
|
|
19
|
+
if (left.isSymbolicLink() || right.isSymbolicLink()) {
|
|
20
|
+
if (!left.isSymbolicLink() || !right.isSymbolicLink())
|
|
21
|
+
return false;
|
|
22
|
+
return fs.readlinkSync(source) === fs.readlinkSync(destination);
|
|
23
|
+
}
|
|
24
|
+
if (left.isDirectory() && right.isDirectory()) {
|
|
25
|
+
const names = fs.readdirSync(source).sort();
|
|
26
|
+
const otherNames = fs.readdirSync(destination).sort();
|
|
27
|
+
return names.length === otherNames.length && names.every((name, index) => name === otherNames[index] && sameTree(path.join(source, name), path.join(destination, name)));
|
|
28
|
+
}
|
|
29
|
+
return left.isFile() && right.isFile() && fs.readFileSync(source).equals(fs.readFileSync(destination));
|
|
30
|
+
}
|
|
31
|
+
function contains(parent, child) {
|
|
32
|
+
const relative = path.relative(parent, child);
|
|
33
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
34
|
+
}
|
|
35
|
+
// Resolve existing ancestors too, including a config directory beneath a junction.
|
|
36
|
+
function resolvedLocation(directory) {
|
|
37
|
+
if (fs.existsSync(directory))
|
|
38
|
+
return fs.realpathSync(directory);
|
|
39
|
+
const parent = path.dirname(directory);
|
|
40
|
+
return parent === directory ? directory : path.join(resolvedLocation(parent), path.basename(directory));
|
|
41
|
+
}
|
|
42
|
+
export function installOpenCode({ sourceDir, configDir, agent = 'opencode', link = false, force = false, dryRun = false, log = console.log }) {
|
|
43
|
+
if (!['opencode', 'deveco'].includes(agent))
|
|
44
|
+
throw new Error(`unsupported agent: ${agent}`);
|
|
45
|
+
const application = agent === 'deveco' ? 'DevEco Code' : 'OpenCode';
|
|
46
|
+
if (!configDir || ['.', '..'].includes(configDir))
|
|
47
|
+
throw new Error(`refusing unsafe OpenCode config directory: ${configDir}`);
|
|
48
|
+
const sourceRoot = fs.realpathSync(path.resolve(sourceDir));
|
|
49
|
+
const configRoot = resolvedLocation(path.resolve(configDir));
|
|
50
|
+
if (configRoot === path.parse(configRoot).root || configRoot === fs.realpathSync(os.homedir()) ||
|
|
51
|
+
contains(sourceRoot, configRoot) || contains(configRoot, sourceRoot)) {
|
|
52
|
+
throw new Error(`refusing unsafe OpenCode config directory: ${configDir}`);
|
|
53
|
+
}
|
|
54
|
+
const entries = [];
|
|
55
|
+
for (const kind of ['skills', 'agents']) {
|
|
56
|
+
const source = path.join(sourceRoot, kind);
|
|
57
|
+
if (!fs.statSync(source).isDirectory())
|
|
58
|
+
throw new Error(`${kind} directory not found: ${source}`);
|
|
59
|
+
const destinationRoot = path.join(configRoot, kind);
|
|
60
|
+
if (stat(destinationRoot)?.isSymbolicLink())
|
|
61
|
+
throw new Error(`refusing linked installation directory: ${destinationRoot}`);
|
|
62
|
+
let count = 0;
|
|
63
|
+
for (const name of fs.readdirSync(source).sort()) {
|
|
64
|
+
const entrySource = path.join(source, name);
|
|
65
|
+
if (kind === 'skills' ? !fs.existsSync(path.join(entrySource, 'SKILL.md')) :
|
|
66
|
+
!name.endsWith('.md') || !fs.statSync(entrySource).isFile())
|
|
67
|
+
continue;
|
|
68
|
+
count++;
|
|
69
|
+
const destination = path.join(destinationRoot, name);
|
|
70
|
+
let unchanged = false;
|
|
71
|
+
if (link && stat(destination)?.isSymbolicLink()) {
|
|
72
|
+
unchanged = path.resolve(path.dirname(destination), fs.readlinkSync(destination)) === entrySource;
|
|
73
|
+
}
|
|
74
|
+
else if (!link) {
|
|
75
|
+
unchanged = sameTree(entrySource, destination);
|
|
76
|
+
}
|
|
77
|
+
if (!unchanged && stat(destination) && !force) {
|
|
78
|
+
throw new Error(`${kind} '${name}' already exists at ${destination}; rerun with --force to replace it`);
|
|
79
|
+
}
|
|
80
|
+
entries.push({ kind, name, source: entrySource, destination, unchanged });
|
|
81
|
+
}
|
|
82
|
+
if (!count)
|
|
83
|
+
throw new Error(`no ${kind} found under ${source}`);
|
|
84
|
+
}
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
if (entry.unchanged) {
|
|
87
|
+
log(`unchanged ${entry.kind}: ${entry.name}`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (dryRun) {
|
|
91
|
+
log(`would install ${entry.kind}: ${entry.source} -> ${entry.destination} (${link ? 'link' : 'copy'})`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const parent = path.dirname(entry.destination);
|
|
95
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
96
|
+
if (!contains(configRoot, fs.realpathSync(parent)))
|
|
97
|
+
throw new Error(`installation escaped config directory: ${parent}`);
|
|
98
|
+
const staging = fs.mkdtempSync(path.join(parent, '.migration-install-'));
|
|
99
|
+
const staged = path.join(staging, 'new');
|
|
100
|
+
const backup = path.join(staging, 'backup');
|
|
101
|
+
try {
|
|
102
|
+
if (link)
|
|
103
|
+
fs.symlinkSync(entry.source, staged, process.platform === 'win32' && entry.kind === 'skills' ? 'junction' : undefined);
|
|
104
|
+
else
|
|
105
|
+
fs.cpSync(entry.source, staged, { recursive: true });
|
|
106
|
+
if (stat(entry.destination))
|
|
107
|
+
fs.renameSync(entry.destination, backup);
|
|
108
|
+
try {
|
|
109
|
+
fs.renameSync(staged, entry.destination);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
if (stat(backup))
|
|
113
|
+
fs.renameSync(backup, entry.destination);
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
// If restoring a replaced entry also failed, retain its backup for recovery.
|
|
119
|
+
if (stat(backup) && !stat(entry.destination)) {
|
|
120
|
+
log(`installation backup retained: ${backup}`);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
log(`installed ${entry.kind}: ${entry.destination}`);
|
|
127
|
+
}
|
|
128
|
+
const skills = entries.filter(entry => entry.kind === 'skills').length;
|
|
129
|
+
const agents = entries.filter(entry => entry.kind === 'agents').length;
|
|
130
|
+
log(`${application} installation ${dryRun ? 'preview' : 'complete'}: ${skills} skill(s), ${agents} agent(s).`);
|
|
131
|
+
log(`Config directory: ${configRoot}`);
|
|
132
|
+
if (!dryRun)
|
|
133
|
+
log(`Quit and restart ${application} to load the installed definitions.`);
|
|
134
|
+
return { skills, agents, configRoot };
|
|
135
|
+
}
|
package/package.json
CHANGED
|
@@ -1,45 +1,51 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@harmonyos-arkts/d2h",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Install Android-to-HarmonyOS migration agents and skills into OpenCode or DevEco Code",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"d2h": "bin/install-opencode.
|
|
8
|
-
},
|
|
9
|
-
"files": [
|
|
10
|
-
"bin/",
|
|
11
|
-
"src/",
|
|
12
|
-
"skills/",
|
|
13
|
-
"agents/",
|
|
14
|
-
"schemas/",
|
|
15
|
-
"opencode/",
|
|
16
|
-
".claude-plugin/",
|
|
17
|
-
"install-opencode.sh",
|
|
18
|
-
"README.md",
|
|
19
|
-
"LICENSE"
|
|
20
|
-
],
|
|
21
|
-
"scripts": {
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
|
|
45
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@harmonyos-arkts/d2h",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Install Android-to-HarmonyOS migration agents and skills into OpenCode or DevEco Code",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"d2h": "dist/bin/install-opencode.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist/bin/",
|
|
11
|
+
"dist/src/",
|
|
12
|
+
"skills/",
|
|
13
|
+
"agents/",
|
|
14
|
+
"schemas/",
|
|
15
|
+
"opencode/",
|
|
16
|
+
".claude-plugin/",
|
|
17
|
+
"install-opencode.sh",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc -p tsconfig.json",
|
|
23
|
+
"check": "tsc --noEmit && npm run build && node dist/scripts/check-skill-refs.js",
|
|
24
|
+
"test": "npm run build && node --test dist/test/*.test.js",
|
|
25
|
+
"prepack": "npm run build",
|
|
26
|
+
"prepublishOnly": "npm run check && npm test"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=20 <25"
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/Harmony-Ai-Tool/android-to-hmos-migration.git"
|
|
34
|
+
},
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public",
|
|
38
|
+
"registry": "https://registry.npmjs.org/"
|
|
39
|
+
},
|
|
40
|
+
"keywords": [
|
|
41
|
+
"android",
|
|
42
|
+
"harmonyos",
|
|
43
|
+
"arkts",
|
|
44
|
+
"migration",
|
|
45
|
+
"opencode"
|
|
46
|
+
],
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "^22.20.5",
|
|
49
|
+
"typescript": "^5.9.3"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/bin/install-opencode.mjs
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { fileURLToPath } from 'node:url';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import os from 'node:os';
|
|
5
|
-
import { installOpenCode } from '../src/install-opencode.mjs';
|
|
6
|
-
|
|
7
|
-
const help = `Install this package's skills and agents into OpenCode or DevEco Code.
|
|
8
|
-
|
|
9
|
-
Usage: d2h [options]
|
|
10
|
-
|
|
11
|
-
--source-dir <path> Source root (defaults to this package)
|
|
12
|
-
--agent <name> opencode (default) or deveco
|
|
13
|
-
--config-dir <path> Override the selected agent's config root
|
|
14
|
-
OPENCODE_CONFIG_DIR / DEVECO_CONFIG_DIR, or
|
|
15
|
-
$XDG_CONFIG_HOME/<agent>, or ~/.config/<agent>
|
|
16
|
-
--link Create absolute symbolic links (development only)
|
|
17
|
-
--force Replace conflicting installed skills and agents
|
|
18
|
-
--dry-run Print operations without writing files
|
|
19
|
-
-h, --help Show this help
|
|
20
|
-
|
|
21
|
-
Only skills/*/SKILL.md trees and agents/*.md are installed.
|
|
22
|
-
Existing application configuration and AGENTS.md are not modified.
|
|
23
|
-
With npx, prefer the default copy mode: its cache can be removed.
|
|
24
|
-
`;
|
|
25
|
-
|
|
26
|
-
try {
|
|
27
|
-
const options = {
|
|
28
|
-
sourceDir: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'),
|
|
29
|
-
agent: 'opencode',
|
|
30
|
-
};
|
|
31
|
-
const args = process.argv.slice(2);
|
|
32
|
-
for (let index = 0; index < args.length; index++) {
|
|
33
|
-
const arg = args[index];
|
|
34
|
-
if (arg === '-h' || arg === '--help') {
|
|
35
|
-
console.log(help);
|
|
36
|
-
process.exit(0);
|
|
37
|
-
}
|
|
38
|
-
if (arg === '--source-dir' || arg === '--config-dir' || arg === '--agent') {
|
|
39
|
-
const value = args[++index];
|
|
40
|
-
if (!value || value.startsWith('--')) throw new Error(`${arg} requires a value`);
|
|
41
|
-
options[{ '--source-dir': 'sourceDir', '--config-dir': 'configDir', '--agent': 'agent' }[arg]] = value;
|
|
42
|
-
} else if (arg === '--link' || arg === '--force' || arg === '--dry-run') {
|
|
43
|
-
options[{ '--link': 'link', '--force': 'force', '--dry-run': 'dryRun' }[arg]] = true;
|
|
44
|
-
} else {
|
|
45
|
-
throw new Error(`unknown option: ${arg}`);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
if (!['opencode', 'deveco'].includes(options.agent)) {
|
|
49
|
-
throw new Error(`unsupported agent: ${options.agent}; choose opencode or deveco`);
|
|
50
|
-
}
|
|
51
|
-
options.configDir ??= process.env[options.agent === 'deveco' ? 'DEVECO_CONFIG_DIR' : 'OPENCODE_CONFIG_DIR'] || path.join(
|
|
52
|
-
process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), options.agent);
|
|
53
|
-
installOpenCode(options);
|
|
54
|
-
} catch (error) {
|
|
55
|
-
console.error(`install-opencode: ${error.message}`);
|
|
56
|
-
process.exitCode = 1;
|
|
57
|
-
}
|
package/src/install-opencode.mjs
DELETED
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import os from 'node:os';
|
|
4
|
-
|
|
5
|
-
function stat(file) {
|
|
6
|
-
try { return fs.lstatSync(file); } catch (error) {
|
|
7
|
-
if (error.code === 'ENOENT') return null;
|
|
8
|
-
throw error;
|
|
9
|
-
}
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function sameTree(source, destination) {
|
|
13
|
-
const left = stat(source);
|
|
14
|
-
const right = stat(destination);
|
|
15
|
-
if (!left || !right) return false;
|
|
16
|
-
if (left.isSymbolicLink() || right.isSymbolicLink()) {
|
|
17
|
-
if (!left.isSymbolicLink() || !right.isSymbolicLink()) return false;
|
|
18
|
-
return fs.readlinkSync(source) === fs.readlinkSync(destination);
|
|
19
|
-
}
|
|
20
|
-
if (left.isDirectory() && right.isDirectory()) {
|
|
21
|
-
const names = fs.readdirSync(source).sort();
|
|
22
|
-
const otherNames = fs.readdirSync(destination).sort();
|
|
23
|
-
return names.length === otherNames.length && names.every((name, index) =>
|
|
24
|
-
name === otherNames[index] && sameTree(path.join(source, name), path.join(destination, name)));
|
|
25
|
-
}
|
|
26
|
-
return left.isFile() && right.isFile() && fs.readFileSync(source).equals(fs.readFileSync(destination));
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function contains(parent, child) {
|
|
30
|
-
const relative = path.relative(parent, child);
|
|
31
|
-
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Resolve existing ancestors too, including a config directory beneath a junction.
|
|
35
|
-
function resolvedLocation(directory) {
|
|
36
|
-
if (fs.existsSync(directory)) return fs.realpathSync(directory);
|
|
37
|
-
const parent = path.dirname(directory);
|
|
38
|
-
return parent === directory ? directory : path.join(resolvedLocation(parent), path.basename(directory));
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function installOpenCode({ sourceDir, configDir, agent = 'opencode', link = false, force = false, dryRun = false, log = console.log }) {
|
|
42
|
-
if (!['opencode', 'deveco'].includes(agent)) throw new Error(`unsupported agent: ${agent}`);
|
|
43
|
-
const application = agent === 'deveco' ? 'DevEco Code' : 'OpenCode';
|
|
44
|
-
if (!configDir || ['.', '..'].includes(configDir)) throw new Error(`refusing unsafe OpenCode config directory: ${configDir}`);
|
|
45
|
-
const sourceRoot = fs.realpathSync(path.resolve(sourceDir));
|
|
46
|
-
const configRoot = resolvedLocation(path.resolve(configDir));
|
|
47
|
-
if (configRoot === path.parse(configRoot).root || configRoot === fs.realpathSync(os.homedir()) ||
|
|
48
|
-
contains(sourceRoot, configRoot) || contains(configRoot, sourceRoot)) {
|
|
49
|
-
throw new Error(`refusing unsafe OpenCode config directory: ${configDir}`);
|
|
50
|
-
}
|
|
51
|
-
const entries = [];
|
|
52
|
-
for (const kind of ['skills', 'agents']) {
|
|
53
|
-
const source = path.join(sourceRoot, kind);
|
|
54
|
-
if (!fs.statSync(source).isDirectory()) throw new Error(`${kind} directory not found: ${source}`);
|
|
55
|
-
const destinationRoot = path.join(configRoot, kind);
|
|
56
|
-
if (stat(destinationRoot)?.isSymbolicLink()) throw new Error(`refusing linked installation directory: ${destinationRoot}`);
|
|
57
|
-
let count = 0;
|
|
58
|
-
for (const name of fs.readdirSync(source).sort()) {
|
|
59
|
-
const entrySource = path.join(source, name);
|
|
60
|
-
if (kind === 'skills' ? !fs.existsSync(path.join(entrySource, 'SKILL.md')) :
|
|
61
|
-
!name.endsWith('.md') || !fs.statSync(entrySource).isFile()) continue;
|
|
62
|
-
count++;
|
|
63
|
-
const destination = path.join(destinationRoot, name);
|
|
64
|
-
let unchanged = false;
|
|
65
|
-
if (link && stat(destination)?.isSymbolicLink()) {
|
|
66
|
-
unchanged = path.resolve(path.dirname(destination), fs.readlinkSync(destination)) === entrySource;
|
|
67
|
-
} else if (!link) {
|
|
68
|
-
unchanged = sameTree(entrySource, destination);
|
|
69
|
-
}
|
|
70
|
-
if (!unchanged && stat(destination) && !force) {
|
|
71
|
-
throw new Error(`${kind} '${name}' already exists at ${destination}; rerun with --force to replace it`);
|
|
72
|
-
}
|
|
73
|
-
entries.push({ kind, name, source: entrySource, destination, unchanged });
|
|
74
|
-
}
|
|
75
|
-
if (!count) throw new Error(`no ${kind} found under ${source}`);
|
|
76
|
-
}
|
|
77
|
-
for (const entry of entries) {
|
|
78
|
-
if (entry.unchanged) { log(`unchanged ${entry.kind}: ${entry.name}`); continue; }
|
|
79
|
-
if (dryRun) { log(`would install ${entry.kind}: ${entry.source} -> ${entry.destination} (${link ? 'link' : 'copy'})`); continue; }
|
|
80
|
-
const parent = path.dirname(entry.destination);
|
|
81
|
-
fs.mkdirSync(parent, { recursive: true });
|
|
82
|
-
if (!contains(configRoot, fs.realpathSync(parent))) throw new Error(`installation escaped config directory: ${parent}`);
|
|
83
|
-
const staging = fs.mkdtempSync(path.join(parent, '.migration-install-'));
|
|
84
|
-
const staged = path.join(staging, 'new');
|
|
85
|
-
const backup = path.join(staging, 'backup');
|
|
86
|
-
try {
|
|
87
|
-
if (link) fs.symlinkSync(entry.source, staged, process.platform === 'win32' && entry.kind === 'skills' ? 'junction' : undefined);
|
|
88
|
-
else fs.cpSync(entry.source, staged, { recursive: true });
|
|
89
|
-
if (stat(entry.destination)) fs.renameSync(entry.destination, backup);
|
|
90
|
-
try { fs.renameSync(staged, entry.destination); } catch (error) {
|
|
91
|
-
if (stat(backup)) fs.renameSync(backup, entry.destination);
|
|
92
|
-
throw error;
|
|
93
|
-
}
|
|
94
|
-
} finally {
|
|
95
|
-
// If restoring a replaced entry also failed, retain its backup for recovery.
|
|
96
|
-
if (stat(backup) && !stat(entry.destination)) {
|
|
97
|
-
log(`installation backup retained: ${backup}`);
|
|
98
|
-
} else {
|
|
99
|
-
fs.rmSync(staging, { recursive: true, force: true });
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
log(`installed ${entry.kind}: ${entry.destination}`);
|
|
103
|
-
}
|
|
104
|
-
const skills = entries.filter(entry => entry.kind === 'skills').length;
|
|
105
|
-
const agents = entries.filter(entry => entry.kind === 'agents').length;
|
|
106
|
-
log(`${application} installation ${dryRun ? 'preview' : 'complete'}: ${skills} skill(s), ${agents} agent(s).`);
|
|
107
|
-
log(`Config directory: ${configRoot}`);
|
|
108
|
-
if (!dryRun) log(`Quit and restart ${application} to load the installed definitions.`);
|
|
109
|
-
return { skills, agents, configRoot };
|
|
110
|
-
}
|