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.
@@ -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) -->
@@ -2046,67 +2046,126 @@ header.hidden {
2046
2046
  Onboarding Tutorial
2047
2047
  ============================================================ */
2048
2048
 
2049
- .onboarding-overlay {
2050
- position: fixed;
2051
- top: 0; left: 0; right: 0; bottom: 0;
2052
- background: rgba(0, 0, 0, 0.6);
2053
- z-index: 10000;
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
- .onboarding-highlight {
2057
- position: fixed;
2058
- border: 2px solid var(--accent, #6366f1);
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
- box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5), 0 0 20px rgba(99, 102, 241, 0.4);
2061
- z-index: 10001;
2062
- pointer-events: none;
2063
- animation: onboard-pulse 1.5s ease-in-out infinite;
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 onboard-pulse {
2067
- 0%, 100% { box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5), 0 0 20px rgba(99, 102, 241, 0.3); }
2068
- 50% { box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5), 0 0 30px rgba(99, 102, 241, 0.6); }
2115
+ @keyframes bubble-float {
2116
+ 0%, 100% { transform: translateY(0); }
2117
+ 50% { transform: translateY(-3px); }
2069
2118
  }
2070
2119
 
2071
- .onboarding-tooltip {
2072
- position: fixed;
2073
- background: var(--bg-elevated, #1e1e2e);
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
- @keyframes onboard-fade-in {
2084
- from { opacity: 0; transform: translateY(8px); }
2085
- to { opacity: 1; transform: translateY(0); }
2125
+ .bc-speech.fade-in {
2126
+ opacity: 1;
2127
+ transition: opacity 0.5s;
2086
2128
  }
2087
2129
 
2088
- .onboarding-title {
2089
- font-size: 15px;
2090
- font-weight: 700;
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
- .onboarding-text {
2096
- font-size: 13px;
2097
- color: var(--text-secondary, #aaa);
2098
- line-height: 1.5;
2099
- margin-bottom: 12px;
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
- .onboarding-actions {
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
- .onboarding-step {
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, #666);
2111
- flex: 1;
2170
+ color: var(--text-muted);
2112
2171
  }
@@ -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
  }
@@ -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
- // Find camp name by port
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
- const campName = camp?.name ?? "unknown";
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"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sanjang",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Local dev environment manager for vibe coders",
5
5
  "type": "module",
6
6
  "bin": {