sanjang 0.3.4 → 0.3.6

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.
@@ -1,4 +1,4 @@
1
- import { execSync } from "node:child_process";
1
+ import { createServer } from "node:net";
2
2
  const portConfig = {
3
3
  fe: { base: 3000, slots: 8 },
4
4
  be: { base: 8000, slots: 8 },
@@ -15,38 +15,48 @@ export function portsForSlot(slot) {
15
15
  bePort: portConfig.be.base + slot,
16
16
  };
17
17
  }
18
- function isPortBusy(port) {
19
- try {
20
- const out = execSync(`lsof -i :${port} -t 2>/dev/null`, { encoding: "utf8" }).trim();
21
- return out.length > 0;
22
- }
23
- catch {
24
- return false;
25
- }
18
+ function probePort(port) {
19
+ return new Promise((resolve) => {
20
+ const srv = createServer();
21
+ srv.once("error", () => resolve(true));
22
+ srv.once("listening", () => {
23
+ srv.close(() => resolve(false));
24
+ });
25
+ srv.listen(port, "127.0.0.1");
26
+ });
26
27
  }
27
- export function scanPorts() {
28
- const status = [];
28
+ // --- 10-second cache ---
29
+ let _cache = null;
30
+ let _cacheTime = 0;
31
+ const CACHE_TTL = 10_000;
32
+ export async function scanPorts() {
33
+ const now = Date.now();
34
+ if (_cache && now - _cacheTime < CACHE_TTL)
35
+ return _cache;
29
36
  const maxSlots = Math.max(portConfig.fe.slots, portConfig.be.slots);
30
- for (let slot = 0; slot < maxSlots; slot++) {
37
+ const slots = Array.from({ length: maxSlots }, (_, i) => i);
38
+ const results = await Promise.all(slots.map(async (slot) => {
31
39
  const { fePort, bePort } = portsForSlot(slot);
32
- status.push({
33
- slot,
34
- fePort,
35
- feBusy: isPortBusy(fePort),
36
- bePort,
37
- beBusy: isPortBusy(bePort),
38
- });
39
- }
40
- return status;
40
+ const [feBusy, beBusy] = await Promise.all([probePort(fePort), probePort(bePort)]);
41
+ return { slot, fePort, feBusy, bePort, beBusy };
42
+ }));
43
+ _cache = results;
44
+ _cacheTime = Date.now();
45
+ return results;
41
46
  }
42
- export function allocate(existingCamps) {
47
+ export async function allocate(existingCamps) {
43
48
  const usedSlots = new Set(existingCamps.map((p) => p.slot));
49
+ const usedFePorts = new Set(existingCamps.map((p) => p.fePort));
50
+ const usedBePorts = new Set(existingCamps.map((p) => p.bePort));
44
51
  const maxSlots = portConfig.fe.slots;
45
52
  for (let slot = 1; slot < maxSlots; slot++) {
46
53
  if (usedSlots.has(slot))
47
54
  continue;
48
55
  const { fePort, bePort } = portsForSlot(slot);
49
- if (!isPortBusy(fePort) && !isPortBusy(bePort)) {
56
+ if (usedFePorts.has(fePort) || usedBePorts.has(bePort))
57
+ continue;
58
+ const [feBusy, beBusy] = await Promise.all([probePort(fePort), probePort(bePort)]);
59
+ if (!feBusy && !beBusy) {
50
60
  return { slot, fePort, bePort };
51
61
  }
52
62
  }
@@ -27,7 +27,7 @@ export function buildFallbackPrBody({ message, actions, diffStat }) {
27
27
  export function buildClaudePrPrompt({ message, diffStat, diff }) {
28
28
  return [
29
29
  "You are writing a GitHub Pull Request description.",
30
- 'The author described the change as: "' + message + '"',
30
+ `The author described the change as: "${message}"`,
31
31
  "",
32
32
  "Here is the diff stat:",
33
33
  diffStat || "(no stat)",
@@ -0,0 +1,11 @@
1
+ import { type ChildProcess } from "node:child_process";
2
+ export declare function stripAnsi(s: string): string;
3
+ /** Build the full dev command, handling npm/yarn/pnpm's `--` separator requirement */
4
+ export declare function buildDevCommand(command: string, portFlag: string | null | undefined, port: number): string;
5
+ /** Kill a process and its entire process group (for shell: true + detached: true spawns) */
6
+ export declare function killProcessGroup(proc: ChildProcess): void;
7
+ /**
8
+ * Detect actual port from dev server stdout logs by polling for a localhost URL.
9
+ * Strips ANSI codes before matching. Verifies port is reachable via TCP probe.
10
+ */
11
+ export declare function detectPortFromLogs(logs: string[], timeoutMs: number): Promise<number | null>;
@@ -0,0 +1,65 @@
1
+ import { createConnection } from "node:net";
2
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
3
+ export function stripAnsi(s) {
4
+ return s.replace(ANSI_RE, "");
5
+ }
6
+ /** Build the full dev command, handling npm/yarn/pnpm's `--` separator requirement */
7
+ export function buildDevCommand(command, portFlag, port) {
8
+ if (!portFlag)
9
+ return command;
10
+ const needsSeparator = /\b(npm|yarn|pnpm)\s+run\b/.test(command);
11
+ return `${command}${needsSeparator ? " --" : ""} ${portFlag} ${port}`;
12
+ }
13
+ /** Kill a process and its entire process group (for shell: true + detached: true spawns) */
14
+ export function killProcessGroup(proc) {
15
+ const pid = proc.pid;
16
+ if (pid) {
17
+ try {
18
+ process.kill(-pid, "SIGTERM");
19
+ }
20
+ catch {
21
+ proc.kill("SIGTERM");
22
+ }
23
+ }
24
+ else {
25
+ proc.kill("SIGTERM");
26
+ }
27
+ }
28
+ /**
29
+ * Detect actual port from dev server stdout logs by polling for a localhost URL.
30
+ * Strips ANSI codes before matching. Verifies port is reachable via TCP probe.
31
+ */
32
+ export function detectPortFromLogs(logs, timeoutMs) {
33
+ return new Promise((resolve) => {
34
+ const deadline = Date.now() + timeoutMs;
35
+ const portRe = /https?:\/\/localhost:(\d+)/;
36
+ function check() {
37
+ for (const line of logs) {
38
+ const match = portRe.exec(stripAnsi(line));
39
+ if (match?.[1]) {
40
+ const port = parseInt(match[1], 10);
41
+ const sock = createConnection({ port, host: "localhost" });
42
+ sock.once("connect", () => {
43
+ sock.destroy();
44
+ resolve(port);
45
+ });
46
+ sock.once("error", () => {
47
+ sock.destroy();
48
+ if (Date.now() < deadline)
49
+ setTimeout(check, 1000);
50
+ else
51
+ resolve(port);
52
+ });
53
+ return;
54
+ }
55
+ }
56
+ if (Date.now() >= deadline) {
57
+ resolve(null);
58
+ }
59
+ else {
60
+ setTimeout(check, 1000);
61
+ }
62
+ }
63
+ check();
64
+ });
65
+ }
@@ -7,6 +7,8 @@ export declare function setConfig(config: SanjangConfig): void;
7
7
  interface StartCampParams {
8
8
  name: string;
9
9
  fePort: number;
10
+ /** Ports reserved by other camps — detected port must not collide with these */
11
+ reservedPorts?: Set<number>;
10
12
  }
11
13
  export declare function startCamp(pg: StartCampParams, onEvent: EventCallback): Promise<number>;
12
14
  export declare function stopCamp(name: string): void;
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { createConnection } from "node:net";
3
3
  import { join } from "node:path";
4
4
  import { campPath, getProjectRoot } from "./worktree.js";
5
+ import { buildDevCommand, detectPortFromLogs, killProcessGroup } from "./process-utils.js";
5
6
  const procs = new Map();
6
7
  let projectConfig = null;
7
8
  let sharedBeProc = null;
@@ -11,41 +12,8 @@ export function setConfig(config) {
11
12
  // ---------------------------------------------------------------------------
12
13
  // Helpers
13
14
  // ---------------------------------------------------------------------------
14
- // Detect actual port from dev server stdout (Vite: "➜ Local: http://localhost:3004/")
15
- function detectPortFromStdout(logs, timeoutMs) {
16
- return new Promise((resolve) => {
17
- const deadline = Date.now() + timeoutMs;
18
- // Patterns: Vite "Local: http://localhost:PORT/", Next "- Local: http://localhost:PORT"
19
- const portRe = /https?:\/\/localhost:(\d+)/;
20
- function check() {
21
- for (const line of logs) {
22
- const match = portRe.exec(line);
23
- if (match?.[1]) {
24
- const port = parseInt(match[1], 10);
25
- // Wait briefly for the port to actually be ready
26
- const sock = createConnection({ port, host: "localhost" });
27
- sock.once("connect", () => { sock.destroy(); resolve(port); });
28
- sock.once("error", () => {
29
- sock.destroy();
30
- // Port printed but not ready yet, retry
31
- if (Date.now() < deadline)
32
- setTimeout(check, 1000);
33
- else
34
- resolve(port); // return the port anyway
35
- });
36
- return;
37
- }
38
- }
39
- if (Date.now() >= deadline) {
40
- resolve(null);
41
- }
42
- else {
43
- setTimeout(check, 1000);
44
- }
45
- }
46
- check();
47
- });
48
- }
15
+ // Re-export detectPortFromLogs as the local name used throughout this file
16
+ const detectPortFromStdout = detectPortFromLogs;
49
17
  function waitForPort(port, timeoutMs) {
50
18
  return new Promise((resolve, reject) => {
51
19
  const deadline = Date.now() + timeoutMs;
@@ -169,8 +137,7 @@ export async function startCamp(pg, onEvent) {
169
137
  // Step 2: Frontend
170
138
  onEvent({ type: "log", source: "sanjang", data: "Frontend 준비 중..." });
171
139
  onEvent({ type: "status", data: "starting-frontend" });
172
- // Build command — try to pass port flag if available, but always verify via stdout
173
- const fullCommand = dev.portFlag ? `${dev.command} ${dev.portFlag} ${fePort}` : dev.command;
140
+ const fullCommand = buildDevCommand(dev.command, dev.portFlag, fePort);
174
141
  const cwd = dev.cwd ? join(wtPath, dev.cwd) : wtPath;
175
142
  const feProc = spawn(fullCommand, [], {
176
143
  cwd,
@@ -185,6 +152,7 @@ export async function startCamp(pg, onEvent) {
185
152
  },
186
153
  stdio: ["ignore", "pipe", "pipe"],
187
154
  shell: true,
155
+ detached: true,
188
156
  });
189
157
  entry.feProc = feProc;
190
158
  attachLogs(feProc, entry.feLogs, "frontend", onEvent);
@@ -198,6 +166,23 @@ export async function startCamp(pg, onEvent) {
198
166
  if (!detectedPort) {
199
167
  throw new Error("dev 서버가 시작되지 않았습니다. 로그를 확인하세요.");
200
168
  }
169
+ // Validate: detected port must not collide with another camp's port
170
+ if (pg.reservedPorts?.has(detectedPort)) {
171
+ onEvent({
172
+ type: "log",
173
+ source: "sanjang",
174
+ data: `⚠️ 포트 충돌 감지: dev 서버가 ${fePort} 대신 ${detectedPort}을 사용했고, 다른 캠프와 겹칩니다. 프로세스를 종료합니다.`,
175
+ });
176
+ stopCamp(name);
177
+ throw new Error(`포트 충돌: dev 서버가 :${detectedPort}에 바인딩했지만, 다른 캠프가 이미 사용 중입니다. 해당 캠프를 정리하거나 포트를 확인하세요.`);
178
+ }
179
+ if (detectedPort !== fePort) {
180
+ onEvent({
181
+ type: "log",
182
+ source: "sanjang",
183
+ data: `ℹ️ dev 서버가 요청한 포트(${fePort}) 대신 ${detectedPort}을 사용합니다.`,
184
+ });
185
+ }
201
186
  const url = `http://localhost:${detectedPort}`;
202
187
  onEvent({ type: "log", source: "sanjang", data: `Frontend 준비 완료 ✓` });
203
188
  onEvent({ type: "url-detected", data: { url, port: detectedPort } });
@@ -209,12 +194,15 @@ export function stopCamp(name) {
209
194
  if (!entry)
210
195
  return;
211
196
  if (entry.feProc && !entry.feProc.killed) {
212
- entry.feProc.kill("SIGTERM");
197
+ killProcessGroup(entry.feProc);
213
198
  // SIGKILL fallback if still alive after 5s
214
199
  const proc = entry.feProc;
200
+ const pid = proc.pid;
215
201
  setTimeout(() => {
216
202
  try {
217
- if (!proc.killed)
203
+ if (!proc.killed && pid)
204
+ process.kill(-pid, "SIGKILL");
205
+ else if (!proc.killed)
218
206
  proc.kill("SIGKILL");
219
207
  }
220
208
  catch {
@@ -1,6 +1,6 @@
1
- import { existsSync, copyFileSync, mkdirSync } from "node:fs";
2
- import { join, dirname } from "node:path";
3
1
  import { execSync } from "node:child_process";
2
+ import { copyFileSync, existsSync, mkdirSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
4
  const PATTERNS = [
5
5
  {
6
6
  test: /Cannot find module|MODULE_NOT_FOUND/i,
@@ -64,7 +64,12 @@ export function executeHeal(action, campPath, projectRoot, setupCommand, copyFil
64
64
  if (!setupCommand)
65
65
  return { action, success: false, detail: "설치 명령이 없습니다." };
66
66
  try {
67
- execSync(setupCommand, { cwd: campPath, stdio: "pipe", timeout: 120_000, shell: true });
67
+ execSync(setupCommand, {
68
+ cwd: campPath,
69
+ stdio: "pipe",
70
+ timeout: 120_000,
71
+ shell: true,
72
+ });
68
73
  return { action, success: true };
69
74
  }
70
75
  catch {
@@ -82,10 +87,16 @@ export function executeHeal(action, campPath, projectRoot, setupCommand, copyFil
82
87
  copyFileSync(src, dst);
83
88
  copied++;
84
89
  }
85
- catch { /* skip */ }
90
+ catch {
91
+ /* skip */
92
+ }
86
93
  }
87
94
  }
88
- return { action, success: copied > 0, detail: copied > 0 ? `${copied}개 파일 복사됨` : "복사할 파일이 없습니다." };
95
+ return {
96
+ action,
97
+ success: copied > 0,
98
+ detail: copied > 0 ? `${copied}개 파일 복사됨` : "복사할 파일이 없습니다.",
99
+ };
89
100
  }
90
101
  case "restart":
91
102
  // Restart is handled by the caller (stop + start)
@@ -32,7 +32,7 @@ export function deepFindEnvFiles(projectRoot, maxDepth = 4) {
32
32
  export function detectSetupIssues(projectRoot) {
33
33
  const issues = [];
34
34
  // 1. Check for env variable references without .env files
35
- const hasEnvFile = ENV_PATTERNS.some(f => existsSync(join(projectRoot, f)));
35
+ const hasEnvFile = ENV_PATTERNS.some((f) => existsSync(join(projectRoot, f)));
36
36
  if (!hasEnvFile) {
37
37
  const envRefFound = scanForEnvReferences(projectRoot);
38
38
  if (envRefFound) {
@@ -52,7 +52,7 @@ export function detectSetupIssues(projectRoot) {
52
52
  }
53
53
  // 3. Check for missing lockfile
54
54
  const lockfiles = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb", "bun.lock"];
55
- if (!lockfiles.some(f => existsSync(join(projectRoot, f)))) {
55
+ if (!lockfiles.some((f) => existsSync(join(projectRoot, f)))) {
56
56
  issues.push({
57
57
  type: "missing-lockfile",
58
58
  message: "lockfile이 없습니다. 의존성 설치가 느릴 수 있습니다.",
@@ -97,7 +97,10 @@ function scanForEnvReferences(dir, depth = 0) {
97
97
  continue;
98
98
  }
99
99
  }
100
- if (entry.isDirectory() && !SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".") && entry.name !== "node_modules") {
100
+ if (entry.isDirectory() &&
101
+ !SKIP_DIRS.has(entry.name) &&
102
+ !entry.name.startsWith(".") &&
103
+ entry.name !== "node_modules") {
101
104
  if (scanForEnvReferences(join(dir, entry.name), depth + 1))
102
105
  return true;
103
106
  }
@@ -129,9 +132,7 @@ function countTurboApps(root) {
129
132
  if (scripts?.dev)
130
133
  count++;
131
134
  }
132
- catch {
133
- continue;
134
- }
135
+ catch { }
135
136
  }
136
137
  }
137
138
  return count;
@@ -15,23 +15,32 @@ function stateFile() {
15
15
  function ensureDir() {
16
16
  mkdirSync(getCampsDir(), { recursive: true });
17
17
  }
18
+ // --- In-memory cache ---
19
+ let _stateCache = null;
18
20
  function read() {
21
+ if (_stateCache !== null)
22
+ return _stateCache;
19
23
  const f = stateFile();
20
- if (!existsSync(f))
21
- return [];
24
+ if (!existsSync(f)) {
25
+ _stateCache = [];
26
+ return _stateCache;
27
+ }
22
28
  try {
23
- return JSON.parse(readFileSync(f, "utf8"));
29
+ _stateCache = JSON.parse(readFileSync(f, "utf8"));
30
+ return _stateCache;
24
31
  }
25
32
  catch {
26
- return [];
33
+ _stateCache = [];
34
+ return _stateCache;
27
35
  }
28
36
  }
29
37
  function write(records) {
30
38
  ensureDir();
31
39
  // Atomic write: write to temp file then rename to prevent corruption
32
- const tmp = stateFile() + ".tmp";
40
+ const tmp = `${stateFile()}.tmp`;
33
41
  writeFileSync(tmp, JSON.stringify(records, null, 2), "utf8");
34
42
  renameSync(tmp, stateFile());
43
+ _stateCache = records;
35
44
  }
36
45
  export function getAll() {
37
46
  return read();
@@ -97,10 +97,7 @@ async function fetchRecentCommits(cwd) {
97
97
  export async function suggestTasks(projectRoot) {
98
98
  const results = [];
99
99
  // gh-dependent fetches — tolerate failure (gh not installed / no repo)
100
- const [issues, prs] = await Promise.allSettled([
101
- fetchIssues(projectRoot),
102
- fetchMyPrs(projectRoot),
103
- ]);
100
+ const [issues, prs] = await Promise.allSettled([fetchIssues(projectRoot), fetchMyPrs(projectRoot)]);
104
101
  // PRs first — most actionable ("이어하기")
105
102
  if (prs.status === "fulfilled") {
106
103
  results.push(...prs.value);
@@ -15,7 +15,7 @@ export declare function detectWarp(): WarpDetectResult;
15
15
  * Opens as a new tab in the existing Warp window (not a new window).
16
16
  * The tab title naturally shows the directory name (= camp name).
17
17
  */
18
- export declare function openWarpTab(campName: string, worktreePath: string): WarpOpenResult;
18
+ export declare function openWarpTab(_campName: string, worktreePath: string): WarpOpenResult;
19
19
  /**
20
20
  * No-op cleanup (launch config removed — using open -a instead).
21
21
  */
@@ -12,7 +12,7 @@ export function detectWarp() {
12
12
  * Opens as a new tab in the existing Warp window (not a new window).
13
13
  * The tab title naturally shows the directory name (= camp name).
14
14
  */
15
- export function openWarpTab(campName, worktreePath) {
15
+ export function openWarpTab(_campName, worktreePath) {
16
16
  const { installed } = detectWarp();
17
17
  if (!installed) {
18
18
  return { opened: false, terminal: null, path: worktreePath };
@@ -9,5 +9,7 @@ export declare function setProjectRoot(root: string): void;
9
9
  export declare function getProjectRoot(): string;
10
10
  export declare function campPath(name: string): string;
11
11
  export declare function listBranches(): Promise<BranchInfo[]>;
12
+ export declare function invalidateBranchCache(): void;
13
+ export declare function startBranchRefresh(intervalMs?: number): ReturnType<typeof setInterval>;
12
14
  export declare function addWorktree(name: string, branch: string): Promise<void>;
13
15
  export declare function removeWorktree(name: string): Promise<void>;
@@ -16,14 +16,11 @@ export function campPath(name) {
16
16
  function git() {
17
17
  return simpleGit(getProjectRoot());
18
18
  }
19
- export async function listBranches() {
20
- // Best-effort fetch — continue with local refs on network failure
21
- try {
22
- await git().fetch(["--prune"]);
23
- }
24
- catch {
25
- /* offline is OK */
26
- }
19
+ // --- Branch cache ---
20
+ let _branchCache = null;
21
+ let _branchCacheTime = 0;
22
+ const BRANCH_CACHE_TTL = 30_000;
23
+ async function parseBranches() {
27
24
  const raw = await git().raw([
28
25
  "for-each-ref",
29
26
  "--sort=-committerdate",
@@ -70,6 +67,31 @@ export async function listBranches() {
70
67
  }
71
68
  return branches;
72
69
  }
70
+ async function refreshBranches() {
71
+ try {
72
+ await git().fetch(["--prune"]);
73
+ }
74
+ catch {
75
+ /* offline is OK */
76
+ }
77
+ const branches = await parseBranches();
78
+ _branchCache = branches;
79
+ _branchCacheTime = Date.now();
80
+ return branches;
81
+ }
82
+ export async function listBranches() {
83
+ if (_branchCache && Date.now() - _branchCacheTime < BRANCH_CACHE_TTL) {
84
+ return _branchCache;
85
+ }
86
+ return refreshBranches();
87
+ }
88
+ export function invalidateBranchCache() {
89
+ _branchCacheTime = 0;
90
+ }
91
+ export function startBranchRefresh(intervalMs = 30_000) {
92
+ refreshBranches().catch(() => { });
93
+ return setInterval(() => refreshBranches().catch(() => { }), intervalMs);
94
+ }
73
95
  export async function addWorktree(name, branch) {
74
96
  const path = campPath(name);
75
97
  const refs = [`origin/${branch}`, branch];
@@ -14,6 +14,7 @@ interface CreateAppResult {
14
14
  installed: boolean;
15
15
  };
16
16
  watchers: Map<string, CampWatcher>;
17
+ healthCheckTimer: NodeJS.Timeout;
17
18
  }
18
19
  export declare function createApp(projectRoot: string, options?: CreateAppOptions): Promise<CreateAppResult>;
19
20
  export declare function startServer(projectRoot: string, options?: CreateAppOptions): Promise<Server>;