qapture2 0.9.1 → 0.10.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/dist/bin/init.cjs CHANGED
@@ -24,8 +24,8 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  ));
25
25
 
26
26
  // src/bin/init.ts
27
- var path6 = __toESM(require("path"), 1);
28
- var fs6 = __toESM(require("fs"), 1);
27
+ var path7 = __toESM(require("path"), 1);
28
+ var fs7 = __toESM(require("fs"), 1);
29
29
  var process2 = __toESM(require("process"), 1);
30
30
 
31
31
  // src/artifacts/SKILL.md
@@ -35,13 +35,20 @@ var SKILL_default = "---\nname: qapture\ndescription: >\n Activated when the us
35
35
  var AGENTS_SECTION_default = "<!-- qa-studio-section -->\n\n## Qapture\n\nThis project uses **Qapture** \u2014 an in-browser QA capture widget that ships\n**zero AI** (no model, no API keys, no network calls). **You** are the AI.\n\n### When you receive a `qa-notes-*.zip`\n\n1. **Unzip** the file.\n2. **Read `notes.md` top-to-bottom**, starting with everything above the\n `---NOTES---` separator:\n - **Project context** \u2014 name, stack, run commands, conventions.\n - **Login Context** \u2014 dev/test/seed credentials for the relevant roles.\n _(DEV/TEST/SEED only \u2014 never commit, log, or forward these values.)_\n - **Coverage Report** \u2014 red/amber/green zone checklist.\n - **Invariants** \u2014 rules you must never violate (e.g. \"prices \u2265 0\",\n \"checkout requires auth\").\n3. **Flag uncovered RED zones** before acting. RED = money / auth / irreversible\n state. If any red zone has no annotation in this ZIP, report it and ask the\n developer whether to proceed.\n4. **Triage the whole batch before touching code.** Read every point first \u2014\n points on different pages can share one root cause (check their runtime\n context for a repeated failing URL or error message) and deserve one fix,\n not N. Write the plan down before editing anything.\n5. **Orchestrate, don't grind through it yourself.** For anything past a\n single trivial point: you're the brain, not the muscle. Spawn one Sonnet\n subagent per point/cluster (model pinned explicitly on every call, never\n inherited; effort is your judgment call per task), parallelized across\n points that touch disjoint files \u2014 never two agents on the same file. You\n supervise by checking what each subagent's report actually claims and by\n running the project's own verify/test command yourself afterward, not by\n rereading every diff or trusting a subagent's \"done.\"\n6. **Each point/cluster, before fixing:**\n - **Page** + **Selector** + **Note** \u2192 locate the element in the source\n (priority: `#id` \u2192 `[data-testid]` \u2192 `aria-label` \u2192 `name` \u2192 visual match\n via the screenshot named in the point's **Screenshot** line \u2014\n `screenshots/point-N.webp`, or `.png` on browsers without WebP).\n - **Severity** (`bug`/`question`/`polish`) and **Status**\n (`open`/`fixed`/`verified` \u2014 `fixed` means someone says it is done but\n nobody has re-tested it yet)\n tell you how to treat the point \u2014 a `question` may not need a code\n change; a `verified` point was already re-checked once.\n - **Runtime context** (when present, in a collapsed `<details>` block) \u2014\n recent console errors/warnings and failed network calls captured right\n before the tester clicked capture, plus an environment snapshot, plus\n **forensics** (contrast/accessibility flags on the captured element) when\n present \u2014 treat these as objective acceptance criteria, not just the\n tester's prose. Read it before assuming a UI-only cause. Query strings in\n any URL there are already redacted; bodies, headers, cookies, and storage\n were never captured at all.\n - **Reproduce it live first** \u2014 run the app, log in as the relevant role,\n navigate to the page, actually trigger the failure \u2014 before writing a\n fix. A static screenshot can hide a stale report or an interaction-only\n bug.\n - Make the change following the project conventions and invariants, then\n re-verify the same repro.\n7. **Report** a summary table of changes, risk levels, and coverage status,\n plus a `### Suggestions` section for anything noticed beyond the reported\n points. Whether you may *implement* an extra idea (not just suggest it)\n follows the same red/amber/green gating as everything else: green \u2014 do it\n inline; amber \u2014 do it and disclose it; red \u2014 propose only, never touch it.\n\n### A single point, no ZIP\n\nA tester can also send you **one point directly**, pasted via Qapture's\n\"Copy as agent prompt\" button, with no ZIP and no preamble. Treat it exactly\nlike one point from step 6 above \u2014 there's nothing to cluster or triage with\nonly one point, and there's no Coverage Report to check or RED-zone flag to\nraise, since there's no journey context at all. Just fix it yourself if it's\ntrivial, or spawn a single subagent if it isn't.\n\n### Full protocol\n\n`.claude/skills/qapture/SKILL.md` (always kept current by `qapture init`).\n\n### Rules\n\n- Never read `.env`, `.env.local`, `.env.production`, or any `secrets/` path.\n- Never edit `qa.config.ts`, `qa.preamble.md`, or any qapture plugin files.\n- Never push/publish/deploy without explicit human approval.\n- Dev/test/seed credentials only \u2014 never use or request production credentials.\n\n_Qapture \u2014 https://github.com/mohammed-farhood/qapture_\n\n<!-- /qa-studio-section -->\n";
36
36
 
37
37
  // src/bin/utils/args.ts
38
+ var DEFAULT_SHOT_PORT = 7017;
38
39
  function parseArgs(argv2) {
39
40
  const [cmd, ...rest] = argv2;
41
+ const base = {
42
+ dir: process.cwd(),
43
+ force: false,
44
+ port: DEFAULT_SHOT_PORT,
45
+ allow: []
46
+ };
40
47
  if (cmd === "version" || cmd === "--version" || cmd === "-v" || cmd === "-V") {
41
- return { command: "version", dir: process.cwd(), force: false };
48
+ return { ...base, command: "version" };
42
49
  }
43
50
  if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
44
- return { command: "help", dir: process.cwd(), force: false };
51
+ return { ...base, command: "help" };
45
52
  }
46
53
  if (cmd === "init") {
47
54
  let dir = process.cwd();
@@ -55,54 +62,229 @@ function parseArgs(argv2) {
55
62
  dir = arg;
56
63
  }
57
64
  }
58
- return { command: "init", dir, force };
65
+ return { ...base, command: "init", dir, force };
66
+ }
67
+ if (cmd === "shots") {
68
+ let port = DEFAULT_SHOT_PORT;
69
+ const allow = [];
70
+ for (let i = 0; i < rest.length; i++) {
71
+ const arg = rest[i];
72
+ if (arg === "--port" || arg === "-p") {
73
+ const n = Number(rest[++i]);
74
+ if (Number.isInteger(n) && n > 0 && n < 65536) port = n;
75
+ } else if (arg.startsWith("--port=")) {
76
+ const n = Number(arg.slice("--port=".length));
77
+ if (Number.isInteger(n) && n > 0 && n < 65536) port = n;
78
+ } else if (arg === "--allow") {
79
+ const origin = rest[++i];
80
+ if (origin) allow.push(origin.replace(/\/$/, ""));
81
+ } else if (arg.startsWith("--allow=")) {
82
+ const origin = arg.slice("--allow=".length);
83
+ if (origin) allow.push(origin.replace(/\/$/, ""));
84
+ }
85
+ }
86
+ return { ...base, command: "shots", port, allow };
59
87
  }
60
- return { command: "help", dir: process.cwd(), force: false };
88
+ return { ...base, command: "help" };
61
89
  }
62
90
 
63
- // src/bin/utils/writeIdempotent.ts
91
+ // src/bin/shotServer.ts
92
+ var http = __toESM(require("http"), 1);
93
+ var os = __toESM(require("os"), 1);
64
94
  var fs = __toESM(require("fs"), 1);
65
95
  var path = __toESM(require("path"), 1);
96
+ var import_node_child_process = require("child_process");
97
+ var MAX_SIDE = 2e4;
98
+ var CAPTURE_TIMEOUT_MS = 8e3;
99
+ function isLoopbackOrigin(origin) {
100
+ try {
101
+ const u = new URL(origin);
102
+ return u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]" || u.hostname === "::1";
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+ function originAllowed(origin, allow) {
108
+ if (!origin) return false;
109
+ if (isLoopbackOrigin(origin)) return true;
110
+ return allow.includes(origin);
111
+ }
112
+ function corsHeaders(origin) {
113
+ return {
114
+ "Access-Control-Allow-Origin": origin,
115
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
116
+ "Access-Control-Allow-Headers": "content-type",
117
+ // Chrome's Private Network Access check: an https page reaching a loopback
118
+ // address is asked for explicitly, and refused unless we say yes here.
119
+ "Access-Control-Allow-Private-Network": "true",
120
+ "Vary": "Origin"
121
+ };
122
+ }
123
+ function sendJson(res, status, body, extra = {}) {
124
+ const text = JSON.stringify(body);
125
+ res.writeHead(status, {
126
+ "Content-Type": "application/json",
127
+ "Content-Length": Buffer.byteLength(text),
128
+ "Cache-Control": "no-store",
129
+ ...extra
130
+ });
131
+ res.end(text);
132
+ }
133
+ function readBody(req, limit = 4096) {
134
+ return new Promise((resolve2, reject) => {
135
+ let size = 0;
136
+ const chunks = [];
137
+ req.on("data", (c) => {
138
+ size += c.length;
139
+ if (size > limit) {
140
+ reject(new Error("body too large"));
141
+ req.destroy();
142
+ return;
143
+ }
144
+ chunks.push(c);
145
+ });
146
+ req.on("end", () => resolve2(Buffer.concat(chunks).toString("utf8")));
147
+ req.on("error", reject);
148
+ });
149
+ }
150
+ function captureRect(rect) {
151
+ const file = path.join(
152
+ os.tmpdir(),
153
+ `qapture-shot-${process.pid}-${Date.now()}.png`
154
+ );
155
+ const region = `${Math.round(rect.x)},${Math.round(rect.y)},${Math.round(rect.w)},${Math.round(rect.h)}`;
156
+ return new Promise((resolve2, reject) => {
157
+ (0, import_node_child_process.execFile)(
158
+ "/usr/sbin/screencapture",
159
+ ["-x", "-o", "-r", "-t", "png", "-R", region, file],
160
+ { timeout: CAPTURE_TIMEOUT_MS },
161
+ (err) => {
162
+ if (err) {
163
+ fs.promises.unlink(file).catch(() => {
164
+ });
165
+ reject(err);
166
+ return;
167
+ }
168
+ fs.promises.readFile(file).then((buf) => {
169
+ fs.promises.unlink(file).catch(() => {
170
+ });
171
+ if (!buf.length) {
172
+ reject(new Error("empty capture"));
173
+ return;
174
+ }
175
+ resolve2(buf);
176
+ }).catch((e) => {
177
+ fs.promises.unlink(file).catch(() => {
178
+ });
179
+ reject(e);
180
+ });
181
+ }
182
+ );
183
+ });
184
+ }
185
+ function parseRect(raw) {
186
+ let parsed;
187
+ try {
188
+ parsed = JSON.parse(raw);
189
+ } catch {
190
+ return null;
191
+ }
192
+ if (!parsed || typeof parsed !== "object") return null;
193
+ const o = parsed;
194
+ const nums = ["x", "y", "w", "h"].map((k) => Number(o[k]));
195
+ if (nums.some((n) => !Number.isFinite(n))) return null;
196
+ const [x, y, w, h] = nums;
197
+ if (w < 1 || h < 1 || w > MAX_SIDE || h > MAX_SIDE) return null;
198
+ return { x, y, w, h };
199
+ }
200
+ function createShotServer(opts) {
201
+ return http.createServer((req, res) => {
202
+ const origin = req.headers.origin;
203
+ const url = (req.url ?? "").split("?")[0];
204
+ if (!originAllowed(origin, opts.allow)) {
205
+ sendJson(res, 403, { error: "origin not allowed" });
206
+ return;
207
+ }
208
+ const cors = corsHeaders(origin);
209
+ if (req.method === "OPTIONS") {
210
+ res.writeHead(204, cors);
211
+ res.end();
212
+ return;
213
+ }
214
+ if (req.method === "GET" && url === "/qapture/health") {
215
+ sendJson(res, 200, { ok: true, engine: "screencapture", platform: process.platform }, cors);
216
+ return;
217
+ }
218
+ if (req.method === "POST" && url === "/qapture/shot") {
219
+ readBody(req).then((body) => {
220
+ const rect = parseRect(body);
221
+ if (!rect) {
222
+ sendJson(res, 400, { error: "bad rect" }, cors);
223
+ return;
224
+ }
225
+ return captureRect(rect).then((png) => {
226
+ if (opts.verbose) {
227
+ process.stdout.write(
228
+ ` shot ${Math.round(rect.w)}\xD7${Math.round(rect.h)} @ ${Math.round(rect.x)},${Math.round(rect.y)} \u2192 ${(png.length / 1024).toFixed(0)} KB
229
+ `
230
+ );
231
+ }
232
+ sendJson(res, 200, {
233
+ png: `data:image/png;base64,${png.toString("base64")}`
234
+ }, cors);
235
+ });
236
+ }).catch((err) => {
237
+ sendJson(res, 500, { error: err.message || "capture failed" }, cors);
238
+ });
239
+ return;
240
+ }
241
+ sendJson(res, 404, { error: "not found" }, cors);
242
+ });
243
+ }
244
+
245
+ // src/bin/utils/writeIdempotent.ts
246
+ var fs2 = __toESM(require("fs"), 1);
247
+ var path2 = __toESM(require("path"), 1);
66
248
  function writeIfAbsent(filePath, content, force) {
67
- if (!force && fs.existsSync(filePath)) {
249
+ if (!force && fs2.existsSync(filePath)) {
68
250
  return "skipped";
69
251
  }
70
252
  ensureParentDir(filePath);
71
- fs.writeFileSync(filePath, content, "utf8");
253
+ fs2.writeFileSync(filePath, content, "utf8");
72
254
  return "written";
73
255
  }
74
256
  function writeAlways(filePath, content) {
75
257
  ensureParentDir(filePath);
76
- fs.writeFileSync(filePath, content, "utf8");
258
+ fs2.writeFileSync(filePath, content, "utf8");
77
259
  }
78
260
  function ensureParentDir(filePath) {
79
- const dir = path.dirname(filePath);
80
- if (!fs.existsSync(dir)) {
81
- fs.mkdirSync(dir, { recursive: true });
261
+ const dir = path2.dirname(filePath);
262
+ if (!fs2.existsSync(dir)) {
263
+ fs2.mkdirSync(dir, { recursive: true });
82
264
  }
83
265
  }
84
266
 
85
267
  // src/bin/utils/mergeAgentsMd.ts
86
- var fs2 = __toESM(require("fs"), 1);
268
+ var fs3 = __toESM(require("fs"), 1);
87
269
  var SENTINEL_OPEN = "<!-- qa-studio-section -->";
88
270
  var SENTINEL_CLOSE = "<!-- /qa-studio-section -->";
89
271
  function mergeAgentsMd(agentsMdPath, sectionContent) {
90
- if (!fs2.existsSync(agentsMdPath)) {
272
+ if (!fs3.existsSync(agentsMdPath)) {
91
273
  writeAlways(agentsMdPath, sectionContent.trimEnd() + "\n");
92
274
  return "created";
93
275
  }
94
- const existing = fs2.readFileSync(agentsMdPath, "utf8");
276
+ const existing = fs3.readFileSync(agentsMdPath, "utf8");
95
277
  const openIdx = existing.indexOf(SENTINEL_OPEN);
96
278
  const closeIdx = existing.indexOf(SENTINEL_CLOSE);
97
279
  if (openIdx !== -1 && closeIdx !== -1 && closeIdx > openIdx) {
98
280
  const before = existing.slice(0, openIdx);
99
281
  const after = existing.slice(closeIdx + SENTINEL_CLOSE.length);
100
282
  const updated = before.replace(/\n*$/, "\n") + sectionContent.trimEnd() + "\n" + after.replace(/^\n*/, "\n");
101
- fs2.writeFileSync(agentsMdPath, updated, "utf8");
283
+ fs3.writeFileSync(agentsMdPath, updated, "utf8");
102
284
  return "replaced";
103
285
  }
104
286
  const separator = existing.trimEnd().length === 0 ? "" : "\n\n";
105
- fs2.writeFileSync(
287
+ fs3.writeFileSync(
106
288
  agentsMdPath,
107
289
  existing.trimEnd() + separator + sectionContent.trimEnd() + "\n",
108
290
  "utf8"
@@ -111,12 +293,12 @@ function mergeAgentsMd(agentsMdPath, sectionContent) {
111
293
  }
112
294
 
113
295
  // src/bin/detectors/detectRoutes.ts
114
- var path4 = __toESM(require("path"), 1);
115
- var fs4 = __toESM(require("fs"), 1);
296
+ var path5 = __toESM(require("path"), 1);
297
+ var fs5 = __toESM(require("fs"), 1);
116
298
 
117
299
  // src/bin/utils/walk.ts
118
- var fs3 = __toESM(require("fs"), 1);
119
- var path2 = __toESM(require("path"), 1);
300
+ var fs4 = __toESM(require("fs"), 1);
301
+ var path3 = __toESM(require("path"), 1);
120
302
  var SKIP_DIRS = /* @__PURE__ */ new Set([
121
303
  "node_modules",
122
304
  ".git",
@@ -136,7 +318,7 @@ function walk(dir) {
136
318
  const results = [];
137
319
  let entries;
138
320
  try {
139
- entries = fs3.readdirSync(dir, { withFileTypes: true });
321
+ entries = fs4.readdirSync(dir, { withFileTypes: true });
140
322
  } catch {
141
323
  return results;
142
324
  }
@@ -144,30 +326,30 @@ function walk(dir) {
144
326
  if (entry.isSymbolicLink()) continue;
145
327
  if (entry.isDirectory()) {
146
328
  if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
147
- results.push(...walk(path2.join(dir, entry.name)));
329
+ results.push(...walk(path3.join(dir, entry.name)));
148
330
  } else if (entry.isFile()) {
149
- results.push(path2.join(dir, entry.name));
331
+ results.push(path3.join(dir, entry.name));
150
332
  }
151
333
  }
152
334
  return results;
153
335
  }
154
336
  function readFileSafe(filePath) {
155
337
  try {
156
- return fs3.readFileSync(filePath, "utf8");
338
+ return fs4.readFileSync(filePath, "utf8");
157
339
  } catch {
158
340
  return "";
159
341
  }
160
342
  }
161
343
  function dirExists(dirPath) {
162
344
  try {
163
- return fs3.statSync(dirPath).isDirectory();
345
+ return fs4.statSync(dirPath).isDirectory();
164
346
  } catch {
165
347
  return false;
166
348
  }
167
349
  }
168
350
 
169
351
  // src/bin/utils/secretGuard.ts
170
- var path3 = __toESM(require("path"), 1);
352
+ var path4 = __toESM(require("path"), 1);
171
353
  var BLOCKED_EXACT_BASENAMES = /* @__PURE__ */ new Set([
172
354
  ".env",
173
355
  ".env.local",
@@ -223,8 +405,8 @@ var BLOCKED_PATH_SEGMENTS = [
223
405
  ];
224
406
  function assertSafeToRead(filePath) {
225
407
  const normalized = filePath.replace(/\\/g, "/");
226
- const basename3 = path3.basename(filePath);
227
- const ext = path3.extname(filePath).toLowerCase();
408
+ const basename3 = path4.basename(filePath);
409
+ const ext = path4.extname(filePath).toLowerCase();
228
410
  if (basename3 === ".env.example") return true;
229
411
  if (/^\.env\.example(\.\w+)?$/.test(basename3)) return true;
230
412
  if (BLOCKED_EXACT_BASENAMES.has(basename3.toLowerCase())) return false;
@@ -285,8 +467,8 @@ function fileToNextPagesRoute(filePath, pagesDir) {
285
467
  }
286
468
  var APP_PAGE_BASENAME = /^page\.(tsx?|jsx?)$/;
287
469
  function fileToAppRoute(filePath, appDir) {
288
- if (!APP_PAGE_BASENAME.test(path4.basename(filePath))) return "";
289
- let rel = path4.dirname(filePath).slice(appDir.length).replace(/\\/g, "/");
470
+ if (!APP_PAGE_BASENAME.test(path5.basename(filePath))) return "";
471
+ let rel = path5.dirname(filePath).slice(appDir.length).replace(/\\/g, "/");
290
472
  if (rel.startsWith("/")) rel = rel.slice(1);
291
473
  rel = rel.replace(/\([^)]*\)\//g, "").replace(/\([^)]*\)$/, "");
292
474
  rel = rel.replace(/\[\.\.\.([^\]]+)\]/g, "*").replace(/\[([^\]]+)\]/g, ":$1");
@@ -298,7 +480,7 @@ var RE_REACT_OBJ = /\bpath:\s*["'](\/?[^"']+)["']/g;
298
480
  var RE_VUE_ROUTE = /\{\s*path:\s*["']([^"']+)["']/g;
299
481
  var RE_HTTP_ROUTE = /\bapp\.(?:get|post|put|patch|delete|all)\(["']([/][^"']+)["']/g;
300
482
  function grepSourceFile(filePath, addRoute) {
301
- if (!SOURCE_EXTS.has(path4.extname(filePath).toLowerCase())) return;
483
+ if (!SOURCE_EXTS.has(path5.extname(filePath).toLowerCase())) return;
302
484
  if (!assertSafeToRead(filePath)) return;
303
485
  const content = readFileSafe(filePath);
304
486
  if (!content) return;
@@ -321,8 +503,8 @@ function detectRoutes(targetDir) {
321
503
  if (trimmed && trimmed !== "*") routes.add(trimmed);
322
504
  };
323
505
  for (const dir of [
324
- path4.join(targetDir, "pages"),
325
- path4.join(targetDir, "src", "pages")
506
+ path5.join(targetDir, "pages"),
507
+ path5.join(targetDir, "src", "pages")
326
508
  ]) {
327
509
  if (!dirExists(dir)) continue;
328
510
  for (const f of walk(dir)) {
@@ -333,8 +515,8 @@ function detectRoutes(targetDir) {
333
515
  }
334
516
  }
335
517
  for (const dir of [
336
- path4.join(targetDir, "app"),
337
- path4.join(targetDir, "src", "app")
518
+ path5.join(targetDir, "app"),
519
+ path5.join(targetDir, "src", "app")
338
520
  ]) {
339
521
  if (!dirExists(dir)) continue;
340
522
  for (const f of walk(dir)) {
@@ -344,7 +526,7 @@ function detectRoutes(targetDir) {
344
526
  }
345
527
  }
346
528
  }
347
- const srcDir = path4.join(targetDir, "src");
529
+ const srcDir = path5.join(targetDir, "src");
348
530
  if (dirExists(srcDir)) {
349
531
  for (const f of walk(srcDir)) {
350
532
  grepSourceFile(f, addRoute);
@@ -352,8 +534,8 @@ function detectRoutes(targetDir) {
352
534
  }
353
535
  const rootCandidates = ["routes.ts", "routes.js", "router.ts", "router.js", "app.ts", "app.js"];
354
536
  for (const name of rootCandidates) {
355
- const f = path4.join(targetDir, name);
356
- if (fs4.existsSync(f)) grepSourceFile(f, addRoute);
537
+ const f = path5.join(targetDir, name);
538
+ if (fs5.existsSync(f)) grepSourceFile(f, addRoute);
357
539
  }
358
540
  const buckets = { buyer: [], seller: [], admin: [] };
359
541
  for (const route of Array.from(routes).sort()) {
@@ -384,8 +566,8 @@ function detectRoutes(targetDir) {
384
566
  }
385
567
 
386
568
  // src/bin/detectors/detectCredentials.ts
387
- var path5 = __toESM(require("path"), 1);
388
- var fs5 = __toESM(require("fs"), 1);
569
+ var path6 = __toESM(require("path"), 1);
570
+ var fs6 = __toESM(require("fs"), 1);
389
571
  var CREDENTIALS_BANNER = '// DEV/TEST/SEED ONLY \u2014 never production, never commit real passwords.\n// Extracted from .env.example and seeder files only.\n// Replace any "TODO: set from env \u2026" values with your actual dev/test credentials.';
390
572
  var SEEDER_PATH_PATTERNS = [
391
573
  /[\\/]seeders?[\\/]/i,
@@ -515,16 +697,16 @@ var SEEDER_SEARCH_DIRS = [
515
697
  ];
516
698
  function detectCredentials(targetDir) {
517
699
  const allMatches = [];
518
- const envExample = path5.join(targetDir, ".env.example");
519
- if (fs5.existsSync(envExample) && assertSafeToRead(envExample)) {
700
+ const envExample = path6.join(targetDir, ".env.example");
701
+ if (fs6.existsSync(envExample) && assertSafeToRead(envExample)) {
520
702
  const content = readFileSafe(envExample);
521
703
  if (content) allMatches.push(...extractMatches(content, envExample));
522
704
  }
523
705
  for (const rel of SEEDER_SEARCH_DIRS) {
524
- const dirPath = path5.join(targetDir, rel);
706
+ const dirPath = path6.join(targetDir, rel);
525
707
  if (!dirExists(dirPath)) continue;
526
708
  const files = walk(dirPath).filter((f) => {
527
- const ext = path5.extname(f);
709
+ const ext = path6.extname(f);
528
710
  return [".js", ".ts", ".mjs", ".cjs", ".json"].includes(ext) && isSeederFile(f) && assertSafeToRead(f);
529
711
  });
530
712
  for (const f of files) {
@@ -775,13 +957,13 @@ var REPO_URL = "https://github.com/mohammed-farhood/qapture";
775
957
  var PKG_VERSION = (() => {
776
958
  try {
777
959
  const candidates = [
778
- path6.join(__dirname, "..", "..", "package.json"),
779
- path6.join(__dirname, "..", "package.json"),
780
- path6.join(__dirname, "package.json")
960
+ path7.join(__dirname, "..", "..", "package.json"),
961
+ path7.join(__dirname, "..", "package.json"),
962
+ path7.join(__dirname, "package.json")
781
963
  ];
782
964
  for (const p of candidates) {
783
- if (fs6.existsSync(p)) {
784
- const pkg = JSON.parse(fs6.readFileSync(p, "utf8"));
965
+ if (fs7.existsSync(p)) {
966
+ const pkg = JSON.parse(fs7.readFileSync(p, "utf8"));
785
967
  if (pkg.version) return pkg.version;
786
968
  }
787
969
  }
@@ -791,14 +973,14 @@ var PKG_VERSION = (() => {
791
973
  })();
792
974
  function readTargetPkg(targetDir) {
793
975
  try {
794
- const raw = fs6.readFileSync(path6.join(targetDir, "package.json"), "utf8");
976
+ const raw = fs7.readFileSync(path7.join(targetDir, "package.json"), "utf8");
795
977
  return JSON.parse(raw);
796
978
  } catch {
797
979
  return {};
798
980
  }
799
981
  }
800
982
  function hasFile(targetDir, ...names) {
801
- return names.some((n) => fs6.existsSync(path6.join(targetDir, n)));
983
+ return names.some((n) => fs7.existsSync(path7.join(targetDir, n)));
802
984
  }
803
985
  function detectFrameworkHints(targetDir, pkg) {
804
986
  const hints = [];
@@ -835,11 +1017,15 @@ qapture CLI v${PKG_VERSION}
835
1017
 
836
1018
  Usage:
837
1019
  qapture init [target-dir] [--force] Scaffold config + artifacts into target-dir
1020
+ qapture shots [--port N] [--allow O] Real screenshots without the browser prompt (macOS)
838
1021
  qapture version Print version
839
1022
 
840
1023
  Options:
841
1024
  --force Overwrite qa.config.* and qa.preamble.md if they already exist
842
1025
  (SKILL.md is always refreshed regardless of --force)
1026
+ --port Port for \`shots\` to listen on (default 7017)
1027
+ --allow Extra origin \`shots\` will answer, repeatable.
1028
+ Loopback (http://localhost:*) is always allowed.
843
1029
 
844
1030
  Docs: ${REPO_URL}
845
1031
 
@@ -916,6 +1102,79 @@ ${DIVIDER}
916
1102
  `
917
1103
  );
918
1104
  }
1105
+ function startShotServer(port, allow) {
1106
+ if (process2.platform !== "darwin") {
1107
+ process2.stderr.write(
1108
+ `
1109
+ qapture shots needs macOS \u2014 it drives /usr/sbin/screencapture,
1110
+ the binary behind Cmd+Shift+4. On ${process2.platform}, Qapture falls back
1111
+ to the browser's own screen capture.
1112
+
1113
+ `
1114
+ );
1115
+ process2.exit(1);
1116
+ }
1117
+ const server = createShotServer({ port, allow, verbose: true });
1118
+ server.on("error", (err) => {
1119
+ if (err.code === "EADDRINUSE") {
1120
+ process2.stderr.write(
1121
+ `
1122
+ Port ${port} is already in use.
1123
+
1124
+ If that is another copy of this helper, you are already running one.
1125
+ Otherwise pick a different port: npx qapture2 shots --port 7018
1126
+ (and set shotPort: 7018 in your Qapture config, so the widget looks there)
1127
+
1128
+ `
1129
+ );
1130
+ } else {
1131
+ process2.stderr.write(`
1132
+ qapture shots failed to start: ${err.message}
1133
+
1134
+ `);
1135
+ }
1136
+ process2.exit(1);
1137
+ });
1138
+ server.listen(port, "127.0.0.1", () => {
1139
+ process2.stdout.write(
1140
+ `
1141
+ ${DIVIDER}
1142
+ qapture shots \u2014 real screenshots, no prompt, no heat
1143
+ ${DIVIDER}
1144
+
1145
+ Listening 127.0.0.1:${port} (loopback only \u2014 not reachable from the network)
1146
+ Engine /usr/sbin/screencapture \u2014 the same one Cmd+Shift+4 uses
1147
+ Answering http://localhost:* and http://127.0.0.1:*
1148
+ ` + (allow.length ? ` ${allow.join("\n ")}
1149
+ ` : ` (add others with --allow https://your-site.example)
1150
+ `) + `
1151
+ First run: macOS will ask this terminal for Screen Recording permission.
1152
+ Grant it once. It is never asked again, and no capture pipeline is left
1153
+ running between shots \u2014 that is the whole point of moving off the
1154
+ browser's getDisplayMedia.
1155
+
1156
+ If captures come back as wallpaper with no windows in them, the
1157
+ permission was not granted: System Settings \u2192 Privacy & Security \u2192
1158
+ Screen Recording, tick your terminal, then restart it.
1159
+
1160
+ Leave this running while you test. Ctrl+C to stop.
1161
+ ${DIVIDER}
1162
+
1163
+ `
1164
+ );
1165
+ });
1166
+ const shutdown = () => {
1167
+ process2.stdout.write(`
1168
+ qapture shots stopped.
1169
+
1170
+ `);
1171
+ server.close(() => process2.exit(0));
1172
+ setTimeout(() => process2.exit(0), 500).unref();
1173
+ };
1174
+ const proc = globalThis.process;
1175
+ proc.on("SIGINT", shutdown);
1176
+ proc.on("SIGTERM", shutdown);
1177
+ }
919
1178
  function main(argv2) {
920
1179
  const args = parseArgs(argv2);
921
1180
  if (args.command === "version") {
@@ -926,16 +1185,20 @@ function main(argv2) {
926
1185
  printUsage();
927
1186
  process2.exit(0);
928
1187
  }
929
- const targetDir = path6.resolve(args.dir);
1188
+ if (args.command === "shots") {
1189
+ startShotServer(args.port, args.allow);
1190
+ return;
1191
+ }
1192
+ const targetDir = path7.resolve(args.dir);
930
1193
  const { force } = args;
931
- if (!fs6.existsSync(targetDir)) {
1194
+ if (!fs7.existsSync(targetDir)) {
932
1195
  process2.stderr.write(`
933
1196
  Error: target directory does not exist: ${targetDir}
934
1197
 
935
1198
  `);
936
1199
  process2.exit(1);
937
1200
  }
938
- if (!fs6.statSync(targetDir).isDirectory()) {
1201
+ if (!fs7.statSync(targetDir).isDirectory()) {
939
1202
  process2.stderr.write(`
940
1203
  Error: ${targetDir} is not a directory
941
1204
 
@@ -967,10 +1230,10 @@ qapture init \u2014 scanning ${targetDir} ...
967
1230
  frameworkHints
968
1231
  });
969
1232
  const preambleText = genPreambleText({ projectName, frameworkHints });
970
- const configPath = path6.join(targetDir, configFilename);
971
- const preamblePath = path6.join(targetDir, "qa.preamble.md");
972
- const skillPath = path6.join(targetDir, ".claude", "skills", "qapture", "SKILL.md");
973
- const agentsMdPath = path6.join(targetDir, "AGENTS.md");
1233
+ const configPath = path7.join(targetDir, configFilename);
1234
+ const preamblePath = path7.join(targetDir, "qa.preamble.md");
1235
+ const skillPath = path7.join(targetDir, ".claude", "skills", "qapture", "SKILL.md");
1236
+ const agentsMdPath = path7.join(targetDir, "AGENTS.md");
974
1237
  const configResult = writeIfAbsent(configPath, configText, force);
975
1238
  const preambleResult = writeIfAbsent(preamblePath, preambleText, force);
976
1239
  writeAlways(skillPath, SKILL_default);