crosspad-mcp-server 4.0.0

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 (80) hide show
  1. package/README.md +187 -0
  2. package/dist/config.d.ts +10 -0
  3. package/dist/config.js +33 -0
  4. package/dist/config.js.map +1 -0
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.js +360 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/tools/architecture.d.ts +16 -0
  9. package/dist/tools/architecture.js +198 -0
  10. package/dist/tools/architecture.js.map +1 -0
  11. package/dist/tools/build-check.d.ts +23 -0
  12. package/dist/tools/build-check.js +162 -0
  13. package/dist/tools/build-check.js.map +1 -0
  14. package/dist/tools/build.d.ts +14 -0
  15. package/dist/tools/build.js +101 -0
  16. package/dist/tools/build.js.map +1 -0
  17. package/dist/tools/diff-core.d.ts +24 -0
  18. package/dist/tools/diff-core.js +88 -0
  19. package/dist/tools/diff-core.js.map +1 -0
  20. package/dist/tools/idf-build.d.ts +10 -0
  21. package/dist/tools/idf-build.js +155 -0
  22. package/dist/tools/idf-build.js.map +1 -0
  23. package/dist/tools/input.d.ts +36 -0
  24. package/dist/tools/input.js +61 -0
  25. package/dist/tools/input.js.map +1 -0
  26. package/dist/tools/log.d.ts +16 -0
  27. package/dist/tools/log.js +49 -0
  28. package/dist/tools/log.js.map +1 -0
  29. package/dist/tools/repos.d.ts +12 -0
  30. package/dist/tools/repos.js +63 -0
  31. package/dist/tools/repos.js.map +1 -0
  32. package/dist/tools/scaffold.d.ts +15 -0
  33. package/dist/tools/scaffold.js +192 -0
  34. package/dist/tools/scaffold.js.map +1 -0
  35. package/dist/tools/screenshot.d.ts +24 -0
  36. package/dist/tools/screenshot.js +80 -0
  37. package/dist/tools/screenshot.js.map +1 -0
  38. package/dist/tools/settings.d.ts +25 -0
  39. package/dist/tools/settings.js +48 -0
  40. package/dist/tools/settings.js.map +1 -0
  41. package/dist/tools/stats.d.ts +18 -0
  42. package/dist/tools/stats.js +31 -0
  43. package/dist/tools/stats.js.map +1 -0
  44. package/dist/tools/symbols.d.ts +20 -0
  45. package/dist/tools/symbols.js +157 -0
  46. package/dist/tools/symbols.js.map +1 -0
  47. package/dist/tools/test.d.ts +24 -0
  48. package/dist/tools/test.js +227 -0
  49. package/dist/tools/test.js.map +1 -0
  50. package/dist/utils/exec.d.ts +58 -0
  51. package/dist/utils/exec.js +292 -0
  52. package/dist/utils/exec.js.map +1 -0
  53. package/dist/utils/git.d.ts +10 -0
  54. package/dist/utils/git.js +29 -0
  55. package/dist/utils/git.js.map +1 -0
  56. package/dist/utils/remote-client.d.ts +17 -0
  57. package/dist/utils/remote-client.js +94 -0
  58. package/dist/utils/remote-client.js.map +1 -0
  59. package/package.json +21 -0
  60. package/server.json +23 -0
  61. package/src/config.ts +45 -0
  62. package/src/index.ts +484 -0
  63. package/src/tools/architecture.ts +260 -0
  64. package/src/tools/build-check.ts +178 -0
  65. package/src/tools/build.ts +130 -0
  66. package/src/tools/diff-core.ts +130 -0
  67. package/src/tools/idf-build.ts +182 -0
  68. package/src/tools/input.ts +80 -0
  69. package/src/tools/log.ts +75 -0
  70. package/src/tools/repos.ts +75 -0
  71. package/src/tools/scaffold.ts +229 -0
  72. package/src/tools/screenshot.ts +100 -0
  73. package/src/tools/settings.ts +68 -0
  74. package/src/tools/stats.ts +38 -0
  75. package/src/tools/symbols.ts +185 -0
  76. package/src/tools/test.ts +264 -0
  77. package/src/utils/exec.ts +376 -0
  78. package/src/utils/git.ts +45 -0
  79. package/src/utils/remote-client.ts +107 -0
  80. package/tsconfig.json +16 -0
@@ -0,0 +1,260 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { REPOS } from "../config.js";
4
+ import { runCommand } from "../utils/exec.js";
5
+
6
+ // --- crosspad_interfaces ---
7
+
8
+ export interface InterfaceInfo {
9
+ name: string;
10
+ file: string;
11
+ }
12
+
13
+ export interface ImplementationInfo {
14
+ className: string;
15
+ file: string;
16
+ platform: string;
17
+ }
18
+
19
+ function findInterfaces(): InterfaceInfo[] {
20
+ const coreInclude = path.join(REPOS["crosspad-core"], "include", "crosspad");
21
+ const results: InterfaceInfo[] = [];
22
+
23
+ function scan(dir: string) {
24
+ if (!fs.existsSync(dir)) return;
25
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
26
+ const fullPath = path.join(dir, entry.name);
27
+ if (entry.isDirectory()) {
28
+ scan(fullPath);
29
+ } else if (entry.name.startsWith("I") && entry.name.endsWith(".hpp")) {
30
+ // Extract class name from file
31
+ const content = fs.readFileSync(fullPath, "utf-8");
32
+ const match = content.match(/class\s+(I[A-Z]\w+)\b/);
33
+ if (match) {
34
+ results.push({
35
+ name: match[1],
36
+ file: fullPath.replace(/\\/g, "/"),
37
+ });
38
+ }
39
+ }
40
+ }
41
+ }
42
+
43
+ scan(coreInclude);
44
+ return results;
45
+ }
46
+
47
+ function findImplementations(interfaceName: string): ImplementationInfo[] {
48
+ const results: ImplementationInfo[] = [];
49
+ const pattern = `class\\s+\\w+.*:\\s*(public\\s+)?.*${interfaceName}`;
50
+
51
+ const platformMap: Record<string, string> = {
52
+ "crosspad-core": "shared",
53
+ "crosspad-gui": "gui",
54
+ "crosspad-pc": "PC",
55
+ "ESP32-S3": "ESP32-S3",
56
+ "2playerCrosspad": "2player",
57
+ };
58
+
59
+ for (const [name, repoPath] of Object.entries(REPOS)) {
60
+ if (!fs.existsSync(repoPath)) continue;
61
+
62
+ const result = runCommand(
63
+ `git grep -n -E "${pattern}" -- "*.hpp" "*.cpp" "*.h"`,
64
+ repoPath
65
+ );
66
+
67
+ if (!result.success && result.stdout.length === 0) continue;
68
+
69
+ for (const line of result.stdout.split("\n")) {
70
+ if (!line.trim()) continue;
71
+ const colonIdx = line.indexOf(":");
72
+ if (colonIdx < 0) continue;
73
+
74
+ const filePart = line.slice(0, colonIdx);
75
+ const codePart = line.slice(colonIdx + 1);
76
+
77
+ // Extract the line number and code
78
+ const lineNumMatch = codePart.match(/^(\d+):(.*)/);
79
+ const code = lineNumMatch ? lineNumMatch[2] : codePart;
80
+
81
+ const classMatch = code.match(/class\s+(\w+)/);
82
+ if (classMatch) {
83
+ results.push({
84
+ className: classMatch[1],
85
+ file: path.join(repoPath, filePart).replace(/\\/g, "/"),
86
+ platform: platformMap[name] ?? name,
87
+ });
88
+ }
89
+ }
90
+ }
91
+
92
+ return results;
93
+ }
94
+
95
+ interface CapabilityInfo {
96
+ flags: string[];
97
+ platforms: Record<string, string[]>;
98
+ }
99
+
100
+ function queryCapabilities(): CapabilityInfo {
101
+ const capsFile = path.join(
102
+ REPOS["crosspad-core"],
103
+ "include",
104
+ "crosspad",
105
+ "platform",
106
+ "PlatformCapabilities.hpp"
107
+ );
108
+
109
+ // Parse enum flags
110
+ const flags: string[] = [];
111
+ if (fs.existsSync(capsFile)) {
112
+ const content = fs.readFileSync(capsFile, "utf-8");
113
+ const enumMatch = content.match(/enum\s+class\s+Capability[^{]*\{([^}]+)\}/s);
114
+ if (enumMatch) {
115
+ for (const line of enumMatch[1].split("\n")) {
116
+ const flagMatch = line.match(/\b(\w+)\s*=/);
117
+ if (flagMatch && flagMatch[1] !== "None" && flagMatch[1] !== "All") {
118
+ flags.push(flagMatch[1]);
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ // Find which platforms set which caps
125
+ const platforms: Record<string, string[]> = {};
126
+ const platformMap: Record<string, string> = {
127
+ "crosspad-pc": "PC",
128
+ "ESP32-S3": "ESP32-S3",
129
+ "2playerCrosspad": "2player",
130
+ };
131
+
132
+ for (const [name, repoPath] of Object.entries(REPOS)) {
133
+ if (!platformMap[name]) continue;
134
+ if (!fs.existsSync(repoPath)) continue;
135
+
136
+ const result = runCommand(
137
+ `git grep -h "addPlatformCapability\\|setPlatformCapabilities" -- "*.cpp" "*.hpp" "*.h"`,
138
+ repoPath
139
+ );
140
+
141
+ if (!result.success && result.stdout.length === 0) continue;
142
+
143
+ const caps: string[] = [];
144
+ for (const line of result.stdout.split("\n")) {
145
+ const matches = line.match(/Capability::(\w+)/g);
146
+ if (matches) {
147
+ for (const m of matches) {
148
+ const cap = m.replace("Capability::", "");
149
+ if (!caps.includes(cap)) caps.push(cap);
150
+ }
151
+ }
152
+ }
153
+ if (caps.length > 0) {
154
+ platforms[platformMap[name]] = caps;
155
+ }
156
+ }
157
+
158
+ return { flags, platforms };
159
+ }
160
+
161
+ export function crosspadInterfaces(
162
+ query: string
163
+ ): Record<string, unknown> {
164
+ const parts = query.trim().split(/\s+/);
165
+ const command = parts[0]?.toLowerCase();
166
+
167
+ if (command === "list") {
168
+ return { interfaces: findInterfaces() };
169
+ }
170
+
171
+ if (command === "implementations" && parts[1]) {
172
+ const interfaceName = parts[1];
173
+ const interfaces = findInterfaces();
174
+ const defined = interfaces.find((i) => i.name === interfaceName);
175
+ return {
176
+ interface: interfaceName,
177
+ defined_in: defined?.file ?? "not found",
178
+ implementations: findImplementations(interfaceName),
179
+ };
180
+ }
181
+
182
+ if (command === "capabilities") {
183
+ const caps = queryCapabilities();
184
+ return { flags: caps.flags, platforms: caps.platforms };
185
+ }
186
+
187
+ return {
188
+ error: `Unknown query: "${query}". Use "list", "implementations <InterfaceName>", or "capabilities".`,
189
+ };
190
+ }
191
+
192
+ // --- crosspad_apps ---
193
+
194
+ export interface AppInfo {
195
+ name: string;
196
+ registration_file: string;
197
+ platform: string;
198
+ }
199
+
200
+ export function crosspadApps(
201
+ platform: "pc" | "esp32" | "2player" | "all"
202
+ ): AppInfo[] {
203
+ const results: AppInfo[] = [];
204
+
205
+ const targets: [string, string][] = [];
206
+ if (platform === "pc" || platform === "all") targets.push(["PC", REPOS["crosspad-pc"]]);
207
+ if (platform === "esp32" || platform === "all") targets.push(["ESP32-S3", REPOS["ESP32-S3"]]);
208
+ if (platform === "2player" || platform === "all") targets.push(["2player", REPOS["2playerCrosspad"]]);
209
+
210
+ for (const [platName, repoPath] of targets) {
211
+ if (!fs.existsSync(repoPath)) continue;
212
+
213
+ // Search for REGISTER_APP and _register_*_app patterns
214
+ const result = runCommand(
215
+ `git grep -n -E "REGISTER_APP\\(|void _register_\\w+_app\\(\\)" -- "*.cpp"`,
216
+ repoPath
217
+ );
218
+
219
+ if (!result.success && result.stdout.length === 0) continue;
220
+
221
+ for (const line of result.stdout.split("\n")) {
222
+ if (!line.trim()) continue;
223
+
224
+ const colonIdx = line.indexOf(":");
225
+ if (colonIdx < 0) continue;
226
+ const filePart = line.slice(0, colonIdx);
227
+ const rest = line.slice(colonIdx + 1);
228
+
229
+ // REGISTER_APP(Name, ...)
230
+ let match = rest.match(/REGISTER_APP\((\w+)/);
231
+ if (match) {
232
+ results.push({
233
+ name: match[1],
234
+ registration_file: filePart,
235
+ platform: platName,
236
+ });
237
+ continue;
238
+ }
239
+
240
+ // void _register_Name_app()
241
+ match = rest.match(/_register_(\w+)_app\(/);
242
+ if (match) {
243
+ results.push({
244
+ name: match[1],
245
+ registration_file: filePart,
246
+ platform: platName,
247
+ });
248
+ }
249
+ }
250
+ }
251
+
252
+ // Deduplicate by name+platform
253
+ const seen = new Set<string>();
254
+ return results.filter((app) => {
255
+ const key = `${app.platform}:${app.name}`;
256
+ if (seen.has(key)) return false;
257
+ seen.add(key);
258
+ return true;
259
+ });
260
+ }
@@ -0,0 +1,178 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { CROSSPAD_PC_ROOT, BUILD_DIR, BIN_EXE, REPOS } from "../config.js";
4
+ import { runCommand } from "../utils/exec.js";
5
+ import { getHead, getSubmodulePin } from "../utils/git.js";
6
+
7
+ export interface BuildCheckResult {
8
+ needs_reconfigure: boolean;
9
+ needs_rebuild: boolean;
10
+ exe_exists: boolean;
11
+ exe_age_seconds: number | null;
12
+ reasons: string[];
13
+ submodule_changes: Record<string, { pinned: string | null; current: string | null; changed: boolean }>;
14
+ new_source_files: string[];
15
+ }
16
+
17
+ /**
18
+ * Detect whether cmake reconfigure or rebuild is needed.
19
+ * Checks:
20
+ * - Does build/ dir exist?
21
+ * - Does bin/main.exe exist?
22
+ * - Are there new .cpp/.hpp files not in CMakeCache?
23
+ * - Did crosspad-core or crosspad-gui HEAD change vs pinned?
24
+ * - Are there uncommitted changes in source?
25
+ */
26
+ export function crosspadBuildCheck(): BuildCheckResult {
27
+ const reasons: string[] = [];
28
+ let needsReconfigure = false;
29
+ let needsRebuild = false;
30
+
31
+ // Check build dir
32
+ const buildExists = fs.existsSync(BUILD_DIR);
33
+ if (!buildExists) {
34
+ needsReconfigure = true;
35
+ reasons.push("build/ directory does not exist — need full configure");
36
+ }
37
+
38
+ // Check exe
39
+ const exeExists = fs.existsSync(BIN_EXE);
40
+ let exeAgeSeconds: number | null = null;
41
+ if (exeExists) {
42
+ const stat = fs.statSync(BIN_EXE);
43
+ exeAgeSeconds = (Date.now() - stat.mtimeMs) / 1000;
44
+ } else {
45
+ needsRebuild = true;
46
+ reasons.push(`${path.relative(CROSSPAD_PC_ROOT, BIN_EXE)} not found — need build`);
47
+ }
48
+
49
+ // Check for source files newer than exe
50
+ if (exeExists) {
51
+ const exeMtime = fs.statSync(BIN_EXE).mtimeMs;
52
+ const srcDirs = [
53
+ path.join(CROSSPAD_PC_ROOT, "src"),
54
+ path.join(CROSSPAD_PC_ROOT, "crosspad-core", "src"),
55
+ path.join(CROSSPAD_PC_ROOT, "crosspad-core", "include"),
56
+ path.join(CROSSPAD_PC_ROOT, "crosspad-gui", "src"),
57
+ path.join(CROSSPAD_PC_ROOT, "crosspad-gui", "include"),
58
+ ];
59
+
60
+ let newerCount = 0;
61
+ for (const dir of srcDirs) {
62
+ if (!fs.existsSync(dir)) continue;
63
+ newerCount += countFilesNewerThan(dir, exeMtime, [".cpp", ".hpp", ".h", ".c"]);
64
+ if (newerCount > 0) break; // One is enough
65
+ }
66
+ if (newerCount > 0) {
67
+ needsRebuild = true;
68
+ reasons.push("Source files are newer than executable");
69
+ }
70
+ }
71
+
72
+ // Check for new source files not tracked by CMake (GLOB_RECURSE freshness)
73
+ const newSourceFiles: string[] = [];
74
+ if (buildExists) {
75
+ const cacheFile = path.join(BUILD_DIR, "build.ninja");
76
+ if (fs.existsSync(cacheFile)) {
77
+ const ninjaContent = fs.readFileSync(cacheFile, "utf-8");
78
+ // Find .cpp files in src/apps that aren't in build.ninja
79
+ const appsDir = path.join(CROSSPAD_PC_ROOT, "src", "apps");
80
+ if (fs.existsSync(appsDir)) {
81
+ const cppFiles = findFiles(appsDir, [".cpp"]);
82
+ for (const f of cppFiles) {
83
+ const relative = path.relative(CROSSPAD_PC_ROOT, f).replace(/\\/g, "/");
84
+ if (!ninjaContent.includes(relative) && !ninjaContent.includes(path.basename(f))) {
85
+ newSourceFiles.push(relative);
86
+ }
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ if (newSourceFiles.length > 0) {
93
+ needsReconfigure = true;
94
+ reasons.push(`${newSourceFiles.length} source file(s) not in build system — need reconfigure`);
95
+ }
96
+
97
+ // Submodule changes (dev-mode aware)
98
+ const submoduleChanges: Record<string, { pinned: string | null; current: string | null; changed: boolean }> = {};
99
+ for (const sub of ["crosspad-core", "crosspad-gui"]) {
100
+ const pinned = getSubmodulePin(CROSSPAD_PC_ROOT, sub);
101
+ const subPath = path.join(CROSSPAD_PC_ROOT, sub);
102
+ let current: string | null = null;
103
+
104
+ if (fs.existsSync(subPath)) {
105
+ // In dev-mode (junction), get HEAD of the junction target
106
+ const result = runCommand("git rev-parse HEAD", subPath);
107
+ current = result.success ? result.stdout.trim() : null;
108
+ }
109
+
110
+ const changed = pinned !== null && current !== null && !current.startsWith(pinned.slice(0, 7));
111
+ submoduleChanges[sub] = { pinned, current, changed };
112
+
113
+ if (changed) {
114
+ needsRebuild = true;
115
+ reasons.push(`${sub} HEAD differs from pinned commit`);
116
+ }
117
+
118
+ // Check for dirty files in submodule
119
+ if (fs.existsSync(subPath)) {
120
+ const dirty = runCommand("git status --porcelain", subPath);
121
+ if (dirty.success && dirty.stdout.trim().length > 0) {
122
+ const dirtyCount = dirty.stdout.trim().split("\n").length;
123
+ needsRebuild = true;
124
+ reasons.push(`${sub} has ${dirtyCount} dirty file(s)`);
125
+ }
126
+ }
127
+ }
128
+
129
+ if (reasons.length === 0) {
130
+ reasons.push("Build appears up to date");
131
+ }
132
+
133
+ return {
134
+ needs_reconfigure: needsReconfigure,
135
+ needs_rebuild: needsRebuild || needsReconfigure,
136
+ exe_exists: exeExists,
137
+ exe_age_seconds: exeAgeSeconds !== null ? Math.round(exeAgeSeconds) : null,
138
+ reasons,
139
+ submodule_changes: submoduleChanges,
140
+ new_source_files: newSourceFiles,
141
+ };
142
+ }
143
+
144
+ function countFilesNewerThan(dir: string, thresholdMs: number, extensions: string[]): number {
145
+ let count = 0;
146
+ try {
147
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
148
+ const fullPath = path.join(dir, entry.name);
149
+ if (entry.isDirectory()) {
150
+ count += countFilesNewerThan(fullPath, thresholdMs, extensions);
151
+ } else if (extensions.some((ext) => entry.name.endsWith(ext))) {
152
+ const stat = fs.statSync(fullPath);
153
+ if (stat.mtimeMs > thresholdMs) count++;
154
+ }
155
+ if (count > 0) return count; // Early exit
156
+ }
157
+ } catch {
158
+ // Ignore permission errors etc.
159
+ }
160
+ return count;
161
+ }
162
+
163
+ function findFiles(dir: string, extensions: string[]): string[] {
164
+ const results: string[] = [];
165
+ try {
166
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
167
+ const fullPath = path.join(dir, entry.name);
168
+ if (entry.isDirectory()) {
169
+ results.push(...findFiles(fullPath, extensions));
170
+ } else if (extensions.some((ext) => entry.name.endsWith(ext))) {
171
+ results.push(fullPath);
172
+ }
173
+ }
174
+ } catch {
175
+ // Ignore
176
+ }
177
+ return results;
178
+ }
@@ -0,0 +1,130 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { CROSSPAD_PC_ROOT, BUILD_DIR, BIN_EXE, VCPKG_TOOLCHAIN, IS_WINDOWS } from "../config.js";
4
+ import { runBuild, runBuildStream, spawnDetached, OnLine } from "../utils/exec.js";
5
+
6
+ export interface BuildResult {
7
+ success: boolean;
8
+ duration_seconds: number;
9
+ errors: string[];
10
+ warnings_count: number;
11
+ output_path: string;
12
+ }
13
+
14
+ function parseErrors(output: string): string[] {
15
+ const errors: string[] = [];
16
+ for (const line of output.split("\n")) {
17
+ if (/\berror\b/i.test(line) && !line.includes("error(s)")) {
18
+ errors.push(line.trim());
19
+ }
20
+ }
21
+ return errors.slice(0, 20); // Cap at 20 errors
22
+ }
23
+
24
+ function countWarnings(output: string): number {
25
+ let count = 0;
26
+ for (const line of output.split("\n")) {
27
+ if (/\bwarning\b/i.test(line) && !line.includes("warning(s)")) {
28
+ count++;
29
+ }
30
+ }
31
+ return count;
32
+ }
33
+
34
+ export async function crosspadBuild(
35
+ mode: "incremental" | "clean" | "reconfigure",
36
+ onLine?: OnLine
37
+ ): Promise<BuildResult> {
38
+ const startTime = Date.now();
39
+
40
+ // Clean: remove build dir
41
+ if (mode === "clean" && fs.existsSync(BUILD_DIR)) {
42
+ onLine?.("stdout", "[crosspad] Cleaning build directory...");
43
+ fs.rmSync(BUILD_DIR, { recursive: true, force: true });
44
+ }
45
+
46
+ // Configure if clean or reconfigure
47
+ if (mode === "clean" || mode === "reconfigure") {
48
+ const generator = process.env.CMAKE_GENERATOR || (IS_WINDOWS ? "Ninja" : "");
49
+ const genFlag = generator ? ` -G ${generator}` : "";
50
+ const configCmd = [
51
+ `cmake -B build${genFlag}`,
52
+ `-DCMAKE_TOOLCHAIN_FILE=${VCPKG_TOOLCHAIN}`,
53
+ "-DCMAKE_BUILD_TYPE=Debug",
54
+ ].join(" ");
55
+
56
+ onLine?.("stdout", `[crosspad] Configuring: ${mode}...`);
57
+
58
+ if (onLine) {
59
+ const configResult = await runBuildStream(configCmd, CROSSPAD_PC_ROOT, onLine, 600_000);
60
+ if (!configResult.success) {
61
+ const combined = configResult.stdout + "\n" + configResult.stderr;
62
+ return {
63
+ success: false,
64
+ duration_seconds: (Date.now() - startTime) / 1000,
65
+ errors: parseErrors(combined),
66
+ warnings_count: countWarnings(combined),
67
+ output_path: BIN_EXE,
68
+ };
69
+ }
70
+ } else {
71
+ const configResult = runBuild(configCmd, CROSSPAD_PC_ROOT, 600_000);
72
+ if (!configResult.success) {
73
+ const combined = configResult.stdout + "\n" + configResult.stderr;
74
+ return {
75
+ success: false,
76
+ duration_seconds: (Date.now() - startTime) / 1000,
77
+ errors: parseErrors(combined),
78
+ warnings_count: countWarnings(combined),
79
+ output_path: BIN_EXE,
80
+ };
81
+ }
82
+ }
83
+ }
84
+
85
+ // Build
86
+ onLine?.("stdout", "[crosspad] Building...");
87
+
88
+ let buildStdout: string;
89
+ let buildStderr: string;
90
+ let buildSuccess: boolean;
91
+
92
+ if (onLine) {
93
+ const buildResult = await runBuildStream("cmake --build build", CROSSPAD_PC_ROOT, onLine, 600_000);
94
+ buildStdout = buildResult.stdout;
95
+ buildStderr = buildResult.stderr;
96
+ buildSuccess = buildResult.success;
97
+ } else {
98
+ const buildResult = runBuild("cmake --build build", CROSSPAD_PC_ROOT, 600_000);
99
+ buildStdout = buildResult.stdout;
100
+ buildStderr = buildResult.stderr;
101
+ buildSuccess = buildResult.success;
102
+ }
103
+
104
+ const combined = buildStdout + "\n" + buildStderr;
105
+ const result: BuildResult = {
106
+ success: buildSuccess,
107
+ duration_seconds: (Date.now() - startTime) / 1000,
108
+ errors: parseErrors(combined),
109
+ warnings_count: countWarnings(combined),
110
+ output_path: BIN_EXE,
111
+ };
112
+
113
+ onLine?.("stdout", `[crosspad] Build ${result.success ? "succeeded" : "FAILED"} in ${result.duration_seconds.toFixed(1)}s`);
114
+
115
+ return result;
116
+ }
117
+
118
+ export interface RunResult {
119
+ pid: number | null;
120
+ exe_path: string;
121
+ }
122
+
123
+ export function crosspadRun(): RunResult {
124
+ if (!fs.existsSync(BIN_EXE)) {
125
+ return { pid: null, exe_path: BIN_EXE };
126
+ }
127
+
128
+ const pid = spawnDetached(BIN_EXE, [], CROSSPAD_PC_ROOT);
129
+ return { pid, exe_path: BIN_EXE };
130
+ }
@@ -0,0 +1,130 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { CROSSPAD_PC_ROOT, REPOS } from "../config.js";
4
+ import { runCommand } from "../utils/exec.js";
5
+ import { getSubmodulePin } from "../utils/git.js";
6
+
7
+ export interface DiffEntry {
8
+ status: string; // A, M, D, R
9
+ file: string;
10
+ }
11
+
12
+ export interface SubmoduleDiff {
13
+ name: string;
14
+ pinned_commit: string | null;
15
+ current_commit: string | null;
16
+ is_dev_mode: boolean;
17
+ ahead_count: number;
18
+ behind_count: number;
19
+ changed_files: DiffEntry[];
20
+ uncommitted_changes: string[];
21
+ commit_log: string[];
22
+ }
23
+
24
+ export interface DiffCoreResult {
25
+ submodules: SubmoduleDiff[];
26
+ }
27
+
28
+ /**
29
+ * Show what changed in crosspad-core and/or crosspad-gui relative to the
30
+ * pinned submodule commit. Essential for dev-mode workflows where you're
31
+ * editing shared repos but haven't committed/pinned yet.
32
+ */
33
+ export function crosspadDiffCore(
34
+ submodule: "crosspad-core" | "crosspad-gui" | "both" = "both"
35
+ ): DiffCoreResult {
36
+ const targets = submodule === "both"
37
+ ? ["crosspad-core", "crosspad-gui"]
38
+ : [submodule];
39
+
40
+ const submodules: SubmoduleDiff[] = [];
41
+
42
+ for (const sub of targets) {
43
+ const subPath = path.join(CROSSPAD_PC_ROOT, sub);
44
+ const isDevMode = isJunction(subPath);
45
+ const pinnedCommit = getSubmodulePin(CROSSPAD_PC_ROOT, sub);
46
+
47
+ // Get current HEAD
48
+ const headResult = runCommand("git rev-parse HEAD", subPath);
49
+ const currentCommit = headResult.success ? headResult.stdout.trim() : null;
50
+
51
+ let aheadCount = 0;
52
+ let behindCount = 0;
53
+ let changedFiles: DiffEntry[] = [];
54
+ let commitLog: string[] = [];
55
+
56
+ if (pinnedCommit && currentCommit && pinnedCommit !== currentCommit) {
57
+ // Count commits ahead/behind
58
+ const countResult = runCommand(
59
+ `git rev-list --count --left-right ${pinnedCommit}...HEAD`,
60
+ subPath
61
+ );
62
+ if (countResult.success) {
63
+ const parts = countResult.stdout.trim().split(/\s+/);
64
+ behindCount = parseInt(parts[0] || "0", 10);
65
+ aheadCount = parseInt(parts[1] || "0", 10);
66
+ }
67
+
68
+ // Get diff stat (files changed between pinned and HEAD)
69
+ const diffResult = runCommand(
70
+ `git diff --name-status ${pinnedCommit}...HEAD`,
71
+ subPath
72
+ );
73
+ if (diffResult.success) {
74
+ changedFiles = diffResult.stdout
75
+ .trim()
76
+ .split("\n")
77
+ .filter((l) => l.length > 0)
78
+ .map((line) => {
79
+ const parts = line.split("\t");
80
+ return { status: parts[0], file: parts.slice(1).join("\t") };
81
+ });
82
+ }
83
+
84
+ // Get commit log between pinned and HEAD
85
+ const logResult = runCommand(
86
+ `git log --oneline ${pinnedCommit}..HEAD`,
87
+ subPath
88
+ );
89
+ if (logResult.success) {
90
+ commitLog = logResult.stdout
91
+ .trim()
92
+ .split("\n")
93
+ .filter((l) => l.length > 0)
94
+ .slice(0, 20); // Cap at 20
95
+ }
96
+ }
97
+
98
+ // Uncommitted changes (working tree)
99
+ const statusResult = runCommand("git status --porcelain", subPath);
100
+ const uncommittedChanges = statusResult.success
101
+ ? statusResult.stdout
102
+ .trim()
103
+ .split("\n")
104
+ .filter((l) => l.length > 0)
105
+ : [];
106
+
107
+ submodules.push({
108
+ name: sub,
109
+ pinned_commit: pinnedCommit,
110
+ current_commit: currentCommit,
111
+ is_dev_mode: isDevMode,
112
+ ahead_count: aheadCount,
113
+ behind_count: behindCount,
114
+ changed_files: changedFiles,
115
+ uncommitted_changes: uncommittedChanges,
116
+ commit_log: commitLog,
117
+ });
118
+ }
119
+
120
+ return { submodules };
121
+ }
122
+
123
+ function isJunction(p: string): boolean {
124
+ try {
125
+ const stat = fs.lstatSync(p);
126
+ return stat.isSymbolicLink();
127
+ } catch {
128
+ return false;
129
+ }
130
+ }