viruagent-cli 0.3.0 → 0.3.2

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 greekr4
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.ko.md CHANGED
@@ -1,9 +1,21 @@
1
1
 
2
- ![viru_run2](https://github.com/user-attachments/assets/76be6d06-3f0d-44e5-8152-d64b2fc68894)
2
+ <p align="center">
3
+ <img src="https://github.com/user-attachments/assets/76be6d06-3f0d-44e5-8152-d64b2fc68894" alt="viruagent-cli" />
4
+ </p>
5
+
6
+ <h1 align="center">viruagent-cli</h1>
3
7
 
4
8
  <p align="center">
5
- <a href="README.ko.md"><img src="https://img.shields.io/badge/한국어-red?style=for-the-badge" alt="Korean"></a>
6
- <a href="README.md"><img src="https://img.shields.io/badge/English-blue?style=for-the-badge" alt="English"></a>
9
+ <a href="https://github.com/greekr4/viruagent-cli/stargazers"><img src="https://img.shields.io/github/stars/greekr4/viruagent-cli" alt="GitHub stars"></a>
10
+ </p>
11
+ <p align="center">
12
+ <a href="https://www.npmjs.com/package/viruagent-cli"><img src="https://img.shields.io/npm/v/viruagent-cli?color=blue" alt="npm version"></a>
13
+ <a href="LICENSE"><img src="https://img.shields.io/npm/l/viruagent-cli" alt="license"></a>
14
+ <a href="https://nodejs.org"><img src="https://img.shields.io/node/v/viruagent-cli" alt="node version"></a>
15
+ </p>
16
+ <p align="center">
17
+ <a href="README.md"><img src="https://img.shields.io/badge/English-blue" alt="English"></a>
18
+ <a href="README.ko.md"><img src="https://img.shields.io/badge/한국어-red" alt="Korean"></a>
7
19
  </p>
8
20
 
9
21
  **AI 에이전트가 블로그를 쓰고, 태그를 만들고, 발행까지 자동으로 처리하는 CLI 도구**
@@ -28,25 +40,25 @@
28
40
  - **에이전트**: 스킬을 읽고 CLI를 조합하여 자율적으로 실행
29
41
  - **커스텀**: 스킬 파일을 직접 수정하여 원하는 대로 동작하도록 설정 가능
30
42
 
31
- ## 설치
43
+ ## 빠른 시작
32
44
 
33
45
  ![viru_install](https://github.com/user-attachments/assets/35efb57f-fada-44c0-8dd4-6d586ef33a7c)
34
46
 
35
- 아래 내용을 AI 에이전트에게 그대로 복사해서 보여주세요.
47
+ 아래 내용을 AI 에이전트에게 그대로 복사해서 보여주세요. (AI 에이전트 대상이므로 영어로 작성되어 있습니다)
36
48
 
37
49
  ```
38
50
  You are installing viruagent-cli, a blog publishing CLI tool. Follow these steps in order.
39
51
 
40
- Step 1: Verify CLI
41
- npx viruagent-cli --spec
42
- If the output contains "ok": true, the CLI is ready. If it fails, check that Node.js
43
- >= 18 is installed.
44
-
45
- Step 2: Install skill
52
+ Step 1: Install skill
46
53
  npx viruagent-cli install-skill
47
54
  This copies the skill file to ~/.claude/commands/viruagent.md. Confirm the file
48
55
  exists before proceeding.
49
56
 
57
+ Step 2: Verify CLI
58
+ npx viruagent-cli --spec
59
+ If the output contains "ok": true, the CLI is ready. If it fails, check that Node.js
60
+ >= 18 is installed.
61
+
50
62
  Tell the user that viruagent-cli installation is complete.
51
63
  ```
52
64
 
@@ -63,7 +75,8 @@ npx viruagent-cli login --from-chrome --profile "Profile 2"
63
75
  npx viruagent-cli login --username <id> --password <pw> --headless
64
76
  ```
65
77
 
66
- `--from-chrome`은 macOS Keychain을 통해 Chrome 쿠키 DB를 직접 복호화합니다. 브라우저 실행 없이, 2FA 없이, 1초 내 완료됩니다.
78
+ > [!TIP]
79
+ > `--from-chrome`은 macOS Keychain을 통해 Chrome 쿠키 DB를 직접 복호화합니다. 브라우저 실행 없이, 2FA 없이, 1초 내 완료됩니다.
67
80
 
68
81
  ## 사용법
69
82
 
@@ -93,14 +106,14 @@ npx viruagent-cli login --username <id> --password <pw> --headless
93
106
 
94
107
  ## 기술 스택
95
108
 
96
- | 영역 | 기술 | 설명 |
97
- | --- | --- | --- |
98
- | CLI 프레임워크 | Commander.js | 명령어 정의, 옵션 파싱, `--spec` 스키마 자동 생성 |
99
- | 브라우저 자동화 | Playwright (Chromium) | 로그인 자동화 |
100
- | 쿠키 복호화 | macOS Keychain + AES-128-CBC | Chrome 세션 임포트 (`--from-chrome`) |
101
- | 세션 관리 | JSON 파일 (`~/.viruagent-cli/`) | 쿠키 기반 세션 저장/복원 |
102
- | 이미지 검색 | DuckDuckGo, Wikimedia, Commons | 키워드 기반 이미지 자동 검색 |
103
- | 출력 형식 | JSON envelope | `{ ok, data }` / `{ ok, error, hint }` |
109
+ | 영역 | 기술 |
110
+ | --- | --- |
111
+ | CLI 프레임워크 | Commander.js |
112
+ | 브라우저 자동화 | Playwright (Chromium) |
113
+ | 쿠키 복호화 | macOS Keychain + AES-128-CBC |
114
+ | 세션 관리 | JSON 파일 (`~/.viruagent-cli/`) |
115
+ | 이미지 검색 | DuckDuckGo, Wikimedia, Commons |
116
+ | 출력 형식 | JSON envelope (`{ ok, data }` / `{ ok, error, hint }`) |
104
117
 
105
118
  ## Contributing
106
119
 
@@ -116,7 +129,3 @@ git checkout -b feature/my-feature
116
129
  git commit -m "[FEAT] Add my feature"
117
130
  git push origin feature/my-feature
118
131
  ```
119
-
120
- ## License
121
-
122
- MIT
package/README.md CHANGED
@@ -1,9 +1,21 @@
1
1
 
2
- ![viru_run2](https://github.com/user-attachments/assets/76be6d06-3f0d-44e5-8152-d64b2fc68894)
2
+ <p align="center">
3
+ <img src="https://github.com/user-attachments/assets/76be6d06-3f0d-44e5-8152-d64b2fc68894" alt="viruagent-cli" />
4
+ </p>
5
+
6
+ <h1 align="center">viruagent-cli</h1>
3
7
 
4
8
  <p align="center">
5
- <a href="README.ko.md"><img src="https://img.shields.io/badge/한국어-red?style=for-the-badge" alt="Korean"></a>
6
- <a href="README.md"><img src="https://img.shields.io/badge/English-blue?style=for-the-badge" alt="English"></a>
9
+ <a href="https://github.com/greekr4/viruagent-cli/stargazers"><img src="https://img.shields.io/github/stars/greekr4/viruagent-cli" alt="GitHub stars"></a>
10
+ </p>
11
+ <p align="center">
12
+ <a href="https://www.npmjs.com/package/viruagent-cli"><img src="https://img.shields.io/npm/v/viruagent-cli?color=blue" alt="npm version"></a>
13
+ <a href="LICENSE"><img src="https://img.shields.io/npm/l/viruagent-cli" alt="license"></a>
14
+ <a href="https://nodejs.org"><img src="https://img.shields.io/node/v/viruagent-cli" alt="node version"></a>
15
+ </p>
16
+ <p align="center">
17
+ <a href="README.md"><img src="https://img.shields.io/badge/English-blue" alt="English"></a>
18
+ <a href="README.ko.md"><img src="https://img.shields.io/badge/한국어-red" alt="Korean"></a>
7
19
  </p>
8
20
 
9
21
  **A CLI tool where AI agents write, tag, and publish blog posts automatically.**
@@ -28,7 +40,7 @@ User: /viruagent "Write a post"
28
40
  - **Agent**: Reads the skill and orchestrates CLI commands autonomously
29
41
  - **Custom**: Edit the skill file to customize behavior
30
42
 
31
- ## Installation
43
+ ## Quick Start
32
44
 
33
45
  ![viru_install](https://github.com/user-attachments/assets/35efb57f-fada-44c0-8dd4-6d586ef33a7c)
34
46
 
@@ -37,16 +49,16 @@ Copy the following to your AI agent:
37
49
  ```
38
50
  You are installing viruagent-cli, a blog publishing CLI tool. Follow these steps in order.
39
51
 
40
- Step 1: Verify CLI
41
- npx viruagent-cli --spec
42
- If the output contains "ok": true, the CLI is ready. If it fails, check that Node.js
43
- >= 18 is installed.
44
-
45
- Step 2: Install skill
52
+ Step 1: Install skill
46
53
  npx viruagent-cli install-skill
47
54
  This copies the skill file to ~/.claude/commands/viruagent.md. Confirm the file
48
55
  exists before proceeding.
49
56
 
57
+ Step 2: Verify CLI
58
+ npx viruagent-cli --spec
59
+ If the output contains "ok": true, the CLI is ready. If it fails, check that Node.js
60
+ >= 18 is installed.
61
+
50
62
  Tell the user that viruagent-cli installation is complete.
51
63
  ```
52
64
 
@@ -63,7 +75,8 @@ npx viruagent-cli login --from-chrome --profile "Profile 2"
63
75
  npx viruagent-cli login --username <id> --password <pw> --headless
64
76
  ```
65
77
 
66
- `--from-chrome` decrypts Chrome's cookie database directly via macOS Keychain. No browser launch, no 2FA — completes in under 1 second.
78
+ > [!TIP]
79
+ > `--from-chrome` decrypts Chrome's cookie database directly via macOS Keychain. No browser launch, no 2FA — completes in under 1 second.
67
80
 
68
81
  ## Usage
69
82
 
@@ -93,14 +106,14 @@ Ask the agent for detailed usage or customization help.
93
106
 
94
107
  ## Tech Stack
95
108
 
96
- | Area | Tech | Description |
97
- | --- | --- | --- |
98
- | CLI Framework | Commander.js | Command definitions, option parsing, `--spec` schema |
99
- | Browser Automation | Playwright (Chromium) | Login automation |
100
- | Cookie Decryption | macOS Keychain + AES-128-CBC | Chrome session import (`--from-chrome`) |
101
- | Session Management | JSON file (`~/.viruagent-cli/`) | Cookie-based session save/restore |
102
- | Image Search | DuckDuckGo, Wikimedia, Commons | Keyword-based auto image search |
103
- | Output Format | JSON envelope | `{ ok, data }` / `{ ok, error, hint }` |
109
+ | Area | Tech |
110
+ | --- | --- |
111
+ | CLI Framework | Commander.js |
112
+ | Browser Automation | Playwright (Chromium) |
113
+ | Cookie Decryption | macOS Keychain + AES-128-CBC |
114
+ | Session Management | JSON file (`~/.viruagent-cli/`) |
115
+ | Image Search | DuckDuckGo, Wikimedia, Commons |
116
+ | Output Format | JSON envelope (`{ ok, data }` / `{ ok, error, hint }`) |
104
117
 
105
118
  ## Contributing
106
119
 
@@ -116,7 +129,3 @@ git checkout -b feature/my-feature
116
129
  git commit -m "[FEAT] Add my feature"
117
130
  git push origin feature/my-feature
118
131
  ```
119
-
120
- ## License
121
-
122
- MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viruagent-cli",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "AI-agent-optimized CLI for blog publishing (Tistory, Naver)",
5
5
  "private": false,
6
6
  "type": "commonjs",
@@ -62,47 +62,67 @@ Every post must follow this structure. Write in the same language as the user's
62
62
 
63
63
  ```html
64
64
  <!-- 1. Hook (blockquote style2 for topic quote) -->
65
- <blockquote data-ke-style="style2">[One impactful sentence about the topic]</blockquote>
65
+ <blockquote data-ke-style="style2">[One impactful sentence that captures the core insight or tension]</blockquote>
66
66
  <p data-ke-size="size16">&nbsp;</p>
67
67
 
68
- <!-- 2. Introduction (what this post covers, what the reader will learn) -->
69
- <p>[Brief overviewset expectations clearly]</p>
68
+ <!-- 2. Introduction (2~3 paragraphs: context, reader empathy, what this post covers) -->
69
+ <p data-ke-size="size18">[Describe the situation the reader relates to paint a vivid picture, 3~5 sentences]</p>
70
+ <p data-ke-size="size18">[Set expectations: what angle this post takes, what the reader will gain]</p>
70
71
  <p data-ke-size="size16">&nbsp;</p>
71
72
 
72
- <!-- 3. Body (2~4 sections with h2/h3, short paragraphs, lists) -->
73
+ <!-- 3. Body (3~4 sections with h2, each section has 2~3 paragraphs) -->
73
74
  <!-- Use <p data-ke-size="size16">&nbsp;</p> between sections for spacing -->
74
75
  <h2>[Section 1 Title — keyword-rich]</h2>
75
- <p>[Short paragraph, max 2~3 sentences]</p>
76
+ <p data-ke-size="size18">[Explain the concept or situation in 3~5 sentences. Include evidence: expert quotes, data, or real-world examples]</p>
77
+ <p data-ke-size="size18">[Deepen the point with analysis, comparison, or implication. Connect to the reader's experience]</p>
76
78
  <ul>
79
+ <li>[Key point — only use lists for 3+ concrete items worth scanning]</li>
77
80
  <li>[Key point]</li>
78
81
  <li>[Key point]</li>
79
82
  </ul>
83
+ <p data-ke-size="size16">&nbsp;</p>
80
84
 
81
85
  <h2>[Section 2 Title]</h2>
82
- <p>[Short paragraph]</p>
86
+ <p data-ke-size="size18">[Introduce a new angle, case study, or supporting argument. 3~5 sentences with specific details]</p>
87
+ <p data-ke-size="size18">[Analyze why this matters. Use <strong>bold</strong> for key terms. Connect back to the main thesis]</p>
88
+ <p data-ke-size="size16">&nbsp;</p>
89
+
90
+ <h2>[Section 3 Title]</h2>
91
+ <p data-ke-size="size18">[Practical application or actionable insight. Show, don't just tell]</p>
92
+ <p data-ke-size="size18">[Bridge to the conclusion — "what this all means"]</p>
93
+ <p data-ke-size="size16">&nbsp;</p>
83
94
 
84
95
  <!-- 4. Summary / Key Takeaways -->
85
96
  <h2>핵심 정리</h2>
86
97
  <ul>
87
- <li>[Takeaway 1]</li>
98
+ <li>[Takeaway 1 — one complete sentence, not a fragment]</li>
88
99
  <li>[Takeaway 2]</li>
89
100
  <li>[Takeaway 3]</li>
90
101
  </ul>
102
+ <p data-ke-size="size16">&nbsp;</p>
91
103
 
92
- <!-- 5. Closing (CTA or next step) -->
93
- <p>[Closing sentenceencourage action, share, or further reading]</p>
104
+ <!-- 5. Closing (specific action the reader can take) -->
105
+ <p data-ke-size="size18">[Closing 1~2 sentences suggest a concrete, immediate action. Not vague "stay tuned" but specific "try this tomorrow"]</p>
94
106
  ```
95
107
 
96
108
  ### Writing Rules
97
109
 
98
110
  - **Title**: Include the primary keyword. 10~20 characters. Short and impactful.
99
- - **Paragraphs**: Max 2~3 sentences each. Break long ideas into multiple paragraphs for readability.
111
+ - **Length**: 3000~4000 characters (한글 기준). Aim for depth, not padding.
112
+ - **Paragraphs**: 3~5 sentences each. Develop ideas fully within a paragraph before moving on. Do NOT write 1~2 sentence paragraphs repeatedly.
113
+ - **Font size**: Use `<p data-ke-size="size18">` for all body text paragraphs. Use `<p data-ke-size="size16">&nbsp;</p>` only for spacing between sections.
100
114
  - **Spacing**: Use `<p data-ke-size="size16">&nbsp;</p>` between sections for line breaks (Tistory-specific).
101
115
  - **Hook**: Always use `<blockquote data-ke-style="style2">` for the opening topic quote.
102
- - **Lists**: Use `<ul>` or `<ol>` for 3+ items. Easier to scan.
116
+ - **Introduction**: 2~3 paragraphs that set context and build reader empathy before diving into the body.
117
+ - **Body sections**: Each h2 section must have 2~3 substantial paragraphs. Do NOT jump straight to bullet lists.
118
+ - **Lists**: Use sparingly — only for 3+ concrete, scannable items. Default to paragraphs for explanation and analysis.
119
+ - **Evidence**: Each body section should include at least one of: expert quote, data point, real company example, or research finding. Cite sources naturally within the text.
120
+ - **Perspective shift**: Include at least one moment that reframes the reader's thinking (e.g., "X is not about A, it's about B").
121
+ - **Transitions**: Connect sections with bridge sentences. Avoid abrupt jumps between topics.
122
+ - **Bold**: Use `<strong>` for key terms and concepts (2~3 per section max).
103
123
  - **Subheadings**: Use `<h2>` for ALL section titles. Do NOT use `<h3>`. Keep heading sizes consistent.
104
- - **Tone**: Conversational but informative. Avoid jargon unless the audience expects it.
105
- - **Length**: 1500~2000 characters (한글 기준) for standard posts. Do NOT exceed 2000 characters.
124
+ - **Tone**: Conversational but substantive. Write as if explaining to a smart colleague, not listing facts for a report.
125
+ - **Closing**: End with a specific, actionable suggestion the reader can try immediately — not a vague "stay tuned."
106
126
  - **SEO**: Primary keyword in title, first paragraph, and at least one `<h2>`. Don't keyword-stuff.
107
127
 
108
128
  ```bash
@@ -1,5 +1,5 @@
1
1
  const { chromium } = require('playwright');
2
- const { execSync } = require('child_process');
2
+ const { execSync, execFileSync } = require('child_process');
3
3
  const fs = require('fs');
4
4
  const os = require('os');
5
5
  const crypto = require('crypto');
@@ -1734,7 +1734,7 @@ const persistTistorySession = async (context, targetSessionPath) => {
1734
1734
  );
1735
1735
  };
1736
1736
 
1737
- const decryptChromeCookie = (encryptedValue, derivedKey) => {
1737
+ const decryptChromeCookieMac = (encryptedValue, derivedKey) => {
1738
1738
  if (!encryptedValue || encryptedValue.length < 4) return '';
1739
1739
  const prefix = encryptedValue.slice(0, 3).toString('ascii');
1740
1740
  if (prefix !== 'v10') return encryptedValue.toString('utf-8');
@@ -1757,19 +1757,136 @@ const decryptChromeCookie = (encryptedValue, derivedKey) => {
1757
1757
  }
1758
1758
  };
1759
1759
 
1760
+ const getWindowsChromeMasterKey = (chromeRoot) => {
1761
+ const localStatePath = path.join(chromeRoot, 'Local State');
1762
+ if (!fs.existsSync(localStatePath)) {
1763
+ throw new Error('Chrome Local State 파일을 찾을 수 없습니다.');
1764
+ }
1765
+ const localState = JSON.parse(fs.readFileSync(localStatePath, 'utf-8'));
1766
+ const encryptedKeyB64 = localState.os_crypt && localState.os_crypt.encrypted_key;
1767
+ if (!encryptedKeyB64) {
1768
+ throw new Error('Chrome Local State에서 암호화 키를 찾을 수 없습니다.');
1769
+ }
1770
+ const encryptedKeyWithPrefix = Buffer.from(encryptedKeyB64, 'base64');
1771
+ // 앞 5바이트 "DPAPI" 접두사 제거
1772
+ const encryptedKey = encryptedKeyWithPrefix.slice(5);
1773
+ const encHex = encryptedKey.toString('hex');
1774
+
1775
+ // PowerShell DPAPI로 복호화
1776
+ const psScript = `
1777
+ Add-Type -AssemblyName System.Security
1778
+ $encBytes = [byte[]]::new(${encryptedKey.length})
1779
+ $hex = '${encHex}'
1780
+ for ($i = 0; $i -lt $encBytes.Length; $i++) {
1781
+ $encBytes[$i] = [Convert]::ToByte($hex.Substring($i * 2, 2), 16)
1782
+ }
1783
+ $decBytes = [System.Security.Cryptography.ProtectedData]::Unprotect($encBytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)
1784
+ $decHex = -join ($decBytes | ForEach-Object { $_.ToString('x2') })
1785
+ Write-Output $decHex
1786
+ `.trim().replace(/\n/g, '; ');
1787
+
1788
+ try {
1789
+ const decHex = execSync(
1790
+ `powershell -NoProfile -Command "${psScript}"`,
1791
+ { encoding: 'utf-8', timeout: 10000 }
1792
+ ).trim();
1793
+ return Buffer.from(decHex, 'hex');
1794
+ } catch {
1795
+ throw new Error('Chrome 암호화 키를 DPAPI로 복호화할 수 없습니다.');
1796
+ }
1797
+ };
1798
+
1799
+ const decryptChromeCookieWindows = (encryptedValue, masterKey) => {
1800
+ if (!encryptedValue || encryptedValue.length < 4) return '';
1801
+ const prefix = encryptedValue.slice(0, 3).toString('ascii');
1802
+ if (prefix !== 'v10' && prefix !== 'v20') return encryptedValue.toString('utf-8');
1803
+
1804
+ // AES-256-GCM: nonce(12바이트) + ciphertext + authTag(16바이트)
1805
+ const nonce = encryptedValue.slice(3, 3 + 12);
1806
+ const authTag = encryptedValue.slice(encryptedValue.length - 16);
1807
+ const ciphertext = encryptedValue.slice(3 + 12, encryptedValue.length - 16);
1808
+
1809
+ try {
1810
+ const decipher = crypto.createDecipheriv('aes-256-gcm', masterKey, nonce);
1811
+ decipher.setAuthTag(authTag);
1812
+ const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
1813
+ return dec.toString('utf-8');
1814
+ } catch {
1815
+ return '';
1816
+ }
1817
+ };
1818
+
1819
+ const decryptChromeCookie = (encryptedValue, key) => {
1820
+ if (process.platform === 'win32') {
1821
+ return decryptChromeCookieWindows(encryptedValue, key);
1822
+ }
1823
+ return decryptChromeCookieMac(encryptedValue, key);
1824
+ };
1825
+
1826
+ const copyFileViaVSS = (srcPath, destPath) => {
1827
+ const scriptPath = path.join(__dirname, '..', 'scripts', 'vss-copy.ps1');
1828
+ if (!fs.existsSync(scriptPath)) return false;
1829
+ try {
1830
+ const result = execSync(
1831
+ 'powershell -NoProfile -ExecutionPolicy Bypass -File "' + scriptPath + '" -SourcePath "' + srcPath + '" -DestPath "' + destPath + '"',
1832
+ { encoding: 'utf-8', timeout: 30000 }
1833
+ ).trim();
1834
+ return result.includes('OK');
1835
+ } catch {
1836
+ return false;
1837
+ }
1838
+ };
1839
+
1840
+ const isChromeRunning = () => {
1841
+ try {
1842
+ if (process.platform === 'win32') {
1843
+ const result = execSync('tasklist /FI "IMAGENAME eq chrome.exe" /NH', { encoding: 'utf-8', timeout: 5000 });
1844
+ return result.includes('chrome.exe');
1845
+ }
1846
+ const result = execSync('pgrep -x "Google Chrome" 2>/dev/null || pgrep -x chrome 2>/dev/null', { encoding: 'utf-8', timeout: 5000 });
1847
+ return result.trim().length > 0;
1848
+ } catch {
1849
+ return false;
1850
+ }
1851
+ };
1852
+
1760
1853
  const extractChromeCookies = (cookiesDb, derivedKey, domainPattern) => {
1761
1854
  const tempDb = path.join(os.tmpdir(), `viruagent-cookies-${Date.now()}.db`);
1855
+
1856
+ // SQLite 온라인 백업 API 사용 (Chrome이 실행 중이어도 동작)
1857
+ // execFileSync로 쉘을 거치지 않아 Windows 경로 공백/이스케이핑 문제 없음
1858
+ const backupCmd = process.platform === 'win32'
1859
+ ? `.backup "${tempDb}"`
1860
+ : `.backup '${tempDb.replace(/'/g, "''")}'`;
1762
1861
  try {
1763
- execSync(`sqlite3 "${cookiesDb}" ".backup '${tempDb}'"`, { timeout: 5000 });
1862
+ execFileSync('sqlite3', [cookiesDb, backupCmd], { stdio: 'ignore', timeout: 10000 });
1764
1863
  } catch {
1765
- throw new Error('Chrome 쿠키 DB 복사에 실패했습니다. Chrome이 실행 중이면 잠시 후 다시 시도해 주세요.');
1864
+ // sqlite3 백업 실패 파일 복사 VSS 순으로 폴백
1865
+ let copied = false;
1866
+ try {
1867
+ fs.copyFileSync(cookiesDb, tempDb);
1868
+ copied = true;
1869
+ } catch {}
1870
+ if (!copied && process.platform === 'win32') {
1871
+ // Windows: VSS(Volume Shadow Copy)로 잠긴 파일 복사
1872
+ copied = copyFileViaVSS(cookiesDb, tempDb);
1873
+ }
1874
+ if (!copied) {
1875
+ throw new Error('Chrome 쿠키 DB 복사에 실패했습니다. Chrome이 실행 중이면 종료 후 다시 시도해 주세요.');
1876
+ }
1877
+ }
1878
+
1879
+ // 백업 후 남은 WAL/SHM 파일 제거 (깨끗한 DB 보장)
1880
+ for (const suffix of ['-wal', '-shm', '-journal']) {
1881
+ try { fs.unlinkSync(tempDb + suffix); } catch {}
1766
1882
  }
1767
1883
 
1768
1884
  try {
1769
- const rows = execSync(
1770
- `sqlite3 -separator '||' "${tempDb}" "SELECT host_key, name, value, hex(encrypted_value), path, expires_utc, is_secure, is_httponly, samesite FROM cookies WHERE host_key LIKE '${domainPattern}'"`,
1771
- { encoding: 'utf-8', timeout: 5000 }
1772
- ).trim();
1885
+ const query = `SELECT host_key, name, value, hex(encrypted_value), path, expires_utc, is_secure, is_httponly, samesite FROM cookies WHERE host_key LIKE '${domainPattern}'`;
1886
+ const rows = execFileSync('sqlite3', ['-separator', '||', tempDb, query], {
1887
+ encoding: 'utf-8',
1888
+ timeout: 5000,
1889
+ }).trim();
1773
1890
  if (!rows) return [];
1774
1891
 
1775
1892
  const chromeEpochOffset = 11644473600;
@@ -1789,31 +1906,205 @@ const extractChromeCookies = (cookiesDb, derivedKey, domainPattern) => {
1789
1906
  }
1790
1907
  };
1791
1908
 
1909
+ const findWindowsChromePath = () => {
1910
+ const candidates = [
1911
+ path.join(process.env['PROGRAMFILES(X86)'] || '', 'Google', 'Chrome', 'Application', 'chrome.exe'),
1912
+ path.join(process.env['PROGRAMFILES'] || '', 'Google', 'Chrome', 'Application', 'chrome.exe'),
1913
+ path.join(process.env['LOCALAPPDATA'] || '', 'Google', 'Chrome', 'Application', 'chrome.exe'),
1914
+ ];
1915
+ return candidates.find(p => fs.existsSync(p)) || null;
1916
+ };
1917
+
1918
+ const generateSelfSignedCert = (domain) => {
1919
+ const tempDir = path.join(os.tmpdir(), `viruagent-cert-${Date.now()}`);
1920
+ fs.mkdirSync(tempDir, { recursive: true });
1921
+ const keyPath = path.join(tempDir, 'key.pem');
1922
+ const certPath = path.join(tempDir, 'cert.pem');
1923
+
1924
+ // openssl (Git for Windows에 포함)
1925
+ const opensslPaths = [
1926
+ 'openssl',
1927
+ 'C:/Program Files/Git/usr/bin/openssl.exe',
1928
+ 'C:/Program Files (x86)/Git/usr/bin/openssl.exe',
1929
+ ];
1930
+ let generated = false;
1931
+ for (const openssl of opensslPaths) {
1932
+ try {
1933
+ execSync(
1934
+ `"${openssl}" req -x509 -newkey rsa:2048 -nodes -keyout "${keyPath}" -out "${certPath}" -days 1 -subj "/CN=${domain}"`,
1935
+ { timeout: 10000, stdio: 'pipe' }
1936
+ );
1937
+ generated = true;
1938
+ break;
1939
+ } catch {}
1940
+ }
1941
+ if (!generated) {
1942
+ try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch {}
1943
+ return null;
1944
+ }
1945
+ return { keyPath, certPath, tempDir };
1946
+ };
1947
+
1948
+ const importSessionViaChromeDirectLaunch = async (targetSessionPath, chromeRoot, profileName) => {
1949
+ // Windows Chrome 145+: v20 App Bound Encryption으로 외부에서 쿠키 복호화 불가
1950
+ // HTTPS 서버 + DNS 리다이렉션으로 Chrome이 보내는 Cookie 헤더에서 세션 추출
1951
+ if (isChromeRunning()) {
1952
+ throw new Error(
1953
+ 'Chrome이 실행 중입니다. --from-chrome 세션 추출을 위해 Chrome을 종료한 후 다시 시도해 주세요.'
1954
+ );
1955
+ }
1956
+
1957
+ const chromePath = findWindowsChromePath();
1958
+ if (!chromePath) {
1959
+ throw new Error('Chrome 실행 파일을 찾을 수 없습니다.');
1960
+ }
1961
+
1962
+ // 1. 자체 서명 인증서 생성 (openssl 필요)
1963
+ const cert = generateSelfSignedCert('www.tistory.com');
1964
+ if (!cert) {
1965
+ throw new Error(
1966
+ 'openssl을 찾을 수 없습니다. Git for Windows를 설치하면 openssl이 포함됩니다.'
1967
+ );
1968
+ }
1969
+
1970
+ const https = require('https');
1971
+ const { spawn } = require('child_process');
1972
+
1973
+ // 2. HTTPS 서버 시작 (포트 443)
1974
+ const server = https.createServer({
1975
+ key: fs.readFileSync(cert.keyPath),
1976
+ cert: fs.readFileSync(cert.certPath),
1977
+ });
1978
+
1979
+ try {
1980
+ await new Promise((resolve, reject) => {
1981
+ server.on('error', reject);
1982
+ server.listen(443, '127.0.0.1', resolve);
1983
+ });
1984
+ } catch (e) {
1985
+ try { fs.rmSync(cert.tempDir, { recursive: true, force: true }); } catch {}
1986
+ throw new Error(`포트 443 바인딩 실패: ${e.message}. 관리자 권한으로 실행해 주세요.`);
1987
+ }
1988
+
1989
+ // 3. 쿠키 수신 Promise
1990
+ let chromeProc = null;
1991
+ const cookiePromise = new Promise((resolve, reject) => {
1992
+ const timeout = setTimeout(() => {
1993
+ reject(new Error('Chrome 쿠키 추출 시간 초과 (15초)'));
1994
+ }, 15000);
1995
+
1996
+ server.on('request', (req, res) => {
1997
+ res.writeHead(200, { 'Content-Type': 'text/html' });
1998
+ res.end('<html><body>Session captured. You can close this window.</body></html>');
1999
+
2000
+ if (req.url === '/' || req.url === '') {
2001
+ clearTimeout(timeout);
2002
+ const cookieHeader = req.headers.cookie || '';
2003
+ resolve(cookieHeader);
2004
+ }
2005
+ });
2006
+ });
2007
+
2008
+ // 4. Chrome 실행 (기본 프로필, DNS 리다이렉션, 인증서 오류 무시)
2009
+ chromeProc = spawn(chromePath, [
2010
+ '--no-first-run',
2011
+ '--no-default-browser-check',
2012
+ `--profile-directory=${profileName}`,
2013
+ '--host-resolver-rules=MAP www.tistory.com 127.0.0.1',
2014
+ '--ignore-certificate-errors',
2015
+ 'https://www.tistory.com/',
2016
+ ], { detached: true, stdio: 'ignore' });
2017
+ chromeProc.unref();
2018
+
2019
+ try {
2020
+ const cookieHeader = await cookiePromise;
2021
+
2022
+ // Cookie 헤더 파싱
2023
+ const cookies = cookieHeader.split(';')
2024
+ .map(c => c.trim())
2025
+ .filter(Boolean)
2026
+ .map(c => {
2027
+ const eqIdx = c.indexOf('=');
2028
+ if (eqIdx < 0) return null;
2029
+ return { name: c.slice(0, eqIdx).trim(), value: c.slice(eqIdx + 1).trim() };
2030
+ })
2031
+ .filter(Boolean);
2032
+
2033
+ const tssession = cookies.find(c => c.name === 'TSSESSION');
2034
+ if (!tssession || !tssession.value) {
2035
+ throw new Error(
2036
+ 'Chrome에 티스토리 로그인 세션이 없습니다. Chrome에서 먼저 티스토리에 로그인해 주세요.'
2037
+ );
2038
+ }
2039
+
2040
+ // Cookie 헤더에는 domain/path/expires 정보가 없으므로 기본값 설정
2041
+ const payload = {
2042
+ cookies: cookies.map(c => ({
2043
+ name: c.name,
2044
+ value: c.value,
2045
+ domain: '.tistory.com',
2046
+ path: '/',
2047
+ expires: -1,
2048
+ httpOnly: false,
2049
+ secure: true,
2050
+ sameSite: 'None',
2051
+ })),
2052
+ updatedAt: new Date().toISOString(),
2053
+ };
2054
+
2055
+ await fs.promises.mkdir(path.dirname(targetSessionPath), { recursive: true });
2056
+ await fs.promises.writeFile(targetSessionPath, JSON.stringify(payload, null, 2), 'utf-8');
2057
+
2058
+ return { cookieCount: cookies.length };
2059
+ } finally {
2060
+ server.close();
2061
+ if (chromeProc) {
2062
+ try { execSync(`taskkill /F /PID ${chromeProc.pid} /T`, { stdio: 'ignore', timeout: 5000 }); } catch {}
2063
+ }
2064
+ try { fs.rmSync(cert.tempDir, { recursive: true, force: true }); } catch {}
2065
+ }
2066
+ };
2067
+
1792
2068
  const importSessionFromChrome = async (targetSessionPath, profileName = 'Default') => {
1793
- const chromeRoot = path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome');
2069
+ let chromeRoot;
2070
+ if (process.platform === 'win32') {
2071
+ chromeRoot = path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'Google', 'Chrome', 'User Data');
2072
+ } else {
2073
+ chromeRoot = path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome');
2074
+ }
1794
2075
  if (!fs.existsSync(chromeRoot)) {
1795
2076
  throw new Error('Chrome이 설치되어 있지 않습니다.');
1796
2077
  }
1797
2078
 
1798
2079
  const profileDir = path.join(chromeRoot, profileName);
1799
- const cookiesDb = path.join(profileDir, 'Cookies');
2080
+ // Windows 최신 Chrome은 Network/Cookies, 이전 버전은 Cookies
2081
+ let cookiesDb = path.join(profileDir, 'Network', 'Cookies');
2082
+ if (!fs.existsSync(cookiesDb)) {
2083
+ cookiesDb = path.join(profileDir, 'Cookies');
2084
+ }
1800
2085
  if (!fs.existsSync(cookiesDb)) {
1801
2086
  throw new Error(`Chrome 프로필 "${profileName}"에 쿠키 DB가 없습니다.`);
1802
2087
  }
1803
2088
 
1804
- // 1) Keychain에서 Chrome 암호화 키 추출
1805
- let keychainPassword;
1806
- try {
1807
- keychainPassword = execSync(
1808
- 'security find-generic-password -s "Chrome Safe Storage" -w',
1809
- { encoding: 'utf-8', timeout: 5000 }
1810
- ).trim();
1811
- } catch {
1812
- throw new Error('Chrome Safe Storage 키를 Keychain에서 읽을 수 없습니다. macOS 권한을 확인해 주세요.');
2089
+ let derivedKey;
2090
+ if (process.platform === 'win32') {
2091
+ // Windows: Local State → DPAPI로 마스터 키 복호화
2092
+ derivedKey = getWindowsChromeMasterKey(chromeRoot);
2093
+ } else {
2094
+ // macOS: Keychain에서 Chrome 암호화 키 추출
2095
+ let keychainPassword;
2096
+ try {
2097
+ keychainPassword = execSync(
2098
+ 'security find-generic-password -s "Chrome Safe Storage" -w',
2099
+ { encoding: 'utf-8', timeout: 5000 }
2100
+ ).trim();
2101
+ } catch {
2102
+ throw new Error('Chrome Safe Storage 키를 Keychain에서 읽을 수 없습니다. macOS 권한을 확인해 주세요.');
2103
+ }
2104
+ derivedKey = crypto.pbkdf2Sync(keychainPassword, 'saltysalt', 1003, 16, 'sha1');
1813
2105
  }
1814
- const derivedKey = crypto.pbkdf2Sync(keychainPassword, 'saltysalt', 1003, 16, 'sha1');
1815
2106
 
1816
- // 2) Chrome에서 tistory + kakao 쿠키 복호화 추출
2107
+ // Chrome에서 tistory + kakao 쿠키 복호화 추출
1817
2108
  const tistoryCookies = extractChromeCookies(cookiesDb, derivedKey, '%tistory.com');
1818
2109
  const kakaoCookies = extractChromeCookies(cookiesDb, derivedKey, '%kakao.com');
1819
2110
 
@@ -1829,6 +2120,11 @@ const importSessionFromChrome = async (targetSessionPath, profileName = 'Default
1829
2120
  // 3) 카카오 세션 쿠키가 있으면 Playwright에 주입 후 자동 로그인
1830
2121
  const hasKakaoSession = kakaoCookies.some(c => c.domain.includes('kakao.com') && (c.name === '_kawlt' || c.name === '_kawltea' || c.name === '_karmt'));
1831
2122
  if (!hasKakaoSession) {
2123
+ // Windows v20 App Bound Encryption: DPAPI만으로 복호화 불가
2124
+ // Playwright persistent context (pipe 모드)로 Chrome 기본 프로필에서 직접 추출
2125
+ if (process.platform === 'win32') {
2126
+ return await importSessionViaChromeDirectLaunch(targetSessionPath, chromeRoot, profileName);
2127
+ }
1832
2128
  throw new Error('Chrome에 카카오 로그인 세션이 없습니다. Chrome에서 먼저 카카오 계정에 로그인해 주세요.');
1833
2129
  }
1834
2130
 
@@ -0,0 +1,28 @@
1
+ param(
2
+ [Parameter(Mandatory=$true)][string]$SourcePath,
3
+ [Parameter(Mandatory=$true)][string]$DestPath
4
+ )
5
+
6
+ $ErrorActionPreference = 'Stop'
7
+
8
+ $drive = $SourcePath.Substring(0, 2) + '\'
9
+ $relativePath = $SourcePath.Substring(2)
10
+
11
+ try {
12
+ $shadow = (Get-WmiObject -List Win32_ShadowCopy).Create($drive, 'ClientAccessible')
13
+ $shadowObj = Get-WmiObject Win32_ShadowCopy | Where-Object { $_.ID -eq $shadow.ShadowID }
14
+ $shadowPath = $shadowObj.DeviceObject + $relativePath
15
+
16
+ cmd /c "copy `"$shadowPath`" `"$DestPath`" /y" | Out-Null
17
+
18
+ $shadowObj.Delete()
19
+
20
+ if (Test-Path $DestPath) {
21
+ Write-Output 'OK'
22
+ } else {
23
+ Write-Output 'FAIL'
24
+ }
25
+ } catch {
26
+ Write-Error $_
27
+ exit 1
28
+ }