@orxataguy/tyr 1.0.8 → 1.0.10-beta.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orxataguy/tyr",
3
- "version": "1.0.8",
3
+ "version": "1.0.10-beta.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "tyr": "./bin/tyr.js"
@@ -22,6 +22,9 @@
22
22
  "release:patch": "npm version patch && git push --follow-tags",
23
23
  "release:minor": "npm version minor && git push --follow-tags",
24
24
  "release:major": "npm version major && git push --follow-tags",
25
+ "release:beta": "npm version prerelease --preid=beta && git push --follow-tags && npm publish --tag beta",
26
+ "release:beta:minor": "npm version preminor --preid=beta && git push --follow-tags && npm publish --tag beta",
27
+ "release:beta:major": "npm version premajor --preid=beta && git push --follow-tags && npm publish --tag beta",
25
28
  "config": "node bin/tyr.js install"
26
29
  },
27
30
  "keywords": [
@@ -49,6 +52,7 @@
49
52
  "find-config": "^1.0.0",
50
53
  "inquirer": "^13.2.1",
51
54
  "js-yaml": "^4.1.1",
55
+ "mongodb": "^7.2.0",
52
56
  "mssql": "^12.2.0",
53
57
  "tsx": "^4.21.0"
54
58
  },
@@ -5,9 +5,11 @@ import { DockerManager } from '../lib/DockerManager.js';
5
5
  import { GitManager } from '../lib/GitManager.js';
6
6
  import { SystemManager } from '../lib/SystemManager.js';
7
7
  import { SQLManager } from '../lib/SQLManager.js';
8
+ import { MongoManager } from '../lib/MongoManager.js';
8
9
  import { WebManager } from '../lib/WebManager.js';
9
10
  import { WorkspaceManager } from '../lib/WorkspaceManager.js';
10
11
  import { JiraManager } from '../lib/JiraManager.js';
12
+ import { SetupManager } from '../lib/SetupManager.js';
11
13
  import { Logger, createLogger } from './Logger.js';
12
14
 
13
15
  export type { Logger };
@@ -21,9 +23,11 @@ export interface ServiceContainer {
21
23
  git: GitManager;
22
24
  sys: SystemManager;
23
25
  db: SQLManager;
26
+ mongo: MongoManager;
24
27
  web: WebManager;
25
28
  workspace: WorkspaceManager;
26
29
  jira: JiraManager;
30
+ setup: SetupManager;
27
31
  }
28
32
 
29
33
  export class Container {
@@ -37,6 +41,7 @@ export class Container {
37
41
  const logger = createLogger(isDebug);
38
42
  const shell = new ShellManager();
39
43
  const db = new SQLManager();
44
+ const mongo = new MongoManager();
40
45
  const web = new WebManager(logger);
41
46
  const fs = new FileSystemManager(logger);
42
47
 
@@ -44,6 +49,7 @@ export class Container {
44
49
  logger,
45
50
  shell,
46
51
  db,
52
+ mongo,
47
53
  web,
48
54
  fs,
49
55
  pkg: new PackageManager(shell, logger),
@@ -52,6 +58,7 @@ export class Container {
52
58
  sys: new SystemManager(shell, logger),
53
59
  workspace: new WorkspaceManager(shell, fs, logger),
54
60
  jira: new JiraManager(web, shell, logger),
61
+ setup: new SetupManager(shell, fs, logger),
55
62
  };
56
63
  }
57
64
 
@@ -0,0 +1,176 @@
1
+ import { MongoClient, Db, Document, Filter, UpdateFilter, InsertOneResult, InsertManyResult, UpdateResult, DeleteResult, WithId, OptionalUnlessRequiredId, FindOptions } from 'mongodb';
2
+
3
+ /**
4
+ * @class MongoManager
5
+ * @description Conector con MongoDB que gestiona el ciclo de vida de la conexión y expone un CRUD genérico.
6
+ */
7
+ export class MongoManager {
8
+ private client!: MongoClient;
9
+ private db!: Db;
10
+ private connected = false;
11
+
12
+ constructor() {}
13
+
14
+ private async init(): Promise<void> {
15
+ if (!this.connected) {
16
+ const uri = process.env.MONGO_URI || 'mongodb://localhost:27017';
17
+ const dbName = process.env.MONGO_DATABASE || '';
18
+
19
+ this.client = new MongoClient(uri);
20
+ await this.client.connect();
21
+ this.db = this.client.db(dbName);
22
+ this.connected = true;
23
+ }
24
+ }
25
+
26
+ private async close(): Promise<void> {
27
+ if (this.connected && this.client) {
28
+ await this.client.close();
29
+ this.connected = false;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * @method insertOne
35
+ * @description Inserta un documento en la colección indicada.
36
+ * @param {string} collection - Nombre de la colección.
37
+ * @param {Document} document - Documento a insertar.
38
+ * @returns {Promise<InsertOneResult>} Resultado de la inserción.
39
+ * @example
40
+ * const result = await mongo.insertOne('users', { name: 'Ana', age: 30 });
41
+ */
42
+ public async insertOne<T extends Document>(collection: string, document: OptionalUnlessRequiredId<T>): Promise<InsertOneResult<T>> {
43
+ await this.init();
44
+ const result = await this.db.collection<T>(collection).insertOne(document);
45
+ await this.close();
46
+ return result;
47
+ }
48
+
49
+ /**
50
+ * @method insertMany
51
+ * @description Inserta múltiples documentos en la colección indicada.
52
+ * @param {string} collection - Nombre de la colección.
53
+ * @param {Document[]} documents - Array de documentos a insertar.
54
+ * @returns {Promise<InsertManyResult>} Resultado de la inserción.
55
+ * @example
56
+ * const result = await mongo.insertMany('users', [{ name: 'Ana' }, { name: 'Luis' }]);
57
+ */
58
+ public async insertMany<T extends Document>(collection: string, documents: OptionalUnlessRequiredId<T>[]): Promise<InsertManyResult<T>> {
59
+ await this.init();
60
+ const result = await this.db.collection<T>(collection).insertMany(documents);
61
+ await this.close();
62
+ return result;
63
+ }
64
+
65
+ /**
66
+ * @method findOne
67
+ * @description Busca el primer documento que coincida con el filtro.
68
+ * @param {string} collection - Nombre de la colección.
69
+ * @param {Filter<Document>} filter - Filtro de búsqueda.
70
+ * @param {FindOptions} [options] - Opciones adicionales (proyección, etc.).
71
+ * @returns {Promise<WithId<T> | null>} El documento encontrado o null.
72
+ * @example
73
+ * const user = await mongo.findOne('users', { name: 'Ana' });
74
+ */
75
+ public async findOne<T extends Document>(collection: string, filter: Filter<T>, options?: FindOptions): Promise<WithId<T> | null> {
76
+ await this.init();
77
+ const result = await this.db.collection<T>(collection).findOne(filter, options);
78
+ await this.close();
79
+ return result;
80
+ }
81
+
82
+ /**
83
+ * @method find
84
+ * @description Busca todos los documentos que coincidan con el filtro.
85
+ * @param {string} collection - Nombre de la colección.
86
+ * @param {Filter<Document>} filter - Filtro de búsqueda. Usa {} para traer todos los documentos.
87
+ * @param {FindOptions} [options] - Opciones adicionales (proyección, límite, etc.).
88
+ * @returns {Promise<WithId<T>[]>} Array de documentos encontrados.
89
+ * @example
90
+ * const users = await mongo.find('users', { age: { $gte: 18 } });
91
+ */
92
+ public async find<T extends Document>(collection: string, filter: Filter<T>, options?: FindOptions): Promise<WithId<T>[]> {
93
+ await this.init();
94
+ const result = await this.db.collection<T>(collection).find(filter, options).toArray();
95
+ await this.close();
96
+ return result;
97
+ }
98
+
99
+ /**
100
+ * @method updateOne
101
+ * @description Actualiza el primer documento que coincida con el filtro.
102
+ * @param {string} collection - Nombre de la colección.
103
+ * @param {Filter<Document>} filter - Filtro para identificar el documento.
104
+ * @param {UpdateFilter<Document>} update - Operación de actualización (ej: { $set: { field: value } }).
105
+ * @returns {Promise<UpdateResult>} Resultado de la actualización.
106
+ * @example
107
+ * const result = await mongo.updateOne('users', { name: 'Ana' }, { $set: { age: 31 } });
108
+ */
109
+ public async updateOne<T extends Document>(collection: string, filter: Filter<T>, update: UpdateFilter<T>): Promise<UpdateResult<T>> {
110
+ await this.init();
111
+ const result = await this.db.collection<T>(collection).updateOne(filter, update);
112
+ await this.close();
113
+ return result;
114
+ }
115
+
116
+ /**
117
+ * @method updateMany
118
+ * @description Actualiza todos los documentos que coincidan con el filtro.
119
+ * @param {string} collection - Nombre de la colección.
120
+ * @param {Filter<Document>} filter - Filtro para identificar los documentos.
121
+ * @param {UpdateFilter<Document>} update - Operación de actualización.
122
+ * @returns {Promise<UpdateResult>} Resultado de la actualización.
123
+ * @example
124
+ * const result = await mongo.updateMany('users', { active: false }, { $set: { active: true } });
125
+ */
126
+ public async updateMany<T extends Document>(collection: string, filter: Filter<T>, update: UpdateFilter<T>): Promise<UpdateResult<T>> {
127
+ await this.init();
128
+ const result = await this.db.collection<T>(collection).updateMany(filter, update);
129
+ await this.close();
130
+ return result;
131
+ }
132
+
133
+ /**
134
+ * @method deleteOne
135
+ * @description Elimina el primer documento que coincida con el filtro.
136
+ * @param {string} collection - Nombre de la colección.
137
+ * @param {Filter<Document>} filter - Filtro para identificar el documento.
138
+ * @returns {Promise<DeleteResult>} Resultado de la eliminación.
139
+ * @example
140
+ * const result = await mongo.deleteOne('users', { name: 'Ana' });
141
+ */
142
+ public async deleteOne<T extends Document>(collection: string, filter: Filter<T>): Promise<DeleteResult> {
143
+ await this.init();
144
+ const result = await this.db.collection<T>(collection).deleteOne(filter);
145
+ await this.close();
146
+ return result;
147
+ }
148
+
149
+ /**
150
+ * @method deleteMany
151
+ * @description Elimina todos los documentos que coincidan con el filtro.
152
+ * @param {string} collection - Nombre de la colección.
153
+ * @param {Filter<Document>} filter - Filtro para identificar los documentos.
154
+ * @returns {Promise<DeleteResult>} Resultado de la eliminación.
155
+ * @example
156
+ * const result = await mongo.deleteMany('users', { active: false });
157
+ */
158
+ public async deleteMany<T extends Document>(collection: string, filter: Filter<T>): Promise<DeleteResult> {
159
+ await this.init();
160
+ const result = await this.db.collection<T>(collection).deleteMany(filter);
161
+ await this.close();
162
+ return result;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * @object MongoManagerTests
168
+ * @description Parámetros de pruebas para validar la funcionalidad de MongoManager.
169
+ */
170
+ export const MongoManagerTests = {
171
+ // insertOne: { collection: 'test', document: { name: 'test_doc', value: 1 } },
172
+ // findOne: { collection: 'test', filter: { name: 'test_doc' } },
173
+ // find: { collection: 'test', filter: {} },
174
+ // updateOne: { collection: 'test', filter: { name: 'test_doc' }, update: { $set: { value: 2 } } },
175
+ // deleteOne: { collection: 'test', filter: { name: 'test_doc' } },
176
+ };
@@ -0,0 +1,328 @@
1
+ import path from 'path';
2
+ import { ShellManager } from './ShellManager.js';
3
+ import { FileSystemManager } from './FileSystemManager.js';
4
+ import { Logger } from '../core/Logger.js';
5
+ import { TyrError } from '../core/TyrError.js';
6
+
7
+ /**
8
+ * @class SetupManager
9
+ * @description Utilities for bootstrapping a development environment from a
10
+ * remote repository. Covers OS detection, dependency checks, docker-compose
11
+ * introspection, compose/buildx installation, Makefile generation, and
12
+ * migration-strategy detection.
13
+ */
14
+ export class SetupManager {
15
+ private shell: ShellManager;
16
+ private fs: FileSystemManager;
17
+ private logger: Logger;
18
+
19
+ constructor(shell: ShellManager, fs: FileSystemManager, logger: Logger) {
20
+ this.shell = shell;
21
+ this.fs = fs;
22
+ this.logger = logger;
23
+ }
24
+
25
+ // ── OS / package manager ────────────────────────────────────────────────
26
+
27
+ /**
28
+ * @method detectPkgMgr
29
+ * @description Detects the system package manager including apk (Alpine Linux).
30
+ * Checks: apk → apt → brew → dnf on Unix; winget → choco → scoop on Windows.
31
+ * @returns {Promise<string>} Manager name ('apk','apt','brew','dnf','winget',
32
+ * 'choco','scoop') or 'unknown' if none found.
33
+ * @example
34
+ * const mgr = await setup.detectPkgMgr();
35
+ * logger.info(`Package manager: ${mgr}`);
36
+ */
37
+ public async detectPkgMgr(): Promise<string> {
38
+ const candidates: [string, string][] =
39
+ process.platform === 'win32'
40
+ ? [['winget', 'winget'], ['choco', 'choco'], ['scoop', 'scoop']]
41
+ : [['apk', 'apk'], ['apt-get', 'apt'], ['brew', 'brew'], ['dnf', 'dnf']];
42
+
43
+ for (const [bin, name] of candidates) {
44
+ const found = await this.shell.exec(
45
+ `which ${bin} 2>/dev/null && echo yes || echo no`
46
+ ).catch(() => 'no');
47
+ if (found.trim() === 'yes') return name;
48
+ }
49
+ return 'unknown';
50
+ }
51
+
52
+ /**
53
+ * @method sysInstall
54
+ * @description Installs a system-level package using the detected package manager.
55
+ * @param {string} pkgMgr - Package manager name as returned by detectPkgMgr().
56
+ * @param {string} pkg - Package name to install (e.g. 'git', 'nodejs', 'docker').
57
+ * @example
58
+ * await setup.sysInstall('apk', 'git');
59
+ */
60
+ public async sysInstall(pkgMgr: string, pkg: string): Promise<void> {
61
+ const cmds: Record<string, string> = {
62
+ apk: `apk add --no-cache ${pkg}`,
63
+ apt: `apt-get install -y ${pkg}`,
64
+ brew: `brew install ${pkg}`,
65
+ dnf: `sudo dnf install -y ${pkg}`,
66
+ winget: `winget install ${pkg}`,
67
+ choco: `choco install -y ${pkg}`,
68
+ scoop: `scoop install ${pkg}`,
69
+ };
70
+ const cmd = cmds[pkgMgr];
71
+ if (!cmd) throw new TyrError(`Unsupported package manager: ${pkgMgr}`);
72
+ try {
73
+ await this.shell.exec(cmd);
74
+ } catch (e) {
75
+ throw new TyrError(`Could not install package '${pkg}' with ${pkgMgr}.`, e, `Run the install command manually: ${cmd}`);
76
+ }
77
+ }
78
+
79
+ /**
80
+ * @method binExists
81
+ * @description Checks whether a binary is available on PATH.
82
+ * @param {string} bin - Binary name to look for (e.g. 'git', 'docker').
83
+ * @returns {Promise<boolean>} True if the binary is found.
84
+ * @example
85
+ * if (!await setup.binExists('docker')) fail('Docker is required.');
86
+ */
87
+ public async binExists(bin: string): Promise<boolean> {
88
+ const result = await this.shell.exec(
89
+ `which ${bin} 2>/dev/null && echo yes || echo no`
90
+ ).catch(() => 'no');
91
+ return result.trim() === 'yes';
92
+ }
93
+
94
+ // ── docker-compose file introspection ───────────────────────────────────
95
+
96
+ /**
97
+ * @method findComposeFile
98
+ * @description Locates a docker-compose file in the given directory.
99
+ * Checks: docker-compose.yml, docker-compose.yaml,
100
+ * docker-compose.override.yml, compose.yml, compose.yaml.
101
+ * @param {string} dir - Directory to search in.
102
+ * @returns {string|null} Absolute path of the compose file, or null.
103
+ * @example
104
+ * const file = setup.findComposeFile('/path/to/repo');
105
+ * if (!file) logger.warn('No compose file found.');
106
+ */
107
+ public findComposeFile(dir: string): string | null {
108
+ const candidates = [
109
+ 'docker-compose.yml',
110
+ 'docker-compose.yaml',
111
+ 'docker-compose.override.yml',
112
+ 'compose.yml',
113
+ 'compose.yaml',
114
+ ];
115
+ for (const f of candidates) {
116
+ const full = path.join(dir, f);
117
+ if (this.fs.exists(full)) return full;
118
+ }
119
+ return null;
120
+ }
121
+
122
+ /**
123
+ * @method extractPorts
124
+ * @description Extracts unique host ports from a docker-compose file.
125
+ * Handles all four YAML port formats:
126
+ * - "HOST:CONTAINER" (quoted or unquoted)
127
+ * - HOST:CONTAINER (numeric pair)
128
+ * - PORT (single plain number)
129
+ * - published: PORT (long-form mapping)
130
+ * @param {string} composeFile - Absolute path to the compose file.
131
+ * @returns {Promise<number[]>} Sorted list of unique host port numbers.
132
+ * @example
133
+ * const ports = await setup.extractPorts('/repo/docker-compose.yml');
134
+ * // [3000, 5432, 8080]
135
+ */
136
+ public async extractPorts(composeFile: string): Promise<number[]> {
137
+ const portSet = new Set<number>();
138
+
139
+ const addPorts = (raw: string) => {
140
+ for (const line of raw.split('\n')) {
141
+ const n = parseInt(line.trim(), 10);
142
+ if (!isNaN(n) && n > 0) portSet.add(n);
143
+ }
144
+ };
145
+
146
+ // Format 1+2: - "HOST:CONTAINER" or - HOST:CONTAINER
147
+ addPorts(await this.shell.exec(
148
+ `grep -E '^\\s*-\\s+"?[0-9]+:[0-9]+"?' "${composeFile}" 2>/dev/null | grep -oE '[0-9]+:[0-9]+' | cut -d: -f1 || true`
149
+ ).catch(() => ''));
150
+
151
+ // Format 3: - PORT (single number)
152
+ addPorts(await this.shell.exec(
153
+ `grep -E '^\\s*-\\s+"?[0-9]+"?\\s*$' "${composeFile}" 2>/dev/null | grep -oE '[0-9]+' || true`
154
+ ).catch(() => ''));
155
+
156
+ // Format 4: published: PORT
157
+ addPorts(await this.shell.exec(
158
+ `grep -E '^\\s+published:\\s*[0-9]+' "${composeFile}" 2>/dev/null | grep -oE '[0-9]+' || true`
159
+ ).catch(() => ''));
160
+
161
+ return Array.from(portSet).sort((a, b) => a - b);
162
+ }
163
+
164
+ /**
165
+ * @method getAppService
166
+ * @description Detects the main application service in a docker-compose file.
167
+ * Tries preferred names first (app, web, backend, api, server, worker),
168
+ * then falls back to the first service defined.
169
+ * @param {string} composeFile - Absolute path to the compose file.
170
+ * @returns {Promise<string>} Service name, or empty string if not found.
171
+ * @example
172
+ * const svc = await setup.getAppService('/repo/docker-compose.yml');
173
+ * // 'api'
174
+ */
175
+ public async getAppService(composeFile: string): Promise<string> {
176
+ const preferred = ['app', 'web', 'backend', 'api', 'server', 'worker'];
177
+ for (const svc of preferred) {
178
+ const r = await this.shell.exec(
179
+ `grep -qE "^ ${svc}:" "${composeFile}" 2>/dev/null && echo yes || echo no`
180
+ ).catch(() => 'no');
181
+ if (r.trim() === 'yes') return svc;
182
+ }
183
+ const first = await this.shell.exec(
184
+ `grep -E "^ [a-zA-Z]" "${composeFile}" 2>/dev/null | head -1 | sed 's/://;s/^[[:space:]]*//'`
185
+ ).catch(() => '');
186
+ return first.trim();
187
+ }
188
+
189
+ // ── docker compose / buildx installation ────────────────────────────────
190
+
191
+ /**
192
+ * @method getComposeCmd
193
+ * @description Resolves the available docker compose command.
194
+ * Prefers the compose plugin ('docker compose'), falls back to the
195
+ * standalone binary ('docker-compose'), and installs it from GitHub
196
+ * (or pip as a last resort) if neither is found.
197
+ * @returns {Promise<string|null>} Command string, or null if unavailable.
198
+ * @example
199
+ * const cmd = await setup.getComposeCmd();
200
+ * if (!cmd) fail('docker compose is required.');
201
+ */
202
+ public async getComposeCmd(): Promise<string | null> {
203
+ const pluginOk = await this.shell.exec('docker compose version 2>/dev/null')
204
+ .then(() => true).catch(() => false);
205
+ if (pluginOk) return 'docker compose';
206
+
207
+ if (await this.binExists('docker-compose')) return 'docker-compose';
208
+
209
+ this.logger.warn('docker-compose no encontrado. Intentando instalar...');
210
+
211
+ const rawArch = await this.shell.exec('uname -m').catch(() => 'x86_64');
212
+ const osName = await this.shell.exec("uname -s | tr '[:upper:]' '[:lower:]'").catch(() => 'linux');
213
+ const bin = '/usr/local/bin/docker-compose';
214
+ const url = `https://github.com/docker/compose/releases/latest/download/docker-compose-${osName.trim()}-${rawArch.trim()}`;
215
+
216
+ this.logger.info('Descargando docker-compose desde GitHub...');
217
+ try {
218
+ await this.shell.exec(`curl -fsSL "${url}" -o "${bin}" && chmod +x "${bin}"`);
219
+ if (await this.shell.exec('docker-compose version 2>/dev/null').then(() => true).catch(() => false)) {
220
+ this.logger.success('docker-compose instalado correctamente.');
221
+ return 'docker-compose';
222
+ }
223
+ } catch { /* fall through to pip */ }
224
+
225
+ const pip = await this.shell.exec('which pip3 2>/dev/null || which pip 2>/dev/null').catch(() => '');
226
+ if (pip.trim()) {
227
+ try {
228
+ await this.shell.exec(
229
+ `${pip.trim()} install --quiet docker-compose --break-system-packages 2>/dev/null || ${pip.trim()} install --quiet docker-compose`
230
+ );
231
+ if (await this.binExists('docker-compose')) {
232
+ this.logger.success('docker-compose instalado via pip.');
233
+ return 'docker-compose';
234
+ }
235
+ } catch { /* fall through */ }
236
+ }
237
+
238
+ this.logger.warn('No se pudo instalar docker-compose automaticamente.');
239
+ this.logger.warn('Instalalo manualmente: https://docs.docker.com/compose/install/');
240
+ return null;
241
+ }
242
+
243
+ /**
244
+ * @method ensureBuildx
245
+ * @description Installs the docker buildx plugin if it is not already available.
246
+ * Fetches the latest release URL dynamically from the GitHub API.
247
+ * @example
248
+ * await setup.ensureBuildx();
249
+ */
250
+ public async ensureBuildx(): Promise<void> {
251
+ const ok = await this.shell.exec('docker buildx version 2>/dev/null')
252
+ .then(() => true).catch(() => false);
253
+ if (ok) return;
254
+
255
+ this.logger.info('Instalando docker buildx plugin...');
256
+ const rawArch = await this.shell.exec('uname -m').catch(() => 'x86_64');
257
+ const arch = rawArch.trim().replace('x86_64', 'amd64').replace('aarch64', 'arm64');
258
+ const osName = await this.shell.exec("uname -s | tr '[:upper:]' '[:lower:]'").catch(() => 'linux');
259
+ const pluginDir = '/usr/local/lib/docker/cli-plugins';
260
+
261
+ const urlRaw = await this.shell.exec(
262
+ `wget -qO- https://api.github.com/repos/docker/buildx/releases/latest ` +
263
+ `| grep "browser_download_url" | grep '"${osName.trim()}-${arch}"' | cut -d'"' -f4 || true`
264
+ ).catch(() => '');
265
+
266
+ if (!urlRaw.trim()) {
267
+ this.logger.warn('No se pudo obtener la URL de docker buildx. El build puede mostrar advertencias.');
268
+ return;
269
+ }
270
+ try {
271
+ await this.shell.exec(
272
+ `mkdir -p "${pluginDir}" && wget -qO "${pluginDir}/docker-buildx" "${urlRaw.trim()}" && chmod +x "${pluginDir}/docker-buildx"`
273
+ );
274
+ this.logger.success('docker buildx instalado.');
275
+ } catch {
276
+ this.logger.warn('No se pudo instalar docker buildx. El build puede mostrar advertencias.');
277
+ }
278
+ }
279
+
280
+ // ── Makefile generation ─────────────────────────────────────────────────
281
+
282
+ /**
283
+ * @method generateMakefile
284
+ * @description Creates a Makefile with docker compose shortcuts in the repo
285
+ * directory. If a Makefile already exists, writes Makefile.dev instead.
286
+ * Does nothing if the target file is already present.
287
+ * @param {string} repoDir - Absolute path to the cloned repository.
288
+ * @example
289
+ * await setup.generateMakefile('/workspace/my-repo');
290
+ */
291
+ public async generateMakefile(repoDir: string): Promise<void> {
292
+ const hasExisting = this.fs.exists(path.join(repoDir, 'Makefile'));
293
+ const makefileName = hasExisting ? 'Makefile.dev' : 'Makefile';
294
+ const makefilePath = path.join(repoDir, makefileName);
295
+ const invokePrefix = hasExisting ? 'make -f Makefile.dev' : 'make';
296
+
297
+ if (this.fs.exists(makefilePath)) {
298
+ this.logger.info(`${makefileName} ya existe. No se sobreescribe.`);
299
+ return;
300
+ }
301
+
302
+ const TAB = '\t';
303
+ const content = [
304
+ `# ${makefileName} generado por setup-dev`,
305
+ '',
306
+ 'up:', TAB + 'docker-compose up -d', '',
307
+ 'build:', TAB + 'docker-compose up -d --build', '',
308
+ 'stop:', TAB + 'docker-compose down --remove-orphans', '',
309
+ 'restart:', TAB + 'docker-compose restart', '',
310
+ 'logs:', TAB + 'docker-compose logs -f', '',
311
+ 'ps:', TAB + 'docker-compose ps', '',
312
+ ].join('\n');
313
+
314
+ await this.fs.write(makefilePath, content);
315
+ this.logger.success(`${makefileName} generado en: ${makefilePath}`);
316
+ this.logger.info(` ${invokePrefix} up -> arrancar contenedores`);
317
+ this.logger.info(` ${invokePrefix} build -> rebuild y arrancar`);
318
+ this.logger.info(` ${invokePrefix} stop -> parar y eliminar (sin orphans)`);
319
+ this.logger.info(` ${invokePrefix} logs -> ver logs en tiempo real`);
320
+ this.logger.info(` ${invokePrefix} ps -> estado de los contenedores`);
321
+ }
322
+ }
323
+
324
+ export const SetupManagerTests = {
325
+ detectPkgMgr: {},
326
+ binExists: { bin: 'git' },
327
+ findComposeFile: { dir: '/tmp' },
328
+ };