@triflux/core 10.50.2 → 10.52.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/agy-session-hook.mjs +11 -56
- package/hooks/codex-session-hook.mjs +11 -101
- package/hub/bridge.mjs +65 -1250
- package/hub/cli-adapter-base.mjs +12 -90
- package/hub/codex-adapter.mjs +0 -1
- package/hub/codex-preflight.mjs +5 -28
- package/hub/index.mjs +3 -19
- package/hub/lib/process-utils.mjs +1 -156
- package/hub/lib/spawn-trace.mjs +3 -2
- package/hub/lib/timeout-defaults.mjs +0 -1
- package/hub/team/claude-daemon-control.mjs +1 -2
- package/hub/team/claude-session-projection.mjs +5 -73
- package/hub/team/nativeProxy.mjs +759 -0
- package/hub/team/session-context.mjs +1 -1
- package/hud/cli-policy.mjs +0 -22
- package/hud/constants.mjs +13 -26
- package/hud/hud-qos-status.mjs +42 -54
- package/hud/providers/gemini.mjs +125 -20
- package/hud/renderers.mjs +101 -122
- package/hud/utils.mjs +10 -6
- package/package.json +2 -6
- package/scripts/lib/agent-route-policy.mjs +143 -62
- package/scripts/lib/cli-claude.mjs +0 -14
- package/scripts/lib/env-probe.mjs +2 -131
- package/scripts/lib/gemini-profiles.mjs +2 -69
- package/scripts/lib/legacy-mcp-cleanup.mjs +283 -0
- package/scripts/lib/machine-profile.mjs +3 -17
- package/scripts/lib/mcp-filter.mjs +109 -53
- package/scripts/lib/mcp-guard-engine.mjs +40 -98
- package/scripts/lib/mcp-health.mjs +2 -2
- package/scripts/lib/mcp-server-catalog.mjs +2 -2
- package/scripts/lib/psmux-info.mjs +11 -59
- package/scripts/lib/session-presence.mjs +2 -100
- package/scripts/lib/skill-template.mjs +0 -245
- package/hub/adaptive-diagnostic.mjs +0 -350
- package/hub/adaptive-inject.mjs +0 -224
- package/hub/adaptive-memory.mjs +0 -277
- package/hub/adaptive.mjs +0 -172
- package/hub/assign-callbacks.mjs +0 -170
- package/hub/delegator/contracts.mjs +0 -38
- package/hub/delegator/index.mjs +0 -14
- package/hub/delegator/schema/delegator-tools.schema.json +0 -250
- package/hub/delegator/service.mjs +0 -336
- package/hub/delegator/tool-definitions.mjs +0 -35
- package/hub/dynamic-routing-engine.mjs +0 -511
- package/hub/hitl.mjs +0 -202
- package/hub/lib/known-errors.json +0 -72
- package/hub/lib/memory-store.mjs +0 -1001
- package/hub/lib/phase-manager.mjs +0 -493
- package/hub/lib/risk-tier.mjs +0 -53
- package/hub/lib/ssh-command.mjs +0 -231
- package/hub/lib/staged-file-detect.mjs +0 -43
- package/hub/lib/state-snapshot.mjs +0 -293
- package/hub/lib/tfx-route-args.mjs +0 -353
- package/hub/lib/trace-recorder.mjs +0 -153
- package/hub/lib/uuidv7.mjs +0 -45
- package/hub/memory-doctor.mjs +0 -640
- package/hub/middleware/quota-middleware.mjs +0 -70
- package/hub/middleware/request-logger.mjs +0 -191
- package/hub/paths.mjs +0 -24
- package/hub/pipeline/index.mjs +0 -163
- package/hub/pipeline/state.mjs +0 -191
- package/hub/pipeline/transitions.mjs +0 -131
- package/hub/reflexion.mjs +0 -290
- package/hub/role-contract.mjs +0 -314
- package/hub/router.mjs +0 -2026
- package/hub/routing-snapshot.mjs +0 -261
- package/hub/session-fingerprint.mjs +0 -418
- package/hub/state.mjs +0 -300
- package/hub/team/synapse-http.mjs +0 -311
- package/hub/team-bridge.mjs +0 -27
- package/mesh/index.mjs +0 -69
- package/mesh/mesh-budget.mjs +0 -137
- package/mesh/mesh-heartbeat.mjs +0 -100
- package/mesh/mesh-protocol.mjs +0 -96
- package/mesh/mesh-queue.mjs +0 -163
- package/mesh/mesh-registry.mjs +0 -78
- package/mesh/mesh-router.mjs +0 -77
- package/scripts/lib/claudemd-scanner.mjs +0 -223
- package/scripts/lib/context.mjs +0 -67
- package/scripts/lib/dynamic-route-cli.mjs +0 -107
- package/scripts/lib/handoff.mjs +0 -187
- package/scripts/lib/logger.mjs +0 -122
- package/scripts/lib/stealth-fetch.mjs +0 -176
package/hub/lib/ssh-command.mjs
DELETED
|
@@ -1,231 +0,0 @@
|
|
|
1
|
-
// hub/lib/ssh-command.mjs — OS-aware SSH command builder
|
|
2
|
-
// PowerShell 호스트에 bash 문법(2>/dev/null, &&, $())을 보내는 사고를 방지한다.
|
|
3
|
-
// 모든 SSH 명령 생성 코드에서 이 유틸리티를 사용할 것.
|
|
4
|
-
|
|
5
|
-
import { readHost, readHosts, resolveHost } from "./hosts-compat.mjs";
|
|
6
|
-
|
|
7
|
-
/** hosts.json 캐시 (프로세스 수명 동안 유지) */
|
|
8
|
-
let hostsCache = null;
|
|
9
|
-
let hostsCacheRoot = null;
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* hosts.json을 로드하여 캐시한다.
|
|
13
|
-
* @param {string} [repoRoot] — 프로젝트 루트 경로
|
|
14
|
-
*/
|
|
15
|
-
function loadHostsCache(repoRoot) {
|
|
16
|
-
const cacheKey = repoRoot || process.cwd();
|
|
17
|
-
if (hostsCache && hostsCacheRoot === cacheKey) return;
|
|
18
|
-
hostsCache = readHosts(repoRoot);
|
|
19
|
-
hostsCacheRoot = cacheKey;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* hosts.json에서 호스트 OS를 조회한다.
|
|
24
|
-
* @param {string} hostAlias — hosts.json의 키 (e.g. "ultra4")
|
|
25
|
-
* @param {string} [repoRoot] — 프로젝트 루트 경로
|
|
26
|
-
* @returns {"windows"|"posix"} 호스트 OS 유형
|
|
27
|
-
*/
|
|
28
|
-
export function detectHostOs(hostAlias, repoRoot) {
|
|
29
|
-
loadHostsCache(repoRoot);
|
|
30
|
-
|
|
31
|
-
const hostCfg = resolveHost(hostAlias, repoRoot)?.host;
|
|
32
|
-
if (hostCfg?.os === "windows") return "windows";
|
|
33
|
-
if (hostCfg?.os) return "posix";
|
|
34
|
-
|
|
35
|
-
// IP 주소나 unknown alias → posix 기본값
|
|
36
|
-
return "posix";
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* OS에 맞는 셸 쿼팅을 적용한다.
|
|
41
|
-
* - posix: 싱글쿼트 래핑 + 내부 싱글쿼트 이스케이프
|
|
42
|
-
* - windows: PowerShell 싱글쿼트 래핑 + 내부 싱글쿼트 더블링
|
|
43
|
-
* @param {string} value
|
|
44
|
-
* @param {"windows"|"posix"} os
|
|
45
|
-
* @returns {string}
|
|
46
|
-
*/
|
|
47
|
-
export function shellQuoteForHost(value, os) {
|
|
48
|
-
const s = String(value);
|
|
49
|
-
if (os === "windows") {
|
|
50
|
-
// PowerShell: 싱글쿼트 내부에서 '' 로 이스케이프
|
|
51
|
-
return `'${s.replace(/'/g, "''")}'`;
|
|
52
|
-
}
|
|
53
|
-
// POSIX: 싱글쿼트 내부에서 '\'' 로 이스케이프
|
|
54
|
-
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* OS에 맞는 stderr 억제 구문을 반환한다.
|
|
59
|
-
* @param {"windows"|"posix"} os
|
|
60
|
-
* @returns {string} "2>$null" 또는 "2>/dev/null"
|
|
61
|
-
*/
|
|
62
|
-
export function suppressStderr(os) {
|
|
63
|
-
return os === "windows" ? "2>$null" : "2>/dev/null";
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* OS에 맞는 명령 연결자를 반환한다.
|
|
68
|
-
* @param {"windows"|"posix"} os
|
|
69
|
-
* @returns {string} "; " (PowerShell) 또는 " && " (bash)
|
|
70
|
-
*/
|
|
71
|
-
export function commandJoin(os) {
|
|
72
|
-
return os === "windows" ? "; " : " && ";
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* OS에 맞는 null 디바이스 경로를 반환한다.
|
|
77
|
-
* @param {"windows"|"posix"} os
|
|
78
|
-
* @returns {string}
|
|
79
|
-
*/
|
|
80
|
-
export function nullDevice(os) {
|
|
81
|
-
return os === "windows" ? "$null" : "/dev/null";
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* SSH execFileSync 인자 배열을 안전하게 구성한다.
|
|
86
|
-
* psmux 명령처럼 원격 셸에서 실행할 명령을 배열로 받아
|
|
87
|
-
* 적절한 쿼팅을 적용한 SSH 인자 배열을 반환한다.
|
|
88
|
-
*
|
|
89
|
-
* @param {string} host — SSH 호스트 (user@ip 또는 alias)
|
|
90
|
-
* @param {string[]} remoteCmd — 원격에서 실행할 명령 + 인자
|
|
91
|
-
* @param {object} [opts]
|
|
92
|
-
* @param {number} [opts.connectTimeout=5] — SSH 연결 타임아웃(초)
|
|
93
|
-
* @param {boolean} [opts.keepalive=true] — ServerAliveInterval/CountMax 활성화
|
|
94
|
-
* @param {"windows"|"posix"} [opts.os] — 호스트 OS (미지정 시 자동 감지)
|
|
95
|
-
* @param {string} [opts.repoRoot] — hosts.json 탐색 경로
|
|
96
|
-
* @returns {string[]} execFileSync('ssh', returnValue) 형태로 사용
|
|
97
|
-
*/
|
|
98
|
-
export function buildSshArgs(host, remoteCmd, opts = {}) {
|
|
99
|
-
const connectTimeout = opts.connectTimeout ?? 5;
|
|
100
|
-
const keepalive = opts.keepalive !== false;
|
|
101
|
-
const hostAlias = host.includes("@") ? host.split("@").pop() : host;
|
|
102
|
-
const os = opts.os ?? detectHostOs(hostAlias, opts.repoRoot);
|
|
103
|
-
|
|
104
|
-
// 명령어(첫 요소)는 쿼팅하지 않고, 인자만 쿼팅
|
|
105
|
-
const [cmd, ...args] = remoteCmd;
|
|
106
|
-
const quotedArgs = args.map((a) => shellQuoteForHost(a, os));
|
|
107
|
-
const remoteCmdStr = [cmd, ...quotedArgs].join(" ");
|
|
108
|
-
|
|
109
|
-
const sshArgs = [
|
|
110
|
-
"-o",
|
|
111
|
-
`ConnectTimeout=${connectTimeout}`,
|
|
112
|
-
"-o",
|
|
113
|
-
"BatchMode=yes",
|
|
114
|
-
];
|
|
115
|
-
|
|
116
|
-
if (keepalive) {
|
|
117
|
-
sshArgs.push("-o", "ServerAliveInterval=30", "-o", "ServerAliveCountMax=3");
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
sshArgs.push(host, remoteCmdStr);
|
|
121
|
-
return sshArgs;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* 검증: 문자열에 호스트 OS와 맞지 않는 셸 문법이 포함되었는지 검사한다.
|
|
126
|
-
* SSH 명령 생성 시 안전 게이트로 사용.
|
|
127
|
-
*
|
|
128
|
-
* @param {string} command — 원격 실행할 명령 문자열
|
|
129
|
-
* @param {"windows"|"posix"} os — 대상 호스트 OS
|
|
130
|
-
* @returns {{ safe: boolean, violations: string[] }}
|
|
131
|
-
*/
|
|
132
|
-
export function validateCommandForOs(command, os) {
|
|
133
|
-
const violations = [];
|
|
134
|
-
|
|
135
|
-
if (os === "windows") {
|
|
136
|
-
// PowerShell에서 해석 불가능한 bash 문법 검출
|
|
137
|
-
if (/2>\/dev\/null/.test(command)) {
|
|
138
|
-
violations.push("2>/dev/null → PowerShell에서는 2>$null 사용");
|
|
139
|
-
}
|
|
140
|
-
if (/\$\(/.test(command) && !/\$\(/.test(command) === false) {
|
|
141
|
-
// $() bash substitution vs PowerShell $() — 문맥 의존
|
|
142
|
-
// PowerShell도 $()를 사용하므로 여기선 경고만
|
|
143
|
-
}
|
|
144
|
-
if (/\s&&\s/.test(command)) {
|
|
145
|
-
violations.push("&& → PowerShell에서는 ; 또는 -and 사용");
|
|
146
|
-
}
|
|
147
|
-
if (/\s\|\|\s/.test(command)) {
|
|
148
|
-
violations.push("|| → PowerShell에서는 ; 또는 -or 사용");
|
|
149
|
-
}
|
|
150
|
-
if (/>\s*\/dev\/null/.test(command)) {
|
|
151
|
-
violations.push(">/dev/null → PowerShell에서는 >$null 사용");
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
return { safe: violations.length === 0, violations };
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* hosts.json에서 호스트 전체 설정을 조회한다.
|
|
160
|
-
* @param {string} hostAlias — hosts.json의 키 (e.g. "ultra4")
|
|
161
|
-
* @param {string} [repoRoot]
|
|
162
|
-
* @returns {object|null}
|
|
163
|
-
*/
|
|
164
|
-
export function getHostConfig(hostAlias, repoRoot) {
|
|
165
|
-
loadHostsCache(repoRoot);
|
|
166
|
-
return readHost(hostAlias, repoRoot);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* 별칭(alias)으로 호스트 키를 찾는다.
|
|
171
|
-
* @param {string} alias — 호스트 키 또는 aliases 배열 내 값
|
|
172
|
-
* @param {string} [repoRoot]
|
|
173
|
-
* @returns {string|null} 호스트 키 (e.g. "ultra4") 또는 null
|
|
174
|
-
*/
|
|
175
|
-
export function resolveHostAlias(alias, repoRoot) {
|
|
176
|
-
loadHostsCache(repoRoot);
|
|
177
|
-
|
|
178
|
-
return resolveHost(alias, repoRoot)?.name ?? null;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* 특정 capability를 보유한 호스트 목록을 반환한다.
|
|
183
|
-
* 명시적 선택 UI에서 바로 리소스 정보를 보여줄 수 있도록 specs를 평탄화한다.
|
|
184
|
-
* @param {string} capability — e.g. "codex", "claude", "gpu"
|
|
185
|
-
* @param {string} [repoRoot]
|
|
186
|
-
* @returns {Array<{ name: string, config: object, specs: { cores?: number, ram_gb?: number } }>}
|
|
187
|
-
*/
|
|
188
|
-
export function selectHostForCapability(capability, repoRoot) {
|
|
189
|
-
loadHostsCache(repoRoot);
|
|
190
|
-
|
|
191
|
-
const matches = [];
|
|
192
|
-
for (const [name, cfg] of Object.entries(hostsCache.hosts || {})) {
|
|
193
|
-
if (cfg.capabilities?.includes(capability)) {
|
|
194
|
-
matches.push({
|
|
195
|
-
name,
|
|
196
|
-
config: cfg,
|
|
197
|
-
specs: {
|
|
198
|
-
cores: cfg.specs?.cores,
|
|
199
|
-
ram_gb: cfg.specs?.ram_gb,
|
|
200
|
-
},
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
return matches;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* 특정 capability를 가진 호스트를 리소스 기준으로 정렬해 반환한다.
|
|
209
|
-
* 자동 선택은 하지 않고, 명시적 선택 목록 정렬에만 사용한다.
|
|
210
|
-
* @param {string} capability — e.g. "codex", "claude", "gpu"
|
|
211
|
-
* @param {"cores"|"ram_gb"} sortBy
|
|
212
|
-
* @param {string} [repoRoot]
|
|
213
|
-
* @returns {Array<{ name: string, config: object, specs: { cores?: number, ram_gb?: number } }>}
|
|
214
|
-
*/
|
|
215
|
-
export function selectHostByResources(capability, sortBy, repoRoot) {
|
|
216
|
-
if (sortBy !== "cores" && sortBy !== "ram_gb") {
|
|
217
|
-
throw new TypeError('sortBy must be "cores" or "ram_gb"');
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
return selectHostForCapability(capability, repoRoot).sort((a, b) => {
|
|
221
|
-
const aValue = a.specs?.[sortBy] ?? -Infinity;
|
|
222
|
-
const bValue = b.specs?.[sortBy] ?? -Infinity;
|
|
223
|
-
return bValue - aValue;
|
|
224
|
-
});
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/** hosts.json 캐시 초기화 (테스트용) */
|
|
228
|
-
export function resetHostsCache() {
|
|
229
|
-
hostsCache = null;
|
|
230
|
-
hostsCacheRoot = null;
|
|
231
|
-
}
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { execFileSync } from "node:child_process";
|
|
2
|
-
|
|
3
|
-
export function detectChangedFiles({
|
|
4
|
-
cwd = process.cwd(),
|
|
5
|
-
includeStaged = true,
|
|
6
|
-
includeUnstaged = true,
|
|
7
|
-
includeUntracked = true,
|
|
8
|
-
} = {}) {
|
|
9
|
-
const staged = includeStaged
|
|
10
|
-
? runGit(cwd, ["diff", "--cached", "--name-only"])
|
|
11
|
-
: [];
|
|
12
|
-
const unstaged = includeUnstaged ? runGit(cwd, ["diff", "--name-only"]) : [];
|
|
13
|
-
const untracked = includeUntracked
|
|
14
|
-
? runGit(cwd, ["ls-files", "--others", "--exclude-standard"])
|
|
15
|
-
: [];
|
|
16
|
-
const files = [...new Set([...staged, ...unstaged, ...untracked])];
|
|
17
|
-
|
|
18
|
-
return {
|
|
19
|
-
stagedCount: staged.length,
|
|
20
|
-
unstagedCount: unstaged.length,
|
|
21
|
-
untrackedCount: untracked.length,
|
|
22
|
-
totalChanged: files.length,
|
|
23
|
-
files,
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function hasCodeChange(opts) {
|
|
28
|
-
return detectChangedFiles(opts).totalChanged > 0;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function runGit(cwd, args) {
|
|
32
|
-
try {
|
|
33
|
-
return execFileSync("git", args, {
|
|
34
|
-
cwd,
|
|
35
|
-
encoding: "utf8",
|
|
36
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
37
|
-
})
|
|
38
|
-
.split(/\r?\n/)
|
|
39
|
-
.filter(Boolean);
|
|
40
|
-
} catch {
|
|
41
|
-
return [];
|
|
42
|
-
}
|
|
43
|
-
}
|
|
@@ -1,293 +0,0 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
|
-
import {
|
|
3
|
-
copyFile,
|
|
4
|
-
mkdir,
|
|
5
|
-
readdir,
|
|
6
|
-
rename,
|
|
7
|
-
rm,
|
|
8
|
-
stat,
|
|
9
|
-
writeFile,
|
|
10
|
-
} from "node:fs/promises";
|
|
11
|
-
import { tmpdir } from "node:os";
|
|
12
|
-
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
13
|
-
import { promisify } from "node:util";
|
|
14
|
-
|
|
15
|
-
const execFileAsync = promisify(execFile);
|
|
16
|
-
const DEFAULT_MAX_SNAPSHOTS = 10;
|
|
17
|
-
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
18
|
-
|
|
19
|
-
export const CODEX_STATE_INCLUDES = Object.freeze([
|
|
20
|
-
"config.toml",
|
|
21
|
-
"AGENTS.md",
|
|
22
|
-
"skills",
|
|
23
|
-
"agents",
|
|
24
|
-
"prompts",
|
|
25
|
-
"plugins",
|
|
26
|
-
]);
|
|
27
|
-
|
|
28
|
-
export const CODEX_STATE_EXCLUDES = Object.freeze([
|
|
29
|
-
"*.sqlite*",
|
|
30
|
-
".sandbox*",
|
|
31
|
-
".tmp",
|
|
32
|
-
"_archived_skills",
|
|
33
|
-
"memories",
|
|
34
|
-
"cache",
|
|
35
|
-
"log",
|
|
36
|
-
"logs",
|
|
37
|
-
"sessions",
|
|
38
|
-
"auth.json",
|
|
39
|
-
".credentials.json",
|
|
40
|
-
"*.bak*",
|
|
41
|
-
"*.tmp-*",
|
|
42
|
-
"cap_sid",
|
|
43
|
-
"installation_id",
|
|
44
|
-
"history.jsonl",
|
|
45
|
-
"models_cache.json",
|
|
46
|
-
]);
|
|
47
|
-
|
|
48
|
-
export const GEMINI_STATE_INCLUDES = Object.freeze([
|
|
49
|
-
"settings.json",
|
|
50
|
-
"settings.local.json",
|
|
51
|
-
"GEMINI.md",
|
|
52
|
-
"commands",
|
|
53
|
-
"extensions",
|
|
54
|
-
"plugins",
|
|
55
|
-
"skills",
|
|
56
|
-
"agents",
|
|
57
|
-
]);
|
|
58
|
-
|
|
59
|
-
export const GEMINI_STATE_EXCLUDES = Object.freeze([
|
|
60
|
-
"*.sqlite*",
|
|
61
|
-
"cache",
|
|
62
|
-
"log",
|
|
63
|
-
"logs",
|
|
64
|
-
"sessions",
|
|
65
|
-
"auth.json",
|
|
66
|
-
".credentials.json",
|
|
67
|
-
"*.bak*",
|
|
68
|
-
]);
|
|
69
|
-
|
|
70
|
-
export const STATE_SNAPSHOT_THRESHOLD_MS = DAY_MS;
|
|
71
|
-
export const STATE_SNAPSHOT_MAX_SNAPSHOTS = DEFAULT_MAX_SNAPSHOTS;
|
|
72
|
-
|
|
73
|
-
function normalizePath(path) {
|
|
74
|
-
return String(path || "")
|
|
75
|
-
.replace(/\\/gu, "/")
|
|
76
|
-
.replace(/^\/+/u, "");
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function globToRegExp(pattern) {
|
|
80
|
-
const escaped = String(pattern).replace(/[.+^${}()|[\]\\]/gu, "\\$&");
|
|
81
|
-
return new RegExp(`^${escaped.replace(/\*/gu, ".*")}$`, "u");
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function compileExclude(pattern) {
|
|
85
|
-
const text = normalizePath(pattern).replace(/\/+$/u, "");
|
|
86
|
-
if (text.includes("*")) {
|
|
87
|
-
const regex = globToRegExp(text);
|
|
88
|
-
return (relativePath) => {
|
|
89
|
-
const normalized = normalizePath(relativePath);
|
|
90
|
-
return normalized.split("/").some((part) => regex.test(part));
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
return (relativePath) => {
|
|
95
|
-
const normalized = normalizePath(relativePath);
|
|
96
|
-
return normalized.split("/").includes(text);
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function isSubpath(parent, child) {
|
|
101
|
-
const rel = relative(parent, child);
|
|
102
|
-
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
async function pathStats(path) {
|
|
106
|
-
try {
|
|
107
|
-
return await stat(path);
|
|
108
|
-
} catch (error) {
|
|
109
|
-
if (error?.code === "ENOENT") return null;
|
|
110
|
-
throw error;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
async function collectFiles({ sourceDir, includes, excludeMatchers }) {
|
|
115
|
-
const sourceRoot = resolve(sourceDir);
|
|
116
|
-
const files = [];
|
|
117
|
-
|
|
118
|
-
async function visit(absPath, relativePath) {
|
|
119
|
-
if (excludeMatchers.some((matcher) => matcher(relativePath))) return;
|
|
120
|
-
|
|
121
|
-
const info = await pathStats(absPath);
|
|
122
|
-
if (!info) return;
|
|
123
|
-
if (info.isDirectory()) {
|
|
124
|
-
const children = await readdir(absPath, { withFileTypes: true });
|
|
125
|
-
for (const child of children) {
|
|
126
|
-
await visit(join(absPath, child.name), join(relativePath, child.name));
|
|
127
|
-
}
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
if (info.isFile()) {
|
|
131
|
-
files.push({
|
|
132
|
-
absPath,
|
|
133
|
-
relativePath: normalizePath(relativePath),
|
|
134
|
-
size: info.size,
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
for (const include of includes || []) {
|
|
140
|
-
const relativeInclude = normalizePath(include);
|
|
141
|
-
if (!relativeInclude || relativeInclude.startsWith("../")) continue;
|
|
142
|
-
const absPath = resolve(sourceRoot, relativeInclude);
|
|
143
|
-
if (!isSubpath(sourceRoot, absPath)) continue;
|
|
144
|
-
await visit(absPath, relativeInclude);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
files.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
148
|
-
return files;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
async function listSnapshots(destDir) {
|
|
152
|
-
const names = await readdir(destDir).catch((error) => {
|
|
153
|
-
if (error?.code === "ENOENT") return [];
|
|
154
|
-
throw error;
|
|
155
|
-
});
|
|
156
|
-
const snapshots = [];
|
|
157
|
-
for (const name of names) {
|
|
158
|
-
if (!name.endsWith(".tar.gz")) continue;
|
|
159
|
-
const path = join(destDir, name);
|
|
160
|
-
const info = await pathStats(path);
|
|
161
|
-
if (info?.isFile()) snapshots.push({ name, path, mtimeMs: info.mtimeMs });
|
|
162
|
-
}
|
|
163
|
-
snapshots.sort(
|
|
164
|
-
(a, b) => b.mtimeMs - a.mtimeMs || b.name.localeCompare(a.name),
|
|
165
|
-
);
|
|
166
|
-
return snapshots;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
async function copyToStaging(files, stagingDir) {
|
|
170
|
-
for (const file of files) {
|
|
171
|
-
const targetPath = join(stagingDir, ...file.relativePath.split("/"));
|
|
172
|
-
await mkdir(dirname(targetPath), { recursive: true });
|
|
173
|
-
await copyFile(file.absPath, targetPath);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
async function createArchive({ stagingDir, archivePath, files }) {
|
|
178
|
-
const listPath = join(stagingDir, ".snapshot-files");
|
|
179
|
-
const archiveName = normalizePath(relative(stagingDir, archivePath));
|
|
180
|
-
await writeFile(
|
|
181
|
-
listPath,
|
|
182
|
-
`${files.map((file) => file.relativePath).join("\n")}\n`,
|
|
183
|
-
"utf8",
|
|
184
|
-
);
|
|
185
|
-
await execFileAsync(
|
|
186
|
-
"tar",
|
|
187
|
-
["-czf", archiveName, "-C", ".", "-T", ".snapshot-files"],
|
|
188
|
-
{
|
|
189
|
-
cwd: stagingDir,
|
|
190
|
-
windowsHide: true,
|
|
191
|
-
},
|
|
192
|
-
);
|
|
193
|
-
await rm(listPath, { force: true });
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
async function pruneSnapshots(destDir, maxSnapshots) {
|
|
197
|
-
const snapshots = await listSnapshots(destDir);
|
|
198
|
-
const keep = Math.max(1, Number(maxSnapshots) || DEFAULT_MAX_SNAPSHOTS);
|
|
199
|
-
for (const snapshot of snapshots.slice(keep)) {
|
|
200
|
-
await rm(snapshot.path, { force: true });
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function formatStamp(date) {
|
|
205
|
-
return date
|
|
206
|
-
.toISOString()
|
|
207
|
-
.replace(/[-:]/gu, "")
|
|
208
|
-
.replace(/\.\d{3}Z$/u, "Z");
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function uniqueSuffix() {
|
|
212
|
-
return `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Snapshot selected user state into a rolling tar.gz archive.
|
|
217
|
-
*
|
|
218
|
-
* @param {object} options
|
|
219
|
-
* @param {string} options.sourceDir
|
|
220
|
-
* @param {string} options.destDir
|
|
221
|
-
* @param {string[]} options.includes
|
|
222
|
-
* @param {string[]} options.excludes
|
|
223
|
-
* @param {number} options.thresholdMs
|
|
224
|
-
* @param {number} [options.maxSnapshots=10]
|
|
225
|
-
* @returns {Promise<{skipped: boolean, reason?: string, path?: string, sizeBytes?: number, fileCount?: number}>}
|
|
226
|
-
*/
|
|
227
|
-
export async function snapshotState({
|
|
228
|
-
sourceDir,
|
|
229
|
-
destDir,
|
|
230
|
-
includes,
|
|
231
|
-
excludes = [],
|
|
232
|
-
thresholdMs = 0,
|
|
233
|
-
maxSnapshots = DEFAULT_MAX_SNAPSHOTS,
|
|
234
|
-
}) {
|
|
235
|
-
const sourceRoot = resolve(sourceDir || "");
|
|
236
|
-
const destRoot = resolve(destDir || "");
|
|
237
|
-
const sourceInfo = await pathStats(sourceRoot);
|
|
238
|
-
if (!sourceInfo?.isDirectory()) {
|
|
239
|
-
return { skipped: true, reason: "source-missing" };
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
await mkdir(destRoot, { recursive: true });
|
|
243
|
-
const snapshots = await listSnapshots(destRoot);
|
|
244
|
-
const newest = snapshots[0];
|
|
245
|
-
if (
|
|
246
|
-
newest &&
|
|
247
|
-
Number(thresholdMs) > 0 &&
|
|
248
|
-
Date.now() - newest.mtimeMs < Number(thresholdMs)
|
|
249
|
-
) {
|
|
250
|
-
return { skipped: true, reason: "threshold", path: newest.path };
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
const excludeMatchers = excludes.map((pattern) => compileExclude(pattern));
|
|
254
|
-
const files = await collectFiles({
|
|
255
|
-
sourceDir: sourceRoot,
|
|
256
|
-
includes,
|
|
257
|
-
excludeMatchers,
|
|
258
|
-
});
|
|
259
|
-
if (files.length === 0) {
|
|
260
|
-
return { skipped: true, reason: "empty" };
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const suffix = uniqueSuffix();
|
|
264
|
-
const stagingDir = join(tmpdir(), `tfx-state-snapshot-${suffix}`);
|
|
265
|
-
const tempArchivePath = join(stagingDir, `.state-${suffix}.tar.gz.tmp`);
|
|
266
|
-
const finalArchivePath = join(
|
|
267
|
-
destRoot,
|
|
268
|
-
`state-${formatStamp(new Date())}-${suffix}.tar.gz`,
|
|
269
|
-
);
|
|
270
|
-
|
|
271
|
-
try {
|
|
272
|
-
await mkdir(stagingDir, { recursive: true });
|
|
273
|
-
await copyToStaging(files, stagingDir);
|
|
274
|
-
await createArchive({
|
|
275
|
-
stagingDir,
|
|
276
|
-
archivePath: tempArchivePath,
|
|
277
|
-
files,
|
|
278
|
-
});
|
|
279
|
-
await rename(tempArchivePath, finalArchivePath);
|
|
280
|
-
await pruneSnapshots(destRoot, maxSnapshots);
|
|
281
|
-
const archiveInfo = await stat(finalArchivePath);
|
|
282
|
-
const sizeBytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
283
|
-
return {
|
|
284
|
-
skipped: false,
|
|
285
|
-
path: finalArchivePath,
|
|
286
|
-
sizeBytes: archiveInfo.size || sizeBytes,
|
|
287
|
-
fileCount: files.length,
|
|
288
|
-
};
|
|
289
|
-
} finally {
|
|
290
|
-
await rm(stagingDir, { recursive: true, force: true });
|
|
291
|
-
await rm(tempArchivePath, { force: true });
|
|
292
|
-
}
|
|
293
|
-
}
|