@getparallel/parx 0.0.0-stage → 0.4.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 +28 -0
- package/LICENSE +12 -0
- package/README.md +138 -2
- package/dist/actualizar.js +112 -0
- package/dist/agente.js +231 -0
- package/dist/api.js +95 -0
- package/dist/carriles.js +44 -0
- package/dist/cli.js +700 -0
- package/dist/config.js +112 -0
- package/dist/consejos.js +54 -0
- package/dist/cuenta.js +261 -0
- package/dist/diff.js +80 -0
- package/dist/herramientas.js +498 -0
- package/dist/imagenes.js +94 -0
- package/dist/informe.js +65 -0
- package/dist/marca.js +200 -0
- package/dist/mision.js +16 -0
- package/dist/modelos.js +28 -0
- package/dist/notificar.js +112 -0
- package/dist/qr.js +272 -0
- package/dist/registro.js +24 -0
- package/dist/seguridad.js +164 -0
- package/dist/shell.js +108 -0
- package/dist/texto.js +165 -0
- package/dist/textos.js +273 -0
- package/dist/tui.js +1198 -0
- package/dist/ui.js +287 -0
- package/package.json +60 -4
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { homedir, tmpdir } from 'node:os';
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
const NOMBRES_DE_CREDENCIAL = [
|
|
5
|
+
/^\.env(\..+)?$/i,
|
|
6
|
+
/^\.envrc$/i,
|
|
7
|
+
/^id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/i,
|
|
8
|
+
/\.(pem|key|p12|pfx|jks|keystore|kdbx|ppk|gpg|asc|der|crt|cer|p8|mobileprovision)$/i,
|
|
9
|
+
/^\.(npmrc|pypirc|netrc|git-credentials|htpasswd|pgpass|my\.cnf|s3cfg|boto|dockercfg|supabase-token|velora-secrets)$/i,
|
|
10
|
+
/^_netrc$/i,
|
|
11
|
+
/^credentials(\.json|\.yml|\.yaml|\.toml)?$/i,
|
|
12
|
+
/^credenciales\.json$/i,
|
|
13
|
+
/^(service[-_]?account|client[-_]?secret)[^/]*\.json$/i,
|
|
14
|
+
/^secrets?\.(json|ya?ml|toml|env)$/i,
|
|
15
|
+
/^terraform\.tfstate(\.backup)?$/i,
|
|
16
|
+
/^\.?vault[-_]?token$/i,
|
|
17
|
+
];
|
|
18
|
+
const PLANTILLAS = /^\.env\.(example|sample|template|dist|defaults)$/i;
|
|
19
|
+
const CARPETAS_DE_CREDENCIAL = ['.ssh', '.aws', '.gnupg', '.azure', '.kube', '.password-store', '.docker', 'Keychains', '1Password', '.vercel', '.supabase'];
|
|
20
|
+
const RUTAS_DE_CREDENCIAL = [join('.config', 'gcloud'), join('.config', 'gh'), join('.config', 'parallel-code'), join('AppData', 'Roaming', 'parallel-code'), join('.config', 'parx'), join('AppData', 'Roaming', 'parx')];
|
|
21
|
+
export function esCredencial(ruta) {
|
|
22
|
+
const nombre = basename(ruta);
|
|
23
|
+
if (PLANTILLAS.test(nombre))
|
|
24
|
+
return false;
|
|
25
|
+
if (NOMBRES_DE_CREDENCIAL.some((r) => r.test(nombre)))
|
|
26
|
+
return true;
|
|
27
|
+
const partes = ruta.split(/[\\/]+/);
|
|
28
|
+
if (partes.some((p) => CARPETAS_DE_CREDENCIAL.includes(p)))
|
|
29
|
+
return true;
|
|
30
|
+
const normal = ruta.replace(/[\\/]+/g, sep);
|
|
31
|
+
return RUTAS_DE_CREDENCIAL.some((r) => normal.includes(`${sep}${r}${sep}`) || normal.endsWith(`${sep}${r}`));
|
|
32
|
+
}
|
|
33
|
+
export function rutaReal(abs) {
|
|
34
|
+
let actual = abs;
|
|
35
|
+
const resto = [];
|
|
36
|
+
while (!existsSync(actual)) {
|
|
37
|
+
const padre = dirname(actual);
|
|
38
|
+
if (padre === actual)
|
|
39
|
+
break;
|
|
40
|
+
resto.unshift(basename(actual));
|
|
41
|
+
actual = padre;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
return join(realpathSync.native(actual), ...resto);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return abs;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function estaDentro(abs, dir) {
|
|
51
|
+
const rel = relative(dir, abs);
|
|
52
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
53
|
+
}
|
|
54
|
+
export function resolverRuta(raiz, pedida) {
|
|
55
|
+
const raizReal = rutaReal(resolve(raiz));
|
|
56
|
+
const expandida = pedida === '~' || pedida.startsWith('~/') || pedida.startsWith('~\\') ? join(homedir(), pedida.slice(1)) : pedida;
|
|
57
|
+
const abs = rutaReal(isAbsolute(expandida) ? resolve(expandida) : resolve(raizReal, expandida));
|
|
58
|
+
const rel = relative(raizReal, abs);
|
|
59
|
+
const dentro = rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
60
|
+
return { absoluta: abs, mostrar: dentro ? rel || '.' : abs, dentro, credencial: esCredencial(abs) || esCredencial(pedida) };
|
|
61
|
+
}
|
|
62
|
+
const BLOQUEADOS = [
|
|
63
|
+
[/(^|[\s'"=/\\(<])\.env(\.[\w.-]+)?(?=$|[\s'";|&)>])/i, 'reads a .env file'],
|
|
64
|
+
[/(^|[\s'"/\\])\.(ssh|aws|gnupg|azure|kube|docker|password-store|npmrc|pypirc|netrc|git-credentials|supabase-token|velora-secrets|vercel)([\s'"/\\;|&)]|$)/i, 'touches a credentials location'],
|
|
65
|
+
[/\bid_(rsa|dsa|ecdsa|ed25519)\b/i, 'touches an SSH key'],
|
|
66
|
+
[/\b(security\s+(find|dump|export)-(generic|internet)?-?(password|keychain)|security\s+dump-keychain)\b/i, 'reads the macOS keychain'],
|
|
67
|
+
[/\b(cmdkey|vaultcmd)\b|Get-StoredCredential|CredentialManager/i, 'reads the Windows credential store'],
|
|
68
|
+
[/\bgit\s+credential\b|\bgh\s+auth\s+token\b|\bnpm\s+token\b|\bvercel\s+(env\s+pull|tokens?)\b|\bsupabase\s+.*access-token\b/i, 'asks a tool for its token'],
|
|
69
|
+
[/credenciales\.json|(parallel-code|parx)[\\/]+credenciales|par_key_|PARALLEL_API_KEY|PARX_API_KEY/i, 'touches parx credentials'],
|
|
70
|
+
[/\.pem\b|\.p12\b|\.pfx\b|\.kdbx\b/i, 'touches a key file'],
|
|
71
|
+
];
|
|
72
|
+
const CON_RIESGO = [
|
|
73
|
+
[/(^|[;&|]\s*|\s)(sudo|doas|su)\s/, 'runs as administrator'],
|
|
74
|
+
[/\brm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s+)+(\/|~|\$HOME|\.\.|\*\s*$)/, 'deletes recursively outside or at the root'],
|
|
75
|
+
[/\brm\s+-[a-zA-Z]*r[a-zA-Z]*f|\brm\s+-[a-zA-Z]*f[a-zA-Z]*r/, 'deletes recursively'],
|
|
76
|
+
[/Remove-Item\b[^\n]*-Recurse|\b(rd|rmdir)\s+\/s\b|\bdel\s+\/[sq]/i, 'deletes recursively'],
|
|
77
|
+
[/\bgit\s+(push|reset\s+--hard|clean\s+-[a-z]*f|checkout\s+--\s|rebase|filter-branch|branch\s+-D)/, 'rewrites or publishes git history'],
|
|
78
|
+
[/\b(npm|pnpm|yarn|bun)\s+(publish|unpublish|deprecate|owner|login|adduser)\b/, 'publishes or changes a registry account'],
|
|
79
|
+
[/\b(npm|pnpm|yarn|bun)\s+(i|install|add)\b[^\n]*\s(-g|--global)\b|\bbrew\s+(install|uninstall|upgrade)|\bpip3?\s+install\b(?![^\n]*(-r\s|--user|\.venv|venv))|\b(apt|apt-get|dnf|yum|pacman|choco|winget|scoop)\s/, 'installs software on the system'],
|
|
80
|
+
[/\b(curl|wget|iwr|Invoke-WebRequest|irm|Invoke-RestMethod)\b[^\n|]*\|\s*(sh|bash|zsh|python3?|node|iex|Invoke-Expression|pwsh|powershell)\b/i, 'pipes a download into an interpreter'],
|
|
81
|
+
[/\b(ssh|scp|sftp|rsync|ftp|telnet|nc|ncat)\s/, 'connects to another machine'],
|
|
82
|
+
[/\b(curl|wget)\b[^\n]*\s(-X\s*(POST|PUT|DELETE|PATCH)|--data|-d\s|-F\s|--upload-file|-T\s)/i, 'sends data over the network'],
|
|
83
|
+
[/\b(chmod|chown)\s+-R\b|\bchmod\s+[0-7]*7[0-7]*7\b/, 'changes permissions recursively'],
|
|
84
|
+
[/\b(mkfs|fdisk|diskutil|dd\s+if=|format\s+[a-z]:|shutdown|reboot|halt|launchctl|systemctl|crontab|reg\s+(add|delete)|Set-ExecutionPolicy|bcdedit|schtasks)\b/i, 'changes the system'],
|
|
85
|
+
[/\b(kill|killall|pkill|taskkill|Stop-Process)\b/i, 'stops processes'],
|
|
86
|
+
[/(^|\s)(>|>>)\s*(\/|~|\$HOME|[A-Za-z]:\\)/, 'writes outside the project'],
|
|
87
|
+
[/(^|[\s;&|])cd\s+(\/|~|\.\.|\$HOME|[A-Za-z]:\\)/, 'leaves the project folder'],
|
|
88
|
+
[/\b(docker|podman)\s+(run|rm|rmi|system\s+prune|volume\s+rm)\b/, 'manages containers'],
|
|
89
|
+
[/\bdeploy\b|\bvercel\b|\bnetlify\b|\bfirebase\s+deploy\b|\bfly\s+deploy\b/i, 'deploys'],
|
|
90
|
+
[/:\(\)\s*\{\s*:\|:&\s*\};:/, 'fork bomb'],
|
|
91
|
+
];
|
|
92
|
+
export function nivelDeComando(comando) {
|
|
93
|
+
const sinPlantillas = comando.replace(/\.env\.(example|sample|template|dist|defaults)\b/gi, '');
|
|
94
|
+
for (const [r, motivo] of BLOQUEADOS)
|
|
95
|
+
if (r.test(sinPlantillas))
|
|
96
|
+
return { nivel: 'credencial', motivo };
|
|
97
|
+
for (const [r, motivo] of CON_RIESGO)
|
|
98
|
+
if (r.test(comando))
|
|
99
|
+
return { nivel: 'riesgo', motivo };
|
|
100
|
+
return { nivel: 'normal', motivo: null };
|
|
101
|
+
}
|
|
102
|
+
export function familiaDeComando(comando) {
|
|
103
|
+
const palabras = comando.trim().split(/\s+/).filter((p) => !/^[A-Z_]+=/.test(p));
|
|
104
|
+
return palabras.slice(0, 2).join(' ');
|
|
105
|
+
}
|
|
106
|
+
const SECRETO = /(KEY|TOKEN|SECRET|PASSWORD|PASSWD|PASSPHRASE|CREDENTIAL|PRIVATE|COOKIE|SESSION|AUTH|DSN|_PAT$|^PAT_)/i;
|
|
107
|
+
const SE_QUEDAN = new Set(['TERM_SESSION_ID', 'XPC_SERVICE_NAME', 'SESSIONNAME', 'XDG_SESSION_TYPE', 'XDG_SESSION_ID', 'XDG_SESSION_CLASS', 'XDG_SESSION_DESKTOP', 'DESKTOP_SESSION']);
|
|
108
|
+
const SIEMPRE_FUERA = new Set(['SSH_AUTH_SOCK', 'GPG_AGENT_INFO', 'DATABASE_URL', 'POSTGRES_URL', 'REDIS_URL', 'MONGODB_URI', 'NPM_CONFIG_USERCONFIG']);
|
|
109
|
+
export function entornoLimpio(env = process.env) {
|
|
110
|
+
const limpio = {};
|
|
111
|
+
for (const [k, v] of Object.entries(env)) {
|
|
112
|
+
if (v === undefined)
|
|
113
|
+
continue;
|
|
114
|
+
if (SIEMPRE_FUERA.has(k.toUpperCase()))
|
|
115
|
+
continue;
|
|
116
|
+
if (SECRETO.test(k) && !SE_QUEDAN.has(k.toUpperCase()))
|
|
117
|
+
continue;
|
|
118
|
+
if (/^[a-z]+:\/\/[^\s:@/]+:[^\s@/]+@/i.test(v))
|
|
119
|
+
continue;
|
|
120
|
+
limpio[k] = v;
|
|
121
|
+
}
|
|
122
|
+
limpio.CI = '1';
|
|
123
|
+
limpio.PARALLEL_CODE = '1';
|
|
124
|
+
limpio.PARX = '1';
|
|
125
|
+
limpio.GIT_TERMINAL_PROMPT = '0';
|
|
126
|
+
return limpio;
|
|
127
|
+
}
|
|
128
|
+
function escaparSb(ruta) {
|
|
129
|
+
return ruta.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
130
|
+
}
|
|
131
|
+
export function perfilDeJaulaMac(raiz, home = homedir(), otras = []) {
|
|
132
|
+
const raizReal = rutaReal(resolve(raiz));
|
|
133
|
+
const tmp = rutaReal(tmpdir());
|
|
134
|
+
const escribibles = [
|
|
135
|
+
raizReal,
|
|
136
|
+
...otras.map((o) => rutaReal(resolve(o))),
|
|
137
|
+
tmp,
|
|
138
|
+
'/private/tmp',
|
|
139
|
+
'/private/var/folders',
|
|
140
|
+
join(home, '.npm'),
|
|
141
|
+
join(home, '.cache'),
|
|
142
|
+
join(home, 'Library', 'Caches'),
|
|
143
|
+
join(home, '.yarn'),
|
|
144
|
+
join(home, '.pnpm-store'),
|
|
145
|
+
join(home, '.bun', 'install', 'cache'),
|
|
146
|
+
join(home, '.cargo', 'registry'),
|
|
147
|
+
join(home, 'go', 'pkg', 'mod'),
|
|
148
|
+
join(home, '.gradle'),
|
|
149
|
+
join(home, '.m2'),
|
|
150
|
+
];
|
|
151
|
+
const prohibidas = [...CARPETAS_DE_CREDENCIAL.map((d) => join(home, d)), join(home, '.config', 'gcloud'), join(home, '.config', 'gh'), join(home, '.config', 'parallel-code'), join(home, '.config', 'parx'), join(home, 'Library', 'Keychains')];
|
|
152
|
+
const lineas = [
|
|
153
|
+
'(version 1)',
|
|
154
|
+
'(allow default)',
|
|
155
|
+
'(deny file-write*)',
|
|
156
|
+
`(allow file-write* ${escribibles.map((r) => `(subpath "${escaparSb(r)}")`).join(' ')} (literal "/dev/null") (literal "/dev/zero") (literal "/dev/dtracehelper") (regex #"^/dev/tty") (regex #"^/dev/fd/"))`,
|
|
157
|
+
`(deny file-read* ${prohibidas.map((r) => `(subpath "${escaparSb(r)}")`).join(' ')} (regex #"/\\.env(\\.[^/]*)?$") (regex #"/\\.(npmrc|netrc|git-credentials|supabase-token|velora-secrets)$"))`,
|
|
158
|
+
`(allow file-read* (regex #"/\\.env\\.(example|sample|template)$"))`,
|
|
159
|
+
];
|
|
160
|
+
return lineas.join('\n');
|
|
161
|
+
}
|
|
162
|
+
export function hayJaulaMac(plataforma = process.platform) {
|
|
163
|
+
return plataforma === 'darwin' && existsSync('/usr/bin/sandbox-exec');
|
|
164
|
+
}
|
package/dist/shell.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { entornoLimpio, hayJaulaMac, perfilDeJaulaMac } from "./seguridad.js";
|
|
6
|
+
let cacheShell = null;
|
|
7
|
+
export function __reiniciarShell() {
|
|
8
|
+
cacheShell = null;
|
|
9
|
+
}
|
|
10
|
+
function existeEnPath(programa) {
|
|
11
|
+
const r = spawnSync(process.platform === 'win32' ? 'where' : 'which', [programa], { stdio: 'ignore' });
|
|
12
|
+
return r.status === 0;
|
|
13
|
+
}
|
|
14
|
+
export function shellDelSistema(env = process.env, plataforma = process.platform) {
|
|
15
|
+
if (cacheShell)
|
|
16
|
+
return cacheShell;
|
|
17
|
+
const pedido = (env.PARALLEL_SHELL ?? '').toLowerCase();
|
|
18
|
+
if (plataforma === 'win32') {
|
|
19
|
+
if (pedido === 'cmd') {
|
|
20
|
+
cacheShell = { nombre: 'cmd', programa: env.ComSpec ?? 'cmd.exe', argumentos: (c) => ['/d', '/s', '/c', c] };
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
const ps = pedido === 'powershell' ? 'powershell.exe' : existeEnPath('pwsh') ? 'pwsh' : 'powershell.exe';
|
|
24
|
+
cacheShell = { nombre: ps === 'pwsh' ? 'PowerShell 7' : 'Windows PowerShell', programa: ps, argumentos: (c) => ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', c] };
|
|
25
|
+
}
|
|
26
|
+
return cacheShell;
|
|
27
|
+
}
|
|
28
|
+
const propio = env.SHELL ?? '';
|
|
29
|
+
const valido = /\/(bash|zsh|sh|fish|dash|ksh)$/.test(propio) && existsSync(propio);
|
|
30
|
+
const programa = pedido && existsSync(pedido) ? pedido : valido ? propio : '/bin/sh';
|
|
31
|
+
cacheShell = { nombre: programa.split('/').pop() ?? 'sh', programa, argumentos: (c) => ['-c', c] };
|
|
32
|
+
return cacheShell;
|
|
33
|
+
}
|
|
34
|
+
export function recortar(texto, max = 24_000) {
|
|
35
|
+
if (texto.length <= max)
|
|
36
|
+
return texto;
|
|
37
|
+
const mitad = Math.floor(max / 2);
|
|
38
|
+
return `${texto.slice(0, mitad)}\n\n[… ${texto.length - max} characters omitted …]\n\n${texto.slice(-mitad)}`;
|
|
39
|
+
}
|
|
40
|
+
export function ejecutarComando(comando, opciones) {
|
|
41
|
+
const shell = shellDelSistema();
|
|
42
|
+
const enJaula = opciones.jaula && hayJaulaMac();
|
|
43
|
+
const programa = enJaula ? '/usr/bin/sandbox-exec' : shell.programa;
|
|
44
|
+
const args = enJaula ? ['-p', perfilDeJaulaMac(opciones.cwd, homedir(), opciones.carpetas ?? []), shell.programa, ...shell.argumentos(comando)] : shell.argumentos(comando);
|
|
45
|
+
const inicio = Date.now();
|
|
46
|
+
return new Promise((resolver) => {
|
|
47
|
+
const hijo = spawn(programa, args, {
|
|
48
|
+
cwd: opciones.cwd,
|
|
49
|
+
env: entornoLimpio(),
|
|
50
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
51
|
+
detached: process.platform !== 'win32',
|
|
52
|
+
windowsHide: true,
|
|
53
|
+
});
|
|
54
|
+
let salida = '';
|
|
55
|
+
let plazoAgotado = false;
|
|
56
|
+
let interrumpido = false;
|
|
57
|
+
const añadir = (b) => {
|
|
58
|
+
const trozo = b.toString('utf8');
|
|
59
|
+
if (salida.length < 2_000_000)
|
|
60
|
+
salida += trozo;
|
|
61
|
+
opciones.alSalir?.(trozo);
|
|
62
|
+
};
|
|
63
|
+
hijo.stdout?.on('data', añadir);
|
|
64
|
+
hijo.stderr?.on('data', añadir);
|
|
65
|
+
const matar = () => {
|
|
66
|
+
if (hijo.pid === undefined || hijo.exitCode !== null)
|
|
67
|
+
return;
|
|
68
|
+
try {
|
|
69
|
+
if (process.platform === 'win32')
|
|
70
|
+
spawnSync('taskkill', ['/pid', String(hijo.pid), '/T', '/F'], { stdio: 'ignore' });
|
|
71
|
+
else
|
|
72
|
+
process.kill(-hijo.pid, 'SIGKILL');
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
try {
|
|
76
|
+
hijo.kill('SIGKILL');
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
const reloj = setTimeout(() => {
|
|
83
|
+
plazoAgotado = true;
|
|
84
|
+
matar();
|
|
85
|
+
}, opciones.plazoMs);
|
|
86
|
+
const alAbortar = () => {
|
|
87
|
+
interrumpido = true;
|
|
88
|
+
matar();
|
|
89
|
+
};
|
|
90
|
+
opciones.señal?.addEventListener('abort', alAbortar, { once: true });
|
|
91
|
+
hijo.on('error', (e) => {
|
|
92
|
+
salida += `\n${String(e.message)}`;
|
|
93
|
+
});
|
|
94
|
+
hijo.on('close', (codigo) => {
|
|
95
|
+
clearTimeout(reloj);
|
|
96
|
+
opciones.señal?.removeEventListener('abort', alAbortar);
|
|
97
|
+
resolver({ codigo, salida, plazoAgotado, interrumpido, ms: Date.now() - inicio, enJaula });
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
export function descripcionDelSistema() {
|
|
102
|
+
const s = shellDelSistema();
|
|
103
|
+
const so = process.platform === 'darwin' ? 'macOS' : process.platform === 'win32' ? 'Windows' : process.platform === 'linux' ? 'Linux' : process.platform;
|
|
104
|
+
return `${so} (${process.arch}), shell: ${s.nombre}`;
|
|
105
|
+
}
|
|
106
|
+
export function esRepoGit(dir) {
|
|
107
|
+
return existsSync(join(dir, '.git'));
|
|
108
|
+
}
|
package/dist/texto.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
const ESCAPE = /\x1b\[[0-9;?]*[ -/]*[@-~]/y;
|
|
2
|
+
export function sinColor(t) {
|
|
3
|
+
return t.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '');
|
|
4
|
+
}
|
|
5
|
+
let PUNTOS = '…';
|
|
6
|
+
export function puntos() {
|
|
7
|
+
return PUNTOS;
|
|
8
|
+
}
|
|
9
|
+
export function usarPuntosAscii(si) {
|
|
10
|
+
PUNTOS = si ? '~' : '…';
|
|
11
|
+
}
|
|
12
|
+
const ANCHO_DOBLE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-가-힣豈-︰-﹏-⦆¢-₩]/;
|
|
13
|
+
export function anchoDeCaracter(ch) {
|
|
14
|
+
const cp = ch.codePointAt(0);
|
|
15
|
+
if (cp < 32 || cp === 0x7f)
|
|
16
|
+
return 0;
|
|
17
|
+
if ((cp >= 0x300 && cp <= 0x36f) || (cp >= 0xfe00 && cp <= 0xfe0f) || cp === 0x200d || cp === 0x200b)
|
|
18
|
+
return 0;
|
|
19
|
+
if (cp > 0xffff && cp < 0xe0000)
|
|
20
|
+
return 2;
|
|
21
|
+
return ANCHO_DOBLE.test(ch) ? 2 : 1;
|
|
22
|
+
}
|
|
23
|
+
export function anchoDe(texto) {
|
|
24
|
+
let n = 0;
|
|
25
|
+
for (const ch of sinColor(texto))
|
|
26
|
+
n += anchoDeCaracter(ch);
|
|
27
|
+
return n;
|
|
28
|
+
}
|
|
29
|
+
function* trozos(texto) {
|
|
30
|
+
let i = 0;
|
|
31
|
+
while (i < texto.length) {
|
|
32
|
+
if (texto[i] === '\x1b') {
|
|
33
|
+
ESCAPE.lastIndex = i;
|
|
34
|
+
const m = ESCAPE.exec(texto);
|
|
35
|
+
if (m) {
|
|
36
|
+
yield { esc: true, v: m[0], w: 0 };
|
|
37
|
+
i += m[0].length;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const cp = texto.codePointAt(i);
|
|
42
|
+
const ch = String.fromCodePoint(cp);
|
|
43
|
+
i += ch.length;
|
|
44
|
+
yield { esc: false, v: ch, w: anchoDeCaracter(ch) };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function cortarAlAncho(texto, max) {
|
|
48
|
+
let n = 0;
|
|
49
|
+
let out = '';
|
|
50
|
+
for (const ch of texto) {
|
|
51
|
+
const w = anchoDeCaracter(ch);
|
|
52
|
+
if (n + w > max)
|
|
53
|
+
break;
|
|
54
|
+
n += w;
|
|
55
|
+
out += ch;
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
export function recortar(texto, max, p = PUNTOS) {
|
|
60
|
+
if (max <= 0)
|
|
61
|
+
return '';
|
|
62
|
+
if (anchoDe(texto) <= max)
|
|
63
|
+
return texto;
|
|
64
|
+
const limite = max - anchoDe(p);
|
|
65
|
+
let n = 0;
|
|
66
|
+
let out = '';
|
|
67
|
+
let conColor = false;
|
|
68
|
+
for (const t of trozos(texto)) {
|
|
69
|
+
if (t.esc) {
|
|
70
|
+
out += t.v;
|
|
71
|
+
conColor = true;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (n + t.w > limite)
|
|
75
|
+
break;
|
|
76
|
+
n += t.w;
|
|
77
|
+
out += t.v;
|
|
78
|
+
}
|
|
79
|
+
return `${out}${conColor ? '\x1b[0m' : ''}${limite >= 0 ? p : ''}`;
|
|
80
|
+
}
|
|
81
|
+
export function recortarMedio(texto, max) {
|
|
82
|
+
if (anchoDe(texto) <= max)
|
|
83
|
+
return texto;
|
|
84
|
+
if (max <= 1)
|
|
85
|
+
return cortarAlAncho(PUNTOS, max);
|
|
86
|
+
const chars = Array.from(texto);
|
|
87
|
+
const atras = Math.ceil((max - 1) / 2);
|
|
88
|
+
const delante = max - 1 - atras;
|
|
89
|
+
let fin = '';
|
|
90
|
+
let w = 0;
|
|
91
|
+
for (let i = chars.length - 1; i >= 0; i--) {
|
|
92
|
+
const cw = anchoDeCaracter(chars[i]);
|
|
93
|
+
if (w + cw > atras)
|
|
94
|
+
break;
|
|
95
|
+
w += cw;
|
|
96
|
+
fin = chars[i] + fin;
|
|
97
|
+
}
|
|
98
|
+
return `${cortarAlAncho(texto, delante)}${PUNTOS}${fin}`;
|
|
99
|
+
}
|
|
100
|
+
export function rellenar(texto, ancho) {
|
|
101
|
+
const r = recortar(texto, ancho);
|
|
102
|
+
return r + ' '.repeat(Math.max(0, ancho - anchoDe(r)));
|
|
103
|
+
}
|
|
104
|
+
function colorAbierto(activos, esc) {
|
|
105
|
+
const m = /^\x1b\[([0-9;]*)m$/.exec(esc);
|
|
106
|
+
if (!m)
|
|
107
|
+
return;
|
|
108
|
+
const p = m[1] ?? '';
|
|
109
|
+
if (p === '' || p === '0')
|
|
110
|
+
activos.length = 0;
|
|
111
|
+
else if (p === '39')
|
|
112
|
+
quitar(activos, (x) => /^\x1b\[(3[0-8]|9[0-7])[;m]/.test(x));
|
|
113
|
+
else if (p === '22')
|
|
114
|
+
quitar(activos, (x) => /^\x1b\[[12]m/.test(x));
|
|
115
|
+
else
|
|
116
|
+
activos.push(esc);
|
|
117
|
+
}
|
|
118
|
+
function quitar(a, f) {
|
|
119
|
+
for (let i = a.length - 1; i >= 0; i--)
|
|
120
|
+
if (f(a[i]))
|
|
121
|
+
a.splice(i, 1);
|
|
122
|
+
}
|
|
123
|
+
export function envolver(texto, ancho, sangria = '') {
|
|
124
|
+
const out = [];
|
|
125
|
+
const anchoSangria = anchoDe(sangria);
|
|
126
|
+
for (const parrafo of texto.split('\n')) {
|
|
127
|
+
let linea = '';
|
|
128
|
+
let w = 0;
|
|
129
|
+
let primera = true;
|
|
130
|
+
const activos = [];
|
|
131
|
+
const max = () => Math.max(1, primera ? ancho : ancho - anchoSangria);
|
|
132
|
+
const cerrar = () => {
|
|
133
|
+
out.push(activos.length ? `${linea}\x1b[0m` : linea);
|
|
134
|
+
linea = `${sangria}${activos.join('')}`;
|
|
135
|
+
w = 0;
|
|
136
|
+
primera = false;
|
|
137
|
+
};
|
|
138
|
+
const palabras = parrafo.match(/\s*\S+|\s+$/g) ?? [''];
|
|
139
|
+
for (const p of palabras) {
|
|
140
|
+
const anchoP = anchoDe(p);
|
|
141
|
+
if (w > 0 && w + anchoP > max()) {
|
|
142
|
+
cerrar();
|
|
143
|
+
const sinEspacio = p.replace(/^\s+/, '');
|
|
144
|
+
añadir(sinEspacio);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
añadir(p);
|
|
148
|
+
}
|
|
149
|
+
out.push(activos.length && linea ? `${linea}\x1b[0m` : linea);
|
|
150
|
+
function añadir(p) {
|
|
151
|
+
for (const t of trozos(p)) {
|
|
152
|
+
if (t.esc) {
|
|
153
|
+
linea += t.v;
|
|
154
|
+
colorAbierto(activos, t.v);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (w + t.w > max() && w > 0)
|
|
158
|
+
cerrar();
|
|
159
|
+
linea += t.v;
|
|
160
|
+
w += t.w;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|