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/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# shilo-cli
|
|
2
|
+
|
|
3
|
+
CLI para subir carpetas locales a repositorios de [Shilo](https://shilo-six.vercel.app),
|
|
4
|
+
con una experiencia tipo git: `shilo login` una vez y después `shilo push`.
|
|
5
|
+
|
|
6
|
+
> **Estado:** Fase 3 (push). Disponibles `login`, `whoami`, `logout`, `init`, `push`.
|
|
7
|
+
> El flujo completo tipo git ya funciona: `shilo login` → `shilo init` → `shilo push`.
|
|
8
|
+
|
|
9
|
+
## Requisitos
|
|
10
|
+
|
|
11
|
+
- Node.js **>= 18** (usa `fetch` y `crypto` nativos; sin dependencias de runtime).
|
|
12
|
+
|
|
13
|
+
## Instalación (local, durante desarrollo)
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
cd cli
|
|
17
|
+
npm link # expone el comando `shilo` global
|
|
18
|
+
# o, sin link:
|
|
19
|
+
node bin/shilo.js <comando>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Uso
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
shilo login # pregunta email y contraseña (oculta)
|
|
26
|
+
shilo login --email you@example.com
|
|
27
|
+
shilo whoami # muestra la sesión activa
|
|
28
|
+
shilo logout # borra las credenciales locales
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Subir una carpeta (flujo tipo git)
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
cd mi-carpeta
|
|
35
|
+
shilo init --project <uuid> # ata la carpeta al repo (escribe .shilo.json)
|
|
36
|
+
shilo push # sube: crea/actualiza + commit
|
|
37
|
+
shilo push -m "mi mensaje" # con mensaje de commit
|
|
38
|
+
shilo push --dry-run # muestra qué haría sin subir
|
|
39
|
+
shilo push --yes # sin pedir confirmación
|
|
40
|
+
shilo push ./otra-carpeta --project <uuid> # sin init, proyecto explícito
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Sólo se suben archivos de **texto**; binarios, oversize (>25 MB) y nombres con
|
|
44
|
+
caracteres no permitidos se saltan con aviso. Los archivos sin cambios no se
|
|
45
|
+
re-suben. Ignorá rutas con un `.shiloignore` (sintaxis estilo `.gitignore`).
|
|
46
|
+
|
|
47
|
+
### Servidor alternativo
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
shilo login --base-url https://mi-shilo.vercel.app
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Credenciales
|
|
54
|
+
|
|
55
|
+
Se guardan en `~/.shilo/config.json` (override con `SHILO_CONFIG_DIR`), **cifradas**
|
|
56
|
+
con AES-256-CBC y con permisos `0600`.
|
|
57
|
+
|
|
58
|
+
- Por defecto la clave de cifrado se deriva de la máquina (hostname + usuario).
|
|
59
|
+
Esto es **ofuscación en reposo**, no secreto fuerte.
|
|
60
|
+
- Para cifrado fuerte, exportá `SHILO_ENCRYPTION_KEY` (una passphrase propia) antes
|
|
61
|
+
de `shilo login`; necesitarás la misma variable para futuras ejecuciones.
|
|
62
|
+
|
|
63
|
+
Se almacena el JWT, el refresh token, la expiración y el email; **no** se guarda la
|
|
64
|
+
contraseña.
|
|
65
|
+
|
|
66
|
+
## Variables de entorno
|
|
67
|
+
|
|
68
|
+
| Variable | Efecto |
|
|
69
|
+
| ----------------------- | ------------------------------------------------------------- |
|
|
70
|
+
| `SHILO_CONFIG_DIR` | Directorio de config (default `~/.shilo`). |
|
|
71
|
+
| `SHILO_ENCRYPTION_KEY` | Passphrase para cifrar credenciales (recomendado). |
|
|
72
|
+
| `NO_COLOR` | Desactiva colores ANSI. |
|
|
73
|
+
|
|
74
|
+
## Roadmap
|
|
75
|
+
|
|
76
|
+
- **Fase 1 — auth** *(hecho)*: `login`, `whoami`, `logout` + config cifrada.
|
|
77
|
+
- **Fase 2 — motor** *(hecho)*: cliente de archivos (`list`/`getMeta`/`create`/`write`/
|
|
78
|
+
`ensurePath`/`commit`) + recorrido de árbol (`.shiloignore`) + detección de binarios.
|
|
79
|
+
- **Fase 3 — push** *(hecho)*: `shilo init` + `shilo push` (crea/actualiza + commit,
|
|
80
|
+
detección de cambios, `.shiloignore`, `--dry-run`).
|
package/bin/shilo.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { parseArgs } = require('../src/args');
|
|
5
|
+
const ui = require('../src/ui');
|
|
6
|
+
|
|
7
|
+
const VERSION = require('../package.json').version;
|
|
8
|
+
|
|
9
|
+
const HELP = `
|
|
10
|
+
${ui.c.bold('shilo')} — sube carpetas locales a repositorios de Shilo.
|
|
11
|
+
|
|
12
|
+
${ui.c.bold('Uso:')}
|
|
13
|
+
shilo <comando> [opciones]
|
|
14
|
+
|
|
15
|
+
${ui.c.bold('Comandos:')}
|
|
16
|
+
login Inicia sesión y guarda credenciales cifradas (~/.shilo/config.json).
|
|
17
|
+
whoami Muestra la sesión activa.
|
|
18
|
+
logout Cierra sesión (borra credenciales locales).
|
|
19
|
+
init [carpeta] Ata una carpeta a un repo de Shilo (--project <uuid>).
|
|
20
|
+
push [carpeta] Sube una carpeta al repo de Shilo (crea/actualiza + commit).
|
|
21
|
+
help Muestra esta ayuda.
|
|
22
|
+
|
|
23
|
+
${ui.c.bold('Opciones de login:')}
|
|
24
|
+
--email <e> Email (si se omite, se pregunta).
|
|
25
|
+
--password <p> Contraseña (si se omite, se pregunta oculta).
|
|
26
|
+
--token <pat> Token de acceso personal (en vez de email + contraseña).
|
|
27
|
+
--base-url <url> Servidor Shilo (default: https://shilo-six.vercel.app).
|
|
28
|
+
|
|
29
|
+
${ui.c.bold('Opciones de init:')}
|
|
30
|
+
--project <uuid> Id del proyecto de Shilo (requerido).
|
|
31
|
+
--token <pat> Token de acceso personal (evita el "login" previo).
|
|
32
|
+
--push Sube la carpeta apenas queda atada.
|
|
33
|
+
--base-url <url> Servidor Shilo.
|
|
34
|
+
--force No verifica acceso ni pregunta antes de sobrescribir.
|
|
35
|
+
|
|
36
|
+
${ui.c.bold('Opciones de push:')}
|
|
37
|
+
--project <uuid> Proyecto destino (pisa el .shilo.json de la carpeta).
|
|
38
|
+
-m, --message <m> Mensaje del commit.
|
|
39
|
+
--dry-run Muestra qué haría, sin subir nada.
|
|
40
|
+
--yes No pide confirmación.
|
|
41
|
+
--base-url <url> Servidor Shilo.
|
|
42
|
+
|
|
43
|
+
${ui.c.bold('Globales:')}
|
|
44
|
+
-v, --version Versión del CLI.
|
|
45
|
+
-h, --help Ayuda.
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
const COMMANDS = {
|
|
49
|
+
login: () => require('../src/commands/login'),
|
|
50
|
+
whoami: () => require('../src/commands/whoami'),
|
|
51
|
+
logout: () => require('../src/commands/logout'),
|
|
52
|
+
init: () => require('../src/commands/init'),
|
|
53
|
+
push: () => require('../src/commands/push'),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
async function main() {
|
|
57
|
+
const argv = process.argv.slice(2);
|
|
58
|
+
const parsed = parseArgs(argv, {
|
|
59
|
+
// 'push' tiene que estar acá: la lista de booleanos es global, y sin
|
|
60
|
+
// registrarlo `shilo init --project X --push .` se comería el `.` como
|
|
61
|
+
// valor de --push, perdiendo el positional de la carpeta.
|
|
62
|
+
booleans: ['help', 'version', 'h', 'v', 'dry-run', 'yes', 'force', 'push'],
|
|
63
|
+
aliases: { h: 'help', v: 'version', m: 'message' },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const cmd = parsed._[0];
|
|
67
|
+
|
|
68
|
+
if (parsed.version && !cmd) {
|
|
69
|
+
console.log(VERSION);
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
if (!cmd || cmd === 'help' || (parsed.help && !cmd)) {
|
|
73
|
+
console.log(HELP);
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const loader = COMMANDS[cmd];
|
|
78
|
+
if (!loader) {
|
|
79
|
+
ui.error(`Comando desconocido: "${cmd}".`);
|
|
80
|
+
console.log(HELP);
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (parsed.help) {
|
|
85
|
+
console.log(HELP);
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Los positionals del comando (sin el nombre del comando).
|
|
90
|
+
const commandArgs = { ...parsed, _: parsed._.slice(1) };
|
|
91
|
+
const handler = loader();
|
|
92
|
+
return handler(commandArgs);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
main()
|
|
96
|
+
.then((code) => process.exit(typeof code === 'number' ? code : 0))
|
|
97
|
+
.catch((err) => {
|
|
98
|
+
ui.error(err && err.message ? err.message : String(err));
|
|
99
|
+
process.exit(1);
|
|
100
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "shilo-cli",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "CLI para subir carpetas locales a repositorios de Shilo (login + push tipo git).",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"shilo",
|
|
7
|
+
"cli",
|
|
8
|
+
"push",
|
|
9
|
+
"upload",
|
|
10
|
+
"repositorios"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://shilo-six.vercel.app",
|
|
13
|
+
"bin": {
|
|
14
|
+
"shilo": "bin/shilo.js"
|
|
15
|
+
},
|
|
16
|
+
"type": "commonjs",
|
|
17
|
+
"main": "src/index.js",
|
|
18
|
+
"files": [
|
|
19
|
+
"bin/",
|
|
20
|
+
"src/",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"start": "node bin/shilo.js"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT"
|
|
30
|
+
}
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cliente HTTP para la API de Shilo. Portado del ShiloClient de
|
|
5
|
+
* Bnei-Yerushalaim-Cloud, adaptado a `fetch` nativo (Node >=18, cero deps).
|
|
6
|
+
*
|
|
7
|
+
* Autenticación (Fase 1):
|
|
8
|
+
* - authenticate(email, password)
|
|
9
|
+
* - refreshToken(refreshToken) (requiere supabaseUrl + anonKey)
|
|
10
|
+
* - ensureValidToken(credentials)
|
|
11
|
+
*
|
|
12
|
+
* Archivos y commits (Fase 2):
|
|
13
|
+
* - listFiles / getFileMeta / readFile
|
|
14
|
+
* - createFile / writeFile / createFolder / ensurePath
|
|
15
|
+
* - createCommit
|
|
16
|
+
*
|
|
17
|
+
* Todos los métodos de red toman `credentials` y devuelven `{ ..., credentials }`
|
|
18
|
+
* con las credenciales posiblemente refrescadas, para que el caller las persista.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const DEFAULT_BASE_URL = 'https://shilo-six.vercel.app';
|
|
22
|
+
const DEFAULT_TIMEOUT_MS = 45000;
|
|
23
|
+
|
|
24
|
+
class ShiloError extends Error {
|
|
25
|
+
constructor(message, { status = null, body = null } = {}) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = 'ShiloError';
|
|
28
|
+
this.status = status;
|
|
29
|
+
this.body = body;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class ShiloClient {
|
|
34
|
+
constructor(options = {}) {
|
|
35
|
+
this.baseURL = (options.baseURL || DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
36
|
+
this.timeoutMs = Number(options.timeoutMs) || DEFAULT_TIMEOUT_MS;
|
|
37
|
+
// Opcionales: habilitan refresh de token sin re-login.
|
|
38
|
+
this.supabaseUrl = options.supabaseUrl || null;
|
|
39
|
+
this.supabaseAnonKey = options.supabaseAnonKey || null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async _fetch(url, { method = 'GET', headers = {}, body = null } = {}) {
|
|
43
|
+
const controller = new AbortController();
|
|
44
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
45
|
+
let res;
|
|
46
|
+
try {
|
|
47
|
+
res = await fetch(url, {
|
|
48
|
+
method,
|
|
49
|
+
headers,
|
|
50
|
+
body: body != null ? JSON.stringify(body) : undefined,
|
|
51
|
+
signal: controller.signal,
|
|
52
|
+
});
|
|
53
|
+
} catch (err) {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
if (err.name === 'AbortError') {
|
|
56
|
+
throw new ShiloError(`Timeout tras ${this.timeoutMs}ms: ${method} ${url}`);
|
|
57
|
+
}
|
|
58
|
+
throw new ShiloError(`Fallo de red: ${err.message}`);
|
|
59
|
+
}
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
|
|
62
|
+
const text = await res.text();
|
|
63
|
+
let data = null;
|
|
64
|
+
if (text) {
|
|
65
|
+
try {
|
|
66
|
+
data = JSON.parse(text);
|
|
67
|
+
} catch (_) {
|
|
68
|
+
data = text;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { res, data };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_errMessage(data, fallback) {
|
|
75
|
+
if (data && typeof data === 'object') {
|
|
76
|
+
if (typeof data.error === 'string') return data.error;
|
|
77
|
+
if (typeof data.message === 'string') return data.message;
|
|
78
|
+
return JSON.stringify(data);
|
|
79
|
+
}
|
|
80
|
+
if (typeof data === 'string' && data) return data;
|
|
81
|
+
return fallback;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Autentica contra /api/auth/login. Devuelve credenciales normalizadas.
|
|
86
|
+
*/
|
|
87
|
+
async authenticate(email, password) {
|
|
88
|
+
const { res, data } = await this._fetch(`${this.baseURL}/api/auth/login`, {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: { 'Content-Type': 'application/json' },
|
|
91
|
+
body: { email, password },
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (!res.ok) {
|
|
95
|
+
throw new ShiloError(
|
|
96
|
+
`Autenticación fallida: ${this._errMessage(data, `HTTP ${res.status}`)}`,
|
|
97
|
+
{ status: res.status, body: data }
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const session = data && data.data && data.data.session;
|
|
102
|
+
const user = data && data.data && data.data.user;
|
|
103
|
+
if (!session || !session.access_token) {
|
|
104
|
+
throw new ShiloError('Respuesta de login inesperada (sin session.access_token)', {
|
|
105
|
+
status: res.status,
|
|
106
|
+
body: data,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const expiresAt = new Date(Date.now() + (session.expires_in || 3600) * 1000).toISOString();
|
|
111
|
+
return {
|
|
112
|
+
jwt: session.access_token,
|
|
113
|
+
refreshToken: session.refresh_token || null,
|
|
114
|
+
expiresAt,
|
|
115
|
+
user: user ? { id: user.id, email: user.email } : null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Refresca el JWT con el refresh_token vía Supabase. Requiere supabaseUrl +
|
|
121
|
+
* supabaseAnonKey configurados; si no, lanza para que el caller pida re-login.
|
|
122
|
+
*/
|
|
123
|
+
async refreshToken(refreshToken) {
|
|
124
|
+
if (!this.supabaseUrl || !this.supabaseAnonKey) {
|
|
125
|
+
throw new ShiloError(
|
|
126
|
+
'Refresh no configurado (falta supabaseUrl/anonKey). Ejecutá "shilo login" de nuevo.'
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
const { res, data } = await this._fetch(
|
|
130
|
+
`${this.supabaseUrl.replace(/\/+$/, '')}/auth/v1/token?grant_type=refresh_token`,
|
|
131
|
+
{
|
|
132
|
+
method: 'POST',
|
|
133
|
+
headers: {
|
|
134
|
+
apikey: this.supabaseAnonKey,
|
|
135
|
+
'Content-Type': 'application/json',
|
|
136
|
+
},
|
|
137
|
+
body: { refresh_token: refreshToken },
|
|
138
|
+
}
|
|
139
|
+
);
|
|
140
|
+
if (!res.ok || !data || !data.access_token) {
|
|
141
|
+
throw new ShiloError('No se pudo refrescar el token. Re-autenticación requerida.', {
|
|
142
|
+
status: res.status,
|
|
143
|
+
body: data,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
const expiresAt = new Date(Date.now() + (data.expires_in || 3600) * 1000).toISOString();
|
|
147
|
+
return {
|
|
148
|
+
jwt: data.access_token,
|
|
149
|
+
refreshToken: data.refresh_token || refreshToken,
|
|
150
|
+
expiresAt,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Devuelve credenciales con JWT válido, refrescando si faltan <5min.
|
|
156
|
+
* Si el refresh no está disponible/falla, propaga para que el CLI pida login.
|
|
157
|
+
*
|
|
158
|
+
* Un PAT no tiene refresh ni expiración local: la validez la decide el
|
|
159
|
+
* servidor en cada request, así que se devuelve tal cual.
|
|
160
|
+
*/
|
|
161
|
+
async ensureValidToken(credentials) {
|
|
162
|
+
if (credentials && credentials.kind === 'pat') return credentials;
|
|
163
|
+
|
|
164
|
+
if (!credentials || !credentials.jwt) {
|
|
165
|
+
throw new ShiloError('No hay sesión. Ejecutá "shilo login".');
|
|
166
|
+
}
|
|
167
|
+
const msLeft = new Date(credentials.expiresAt).getTime() - Date.now();
|
|
168
|
+
if (msLeft >= 5 * 60 * 1000) return credentials;
|
|
169
|
+
|
|
170
|
+
if (!credentials.refreshToken) {
|
|
171
|
+
throw new ShiloError('Sesión expirada. Ejecutá "shilo login" de nuevo.');
|
|
172
|
+
}
|
|
173
|
+
const fresh = await this.refreshToken(credentials.refreshToken);
|
|
174
|
+
return { ...credentials, ...fresh };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Verifica que unas credenciales sean aceptadas por el servidor, listando los
|
|
179
|
+
* proyectos del usuario. Sirve tanto para JWT como para PAT.
|
|
180
|
+
* Devuelve `{ ok: <bool>, status, error }` — no lanza por credenciales malas.
|
|
181
|
+
*/
|
|
182
|
+
async verifyCredentials(credentials) {
|
|
183
|
+
const headers = { Authorization: `Bearer ${credentials.jwt}` };
|
|
184
|
+
const { res, data } = await this._fetch(`${this.baseURL}/api/projects`, { headers });
|
|
185
|
+
if (res.ok) return { ok: true, status: res.status, error: null };
|
|
186
|
+
return {
|
|
187
|
+
ok: false,
|
|
188
|
+
status: res.status,
|
|
189
|
+
error: this._errMessage(data, `HTTP ${res.status}`),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// Helpers de archivos
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Codifica una ruta de repo (segmentos separados por `/`) para la URL,
|
|
199
|
+
* escapando cada segmento pero conservando los separadores. Los nombres
|
|
200
|
+
* válidos en Shilo pueden incluir `[ ] ( ) @`, que conviene escapar.
|
|
201
|
+
*/
|
|
202
|
+
_encodePath(repoPath) {
|
|
203
|
+
if (!repoPath) return '';
|
|
204
|
+
return repoPath
|
|
205
|
+
.split('/')
|
|
206
|
+
.filter(Boolean)
|
|
207
|
+
.map((seg) => encodeURIComponent(seg))
|
|
208
|
+
.join('/');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
_fileUrl(projectId, repoPath = '') {
|
|
212
|
+
const enc = this._encodePath(repoPath);
|
|
213
|
+
return enc
|
|
214
|
+
? `${this.baseURL}/api/files/${projectId}/${enc}`
|
|
215
|
+
: `${this.baseURL}/api/files/${projectId}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async _authFetch(url, credentials, { method = 'GET', body = null } = {}) {
|
|
219
|
+
const validCreds = await this.ensureValidToken(credentials);
|
|
220
|
+
const headers = { Authorization: `Bearer ${validCreds.jwt}` };
|
|
221
|
+
if (body != null) headers['Content-Type'] = 'application/json';
|
|
222
|
+
const { res, data } = await this._fetch(url, { method, headers, body });
|
|
223
|
+
return { res, data, credentials: validCreds };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Lista el contenido directo de una carpeta (no recursa). Raíz responde
|
|
228
|
+
* `{ files }`, una carpeta responde `{ folder, files }`.
|
|
229
|
+
* Devuelve `{ files: [...], credentials }`.
|
|
230
|
+
*/
|
|
231
|
+
async listFiles(projectId, credentials, folderPath = '') {
|
|
232
|
+
const url = this._fileUrl(projectId, folderPath);
|
|
233
|
+
const { res, data, credentials: creds } = await this._authFetch(url, credentials);
|
|
234
|
+
if (!res.ok) {
|
|
235
|
+
throw new ShiloError(
|
|
236
|
+
`No se pudieron listar archivos: ${this._errMessage(data, `HTTP ${res.status}`)}`,
|
|
237
|
+
{ status: res.status, body: data }
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
const files = data && Array.isArray(data.files) ? data.files : Array.isArray(data) ? data : [];
|
|
241
|
+
return { files, credentials: creds };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Obtiene la metadata de un archivo/carpeta por ruta SIN descargar contenido.
|
|
246
|
+
* Devuelve `{ file: <obj|null>, exists: <bool>, credentials }`.
|
|
247
|
+
* Un 404 no es error: significa que la ruta no existe todavía (archivo nuevo).
|
|
248
|
+
*/
|
|
249
|
+
async getFileMeta(projectId, filePath, credentials) {
|
|
250
|
+
const url = this._fileUrl(projectId, filePath);
|
|
251
|
+
const { res, data, credentials: creds } = await this._authFetch(url, credentials);
|
|
252
|
+
if (res.status === 404) {
|
|
253
|
+
return { file: null, exists: false, credentials: creds };
|
|
254
|
+
}
|
|
255
|
+
if (!res.ok) {
|
|
256
|
+
throw new ShiloError(
|
|
257
|
+
`No se pudo consultar "${filePath}": ${this._errMessage(data, `HTTP ${res.status}`)}`,
|
|
258
|
+
{ status: res.status, body: data }
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
const file = data && data.file ? data.file : data;
|
|
262
|
+
return { file: file || null, exists: !!file, credentials: creds };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Lee metadata + contenido de un archivo (`?content=true`).
|
|
267
|
+
* Devuelve `{ file: { ...meta, content }, credentials }`.
|
|
268
|
+
*/
|
|
269
|
+
async readFile(projectId, filePath, credentials) {
|
|
270
|
+
const url = `${this._fileUrl(projectId, filePath)}?content=true`;
|
|
271
|
+
const { res, data, credentials: creds } = await this._authFetch(url, credentials);
|
|
272
|
+
if (!res.ok) {
|
|
273
|
+
throw new ShiloError(
|
|
274
|
+
`No se pudo leer "${filePath}": ${this._errMessage(data, `HTTP ${res.status}`)}`,
|
|
275
|
+
{ status: res.status, body: data }
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
const file = data && data.file ? data.file : {};
|
|
279
|
+
const content = data && data.content !== undefined ? data.content : null;
|
|
280
|
+
return { file: { ...file, content }, credentials: creds };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Crea un archivo nuevo (POST a la carpeta padre). Idempotente en el server:
|
|
285
|
+
* si ya existe, responde 409 con el objeto existente. Aquí tratamos el 409
|
|
286
|
+
* como "ya existía" y devolvemos `{ file, created: false }` para que el caller
|
|
287
|
+
* decida (normalmente, hacer un PUT).
|
|
288
|
+
* Devuelve `{ file, created: <bool>, credentials }`.
|
|
289
|
+
*/
|
|
290
|
+
async createFile(projectId, filePath, content, mimeType, credentials) {
|
|
291
|
+
const parts = filePath.split('/').filter(Boolean);
|
|
292
|
+
const fileName = parts.pop();
|
|
293
|
+
const parentPath = parts.join('/');
|
|
294
|
+
const url = this._fileUrl(projectId, parentPath);
|
|
295
|
+
|
|
296
|
+
const { res, data, credentials: creds } = await this._authFetch(url, credentials, {
|
|
297
|
+
method: 'POST',
|
|
298
|
+
body: { name: fileName, type: 'file', content, mimeType },
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
if (res.status === 409) {
|
|
302
|
+
const existing = (data && (data.file || data.folder)) || null;
|
|
303
|
+
return { file: existing, created: false, credentials: creds };
|
|
304
|
+
}
|
|
305
|
+
if (!res.ok) {
|
|
306
|
+
throw new ShiloError(
|
|
307
|
+
`No se pudo crear "${filePath}": ${this._errMessage(data, `HTTP ${res.status}`)}`,
|
|
308
|
+
{ status: res.status, body: data }
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
const file = (data && (data.file || data)) || null;
|
|
312
|
+
return { file, created: true, credentials: creds };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Actualiza el contenido de un archivo existente (PUT). Devuelve el archivo
|
|
317
|
+
* con su NUEVO `storage_path`. `{ file, credentials }`.
|
|
318
|
+
*/
|
|
319
|
+
async writeFile(projectId, filePath, content, mimeType, credentials) {
|
|
320
|
+
const url = this._fileUrl(projectId, filePath);
|
|
321
|
+
const { res, data, credentials: creds } = await this._authFetch(url, credentials, {
|
|
322
|
+
method: 'PUT',
|
|
323
|
+
body: { content, mimeType },
|
|
324
|
+
});
|
|
325
|
+
if (!res.ok) {
|
|
326
|
+
throw new ShiloError(
|
|
327
|
+
`No se pudo actualizar "${filePath}": ${this._errMessage(data, `HTTP ${res.status}`)}`,
|
|
328
|
+
{ status: res.status, body: data }
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
const file = (data && (data.file || data)) || null;
|
|
332
|
+
return { file, credentials: creds };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Crea una carpeta bajo `parentPath` (vacío = raíz). Un 409 significa que ya
|
|
337
|
+
* existe: no es fatal, devolvemos `{ folder, existed: true }`.
|
|
338
|
+
* `{ folder, existed: <bool>, credentials }`.
|
|
339
|
+
*/
|
|
340
|
+
async createFolder(projectId, folderName, parentPath = '', credentials) {
|
|
341
|
+
const url = this._fileUrl(projectId, parentPath);
|
|
342
|
+
const { res, data, credentials: creds } = await this._authFetch(url, credentials, {
|
|
343
|
+
method: 'POST',
|
|
344
|
+
body: { name: folderName, type: 'folder' },
|
|
345
|
+
});
|
|
346
|
+
if (res.status === 409) {
|
|
347
|
+
const existing = (data && (data.folder || data.file)) || { name: folderName };
|
|
348
|
+
return { folder: existing, existed: true, credentials: creds };
|
|
349
|
+
}
|
|
350
|
+
if (!res.ok) {
|
|
351
|
+
throw new ShiloError(
|
|
352
|
+
`No se pudo crear la carpeta "${folderName}": ${this._errMessage(data, `HTTP ${res.status}`)}`,
|
|
353
|
+
{ status: res.status, body: data }
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
const folder = (data && (data.file || data)) || null;
|
|
357
|
+
return { folder, existed: false, credentials: creds };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Asegura que existan todas las carpetas del path de un archivo.
|
|
362
|
+
* Estrategia anti-duplicados: Shilo puede no devolver 409 fiable, así que
|
|
363
|
+
* listamos el padre antes de crear y reutilizamos la carpeta si ya existe.
|
|
364
|
+
* `ensuredCache` (Set de rutas completas) evita re-listar dentro de una corrida.
|
|
365
|
+
* Devuelve las credenciales (posiblemente refrescadas).
|
|
366
|
+
*/
|
|
367
|
+
async ensurePath(projectId, filePath, credentials, ensuredCache = null) {
|
|
368
|
+
const parts = filePath.split('/').filter(Boolean);
|
|
369
|
+
parts.pop(); // quitar el nombre del archivo
|
|
370
|
+
if (parts.length === 0) return credentials;
|
|
371
|
+
|
|
372
|
+
let currentCreds = credentials;
|
|
373
|
+
let parentPath = '';
|
|
374
|
+
|
|
375
|
+
for (const folderName of parts) {
|
|
376
|
+
const fullPath = parentPath ? `${parentPath}/${folderName}` : folderName;
|
|
377
|
+
|
|
378
|
+
if (ensuredCache && ensuredCache.has(fullPath)) {
|
|
379
|
+
parentPath = fullPath;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
let exists = false;
|
|
384
|
+
try {
|
|
385
|
+
const listResult = await this.listFiles(projectId, currentCreds, parentPath);
|
|
386
|
+
currentCreds = listResult.credentials;
|
|
387
|
+
exists = listResult.files.some((e) => e && e.type === 'folder' && e.name === folderName);
|
|
388
|
+
} catch (_) {
|
|
389
|
+
// El padre puede no existir aún: dejamos que createFolder lo cree.
|
|
390
|
+
exists = false;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (!exists) {
|
|
394
|
+
const result = await this.createFolder(projectId, folderName, parentPath, currentCreds);
|
|
395
|
+
currentCreds = result.credentials;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (ensuredCache) ensuredCache.add(fullPath);
|
|
399
|
+
parentPath = fullPath;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return currentCreds;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Crea un commit con la lista de cambios de archivos (máx 100 por lote).
|
|
407
|
+
* `changes`: [{ file_id, action, previous_storage_path?, new_storage_path? }].
|
|
408
|
+
* Devuelve `{ commit, credentials }`.
|
|
409
|
+
*/
|
|
410
|
+
async createCommit(projectId, message, changes, credentials) {
|
|
411
|
+
const url = `${this.baseURL}/api/commits/${projectId}`;
|
|
412
|
+
const { res, data, credentials: creds } = await this._authFetch(url, credentials, {
|
|
413
|
+
method: 'POST',
|
|
414
|
+
body: { message, files: changes },
|
|
415
|
+
});
|
|
416
|
+
if (!res.ok) {
|
|
417
|
+
throw new ShiloError(
|
|
418
|
+
`No se pudo crear el commit: ${this._errMessage(data, `HTTP ${res.status}`)}`,
|
|
419
|
+
{ status: res.status, body: data }
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
const commit = (data && data.commit) || data;
|
|
423
|
+
return { commit, credentials: creds };
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
module.exports = { ShiloClient, ShiloError };
|