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.
- package/README.md +187 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.js +33 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +360 -0
- package/dist/index.js.map +1 -0
- package/dist/tools/architecture.d.ts +16 -0
- package/dist/tools/architecture.js +198 -0
- package/dist/tools/architecture.js.map +1 -0
- package/dist/tools/build-check.d.ts +23 -0
- package/dist/tools/build-check.js +162 -0
- package/dist/tools/build-check.js.map +1 -0
- package/dist/tools/build.d.ts +14 -0
- package/dist/tools/build.js +101 -0
- package/dist/tools/build.js.map +1 -0
- package/dist/tools/diff-core.d.ts +24 -0
- package/dist/tools/diff-core.js +88 -0
- package/dist/tools/diff-core.js.map +1 -0
- package/dist/tools/idf-build.d.ts +10 -0
- package/dist/tools/idf-build.js +155 -0
- package/dist/tools/idf-build.js.map +1 -0
- package/dist/tools/input.d.ts +36 -0
- package/dist/tools/input.js +61 -0
- package/dist/tools/input.js.map +1 -0
- package/dist/tools/log.d.ts +16 -0
- package/dist/tools/log.js +49 -0
- package/dist/tools/log.js.map +1 -0
- package/dist/tools/repos.d.ts +12 -0
- package/dist/tools/repos.js +63 -0
- package/dist/tools/repos.js.map +1 -0
- package/dist/tools/scaffold.d.ts +15 -0
- package/dist/tools/scaffold.js +192 -0
- package/dist/tools/scaffold.js.map +1 -0
- package/dist/tools/screenshot.d.ts +24 -0
- package/dist/tools/screenshot.js +80 -0
- package/dist/tools/screenshot.js.map +1 -0
- package/dist/tools/settings.d.ts +25 -0
- package/dist/tools/settings.js +48 -0
- package/dist/tools/settings.js.map +1 -0
- package/dist/tools/stats.d.ts +18 -0
- package/dist/tools/stats.js +31 -0
- package/dist/tools/stats.js.map +1 -0
- package/dist/tools/symbols.d.ts +20 -0
- package/dist/tools/symbols.js +157 -0
- package/dist/tools/symbols.js.map +1 -0
- package/dist/tools/test.d.ts +24 -0
- package/dist/tools/test.js +227 -0
- package/dist/tools/test.js.map +1 -0
- package/dist/utils/exec.d.ts +58 -0
- package/dist/utils/exec.js +292 -0
- package/dist/utils/exec.js.map +1 -0
- package/dist/utils/git.d.ts +10 -0
- package/dist/utils/git.js +29 -0
- package/dist/utils/git.js.map +1 -0
- package/dist/utils/remote-client.d.ts +17 -0
- package/dist/utils/remote-client.js +94 -0
- package/dist/utils/remote-client.js.map +1 -0
- package/package.json +21 -0
- package/server.json +23 -0
- package/src/config.ts +45 -0
- package/src/index.ts +484 -0
- package/src/tools/architecture.ts +260 -0
- package/src/tools/build-check.ts +178 -0
- package/src/tools/build.ts +130 -0
- package/src/tools/diff-core.ts +130 -0
- package/src/tools/idf-build.ts +182 -0
- package/src/tools/input.ts +80 -0
- package/src/tools/log.ts +75 -0
- package/src/tools/repos.ts +75 -0
- package/src/tools/scaffold.ts +229 -0
- package/src/tools/screenshot.ts +100 -0
- package/src/tools/settings.ts +68 -0
- package/src/tools/stats.ts +38 -0
- package/src/tools/symbols.ts +185 -0
- package/src/tools/test.ts +264 -0
- package/src/utils/exec.ts +376 -0
- package/src/utils/git.ts +45 -0
- package/src/utils/remote-client.ts +107 -0
- 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
|
+
}
|