gipity 1.1.4 → 1.1.6

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 (52) hide show
  1. package/dist/adopt-cwd.js +1 -1
  2. package/dist/agents/agy.js +52 -0
  3. package/dist/agents/index.js +2 -0
  4. package/dist/api.js +19 -3
  5. package/dist/banner.js +3 -5
  6. package/dist/capture/sources/agy.js +88 -0
  7. package/dist/client-context.js +9 -0
  8. package/dist/commands/add.js +1 -1
  9. package/dist/commands/brand.js +125 -0
  10. package/dist/commands/build.js +13 -12
  11. package/dist/commands/chat.js +1 -1
  12. package/dist/commands/deploy.js +1 -1
  13. package/dist/commands/fn.js +2 -8
  14. package/dist/commands/generate.js +5 -5
  15. package/dist/commands/gmail.js +1 -1
  16. package/dist/commands/init.js +2 -2
  17. package/dist/commands/job.js +10 -5
  18. package/dist/commands/key.js +91 -0
  19. package/dist/commands/load.js +2 -2
  20. package/dist/commands/login.js +7 -2
  21. package/dist/commands/page-eval.js +47 -8
  22. package/dist/commands/page-fetch.js +14 -10
  23. package/dist/commands/page-inspect.js +2 -2
  24. package/dist/commands/page-screenshot.js +5 -5
  25. package/dist/commands/page-test.js +1 -1
  26. package/dist/commands/records.js +57 -19
  27. package/dist/commands/remove.js +1 -1
  28. package/dist/commands/sandbox.js +1 -1
  29. package/dist/commands/save.js +1 -1
  30. package/dist/commands/secrets.js +1 -1
  31. package/dist/commands/service.js +1 -1
  32. package/dist/commands/setup.js +4 -6
  33. package/dist/commands/status.js +41 -12
  34. package/dist/commands/storage.js +2 -2
  35. package/dist/commands/sync.js +15 -0
  36. package/dist/commands/test.js +1 -1
  37. package/dist/commands/token.js +6 -1
  38. package/dist/commands/uninstall.js +14 -1
  39. package/dist/commands/upload.js +1 -1
  40. package/dist/commands/workflow.js +1 -1
  41. package/dist/hooks/capture-runner.js +20 -8
  42. package/dist/index.js +1046 -518
  43. package/dist/knowledge.js +4 -1
  44. package/dist/login-flow.js +44 -2
  45. package/dist/project-setup.js +2 -0
  46. package/dist/relay/diagnostics.js +4 -2
  47. package/dist/relay/onboarding.js +26 -44
  48. package/dist/setup.js +198 -16
  49. package/dist/sync.js +6 -6
  50. package/dist/template-vars.js +74 -0
  51. package/dist/utils.js +60 -3
  52. package/package.json +2 -2
@@ -0,0 +1,91 @@
1
+ import { Command } from 'commander';
2
+ import { get, post, del } from '../api.js';
3
+ import { resolveProjectContext } from '../config.js';
4
+ import { bold, muted, success, warning } from '../colors.js';
5
+ import { run, printList } from '../helpers/index.js';
6
+ /**
7
+ * Project API keys - the revocable secret a script, cron, or agent sends as
8
+ * `X-Api-Key` to write to YOUR app without a browser login.
9
+ *
10
+ * These are per-project and app-facing; `gipity token` mints account-level
11
+ * agent tokens (gip_at_*) that drive the CLI itself. Without this command the
12
+ * only mint path was raw curl, so agents asked for "a key I can generate and
13
+ * revoke" built their own key table instead of using the platform's.
14
+ */
15
+ export const keyCommand = new Command('key')
16
+ .description('Manage project API keys for scripts and agents (X-Api-Key)')
17
+ .addHelpText('after', `
18
+ Give a script, cron, or agent write access to your app without a login:
19
+
20
+ gipity key create "laptop importer" --role editor
21
+ # the script sends: X-Api-Key: <the key printed once above>
22
+
23
+ Inside a function the caller shows up as ctx.auth.via === 'api_key' with
24
+ ctx.auth.apiKeyName set to the key's name - stamp that on rows to tell
25
+ script-written entries from hand-entered ones. Never build your own key table.
26
+
27
+ Account-level tokens that run the CLI headlessly are a different thing: see
28
+ gipity token --help.`);
29
+ const fmtDate = (d) => (d ? new Date(d).toLocaleDateString() : 'never');
30
+ const projectOpt = ['--project <guid-or-slug>', 'Target a specific project instead of cwd / Home'];
31
+ async function projectGuid(opts) {
32
+ const { config } = await resolveProjectContext({ projectOverride: opts.project });
33
+ return config.projectGuid;
34
+ }
35
+ keyCommand
36
+ .command('create <name>')
37
+ .description('Mint a project API key (shown once). Give it a name you will recognize later')
38
+ .option('--role <role>', 'viewer | editor | owner (default: viewer)', 'viewer')
39
+ .option('--expires-days <n>', 'Days until the key expires (default: never)')
40
+ .option(...projectOpt)
41
+ .option('--json', 'Output as JSON')
42
+ .action((name, opts) => run('Create', async () => {
43
+ const body = { name, role: opts.role };
44
+ if (opts.expiresDays !== undefined) {
45
+ const days = parseInt(opts.expiresDays, 10);
46
+ if (!Number.isFinite(days) || days <= 0)
47
+ throw new Error('--expires-days must be a positive number of days');
48
+ body.expires_in_days = days;
49
+ }
50
+ const res = await post(`/projects/${await projectGuid(opts)}/api-keys`, body);
51
+ const k = res.data;
52
+ if (opts.json) {
53
+ console.log(JSON.stringify(k));
54
+ return;
55
+ }
56
+ const expNote = k.expires_at ? ` (expires ${fmtDate(k.expires_at)})` : ' (never expires)';
57
+ console.log(success(`Created API key ${bold(k.short_guid)} "${k.name}" as ${k.role}${muted(expNote)}.`));
58
+ console.log('');
59
+ console.log(k.key);
60
+ console.log('');
61
+ console.log(muted('Send it from your script on every request:'));
62
+ console.log(muted(` curl -H "X-Api-Key: ${k.key}" ...`));
63
+ console.log(muted(`Revoke it any time: gipity key revoke ${k.short_guid}`));
64
+ console.log('');
65
+ console.log(warning('Copy it now - it will not be shown again.'));
66
+ }));
67
+ keyCommand
68
+ .command('list')
69
+ .alias('ls')
70
+ .description('List this project\'s API keys (values are never shown again)')
71
+ .option(...projectOpt)
72
+ .option('--json', 'Output as JSON')
73
+ .action((opts) => run('List', async () => {
74
+ const res = await get(`/projects/${await projectGuid(opts)}/api-keys`);
75
+ printList(res.data, opts, 'No API keys. Mint one with: gipity key create "my script" --role editor', (k) => `${bold(k.short_guid)} ${k.name} ${muted(`${k.role} ${k.prefix}… last used ${fmtDate(k.last_used_at)} expires ${fmtDate(k.expires_at)}`)}`);
76
+ }));
77
+ keyCommand
78
+ .command('revoke <short_guid>')
79
+ .alias('rm')
80
+ .description('Revoke a project API key (instant, irreversible)')
81
+ .option(...projectOpt)
82
+ .option('--json', 'Output as JSON')
83
+ .action((shortGuid, opts) => run('Revoke', async () => {
84
+ await del(`/projects/${await projectGuid(opts)}/api-keys/${encodeURIComponent(shortGuid)}`);
85
+ if (opts.json) {
86
+ console.log(JSON.stringify({ short_guid: shortGuid, revoked: true }));
87
+ return;
88
+ }
89
+ console.log(success(`Revoked API key ${bold(shortGuid)}.`));
90
+ }));
91
+ //# sourceMappingURL=key.js.map
@@ -152,7 +152,7 @@ export const loadCommand = new Command('load')
152
152
  if (opts.inspect) {
153
153
  const res = opts.json
154
154
  ? await doInspect()
155
- : await withSpinner('Inspecting', doInspect, { done: null });
155
+ : await withSpinner('Inspecting...', doInspect, { done: null });
156
156
  if (opts.json) {
157
157
  console.log(JSON.stringify(res.data));
158
158
  return;
@@ -162,7 +162,7 @@ export const loadCommand = new Command('load')
162
162
  }
163
163
  const { data, partial } = opts.json
164
164
  ? await doImport()
165
- : await withSpinner('Importing', doImport, { done: null });
165
+ : await withSpinner('Importing...', doImport, { done: null });
166
166
  const project = data.project;
167
167
  // Materialize a local dir and link it, exactly like `gipity project create`:
168
168
  // `.gipity.json` is written directly inside the new dir, then a soft sync
@@ -4,6 +4,7 @@ import { publicPost } from '../api.js';
4
4
  import { prompt, decodeJwtExp } from '../utils.js';
5
5
  import { success, error as clrError, muted } from '../colors.js';
6
6
  import { flushBugQueue } from '../bug-queue.js';
7
+ import { warnBeforeCodeIfUnexpectedNewAccount, warnIfUnexpectedNewAccount } from '../login-flow.js';
7
8
  export const loginCommand = new Command('login')
8
9
  .description('Log in or sign up')
9
10
  .option('--email <email>', 'Email address')
@@ -19,9 +20,10 @@ export const loginCommand = new Command('login')
19
20
  }
20
21
  // Email only → send code and exit (non-interactive step 1)
21
22
  if (email && !code) {
22
- await publicPost('/auth/login', { email });
23
+ const sendRes = await publicPost('/auth/login', { email });
23
24
  console.log('Check your email for a 6-digit code.');
24
25
  console.log(muted(`Then run: gipity login --email ${email} --code <code>`));
26
+ warnBeforeCodeIfUnexpectedNewAccount(sendRes.isNewUser, email);
25
27
  return;
26
28
  }
27
29
  // Fully interactive flow
@@ -34,9 +36,10 @@ export const loginCommand = new Command('login')
34
36
  console.error(clrError('Email required.'));
35
37
  process.exit(1);
36
38
  }
37
- await publicPost('/auth/login', { email });
39
+ const sendRes = await publicPost('/auth/login', { email });
38
40
  console.log('');
39
41
  console.log('Check your email for a 6-digit code.');
42
+ warnBeforeCodeIfUnexpectedNewAccount(sendRes.isNewUser, email);
40
43
  code = await prompt('Code: ');
41
44
  await verify(email, code);
42
45
  }
@@ -46,6 +49,7 @@ export const loginCommand = new Command('login')
46
49
  }
47
50
  });
48
51
  async function verify(email, code) {
52
+ const priorAuth = getAuth();
49
53
  const res = await publicPost('/auth/verify', { email, code });
50
54
  const exp = decodeJwtExp(res.accessToken);
51
55
  if (!exp) {
@@ -60,6 +64,7 @@ async function verify(email, code) {
60
64
  expiresAt,
61
65
  });
62
66
  console.log(success(`Logged in (${email}).`));
67
+ warnIfUnexpectedNewAccount(res.isNewUser, email, priorAuth);
63
68
  // A fresh session is the clearest "we're reconnected" signal - clear any bug
64
69
  // reports that got stranded while this account's session was expired/offline.
65
70
  const delivered = await flushBugQueue().catch(() => 0);
@@ -106,7 +106,7 @@ export function summarizeExpr(expr) {
106
106
  if (oneLine && expr.trim().length <= EXPR_ECHO_MAX_CHARS)
107
107
  return expr.trim();
108
108
  const first = (meaningful[0] ?? '').trim();
109
- const head = first.length > EXPR_ECHO_MAX_CHARS ? `${first.slice(0, EXPR_ECHO_MAX_CHARS - 1)}…` : first;
109
+ const head = first.length > EXPR_ECHO_MAX_CHARS ? `${first.slice(0, EXPR_ECHO_MAX_CHARS - 3)}...` : first;
110
110
  const shape = oneLine
111
111
  ? `(${expr.trim().length} chars)`
112
112
  : `(+${meaningful.length - 1} more ${meaningful.length - 1 === 1 ? 'line' : 'lines'}, ${expr.trim().length} chars)`;
@@ -257,6 +257,31 @@ export function budgetOverrunHint(reason, usedBudgetMs) {
257
257
  `(up to ${EVAL_SCRIPT_BUDGET_MAX_MS}), e.g. --timeout ${EVAL_SCRIPT_BUDGET_MAX_MS} — ` +
258
258
  `or gate on a ready signal instead: --wait-for '<selector>' --wait-timeout ${MAX_WAIT_MS}.`;
259
259
  }
260
+ /** The server aborts a script whose page navigated out from under it, and tells
261
+ * the caller to "split the check into two evals". For the case that actually
262
+ * produces this (seed state, reload, assert the state came back), that advice
263
+ * sends the caller down the wrong road: two evals are two independent page
264
+ * sessions, so the second one has to re-seed before it can read anything, and
265
+ * the thing being verified (a real reload restoring real persisted state) is
266
+ * never exercised. `--reload` is the primitive for it: one page load, reloaded
267
+ * IN PLACE with storage preserved, second expression against the post-reload
268
+ * DOM. Name it here, at the exact moment the caller hand-rolled it. */
269
+ export function navigationAbortHint(reason) {
270
+ if (!/navigated or reloaded/i.test(reason))
271
+ return null;
272
+ return `If you were verifying that state SURVIVES a reload, that is what --reload is for (one command, one page): ` +
273
+ `gipity page eval "<url>" '<seed/assert expr>' --reload '<assert-restored expr>'. ` +
274
+ `It reloads the page in place (localStorage/sessionStorage/cookies preserved) and reports the second result ` +
275
+ `separately. Splitting into two 'page eval' calls does NOT do this: each call is its own browser session, so ` +
276
+ `the second one starts from empty storage and never exercises the restore path.`;
277
+ }
278
+ /** True when the submitted script already drives the app's clock itself
279
+ * (`core.advance(seconds)` on the 3D templates, the imported `advance(seconds)`
280
+ * on the 2D one). Such a script does not care what the renderer paints at, so
281
+ * the slow-render warning below has nothing to tell it. Exported for tests. */
282
+ export function stepsDeterministically(script) {
283
+ return /\badvance\s*\(/.test(script);
284
+ }
260
285
  /** The headless browser paints slowly, and what that MEANS depends on what the
261
286
  * page is doing — so the one-size warning was actively misleading on a --camera
262
287
  * run. A vision app has no loop to step: its pipeline is driven by camera
@@ -273,6 +298,8 @@ export function budgetOverrunHint(reason, usedBudgetMs) {
273
298
  * deterministic way to wait (--wait-for) instead of inviting a wall-clock
274
299
  * escalation that is guaranteed to be wasted. Exported for tests. */
275
300
  export function slowRenderMessage(fps, o) {
301
+ if (!o.camera && stepsDeterministically(o.script ?? ''))
302
+ return null;
276
303
  if (o.camera) {
277
304
  const frames = Math.max(1, Math.round(fps * (o.waitMs / 1000)));
278
305
  return `${warning('⚠ Slow render:')} page painted at ${fps} fps, so the app's vision pipeline ran on roughly `
@@ -379,10 +406,10 @@ export const pageEvalCommand = new Command('eval')
379
406
  .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')
380
407
  .argument('<url>', 'URL to load')
381
408
  .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.`)
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).`)
409
+ .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
410
  .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
411
  .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.')
412
+ .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
413
  .option('--reload-file <path>', 'Read the post-reload expression from a file instead of inline --reload (mutually exclusive)')
387
414
  .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
415
  .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.')
@@ -426,7 +453,7 @@ export const pageEvalCommand = new Command('eval')
426
453
  }
427
454
  catch {
428
455
  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'
456
+ ? '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
457
  : `error: Cannot read file: ${opts.file}`);
431
458
  }
432
459
  }
@@ -540,12 +567,12 @@ export const pageEvalCommand = new Command('eval')
540
567
  projectGuid = config.projectGuid;
541
568
  }
542
569
  if (opts.camera) {
543
- console.log(muted(`Hosting camera feed ${opts.camera}…`));
570
+ console.log(muted(`Hosting camera feed ${opts.camera}...`));
544
571
  camera = await uploadCameraFeed(projectGuid, opts.camera);
545
572
  }
546
573
  if (fixturePaths.length) {
547
574
  for (const p of fixturePaths) {
548
- console.log(muted(`Hosting fixture ${p}…`));
575
+ console.log(muted(`Hosting fixture ${p}...`));
549
576
  hosted.push(await uploadPublicFixture(projectGuid, p));
550
577
  }
551
578
  const map = {};
@@ -589,7 +616,10 @@ export const pageEvalCommand = new Command('eval')
589
616
  // raises it — and the flag is what the caller needs next. Name it, with
590
617
  // the value THIS run used, so the retry is an edit rather than a guess.
591
618
  const msg = err?.message ?? '';
592
- const hint = budgetOverrunHint(msg, scriptBudgetMs);
619
+ const hint = budgetOverrunHint(msg, scriptBudgetMs)
620
+ // A script aborted by its own reload is nearly always a persistence
621
+ // check hand-rolled without --reload; point at the flag that does it.
622
+ ?? (reloadExpr === undefined ? navigationAbortHint(msg) : null);
593
623
  if (!hint)
594
624
  throw err;
595
625
  throw new Error(`${msg}\n${hint}`);
@@ -632,8 +662,17 @@ export const pageEvalCommand = new Command('eval')
632
662
  // page's rAF loop is its clock, and engines cap the per-frame delta, so
633
663
  // `setTimeout(2000)` may advance only a fraction of a second of app time.
634
664
  // Without this line the eval returns a plausible-looking false negative.
665
+ // A script that already steps the loop itself is immune to all of that, so
666
+ // slowRenderMessage returns null for it rather than prescribing the fix it
667
+ // is already applying.
635
668
  if (d.slowRender) {
636
- console.log(slowRenderMessage(d.slowRender.fps, { camera: !!camera, waitMs }));
669
+ const slow = slowRenderMessage(d.slowRender.fps, {
670
+ camera: !!camera,
671
+ waitMs,
672
+ script: [expr, ...steps, reloadExpr ?? ''].join('\n'),
673
+ });
674
+ if (slow)
675
+ console.log(slow);
637
676
  }
638
677
  // Identity line on EVERY run: without it an agent can't distinguish
639
678
  // "signed-in eval" from "--auth silently no-op'd against the anonymous
@@ -30,9 +30,6 @@ function looksLikeHtml(body) {
30
30
  const head = body.slice(0, 300).toLowerCase();
31
31
  return /<!doctype html|<html[\s>]/.test(head);
32
32
  }
33
- function sha256(s) {
34
- return createHash('sha256').update(s).digest('hex');
35
- }
36
33
  function baseWithSlash(url) {
37
34
  return url.endsWith('/') ? url : url + '/';
38
35
  }
@@ -43,8 +40,16 @@ function resolveUrl(base, path) {
43
40
  async function fetchRaw(url) {
44
41
  try {
45
42
  const res = await fetch(url, { redirect: 'follow' });
46
- const body = await res.text();
47
- return { status: res.status, contentType: res.headers.get('content-type'), body };
43
+ // Read raw bytes, not text(): decoding binary as UTF-8 turns every invalid
44
+ // byte into a 3-byte replacement char, inflating the measured size ~1.7x.
45
+ const buf = Buffer.from(await res.arrayBuffer());
46
+ return {
47
+ status: res.status,
48
+ contentType: res.headers.get('content-type'),
49
+ body: buf.toString('utf-8'),
50
+ bytes: buf.length,
51
+ sha256: createHash('sha256').update(buf).digest('hex'),
52
+ };
48
53
  }
49
54
  catch {
50
55
  return null; // DNS failure, connection refused, etc.
@@ -59,21 +64,20 @@ async function probeShell(base) {
59
64
  return {
60
65
  served: r.status >= 200 && r.status < 300,
61
66
  status: r.status,
62
- sha256: sha256(r.body),
67
+ sha256: r.sha256,
63
68
  isHtml: looksLikeHtml(r.body),
64
69
  };
65
70
  }
66
71
  function classify(path, r, shell) {
67
72
  if (!r)
68
73
  return { status: null, contentType: null, bytes: 0, verdict: 'MISSING', detail: 'fetch failed (host unreachable)' };
69
- const bytes = Buffer.byteLength(r.body);
70
- const base = { status: r.status, contentType: r.contentType, bytes };
74
+ const base = { status: r.status, contentType: r.contentType, bytes: r.bytes };
71
75
  // Honest 404/5xx - the file simply isn't there.
72
76
  if (r.status >= 400)
73
77
  return { ...base, verdict: 'MISSING', detail: `HTTP ${r.status}` };
74
78
  const expect = expectedType(path);
75
79
  // 1) Served the catch-all shell verbatim → 200, but the file isn't deployed.
76
- if (shell.served && shell.sha256 && sha256(r.body) === shell.sha256) {
80
+ if (shell.served && shell.sha256 && r.sha256 === shell.sha256) {
77
81
  return { ...base, verdict: 'MISSING', detail: `HTTP ${r.status} but served the SPA shell (file not deployed)` };
78
82
  }
79
83
  // 2) Asked for a non-HTML asset but got an HTML body → a per-path shell variant.
@@ -92,7 +96,7 @@ const TAG = {
92
96
  'WRONG-TYPE': warning('WRONG-TYPE'),
93
97
  };
94
98
  export const pageFetchCommand = new Command('fetch')
95
- .description('Verify deployed non-rendered files (llms.txt, AGENTS.md, robots.txt, served JSON) really exist - catches the static-host trap where a missing file returns 200 with the SPA shell instead of a 404')
99
+ .description('Verify deployed non-rendered files (llms.txt, AGENTS.md, robots.txt, served JSON...) really exist - catches the static-host trap where a missing file returns 200 with the SPA shell instead of a 404')
96
100
  .argument('<url>', 'Deployed app base URL; file paths resolve relative to it')
97
101
  .argument('<paths...>', 'One or more file paths to verify, e.g. llms.txt AGENTS.md robots.txt')
98
102
  .option('--json', 'Output as JSON')
@@ -26,10 +26,10 @@ function shortUrl(url, truncate = true, maxLen = 100) {
26
26
  }
27
27
  if (!truncate || result.length <= maxLen)
28
28
  return result;
29
- const keep = maxLen - 1;
29
+ const keep = maxLen - 3;
30
30
  const headLen = Math.ceil(keep / 2);
31
31
  const tailLen = Math.floor(keep / 2);
32
- return result.slice(0, headLen) + '' + result.slice(-tailLen);
32
+ return result.slice(0, headLen) + '...' + result.slice(-tailLen);
33
33
  }
34
34
  export const pageInspectCommand = new Command('inspect')
35
35
  .description('Inspect a web page (console, failed resources, timing, layout overflow). ONE passive load - to verify realtime/presence across concurrent clients use `page test --observe`')
@@ -235,8 +235,8 @@ export const pageScreenshotCommand = new Command('screenshot')
235
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.`)
236
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).`)
237
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.`)
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
+ .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.')
240
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.')
241
241
  .option('-o, --output <file>', 'Output path (single viewport only; default .gipity/screenshots/ss-<host>-<timestamp>.png)')
242
242
  .option('--no-reload-between', 'Skip reload between viewports (faster, lower fidelity - only safe for static pages)')
@@ -283,7 +283,7 @@ export const pageScreenshotCommand = new Command('screenshot')
283
283
  }
284
284
  catch {
285
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"
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
287
  : `Cannot read file: ${opts.file}`);
288
288
  }
289
289
  }
@@ -372,7 +372,7 @@ export const pageScreenshotCommand = new Command('screenshot')
372
372
  assertCameraFile(opts.camera);
373
373
  if (!projectGuid)
374
374
  throw new Error('--camera needs a linked project (the frame is hosted for the browser to fetch) — run `gipity link` first.');
375
- console.log(muted(`Hosting camera feed ${opts.camera}…`));
375
+ console.log(muted(`Hosting camera feed ${opts.camera}...`));
376
376
  camera = await uploadCameraFeed(projectGuid, opts.camera);
377
377
  }
378
378
  // The pre-capture script the server runs after the post-load delay: the
@@ -407,7 +407,7 @@ export const pageScreenshotCommand = new Command('screenshot')
407
407
  try {
408
408
  entries = opts.json
409
409
  ? await doShoot()
410
- : await withSpinner('Capturing', doShoot, { done: null });
410
+ : await withSpinner('Capturing...', doShoot, { done: null });
411
411
  }
412
412
  catch (err) {
413
413
  // The sandbox budget covers the WHOLE capture — load, --action, settle,
@@ -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)')
@@ -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')
@@ -57,9 +85,10 @@ recordsCommand
57
85
  .option('--limit <n>', 'Max rows', '20')
58
86
  .option('--offset <n>', 'Offset', '0')
59
87
  .option('--fields <fields>', 'Comma-separated column names')
88
+ .option(ANON_FLAG, ANON_HELP)
60
89
  .option('--json', 'Output as JSON')
61
90
  .action((table, opts) => run('Query', async () => {
62
- const config = requireConfig();
91
+ const api = await recordsHttp(opts);
63
92
  const params = new URLSearchParams();
64
93
  if (opts.filter)
65
94
  params.set('filter', opts.filter);
@@ -69,7 +98,7 @@ recordsCommand
69
98
  params.set('offset', opts.offset);
70
99
  if (opts.fields)
71
100
  params.set('fields', opts.fields);
72
- const res = await get(`/api/${config.projectGuid}/records/${table}?${params}`);
101
+ const res = await api.get(`/api/${api.guid}/records/${table}?${params}`);
73
102
  if (opts.json) {
74
103
  console.log(JSON.stringify(res));
75
104
  }
@@ -85,21 +114,26 @@ recordsCommand
85
114
  recordsCommand
86
115
  .command('get <table> <id>')
87
116
  .description('Get a record')
117
+ .option(ANON_FLAG, ANON_HELP)
88
118
  .option('--json', 'Output as JSON')
89
119
  .action((table, id, opts) => run('Get', async () => {
90
- const config = requireConfig();
91
- const res = await get(`/api/${config.projectGuid}/records/${table}/${id}`);
120
+ const api = await recordsHttp(opts);
121
+ const res = await api.get(`/api/${api.guid}/records/${table}/${id}`);
92
122
  console.log(opts.json ? JSON.stringify(res.data) : JSON.stringify(res.data, null, 2));
93
123
  }));
94
124
  recordsCommand
95
- .command('history <table> <id>')
96
- .description('Audit history for a record (who/what changed it, with English summaries)')
125
+ .command('history <table> [id]')
126
+ .description('Audit history (who/what changed it, with English summaries). Omit <id> for the whole table\'s feed.')
97
127
  .option('--limit <n>', 'Max events', '20')
128
+ .option(ANON_FLAG, ANON_HELP)
98
129
  .option('--json', 'Output as JSON')
99
130
  .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 => {
131
+ const api = await recordsHttp(opts);
132
+ // Table-wide feed when no id is given - the same endpoint an activity/history
133
+ // view reads, so verifying it needs no hand-built HTTP call.
134
+ const scope = id ? `${table}/${encodeURIComponent(id)}` : table;
135
+ const res = await api.get(`/api/${api.guid}/records/${scope}/history?limit=${encodeURIComponent(opts.limit)}`);
136
+ printList(res.data, opts, id ? 'No history for this record.' : `No history for "${table}".`, e => {
103
137
  const summary = e.detail?.summary || `${e.action} ${e.entity_type} ${e.entity_id}`;
104
138
  return `${muted(e.created_at)} ${bold(e.source || '-')} ${summary}`;
105
139
  });
@@ -108,34 +142,38 @@ recordsCommand
108
142
  .command('create <table>')
109
143
  .description('Create a record')
110
144
  .requiredOption('--data <json>', 'JSON object with field values')
145
+ .option(ANON_FLAG, ANON_HELP)
111
146
  .option('--json', 'Output as JSON')
112
147
  .action((table, opts) => run('Create', async () => {
113
- const config = requireConfig();
148
+ const api = await recordsHttp(opts);
114
149
  const data = JSON.parse(opts.data);
115
- const res = await post(`/api/${config.projectGuid}/records/${table}`, data);
150
+ const res = await api.post(`/api/${api.guid}/records/${table}`, data);
116
151
  printResult(`Created: ${JSON.stringify(res.data)}`, opts, res.data);
117
152
  }));
118
153
  recordsCommand
119
154
  .command('update <table> <id>')
120
155
  .description('Update a record')
121
156
  .requiredOption('--data <json>', 'JSON object with fields to update')
157
+ .option(ANON_FLAG, ANON_HELP)
122
158
  .option('--json', 'Output as JSON')
123
159
  .action((table, id, opts) => run('Update', async () => {
124
- const config = requireConfig();
160
+ const api = await recordsHttp(opts);
125
161
  const data = JSON.parse(opts.data);
126
- const res = await put(`/api/${config.projectGuid}/records/${table}/${id}`, data);
162
+ const res = await api.put(`/api/${api.guid}/records/${table}/${id}`, data);
127
163
  printResult(`Updated: ${JSON.stringify(res.data)}`, opts, res.data);
128
164
  }));
129
165
  recordsCommand
130
166
  .command('delete <table> <id>')
131
167
  .description('Delete a record')
132
- .action((table, id) => run('Delete', async () => {
168
+ .option(ANON_FLAG, ANON_HELP)
169
+ .option('--json', 'Output as JSON')
170
+ .action((table, id, opts) => run('Delete', async () => {
133
171
  if (!await confirm(`Delete record ${id} from "${table}"?`)) {
134
- console.log('Cancelled.');
172
+ printResult('Cancelled.', opts, { table, id, deleted: false, cancelled: true });
135
173
  return;
136
174
  }
137
- const config = requireConfig();
138
- await del(`/api/${config.projectGuid}/records/${table}/${id}`);
139
- printResult('Deleted.', { json: false });
175
+ const api = await recordsHttp(opts);
176
+ const res = await api.del(`/api/${api.guid}/records/${table}/${id}`);
177
+ printResult('Deleted.', opts, res?.data ?? { table, id, deleted: true });
140
178
  }));
141
179
  //# sourceMappingURL=records.js.map
@@ -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
@@ -464,7 +464,7 @@ GCC/Rust).
464
464
  });
465
465
  const res = opts.json
466
466
  ? await doRun()
467
- : await withSpinner('Running in sandbox', doRun, { done: null });
467
+ : await withSpinner('Running in sandbox...', doRun, { done: null });
468
468
  // Pull sandbox-written outputs down to the local cwd automatically. The
469
469
  // server has already mirrored them into the project (VFS) and handed back
470
470
  // the exact list, so honoring it here means files land locally without a
@@ -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,7 +21,7 @@ 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')
@@ -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.')}`);