shilo-cli 0.7.0 → 0.9.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 +338 -255
- package/bin/shilo.js +211 -165
- package/package.json +1 -1
- package/src/ShiloClient.js +1066 -837
- package/src/args.js +32 -8
- package/src/checks.js +57 -0
- package/src/commands/deploys.js +105 -0
- package/src/commands/diff.js +19 -3
- package/src/commands/export.js +226 -0
- package/src/commands/init.js +8 -0
- package/src/commands/log.js +4 -0
- package/src/commands/pull.js +86 -2
- package/src/commands/push.js +668 -69
- package/src/commands/review.js +101 -0
- package/src/commands/status.js +148 -14
- package/src/exportgit.js +417 -0
- package/src/gitobjects.js +532 -0
- package/src/projectconfig.js +76 -0
- package/src/safelist.js +101 -0
- package/src/secrets.js +75 -9
- package/src/sync.js +56 -5
- package/src/walk.js +30 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const config = require('../config');
|
|
4
|
+
const ui = require('../ui');
|
|
5
|
+
const sync = require('../sync');
|
|
6
|
+
const { resolveContext } = require('../context');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* shilo review [versión] [--aprobado | --reparos | --rechazado] [-m <nota>] [--json]
|
|
10
|
+
*
|
|
11
|
+
* Revisión humana (Etapa 4). Sin veredicto, lista quién revisó esa versión y
|
|
12
|
+
* qué dijo. Con uno, deja constancia. La versión es `actual` si no se dice
|
|
13
|
+
* otra cosa, o lo que imprime `shilo log` (alcanza un prefijo de 7 caracteres).
|
|
14
|
+
*
|
|
15
|
+
* El positional es una versión, no una carpeta: el contexto se resuelve siempre
|
|
16
|
+
* sobre el directorio actual, igual que en `log`.
|
|
17
|
+
*/
|
|
18
|
+
const FLAGS = { aprobado: 'aprobado', reparos: 'con_reparos', rechazado: 'rechazado' };
|
|
19
|
+
|
|
20
|
+
const ETIQUETAS = {
|
|
21
|
+
aprobado: 'aprobado',
|
|
22
|
+
con_reparos: 'con reparos',
|
|
23
|
+
rechazado: 'rechazado',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
async function review(args) {
|
|
27
|
+
const version = typeof args._[0] === 'string' && args._[0].trim() ? args._[0].trim() : 'actual';
|
|
28
|
+
const elegidos = Object.keys(FLAGS).filter((f) => args[f]);
|
|
29
|
+
if (elegidos.length > 1) {
|
|
30
|
+
ui.error('Elegí un solo veredicto: --aprobado, --reparos o --rechazado.');
|
|
31
|
+
return 1;
|
|
32
|
+
}
|
|
33
|
+
const verdict = elegidos.length ? FLAGS[elegidos[0]] : null;
|
|
34
|
+
const note = typeof args.message === 'string' ? args.message.trim() : '';
|
|
35
|
+
|
|
36
|
+
if (!verdict && note) {
|
|
37
|
+
ui.error('La nota va con un veredicto: --aprobado, --reparos o --rechazado.');
|
|
38
|
+
return 1;
|
|
39
|
+
}
|
|
40
|
+
// La 017 lo exige igual; avisarlo acá ahorra un viaje y dice por qué.
|
|
41
|
+
if (verdict && verdict !== 'aprobado' && !note) {
|
|
42
|
+
ui.error('Un reparo o un rechazo necesita una nota que diga qué falta: -m "<nota>".');
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const ctx = resolveContext({ ...args, _: [] });
|
|
47
|
+
if (!ctx.ok) return ctx.code;
|
|
48
|
+
const { projectId, cfg, client, mgr } = ctx;
|
|
49
|
+
|
|
50
|
+
let result;
|
|
51
|
+
try {
|
|
52
|
+
const creds = await mgr.valid();
|
|
53
|
+
result = verdict
|
|
54
|
+
? await client.review(projectId, { version, verdict, note }, creds)
|
|
55
|
+
: await client.listReviews(projectId, version, creds);
|
|
56
|
+
} catch (e) {
|
|
57
|
+
ui.error(e.message);
|
|
58
|
+
return 1;
|
|
59
|
+
} finally {
|
|
60
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (args.json) {
|
|
64
|
+
console.log(JSON.stringify({ projectId, version: result.version, reviews: result.reviews }, null, 2));
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const corto = result.version ? String(result.version).slice(0, 8) : version;
|
|
69
|
+
if (verdict) ui.success(`Revisión registrada: ${ETIQUETAS[verdict]} (${corto}).`);
|
|
70
|
+
|
|
71
|
+
ui.info(`Revisiones de ${ui.c.yellow(corto)}`);
|
|
72
|
+
ui.info('');
|
|
73
|
+
if (result.reviews.length === 0) {
|
|
74
|
+
ui.info('Nadie la revisó todavía.');
|
|
75
|
+
ui.info(ui.c.dim('Para revisarla: shilo review ' + (version === 'actual' ? '' : version + ' ') + '--aprobado'));
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
for (const r of result.reviews) {
|
|
79
|
+
ui.info(`${pintar(r.verdict)} ${ui.c.bold(sync.describeAuthor(r.reviewer))} ${ui.c.dim(sync.formatDate(r.reviewed_at))}`);
|
|
80
|
+
if (r.note) ui.info(` ${r.note}`);
|
|
81
|
+
}
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function pintar(verdict) {
|
|
86
|
+
const texto = ETIQUETAS[verdict] || verdict;
|
|
87
|
+
if (verdict === 'aprobado') return ui.c.green(texto);
|
|
88
|
+
if (verdict === 'rechazado') return ui.c.red(texto);
|
|
89
|
+
return ui.c.yellow(texto);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Una marca corta para `shilo log`: la revisión más reciente manda. */
|
|
93
|
+
function marcaDeRevision(reviews) {
|
|
94
|
+
if (!Array.isArray(reviews) || reviews.length === 0) return '';
|
|
95
|
+
const ultima = reviews[0];
|
|
96
|
+
const extra = reviews.length > 1 ? ` +${reviews.length - 1}` : '';
|
|
97
|
+
return `${pintar(ultima.verdict)} por ${sync.describeAuthor(ultima.reviewer)}${extra}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = review;
|
|
101
|
+
module.exports.marcaDeRevision = marcaDeRevision;
|
package/src/commands/status.js
CHANGED
|
@@ -6,19 +6,29 @@ const sync = require('../sync');
|
|
|
6
6
|
const remote = require('../remote');
|
|
7
7
|
const { resolveContext } = require('../context');
|
|
8
8
|
|
|
9
|
+
// A partir de cuántos días sin sincronizar se recuerda hacerlo.
|
|
10
|
+
const STALE_DAYS = 30;
|
|
11
|
+
|
|
9
12
|
/**
|
|
10
13
|
* shilo status [carpeta] [--project <uuid>] [--base-url <url>] [--short] [--json]
|
|
11
14
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
+
* Compara la carpeta local contra el remoto SIN bajar contenido y sin escribir
|
|
16
|
+
* nada en el servidor. Contesta las preguntas que uno se hace antes de arrancar
|
|
17
|
+
* a trabajar:
|
|
15
18
|
*
|
|
16
19
|
* - ¿el remoto tiene cambios que no tengo? (y de quién son)
|
|
17
20
|
* - ¿tengo cambios locales sin subir?
|
|
21
|
+
* - ¿hay algo mío esperando aprobación?
|
|
22
|
+
*
|
|
23
|
+
* La comparación es por ruta + tamaño (un listado por carpeta, cero descargas)
|
|
24
|
+
* y, cuando el tamaño coincide, por el SHA-256 que el remoto publica en el
|
|
25
|
+
* listado (`sync.refineByHash`): sigue sin bajar nada.
|
|
18
26
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
27
|
+
* Lo único que escribe es `.shilo.json`, y sólo cuando la carpeta coincide
|
|
28
|
+
* entera con la versión actual: ahí anota la fecha y la `base`. Es el caso de
|
|
29
|
+
* una propuesta aprobada en la web, que si no dejaba "Al día" al lado de
|
|
30
|
+
* "última sincronización hace 2 meses" y al push siguiente partiendo de una
|
|
31
|
+
* base vieja.
|
|
22
32
|
*/
|
|
23
33
|
async function status(args) {
|
|
24
34
|
const ctx = resolveContext(args);
|
|
@@ -52,15 +62,46 @@ async function status(args) {
|
|
|
52
62
|
const attribution = await sync.buildAuthorIndex(client, projectId, mgr, { commits: 50 });
|
|
53
63
|
config.persistCredsIfChanged(cfg, mgr.current);
|
|
54
64
|
|
|
55
|
-
|
|
56
|
-
|
|
65
|
+
// 2b) Propuestas abiertas (best-effort: un servidor viejo no las tiene). De
|
|
66
|
+
// paso dicen quién soy —con token personal la sesión no lo trae—, si soy
|
|
67
|
+
// el dueño, y cuál es la versión actual.
|
|
68
|
+
let listed = null;
|
|
69
|
+
try {
|
|
70
|
+
listed = await client.listProposals(projectId, await mgr.valid(), { state: 'abierta' });
|
|
71
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
72
|
+
} catch (_) {
|
|
73
|
+
listed = null;
|
|
74
|
+
}
|
|
75
|
+
const viewer = (listed && listed.viewer) || null;
|
|
76
|
+
|
|
77
|
+
const me =
|
|
78
|
+
cfg.credentials && cfg.credentials.user ? cfg.credentials.user.id : viewer ? viewer.id : null;
|
|
79
|
+
const cmp = sync.refineByHash(sync.compareTrees(local.files, tree.files), {
|
|
80
|
+
dir,
|
|
81
|
+
projectId,
|
|
82
|
+
baseURL,
|
|
83
|
+
});
|
|
57
84
|
|
|
58
85
|
// 3) "Detrás del remoto" = lo que otro subió y yo no tengo igual: archivos
|
|
59
86
|
// que faltan localmente, o que difieren y cambiaron después de mi lastSync.
|
|
87
|
+
//
|
|
88
|
+
// Lo que está ignorado localmente NO cuenta como "atrás": `walkLocal` no
|
|
89
|
+
// lo ve, así que quedaría en `onlyRemote` para siempre y el exit 2 —que
|
|
90
|
+
// significa "pull antes de trabajar"— se volvería ruido permanente. Se
|
|
91
|
+
// informa aparte: que alguien haya subido tu `settings.local.json` importa,
|
|
92
|
+
// pero no es trabajo que te falte bajar.
|
|
93
|
+
const isIgnoredPath = sync.makePathMatcher(dir);
|
|
94
|
+
|
|
60
95
|
const behind = [];
|
|
96
|
+
const ignoredRemote = [];
|
|
61
97
|
for (const item of cmp.onlyRemote) {
|
|
98
|
+
if (isIgnoredPath(item.repoPath)) {
|
|
99
|
+
ignoredRemote.push({ ...item, kind: 'ignorado local' });
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
62
102
|
behind.push({ ...item, kind: 'falta local' });
|
|
63
103
|
}
|
|
104
|
+
ignoredRemote.sort((a, b) => a.repoPath.localeCompare(b.repoPath));
|
|
64
105
|
for (const item of cmp.differing) {
|
|
65
106
|
if (sync.changedSinceSync(item.remote, lastSync) !== false) {
|
|
66
107
|
behind.push({ ...item, kind: 'difiere' });
|
|
@@ -85,6 +126,34 @@ async function status(args) {
|
|
|
85
126
|
};
|
|
86
127
|
};
|
|
87
128
|
|
|
129
|
+
// 4b) Propuestas abiertas: las mías (esperan aprobación) y, si soy el dueño,
|
|
130
|
+
// las de los demás (esperan mi decisión).
|
|
131
|
+
const openProposals = listed ? listed.proposals : [];
|
|
132
|
+
const mineOpen = me ? openProposals.filter((p) => p.author_id === me) : [];
|
|
133
|
+
const othersOpen = viewer && viewer.is_owner ? openProposals.filter((p) => p.author_id !== me) : [];
|
|
134
|
+
const projectInfo = (listed && listed.project) || null;
|
|
135
|
+
const owner = projectInfo && projectInfo.owner;
|
|
136
|
+
const ownerName = owner ? owner.display_name || owner.username : null;
|
|
137
|
+
const linkTo = (p) =>
|
|
138
|
+
projectInfo && projectInfo.path
|
|
139
|
+
? `${baseURL.replace(/\/+$/, '')}${projectInfo.path}?propuesta=${p.id.slice(0, 8)}`
|
|
140
|
+
: null;
|
|
141
|
+
|
|
142
|
+
// 4c) Carpeta idéntica a la versión actual → anotarlo (ver cabecera).
|
|
143
|
+
const aligned = behind.length === 0 && ahead.length === 0 && tree.errors.length === 0;
|
|
144
|
+
let syncedAt = lastSync;
|
|
145
|
+
if (aligned && listed && listed.main) {
|
|
146
|
+
const stale = !savedRemote || savedRemote.base !== listed.main || !lastSync;
|
|
147
|
+
if (stale && remote.touchSync(dir, { projectId, baseURL, base: listed.main })) {
|
|
148
|
+
syncedAt = new Date().toISOString();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 4d) Hace cuánto que esta carpeta no se alinea con el proyecto.
|
|
153
|
+
const daysSinceSync = syncedAt
|
|
154
|
+
? Math.floor((Date.now() - new Date(syncedAt).getTime()) / 86400000)
|
|
155
|
+
: null;
|
|
156
|
+
|
|
88
157
|
if (asJson) {
|
|
89
158
|
console.log(
|
|
90
159
|
JSON.stringify(
|
|
@@ -92,11 +161,16 @@ async function status(args) {
|
|
|
92
161
|
projectId,
|
|
93
162
|
baseURL,
|
|
94
163
|
dir,
|
|
95
|
-
lastSync:
|
|
164
|
+
lastSync: syncedAt || null,
|
|
165
|
+
pendingProposals: mineOpen.map((p) => ({ id: p.id, title: p.title, createdAt: p.created_at, url: linkTo(p) })),
|
|
166
|
+
awaitingMyDecision: othersOpen.map((p) => ({ id: p.id, title: p.title, url: linkTo(p) })),
|
|
96
167
|
remoteFiles: tree.files.length,
|
|
97
168
|
localFiles: local.files.length,
|
|
98
169
|
behind: behind.map((i) => ({ path: i.repoPath, kind: i.kind, lastCommit: decorate(i) })),
|
|
99
170
|
ahead: ahead.map((i) => ({ path: i.repoPath, kind: i.kind })),
|
|
171
|
+
// Campo aparte a propósito: si fueran parte de `behind`, un script
|
|
172
|
+
// volvería a leer "estás desactualizado" por algo que nunca vas a bajar.
|
|
173
|
+
ignoredRemote: ignoredRemote.map((i) => ({ path: i.repoPath })),
|
|
100
174
|
unchanged: cmp.same.length,
|
|
101
175
|
listErrors: tree.errors,
|
|
102
176
|
},
|
|
@@ -113,8 +187,8 @@ async function status(args) {
|
|
|
113
187
|
}
|
|
114
188
|
|
|
115
189
|
ui.info('');
|
|
116
|
-
if (
|
|
117
|
-
ui.info(ui.c.dim(`Última sincronización de esta carpeta: ${sync.formatDate(
|
|
190
|
+
if (syncedAt) {
|
|
191
|
+
ui.info(ui.c.dim(`Última sincronización de esta carpeta: ${sync.formatDate(syncedAt)}`));
|
|
118
192
|
} else {
|
|
119
193
|
ui.info(
|
|
120
194
|
ui.c.dim(
|
|
@@ -125,8 +199,52 @@ async function status(args) {
|
|
|
125
199
|
}
|
|
126
200
|
ui.info('');
|
|
127
201
|
|
|
202
|
+
/** Los ignorados que existen en el remoto: una línea, o la lista con --show-ignored. */
|
|
203
|
+
const reportIgnored = () => {
|
|
204
|
+
if (ignoredRemote.length === 0) return;
|
|
205
|
+
ui.info(
|
|
206
|
+
ui.c.dim(
|
|
207
|
+
`${ignoredRemote.length} archivo(s) existen en el remoto pero están ignorados localmente` +
|
|
208
|
+
(args['show-ignored'] ? ':' : ' (--show-ignored para verlos).')
|
|
209
|
+
)
|
|
210
|
+
);
|
|
211
|
+
if (args['show-ignored']) {
|
|
212
|
+
for (const item of ignoredRemote) ui.info(` ${ui.c.dim('·')} ${ui.c.dim(item.repoPath)}`);
|
|
213
|
+
}
|
|
214
|
+
ui.info('');
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/** Propuestas abiertas: lo primero que hay que saber, antes que la lista de archivos. */
|
|
218
|
+
const reportProposals = () => {
|
|
219
|
+
if (mineOpen.length > 0) {
|
|
220
|
+
const who = viewer && viewer.is_owner ? 'tu aprobación' : ownerName ? `la aprobación de ${ownerName}` : 'aprobación';
|
|
221
|
+
ui.info(ui.c.bold(`Tenés ${mineOpen.length} propuesta(s) esperando ${who}:`));
|
|
222
|
+
for (const p of mineOpen) {
|
|
223
|
+
const link = linkTo(p);
|
|
224
|
+
ui.info(
|
|
225
|
+
` ${ui.c.yellow('⧗')} ${p.title || p.id.slice(0, 8)} ${ui.c.dim(`— ${sync.formatDate(p.created_at)}`)}` +
|
|
226
|
+
(link ? `\n ${ui.c.cyan(link)}` : '')
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
ui.info('');
|
|
230
|
+
}
|
|
231
|
+
if (othersOpen.length > 0) {
|
|
232
|
+
ui.info(ui.c.bold(`${othersOpen.length} propuesta(s) de otras personas esperan tu decisión:`));
|
|
233
|
+
for (const p of othersOpen) {
|
|
234
|
+
const by = p.author ? p.author.display_name || p.author.username : 'alguien';
|
|
235
|
+
const link = linkTo(p);
|
|
236
|
+
ui.info(` ${ui.c.yellow('⧗')} ${p.title || p.id.slice(0, 8)} ${ui.c.dim(`— de ${by}`)}` + (link ? `\n ${ui.c.cyan(link)}` : ''));
|
|
237
|
+
}
|
|
238
|
+
ui.info('');
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
reportProposals();
|
|
243
|
+
|
|
128
244
|
if (behind.length === 0 && ahead.length === 0) {
|
|
129
|
-
ui.success(`Al día: ${cmp.same.length} archivo(s) coinciden con el
|
|
245
|
+
ui.success(`Al día: ${cmp.same.length} archivo(s) coinciden con el proyecto.`);
|
|
246
|
+
ui.info('');
|
|
247
|
+
reportIgnored();
|
|
130
248
|
return 0;
|
|
131
249
|
}
|
|
132
250
|
|
|
@@ -148,7 +266,10 @@ async function status(args) {
|
|
|
148
266
|
}
|
|
149
267
|
|
|
150
268
|
if (ahead.length > 0) {
|
|
151
|
-
ui.info(
|
|
269
|
+
ui.info(
|
|
270
|
+
ui.c.bold(`Tenés ${ahead.length} cambio(s) local(es) que todavía no son parte del proyecto`) +
|
|
271
|
+
(mineOpen.length > 0 ? ui.c.bold(' (pueden estar en tu propuesta pendiente):') : ui.c.bold(':'))
|
|
272
|
+
);
|
|
152
273
|
for (const item of ahead) {
|
|
153
274
|
const mark = item.kind === 'nuevo local' ? ui.c.green('+') : ui.c.yellow('~');
|
|
154
275
|
ui.info(` ${mark} ${item.repoPath}`);
|
|
@@ -156,9 +277,11 @@ async function status(args) {
|
|
|
156
277
|
ui.info('');
|
|
157
278
|
}
|
|
158
279
|
|
|
280
|
+
reportIgnored();
|
|
281
|
+
|
|
159
282
|
ui.info(
|
|
160
283
|
ui.c.dim(
|
|
161
|
-
` ${cmp.same.length} sin cambios
|
|
284
|
+
` ${cmp.same.length} sin cambios ` +
|
|
162
285
|
`${local.ignored} ignorados ${local.skipped.length} saltados`
|
|
163
286
|
)
|
|
164
287
|
);
|
|
@@ -175,10 +298,21 @@ async function status(args) {
|
|
|
175
298
|
ui.info(ui.c.dim('Mirá "shilo diff <archivo>" antes de decidir.'));
|
|
176
299
|
} else if (behind.length > 0) {
|
|
177
300
|
ui.info(ui.c.dim('Sugerido: "shilo pull --dry-run" para ver qué bajaría.'));
|
|
301
|
+
} else if (mineOpen.length > 0) {
|
|
302
|
+
ui.info(ui.c.dim('Tus cambios ya están guardados: falta que se apruebe la propuesta de arriba.'));
|
|
178
303
|
} else {
|
|
179
304
|
ui.info(ui.c.dim('Sugerido: "shilo push --dry-run" para ver qué subiría.'));
|
|
180
305
|
}
|
|
181
306
|
|
|
307
|
+
// Recordatorio: sincronizar seguido da propuestas chicas y fáciles de revisar.
|
|
308
|
+
if (ahead.length > 0 && daysSinceSync !== null && daysSinceSync >= STALE_DAYS) {
|
|
309
|
+
ui.info('');
|
|
310
|
+
ui.warn(
|
|
311
|
+
`Hace ${daysSinceSync} días que esta carpeta no se sincroniza. Subí seguido: ` +
|
|
312
|
+
'cuanto más chico el cambio, más fácil de revisar y menos choques con lo de otros.'
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
182
316
|
// Exit code 2 = "estás desactualizado". Útil para encadenar en scripts:
|
|
183
317
|
// `shilo status || echo "bajá antes de trabajar"`.
|
|
184
318
|
return behind.length > 0 ? 2 : 0;
|