gipity 1.1.3 → 1.1.5

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 (47) hide show
  1. package/dist/adopt-cwd.js +1 -1
  2. package/dist/banner.js +3 -5
  3. package/dist/catalog.js +1 -3
  4. package/dist/commands/add.js +1 -1
  5. package/dist/commands/build.js +111 -40
  6. package/dist/commands/chat.js +1 -1
  7. package/dist/commands/deploy.js +7 -3
  8. package/dist/commands/file.js +18 -0
  9. package/dist/commands/fn.js +6 -6
  10. package/dist/commands/generate.js +80 -16
  11. package/dist/commands/gmail.js +1 -1
  12. package/dist/commands/job.js +132 -10
  13. package/dist/commands/load.js +2 -2
  14. package/dist/commands/login.js +7 -2
  15. package/dist/commands/page-eval.js +82 -13
  16. package/dist/commands/page-fetch.js +14 -10
  17. package/dist/commands/page-inspect.js +28 -3
  18. package/dist/commands/page-screenshot.js +36 -7
  19. package/dist/commands/page-test.js +1 -1
  20. package/dist/commands/records.js +14 -1
  21. package/dist/commands/remove.js +1 -1
  22. package/dist/commands/sandbox.js +182 -10
  23. package/dist/commands/save.js +1 -1
  24. package/dist/commands/secrets.js +1 -1
  25. package/dist/commands/service.js +7 -2
  26. package/dist/commands/setup.js +4 -6
  27. package/dist/commands/status.js +41 -12
  28. package/dist/commands/storage.js +2 -2
  29. package/dist/commands/sync.js +15 -0
  30. package/dist/commands/test.js +33 -1
  31. package/dist/commands/update.js +4 -0
  32. package/dist/commands/upload.js +69 -121
  33. package/dist/commands/workflow.js +1 -1
  34. package/dist/helpers/body.js +117 -0
  35. package/dist/helpers/duration.js +34 -0
  36. package/dist/helpers/index.js +2 -0
  37. package/dist/index.js +1173 -620
  38. package/dist/knowledge.js +7 -7
  39. package/dist/login-flow.js +44 -2
  40. package/dist/relay/onboarding.js +26 -44
  41. package/dist/sync.js +6 -6
  42. package/dist/updater/bootstrap.js +27 -16
  43. package/dist/updater/check.js +86 -10
  44. package/dist/updater/install.js +82 -0
  45. package/dist/updater/shim.js +90 -27
  46. package/dist/utils.js +60 -3
  47. package/package.json +2 -2
@@ -2,7 +2,7 @@ import { Command } from 'commander';
2
2
  import { get, post, del, getBaseUrl, getAuthHeader } from '../api.js';
3
3
  import { requireConfig } from '../config.js';
4
4
  import { error as clrError, bold, muted, success, warning as warn } from '../colors.js';
5
- import { run, printList } from '../helpers/index.js';
5
+ import { run, printList, resolveBody, parseDuration } from '../helpers/index.js';
6
6
  export const jobCommand = new Command('job')
7
7
  .description('Run long jobs (CPU/GPU)')
8
8
  .addHelpText('after', '\nCPU sandbox or GPU (gpu-small=L4, gpu-medium=A10G, gpu-large=A100, gpu-huge=H100). Declared in the gipity.yaml jobs: phase; submit and poll via the subcommands below.');
@@ -23,14 +23,14 @@ jobCommand
23
23
  }));
24
24
  jobCommand
25
25
  .command('submit <name> [body]')
26
- .description('Submit a job (returns a run guid; poll with `job status <guid>`)')
27
- .option('--data <json>', 'JSON input body')
26
+ .description('Submit a job (returns a run guid; block on it with `job wait <guid>`)')
27
+ .option('-d, --data <json>', 'JSON input body: inline JSON, @file to read a file, or @- / - for stdin')
28
+ .option('--file <field=@path>', 'Attach a file as { data, media_type } under <field> (the vision/media service shape), repeatable, e.g. --file image=@scan.png', (v, acc) => (acc || []).concat(v))
28
29
  .option('--idempotency-key <key>', 'Idempotency key (replays return the existing run)')
29
30
  .option('--json', 'Output as JSON')
30
31
  .action((name, bodyArg, opts) => run('Submit', async () => {
31
32
  const config = requireConfig();
32
- const raw = bodyArg || opts.data || '{}';
33
- const input = JSON.parse(raw);
33
+ const input = resolveBody(bodyArg || opts.data, opts.file);
34
34
  const body = { input };
35
35
  if (opts.idempotencyKey)
36
36
  body.idempotency_key = opts.idempotencyKey;
@@ -41,6 +41,12 @@ jobCommand
41
41
  else {
42
42
  const tag = res.data.replayed ? warn(' (replayed)') : '';
43
43
  console.log(`${bold(res.data.run_guid)} ${muted(res.data.status)}${tag}`);
44
+ // Awaiting the run is the natural next step after every submit; point the
45
+ // agent at the blocking command so it doesn't hand-roll a status poll loop.
46
+ const TERMINAL = new Set(['success', 'failed', 'cancelled', 'canceled', 'error']);
47
+ if (!TERMINAL.has(res.data.status)) {
48
+ console.error(muted(`Block until it finishes: gipity job wait ${res.data.run_guid}`));
49
+ }
44
50
  }
45
51
  }));
46
52
  jobCommand
@@ -57,8 +63,13 @@ jobCommand
57
63
  const r = res.data;
58
64
  const statusColor = r.status === 'success' ? success : r.status === 'failed' ? clrError : muted;
59
65
  console.log(`${statusColor(r.status)} ${muted(r.guid)}`);
60
- if (r.progress_pct != null)
61
- console.log(`progress: ${Math.round(r.progress_pct * 100)}%${r.progress_message ? ` (${r.progress_message})` : ''}`);
66
+ if (r.progress_pct != null) {
67
+ const prog = `${Math.round(r.progress_pct * 100)}%${r.progress_message ? ` (${r.progress_message})` : ''}`;
68
+ // A failed run can have reported 100% (done) right before dying; a bare
69
+ // "progress: 100% (done)" next to status=failed reads as success. Label it.
70
+ const failedish = r.status === 'failed' || r.status === 'cancelled' || r.status === 'canceled' || r.status === 'error';
71
+ console.log(failedish ? `last progress before ${r.status}: ${prog}` : `progress: ${prog}`);
72
+ }
62
73
  if (r.duration_ms != null)
63
74
  console.log(`duration: ${r.duration_ms}ms`);
64
75
  if (r.error)
@@ -66,6 +77,71 @@ jobCommand
66
77
  if (r.output)
67
78
  console.log(`output: ${JSON.stringify(r.output)}`);
68
79
  }));
80
+ jobCommand
81
+ .command('wait <runGuid>')
82
+ .description('Block until a job run finishes, or exit at --timeout so the shell never hangs (poll again to keep waiting)')
83
+ .option('--timeout <seconds>', 'Max seconds to wait before giving up and reporting current progress. Bare number = seconds; an explicit unit (90s) means the same here and on `sandbox run --timeout`.', '90')
84
+ .option('--interval <seconds>', 'Seconds between polls', '3')
85
+ .option('--json', 'Output as JSON')
86
+ .action((runGuid, opts) => run('Wait', async () => {
87
+ const config = requireConfig();
88
+ // Terminal states end the wait; anything else means "still working, keep polling".
89
+ const TERMINAL = new Set(['success', 'failed', 'cancelled', 'canceled', 'error']);
90
+ const durOpt = parseDuration(opts.timeout, 's');
91
+ const timeoutSec = durOpt ? Math.max(1, Math.round(durOpt.value)) : parseInt(opts.timeout, 10);
92
+ const timeout = Number.isFinite(timeoutSec) && timeoutSec > 0 ? timeoutSec : 90;
93
+ const interval = Math.max(1, parseInt(opts.interval, 10) || 3);
94
+ const deadline = Date.now() + timeout * 1000;
95
+ const fetchRun = async () => (await get(`/projects/${config.projectGuid}/jobs/runs/${encodeURIComponent(runGuid)}`)).data;
96
+ let r = await fetchRun();
97
+ let lastProgress = '';
98
+ while (!TERMINAL.has(r.status) && Date.now() < deadline) {
99
+ // Surface progress changes to stderr so an agent watching sees liveness
100
+ // without the poll noise polluting the machine-readable stdout result.
101
+ if (!opts.json) {
102
+ const prog = r.progress_pct != null
103
+ ? `${Math.round(r.progress_pct * 100)}%${r.progress_message ? ` (${r.progress_message})` : ''}`
104
+ : r.status;
105
+ if (prog !== lastProgress) {
106
+ console.error(muted(`... ${prog}`));
107
+ lastProgress = prog;
108
+ }
109
+ }
110
+ const remaining = deadline - Date.now();
111
+ if (remaining <= 0)
112
+ break;
113
+ await new Promise(res => setTimeout(res, Math.min(interval * 1000, remaining)));
114
+ r = await fetchRun();
115
+ }
116
+ const done = TERMINAL.has(r.status);
117
+ if (opts.json) {
118
+ console.log(JSON.stringify(done ? r : { ...r, waiting: true, timed_out: true }));
119
+ }
120
+ else if (!done) {
121
+ // Not an error - the job is simply still going. Say so plainly and tell
122
+ // the agent exactly how to resume, then exit non-zero so a chained `&&`
123
+ // doesn't proceed as if the run had finished.
124
+ const prog = r.progress_pct != null ? ` ${Math.round(r.progress_pct * 100)}%` : '';
125
+ console.log(`${warn('still running')}${prog}${r.progress_message ? ` (${r.progress_message})` : ''} ${muted(runGuid)}`);
126
+ console.log(muted(`Not finished after ${timeout}s. Run \`gipity job wait ${runGuid}\` again to keep waiting.`));
127
+ }
128
+ else {
129
+ const statusColor = r.status === 'success' ? success : clrError;
130
+ console.log(`${statusColor(r.status)} ${muted(r.guid)}`);
131
+ if (r.duration_ms != null)
132
+ console.log(`duration: ${r.duration_ms}ms`);
133
+ if (r.error)
134
+ console.log(`${clrError('error:')} ${r.error}`);
135
+ if (r.output)
136
+ console.log(`output: ${JSON.stringify(r.output)}`);
137
+ }
138
+ // Exit codes: success → 0, failed/cancelled → 1, still-running-at-timeout → 2
139
+ // (distinct from a real failure so agents can tell "not yet" from "broke").
140
+ if (!done)
141
+ process.exit(2);
142
+ if (r.status !== 'success')
143
+ process.exit(1);
144
+ }));
69
145
  jobCommand
70
146
  .command('runs <name>')
71
147
  .description('List recent runs for a job')
@@ -87,6 +163,7 @@ jobCommand
87
163
  .description('Stream live logs for a job run (--no-follow for a one-shot snapshot)')
88
164
  .option('--follow', 'Stream via SSE (default)', true)
89
165
  .option('--no-follow', 'One-shot snapshot of run state instead of streaming')
166
+ .option('--timeout <seconds>', 'Stop streaming after N seconds and exit cleanly, so a bounded peek at the live log never hangs the shell (no shell `timeout` wrapper needed). Bare number = seconds; unit (20s) also accepted.')
90
167
  .option('--json', 'Output as JSON')
91
168
  .action((runGuid, opts) => run('Logs', async () => {
92
169
  const config = requireConfig();
@@ -95,6 +172,16 @@ jobCommand
95
172
  console.log(opts.json ? JSON.stringify(res.data) : JSON.stringify(res.data, null, 2));
96
173
  return;
97
174
  }
175
+ // Optional bounded peek: close the stream after --timeout seconds instead of
176
+ // holding open until the run finishes. Lets an agent glance at early stdout
177
+ // (e.g. catch an import error during cold start) without wrapping the command
178
+ // in a shell `timeout`, then poll again to keep watching.
179
+ let peekSeconds = 0;
180
+ if (opts.timeout != null) {
181
+ const dur = parseDuration(String(opts.timeout), 's');
182
+ const secs = dur ? dur.value : parseFloat(String(opts.timeout));
183
+ peekSeconds = Number.isFinite(secs) && secs > 0 ? secs : 0;
184
+ }
98
185
  // SSE stream. Manual fetch + line buffering - Node's EventSource isn't built-in,
99
186
  // and the SSE format is simple enough to parse inline (event: <name>\ndata: <json>\n\n).
100
187
  const url = `${getBaseUrl()}/projects/${config.projectGuid}/jobs/runs/${encodeURIComponent(runGuid)}/logs/stream`;
@@ -102,8 +189,28 @@ jobCommand
102
189
  const headers = { 'Accept': 'text/event-stream' };
103
190
  if (authHeader)
104
191
  headers.Authorization = authHeader;
105
- const res = await fetch(url, { headers });
192
+ // Abort the underlying request when the peek window elapses so the socket is
193
+ // torn down cleanly rather than left dangling.
194
+ const controller = new AbortController();
195
+ let peekTimer;
196
+ let peekedOut = false;
197
+ if (peekSeconds > 0) {
198
+ peekTimer = setTimeout(() => { peekedOut = true; controller.abort(); }, peekSeconds * 1000);
199
+ }
200
+ let res;
201
+ try {
202
+ res = await fetch(url, { headers, signal: controller.signal });
203
+ }
204
+ catch (e) {
205
+ if (peekedOut) {
206
+ console.error(muted(`... still streaming after ${peekSeconds}s. Run \`gipity job logs ${runGuid} --timeout ${Math.round(peekSeconds)}\` again to keep watching, or \`gipity job wait ${runGuid}\` to block until it finishes.`));
207
+ return;
208
+ }
209
+ throw e;
210
+ }
106
211
  if (!res.ok || !res.body) {
212
+ if (peekTimer)
213
+ clearTimeout(peekTimer);
107
214
  console.error(clrError(`Failed to open stream: HTTP ${res.status}`));
108
215
  process.exit(1);
109
216
  }
@@ -112,9 +219,24 @@ jobCommand
112
219
  let buffer = '';
113
220
  let currentEvent = '';
114
221
  while (true) {
115
- const { done, value } = await reader.read();
116
- if (done)
222
+ let chunk;
223
+ try {
224
+ chunk = await reader.read();
225
+ }
226
+ catch (e) {
227
+ // Peek window elapsed mid-stream: report cleanly and exit 0 (not an error).
228
+ if (peekedOut) {
229
+ console.error(muted(`... still streaming after ${peekSeconds}s. Run \`gipity job logs ${runGuid} --timeout ${Math.round(peekSeconds)}\` again to keep watching, or \`gipity job wait ${runGuid}\` to block until it finishes.`));
230
+ return;
231
+ }
232
+ throw e;
233
+ }
234
+ const { done, value } = chunk;
235
+ if (done) {
236
+ if (peekTimer)
237
+ clearTimeout(peekTimer);
117
238
  break;
239
+ }
118
240
  buffer += decoder.decode(value, { stream: true });
119
241
  let nl;
120
242
  while ((nl = buffer.indexOf('\n')) >= 0) {
@@ -152,7 +152,7 @@ export const loadCommand = new Command('load')
152
152
  if (opts.inspect) {
153
153
  const res = opts.json
154
154
  ? await doInspect()
155
- : await withSpinner('Inspecting', doInspect, { done: null });
155
+ : await withSpinner('Inspecting...', doInspect, { done: null });
156
156
  if (opts.json) {
157
157
  console.log(JSON.stringify(res.data));
158
158
  return;
@@ -162,7 +162,7 @@ export const loadCommand = new Command('load')
162
162
  }
163
163
  const { data, partial } = opts.json
164
164
  ? await doImport()
165
- : await withSpinner('Importing', doImport, { done: null });
165
+ : await withSpinner('Importing...', doImport, { done: null });
166
166
  const project = data.project;
167
167
  // Materialize a local dir and link it, exactly like `gipity project create`:
168
168
  // `.gipity.json` is written directly inside the new dir, then a soft sync
@@ -4,6 +4,7 @@ import { publicPost } from '../api.js';
4
4
  import { prompt, decodeJwtExp } from '../utils.js';
5
5
  import { success, error as clrError, muted } from '../colors.js';
6
6
  import { flushBugQueue } from '../bug-queue.js';
7
+ import { warnBeforeCodeIfUnexpectedNewAccount, warnIfUnexpectedNewAccount } from '../login-flow.js';
7
8
  export const loginCommand = new Command('login')
8
9
  .description('Log in or sign up')
9
10
  .option('--email <email>', 'Email address')
@@ -19,9 +20,10 @@ export const loginCommand = new Command('login')
19
20
  }
20
21
  // Email only → send code and exit (non-interactive step 1)
21
22
  if (email && !code) {
22
- await publicPost('/auth/login', { email });
23
+ const sendRes = await publicPost('/auth/login', { email });
23
24
  console.log('Check your email for a 6-digit code.');
24
25
  console.log(muted(`Then run: gipity login --email ${email} --code <code>`));
26
+ warnBeforeCodeIfUnexpectedNewAccount(sendRes.isNewUser, email);
25
27
  return;
26
28
  }
27
29
  // Fully interactive flow
@@ -34,9 +36,10 @@ export const loginCommand = new Command('login')
34
36
  console.error(clrError('Email required.'));
35
37
  process.exit(1);
36
38
  }
37
- await publicPost('/auth/login', { email });
39
+ const sendRes = await publicPost('/auth/login', { email });
38
40
  console.log('');
39
41
  console.log('Check your email for a 6-digit code.');
42
+ warnBeforeCodeIfUnexpectedNewAccount(sendRes.isNewUser, email);
40
43
  code = await prompt('Code: ');
41
44
  await verify(email, code);
42
45
  }
@@ -46,6 +49,7 @@ export const loginCommand = new Command('login')
46
49
  }
47
50
  });
48
51
  async function verify(email, code) {
52
+ const priorAuth = getAuth();
49
53
  const res = await publicPost('/auth/verify', { email, code });
50
54
  const exp = decodeJwtExp(res.accessToken);
51
55
  if (!exp) {
@@ -60,6 +64,7 @@ async function verify(email, code) {
60
64
  expiresAt,
61
65
  });
62
66
  console.log(success(`Logged in (${email}).`));
67
+ warnIfUnexpectedNewAccount(res.isNewUser, email, priorAuth);
63
68
  // A fresh session is the clearest "we're reconnected" signal - clear any bug
64
69
  // reports that got stranded while this account's session was expired/offline.
65
70
  const delivered = await flushBugQueue().catch(() => 0);
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { Command, Option } from 'commander';
3
3
  import { post, get, ApiError } from '../api.js';
4
4
  import { brand, bold, muted, warning, success } from '../colors.js';
5
- import { run } from '../helpers/index.js';
5
+ import { run, parseDuration } from '../helpers/index.js';
6
6
  import { getAuth } from '../auth.js';
7
7
  import { resolveProjectContext } from '../config.js';
8
8
  import { uploadPublicFixture, uploadCameraFeed, assertCameraFile, deleteFixture } from '../page-fixtures.js';
@@ -106,7 +106,7 @@ export function summarizeExpr(expr) {
106
106
  if (oneLine && expr.trim().length <= EXPR_ECHO_MAX_CHARS)
107
107
  return expr.trim();
108
108
  const first = (meaningful[0] ?? '').trim();
109
- const head = first.length > EXPR_ECHO_MAX_CHARS ? `${first.slice(0, EXPR_ECHO_MAX_CHARS - 1)}…` : first;
109
+ const head = first.length > EXPR_ECHO_MAX_CHARS ? `${first.slice(0, EXPR_ECHO_MAX_CHARS - 3)}...` : first;
110
110
  const shape = oneLine
111
111
  ? `(${expr.trim().length} chars)`
112
112
  : `(+${meaningful.length - 1} more ${meaningful.length - 1 === 1 ? 'line' : 'lines'}, ${expr.trim().length} chars)`;
@@ -148,6 +148,13 @@ export function capWaitForTimeoutMs(raw) {
148
148
  `or watch for it INSIDE the script (--timeout ${EVAL_SCRIPT_BUDGET_MAX_MS}).`));
149
149
  return WAIT_FOR_MAX_MS;
150
150
  }
151
+ /** Read a --file/--reload-file script. `-` means stdin, so an agent can pipe a
152
+ * heredoc straight in — `gipity page eval <url> --file - <<'EOF' … EOF` — with
153
+ * no throwaway tmp file to write and then clean up. readFileSync(0) drains fd 0
154
+ * (stdin) to EOF. Any other value is a path on disk. */
155
+ export function readScriptFile(pathArg) {
156
+ return readFileSync(pathArg === '-' ? 0 : pathArg, 'utf8');
157
+ }
151
158
  /** True when `s` is an expression (`document.title`, an IIFE) rather than a
152
159
  * statement body (`const x = …; return x`). Mirrors the server's own parse
153
160
  * choice: only an expression may be spliced into `return (…)`. Nothing is
@@ -192,6 +199,12 @@ const CAMERA_SETUP_BUDGET_MS = 30_000;
192
199
  export const EVAL_SCRIPT_BUDGET_MS = 30_000;
193
200
  export const EVAL_SCRIPT_BUDGET_CAMERA_MS = 45_000;
194
201
  export const EVAL_SCRIPT_BUDGET_MAX_MS = 90_000;
202
+ // The smallest in-page budget the server will honour. A --timeout below this is
203
+ // floored to it, so any sub-floor value is useless as a real ms budget AND is
204
+ // almost always seconds typed as ms (--timeout 120 meaning 120s) — the guard in
205
+ // the action rejects it up front with the unit named, rather than letting it
206
+ // silently floor and then stall with an opaque "1s budget" error.
207
+ export const EVAL_SCRIPT_BUDGET_MIN_MS = 1_000;
195
208
  /** The in-page budget this call gets: the caller's --timeout when given, else the
196
209
  * default for the kind of run (roomier under --camera/--fake-media). Clamped to
197
210
  * the server's accepted range — a value over the max is clamped and explained on
@@ -209,7 +222,7 @@ export function capScriptBudgetMs(rawTimeout, hasMedia) {
209
222
  `absorb it in the pre-eval window (--wait-for '<ready-selector>'), keeping the body to a quick read.`));
210
223
  return EVAL_SCRIPT_BUDGET_MAX_MS;
211
224
  }
212
- return Math.max(1_000, parsed);
225
+ return Math.max(EVAL_SCRIPT_BUDGET_MIN_MS, parsed);
213
226
  }
214
227
  /** Upper bound on the server-side work this eval asked for. EVERY leg the caller
215
228
  * can lengthen has to be in here, or the client gives up on a job that is still
@@ -366,9 +379,9 @@ export const pageEvalCommand = new Command('eval')
366
379
  .description('Evaluate JS in a real browser on a page (DOM, computed styles, element rects; inline expr or --file script). ONE client per call - to verify realtime/presence across concurrent clients use `page test --observe` instead')
367
380
  .argument('<url>', 'URL to load')
368
381
  .argument('[expr]', `JavaScript to evaluate in page context (inline expression or statement body; await works, and the trailing expression is returned automatically — REPL-style — so no explicit return is needed; result is JSON-serialized). Omit when using --file. Time budget: the body has ${EVAL_SCRIPT_BUDGET_MS / 1000}s to finish after page load (${EVAL_SCRIPT_BUDGET_CAMERA_MS / 1000}s with --camera) - raise it with --timeout, max ${EVAL_SCRIPT_BUDGET_MAX_MS / 1000}s.`)
369
- .option('--file <path>', `Read the script body from a file instead of the inline <expr> arg (mutually exclusive). Runs as an async function body, so top-level return/await work. Same post-load budget as <expr> (--timeout).`)
382
+ .option('--file <path>', `Read the script body from a file instead of the inline <expr> arg (mutually exclusive), or --file - to read it from stdin (pipe a heredoc: --file - <<'EOF' ... EOF) with no tmp file. Runs as an async function body, so top-level return/await work. Same post-load budget as <expr> (--timeout).`)
370
383
  .option('--step <expr>', `Run another expression against the SAME loaded page, after <expr> (repeat, max ${MAX_EVAL_STEPS}). Whatever that page load paid for — a vision model coming up, a game booting, a socket connecting — stays up for every step, so an N-part check costs ONE page load instead of N. Each step gets its own in-page budget and its own reported result.`, (val, prev) => [...prev, val], [])
371
- .option('--fixture <path>', 'Host a local file and expose it to the eval as `fixtureUrl` (and under `fixtures` by basename) to fetch in-page. For verifying a render/parse path against a real binary (an MP3, an image) - no size limit, auto-deleted after the run. Repeat for several files (single-value so it never swallows the inline <expr>).', (val, prev) => [...prev, val], [])
384
+ .option('--fixture <path>', 'Host a local file and expose it to the eval as `fixtureUrl` (and under `fixtures` by basename) to fetch in-page. For verifying a render/parse path against a real binary (an MP3, an image) - no size limit, auto-deleted after the run. The hosted URL has permissive CORS, so `new Image(); img.crossOrigin="anonymous"; img.src=fixtureUrl` decodes untainted for a canvas/pixel read - this is the UPLOAD analog of --camera: feed a known photo/video to a file-upload vision app (web-vision-detect) and run its detector, no need to deploy a throwaway test asset into the app. Repeat for several files (single-value so it never swallows the inline <expr>).', (val, prev) => [...prev, val], [])
372
385
  .option('--reload <expr>', 'After the first eval, reload the page IN PLACE (localStorage/sessionStorage/cookies preserved) and evaluate this second expression against the post-reload DOM. One command verifies persisted state survives a reload: seed/assert state with <expr>, then assert the restored UI here.')
373
386
  .option('--reload-file <path>', 'Read the post-reload expression from a file instead of inline --reload (mutually exclusive)')
374
387
  .option('--camera <path>', `Play a local image or video (.png/.jpg/.webp/.mp4/.webm/.y4m/.mjpeg) as the browser's WEBCAM feed, so a camera app's real pipeline (getUserMedia → MediaPipe/YOLOX → your app logic) runs headlessly on a frame you choose. Implies --fake-media and waits ${CAMERA_DEFAULT_WAIT_MS / 1000}s for the vision model to load. No frame handy? gipity generate image "a hand making a closed fist, palm to camera".`)
@@ -376,7 +389,7 @@ export const pageEvalCommand = new Command('eval')
376
389
  .option('--wait <ms>', 'Sleep this many ms after DOMContentLoaded before evaluating (lets late async work settle; max 30000). With --wait-for, it elapses AFTER the selector appears - gate, then settle.', '500')
377
390
  .option('--wait-for <selector>', `Wait until this CSS selector appears before evaluating, then evaluate (deterministic - beats guessing a --wait). Gate on the state you are ASSERTING, not just a ready flag: '#verdict:not(:empty)' returns the instant the round lands, where a fixed wait snapshots mid-sequence. Same flag on page inspect/screenshot. Max ${WAIT_FOR_MAX_MS}ms (--wait-timeout).`)
378
391
  .option('--wait-timeout <ms>', `Max ms to wait for --wait-for before giving up (max ${WAIT_FOR_MAX_MS})`, '5000')
379
- .option('--timeout <ms>', `How long the script itself may run IN the page - its own await/setTimeout pauses count (default ${EVAL_SCRIPT_BUDGET_MS}, ${EVAL_SCRIPT_BUDGET_CAMERA_MS} with --camera/--fake-media; max ${EVAL_SCRIPT_BUDGET_MAX_MS}). Raise it to trace a sequence that unfolds over time (a game round, an animation). Distinct from --wait, which only sleeps BEFORE the script.`)
392
+ .option('--timeout <ms>', `How long the script itself may run IN the page. Bare number = MILLISECONDS (so 90s = 90000, NOT 90); or pass an explicit unit that means the same on both this and \`sandbox run --timeout\` — --timeout 90s. Its own await/setTimeout pauses count (default ${EVAL_SCRIPT_BUDGET_MS}, ${EVAL_SCRIPT_BUDGET_CAMERA_MS} with --camera/--fake-media; floor 1000, max ${EVAL_SCRIPT_BUDGET_MAX_MS}). Raise it to trace a sequence that unfolds over time (a game round, an animation). Distinct from --wait, which only sleeps BEFORE the script.`)
380
393
  .option('--auth', 'Evaluate signed in as you (your Gipity account), so a page behind a Sign-in-with-Gipity login is reachable. Only works for apps using Sign in with Gipity, hosted on *.gipity.ai. Without this flag the page loads as a genuinely anonymous, signed-out visitor — nothing carries over from earlier --auth runs.')
381
394
  .option('--json', 'Output as JSON')
382
395
  .action((url, exprArg, opts) => run('Page eval', async () => {
@@ -409,10 +422,12 @@ export const pageEvalCommand = new Command('eval')
409
422
  let expr = exprArg;
410
423
  if (opts.file) {
411
424
  try {
412
- expr = readFileSync(opts.file, 'utf8');
425
+ expr = readScriptFile(opts.file);
413
426
  }
414
427
  catch {
415
- pageEvalCommand.error(`error: Cannot read file: ${opts.file}`);
428
+ pageEvalCommand.error(opts.file === '-'
429
+ ? 'error: --file - reads the script from stdin, but stdin was empty/unreadable - pipe it in, e.g. gipity page eval "<url>" --file - <<\'EOF\' ... EOF'
430
+ : `error: Cannot read file: ${opts.file}`);
416
431
  }
417
432
  }
418
433
  // Post-reload expression: inline --reload or --reload-file, same shape
@@ -423,7 +438,7 @@ export const pageEvalCommand = new Command('eval')
423
438
  let reloadExpr = opts.reload;
424
439
  if (opts.reloadFile) {
425
440
  try {
426
- reloadExpr = readFileSync(opts.reloadFile, 'utf8');
441
+ reloadExpr = readScriptFile(opts.reloadFile);
427
442
  }
428
443
  catch {
429
444
  pageEvalCommand.error(`error: Cannot read file: ${opts.reloadFile}`);
@@ -434,6 +449,41 @@ export const pageEvalCommand = new Command('eval')
434
449
  pageEvalCommand.error(`error: at most ${MAX_EVAL_STEPS} --step expressions ride on one page load (got ${steps.length}) — ` +
435
450
  `do more per step, or split this into two evals`);
436
451
  }
452
+ // --timeout is native MILLISECONDS here but native SECONDS on the sibling
453
+ // `sandbox run --timeout`. An explicit unit suffix reconciles them: `--timeout
454
+ // 90s` means 90 seconds on BOTH commands. A suffixed value is normalized to ms
455
+ // up front, so the rest of this action (and the bare-number guard below) sees a
456
+ // plain ms number and the portable form just works.
457
+ if (opts.timeout !== undefined) {
458
+ const dur = parseDuration(opts.timeout, 'ms');
459
+ if (dur?.hadSuffix)
460
+ opts.timeout = String(Math.round(dur.value));
461
+ }
462
+ // A BARE number below the in-page floor is the unit-mixup tell: a real ms
463
+ // budget that small is useless (it just floors to the minimum), so a sub-floor
464
+ // bare number is almost always seconds typed as ms (--timeout 90 meaning 90s).
465
+ // Left alone it clamps SILENTLY to a ~1s budget and the script then stalls with
466
+ // an opaque "1s budget" error that never reveals the unit mix-up. Reject it up
467
+ // front, naming the unit AND the portable suffix form, so the fix is one obvious
468
+ // edit instead of a doomed run.
469
+ if (opts.timeout !== undefined) {
470
+ const t = parseInt(opts.timeout, 10);
471
+ if (Number.isFinite(t) && t > 0 && t < EVAL_SCRIPT_BUDGET_MIN_MS) {
472
+ const overMax = t * 1000 > EVAL_SCRIPT_BUDGET_MAX_MS;
473
+ const asMs = Math.min(t * 1000, EVAL_SCRIPT_BUDGET_MAX_MS);
474
+ // A value-mixup, not an arg-SHAPE error: the message below already carries
475
+ // the complete fix (pass `--timeout ${t}s`), so this does NOT go through
476
+ // pageEvalCommand.error() — that dumps the whole options list bracketing
477
+ // the message, and an agent piping the output through `| tail` reads the
478
+ // option list as "it rejected me with help" and misses the one-line fix.
479
+ // Throw instead: `run()` prints just the targeted message, nothing else.
480
+ throw new Error(`--timeout is in MILLISECONDS (got ${t}, under the ${EVAL_SCRIPT_BUDGET_MIN_MS}ms in-page floor). ` +
481
+ `Unlike \`sandbox run --timeout\` (seconds), page eval's is ms — or pass an explicit unit that means the ` +
482
+ `same on both: --timeout ${t}s. ${t} looks like seconds, so for ${t} second${t === 1 ? '' : 's'} pass ` +
483
+ `--timeout ${t}s (= ${asMs}ms)` +
484
+ `${overMax ? ` (${t}s is over the ${EVAL_SCRIPT_BUDGET_MAX_MS / 1000}s max, so this is the ceiling)` : ''}.`);
485
+ }
486
+ }
437
487
  let waitMs = capWaitMs(opts.wait, url);
438
488
  const waitForTimeoutMs = capWaitForTimeoutMs(opts.waitTimeout);
439
489
  // The in-page budget for the script itself. Always sent, so the caller (and
@@ -490,12 +540,12 @@ export const pageEvalCommand = new Command('eval')
490
540
  projectGuid = config.projectGuid;
491
541
  }
492
542
  if (opts.camera) {
493
- console.log(muted(`Hosting camera feed ${opts.camera}…`));
543
+ console.log(muted(`Hosting camera feed ${opts.camera}...`));
494
544
  camera = await uploadCameraFeed(projectGuid, opts.camera);
495
545
  }
496
546
  if (fixturePaths.length) {
497
547
  for (const p of fixturePaths) {
498
- console.log(muted(`Hosting fixture ${p}…`));
548
+ console.log(muted(`Hosting fixture ${p}...`));
499
549
  hosted.push(await uploadPublicFixture(projectGuid, p));
500
550
  }
501
551
  const map = {};
@@ -601,7 +651,7 @@ export const pageEvalCommand = new Command('eval')
601
651
  console.log(`${muted('Camera:')} ${camera.name} ${muted('(played as the webcam feed; getUserMedia resolves)')}`);
602
652
  if (hosted.length)
603
653
  console.log(`${muted('Fixtures:')} ${hosted.map((h) => h.name).join(', ')}`);
604
- console.error(opts.file ? `${muted('Script:')} ${opts.file}` : `${muted('Expression:')} ${summarizeExpr(expr)}`);
654
+ console.error(opts.file ? `${muted('Script:')} ${opts.file === '-' ? '(stdin)' : opts.file}` : `${muted('Expression:')} ${summarizeExpr(expr)}`);
605
655
  console.log(`\n${result.trim() ? result : muted('(empty result)')}`);
606
656
  if (noValue)
607
657
  console.log(muted(`\n${EVAL_NO_VALUE_HINT}`));
@@ -646,7 +696,16 @@ for (const f of JS_DECOY_FLAGS)
646
696
  // clients see each other (presence, shared state). For that, use the genuinely-
647
697
  // concurrent `page test --observe` instead, which overlaps N clients and reports
648
698
  // whether they actually ran together.
649
- pageEvalCommand.addHelpText('after', `
699
+ //
700
+ // This whole narrative renders ONLY on an explicit `--help`, never on an
701
+ // arg-shape error. An error dumps the command via outputHelp too, but appending
702
+ // ~70 lines of examples/time-budget/realtime guidance on top of a one-line "URL
703
+ // must be absolute" buries the actual error — and its trailing realtime block,
704
+ // off-topic for a plain probe, has misled agents into "fixing the eval syntax".
705
+ // The one realtime pointer worth keeping on error already lives in the command
706
+ // DESCRIPTION ("ONE client per call - use `page test --observe`"), which renders
707
+ // on error anyway, so nothing is lost by withholding the manual here.
708
+ pageEvalCommand.addHelpText('after', (context) => context.error ? '' : `
650
709
  Examples:
651
710
  gipity page eval "https://dev.gipity.ai/me/app/" "document.title"
652
711
  # Functionally test a page's own code paths: save a script that drives the UI
@@ -682,6 +741,16 @@ Examples:
682
741
  "window.__vision.gesture()" \\
683
742
  --step "document.getElementById('see').textContent" \\
684
743
  --step "({ score: score.textContent, verdict: verdict.textContent })"
744
+ # File-UPLOAD vision app (web-vision-detect, no camera)? --fixture is the analog of
745
+ # --camera: it hosts your test photo at a CORS-permissive URL, so you feed the kit's
746
+ # detector a known image and read back the labels - no deploying a throwaway asset into
747
+ # the app tree (and no cleanup redeploy). crossOrigin='anonymous' keeps the canvas clean.
748
+ gipity generate image "street scene: three people, two cars, one dog" -o street.jpg
749
+ gipity page eval "https://dev.gipity.ai/me/app/" --fixture street.jpg \\
750
+ "const { createDetector } = await import('./packages/web-vision-detect/index.js'); \\
751
+ const det = await createDetector({ model: 'nano' }); \\
752
+ const img = new Image(); img.crossOrigin = 'anonymous'; img.src = fixtureUrl; await img.decode(); \\
753
+ const { detections } = await det.detect(img); return detections.map(d => d.label);"
685
754
 
686
755
  Module resolution: dynamic import() specifiers starting with ./ or ../ resolve
687
756
  against the PAGE URL, so import('./packages/i18n/index.js') loads the app's own
@@ -30,9 +30,6 @@ function looksLikeHtml(body) {
30
30
  const head = body.slice(0, 300).toLowerCase();
31
31
  return /<!doctype html|<html[\s>]/.test(head);
32
32
  }
33
- function sha256(s) {
34
- return createHash('sha256').update(s).digest('hex');
35
- }
36
33
  function baseWithSlash(url) {
37
34
  return url.endsWith('/') ? url : url + '/';
38
35
  }
@@ -43,8 +40,16 @@ function resolveUrl(base, path) {
43
40
  async function fetchRaw(url) {
44
41
  try {
45
42
  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 };
43
+ // Read raw bytes, not text(): decoding binary as UTF-8 turns every invalid
44
+ // byte into a 3-byte replacement char, inflating the measured size ~1.7x.
45
+ const buf = Buffer.from(await res.arrayBuffer());
46
+ return {
47
+ status: res.status,
48
+ contentType: res.headers.get('content-type'),
49
+ body: buf.toString('utf-8'),
50
+ bytes: buf.length,
51
+ sha256: createHash('sha256').update(buf).digest('hex'),
52
+ };
48
53
  }
49
54
  catch {
50
55
  return null; // DNS failure, connection refused, etc.
@@ -59,21 +64,20 @@ async function probeShell(base) {
59
64
  return {
60
65
  served: r.status >= 200 && r.status < 300,
61
66
  status: r.status,
62
- sha256: sha256(r.body),
67
+ sha256: r.sha256,
63
68
  isHtml: looksLikeHtml(r.body),
64
69
  };
65
70
  }
66
71
  function classify(path, r, shell) {
67
72
  if (!r)
68
73
  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 };
74
+ const base = { status: r.status, contentType: r.contentType, bytes: r.bytes };
71
75
  // Honest 404/5xx - the file simply isn't there.
72
76
  if (r.status >= 400)
73
77
  return { ...base, verdict: 'MISSING', detail: `HTTP ${r.status}` };
74
78
  const expect = expectedType(path);
75
79
  // 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) {
80
+ if (shell.served && shell.sha256 && r.sha256 === shell.sha256) {
77
81
  return { ...base, verdict: 'MISSING', detail: `HTTP ${r.status} but served the SPA shell (file not deployed)` };
78
82
  }
79
83
  // 2) Asked for a non-HTML asset but got an HTML body → a per-path shell variant.
@@ -92,7 +96,7 @@ const TAG = {
92
96
  'WRONG-TYPE': warning('WRONG-TYPE'),
93
97
  };
94
98
  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')
99
+ .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
100
  .argument('<url>', 'Deployed app base URL; file paths resolve relative to it')
97
101
  .argument('<paths...>', 'One or more file paths to verify, e.g. llms.txt AGENTS.md robots.txt')
98
102
  .option('--json', 'Output as JSON')
@@ -26,10 +26,10 @@ function shortUrl(url, truncate = true, maxLen = 100) {
26
26
  }
27
27
  if (!truncate || result.length <= maxLen)
28
28
  return result;
29
- const keep = maxLen - 1;
29
+ const keep = maxLen - 3;
30
30
  const headLen = Math.ceil(keep / 2);
31
31
  const tailLen = Math.floor(keep / 2);
32
- return result.slice(0, headLen) + '' + result.slice(-tailLen);
32
+ return result.slice(0, headLen) + '...' + result.slice(-tailLen);
33
33
  }
34
34
  export const pageInspectCommand = new Command('inspect')
35
35
  .description('Inspect a web page (console, failed resources, timing, layout overflow). ONE passive load - to verify realtime/presence across concurrent clients use `page test --observe`')
@@ -80,7 +80,24 @@ export async function inspectPage(url, opts = {}) {
80
80
  auth: opts.auth || undefined,
81
81
  };
82
82
  const res = await post(`/tools/browser/inspect`, inspectBody);
83
- const b = res.data;
83
+ const b = res?.data;
84
+ // The inspector can hand back nothing to report — an auth-gated or
85
+ // JS-rendered SPA whose client render hadn't run, a navigation that
86
+ // redirected away, or a server-side hiccup that returned an empty bundle.
87
+ // Left unguarded the report below either crashes on `b.failedResources` or
88
+ // prints a bare header that reads as blank output — no signal at the exact
89
+ // moment the agent reached for verification. Say plainly that nothing was
90
+ // captured and point at the levers that fix it.
91
+ if (!b || typeof b !== 'object') {
92
+ if (opts.json) {
93
+ console.log(JSON.stringify({ url, captured: false, note: 'inspector returned no page data' }));
94
+ return;
95
+ }
96
+ console.log(`${brand('Inspecting')} ${bold(url)}`);
97
+ console.log(warning('Captured no page data — the page returned an empty response.'));
98
+ console.log(muted('The page may have failed to load, redirected away, or rendered nothing server-side. Retry once; if it persists pass --wait-for <selector> to wait for a specific element to appear, or --auth if it is behind Sign in with Gipity.'));
99
+ return;
100
+ }
84
101
  // ── Move resource-load failures out of the console, where they're noise ──
85
102
  // A sub-resource that returns an HTTP 4xx/5xx is reported twice: once in
86
103
  // `failedResources` (CDP network layer — carries the full URL, method, and
@@ -179,6 +196,14 @@ export async function inspectPage(url, opts = {}) {
179
196
  console.log(`${muted('Title:')} ${b.title || '(none)'}`);
180
197
  console.log(`${muted('Elements:')} ${b.elementCount || 0}`);
181
198
  console.log(`${muted('Page weight:')} ${info(formatSize(b.totalBytes || 0))}`);
199
+ // A page that came back with no DOM and no title rendered essentially
200
+ // nothing the inspector could see. That's the auth-gated / JS-rendered SPA
201
+ // trap: the header above alone reads as "loaded fine, just empty" when the
202
+ // real story is "client render or auth gate produced nothing yet." Flag it
203
+ // so the agent knows to wait for the render or sign in, not to trust silence.
204
+ if ((b.elementCount || 0) === 0 && !b.title) {
205
+ console.log(`${warning('⚠ Rendered no DOM (0 elements, no title)')} ${muted('— the client-side render likely had not run yet (try --wait-for <selector>), the page failed to load, or it is behind a sign-in gate (try --auth).')}`);
206
+ }
182
207
  // ── Timing ──
183
208
  console.log(`\n${bold('Timing:')}`);
184
209
  console.log(`${muted('TTFB:')} ${timing.ttfb}ms`);