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.
- package/README.md +122 -63
- package/bin.js +252 -94
- package/lib.js +1 -1
- package/package.json +2 -1
- package/ui.js +279 -0
package/README.md
CHANGED
|
@@ -1,74 +1,130 @@
|
|
|
1
1
|
# shadownrx-code
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/shadownrx-code)
|
|
4
|
+
[](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
|
|
5
|
-
(Capacitor) o Electron,
|
|
6
|
-
`.github/workflows/build.yml`
|
|
7
|
-
prepara el workflow que
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
65
|
+
El paquete está publicado en npm como
|
|
66
|
+
[`shadownrx-code`](https://www.npmjs.com/package/shadownrx-code).
|
|
26
67
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
91
|
+
node bin.js
|
|
53
92
|
```
|
|
54
93
|
|
|
55
|
-
|
|
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
|
|
65
|
-
[MCP](https://modelcontextprotocol.io)
|
|
66
|
-
Code, Windsurf, o cualquier editor con soporte
|
|
67
|
-
Cursor) puede
|
|
68
|
-
terminal
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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
|
|
101
|
-
mock): `mcp-server.test.js` levanta `mcp-server.js` como
|
|
102
|
-
|
|
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
|
|
107
|
-
[`build-mobile.yml`](../.github/workflows/build-mobile.yml)
|
|
108
|
-
opción que prometa
|
|
109
|
-
|
|
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` (
|
|
171
|
+
| ¿Adjuntar a un GitHub Release en tags? | `create_release` (incluye el bloque `permissions: contents: write` requerido) |
|
|
116
172
|
|
|
117
|
-
Para Electron, en
|
|
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
|
|
121
|
-
exactos que
|
|
122
|
-
|
|
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,
|
|
181
|
+
Si `.github/workflows/build.yml` ya existe, se solicita confirmación antes
|
|
182
|
+
de sobrescribirlo.
|
|
125
183
|
|
|
126
184
|
## Desarrollo
|
|
127
185
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
[`prompts`](https://www.npmjs.com/package/prompts))
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
26
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
205
|
+
});
|
|
206
|
+
} else step++;
|
|
207
|
+
target ??= 'both';
|
|
83
208
|
buildAndroid = target !== 'ios';
|
|
84
209
|
buildIos = target !== 'android';
|
|
85
210
|
|
|
86
|
-
|
|
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
|
-
|
|
94
|
-
);
|
|
95
|
-
wantsSigning = signingAnswer.wantsSigning;
|
|
217
|
+
});
|
|
218
|
+
} else step++;
|
|
96
219
|
}
|
|
97
220
|
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
if (
|
|
147
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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/
|
|
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.
|
|
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
|
+
}
|