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.
- package/dist/catalog.js +1 -3
- package/dist/commands/build.js +99 -29
- package/dist/commands/deploy.js +6 -2
- package/dist/commands/file.js +18 -0
- package/dist/commands/fn.js +6 -6
- package/dist/commands/generate.js +76 -12
- package/dist/commands/job.js +125 -8
- package/dist/commands/page-eval.js +79 -10
- package/dist/commands/page-inspect.js +26 -1
- package/dist/commands/page-screenshot.js +33 -4
- package/dist/commands/records.js +14 -1
- package/dist/commands/sandbox.js +181 -9
- package/dist/commands/service.js +6 -1
- package/dist/commands/test.js +32 -0
- package/dist/commands/update.js +4 -0
- package/dist/commands/upload.js +69 -121
- package/dist/helpers/body.js +117 -0
- package/dist/helpers/duration.js +34 -0
- package/dist/helpers/index.js +2 -0
- package/dist/index.js +848 -375
- package/dist/knowledge.js +4 -7
- package/dist/updater/bootstrap.js +27 -16
- package/dist/updater/check.js +86 -10
- package/dist/updater/install.js +82 -0
- package/dist/updater/shim.js +90 -27
- package/package.json +2 -2
|
@@ -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(
|
|
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
|
|
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 =
|
|
425
|
+
expr = readScriptFile(opts.file);
|
|
413
426
|
}
|
|
414
427
|
catch {
|
|
415
|
-
pageEvalCommand.error(
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
550
|
-
|
|
551
|
-
|
|
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).
|
package/dist/commands/records.js
CHANGED
|
@@ -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
|
|
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')
|
package/dist/commands/sandbox.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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);
|
package/dist/commands/service.js
CHANGED
|
@@ -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
|
-
.
|
|
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)'));
|