shadownrx-code 2.0.0 → 2.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.
Files changed (5) hide show
  1. package/README.md +122 -63
  2. package/bin.js +252 -94
  3. package/lib.js +1 -1
  4. package/package.json +2 -1
  5. package/ui.js +279 -0
package/README.md CHANGED
@@ -1,74 +1,130 @@
1
1
  # shadownrx-code
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/shadownrx-code.svg)](https://www.npmjs.com/package/shadownrx-code)
4
+ [![node](https://img.shields.io/node/v/shadownrx-code.svg)](https://www.npmjs.com/package/shadownrx-code)
5
+
3
6
  Configurador interactivo de terminal para la plataforma de build de
4
- `shadownrx/code`. Detecta si tu proyecto es Flutter, React Native, una PWA
5
- (Capacitor) o Electron, te hace un par de preguntas, y te escribe
6
- `.github/workflows/build.yml` ya configurado — no compila nada localmente, solo
7
- prepara el workflow que corre gratis en GitHub Actions.
7
+ `shadownrx/code`. Detecta si el proyecto es Flutter, React Native, una PWA
8
+ (Capacitor) o Electron, formula las preguntas de configuración necesarias, y
9
+ genera `.github/workflows/build.yml` listo para usar. No compila nada de
10
+ forma local: únicamente prepara el workflow que se ejecuta en GitHub
11
+ Actions.
8
12
 
9
13
  ```
10
- ▲ shadownrx/code — configurador de CI para Android, iOS y Electron
14
+ ███████╗██╗ ██╗ █████╗ ██████╗ ██████╗ ██╗ ██╗███╗ ██╗██████╗ ██╗ ██╗
15
+ ...
16
+ /code · CI gratis para Android, iOS y Electron · v2.0.1
17
+
18
+ ✔ Proyecto detectado: React Native
19
+
20
+ ━━━━━━━━━━━━──────────── Paso 3/4 · Firma
21
+ ✔ ¿Ya tenés listos los secrets de firma (keystore / certificado de Apple)? … yes
22
+
23
+ ╭─ Pipeline en GitHub Actions ───────────────────────────────────────────╮
24
+ │ ◉ git push main · PR · tags v* · manual │
25
+ │ │ │
26
+ │ ◆ detect [ubuntu] project_type: react-native │
27
+ │ │ │
28
+ │ ├─▶ build-android [ubuntu] → .apk .aab │
29
+ │ ├─▶ build-ios [macos] → .ipa │
30
+ │ │ │
31
+ │ ◆ notify [ubuntu] resumen · Slack opcional │
32
+ ╰────────────────────────────────────────────────────────────────────────╯
33
+ ```
34
+
35
+ Antes de escribir muestra un resumen, el pipeline que va a correr en GitHub
36
+ Actions y una vista previa del YAML con resaltado de sintaxis. Respeta
37
+ `NO_COLOR`, y si la terminal es angosta usa un encabezado compacto.
11
38
 
12
- ? Detecté un proyecto React Native en este directorio. ¿Es correcto? › Yes
13
- ? ¿Qué deseas compilar? › Android + iOS (ambas)
14
- ? ¿Ya tenés listos los secrets de firma (keystore / certificado de Apple)? › No
15
- ? ¿Adjuntar los builds a un GitHub Release cuando pushees un tag (v1.2.3)? › No
39
+ ### Opciones
40
+
41
+ | Flag | Qué hace |
42
+ |---|---|
43
+ | `-y`, `--yes` | Sin preguntas: usa el tipo detectado (o `auto`) y los valores por defecto |
44
+ | `-t`, `--type <tipo>` | Fija el tipo: `flutter`, `react-native`, `pwa`, `electron` o `auto` |
45
+ | `--target <t>` | Qué compilar (no Electron): `both`, `android` o `ios` |
46
+ | `--release` | Adjunta los builds a un GitHub Release en tags `v*` |
47
+ | `--dry-run` | Muestra el workflow sin escribir nada |
48
+ | `-f`, `--force` | Sobrescribe `build.yml` sin preguntar |
49
+ | `--cwd <dir>` | Proyecto a configurar (por defecto, el directorio actual) |
50
+ | `--no-color` | Desactiva los colores |
16
51
 
17
- ✔ Escribí .github/workflows/build.yml
52
+ ```bash
53
+ npx shadownrx-code -y --release # CI sin preguntas, con releases
54
+ npx shadownrx-code -t flutter --dry-run # solo previsualizar
18
55
  ```
19
56
 
20
- Para Electron, la pregunta "¿Qué deseas compilar?" y la de firma no aparecen
21
- (Electron siempre compila Linux + macOS + Windows, y la firma todavía no está
22
- soportada) — solo pregunta el tipo de proyecto y si querés adjuntar a un
23
- GitHub Release.
57
+ Para proyectos Electron las preguntas de plataforma de compilación y de
58
+ firma no se muestran: Electron siempre compila para Linux, macOS y Windows,
59
+ y la firma de sus artefactos aún no está soportada por la plataforma. En ese
60
+ caso solo se pregunta el tipo de proyecto y si se desea adjuntar los
61
+ artefactos a un GitHub Release.
62
+
63
+ ## Instalación
24
64
 
25
- ## Usar
65
+ El paquete está publicado en npm como
66
+ [`shadownrx-code`](https://www.npmjs.com/package/shadownrx-code).
26
67
 
27
- Publicado en npm como
28
- [`shadownrx-code`](https://www.npmjs.com/package/shadownrx-code) (v2.0.0).
29
- Alcanza con:
68
+ **Sin instalación** (recomendado): `npx` descarga la última versión
69
+ publicada, la ejecuta una única vez, y no deja nada instalado de forma
70
+ permanente.
30
71
 
31
72
  ```bash
32
73
  npx shadownrx-code
33
74
  ```
34
75
 
35
- parado en la raíz de tu proyecto (Flutter, React Native, PWA o Electron) — sin
36
- clonar nada ni instalar nada de forma permanente: `npx` baja la última
37
- versión publicada, la corre, y listo.
38
-
39
- También podés instalarlo global si preferís no repetir la descarga cada vez:
76
+ **Instalación global**, si se prefiere evitar la descarga en cada
77
+ ejecución:
40
78
 
41
79
  ```bash
42
80
  npm install -g shadownrx-code
43
81
  shadownrx-code
44
82
  ```
45
83
 
46
- O correr la versión local del repo clonado:
84
+ **Desde el código fuente del repositorio**, sin depender del registro de
85
+ npm:
47
86
 
48
87
  ```bash
49
88
  git clone https://github.com/shadownrx/code
50
89
  cd code/cli
51
90
  npm install
52
- node bin.js # parado en la raíz de TU proyecto, o pasale el cwd que corresponda
91
+ node bin.js
53
92
  ```
54
93
 
55
- o, apuntando directo a la carpeta de tu proyecto:
94
+ También puede apuntarse directamente a un proyecto en otra ruta:
56
95
 
57
96
  ```bash
58
97
  cd /ruta/a/tu/proyecto
59
98
  node /ruta/a/code/cli/bin.js
60
99
  ```
61
100
 
101
+ En todos los casos, el comando debe ejecutarse parado en la raíz del
102
+ proyecto a configurar (Flutter, React Native, PWA o Electron).
103
+
62
104
  ## Como tool de Cursor (o cualquier cliente MCP)
63
105
 
64
- Además del CLI interactivo, `mcp-server.js` expone lo mismo como un servidor
65
- [MCP](https://modelcontextprotocol.io) — así el agente de Cursor (o Claude
66
- Code, Windsurf, o cualquier editor con soporte MCP; no es algo específico de
67
- Cursor) puede llamarlo directamente en la conversación, sin que abras una
68
- terminal vos.
106
+ Además del CLI interactivo, `mcp-server.js` expone la misma funcionalidad
107
+ como un servidor [MCP](https://modelcontextprotocol.io), de modo que el
108
+ agente de Cursor (o Claude Code, Windsurf, o cualquier editor con soporte
109
+ MCP — no es específico de Cursor) puede invocarla directamente durante la
110
+ conversación, sin necesidad de abrir una terminal.
111
+
112
+ Configuración en `.cursor/mcp.json` del proyecto (o en el `mcp.json`
113
+ global de Cursor):
69
114
 
70
- Agregá esto a `.cursor/mcp.json` en tu proyecto (o al `mcp.json` global de
71
- Cursor):
115
+ ```json
116
+ {
117
+ "mcpServers": {
118
+ "shadownrx-code": {
119
+ "command": "npx",
120
+ "args": ["-y", "shadownrx-code-mcp"]
121
+ }
122
+ }
123
+ }
124
+ ```
125
+
126
+ Alternativamente, apuntando a una copia local del repositorio en lugar del
127
+ paquete publicado:
72
128
 
73
129
  ```json
74
130
  {
@@ -81,56 +137,59 @@ Cursor):
81
137
  }
82
138
  ```
83
139
 
84
- También podés usar `"command": "npx", "args": ["-y", "shadownrx-code-mcp"]`
85
- en vez de la ruta absoluta, sin necesidad de clonar el repo.
86
-
87
- Este mismo repo ya trae [`.cursor/mcp.json`](../.cursor/mcp.json) apuntando a
88
- `cli/mcp-server.js` con `${workspaceFolder}` — si abrís `shadownrx/code` en
89
- Cursor, la tool queda disponible sola. Eso sí, corré `npm install` en `cli/`
90
- primero (`node_modules` no se commitea): el server necesita
91
- `@modelcontextprotocol/sdk` y `zod` instalados para arrancar.
140
+ Este repositorio incluye [`.cursor/mcp.json`](../.cursor/mcp.json)
141
+ configurado con la segunda variante y `${workspaceFolder}` — al abrir
142
+ `shadownrx/code` en Cursor, la tool queda disponible automáticamente. Es
143
+ necesario ejecutar `npm install` en `cli/` una vez (`node_modules` no se
144
+ versiona): el servidor requiere `@modelcontextprotocol/sdk` y `zod`
145
+ instalados para iniciar.
92
146
 
93
147
  Expone dos tools:
94
148
 
95
149
  | Tool | Qué hace |
96
150
  |---|---|
97
- | `detect_project_type` | Solo lee: reporta si un directorio es Flutter/React Native/PWA/Electron. No escribe nada. |
98
- | `setup_mobile_ci` | Detecta (o recibe `project_type` explícito) y escribe `.github/workflows/build.yml` — mismos parámetros que el CLI interactivo (`build_android`, `build_ios`, `build_electron`, `create_release`), más `overwrite` para confirmar si el archivo ya existe. |
151
+ | `detect_project_type` | Solo lectura: reporta si un directorio es Flutter, React Native, PWA o Electron. No escribe nada. |
152
+ | `setup_mobile_ci` | Detecta el tipo de proyecto (o recibe `project_type` explícito) y escribe `.github/workflows/build.yml` — mismos parámetros que el CLI interactivo (`build_android`, `build_ios`, `build_electron`, `create_release`), más `overwrite` para confirmar la sobrescritura si el archivo ya existe. |
99
153
 
100
- Ambas tools están probadas de punta a punta contra el server real (no un
101
- mock): `mcp-server.test.js` levanta `mcp-server.js` como subproceso vía stdio
102
- con el `Client` del SDK de MCP y llama las tools de verdad.
154
+ Ambas tools están cubiertas por tests de punta a punta contra el servidor
155
+ real (no un mock): `mcp-server.test.js` levanta `mcp-server.js` como
156
+ subproceso vía stdio, usando el `Client` del SDK de MCP para invocar las
157
+ tools reales.
103
158
 
104
159
  ## Qué pregunta y por qué
105
160
 
106
- Cada pregunta mapea 1 a 1 a un input real de
107
- [`build-mobile.yml`](../.github/workflows/build-mobile.yml) — no hay ninguna
108
- opción que prometa algo que la plataforma no haga. Ver
109
- [`docs/USAGE.md`](../docs/USAGE.md) para el detalle de cada input.
161
+ Cada pregunta mapea uno a uno con un input real de
162
+ [`build-mobile.yml`](../.github/workflows/build-mobile.yml): no hay ninguna
163
+ opción que prometa una funcionalidad que la plataforma no implemente. El
164
+ detalle de cada input está documentado en
165
+ [`docs/USAGE.md`](../docs/USAGE.md).
110
166
 
111
167
  | Pregunta | Input generado |
112
168
  |---|---|
113
169
  | Tipo de proyecto (auto-detectado o elegido) | `project_type` |
114
170
  | Qué compilar (Android / iOS / ambas) — no aplica a Electron | `build_android`, `build_ios` |
115
- | ¿Adjuntar a un GitHub Release en tags? | `create_release` (+ el bloque `permissions: contents: write` que necesita) |
171
+ | ¿Adjuntar a un GitHub Release en tags? | `create_release` (incluye el bloque `permissions: contents: write` requerido) |
116
172
 
117
- Para Electron, en vez de `build_android`/`build_ios` se emite
118
- `build_electron: true` (siempre compila los tres sistemas operativos).
173
+ Para proyectos Electron, en lugar de `build_android`/`build_ios` se emite
174
+ `build_electron: true` (siempre compila para los tres sistemas operativos).
119
175
 
120
- Si respondés que ya tenés los secrets de firma listos, te lista los nombres
121
- exactos que hay que cargar en GitHub y te manda a `docs/SIGNING.md` — no los
122
- pide ni los toca, esos siempre se configuran a mano como secrets de GitHub.
176
+ Si se indica que los secrets de firma ya están configurados, se listan los
177
+ nombres exactos que deben cargarse en GitHub y se referencia
178
+ `docs/SIGNING.md`. El CLI no solicita ni manipula esos secrets: siempre se
179
+ configuran manualmente como GitHub Secrets.
123
180
 
124
- Si `.github/workflows/build.yml` ya existe, pregunta antes de pisarlo.
181
+ Si `.github/workflows/build.yml` ya existe, se solicita confirmación antes
182
+ de sobrescribirlo.
125
183
 
126
184
  ## Desarrollo
127
185
 
128
- `lib.js` tiene toda la lógica pura (detección de proyecto + generación del
129
- YAML) separada de `bin.js` (la parte interactiva con
130
- [`prompts`](https://www.npmjs.com/package/prompts)) y de `mcp-server.js` (el
131
- server MCP) — justamente para no duplicar la lógica y poder testearla sin
132
- simular una terminal ni un cliente MCP real:
186
+ La lógica pura (detección de proyecto y generación del YAML) vive en
187
+ `lib.js`, separada de `bin.js` (la interfaz interactiva, con
188
+ [`prompts`](https://www.npmjs.com/package/prompts)), de `ui.js` (banner,
189
+ paneles, spinner y diagrama en ANSI puro, sin dependencias) y de `mcp-server.js`
190
+ (el servidor MCP). Esta separación evita duplicar lógica y permite testear
191
+ sin simular una terminal ni un cliente MCP real:
133
192
 
134
193
  ```bash
135
- npm test # node --test — corre lib.test.js y mcp-server.test.js
194
+ npm test # node --test — corre lib.test.js, ui.test.js y mcp-server.test.js
136
195
  ```
package/bin.js CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'node:fs';
3
+ import os from 'node:os';
3
4
  import path from 'node:path';
5
+ import { parseArgs } from 'node:util';
4
6
  import prompts from 'prompts';
5
7
  import {
6
8
  PROJECT_LABELS,
@@ -9,151 +11,307 @@ import {
9
11
  detectProjectType,
10
12
  iosSecrets,
11
13
  } from './lib.js';
14
+ import {
15
+ banner,
16
+ box,
17
+ c,
18
+ gradient,
19
+ highlightYaml,
20
+ pipeline,
21
+ rule,
22
+ setColor,
23
+ spin,
24
+ stepHeader,
25
+ sym,
26
+ table,
27
+ } from './ui.js';
28
+
29
+ const pkg = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
30
+ const TAGLINE = 'CI gratis para Android, iOS y Electron';
31
+ const PROJECT_TYPES = ['flutter', 'react-native', 'pwa', 'electron', 'auto'];
32
+ const TARGETS = ['both', 'android', 'ios'];
33
+ // Inner width shared by every panel so their right edges line up.
34
+ const PANEL = 72;
35
+
36
+ const HELP_ROWS = [
37
+ ['-y, --yes', 'Sin preguntas: usa lo detectado y los defaults'],
38
+ ['-t, --type <tipo>', `Tipo: ${PROJECT_TYPES.join('|')}`],
39
+ [' --target <t>', `Qué compilar: ${TARGETS.join('|')}`],
40
+ [' --release', 'Adjuntar builds a un GitHub Release en tags v*'],
41
+ [' --dry-run', 'Muestra el workflow sin escribir nada'],
42
+ ['-f, --force', 'Sobrescribe build.yml sin preguntar'],
43
+ [' --cwd <dir>', 'Proyecto a configurar (default: directorio actual)'],
44
+ [' --no-color', 'Desactiva colores (también respeta NO_COLOR)'],
45
+ ['-v, --version', 'Muestra la versión'],
46
+ ['-h, --help', 'Muestra esta ayuda'],
47
+ ];
12
48
 
13
- const cwd = process.cwd();
49
+ function parseCli(argv) {
50
+ const { values } = parseArgs({
51
+ args: argv,
52
+ options: {
53
+ yes: { type: 'boolean', short: 'y' },
54
+ type: { type: 'string', short: 't' },
55
+ target: { type: 'string' },
56
+ release: { type: 'boolean' },
57
+ 'dry-run': { type: 'boolean' },
58
+ force: { type: 'boolean', short: 'f' },
59
+ cwd: { type: 'string' },
60
+ 'no-color': { type: 'boolean' },
61
+ version: { type: 'boolean', short: 'v' },
62
+ help: { type: 'boolean', short: 'h' },
63
+ },
64
+ strict: true,
65
+ });
66
+ if (values.type && !PROJECT_TYPES.includes(values.type)) {
67
+ throw new Error(`--type inválido: "${values.type}". Opciones: ${PROJECT_TYPES.join(', ')}`);
68
+ }
69
+ if (values.target && !TARGETS.includes(values.target)) {
70
+ throw new Error(`--target inválido: "${values.target}". Opciones: ${TARGETS.join(', ')}`);
71
+ }
72
+ return values;
73
+ }
74
+
75
+ function printHelp() {
76
+ console.log(banner({ version: pkg.version, tagline: TAGLINE }));
77
+ console.log(
78
+ box(
79
+ [
80
+ `${c.bold('Uso:')} ${c.cyan('npx shadownrx-code')} ${c.gray('[opciones]')}`,
81
+ '',
82
+ ...table(HELP_ROWS, { keyColor: c.cyan }),
83
+ '',
84
+ c.gray('Ejemplos:'),
85
+ ` ${c.cyan('npx shadownrx-code')} ${c.gray('# modo interactivo')}`,
86
+ ` ${c.cyan('npx shadownrx-code -y --release')} ${c.gray('# defaults + releases')}`,
87
+ ` ${c.cyan('npx shadownrx-code -t flutter --dry-run')} ${c.gray('# solo previsualizar')}`,
88
+ ],
89
+ { title: 'Ayuda', minWidth: PANEL }
90
+ )
91
+ );
92
+ console.log();
93
+ }
94
+
95
+ function displayPath(p) {
96
+ const home = os.homedir();
97
+ const short = p.startsWith(home) ? '~' + p.slice(home.length) : p;
98
+ return short.length > PANEL - 12 ? '…' + short.slice(-(PANEL - 13)) : short;
99
+ }
14
100
 
15
101
  function onCancel() {
16
- console.log('\nCancelado — no se modificó nada.\n');
102
+ console.log(`\n ${sym.fail()} ${c.bold('Cancelado')} ${c.gray('— no se modificó nada.')}\n`);
17
103
  process.exit(1);
18
104
  }
19
105
 
106
+ async function ask(question) {
107
+ return prompts({ name: 'value', ...question }, { onCancel }).then((r) => r.value);
108
+ }
109
+
20
110
  async function main() {
21
- console.log('\n▲ shadownrx/code — configurador de CI para Android, iOS y Electron\n');
22
- console.log('Esto escribe .github/workflows/build.yml en este proyecto. No compila nada');
23
- console.log('acá: el build corre gratis en GitHub Actions con cada push.\n');
111
+ let opts;
112
+ try {
113
+ opts = parseCli(process.argv.slice(2));
114
+ } catch (err) {
115
+ console.error(`\n ${sym.fail()} ${err.message}\n ${c.gray('Probá')} ${c.cyan('--help')}\n`);
116
+ process.exit(2);
117
+ }
118
+ if (opts['no-color']) setColor(false);
119
+ if (opts.version) return console.log(pkg.version);
120
+ if (opts.help) return printHelp();
24
121
 
25
- const detected = detectProjectType(cwd);
26
- let projectType = detected;
122
+ const cwd = path.resolve(opts.cwd ?? process.cwd());
123
+ const auto = Boolean(opts.yes);
27
124
 
125
+ console.log(banner({ version: pkg.version, tagline: TAGLINE }));
126
+ console.log(
127
+ box(
128
+ [
129
+ `Genera ${c.cyan('.github/workflows/build.yml')} para este proyecto.`,
130
+ `No compila nada acá: el build corre ${c.green('gratis')} en GitHub Actions`,
131
+ `con cada push — sin Mac ni Windows en tu máquina.`,
132
+ '',
133
+ `${c.gray('Proyecto:')} ${c.bold(displayPath(cwd))}`,
134
+ ],
135
+ { title: gradient('Configurador de CI'), minWidth: PANEL }
136
+ )
137
+ );
138
+
139
+ // ── Detección ────────────────────────────────────────────────────────────
140
+ console.log();
141
+ const detected = await spin('Analizando el proyecto…', () => detectProjectType(cwd));
28
142
  if (detected) {
29
- const { confirmDetected } = await prompts(
30
- {
31
- type: 'confirm',
32
- name: 'confirmDetected',
33
- message: `Detecté un proyecto ${PROJECT_LABELS[detected]} en este directorio. ¿Es correcto?`,
34
- initial: true,
35
- },
36
- { onCancel }
37
- );
38
- if (!confirmDetected) projectType = null;
143
+ console.log(` ${sym.ok()} Proyecto detectado: ${c.bold(PROJECT_LABELS[detected])}`);
144
+ } else {
145
+ console.log(` ${sym.warn()} ${c.yellow('No pude detectar el tipo de proyecto')} ${c.gray('(Flutter / RN / PWA / Electron)')}`);
39
146
  }
40
147
 
148
+ let projectType = opts.type ?? null;
149
+ const willBeElectron = (projectType ?? detected) === 'electron';
150
+ const totalSteps = willBeElectron ? 2 : 4;
151
+ let step = 0;
152
+
41
153
  if (!projectType) {
42
- const { picked } = await prompts(
43
- {
44
- type: 'select',
45
- name: 'picked',
46
- message: '¿Qué tipo de proyecto es?',
47
- choices: [
48
- { title: 'Flutter', value: 'flutter' },
49
- { title: 'React Native', value: 'react-native' },
50
- { title: 'PWA (Capacitor)', value: 'pwa' },
51
- { title: 'Electron', value: 'electron' },
52
- { title: 'Detectar automáticamente en cada build', value: 'auto' },
53
- ],
54
- },
55
- { onCancel }
56
- );
57
- projectType = picked;
154
+ if (auto) {
155
+ projectType = detected ?? 'auto';
156
+ } else {
157
+ console.log(stepHeader(++step, totalSteps, 'Tipo de proyecto'));
158
+ if (detected) {
159
+ const ok = await ask({
160
+ type: 'confirm',
161
+ message: `¿Es un proyecto ${PROJECT_LABELS[detected]}?`,
162
+ initial: true,
163
+ });
164
+ if (ok) projectType = detected;
165
+ }
166
+ if (!projectType) {
167
+ projectType = await ask({
168
+ type: 'select',
169
+ message: '¿Qué tipo de proyecto es?',
170
+ choices: [
171
+ { title: 'Flutter', value: 'flutter', description: 'pubspec.yaml' },
172
+ { title: 'React Native', value: 'react-native', description: 'react-native en package.json' },
173
+ { title: 'PWA (Capacitor)', value: 'pwa', description: 'capacitor.config.*' },
174
+ { title: 'Electron', value: 'electron', description: 'escritorio: Linux, macOS, Windows' },
175
+ { title: 'Detectar en cada build', value: 'auto', description: 'project_type: auto' },
176
+ ],
177
+ });
178
+ }
179
+ }
180
+ } else {
181
+ step++;
58
182
  }
59
183
 
60
184
  const isElectron = projectType === 'electron';
185
+ const steps = isElectron ? 2 : 4;
61
186
 
62
187
  let buildAndroid = true;
63
188
  let buildIos = true;
64
- let buildElectron = true;
65
189
  let wantsSigning = false;
66
190
 
67
191
  if (isElectron) {
68
- console.log('\nElectron compila Linux, macOS y Windows en paralelo — no hace falta elegir.\n');
192
+ console.log(`\n ${sym.info()} Electron compila ${c.bold('Linux, macOS y Windows')} en paralelo ${c.gray('— no hace falta elegir.')}`);
69
193
  } else {
70
- const { target } = await prompts(
71
- {
194
+ let target = opts.target;
195
+ if (!target && !auto) {
196
+ console.log(stepHeader(++step, steps, 'Plataformas'));
197
+ target = await ask({
72
198
  type: 'select',
73
- name: 'target',
74
199
  message: '¿Qué deseas compilar?',
75
200
  choices: [
76
- { title: 'Android + iOS (ambas)', value: 'both' },
77
- { title: 'Solo Android', value: 'android' },
78
- { title: 'Solo iOS', value: 'ios' },
201
+ { title: 'Android + iOS', value: 'both', description: '.apk/.aab + .ipa' },
202
+ { title: 'Solo Android', value: 'android', description: '.apk/.aab en runner Linux' },
203
+ { title: 'Solo iOS', value: 'ios', description: '.ipa en runner macOS' },
79
204
  ],
80
- },
81
- { onCancel }
82
- );
205
+ });
206
+ } else step++;
207
+ target ??= 'both';
83
208
  buildAndroid = target !== 'ios';
84
209
  buildIos = target !== 'android';
85
210
 
86
- const signingAnswer = await prompts(
87
- {
211
+ if (!auto) {
212
+ console.log(stepHeader(++step, steps, 'Firma'));
213
+ wantsSigning = await ask({
88
214
  type: 'confirm',
89
- name: 'wantsSigning',
90
215
  message: '¿Ya tenés listos los secrets de firma (keystore / certificado de Apple)?',
91
216
  initial: false,
92
- },
93
- { onCancel }
94
- );
95
- wantsSigning = signingAnswer.wantsSigning;
217
+ });
218
+ } else step++;
96
219
  }
97
220
 
98
- const { wantsRelease } = await prompts(
99
- {
221
+ let createRelease = Boolean(opts.release);
222
+ if (!opts.release && !auto) {
223
+ console.log(stepHeader(++step, steps, 'Releases'));
224
+ createRelease = await ask({
100
225
  type: 'confirm',
101
- name: 'wantsRelease',
102
- message: '¿Adjuntar los builds a un GitHub Release cuando pushees un tag (v1.2.3)?',
226
+ message: '¿Adjuntar los builds a un GitHub Release al pushear un tag (v1.2.3)?',
103
227
  initial: false,
104
- },
105
- { onCancel }
228
+ });
229
+ }
230
+
231
+ // ── Resumen ──────────────────────────────────────────────────────────────
232
+ const config = { projectType, buildAndroid, buildIos, buildElectron: true, createRelease };
233
+ const yaml = buildWorkflowYaml(config);
234
+ const yes = c.green('sí');
235
+ const no = c.gray('no');
236
+
237
+ const platforms = isElectron
238
+ ? 'Linux · macOS · Windows'
239
+ : [buildAndroid && 'Android', buildIos && 'iOS'].filter(Boolean).join(' · ');
240
+
241
+ console.log(`\n ${gradient('━'.repeat(PANEL + 2))}\n`);
242
+ console.log(
243
+ box(
244
+ table([
245
+ ['Proyecto', c.bold(PROJECT_LABELS[projectType] ?? 'Auto-detectar en cada build')],
246
+ ['Plataformas', c.bold(platforms)],
247
+ ['Firma', isElectron ? c.gray('no soportada aún') : wantsSigning ? yes : c.gray('sin firmar (por ahora)')],
248
+ ['GitHub Release', createRelease ? yes : no],
249
+ ]),
250
+ { title: 'Resumen', color: c.cyan, minWidth: PANEL }
251
+ )
106
252
  );
253
+ console.log();
254
+ console.log(box(pipeline(config), { title: 'Pipeline en GitHub Actions', color: c.magenta, minWidth: PANEL }));
255
+ console.log();
256
+ console.log(box(highlightYaml(yaml), { title: '.github/workflows/build.yml', color: c.blue, minWidth: PANEL }));
257
+ console.log();
107
258
 
259
+ if (opts['dry-run']) {
260
+ console.log(` ${sym.info()} ${c.bold('--dry-run')}: no se escribió nada.\n`);
261
+ return;
262
+ }
263
+
264
+ // ── Escritura ────────────────────────────────────────────────────────────
108
265
  const workflowPath = path.join(cwd, '.github', 'workflows', 'build.yml');
109
- if (fs.existsSync(workflowPath)) {
110
- const { overwrite } = await prompts(
111
- {
112
- type: 'confirm',
113
- name: 'overwrite',
114
- message: `Ya existe ${path.relative(cwd, workflowPath)}. ¿Sobrescribir?`,
115
- initial: false,
116
- },
117
- { onCancel }
118
- );
119
- if (!overwrite) {
120
- console.log('\nCancelado — no se modificó nada.\n');
266
+ const rel = path.relative(cwd, workflowPath);
267
+
268
+ if (!auto) {
269
+ const go = await ask({ type: 'confirm', message: `¿Escribir ${rel}?`, initial: true });
270
+ if (!go) onCancel();
271
+ }
272
+
273
+ if (fs.existsSync(workflowPath) && !opts.force) {
274
+ if (fs.readFileSync(workflowPath, 'utf8') === yaml) {
275
+ console.log(` ${sym.ok()} ${rel} ya está al día ${c.gray('— nada que cambiar.')}\n`);
121
276
  return;
122
277
  }
278
+ if (auto) {
279
+ console.log(` ${sym.warn()} ${c.yellow(`Ya existe ${rel}.`)} Usá ${c.cyan('--force')} para sobrescribirlo.\n`);
280
+ process.exit(1);
281
+ }
282
+ const overwrite = await ask({ type: 'confirm', message: `Ya existe ${rel}. ¿Sobrescribir?`, initial: false });
283
+ if (!overwrite) onCancel();
123
284
  }
124
285
 
125
- const yaml = buildWorkflowYaml({
126
- projectType,
127
- buildAndroid,
128
- buildIos,
129
- buildElectron,
130
- createRelease: wantsRelease,
131
- });
286
+ await spin(`Escribiendo ${rel}…`, () => {
287
+ fs.mkdirSync(path.dirname(workflowPath), { recursive: true });
288
+ fs.writeFileSync(workflowPath, yaml);
289
+ }, { minMs: 300 });
132
290
 
133
- fs.mkdirSync(path.dirname(workflowPath), { recursive: true });
134
- fs.writeFileSync(workflowPath, yaml);
135
-
136
- console.log(`\n✔ Escribí ${path.relative(cwd, workflowPath)}\n`);
291
+ // ── Próximos pasos ───────────────────────────────────────────────────────
292
+ const next = [`${sym.ok()} ${c.bold(`Escribí ${rel}`)}`, ''];
137
293
 
138
294
  if (isElectron) {
139
- console.log(
140
- 'Los builds de Electron salen sin firmar por ahora (la firma de macOS/Windows\n' +
141
- 'todavía no está soportada por la plataforma).\n'
142
- );
295
+ next.push(`${sym.warn()} Los builds de Electron salen ${c.yellow('sin firmar')} por ahora.`, '');
143
296
  } else if (wantsSigning) {
144
- console.log('Agregá estos secrets en GitHub (Settings → Secrets and variables → Actions):\n');
145
- if (buildAndroid) console.log(` Android: ${androidSecrets().join(', ')}`);
146
- if (buildIos) console.log(` iOS: ${iosSecrets().join(', ')}`);
147
- console.log('\nDetalle paso a paso: https://github.com/shadownrx/code/blob/main/docs/SIGNING.md\n');
297
+ next.push(c.bold('Cargá estos secrets en GitHub:'));
298
+ next.push(c.gray('Settings → Secrets and variables → Actions'), '');
299
+ if (buildAndroid) next.push(c.green('Android'), ...androidSecrets().map((s) => ` ${c.gray('☐')} ${s}`));
300
+ if (buildIos) next.push(c.green('iOS'), ...iosSecrets().map((s) => ` ${c.gray('☐')} ${s}`));
301
+ next.push('', `${c.gray('Guía:')} ${c.underline('https://github.com/shadownrx/code/blob/main/docs/SIGNING.md')}`, '');
148
302
  } else {
149
- console.log(
150
- 'Por ahora va a compilar sin firmar (sirve para probar que compila y correr en\n' +
151
- 'simulador/emulador). Cuando quieras builds firmados, mirá docs/SIGNING.md.\n'
152
- );
303
+ next.push(`${sym.info()} Va a compilar ${c.yellow('sin firmar')}: sirve para emulador/simulador.`);
304
+ next.push(` ${c.gray('Para builds firmados mirá')} ${c.cyan('docs/SIGNING.md')}`, '');
153
305
  }
154
306
 
155
- console.log('Siguiente paso: hacé commit y push — el primer build corre solo, gratis,');
156
- console.log('sin necesitar Mac (ni Windows, si es Electron) en tu máquina.\n');
307
+ next.push(rule(c.bold('Siguiente paso'), PANEL - 2).trimStart(), '');
308
+ next.push(` ${c.cyan('git add')} ${rel}`);
309
+ next.push(` ${c.cyan('git commit')} -m ${c.yellow('"ci: build con shadownrx/code"')}`);
310
+ next.push(` ${c.cyan('git push')}`);
311
+ if (createRelease) next.push('', `${c.gray('Para un release:')} ${c.cyan('git tag v1.0.0 && git push --tags')}`);
312
+
313
+ console.log(box(next, { title: gradient('Listo'), color: c.green, style: 'round', minWidth: PANEL }));
314
+ console.log();
157
315
  }
158
316
 
159
317
  main();
package/lib.js CHANGED
@@ -9,7 +9,7 @@ export const PROJECT_LABELS = {
9
9
  };
10
10
 
11
11
  /**
12
- * Same detection rules as .github/actions/detect-project/action.yml,
12
+ * Same detection rules as the `detect` job in .github/workflows/build-mobile.yml,
13
13
  * ported to Node so the CLI can guess before asking.
14
14
  */
15
15
  export function detectProjectType(cwd) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shadownrx-code",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Interactive CLI + MCP server for the shadownrx/code free Android/iOS/PWA/Electron build platform — writes .github/workflows/build.yml for your project, or exposes the same thing as tools for Cursor/Claude Code/any MCP client.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,6 +10,7 @@
10
10
  "files": [
11
11
  "bin.js",
12
12
  "lib.js",
13
+ "ui.js",
13
14
  "mcp-server.js"
14
15
  ],
15
16
  "scripts": {
package/ui.js ADDED
@@ -0,0 +1,279 @@
1
+ // Terminal rendering helpers for bin.js: banner, boxes, spinner, pipeline
2
+ // diagram, YAML highlighting. Pure ANSI, no dependencies. Every helper
3
+ // degrades to plain ASCII-art text when color is off (NO_COLOR, non-TTY,
4
+ // --no-color), so piping the output into a file or CI log stays readable.
5
+
6
+ const env = process.env;
7
+
8
+ export function colorLevel(stream = process.stdout) {
9
+ if ('NO_COLOR' in env) return 0;
10
+ if (env.FORCE_COLOR === '0') return 0;
11
+ if (!stream.isTTY && !env.FORCE_COLOR) return 0;
12
+ if (/truecolor|24bit/i.test(env.COLORTERM || '')) return 3;
13
+ if (/-256(color)?$/i.test(env.TERM || '')) return 2;
14
+ return env.FORCE_COLOR === '3' ? 3 : 2;
15
+ }
16
+
17
+ let level = colorLevel();
18
+ export function setColor(enabled) {
19
+ level = enabled ? Math.max(colorLevel(), 2) : 0;
20
+ }
21
+
22
+ const sgr = (open, close) => (s) => (level ? `\x1b[${open}m${s}\x1b[${close}m` : String(s));
23
+ export const c = {
24
+ bold: sgr(1, 22),
25
+ dim: sgr(2, 22),
26
+ italic: sgr(3, 23),
27
+ underline: sgr(4, 24),
28
+ red: sgr(31, 39),
29
+ green: sgr(32, 39),
30
+ yellow: sgr(33, 39),
31
+ blue: sgr(34, 39),
32
+ magenta: sgr(35, 39),
33
+ cyan: sgr(36, 39),
34
+ gray: sgr(90, 39),
35
+ };
36
+
37
+ function rgbTo256(r, g, b) {
38
+ const q = (v) => Math.round((v / 255) * 5);
39
+ return 16 + 36 * q(r) + 6 * q(g) + q(b);
40
+ }
41
+
42
+ export function rgb(r, g, b) {
43
+ return (s) => {
44
+ if (!level) return String(s);
45
+ const code = level === 3 ? `38;2;${r};${g};${b}` : `38;5;${rgbTo256(r, g, b)}`;
46
+ return `\x1b[${code}m${s}\x1b[39m`;
47
+ };
48
+ }
49
+
50
+ // Brand gradient: cyan → violet → pink.
51
+ const STOPS = [
52
+ [0, 229, 255],
53
+ [124, 77, 255],
54
+ [255, 45, 149],
55
+ ];
56
+
57
+ function gradientAt(t) {
58
+ const seg = Math.min(STOPS.length - 2, Math.floor(t * (STOPS.length - 1)));
59
+ const local = t * (STOPS.length - 1) - seg;
60
+ const [a, b] = [STOPS[seg], STOPS[seg + 1]];
61
+ return a.map((v, i) => Math.round(v + (b[i] - v) * local));
62
+ }
63
+
64
+ /** Colors each visible character of a plain string along the brand gradient. */
65
+ export function gradient(text, { offset = 0, span } = {}) {
66
+ if (!level) return text;
67
+ const chars = [...text];
68
+ const total = span ?? chars.length;
69
+ return chars
70
+ .map((ch, i) => (ch === ' ' ? ch : rgb(...gradientAt(Math.min(1, (i + offset) / Math.max(1, total - 1))))(ch)))
71
+ .join('');
72
+ }
73
+
74
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
75
+ export const stripAnsi = (s) => String(s).replace(ANSI_RE, '');
76
+ export const visibleWidth = (s) => [...stripAnsi(s)].length;
77
+ export const padEnd = (s, width) => s + ' '.repeat(Math.max(0, width - visibleWidth(s)));
78
+
79
+ // ─── Banner ─────────────────────────────────────────────────────────────────
80
+
81
+ // "ANSI Shadow" figlet font, only the glyphs the banner needs. Each glyph's
82
+ // rows share one width so letters can be concatenated column by column.
83
+ const FONT = {
84
+ S: ['███████╗', '██╔════╝', '███████╗', '╚════██║', '███████║', '╚══════╝'],
85
+ H: ['██╗ ██╗', '██║ ██║', '███████║', '██╔══██║', '██║ ██║', '╚═╝ ╚═╝'],
86
+ A: [' █████╗ ', '██╔══██╗', '███████║', '██╔══██║', '██║ ██║', '╚═╝ ╚═╝'],
87
+ D: ['██████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '██████╔╝', '╚═════╝ '],
88
+ O: [' ██████╗ ', '██╔═══██╗', '██║ ██║', '██║ ██║', '╚██████╔╝', ' ╚═════╝ '],
89
+ W: ['██╗ ██╗', '██║ ██║', '██║ █╗ ██║', '██║███╗██║', '╚███╔███╔╝', ' ╚══╝╚══╝ '],
90
+ N: ['███╗ ██╗', '████╗ ██║', '██╔██╗ ██║', '██║╚██╗██║', '██║ ╚████║', '╚═╝ ╚═══╝'],
91
+ R: ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔══██╗', '██║ ██║', '╚═╝ ╚═╝'],
92
+ X: ['██╗ ██╗', '╚██╗██╔╝', ' ╚███╔╝ ', ' ██╔██╗ ', '██╔╝ ██╗', '╚═╝ ╚═╝'],
93
+ };
94
+
95
+ /** Renders `word` in the ANSI Shadow font; returns one string per row. */
96
+ export function bigText(word) {
97
+ const glyphs = [...word.toUpperCase()].map((ch) => {
98
+ if (!FONT[ch]) throw new Error(`bigText: no glyph for "${ch}"`);
99
+ return FONT[ch];
100
+ });
101
+ return FONT.S.map((_, r) => glyphs.map((g) => g[r]).join('').replace(/\s+$/, ''));
102
+ }
103
+
104
+ export function banner({ version, tagline, columns = process.stdout.columns || 80 }) {
105
+ const sub = `${c.bold('/code')} ${c.gray('·')} ${c.gray(tagline)} ${c.gray('·')} ${c.dim(`v${version}`)}`;
106
+ const lines = bigText('shadownrx');
107
+ // Narrow terminal: the 77-column logo would wrap into garbage.
108
+ if (columns < Math.max(...lines.map((l) => [...l].length)) + 2) {
109
+ return ['', ` ${gradient('▲ shadownrx')}${sub}`, ''].join('\n');
110
+ }
111
+ const width = Math.max(...lines.map((l) => [...l].length));
112
+ const art = lines.map((line) => {
113
+ if (!level) return line;
114
+ return [...line]
115
+ .map((ch, i) => {
116
+ if (ch === ' ') return ch;
117
+ const paint = rgb(...gradientAt(i / (width - 1)));
118
+ // Solid blocks in full color, the box-drawing "shadow" dimmed.
119
+ return ch === '█' ? paint(ch) : c.dim(paint(ch));
120
+ })
121
+ .join('');
122
+ });
123
+ return ['', ...art.map((l) => ' ' + l), ' ' + sub, ''].join('\n');
124
+ }
125
+
126
+ // ─── Boxes & layout ─────────────────────────────────────────────────────────
127
+
128
+ const BORDERS = {
129
+ round: { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' },
130
+ heavy: { tl: '┏', tr: '┓', bl: '┗', br: '┛', h: '━', v: '┃' },
131
+ double: { tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║' },
132
+ };
133
+
134
+ /**
135
+ * Draws a framed panel around `content` (string or array of lines).
136
+ * `color` paints the border; `title` is embedded in the top edge.
137
+ */
138
+ export function box(content, { title, color = c.gray, style = 'round', padding = 1, minWidth = 0 } = {}) {
139
+ const b = BORDERS[style];
140
+ const lines = (Array.isArray(content) ? content : String(content).split('\n'));
141
+ const titleWidth = title ? visibleWidth(title) + 4 : 0;
142
+ const inner = Math.max(minWidth, titleWidth, ...lines.map((l) => visibleWidth(l) + padding * 2));
143
+ const pad = ' '.repeat(padding);
144
+
145
+ let top;
146
+ if (title) {
147
+ const head = `${b.h} ${title} `;
148
+ top = color(b.tl + b.h) + c.bold(` ${title} `) + color(b.h.repeat(Math.max(0, inner - visibleWidth(head))) + b.tr);
149
+ } else {
150
+ top = color(b.tl + b.h.repeat(inner) + b.tr);
151
+ }
152
+ const body = lines.map((l) => color(b.v) + padEnd(pad + l, inner) + color(b.v));
153
+ const bottom = color(b.bl + b.h.repeat(inner) + b.br);
154
+ return [top, ...body, bottom].map((l) => ' ' + l).join('\n');
155
+ }
156
+
157
+ /** Two-column key/value rows with the keys right-padded to the same width. */
158
+ export function table(rows, { keyColor = c.gray } = {}) {
159
+ const kw = Math.max(...rows.map(([k]) => visibleWidth(k)));
160
+ return rows.map(([k, v]) => `${keyColor(padEnd(k, kw))} ${v}`);
161
+ }
162
+
163
+ export function progressBar(done, total, width = 24) {
164
+ const filled = Math.round((done / total) * width);
165
+ const bar = '━'.repeat(filled);
166
+ return gradient(bar, { span: width }) + c.gray('─'.repeat(width - filled));
167
+ }
168
+
169
+ export function stepHeader(n, total, title) {
170
+ const label = c.bold(`Paso ${n}/${total}`);
171
+ return `\n ${progressBar(n - 1, total)} ${label} ${c.gray('·')} ${title}\n`;
172
+ }
173
+
174
+ export function rule(label = '', width = 64) {
175
+ if (!label) return ' ' + c.gray('─'.repeat(width));
176
+ const tail = Math.max(2, width - visibleWidth(label) - 4);
177
+ return ` ${c.gray('──')} ${label} ${c.gray('─'.repeat(tail))}`;
178
+ }
179
+
180
+ // ─── Spinner ────────────────────────────────────────────────────────────────
181
+
182
+ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
183
+
184
+ /**
185
+ * Runs `task` while showing an animated spinner (TTY only), keeping it
186
+ * visible for at least `minMs` so it doesn't flash by unreadably.
187
+ */
188
+ export async function spin(text, task, { minMs = 450, stream = process.stdout } = {}) {
189
+ const started = Date.now();
190
+ let i = 0;
191
+ let timer;
192
+ if (stream.isTTY) {
193
+ stream.write('\x1b[?25l');
194
+ timer = setInterval(() => {
195
+ stream.write(`\r ${rgb(...gradientAt((i % FRAMES.length) / (FRAMES.length - 1)))(FRAMES[i % FRAMES.length])} ${text}`);
196
+ i++;
197
+ }, 70);
198
+ }
199
+ try {
200
+ const result = await task();
201
+ const left = minMs - (Date.now() - started);
202
+ if (stream.isTTY && left > 0) await new Promise((r) => setTimeout(r, left));
203
+ return result;
204
+ } finally {
205
+ if (timer) {
206
+ clearInterval(timer);
207
+ stream.write('\r\x1b[2K\x1b[?25h');
208
+ }
209
+ }
210
+ }
211
+
212
+ export const sym = {
213
+ ok: () => c.green('✔'),
214
+ fail: () => c.red('✖'),
215
+ warn: () => c.yellow('▲'),
216
+ info: () => c.cyan('●'),
217
+ arrow: () => c.gray('›'),
218
+ dot: () => c.gray('·'),
219
+ };
220
+
221
+ // ─── Pipeline diagram ───────────────────────────────────────────────────────
222
+
223
+ /**
224
+ * ASCII flow of the jobs build-mobile.yml will run for this configuration,
225
+ * mirroring the real job graph (detect → build-* → notify).
226
+ */
227
+ export function pipeline({ projectType, buildAndroid, buildIos, createRelease }) {
228
+ const jobs = [];
229
+ if (projectType === 'electron') {
230
+ jobs.push(['electron · linux', 'ubuntu', '.AppImage']);
231
+ jobs.push(['electron · macos', 'macos', '.dmg']);
232
+ jobs.push(['electron · windows', 'windows', '.exe']);
233
+ } else {
234
+ if (buildAndroid) jobs.push(['build-android', 'ubuntu', '.apk .aab']);
235
+ if (buildIos) jobs.push(['build-ios', 'macos', '.ipa']);
236
+ }
237
+
238
+ const JOB = 20;
239
+ const RUN = 10;
240
+ const node = (glyph, name, run, note) =>
241
+ `${padEnd(glyph, 4)}${c.bold(padEnd(name, JOB))}${c.gray(padEnd(`[${run}]`, RUN))} ${note}`;
242
+ const pipe = c.gray('│');
243
+
244
+ const lines = [
245
+ `${padEnd(c.cyan('◉'), 4)}${c.bold(padEnd('git push', JOB + RUN))} ${c.gray('main · PR · tags v* · manual')}`,
246
+ pipe,
247
+ node(c.cyan('◆'), 'detect', 'ubuntu', c.gray(`project_type: ${projectType}`)),
248
+ pipe,
249
+ ...jobs.map(([name, run, out]) => node(c.gray('├─▶'), name, run, `${c.gray('→')} ${c.green(out)}`)),
250
+ pipe,
251
+ node(c.magenta('◆'), 'notify', 'ubuntu', c.gray('resumen · Slack opcional')),
252
+ ];
253
+ if (createRelease) lines.push(`${padEnd(c.gray('└─▶'), 4)}${c.yellow(padEnd('GitHub Release', JOB + RUN))} ${c.gray('adjunta los builds en tags v*')}`);
254
+ return lines;
255
+ }
256
+
257
+ // ─── YAML highlighting ──────────────────────────────────────────────────────
258
+
259
+ /** Minimal line-based YAML coloring, enough for the workflow we generate. */
260
+ export function highlightYaml(yaml) {
261
+ return yaml
262
+ .replace(/\n$/, '')
263
+ .split('\n')
264
+ .map((line, i) => {
265
+ const num = c.gray(String(i + 1).padStart(2, ' ') + ' │ ');
266
+ if (!level) return num + line;
267
+ if (/^\s*#/.test(line)) return num + c.gray(line);
268
+ const m = line.match(/^(\s*)(-\s+)?([\w.-]+)(:)(\s*)(.*)$/);
269
+ if (!m) return num + line;
270
+ const [, ind, dash = '', key, colon, sp, value] = m;
271
+ let v = value;
272
+ if (/^(true|false)$/.test(value)) v = c.magenta(value);
273
+ else if (/^\[.*\]$/.test(value)) v = c.yellow(value);
274
+ else if (/^["']/.test(value)) v = c.yellow(value);
275
+ else if (value) v = c.green(value);
276
+ return num + ind + c.gray(dash) + c.cyan(key) + c.gray(colon) + sp + v;
277
+ })
278
+ .join('\n');
279
+ }