gipity 1.1.3 → 1.1.4

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.
@@ -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';
@@ -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
@@ -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
@@ -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`);
@@ -8,6 +8,7 @@ import { brand, bold, muted, success, warning } from '../colors.js';
8
8
  import { formatSize } from '../utils.js';
9
9
  import { run } from '../helpers/index.js';
10
10
  import { withSpinner } from '../progress.js';
11
+ import { readScriptFile } from './page-eval.js';
11
12
  import { uploadCameraFeed, assertCameraFile, deleteFixture } from '../page-fixtures.js';
12
13
  /** `mobile` and `tablet` name a real handset/tablet rather than just a window size.
13
14
  * The server emulates it end to end - mobile user-agent, DPR, and crucially TOUCH,
@@ -235,6 +236,7 @@ export const pageScreenshotCommand = new Command('screenshot')
235
236
  .option('--wait-for <selector>', `Wait until this CSS selector appears before capturing, then capture (deterministic - beats guessing a --wait). Same flag as page eval/inspect. Gate on what you are photographing ('[data-vision="ready"]', '#verdict:not(:empty)'). Max ${WAIT_FOR_MAX_MS}ms (--wait-timeout).`)
236
237
  .option('--wait-timeout <ms>', `Max ms to wait for --wait-for before giving up (default ${WAIT_FOR_DEFAULT_MS}, max ${WAIT_FOR_MAX_MS}). Timing out is reported - the capture still happens, so you see the state it got stuck in.`)
237
238
  .option('--action <js>', 'Run JS in the page before capturing — e.g. click a button to enter a state ("document.getElementById(\'play\').click()"). Runs as an async function body, so const/await and app-relative import(\'./…\') work. Runs after the post-load delay, then settles again before the shot. If it throws, the capture still happens and the failure is reported.')
239
+ .option('--file <path>', 'Read the pre-capture script from a file instead of inline --action (mutually exclusive), or --file - to read it from stdin (pipe a heredoc: --file - <<\'EOF\' … EOF) with no tmp file. For a multi-step driver — click through a flow, wait for it to render — then capture. Same async-function-body semantics as --action; same --file flag as page eval.')
238
240
  .option('--full', 'Capture the full scrollable page (default: viewport only). Scrolls the page through first so scroll-reveal/fade-in-on-scroll (IntersectionObserver) sections render into the shot instead of capturing blank.')
239
241
  .option('-o, --output <file>', 'Output path (single viewport only; default .gipity/screenshots/ss-<host>-<timestamp>.png)')
240
242
  .option('--no-reload-between', 'Skip reload between viewports (faster, lower fidelity - only safe for static pages)')
@@ -264,8 +266,27 @@ export const pageScreenshotCommand = new Command('screenshot')
264
266
  if (aliasFlag) {
265
267
  console.error(muted(`Note: treating ${aliasFlag} as --action — it runs your JS in the page before the capture. --action is the canonical flag.`));
266
268
  }
267
- const actionScript = [opts.action, ...ACTION_ALIAS_FLAGS.map((f) => opts[f.slice(2)])]
269
+ // The pre-capture script can come inline (--action / its JS-intent aliases) or
270
+ // from a file/stdin (--file, the same script-passing ergonomics as page eval).
271
+ // They are mutually exclusive: one page load runs one pre-capture body, so a
272
+ // caller that passed both meant one of them — say so rather than silently
273
+ // concatenating a driver script onto a stray click.
274
+ const inlineAction = [opts.action, ...ACTION_ALIAS_FLAGS.map((f) => opts[f.slice(2)])]
268
275
  .filter(Boolean).join('\n');
276
+ if (opts.file && inlineAction) {
277
+ throw new Error('Pass either --file <path> or an inline --action script, not both');
278
+ }
279
+ let actionScript = inlineAction;
280
+ if (opts.file) {
281
+ try {
282
+ actionScript = readScriptFile(opts.file);
283
+ }
284
+ catch {
285
+ throw new Error(opts.file === '-'
286
+ ? "--file - reads the pre-capture script from stdin, but stdin was empty/unreadable — pipe it in, e.g. gipity page screenshot \"<url>\" --file - <<'EOF' … EOF"
287
+ : `Cannot read file: ${opts.file}`);
288
+ }
289
+ }
269
290
  // --wait is the canonical name (it is what `page inspect`/`page eval` call it);
270
291
  // --post-load-delay stays as a hidden alias. Whether the caller named EITHER is
271
292
  // what decides the defaults below, so keep the raw "was it set" signal.
@@ -546,9 +567,17 @@ Waiting for the page to reach a state before the shot?
546
567
 
547
568
  Capturing a state that needs an interaction (start a game, open a menu, dismiss a modal)?
548
569
  Use --action to run JS in the page before the shot — it fires after the wait
549
- (and after any --wait-for gate), then settles again so the result has painted. Do
550
- NOT hand-roll a 'page eval' that returns a base64 image: the eval result is capped
551
- (~16KB) and truncates the PNG.
570
+ (and after any --wait-for gate), then settles again so the result has painted. For
571
+ a multi-step driver (click, wait, click) pipe it as a heredoc with --file - (same
572
+ as 'page eval') instead of cramming it into one inline string:
573
+ gipity page screenshot "https://dev.gipity.ai/me/app/" --file - <<'EOF'
574
+ document.getElementById('load-sample').click();
575
+ await new Promise(r => setTimeout(r, 500));
576
+ document.getElementById('run').click();
577
+ await new Promise(r => setTimeout(r, 2000));
578
+ EOF
579
+ Do NOT hand-roll a 'page eval' that returns a base64 image: the eval result is
580
+ capped (~16KB) and truncates the PNG.
552
581
 
553
582
  Capturing an off-screen region or reading element data?
554
583
  • --full captures the ENTIRE scrollable page (then crop to the region).
@@ -9,7 +9,7 @@ import { confirm } from '../utils.js';
9
9
  // Records API is the only records surface that exists server-side; there is no
10
10
  // /projects/<guid>/records mirror.)
11
11
  export const recordsCommand = new Command('records')
12
- .description('Manage records-kit data (registry-driven CRUD)');
12
+ .description('Manage app records (Gipity Records - validated CRUD with audit history)');
13
13
  recordsCommand
14
14
  .command('list')
15
15
  .description('List record tables')
@@ -91,6 +91,19 @@ recordsCommand
91
91
  const res = await get(`/api/${config.projectGuid}/records/${table}/${id}`);
92
92
  console.log(opts.json ? JSON.stringify(res.data) : JSON.stringify(res.data, null, 2));
93
93
  }));
94
+ recordsCommand
95
+ .command('history <table> <id>')
96
+ .description('Audit history for a record (who/what changed it, with English summaries)')
97
+ .option('--limit <n>', 'Max events', '20')
98
+ .option('--json', 'Output as JSON')
99
+ .action((table, id, opts) => run('History', async () => {
100
+ const config = requireConfig();
101
+ const res = await get(`/api/${config.projectGuid}/records/${table}/${id}/history?limit=${encodeURIComponent(opts.limit)}`);
102
+ printList(res.data, opts, 'No history for this record.', e => {
103
+ const summary = e.detail?.summary || `${e.action} ${e.entity_type} ${e.entity_id}`;
104
+ return `${muted(e.created_at)} ${bold(e.source || '-')} ${summary}`;
105
+ });
106
+ }));
94
107
  recordsCommand
95
108
  .command('create <table>')
96
109
  .description('Create a record')
@@ -6,7 +6,7 @@ import { resolveProjectContext, getConfigPath, getProjectRoot, shouldIgnore } fr
6
6
  import { SCRATCH_IGNORE } from '../setup.js';
7
7
  import { sync } from '../sync.js';
8
8
  import { error as clrError, dim } from '../colors.js';
9
- import { run } from '../helpers/index.js';
9
+ import { run, parseDuration } from '../helpers/index.js';
10
10
  import { createProgressReporter, withSpinner } from '../progress.js';
11
11
  const LANG_MAP = {
12
12
  js: 'javascript',
@@ -144,6 +144,77 @@ function explainSplitArgs(args) {
144
144
  lines.push('', 'Fix, in order of preference:', dim(' 1. Put the code in a file (best for anything with quotes, $(...), or newlines):'), ' gipity sandbox run --file script.sh', dim(' 2. Use the interpreter shorthand on a file:'), ' gipity sandbox run bash script.sh', dim(' 3. Keep it inline, but as ONE argument your shell will not split:'), " gipity sandbox run --language bash 'echo hi'");
145
145
  return lines.join('\n');
146
146
  }
147
+ // Regex fragments for the scratch directory names, derived from SCRATCH_IGNORE
148
+ // so this can never drift from what sync actually ignores. Each pattern's
149
+ // trailing '/' is dropped, dots are escaped, and a leading-`*` glob (`*_tmp/`)
150
+ // becomes a filename-char run so `frames_tmp/` is matched too. `shouldIgnore`
151
+ // is the authoritative filter downstream, so a slightly broad candidate here is
152
+ // harmless — it just gets dropped if sync wouldn't actually ignore it.
153
+ const SCRATCH_DIR_PATTERNS = SCRATCH_IGNORE
154
+ .map((p) => p.replace(/\/$/, ''))
155
+ .filter(Boolean)
156
+ .map((p) => p.replace(/[.]/g, '\\.').replace(/\*/g, '[A-Za-z0-9._-]*'));
157
+ /**
158
+ * References to a scratch directory inside a code body — the files the sandbox's
159
+ * auto-mirror will NOT carry, because sync ignores the scratch namespaces. Two
160
+ * shapes of the same trap are matched:
161
+ * - project-relative `tmp/nimbus.pdf` (how you name a root file locally), and
162
+ * - mirror-absolute `/work/tmp/nimbus.pdf` (how you name that same file inside
163
+ * the container, where the auto-mirror lands the project under /work/).
164
+ * Both are returned project-relative (the `/work/` prefix stripped) so the caller
165
+ * can resolve them on local disk and print a clean path. Deliberately NOT matched:
166
+ * `/tmp/out` (the container's own writable /tmp is fine) nor `mytmp/x` (word
167
+ * boundary). Covers every SCRATCH_IGNORE namespace, including the `*_tmp/` glob.
168
+ * Returns the distinct referenced paths. Exported for tests.
169
+ */
170
+ export function scratchRefsInCode(code) {
171
+ const dirs = SCRATCH_DIR_PATTERNS.join('|');
172
+ if (!dirs)
173
+ return [];
174
+ // Match either a `/work/`-prefixed reference or a bare one that is not preceded
175
+ // by a slash (absolute/nested path) or a word/dot char (mytmp/, a.tmp/).
176
+ const re = new RegExp(`(?:\\/work\\/|(?<![\\w/.]))(?:${dirs})\\/[A-Za-z0-9._\\-\\/]+`, 'g');
177
+ return [...new Set((code.match(re) ?? []).map((m) => m.replace(/^\/work\//, '')))];
178
+ }
179
+ /**
180
+ * True when the code WRITES `p` at its FIRST use, so the file is produced inside
181
+ * the sandbox and any later reference (an `ls`/`cat` to inspect it, a second
182
+ * pass over it) reads that in-sandbox copy rather than an unmirrored input. Two
183
+ * output shapes are recognized, judged at the path's first occurrence:
184
+ * - a flagged/redirected output: `-o p`, `-of p`, `--output p`, `> p`, `>> p`, `tee p`;
185
+ * - an ffmpeg trailing-positional output: ffmpeg names its output as the last
186
+ * positional (NO `-o` flag), so a path in an ffmpeg segment that is not the
187
+ * `-i`/`--input` argument is an output.
188
+ * This keeps the write-then-inspect pattern in one script working (ffmpeg writes
189
+ * tmp/clip.mp4, then `ls tmp/clip.mp4`), while still catching a genuine staged
190
+ * INPUT read whose first use is not a write (`cat tmp/x`, `pandoc tmp/x.md ...`,
191
+ * `open('tmp/x')`, `ffmpeg -i tmp/x ...`). It also lets the normal iterate loop
192
+ * through: a scratch OUTPUT lands locally after the first run, so on a re-run it
193
+ * exists, but its first reference is still the write.
194
+ */
195
+ export function isWriteTarget(code, p) {
196
+ const path = p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
197
+ const m = new RegExp(`(?:\\/work\\/)?${path}(?![\\w./-])`).exec(code);
198
+ if (!m)
199
+ return false;
200
+ const idx = m.index;
201
+ const before = code.slice(Math.max(0, idx - 40), idx);
202
+ // Flagged / redirected output position immediately before the path.
203
+ if (/(?:^|[\s'"`;(])(?:-o|-of|--out(?:put)?[= ]|>>?|tee(?:\s+-a)?)\s*['"`]?(?:\/work\/)?$/.test(before))
204
+ return true;
205
+ // ffmpeg's output is a trailing positional (no `-o`): a path in an ffmpeg
206
+ // segment that isn't the `-i`/`--input` argument is an output.
207
+ const segStart = Math.max(code.lastIndexOf('&&', idx), code.lastIndexOf('||', idx), code.lastIndexOf(';', idx), code.lastIndexOf('|', idx), code.lastIndexOf('\n', idx));
208
+ // segStart points at the FIRST char of the separator; a two-char operator
209
+ // (`&&`/`||`) leaves its second char in the slice, so strip any leading
210
+ // operator/space chars before reading the command word. Without this, the
211
+ // canonical `mkdir -p tmp && ffmpeg -i a.png tmp/out.mp4` idiom left a stray
212
+ // `&` that defeated the command extractor and false-blocked the re-run.
213
+ const seg = code.slice(segStart + 1, idx).replace(/^[\s&|;]+/, '');
214
+ const cmd = /^\s*([A-Za-z0-9_./-]+)/.exec(seg)?.[1]?.split('/').pop()?.toLowerCase();
215
+ const afterInputFlag = /(?:^|[\s'"`])(?:-i|--input)[= ]\s*['"`]?$/.test(before);
216
+ return cmd === 'ffmpeg' && !afterInputFlag;
217
+ }
147
218
  /** Project-relative path from the process cwd, or undefined when there's
148
219
  * no local config (one-off mode) or the cwd is at/above the project root. */
149
220
  function resolveRelativeCwd() {
@@ -171,7 +242,16 @@ sandboxCommand
171
242
  // positional arg is the canonical spelling). Accept it as a working alias
172
243
  // rather than bouncing the guess into an unknown-option --help detour.
173
244
  .addOption(new Option('--code <code>', 'Alias for the positional inline <code> arg').hideHelp())
174
- .option('--timeout <seconds>', 'Execution timeout in seconds', '30')
245
+ // `bash -c '<code>'` and `node -e '<code>'` are the universal ways to run an
246
+ // inline command, and an agent reaches for them by reflex - but the bare
247
+ // `-c` / `-e` used to hit commander's OWN parser first ("unknown option
248
+ // '-c'"), before the interpreter-token logic below ever ran. Declaring them
249
+ // as hidden inline-code aliases makes the idiom just work: `-c`/`--cmd`
250
+ // implies bash, `-e`/`--eval` implies node, and a leading `bash`/`python`/
251
+ // `node` token still pins the language (so `python -c '...'` runs as python).
252
+ .addOption(new Option('-c, --cmd <code>', 'Inline code (bash idiom: `sandbox run bash -c "<code>"`)').hideHelp())
253
+ .addOption(new Option('-e, --eval <code>', 'Inline code (node idiom: `sandbox run node -e "<code>"`)').hideHelp())
254
+ .option('--timeout <seconds>', 'Execution timeout. Bare number = seconds; or pass an explicit unit that means the same on both this and `page eval --timeout`, e.g. --timeout 90s.', '30')
175
255
  .option('--input <path>', 'Narrow to specific project files instead of auto-mirroring the whole tree (repeatable). Use this only for >1 GB projects or when you want surgical control.', (v, prev) => [...(prev ?? []), v])
176
256
  // Named "discard", not "no-sync": in Gipity vocabulary "sync" means the
177
257
  // local<->cloud file sync, so a no-sync spelling reads as "give me the file
@@ -214,6 +294,8 @@ Examples:
214
294
  $ gipity sandbox run --file build_report.py
215
295
  $ gipity sandbox run python build_report.py # same thing, interpreter shorthand
216
296
  $ gipity sandbox run bash "echo hi; ffmpeg -version" # inline, language pinned
297
+ $ gipity sandbox run bash -c "ffmpeg -version" # the bash -c idiom also works
298
+ $ gipity sandbox run node -e "console.log(process.version)" # and node -e
217
299
 
218
300
  # Surgical: only these files are mirrored in
219
301
  $ gipity sandbox run --language bash \\
@@ -242,13 +324,26 @@ GCC/Rust).
242
324
  let inlineCode;
243
325
  let filePath = opts.file;
244
326
  let langFromInterp;
245
- // --code alias: fold it into the positional slot before the shape checks.
246
- if (opts.code !== undefined) {
247
- if (args.length) {
248
- console.error(clrError('Pass the code once: either positionally or via --code, not both'));
327
+ // Inline code may arrive via a flag alias instead of the positional slot:
328
+ // --code language-agnostic (needs --language or an interpreter token)
329
+ // --cmd / -c the `bash -c '<code>'` idiom → implies bash
330
+ // --eval / -e the `node -e '<code>'` idiom implies node
331
+ // Fold whichever was given into the positional slot before the shape checks,
332
+ // preserving a leading interpreter token (`python -c '...'` stays python).
333
+ const flagCode = opts.code ?? opts.cmd ?? opts.eval;
334
+ if (flagCode !== undefined) {
335
+ // The positional slot may legitimately hold ONLY a leading interpreter
336
+ // token that pins the language; anything more is a duplicate copy of the code.
337
+ const leadIsInterp = args.length === 1 && INTERPRETERS[args[0].toLowerCase()] !== undefined;
338
+ if (args.length > (leadIsInterp ? 1 : 0)) {
339
+ console.error(clrError('Pass the code once: either positionally or via --code/--cmd/-c/--eval/-e, not both'));
249
340
  process.exit(1);
250
341
  }
251
- args = [opts.code];
342
+ const interp = leadIsInterp ? args[0]
343
+ : opts.cmd !== undefined ? 'bash'
344
+ : opts.eval !== undefined ? 'node'
345
+ : undefined;
346
+ args = interp ? [interp, flagCode] : [flagCode];
252
347
  }
253
348
  if (args.length >= 2 && INTERPRETERS[args[0].toLowerCase()] !== undefined) {
254
349
  langFromInterp = INTERPRETERS[args[0].toLowerCase()];
@@ -267,7 +362,9 @@ GCC/Rust).
267
362
  process.exit(1);
268
363
  }
269
364
  if (inlineCode !== undefined && filePath) {
270
- console.error(clrError('Pass either an inline <code> arg or --file <path>, not both'));
365
+ console.error(clrError('Pass the code ONE way, not both: an inline <code> arg OR --file <path>.'));
366
+ console.error(dim(" inline: gipity sandbox run bash 'echo hi'"));
367
+ console.error(dim(' file: gipity sandbox run --file script.sh'));
271
368
  process.exit(1);
272
369
  }
273
370
  if (inlineCode === undefined && !filePath) {
@@ -299,7 +396,10 @@ GCC/Rust).
299
396
  }
300
397
  // Args are good - now it's worth resolving (and announcing) the project.
301
398
  const { config } = await resolveProjectContext();
302
- const timeout = parseInt(opts.timeout, 10);
399
+ // A bare number is native SECONDS; an explicit suffix (90s / 1500ms / 2m) is
400
+ // portable with `page eval --timeout` and converted to seconds here.
401
+ const durOpt = parseDuration(opts.timeout, 's');
402
+ const timeout = durOpt ? Math.max(1, Math.round(durOpt.value)) : parseInt(opts.timeout, 10);
303
403
  const cwd = resolveRelativeCwd();
304
404
  // A scratch path is never synced, so it can never reach the VFS the sandbox
305
405
  // mirrors from - `--input tmp/frame.png` would fail inside the container with
@@ -311,6 +411,31 @@ GCC/Rust).
311
411
  console.error(dim(' Stage inputs at a real project path (src/, docs/, assets/) and delete them afterward.'));
312
412
  process.exit(1);
313
413
  }
414
+ // Same trap, one step less obvious: a scratch file referenced as an INPUT
415
+ // from inside the code body (not via --input). The auto-mirror skips the
416
+ // scratch namespaces, so the container hits a bare "No such file" for a path
417
+ // the caller can plainly see on local disk. Catch it here, pre-sync, with the
418
+ // reason. We flag a reference only when that scratch file ACTUALLY EXISTS
419
+ // locally — which cleanly separates a staged input (must exist to be read)
420
+ // from a scratch OUTPUT target like `tmp/preview.png` (valid; doesn't exist
421
+ // yet, and outputs written under tmp/ come back on their own).
422
+ if (source) {
423
+ const scratchReads = scratchRefsInCode(source).filter((p) => shouldIgnore(p, SCRATCH_IGNORE)
424
+ && existsSync(resolve(process.cwd(), p))
425
+ // A previous run's OUTPUT lands locally, so on a re-run it exists -
426
+ // but a path written at its FIRST use (ffmpeg output, -o, >) is
427
+ // produced in the sandbox, so a later inspect (`ls`/`cat`) in the same
428
+ // script reads that copy, not the mirror. Only a genuine staged input
429
+ // read (first use isn't a write) is blocked.
430
+ && !isWriteTarget(source, p));
431
+ if (scratchReads.length) {
432
+ console.error(clrError(`Scratch files are never mirrored into the sandbox, so it can't read: ${scratchReads.join(', ')}`));
433
+ console.error(dim(` ${SCRATCH_IGNORE.join(', ')} are ignored by sync, so the sandbox never sees them.`));
434
+ console.error(dim(' Stage the input at a real project path (src/, docs/, assets/) and delete it afterward.'));
435
+ console.error(dim(' (Writing OUTPUT under tmp/ is fine — scratch outputs come back to your local tmp/.)'));
436
+ process.exit(1);
437
+ }
438
+ }
314
439
  // Push local working-tree changes up before executing. The sandbox mirrors
315
440
  // the *server* (VFS), not the local cwd, so any input staged outside Claude's
316
441
  // Write/Edit auto-push hook - a Bash `cp`/`ffmpeg`/redirect, or any external
@@ -407,6 +532,18 @@ GCC/Rust).
407
532
  if (pulledLocal)
408
533
  console.log(dim("Not pulled locally - run 'gipity sync' to fetch them."));
409
534
  }
535
+ // A sandbox output that lands directly at the project ROOT (a bare
536
+ // filename, no directory) is almost always a throwaway probe, not a
537
+ // real asset - real assets live under src/, functions/, assets/, etc.
538
+ // Root files sync and are picked up by the deploy `files` phase, so a
539
+ // stray one ships unless the caller remembers to delete it. Point at
540
+ // tmp/ (local-only, never synced or deployed) at the moment of use so
541
+ // the next probe never has to be hand-cleaned off a deployable path.
542
+ const rootStrays = [...onDisk, ...notOnDisk].filter((f) => !f.includes('/'));
543
+ if (rootStrays.length > 0) {
544
+ console.log(dim(`\nNote: ${rootStrays.join(', ')} landed at the project root and will deploy with the files phase.`));
545
+ console.log(dim('For a throwaway probe or fixture, write it under tmp/ instead - scratch outputs stay on local disk and never sync or deploy.'));
546
+ }
410
547
  }
411
548
  if (scratchWritten.length > 0) {
412
549
  console.log('\nScratch outputs (local only - never synced or deployed):');
@@ -418,7 +555,42 @@ GCC/Rust).
418
555
  for (const f of res.data.skippedOutputFiles)
419
556
  console.log(dim(`${f}`));
420
557
  }
558
+ // Silent-failure guard: a command that exits 0 yet leaves a 0-byte output
559
+ // almost always failed silently - a broken ffmpeg filter, or an OOM the
560
+ // shell swallowed. Without this the only signal is an `ls -la` afterward,
561
+ // and the agent burns a turn inferring "the encode failed" from a file
562
+ // size. Name it at the moment it happens, for scratch (bytes returned
563
+ // inline) and synced outputs (stat on local disk) alike.
564
+ if (res.data.exitCode === 0) {
565
+ const emptyScratch = (res.data.scratchFiles ?? [])
566
+ .filter((f) => f.contentBase64.length === 0)
567
+ .map((f) => f.path.replace(/\\/g, '/'));
568
+ const root = getProjectRoot();
569
+ const emptyOutputs = pulledLocal && root
570
+ ? (res.data.outputFiles ?? []).filter((f) => {
571
+ try {
572
+ return statSync(resolve(root, f)).size === 0;
573
+ }
574
+ catch {
575
+ return false;
576
+ }
577
+ })
578
+ : [];
579
+ const empties = [...new Set([...emptyScratch, ...emptyOutputs])];
580
+ if (empties.length > 0) {
581
+ console.error(dim(`Note: ${empties.join(', ')} was written but is empty (0 bytes) - the command exited 0 but likely failed silently (a broken filter, or an out-of-memory kill the shell swallowed). Re-check the command, or shrink the job if it may have run out of memory.`));
582
+ }
583
+ }
421
584
  if (res.data.exitCode !== 0) {
585
+ // A process killed by a signal exits 128+signal. 137 (SIGKILL) on the
586
+ // sandbox is almost always the OS OOM-killer - the raw `/work/_run.sh:
587
+ // Killed` line the shell prints never says so, so a bare retry at the
588
+ // same size just OOMs again. Name it and point at the usual fix.
589
+ if (res.data.exitCode === 137) {
590
+ console.error(clrError('The sandbox process was killed (exit 137) - it ran out of memory.'));
591
+ console.error(dim(' Shrink the job: for ffmpeg, downscale (-vf scale=640:-2) and add -preset ultrafast;'));
592
+ console.error(dim(' for ImageMagick, lower -density/-resize. Process in smaller chunks if it still OOMs.'));
593
+ }
422
594
  // No "did you mean another language?" hint is needed: the language is now
423
595
  // always something the caller pinned, never a silent default we chose.
424
596
  process.exit(res.data.exitCode);
@@ -25,8 +25,13 @@ export const serviceCommand = new Command('service')
25
25
  serviceCommand
26
26
  .command('list')
27
27
  .description('List callable app services')
28
- .action(() => run('Services', async () => {
28
+ .option('--json', 'Output the service list as JSON')
29
+ .action((opts) => run('Services', async () => {
29
30
  requireConfig();
31
+ if (opts.json) {
32
+ console.log(JSON.stringify(SERVICES));
33
+ return;
34
+ }
30
35
  const width = SERVICES.reduce((m, s) => Math.max(m, s.name.length), 0);
31
36
  console.log('Call one with `gipity service call <name> \'{"json":"body"}\'`');
32
37
  console.log(muted('(GET endpoints like llm/models, tts/voices take --get and no body)'));