dex-termux-cli 0.1.0

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.
@@ -0,0 +1,1013 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { spawn } from 'node:child_process';
5
+ import {
6
+ clearProjectState,
7
+ getUserConfigPath,
8
+ loadUserConfig,
9
+ saveProjectState,
10
+ } from '../core/config.js';
11
+ import { printSection } from '../ui/output.js';
12
+ import { detectProjectContext } from '../utils/project-context.js';
13
+
14
+ const RUNTIME_CANDIDATES = {
15
+ python: [
16
+ 'python3',
17
+ 'python',
18
+ '/data/data/com.termux/files/usr/bin/python3',
19
+ '/data/data/com.termux/files/usr/bin/python',
20
+ ],
21
+ node: ['node', '/data/data/com.termux/files/usr/bin/node'],
22
+ php: ['php', '/data/data/com.termux/files/usr/bin/php'],
23
+ ruby: ['ruby', '/data/data/com.termux/files/usr/bin/ruby'],
24
+ go: ['go', '/data/data/com.termux/files/usr/bin/go'],
25
+ rust: ['rustc', '/data/data/com.termux/files/usr/bin/rustc'],
26
+ };
27
+
28
+ const RUNTIME_PACKAGES = {
29
+ python: ['python'],
30
+ node: ['nodejs'],
31
+ php: ['php'],
32
+ ruby: ['ruby'],
33
+ go: ['golang'],
34
+ rust: ['rust'],
35
+ };
36
+
37
+ const RUNTIME_SMOKE_TESTS = {
38
+ python: ['-c', 'print("dex-ok")'],
39
+ node: ['-e', 'console.log("dex-ok")'],
40
+ php: ['-r', 'echo "dex-ok";'],
41
+ ruby: ['-e', 'puts "dex-ok"'],
42
+ go: ['version'],
43
+ rust: ['--version'],
44
+ };
45
+
46
+ const TOOL_CANDIDATES = {
47
+ npm: ['npm', '/data/data/com.termux/files/usr/bin/npm'],
48
+ composer: ['composer', '/data/data/com.termux/files/usr/bin/composer'],
49
+ };
50
+
51
+ const TOOL_SMOKE_TESTS = {
52
+ npm: ['--version'],
53
+ composer: ['--version'],
54
+ };
55
+
56
+ const TOOL_PACKAGES = {
57
+ composer: ['composer'],
58
+ };
59
+
60
+ const PYTHON_IMPORT_TO_PACKAGE = {
61
+ PIL: 'Pillow',
62
+ cv2: 'opencv-python',
63
+ discord: 'discord.py',
64
+ dotenv: 'python-dotenv',
65
+ flask: 'Flask',
66
+ nextcord: 'nextcord',
67
+ numpy: 'numpy',
68
+ pandas: 'pandas',
69
+ pyrogram: 'pyrogram',
70
+ pytz: 'pytz',
71
+ requests: 'requests',
72
+ telebot: 'pyTelegramBotAPI',
73
+ telegram: 'python-telegram-bot',
74
+ yaml: 'PyYAML',
75
+ };
76
+
77
+ const PYTHON_SYSTEM_PACKAGE_HINTS = {
78
+ Pillow: ['libjpeg-turbo', 'libpng', 'freetype', 'libwebp', 'libtiff'],
79
+ };
80
+
81
+ const PYTHON_STDLIB_MODULES = new Set([
82
+ 'abc', 'argparse', 'array', 'asyncio', 'base64', 'calendar', 'collections', 'concurrent', 'contextlib',
83
+ 'copy', 'csv', 'dataclasses', 'datetime', 'decimal', 'difflib', 'enum', 'functools', 'glob', 'hashlib',
84
+ 'heapq', 'html', 'http', 'importlib', 'inspect', 'io', 'itertools', 'json', 'logging', 'math', 'numbers',
85
+ 'operator', 'os', 'pathlib', 'pickle', 'platform', 'queue', 'random', 're', 'secrets', 'shutil', 'signal',
86
+ 'socket', 'sqlite3', 'statistics', 'string', 'subprocess', 'sys', 'tempfile', 'threading', 'time',
87
+ 'traceback', 'typing', 'unittest', 'urllib', 'uuid', 'warnings', 'weakref', 'xml', 'zipfile',
88
+ ]);
89
+
90
+ export async function runInstallCommand() {
91
+ const config = await loadUserConfig();
92
+
93
+ if (!config.features.smartProjectInstall) {
94
+ throw new Error(
95
+ `La instalacion segura de proyectos esta desactivada. Activa features.smartProjectInstall en ${getUserConfigPath()}`,
96
+ );
97
+ }
98
+
99
+ const projectContext = await detectProjectContext();
100
+ if (!projectContext) {
101
+ throw new Error('No pude detectar el lenguaje del proyecto actual.');
102
+ }
103
+
104
+ const projectRoot = process.cwd();
105
+ const projectKey = getProjectKey(projectRoot);
106
+ const storedProjectState = config.projects?.[projectKey] || null;
107
+ const installMode = await detectInstallMode(projectContext.type, projectRoot);
108
+ const runtime = await ensureRuntimeAvailable(projectContext.type);
109
+
110
+ if (!runtime) {
111
+ throw new Error(`No encontre el runtime para ${projectContext.label} y no pude instalarlo con pkg.`);
112
+ }
113
+
114
+ if (!installMode) {
115
+ throw new Error(
116
+ `Detecte ${projectContext.label} y el runtime ya esta listo, pero Dex todavia no sabe instalar dependencias para ese lenguaje.`,
117
+ );
118
+ }
119
+
120
+ const installCommand = await ensureInstallCommandAvailable(runtime, installMode);
121
+ const preferSafeMode = shouldUseSafeMode(projectContext.type, projectRoot, storedProjectState);
122
+
123
+ printSection('Instalar Proyecto');
124
+ console.log(`Proyecto : ${projectRoot}`);
125
+ console.log(`Lenguaje : ${projectContext.label}`);
126
+ console.log(`Modo : ${installMode.label}`);
127
+ console.log(`Runtime : ${runtime}`);
128
+ console.log(`Comando : ${installCommand} ${installMode.args.join(' ')}`);
129
+ console.log(`Perfil : ${preferSafeMode ? 'modo seguro directo' : 'modo normal primero'}`);
130
+ console.log('Prueba : runtime verificado por Dex');
131
+
132
+ if (installMode.packages?.length) {
133
+ console.log(`Paquetes : ${installMode.packages.join(', ')}`);
134
+ }
135
+
136
+ console.log('');
137
+
138
+ if (preferSafeMode) {
139
+ await runSafeInstall({
140
+ projectRoot,
141
+ runtime,
142
+ installCommand,
143
+ installMode,
144
+ projectKey,
145
+ projectContext,
146
+ reason: storedProjectState?.lastFailureReason || 'preferencia guardada',
147
+ });
148
+ return;
149
+ }
150
+
151
+ console.log('Intento 1: instalacion normal dentro del proyecto.');
152
+ console.log('');
153
+
154
+ const directExitCode = await runCommand(installCommand, installMode.args, projectRoot);
155
+ if (directExitCode === 0) {
156
+ await clearProjectState(projectKey);
157
+ console.log('Instalacion completada sin entorno extra.');
158
+ return;
159
+ }
160
+
161
+ if (!supportsSafeMode(projectContext.type)) {
162
+ throw new Error(`La instalacion fallo y el rescate automatico aun no existe para ${projectContext.label}.`);
163
+ }
164
+
165
+ if (!isAndroidStoragePath(projectRoot)) {
166
+ throw new Error('La instalacion fallo y el proyecto no esta en Android storage. No aplique rescate automatico.');
167
+ }
168
+
169
+ await saveProjectState(projectKey, {
170
+ path: projectRoot,
171
+ language: projectContext.type,
172
+ preferSafeInstall: true,
173
+ lastFailureReason: 'fallo instalacion normal en Android storage',
174
+ installModeLabel: installMode.label,
175
+ packages: installMode.packages || [],
176
+ safeMode: getSafeModeKind(projectContext.type),
177
+ updatedAt: new Date().toISOString(),
178
+ });
179
+
180
+ await runSafeInstall({
181
+ projectRoot,
182
+ runtime,
183
+ installCommand,
184
+ installMode,
185
+ projectKey,
186
+ projectContext,
187
+ reason: 'fallo instalacion normal en Android storage',
188
+ });
189
+ }
190
+
191
+ async function runSafeInstall({
192
+ projectRoot,
193
+ runtime,
194
+ installCommand,
195
+ installMode,
196
+ projectKey,
197
+ projectContext,
198
+ reason,
199
+ }) {
200
+ if (!supportsSafeMode(projectContext.type)) {
201
+ throw new Error(`El modo seguro directo aun no existe para ${projectContext.label}.`);
202
+ }
203
+
204
+ if (!isAndroidStoragePath(projectRoot)) {
205
+ throw new Error('El modo seguro directo solo existe para proyectos dentro de Android storage.');
206
+ }
207
+
208
+ if (projectContext.type === 'python') {
209
+ const venvDir = getRescueVenvPath(projectRoot);
210
+ console.log(`Modo Dex : entrando directo al entorno seguro (${reason}).`);
211
+ console.log(`Base HOME: ${process.env.HOME || '/data/data/com.termux/files/home'}`);
212
+ console.log(`Entorno : ${venvDir}`);
213
+ console.log(`Regreso : ${projectRoot}`);
214
+ console.log('');
215
+
216
+ await ensurePythonSystemPackages(installMode);
217
+ await ensureRescueVenv(runtime, venvDir);
218
+
219
+ const rescuePython = path.join(venvDir, 'bin', 'python');
220
+ const rescueExitCode = await runCommand(rescuePython, installMode.args, projectRoot);
221
+ if (rescueExitCode !== 0) {
222
+ await saveProjectState(projectKey, {
223
+ path: projectRoot,
224
+ language: projectContext.type,
225
+ preferSafeInstall: true,
226
+ lastFailureReason: 'fallo tambien en entorno seguro',
227
+ installModeLabel: installMode.label,
228
+ packages: installMode.packages || [],
229
+ safeMode: 'python-venv',
230
+ safeWorkspacePath: venvDir,
231
+ updatedAt: new Date().toISOString(),
232
+ });
233
+
234
+ throw new Error('La instalacion tambien fallo dentro del entorno seguro.');
235
+ }
236
+
237
+ await saveProjectState(projectKey, {
238
+ path: projectRoot,
239
+ language: projectContext.type,
240
+ preferSafeInstall: true,
241
+ lastFailureReason: '',
242
+ installModeLabel: installMode.label,
243
+ packages: installMode.packages || [],
244
+ safeMode: 'python-venv',
245
+ safeWorkspacePath: venvDir,
246
+ updatedAt: new Date().toISOString(),
247
+ lastSuccessAt: new Date().toISOString(),
248
+ });
249
+
250
+ console.log('Instalacion completada usando entorno seguro de Dex.');
251
+ console.log(`Venv : ${venvDir}`);
252
+ return;
253
+ }
254
+
255
+ if (projectContext.type === 'node') {
256
+ const safeProjectDir = getSafeProjectWorkspacePath(projectRoot, projectContext.type);
257
+ console.log(`Modo Dex : entrando directo al entorno seguro (${reason}).`);
258
+ console.log(`Base HOME: ${process.env.HOME || '/data/data/com.termux/files/home'}`);
259
+ console.log(`Seguro : ${safeProjectDir}`);
260
+ console.log(`Origen : ${projectRoot}`);
261
+ console.log('');
262
+
263
+ await syncProjectToSafeWorkspace(projectRoot, safeProjectDir);
264
+
265
+ const rescueExitCode = await runCommand(installCommand, installMode.args, safeProjectDir);
266
+ if (rescueExitCode !== 0) {
267
+ await saveProjectState(projectKey, {
268
+ path: projectRoot,
269
+ language: projectContext.type,
270
+ preferSafeInstall: true,
271
+ lastFailureReason: 'fallo tambien en entorno seguro',
272
+ installModeLabel: installMode.label,
273
+ packages: installMode.packages || [],
274
+ safeMode: 'node-workspace',
275
+ safeWorkspacePath: safeProjectDir,
276
+ updatedAt: new Date().toISOString(),
277
+ });
278
+
279
+ throw new Error('La instalacion tambien fallo dentro del entorno seguro.');
280
+ }
281
+
282
+ await saveProjectState(projectKey, {
283
+ path: projectRoot,
284
+ language: projectContext.type,
285
+ preferSafeInstall: true,
286
+ lastFailureReason: '',
287
+ installModeLabel: installMode.label,
288
+ packages: installMode.packages || [],
289
+ safeMode: 'node-workspace',
290
+ safeWorkspacePath: safeProjectDir,
291
+ updatedAt: new Date().toISOString(),
292
+ lastSuccessAt: new Date().toISOString(),
293
+ });
294
+
295
+ console.log('Instalacion completada usando espacio seguro de Dex para Node.');
296
+ console.log(`Seguro : ${safeProjectDir}`);
297
+ return;
298
+ }
299
+
300
+ if (projectContext.type === 'php') {
301
+ const safeProjectDir = getSafeProjectWorkspacePath(projectRoot, projectContext.type);
302
+ console.log(`Modo Dex : entrando directo al entorno seguro (${reason}).`);
303
+ console.log(`Base HOME: ${process.env.HOME || '/data/data/com.termux/files/home'}`);
304
+ console.log(`Seguro : ${safeProjectDir}`);
305
+ console.log(`Origen : ${projectRoot}`);
306
+ console.log('');
307
+
308
+ await syncProjectToSafeWorkspace(projectRoot, safeProjectDir);
309
+
310
+ const rescueExitCode = await runCommand(installCommand, installMode.args, safeProjectDir);
311
+ if (rescueExitCode !== 0) {
312
+ await saveProjectState(projectKey, {
313
+ path: projectRoot,
314
+ language: projectContext.type,
315
+ preferSafeInstall: true,
316
+ lastFailureReason: 'fallo tambien en entorno seguro',
317
+ installModeLabel: installMode.label,
318
+ packages: installMode.packages || [],
319
+ safeMode: 'php-workspace',
320
+ safeWorkspacePath: safeProjectDir,
321
+ updatedAt: new Date().toISOString(),
322
+ });
323
+
324
+ throw new Error('La instalacion tambien fallo dentro del entorno seguro.');
325
+ }
326
+
327
+ await saveProjectState(projectKey, {
328
+ path: projectRoot,
329
+ language: projectContext.type,
330
+ preferSafeInstall: true,
331
+ lastFailureReason: '',
332
+ installModeLabel: installMode.label,
333
+ packages: installMode.packages || [],
334
+ safeMode: 'php-workspace',
335
+ safeWorkspacePath: safeProjectDir,
336
+ updatedAt: new Date().toISOString(),
337
+ lastSuccessAt: new Date().toISOString(),
338
+ });
339
+
340
+ console.log('Instalacion completada usando espacio seguro de Dex para PHP.');
341
+ console.log(`Seguro : ${safeProjectDir}`);
342
+ return;
343
+ }
344
+
345
+ throw new Error(`El modo seguro directo aun no existe para ${projectContext.label}.`);
346
+ }
347
+
348
+ function shouldUseSafeMode(projectType, projectRoot, projectState) {
349
+ return Boolean(
350
+ supportsSafeMode(projectType)
351
+ && isAndroidStoragePath(projectRoot)
352
+ && projectState?.preferSafeInstall,
353
+ );
354
+ }
355
+
356
+ function getProjectKey(projectRoot) {
357
+ return createHash('sha1').update(projectRoot).digest('hex');
358
+ }
359
+
360
+ async function detectInstallMode(projectType, projectRoot) {
361
+ if (projectType === 'node') {
362
+ const packageLockPath = path.join(projectRoot, 'package-lock.json');
363
+ if (await pathExists(packageLockPath)) {
364
+ return {
365
+ label: 'package-lock.json',
366
+ commandType: 'tool',
367
+ tool: 'npm',
368
+ args: ['ci'],
369
+ };
370
+ }
371
+
372
+ const shrinkwrapPath = path.join(projectRoot, 'npm-shrinkwrap.json');
373
+ if (await pathExists(shrinkwrapPath)) {
374
+ return {
375
+ label: 'npm-shrinkwrap.json',
376
+ commandType: 'tool',
377
+ tool: 'npm',
378
+ args: ['ci'],
379
+ };
380
+ }
381
+
382
+ const packageJsonPath = path.join(projectRoot, 'package.json');
383
+ if (await pathExists(packageJsonPath)) {
384
+ return {
385
+ label: 'package.json',
386
+ commandType: 'tool',
387
+ tool: 'npm',
388
+ args: ['install'],
389
+ };
390
+ }
391
+ }
392
+
393
+ if (projectType === 'php') {
394
+ const composerLockPath = path.join(projectRoot, 'composer.lock');
395
+ if (await pathExists(composerLockPath)) {
396
+ return {
397
+ label: 'composer.lock',
398
+ commandType: 'tool',
399
+ tool: 'composer',
400
+ args: ['install', '--no-interaction'],
401
+ };
402
+ }
403
+
404
+ const composerJsonPath = path.join(projectRoot, 'composer.json');
405
+ if (await pathExists(composerJsonPath)) {
406
+ return {
407
+ label: 'composer.json',
408
+ commandType: 'tool',
409
+ tool: 'composer',
410
+ args: ['install', '--no-interaction'],
411
+ };
412
+ }
413
+ }
414
+
415
+ if (projectType !== 'python') {
416
+ return null;
417
+ }
418
+
419
+ const requirementsPath = path.join(projectRoot, 'requirements.txt');
420
+ if (await pathExists(requirementsPath)) {
421
+ return {
422
+ label: 'requirements.txt',
423
+ commandType: 'runtime',
424
+ args: ['-m', 'pip', 'install', '-r', 'requirements.txt'],
425
+ };
426
+ }
427
+
428
+ const pyprojectPath = path.join(projectRoot, 'pyproject.toml');
429
+ if (await pathExists(pyprojectPath)) {
430
+ return {
431
+ label: 'pyproject.toml',
432
+ commandType: 'runtime',
433
+ args: ['-m', 'pip', 'install', '.'],
434
+ };
435
+ }
436
+
437
+ const setupPath = path.join(projectRoot, 'setup.py');
438
+ if (await pathExists(setupPath)) {
439
+ return {
440
+ label: 'setup.py',
441
+ commandType: 'runtime',
442
+ args: ['-m', 'pip', 'install', '.'],
443
+ };
444
+ }
445
+
446
+ const inferredPackages = await inferPythonPackagesFromImports(projectRoot);
447
+ if (inferredPackages.length) {
448
+ return {
449
+ label: 'imports detectados del proyecto',
450
+ commandType: 'runtime',
451
+ args: ['-m', 'pip', 'install', ...inferredPackages],
452
+ packages: inferredPackages,
453
+ };
454
+ }
455
+
456
+ return null;
457
+ }
458
+
459
+ async function ensureInstallCommandAvailable(runtime, installMode) {
460
+ if (installMode.commandType === 'runtime') {
461
+ return runtime;
462
+ }
463
+
464
+ if (installMode.commandType === 'tool' && installMode.tool) {
465
+ const tool = await ensureToolAvailable(installMode.tool);
466
+ if (tool) {
467
+ return tool;
468
+ }
469
+
470
+ throw new Error(`No encontre la herramienta ${installMode.tool} para completar la instalacion segura.`);
471
+ }
472
+
473
+ throw new Error('Dex no pudo resolver el comando de instalacion para este proyecto.');
474
+ }
475
+
476
+ async function inferPythonPackagesFromImports(projectRoot) {
477
+ const files = await collectPythonFiles(projectRoot);
478
+ const localModules = await collectLocalPythonModules(projectRoot);
479
+ const packages = new Set();
480
+
481
+ for (const filePath of files) {
482
+ const raw = await fs.readFile(filePath, 'utf8').catch(() => '');
483
+ const modules = extractPythonImports(raw);
484
+
485
+ for (const moduleName of modules) {
486
+ if (PYTHON_STDLIB_MODULES.has(moduleName)) {
487
+ continue;
488
+ }
489
+
490
+ if (localModules.has(moduleName)) {
491
+ continue;
492
+ }
493
+
494
+ const packageName = PYTHON_IMPORT_TO_PACKAGE[moduleName];
495
+ if (packageName) {
496
+ packages.add(packageName);
497
+ }
498
+ }
499
+ }
500
+
501
+ return [...packages].sort();
502
+ }
503
+
504
+ async function collectPythonFiles(rootDir) {
505
+ const found = [];
506
+ await walkPythonTree(rootDir, found);
507
+ return found;
508
+ }
509
+
510
+ async function walkPythonTree(currentDir, found) {
511
+ let entries = [];
512
+
513
+ try {
514
+ entries = await fs.readdir(currentDir, { withFileTypes: true });
515
+ } catch {
516
+ return;
517
+ }
518
+
519
+ for (const entry of entries) {
520
+ if (entry.name === '__pycache__' || entry.name === '.git' || entry.name === '.venv' || entry.name === 'venv') {
521
+ continue;
522
+ }
523
+
524
+ const fullPath = path.join(currentDir, entry.name);
525
+
526
+ if (entry.isDirectory()) {
527
+ await walkPythonTree(fullPath, found);
528
+ continue;
529
+ }
530
+
531
+ if (entry.isFile() && entry.name.endsWith('.py')) {
532
+ found.push(fullPath);
533
+ }
534
+ }
535
+ }
536
+
537
+ async function collectLocalPythonModules(projectRoot) {
538
+ const names = new Set();
539
+ let entries = [];
540
+
541
+ try {
542
+ entries = await fs.readdir(projectRoot, { withFileTypes: true });
543
+ } catch {
544
+ return names;
545
+ }
546
+
547
+ for (const entry of entries) {
548
+ if (entry.isFile() && entry.name.endsWith('.py')) {
549
+ names.add(path.basename(entry.name, '.py'));
550
+ continue;
551
+ }
552
+
553
+ if (entry.isDirectory()) {
554
+ names.add(entry.name);
555
+ }
556
+ }
557
+
558
+ return names;
559
+ }
560
+
561
+ function extractPythonImports(source) {
562
+ const modules = new Set();
563
+ const lines = source.split(/\r?\n/);
564
+
565
+ for (const line of lines) {
566
+ const trimmed = line.trim();
567
+
568
+ if (!trimmed || trimmed.startsWith('#')) {
569
+ continue;
570
+ }
571
+
572
+ let match = trimmed.match(/^import\s+(.+)$/);
573
+ if (match) {
574
+ const parts = match[1].split(',');
575
+ for (const part of parts) {
576
+ const topLevel = part.trim().split(/\s+as\s+/)[0].split('.')[0].trim();
577
+ if (topLevel) {
578
+ modules.add(topLevel);
579
+ }
580
+ }
581
+ continue;
582
+ }
583
+
584
+ match = trimmed.match(/^from\s+([A-Za-z_][\w.]*)\s+import\s+/);
585
+ if (match) {
586
+ const topLevel = match[1].split('.')[0].trim();
587
+ if (topLevel) {
588
+ modules.add(topLevel);
589
+ }
590
+ }
591
+ }
592
+
593
+ return modules;
594
+ }
595
+
596
+ async function ensureRuntimeAvailable(projectType) {
597
+ const runtime = await findRuntimeExecutable(projectType);
598
+ if (runtime) {
599
+ return runtime;
600
+ }
601
+
602
+ const installed = await installRuntimeWithPkg(projectType);
603
+ if (!installed) {
604
+ return '';
605
+ }
606
+
607
+ return findRuntimeExecutable(projectType);
608
+ }
609
+
610
+ async function findRuntimeExecutable(projectType) {
611
+ const candidates = RUNTIME_CANDIDATES[projectType] || [];
612
+ const smokeArgs = RUNTIME_SMOKE_TESTS[projectType] || ['--version'];
613
+
614
+ for (const candidate of candidates) {
615
+ const ok = await canRun(candidate, smokeArgs);
616
+ if (ok) {
617
+ return candidate;
618
+ }
619
+ }
620
+
621
+ return '';
622
+ }
623
+
624
+ async function findToolExecutable(toolName) {
625
+ const candidates = TOOL_CANDIDATES[toolName] || [];
626
+ const smokeArgs = TOOL_SMOKE_TESTS[toolName] || ['--version'];
627
+
628
+ for (const candidate of candidates) {
629
+ const ok = await canRun(candidate, smokeArgs);
630
+ if (ok) {
631
+ return candidate;
632
+ }
633
+ }
634
+
635
+ return '';
636
+ }
637
+
638
+ async function ensureToolAvailable(toolName) {
639
+ const tool = await findToolExecutable(toolName);
640
+ if (tool) {
641
+ return tool;
642
+ }
643
+
644
+ const installed = await installToolWithPkg(toolName);
645
+ if (!installed) {
646
+ return '';
647
+ }
648
+
649
+ return findToolExecutable(toolName);
650
+ }
651
+
652
+ async function installToolWithPkg(toolName) {
653
+ const packages = TOOL_PACKAGES[toolName] || [];
654
+
655
+ if (!packages.length) {
656
+ return false;
657
+ }
658
+
659
+ console.log(`Herramienta faltante: ${toolName}. Intentando instalar con pkg...`);
660
+ console.log(`Paquetes : ${packages.join(', ')}`);
661
+ console.log('');
662
+
663
+ return installPackagesWithPkg(packages, toolName);
664
+ }
665
+
666
+ async function installRuntimeWithPkg(projectType) {
667
+ const packages = RUNTIME_PACKAGES[projectType] || [];
668
+
669
+ if (!packages.length) {
670
+ return false;
671
+ }
672
+
673
+ console.log(`Runtime faltante para ${projectType}. Intentando instalar con pkg...`);
674
+ console.log(`Paquetes : ${packages.join(', ')}`);
675
+ console.log('');
676
+
677
+ return installPackagesWithPkg(packages, projectType);
678
+ }
679
+
680
+ function throwPkgInstallError(projectType, output) {
681
+ const normalized = output.toLowerCase();
682
+
683
+ if (normalized.includes('no mirror or mirror group selected') || normalized.includes('termux-change-repo')) {
684
+ throw new Error(
685
+ `No pude instalar ${projectType} porque Termux no tiene mirrors configurados. Ejecuta termux-change-repo, luego pkg update, y despues vuelve a correr dex -i.`,
686
+ );
687
+ }
688
+
689
+ if (normalized.includes('temporary failure resolving') || normalized.includes('failed to fetch') || normalized.includes('could not resolve')) {
690
+ throw new Error(
691
+ `No pude instalar ${projectType} con pkg por un problema de red o repositorios. Revisa tu conexion, corre pkg update y vuelve a intentar.`,
692
+ );
693
+ }
694
+
695
+ if (normalized.includes('permission denied')) {
696
+ throw new Error(
697
+ `pkg no pudo instalar ${projectType} por permisos insuficientes o entorno bloqueado. Revisa Termux y vuelve a intentarlo.`,
698
+ );
699
+ }
700
+
701
+ throw new Error(`No pude instalar ${projectType} con pkg. Revisa la salida anterior para ver el motivo exacto.`);
702
+ }
703
+
704
+ async function ensurePythonSystemPackages(installMode) {
705
+ const packages = gatherSystemPackagesForPythonInstall(installMode);
706
+ if (!packages.length) {
707
+ return;
708
+ }
709
+
710
+ console.log('Dex nota: preparando paquetes nativos de Termux para este proyecto.');
711
+ console.log(`Sistema : ${packages.join(', ')}`);
712
+ console.log('');
713
+
714
+ await installPackagesWithPkg(packages, 'dependencias nativas de Python');
715
+ }
716
+
717
+ function gatherSystemPackagesForPythonInstall(installMode) {
718
+ const packages = new Set();
719
+ const projectPackages = installMode.packages || [];
720
+
721
+ for (const packageName of projectPackages) {
722
+ const hintedPackages = PYTHON_SYSTEM_PACKAGE_HINTS[packageName] || [];
723
+ for (const hintedPackage of hintedPackages) {
724
+ packages.add(hintedPackage);
725
+ }
726
+ }
727
+
728
+ return [...packages];
729
+ }
730
+
731
+ async function installPackagesWithPkg(packages, label) {
732
+ const pkgBinary = await findPkgBinary();
733
+
734
+ if (!pkgBinary || !packages.length) {
735
+ return false;
736
+ }
737
+
738
+ const result = await runCommandCapture(
739
+ pkgBinary,
740
+ ['install', '-y', ...packages],
741
+ process.env.HOME || '/data/data/com.termux/files/home',
742
+ );
743
+
744
+ if (result.stdout.trim()) {
745
+ process.stdout.write(result.stdout);
746
+ if (!result.stdout.endsWith('\n')) {
747
+ process.stdout.write('\n');
748
+ }
749
+ }
750
+
751
+ if (result.stderr.trim()) {
752
+ process.stderr.write(result.stderr);
753
+ if (!result.stderr.endsWith('\n')) {
754
+ process.stderr.write('\n');
755
+ }
756
+ }
757
+
758
+ if (result.exitCode === 0) {
759
+ return true;
760
+ }
761
+
762
+ throwPkgInstallError(label, result.output);
763
+ return false;
764
+ }
765
+
766
+ async function findPkgBinary() {
767
+ const candidates = ['pkg', '/data/data/com.termux/files/usr/bin/pkg'];
768
+
769
+ for (const candidate of candidates) {
770
+ if (candidate.includes('/')) {
771
+ if (await pathExists(candidate)) {
772
+ return candidate;
773
+ }
774
+
775
+ continue;
776
+ }
777
+
778
+ const ok = await canRun(candidate, ['help']);
779
+ if (ok) {
780
+ return candidate;
781
+ }
782
+ }
783
+
784
+ return '';
785
+ }
786
+
787
+ async function ensureRescueVenv(basePython, venvDir) {
788
+ const rescuePython = path.join(venvDir, 'bin', 'python');
789
+ if (await pathExists(rescuePython)) {
790
+ return;
791
+ }
792
+
793
+ await fs.mkdir(path.dirname(venvDir), { recursive: true });
794
+
795
+ let exitCode = await runCommand(
796
+ basePython,
797
+ ['-m', 'venv', venvDir],
798
+ process.env.HOME || '/data/data/com.termux/files/home',
799
+ );
800
+
801
+ if (exitCode === 0 && (await pathExists(rescuePython))) {
802
+ return;
803
+ }
804
+
805
+ console.log('Dex nota: el soporte de entorno virtual no estaba listo. Intentando prepararlo...');
806
+ console.log('');
807
+
808
+ await ensureVirtualenvSupport(basePython);
809
+
810
+ exitCode = await runCommand(
811
+ basePython,
812
+ ['-m', 'virtualenv', venvDir],
813
+ process.env.HOME || '/data/data/com.termux/files/home',
814
+ );
815
+
816
+ if (exitCode !== 0 || !(await pathExists(rescuePython))) {
817
+ throw new Error('No pude crear el entorno seguro en HOME ni preparando virtualenv automaticamente.');
818
+ }
819
+ }
820
+
821
+ async function ensureVirtualenvSupport(basePython) {
822
+ const home = process.env.HOME || '/data/data/com.termux/files/home';
823
+
824
+ await runCommand(basePython, ['-m', 'ensurepip', '--upgrade'], home).catch(() => 1);
825
+
826
+ const pipCandidates = [
827
+ ['-m', 'pip', '--version'],
828
+ ['-m', 'pip3', '--version'],
829
+ ];
830
+
831
+ let pipReady = false;
832
+ for (const args of pipCandidates) {
833
+ if (await canRun(basePython, args)) {
834
+ pipReady = true;
835
+ break;
836
+ }
837
+ }
838
+
839
+ if (!pipReady) {
840
+ throw new Error('No pude preparar pip para crear el entorno seguro.');
841
+ }
842
+
843
+ const installExitCode = await runCommand(
844
+ basePython,
845
+ ['-m', 'pip', 'install', '--upgrade', 'pip', 'virtualenv'],
846
+ home,
847
+ );
848
+
849
+ if (installExitCode !== 0) {
850
+ throw new Error('No pude instalar virtualenv para preparar el entorno seguro.');
851
+ }
852
+ }
853
+
854
+ function getRescueVenvPath(projectRoot) {
855
+ const home = process.env.HOME || '/data/data/com.termux/files/home';
856
+ const digest = createHash('sha1').update(projectRoot).digest('hex').slice(0, 8);
857
+ const baseName = path.basename(projectRoot).toLowerCase().replace(/[^a-z0-9._-]+/g, '-');
858
+ const safeName = baseName || 'project';
859
+ return path.join(home, '.dex', 'venvs', `${safeName}-${digest}`);
860
+ }
861
+
862
+ function getSafeProjectWorkspacePath(projectRoot, projectType) {
863
+ const home = process.env.HOME || '/data/data/com.termux/files/home';
864
+ const digest = createHash('sha1').update(projectRoot).digest('hex').slice(0, 8);
865
+ const baseName = path.basename(projectRoot).toLowerCase().replace(/[^a-z0-9._-]+/g, '-');
866
+ const safeName = baseName || projectType || 'project';
867
+ const safeKind = projectType || 'project';
868
+ return path.join(home, '.dex', 'projects', safeKind, `${safeName}-${digest}`);
869
+ }
870
+
871
+ async function syncProjectToSafeWorkspace(projectRoot, safeProjectDir) {
872
+ await fs.mkdir(safeProjectDir, { recursive: true });
873
+ await copyDirectoryRecursive(projectRoot, safeProjectDir);
874
+ }
875
+
876
+ async function copyDirectoryRecursive(sourceDir, targetDir) {
877
+ let entries = [];
878
+
879
+ try {
880
+ entries = await fs.readdir(sourceDir, { withFileTypes: true });
881
+ } catch {
882
+ return;
883
+ }
884
+
885
+ for (const entry of entries) {
886
+ if (shouldSkipSafeCopy(entry.name)) {
887
+ continue;
888
+ }
889
+
890
+ const sourcePath = path.join(sourceDir, entry.name);
891
+ const targetPath = path.join(targetDir, entry.name);
892
+
893
+ if (entry.isDirectory()) {
894
+ await fs.mkdir(targetPath, { recursive: true });
895
+ await copyDirectoryRecursive(sourcePath, targetPath);
896
+ continue;
897
+ }
898
+
899
+ if (entry.isFile()) {
900
+ await fs.copyFile(sourcePath, targetPath);
901
+ }
902
+ }
903
+ }
904
+
905
+ function shouldSkipSafeCopy(name) {
906
+ return name === '.git'
907
+ || name === 'node_modules'
908
+ || name === 'vendor'
909
+ || name === '.venv'
910
+ || name === 'venv'
911
+ || name === '__pycache__';
912
+ }
913
+
914
+ async function pathExists(targetPath) {
915
+ try {
916
+ await fs.access(targetPath);
917
+ return true;
918
+ } catch {
919
+ return false;
920
+ }
921
+ }
922
+
923
+ function isAndroidStoragePath(projectRoot) {
924
+ return projectRoot.startsWith('/sdcard') || projectRoot.startsWith('/storage/emulated/0');
925
+ }
926
+
927
+ function supportsSafeMode(projectType) {
928
+ return projectType === 'python' || projectType === 'node' || projectType === 'php';
929
+ }
930
+
931
+ function getSafeModeKind(projectType) {
932
+ if (projectType === 'python') {
933
+ return 'python-venv';
934
+ }
935
+
936
+ if (projectType === 'node') {
937
+ return 'node-workspace';
938
+ }
939
+
940
+ if (projectType === 'php') {
941
+ return 'php-workspace';
942
+ }
943
+
944
+ return '';
945
+ }
946
+
947
+ function runCommand(command, args, cwd) {
948
+ return new Promise((resolve, reject) => {
949
+ const child = spawn(command, args, {
950
+ cwd,
951
+ stdio: 'inherit',
952
+ env: process.env,
953
+ });
954
+
955
+ child.on('error', reject);
956
+ child.on('exit', (code, signal) => {
957
+ if (signal) {
958
+ reject(new Error(`El comando termino por senal: ${signal}`));
959
+ return;
960
+ }
961
+
962
+ resolve(code || 0);
963
+ });
964
+ });
965
+ }
966
+
967
+ function runCommandCapture(command, args, cwd) {
968
+ return new Promise((resolve, reject) => {
969
+ const child = spawn(command, args, {
970
+ cwd,
971
+ stdio: ['ignore', 'pipe', 'pipe'],
972
+ env: process.env,
973
+ });
974
+
975
+ let stdout = '';
976
+ let stderr = '';
977
+
978
+ child.stdout.on('data', (chunk) => {
979
+ stdout += String(chunk);
980
+ });
981
+
982
+ child.stderr.on('data', (chunk) => {
983
+ stderr += String(chunk);
984
+ });
985
+
986
+ child.on('error', reject);
987
+ child.on('exit', (code, signal) => {
988
+ if (signal) {
989
+ reject(new Error(`El comando termino por senal: ${signal}`));
990
+ return;
991
+ }
992
+
993
+ resolve({
994
+ exitCode: code || 0,
995
+ stdout,
996
+ stderr,
997
+ output: `${stdout}\n${stderr}`,
998
+ });
999
+ });
1000
+ });
1001
+ }
1002
+
1003
+ function canRun(command, args) {
1004
+ return new Promise((resolve) => {
1005
+ const child = spawn(command, args, {
1006
+ stdio: 'ignore',
1007
+ env: process.env,
1008
+ });
1009
+
1010
+ child.on('error', () => resolve(false));
1011
+ child.on('exit', (code) => resolve(code === 0));
1012
+ });
1013
+ }