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.
- package/dashboard/app.js +572 -26
- package/dashboard/index.html +136 -37
- package/dashboard/style.css +293 -4
- package/dist/bin/sanjang.js +144 -1
- package/dist/lib/config.d.ts +5 -0
- package/dist/lib/config.js +26 -0
- package/dist/lib/engine/conflict.d.ts +13 -0
- package/dist/lib/engine/conflict.js +41 -0
- package/dist/lib/engine/main-server.d.ts +6 -2
- package/dist/lib/engine/main-server.js +152 -82
- package/dist/lib/engine/ports.d.ts +2 -2
- package/dist/lib/engine/ports.js +33 -23
- package/dist/lib/engine/process-utils.d.ts +11 -0
- package/dist/lib/engine/process-utils.js +65 -0
- package/dist/lib/engine/process.d.ts +2 -0
- package/dist/lib/engine/process.js +27 -42
- package/dist/lib/engine/state.js +13 -4
- package/dist/lib/engine/worktree.d.ts +2 -0
- package/dist/lib/engine/worktree.js +30 -8
- package/dist/lib/server.d.ts +1 -0
- package/dist/lib/server.js +496 -49
- package/dist/lib/types.d.ts +6 -0
- package/package.json +7 -4
|
@@ -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
|
-
//
|
|
15
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
package/dist/lib/engine/state.js
CHANGED
|
@@ -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
|
-
|
|
24
|
+
if (!existsSync(f)) {
|
|
25
|
+
_stateCache = [];
|
|
26
|
+
return _stateCache;
|
|
27
|
+
}
|
|
22
28
|
try {
|
|
23
|
-
|
|
29
|
+
_stateCache = JSON.parse(readFileSync(f, "utf8"));
|
|
30
|
+
return _stateCache;
|
|
24
31
|
}
|
|
25
32
|
catch {
|
|
26
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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];
|
package/dist/lib/server.d.ts
CHANGED
|
@@ -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>;
|