myskillshub 1.0.5 → 1.0.7
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/package.json +1 -1
- package/src/commands/publish.js +368 -18
package/package.json
CHANGED
package/src/commands/publish.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
const { Command } = require('commander');
|
|
2
2
|
const chalk = require('chalk');
|
|
3
3
|
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
4
5
|
const path = require('path');
|
|
5
6
|
const axios = require('axios');
|
|
7
|
+
const readline = require('readline');
|
|
6
8
|
const oraModule = require('ora');
|
|
7
9
|
const ora = oraModule.default || oraModule;
|
|
8
10
|
const archiver = require('archiver');
|
|
@@ -10,6 +12,7 @@ const FormData = require('form-data');
|
|
|
10
12
|
const { createWriteStream } = require('fs');
|
|
11
13
|
|
|
12
14
|
const program = new Command();
|
|
15
|
+
const DEFAULT_CATEGORY_SLUG = 'dev-tools';
|
|
13
16
|
|
|
14
17
|
program
|
|
15
18
|
.name('publish')
|
|
@@ -17,6 +20,10 @@ program
|
|
|
17
20
|
.argument('[directory]', 'Directory containing skill to publish (default: current directory)')
|
|
18
21
|
.option('--verbose', 'Verbose output')
|
|
19
22
|
.option('-a, --api-url <url>', 'SkillHub API URL', 'http://localhost:3000')
|
|
23
|
+
.option('--username <username>', 'Login username (or use SKILLSHUB_USERNAME)')
|
|
24
|
+
.option('--password <password>', 'Login password (or use SKILLSHUB_PASSWORD)')
|
|
25
|
+
.option('--category-id <id>', 'Category ID for publish')
|
|
26
|
+
.option('--category-slug <slug>', 'Category slug for publish (auto resolve to ID)')
|
|
20
27
|
.action(async (directory, options) => {
|
|
21
28
|
const targetDirectory = directory || '.';
|
|
22
29
|
const spinner = ora('Starting publish process...').start();
|
|
@@ -40,23 +47,34 @@ program
|
|
|
40
47
|
|
|
41
48
|
// Parse skill.md content (basic parsing)
|
|
42
49
|
const skillData = parseSkillMd(skillMdContent);
|
|
50
|
+
const frontmatter = parseFrontmatter(skillMdContent);
|
|
51
|
+
skillData.description = normalizeDescription(frontmatter.description || skillData.description, skillData.name);
|
|
52
|
+
skillData.tags = normalizeTags(frontmatter.tags || []);
|
|
43
53
|
spinner.succeed(chalk.green(`Found skill: ${skillData.name}`));
|
|
44
54
|
|
|
55
|
+
spinner.start('Resolving category...');
|
|
56
|
+
const categoryId = await resolveCategoryId(options.apiUrl, options, frontmatter);
|
|
57
|
+
spinner.succeed(chalk.green(`Resolved category_id: ${categoryId}`));
|
|
58
|
+
|
|
59
|
+
spinner.start('Authenticating...');
|
|
60
|
+
const auth = await getAuthCookie(options.apiUrl, options, spinner);
|
|
61
|
+
spinner.succeed(chalk.green(auth.fromCache ? 'Authenticated with cached session' : 'Login succeeded'));
|
|
62
|
+
|
|
45
63
|
// Create ZIP package
|
|
46
|
-
spinner.
|
|
64
|
+
spinner.start('Creating ZIP package...');
|
|
47
65
|
const zipPath = await createZipPackage(targetDirectory);
|
|
48
66
|
spinner.succeed(chalk.green(`ZIP package created: ${zipPath}`));
|
|
49
67
|
|
|
50
68
|
// Upload to SkillHub API
|
|
51
|
-
spinner.
|
|
52
|
-
const result = await uploadSkill(zipPath, skillData, options.apiUrl);
|
|
69
|
+
spinner.start('Uploading to SkillHub API...');
|
|
70
|
+
const result = await uploadSkill(zipPath, skillData, categoryId, options.apiUrl, auth.cookie);
|
|
53
71
|
|
|
54
72
|
spinner.succeed(chalk.green('Skill published successfully!'));
|
|
55
73
|
console.log(chalk.cyan(`Skill slug: ${result.slug}`));
|
|
56
74
|
console.log(chalk.cyan(`Skill URL: ${options.apiUrl}/skills/${result.slug}`));
|
|
57
75
|
|
|
58
76
|
} catch (error) {
|
|
59
|
-
spinner.fail(chalk.red(`Publish failed: ${error
|
|
77
|
+
spinner.fail(chalk.red(`Publish failed: ${formatErrorMessage(error)}`));
|
|
60
78
|
if (options.verbose) {
|
|
61
79
|
console.error(error);
|
|
62
80
|
}
|
|
@@ -75,6 +93,68 @@ function resolveSkillFilePath(directory) {
|
|
|
75
93
|
return null;
|
|
76
94
|
}
|
|
77
95
|
|
|
96
|
+
function parseFrontmatter(content) {
|
|
97
|
+
const text = String(content || '');
|
|
98
|
+
if (!text.startsWith('---')) {
|
|
99
|
+
return {};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const end = text.indexOf('---', 3);
|
|
103
|
+
if (end < 0) {
|
|
104
|
+
return {};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const block = text.slice(3, end).trim();
|
|
108
|
+
const lines = block.split('\n');
|
|
109
|
+
const result = {};
|
|
110
|
+
let currentArrayKey = null;
|
|
111
|
+
|
|
112
|
+
for (const rawLine of lines) {
|
|
113
|
+
const line = rawLine.trim();
|
|
114
|
+
if (!line || line.startsWith('#')) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (line.startsWith('- ')) {
|
|
119
|
+
if (currentArrayKey) {
|
|
120
|
+
if (!Array.isArray(result[currentArrayKey])) {
|
|
121
|
+
result[currentArrayKey] = [];
|
|
122
|
+
}
|
|
123
|
+
result[currentArrayKey].push(stripQuotes(line.slice(2).trim()));
|
|
124
|
+
}
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
currentArrayKey = null;
|
|
129
|
+
const idx = line.indexOf(':');
|
|
130
|
+
if (idx < 0) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const key = line.slice(0, idx).trim();
|
|
135
|
+
const value = line.slice(idx + 1).trim();
|
|
136
|
+
if (!value) {
|
|
137
|
+
currentArrayKey = key;
|
|
138
|
+
result[key] = [];
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
143
|
+
const arr = value.slice(1, -1).trim();
|
|
144
|
+
result[key] = arr ? arr.split(',').map((item) => stripQuotes(item.trim())) : [];
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
result[key] = stripQuotes(value);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function stripQuotes(value) {
|
|
155
|
+
return String(value || '').replace(/^['"]|['"]$/g, '');
|
|
156
|
+
}
|
|
157
|
+
|
|
78
158
|
function parseSkillMd(content) {
|
|
79
159
|
const lines = content.split('\n');
|
|
80
160
|
const skillData = {
|
|
@@ -83,7 +163,8 @@ function parseSkillMd(content) {
|
|
|
83
163
|
version: '1.0.0',
|
|
84
164
|
author: '',
|
|
85
165
|
keywords: [],
|
|
86
|
-
installCommand: ''
|
|
166
|
+
installCommand: '',
|
|
167
|
+
tags: []
|
|
87
168
|
};
|
|
88
169
|
|
|
89
170
|
let inDescription = false;
|
|
@@ -116,6 +197,249 @@ function parseSkillMd(content) {
|
|
|
116
197
|
return skillData;
|
|
117
198
|
}
|
|
118
199
|
|
|
200
|
+
function normalizeDescription(description, skillName) {
|
|
201
|
+
const text = String(description || '').trim();
|
|
202
|
+
if (text.length >= 10 && text.length <= 500) {
|
|
203
|
+
return text;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const fallback = `Skill "${skillName || 'unknown'}" published via myskillshub CLI.`;
|
|
207
|
+
return fallback.slice(0, 500);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function normalizeTags(tags) {
|
|
211
|
+
if (!Array.isArray(tags)) {
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
return tags.map((item) => String(item || '').trim()).filter(Boolean).slice(0, 10);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function resolveCategoryId(apiUrl, options, frontmatter) {
|
|
218
|
+
if (options.categoryId) {
|
|
219
|
+
const id = parseInt(options.categoryId, 10);
|
|
220
|
+
if (!Number.isInteger(id) || id <= 0) {
|
|
221
|
+
throw new Error(`Invalid --category-id: ${options.categoryId}`);
|
|
222
|
+
}
|
|
223
|
+
return id;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (frontmatter.category_id) {
|
|
227
|
+
const id = parseInt(frontmatter.category_id, 10);
|
|
228
|
+
if (Number.isInteger(id) && id > 0) {
|
|
229
|
+
return id;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const slugCandidate = options.categorySlug
|
|
234
|
+
|| frontmatter.category_slug
|
|
235
|
+
|| frontmatter.category
|
|
236
|
+
|| DEFAULT_CATEGORY_SLUG;
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
const response = await axios.get(`${apiUrl}/api/categories/${encodeURIComponent(String(slugCandidate).trim())}`);
|
|
240
|
+
const category = response.data?.data?.category;
|
|
241
|
+
if (!response.data?.success || !category?.id) {
|
|
242
|
+
throw new Error(`Failed to resolve category slug: ${slugCandidate}`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return category.id;
|
|
246
|
+
} catch (error) {
|
|
247
|
+
if (error.response?.status === 404 && slugCandidate === DEFAULT_CATEGORY_SLUG) {
|
|
248
|
+
throw new Error(`Default category "${DEFAULT_CATEGORY_SLUG}" not found. Use --category-id or --category-slug.`);
|
|
249
|
+
}
|
|
250
|
+
throw new Error(`Failed to resolve category slug: ${slugCandidate}. ${formatErrorMessage(error)}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function getAuthCookie(apiUrl, options, spinner) {
|
|
255
|
+
const normalizedApiUrl = normalizeApiUrl(apiUrl);
|
|
256
|
+
const resolvedUsername = options.username || process.env.SKILLSHUB_USERNAME || '';
|
|
257
|
+
const resolvedPassword = options.password || process.env.SKILLSHUB_PASSWORD || '';
|
|
258
|
+
const hasCompleteExplicitCredentials = Boolean(resolvedUsername && resolvedPassword);
|
|
259
|
+
|
|
260
|
+
if (!hasCompleteExplicitCredentials) {
|
|
261
|
+
const cachedCookie = getCachedCookie(normalizedApiUrl);
|
|
262
|
+
if (cachedCookie) {
|
|
263
|
+
const isValid = await isSessionValid(normalizedApiUrl, cachedCookie);
|
|
264
|
+
if (isValid) {
|
|
265
|
+
return { cookie: cachedCookie, fromCache: true };
|
|
266
|
+
}
|
|
267
|
+
removeCachedCookie(normalizedApiUrl);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const credentials = await resolveCredentials(options, spinner);
|
|
272
|
+
const cookie = await loginAndGetCookie(normalizedApiUrl, credentials.username, credentials.password);
|
|
273
|
+
saveCachedCookie(normalizedApiUrl, cookie);
|
|
274
|
+
return { cookie, fromCache: false };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function normalizeApiUrl(apiUrl) {
|
|
278
|
+
return String(apiUrl || '').trim().replace(/\/+$/, '');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function getSessionStorePath() {
|
|
282
|
+
return path.join(os.homedir(), '.myskillshub', 'session.json');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function readSessionStore() {
|
|
286
|
+
const sessionStorePath = getSessionStorePath();
|
|
287
|
+
if (!fs.existsSync(sessionStorePath)) {
|
|
288
|
+
return { sessions: {} };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
const raw = fs.readFileSync(sessionStorePath, 'utf8');
|
|
293
|
+
const parsed = JSON.parse(raw);
|
|
294
|
+
if (parsed && typeof parsed === 'object' && parsed.sessions && typeof parsed.sessions === 'object') {
|
|
295
|
+
return parsed;
|
|
296
|
+
}
|
|
297
|
+
return { sessions: {} };
|
|
298
|
+
} catch (error) {
|
|
299
|
+
return { sessions: {} };
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function writeSessionStore(data) {
|
|
304
|
+
const sessionStorePath = getSessionStorePath();
|
|
305
|
+
const sessionStoreDir = path.dirname(sessionStorePath);
|
|
306
|
+
fs.mkdirSync(sessionStoreDir, { recursive: true, mode: 0o700 });
|
|
307
|
+
fs.writeFileSync(sessionStorePath, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function getCachedCookie(apiUrl) {
|
|
311
|
+
const store = readSessionStore();
|
|
312
|
+
return store.sessions?.[apiUrl]?.cookie || null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function saveCachedCookie(apiUrl, cookie) {
|
|
316
|
+
const store = readSessionStore();
|
|
317
|
+
store.sessions = store.sessions || {};
|
|
318
|
+
store.sessions[apiUrl] = {
|
|
319
|
+
cookie,
|
|
320
|
+
updatedAt: new Date().toISOString()
|
|
321
|
+
};
|
|
322
|
+
writeSessionStore(store);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function removeCachedCookie(apiUrl) {
|
|
326
|
+
const store = readSessionStore();
|
|
327
|
+
if (store.sessions && store.sessions[apiUrl]) {
|
|
328
|
+
delete store.sessions[apiUrl];
|
|
329
|
+
writeSessionStore(store);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function isSessionValid(apiUrl, cookie) {
|
|
334
|
+
try {
|
|
335
|
+
const response = await axios.get(`${apiUrl}/api/auth/me`, {
|
|
336
|
+
headers: {
|
|
337
|
+
Cookie: cookie
|
|
338
|
+
},
|
|
339
|
+
validateStatus: () => true
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
return response.status === 200 && Boolean(response.data?.success);
|
|
343
|
+
} catch (error) {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function resolveCredentials(options, spinner) {
|
|
349
|
+
let username = options.username || process.env.SKILLSHUB_USERNAME || '';
|
|
350
|
+
let password = options.password || process.env.SKILLSHUB_PASSWORD || '';
|
|
351
|
+
const needsPrompt = !username || !password;
|
|
352
|
+
|
|
353
|
+
if (needsPrompt && !process.stdin.isTTY) {
|
|
354
|
+
throw new Error('Missing credentials in non-interactive mode. Use --username/--password or SKILLSHUB_USERNAME/SKILLSHUB_PASSWORD.');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (!username) {
|
|
358
|
+
if (spinner && spinner.isSpinning) {
|
|
359
|
+
spinner.stop();
|
|
360
|
+
}
|
|
361
|
+
username = await promptInput('SkillHub username: ');
|
|
362
|
+
if (spinner && !spinner.isSpinning) {
|
|
363
|
+
spinner.start('Logging in...');
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (!password) {
|
|
368
|
+
if (spinner && spinner.isSpinning) {
|
|
369
|
+
spinner.stop();
|
|
370
|
+
}
|
|
371
|
+
password = await promptPassword('SkillHub password: ');
|
|
372
|
+
if (spinner && !spinner.isSpinning) {
|
|
373
|
+
spinner.start('Logging in...');
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (!username || !password) {
|
|
378
|
+
throw new Error('Publish requires username and password.');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return { username, password };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function promptInput(question) {
|
|
385
|
+
return new Promise((resolve) => {
|
|
386
|
+
const rl = readline.createInterface({
|
|
387
|
+
input: process.stdin,
|
|
388
|
+
output: process.stdout,
|
|
389
|
+
terminal: true
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
rl.question(question, (answer) => {
|
|
393
|
+
rl.close();
|
|
394
|
+
resolve(String(answer || '').trim());
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function promptPassword(question) {
|
|
400
|
+
return new Promise((resolve) => {
|
|
401
|
+
const rl = readline.createInterface({
|
|
402
|
+
input: process.stdin,
|
|
403
|
+
output: process.stdout,
|
|
404
|
+
terminal: true
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
rl.stdoutMuted = true;
|
|
408
|
+
const originalWriteToOutput = rl._writeToOutput;
|
|
409
|
+
rl._writeToOutput = function writeToOutput(stringToWrite) {
|
|
410
|
+
if (rl.stdoutMuted) {
|
|
411
|
+
if (stringToWrite === '\n' || stringToWrite === '\r\n') {
|
|
412
|
+
rl.output.write(stringToWrite);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
rl.output.write('*');
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
originalWriteToOutput.call(rl, stringToWrite);
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
rl.question(question, (password) => {
|
|
422
|
+
rl.history = [];
|
|
423
|
+
rl.close();
|
|
424
|
+
resolve(String(password || '').trim());
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function loginAndGetCookie(apiUrl, username, password) {
|
|
430
|
+
const response = await axios.post(`${apiUrl}/api/auth/login`, {
|
|
431
|
+
username,
|
|
432
|
+
password
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
const setCookie = response.headers['set-cookie'];
|
|
436
|
+
if (!Array.isArray(setCookie) || setCookie.length === 0) {
|
|
437
|
+
throw new Error('Login succeeded but no session cookie returned');
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return setCookie.map((cookie) => cookie.split(';')[0]).join('; ');
|
|
441
|
+
}
|
|
442
|
+
|
|
119
443
|
function createZipPackage(directory) {
|
|
120
444
|
return new Promise((resolve, reject) => {
|
|
121
445
|
const baseName = path.basename(path.resolve(directory));
|
|
@@ -153,26 +477,52 @@ function createZipPackage(directory) {
|
|
|
153
477
|
});
|
|
154
478
|
}
|
|
155
479
|
|
|
156
|
-
async function uploadSkill(zipPath, skillData, apiUrl) {
|
|
480
|
+
async function uploadSkill(zipPath, skillData, categoryId, apiUrl, authCookie) {
|
|
157
481
|
const formData = new FormData();
|
|
158
482
|
formData.append('file', fs.createReadStream(zipPath));
|
|
159
|
-
formData.append('
|
|
483
|
+
formData.append('title', skillData.name);
|
|
160
484
|
formData.append('description', skillData.description);
|
|
161
|
-
formData.append('
|
|
162
|
-
formData.append('
|
|
163
|
-
|
|
164
|
-
|
|
485
|
+
formData.append('category_id', String(categoryId));
|
|
486
|
+
formData.append('tags', JSON.stringify(skillData.tags || []));
|
|
487
|
+
|
|
488
|
+
try {
|
|
489
|
+
const response = await axios.post(`${apiUrl}/api/skills/publish`, formData, {
|
|
490
|
+
headers: {
|
|
491
|
+
...formData.getHeaders(),
|
|
492
|
+
Cookie: authCookie
|
|
493
|
+
}
|
|
494
|
+
});
|
|
165
495
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
496
|
+
return response.data?.data || response.data;
|
|
497
|
+
} finally {
|
|
498
|
+
if (fs.existsSync(zipPath)) {
|
|
499
|
+
fs.unlinkSync(zipPath);
|
|
169
500
|
}
|
|
170
|
-
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
171
503
|
|
|
172
|
-
|
|
173
|
-
|
|
504
|
+
function formatErrorMessage(error) {
|
|
505
|
+
if (!error) {
|
|
506
|
+
return 'Unknown error';
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const status = error.response?.status;
|
|
510
|
+
const apiError = error.response?.data?.error;
|
|
511
|
+
const directMessage = error.response?.data?.message;
|
|
512
|
+
|
|
513
|
+
if (apiError?.message) {
|
|
514
|
+
return status ? `HTTP ${status}: ${apiError.message}` : apiError.message;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (directMessage) {
|
|
518
|
+
return status ? `HTTP ${status}: ${directMessage}` : directMessage;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (status) {
|
|
522
|
+
return `HTTP ${status}: ${error.message}`;
|
|
523
|
+
}
|
|
174
524
|
|
|
175
|
-
return
|
|
525
|
+
return error.message || 'Unknown error';
|
|
176
526
|
}
|
|
177
527
|
|
|
178
528
|
module.exports = program;
|