shilo-cli 0.2.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 +80 -0
- package/bin/shilo.js +100 -0
- package/package.json +30 -0
- package/src/ShiloClient.js +427 -0
- package/src/args.js +77 -0
- package/src/commands/init.js +110 -0
- package/src/commands/login.js +99 -0
- package/src/commands/logout.js +19 -0
- package/src/commands/push.js +306 -0
- package/src/commands/whoami.js +49 -0
- package/src/config.js +164 -0
- package/src/prompt.js +53 -0
- package/src/remote.js +59 -0
- package/src/textguard.js +131 -0
- package/src/ui.js +36 -0
- package/src/walk.js +213 -0
package/src/args.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parser de argumentos minimalista (sin dependencias).
|
|
5
|
+
*
|
|
6
|
+
* Soporta:
|
|
7
|
+
* --flag -> { flag: true }
|
|
8
|
+
* --key value -> { key: "value" }
|
|
9
|
+
* --key=value -> { key: "value" }
|
|
10
|
+
* -m "msg" -> alias corto (ver `aliases`)
|
|
11
|
+
* positionals -> array de argumentos sueltos
|
|
12
|
+
*
|
|
13
|
+
* Devuelve { _: [positionals...], ...flags }.
|
|
14
|
+
*/
|
|
15
|
+
function parseArgs(argv, { booleans = [], aliases = {} } = {}) {
|
|
16
|
+
const out = { _: [] };
|
|
17
|
+
const boolSet = new Set(booleans);
|
|
18
|
+
|
|
19
|
+
const canonical = (k) => aliases[k] || k;
|
|
20
|
+
|
|
21
|
+
for (let i = 0; i < argv.length; i++) {
|
|
22
|
+
let token = argv[i];
|
|
23
|
+
|
|
24
|
+
if (token === '--') {
|
|
25
|
+
// Todo lo que sigue son posicionales.
|
|
26
|
+
out._.push(...argv.slice(i + 1));
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (token.startsWith('--')) {
|
|
31
|
+
let key = token.slice(2);
|
|
32
|
+
let value;
|
|
33
|
+
const eq = key.indexOf('=');
|
|
34
|
+
if (eq !== -1) {
|
|
35
|
+
value = key.slice(eq + 1);
|
|
36
|
+
key = key.slice(0, eq);
|
|
37
|
+
}
|
|
38
|
+
key = canonical(key);
|
|
39
|
+
if (value !== undefined) {
|
|
40
|
+
out[key] = value;
|
|
41
|
+
} else if (boolSet.has(key)) {
|
|
42
|
+
out[key] = true;
|
|
43
|
+
} else {
|
|
44
|
+
const next = argv[i + 1];
|
|
45
|
+
if (next !== undefined && !next.startsWith('-')) {
|
|
46
|
+
out[key] = next;
|
|
47
|
+
i++;
|
|
48
|
+
} else {
|
|
49
|
+
out[key] = true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (token.startsWith('-') && token.length > 1) {
|
|
56
|
+
const key = canonical(token.slice(1));
|
|
57
|
+
if (boolSet.has(key)) {
|
|
58
|
+
out[key] = true;
|
|
59
|
+
} else {
|
|
60
|
+
const next = argv[i + 1];
|
|
61
|
+
if (next !== undefined && !next.startsWith('-')) {
|
|
62
|
+
out[key] = next;
|
|
63
|
+
i++;
|
|
64
|
+
} else {
|
|
65
|
+
out[key] = true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
out._.push(token);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { parseArgs };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const config = require('../config');
|
|
6
|
+
const remote = require('../remote');
|
|
7
|
+
const prompt = require('../prompt');
|
|
8
|
+
const ui = require('../ui');
|
|
9
|
+
const { ShiloClient } = require('../ShiloClient');
|
|
10
|
+
|
|
11
|
+
const DEFAULT_BASE_URL = 'https://shilo-six.vercel.app';
|
|
12
|
+
|
|
13
|
+
// UUID v4 laxo (acepta cualquier versión). El backend valida de verdad; esto es
|
|
14
|
+
// sólo un aviso temprano ante un typo evidente.
|
|
15
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* shilo init --project <uuid> [carpeta] [--token <pat>] [--push] [--base-url <url>] [--force]
|
|
19
|
+
*
|
|
20
|
+
* Ata una carpeta local a un repositorio de Shilo escribiendo `.shilo.json`.
|
|
21
|
+
* El projectId se pasa directo (por ID). Antes de escribir, verifica que el
|
|
22
|
+
* proyecto sea accesible con la sesión actual (salvo --force).
|
|
23
|
+
*
|
|
24
|
+
* Con --token guarda las credenciales antes de verificar, de modo que el
|
|
25
|
+
* comando de una línea que se copia desde la web funcione sin "login" previo.
|
|
26
|
+
* Con --push, sube la carpeta al terminar.
|
|
27
|
+
*/
|
|
28
|
+
async function init(args) {
|
|
29
|
+
const dir = path.resolve(args._[0] || '.');
|
|
30
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
|
31
|
+
ui.error(`No es una carpeta válida: ${dir}`);
|
|
32
|
+
return 1;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const projectId = typeof args.project === 'string' ? args.project.trim() : '';
|
|
36
|
+
if (!projectId) {
|
|
37
|
+
ui.error('Falta el id del proyecto. Usá: shilo init --project <uuid> [carpeta]');
|
|
38
|
+
return 1;
|
|
39
|
+
}
|
|
40
|
+
if (!UUID_RE.test(projectId)) {
|
|
41
|
+
ui.warn(`"${projectId}" no parece un UUID. Continúo igual (el servidor validará).`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// --token: persistir credenciales antes del chequeo de sesión, para que el
|
|
45
|
+
// comando de una línea copiado desde la web ande sin "shilo login" previo.
|
|
46
|
+
const token = typeof args.token === 'string' ? args.token.trim() : '';
|
|
47
|
+
if (token) {
|
|
48
|
+
const existing = config.load() || {};
|
|
49
|
+
config.save({
|
|
50
|
+
baseURL: args['base-url'] || existing.baseURL || DEFAULT_BASE_URL,
|
|
51
|
+
supabaseUrl: existing.supabaseUrl || null,
|
|
52
|
+
supabaseAnonKey: existing.supabaseAnonKey || null,
|
|
53
|
+
credentials: config.patCredentials(token),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const cfg = config.load();
|
|
58
|
+
if (!cfg || !cfg.credentials) {
|
|
59
|
+
ui.error('No hay sesión activa. Ejecutá "shilo login" primero.');
|
|
60
|
+
return 1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const baseURL = args['base-url'] || (cfg && cfg.baseURL) || DEFAULT_BASE_URL;
|
|
64
|
+
|
|
65
|
+
// Aviso si ya hay un remote configurado.
|
|
66
|
+
const existing = remote.read(dir);
|
|
67
|
+
if (existing && existing.projectId && !args.force) {
|
|
68
|
+
ui.warn(
|
|
69
|
+
`Esta carpeta ya apunta al proyecto ${ui.c.bold(existing.projectId)}` +
|
|
70
|
+
(existing.baseURL ? ` en ${existing.baseURL}` : '') + '.'
|
|
71
|
+
);
|
|
72
|
+
const ok = await prompt.confirm('¿Sobrescribir el remote?', false);
|
|
73
|
+
if (!ok) {
|
|
74
|
+
ui.info('Cancelado.');
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Verificar acceso al proyecto (salvo --force), listando la raíz.
|
|
80
|
+
if (!args.force) {
|
|
81
|
+
const client = new ShiloClient({
|
|
82
|
+
baseURL,
|
|
83
|
+
supabaseUrl: cfg.supabaseUrl || null,
|
|
84
|
+
supabaseAnonKey: cfg.supabaseAnonKey || null,
|
|
85
|
+
});
|
|
86
|
+
ui.info(`Verificando acceso al proyecto en ${ui.c.cyan(baseURL)} ...`);
|
|
87
|
+
try {
|
|
88
|
+
const { credentials } = await client.listFiles(projectId, cfg.credentials, '');
|
|
89
|
+
config.persistCredsIfChanged(cfg, credentials);
|
|
90
|
+
} catch (e) {
|
|
91
|
+
ui.error(`No se pudo acceder al proyecto: ${e.message}`);
|
|
92
|
+
ui.info(ui.c.dim('Revisá el id, tus permisos o la conexión. Podés forzar con --force.'));
|
|
93
|
+
return 1;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const written = remote.write(dir, { projectId, baseURL });
|
|
98
|
+
ui.success(`Carpeta atada al proyecto ${ui.c.bold(projectId)}.`);
|
|
99
|
+
ui.info(ui.c.dim(`Remote guardado en ${written}`));
|
|
100
|
+
|
|
101
|
+
if (args.push) {
|
|
102
|
+
// El usuario ya confirmó al pasar --push: no volvemos a preguntar.
|
|
103
|
+
return require('./push')({ ...args, _: [dir], yes: true });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
ui.info(`Ahora podés subir con: ${ui.c.bold('shilo push')}`);
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = init;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const config = require('../config');
|
|
4
|
+
const prompt = require('../prompt');
|
|
5
|
+
const ui = require('../ui');
|
|
6
|
+
const { ShiloClient } = require('../ShiloClient');
|
|
7
|
+
|
|
8
|
+
const DEFAULT_BASE_URL = 'https://shilo-six.vercel.app';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* shilo login [--email <e>] [--password <p>] [--token <pat>] [--base-url <url>]
|
|
12
|
+
*
|
|
13
|
+
* Autentica contra Shilo y guarda las credenciales cifradas en ~/.shilo/config.json.
|
|
14
|
+
* Con --token usa un Personal Access Token (generado desde la web) en lugar de
|
|
15
|
+
* email + contraseña.
|
|
16
|
+
*/
|
|
17
|
+
async function login(args) {
|
|
18
|
+
const baseURL = args['base-url'] || (config.load() || {}).baseURL || DEFAULT_BASE_URL;
|
|
19
|
+
|
|
20
|
+
const token = typeof args.token === 'string' ? args.token.trim() : '';
|
|
21
|
+
if (token) return loginWithToken(token, baseURL);
|
|
22
|
+
|
|
23
|
+
let email = args.email;
|
|
24
|
+
let pass = args.password;
|
|
25
|
+
|
|
26
|
+
if (!email) email = await prompt.question('Email: ');
|
|
27
|
+
if (!email) {
|
|
28
|
+
ui.error('Email requerido.');
|
|
29
|
+
return 1;
|
|
30
|
+
}
|
|
31
|
+
if (!pass) pass = await prompt.password('Contraseña: ');
|
|
32
|
+
if (!pass) {
|
|
33
|
+
ui.error('Contraseña requerida.');
|
|
34
|
+
return 1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const client = new ShiloClient({ baseURL });
|
|
38
|
+
ui.info(`Autenticando en ${ui.c.cyan(baseURL)} ...`);
|
|
39
|
+
|
|
40
|
+
let creds;
|
|
41
|
+
try {
|
|
42
|
+
creds = await client.authenticate(email, pass);
|
|
43
|
+
} catch (e) {
|
|
44
|
+
ui.error(e.message);
|
|
45
|
+
return 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Preservar supabaseUrl/anonKey si el usuario los configuró antes (habilitan refresh).
|
|
49
|
+
const existing = config.load() || {};
|
|
50
|
+
const saved = config.save({
|
|
51
|
+
baseURL,
|
|
52
|
+
supabaseUrl: existing.supabaseUrl || null,
|
|
53
|
+
supabaseAnonKey: existing.supabaseAnonKey || null,
|
|
54
|
+
credentials: creds,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const who = creds.user && creds.user.email ? creds.user.email : email;
|
|
58
|
+
ui.success(`Sesión iniciada como ${ui.c.bold(who)}.`);
|
|
59
|
+
ui.info(ui.c.dim(`Credenciales guardadas (cifradas) en ${saved}`));
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Guarda un PAT como credenciales, verificándolo contra el servidor antes de
|
|
65
|
+
* declarar éxito (un token inválido no debe quedar persistido en silencio).
|
|
66
|
+
*/
|
|
67
|
+
async function loginWithToken(token, baseURL) {
|
|
68
|
+
const client = new ShiloClient({ baseURL });
|
|
69
|
+
ui.info(`Verificando token en ${ui.c.cyan(baseURL)} ...`);
|
|
70
|
+
|
|
71
|
+
const creds = config.patCredentials(token);
|
|
72
|
+
let check;
|
|
73
|
+
try {
|
|
74
|
+
check = await client.verifyCredentials(creds);
|
|
75
|
+
} catch (e) {
|
|
76
|
+
ui.error(e.message);
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!check.ok) {
|
|
81
|
+
ui.error(`El token no es válido: ${check.error}`);
|
|
82
|
+
ui.info(ui.c.dim('Generá uno nuevo desde Shilo (Ajustes → tokens de acceso).'));
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const existing = config.load() || {};
|
|
87
|
+
const saved = config.save({
|
|
88
|
+
baseURL,
|
|
89
|
+
supabaseUrl: existing.supabaseUrl || null,
|
|
90
|
+
supabaseAnonKey: existing.supabaseAnonKey || null,
|
|
91
|
+
credentials: creds,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
ui.success('Token de acceso guardado.');
|
|
95
|
+
ui.info(ui.c.dim(`Credenciales guardadas (cifradas) en ${saved}`));
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = login;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const config = require('../config');
|
|
4
|
+
const ui = require('../ui');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* shilo logout — borra el archivo de credenciales local.
|
|
8
|
+
*/
|
|
9
|
+
async function logout() {
|
|
10
|
+
const removed = config.clear();
|
|
11
|
+
if (removed) {
|
|
12
|
+
ui.success('Sesión cerrada. Credenciales locales eliminadas.');
|
|
13
|
+
} else {
|
|
14
|
+
ui.info('No había credenciales guardadas.');
|
|
15
|
+
}
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = logout;
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const config = require('../config');
|
|
6
|
+
const remote = require('../remote');
|
|
7
|
+
const prompt = require('../prompt');
|
|
8
|
+
const ui = require('../ui');
|
|
9
|
+
const { walk } = require('../walk');
|
|
10
|
+
const { classifyContent } = require('../textguard');
|
|
11
|
+
const { ShiloClient } = require('../ShiloClient');
|
|
12
|
+
|
|
13
|
+
const DEFAULT_BASE_URL = 'https://shilo-six.vercel.app';
|
|
14
|
+
const COMMIT_BATCH = 100; // máximo de archivos por commit (límite del backend)
|
|
15
|
+
|
|
16
|
+
// Mapa mínimo extensión -> mime para texto. El backend default es text/plain.
|
|
17
|
+
const TEXT_MIME = {
|
|
18
|
+
js: 'text/javascript', mjs: 'text/javascript', cjs: 'text/javascript',
|
|
19
|
+
ts: 'text/typescript', jsx: 'text/javascript', tsx: 'text/typescript',
|
|
20
|
+
json: 'application/json', html: 'text/html', htm: 'text/html',
|
|
21
|
+
css: 'text/css', md: 'text/markdown', markdown: 'text/markdown',
|
|
22
|
+
csv: 'text/csv', xml: 'application/xml', yml: 'text/yaml', yaml: 'text/yaml',
|
|
23
|
+
svg: 'image/svg+xml', txt: 'text/plain',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function guessMime(name) {
|
|
27
|
+
const dot = name.lastIndexOf('.');
|
|
28
|
+
const ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
|
|
29
|
+
return TEXT_MIME[ext] || 'text/plain';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* shilo push [carpeta] [--project <uuid>] [-m <msg>] [--dry-run] [--yes] [--base-url <url>]
|
|
34
|
+
*
|
|
35
|
+
* Replica una carpeta local a un repo de Shilo: crea archivos nuevos, actualiza
|
|
36
|
+
* los que cambiaron, salta binarios/oversize/nombres inválidos, y registra todo
|
|
37
|
+
* en uno o más commits.
|
|
38
|
+
*/
|
|
39
|
+
async function push(args) {
|
|
40
|
+
const dir = path.resolve(args._[0] || '.');
|
|
41
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
|
42
|
+
ui.error(`No es una carpeta válida: ${dir}`);
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 1) Resolver remote: --project pisa a .shilo.json.
|
|
47
|
+
const savedRemote = remote.read(dir);
|
|
48
|
+
const projectId =
|
|
49
|
+
(typeof args.project === 'string' && args.project.trim()) ||
|
|
50
|
+
(savedRemote && savedRemote.projectId) ||
|
|
51
|
+
null;
|
|
52
|
+
|
|
53
|
+
if (!projectId) {
|
|
54
|
+
ui.error('No hay proyecto configurado en esta carpeta.');
|
|
55
|
+
ui.info('Corré "shilo init --project <uuid>" o pasá --project <uuid>.');
|
|
56
|
+
return 1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 2) Sesión.
|
|
60
|
+
const cfg = config.load();
|
|
61
|
+
if (!cfg || !cfg.credentials) {
|
|
62
|
+
ui.error('No hay sesión activa. Ejecutá "shilo login" primero.');
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const baseURL =
|
|
67
|
+
args['base-url'] || (savedRemote && savedRemote.baseURL) || cfg.baseURL || DEFAULT_BASE_URL;
|
|
68
|
+
|
|
69
|
+
const dryRun = !!args['dry-run'];
|
|
70
|
+
const message =
|
|
71
|
+
typeof args.message === 'string' && args.message.trim() ? args.message.trim() : null;
|
|
72
|
+
|
|
73
|
+
// 3) Recorrer la carpeta.
|
|
74
|
+
const { files, skipped, ignored } = walk(dir);
|
|
75
|
+
|
|
76
|
+
if (skipped.length > 0) {
|
|
77
|
+
for (const s of skipped) {
|
|
78
|
+
ui.warn(`saltado ${ui.c.dim(s.relPath)} — ${s.reason}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 4) Leer y clasificar (texto vs binario/oversize).
|
|
83
|
+
const uploads = []; // { relPath, absPath, content, mimeType }
|
|
84
|
+
const skippedBinary = [];
|
|
85
|
+
for (const f of files) {
|
|
86
|
+
let buf;
|
|
87
|
+
try {
|
|
88
|
+
buf = fs.readFileSync(f.absPath);
|
|
89
|
+
} catch (e) {
|
|
90
|
+
skippedBinary.push({ relPath: f.relPath, reason: `no se pudo leer: ${e.message}` });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const cls = classifyContent(path.basename(f.relPath), buf);
|
|
94
|
+
if (!cls.isText) {
|
|
95
|
+
skippedBinary.push({ relPath: f.relPath, reason: cls.reason });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
uploads.push({
|
|
99
|
+
relPath: f.relPath,
|
|
100
|
+
absPath: f.absPath,
|
|
101
|
+
content: cls.content,
|
|
102
|
+
mimeType: guessMime(f.relPath),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const s of skippedBinary) {
|
|
107
|
+
ui.warn(`saltado ${ui.c.dim(s.relPath)} — ${s.reason}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (uploads.length === 0) {
|
|
111
|
+
ui.info('No hay archivos de texto para subir.');
|
|
112
|
+
ui.info(
|
|
113
|
+
ui.c.dim(
|
|
114
|
+
`(${ignored} ignorados, ${skipped.length + skippedBinary.length} saltados)`
|
|
115
|
+
)
|
|
116
|
+
);
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const client = new ShiloClient({
|
|
121
|
+
baseURL,
|
|
122
|
+
supabaseUrl: cfg.supabaseUrl || null,
|
|
123
|
+
supabaseAnonKey: cfg.supabaseAnonKey || null,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
ui.info(
|
|
127
|
+
`Proyecto ${ui.c.bold(projectId)} en ${ui.c.cyan(baseURL)} — ` +
|
|
128
|
+
`${uploads.length} archivo(s) de texto, ${ignored} ignorado(s), ` +
|
|
129
|
+
`${skipped.length + skippedBinary.length} saltado(s).`
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// 5) Clasificar contra el remote (added / modified / unchanged) para planear.
|
|
133
|
+
// Esto ya toca la red (getFileMeta / readFile) pero no escribe nada.
|
|
134
|
+
let creds = cfg.credentials;
|
|
135
|
+
const plan = { added: [], modified: [], unchanged: [], failed: [] };
|
|
136
|
+
|
|
137
|
+
for (const u of uploads) {
|
|
138
|
+
try {
|
|
139
|
+
const meta = await client.getFileMeta(projectId, u.relPath, creds);
|
|
140
|
+
creds = meta.credentials;
|
|
141
|
+
|
|
142
|
+
if (!meta.exists) {
|
|
143
|
+
plan.added.push(u);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (meta.file && meta.file.type === 'folder') {
|
|
147
|
+
plan.failed.push({ ...u, reason: 'ya existe una carpeta con ese nombre' });
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Existe como archivo: ver si cambió comparando tamaño y luego contenido.
|
|
152
|
+
const localSize = Buffer.byteLength(u.content, 'utf8');
|
|
153
|
+
if (meta.file && typeof meta.file.size === 'number' && meta.file.size === localSize) {
|
|
154
|
+
const remoteRead = await client.readFile(projectId, u.relPath, creds);
|
|
155
|
+
creds = remoteRead.credentials;
|
|
156
|
+
if (remoteRead.file && remoteRead.file.content === u.content) {
|
|
157
|
+
plan.unchanged.push(u);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
plan.modified.push({ ...u, remote: meta.file });
|
|
162
|
+
} catch (e) {
|
|
163
|
+
plan.failed.push({ ...u, reason: e.message });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
config.persistCredsIfChanged(cfg, creds);
|
|
168
|
+
|
|
169
|
+
// 6) Reporte del plan.
|
|
170
|
+
ui.info('');
|
|
171
|
+
ui.info(` ${ui.c.green('nuevos:')} ${plan.added.length}`);
|
|
172
|
+
ui.info(` ${ui.c.yellow('modificados:')} ${plan.modified.length}`);
|
|
173
|
+
ui.info(` ${ui.c.dim('sin cambios:')} ${plan.unchanged.length}`);
|
|
174
|
+
if (plan.failed.length) {
|
|
175
|
+
ui.info(` ${ui.c.red('con error:')} ${plan.failed.length}`);
|
|
176
|
+
}
|
|
177
|
+
ui.info('');
|
|
178
|
+
|
|
179
|
+
const toWrite = plan.added.length + plan.modified.length;
|
|
180
|
+
if (toWrite === 0) {
|
|
181
|
+
if (plan.failed.length) {
|
|
182
|
+
for (const f of plan.failed) ui.error(`${f.relPath} — ${f.reason}`);
|
|
183
|
+
return 1;
|
|
184
|
+
}
|
|
185
|
+
ui.success('Todo al día. Nada para subir.');
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (dryRun) {
|
|
190
|
+
for (const u of plan.added) ui.info(` ${ui.c.green('+')} ${u.relPath}`);
|
|
191
|
+
for (const u of plan.modified) ui.info(` ${ui.c.yellow('~')} ${u.relPath}`);
|
|
192
|
+
for (const f of plan.failed) ui.info(` ${ui.c.red('!')} ${f.relPath} — ${f.reason}`);
|
|
193
|
+
ui.info('');
|
|
194
|
+
ui.info(ui.c.dim('(--dry-run: no se subió nada)'));
|
|
195
|
+
return plan.failed.length ? 1 : 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 7) Confirmar (salvo --yes).
|
|
199
|
+
if (!args.yes) {
|
|
200
|
+
const ok = await prompt.confirm(`¿Subir ${toWrite} archivo(s) al proyecto?`, true);
|
|
201
|
+
if (!ok) {
|
|
202
|
+
ui.info('Cancelado.');
|
|
203
|
+
return 0;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 8) Ejecutar subidas. Se acumulan los cambios para el/los commit(s).
|
|
208
|
+
const changes = []; // { file_id, action, previous_storage_path?, new_storage_path? }
|
|
209
|
+
const ensuredCache = new Set();
|
|
210
|
+
const failedWrites = [];
|
|
211
|
+
|
|
212
|
+
for (const u of plan.added) {
|
|
213
|
+
try {
|
|
214
|
+
creds = await client.ensurePath(projectId, u.relPath, creds, ensuredCache);
|
|
215
|
+
const created = await client.createFile(
|
|
216
|
+
projectId, u.relPath, u.content, u.mimeType, creds
|
|
217
|
+
);
|
|
218
|
+
creds = created.credentials;
|
|
219
|
+
|
|
220
|
+
if (created.created && created.file) {
|
|
221
|
+
changes.push({
|
|
222
|
+
file_id: created.file.id,
|
|
223
|
+
action: 'added',
|
|
224
|
+
new_storage_path: created.file.storage_path,
|
|
225
|
+
});
|
|
226
|
+
ui.success(`${ui.c.green('+')} ${u.relPath}`);
|
|
227
|
+
} else {
|
|
228
|
+
// 409: apareció entremedio; caemos a update.
|
|
229
|
+
const upd = await client.writeFile(projectId, u.relPath, u.content, u.mimeType, creds);
|
|
230
|
+
creds = upd.credentials;
|
|
231
|
+
changes.push({
|
|
232
|
+
file_id: upd.file.id,
|
|
233
|
+
action: 'modified',
|
|
234
|
+
previous_storage_path: (created.file && created.file.storage_path) || upd.file.storage_path,
|
|
235
|
+
new_storage_path: upd.file.storage_path,
|
|
236
|
+
});
|
|
237
|
+
ui.success(`${ui.c.yellow('~')} ${u.relPath}`);
|
|
238
|
+
}
|
|
239
|
+
config.persistCredsIfChanged(cfg, creds);
|
|
240
|
+
} catch (e) {
|
|
241
|
+
failedWrites.push({ relPath: u.relPath, reason: e.message });
|
|
242
|
+
ui.error(`${u.relPath} — ${e.message}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
for (const u of plan.modified) {
|
|
247
|
+
try {
|
|
248
|
+
const upd = await client.writeFile(projectId, u.relPath, u.content, u.mimeType, creds);
|
|
249
|
+
creds = upd.credentials;
|
|
250
|
+
changes.push({
|
|
251
|
+
file_id: upd.file.id,
|
|
252
|
+
action: 'modified',
|
|
253
|
+
previous_storage_path: u.remote ? u.remote.storage_path : undefined,
|
|
254
|
+
new_storage_path: upd.file.storage_path,
|
|
255
|
+
});
|
|
256
|
+
ui.success(`${ui.c.yellow('~')} ${u.relPath}`);
|
|
257
|
+
config.persistCredsIfChanged(cfg, creds);
|
|
258
|
+
} catch (e) {
|
|
259
|
+
failedWrites.push({ relPath: u.relPath, reason: e.message });
|
|
260
|
+
ui.error(`${u.relPath} — ${e.message}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 9) Commit(s) en lotes de <=100.
|
|
265
|
+
let committed = 0;
|
|
266
|
+
if (changes.length > 0) {
|
|
267
|
+
const total = changes.length;
|
|
268
|
+
const nBatches = Math.ceil(total / COMMIT_BATCH);
|
|
269
|
+
for (let i = 0; i < total; i += COMMIT_BATCH) {
|
|
270
|
+
const batch = changes.slice(i, i + COMMIT_BATCH);
|
|
271
|
+
const partNo = Math.floor(i / COMMIT_BATCH) + 1;
|
|
272
|
+
const msg = buildMessage(message, batch.length, total, partNo, nBatches);
|
|
273
|
+
try {
|
|
274
|
+
const result = await client.createCommit(projectId, msg, batch, creds);
|
|
275
|
+
creds = result.credentials;
|
|
276
|
+
committed += batch.length;
|
|
277
|
+
config.persistCredsIfChanged(cfg, creds);
|
|
278
|
+
} catch (e) {
|
|
279
|
+
ui.error(`Fallo al crear el commit (lote ${partNo}/${nBatches}): ${e.message}`);
|
|
280
|
+
failedWrites.push({ relPath: `<commit lote ${partNo}>`, reason: e.message });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
config.persistCredsIfChanged(cfg, creds);
|
|
286
|
+
|
|
287
|
+
// 10) Resumen final.
|
|
288
|
+
ui.info('');
|
|
289
|
+
const nCommits = Math.ceil(changes.length / COMMIT_BATCH);
|
|
290
|
+
if (failedWrites.length === 0) {
|
|
291
|
+
ui.success(
|
|
292
|
+
`Push completo: ${committed} archivo(s) en ${nCommits} commit(s).`
|
|
293
|
+
);
|
|
294
|
+
return 0;
|
|
295
|
+
}
|
|
296
|
+
ui.warn(`Push con problemas: ${failedWrites.length} archivo(s)/commit(s) fallaron.`);
|
|
297
|
+
return 1;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function buildMessage(userMsg, batchCount, total, partNo, nBatches) {
|
|
301
|
+
const base = userMsg || `shilo push: ${total} archivo(s)`;
|
|
302
|
+
if (nBatches > 1) return `${base} (${partNo}/${nBatches})`;
|
|
303
|
+
return base;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
module.exports = push;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const config = require('../config');
|
|
4
|
+
const ui = require('../ui');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* shilo whoami — muestra la sesión activa (si la hay) sin exponer el token.
|
|
8
|
+
*/
|
|
9
|
+
async function whoami() {
|
|
10
|
+
let cfg;
|
|
11
|
+
try {
|
|
12
|
+
cfg = config.load();
|
|
13
|
+
} catch (e) {
|
|
14
|
+
ui.error(e.message);
|
|
15
|
+
return 1;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!cfg || !cfg.credentials) {
|
|
19
|
+
ui.warn('No hay sesión activa. Ejecutá "shilo login".');
|
|
20
|
+
return 1;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { user, expiresAt, kind } = cfg.credentials;
|
|
24
|
+
const email = (user && user.email) || '(desconocido)';
|
|
25
|
+
|
|
26
|
+
// Un PAT no lleva expiración local: la valida el servidor en cada request.
|
|
27
|
+
if (kind === 'pat') {
|
|
28
|
+
ui.info(`Usuario: ${ui.c.bold(email)}`);
|
|
29
|
+
ui.info(`Servidor: ${cfg.baseURL || '(default)'}`);
|
|
30
|
+
ui.info(`Token: ${ui.c.green('token de acceso personal')}`);
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const expDate = new Date(expiresAt);
|
|
35
|
+
const msLeft = expDate.getTime() - Date.now();
|
|
36
|
+
const estado =
|
|
37
|
+
msLeft <= 0
|
|
38
|
+
? ui.c.red('expirado')
|
|
39
|
+
: msLeft < 5 * 60 * 1000
|
|
40
|
+
? ui.c.yellow('expira pronto')
|
|
41
|
+
: ui.c.green('válido');
|
|
42
|
+
|
|
43
|
+
ui.info(`Usuario: ${ui.c.bold(email)}`);
|
|
44
|
+
ui.info(`Servidor: ${cfg.baseURL || '(default)'}`);
|
|
45
|
+
ui.info(`Token: ${estado} ${ui.c.dim(`(exp. ${expDate.toLocaleString()})`)}`);
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = whoami;
|