@bridgeline-digital/hawk-app-cli 0.1.1-dev.20261026194933 → 0.1.1

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/README.md CHANGED
@@ -315,6 +315,7 @@ npx hawk-app errors --explain 3fa1c2d9 # one error's brief, then Claude's like
315
315
  npx hawk-app usage # the last 30 days (--days 1-400): opens, view opens, users and clients; staff apart
316
316
  npx hawk-app usage --screens # screens and actions: the most used, then the least used, with the 3 newest versions
317
317
  npx hawk-app usage --screens --version 4 # one version's counts
318
+ npx hawk-app usage --speed # each screen's data time (median, 90%) beside the version before, slower ones flagged; the slowest calls
318
319
  ```
319
320
 
320
321
  **What people use.** Screens are the app's routes (ids as `:id`), its views, and what it names with `app.usage.screen`
@@ -340,6 +341,38 @@ It needs the version's **source maps**: the templates build them (`productionBro
340
341
  platform's private bucket instead of the CDN, so your source is never public. Without maps the stack stays minified and
341
342
  the brief says so. Errors of versions deployed before keep their minified stacks; the next deploy maps the new ones.
342
343
 
344
+ ## Problems users report (`hawk-app problems`)
345
+
346
+ Users report what went wrong with the flag icon in the app's bar in HawkSearch: their words, a picture of the page,
347
+ what the page showed, the version, and a recording if they agreed to one (platform/docs/problems.md).
348
+
349
+ ```bash
350
+ npx hawk-app problems # the newest first: id, when, client, version, page, what came with it
351
+ npx hawk-app problems show pk7m2n9x4q # the words, a 5-minute link to the picture, the page context, the recording
352
+ ```
353
+
354
+ The client's data: Bridgeline users only (`hawk-app sso`), kept 30 days, nothing written to disk. Declare and set
355
+ `reportEmails` (`{ "type": "emails" }`) to get each report by email.
356
+
357
+ ## Replay a client's problem (`hawk-app dev --replay`)
358
+
359
+ "It shows nothing for client X": a Bridgeline user opens the app as X in HawkSearch and turns on **Record this
360
+ session** (the review bar's ⋮ menu). For up to 30 minutes the platform keeps every call the app makes in their session,
361
+ with its answer (platform/docs/recordings.md). Then:
362
+
363
+ ```bash
364
+ npx hawk-app recordings # the app's recorded sessions (kept 7 days): id, who, client, calls, failed calls
365
+ npx hawk-app dev --replay r7k2m9xq4p # your local code, answered as it was for that client
366
+ ```
367
+
368
+ - `dev --replay` runs as the recorded client and site. Each call gets the recorded answer (the same method, path and
369
+ body; else the same path; else the same route with other query values), so the page behaves as it did, and you fix
370
+ it against the real case. A call the session never made answers 501 `not_recorded` (the terminal lists them); the
371
+ AI stream isn't recorded. The errors the page reported then are listed at start.
372
+ - Nothing reaches the platform, and the session's date range is used.
373
+ - **The client's data:** Bridgeline users only (`hawk-app sso` keys; others get 403). The recording stays in memory
374
+ (never on disk), is gone when you stop, and is never given to an AI assistant. Don't share screenshots of it.
375
+
343
376
  ## Accessibility check
344
377
 
345
378
  `hawk-app deploy` runs axe-core (WCAG 2.1 A/AA and best practices) over up to 10 built pages and sends the result with
@@ -569,6 +602,17 @@ up to 50 rows plus the bytes scanned. `--client` is the HawkSearch client id (th
569
602
  `context.tenant.clientId`); on dev, use a demo or test client. `string[]` values are comma-separated (`--param engines=a,b`). It only works
570
603
  against the dev platform. When too many queries are running (429 `too_many_queries`) it waits and tries again.
571
604
 
605
+ ```bash
606
+ npx hawk-app data shape queries/top-searches.sql --param from=2026-09-01 --param to=2026-09-30
607
+ npx hawk-app data shape report:top-searches --param engine=12
608
+ ```
609
+
610
+ `data shape` runs a query or an analytics report for your dev client (`HAWK_APPS_DEV_CLIENT`, or `--client`) and
611
+ prints only its **shape**: each column's type, what its values look like (number text, dates, uuids, urls), how often
612
+ it's empty, how many distinct values and its size, all bucketed. Never a value. It's what the MCP server's `data_shape`
613
+ tool gives Claude Code, so the code it writes handles the real answer (numbers sent as text, empty columns) without
614
+ client data entering the chat.
615
+
572
616
  The sensitive tables (visitor-level data such as `summary`) are listed apart: only apps Engineering has opted in can
573
617
  query them. Ask Engineering if your app needs one.
574
618
 
package/agents/AGENTS.md CHANGED
@@ -155,6 +155,13 @@ channel's email).
155
155
  (`false` while loading), or `app.setPdfExport({ fileName })` and `app.setPdfExport(null)` when the page leaves. The
156
156
  host shows a PDF icon and downloads the whole page; mark buttons and filters `data-hawk-pdf-ignore`.
157
157
 
158
+ **Saved views:** keep what makes a page's state (filters, range, tab, sort) in the UI kit's `useSavedState(initial)`
159
+ (or `app.state.set` and `app.state.onApply`), so users can save, share and link views from HawkSearch's Views menu. One
160
+ small JSON object, never the data.
161
+
162
+ **Ratings and reviews** need no code: users rate the app from HawkSearch's app header. To get each new review by email,
163
+ declare `config.reviewEmails` (`{ "type": "emails" }`) and set it (`hawk-app config set reviewEmails …`).
164
+
158
165
  **Usage** is counted by the bridge: routes and views as screens. Name what they don't show: `useScreen('Logs')` from the
159
166
  UI kit in each tab (or `app.usage.screen`), and mark the main buttons `data-hawk-usage="Export CSV"` (or
160
167
  `app.usage.action`). Name the thing, never data (a keyword, a client).
@@ -205,6 +212,10 @@ npx hawk-app errors # errors in the app's pages (deployed versions)
205
212
  npx hawk-app errors --explain <id> # one error mapped to the source, the failed calls, what changed; then a likely fix
206
213
  npx hawk-app usage # opens, users and clients per day
207
214
  npx hawk-app usage --screens # the screens and actions used, most and least, per version (--version <n>)
215
+ npx hawk-app usage --speed # how fast each screen gets its data, per version; the slowest platform calls
216
+ npx hawk-app data shape report:<name> # what a report (or queries/<name>.sql) really answers: types, empties, sizes; no values
217
+ npx hawk-app problems # what users reported ("Report a problem"); hawk-app problems show <id> in a terminal (client data)
218
+ npx hawk-app recordings # recorded sessions of a client's problem; the developer replays one: hawk-app dev --replay <id>
208
219
  npx hawk-app upgrade # bring the app (and this file) up to the CLI's current template
209
220
  npx hawk-app upgrade --channel latest # a dev app (bridge/CLI on the `dev` tag) to the released versions
210
221
  ```
@@ -25,7 +25,9 @@ each version and approvers review them. Check first whether an analytics report
25
25
  `min`/`max`, `maxItems`.
26
26
  5. **Call it** with `app.data.query('<name>', { from, to })` (or `ctx.platform.data.query` in a function). Look up the
27
27
  result shape and errors with `docs` ("data.query"). Handle a client without analytics.
28
- 6. **Check it.** Run `validate`. To try the SQL, tell the user to run
28
+ 6. **Check it.** Run `validate`, then `data_shape` with `queries/<name>.sql` and its params: it runs the query for the
29
+ developer's dev client and gives you its shape (types, empty rates, sizes), never rows. Adjust the SQL (casts,
30
+ `COALESCE`) and the code reading it to that shape. To see the rows themselves, tell the user to run
29
31
  `npx hawk-app data run queries/<name>.sql --client <test client id> --param from=… --param to=…` in a terminal: its rows
30
32
  are client data and must not come into the chat. Queries run only from a **deployed** version, so the app sees a
31
33
  new or changed query after a deploy (only when the user asks).
@@ -19,6 +19,9 @@ colors and light/dark mode. Never restyle the kit or add another font.
19
19
  - the client's HawkSearch setup or searches: `hawksearch_api`;
20
20
  - your own SQL: the **add-query** skill;
21
21
  - server work or secrets: the **add-function** skill.
22
+ Then call `data_shape` for each report or query the screen reads (`report:<name>` with its fields, or
23
+ `queries/<name>.sql`): it shows what really comes back for the developer's client (number-as-text, empty columns,
24
+ nulls, sizes), never the values. Write the parsing, empty states and formatting for that shape.
22
25
  Tell the user which scopes the screen needs before adding them to `hawk-app.json`.
23
26
  4. **Build it** in the app's style:
24
27
  - read the client, engine and dates from `app.context` and re-load on `app.onContextChange`;
@@ -1226,7 +1226,7 @@ They're kept until deleted, and each new one is emailed to whoever you set.
1226
1226
  ```
1227
1227
 
1228
1228
  ```tsx
1229
- // The UI kit: a badge with the count (one request for all the page's badges); a click opens the thread.
1229
+ // The UI kit: a comment icon with a "!" when there are comments (one count request for all the page's icons); a click opens the thread.
1230
1230
  <HawkComments target={`keyword:${row.keyword}`} label={row.keyword} />
1231
1231
  <HawkComments target="page:summary" label="This month's summary" />
1232
1232
  ```
@@ -1452,6 +1452,25 @@ Failed data calls throw `HawkApiError` with `status` and `code`; failed host req
1452
1452
 
1453
1453
  A `403` without one of these codes means the app lacks the scope for that call.
1454
1454
 
1455
+ ## Saved views
1456
+
1457
+ Users save the page's state under a name and open it again later or share it with their company, from the host's
1458
+ Views menu (a bookmark icon) or a link. Say what makes the page's state, and restore it:
1459
+
1460
+ ```ts
1461
+ app.state.set({ range, target, sort }); // as it changes; null while there's nothing to save
1462
+ const stop = app.state.onApply((state, view) => { // a saved view (or its link) was opened
1463
+ setRange(state.range);
1464
+ setTarget(state.target);
1465
+ setSort(state.sort);
1466
+ });
1467
+ ```
1468
+
1469
+ One small JSON object (at most 16 KB), never the page's data. The menu shows only while the app has set a state; a view
1470
+ opened by a link reaches `onApply` even if the app registers it later. The UI kit's `useSavedState(initial)` does both
1471
+ (`const [filters, setFilters] = useSavedState({ range: 'l13', target: null })`). Errors: `invalid_request` (not a JSON
1472
+ object, too large). See the platform's docs/saved-views.md.
1473
+
1455
1474
  ## Errors and usage
1456
1475
 
1457
1476
  `createApp` reports, on its own, one open per page load (the full app or the view showing), the screens and actions
@@ -1482,7 +1501,12 @@ app.usage.action('Bulk fix'); // an action that isn't a click on one element
1482
1501
  Names are letters, digits and simple punctuation, up to 80 characters: name the thing (`Export CSV`), not the data
1483
1502
  (never a keyword or a client). `hawk-app usage --screens` lists the most and least used, per version.
1484
1503
 
1485
- `createApp({ reportErrors: false, reportOpens: false, reportUsage: false })` turns any of them off.
1504
+ **How fast it is.** The bridge also times each screen: how long the page took to load, how long a screen took to get
1505
+ its data (until its platform calls are done), slow clicks (40 ms or more to the next paint) and each platform call by
1506
+ route. Only times and names. `hawk-app usage --speed` shows each screen's median and 90th percentile, the version
1507
+ beside the one before it, and the slowest calls.
1508
+
1509
+ `createApp({ reportErrors: false, reportOpens: false, reportUsage: false, reportSpeed: false })` turns any of them off.
1486
1510
 
1487
1511
  ## Talk to the host
1488
1512
 
@@ -1580,6 +1604,9 @@ useEffect(() => void app.assistant.setContext({ page: 'Recommendation Dashboard'
1580
1604
  await app.assistant.ask('Which widgets are taking up space?');
1581
1605
  ```
1582
1606
 
1607
+ Asking from a dialog or drawer is fine: the host's assistant is part of the page the dialog covers, so the host closes
1608
+ the dialog first (as a click on its backdrop would) and the answer is in view.
1609
+
1583
1610
  A message is at most 2,000 characters. Errors (`HawkBridgeError`): `unsupported` (no assistant on the page), `scope_missing`,
1584
1611
  `invalid_request`; `setContext` needs no scope (without it, only "Explain this page" gets the context). Hosts: `<hawk-app-frame>` fires a cancelable `hawk-assistant` event (`detail.message`; `hawk-assistant-context` with `detail.context` for the context); a host that
1585
1612
  answers it itself calls `preventDefault()` and sets the element's `assistant` attribute. Otherwise the element sends the
package/docs/ui/README.md CHANGED
@@ -42,12 +42,16 @@ export function Page() {
42
42
  then renders its children in the theme. It follows the host when its theme changes.
43
43
  - **`useHawk()`** returns `{ app, context }`, always connected (the provider renders children only then).
44
44
  `useHawkAppState()` is the hook underneath, for an app that wants to render the states itself.
45
- - **`<HawkComments target label?>`** (the `comments` scope) is a comment badge with its count; a click opens the
45
+ - **`<HawkComments target label?>`** (the `comments` scope) is a comment icon with a "!" when there are comments (the count in its tooltip); a click opens the
46
46
  thread in a drawer, where everyone at the client reads and adds comments (authors edit and delete their own). The
47
47
  page's badges share one count request. See the bridge guide's "Comments".
48
48
  - **`usePdfExport(options | false)`** lets the user export the page as a PDF while it's mounted: the host shows a PDF
49
49
  icon, and a click downloads the whole page. `usePdfExport({ fileName: 'Zero results' })`, or `false` while loading.
50
50
  Leave out buttons and filters with `data-hawk-pdf-ignore`. See the bridge guide's "Export a page as a PDF".
51
+ - **`useSavedState(initial)`** is `useState` for what makes a saved view (filters, range, tab, sort): the value is the
52
+ page's state for the host's Views menu, and opening a saved view or its link sets it back.
53
+ `const [filters, setFilters] = useSavedState({ range: 'l13', target: null })`. One small JSON object, once per page.
54
+ See the bridge guide's "Saved views".
51
55
  - **`useScreen(name | null)`** counts a screen the routes don't show, such as a tab, each time it's shown:
52
56
  `useScreen('Logs')` in the tab's component. Routes and views are counted by the bridge, and so are clicks on
53
57
  `data-hawk-usage="Export CSV"` elements. See `hawk-app usage --screens` and the bridge guide's "Errors and usage".
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridgeline-digital/hawk-app-cli",
3
- "version": "0.1.1-dev.20261026194933",
3
+ "version": "0.1.1",
4
4
  "description": "For Bridgeline developers: create, deploy and register Hawk Apps (Services-built tools embedded in HawkSearch). Requires a Hawk Apps publisher key.",
5
5
  "author": "Bridgeline Digital",
6
6
  "license": "UNLICENSED",
package/src/cli.mjs CHANGED
@@ -30,6 +30,8 @@ import { testCommand } from './test-command.mjs';
30
30
  import { appChannel } from './channel.mjs';
31
31
  import { accessibilityWarnings, checkAccessibility } from './accessibility.mjs';
32
32
  import { errorsCommand, usageCommand } from './telemetry.mjs';
33
+ import { recordingsCommand } from './replay.mjs';
34
+ import { problemsCommand } from './problems.mjs';
33
35
 
34
36
  /** The short help: what each command is for, by task. `hawk-app help <command>` shows a command's details. */
35
37
  const HELP = `hawk-app: build, run and release Hawk Apps
@@ -66,6 +68,8 @@ Data and functions
66
68
 
67
69
  Monitor
68
70
  errors, usage The app's page errors; its opens, users, clients, screens and actions
71
+ recordings Recorded sessions to replay with hawk-app dev --replay <id> (Bridgeline users)
72
+ problems What users reported with "Report a problem" (Bridgeline users)
69
73
 
70
74
  Account
71
75
  login, whoami, logout A publisher key (for CI), and which key and platform apply here
@@ -164,6 +168,20 @@ Usage:
164
168
  from the deployed version: deploy after changing them. The
165
169
  client API ("api") runs the local functions/ instead:
166
170
  /v1/clients/<id>/apps/<slug>/api/<name> on this port
171
+ hawk-app dev --replay <id> [--url <app dev URL>] [--port 3200] [--no-start]
172
+ Run your local code against a recorded session (a Bridgeline
173
+ user's "Record this session" in HawkSearch): the app's calls are
174
+ answered as they were for that client, nothing reaches the
175
+ platform. Bridgeline users only (hawk-app sso); kept in memory
176
+ hawk-app usage --speed [--days 14] [--version <n>]
177
+ How fast each screen gets its data (median and 90th percentile),
178
+ the version beside the one before it (slower ones flagged), the
179
+ page's load, slow clicks and the slowest platform calls
180
+ hawk-app recordings The app's recorded sessions (kept 7 days), with their ids
181
+ hawk-app problems [show <id>] What users reported with "Report a problem" (the flag in the
182
+ app's bar): their words, a picture of the page (a 5-minute link),
183
+ what the page showed, the version, a recording if they agreed.
184
+ Bridgeline users only; kept 30 days
167
185
 
168
186
  Clients (who has the app; also managed in the Hawk Analytics portal, not in hawk-app.json):
169
187
  hawk-app clients Show the app's visibility and its clients
@@ -209,6 +227,11 @@ Custom data (app queries, scope data:read):
209
227
  hawk-app data run <queries/name.sql> --client <id> [--param name=value …]
210
228
  Run a query against a client's data (dev platform only);
211
229
  string[] values are comma-separated
230
+ hawk-app data shape <queries/name.sql | report:<name>> [--client <id>] [--param name=value …]
231
+ What a query or analytics report answers, as its shape only
232
+ (columns, types, how often empty, what values look like,
233
+ bucketed sizes): never the values. Default client: your dev
234
+ client (HAWK_APPS_DEV_CLIENT). For the AI that writes the code
212
235
 
213
236
  Functions (server-side code, scope functions:invoke):
214
237
  hawk-app secrets list List the app's secrets (names only, never values)
@@ -605,7 +628,8 @@ export async function main(argv, { fetchImpl = fetch, log = console.log, runBuil
605
628
  case 'dev': {
606
629
  const manifest = await loadManifest(manifestFile);
607
630
  const projectDir = dirname(resolve(manifestFile));
608
- if (!flags.client && !(env ?? process.env).HAWK_APPS_DEV_CLIENT) {
631
+ // A replay runs as the recorded client.
632
+ if (!flags.replay && !flags.client && !(env ?? process.env).HAWK_APPS_DEV_CLIENT) {
609
633
  // No client yet: ask which one (by name), and remember it in .env.local for next time.
610
634
  const id = await pickDevClient({ client, slug: manifest.slug, ...(choose ? { choose } : {}), ...(ask ? { ask } : {}), log });
611
635
  await rememberDevClient(projectDir, id);
@@ -629,7 +653,10 @@ export async function main(argv, { fetchImpl = fetch, log = console.log, runBuil
629
653
  }
630
654
 
631
655
  case 'data':
632
- return dataCommand({ client, sub: positional[0], args: positional.slice(1), flags, ...(sleep ? { sleep } : {}), log });
656
+ return dataCommand({
657
+ client, sub: positional[0], args: positional.slice(1), flags, ...(sleep ? { sleep } : {}), log,
658
+ ...(env ? { env } : {}), manifest: () => loadManifest(manifestFile),
659
+ });
633
660
 
634
661
  case 'secrets': {
635
662
  const manifest = await loadManifest(manifestFile);
@@ -665,6 +692,14 @@ export async function main(argv, { fetchImpl = fetch, log = console.log, runBuil
665
692
  return usageCommand({ client: client(), manifest: await loadManifest(manifestFile), flags, log });
666
693
  }
667
694
 
695
+ case 'recordings': {
696
+ return recordingsCommand({ client: client(), manifest: await loadManifest(manifestFile), log });
697
+ }
698
+
699
+ case 'problems': {
700
+ return problemsCommand({ client: client(), manifest: await loadManifest(manifestFile), sub: positional[0], args: positional.slice(1), log });
701
+ }
702
+
668
703
  case 'logs': {
669
704
  const manifest = await loadManifest(manifestFile);
670
705
  const controller = new AbortController();
@@ -857,7 +892,7 @@ export function parse(argv) {
857
892
  flags.param = [...(flags.param ?? []), inline ?? argv[++i]];
858
893
  continue;
859
894
  }
860
- const takesValue = ['manifest', 'platform', 'slug', 'name', 'template', 'client', 'app', 'since', 'value-from-env', 'engine', 'url', 'port', 'env', 'publisher', 'pick', 'channel', 'days', 'alias', 'explain', 'version'].includes(name);
895
+ const takesValue = ['manifest', 'platform', 'slug', 'name', 'template', 'client', 'app', 'since', 'value-from-env', 'engine', 'url', 'port', 'env', 'publisher', 'pick', 'channel', 'days', 'alias', 'explain', 'version', 'replay'].includes(name);
861
896
  flags[name] = takesValue ? inline ?? argv[++i] : true;
862
897
  } else if (command === undefined) {
863
898
  command = arg;
package/src/data.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { basename } from 'node:path';
3
3
  import { checkValue, parseParamValue, validateQuery } from './queries.mjs';
4
+ import { formatShape, shapeOf } from './shape.mjs';
4
5
 
5
6
  const MAX_ROWS_SHOWN = 50;
6
7
  const MAX_CELL_WIDTH = 40;
@@ -11,7 +12,8 @@ const CLIENT_ID = /^[A-Za-z0-9]+$/;
11
12
  const USAGE = `Usage:
12
13
  hawk-app data tables [--app <slug>]
13
14
  hawk-app data describe <table>
14
- hawk-app data run <queries/name.sql> --client <id> [--param name=value …]`;
15
+ hawk-app data run <queries/name.sql> --client <id> [--param name=value …]
16
+ hawk-app data shape <queries/name.sql | report:<name>> [--client <id>] [--param name=value …]`;
15
17
 
16
18
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
17
19
 
@@ -19,7 +21,7 @@ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
19
21
  * `hawk-app data …`: the tables app queries can use, and running a query against a client's data before deploying it.
20
22
  * `client` is a factory, so a usage mistake shows before any login check.
21
23
  */
22
- export async function dataCommand({ client, sub, args = [], flags = {}, sleep = wait, log = console.log }) {
24
+ export async function dataCommand({ client, sub, args = [], flags = {}, sleep = wait, log = console.log, env = process.env, manifest = null, fetchImpl = fetch, today = () => new Date() }) {
23
25
  switch (sub) {
24
26
  case 'tables': {
25
27
  const app = typeof flags.app === 'string' ? flags.app : undefined;
@@ -53,17 +55,69 @@ export async function dataCommand({ client, sub, args = [], flags = {}, sleep =
53
55
  return 0;
54
56
  }
55
57
 
56
- case 'run':
57
- return run({ client, file: args[0], flags, sleep, log });
58
+ case 'run': {
59
+ const data = await execute({ client, file: args[0], clientId: flags.client, flags, sleep, log });
60
+ printTable(data.columns ?? [], data.rows ?? [], log);
61
+ const rowCount = data.rowCount ?? data.rows?.length ?? 0;
62
+ const shown = Math.min(rowCount, MAX_ROWS_SHOWN, data.rows?.length ?? 0);
63
+ log(`${rowCount} row${rowCount === 1 ? '' : 's'}${rowCount > shown ? ` (showing the first ${shown})` : ''}, ${formatBytes(data.bytesScanned ?? 0)} scanned${data.cached ? ' (cached result)' : ''}.`);
64
+ return 0;
65
+ }
66
+
67
+ case 'shape':
68
+ return shape({ client, source: args[0], flags, sleep, log, env, manifest, fetchImpl, today });
58
69
 
59
70
  default:
60
71
  throw new Error(sub ? `Unknown data command "${sub}".\n${USAGE}` : USAGE);
61
72
  }
62
73
  }
63
74
 
64
- async function run({ client, file, flags, sleep, log }) {
75
+ /**
76
+ * `hawk-app data shape <queries/name.sql | report:<name>>`: what a query or an analytics report answers for a client
77
+ * (default the app's dev client, HAWK_APPS_DEV_CLIENT), as its shape only: columns, types, how often empty, what values
78
+ * look like, bucketed sizes. Never the values: it's for the AI that writes the code (the MCP `data_shape` tool).
79
+ */
80
+ async function shape({ client, source, flags, sleep, log, env, manifest, fetchImpl, today }) {
81
+ if (!source || !(source.endsWith('.sql') || source.startsWith('report:'))) throw new Error(USAGE);
82
+ const clientId = typeof flags.client === 'string' ? flags.client : env.HAWK_APPS_DEV_CLIENT;
83
+ if (!clientId || !CLIENT_ID.test(clientId)) {
84
+ throw new Error('Pass the client: --client <id> (or set HAWK_APPS_DEV_CLIENT in .env.local: `hawk-app dev` asks once and saves it).');
85
+ }
86
+ let result;
87
+ if (source.endsWith('.sql')) {
88
+ const data = await execute({ client, file: source, clientId, flags, sleep, log: () => undefined });
89
+ result = shapeOf(data.rows ?? [], data.columns ?? []);
90
+ } else {
91
+ const app = manifest ? await manifest() : null;
92
+ if (!app?.slug) throw new Error('Run it in the app\'s folder (next to hawk-app.json): reports are called as the app.');
93
+ const name = source.slice('report:'.length).replace(/^\/+|\/+$/g, '');
94
+ if (!/^[A-Za-z0-9/_-]+$/.test(name)) throw new Error(`"${name}" isn't a report name (analytics_reports lists them).`);
95
+ const end = today();
96
+ const from = new Date(end.getTime() - 29 * 86_400_000).toISOString().slice(0, 10);
97
+ const body = { from, to: end.toISOString().slice(0, 10) };
98
+ for (const entry of flags.param ?? []) {
99
+ const eq = typeof entry === 'string' ? entry.indexOf('=') : -1;
100
+ if (eq <= 0) throw new Error(`--param needs name=value, not "${entry}".`);
101
+ const raw = entry.slice(eq + 1);
102
+ body[entry.slice(0, eq)] = /^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : /^(true|false)$/.test(raw) ? raw === 'true' : raw;
103
+ }
104
+ const issued = await client().devToken(app.slug, { clientId, engineId: env.HAWK_APPS_DEV_ENGINE || null, internal: true });
105
+ const url = new URL(`analytics/${name}`, issued.apiBaseUrl.endsWith('/') ? issued.apiBaseUrl : `${issued.apiBaseUrl}/`);
106
+ const response = await fetchImpl(url, { method: 'POST', headers: { Authorization: `Bearer ${issued.token}`, 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body) });
107
+ const payload = await response.json().catch(() => null);
108
+ if (!response.ok) {
109
+ throw new Error(`The report answered ${response.status}${payload?.code ? ` ${payload.code}` : ''}${payload?.detail ? `: ${payload.detail}` : ''}.`);
110
+ }
111
+ const rows = Array.isArray(payload?.data) ? payload.data : Array.isArray(payload) ? payload : [];
112
+ result = shapeOf(rows);
113
+ }
114
+ for (const line of formatShape(`${source} for client ${clientId}`, result)) log(line);
115
+ return 0;
116
+ }
117
+
118
+ /** Checks and runs a query file against a client; resolves with the platform's answer (columns, rows, …). */
119
+ async function execute({ client, file, clientId, flags, sleep, log }) {
65
120
  if (!file || !file.endsWith('.sql')) throw new Error(USAGE);
66
- const clientId = flags.client;
67
121
  if (typeof clientId !== 'string' || !CLIENT_ID.test(clientId)) throw new Error('Pass the client to run against: --client <id> (letters and digits, e.g. e3141).');
68
122
 
69
123
  const jsonFile = file.replace(/\.sql$/, '.json');
@@ -128,13 +182,7 @@ async function run({ client, file, flags, sleep, log }) {
128
182
  await sleep(Math.min(Math.max(1000 * 2 ** attempt, data.retryAfterMs ?? 0), MAX_POLL_DELAY_MS));
129
183
  ({ status, data } = await retrying(() => platform.dataExecution(data.executionId)));
130
184
  }
131
-
132
-
133
- printTable(data.columns ?? [], data.rows ?? [], log);
134
- const rowCount = data.rowCount ?? data.rows?.length ?? 0;
135
- const shown = Math.min(rowCount, MAX_ROWS_SHOWN, data.rows?.length ?? 0);
136
- log(`${rowCount} row${rowCount === 1 ? '' : 's'}${rowCount > shown ? ` (showing the first ${shown})` : ''}, ${formatBytes(data.bytesScanned ?? 0)} scanned${data.cached ? ' (cached result)' : ''}.`);
137
- return 0;
185
+ return data;
138
186
  }
139
187
 
140
188
  async function readText(file) {
package/src/dev.mjs CHANGED
@@ -7,6 +7,7 @@ import { join } from 'node:path';
7
7
  import { pathToFileURL } from 'node:url';
8
8
  import { TEMPLATES } from './create.mjs';
9
9
  import { bundleFunction, loadFunctions } from './functions.mjs';
10
+ import { loadRecording, recordedErrors, replayContext, replayer, replayToken } from './replay.mjs';
10
11
 
11
12
  const BRIDGE_PACKAGE = '@bridgeline-digital/hawk-app-bridge';
12
13
  export const DEFAULT_APP_URL = 'http://localhost:3100/';
@@ -22,13 +23,22 @@ const MAX_PROXY_BODY = 10 * 1024 * 1024;
22
23
  * (`POST /publish/apps/{slug}/dev-token`, any client), and proxies the app's platform calls (so no CORS setup is needed,
23
24
  * on dev or prod). Nothing is written to disk; the HawkSearch API key, if any, comes from the environment.
24
25
  */
25
- export async function devCommand({ client, manifest, flags = {}, projectDir = process.cwd(), env = process.env, log = console.log, signal, startDev = npmRunDev, waitForApp = waitForUrl }) {
26
- const clientId = flags.client ?? env.HAWK_APPS_DEV_CLIENT;
26
+ export async function devCommand({ client, manifest, flags = {}, projectDir = process.cwd(), env = process.env, log = console.log, signal, startDev = npmRunDev, waitForApp = waitForUrl, fetchImpl = fetch }) {
27
+ // --replay <id>: a recorded session's answers instead of the platform, as its client and site.
28
+ let replay = null;
29
+ if (flags.replay !== undefined) {
30
+ if (typeof flags.replay !== 'string') throw new Error('--replay needs a recording id, e.g. --replay r7k2m9xq4p (hawk-app recordings lists them).');
31
+ log(`› Downloading recording ${flags.replay} …`);
32
+ const loaded = await loadRecording({ client, slug: manifest.slug, id: flags.replay, fetchImpl, log });
33
+ replay = { ...loaded, answer: replayer(loaded.calls), context: replayContext(loaded.calls) };
34
+ }
35
+ const clientId = replay ? replay.recording.clientId : flags.client ?? env.HAWK_APPS_DEV_CLIENT;
27
36
  if (!clientId || clientId === true) {
28
37
  throw new Error('Usage: hawk-app dev --client <client id> [--engine <id>] [--url http://localhost:3100] [--port 3200] [--client-user] [--no-start]\n'
38
+ + ' hawk-app dev --replay <recording id>\n'
29
39
  + ' (or set HAWK_APPS_DEV_CLIENT in .env.local)');
30
40
  }
31
- const engineId = flags.engine ?? env.HAWK_APPS_DEV_ENGINE ?? null;
41
+ const engineId = replay ? replay.recording.engineId ?? null : flags.engine ?? env.HAWK_APPS_DEV_ENGINE ?? null;
32
42
  const appUrl = normalizeAppUrl(typeof flags.url === 'string' ? flags.url : defaultAppUrl(projectDir, manifest));
33
43
  const port = Number(flags.port ?? DEFAULT_PORT);
34
44
  if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('--port must be a port number.');
@@ -37,11 +47,15 @@ export async function devCommand({ client, manifest, flags = {}, projectDir = pr
37
47
  : null;
38
48
 
39
49
  const hostScript = await bundleHost(projectDir);
40
- const tokens = tokenBroker({ client, slug: manifest.slug, hawkSearch });
50
+ const tokens = replay
51
+ ? async () => replayToken({ slug: manifest.slug, recording: replay.recording, scopes: Array.isArray(manifest.scopes) ? manifest.scopes : [] })
52
+ : tokenBroker({ client, slug: manifest.slug, hawkSearch });
41
53
  const settings = {
42
- slug: manifest.slug, name: manifest.name ?? manifest.slug, appUrl, clientId: String(clientId), engineId: engineId ? String(engineId) : null, internal: !flags['client-user'],
54
+ slug: manifest.slug, name: manifest.name ?? manifest.slug, appUrl, clientId: String(clientId), engineId: engineId ? String(engineId) : null, internal: replay ? true : !flags['client-user'],
43
55
  // The page's Action menu opens these views in a dialog, as HawkSearch does from its screens.
44
56
  actions: actionsFor(manifest),
57
+ ...(replay ? { replay: replaySettings(manifest, replay) } : {}),
58
+ ...(replay?.context.from && replay.context.to ? { from: replay.context.from, to: replay.context.to } : {}),
45
59
  };
46
60
 
47
61
  // The app's dev server, unless it runs already (--url) or --no-start.
@@ -51,10 +65,10 @@ export async function devCommand({ client, manifest, flags = {}, projectDir = pr
51
65
  child = startDev({ cwd: projectDir });
52
66
  }
53
67
 
54
- const clientApi = manifest.api && Object.keys(manifest.api).length
68
+ const clientApi = !replay && manifest.api && Object.keys(manifest.api).length
55
69
  ? localClientApi({ projectDir, manifest, client, engineId: settings.engineId, hawkSearchDomain: (env.HAWKSEARCH_DOMAIN || DEFAULT_HAWKSEARCH_DOMAIN).replace(/\/+$/, ''), log })
56
70
  : null;
57
- const server = createHostServer({ settings, hostScript, tokens, platformBase: client.base, clientApi, log });
71
+ const server = createHostServer({ settings, hostScript, tokens, platformBase: client.base, clientApi, replay, log });
58
72
  await new Promise((resolve, reject) => {
59
73
  server.once('error', (error) => reject(error.code === 'EADDRINUSE' ? new Error(`Port ${port} is in use: pick another with --port.`) : error));
60
74
  server.listen(port, '127.0.0.1', resolve);
@@ -66,7 +80,11 @@ export async function devCommand({ client, manifest, flags = {}, projectDir = pr
66
80
  log('');
67
81
  log(`✔ Local host: http://localhost:${port}/`);
68
82
  log(` app ${settings.slug} from ${appUrl}, client ${settings.clientId}${settings.engineId ? `, site ${settings.engineId}` : ''}, as a ${settings.internal ? 'Bridgeline' : 'client'} user`);
69
- log(` platform ${client.base} (real data)${hawkSearch ? `; HawkSearch ${hawkSearch.domain} with HAWKSEARCH_API_KEY` : '; no HawkSearch key (set HAWKSEARCH_API_KEY for the dashboard API)'}`);
83
+ if (replay) {
84
+ for (const line of replayIntro(replay)) log(` ${line}`);
85
+ } else {
86
+ log(` platform ${client.base} (real data)${hawkSearch ? `; HawkSearch ${hawkSearch.domain} with HAWKSEARCH_API_KEY` : '; no HawkSearch key (set HAWKSEARCH_API_KEY for the dashboard API)'}`);
87
+ }
70
88
  if (clientApi) {
71
89
  const [name, endpoint] = Object.entries(manifest.api)[0];
72
90
  log(` client API: your local functions/, e.g. curl -H "X-HawkSearch-ApiKey: <site API key>" ${(endpoint.method ?? 'GET').toUpperCase() === 'POST' ? '-X POST -d "{}" ' : ''}http://localhost:${port}/v1/clients/${settings.clientId}/apps/${settings.slug}/api/${name}`);
@@ -84,6 +102,34 @@ export async function devCommand({ client, manifest, flags = {}, projectDir = pr
84
102
  return 0;
85
103
  }
86
104
 
105
+ /** What the page shows of a replay: the recording, and the view to open when the session was in one. */
106
+ function replaySettings(manifest, replay) {
107
+ const r = replay.recording;
108
+ const view = replay.context.view;
109
+ const path = view ? manifest.views?.[view]?.path : null;
110
+ return {
111
+ id: r.id, by: r.startedBy?.name || r.startedBy?.email || null, startedAt: r.startedAt, calls: replay.calls.length, version: r.version ?? null,
112
+ ...(view && typeof path === 'string' ? { view, viewPath: path.replace(/^\/+|\/+$/g, '') } : {}),
113
+ };
114
+ }
115
+
116
+ /** The terminal's summary of a replay: what was recorded, and the errors the page reported then. */
117
+ export function replayIntro(replay) {
118
+ const r = replay.recording;
119
+ const lines = [
120
+ `replaying recording ${r.id}: ${replay.calls.length} calls by ${r.startedBy?.name || r.startedBy?.email || 'a Bridgeline user'}, ${new Date(r.startedAt).toISOString().slice(0, 16).replace('T', ' ')} UTC${r.version ? `, v${r.version}` : ''}`,
121
+ 'nothing reaches the platform: the app\'s calls are answered from the recording (501 not_recorded when the session never made them)',
122
+ 'the recording is the client\'s data: it stays in memory and is gone when you stop',
123
+ ];
124
+ if (replay.context.view) lines.push(`the session ran the view "${replay.context.view}" (opened here without its params: use the Action menu for an action's)`);
125
+ const errors = recordedErrors(replay.calls);
126
+ if (errors.length) {
127
+ lines.push(`the page reported ${errors.length === 1 ? 'this error' : `${errors.length} errors`} then:`);
128
+ for (const e of errors.slice(0, 5)) lines.push(` ${e}`);
129
+ }
130
+ return lines;
131
+ }
132
+
87
133
  /** Each action of hawk-app.json with its view's path and height and the params its targets read, for the page's Action menu. */
88
134
  export function actionsFor(manifest) {
89
135
  const views = manifest.views && typeof manifest.views === 'object' ? manifest.views : {};
@@ -165,7 +211,7 @@ export async function bundleHost(projectDir) {
165
211
  * The local host: `/` (the page), `/host.js` (the bridge's host side), `POST /token` (from the page only), `/api/*`
166
212
  * (the platform, for the app; CORS for the app's origin only) and `POST /ai-stream` (the AI stream, see `proxyAiStream`).
167
213
  */
168
- export function createHostServer({ settings, hostScript, tokens, platformBase, clientApi = null, log = console.log, fetchImpl = fetch }) {
214
+ export function createHostServer({ settings, hostScript, tokens, platformBase, clientApi = null, replay = null, log = console.log, fetchImpl = fetch }) {
169
215
  const appOrigin = new URL(settings.appUrl).origin;
170
216
  // The real AI stream URL from the platform's last `GET ai` answer, which the app is told is `/ai-stream` here.
171
217
  const ai = { streamUrl: null };
@@ -189,9 +235,15 @@ export function createHostServer({ settings, hostScript, tokens, platformBase, c
189
235
  const issued = await tokens({ clientId: String(body.clientId ?? ''), engineId: body.engineId ? String(body.engineId) : null, internal: body.internal !== false, forceRefresh: !!body.forceRefresh });
190
236
  return send(res, 200, 'application/json', JSON.stringify(issued));
191
237
  }
238
+ if (replay && url.pathname.startsWith('/api/')) {
239
+ return replayAnswer(req, res, url, { appOrigin, hostOrigin, replay, log });
240
+ }
192
241
  if (url.pathname.startsWith('/api/')) {
193
242
  return proxy(req, res, url, { platformBase, appOrigin, hostOrigin, ai, fetchImpl });
194
243
  }
244
+ if (replay && url.pathname === '/ai-stream') {
245
+ return notRecorded(res, corsHeaders(appOrigin, 'POST'), req.method, 'The AI stream isn\'t recorded: Claude\'s answers can\'t be replayed.');
246
+ }
195
247
  if (url.pathname === '/ai-stream') {
196
248
  return proxyAiStream(req, res, { appOrigin, ai, fetchImpl });
197
249
  }
@@ -345,6 +397,70 @@ async function proxy(req, res, url, { platformBase, appOrigin, hostOrigin, ai, f
345
397
  res.end(payload);
346
398
  }
347
399
 
400
+ /**
401
+ * A replayed call: the recorded answer (status, content type, body), or 501 `not_recorded` when the session never made
402
+ * it (logged once each). Telemetry is answered here and not reported (a replay isn't anyone using the app). `GET ai`
403
+ * points the app at this host's `/ai-stream`, which isn't recorded either.
404
+ */
405
+ async function replayAnswer(req, res, url, { appOrigin, hostOrigin, replay, log }) {
406
+ const cors = corsHeaders(appOrigin, 'GET, POST, PUT, DELETE');
407
+ if (req.method === 'OPTIONS') {
408
+ res.writeHead(204, cors);
409
+ return res.end();
410
+ }
411
+ const body = ['GET', 'HEAD'].includes(req.method) ? '' : (await readBody(req, MAX_PROXY_BODY)).toString('utf8');
412
+ let pathname;
413
+ try {
414
+ pathname = decodeURIComponent(url.pathname);
415
+ } catch {
416
+ pathname = url.pathname;
417
+ }
418
+ if (!pathname.startsWith('/api/v1/')) return send(res, 404, 'text/plain', 'Not found');
419
+ const path = pathname.slice('/api/v1/'.length) + url.search;
420
+ if (path.startsWith('telemetry/')) {
421
+ res.writeHead(204, { ...cors, 'Cache-Control': 'no-store' });
422
+ return res.end();
423
+ }
424
+ const hit = replay.answer(req.method, path, body);
425
+ if (!hit) {
426
+ replay.missed ??= new Set();
427
+ if (!replay.missed.has(`${req.method} ${path}`)) {
428
+ replay.missed.add(`${req.method} ${path}`);
429
+ log(`! not recorded: ${req.method} ${path} (answered 501)`);
430
+ }
431
+ return notRecorded(res, cors, req.method, `The recorded session never made ${req.method} ${path.split('?')[0]}.`);
432
+ }
433
+ const { call } = hit;
434
+ if (call.truncated) {
435
+ return send(res, 502, 'application/problem+json', JSON.stringify({ status: 502, code: 'too_large_to_record', detail: `The answer to ${req.method} ${path.split('?')[0]} was over 2 MB and wasn't recorded.` }), cors);
436
+ }
437
+ let payload = call.body == null ? Buffer.alloc(0) : call.base64 ? Buffer.from(call.body, 'base64') : Buffer.from(call.body, 'utf8');
438
+ if (req.method === 'GET' && path === 'ai' && call.status < 300) payload = pointAiAt(payload, `${hostOrigin}/ai-stream`);
439
+ if (call.status === 204 || payload.length === 0) {
440
+ res.writeHead(call.status, { ...cors, 'Cache-Control': 'no-store' });
441
+ return res.end();
442
+ }
443
+ send(res, call.status, call.contentType ?? 'application/json', payload, cors);
444
+ }
445
+
446
+ function notRecorded(res, cors, method, detail) {
447
+ if (method === 'OPTIONS') {
448
+ res.writeHead(204, cors);
449
+ return res.end();
450
+ }
451
+ send(res, 501, 'application/problem+json', JSON.stringify({ status: 501, code: 'not_recorded', detail }), cors);
452
+ }
453
+
454
+ /** The recorded `GET ai` answer with its stream URL pointed at this host (whose replay says the stream isn't recorded). */
455
+ function pointAiAt(payload, localStream) {
456
+ try {
457
+ const body = JSON.parse(payload.toString('utf8'));
458
+ return body && typeof body === 'object' && 'streamUrl' in body ? Buffer.from(JSON.stringify({ ...body, streamUrl: localStream })) : payload;
459
+ } catch {
460
+ return payload;
461
+ }
462
+ }
463
+
348
464
  /** A real AI stream URL: https on a Lambda function URL host (`*.on.aws`), path `/v1/ai/messages`, nothing else. */
349
465
  export function isAiStreamUrl(value) {
350
466
  try {
@@ -428,8 +544,8 @@ function readBody(req, limit) {
428
544
  });
429
545
  }
430
546
 
431
- function send(res, status, type, body) {
432
- res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' });
547
+ function send(res, status, type, body, headers = {}) {
548
+ res.writeHead(status, { ...headers, 'Content-Type': type, 'Cache-Control': 'no-store' });
433
549
  res.end(body);
434
550
  }
435
551
 
@@ -458,7 +574,7 @@ const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (c) => ({ '&': '
458
574
  export function hostPage(settings) {
459
575
  const today = new Date();
460
576
  const from = new Date(today.getTime() - 29 * 86_400_000);
461
- const initial = { ...settings, from: from.toISOString().slice(0, 10), to: today.toISOString().slice(0, 10), theme: 'light' };
577
+ const initial = { ...settings, from: settings.from ?? from.toISOString().slice(0, 10), to: settings.to ?? today.toISOString().slice(0, 10), theme: 'light' };
462
578
  return `<!doctype html>
463
579
  <html lang="en">
464
580
  <head>
@@ -491,6 +607,7 @@ export function hostPage(settings) {
491
607
  dialog::backdrop { background: rgba(0,0,0,.45); }
492
608
  #actionTitle { margin: 0 0 8px; font-weight: 600; }
493
609
  #actionFrame { min-height: 200px; margin-bottom: 8px; }
610
+ #replay { padding: 6px 16px; background: #fef0c7; color: #7a2e0e; border-bottom: 1px solid #fedf89; font-size: 13px; }
494
611
  </style>
495
612
  </head>
496
613
  <body>
@@ -506,12 +623,16 @@ export function hostPage(settings) {
506
623
  <button id="reload">Reload app</button>
507
624
  <hawk-app-explain for="frame"></hawk-app-explain>
508
625
  <hawk-app-pdf for="frame"></hawk-app-pdf>
626
+ <hawk-app-reviews for="frame"></hawk-app-reviews>
627
+ <hawk-app-report for="frame"></hawk-app-report>
628
+ <hawk-app-views for="frame"></hawk-app-views>
509
629
  <span id="actions" hidden>
510
630
  <label>Action <select id="action"></select></label>
511
631
  <label>Value <input id="actionValue" size="14" placeholder="e.g. rain boots"></label>
512
632
  <button id="runAction">Run</button>
513
633
  </span>
514
634
  </header>
635
+ <div id="replay" hidden></div>
515
636
  <dialog id="actionDialog"><p id="actionTitle"></p><hawk-app-frame id="actionFrame"></hawk-app-frame><button id="closeAction">Close</button></dialog>
516
637
  <main>
517
638
  <div>
@@ -533,6 +654,15 @@ export function hostPage(settings) {
533
654
  let state = { ...initial };
534
655
  for (const key of ['clientId', 'engineId', 'from', 'to', 'theme']) $(key).value = state[key] ?? '';
535
656
  $('internal').value = String(state.internal);
657
+ // A replay: the recorded client, site and user only (its answers are theirs).
658
+ const replay = initial.replay;
659
+ if (replay) {
660
+ for (const key of ['clientId', 'engineId', 'internal']) $(key).disabled = true;
661
+ $('replay').hidden = false;
662
+ $('replay').textContent = 'Replaying recording ' + replay.id + (replay.by ? ' by ' + replay.by : '') + ' · ' + new Date(replay.startedAt).toLocaleString()
663
+ + ' · ' + replay.calls + ' calls' + (replay.version ? ' · recorded on v' + replay.version : '') + (replay.view ? ' · view ' + replay.view : '')
664
+ + ' · answers come from the recording (the client\\'s data: don\\'t share screenshots)';
665
+ }
536
666
 
537
667
  const event = (text) => { const row = document.createElement('div'); row.textContent = new Date().toLocaleTimeString() + ' ' + text; $('events').prepend(row); };
538
668
  const claims = (token) => JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
@@ -581,8 +711,10 @@ export function hostPage(settings) {
581
711
  document.body.classList.toggle('dark', state.theme === 'dark');
582
712
  const t = await token(false).catch(() => null);
583
713
  if (!t) return;
584
- frame.context = context(t.token);
585
- if (reloadApp) { frame.removeAttribute('src'); frame.setAttribute('src', initial.appUrl); }
714
+ // A replayed session that ran in a view opens that view (its params aren't recorded).
715
+ const view = replay && replay.view ? { name: replay.view, params: {}, embedded: true } : null;
716
+ frame.context = view ? { ...context(t.token), view } : context(t.token);
717
+ if (reloadApp) { frame.removeAttribute('src'); frame.setAttribute('src', view ? new URL(replay.viewPath + '/', initial.appUrl).href : initial.appUrl); }
586
718
  }
587
719
 
588
720
  $('apply').addEventListener('click', () => {
package/src/mcp.mjs CHANGED
@@ -5,7 +5,8 @@
5
5
  // - No client names: outputs show client ids only. clients_add asks the person to pick the client in a form their
6
6
  // assistant shows (MCP elicitation); the answer comes back to this server, and the assistant hears only that a
7
7
  // client was added. Without forms, it sends the person to `hawk-app clients add --pick` in a terminal.
8
- // - No query results: `data run` would put client rows in the assistant's context. Table and column names only.
8
+ // - No query results: `data run` would put client rows in the assistant's context. Table and column names only, and
9
+ // `data_shape`: what an answer looks like (types, empty rates, bucketed sizes), never its values.
9
10
  // - The publisher key never passes through a tool: the commands use the saved login, as the CLI does.
10
11
  // - deploy asks nothing: the assistant's client asks the user before calling it (destructive hint),
11
12
  // and a question the CLI would ask (a teammate's app, serious vulnerabilities) cancels unless `force` is set.
@@ -31,7 +32,7 @@ const TAIL_LINES = 40;
31
32
  const INSTRUCTIONS = `Tools for building Hawk Apps: static web apps that run inside the HawkSearch dashboard and get their context and data through the bridge (@bridgeline-digital/hawk-app-bridge).
32
33
  - Read the app's AGENTS.md first. Before using a bridge call, look it up with \`docs\` (options, limits, error codes); the showcase sections are working examples of every feature.
33
34
  - Before building a screen, \`ui_components\` lists the UI kit's components for this app's template (Spark's look): use them instead of writing your own.
34
- - For analytics data, \`analytics_reports\` lists the reports app.analytics.report() can call, and gives one report's columns and filters: check it before writing a query.
35
+ - For analytics data, \`analytics_reports\` lists the reports app.analytics.report() can call, and gives one report's columns and filters: check it before writing a query. \`data_shape\` then shows what a report or query really answers for the developer's client (types, empty columns, number-as-text; never values): check it before writing code that reads the data.
35
36
  - For HawkSearch's own APIs (dashboard, search, recommendations, indexing), \`hawksearch_api\` gives an operation's fields, the bridge call and its scope, and says what apps can't call: check it before writing one.
36
37
  - \`validate\` after changing hawk-app.json, queries/ or functions/, and \`validate\` with build=true after changing the build setup.
37
38
  - \`deploy\` changes the app on the dev platform: only when the user asks (dryRun: true only shows what hawk-app.json would change). It never touches prod.
@@ -295,6 +296,23 @@ export function createMcpServer({
295
296
  async ({ table, dir }) => result(await run(['data', 'describe', table], { dir })),
296
297
  );
297
298
 
299
+ server.registerTool(
300
+ 'data_shape',
301
+ {
302
+ title: 'Real data shape',
303
+ description:
304
+ 'What a query (queries/<name>.sql) or an analytics report (report:<name>) really answers for the developer\'s dev client, as its shape only: each column\'s type, what its values look like (number text, dates, ids, urls), how often it is empty, bucketed sizes and counts. Never the values (client data). Use it before writing code or tests that read the data: handle the empty columns, number-as-text and nulls it shows.',
305
+ inputSchema: {
306
+ source: z.string().regex(/^(queries\/[A-Za-z0-9_-]+\.sql|report:[A-Za-z0-9/_-]+)$/).describe('queries/<name>.sql, or report:<name> (a name from analytics_reports).'),
307
+ params: z.record(z.string(), z.string()).optional().describe('Parameters: the query\'s, or the report\'s request fields (from and to default to the last 30 days).'),
308
+ dir: dirParam,
309
+ },
310
+ annotations: { ...readOnly, openWorldHint: true },
311
+ },
312
+ async ({ source, params, dir }) =>
313
+ result(await run(['data', 'shape', source, ...Object.entries(params ?? {}).flatMap(([k, v]) => ['--param', `${k}=${v}`])], { dir })),
314
+ );
315
+
298
316
  server.registerTool(
299
317
  'logs',
300
318
  {
@@ -346,13 +364,38 @@ export function createMcpServer({
346
364
  inputSchema: {
347
365
  days: z.number().int().min(1).max(400).optional().describe('Days back; default 30.'),
348
366
  screens: z.boolean().optional().describe('The screens (routes, views, named tabs) and actions (marked clicks) used, instead of opens.'),
367
+ speed: z.boolean().optional().describe('How fast each screen gets its data, per version (slower ones flagged), and the slowest platform calls, instead of opens.'),
349
368
  version: z.number().int().min(1).optional().describe('With screens: one version\'s counts only.'),
350
369
  dir: dirParam,
351
370
  },
352
371
  annotations: { ...readOnly, openWorldHint: true },
353
372
  },
354
- async ({ days, screens, version, dir }) =>
355
- result(await run(['usage', ...(screens ? ['--screens'] : []), ...(screens && version ? ['--version', String(version)] : []), ...(days ? ['--days', String(days)] : [])], { dir })),
373
+ async ({ days, screens, speed, version, dir }) =>
374
+ result(await run(['usage', ...(speed ? ['--speed'] : screens ? ['--screens'] : []), ...((screens || speed) && version ? ['--version', String(version)] : []), ...(days ? ['--days', String(days)] : [])], { dir })),
375
+ );
376
+
377
+ server.registerTool(
378
+ 'recordings',
379
+ {
380
+ title: 'Recorded sessions',
381
+ description:
382
+ 'The app\'s recorded sessions on the dev platform (a Bridgeline user\'s "Record this session" in HawkSearch, kept 7 days): id, who, client, calls, failed calls. The developer replays one in a terminal with `hawk-app dev --replay <id>`; the recording itself (the client\'s data) is never given to the assistant. Bridgeline users only (hawk-app sso).',
383
+ inputSchema: { dir: dirParam },
384
+ annotations: { ...readOnly, openWorldHint: true },
385
+ },
386
+ async ({ dir }) => result(await run(['recordings'], { dir })),
387
+ );
388
+
389
+ server.registerTool(
390
+ 'problems',
391
+ {
392
+ title: 'Reported problems',
393
+ description:
394
+ 'The problems users reported in the app ("Report a problem" in HawkSearch, kept 30 days): id, when, client, version, page, and whether a picture, the page context or a recording came with it. Not what the user wrote, the picture or the context (client data): the developer reads one in a terminal with `hawk-app problems show <id>` and replays a recording with `hawk-app dev --replay <id>`. Bridgeline users only (hawk-app sso).',
395
+ inputSchema: { dir: dirParam },
396
+ annotations: { ...readOnly, openWorldHint: true },
397
+ },
398
+ async ({ dir }) => result(await run(['problems'], { dir })),
356
399
  );
357
400
 
358
401
  server.registerTool(
package/src/platform.mjs CHANGED
@@ -144,6 +144,16 @@ export function platformClient({ url = process.env.HAWK_APPS_PLATFORM_URL, key =
144
144
  appUsage: (slug, days) => call('GET', `/publish/apps/${encodeURIComponent(slug)}/usage?days=${days}`),
145
145
  /** Screens and actions used: { days, versions, screens: [{ name, count, staffCount, clients, versions: [{ version, count }] }], actions }. */
146
146
  appScreens: (slug, days) => call('GET', `/publish/apps/${encodeURIComponent(slug)}/usage/screens?days=${days}`),
147
+ /** How fast screens are: { days, versions, metrics: [{ screen, metric, route, version, count, p50, p90, avgMs }] }. */
148
+ appSpeed: (slug, days) => call('GET', `/publish/apps/${encodeURIComponent(slug)}/usage/speed?days=${days}`),
149
+ /** Problem reports (Bridgeline users only): { problems: [{ id, clientId, email, version, title, createdAt, hasPicture, recordingId, … }] }. */
150
+ problems: (slug) => call('GET', `/publish/apps/${encodeURIComponent(slug)}/problems`),
151
+ /** One report: { problem, text, pictureUrl (5 minutes), context, replay }. */
152
+ problem: (slug, id) => call('GET', `/publish/apps/${encodeURIComponent(slug)}/problems/${encodeURIComponent(id)}`),
153
+ /** Recorded sessions (Bridgeline users only: keys from `hawk-app sso`): { recordings: [{ id, clientId, startedBy, startedAt, calls, … }] }. */
154
+ recordings: (slug) => call('GET', `/publish/apps/${encodeURIComponent(slug)}/recordings`),
155
+ /** One recording to replay: { recording, calls } locally, { recording, callUrls } (5-minute links) in AWS. */
156
+ recording: (slug, id) => call('GET', `/publish/apps/${encodeURIComponent(slug)}/recordings/${encodeURIComponent(id)}`),
147
157
  /** One page of a function's logs: { events: [{ timestamp, message }], nextToken }. */
148
158
  functionLogs: (slug, name, { since, nextToken } = {}) => {
149
159
  const query = new URLSearchParams();
@@ -0,0 +1,48 @@
1
+ // `hawk-app problems`: what users reported with "Report a problem" in the host's app bar (platform/docs/problems.md):
2
+ // their words, a picture of the page, what the page showed, the version, and a recording when they agreed to one. It's
3
+ // the client's data: Bridgeline users only (keys from `hawk-app sso`), kept 30 days. Nothing is written to disk.
4
+
5
+ const when = (iso) => new Date(iso).toISOString().slice(0, 16).replace('T', ' ');
6
+
7
+ /** `hawk-app problems` (the newest first) or `hawk-app problems show <id>`. */
8
+ export async function problemsCommand({ client, manifest, sub, args = [], log = console.log }) {
9
+ if (sub === 'show') return show({ client, manifest, id: args[0], log });
10
+ if (sub) throw new Error(`Unknown problems command "${sub}". Usage: hawk-app problems [show <id>]`);
11
+ const { problems } = await client.problems(manifest.slug);
12
+ if (!problems.length) {
13
+ log(`No problems reported in ${manifest.slug} (reports are kept 30 days).`);
14
+ log('Users report them with the flag icon in the app\'s bar in HawkSearch.');
15
+ return 0;
16
+ }
17
+ log(`Problems reported in ${manifest.slug} (the client's data: don't share them):`);
18
+ log('');
19
+ log(' Id Reported (UTC) Client Version Page With');
20
+ for (const p of problems) {
21
+ const page = (p.title ?? '—').slice(0, 24);
22
+ const extras = [p.hasPicture ? 'picture' : null, p.hasContext ? 'context' : null, p.recordingId ? 'recording' : null].filter(Boolean).join(', ') || '—';
23
+ log(` ${p.id.padEnd(10)} ${when(p.createdAt)} ${p.clientId.slice(0, 11).padEnd(11)} ${(p.version ? `v${p.version}` : '—').padEnd(7)} ${page.padEnd(24)} ${extras}`);
24
+ }
25
+ log('');
26
+ log(`Read one: hawk-app problems show ${problems[0].id}`);
27
+ return 0;
28
+ }
29
+
30
+ async function show({ client, manifest, id, log }) {
31
+ if (!/^p[a-z0-9]{9}$/.test(String(id ?? ''))) throw new Error('Usage: hawk-app problems show <id> (e.g. pk7m2n9x4q; `hawk-app problems` lists them).');
32
+ const { problem: p, text, pictureUrl, context, replay } = await client.problem(manifest.slug, id);
33
+ log(`${p.id}: reported ${when(p.createdAt)} UTC by ${p.email ?? 'a user'}${p.internal ? ' (Bridgeline)' : ''}, client ${p.clientId}${p.version ? `, v${p.version}` : ''}${p.title ? `, on "${p.title}"` : ''}`);
34
+ log('');
35
+ for (const line of text.split(/\r?\n/)) log(` ${line}`);
36
+ log('');
37
+ if (pictureUrl?.startsWith('https://')) log(`Picture (the link works for 5 minutes): ${pictureUrl}`);
38
+ else if (pictureUrl) log('Picture: kept (a local platform: open it from the platform\'s answer).');
39
+ else log('Picture: none.');
40
+ if (context) {
41
+ log('What the page showed (its page context):');
42
+ for (const line of JSON.stringify(context, null, 2).split('\n')) log(` ${line}`);
43
+ }
44
+ if (replay) log(`Recording: ${replay}`);
45
+ log('');
46
+ log('The client\'s data: don\'t share it. Deleted 30 days after it was sent.');
47
+ return 0;
48
+ }
package/src/replay.mjs ADDED
@@ -0,0 +1,176 @@
1
+ // Recorded sessions (platform/docs/recordings.md): a Bridgeline user turned on "Record this session" for the app in
2
+ // HawkSearch, and the platform kept their session's calls with the answers. `hawk-app recordings` lists them;
3
+ // `hawk-app dev --replay <id>` (dev.mjs) serves the app from one, so the page behaves as it did for that client. A
4
+ // recording holds the client's data: it's downloaded into memory only (never written to disk), and only Bridgeline
5
+ // users (keys from `hawk-app sso`) can download it.
6
+
7
+ const DOWNLOADS_AT_ONCE = 8;
8
+
9
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
10
+
11
+ const when = (iso) => new Date(iso).toISOString().slice(0, 16).replace('T', ' ');
12
+
13
+ function size(bytes) {
14
+ if (bytes < 1024) return `${bytes} B`;
15
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
16
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
17
+ }
18
+
19
+ function state(r) {
20
+ if (r.recording) return 'recording';
21
+ if (r.full) return 'full';
22
+ return 'done';
23
+ }
24
+
25
+ /** `hawk-app recordings`: the app's recorded sessions (kept 7 days), the newest first. */
26
+ export async function recordingsCommand({ client, manifest, log = console.log }) {
27
+ const { recordings } = await client.recordings(manifest.slug);
28
+ if (!recordings.length) {
29
+ log(`No recorded sessions of ${manifest.slug} (they're kept 7 days).`);
30
+ log('A Bridgeline user records one in HawkSearch: open the app as the client, then ⋮ → Record this session.');
31
+ return 0;
32
+ }
33
+ log(`Recorded sessions of ${manifest.slug} (kept 7 days, the client's data: don't share them):`);
34
+ log('');
35
+ log(' Id Started (UTC) By Client Calls Failed Size State');
36
+ for (const r of recordings) {
37
+ const by = r.startedBy?.name || r.startedBy?.email || '—';
38
+ log(` ${r.id.padEnd(10)} ${when(r.startedAt)} ${by.slice(0, 20).padEnd(20)} ${r.clientId.slice(0, 11).padEnd(11)} ${String(r.calls).padStart(5)} ${String(r.failed).padStart(6)} ${size(r.bytes).padStart(8)} ${state(r)}`);
39
+ }
40
+ log('');
41
+ log(`Replay one locally: hawk-app dev --replay ${recordings[0].id}`);
42
+ return 0;
43
+ }
44
+
45
+ /**
46
+ * The recording and its calls, in order. In AWS each call is a 5-minute download link (fetched here, a few at a time);
47
+ * locally the platform sends the calls themselves. A call that can't be fetched is left out (and said).
48
+ */
49
+ export async function loadRecording({ client, slug, id, fetchImpl = fetch, log = console.log }) {
50
+ if (!/^[a-z0-9]{10}$/.test(String(id))) throw new Error('--replay needs a recording id, e.g. --replay r7k2m9xq4p (hawk-app recordings lists them).');
51
+ const download = await client.recording(slug, id);
52
+ let calls = download.calls ?? [];
53
+ if (!download.calls && download.callUrls?.length) {
54
+ const urls = download.callUrls;
55
+ const fetched = new Array(urls.length);
56
+ let next = 0;
57
+ let missing = 0;
58
+ const worker = async () => {
59
+ while (next < urls.length) {
60
+ const i = next++;
61
+ try {
62
+ const response = await fetchImpl(urls[i]);
63
+ if (!response.ok) throw new Error(String(response.status));
64
+ fetched[i] = await response.json();
65
+ } catch {
66
+ missing++;
67
+ }
68
+ }
69
+ };
70
+ await Promise.all(Array.from({ length: Math.min(DOWNLOADS_AT_ONCE, urls.length) }, worker));
71
+ calls = fetched.filter(Boolean);
72
+ if (missing) log(`! ${plural(missing, 'call')} of the recording couldn't be downloaded: the replay answers them 501 not_recorded.`);
73
+ }
74
+ return { recording: download.recording, calls: calls.sort((a, b) => a.seq - b.seq) };
75
+ }
76
+
77
+ const TELEMETRY = /^telemetry\//;
78
+
79
+ /**
80
+ * Answers a call from the recording: the same method, path (with its query) and body, in the order they were made (the
81
+ * last answer repeats); else the same method and path; else the same method and route without the query (e.g. other
82
+ * dates). Null when the session never made it. Telemetry isn't served from here (the replay answers it itself).
83
+ */
84
+ export function replayer(calls) {
85
+ const exact = new Map();
86
+ const byPath = new Map();
87
+ const byRoute = new Map();
88
+ const used = new Map();
89
+ const add = (map, key, call) => {
90
+ if (!map.has(key)) map.set(key, []);
91
+ map.get(key).push(call);
92
+ };
93
+ for (const call of calls) {
94
+ if (TELEMETRY.test(call.path)) continue;
95
+ add(exact, `${call.method} ${call.path} ${normalizeBody(call.requestBody)}`, call);
96
+ add(byPath, `${call.method} ${call.path}`, call);
97
+ add(byRoute, `${call.method} ${call.path.split('?')[0]}`, call);
98
+ }
99
+ return (method, path, body) => {
100
+ for (const [map, key, match] of [
101
+ [exact, `${method} ${path} ${normalizeBody(body)}`, 'exact'],
102
+ [byPath, `${method} ${path}`, 'path'],
103
+ [byRoute, `${method} ${path.split('?')[0]}`, 'route'],
104
+ ]) {
105
+ const queue = map.get(key);
106
+ if (!queue) continue;
107
+ const n = used.get(key) ?? 0;
108
+ used.set(key, n + 1);
109
+ return { call: queue[Math.min(n, queue.length - 1)], match };
110
+ }
111
+ return null;
112
+ };
113
+ }
114
+
115
+ /** JSON bodies compared without their formatting; anything else as it is. */
116
+ function normalizeBody(body) {
117
+ if (body === undefined || body === null || body === '') return '';
118
+ try {
119
+ return JSON.stringify(JSON.parse(body));
120
+ } catch {
121
+ return String(body);
122
+ }
123
+ }
124
+
125
+ /** The session's context, as far as the calls show it: the view (when every call came from one) and the dates. */
126
+ export function replayContext(calls) {
127
+ const own = calls.filter((c) => !TELEMETRY.test(c.path));
128
+ const views = new Set(own.map((c) => c.view ?? null));
129
+ const view = views.size === 1 ? [...views][0] : null;
130
+ let from = null;
131
+ let to = null;
132
+ for (const c of own) {
133
+ const text = `${c.path} ${c.requestBody ?? ''}`;
134
+ from ??= /\bfrom["=:\s]+"?(\d{4}-\d{2}-\d{2})/.exec(text)?.[1] ?? null;
135
+ to ??= /\bto["=:\s]+"?(\d{4}-\d{2}-\d{2})/.exec(text)?.[1] ?? null;
136
+ }
137
+ return { view, from, to };
138
+ }
139
+
140
+ /** What the page reported while it was recorded (`telemetry/errors`): the errors, once each. */
141
+ export function recordedErrors(calls) {
142
+ const seen = new Set();
143
+ const errors = [];
144
+ for (const c of calls) {
145
+ if (c.path !== 'telemetry/errors' || !c.requestBody) continue;
146
+ let body;
147
+ try {
148
+ body = JSON.parse(c.requestBody);
149
+ } catch {
150
+ continue;
151
+ }
152
+ for (const e of Array.isArray(body?.errors) ? body.errors : []) {
153
+ const line = `${e.kind === 'rejection' ? 'unhandled rejection' : 'error'}: ${String(e.message ?? '').slice(0, 200)}`;
154
+ if (!seen.has(line)) {
155
+ seen.add(line);
156
+ errors.push(line);
157
+ }
158
+ }
159
+ }
160
+ return errors;
161
+ }
162
+
163
+ /**
164
+ * A stand-in app token for the replay (the replay doesn't check it, and nothing reaches the platform): the recorded
165
+ * client, site and a Bridgeline user, with the app's scopes, so the bridge and the page work as they did.
166
+ */
167
+ export function replayToken({ slug, recording, scopes = [], now = Date.now() }) {
168
+ const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
169
+ const exp = Math.floor(now / 1000) + 3600;
170
+ const payload = {
171
+ sub: recording.startedBy?.email ? `replay:${recording.startedBy.email}` : 'replay', app: slug, client_id: recording.clientId,
172
+ ...(recording.engineId ? { engine: recording.engineId } : {}), internal: true, scope: scopes.join(' '), ...(recording.version ? { ver: recording.version } : {}),
173
+ exp, replay: recording.id,
174
+ };
175
+ return { token: `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.replay`, expiresAt: new Date(exp * 1000).toISOString(), version: recording.version ?? null };
176
+ }
package/src/shape.mjs ADDED
@@ -0,0 +1,111 @@
1
+ // The shape of real data, for the AI that writes the app's code: what a query or an analytics report answers for a
2
+ // client, as column names, types, how often each is empty and what its values look like, never the values themselves
3
+ // (they're the client's data and stay out of the assistant's context). Magnitudes and counts come in coarse buckets.
4
+
5
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
7
+ const DATETIME = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/;
8
+ const NUMBER_TEXT = /^-?\d+(\.\d+)?$/;
9
+ const BOOL_TEXT = /^(true|false)$/i;
10
+ const URL_TEXT = /^https?:\/\//i;
11
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
12
+
13
+ /** "none", "1", "2-9", "10-99", "100-999", "1,000+": how many, without the number. */
14
+ export function countBucket(n) {
15
+ if (n <= 0) return 'none';
16
+ if (n === 1) return '1';
17
+ if (n < 10) return '2-9';
18
+ if (n < 100) return '10-99';
19
+ if (n < 1000) return '100-999';
20
+ return '1,000+';
21
+ }
22
+
23
+ /** "0", "under 1", "1-9", "10-99", ..., "1M+": a number's size, not the number. */
24
+ export function magnitude(n) {
25
+ const a = Math.abs(n);
26
+ if (a === 0) return '0';
27
+ if (a < 1) return 'under 1';
28
+ if (a >= 1_000_000) return '1M+';
29
+ const digits = Math.floor(Math.log10(a)) + 1;
30
+ const low = 10 ** (digits - 1);
31
+ return `${low === 1 ? 1 : low.toLocaleString('en-US')}-${(10 ** digits - 1).toLocaleString('en-US')}`;
32
+ }
33
+
34
+ function kindOf(value) {
35
+ if (value === null || value === undefined || value === '') return 'empty';
36
+ if (typeof value === 'boolean') return 'boolean';
37
+ if (typeof value === 'number') return Number.isInteger(value) ? 'integer' : 'decimal';
38
+ if (Array.isArray(value)) return 'array';
39
+ if (typeof value === 'object') return 'object';
40
+ const text = String(value);
41
+ if (DATE.test(text)) return 'date text';
42
+ if (DATETIME.test(text)) return 'date-time text';
43
+ if (NUMBER_TEXT.test(text)) return 'number text';
44
+ if (BOOL_TEXT.test(text)) return 'boolean text';
45
+ if (UUID.test(text)) return 'uuid text';
46
+ if (URL_TEXT.test(text)) return 'url text';
47
+ if (EMAIL.test(text)) return 'email text';
48
+ return 'text';
49
+ }
50
+
51
+ /**
52
+ * The shape of rows (objects, or arrays with `columns`): per column its declared type (when given), the kinds its values
53
+ * have, how many are empty, numbers' sizes and signs, texts' lengths, and how many distinct values (bucketed).
54
+ */
55
+ export function shapeOf(rows, columns = null) {
56
+ const objects = columns
57
+ ? rows.map((row) => Object.fromEntries(columns.map((c, i) => [c.name, row[i]])))
58
+ : rows.filter((row) => row && typeof row === 'object' && !Array.isArray(row));
59
+ const names = columns ? columns.map((c) => c.name) : [...new Set(objects.flatMap((row) => Object.keys(row)))];
60
+ const declared = Object.fromEntries((columns ?? []).map((c) => [c.name, c.type]));
61
+ return {
62
+ rows: countBucket(objects.length),
63
+ columns: names.map((name) => {
64
+ const values = objects.map((row) => row[name]);
65
+ const kinds = new Map();
66
+ for (const v of values) kinds.set(kindOf(v), (kinds.get(kindOf(v)) ?? 0) + 1);
67
+ const empty = kinds.get('empty') ?? 0;
68
+ kinds.delete('empty');
69
+ const numbers = values.map((v) => (typeof v === 'number' ? v : typeof v === 'string' && NUMBER_TEXT.test(v) ? Number(v) : null)).filter((v) => v !== null);
70
+ const texts = values.filter((v) => typeof v === 'string' && v !== '');
71
+ const shape = {
72
+ name,
73
+ ...(declared[name] ? { type: declared[name] } : {}),
74
+ kinds: [...kinds.entries()].sort((a, b) => b[1] - a[1]).map(([kind]) => kind),
75
+ empty: objects.length ? `${Math.round((empty / objects.length) * 100)}%` : '—',
76
+ distinct: countBucket(new Set(values.filter((v) => v !== null && v !== undefined && v !== '').map((v) => JSON.stringify(v))).size),
77
+ };
78
+ if (numbers.length) {
79
+ shape.size = `${magnitude(Math.min(...numbers.map(Math.abs)))} to ${magnitude(Math.max(...numbers.map(Math.abs)))}`;
80
+ if (numbers.some((n) => n < 0)) shape.negative = true;
81
+ }
82
+ if (texts.length && !numbers.length) {
83
+ const lengths = texts.map((t) => t.length);
84
+ const [shortest, longest] = [Math.min(...lengths), Math.max(...lengths)];
85
+ shape.length = `${shortest === longest ? shortest : `${shortest}-${longest}`} characters`;
86
+ }
87
+ return shape;
88
+ }),
89
+ };
90
+ }
91
+
92
+ /** The shape as lines for the terminal and the assistant. */
93
+ export function formatShape(source, shape) {
94
+ const lines = [`${source}: ${shape.rows} row${shape.rows === '1' ? '' : 's'} (counts and sizes are bucketed; no values are shown)`];
95
+ if (!shape.columns.length) {
96
+ lines.push(' no columns (an empty answer: try another client, dates or parameters)');
97
+ return lines;
98
+ }
99
+ for (const c of shape.columns) {
100
+ const parts = [
101
+ c.type ? `type ${c.type}` : null,
102
+ c.kinds.length ? c.kinds.join(' | ') : 'always empty',
103
+ `empty ${c.empty}`,
104
+ `distinct ${c.distinct}`,
105
+ c.size ? `size ${c.size}${c.negative ? ', some negative' : ''}` : null,
106
+ c.length ? c.length : null,
107
+ ].filter(Boolean);
108
+ lines.push(` ${c.name}: ${parts.join(' · ')}`);
109
+ }
110
+ return lines;
111
+ }
package/src/telemetry.mjs CHANGED
@@ -72,6 +72,7 @@ async function explainCommand({ client, manifest, flags, log, write }) {
72
72
  */
73
73
  export async function usageCommand({ client, manifest, flags = {}, log = console.log }) {
74
74
  if (flags.screens) return screensCommand({ client, manifest, flags, log });
75
+ if (flags.speed) return speedCommand({ client, manifest, flags, log });
75
76
  const days = parseDays(flags.days, 30, 400);
76
77
  const usage = await client.appUsage(manifest.slug, days);
77
78
  log(`${manifest.slug}, the last ${plural(days, 'day')}:`);
@@ -90,6 +91,61 @@ export async function usageCommand({ client, manifest, flags = {}, log = console
90
91
  return 0;
91
92
  }
92
93
 
94
+ const seconds = (ms) => (ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`);
95
+ const SLOWER = 1.2;
96
+ const SLOWER_MS = 200;
97
+
98
+ /**
99
+ * `hawk-app usage --speed [--days 14] [--version <n>]`: per screen, the median and 90th percentile of its data time
100
+ * (and the page's load, slow clicks) in a version (default the newest) beside the version before it, the screens that
101
+ * got slower flagged; then the slowest platform calls.
102
+ */
103
+ async function speedCommand({ client, manifest, flags, log }) {
104
+ const days = parseDays(flags.days, 14, 90);
105
+ const speed = await client.appSpeed(manifest.slug, days);
106
+ let version = speed.versions[0];
107
+ if (flags.version !== undefined) {
108
+ version = Number(String(flags.version).replace(/^v/i, ''));
109
+ if (!Number.isInteger(version) || version < 1) throw new Error('--version must be a version number, e.g. --version 4.');
110
+ }
111
+ log(`${manifest.slug}, the last ${plural(days, 'day')}:`);
112
+ if (!speed.metrics.length || !speed.versions.includes(version)) {
113
+ log(speed.metrics.length ? ` Nothing measured in v${version} (versions measured: ${speed.versions.map((v) => `v${v}`).join(', ')}).` : ' Nothing measured yet.');
114
+ log(' The bridge measures each screen when clients use the app (not in hawk-app dev).');
115
+ return 0;
116
+ }
117
+ const before = speed.versions.find((v) => v < version);
118
+ const find = (screen, metric, v, route = null) => speed.metrics.find((m) => m.screen === screen && m.metric === metric && m.version === v && (m.route ?? null) === route);
119
+ const pair = (m) => (m ? `${seconds(m.p50)} / ${seconds(m.p90)}` : '—');
120
+ const screens = [...new Set(speed.metrics.filter((m) => m.version === version && m.metric !== 'call').map((m) => m.screen))];
121
+ // The slowest screens first (by their data's 90th percentile).
122
+ screens.sort((a, b) => (find(b, 'data', version)?.p90 ?? 0) - (find(a, 'data', version)?.p90 ?? 0));
123
+ const width = Math.min(36, Math.max(6, ...screens.map((s) => s.length)));
124
+ log('');
125
+ log(` ${'Screen'.padEnd(width)} ${`Data v${version}`.padEnd(15)} ${before ? `Data v${before}`.padEnd(15) : ''} ${'Load'.padEnd(15)} Slow clicks`);
126
+ log(` ${''.padEnd(width)} ${'median / 90%'.padEnd(15)} ${before ? 'median / 90%'.padEnd(15) : ''} ${'median / 90%'.padEnd(15)}`);
127
+ let slower = 0;
128
+ for (const screen of screens) {
129
+ const data = find(screen, 'data', version);
130
+ const old = before ? find(screen, 'data', before) : null;
131
+ const worse = data && old && data.p90 > old.p90 * SLOWER && data.p90 - old.p90 >= SLOWER_MS;
132
+ if (worse) slower++;
133
+ const clicks = find(screen, 'interaction', version);
134
+ const name = screen.length > width ? `${screen.slice(0, width - 1)}…` : screen;
135
+ log(` ${name.padEnd(width)} ${pair(data).padEnd(15)} ${before ? pair(old).padEnd(15) : ''} ${pair(find(screen, 'load', version)).padEnd(15)} ${clicks ? clicks.count : 0}${worse ? ' ! slower' : ''}`);
136
+ }
137
+ const calls = speed.metrics.filter((m) => m.version === version && m.metric === 'call').sort((a, b) => b.p90 - a.p90).slice(0, 8);
138
+ if (calls.length) {
139
+ log('');
140
+ log(` Slowest platform calls in v${version} (90th percentile):`);
141
+ for (const c of calls) log(` ${seconds(c.p90).padStart(7)} ${c.route} (${c.screen}, ${plural(c.count, 'call')}, median ${seconds(c.p50)})`);
142
+ }
143
+ log('');
144
+ if (slower) log(`${plural(slower, 'screen')} got slower than v${before} (90th percentile up more than 20%).`);
145
+ log('Data: from a screen showing until its platform calls are done. Times are approximate (bucketed); hawk-app dev isn\'t counted.');
146
+ return 0;
147
+ }
148
+
93
149
  const MOST = 15;
94
150
  const LEAST = 5;
95
151
  const VERSION_COLUMNS = 3;
@@ -10,8 +10,8 @@
10
10
  "app:audit": "npm audit --omit=dev --audit-level=high"
11
11
  },
12
12
  "dependencies": {
13
- "@bridgeline-digital/hawk-app-bridge": "^0.1.0",
14
- "@bridgeline-digital/hawk-app-ui": "^0.1.0",
13
+ "@bridgeline-digital/hawk-app-bridge": "^0.1.1",
14
+ "@bridgeline-digital/hawk-app-ui": "^0.1.1",
15
15
  "next": "^16.4.0",
16
16
  "react": "^19.1.0",
17
17
  "react-dom": "^19.1.0"
@@ -10,8 +10,8 @@
10
10
  "app:audit": "npm audit --omit=dev --audit-level=high"
11
11
  },
12
12
  "dependencies": {
13
- "@bridgeline-digital/hawk-app-bridge": "^0.1.0",
14
- "@bridgeline-digital/hawk-app-ui": "^0.1.0",
13
+ "@bridgeline-digital/hawk-app-bridge": "^0.1.1",
14
+ "@bridgeline-digital/hawk-app-ui": "^0.1.1",
15
15
  "@emotion/react": "^11.14.0",
16
16
  "@emotion/styled": "^11.14.1",
17
17
  "@mui/icons-material": "^9.5.0",
@@ -12,8 +12,8 @@
12
12
  "app:audit": "npm audit --omit=dev --audit-level=high"
13
13
  },
14
14
  "dependencies": {
15
- "@bridgeline-digital/hawk-app-bridge": "^0.1.0",
16
- "@bridgeline-digital/hawk-app-ui": "^0.1.0",
15
+ "@bridgeline-digital/hawk-app-bridge": "^0.1.1",
16
+ "@bridgeline-digital/hawk-app-ui": "^0.1.1",
17
17
  "preact": "^11.0.0"
18
18
  },
19
19
  "devDependencies": {
@@ -12,8 +12,8 @@
12
12
  "app:audit": "npm audit --omit=dev --audit-level=high"
13
13
  },
14
14
  "dependencies": {
15
- "@bridgeline-digital/hawk-app-bridge": "^0.1.0",
16
- "@bridgeline-digital/hawk-app-ui": "^0.1.0",
15
+ "@bridgeline-digital/hawk-app-bridge": "^0.1.1",
16
+ "@bridgeline-digital/hawk-app-ui": "^0.1.1",
17
17
  "react": "^19.1.0",
18
18
  "react-dom": "^19.1.0"
19
19
  },