@capagents/forgeqe 1.3.1 → 1.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.
- package/package.json +1 -1
- package/py/forgeqe_engine/cli.py +10 -0
- package/py/forgeqe_engine/summary.py +6 -0
- package/src/cases/defects.ts +2 -1
- package/src/engine/setup.ts +3 -0
- package/src/update.ts +118 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@capagents/forgeqe",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.2",
|
|
4
4
|
"description": "ForgeQE: a browser agent CLI that runs goals and test cases in local Chrome or Chromium, or BrowserStack and TestMu AI/LambdaTest when configured, and turns passing runs into tests in your repo",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/py/forgeqe_engine/cli.py
CHANGED
|
@@ -171,8 +171,18 @@ def _token(out: TextIO) -> int:
|
|
|
171
171
|
return 1
|
|
172
172
|
|
|
173
173
|
|
|
174
|
+
def _utf8_stdio() -> None:
|
|
175
|
+
"""The CLI speaks UTF-8 on every pipe; on Windows a pipe defaults to the ANSI code page, which turns a curly quote into a lone surrogate."""
|
|
176
|
+
for stream in (sys.stdin, sys.stderr):
|
|
177
|
+
try:
|
|
178
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
179
|
+
except (AttributeError, ValueError):
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
|
|
174
183
|
def main(argv: list[str] | None = None) -> int:
|
|
175
184
|
args = list(sys.argv[1:] if argv is None else argv)
|
|
185
|
+
_utf8_stdio()
|
|
176
186
|
out = _event_stream()
|
|
177
187
|
_configure_env()
|
|
178
188
|
cmd = args[0] if args else "run"
|
|
@@ -81,6 +81,12 @@ def _error_summary_from_history(history: Any) -> str:
|
|
|
81
81
|
n = len(errs)
|
|
82
82
|
if len(distinct) == 1:
|
|
83
83
|
msg = distinct[0]
|
|
84
|
+
if n > 1 and "surrogates not allowed" in msg:
|
|
85
|
+
return (
|
|
86
|
+
f"Run stopped after {n} identical errors: {msg}. "
|
|
87
|
+
"The request to the model held text that was decoded in the wrong encoding, so it was never sent; "
|
|
88
|
+
"the LLM provider is not at fault. Update ForgeQE; the engine now reads its input as UTF-8."
|
|
89
|
+
)
|
|
84
90
|
if n > 1:
|
|
85
91
|
return (
|
|
86
92
|
f"Run stopped after {n} identical errors: {msg}. "
|
package/src/cases/defects.ts
CHANGED
|
@@ -196,7 +196,8 @@ export class DefectBook {
|
|
|
196
196
|
.filter(({ p }) => !this.noise.has(problemKey(p)))
|
|
197
197
|
// The strongest kind; of those, the one nearest the failure (the latest step).
|
|
198
198
|
.sort((a, b) => rank(a.p) - rank(b.p) || b.step - a.step)[0]?.p
|
|
199
|
-
|
|
199
|
+
// about:blank and other non-web pages are no page: their key would read "nullblank".
|
|
200
|
+
const page = r.finalUrl && /^https?:/i.test(r.finalUrl) ? pageKey(r.finalUrl) : undefined
|
|
200
201
|
if (evidence) {
|
|
201
202
|
const key = problemKey(evidence)
|
|
202
203
|
const term = problemTerm(evidence)
|
package/src/engine/setup.ts
CHANGED
|
@@ -68,6 +68,9 @@ export function engineEnv(extra?: Record<string, string>, allow: string[] = []):
|
|
|
68
68
|
const env = allowListEnv(process.env, allow)
|
|
69
69
|
env.PYTHONPATH = [ENGINE_SOURCE, env.PYTHONPATH].filter(Boolean).join(path.delimiter)
|
|
70
70
|
env.PYTHONUNBUFFERED = "1"
|
|
71
|
+
// Requests, prompts and logs are UTF-8; Windows would otherwise read pipes and files in its ANSI code page.
|
|
72
|
+
env.PYTHONUTF8 = "1"
|
|
73
|
+
env.PYTHONIOENCODING = "utf-8"
|
|
71
74
|
return { ...env, ...extra }
|
|
72
75
|
}
|
|
73
76
|
|
package/src/update.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process"
|
|
2
|
-
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process"
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
3
4
|
import path from "node:path"
|
|
4
5
|
import { packageVersion } from "./tui/theme.ts"
|
|
5
6
|
|
|
@@ -28,19 +29,50 @@ export interface UpdateDeps {
|
|
|
28
29
|
platform: NodeJS.Platform
|
|
29
30
|
sleep: (ms: number) => Promise<void>
|
|
30
31
|
now: () => number
|
|
32
|
+
/**
|
|
33
|
+
* Windows: starts `script` (a .cmd) detached, to run once this process has exited, and returns its log file.
|
|
34
|
+
* A running ForgeQE keeps files of its own install open (its launcher, native libraries), and Windows does not
|
|
35
|
+
* let an installer replace a file that is in use, so the update cannot run from inside ForgeQE.
|
|
36
|
+
*/
|
|
37
|
+
defer?: (script: string) => string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* How to start `cmd` on `platform`. Windows runs .cmd and .bat files (npm, pyenv-win) only through a shell, which Node
|
|
42
|
+
* refuses to do on its own (EINVAL), so those go through cmd.exe with each argument quoted.
|
|
43
|
+
*/
|
|
44
|
+
export function spawnPlan(cmd: string, args: string[], platform: NodeJS.Platform): { cmd: string; args: string[]; shell: boolean } {
|
|
45
|
+
if (platform !== "win32" || /\.exe$/i.test(cmd)) return { cmd, args, shell: false }
|
|
46
|
+
return { cmd: winQuote(cmd), args: args.map(winQuote), shell: true }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One argument for cmd.exe: quoted when it has a space or a character cmd.exe would read. */
|
|
50
|
+
export function winQuote(a: string): string {
|
|
51
|
+
return /[\s&|<>^()%!"]/.test(a) ? `"${a.replace(/"/g, '""')}"` : a
|
|
31
52
|
}
|
|
32
53
|
|
|
33
54
|
export function defaultDeps(): UpdateDeps {
|
|
34
55
|
return {
|
|
35
56
|
fetch,
|
|
36
57
|
run: (cmd, args) => {
|
|
37
|
-
const
|
|
58
|
+
const plan = spawnPlan(cmd, args, process.platform)
|
|
59
|
+
const r = spawnSync(plan.cmd, plan.args, { encoding: "utf8", shell: plan.shell, windowsHide: true })
|
|
38
60
|
return { status: r.error ? null : r.status, stdout: r.stdout || "", stderr: r.stderr || (r.error ? String(r.error.message) : "") }
|
|
39
61
|
},
|
|
40
62
|
env: process.env,
|
|
41
63
|
platform: process.platform,
|
|
42
64
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
43
65
|
now: () => Date.now(),
|
|
66
|
+
defer: (script) => {
|
|
67
|
+
const dir = path.join(tmpdir(), "forgeqe-update")
|
|
68
|
+
mkdirSync(dir, { recursive: true })
|
|
69
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-")
|
|
70
|
+
const file = path.join(dir, `update-${stamp}.cmd`)
|
|
71
|
+
const log = path.join(dir, `update-${stamp}.log`)
|
|
72
|
+
writeFileSync(file, script.replace(/\{LOG\}/g, log).replace(/\n/g, "\r\n"))
|
|
73
|
+
spawn("cmd.exe", ["/d", "/c", file], { detached: true, stdio: "ignore", windowsHide: true }).unref()
|
|
74
|
+
return log
|
|
75
|
+
},
|
|
44
76
|
}
|
|
45
77
|
}
|
|
46
78
|
|
|
@@ -142,12 +174,33 @@ function packageDirOf(file: string): string | undefined {
|
|
|
142
174
|
return undefined
|
|
143
175
|
}
|
|
144
176
|
|
|
177
|
+
/**
|
|
178
|
+
* The package a `forgeqe` on PATH belongs to when it is a copy or a wrapper, not a link: npm on Windows writes
|
|
179
|
+
* forgeqe.cmd next to node_modules; bun on Windows writes forgeqe.exe in ~/.bun/bin for ~/.bun/install/global; an npm
|
|
180
|
+
* prefix elsewhere keeps packages in <prefix>/lib/node_modules.
|
|
181
|
+
*/
|
|
182
|
+
function packageOfShim(binDir: string): string | undefined {
|
|
183
|
+
const parts = NPM_PACKAGE.split("/")
|
|
184
|
+
for (const dir of [path.join(binDir, "node_modules", ...parts), path.join(binDir, "..", "install", "global", "node_modules", ...parts), path.join(binDir, "..", "lib", "node_modules", ...parts)]) {
|
|
185
|
+
const found = packageDirOf(path.join(dir, "package.json"))
|
|
186
|
+
if (found) return path.resolve(found)
|
|
187
|
+
}
|
|
188
|
+
return undefined
|
|
189
|
+
}
|
|
190
|
+
|
|
145
191
|
function pythonOfScript(script: string, platform: NodeJS.Platform): string | undefined {
|
|
146
192
|
if (platform === "win32") {
|
|
147
193
|
const dir = path.dirname(script)
|
|
148
|
-
if (path.basename(dir).toLowerCase() !== "scripts") return undefined
|
|
149
194
|
const python = path.join(path.dirname(dir), "python.exe")
|
|
150
|
-
|
|
195
|
+
if (path.basename(dir).toLowerCase() === "scripts" && existsSync(python)) return python
|
|
196
|
+
// pip's forgeqe.exe carries the Python it was installed for (`#!C:\...\python.exe` near its end): the way to find
|
|
197
|
+
// it for `pip install --user`, whose Scripts folder is not next to python.exe.
|
|
198
|
+
try {
|
|
199
|
+
const text = readFileSync(script).toString("latin1")
|
|
200
|
+
const m = [...text.matchAll(/#!\s*"?([^"\r\n]*?pythonw?\.exe)"?/gi)].pop()
|
|
201
|
+
if (m && existsSync(m[1])) return m[1]
|
|
202
|
+
} catch {}
|
|
203
|
+
return undefined
|
|
151
204
|
}
|
|
152
205
|
let first = ""
|
|
153
206
|
try {
|
|
@@ -182,7 +235,10 @@ export function findInstalls(deps: UpdateDeps): Install[] {
|
|
|
182
235
|
}
|
|
183
236
|
if (seen.has(target)) continue
|
|
184
237
|
seen.add(target)
|
|
185
|
-
const pkgDir = packageDirOf(target)
|
|
238
|
+
const pkgDir = packageDirOf(target) || packageOfShim(dir)
|
|
239
|
+
// npm on Windows writes forgeqe, forgeqe.cmd and forgeqe.ps1 for one install.
|
|
240
|
+
if (pkgDir && seen.has(`pkg:${pkgDir}`)) continue
|
|
241
|
+
if (pkgDir) seen.add(`pkg:${pkgDir}`)
|
|
186
242
|
if (pkgDir) {
|
|
187
243
|
let version = ""
|
|
188
244
|
try {
|
|
@@ -233,6 +289,47 @@ export function commandLine([cmd, args]: [string, string[]]): string {
|
|
|
233
289
|
return [cmd, ...args].map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(" ")
|
|
234
290
|
}
|
|
235
291
|
|
|
292
|
+
/** What to do when an installer refuses for a reason the machine sets, not ForgeQE (Linux: root-owned npm, PEP 668). */
|
|
293
|
+
export function failureHint(kind: InstallKind, output: string, platform: NodeJS.Platform): string {
|
|
294
|
+
if (/EACCES|permission denied/i.test(output) && kind === "npm") {
|
|
295
|
+
return platform === "win32"
|
|
296
|
+
? "npm's global folder is not writable: run the terminal as administrator, or install with bun (bun add -g @capagents/forgeqe)."
|
|
297
|
+
: "npm's global folder belongs to root: run sudo npm install -g @capagents/forgeqe, or give npm a folder of your own (npm config set prefix ~/.npm-global)."
|
|
298
|
+
}
|
|
299
|
+
if (/externally-managed-environment/i.test(output)) {
|
|
300
|
+
return "This Python is managed by the system and pip will not change it: install with pipx install forgeqe-cli, or in a virtual environment."
|
|
301
|
+
}
|
|
302
|
+
if (/EBUSY|EPERM|being used by another process/i.test(output)) {
|
|
303
|
+
return "A file of the install is in use: close every ForgeQE window and run forgeqe update again."
|
|
304
|
+
}
|
|
305
|
+
return ""
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The .cmd that updates Windows installs after the ForgeQE that started it has exited: it waits for these processes
|
|
310
|
+
* (ForgeQE and its launcher), runs each install's commands, and writes everything to {LOG}.
|
|
311
|
+
*/
|
|
312
|
+
export function windowsUpdateScript(steps: { kind: InstallKind; bin: string; commands: Array<[string, string[]]> }[], pids: number[], version: string): string {
|
|
313
|
+
const lines = ["@echo off", "setlocal", ":wait"]
|
|
314
|
+
for (const pid of pids.filter((p) => Number.isInteger(p) && p > 0)) {
|
|
315
|
+
// ping waits a second where timeout cannot (a detached script has no console); System32 paths, so Git's find
|
|
316
|
+
// or another tasklist on PATH is never the one that runs.
|
|
317
|
+
lines.push(`"%SystemRoot%\\System32\\tasklist.exe" /FI "PID eq ${pid}" 2>nul | "%SystemRoot%\\System32\\find.exe" " ${pid} " >nul && (ping -n 2 127.0.0.1 >nul & goto wait)`)
|
|
318
|
+
}
|
|
319
|
+
lines.push(`echo Updating ForgeQE to ${version} > "{LOG}"`)
|
|
320
|
+
for (const step of steps) {
|
|
321
|
+
lines.push(`echo. >> "{LOG}"`, `echo Updating the ${step.kind} install (${step.bin}) >> "{LOG}"`)
|
|
322
|
+
for (const [cmd, args] of step.commands) {
|
|
323
|
+
const line = [cmd, ...args].map(winQuote).join(" ")
|
|
324
|
+
const cacheStep = args[0] === "pm" && args[1] === "cache"
|
|
325
|
+
lines.push(`echo $ ${line.replace(/[&|<>^]/g, "^$&")} >> "{LOG}"`)
|
|
326
|
+
lines.push(`call ${line} >> "{LOG}" 2>&1${cacheStep ? "" : ` || echo FAILED: ${step.kind} install >> "{LOG}"`}`)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
lines.push(`echo. >> "{LOG}"`, `echo Done. Open a new terminal and run: forgeqe --version >> "{LOG}"`)
|
|
330
|
+
return `${lines.join("\n")}\n`
|
|
331
|
+
}
|
|
332
|
+
|
|
236
333
|
export interface UpdateOptions {
|
|
237
334
|
/** A version to install instead of the latest. */
|
|
238
335
|
version?: string
|
|
@@ -283,6 +380,16 @@ export async function update(opts: UpdateOptions = {}, deps: UpdateDeps = defaul
|
|
|
283
380
|
}
|
|
284
381
|
}
|
|
285
382
|
|
|
383
|
+
if (deps.platform === "win32" && deps.defer) {
|
|
384
|
+
// Windows will not replace files the running ForgeQE holds open, so the installs update once it has exited.
|
|
385
|
+
const steps = stale.map((inst) => ({ kind: inst.kind, bin: inst.bin, commands: updateCommands(inst, target, deps.env) }))
|
|
386
|
+
for (const step of steps) for (const c of step.commands) log(` $ ${commandLine(c)}`)
|
|
387
|
+
const logFile = deps.defer(windowsUpdateScript(steps, [process.pid, process.ppid], target))
|
|
388
|
+
log(`\nThe update to ${target} starts as soon as this command exits (Windows locks the files a running ForgeQE uses).`)
|
|
389
|
+
log(`It writes what it does to ${logFile}. Then open a new terminal and run forgeqe --version.`)
|
|
390
|
+
return 0
|
|
391
|
+
}
|
|
392
|
+
|
|
286
393
|
let failed = 0
|
|
287
394
|
for (const inst of stale) {
|
|
288
395
|
log(`\nUpdating the ${inst.kind} install (${inst.bin})`)
|
|
@@ -293,7 +400,10 @@ export async function update(opts: UpdateOptions = {}, deps: UpdateDeps = defaul
|
|
|
293
400
|
const cacheStep = c[1][0] === "pm" && c[1][1] === "cache"
|
|
294
401
|
if (cacheStep) continue
|
|
295
402
|
failed++
|
|
296
|
-
|
|
403
|
+
const output = (r.stderr || r.stdout).trim()
|
|
404
|
+
log(output.split("\n").slice(-5).map((l) => ` ${l}`).join("\n"))
|
|
405
|
+
const hint = failureHint(inst.kind, output, deps.platform)
|
|
406
|
+
if (hint) log(` ${hint}`)
|
|
297
407
|
break
|
|
298
408
|
}
|
|
299
409
|
}
|
|
@@ -306,6 +416,6 @@ export async function update(opts: UpdateOptions = {}, deps: UpdateDeps = defaul
|
|
|
306
416
|
log(`\n${behind.length || failed} install(s) are still behind ${target}; the output above says why.`)
|
|
307
417
|
return 1
|
|
308
418
|
}
|
|
309
|
-
log(`\nforgeqe ${target} is installed. Open a new terminal (or run hash -r) if the shell still finds the old one.`)
|
|
419
|
+
log(`\nforgeqe ${target} is installed. Open a new terminal${deps.platform === "win32" ? "" : " (or run hash -r)"} if the shell still finds the old one.`)
|
|
310
420
|
return 0
|
|
311
421
|
}
|