gipity 1.1.5 → 1.1.7

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.
@@ -5,6 +5,7 @@ import { brand, bold, muted, warning, success } from '../colors.js';
5
5
  import { run, parseDuration } from '../helpers/index.js';
6
6
  import { getAuth } from '../auth.js';
7
7
  import { resolveProjectContext } from '../config.js';
8
+ import { createCheckpoint, restoreCheckpoint, resolveDatabase } from '../db-checkpoint.js';
8
9
  import { uploadPublicFixture, uploadCameraFeed, assertCameraFile, deleteFixture } from '../page-fixtures.js';
9
10
  // Shown when an eval runs cleanly but returns nothing serializable. Turns a
10
11
  // bare/opaque `null` into a deterministic, actionable nudge so the agent shapes
@@ -257,6 +258,31 @@ export function budgetOverrunHint(reason, usedBudgetMs) {
257
258
  `(up to ${EVAL_SCRIPT_BUDGET_MAX_MS}), e.g. --timeout ${EVAL_SCRIPT_BUDGET_MAX_MS} — ` +
258
259
  `or gate on a ready signal instead: --wait-for '<selector>' --wait-timeout ${MAX_WAIT_MS}.`;
259
260
  }
261
+ /** The server aborts a script whose page navigated out from under it, and tells
262
+ * the caller to "split the check into two evals". For the case that actually
263
+ * produces this (seed state, reload, assert the state came back), that advice
264
+ * sends the caller down the wrong road: two evals are two independent page
265
+ * sessions, so the second one has to re-seed before it can read anything, and
266
+ * the thing being verified (a real reload restoring real persisted state) is
267
+ * never exercised. `--reload` is the primitive for it: one page load, reloaded
268
+ * IN PLACE with storage preserved, second expression against the post-reload
269
+ * DOM. Name it here, at the exact moment the caller hand-rolled it. */
270
+ export function navigationAbortHint(reason) {
271
+ if (!/navigated or reloaded/i.test(reason))
272
+ return null;
273
+ return `If you were verifying that state SURVIVES a reload, that is what --reload is for (one command, one page): ` +
274
+ `gipity page eval "<url>" '<seed/assert expr>' --reload '<assert-restored expr>'. ` +
275
+ `It reloads the page in place (localStorage/sessionStorage/cookies preserved) and reports the second result ` +
276
+ `separately. Splitting into two 'page eval' calls does NOT do this: each call is its own browser session, so ` +
277
+ `the second one starts from empty storage and never exercises the restore path.`;
278
+ }
279
+ /** True when the submitted script already drives the app's clock itself
280
+ * (`core.advance(seconds)` on the 3D templates, the imported `advance(seconds)`
281
+ * on the 2D one). Such a script does not care what the renderer paints at, so
282
+ * the slow-render warning below has nothing to tell it. Exported for tests. */
283
+ export function stepsDeterministically(script) {
284
+ return /\badvance\s*\(/.test(script);
285
+ }
260
286
  /** The headless browser paints slowly, and what that MEANS depends on what the
261
287
  * page is doing — so the one-size warning was actively misleading on a --camera
262
288
  * run. A vision app has no loop to step: its pipeline is driven by camera
@@ -273,6 +299,8 @@ export function budgetOverrunHint(reason, usedBudgetMs) {
273
299
  * deterministic way to wait (--wait-for) instead of inviting a wall-clock
274
300
  * escalation that is guaranteed to be wasted. Exported for tests. */
275
301
  export function slowRenderMessage(fps, o) {
302
+ if (!o.camera && stepsDeterministically(o.script ?? ''))
303
+ return null;
276
304
  if (o.camera) {
277
305
  const frames = Math.max(1, Math.round(fps * (o.waitMs / 1000)));
278
306
  return `${warning('⚠ Slow render:')} page painted at ${fps} fps, so the app's vision pipeline ran on roughly `
@@ -382,7 +410,7 @@ export const pageEvalCommand = new Command('eval')
382
410
  .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).`)
383
411
  .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], [])
384
412
  .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], [])
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.')
413
+ .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 ("remember it when I come back"): seed/assert state with <expr>, then assert the restored UI here. This is the ONLY way to check that - reloading inside the body aborts the eval (the result is lost with the old page), and two separate `page eval` calls are two fresh browser profiles, so the second starts from empty storage.')
386
414
  .option('--reload-file <path>', 'Read the post-reload expression from a file instead of inline --reload (mutually exclusive)')
387
415
  .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".`)
388
416
  .option('--fake-media', 'Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so a voice/camera app runs headlessly instead of hitting its no-camera path. The feed is a built-in test pattern (and a tone) — nothing a vision model can recognize; to drive a vision app use --camera <path> instead.')
@@ -391,6 +419,7 @@ export const pageEvalCommand = new Command('eval')
391
419
  .option('--wait-timeout <ms>', `Max ms to wait for --wait-for before giving up (max ${WAIT_FOR_MAX_MS})`, '5000')
392
420
  .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.`)
393
421
  .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.')
422
+ .option('--restore-db', "Snapshot the app database before the script runs and roll it back after, so a write-path check (click Approve, submit the form, edit a row) leaves the real data untouched. Use this whenever the eval WRITES - it is the undo for driving the deployed app against real project data. Same snapshot/undo standalone: gipity db checkpoint / gipity db restore.")
394
423
  .option('--json', 'Output as JSON')
395
424
  .action((url, exprArg, opts) => run('Page eval', async () => {
396
425
  // A JS-intent flag guess (captured as a hidden decoy below): redirect to the
@@ -534,7 +563,24 @@ export const pageEvalCommand = new Command('eval')
534
563
  // instant error, not an upload plus an opaque browser-side failure.
535
564
  if (opts.camera)
536
565
  assertCameraFile(opts.camera);
566
+ // Set when --restore-db took a checkpoint; the finally below rolls the app
567
+ // database back to it, so a write-path smoke test leaves no residue in the
568
+ // real data the user is about to look at.
569
+ let restoreDb;
537
570
  try {
571
+ if (opts.restoreDb) {
572
+ const { config } = await resolveProjectContext({});
573
+ projectGuid = config.projectGuid;
574
+ const database = await resolveDatabase(config.projectGuid);
575
+ if (!database) {
576
+ console.error(muted('--restore-db: this project has no database - nothing to roll back.'));
577
+ }
578
+ else {
579
+ const cp = await createCheckpoint(config.projectGuid, database);
580
+ console.error(muted(`Checkpointed ${cp.tables.length} table(s), ${cp.rows} row(s) in '${database}' - rolled back when this eval finishes.`));
581
+ restoreDb = { projectGuid: config.projectGuid, database };
582
+ }
583
+ }
538
584
  if (fixturePaths.length || opts.camera) {
539
585
  const { config } = await resolveProjectContext({});
540
586
  projectGuid = config.projectGuid;
@@ -589,7 +635,10 @@ export const pageEvalCommand = new Command('eval')
589
635
  // raises it — and the flag is what the caller needs next. Name it, with
590
636
  // the value THIS run used, so the retry is an edit rather than a guess.
591
637
  const msg = err?.message ?? '';
592
- const hint = budgetOverrunHint(msg, scriptBudgetMs);
638
+ const hint = budgetOverrunHint(msg, scriptBudgetMs)
639
+ // A script aborted by its own reload is nearly always a persistence
640
+ // check hand-rolled without --reload; point at the flag that does it.
641
+ ?? (reloadExpr === undefined ? navigationAbortHint(msg) : null);
593
642
  if (!hint)
594
643
  throw err;
595
644
  throw new Error(`${msg}\n${hint}`);
@@ -632,8 +681,17 @@ export const pageEvalCommand = new Command('eval')
632
681
  // page's rAF loop is its clock, and engines cap the per-frame delta, so
633
682
  // `setTimeout(2000)` may advance only a fraction of a second of app time.
634
683
  // Without this line the eval returns a plausible-looking false negative.
684
+ // A script that already steps the loop itself is immune to all of that, so
685
+ // slowRenderMessage returns null for it rather than prescribing the fix it
686
+ // is already applying.
635
687
  if (d.slowRender) {
636
- console.log(slowRenderMessage(d.slowRender.fps, { camera: !!camera, waitMs }));
688
+ const slow = slowRenderMessage(d.slowRender.fps, {
689
+ camera: !!camera,
690
+ waitMs,
691
+ script: [expr, ...steps, reloadExpr ?? ''].join('\n'),
692
+ });
693
+ if (slow)
694
+ console.log(slow);
637
695
  }
638
696
  // Identity line on EVERY run: without it an agent can't distinguish
639
697
  // "signed-in eval" from "--auth silently no-op'd against the anonymous
@@ -676,6 +734,15 @@ export const pageEvalCommand = new Command('eval')
676
734
  }
677
735
  }
678
736
  finally {
737
+ if (restoreDb) {
738
+ try {
739
+ const r = await restoreCheckpoint(restoreDb.projectGuid, restoreDb.database);
740
+ console.error(muted(`Rolled ${r.tables.length} table(s) back to the checkpoint (${r.rows} row(s)) - nothing this eval wrote survives.`));
741
+ }
742
+ catch (err) {
743
+ console.error(warning(`⚠ Could not roll back the database - the checkpoint is still there, retry with: gipity db restore (${err.message})`));
744
+ }
745
+ }
679
746
  for (const h of [...hosted, ...(camera ? [camera] : [])]) {
680
747
  try {
681
748
  await deleteFixture(projectGuid, h.guid);
@@ -1,6 +1,6 @@
1
1
  import { Command, Option } from 'commander';
2
2
  import { mkdirSync, writeFileSync } from 'fs';
3
- import { dirname, join, resolve as resolvePath } from 'path';
3
+ import { dirname, extname, join, resolve as resolvePath } from 'path';
4
4
  import { postForTarEntries } from '../api.js';
5
5
  import { getConfig, getProjectRoot } from '../config.js';
6
6
  import { getAuth } from '../auth.js';
@@ -171,6 +171,19 @@ function resolveDevice(name) {
171
171
  const available = [...Object.keys(DEVICE_PRESETS), ...TOUCH_DEVICES].join(', ');
172
172
  throw new Error(`Unknown --device preset: "${name}" (known: ${available})`);
173
173
  }
174
+ /** Slug used to tell one viewport's `-o` file from another's: the device name the
175
+ * caller typed (`desktop`, `Pixel 9` → `pixel-9`) or the raw dimensions. */
176
+ function viewportSlug(name) {
177
+ return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'shot';
178
+ }
179
+ /** Expand an `--output` path into one path per viewport by treating it as a stem:
180
+ * `tmp/shot.png` + `mobile` → `tmp/shot-mobile.png`. Keeps the caller's extension
181
+ * (defaulting to .png) so a multi-device capture writes every file it took. */
182
+ function stemPath(output, suffix) {
183
+ const ext = extname(output) || '.png';
184
+ const base = ext && output.endsWith(ext) ? output.slice(0, -ext.length) : output;
185
+ return `${base}-${suffix}${ext}`;
186
+ }
174
187
  function splitCsv(values) {
175
188
  if (!values || values.length === 0)
176
189
  return [];
@@ -238,7 +251,7 @@ export const pageScreenshotCommand = new Command('screenshot')
238
251
  .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
252
  .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.')
240
253
  .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.')
241
- .option('-o, --output <file>', 'Output path (single viewport only; default .gipity/screenshots/ss-<host>-<timestamp>.png)')
254
+ .option('-o, --output <file>', 'Output path (default .gipity/screenshots/ss-<host>-<timestamp>.png). With several viewports it is a stem: -o tmp/shot.png --device desktop,mobile writes tmp/shot-desktop.png and tmp/shot-mobile.png.')
242
255
  .option('--no-reload-between', 'Skip reload between viewports (faster, lower fidelity - only safe for static pages)')
243
256
  .option('--fake-media', 'Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so voice/camera apps render headlessly. The video feed is a built-in test pattern — to capture what the app does with a REAL frame (a hand, a face, an object), use --camera <path> instead.')
244
257
  .option('--camera <path>', 'Play a local image or video (.png/.jpg/.webp/.mp4/.webm/.y4m/.mjpeg) as the browser\'s WEBCAM feed, then capture — so the shot shows the app reacting to a frame you chose (detected gesture, boxes, labels). Implies --fake-media.')
@@ -347,9 +360,12 @@ export const pageScreenshotCommand = new Command('screenshot')
347
360
  ...deviceNames.map(resolveDevice),
348
361
  ...viewportStrs.map(parseViewportString),
349
362
  ];
350
- if (opts.output && customViewports.length > 1) {
351
- throw new Error('--output can only be used with a single viewport');
352
- }
363
+ // Parallel to customViewports: what each capture is called when `--output` has
364
+ // to name more than one file.
365
+ const viewportLabels = [
366
+ ...deviceNames.map(viewportSlug),
367
+ ...viewportStrs.map(viewportSlug),
368
+ ];
353
369
  // Server defaults to 1280×720 when viewports is omitted - don't send it in
354
370
  // the no-flag case so the filename stays unsuffixed (no viewport segment).
355
371
  const userSpecifiedViewports = customViewports.length > 0;
@@ -437,9 +453,21 @@ export const pageScreenshotCommand = new Command('screenshot')
437
453
  }
438
454
  const dir = defaultScreenshotDir();
439
455
  const savedFiles = [];
456
+ // With more than one viewport, `--output` is a stem: every capture gets its own
457
+ // file (shot.png → shot-desktop.png, shot-mobile.png) instead of overwriting.
458
+ const usedStems = new Set();
459
+ const outputPath = (i) => {
460
+ if (pngs.length === 1)
461
+ return opts.output;
462
+ let suffix = viewportLabels[i] ?? dimSuffix(meta.screenshots[i].viewport);
463
+ while (usedStems.has(suffix))
464
+ suffix += `-${i + 1}`;
465
+ usedStems.add(suffix);
466
+ return stemPath(opts.output, suffix);
467
+ };
440
468
  for (let i = 0; i < pngs.length; i++) {
441
469
  const target = opts.output
442
- ? opts.output
470
+ ? outputPath(i)
443
471
  : join(dir, names[i] ?? shotName(meta.screenshots[i].viewport));
444
472
  // Create the target's parent dir so a `-o` path under a not-yet-existing
445
473
  // directory (e.g. .gipity/screenshots/home.png) writes cleanly instead of
@@ -1,5 +1,6 @@
1
1
  import { Command } from 'commander';
2
- import { get, post, put, patch, del } from '../api.js';
2
+ import { get, post, put, patch, del, publicRequest, mintAppToken } from '../api.js';
3
+ import { getAuth } from '../auth.js';
3
4
  import { requireConfig } from '../config.js';
4
5
  import { bold, muted } from '../colors.js';
5
6
  import { run, printList, printResult } from '../helpers/index.js';
@@ -10,6 +11,33 @@ import { confirm } from '../utils.js';
10
11
  // /projects/<guid>/records mirror.)
11
12
  export const recordsCommand = new Command('records')
12
13
  .description('Manage app records (Gipity Records - validated CRUD with audit history)');
14
+ const ANON_FLAG = '--anon';
15
+ const ANON_HELP = 'Call as an anonymous visitor (the public path a signed-out user hits) instead of as your signed-in account';
16
+ /** One HTTP surface for every records subcommand, so `--anon` is a persona
17
+ * switch rather than a different code path. Anonymous calls mint the same
18
+ * short-lived app token the browser SDK uses and send no owner credentials -
19
+ * this is exactly what a signed-out visitor's request looks like, so a 401 here
20
+ * is the true answer for an auth-gated table. The persona goes to stderr so
21
+ * stdout stays parseable; without it, "verify the anonymous path" silently runs
22
+ * as the owner and the public path never gets exercised. */
23
+ async function recordsHttp(opts) {
24
+ const config = requireConfig();
25
+ if (!opts.anon) {
26
+ const who = getAuth()?.email;
27
+ console.error(muted(`Auth: calling as ${who ?? 'your signed-in account'} (the owner persona; use ${ANON_FLAG} for the public visitor path)`));
28
+ return { guid: config.projectGuid, get, post, put, patch, del };
29
+ }
30
+ console.error(muted('Auth: anonymous visitor (the public path a signed-out user hits)'));
31
+ const headers = await mintAppToken(config.projectGuid);
32
+ return {
33
+ guid: config.projectGuid,
34
+ get: (path) => publicRequest('GET', path, undefined, headers),
35
+ post: (path, body) => publicRequest('POST', path, body, headers),
36
+ put: (path, body) => publicRequest('PUT', path, body, headers),
37
+ patch: (path, body) => publicRequest('PATCH', path, body, headers),
38
+ del: (path, body) => publicRequest('DELETE', path, body, headers),
39
+ };
40
+ }
13
41
  recordsCommand
14
42
  .command('list')
15
43
  .description('List record tables')
@@ -51,25 +79,38 @@ recordsCommand
51
79
  }));
52
80
  recordsCommand
53
81
  .command('query <table>')
54
- .description('List records')
55
- .option('--filter <filter>', 'Filter string (e.g., "status:eq:active")')
82
+ .description('List records (full-text search, filter, sort, recycle bin)')
83
+ .option('-q, --q <text>', 'Full-text search across the table\'s text columns (needs `--searchable true` on the table)')
84
+ .option('--filter <filter>', 'AND filter string (e.g., "status:eq:active")')
85
+ .option('--any <filter>', 'OR filter group, same grammar as --filter (e.g., "owner:eq:me,shared:eq:true")')
56
86
  .option('--sort <sort>', 'Sort string (e.g., "created_at:desc")')
57
87
  .option('--limit <n>', 'Max rows', '20')
58
88
  .option('--offset <n>', 'Offset', '0')
59
89
  .option('--fields <fields>', 'Comma-separated column names')
90
+ .option('--include-deleted', 'Include soft-deleted rows (owner/editor)')
91
+ .option('--only-deleted', 'Only soft-deleted rows - the recycle bin (owner/editor)')
92
+ .option(ANON_FLAG, ANON_HELP)
60
93
  .option('--json', 'Output as JSON')
61
94
  .action((table, opts) => run('Query', async () => {
62
- const config = requireConfig();
95
+ const api = await recordsHttp(opts);
63
96
  const params = new URLSearchParams();
97
+ if (opts.q)
98
+ params.set('q', opts.q);
64
99
  if (opts.filter)
65
100
  params.set('filter', opts.filter);
101
+ if (opts.any)
102
+ params.set('any', opts.any);
66
103
  if (opts.sort)
67
104
  params.set('sort', opts.sort);
68
105
  params.set('limit', opts.limit);
69
106
  params.set('offset', opts.offset);
70
107
  if (opts.fields)
71
108
  params.set('fields', opts.fields);
72
- const res = await get(`/api/${config.projectGuid}/records/${table}?${params}`);
109
+ if (opts.includeDeleted)
110
+ params.set('include_deleted', '1');
111
+ if (opts.onlyDeleted)
112
+ params.set('only_deleted', '1');
113
+ const res = await api.get(`/api/${api.guid}/records/${table}?${params}`);
73
114
  if (opts.json) {
74
115
  console.log(JSON.stringify(res));
75
116
  }
@@ -85,21 +126,26 @@ recordsCommand
85
126
  recordsCommand
86
127
  .command('get <table> <id>')
87
128
  .description('Get a record')
129
+ .option(ANON_FLAG, ANON_HELP)
88
130
  .option('--json', 'Output as JSON')
89
131
  .action((table, id, opts) => run('Get', async () => {
90
- const config = requireConfig();
91
- const res = await get(`/api/${config.projectGuid}/records/${table}/${id}`);
132
+ const api = await recordsHttp(opts);
133
+ const res = await api.get(`/api/${api.guid}/records/${table}/${id}`);
92
134
  console.log(opts.json ? JSON.stringify(res.data) : JSON.stringify(res.data, null, 2));
93
135
  }));
94
136
  recordsCommand
95
- .command('history <table> <id>')
96
- .description('Audit history for a record (who/what changed it, with English summaries)')
137
+ .command('history <table> [id]')
138
+ .description('Audit history (who/what changed it, with English summaries). Omit <id> for the whole table\'s feed.')
97
139
  .option('--limit <n>', 'Max events', '20')
140
+ .option(ANON_FLAG, ANON_HELP)
98
141
  .option('--json', 'Output as JSON')
99
142
  .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 => {
143
+ const api = await recordsHttp(opts);
144
+ // Table-wide feed when no id is given - the same endpoint an activity/history
145
+ // view reads, so verifying it needs no hand-built HTTP call.
146
+ const scope = id ? `${table}/${encodeURIComponent(id)}` : table;
147
+ const res = await api.get(`/api/${api.guid}/records/${scope}/history?limit=${encodeURIComponent(opts.limit)}`);
148
+ printList(res.data, opts, id ? 'No history for this record.' : `No history for "${table}".`, e => {
103
149
  const summary = e.detail?.summary || `${e.action} ${e.entity_type} ${e.entity_id}`;
104
150
  return `${muted(e.created_at)} ${bold(e.source || '-')} ${summary}`;
105
151
  });
@@ -108,34 +154,58 @@ recordsCommand
108
154
  .command('create <table>')
109
155
  .description('Create a record')
110
156
  .requiredOption('--data <json>', 'JSON object with field values')
157
+ .option(ANON_FLAG, ANON_HELP)
111
158
  .option('--json', 'Output as JSON')
112
159
  .action((table, opts) => run('Create', async () => {
113
- const config = requireConfig();
160
+ const api = await recordsHttp(opts);
114
161
  const data = JSON.parse(opts.data);
115
- const res = await post(`/api/${config.projectGuid}/records/${table}`, data);
162
+ const res = await api.post(`/api/${api.guid}/records/${table}`, data);
116
163
  printResult(`Created: ${JSON.stringify(res.data)}`, opts, res.data);
117
164
  }));
118
165
  recordsCommand
119
166
  .command('update <table> <id>')
120
167
  .description('Update a record')
121
168
  .requiredOption('--data <json>', 'JSON object with fields to update')
169
+ .option(ANON_FLAG, ANON_HELP)
122
170
  .option('--json', 'Output as JSON')
123
171
  .action((table, id, opts) => run('Update', async () => {
124
- const config = requireConfig();
172
+ const api = await recordsHttp(opts);
125
173
  const data = JSON.parse(opts.data);
126
- const res = await put(`/api/${config.projectGuid}/records/${table}/${id}`, data);
174
+ const res = await api.put(`/api/${api.guid}/records/${table}/${id}`, data);
127
175
  printResult(`Updated: ${JSON.stringify(res.data)}`, opts, res.data);
128
176
  }));
177
+ // A table that declares `soft_delete_column` only STAMPS the row on delete - it
178
+ // stays queryable via `query --only-deleted` and keeps its audit history. That's
179
+ // right for user data and wrong for a probe row you just minted to test the
180
+ // wiring, so `--purge` (the server's ?purge=1) is the one call that really
181
+ // removes it. The plain success line says which of the two happened and names
182
+ // the escape hatch, so nobody has to go spelunking through gipity.js to find it.
129
183
  recordsCommand
130
184
  .command('delete <table> <id>')
131
- .description('Delete a record')
132
- .action((table, id) => run('Delete', async () => {
133
- if (!await confirm(`Delete record ${id} from "${table}"?`)) {
134
- console.log('Cancelled.');
185
+ .description('Delete a record (soft-delete when the table declares one; --purge removes it for good)')
186
+ .option('--purge', 'Hard-delete: remove the row AND erase its audit history (owner/editor). Use it to scrub probe rows.')
187
+ .option(ANON_FLAG, ANON_HELP)
188
+ .option('--json', 'Output as JSON')
189
+ .action((table, id, opts) => run('Delete', async () => {
190
+ const what = opts.purge ? `Purge record ${id} from "${table}" (also erases its history)?` : `Delete record ${id} from "${table}"?`;
191
+ if (!await confirm(what)) {
192
+ printResult('Cancelled.', opts, { table, id, deleted: false, cancelled: true });
135
193
  return;
136
194
  }
137
- const config = requireConfig();
138
- await del(`/api/${config.projectGuid}/records/${table}/${id}`);
139
- printResult('Deleted.', { json: false });
195
+ const api = await recordsHttp(opts);
196
+ const res = await api.del(`/api/${api.guid}/records/${table}/${id}${opts.purge ? '?purge=1' : ''}`);
197
+ const data = res?.data ?? { table, id, deleted: true, purged: !!opts.purge };
198
+ printResult(data.purged
199
+ ? 'Purged - the row and its audit history are gone.'
200
+ : `Deleted. If "${table}" declares a soft-delete column the row is only stamped deleted (see it with \`gipity records query ${table} --only-deleted\`, bring it back with \`gipity records restore ${table} ${id}\`, remove it for good with \`--purge\`).`, opts, data);
201
+ }));
202
+ recordsCommand
203
+ .command('restore <table> <id>')
204
+ .description('Un-delete a soft-deleted record (owner/editor)')
205
+ .option('--json', 'Output as JSON')
206
+ .action((table, id, opts) => run('Restore', async () => {
207
+ const api = await recordsHttp(opts);
208
+ const res = await api.post(`/api/${api.guid}/records/${table}/${id}/restore`);
209
+ printResult('Restored.', opts, res?.data ?? { table, id, restored: true });
140
210
  }));
141
211
  //# sourceMappingURL=records.js.map
@@ -1,8 +1,9 @@
1
1
  import { Command } from 'commander';
2
- import { get, post } from '../api.js';
2
+ import { get, post, publicRequest, mintAppToken, ApiError } from '../api.js';
3
+ import { getAuth } from '../auth.js';
3
4
  import { requireConfig } from '../config.js';
4
5
  import { bold, muted } from '../colors.js';
5
- import { run } from '../helpers/index.js';
6
+ import { run, emitField, resolveBody } from '../helpers/index.js';
6
7
  /** Known app-service endpoints, shown by `gipity service list` and used to
7
8
  * give a helpful hint on typos. POST endpoints take a JSON body; GET
8
9
  * endpoints (model/voice listings) are reached with `--get`. */
@@ -35,16 +36,41 @@ serviceCommand
35
36
  const width = SERVICES.reduce((m, s) => Math.max(m, s.name.length), 0);
36
37
  console.log('Call one with `gipity service call <name> \'{"json":"body"}\'`');
37
38
  console.log(muted('(GET endpoints like llm/models, tts/voices take --get and no body)'));
39
+ console.log(muted('Verifying what a signed-out visitor of your deployed app gets? Add --anon.'));
38
40
  console.log('');
39
41
  for (const s of SERVICES) {
40
42
  console.log(`${bold(s.name.padEnd(width))} ${muted(`[${s.method}] ${s.desc}`)}`);
41
43
  }
42
44
  }));
45
+ /** A signed-out visitor's 401 on a service is almost never "the caller needs to
46
+ * log in" — it is the app's service `billing_mode` still on the `user_pays`
47
+ * default, which asks every anonymous visitor of a PUBLIC page to sign in and
48
+ * pay from their own credits. The server's raw body says LOGIN_REQUIRED and
49
+ * nothing about the manifest, so the fix costs a doc hunt across skills. Name
50
+ * the cause and the exact edit here, at the moment it happens. */
51
+ export function anonBillingHint(name, err) {
52
+ if (!(err instanceof ApiError) || err.statusCode !== 401)
53
+ return null;
54
+ // Only the "sign in to use this" refusal is a billing-mode symptom. A missing
55
+ // credential (the visitor token never minted) is a different failure with a
56
+ // different fix, and must not be dressed up as one.
57
+ if (err.code !== 'LOGIN_REQUIRED')
58
+ return null;
59
+ const service = name.split('/')[0];
60
+ return (`An anonymous visitor cannot call '${service}': it is on the user_pays default, so every signed-out ` +
61
+ `visitor to a public page is asked to sign in and pay from their own credits. If this app serves the ` +
62
+ `public (a help bubble, a landing page), switch it to owner-pays in gipity.yaml and redeploy:\n` +
63
+ ` deploy:\n phases:\n - services\n service_definitions:\n - service: ${service}\n billing_mode: owner_pays\n` +
64
+ ` gipity deploy dev --only services`);
65
+ }
43
66
  serviceCommand
44
67
  .command('call <name> [body]')
45
- .description('Call an app service by name (e.g. llm, image, music). Uses your logged-in session - no token wrangling.')
46
- .option('--data <json>', 'JSON request body (alternative to the positional [body])')
68
+ .description('Call an app service by name (e.g. llm, image, music). Uses your logged-in session - no token wrangling; --anon calls it as a signed-out visitor instead.')
69
+ .option('-d, --data <json>', 'JSON request body: inline JSON, @file to read a file, or @- / - for stdin (alternative to the positional [body])')
70
+ .option('--file <field=@path>', 'Attach a file as { data, media_type } under <field> (the vision/media shape these services expect), repeatable, e.g. --file image=@receipt.png', (v, acc) => (acc || []).concat(v))
71
+ .option('--anon', 'Call as an anonymous visitor (the public path a signed-out user of your deployed app hits) instead of as your signed-in account')
47
72
  .option('--get', 'Issue a GET (for listing endpoints like llm/models, tts/voices)')
73
+ .option('--field <path>', 'Print only this field of the result (dot path, e.g. choices.0.message.content)')
48
74
  .option('--json', 'Output compact JSON')
49
75
  .action((name, bodyArg, opts) => run('Call', async () => {
50
76
  const config = requireConfig();
@@ -52,9 +78,47 @@ serviceCommand
52
78
  // like `location/geocode` and `llm/models` resolve correctly.
53
79
  const path = name.split('/').map(encodeURIComponent).join('/');
54
80
  const url = `/api/${config.projectGuid}/services/${path}`;
55
- const res = opts.get
56
- ? await get(url)
57
- : await post(url, JSON.parse(bodyArg || opts.data || '{}'));
81
+ const body = resolveBody(bodyArg || opts.data, opts.file);
82
+ // Name the calling identity (stderr, so stdout stays parseable). An owner
83
+ // call always succeeds regardless of billing mode, so without this line a
84
+ // "the service works" verification silently proves nothing about the
85
+ // visitors the deployed app actually serves.
86
+ if (opts.anon) {
87
+ console.error(muted('Auth: anonymous visitor (the public path a signed-out user of your deployed app hits)'));
88
+ }
89
+ else {
90
+ const who = getAuth()?.email;
91
+ console.error(muted(`Auth: calling as ${who ?? 'your signed-in account'} (the owner persona - always billed to you and never blocked by billing_mode; use --anon for the public visitor path)`));
92
+ }
93
+ let res;
94
+ try {
95
+ if (opts.anon) {
96
+ // The short-lived app token IS the visitor's credential here (unlike a
97
+ // public function, a service is never callable with no token at all).
98
+ // If it can't be minted, say so — otherwise the request comes back as a
99
+ // bare 401 that reads like an app-permissions problem.
100
+ const headers = await mintAppToken(config.projectGuid);
101
+ if (!headers) {
102
+ throw new Error(`Could not mint a visitor token for this project, so there is no anonymous path to call. ` +
103
+ `That means the app is not deployed on this API base yet: run \`gipity deploy dev\` first, ` +
104
+ `then re-run this command.`);
105
+ }
106
+ res = await publicRequest(opts.get ? 'GET' : 'POST', url, opts.get ? undefined : body, headers);
107
+ }
108
+ else {
109
+ res = opts.get ? await get(url) : await post(url, body);
110
+ }
111
+ }
112
+ catch (err) {
113
+ const hint = opts.anon ? anonBillingHint(name, err) : null;
114
+ if (!hint)
115
+ throw err;
116
+ throw new Error(`${err.message}\n${hint}`);
117
+ }
118
+ if (opts.field) {
119
+ emitField(res, opts.field);
120
+ return;
121
+ }
58
122
  console.log(opts.json ? JSON.stringify(res) : JSON.stringify(res, null, 2));
59
123
  }));
60
124
  //# sourceMappingURL=service.js.map
@@ -4,7 +4,12 @@ import { bold, muted, success, warning } from '../colors.js';
4
4
  import { run, printList } from '../helpers/index.js';
5
5
  export const tokenCommand = new Command('token')
6
6
  .description('Manage API tokens')
7
- .addHelpText('after', '\nLong-lived agent API tokens (gip_at_*) for headless agents and CI.');
7
+ .addHelpText('after', `
8
+ Long-lived agent API tokens (gip_at_*) for headless agents and CI - they drive
9
+ the gipity CLI as YOU.
10
+
11
+ To let a script or cron write to one app instead, mint a project API key:
12
+ gipity key create "my script" --role editor (sent as X-Api-Key).`);
8
13
  const fmtDate = (d) => (d ? new Date(d).toLocaleDateString() : 'never');
9
14
  tokenCommand
10
15
  .command('create')
@@ -19,7 +19,7 @@ import { confirm, getAutoConfirm } from '../utils.js';
19
19
  import { bold, brand, dim, success, error as clrError, muted } from '../colors.js';
20
20
  import * as relayState from '../relay/state.js';
21
21
  import { planFor, UnsupportedPlatformError } from '../relay/installers.js';
22
- import { GIPITY_PLUGIN_ID, GIPITY_MARKETPLACE_NAME, stripGipityHooks, grokInstallState, agentSkillsState, AGENTS_SKILLS_DIR } from '../setup.js';
22
+ import { GIPITY_PLUGIN_ID, GIPITY_MARKETPLACE_NAME, stripGipityHooks, grokInstallState, agentSkillsState, AGENTS_SKILLS_DIR, agySkillsState, AGY_SKILLS_DIR, } from '../setup.js';
23
23
  /** Remove Gipity's entries from the user-scope Claude Code settings: the
24
24
  * plugin enablement, the marketplace registration, and any legacy hook
25
25
  * blocks older CLI versions wrote there. Surgical - everything else in the
@@ -240,6 +240,19 @@ export const uninstallCommand = new Command('uninstall')
240
240
  }
241
241
  console.log(`${success(`Removed ${agentSkills.skills.length} Gipity skills from ~/.agents/skills.`)}`);
242
242
  }
243
+ // 4c. Remove the skills the CLI copied into Antigravity's own global
244
+ // skill root (~/.gemini/config/skills - a different directory from
245
+ // Codex's ~/.agents/skills, see setup.ts).
246
+ const agySkills = agySkillsState();
247
+ if (agySkills.skills.length) {
248
+ for (const name of agySkills.skills) {
249
+ try {
250
+ rmSync(join(AGY_SKILLS_DIR, name), { recursive: true, force: true });
251
+ }
252
+ catch { /* best-effort */ }
253
+ }
254
+ console.log(`${success(`Removed ${agySkills.skills.length} Gipity skills from ~/.gemini/config/skills.`)}`);
255
+ }
243
256
  // 5. Wipe ~/.gipity/ (this also removes the agent-hooks scripts and the
244
257
  // agent-skills manifest, which live under it).
245
258
  if (existsSync(gipityDir)) {