@pollit/twin-dev-bot 0.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/LICENSE +661 -0
- package/README.md +415 -0
- package/bin/twindevbot.js +22 -0
- package/dist/action-payload-store.d.ts +22 -0
- package/dist/action-payload-store.js +54 -0
- package/dist/active-runners.d.ts +44 -0
- package/dist/active-runners.js +114 -0
- package/dist/channel-store.d.ts +16 -0
- package/dist/channel-store.js +91 -0
- package/dist/claude/active-runners.d.ts +44 -0
- package/dist/claude/active-runners.js +114 -0
- package/dist/claude/claude-runner.d.ts +57 -0
- package/dist/claude/claude-runner.js +210 -0
- package/dist/claude/session-manager.d.ts +62 -0
- package/dist/claude/session-manager.js +247 -0
- package/dist/claude-runner.d.ts +57 -0
- package/dist/claude-runner.js +210 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +271 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +49 -0
- package/dist/conversation-store.d.ts +53 -0
- package/dist/conversation-store.js +173 -0
- package/dist/core/config.d.ts +9 -0
- package/dist/core/config.js +49 -0
- package/dist/core/logger.d.ts +34 -0
- package/dist/core/logger.js +110 -0
- package/dist/core/paths.d.ts +11 -0
- package/dist/core/paths.js +18 -0
- package/dist/core/platform.d.ts +18 -0
- package/dist/core/platform.js +33 -0
- package/dist/daemon/index.d.ts +3 -0
- package/dist/daemon/index.js +14 -0
- package/dist/daemon/macos.d.ts +8 -0
- package/dist/daemon/macos.js +150 -0
- package/dist/daemon/types.d.ts +9 -0
- package/dist/daemon/types.js +1 -0
- package/dist/daemon/windows.d.ts +8 -0
- package/dist/daemon/windows.js +137 -0
- package/dist/handlers/claude-command.d.ts +2 -0
- package/dist/handlers/claude-command.js +634 -0
- package/dist/handlers/claude-runner-setup.d.ts +16 -0
- package/dist/handlers/claude-runner-setup.js +445 -0
- package/dist/handlers/index.d.ts +3 -0
- package/dist/handlers/index.js +3 -0
- package/dist/handlers/init-handlers.d.ts +2 -0
- package/dist/handlers/init-handlers.js +189 -0
- package/dist/handlers/question-handlers.d.ts +2 -0
- package/dist/handlers/question-handlers.js +835 -0
- package/dist/i18n/en.d.ts +150 -0
- package/dist/i18n/en.js +163 -0
- package/dist/i18n/index.d.ts +20 -0
- package/dist/i18n/index.js +31 -0
- package/dist/i18n/ko.d.ts +1 -0
- package/dist/i18n/ko.js +141 -0
- package/dist/logger.d.ts +34 -0
- package/dist/logger.js +110 -0
- package/dist/multi-select-state.d.ts +58 -0
- package/dist/multi-select-state.js +151 -0
- package/dist/paths.d.ts +11 -0
- package/dist/paths.js +18 -0
- package/dist/pending-questions.d.ts +53 -0
- package/dist/pending-questions.js +139 -0
- package/dist/platform.d.ts +18 -0
- package/dist/platform.js +33 -0
- package/dist/progress-tracker.d.ts +47 -0
- package/dist/progress-tracker.js +218 -0
- package/dist/question-blocks.d.ts +27 -0
- package/dist/question-blocks.js +235 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +83 -0
- package/dist/session-manager.d.ts +62 -0
- package/dist/session-manager.js +247 -0
- package/dist/setup.d.ts +5 -0
- package/dist/setup.js +132 -0
- package/dist/slack/progress-tracker.d.ts +47 -0
- package/dist/slack/progress-tracker.js +218 -0
- package/dist/slack/question-blocks.d.ts +27 -0
- package/dist/slack/question-blocks.js +235 -0
- package/dist/stores/action-payload-store.d.ts +22 -0
- package/dist/stores/action-payload-store.js +54 -0
- package/dist/stores/channel-store.d.ts +16 -0
- package/dist/stores/channel-store.js +91 -0
- package/dist/stores/multi-select-state.d.ts +58 -0
- package/dist/stores/multi-select-state.js +151 -0
- package/dist/stores/pending-questions.d.ts +53 -0
- package/dist/stores/pending-questions.js +139 -0
- package/dist/stores/workspace-store.d.ts +27 -0
- package/dist/stores/workspace-store.js +160 -0
- package/dist/templates.d.ts +23 -0
- package/dist/templates.js +292 -0
- package/dist/types/claude-stream.d.ts +116 -0
- package/dist/types/claude-stream.js +3 -0
- package/dist/types/conversation.d.ts +16 -0
- package/dist/types/conversation.js +4 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.js +2 -0
- package/dist/types/slack.d.ts +51 -0
- package/dist/types/slack.js +1 -0
- package/dist/utils/display-width.d.ts +8 -0
- package/dist/utils/display-width.js +33 -0
- package/dist/utils/safe-async.d.ts +6 -0
- package/dist/utils/safe-async.js +14 -0
- package/dist/utils/slack-message.d.ts +73 -0
- package/dist/utils/slack-message.js +220 -0
- package/dist/utils/slack-rate-limit.d.ts +5 -0
- package/dist/utils/slack-rate-limit.js +49 -0
- package/dist/workspace-store.d.ts +27 -0
- package/dist/workspace-store.js +160 -0
- package/package.json +51 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 복수 질문 배치 관리
|
|
3
|
+
*
|
|
4
|
+
* Claude의 AskUserQuestion이 여러 질문을 동시에 보낼 때,
|
|
5
|
+
* Slack에서는 한 번에 하나씩 표시하고 모든 답변을 수집한 후
|
|
6
|
+
* 조합하여 Claude에 전달합니다.
|
|
7
|
+
*
|
|
8
|
+
* 키: threadTs (스레드당 하나의 배치)
|
|
9
|
+
*/
|
|
10
|
+
import { createLogger } from "../core/logger.js";
|
|
11
|
+
const log = createLogger("pending-questions");
|
|
12
|
+
const batchMap = new Map();
|
|
13
|
+
// 1시간이 지난 배치는 자동 삭제 (정상적으로는 오래 걸리지 않음)
|
|
14
|
+
const MAX_AGE_MS = 60 * 60 * 1000;
|
|
15
|
+
let cleanupInterval = null;
|
|
16
|
+
/**
|
|
17
|
+
* 배치 초기화 (questions.length > 1일 때 호출)
|
|
18
|
+
*/
|
|
19
|
+
export function initPendingBatch(threadTs, questions, projectName, channelId) {
|
|
20
|
+
batchMap.set(threadTs, {
|
|
21
|
+
questions,
|
|
22
|
+
answers: [],
|
|
23
|
+
currentIndex: 0,
|
|
24
|
+
projectName,
|
|
25
|
+
channelId,
|
|
26
|
+
createdAt: Date.now(),
|
|
27
|
+
});
|
|
28
|
+
log.info("Pending batch initialized", { threadTs, questionCount: questions.length });
|
|
29
|
+
// 타이머가 없으면 시작
|
|
30
|
+
ensureCleanupTimer();
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 대기 중인 배치 존재 여부
|
|
34
|
+
*/
|
|
35
|
+
export function hasPendingBatch(threadTs) {
|
|
36
|
+
return batchMap.has(threadTs);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 답변 기록 후 다음 질문 반환.
|
|
40
|
+
* done=true이면 모든 질문에 답변 완료.
|
|
41
|
+
*/
|
|
42
|
+
export function recordAnswerAndAdvance(threadTs, answer) {
|
|
43
|
+
const batch = batchMap.get(threadTs);
|
|
44
|
+
if (!batch)
|
|
45
|
+
return null;
|
|
46
|
+
batch.answers.push(answer);
|
|
47
|
+
batch.currentIndex++;
|
|
48
|
+
if (batch.currentIndex >= batch.questions.length) {
|
|
49
|
+
return { done: true, batch };
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
done: false,
|
|
53
|
+
nextQuestion: batch.questions[batch.currentIndex],
|
|
54
|
+
batch,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 모든 답변을 조합하여 Claude resume용 문자열 생성.
|
|
59
|
+
* 형식: "[header]: answer" (줄바꿈 구분)
|
|
60
|
+
* header가 없으면 질문 텍스트 앞 50자 사용.
|
|
61
|
+
*/
|
|
62
|
+
export function buildCombinedAnswer(threadTs) {
|
|
63
|
+
const batch = batchMap.get(threadTs);
|
|
64
|
+
if (!batch)
|
|
65
|
+
return null;
|
|
66
|
+
if (batch.questions.length === 1) {
|
|
67
|
+
return batch.answers[0] ?? null;
|
|
68
|
+
}
|
|
69
|
+
return batch.questions
|
|
70
|
+
.map((q, i) => {
|
|
71
|
+
const label = q.header || q.question.slice(0, 50);
|
|
72
|
+
const answer = batch.answers[i] ?? "";
|
|
73
|
+
return `[${label}]: ${answer}`;
|
|
74
|
+
})
|
|
75
|
+
.join("\n");
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* 배치 정리
|
|
79
|
+
*/
|
|
80
|
+
export function clearPendingBatch(threadTs) {
|
|
81
|
+
batchMap.delete(threadTs);
|
|
82
|
+
log.debug("Pending batch cleared", { threadTs });
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* 오래된 배치 정리
|
|
86
|
+
*/
|
|
87
|
+
function cleanup() {
|
|
88
|
+
const now = Date.now();
|
|
89
|
+
let cleanedCount = 0;
|
|
90
|
+
for (const [threadTs, batch] of batchMap.entries()) {
|
|
91
|
+
const ageMs = now - batch.createdAt;
|
|
92
|
+
if (ageMs > MAX_AGE_MS) {
|
|
93
|
+
batchMap.delete(threadTs);
|
|
94
|
+
cleanedCount++;
|
|
95
|
+
log.info("Pending batch expired", {
|
|
96
|
+
threadTs,
|
|
97
|
+
projectName: batch.projectName,
|
|
98
|
+
ageMinutes: Math.floor(ageMs / (60 * 1000)),
|
|
99
|
+
answeredCount: batch.answers.length,
|
|
100
|
+
totalCount: batch.questions.length,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (cleanedCount > 0) {
|
|
105
|
+
log.info("Pending batch cleanup completed", { cleanedCount, remaining: batchMap.size });
|
|
106
|
+
}
|
|
107
|
+
return cleanedCount;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* 정리 타이머 시작
|
|
111
|
+
*/
|
|
112
|
+
function ensureCleanupTimer() {
|
|
113
|
+
if (cleanupInterval)
|
|
114
|
+
return;
|
|
115
|
+
// 즉시 한 번 실행
|
|
116
|
+
cleanup();
|
|
117
|
+
// 매 시간마다 실행
|
|
118
|
+
cleanupInterval = setInterval(() => {
|
|
119
|
+
cleanup();
|
|
120
|
+
}, 60 * 60 * 1000);
|
|
121
|
+
cleanupInterval.unref();
|
|
122
|
+
log.debug("Pending batch cleanup timer started (runs every hour)");
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* 정리 타이머 중지 (주로 테스트용)
|
|
126
|
+
*/
|
|
127
|
+
export function stopCleanupTimer() {
|
|
128
|
+
if (cleanupInterval) {
|
|
129
|
+
clearInterval(cleanupInterval);
|
|
130
|
+
cleanupInterval = null;
|
|
131
|
+
log.debug("Pending batch cleanup timer stopped");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* 테스트용: cleanup 수동 실행
|
|
136
|
+
*/
|
|
137
|
+
export function runCleanup() {
|
|
138
|
+
return cleanup();
|
|
139
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace Store
|
|
3
|
+
*
|
|
4
|
+
* /twindevbot goto 로 생성된 스레드와 작업 디렉토리의 매핑을 관리합니다.
|
|
5
|
+
* 스레드에 첫 메시지가 오면 이 매핑을 사용하여 Claude 세션을 시작합니다.
|
|
6
|
+
*
|
|
7
|
+
* 키: threadTs (Slack 스레드 부모 메시지 타임스탬프)
|
|
8
|
+
* 값: { directory, projectName, channelId }
|
|
9
|
+
*/
|
|
10
|
+
export interface Workspace {
|
|
11
|
+
directory: string;
|
|
12
|
+
projectName: string;
|
|
13
|
+
channelId: string;
|
|
14
|
+
autopilot?: boolean;
|
|
15
|
+
createdAt?: Date;
|
|
16
|
+
}
|
|
17
|
+
export declare function addWorkspace(threadTs: string, workspace: Workspace): void;
|
|
18
|
+
export declare function getWorkspace(threadTs: string): Workspace | undefined;
|
|
19
|
+
export declare function removeWorkspace(threadTs: string): void;
|
|
20
|
+
/**
|
|
21
|
+
* 정리 타이머 중지 (주로 테스트용)
|
|
22
|
+
*/
|
|
23
|
+
export declare function stopCleanupTimer(): void;
|
|
24
|
+
/**
|
|
25
|
+
* 테스트용: cleanup 수동 실행
|
|
26
|
+
*/
|
|
27
|
+
export declare function runCleanup(): number;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace Store
|
|
3
|
+
*
|
|
4
|
+
* /twindevbot goto 로 생성된 스레드와 작업 디렉토리의 매핑을 관리합니다.
|
|
5
|
+
* 스레드에 첫 메시지가 오면 이 매핑을 사용하여 Claude 세션을 시작합니다.
|
|
6
|
+
*
|
|
7
|
+
* 키: threadTs (Slack 스레드 부모 메시지 타임스탬프)
|
|
8
|
+
* 값: { directory, projectName, channelId }
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync, renameSync } from "fs";
|
|
11
|
+
import { createLogger } from "../core/logger.js";
|
|
12
|
+
import { WORKSPACES_FILE } from "../core/paths.js";
|
|
13
|
+
const log = createLogger("workspace-store");
|
|
14
|
+
const workspaces = new Map();
|
|
15
|
+
let cleanupInterval = null;
|
|
16
|
+
// 24시간이 지난 워크스페이스는 자동 삭제
|
|
17
|
+
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
18
|
+
function loadFromFile() {
|
|
19
|
+
try {
|
|
20
|
+
if (!existsSync(WORKSPACES_FILE)) {
|
|
21
|
+
log.info("No workspaces file found, starting fresh");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const content = readFileSync(WORKSPACES_FILE, "utf-8");
|
|
25
|
+
let data;
|
|
26
|
+
try {
|
|
27
|
+
data = JSON.parse(content);
|
|
28
|
+
}
|
|
29
|
+
catch (parseError) {
|
|
30
|
+
log.error("Workspaces file is corrupted, starting fresh", { parseError });
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (!data.workspaces || !Array.isArray(data.workspaces)) {
|
|
34
|
+
log.error("Workspaces file has invalid structure, starting fresh");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
let loadedCount = 0;
|
|
38
|
+
for (const w of data.workspaces) {
|
|
39
|
+
try {
|
|
40
|
+
workspaces.set(w.threadTs, {
|
|
41
|
+
directory: w.directory,
|
|
42
|
+
projectName: w.projectName,
|
|
43
|
+
channelId: w.channelId,
|
|
44
|
+
autopilot: w.autopilot,
|
|
45
|
+
createdAt: w.createdAt ? new Date(w.createdAt) : new Date(),
|
|
46
|
+
});
|
|
47
|
+
loadedCount++;
|
|
48
|
+
}
|
|
49
|
+
catch (entryError) {
|
|
50
|
+
log.warn("Skipping invalid workspace entry", { entry: w, error: entryError });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
log.info("Workspaces loaded from file", { count: loadedCount, total: data.workspaces.length });
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
log.error("Failed to load workspaces from file", { error });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function saveToFile() {
|
|
60
|
+
try {
|
|
61
|
+
const serialized = Array.from(workspaces.entries()).map(([threadTs, w]) => ({
|
|
62
|
+
threadTs,
|
|
63
|
+
directory: w.directory,
|
|
64
|
+
projectName: w.projectName,
|
|
65
|
+
channelId: w.channelId,
|
|
66
|
+
autopilot: w.autopilot ?? undefined,
|
|
67
|
+
createdAt: w.createdAt?.toISOString(),
|
|
68
|
+
}));
|
|
69
|
+
const data = {
|
|
70
|
+
version: 1,
|
|
71
|
+
workspaces: serialized,
|
|
72
|
+
};
|
|
73
|
+
const tmpFile = WORKSPACES_FILE + ".tmp";
|
|
74
|
+
writeFileSync(tmpFile, JSON.stringify(data, null, 2));
|
|
75
|
+
renameSync(tmpFile, WORKSPACES_FILE);
|
|
76
|
+
log.debug("Workspaces saved to file", { count: serialized.length });
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
log.error("Failed to save workspaces to file", { error });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// 모듈 로드 시 파일에서 복원
|
|
83
|
+
loadFromFile();
|
|
84
|
+
// 정리 타이머 시작
|
|
85
|
+
function startCleanupTimer() {
|
|
86
|
+
if (cleanupInterval)
|
|
87
|
+
return;
|
|
88
|
+
// 매 시간마다 실행 (첫 실행은 1시간 후)
|
|
89
|
+
cleanupInterval = setInterval(() => {
|
|
90
|
+
cleanup();
|
|
91
|
+
}, 60 * 60 * 1000);
|
|
92
|
+
cleanupInterval.unref();
|
|
93
|
+
log.debug("Workspace cleanup timer started (runs every hour)");
|
|
94
|
+
}
|
|
95
|
+
startCleanupTimer();
|
|
96
|
+
export function addWorkspace(threadTs, workspace) {
|
|
97
|
+
// createdAt이 없으면 현재 시간으로 설정
|
|
98
|
+
if (!workspace.createdAt) {
|
|
99
|
+
workspace.createdAt = new Date();
|
|
100
|
+
}
|
|
101
|
+
workspaces.set(threadTs, workspace);
|
|
102
|
+
log.info("Workspace registered", {
|
|
103
|
+
threadTs,
|
|
104
|
+
projectName: workspace.projectName,
|
|
105
|
+
directory: workspace.directory,
|
|
106
|
+
});
|
|
107
|
+
saveToFile();
|
|
108
|
+
}
|
|
109
|
+
export function getWorkspace(threadTs) {
|
|
110
|
+
return workspaces.get(threadTs);
|
|
111
|
+
}
|
|
112
|
+
export function removeWorkspace(threadTs) {
|
|
113
|
+
workspaces.delete(threadTs);
|
|
114
|
+
log.debug("Workspace removed", { threadTs });
|
|
115
|
+
saveToFile();
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* 오래된 워크스페이스 정리 (createdAt 기준)
|
|
119
|
+
*/
|
|
120
|
+
function cleanup() {
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
let cleanedCount = 0;
|
|
123
|
+
for (const [threadTs, workspace] of workspaces.entries()) {
|
|
124
|
+
// createdAt이 없는 항목은 건너뜀 (레거시 데이터)
|
|
125
|
+
if (!workspace.createdAt) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const ageMs = now - workspace.createdAt.getTime();
|
|
129
|
+
if (ageMs > MAX_AGE_MS) {
|
|
130
|
+
workspaces.delete(threadTs);
|
|
131
|
+
cleanedCount++;
|
|
132
|
+
log.info("Workspace expired", {
|
|
133
|
+
threadTs,
|
|
134
|
+
projectName: workspace.projectName,
|
|
135
|
+
ageDays: Math.floor(ageMs / (24 * 60 * 60 * 1000)),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (cleanedCount > 0) {
|
|
140
|
+
saveToFile();
|
|
141
|
+
log.info("Workspace cleanup completed", { cleanedCount, remaining: workspaces.size });
|
|
142
|
+
}
|
|
143
|
+
return cleanedCount;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* 정리 타이머 중지 (주로 테스트용)
|
|
147
|
+
*/
|
|
148
|
+
export function stopCleanupTimer() {
|
|
149
|
+
if (cleanupInterval) {
|
|
150
|
+
clearInterval(cleanupInterval);
|
|
151
|
+
cleanupInterval = null;
|
|
152
|
+
log.debug("Workspace cleanup timer stopped");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* 테스트용: cleanup 수동 실행
|
|
157
|
+
*/
|
|
158
|
+
export function runCleanup() {
|
|
159
|
+
return cleanup();
|
|
160
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface FrameworkTemplate {
|
|
2
|
+
name: string;
|
|
3
|
+
category: "frontend" | "backend";
|
|
4
|
+
/**
|
|
5
|
+
* scaffold 명령어 또는 Node.js 함수를 반환.
|
|
6
|
+
* - string: 셸 명령어 (cwd가 BASE_DIR인 상태에서 실행됨)
|
|
7
|
+
* - (cwd: string) => Promise<void>: Node.js API로 직접 수행 (크로스 플랫폼)
|
|
8
|
+
*/
|
|
9
|
+
scaffold: (projectName: string) => string | ((cwd: string) => Promise<void>);
|
|
10
|
+
/** scaffold 실행 타임아웃 (ms). 미지정 시 DEFAULT_SCAFFOLD_TIMEOUT 적용 */
|
|
11
|
+
timeout?: number;
|
|
12
|
+
}
|
|
13
|
+
/** 기본 scaffold 타임아웃: 5분 */
|
|
14
|
+
export declare const DEFAULT_SCAFFOLD_TIMEOUT = 300000;
|
|
15
|
+
export declare const TEMPLATES: Record<string, FrameworkTemplate>;
|
|
16
|
+
/**
|
|
17
|
+
* 템플릿 조회
|
|
18
|
+
*/
|
|
19
|
+
export declare function getTemplate(key: string): FrameworkTemplate | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* 사용 가능한 템플릿 목록 (카테고리별 그룹)
|
|
22
|
+
*/
|
|
23
|
+
export declare function getTemplateListText(): string;
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 프로젝트 템플릿 정의
|
|
3
|
+
*
|
|
4
|
+
* /twindevbot new <directory> --template <key> 에서 사용.
|
|
5
|
+
* 각 프레임워크의 공식 scaffold CLI 커맨드를 정의합니다.
|
|
6
|
+
*/
|
|
7
|
+
import { execSync } from "child_process";
|
|
8
|
+
import { createWriteStream, mkdirSync, unlinkSync, writeFileSync } from "fs";
|
|
9
|
+
import https from "https";
|
|
10
|
+
import { tmpdir } from "os";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import { t } from "./i18n/index.js";
|
|
13
|
+
/** 기본 scaffold 타임아웃: 5분 */
|
|
14
|
+
export const DEFAULT_SCAFFOLD_TIMEOUT = 300_000;
|
|
15
|
+
const MAX_REDIRECTS = 10;
|
|
16
|
+
/** 다운로드 요청 타임아웃: 30초 */
|
|
17
|
+
const DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
18
|
+
/**
|
|
19
|
+
* URL에서 파일을 다운로드 (리다이렉트 지원, 최대 MAX_REDIRECTS 회)
|
|
20
|
+
*/
|
|
21
|
+
function downloadFile(url, destPath) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const request = (targetUrl, redirectCount) => {
|
|
24
|
+
if (redirectCount > MAX_REDIRECTS) {
|
|
25
|
+
reject(new Error(`Too many redirects (max ${MAX_REDIRECTS})`));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const req = https.get(targetUrl, { timeout: DOWNLOAD_TIMEOUT_MS }, (res) => {
|
|
29
|
+
if (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307 || res.statusCode === 308) {
|
|
30
|
+
const location = res.headers.location;
|
|
31
|
+
if (!location) {
|
|
32
|
+
reject(new Error("Redirect without location header"));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
res.resume();
|
|
36
|
+
const resolved = new URL(location, targetUrl).href;
|
|
37
|
+
request(resolved, redirectCount + 1);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (res.statusCode !== 200) {
|
|
41
|
+
res.resume();
|
|
42
|
+
reject(new Error(`HTTP ${res.statusCode}`));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const file = createWriteStream(destPath);
|
|
46
|
+
res.pipe(file);
|
|
47
|
+
file.on("finish", () => { file.close(() => resolve()); });
|
|
48
|
+
file.on("error", reject);
|
|
49
|
+
});
|
|
50
|
+
req.on("timeout", () => {
|
|
51
|
+
req.destroy();
|
|
52
|
+
reject(new Error(`Download timed out after ${DOWNLOAD_TIMEOUT_MS}ms: ${targetUrl}`));
|
|
53
|
+
});
|
|
54
|
+
req.on("error", reject);
|
|
55
|
+
};
|
|
56
|
+
request(url, 0);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
60
|
+
// Defence-in-depth: scaffold 함수 내부에서도 프로젝트 이름을 검증
|
|
61
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
62
|
+
const SAFE_PROJECT_NAME_RE = /^[a-zA-Z0-9._-]+$/;
|
|
63
|
+
function assertSafeProjectName(name) {
|
|
64
|
+
if (name === "." || name === "..") {
|
|
65
|
+
throw new Error(`Unsafe project name: "${name}". Directory traversal names are not allowed.`);
|
|
66
|
+
}
|
|
67
|
+
if (name.startsWith("-")) {
|
|
68
|
+
throw new Error(`Unsafe project name: "${name}". Names starting with "-" are not allowed.`);
|
|
69
|
+
}
|
|
70
|
+
if (!SAFE_PROJECT_NAME_RE.test(name)) {
|
|
71
|
+
throw new Error(`Unsafe project name: "${name}". Only alphanumeric characters, dots, hyphens, and underscores are allowed.`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export const TEMPLATES = {
|
|
75
|
+
// ── Frontend ──────────────────────────────────────────────
|
|
76
|
+
react: {
|
|
77
|
+
name: "React (Vite + TypeScript)",
|
|
78
|
+
category: "frontend",
|
|
79
|
+
scaffold: (name) => {
|
|
80
|
+
assertSafeProjectName(name);
|
|
81
|
+
return `npm create vite@latest ${name} -- --template react-ts --no-interactive`;
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
nextjs: {
|
|
85
|
+
name: "Next.js",
|
|
86
|
+
category: "frontend",
|
|
87
|
+
scaffold: (name) => {
|
|
88
|
+
assertSafeProjectName(name);
|
|
89
|
+
return `npx create-next-app@latest ${name} --yes --ts --eslint --app --src-dir --tailwind --import-alias "@/*" --use-npm`;
|
|
90
|
+
},
|
|
91
|
+
timeout: 600_000,
|
|
92
|
+
},
|
|
93
|
+
vue: {
|
|
94
|
+
name: "Vue (create-vue)",
|
|
95
|
+
category: "frontend",
|
|
96
|
+
scaffold: (name) => {
|
|
97
|
+
assertSafeProjectName(name);
|
|
98
|
+
return `npm create vue@latest ${name} -- --ts --router --pinia --eslint --prettier`;
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
nuxt: {
|
|
102
|
+
name: "Nuxt",
|
|
103
|
+
category: "frontend",
|
|
104
|
+
scaffold: (name) => {
|
|
105
|
+
assertSafeProjectName(name);
|
|
106
|
+
return `npx nuxi@latest init ${name} --template v4-compat --gitInit false --packageManager npm --no-modules`;
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
sveltekit: {
|
|
110
|
+
name: "SvelteKit",
|
|
111
|
+
category: "frontend",
|
|
112
|
+
scaffold: (name) => {
|
|
113
|
+
assertSafeProjectName(name);
|
|
114
|
+
return `npx sv create ${name} --template minimal --types ts --no-add-ons --no-install`;
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
angular: {
|
|
118
|
+
name: "Angular",
|
|
119
|
+
category: "frontend",
|
|
120
|
+
scaffold: (name) => {
|
|
121
|
+
assertSafeProjectName(name);
|
|
122
|
+
return `npx @angular/cli@latest new ${name} --defaults --skip-install --no-interactive`;
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
"react-native-expo": {
|
|
126
|
+
name: "React Native (Expo)",
|
|
127
|
+
category: "frontend",
|
|
128
|
+
scaffold: (name) => {
|
|
129
|
+
assertSafeProjectName(name);
|
|
130
|
+
return `npx create-expo-app@latest ${name} --template blank-typescript --yes`;
|
|
131
|
+
},
|
|
132
|
+
timeout: 600_000,
|
|
133
|
+
},
|
|
134
|
+
"react-native-bare": {
|
|
135
|
+
name: "React Native (Bare CLI)",
|
|
136
|
+
category: "frontend",
|
|
137
|
+
scaffold: (name) => {
|
|
138
|
+
assertSafeProjectName(name);
|
|
139
|
+
// React Native CLI requires PascalCase project names (no hyphens)
|
|
140
|
+
const pascalName = name
|
|
141
|
+
.split(/[-_]+/)
|
|
142
|
+
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
|
143
|
+
.join("");
|
|
144
|
+
return `npx @react-native-community/cli@latest init ${pascalName} --directory ${name}`;
|
|
145
|
+
},
|
|
146
|
+
timeout: 600_000,
|
|
147
|
+
},
|
|
148
|
+
flutter: {
|
|
149
|
+
name: "Flutter",
|
|
150
|
+
category: "frontend",
|
|
151
|
+
scaffold: (name) => {
|
|
152
|
+
assertSafeProjectName(name);
|
|
153
|
+
return `flutter create ${name}`;
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
// ── Backend ───────────────────────────────────────────────
|
|
157
|
+
express: {
|
|
158
|
+
name: "Express",
|
|
159
|
+
category: "backend",
|
|
160
|
+
scaffold: (name) => {
|
|
161
|
+
assertSafeProjectName(name);
|
|
162
|
+
return `npx express-generator ${name} --no-view`;
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
nestjs: {
|
|
166
|
+
name: "NestJS",
|
|
167
|
+
category: "backend",
|
|
168
|
+
scaffold: (name) => {
|
|
169
|
+
assertSafeProjectName(name);
|
|
170
|
+
return `npx @nestjs/cli@latest new ${name} --package-manager npm --skip-install --skip-git`;
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
fastify: {
|
|
174
|
+
name: "Fastify (TypeScript)",
|
|
175
|
+
category: "backend",
|
|
176
|
+
scaffold: (name) => {
|
|
177
|
+
assertSafeProjectName(name);
|
|
178
|
+
return `npx fastify-cli generate ${name} --lang=ts`;
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
"spring-boot": {
|
|
182
|
+
name: "Spring Boot",
|
|
183
|
+
category: "backend",
|
|
184
|
+
scaffold: (name) => {
|
|
185
|
+
assertSafeProjectName(name);
|
|
186
|
+
return async (cwd) => {
|
|
187
|
+
const zipPath = join(tmpdir(), `spring-${name}.zip`);
|
|
188
|
+
const safePkgName = name.replace(/-/g, "_");
|
|
189
|
+
const enc = encodeURIComponent;
|
|
190
|
+
const url = `https://start.spring.io/starter.zip?type=maven-project&language=java&baseDir=${enc(name)}&groupId=com.example&artifactId=${enc(name)}&name=${enc(name)}&packageName=${enc(`com.example.${safePkgName}`)}&dependencies=web`;
|
|
191
|
+
try {
|
|
192
|
+
await downloadFile(url, zipPath);
|
|
193
|
+
if (process.platform === "win32") {
|
|
194
|
+
execSync(`powershell -Command "Expand-Archive -Path '${zipPath}' -DestinationPath '.' -Force"`, { cwd });
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
execSync(`unzip -q "${zipPath}" -d .`, { cwd });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
try {
|
|
202
|
+
unlinkSync(zipPath);
|
|
203
|
+
}
|
|
204
|
+
catch { /* 파일이 없을 수 있음 */ }
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
},
|
|
208
|
+
timeout: 600_000,
|
|
209
|
+
},
|
|
210
|
+
django: {
|
|
211
|
+
name: "Django",
|
|
212
|
+
category: "backend",
|
|
213
|
+
scaffold: (name) => {
|
|
214
|
+
assertSafeProjectName(name);
|
|
215
|
+
return `django-admin startproject ${name}`;
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
fastapi: {
|
|
219
|
+
name: "FastAPI",
|
|
220
|
+
category: "backend",
|
|
221
|
+
scaffold: (name) => {
|
|
222
|
+
assertSafeProjectName(name);
|
|
223
|
+
return async (cwd) => {
|
|
224
|
+
const dir = join(cwd, name);
|
|
225
|
+
mkdirSync(dir, { recursive: true });
|
|
226
|
+
writeFileSync(join(dir, "main.py"), [
|
|
227
|
+
"from fastapi import FastAPI",
|
|
228
|
+
"",
|
|
229
|
+
"app = FastAPI()",
|
|
230
|
+
"",
|
|
231
|
+
"",
|
|
232
|
+
'@app.get("/")',
|
|
233
|
+
"def read_root():",
|
|
234
|
+
' return {"Hello": "World"}',
|
|
235
|
+
"",
|
|
236
|
+
].join("\n"));
|
|
237
|
+
};
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
go: {
|
|
241
|
+
name: "Go (module)",
|
|
242
|
+
category: "backend",
|
|
243
|
+
scaffold: (name) => {
|
|
244
|
+
assertSafeProjectName(name);
|
|
245
|
+
return async (cwd) => {
|
|
246
|
+
const dir = join(cwd, name);
|
|
247
|
+
mkdirSync(dir, { recursive: true });
|
|
248
|
+
execSync(`go mod init example.com/${name}`, { cwd: dir });
|
|
249
|
+
};
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
rails: {
|
|
253
|
+
name: "Ruby on Rails (API)",
|
|
254
|
+
category: "backend",
|
|
255
|
+
scaffold: (name) => {
|
|
256
|
+
assertSafeProjectName(name);
|
|
257
|
+
return `rails new ${name} --skip-bundle --skip-git --api`;
|
|
258
|
+
},
|
|
259
|
+
timeout: 600_000,
|
|
260
|
+
},
|
|
261
|
+
laravel: {
|
|
262
|
+
name: "Laravel",
|
|
263
|
+
category: "backend",
|
|
264
|
+
scaffold: (name) => {
|
|
265
|
+
assertSafeProjectName(name);
|
|
266
|
+
return `composer create-project laravel/laravel ${name} --no-interaction --prefer-dist`;
|
|
267
|
+
},
|
|
268
|
+
timeout: 600_000,
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
/**
|
|
272
|
+
* 템플릿 조회
|
|
273
|
+
*/
|
|
274
|
+
export function getTemplate(key) {
|
|
275
|
+
return TEMPLATES[key.toLowerCase()];
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* 사용 가능한 템플릿 목록 (카테고리별 그룹)
|
|
279
|
+
*/
|
|
280
|
+
export function getTemplateListText() {
|
|
281
|
+
const grouped = {
|
|
282
|
+
frontend: [],
|
|
283
|
+
backend: [],
|
|
284
|
+
};
|
|
285
|
+
for (const [key, tmpl] of Object.entries(TEMPLATES)) {
|
|
286
|
+
grouped[tmpl.category].push(`\`${key}\``);
|
|
287
|
+
}
|
|
288
|
+
const lines = [];
|
|
289
|
+
lines.push("> " + t("template.frontend") + grouped.frontend.join(", "));
|
|
290
|
+
lines.push("> " + t("template.backend") + grouped.backend.join(", "));
|
|
291
|
+
return lines.join("\n");
|
|
292
|
+
}
|