gipity 1.0.424 → 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) {
@@ -206,6 +206,12 @@ export const addCommand = new Command('add')
206
206
  }
207
207
  // The server runs the whole install pipeline before responding; animate the
208
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
+ }
209
215
  const doAdd = () => post(`/projects/${config.projectGuid}/add`, body);
210
216
  const res = opts.json
211
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')
@@ -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
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.')
146
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')
@@ -186,6 +209,20 @@ export const pageEvalCommand = new Command('eval')
186
209
  pageEvalCommand.error(`error: Cannot read file: ${opts.file}`);
187
210
  }
188
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
+ }
189
226
  const waitMs = capWaitMs(opts.wait, url);
190
227
  const parsedTimeout = parseInt(opts.waitTimeout, 10);
191
228
  const waitForTimeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout >= 0 ? parsedTimeout : 5000;
@@ -214,31 +251,54 @@ export const pageEvalCommand = new Command('eval')
214
251
  }
215
252
  const kickoff = await post('/tools/browser/eval', {
216
253
  url, expr: sentExpr, waitMs,
254
+ reloadExpr,
217
255
  waitForSelector: opts.waitFor || undefined,
218
256
  waitForTimeoutMs: opts.waitFor ? waitForTimeoutMs : undefined,
219
257
  auth: opts.auth || undefined,
220
258
  });
221
- 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);
222
261
  const { result, noValue } = normalizeEvalResult(d.result);
262
+ const reload = d.reloadResult !== undefined ? normalizeEvalResult(d.reloadResult) : undefined;
223
263
  const execTimeout = evalExecTimeoutMessage(d.result);
224
264
  if (execTimeout)
225
265
  throw new Error(execTimeout);
226
266
  if (opts.json) {
227
- 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
+ }));
228
272
  return;
229
273
  }
230
274
  console.log(`${brand('Eval')} ${bold(d.url || url)}`);
231
275
  if (d.navigationIncomplete) {
232
276
  console.log(`${warning('⚠ Navigation incomplete:')} ${d.note || 'page did not reach full load'}`);
233
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
+ }
234
286
  if (hosted.length)
235
287
  console.log(`${muted('Fixtures:')} ${hosted.map((h) => h.name).join(', ')}`);
236
- console.log(opts.file ? `${muted('Script:')} ${opts.file}` : `${muted('Expression:')} ${expr}`);
288
+ console.log(opts.file ? `${muted('Script:')} ${opts.file}` : `${muted('Expression:')} ${summarizeExpr(expr)}`);
237
289
  console.log(`\n${result.trim() ? result : muted('(empty result)')}`);
238
290
  if (noValue)
239
291
  console.log(muted(`\n${EVAL_NO_VALUE_HINT}`));
240
292
  if (d.truncated)
241
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
+ }
242
302
  }
243
303
  finally {
244
304
  for (const h of hosted) {
@@ -271,6 +331,16 @@ Examples:
271
331
  # fetch-able 'fixtureUrl', runs the eval, then deletes the hosted copy:
272
332
  gipity page eval "https://dev.gipity.ai/me/app/" --fixture ./sample.mp3 \\
273
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.
274
344
 
275
345
  The eval body runs under a ~20s in-page execution budget (its own await/setTimeout
276
346
  pauses count; --wait only sleeps BEFORE the eval and does not extend it). For a long
@@ -32,7 +32,7 @@ function shortUrl(url, truncate = true, maxLen = 100) {
32
32
  return result.slice(0, headLen) + '…' + result.slice(-tailLen);
33
33
  }
34
34
  export const pageInspectCommand = new Command('inspect')
35
- .description('Inspect a web page (console, failed resources, timing, layout overflow)')
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`')
36
36
  .argument('<url>', 'URL to inspect')
37
37
  .option('--wait <ms>', 'Sleep this many ms after DOMContentLoaded before capturing (lets late async/LCP work settle; max 30000)', '500')
38
38
  .option('--wait-for <selector>', 'Wait until this CSS selector appears before capturing (deterministic; replaces --wait)')
@@ -3,7 +3,8 @@ import { mkdirSync, writeFileSync } from 'fs';
3
3
  import { dirname, join, resolve as resolvePath } from 'path';
4
4
  import { postForTarEntries } from '../api.js';
5
5
  import { getProjectRoot } from '../config.js';
6
- import { brand, bold, muted, success } from '../colors.js';
6
+ import { getAuth } from '../auth.js';
7
+ import { brand, bold, muted, success, warning } from '../colors.js';
7
8
  import { formatSize } from '../utils.js';
8
9
  import { run } from '../helpers/index.js';
9
10
  import { withSpinner } from '../progress.js';
@@ -28,6 +29,25 @@ function label(text) {
28
29
  function fmtMs(ms) {
29
30
  return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
30
31
  }
32
+ /** Auth state line (same format as page inspect/eval): without it an agent
33
+ * can't tell a signed-in capture from "--auth silently no-op'd and this is
34
+ * the anonymous page". */
35
+ function printAuthLine(auth) {
36
+ if (!auth?.requested)
37
+ return;
38
+ const who = getAuth()?.email;
39
+ console.log(auth.established
40
+ ? `${label('Auth')} ${success('session established')}${who ? muted(` as ${who}`) : ''} ${muted('(what the page renders with it is app-defined)')}`
41
+ : `${warning('Auth: session NOT established')}${auth.detail ? ` — ${auth.detail}` : ''} ${muted('(this is the anonymous view)')}`);
42
+ }
43
+ /** A failed --action still yields a screenshot - of the page the action never
44
+ * touched. Silence there is the trap: the image looks plausible and the command
45
+ * reports success, so the caller trusts a capture of the wrong state. Say so. */
46
+ function printActionErrorLine(actionError) {
47
+ if (!actionError)
48
+ return;
49
+ console.log(`${warning('⚠ --action failed:')} ${actionError} ${muted('(this image shows the page BEFORE the action ran)')}`);
50
+ }
31
51
  function fmtPerformance(p) {
32
52
  const parts = [
33
53
  `TTFB ${fmtMs(p.ttfb)}`,
@@ -137,7 +157,7 @@ export const pageScreenshotCommand = new Command('screenshot')
137
157
  // so the `?? opts.wait` merge below would never see the --wait alias. Default
138
158
  // is applied in the merge instead.
139
159
  .option('--post-load-delay <ms>', 'Delay after DOMContentLoaded before capture, in ms (default: 1000)')
140
- .option('--action <js>', 'Run JS in the page before capturing — e.g. click a button to enter a state ("document.getElementById(\'play\').click()"). Runs after the post-load delay, then settles again before the shot.')
160
+ .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.')
141
161
  .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.')
142
162
  .option('-o, --output <file>', 'Output path (single viewport only; default .gipity/screenshots/ss-<host>-<timestamp>.png)')
143
163
  .option('--device <names>', `Device preset(s): ${Object.keys(DEVICE_PRESETS).join(', ')} (comma-separated or repeat flag). mobile/tablet emulate a real touch device — touch events, mobile user-agent, DPR — so touch-gated mobile UI actually renders.`, appendOption, [])
@@ -223,6 +243,7 @@ export const pageScreenshotCommand = new Command('screenshot')
223
243
  final_url: meta.finalUrl,
224
244
  status: meta.status,
225
245
  performance: meta.performance,
246
+ ...(meta.auth ? { auth: meta.auth } : {}),
226
247
  },
227
248
  screenshots: meta.screenshots.map((s, i) => ({
228
249
  file: savedFiles[i],
@@ -253,6 +274,8 @@ export const pageScreenshotCommand = new Command('screenshot')
253
274
  console.log(`${label('Web page status')} ${meta.status}`);
254
275
  if (meta.performance)
255
276
  console.log(`${label('Web page perf')} ${fmtPerformance(meta.performance)}`);
277
+ printAuthLine(meta.auth);
278
+ printActionErrorLine(meta.actionError);
256
279
  if (s.viewport.device)
257
280
  console.log(`${label('Emulated device')} ${s.viewport.device} ${muted('(touch events, mobile user-agent)')}`);
258
281
  const sizePart = formatSize(s.screenshotSizeBytes) + (meta.full ? ' (full page)' : '');
@@ -271,6 +294,8 @@ export const pageScreenshotCommand = new Command('screenshot')
271
294
  console.log(`${label('Web page status')} ${meta.status}`);
272
295
  if (meta.performance)
273
296
  console.log(`${label('Web page perf')} ${fmtPerformance(meta.performance)}`);
297
+ printAuthLine(meta.auth);
298
+ printActionErrorLine(meta.actionError);
274
299
  for (let i = 0; i < meta.screenshots.length; i++) {
275
300
  const s = meta.screenshots[i];
276
301
  const dims = `${s.viewport.width}×${s.viewport.height}${s.viewport.deviceScaleFactor > 1 ? ` @${s.viewport.deviceScaleFactor}x` : ''}`;
@@ -10,7 +10,7 @@ import { pageFetchCommand } from './page-fetch.js';
10
10
  // top-level surface lean and makes the siblings discoverable via `page --help`.
11
11
  // `inspect` is the rendered DOM (browser); `fetch` is the raw asset (plain HTTP).
12
12
  export const pageCommand = new Command('page')
13
- .description('Inspect web pages')
13
+ .description('Inspect, drive, and test web pages in a real browser (page test = N concurrent clients for realtime/presence verification)')
14
14
  .addCommand(pageInspectCommand)
15
15
  .addCommand(pageEvalCommand)
16
16
  .addCommand(pageScreenshotCommand)
@@ -169,6 +169,11 @@ sandboxCommand
169
169
  .option('--file <path>', 'Read the code body from a file instead of the inline <code> arg; --language is inferred from the extension when not given')
170
170
  .option('--timeout <seconds>', 'Execution timeout in seconds', '30')
171
171
  .option('--input <path>', 'Narrow to specific project files instead of auto-mirroring the whole tree (repeatable). Use this only for >1 GB projects or when you want surgical control.', (v, prev) => [...(prev ?? []), v])
172
+ // Commander maps a `--no-` prefixed flag to the un-prefixed camelCase name
173
+ // (`opts.syncOutput`); the explicit `[]` default stops commander's negated-
174
+ // boolean convention from defaulting it to `true`. Collected as an array so
175
+ // the flag is repeatable.
176
+ .option('--no-sync-output <glob>', 'Do not persist run outputs matching this glob back to the project (repeatable). For byproducts you want to inspect once but never keep, e.g. --no-sync-output "docs/preview*". Supports *, **, and dir/ prefixes.', (v, prev) => [...prev, v], [])
172
177
  .option('--json', 'Output as JSON')
173
178
  .addHelpText('after', `
174
179
  By default the whole project is auto-mirrored into /work/ (up to 1 GB) -
@@ -263,6 +268,13 @@ GCC/Rust).
263
268
  // This runs BEFORE the project sync and the server round trip below, so a
264
269
  // missing language costs nothing but the message.
265
270
  const language = resolveLanguage({ langFromInterp, langOpt: opts.language, filePath, inlineCode });
271
+ // Validate --no-sync-output globs while we're still pre-network: an empty
272
+ // pattern (e.g. a quoting mishap) would silently match nothing server-side.
273
+ const noSyncOutput = opts.syncOutput ?? [];
274
+ if (noSyncOutput.some((g) => !g.trim())) {
275
+ console.error(clrError('--no-sync-output requires a non-empty glob (e.g. --no-sync-output "docs/preview*")'));
276
+ process.exit(1);
277
+ }
266
278
  // Args are good - now it's worth resolving (and announcing) the project.
267
279
  const { config } = await resolveProjectContext();
268
280
  const timeout = parseInt(opts.timeout, 10);
@@ -297,6 +309,10 @@ GCC/Rust).
297
309
  timeout: isNaN(timeout) ? 30 : timeout,
298
310
  input_files: opts.input,
299
311
  cwd,
312
+ // The filter must run SERVER-side (in the output extractor): skipping
313
+ // only in the CLI would still write the files into project storage and
314
+ // make every later sync propose deleting them.
315
+ noSyncOutput: noSyncOutput.length ? noSyncOutput : undefined,
300
316
  });
301
317
  const res = opts.json
302
318
  ? await doRun()
@@ -333,6 +349,11 @@ GCC/Rust).
333
349
  for (const f of res.data.outputFiles)
334
350
  console.log(`${f}`);
335
351
  }
352
+ if (res.data.skippedOutputFiles && res.data.skippedOutputFiles.length > 0) {
353
+ console.log(dim('\nNot persisted (--no-sync-output):'));
354
+ for (const f of res.data.skippedOutputFiles)
355
+ console.log(dim(`${f}`));
356
+ }
336
357
  if (res.data.exitCode !== 0) {
337
358
  // No "did you mean another language?" hint is needed: the language is now
338
359
  // always something the caller pinned, never a silent default we chose.
@@ -1,6 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import { get, post } from '../api.js';
3
- import { requireConfig } from '../config.js';
3
+ import { requireConfig, getProjectRoot } from '../config.js';
4
4
  import { success, error as clrError, warning, muted, bold, dim } from '../colors.js';
5
5
  import { run, syncBeforeAction } from '../helpers/index.js';
6
6
  // Absolute poll ceiling - the server reaps stalled runs (~65 min) well before
@@ -15,6 +15,22 @@ function statusIcon(status) {
15
15
  return muted('→');
16
16
  return muted('?');
17
17
  }
18
+ /** When the runner finds nothing, list the test-looking files it deliberately
19
+ * skipped (kit tests under src/packages/, .spec.* files) so "no tests" never
20
+ * contradicts what's on disk, and point at the sandbox as the way to run
21
+ * frontend/kit module tests (cli#120/cli#121). */
22
+ function printSkippedCandidates(skipped) {
23
+ if (skipped.length === 0)
24
+ return;
25
+ console.log('');
26
+ console.log(muted(`Found ${skipped.length} test-looking file${skipped.length === 1 ? '' : 's'} that gipity test does not run:`));
27
+ for (const s of skipped.slice(0, 10)) {
28
+ console.log(muted(` ${s.path} (${s.reason})`));
29
+ }
30
+ if (skipped.length > 10)
31
+ console.log(muted(` ... and ${skipped.length - 10} more`));
32
+ console.log(muted('Run frontend/kit module tests in the sandbox: gipity sandbox run bash "node <file>"'));
33
+ }
18
34
  // Long-run hint for non-TTY runs: after this, print a one-time "not hung" + faster-path hint.
19
35
  const LONG_RUN_MS = 60000;
20
36
  async function pollTestStatus(projectGuid, runGuid, opts) {
@@ -169,9 +185,21 @@ export const testCommand = new Command('test')
169
185
  process.exit(1);
170
186
  }
171
187
  if (!filterPath && data.total === 0 && data.results.length === 0) {
172
- console.log(muted('No tests ran - this app has no tests/*.test.js files.'));
188
+ // Name the searched root: discovery is server-side over the synced
189
+ // project tree's tests/, never the shell cwd - an agent running from a
190
+ // subdirectory must not read this as "the project has no tests" for
191
+ // the wrong reason (cli#120).
192
+ const root = getProjectRoot();
193
+ console.log(muted(`No tests ran - no tests/*.test.js files under the project root${root ? ` (${root})` : ''}.`));
173
194
  console.log(muted('gipity test runs server-function tests only; a frontend-only app (no functions/) has nothing here to run.'));
174
195
  console.log(muted('Verify a frontend app by driving the live page: gipity page inspect <url> (or gipity page eval).'));
196
+ // Enumerate test-looking files the runner deliberately skips (kit
197
+ // tests, .spec.*) so this answer never contradicts what's on disk.
198
+ try {
199
+ const listRes = await get(`/projects/${config.projectGuid}/test/list`);
200
+ printSkippedCandidates(listRes.data.skipped ?? []);
201
+ }
202
+ catch { /* the skipped list is advisory - never fail the run over it */ }
175
203
  return;
176
204
  }
177
205
  if (data.status === 'failed' && data.errorMessage) {
@@ -268,9 +296,17 @@ testCommand
268
296
  return;
269
297
  }
270
298
  if (total === 0) {
299
+ // Discovery is server-side over the synced project tree's tests/ -
300
+ // never the shell cwd. Say which root was searched so this can't be
301
+ // misread as a definitive project-level fact from a subdirectory
302
+ // (cli#120), and list any test-looking files deliberately skipped
303
+ // (kit tests, .spec.*) instead of asserting none exist (cli#121).
304
+ const root = getProjectRoot();
271
305
  console.log(muted(pathFilter
272
306
  ? `No test files matched filter: ${pathFilter}`
273
- : 'No test files found. Add *.test.js files under tests/.'));
307
+ : `No test files found under tests/ at the project root${root ? ` (${root})` : ''}. Add *.test.js files under tests/.`));
308
+ if (!pathFilter)
309
+ printSkippedCandidates(res.data.skipped ?? []);
274
310
  return;
275
311
  }
276
312
  console.log(bold(`Test files${pathFilter ? ` (filter: ${pathFilter})` : ''}: ${total}`));
@@ -29,6 +29,25 @@ function formatRunLine(r) {
29
29
  const statusColor = r.status === 'completed' ? success : r.status === 'failed' ? clrError : muted;
30
30
  return `${muted(r.short_guid)} ${statusColor(r.status)} ${dur} ${runTokens(r)} tokens ${muted(fmtTime(r.started_at))}`;
31
31
  }
32
+ /** Print each step's status, tokens, model, error and output. A run line alone
33
+ * says a run finished, not what it did — without the steps you can't tell a
34
+ * workflow that wrote a row from one that silently skipped every step. */
35
+ function printStepRuns(steps, emptyNote) {
36
+ if (steps.length === 0) {
37
+ console.log(` ${muted(emptyNote)}`);
38
+ return;
39
+ }
40
+ for (const s of steps) {
41
+ const statusColor = s.status === 'completed' ? success : s.status === 'failed' ? clrError : muted;
42
+ const model = s.model_used ? ` ${muted(`[${s.model_used}]`)}` : '';
43
+ console.log(` ${s.step_order}. ${statusColor(s.status)} ${s.tokens_used ?? 0} tokens${model}`);
44
+ if (s.error_message)
45
+ console.log(` ${clrError(s.error_message)}`);
46
+ if (s.output_json !== null && s.output_json !== undefined) {
47
+ console.log(JSON.stringify(s.output_json, null, 2).split('\n').map(l => ` ${l}`).join('\n'));
48
+ }
49
+ }
50
+ }
32
51
  const TERMINAL_RUN_STATUSES = new Set(['completed', 'failed', 'cancelled']);
33
52
  const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
34
53
  /**
@@ -155,6 +174,10 @@ workflowCommand
155
174
  console.log(formatRunLine(r));
156
175
  if (r.error_message)
157
176
  console.log(` ${clrError(r.error_message)}`);
177
+ // The whole point of --wait is to see what the run did. The detail endpoint
178
+ // we just polled already carries the steps, so show them rather than make
179
+ // the caller re-query the database to find out whether anything happened.
180
+ printStepRuns(r.step_runs ?? [], '(no steps recorded)');
158
181
  }
159
182
  if (r.status !== 'completed')
160
183
  process.exit(1);
@@ -176,22 +199,7 @@ workflowCommand
176
199
  return;
177
200
  }
178
201
  console.log(formatRunLine(r));
179
- const steps = r.step_runs ?? [];
180
- if (steps.length === 0) {
181
- console.log(' (no steps recorded)');
182
- return;
183
- }
184
- for (const s of steps) {
185
- const statusColor = s.status === 'completed' ? success : s.status === 'failed' ? clrError : muted;
186
- const model = s.model_used ? ` ${muted(`[${s.model_used}]`)}` : '';
187
- console.log(` ${s.step_order}. ${statusColor(s.status)} ${s.tokens_used ?? 0} tokens${model}`);
188
- if (s.error_message)
189
- console.log(` ${clrError(s.error_message)}`);
190
- if (s.output_json !== null && s.output_json !== undefined) {
191
- const pretty = JSON.stringify(s.output_json, null, 2).split('\n').map(l => ` ${l}`).join('\n');
192
- console.log(pretty);
193
- }
194
- }
202
+ printStepRuns(r.step_runs ?? [], '(no steps recorded)');
195
203
  return;
196
204
  }
197
205
  const res = await get(`/workflows/${wf.short_guid}/runs`);