@buaa_smat/hometrans 0.1.11 → 0.1.13

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/dist/cli/init.js CHANGED
@@ -7,14 +7,14 @@
7
7
  */
8
8
  import fs from 'node:fs/promises';
9
9
  import path from 'node:path';
10
- import { execFileSync } from 'node:child_process';
10
+ import { execFileSync, spawnSync } from 'node:child_process';
11
11
  import { fileURLToPath } from 'node:url';
12
12
  import chalk from 'chalk';
13
13
  import figlet from 'figlet';
14
14
  import inquirer from 'inquirer';
15
15
  import { parseTree, modify, applyEdits } from 'jsonc-parser';
16
16
  import { setupMcpForAllEditors } from './mcp-setup.js';
17
- import { deriveSdkPaths, expandHome, getConfigPath, getToolsDir, loadHomeTransConfig, saveHomeTransConfig, } from './config-store.js';
17
+ import { deriveSdkPaths, expandHome, getConfigDir, getConfigPath, getToolsDir, loadHomeTransConfig, saveHomeTransConfig, } from './config-store.js';
18
18
  function ensureChalkColor() {
19
19
  if (process.stdout.isTTY && chalk.level === 0) {
20
20
  chalk.level = 1;
@@ -90,6 +90,55 @@ async function installTools(toolsRoot, config) {
90
90
  function resolveAutotestDir(toolPath) {
91
91
  return path.join(toolPath, 'test-tools', 'autotest');
92
92
  }
93
+ async function readFileIfExists(p) {
94
+ try {
95
+ return await fs.readFile(p, 'utf-8');
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ }
101
+ /** Template drift check; line-ending differences alone don't count. */
102
+ export function templatesDiffer(a, b) {
103
+ if (a === null || b === null)
104
+ return false;
105
+ const norm = (s) => s.replace(/\r\n/g, '\n');
106
+ return norm(a) !== norm(b);
107
+ }
108
+ /** e.g. 20260610-153012 — used to name config backups. */
109
+ function timestampSuffix() {
110
+ const d = new Date();
111
+ const p = (n) => String(n).padStart(2, '0');
112
+ return (`${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}` +
113
+ `-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`);
114
+ }
115
+ /**
116
+ * The bundled template changed since the local config was created — ask
117
+ * whether to regenerate. Y = back up the current file with a timestamp and
118
+ * recreate from the new template; N (default, and the non-interactive
119
+ * fallback) = keep the current file and only refresh the keys in place.
120
+ * Ctrl-C aborts init.
121
+ */
122
+ async function confirmRegenerateFromTemplate(label) {
123
+ console.log(chalk.yellow(` ! ${label}: the bundled template changed since this file was created.`));
124
+ try {
125
+ const answers = await inquirer.prompt([
126
+ {
127
+ type: 'confirm',
128
+ name: 'regenerate',
129
+ message: `Regenerate ${label} from the new template? (Y = back up current file with a timestamp, then recreate; N = keep current file, only refresh keys)`,
130
+ default: false,
131
+ },
132
+ ]);
133
+ return answers.regenerate;
134
+ }
135
+ catch (err) {
136
+ if (isPromptAbort(err))
137
+ abortInit();
138
+ console.log(chalk.yellow(' Non-interactive: keeping current file (keys refreshed only).'));
139
+ return false;
140
+ }
141
+ }
93
142
  /**
94
143
  * Initialize / refresh `agents/test-tools/autotest/config.yaml`:
95
144
  * - If config.yaml does not exist, seed it from config.yaml.example.
@@ -98,7 +147,7 @@ function resolveAutotestDir(toolPath) {
98
147
  * Returns a status string for the result summary, or null if nothing was done
99
148
  * (e.g., the autotest folder isn't present in this package).
100
149
  */
101
- async function refreshAutotestConfig(autotestDir, apiKey) {
150
+ async function refreshAutotestConfig(autotestDir, apiKey, prevExampleContent = null) {
102
151
  const examplePath = path.join(autotestDir, 'config.yaml.example');
103
152
  const configPath = path.join(autotestDir, 'config.yaml');
104
153
  const hasExample = await fs
@@ -108,6 +157,8 @@ async function refreshAutotestConfig(autotestDir, apiKey) {
108
157
  if (!hasExample)
109
158
  return null;
110
159
  let seeded = false;
160
+ let backupName = null;
161
+ let templateBackupName = null;
111
162
  const hasConfig = await fs
112
163
  .access(configPath)
113
164
  .then(() => true)
@@ -116,7 +167,33 @@ async function refreshAutotestConfig(autotestDir, apiKey) {
116
167
  await fs.copyFile(examplePath, configPath);
117
168
  seeded = true;
118
169
  }
170
+ else {
171
+ // Template drift: the bundled example changed since the local config was
172
+ // created (prevExampleContent = local example snapshot taken before the
173
+ // tools copy). Y regenerates from the new template after a timestamped
174
+ // backup; N keeps the local file (key-only refresh below) — but saves the
175
+ // old template alongside, since the new one just overwrote it on disk
176
+ // and the local config is still based on the old one.
177
+ const newExample = await fs.readFile(examplePath, 'utf-8');
178
+ if (templatesDiffer(prevExampleContent, newExample)) {
179
+ if (await confirmRegenerateFromTemplate('autotest config.yaml')) {
180
+ backupName = `config.yaml.${timestampSuffix()}.bak`;
181
+ await fs.copyFile(configPath, path.join(autotestDir, backupName));
182
+ await fs.copyFile(examplePath, configPath);
183
+ }
184
+ else {
185
+ templateBackupName = `config.yaml.example.${timestampSuffix()}.bak`;
186
+ await fs.writeFile(path.join(autotestDir, templateBackupName), prevExampleContent, 'utf-8');
187
+ }
188
+ }
189
+ }
119
190
  if (!apiKey) {
191
+ if (backupName) {
192
+ return `autotest config.yaml regenerated from new template (backup: ${backupName}; api_key left as placeholder)`;
193
+ }
194
+ if (templateBackupName) {
195
+ return `autotest config.yaml kept (old template backed up: ${templateBackupName})`;
196
+ }
120
197
  return seeded
121
198
  ? `autotest config.yaml seeded (api_key left as placeholder)`
122
199
  : null;
@@ -127,6 +204,12 @@ async function refreshAutotestConfig(autotestDir, apiKey) {
127
204
  if (updated !== original) {
128
205
  await fs.writeFile(configPath, updated, 'utf-8');
129
206
  }
207
+ if (backupName) {
208
+ return `autotest config.yaml regenerated from new template + api_key filled (backup: ${backupName})`;
209
+ }
210
+ if (templateBackupName) {
211
+ return `autotest config.yaml api_key refreshed (kept local file; old template backed up: ${templateBackupName})`;
212
+ }
130
213
  return seeded
131
214
  ? `autotest config.yaml seeded + api_key filled`
132
215
  : `autotest config.yaml api_key refreshed`;
@@ -144,7 +227,7 @@ const UI_ALIGN_SKILL = 'hmos-incremental-ui-align';
144
227
  * Returns a status string for the result summary, or null if nothing was
145
228
  * done (skill not installed there, or no values to write into an existing file).
146
229
  */
147
- export async function refreshUiAlignConfig(skillsDir, glmApiKey, hmosSdkDir) {
230
+ export async function refreshUiAlignConfig(skillsDir, glmApiKey, hmosSdkDir, prevExampleContent = null) {
148
231
  const skillDir = path.join(skillsDir, UI_ALIGN_SKILL);
149
232
  const examplePath = path.join(skillDir, 'config-example.json');
150
233
  const configPath = path.join(skillDir, 'config.json');
@@ -155,6 +238,8 @@ export async function refreshUiAlignConfig(skillsDir, glmApiKey, hmosSdkDir) {
155
238
  if (!hasExample)
156
239
  return null;
157
240
  let seeded = false;
241
+ let backupName = null;
242
+ let templateBackupName = null;
158
243
  const hasConfig = await fs
159
244
  .access(configPath)
160
245
  .then(() => true)
@@ -163,12 +248,38 @@ export async function refreshUiAlignConfig(skillsDir, glmApiKey, hmosSdkDir) {
163
248
  await fs.copyFile(examplePath, configPath);
164
249
  seeded = true;
165
250
  }
251
+ else {
252
+ // Template drift: bundled config-example.json changed since the local
253
+ // config.json was created (prevExampleContent = snapshot taken before the
254
+ // skills copy). Y regenerates from the new template after a timestamped
255
+ // backup; N keeps the local file (key-only update below) — but saves the
256
+ // old template alongside, since the new one just overwrote it on disk
257
+ // and the local config is still based on the old one.
258
+ const newExample = await fs.readFile(examplePath, 'utf-8');
259
+ if (templatesDiffer(prevExampleContent, newExample)) {
260
+ if (await confirmRegenerateFromTemplate(`${UI_ALIGN_SKILL}/config.json`)) {
261
+ backupName = `config.json.${timestampSuffix()}.bak`;
262
+ await fs.copyFile(configPath, path.join(skillDir, backupName));
263
+ await fs.copyFile(examplePath, configPath);
264
+ }
265
+ else {
266
+ templateBackupName = `config-example.json.${timestampSuffix()}.bak`;
267
+ await fs.writeFile(path.join(skillDir, templateBackupName), prevExampleContent, 'utf-8');
268
+ }
269
+ }
270
+ }
166
271
  const updates = [];
167
272
  if (glmApiKey)
168
273
  updates.push(['glm_api_key', glmApiKey]);
169
274
  if (hmosSdkDir)
170
275
  updates.push(['hmos_sdk_dir', hmosSdkDir]);
171
276
  if (updates.length === 0) {
277
+ if (backupName) {
278
+ return `${UI_ALIGN_SKILL}/config.json regenerated from new template (backup: ${backupName}; glm_api_key / hmos_sdk_dir left empty)`;
279
+ }
280
+ if (templateBackupName) {
281
+ return `${UI_ALIGN_SKILL}/config.json kept (old template backed up: ${templateBackupName})`;
282
+ }
172
283
  return seeded
173
284
  ? `${UI_ALIGN_SKILL}/config.json seeded (glm_api_key / hmos_sdk_dir left empty)`
174
285
  : null;
@@ -191,11 +302,17 @@ export async function refreshUiAlignConfig(skillsDir, glmApiKey, hmosSdkDir) {
191
302
  await fs.writeFile(configPath, raw, 'utf-8');
192
303
  }
193
304
  const what = updates.map(([k]) => k).join(' + ');
305
+ if (backupName) {
306
+ return `${UI_ALIGN_SKILL}/config.json regenerated from new template + ${what} filled (backup: ${backupName})`;
307
+ }
308
+ if (templateBackupName) {
309
+ return `${UI_ALIGN_SKILL}/config.json ${what} refreshed (kept local file; old template backed up: ${templateBackupName})`;
310
+ }
194
311
  return seeded
195
312
  ? `${UI_ALIGN_SKILL}/config.json seeded + ${what} filled`
196
313
  : `${UI_ALIGN_SKILL}/config.json ${what} refreshed`;
197
314
  }
198
- async function installSkillsTo(skillsRoot, targetDir) {
315
+ async function installSkillsTo(skillsRoot, targetDir, skipNames = new Set()) {
199
316
  let entries;
200
317
  try {
201
318
  entries = await fs.readdir(skillsRoot, { withFileTypes: true });
@@ -207,6 +324,8 @@ async function installSkillsTo(skillsRoot, targetDir) {
207
324
  for (const entry of entries) {
208
325
  if (!entry.isDirectory())
209
326
  continue;
327
+ if (skipNames.has(entry.name))
328
+ continue;
210
329
  const skillSrc = path.join(skillsRoot, entry.name);
211
330
  const hasSkillFile = await fs
212
331
  .access(path.join(skillSrc, 'SKILL.md'))
@@ -220,7 +339,7 @@ async function installSkillsTo(skillsRoot, targetDir) {
220
339
  }
221
340
  return installed;
222
341
  }
223
- async function installAgentsTo(agentsRoot, targetDir) {
342
+ async function installAgentsTo(agentsRoot, targetDir, skipNames = new Set()) {
224
343
  let entries;
225
344
  try {
226
345
  entries = await fs.readdir(agentsRoot, { withFileTypes: true });
@@ -231,6 +350,8 @@ async function installAgentsTo(agentsRoot, targetDir) {
231
350
  await fs.mkdir(targetDir, { recursive: true });
232
351
  const installed = [];
233
352
  for (const entry of entries) {
353
+ if (skipNames.has(entry.name))
354
+ continue;
234
355
  const srcPath = path.join(agentsRoot, entry.name);
235
356
  const destPath = path.join(targetDir, entry.name);
236
357
  if (entry.isDirectory()) {
@@ -245,6 +366,78 @@ async function installAgentsTo(agentsRoot, targetDir) {
245
366
  }
246
367
  return installed;
247
368
  }
369
+ /** Names of bundled skill folders (those containing a SKILL.md). */
370
+ async function listBundledSkillNames(skillsRoot) {
371
+ try {
372
+ const entries = await fs.readdir(skillsRoot, { withFileTypes: true });
373
+ const names = [];
374
+ for (const entry of entries) {
375
+ if (!entry.isDirectory())
376
+ continue;
377
+ const hasSkillFile = await fs
378
+ .access(path.join(skillsRoot, entry.name, 'SKILL.md'))
379
+ .then(() => true)
380
+ .catch(() => false);
381
+ if (hasSkillFile)
382
+ names.push(entry.name);
383
+ }
384
+ return names;
385
+ }
386
+ catch {
387
+ return [];
388
+ }
389
+ }
390
+ /** Top-level names (files and folders) under the bundled agents dir. */
391
+ async function listBundledAgentNames(agentsRoot) {
392
+ try {
393
+ const entries = await fs.readdir(agentsRoot, { withFileTypes: true });
394
+ return entries.map((entry) => entry.name);
395
+ }
396
+ catch {
397
+ return [];
398
+ }
399
+ }
400
+ async function pathExists(p) {
401
+ try {
402
+ await fs.access(p);
403
+ return true;
404
+ }
405
+ catch {
406
+ return false;
407
+ }
408
+ }
409
+ /**
410
+ * Ask whether same-name skills/agents already present in the editor's target
411
+ * dirs may be overwritten. Returns true (overwrite) when the user confirms or
412
+ * the shell is non-interactive (legacy behavior); false keeps existing files —
413
+ * the caller then only refreshes the API-key config files. Ctrl-C aborts init.
414
+ */
415
+ async function confirmOverwrite(editorName, skillConflicts, agentConflicts) {
416
+ console.log(chalk.yellow(` ! ${editorName}: the following already exist and would be overwritten:`));
417
+ if (skillConflicts.length > 0) {
418
+ console.log(chalk.yellow(` skills: ${skillConflicts.join(', ')}`));
419
+ }
420
+ if (agentConflicts.length > 0) {
421
+ console.log(chalk.yellow(` agents: ${agentConflicts.join(', ')}`));
422
+ }
423
+ try {
424
+ const answers = await inquirer.prompt([
425
+ {
426
+ type: 'confirm',
427
+ name: 'overwrite',
428
+ message: `Overwrite them in ${editorName}? (N = keep existing files; new items are still installed and API-key configs refreshed)`,
429
+ default: true,
430
+ },
431
+ ]);
432
+ return answers.overwrite;
433
+ }
434
+ catch (err) {
435
+ if (isPromptAbort(err))
436
+ abortInit();
437
+ console.log(chalk.yellow(' Non-interactive: overwriting by default.'));
438
+ return true;
439
+ }
440
+ }
248
441
  async function installForEditor(editor, skillsRoot, agentsRoot, glmApiKey, hmosSdkDir, result) {
249
442
  const marker = expandHome(editor.markerDir);
250
443
  if (marker && !(await dirExists(marker))) {
@@ -253,8 +446,30 @@ async function installForEditor(editor, skillsRoot, agentsRoot, glmApiKey, hmosS
253
446
  }
254
447
  const skillsDir = expandHome(editor.skillsDir);
255
448
  const agentsDir = expandHome(editor.agentsDir);
449
+ // Same-name items already in the editor's dirs → ask before overwriting.
450
+ const skillConflicts = [];
451
+ for (const name of await listBundledSkillNames(skillsRoot)) {
452
+ if (await pathExists(path.join(skillsDir, name)))
453
+ skillConflicts.push(name);
454
+ }
455
+ const agentConflicts = [];
456
+ for (const name of await listBundledAgentNames(agentsRoot)) {
457
+ if (await pathExists(path.join(agentsDir, name)))
458
+ agentConflicts.push(name);
459
+ }
460
+ let overwrite = true;
461
+ if (skillConflicts.length > 0 || agentConflicts.length > 0) {
462
+ overwrite = await confirmOverwrite(editor.name, skillConflicts, agentConflicts);
463
+ }
464
+ // Snapshot the installed ui-align template BEFORE the skills copy updates
465
+ // it — refreshUiAlignConfig uses it to detect template drift.
466
+ const prevUiAlignExample = await readFileIfExists(path.join(skillsDir, UI_ALIGN_SKILL, 'config-example.json'));
467
+ // N = incremental install: keep the conflicting items untouched, still copy
468
+ // everything that doesn't exist in the target dirs yet.
469
+ const skipSkills = overwrite ? new Set() : new Set(skillConflicts);
470
+ const skipAgents = overwrite ? new Set() : new Set(agentConflicts);
256
471
  try {
257
- const skills = await installSkillsTo(skillsRoot, skillsDir);
472
+ const skills = await installSkillsTo(skillsRoot, skillsDir, skipSkills);
258
473
  if (skills.length > 0) {
259
474
  result.configured.push(`${editor.name} skills (${skills.length} -> ${prettyHome(skillsDir)})`);
260
475
  }
@@ -262,10 +477,14 @@ async function installForEditor(editor, skillsRoot, agentsRoot, glmApiKey, hmosS
262
477
  catch (err) {
263
478
  result.errors.push(`${editor.name} skills: ${err.message}`);
264
479
  }
480
+ if (!overwrite) {
481
+ result.skipped.push(`${editor.name} existing kept: ${skipSkills.size} skills, ${skipAgents.size} agents (new items installed, API-key configs refreshed)`);
482
+ }
265
483
  // Seed / refresh the incremental-ui-align per-skill config.json with the
266
- // GLM key + SDK dir from ~/.hometrans/config.json (existing files: key-only update).
484
+ // GLM key + SDK dir from ~/.hometrans/config.json (existing files: key-only
485
+ // update). Runs regardless of the overwrite decision above.
267
486
  try {
268
- const status = await refreshUiAlignConfig(skillsDir, glmApiKey, hmosSdkDir);
487
+ const status = await refreshUiAlignConfig(skillsDir, glmApiKey, hmosSdkDir, prevUiAlignExample);
269
488
  if (status) {
270
489
  result.configured.push(`${editor.name} ${status}`);
271
490
  }
@@ -274,7 +493,7 @@ async function installForEditor(editor, skillsRoot, agentsRoot, glmApiKey, hmosS
274
493
  result.errors.push(`${editor.name} ${UI_ALIGN_SKILL}/config.json: ${err.message}`);
275
494
  }
276
495
  try {
277
- const agents = await installAgentsTo(agentsRoot, agentsDir);
496
+ const agents = await installAgentsTo(agentsRoot, agentsDir, skipAgents);
278
497
  if (agents.length > 0) {
279
498
  result.configured.push(`${editor.name} agents (${agents.length} -> ${prettyHome(agentsDir)})`);
280
499
  }
@@ -291,6 +510,51 @@ async function installForEditor(editor, skillsRoot, agentsRoot, glmApiKey, hmosS
291
510
  export function prettyHome(p) {
292
511
  return p;
293
512
  }
513
+ /** Bundled at the package root, alongside skills/ and agents/. */
514
+ function resolveEnvRequirementsPath() {
515
+ return path.resolve(__dirname, '..', '..', 'env-requirements.json');
516
+ }
517
+ /**
518
+ * Copy the bundled env-requirements.json into ~/.hometrans, alongside
519
+ * config.json, so it sits next to the user's config. Returns the destination
520
+ * path, or null if the package ships no env-requirements.json.
521
+ */
522
+ async function installEnvRequirements() {
523
+ const src = resolveEnvRequirementsPath();
524
+ if (!(await fileExists(src)))
525
+ return null;
526
+ const dest = path.join(getConfigDir(), 'env-requirements.json');
527
+ await fs.mkdir(getConfigDir(), { recursive: true });
528
+ await fs.copyFile(src, dest);
529
+ return dest;
530
+ }
531
+ /**
532
+ * Load env-requirements.json. The leading `$`-prefixed keys are documentation
533
+ * and ignored. Returns null (with a warning) if the file is missing or invalid
534
+ * so `ht init` degrades gracefully instead of crashing.
535
+ */
536
+ export async function loadEnvRequirements() {
537
+ const p = resolveEnvRequirementsPath();
538
+ let raw;
539
+ try {
540
+ raw = await fs.readFile(p, 'utf-8');
541
+ }
542
+ catch {
543
+ console.log(chalk.yellow(` ! env-requirements.json not found at ${p} — skipping impact check.`));
544
+ return null;
545
+ }
546
+ try {
547
+ const parsed = JSON.parse(raw);
548
+ if (!parsed.tools || !Array.isArray(parsed.requirements)) {
549
+ throw new Error('expected { tools: {...}, requirements: [...] }');
550
+ }
551
+ return parsed;
552
+ }
553
+ catch (err) {
554
+ console.log(chalk.yellow(` ! env-requirements.json is invalid (${err.message}) — skipping impact check.`));
555
+ return null;
556
+ }
557
+ }
294
558
  /** Locate a command on PATH (`where` on Windows, `which` elsewhere). */
295
559
  function whichSync(cmd) {
296
560
  const isWin = process.platform === 'win32';
@@ -307,18 +571,19 @@ function whichSync(cmd) {
307
571
  return null;
308
572
  }
309
573
  }
310
- function captureVersion(cmd, args) {
311
- try {
312
- const out = execFileSync(cmd, args, {
313
- encoding: 'utf-8',
314
- timeout: 5000,
315
- stdio: ['ignore', 'pipe', 'pipe'],
316
- });
317
- return out.split('\n')[0].trim() || null;
318
- }
319
- catch {
574
+ /**
575
+ * Run a tool's verify command and return its first non-empty output line.
576
+ * Reads the configured stream first (stdout by default; stderr for `java
577
+ * -version`), falling back to the combined output if that stream is empty.
578
+ */
579
+ function captureVerify(cmd, args, stream) {
580
+ const r = spawnSync(cmd, args, { encoding: 'utf-8', timeout: 5000 });
581
+ if (r.error)
320
582
  return null;
321
- }
583
+ const primary = stream === 'stderr' ? r.stderr : r.stdout;
584
+ const text = primary && primary.trim() ? primary : `${r.stdout ?? ''}\n${r.stderr ?? ''}`;
585
+ const line = (text ?? '').split('\n').map((l) => l.trim()).filter(Boolean)[0];
586
+ return line || null;
322
587
  }
323
588
  async function fileExists(p) {
324
589
  try {
@@ -329,123 +594,197 @@ async function fileExists(p) {
329
594
  return false;
330
595
  }
331
596
  }
332
- /** Install hints shown when a tool is missing. */
333
- const TOOL_HINTS = {
334
- deveco: 'set DEVECO_SDK_HOME via `ht init` (DevEco Studio install)',
335
- adb: 'Android SDK platform-tools (needed for Android-device capture)',
336
- hdc: 'ships with DevEco: <sdk>/default/openharmony/toolchains',
337
- python: 'install Python >= 3.10 and add to PATH (UI-align capture/parse scripts)',
338
- uv: 'https://docs.astral.sh/uv (AutoTest runs under uv)',
339
- java: 'any JDK 17+, or reuse DevEco jbr: <deveco>/jbr/bin',
340
- gitnexus: 'npm i -g gitnexus && gitnexus setup',
341
- };
597
+ /** Parse the first dotted-number sequence into [major, minor, patch]. */
598
+ export function parseVersion(s) {
599
+ const m = s.match(/(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
600
+ if (!m)
601
+ return null;
602
+ return [Number(m[1] || 0), Number(m[2] || 0), Number(m[3] || 0)];
603
+ }
604
+ function cmpVersion(a, b) {
605
+ for (let i = 0; i < 3; i++) {
606
+ const d = a[i] - b[i];
607
+ if (d !== 0)
608
+ return d < 0 ? -1 : 1;
609
+ }
610
+ return 0;
611
+ }
612
+ /**
613
+ * Is `version` inside the [min, max) window? Unparseable versions or an absent
614
+ * range are treated as satisfied (we never block on what we can't measure).
615
+ */
616
+ export function versionInRange(version, range) {
617
+ if (!range || (!range.min && !range.max))
618
+ return true;
619
+ const pv = parseVersion(version);
620
+ if (!pv)
621
+ return true;
622
+ if (range.min) {
623
+ const pm = parseVersion(range.min);
624
+ if (pm && cmpVersion(pv, pm) < 0)
625
+ return false;
626
+ }
627
+ if (range.max) {
628
+ const px = parseVersion(range.max);
629
+ if (px && cmpVersion(pv, px) >= 0)
630
+ return false;
631
+ }
632
+ return true;
633
+ }
634
+ /** Human-readable form of a range, e.g. ">= 3.10", "< 2.0", "[1.0, 2.0)". */
635
+ function describeRange(range) {
636
+ if (!range)
637
+ return '';
638
+ if (range.min && range.max)
639
+ return `[${range.min}, ${range.max})`;
640
+ if (range.min)
641
+ return `>= ${range.min}`;
642
+ if (range.max)
643
+ return `< ${range.max}`;
644
+ return '';
645
+ }
342
646
  /**
343
- * Detect external tools. `hdc` falls back to the DevEco toolchains dir and
344
- * `java` to DevEco's bundled jbr when not on PATH, mirroring how the
345
- * skills/agents themselves resolve them.
647
+ * Detect every tool declared in env-requirements.json. `source: 'env-dir'`
648
+ * tools are a directory check; `source: 'path'` tools are located by trying
649
+ * each alias on PATH, then the optional DevEco fallback path. When a `verify`
650
+ * command is present we also capture and parse the tool's version.
346
651
  */
347
- async function detectEnvironment(env) {
652
+ async function detectEnvironment(spec, env) {
348
653
  const status = new Map();
349
654
  const isWin = process.platform === 'win32';
350
- const sdkOk = env.DEVECO_SDK_HOME ? await dirExists(env.DEVECO_SDK_HOME) : false;
351
- status.set('deveco', {
352
- found: sdkOk,
353
- path: sdkOk ? env.DEVECO_SDK_HOME : undefined,
354
- });
355
- for (const cmd of ['adb', 'python', 'uv', 'gitnexus']) {
356
- const p = whichSync(cmd);
357
- const st = { found: !!p, path: p ?? undefined };
358
- if (cmd === 'python' && p) {
359
- const v = captureVersion('python', ['--version']);
360
- if (v) {
361
- st.note = v;
362
- const m = v.match(/(\d+)\.(\d+)/);
363
- if (m && (Number(m[1]) < 3 || (Number(m[1]) === 3 && Number(m[2]) < 10))) {
364
- st.note += ' (UI-align scripts require >= 3.10)';
655
+ const baseDir = (key) => key === 'DEVECO_SDK_HOME'
656
+ ? env.DEVECO_SDK_HOME
657
+ : key === 'DEVECO_PATH'
658
+ ? env.DEVECO_PATH
659
+ : '';
660
+ for (const [id, tool] of Object.entries(spec.tools)) {
661
+ if (tool.source === 'env-dir') {
662
+ const dir = tool.envVar ? baseDir(tool.envVar) : '';
663
+ const ok = dir ? await dirExists(dir) : false;
664
+ status.set(id, { found: ok, path: ok ? dir : undefined });
665
+ continue;
666
+ }
667
+ // source 'path': try each alias on PATH, then the fallback path.
668
+ const aliases = tool.aliases?.length ? tool.aliases : [id];
669
+ let resolvedPath = null;
670
+ let cmdToRun = null;
671
+ for (const alias of aliases) {
672
+ const p = whichSync(alias);
673
+ if (p) {
674
+ resolvedPath = p;
675
+ cmdToRun = alias;
676
+ break;
677
+ }
678
+ }
679
+ let viaFallback = false;
680
+ if (!resolvedPath && tool.fallback) {
681
+ const base = baseDir(tool.fallback.base);
682
+ if (base) {
683
+ const exe = isWin ? `${tool.fallback.exe}.exe` : tool.fallback.exe;
684
+ const cand = path.join(base, ...tool.fallback.segments, exe);
685
+ if (await fileExists(cand)) {
686
+ resolvedPath = cand;
687
+ cmdToRun = cand;
688
+ viaFallback = true;
365
689
  }
366
690
  }
367
691
  }
368
- status.set(cmd, st);
369
- }
370
- // hdc: PATH first, then DevEco toolchains.
371
- let hdcPath = whichSync('hdc');
372
- if (!hdcPath && sdkOk) {
373
- const cand = path.join(env.DEVECO_SDK_HOME, 'default', 'openharmony', 'toolchains', isWin ? 'hdc.exe' : 'hdc');
374
- if (await fileExists(cand))
375
- hdcPath = cand;
376
- }
377
- status.set('hdc', { found: !!hdcPath, path: hdcPath ?? undefined });
378
- // java: PATH first, then DevEco jbr.
379
- let javaPath = whichSync('java');
380
- let javaNote;
381
- if (!javaPath && env.DEVECO_PATH) {
382
- const cand = path.join(env.DEVECO_PATH, 'jbr', 'bin', isWin ? 'java.exe' : 'java');
383
- if (await fileExists(cand)) {
384
- javaPath = cand;
385
- javaNote = 'via DevEco jbr';
692
+ const st = { found: !!resolvedPath, path: resolvedPath ?? undefined };
693
+ if (resolvedPath && cmdToRun && tool.verify) {
694
+ const raw = captureVerify(cmdToRun, tool.verify, tool.versionStream === 'stderr' ? 'stderr' : 'stdout');
695
+ if (raw) {
696
+ st.note = viaFallback ? `${raw} (via ${tool.fallback.base} fallback)` : raw;
697
+ if (tool.versionRegex) {
698
+ const m = raw.match(new RegExp(tool.versionRegex));
699
+ if (m && m[1])
700
+ st.version = m[1];
701
+ }
702
+ }
703
+ else if (viaFallback) {
704
+ st.note = `via ${tool.fallback.base} fallback`;
705
+ }
706
+ }
707
+ else if (viaFallback) {
708
+ st.note = `via ${tool.fallback.base} fallback`;
386
709
  }
710
+ status.set(id, st);
387
711
  }
388
- status.set('java', { found: !!javaPath, path: javaPath ?? undefined, note: javaNote });
389
712
  return status;
390
713
  }
391
714
  /**
392
- * Which tools each skill/agent needs. `required` missing BLOCKED;
393
- * only `optional` missing LIMITED. Kept in sync with the README
394
- * "Per-skill environment requirements" matrix.
715
+ * A dependency is satisfied when its tool is present AND its detected version
716
+ * (if known) falls in the required range (dependency range overrides the tool
717
+ * default). Returns the reason token shown in the impact table when not.
395
718
  */
396
- const IMPACT_MATRIX = [
397
- { name: 'hmos-spec-generate', kind: 'skill', required: ['gitnexus'], optional: [] },
398
- { name: 'hmos-resources-convert', kind: 'skill', required: [], optional: ['java'], note: 'no java -> falls back to source res/' },
399
- { name: 'hmos-batch-ui-align', kind: 'skill', required: ['python'], optional: ['adb'], note: 'adb only for page exploration' },
400
- { name: 'hmos-incremental-ui-align', kind: 'skill', required: ['deveco', 'python', 'hdc', 'adb'], optional: [] },
401
- { name: 'hmos-integration-test', kind: 'skill', required: ['deveco', 'uv', 'hdc'], optional: [] },
402
- { name: 'hmos-fix-build-errors', kind: 'skill', required: ['deveco'], optional: [] },
403
- { name: 'hmos-convert-pipeline', kind: 'skill', required: ['deveco'], optional: ['uv', 'hdc'], note: 'test stage skippable via skip-test' },
404
- { name: 'logic-context-builder', kind: 'agent', required: [], optional: ['python'] },
405
- { name: 'logic-coder', kind: 'agent', required: [], optional: ['python'] },
406
- { name: 'spec-generator', kind: 'agent', required: ['gitnexus'], optional: [] },
407
- { name: 'build-fixer', kind: 'agent', required: ['deveco'], optional: [] },
408
- { name: 'code-reviewer', kind: 'agent', required: [], optional: ['deveco'] },
409
- { name: 'review-fixer', kind: 'agent', required: [], optional: [] },
410
- { name: 'self-tester', kind: 'agent', required: ['uv', 'hdc'], optional: [] },
411
- { name: 'self-test-fixer', kind: 'agent', required: [], optional: [] },
412
- ];
719
+ function evaluateDependency(dep, spec, tools) {
720
+ const st = tools.get(dep.tool);
721
+ const range = dep.version ?? spec.tools[dep.tool]?.version ?? null;
722
+ if (!st || !st.found) {
723
+ return { satisfied: false, reason: dep.tool };
724
+ }
725
+ if (st.version && !versionInRange(st.version, range)) {
726
+ return {
727
+ satisfied: false,
728
+ reason: `${dep.tool} ${st.version} (need ${describeRange(range)})`,
729
+ };
730
+ }
731
+ return { satisfied: true, reason: '' };
732
+ }
413
733
  /** Print detected tools + the per-skill/agent impact table. */
414
734
  export async function runEnvironmentCheck(env) {
415
735
  console.log('');
416
736
  console.log(chalk.blue(' Environment Check'));
417
- const tools = await detectEnvironment(env);
418
- const order = ['deveco', 'adb', 'hdc', 'python', 'uv', 'java', 'gitnexus'];
737
+ const spec = await loadEnvRequirements();
738
+ if (!spec)
739
+ return;
740
+ const tools = await detectEnvironment(spec, env);
741
+ const order = Object.keys(spec.tools);
419
742
  for (const name of order) {
420
743
  const st = tools.get(name);
744
+ const tool = spec.tools[name];
421
745
  if (st.found) {
422
- const note = st.note ? chalk.gray(` (${st.note})`) : '';
423
- console.log(` ${chalk.green('+')} ${name.padEnd(9)} ${st.path ?? ''}${note}`);
746
+ let note = st.note ?? '';
747
+ if (st.version && tool.version && !versionInRange(st.version, tool.version)) {
748
+ note += ` (requires ${describeRange(tool.version)})`;
749
+ }
750
+ const noteStr = note ? chalk.gray(` (${note})`) : '';
751
+ console.log(` ${chalk.green('+')} ${name.padEnd(9)} ${st.path ?? ''}${noteStr}`);
424
752
  }
425
753
  else {
426
- console.log(` ${chalk.yellow('!')} ${name.padEnd(9)} ${chalk.yellow('not found')} ${chalk.gray(TOOL_HINTS[name] ?? '')}`);
754
+ console.log(` ${chalk.yellow('!')} ${name.padEnd(9)} ${chalk.yellow('not found')} ${chalk.gray(tool.hint ?? '')}`);
427
755
  }
428
756
  }
429
- const missing = order.filter((n) => !tools.get(n).found);
430
757
  console.log('');
431
758
  console.log(chalk.blue(' Impact on skills/agents:'));
432
759
  const nameW = 28;
433
760
  console.log(chalk.gray(` ${'Name'.padEnd(nameW)} ${'Kind'.padEnd(6)} ${'Status'.padEnd(8)} Missing`));
434
- for (const row of IMPACT_MATRIX) {
435
- const missReq = row.required.filter((t) => missing.includes(t));
436
- const missOpt = row.optional.filter((t) => missing.includes(t));
761
+ let allOk = true;
762
+ for (const row of spec.requirements) {
763
+ const unmetReq = [];
764
+ const unmetOpt = [];
765
+ for (const dep of row.dependencies) {
766
+ const { satisfied, reason } = evaluateDependency(dep, spec, tools);
767
+ if (satisfied)
768
+ continue;
769
+ if (dep.level === 'required')
770
+ unmetReq.push(reason);
771
+ else
772
+ unmetOpt.push(`${reason}(optional)`);
773
+ }
437
774
  let plain;
438
775
  let color;
439
776
  let parts;
440
- if (missReq.length > 0) {
777
+ if (unmetReq.length > 0) {
441
778
  plain = 'BLOCKED';
442
779
  color = chalk.red;
443
- parts = [...missReq, ...missOpt.map((t) => `${t}(optional)`)];
780
+ parts = [...unmetReq, ...unmetOpt];
781
+ allOk = false;
444
782
  }
445
- else if (missOpt.length > 0) {
783
+ else if (unmetOpt.length > 0) {
446
784
  plain = 'LIMITED';
447
785
  color = chalk.yellow;
448
- parts = missOpt.map((t) => `${t}(optional)`);
786
+ parts = unmetOpt;
787
+ allOk = false;
449
788
  }
450
789
  else {
451
790
  plain = 'OK';
@@ -456,7 +795,7 @@ export async function runEnvironmentCheck(env) {
456
795
  console.log(` ${row.name.padEnd(nameW)} ${row.kind.padEnd(6)} ${color(plain.padEnd(8))} ${parts.join(', ')}${noteStr}`);
457
796
  }
458
797
  console.log('');
459
- if (missing.length === 0) {
798
+ if (allOk) {
460
799
  console.log(chalk.green(' All environment dependencies found — every skill/agent is fully usable.'));
461
800
  }
462
801
  else {
@@ -624,6 +963,10 @@ export async function initCommand(options = {}) {
624
963
  catch (err) {
625
964
  console.log(chalk.yellow(` ! environment check skipped: ${err.message}`));
626
965
  }
966
+ // Snapshot the currently installed autotest template BEFORE the tools copy
967
+ // overwrites it — refreshAutotestConfig compares it with the new template
968
+ // to detect drift and offer a config.yaml regeneration.
969
+ const prevAutotestExample = await readFileIfExists(path.join(resolveAutotestDir(config.env.TOOL_PATH || getToolsDir()), 'config.yaml.example'));
627
970
  // Copy bundled tools/ into ~/.hometrans/tools and record env.TOOL_PATH.
628
971
  // Must run before the autotest config step below, which seeds config.yaml
629
972
  // into the installed tools dir (the location agents read via env.TOOL_PATH).
@@ -638,13 +981,24 @@ export async function initCommand(options = {}) {
638
981
  catch (err) {
639
982
  console.log(chalk.red(` ! tools copy: ${err.message}`));
640
983
  }
984
+ // Copy env-requirements.json into ~/.hometrans, next to config.json, so the
985
+ // declared environment requirements live alongside the user's config.
986
+ try {
987
+ const envReqDest = await installEnvRequirements();
988
+ if (envReqDest) {
989
+ console.log(chalk.green(` + env-requirements.json copied -> ${prettyHome(envReqDest)}`));
990
+ }
991
+ }
992
+ catch (err) {
993
+ console.log(chalk.red(` ! env-requirements.json copy: ${err.message}`));
994
+ }
641
995
  // Initialize / refresh autotest config.yaml from the example template,
642
996
  // inside the installed tools dir (<TOOL_PATH>/test-tools/autotest).
643
997
  try {
644
998
  const toolPath = installedToolPath ?? config.env.TOOL_PATH;
645
999
  if (toolPath) {
646
1000
  const autotestDir = resolveAutotestDir(toolPath);
647
- const status = await refreshAutotestConfig(autotestDir, config.env.TEST_API_KEY);
1001
+ const status = await refreshAutotestConfig(autotestDir, config.env.TEST_API_KEY, prevAutotestExample);
648
1002
  if (status) {
649
1003
  console.log(chalk.green(` + ${status}`));
650
1004
  console.log(chalk.gray(` ${path.join(autotestDir, 'config.yaml')}`));