viruagent-cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/runner.js ADDED
@@ -0,0 +1,175 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const os = require('os');
5
+
6
+ const { createProviderManager } = require('./services/providerManager');
7
+
8
+ const parseList = (value) =>
9
+ String(value || '')
10
+ .split(',')
11
+ .map((item) => item.trim())
12
+ .filter(Boolean);
13
+
14
+ const parseIntOrNull = (value) => {
15
+ const parsed = Number(value);
16
+ return Number.isFinite(parsed) ? Math.trunc(parsed) : null;
17
+ };
18
+
19
+ const readContent = (opts) => {
20
+ if (opts.contentFile) {
21
+ const fullPath = path.resolve(process.cwd(), String(opts.contentFile));
22
+ if (!fs.existsSync(fullPath)) {
23
+ const err = new Error(`File not found: ${opts.contentFile}`);
24
+ err.code = 'FILE_NOT_FOUND';
25
+ throw err;
26
+ }
27
+ return fs.readFileSync(fullPath, 'utf-8');
28
+ }
29
+ return String(opts.content || '');
30
+ };
31
+
32
+ const createError = (code, message, hint) => {
33
+ const err = new Error(message);
34
+ err.code = code;
35
+ if (hint) err.hint = hint;
36
+ return err;
37
+ };
38
+
39
+ // Write commands that support --dry-run
40
+ const WRITE_COMMANDS = new Set(['publish', 'save-draft', 'login', 'logout']);
41
+
42
+ const runCommand = async (command, opts = {}) => {
43
+ // --dry-run support for write commands
44
+ if (opts.dryRun && WRITE_COMMANDS.has(command)) {
45
+ return { dryRun: true, command, params: { ...opts, dryRun: undefined } };
46
+ }
47
+
48
+ if (command === 'install-skill') {
49
+ const skillSrc = path.resolve(__dirname, '..', 'skills', 'viruagent.md');
50
+ if (!fs.existsSync(skillSrc)) {
51
+ throw createError('FILE_NOT_FOUND', 'Skill file not found in package');
52
+ }
53
+
54
+ // Detect target: Claude Code (~/.claude/commands/) or custom
55
+ const targetDir = opts.target
56
+ || path.join(os.homedir(), '.claude', 'commands');
57
+ fs.mkdirSync(targetDir, { recursive: true });
58
+
59
+ const dest = path.join(targetDir, 'viruagent.md');
60
+ fs.copyFileSync(skillSrc, dest);
61
+ return { installed: true, path: dest };
62
+ }
63
+
64
+ const manager = createProviderManager();
65
+
66
+ if (command === 'list-providers') {
67
+ return { providers: manager.getAvailableProviders() };
68
+ }
69
+
70
+ const providerName = opts.provider || 'tistory';
71
+ let provider;
72
+ try {
73
+ provider = manager.getProvider(providerName);
74
+ } catch {
75
+ throw createError(
76
+ 'PROVIDER_NOT_FOUND',
77
+ `Unknown provider: ${providerName}`,
78
+ 'viruagent-cli list-providers'
79
+ );
80
+ }
81
+
82
+ const withProvider = (fn) => async () => {
83
+ const result = await fn();
84
+ return { provider: providerName, ...result };
85
+ };
86
+
87
+ switch (command) {
88
+ case 'status':
89
+ case 'auth-status':
90
+ return withProvider(() => provider.authStatus())();
91
+
92
+ case 'login':
93
+ return withProvider(() =>
94
+ provider.login({
95
+ headless: Boolean(opts.headless),
96
+ manual: Boolean(opts.manual),
97
+ username: opts.username || undefined,
98
+ password: opts.password || undefined,
99
+ twoFactorCode: opts.twoFactorCode || undefined,
100
+ })
101
+ )();
102
+
103
+ case 'publish': {
104
+ const content = readContent(opts);
105
+ if (!content) {
106
+ throw createError('MISSING_CONTENT', 'publish requires --content or --content-file', 'viruagent-cli publish --spec');
107
+ }
108
+ return withProvider(() =>
109
+ provider.publish({
110
+ title: opts.title || '',
111
+ content,
112
+ visibility: opts.visibility || 'public',
113
+ category: parseIntOrNull(opts.category),
114
+ tags: opts.tags || '',
115
+ thumbnail: opts.thumbnail || undefined,
116
+ relatedImageKeywords: parseList(opts.relatedImageKeywords),
117
+ enforceSystemPrompt: opts.enforceSystemPrompt !== false,
118
+ imageUrls: parseList(opts.imageUrls),
119
+ imageUploadLimit: parseIntOrNull(opts.imageUploadLimit) || 1,
120
+ minimumImageCount: parseIntOrNull(opts.minimumImageCount) || 1,
121
+ autoUploadImages: opts.autoUploadImages !== false,
122
+ })
123
+ )();
124
+ }
125
+
126
+ case 'save-draft': {
127
+ const content = readContent(opts);
128
+ if (!content) {
129
+ throw createError('MISSING_CONTENT', 'save-draft requires --content or --content-file', 'viruagent-cli save-draft --spec');
130
+ }
131
+ return withProvider(() =>
132
+ provider.saveDraft({
133
+ title: opts.title || '',
134
+ content,
135
+ relatedImageKeywords: parseList(opts.relatedImageKeywords),
136
+ enforceSystemPrompt: opts.enforceSystemPrompt !== false,
137
+ imageUrls: parseList(opts.imageUrls),
138
+ imageUploadLimit: parseIntOrNull(opts.imageUploadLimit) || 1,
139
+ minimumImageCount: parseIntOrNull(opts.minimumImageCount) || 1,
140
+ autoUploadImages: opts.autoUploadImages !== false,
141
+ tags: opts.tags || '',
142
+ category: parseIntOrNull(opts.category),
143
+ })
144
+ )();
145
+ }
146
+
147
+ case 'list-categories':
148
+ return withProvider(() => provider.listCategories())();
149
+
150
+ case 'list-posts':
151
+ return withProvider(() =>
152
+ provider.listPosts({ limit: parseIntOrNull(opts.limit) || 20 })
153
+ )();
154
+
155
+ case 'read-post': {
156
+ if (!opts.postId) {
157
+ throw createError('INVALID_POST_ID', 'read-post requires --post-id', 'viruagent-cli read-post --spec');
158
+ }
159
+ return withProvider(() =>
160
+ provider.getPost({
161
+ postId: opts.postId,
162
+ includeDraft: Boolean(opts.includeDraft),
163
+ })
164
+ )();
165
+ }
166
+
167
+ case 'logout':
168
+ return withProvider(() => provider.logout())();
169
+
170
+ default:
171
+ throw createError('UNKNOWN_COMMAND', `Unknown command: ${command}`, 'viruagent-cli --spec');
172
+ }
173
+ };
174
+
175
+ module.exports = { runCommand };
@@ -0,0 +1,43 @@
1
+ const path = require('path');
2
+ const { getSessionPath } = require('../storage/sessionStore');
3
+ const createTistoryProvider = require('../providers/tistoryProvider');
4
+ const createNaverProvider = require('../providers/naverProvider');
5
+
6
+ const providerFactory = {
7
+ tistory: createTistoryProvider,
8
+ naver: createNaverProvider,
9
+ };
10
+
11
+ const providers = ['tistory', 'naver'];
12
+
13
+ const createProviderManager = () => {
14
+ const cache = new Map();
15
+
16
+ const getProvider = (provider = 'tistory') => {
17
+ const normalized = String(provider || 'tistory').toLowerCase();
18
+ if (!providerFactory[normalized]) {
19
+ throw new Error(`지원하지 않는 provider입니다: ${provider}. 가능한 값: ${providers.join(', ')}`);
20
+ }
21
+
22
+ if (!cache.has(normalized)) {
23
+ const sessionPath = getSessionPath(normalized);
24
+ const options = {
25
+ provider: normalized,
26
+ sessionPath,
27
+ };
28
+ const providerInstance = providerFactory[normalized](options);
29
+ cache.set(normalized, providerInstance);
30
+ }
31
+
32
+ return cache.get(normalized);
33
+ };
34
+
35
+ const getAvailableProviders = () => providers.map((provider) => ({
36
+ id: provider,
37
+ name: provider === 'tistory' ? 'Tistory' : 'Naver Blog',
38
+ }));
39
+
40
+ return { getProvider, getAvailableProviders };
41
+ };
42
+
43
+ module.exports = { createProviderManager };
@@ -0,0 +1,330 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const vm = require('vm');
4
+
5
+ const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36';
6
+ const API_HOST = 'https://www.tistory.com';
7
+
8
+ const getTimeout = () => 20000;
9
+
10
+ const normalizeCookies = (session) => {
11
+ if (!session) {
12
+ return [];
13
+ }
14
+
15
+ const rawCookies = Array.isArray(session)
16
+ ? session
17
+ : Array.isArray(session.cookies)
18
+ ? session.cookies
19
+ : [];
20
+
21
+ return rawCookies
22
+ .filter((cookie) => cookie && typeof cookie === 'object')
23
+ .filter((cookie) => cookie.name && cookie.value !== undefined && cookie.value !== null)
24
+ .filter((cookie) => {
25
+ if (!cookie.domain) return true;
26
+ return String(cookie.domain).includes('tistory') || String(cookie.domain).includes('tistory.com');
27
+ })
28
+ .map((cookie) => `${cookie.name}=${cookie.value}`);
29
+ };
30
+
31
+ const readSessionCookies = (sessionPath) => {
32
+ const resolvedPath = path.resolve(sessionPath);
33
+ if (!fs.existsSync(resolvedPath)) {
34
+ throw new Error(`세션 파일이 없습니다. ${resolvedPath}에 로그인 정보를 먼저 저장하세요.`);
35
+ }
36
+
37
+ let raw;
38
+ try {
39
+ raw = JSON.parse(fs.readFileSync(resolvedPath, 'utf-8'));
40
+ } catch (error) {
41
+ throw new Error(`세션 파일 파싱 실패: ${error.message}`);
42
+ }
43
+
44
+ const cookies = normalizeCookies(raw);
45
+ if (!cookies.length) {
46
+ throw new Error('세션에 유효한 쿠키가 없습니다. 다시 로그인해 주세요.');
47
+ }
48
+
49
+ return cookies.join('; ');
50
+ };
51
+
52
+ const buildReferer = (base) => `${base}/newpost`;
53
+
54
+ const createFetchController = () => {
55
+ const controller = new AbortController();
56
+ const timeout = setTimeout(() => controller.abort(), getTimeout());
57
+ return { controller, timeout };
58
+ };
59
+
60
+ const createTistoryApiClient = ({ sessionPath }) => {
61
+ let blogName = null;
62
+ let blogInfo = null;
63
+
64
+ const resetState = () => {
65
+ blogName = null;
66
+ blogInfo = null;
67
+ };
68
+
69
+ const getSessionCookies = () => readSessionCookies(sessionPath);
70
+
71
+ const getBase = () => {
72
+ if (!blogName) {
73
+ throw new Error('블로그 이름이 초기화되지 않았습니다. initBlog()를 먼저 호출하세요.');
74
+ }
75
+ return `https://${blogName}.tistory.com/manage`;
76
+ };
77
+
78
+ const getHeaders = (baseOverride) => {
79
+ const base = baseOverride || getBase();
80
+ return {
81
+ Cookie: getSessionCookies(),
82
+ 'Content-Type': 'application/json;charset=UTF-8',
83
+ 'User-Agent': USER_AGENT,
84
+ Referer: buildReferer(base),
85
+ 'X-Requested-With': 'XMLHttpRequest',
86
+ };
87
+ };
88
+
89
+ const normalizeTagPayload = (value = '') => {
90
+ const values = Array.isArray(value)
91
+ ? value
92
+ : String(value || '').replace(/\r?\n/g, ',').split(',');
93
+
94
+ return values
95
+ .map((tag) => String(tag || '').trim())
96
+ .filter(Boolean)
97
+ .map((tag) => tag.replace(/["']/g, '').trim())
98
+ .filter(Boolean)
99
+ .slice(0, 10)
100
+ .join(',');
101
+ };
102
+
103
+ const normalizeThumbnail = (value = '') => {
104
+ const normalized = String(value || '').trim().replace(/^kage@/i, '').split(/[?#]/)[0].trim();
105
+ if (!normalized) return null;
106
+ if (/^https?:\/\//i.test(normalized)) {
107
+ return normalized;
108
+ }
109
+
110
+ if (/\/[^/]+\.[A-Za-z0-9]+$/u.test(normalized)) {
111
+ return `kage@${normalized}`;
112
+ }
113
+
114
+ const suffix = normalized.endsWith('/') ? 'img.jpg' : '/img.jpg';
115
+ return `kage@${normalized}${suffix}`;
116
+ };
117
+
118
+ const requestJson = async (url, options = {}) => {
119
+ const { controller, timeout } = createFetchController();
120
+ try {
121
+ const response = await fetch(url, {
122
+ redirect: 'follow',
123
+ signal: controller.signal,
124
+ ...options,
125
+ });
126
+ if (!response.ok) {
127
+ let detail = '';
128
+ try {
129
+ detail = await response.text();
130
+ detail = detail ? `: ${detail.slice(0, 200)}` : '';
131
+ } catch {
132
+ detail = '';
133
+ }
134
+ throw new Error(`요청 실패: ${response.status} ${response.statusText}${detail}`);
135
+ }
136
+ return response.json();
137
+ } finally {
138
+ clearTimeout(timeout);
139
+ }
140
+ };
141
+
142
+ const requestText = async (url, options = {}) => {
143
+ const { controller, timeout } = createFetchController();
144
+ try {
145
+ const response = await fetch(url, {
146
+ redirect: 'follow',
147
+ signal: controller.signal,
148
+ ...options,
149
+ });
150
+ if (!response.ok) {
151
+ throw new Error(`요청 실패: ${response.status} ${response.statusText}`);
152
+ }
153
+ return response.text();
154
+ } finally {
155
+ clearTimeout(timeout);
156
+ }
157
+ };
158
+
159
+ const flattenCategories = (tree = [], collector = {}) => {
160
+ for (const item of tree) {
161
+ if (!item || typeof item !== 'object') continue;
162
+ collector[item.label] = Number(item.id);
163
+ if (Array.isArray(item.children) && item.children.length > 0) {
164
+ flattenCategories(item.children, collector);
165
+ }
166
+ }
167
+ return collector;
168
+ };
169
+
170
+ const initBlog = async () => {
171
+ if (blogName) return blogName;
172
+
173
+ const headers = {
174
+ Cookie: getSessionCookies(),
175
+ 'User-Agent': USER_AGENT,
176
+ Referer: `${API_HOST}/manage`,
177
+ };
178
+
179
+ const response = await fetch(`${API_HOST}/legacy/member/blog/api/myBlogs`, {
180
+ headers,
181
+ redirect: 'follow',
182
+ });
183
+ if (!response.ok) {
184
+ throw new Error(`블로그 정보 조회 실패: ${response.status}`);
185
+ }
186
+
187
+ const contentType = response.headers.get('content-type') || '';
188
+ if (!contentType.includes('application/json')) {
189
+ throw new Error('세션이 만료되었습니다. /auth/login으로 다시 로그인하세요.');
190
+ }
191
+
192
+ const json = await response.json();
193
+ const defaultBlog = (json?.data || []).find((blog) => blog?.defaultBlog) || (json?.data || [])[0];
194
+ if (!defaultBlog) {
195
+ throw new Error('블로그를 찾을 수 없습니다.');
196
+ }
197
+
198
+ blogName = defaultBlog.name;
199
+ blogInfo = defaultBlog;
200
+ return blogName;
201
+ };
202
+
203
+ const publishPost = async ({ title, content, visibility = 20, category = 0, tag = '', thumbnail = null }) => {
204
+ const base = getBase();
205
+ const normalizedTag = normalizeTagPayload(tag);
206
+ const normalizedThumbnail = normalizeThumbnail(thumbnail);
207
+ const body = {
208
+ id: '0',
209
+ title,
210
+ content,
211
+ visibility,
212
+ category,
213
+ tag: normalizedTag,
214
+ published: 1,
215
+ type: 'post',
216
+ uselessMarginForEntry: 1,
217
+ cclCommercial: 0,
218
+ cclDerive: 0,
219
+ attachments: [],
220
+ recaptchaValue: '',
221
+ draftSequence: null,
222
+ ...(normalizedThumbnail ? { thumbnail: normalizedThumbnail } : {}),
223
+ };
224
+
225
+ return requestJson(`${base}/post.json`, {
226
+ method: 'POST',
227
+ headers: getHeaders(base),
228
+ body: JSON.stringify(body),
229
+ });
230
+ };
231
+
232
+ const saveDraft = async ({ title, content }) => {
233
+ const base = getBase();
234
+ return requestJson(`${base}/drafts`, {
235
+ method: 'POST',
236
+ headers: getHeaders(base),
237
+ body: JSON.stringify({ title, content }),
238
+ });
239
+ };
240
+
241
+ const getPosts = async () => {
242
+ const base = getBase();
243
+ return requestJson(`${base}/posts.json`, {
244
+ method: 'GET',
245
+ headers: getHeaders(base),
246
+ });
247
+ };
248
+
249
+ const getCategories = async () => {
250
+ const base = getBase();
251
+ const html = await requestText(`${base}/newpost`, {
252
+ method: 'GET',
253
+ headers: getHeaders(base),
254
+ });
255
+
256
+ const match = html.match(/window\.Config\s*=\s*(\{[\s\S]*?\})\s*(?:\n|;)/);
257
+ if (!match) {
258
+ throw new Error('카테고리 파싱 실패');
259
+ }
260
+
261
+ const sandbox = {};
262
+ vm.runInNewContext(`var result = ${match[1]};`, sandbox);
263
+ const rootCategories = sandbox?.result?.blog?.categories;
264
+ if (!Array.isArray(rootCategories)) {
265
+ throw new Error('카테고리 파싱 실패');
266
+ }
267
+
268
+ return flattenCategories(rootCategories, {});
269
+ };
270
+
271
+ const uploadImage = async (imageBuffer, filename = 'image.jpg') => {
272
+ const base = getBase();
273
+ const formData = new FormData();
274
+ const blob = new Blob([imageBuffer], { type: 'image/jpeg' });
275
+ formData.append('file', blob, filename);
276
+
277
+ const response = await fetch(`${base}/post/attach.json`, {
278
+ method: 'POST',
279
+ headers: {
280
+ Cookie: getSessionCookies(),
281
+ 'User-Agent': USER_AGENT,
282
+ Referer: buildReferer(base),
283
+ Accept: 'application/json, text/plain, */*',
284
+ },
285
+ body: formData,
286
+ });
287
+
288
+ if (!response.ok) {
289
+ const text = await response.text().catch(() => '');
290
+ throw new Error(`이미지 업로드 실패: ${response.status} ${text ? `: ${text.slice(0, 500)}` : ''}`);
291
+ }
292
+
293
+ const uploaded = await response.json();
294
+ if (!uploaded?.url) {
295
+ throw new Error('이미지 업로드 응답에 URL이 없습니다.');
296
+ }
297
+ return uploaded;
298
+ };
299
+
300
+ const getPost = async ({ postId, includeDraft = false } = {}) => {
301
+ const normalizedPostId = String(postId || '').trim();
302
+ if (!normalizedPostId) {
303
+ return null;
304
+ }
305
+
306
+ const result = await getPosts();
307
+ const candidates = [];
308
+ if (Array.isArray(result?.items)) {
309
+ candidates.push(...result.items);
310
+ }
311
+ if (includeDraft && Array.isArray(result?.drafts)) {
312
+ candidates.push(...result.drafts);
313
+ }
314
+
315
+ return candidates.find((item) => String(item.id) === normalizedPostId) || null;
316
+ };
317
+
318
+ return {
319
+ initBlog,
320
+ publishPost,
321
+ saveDraft,
322
+ getPosts,
323
+ getCategories,
324
+ uploadImage,
325
+ getPost,
326
+ resetState,
327
+ };
328
+ };
329
+
330
+ module.exports = createTistoryApiClient;
@@ -0,0 +1,67 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+
5
+ const BASE_DIR = path.join(os.homedir(), '.viruagent-cli');
6
+ const SESSION_DIR = path.join(BASE_DIR, 'sessions');
7
+ const META_FILE = path.join(BASE_DIR, 'providers.json');
8
+
9
+ const ensureDir = (target) => {
10
+ if (!fs.existsSync(target)) fs.mkdirSync(target, { recursive: true });
11
+ };
12
+
13
+ const normalizeProvider = (provider) => String(provider || 'tistory').toLowerCase();
14
+
15
+ const readJson = (target) => {
16
+ if (!fs.existsSync(target)) return {};
17
+ try {
18
+ const raw = fs.readFileSync(target, 'utf-8');
19
+ return JSON.parse(raw);
20
+ } catch {
21
+ return {};
22
+ }
23
+ };
24
+
25
+ const writeJson = (target, data) => {
26
+ ensureDir(path.dirname(target));
27
+ fs.writeFileSync(target, JSON.stringify(data, null, 2), 'utf-8');
28
+ };
29
+
30
+ const getSessionPath = (provider) => {
31
+ ensureDir(SESSION_DIR);
32
+ return path.join(SESSION_DIR, `${normalizeProvider(provider)}-session.json`);
33
+ };
34
+
35
+ const getProvidersMeta = () => {
36
+ ensureDir(BASE_DIR);
37
+ return readJson(META_FILE);
38
+ };
39
+
40
+ const saveProviderMeta = (provider, patch) => {
41
+ const meta = getProvidersMeta();
42
+ meta[normalizeProvider(provider)] = {
43
+ ...(meta[normalizeProvider(provider)] || {}),
44
+ ...patch,
45
+ provider: normalizeProvider(provider),
46
+ updatedAt: new Date().toISOString(),
47
+ };
48
+ writeJson(META_FILE, meta);
49
+ };
50
+
51
+ const getProviderMeta = (provider) => {
52
+ const meta = getProvidersMeta();
53
+ return meta[normalizeProvider(provider)] || null;
54
+ };
55
+
56
+ const clearProviderMeta = (provider) => {
57
+ const meta = getProvidersMeta();
58
+ delete meta[normalizeProvider(provider)];
59
+ writeJson(META_FILE, meta);
60
+ };
61
+
62
+ module.exports = {
63
+ getSessionPath,
64
+ getProviderMeta,
65
+ saveProviderMeta,
66
+ clearProviderMeta,
67
+ };