gipity 1.0.384 → 1.0.387

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/api.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Readable } from 'stream';
2
2
  import * as tar from 'tar-stream';
3
3
  import { getAuth, refreshTokenIfNeeded } from './auth.js';
4
- import { getConfig, getApiBaseOverride, requireConfig, saveConfig } from './config.js';
4
+ import { resolveApiBase, requireConfig, saveConfig } from './config.js';
5
5
  export class ApiError extends Error {
6
6
  statusCode;
7
7
  code;
@@ -27,7 +27,7 @@ async function getHeaders() {
27
27
  };
28
28
  }
29
29
  function baseUrl() {
30
- return getApiBaseOverride() || getConfig()?.apiBase || 'https://a.gipity.ai';
30
+ return resolveApiBase();
31
31
  }
32
32
  /** Exposed so streaming consumers (SSE) can build URLs without re-implementing
33
33
  * the override / config resolution. */
@@ -101,6 +101,9 @@ export async function postForTarEntries(path, body) {
101
101
  export function put(path, body) {
102
102
  return request('PUT', path, body);
103
103
  }
104
+ export function patch(path, body) {
105
+ return request('PATCH', path, body);
106
+ }
104
107
  export function del(path, body) {
105
108
  return request('DELETE', path, body);
106
109
  }
package/dist/auth.js CHANGED
@@ -1,4 +1,4 @@
1
- import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'fs';
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, chmodSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { homedir } from 'os';
4
4
  import { decodeJwtExp } from './utils.js';
@@ -33,8 +33,16 @@ export function readAuthFresh() {
33
33
  }
34
34
  }
35
35
  export function saveAuth(data) {
36
- mkdirSync(AUTH_DIR, { recursive: true });
37
- writeFileSync(AUTH_FILE, JSON.stringify(data, null, 2));
36
+ // Lock down to owner-only: this file holds the account access + 7-day refresh
37
+ // tokens, so a default 0644/0755 would let any other local user read them.
38
+ // (The relay state file already does this; auth.json is the more sensitive of
39
+ // the two.) chmod after write to also tighten any pre-existing loose file.
40
+ mkdirSync(AUTH_DIR, { recursive: true, mode: 0o700 });
41
+ writeFileSync(AUTH_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
42
+ try {
43
+ chmodSync(AUTH_FILE, 0o600);
44
+ }
45
+ catch { /* best-effort on platforms without chmod */ }
38
46
  cached = data;
39
47
  }
40
48
  export function clearAuth() {
@@ -52,17 +60,18 @@ export function isExpired() {
52
60
  const buffer = 5 * 60 * 1000; // 5 minute buffer
53
61
  return Date.now() > expiresAt - buffer;
54
62
  }
55
- export function getTimeRemaining() {
63
+ /** True only when re-login is genuinely required: the refresh token itself
64
+ * has expired. Access-token expiry (`expiresAt` / isExpired) is invisible
65
+ * to users — every API call renews it via refreshTokenIfNeeded() — so it
66
+ * must never be surfaced as a session warning. */
67
+ export function sessionExpired() {
56
68
  const auth = getAuth();
57
69
  if (!auth)
58
- return 'not authenticated';
59
- const ms = new Date(auth.expiresAt).getTime() - Date.now();
60
- if (ms <= 0)
61
- return 'expired';
62
- const mins = Math.floor(ms / 60000);
63
- if (mins < 60)
64
- return `${mins}m remaining`;
65
- return `${Math.floor(mins / 60)}h ${mins % 60}m remaining`;
70
+ return true;
71
+ const exp = decodeJwtExp(auth.refreshToken);
72
+ if (!exp)
73
+ return false; // undecodable - let the refresh path decide
74
+ return Date.now() > exp * 1000;
66
75
  }
67
76
  export async function refreshTokenIfNeeded() {
68
77
  if (!isExpired())
@@ -72,7 +81,7 @@ export async function refreshTokenIfNeeded() {
72
81
  return; // not logged in, caller will handle
73
82
  try {
74
83
  const config = await import('./config.js');
75
- const apiBase = config.getApiBaseOverride() || config.getConfig()?.apiBase || 'https://a.gipity.ai';
84
+ const apiBase = config.resolveApiBase();
76
85
  const res = await fetch(`${apiBase}/auth/refresh`, {
77
86
  method: 'POST',
78
87
  headers: { 'Content-Type': 'application/json' },
@@ -179,7 +179,8 @@ export const addCommand = new Command('add')
179
179
  }
180
180
  const { name: labelName, files } = buildLocalPayload(resolved);
181
181
  const kind = sniffPayloadKind(files);
182
- console.log(muted(`Uploading ${files.length} file(s) from ${resolved} (${kind}) ...`));
182
+ // Progress goes to stderr so `gipity add --json` keeps stdout pure JSON.
183
+ console.error(muted(`Uploading ${files.length} file(s) from ${resolved} (${kind}) ...`));
183
184
  body = {
184
185
  name: labelName,
185
186
  title: opts.title,
@@ -19,7 +19,7 @@ function resolveCommand(cmd) {
19
19
  }
20
20
  import { getAuth, saveAuth, clearAuth } from '../auth.js';
21
21
  import { get, post, publicPost, ApiError, getAccountSlug } from '../api.js';
22
- import { getConfig, saveConfigAt, clearConfigCache, getApiBaseOverride, getConfigPath } from '../config.js';
22
+ import { getConfig, saveConfigAt, clearConfigCache, getApiBaseOverride, DEFAULT_API_BASE, getConfigPath } from '../config.js';
23
23
  import { sync } from '../sync.js';
24
24
  import { slugify, setupClaudeHooks, setupClaudeMd, setupAgentsMd, setupGitignore, DEFAULT_SYNC_IGNORE, isSyncIgnored } from '../setup.js';
25
25
  import { buildProjectContextBlock as buildProjectContextBlockText, buildNewProjectPrompt, buildResumeWrap, buildFreshWrap, } from '../prompts.js';
@@ -384,7 +384,7 @@ export const claudeCommand = new Command('claude')
384
384
  accountSlug,
385
385
  agentGuid,
386
386
  conversationGuid: null,
387
- apiBase: getApiBaseOverride() || 'https://a.gipity.ai',
387
+ apiBase: getApiBaseOverride() || DEFAULT_API_BASE,
388
388
  ignore: DEFAULT_SYNC_IGNORE,
389
389
  });
390
390
  console.log(`\n Using ${projectDir}`);
@@ -470,7 +470,7 @@ export const claudeCommand = new Command('claude')
470
470
  err?.code === 'ETIMEDOUT' || err?.cause?.code === 'ECONNREFUSED' ||
471
471
  err?.cause?.code === 'ENOTFOUND' || err?.cause?.code === 'ETIMEDOUT';
472
472
  if (isConnectionError) {
473
- const apiBase = getApiBaseOverride() || 'https://a.gipity.ai';
473
+ const apiBase = getApiBaseOverride() || DEFAULT_API_BASE;
474
474
  console.error(` ${clrError(`Could not connect to ${apiBase}`)}`);
475
475
  console.error(` ${muted('Check your connection and try again.')}`);
476
476
  process.exit(1);
@@ -554,7 +554,7 @@ export const claudeCommand = new Command('claude')
554
554
  accountSlug,
555
555
  agentGuid,
556
556
  conversationGuid: null,
557
- apiBase: getApiBaseOverride() || 'https://a.gipity.ai',
557
+ apiBase: getApiBaseOverride() || DEFAULT_API_BASE,
558
558
  ignore: DEFAULT_SYNC_IGNORE,
559
559
  });
560
560
  console.log(`\n Using ${projectDir}`);
@@ -91,7 +91,10 @@ dbCommand
91
91
  else {
92
92
  const config = requireConfig();
93
93
  const res = await get(`/projects/${config.projectGuid}/databases`);
94
- printList(res.data, opts, 'No databases. Create one: gipity db create <name>', db => db.friendlyName);
94
+ // This list is project-scoped; the account-wide database cap counts
95
+ // databases across ALL projects. An empty project can still be at the
96
+ // cap, so point at `--all` rather than implying nothing exists.
97
+ printList(res.data, opts, 'No databases in this project. Run `gipity db list --all` to see every database counting toward your account cap, or create one: gipity db create <name>', db => db.friendlyName);
95
98
  }
96
99
  }));
97
100
  dbCommand
@@ -20,7 +20,7 @@ export const deployCommand = new Command('deploy')
20
20
  .argument('[target]', 'dev or prod', 'dev')
21
21
  .option('--source-dir <dir>', 'Source directory to deploy from')
22
22
  .option('--only <phases>', 'Run only specific phases (comma-separated)')
23
- .option('--force', 'Re-run all phases, ignore checksums')
23
+ .option('--force', 'Re-run all phases (ignore checksums) and bypass the sync bulk-deletion guard')
24
24
  .option('--no-sync', 'Skip sync-up before deploy')
25
25
  .option('--optimize', 'Run build optimization')
26
26
  .option('--json', 'Output as JSON')
@@ -73,8 +73,20 @@ export const deployCommand = new Command('deploy')
73
73
  }
74
74
  }
75
75
  console.log(muted('─'.repeat(40)));
76
- const hasFailed = d.phases?.some(p => p.status === 'failed');
77
- if (hasFailed) {
76
+ const failedPhases = d.phases?.filter(p => p.status === 'failed') ?? [];
77
+ if (failedPhases.length > 0) {
78
+ // The database phase can fail on the account-wide database cap, whose
79
+ // server message ("Maximum of N databases reached. Drop one first.")
80
+ // names no command. The droppable databases live in OTHER projects, so
81
+ // the default project-scoped `gipity db list` shows nothing — point the
82
+ // caller straight at the account-wide list + drop path so they don't
83
+ // dead-end (or reach for raw DB access) to free a slot.
84
+ if (failedPhases.some(p => /databases? reached|database (cap|limit)/i.test(p.summary))) {
85
+ console.log('');
86
+ console.log(muted('Free a slot under the account database cap:'));
87
+ console.log(` ${brand('gipity db list --all')} ${muted('# every database counting toward the cap, by project')}`);
88
+ console.log(` ${brand('gipity db drop <name> --project <slug>')} ${muted('# drop one from another project')}`);
89
+ }
78
90
  console.log(clrError(`Deploy failed`) + muted(` (${d.elapsedMs}ms)`));
79
91
  process.exit(1);
80
92
  }
@@ -2,36 +2,38 @@ import { Command } from 'commander';
2
2
  import { basename, resolve, dirname } from 'path';
3
3
  import { existsSync } from 'fs';
4
4
  import { getAccountSlug } from '../api.js';
5
- import { getConfig, getConfigPath } from '../config.js';
5
+ import { getConfig, getConfigPath, saveConfigAt } from '../config.js';
6
6
  import { getAuth } from '../auth.js';
7
- import { slugify, setupClaudeHooks, setupGitignore, SUPPORTED_TOOLS } from '../setup.js';
7
+ import { slugify, setupClaudeHooks, setupGitignore, SUPPORTED_TOOLS, DEFAULT_TOOLS, DEFAULT_SYNC_IGNORE } from '../setup.js';
8
8
  import { success, error as clrError, info, muted, bold } from '../colors.js';
9
9
  import { confirm } from '../utils.js';
10
10
  import { scanForAdoption, adoptCurrentDir, canAdoptCwd, formatBytes, formatCwdLabel, ADOPT_THRESHOLDS, } from '../adopt-cwd.js';
11
11
  const TOOL_KEYS = SUPPORTED_TOOLS.map(t => t.key);
12
12
  function resolveTools(forFlag) {
13
- if (!forFlag || forFlag === 'all')
14
- return SUPPORTED_TOOLS;
13
+ if (!forFlag)
14
+ return DEFAULT_TOOLS;
15
15
  const requested = forFlag.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
16
16
  const unknown = requested.filter(k => !TOOL_KEYS.includes(k) && k !== 'all');
17
17
  if (unknown.length) {
18
18
  throw new Error(`Unknown --for value(s): ${unknown.join(', ')}. Valid: ${TOOL_KEYS.join(', ')}, all`);
19
19
  }
20
- if (requested.includes('all'))
21
- return SUPPORTED_TOOLS;
22
- return SUPPORTED_TOOLS.filter(t => requested.includes(t.key));
20
+ // `all` expands to the default set; an opt-in tool still joins when named
21
+ // alongside it (`--for all,aider`).
22
+ return SUPPORTED_TOOLS.filter(t => requested.includes(t.key) || (!t.optIn && requested.includes('all')));
23
23
  }
24
24
  export const initCommand = new Command('init')
25
25
  .description('Link this directory to a Gipity project (writes primer files so your AI coding tool understands Gipity)')
26
26
  .argument('[name]', 'Project name/slug (defaults to current directory name)')
27
27
  .option('--agent <guid>', 'Agent GUID to use')
28
- .option('--for <tools>', `Which AI tool primer files to write (comma-separated). Default: all. Choices: ${TOOL_KEYS.join(', ')}, all`)
28
+ .option('--for <tools>', `Which AI tool primer files to write (comma-separated). Default: all except aider (opt-in - it also writes .aider.conf.yml). Choices: ${TOOL_KEYS.join(', ')}, all`)
29
29
  .addHelpText('after', `
30
30
  Examples:
31
31
  $ gipity init Link cwd as a new project (slug = dir name).
32
32
  $ gipity init my-app Link cwd with an explicit slug.
33
33
  $ gipity init --for codex Write only AGENTS.md (skip Claude/Cursor/etc).
34
34
  $ gipity init --for cursor,gemini Write only the Cursor + Gemini primers.
35
+ $ gipity init --for aider AGENTS.md + a read: entry in .aider.conf.yml
36
+ (aider auto-reads nothing, so it's opt-in).
35
37
 
36
38
  Working with an existing Gipity project:
37
39
  - If cwd's name matches the remote project's slug, init auto-adopts it.
@@ -79,6 +81,17 @@ Working with an existing Gipity project:
79
81
  setupClaudeHooks();
80
82
  writeAllPrimers();
81
83
  setupGitignore();
84
+ // The config's ignore list was frozen at link time, so a workstation
85
+ // artifact introduced by a newer CLI (e.g. aider's .aider.conf.yml)
86
+ // would sync up as project content. Union in the current defaults.
87
+ if (existing) {
88
+ const cur = existing.ignore ?? (existing.ignore = []);
89
+ const missing = DEFAULT_SYNC_IGNORE.filter(e => !cur.includes(e));
90
+ if (missing.length) {
91
+ cur.push(...missing);
92
+ saveConfigAt(cwd, existing);
93
+ }
94
+ }
82
95
  console.log(success(`Refreshed primer files: ${primerSummary}.`));
83
96
  return;
84
97
  }
@@ -1,8 +1,66 @@
1
+ import { readFileSync } from 'node:fs';
1
2
  import { Command } from 'commander';
2
3
  import { post, get, ApiError } from '../api.js';
3
4
  import { brand, bold, muted, warning } from '../colors.js';
4
5
  import { run } from '../helpers/index.js';
6
+ import { resolveProjectContext } from '../config.js';
7
+ import { uploadPublicFixture, deleteFixture } from '../page-fixtures.js';
8
+ // Shown when an eval runs cleanly but returns nothing serializable. Turns a
9
+ // bare/opaque `null` into a deterministic, actionable nudge so the agent shapes
10
+ // a returnable value instead of guessing and retrying.
11
+ export const EVAL_NO_VALUE_HINT = 'The eval ran but returned no JSON-serializable value. A statement body with no `return`, an assignment, a void call, or a DOM node/function all serialize to null. ' +
12
+ 'End the script with an expression — or an explicit `return` — that yields plain data, e.g. `return { label: input.value, count: items.length }` or `return JSON.stringify(payload)`.';
13
+ /** Normalize a raw eval result for display. The eval can come back as a useful
14
+ * serialized value, the literal `null`/`undefined`/empty string, or — when the
15
+ * script returns undefined — agent-browser's raw envelope leaking through
16
+ * (`{"success":true,"data":{"origin":…,"result":null},"error":null}`). The last
17
+ * two mean the same thing to the agent: no value came back. Unwrap the leaked
18
+ * envelope so it never reaches the agent as an opaque blob, and flag the
19
+ * no-value cases so the caller can attach EVAL_NO_VALUE_HINT. */
20
+ export function normalizeEvalResult(raw) {
21
+ const trimmed = (raw ?? '').trim();
22
+ if (trimmed === '' || trimmed === 'null' || trimmed === 'undefined') {
23
+ return { result: trimmed, noValue: true };
24
+ }
25
+ // A leaked agent-browser eval envelope (only emitted when the eval returns
26
+ // undefined): unwrap to the inner value. Strict shape match — exact key set
27
+ // plus a string origin — so a genuine user object never trips this.
28
+ if (trimmed.startsWith('{') && trimmed.includes('"result"')) {
29
+ try {
30
+ const env = JSON.parse(trimmed);
31
+ const isEnvelope = env && typeof env === 'object'
32
+ && Object.keys(env).every((k) => k === 'success' || k === 'data' || k === 'error')
33
+ && env.data && typeof env.data === 'object'
34
+ && typeof env.data.origin === 'string' && 'result' in env.data;
35
+ if (isEnvelope) {
36
+ const inner = env.data.result;
37
+ if (inner == null)
38
+ return { result: 'null', noValue: true };
39
+ return { result: typeof inner === 'string' ? inner : JSON.stringify(inner), noValue: false };
40
+ }
41
+ }
42
+ catch { /* not the envelope — fall through and show the raw value */ }
43
+ }
44
+ return { result: raw, noValue: false };
45
+ }
5
46
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
47
+ // A single browser session is held open synchronously for the whole --wait, so
48
+ // the server caps it at the gateway idle timeout. Longer is impossible in one
49
+ // shot; watching an app past 30s means several windows, not one big wait.
50
+ export const MAX_WAIT_MS = 30_000;
51
+ /** Parse --wait (defaulting to 500ms), clamping to the per-call cap. When the
52
+ * caller asks for more than the cap, clamp and explain — to stderr, so --json
53
+ * stdout stays clean — and point at the windowed watch primitive instead of
54
+ * leaking the server's raw "Too big" validation error. */
55
+ export function capWaitMs(rawWait, url) {
56
+ const parsed = parseInt(rawWait, 10);
57
+ const wait = Number.isFinite(parsed) && parsed >= 0 ? parsed : 500;
58
+ if (wait <= MAX_WAIT_MS)
59
+ return wait;
60
+ console.error(warning(`--wait ${wait}ms exceeds the ${MAX_WAIT_MS}ms cap (one browser session is held open synchronously; longer trips the gateway timeout) — using ${MAX_WAIT_MS}ms. ` +
61
+ `To watch an app that keeps changing past 30s, cover the span with staggered windows in one command: gipity page test "${url}" --clients N --stagger S.`));
62
+ return MAX_WAIT_MS;
63
+ }
6
64
  /** Poll the async eval job until it finishes. Eval runs server-side as a
7
65
  * short-lived job (so a long --wait can't trip the gateway idle timeout);
8
66
  * we submit, then poll the result out of the job store. `expectedWorkMs` is
@@ -35,41 +93,137 @@ export async function pollEvalResult(evalJobId, expectedWorkMs) {
35
93
  }
36
94
  throw new ApiError(504, 'EVAL_TIMEOUT', 'Eval did not finish in time; narrow the expression or lower --wait');
37
95
  }
96
+ // The in-page execution budget for an eval body's OWN runtime (its `await`/
97
+ // `setTimeout` pauses), enforced by agent-browser's per-command CDP timeout
98
+ // (AGENT_BROWSER_DEFAULT_TIMEOUT) — distinct from --wait, which only sleeps
99
+ // BEFORE the eval. Used to translate the opaque timeout envelope into guidance.
100
+ const EVAL_EXEC_BUDGET_MS = 20_000;
101
+ /** When the eval body's own runtime overruns the in-page execution budget,
102
+ * agent-browser aborts the `Runtime.evaluate` CDP call and the failure comes
103
+ * back as a `{success:false, error:"CDP command timed out: Runtime.evaluate"}`
104
+ * envelope that the server surfaces verbatim as the eval `result` — opaque to
105
+ * the caller (no timeout named, no distinction from the page or --wait). Detect
106
+ * exactly that envelope and return an actionable message; null otherwise. */
107
+ export function evalExecTimeoutMessage(result) {
108
+ let parsed;
109
+ try {
110
+ parsed = JSON.parse(result);
111
+ }
112
+ catch {
113
+ return null;
114
+ }
115
+ if (!parsed || parsed.success !== false || typeof parsed.error !== 'string')
116
+ return null;
117
+ if (!/CDP command timed out:\s*Runtime\.evaluate/i.test(parsed.error))
118
+ return null;
119
+ return (`the expression hit the ~${EVAL_EXEC_BUDGET_MS / 1000}s in-page execution budget — the eval body ` +
120
+ `(including its own await/setTimeout pauses) ran longer than that. This budget is the time the ` +
121
+ `expression itself is allowed to run; it is separate from --wait, which only sleeps BEFORE the eval ` +
122
+ `and cannot extend it. Split a long interactive check into several shorter 'page eval' calls (e.g. ` +
123
+ `one per state to verify), keeping each body's in-page waits well under ${EVAL_EXEC_BUDGET_MS / 1000}s.`);
124
+ }
38
125
  // The long-tail escape hatch alongside `page inspect`'s fixed bundle: when the
39
126
  // curated metrics don't cover what you need (computed styles, element rects,
40
127
  // visibility, z-index stacks), eval an expression in page context and get the
41
128
  // serialized result back. Runs in the same browser sandbox as inspect.
129
+ //
130
+ // The body runs as an async function, so it can be an inline expression OR a
131
+ // multi-statement script with `return`/`await`. Pass a saved script with
132
+ // --file to functionally exercise a page's own code paths headlessly (drive
133
+ // tools, undo/redo, transforms) and `return` a JSON-serializable result —
134
+ // no /tmp + shell command-substitution harness needed.
42
135
  export const pageEvalCommand = new Command('eval')
43
- .description('Evaluate a JS expression in a real browser on a page (DOM, computed styles, element rects)')
136
+ .description('Evaluate JS in a real browser on a page (DOM, computed styles, element rects; inline expr or --file script)')
44
137
  .argument('<url>', 'URL to load')
45
- .argument('<expr>', 'JavaScript expression to evaluate in page context (result is JSON-serialized)')
46
- .option('--wait <ms>', 'Sleep this many ms after DOMContentLoaded before evaluating (lets late async work settle)', '500')
138
+ .argument('[expr]', 'JavaScript to evaluate in page context (inline expression or statement body with return/await; result is JSON-serialized). Omit when using --file.')
139
+ .option('--file <path>', 'Read the script body from a file instead of the inline <expr> arg (mutually exclusive). Runs as an async function body, so top-level return/await work.')
140
+ .option('--fixture <path>', 'Host a local file and expose it to the eval as `fixtureUrl` (and under `fixtures` by basename) to fetch in-page. For verifying a render/parse path against a real binary (an MP3, an image) - no size limit, auto-deleted after the run. Repeat for several files (single-value so it never swallows the inline <expr>).', (val, prev) => [...prev, val], [])
141
+ .option('--wait <ms>', 'Sleep this many ms after DOMContentLoaded before evaluating (lets late async work settle; max 30000)', '500')
47
142
  .option('--wait-for <selector>', 'Wait until this CSS selector appears before evaluating (deterministic; replaces --wait)')
48
143
  .option('--wait-timeout <ms>', 'Max ms to wait for --wait-for before giving up', '5000')
49
144
  .option('--json', 'Output as JSON')
50
- .action((url, expr, opts) => run('Page eval', async () => {
51
- const parsedWait = parseInt(opts.wait, 10);
52
- const waitMs = Number.isFinite(parsedWait) && parsedWait >= 0 ? parsedWait : 500;
145
+ .action((url, exprArg, opts) => run('Page eval', async () => {
146
+ // Arg-shape errors go through commander's error() so the enableHelpAfterError
147
+ // hook renders this command's help inline with the one-line error LAST
148
+ // (survives `| tail`), same as commander-detected errors like a missing url.
149
+ if (exprArg !== undefined && opts.file) {
150
+ pageEvalCommand.error('error: Pass either an inline <expr> arg or --file <path>, not both');
151
+ }
152
+ if (exprArg === undefined && !opts.file) {
153
+ pageEvalCommand.error('error: Provide an inline <expr> arg or --file <path>');
154
+ }
155
+ let expr = exprArg;
156
+ if (opts.file) {
157
+ try {
158
+ expr = readFileSync(opts.file, 'utf8');
159
+ }
160
+ catch {
161
+ pageEvalCommand.error(`error: Cannot read file: ${opts.file}`);
162
+ }
163
+ }
164
+ const waitMs = capWaitMs(opts.wait, url);
53
165
  const parsedTimeout = parseInt(opts.waitTimeout, 10);
54
166
  const waitForTimeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout >= 0 ? parsedTimeout : 5000;
55
- const kickoff = await post('/tools/browser/eval', {
56
- url, expr, waitMs,
57
- waitForSelector: opts.waitFor || undefined,
58
- waitForTimeoutMs: opts.waitFor ? waitForTimeoutMs : undefined,
59
- });
60
- const d = await pollEvalResult(kickoff.data.evalJobId, waitMs);
61
- if (opts.json) {
62
- console.log(JSON.stringify(d));
63
- return;
167
+ // --fixture: host each file publicly, then splice `fixtures` / `fixtureUrl`
168
+ // into the eval scope so the page can fetch the bytes. The prelude makes the
169
+ // body a statement (const/return), so the server's expression form fails to
170
+ // parse and it falls back to the function-body form - which runs both inline
171
+ // exprs (wrapped in `return (...)`) and --file scripts. Cleanup in `finally`.
172
+ const fixturePaths = opts.fixture ?? [];
173
+ const hosted = [];
174
+ let projectGuid;
175
+ let sentExpr = expr;
176
+ try {
177
+ if (fixturePaths.length) {
178
+ const { config } = await resolveProjectContext({});
179
+ projectGuid = config.projectGuid;
180
+ for (const p of fixturePaths) {
181
+ console.log(muted(`Hosting fixture ${p}…`));
182
+ hosted.push(await uploadPublicFixture(projectGuid, p));
183
+ }
184
+ const map = {};
185
+ for (const h of hosted)
186
+ map[h.name] = h.url;
187
+ const prelude = `const fixtures=${JSON.stringify(map)};const fixtureUrl=${JSON.stringify(hosted[0].url)};`;
188
+ sentExpr = opts.file ? `${prelude}\n${expr}` : `${prelude}\nreturn (${expr});`;
189
+ }
190
+ const kickoff = await post('/tools/browser/eval', {
191
+ url, expr: sentExpr, waitMs,
192
+ waitForSelector: opts.waitFor || undefined,
193
+ waitForTimeoutMs: opts.waitFor ? waitForTimeoutMs : undefined,
194
+ });
195
+ const d = await pollEvalResult(kickoff.data.evalJobId, waitMs);
196
+ const { result, noValue } = normalizeEvalResult(d.result);
197
+ const execTimeout = evalExecTimeoutMessage(d.result);
198
+ if (execTimeout)
199
+ throw new Error(execTimeout);
200
+ if (opts.json) {
201
+ console.log(JSON.stringify(noValue ? { ...d, result, hint: EVAL_NO_VALUE_HINT } : { ...d, result }));
202
+ return;
203
+ }
204
+ console.log(`${brand('Eval')} ${bold(d.url || url)}`);
205
+ if (d.navigationIncomplete) {
206
+ console.log(`${warning('⚠ Navigation incomplete:')} ${d.note || 'page did not reach full load'}`);
207
+ }
208
+ if (hosted.length)
209
+ console.log(`${muted('Fixtures:')} ${hosted.map((h) => h.name).join(', ')}`);
210
+ console.log(opts.file ? `${muted('Script:')} ${opts.file}` : `${muted('Expression:')} ${expr}`);
211
+ console.log(`\n${result.trim() ? result : muted('(empty result)')}`);
212
+ if (noValue)
213
+ console.log(muted(`\n${EVAL_NO_VALUE_HINT}`));
214
+ if (d.truncated)
215
+ console.log(muted('\n(result truncated to fit context - narrow the expression for the full value)'));
64
216
  }
65
- console.log(`${brand('Eval')} ${bold(d.url || url)}`);
66
- if (d.navigationIncomplete) {
67
- console.log(`${warning('⚠ Navigation incomplete:')} ${d.note || 'page did not reach full load'}`);
217
+ finally {
218
+ for (const h of hosted) {
219
+ try {
220
+ await deleteFixture(projectGuid, h.guid);
221
+ }
222
+ catch (err) {
223
+ console.error(warning(`⚠ Could not auto-delete fixture "${h.name}" (${h.guid}) — still hosted at ${h.url}: ${err.message}`));
224
+ }
225
+ }
68
226
  }
69
- console.log(`${muted('Expression:')} ${expr}`);
70
- console.log(`\n${d.result || muted('(empty result)')}`);
71
- if (d.truncated)
72
- console.log(muted('\n(result truncated to fit context - narrow the expression for the full value)'));
73
227
  }));
74
228
  // Each `page eval` call runs to completion before the next starts, so two evals
75
229
  // fired back-to-back never coexist in time - they CANNOT test whether two live
@@ -77,6 +231,21 @@ export const pageEvalCommand = new Command('eval')
77
231
  // concurrent `page test --observe` instead, which overlaps N clients and reports
78
232
  // whether they actually ran together.
79
233
  pageEvalCommand.addHelpText('after', `
234
+ Examples:
235
+ gipity page eval "https://dev.gipity.ai/me/app/" "document.title"
236
+ # Functionally test a page's own code paths: save a script that drives the UI
237
+ # and returns a JSON-serializable result, then run it (no /tmp + shell quoting):
238
+ gipity page eval "https://dev.gipity.ai/me/app/" --file ./tests/draw-flow.js --json
239
+ # Verify a render/parse path against a REAL file: --fixture hosts it, injects a
240
+ # fetch-able 'fixtureUrl', runs the eval, then deletes the hosted copy:
241
+ gipity page eval "https://dev.gipity.ai/me/app/" --fixture ./sample.mp3 \\
242
+ "(async()=>{ const b = await fetch(fixtureUrl).then(r=>r.arrayBuffer()); return window.App.parseId3(b); })()"
243
+
244
+ The eval body runs under a ~20s in-page execution budget (its own await/setTimeout
245
+ pauses count; --wait only sleeps BEFORE the eval and does not extend it). For a long
246
+ interactive sequence, split it into several shorter evals (one per state to verify)
247
+ rather than one body with many long waits.
248
+
80
249
  Testing realtime/shared state across clients?
81
250
  Separate 'page eval' calls run sequentially (one finishes before the next
82
251
  starts), so they never overlap and will each see only themselves - a false
@@ -3,6 +3,9 @@ import { post } from '../api.js';
3
3
  import { formatSize } from '../utils.js';
4
4
  import { brand, bold, error as clrError, warning, muted, info } from '../colors.js';
5
5
  import { run } from '../helpers/index.js';
6
+ import { capWaitMs } from './page-eval.js';
7
+ /** A console line is an error-level entry (page error or console.error). */
8
+ const isErrorLine = (line) => /^error:/i.test(line);
6
9
  function shortUrl(url, truncate = true, maxLen = 100) {
7
10
  let result;
8
11
  try {
@@ -22,7 +25,7 @@ function shortUrl(url, truncate = true, maxLen = 100) {
22
25
  export const pageInspectCommand = new Command('inspect')
23
26
  .description('Inspect a web page (console, failed resources, timing, layout overflow)')
24
27
  .argument('<url>', 'URL to inspect')
25
- .option('--wait <ms>', 'Sleep this many ms after DOMContentLoaded before capturing (lets late async/LCP work settle)', '500')
28
+ .option('--wait <ms>', 'Sleep this many ms after DOMContentLoaded before capturing (lets late async/LCP work settle; max 30000)', '500')
26
29
  .option('--wait-for <selector>', 'Wait until this CSS selector appears before capturing (deterministic; replaces --wait)')
27
30
  .option('--wait-timeout <ms>', 'Max ms to wait for --wait-for before giving up', '5000')
28
31
  .option('--json', 'Output as JSON')
@@ -39,21 +42,41 @@ export const pageInspectCommand = new Command('inspect')
39
42
  process.exit(1);
40
43
  }
41
44
  return run('Page inspect', async () => {
42
- const parsedWait = parseInt(opts.wait, 10);
43
- const waitMs = Number.isFinite(parsedWait) && parsedWait >= 0 ? parsedWait : 500;
45
+ const waitMs = capWaitMs(opts.wait, url);
44
46
  const parsedTimeout = parseInt(opts.waitTimeout, 10);
45
47
  const waitForTimeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout >= 0 ? parsedTimeout : 5000;
46
48
  const truncate = opts.truncate !== false;
47
49
  const showAll = opts.all === true;
48
- const res = await post(`/tools/browser/inspect`, {
50
+ const inspectBody = {
49
51
  url, waitMs,
50
52
  waitForSelector: opts.waitFor || undefined,
51
53
  waitForTimeoutMs: opts.waitFor ? waitForTimeoutMs : undefined,
52
54
  fakeMedia: opts.fakeMedia || undefined,
53
- });
55
+ };
56
+ const res = await post(`/tools/browser/inspect`, inspectBody);
54
57
  const b = res.data;
58
+ // Self-verify console errors before flagging them. A freshly-deployed page's
59
+ // first hit can throw a one-time, non-reproducible error — typically a
60
+ // cross-origin "Script error." with no message/stack from a CDN asset still
61
+ // propagating — and reporting it as a real defect sends agents chasing a
62
+ // phantom. So when the first probe reports error-level console lines, re-probe
63
+ // once (the sticky session is now warm) and keep only the errors that recur;
64
+ // errors seen on a single probe are surfaced separately as transient noise.
65
+ let transientErrors = [];
66
+ if ((b.console || []).some(isErrorLine)) {
67
+ try {
68
+ const verify = await post(`/tools/browser/inspect`, inspectBody);
69
+ const recurring = new Set((verify.data.console || []).filter(isErrorLine));
70
+ transientErrors = (b.console || []).filter((l) => isErrorLine(l) && !recurring.has(l));
71
+ b.console = (b.console || []).filter((l) => !isErrorLine(l) || recurring.has(l));
72
+ }
73
+ catch {
74
+ // Re-probe failed (timeout / browser error) — report the first probe's
75
+ // console as-is rather than hiding anything.
76
+ }
77
+ }
55
78
  if (opts.json) {
56
- console.log(JSON.stringify(b));
79
+ console.log(JSON.stringify(transientErrors.length ? { ...b, transientConsole: transientErrors } : b));
57
80
  return;
58
81
  }
59
82
  const timing = b.timing || { ttfb: 0, domReady: 0, load: 0 };
@@ -83,6 +106,14 @@ export const pageInspectCommand = new Command('inspect')
83
106
  else {
84
107
  console.log(`\n${bold('Console:')} ${muted('(clean)')}`);
85
108
  }
109
+ // ── Transient console errors (seen on first probe, gone on re-probe) ──
110
+ if (transientErrors.length > 0) {
111
+ console.log(`\n${bold('Transient console errors')} ${muted(`(${transientErrors.length}, not reproduced on re-probe)`)}:`);
112
+ for (const line of transientErrors) {
113
+ console.log(muted(line));
114
+ }
115
+ console.log(muted('One-time cold-load artifact (first hit of freshly-deployed assets, or a cross-origin script) — not reproducible, not in your app code. Ignore unless it recurs.'));
116
+ }
86
117
  // ── Failed Resources ──
87
118
  // Browsers auto-request /favicon.ico at the site root for every page, so a
88
119
  // 404 there isn't a resource the page actually links — it's noise on any
@@ -1,6 +1,6 @@
1
1
  import { Command, Option } from 'commander';
2
2
  import { mkdirSync, writeFileSync } from 'fs';
3
- import { join, resolve as resolvePath } from 'path';
3
+ import { dirname, join, resolve as resolvePath } from 'path';
4
4
  import { postForTarEntries } from '../api.js';
5
5
  import { getProjectRoot } from '../config.js';
6
6
  import { brand, bold, muted, success } from '../colors.js';
@@ -96,7 +96,10 @@ function appendOption(value, previous = []) {
96
96
  export const pageScreenshotCommand = new Command('screenshot')
97
97
  .description('Screenshot a web page')
98
98
  .argument('<url>', 'URL to screenshot')
99
- .option('--post-load-delay <ms>', 'Delay after DOMContentLoaded before capture, in ms', '1000')
99
+ // No commander default: a default here makes opts.postLoadDelay always set,
100
+ // so the `?? opts.wait` merge below would never see the --wait alias. Default
101
+ // is applied in the merge instead.
102
+ .option('--post-load-delay <ms>', 'Delay after DOMContentLoaded before capture, in ms (default: 1000)')
100
103
  .option('--full', 'Capture the full scrollable page (default: viewport only)')
101
104
  .option('-o, --output <file>', 'Output path (single viewport only; default .gipity/screenshots/ss-<host>-<timestamp>.png)')
102
105
  .option('--device <names>', `Viewport preset(s): ${Object.keys(DEVICE_PRESETS).join(', ')} (comma-separated or repeat flag)`, appendOption, [])
@@ -110,7 +113,10 @@ export const pageScreenshotCommand = new Command('screenshot')
110
113
  // rather than reject it as an unknown option and send them on a --help detour.
111
114
  .addOption(new Option('--full-page', 'Alias for --full').hideHelp())
112
115
  .action((url, opts) => run('Page screenshot', async () => {
113
- const delayRaw = opts.postLoadDelay ?? opts.wait;
116
+ // --wait is a hidden alias for --post-load-delay (agents reach for it because
117
+ // sibling `page inspect`/`eval` name the flag --wait). Canonical name wins if
118
+ // both given; fall back to the 1000ms default when neither is set.
119
+ const delayRaw = opts.postLoadDelay ?? opts.wait ?? '1000';
114
120
  const postLoadDelayMs = delayRaw !== undefined ? parseInt(String(delayRaw), 10) : undefined;
115
121
  if (postLoadDelayMs !== undefined && (!Number.isFinite(postLoadDelayMs) || postLoadDelayMs < 0)) {
116
122
  throw new Error('--post-load-delay must be a non-negative integer (ms)');
@@ -147,8 +153,6 @@ export const pageScreenshotCommand = new Command('screenshot')
147
153
  const slug = slugFromUrl(url);
148
154
  const ts = timestampSlug();
149
155
  const dir = defaultScreenshotDir();
150
- if (!opts.output)
151
- mkdirSync(dir, { recursive: true });
152
156
  const savedFiles = [];
153
157
  for (let i = 0; i < pngs.length; i++) {
154
158
  const shot = meta.screenshots[i];
@@ -156,6 +160,10 @@ export const pageScreenshotCommand = new Command('screenshot')
156
160
  const target = opts.output
157
161
  ? opts.output
158
162
  : join(dir, defaultFilename(slug, ts, suffix));
163
+ // Create the target's parent dir so a `-o` path under a not-yet-existing
164
+ // directory (e.g. .gipity/screenshots/home.png) writes cleanly instead of
165
+ // failing with a raw ENOENT and forcing a manual `mkdir -p`.
166
+ mkdirSync(dirname(target), { recursive: true });
159
167
  writeFileSync(target, pngs[i].buffer);
160
168
  // Absolute path so the agent knows exactly where the file landed.
161
169
  savedFiles.push(resolvePath(target));