sanjang 0.3.5 → 0.3.7

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.
@@ -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,44 +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", () => {
28
- sock.destroy();
29
- resolve(port);
30
- });
31
- sock.once("error", () => {
32
- sock.destroy();
33
- // Port printed but not ready yet, retry
34
- if (Date.now() < deadline)
35
- setTimeout(check, 1000);
36
- else
37
- resolve(port); // return the port anyway
38
- });
39
- return;
40
- }
41
- }
42
- if (Date.now() >= deadline) {
43
- resolve(null);
44
- }
45
- else {
46
- setTimeout(check, 1000);
47
- }
48
- }
49
- check();
50
- });
51
- }
15
+ // Re-export detectPortFromLogs as the local name used throughout this file
16
+ const detectPortFromStdout = detectPortFromLogs;
52
17
  function waitForPort(port, timeoutMs) {
53
18
  return new Promise((resolve, reject) => {
54
19
  const deadline = Date.now() + timeoutMs;
@@ -172,8 +137,7 @@ export async function startCamp(pg, onEvent) {
172
137
  // Step 2: Frontend
173
138
  onEvent({ type: "log", source: "sanjang", data: "Frontend 준비 중..." });
174
139
  onEvent({ type: "status", data: "starting-frontend" });
175
- // Build command — try to pass port flag if available, but always verify via stdout
176
- const fullCommand = dev.portFlag ? `${dev.command} ${dev.portFlag} ${fePort}` : dev.command;
140
+ const fullCommand = buildDevCommand(dev.command, dev.portFlag, fePort);
177
141
  const cwd = dev.cwd ? join(wtPath, dev.cwd) : wtPath;
178
142
  const feProc = spawn(fullCommand, [], {
179
143
  cwd,
@@ -188,6 +152,7 @@ export async function startCamp(pg, onEvent) {
188
152
  },
189
153
  stdio: ["ignore", "pipe", "pipe"],
190
154
  shell: true,
155
+ detached: true,
191
156
  });
192
157
  entry.feProc = feProc;
193
158
  attachLogs(feProc, entry.feLogs, "frontend", onEvent);
@@ -201,6 +166,23 @@ export async function startCamp(pg, onEvent) {
201
166
  if (!detectedPort) {
202
167
  throw new Error("dev 서버가 시작되지 않았습니다. 로그를 확인하세요.");
203
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
+ }
204
186
  const url = `http://localhost:${detectedPort}`;
205
187
  onEvent({ type: "log", source: "sanjang", data: `Frontend 준비 완료 ✓` });
206
188
  onEvent({ type: "url-detected", data: { url, port: detectedPort } });
@@ -212,12 +194,15 @@ export function stopCamp(name) {
212
194
  if (!entry)
213
195
  return;
214
196
  if (entry.feProc && !entry.feProc.killed) {
215
- entry.feProc.kill("SIGTERM");
197
+ killProcessGroup(entry.feProc);
216
198
  // SIGKILL fallback if still alive after 5s
217
199
  const proc = entry.feProc;
200
+ const pid = proc.pid;
218
201
  setTimeout(() => {
219
202
  try {
220
- if (!proc.killed)
203
+ if (!proc.killed && pid)
204
+ process.kill(-pid, "SIGKILL");
205
+ else if (!proc.killed)
221
206
  proc.kill("SIGKILL");
222
207
  }
223
208
  catch {
@@ -15,15 +15,23 @@ 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) {
@@ -32,6 +40,7 @@ function write(records) {
32
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();
@@ -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>;