shilo-cli 0.2.0 → 0.3.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 +93 -2
- package/bin/shilo.js +41 -2
- package/package.json +6 -2
- package/src/ShiloClient.js +22 -0
- package/src/commands/diff.js +158 -0
- package/src/commands/init.js +6 -1
- package/src/commands/log.js +155 -0
- package/src/commands/pull.js +239 -0
- package/src/commands/push.js +82 -8
- package/src/commands/status.js +187 -0
- package/src/context.js +82 -0
- package/src/remote.js +29 -3
- package/src/sync.js +344 -0
- package/src/textdiff.js +196 -0
|
@@ -0,0 +1,239 @@
|
|
|
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 sync = require('../sync');
|
|
10
|
+
const { validatePath } = require('../textguard');
|
|
11
|
+
const { resolveContext } = require('../context');
|
|
12
|
+
|
|
13
|
+
const CONCURRENCY = sync.CONCURRENCY;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resuelve la ruta absoluta de escritura para un `repoPath`, blindando contra
|
|
17
|
+
* path traversal. Valida cada segmento con las mismas reglas del backend y
|
|
18
|
+
* exige que el destino final quede dentro de `destDir`.
|
|
19
|
+
* Devuelve `{ ok: true, target }` o `{ ok: false, reason }`.
|
|
20
|
+
*/
|
|
21
|
+
function safeTarget(destDir, repoPath) {
|
|
22
|
+
const check = validatePath(repoPath);
|
|
23
|
+
if (!check.ok) return { ok: false, reason: check.reason };
|
|
24
|
+
|
|
25
|
+
const target = path.resolve(destDir, repoPath);
|
|
26
|
+
const rel = path.relative(destDir, target);
|
|
27
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
28
|
+
return { ok: false, reason: 'la ruta escaparía de la carpeta destino' };
|
|
29
|
+
}
|
|
30
|
+
return { ok: true, target };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* shilo pull [carpeta] [--project <uuid>] [--base-url <url>] [--dry-run] [--yes] [--force]
|
|
35
|
+
*
|
|
36
|
+
* Baja un proyecto de Shilo a una carpeta local y deja `.shilo.json` listo para
|
|
37
|
+
* un `push` posterior.
|
|
38
|
+
*
|
|
39
|
+
* Seguridad ante trabajo local sin guardar: el pull NO pisa un archivo local que
|
|
40
|
+
* difiera del remoto salvo con `--force`. Los archivos que sólo existen en el
|
|
41
|
+
* remoto, y los que ya coinciden byte a byte, se resuelven solos. Un archivo que
|
|
42
|
+
* sólo existe localmente nunca se toca (el pull no borra).
|
|
43
|
+
*/
|
|
44
|
+
async function pull(args) {
|
|
45
|
+
const ctx = resolveContext(args, { requireDir: false });
|
|
46
|
+
if (!ctx.ok) return ctx.code;
|
|
47
|
+
|
|
48
|
+
const { dir, projectId, baseURL, cfg, client, mgr } = ctx;
|
|
49
|
+
|
|
50
|
+
// La carpeta destino puede no existir todavía: la creamos al escribir. Si
|
|
51
|
+
// existe pero es un archivo, es un error del usuario.
|
|
52
|
+
if (fs.existsSync(dir) && !fs.statSync(dir).isDirectory()) {
|
|
53
|
+
ui.error(`No es una carpeta válida: ${dir}`);
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const dryRun = !!args['dry-run'];
|
|
58
|
+
|
|
59
|
+
ui.info(`Proyecto ${ui.c.bold(projectId)} en ${ui.c.cyan(baseURL)} — recorriendo árbol ...`);
|
|
60
|
+
|
|
61
|
+
let tree;
|
|
62
|
+
try {
|
|
63
|
+
tree = await sync.walkRemote(client, projectId, mgr);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
ui.error(`No se pudo recorrer el proyecto: ${e.message}`);
|
|
66
|
+
return 1;
|
|
67
|
+
} finally {
|
|
68
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const err of tree.errors) {
|
|
72
|
+
ui.warn(`no se pudo listar ${ui.c.dim(err.folderPath || '(raíz)')} — ${err.reason}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 1) Separar texto (descargable) de lo que no se puede bajar sin corromper.
|
|
76
|
+
const candidates = [];
|
|
77
|
+
const skippedBinary = [];
|
|
78
|
+
const unsafe = [];
|
|
79
|
+
for (const f of tree.files) {
|
|
80
|
+
const safe = safeTarget(dir, f.repoPath);
|
|
81
|
+
if (!safe.ok) {
|
|
82
|
+
unsafe.push({ repoPath: f.repoPath, reason: safe.reason });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (!sync.isTextMime(f.mime_type)) {
|
|
86
|
+
skippedBinary.push({ repoPath: f.repoPath, reason: `mime no textual (${f.mime_type})` });
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
candidates.push({ ...f, target: safe.target });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const u of unsafe) ui.warn(`saltado ${ui.c.dim(u.repoPath)} — ${u.reason}`);
|
|
93
|
+
for (const s of skippedBinary) ui.warn(`saltado ${ui.c.dim(s.repoPath)} — ${s.reason}`);
|
|
94
|
+
|
|
95
|
+
// 2) Clasificar contra lo que hay en disco, sin bajar nada todavía.
|
|
96
|
+
// `maybeSame` (mismo tamaño) se confirma recién al descargar el contenido.
|
|
97
|
+
const local = sync.walkLocal(dir);
|
|
98
|
+
const localByPath = new Map(local.files.map((f) => [f.repoPath, f]));
|
|
99
|
+
|
|
100
|
+
const newFiles = []; // sólo en el remoto: bajar sin riesgo
|
|
101
|
+
const maybeSame = []; // existe local con el mismo tamaño
|
|
102
|
+
const conflicting = []; // existe local con distinto tamaño: pisaría trabajo
|
|
103
|
+
for (const f of candidates) {
|
|
104
|
+
const localFile = localByPath.get(f.repoPath);
|
|
105
|
+
if (!localFile) newFiles.push(f);
|
|
106
|
+
else if (typeof f.size === 'number' && f.size !== localFile.size) conflicting.push(f);
|
|
107
|
+
else maybeSame.push(f);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const remotePaths = new Set(candidates.map((f) => f.repoPath));
|
|
111
|
+
const onlyLocal = local.files.filter((f) => !remotePaths.has(f.repoPath));
|
|
112
|
+
|
|
113
|
+
ui.info('');
|
|
114
|
+
ui.info(
|
|
115
|
+
` ${ui.c.green('nuevos:')} ${newFiles.length}\n` +
|
|
116
|
+
` ${ui.c.yellow('en conflicto:')} ${conflicting.length} ${ui.c.dim('(difieren en tamaño del local)')}\n` +
|
|
117
|
+
` ${ui.c.dim('posiblemente iguales:')} ${maybeSame.length}\n` +
|
|
118
|
+
` ${ui.c.dim('sólo locales:')} ${onlyLocal.length} ${ui.c.dim('(el pull no los toca)')}` +
|
|
119
|
+
(skippedBinary.length ? `\n ${ui.c.dim('binarios saltados:')} ${skippedBinary.length}` : '') +
|
|
120
|
+
(unsafe.length ? `\n ${ui.c.red('inseguros:')} ${unsafe.length}` : '')
|
|
121
|
+
);
|
|
122
|
+
ui.info('');
|
|
123
|
+
|
|
124
|
+
// 3) --dry-run: mostrar el plan contra el estado local y salir sin tocar disco.
|
|
125
|
+
if (dryRun) {
|
|
126
|
+
for (const f of newFiles) ui.info(` ${ui.c.green('↓')} ${f.repoPath} ${ui.c.dim('(nuevo)')}`);
|
|
127
|
+
for (const f of conflicting) {
|
|
128
|
+
const localFile = localByPath.get(f.repoPath);
|
|
129
|
+
const delta = f.size - localFile.size;
|
|
130
|
+
ui.info(
|
|
131
|
+
` ${ui.c.yellow('~')} ${f.repoPath} ` +
|
|
132
|
+
ui.c.dim(`(sobrescribe tu versión local, ${delta > 0 ? '+' : ''}${delta} bytes)`)
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
for (const f of onlyLocal) ui.info(` ${ui.c.dim('·')} ${ui.c.dim(`${f.repoPath} (sólo local, se conserva)`)}`);
|
|
136
|
+
ui.info('');
|
|
137
|
+
if (conflicting.length > 0) {
|
|
138
|
+
ui.warn(`${conflicting.length} archivo(s) locales se sobrescribirían: hace falta --force.`);
|
|
139
|
+
ui.info(ui.c.dim('Mirá "shilo diff <archivo>" antes de bajarlos.'));
|
|
140
|
+
}
|
|
141
|
+
ui.info(
|
|
142
|
+
ui.c.dim(
|
|
143
|
+
'(--dry-run: no se bajó nada. Los "posiblemente iguales" se confirman al descargar.)'
|
|
144
|
+
)
|
|
145
|
+
);
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 4) Sin --force, los conflictos quedan afuera en vez de abortar todo el pull:
|
|
150
|
+
// lo nuevo se baja igual y el usuario decide qué hacer con el resto.
|
|
151
|
+
let toDownload = [...newFiles, ...maybeSame];
|
|
152
|
+
if (conflicting.length > 0) {
|
|
153
|
+
if (args.force) {
|
|
154
|
+
toDownload = toDownload.concat(conflicting);
|
|
155
|
+
} else {
|
|
156
|
+
ui.warn(`${conflicting.length} archivo(s) difieren de tu copia local y NO se van a bajar:`);
|
|
157
|
+
for (const f of conflicting) ui.info(` ${ui.c.yellow('~')} ${f.repoPath}`);
|
|
158
|
+
ui.info(ui.c.dim('Usá --force para sobrescribirlos, o "shilo diff <archivo>" para comparar.'));
|
|
159
|
+
ui.info('');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (toDownload.length === 0) {
|
|
164
|
+
ui.info('No hay archivos para bajar.');
|
|
165
|
+
return conflicting.length > 0 && !args.force ? 1 : 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 5) Confirmar (salvo --yes).
|
|
169
|
+
if (!args.yes) {
|
|
170
|
+
const overwrites = toDownload.filter((f) => fs.existsSync(f.target)).length;
|
|
171
|
+
const label = overwrites
|
|
172
|
+
? `¿Bajar ${toDownload.length} archivo(s) a "${dir}" (${overwrites} ya existen)?`
|
|
173
|
+
: `¿Bajar ${toDownload.length} archivo(s) a "${dir}"?`;
|
|
174
|
+
const ok = await prompt.confirm(label, true);
|
|
175
|
+
if (!ok) {
|
|
176
|
+
ui.info('Cancelado.');
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 6) Descargar y escribir, con pool. Recién acá se sabe si un archivo del
|
|
182
|
+
// mismo tamaño era realmente idéntico: si lo era, no se reescribe.
|
|
183
|
+
const written = [];
|
|
184
|
+
const unchanged = [];
|
|
185
|
+
const failed = [];
|
|
186
|
+
const outcomes = await sync.runPool(toDownload, CONCURRENCY, async (f) => {
|
|
187
|
+
const creds = await mgr.valid();
|
|
188
|
+
const read = await client.readFile(projectId, f.repoPath, creds);
|
|
189
|
+
const content = read.file && read.file.content != null ? read.file.content : '';
|
|
190
|
+
|
|
191
|
+
const current = sync.readLocalText(f.target);
|
|
192
|
+
if (current !== null && current === content) {
|
|
193
|
+
return { repoPath: f.repoPath, skipped: true };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
fs.mkdirSync(path.dirname(f.target), { recursive: true });
|
|
197
|
+
fs.writeFileSync(f.target, content, 'utf8');
|
|
198
|
+
return { repoPath: f.repoPath, skipped: false };
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
for (const res of outcomes) {
|
|
202
|
+
if (res && res.__error) {
|
|
203
|
+
failed.push({ repoPath: res.item.repoPath, reason: res.__error });
|
|
204
|
+
ui.error(`${res.item.repoPath} — ${res.__error}`);
|
|
205
|
+
} else if (res.skipped) {
|
|
206
|
+
unchanged.push(res.repoPath);
|
|
207
|
+
} else {
|
|
208
|
+
written.push(res.repoPath);
|
|
209
|
+
ui.success(`${ui.c.green('↓')} ${res.repoPath}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
214
|
+
|
|
215
|
+
// 7) Binding + marca de sincronización para el `status` siguiente. Sólo si el
|
|
216
|
+
// pull cubrió todo: con conflictos pendientes la carpeta NO quedó alineada.
|
|
217
|
+
const complete = failed.length === 0 && (args.force || conflicting.length === 0);
|
|
218
|
+
try {
|
|
219
|
+
if (complete) {
|
|
220
|
+
remote.write(dir, { projectId, baseURL, lastSync: new Date().toISOString() });
|
|
221
|
+
} else if (written.length > 0) {
|
|
222
|
+
remote.write(dir, { projectId, baseURL });
|
|
223
|
+
}
|
|
224
|
+
} catch (e) {
|
|
225
|
+
ui.warn(`No se pudo escribir ${remote.REMOTE_FILENAME}: ${e.message}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 8) Resumen.
|
|
229
|
+
ui.info('');
|
|
230
|
+
const tail = unchanged.length ? ` (${unchanged.length} ya estaban al día)` : '';
|
|
231
|
+
if (failed.length === 0) {
|
|
232
|
+
ui.success(`Pull completo: ${written.length} archivo(s) actualizados en "${dir}"${tail}.`);
|
|
233
|
+
return conflicting.length > 0 && !args.force ? 1 : 0;
|
|
234
|
+
}
|
|
235
|
+
ui.warn(`Pull con problemas: ${failed.length} archivo(s) fallaron, ${written.length} bajados${tail}.`);
|
|
236
|
+
return 1;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
module.exports = pull;
|
package/src/commands/push.js
CHANGED
|
@@ -6,6 +6,7 @@ const config = require('../config');
|
|
|
6
6
|
const remote = require('../remote');
|
|
7
7
|
const prompt = require('../prompt');
|
|
8
8
|
const ui = require('../ui');
|
|
9
|
+
const sync = require('../sync');
|
|
9
10
|
const { walk } = require('../walk');
|
|
10
11
|
const { classifyContent } = require('../textguard');
|
|
11
12
|
const { ShiloClient } = require('../ShiloClient');
|
|
@@ -69,6 +70,12 @@ async function push(args) {
|
|
|
69
70
|
const dryRun = !!args['dry-run'];
|
|
70
71
|
const message =
|
|
71
72
|
typeof args.message === 'string' && args.message.trim() ? args.message.trim() : null;
|
|
73
|
+
const card = typeof args.card === 'string' && args.card.trim() ? args.card.trim() : null;
|
|
74
|
+
|
|
75
|
+
if (card && card.length > 100) {
|
|
76
|
+
ui.error('El id de tarjeta (--card) no puede superar 100 caracteres.');
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
72
79
|
|
|
73
80
|
// 3) Recorrer la carpeta.
|
|
74
81
|
const { files, skipped, ignored } = walk(dir);
|
|
@@ -166,6 +173,32 @@ async function push(args) {
|
|
|
166
173
|
|
|
167
174
|
config.persistCredsIfChanged(cfg, creds);
|
|
168
175
|
|
|
176
|
+
// 5b) Atribución: para los archivos que vamos a pisar, quién los tocó último.
|
|
177
|
+
// Es lo que distingue "esto lo subí yo antes" de "esto lo va a perder un
|
|
178
|
+
// compañero". Best-effort: si el historial no se puede leer, seguimos.
|
|
179
|
+
let attribution = { index: new Map() };
|
|
180
|
+
const myId = cfg.credentials && cfg.credentials.user ? cfg.credentials.user.id : null;
|
|
181
|
+
if (plan.modified.length > 0) {
|
|
182
|
+
const mgr = sync.makeCredsManager(client, cfg);
|
|
183
|
+
attribution = await sync.buildAuthorIndex(client, projectId, mgr, { commits: 50 });
|
|
184
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Etiqueta " — última edición de X, fecha" para un archivo del plan. */
|
|
188
|
+
const authorNote = (u) => {
|
|
189
|
+
const meta = u.remote && attribution.index.get(u.remote.id);
|
|
190
|
+
if (!meta) return '';
|
|
191
|
+
const mine = myId && meta.authorId && meta.authorId === myId;
|
|
192
|
+
const who = mine ? ui.c.dim('vos') : ui.c.bold(meta.author);
|
|
193
|
+
return ui.c.dim(' — última edición de ') + who + ui.c.dim(`, ${sync.formatDate(meta.date)}`);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/** ¿El último que tocó este archivo fue otra persona? */
|
|
197
|
+
const touchedByOthers = plan.modified.filter((u) => {
|
|
198
|
+
const meta = u.remote && attribution.index.get(u.remote.id);
|
|
199
|
+
return meta && meta.authorId && myId && meta.authorId !== myId;
|
|
200
|
+
});
|
|
201
|
+
|
|
169
202
|
// 6) Reporte del plan.
|
|
170
203
|
ui.info('');
|
|
171
204
|
ui.info(` ${ui.c.green('nuevos:')} ${plan.added.length}`);
|
|
@@ -188,14 +221,38 @@ async function push(args) {
|
|
|
188
221
|
|
|
189
222
|
if (dryRun) {
|
|
190
223
|
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}`);
|
|
224
|
+
for (const u of plan.modified) ui.info(` ${ui.c.yellow('~')} ${u.relPath}${authorNote(u)}`);
|
|
192
225
|
for (const f of plan.failed) ui.info(` ${ui.c.red('!')} ${f.relPath} — ${f.reason}`);
|
|
193
226
|
ui.info('');
|
|
227
|
+
if (!myId && plan.modified.length > 0 && attribution.index.size > 0) {
|
|
228
|
+
ui.info(
|
|
229
|
+
ui.c.dim(
|
|
230
|
+
'(con token de acceso personal no sabemos tu id de usuario: ' +
|
|
231
|
+
'el autor se muestra, pero no se marca cuál sos vos)'
|
|
232
|
+
)
|
|
233
|
+
);
|
|
234
|
+
ui.info('');
|
|
235
|
+
}
|
|
236
|
+
if (touchedByOthers.length > 0) {
|
|
237
|
+
ui.warn(
|
|
238
|
+
`${touchedByOthers.length} de esos archivos los editó otra persona: ` +
|
|
239
|
+
`este push pisaría su versión.`
|
|
240
|
+
);
|
|
241
|
+
ui.info(ui.c.dim('Revisalos con "shilo diff <archivo>" o traelos con "shilo pull".'));
|
|
242
|
+
ui.info('');
|
|
243
|
+
}
|
|
194
244
|
ui.info(ui.c.dim('(--dry-run: no se subió nada)'));
|
|
195
245
|
return plan.failed.length ? 1 : 0;
|
|
196
246
|
}
|
|
197
247
|
|
|
198
|
-
// 7) Confirmar (salvo --yes).
|
|
248
|
+
// 7) Confirmar (salvo --yes). Si vamos a pisar trabajo ajeno, lo decimos
|
|
249
|
+
// antes de preguntar: es el caso que motivó todo este flujo.
|
|
250
|
+
if (touchedByOthers.length > 0) {
|
|
251
|
+
ui.warn(`Vas a pisar ${touchedByOthers.length} archivo(s) editado(s) por otra persona:`);
|
|
252
|
+
for (const u of touchedByOthers) ui.info(` ${ui.c.red('!')} ${u.relPath}${authorNote(u)}`);
|
|
253
|
+
ui.info('');
|
|
254
|
+
}
|
|
255
|
+
|
|
199
256
|
if (!args.yes) {
|
|
200
257
|
const ok = await prompt.confirm(`¿Subir ${toWrite} archivo(s) al proyecto?`, true);
|
|
201
258
|
if (!ok) {
|
|
@@ -269,7 +326,7 @@ async function push(args) {
|
|
|
269
326
|
for (let i = 0; i < total; i += COMMIT_BATCH) {
|
|
270
327
|
const batch = changes.slice(i, i + COMMIT_BATCH);
|
|
271
328
|
const partNo = Math.floor(i / COMMIT_BATCH) + 1;
|
|
272
|
-
const msg = buildMessage(message, batch.length, total, partNo, nBatches);
|
|
329
|
+
const msg = buildMessage(message, batch.length, total, partNo, nBatches, card);
|
|
273
330
|
try {
|
|
274
331
|
const result = await client.createCommit(projectId, msg, batch, creds);
|
|
275
332
|
creds = result.credentials;
|
|
@@ -284,7 +341,13 @@ async function push(args) {
|
|
|
284
341
|
|
|
285
342
|
config.persistCredsIfChanged(cfg, creds);
|
|
286
343
|
|
|
287
|
-
// 10)
|
|
344
|
+
// 10) Anotar la sincronización: `status` la usa después para saber qué del
|
|
345
|
+
// remoto es posterior a lo último que subimos.
|
|
346
|
+
if (failedWrites.length === 0) {
|
|
347
|
+
remote.touchSync(dir, { projectId, baseURL });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// 11) Resumen final.
|
|
288
351
|
ui.info('');
|
|
289
352
|
const nCommits = Math.ceil(changes.length / COMMIT_BATCH);
|
|
290
353
|
if (failedWrites.length === 0) {
|
|
@@ -297,10 +360,21 @@ async function push(args) {
|
|
|
297
360
|
return 1;
|
|
298
361
|
}
|
|
299
362
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
363
|
+
/**
|
|
364
|
+
* Arma el mensaje del commit. Si vino `--card`, se agrega como trailer
|
|
365
|
+
* `[card: <id>]` al final: queda visible en el historial y es fácil de grepear
|
|
366
|
+
* para cruzar un cambio con la tarjeta que lo pidió (Metrónomo u otro tablero).
|
|
367
|
+
* El backend corta en 500 caracteres, así que recortamos el mensaje del usuario
|
|
368
|
+
* antes que el trailer.
|
|
369
|
+
*/
|
|
370
|
+
function buildMessage(userMsg, batchCount, total, partNo, nBatches, card) {
|
|
371
|
+
let base = userMsg || `shilo push: ${total} archivo(s)`;
|
|
372
|
+
if (nBatches > 1) base = `${base} (${partNo}/${nBatches})`;
|
|
373
|
+
if (!card) return base.slice(0, 500);
|
|
374
|
+
|
|
375
|
+
const trailer = ` [card: ${card}]`;
|
|
376
|
+
const room = 500 - trailer.length;
|
|
377
|
+
return `${base.slice(0, room)}${trailer}`;
|
|
304
378
|
}
|
|
305
379
|
|
|
306
380
|
module.exports = push;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const config = require('../config');
|
|
4
|
+
const ui = require('../ui');
|
|
5
|
+
const sync = require('../sync');
|
|
6
|
+
const remote = require('../remote');
|
|
7
|
+
const { resolveContext } = require('../context');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* shilo status [carpeta] [--project <uuid>] [--base-url <url>] [--short] [--json]
|
|
11
|
+
*
|
|
12
|
+
* Comando de sólo lectura: compara la carpeta local contra el remoto SIN bajar
|
|
13
|
+
* contenido y sin escribir nada, ni local ni remoto. Contesta las dos preguntas
|
|
14
|
+
* que uno se hace antes de arrancar a trabajar:
|
|
15
|
+
*
|
|
16
|
+
* - ¿el remoto tiene cambios que no tengo? (y de quién son)
|
|
17
|
+
* - ¿tengo cambios locales sin subir?
|
|
18
|
+
*
|
|
19
|
+
* La comparación es por ruta + tamaño (un listado por carpeta, cero descargas).
|
|
20
|
+
* Dos archivos del mismo tamaño se reportan como "iguales" aunque su contenido
|
|
21
|
+
* pudiera diferir: para confirmar byte a byte están `shilo diff` y `push`.
|
|
22
|
+
*/
|
|
23
|
+
async function status(args) {
|
|
24
|
+
const ctx = resolveContext(args);
|
|
25
|
+
if (!ctx.ok) return ctx.code;
|
|
26
|
+
|
|
27
|
+
const { dir, projectId, baseURL, cfg, client, mgr, savedRemote } = ctx;
|
|
28
|
+
const asJson = !!args.json;
|
|
29
|
+
const short = !!args.short;
|
|
30
|
+
const lastSync = savedRemote && savedRemote.lastSync;
|
|
31
|
+
|
|
32
|
+
if (!asJson) {
|
|
33
|
+
ui.info(
|
|
34
|
+
`Proyecto ${ui.c.bold(projectId)} en ${ui.c.cyan(baseURL)} — comparando ${ui.c.dim(dir)} ...`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 1) Árbol local (offline) y remoto (sólo listados).
|
|
39
|
+
const local = sync.walkLocal(dir);
|
|
40
|
+
|
|
41
|
+
let tree;
|
|
42
|
+
try {
|
|
43
|
+
tree = await sync.walkRemote(client, projectId, mgr);
|
|
44
|
+
} catch (e) {
|
|
45
|
+
ui.error(`No se pudo consultar el proyecto: ${e.message}`);
|
|
46
|
+
return 1;
|
|
47
|
+
} finally {
|
|
48
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 2) Atribución: quién tocó cada archivo por última vez (best-effort).
|
|
52
|
+
const attribution = await sync.buildAuthorIndex(client, projectId, mgr, { commits: 50 });
|
|
53
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
54
|
+
|
|
55
|
+
const me = cfg.credentials && cfg.credentials.user ? cfg.credentials.user.id : null;
|
|
56
|
+
const cmp = sync.compareTrees(local.files, tree.files);
|
|
57
|
+
|
|
58
|
+
// 3) "Detrás del remoto" = lo que otro subió y yo no tengo igual: archivos
|
|
59
|
+
// que faltan localmente, o que difieren y cambiaron después de mi lastSync.
|
|
60
|
+
const behind = [];
|
|
61
|
+
for (const item of cmp.onlyRemote) {
|
|
62
|
+
behind.push({ ...item, kind: 'falta local' });
|
|
63
|
+
}
|
|
64
|
+
for (const item of cmp.differing) {
|
|
65
|
+
if (sync.changedSinceSync(item.remote, lastSync) !== false) {
|
|
66
|
+
behind.push({ ...item, kind: 'difiere' });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
behind.sort((a, b) => a.repoPath.localeCompare(b.repoPath));
|
|
70
|
+
|
|
71
|
+
// 4) "Adelante" = lo que yo tengo y el remoto no refleja.
|
|
72
|
+
const ahead = [
|
|
73
|
+
...cmp.onlyLocal.map((i) => ({ ...i, kind: 'nuevo local' })),
|
|
74
|
+
...cmp.differing.map((i) => ({ ...i, kind: 'modificado local' })),
|
|
75
|
+
].sort((a, b) => a.repoPath.localeCompare(b.repoPath));
|
|
76
|
+
|
|
77
|
+
const decorate = (item) => {
|
|
78
|
+
const meta = item.remote && attribution.index.get(item.remote.id);
|
|
79
|
+
if (!meta) return null;
|
|
80
|
+
return {
|
|
81
|
+
author: meta.author,
|
|
82
|
+
mine: !!(me && meta.authorId && meta.authorId === me),
|
|
83
|
+
date: meta.date,
|
|
84
|
+
message: meta.message,
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
if (asJson) {
|
|
89
|
+
console.log(
|
|
90
|
+
JSON.stringify(
|
|
91
|
+
{
|
|
92
|
+
projectId,
|
|
93
|
+
baseURL,
|
|
94
|
+
dir,
|
|
95
|
+
lastSync: lastSync || null,
|
|
96
|
+
remoteFiles: tree.files.length,
|
|
97
|
+
localFiles: local.files.length,
|
|
98
|
+
behind: behind.map((i) => ({ path: i.repoPath, kind: i.kind, lastCommit: decorate(i) })),
|
|
99
|
+
ahead: ahead.map((i) => ({ path: i.repoPath, kind: i.kind })),
|
|
100
|
+
unchanged: cmp.same.length,
|
|
101
|
+
listErrors: tree.errors,
|
|
102
|
+
},
|
|
103
|
+
null,
|
|
104
|
+
2
|
|
105
|
+
)
|
|
106
|
+
);
|
|
107
|
+
return behind.length > 0 ? 2 : 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 5) Reporte legible.
|
|
111
|
+
for (const err of tree.errors) {
|
|
112
|
+
ui.warn(`no se pudo listar ${ui.c.dim(err.folderPath || '(raíz)')} — ${err.reason}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
ui.info('');
|
|
116
|
+
if (lastSync) {
|
|
117
|
+
ui.info(ui.c.dim(`Última sincronización de esta carpeta: ${sync.formatDate(lastSync)}`));
|
|
118
|
+
} else {
|
|
119
|
+
ui.info(
|
|
120
|
+
ui.c.dim(
|
|
121
|
+
`Esta carpeta no registra una sincronización previa ` +
|
|
122
|
+
`(hacé un pull o push para que ${remote.REMOTE_FILENAME} la anote).`
|
|
123
|
+
)
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
ui.info('');
|
|
127
|
+
|
|
128
|
+
if (behind.length === 0 && ahead.length === 0) {
|
|
129
|
+
ui.success(`Al día: ${cmp.same.length} archivo(s) coinciden con el remoto.`);
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (behind.length > 0) {
|
|
134
|
+
ui.info(ui.c.bold(`El remoto tiene ${behind.length} cambio(s) que no tenés localmente:`));
|
|
135
|
+
for (const item of behind) {
|
|
136
|
+
const mark = item.kind === 'falta local' ? ui.c.green('↓') : ui.c.yellow('≠');
|
|
137
|
+
const meta = decorate(item);
|
|
138
|
+
let suffix = '';
|
|
139
|
+
if (meta) {
|
|
140
|
+
const who = meta.mine ? ui.c.dim('vos') : ui.c.bold(meta.author);
|
|
141
|
+
suffix = ui.c.dim(` — último commit de `) + who + ui.c.dim(`, ${sync.formatDate(meta.date)}`);
|
|
142
|
+
} else if (item.remote && item.remote.updated_at) {
|
|
143
|
+
suffix = ui.c.dim(` — actualizado ${sync.formatDate(item.remote.updated_at)}`);
|
|
144
|
+
}
|
|
145
|
+
ui.info(` ${mark} ${item.repoPath}${short ? '' : suffix}`);
|
|
146
|
+
}
|
|
147
|
+
ui.info('');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (ahead.length > 0) {
|
|
151
|
+
ui.info(ui.c.bold(`Tenés ${ahead.length} cambio(s) local(es) sin subir:`));
|
|
152
|
+
for (const item of ahead) {
|
|
153
|
+
const mark = item.kind === 'nuevo local' ? ui.c.green('+') : ui.c.yellow('~');
|
|
154
|
+
ui.info(` ${mark} ${item.repoPath}`);
|
|
155
|
+
}
|
|
156
|
+
ui.info('');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
ui.info(
|
|
160
|
+
ui.c.dim(
|
|
161
|
+
` ${cmp.same.length} sin cambios (por tamaño) ` +
|
|
162
|
+
`${local.ignored} ignorados ${local.skipped.length} saltados`
|
|
163
|
+
)
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// Los archivos que aparecen en ambas listas son el caso peligroso: el push
|
|
167
|
+
// los pisaría sin traerse antes lo del otro.
|
|
168
|
+
const conflicting = ahead.filter((a) => behind.some((b) => b.repoPath === a.repoPath));
|
|
169
|
+
ui.info('');
|
|
170
|
+
if (conflicting.length > 0) {
|
|
171
|
+
ui.warn(
|
|
172
|
+
`${conflicting.length} archivo(s) cambiaron de los dos lados: un push los pisaría.`
|
|
173
|
+
);
|
|
174
|
+
for (const item of conflicting) ui.info(` ${ui.c.red('!')} ${item.repoPath}`);
|
|
175
|
+
ui.info(ui.c.dim('Mirá "shilo diff <archivo>" antes de decidir.'));
|
|
176
|
+
} else if (behind.length > 0) {
|
|
177
|
+
ui.info(ui.c.dim('Sugerido: "shilo pull --dry-run" para ver qué bajaría.'));
|
|
178
|
+
} else {
|
|
179
|
+
ui.info(ui.c.dim('Sugerido: "shilo push --dry-run" para ver qué subiría.'));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Exit code 2 = "estás desactualizado". Útil para encadenar en scripts:
|
|
183
|
+
// `shilo status || echo "bajá antes de trabajar"`.
|
|
184
|
+
return behind.length > 0 ? 2 : 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = status;
|
package/src/context.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolución del contexto común a los comandos que hablan con un proyecto:
|
|
5
|
+
* carpeta -> remote (.shilo.json) -> sesión -> cliente HTTP.
|
|
6
|
+
*
|
|
7
|
+
* Los comandos de sólo lectura (`status`, `log`, `diff`) comparten exactamente
|
|
8
|
+
* este preámbulo; tenerlo en un lugar evita que cada uno resuelva el proyecto o
|
|
9
|
+
* el baseURL con reglas ligeramente distintas.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const config = require('./config');
|
|
15
|
+
const remote = require('./remote');
|
|
16
|
+
const ui = require('./ui');
|
|
17
|
+
const sync = require('./sync');
|
|
18
|
+
const { ShiloClient } = require('./ShiloClient');
|
|
19
|
+
|
|
20
|
+
const DEFAULT_BASE_URL = 'https://shilo-six.vercel.app';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Devuelve `{ ok: true, dir, projectId, baseURL, cfg, client, mgr, savedRemote }`
|
|
24
|
+
* o `{ ok: false, code }` habiendo ya impreso el error.
|
|
25
|
+
*
|
|
26
|
+
* `requireDir`: exige que la carpeta exista (todos menos `pull`, que la crea).
|
|
27
|
+
*/
|
|
28
|
+
function resolveContext(args, { requireDir = true } = {}) {
|
|
29
|
+
const dir = path.resolve(args._[0] || '.');
|
|
30
|
+
|
|
31
|
+
if (requireDir && (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory())) {
|
|
32
|
+
ui.error(`No es una carpeta válida: ${dir}`);
|
|
33
|
+
return { ok: false, code: 1 };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let savedRemote = null;
|
|
37
|
+
try {
|
|
38
|
+
savedRemote = remote.read(dir);
|
|
39
|
+
} catch (e) {
|
|
40
|
+
ui.error(e.message);
|
|
41
|
+
return { ok: false, code: 1 };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const projectId =
|
|
45
|
+
(typeof args.project === 'string' && args.project.trim()) ||
|
|
46
|
+
(savedRemote && savedRemote.projectId) ||
|
|
47
|
+
null;
|
|
48
|
+
|
|
49
|
+
if (!projectId) {
|
|
50
|
+
ui.error('No hay proyecto configurado en esta carpeta.');
|
|
51
|
+
ui.info('Pasá --project <uuid> o corré "shilo init --project <uuid>" primero.');
|
|
52
|
+
return { ok: false, code: 1 };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const cfg = config.load();
|
|
56
|
+
if (!cfg || !cfg.credentials) {
|
|
57
|
+
ui.error('No hay sesión activa. Ejecutá "shilo login" primero.');
|
|
58
|
+
return { ok: false, code: 1 };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const baseURL =
|
|
62
|
+
args['base-url'] || (savedRemote && savedRemote.baseURL) || cfg.baseURL || DEFAULT_BASE_URL;
|
|
63
|
+
|
|
64
|
+
const client = new ShiloClient({
|
|
65
|
+
baseURL,
|
|
66
|
+
supabaseUrl: cfg.supabaseUrl || null,
|
|
67
|
+
supabaseAnonKey: cfg.supabaseAnonKey || null,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
ok: true,
|
|
72
|
+
dir,
|
|
73
|
+
projectId,
|
|
74
|
+
baseURL,
|
|
75
|
+
cfg,
|
|
76
|
+
client,
|
|
77
|
+
mgr: sync.makeCredsManager(client, cfg),
|
|
78
|
+
savedRemote,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { resolveContext, DEFAULT_BASE_URL };
|