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

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
@@ -306,14 +306,22 @@ HawkSearch). The full picture, with diagrams: docs/versions-and-aliases.md in th
306
306
 
307
307
  ## Errors and usage (`hawk-app errors`, `hawk-app usage`)
308
308
 
309
- The bridge reports the app's page errors and one open per page load (platform/docs/telemetry.md):
309
+ The bridge reports the app's page errors, one open per page load, and the screens and actions used
310
+ (platform/docs/telemetry.md):
310
311
 
311
312
  ```bash
312
313
  npx hawk-app errors # the last 7 days (--days 1-30): grouped, the most frequent first, with versions, clients and an [id]
313
314
  npx hawk-app errors --explain 3fa1c2d9 # one error's brief, then Claude's likely cause and patch (--no-ai: the brief only)
314
315
  npx hawk-app usage # the last 30 days (--days 1-400): opens, view opens, users and clients; staff apart
316
+ npx hawk-app usage --screens # screens and actions: the most used, then the least used, with the 3 newest versions
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
315
319
  ```
316
320
 
321
+ **What people use.** Screens are the app's routes (ids as `:id`), its views, and what it names with `app.usage.screen`
322
+ (the UI kit's `useScreen('Logs')`, for a tab); actions are clicks on `data-hawk-usage="Export CSV"` elements and
323
+ `app.usage.action`. Compare versions to see whether a change is used, and the least used to see what to simplify.
324
+
317
325
  **Errors that come with a fix.** `--explain <id>` writes the error's **brief** and asks Claude for the cause:
318
326
 
319
327
  - **The stack, mapped to your source:** each of the app's frames as `src/app/page.tsx:42:17`, with the code around it
@@ -333,6 +341,38 @@ It needs the version's **source maps**: the templates build them (`productionBro
333
341
  platform's private bucket instead of the CDN, so your source is never public. Without maps the stack stays minified and
334
342
  the brief says so. Errors of versions deployed before keep their minified stacks; the next deploy maps the new ones.
335
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
+
336
376
  ## Accessibility check
337
377
 
338
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
@@ -562,6 +602,17 @@ up to 50 rows plus the bytes scanned. `--client` is the HawkSearch client id (th
562
602
  `context.tenant.clientId`); on dev, use a demo or test client. `string[]` values are comma-separated (`--param engines=a,b`). It only works
563
603
  against the dev platform. When too many queries are running (429 `too_many_queries`) it waits and tries again.
564
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
+
565
616
  The sensitive tables (visitor-level data such as `summary`) are listed apart: only apps Engineering has opted in can
566
617
  query them. Ask Engineering if your app needs one.
567
618
 
@@ -739,7 +790,7 @@ attribute (`"data": "hawk-keyword"`), used as is when the page has it, with its
739
790
  (the bridge README's **Actions**, with every target setting). `hawk-app validate` checks the targets (paths and
740
791
  patterns are regular expressions, params the view declares); `hawk-app status` lists the actions. `hawk-app dev` has an
741
792
  **Action** menu: pick an action, type a value, and the view opens in a dialog as in HawkSearch. For complete recipes
742
- (actions, schedules, events, a client API endpoint and their tests), see [docs/examples.md](../../docs/examples.md).
793
+ (actions, schedules, events, a client API endpoint and their tests), see the [Recipes](../../docs/examples.md).
743
794
 
744
795
  ### Testing functions (`hawk-app test`)
745
796
 
package/agents/AGENTS.md CHANGED
@@ -146,10 +146,26 @@ With the UI kit: `usePageContext(rows ? { page: '<the page>', dateRange, totals,
146
146
  `app.assistant.setContext({ ... })` whenever the data or filters change. Plain names and numbers of what's on screen,
147
147
  at most 50 KB; summarize long tables. `hawk-app validate` warns when an app never sets one.
148
148
 
149
+ **Comments** (scope `comments`): `<HawkComments target="keyword:…" label="…" />` from the UI kit on what people discuss
150
+ (a row, a widget, `page:summary`); targets are stable ids, never personal data. Declare `config.commentEmails`
151
+ (`{ "type": "emails" }`) so new comments are emailed (`hawk-app config set commentEmails …`, a person or a Slack
152
+ channel's email).
153
+
149
154
  **Export as PDF** (only on pages worth keeping: a report, a summary): `usePdfExport({ fileName })` from the UI kit
150
155
  (`false` while loading), or `app.setPdfExport({ fileName })` and `app.setPdfExport(null)` when the page leaves. The
151
156
  host shows a PDF icon and downloads the whole page; mark buttons and filters `data-hawk-pdf-ignore`.
152
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
+
165
+ **Usage** is counted by the bridge: routes and views as screens. Name what they don't show: `useScreen('Logs')` from the
166
+ UI kit in each tab (or `app.usage.screen`), and mark the main buttons `data-hawk-usage="Export CSV"` (or
167
+ `app.usage.action`). Name the thing, never data (a keyword, a client).
168
+
153
169
  **Actions** (`actions` in `hawk-app.json`) add a button to HawkSearch's own pages that opens a view in a dialog:
154
170
  `{ "<name>": { "label": "Fix with …", "view": "fix", "targets": { "hawksearch": [{ "path": "<regex on the page path>",
155
171
  "selector": "<CSS: each thing>", "anchor"?: "<CSS inside it>", "position"?: "after", "params": { "keyword": { "data"?,
@@ -195,6 +211,11 @@ npx hawk-app config list # config values (config set <key> <value> [--cl
195
211
  npx hawk-app errors # errors in the app's pages (deployed versions), the most frequent first, with [id]s
196
212
  npx hawk-app errors --explain <id> # one error mapped to the source, the failed calls, what changed; then a likely fix
197
213
  npx hawk-app usage # opens, users and clients per day
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>
198
219
  npx hawk-app upgrade # bring the app (and this file) up to the CLI's current template
199
220
  npx hawk-app upgrade --channel latest # a dev app (bridge/CLI on the `dev` tag) to the released versions
200
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`;
@@ -150,13 +150,15 @@ buttons appear in HawkSearch: nothing changes in HawkSearch for it.
150
150
  "params": { "keyword": { "selector": "td.table__cell:first-child" } } // its text
151
151
  },
152
152
  {
153
+ // AI Advisor: only the insights that suggest a synonym or a redirect (HawkSearch marks each insight with
154
+ // data-hawk-action, data-hawk-category, data-hawk-keyword and data-hawk-insight-id).
153
155
  "path": "/admin/recommendations/?$",
154
- "selector": ".recommendations-list__item:not(.recommendations-list__item--empty)",
156
+ "selector": ".recommendations-list__item[data-hawk-action=\"Synonym\"], .recommendations-list__item[data-hawk-action=\"Redirect\"]",
155
157
  "anchor": ".recommendations-list__text",
156
158
  "position": "after",
157
159
  "params": {
158
- "keyword": { "selector": ".recommendations-list__text", "pattern": "[\"“”]([^\"“”]{1,100})[\"“”]" },
159
- "category": { "selector": ".recommendations-list__category-tag" }
160
+ "keyword": { "data": "hawk-keyword" }, // the insight's exact term
161
+ "category": { "data": "hawk-category" }
160
162
  }
161
163
  }
162
164
  ]
@@ -177,7 +179,8 @@ buttons appear in HawkSearch: nothing changes in HawkSearch for it.
177
179
  - **Pick stable selectors:** ids, `data-*` attributes and element names over styling classes. HawkSearch doesn't know
178
180
  about your selectors: when it changes its markup, your buttons stop appearing until you deploy new targets.
179
181
  - **Approval:** `actions` is a setting that always needs an approver (never auto-approve): reviewers see each target and
180
- what it reads from the page. Read only what the view needs. Settings and the Apps pages never show actions.
182
+ what it reads from the page. Before approval, Bridgeline staff already get the new actions on a draft app (the version
183
+ they open), so they can try the buttons in HawkSearch first; clients only ever get approved ones. Read only what the view needs. Settings and the Apps pages never show actions.
181
184
 
182
185
  ```tsx
183
186
  // src/app/views/fix/page.tsx
@@ -1209,6 +1212,46 @@ await app.storage.files.delete('exports/prices.csv');
1209
1212
 
1210
1213
  See the platform's docs/storage.md.
1211
1214
 
1215
+ ## Comments: `comments`
1216
+
1217
+ Let the people using the app talk about what it shows, right where it is: a comment thread on a search term, a report
1218
+ row, a widget or the whole page. Everyone at the client who uses the app sees the comments, and so do Bridgeline staff.
1219
+ They're kept until deleted, and each new one is emailed to whoever you set.
1220
+
1221
+ ```json
1222
+ {
1223
+ "scopes": ["comments"],
1224
+ "config": { "commentEmails": { "type": "emails", "label": "Who gets new comments" } }
1225
+ }
1226
+ ```
1227
+
1228
+ ```tsx
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
+ <HawkComments target={`keyword:${row.keyword}`} label={row.keyword} />
1231
+ <HawkComments target="page:summary" label="This month's summary" />
1232
+ ```
1233
+
1234
+ ```ts
1235
+ // Or the bridge itself:
1236
+ const thread = await app.comments.list('keyword:rain-boots'); // [{ id, text, author, createdAt, mine, canDelete, … }], oldest first
1237
+ await app.comments.add('keyword:rain-boots', 'Catalog gap or synonym?', { label: 'rain boots' });
1238
+ await app.comments.edit('keyword:rain-boots', id, 'Synonym: we sell rain boots.'); // the author only
1239
+ await app.comments.delete('keyword:rain-boots', id); // the author, or Bridgeline staff
1240
+ const counts = await app.comments.counts(['keyword:rain-boots', 'keyword:wellies']); // { 'keyword:rain-boots': 2 }
1241
+ ```
1242
+
1243
+ - **Targets** are yours: stable ids of 1-200 characters (letters, digits, `. _ - : / # @`), never personal data. The
1244
+ same target on two pages is the same thread. `label` names it in words for the drawer and the email.
1245
+ - **Email:** set `commentEmails` (declared as `emails` in `config`) to a person, a team list or a Slack channel's
1246
+ email address (Slack: the channel's Integrations › Send emails to this channel), for every client or one:
1247
+ `npx hawk-app config set commentEmails merch@agency.example [--client e3141]`. No deploy needed. Each new comment is
1248
+ sent there from the platform, with the app's name, the target and the client; replies go to the author. Without the
1249
+ key, nothing is sent. `hawk-app validate` warns about it.
1250
+ - **Authors** are shown by the email the host sends; Bridgeline staff are marked. Text only, up to 4,000 characters;
1251
+ 30 new or edited comments a minute per app and client (429 `too_many_requests`).
1252
+ - Errors (`HawkApiError`): 400 `invalid_target` / `invalid_comment`, 403 `scope_missing` / `not_author`, 404
1253
+ `comment_not_found`. See the platform's docs/comments.md.
1254
+
1212
1255
  ## External Lambdas: `lambdas:invoke`
1213
1256
 
1214
1257
  Call a Lambda written outside Hawk Apps (for example HawkAnalytics' client setup) from the app or a function. Declare
@@ -1409,11 +1452,31 @@ Failed data calls throw `HawkApiError` with `status` and `code`; failed host req
1409
1452
 
1410
1453
  A `403` without one of these codes means the app lacks the scope for that call.
1411
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
+
1412
1474
  ## Errors and usage
1413
1475
 
1414
- `createApp` reports, on its own, one open per page load (the full app or the view showing) and the page's uncaught
1415
- errors and unhandled rejections: at most 10 per page load, each once, without query strings. The app's team sees them
1416
- with `hawk-app errors` and `hawk-app usage`, and Bridgeline in the portal (platform/docs/telemetry.md).
1476
+ `createApp` reports, on its own, one open per page load (the full app or the view showing), the screens and actions
1477
+ used, and the page's uncaught errors and unhandled rejections: at most 10 per page load, each once, without query
1478
+ strings. The app's team sees them with `hawk-app errors` and `hawk-app usage`, and Bridgeline in the portal
1479
+ (platform/docs/telemetry.md).
1417
1480
 
1418
1481
  ```ts
1419
1482
  try {
@@ -1424,7 +1487,26 @@ try {
1424
1487
  }
1425
1488
  ```
1426
1489
 
1427
- `createApp({ reportErrors: false, reportOpens: false })` turns either off.
1490
+ **What people use.** The bridge also counts the **screens** people open (each route of the full app, as `/orders/:id`
1491
+ with ids left out, or the view showing) and the **actions** they take: clicks on elements marked `data-hawk-usage`. Only
1492
+ these names are sent, batched every 15 s, never what users type or see. Count what the routes don't show yourself:
1493
+
1494
+ ```tsx
1495
+ <Button data-hawk-usage="Export CSV" onClick={exportCsv}>Export</Button> // counted on each click
1496
+
1497
+ app.usage.screen('Logs'); // a tab or step within one route (the UI kit: useScreen('Logs') in the tab)
1498
+ app.usage.action('Bulk fix'); // an action that isn't a click on one element
1499
+ ```
1500
+
1501
+ Names are letters, digits and simple punctuation, up to 80 characters: name the thing (`Export CSV`), not the data
1502
+ (never a keyword or a client). `hawk-app usage --screens` lists the most and least used, per version.
1503
+
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.
1428
1510
 
1429
1511
  ## Talk to the host
1430
1512
 
@@ -1522,6 +1604,9 @@ useEffect(() => void app.assistant.setContext({ page: 'Recommendation Dashboard'
1522
1604
  await app.assistant.ask('Which widgets are taking up space?');
1523
1605
  ```
1524
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
+
1525
1610
  A message is at most 2,000 characters. Errors (`HawkBridgeError`): `unsupported` (no assistant on the page), `scope_missing`,
1526
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
1527
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,9 +42,19 @@ 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 icon with a "!" when there are comments (the count in its tooltip); a click opens the
46
+ thread in a drawer, where everyone at the client reads and adds comments (authors edit and delete their own). The
47
+ page's badges share one count request. See the bridge guide's "Comments".
45
48
  - **`usePdfExport(options | false)`** lets the user export the page as a PDF while it's mounted: the host shows a PDF
46
49
  icon, and a click downloads the whole page. `usePdfExport({ fileName: 'Zero results' })`, or `false` while loading.
47
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".
55
+ - **`useScreen(name | null)`** counts a screen the routes don't show, such as a tab, each time it's shown:
56
+ `useScreen('Logs')` in the tab's component. Routes and views are counted by the bridge, and so are clicks on
57
+ `data-hawk-usage="Export CSV"` elements. See `hawk-app usage --screens` and the bridge guide's "Errors and usage".
48
58
  - **`usePageContext(object | null)`** says what the page shows, for the host's **Explain this page** button (and its
49
59
  assistant): `usePageContext(rows ? { page: 'Zero-result searches', dateRange, top10 } : null)`. Plain names and
50
60
  numbers, at most 50 KB; sent shortly after it changes, cleared when the component unmounts. See the bridge guide's
@@ -85,9 +95,8 @@ const f = useFormat(); // in the host's locale
85
95
 
86
96
  The host shows the frame over the whole page while a dialog or drawer is open, so they're always fully visible.
87
97
 
88
- Every component, in light and dark, is in the [UI kit gallery](GALLERY.md) on the developer site (the gallery app,
89
- [examples/ui-kit](../../examples/ui-kit), as a preview; run it with `npm run dev` there and open it through a host to
90
- see it in a real one). `<HawkAppProvider preview={{ mode: 'dark' }}>` renders without a host, with sample context
98
+ Every component, in light and dark, is in the [UI kit gallery](GALLERY.md) on the developer site (the repository's
99
+ gallery app, `examples/ui-kit`, rendered as a preview). `<HawkAppProvider preview={{ mode: 'dark' }}>` renders without a host, with sample context
91
100
  (`previewContext()`), for galleries, docs and screenshots. New apps get the kit from the CLI's `next-mui` template
92
101
  (`hawk-app create my-app --template next-mui`); an existing next-mui app gets it with `hawk-app upgrade`.
93
102
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridgeline-digital/hawk-app-cli",
3
- "version": "0.1.1-dev.20261026155507",
3
+ "version": "0.1.1-dev.20261026224639",
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",
@@ -120,7 +120,8 @@
120
120
  "hawksearch:recommendations",
121
121
  "storage",
122
122
  "assistant",
123
- "hawkai:read"
123
+ "hawkai:read",
124
+ "comments"
124
125
  ]
125
126
  },
126
127
  "uniqueItems": true
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
@@ -65,7 +67,9 @@ Data and functions
65
67
  secrets, logs Function secrets and logs
66
68
 
67
69
  Monitor
68
- errors, usage The app's page errors, and its opens, users and clients per day
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
@@ -196,6 +214,11 @@ Monitor:
196
214
  cause and patch (--no-ai: the brief only)
197
215
  hawk-app usage [--days 30] Opens of the app and its views, users and clients per day (up to
198
216
  400 days; Bridgeline staff counted apart)
217
+ hawk-app usage --screens [--days 30] [--version <n>]
218
+ The screens (routes, views, app.usage.screen) and actions
219
+ ([data-hawk-usage], app.usage.action) people use: the most used
220
+ first, the least used last, the newest versions side by side
221
+ (--version: one version's counts)
199
222
 
200
223
  Custom data (app queries, scope data:read):
201
224
  hawk-app data tables [--app <slug>] List the tables queries can use (--app: is the app
@@ -204,6 +227,11 @@ Custom data (app queries, scope data:read):
204
227
  hawk-app data run <queries/name.sql> --client <id> [--param name=value …]
205
228
  Run a query against a client's data (dev platform only);
206
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
207
235
 
208
236
  Functions (server-side code, scope functions:invoke):
209
237
  hawk-app secrets list List the app's secrets (names only, never values)
@@ -600,7 +628,8 @@ export async function main(argv, { fetchImpl = fetch, log = console.log, runBuil
600
628
  case 'dev': {
601
629
  const manifest = await loadManifest(manifestFile);
602
630
  const projectDir = dirname(resolve(manifestFile));
603
- 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) {
604
633
  // No client yet: ask which one (by name), and remember it in .env.local for next time.
605
634
  const id = await pickDevClient({ client, slug: manifest.slug, ...(choose ? { choose } : {}), ...(ask ? { ask } : {}), log });
606
635
  await rememberDevClient(projectDir, id);
@@ -624,7 +653,10 @@ export async function main(argv, { fetchImpl = fetch, log = console.log, runBuil
624
653
  }
625
654
 
626
655
  case 'data':
627
- 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
+ });
628
660
 
629
661
  case 'secrets': {
630
662
  const manifest = await loadManifest(manifestFile);
@@ -660,6 +692,14 @@ export async function main(argv, { fetchImpl = fetch, log = console.log, runBuil
660
692
  return usageCommand({ client: client(), manifest: await loadManifest(manifestFile), flags, log });
661
693
  }
662
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
+
663
703
  case 'logs': {
664
704
  const manifest = await loadManifest(manifestFile);
665
705
  const controller = new AbortController();
@@ -852,7 +892,7 @@ export function parse(argv) {
852
892
  flags.param = [...(flags.param ?? []), inline ?? argv[++i]];
853
893
  continue;
854
894
  }
855
- const takesValue = ['manifest', 'platform', 'slug', 'name', 'template', 'client', 'app', 'since', 'value-from-env', 'engine', 'url', 'port', 'env', 'publisher', 'pick', 'channel', 'days', 'alias', 'explain'].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);
856
896
  flags[name] = takesValue ? inline ?? argv[++i] : true;
857
897
  } else if (command === undefined) {
858
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) {