@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capagents/forgeqe",
3
- "version": "1.3.1",
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",
@@ -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}. "
@@ -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
- const page = r.finalUrl ? pageKey(r.finalUrl) : undefined
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)
@@ -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 r = spawnSync(cmd, args, { encoding: "utf8" })
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
- return existsSync(python) ? python : undefined
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
- log((r.stderr || r.stdout).trim().split("\n").slice(-5).map((l) => ` ${l}`).join("\n"))
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
  }