sanjang 0.3.0 → 0.3.2

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.
Files changed (66) hide show
  1. package/dist/bin/sanjang.d.ts +1 -0
  2. package/dist/bin/sanjang.js +138 -0
  3. package/dist/lib/config.d.ts +19 -0
  4. package/dist/lib/config.js +318 -0
  5. package/dist/lib/engine/cache.d.ts +7 -0
  6. package/dist/lib/engine/cache.js +183 -0
  7. package/dist/lib/engine/config-hotfix.d.ts +7 -0
  8. package/dist/lib/engine/config-hotfix.js +129 -0
  9. package/dist/lib/engine/conflict.d.ts +12 -0
  10. package/dist/lib/engine/conflict.js +32 -0
  11. package/dist/lib/engine/diagnostics.d.ts +15 -0
  12. package/dist/lib/engine/diagnostics.js +58 -0
  13. package/dist/lib/engine/naming.d.ts +10 -0
  14. package/dist/lib/engine/naming.js +83 -0
  15. package/dist/lib/engine/ports.d.ts +9 -0
  16. package/dist/lib/engine/ports.js +55 -0
  17. package/dist/lib/engine/pr.d.ts +27 -0
  18. package/dist/lib/engine/pr.js +54 -0
  19. package/dist/lib/engine/process.d.ts +15 -0
  20. package/dist/lib/engine/process.js +250 -0
  21. package/dist/lib/engine/self-heal.d.ts +12 -0
  22. package/dist/lib/engine/self-heal.js +98 -0
  23. package/dist/lib/engine/smart-init.d.ts +7 -0
  24. package/dist/lib/engine/smart-init.js +138 -0
  25. package/dist/lib/engine/smart-pr.d.ts +19 -0
  26. package/dist/lib/engine/smart-pr.js +105 -0
  27. package/dist/lib/engine/snapshot.d.ts +10 -0
  28. package/dist/lib/engine/snapshot.js +35 -0
  29. package/dist/lib/engine/state.d.ts +7 -0
  30. package/dist/lib/engine/state.js +53 -0
  31. package/dist/lib/engine/suggest.d.ts +21 -0
  32. package/dist/lib/engine/suggest.js +121 -0
  33. package/dist/lib/engine/warp.d.ts +23 -0
  34. package/dist/lib/engine/warp.js +32 -0
  35. package/dist/lib/engine/watcher.d.ts +11 -0
  36. package/dist/lib/engine/watcher.js +43 -0
  37. package/dist/lib/engine/worktree.d.ts +13 -0
  38. package/dist/lib/engine/worktree.js +91 -0
  39. package/dist/lib/server.d.ts +20 -0
  40. package/dist/lib/server.js +1399 -0
  41. package/dist/lib/types.d.ts +109 -0
  42. package/dist/lib/types.js +2 -0
  43. package/package.json +5 -5
  44. package/bin/__tests__/sanjang.test.ts +0 -42
  45. package/bin/sanjang.js +0 -17
  46. package/bin/sanjang.ts +0 -144
  47. package/lib/config.ts +0 -337
  48. package/lib/engine/cache.ts +0 -218
  49. package/lib/engine/config-hotfix.ts +0 -161
  50. package/lib/engine/conflict.ts +0 -33
  51. package/lib/engine/diagnostics.ts +0 -81
  52. package/lib/engine/naming.ts +0 -93
  53. package/lib/engine/ports.ts +0 -61
  54. package/lib/engine/pr.ts +0 -71
  55. package/lib/engine/process.ts +0 -283
  56. package/lib/engine/self-heal.ts +0 -130
  57. package/lib/engine/smart-init.ts +0 -136
  58. package/lib/engine/smart-pr.ts +0 -130
  59. package/lib/engine/snapshot.ts +0 -45
  60. package/lib/engine/state.ts +0 -60
  61. package/lib/engine/suggest.ts +0 -169
  62. package/lib/engine/warp.ts +0 -47
  63. package/lib/engine/watcher.ts +0 -40
  64. package/lib/engine/worktree.ts +0 -100
  65. package/lib/server.ts +0 -1560
  66. package/lib/types.ts +0 -130
@@ -1,130 +0,0 @@
1
- /**
2
- * Smart PR description generator.
3
- *
4
- * Uses `claude -p` to generate a human-readable PR description from the diff.
5
- * Falls back to a simple file-count summary when the CLI is unavailable.
6
- */
7
-
8
- import { spawn, spawnSync } from "node:child_process";
9
-
10
- const TIMEOUT_MS = 30_000;
11
-
12
- interface SpawnResult {
13
- stdout: string;
14
- stderr: string;
15
- code: number | null;
16
- timedOut: boolean;
17
- }
18
-
19
- /**
20
- * Run a command asynchronously with a timeout.
21
- */
22
- function run(cmd: string, args: string[], opts: { cwd: string; timeoutMs: number }): Promise<SpawnResult> {
23
- return new Promise((resolve) => {
24
- let stdout = "";
25
- let stderr = "";
26
- let timedOut = false;
27
-
28
- const child = spawn(cmd, args, {
29
- cwd: opts.cwd,
30
- stdio: ["ignore", "pipe", "pipe"],
31
- env: { ...process.env, FORCE_COLOR: "0" },
32
- });
33
-
34
- const timer = setTimeout(() => {
35
- timedOut = true;
36
- child.kill("SIGTERM");
37
- }, opts.timeoutMs);
38
-
39
- child.stdout.on("data", (chunk: Buffer) => {
40
- stdout += chunk.toString();
41
- });
42
- child.stderr.on("data", (chunk: Buffer) => {
43
- stderr += chunk.toString();
44
- });
45
-
46
- child.on("close", (code: number | null) => {
47
- clearTimeout(timer);
48
- resolve({ stdout, stderr, code, timedOut });
49
- });
50
-
51
- child.on("error", () => {
52
- clearTimeout(timer);
53
- resolve({ stdout, stderr, code: null, timedOut });
54
- });
55
- });
56
- }
57
-
58
- /**
59
- * Parse diff --stat output to extract file count.
60
- * Example line: " 3 files changed, 10 insertions(+), 2 deletions(-)"
61
- */
62
- export function parseDiffStatSummary(diffStat: string): string {
63
- const trimmed = diffStat.trim();
64
- if (!trimmed) return "변경사항이 없어요";
65
-
66
- const lines = trimmed.split("\n");
67
- const summaryLine = lines[lines.length - 1] ?? "";
68
- const filesMatch = /(\d+)\s+files?\s+changed/.exec(summaryLine);
69
- const insertMatch = /(\d+)\s+insertions?/.exec(summaryLine);
70
- const deleteMatch = /(\d+)\s+deletions?/.exec(summaryLine);
71
-
72
- const fileCount = filesMatch ? parseInt(filesMatch[1]!, 10) : 0;
73
- const insertions = insertMatch ? parseInt(insertMatch[1]!, 10) : 0;
74
- const deletions = deleteMatch ? parseInt(deleteMatch[1]!, 10) : 0;
75
-
76
- if (fileCount === 0) return "변경사항이 없어요";
77
-
78
- const parts: string[] = [`${fileCount}개 파일을 수정했어요`];
79
- if (insertions > 0 || deletions > 0) {
80
- parts.push(`(+${insertions}, -${deletions})`);
81
- }
82
- return parts.join(" ");
83
- }
84
-
85
- /**
86
- * Check if claude CLI is available on PATH.
87
- */
88
- function isClaudeAvailable(): boolean {
89
- const result = spawnSync("which", ["claude"], { stdio: "pipe" });
90
- return result.status === 0;
91
- }
92
-
93
- /**
94
- * Generate a PR description for the given worktree path.
95
- *
96
- * 1. Runs `git diff --stat` and `git diff` in the worktree.
97
- * 2. If claude CLI is available, asks it to summarise in Korean.
98
- * 3. Falls back to a simple file-count summary otherwise.
99
- */
100
- export async function generatePrDescription(wtPath: string): Promise<string> {
101
- // Gather diff info
102
- const [statResult, diffResult] = await Promise.all([
103
- run("git", ["diff", "--stat", "HEAD"], { cwd: wtPath, timeoutMs: 10_000 }),
104
- run("git", ["diff", "HEAD"], { cwd: wtPath, timeoutMs: 10_000 }),
105
- ]);
106
-
107
- const diffStat = statResult.stdout.trim();
108
- const diff = diffResult.stdout;
109
-
110
- if (!diffStat && !diff.trim()) {
111
- return "변경사항이 없어요";
112
- }
113
-
114
- // Try claude CLI
115
- if (isClaudeAvailable()) {
116
- const diffSnippet = diff.slice(0, 500);
117
- const prompt = `이 변경사항을 비개발자도 이해할 수 있게 한국어 2-3줄로 설명해줘:\n\n${diffStat}\n\n${diffSnippet}`;
118
- const claudeResult = await run("claude", ["-p", prompt, "--output-format", "text"], {
119
- cwd: wtPath,
120
- timeoutMs: TIMEOUT_MS,
121
- });
122
-
123
- if (claudeResult.code === 0 && claudeResult.stdout.trim()) {
124
- return claudeResult.stdout.trim();
125
- }
126
- }
127
-
128
- // Fallback
129
- return parseDiffStatSummary(diffStat);
130
- }
@@ -1,45 +0,0 @@
1
- import { simpleGit } from "simple-git";
2
- import { campPath } from "./worktree.ts";
3
-
4
- const STASH_PREFIX = "sanjang-snapshot:";
5
-
6
- interface StashEntry {
7
- index: number;
8
- message: string;
9
- isSanjangSnapshot: boolean;
10
- date: string;
11
- }
12
-
13
- export async function saveSnapshot(name: string, label: string): Promise<void> {
14
- const git = simpleGit(campPath(name));
15
- await git.raw(["stash", "push", "--include-untracked", "-m", `${STASH_PREFIX}${label}`]);
16
- }
17
-
18
- export async function restoreSnapshot(name: string, index: number): Promise<void> {
19
- const git = simpleGit(campPath(name));
20
- await git.raw(["checkout", "--", "."]).catch(() => {});
21
- await git.raw(["clean", "-fd"]).catch(() => {});
22
- await git.raw(["stash", "apply", `stash@{${index}}`]);
23
- }
24
-
25
- export async function listSnapshots(name: string): Promise<StashEntry[]> {
26
- const git = simpleGit(campPath(name));
27
- try {
28
- const result = await git.raw(["stash", "list", "--format=%gd|%s|%ci"]);
29
- if (!result?.trim()) return [];
30
-
31
- return result
32
- .trim()
33
- .split("\n")
34
- .map((line: string) => {
35
- const [ref, message, date] = line.split("|");
36
- const match = ref?.match(/stash@\{(\d+)\}/);
37
- const index = match ? parseInt(match[1]!, 10) : 0;
38
- const isSanjangSnapshot = message ? message.includes(STASH_PREFIX) : false;
39
- return { index, message: message || "", isSanjangSnapshot, date: date || "" };
40
- })
41
- .filter((entry: StashEntry) => entry.isSanjangSnapshot);
42
- } catch {
43
- return [];
44
- }
45
- }
@@ -1,60 +0,0 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import type { Camp } from "../types.ts";
4
-
5
- let campsDir: string | null = null;
6
-
7
- export function setCampsDir(dir: string): void {
8
- campsDir = dir;
9
- }
10
-
11
- export function getCampsDir(): string {
12
- if (!campsDir) throw new Error("campsDir not initialized. Call setCampsDir() first.");
13
- return campsDir;
14
- }
15
-
16
- function stateFile(): string {
17
- return join(getCampsDir(), "state.json");
18
- }
19
-
20
- function ensureDir(): void {
21
- mkdirSync(getCampsDir(), { recursive: true });
22
- }
23
-
24
- function read(): Camp[] {
25
- const f = stateFile();
26
- if (!existsSync(f)) return [];
27
- try {
28
- return JSON.parse(readFileSync(f, "utf8"));
29
- } catch {
30
- return [];
31
- }
32
- }
33
-
34
- function write(records: Camp[]): void {
35
- ensureDir();
36
- // Atomic write: write to temp file then rename to prevent corruption
37
- const tmp = stateFile() + ".tmp";
38
- writeFileSync(tmp, JSON.stringify(records, null, 2), "utf8");
39
- renameSync(tmp, stateFile());
40
- }
41
-
42
- export function getAll(): Camp[] {
43
- return read();
44
- }
45
-
46
- export function getOne(name: string): Camp | null {
47
- return read().find((r) => r.name === name) ?? null;
48
- }
49
-
50
- export function upsert(record: Camp): void {
51
- const records = read();
52
- const idx = records.findIndex((r) => r.name === record.name);
53
- if (idx === -1) records.push(record);
54
- else records[idx] = record;
55
- write(records);
56
- }
57
-
58
- export function remove(name: string): void {
59
- write(read().filter((r) => r.name !== name));
60
- }
@@ -1,169 +0,0 @@
1
- /**
2
- * Task suggestion engine.
3
- *
4
- * Aggregates open issues, PRs, and recent git activity to surface
5
- * actionable suggestions on the dashboard — no LLM required.
6
- */
7
-
8
- import { spawn } from "node:child_process";
9
-
10
- // ---------------------------------------------------------------------------
11
- // Types
12
- // ---------------------------------------------------------------------------
13
-
14
- export interface Suggestion {
15
- type: "issue" | "pr" | "recent";
16
- title: string;
17
- detail?: string;
18
- action?: string; // e.g., branch name to create camp from
19
- }
20
-
21
- interface GhIssue {
22
- number: number;
23
- title: string;
24
- labels: Array<{ name: string }>;
25
- }
26
-
27
- interface GhPr {
28
- number: number;
29
- title: string;
30
- headRefName: string;
31
- }
32
-
33
- // ---------------------------------------------------------------------------
34
- // Helpers
35
- // ---------------------------------------------------------------------------
36
-
37
- const TIMEOUT_MS = 10_000;
38
-
39
- /**
40
- * Async spawn wrapper — resolves with stdout or rejects on timeout / error.
41
- */
42
- function run(cmd: string, args: string[], cwd: string): Promise<string> {
43
- return new Promise((resolve, reject) => {
44
- const child = spawn(cmd, args, {
45
- cwd,
46
- stdio: ["ignore", "pipe", "pipe"],
47
- env: { ...process.env },
48
- });
49
-
50
- let stdout = "";
51
- let stderr = "";
52
-
53
- child.stdout.on("data", (chunk: Buffer) => {
54
- stdout += chunk.toString();
55
- });
56
- child.stderr.on("data", (chunk: Buffer) => {
57
- stderr += chunk.toString();
58
- });
59
-
60
- const timer = setTimeout(() => {
61
- child.kill("SIGTERM");
62
- reject(new Error(`Command timed out: ${cmd} ${args.join(" ")}`));
63
- }, TIMEOUT_MS);
64
-
65
- child.on("close", (code) => {
66
- clearTimeout(timer);
67
- if (code === 0) resolve(stdout);
68
- else reject(new Error(`Exit ${code}: ${stderr || stdout}`));
69
- });
70
-
71
- child.on("error", (err) => {
72
- clearTimeout(timer);
73
- reject(err);
74
- });
75
- });
76
- }
77
-
78
- // ---------------------------------------------------------------------------
79
- // Data fetchers
80
- // ---------------------------------------------------------------------------
81
-
82
- async function fetchIssues(cwd: string): Promise<Suggestion[]> {
83
- const raw = await run(
84
- "gh",
85
- ["issue", "list", "--state", "open", "--limit", "5", "--json", "number,title,labels"],
86
- cwd,
87
- );
88
- const issues: GhIssue[] = JSON.parse(raw);
89
- return issues.map((i) => {
90
- const labelStr = i.labels.map((l) => l.name).join(", ");
91
- return {
92
- type: "issue" as const,
93
- title: `#${i.number} ${i.title}`,
94
- detail: labelStr || undefined,
95
- };
96
- });
97
- }
98
-
99
- async function fetchMyPrs(cwd: string): Promise<Suggestion[]> {
100
- const raw = await run(
101
- "gh",
102
- ["pr", "list", "--state", "open", "--author", "@me", "--limit", "3", "--json", "number,title,headRefName"],
103
- cwd,
104
- );
105
- const prs: GhPr[] = JSON.parse(raw);
106
- return prs.map((p) => ({
107
- type: "pr" as const,
108
- title: `#${p.number} ${p.title}`,
109
- detail: p.headRefName,
110
- action: p.headRefName,
111
- }));
112
- }
113
-
114
- async function fetchRecentCommits(cwd: string): Promise<Suggestion[]> {
115
- const raw = await run("git", ["log", "--oneline", "-10"], cwd);
116
- const lines = raw.trim().split("\n").filter(Boolean);
117
- return lines.map((line) => {
118
- const spaceIdx = line.indexOf(" ");
119
- const hash = spaceIdx > 0 ? line.slice(0, spaceIdx) : line;
120
- const msg = spaceIdx > 0 ? line.slice(spaceIdx + 1) : "";
121
- return {
122
- type: "recent" as const,
123
- title: msg || hash,
124
- detail: hash,
125
- };
126
- });
127
- }
128
-
129
- // ---------------------------------------------------------------------------
130
- // Public API
131
- // ---------------------------------------------------------------------------
132
-
133
- /**
134
- * Suggest tasks the user might work on next.
135
- *
136
- * Aggregates data from GitHub (issues, PRs) and git (recent commits).
137
- * If `gh` CLI is unavailable, returns git-based suggestions only.
138
- *
139
- * Results are sorted by relevance: PRs (이어하기) > Issues (이슈) > Recent (최근 작업).
140
- */
141
- export async function suggestTasks(projectRoot: string): Promise<Suggestion[]> {
142
- const results: Suggestion[] = [];
143
-
144
- // gh-dependent fetches — tolerate failure (gh not installed / no repo)
145
- const [issues, prs] = await Promise.allSettled([
146
- fetchIssues(projectRoot),
147
- fetchMyPrs(projectRoot),
148
- ]);
149
-
150
- // PRs first — most actionable ("이어하기")
151
- if (prs.status === "fulfilled") {
152
- results.push(...prs.value);
153
- }
154
-
155
- // Issues next ("이슈")
156
- if (issues.status === "fulfilled") {
157
- results.push(...issues.value);
158
- }
159
-
160
- // Recent commits always available ("최근 작업")
161
- try {
162
- const recent = await fetchRecentCommits(projectRoot);
163
- results.push(...recent);
164
- } catch {
165
- // No git history — return whatever we have
166
- }
167
-
168
- return results;
169
- }
@@ -1,47 +0,0 @@
1
- import { spawnSync } from "node:child_process";
2
- import { existsSync } from "node:fs";
3
-
4
- interface WarpDetectResult {
5
- installed: boolean;
6
- }
7
-
8
- interface WarpOpenResult {
9
- opened: boolean;
10
- terminal: string | null;
11
- path?: string;
12
- }
13
-
14
- /**
15
- * Detect if Warp terminal is installed.
16
- */
17
- export function detectWarp(): WarpDetectResult {
18
- const installed = existsSync("/Applications/Warp.app");
19
- return { installed };
20
- }
21
-
22
- /**
23
- * Open a Warp tab at the given worktree path.
24
- * Opens as a new tab in the existing Warp window (not a new window).
25
- * The tab title naturally shows the directory name (= camp name).
26
- */
27
- export function openWarpTab(campName: string, worktreePath: string): WarpOpenResult {
28
- const { installed } = detectWarp();
29
- if (!installed) {
30
- return { opened: false, terminal: null, path: worktreePath };
31
- }
32
-
33
- // open -a Warp {path} → opens tab in existing window with dir name as title
34
- const result = spawnSync("open", ["-a", "Warp", worktreePath], { stdio: "pipe" });
35
-
36
- return {
37
- opened: result.status === 0,
38
- terminal: "warp",
39
- };
40
- }
41
-
42
- /**
43
- * No-op cleanup (launch config removed — using open -a instead).
44
- */
45
- export function removeLaunchConfig(_campName: string): void {
46
- // Intentionally empty — kept for API compatibility with server.ts
47
- }
@@ -1,40 +0,0 @@
1
- import { watch, type FSWatcher } from "node:fs";
2
-
3
- export class CampWatcher {
4
- private watcher: FSWatcher | null = null;
5
- private timer: ReturnType<typeof setTimeout> | null = null;
6
- private stopped = false;
7
-
8
- constructor(
9
- private readonly dir: string,
10
- private readonly onChange: () => void,
11
- private readonly debounceMs: number = 500,
12
- ) {}
13
-
14
- start(): void {
15
- this.stopped = false;
16
- try {
17
- this.watcher = watch(this.dir, { recursive: true }, () => {
18
- if (this.stopped) return;
19
- if (this.timer) clearTimeout(this.timer);
20
- this.timer = setTimeout(() => {
21
- if (!this.stopped) this.onChange();
22
- }, this.debounceMs);
23
- });
24
- } catch {
25
- // fs.watch can fail on some platforms/dirs — silently degrade
26
- }
27
- }
28
-
29
- stop(): void {
30
- this.stopped = true;
31
- if (this.timer) {
32
- clearTimeout(this.timer);
33
- this.timer = null;
34
- }
35
- if (this.watcher) {
36
- this.watcher.close();
37
- this.watcher = null;
38
- }
39
- }
40
- }
@@ -1,100 +0,0 @@
1
- import { join } from "node:path";
2
- import { type SimpleGit, simpleGit } from "simple-git";
3
- import { getCampsDir } from "./state.ts";
4
-
5
- let projectRoot: string | null = null;
6
-
7
- export interface BranchInfo {
8
- name: string;
9
- remote: boolean;
10
- local: boolean;
11
- date: string;
12
- category?: "default" | "feature" | "fix" | "other";
13
- }
14
-
15
- export function setProjectRoot(root: string): void {
16
- projectRoot = root;
17
- }
18
-
19
- export function getProjectRoot(): string {
20
- if (!projectRoot) throw new Error("projectRoot not initialized. Call setProjectRoot() first.");
21
- return projectRoot;
22
- }
23
-
24
- export function campPath(name: string): string {
25
- return join(getCampsDir(), name);
26
- }
27
-
28
- function git(): SimpleGit {
29
- return simpleGit(getProjectRoot());
30
- }
31
-
32
- export async function listBranches(): Promise<BranchInfo[]> {
33
- // Best-effort fetch — continue with local refs on network failure
34
- try {
35
- await git().fetch(["--prune"]);
36
- } catch {
37
- /* offline is OK */
38
- }
39
-
40
- const raw = await git().raw([
41
- "for-each-ref",
42
- "--sort=-committerdate",
43
- "--format=%(refname:short)\t%(committerdate:relative)\t%(refname)",
44
- "refs/heads/",
45
- "refs/remotes/origin/",
46
- ]);
47
-
48
- const map = new Map<string, BranchInfo>();
49
- for (const line of raw.trim().split("\n")) {
50
- if (!line) continue;
51
- const [shortName, date, fullRef] = line.split("\t");
52
- if (!shortName || !fullRef) continue;
53
- if (shortName.includes("HEAD")) continue;
54
- const isRemote = fullRef.startsWith("refs/remotes/origin/");
55
- const clean = shortName.replace(/^origin\//, "").trim();
56
- if (!clean) continue;
57
-
58
- const entry: BranchInfo = map.get(clean) || { name: clean, remote: false, local: false, date: date ?? "" };
59
- if (isRemote) entry.remote = true;
60
- else entry.local = true;
61
- if (!entry.date) entry.date = date ?? "";
62
- map.set(clean, entry);
63
- }
64
-
65
- const branches = [...map.values()];
66
-
67
- for (const b of branches) {
68
- if (["dev", "main", "master"].includes(b.name)) {
69
- b.category = "default";
70
- } else if (b.name.startsWith("feature/")) {
71
- b.category = "feature";
72
- } else if (b.name.startsWith("fix/") || b.name.startsWith("hotfix/")) {
73
- b.category = "fix";
74
- } else {
75
- b.category = "other";
76
- }
77
- }
78
-
79
- return branches;
80
- }
81
-
82
- export async function addWorktree(name: string, branch: string): Promise<void> {
83
- const path = campPath(name);
84
- const refs = [`origin/${branch}`, branch];
85
- let lastErr: unknown;
86
- for (const ref of refs) {
87
- try {
88
- await git().raw(["worktree", "add", "--detach", path, ref]);
89
- return;
90
- } catch (err) {
91
- lastErr = err;
92
- }
93
- }
94
- throw lastErr;
95
- }
96
-
97
- export async function removeWorktree(name: string): Promise<void> {
98
- const path = campPath(name);
99
- await git().raw(["worktree", "remove", "--force", path]);
100
- }