@harmonyos-arkts/d2h 0.1.3 → 0.1.6

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.
Files changed (65) hide show
  1. package/README.md +78 -44
  2. package/agents/android-to-hmos-00-orchestrator.md +34 -28
  3. package/agents/d2h.md +266 -95
  4. package/dist/bin/install-opencode.js +137 -43
  5. package/dist/src/install-opencode.js +44 -0
  6. package/dist/src/install-vscode.js +226 -0
  7. package/dist/src/toolchain.js +87 -0
  8. package/docs/environment-check.md +86 -0
  9. package/opencode/agents.json +1 -1
  10. package/opencode/vscode-d2h-package.json +7 -0
  11. package/opencode/vscode-d2h-plugin.mjs +29 -0
  12. package/package.json +6 -4
  13. package/schemas/task-envelope.schema.json +58 -4
  14. package/schemas/task-plan.schema.json +281 -63
  15. package/scripts/check-environment.mjs +321 -0
  16. package/skills/d2h-hypium-forge/SKILL.md +252 -0
  17. package/skills/d2h-hypium-forge/references/functional-spec-template.md +140 -0
  18. package/skills/d2h-hypium-forge/references/official-api.md +135 -0
  19. package/skills/d2h-hypium-forge/references/pitfalls.md +98 -0
  20. package/skills/d2h-hypium-forge/references/testability-template.md +71 -0
  21. package/skills/d2h-hypium-forge/scripts/_common_template.py +614 -0
  22. package/skills/d2h-hypium-forge/scripts/analyze_results.py +156 -0
  23. package/skills/d2h-hypium-forge/scripts/assert_audit.py +107 -0
  24. package/skills/d2h-hypium-forge/scripts/collect_runtime_pages.py +169 -0
  25. package/skills/d2h-hypium-forge/scripts/forge_report.py +325 -0
  26. package/skills/d2h-hypium-forge/scripts/inject_resource_ids.py +114 -0
  27. package/skills/d2h-hypium-forge/scripts/lint_asserts.py +80 -0
  28. package/skills/d2h-hypium-forge/scripts/lint_locate.py +60 -0
  29. package/skills/d2h-hypium-forge/scripts/merge_appgraph.py +249 -0
  30. package/skills/d2h-hypium-forge/scripts/precondition.py +140 -0
  31. package/skills/d2h-hypium-forge/scripts/preinject_ids.py +232 -0
  32. package/skills/d2h-hypium-forge/scripts/run_direct.py +392 -0
  33. package/skills/d2h-hypium-forge/scripts/run_parallel.py +121 -0
  34. package/skills/d2h-hypium-forge/scripts/scan_features.py +255 -0
  35. package/skills/d2h-hypium-forge/scripts/upgrade_asserts.py +66 -0
  36. package/skills/d2h-hypium-forge/scripts/validate_scenarios.py +84 -0
  37. package/skills/d2h-utg-ai-generate/SKILL.md +128 -0
  38. package/skills/d2h-utg-ai-generate/references/click-paths-schema.json +76 -0
  39. package/skills/d2h-utg-ai-generate/references/extraction-guide.md +34 -0
  40. package/skills/d2h-utg-ai-generate/references/facts-contract.md +96 -0
  41. package/skills/d2h-utg-ai-generate/references/page-schema.json +61 -0
  42. package/skills/d2h-utg-ai-generate/references/utg-schema.json +89 -0
  43. package/skills/d2h-utg-ai-generate/scripts/build_page_json.py +314 -0
  44. package/skills/d2h-utg-ai-generate/scripts/build_utg.py +123 -0
  45. package/skills/d2h-utg-ai-generate/scripts/capture_from_page_json.ts +280 -0
  46. package/skills/d2h-utg-ai-generate/scripts/utg_common.py +524 -0
  47. package/skills/d2h-utg-ai-generate/scripts/utg_html.py +489 -0
  48. package/skills/d2h-utg-ai-generate/scripts/validate_utg.py +69 -0
  49. package/skills/d2h-utg-ai-generate/scripts/vendor/D3-LICENSE +13 -0
  50. package/skills/d2h-utg-ai-generate/scripts/vendor/d3-7.9.0.min.js +2 -0
  51. package/skills/d2h-utg-ai-generate/tests/test_pipeline.py +369 -0
  52. package/skills/d2h-utg-static-generate/SKILL.md +73 -0
  53. package/skills/d2h-utg-static-generate/references/discoveries.md +38 -0
  54. package/skills/d2h-utg-static-generate/scripts/generate.py +499 -0
  55. package/skills/d2h-utg-static-generate/tests/test_static.py +335 -0
  56. package/skills/d2h-utg-static-generate/tests/verify-offline-html.py +60 -0
  57. package/skills/d2h-utg-static-generate/tests/verify-produced-fixture.py +131 -0
  58. package/skills/harmonyos-migration-playbook/references/ui-automation.md +4 -3
  59. package/skills/harmonyos-migration-playbook/references/windows-environment.md +1 -1
  60. package/skills/managing-d2h-toolchain/SKILL.md +23 -0
  61. package/skills/managing-d2h-toolchain/environment-requirements.json +16 -0
  62. package/skills/managing-d2h-toolchain/toolchain.json +16 -0
  63. package/skills/preparing-migration-workspace/SKILL.md +9 -7
  64. package/agents/d2h_v2.md +0 -351
  65. package/skills/preparing-migration-workspace/toolchain.json +0 -19
@@ -2,57 +2,151 @@
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import path from 'node:path';
4
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.
5
+ import { inspectInstallation, installOpenCode } from '../src/install-opencode.js';
6
+ import { inspectToolchain, installToolchain, loadToolchain } from '../src/toolchain.js';
7
+ import fs from 'node:fs';
8
+ import { inspectVSCodeInstallation, installVSCode } from '../src/install-vscode.js';
9
+ const help = `Install this package's skills and agents into OpenCode or DevEco Code.
10
+
11
+ Usage: d2h [options]
12
+
13
+ d2h install Install compatible CLIs and agent/skill definitions
14
+ d2h upgrade Reapply this package's tested toolchain and definitions
15
+ d2h upgrade --check Preview version and installation status only
16
+ d2h doctor [--agent opencode|deveco|vscode] [--config-dir <path>]
17
+ Check definitions, host environment and CLI capabilities
18
+
19
+ --check-env Check host tools and write reports without installing skills
20
+ Example: d2h --check-env --output-dir ./environment-report
21
+ Use d2h --check-env --help for check options
22
+
23
+ --source-dir <path> Source root (defaults to this package)
24
+ --agent <name> opencode (default), deveco or vscode
25
+ --extension-dir <path> VS Code HarmonyOS Dev Assistant extension root
26
+ --config-dir <path> Override the selected agent's config root
27
+ OPENCODE_CONFIG_DIR / DEVECO_CONFIG_DIR, or
28
+ $XDG_CONFIG_HOME/<agent>, or ~/.config/<agent>
29
+ --link Create absolute symbolic links (development only)
30
+ --force Replace conflicting definitions and managed CLI npm links
31
+ --dry-run Print operations without writing files
32
+ --skip-tools Install definitions only (local development)
33
+ -h, --help Show this help
34
+
35
+ Managed AppGraph and droid2hmos CLIs plus skills/*/SKILL.md trees and agents/*.md are installed.
36
+ Existing application configuration and AGENTS.md are not modified.
37
+ With npx, prefer the default copy mode: its cache can be removed.
23
38
  `;
24
39
  try {
25
- const options = {
26
- sourceDir: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'),
27
- agent: 'opencode',
28
- };
29
40
  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);
41
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
42
+ if (args[0] === 'doctor') {
43
+ let agent = 'opencode';
44
+ let configDir;
45
+ let extensionDir;
46
+ for (let index = 1; index < args.length; index++) {
47
+ const arg = args[index];
48
+ if (arg === '--agent' || arg === '--config-dir' || arg === '--extension-dir') {
49
+ const value = args[++index];
50
+ if (!value || value.startsWith('--'))
51
+ throw new Error(`${arg} requires a value`);
52
+ if (arg === '--agent')
53
+ agent = value;
54
+ else if (arg === '--config-dir')
55
+ configDir = value;
56
+ else
57
+ extensionDir = value;
58
+ }
59
+ else
60
+ throw new Error(`unknown doctor option: ${arg}`);
35
61
  }
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;
62
+ if (!['opencode', 'deveco', 'vscode'].includes(agent))
63
+ throw new Error(`unsupported agent: ${agent}`);
64
+ configDir ??= process.env[agent === 'deveco' ? 'DEVECO_CONFIG_DIR' : 'OPENCODE_CONFIG_DIR'] || path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), agent);
65
+ const installation = agent === 'vscode' ? inspectVSCodeInstallation(extensionDir) : inspectInstallation(configDir);
66
+ const packageVersion = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8')).version;
67
+ const definitionsMatch = installation.status === 'passed' && installation.packageVersion === packageVersion &&
68
+ (agent === 'vscode' || ('agent' in installation && installation.agent === agent));
69
+ console.log(`definitions ${definitionsMatch ? 'passed' : 'failed'}: ${installation.summary}${definitionsMatch ? '' : `; reinstall ${agent} definitions from D2H ${packageVersion}`}`);
70
+ const results = inspectToolchain(packageRoot);
71
+ for (const item of results)
72
+ console.log(`${item.status}: ${item.package} expected ${item.expectedVersion}, installed ${item.installedVersion ?? 'missing'}; capabilities ${JSON.stringify(item.capabilities)}`);
73
+ const checkerUrl = new URL('../../scripts/check-environment.mjs', import.meta.url);
74
+ const { main } = await import(checkerUrl.href);
75
+ const environmentExit = await main([]);
76
+ if (environmentExit !== 0 || !definitionsMatch || results.some(item => item.status !== 'passed'))
77
+ process.exitCode = 1;
78
+ }
79
+ else if (args.includes('--check-env')) {
80
+ if (args.filter(arg => arg === '--check-env').length !== 1)
81
+ throw new Error('--check-env may only be specified once');
82
+ const checkerUrl = new URL('../../scripts/check-environment.mjs', import.meta.url);
83
+ const { main } = await import(checkerUrl.href);
84
+ process.exitCode = await main(args.filter(arg => arg !== '--check-env'));
85
+ }
86
+ else {
87
+ const explicitSource = args.includes('--source-dir');
88
+ const upgrade = args[0] === 'upgrade';
89
+ const installArgs = ['install', 'upgrade'].includes(args[0]) ? args.slice(1) : args;
90
+ const checkOnly = upgrade && installArgs.includes('--check');
91
+ const skipTools = installArgs.includes('--skip-tools') || explicitSource;
92
+ let extensionDir;
93
+ const options = {
94
+ sourceDir: packageRoot,
95
+ agent: 'opencode',
96
+ };
97
+ for (let index = 0; index < installArgs.length; index++) {
98
+ const arg = installArgs[index];
99
+ if (arg === '-h' || arg === '--help') {
100
+ console.log(help);
101
+ process.exit(0);
102
+ }
103
+ if (arg === '--source-dir' || arg === '--config-dir' || arg === '--agent' || arg === '--extension-dir') {
104
+ const value = installArgs[++index];
105
+ if (!value || value.startsWith('--'))
106
+ throw new Error(`${arg} requires a value`);
107
+ if (arg === '--extension-dir')
108
+ extensionDir = value;
109
+ else {
110
+ const key = { '--source-dir': 'sourceDir', '--config-dir': 'configDir', '--agent': 'agent' }[arg];
111
+ options[key] = value;
112
+ }
113
+ }
114
+ else if (arg === '--link' || arg === '--force' || arg === '--dry-run') {
115
+ const key = { '--link': 'link', '--force': 'force', '--dry-run': 'dryRun' }[arg];
116
+ options[key] = true;
117
+ }
118
+ else if (arg === '--skip-tools' || (upgrade && arg === '--check')) {
119
+ continue;
120
+ }
121
+ else {
122
+ throw new Error(`unknown option: ${arg}`);
123
+ }
42
124
  }
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;
125
+ if (!options.agent || !['opencode', 'deveco', 'vscode'].includes(options.agent)) {
126
+ throw new Error(`unsupported agent: ${options.agent}; choose opencode, deveco or vscode`);
46
127
  }
47
- else {
48
- throw new Error(`unknown option: ${arg}`);
128
+ if (options.agent !== 'vscode' && extensionDir)
129
+ throw new Error('--extension-dir requires --agent vscode');
130
+ if (options.agent === 'vscode' && options.configDir)
131
+ throw new Error('--config-dir is not used for VS Code; use --extension-dir');
132
+ 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);
133
+ if (checkOnly) {
134
+ const installation = options.agent === 'vscode' ? inspectVSCodeInstallation(extensionDir) : inspectInstallation(options.configDir);
135
+ console.log(`definitions ${installation.status}: ${installation.summary}`);
136
+ for (const item of inspectToolchain(packageRoot))
137
+ console.log(`${item.status}: ${item.package} expected ${item.expectedVersion}, installed ${item.installedVersion ?? 'missing'}`);
138
+ }
139
+ else if (!skipTools)
140
+ installToolchain(packageRoot, { dryRun: options.dryRun, force: options.force });
141
+ else if (!explicitSource && !options.dryRun)
142
+ console.log(`Skipped managed CLI installation (${Object.keys(loadToolchain(packageRoot).tools).join(', ')}).`);
143
+ if (!checkOnly) {
144
+ if (options.agent === 'vscode')
145
+ installVSCode({ sourceDir: options.sourceDir, extensionDir, dryRun: options.dryRun });
146
+ else
147
+ installOpenCode(options);
49
148
  }
50
149
  }
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
150
  }
57
151
  catch (error) {
58
152
  console.error(`install-opencode: ${error instanceof Error ? error.message : String(error)}`);
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
+ import crypto from 'node:crypto';
4
5
  function stat(file) {
5
6
  try {
6
7
  return fs.lstatSync(file);
@@ -28,6 +29,34 @@ function sameTree(source, destination) {
28
29
  }
29
30
  return left.isFile() && right.isFile() && fs.readFileSync(source).equals(fs.readFileSync(destination));
30
31
  }
32
+ function digest(file) {
33
+ const info = fs.lstatSync(file);
34
+ const hash = crypto.createHash('sha256');
35
+ if (info.isSymbolicLink())
36
+ return hash.update(`link:${fs.readlinkSync(file)}`).digest('hex');
37
+ if (info.isFile())
38
+ return hash.update(fs.readFileSync(file)).digest('hex');
39
+ for (const name of fs.readdirSync(file).sort())
40
+ hash.update(name).update(digest(path.join(file, name)));
41
+ return hash.digest('hex');
42
+ }
43
+ export function inspectInstallation(configDir) {
44
+ const root = path.resolve(configDir);
45
+ const receipt = path.join(root, '.d2h-installation.json');
46
+ if (!fs.existsSync(receipt))
47
+ return { status: 'missing', summary: `installation record missing: ${receipt}` };
48
+ const value = JSON.parse(fs.readFileSync(receipt, 'utf8'));
49
+ if (value.schemaVersion !== 1 || !Array.isArray(value.entries))
50
+ throw new Error(`invalid installation record: ${receipt}`);
51
+ const changed = value.entries.filter(entry => {
52
+ if (!['skills', 'agents'].includes(entry.kind) || !/^[a-zA-Z0-9_.-]+$/.test(entry.name))
53
+ throw new Error(`invalid installation entry: ${entry.name}`);
54
+ const target = path.join(root, entry.kind, entry.name);
55
+ return !stat(target) || digest(target) !== entry.digest;
56
+ }).map(entry => `${entry.kind}/${entry.name}`);
57
+ return { status: changed.length ? 'failed' : 'passed', packageVersion: value.packageVersion, agent: value.agent,
58
+ summary: changed.length ? `changed or missing: ${changed.join(', ')}` : `${value.entries.length} installed definitions verified` };
59
+ }
31
60
  function contains(parent, child) {
32
61
  const relative = path.relative(parent, child);
33
62
  return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
@@ -127,6 +156,21 @@ export function installOpenCode({ sourceDir, configDir, agent = 'opencode', link
127
156
  }
128
157
  const skills = entries.filter(entry => entry.kind === 'skills').length;
129
158
  const agents = entries.filter(entry => entry.kind === 'agents').length;
159
+ if (!dryRun) {
160
+ const receipt = path.join(configRoot, '.d2h-installation.json');
161
+ const temporary = path.join(configRoot, `.d2h-installation-${process.pid}.tmp`);
162
+ const packageFile = path.join(sourceRoot, 'package.json');
163
+ const packageVersion = fs.existsSync(packageFile) ? JSON.parse(fs.readFileSync(packageFile, 'utf8')).version : 'source';
164
+ const record = { schemaVersion: 1, packageVersion, agent, entries: entries.map(entry => ({ kind: entry.kind, name: entry.name, digest: digest(entry.destination) })) };
165
+ try {
166
+ fs.writeFileSync(temporary, JSON.stringify(record, null, 2) + '\n');
167
+ fs.renameSync(temporary, receipt);
168
+ }
169
+ finally {
170
+ if (fs.existsSync(temporary))
171
+ fs.rmSync(temporary);
172
+ }
173
+ }
130
174
  log(`${application} installation ${dryRun ? 'preview' : 'complete'}: ${skills} skill(s), ${agents} agent(s).`);
131
175
  log(`Config directory: ${configRoot}`);
132
176
  if (!dryRun)
@@ -0,0 +1,226 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ const extensionPrefix = 'huaweideveloperserviceplatform.harmonyosdevassistantforvscode-';
6
+ const modeLabel = '安卓转鸿蒙';
7
+ function digest(file) {
8
+ const hash = crypto.createHash('sha256');
9
+ const info = fs.lstatSync(file);
10
+ if (info.isFile())
11
+ return hash.update(fs.readFileSync(file)).digest('hex');
12
+ for (const name of fs.readdirSync(file).sort())
13
+ hash.update(name).update(digest(path.join(file, name)));
14
+ return hash.digest('hex');
15
+ }
16
+ function inside(root, target) {
17
+ const relative = path.relative(root, target);
18
+ return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
19
+ }
20
+ function assertSafeDestination(root, target) {
21
+ if (!inside(root, target))
22
+ throw new Error(`VS Code extension path escaped its root: ${target}`);
23
+ let parent = path.dirname(target);
24
+ while (inside(root, parent)) {
25
+ if (fs.existsSync(parent) && fs.lstatSync(parent).isSymbolicLink())
26
+ throw new Error(`refusing linked extension parent: ${parent}`);
27
+ parent = path.dirname(parent);
28
+ }
29
+ if (fs.existsSync(target) && fs.lstatSync(target).isSymbolicLink())
30
+ throw new Error(`refusing linked extension target: ${target}`);
31
+ }
32
+ export function locateVSCodeExtension(extensionDir, home = os.homedir()) {
33
+ if (extensionDir)
34
+ return path.resolve(extensionDir);
35
+ const extensions = path.join(home, '.vscode', 'extensions');
36
+ if (!fs.existsSync(extensions))
37
+ throw new Error(`VS Code extensions directory not found: ${extensions}`);
38
+ const candidates = fs.readdirSync(extensions).filter(name => name.toLowerCase().startsWith(extensionPrefix))
39
+ .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
40
+ if (!candidates.length)
41
+ throw new Error('HarmonyOS Dev Assistant VS Code extension is not installed');
42
+ return path.join(extensions, candidates[0]);
43
+ }
44
+ function validatedRoot(extensionDir) {
45
+ const root = fs.realpathSync(extensionDir);
46
+ const metadata = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
47
+ if (String(metadata.name).toLowerCase() !== 'harmonyosdevassistantforvscode' ||
48
+ String(metadata.publisher).toLowerCase() !== 'huaweideveloperserviceplatform') {
49
+ throw new Error(`not the HarmonyOS Dev Assistant VS Code extension: ${root}`);
50
+ }
51
+ return root;
52
+ }
53
+ function activeBundle(root, bundleCacheRoot = path.join(os.homedir(), '.harmonybot', 'opencode-bundle', 'vscode')) {
54
+ const hashFile = path.join(root, 'resources', 'opencode', '.bundle-hash');
55
+ const hash = fs.readFileSync(hashFile, 'utf8').trim();
56
+ if (!/^[a-f0-9]{16}$/i.test(hash))
57
+ throw new Error(`invalid VS Code OpenCode bundle hash: ${hashFile}`);
58
+ const bundle = path.join(bundleCacheRoot, hash);
59
+ if (!fs.existsSync(path.join(bundle, '.bundle-complete')) || !fs.existsSync(path.join(bundle, 'opencode.json'))) {
60
+ throw new Error(`VS Code OpenCode bundle is not extracted: ${bundle}; start the extension once before installing D2H`);
61
+ }
62
+ return fs.realpathSync(bundle);
63
+ }
64
+ export function updateD2HModeLabel(source) {
65
+ const map = /\{"harmonyos-multi-device"[^{}]{0,1000}"hmos-library-adapter-fast"[^{}]{0,300}\}/g;
66
+ const matches = [...source.matchAll(map)];
67
+ if (matches.length !== 1)
68
+ throw new Error(`expected one VS Code mode map, found ${matches.length}`);
69
+ const original = matches[0][0];
70
+ const next = /(?:^|,)d2h:"[^"]*"/.test(original)
71
+ ? original.replace(/((?:^|,)d2h:)"[^"]*"/, `$1${JSON.stringify(modeLabel)}`)
72
+ : original.slice(0, -1) + `,d2h:${JSON.stringify(modeLabel)}}`;
73
+ return source.slice(0, matches[0].index) + next + source.slice(matches[0].index + original.length);
74
+ }
75
+ function updateConfig(source) {
76
+ const value = JSON.parse(source);
77
+ value.agent ??= {};
78
+ value.agent.d2h = { ...value.agent.d2h, description: modeLabel,
79
+ tools: { ...value.agent.d2h?.tools, read: true, write: true, edit: true },
80
+ permission: { ...value.agent.d2h?.permission, read: 'allow', edit: 'allow' } };
81
+ delete value.agent.d2h.permission.write;
82
+ value.plugin ??= [];
83
+ if (!Array.isArray(value.plugin))
84
+ throw new Error('opencode plugin list must be an array');
85
+ if (!value.plugin.includes('./plugin/d2h'))
86
+ value.plugin.push('./plugin/d2h');
87
+ return JSON.stringify(value, null, 2) + '\n';
88
+ }
89
+ function copyWithBackup(source, destination, backupRoot, root) {
90
+ if (!inside(root, destination) || !inside(root, backupRoot))
91
+ throw new Error('VS Code extension path escaped its root');
92
+ if (fs.existsSync(destination) && digest(source) === digest(destination))
93
+ return false;
94
+ if (fs.existsSync(destination)) {
95
+ if (fs.lstatSync(destination).isSymbolicLink())
96
+ throw new Error(`refusing linked extension path: ${destination}`);
97
+ const backup = path.join(backupRoot, path.relative(root, destination));
98
+ fs.mkdirSync(path.dirname(backup), { recursive: true });
99
+ fs.renameSync(destination, backup);
100
+ try {
101
+ fs.cpSync(source, destination, { recursive: fs.statSync(source).isDirectory() });
102
+ }
103
+ catch (error) {
104
+ fs.renameSync(backup, destination);
105
+ throw error;
106
+ }
107
+ }
108
+ else {
109
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
110
+ fs.cpSync(source, destination, { recursive: fs.statSync(source).isDirectory() });
111
+ }
112
+ return true;
113
+ }
114
+ export function installVSCode({ sourceDir, extensionDir, bundleCacheRoot, dryRun = false, log = console.log }) {
115
+ const sourceRoot = fs.realpathSync(sourceDir);
116
+ const root = validatedRoot(locateVSCodeExtension(extensionDir));
117
+ const bundle = activeBundle(root, bundleCacheRoot);
118
+ const pluginRoot = path.join(root, 'resources', 'opencode', 'plugin', 'd2h');
119
+ const configFile = path.join(root, 'resources', 'opencode', 'opencode.json');
120
+ const bundleConfigFile = path.join(bundle, 'opencode.json');
121
+ const distFiles = ['dist/extension.js', 'dist/webview/main.js'];
122
+ const patches = [
123
+ { file: configFile, content: updateConfig(fs.readFileSync(configFile, 'utf8')) },
124
+ ...distFiles.map(name => ({ file: path.join(root, name), content: updateD2HModeLabel(fs.readFileSync(path.join(root, name), 'utf8')) })),
125
+ ];
126
+ const copies = [
127
+ { source: path.join(sourceRoot, 'skills'), destination: path.join(pluginRoot, 'skills') },
128
+ { source: path.join(sourceRoot, 'agents'), destination: path.join(pluginRoot, 'agents') },
129
+ { source: path.join(sourceRoot, 'AGENTS.md'), destination: path.join(pluginRoot, 'AGENTS.md') },
130
+ { source: path.join(sourceRoot, 'opencode', 'vscode-d2h-plugin.mjs'), destination: path.join(pluginRoot, 'src', 'index.js') },
131
+ { source: path.join(sourceRoot, 'opencode', 'vscode-d2h-package.json'), destination: path.join(pluginRoot, 'package.json') },
132
+ ];
133
+ const bundleCopies = copies.map(entry => ({ source: entry.source,
134
+ destination: path.join(bundle, 'plugin', 'd2h', path.relative(pluginRoot, entry.destination)) }));
135
+ const bundleConfig = updateConfig(fs.readFileSync(bundleConfigFile, 'utf8'));
136
+ for (const entry of copies)
137
+ if (!fs.existsSync(entry.source))
138
+ throw new Error(`missing D2H source: ${entry.source}`);
139
+ for (const target of [...copies.map(entry => entry.destination), ...patches.map(entry => entry.file)])
140
+ assertSafeDestination(root, target);
141
+ for (const target of [...bundleCopies.map(entry => entry.destination), bundleConfigFile])
142
+ assertSafeDestination(bundle, target);
143
+ if (dryRun) {
144
+ for (const entry of copies)
145
+ log(`would copy ${entry.source} -> ${entry.destination}`);
146
+ for (const entry of patches)
147
+ log(`would update ${entry.file}`);
148
+ for (const entry of bundleCopies)
149
+ log(`would copy ${entry.source} -> ${entry.destination}`);
150
+ log(`would update ${bundleConfigFile}`);
151
+ return { extensionRoot: root, changed: false };
152
+ }
153
+ if (!/^name: d2h\r?$/m.test(fs.readFileSync(path.join(sourceRoot, 'agents', 'd2h.md'), 'utf8'))) {
154
+ throw new Error('d2h.md frontmatter name is missing');
155
+ }
156
+ const backupRoot = fs.mkdtempSync(path.join(root, '.d2h-backup-'));
157
+ let changed = false;
158
+ for (const entry of copies)
159
+ changed = copyWithBackup(entry.source, entry.destination, backupRoot, root) || changed;
160
+ for (const entry of patches) {
161
+ if (fs.readFileSync(entry.file, 'utf8') === entry.content)
162
+ continue;
163
+ if (fs.lstatSync(entry.file).isSymbolicLink())
164
+ throw new Error(`refusing linked extension file: ${entry.file}`);
165
+ const backup = path.join(backupRoot, path.relative(root, entry.file));
166
+ fs.mkdirSync(path.dirname(backup), { recursive: true });
167
+ fs.copyFileSync(entry.file, backup);
168
+ const temporary = `${entry.file}.d2h-${process.pid}.tmp`;
169
+ fs.writeFileSync(temporary, entry.content);
170
+ fs.renameSync(temporary, entry.file);
171
+ changed = true;
172
+ }
173
+ const bundleBackupRoot = fs.mkdtempSync(path.join(bundle, '.d2h-backup-'));
174
+ for (const entry of bundleCopies)
175
+ changed = copyWithBackup(entry.source, entry.destination, bundleBackupRoot, bundle) || changed;
176
+ if (fs.readFileSync(bundleConfigFile, 'utf8') !== bundleConfig) {
177
+ const backup = path.join(bundleBackupRoot, 'opencode.json');
178
+ fs.copyFileSync(bundleConfigFile, backup);
179
+ const temporary = `${bundleConfigFile}.d2h-${process.pid}.tmp`;
180
+ fs.writeFileSync(temporary, bundleConfig);
181
+ fs.renameSync(temporary, bundleConfigFile);
182
+ changed = true;
183
+ }
184
+ if (fs.readdirSync(bundleBackupRoot).length === 0)
185
+ fs.rmdirSync(bundleBackupRoot);
186
+ const tracked = [...copies.map(entry => entry.destination), ...patches.map(entry => entry.file)];
187
+ const receipt = { schemaVersion: 1, packageVersion: JSON.parse(fs.readFileSync(path.join(sourceRoot, 'package.json'), 'utf8')).version,
188
+ files: tracked.map(file => ({ path: path.relative(root, file).replaceAll('\\', '/'), digest: digest(file) })) };
189
+ fs.writeFileSync(path.join(pluginRoot, '.d2h-installation.json'), JSON.stringify(receipt, null, 2) + '\n');
190
+ if (!changed)
191
+ fs.rmdirSync(backupRoot);
192
+ log(`VS Code D2H ${changed ? 'installed' : 'unchanged'} at ${root}; active bundle: ${bundle}${changed ? `; backup: ${backupRoot}` : ''}`);
193
+ return { extensionRoot: root, changed, backupRoot: changed ? backupRoot : undefined };
194
+ }
195
+ export function inspectVSCodeInstallation(extensionDir, bundleCacheRoot) {
196
+ const root = validatedRoot(locateVSCodeExtension(extensionDir));
197
+ const file = path.join(root, 'resources', 'opencode', 'plugin', 'd2h', '.d2h-installation.json');
198
+ if (!fs.existsSync(file))
199
+ return { status: 'missing', summary: `installation record missing: ${file}` };
200
+ const receipt = JSON.parse(fs.readFileSync(file, 'utf8'));
201
+ if (receipt.schemaVersion !== 1 || !Array.isArray(receipt.files))
202
+ throw new Error(`invalid VS Code installation record: ${file}`);
203
+ const changed = receipt.files.filter(entry => {
204
+ const target = path.resolve(root, entry.path);
205
+ return !inside(root, target) || !fs.existsSync(target) || digest(target) !== entry.digest;
206
+ }).map(entry => entry.path);
207
+ try {
208
+ const bundle = activeBundle(root, bundleCacheRoot);
209
+ const pluginRoot = path.join(root, 'resources', 'opencode', 'plugin', 'd2h');
210
+ for (const name of ['skills', 'agents', 'AGENTS.md', 'src/index.js', 'package.json']) {
211
+ const source = path.join(pluginRoot, name);
212
+ const target = path.join(bundle, 'plugin', 'd2h', name);
213
+ if (!fs.existsSync(target) || digest(source) !== digest(target))
214
+ changed.push(`bundle/plugin/d2h/${name}`);
215
+ }
216
+ const config = JSON.parse(fs.readFileSync(path.join(bundle, 'opencode.json'), 'utf8'));
217
+ if (!config.plugin?.includes('./plugin/d2h') || config.agent?.d2h?.description !== modeLabel) {
218
+ changed.push('bundle/opencode.json');
219
+ }
220
+ }
221
+ catch (error) {
222
+ changed.push(`active bundle: ${error instanceof Error ? error.message : String(error)}`);
223
+ }
224
+ return { status: changed.length ? 'failed' : 'passed', packageVersion: receipt.packageVersion,
225
+ summary: changed.length ? `changed: ${changed.join(', ')}` : `${receipt.files.length} VS Code resources and active bundle verified` };
226
+ }
@@ -0,0 +1,87 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
4
+ export function loadToolchain(root) {
5
+ const value = JSON.parse(fs.readFileSync(path.join(root, 'skills', 'managing-d2h-toolchain', 'toolchain.json'), 'utf8'));
6
+ if (value?.schemaVersion !== 1 || !value.tools || Array.isArray(value.tools) || !Object.keys(value.tools).length ||
7
+ !value.node || !/^\d+\.\d+\.\d+$/.test(value.node.minimum) ||
8
+ !/^\d+\.\d+\.\d+$/.test(value.node.maximumExclusive))
9
+ throw new Error('invalid toolchain manifest');
10
+ for (const [name, tool] of Object.entries(value.tools)) {
11
+ if (!/^[a-z][a-z0-9-]*$/.test(name) || tool?.package !== `@harmonyos-arkts/${name}` ||
12
+ !/^\d+\.\d+\.\d+$/.test(tool.version) || !tool.capabilities ||
13
+ typeof tool.capabilities !== 'object' || Array.isArray(tool.capabilities) ||
14
+ !Object.keys(tool.capabilities).length || Object.entries(tool.capabilities).some(([capability, args]) => !/^[a-z][a-z0-9-]*$/.test(capability) || !Array.isArray(args) || !args.length ||
15
+ args.some(arg => typeof arg !== 'string' || !arg))) {
16
+ throw new Error(`invalid toolchain entry: ${name}`);
17
+ }
18
+ }
19
+ return value;
20
+ }
21
+ export const run = (command, args) => {
22
+ let executable = command;
23
+ let parameters = args;
24
+ if (process.platform === 'win32') {
25
+ const quote = (value) => `'${value.replaceAll("'", "''")}'`;
26
+ const binary = /^(npm|appgraph|droid2hmos)$/.test(command) ? `${command}.cmd` : command;
27
+ const script = `& ${[binary, ...args].map(quote).join(' ')}; exit $LASTEXITCODE`;
28
+ executable = 'powershell.exe';
29
+ parameters = ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')];
30
+ }
31
+ const result = spawnSync(executable, parameters, { encoding: 'utf8', windowsHide: true, timeout: 300000 });
32
+ return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '', error: result.error };
33
+ };
34
+ function version(output) { return output.match(/\b\d+\.\d+\.\d+\b/)?.[0] ?? null; }
35
+ export function inspectTool(name, tool, runner = run) {
36
+ const current = runner(name, ['--version']);
37
+ const installedVersion = current.status === 0 ? version(`${current.stdout}\n${current.stderr}`) : null;
38
+ const capabilities = Object.fromEntries(Object.entries(tool.capabilities).map(([capability, args]) => {
39
+ const result = installedVersion ? runner(name, args) : null;
40
+ const output = `${result?.stdout ?? ''}\n${result?.stderr ?? ''}`;
41
+ const expectedCommand = capability === 'analyze-android' ? 'analyze' : capability === 'build-android' ? 'build' : capability;
42
+ return [capability, result?.status === 0 && output.includes(expectedCommand)];
43
+ }));
44
+ return { name, package: tool.package, expectedVersion: tool.version, installedVersion,
45
+ status: installedVersion === tool.version && Object.values(capabilities).every(Boolean) ? 'passed' : 'failed', capabilities };
46
+ }
47
+ export function inspectToolchain(root, runner = run) {
48
+ const manifest = loadToolchain(root);
49
+ return Object.entries(manifest.tools).map(([name, tool]) => inspectTool(name, tool, runner));
50
+ }
51
+ export function installToolchain(root, { dryRun = false, force = false, runner = run, log = console.log } = {}) {
52
+ const manifest = loadToolchain(root);
53
+ const before = Object.entries(manifest.tools).map(([name, tool]) => inspectTool(name, tool, runner));
54
+ const targets = force ? before : before.filter(item => item.status !== 'passed');
55
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
56
+ const globalRoot = targets.length ? runner(npm, ['root', '--global']) : null;
57
+ if (targets.length && globalRoot?.status !== 0)
58
+ throw new Error(`cannot inspect global npm installation: ${globalRoot?.stderr ?? 'unknown error'}`);
59
+ for (const item of targets) {
60
+ const tool = manifest.tools[item.name];
61
+ const coordinate = `${tool.package}@${tool.version}`;
62
+ const linkedPath = path.join(globalRoot.stdout.trim(), ...tool.package.split('/'));
63
+ const linked = fs.existsSync(linkedPath) && fs.lstatSync(linkedPath).isSymbolicLink();
64
+ if (linked && !force) {
65
+ if (dryRun) {
66
+ log(`would preserve npm link ${tool.package} at ${linkedPath}; rerun with --force to replace it`);
67
+ continue;
68
+ }
69
+ throw new Error(`${tool.package} is an npm link at ${linkedPath}; rerun with --force to replace it or use --skip-tools.`);
70
+ }
71
+ if (dryRun) {
72
+ log(`would ${linked ? 'replace npm link with' : 'install'} ${coordinate}`);
73
+ continue;
74
+ }
75
+ const result = runner(npm, ['install', '--global', coordinate]);
76
+ if (result.status !== 0)
77
+ throw new Error(`failed to install ${coordinate}: ${result.stderr || result.error?.message}`);
78
+ if (linked && fs.existsSync(linkedPath) && fs.lstatSync(linkedPath).isSymbolicLink()) {
79
+ throw new Error(`${coordinate} installation completed but npm link remains at ${linkedPath}`);
80
+ }
81
+ const verified = inspectTool(item.name, tool, runner);
82
+ if (verified.status !== 'passed')
83
+ throw new Error(`${coordinate} installed but CLI version or capabilities failed verification`);
84
+ log(`installed and verified ${coordinate}`);
85
+ }
86
+ return before;
87
+ }