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
@@ -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,
@@ -234,7 +235,8 @@ export const pageScreenshotCommand = new Command('screenshot')
234
235
  .option('--wait <ms>', `Sleep this many ms after DOMContentLoaded before capturing (default 1000, max ${MAX_POST_LOAD_DELAY_MS}). With --camera it defaults to ${CAMERA_DEFAULT_DELAY_MS} so the vision model is up. Same flag as page eval/inspect.`)
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
- .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.')
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.
@@ -351,7 +372,7 @@ export const pageScreenshotCommand = new Command('screenshot')
351
372
  assertCameraFile(opts.camera);
352
373
  if (!projectGuid)
353
374
  throw new Error('--camera needs a linked project (the frame is hosted for the browser to fetch) — run `gipity link` first.');
354
- console.log(muted(`Hosting camera feed ${opts.camera}…`));
375
+ console.log(muted(`Hosting camera feed ${opts.camera}...`));
355
376
  camera = await uploadCameraFeed(projectGuid, opts.camera);
356
377
  }
357
378
  // The pre-capture script the server runs after the post-load delay: the
@@ -386,7 +407,7 @@ export const pageScreenshotCommand = new Command('screenshot')
386
407
  try {
387
408
  entries = opts.json
388
409
  ? await doShoot()
389
- : await withSpinner('Capturing', doShoot, { done: null });
410
+ : await withSpinner('Capturing...', doShoot, { done: null });
390
411
  }
391
412
  catch (err) {
392
413
  // The sandbox budget covers the WHOLE capture — load, --action, settle,
@@ -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).
@@ -268,7 +268,7 @@ export const pageTestCommand = new Command('test')
268
268
  // Interactive mode (--observe drives it):
269
269
  .option('--observe <expr>', 'Interactive: JS expression sampled in each client to read shared state (e.g. presence count). Switches on interactive mode.')
270
270
  .option('--action <expr>', 'Interactive: one-time JS run in each client before observing (e.g. fill a name + submit). {{label}}/{{i}} are substituted per client.')
271
- .option('--labels <csv>', 'Per-client labels substituted for {{label}} in the URL/--action/--observe (default client-0, client-1, )')
271
+ .option('--labels <csv>', 'Per-client labels substituted for {{label}} in the URL/--action/--observe (default client-0, client-1, ...)')
272
272
  .option('--hold <ms>', `Interactive: total observe window per client (${MIN_HOLD_MS}-${MAX_HOLD_MS}ms)`, '8000')
273
273
  .option('--samples <k>', 'Interactive: number of observations across the hold window (2-30)', '6')
274
274
  .option('--wait-for <selector>', 'Interactive: wait for this CSS selector before running --action (deterministic readiness gate)')
@@ -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')
@@ -22,7 +22,7 @@ export const removeCommand = new Command('remove')
22
22
  const doRemove = () => post(`/projects/${config.projectGuid}/remove`, { name: kit });
23
23
  const res = opts.json
24
24
  ? await doRemove()
25
- : await withSpinner('Removing', doRemove, { done: null });
25
+ : await withSpinner('Removing...', doRemove, { done: null });
26
26
  const data = res.data;
27
27
  // Pull the kit's deletions locally. Whitelist ONLY the kit's own removed files
28
28
  // so they bypass the bulk-delete guard (the removal is explicit), while any
@@ -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
@@ -339,7 +464,7 @@ GCC/Rust).
339
464
  });
340
465
  const res = opts.json
341
466
  ? await doRun()
342
- : await withSpinner('Running in sandbox', doRun, { done: null });
467
+ : await withSpinner('Running in sandbox...', doRun, { done: null });
343
468
  // Pull sandbox-written outputs down to the local cwd automatically. The
344
469
  // server has already mirrored them into the project (VFS) and handed back
345
470
  // the exact list, so honoring it here means files land locally without a
@@ -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);
@@ -52,7 +52,7 @@ export const saveCommand = new Command('save')
52
52
  const doExport = () => downloadWithHeaders(`/projects/${config.projectGuid}/export`);
53
53
  const { buffer, headers } = opts.json
54
54
  ? await doExport()
55
- : await withSpinner('Exporting', doExport, { done: null });
55
+ : await withSpinner('Exporting...', doExport, { done: null });
56
56
  const filename = dispositionFilename(headers) ?? `${config.projectSlug}.gip`;
57
57
  let dest = path.resolve(output ?? filename);
58
58
  if (output && fs.existsSync(dest) && fs.statSync(dest).isDirectory()) {
@@ -34,7 +34,7 @@ secretsCommand
34
34
  }
35
35
  console.log(bold(`${res.data.length} ${scope} secret${res.data.length === 1 ? '' : 's'}:`));
36
36
  for (const s of res.data) {
37
- const masked = s.preview ? muted(`…${s.preview}`) : muted('(hidden)');
37
+ const masked = s.preview ? muted(`...${s.preview}`) : muted('(hidden)');
38
38
  console.log(` ${s.name} ${masked} ${muted(`updated ${new Date(s.updated_at).toLocaleDateString()}`)}`);
39
39
  }
40
40
  }));
@@ -21,12 +21,17 @@ const SERVICES = [
21
21
  ];
22
22
  export const serviceCommand = new Command('service')
23
23
  .description('Call an app service (llm, tts, image, transcribe, ...)')
24
- .addHelpText('after', '\nCall a Gipity app service: LLM, image, music, TTS, and more.');
24
+ .addHelpText('after', '\nCall a Gipity app service: LLM, image, music, TTS, and more.\nPer-service docs: gipity skill read app-llm | app-tts | app-image | app-video | app-audio (transcribe, sound, music) | app-location');
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)'));
@@ -19,8 +19,7 @@ import { Command } from 'commander';
19
19
  import { getAuth, sessionExpired, refreshTokenIfNeeded } from '../auth.js';
20
20
  import { interactiveLogin } from '../login-flow.js';
21
21
  import { runRelaySetup } from '../relay/onboarding.js';
22
- import * as relayState from '../relay/state.js';
23
- import { bold, brand, success, muted, error as clrError } from '../colors.js';
22
+ import { bold, muted, error as clrError } from '../colors.js';
24
23
  export const connectCommand = new Command('connect')
25
24
  .alias('setup') // legacy name, kept working but not advertised
26
25
  .description('Connect this computer to gipity.ai so the web CLI can drive it (no project, no launch)')
@@ -46,11 +45,10 @@ export const connectCommand = new Command('connect')
46
45
  console.log('');
47
46
  // ── Step 2: Relay setup (always run — the user asked for it) ───────
48
47
  const enabled = await runRelaySetup({ mode: 'run-now' });
49
- // ── Step 3: Done. No project, no Claude Code launch. ──────────────
48
+ // ── Step 3: Done. No project, no Claude Code launch. The shared flow
49
+ // already printed its own "Done!" line - just add the manage hint. ──
50
50
  if (enabled) {
51
- const running = relayState.isRelayEnabled() && !relayState.isPaused();
52
- console.log(` ${success('Done')} — your relay ${running ? 'is running in the background' : 'is set up'} and will start with your computer.`);
53
- console.log(` ${muted('Open')} ${brand('gipity.ai')} ${muted('and start a chat to drive your coding agent here. Manage it with `gipity relay status`.')}`);
51
+ console.log(` ${muted('Manage it anytime with `gipity relay status`.')}`);
54
52
  }
55
53
  else {
56
54
  console.log(` ${muted('No relay set up. Run `gipity connect` again anytime, or `gipity build` to start building.')}`);
@@ -4,7 +4,7 @@ import { join, resolve } from 'path';
4
4
  import { homedir } from 'os';
5
5
  import { getAuth, sessionExpired } from '../auth.js';
6
6
  import { get, usingEnvToken, ApiError } from '../api.js';
7
- import { getConfig, liveUrl } from '../config.js';
7
+ import { getConfig, liveUrl, resolveApiBase } from '../config.js';
8
8
  import { brand, success, warning, muted, error as clrError } from '../colors.js';
9
9
  import { GIPITY_PLUGIN_ID, GIPITY_MARKETPLACE_NAME, setupClaudeHooks, ensureGipityPlugin, ensureGipityPluginInstalled, userScopeInstallState } from '../setup.js';
10
10
  import { flushBugQueue } from '../bug-queue.js';
@@ -39,15 +39,20 @@ function checkGipityPlugin() {
39
39
  const stale = missing.length === 1 && missing[0] === 'install' && install.exists;
40
40
  return { missing, ok: missing.length === 0, stale };
41
41
  }
42
+ /** `account` is the server's `account_slug` for the authenticated identity
43
+ * (only populated on 'ok' - the /users/me call that proved the probe also
44
+ * returns it) - used for the ownership cross-check against the locally
45
+ * cached project (bug cli#S2: a wrong-account session used to read as
46
+ * fully healthy until a later command 404'd). */
42
47
  async function probeAuth(loggedIn) {
43
48
  if (!loggedIn && !usingEnvToken())
44
- return 'none';
49
+ return { state: 'none', account: null };
45
50
  if (!usingEnvToken() && sessionExpired())
46
- return 'expired';
51
+ return { state: 'expired', account: null };
47
52
  // Cap the probe well below the API layer's 60s request timeout - status is
48
53
  // a diagnostic command and must answer fast even when the network is dark.
49
- const timeout = new Promise(res => setTimeout(() => res('unreachable'), 5000).unref?.());
50
- const call = get('/users/me').then(() => 'ok', (err) => (err instanceof ApiError && err.statusCode === 401 ? 'rejected' : 'unreachable'));
54
+ const timeout = new Promise(res => setTimeout(() => res({ state: 'unreachable', account: null }), 5000).unref?.());
55
+ const call = get('/users/me').then((res) => ({ state: 'ok', account: res.data?.accountSlug ?? null }), (err) => ({ state: (err instanceof ApiError && err.statusCode === 401 ? 'rejected' : 'unreachable'), account: null }));
51
56
  return Promise.race([call, timeout]);
52
57
  }
53
58
  // `whoami` is the name agents reach for first when they want the signed-in
@@ -71,6 +76,12 @@ export const statusCommand = new Command('status')
71
76
  // is empty, which is the common case, so this stays a no-op most runs.
72
77
  const queueDelivered = (auth && !sessionExpired()) ? await flushBugQueue().catch(() => 0) : 0;
73
78
  const probe = await probeAuth(!!auth);
79
+ // RBAC lets a project be shared to a collaborator whose own account
80
+ // legitimately differs from the project owner's - so this is advisory,
81
+ // never a hard error. Only meaningful once a live 'ok' call has actually
82
+ // returned an account to compare against config.accountSlug.
83
+ const accountMismatch = probe.state === 'ok' && !!config && !!probe.account && probe.account !== config.accountSlug;
84
+ const apiBaseInUse = resolveApiBase();
74
85
  if (opts.json) {
75
86
  console.log(JSON.stringify({
76
87
  project: config ? {
@@ -78,17 +89,21 @@ export const statusCommand = new Command('status')
78
89
  slug: config.projectSlug,
79
90
  account: config.accountSlug,
80
91
  apiBase: config.apiBase,
92
+ apiBaseInUse,
81
93
  url: liveUrl(config),
82
94
  } : null,
83
95
  // `valid` reflects the refresh token (the real session) - access
84
96
  // tokens auto-renew, so their expiry must not read as "invalid".
85
97
  // `probe` is what one live call just proved: 'rejected' means every
86
98
  // authenticated command will fail even though `valid` reads true.
99
+ // 'mismatch' overrides 'ok' when the live account isn't the one that
100
+ // owns this project - `valid` still reads true (the token IS valid).
87
101
  auth: (auth || usingEnvToken()) ? {
88
102
  email: auth?.email,
103
+ account: probe.account,
89
104
  source: usingEnvToken() ? 'agent-token' : 'session',
90
- valid: usingEnvToken() ? probe !== 'rejected' : !sessionExpired(),
91
- probe,
105
+ valid: usingEnvToken() ? probe.state !== 'rejected' : !sessionExpired(),
106
+ probe: accountMismatch ? 'mismatch' : probe.state,
92
107
  } : null,
93
108
  plugin: hookCheck,
94
109
  }, null, 2));
@@ -102,28 +117,42 @@ export const statusCommand = new Command('status')
102
117
  console.log(`${muted('Account:')} ${config.accountSlug}`);
103
118
  console.log(`${muted('Live:')} ${liveUrl(config)}`);
104
119
  console.log(`${muted('API:')} ${config.apiBase}`);
120
+ // apiBase is only what THIS project recorded - resolveApiBase() is what
121
+ // every real request actually uses (it can diverge via GIPITY_API_BASE,
122
+ // --api-base, or a disallowed host being dropped to the default). Surface
123
+ // the divergence rather than silently trusting the recorded value.
124
+ if (apiBaseInUse !== config.apiBase) {
125
+ console.log(`${muted('API (in use):')} ${warning(apiBaseInUse)} ${muted('(overrides .gipity.json — GIPITY_API_BASE / --api-base / allowlist)')}`);
126
+ }
105
127
  if (config.agentGuid)
106
128
  console.log(`${muted('Agent:')} ${config.agentGuid}`);
107
129
  }
108
130
  if (usingEnvToken()) {
109
- console.log(`${muted('Auth:')} ${probe === 'rejected'
131
+ console.log(`${muted('Auth:')} ${probe.state === 'rejected'
110
132
  ? warning('agent API token (GIPITY_TOKEN) rejected by the server — mint a new one: gipity skill read agent-deploy')
111
- : success('agent API token (GIPITY_TOKEN)')}${probe === 'unreachable' ? ` ${muted('(unverified — API unreachable)')}` : ''}`);
133
+ : success('agent API token (GIPITY_TOKEN)')}${probe.state === 'unreachable' ? ` ${muted('(unverified — API unreachable)')}` : ''}`);
112
134
  }
113
135
  else if (!auth) {
114
136
  console.log(`${muted('Auth:')} ${warning('not logged in. Run: gipity login')}`);
115
137
  }
116
- else if (probe === 'expired') {
138
+ else if (probe.state === 'expired') {
117
139
  console.log(`${muted('Auth:')} ${warning(`session expired for ${auth.email}. Run: gipity login (headless/CI: set GIPITY_TOKEN — gipity skill read agent-deploy)`)}`);
118
140
  }
119
- else if (probe === 'rejected') {
141
+ else if (probe.state === 'rejected') {
120
142
  // Locally fresh but the server says no (refresh token rotated away or
121
143
  // revoked). Without the live probe this printed a green identity while
122
144
  // every authenticated command failed.
123
145
  console.log(`${muted('Auth:')} ${warning(`session for ${auth.email} was rejected by the server. Run: gipity login (headless/CI: set GIPITY_TOKEN — gipity skill read agent-deploy)`)}`);
124
146
  }
125
147
  else {
126
- console.log(`${muted('Auth:')} ${success(auth.email)}${probe === 'unreachable' ? ` ${muted('(unverified — API unreachable)')}` : ''}`);
148
+ console.log(`${muted('Auth:')} ${success(auth.email)}${probe.state === 'unreachable' ? ` ${muted('(unverified — API unreachable)')}` : ''}`);
149
+ }
150
+ // Source-independent (session or GIPITY_TOKEN): a mismatch under an agent
151
+ // token is the same wrong-account class and must not be hidden inside the
152
+ // cascade above, which only special-cases 'rejected' for that source.
153
+ if (accountMismatch) {
154
+ console.log(`${muted('Account:')} ${warning(`logged-in account (${probe.account}) differs from this project's account (${config.accountSlug}). If you didn't expect this you may be logged into the wrong account — run: gipity login`)}`);
155
+ console.log(muted('(If this project was shared with you via gipity rbac, this is expected.)'));
127
156
  }
128
157
  if (queueDelivered > 0) {
129
158
  console.log(`${muted('Bug queue:')} ${success(`delivered ${queueDelivered} queued bug report${queueDelivered === 1 ? '' : 's'}`)}`);
@@ -50,12 +50,12 @@ function printUsage(d) {
50
50
  console.log(`${bold('By project')} ${muted(scope)}`);
51
51
  for (const p of d.projects) {
52
52
  const name = p.projectName ?? '(no project)';
53
- row(name.length > 16 ? `${name.slice(0, 15)}…` : name, formatBytes(p.liveBytes), `${p.liveFiles.toLocaleString()} files`);
53
+ row(name.length > 16 ? `${name.slice(0, 13)}...` : name, formatBytes(p.liveBytes), `${p.liveFiles.toLocaleString()} files`);
54
54
  }
55
55
  if (hidden > 0) {
56
56
  const shownBytes = d.projects.reduce((n, p) => n + p.liveBytes, 0);
57
57
  const shownFiles = d.projects.reduce((n, p) => n + p.liveFiles, 0);
58
- row(`…and ${hidden.toLocaleString()} more`, formatBytes(totals.liveBytes - shownBytes), `${(totals.liveFiles - shownFiles).toLocaleString()} files`);
58
+ row(`...and ${hidden.toLocaleString()} more`, formatBytes(totals.liveBytes - shownBytes), `${(totals.liveFiles - shownFiles).toLocaleString()} files`);
59
59
  }
60
60
  }
61
61
  const r = d.versionRetention;