gipity 1.0.356 → 1.0.374

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 (60) hide show
  1. package/dist/banner.js +3 -1
  2. package/dist/capture/sources/claude-code.js +76 -11
  3. package/dist/commands/add.js +45 -28
  4. package/dist/commands/agent.js +3 -5
  5. package/dist/commands/approval.js +3 -3
  6. package/dist/commands/audit.js +2 -2
  7. package/dist/commands/chat.js +4 -4
  8. package/dist/commands/claude.js +8 -9
  9. package/dist/commands/credits.js +1 -1
  10. package/dist/commands/db.js +7 -6
  11. package/dist/commands/deploy.js +5 -8
  12. package/dist/commands/doctor.js +11 -13
  13. package/dist/commands/domain.js +18 -15
  14. package/dist/commands/email.js +0 -4
  15. package/dist/commands/fn.js +2 -2
  16. package/dist/commands/generate.js +73 -18
  17. package/dist/commands/job.js +6 -6
  18. package/dist/commands/location.js +7 -7
  19. package/dist/commands/login.js +2 -16
  20. package/dist/commands/logout.js +2 -3
  21. package/dist/commands/logs.js +1 -1
  22. package/dist/commands/page-eval.js +48 -7
  23. package/dist/commands/page-fetch.js +136 -0
  24. package/dist/commands/page-inspect.js +59 -29
  25. package/dist/commands/page-screenshot.js +51 -41
  26. package/dist/commands/page-test.js +86 -0
  27. package/dist/commands/page.js +8 -3
  28. package/dist/commands/plan.js +4 -4
  29. package/dist/commands/push.js +2 -6
  30. package/dist/commands/realtime.js +7 -9
  31. package/dist/commands/relay-install.js +18 -21
  32. package/dist/commands/relay.js +29 -31
  33. package/dist/commands/sandbox.js +16 -3
  34. package/dist/commands/service.js +54 -0
  35. package/dist/commands/skill.js +2 -1
  36. package/dist/commands/status.js +2 -2
  37. package/dist/commands/sync.js +4 -1
  38. package/dist/commands/test.js +7 -13
  39. package/dist/commands/text.js +148 -0
  40. package/dist/commands/uninstall.js +20 -42
  41. package/dist/commands/update.js +0 -2
  42. package/dist/commands/upload.js +4 -4
  43. package/dist/commands/workflow.js +11 -16
  44. package/dist/config.js +8 -1
  45. package/dist/help-skills.js +1 -0
  46. package/dist/helpers/output.js +52 -8
  47. package/dist/helpers/text-analysis.js +200 -0
  48. package/dist/hooks/capture-runner.js +32 -8
  49. package/dist/index.js +35 -2
  50. package/dist/knowledge.js +32 -7
  51. package/dist/progress.js +60 -0
  52. package/dist/project-setup.js +5 -1
  53. package/dist/provider-docs.js +7 -7
  54. package/dist/relay/daemon.js +11 -1
  55. package/dist/relay/stream-json.js +45 -8
  56. package/dist/setup.js +38 -11
  57. package/dist/sync.js +30 -8
  58. package/dist/updater/shim.js +18 -4
  59. package/dist/upload.js +6 -0
  60. package/package.json +5 -4
@@ -0,0 +1,136 @@
1
+ import { Command } from 'commander';
2
+ import { createHash } from 'crypto';
3
+ import { brand, bold, muted, success, warning, error as clrError } from '../colors.js';
4
+ import { formatSize } from '../utils.js';
5
+ import { run } from '../helpers/index.js';
6
+ function expectedType(path) {
7
+ const p = path.toLowerCase().split('?')[0].split('#')[0];
8
+ if (p.endsWith('.json'))
9
+ return 'json';
10
+ if (p.endsWith('.xml'))
11
+ return 'xml';
12
+ if (p.endsWith('.html') || p.endsWith('.htm'))
13
+ return 'html';
14
+ if (p.endsWith('.txt') || p.endsWith('.md') || p.endsWith('.markdown'))
15
+ return 'text';
16
+ return null;
17
+ }
18
+ function contentTypeMatches(expect, ct) {
19
+ if (!expect || !ct)
20
+ return true; // no expectation, or host sent no type → don't flag
21
+ const c = ct.toLowerCase();
22
+ switch (expect) {
23
+ case 'json': return c.includes('application/json');
24
+ case 'xml': return c.includes('xml');
25
+ case 'html': return c.includes('text/html');
26
+ case 'text': return c.includes('text/plain') || c.includes('text/markdown');
27
+ }
28
+ }
29
+ function looksLikeHtml(body) {
30
+ const head = body.slice(0, 300).toLowerCase();
31
+ return /<!doctype html|<html[\s>]/.test(head);
32
+ }
33
+ function sha256(s) {
34
+ return createHash('sha256').update(s).digest('hex');
35
+ }
36
+ function baseWithSlash(url) {
37
+ return url.endsWith('/') ? url : url + '/';
38
+ }
39
+ /** Resolve a file path against the app base, keeping the app's subpath. */
40
+ function resolveUrl(base, path) {
41
+ return new URL(path.replace(/^\/+/, ''), baseWithSlash(base)).toString();
42
+ }
43
+ async function fetchRaw(url) {
44
+ try {
45
+ const res = await fetch(url, { redirect: 'follow' });
46
+ const body = await res.text();
47
+ return { status: res.status, contentType: res.headers.get('content-type'), body };
48
+ }
49
+ catch {
50
+ return null; // DNS failure, connection refused, etc.
51
+ }
52
+ }
53
+ /** Hit a guaranteed-absent path to learn whether the host serves a catch-all shell. */
54
+ async function probeShell(base) {
55
+ const rand = Math.random().toString(36).slice(2) + Date.now().toString(36);
56
+ const r = await fetchRaw(resolveUrl(base, `__gipity_probe_${rand}.notreal`));
57
+ if (!r)
58
+ return { served: false, status: 0, sha256: null, isHtml: false };
59
+ return {
60
+ served: r.status >= 200 && r.status < 300,
61
+ status: r.status,
62
+ sha256: sha256(r.body),
63
+ isHtml: looksLikeHtml(r.body),
64
+ };
65
+ }
66
+ function classify(path, r, shell) {
67
+ if (!r)
68
+ return { status: null, contentType: null, bytes: 0, verdict: 'MISSING', detail: 'fetch failed (host unreachable)' };
69
+ const bytes = Buffer.byteLength(r.body);
70
+ const base = { status: r.status, contentType: r.contentType, bytes };
71
+ // Honest 404/5xx - the file simply isn't there.
72
+ if (r.status >= 400)
73
+ return { ...base, verdict: 'MISSING', detail: `HTTP ${r.status}` };
74
+ const expect = expectedType(path);
75
+ // 1) Served the catch-all shell verbatim → 200, but the file isn't deployed.
76
+ if (shell.served && shell.sha256 && sha256(r.body) === shell.sha256) {
77
+ return { ...base, verdict: 'MISSING', detail: `HTTP ${r.status} but served the SPA shell (file not deployed)` };
78
+ }
79
+ // 2) Asked for a non-HTML asset but got an HTML body → a per-path shell variant.
80
+ if (expect && expect !== 'html' && looksLikeHtml(r.body)) {
81
+ return { ...base, verdict: 'MISSING', detail: `HTTP ${r.status} but got HTML where ${expect} expected (SPA shell)` };
82
+ }
83
+ // 3) Present and real, but the content-type header disagrees with the extension.
84
+ if (expect && !contentTypeMatches(expect, r.contentType)) {
85
+ return { ...base, verdict: 'WRONG-TYPE', detail: `expected ${expect}, served as ${r.contentType ?? '(none)'}` };
86
+ }
87
+ return { ...base, verdict: 'OK', detail: r.contentType ?? '' };
88
+ }
89
+ const TAG = {
90
+ 'OK': success('OK '),
91
+ 'MISSING': clrError('MISSING '),
92
+ 'WRONG-TYPE': warning('WRONG-TYPE'),
93
+ };
94
+ export const pageFetchCommand = new Command('fetch')
95
+ .description('Verify deployed non-rendered files (llms.txt, AGENTS.md, robots.txt, served JSON…) really exist - catches the static-host trap where a missing file returns 200 with the SPA shell instead of a 404')
96
+ .argument('<url>', 'Deployed app base URL; file paths resolve relative to it')
97
+ .argument('<paths...>', 'One or more file paths to verify, e.g. llms.txt AGENTS.md robots.txt')
98
+ .option('--json', 'Output as JSON')
99
+ .action((url, paths, opts) => run('Page fetch', async () => {
100
+ const shell = await probeShell(url);
101
+ const results = [];
102
+ for (const p of paths) {
103
+ const target = resolveUrl(url, p);
104
+ const c = classify(p, await fetchRaw(target), shell);
105
+ results.push({ path: p, url: target, ...c });
106
+ }
107
+ const failed = results.filter((r) => r.verdict !== 'OK');
108
+ if (opts.json) {
109
+ console.log(JSON.stringify({
110
+ base: baseWithSlash(url),
111
+ shellFallback: shell.served,
112
+ ok: failed.length === 0,
113
+ failed: failed.length,
114
+ files: results,
115
+ }));
116
+ if (failed.length)
117
+ process.exitCode = 1;
118
+ return;
119
+ }
120
+ console.log(`${brand('Page fetch')} ${bold(baseWithSlash(url))}`);
121
+ if (shell.served) {
122
+ console.log(`${muted('Host serves a catch-all shell for unknown paths - a bare 200 is not proof; verifying bodies.')}`);
123
+ }
124
+ const pad = Math.min(48, Math.max(...results.map((r) => r.path.length)));
125
+ for (const r of results) {
126
+ const size = r.verdict === 'OK' ? muted(formatSize(r.bytes).padStart(9)) : ' '.repeat(9);
127
+ const detail = r.detail && r.verdict !== 'OK' ? muted(` ${r.detail}`) : '';
128
+ console.log(`${TAG[r.verdict]} ${r.path.padEnd(pad)} ${size}${detail}`);
129
+ }
130
+ console.log(failed.length === 0
131
+ ? `\n${success(`✓ all ${results.length} file(s) present with the right content-type`)}`
132
+ : `\n${clrError(`✗ ${failed.length} of ${results.length} file(s) missing or wrong-type`)}`);
133
+ if (failed.length)
134
+ process.exitCode = 1;
135
+ }));
136
+ //# sourceMappingURL=page-fetch.js.map
@@ -23,9 +23,12 @@ export const pageInspectCommand = new Command('inspect')
23
23
  .description('Inspect a web page (console, failed resources, timing, layout overflow)')
24
24
  .argument('<url>', 'URL to inspect')
25
25
  .option('--wait <ms>', 'Sleep this many ms after DOMContentLoaded before capturing (lets late async/LCP work settle)', '500')
26
+ .option('--wait-for <selector>', 'Wait until this CSS selector appears before capturing (deterministic; replaces --wait)')
27
+ .option('--wait-timeout <ms>', 'Max ms to wait for --wait-for before giving up', '5000')
26
28
  .option('--json', 'Output as JSON')
27
29
  .option('--no-truncate', 'Show full URLs instead of truncating long ones with middle-ellipsis')
28
30
  .option('--all', 'Include render-blocking, large resources, oversized images, overflow culprits, and LCP detail')
31
+ .option('--fake-media', 'Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so voice/camera apps run headlessly (audio is a built-in tone, not real speech)')
29
32
  // Hidden redirect: agents reach for `page inspect --screenshot`. We don't take
30
33
  // an image here (`page screenshot` is the single path for that) — just point there.
31
34
  .addOption(new Option('--screenshot [path]', 'Capture a screenshot').hideHelp())
@@ -38,9 +41,16 @@ export const pageInspectCommand = new Command('inspect')
38
41
  return run('Page inspect', async () => {
39
42
  const parsedWait = parseInt(opts.wait, 10);
40
43
  const waitMs = Number.isFinite(parsedWait) && parsedWait >= 0 ? parsedWait : 500;
44
+ const parsedTimeout = parseInt(opts.waitTimeout, 10);
45
+ const waitForTimeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout >= 0 ? parsedTimeout : 5000;
41
46
  const truncate = opts.truncate !== false;
42
47
  const showAll = opts.all === true;
43
- const res = await post(`/tools/browser/inspect`, { url, waitMs });
48
+ const res = await post(`/tools/browser/inspect`, {
49
+ url, waitMs,
50
+ waitForSelector: opts.waitFor || undefined,
51
+ waitForTimeoutMs: opts.waitFor ? waitForTimeoutMs : undefined,
52
+ fakeMedia: opts.fakeMedia || undefined,
53
+ });
44
54
  const b = res.data;
45
55
  if (opts.json) {
46
56
  console.log(JSON.stringify(b));
@@ -48,78 +58,98 @@ export const pageInspectCommand = new Command('inspect')
48
58
  }
49
59
  const timing = b.timing || { ttfb: 0, domReady: 0, load: 0 };
50
60
  // ── Page Info ──
51
- console.log(`\n${brand('Inspecting')} ${bold(b.url || url)}`);
52
- console.log(` ${muted('Title:')} ${b.title || '(none)'}`);
53
- console.log(` ${muted('Elements:')} ${b.elementCount || 0}`);
54
- console.log(` ${muted('Page weight:')} ${info(formatSize(b.totalBytes || 0))}`);
61
+ console.log(`${brand('Inspecting')} ${bold(b.url || url)}`);
62
+ if (b.navigationIncomplete) {
63
+ console.log(`${warning('⚠ Navigation incomplete:')} ${b.note || 'page did not reach full load'}`);
64
+ }
65
+ console.log(`${muted('Title:')} ${b.title || '(none)'}`);
66
+ console.log(`${muted('Elements:')} ${b.elementCount || 0}`);
67
+ console.log(`${muted('Page weight:')} ${info(formatSize(b.totalBytes || 0))}`);
55
68
  // ── Timing ──
56
- console.log(`\n ${bold('Timing:')}`);
57
- console.log(` ${muted('TTFB:')} ${timing.ttfb}ms`);
58
- console.log(` ${muted('DOM ready:')} ${timing.domReady}ms`);
59
- console.log(` ${muted('Load:')} ${timing.load}ms`);
69
+ console.log(`\n${bold('Timing:')}`);
70
+ console.log(`${muted('TTFB:')} ${timing.ttfb}ms`);
71
+ console.log(`${muted('DOM ready:')} ${timing.domReady}ms`);
72
+ console.log(`${muted('Load:')} ${timing.load}ms`);
60
73
  if (showAll && b.lcp) {
61
- console.log(` LCP: ${b.lcp.time}ms (${b.lcp.element}${b.lcp.url ? ' ' + shortUrl(b.lcp.url, truncate) : ''})`);
74
+ console.log(`LCP: ${b.lcp.time}ms (${b.lcp.element}${b.lcp.url ? ' ' + shortUrl(b.lcp.url, truncate) : ''})`);
62
75
  }
63
76
  // ── Console ──
64
77
  if (b.console?.length > 0) {
65
- console.log(`\n ${bold('Console')} ${muted(`(${b.console.length})`)}:`);
78
+ console.log(`\n${bold('Console')} ${muted(`(${b.console.length})`)}:`);
66
79
  for (const line of b.console) {
67
- console.log(` ${warning(line)}`);
80
+ console.log(`${warning(line)}`);
68
81
  }
69
82
  }
70
83
  else {
71
- console.log(`\n ${bold('Console:')} ${muted('(clean)')}`);
84
+ console.log(`\n${bold('Console:')} ${muted('(clean)')}`);
72
85
  }
73
86
  // ── Failed Resources ──
74
- if (b.failedResources?.length > 0) {
75
- console.log(`\n ${clrError(`Failed resources (${b.failedResources.length}):`)}`);
76
- for (const r of b.failedResources) {
77
- console.log(` ${clrError(r)}`);
87
+ // Browsers auto-request /favicon.ico at the site root for every page, so a
88
+ // 404 there isn't a resource the page actually links — it's noise on any
89
+ // app served under a subpath. Split that implicit request out of the failure
90
+ // list into a harmless note rather than flagging it as an error.
91
+ const isImplicitFavicon = (entry) => {
92
+ const urlPart = entry.replace(/\s*\([^)]*\)\s*$/, '');
93
+ try {
94
+ return new URL(urlPart).pathname === '/favicon.ico';
95
+ }
96
+ catch {
97
+ return false;
78
98
  }
99
+ };
100
+ const failed = (b.failedResources || []).filter((r) => !isImplicitFavicon(r));
101
+ const rootFaviconMissing = (b.failedResources || []).some(isImplicitFavicon);
102
+ if (failed.length > 0) {
103
+ console.log(`\n${clrError(`Failed resources (${failed.length}):`)}`);
104
+ for (const r of failed) {
105
+ console.log(`${clrError(r)}`);
106
+ }
107
+ }
108
+ if (rootFaviconMissing) {
109
+ console.log(`\n${muted('No root /favicon.ico (browsers request this automatically; harmless for app pages served under a subpath)')}`);
79
110
  }
80
111
  // ── Layout (horizontal overflow) ──
81
112
  if (b.overflow) {
82
113
  if (b.overflow.overflowX) {
83
- console.log(`\n ${clrError(`Horizontal overflow: +${b.overflow.amount}px`)} ${muted(`(content ${b.overflow.scrollWidth}px vs viewport ${b.overflow.clientWidth}px)`)}`);
114
+ console.log(`\n${clrError(`Horizontal overflow: +${b.overflow.amount}px`)} ${muted(`(content ${b.overflow.scrollWidth}px vs viewport ${b.overflow.clientWidth}px)`)}`);
84
115
  if (showAll && b.overflow.culprits.length > 0) {
85
- console.log(` ${muted('Overflowing elements:')}`);
116
+ console.log(`${muted('Overflowing elements:')}`);
86
117
  for (const c of b.overflow.culprits) {
87
118
  const sel = c.cls ? `${c.tag}.${c.cls.split(/\s+/)[0]}` : c.tag;
88
- console.log(` ${sel} ${muted(`(right ${c.right}px, width ${c.width}px)`)}`);
119
+ console.log(`${sel} ${muted(`(right ${c.right}px, width ${c.width}px)`)}`);
89
120
  }
90
121
  }
91
122
  else if (b.overflow.culprits.length > 0) {
92
- console.log(` ${muted(`${b.overflow.culprits.length} overflowing element(s) - use --all to list`)}`);
123
+ console.log(`${muted(`${b.overflow.culprits.length} overflowing element(s) - use --all to list`)}`);
93
124
  }
94
125
  }
95
126
  else {
96
- console.log(`\n ${bold('Layout:')} ${muted('no horizontal overflow')}`);
127
+ console.log(`\n${bold('Layout:')} ${muted('no horizontal overflow')}`);
97
128
  }
98
129
  }
99
130
  if (showAll) {
100
131
  // ── Render Blocking ──
101
132
  if (b.renderBlocking?.length > 0) {
102
- console.log(`\n ${warning(`Render-blocking (${b.renderBlocking.length}):`)}`);
133
+ console.log(`\n${warning(`Render-blocking (${b.renderBlocking.length}):`)}`);
103
134
  for (const r of b.renderBlocking) {
104
- console.log(` ${shortUrl(r, truncate)}`);
135
+ console.log(`${shortUrl(r, truncate)}`);
105
136
  }
106
137
  }
107
138
  // ── Large Resources ──
108
139
  if (b.largeResources?.length > 0) {
109
- console.log(`\n ${warning(`Large resources >100KB (${b.largeResources.length}):`)}`);
140
+ console.log(`\n${warning(`Large resources >100KB (${b.largeResources.length}):`)}`);
110
141
  for (const r of b.largeResources) {
111
- console.log(` ${info(formatSize(r.size).padEnd(10))} ${muted(r.type.padEnd(8))} ${shortUrl(r.url, truncate)}`);
142
+ console.log(`${info(formatSize(r.size).padEnd(10))} ${muted(r.type.padEnd(8))} ${shortUrl(r.url, truncate)}`);
112
143
  }
113
144
  }
114
145
  // ── Oversized Images ──
115
146
  if (b.oversizedImages?.length > 0) {
116
- console.log(`\n ${warning(`Oversized images (${b.oversizedImages.length}):`)}`);
147
+ console.log(`\n${warning(`Oversized images (${b.oversizedImages.length}):`)}`);
117
148
  for (const img of b.oversizedImages) {
118
- console.log(` ${img.natural} served, ${img.displayed} displayed - ${shortUrl(img.src, truncate)}`);
149
+ console.log(`${img.natural} served, ${img.displayed} displayed - ${shortUrl(img.src, truncate)}`);
119
150
  }
120
151
  }
121
152
  }
122
- console.log('');
123
153
  });
124
154
  });
125
155
  //# sourceMappingURL=page-inspect.js.map
@@ -1,6 +1,8 @@
1
1
  import { Command, Option } from 'commander';
2
- import { existsSync, readdirSync, writeFileSync } from 'fs';
2
+ import { mkdirSync, writeFileSync } from 'fs';
3
+ import { join, resolve as resolvePath } from 'path';
3
4
  import { postForTarEntries } from '../api.js';
5
+ import { getProjectRoot } from '../config.js';
4
6
  import { brand, bold, muted, success } from '../colors.js';
5
7
  import { formatSize } from '../utils.js';
6
8
  import { run } from '../helpers/index.js';
@@ -44,23 +46,23 @@ function dimSuffix(vp) {
44
46
  const dpr = vp.deviceScaleFactor ?? 1;
45
47
  return dpr === 1 ? `${vp.width}x${vp.height}` : `${vp.width}x${vp.height}@${dpr}`;
46
48
  }
47
- function nextNumberedFilename(slug, suffix) {
48
- const prefix = suffix ? `ss-${slug}-${suffix}-` : `ss-${slug}-`;
49
- const escaped = prefix.replace(/[-.@]/g, '\\$&');
50
- const existing = readdirSync('.')
51
- .map((f) => {
52
- const m = f.match(new RegExp(`^${escaped}(\\d{3,})\\.png$`));
53
- return m ? parseInt(m[1], 10) : -1;
54
- })
55
- .filter((n) => n >= 0);
56
- const next = existing.length ? Math.max(...existing) + 1 : 1;
57
- let n = next;
58
- let candidate = `${prefix}${String(n).padStart(3, '0')}.png`;
59
- while (existsSync(candidate)) {
60
- n += 1;
61
- candidate = `${prefix}${String(n).padStart(3, '0')}.png`;
62
- }
63
- return candidate;
49
+ /** Default screenshot directory: `<project-root>/.gipity/screenshots`, falling
50
+ * back to `./.gipity/screenshots` in one-off mode (no linked project). `.gipity/`
51
+ * is sync-ignored, so these verification artifacts never sync to Gipity or
52
+ * deploy to the CDN - and they stay out of the project root. */
53
+ function defaultScreenshotDir() {
54
+ const root = getProjectRoot();
55
+ return join(root ?? '.', '.gipity', 'screenshots');
56
+ }
57
+ /** `yyyy-mm-dd_hh-mm-ss` per the repo timestamp convention - sorts chronologically,
58
+ * filesystem-safe. One stamp per invocation; viewport suffixes keep multi-shot
59
+ * runs distinct so they never collide on the shared timestamp. */
60
+ export function timestampSlug(d = new Date()) {
61
+ const p = (n) => String(n).padStart(2, '0');
62
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}`;
63
+ }
64
+ export function defaultFilename(slug, ts, suffix) {
65
+ return suffix ? `ss-${slug}-${suffix}-${ts}.png` : `ss-${slug}-${ts}.png`;
64
66
  }
65
67
  function parseViewportString(s) {
66
68
  const m = s.trim().match(/^(\d+)x(\d+)(?:@(\d+(?:\.\d+)?))?$/i);
@@ -96,10 +98,11 @@ export const pageScreenshotCommand = new Command('screenshot')
96
98
  .argument('<url>', 'URL to screenshot')
97
99
  .option('--post-load-delay <ms>', 'Delay after DOMContentLoaded before capture, in ms', '1000')
98
100
  .option('--full', 'Capture the full scrollable page (default: viewport only)')
99
- .option('-o, --output <file>', 'Output filename (single viewport only; default ss-<host>-NNN.png)')
101
+ .option('-o, --output <file>', 'Output path (single viewport only; default .gipity/screenshots/ss-<host>-<timestamp>.png)')
100
102
  .option('--device <names>', `Viewport preset(s): ${Object.keys(DEVICE_PRESETS).join(', ')} (comma-separated or repeat flag)`, appendOption, [])
101
103
  .option('--viewport <dims>', 'Raw viewport(s): WxH or WxH@dpr (comma-separated or repeat flag)', appendOption, [])
102
104
  .option('--no-reload-between', 'Skip reload between viewports (faster, lower fidelity - only safe for static pages)')
105
+ .option('--fake-media', 'Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so voice/camera apps render headlessly (audio is a built-in tone, not real speech)')
103
106
  .option('--json', 'Output JSON metadata instead of a friendly summary')
104
107
  .addOption(new Option('--wait <ms>', 'Alias for --post-load-delay').hideHelp())
105
108
  .action((url, opts) => run('Page screenshot', async () => {
@@ -118,7 +121,7 @@ export const pageScreenshotCommand = new Command('screenshot')
118
121
  throw new Error('--output can only be used with a single viewport');
119
122
  }
120
123
  // Server defaults to 1280×720 when viewports is omitted - don't send it in
121
- // the no-flag case so filename stays unsuffixed (`ss-host-NNN.png`).
124
+ // the no-flag case so the filename stays unsuffixed (no viewport segment).
122
125
  const userSpecifiedViewports = customViewports.length > 0;
123
126
  const body = {
124
127
  url,
@@ -126,6 +129,7 @@ export const pageScreenshotCommand = new Command('screenshot')
126
129
  full: !!opts.full,
127
130
  reloadBetween: opts.reloadBetween !== false,
128
131
  ...(userSpecifiedViewports ? { viewports: customViewports } : {}),
132
+ ...(opts.fakeMedia ? { fakeMedia: true } : {}),
129
133
  };
130
134
  const entries = await postForTarEntries('/tools/browser/screenshot', body);
131
135
  const metaEntry = entries.find((e) => e.name === 'meta.json');
@@ -137,13 +141,20 @@ export const pageScreenshotCommand = new Command('screenshot')
137
141
  throw new Error(`Server returned ${pngs.length} PNGs but ${meta.screenshots.length} metadata entries`);
138
142
  }
139
143
  const slug = slugFromUrl(url);
144
+ const ts = timestampSlug();
145
+ const dir = defaultScreenshotDir();
146
+ if (!opts.output)
147
+ mkdirSync(dir, { recursive: true });
140
148
  const savedFiles = [];
141
149
  for (let i = 0; i < pngs.length; i++) {
142
150
  const shot = meta.screenshots[i];
143
151
  const suffix = userSpecifiedViewports ? dimSuffix(shot.viewport) : undefined;
144
- const filename = opts.output || nextNumberedFilename(slug, suffix);
145
- writeFileSync(filename, pngs[i].buffer);
146
- savedFiles.push(filename);
152
+ const target = opts.output
153
+ ? opts.output
154
+ : join(dir, defaultFilename(slug, ts, suffix));
155
+ writeFileSync(target, pngs[i].buffer);
156
+ // Absolute path so the agent knows exactly where the file landed.
157
+ savedFiles.push(resolvePath(target));
147
158
  }
148
159
  if (opts.json) {
149
160
  console.log(JSON.stringify({
@@ -172,41 +183,40 @@ export const pageScreenshotCommand = new Command('screenshot')
172
183
  }
173
184
  if (meta.screenshots.length === 1) {
174
185
  const s = meta.screenshots[0];
175
- console.log(`\n${brand('Screenshot')} ${bold(url)}`);
186
+ console.log(`${brand('Screenshot')} ${bold(url)}`);
176
187
  if (meta.title)
177
- console.log(` ${label('Web page title')} ${meta.title}`);
188
+ console.log(`${label('Web page title')} ${meta.title}`);
178
189
  if (meta.finalUrl)
179
- console.log(` ${label('Web page URL')} ${meta.finalUrl}`);
190
+ console.log(`${label('Web page URL')} ${meta.finalUrl}`);
180
191
  if (meta.status != null)
181
- console.log(` ${label('Web page status')} ${meta.status}`);
192
+ console.log(`${label('Web page status')} ${meta.status}`);
182
193
  if (meta.performance)
183
- console.log(` ${label('Web page perf')} ${fmtPerformance(meta.performance)}`);
194
+ console.log(`${label('Web page perf')} ${fmtPerformance(meta.performance)}`);
184
195
  const sizePart = formatSize(s.screenshotSizeBytes) + (meta.full ? ' (full page)' : '');
185
- console.log(` ${label('Screenshot size')} ${sizePart}`);
196
+ console.log(`${label('Screenshot size')} ${sizePart}`);
186
197
  if (s.width && s.height)
187
- console.log(` ${label('Screenshot dims')} ${s.width} × ${s.height}`);
188
- console.log(` ${label('Screenshot file')} ${success(savedFiles[0])}\n`);
198
+ console.log(`${label('Screenshot dims')} ${s.width} × ${s.height}`);
199
+ console.log(`${label('Screenshot file')} ${success(savedFiles[0])}`);
189
200
  return;
190
201
  }
191
- console.log(`\n${brand('Loading')} ${bold(url)} ${muted(`once → ${meta.screenshots.length} viewports`)}`);
202
+ console.log(`${brand('Loading')} ${bold(url)} ${muted(`once → ${meta.screenshots.length} viewports`)}`);
192
203
  if (meta.title)
193
- console.log(` ${label('Web page title')} ${meta.title}`);
204
+ console.log(`${label('Web page title')} ${meta.title}`);
194
205
  if (meta.finalUrl)
195
- console.log(` ${label('Web page URL')} ${meta.finalUrl}`);
206
+ console.log(`${label('Web page URL')} ${meta.finalUrl}`);
196
207
  if (meta.status != null)
197
- console.log(` ${label('Web page status')} ${meta.status}`);
208
+ console.log(`${label('Web page status')} ${meta.status}`);
198
209
  if (meta.performance)
199
- console.log(` ${label('Web page perf')} ${fmtPerformance(meta.performance)}`);
210
+ console.log(`${label('Web page perf')} ${fmtPerformance(meta.performance)}`);
200
211
  for (let i = 0; i < meta.screenshots.length; i++) {
201
212
  const s = meta.screenshots[i];
202
213
  const dims = `${s.viewport.width}×${s.viewport.height}${s.viewport.deviceScaleFactor > 1 ? ` @${s.viewport.deviceScaleFactor}x` : ''}`;
203
- console.log(`\n ${brand('@ ' + dims)}`);
214
+ console.log(`\n${brand('@ ' + dims)}`);
204
215
  const sizePart = formatSize(s.screenshotSizeBytes) + (meta.full ? ' (full page)' : '');
205
- console.log(` ${label('Screenshot size')} ${sizePart}`);
216
+ console.log(`${label('Screenshot size')} ${sizePart}`);
206
217
  if (s.width && s.height)
207
- console.log(` ${label('Screenshot dims')} ${s.width} × ${s.height}`);
208
- console.log(` ${label('Screenshot file')} ${success(savedFiles[i])}`);
218
+ console.log(`${label('Screenshot dims')} ${s.width} × ${s.height}`);
219
+ console.log(`${label('Screenshot file')} ${success(savedFiles[i])}`);
209
220
  }
210
- console.log('');
211
221
  }));
212
222
  //# sourceMappingURL=page-screenshot.js.map
@@ -0,0 +1,86 @@
1
+ import { Command } from 'commander';
2
+ import { post } from '../api.js';
3
+ import { brand, bold, muted, warning, success, error as clrError } from '../colors.js';
4
+ import { run } from '../helpers/index.js';
5
+ // Lines worth surfacing - genuine errors and crash signatures, not benign warnings.
6
+ const BAD = /^error:|uncaught|unhandled|message handler error|\bcrash|RuntimeError/i;
7
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
8
+ /** Run one passive page load via the inspect endpoint and return its console. */
9
+ async function inspectClient(url, waitMs, i) {
10
+ try {
11
+ const res = await post('/tools/browser/inspect', { url, waitMs });
12
+ return { i, lines: res.data.console ?? [] };
13
+ }
14
+ catch (err) {
15
+ return { i, lines: [], error: err instanceof Error ? err.message : String(err) };
16
+ }
17
+ }
18
+ // Headless multi-client realtime check: spin N staggered browser clients at a
19
+ // deployed URL and flag error/crash lines across their consoles. The verifier
20
+ // for realtime apps (host election, presence, world sync, reconnection) -
21
+ // promotes the internal multi-client-test script to a first-class command.
22
+ //
23
+ // Passive page loads only: each client just loads the URL and settles. An app
24
+ // that connects to realtime only after a user action (e.g. a lobby that joins
25
+ // on a button press) needs a URL-param test mode so the load alone exercises
26
+ // the path - see the app-realtime skill.
27
+ export const pageTestCommand = new Command('test')
28
+ .description('Multi-client realtime check: load a URL in N staggered headless clients, flag console errors')
29
+ .argument('<url>', 'Deployed URL to load in every client')
30
+ .option('--clients <n>', 'Number of headless clients to launch', '2')
31
+ .option('--stagger <s>', 'Seconds between client starts (client 0 settles first, e.g. as host)', '12')
32
+ .option('--wait <ms>', 'Milliseconds each client stays open after load (max 30000)', '24000')
33
+ .option('--json', 'Output as JSON')
34
+ .action((url, opts) => run('Page test', async () => {
35
+ const clients = Math.max(1, parseInt(opts.clients, 10) || 2);
36
+ const stagger = Math.max(0, parseInt(opts.stagger, 10) || 0);
37
+ const wait = Math.min(30000, Math.max(2000, parseInt(opts.wait, 10) || 24000));
38
+ if (!opts.json) {
39
+ console.log(`${brand('Page test')} ${bold(url)}`);
40
+ console.log(`${muted(`${clients} client(s), stagger ${stagger}s, ${wait}ms open each`)}`);
41
+ }
42
+ const runs = [];
43
+ for (let i = 0; i < clients; i++) {
44
+ runs.push((async () => {
45
+ await sleep(i * stagger * 1000);
46
+ if (!opts.json)
47
+ console.log(`${muted(`client ${i}${i === 0 ? ' (first)' : ''} starting`)}`);
48
+ return inspectClient(url, wait, i);
49
+ })());
50
+ }
51
+ const results = (await Promise.all(runs)).sort((a, b) => a.i - b.i);
52
+ let problems = 0;
53
+ for (const r of results) {
54
+ if (r.error)
55
+ problems++;
56
+ else
57
+ problems += r.lines.filter((l) => BAD.test(l)).length;
58
+ }
59
+ if (opts.json) {
60
+ console.log(JSON.stringify({ url, clients, stagger, wait, problems, results }));
61
+ if (problems > 0)
62
+ process.exitCode = 1;
63
+ return;
64
+ }
65
+ for (const r of results) {
66
+ console.log(`\n${bold(`=== client ${r.i}${r.i === 0 ? ' (first)' : ''} ===`)}`);
67
+ if (r.error) {
68
+ console.log(`${clrError(`page inspect failed: ${r.error}`)}`);
69
+ continue;
70
+ }
71
+ if (r.lines.length === 0) {
72
+ console.log(`${muted('(no console output)')}`);
73
+ continue;
74
+ }
75
+ for (const line of r.lines) {
76
+ const bad = BAD.test(line);
77
+ console.log(`${bad ? warning('⚠ ' + line) : ' ' + line}`);
78
+ }
79
+ }
80
+ console.log(problems === 0
81
+ ? `\n${success('✓ no error/crash lines across all clients')}`
82
+ : `\n${clrError(`⚠ ${problems} error/crash line(s) flagged above`)}`);
83
+ if (problems > 0)
84
+ process.exitCode = 1;
85
+ }));
86
+ //# sourceMappingURL=page-test.js.map
@@ -2,15 +2,20 @@ import { Command } from 'commander';
2
2
  import { pageInspectCommand } from './page-inspect.js';
3
3
  import { pageScreenshotCommand } from './page-screenshot.js';
4
4
  import { pageEvalCommand } from './page-eval.js';
5
+ import { pageTestCommand } from './page-test.js';
6
+ import { pageFetchCommand } from './page-fetch.js';
5
7
  // Parent namespace grouping the page/browser diagnostics under one command:
6
- // gipity page inspect | eval | screenshot
8
+ // gipity page inspect | eval | screenshot | test | fetch
7
9
  // Each subcommand is canonical for its capability; the namespace keeps the
8
10
  // top-level surface lean and makes the siblings discoverable via `page --help`.
11
+ // `inspect` is the rendered DOM (browser); `fetch` is the raw asset (plain HTTP).
9
12
  export const pageCommand = new Command('page')
10
- .description('Inspect, evaluate, and screenshot web pages (page inspect | eval | screenshot)')
13
+ .description('Inspect, evaluate, screenshot, multi-client test, and verify raw files of web pages (page inspect | eval | screenshot | test | fetch)')
11
14
  .addCommand(pageInspectCommand)
12
15
  .addCommand(pageEvalCommand)
13
- .addCommand(pageScreenshotCommand);
16
+ .addCommand(pageScreenshotCommand)
17
+ .addCommand(pageTestCommand)
18
+ .addCommand(pageFetchCommand);
14
19
  // No subcommand → show help instead of commander's terse error.
15
20
  pageCommand.action(() => {
16
21
  pageCommand.help();
@@ -22,7 +22,7 @@ function formatLimit(key, value) {
22
22
  }
23
23
  return value.toLocaleString();
24
24
  }
25
- function renderLimits(limits, indent = ' ') {
25
+ function renderLimits(limits, indent = '') {
26
26
  for (const key of Object.keys(LIMIT_LABELS)) {
27
27
  const value = limits[key];
28
28
  if (value !== undefined) {
@@ -48,14 +48,14 @@ export const planCommand = new Command('plan')
48
48
  const price = plan.monthlyPriceUsd > 0 ? ` $${plan.monthlyPriceUsd}/mo` : '';
49
49
  console.log(`Plan: ${brand(plan.displayName)} (${plan.tier})${price}`);
50
50
  if (plan.monthlyCredits > 0) {
51
- console.log(` ${plan.monthlyCredits.toLocaleString()} credits/mo, ${plan.creditExpiryDays}-day expiry`);
51
+ console.log(`${plan.monthlyCredits.toLocaleString()} credits/mo, ${plan.creditExpiryDays}-day expiry`);
52
52
  }
53
53
  }
54
54
  else {
55
55
  console.log(`Plan: ${tier} (no matching plan row)`);
56
56
  }
57
57
  if (planAppliedAt) {
58
- console.log(` ${dim('Applied: ' + new Date(planAppliedAt).toLocaleDateString())}`);
58
+ console.log(`${dim('Applied: ' + new Date(planAppliedAt).toLocaleDateString())}`);
59
59
  }
60
60
  console.log('\nLimits:');
61
61
  renderLimits(limits);
@@ -85,7 +85,7 @@ planCommand
85
85
  ? ` - ${plan.monthlyCredits.toLocaleString()} credits/mo (${plan.creditExpiryDays}-day expiry)`
86
86
  : '';
87
87
  console.log(`${marker}${plan.displayName} (${plan.tier})${price}${credits}`);
88
- renderLimits(plan.limits, ' ');
88
+ renderLimits(plan.limits);
89
89
  console.log('');
90
90
  }
91
91
  console.log(dim('(* = your current plan)'));
@@ -1,7 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import { resolve } from 'path';
3
3
  import { pushFile } from '../sync.js';
4
- import { error as clrError } from '../colors.js';
4
+ import { error as clrError, success } from '../colors.js';
5
5
  export const pushCommand = new Command('push')
6
6
  .description('Push a file')
7
7
  .argument('<file>', 'File path to push')
@@ -22,16 +22,12 @@ export const pushCommand = new Command('push')
22
22
  }
23
23
  await pushFile(fullPath);
24
24
  if (!opts.quiet) {
25
- console.log('');
26
- console.log(`Pushed ${file}`);
27
- console.log('');
25
+ console.log(success(`Pushed ${file}`));
28
26
  }
29
27
  }
30
28
  catch (err) {
31
29
  if (!opts.quiet) {
32
- console.log('');
33
30
  console.error(clrError(`Push failed: ${err.message}`));
34
- console.log('');
35
31
  }
36
32
  process.exit(1);
37
33
  }