@triflux/core 10.0.0-alpha.2 → 10.0.1
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/keyword-rules.json +10 -17
- package/hooks/safety-guard.mjs +61 -35
- package/hub/lib/ssh-command.mjs +74 -13
- package/hub/lib/ssh-retry.mjs +59 -0
- package/hub/state.mjs +27 -14
- package/package.json +11 -3
- package/scripts/lib/skill-template.mjs +123 -19
package/hooks/keyword-rules.json
CHANGED
|
@@ -38,8 +38,12 @@
|
|
|
38
38
|
"mcp_route": null
|
|
39
39
|
},
|
|
40
40
|
{
|
|
41
|
-
"id": "tfx-
|
|
41
|
+
"id": "tfx-swarm",
|
|
42
42
|
"patterns": [
|
|
43
|
+
{
|
|
44
|
+
"source": "\\btfx[\\s-]?swarm\\b",
|
|
45
|
+
"flags": "i"
|
|
46
|
+
},
|
|
43
47
|
{
|
|
44
48
|
"source": "\\btfx[\\s-]?codex[\\s-]?swarm\\b",
|
|
45
49
|
"flags": "i"
|
|
@@ -47,20 +51,7 @@
|
|
|
47
51
|
{
|
|
48
52
|
"source": "\\bcodex[\\s-]?swarm\\b",
|
|
49
53
|
"flags": "i"
|
|
50
|
-
}
|
|
51
|
-
],
|
|
52
|
-
"skill": "tfx-codex-swarm",
|
|
53
|
-
"priority": 1,
|
|
54
|
-
"supersedes": [
|
|
55
|
-
"tfx-codex"
|
|
56
|
-
],
|
|
57
|
-
"exclusive": false,
|
|
58
|
-
"state": null,
|
|
59
|
-
"mcp_route": null
|
|
60
|
-
},
|
|
61
|
-
{
|
|
62
|
-
"id": "tfx-remote-spawn",
|
|
63
|
-
"patterns": [
|
|
54
|
+
},
|
|
64
55
|
{
|
|
65
56
|
"source": "\\btfx[\\s-]?remote[\\s-]?spawn\\b",
|
|
66
57
|
"flags": "i"
|
|
@@ -70,9 +61,11 @@
|
|
|
70
61
|
"flags": "i"
|
|
71
62
|
}
|
|
72
63
|
],
|
|
73
|
-
"skill": "tfx-
|
|
64
|
+
"skill": "tfx-swarm",
|
|
74
65
|
"priority": 1,
|
|
75
|
-
"supersedes": [
|
|
66
|
+
"supersedes": [
|
|
67
|
+
"tfx-codex"
|
|
68
|
+
],
|
|
76
69
|
"exclusive": false,
|
|
77
70
|
"state": null,
|
|
78
71
|
"mcp_route": null
|
package/hooks/safety-guard.mjs
CHANGED
|
@@ -26,6 +26,18 @@ const BLOCK_RULES = [
|
|
|
26
26
|
{ pattern: /\bpsmux\s+kill-server\b/i, reason: "psmux kill-server 차단 — 모든 세션이 즉시 종료됩니다. node hub/team/psmux.mjs kill-swarm 사용", skipIfGit: true },
|
|
27
27
|
];
|
|
28
28
|
|
|
29
|
+
const WT_DIRECT_PATTERNS = [
|
|
30
|
+
/\bwt\.exe\b/i,
|
|
31
|
+
/\bwt\s+new-tab\b/i,
|
|
32
|
+
/\bwt\s+split-pane\b/i,
|
|
33
|
+
/\bwt\s+-w\b/i,
|
|
34
|
+
/\bStart-Process\s+wt/i,
|
|
35
|
+
/\bStart-Process\s+['"]?wt\.exe/i,
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const WT_DIRECT_BLOCK_MESSAGE =
|
|
39
|
+
"[safety-guard] wt.exe 직접 호출 차단됨. → hub/team/wt-manager.mjs의 createTab() / splitPane() / applySplitLayout()을 사용하세요.";
|
|
40
|
+
|
|
29
41
|
// ── 경고 규칙 ──────────────────────────────────────────────
|
|
30
42
|
const WARN_RULES = [
|
|
31
43
|
{ pattern: /\bgit\s+push\b(?!.*--force)/i, warn: "git push 감지. 원격 저장소에 반영됩니다." },
|
|
@@ -46,6 +58,45 @@ function readStdin() {
|
|
|
46
58
|
}
|
|
47
59
|
}
|
|
48
60
|
|
|
61
|
+
function shouldSkipSegment(segment) {
|
|
62
|
+
return !segment || segment.startsWith("#") || /^\s*(echo|printf|grep|git\s+commit)\b/i.test(segment);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function hasSegmentInvocation(cmd, patterns) {
|
|
66
|
+
if (!patterns.some((pattern) => pattern.test(cmd))) return false;
|
|
67
|
+
|
|
68
|
+
const lines = cmd.split(/\n/);
|
|
69
|
+
let heredocDelimiter = null;
|
|
70
|
+
return lines.some((line) => {
|
|
71
|
+
if (heredocDelimiter !== null) {
|
|
72
|
+
if (line.trim() === heredocDelimiter) heredocDelimiter = null;
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const heredocMatch = line.match(/<<['"]?(\w+)['"]?/);
|
|
77
|
+
if (heredocMatch) {
|
|
78
|
+
heredocDelimiter = heredocMatch[1];
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const segments = line.split(/\s*(?:&&|;|\|\|)\s*/);
|
|
83
|
+
return segments.some((seg) => {
|
|
84
|
+
const trimmed = seg.trim();
|
|
85
|
+
if (shouldSkipSegment(trimmed)) return false;
|
|
86
|
+
return patterns.some((pattern) => pattern.test(trimmed));
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function blockCommand(message, command) {
|
|
92
|
+
process.stderr.write(
|
|
93
|
+
`${message}\n` +
|
|
94
|
+
`명령어: ${command.slice(0, 120)}${command.length > 120 ? "..." : ""}\n` +
|
|
95
|
+
"이 명령은 실행할 수 없습니다. 안전한 대안을 사용하세요."
|
|
96
|
+
);
|
|
97
|
+
process.exit(2);
|
|
98
|
+
}
|
|
99
|
+
|
|
49
100
|
function main() {
|
|
50
101
|
const raw = readStdin();
|
|
51
102
|
if (!raw.trim()) process.exit(0);
|
|
@@ -65,47 +116,22 @@ function main() {
|
|
|
65
116
|
// psmux 명령이 실제 CLI 호출인지 판별 (오탐 방지)
|
|
66
117
|
// git commit 메시지, echo, grep, cat, heredoc 안의 텍스트는 무시
|
|
67
118
|
function isPsmuxInvocation(cmd) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (heredocDelimiter !== null) {
|
|
78
|
-
if (line.trim() === heredocDelimiter) heredocDelimiter = null;
|
|
79
|
-
return false;
|
|
80
|
-
}
|
|
81
|
-
// heredoc 시작 감지: <<'WORD', <<"WORD", <<WORD
|
|
82
|
-
const heredocMatch = line.match(/<<['"]?(\w+)['"]?/);
|
|
83
|
-
if (heredocMatch) {
|
|
84
|
-
heredocDelimiter = heredocMatch[1];
|
|
85
|
-
// heredoc 시작 줄 자체는 실제 명령이므로 계속 검사하되
|
|
86
|
-
// 시작 줄에 psmux kill이 포함될 리 없으므로 여기서 false 반환
|
|
87
|
-
return false;
|
|
88
|
-
}
|
|
89
|
-
const segments = line.split(/\s*(?:&&|;|\|\|)\s*/);
|
|
90
|
-
return segments.some((seg) => {
|
|
91
|
-
const t = seg.trim();
|
|
92
|
-
if (!t || t.startsWith("#")) return false;
|
|
93
|
-
if (/^\s*(echo|printf|grep|git\s+commit)\b/i.test(t)) return false;
|
|
94
|
-
return /\bpsmux\s+kill-(session|server)\b/i.test(t);
|
|
95
|
-
});
|
|
96
|
-
});
|
|
119
|
+
return hasSegmentInvocation(cmd, [/\bpsmux\s+kill-(session|server)\b/i]);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function isWtDirectInvocation(cmd) {
|
|
123
|
+
return hasSegmentInvocation(cmd, WT_DIRECT_PATTERNS);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (isWtDirectInvocation(command)) {
|
|
127
|
+
blockCommand(WT_DIRECT_BLOCK_MESSAGE, command);
|
|
97
128
|
}
|
|
98
129
|
|
|
99
130
|
// 1. BLOCK 체크 — exit 2로 차단
|
|
100
131
|
for (const rule of BLOCK_RULES) {
|
|
101
132
|
if (rule.skipIfGit && !isPsmuxInvocation(command)) continue;
|
|
102
133
|
if (rule.pattern.test(command)) {
|
|
103
|
-
|
|
104
|
-
`[triflux safety-guard] BLOCKED: ${rule.reason}\n` +
|
|
105
|
-
`명령어: ${command.slice(0, 120)}${command.length > 120 ? "..." : ""}\n` +
|
|
106
|
-
`이 명령은 실행할 수 없습니다. 안전한 대안을 사용하세요.`
|
|
107
|
-
);
|
|
108
|
-
process.exit(2);
|
|
134
|
+
blockCommand(`[triflux safety-guard] BLOCKED: ${rule.reason}`, command);
|
|
109
135
|
}
|
|
110
136
|
}
|
|
111
137
|
|
package/hub/lib/ssh-command.mjs
CHANGED
|
@@ -8,6 +8,22 @@ import { join } from "node:path";
|
|
|
8
8
|
/** hosts.json 캐시 (프로세스 수명 동안 유지) */
|
|
9
9
|
let hostsCache = null;
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* hosts.json을 로드하여 캐시한다.
|
|
13
|
+
* @param {string} [repoRoot] — 프로젝트 루트 경로
|
|
14
|
+
*/
|
|
15
|
+
function loadHostsCache(repoRoot) {
|
|
16
|
+
if (hostsCache) return;
|
|
17
|
+
try {
|
|
18
|
+
const hostsPath = repoRoot
|
|
19
|
+
? join(repoRoot, "references", "hosts.json")
|
|
20
|
+
: join(process.cwd(), "references", "hosts.json");
|
|
21
|
+
hostsCache = JSON.parse(readFileSync(hostsPath, "utf8"));
|
|
22
|
+
} catch {
|
|
23
|
+
hostsCache = { hosts: {} };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
11
27
|
/**
|
|
12
28
|
* hosts.json에서 호스트 OS를 조회한다.
|
|
13
29
|
* @param {string} hostAlias — hosts.json의 키 (e.g. "ultra4")
|
|
@@ -15,16 +31,7 @@ let hostsCache = null;
|
|
|
15
31
|
* @returns {"windows"|"posix"} 호스트 OS 유형
|
|
16
32
|
*/
|
|
17
33
|
export function detectHostOs(hostAlias, repoRoot) {
|
|
18
|
-
|
|
19
|
-
try {
|
|
20
|
-
const hostsPath = repoRoot
|
|
21
|
-
? join(repoRoot, "references", "hosts.json")
|
|
22
|
-
: join(process.cwd(), "references", "hosts.json");
|
|
23
|
-
hostsCache = JSON.parse(readFileSync(hostsPath, "utf8"));
|
|
24
|
-
} catch {
|
|
25
|
-
hostsCache = { hosts: {} };
|
|
26
|
-
}
|
|
27
|
-
}
|
|
34
|
+
loadHostsCache(repoRoot);
|
|
28
35
|
|
|
29
36
|
const hostCfg = hostsCache.hosts?.[hostAlias];
|
|
30
37
|
if (hostCfg?.os === "windows") return "windows";
|
|
@@ -88,12 +95,14 @@ export function nullDevice(os) {
|
|
|
88
95
|
* @param {string[]} remoteCmd — 원격에서 실행할 명령 + 인자
|
|
89
96
|
* @param {object} [opts]
|
|
90
97
|
* @param {number} [opts.connectTimeout=5] — SSH 연결 타임아웃(초)
|
|
98
|
+
* @param {boolean} [opts.keepalive=true] — ServerAliveInterval/CountMax 활성화
|
|
91
99
|
* @param {"windows"|"posix"} [opts.os] — 호스트 OS (미지정 시 자동 감지)
|
|
92
100
|
* @param {string} [opts.repoRoot] — hosts.json 탐색 경로
|
|
93
101
|
* @returns {string[]} execFileSync('ssh', returnValue) 형태로 사용
|
|
94
102
|
*/
|
|
95
103
|
export function buildSshArgs(host, remoteCmd, opts = {}) {
|
|
96
104
|
const connectTimeout = opts.connectTimeout ?? 5;
|
|
105
|
+
const keepalive = opts.keepalive !== false;
|
|
97
106
|
const hostAlias = host.includes("@") ? host.split("@").pop() : host;
|
|
98
107
|
const os = opts.os ?? detectHostOs(hostAlias, opts.repoRoot);
|
|
99
108
|
|
|
@@ -102,12 +111,17 @@ export function buildSshArgs(host, remoteCmd, opts = {}) {
|
|
|
102
111
|
const quotedArgs = args.map((a) => shellQuoteForHost(a, os));
|
|
103
112
|
const remoteCmdStr = [cmd, ...quotedArgs].join(" ");
|
|
104
113
|
|
|
105
|
-
|
|
114
|
+
const sshArgs = [
|
|
106
115
|
"-o", `ConnectTimeout=${connectTimeout}`,
|
|
107
116
|
"-o", "BatchMode=yes",
|
|
108
|
-
host,
|
|
109
|
-
remoteCmdStr,
|
|
110
117
|
];
|
|
118
|
+
|
|
119
|
+
if (keepalive) {
|
|
120
|
+
sshArgs.push("-o", "ServerAliveInterval=30", "-o", "ServerAliveCountMax=3");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
sshArgs.push(host, remoteCmdStr);
|
|
124
|
+
return sshArgs;
|
|
111
125
|
}
|
|
112
126
|
|
|
113
127
|
/**
|
|
@@ -144,6 +158,53 @@ export function validateCommandForOs(command, os) {
|
|
|
144
158
|
return { safe: violations.length === 0, violations };
|
|
145
159
|
}
|
|
146
160
|
|
|
161
|
+
/**
|
|
162
|
+
* hosts.json에서 호스트 전체 설정을 조회한다.
|
|
163
|
+
* @param {string} hostAlias — hosts.json의 키 (e.g. "ultra4")
|
|
164
|
+
* @param {string} [repoRoot]
|
|
165
|
+
* @returns {object|null}
|
|
166
|
+
*/
|
|
167
|
+
export function getHostConfig(hostAlias, repoRoot) {
|
|
168
|
+
loadHostsCache(repoRoot);
|
|
169
|
+
return hostsCache.hosts?.[hostAlias] ?? null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 별칭(alias)으로 호스트 키를 찾는다.
|
|
174
|
+
* @param {string} alias — 호스트 키 또는 aliases 배열 내 값
|
|
175
|
+
* @param {string} [repoRoot]
|
|
176
|
+
* @returns {string|null} 호스트 키 (e.g. "ultra4") 또는 null
|
|
177
|
+
*/
|
|
178
|
+
export function resolveHostAlias(alias, repoRoot) {
|
|
179
|
+
loadHostsCache(repoRoot);
|
|
180
|
+
|
|
181
|
+
if (hostsCache.hosts?.[alias]) return alias;
|
|
182
|
+
|
|
183
|
+
for (const [name, cfg] of Object.entries(hostsCache.hosts || {})) {
|
|
184
|
+
if (cfg.aliases?.includes(alias)) return name;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 특정 capability를 보유한 호스트 목록을 반환한다.
|
|
192
|
+
* @param {string} capability — e.g. "codex", "claude", "gpu"
|
|
193
|
+
* @param {string} [repoRoot]
|
|
194
|
+
* @returns {Array<{ name: string, config: object }>}
|
|
195
|
+
*/
|
|
196
|
+
export function selectHostForCapability(capability, repoRoot) {
|
|
197
|
+
loadHostsCache(repoRoot);
|
|
198
|
+
|
|
199
|
+
const matches = [];
|
|
200
|
+
for (const [name, cfg] of Object.entries(hostsCache.hosts || {})) {
|
|
201
|
+
if (cfg.capabilities?.includes(capability)) {
|
|
202
|
+
matches.push({ name, config: cfg });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return matches;
|
|
206
|
+
}
|
|
207
|
+
|
|
147
208
|
/** hosts.json 캐시 초기화 (테스트용) */
|
|
148
209
|
export function resetHostsCache() {
|
|
149
210
|
hostsCache = null;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// hub/lib/ssh-retry.mjs — SSH command retry with exponential backoff
|
|
2
|
+
// transient SSH 에러(connection reset, broken pipe 등)를 감지하여 자동 재시도한다.
|
|
3
|
+
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MAX_RETRIES = 2;
|
|
7
|
+
const DEFAULT_BASE_DELAY_MS = 1000;
|
|
8
|
+
|
|
9
|
+
/** transient SSH 에러 패턴 */
|
|
10
|
+
const TRANSIENT_RE =
|
|
11
|
+
/connection (reset|refused|timed out)|broken pipe|network is unreachable|ssh_exchange_identification|kex_exchange_identification/i;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* execFileSync('ssh', args) 를 retry 래핑한다.
|
|
15
|
+
* transient 에러만 재시도하고, 그 외 에러는 즉시 throw.
|
|
16
|
+
*
|
|
17
|
+
* @param {string[]} args — ssh 인자 배열
|
|
18
|
+
* @param {object} [opts] — execFileSync 옵션 + retry 설정
|
|
19
|
+
* @param {number} [opts.maxRetries=2]
|
|
20
|
+
* @param {number} [opts.baseDelayMs=1000]
|
|
21
|
+
* @returns {string|Buffer} ssh stdout
|
|
22
|
+
*/
|
|
23
|
+
export function execSshWithRetry(args, opts = {}) {
|
|
24
|
+
const { maxRetries = DEFAULT_MAX_RETRIES, baseDelayMs = DEFAULT_BASE_DELAY_MS, ...execOpts } = opts;
|
|
25
|
+
|
|
26
|
+
let lastError;
|
|
27
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
28
|
+
try {
|
|
29
|
+
return execFileSync("ssh", args, execOpts);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
lastError = err;
|
|
32
|
+
if (attempt < maxRetries && isTransientSshError(err)) {
|
|
33
|
+
sleepSync(baseDelayMs * 2 ** attempt);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
throw err;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
throw lastError;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* transient SSH 에러인지 판별한다.
|
|
44
|
+
* @param {Error} err
|
|
45
|
+
* @returns {boolean}
|
|
46
|
+
*/
|
|
47
|
+
export function isTransientSshError(err) {
|
|
48
|
+
const msg = String(err.stderr || err.message || "");
|
|
49
|
+
return TRANSIENT_RE.test(msg);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 동기 sleep (Atomics.wait 기반).
|
|
54
|
+
* @param {number} ms
|
|
55
|
+
*/
|
|
56
|
+
function sleepSync(ms) {
|
|
57
|
+
if (ms <= 0) return;
|
|
58
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
59
|
+
}
|
package/hub/state.mjs
CHANGED
|
@@ -5,7 +5,8 @@ import { dirname, join } from 'node:path';
|
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
|
|
7
7
|
const PROJECT_ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
8
|
-
const
|
|
8
|
+
const PID_FILE_NAME = 'hub.pid';
|
|
9
|
+
const LEGACY_STATE_FILE_NAME = 'hub-state.json';
|
|
9
10
|
const LOCK_FILE_NAME = 'hub-start.lock';
|
|
10
11
|
|
|
11
12
|
let heldLockPath = null;
|
|
@@ -17,7 +18,11 @@ function getStateDir(options = {}) {
|
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
function getStatePath(options = {}) {
|
|
20
|
-
return join(getStateDir(options),
|
|
21
|
+
return options.statePath || join(getStateDir(options), PID_FILE_NAME);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getLegacyStatePath(options = {}) {
|
|
25
|
+
return join(getStateDir(options), LEGACY_STATE_FILE_NAME);
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
function getLockPath(options = {}) {
|
|
@@ -59,6 +64,21 @@ function safeReplaceFile(tempPath, targetPath) {
|
|
|
59
64
|
}
|
|
60
65
|
}
|
|
61
66
|
|
|
67
|
+
function writeJsonFile(targetPath, payload) {
|
|
68
|
+
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
|
|
69
|
+
writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
70
|
+
safeReplaceFile(tempPath, targetPath);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readJsonFile(filePath) {
|
|
74
|
+
try {
|
|
75
|
+
if (!existsSync(filePath)) return null;
|
|
76
|
+
return parseJson(readFileSync(filePath, 'utf8'), null);
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
62
82
|
/**
|
|
63
83
|
* 허브의 현재 상태(PID, 포트, 버전 등)를 파일에 기록합니다.
|
|
64
84
|
* 원자적(atomic) 쓰기를 위해 임시 파일을 생성한 후 교체하는 방식을 사용합니다.
|
|
@@ -73,15 +93,14 @@ function safeReplaceFile(tempPath, targetPath) {
|
|
|
73
93
|
* @param {string} [options.stateDir] - 상태 파일이 저장될 디렉토리
|
|
74
94
|
* @returns {object} 기록된 상태 데이터
|
|
75
95
|
*/
|
|
76
|
-
export function writeState({ pid, port, version, sessionId, startedAt }, options = {}) {
|
|
96
|
+
export function writeState({ pid, port, version, sessionId, startedAt, ...rest }, options = {}) {
|
|
77
97
|
const stateDir = getStateDir(options);
|
|
78
98
|
const statePath = getStatePath(options);
|
|
79
|
-
const
|
|
80
|
-
const payload = { pid, port, version, sessionId, startedAt };
|
|
99
|
+
const payload = { pid, port, version, sessionId, startedAt, ...rest };
|
|
81
100
|
|
|
82
101
|
mkdirSync(stateDir, { recursive: true });
|
|
83
|
-
|
|
84
|
-
|
|
102
|
+
writeJsonFile(statePath, payload);
|
|
103
|
+
try { unlinkSync(getLegacyStatePath(options)); } catch {}
|
|
85
104
|
return payload;
|
|
86
105
|
}
|
|
87
106
|
|
|
@@ -93,13 +112,7 @@ export function writeState({ pid, port, version, sessionId, startedAt }, options
|
|
|
93
112
|
* @returns {object|null} 읽어온 상태 데이터 또는 실패 시 null
|
|
94
113
|
*/
|
|
95
114
|
export function readState(options = {}) {
|
|
96
|
-
|
|
97
|
-
try {
|
|
98
|
-
if (!existsSync(statePath)) return null;
|
|
99
|
-
return parseJson(readFileSync(statePath, 'utf8'), null);
|
|
100
|
-
} catch {
|
|
101
|
-
return null;
|
|
102
|
-
}
|
|
115
|
+
return readJsonFile(getStatePath(options)) ?? readJsonFile(getLegacyStatePath(options));
|
|
103
116
|
}
|
|
104
117
|
|
|
105
118
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@triflux/core",
|
|
3
|
-
"version": "10.0.
|
|
3
|
+
"version": "10.0.1",
|
|
4
4
|
"description": "triflux core — CLI routing, pipeline, adapters. Zero native dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "hub/index.mjs",
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
"./hub/*": "./hub/*",
|
|
10
10
|
"./scripts/*": "./scripts/*"
|
|
11
11
|
},
|
|
12
|
-
"engines": {
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18.0.0"
|
|
14
|
+
},
|
|
13
15
|
"dependencies": {
|
|
14
16
|
"pino": "^10.3.1",
|
|
15
17
|
"pino-pretty": "^13.1.3",
|
|
@@ -21,7 +23,13 @@
|
|
|
21
23
|
"hooks",
|
|
22
24
|
"hud"
|
|
23
25
|
],
|
|
24
|
-
"keywords": [
|
|
26
|
+
"keywords": [
|
|
27
|
+
"claude-code",
|
|
28
|
+
"cli-routing",
|
|
29
|
+
"codex",
|
|
30
|
+
"gemini",
|
|
31
|
+
"triflux"
|
|
32
|
+
],
|
|
25
33
|
"author": "tellang",
|
|
26
34
|
"license": "MIT",
|
|
27
35
|
"repository": {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
-
import { basename, extname, join, relative } from "node:path";
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
|
|
4
4
|
const IF_TAG_RE = /{{\s*(#if\s+([A-Za-z0-9_.-]+)|\/if)\s*}}/g;
|
|
5
|
-
const
|
|
5
|
+
const PARTIAL_RE = /{{>\s*([A-Za-z0-9_./-]+)\s*}}/g;
|
|
6
|
+
const FILE_INCLUDE_RE = /{{#include\s+([A-Za-z0-9_./-]+)\s*}}/g;
|
|
6
7
|
const VARIABLE_RE = /{{\s*([A-Za-z0-9_.-]+)\s*}}/g;
|
|
7
8
|
const FRONTMATTER_RE = /^---\r?\n(?:([\s\S]*?)\r?\n)?---\r?\n?/;
|
|
8
9
|
|
|
@@ -39,6 +40,44 @@ function parseScalar(raw) {
|
|
|
39
40
|
return bool ?? value;
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
function parseMultilineValue(lines, startIndex, marker) {
|
|
44
|
+
const fold = marker.startsWith(">");
|
|
45
|
+
const chunks = [];
|
|
46
|
+
let index = startIndex;
|
|
47
|
+
|
|
48
|
+
while (index + 1 < lines.length && /^\s+/.test(lines[index + 1])) {
|
|
49
|
+
index += 1;
|
|
50
|
+
chunks.push(lines[index].replace(/^\s+/, ""));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
index,
|
|
55
|
+
value: fold ? chunks.join(" ").trim() : chunks.join("\n").trim(),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseListValue(lines, startIndex) {
|
|
60
|
+
const items = [];
|
|
61
|
+
let index = startIndex;
|
|
62
|
+
|
|
63
|
+
while (index + 1 < lines.length) {
|
|
64
|
+
const nextLine = lines[index + 1];
|
|
65
|
+
const match = nextLine.match(/^\s*-\s+(.*)$/);
|
|
66
|
+
if (match) {
|
|
67
|
+
index += 1;
|
|
68
|
+
items.push(parseScalar(match[1]));
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (/^\s*$/.test(nextLine)) {
|
|
72
|
+
index += 1;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { index, value: items };
|
|
79
|
+
}
|
|
80
|
+
|
|
42
81
|
function parseFrontmatterBlock(block) {
|
|
43
82
|
const lines = block.split(/\r?\n/);
|
|
44
83
|
const data = {};
|
|
@@ -53,16 +92,21 @@ function parseFrontmatterBlock(block) {
|
|
|
53
92
|
const value = rawValue.trim();
|
|
54
93
|
|
|
55
94
|
if (value === ">" || value === "|" || value === ">-" || value === "|-") {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
i += 1;
|
|
60
|
-
chunks.push(lines[i].replace(/^\s+/, ""));
|
|
61
|
-
}
|
|
62
|
-
data[key] = fold ? chunks.join(" ").trim() : chunks.join("\n").trim();
|
|
95
|
+
const parsed = parseMultilineValue(lines, i, value);
|
|
96
|
+
data[key] = parsed.value;
|
|
97
|
+
i = parsed.index;
|
|
63
98
|
continue;
|
|
64
99
|
}
|
|
65
100
|
|
|
101
|
+
if (!value) {
|
|
102
|
+
const parsed = parseListValue(lines, i);
|
|
103
|
+
if (parsed.index !== i) {
|
|
104
|
+
data[key] = parsed.value;
|
|
105
|
+
i = parsed.index;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
66
110
|
data[key] = parseScalar(value);
|
|
67
111
|
}
|
|
68
112
|
|
|
@@ -109,8 +153,8 @@ function evaluateConditionals(source, context) {
|
|
|
109
153
|
return walk(0, false).output;
|
|
110
154
|
}
|
|
111
155
|
|
|
112
|
-
function
|
|
113
|
-
return source.replace(
|
|
156
|
+
function renderPartials(source, context, partials, options, includeStack) {
|
|
157
|
+
return source.replace(PARTIAL_RE, (_full, partialName) => {
|
|
114
158
|
const partial = partials[partialName];
|
|
115
159
|
if (partial == null) {
|
|
116
160
|
throw new Error(`Missing partial: ${partialName}`);
|
|
@@ -119,7 +163,53 @@ function renderIncludes(source, context, partials, includeStack) {
|
|
|
119
163
|
const chain = [...includeStack, partialName].join(" -> ");
|
|
120
164
|
throw new Error(`Circular partial include: ${chain}`);
|
|
121
165
|
}
|
|
122
|
-
return renderWithContext(partial, context, partials,
|
|
166
|
+
return renderWithContext(partial, context, partials, options, [
|
|
167
|
+
...includeStack,
|
|
168
|
+
partialName,
|
|
169
|
+
]);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function normalizeIncludeName(includeName) {
|
|
174
|
+
return includeName.replace(/\\/g, "/");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function resolveIncludeContent(includeName, options) {
|
|
178
|
+
const normalizedName = normalizeIncludeName(includeName);
|
|
179
|
+
if (Object.prototype.hasOwnProperty.call(options.includes, normalizedName)) {
|
|
180
|
+
return options.includes[normalizedName];
|
|
181
|
+
}
|
|
182
|
+
if (!options.includeBaseDir) {
|
|
183
|
+
throw new Error(`Missing include: ${normalizedName}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const baseDir = resolve(options.includeBaseDir);
|
|
187
|
+
const fullPath = resolve(baseDir, normalizedName);
|
|
188
|
+
const relativePath = relative(baseDir, fullPath);
|
|
189
|
+
const isInsideBaseDir =
|
|
190
|
+
relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
|
|
191
|
+
|
|
192
|
+
if (!isInsideBaseDir || !existsSync(fullPath)) {
|
|
193
|
+
throw new Error(`Missing include: ${normalizedName}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const content = readFileSync(fullPath, "utf8");
|
|
197
|
+
options.includes[normalizedName] = content;
|
|
198
|
+
return content;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function renderFileIncludes(source, context, partials, options, includeStack) {
|
|
202
|
+
return source.replace(FILE_INCLUDE_RE, (_full, includeName) => {
|
|
203
|
+
const normalizedName = normalizeIncludeName(includeName);
|
|
204
|
+
const content = resolveIncludeContent(normalizedName, options);
|
|
205
|
+
if (includeStack.includes(normalizedName)) {
|
|
206
|
+
const chain = [...includeStack, normalizedName].join(" -> ");
|
|
207
|
+
throw new Error(`Circular template include: ${chain}`);
|
|
208
|
+
}
|
|
209
|
+
return renderWithContext(content, context, partials, options, [
|
|
210
|
+
...includeStack,
|
|
211
|
+
normalizedName,
|
|
212
|
+
]);
|
|
123
213
|
});
|
|
124
214
|
}
|
|
125
215
|
|
|
@@ -133,14 +223,25 @@ function renderVariables(source, context) {
|
|
|
133
223
|
});
|
|
134
224
|
}
|
|
135
225
|
|
|
136
|
-
function renderWithContext(source, context, partials, includeStack = []) {
|
|
226
|
+
function renderWithContext(source, context, partials, options, includeStack = []) {
|
|
137
227
|
const afterIf = evaluateConditionals(source, context);
|
|
138
|
-
const
|
|
139
|
-
|
|
228
|
+
const afterPartials = renderPartials(afterIf, context, partials, options, includeStack);
|
|
229
|
+
const afterFileIncludes = renderFileIncludes(
|
|
230
|
+
afterPartials,
|
|
231
|
+
context,
|
|
232
|
+
partials,
|
|
233
|
+
options,
|
|
234
|
+
includeStack,
|
|
235
|
+
);
|
|
236
|
+
return renderVariables(afterFileIncludes, context);
|
|
140
237
|
}
|
|
141
238
|
|
|
142
239
|
function readAllTemplateFiles(rootDir, currentDir = rootDir) {
|
|
143
|
-
|
|
240
|
+
if (!rootDir || !existsSync(rootDir)) return [];
|
|
241
|
+
|
|
242
|
+
const entries = readdirSync(currentDir, { withFileTypes: true }).sort((left, right) =>
|
|
243
|
+
left.name.localeCompare(right.name),
|
|
244
|
+
);
|
|
144
245
|
const files = [];
|
|
145
246
|
|
|
146
247
|
for (const entry of entries) {
|
|
@@ -217,6 +318,9 @@ export function loadTemplatePartials(partialsDir) {
|
|
|
217
318
|
}
|
|
218
319
|
|
|
219
320
|
export function renderSkillTemplate(template, context = {}, options = {}) {
|
|
220
|
-
const { partials = {} } = options;
|
|
221
|
-
return renderWithContext(template, context, partials
|
|
321
|
+
const { partials = {}, includes = {}, includeBaseDir = "" } = options;
|
|
322
|
+
return renderWithContext(template, context, partials, {
|
|
323
|
+
includeBaseDir,
|
|
324
|
+
includes: { ...includes },
|
|
325
|
+
});
|
|
222
326
|
}
|