triflux 10.2.1 → 10.3.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/README.md +236 -156
- package/hub/bridge.mjs +638 -290
- package/hub/codex-compat.mjs +1 -1
- package/hub/fullcycle.mjs +1 -1
- package/hub/intent.mjs +1 -0
- package/hub/lib/mcp-response-cache.mjs +205 -0
- package/hub/pipe.mjs +228 -119
- package/hub/reflexion.mjs +87 -13
- package/hub/research.mjs +1 -0
- package/hub/server.mjs +997 -611
- package/hub/team/conductor-registry.mjs +121 -0
- package/hub/team/conductor.mjs +256 -125
- package/hub/team/execution-mode.mjs +105 -0
- package/hub/team/headless.mjs +686 -252
- package/hub/team/lead-control.mjs +91 -4
- package/hub/team/mcp-selector.mjs +145 -0
- package/hub/team/session-sync.mjs +153 -6
- package/hub/team/swarm-hypervisor.mjs +208 -86
- package/hub/token-mode.mjs +1 -0
- package/hub/tools.mjs +474 -252
- package/package.json +5 -5
- package/scripts/codex-gateway-preflight.mjs +133 -0
- package/scripts/codex-mcp-gateway-sync.mjs +199 -0
- package/skills/star-prompt/SKILL.md +169 -69
- package/skills/tfx-setup/SKILL.md +124 -0
- package/skills/tfx-swarm/SKILL.md +124 -72
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "triflux",
|
|
3
|
-
"version": "10.
|
|
3
|
+
"version": "10.3.0",
|
|
4
4
|
"description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -44,11 +44,11 @@
|
|
|
44
44
|
"lint": "biome check .",
|
|
45
45
|
"lint:fix": "biome check --fix .",
|
|
46
46
|
"health": "npm test && npm run lint",
|
|
47
|
-
"test": "node scripts/test-lock.mjs --test --test-force-exit --test-concurrency=
|
|
48
|
-
"test:unit": "node scripts/test-lock.mjs --test --test-force-exit --test-concurrency=
|
|
49
|
-
"test:integration": "node scripts/test-lock.mjs --test --test-force-exit --test-concurrency=
|
|
47
|
+
"test": "node scripts/test-lock.mjs --test --test-force-exit --test-concurrency=8 \"tests/**/*.test.mjs\" \"scripts/__tests__/**/*.test.mjs\"",
|
|
48
|
+
"test:unit": "node scripts/test-lock.mjs --test --test-force-exit --test-concurrency=8 tests/unit/**/*.test.mjs",
|
|
49
|
+
"test:integration": "node scripts/test-lock.mjs --test --test-force-exit --test-concurrency=8 tests/integration/**/*.test.mjs",
|
|
50
50
|
"test:route-smoke": "node scripts/test-lock.mjs --test scripts/test-tfx-route-no-claude-native.mjs",
|
|
51
|
-
"test:contract": "node scripts/test-lock.mjs --test --test-force-exit --test-concurrency=
|
|
51
|
+
"test:contract": "node scripts/test-lock.mjs --test --test-force-exit --test-concurrency=8 tests/contract/**/*.test.mjs",
|
|
52
52
|
"gen:skill-docs": "node scripts/gen-skill-docs.mjs",
|
|
53
53
|
"gen:skill-manifest": "node scripts/gen-skill-manifest.mjs"
|
|
54
54
|
},
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/codex-gateway-preflight.mjs — Codex MCP 초기화 시 gateway 자동 기동
|
|
3
|
+
// Codex config.toml에 MCP 서버로 등록되면, Codex 시작 시 이 스크립트가 먼저 실행되어
|
|
4
|
+
// gateway가 alive인지 확인하고 죽었으면 기동한다.
|
|
5
|
+
// 실제 MCP 기능은 없음 (no-op). 역할은 gateway 보장뿐.
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { join, dirname } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
|
|
12
|
+
const PLUGIN_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
13
|
+
const PROBE_PORT = 8100;
|
|
14
|
+
const PROBE_TIMEOUT_MS = 2000;
|
|
15
|
+
const STARTUP_WAIT_MS = 6000;
|
|
16
|
+
const POLL_MS = 500;
|
|
17
|
+
const CACHE_TTL_MS = 5 * 60 * 1000; // 5분
|
|
18
|
+
const CACHE_FILE = join(tmpdir(), 'tfx-gateway-alive.json');
|
|
19
|
+
|
|
20
|
+
function readCache() {
|
|
21
|
+
try {
|
|
22
|
+
if (!existsSync(CACHE_FILE)) return null;
|
|
23
|
+
const data = JSON.parse(readFileSync(CACHE_FILE, 'utf8'));
|
|
24
|
+
if (Date.now() - data.ts < CACHE_TTL_MS) return data;
|
|
25
|
+
} catch { /* ignore */ }
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function writeCache() {
|
|
30
|
+
try {
|
|
31
|
+
writeFileSync(CACHE_FILE, JSON.stringify({ ts: Date.now(), alive: true }));
|
|
32
|
+
} catch { /* ignore */ }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function isGatewayAlive() {
|
|
36
|
+
// B) Preflight 캐시: 5분 이내 확인했으면 프로브 스킵
|
|
37
|
+
const cached = readCache();
|
|
38
|
+
if (cached) return true;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetch(`http://127.0.0.1:${PROBE_PORT}/healthz`, {
|
|
42
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
|
43
|
+
});
|
|
44
|
+
if (res.ok) { writeCache(); return true; }
|
|
45
|
+
return false;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function startGateway() {
|
|
52
|
+
const script = join(PLUGIN_ROOT, 'scripts', 'mcp-gateway-start.mjs');
|
|
53
|
+
if (!existsSync(script)) {
|
|
54
|
+
process.stderr.write('[gateway-preflight] mcp-gateway-start.mjs not found\n');
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const { execSync } = await import('node:child_process');
|
|
59
|
+
try {
|
|
60
|
+
execSync(`node "${script}"`, {
|
|
61
|
+
stdio: 'ignore',
|
|
62
|
+
timeout: STARTUP_WAIT_MS + 2000,
|
|
63
|
+
cwd: PLUGIN_ROOT,
|
|
64
|
+
});
|
|
65
|
+
} catch {
|
|
66
|
+
// gateway-start는 자체 프로세스로 spawn하므로 parent는 바로 종료 가능
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 헬스체크 대기
|
|
70
|
+
const deadline = Date.now() + STARTUP_WAIT_MS;
|
|
71
|
+
while (Date.now() < deadline) {
|
|
72
|
+
if (await isGatewayAlive()) return true;
|
|
73
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── MCP stdio 프로토콜 (no-op server) ──
|
|
79
|
+
|
|
80
|
+
async function main() {
|
|
81
|
+
// 1) gateway 보장
|
|
82
|
+
const alive = await isGatewayAlive();
|
|
83
|
+
if (!alive) {
|
|
84
|
+
process.stderr.write('[gateway-preflight] gateway down, starting...\n');
|
|
85
|
+
const ok = await startGateway();
|
|
86
|
+
process.stderr.write(ok
|
|
87
|
+
? '[gateway-preflight] gateway started\n'
|
|
88
|
+
: '[gateway-preflight] gateway start failed\n');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 2) MCP JSON-RPC stdio — initialize 핸드셰이크만 처리하고 idle 유지
|
|
92
|
+
const { createInterface } = await import('node:readline');
|
|
93
|
+
const rl = createInterface({ input: process.stdin });
|
|
94
|
+
|
|
95
|
+
rl.on('line', (line) => {
|
|
96
|
+
try {
|
|
97
|
+
const msg = JSON.parse(line);
|
|
98
|
+
if (msg.method === 'initialize') {
|
|
99
|
+
const response = {
|
|
100
|
+
jsonrpc: '2.0',
|
|
101
|
+
id: msg.id,
|
|
102
|
+
result: {
|
|
103
|
+
protocolVersion: '2024-11-05',
|
|
104
|
+
capabilities: {},
|
|
105
|
+
serverInfo: { name: 'tfx-gateway-preflight', version: '1.0.0' },
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
process.stdout.write(JSON.stringify(response) + '\n');
|
|
109
|
+
} else if (msg.method === 'notifications/initialized') {
|
|
110
|
+
// ack, no response needed
|
|
111
|
+
} else if (msg.method === 'tools/list') {
|
|
112
|
+
process.stdout.write(JSON.stringify({
|
|
113
|
+
jsonrpc: '2.0',
|
|
114
|
+
id: msg.id,
|
|
115
|
+
result: { tools: [] },
|
|
116
|
+
}) + '\n');
|
|
117
|
+
} else if (msg.id !== undefined) {
|
|
118
|
+
// unknown method with id — respond empty
|
|
119
|
+
process.stdout.write(JSON.stringify({
|
|
120
|
+
jsonrpc: '2.0',
|
|
121
|
+
id: msg.id,
|
|
122
|
+
result: {},
|
|
123
|
+
}) + '\n');
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
// ignore parse errors
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
rl.on('close', () => process.exit(0));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
main();
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/codex-mcp-gateway-sync.mjs — Codex config.toml MCP를 gateway SSE로 전환
|
|
3
|
+
// Usage: node codex-mcp-gateway-sync.mjs [--enable|--disable|--status]
|
|
4
|
+
//
|
|
5
|
+
// 문제: Codex CLI가 매 호출마다 MCP 서버를 stdio로 spawn → 좀비 Node.js 프로세스
|
|
6
|
+
// 해결: mcp-gateway-start.mjs의 싱글톤 SSE 데몬을 재사용하도록 config.toml 전환
|
|
7
|
+
//
|
|
8
|
+
// before: [mcp_servers.context7]
|
|
9
|
+
// command = "npx"
|
|
10
|
+
// args = ["-y", "@upstash/context7-mcp@latest"]
|
|
11
|
+
//
|
|
12
|
+
// after: [mcp_servers.context7]
|
|
13
|
+
// url = "http://127.0.0.1:8100/sse"
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync, writeFileSync, copyFileSync, mkdirSync } from 'node:fs';
|
|
16
|
+
import { join, dirname } from 'node:path';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
import { SERVERS } from './mcp-gateway-start.mjs';
|
|
21
|
+
|
|
22
|
+
const CODEX_CONFIG = join(homedir(), '.codex', 'config.toml');
|
|
23
|
+
const BACKUP_SUFFIX = '.pre-gateway.bak';
|
|
24
|
+
|
|
25
|
+
// gateway 서버 → SSE URL 매핑
|
|
26
|
+
const GATEWAY_MAP = new Map(
|
|
27
|
+
SERVERS.map((s) => [s.name, `http://127.0.0.1:${s.port}/sse`]),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
// stdio 정의를 보존해야 하는 MCP 서버 (gateway 대상 아님)
|
|
31
|
+
const KEEP_STDIO = new Set([
|
|
32
|
+
'omx_state', 'omx_memory', 'omx_code_intel', 'omx_trace', 'omx_team_run',
|
|
33
|
+
'tfx-hub',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
function parseTomlMcpServers(content) {
|
|
37
|
+
const servers = new Map();
|
|
38
|
+
const re = /^\[mcp_servers\.([^\]]+)\]\s*$/gm;
|
|
39
|
+
let match;
|
|
40
|
+
|
|
41
|
+
while ((match = re.exec(content)) !== null) {
|
|
42
|
+
const name = match[1];
|
|
43
|
+
const startIdx = match.index + match[0].length;
|
|
44
|
+
|
|
45
|
+
// 다음 [section] 또는 파일 끝까지가 이 서버의 범위
|
|
46
|
+
const nextSection = content.indexOf('\n[', startIdx);
|
|
47
|
+
const block = content.slice(startIdx, nextSection === -1 ? undefined : nextSection).trim();
|
|
48
|
+
|
|
49
|
+
const hasUrl = /^url\s*=/m.test(block);
|
|
50
|
+
const hasCommand = /^command\s*=/m.test(block);
|
|
51
|
+
|
|
52
|
+
servers.set(name, { block, hasUrl, hasCommand, startIdx, headerIdx: match.index });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return servers;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function buildSseEntry(name, url) {
|
|
59
|
+
return `[mcp_servers.${name}]\nurl = "${url}"\n`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function buildStdioEntry(name, block) {
|
|
63
|
+
return `[mcp_servers.${name}]\n${block}\n`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── enable: stdio → SSE ──
|
|
67
|
+
|
|
68
|
+
export function enableGateway() {
|
|
69
|
+
if (!existsSync(CODEX_CONFIG)) {
|
|
70
|
+
console.log('[SKIP] ~/.codex/config.toml not found');
|
|
71
|
+
return { changed: 0, skipped: 0 };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const original = readFileSync(CODEX_CONFIG, 'utf8');
|
|
75
|
+
const servers = parseTomlMcpServers(original);
|
|
76
|
+
|
|
77
|
+
// 백업 (최초 1회만)
|
|
78
|
+
const backupPath = CODEX_CONFIG + BACKUP_SUFFIX;
|
|
79
|
+
if (!existsSync(backupPath)) {
|
|
80
|
+
copyFileSync(CODEX_CONFIG, backupPath);
|
|
81
|
+
console.log(`[BACKUP] ${backupPath}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let content = original;
|
|
85
|
+
let changed = 0;
|
|
86
|
+
let skipped = 0;
|
|
87
|
+
|
|
88
|
+
for (const [name, url] of GATEWAY_MAP) {
|
|
89
|
+
const srv = servers.get(name);
|
|
90
|
+
|
|
91
|
+
if (!srv) {
|
|
92
|
+
// 서버 미등록 → SSE entry 추가
|
|
93
|
+
content += `\n${buildSseEntry(name, url)}`;
|
|
94
|
+
changed++;
|
|
95
|
+
console.log(`[ADD] ${name} → ${url}`);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (srv.hasUrl) {
|
|
100
|
+
// 이미 URL 기반 → 스킵
|
|
101
|
+
skipped++;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (srv.hasCommand) {
|
|
106
|
+
// stdio → SSE 전환: 기존 블록을 URL로 교체
|
|
107
|
+
const oldSection = `[mcp_servers.${name}]\n${srv.block}`;
|
|
108
|
+
const newSection = buildSseEntry(name, url).trim();
|
|
109
|
+
content = content.replace(oldSection, newSection);
|
|
110
|
+
changed++;
|
|
111
|
+
console.log(`[CONVERT] ${name}: stdio → ${url}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// preflight MCP 서버 등록 — Codex 시작 시 gateway 자동 기동 보장
|
|
116
|
+
const preflightName = 'tfx-gateway-preflight';
|
|
117
|
+
if (!servers.has(preflightName)) {
|
|
118
|
+
const resolvedPath = join(dirname(fileURLToPath(import.meta.url)), 'codex-gateway-preflight.mjs')
|
|
119
|
+
.replace(/\\/g, '\\\\');
|
|
120
|
+
content += `\n[mcp_servers.${preflightName}]\ncommand = "node"\nargs = ["${resolvedPath}"]\nenabled = true\nstartup_timeout_sec = 10\n`;
|
|
121
|
+
changed++;
|
|
122
|
+
console.log(`[ADD] ${preflightName} — Codex 시작 시 gateway 자동 기동`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (changed > 0) {
|
|
126
|
+
writeFileSync(CODEX_CONFIG, content, 'utf8');
|
|
127
|
+
console.log(`\n[DONE] ${changed} servers converted, ${skipped} already SSE`);
|
|
128
|
+
} else {
|
|
129
|
+
console.log(`\n[DONE] No changes needed (${skipped} already SSE)`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { changed, skipped };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── disable: SSE → stdio 복원 ──
|
|
136
|
+
|
|
137
|
+
export function disableGateway() {
|
|
138
|
+
const backupPath = CODEX_CONFIG + BACKUP_SUFFIX;
|
|
139
|
+
if (!existsSync(backupPath)) {
|
|
140
|
+
console.log('[SKIP] No backup found — nothing to restore');
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
copyFileSync(backupPath, CODEX_CONFIG);
|
|
145
|
+
console.log('[RESTORE] config.toml restored from pre-gateway backup');
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── status: 현재 상태 확인 ──
|
|
150
|
+
|
|
151
|
+
export function getStatus() {
|
|
152
|
+
if (!existsSync(CODEX_CONFIG)) {
|
|
153
|
+
return { exists: false, servers: [] };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const content = readFileSync(CODEX_CONFIG, 'utf8');
|
|
157
|
+
const servers = parseTomlMcpServers(content);
|
|
158
|
+
const result = [];
|
|
159
|
+
|
|
160
|
+
for (const [name, url] of GATEWAY_MAP) {
|
|
161
|
+
const srv = servers.get(name);
|
|
162
|
+
if (!srv) {
|
|
163
|
+
result.push({ name, mode: 'missing', url });
|
|
164
|
+
} else if (srv.hasUrl) {
|
|
165
|
+
result.push({ name, mode: 'sse', url });
|
|
166
|
+
} else {
|
|
167
|
+
result.push({ name, mode: 'stdio', url });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { exists: true, servers: result };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── CLI ──
|
|
175
|
+
|
|
176
|
+
const arg = process.argv[2];
|
|
177
|
+
|
|
178
|
+
if (arg === '--enable') {
|
|
179
|
+
enableGateway();
|
|
180
|
+
} else if (arg === '--disable') {
|
|
181
|
+
disableGateway();
|
|
182
|
+
} else if (arg === '--status') {
|
|
183
|
+
const { exists, servers } = getStatus();
|
|
184
|
+
if (!exists) {
|
|
185
|
+
console.log('config.toml not found');
|
|
186
|
+
process.exit(1);
|
|
187
|
+
}
|
|
188
|
+
console.log('\nCodex MCP Gateway Status:');
|
|
189
|
+
console.log('─'.repeat(50));
|
|
190
|
+
for (const s of servers) {
|
|
191
|
+
const icon = s.mode === 'sse' ? '✅' : s.mode === 'stdio' ? '⚠️' : '❌';
|
|
192
|
+
console.log(`${icon} ${s.name.padEnd(15)} ${s.mode.padEnd(8)} ${s.mode === 'stdio' ? '← zombie risk' : ''}`);
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
console.log('Usage: codex-mcp-gateway-sync.mjs [--enable|--disable|--status]');
|
|
196
|
+
console.log(' --enable Convert stdio MCP servers to SSE gateway URLs');
|
|
197
|
+
console.log(' --disable Restore original stdio config from backup');
|
|
198
|
+
console.log(' --status Show current MCP connection mode per server');
|
|
199
|
+
}
|
|
@@ -1,122 +1,222 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: star-prompt
|
|
3
3
|
description: >-
|
|
4
|
-
CLI 프로젝트의 setup/postinstall 흐름에 GitHub 스타
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
'
|
|
4
|
+
CLI 프로젝트의 setup/postinstall 흐름에 GitHub 스타 요청을 추가한다.
|
|
5
|
+
기본: 모달 차단형 (AskUserQuestion). --soft: 부드러운 confirm 모드.
|
|
6
|
+
gh CLI 인증 확인 → 이미 스타 여부 감지 → 선택 강제 → gh API로 자동 스타.
|
|
7
|
+
'star prompt', '스타 요청', '리포 스타', 'star request', '깃헙 스타 넣어줘',
|
|
8
|
+
'star 눌러달라고', '응원 요청' 같은 요청에 사용한다.
|
|
8
9
|
---
|
|
9
10
|
|
|
10
11
|
# tfx-star-prompt — GitHub Star Request Prompt
|
|
11
12
|
|
|
12
|
-
CLI 도구의 setup 완료 시점에 GitHub 리포
|
|
13
|
-
|
|
13
|
+
CLI 도구의 setup/postinstall 완료 시점에 GitHub 리포 스타 요청을 추가한다.
|
|
14
|
+
기본 모드는 aggressive(모달 차단형)이며, `--soft`를 전달하면 기존 부드러운 confirm 모드로 폴백한다.
|
|
15
|
+
CI/비인터랙티브 환경에서는 자동으로 soft 모드로 강등한다.
|
|
14
16
|
|
|
15
17
|
## 동작 흐름
|
|
16
18
|
|
|
17
19
|
```
|
|
18
|
-
|
|
20
|
+
detectInteractive() ─── false → soft 모드 강제
|
|
21
|
+
│
|
|
22
|
+
✓ true
|
|
23
|
+
│
|
|
24
|
+
gh --version ─── 실패 → URL만 표시
|
|
25
|
+
│
|
|
26
|
+
✓ 설치됨
|
|
27
|
+
│
|
|
28
|
+
gh auth status ─── 실패 → URL만 표시
|
|
19
29
|
│
|
|
20
30
|
✓ 인증됨
|
|
21
31
|
│
|
|
22
|
-
gh api user/starred/{owner}/{repo}
|
|
32
|
+
gh api user/starred/{owner}/{repo}
|
|
33
|
+
├─ 성공 → "이미 함께하고 계시군요. ⭐" + markPrompted()
|
|
34
|
+
├─ 404 → 미스타로 진행
|
|
35
|
+
└─ 그 외 에러 → 프롬프트 없이 URL만 표시 (마커 남기지 않음)
|
|
23
36
|
│
|
|
24
37
|
✗ 미스타
|
|
25
38
|
│
|
|
26
|
-
|
|
39
|
+
이미 프롬프트 본 유저(마커 존재)면 즉시 스킵
|
|
40
|
+
│
|
|
41
|
+
aggressive 기본: AskUserQuestion([예, 누를게요] / [아니오]) 블로킹 선택
|
|
42
|
+
soft(--soft): confirm("⭐ 하나가 큰 차이를 만듭니다.")
|
|
43
|
+
│
|
|
44
|
+
├─ 아니오 → aggressive: 안내 + URL / soft: URL만 + markPrompted()
|
|
45
|
+
└─ 예
|
|
27
46
|
│
|
|
28
47
|
Y
|
|
29
48
|
│
|
|
30
|
-
gh api -X PUT /user/starred/{owner}/{repo}
|
|
49
|
+
gh api -X PUT /user/starred/{owner}/{repo}
|
|
50
|
+
├─ 성공 → aggressive: "감사합니다! 여러분의 ⭐가 프로젝트를 성장시킵니다."
|
|
51
|
+
│ soft: "함께해 주셔서 감사합니다. ⭐"
|
|
52
|
+
└─ 실패 → URL 폴백
|
|
31
53
|
│
|
|
32
|
-
|
|
54
|
+
모든 프롬프트 완료 경로는 markPrompted() 호출
|
|
33
55
|
```
|
|
34
56
|
|
|
35
57
|
## 구현 패턴
|
|
36
58
|
|
|
37
|
-
###
|
|
59
|
+
### 유틸리티 계약
|
|
60
|
+
|
|
61
|
+
- `ok(message)`: 성공 메시지 출력(초록/강조 톤)
|
|
62
|
+
- `info(message)`: 일반 안내 메시지 출력
|
|
63
|
+
- `warn(message)`: 경고/실패 폴백 메시지 출력
|
|
64
|
+
- `confirm(message, defaultValue)`: soft 모드용 Y/n 확인
|
|
65
|
+
- `askUserQuestion({ question, options })`: aggressive 모달 선택 UI
|
|
66
|
+
- 옵션은 정확히 `[예, 누를게요]`, `[아니오]`
|
|
67
|
+
- 선택 전까지 흐름을 블로킹한다
|
|
68
|
+
|
|
69
|
+
### 전체 `starRequest` 교체 패턴
|
|
38
70
|
|
|
39
|
-
|
|
71
|
+
아래 패턴으로 기존 `starRequest` 함수를 전면 교체한다.
|
|
40
72
|
|
|
41
73
|
```javascript
|
|
42
|
-
|
|
43
|
-
|
|
74
|
+
import fs from "node:fs";
|
|
75
|
+
import os from "node:os";
|
|
76
|
+
import path from "node:path";
|
|
77
|
+
import { execFileSync } from "node:child_process";
|
|
78
|
+
|
|
79
|
+
function detectInteractive() {
|
|
80
|
+
if (!process.stdout.isTTY) return false;
|
|
81
|
+
if (process.env.CI) return false;
|
|
82
|
+
if (process.env.TERM === "dumb") return false;
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function runGh(args) {
|
|
87
|
+
return execFileSync("gh", args, {
|
|
88
|
+
timeout: 10000,
|
|
89
|
+
encoding: "utf8",
|
|
90
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getHttpStatus(error) {
|
|
95
|
+
const out = [error?.stdout, error?.stderr].filter(Boolean).join("\n");
|
|
96
|
+
const match = out.match(/HTTP\s+(\d{3})/i) || out.match(/\b(\d{3})\b/);
|
|
97
|
+
return match ? Number(match[1]) : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function starRequest({
|
|
101
|
+
owner,
|
|
102
|
+
repo,
|
|
103
|
+
soft = false,
|
|
104
|
+
askUserQuestion,
|
|
105
|
+
confirm,
|
|
106
|
+
ok,
|
|
107
|
+
info,
|
|
108
|
+
warn,
|
|
109
|
+
}) {
|
|
110
|
+
const repoUrl = `https://github.com/${owner}/${repo}`;
|
|
111
|
+
const interactive = detectInteractive();
|
|
112
|
+
const useSoft = soft || !interactive;
|
|
113
|
+
|
|
114
|
+
const MARKER_DIR = path.join(os.homedir(), ".config", "star-prompt");
|
|
115
|
+
const MARKER = path.join(MARKER_DIR, `${owner}-${repo}.prompted`);
|
|
116
|
+
|
|
117
|
+
const markPrompted = () => {
|
|
118
|
+
fs.mkdirSync(MARKER_DIR, { recursive: true });
|
|
119
|
+
fs.writeFileSync(MARKER, new Date().toISOString(), "utf8");
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
if (fs.existsSync(MARKER)) return;
|
|
123
|
+
|
|
44
124
|
try {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
125
|
+
runGh(["--version"]);
|
|
126
|
+
} catch {
|
|
127
|
+
info(repoUrl);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
50
130
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
131
|
+
try {
|
|
132
|
+
runGh(["auth", "status"]);
|
|
133
|
+
} catch {
|
|
134
|
+
info(repoUrl);
|
|
54
135
|
return;
|
|
55
136
|
}
|
|
56
137
|
|
|
57
138
|
let alreadyStarred = false;
|
|
58
139
|
try {
|
|
59
|
-
|
|
60
|
-
timeout: 5000, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"],
|
|
61
|
-
});
|
|
140
|
+
runGh(["api", `user/starred/${owner}/${repo}`]);
|
|
62
141
|
alreadyStarred = true;
|
|
63
|
-
} catch {
|
|
142
|
+
} catch (error) {
|
|
143
|
+
const status = getHttpStatus(error);
|
|
144
|
+
if (status === 404) {
|
|
145
|
+
alreadyStarred = false;
|
|
146
|
+
} else {
|
|
147
|
+
// API 에러(404 외): 프롬프트 없이 URL만 출력, 마커 미기록
|
|
148
|
+
warn(repoUrl);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
64
152
|
|
|
65
153
|
if (alreadyStarred) {
|
|
66
|
-
ok(
|
|
154
|
+
ok("이미 함께하고 계시군요. ⭐");
|
|
155
|
+
markPrompted();
|
|
67
156
|
return;
|
|
68
157
|
}
|
|
69
158
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
|
|
74
|
-
});
|
|
75
|
-
ok(`함께해 주셔서 감사합니다. ⭐`);
|
|
76
|
-
} catch {
|
|
77
|
-
info(`https://github.com/{owner}/{repo}`);
|
|
78
|
-
}
|
|
159
|
+
let accepted = false;
|
|
160
|
+
if (useSoft) {
|
|
161
|
+
accepted = await confirm("⭐ 하나가 큰 차이를 만듭니다.", true);
|
|
79
162
|
} else {
|
|
80
|
-
|
|
81
|
-
|
|
163
|
+
const answer = await askUserQuestion({
|
|
164
|
+
question: "⭐ 이 프로젝트가 마음에 드셨나요? 스타를 누르시겠습니까?",
|
|
165
|
+
options: ["예, 누를게요", "아니오"],
|
|
166
|
+
});
|
|
167
|
+
accepted = answer === "예, 누를게요";
|
|
82
168
|
}
|
|
83
|
-
}
|
|
84
|
-
```
|
|
85
169
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
170
|
+
if (!accepted) {
|
|
171
|
+
if (useSoft) {
|
|
172
|
+
info(repoUrl);
|
|
173
|
+
} else {
|
|
174
|
+
info(`괜찮습니다. 나중에 마음이 바뀌시면: ${repoUrl}`);
|
|
175
|
+
}
|
|
176
|
+
markPrompted();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
89
179
|
|
|
90
|
-
```javascript
|
|
91
|
-
try {
|
|
92
|
-
execFileSync("gh", ["auth", "status"], { timeout: 5000, stdio: ["pipe","pipe","pipe"] });
|
|
93
180
|
try {
|
|
94
|
-
|
|
95
|
-
|
|
181
|
+
runGh(["api", "-X", "PUT", `/user/starred/${owner}/${repo}`]);
|
|
182
|
+
if (useSoft) {
|
|
183
|
+
ok("함께해 주셔서 감사합니다. ⭐");
|
|
184
|
+
} else {
|
|
185
|
+
ok("감사합니다! 여러분의 ⭐가 프로젝트를 성장시킵니다.");
|
|
186
|
+
}
|
|
96
187
|
} catch {
|
|
97
|
-
|
|
188
|
+
warn(repoUrl);
|
|
189
|
+
} finally {
|
|
190
|
+
markPrompted();
|
|
98
191
|
}
|
|
99
|
-
} catch {
|
|
100
|
-
info(`⭐ 하나가 큰 차이를 만듭니다. https://github.com/{owner}/{repo}`);
|
|
101
192
|
}
|
|
102
193
|
```
|
|
103
194
|
|
|
104
195
|
## 적용 시 규칙
|
|
105
196
|
|
|
106
|
-
1. `
|
|
107
|
-
2.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
|
122
|
-
|
|
197
|
+
1. 기본 모드는 aggressive이며 `--soft` 전달 시에만 soft 모드로 전환한다.
|
|
198
|
+
2. CI/비인터랙티브 감지는 아래 조건으로 강제 soft 폴백한다.
|
|
199
|
+
- `!process.stdout.isTTY || process.env.CI || process.env.TERM === "dumb"`
|
|
200
|
+
3. gh 설치 확인은 `which` 대신 `gh --version`으로 수행한다 (크로스플랫폼).
|
|
201
|
+
4. gh 인증 확인은 `gh auth status`로 수행한다.
|
|
202
|
+
5. 스타 여부 체크는 `gh api user/starred/{owner}/{repo}`를 사용하고, `404`만 미스타로 판단한다.
|
|
203
|
+
6. 스타 체크 API 에러가 `404` 외 상태면 프롬프트를 띄우지 않고 URL만 출력하며 마커를 남기지 않는다.
|
|
204
|
+
7. 중복 요청 방지를 위해 아래 마커를 사용한다.
|
|
205
|
+
- `~/.config/star-prompt/{owner}-{repo}.prompted`
|
|
206
|
+
- 이미 스타한 유저도 `markPrompted()`를 호출한다.
|
|
207
|
+
8. 모든 gh 호출은 `timeout: 10000`, `stdio: ["pipe","pipe","pipe"]`를 사용한다.
|
|
208
|
+
9. setup/postinstall의 핵심 성공/실패 처리 흐름을 막지 않도록, 스타 로직 실패는 모두 조용히 폴백한다.
|
|
209
|
+
|
|
210
|
+
## UX 카피 테이블
|
|
211
|
+
|
|
212
|
+
| 상황 | Aggressive (기본) | Soft (`--soft`) |
|
|
213
|
+
|------|-------------------|-----------------|
|
|
214
|
+
| 이미 스타 | `이미 함께하고 계시군요. ⭐` | (동일) |
|
|
215
|
+
| 요청 | `AskUserQuestion: ⭐ 이 프로젝트가 마음에 드셨나요? 스타를 누르시겠습니까?` | `⭐ 하나가 큰 차이를 만듭니다.` |
|
|
216
|
+
| 수락 후 | `감사합니다! 여러분의 ⭐가 프로젝트를 성장시킵니다.` | `함께해 주셔서 감사합니다. ⭐` |
|
|
217
|
+
| 거절 | `괜찮습니다. 나중에 마음이 바뀌시면:` + URL | URL만 |
|
|
218
|
+
| gh 미설치 | URL만 표시 | (동일) |
|
|
219
|
+
| gh 미인증 | URL만 표시 | (동일) |
|
|
220
|
+
| CI/비인터랙티브 | soft 자동 폴백 | (동일) |
|
|
221
|
+
| API 에러 (404 외) | 프롬프트 없이 URL만 표시, 마커 안 남김 | (동일) |
|
|
222
|
+
| 이미 프롬프트 본 유저 | 스킵 | (동일) |
|