@orxataguy/tyr 1.0.5 → 1.0.8
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/package.json +1 -1
- package/src/core/Container.ts +10 -2
- package/src/core/Kernel.ts +15 -0
- package/src/core/sys/config.ts +77 -0
- package/src/core/sys/help.ts +149 -0
- package/src/lib/FileSystemManager.ts +22 -3
- package/src/lib/GitManager.ts +59 -0
- package/src/lib/JiraManager.ts +103 -0
- package/src/lib/ShellManager.ts +84 -1
- package/src/lib/WebManager.ts +40 -0
- package/src/lib/WorkspaceManager.ts +87 -0
package/package.json
CHANGED
package/src/core/Container.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { GitManager } from '../lib/GitManager.js';
|
|
|
6
6
|
import { SystemManager } from '../lib/SystemManager.js';
|
|
7
7
|
import { SQLManager } from '../lib/SQLManager.js';
|
|
8
8
|
import { WebManager } from '../lib/WebManager.js';
|
|
9
|
+
import { WorkspaceManager } from '../lib/WorkspaceManager.js';
|
|
10
|
+
import { JiraManager } from '../lib/JiraManager.js';
|
|
9
11
|
import { Logger, createLogger } from './Logger.js';
|
|
10
12
|
|
|
11
13
|
export type { Logger };
|
|
@@ -20,6 +22,8 @@ export interface ServiceContainer {
|
|
|
20
22
|
sys: SystemManager;
|
|
21
23
|
db: SQLManager;
|
|
22
24
|
web: WebManager;
|
|
25
|
+
workspace: WorkspaceManager;
|
|
26
|
+
jira: JiraManager;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
export class Container {
|
|
@@ -33,17 +37,21 @@ export class Container {
|
|
|
33
37
|
const logger = createLogger(isDebug);
|
|
34
38
|
const shell = new ShellManager();
|
|
35
39
|
const db = new SQLManager();
|
|
40
|
+
const web = new WebManager(logger);
|
|
41
|
+
const fs = new FileSystemManager(logger);
|
|
36
42
|
|
|
37
43
|
this.services = {
|
|
38
44
|
logger,
|
|
39
45
|
shell,
|
|
40
46
|
db,
|
|
41
|
-
web
|
|
42
|
-
fs
|
|
47
|
+
web,
|
|
48
|
+
fs,
|
|
43
49
|
pkg: new PackageManager(shell, logger),
|
|
44
50
|
docker: new DockerManager(shell, logger),
|
|
45
51
|
git: new GitManager(shell, logger),
|
|
46
52
|
sys: new SystemManager(shell, logger),
|
|
53
|
+
workspace: new WorkspaceManager(shell, fs, logger),
|
|
54
|
+
jira: new JiraManager(web, shell, logger),
|
|
47
55
|
};
|
|
48
56
|
}
|
|
49
57
|
|
package/src/core/Kernel.ts
CHANGED
|
@@ -11,6 +11,7 @@ import rem from './sys/rem';
|
|
|
11
11
|
import doc from './sys/doc';
|
|
12
12
|
import ai from './sys/ai';
|
|
13
13
|
import config from './sys/config';
|
|
14
|
+
import help from './sys/help';
|
|
14
15
|
|
|
15
16
|
import { TyrError } from './TyrError';
|
|
16
17
|
|
|
@@ -126,6 +127,20 @@ export class Kernel {
|
|
|
126
127
|
return;
|
|
127
128
|
}
|
|
128
129
|
|
|
130
|
+
// --help / -h: lista todos los comandos disponibles con su documentación
|
|
131
|
+
if (commandName === '--help' || commandName === '-h') {
|
|
132
|
+
const helpContext = {
|
|
133
|
+
...this.container.get(),
|
|
134
|
+
frameworkRoot: this.frameworkRoot,
|
|
135
|
+
userRoot: this.userRoot,
|
|
136
|
+
run: async () => {},
|
|
137
|
+
task: async <T>(_: string, action: () => Promise<T> | T) => action(),
|
|
138
|
+
fail: (msg: string) => { throw new Error(msg); },
|
|
139
|
+
} as any;
|
|
140
|
+
await help(helpContext)(args.slice(1));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
129
144
|
const runInternal = async (cmd: string, cmdArgs: string[] = []) => {
|
|
130
145
|
await this.handle([cmd, ...cmdArgs]);
|
|
131
146
|
};
|
package/src/core/sys/config.ts
CHANGED
|
@@ -59,6 +59,33 @@ function detectShellRcFile(homeDir: string): string | null {
|
|
|
59
59
|
return fallbacks.find(p => existsSync(p)) ?? path.join(homeDir, '.bashrc');
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
const PACKAGE_JSON_TEMPLATE = `{
|
|
63
|
+
"name": "tyr-commands",
|
|
64
|
+
"version": "1.0.0",
|
|
65
|
+
"type": "module",
|
|
66
|
+
"private": true,
|
|
67
|
+
"description": "Comandos personalizados de Tyr (~/.tyr/)",
|
|
68
|
+
"dependencies": {
|
|
69
|
+
"@orxataguy/tyr": "latest"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
`;
|
|
73
|
+
|
|
74
|
+
const TSCONFIG_TEMPLATE = `{
|
|
75
|
+
"compilerOptions": {
|
|
76
|
+
"target": "ESNext",
|
|
77
|
+
"module": "ESNext",
|
|
78
|
+
"moduleResolution": "node",
|
|
79
|
+
"esModuleInterop": true,
|
|
80
|
+
"strict": true,
|
|
81
|
+
"allowSyntheticDefaultImports": true,
|
|
82
|
+
"skipLibCheck": true,
|
|
83
|
+
"noEmit": true
|
|
84
|
+
},
|
|
85
|
+
"include": ["commands/**/*.ts"]
|
|
86
|
+
}
|
|
87
|
+
`;
|
|
88
|
+
|
|
62
89
|
const ENV_TEMPLATE = `# ~/.tyr/.env
|
|
63
90
|
# Variables de entorno para Tyr. Este archivo nunca debe subirse a git.
|
|
64
91
|
#
|
|
@@ -220,6 +247,27 @@ export default function config({ logger, fs: tyrFs, frameworkRoot, shell }: TyrC
|
|
|
220
247
|
logger.success(`Archivo creado: ${envPath}`);
|
|
221
248
|
}
|
|
222
249
|
|
|
250
|
+
const packageJsonPath = path.join(userRoot, 'package.json');
|
|
251
|
+
if (!tyrFs.exists(packageJsonPath)) {
|
|
252
|
+
await tyrFs.write(packageJsonPath, PACKAGE_JSON_TEMPLATE);
|
|
253
|
+
logger.success(`Archivo creado: ${packageJsonPath}`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const tsconfigPath = path.join(userRoot, 'tsconfig.json');
|
|
257
|
+
if (!tyrFs.exists(tsconfigPath)) {
|
|
258
|
+
await tyrFs.write(tsconfigPath, TSCONFIG_TEMPLATE);
|
|
259
|
+
logger.success(`Archivo creado: ${tsconfigPath}`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
logger.info('\nInstalando dependencias de tipos en ~/.tyr...');
|
|
263
|
+
shell.cd(userRoot);
|
|
264
|
+
try {
|
|
265
|
+
await shell.exec('npm install');
|
|
266
|
+
logger.success('Dependencias instaladas correctamente.');
|
|
267
|
+
} catch {
|
|
268
|
+
logger.warn('No se pudo ejecutar npm install en ~/.tyr. Hazlo manualmente.');
|
|
269
|
+
}
|
|
270
|
+
|
|
223
271
|
if (repoUrl) {
|
|
224
272
|
logger.info('\nSubiendo configuración inicial al repositorio...');
|
|
225
273
|
shell.cd(userRoot);
|
|
@@ -234,6 +282,35 @@ export default function config({ logger, fs: tyrFs, frameworkRoot, shell }: TyrC
|
|
|
234
282
|
}
|
|
235
283
|
}
|
|
236
284
|
|
|
285
|
+
// Garantizar package.json + tsconfig.json + npm install siempre,
|
|
286
|
+
// tanto en init fresh como al clonar un repo existente.
|
|
287
|
+
const packageJsonPath = path.join(userRoot, 'package.json');
|
|
288
|
+
const tsconfigPath = path.join(userRoot, 'tsconfig.json');
|
|
289
|
+
let needsInstall = false;
|
|
290
|
+
|
|
291
|
+
if (!tyrFs.exists(packageJsonPath)) {
|
|
292
|
+
await tyrFs.write(packageJsonPath, PACKAGE_JSON_TEMPLATE);
|
|
293
|
+
logger.success(`Archivo creado: ${packageJsonPath}`);
|
|
294
|
+
needsInstall = true;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (!tyrFs.exists(tsconfigPath)) {
|
|
298
|
+
await tyrFs.write(tsconfigPath, TSCONFIG_TEMPLATE);
|
|
299
|
+
logger.success(`Archivo creado: ${tsconfigPath}`);
|
|
300
|
+
needsInstall = true;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (needsInstall) {
|
|
304
|
+
logger.info('\nInstalando dependencias de tipos en ~/.tyr...');
|
|
305
|
+
shell.cd(userRoot);
|
|
306
|
+
try {
|
|
307
|
+
await shell.exec('npm install');
|
|
308
|
+
logger.success('Dependencias instaladas.');
|
|
309
|
+
} catch {
|
|
310
|
+
logger.warn('No se pudo ejecutar npm install en ~/.tyr. Hazlo manualmente.');
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
237
314
|
const aliasesPath = path.join(userRoot, `aliases${ext}`);
|
|
238
315
|
const pluginsPath = path.join(userRoot, `plugins${ext}`);
|
|
239
316
|
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { TyrContext } from '../Kernel';
|
|
4
|
+
|
|
5
|
+
interface CommandDoc {
|
|
6
|
+
name: string;
|
|
7
|
+
description: string;
|
|
8
|
+
usage: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Extrae el primer bloque JSDoc de un archivo .tyr.ts y lo parsea
|
|
13
|
+
* en descripción y ejemplos de uso.
|
|
14
|
+
*/
|
|
15
|
+
function parseCommandDoc(filePath: string): CommandDoc {
|
|
16
|
+
const fileName = path.basename(filePath, '.tyr.ts');
|
|
17
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
18
|
+
|
|
19
|
+
const match = content.match(/\/\*\*([\s\S]*?)\*\//);
|
|
20
|
+
if (!match) {
|
|
21
|
+
return { name: fileName, description: '', usage: '' };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Limpiar cada línea: eliminar el * inicial y espacios
|
|
25
|
+
const lines = match[1]
|
|
26
|
+
.split('\n')
|
|
27
|
+
.map(line => line.replace(/^\s*\*\s?/, '').trimEnd());
|
|
28
|
+
|
|
29
|
+
// Separar en descripción y bloque "Uso:"
|
|
30
|
+
const usoIndex = lines.findIndex(l => /^uso:/i.test(l.trim()));
|
|
31
|
+
|
|
32
|
+
let description = '';
|
|
33
|
+
let usage = '';
|
|
34
|
+
|
|
35
|
+
if (usoIndex !== -1) {
|
|
36
|
+
description = lines
|
|
37
|
+
.slice(0, usoIndex)
|
|
38
|
+
.filter(l => l.trim() !== '')
|
|
39
|
+
.join('\n')
|
|
40
|
+
.trim();
|
|
41
|
+
|
|
42
|
+
usage = lines
|
|
43
|
+
.slice(usoIndex + 1)
|
|
44
|
+
.filter(l => l.trim() !== '')
|
|
45
|
+
.map(l => l.trim())
|
|
46
|
+
.join('\n')
|
|
47
|
+
.trim();
|
|
48
|
+
} else {
|
|
49
|
+
description = lines.filter(l => l.trim() !== '').join('\n').trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { name: fileName, description, usage };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default function help({ userRoot }: TyrContext) {
|
|
56
|
+
return async (_args: string[]) => {
|
|
57
|
+
const commandsDir = path.join(userRoot, 'commands');
|
|
58
|
+
|
|
59
|
+
// ── ANSI ──────────────────────────────────────────────────────────
|
|
60
|
+
const reset = '\x1b[0m';
|
|
61
|
+
const bold = '\x1b[1m';
|
|
62
|
+
const dim = '\x1b[2m';
|
|
63
|
+
const cyan = '\x1b[36m';
|
|
64
|
+
const green = '\x1b[32m';
|
|
65
|
+
const yellow = '\x1b[33m';
|
|
66
|
+
const gray = '\x1b[90m';
|
|
67
|
+
const white = '\x1b[37m';
|
|
68
|
+
// ──────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
const separator = `${gray} ${'─'.repeat(50)}${reset}`;
|
|
71
|
+
|
|
72
|
+
console.log('');
|
|
73
|
+
console.log(` ${bold}${cyan}tyr${reset} ${white}Comandos disponibles${reset}`);
|
|
74
|
+
console.log(separator);
|
|
75
|
+
console.log('');
|
|
76
|
+
|
|
77
|
+
// Flags y comandos built-in del framework
|
|
78
|
+
const builtins = [
|
|
79
|
+
{ name: '--help', description: 'Muestra este listado de comandos.', usage: 'tyr --help' },
|
|
80
|
+
{ name: '--version', description: 'Muestra la versión instalada de tyr.', usage: 'tyr --version' },
|
|
81
|
+
{ name: '--config', description: 'Configura tyr por primera vez.', usage: 'tyr --config' },
|
|
82
|
+
{ name: '--update', description: 'Actualiza ~/.tyr desde el repositorio git.', usage: 'tyr --update' },
|
|
83
|
+
{ name: '--upgrade', description: 'Actualiza el paquete npm de tyr.', usage: 'tyr --upgrade' },
|
|
84
|
+
{ name: 'gen', description: 'Genera un nuevo comando a partir de una descripción con IA.', usage: 'tyr gen <nombre> "<descripción>"' },
|
|
85
|
+
{ name: 'doc', description: 'Levanta la documentación del framework en el navegador.', usage: 'tyr doc' },
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
console.log(` ${bold}${yellow}Framework${reset}`);
|
|
89
|
+
console.log('');
|
|
90
|
+
|
|
91
|
+
for (const cmd of builtins) {
|
|
92
|
+
console.log(` ${bold}${green}${cmd.name.padEnd(14)}${reset}${dim}${cmd.description}${reset}`);
|
|
93
|
+
console.log(` ${' '.repeat(14)}${gray}${cmd.usage}${reset}`);
|
|
94
|
+
console.log('');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Comandos de usuario en ~/.tyr/commands/
|
|
98
|
+
if (!fs.existsSync(commandsDir)) {
|
|
99
|
+
console.log(separator);
|
|
100
|
+
console.log(` ${yellow}No se encontró la carpeta de comandos: ${commandsDir}${reset}`);
|
|
101
|
+
console.log('');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const files = fs.readdirSync(commandsDir)
|
|
106
|
+
.filter(f => f.endsWith('.tyr.ts'))
|
|
107
|
+
.sort();
|
|
108
|
+
|
|
109
|
+
if (files.length === 0) {
|
|
110
|
+
console.log(separator);
|
|
111
|
+
console.log(` ${dim}No hay comandos en ${commandsDir}${reset}`);
|
|
112
|
+
console.log('');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
console.log(separator);
|
|
117
|
+
console.log('');
|
|
118
|
+
console.log(` ${bold}${yellow}Comandos de usuario${reset} ${gray}(~/.tyr/commands/)${reset}`);
|
|
119
|
+
console.log('');
|
|
120
|
+
|
|
121
|
+
for (const file of files) {
|
|
122
|
+
const doc = parseCommandDoc(path.join(commandsDir, file));
|
|
123
|
+
|
|
124
|
+
console.log(` ${bold}${green}${doc.name}${reset}`);
|
|
125
|
+
|
|
126
|
+
if (doc.description) {
|
|
127
|
+
for (const line of doc.description.split('\n')) {
|
|
128
|
+
console.log(` ${dim}${line}${reset}`);
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
console.log(` ${gray}Sin descripción${reset}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (doc.usage) {
|
|
135
|
+
console.log('');
|
|
136
|
+
console.log(` ${gray} Uso:${reset}`);
|
|
137
|
+
for (const line of doc.usage.split('\n')) {
|
|
138
|
+
console.log(` ${cyan} ${line}${reset}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
console.log('');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
console.log(separator);
|
|
146
|
+
console.log(` ${dim}Genera un comando nuevo con ${cyan}tyr gen <nombre> "<qué debe hacer>"${reset}`);
|
|
147
|
+
console.log('');
|
|
148
|
+
};
|
|
149
|
+
}
|
|
@@ -19,9 +19,28 @@ export class FileSystemManager {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
private resolvePath(filePath: string): string {
|
|
22
|
-
return
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
return this.expandPath(filePath);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @method expandPath
|
|
27
|
+
* @description Expands `~` and `$HOME` in a path string to the actual home directory.
|
|
28
|
+
* Useful for reading paths from environment variables (dotenv does not expand shell variables).
|
|
29
|
+
* @param {string} filePath - The path to expand.
|
|
30
|
+
* @returns {string} The expanded absolute path.
|
|
31
|
+
* @example
|
|
32
|
+
* const dir = fs.expandPath(process.env.INTEGRATIONS_DIR!);
|
|
33
|
+
* // "~/dev/datosBroker" → "/Users/mandreu/dev/datosBroker"
|
|
34
|
+
*/
|
|
35
|
+
public expandPath(filePath: string): string {
|
|
36
|
+
const home = homedir();
|
|
37
|
+
if (filePath.startsWith('~/') || filePath === '~') {
|
|
38
|
+
return path.join(home, filePath.slice(1));
|
|
39
|
+
}
|
|
40
|
+
if (filePath.startsWith('$HOME/') || filePath === '$HOME') {
|
|
41
|
+
return path.join(home, filePath.slice(5));
|
|
42
|
+
}
|
|
43
|
+
return filePath;
|
|
25
44
|
}
|
|
26
45
|
|
|
27
46
|
/**
|
package/src/lib/GitManager.ts
CHANGED
|
@@ -64,6 +64,65 @@ export class GitManager {
|
|
|
64
64
|
throw new TyrError(`Could not find the repository ` + repoUrl, e, 'Check if the repository exists or if you have the right permissions to clone it.');
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @method cloneTo
|
|
70
|
+
* @description Clones a remote repository into a specific target directory.
|
|
71
|
+
* @param {string} repoUrl - The HTTPS or SSH URL of the repository.
|
|
72
|
+
* @param {string} destDir - The absolute path of the destination directory.
|
|
73
|
+
* @example
|
|
74
|
+
* await git.cloneTo('git@github.com:org/repo.git', '/path/to/dest');
|
|
75
|
+
*/
|
|
76
|
+
public async cloneTo(repoUrl: string, destDir: string): Promise<void> {
|
|
77
|
+
this.logger.info(`Clonando ${repoUrl}...`);
|
|
78
|
+
const loader = this.shell.showLoader('Clonando repositorio...');
|
|
79
|
+
try {
|
|
80
|
+
await this.shell.exec(`git clone "${repoUrl}" "${destDir}"`);
|
|
81
|
+
await this.shell.exec(`git -C "${destDir}" config --add core.filemode false`);
|
|
82
|
+
loader.stop();
|
|
83
|
+
this.logger.success('Clonación completada.');
|
|
84
|
+
} catch (e) {
|
|
85
|
+
loader.stop();
|
|
86
|
+
throw new TyrError(`No se pudo clonar el repositorio: ${repoUrl}`, e, 'Comprueba que el repositorio existe y que tienes permisos para clonarlo.');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @method checkRepoExists
|
|
92
|
+
* @description Checks if a remote Git repository is accessible via ls-remote.
|
|
93
|
+
* @param {string} repoUrl - The URL of the repository to check.
|
|
94
|
+
* @returns {Promise<boolean>} True if the repository is reachable.
|
|
95
|
+
* @example
|
|
96
|
+
* const exists = await git.checkRepoExists('git@github.com:org/repo.git');
|
|
97
|
+
*/
|
|
98
|
+
public async checkRepoExists(repoUrl: string): Promise<boolean> {
|
|
99
|
+
try {
|
|
100
|
+
await this.shell.exec(`git ls-remote "${repoUrl}" HEAD`);
|
|
101
|
+
return true;
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @method initWithRemote
|
|
109
|
+
* @description Removes an existing .git folder if present, then initialises a new Git repository
|
|
110
|
+
* in the given directory and configures a remote origin.
|
|
111
|
+
* @param {string} dir - The absolute path of the directory to initialise.
|
|
112
|
+
* @param {string} remoteUrl - The remote URL to set as origin.
|
|
113
|
+
* @example
|
|
114
|
+
* await git.initWithRemote('/path/to/dir', 'git@github.com:org/repo.git');
|
|
115
|
+
*/
|
|
116
|
+
public async initWithRemote(dir: string, remoteUrl: string): Promise<void> {
|
|
117
|
+
try {
|
|
118
|
+
await this.shell.exec(
|
|
119
|
+
`cd "${dir}" && rm -rf .git && git init -b master && git remote add origin "${remoteUrl}" && git config --add core.filemode false && echo 'node_modules' >> .gitignore`
|
|
120
|
+
);
|
|
121
|
+
this.logger.success(`Repositorio Git inicializado en ${dir}`);
|
|
122
|
+
} catch (e) {
|
|
123
|
+
throw new TyrError(`No se pudo inicializar el repositorio en ${dir}`, e);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
67
126
|
}
|
|
68
127
|
|
|
69
128
|
/**
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { WebManager } from './WebManager.js';
|
|
2
|
+
import { ShellManager } from './ShellManager.js';
|
|
3
|
+
import { Logger } from '../core/Logger.js';
|
|
4
|
+
|
|
5
|
+
interface JiraIssue {
|
|
6
|
+
key: string;
|
|
7
|
+
summary: string;
|
|
8
|
+
status: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @class JiraManager
|
|
13
|
+
* @description Integration with the Jira REST API. Allows fetching and selecting issues
|
|
14
|
+
* assigned to the current user. Falls back to manual branch input if Jira is unavailable.
|
|
15
|
+
*
|
|
16
|
+
* Required environment variables:
|
|
17
|
+
* JIRA_URL – e.g. https://yourcompany.atlassian.net
|
|
18
|
+
* JIRA_TOKEN – base64 of "email:api_token" (Basic Auth)
|
|
19
|
+
*/
|
|
20
|
+
export class JiraManager {
|
|
21
|
+
private web: WebManager;
|
|
22
|
+
private shell: ShellManager;
|
|
23
|
+
private logger: Logger;
|
|
24
|
+
|
|
25
|
+
constructor(web: WebManager, shell: ShellManager, logger: Logger) {
|
|
26
|
+
this.web = web;
|
|
27
|
+
this.shell = shell;
|
|
28
|
+
this.logger = logger;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private get jiraUrl(): string | undefined {
|
|
32
|
+
return process.env.JIRA_URL;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private get jiraToken(): string | undefined {
|
|
36
|
+
return process.env.JIRA_TOKEN;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private async fetchMyIssues(): Promise<JiraIssue[]> {
|
|
40
|
+
if (!this.jiraUrl || !this.jiraToken) return [];
|
|
41
|
+
|
|
42
|
+
const jql = 'assignee = currentUser() AND status IN ("To develop","TO BE DONE",Backlog,"Pending Info",Developing,DEVELOPMENT,"In development",TEST,DESIGN,"To Do","In Progress")';
|
|
43
|
+
const url = `${this.jiraUrl}/rest/api/3/search?jql=${encodeURIComponent(jql)}`;
|
|
44
|
+
|
|
45
|
+
const data = await this.web.get(url, {
|
|
46
|
+
headers: { Authorization: `Basic ${this.jiraToken}` },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return (data.issues ?? []).map((issue: any) => ({
|
|
50
|
+
key: issue.key,
|
|
51
|
+
summary: issue.fields.summary,
|
|
52
|
+
status: issue.fields.status.name,
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @method selectIssue
|
|
58
|
+
* @description Presents the user's open Jira issues as an interactive list and returns
|
|
59
|
+
* the selected issue key (e.g. "PROJ-123"). If Jira is not configured or unreachable,
|
|
60
|
+
* falls back to a free-text prompt for a branch name.
|
|
61
|
+
* Returns null if the user chooses to skip.
|
|
62
|
+
* @returns {Promise<string | null>} The selected Jira key, a branch name, or null.
|
|
63
|
+
* @example
|
|
64
|
+
* const branch = await jira.selectIssue();
|
|
65
|
+
* if (branch) await workspace.tagWorkspace(dir, branch);
|
|
66
|
+
*/
|
|
67
|
+
public async selectIssue(): Promise<string | null> {
|
|
68
|
+
// Try Jira API
|
|
69
|
+
if (this.jiraUrl && this.jiraToken) {
|
|
70
|
+
try {
|
|
71
|
+
const issues = await this.fetchMyIssues();
|
|
72
|
+
const choices = [
|
|
73
|
+
...issues.map(i => ({
|
|
74
|
+
name: `${i.key} - ${i.summary} [${i.status}]`,
|
|
75
|
+
value: i.key,
|
|
76
|
+
})),
|
|
77
|
+
{ name: 'Sin ticket (introducir rama manualmente)', value: '__manual__' },
|
|
78
|
+
{ name: 'Omitir (no crear rama)', value: '__skip__' },
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
const selected = await this.shell.select(choices, 'Selecciona un ticket de Jira:');
|
|
82
|
+
|
|
83
|
+
if (selected === '__skip__') return null;
|
|
84
|
+
if (selected === '__manual__') return this.askForBranch();
|
|
85
|
+
|
|
86
|
+
return selected;
|
|
87
|
+
} catch {
|
|
88
|
+
this.logger.warn('No se ha podido conectar con Jira. Introducción manual de rama.');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return this.askForBranch();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private async askForBranch(): Promise<string | null> {
|
|
96
|
+
const raw = await this.shell.input('Introduce el nombre de la nueva rama (vacío para omitir):');
|
|
97
|
+
if (!raw.trim()) return null;
|
|
98
|
+
// Normalise: take the last segment separated by '/'
|
|
99
|
+
return raw.trim().split('/').pop()?.toUpperCase() ?? raw.trim();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const JiraManagerTests = {};
|
package/src/lib/ShellManager.ts
CHANGED
|
@@ -104,6 +104,86 @@ export class ShellManager {
|
|
|
104
104
|
|
|
105
105
|
this.cwd = resolve(this.cwd, expandedPath);
|
|
106
106
|
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @method getCwd
|
|
110
|
+
* @description Returns the current working directory used by this instance.
|
|
111
|
+
* @returns {string} The current working directory.
|
|
112
|
+
* @example
|
|
113
|
+
* const dir = shell.getCwd();
|
|
114
|
+
*/
|
|
115
|
+
public getCwd(): string {
|
|
116
|
+
return this.cwd;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @method confirm
|
|
121
|
+
* @description Prompts the user with a yes/no question via CLI.
|
|
122
|
+
* @param {string} question - The question to display.
|
|
123
|
+
* @param {boolean} defaultValue - Default answer if user presses Enter (default: false).
|
|
124
|
+
* @returns {Promise<boolean>} True if the user confirmed.
|
|
125
|
+
* @example
|
|
126
|
+
* const ok = await shell.confirm('¿Continuar?', false);
|
|
127
|
+
*/
|
|
128
|
+
public async confirm(question: string, defaultValue: boolean = false): Promise<boolean> {
|
|
129
|
+
try {
|
|
130
|
+
const result = await inquirer.prompt([{
|
|
131
|
+
type: 'confirm',
|
|
132
|
+
name: 'value',
|
|
133
|
+
message: question,
|
|
134
|
+
default: defaultValue,
|
|
135
|
+
}]);
|
|
136
|
+
return result.value;
|
|
137
|
+
} catch (e) {
|
|
138
|
+
throw new TyrError(`Error al mostrar la confirmación: ${question}`, e);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @method select
|
|
144
|
+
* @description Prompts the user to select one option from a list.
|
|
145
|
+
* @param {Array<{name: string, value: string}>} choices - The available options.
|
|
146
|
+
* @param {string} question - The question to display.
|
|
147
|
+
* @returns {Promise<string>} The selected value.
|
|
148
|
+
* @example
|
|
149
|
+
* const branch = await shell.select([{ name: 'main', value: 'main' }], '¿Qué rama?');
|
|
150
|
+
*/
|
|
151
|
+
public async select(choices: { name: string; value: string }[], question: string): Promise<string> {
|
|
152
|
+
try {
|
|
153
|
+
const result = await inquirer.prompt([{
|
|
154
|
+
type: 'list',
|
|
155
|
+
name: 'value',
|
|
156
|
+
message: question,
|
|
157
|
+
choices,
|
|
158
|
+
}]);
|
|
159
|
+
return result.value;
|
|
160
|
+
} catch (e) {
|
|
161
|
+
throw new TyrError(`Error al mostrar la selección: ${question}`, e);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @method checkbox
|
|
167
|
+
* @description Prompts the user to select multiple options from a list.
|
|
168
|
+
* @param {Array<{name: string, value: string}>} choices - The available options.
|
|
169
|
+
* @param {string} question - The question to display.
|
|
170
|
+
* @returns {Promise<string[]>} The selected values.
|
|
171
|
+
* @example
|
|
172
|
+
* const widgets = await shell.checkbox(choices, '¿Qué widgets incluir?');
|
|
173
|
+
*/
|
|
174
|
+
public async checkbox(choices: { name: string; value: string }[], question: string): Promise<string[]> {
|
|
175
|
+
try {
|
|
176
|
+
const result = await inquirer.prompt([{
|
|
177
|
+
type: 'checkbox',
|
|
178
|
+
name: 'value',
|
|
179
|
+
message: question,
|
|
180
|
+
choices,
|
|
181
|
+
}]);
|
|
182
|
+
return result.value;
|
|
183
|
+
} catch (e) {
|
|
184
|
+
throw new TyrError(`Error al mostrar las opciones: ${question}`, e);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
107
187
|
}
|
|
108
188
|
|
|
109
189
|
/**
|
|
@@ -114,5 +194,8 @@ export const ShellManagerTests = {
|
|
|
114
194
|
exec: { command: 'node -v' },
|
|
115
195
|
cd: { path: '/tmp' },
|
|
116
196
|
input: { question: 'Enter a test value:' },
|
|
117
|
-
showLoader: { message: 'Loading test...' }
|
|
197
|
+
showLoader: { message: 'Loading test...' },
|
|
198
|
+
confirm: { question: '¿Continuar?', defaultValue: false },
|
|
199
|
+
select: { choices: [{ name: 'Opción A', value: 'a' }, { name: 'Opción B', value: 'b' }], question: '¿Cuál eliges?' },
|
|
200
|
+
checkbox: { choices: [{ name: 'Item 1', value: '1' }, { name: 'Item 2', value: '2' }], question: '¿Cuáles quieres?' },
|
|
118
201
|
};
|
package/src/lib/WebManager.ts
CHANGED
|
@@ -24,6 +24,46 @@ export class WebManager {
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* @method get
|
|
29
|
+
* @description Makes an HTTP GET request and returns the raw response data. Useful for REST APIs.
|
|
30
|
+
* @param {string} url - The URL to request.
|
|
31
|
+
* @param {AxiosRequestConfig} config - Optional Axios config (headers, params, etc.).
|
|
32
|
+
* @returns {Promise<any>} The response data.
|
|
33
|
+
* @example
|
|
34
|
+
* const data = await web.get('https://api.example.com/items', { headers: { Authorization: 'Bearer token' } });
|
|
35
|
+
*/
|
|
36
|
+
public async get(url: string, config?: AxiosRequestConfig): Promise<any> {
|
|
37
|
+
try {
|
|
38
|
+
const response = await axios.get(url, config);
|
|
39
|
+
return response.data;
|
|
40
|
+
} catch (e: any) {
|
|
41
|
+
const status = e?.response?.status;
|
|
42
|
+
const err = new TyrError(`HTTP GET failed (${status ?? 'unknown'}): ${url}`, e, 'Check your network connection and the URL.');
|
|
43
|
+
(err as any).status = status;
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @method getMetaTag
|
|
50
|
+
* @description Fetches a page and returns the content of a specific meta tag.
|
|
51
|
+
* @param {string} url - The URL of the page to scrape.
|
|
52
|
+
* @param {string} metaName - The name attribute of the meta tag.
|
|
53
|
+
* @returns {Promise<string|null>} The content of the meta tag, or null if not found.
|
|
54
|
+
* @example
|
|
55
|
+
* const webname = await web.getMetaTag('https://client.example.com', 'webname');
|
|
56
|
+
*/
|
|
57
|
+
public async getMetaTag(url: string, metaName: string): Promise<string | null> {
|
|
58
|
+
try {
|
|
59
|
+
const $ = await this.fetch(url);
|
|
60
|
+
return $(`meta[name="${metaName}"]`).attr('content') ?? null;
|
|
61
|
+
} catch (e) {
|
|
62
|
+
if (e instanceof TyrError) throw e;
|
|
63
|
+
throw new TyrError(`Could not read meta tag '${metaName}' from: ${url}`, e);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
27
67
|
/**
|
|
28
68
|
* @method selectFromWeb
|
|
29
69
|
* @description Selects elements from a web page using a CSS selector.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { ShellManager } from './ShellManager.js';
|
|
2
|
+
import { FileSystemManager } from './FileSystemManager.js';
|
|
3
|
+
import { Logger } from '../core/Logger.js';
|
|
4
|
+
import { TyrError } from '../core/TyrError.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @class WorkspaceManager
|
|
8
|
+
* @description Manages local workspace directories: checks for existing repos,
|
|
9
|
+
* creates branches and opens the project in VSCode.
|
|
10
|
+
*/
|
|
11
|
+
export class WorkspaceManager {
|
|
12
|
+
private shell: ShellManager;
|
|
13
|
+
private fs: FileSystemManager;
|
|
14
|
+
private logger: Logger;
|
|
15
|
+
|
|
16
|
+
constructor(shell: ShellManager, fs: FileSystemManager, logger: Logger) {
|
|
17
|
+
this.shell = shell;
|
|
18
|
+
this.fs = fs;
|
|
19
|
+
this.logger = logger;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @method checkExisting
|
|
24
|
+
* @description Checks whether a workspace directory already exists.
|
|
25
|
+
* If it does, asks the user if they want to replace it (deleting it first).
|
|
26
|
+
* Returns true if the caller should proceed with creating the workspace.
|
|
27
|
+
* @param {string} dirPath - Absolute path to the workspace directory.
|
|
28
|
+
* @param {string} type - Human-readable type name shown in messages (e.g. 'integración', 'web').
|
|
29
|
+
* @returns {Promise<boolean>} True if the workspace can be created/overwritten.
|
|
30
|
+
* @example
|
|
31
|
+
* const proceed = await workspace.checkExisting('/path/to/repo', 'integración');
|
|
32
|
+
* if (!proceed) return;
|
|
33
|
+
*/
|
|
34
|
+
public async checkExisting(dirPath: string, type: string = 'directorio'): Promise<boolean> {
|
|
35
|
+
if (!this.fs.exists(dirPath)) return true;
|
|
36
|
+
|
|
37
|
+
this.logger.warn(`Este ${type} ya existe: ${dirPath}`);
|
|
38
|
+
const replace = await this.shell.confirm('¿Quieres reemplazarlo?', false);
|
|
39
|
+
|
|
40
|
+
if (!replace) {
|
|
41
|
+
this.logger.info('Operación cancelada.');
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
await this.shell.exec(`rm -rf "${dirPath}"`);
|
|
47
|
+
this.logger.info('Directorio existente eliminado.');
|
|
48
|
+
return true;
|
|
49
|
+
} catch (e) {
|
|
50
|
+
throw new TyrError(`No se pudo eliminar el directorio existente: ${dirPath}`, e);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @method tagWorkspace
|
|
56
|
+
* @description Tags a workspace by creating and checking out a new Git branch,
|
|
57
|
+
* and optionally opening it in VSCode.
|
|
58
|
+
* @param {string} dir - Absolute path to the workspace directory.
|
|
59
|
+
* @param {string | null} branch - Branch name to create (null = skip branch creation).
|
|
60
|
+
* @param {boolean} openCode - Whether to open the directory in VSCode (default: true).
|
|
61
|
+
* @example
|
|
62
|
+
* await workspace.tagWorkspace('/path/to/repo', 'PROJ-123', true);
|
|
63
|
+
*/
|
|
64
|
+
public async tagWorkspace(dir: string, branch: string | null, openCode: boolean = true): Promise<void> {
|
|
65
|
+
if (branch) {
|
|
66
|
+
try {
|
|
67
|
+
await this.shell.exec(`git -C "${dir}" checkout -b ${branch}`);
|
|
68
|
+
this.logger.success(`Rama '${branch}' creada.`);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
this.logger.warn(`No se pudo crear la rama '${branch}'. Puede que ya exista.`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (openCode) {
|
|
75
|
+
try {
|
|
76
|
+
await this.shell.exec(`code "${dir}"`);
|
|
77
|
+
} catch {
|
|
78
|
+
this.logger.warn('No se pudo abrir VSCode. Asegúrate de tener el comando "code" instalado.');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const WorkspaceManagerTests = {
|
|
85
|
+
checkExisting: { dirPath: '/tmp/tyr-workspace-test', type: 'integración' },
|
|
86
|
+
tagWorkspace: { dir: '/tmp/tyr-workspace-test', branch: null, openCode: false },
|
|
87
|
+
};
|