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.
- package/dist/banner.js +3 -1
- package/dist/capture/sources/claude-code.js +76 -11
- package/dist/commands/add.js +45 -28
- package/dist/commands/agent.js +3 -5
- package/dist/commands/approval.js +3 -3
- package/dist/commands/audit.js +2 -2
- package/dist/commands/chat.js +4 -4
- package/dist/commands/claude.js +8 -9
- package/dist/commands/credits.js +1 -1
- package/dist/commands/db.js +7 -6
- package/dist/commands/deploy.js +5 -8
- package/dist/commands/doctor.js +11 -13
- package/dist/commands/domain.js +18 -15
- package/dist/commands/email.js +0 -4
- package/dist/commands/fn.js +2 -2
- package/dist/commands/generate.js +73 -18
- package/dist/commands/job.js +6 -6
- package/dist/commands/location.js +7 -7
- package/dist/commands/login.js +2 -16
- package/dist/commands/logout.js +2 -3
- package/dist/commands/logs.js +1 -1
- package/dist/commands/page-eval.js +48 -7
- package/dist/commands/page-fetch.js +136 -0
- package/dist/commands/page-inspect.js +59 -29
- package/dist/commands/page-screenshot.js +51 -41
- package/dist/commands/page-test.js +86 -0
- package/dist/commands/page.js +8 -3
- package/dist/commands/plan.js +4 -4
- package/dist/commands/push.js +2 -6
- package/dist/commands/realtime.js +7 -9
- package/dist/commands/relay-install.js +18 -21
- package/dist/commands/relay.js +29 -31
- package/dist/commands/sandbox.js +16 -3
- package/dist/commands/service.js +54 -0
- package/dist/commands/skill.js +2 -1
- package/dist/commands/status.js +2 -2
- package/dist/commands/sync.js +4 -1
- package/dist/commands/test.js +7 -13
- package/dist/commands/text.js +148 -0
- package/dist/commands/uninstall.js +20 -42
- package/dist/commands/update.js +0 -2
- package/dist/commands/upload.js +4 -4
- package/dist/commands/workflow.js +11 -16
- package/dist/config.js +8 -1
- package/dist/help-skills.js +1 -0
- package/dist/helpers/output.js +52 -8
- package/dist/helpers/text-analysis.js +200 -0
- package/dist/hooks/capture-runner.js +32 -8
- package/dist/index.js +35 -2
- package/dist/knowledge.js +32 -7
- package/dist/progress.js +60 -0
- package/dist/project-setup.js +5 -1
- package/dist/provider-docs.js +7 -7
- package/dist/relay/daemon.js +11 -1
- package/dist/relay/stream-json.js +45 -8
- package/dist/setup.js +38 -11
- package/dist/sync.js +30 -8
- package/dist/updater/shim.js +18 -4
- package/dist/upload.js +6 -0
- 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`, {
|
|
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(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
57
|
-
console.log(
|
|
58
|
-
console.log(
|
|
59
|
-
console.log(
|
|
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(`
|
|
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
|
|
78
|
+
console.log(`\n${bold('Console')} ${muted(`(${b.console.length})`)}:`);
|
|
66
79
|
for (const line of b.console) {
|
|
67
|
-
console.log(
|
|
80
|
+
console.log(`${warning(line)}`);
|
|
68
81
|
}
|
|
69
82
|
}
|
|
70
83
|
else {
|
|
71
|
-
console.log(`\n
|
|
84
|
+
console.log(`\n${bold('Console:')} ${muted('(clean)')}`);
|
|
72
85
|
}
|
|
73
86
|
// ── Failed Resources ──
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
133
|
+
console.log(`\n${warning(`Render-blocking (${b.renderBlocking.length}):`)}`);
|
|
103
134
|
for (const r of b.renderBlocking) {
|
|
104
|
-
console.log(
|
|
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
|
|
140
|
+
console.log(`\n${warning(`Large resources >100KB (${b.largeResources.length}):`)}`);
|
|
110
141
|
for (const r of b.largeResources) {
|
|
111
|
-
console.log(
|
|
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
|
|
147
|
+
console.log(`\n${warning(`Oversized images (${b.oversizedImages.length}):`)}`);
|
|
117
148
|
for (const img of b.oversizedImages) {
|
|
118
|
-
console.log(
|
|
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 {
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
return
|
|
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
|
|
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 (
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
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(
|
|
186
|
+
console.log(`${brand('Screenshot')} ${bold(url)}`);
|
|
176
187
|
if (meta.title)
|
|
177
|
-
console.log(
|
|
188
|
+
console.log(`${label('Web page title')} ${meta.title}`);
|
|
178
189
|
if (meta.finalUrl)
|
|
179
|
-
console.log(
|
|
190
|
+
console.log(`${label('Web page URL')} ${meta.finalUrl}`);
|
|
180
191
|
if (meta.status != null)
|
|
181
|
-
console.log(
|
|
192
|
+
console.log(`${label('Web page status')} ${meta.status}`);
|
|
182
193
|
if (meta.performance)
|
|
183
|
-
console.log(
|
|
194
|
+
console.log(`${label('Web page perf')} ${fmtPerformance(meta.performance)}`);
|
|
184
195
|
const sizePart = formatSize(s.screenshotSizeBytes) + (meta.full ? ' (full page)' : '');
|
|
185
|
-
console.log(
|
|
196
|
+
console.log(`${label('Screenshot size')} ${sizePart}`);
|
|
186
197
|
if (s.width && s.height)
|
|
187
|
-
console.log(
|
|
188
|
-
console.log(
|
|
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(
|
|
202
|
+
console.log(`${brand('Loading')} ${bold(url)} ${muted(`once → ${meta.screenshots.length} viewports`)}`);
|
|
192
203
|
if (meta.title)
|
|
193
|
-
console.log(
|
|
204
|
+
console.log(`${label('Web page title')} ${meta.title}`);
|
|
194
205
|
if (meta.finalUrl)
|
|
195
|
-
console.log(
|
|
206
|
+
console.log(`${label('Web page URL')} ${meta.finalUrl}`);
|
|
196
207
|
if (meta.status != null)
|
|
197
|
-
console.log(
|
|
208
|
+
console.log(`${label('Web page status')} ${meta.status}`);
|
|
198
209
|
if (meta.performance)
|
|
199
|
-
console.log(
|
|
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
|
|
214
|
+
console.log(`\n${brand('@ ' + dims)}`);
|
|
204
215
|
const sizePart = formatSize(s.screenshotSizeBytes) + (meta.full ? ' (full page)' : '');
|
|
205
|
-
console.log(
|
|
216
|
+
console.log(`${label('Screenshot size')} ${sizePart}`);
|
|
206
217
|
if (s.width && s.height)
|
|
207
|
-
console.log(
|
|
208
|
-
console.log(
|
|
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
|
package/dist/commands/page.js
CHANGED
|
@@ -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
|
|
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();
|
package/dist/commands/plan.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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)'));
|
package/dist/commands/push.js
CHANGED
|
@@ -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
|
}
|