charge-connect 0.1.1 → 0.1.3
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/cli.js +86 -9
- package/collect.js +61 -6
- package/install.linux.sh +60 -0
- package/install.ps1 +107 -10
- package/package.json +2 -1
package/cli.js
CHANGED
|
@@ -16,6 +16,8 @@ const HOME = process.env.HOME ?? process.env.USERPROFILE;
|
|
|
16
16
|
const CONF_DIR = process.env.CHARGE_HOME ?? path.join(HOME, ".charge");
|
|
17
17
|
const CONF = path.join(CONF_DIR, "config.json");
|
|
18
18
|
const WIN = process.platform === "win32";
|
|
19
|
+
const LINUX = process.platform === "linux";
|
|
20
|
+
const INSTALLER = WIN ? "install.ps1" : LINUX ? "install.linux.sh" : "install.sh";
|
|
19
21
|
|
|
20
22
|
// 백엔드 주소 — collector/cloud.json (출시 시 charge 전용 프로젝트로 교체, npm 패키지에 포함)
|
|
21
23
|
const CLOUD = (() => {
|
|
@@ -57,12 +59,15 @@ async function pair(code) {
|
|
|
57
59
|
function installRuntime() {
|
|
58
60
|
const dest = path.join(CONF_DIR, "app");
|
|
59
61
|
fs.mkdirSync(dest, { recursive: true, mode: 0o700 });
|
|
60
|
-
|
|
62
|
+
const files = ["cli.js", "collect.js", "install.sh", "install.linux.sh", "install.ps1", "cloud.json", "package.json"];
|
|
63
|
+
for (const f of files) {
|
|
61
64
|
const src = path.join(__dirname, f);
|
|
62
65
|
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(dest, f));
|
|
63
66
|
}
|
|
64
67
|
if (!WIN) {
|
|
65
|
-
|
|
68
|
+
for (const f of ["install.sh", "install.linux.sh"]) {
|
|
69
|
+
try { fs.chmodSync(path.join(dest, f), 0o755); } catch {}
|
|
70
|
+
}
|
|
66
71
|
}
|
|
67
72
|
return dest;
|
|
68
73
|
}
|
|
@@ -71,9 +76,8 @@ function collect(dir = __dirname) {
|
|
|
71
76
|
execFileSync(process.execPath, [path.join(dir, "collect.js")], { stdio: "inherit" });
|
|
72
77
|
}
|
|
73
78
|
|
|
74
|
-
function installSchedule() {
|
|
75
|
-
const
|
|
76
|
-
const p = path.join(dir, WIN ? "install.ps1" : "install.sh");
|
|
79
|
+
function installSchedule(dir) {
|
|
80
|
+
const p = path.join(dir, INSTALLER);
|
|
77
81
|
if (WIN) {
|
|
78
82
|
execFileSync("powershell.exe", ["-ExecutionPolicy", "Bypass", "-File", p], { stdio: "inherit" });
|
|
79
83
|
} else {
|
|
@@ -81,6 +85,41 @@ function installSchedule() {
|
|
|
81
85
|
}
|
|
82
86
|
}
|
|
83
87
|
|
|
88
|
+
// ccusage가 전역에 없어도 수집은 npx 폴백으로 동작하지만, 5분마다 npx 해석을 거쳐
|
|
89
|
+
// 느려진다. 사용자가 터미널 앞에 있는 페어링 시점에 한 번 물어보고 깔아준다.
|
|
90
|
+
async function offerCcusageInstall() {
|
|
91
|
+
// Windows에서 ccusage/npm은 .cmd 셔틀이라 cmd.exe /d /c 로 감싼다 — shell:true의
|
|
92
|
+
// DEP0190 경고를 피하면서 AutoRun도 억제하는, collect.js의 runAsync와 같은 방식.
|
|
93
|
+
const run = (cmd, args, opts) =>
|
|
94
|
+
execFileSync(WIN ? process.env.ComSpec ?? "cmd.exe" : cmd, WIN ? ["/d", "/c", cmd, ...args] : args, opts);
|
|
95
|
+
try {
|
|
96
|
+
run("ccusage", ["--version"], { stdio: "ignore", timeout: 15_000 });
|
|
97
|
+
return; // 이미 설치돼 있다
|
|
98
|
+
} catch {}
|
|
99
|
+
const hint = "'npm i -g ccusage'를 해두면 수집이 빨라집니다";
|
|
100
|
+
// 파이프/CI처럼 물어볼 콘솔이 없으면 안내만 하고 넘어간다
|
|
101
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
102
|
+
console.log(`참고: ${hint} (지금은 npx 대체 실행으로 동작).`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const rl = require("node:readline/promises").createInterface({ input: process.stdin, output: process.stdout });
|
|
106
|
+
const answer = (await rl.question("수집 도구 ccusage를 전역 설치할까요? 수집이 빨라집니다 (Y/n) ")).trim().toLowerCase();
|
|
107
|
+
rl.close();
|
|
108
|
+
if (answer === "n" || answer === "no") {
|
|
109
|
+
console.log(`건너뜁니다 — 나중에 ${hint}.`);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
run("npm", ["install", "-g", "ccusage"], { stdio: "inherit", timeout: 300_000 });
|
|
114
|
+
console.log("✓ ccusage 설치 완료");
|
|
115
|
+
} catch {
|
|
116
|
+
console.error(
|
|
117
|
+
"설치에 실패했지만 수집은 npx 대체 실행으로 계속 동작합니다." +
|
|
118
|
+
(WIN ? ` 나중에 ${hint}.` : ` 나중에 ${hint} (권한 오류라면 'sudo npm i -g ccusage').`)
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
84
123
|
(async () => {
|
|
85
124
|
const arg = process.argv[2];
|
|
86
125
|
if (!arg) {
|
|
@@ -96,12 +135,50 @@ function installSchedule() {
|
|
|
96
135
|
console.log("✓ 페어링 해제 (스케줄 해제는 install.sh/install.ps1 안내 참고)");
|
|
97
136
|
return;
|
|
98
137
|
}
|
|
99
|
-
// 페어링 코드로 간주: 페어링 →
|
|
138
|
+
// 페어링 코드로 간주: 페어링 → 스케줄 등록 → 첫 수집.
|
|
139
|
+
// 스케줄을 먼저 등록한다 — 첫 수집이 실패해도(네트워크가 잠깐 끊기는 등) 자동 수집은
|
|
140
|
+
// 살아 있어야 한다. 페어링 코드는 이미 소모돼서 같은 코드로 재시도할 수 없기 때문이다.
|
|
100
141
|
await pair(arg);
|
|
101
|
-
|
|
102
|
-
|
|
142
|
+
const dir = installRuntime();
|
|
143
|
+
|
|
103
144
|
console.log("5분 간격 자동 수집을 등록합니다…");
|
|
104
|
-
|
|
145
|
+
let scheduled = true;
|
|
146
|
+
let guided = false; // 설치 스크립트가 수동 등록 방법을 이미 출력했다 (종료 코드 3)
|
|
147
|
+
try {
|
|
148
|
+
installSchedule(dir);
|
|
149
|
+
} catch (e) {
|
|
150
|
+
scheduled = false;
|
|
151
|
+
if (e.status === 3) guided = true;
|
|
152
|
+
else console.error(`자동 수집 등록 실패: ${e.message ?? e}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 스케줄 등록 뒤에 물어본다 — 프롬프트에서 사용자가 자리를 비워도 자동 수집은 이미 살아 있다
|
|
156
|
+
await offerCcusageInstall();
|
|
157
|
+
|
|
158
|
+
console.log("첫 수집을 실행합니다…");
|
|
159
|
+
let collected = true;
|
|
160
|
+
try {
|
|
161
|
+
collect(dir);
|
|
162
|
+
} catch {
|
|
163
|
+
collected = false;
|
|
164
|
+
console.error("첫 수집에 실패했습니다 (네트워크 문제일 수 있습니다).");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!scheduled) {
|
|
168
|
+
if (guided) {
|
|
169
|
+
console.error("페어링은 끝났습니다. 위 안내대로 자동 수집을 등록하면 완료됩니다.");
|
|
170
|
+
} else {
|
|
171
|
+
const p = path.join(dir, INSTALLER);
|
|
172
|
+
console.error("페어링은 끝났습니다. 자동 수집 등록만 직접 마무리해주세요.");
|
|
173
|
+
console.error(` 다시 시도: ${WIN ? `powershell -ExecutionPolicy Bypass -File "${p}"` : `bash "${p}"`}`);
|
|
174
|
+
}
|
|
175
|
+
process.exit(1);
|
|
176
|
+
}
|
|
177
|
+
// 첫 수집이 실패했으면 앱에 아직 데이터가 없다 — 됐다고 말하지 않는다
|
|
178
|
+
if (!collected) {
|
|
179
|
+
console.log("✓ 페어링과 자동 수집 등록은 끝났습니다. 5분 뒤 수집을 다시 시도합니다.");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
105
182
|
console.log("✓ 설정 끝! 이제 앱에서 데이터가 보입니다.");
|
|
106
183
|
})().catch((e) => {
|
|
107
184
|
console.error(e.message ?? e);
|
package/collect.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Charge 수집기 — ccusage로 토큰 사용량을 뽑아 Charge 백엔드에 업로드한다.
|
|
3
|
-
// 사용법: node collect.js [--dry-run] [--days N]
|
|
3
|
+
// 사용법: node collect.js [--dry-run] [--days N] [--log 경로]
|
|
4
4
|
// 인증: `npx charge-connect <페어링코드>`가 저장한 ~/.charge/config.json의 디바이스 토큰
|
|
5
5
|
|
|
6
6
|
const { execFile } = require("node:child_process");
|
|
@@ -16,24 +16,76 @@ const parsedDays = daysArg > -1 ? parseInt(process.argv[daysArg + 1], 10) : NaN;
|
|
|
16
16
|
const DAYS = Number.isFinite(parsedDays) && parsedDays > 0 ? parsedDays : 60;
|
|
17
17
|
const CACHE_FILE = path.join(__dirname, ".last-payload.json");
|
|
18
18
|
const HOME = process.env.HOME ?? process.env.USERPROFILE;
|
|
19
|
+
|
|
20
|
+
// 스케줄러가 부를 때는 볼 콘솔이 없으니 출력을 직접 파일로 받는다.
|
|
21
|
+
// 셸 리다이렉션(cmd `>`)에 맡기면 경로의 %VAR% 확장·로그 잠금 같은 문제를 떠안게 된다.
|
|
22
|
+
// 5분마다 도는 작업이라 그냥 이어 붙이면 끝없이 자라므로 5MB에서 .old로 민다.
|
|
23
|
+
const logArg = process.argv.indexOf("--log");
|
|
24
|
+
const LOG_FILE = logArg > -1 ? process.argv[logArg + 1] : null;
|
|
25
|
+
if (logArg > -1 && (!LOG_FILE || LOG_FILE.startsWith("-"))) {
|
|
26
|
+
// 값을 안 주면 조용히 로그가 꺼지고, 다음 옵션을 삼키면 "--dry-run"이라는 파일에 쓰게 된다
|
|
27
|
+
console.error("--log 뒤에는 파일 경로가 필요합니다.");
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
if (LOG_FILE) {
|
|
31
|
+
try {
|
|
32
|
+
if (fs.statSync(LOG_FILE).size > 5 * 1024 * 1024) fs.renameSync(LOG_FILE, `${LOG_FILE}.old`);
|
|
33
|
+
} catch {}
|
|
34
|
+
// 비동기 스트림은 process.exit에서 버퍼를 잃을 수 있어 동기로 쓴다 (줄 수가 적다)
|
|
35
|
+
const write = (...args) => {
|
|
36
|
+
try {
|
|
37
|
+
fs.appendFileSync(LOG_FILE, `${args.map(String).join(" ")}\n`);
|
|
38
|
+
} catch {}
|
|
39
|
+
};
|
|
40
|
+
console.log = write;
|
|
41
|
+
console.error = write;
|
|
42
|
+
// 스케줄러가 돌릴 때는 Node 기본 stderr가 어디에도 안 남는다. 잡지 않으면
|
|
43
|
+
// 크래시했을 때 로그에 시작 줄만 덩그러니 남아 원인을 알 수 없다.
|
|
44
|
+
const fatal = (label) => (err) => {
|
|
45
|
+
write(`[${new Date().toISOString()}] ${label}: ${err?.stack ?? err}`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
};
|
|
48
|
+
process.on("uncaughtException", fatal("치명적 오류"));
|
|
49
|
+
process.on("unhandledRejection", fatal("처리되지 않은 거부"));
|
|
50
|
+
// 시작 줄을 바로 남긴다 — '언제 마지막으로 돌았나'를 알 수 있고,
|
|
51
|
+
// install.ps1이 스케줄 작업이 실제로 떴는지 판정하는 근거이기도 하다.
|
|
52
|
+
write(`[${new Date().toISOString()}] 수집 시작`);
|
|
53
|
+
}
|
|
19
54
|
const WIN = process.platform === "win32";
|
|
20
|
-
|
|
55
|
+
// 스케줄러(작업 스케줄러/launchd)가 만드는 환경에는 셸 프로필에서 붙는 PATH가 없다.
|
|
56
|
+
// Windows에서 nvm-windows·fnm·volta로 Node를 깐 사용자는 그래서 npx/ccusage를 못 찾는다.
|
|
57
|
+
// node가 자기 위치는 알고 있으므로, 그 디렉터리와 npm 전역 bin을 직접 얹어준다.
|
|
58
|
+
const EXTRA_PATH = WIN
|
|
59
|
+
? [path.dirname(process.execPath), process.env.APPDATA && path.join(process.env.APPDATA, "npm")]
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.map((p) => `;${p}`)
|
|
62
|
+
.join("")
|
|
63
|
+
: ":/opt/homebrew/bin:/usr/local/bin";
|
|
21
64
|
const DEFAULT_CODEXBAR_CLI = "/Applications/CodexBar.app/Contents/Helpers/CodexBarCLI";
|
|
22
65
|
|
|
23
66
|
// 모든 외부 명령은 비동기로 실행한다 — 동기(execFileSync)는 이벤트 루프를 세워
|
|
24
67
|
// 진행 중인 fetch(AbortSignal 타이머 포함)를 전부 타임아웃시킨다.
|
|
25
|
-
// Windows에서 npx/ccusage는 .cmd 셔틀이라 셸을 거쳐야 실행된다 (인자는 모두 고정 문자열).
|
|
26
68
|
const execOpts = (timeout) => ({
|
|
27
69
|
encoding: "utf8",
|
|
28
70
|
maxBuffer: 64 * 1024 * 1024,
|
|
29
71
|
timeout,
|
|
30
72
|
env: { ...process.env, PATH: `${process.env.PATH}${EXTRA_PATH}` },
|
|
31
|
-
shell: WIN,
|
|
32
73
|
});
|
|
33
74
|
|
|
34
75
|
async function runAsync(cmd, args, timeout = 120_000) {
|
|
76
|
+
// Windows에서 npx/ccusage는 .cmd 셔틀이라 셸 없이는 실행되지 않는데, shell:true에
|
|
77
|
+
// 인자 배열을 주면 Node 24부터 DEP0190 경고가 붙는다. 그래서 ComSpec /d /c 로 직접
|
|
78
|
+
// 감싼다 — /d는 shell:true도 내부에서 쓰던 AutoRun 억제로, 사용자 레지스트리의
|
|
79
|
+
// AutoRun 출력(chcp·clink 등)이 JSON 파싱을 깨는 것을 막는다. 경로로 주어진 명령
|
|
80
|
+
// (CodexBar CLI 같은 .exe)은 셸이 필요 없고 cmd 인용 규칙이 공백·괄호 경로를
|
|
81
|
+
// 깨뜨리므로 직접 실행한다. cmd로 넘기는 인자는 모두 고정 문자열이다.
|
|
82
|
+
// 한계: .cmd/.bat 경로는 cmd 경유가 불가피해 특수문자(공백+괄호, &) 경로는 지원하지 않는다.
|
|
83
|
+
const viaCmd = WIN && (!/[\\/]/.test(cmd) || /\.(cmd|bat)$/i.test(cmd));
|
|
84
|
+
const [file, argv] = viaCmd
|
|
85
|
+
? [process.env.ComSpec ?? "cmd.exe", ["/d", "/c", cmd, ...args]]
|
|
86
|
+
: [cmd, args];
|
|
35
87
|
// promisify된 execFile은 실패 시에도 err.stdout/stderr를 붙여준다
|
|
36
|
-
return (await execFileAsync(
|
|
88
|
+
return (await execFileAsync(file, argv, execOpts(timeout))).stdout;
|
|
37
89
|
}
|
|
38
90
|
|
|
39
91
|
async function runCcusage(args) {
|
|
@@ -326,6 +378,7 @@ async function codexLiveWindows(auth) {
|
|
|
326
378
|
// 마지막으로 Codex를 실제 사용한 시점의 값이라 실시간 조회가 실패했을 때만 쓴다
|
|
327
379
|
function codexSnapshotWindows() {
|
|
328
380
|
const dir = path.join(HOME, ".codex", "sessions");
|
|
381
|
+
if (!fs.existsSync(dir)) return null; // Codex 미설치
|
|
329
382
|
let latest = null;
|
|
330
383
|
(function walk(d) {
|
|
331
384
|
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
@@ -490,7 +543,8 @@ function parseCodexBarJSON(raw) {
|
|
|
490
543
|
|
|
491
544
|
async function collectCodexBarProviders() {
|
|
492
545
|
if (process.env.CHARGE_DISABLE_CODEXBAR === "1") return { providers: [], complete: false };
|
|
493
|
-
|
|
546
|
+
// 따옴표로 감싼 경로(Windows에서 흔한 습관)는 벗겨서 execFile에 그대로 넘긴다
|
|
547
|
+
const cli = process.env.CHARGE_CODEXBAR_CLI?.replace(/^"(.*)"$/s, "$1")
|
|
494
548
|
?? (!WIN && fs.existsSync(DEFAULT_CODEXBAR_CLI) ? DEFAULT_CODEXBAR_CLI : null);
|
|
495
549
|
if (!cli) return { providers: [], complete: false };
|
|
496
550
|
|
|
@@ -664,6 +718,7 @@ module.exports = {
|
|
|
664
718
|
normalizeResetAt,
|
|
665
719
|
parseCodexBarJSON,
|
|
666
720
|
resolveMode,
|
|
721
|
+
runAsync,
|
|
667
722
|
sanitizeCachedProvider,
|
|
668
723
|
titleCaseProvider,
|
|
669
724
|
usefulPlan,
|
package/install.linux.sh
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Linux/WSL에 수집기를 등록한다 (5분 간격).
|
|
3
|
+
# systemd 사용자 세션이 있으면 타이머로 등록하고, 없으면(WSL에서 흔하다) cron 한 줄을 안내한다.
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
|
|
6
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
7
|
+
LOG_DIR="${CHARGE_HOME:-$HOME/.charge}"
|
|
8
|
+
LOG="$LOG_DIR/collector.log"
|
|
9
|
+
|
|
10
|
+
NODE="$(command -v node || true)"
|
|
11
|
+
if [ -z "$NODE" ]; then
|
|
12
|
+
echo "Node.js를 찾을 수 없습니다."
|
|
13
|
+
echo "배포판 패키지나 https://nodejs.org 로 설치한 뒤 다시 실행해주세요."
|
|
14
|
+
exit 1
|
|
15
|
+
fi
|
|
16
|
+
|
|
17
|
+
mkdir -p "$LOG_DIR"
|
|
18
|
+
|
|
19
|
+
# systemctl 이 있어도 사용자 세션이 안 뜬 경우가 많다 (WSL, 컨테이너, SSH) — 실제로 물어본다
|
|
20
|
+
if command -v systemctl >/dev/null 2>&1 && systemctl --user show-environment >/dev/null 2>&1; then
|
|
21
|
+
UNIT_DIR="$HOME/.config/systemd/user"
|
|
22
|
+
mkdir -p "$UNIT_DIR"
|
|
23
|
+
|
|
24
|
+
cat > "$UNIT_DIR/charge-connect.service" <<EOF
|
|
25
|
+
[Unit]
|
|
26
|
+
Description=Charge usage collector
|
|
27
|
+
|
|
28
|
+
[Service]
|
|
29
|
+
Type=oneshot
|
|
30
|
+
ExecStart="$NODE" "$SCRIPT_DIR/collect.js" --log "$LOG"
|
|
31
|
+
EOF
|
|
32
|
+
|
|
33
|
+
cat > "$UNIT_DIR/charge-connect.timer" <<EOF
|
|
34
|
+
[Unit]
|
|
35
|
+
Description=Charge usage collector (5분 간격)
|
|
36
|
+
|
|
37
|
+
[Timer]
|
|
38
|
+
OnBootSec=1min
|
|
39
|
+
OnUnitActiveSec=5min
|
|
40
|
+
Persistent=true
|
|
41
|
+
|
|
42
|
+
[Install]
|
|
43
|
+
WantedBy=timers.target
|
|
44
|
+
EOF
|
|
45
|
+
|
|
46
|
+
systemctl --user daemon-reload
|
|
47
|
+
systemctl --user enable --now charge-connect.timer
|
|
48
|
+
echo "등록 완료: charge-connect.timer (5분 간격). 로그: $LOG"
|
|
49
|
+
echo "해제하려면: systemctl --user disable --now charge-connect.timer"
|
|
50
|
+
exit 0
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
# 여기까지 왔으면 자동 등록은 못 한다. 종료 코드 3으로 알려 cli.js가 안내를 덧붙이게 한다.
|
|
54
|
+
echo "systemd 사용자 세션을 찾지 못했습니다 (WSL에서는 흔한 일입니다)."
|
|
55
|
+
echo "아래 한 줄을 crontab에 넣으면 5분마다 수집합니다:"
|
|
56
|
+
echo
|
|
57
|
+
echo " */5 * * * * '$NODE' '$SCRIPT_DIR/collect.js' --log '$LOG'"
|
|
58
|
+
echo
|
|
59
|
+
echo "등록: crontab -e (cron이 안 돌고 있으면: sudo service cron start)"
|
|
60
|
+
exit 3
|
package/install.ps1
CHANGED
|
@@ -1,18 +1,36 @@
|
|
|
1
|
-
# Charge 수집기를 Windows 작업 스케줄러에 등록한다 (5분 간격).
|
|
1
|
+
# Charge 수집기를 Windows 작업 스케줄러에 등록한다 (5분 간격).
|
|
2
2
|
# 실행: PowerShell에서 .\install.ps1 (실행 정책 오류 시: powershell -ExecutionPolicy Bypass -File install.ps1)
|
|
3
|
+
#
|
|
4
|
+
# 이 파일은 UTF-8 BOM으로 저장해야 한다 — cli.js가 부르는 Windows PowerShell 5.1은
|
|
5
|
+
# BOM이 없으면 시스템 ANSI 코드페이지로 읽어서 아래 한글 안내가 전부 깨진다.
|
|
3
6
|
$ErrorActionPreference = "Stop"
|
|
4
7
|
|
|
5
8
|
$taskName = "ChargeConnect"
|
|
6
|
-
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
7
|
-
$node = (Get-Command node).Source
|
|
8
|
-
$log = Join-Path $env:USERPROFILE "charge-connect.log"
|
|
9
|
+
$scriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path }
|
|
9
10
|
|
|
10
|
-
#
|
|
11
|
+
# Node가 없을 때 PowerShell 원문 에러 대신 사람이 읽을 수 있는 안내를 낸다
|
|
12
|
+
$nodeCmd = Get-Command node -ErrorAction SilentlyContinue
|
|
13
|
+
if (-not $nodeCmd) {
|
|
14
|
+
Write-Host "Node.js를 찾을 수 없습니다."
|
|
15
|
+
Write-Host "https://nodejs.org 에서 LTS 버전을 설치한 뒤 다시 실행해주세요."
|
|
16
|
+
exit 1
|
|
17
|
+
}
|
|
18
|
+
$node = $nodeCmd.Source
|
|
19
|
+
|
|
20
|
+
$logDir = if ($env:CHARGE_HOME) { $env:CHARGE_HOME } else { Join-Path $env:USERPROFILE ".charge" }
|
|
21
|
+
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
|
|
22
|
+
$log = Join-Path $logDir "collector.log"
|
|
23
|
+
$logOld = $log + ".old"
|
|
24
|
+
|
|
25
|
+
# 예전 이름(ChargeCollector)·예전 로그 위치의 잔재가 있으면 정리
|
|
11
26
|
Unregister-ScheduledTask -TaskName "ChargeCollector" -Confirm:$false -ErrorAction SilentlyContinue
|
|
27
|
+
Remove-Item -Force -ErrorAction SilentlyContinue (Join-Path $env:USERPROFILE "charge-connect.log")
|
|
12
28
|
|
|
13
|
-
#
|
|
14
|
-
|
|
15
|
-
|
|
29
|
+
# node 를 직접 실행하고 로그는 collect.js 가 --log 로 직접 쓴다.
|
|
30
|
+
# cmd /c 로 감싸 리다이렉션하면 경로 안의 %VAR% 가 확장되고, move 실패가 조용히 묻히고,
|
|
31
|
+
# 실행이 겹칠 때 로그 잠금으로 출력이 통째로 사라진다. 셸을 안 거치면 그 셋이 모두 없어진다.
|
|
32
|
+
$arg = "`"$scriptDir\collect.js`" --log `"$log`""
|
|
33
|
+
$action = New-ScheduledTaskAction -Execute $node -Argument $arg -WorkingDirectory $scriptDir
|
|
16
34
|
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) `
|
|
17
35
|
-RepetitionInterval (New-TimeSpan -Minutes 5) `
|
|
18
36
|
-RepetitionDuration (New-TimeSpan -Days 3650)
|
|
@@ -20,8 +38,87 @@ $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable `
|
|
|
20
38
|
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
|
|
21
39
|
-ExecutionTimeLimit (New-TimeSpan -Minutes 10)
|
|
22
40
|
|
|
23
|
-
Register-
|
|
24
|
-
|
|
41
|
+
function Register-ChargeTask([switch]$Background) {
|
|
42
|
+
$p = @{ TaskName = $taskName; Action = $action; Trigger = $trigger; Settings = $settings; Force = $true }
|
|
43
|
+
if ($Background) {
|
|
44
|
+
# 기본값(LogonType Interactive)으로 등록하면 콘솔 창이 5분마다 화면에 뜬다.
|
|
45
|
+
# S4U는 대화형 세션 없이 실행돼 창이 뜨지 않고, 로그오프 상태에서도 수집이 돈다.
|
|
46
|
+
$p.Principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" `
|
|
47
|
+
-LogonType S4U -RunLevel Limited
|
|
48
|
+
}
|
|
49
|
+
Register-ScheduledTask @p | Out-Null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function Get-LogState {
|
|
53
|
+
$s = ""
|
|
54
|
+
foreach ($f in @($log, $logOld)) {
|
|
55
|
+
# Test-Path 로 확인하고 Get-Item 을 따로 부르면, 그 사이 수집기가 로그를 밀었을 때
|
|
56
|
+
# ErrorActionPreference='Stop' 에 걸려 설치가 통째로 죽는다. 한 번에 가져온다.
|
|
57
|
+
$i = Get-Item -LiteralPath $f -ErrorAction SilentlyContinue
|
|
58
|
+
if ($i) { $s += "$($i.Length):$($i.LastWriteTime.Ticks);" } else { $s += "-;" }
|
|
59
|
+
}
|
|
60
|
+
return $s
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# 등록에 성공해도 실행은 실패할 수 있다. S4U 로그온에는 '일괄 작업으로 로그온'
|
|
64
|
+
# (SeBatchLogonRight) 권한이 필요한데, 비관리자 계정이나 GPO로 제한된 PC에서는
|
|
65
|
+
# 등록만 통과하고 실행이 매번 0x80070569로 죽는다. 그때는 프로세스 자체가 안 떠서
|
|
66
|
+
# 로그도 안 남기 때문에, 확인하지 않으면 수집이 조용히 영영 멈춘다.
|
|
67
|
+
# 그래서 실제로 한 번 돌려보고 프로세스가 떴다는 증거(로그 파일 변화)를 확인한다.
|
|
68
|
+
function Test-ChargeTaskRuns {
|
|
69
|
+
# 예전 정의로 돌던 인스턴스가 남아 있으면 그쪽 출력을 새 작업의 성공으로 오인한다
|
|
70
|
+
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
|
71
|
+
Start-Sleep -Milliseconds 500
|
|
72
|
+
$before = Get-LogState
|
|
73
|
+
Start-ScheduledTask -TaskName $taskName
|
|
74
|
+
$deadline = (Get-Date).AddSeconds(60)
|
|
75
|
+
while ((Get-Date) -lt $deadline) {
|
|
76
|
+
# collect.js 는 뜨자마자 로그에 시작 줄을 남긴다 — 프로세스가 떴다는 직접 증거.
|
|
77
|
+
# '실행 중(0x41301)' 상태만으로 성공 처리하면 남아 있던 인스턴스에 속을 수 있다.
|
|
78
|
+
if ((Get-LogState) -ne $before) { return $true }
|
|
79
|
+
$r = (Get-ScheduledTaskInfo -TaskName $taskName).LastTaskResult
|
|
80
|
+
# 0x80070569 요청한 로그온 유형이 부여되지 않음 / 0x8007052E 로그온 실패
|
|
81
|
+
if ($r -eq 2147943785 -or $r -eq 2147943726) { return $false }
|
|
82
|
+
Start-Sleep -Milliseconds 500
|
|
83
|
+
}
|
|
84
|
+
return $false
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
$background = $true
|
|
88
|
+
$adminRetryHint = $false
|
|
89
|
+
try {
|
|
90
|
+
Register-ChargeTask -Background
|
|
91
|
+
} catch {
|
|
92
|
+
Write-Host "백그라운드 실행 등록 실패: $($_.Exception.Message)"
|
|
93
|
+
# 비관리자 PowerShell에서는 S4U(창 없는) 등록이 항상 거부된다 — 같은 계정을 관리자
|
|
94
|
+
# 권한으로 승격해 재실행하면 해결되므로 그때만 안내한다. 판별은 토큰 Claims의
|
|
95
|
+
# Administrators SID(S-1-5-32-544)로 한다 — UAC 필터드 토큰에서 이 그룹은 deny-only로
|
|
96
|
+
# 강등되는데 .Groups는 deny-only를 걸러내 버리고, .Claims에는 DenyOnlySid로 남는다.
|
|
97
|
+
# 표준 사용자(Claims에 아예 없음)에게 안내하면 다른 관리자 계정으로 승격해 그 계정
|
|
98
|
+
# 프로필로 작업을 덮어쓰게 되고, 아래 Test-ChargeTaskRuns 폴백(SeBatchLogonRight 부재)은
|
|
99
|
+
# 승격으로 해결되지 않는다 — 두 경우 모두 안내하지 않는다.
|
|
100
|
+
$adminRetryHint = [Security.Principal.WindowsIdentity]::GetCurrent().Claims.Value -contains "S-1-5-32-544"
|
|
101
|
+
$background = $false
|
|
102
|
+
}
|
|
103
|
+
if ($background -and -not (Test-ChargeTaskRuns)) {
|
|
104
|
+
Write-Host "백그라운드 실행 권한이 없어 대화형 모드로 되돌립니다."
|
|
105
|
+
$background = $false
|
|
106
|
+
}
|
|
107
|
+
if (-not $background) {
|
|
108
|
+
try {
|
|
109
|
+
Register-ChargeTask
|
|
110
|
+
Start-ScheduledTask -TaskName $taskName
|
|
111
|
+
} catch {
|
|
112
|
+
Write-Host "작업 스케줄러 등록에 실패했습니다: $($_.Exception.Message)"
|
|
113
|
+
Write-Host "수동으로 수집하려면: node `"$scriptDir\collect.js`""
|
|
114
|
+
exit 1
|
|
115
|
+
}
|
|
116
|
+
Write-Host "참고: 5분마다 콘솔 창이 잠깐 보일 수 있습니다 (백그라운드 실행 권한이 없는 환경)."
|
|
117
|
+
if ($adminRetryHint) {
|
|
118
|
+
Write-Host "창이 뜨지 않게 하려면: 관리자 권한 PowerShell에서 아래를 한 번 실행하세요."
|
|
119
|
+
Write-Host " powershell -ExecutionPolicy Bypass -File `"$scriptDir\install.ps1`""
|
|
120
|
+
}
|
|
121
|
+
}
|
|
25
122
|
|
|
26
123
|
Write-Host "등록 완료: $taskName (5분 간격). 로그: $log"
|
|
27
124
|
Write-Host "해제하려면: Unregister-ScheduledTask -TaskName $taskName -Confirm:`$false"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "charge-connect",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Connects your computer to the Charge iPhone app and uploads AI coding usage",
|
|
5
5
|
"bin": {
|
|
6
6
|
"charge-connect": "cli.js"
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"cli.js",
|
|
13
13
|
"collect.js",
|
|
14
14
|
"install.sh",
|
|
15
|
+
"install.linux.sh",
|
|
15
16
|
"install.ps1",
|
|
16
17
|
"cloud.json"
|
|
17
18
|
],
|