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.
@@ -1,4 +1,12 @@
1
1
  #!/usr/bin/env node
2
+ // Node 22+ required for --experimental-transform-types
3
+ const nodeVersion = parseInt(process.versions.node.split(".")[0], 10);
4
+ if (nodeVersion < 22) {
5
+ console.error(`⛰ 산장: Node 22 이상이 필요합니다. (현재: v${process.versions.node})`);
6
+ console.error(" 해결: nvm install 22 && nvm use 22");
7
+ console.error(" 또는: https://nodejs.org 에서 최신 LTS를 설치하세요.");
8
+ process.exit(1);
9
+ }
2
10
  import { execSync } from "node:child_process";
3
11
  import { existsSync } from "node:fs";
4
12
  import { resolve } from "node:path";
@@ -116,21 +124,156 @@ if (command === "init") {
116
124
  }
117
125
  else if (command === "help" || command === "--help" || command === "-h") {
118
126
  console.log(`
119
- ⛰ 산장 (Sanjang) — 바이브코더를 위한 로컬 개발 환경 매니저
127
+ ⛰ 산장 (Sanjang) — 비개발자가 AI로 코드를 고치고 PR을 보낼 수 있는 로컬 개발 환경
120
128
 
121
129
  사용법:
122
130
  sanjang 서버 시작 (대시보드: http://localhost:4000)
123
131
  sanjang init 프로젝트 분석 → sanjang.config.js 생성
132
+ sanjang list 캠프 목록 보기
133
+ sanjang status 서버 + 캠프 상태 확인
134
+ sanjang start <name> 캠프 시작
135
+ sanjang stop <name> 캠프 중지
136
+ sanjang open <name> 캠프를 브라우저에서 열기
124
137
  sanjang help 이 도움말
125
138
 
126
139
  옵션:
127
140
  --port <N> 대시보드 포트 (기본: 4000)
128
141
  --project <path> 프로젝트 경로 (기본: 현재 디렉토리)
142
+ --json JSON으로 출력 (Claude Code 등 자동화용)
129
143
  --force 기존 설정을 덮어쓰고 다시 생성
130
144
 
131
145
  자세히: https://github.com/paul-sherpas/sanjang
132
146
  `);
133
147
  }
148
+ else if (command === "list" || command === "status" || command === "start" || command === "stop" || command === "open") {
149
+ // CLI commands that talk to the running sanjang server
150
+ const jsonMode = args.includes("--json");
151
+ const campName = args[1] && !args[1].startsWith("-") ? args[1] : undefined;
152
+ const baseUrl = `http://127.0.0.1:${port}`;
153
+ async function apiFetch(path, method = "GET", body) {
154
+ const opts = { method, headers: { "content-type": "application/json" } };
155
+ if (body)
156
+ opts.body = JSON.stringify(body);
157
+ const res = await fetch(`${baseUrl}${path}`, opts);
158
+ if (!res.ok) {
159
+ const err = await res.json().catch(() => ({ error: res.statusText }));
160
+ throw new Error(err.error || `HTTP ${res.status}`);
161
+ }
162
+ return res.json();
163
+ }
164
+ async function tryApi(fn) {
165
+ try {
166
+ return await fn();
167
+ }
168
+ catch (err) {
169
+ if (err.cause && String(err.cause).includes("ECONNREFUSED")) {
170
+ console.error("⛰ 산장 서버가 실행되지 않고 있습니다.");
171
+ console.error(` sanjang 또는 npx sanjang 으로 먼저 시작하세요.`);
172
+ process.exit(1);
173
+ }
174
+ throw err;
175
+ }
176
+ }
177
+ try {
178
+ if (command === "list") {
179
+ const camps = await tryApi(() => apiFetch("/api/playgrounds"));
180
+ if (jsonMode) {
181
+ process.stdout.write(JSON.stringify(camps, null, 2) + "\n");
182
+ }
183
+ else if (camps.length === 0) {
184
+ console.log("⛰ 캠프가 없습니다. 대시보드에서 만들어보세요.");
185
+ }
186
+ else {
187
+ console.log("⛰ 캠프 목록:\n");
188
+ for (const c of camps) {
189
+ const status = c.status === "running" ? "🟢" : c.status === "error" ? "🔴" : "⚪";
190
+ const url = c.status === "running" && c.fePort ? `http://localhost:${c.fePort}` : "";
191
+ console.log(` ${status} ${c.name}\t${c.branch}\t${url}`);
192
+ }
193
+ }
194
+ }
195
+ else if (command === "status") {
196
+ const camps = await tryApi(() => apiFetch("/api/playgrounds"));
197
+ const running = camps.filter(c => c.status === "running").length;
198
+ const total = camps.length;
199
+ if (jsonMode) {
200
+ process.stdout.write(JSON.stringify({ server: { url: baseUrl, status: "running" }, camps }, null, 2) + "\n");
201
+ }
202
+ else {
203
+ console.log(`⛰ 산장 서버: ${baseUrl}`);
204
+ console.log(` 캠프: ${total}개 (실행 중 ${running}개)`);
205
+ for (const c of camps) {
206
+ const status = c.status === "running" ? "🟢" : c.status === "error" ? "🔴" : "⚪";
207
+ console.log(` ${status} ${c.name} (${c.status})`);
208
+ }
209
+ }
210
+ }
211
+ else if (command === "start") {
212
+ if (!campName) {
213
+ console.error("⛰ 사용법: sanjang start <캠프이름>");
214
+ process.exit(1);
215
+ }
216
+ const result = await tryApi(() => apiFetch(`/api/playgrounds/${campName}/start`, "POST"));
217
+ if (jsonMode) {
218
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
219
+ }
220
+ else {
221
+ console.log(`⛰ ${campName} 캠프를 시작합니다.`);
222
+ }
223
+ }
224
+ else if (command === "stop") {
225
+ if (!campName) {
226
+ console.error("⛰ 사용법: sanjang stop <캠프이름>");
227
+ process.exit(1);
228
+ }
229
+ const result = await tryApi(() => apiFetch(`/api/playgrounds/${campName}/stop`, "POST"));
230
+ if (jsonMode) {
231
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
232
+ }
233
+ else {
234
+ console.log(`⛰ ${campName} 캠프를 중지합니다.`);
235
+ }
236
+ }
237
+ else if (command === "open") {
238
+ if (!campName) {
239
+ console.error("⛰ 사용법: sanjang open <캠프이름>");
240
+ process.exit(1);
241
+ }
242
+ const camps = await tryApi(() => apiFetch("/api/playgrounds"));
243
+ const camp = camps.find(c => c.name === campName);
244
+ if (!camp) {
245
+ console.error(`⛰ "${campName}" 캠프를 찾을 수 없습니다.`);
246
+ process.exit(1);
247
+ }
248
+ if (camp.status !== "running" || !camp.fePort) {
249
+ console.error(`⛰ "${campName}" 캠프가 실행 중이 아닙니다. sanjang start ${campName} 을 먼저 실행하세요.`);
250
+ process.exit(1);
251
+ }
252
+ const campUrl = `http://localhost:${camp.fePort}`;
253
+ if (jsonMode) {
254
+ process.stdout.write(JSON.stringify({ name: campName, url: campUrl, status: camp.status }, null, 2) + "\n");
255
+ }
256
+ else {
257
+ console.log(`⛰ ${campName} 캠프를 브라우저에서 엽니다. → ${campUrl}`);
258
+ }
259
+ const { spawn: spawnOpen } = await import("node:child_process");
260
+ const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
261
+ try {
262
+ spawnOpen(openCmd, [campUrl], { stdio: "ignore", detached: true }).unref();
263
+ }
264
+ catch { /* */ }
265
+ }
266
+ }
267
+ catch (err) {
268
+ if (jsonMode) {
269
+ process.stdout.write(JSON.stringify({ error: err.message }, null, 2) + "\n");
270
+ }
271
+ else {
272
+ console.error(`⛰ 오류: ${err.message}`);
273
+ }
274
+ process.exit(1);
275
+ }
276
+ }
134
277
  else {
135
278
  // Default: start server — auto-init if no config exists
136
279
  const configPath = resolve(projectRoot, "sanjang.config.js");
@@ -4,6 +4,11 @@ import type { DetectedApp, DetectedProject, GenerateConfigResult, SanjangConfig
4
4
  * Returns merged config with defaults.
5
5
  */
6
6
  export declare function loadConfig(projectRoot: string): Promise<SanjangConfig>;
7
+ /**
8
+ * Auto-detect a test command from package.json.
9
+ * Returns null if no test script found or it's the npm default placeholder.
10
+ */
11
+ export declare function detectTestCommand(projectRoot: string, cwd?: string): string | null;
7
12
  /**
8
13
  * Auto-detect project type and generate config.
9
14
  */
@@ -26,6 +26,7 @@ const DEFAULTS = {
26
26
  fe: { base: 3000, slots: 8 },
27
27
  be: { base: 8000, slots: 8 },
28
28
  },
29
+ test: null,
29
30
  };
30
31
  /**
31
32
  * Load sanjang.config.js from project root.
@@ -69,8 +70,33 @@ function mergeConfig(user) {
69
70
  be: { ...DEFAULTS.ports.be, ...userPorts.be },
70
71
  };
71
72
  }
73
+ if (user.test) {
74
+ config.test = typeof user.test === "string"
75
+ ? { command: user.test }
76
+ : user.test;
77
+ }
72
78
  return config;
73
79
  }
80
+ /**
81
+ * Auto-detect a test command from package.json.
82
+ * Returns null if no test script found or it's the npm default placeholder.
83
+ */
84
+ export function detectTestCommand(projectRoot, cwd = ".") {
85
+ const pkgPath = join(projectRoot, cwd, "package.json");
86
+ if (!existsSync(pkgPath))
87
+ return null;
88
+ try {
89
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
90
+ const testScript = pkg?.scripts?.test;
91
+ if (typeof testScript === "string" && !testScript.includes("no test specified")) {
92
+ return "npm test";
93
+ }
94
+ }
95
+ catch {
96
+ // ignore
97
+ }
98
+ return null;
99
+ }
74
100
  /**
75
101
  * Auto-detect project type and generate config.
76
102
  */
@@ -6,6 +6,19 @@
6
6
  * Conflict markers: UU (both modified), AA (both added), DD, AU, UA, DU, UD
7
7
  */
8
8
  export declare function parseConflictFiles(statusOutput: string | null | undefined): string[];
9
+ /**
10
+ * A single conflict section within a file.
11
+ */
12
+ export interface ConflictSection {
13
+ ours: string;
14
+ theirs: string;
15
+ startLine: number;
16
+ }
17
+ /**
18
+ * Parse conflict markers from file content.
19
+ * Returns an array of conflict sections found in the file.
20
+ */
21
+ export declare function parseConflictSections(content: string): ConflictSection[];
9
22
  /**
10
23
  * Build a Claude prompt to resolve merge conflicts.
11
24
  */
@@ -14,6 +14,47 @@ export function parseConflictFiles(statusOutput) {
14
14
  .filter((line) => /^(UU|AA|DD|AU|UA|DU|UD)\s/.test(line))
15
15
  .map((line) => line.slice(3).trim());
16
16
  }
17
+ /**
18
+ * Parse conflict markers from file content.
19
+ * Returns an array of conflict sections found in the file.
20
+ */
21
+ export function parseConflictSections(content) {
22
+ const lines = content.split("\n");
23
+ const sections = [];
24
+ let i = 0;
25
+ while (i < lines.length) {
26
+ if (lines[i].startsWith("<<<<<<<")) {
27
+ const startLine = i + 1;
28
+ const oursLines = [];
29
+ const theirsLines = [];
30
+ let inTheirs = false;
31
+ i++;
32
+ while (i < lines.length) {
33
+ const line = lines[i];
34
+ if (line.startsWith("=======")) {
35
+ inTheirs = true;
36
+ }
37
+ else if (line.startsWith(">>>>>>>")) {
38
+ break;
39
+ }
40
+ else if (inTheirs) {
41
+ theirsLines.push(line);
42
+ }
43
+ else {
44
+ oursLines.push(line);
45
+ }
46
+ i++;
47
+ }
48
+ sections.push({
49
+ ours: oursLines.join("\n"),
50
+ theirs: theirsLines.join("\n"),
51
+ startLine,
52
+ });
53
+ }
54
+ i++;
55
+ }
56
+ return sections;
57
+ }
17
58
  /**
18
59
  * Build a Claude prompt to resolve merge conflicts.
19
60
  */
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Main branch dev server manager.
3
- * Runs a dev server from the project root for side-by-side comparison preview.
3
+ * Creates a git worktree from origin's default branch and runs a dev server
4
+ * for side-by-side comparison with the camp's changes.
4
5
  */
5
6
  import type { SanjangConfig } from "../types.ts";
6
7
  interface MainServerState {
@@ -10,6 +11,9 @@ interface MainServerState {
10
11
  }
11
12
  export declare function getMainServerState(): MainServerState;
12
13
  export declare function getMainServerLogs(): string[];
13
- export declare function startMainServer(projectRoot: string, config: SanjangConfig, onReady?: (port: number) => void): Promise<void>;
14
+ export declare function startMainServer(projectRoot: string, config: SanjangConfig, callbacks?: {
15
+ onReady?: (port: number) => void;
16
+ onLog?: (msg: string) => void;
17
+ }): Promise<void>;
14
18
  export declare function stopMainServer(): void;
15
19
  export {};
@@ -1,111 +1,181 @@
1
1
  /**
2
2
  * Main branch dev server manager.
3
- * Runs a dev server from the project root for side-by-side comparison preview.
3
+ * Creates a git worktree from origin's default branch and runs a dev server
4
+ * for side-by-side comparison with the camp's changes.
4
5
  */
5
- import { spawn } from "node:child_process";
6
- import { createConnection } from "node:net";
6
+ import { spawn, spawnSync } from "node:child_process";
7
+ import { existsSync, mkdirSync, rmSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { buildDevCommand, detectPortFromLogs, killProcessGroup } from "./process-utils.js";
7
10
  let state = { status: "stopped", port: null, error: null };
8
11
  let proc = null;
9
12
  let logs = [];
13
+ let worktreePath = null;
14
+ let savedProjectRoot = null;
10
15
  export function getMainServerState() {
11
16
  return { ...state };
12
17
  }
13
18
  export function getMainServerLogs() {
14
19
  return [...logs];
15
20
  }
16
- export async function startMainServer(projectRoot, config, onReady) {
17
- if (state.status === "running" || state.status === "starting")
18
- return;
19
- state = { status: "starting", port: null, error: null };
20
- logs = [];
21
- const basePort = config.dev.port + 100;
22
- const cmdParts = config.dev.command.split(/\s+/);
23
- const cmd = cmdParts[0];
24
- const args = cmdParts.slice(1);
25
- if (config.dev.portFlag) {
26
- args.push(config.dev.portFlag, String(basePort));
27
- }
28
- const cwd = config.dev.cwd
29
- ? config.dev.cwd.startsWith("/")
30
- ? config.dev.cwd
31
- : `${projectRoot}/${config.dev.cwd}`
32
- : projectRoot;
33
- proc = spawn(cmd, args, {
34
- cwd,
35
- stdio: ["ignore", "pipe", "pipe"],
36
- env: { ...process.env, ...config.dev.env, FORCE_COLOR: "0" },
37
- shell: true,
38
- });
39
- proc.stdout?.on("data", (chunk) => {
40
- const line = chunk.toString();
41
- logs.push(line);
42
- if (logs.length > 100)
43
- logs.shift();
44
- });
45
- proc.stderr?.on("data", (chunk) => {
46
- const line = chunk.toString();
47
- logs.push(line);
48
- if (logs.length > 100)
49
- logs.shift();
21
+ /** Detect the default branch from origin (main, master, dev, etc.) */
22
+ function detectDefaultBranch(projectRoot) {
23
+ const headRef = spawnSync("git", ["-C", projectRoot, "symbolic-ref", "refs/remotes/origin/HEAD"], {
24
+ encoding: "utf8",
25
+ stdio: "pipe",
50
26
  });
51
- proc.on("close", (code) => {
52
- if (state.status !== "stopped") {
53
- state = { status: "stopped", port: null, error: code ? `exit ${code}` : null };
27
+ if (headRef.status === 0 && headRef.stdout.trim()) {
28
+ return headRef.stdout.trim().replace("refs/remotes/origin/", "");
29
+ }
30
+ for (const name of ["main", "master", "dev", "develop"]) {
31
+ const check = spawnSync("git", ["-C", projectRoot, "rev-parse", "--verify", `origin/${name}`], {
32
+ encoding: "utf8",
33
+ stdio: "pipe",
34
+ });
35
+ if (check.status === 0)
36
+ return name;
37
+ }
38
+ return "main";
39
+ }
40
+ /** Create a git worktree for the comparison server */
41
+ function ensureWorktree(projectRoot, branch) {
42
+ const campsDir = join(projectRoot, ".sanjang", "camps");
43
+ const wtPath = join(campsDir, "__main__");
44
+ if (existsSync(wtPath)) {
45
+ spawnSync("git", ["-C", projectRoot, "worktree", "remove", "--force", wtPath], { stdio: "pipe" });
46
+ if (existsSync(wtPath)) {
47
+ rmSync(wtPath, { recursive: true, force: true });
54
48
  }
55
- proc = null;
49
+ }
50
+ const hasOrigin = spawnSync("git", ["-C", projectRoot, "remote"], {
51
+ encoding: "utf8",
52
+ stdio: "pipe",
53
+ }).stdout.trim().includes("origin");
54
+ let ref;
55
+ if (hasOrigin) {
56
+ spawnSync("git", ["-C", projectRoot, "fetch", "origin", branch], { stdio: "pipe" });
57
+ ref = `origin/${branch}`;
58
+ }
59
+ else {
60
+ ref = branch;
61
+ }
62
+ if (!existsSync(campsDir))
63
+ mkdirSync(campsDir, { recursive: true });
64
+ const result = spawnSync("git", ["-C", projectRoot, "worktree", "add", "--detach", wtPath, ref], {
65
+ encoding: "utf8",
66
+ stdio: "pipe",
56
67
  });
57
- proc.on("error", (err) => {
58
- state = { status: "error", port: null, error: err.message };
59
- proc = null;
68
+ if (result.status !== 0) {
69
+ throw new Error(`worktree 생성 실패: ${result.stderr?.trim() || "unknown error"}`);
70
+ }
71
+ return wtPath;
72
+ }
73
+ function runSetup(wtPath, config, onLog) {
74
+ if (!config.setup)
75
+ return;
76
+ const setupCwd = config.dev.cwd ? join(wtPath, config.dev.cwd) : wtPath;
77
+ onLog(`의존성 설치 중: ${config.setup}`);
78
+ const result = spawnSync(config.setup, [], {
79
+ cwd: setupCwd,
80
+ shell: true,
81
+ stdio: "pipe",
82
+ encoding: "utf8",
83
+ timeout: 120_000,
60
84
  });
61
- const detectedPort = await detectMainPort(logs, basePort, 15_000);
62
- if (detectedPort) {
63
- state = { status: "running", port: detectedPort, error: null };
64
- onReady?.(detectedPort);
85
+ if (result.status !== 0) {
86
+ onLog(`⚠️ setup 실패 (exit ${result.status}), 계속 진행합니다`);
65
87
  }
66
88
  else {
67
- state = { status: "error", port: null, error: "포트를 감지하지 못했어요" };
89
+ onLog("의존성 설치 완료 ");
90
+ }
91
+ }
92
+ export async function startMainServer(projectRoot, config, callbacks) {
93
+ if (state.status === "running" || state.status === "starting")
94
+ return;
95
+ const log = callbacks?.onLog ?? (() => { });
96
+ state = { status: "starting", port: null, error: null };
97
+ logs = [];
98
+ savedProjectRoot = projectRoot;
99
+ try {
100
+ const branch = detectDefaultBranch(projectRoot);
101
+ log(`비교 기준: origin/${branch}`);
102
+ log("원본 소스 준비 중...");
103
+ const wtPath = ensureWorktree(projectRoot, branch);
104
+ worktreePath = wtPath;
105
+ runSetup(wtPath, config, log);
106
+ const basePort = config.dev.port + 100;
107
+ const fullCommand = buildDevCommand(config.dev.command, config.dev.portFlag, basePort);
108
+ const cwd = config.dev.cwd ? join(wtPath, config.dev.cwd) : wtPath;
109
+ log(`dev 서버 시작: ${fullCommand}`);
110
+ proc = spawn(fullCommand, [], {
111
+ cwd,
112
+ stdio: ["ignore", "pipe", "pipe"],
113
+ env: { ...process.env, ...config.dev.env, FORCE_COLOR: "0", NO_COLOR: "1" },
114
+ shell: true,
115
+ detached: true,
116
+ });
117
+ proc.stdout?.on("data", (chunk) => {
118
+ const line = chunk.toString();
119
+ logs.push(line);
120
+ if (logs.length > 100)
121
+ logs.shift();
122
+ });
123
+ proc.stderr?.on("data", (chunk) => {
124
+ const line = chunk.toString();
125
+ logs.push(line);
126
+ if (logs.length > 100)
127
+ logs.shift();
128
+ });
129
+ proc.on("close", (code) => {
130
+ if (state.status !== "stopped") {
131
+ state = { status: "stopped", port: null, error: code ? `exit ${code}` : null };
132
+ }
133
+ proc = null;
134
+ });
135
+ proc.on("error", (err) => {
136
+ state = { status: "error", port: null, error: err.message };
137
+ proc = null;
138
+ });
139
+ const detectedPort = await detectPortFromLogs(logs, 60_000);
140
+ if (detectedPort) {
141
+ state = { status: "running", port: detectedPort, error: null };
142
+ log(`비교 서버 준비 완료 ✓ (origin/${branch}, :${detectedPort})`);
143
+ callbacks?.onReady?.(detectedPort);
144
+ }
145
+ else {
146
+ if (proc) {
147
+ killProcessGroup(proc);
148
+ proc = null;
149
+ }
150
+ const lastLog = logs.slice(-5).join("\n").trim();
151
+ state = { status: "error", port: null, error: lastLog || "포트를 감지하지 못했어요" };
152
+ }
153
+ }
154
+ catch (err) {
155
+ const message = err instanceof Error ? err.message : String(err);
156
+ state = { status: "error", port: null, error: message };
157
+ log(`❌ 비교 서버 시작 실패: ${message}`);
68
158
  }
69
159
  }
70
160
  export function stopMainServer() {
71
161
  state = { status: "stopped", port: null, error: null };
72
162
  if (proc) {
73
- proc.kill("SIGTERM");
163
+ killProcessGroup(proc);
74
164
  proc = null;
75
165
  }
76
166
  logs = [];
77
- }
78
- function detectMainPort(logLines, _fallbackPort, timeoutMs) {
79
- return new Promise((resolve) => {
80
- const deadline = Date.now() + timeoutMs;
81
- const portRe = /https?:\/\/localhost:(\d+)/;
82
- function check() {
83
- for (const line of logLines) {
84
- const match = portRe.exec(line);
85
- if (match?.[1]) {
86
- const port = parseInt(match[1], 10);
87
- const sock = createConnection({ port, host: "localhost" });
88
- sock.once("connect", () => {
89
- sock.destroy();
90
- resolve(port);
91
- });
92
- sock.once("error", () => {
93
- sock.destroy();
94
- if (Date.now() < deadline)
95
- setTimeout(check, 1000);
96
- else
97
- resolve(port);
98
- });
99
- return;
100
- }
101
- }
102
- if (Date.now() >= deadline) {
103
- resolve(null);
104
- }
105
- else {
106
- setTimeout(check, 1000);
167
+ if (worktreePath && existsSync(worktreePath) && savedProjectRoot) {
168
+ try {
169
+ spawnSync("git", ["-C", savedProjectRoot, "worktree", "remove", "--force", worktreePath], {
170
+ stdio: "pipe",
171
+ });
172
+ }
173
+ catch {
174
+ try {
175
+ rmSync(worktreePath, { recursive: true, force: true });
107
176
  }
177
+ catch { }
108
178
  }
109
- check();
110
- });
179
+ worktreePath = null;
180
+ }
111
181
  }
@@ -4,6 +4,6 @@ export declare function portsForSlot(slot: number): {
4
4
  fePort: number;
5
5
  bePort: number;
6
6
  };
7
- export declare function scanPorts(): PortStatus[];
8
- export declare function allocate(existingCamps: Pick<Camp, "slot">[]): PortAllocation;
7
+ export declare function scanPorts(): Promise<PortStatus[]>;
8
+ export declare function allocate(existingCamps: Pick<Camp, "slot" | "fePort" | "bePort">[]): Promise<PortAllocation>;
9
9
  export declare function release(_name: string): void;
@@ -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
  }