shilo-cli 0.2.0 → 0.3.1

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.
@@ -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;
@@ -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);
@@ -131,40 +138,70 @@ async function push(args) {
131
138
 
132
139
  // 5) Clasificar contra el remote (added / modified / unchanged) para planear.
133
140
  // Esto ya toca la red (getFileMeta / readFile) pero no escribe nada.
134
- let creds = cfg.credentials;
141
+ // Se hace con un pool de concurrencia (mismo patrón que walkRemote en
142
+ // pull/status) en vez de un archivo a la vez: es lo que hacía que un push
143
+ // de muchos archivos tardara minutos por puro round-trip secuencial.
144
+ // `mgr` serializa el refresh del JWT para que llamadas paralelas no
145
+ // quemen el mismo refresh_token.
146
+ const mgr = sync.makeCredsManager(client, cfg);
135
147
  const plan = { added: [], modified: [], unchanged: [], failed: [] };
136
148
 
137
- for (const u of uploads) {
138
- try {
139
- const meta = await client.getFileMeta(projectId, u.relPath, creds);
140
- creds = meta.credentials;
149
+ const planResults = await sync.runPool(uploads, sync.CONCURRENCY, async (u) => {
150
+ const creds = await mgr.valid();
151
+ const meta = await client.getFileMeta(projectId, u.relPath, creds);
141
152
 
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
- }
153
+ if (!meta.exists) return { kind: 'added', u };
154
+ if (meta.file && meta.file.type === 'folder') {
155
+ return { kind: 'failed', u, reason: 'ya existe una carpeta con ese nombre' };
156
+ }
150
157
 
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
- }
158
+ // Existe como archivo: ver si cambió comparando tamaño y luego contenido.
159
+ const localSize = Buffer.byteLength(u.content, 'utf8');
160
+ if (meta.file && typeof meta.file.size === 'number' && meta.file.size === localSize) {
161
+ const readCreds = await mgr.valid();
162
+ const remoteRead = await client.readFile(projectId, u.relPath, readCreds);
163
+ if (remoteRead.file && remoteRead.file.content === u.content) {
164
+ return { kind: 'unchanged', u };
160
165
  }
161
- plan.modified.push({ ...u, remote: meta.file });
162
- } catch (e) {
163
- plan.failed.push({ ...u, reason: e.message });
164
166
  }
167
+ return { kind: 'modified', u: { ...u, remote: meta.file } };
168
+ });
169
+
170
+ for (const r of planResults) {
171
+ if (r && r.__error) {
172
+ plan.failed.push({ ...r.item, reason: r.__error });
173
+ continue;
174
+ }
175
+ if (r.kind === 'failed') plan.failed.push({ ...r.u, reason: r.reason });
176
+ else plan[r.kind].push(r.u);
177
+ }
178
+
179
+ config.persistCredsIfChanged(cfg, mgr.current);
180
+
181
+ // 5b) Atribución: para los archivos que vamos a pisar, quién los tocó último.
182
+ // Es lo que distingue "esto lo subí yo antes" de "esto lo va a perder un
183
+ // compañero". Best-effort: si el historial no se puede leer, seguimos.
184
+ let attribution = { index: new Map() };
185
+ const myId = cfg.credentials && cfg.credentials.user ? cfg.credentials.user.id : null;
186
+ if (plan.modified.length > 0) {
187
+ attribution = await sync.buildAuthorIndex(client, projectId, mgr, { commits: 50 });
188
+ config.persistCredsIfChanged(cfg, mgr.current);
165
189
  }
166
190
 
167
- config.persistCredsIfChanged(cfg, creds);
191
+ /** Etiqueta " — última edición de X, fecha" para un archivo del plan. */
192
+ const authorNote = (u) => {
193
+ const meta = u.remote && attribution.index.get(u.remote.id);
194
+ if (!meta) return '';
195
+ const mine = myId && meta.authorId && meta.authorId === myId;
196
+ const who = mine ? ui.c.dim('vos') : ui.c.bold(meta.author);
197
+ return ui.c.dim(' — última edición de ') + who + ui.c.dim(`, ${sync.formatDate(meta.date)}`);
198
+ };
199
+
200
+ /** ¿El último que tocó este archivo fue otra persona? */
201
+ const touchedByOthers = plan.modified.filter((u) => {
202
+ const meta = u.remote && attribution.index.get(u.remote.id);
203
+ return meta && meta.authorId && myId && meta.authorId !== myId;
204
+ });
168
205
 
169
206
  // 6) Reporte del plan.
170
207
  ui.info('');
@@ -188,14 +225,38 @@ async function push(args) {
188
225
 
189
226
  if (dryRun) {
190
227
  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}`);
228
+ for (const u of plan.modified) ui.info(` ${ui.c.yellow('~')} ${u.relPath}${authorNote(u)}`);
192
229
  for (const f of plan.failed) ui.info(` ${ui.c.red('!')} ${f.relPath} — ${f.reason}`);
193
230
  ui.info('');
231
+ if (!myId && plan.modified.length > 0 && attribution.index.size > 0) {
232
+ ui.info(
233
+ ui.c.dim(
234
+ '(con token de acceso personal no sabemos tu id de usuario: ' +
235
+ 'el autor se muestra, pero no se marca cuál sos vos)'
236
+ )
237
+ );
238
+ ui.info('');
239
+ }
240
+ if (touchedByOthers.length > 0) {
241
+ ui.warn(
242
+ `${touchedByOthers.length} de esos archivos los editó otra persona: ` +
243
+ `este push pisaría su versión.`
244
+ );
245
+ ui.info(ui.c.dim('Revisalos con "shilo diff <archivo>" o traelos con "shilo pull".'));
246
+ ui.info('');
247
+ }
194
248
  ui.info(ui.c.dim('(--dry-run: no se subió nada)'));
195
249
  return plan.failed.length ? 1 : 0;
196
250
  }
197
251
 
198
- // 7) Confirmar (salvo --yes).
252
+ // 7) Confirmar (salvo --yes). Si vamos a pisar trabajo ajeno, lo decimos
253
+ // antes de preguntar: es el caso que motivó todo este flujo.
254
+ if (touchedByOthers.length > 0) {
255
+ ui.warn(`Vas a pisar ${touchedByOthers.length} archivo(s) editado(s) por otra persona:`);
256
+ for (const u of touchedByOthers) ui.info(` ${ui.c.red('!')} ${u.relPath}${authorNote(u)}`);
257
+ ui.info('');
258
+ }
259
+
199
260
  if (!args.yes) {
200
261
  const ok = await prompt.confirm(`¿Subir ${toWrite} archivo(s) al proyecto?`, true);
201
262
  if (!ok) {
@@ -209,57 +270,94 @@ async function push(args) {
209
270
  const ensuredCache = new Set();
210
271
  const failedWrites = [];
211
272
 
273
+ // 8a) Asegurar carpetas primero, EN SERIE. `ensuredCache` evita re-listar una
274
+ // carpeta ya confirmada, pero además evita la carrera real: el backend no
275
+ // siempre responde 409 de forma confiable si dos requests intentan crear
276
+ // la misma carpeta al mismo tiempo, así que esta fase no se paraleliza.
277
+ // Como la mayoría de los archivos comparten carpetas, el cache hace que
278
+ // esto sea barato (proporcional a carpetas únicas, no a archivos).
279
+ const ensureFailed = new Set();
212
280
  for (const u of plan.added) {
213
281
  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);
282
+ const creds = await mgr.valid();
283
+ await client.ensurePath(projectId, u.relPath, creds, ensuredCache);
240
284
  } catch (e) {
285
+ ensureFailed.add(u.relPath);
241
286
  failedWrites.push({ relPath: u.relPath, reason: e.message });
242
287
  ui.error(`${u.relPath} — ${e.message}`);
243
288
  }
244
289
  }
290
+ config.persistCredsIfChanged(cfg, mgr.current);
291
+
292
+ // 8b) Subidas propiamente dichas, en paralelo: acá es donde vive el grueso
293
+ // del tiempo (un request por archivo), y a diferencia de ensurePath no
294
+ // hay riesgo de carrera porque cada archivo tiene una ruta propia.
295
+ const addedReady = plan.added.filter((u) => !ensureFailed.has(u.relPath));
296
+ const addedResults = await sync.runPool(addedReady, sync.CONCURRENCY, async (u) => {
297
+ const creds = await mgr.valid();
298
+ const created = await client.createFile(projectId, u.relPath, u.content, u.mimeType, creds);
299
+
300
+ if (created.created && created.file) {
301
+ return {
302
+ u,
303
+ symbol: '+',
304
+ change: {
305
+ file_id: created.file.id,
306
+ action: 'added',
307
+ new_storage_path: created.file.storage_path,
308
+ },
309
+ };
310
+ }
311
+ // 409: apareció entremedio; caemos a update.
312
+ const writeCreds = await mgr.valid();
313
+ const upd = await client.writeFile(projectId, u.relPath, u.content, u.mimeType, writeCreds);
314
+ return {
315
+ u,
316
+ symbol: '~',
317
+ change: {
318
+ file_id: upd.file.id,
319
+ action: 'modified',
320
+ previous_storage_path: (created.file && created.file.storage_path) || upd.file.storage_path,
321
+ new_storage_path: upd.file.storage_path,
322
+ },
323
+ };
324
+ });
245
325
 
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({
326
+ for (const r of addedResults) {
327
+ if (r && r.__error) {
328
+ failedWrites.push({ relPath: r.item.relPath, reason: r.__error });
329
+ ui.error(`${r.item.relPath} — ${r.__error}`);
330
+ continue;
331
+ }
332
+ changes.push(r.change);
333
+ ui.success(`${ui.c[r.symbol === '+' ? 'green' : 'yellow'](r.symbol)} ${r.u.relPath}`);
334
+ }
335
+ config.persistCredsIfChanged(cfg, mgr.current);
336
+
337
+ const modifiedResults = await sync.runPool(plan.modified, sync.CONCURRENCY, async (u) => {
338
+ const creds = await mgr.valid();
339
+ const upd = await client.writeFile(projectId, u.relPath, u.content, u.mimeType, creds);
340
+ return {
341
+ u,
342
+ change: {
251
343
  file_id: upd.file.id,
252
344
  action: 'modified',
253
345
  previous_storage_path: u.remote ? u.remote.storage_path : undefined,
254
346
  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}`);
347
+ },
348
+ };
349
+ });
350
+
351
+ for (const r of modifiedResults) {
352
+ if (r && r.__error) {
353
+ failedWrites.push({ relPath: r.item.relPath, reason: r.__error });
354
+ ui.error(`${r.item.relPath} — ${r.__error}`);
355
+ continue;
261
356
  }
357
+ changes.push(r.change);
358
+ ui.success(`${ui.c.yellow('~')} ${r.u.relPath}`);
262
359
  }
360
+ config.persistCredsIfChanged(cfg, mgr.current);
263
361
 
264
362
  // 9) Commit(s) en lotes de <=100.
265
363
  let committed = 0;
@@ -269,12 +367,12 @@ async function push(args) {
269
367
  for (let i = 0; i < total; i += COMMIT_BATCH) {
270
368
  const batch = changes.slice(i, i + COMMIT_BATCH);
271
369
  const partNo = Math.floor(i / COMMIT_BATCH) + 1;
272
- const msg = buildMessage(message, batch.length, total, partNo, nBatches);
370
+ const msg = buildMessage(message, batch.length, total, partNo, nBatches, card);
273
371
  try {
274
- const result = await client.createCommit(projectId, msg, batch, creds);
275
- creds = result.credentials;
372
+ const creds = await mgr.valid();
373
+ await client.createCommit(projectId, msg, batch, creds);
276
374
  committed += batch.length;
277
- config.persistCredsIfChanged(cfg, creds);
375
+ config.persistCredsIfChanged(cfg, mgr.current);
278
376
  } catch (e) {
279
377
  ui.error(`Fallo al crear el commit (lote ${partNo}/${nBatches}): ${e.message}`);
280
378
  failedWrites.push({ relPath: `<commit lote ${partNo}>`, reason: e.message });
@@ -282,9 +380,15 @@ async function push(args) {
282
380
  }
283
381
  }
284
382
 
285
- config.persistCredsIfChanged(cfg, creds);
383
+ config.persistCredsIfChanged(cfg, mgr.current);
384
+
385
+ // 10) Anotar la sincronización: `status` la usa después para saber qué del
386
+ // remoto es posterior a lo último que subimos.
387
+ if (failedWrites.length === 0) {
388
+ remote.touchSync(dir, { projectId, baseURL });
389
+ }
286
390
 
287
- // 10) Resumen final.
391
+ // 11) Resumen final.
288
392
  ui.info('');
289
393
  const nCommits = Math.ceil(changes.length / COMMIT_BATCH);
290
394
  if (failedWrites.length === 0) {
@@ -297,10 +401,21 @@ async function push(args) {
297
401
  return 1;
298
402
  }
299
403
 
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;
404
+ /**
405
+ * Arma el mensaje del commit. Si vino `--card`, se agrega como trailer
406
+ * `[card: <id>]` al final: queda visible en el historial y es fácil de grepear
407
+ * para cruzar un cambio con la tarjeta que lo pidió (Metrónomo u otro tablero).
408
+ * El backend corta en 500 caracteres, así que recortamos el mensaje del usuario
409
+ * antes que el trailer.
410
+ */
411
+ function buildMessage(userMsg, batchCount, total, partNo, nBatches, card) {
412
+ let base = userMsg || `shilo push: ${total} archivo(s)`;
413
+ if (nBatches > 1) base = `${base} (${partNo}/${nBatches})`;
414
+ if (!card) return base.slice(0, 500);
415
+
416
+ const trailer = ` [card: ${card}]`;
417
+ const room = 500 - trailer.length;
418
+ return `${base.slice(0, room)}${trailer}`;
304
419
  }
305
420
 
306
421
  module.exports = push;