@kaydash9999/banker-plugins 0.7.1 → 0.9.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/hooks/hooks.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "description": "banker obsidizer: canonicalize an OMC wiki page right after it is written, but only when the vault is armed via `obsidizer --enable`. Inert otherwise.",
2
+ "description": "banker hooks: obsidizer wiki canonicalization (PostToolUse, inert until `obsidizer --enable`) + anonymous usage counting (PostToolUse Skill / UserPromptExpansion, count-default-on, opt-out BANKER_NO_TELEMETRY, no-op without endpoint) + update-check notification (SessionStart, opt-out BANKER_NO_UPDATE_CHECK).",
3
3
  "hooks": {
4
4
  "PostToolUse": [
5
5
  {
@@ -11,6 +11,40 @@
11
11
  "timeout": 5
12
12
  }
13
13
  ]
14
+ },
15
+ {
16
+ "matcher": "Skill",
17
+ "hooks": [
18
+ {
19
+ "type": "command",
20
+ "command": "node \"$CLAUDE_PLUGIN_ROOT\"/hooks/run.cjs \"$CLAUDE_PLUGIN_ROOT\"/hooks/telemetry-count-skill.mjs",
21
+ "timeout": 5
22
+ }
23
+ ]
24
+ }
25
+ ],
26
+ "UserPromptExpansion": [
27
+ {
28
+ "matcher": "banker:.*",
29
+ "hooks": [
30
+ {
31
+ "type": "command",
32
+ "command": "node \"$CLAUDE_PLUGIN_ROOT\"/hooks/run.cjs \"$CLAUDE_PLUGIN_ROOT\"/hooks/telemetry-count.mjs",
33
+ "timeout": 5
34
+ }
35
+ ]
36
+ }
37
+ ],
38
+ "SessionStart": [
39
+ {
40
+ "matcher": "*",
41
+ "hooks": [
42
+ {
43
+ "type": "command",
44
+ "command": "node \"$CLAUDE_PLUGIN_ROOT\"/hooks/run.cjs \"$CLAUDE_PLUGIN_ROOT\"/hooks/update-notify.mjs",
45
+ "timeout": 5
46
+ }
47
+ ]
14
48
  }
15
49
  ]
16
50
  }
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * banker 텔레메트리 count 훅 (PostToolUse, matcher: Skill) - 모델이 자동 호출한
4
+ * banker:<name> 스킬을 로컬 usage-log 에 `<name>\t<hour>` 한 줄로 기록한다.
5
+ *
6
+ * fail-closed 규약(각 항목은 telemetry-count-skill.test.mjs 가 검증한다):
7
+ * - countingActive() 가 아니면 로컬 쓰기조차 하지 않는다. 파일도 만들지 않는다.
8
+ * - 어떤 오류도 삼키고 항상 exit 0. 세션/툴호출을 절대 막지 않는다.
9
+ * - PII/식별자를 다루지 않는다. 로그 한 줄 = 이름 문자열 + TAB + hour(0-23) 뿐이다.
10
+ *
11
+ * payload 필드 확정(US-4 라이브 스모크테스트): PostToolUse(Skill) stdin payload 는
12
+ * `tool_input.skill`(예: "banker:compact-copy")을 담는다. 보조 소스로
13
+ * `tool_response.commandName` 도 시도한다. 사용자가 /banker:* 를 직접 호출한 경우는
14
+ * UserPromptExpansion 쪽(telemetry-count.mjs)이 담당한다 - 두 경로는 disjoint 로 실측
15
+ * 확정되어 dedup 이 불요하다.
16
+ *
17
+ * 체크인(usage-log 집계 후 서버 전송)은 이 훅의 책임이 아니다. 트리거는 SessionStart
18
+ * update-notify 가 throttle 경과 시 별도로 연다 - 이 훅은 append 만 하고 flush/checkin 을
19
+ * spawn 하지 않는다.
20
+ *
21
+ * 배선: hooks.json 의 PostToolUse(matcher Skill)에 등록되어 모델 자동 스킬 호출 시 발화한다.
22
+ */
23
+ import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
24
+
25
+ import { configDir, countingActive, recordUsedSkill, USAGE_LOG_PATH, LOG_MAX_LINES } from '../bin/lib/telemetry-config.mjs';
26
+
27
+ // banker:<name> 전체 일치 - 이름 문자열 자체를 검증한다(TAB/개행 등 이질 문자가 섞여
28
+ // usage-log 의 `name\thour` 형식을 깨는 것을 막는다). i 플래그는 매칭만 관대하게(실제 이름은 소문자).
29
+ const NAME_RE = /^banker:[a-z0-9_-]+$/i;
30
+
31
+ function readStdin() {
32
+ return new Promise((resolvePromise, rejectPromise) => {
33
+ let data = '';
34
+ process.stdin.setEncoding('utf8');
35
+ process.stdin.on('data', (chunk) => { data += chunk; });
36
+ process.stdin.on('end', () => resolvePromise(data));
37
+ process.stdin.on('error', rejectPromise);
38
+ });
39
+ }
40
+
41
+ // PostToolUse(Skill) payload 에서 모델이 자동 호출한 banker:<name> 스킬 이름을 추출한다.
42
+ // 주 소스는 tool_input.skill, 없거나 미매치면 보조로 tool_response.commandName 을 시도한다.
43
+ function extractName(payload) {
44
+ const candidates = [payload?.tool_input?.skill, payload?.tool_response?.commandName];
45
+ for (const c of candidates) {
46
+ if (typeof c === 'string' && NAME_RE.test(c)) return c;
47
+ }
48
+ return null;
49
+ }
50
+
51
+ // 파일의 개행 개수 = 줄 수(각 append 가 끝에 개행을 붙이므로). 없거나 읽기 실패면 0.
52
+ function lineCount(path) {
53
+ try {
54
+ const raw = readFileSync(path, 'utf8');
55
+ return (raw.match(/\n/g) || []).length;
56
+ } catch {
57
+ return 0;
58
+ }
59
+ }
60
+
61
+ // usage-log 에 `<name>\t<hour>` 한 줄을 append 한다(hour = 로컬 시각 0-23). 상한 도달 시
62
+ // 스킵(best-effort; 체크인이 비우면 재개). flush/checkin 은 여기서 spawn 하지 않는다
63
+ // (트리거는 SessionStart update-notify 소관).
64
+ function record(name) {
65
+ try { mkdirSync(configDir(), { recursive: true }); } catch { /* best-effort */ }
66
+ try {
67
+ if (lineCount(USAGE_LOG_PATH) < LOG_MAX_LINES) {
68
+ const hour = new Date().getHours();
69
+ appendFileSync(USAGE_LOG_PATH, `${name}\t${hour}\n`);
70
+ }
71
+ } catch { /* best-effort */ }
72
+ }
73
+
74
+ async function main() {
75
+ let raw;
76
+ try {
77
+ raw = await readStdin();
78
+ } catch {
79
+ return; // stdin 읽기 실패 - 무동작.
80
+ }
81
+ let payload;
82
+ try {
83
+ payload = JSON.parse(raw);
84
+ } catch {
85
+ return; // malformed stdin - 무동작.
86
+ }
87
+ if (!countingActive()) return; // 카운팅 비활성 - 로컬 쓰기조차 하지 않는다.
88
+ const name = extractName(payload);
89
+ if (!name) return; // banker:<name> 아님 - 무동작.
90
+ record(name); // 서버 카운팅용 usage-log append (스킬별 x 시간대 버킷).
91
+ recordUsedSkill(name); // 개인화 알림용 로컬 집합 union (전송 안 함; update-notify 만 로컬에서 읽는다).
92
+ }
93
+
94
+ main()
95
+ .catch(() => {}) // 어떤 예외도 세션을 막지 않는다.
96
+ .finally(() => process.exit(0));
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * banker 텔레메트리 count 훅 (UserPromptExpansion) - 사용자가 직접 호출한 /banker:<name>
4
+ * 커맨드를 로컬 usage-log 에 `<name>\t<hour>` 한 줄로 기록한다.
5
+ *
6
+ * fail-closed 규약(각 항목은 telemetry-count.test.mjs 가 검증한다):
7
+ * - countingActive() 가 아니면 로컬 쓰기조차 하지 않는다. 파일도 만들지 않는다.
8
+ * - 어떤 오류도 삼키고 항상 exit 0. 세션/프롬프트를 절대 막지 않는다.
9
+ * - PII/식별자를 다루지 않는다. 로그 한 줄 = 이름 문자열 + TAB + hour(0-23) 뿐이다.
10
+ *
11
+ * payload 필드 확정(US-4 라이브 스모크테스트): UserPromptExpansion stdin payload 는
12
+ * `command_name`(예: "banker:setup-lsp")과 `command_source`("plugin")를 담는다. 사용자가
13
+ * /banker:* 를 직접 호출한 경우만 이 이벤트가 발화하고, 모델이 자동 호출하는 스킬은
14
+ * PostToolUse(Skill) 쪽(telemetry-count-skill.mjs)이 담당한다 - 두 경로는 disjoint 로 실측
15
+ * 확정되어 dedup 이 불요하다. 더 이상 방어적 다중필드 추출이 필요 없다.
16
+ *
17
+ * 체크인(usage-log 집계 후 서버 전송)은 이 훅의 책임이 아니다. 트리거는 SessionStart
18
+ * update-notify 가 throttle 경과 시 별도로 연다 - 이 훅은 append 만 하고 flush/checkin 을
19
+ * spawn 하지 않는다.
20
+ *
21
+ * 배선: hooks.json 의 UserPromptExpansion(matcher banker:.*)에 등록되어 사용자 직접 /banker:* 호출 시 발화한다.
22
+ */
23
+ import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
24
+
25
+ import { configDir, countingActive, recordUsedSkill, USAGE_LOG_PATH, LOG_MAX_LINES } from '../bin/lib/telemetry-config.mjs';
26
+
27
+ // banker:<name> 전체 일치 - 이름 문자열 자체를 검증한다(TAB/개행 등 이질 문자가 섞여
28
+ // usage-log 의 `name\thour` 형식을 깨는 것을 막는다). i 플래그는 매칭만 관대하게(실제 이름은 소문자).
29
+ const NAME_RE = /^banker:[a-z0-9_-]+$/i;
30
+
31
+ function readStdin() {
32
+ return new Promise((resolvePromise, rejectPromise) => {
33
+ let data = '';
34
+ process.stdin.setEncoding('utf8');
35
+ process.stdin.on('data', (chunk) => { data += chunk; });
36
+ process.stdin.on('end', () => resolvePromise(data));
37
+ process.stdin.on('error', rejectPromise);
38
+ });
39
+ }
40
+
41
+ // UserPromptExpansion payload 에서 사용자가 직접 호출한 banker:<name> 이름을 추출한다.
42
+ // command_source 가 'plugin' 이고 command_name 이 banker:<name> 형식일 때만 반환, 그 외 null.
43
+ function extractName(payload) {
44
+ if (payload?.command_source !== 'plugin') return null;
45
+ const name = payload?.command_name;
46
+ return (typeof name === 'string' && NAME_RE.test(name)) ? name : null;
47
+ }
48
+
49
+ // 파일의 개행 개수 = 줄 수(각 append 가 끝에 개행을 붙이므로). 없거나 읽기 실패면 0.
50
+ function lineCount(path) {
51
+ try {
52
+ const raw = readFileSync(path, 'utf8');
53
+ return (raw.match(/\n/g) || []).length;
54
+ } catch {
55
+ return 0;
56
+ }
57
+ }
58
+
59
+ // usage-log 에 `<name>\t<hour>` 한 줄을 append 한다(hour = 로컬 시각 0-23). 상한 도달 시
60
+ // 스킵(best-effort; 체크인이 비우면 재개). flush/checkin 은 여기서 spawn 하지 않는다
61
+ // (트리거는 SessionStart update-notify 소관).
62
+ function record(name) {
63
+ try { mkdirSync(configDir(), { recursive: true }); } catch { /* best-effort */ }
64
+ try {
65
+ if (lineCount(USAGE_LOG_PATH) < LOG_MAX_LINES) {
66
+ const hour = new Date().getHours();
67
+ appendFileSync(USAGE_LOG_PATH, `${name}\t${hour}\n`);
68
+ }
69
+ } catch { /* best-effort */ }
70
+ }
71
+
72
+ async function main() {
73
+ let raw;
74
+ try {
75
+ raw = await readStdin();
76
+ } catch {
77
+ return; // stdin 읽기 실패 - 무동작.
78
+ }
79
+ let payload;
80
+ try {
81
+ payload = JSON.parse(raw);
82
+ } catch {
83
+ return; // malformed stdin - 무동작.
84
+ }
85
+ if (!countingActive()) return; // 카운팅 비활성 - 로컬 쓰기조차 하지 않는다.
86
+ const name = extractName(payload);
87
+ if (!name) return; // banker:<name> 아님 - 무동작.
88
+ record(name); // 서버 카운팅용 usage-log append (스킬별 x 시간대 버킷).
89
+ recordUsedSkill(name); // 개인화 알림용 로컬 집합 union (전송 안 함; update-notify 만 로컬에서 읽는다).
90
+ }
91
+
92
+ main()
93
+ .catch(() => {}) // 어떤 예외도 세션을 막지 않는다.
94
+ .finally(() => process.exit(0));
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * banker 카운팅 체크인 훅 - usage-log 를 스킬별 x 시간대(hour 0-23)별 count 로 집계해 엔드포인트로
4
+ * 한 번 POST 하고, 응답의 latest 로 update-check 캐시를 겸해 갱신한다. 전송 시도 후(성공/실패 무관)
5
+ * 로그를 truncate 해 24h 윈도우로 상한한다. telemetry-flush 를 대체(evolve)한다.
6
+ *
7
+ * fail-closed 규약(각 항목은 update-checkin.test.mjs 가 검증한다):
8
+ * - 미활성(countingActive) 또는 엔드포인트 없음이면 무동작(로그도 건드리지 않는다).
9
+ * - PII/IP/지속ID 절대 미포함. 페이로드는 {version, os, counts} 세 키뿐(os=플랫폼 계열,
10
+ * counts=스킬별 x 시간대별 호출수).
11
+ * - 자체 네트워크 timeout 으로 hang 방지. 어떤 오류도 삼키고 항상 exit 0. 어떤 경로도 reject 안 함.
12
+ * - 비-loopback 평문 http 는 전송하지 않는다(https-only). loopback http 만 로컬 테스트 mock 에 허용.
13
+ *
14
+ * count-default-on: 이 훅은 hooks.json 에 배선되지 않는다(스탠드얼론 .mjs). update-notify 가
15
+ * countingActive + 엔드포인트 설정 시 fetcher 로 detached spawn 한다(응답 latest 가 알림 캐시를 겸함).
16
+ */
17
+ import { readFileSync, writeFileSync } from 'node:fs';
18
+ import http from 'node:http';
19
+ import https from 'node:https';
20
+
21
+ import {
22
+ countingActive,
23
+ endpoint,
24
+ USAGE_LOG_PATH,
25
+ LAST_FLUSH_PATH,
26
+ SKILL_CHANGES_URL,
27
+ installedVersion,
28
+ writeUpdateCache,
29
+ changedSkillsBetween,
30
+ } from '../bin/lib/telemetry-config.mjs';
31
+
32
+ const NET_TIMEOUT_MS = 10_000;
33
+
34
+ // usage-log 각 줄 `<name>\t<hour 0-23>` 를 스킬별 x 시간대별 count 로 집계한다.
35
+ // 빈 줄·TAB 없는 줄·이름 없는 줄·hour 가 0-23 정수가 아닌 줄은 무시한다.
36
+ // 반환 예: { 'banker:compact-copy': { '9': 3, '14': 1 }, 'banker:setup': { '9': 1 } }.
37
+ function aggregate(raw) {
38
+ const counts = {};
39
+ for (const line of raw.split('\n')) {
40
+ if (!line.trim()) continue; // 빈 줄 무시.
41
+ const tab = line.indexOf('\t');
42
+ if (tab < 0) continue; // TAB 없는 malformed 줄 무시.
43
+ const name = line.slice(0, tab).trim();
44
+ const hourStr = line.slice(tab + 1).trim();
45
+ if (!name) continue; // 이름 없는 줄 무시.
46
+ if (!/^\d+$/.test(hourStr)) continue; // 정수 아닌 hour 무시(음수·소수·비수치 배제).
47
+ const hour = Number(hourStr);
48
+ if (hour > 23) continue; // 0-23 범위 밖 무시(하한은 \d+ 가 보장).
49
+ const key = String(hour); // '09' -> '9' 정규화.
50
+ if (!counts[name]) counts[name] = {};
51
+ counts[name][key] = (counts[name][key] || 0) + 1;
52
+ }
53
+ return counts;
54
+ }
55
+
56
+ // loopback 호스트만 평문 http 를 허용한다(로컬 테스트 mock 전용). update-fetch.mjs 와 동일 판별:
57
+ // URL.hostname 은 IPv6 를 대괄호로 감싸므로([::1]) 벗겨서 비교한다.
58
+ function isLoopback(hostname) {
59
+ const h = (hostname || '').replace(/^\[|\]$/g, '').toLowerCase();
60
+ return h === 'localhost' || h === '127.0.0.1' || h === '::1';
61
+ }
62
+
63
+ // 엔드포인트로 JSON POST. 어떤 경로에서도 reject 하지 않고, 전송 시도가 끝나면 응답 본문(문자열)
64
+ // 또는 null 로 resolve 한다. 자체 timeout(NET_TIMEOUT_MS)으로 무응답 엔드포인트에도 hang 하지 않는다.
65
+ function post(url, body) {
66
+ return new Promise((resolvePromise) => {
67
+ let done = false;
68
+ const finish = (responseBody = null) => { if (!done) { done = true; resolvePromise(responseBody); } };
69
+ let parsed;
70
+ try {
71
+ parsed = new URL(url);
72
+ } catch {
73
+ return finish(); // 잘못된 엔드포인트 - 전송 스킵.
74
+ }
75
+ // PRIVACY.md 정합: 비-loopback 엔드포인트에는 https 만 허용한다. 평문 http 는
76
+ // loopback(localhost/127.0.0.1/::1) 로컬 테스트 mock 에만, http/https 외 스킴은
77
+ // 전송하지 않고 no-op(fail-closed). 평문 비-loopback 유출을 원천 차단한다.
78
+ if (parsed.protocol === 'http:') {
79
+ if (!isLoopback(parsed.hostname)) return finish();
80
+ } else if (parsed.protocol !== 'https:') {
81
+ return finish();
82
+ }
83
+ const transport = parsed.protocol === 'http:' ? http : https;
84
+ try {
85
+ const req = transport.request(parsed, {
86
+ method: 'POST',
87
+ headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) },
88
+ }, (res) => {
89
+ let data = '';
90
+ res.on('data', (c) => { data += c; });
91
+ res.on('end', () => finish(data));
92
+ res.on('error', () => finish());
93
+ });
94
+ req.on('error', () => finish()); // 연결 거부/리셋 등은 즉시 여기로.
95
+ req.setTimeout(NET_TIMEOUT_MS, () => req.destroy());
96
+ req.write(body);
97
+ req.end();
98
+ } catch {
99
+ finish();
100
+ }
101
+ });
102
+ }
103
+
104
+ // 응답 본문에서 latest 버전 문자열만 방어 추출. null·비문자열·빈·malformed·비문자열 latest 값 → null.
105
+ function extractLatest(responseBody) {
106
+ if (typeof responseBody !== 'string' || !responseBody) return null;
107
+ try {
108
+ const obj = JSON.parse(responseBody);
109
+ const v = (obj && typeof obj === 'object') ? obj.latest : undefined;
110
+ return (typeof v === 'string' && v) ? v : null;
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ // 변경-스킬 매니페스트(공개 GitHub raw JSON: {"x.y.z": ["banker:skill", ...]})를 GET 해 객체로 반환한다.
117
+ // update-fetch 의 동일 함수와 self-contained 로 나란히 둔다(훅별 자기완결 관례·isLoopback 도 이미 각자 보유).
118
+ // post() 와 동일한 안전 가드: 비-https 는 loopback 만 허용·3xx no-op·자체 timeout·malformed→null. 어떤
119
+ // 실패에도 reject 하지 않고 null 로 resolve 한다(개인화는 best-effort). 비배열 객체만 반환.
120
+ function fetchSkillChangesMap(url) {
121
+ return new Promise((resolvePromise) => {
122
+ let done = false;
123
+ const finish = (result) => { if (!done) { done = true; resolvePromise(result); } };
124
+ let parsed;
125
+ try {
126
+ parsed = new URL(url);
127
+ } catch {
128
+ return finish(null);
129
+ }
130
+ if (parsed.protocol === 'http:') {
131
+ if (!isLoopback(parsed.hostname)) return finish(null);
132
+ } else if (parsed.protocol !== 'https:') {
133
+ return finish(null);
134
+ }
135
+ const transport = parsed.protocol === 'http:' ? http : https;
136
+ try {
137
+ const req = transport.request(parsed, { method: 'GET' }, (res) => {
138
+ const status = res.statusCode || 0;
139
+ if (status >= 300 && status < 400) { res.resume(); return finish(null); } // 리다이렉트 미추적.
140
+ let data = '';
141
+ res.on('data', (c) => { data += c; });
142
+ res.on('end', () => {
143
+ try {
144
+ const obj = JSON.parse(data);
145
+ finish((obj && typeof obj === 'object' && !Array.isArray(obj)) ? obj : null);
146
+ } catch {
147
+ finish(null);
148
+ }
149
+ });
150
+ res.on('error', () => finish(null));
151
+ });
152
+ req.on('error', () => finish(null));
153
+ req.setTimeout(NET_TIMEOUT_MS, () => req.destroy());
154
+ req.end(); // GET - 요청 본문 없음.
155
+ } catch {
156
+ finish(null);
157
+ }
158
+ });
159
+ }
160
+
161
+ async function main() {
162
+ if (!countingActive()) return; // 미활성 - 무동작(로그 미변경).
163
+ const url = endpoint();
164
+ if (!url) return; // 엔드포인트 없음 - 무동작(로그 미변경).
165
+
166
+ // 로그 없음도 유효한 체크인(설치 펄스 + 알림 fetch 겸함): 빈 counts 로 진행한다.
167
+ let raw = '';
168
+ try { raw = readFileSync(USAGE_LOG_PATH, 'utf8'); } catch { raw = ''; }
169
+
170
+ // PII/IP/지속ID 없음: version + os(플랫폼 계열) + counts(스킬별 x 시간대)뿐.
171
+ const payload = JSON.stringify({
172
+ version: installedVersion() || 'unknown',
173
+ os: process.platform,
174
+ counts: aggregate(raw),
175
+ });
176
+ const responseBody = await post(url, payload);
177
+
178
+ // 응답의 latest 로 공유 update-check 캐시를 갱신(알림 겸함). 없으면 캐시 미변경.
179
+ // update-fetch 처럼 notified:null 재무장을 하지 않아도 등가다: dedupe 판정이 notified===latest
180
+ // (update-notify)라 전진 버전에서 구 notified 는 항상 신 latest 와 달라 재고지가 정상 발화하고,
181
+ // count-default-on 은 이 훅만·npm 배포는 update-fetch 만 써 같은 캐시를 겹쳐 쓰지 않는다.
182
+ const latest = extractLatest(responseBody);
183
+ if (latest) {
184
+ // 개인화용: 변경-스킬 매니페스트를 best-effort GET(실패 시 changedSkills 생략, 기존 값 보존).
185
+ const changesUrl = process.env.BANKER_SKILL_CHANGES_URL || SKILL_CHANGES_URL; // 테스트 주입용 override.
186
+ const map = await fetchSkillChangesMap(changesUrl);
187
+ const patch = { latest, checkedAt: Date.now() };
188
+ if (map) patch.changedSkills = changedSkillsBetween(map, installedVersion(), latest);
189
+ try { writeUpdateCache(patch); } catch { /* best-effort */ }
190
+ }
191
+
192
+ // 전송 시도 후(성공/실패 무관) 로그를 비워 24h 윈도우로 상한(배치 유실 허용).
193
+ try { writeFileSync(USAGE_LOG_PATH, ''); } catch { /* best-effort */ }
194
+ try { writeFileSync(LAST_FLUSH_PATH, new Date().toISOString()); } catch { /* best-effort */ }
195
+ }
196
+
197
+ main()
198
+ .catch(() => {}) // 어떤 예외도 삼킨다.
199
+ .finally(() => process.exit(0));
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * banker 업데이트 알림 fetcher 훅 - npm 레지스트리에서 최신 버전을 조회해 update-check 캐시를 갱신한다.
4
+ * SessionStart 알림 훅(update-notify)이 이 fetcher 를 detached 로 기동해 캐시를 채우고, 알림 렌더링
5
+ * 자체는 캐시만 읽는 별도 훅이 담당한다(이 파일은 네트워크 조회만 - 화면 출력 없음).
6
+ *
7
+ * fail-closed 규약(각 항목은 update-fetch.test.mjs 가 검증한다):
8
+ * - throttle(UPDATE_THROTTLE_MS) 미경과면 무동작(캐시가 없으면 진행).
9
+ * - 비-https 는 loopback(localhost/127.0.0.1/::1) 에서만 http 허용, 그 외 비-https 는 거부.
10
+ * - 3xx 리다이렉트는 따라가지 않고 no-op.
11
+ * - 응답 JSON 에서 version 문자열만 방어 추출 - 없거나 비문자열이면 무동작(캐시 미기록).
12
+ * - 캐시는 writeUpdateCache 로 병합 기록: fetch 된 latest 가 기존 캐시와 다르면 notified 를 초기화
13
+ * (재무장)하고, 같으면 형제 필드(notified 등)를 patch 에서 생략해 그대로 보존한다.
14
+ * - 어떤 오류도 삼키고 항상 exit 0. GET 뿐이라 페이로드/식별자 전송이 없다.
15
+ *
16
+ * 이 훅은 hooks.json 에 배선되지 않는다(스탠드얼론 .mjs). update-notify 가 detached spawn 한다.
17
+ */
18
+ import { readFileSync } from 'node:fs';
19
+ import http from 'node:http';
20
+ import https from 'node:https';
21
+
22
+ import { UPDATE_CHECK_PATH, UPDATE_THROTTLE_MS, NPM_LATEST_URL, writeUpdateCache } from '../bin/lib/telemetry-config.mjs';
23
+
24
+ const NET_TIMEOUT_MS = 10_000;
25
+
26
+ // loopback 호스트만 평문 http 를 허용한다(로컬 테스트 mock 전용). update-checkin.mjs 와 동일 판별:
27
+ // URL.hostname 은 IPv6 를 대괄호로 감싸므로([::1]) 벗겨서 비교한다.
28
+ function isLoopback(hostname) {
29
+ const h = (hostname || '').replace(/^\[|\]$/g, '').toLowerCase();
30
+ return h === 'localhost' || h === '127.0.0.1' || h === '::1';
31
+ }
32
+
33
+ // update-check 캐시를 읽어 객체로 반환. 없음·malformed·권한 오류 등 어떤 예외에도 던지지 않고 {} 반환.
34
+ function readCache() {
35
+ try {
36
+ const obj = JSON.parse(readFileSync(UPDATE_CHECK_PATH, 'utf8'));
37
+ return (obj && typeof obj === 'object' && !Array.isArray(obj)) ? obj : {};
38
+ } catch {
39
+ return {};
40
+ }
41
+ }
42
+
43
+ // url 에 GET 요청해 응답 JSON 의 version 문자열만 추출한다. 실패·거부·리다이렉트·malformed 는 전부
44
+ // null. reject 하지 않고 항상 resolve 한다(hang 방지). GET 뿐이라 페이로드·식별자 헤더가 없다.
45
+ function fetchLatestVersion(url) {
46
+ return new Promise((resolvePromise) => {
47
+ let done = false;
48
+ const finish = (result) => { if (!done) { done = true; resolvePromise(result); } };
49
+ let parsed;
50
+ try {
51
+ parsed = new URL(url);
52
+ } catch {
53
+ return finish(null); // 잘못된 URL - 조회 스킵.
54
+ }
55
+ // 비-https 는 loopback 로컬 테스트 mock 에만 허용, 그 외 스킴/호스트는 no-op(fail-closed).
56
+ if (parsed.protocol === 'http:') {
57
+ if (!isLoopback(parsed.hostname)) return finish(null);
58
+ } else if (parsed.protocol !== 'https:') {
59
+ return finish(null);
60
+ }
61
+ const transport = parsed.protocol === 'http:' ? http : https;
62
+ try {
63
+ const req = transport.request(parsed, { method: 'GET' }, (res) => {
64
+ const status = res.statusCode || 0;
65
+ // 3xx 리다이렉트는 따라가지 않는다: 응답을 흘려보내고 no-op 으로 종료.
66
+ if (status >= 300 && status < 400) {
67
+ res.resume();
68
+ return finish(null);
69
+ }
70
+ let data = '';
71
+ res.on('data', (c) => { data += c; });
72
+ res.on('end', () => {
73
+ try {
74
+ const obj = JSON.parse(data);
75
+ const v = obj && typeof obj === 'object' ? obj.version : undefined;
76
+ finish((typeof v === 'string' && v) ? v : null);
77
+ } catch {
78
+ finish(null); // 빈/malformed 본문.
79
+ }
80
+ });
81
+ res.on('error', () => finish(null));
82
+ });
83
+ req.on('error', () => finish(null)); // 연결 거부/리셋 등은 즉시 여기로.
84
+ req.setTimeout(NET_TIMEOUT_MS, () => req.destroy());
85
+ req.end(); // GET - 요청 본문 없음.
86
+ } catch {
87
+ finish(null);
88
+ }
89
+ });
90
+ }
91
+
92
+ async function main() {
93
+ const cache = readCache();
94
+ const checkedAt = typeof cache.checkedAt === 'number' ? cache.checkedAt : null;
95
+ if (checkedAt !== null && (Date.now() - checkedAt) < UPDATE_THROTTLE_MS) return; // throttle 미경과 - no-op.
96
+
97
+ const url = process.env.BANKER_NPM_URL || NPM_LATEST_URL; // 테스트 주입용 override.
98
+ const latest = await fetchLatestVersion(url);
99
+ if (!latest) return; // 조회 실패·거부·malformed - 캐시 미기록.
100
+
101
+ // 개인화 매니페스트는 여기서 조회하지 않는다: update-fetch 는 카운팅 opt-out(countingActive false) 경로의
102
+ // fetcher 이고, 개인화 표시도 countingActive() 로 게이트되므로 이 경로에선 표시될 일이 없다. 매니페스트
103
+ // 조회는 카운팅 활성 경로의 update-checkin 만 수행한다(중복·죽은 조회 제거).
104
+ const cachedLatest = typeof cache.latest === 'string' ? cache.latest : null;
105
+ if (cachedLatest !== latest) {
106
+ writeUpdateCache({ latest, checkedAt: Date.now(), notified: null }); // 신버전 - notified 재무장.
107
+ } else {
108
+ writeUpdateCache({ latest, checkedAt: Date.now() }); // 동일 버전 - notified 등 형제 필드는 병합 보존.
109
+ }
110
+ }
111
+
112
+ main()
113
+ .catch(() => {}) // 어떤 예외도 삼킨다.
114
+ .finally(() => process.exit(0));