@orxataguy/tyr 1.0.9 → 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.9",
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": [
@@ -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,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
+ };