@getparallel/parx 0.6.0 → 0.8.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/CHANGELOG.md CHANGED
@@ -6,6 +6,17 @@ changes). Entries are generated by `npm run release` from the git history of `ap
6
6
 
7
7
  <!-- Notes written under «## Unreleased» are used as the next entry instead of the git log. -->
8
8
 
9
+ ## 0.8.0 (2026-10-18)
10
+
11
+ - Publicar desde parx (t464): la web construida con parx sale en <nombre>.getparallel.net o en su dominio
12
+
13
+ ## 0.7.0 (2026-10-18)
14
+
15
+ - Un paso corto ya no tumba el plan; «Llévalo a tu negocio» lleva a la misión en marcha y sin palabras que disparen la plantilla de clientes
16
+ - Del código al negocio (x03): «Llévalo a tu negocio» en Sesiones y /mission con el enlace de la web
17
+ - Ver en el móvil la web que construye (x01)
18
+ - El Mac siempre listo (x07): parx service on/off/add/remove/status, tareas abiertas desde el móvil
19
+
9
20
  ## 0.6.0 (2026-10-18)
10
21
 
11
22
  - parx updates itself. Every few hours it checks npm in the background; a new version is downloaded, verified (npm's registry signature against npm's public key pinned in parx, plus the package's SHA-512) and start-tested, then kept ready in `~/.config/parx/versiones`. It starts with your next session, never mid-session, with one line: «parx 0.5.1 updated · what's new». If a new version fails to start, parx goes back to the previous one on its own. `/autoupdate off` (or `PARX_NO_AUTO_UPDATE=1`) keeps the old behaviour: a line when there is a new version, and `parx update` installs it.
package/dist/api.js CHANGED
@@ -128,6 +128,12 @@ export function latidoRemoto(servidor, token, id, cuerpo, señal) {
128
128
  export function ordenesRemotas(servidor, token, id, esperaMs, señal) {
129
129
  return llamar(servidor, `/api/terminal/remoto/${encodeURIComponent(id)}/ordenes?espera=${esperaMs}`, { token, señal, plazoMs: esperaMs + 15_000 });
130
130
  }
131
+ export function subirVistaRemota(servidor, token, id, cuerpo) {
132
+ return llamar(servidor, `/api/terminal/remoto/${encodeURIComponent(id)}/vista`, { token, cuerpo, plazoMs: 60_000 });
133
+ }
134
+ export function publicarWebRemota(servidor, token, id) {
135
+ return llamar(servidor, `/api/terminal/remoto/${encodeURIComponent(id)}/publicar`, { token, cuerpo: {}, plazoMs: 90_000 });
136
+ }
131
137
  export function cerrarRemoto(servidor, token, id, plazoMs = 3000) {
132
138
  return llamar(servidor, `/api/terminal/remoto/${encodeURIComponent(id)}`, { token, metodo: 'DELETE', plazoMs });
133
139
  }
package/dist/cli.js CHANGED
@@ -9,7 +9,7 @@ import { ALIAS_COMANDO, ALIAS_SI_NO, COMANDOS, t, USO } from "./textos.js";
9
9
  import { c, capacidades, colorDeModo, fijarCapacidades, sep, signo, sinColor, SIMBOLOS } from "./marca.js";
10
10
  import { guardarAjustes, leerAjustes, migrarCarpetaVieja, VERSION } from "./config.js";
11
11
  import { MODELO_POR_DEFECTO, MODELOS, modeloDe, nombreDeModelo } from "./modelos.js";
12
- import { cicloDeMision, compartirMision, correoVisible, crearMision, equipo, ErrorDeParallel, leerBandeja, leerConexiones, leerCuenta, leerMisionDeLaCuenta, leerMisiones, leerPlantillas, quienEs, responderBandeja, verMision, } from "./api.js";
12
+ import { cicloDeMision, compartirMision, correoVisible, crearMision, equipo, ErrorDeParallel, leerBandeja, leerConexiones, leerCuenta, leerMisionDeLaCuenta, leerMisiones, leerPlantillas, quienEs, responderBandeja, verMision, publicarWebRemota, } from "./api.js";
13
13
  import { abrirNavegador, comoHasEntrado, credencialesActivas, entrar, formaDeLaOpcion, salir } from "./cuenta.js";
14
14
  import { accionesDeUnaMision, filtrarPorTitulo, hace } from "./comandos.js";
15
15
  import { hacerTarea, mensajeDeError, nuevaSesion, reiniciarConversacion } from "./agente.js";
@@ -17,6 +17,8 @@ import { deshacerUltimo, permitirCarpeta } from "./herramientas.js";
17
17
  import { Registro } from "./registro.js";
18
18
  import { Pantalla } from "./ui.js";
19
19
  import { Remoto } from "./remoto.js";
20
+ import { VistaPrevia } from "./vista.js";
21
+ import { anadirCarpeta, carpetasDelServicio, instalarServicio, quitarCarpeta, quitarServicio, reiniciarServicio, rutaDelRegistro, servicioEnMarcha, servicioInstalado, servir } from "./servicio.js";
20
22
  import { qrEnTexto } from "./qr.js";
21
23
  import { acciones, bloqueDeBienvenida, bloqueDeLaPersona, bloqueDeTabla, duracion, Superficie } from "./tui.js";
22
24
  import { CONSEJOS, cambiarConsejos, consejo, elegirConsejos, ordenDeConsejos } from "./consejos.js";
@@ -49,6 +51,9 @@ function opciones() {
49
51
  'no-animation': { type: 'boolean' },
50
52
  animation: { type: 'boolean' },
51
53
  remote: { type: 'boolean' },
54
+ serve: { type: 'boolean' },
55
+ 'remote-child': { type: 'boolean' },
56
+ origen: { type: 'string' },
52
57
  version: { type: 'boolean', short: 'v' },
53
58
  help: { type: 'boolean', short: 'h' },
54
59
  browser: { type: 'boolean' },
@@ -222,6 +227,62 @@ async function principal() {
222
227
  pantalla.escribir();
223
228
  return 0;
224
229
  }
230
+ if (v.serve) {
231
+ const c0 = credencialesActivas();
232
+ if (!c0) {
233
+ console.log(t('cuenta.sin_sesion'));
234
+ return 1;
235
+ }
236
+ return servir(c0);
237
+ }
238
+ if (primero === 'service' || primero === 'servicio') {
239
+ const accion = (positionals[1] ?? 'status').toLowerCase();
240
+ const ruta = positionals.slice(2).join(' ') || process.cwd();
241
+ const lista = () => carpetasDelServicio().map((x) => x.nombre).join(', ') || t('servicio.ninguna');
242
+ if (accion === 'add') {
243
+ const r = anadirCarpeta(ruta);
244
+ pantalla.escribir(r ? t('servicio.anadida', { ruta: rutaCorta(r, 60) }) : t('servicio.no_anadida', { ruta }));
245
+ if (r)
246
+ reiniciarServicio();
247
+ return r ? 0 : 1;
248
+ }
249
+ if (accion === 'remove') {
250
+ const ok = quitarCarpeta(positionals.slice(2).join(' '));
251
+ pantalla.escribir(ok ? t('servicio.quitada', { ruta: positionals.slice(2).join(' ') }) : t('servicio.no_quitada', { ruta: positionals.slice(2).join(' ') }));
252
+ if (ok)
253
+ reiniciarServicio();
254
+ return ok ? 0 : 1;
255
+ }
256
+ if (accion === 'on') {
257
+ if (process.platform !== 'darwin') {
258
+ pantalla.error(t('servicio.solo_mac'));
259
+ return 1;
260
+ }
261
+ if (!credencialesActivas()) {
262
+ pantalla.escribir(t('cuenta.sin_sesion'));
263
+ return 1;
264
+ }
265
+ if (!carpetasDelServicio().length && !anadirCarpeta(process.cwd())) {
266
+ pantalla.escribir(t('servicio.sin_carpetas'));
267
+ return 1;
268
+ }
269
+ instalarServicio();
270
+ pantalla.escribir(t('servicio.on', { carpetas: lista() }));
271
+ pantalla.escribir(c.gris(t('servicio.registro', { ruta: rutaDelRegistro() })));
272
+ return 0;
273
+ }
274
+ if (accion === 'off') {
275
+ quitarServicio();
276
+ pantalla.escribir(t('servicio.off'));
277
+ return 0;
278
+ }
279
+ if (accion === 'status') {
280
+ pantalla.escribir(t('servicio.estado', { estado: servicioInstalado() && servicioEnMarcha() ? t('servicio.en_marcha') : t('servicio.parado'), lista: lista() }));
281
+ return 0;
282
+ }
283
+ pantalla.escribir(t('servicio.uso'));
284
+ return 2;
285
+ }
225
286
  const modoPedido = (v.mode ?? v.modo);
226
287
  const pedido = modoDe(modoPedido);
227
288
  if (pedido === 'bypass') {
@@ -302,6 +363,24 @@ async function principal() {
302
363
  let superficie = null;
303
364
  let remoto = null;
304
365
  let deFueraPendientes = 0;
366
+ let sugeridaMision = false;
367
+ let vistaPrevia = new VistaPrevia();
368
+ const actualizarVista = async (forzar = false) => {
369
+ const r0 = remoto;
370
+ if (!r0?.encendido || !r0.id)
371
+ return null;
372
+ try {
373
+ const r = await vistaPrevia.actualizar({ servidor: cred.servidor, token: cred.token, remoto: r0.id, raiz });
374
+ if (!r)
375
+ return null;
376
+ if (r.cambiados > 0 || forzar)
377
+ r0.evento('vista', { url: r.url, version: r.version, archivos: r.cambiados, carpeta: r.carpeta });
378
+ return { url: r.url };
379
+ }
380
+ catch (e) {
381
+ return { error: e instanceof ErrorDeParallel ? e.codigo : 'red' };
382
+ }
383
+ };
305
384
  let bienvenida;
306
385
  let consejosDeEntrada = [];
307
386
  const confirmarNuevaEnMarcha = async () => {
@@ -397,6 +476,12 @@ async function principal() {
397
476
  superficie?.descansar();
398
477
  remoto?.fijarEstado('libre', null);
399
478
  }
479
+ if (remoto?.encendido)
480
+ void actualizarVista();
481
+ if (!sugeridaMision && sesion.diario.some((d) => d.ficheros.length) && superficie) {
482
+ sugeridaMision = true;
483
+ soloAqui(() => pantalla.escribir(c.gris(t('mision.sugerencia'))));
484
+ }
400
485
  quizaAvisar(t('aviso.termino', { tarea: texto.replace(/\s+/g, ' ').slice(0, 80) }), Date.now() - inicio);
401
486
  return seguir;
402
487
  };
@@ -434,6 +519,7 @@ async function principal() {
434
519
  try {
435
520
  const r = await nuevo.abrir({ equipo: equipo().equipo, version: equipo().version, carpeta: basename(raiz), modelo: nombreDeModelo(sesion.modelo), modo: nombreDeModo(sesion.ctx.modo) });
436
521
  remoto = nuevo;
522
+ vistaPrevia = new VistaPrevia();
437
523
  registro.apuntar('remoto', { encendido: true });
438
524
  enseñarEnlace(r.url, enseñarQR);
439
525
  pantalla.espejo = nuevo;
@@ -529,6 +615,63 @@ async function principal() {
529
615
  quizaAvisar(t('carriles.hecho', { n: tareas.length }), ms);
530
616
  return r.ok;
531
617
  };
618
+ if (v['remote-child']) {
619
+ const cola = [];
620
+ let despertar = null;
621
+ let cortada = false;
622
+ const r = new Remoto({
623
+ servidor: cred.servidor,
624
+ token: cred.token,
625
+ alMensaje: (texto) => {
626
+ cola.push(texto);
627
+ despertar?.();
628
+ },
629
+ alParar: () => {
630
+ if (ocupado)
631
+ control.abort();
632
+ },
633
+ alApagarse: () => {
634
+ cortada = true;
635
+ if (ocupado)
636
+ control.abort();
637
+ despertar?.();
638
+ },
639
+ });
640
+ const e = equipo();
641
+ try {
642
+ await r.abrir({ equipo: e.equipo, version: e.version, carpeta: basename(raiz), modelo: nombreDeModelo(sesion.modelo), modo: nombreDeModo(sesion.ctx.modo), ...(typeof v.origen === 'string' ? { origen: v.origen } : {}) });
643
+ }
644
+ catch {
645
+ return 1;
646
+ }
647
+ remoto = r;
648
+ pantalla.espejo = r;
649
+ if (tarea) {
650
+ deFueraPendientes++;
651
+ await hacer(tarea);
652
+ }
653
+ const INACTIVA_MS = 30 * 60 * 1000;
654
+ while (!cortada) {
655
+ const siguiente = cola.shift();
656
+ if (siguiente) {
657
+ deFueraPendientes++;
658
+ await hacer(siguiente);
659
+ continue;
660
+ }
661
+ const hubo = await new Promise((res) => {
662
+ const reloj = setTimeout(() => res(false), INACTIVA_MS);
663
+ despertar = () => {
664
+ clearTimeout(reloj);
665
+ res(true);
666
+ };
667
+ });
668
+ despertar = null;
669
+ if (!hubo)
670
+ break;
671
+ }
672
+ await r.cerrar();
673
+ return 0;
674
+ }
532
675
  if (tarea) {
533
676
  cabeceraCorta([c.gris([nombreDeModo(modo), nombreDeModelo(modelo), cuenta.plan, rutaCorta(raiz)].filter(Boolean).join(sep()))]);
534
677
  if (quiereBypass && (await confirmarBypass()))
@@ -1088,7 +1231,7 @@ async function principal() {
1088
1231
  break;
1089
1232
  }
1090
1233
  case 'mission': {
1091
- const objetivo = objetivoDeMision(resto, sesion.diario);
1234
+ const objetivo = objetivoDeMision(resto, sesion.diario, vistaPrevia.url);
1092
1235
  if (!objetivo) {
1093
1236
  pantalla.escribir(t('mision.nada'));
1094
1237
  break;
@@ -1197,6 +1340,44 @@ async function principal() {
1197
1340
  await encenderRemoto(true);
1198
1341
  break;
1199
1342
  }
1343
+ case 'preview': {
1344
+ if (!remoto?.encendido) {
1345
+ pantalla.escribir(t('vista.sin_remoto'));
1346
+ break;
1347
+ }
1348
+ const r = await actualizarVista(true);
1349
+ if (!r)
1350
+ pantalla.escribir(t('vista.sin_web'));
1351
+ else if ('error' in r)
1352
+ pantalla.error(t('vista.no_subida', { motivo: r.error }));
1353
+ else
1354
+ pantalla.escribir(t('vista.lista', { url: c.marca(r.url) }));
1355
+ break;
1356
+ }
1357
+ case 'publish': {
1358
+ const r0 = remoto;
1359
+ if (!r0?.encendido || !r0.id) {
1360
+ pantalla.escribir(t('publicar.sin_remoto'));
1361
+ break;
1362
+ }
1363
+ const subida = await actualizarVista(true);
1364
+ if (!subida) {
1365
+ pantalla.escribir(t('vista.sin_web'));
1366
+ break;
1367
+ }
1368
+ if ('error' in subida) {
1369
+ pantalla.error(t('vista.no_subida', { motivo: subida.error }));
1370
+ break;
1371
+ }
1372
+ try {
1373
+ const p = await publicarWebRemota(cred.servidor, cred.token, r0.id);
1374
+ pantalla.escribir(t('publicar.lista', { url: c.marca(p.url) }));
1375
+ }
1376
+ catch (e) {
1377
+ pantalla.error(t('publicar.error', { motivo: e instanceof ErrorDeParallel ? `${e.codigo}${e.detalle ? ` ${e.detalle}` : ''}` : 'red' }));
1378
+ }
1379
+ break;
1380
+ }
1200
1381
  case 'autoupdate': {
1201
1382
  const si = ALIAS_SI_NO[arg] ?? (autoactualizarApagada() ? 'si' : 'no');
1202
1383
  guardarAjustes({ autoactualizar: si === 'si' });
package/dist/config.js CHANGED
@@ -100,6 +100,8 @@ export function leerAjustes(dir = carpetaDeConfiguracion()) {
100
100
  out.autoactualizar = a.autoactualizar;
101
101
  if (typeof a.remoto === 'boolean')
102
102
  out.remoto = a.remoto;
103
+ if (Array.isArray(a.carpetasRemotas))
104
+ out.carpetasRemotas = a.carpetasRemotas.filter((x) => typeof x === 'string').slice(0, 20);
103
105
  const k = a.consejos;
104
106
  if (k && typeof k === 'object') {
105
107
  const n = (x) => (typeof x === 'number' && Number.isFinite(x) && x >= 0 ? Math.floor(x) : 0);
package/dist/mision.js CHANGED
@@ -1,12 +1,13 @@
1
1
  export const MAX_OBJETIVO = 600;
2
- export function objetivoDeMision(meta, diario) {
2
+ export function objetivoDeMision(meta, diario, vista = null) {
3
3
  const ultima = [...diario].reverse().find((d) => d.pedido.trim());
4
4
  const que = (meta.trim() || ultima?.pedido.trim() || '').replace(/\s+/g, ' ');
5
5
  if (que.length < 4)
6
6
  return null;
7
7
  const ficheros = [...new Set(diario.flatMap((d) => d.ficheros))].slice(0, 6);
8
- const contexto = ultima
9
- ? ` Context from the terminal (parx): ${ficheros.length ? `changed ${ficheros.join(', ')}. ` : ''}${ultima.respuesta ? `Last result: ${ultima.respuesta.replace(/\s+/g, ' ')}` : ''}`
8
+ const enlace = vista ? `preview of the website (temporary link, valid 24 hours): ${vista}. ` : '';
9
+ const contexto = ultima || vista
10
+ ? ` Context from the terminal (parx): ${enlace}${ficheros.length ? `changed ${ficheros.join(', ')}. ` : ''}${ultima?.respuesta ? `Last result: ${ultima.respuesta.replace(/\s+/g, ' ')}` : ''}`
10
11
  : '';
11
12
  const base = que.length > MAX_OBJETIVO ? `${que.slice(0, MAX_OBJETIVO - 1)}…` : que;
12
13
  const sitio = MAX_OBJETIVO - base.length;
package/dist/remoto.js CHANGED
@@ -242,9 +242,20 @@ export class Remoto {
242
242
  return;
243
243
  }
244
244
  if (orden.tipo === 'parar') {
245
+ for (const [id, p] of this.preguntas) {
246
+ this.preguntas.delete(id);
247
+ p.resolver('no');
248
+ }
245
249
  this.o.alParar();
246
250
  return;
247
251
  }
252
+ if (orden.tipo === 'abrir') {
253
+ const carpeta = typeof orden.datos.carpeta === 'string' ? orden.datos.carpeta : '';
254
+ const texto = typeof orden.datos.texto === 'string' ? orden.datos.texto.trim().slice(0, MAX_TEXTO) : '';
255
+ if (carpeta && texto)
256
+ this.o.alAbrir?.({ orden: orden.id, carpeta, texto });
257
+ return;
258
+ }
248
259
  if (orden.tipo === 'respuesta') {
249
260
  const id = String(orden.datos.pregunta ?? '');
250
261
  const valor = orden.datos.valor;
@@ -0,0 +1,191 @@
1
+ import { spawn, execFileSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, mkdirSync, openSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
4
+ import { homedir, hostname } from 'node:os';
5
+ import { basename, join, resolve } from 'node:path';
6
+ import { equipo, ErrorDeParallel } from "./api.js";
7
+ import { guardarAjustes, leerAjustes } from "./config.js";
8
+ import { Remoto } from "./remoto.js";
9
+ export const ETIQUETA = 'net.getparallel.parx';
10
+ export const MAX_SESIONES = 3;
11
+ export function idDeCarpeta(ruta) {
12
+ return createHash('sha256').update(ruta).digest('hex').slice(0, 16);
13
+ }
14
+ export function carpetasDelServicio(rutas = leerAjustes().carpetasRemotas ?? []) {
15
+ return rutas
16
+ .filter((r) => {
17
+ try {
18
+ return existsSync(r) && statSync(r).isDirectory();
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ })
24
+ .map((ruta) => ({ id: idDeCarpeta(ruta), nombre: basename(ruta) || ruta, ruta }));
25
+ }
26
+ export function anadirCarpeta(ruta) {
27
+ const abs = resolve(ruta);
28
+ if (!existsSync(abs) || !statSync(abs).isDirectory())
29
+ return null;
30
+ const real = realpathSync(abs);
31
+ if (real === homedir() || real === '/' || real === realpathSync(homedir()))
32
+ return null;
33
+ const lista = leerAjustes().carpetasRemotas ?? [];
34
+ if (!lista.includes(real))
35
+ guardarAjustes({ carpetasRemotas: [...lista, real].slice(-20) });
36
+ return real;
37
+ }
38
+ export function quitarCarpeta(ruta) {
39
+ const lista = leerAjustes().carpetasRemotas ?? [];
40
+ let real = resolve(ruta);
41
+ try {
42
+ real = realpathSync(real);
43
+ }
44
+ catch {
45
+ }
46
+ const queda = lista.filter((x) => x !== real && basename(x) !== ruta);
47
+ guardarAjustes({ carpetasRemotas: queda });
48
+ return queda.length !== lista.length;
49
+ }
50
+ export const rutaDelPlist = () => join(homedir(), 'Library', 'LaunchAgents', `${ETIQUETA}.plist`);
51
+ export const rutaDelRegistro = () => join(homedir(), 'Library', 'Logs', 'parx-service.log');
52
+ const xml = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
53
+ export function plistDelServicio(o) {
54
+ const args = [o.node, ...o.argumentos, '--serve'].map((a) => ` <string>${xml(a)}</string>`).join('\n');
55
+ return `<?xml version="1.0" encoding="UTF-8"?>
56
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
57
+ <plist version="1.0">
58
+ <dict>
59
+ <key>Label</key>
60
+ <string>${ETIQUETA}</string>
61
+ <key>ProgramArguments</key>
62
+ <array>
63
+ ${args}
64
+ </array>
65
+ <key>RunAtLoad</key>
66
+ <true/>
67
+ <key>KeepAlive</key>
68
+ <dict>
69
+ <key>SuccessfulExit</key>
70
+ <false/>
71
+ </dict>
72
+ <key>ThrottleInterval</key>
73
+ <integer>30</integer>
74
+ <key>EnvironmentVariables</key>
75
+ <dict>
76
+ <key>PATH</key>
77
+ <string>${xml(o.path)}</string>
78
+ </dict>
79
+ <key>StandardOutPath</key>
80
+ <string>${xml(o.registro)}</string>
81
+ <key>StandardErrorPath</key>
82
+ <string>${xml(o.registro)}</string>
83
+ </dict>
84
+ </plist>
85
+ `;
86
+ }
87
+ const dominio = () => `gui/${process.getuid?.() ?? 501}`;
88
+ export function servicioInstalado() {
89
+ return existsSync(rutaDelPlist());
90
+ }
91
+ export function instalarServicio() {
92
+ if (process.platform !== 'darwin')
93
+ throw new Error('solo_mac');
94
+ const plist = rutaDelPlist();
95
+ mkdirSync(join(homedir(), 'Library', 'LaunchAgents'), { recursive: true });
96
+ mkdirSync(join(homedir(), 'Library', 'Logs'), { recursive: true });
97
+ writeFileSync(plist, plistDelServicio({ node: process.execPath, argumentos: [...process.execArgv, process.argv[1]], path: process.env.PATH ?? '/usr/bin:/bin', registro: rutaDelRegistro() }), { mode: 0o644 });
98
+ try {
99
+ execFileSync('launchctl', ['bootout', `${dominio()}/${ETIQUETA}`], { stdio: 'ignore' });
100
+ }
101
+ catch {
102
+ }
103
+ execFileSync('launchctl', ['bootstrap', dominio(), plist], { stdio: 'ignore' });
104
+ }
105
+ export function reiniciarServicio() {
106
+ if (!servicioInstalado())
107
+ return;
108
+ try {
109
+ execFileSync('launchctl', ['kickstart', '-k', `${dominio()}/${ETIQUETA}`], { stdio: 'ignore' });
110
+ }
111
+ catch {
112
+ }
113
+ }
114
+ export function quitarServicio() {
115
+ try {
116
+ execFileSync('launchctl', ['bootout', `${dominio()}/${ETIQUETA}`], { stdio: 'ignore' });
117
+ }
118
+ catch {
119
+ }
120
+ rmSync(rutaDelPlist(), { force: true });
121
+ }
122
+ export function servicioEnMarcha() {
123
+ try {
124
+ execFileSync('launchctl', ['print', `${dominio()}/${ETIQUETA}`], { stdio: 'ignore' });
125
+ return true;
126
+ }
127
+ catch {
128
+ return false;
129
+ }
130
+ }
131
+ const log = (...a) => console.log(new Date().toISOString(), ...a);
132
+ const dormir = (ms) => new Promise((r) => setTimeout(r, ms));
133
+ export function lanzarSesion(o) {
134
+ const salida = openSync(rutaDelRegistro(), 'a');
135
+ return spawn(process.execPath, [...process.execArgv, process.argv[1], '--remote-child', '--origen', o.orden, '--dir', o.ruta, '--', o.texto], {
136
+ cwd: o.ruta,
137
+ stdio: ['ignore', salida, salida],
138
+ env: { ...process.env, PARX_NO_UPDATE_CHECK: '1' },
139
+ });
140
+ }
141
+ export async function servir(cred) {
142
+ const carpetas = carpetasDelServicio();
143
+ if (!carpetas.length) {
144
+ log('no folders: run `parx service add` in a project folder');
145
+ return 0;
146
+ }
147
+ let vivas = 0;
148
+ let fin = null;
149
+ const terminado = new Promise((r) => (fin = r));
150
+ const remoto = new Remoto({
151
+ servidor: cred.servidor,
152
+ token: cred.token,
153
+ alMensaje: () => { },
154
+ alParar: () => { },
155
+ alAbrir: ({ orden, carpeta, texto }) => {
156
+ const c = carpetasDelServicio().find((x) => x.id === carpeta);
157
+ if (!c)
158
+ return log('open: unknown folder', carpeta);
159
+ if (vivas >= MAX_SESIONES)
160
+ return log('open: too many sessions');
161
+ vivas++;
162
+ log('open', c.nombre);
163
+ const hijo = lanzarSesion({ ruta: c.ruta, orden, texto });
164
+ hijo.on('exit', () => vivas--);
165
+ },
166
+ alApagarse: (motivo) => {
167
+ log('stopped:', motivo);
168
+ fin?.(motivo === 'web' ? 0 : 1);
169
+ },
170
+ });
171
+ const e = equipo();
172
+ for (let pausa = 5000;; pausa = Math.min(300_000, pausa * 2)) {
173
+ try {
174
+ await remoto.abrir({ tipo: 'servicio', carpetas: carpetas.map(({ id, nombre }) => ({ id, nombre })), equipo: e.equipo || hostname(), version: e.version, carpeta: '', modelo: '', modo: '' });
175
+ log('listening:', carpetas.map((c) => c.nombre).join(', '));
176
+ break;
177
+ }
178
+ catch (err) {
179
+ if (err instanceof ErrorDeParallel && (err.codigo === 'sesion' || err.codigo === 'sin_acceso')) {
180
+ log('cannot start:', err.codigo);
181
+ return 1;
182
+ }
183
+ log('no connection, retrying');
184
+ await dormir(pausa);
185
+ }
186
+ }
187
+ const salir = () => void remoto.cerrar().finally(() => fin?.(0));
188
+ process.on('SIGTERM', salir);
189
+ process.on('SIGINT', salir);
190
+ return terminado;
191
+ }
package/dist/textos.js CHANGED
@@ -237,6 +237,28 @@ export const TEXTOS = {
237
237
  'remoto.desde_el_movil': 'from your phone',
238
238
  'remoto.parado': 'Stopped from your phone.',
239
239
  'remoto.solo_interactiva': 'Remote control works in an interactive session: run parx and then /remote.',
240
+ 'mision.sugerencia': 'Next: /mission send it to my client · or announce it, or charge for it. It asks before sending anything.',
241
+ 'vista.lista': 'Preview on your phone (updates after each task): {url} · /publish puts it online.',
242
+ 'vista.sin_remoto': 'Turn on /remote first: the preview opens from your phone.',
243
+ 'vista.sin_web': 'No web to preview: there is no index.html here or in dist/, build/ or out/.',
244
+ 'vista.no_subida': 'The preview could not be uploaded: {motivo}.',
245
+ 'publicar.lista': 'Live at {url} · the same address every time you /publish. Your own domain: from Sessions on your phone.',
246
+ 'publicar.sin_remoto': 'Turn on /remote first: publishing goes through your Parallel account.',
247
+ 'publicar.error': 'Could not publish: {motivo}.',
248
+ 'servicio.on': 'Your Mac is ready. From Sessions on your phone you can start a task in: {carpetas}. It starts again every time you log in.',
249
+ 'servicio.off': 'The service is off. Your phone can no longer start tasks on this Mac.',
250
+ 'servicio.anadida': 'Added {ruta}. Your phone can start tasks there.',
251
+ 'servicio.no_anadida': '{ruta} cannot be added: it must be an existing folder, and not your whole home folder.',
252
+ 'servicio.quitada': 'Removed {ruta}.',
253
+ 'servicio.no_quitada': '{ruta} was not in the list.',
254
+ 'servicio.estado': 'Service: {estado}. Folders: {lista}.',
255
+ 'servicio.en_marcha': 'running',
256
+ 'servicio.parado': 'off',
257
+ 'servicio.ninguna': 'none',
258
+ 'servicio.sin_carpetas': 'Add a folder first: go to a project folder and run parx service add.',
259
+ 'servicio.solo_mac': 'The service is only for macOS for now.',
260
+ 'servicio.registro': 'Log: {ruta}',
261
+ 'servicio.uso': 'parx service on | off | add [folder] | remove <folder> | status',
240
262
  'error.sin_saldo': 'You are out of Actions. Top up or upgrade at getparallel.net/settings/billing',
241
263
  'error.sin_acceso': 'Your account does not have parx yet: it is in testing.',
242
264
  'error.sesion': 'Your session expired. Run: parx login',
@@ -325,6 +347,8 @@ export const COMANDOS = [
325
347
  ['folder', 'Let parx work in another folder'],
326
348
  ['lanes', 'Run 2 or 3 tasks at once'],
327
349
  ['remote', 'Control this parx from your phone'],
350
+ ['preview', 'See the web it is building on your phone'],
351
+ ['publish', 'Put the web online at name.getparallel.net'],
328
352
  ['mission', 'Keep it running on Parallel, phone alert when done'],
329
353
  ['missions', 'Pick a mission and control it from here'],
330
354
  ['inbox', 'Answer what your missions are waiting for'],
@@ -352,6 +376,8 @@ export const ALIAS_COMANDO = {
352
376
  folder: 'folder', 'add-folder': 'folder', carpeta: 'folder', dir: 'folder',
353
377
  lanes: 'lanes', carriles: 'lanes',
354
378
  remote: 'remote', 'remote-control': 'remote', rc: 'remote', remoto: 'remote', phone: 'remote',
379
+ preview: 'preview', vista: 'preview', previa: 'preview',
380
+ publish: 'publish', publica: 'publish', publicar: 'publish', deploy: 'publish', online: 'publish',
355
381
  mission: 'mission', mision: 'mission', misión: 'mission',
356
382
  missions: 'missions', misiones: 'missions',
357
383
  inbox: 'inbox', bandeja: 'inbox', approvals: 'inbox', waiting: 'inbox',
@@ -410,6 +436,9 @@ export const USO = [
410
436
  ' /remote link and QR to control this parx from your phone or a browser',
411
437
  ' /remote always turn it on every time parx opens on this computer',
412
438
  ' /remote off turn it off here',
439
+ ' parx service add let your phone start tasks in this folder (macOS)',
440
+ ' parx service on keep your Mac ready at login, even with parx closed',
441
+ ' parx service off | status stop it, or see how it is',
413
442
  '',
414
443
  'Updates:',
415
444
  ' parx checks npm every few hours, without slowing anything down, and installs new versions',
package/dist/ui.js CHANGED
@@ -308,6 +308,16 @@ export class Pantalla {
308
308
  }
309
309
  this.pausarLinea();
310
310
  if (!this.tty || !this.rl) {
311
+ if (this.espejo) {
312
+ const op = opcionesDePregunta(tipo);
313
+ const remota = this.espejo.preguntaRemota(sinColor(texto), tipo, op.opciones.map((etiqueta, i) => ({ etiqueta, valor: op.valores[i] })));
314
+ if (remota.deFuera) {
315
+ const r = await remota.deFuera;
316
+ remota.contestada(r, 'movil');
317
+ return r;
318
+ }
319
+ remota.contestada('no', 'ordenador');
320
+ }
311
321
  this.aviso(`${texto} → ${t('aviso.rechazado')}`);
312
322
  return 'no';
313
323
  }
package/dist/vista.js ADDED
@@ -0,0 +1,133 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
3
+ import { join, relative, sep } from 'node:path';
4
+ import { subirVistaRemota } from "./api.js";
5
+ export const EXTENSIONES = ['html', 'htm', 'css', 'js', 'mjs', 'json', 'webmanifest', 'txt', 'xml', 'svg', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'ico', 'woff', 'woff2', 'ttf', 'otf', 'mp4', 'webm', 'mp3', 'wav', 'map'];
6
+ const EXT = new Set(EXTENSIONES);
7
+ const SALTAR = new Set(['node_modules', '.git', '.next', '.turbo', '.cache', 'coverage', '__pycache__', '.venv', 'venv', 'target']);
8
+ export const MAX_ARCHIVOS = 300;
9
+ export const MAX_ARCHIVO = 5 * 1024 * 1024;
10
+ export const MAX_TOTAL = 25 * 1024 * 1024;
11
+ const LOTE = 2_300_000;
12
+ export function raizWeb(raiz) {
13
+ for (const c of ['dist', 'build', 'out', '.', 'public', 'docs', 'site']) {
14
+ const abs = c === '.' ? raiz : join(raiz, c);
15
+ try {
16
+ if (existsSync(join(abs, 'index.html')) && statSync(join(abs, 'index.html')).isFile())
17
+ return { abs, nombre: c };
18
+ }
19
+ catch {
20
+ }
21
+ }
22
+ return null;
23
+ }
24
+ export function recogerWeb(abs) {
25
+ const out = [];
26
+ let total = 0;
27
+ const recorrer = (dir, profundidad) => {
28
+ if (profundidad > 8 || out.length >= MAX_ARCHIVOS)
29
+ return;
30
+ let entradas;
31
+ try {
32
+ entradas = readdirSync(dir).sort();
33
+ }
34
+ catch {
35
+ return;
36
+ }
37
+ for (const n of entradas) {
38
+ if (out.length >= MAX_ARCHIVOS)
39
+ return;
40
+ if (n.startsWith('.') || SALTAR.has(n))
41
+ continue;
42
+ const ruta = join(dir, n);
43
+ let st;
44
+ try {
45
+ st = statSync(ruta);
46
+ }
47
+ catch {
48
+ continue;
49
+ }
50
+ if (st.isDirectory()) {
51
+ recorrer(ruta, profundidad + 1);
52
+ continue;
53
+ }
54
+ const ext = (/\.([a-z0-9]+)$/i.exec(n)?.[1] ?? '').toLowerCase();
55
+ if (!st.isFile() || !EXT.has(ext) || st.size > MAX_ARCHIVO || total + st.size > MAX_TOTAL)
56
+ continue;
57
+ const rel = relative(abs, ruta).split(sep).join('/');
58
+ if (!/^[A-Za-z0-9._\-/@+ ()]+$/.test(rel) || rel.length > 200)
59
+ continue;
60
+ const datos = readFileSync(ruta);
61
+ total += st.size;
62
+ out.push({ ruta: rel, abs: ruta, hash: createHash('sha256').update(datos).digest('hex'), tam: st.size });
63
+ }
64
+ };
65
+ recorrer(abs, 0);
66
+ return out;
67
+ }
68
+ export function enTandas(lista, max = LOTE) {
69
+ const tandas = [];
70
+ let actual = [];
71
+ let peso = 0;
72
+ for (const a of lista) {
73
+ if (actual.length && peso + a.tam > max) {
74
+ tandas.push(actual);
75
+ actual = [];
76
+ peso = 0;
77
+ }
78
+ actual.push(a);
79
+ peso += a.tam;
80
+ }
81
+ tandas.push(actual);
82
+ return tandas;
83
+ }
84
+ export class VistaPrevia {
85
+ token = randomBytes(32).toString('base64url');
86
+ subirLote;
87
+ constructor(subirLote = subirVistaRemota) {
88
+ this.subirLote = subirLote;
89
+ }
90
+ subidos = new Map();
91
+ url = null;
92
+ version = 0;
93
+ enMarcha = null;
94
+ async actualizar(o) {
95
+ while (this.enMarcha)
96
+ await this.enMarcha.catch(() => undefined);
97
+ const p = this.subir(o);
98
+ this.enMarcha = p;
99
+ try {
100
+ return await p;
101
+ }
102
+ finally {
103
+ this.enMarcha = null;
104
+ }
105
+ }
106
+ async subir(o) {
107
+ const web = raizWeb(o.raiz);
108
+ if (!web)
109
+ return null;
110
+ const lista = recogerWeb(web.abs);
111
+ if (!lista.some((a) => a.ruta === 'index.html'))
112
+ return null;
113
+ const cambiados = lista.filter((a) => this.subidos.get(a.ruta) !== a.hash);
114
+ const iguales = cambiados.length === 0 && lista.length === this.subidos.size;
115
+ if (iguales && this.url)
116
+ return { url: this.url, version: this.version, cambiados: 0, carpeta: web.nombre };
117
+ const tandas = enTandas(cambiados);
118
+ for (let i = 0; i < tandas.length; i++) {
119
+ const ultima = i === tandas.length - 1;
120
+ const archivos = tandas[i].map((a) => ({ ruta: a.ruta, datos: readFileSync(a.abs).toString('base64') }));
121
+ const r = await this.subirLote(o.servidor, o.token, o.remoto, {
122
+ archivos,
123
+ ...(ultima ? { raiz: web.nombre, token: this.token, lista: lista.map(({ ruta, hash, tam }) => ({ ruta, hash, tam })) } : {}),
124
+ });
125
+ if (ultima) {
126
+ this.url = r.url;
127
+ this.version = r.version;
128
+ }
129
+ }
130
+ this.subidos = new Map(lista.map((a) => [a.ruta, a.hash]));
131
+ return { url: this.url, version: this.version, cambiados: cambiados.length, carpeta: web.nombre };
132
+ }
133
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getparallel/parx",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "parx: the coding agent of Parallel in your terminal. Reads and edits your project, runs commands and tests, and does coding tasks end to end. Mac, Windows and Linux.",
5
5
  "keywords": [
6
6
  "parx",