@giwonn/claude-daily-review 0.3.13 → 0.4.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/.claude-plugin/marketplace.json +1 -1
- package/commands/generate.md +40 -0
- package/hooks/on-stop.mjs +33 -1
- package/hooks/recover-sessions.mjs +15 -9
- package/lib/collect-raw-logs.mjs +26 -2
- package/lib/git-parser.mjs +79 -0
- package/lib/github-storage.mjs +32 -18
- package/lib/raw-logger.mjs +22 -0
- package/lib/storage.mjs +6 -2
- package/lib/types.d.ts +10 -0
- package/package.json +1 -1
package/commands/generate.md
CHANGED
|
@@ -65,9 +65,24 @@ This outputs JSON with:
|
|
|
65
65
|
- `needs.quarterly`: quarters that need quarterly summary
|
|
66
66
|
- `needs.yearly`: years that need yearly summary
|
|
67
67
|
- `logs`: raw conversation logs keyed by date
|
|
68
|
+
- `gitActivity`: git commit entries keyed by date (each with `action`, `hash`, `branch`, `message`, `remote`, `ghAccount`, `cwd`)
|
|
68
69
|
|
|
69
70
|
If `needs` is all empty, tell the user "해당 기간에 생성할 회고가 없습니다." and stop.
|
|
70
71
|
|
|
72
|
+
## Step 1.5: Git Account Access Check
|
|
73
|
+
|
|
74
|
+
If `gitActivity` has entries, verify account access before generating reviews:
|
|
75
|
+
|
|
76
|
+
1. Collect unique `ghAccount` values from all git entries
|
|
77
|
+
2. Run `gh auth status` to get the list of currently authenticated accounts
|
|
78
|
+
3. For each `ghAccount` NOT in the authenticated list, ask the user via AskUserQuestion:
|
|
79
|
+
> "다음 GitHub 계정의 커밋 이력이 있지만, 현재 gh에 로그인되어 있지 않습니다: `{account}`"
|
|
80
|
+
- option 1: label: "로그인하기", description: "gh auth login으로 인증합니다"
|
|
81
|
+
- option 2: label: "해당 커밋 무시", description: "이 계정의 커밋 정보 없이 진행합니다"
|
|
82
|
+
- If "로그인하기": Tell the user to run `! gh auth login` in their terminal, then verify with `gh auth status`
|
|
83
|
+
- If "해당 커밋 무시": Remove that account's entries from `gitActivity`
|
|
84
|
+
4. Remember the original active account (`gh auth status --active`) so you can restore it later
|
|
85
|
+
|
|
71
86
|
## Step 2: Apply Filters
|
|
72
87
|
|
|
73
88
|
- **Project filter**: If the user requested a specific project, filter `logs[date]` entries to only include those where the last path segment of `cwd` matches the project name.
|
|
@@ -84,8 +99,27 @@ For each date in `needs.daily`:
|
|
|
84
99
|
- **배운 것**: New things learned
|
|
85
100
|
- **고민한 포인트**: Decisions and reasoning
|
|
86
101
|
- **질문과 답변**: Key Q&A (summarized)
|
|
102
|
+
- **커밋 내역**: If `gitActivity[date]` has entries for this project's cwd (see below)
|
|
87
103
|
4. General questions go under "미분류"
|
|
88
104
|
|
|
105
|
+
### Using Git Activity in Daily Reviews
|
|
106
|
+
|
|
107
|
+
If `gitActivity[date]` has entries matching a project (by `cwd`):
|
|
108
|
+
|
|
109
|
+
1. Switch gh account if needed: `gh auth switch --user <ghAccount>`
|
|
110
|
+
2. Parse `remote` to extract `owner/repo`:
|
|
111
|
+
- SSH format `git@github.com:owner/repo.git` → `owner/repo`
|
|
112
|
+
- HTTPS format `https://github.com/owner/repo.git` → `owner/repo`
|
|
113
|
+
3. Fetch commit details: `gh api repos/{owner}/{repo}/commits/{hash} --jq '.files[].filename'` to see changed files
|
|
114
|
+
4. Use the conversation context + commit info to describe what was actually implemented
|
|
115
|
+
5. After all lookups for a given account, switch back to the original account
|
|
116
|
+
|
|
117
|
+
Include in the review:
|
|
118
|
+
```markdown
|
|
119
|
+
**커밋 내역:**
|
|
120
|
+
- [`{short_hash}`](https://github.com/{owner}/{repo}/commit/{hash}) — {message}
|
|
121
|
+
```
|
|
122
|
+
|
|
89
123
|
Write via:
|
|
90
124
|
```bash
|
|
91
125
|
echo '<content>' | CLAUDE_PLUGIN_DATA="${CLAUDE_PLUGIN_DATA}" node "${CLAUDE_PLUGIN_ROOT}/lib/storage-cli.mjs" write "daily/{date}.md"
|
|
@@ -193,3 +227,9 @@ Tell the user what was generated:
|
|
|
193
227
|
> - 연간 요약: {count}개
|
|
194
228
|
|
|
195
229
|
Only show lines where count > 0.
|
|
230
|
+
|
|
231
|
+
If git activity was included, also report:
|
|
232
|
+
> - 커밋 연동: {count}개 커밋 반영
|
|
233
|
+
|
|
234
|
+
If any git entries were skipped (account not logged in), note:
|
|
235
|
+
> - ⚠ 일부 커밋은 GitHub 계정 미연동으로 제외됨
|
package/hooks/on-stop.mjs
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// @ts-check
|
|
3
3
|
import { loadConfig, createStorageAdapter } from '../lib/config.mjs';
|
|
4
|
-
import { parseHookInput, appendRawLog } from '../lib/raw-logger.mjs';
|
|
4
|
+
import { parseHookInput, appendRawLog, appendGitLogs } from '../lib/raw-logger.mjs';
|
|
5
|
+
import { parseGitActivity } from '../lib/git-parser.mjs';
|
|
5
6
|
import { getRawDir } from '../lib/vault.mjs';
|
|
6
7
|
import { formatDate } from '../lib/periods.mjs';
|
|
7
8
|
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
9
|
+
import { execSync } from 'child_process';
|
|
8
10
|
import { dirname, join } from 'path';
|
|
9
11
|
|
|
10
12
|
/**
|
|
@@ -58,6 +60,36 @@ async function main() {
|
|
|
58
60
|
const date = formatDate(new Date());
|
|
59
61
|
await appendRawLog(storage, sessionDir, date, input);
|
|
60
62
|
|
|
63
|
+
// Extract and save git activity from transcript
|
|
64
|
+
if (input.transcript_path) {
|
|
65
|
+
const gitEntries = parseGitActivity(input.transcript_path);
|
|
66
|
+
if (gitEntries.length > 0) {
|
|
67
|
+
// Resolve remote URL for each unique cwd
|
|
68
|
+
/** @type {Map<string, string>} */
|
|
69
|
+
const remoteByDir = new Map();
|
|
70
|
+
for (const entry of gitEntries) {
|
|
71
|
+
if (!entry.cwd || remoteByDir.has(entry.cwd)) continue;
|
|
72
|
+
try {
|
|
73
|
+
const remote = execSync('git remote get-url origin', { cwd: entry.cwd, encoding: 'utf-8', timeout: 5000 }).trim();
|
|
74
|
+
remoteByDir.set(entry.cwd, remote);
|
|
75
|
+
} catch { remoteByDir.set(entry.cwd, ''); }
|
|
76
|
+
}
|
|
77
|
+
for (const entry of gitEntries) {
|
|
78
|
+
entry.remote = remoteByDir.get(entry.cwd) || '';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Get current gh account
|
|
82
|
+
let ghAccount = '';
|
|
83
|
+
try {
|
|
84
|
+
const status = execSync('gh auth status 2>&1', { encoding: 'utf-8', timeout: 5000 });
|
|
85
|
+
const match = status.match(/Logged in to github\.com account (\S+)/);
|
|
86
|
+
if (match) ghAccount = match[1];
|
|
87
|
+
} catch { /* gh not available or not logged in */ }
|
|
88
|
+
|
|
89
|
+
await appendGitLogs(storage, sessionDir, date, gitEntries, input.session_id, ghAccount);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
61
93
|
// Save transcript path locally for session recovery (not to remote storage)
|
|
62
94
|
if (input.transcript_path) {
|
|
63
95
|
const dataDir = process.env.CLAUDE_PLUGIN_DATA;
|
|
@@ -23,19 +23,25 @@ function acquireLock() {
|
|
|
23
23
|
|
|
24
24
|
mkdirSync(dirname(lockPath), { recursive: true });
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
const lockData = JSON.stringify({ pid: process.pid, timestamp: new Date().toISOString() });
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
writeFileSync(lockPath, lockData, { flag: 'wx' });
|
|
30
|
+
return true;
|
|
31
|
+
} catch {
|
|
32
|
+
// File exists — check if stale
|
|
28
33
|
try {
|
|
29
34
|
const lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
|
|
30
35
|
const age = Date.now() - new Date(lock.timestamp).getTime();
|
|
31
|
-
if (age < LOCK_STALE_MS) return false;
|
|
32
|
-
|
|
36
|
+
if (age < LOCK_STALE_MS) return false;
|
|
37
|
+
unlinkSync(lockPath);
|
|
38
|
+
// Retry once after removing stale lock
|
|
39
|
+
try {
|
|
40
|
+
writeFileSync(lockPath, lockData, { flag: 'wx' });
|
|
41
|
+
return true;
|
|
42
|
+
} catch { return false; }
|
|
43
|
+
} catch { return false; }
|
|
33
44
|
}
|
|
34
|
-
|
|
35
|
-
try {
|
|
36
|
-
writeFileSync(lockPath, JSON.stringify({ pid: process.pid, timestamp: new Date().toISOString() }));
|
|
37
|
-
return true;
|
|
38
|
-
} catch { return false; }
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
function releaseLock() {
|
package/lib/collect-raw-logs.mjs
CHANGED
|
@@ -69,6 +69,8 @@ async function main() {
|
|
|
69
69
|
const sessions = await storage.list('.raw');
|
|
70
70
|
/** @type {Record<string, Array<{type: string, message: string, cwd: string, timestamp: string}>>} */
|
|
71
71
|
const logsByDate = {};
|
|
72
|
+
/** @type {Record<string, Array<{action: string, hash: string, branch: string, message: string, remote: string, ghAccount: string, cwd: string, timestamp: string}>>} */
|
|
73
|
+
const gitByDate = {};
|
|
72
74
|
const affectedDates = new Set();
|
|
73
75
|
|
|
74
76
|
for (const sess of sessions) {
|
|
@@ -86,6 +88,25 @@ async function main() {
|
|
|
86
88
|
for (const line of content.trim().split('\n')) {
|
|
87
89
|
try {
|
|
88
90
|
const entry = JSON.parse(line);
|
|
91
|
+
|
|
92
|
+
if (entry.type === 'git') {
|
|
93
|
+
if (!gitByDate[date]) gitByDate[date] = [];
|
|
94
|
+
gitByDate[date].push({
|
|
95
|
+
action: entry.action || '',
|
|
96
|
+
hash: entry.hash || '',
|
|
97
|
+
branch: entry.branch || '',
|
|
98
|
+
message: entry.message || '',
|
|
99
|
+
remote: entry.remote || '',
|
|
100
|
+
ghAccount: entry.ghAccount || '',
|
|
101
|
+
cwd: entry.cwd || '',
|
|
102
|
+
timestamp: entry.timestamp || '',
|
|
103
|
+
});
|
|
104
|
+
if (!lastGenerated || entry.timestamp > lastGenerated) {
|
|
105
|
+
affectedDates.add(date);
|
|
106
|
+
}
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
89
110
|
if (!logsByDate[date]) logsByDate[date] = [];
|
|
90
111
|
logsByDate[date].push({
|
|
91
112
|
type: entry.type || 'unknown',
|
|
@@ -104,7 +125,7 @@ async function main() {
|
|
|
104
125
|
|
|
105
126
|
// 2. If no new entries, nothing to do
|
|
106
127
|
if (affectedDates.size === 0) {
|
|
107
|
-
console.log(JSON.stringify({ needs: { daily: [], weekly: [], monthly: [], quarterly: [], yearly: [] }, logs: {} }));
|
|
128
|
+
console.log(JSON.stringify({ needs: { daily: [], weekly: [], monthly: [], quarterly: [], yearly: [] }, logs: {}, gitActivity: {} }));
|
|
108
129
|
return;
|
|
109
130
|
}
|
|
110
131
|
|
|
@@ -126,8 +147,10 @@ async function main() {
|
|
|
126
147
|
|
|
127
148
|
// 4. Only include logs for affected dates
|
|
128
149
|
const filteredLogs = {};
|
|
150
|
+
const filteredGit = {};
|
|
129
151
|
for (const date of affectedDates) {
|
|
130
|
-
filteredLogs[date] = logsByDate[date];
|
|
152
|
+
if (logsByDate[date]) filteredLogs[date] = logsByDate[date];
|
|
153
|
+
if (gitByDate[date]) filteredGit[date] = gitByDate[date];
|
|
131
154
|
}
|
|
132
155
|
|
|
133
156
|
// 5. Output
|
|
@@ -145,6 +168,7 @@ async function main() {
|
|
|
145
168
|
yearly: [...affectedYears].sort(),
|
|
146
169
|
},
|
|
147
170
|
logs: filteredLogs,
|
|
171
|
+
gitActivity: filteredGit,
|
|
148
172
|
};
|
|
149
173
|
|
|
150
174
|
console.log(JSON.stringify(result));
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** @typedef {import('./types.d.ts').GitEntry} GitEntry */
|
|
3
|
+
|
|
4
|
+
import { readFileSync } from 'fs';
|
|
5
|
+
|
|
6
|
+
/** @param {string} content @returns {Array<GitEntry>} */
|
|
7
|
+
function extractGitEntries(content) {
|
|
8
|
+
const lines = content.trim().split('\n');
|
|
9
|
+
|
|
10
|
+
/** @type {Map<string, {command: string, cwd: string, timestamp: string}>} */
|
|
11
|
+
const gitToolUses = new Map();
|
|
12
|
+
/** @type {Array<GitEntry>} */
|
|
13
|
+
const entries = [];
|
|
14
|
+
|
|
15
|
+
for (const line of lines) {
|
|
16
|
+
let parsed;
|
|
17
|
+
try { parsed = JSON.parse(line); } catch { continue; }
|
|
18
|
+
if (parsed.type !== 'assistant' || !parsed.message?.content) continue;
|
|
19
|
+
|
|
20
|
+
const contents = Array.isArray(parsed.message.content) ? parsed.message.content : [];
|
|
21
|
+
const timestamp = parsed.timestamp || '';
|
|
22
|
+
const cwd = parsed.cwd || '';
|
|
23
|
+
|
|
24
|
+
for (const block of contents) {
|
|
25
|
+
if (block.type === 'tool_use' && block.name === 'Bash' && block.input?.command) {
|
|
26
|
+
const cmd = block.input.command;
|
|
27
|
+
if (/\bgit\s+commit\b/.test(cmd)) {
|
|
28
|
+
gitToolUses.set(block.id, { command: cmd, cwd, timestamp });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (block.type === 'tool_result' && block.tool_use_id && gitToolUses.has(block.tool_use_id)) {
|
|
33
|
+
if (block.is_error) continue;
|
|
34
|
+
const toolUse = /** @type {{command: string, cwd: string, timestamp: string}} */ (gitToolUses.get(block.tool_use_id));
|
|
35
|
+
const output = typeof block.content === 'string' ? block.content : '';
|
|
36
|
+
if (!output) continue;
|
|
37
|
+
|
|
38
|
+
const commitInfo = parseCommitOutput(output);
|
|
39
|
+
if (commitInfo) {
|
|
40
|
+
entries.push({
|
|
41
|
+
action: 'commit',
|
|
42
|
+
hash: commitInfo.hash,
|
|
43
|
+
branch: commitInfo.branch,
|
|
44
|
+
message: commitInfo.message,
|
|
45
|
+
cwd: cwd || toolUse.cwd,
|
|
46
|
+
timestamp: timestamp || toolUse.timestamp,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return entries;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Parse git commit output: "[branch hash] message"
|
|
58
|
+
* @param {string} output
|
|
59
|
+
* @returns {{hash: string, branch: string, message: string} | null}
|
|
60
|
+
*/
|
|
61
|
+
function parseCommitOutput(output) {
|
|
62
|
+
// Matches: [main abc1234] commit message
|
|
63
|
+
// Also: [main (root-commit) abc1234] first commit
|
|
64
|
+
const match = output.match(/\[([^\s\]]+)(?:\s+\([^)]+\))?\s+([a-f0-9]+)\]\s+(.+)/);
|
|
65
|
+
if (!match) return null;
|
|
66
|
+
return { branch: match[1], hash: match[2], message: match[3].trim() };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Parse transcript file for git commit activity.
|
|
71
|
+
* @param {string} transcriptPath
|
|
72
|
+
* @returns {Array<GitEntry>}
|
|
73
|
+
*/
|
|
74
|
+
export function parseGitActivity(transcriptPath) {
|
|
75
|
+
try {
|
|
76
|
+
const content = readFileSync(transcriptPath, 'utf-8');
|
|
77
|
+
return extractGitEntries(content);
|
|
78
|
+
} catch { return []; }
|
|
79
|
+
}
|
package/lib/github-storage.mjs
CHANGED
|
@@ -15,21 +15,35 @@ export class GitHubStorageAdapter {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/** @private @param {string} path @returns {string} */
|
|
18
|
-
getUrl(path) {
|
|
18
|
+
getUrl(path) {
|
|
19
|
+
if (path.split('/').includes('..')) throw new Error('Invalid path: traversal not allowed');
|
|
20
|
+
return this.basePath ? `${this.baseUrl}/${this.basePath}/${path}` : `${this.baseUrl}/${path}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @private
|
|
25
|
+
* @param {string} url
|
|
26
|
+
* @param {RequestInit} [options]
|
|
27
|
+
* @returns {Promise<Record<string, unknown> | null>}
|
|
28
|
+
*/
|
|
29
|
+
async fetchOrNull(url, options) {
|
|
30
|
+
const res = await fetch(url, { ...options, headers: this.headers });
|
|
31
|
+
if (res.status === 404) return null;
|
|
32
|
+
if (!res.ok) throw new Error(`GitHub API error: ${res.status}`);
|
|
33
|
+
return /** @type {Record<string, unknown>} */ (await res.json());
|
|
34
|
+
}
|
|
19
35
|
|
|
20
36
|
/** @private @param {string} path @returns {Promise<string | null>} */
|
|
21
37
|
async getSha(path) {
|
|
22
|
-
const
|
|
23
|
-
if (
|
|
24
|
-
const data = /** @type {Record<string, unknown>} */ (await res.json());
|
|
38
|
+
const data = await this.fetchOrNull(this.getUrl(path), { method: 'GET' });
|
|
39
|
+
if (!data) return null;
|
|
25
40
|
return /** @type {string | null} */ (data.sha || null);
|
|
26
41
|
}
|
|
27
42
|
|
|
28
43
|
/** @param {string} path @returns {Promise<string | null>} */
|
|
29
44
|
async read(path) {
|
|
30
|
-
const
|
|
31
|
-
if (
|
|
32
|
-
const data = /** @type {Record<string, unknown>} */ (await res.json());
|
|
45
|
+
const data = await this.fetchOrNull(this.getUrl(path), { method: 'GET' });
|
|
46
|
+
if (!data) return null;
|
|
33
47
|
return Buffer.from(/** @type {string} */ (data.content), 'base64').toString('utf-8');
|
|
34
48
|
}
|
|
35
49
|
|
|
@@ -40,10 +54,13 @@ export class GitHubStorageAdapter {
|
|
|
40
54
|
const body = { message: `update ${path}`, content: Buffer.from(content).toString('base64') };
|
|
41
55
|
if (sha) body.sha = sha;
|
|
42
56
|
const res = await fetch(this.getUrl(path), { method: 'PUT', headers: this.headers, body: JSON.stringify(body) });
|
|
43
|
-
if (
|
|
57
|
+
if (res.status === 409) {
|
|
44
58
|
const freshSha = await this.getSha(path);
|
|
45
59
|
if (freshSha) body.sha = freshSha;
|
|
46
|
-
await fetch(this.getUrl(path), { method: 'PUT', headers: this.headers, body: JSON.stringify(body) });
|
|
60
|
+
const retry = await fetch(this.getUrl(path), { method: 'PUT', headers: this.headers, body: JSON.stringify(body) });
|
|
61
|
+
if (!retry.ok) throw new Error(`GitHub API error: ${retry.status}`);
|
|
62
|
+
} else if (!res.ok) {
|
|
63
|
+
throw new Error(`GitHub API error: ${res.status}`);
|
|
47
64
|
}
|
|
48
65
|
}
|
|
49
66
|
|
|
@@ -55,16 +72,14 @@ export class GitHubStorageAdapter {
|
|
|
55
72
|
|
|
56
73
|
/** @param {string} path @returns {Promise<boolean>} */
|
|
57
74
|
async exists(path) {
|
|
58
|
-
const
|
|
59
|
-
return
|
|
75
|
+
const data = await this.fetchOrNull(this.getUrl(path), { method: 'GET' });
|
|
76
|
+
return data !== null;
|
|
60
77
|
}
|
|
61
78
|
|
|
62
79
|
/** @param {string} dir @returns {Promise<string[]>} */
|
|
63
80
|
async list(dir) {
|
|
64
|
-
const
|
|
65
|
-
if (
|
|
66
|
-
const data = await res.json();
|
|
67
|
-
if (!Array.isArray(data)) return [];
|
|
81
|
+
const data = await this.fetchOrNull(this.getUrl(dir), { method: 'GET' });
|
|
82
|
+
if (!data || !Array.isArray(data)) return [];
|
|
68
83
|
return data.map((/** @type {{ name: string }} */ entry) => entry.name);
|
|
69
84
|
}
|
|
70
85
|
|
|
@@ -73,9 +88,8 @@ export class GitHubStorageAdapter {
|
|
|
73
88
|
|
|
74
89
|
/** @param {string} path @returns {Promise<boolean>} */
|
|
75
90
|
async isDirectory(path) {
|
|
76
|
-
const
|
|
77
|
-
if (
|
|
78
|
-
const data = await res.json();
|
|
91
|
+
const data = await this.fetchOrNull(this.getUrl(path), { method: 'GET' });
|
|
92
|
+
if (!data) return false;
|
|
79
93
|
return Array.isArray(data);
|
|
80
94
|
}
|
|
81
95
|
}
|
package/lib/raw-logger.mjs
CHANGED
|
@@ -10,6 +10,8 @@ export function parseHookInput(raw) {
|
|
|
10
10
|
return /** @type {HookInput} */ (parsed);
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/** @typedef {import('./types.d.ts').GitEntry} GitEntry */
|
|
14
|
+
|
|
13
15
|
/** @param {StorageAdapter} storage @param {string} sessionDir @param {string} date @param {HookInput} entry @returns {Promise<void>} */
|
|
14
16
|
export async function appendRawLog(storage, sessionDir, date, entry) {
|
|
15
17
|
await storage.mkdir(sessionDir);
|
|
@@ -31,3 +33,23 @@ export async function appendRawLog(storage, sessionDir, date, entry) {
|
|
|
31
33
|
await storage.append(logPath, lines);
|
|
32
34
|
}
|
|
33
35
|
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Append git activity entries to raw log.
|
|
39
|
+
* @param {StorageAdapter} storage
|
|
40
|
+
* @param {string} sessionDir
|
|
41
|
+
* @param {string} date
|
|
42
|
+
* @param {Array<GitEntry>} gitEntries
|
|
43
|
+
* @param {string} sessionId
|
|
44
|
+
* @param {string} ghAccount
|
|
45
|
+
* @returns {Promise<void>}
|
|
46
|
+
*/
|
|
47
|
+
export async function appendGitLogs(storage, sessionDir, date, gitEntries, sessionId, ghAccount) {
|
|
48
|
+
if (gitEntries.length === 0) return;
|
|
49
|
+
await storage.mkdir(sessionDir);
|
|
50
|
+
const logPath = `${sessionDir}/${date}.jsonl`;
|
|
51
|
+
const lines = gitEntries.map(e =>
|
|
52
|
+
JSON.stringify({ type: 'git', action: e.action, hash: e.hash, branch: e.branch, message: e.message, remote: e.remote, cwd: e.cwd, ghAccount, session_id: sessionId, timestamp: e.timestamp })
|
|
53
|
+
).join('\n') + '\n';
|
|
54
|
+
await storage.append(logPath, lines);
|
|
55
|
+
}
|
package/lib/storage.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/** @typedef {import('./types.d.ts').StorageAdapter} StorageAdapter */
|
|
3
3
|
|
|
4
4
|
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs';
|
|
5
|
-
import { dirname, join } from 'path';
|
|
5
|
+
import { dirname, join, resolve as pathResolve } from 'path';
|
|
6
6
|
|
|
7
7
|
/** @implements {StorageAdapter} */
|
|
8
8
|
export class LocalStorageAdapter {
|
|
@@ -14,7 +14,11 @@ export class LocalStorageAdapter {
|
|
|
14
14
|
|
|
15
15
|
/** @private @param {string} path @returns {string} */
|
|
16
16
|
resolve(path) {
|
|
17
|
-
|
|
17
|
+
const full = pathResolve(this.basePath, path);
|
|
18
|
+
if (full !== this.basePath && !full.startsWith(this.basePath + '/')) {
|
|
19
|
+
throw new Error('Invalid path: traversal outside base directory');
|
|
20
|
+
}
|
|
21
|
+
return full;
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
/** @param {string} path @returns {Promise<string | null>} */
|
package/lib/types.d.ts
CHANGED
|
@@ -55,6 +55,16 @@ export interface HookInput {
|
|
|
55
55
|
[key: string]: unknown;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
export interface GitEntry {
|
|
59
|
+
action: 'commit';
|
|
60
|
+
hash: string;
|
|
61
|
+
branch: string;
|
|
62
|
+
message?: string;
|
|
63
|
+
remote?: string;
|
|
64
|
+
cwd: string;
|
|
65
|
+
timestamp: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
58
68
|
export interface DeviceCodeResponse {
|
|
59
69
|
device_code: string;
|
|
60
70
|
user_code: string;
|
package/package.json
CHANGED