visionary-cli 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/LICENSE +21 -0
- package/README.md +31 -0
- package/api.mjs +182 -0
- package/article.mjs +138 -0
- package/auth.mjs +38 -0
- package/bin/visionary.mjs +5 -0
- package/column.mjs +197 -0
- package/draft.mjs +185 -0
- package/index.mjs +30 -0
- package/package.json +36 -0
- package/shared.mjs +151 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PassingTraveller111
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Visionary CLI
|
|
2
|
+
|
|
3
|
+
Command line tools for publishing and managing Visionary content.
|
|
4
|
+
|
|
5
|
+
## Local Global Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm link
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
After linking, run the CLI from any directory:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
visionary --help
|
|
15
|
+
visionary auth login --username <username> --password <password> --base-url <url>
|
|
16
|
+
visionary draft create --title <title> --content-file <path> --summary <text> --tags <a,b>
|
|
17
|
+
visionary draft publish --id <id> --confirm
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The `visionary-cli` command is also available as an alias.
|
|
21
|
+
|
|
22
|
+
## Configuration
|
|
23
|
+
|
|
24
|
+
The CLI reads auth and target site values from these sources, in priority order:
|
|
25
|
+
|
|
26
|
+
1. Command options such as `--base-url`, `--token`, and `--cookie`
|
|
27
|
+
2. Environment variables: `VISIONARY_BASE_URL`, `VISIONARY_TOKEN`, `VISIONARY_COOKIE`
|
|
28
|
+
3. Saved config at `~/.visionary-cli/config.json`
|
|
29
|
+
4. Default service: `https://visionaryblog.cn`
|
|
30
|
+
|
|
31
|
+
Run `visionary auth login ...` to save the token and base URL locally.
|
package/api.mjs
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_BASE_URLS = ['https://visionaryblog.cn'];
|
|
6
|
+
const CONFIG_DIR = join(homedir(), '.visionary-cli');
|
|
7
|
+
|
|
8
|
+
export const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
9
|
+
|
|
10
|
+
const endpoints = {
|
|
11
|
+
login: '/api/auth/login',
|
|
12
|
+
getUserInfo: '/api/users/me',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
let resolvedBaseUrl;
|
|
16
|
+
let storedConfig = {};
|
|
17
|
+
|
|
18
|
+
export async function initializeConfig() {
|
|
19
|
+
storedConfig = await loadConfig();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getResolvedBaseUrl() {
|
|
23
|
+
return resolvedBaseUrl;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function request(path, options, init = {}) {
|
|
27
|
+
const cookie = getCookie(options);
|
|
28
|
+
const candidates = resolvedBaseUrl ? [resolvedBaseUrl] : getBaseUrlCandidates(options);
|
|
29
|
+
let lastError;
|
|
30
|
+
const hasJsonBody = typeof init.body === 'string';
|
|
31
|
+
|
|
32
|
+
for (const baseUrl of candidates) {
|
|
33
|
+
try {
|
|
34
|
+
const response = await fetch(`${baseUrl}${path}`, {
|
|
35
|
+
...init,
|
|
36
|
+
headers: {
|
|
37
|
+
Accept: 'application/json',
|
|
38
|
+
Cookie: cookie,
|
|
39
|
+
...(hasJsonBody ? { 'Content-Type': 'application/json' } : {}),
|
|
40
|
+
...init.headers,
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const text = await response.text();
|
|
45
|
+
const data = text ? JSON.parse(text) : null;
|
|
46
|
+
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
lastError = new Error(`HTTP ${response.status}`);
|
|
49
|
+
lastError.details = data;
|
|
50
|
+
if (response.status === 404 && !resolvedBaseUrl) continue;
|
|
51
|
+
throw lastError;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
resolvedBaseUrl = baseUrl;
|
|
55
|
+
return data;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
lastError = error;
|
|
58
|
+
if (resolvedBaseUrl) break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
throw lastError;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function loginRequest(options, body) {
|
|
66
|
+
const candidates = getBaseUrlCandidates(options);
|
|
67
|
+
let lastError;
|
|
68
|
+
|
|
69
|
+
for (const baseUrl of candidates) {
|
|
70
|
+
try {
|
|
71
|
+
const response = await fetch(`${baseUrl}${endpoints.login}`, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: {
|
|
74
|
+
Accept: 'application/json',
|
|
75
|
+
'Content-Type': 'application/json',
|
|
76
|
+
},
|
|
77
|
+
body: JSON.stringify(body),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const text = await response.text();
|
|
81
|
+
const data = text ? JSON.parse(text) : null;
|
|
82
|
+
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
lastError = new Error(data?.message || data?.msg || `HTTP ${response.status}`);
|
|
85
|
+
lastError.details = data;
|
|
86
|
+
if (response.status === 404) continue;
|
|
87
|
+
throw lastError;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const setCookie = response.headers.get('set-cookie') || '';
|
|
91
|
+
const token = parseTokenFromSetCookie(setCookie);
|
|
92
|
+
if (!token) {
|
|
93
|
+
throw new Error('Login succeeded but no token cookie was returned.');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
resolvedBaseUrl = baseUrl;
|
|
97
|
+
return { data, token };
|
|
98
|
+
} catch (error) {
|
|
99
|
+
lastError = error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
throw lastError;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function saveAuthConfig(token) {
|
|
107
|
+
storedConfig = {
|
|
108
|
+
...storedConfig,
|
|
109
|
+
baseUrl: resolvedBaseUrl,
|
|
110
|
+
token,
|
|
111
|
+
};
|
|
112
|
+
await saveConfig(storedConfig);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function getCurrentUser(options) {
|
|
116
|
+
const response = await request(endpoints.getUserInfo, options, { method: 'GET' });
|
|
117
|
+
if (response?.ok !== true) {
|
|
118
|
+
throw new Error('Failed to read current user info. Check token/cookie permissions.');
|
|
119
|
+
}
|
|
120
|
+
return response.data;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function uploadFileRequest(options, path, filePath) {
|
|
124
|
+
const buffer = await readFile(filePath);
|
|
125
|
+
const formData = new FormData();
|
|
126
|
+
formData.append('file', new Blob([buffer]), basename(filePath));
|
|
127
|
+
|
|
128
|
+
const response = await request(path, options, {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
body: formData,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (response?.ok !== true) {
|
|
134
|
+
throw new Error(response?.error?.message || 'Failed to upload file.');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return response.data;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function getBaseUrlCandidates(options) {
|
|
141
|
+
const configuredBaseUrl = options.baseUrl || process.env.VISIONARY_BASE_URL || storedConfig.baseUrl;
|
|
142
|
+
if (configuredBaseUrl) return [normalizeBaseUrl(configuredBaseUrl)];
|
|
143
|
+
return DEFAULT_BASE_URLS;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function normalizeBaseUrl(baseUrl) {
|
|
147
|
+
try {
|
|
148
|
+
return new URL(baseUrl).origin;
|
|
149
|
+
} catch {
|
|
150
|
+
return baseUrl.replace(/\/+$/, '');
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function getCookie(options) {
|
|
155
|
+
const cookie = options.cookie || process.env.VISIONARY_COOKIE;
|
|
156
|
+
if (cookie) return cookie;
|
|
157
|
+
|
|
158
|
+
const token = options.token || process.env.VISIONARY_TOKEN || storedConfig.token;
|
|
159
|
+
if (token) return `token=${token}`;
|
|
160
|
+
|
|
161
|
+
throw new Error('Missing auth. Run auth login, or provide --token, --cookie, VISIONARY_TOKEN, or VISIONARY_COOKIE.');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function loadConfig() {
|
|
165
|
+
try {
|
|
166
|
+
const configText = await readFile(CONFIG_FILE, 'utf8');
|
|
167
|
+
return JSON.parse(configText);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (error.code === 'ENOENT') return {};
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function saveConfig(config) {
|
|
175
|
+
await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
176
|
+
await writeFile(CONFIG_FILE, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function parseTokenFromSetCookie(setCookie) {
|
|
180
|
+
const match = setCookie.match(/(?:^|,\s*)token=([^;]+)/);
|
|
181
|
+
return match ? decodeURIComponent(match[1]) : '';
|
|
182
|
+
}
|
package/article.mjs
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { getCurrentUser, getResolvedBaseUrl, request, uploadFileRequest } from './api.mjs';
|
|
2
|
+
import { buildQuery, output, parseIntegerOption, requireOption } from './shared.mjs';
|
|
3
|
+
|
|
4
|
+
export async function handleArticleCommand(subcommand, options) {
|
|
5
|
+
if (subcommand === 'list') return listArticles(options);
|
|
6
|
+
if (subcommand === 'public-list') return listPublishedArticles(options);
|
|
7
|
+
if (subcommand === 'search') return searchArticles(options);
|
|
8
|
+
if (subcommand === 'get') return getArticle(options);
|
|
9
|
+
if (subcommand === 'delete') return deleteArticle(options);
|
|
10
|
+
if (subcommand === 'cover-upload') return uploadArticleCover(options);
|
|
11
|
+
if (subcommand === 'image-upload') return uploadArticleImage(options);
|
|
12
|
+
throw new Error(`Unknown article subcommand: ${subcommand}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function readUserArticles(options, userId) {
|
|
16
|
+
const response = await request(`/api/users/${Number(userId)}/articles`, options, { method: 'GET' });
|
|
17
|
+
|
|
18
|
+
if (response?.ok !== true) {
|
|
19
|
+
throw new Error(response?.error?.message || 'Failed to read user articles.');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return response.data;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function readPublishedArticles(options) {
|
|
26
|
+
const pageNum = parseIntegerOption(options, 'pageNum', 0);
|
|
27
|
+
const pageSize = parseIntegerOption(options, 'pageSize', 8);
|
|
28
|
+
const sort = options.sort || 'new';
|
|
29
|
+
const response = await request(`/api/articles${buildQuery({ pageNum, pageSize, sort })}`, options, { method: 'GET' });
|
|
30
|
+
|
|
31
|
+
if (response?.ok !== true) {
|
|
32
|
+
throw new Error(response?.error?.message || 'Failed to read published articles.');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return response.data;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function searchArticlesRequest(options) {
|
|
39
|
+
const keyword = requireOption(options, 'keyword');
|
|
40
|
+
const pageNum = parseIntegerOption(options, 'pageNum', 0);
|
|
41
|
+
const pageSize = parseIntegerOption(options, 'pageSize', 8);
|
|
42
|
+
const response = await request(`/api/articles${buildQuery({ keyword, pageNum, pageSize })}`, options, { method: 'GET' });
|
|
43
|
+
|
|
44
|
+
if (response?.ok !== true) {
|
|
45
|
+
throw new Error(response?.error?.message || 'Failed to search articles.');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return response.data;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function readArticle(options, id) {
|
|
52
|
+
const response = await request(`/api/articles/${Number(id)}`, options, { method: 'GET' });
|
|
53
|
+
|
|
54
|
+
if (response?.ok !== true) {
|
|
55
|
+
throw new Error(response?.error?.message || 'Failed to read article.');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return response.data;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function deleteArticleRequest(options, id) {
|
|
62
|
+
const response = await request(`/api/articles/${Number(id)}`, options, { method: 'DELETE' });
|
|
63
|
+
|
|
64
|
+
if (response?.ok !== true) {
|
|
65
|
+
throw new Error(response?.error?.message || 'Failed to delete article.');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return response.data;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function listArticles(options) {
|
|
72
|
+
const userId = options.userId || (await getCurrentUser(options)).id;
|
|
73
|
+
const limit = parseIntegerOption(options, 'limit', 0);
|
|
74
|
+
let articles = await readUserArticles(options, userId);
|
|
75
|
+
|
|
76
|
+
if (options.publishedOnly) {
|
|
77
|
+
articles = articles.filter((article) => article.is_published === 1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (limit > 0) {
|
|
81
|
+
articles = articles.slice(0, limit);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
output({ success: true, action: 'article.list', userId: Number(userId), count: articles.length, articles });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function listPublishedArticles(options) {
|
|
88
|
+
const data = await readPublishedArticles(options);
|
|
89
|
+
output({ success: true, action: 'article.public-list', ...data });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function searchArticles(options) {
|
|
93
|
+
const data = await searchArticlesRequest(options);
|
|
94
|
+
output({ success: true, action: 'article.search', keyword: options.keyword, ...data });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function getArticle(options) {
|
|
98
|
+
const id = requireOption(options, 'id');
|
|
99
|
+
const article = await readArticle(options, id);
|
|
100
|
+
output({
|
|
101
|
+
success: true,
|
|
102
|
+
action: 'article.get',
|
|
103
|
+
article,
|
|
104
|
+
articleUrl: `${getResolvedBaseUrl()}/reader/${id}`,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function deleteArticle(options) {
|
|
109
|
+
if (!options.confirm) {
|
|
110
|
+
throw new Error('Deleting an article requires --confirm to avoid accidental removal.');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const id = requireOption(options, 'id');
|
|
114
|
+
const data = await deleteArticleRequest(options, id);
|
|
115
|
+
output({ success: true, action: 'article.delete', articleId: Number(id), data });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function uploadArticleCover(options) {
|
|
119
|
+
const filePath = requireOption(options, 'file');
|
|
120
|
+
const data = await uploadFileRequest(options, '/api/articles/cover', filePath);
|
|
121
|
+
output({
|
|
122
|
+
success: true,
|
|
123
|
+
action: 'article.cover-upload',
|
|
124
|
+
cover: data?.Location ? `https://${data.Location}` : undefined,
|
|
125
|
+
data,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function uploadArticleImage(options) {
|
|
130
|
+
const filePath = requireOption(options, 'file');
|
|
131
|
+
const data = await uploadFileRequest(options, '/api/articles/images', filePath);
|
|
132
|
+
output({
|
|
133
|
+
success: true,
|
|
134
|
+
action: 'article.image-upload',
|
|
135
|
+
image: data?.Location ? `https://${data.Location}` : undefined,
|
|
136
|
+
data,
|
|
137
|
+
});
|
|
138
|
+
}
|
package/auth.mjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { CONFIG_FILE, getResolvedBaseUrl, loginRequest, saveAuthConfig } from './api.mjs';
|
|
2
|
+
import { output } from './shared.mjs';
|
|
3
|
+
|
|
4
|
+
export async function handleAuthCommand(subcommand, options) {
|
|
5
|
+
if (subcommand === 'login') return login(options);
|
|
6
|
+
throw new Error(`Unknown auth subcommand: ${subcommand}`);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function login(options) {
|
|
10
|
+
const username = options.username || process.env.VISIONARY_USERNAME;
|
|
11
|
+
const password = options.password || process.env.VISIONARY_PASSWORD;
|
|
12
|
+
|
|
13
|
+
if (!username) throw new Error('Missing required option --username or VISIONARY_USERNAME');
|
|
14
|
+
if (!password) throw new Error('Missing required option --password or VISIONARY_PASSWORD');
|
|
15
|
+
|
|
16
|
+
const { data, token } = await loginRequest(options, {
|
|
17
|
+
username,
|
|
18
|
+
password,
|
|
19
|
+
isRemember: Boolean(options.remember),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
await saveAuthConfig(token);
|
|
23
|
+
|
|
24
|
+
output({
|
|
25
|
+
success: true,
|
|
26
|
+
action: 'auth.login',
|
|
27
|
+
baseUrl: getResolvedBaseUrl(),
|
|
28
|
+
configFile: CONFIG_FILE,
|
|
29
|
+
user: sanitizeUser(data?.data),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sanitizeUser(user) {
|
|
34
|
+
if (!user) return undefined;
|
|
35
|
+
const safeUser = { ...user };
|
|
36
|
+
delete safeUser.password;
|
|
37
|
+
return safeUser;
|
|
38
|
+
}
|
package/column.mjs
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { getCurrentUser, getResolvedBaseUrl, request, uploadFileRequest } from './api.mjs';
|
|
2
|
+
import { output, parseIds, requireOption } from './shared.mjs';
|
|
3
|
+
|
|
4
|
+
export async function handleColumnCommand(subcommand, options) {
|
|
5
|
+
if (subcommand === 'list') return listColumns(options);
|
|
6
|
+
if (subcommand === 'get') return getColumn(options);
|
|
7
|
+
if (subcommand === 'create') return createColumn(options);
|
|
8
|
+
if (subcommand === 'update') return updateColumn(options);
|
|
9
|
+
if (subcommand === 'delete') return deleteColumn(options);
|
|
10
|
+
if (subcommand === 'articles') return getColumnArticles(options);
|
|
11
|
+
if (subcommand === 'set-articles') return setColumnArticles(options);
|
|
12
|
+
if (subcommand === 'candidates') return getColumnCandidates(options);
|
|
13
|
+
if (subcommand === 'cover-upload') return uploadColumnCover(options);
|
|
14
|
+
throw new Error(`Unknown column subcommand: ${subcommand}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function readColumns(options, userId) {
|
|
18
|
+
const response = await request(`/api/users/${Number(userId)}/columns`, options, { method: 'GET' });
|
|
19
|
+
|
|
20
|
+
if (response?.ok !== true) {
|
|
21
|
+
throw new Error(response?.error?.message || 'Failed to read columns.');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return response.data;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function readColumn(options, id) {
|
|
28
|
+
const response = await request(`/api/columns/${Number(id)}`, options, { method: 'GET' });
|
|
29
|
+
|
|
30
|
+
if (response?.ok !== true) {
|
|
31
|
+
throw new Error(response?.error?.message || 'Failed to read column.');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return response.data;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function saveColumn(options, column) {
|
|
38
|
+
const isNewColumn = column.column_id === undefined;
|
|
39
|
+
const response = await request(isNewColumn ? '/api/columns' : `/api/columns/${Number(column.column_id)}`, options, {
|
|
40
|
+
method: isNewColumn ? 'POST' : 'PATCH',
|
|
41
|
+
body: JSON.stringify(column),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (response?.ok !== true) {
|
|
45
|
+
throw new Error(response?.error?.message || 'Failed to save column.');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return response.data;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function deleteColumnRequest(options, id) {
|
|
52
|
+
const response = await request(`/api/columns/${Number(id)}`, options, { method: 'DELETE' });
|
|
53
|
+
|
|
54
|
+
if (response?.ok !== true) {
|
|
55
|
+
throw new Error(response?.error?.message || 'Failed to delete column.');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return response.data;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function readColumnArticles(options, id) {
|
|
62
|
+
const response = await request(`/api/columns/${Number(id)}/articles`, options, { method: 'GET' });
|
|
63
|
+
|
|
64
|
+
if (response?.ok !== true) {
|
|
65
|
+
throw new Error(response?.error?.message || 'Failed to read column articles.');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return response.data;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function updateColumnArticles(options, id, articleIds) {
|
|
72
|
+
const response = await request(`/api/columns/${Number(id)}/articles`, options, {
|
|
73
|
+
method: 'PUT',
|
|
74
|
+
body: JSON.stringify({ article_ids: articleIds }),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (response?.ok !== true) {
|
|
78
|
+
throw new Error(response?.error?.message || 'Failed to update column articles.');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return response.data;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function readColumnCandidates(options) {
|
|
85
|
+
const response = await request('/api/articles/column-candidates', options, { method: 'GET' });
|
|
86
|
+
|
|
87
|
+
if (response?.ok !== true) {
|
|
88
|
+
throw new Error(response?.error?.message || 'Failed to read column candidate articles.');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return response.data;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function listColumns(options) {
|
|
95
|
+
const userId = options.userId || (await getCurrentUser(options)).id;
|
|
96
|
+
const columns = await readColumns(options, userId);
|
|
97
|
+
output({ success: true, action: 'column.list', userId: Number(userId), columns });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function getColumn(options) {
|
|
101
|
+
const id = requireOption(options, 'id');
|
|
102
|
+
const [column, articles] = await Promise.all([
|
|
103
|
+
readColumn(options, id),
|
|
104
|
+
readColumnArticles(options, id),
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
output({
|
|
108
|
+
success: true,
|
|
109
|
+
action: 'column.get',
|
|
110
|
+
column,
|
|
111
|
+
articles,
|
|
112
|
+
columnUrl: `${getResolvedBaseUrl()}/userCenter/Columns/${id}`,
|
|
113
|
+
manageUrl: `${getResolvedBaseUrl()}/creator/content/columns/manage/${id}`,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function createColumn(options) {
|
|
118
|
+
const name = requireOption(options, 'name');
|
|
119
|
+
const description = requireOption(options, 'description');
|
|
120
|
+
|
|
121
|
+
const data = await saveColumn(options, {
|
|
122
|
+
column_name: name,
|
|
123
|
+
description,
|
|
124
|
+
...(options.cover ? { cover_image: options.cover } : {}),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const columnId = data?.insertId;
|
|
128
|
+
output({
|
|
129
|
+
success: true,
|
|
130
|
+
action: 'column.create',
|
|
131
|
+
columnId,
|
|
132
|
+
columnUrl: columnId ? `${getResolvedBaseUrl()}/userCenter/Columns/${columnId}` : undefined,
|
|
133
|
+
manageUrl: columnId ? `${getResolvedBaseUrl()}/creator/content/columns/manage/${columnId}` : undefined,
|
|
134
|
+
data,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function updateColumn(options) {
|
|
139
|
+
const id = requireOption(options, 'id');
|
|
140
|
+
const existingColumn = await readColumn(options, id);
|
|
141
|
+
|
|
142
|
+
const data = await saveColumn(options, {
|
|
143
|
+
column_id: Number(id),
|
|
144
|
+
column_name: options.name ?? existingColumn.column_name ?? '',
|
|
145
|
+
description: options.description ?? existingColumn.description ?? '',
|
|
146
|
+
cover_image: options.cover ?? existingColumn.cover_image ?? '',
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
output({
|
|
150
|
+
success: true,
|
|
151
|
+
action: 'column.update',
|
|
152
|
+
columnId: Number(id),
|
|
153
|
+
columnUrl: `${getResolvedBaseUrl()}/userCenter/Columns/${id}`,
|
|
154
|
+
manageUrl: `${getResolvedBaseUrl()}/creator/content/columns/manage/${id}`,
|
|
155
|
+
data,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function deleteColumn(options) {
|
|
160
|
+
if (!options.confirm) {
|
|
161
|
+
throw new Error('Deleting a column requires --confirm to avoid accidental removal.');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const id = requireOption(options, 'id');
|
|
165
|
+
const data = await deleteColumnRequest(options, id);
|
|
166
|
+
output({ success: true, action: 'column.delete', columnId: Number(id), data });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function getColumnArticles(options) {
|
|
170
|
+
const id = requireOption(options, 'id');
|
|
171
|
+
const articles = await readColumnArticles(options, id);
|
|
172
|
+
output({ success: true, action: 'column.articles', columnId: Number(id), articles });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function setColumnArticles(options) {
|
|
176
|
+
const id = requireOption(options, 'id');
|
|
177
|
+
if (options.articleIds === undefined) throw new Error('Missing required option --article-ids');
|
|
178
|
+
const articleIds = parseIds(options.articleIds);
|
|
179
|
+
const data = await updateColumnArticles(options, id, articleIds);
|
|
180
|
+
output({ success: true, action: 'column.set-articles', columnId: Number(id), articleIds, data });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function getColumnCandidates(options) {
|
|
184
|
+
const articles = await readColumnCandidates(options);
|
|
185
|
+
output({ success: true, action: 'column.candidates', articles });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function uploadColumnCover(options) {
|
|
189
|
+
const filePath = requireOption(options, 'file');
|
|
190
|
+
const data = await uploadFileRequest(options, '/api/columns/cover', filePath);
|
|
191
|
+
output({
|
|
192
|
+
success: true,
|
|
193
|
+
action: 'column.cover-upload',
|
|
194
|
+
cover: data?.Location ? `https://${data.Location}` : undefined,
|
|
195
|
+
data,
|
|
196
|
+
});
|
|
197
|
+
}
|
package/draft.mjs
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { getCurrentUser, getResolvedBaseUrl, request } from './api.mjs';
|
|
3
|
+
import { normalizeTags, output, parseTags, requireOption, stripLeadingH1 } from './shared.mjs';
|
|
4
|
+
|
|
5
|
+
export async function handleDraftCommand(subcommand, options) {
|
|
6
|
+
if (subcommand === 'create') return createDraft(options);
|
|
7
|
+
if (subcommand === 'get') return getDraft(options);
|
|
8
|
+
if (subcommand === 'update') return updateDraft(options);
|
|
9
|
+
if (subcommand === 'publish') return publishDraft(options);
|
|
10
|
+
throw new Error(`Unknown draft subcommand: ${subcommand}`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function readDraft(options, id) {
|
|
14
|
+
const response = await request(`/api/drafts/${Number(id)}`, options, { method: 'GET' });
|
|
15
|
+
|
|
16
|
+
if (response?.ok !== true) {
|
|
17
|
+
throw new Error(response?.error?.message || 'Failed to read draft.');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return response.data;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function saveDraft(options, draft) {
|
|
24
|
+
const isNewDraft = draft.draftId === 'new';
|
|
25
|
+
const response = await request(isNewDraft ? '/api/drafts' : `/api/drafts/${Number(draft.draftId)}`, options, {
|
|
26
|
+
method: isNewDraft ? 'POST' : 'PATCH',
|
|
27
|
+
body: JSON.stringify(draft),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (response?.ok !== true) {
|
|
31
|
+
throw new Error(response?.error?.message || 'Failed to save draft.');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return response.data;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function publishDraftRequest(options, draftId) {
|
|
38
|
+
const response = await request(`/api/drafts/${Number(draftId)}/publish`, options, {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (response?.ok !== true) {
|
|
43
|
+
throw new Error(response?.error?.message || 'Failed to publish draft.');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return response.data;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function loadContent(options, existingContent = '') {
|
|
50
|
+
if (options.content !== undefined && options.contentFile) {
|
|
51
|
+
throw new Error('Use either --content or --content-file, not both.');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (options.content !== undefined) {
|
|
55
|
+
return options.keepTitle ? options.content : stripLeadingH1(options.content);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!options.contentFile) return existingContent;
|
|
59
|
+
const content = await readFile(options.contentFile, 'utf8');
|
|
60
|
+
return options.keepTitle ? content : stripLeadingH1(content);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function requireDraftContent(options) {
|
|
64
|
+
if (options.content !== undefined || options.contentFile) return;
|
|
65
|
+
throw new Error('Missing required option --content or --content-file');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function createDraft(options) {
|
|
69
|
+
const title = requireOption(options, 'title');
|
|
70
|
+
requireDraftContent(options);
|
|
71
|
+
|
|
72
|
+
const [user, content] = await Promise.all([
|
|
73
|
+
getCurrentUser(options),
|
|
74
|
+
loadContent(options),
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
const data = await saveDraft(options, {
|
|
78
|
+
draftId: 'new',
|
|
79
|
+
title,
|
|
80
|
+
content,
|
|
81
|
+
summary: options.summary || '',
|
|
82
|
+
tags: parseTags(options.tags),
|
|
83
|
+
author_id: user.id,
|
|
84
|
+
author_nickname: user.nick_name,
|
|
85
|
+
cover: options.cover || '',
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const draftId = data?.insertId;
|
|
89
|
+
const urls = buildDraftUrls({ draftId });
|
|
90
|
+
output({
|
|
91
|
+
success: true,
|
|
92
|
+
action: 'draft.create',
|
|
93
|
+
draftId,
|
|
94
|
+
draftUrl: urls.draftUrl,
|
|
95
|
+
data,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function getDraft(options) {
|
|
100
|
+
const id = requireOption(options, 'id');
|
|
101
|
+
const draft = await readDraft(options, id);
|
|
102
|
+
const urls = buildDraftUrls({ draftId: id });
|
|
103
|
+
output({
|
|
104
|
+
success: true,
|
|
105
|
+
action: 'draft.get',
|
|
106
|
+
draftId: Number(id),
|
|
107
|
+
draftUrl: urls.draftUrl,
|
|
108
|
+
draft,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function updateDraft(options) {
|
|
113
|
+
const id = requireOption(options, 'id');
|
|
114
|
+
const existingDraft = await readDraft(options, id);
|
|
115
|
+
const content = await loadContent(options, existingDraft.content || '');
|
|
116
|
+
|
|
117
|
+
const data = await saveDraft(options, {
|
|
118
|
+
draftId: Number(id),
|
|
119
|
+
title: options.title ?? existingDraft.title ?? '',
|
|
120
|
+
content,
|
|
121
|
+
summary: options.summary ?? existingDraft.summary ?? '',
|
|
122
|
+
tags: parseTags(options.tags, existingDraft.tags),
|
|
123
|
+
author_id: existingDraft.author_id,
|
|
124
|
+
author_nickname: existingDraft.author_nickname,
|
|
125
|
+
cover: options.cover ?? existingDraft.cover ?? '',
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const urls = buildDraftUrls({ draftId: id });
|
|
129
|
+
output({
|
|
130
|
+
success: true,
|
|
131
|
+
action: 'draft.update',
|
|
132
|
+
draftId: Number(id),
|
|
133
|
+
draftUrl: urls.draftUrl,
|
|
134
|
+
data,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function publishDraft(options) {
|
|
139
|
+
if (!options.confirm) {
|
|
140
|
+
throw new Error('Publishing requires --confirm to avoid accidental release.');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const id = requireOption(options, 'id');
|
|
144
|
+
const draft = await readDraft(options, id);
|
|
145
|
+
validatePublishableDraft(draft);
|
|
146
|
+
|
|
147
|
+
const data = await publishDraftRequest(options, id);
|
|
148
|
+
const articleId = data?.article_id;
|
|
149
|
+
const reviewId = data?.review_id;
|
|
150
|
+
const urls = buildDraftUrls({ draftId: data?.draft_id ?? id, reviewId, articleId });
|
|
151
|
+
|
|
152
|
+
output({
|
|
153
|
+
success: true,
|
|
154
|
+
action: 'draft.publish',
|
|
155
|
+
draftId: data?.draft_id ?? Number(id),
|
|
156
|
+
reviewId,
|
|
157
|
+
articleId,
|
|
158
|
+
draftUrl: urls.draftUrl,
|
|
159
|
+
reviewUrl: urls.reviewUrl,
|
|
160
|
+
articleUrl: urls.articleUrl,
|
|
161
|
+
note: 'The backend marks the article published after its async review step completes.',
|
|
162
|
+
data,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function buildDraftUrls({ draftId, reviewId, articleId }) {
|
|
167
|
+
const baseUrl = getResolvedBaseUrl();
|
|
168
|
+
return {
|
|
169
|
+
draftUrl: draftId ? `${baseUrl}/editor/draft/v2/${draftId}` : undefined,
|
|
170
|
+
reviewUrl: reviewId ? `${baseUrl}/reader/review/${reviewId}` : undefined,
|
|
171
|
+
articleUrl: articleId ? `${baseUrl}/reader/${articleId}` : undefined,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function validatePublishableDraft(draft) {
|
|
176
|
+
const missing = [];
|
|
177
|
+
if (!draft.title) missing.push('title');
|
|
178
|
+
if (!draft.content) missing.push('content');
|
|
179
|
+
if (!draft.summary) missing.push('summary');
|
|
180
|
+
if (!normalizeTags(draft.tags).length) missing.push('tags');
|
|
181
|
+
|
|
182
|
+
if (missing.length) {
|
|
183
|
+
throw new Error(`Draft is not publishable. Missing: ${missing.join(', ')}.`);
|
|
184
|
+
}
|
|
185
|
+
}
|
package/index.mjs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
import { handleArticleCommand } from './article.mjs';
|
|
3
|
+
import { handleAuthCommand } from './auth.mjs';
|
|
4
|
+
import { handleColumnCommand } from './column.mjs';
|
|
5
|
+
import { handleDraftCommand } from './draft.mjs';
|
|
6
|
+
import { initializeConfig } from './api.mjs';
|
|
7
|
+
import { fail, parseArgs, printUsage } from './shared.mjs';
|
|
8
|
+
|
|
9
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
10
|
+
await initializeConfig();
|
|
11
|
+
const { command, subcommand, options } = parseArgs(argv);
|
|
12
|
+
|
|
13
|
+
if (options.help || command === '--help' || !command) {
|
|
14
|
+
printUsage();
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (command === 'auth') return handleAuthCommand(subcommand, options);
|
|
19
|
+
if (command === 'draft') return handleDraftCommand(subcommand, options);
|
|
20
|
+
if (command === 'article') return handleArticleCommand(subcommand, options);
|
|
21
|
+
if (command === 'column') return handleColumnCommand(subcommand, options);
|
|
22
|
+
|
|
23
|
+
throw new Error(`Unknown command: ${command}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function run() {
|
|
27
|
+
main().catch((error) => {
|
|
28
|
+
fail(error.message, error.details);
|
|
29
|
+
});
|
|
30
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "visionary-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A Visionary CLI for AI-assisted writing workflows, content publishing, and article management.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"visionary": "./bin/visionary.mjs",
|
|
8
|
+
"visionary-cli": "./bin/visionary.mjs"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=20"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+ssh://git@github.com/PassingTraveller111/visionary-cli.git"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/PassingTraveller111/visionary-cli#readme",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/PassingTraveller111/visionary-cli/issues"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"visionary",
|
|
23
|
+
"cli",
|
|
24
|
+
"markdown",
|
|
25
|
+
"publishing",
|
|
26
|
+
"ai-writing"
|
|
27
|
+
],
|
|
28
|
+
"author": "PassingTraveller111",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"files": [
|
|
31
|
+
"bin",
|
|
32
|
+
"*.mjs",
|
|
33
|
+
"README.md",
|
|
34
|
+
"LICENSE"
|
|
35
|
+
]
|
|
36
|
+
}
|
package/shared.mjs
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
export function printUsage() {
|
|
2
|
+
console.log(`Usage:
|
|
3
|
+
visionary auth login --username <username> --password <password> [--base-url <url>] [--remember]
|
|
4
|
+
visionary draft create --title <title> (--content <markdown> | --content-file <path>) [--summary <text>] [--tags <a,b>] [--cover <url>] [--keep-title]
|
|
5
|
+
visionary draft get --id <id>
|
|
6
|
+
visionary draft update --id <id> [--title <title>] [--content <markdown> | --content-file <path>] [--summary <text>] [--tags <a,b>] [--cover <url>] [--keep-title]
|
|
7
|
+
visionary draft publish --id <id> --confirm
|
|
8
|
+
visionary article list [--user-id <id>] [--limit <n>] [--published-only]
|
|
9
|
+
visionary article public-list [--page-num <n>] [--page-size <n>] [--sort new|hot]
|
|
10
|
+
visionary article search --keyword <text> [--page-num <n>] [--page-size <n>]
|
|
11
|
+
visionary article get --id <id>
|
|
12
|
+
visionary article delete --id <id> --confirm
|
|
13
|
+
visionary article cover-upload --file <path>
|
|
14
|
+
visionary article image-upload --file <path>
|
|
15
|
+
visionary column list [--user-id <id>]
|
|
16
|
+
visionary column get --id <id>
|
|
17
|
+
visionary column create --name <name> --description <text> [--cover <url>]
|
|
18
|
+
visionary column update --id <id> [--name <name>] [--description <text>] [--cover <url>]
|
|
19
|
+
visionary column delete --id <id> --confirm
|
|
20
|
+
visionary column articles --id <id>
|
|
21
|
+
visionary column set-articles --id <id> --article-ids <id,id>
|
|
22
|
+
visionary column candidates
|
|
23
|
+
visionary column cover-upload --file <path>
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
--base-url <url> Visionary site URL. Defaults to VISIONARY_BASE_URL, saved config, then https://visionaryblog.cn.
|
|
27
|
+
--token <token> Auth token value. Defaults to VISIONARY_TOKEN, then saved config.
|
|
28
|
+
--cookie <cookie> Full Cookie header. Defaults to VISIONARY_COOKIE.
|
|
29
|
+
--remember Ask login API to issue a longer-lived token.
|
|
30
|
+
--confirm Required for commands that publish content.
|
|
31
|
+
--keep-title Keep a leading H1 from --content/--content-file. By default, leading H1 is stripped before upload.
|
|
32
|
+
--json Kept for agent compatibility. Output is always JSON.
|
|
33
|
+
--help Show this help.
|
|
34
|
+
`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parseArgs(argv) {
|
|
38
|
+
const args = [...argv];
|
|
39
|
+
const command = args.shift();
|
|
40
|
+
const subcommand = args.shift();
|
|
41
|
+
const options = {};
|
|
42
|
+
|
|
43
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
44
|
+
const arg = args[index];
|
|
45
|
+
if (!arg.startsWith('--')) {
|
|
46
|
+
throw new Error(`Unexpected argument: ${arg}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const key = arg.slice(2);
|
|
50
|
+
if (key === 'json' || key === 'help' || key === 'remember' || key === 'confirm' || key === 'keep-title' || key === 'published-only') {
|
|
51
|
+
options[toCamelCase(key)] = true;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const value = args[index + 1];
|
|
56
|
+
if (value === undefined || value.startsWith('--')) {
|
|
57
|
+
throw new Error(`Missing value for --${key}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
options[toCamelCase(key)] = value;
|
|
61
|
+
index += 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { command, subcommand, options };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function output(data) {
|
|
68
|
+
console.log(JSON.stringify(data, null, 2));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function fail(message, details) {
|
|
72
|
+
output({ success: false, error: message, details });
|
|
73
|
+
process.exitCode = 1;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function stripLeadingH1(content) {
|
|
77
|
+
return content
|
|
78
|
+
.replace(/^\uFEFF?\s*#\s+[^\n]*(?:\n+|$)/, '')
|
|
79
|
+
.replace(/^\uFEFF?\s*<h1(?:\s+[^>]*)?>[\s\S]*?<\/h1>\s*/i, '');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function parseTags(value, fallback = []) {
|
|
83
|
+
if (value === undefined) return normalizeTags(fallback);
|
|
84
|
+
|
|
85
|
+
const trimmed = value.trim();
|
|
86
|
+
if (!trimmed) return [];
|
|
87
|
+
|
|
88
|
+
if (trimmed.startsWith('[')) {
|
|
89
|
+
const parsed = JSON.parse(trimmed);
|
|
90
|
+
return normalizeTags(parsed);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return trimmed.split(',').map((tag) => tag.trim()).filter(Boolean);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function parseIds(value) {
|
|
97
|
+
if (value === undefined) return [];
|
|
98
|
+
|
|
99
|
+
const trimmed = value.trim();
|
|
100
|
+
if (!trimmed) return [];
|
|
101
|
+
|
|
102
|
+
if (trimmed.startsWith('[')) {
|
|
103
|
+
const parsed = JSON.parse(trimmed);
|
|
104
|
+
if (!Array.isArray(parsed)) throw new Error('--article-ids JSON value must be an array.');
|
|
105
|
+
return parsed.map((id) => String(id).trim()).filter(Boolean);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return trimmed.split(',').map((id) => id.trim()).filter(Boolean);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function normalizeTags(value) {
|
|
112
|
+
if (Array.isArray(value)) return value.map(String);
|
|
113
|
+
if (typeof value === 'string') {
|
|
114
|
+
try {
|
|
115
|
+
const parsed = JSON.parse(value);
|
|
116
|
+
if (Array.isArray(parsed)) return parsed.map(String);
|
|
117
|
+
} catch {
|
|
118
|
+
return value.split(',').map((tag) => tag.trim()).filter(Boolean);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function requireOption(options, key) {
|
|
125
|
+
if (!options[key]) throw new Error(`Missing required option --${formatOptionName(key)}`);
|
|
126
|
+
return options[key];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function parseIntegerOption(options, key, fallback) {
|
|
130
|
+
if (options[key] === undefined) return fallback;
|
|
131
|
+
const value = Number(options[key]);
|
|
132
|
+
if (!Number.isInteger(value)) throw new Error(`Invalid --${formatOptionName(key)} value.`);
|
|
133
|
+
return value;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function buildQuery(params) {
|
|
137
|
+
const query = new URLSearchParams();
|
|
138
|
+
for (const [key, value] of Object.entries(params)) {
|
|
139
|
+
if (value !== undefined && value !== '') query.set(key, String(value));
|
|
140
|
+
}
|
|
141
|
+
const queryString = query.toString();
|
|
142
|
+
return queryString ? `?${queryString}` : '';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function toCamelCase(value) {
|
|
146
|
+
return value.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function formatOptionName(key) {
|
|
150
|
+
return key.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`);
|
|
151
|
+
}
|