@basetisia/skill-manager 0.1.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 +242 -0
- package/bin/index.js +119 -0
- package/package.json +27 -0
- package/scripts/publish.sh +37 -0
- package/scripts/setup.sh +50 -0
- package/src/adapters/adapters.test.js +175 -0
- package/src/adapters/claude.js +26 -0
- package/src/adapters/codex.js +31 -0
- package/src/adapters/cursor.js +46 -0
- package/src/adapters/gemini.js +26 -0
- package/src/adapters/index.js +45 -0
- package/src/adapters/shared.js +48 -0
- package/src/adapters/windsurf.js +82 -0
- package/src/commands/detect.js +135 -0
- package/src/commands/init.js +130 -0
- package/src/commands/install.js +105 -0
- package/src/commands/list.js +69 -0
- package/src/commands/login.js +29 -0
- package/src/commands/logout.js +13 -0
- package/src/commands/status.js +56 -0
- package/src/commands/whoami.js +29 -0
- package/src/manifest.js +84 -0
- package/src/manifest.test.js +183 -0
- package/src/utils/api.js +39 -0
- package/src/utils/auth.js +287 -0
- package/src/utils/config.js +35 -0
- package/src/utils/fs.js +39 -0
- package/src/utils/gitlab.js +78 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { readFile, writeFile, unlink, mkdir, chmod } from "node:fs/promises";
|
|
6
|
+
import { exec } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
// ── Cognito config ──
|
|
9
|
+
|
|
10
|
+
const COGNITO_DOMAIN = "ppm-basetis";
|
|
11
|
+
const COGNITO_REGION = "eu-north-1";
|
|
12
|
+
const CLIENT_ID = "28jssr3bg1vsl8io9e503tniuu";
|
|
13
|
+
const BASE_URL = `https://${COGNITO_DOMAIN}.auth.${COGNITO_REGION}.amazoncognito.com`;
|
|
14
|
+
const CALLBACK_PORT = 9876;
|
|
15
|
+
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}/callback`;
|
|
16
|
+
const LOGIN_TIMEOUT_MS = 120_000;
|
|
17
|
+
|
|
18
|
+
const CONFIG_DIR = join(homedir(), ".skill-manager");
|
|
19
|
+
const CREDENTIALS_FILE = join(CONFIG_DIR, "credentials.json");
|
|
20
|
+
|
|
21
|
+
// ── PKCE helpers ──
|
|
22
|
+
|
|
23
|
+
function generateRandomString(length) {
|
|
24
|
+
return randomBytes(length).toString("hex");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function sha256(plain) {
|
|
28
|
+
const encoder = new TextEncoder();
|
|
29
|
+
return crypto.subtle.digest("SHA-256", encoder.encode(plain));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function base64UrlEncode(buffer) {
|
|
33
|
+
const bytes = new Uint8Array(buffer);
|
|
34
|
+
let str = "";
|
|
35
|
+
bytes.forEach((b) => (str += String.fromCharCode(b)));
|
|
36
|
+
return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Credential storage ──
|
|
40
|
+
|
|
41
|
+
export async function loadCredentials() {
|
|
42
|
+
try {
|
|
43
|
+
const raw = await readFile(CREDENTIALS_FILE, "utf-8");
|
|
44
|
+
return JSON.parse(raw);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (err.code === "ENOENT") return null;
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function saveCredentials(tokens) {
|
|
52
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
53
|
+
const data = {
|
|
54
|
+
id_token: tokens.id_token,
|
|
55
|
+
access_token: tokens.access_token,
|
|
56
|
+
refresh_token: tokens.refresh_token,
|
|
57
|
+
expires_at: Date.now() + tokens.expires_in * 1000,
|
|
58
|
+
};
|
|
59
|
+
await writeFile(CREDENTIALS_FILE, JSON.stringify(data, null, 2) + "\n");
|
|
60
|
+
await chmod(CREDENTIALS_FILE, 0o600);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function clearCredentials() {
|
|
64
|
+
try {
|
|
65
|
+
await unlink(CREDENTIALS_FILE);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (err.code !== "ENOENT") throw err;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Token validation & refresh ──
|
|
72
|
+
|
|
73
|
+
function isTokenFresh(creds) {
|
|
74
|
+
return creds && creds.expires_at > Date.now() + 5 * 60 * 1000;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function refreshTokens(creds) {
|
|
78
|
+
const body = new URLSearchParams({
|
|
79
|
+
grant_type: "refresh_token",
|
|
80
|
+
client_id: CLIENT_ID,
|
|
81
|
+
refresh_token: creds.refresh_token,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const response = await fetch(`${BASE_URL}/oauth2/token`, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
87
|
+
body,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (!response.ok) {
|
|
91
|
+
await clearCredentials();
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const tokens = await response.json();
|
|
96
|
+
// Preserve existing refresh_token if not returned
|
|
97
|
+
if (!tokens.refresh_token) {
|
|
98
|
+
tokens.refresh_token = creds.refresh_token;
|
|
99
|
+
}
|
|
100
|
+
await saveCredentials(tokens);
|
|
101
|
+
return await loadCredentials();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function getValidToken() {
|
|
105
|
+
let creds = await loadCredentials();
|
|
106
|
+
if (!creds) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
"No estás autenticado. Ejecuta primero: skill-manager login"
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (isTokenFresh(creds)) {
|
|
113
|
+
return creds.id_token;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (creds.refresh_token) {
|
|
117
|
+
creds = await refreshTokens(creds);
|
|
118
|
+
if (creds) return creds.id_token;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
await clearCredentials();
|
|
122
|
+
throw new Error("Sesión expirada. Ejecuta: skill-manager login");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Parse id_token ──
|
|
126
|
+
|
|
127
|
+
export function parseIdToken(token) {
|
|
128
|
+
try {
|
|
129
|
+
const payload = JSON.parse(
|
|
130
|
+
Buffer.from(token.split(".")[1], "base64url").toString()
|
|
131
|
+
);
|
|
132
|
+
const name =
|
|
133
|
+
payload.name ||
|
|
134
|
+
[payload.given_name, payload.family_name].filter(Boolean).join(" ") ||
|
|
135
|
+
payload.email?.split("@")[0] ||
|
|
136
|
+
"User";
|
|
137
|
+
return { email: payload.email, name, sub: payload.sub };
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Open browser ──
|
|
144
|
+
|
|
145
|
+
function openBrowser(url) {
|
|
146
|
+
const cmd =
|
|
147
|
+
process.platform === "darwin"
|
|
148
|
+
? `open "${url}"`
|
|
149
|
+
: process.platform === "win32"
|
|
150
|
+
? `start "${url}"`
|
|
151
|
+
: `xdg-open "${url}"`;
|
|
152
|
+
|
|
153
|
+
return new Promise((resolve) => {
|
|
154
|
+
exec(cmd, (err) => {
|
|
155
|
+
if (err) {
|
|
156
|
+
// Browser didn't open, user will need to copy the URL
|
|
157
|
+
resolve(false);
|
|
158
|
+
} else {
|
|
159
|
+
resolve(true);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Login flow ──
|
|
166
|
+
|
|
167
|
+
export async function startLoginFlow() {
|
|
168
|
+
const verifier = generateRandomString(64);
|
|
169
|
+
const challenge = base64UrlEncode(await sha256(verifier));
|
|
170
|
+
|
|
171
|
+
const params = new URLSearchParams({
|
|
172
|
+
response_type: "code",
|
|
173
|
+
client_id: CLIENT_ID,
|
|
174
|
+
redirect_uri: REDIRECT_URI,
|
|
175
|
+
scope: "email openid profile",
|
|
176
|
+
identity_provider: "Google",
|
|
177
|
+
code_challenge_method: "S256",
|
|
178
|
+
code_challenge: challenge,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const authorizeUrl = `${BASE_URL}/oauth2/authorize?${params}`;
|
|
182
|
+
|
|
183
|
+
return new Promise((resolve, reject) => {
|
|
184
|
+
let settled = false;
|
|
185
|
+
|
|
186
|
+
const server = createServer(async (req, res) => {
|
|
187
|
+
if (settled) return;
|
|
188
|
+
|
|
189
|
+
const url = new URL(req.url, `http://localhost:${CALLBACK_PORT}`);
|
|
190
|
+
if (url.pathname !== "/callback") {
|
|
191
|
+
res.writeHead(404);
|
|
192
|
+
res.end();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const code = url.searchParams.get("code");
|
|
197
|
+
const error = url.searchParams.get("error");
|
|
198
|
+
|
|
199
|
+
if (error || !code) {
|
|
200
|
+
settled = true;
|
|
201
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
202
|
+
res.end(
|
|
203
|
+
"<html><body><h2>Error de autenticación</h2><p>Puedes cerrar esta pestaña.</p></body></html>"
|
|
204
|
+
);
|
|
205
|
+
server.close();
|
|
206
|
+
reject(
|
|
207
|
+
new Error(
|
|
208
|
+
error || "No se recibió código de autorización"
|
|
209
|
+
)
|
|
210
|
+
);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Exchange code for tokens
|
|
215
|
+
try {
|
|
216
|
+
const body = new URLSearchParams({
|
|
217
|
+
grant_type: "authorization_code",
|
|
218
|
+
client_id: CLIENT_ID,
|
|
219
|
+
redirect_uri: REDIRECT_URI,
|
|
220
|
+
code,
|
|
221
|
+
code_verifier: verifier,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const response = await fetch(`${BASE_URL}/oauth2/token`, {
|
|
225
|
+
method: "POST",
|
|
226
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
227
|
+
body,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
if (!response.ok) {
|
|
231
|
+
throw new Error("Token exchange failed");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const tokens = await response.json();
|
|
235
|
+
await saveCredentials(tokens);
|
|
236
|
+
|
|
237
|
+
settled = true;
|
|
238
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
239
|
+
res.end(
|
|
240
|
+
"<html><body style='font-family:system-ui;text-align:center;margin-top:80px'>" +
|
|
241
|
+
"<h2>✅ Autenticación correcta</h2>" +
|
|
242
|
+
"<p>Puedes cerrar esta pestaña y volver al terminal.</p></body></html>"
|
|
243
|
+
);
|
|
244
|
+
server.close();
|
|
245
|
+
|
|
246
|
+
const user = parseIdToken(tokens.id_token);
|
|
247
|
+
resolve(user);
|
|
248
|
+
} catch (err) {
|
|
249
|
+
settled = true;
|
|
250
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
251
|
+
res.end(
|
|
252
|
+
"<html><body><h2>Error</h2><p>Fallo en el intercambio de tokens.</p></body></html>"
|
|
253
|
+
);
|
|
254
|
+
server.close();
|
|
255
|
+
reject(err);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
server.on("error", (err) => {
|
|
260
|
+
if (err.code === "EADDRINUSE") {
|
|
261
|
+
reject(
|
|
262
|
+
new Error(
|
|
263
|
+
`El puerto ${CALLBACK_PORT} está en uso. Cierra la aplicación que lo utiliza e inténtalo de nuevo.`
|
|
264
|
+
)
|
|
265
|
+
);
|
|
266
|
+
} else {
|
|
267
|
+
reject(err);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
server.listen(CALLBACK_PORT, async () => {
|
|
272
|
+
const opened = await openBrowser(authorizeUrl);
|
|
273
|
+
if (!opened) {
|
|
274
|
+
console.log(`\nAbre esta URL en tu navegador:\n${authorizeUrl}\n`);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// Timeout
|
|
279
|
+
setTimeout(() => {
|
|
280
|
+
if (!settled) {
|
|
281
|
+
settled = true;
|
|
282
|
+
server.close();
|
|
283
|
+
reject(new Error("Timeout: no se completó la autenticación en 2 minutos."));
|
|
284
|
+
}
|
|
285
|
+
}, LOGIN_TIMEOUT_MS);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = join(homedir(), ".skill-manager");
|
|
6
|
+
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
7
|
+
|
|
8
|
+
export function getConfigPath() {
|
|
9
|
+
return CONFIG_FILE;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function loadConfig() {
|
|
13
|
+
try {
|
|
14
|
+
const raw = await readFile(CONFIG_FILE, "utf-8");
|
|
15
|
+
return JSON.parse(raw);
|
|
16
|
+
} catch (err) {
|
|
17
|
+
if (err.code === "ENOENT") return null;
|
|
18
|
+
throw err;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function saveConfig(config) {
|
|
23
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
24
|
+
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function requireConfig() {
|
|
28
|
+
const config = await loadConfig();
|
|
29
|
+
if (!config) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
"No se encontró configuración. Ejecuta primero: skill-manager init"
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return config;
|
|
35
|
+
}
|
package/src/utils/fs.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { readdir, mkdir, readFile as nodeReadFile, writeFile as nodeWriteFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export async function ensureDir(dirPath) {
|
|
6
|
+
await mkdir(dirPath, { recursive: true });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function writeFile(filePath, content) {
|
|
10
|
+
await ensureDir(dirname(filePath));
|
|
11
|
+
await nodeWriteFile(filePath, content, "utf-8");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function readFile(filePath) {
|
|
15
|
+
try {
|
|
16
|
+
return await nodeReadFile(filePath, "utf-8");
|
|
17
|
+
} catch (err) {
|
|
18
|
+
if (err.code === "ENOENT") return null;
|
|
19
|
+
throw err;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function listDirs(dirPath) {
|
|
24
|
+
try {
|
|
25
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
26
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
if (err.code === "ENOENT") return [];
|
|
29
|
+
throw err;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function homeDir() {
|
|
34
|
+
return homedir();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function cwdDir() {
|
|
38
|
+
return process.cwd();
|
|
39
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { requireConfig } from "./config.js";
|
|
2
|
+
|
|
3
|
+
export async function verifyConnection(registry) {
|
|
4
|
+
const { type, url, projectId, token } = registry;
|
|
5
|
+
|
|
6
|
+
if (type === "gitlab") {
|
|
7
|
+
const apiUrl = `${url.replace(/\/$/, "")}/api/v4/projects/${encodeURIComponent(projectId)}`;
|
|
8
|
+
const res = await fetch(apiUrl, {
|
|
9
|
+
headers: { "PRIVATE-TOKEN": token },
|
|
10
|
+
});
|
|
11
|
+
if (!res.ok) {
|
|
12
|
+
const body = await res.text().catch(() => "");
|
|
13
|
+
throw new Error(`GitLab API ${res.status}: ${body || res.statusText}`);
|
|
14
|
+
}
|
|
15
|
+
return await res.json();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (type === "github") {
|
|
19
|
+
const apiUrl = `https://api.github.com/repos/${projectId}`;
|
|
20
|
+
const res = await fetch(apiUrl, {
|
|
21
|
+
headers: {
|
|
22
|
+
Authorization: `Bearer ${token}`,
|
|
23
|
+
Accept: "application/vnd.github+json",
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
if (!res.ok) {
|
|
27
|
+
const body = await res.text().catch(() => "");
|
|
28
|
+
throw new Error(`GitHub API ${res.status}: ${body || res.statusText}`);
|
|
29
|
+
}
|
|
30
|
+
return await res.json();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
throw new Error(`Tipo de registro no soportado para verificación: ${type}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function fetchFileContent(filePath, configOverride) {
|
|
37
|
+
const config = configOverride || (await requireConfig());
|
|
38
|
+
const { type, url, projectId, token, branch } = config.registry;
|
|
39
|
+
|
|
40
|
+
const encodedPath = encodeURIComponent(filePath);
|
|
41
|
+
|
|
42
|
+
if (type === "gitlab") {
|
|
43
|
+
const apiUrl = `${url.replace(/\/$/, "")}/api/v4/projects/${encodeURIComponent(projectId)}/repository/files/${encodedPath}/raw?ref=${branch}`;
|
|
44
|
+
const res = await fetch(apiUrl, {
|
|
45
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
46
|
+
});
|
|
47
|
+
if (!res.ok) {
|
|
48
|
+
if (res.status === 404) {
|
|
49
|
+
throw new Error(`El archivo ${filePath} no existe en el repositorio.`);
|
|
50
|
+
}
|
|
51
|
+
throw new Error(
|
|
52
|
+
`No se puede conectar al repositorio. Verifica tu token y URL. (${res.status})`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return await res.text();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (type === "github") {
|
|
59
|
+
const apiUrl = `https://api.github.com/repos/${projectId}/contents/${filePath}?ref=${branch}`;
|
|
60
|
+
const res = await fetch(apiUrl, {
|
|
61
|
+
headers: {
|
|
62
|
+
Authorization: `Bearer ${token}`,
|
|
63
|
+
Accept: "application/vnd.github.raw+json",
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
if (!res.ok) {
|
|
67
|
+
if (res.status === 404) {
|
|
68
|
+
throw new Error(`El archivo ${filePath} no existe en el repositorio.`);
|
|
69
|
+
}
|
|
70
|
+
throw new Error(
|
|
71
|
+
`No se puede conectar al repositorio. Verifica tu token y URL. (${res.status})`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return await res.text();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
throw new Error(`Tipo de registro no soportado: ${type}`);
|
|
78
|
+
}
|