patchwarden 0.4.0 → 0.6.1

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 (189) hide show
  1. package/PatchWarden.cmd +51 -0
  2. package/README.en.md +1022 -0
  3. package/README.md +897 -358
  4. package/dist/assessments/agentAssessor.d.ts +15 -0
  5. package/dist/assessments/agentAssessor.js +293 -0
  6. package/dist/assessments/assessmentStore.d.ts +133 -0
  7. package/dist/assessments/assessmentStore.js +238 -0
  8. package/dist/assessments/confirmCli.d.ts +9 -0
  9. package/dist/assessments/confirmCli.js +26 -0
  10. package/dist/config.d.ts +22 -1
  11. package/dist/config.js +156 -3
  12. package/dist/direct/directAudit.d.ts +23 -0
  13. package/dist/direct/directAudit.js +309 -0
  14. package/dist/direct/directGuards.d.ts +20 -0
  15. package/dist/direct/directGuards.js +137 -0
  16. package/dist/direct/directPatch.d.ts +17 -0
  17. package/dist/direct/directPatch.js +113 -0
  18. package/dist/direct/directSessionStore.d.ts +63 -0
  19. package/dist/direct/directSessionStore.js +192 -0
  20. package/dist/direct/directVerification.d.ts +12 -0
  21. package/dist/direct/directVerification.js +96 -0
  22. package/dist/doctor.js +73 -5
  23. package/dist/logging.d.ts +52 -0
  24. package/dist/logging.js +123 -0
  25. package/dist/runner/agentInvocation.d.ts +23 -0
  26. package/dist/runner/agentInvocation.js +106 -0
  27. package/dist/runner/changeCapture.d.ts +70 -0
  28. package/dist/runner/changeCapture.js +278 -6
  29. package/dist/runner/runTask.js +271 -65
  30. package/dist/runner/simpleProcess.d.ts +21 -0
  31. package/dist/runner/simpleProcess.js +206 -0
  32. package/dist/runner/watch.js +17 -2
  33. package/dist/security/commandGuard.d.ts +2 -1
  34. package/dist/security/commandGuard.js +25 -3
  35. package/dist/security/riskEngine.d.ts +27 -0
  36. package/dist/security/riskEngine.js +118 -0
  37. package/dist/security/runtimeGuard.d.ts +6 -0
  38. package/dist/security/runtimeGuard.js +28 -0
  39. package/dist/smoke-test.js +1353 -7
  40. package/dist/test/unit/android-doctor.test.d.ts +1 -0
  41. package/dist/test/unit/android-doctor.test.js +118 -0
  42. package/dist/test/unit/chinese-path.test.d.ts +1 -0
  43. package/dist/test/unit/chinese-path.test.js +91 -0
  44. package/dist/test/unit/command-guard.test.d.ts +1 -0
  45. package/dist/test/unit/command-guard.test.js +160 -0
  46. package/dist/test/unit/direct-guards.test.d.ts +1 -0
  47. package/dist/test/unit/direct-guards.test.js +213 -0
  48. package/dist/test/unit/logging.test.d.ts +1 -0
  49. package/dist/test/unit/logging.test.js +275 -0
  50. package/dist/test/unit/path-guard.test.d.ts +1 -0
  51. package/dist/test/unit/path-guard.test.js +109 -0
  52. package/dist/test/unit/safe-status.test.d.ts +1 -0
  53. package/dist/test/unit/safe-status.test.js +165 -0
  54. package/dist/test/unit/sensitive-guard.test.d.ts +1 -0
  55. package/dist/test/unit/sensitive-guard.test.js +104 -0
  56. package/dist/test/unit/sync-file.test.d.ts +1 -0
  57. package/dist/test/unit/sync-file.test.js +154 -0
  58. package/dist/test/unit/watcher-status.test.d.ts +1 -0
  59. package/dist/test/unit/watcher-status.test.js +169 -0
  60. package/dist/tools/androidDoctor.d.ts +38 -0
  61. package/dist/tools/androidDoctor.js +391 -0
  62. package/dist/tools/applyPatch.d.ts +16 -0
  63. package/dist/tools/applyPatch.js +41 -0
  64. package/dist/tools/auditSession.d.ts +5 -0
  65. package/dist/tools/auditSession.js +12 -0
  66. package/dist/tools/auditTask.d.ts +7 -0
  67. package/dist/tools/auditTask.js +105 -7
  68. package/dist/tools/createDirectSession.d.ts +14 -0
  69. package/dist/tools/createDirectSession.js +54 -0
  70. package/dist/tools/createTask.d.ts +48 -1
  71. package/dist/tools/createTask.js +298 -47
  72. package/dist/tools/finalizeDirectSession.d.ts +19 -0
  73. package/dist/tools/finalizeDirectSession.js +84 -0
  74. package/dist/tools/getTaskSummary.d.ts +50 -0
  75. package/dist/tools/getTaskSummary.js +79 -2
  76. package/dist/tools/healthCheck.d.ts +27 -12
  77. package/dist/tools/healthCheck.js +44 -6
  78. package/dist/tools/readWorkspaceFile.d.ts +7 -1
  79. package/dist/tools/readWorkspaceFile.js +48 -2
  80. package/dist/tools/registry.js +270 -19
  81. package/dist/tools/runVerification.d.ts +16 -0
  82. package/dist/tools/runVerification.js +32 -0
  83. package/dist/tools/safeStatus.d.ts +19 -0
  84. package/dist/tools/safeStatus.js +72 -0
  85. package/dist/tools/savePlan.d.ts +1 -0
  86. package/dist/tools/savePlan.js +38 -7
  87. package/dist/tools/searchWorkspace.d.ts +19 -0
  88. package/dist/tools/searchWorkspace.js +205 -0
  89. package/dist/tools/syncFile.d.ts +18 -0
  90. package/dist/tools/syncFile.js +65 -0
  91. package/dist/tools/taskOutputs.d.ts +2 -2
  92. package/dist/tools/taskTemplates.js +2 -1
  93. package/dist/tools/toolCatalog.d.ts +4 -3
  94. package/dist/tools/toolCatalog.js +34 -5
  95. package/dist/tools/waitForTask.d.ts +2 -2
  96. package/dist/tools/waitForTask.js +1 -1
  97. package/dist/version.d.ts +2 -2
  98. package/dist/version.js +2 -2
  99. package/dist/watcherStatus.d.ts +1 -0
  100. package/dist/watcherStatus.js +96 -4
  101. package/docs/chatgpt-usage.md +101 -0
  102. package/docs/performance-notes.md +55 -0
  103. package/docs/release-checklist.md +14 -0
  104. package/docs/release-v0.6.0.md +71 -0
  105. package/docs/release-v0.6.1.md +75 -0
  106. package/examples/config.example.json +5 -0
  107. package/examples/openai-tunnel/README.md +11 -4
  108. package/examples/openai-tunnel/chatgpt-test-prompt.md +18 -14
  109. package/examples/openai-tunnel/tunnel-client.example.yaml +8 -0
  110. package/package.json +13 -8
  111. package/scripts/brand-check.js +58 -12
  112. package/scripts/control-smoke.js +206 -0
  113. package/scripts/http-mcp-smoke.js +10 -2
  114. package/scripts/launchers/Check-PatchWarden-Health.cmd +6 -0
  115. package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +6 -0
  116. package/scripts/launchers/Restart-PatchWarden.cmd +6 -0
  117. package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +7 -0
  118. package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +7 -0
  119. package/scripts/lifecycle-smoke.js +402 -8
  120. package/scripts/manage-patchwarden.ps1 +639 -0
  121. package/scripts/mcp-manifest-check.js +123 -58
  122. package/scripts/mcp-smoke.js +160 -2
  123. package/scripts/pack-clean.js +160 -5
  124. package/scripts/package-manifest-check.js +78 -0
  125. package/scripts/patchwarden-mcp-direct.cmd +7 -0
  126. package/scripts/patchwarden-mcp-stdio.cmd +1 -1
  127. package/scripts/restart-patchwarden.ps1 +5 -4
  128. package/scripts/start-patchwarden-tunnel.ps1 +261 -30
  129. package/scripts/tunnel-supervisor-smoke.js +36 -4
  130. package/scripts/unit-tests.js +36 -0
  131. package/scripts/watcher-supervisor-smoke.js +10 -6
  132. package/src/assessments/agentAssessor.ts +324 -0
  133. package/src/assessments/assessmentStore.ts +426 -0
  134. package/src/assessments/confirmCli.ts +29 -0
  135. package/src/config.ts +178 -4
  136. package/src/direct/directAudit.ts +400 -0
  137. package/src/direct/directGuards.ts +279 -0
  138. package/src/direct/directPatch.ts +258 -0
  139. package/src/direct/directSessionStore.ts +345 -0
  140. package/src/direct/directVerification.ts +138 -0
  141. package/src/doctor.ts +103 -7
  142. package/src/logging.ts +152 -0
  143. package/src/runner/agentInvocation.ts +125 -0
  144. package/src/runner/changeCapture.ts +352 -6
  145. package/src/runner/runTask.ts +279 -63
  146. package/src/runner/simpleProcess.ts +223 -0
  147. package/src/runner/watch.ts +18 -2
  148. package/src/security/commandGuard.ts +46 -4
  149. package/src/security/riskEngine.ts +160 -0
  150. package/src/security/runtimeGuard.ts +41 -0
  151. package/src/smoke-test.ts +1291 -4
  152. package/src/test/unit/android-doctor.test.ts +158 -0
  153. package/src/test/unit/chinese-path.test.ts +106 -0
  154. package/src/test/unit/command-guard.test.ts +221 -0
  155. package/src/test/unit/direct-guards.test.ts +297 -0
  156. package/src/test/unit/logging.test.ts +325 -0
  157. package/src/test/unit/path-guard.test.ts +150 -0
  158. package/src/test/unit/safe-status.test.ts +187 -0
  159. package/src/test/unit/sensitive-guard.test.ts +124 -0
  160. package/src/test/unit/sync-file.test.ts +231 -0
  161. package/src/test/unit/watcher-status.test.ts +190 -0
  162. package/src/tools/androidDoctor.ts +424 -0
  163. package/src/tools/applyPatch.ts +86 -0
  164. package/src/tools/auditSession.ts +28 -0
  165. package/src/tools/auditTask.ts +111 -7
  166. package/src/tools/createDirectSession.ts +113 -0
  167. package/src/tools/createTask.ts +405 -55
  168. package/src/tools/finalizeDirectSession.ts +144 -0
  169. package/src/tools/getTaskSummary.ts +133 -2
  170. package/src/tools/healthCheck.ts +45 -6
  171. package/src/tools/readWorkspaceFile.ts +85 -2
  172. package/src/tools/registry.ts +305 -19
  173. package/src/tools/runVerification.ts +58 -0
  174. package/src/tools/safeStatus.ts +96 -0
  175. package/src/tools/savePlan.ts +57 -7
  176. package/src/tools/searchWorkspace.ts +275 -0
  177. package/src/tools/syncFile.ts +122 -0
  178. package/src/tools/taskTemplates.ts +2 -1
  179. package/src/tools/toolCatalog.ts +37 -6
  180. package/src/tools/waitForTask.ts +3 -3
  181. package/src/version.ts +2 -2
  182. package/src/watcherStatus.ts +101 -4
  183. package/tsconfig.json +18 -17
  184. package/Check-PatchWarden-Health.cmd +0 -6
  185. package/Reset-PatchWarden-Tunnel-Key.cmd +0 -6
  186. package/Restart-PatchWarden.cmd +0 -19
  187. package/Start-PatchWarden-Tunnel.cmd +0 -7
  188. package/docs/release-v0.3.0.md +0 -43
  189. package/docs/release-v0.4.0.md +0 -74
@@ -0,0 +1,190 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync, existsSync, mkdir } from "node:fs";
4
+ import { join, dirname } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import type { PatchWardenConfig } from "../../config.js";
7
+ import { readWatcherStatus, getWatcherHeartbeatPath } from "../../watcherStatus.js";
8
+
9
+ function makeConfig(workspaceRoot: string): PatchWardenConfig {
10
+ return {
11
+ workspaceRoot,
12
+ plansDir: ".patchwarden/plans",
13
+ tasksDir: ".patchwarden/tasks",
14
+ assessmentsDir: ".patchwarden/assessments",
15
+ assessmentTtlSeconds: 3600,
16
+ agents: { codex: { command: "codex", args: ["exec", "{prompt}"] } },
17
+ allowedTestCommands: ["npm test"],
18
+ repoAllowedTestCommands: {},
19
+ maxReadFileBytes: 200_000,
20
+ defaultTaskTimeoutSeconds: 900,
21
+ maxTaskTimeoutSeconds: 3600,
22
+ watcherStaleSeconds: 30,
23
+ directSessionsDir: ".patchwarden/direct-sessions",
24
+ directSessionTtlSeconds: 3600,
25
+ directMaxPatchBytes: 200_000,
26
+ directMaxFileBytes: 500_000,
27
+ };
28
+ }
29
+
30
+ describe("readWatcherStatus — watcher stale fallback", () => {
31
+ let tempDir: string;
32
+ let config: PatchWardenConfig;
33
+
34
+ beforeEach(() => {
35
+ tempDir = mkdtempSync(join(tmpdir(), "pw-watcher-test-"));
36
+ config = makeConfig(tempDir);
37
+ mkdirSync(join(tempDir, ".patchwarden", "tasks"), { recursive: true });
38
+ });
39
+
40
+ afterEach(() => {
41
+ rmSync(tempDir, { recursive: true, force: true });
42
+ });
43
+
44
+ it("returns missing when no heartbeat and no running task", () => {
45
+ const result = readWatcherStatus(config, Date.now());
46
+ assert.equal(result.status, "missing");
47
+ assert.equal(result.available, false);
48
+ assert.equal(result.activity, null);
49
+ });
50
+
51
+ it("returns healthy when watcher heartbeat is fresh", () => {
52
+ const heartbeatPath = getWatcherHeartbeatPath(config);
53
+ mkdirSync(dirname(heartbeatPath), { recursive: true });
54
+ writeFileSync(heartbeatPath, JSON.stringify({
55
+ status: "running",
56
+ pid: process.pid,
57
+ last_heartbeat_at: new Date().toISOString(),
58
+ }), "utf-8");
59
+
60
+ const result = readWatcherStatus(config, Date.now());
61
+ assert.equal(result.status, "healthy");
62
+ assert.equal(result.available, true);
63
+ assert.equal(result.activity, null);
64
+ });
65
+
66
+ it("returns stale when watcher heartbeat is old and no running task", () => {
67
+ const heartbeatPath = getWatcherHeartbeatPath(config);
68
+ mkdirSync(dirname(heartbeatPath), { recursive: true });
69
+ const oldTime = new Date(Date.now() - 120_000).toISOString();
70
+ writeFileSync(heartbeatPath, JSON.stringify({
71
+ status: "running",
72
+ pid: process.pid,
73
+ last_heartbeat_at: oldTime,
74
+ }), "utf-8");
75
+
76
+ const result = readWatcherStatus(config, Date.now());
77
+ assert.equal(result.status, "stale");
78
+ assert.equal(result.available, false);
79
+ assert.equal(result.activity, null);
80
+ });
81
+
82
+ it("returns healthy when watcher heartbeat is stale but a running task has fresh heartbeat", () => {
83
+ // Write stale watcher heartbeat
84
+ const heartbeatPath = getWatcherHeartbeatPath(config);
85
+ mkdirSync(dirname(heartbeatPath), { recursive: true });
86
+ const oldTime = new Date(Date.now() - 120_000).toISOString();
87
+ writeFileSync(heartbeatPath, JSON.stringify({
88
+ status: "running",
89
+ pid: process.pid,
90
+ last_heartbeat_at: oldTime,
91
+ }), "utf-8");
92
+
93
+ // Create a running task with fresh heartbeat
94
+ const taskDir = join(tempDir, ".patchwarden", "tasks", "test-task-001");
95
+ mkdirSync(taskDir, { recursive: true });
96
+ writeFileSync(join(taskDir, "status.json"), JSON.stringify({
97
+ status: "running",
98
+ task_id: "test-task-001",
99
+ }), "utf-8");
100
+ writeFileSync(join(taskDir, "runtime.json"), JSON.stringify({
101
+ phase: "executing_agent",
102
+ last_heartbeat_at: new Date().toISOString(),
103
+ current_command: "codex exec",
104
+ }), "utf-8");
105
+
106
+ const result = readWatcherStatus(config, Date.now());
107
+ assert.equal(result.status, "healthy");
108
+ assert.equal(result.available, true);
109
+ assert.ok(result.activity);
110
+ assert.match(result.activity!, /test-task-001/);
111
+ });
112
+
113
+ it("returns healthy when watcher heartbeat is missing but a running task has fresh heartbeat", () => {
114
+ // No watcher heartbeat file at all
115
+ // Create a running task with fresh heartbeat
116
+ const taskDir = join(tempDir, ".patchwarden", "tasks", "test-task-002");
117
+ mkdirSync(taskDir, { recursive: true });
118
+ writeFileSync(join(taskDir, "status.json"), JSON.stringify({
119
+ status: "running",
120
+ task_id: "test-task-002",
121
+ }), "utf-8");
122
+ writeFileSync(join(taskDir, "runtime.json"), JSON.stringify({
123
+ phase: "running_tests",
124
+ last_heartbeat_at: new Date().toISOString(),
125
+ current_command: "npm test",
126
+ }), "utf-8");
127
+
128
+ const result = readWatcherStatus(config, Date.now());
129
+ assert.equal(result.status, "healthy");
130
+ assert.equal(result.available, true);
131
+ assert.ok(result.activity);
132
+ assert.match(result.activity!, /test-task-002/);
133
+ });
134
+
135
+ it("returns stale when both watcher heartbeat and task heartbeat are old", () => {
136
+ const heartbeatPath = getWatcherHeartbeatPath(config);
137
+ mkdirSync(dirname(heartbeatPath), { recursive: true });
138
+ const oldTime = new Date(Date.now() - 120_000).toISOString();
139
+ writeFileSync(heartbeatPath, JSON.stringify({
140
+ status: "running",
141
+ pid: process.pid,
142
+ last_heartbeat_at: oldTime,
143
+ }), "utf-8");
144
+
145
+ const taskDir = join(tempDir, ".patchwarden", "tasks", "test-task-003");
146
+ mkdirSync(taskDir, { recursive: true });
147
+ writeFileSync(join(taskDir, "status.json"), JSON.stringify({
148
+ status: "running",
149
+ task_id: "test-task-003",
150
+ }), "utf-8");
151
+ writeFileSync(join(taskDir, "runtime.json"), JSON.stringify({
152
+ phase: "executing_agent",
153
+ last_heartbeat_at: oldTime,
154
+ current_command: "codex exec",
155
+ }), "utf-8");
156
+
157
+ const result = readWatcherStatus(config, Date.now());
158
+ assert.equal(result.status, "stale");
159
+ assert.equal(result.available, false);
160
+ assert.equal(result.activity, null);
161
+ });
162
+
163
+ it("does not fall back to non-running tasks", () => {
164
+ const heartbeatPath = getWatcherHeartbeatPath(config);
165
+ mkdirSync(dirname(heartbeatPath), { recursive: true });
166
+ const oldTime = new Date(Date.now() - 120_000).toISOString();
167
+ writeFileSync(heartbeatPath, JSON.stringify({
168
+ status: "running",
169
+ pid: process.pid,
170
+ last_heartbeat_at: oldTime,
171
+ }), "utf-8");
172
+
173
+ // Done task with fresh heartbeat should not trigger fallback
174
+ const taskDir = join(tempDir, ".patchwarden", "tasks", "test-task-004");
175
+ mkdirSync(taskDir, { recursive: true });
176
+ writeFileSync(join(taskDir, "status.json"), JSON.stringify({
177
+ status: "done",
178
+ task_id: "test-task-004",
179
+ }), "utf-8");
180
+ writeFileSync(join(taskDir, "runtime.json"), JSON.stringify({
181
+ phase: "completed",
182
+ last_heartbeat_at: new Date().toISOString(),
183
+ current_command: null,
184
+ }), "utf-8");
185
+
186
+ const result = readWatcherStatus(config, Date.now());
187
+ assert.equal(result.status, "stale");
188
+ assert.equal(result.available, false);
189
+ });
190
+ });
@@ -0,0 +1,424 @@
1
+ /**
2
+ * PatchWarden Android Doctor — read-only Android build environment diagnostics.
3
+ *
4
+ * Inspects a repository for an `android_app` directory and reports whether the
5
+ * local environment can build an APK. The module is strictly read-only: it never
6
+ * auto-downloads the Android SDK, never modifies system environment variables,
7
+ * and never installs global dependencies. It only runs `java -version` (read-only)
8
+ * and inspects files/directories/env vars that already exist.
9
+ */
10
+
11
+ import { execSync } from "node:child_process";
12
+ import { existsSync, readFileSync, statSync } from "node:fs";
13
+ import { join } from "node:path";
14
+
15
+ // ── Types ──────────────────────────────────────────────────────────
16
+
17
+ export type AndroidCheckStatus = "ok" | "warn" | "fail" | "skip";
18
+
19
+ export interface AndroidDiagnosticItem {
20
+ check: string;
21
+ status: AndroidCheckStatus;
22
+ reason: string;
23
+ suggested_fix: string;
24
+ }
25
+
26
+ export interface AndroidBuildDiagnosticReport {
27
+ status: "ok" | "warn" | "fail";
28
+ repo_path: string;
29
+ android_app_path: string;
30
+ checks: AndroidDiagnosticItem[];
31
+ checked_at: string;
32
+ }
33
+
34
+ export type AndroidBuildDiagnostic =
35
+ | { status: "skip"; reason: string }
36
+ | AndroidBuildDiagnosticReport;
37
+
38
+ // ── Constants ──────────────────────────────────────────────────────
39
+
40
+ /**
41
+ * Required explanatory sentence emitted whenever the Android SDK is missing.
42
+ * Callers rely on this exact wording to detect the "SDK missing" condition.
43
+ */
44
+ const SDK_MISSING_REASON =
45
+ "Android project exists, APK not built because Android SDK is missing.";
46
+
47
+ // ── Helpers ────────────────────────────────────────────────────────
48
+
49
+ /**
50
+ * Run a shell command and return its trimmed combined stdout/stderr output.
51
+ * Returns an empty string when the command is missing or fails so callers can
52
+ * treat absence gracefully. A timeout guards against hanging processes.
53
+ */
54
+ function runCmd(cmdStr: string): string {
55
+ try {
56
+ return execSync(cmdStr, {
57
+ encoding: "utf-8",
58
+ timeout: 8000,
59
+ stdio: ["ignore", "pipe", "ignore"],
60
+ }).trim();
61
+ } catch {
62
+ return "";
63
+ }
64
+ }
65
+
66
+ /** True when `path` exists and is a directory. Never throws. */
67
+ function isDirectory(path: string): boolean {
68
+ try {
69
+ return existsSync(path) && statSync(path).isDirectory();
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ /** Extract the quoted version token from `java -version` output. */
76
+ function parseJavaVersion(output: string): string | null {
77
+ const match = output.match(/version "([^"]+)"/);
78
+ return match ? match[1] : null;
79
+ }
80
+
81
+ /**
82
+ * Convert a Java version string to its major version number.
83
+ * "17.0.1" -> 17, "21" -> 21, "1.8.0_292" -> 8 (legacy 1.x scheme).
84
+ */
85
+ function parseJavaMajor(version: string): number | null {
86
+ const match = version.match(/^(\d+)(?:\.(\d+))?/);
87
+ if (!match) return null;
88
+ const major = parseInt(match[1], 10);
89
+ if (major === 1 && match[2]) return parseInt(match[2], 10);
90
+ return major;
91
+ }
92
+
93
+ /** Safely read a UTF-8 file, returning null on any error. */
94
+ function readTextFile(path: string): string | null {
95
+ try {
96
+ return readFileSync(path, "utf-8");
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ // ── Public API ─────────────────────────────────────────────────────
103
+
104
+ /**
105
+ * Diagnose the Android build readiness for the repository at `repoPath`.
106
+ *
107
+ * - If `<repoPath>/android_app` does not exist, returns a skip result.
108
+ * - Otherwise runs 10 read-only checks and returns a structured report with an
109
+ * overall status: "fail" if any check failed, "warn" if any warned, else "ok".
110
+ *
111
+ * This function performs no mutations: it does not download the SDK, change env
112
+ * vars, or install dependencies.
113
+ */
114
+ export function diagnoseAndroidBuild(repoPath: string): AndroidBuildDiagnostic {
115
+ const androidAppPath = join(repoPath, "android_app");
116
+ if (!isDirectory(androidAppPath)) {
117
+ return { status: "skip", reason: "No android_app directory found" };
118
+ }
119
+
120
+ const checks: AndroidDiagnosticItem[] = [];
121
+ const checkedAt = new Date().toISOString();
122
+
123
+ // Resolve the Android SDK location. ANDROID_HOME is preferred; ANDROID_SDK_ROOT
124
+ // is a legacy fallback that some tools still honor.
125
+ const androidHomeEnv = process.env.ANDROID_HOME || "";
126
+ const androidSdkRootEnv = process.env.ANDROID_SDK_ROOT || "";
127
+ const sdkRoot = androidHomeEnv || androidSdkRootEnv;
128
+ const sdkExists = sdkRoot !== "" && isDirectory(sdkRoot);
129
+
130
+ // 1. java -version ------------------------------------------------------
131
+ // `java -version` writes to stderr; redirect with 2>&1 so execSync captures it.
132
+ const javaOutput = runCmd("java -version 2>&1");
133
+ const javaVersion = parseJavaVersion(javaOutput);
134
+ if (javaVersion) {
135
+ const major = parseJavaMajor(javaVersion);
136
+ if (major !== null && major < 17) {
137
+ checks.push({
138
+ check: "java -version",
139
+ status: "warn",
140
+ reason: `Java ${javaVersion} found. Modern Android Gradle Plugin (8.x) requires JDK 17+.`,
141
+ suggested_fix:
142
+ "Install JDK 17 or newer and place it first on PATH (or point JAVA_HOME at it).",
143
+ });
144
+ } else {
145
+ checks.push({
146
+ check: "java -version",
147
+ status: "ok",
148
+ reason: `Java ${javaVersion} is available on PATH.`,
149
+ suggested_fix: "",
150
+ });
151
+ }
152
+ } else {
153
+ checks.push({
154
+ check: "java -version",
155
+ status: "fail",
156
+ reason: "java command not found or did not report a version.",
157
+ suggested_fix:
158
+ "Install a JDK (17+ recommended for modern Android) and add its bin directory to PATH.",
159
+ });
160
+ }
161
+
162
+ // 2. JAVA_HOME ----------------------------------------------------------
163
+ const javaHome = process.env.JAVA_HOME || "";
164
+ if (javaHome && isDirectory(javaHome)) {
165
+ checks.push({
166
+ check: "JAVA_HOME",
167
+ status: "ok",
168
+ reason: `JAVA_HOME is set to ${javaHome}.`,
169
+ suggested_fix: "",
170
+ });
171
+ } else if (javaHome) {
172
+ checks.push({
173
+ check: "JAVA_HOME",
174
+ status: "warn",
175
+ reason: `JAVA_HOME is set to "${javaHome}" but that directory does not exist.`,
176
+ suggested_fix: "Point JAVA_HOME at a valid JDK installation directory.",
177
+ });
178
+ } else {
179
+ checks.push({
180
+ check: "JAVA_HOME",
181
+ status: "warn",
182
+ reason: "JAVA_HOME is not set. Gradle may be unable to locate the JDK.",
183
+ suggested_fix:
184
+ "Set JAVA_HOME to your JDK installation (e.g. C:\\Program Files\\Java\\jdk-17).",
185
+ });
186
+ }
187
+
188
+ // 3. ANDROID_HOME -------------------------------------------------------
189
+ if (!androidHomeEnv) {
190
+ if (!sdkExists) {
191
+ checks.push({
192
+ check: "ANDROID_HOME",
193
+ status: "fail",
194
+ reason: `ANDROID_HOME is not set. ${SDK_MISSING_REASON}`,
195
+ suggested_fix:
196
+ "Install the Android SDK and set ANDROID_HOME to its root directory " +
197
+ "(e.g. C:\\Users\\<you>\\AppData\\Local\\Android\\Sdk).",
198
+ });
199
+ } else {
200
+ checks.push({
201
+ check: "ANDROID_HOME",
202
+ status: "warn",
203
+ reason:
204
+ "ANDROID_HOME is not set. ANDROID_SDK_ROOT points at an SDK, but ANDROID_HOME is the preferred variable.",
205
+ suggested_fix: "Set ANDROID_HOME to the same value as ANDROID_SDK_ROOT.",
206
+ });
207
+ }
208
+ } else if (!isDirectory(androidHomeEnv)) {
209
+ checks.push({
210
+ check: "ANDROID_HOME",
211
+ status: "fail",
212
+ reason:
213
+ `ANDROID_HOME is set to "${androidHomeEnv}" but that directory does not exist.` +
214
+ (sdkExists ? "" : ` ${SDK_MISSING_REASON}`),
215
+ suggested_fix:
216
+ "Install the Android SDK at the ANDROID_HOME path, or correct the variable to point at an existing SDK.",
217
+ });
218
+ } else {
219
+ checks.push({
220
+ check: "ANDROID_HOME",
221
+ status: "ok",
222
+ reason: `ANDROID_HOME is set to ${androidHomeEnv}.`,
223
+ suggested_fix: "",
224
+ });
225
+ }
226
+
227
+ // 4. ANDROID_SDK_ROOT ---------------------------------------------------
228
+ if (androidSdkRootEnv && isDirectory(androidSdkRootEnv)) {
229
+ checks.push({
230
+ check: "ANDROID_SDK_ROOT",
231
+ status: "ok",
232
+ reason: `ANDROID_SDK_ROOT is set to ${androidSdkRootEnv}.`,
233
+ suggested_fix: "",
234
+ });
235
+ } else if (androidSdkRootEnv) {
236
+ checks.push({
237
+ check: "ANDROID_SDK_ROOT",
238
+ status: "warn",
239
+ reason: `ANDROID_SDK_ROOT is set to "${androidSdkRootEnv}" but that directory does not exist.`,
240
+ suggested_fix: "Point ANDROID_SDK_ROOT at a valid Android SDK directory.",
241
+ });
242
+ } else {
243
+ checks.push({
244
+ check: "ANDROID_SDK_ROOT",
245
+ status: "warn",
246
+ reason:
247
+ "ANDROID_SDK_ROOT is not set. Some tools fall back to ANDROID_HOME, but setting both is recommended.",
248
+ suggested_fix: "Set ANDROID_SDK_ROOT to the same value as ANDROID_HOME.",
249
+ });
250
+ }
251
+
252
+ // 5. Android SDK platform ----------------------------------------------
253
+ if (!sdkExists) {
254
+ checks.push({
255
+ check: "Android SDK platform",
256
+ status: "skip",
257
+ reason: `Skipped: Android SDK not available. ${SDK_MISSING_REASON}`,
258
+ suggested_fix: "Install the Android SDK and a platform via sdkmanager.",
259
+ });
260
+ } else {
261
+ const platformsDir = join(sdkRoot, "platforms");
262
+ if (isDirectory(platformsDir)) {
263
+ checks.push({
264
+ check: "Android SDK platform",
265
+ status: "ok",
266
+ reason: `Android SDK platforms directory found at ${platformsDir}.`,
267
+ suggested_fix: "",
268
+ });
269
+ } else {
270
+ checks.push({
271
+ check: "Android SDK platform",
272
+ status: "warn",
273
+ reason: `No platforms directory found at ${platformsDir}. No Android platform is installed.`,
274
+ suggested_fix: 'Run: sdkmanager "platforms;android-34"',
275
+ });
276
+ }
277
+ }
278
+
279
+ // 6. Android build-tools ------------------------------------------------
280
+ if (!sdkExists) {
281
+ checks.push({
282
+ check: "Android build-tools",
283
+ status: "skip",
284
+ reason: `Skipped: Android SDK not available. ${SDK_MISSING_REASON}`,
285
+ suggested_fix: "Install the Android SDK and build-tools via sdkmanager.",
286
+ });
287
+ } else {
288
+ const buildToolsDir = join(sdkRoot, "build-tools");
289
+ if (isDirectory(buildToolsDir)) {
290
+ checks.push({
291
+ check: "Android build-tools",
292
+ status: "ok",
293
+ reason: `Android build-tools directory found at ${buildToolsDir}.`,
294
+ suggested_fix: "",
295
+ });
296
+ } else {
297
+ checks.push({
298
+ check: "Android build-tools",
299
+ status: "warn",
300
+ reason: `No build-tools directory found at ${buildToolsDir}.`,
301
+ suggested_fix: 'Run: sdkmanager "build-tools;34.0.0"',
302
+ });
303
+ }
304
+ }
305
+
306
+ // 7. Gradle wrapper -----------------------------------------------------
307
+ const gradlewUnix = join(androidAppPath, "gradlew");
308
+ const gradlewWin = join(androidAppPath, "gradlew.bat");
309
+ const wrapperProps = join(androidAppPath, "gradle", "wrapper", "gradle-wrapper.properties");
310
+ let gradleVersion: string | null = null;
311
+ const propsContent = readTextFile(wrapperProps);
312
+ if (propsContent) {
313
+ const match = propsContent.match(/gradle-([\d.]+)-/);
314
+ gradleVersion = match ? match[1] : null;
315
+ }
316
+ if (existsSync(gradlewUnix) || existsSync(gradlewWin)) {
317
+ checks.push({
318
+ check: "Gradle wrapper",
319
+ status: "ok",
320
+ reason: gradleVersion
321
+ ? `Gradle wrapper found in android_app (Gradle ${gradleVersion}).`
322
+ : "Gradle wrapper found in android_app.",
323
+ suggested_fix: "",
324
+ });
325
+ } else {
326
+ checks.push({
327
+ check: "Gradle wrapper",
328
+ status: "warn",
329
+ reason: "No gradlew or gradlew.bat found in android_app.",
330
+ suggested_fix:
331
+ "Generate the wrapper inside android_app with: gradle wrapper (or copy gradlew/gradlew.bat/gradle-wrapper.jar from an existing project).",
332
+ });
333
+ }
334
+
335
+ // 8. settings.gradle.kts -----------------------------------------------
336
+ const settingsGradleKts = join(androidAppPath, "settings.gradle.kts");
337
+ const settingsGradleGroovy = join(androidAppPath, "settings.gradle");
338
+ if (existsSync(settingsGradleKts)) {
339
+ checks.push({
340
+ check: "android_app/settings.gradle.kts",
341
+ status: "ok",
342
+ reason: "settings.gradle.kts found in android_app.",
343
+ suggested_fix: "",
344
+ });
345
+ } else if (existsSync(settingsGradleGroovy)) {
346
+ checks.push({
347
+ check: "android_app/settings.gradle.kts",
348
+ status: "warn",
349
+ reason: "settings.gradle.kts not found, but settings.gradle (Groovy DSL) is present.",
350
+ suggested_fix: "Kotlin DSL is preferred; consider migrating settings.gradle to settings.gradle.kts.",
351
+ });
352
+ } else {
353
+ checks.push({
354
+ check: "android_app/settings.gradle.kts",
355
+ status: "warn",
356
+ reason: "settings.gradle.kts not found in android_app.",
357
+ suggested_fix: "Create android_app/settings.gradle.kts declaring the project modules.",
358
+ });
359
+ }
360
+
361
+ // 9. app/build.gradle.kts ----------------------------------------------
362
+ const appBuildGradleKts = join(androidAppPath, "app", "build.gradle.kts");
363
+ const appBuildGradleGroovy = join(androidAppPath, "app", "build.gradle");
364
+ if (existsSync(appBuildGradleKts)) {
365
+ checks.push({
366
+ check: "android_app/app/build.gradle.kts",
367
+ status: "ok",
368
+ reason: "app/build.gradle.kts found.",
369
+ suggested_fix: "",
370
+ });
371
+ } else if (existsSync(appBuildGradleGroovy)) {
372
+ checks.push({
373
+ check: "android_app/app/build.gradle.kts",
374
+ status: "warn",
375
+ reason: "app/build.gradle.kts not found, but app/build.gradle (Groovy DSL) is present.",
376
+ suggested_fix: "Kotlin DSL is preferred; consider migrating app/build.gradle to app/build.gradle.kts.",
377
+ });
378
+ } else {
379
+ checks.push({
380
+ check: "android_app/app/build.gradle.kts",
381
+ status: "warn",
382
+ reason: "app/build.gradle.kts not found in android_app/app.",
383
+ suggested_fix: "Create android_app/app/build.gradle.kts with the Android application plugin and build config.",
384
+ });
385
+ }
386
+
387
+ // 10. APK output path ---------------------------------------------------
388
+ const apkDir = join(androidAppPath, "app", "build", "outputs", "apk");
389
+ if (!sdkExists) {
390
+ checks.push({
391
+ check: "APK output path",
392
+ status: "skip",
393
+ reason: SDK_MISSING_REASON,
394
+ suggested_fix: "Install the Android SDK, then build with: cd android_app && ./gradlew assembleDebug",
395
+ });
396
+ } else if (isDirectory(apkDir)) {
397
+ checks.push({
398
+ check: "APK output path",
399
+ status: "ok",
400
+ reason: `APK output directory found at ${apkDir}.`,
401
+ suggested_fix: "",
402
+ });
403
+ } else {
404
+ checks.push({
405
+ check: "APK output path",
406
+ status: "warn",
407
+ reason: "APK output directory not found. The project has not been built yet.",
408
+ suggested_fix: "Build the APK: cd android_app && ./gradlew assembleDebug",
409
+ });
410
+ }
411
+
412
+ // Overall status: fail dominates warn dominates ok.
413
+ const hasFail = checks.some((c) => c.status === "fail");
414
+ const hasWarn = checks.some((c) => c.status === "warn");
415
+ const overall: "ok" | "warn" | "fail" = hasFail ? "fail" : hasWarn ? "warn" : "ok";
416
+
417
+ return {
418
+ status: overall,
419
+ repo_path: repoPath,
420
+ android_app_path: androidAppPath,
421
+ checks,
422
+ checked_at: checkedAt,
423
+ };
424
+ }
@@ -0,0 +1,86 @@
1
+ import { getConfig } from "../config.js";
2
+ import {
3
+ readDirectSession,
4
+ appendDirectSessionOperation,
5
+ } from "../direct/directSessionStore.js";
6
+ import {
7
+ guardDirectSessionActive,
8
+ guardDirectWritePath,
9
+ guardDirectPatchSize,
10
+ } from "../direct/directGuards.js";
11
+ import {
12
+ applyPatchOperations,
13
+ validateExpectedSha256,
14
+ computeFileSha256,
15
+ type PatchOperation,
16
+ } from "../direct/directPatch.js";
17
+
18
+ export interface ApplyPatchInput {
19
+ session_id: string;
20
+ path: string;
21
+ expected_sha256: string;
22
+ operations: PatchOperation[];
23
+ }
24
+
25
+ export interface ApplyPatchOutput {
26
+ path: string;
27
+ before_sha256: string;
28
+ after_sha256: string;
29
+ operations_applied: number;
30
+ bytes_changed: number;
31
+ next_action: string;
32
+ }
33
+
34
+ export function applyPatch(input: ApplyPatchInput): ApplyPatchOutput {
35
+ const config = getConfig();
36
+
37
+ // 1. Read session and guard active (not expired, not finalized)
38
+ const session = readDirectSession(input.session_id);
39
+ guardDirectSessionActive(session);
40
+
41
+ // 2. Guard write path (inside repo, not sensitive, not binary,
42
+ // not node_modules/release/dist)
43
+ const resolvedPath = guardDirectWritePath(
44
+ input.path,
45
+ session.resolved_repo_path,
46
+ config.workspaceRoot
47
+ );
48
+
49
+ // 3. Calculate patch size and guard against directMaxPatchBytes
50
+ const patchBytes = JSON.stringify(input.operations).length;
51
+ guardDirectPatchSize(patchBytes);
52
+
53
+ // 4. Validate expected_sha256 matches current file hash
54
+ const beforeSha256 = validateExpectedSha256(
55
+ resolvedPath,
56
+ input.expected_sha256
57
+ );
58
+
59
+ // 5. Apply patch operations
60
+ const patchResult = applyPatchOperations(resolvedPath, input.operations);
61
+
62
+ // Re-read the file from disk to compute the authoritative after_sha256
63
+ const afterSha256 = computeFileSha256(resolvedPath);
64
+
65
+ // 6. Append operation record to session
66
+ appendDirectSessionOperation(input.session_id, {
67
+ index: session.operations.length,
68
+ timestamp: new Date().toISOString(),
69
+ path: input.path,
70
+ before_sha256: beforeSha256,
71
+ after_sha256: afterSha256,
72
+ operations_applied: patchResult.operations_applied,
73
+ bytes_changed: patchResult.bytes_changed,
74
+ });
75
+
76
+ // 7. Return result
77
+ return {
78
+ path: input.path,
79
+ before_sha256: beforeSha256,
80
+ after_sha256: afterSha256,
81
+ operations_applied: patchResult.operations_applied,
82
+ bytes_changed: patchResult.bytes_changed,
83
+ next_action:
84
+ "Call run_verification to test the changes, or apply_patch for more edits.",
85
+ };
86
+ }