sanjang 0.3.2 → 0.3.4
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/dashboard/app.js +897 -132
- package/dashboard/index.html +19 -6
- package/dashboard/style.css +102 -43
- package/dist/bin/sanjang.js +43 -1
- package/dist/lib/server.js +69 -2
- package/package.json +1 -1
package/dashboard/index.html
CHANGED
|
@@ -17,6 +17,20 @@
|
|
|
17
17
|
|
|
18
18
|
<!-- Portal Home -->
|
|
19
19
|
<div id="portal">
|
|
20
|
+
<!-- 베이스캠프 씬 -->
|
|
21
|
+
<div class="portal-section" id="basecamp-scene">
|
|
22
|
+
<div class="bc-scene" id="bc-scene-container">
|
|
23
|
+
<!-- JS renders time-based SVG scene here -->
|
|
24
|
+
</div>
|
|
25
|
+
<!-- Speech bubble stays outside SVG for text rendering -->
|
|
26
|
+
<div class="bc-speech-wrap">
|
|
27
|
+
<div class="bc-sherpa-hitbox" onclick="toggleSherpaMode()" title="클릭해서 모드 변경"></div>
|
|
28
|
+
<div class="bc-speech fade-in" id="sherpa-speech">
|
|
29
|
+
<span id="sherpa-quote"></span>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
20
34
|
<!-- 새로 시작 (핵심 액션, 맨 위) -->
|
|
21
35
|
<div class="portal-section">
|
|
22
36
|
<div class="portal-quickstart">
|
|
@@ -34,17 +48,16 @@
|
|
|
34
48
|
<div id="portal-work" class="portal-work-list"></div>
|
|
35
49
|
</div>
|
|
36
50
|
|
|
37
|
-
<!-- 추천 작업 (최대 5개, 이어하기와 중복 제거) -->
|
|
38
|
-
<div class="portal-section" id="portal-suggestions-section" style="display:none">
|
|
39
|
-
<h2 class="portal-section-title">이런 걸 해볼 수 있어요</h2>
|
|
40
|
-
<div id="portal-suggestions" class="portal-work-list"></div>
|
|
41
|
-
</div>
|
|
42
|
-
|
|
43
51
|
<!-- 기존 캠프가 있으면 아래에 카드 그리드 표시 -->
|
|
44
52
|
<div id="portal-camps-section" class="portal-section hidden">
|
|
45
53
|
<h2 class="portal-section-title">캠프</h2>
|
|
46
54
|
<div class="grid" id="grid"></div>
|
|
47
55
|
</div>
|
|
56
|
+
|
|
57
|
+
<!-- 활동 지형도 -->
|
|
58
|
+
<div class="portal-section" id="activity-trail-section" style="display:none">
|
|
59
|
+
<div id="activity-trail"></div>
|
|
60
|
+
</div>
|
|
48
61
|
</div>
|
|
49
62
|
|
|
50
63
|
<!-- Workspace View (full-screen preview + slide panel) -->
|
package/dashboard/style.css
CHANGED
|
@@ -2046,67 +2046,126 @@ header.hidden {
|
|
|
2046
2046
|
Onboarding Tutorial
|
|
2047
2047
|
============================================================ */
|
|
2048
2048
|
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2049
|
+
|
|
2050
|
+
/* ============================================================
|
|
2051
|
+
Basecamp Scene — Himalaya
|
|
2052
|
+
============================================================ */
|
|
2053
|
+
|
|
2054
|
+
.bc-scene {
|
|
2055
|
+
position: relative;
|
|
2056
|
+
height: 220px;
|
|
2057
|
+
border-radius: 12px;
|
|
2058
|
+
overflow: hidden;
|
|
2059
|
+
border: 1px solid #1c2030;
|
|
2054
2060
|
}
|
|
2055
2061
|
|
|
2056
|
-
.
|
|
2057
|
-
|
|
2058
|
-
|
|
2062
|
+
.bc-scene svg {
|
|
2063
|
+
display: block;
|
|
2064
|
+
width: 100%;
|
|
2065
|
+
height: 100%;
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
/* Speech bubble positioning */
|
|
2069
|
+
.bc-speech-wrap {
|
|
2070
|
+
position: absolute;
|
|
2071
|
+
bottom: 56px;
|
|
2072
|
+
left: calc(50% + 16px);
|
|
2073
|
+
z-index: 5;
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
.bc-sherpa-hitbox {
|
|
2077
|
+
position: absolute;
|
|
2078
|
+
bottom: -44px;
|
|
2079
|
+
left: -8px;
|
|
2080
|
+
width: 44px;
|
|
2081
|
+
height: 44px;
|
|
2082
|
+
cursor: pointer;
|
|
2083
|
+
z-index: 6;
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
#basecamp-scene {
|
|
2087
|
+
position: relative;
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
.bc-speech {
|
|
2091
|
+
position: relative;
|
|
2092
|
+
background: #1c2030;
|
|
2093
|
+
border: 1px solid #2a2f42;
|
|
2059
2094
|
border-radius: 8px;
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2095
|
+
padding: 6px 10px;
|
|
2096
|
+
font-size: 11px;
|
|
2097
|
+
color: #e4e8f0;
|
|
2098
|
+
white-space: nowrap;
|
|
2099
|
+
animation: bubble-float 3s ease-in-out infinite;
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
.bc-speech::after {
|
|
2103
|
+
content: '';
|
|
2104
|
+
position: absolute;
|
|
2105
|
+
bottom: -5px;
|
|
2106
|
+
left: 20px;
|
|
2107
|
+
width: 8px;
|
|
2108
|
+
height: 8px;
|
|
2109
|
+
background: #1c2030;
|
|
2110
|
+
border-right: 1px solid #2a2f42;
|
|
2111
|
+
border-bottom: 1px solid #2a2f42;
|
|
2112
|
+
transform: rotate(45deg);
|
|
2064
2113
|
}
|
|
2065
2114
|
|
|
2066
|
-
@keyframes
|
|
2067
|
-
0%, 100% {
|
|
2068
|
-
50% {
|
|
2115
|
+
@keyframes bubble-float {
|
|
2116
|
+
0%, 100% { transform: translateY(0); }
|
|
2117
|
+
50% { transform: translateY(-3px); }
|
|
2069
2118
|
}
|
|
2070
2119
|
|
|
2071
|
-
.
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
border: 1px solid var(--border, #333);
|
|
2075
|
-
border-radius: 12px;
|
|
2076
|
-
padding: 16px 20px;
|
|
2077
|
-
max-width: 320px;
|
|
2078
|
-
z-index: 10002;
|
|
2079
|
-
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
|
|
2080
|
-
animation: onboard-fade-in 0.3s ease-out;
|
|
2120
|
+
.bc-speech.fade-out {
|
|
2121
|
+
opacity: 0;
|
|
2122
|
+
transition: opacity 0.5s;
|
|
2081
2123
|
}
|
|
2082
2124
|
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2125
|
+
.bc-speech.fade-in {
|
|
2126
|
+
opacity: 1;
|
|
2127
|
+
transition: opacity 0.5s;
|
|
2086
2128
|
}
|
|
2087
2129
|
|
|
2088
|
-
.
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
color: var(--text-primary, #fff);
|
|
2092
|
-
margin-bottom: 6px;
|
|
2130
|
+
.bc-speech.guide-mode {
|
|
2131
|
+
border-color: rgba(99, 102, 241, 0.4);
|
|
2132
|
+
background: #1a1d2e;
|
|
2093
2133
|
}
|
|
2094
2134
|
|
|
2095
|
-
.
|
|
2096
|
-
|
|
2097
|
-
color:
|
|
2098
|
-
|
|
2099
|
-
|
|
2135
|
+
.bc-speech.guide-mode::after {
|
|
2136
|
+
background: #1a1d2e;
|
|
2137
|
+
border-color: rgba(99, 102, 241, 0.4);
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
#sherpa-quote {
|
|
2141
|
+
transition: opacity 0.5s;
|
|
2100
2142
|
}
|
|
2101
2143
|
|
|
2102
|
-
|
|
2144
|
+
/* ============================================================
|
|
2145
|
+
Activity Trail
|
|
2146
|
+
============================================================ */
|
|
2147
|
+
|
|
2148
|
+
#activity-trail {
|
|
2149
|
+
background: #0a0c11;
|
|
2150
|
+
border: 1px solid #1c2030;
|
|
2151
|
+
border-radius: 10px;
|
|
2152
|
+
overflow: hidden;
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
.activity-info {
|
|
2156
|
+
padding: 12px 20px;
|
|
2103
2157
|
display: flex;
|
|
2158
|
+
justify-content: space-between;
|
|
2104
2159
|
align-items: center;
|
|
2105
|
-
gap: 8px;
|
|
2106
2160
|
}
|
|
2107
2161
|
|
|
2108
|
-
.
|
|
2162
|
+
.activity-streak {
|
|
2163
|
+
font-size: 13px;
|
|
2164
|
+
color: #8fbc8f;
|
|
2165
|
+
font-weight: 600;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
.activity-period {
|
|
2109
2169
|
font-size: 11px;
|
|
2110
|
-
color: var(--text-muted
|
|
2111
|
-
flex: 1;
|
|
2170
|
+
color: var(--text-muted);
|
|
2112
2171
|
}
|
package/dist/bin/sanjang.js
CHANGED
|
@@ -132,7 +132,49 @@ else if (command === "help" || command === "--help" || command === "-h") {
|
|
|
132
132
|
`);
|
|
133
133
|
}
|
|
134
134
|
else {
|
|
135
|
-
// Default: start server
|
|
135
|
+
// Default: start server — auto-init if no config exists
|
|
136
|
+
const configPath = resolve(projectRoot, "sanjang.config.js");
|
|
137
|
+
if (!existsSync(configPath)) {
|
|
138
|
+
console.log("⛰ 설정 파일이 없습니다. 프로젝트를 분석합니다...\n");
|
|
139
|
+
const { generateConfig, detectApps } = await import("../lib/config.js");
|
|
140
|
+
const apps = detectApps(projectRoot);
|
|
141
|
+
let appDir;
|
|
142
|
+
if (apps.length >= 2) {
|
|
143
|
+
console.log("⛰ 여러 앱이 감지되었습니다:");
|
|
144
|
+
for (let i = 0; i < apps.length; i++) {
|
|
145
|
+
console.log(` ${i + 1}) ${apps[i].dir}/\t(${apps[i].framework})`);
|
|
146
|
+
}
|
|
147
|
+
console.log("");
|
|
148
|
+
const { createInterface } = await import("node:readline");
|
|
149
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
150
|
+
const answer = await new Promise((r) => { rl.question(" 어떤 앱을 띄울까요? [번호]: ", r); });
|
|
151
|
+
rl.close();
|
|
152
|
+
const idx = parseInt(answer) - 1;
|
|
153
|
+
if (idx < 0 || idx >= apps.length || isNaN(idx)) {
|
|
154
|
+
console.error("⛰ 잘못된 선택입니다.");
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
appDir = apps[idx].dir;
|
|
158
|
+
console.log(` → ${appDir}/ (${apps[idx].framework}) 선택됨\n`);
|
|
159
|
+
}
|
|
160
|
+
else if (apps.length === 1) {
|
|
161
|
+
appDir = apps[0].dir;
|
|
162
|
+
}
|
|
163
|
+
const result = generateConfig(projectRoot, { appDir, force });
|
|
164
|
+
if (result.created) {
|
|
165
|
+
console.log(`⛰ ${result.message}`);
|
|
166
|
+
console.log(` 프레임워크: ${result.framework}\n`);
|
|
167
|
+
}
|
|
168
|
+
// Add .sanjang to .gitignore
|
|
169
|
+
const gitignorePath = resolve(projectRoot, ".gitignore");
|
|
170
|
+
if (existsSync(gitignorePath)) {
|
|
171
|
+
const { readFileSync, appendFileSync } = await import("node:fs");
|
|
172
|
+
const content = readFileSync(gitignorePath, "utf8");
|
|
173
|
+
if (!content.includes(".sanjang")) {
|
|
174
|
+
appendFileSync(gitignorePath, "\n# Sanjang local dev camps\n.sanjang/\n");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
136
178
|
const { startServer } = await import("../lib/server.js");
|
|
137
179
|
await startServer(projectRoot, { port });
|
|
138
180
|
}
|
package/dist/lib/server.js
CHANGED
|
@@ -304,10 +304,13 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
304
304
|
if (!Number.isFinite(targetPort) || targetPort < 1000 || targetPort > 65535) {
|
|
305
305
|
return res.status(400).send("Invalid port");
|
|
306
306
|
}
|
|
307
|
-
//
|
|
307
|
+
// Only allow proxying to known camp ports (prevent SSRF to arbitrary local services)
|
|
308
308
|
const camps = getAll();
|
|
309
309
|
const camp = camps.find(c => c.fePort === targetPort);
|
|
310
|
-
|
|
310
|
+
if (!camp) {
|
|
311
|
+
return res.status(403).send("이 포트는 활성 캠프가 아닙니다.");
|
|
312
|
+
}
|
|
313
|
+
const campName = camp.name;
|
|
311
314
|
const targetPath = req.url || "/";
|
|
312
315
|
const proxyReq = httpRequest({ hostname: "127.0.0.1", port: targetPort, path: targetPath, method: req.method, headers: { ...req.headers, host: `localhost:${targetPort}` } }, (proxyRes) => {
|
|
313
316
|
const contentType = proxyRes.headers["content-type"] || "";
|
|
@@ -1357,6 +1360,70 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
1357
1360
|
}
|
|
1358
1361
|
res.json({ fixed: false, description: fix?.description ?? "자동으로 고칠 수 있는 문제를 찾지 못했습니다." });
|
|
1359
1362
|
});
|
|
1363
|
+
let activityCache = null;
|
|
1364
|
+
const ACTIVITY_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
1365
|
+
app.get("/api/activity", (_req, res) => {
|
|
1366
|
+
if (activityCache && Date.now() - activityCache.ts < ACTIVITY_CACHE_TTL) {
|
|
1367
|
+
return res.json(activityCache.data);
|
|
1368
|
+
}
|
|
1369
|
+
// --- daily commits (last 4 weeks) ---
|
|
1370
|
+
const gitLog = spawnSync("git", ["log", "--since=4 weeks ago", "--format=%ad", "--date=short"], {
|
|
1371
|
+
cwd: projectRoot,
|
|
1372
|
+
stdio: "pipe",
|
|
1373
|
+
encoding: "utf8",
|
|
1374
|
+
});
|
|
1375
|
+
const commitDates = (gitLog.status === 0 ? gitLog.stdout : "").trim().split("\n").filter(Boolean);
|
|
1376
|
+
const countByDate = new Map();
|
|
1377
|
+
for (const d of commitDates) {
|
|
1378
|
+
countByDate.set(d, (countByDate.get(d) ?? 0) + 1);
|
|
1379
|
+
}
|
|
1380
|
+
// Fill missing days in the 4-week window
|
|
1381
|
+
const daily = [];
|
|
1382
|
+
const now = new Date();
|
|
1383
|
+
for (let i = 27; i >= 0; i--) {
|
|
1384
|
+
const d = new Date(now);
|
|
1385
|
+
d.setDate(d.getDate() - i);
|
|
1386
|
+
const key = d.toISOString().slice(0, 10);
|
|
1387
|
+
daily.push({ date: key, commits: countByDate.get(key) ?? 0 });
|
|
1388
|
+
}
|
|
1389
|
+
// --- merged PRs ---
|
|
1390
|
+
let mergedPrs = [];
|
|
1391
|
+
const ghResult = spawnSync("gh", ["pr", "list", "--state", "merged", "--limit", "10", "--json", "number,title,mergedAt"], {
|
|
1392
|
+
cwd: projectRoot,
|
|
1393
|
+
stdio: "pipe",
|
|
1394
|
+
encoding: "utf8",
|
|
1395
|
+
});
|
|
1396
|
+
if (ghResult.status === 0 && ghResult.stdout) {
|
|
1397
|
+
try {
|
|
1398
|
+
const parsed = JSON.parse(ghResult.stdout);
|
|
1399
|
+
mergedPrs = parsed;
|
|
1400
|
+
}
|
|
1401
|
+
catch {
|
|
1402
|
+
// malformed JSON — keep empty
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
// --- streak (consecutive days with commits, backward from today) ---
|
|
1406
|
+
let streak = 0;
|
|
1407
|
+
const todayStr = now.toISOString().slice(0, 10);
|
|
1408
|
+
for (let i = 0; i <= 27; i++) {
|
|
1409
|
+
const d = new Date(now);
|
|
1410
|
+
d.setDate(d.getDate() - i);
|
|
1411
|
+
const key = d.toISOString().slice(0, 10);
|
|
1412
|
+
if ((countByDate.get(key) ?? 0) > 0) {
|
|
1413
|
+
streak++;
|
|
1414
|
+
}
|
|
1415
|
+
else if (key === todayStr) {
|
|
1416
|
+
// Today having 0 commits is ok — streak can start from yesterday
|
|
1417
|
+
continue;
|
|
1418
|
+
}
|
|
1419
|
+
else {
|
|
1420
|
+
break;
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
const data = { daily, mergedPrs, streak };
|
|
1424
|
+
activityCache = { data, ts: Date.now() };
|
|
1425
|
+
res.json(data);
|
|
1426
|
+
});
|
|
1360
1427
|
// SPA fallback
|
|
1361
1428
|
app.get("*", (_req, res) => {
|
|
1362
1429
|
res.sendFile(join(dashboardDir, "index.html"));
|