@botskill/cli 1.0.3 → 1.0.5

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/src/lib/auth.js CHANGED
@@ -1,95 +1,118 @@
1
- import path from 'path';
2
- import os from 'os';
3
- import Configstore from 'configstore';
4
- import axios from 'axios';
5
- import { getDefaultApiUrl } from './constants.js';
6
-
7
- const CONFIG_PATH = path.join(os.homedir(), '.skm', 'config.json');
8
- const defaultUrl = getDefaultApiUrl();
9
-
10
- const config = new Configstore('botskill-cli', { apiUrl: defaultUrl }, {
11
- configPath: CONFIG_PATH,
12
- });
13
-
14
- export const getConfigPath = () => config.path;
15
-
16
- /** 优先级: 环境变量 BOTSKILL_API_URL > 配置文件 > 构建时默认值 */
17
- export const getApiUrl = () =>
18
- process.env.BOTSKILL_API_URL || config.get('apiUrl') || getDefaultApiUrl();
19
-
20
- export const setApiUrl = (url) => config.set('apiUrl', url);
21
-
22
- export const getToken = () => config.get('token');
23
-
24
- export const getRefreshToken = () => config.get('refreshToken');
25
-
26
- export const getUser = () => config.get('user');
27
-
28
- export const setAuth = (data) => {
29
- if (data.token || data.accessToken) {
30
- config.set('token', data.token || data.accessToken);
31
- }
32
- if (data.refreshToken) {
33
- config.set('refreshToken', data.refreshToken);
34
- }
35
- if (data.user) {
36
- config.set('user', data.user);
37
- }
38
- };
39
-
40
- export const clearAuth = () => {
41
- config.delete('token');
42
- config.delete('refreshToken');
43
- config.delete('user');
44
- };
45
-
46
- export const isLoggedIn = () => !!config.get('token');
47
-
48
- export const createApiClient = () => {
49
- const baseURL = getApiUrl();
50
- const client = axios.create({
51
- baseURL,
52
- timeout: 15000,
53
- headers: { 'Content-Type': 'application/json' },
54
- });
55
-
56
- client.interceptors.request.use((cfg) => {
57
- const token = getToken();
58
- if (token) {
59
- cfg.headers.Authorization = `Bearer ${token}`;
60
- }
61
- return cfg;
62
- });
63
-
64
- client.interceptors.response.use(
65
- (res) => res,
66
- async (err) => {
67
- if (err.response?.status !== 401) return Promise.reject(err);
68
- const req = err.config;
69
- if (req._retry) return Promise.reject(err);
70
- if (req.url?.includes('/auth/login') || req.url?.includes('/auth/refresh')) {
71
- return Promise.reject(err);
72
- }
73
-
74
- const refreshToken = getRefreshToken();
75
- if (!refreshToken) return Promise.reject(err);
76
-
77
- try {
78
- const res = await axios.post(`${baseURL}/auth/refresh`, { refreshToken });
79
- const data = res.data?.data || res.data;
80
- const newToken = data.accessToken || data.token;
81
- const newRefresh = data.refreshToken;
82
- if (newToken) {
83
- config.set('token', newToken);
84
- if (newRefresh) config.set('refreshToken', newRefresh);
85
- req._retry = true;
86
- req.headers.Authorization = `Bearer ${newToken}`;
87
- return client(req);
88
- }
89
- } catch (_) {}
90
- return Promise.reject(err);
91
- }
92
- );
93
-
94
- return client;
95
- };
1
+ import path from 'path';
2
+ import os from 'os';
3
+ import Configstore from 'configstore';
4
+ import axios from 'axios';
5
+ import { getDefaultApiUrl } from './constants.js';
6
+
7
+ const CONFIG_PATH = path.join(os.homedir(), '.skm', 'config.json');
8
+ const defaultUrl = getDefaultApiUrl();
9
+
10
+ const config = new Configstore('botskill-cli', { apiUrl: defaultUrl }, {
11
+ configPath: CONFIG_PATH,
12
+ });
13
+
14
+ export const getConfigPath = () => config.path;
15
+
16
+ /** 规范化 API 地址:若未以 /api 结尾则自动追加,用户无需手动加 /api */
17
+ export const normalizeApiUrl = (url) => {
18
+ if (!url || typeof url !== 'string') return url;
19
+ const u = url.replace(/\/+$/, '');
20
+ return u.endsWith('/api') ? u : `${u}/api`;
21
+ };
22
+
23
+ /** 优先级: 环境变量 BOTSKILL_API_URL > 配置文件 > 构建时默认值 */
24
+ export const getApiUrl = () =>
25
+ normalizeApiUrl(process.env.BOTSKILL_API_URL || config.get('apiUrl') || getDefaultApiUrl());
26
+
27
+ export const setApiUrl = (url) => config.set('apiUrl', url);
28
+
29
+ export const getToken = () => config.get('token');
30
+
31
+ export const getRefreshToken = () => config.get('refreshToken');
32
+
33
+ export const getUser = () => config.get('user');
34
+
35
+ export const setAuth = (data) => {
36
+ if (data.token || data.accessToken) {
37
+ config.set('token', data.token || data.accessToken);
38
+ }
39
+ if (data.refreshToken) {
40
+ config.set('refreshToken', data.refreshToken);
41
+ }
42
+ if (data.user) {
43
+ config.set('user', data.user);
44
+ }
45
+ };
46
+
47
+ export const clearAuth = () => {
48
+ config.delete('token');
49
+ config.delete('refreshToken');
50
+ config.delete('user');
51
+ };
52
+
53
+ export const isLoggedIn = () => !!config.get('token');
54
+
55
+ /** 从 axios 错误中提取请求 URL,用于错误输出 */
56
+ export const getErrorUrl = (err) => {
57
+ const cfg = err?.config;
58
+ if (!cfg) return getApiUrl();
59
+ if (cfg.url && (cfg.url.startsWith('http://') || cfg.url.startsWith('https://'))) return cfg.url;
60
+ if (cfg.baseURL) {
61
+ const p = cfg.url || '';
62
+ return cfg.baseURL.replace(/\/$/, '') + (p.startsWith('/') ? p : '/' + p);
63
+ }
64
+ return getApiUrl();
65
+ };
66
+
67
+ /**
68
+ * 创建 API 客户端
69
+ * @param {string} [overrideUrl] - 可选,覆盖本次请求的 API 地址(来自 --api-url)
70
+ */
71
+ export const createApiClient = (overrideUrl) => {
72
+ const baseURL = normalizeApiUrl(overrideUrl || getApiUrl());
73
+ const client = axios.create({
74
+ baseURL,
75
+ timeout: 15000,
76
+ headers: { 'Content-Type': 'application/json' },
77
+ });
78
+
79
+ client.interceptors.request.use((cfg) => {
80
+ const token = getToken();
81
+ if (token) {
82
+ cfg.headers.Authorization = `Bearer ${token}`;
83
+ }
84
+ return cfg;
85
+ });
86
+
87
+ client.interceptors.response.use(
88
+ (res) => res,
89
+ async (err) => {
90
+ if (err.response?.status !== 401) return Promise.reject(err);
91
+ const req = err.config;
92
+ if (req._retry) return Promise.reject(err);
93
+ if (req.url?.includes('/auth/login') || req.url?.includes('/auth/refresh')) {
94
+ return Promise.reject(err);
95
+ }
96
+
97
+ const refreshToken = getRefreshToken();
98
+ if (!refreshToken) return Promise.reject(err);
99
+
100
+ try {
101
+ const res = await axios.post(`${baseURL}/auth/refresh`, { refreshToken });
102
+ const data = res.data?.data || res.data;
103
+ const newToken = data.accessToken || data.token;
104
+ const newRefresh = data.refreshToken;
105
+ if (newToken) {
106
+ config.set('token', newToken);
107
+ if (newRefresh) config.set('refreshToken', newRefresh);
108
+ req._retry = true;
109
+ req.headers.Authorization = `Bearer ${newToken}`;
110
+ return client(req);
111
+ }
112
+ } catch (_) {}
113
+ return Promise.reject(err);
114
+ }
115
+ );
116
+
117
+ return client;
118
+ };
@@ -1,10 +1,10 @@
1
- /**
2
- * 默认 API 地址,可通过 build 时环境变量 BOTSKILL_API_URL 注入
3
- * 发布生产: BOTSKILL_API_URL=https://api.botskill.ai npm run build
4
- * 开发/本地: 保持 __DEFAULT_API_URL__ 时使用 localhost
5
- */
6
- export const DEFAULT_API_URL = "http://localhost:3001/api";
7
- export const FALLBACK_API_URL = 'http://localhost:3001/api';
8
-
9
- export const getDefaultApiUrl = () =>
10
- DEFAULT_API_URL === '__DEFAULT_API_URL__' ? FALLBACK_API_URL : DEFAULT_API_URL;
1
+ /**
2
+ * 默认 API 地址,可通过 build 时环境变量 BOTSKILL_API_URL 注入
3
+ * 发布生产: BOTSKILL_API_URL=https://api.botskill.ai npm run build
4
+ * 开发/本地: 保持 __DEFAULT_API_URL__ 时使用 localhost
5
+ */
6
+ export const DEFAULT_API_URL = "http://localhost:3001/api";
7
+ export const FALLBACK_API_URL = 'http://localhost:3000/api';
8
+
9
+ export const getDefaultApiUrl = () =>
10
+ DEFAULT_API_URL === '__DEFAULT_API_URL__' ? FALLBACK_API_URL : DEFAULT_API_URL;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * 统一格式化 API 错误输出,便于 CLI 排查问题
3
+ */
4
+ import { getErrorUrl } from './auth.js';
5
+
6
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
7
+ const red = (s) => `\x1b[31m${s}\x1b[0m`;
8
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
9
+
10
+ /**
11
+ * 从 axios 错误中提取用户可读的消息
12
+ */
13
+ function extractMessage(err, fallback = 'Request failed') {
14
+ if (err._overrideMsg) return err._overrideMsg;
15
+ if (err.response?.data && !Buffer.isBuffer(err.response.data)) {
16
+ const d = err.response.data;
17
+ return d.error || d.message || (typeof d === 'string' ? d : fallback);
18
+ }
19
+ if (err.code === 'ECONNREFUSED') {
20
+ return 'Connection refused. Server may not be running.';
21
+ }
22
+ if (err.code === 'ENOTFOUND') {
23
+ return 'DNS lookup failed. Check API URL or network.';
24
+ }
25
+ if (err.code === 'ETIMEDOUT' || err.code === 'ECONNABORTED') {
26
+ return 'Request timed out.';
27
+ }
28
+ if (err.code === 'ERR_NETWORK') {
29
+ return 'Network error.';
30
+ }
31
+ return err.message || fallback;
32
+ }
33
+
34
+ /**
35
+ * 根据错误类型返回排查提示
36
+ */
37
+ function getHints(err) {
38
+ const hints = [];
39
+ const status = err.response?.status;
40
+ const code = err.code;
41
+
42
+ if (code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'ETIMEDOUT' || code === 'ERR_NETWORK') {
43
+ hints.push('Run "skm config" to view or set API URL');
44
+ hints.push('Ensure the backend server is running');
45
+ }
46
+ if (status === 401) {
47
+ hints.push('Run "skm login" to authenticate');
48
+ }
49
+ if (status === 404) {
50
+ hints.push('Check if the resource exists');
51
+ }
52
+ if (status === 403) {
53
+ hints.push('Your account may not have permission');
54
+ }
55
+ if (status && status >= 500) {
56
+ hints.push('Server error. Try again later or contact support');
57
+ }
58
+
59
+ return hints;
60
+ }
61
+
62
+ /**
63
+ * 格式化并输出 API 错误信息(符合 CLI 风格,便于排查)
64
+ * @param {Error} err - axios 错误
65
+ * @param {Object} opts - 选项
66
+ * @param {string} opts.prefix - 错误前缀,如 "List failed"、"Download failed"
67
+ * @param {string} opts.fallback - 无消息时的默认文案
68
+ * @param {boolean} opts.skipUrl - 是否跳过 URL 输出(如登录失败时 URL 可能重复)
69
+ */
70
+ export function formatApiError(err, opts = {}) {
71
+ const { prefix = 'Error', fallback = 'Request failed', skipUrl = false } = opts;
72
+ const msg = extractMessage(err, fallback);
73
+ const url = getErrorUrl(err);
74
+ const status = err.response?.status;
75
+ const hints = getHints(err);
76
+
77
+ const lines = [];
78
+
79
+ // 主错误信息
80
+ lines.push(red(`${prefix}: ${msg}`));
81
+
82
+ // 辅助信息:URL、状态码
83
+ const meta = [];
84
+ if (!skipUrl) meta.push(`URL: ${url}`);
85
+ if (status) meta.push(`HTTP ${status}`);
86
+ if (meta.length) lines.push(dim(meta.join(' | ')));
87
+
88
+ // 排查提示
89
+ if (hints.length) {
90
+ lines.push('');
91
+ lines.push(yellow('Hint:'));
92
+ hints.forEach((h) => lines.push(dim(` • ${h}`)));
93
+ }
94
+
95
+ return lines.join('\n');
96
+ }
97
+
98
+ /**
99
+ * 输出错误并退出
100
+ */
101
+ export function printApiError(err, opts = {}) {
102
+ console.error(formatApiError(err, opts));
103
+ process.exit(1);
104
+ }
105
+
106
+ /**
107
+ * 输出简单错误(无 API 上下文,如 NOT_LOGGED_IN、FILE_NOT_FOUND)
108
+ */
109
+ export function printSimpleError(message, hint = null) {
110
+ console.error(red(`Error: ${message}`));
111
+ if (hint) console.error(dim(`Hint: ${hint}`));
112
+ process.exit(1);
113
+ }
@@ -1,91 +1,93 @@
1
- import path from 'path';
2
- import fs from 'fs-extra';
3
- import { createApiClient, getToken } from './auth.js';
4
-
5
- const validCategories = ['ai', 'data', 'web', 'devops', 'security', 'tools'];
6
-
7
- /**
8
- * Find uploadable file in cwd
9
- * Priority: SKILL.md, skill.zip, skill.tar.gz, dist.zip
10
- */
11
- export async function findUploadFile(cwd = process.cwd()) {
12
- const candidates = [
13
- path.join(cwd, 'SKILL.md'),
14
- path.join(cwd, 'skill.zip'),
15
- path.join(cwd, 'skill.tar.gz'),
16
- path.join(cwd, 'dist.zip'),
17
- ];
18
- for (const p of candidates) {
19
- if (await fs.pathExists(p)) {
20
- const stat = await fs.stat(p);
21
- if (stat.isFile()) return p;
22
- }
23
- }
24
- return null;
25
- }
26
-
27
- /**
28
- * Upload skill file (SKILL.md, .zip, .tar.gz) to BotSkill
29
- * @param {string} filePath - Path to file
30
- */
31
- export async function uploadSkillFile(filePath) {
32
- const token = getToken();
33
- if (!token) {
34
- throw new Error('NOT_LOGGED_IN');
35
- }
36
-
37
- if (!await fs.pathExists(filePath)) {
38
- throw new Error('FILE_NOT_FOUND');
39
- }
40
-
41
- const FormData = (await import('form-data')).default;
42
- const form = new FormData();
43
- form.append('file', await fs.createReadStream(filePath), {
44
- filename: path.basename(filePath),
45
- });
46
-
47
- const api = createApiClient();
48
- const res = await api.post('/skills/upload', form, {
49
- headers: form.getHeaders(),
50
- maxBodyLength: Infinity,
51
- maxContentLength: Infinity,
52
- });
53
- return res.data?.skill || res.data;
54
- }
55
-
56
- /**
57
- * Legacy: upload via JSON (for backward compatibility, may be deprecated)
58
- */
59
- export async function uploadSkill(options = {}) {
60
- const token = getToken();
61
- if (!token) throw new Error('NOT_LOGGED_IN');
62
-
63
- let config = {};
64
- const configPath = path.join(process.cwd(), 'skill.config.json');
65
- if (await fs.pathExists(configPath)) {
66
- config = await fs.readJson(configPath);
67
- }
68
-
69
- const skillData = {
70
- name: options.name || config.name,
71
- description: options.description || config.description,
72
- version: options.version || config.version || '1.0.0',
73
- category: options.category || config.category || 'tools',
74
- tags: config.tags || [],
75
- license: config.license || 'MIT',
76
- repositoryUrl: config.repositoryUrl || undefined,
77
- documentationUrl: config.documentationUrl || undefined,
78
- demoUrl: config.demoUrl || undefined,
79
- };
80
-
81
- if (!skillData.name || !skillData.description) throw new Error('MISSING_FIELDS');
82
- if (!validCategories.includes(skillData.category)) {
83
- throw new Error(`Invalid category. Must be one of: ${validCategories.join(', ')}`);
84
- }
85
-
86
- const api = createApiClient();
87
- const res = await api.post('/skills', skillData);
88
- return res.data?.skill || res.data;
89
- }
90
-
91
- export { validCategories };
1
+ import path from 'path';
2
+ import fs from 'fs-extra';
3
+ import { createApiClient, getToken } from './auth.js';
4
+
5
+ const validCategories = ['ai', 'data', 'web', 'devops', 'security', 'tools'];
6
+
7
+ /**
8
+ * Find uploadable file in cwd
9
+ * Priority: SKILL.md, skill.zip, skill.tar.gz, dist.zip
10
+ */
11
+ export async function findUploadFile(cwd = process.cwd()) {
12
+ const candidates = [
13
+ path.join(cwd, 'SKILL.md'),
14
+ path.join(cwd, 'skill.zip'),
15
+ path.join(cwd, 'skill.tar.gz'),
16
+ path.join(cwd, 'dist.zip'),
17
+ ];
18
+ for (const p of candidates) {
19
+ if (await fs.pathExists(p)) {
20
+ const stat = await fs.stat(p);
21
+ if (stat.isFile()) return p;
22
+ }
23
+ }
24
+ return null;
25
+ }
26
+
27
+ /**
28
+ * Upload skill file (SKILL.md, .zip, .tar.gz) to BotSkill
29
+ * @param {string} filePath - Path to file
30
+ * @param {Object} [opts] - 可选
31
+ * @param {string} [opts.apiUrl] - 覆盖 API 地址(来自 --api-url)
32
+ */
33
+ export async function uploadSkillFile(filePath, opts = {}) {
34
+ const token = getToken();
35
+ if (!token) {
36
+ throw new Error('NOT_LOGGED_IN');
37
+ }
38
+
39
+ if (!await fs.pathExists(filePath)) {
40
+ throw new Error('FILE_NOT_FOUND');
41
+ }
42
+
43
+ const FormData = (await import('form-data')).default;
44
+ const form = new FormData();
45
+ form.append('file', await fs.createReadStream(filePath), {
46
+ filename: path.basename(filePath),
47
+ });
48
+
49
+ const api = createApiClient(opts.apiUrl);
50
+ const res = await api.post('/skills/upload', form, {
51
+ headers: form.getHeaders(),
52
+ maxBodyLength: Infinity,
53
+ maxContentLength: Infinity,
54
+ });
55
+ return res.data?.skill || res.data;
56
+ }
57
+
58
+ /**
59
+ * Legacy: upload via JSON (for backward compatibility, may be deprecated)
60
+ */
61
+ export async function uploadSkill(options = {}) {
62
+ const token = getToken();
63
+ if (!token) throw new Error('NOT_LOGGED_IN');
64
+
65
+ let config = {};
66
+ const configPath = path.join(process.cwd(), 'skill.config.json');
67
+ if (await fs.pathExists(configPath)) {
68
+ config = await fs.readJson(configPath);
69
+ }
70
+
71
+ const skillData = {
72
+ name: options.name || config.name,
73
+ description: options.description || config.description,
74
+ version: options.version || config.version || '1.0.0',
75
+ category: options.category || config.category || 'tools',
76
+ tags: config.tags || [],
77
+ license: config.license || 'MIT',
78
+ repositoryUrl: config.repositoryUrl || undefined,
79
+ documentationUrl: config.documentationUrl || undefined,
80
+ demoUrl: config.demoUrl || undefined,
81
+ };
82
+
83
+ if (!skillData.name || !skillData.description) throw new Error('MISSING_FIELDS');
84
+ if (!validCategories.includes(skillData.category)) {
85
+ throw new Error(`Invalid category. Must be one of: ${validCategories.join(', ')}`);
86
+ }
87
+
88
+ const api = createApiClient();
89
+ const res = await api.post('/skills', skillData);
90
+ return res.data?.skill || res.data;
91
+ }
92
+
93
+ export { validCategories };