gipity 1.0.422 → 1.0.425

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
@@ -353,12 +353,14 @@ export async function getAccountSlug() {
353
353
  accountSlugCache = res.data.accountSlug;
354
354
  return accountSlugCache;
355
355
  }
356
- /** Unauthenticated request (for login/verify) */
357
- export async function publicPost(path, body) {
356
+ /** Unauthenticated request (for login/verify, and anonymous-visitor calls like
357
+ * `fn call --anon`). extraHeaders lets callers attach non-auth headers such as
358
+ * X-App-Token; no Authorization header is ever sent. */
359
+ export async function publicPost(path, body, extraHeaders) {
358
360
  const url = `${baseUrl()}${path}`;
359
361
  const res = await fetch(url, {
360
362
  method: 'POST',
361
- headers: { ...clientHeaders(), 'Content-Type': 'application/json' },
363
+ headers: { ...clientHeaders(), 'Content-Type': 'application/json', ...extraHeaders },
362
364
  body: JSON.stringify(body),
363
365
  });
364
366
  if (!res.ok) {
@@ -5,7 +5,7 @@ import { Command } from 'commander';
5
5
  import { post } from '../api.js';
6
6
  import { requireConfig } from '../config.js';
7
7
  import { sync } from '../sync.js';
8
- import { success, muted, bold, warning } from '../colors.js';
8
+ import { success, muted, bold, warning, error as clrError } from '../colors.js';
9
9
  import { run } from '../helpers/index.js';
10
10
  import { createProgressReporter, withSpinner } from '../progress.js';
11
11
  const STARTERS = [
@@ -164,9 +164,14 @@ export const addCommand = new Command('add')
164
164
  }
165
165
  return;
166
166
  }
167
- // No name = show the full help (usage + options + catalog), same as --help.
167
+ // No name is a usage error, not a help request: error + help on STDERR and
168
+ // a nonzero exit. (It used to outputHelp() to stdout and exit 0, which an
169
+ // agent piping through `tail` reads as a successful add that printed
170
+ // help-shaped output.)
168
171
  if (!name) {
169
- command.outputHelp();
172
+ console.error(clrError('Missing template or kit name. Usage: gipity add <name> (see the catalog below, or `gipity add --list`)'));
173
+ command.outputHelp({ error: true });
174
+ process.exitCode = 1;
170
175
  return;
171
176
  }
172
177
  const config = requireConfig();
@@ -201,6 +206,12 @@ export const addCommand = new Command('add')
201
206
  }
202
207
  // The server runs the whole install pipeline before responding; animate the
203
208
  // wait, then clear the spinner so the installed-files list is the result.
209
+ // The spinner is TTY-only, so piped/agent transcripts would otherwise show
210
+ // a silent multi-second gap (favicon generation on a cold cache can take
211
+ // ~10s, cli#117) - leave one stderr marker so the wait reads as work.
212
+ if (!opts.json && !process.stdout.isTTY) {
213
+ console.error(muted('Installing (server writes files + generates favicons; first add for a title can take ~10s)...'));
214
+ }
204
215
  const doAdd = () => post(`/projects/${config.projectGuid}/add`, body);
205
216
  const res = opts.json
206
217
  ? await doAdd()
@@ -15,7 +15,9 @@ export const bugCommand = new Command('bug')
15
15
  `\nso the team can triage it into a fix.\n` +
16
16
  `\nCategories: ${CATEGORIES.join(', ')}` +
17
17
  `\nSeverity: ${SEVERITY_HINT}` +
18
- `\nNever include PII or user data — describe the platform problem in the abstract.`);
18
+ `\nNever include PII or user data — describe the platform problem in the abstract.` +
19
+ `\nFiled one by mistake? Withdraw it yourself with \`gipity bug retract <id>\`` +
20
+ `\n(works until a human picks it up) — don't file a second report asking for a close.`);
19
21
  bugCommand
20
22
  .command('report')
21
23
  .description('File a bug / friction report about the Gipity platform')
@@ -43,6 +45,24 @@ bugCommand
43
45
  }
44
46
  console.log(success(`✓ Bug report filed (${res.data.report_guid}) — queued for triage.`));
45
47
  }));
48
+ bugCommand
49
+ .command('retract <id>')
50
+ .description('Withdraw a bug report you filed (e.g. it was your own mistake)')
51
+ .option('--reason <text>', 'Short note for triage on why you are retracting')
52
+ .option('--project <guid-or-slug>', 'Resolve auth against a specific project instead of cwd / Home')
53
+ .option('--json', 'Output raw JSON')
54
+ .addHelpText('after', `\nOnly works on your own reports, and only while they are still queued` +
55
+ `\n(status new/triaged). Once a report is filed/fixed/dismissed, a human` +
56
+ `\nowns closing it out. Find report ids with \`gipity bug list\`.`)
57
+ .action((id, opts) => run('Bug report', async () => {
58
+ const { config } = await resolveProjectContext({ projectOverride: opts.project });
59
+ const res = await post(`/api/${config.projectGuid}/services/bug-report/retract`, { report_guid: id, reason: opts.reason });
60
+ if (opts.json) {
61
+ console.log(JSON.stringify(res.data));
62
+ return;
63
+ }
64
+ console.log(success(`✓ Bug report ${res.data.report_guid} retracted — it is out of the triage queue.`));
65
+ }));
46
66
  bugCommand
47
67
  .command('list')
48
68
  .description('List the bug / friction reports you have filed')
@@ -20,7 +20,25 @@ import { printBanner } from '../banner.js';
20
20
  import { scanForAdoption, isLikelyEmpty, canAdoptCwd, formatCwdLabel, formatBytes, adoptCurrentDir, ADOPT_THRESHOLDS, } from '../adopt-cwd.js';
21
21
  import { isClaudeInstalled, ensureClaudeInstalled, CLAUDE_PACKAGE } from '../claude-setup.js';
22
22
  const __clDir = dirname(fileURLToPath(import.meta.url));
23
- const __clPkg = JSON.parse(readFileSync(resolve(__clDir, '../../package.json'), 'utf-8'));
23
+ // Walk up to the nearest gipity package.json instead of a hardcoded '../..':
24
+ // the per-module tsc layout puts this file at dist/commands/, the bundled CLI
25
+ // at dist/, so a fixed depth breaks one of the two layouts.
26
+ function readOwnPkg(fromDir) {
27
+ for (let d = fromDir;; d = dirname(d)) {
28
+ const p = join(d, 'package.json');
29
+ if (existsSync(p)) {
30
+ try {
31
+ const pkg = JSON.parse(readFileSync(p, 'utf-8'));
32
+ if (pkg.name === 'gipity')
33
+ return pkg;
34
+ }
35
+ catch { /* keep walking */ }
36
+ }
37
+ if (dirname(d) === d)
38
+ return {};
39
+ }
40
+ }
41
+ const __clPkg = readOwnPkg(__clDir);
24
42
  /** Report a sync run to the user. Beyond the applied-changes line, this SURFACES
25
43
  * sync.errors - a download that came back incomplete (truncated bulk tar, a file
26
44
  * that couldn't be refetched) lands here, so we never print "ready" over a
@@ -292,7 +310,7 @@ export const claudeCommand = new Command('claude')
292
310
  process.exit(1);
293
311
  }
294
312
  if (!nonInteractive) {
295
- printBanner({ version: __clPkg.version, email: auth?.email, cwd: process.cwd() });
313
+ printBanner({ version: __clPkg.version ?? 'unknown', email: auth?.email, cwd: process.cwd() });
296
314
  }
297
315
  // A GIPITY_TOKEN env token authenticates every request on its own, so the
298
316
  // saved session's expiry is irrelevant when one is set — never re-login or
@@ -5,6 +5,7 @@ import { formatSize } from '../utils.js';
5
5
  import { success, error as clrError, warning, muted, bold, brand } from '../colors.js';
6
6
  import { run, syncBeforeAction } from '../helpers/index.js';
7
7
  import { withSpinner } from '../progress.js';
8
+ import { inspectPage } from './page-inspect.js';
8
9
  // ── Status icons ───────────────────────────────────────────────────────
9
10
  function statusIcon(status) {
10
11
  if (status === 'ok')
@@ -26,6 +27,7 @@ export const deployCommand = new Command('deploy')
26
27
  .option('--optimize', 'Force Vite build optimization on (default for prod; use this to optimize a dev deploy too)')
27
28
  .option('--no-optimize', 'Skip build optimization and upload files as-is - the escape hatch for plain-HTML apps whose <script src> tags are not type="module"')
28
29
  .option('--json', 'Output as JSON')
30
+ .option('--inspect [path]', 'After a successful deploy, run `page inspect` on the deployed URL (or URL + path) in the same command - one build-loop step instead of two. With --json, emits two JSON lines: deploy, then inspect.')
29
31
  .action((target, opts) => run('Deploy', async () => {
30
32
  if (target !== 'dev' && target !== 'prod') {
31
33
  console.error(clrError('Target must be "dev" or "prod"'));
@@ -44,8 +46,32 @@ export const deployCommand = new Command('deploy')
44
46
  ? await doDeploy()
45
47
  : await withSpinner(`Deploying to ${target}…`, doDeploy, { done: null });
46
48
  const d = res.data;
49
+ // Deploy + verify in one command: after a successful deploy, run the
50
+ // same inspect pipeline `page inspect` uses against the live URL. An
51
+ // inspect failure is reported but never turns a successful deploy into
52
+ // a nonzero exit - the deploy DID land.
53
+ const inspectAfter = async () => {
54
+ if (!opts.inspect)
55
+ return;
56
+ if (!d.url) {
57
+ console.error(warning('--inspect skipped: this deploy produced no URL (e.g. --only database)'));
58
+ return;
59
+ }
60
+ const url = typeof opts.inspect === 'string' ? new URL(opts.inspect, d.url).toString() : d.url;
61
+ if (!opts.json)
62
+ console.log('');
63
+ try {
64
+ await inspectPage(url, { json: opts.json });
65
+ }
66
+ catch (err) {
67
+ console.error(warning(`Inspect failed (deploy itself succeeded): ${err?.message ?? err}`));
68
+ }
69
+ };
47
70
  if (opts.json) {
48
71
  console.log(JSON.stringify(d));
72
+ const failedJson = d.phases?.some(p => p.status === 'failed');
73
+ if (!failedJson)
74
+ await inspectAfter();
49
75
  return;
50
76
  }
51
77
  // Format output
@@ -54,7 +80,8 @@ export const deployCommand = new Command('deploy')
54
80
  console.log(muted('─'.repeat(40)));
55
81
  if (d.phases && d.phases.length > 0) {
56
82
  for (const phase of d.phases) {
57
- console.log(`${statusIcon(phase.status)} ${bold(phase.name)}: ${phase.summary}`);
83
+ const ms = phase.elapsedMs != null ? muted(` (${phase.elapsedMs}ms)`) : '';
84
+ console.log(`${statusIcon(phase.status)} ${bold(phase.name)}: ${phase.summary}${ms}`);
58
85
  }
59
86
  }
60
87
  else {
@@ -101,6 +128,7 @@ export const deployCommand = new Command('deploy')
101
128
  // has to reconstruct the URL convention or guess a subdomain.
102
129
  if (d.url)
103
130
  console.log(`${muted('Live:')} ${brand(d.url)}`);
131
+ await inspectAfter();
104
132
  }
105
133
  }));
106
134
  //# sourceMappingURL=deploy.js.map
@@ -1,5 +1,5 @@
1
1
  import { Command } from 'commander';
2
- import { get, post, del } from '../api.js';
2
+ import { get, post, del, publicPost } from '../api.js';
3
3
  import { requireConfig } from '../config.js';
4
4
  import { error as clrError, bold, muted, success, warning } from '../colors.js';
5
5
  import { run, printList, emitField } from '../helpers/index.js';
@@ -42,17 +42,36 @@ fnCommand
42
42
  return line;
43
43
  });
44
44
  }));
45
+ /** Invoke a function exactly like an anonymous visitor: mint the same
46
+ * short-lived public app token the browser SDK uses (best-effort — public
47
+ * functions run with no token at all), POST with no user credentials, and
48
+ * return the standard envelope. A 401 here is the correct answer for an
49
+ * auth-gated function — the server's message is surfaced as-is, with no
50
+ * "run: gipity login" misdirection. */
51
+ async function callAnon(projectGuid, name, body) {
52
+ let appToken;
53
+ try {
54
+ const minted = await publicPost('/api/token', { app: projectGuid });
55
+ appToken = minted.data.token;
56
+ }
57
+ catch { /* public functions work without a token; auth-gated ones will 401 with the real reason */ }
58
+ return publicPost(`/api/${projectGuid}/fn/${encodeURIComponent(name)}`, body, appToken ? { 'X-App-Token': appToken } : undefined);
59
+ }
45
60
  fnCommand
46
61
  .command('call <name> [body]')
47
62
  .description('Call a function')
48
63
  .option('--data <json>', 'JSON request body')
64
+ .option('--anon', 'Call as an anonymous visitor (the public path a signed-out user hits) instead of as your signed-in account')
49
65
  .option('--field <path>', 'Print only this field of the result (dot path, e.g. items.0.short_guid)')
50
66
  .option('--json', 'Output as JSON')
51
67
  .action((name, bodyArg, opts) => run('Call', async () => {
52
68
  const config = requireConfig();
53
69
  const raw = bodyArg || opts.data || '{}';
54
70
  const body = JSON.parse(raw);
55
- const res = await post(`/api/${config.projectGuid}/fn/${encodeURIComponent(name)}`, body);
71
+ const path = `/api/${config.projectGuid}/fn/${encodeURIComponent(name)}`;
72
+ const res = opts.anon
73
+ ? await callAnon(config.projectGuid, name, body)
74
+ : await post(path, body);
56
75
  if (opts.field) {
57
76
  emitField(res.data, opts.field);
58
77
  return;
@@ -1,8 +1,9 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { Command, Option } from 'commander';
3
3
  import { post, get, ApiError } from '../api.js';
4
- import { brand, bold, muted, warning } from '../colors.js';
4
+ import { brand, bold, muted, warning, success } from '../colors.js';
5
5
  import { run } from '../helpers/index.js';
6
+ import { getAuth } from '../auth.js';
6
7
  import { resolveProjectContext } from '../config.js';
7
8
  import { uploadPublicFixture, deleteFixture } from '../page-fixtures.js';
8
9
  // Shown when an eval runs cleanly but returns nothing serializable. Turns a
@@ -43,6 +44,26 @@ export function normalizeEvalResult(raw) {
43
44
  }
44
45
  return { result: raw, noValue: false };
45
46
  }
47
+ // A one-line inline expr is worth echoing back — it's the thing you're asserting
48
+ // on. A multi-line driver script is not: echoing 30 lines of the caller's own
49
+ // source above the result buries the value, and an echo that lands right before
50
+ // `(empty result)` reads like the parser choked on the script rather than like
51
+ // the script returned nothing. Collapse anything bigger than a single short line
52
+ // to its first line plus a shape summary.
53
+ const EXPR_ECHO_MAX_CHARS = 120;
54
+ export function summarizeExpr(expr) {
55
+ const lines = expr.split('\n');
56
+ const meaningful = lines.filter((l) => l.trim() !== '');
57
+ const oneLine = meaningful.length <= 1;
58
+ if (oneLine && expr.trim().length <= EXPR_ECHO_MAX_CHARS)
59
+ return expr.trim();
60
+ const first = (meaningful[0] ?? '').trim();
61
+ const head = first.length > EXPR_ECHO_MAX_CHARS ? `${first.slice(0, EXPR_ECHO_MAX_CHARS - 1)}…` : first;
62
+ const shape = oneLine
63
+ ? `(${expr.trim().length} chars)`
64
+ : `(+${meaningful.length - 1} more ${meaningful.length - 1 === 1 ? 'line' : 'lines'}, ${expr.trim().length} chars)`;
65
+ return `${head} ${shape}`;
66
+ }
46
67
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
47
68
  // A single browser session is held open synchronously for the whole --wait, so
48
69
  // the server caps it at the gateway idle timeout. Longer is impossible in one
@@ -140,11 +161,13 @@ const JS_DECOY_FLAGS = ['--js', '--javascript', '--script', '--code', '--expr',
140
161
  // tools, undo/redo, transforms) and `return` a JSON-serializable result —
141
162
  // no /tmp + shell command-substitution harness needed.
142
163
  export const pageEvalCommand = new Command('eval')
143
- .description('Evaluate JS in a real browser on a page (DOM, computed styles, element rects; inline expr or --file script)')
164
+ .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')
144
165
  .argument('<url>', 'URL to load')
145
- .argument('[expr]', 'JavaScript to evaluate in page context (inline expression or statement body with return/await; result is JSON-serialized). Omit when using --file.')
146
- .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.')
166
+ .argument('[expr]', 'JavaScript to evaluate in page context (inline expression or statement body with return/await; result is JSON-serialized). Omit when using --file. Time budget: the body has ~20s to finish after page load - keep driver scripts within it.')
167
+ .option('--file <path>', 'Read the script body from a file instead of the inline <expr> arg (mutually exclusive). Runs as an async function body, so top-level return/await work. Same ~20s post-load budget as <expr>.')
147
168
  .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], [])
169
+ .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.')
170
+ .option('--reload-file <path>', 'Read the post-reload expression from a file instead of inline --reload (mutually exclusive)')
148
171
  .option('--wait <ms>', 'Sleep this many ms after DOMContentLoaded before evaluating (lets late async work settle; max 30000)', '500')
149
172
  .option('--wait-for <selector>', 'Wait until this CSS selector appears before evaluating (deterministic; replaces --wait)')
150
173
  .option('--wait-timeout <ms>', 'Max ms to wait for --wait-for before giving up', '5000')
@@ -167,6 +190,16 @@ export const pageEvalCommand = new Command('eval')
167
190
  if (exprArg === undefined && !opts.file) {
168
191
  pageEvalCommand.error('error: Provide an inline <expr> arg or --file <path>');
169
192
  }
193
+ // Catch a swapped <url>/<expr> locally: the server's bare "Invalid URL"
194
+ // names neither the bad argument nor the expected order, so it reads like
195
+ // the page failed to evaluate rather than like a mis-invocation.
196
+ if (!/^https?:\/\//i.test(url)) {
197
+ const flatUrl = url.replace(/\s+/g, ' ').trim();
198
+ const shownUrl = flatUrl.length > 60 ? `${flatUrl.slice(0, 57)}...` : flatUrl;
199
+ pageEvalCommand.error(exprArg !== undefined && /^https?:\/\//i.test(exprArg)
200
+ ? `error: arguments are swapped — the URL is the FIRST positional: gipity page eval "${exprArg}" '<expr>'`
201
+ : `error: <url> must be an absolute http(s) URL (got: "${shownUrl}") — usage: gipity page eval <url> [expr]`);
202
+ }
170
203
  let expr = exprArg;
171
204
  if (opts.file) {
172
205
  try {
@@ -176,6 +209,20 @@ export const pageEvalCommand = new Command('eval')
176
209
  pageEvalCommand.error(`error: Cannot read file: ${opts.file}`);
177
210
  }
178
211
  }
212
+ // Post-reload expression: inline --reload or --reload-file, same shape
213
+ // rules as the primary <expr>/--file pair.
214
+ if (opts.reload !== undefined && opts.reloadFile) {
215
+ pageEvalCommand.error('error: Pass either --reload <expr> or --reload-file <path>, not both');
216
+ }
217
+ let reloadExpr = opts.reload;
218
+ if (opts.reloadFile) {
219
+ try {
220
+ reloadExpr = readFileSync(opts.reloadFile, 'utf8');
221
+ }
222
+ catch {
223
+ pageEvalCommand.error(`error: Cannot read file: ${opts.reloadFile}`);
224
+ }
225
+ }
179
226
  const waitMs = capWaitMs(opts.wait, url);
180
227
  const parsedTimeout = parseInt(opts.waitTimeout, 10);
181
228
  const waitForTimeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout >= 0 ? parsedTimeout : 5000;
@@ -204,31 +251,54 @@ export const pageEvalCommand = new Command('eval')
204
251
  }
205
252
  const kickoff = await post('/tools/browser/eval', {
206
253
  url, expr: sentExpr, waitMs,
254
+ reloadExpr,
207
255
  waitForSelector: opts.waitFor || undefined,
208
256
  waitForTimeoutMs: opts.waitFor ? waitForTimeoutMs : undefined,
209
257
  auth: opts.auth || undefined,
210
258
  });
211
- const d = await pollEvalResult(kickoff.data.evalJobId, waitMs);
259
+ // The reload leg re-runs the settle before its eval — budget for both.
260
+ const d = await pollEvalResult(kickoff.data.evalJobId, reloadExpr !== undefined ? waitMs * 2 : waitMs);
212
261
  const { result, noValue } = normalizeEvalResult(d.result);
262
+ const reload = d.reloadResult !== undefined ? normalizeEvalResult(d.reloadResult) : undefined;
213
263
  const execTimeout = evalExecTimeoutMessage(d.result);
214
264
  if (execTimeout)
215
265
  throw new Error(execTimeout);
216
266
  if (opts.json) {
217
- console.log(JSON.stringify(noValue ? { ...d, result, hint: EVAL_NO_VALUE_HINT } : { ...d, result }));
267
+ console.log(JSON.stringify({
268
+ ...d, result,
269
+ ...(reload ? { reloadResult: reload.result } : {}),
270
+ ...(noValue ? { hint: EVAL_NO_VALUE_HINT } : {}),
271
+ }));
218
272
  return;
219
273
  }
220
274
  console.log(`${brand('Eval')} ${bold(d.url || url)}`);
221
275
  if (d.navigationIncomplete) {
222
276
  console.log(`${warning('⚠ Navigation incomplete:')} ${d.note || 'page did not reach full load'}`);
223
277
  }
278
+ // Auth state: without this line an agent can't distinguish "signed-in
279
+ // eval" from "--auth silently no-op'd against the anonymous page".
280
+ if (d.auth?.requested) {
281
+ const who = getAuth()?.email;
282
+ console.log(d.auth.established
283
+ ? `${muted('Auth:')} ${success('session established')}${who ? muted(` as ${who}`) : ''} ${muted('(what the page renders with it is app-defined)')}`
284
+ : `${warning('Auth: session NOT established')}${d.auth.detail ? ` — ${d.auth.detail}` : ''} ${muted('(this is the anonymous view)')}`);
285
+ }
224
286
  if (hosted.length)
225
287
  console.log(`${muted('Fixtures:')} ${hosted.map((h) => h.name).join(', ')}`);
226
- console.log(opts.file ? `${muted('Script:')} ${opts.file}` : `${muted('Expression:')} ${expr}`);
288
+ console.log(opts.file ? `${muted('Script:')} ${opts.file}` : `${muted('Expression:')} ${summarizeExpr(expr)}`);
227
289
  console.log(`\n${result.trim() ? result : muted('(empty result)')}`);
228
290
  if (noValue)
229
291
  console.log(muted(`\n${EVAL_NO_VALUE_HINT}`));
230
292
  if (d.truncated)
231
293
  console.log(muted('\n(result truncated to fit context - narrow the expression for the full value)'));
294
+ if (reload) {
295
+ console.log(`\n${bold('After reload')} ${muted('(page reloaded in place — storage preserved)')}`);
296
+ console.log(reload.result.trim() ? reload.result : muted('(empty result)'));
297
+ if (reload.noValue)
298
+ console.log(muted(EVAL_NO_VALUE_HINT));
299
+ if (d.reloadTruncated)
300
+ console.log(muted('(reload result truncated to fit context - narrow the expression for the full value)'));
301
+ }
232
302
  }
233
303
  finally {
234
304
  for (const h of hosted) {
@@ -261,6 +331,16 @@ Examples:
261
331
  # fetch-able 'fixtureUrl', runs the eval, then deletes the hosted copy:
262
332
  gipity page eval "https://dev.gipity.ai/me/app/" --fixture ./sample.mp3 \\
263
333
  "(async()=>{ const b = await fetch(fixtureUrl).then(r=>r.arrayBuffer()); return window.App.parseId3(b); })()"
334
+ # Verify persisted state survives a reload (localStorage/sessionStorage kept):
335
+ # run <expr>, reload the page in place, then run the --reload expression:
336
+ gipity page eval "https://dev.gipity.ai/me/app/" \\
337
+ "localStorage.setItem('todo','milk'); document.title" \\
338
+ --reload "({ restored: localStorage.getItem('todo'), heading: document.querySelector('h1')?.textContent })"
339
+
340
+ Module resolution: dynamic import() specifiers starting with ./ or ../ resolve
341
+ against the PAGE URL, so import('./packages/i18n/index.js') loads the app's own
342
+ module without hand-building the deployed /account/project/ path. Absolute paths
343
+ and full URLs pass through unchanged.
264
344
 
265
345
  The eval body runs under a ~20s in-page execution budget (its own await/setTimeout
266
346
  pauses count; --wait only sleeps BEFORE the eval and does not extend it). For a long