@bridgeline-digital/hawk-app-cli 0.1.2-dev.20261027234716 → 0.1.2-dev.20261028113950
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 +22 -1
- package/agents/AGENTS.md +13 -2
- package/docs/bridge/README.md +56 -1
- package/package.json +1 -1
- package/schema/hawk-app.schema.json +17 -0
- package/src/cli.mjs +59 -5
- package/src/contracts.mjs +212 -0
- package/src/create.mjs +8 -1
- package/src/deploy.mjs +21 -7
- package/src/help.mjs +56 -0
- package/src/manifest.mjs +36 -3
package/README.md
CHANGED
|
@@ -648,6 +648,25 @@ and a version whose functions changed **always needs review**, even on an auto-a
|
|
|
648
648
|
5. Call it from the app: `await app.functions.call('sync-feed', { since: '2026-09-01' })`. Like queries, functions
|
|
649
649
|
always run from a **deployed** version, also in dev mode (`?devUrl=`).
|
|
650
650
|
|
|
651
|
+
**Contracts.** Declare what a function takes and returns with zod (`npm install zod`), and deploy sends them with the
|
|
652
|
+
function: the platform then refuses a call whose input doesn't match (400 `invalid_function_input`) before it runs.
|
|
653
|
+
|
|
654
|
+
```ts
|
|
655
|
+
import { z } from 'zod';
|
|
656
|
+
import { defineFunction } from '@bridgeline-digital/hawk-app-bridge/server';
|
|
657
|
+
export const handler = defineFunction({
|
|
658
|
+
input: z.object({ name: z.string() }),
|
|
659
|
+
output: z.object({ greeting: z.string() }),
|
|
660
|
+
async handler(input, ctx) { return { greeting: `Hello ${input.name} from ${ctx.client.id}` }; },
|
|
661
|
+
});
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
`npx hawk-app types` writes `hawk-functions.d.ts` (in `src/`, else next to `hawk-app.json`) from the contracts, so
|
|
665
|
+
pages get `app.functions.call('echo', { name })` typed without a type argument; `hawk-app dev` refreshes it when it
|
|
666
|
+
starts, and deploy warns when it's out of date. Deploy prints `contracts: <names>` and stops when a contract can't be
|
|
667
|
+
read (a schema that isn't zod, or larger than 6 KB as JSON Schema). Client API endpoints get an OpenAPI document from
|
|
668
|
+
them, and approvers see contract changes that may break callers.
|
|
669
|
+
|
|
651
670
|
`hawk-app validate` and `hawk-app deploy` check, with the platform's rules:
|
|
652
671
|
- at most 10 functions; names are lowercase letters, digits and single dashes, up to 40 characters;
|
|
653
672
|
- `job` true or false; `timeoutSeconds` 1–25 (1–900 for a job), `memoryMb` 128–1024, `secrets` up to 20 unique
|
|
@@ -785,7 +804,9 @@ param is read:
|
|
|
785
804
|
Deploy; once the version is approved, the buttons appear in HawkSearch with no change there. `actions` always needs an
|
|
786
805
|
approver (never auto-approve): reviewers see each target and what it reads. Pick stable selectors (ids, `data-*`
|
|
787
806
|
attributes, element names): if HawkSearch changes its markup, deploy new targets. A param can also name a `data-*`
|
|
788
|
-
attribute (`"data": "hawk-keyword"`), used as is when the page has it, with its selector and pattern as the fallback.
|
|
807
|
+
attribute (`"data": "hawk-keyword"`), used as is when the page has it, with its selector and pattern as the fallback.
|
|
808
|
+
A target's `when` shows the button only where the values read meet conditions (`equals`, `in` or `pattern`, ignoring
|
|
809
|
+
case), e.g. `"when": { "category": { "in": ["Synonym", "Redirect"] } }`. The view reads
|
|
789
810
|
`app.context.view.params` and `app.context.view.action`, and calls `app.action.done({ changed })` to close the dialog
|
|
790
811
|
(the bridge README's **Actions**, with every target setting). `hawk-app validate` checks the targets (paths and
|
|
791
812
|
patterns are regular expressions, params the view declares); `hawk-app status` lists the actions. `hawk-app dev` has an
|
package/agents/AGENTS.md
CHANGED
|
@@ -148,6 +148,11 @@ With the UI kit: `usePageContext(rows ? { page: '<the page>', dateRange, totals,
|
|
|
148
148
|
`app.assistant.setContext({ ... })` whenever the data or filters change. Plain names and numbers of what's on screen,
|
|
149
149
|
at most 50 KB; summarize long tables. `hawk-app validate` warns when an app never sets one.
|
|
150
150
|
|
|
151
|
+
**Help** (`HELP.md` next to hawk-app.json): the app's guide for its users, which HawkSearch's Help tab answers "How do
|
|
152
|
+
I…?" from. Keep it in step with the app: when you add or change a screen, a button or a workflow, update the steps in
|
|
153
|
+
HELP.md too, naming buttons and fields exactly as the UI shows them. Plain Markdown for merchandisers (not developers),
|
|
154
|
+
at most 32 KB; it ships with each deploy. HTML comments are notes for the team and are left out.
|
|
155
|
+
|
|
151
156
|
**Comments** (scope `comments`): `<HawkComments target="keyword:…" label="…" />` from the UI kit on what people discuss
|
|
152
157
|
(a row, a widget, `page:summary`); targets are stable ids, never personal data. Declare `config.commentEmails`
|
|
153
158
|
(`{ "type": "emails" }`) so new comments are emailed (`hawk-app config set commentEmails …`, a person or a Slack
|
|
@@ -175,8 +180,9 @@ UI kit in each tab (or `app.usage.screen`), and mark the main buttons `data-hawk
|
|
|
175
180
|
**Actions** (`actions` in `hawk-app.json`) add a button to HawkSearch's own pages that opens a view in a dialog:
|
|
176
181
|
`{ "<name>": { "label": "Fix with …", "view": "fix", "targets": { "hawksearch": [{ "path": "<regex on the page path>",
|
|
177
182
|
"selector": "<CSS: each thing>", "anchor"?: "<CSS inside it>", "position"?: "after", "params": { "keyword": { "data"?,
|
|
178
|
-
"selector"?, "attribute"?, "pattern"? } }
|
|
179
|
-
|
|
183
|
+
"selector"?, "attribute"?, "pattern"? } }, "when"?: { "<param>": { "equals" | "in" | "pattern": … } } }] } } }`.
|
|
184
|
+
`"data": "keyword"` reads `data-keyword` as is when the page has it, else the rest of the reader applies. Use `when` so
|
|
185
|
+
the button shows only where the action applies (conditions on the values read, ignoring case), not on every match. Prefer stable selectors (ids, `data-*`, element names); the view must list the
|
|
180
186
|
params in its `params` and handle them missing. The view reads `app.context.view.params` (and `context.view.action`:
|
|
181
187
|
`{ name }`), its toasts show, and `app.action.done({ changed: true })` closes the dialog once the work is saved. Actions
|
|
182
188
|
always need an approver. Try it with `hawk-app dev`'s Action menu.
|
|
@@ -195,6 +201,11 @@ page, at the slot's height, so don't rely on the full window either.
|
|
|
195
201
|
`@bridgeline-digital/hawk-app-bridge/server`. Use `ctx.platform` (same APIs as `app`), `ctx.secrets.NAME`,
|
|
196
202
|
`ctx.client.id`, `ctx.user`. Stateless; 25 s, or 900 s for a job (`"job": true`). Engineering enables functions per
|
|
197
203
|
app.
|
|
204
|
+
- **Contracts** (prefer for every function): `export const handler = defineFunction({ input: z.object({…}), output:
|
|
205
|
+
z.object({…}), async handler(input, ctx) {…} })` with zod (add it to the app). The platform refuses a call whose
|
|
206
|
+
input doesn't match (400 `invalid_function_input`) before the function runs. Run `npx hawk-app types` after changing
|
|
207
|
+
a contract: it writes `hawk-functions.d.ts`, so pages call `app.functions.call('<name>', input)` without a type
|
|
208
|
+
argument and get the result typed. Client API endpoints get an OpenAPI document from them.
|
|
198
209
|
|
|
199
210
|
## Project
|
|
200
211
|
|
package/docs/bridge/README.md
CHANGED
|
@@ -176,6 +176,11 @@ buttons appear in HawkSearch: nothing changes in HawkSearch for it.
|
|
|
176
176
|
as is (no pattern); when it's missing or empty, the param's `selector`, `attribute` and `pattern` read it as above.
|
|
177
177
|
So a target can be ready for an attribute HawkSearch adds later, e.g. `"keyword": { "data": "hawk-keyword",
|
|
178
178
|
"selector": "td.table__cell:first-child" }`. With `data` alone, nothing is read until the attribute is there.
|
|
179
|
+
- **Only where it applies (optional):** `when` shows the button only on the things whose values meet every condition,
|
|
180
|
+
by param (one of the target's `params`): `{ "equals": "…" }`, `{ "in": ["…", …] }` (at most 20) or
|
|
181
|
+
`{ "pattern": "<regex>" }`, all ignoring case. A param that couldn't be read fails its condition, so no button goes
|
|
182
|
+
where a value is missing. Use it for what a CSS selector can't say: a cell's text, a value a pattern pulls out, e.g.
|
|
183
|
+
`"when": { "category": { "in": ["Zero results", "Poor results"] }, "keyword": { "pattern": "\\S" } }`.
|
|
179
184
|
- **Pick stable selectors:** ids, `data-*` attributes and element names over styling classes. HawkSearch doesn't know
|
|
180
185
|
about your selectors: when it changes its markup, your buttons stop appearing until you deploy new targets.
|
|
181
186
|
- **Approval:** `actions` is a setting that always needs an approver (never auto-approve): reviewers see each target and
|
|
@@ -577,6 +582,42 @@ const { items } = await app.functions.call<{ items: unknown[] }>('sync-feed', {
|
|
|
577
582
|
what `handler` returned (up to 6 MB). It waits up to `timeoutMs` (default 30 s) and retries while the platform answers
|
|
578
583
|
429 (too many calls running), like data queries.
|
|
579
584
|
|
|
585
|
+
### Contracts: `defineFunction`
|
|
586
|
+
|
|
587
|
+
Say what a function takes and returns with zod schemas (add `zod` to the app), and every caller agrees on one shape:
|
|
588
|
+
|
|
589
|
+
```ts
|
|
590
|
+
// functions/price-check.ts
|
|
591
|
+
import { z } from 'zod';
|
|
592
|
+
import { defineFunction } from '@bridgeline-digital/hawk-app-bridge/server';
|
|
593
|
+
|
|
594
|
+
export const handler = defineFunction({
|
|
595
|
+
input: z.object({ sku: z.string().min(1), limit: z.number().int().default(5) }),
|
|
596
|
+
output: z.object({ ours: z.number(), lowest: z.number().nullable() }),
|
|
597
|
+
async handler({ sku, limit }, ctx) { // typed from input: limit is a number (its default applied)
|
|
598
|
+
return { ours: 10, lowest: null };
|
|
599
|
+
},
|
|
600
|
+
});
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
```ts
|
|
604
|
+
// In the app, after `npx hawk-app types` (hawk-app dev runs it when it starts)
|
|
605
|
+
const price = await app.functions.call('price-check', { sku: 'abc' }); // input checked, result typed
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
- **The platform checks every call's input** against the contract before the function runs: a mismatch is
|
|
609
|
+
`HawkApiError` 400 `invalid_function_input`, with `errors` saying what's wrong (`"/sku: …"`); nothing runs or is
|
|
610
|
+
billed. Jobs (`start`) and the app's client API endpoints are checked the same way.
|
|
611
|
+
- **Typed calls:** `hawk-app types` writes `hawk-functions.d.ts` from the contracts; `call` and `start` are then typed
|
|
612
|
+
for those functions, and a function without a contract stays `call<T>(name, input)`. Deploy warns when the file is
|
|
613
|
+
behind the contracts.
|
|
614
|
+
- **Client API:** an endpoint's function contract types a GET's query (`?limit=5` is a number) and describes the
|
|
615
|
+
endpoint in the OpenAPI document the API access screen offers.
|
|
616
|
+
- **Review:** a contract change that may break callers (a newly required input field, a removed output field, a
|
|
617
|
+
changed type) is flagged for the approver.
|
|
618
|
+
- Any Standard Schema works for the input check in the function, but deploy describes only zod schemas (zod 4's
|
|
619
|
+
`toJSONSchema`). Keep each schema to the fields callers send and read (at most 6 KB as JSON Schema).
|
|
620
|
+
|
|
580
621
|
**`ctx`** (`HawkFunctionContext`)
|
|
581
622
|
|
|
582
623
|
| | |
|
|
@@ -1566,6 +1607,20 @@ Hosts: `<hawk-app-explain for="<frame id>">` (registered with `<hawk-app-frame>`
|
|
|
1566
1607
|
the frame's `pageContext`, `hawk-page-context` event and `explainPage()` are there for hosts that build their own. See
|
|
1567
1608
|
[HOSTS.md](HOSTS.md).
|
|
1568
1609
|
|
|
1610
|
+
### Help: the app's guide, `HELP.md`
|
|
1611
|
+
|
|
1612
|
+
The same panel has a **Help** tab where users ask "How do I…?" and the platform's AI answers from your app's guide:
|
|
1613
|
+
`HELP.md`, next to hawk-app.json. No code and no scope; the button shows for Help alone on pages that set no context.
|
|
1614
|
+
|
|
1615
|
+
- **Write it for the people who use the app:** what it's for in two or three sentences, each task as numbered steps that
|
|
1616
|
+
name the buttons and fields as the app shows them, what its numbers and statuses mean, and common questions with their
|
|
1617
|
+
answers. Plain Markdown, at most 32 KB. HTML comments are notes for your team: Help leaves them out.
|
|
1618
|
+
- **It ships with each deploy** (privately, not on the CDN), so every version answers from its own guide; deploy after
|
|
1619
|
+
changing it. `hawk-app create` starts one with only comments, and Help stays off until you write it.
|
|
1620
|
+
- **Answers** use the guide and the page context (above), so "what does this column mean?" gets an answer about the
|
|
1621
|
+
page the user is on. They never invent features; a question the guide doesn't cover is pointed to "Report a problem".
|
|
1622
|
+
Help and Explain share one daily budget per client.
|
|
1623
|
+
|
|
1569
1624
|
### Export a page as a PDF
|
|
1570
1625
|
|
|
1571
1626
|
Pages worth keeping (a report, a summary for a meeting) can offer **Export as PDF**: while the page allows it, the
|
|
@@ -1726,7 +1781,7 @@ Which clients have the app is not in this file: manage them in the Hawk Analytic
|
|
|
1726
1781
|
| `functions` | Your [functions](#functions-functionsinvoke) and their settings: `{ "<name>": { "job", "timeoutSeconds", "memoryMb", "secrets" } }`. Recorded with each version. |
|
|
1727
1782
|
| `schedules` | [Schedules](#schedules) for job functions: `{ "<name>": { "function", "cron", "timezone" } }`, at most 5, at most hourly. Needs `functions:invoke`. Reviewed like scopes. |
|
|
1728
1783
|
| `events` | [Events](#events): a job function per event (`client.added`, `config.changed`, `version.released`, `hawksearch.changed`, `index.rebuilt`), `{ "<event>": { "function" } }` (`hawksearch.changed` also takes `sections`). Needs `functions:invoke`. Reviewed like scopes. |
|
|
1729
|
-
| `actions` | [Actions](#actions-start-the-app-from-hawksearchs-screens): buttons in HawkSearch's pages that open a view in a dialog, `{ "<name>": { "label", "view", "targets": { "hawksearch": [{ "path", "selector", "anchor"?, "position"?, "params" }] } } }` (at most 10). Always reviewed by an approver. |
|
|
1784
|
+
| `actions` | [Actions](#actions-start-the-app-from-hawksearchs-screens): buttons in HawkSearch's pages that open a view in a dialog, `{ "<name>": { "label", "view", "targets": { "hawksearch": [{ "path", "selector", "anchor"?, "position"?, "params", "when"? }] } } }` (at most 10). Always reviewed by an approver. |
|
|
1730
1785
|
| `api` | [Client API](#client-api): endpoints the client's systems call with their HawkSearch site API key, `{ "<name>": { "function", "method"?: "GET" \| "POST", "description"? } }`, each a function that isn't a job (at most 10). Needs `functions:invoke`. Reviewed like scopes. |
|
|
1731
1786
|
| `systemParameters` | The HawkSearch [system parameters](#hawksearch-system-parameters-hawksearchsysparamswrite) the app may read and set, e.g. `["Timezone"]`. Needs `hawksearch:sysparams:write`. Reviewed like scopes. |
|
|
1732
1787
|
| `config` | [App config](#app-config) keys: `{ "<key>": { "type", "label", "description", "default" } }`, at most 50. The values are set outside the file (portal or `npx hawk-app config`) and aren't secret. Reviewed like scopes. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bridgeline-digital/hawk-app-cli",
|
|
3
|
-
"version": "0.1.2-dev.
|
|
3
|
+
"version": "0.1.2-dev.20261028113950",
|
|
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",
|
|
@@ -236,6 +236,23 @@
|
|
|
236
236
|
"pattern": { "type": "string", "minLength": 1, "maxLength": 300 }
|
|
237
237
|
}
|
|
238
238
|
}
|
|
239
|
+
},
|
|
240
|
+
"when": {
|
|
241
|
+
"description": "Shows the button only on the things whose values meet every condition, by param (one of this target's params), ignoring case. A param that couldn't be read fails. E.g. { \"category\": { \"in\": [\"Synonym\", \"Redirect\"] } }.",
|
|
242
|
+
"type": "object",
|
|
243
|
+
"minProperties": 1,
|
|
244
|
+
"maxProperties": 10,
|
|
245
|
+
"additionalProperties": {
|
|
246
|
+
"type": "object",
|
|
247
|
+
"minProperties": 1,
|
|
248
|
+
"maxProperties": 1,
|
|
249
|
+
"additionalProperties": false,
|
|
250
|
+
"properties": {
|
|
251
|
+
"equals": { "description": "The value is this.", "type": "string", "minLength": 1, "maxLength": 100 },
|
|
252
|
+
"in": { "description": "The value is one of these.", "type": "array", "minItems": 1, "maxItems": 20, "items": { "type": "string", "minLength": 1, "maxLength": 100 } },
|
|
253
|
+
"pattern": { "description": "The value matches this regular expression.", "type": "string", "minLength": 1, "maxLength": 300 }
|
|
254
|
+
}
|
|
255
|
+
}
|
|
239
256
|
}
|
|
240
257
|
}
|
|
241
258
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { writeFile } from 'node:fs/promises';
|
|
3
|
-
import { basename, dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
4
4
|
import { TEMPLATES, createProject, titleCase, toSlug } from './create.mjs';
|
|
5
5
|
import { addAgentFiles } from './agents.mjs';
|
|
6
6
|
import { confirmVulnerabilities, deploy, npmRunBuild, submissionClearedMessage } from './deploy.mjs';
|
|
@@ -16,8 +16,10 @@ import { DEFAULT_ICON_SVG, checkIcon } from './app-icon.mjs';
|
|
|
16
16
|
import { checkScreenshots } from './store-listing.mjs';
|
|
17
17
|
import { platformClient } from './platform.mjs';
|
|
18
18
|
import { dataCommand } from './data.mjs';
|
|
19
|
+
import { hasZod, readContracts, TYPES_FILE, writeTypes } from './contracts.mjs';
|
|
20
|
+
import { HELP_FILE, loadHelp } from './help.mjs';
|
|
19
21
|
import { DATA_SCOPE, QUERIES_DIR, loadQueries } from './queries.mjs';
|
|
20
|
-
import { FUNCTIONS_DIR, checkBridge, describeFunctions, loadFunctions } from './functions.mjs';
|
|
22
|
+
import { BRIDGE_PACKAGE, FUNCTIONS_DIR, checkBridge, describeFunctions, loadFunctions } from './functions.mjs';
|
|
21
23
|
import { secretsCommand } from './secrets.mjs';
|
|
22
24
|
import { configCommand } from './config.mjs';
|
|
23
25
|
import { clientsCommand, pickDevClient, rememberDevClient } from './clients.mjs';
|
|
@@ -249,6 +251,12 @@ Functions (server-side code, scope functions:invoke):
|
|
|
249
251
|
functions/__recordings__/). --record calls the platform once
|
|
250
252
|
(as the test's client, HAWKSEARCH_API_KEY for HawkSearch) and
|
|
251
253
|
saves its answers
|
|
254
|
+
hawk-app types Write hawk-functions.d.ts from the functions' contracts
|
|
255
|
+
(defineFunction's zod input and output), so pages get typed
|
|
256
|
+
app.functions.call. hawk-app dev refreshes it when it starts
|
|
257
|
+
Contracts: export const handler = defineFunction({ input, output, handler }) with zod schemas. Deploy sends them;
|
|
258
|
+
the platform refuses a call whose input doesn't match (400 invalid_function_input) before the function runs, and
|
|
259
|
+
the API access screen offers the client API's OpenAPI document.
|
|
252
260
|
Background jobs: "job": true on a function (up to 900 s), started with app.functions.start. "schedules" in
|
|
253
261
|
hawk-app.json run job functions on an EventBridge cron (at most hourly) for clients where a user turns them on.
|
|
254
262
|
"events" in hawk-app.json run a job function when something happens for a client: client.added (a client got the
|
|
@@ -455,6 +463,7 @@ export async function main(argv, { fetchImpl = fetch, log = console.log, runBuil
|
|
|
455
463
|
const queries = await checkQueries(manifest, manifestFile);
|
|
456
464
|
const functions = await checkFunctions(manifest, manifestFile);
|
|
457
465
|
const projectDir = dirname(resolve(manifestFile));
|
|
466
|
+
const help = await loadHelp(projectDir);
|
|
458
467
|
const project = await checkProject(projectDir);
|
|
459
468
|
const { url } = resolveSettings({ flagPlatform: flags.platform, flagEnv: flags.env });
|
|
460
469
|
const channelWarning = devAppOnProd(projectDir, url);
|
|
@@ -468,9 +477,9 @@ export async function main(argv, { fetchImpl = fetch, log = console.log, runBuil
|
|
|
468
477
|
const { errors: buildErrors, warnings: buildWarnings } = flags.build
|
|
469
478
|
? await checkBuild({ projectDir, manifest, folder: positional[0], runBuild, log })
|
|
470
479
|
: { errors: [], warnings: [] };
|
|
471
|
-
const ok = `✔ ${manifestFile} looks good.${queries ? ` ${QUERIES_DIR}/: ${describeQueryCount(queries.queries.length)}.` : ''}${functions ? ` ${FUNCTIONS_DIR}/: ${describeFunctions(functions.functions.length)}.` : ''}${flags.build ? ' The build\'s URLs are under its base path.' : ''}`;
|
|
480
|
+
const ok = `✔ ${manifestFile} looks good.${queries ? ` ${QUERIES_DIR}/: ${describeQueryCount(queries.queries.length)}.` : ''}${functions ? ` ${FUNCTIONS_DIR}/: ${describeFunctions(functions.functions.length)}.` : ''}${help?.file ? ` ${HELP_FILE}: the Help tab answers from it.` : ''}${flags.build ? ' The build\'s URLs are under its base path.' : ''}`;
|
|
472
481
|
return report(
|
|
473
|
-
{ errors: [...result.errors, ...icon.errors, ...screenshots.errors, ...(queries?.errors ?? []), ...(functions?.errors ?? []), ...buildErrors], warnings: [...result.warnings, ...project.warnings, ...buildWarnings] },
|
|
482
|
+
{ errors: [...result.errors, ...icon.errors, ...screenshots.errors, ...(queries?.errors ?? []), ...(functions?.errors ?? []), ...(help?.errors ?? []), ...buildErrors], warnings: [...result.warnings, ...project.warnings, ...buildWarnings] },
|
|
474
483
|
ok,
|
|
475
484
|
log,
|
|
476
485
|
);
|
|
@@ -513,6 +522,11 @@ export async function main(argv, { fetchImpl = fetch, log = console.log, runBuil
|
|
|
513
522
|
for (const error of functions.errors) log(`✖ ${error}`);
|
|
514
523
|
throw new Error(`Fix the functions first.`);
|
|
515
524
|
}
|
|
525
|
+
const help = await loadHelp(projectDir);
|
|
526
|
+
if (help?.errors.length) {
|
|
527
|
+
for (const error of help.errors) log(`✖ ${error}`);
|
|
528
|
+
throw new Error(`Fix ${HELP_FILE} first.`);
|
|
529
|
+
}
|
|
516
530
|
const alias = flags.alias === undefined ? undefined : checkAlias(flags.alias);
|
|
517
531
|
// hawk-app.json as the platform takes it ($schema and template are for local tools).
|
|
518
532
|
const { $schema: _schema, template: _template, ...settings } = manifest;
|
|
@@ -536,7 +550,7 @@ export async function main(argv, { fetchImpl = fetch, log = console.log, runBuil
|
|
|
536
550
|
storeFiles: screenshots.files,
|
|
537
551
|
dir: positional[0] ?? resolve(projectDir, target.folder),
|
|
538
552
|
build: target.build && !flags['no-build'],
|
|
539
|
-
serverFiles: queries?.files ?? [],
|
|
553
|
+
serverFiles: [...(queries?.files ?? []), ...(help?.file ? [help.file] : [])],
|
|
540
554
|
functions: functions?.functions ?? [],
|
|
541
555
|
projectDir,
|
|
542
556
|
audit: !flags['no-audit'],
|
|
@@ -647,9 +661,36 @@ export async function main(argv, { fetchImpl = fetch, log = console.log, runBuil
|
|
|
647
661
|
await client().register(manifest.slug, settings, false);
|
|
648
662
|
log(`✔ Registered ${manifest.slug} on the platform (not released: only Bridgeline staff see it).`);
|
|
649
663
|
}
|
|
664
|
+
// Typed function calls follow the contracts as they are now (never stops dev).
|
|
665
|
+
await refreshTypes(projectDir, manifest, log).catch(() => undefined);
|
|
650
666
|
return devCommand({ client: client(), manifest, flags, projectDir, ...(env ? { env } : {}), signal, log });
|
|
651
667
|
}
|
|
652
668
|
|
|
669
|
+
case 'types': {
|
|
670
|
+
const manifest = await loadManifest(manifestFile);
|
|
671
|
+
const projectDir = dirname(resolve(manifestFile));
|
|
672
|
+
const loaded = await loadFunctions(projectDir, manifest);
|
|
673
|
+
if (loaded?.errors.length) {
|
|
674
|
+
for (const error of loaded.errors) log(`✖ ${error}`);
|
|
675
|
+
return 1;
|
|
676
|
+
}
|
|
677
|
+
const { contracts, errors } = await readContracts({ projectDir, functions: loaded?.functions ?? [] });
|
|
678
|
+
if (errors.length) {
|
|
679
|
+
for (const error of errors) log(`✖ ${error}`);
|
|
680
|
+
return 1;
|
|
681
|
+
}
|
|
682
|
+
const { file, changed } = await writeTypes(projectDir, contracts);
|
|
683
|
+
const typed = [...contracts].filter(([, c]) => c).map(([name]) => name);
|
|
684
|
+
if (!file) {
|
|
685
|
+
log(hasZod(projectDir) || !loaded?.functions.length
|
|
686
|
+
? `No function has a contract yet${changed ? ` (removed the old ${TYPES_FILE})` : ''}. Declare one with defineFunction({ input, output, handler }) from ${BRIDGE_PACKAGE}/server.`
|
|
687
|
+
: `No contracts: the app has no zod. Add it (\`npm install zod\`) and declare them with defineFunction({ input, output, handler }).`);
|
|
688
|
+
return 0;
|
|
689
|
+
}
|
|
690
|
+
log(`✔ ${relative(projectDir, file).split(sep).join('/')} ${changed ? 'written' : 'up to date'}: typed app.functions.call for ${typed.join(', ')}.`);
|
|
691
|
+
return 0;
|
|
692
|
+
}
|
|
693
|
+
|
|
653
694
|
case 'test': {
|
|
654
695
|
const projectDir = dirname(resolve(manifestFile));
|
|
655
696
|
const settings = flags.record ? resolveSettings({ flagPlatform: flags.platform, flagEnv: flags.env }) : null;
|
|
@@ -838,6 +879,19 @@ async function checkFunctions(manifest, manifestFile) {
|
|
|
838
879
|
return result;
|
|
839
880
|
}
|
|
840
881
|
|
|
882
|
+
/** `hawk-app dev`'s start: hawk-functions.d.ts from the contracts as they are now (a line only when it changed). */
|
|
883
|
+
async function refreshTypes(projectDir, manifest, log) {
|
|
884
|
+
const loaded = await loadFunctions(projectDir, manifest);
|
|
885
|
+
if (!loaded?.functions.length || loaded.errors.length) return;
|
|
886
|
+
const { contracts, errors } = await readContracts({ projectDir, functions: loaded.functions });
|
|
887
|
+
if (errors.length) {
|
|
888
|
+
log(`! ${errors[0]}`);
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
const { file, changed } = await writeTypes(projectDir, contracts);
|
|
892
|
+
if (changed && file) log(`✔ ${relative(projectDir, file).split(sep).join('/')} updated from the functions' contracts.`);
|
|
893
|
+
}
|
|
894
|
+
|
|
841
895
|
/**
|
|
842
896
|
* `validate --build [<folder>]`: builds the app as deploy would (with a stand-in base path, /<slug>/v0) and checks
|
|
843
897
|
* that its HTML only loads URLs under it. The folder comes from the argument or the app:deploy script.
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join, relative, sep } from 'node:path';
|
|
6
|
+
import { pathToFileURL } from 'node:url';
|
|
7
|
+
|
|
8
|
+
// Function contracts: zod schemas of what a function takes and returns (`defineFunction({ input, output, handler })`
|
|
9
|
+
// from the bridge's /server). The CLI reads them with the app's own zod, as JSON Schema: deploy sends them with each
|
|
10
|
+
// function (the platform checks calls' input against them), and `hawk-app types` writes hawk-functions.d.ts so pages
|
|
11
|
+
// get typed `app.functions.call`. Same size limit as the platform.
|
|
12
|
+
export const MAX_SCHEMA_BYTES = 6 * 1024;
|
|
13
|
+
export const TYPES_FILE = 'hawk-functions.d.ts';
|
|
14
|
+
|
|
15
|
+
/** The generated entry: the function's contract as JSON Schema, converted by the app's zod (the one its schemas come from). */
|
|
16
|
+
function contractSource(entry) {
|
|
17
|
+
const userModule = entry.split(sep).join('/');
|
|
18
|
+
return [
|
|
19
|
+
`import * as fn from ${JSON.stringify(userModule)};`,
|
|
20
|
+
"import { toJSONSchema } from 'zod';",
|
|
21
|
+
'const contract = fn.handler && fn.handler.contract;',
|
|
22
|
+
"const convert = (schema, io) => (schema ? toJSONSchema(schema, { io, unrepresentable: 'any' }) : null);",
|
|
23
|
+
"export default contract ? { input: convert(contract.input, 'input'), output: convert(contract.output, 'output') } : null;",
|
|
24
|
+
'',
|
|
25
|
+
].join('\n');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Whether the app has zod (its contracts' schemas): without it, its functions have no contracts. */
|
|
29
|
+
export function hasZod(projectDir) {
|
|
30
|
+
try {
|
|
31
|
+
createRequire(join(projectDir, 'package.json')).resolve('zod');
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Each function's contract, `{ input, output }` JSON Schemas (each null when not declared), or null for a function
|
|
40
|
+
* without one. Loads each function once in this process (its module's top-level code runs, as in `hawk-app dev`).
|
|
41
|
+
* @param {{ projectDir: string, functions: { name: string, entry: string }[] }} options
|
|
42
|
+
* @returns {Promise<{ contracts: Map<string, { input: object | null, output: object | null } | null>, errors: string[] }>}
|
|
43
|
+
*/
|
|
44
|
+
export async function readContracts({ projectDir, functions }) {
|
|
45
|
+
const contracts = new Map();
|
|
46
|
+
const errors = [];
|
|
47
|
+
if (!functions.length || !hasZod(projectDir)) {
|
|
48
|
+
for (const fn of functions) contracts.set(fn.name, null);
|
|
49
|
+
return { contracts, errors };
|
|
50
|
+
}
|
|
51
|
+
const esbuild = await import('esbuild');
|
|
52
|
+
const dir = await mkdtemp(join(tmpdir(), 'hawk-contracts-'));
|
|
53
|
+
try {
|
|
54
|
+
for (const fn of functions) {
|
|
55
|
+
const where = `functions/${fn.name}`;
|
|
56
|
+
const file = join(dir, `${fn.name}.mjs`);
|
|
57
|
+
try {
|
|
58
|
+
await esbuild.build({
|
|
59
|
+
stdin: { contents: contractSource(fn.entry), resolveDir: projectDir, sourcefile: `hawk-contract-${fn.name}.mjs`, loader: 'js' },
|
|
60
|
+
absWorkingDir: projectDir,
|
|
61
|
+
bundle: true,
|
|
62
|
+
platform: 'node',
|
|
63
|
+
target: 'node22',
|
|
64
|
+
format: 'esm',
|
|
65
|
+
outfile: file,
|
|
66
|
+
logLevel: 'silent',
|
|
67
|
+
banner: { js: "import { createRequire as __hawkCreateRequire } from 'node:module';\nconst require = __hawkCreateRequire(import.meta.url);" },
|
|
68
|
+
});
|
|
69
|
+
const contract = (await import(`${pathToFileURL(file).href}?t=${Date.now()}`)).default ?? null;
|
|
70
|
+
if (contract) {
|
|
71
|
+
for (const side of ['input', 'output']) {
|
|
72
|
+
const bytes = contract[side] ? Buffer.byteLength(JSON.stringify(contract[side])) : 0;
|
|
73
|
+
if (bytes > MAX_SCHEMA_BYTES) {
|
|
74
|
+
errors.push(`${where}: the contract's ${side} schema is ${Math.ceil(bytes / 1024)} KB as JSON Schema; at most ${MAX_SCHEMA_BYTES / 1024} KB. Describe the fields callers send and read, not every nested detail.`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
contracts.set(fn.name, contract && (contract.input || contract.output) ? contract : null);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
const text = error?.errors?.map((e) => e.text).join('; ') || error?.message || String(error);
|
|
81
|
+
errors.push(`${where}: couldn't read its contract (defineFunction's input and output must be zod schemas): ${text}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} finally {
|
|
85
|
+
await rm(dir, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
return { contracts, errors };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Where hawk-functions.d.ts goes: src/ when the app has one (the templates' TypeScript includes it), else next to hawk-app.json. */
|
|
91
|
+
export function typesPath(projectDir) {
|
|
92
|
+
return existsSync(join(projectDir, 'src')) ? join(projectDir, 'src', TYPES_FILE) : join(projectDir, TYPES_FILE);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* hawk-functions.d.ts: the functions with a contract, by name, as `HawkFunctions` of the bridge's client (typed
|
|
97
|
+
* `app.functions.call` and `start`). Null when no function has one.
|
|
98
|
+
*/
|
|
99
|
+
export function typesSource(contracts) {
|
|
100
|
+
const typed = [...contracts].filter(([, c]) => c).sort(([a], [b]) => a.localeCompare(b));
|
|
101
|
+
if (!typed.length) return null;
|
|
102
|
+
const lines = [
|
|
103
|
+
'// Generated by `hawk-app types` from the functions\' contracts (defineFunction in functions/). Don\'t edit it:',
|
|
104
|
+
'// run `npx hawk-app types` again after changing a contract (`hawk-app dev` does it when it starts).',
|
|
105
|
+
"import '@bridgeline-digital/hawk-app-bridge/client';",
|
|
106
|
+
'',
|
|
107
|
+
"declare module '@bridgeline-digital/hawk-app-bridge/client' {",
|
|
108
|
+
' interface HawkFunctions {',
|
|
109
|
+
];
|
|
110
|
+
for (const [name, contract] of typed) {
|
|
111
|
+
lines.push(` ${JSON.stringify(name)}: {`);
|
|
112
|
+
lines.push(` input: ${contract.input ? toTypeScript(contract.input, contract.input, 3) : 'unknown'};`);
|
|
113
|
+
lines.push(` output: ${contract.output ? toTypeScript(contract.output, contract.output, 3) : 'unknown'};`);
|
|
114
|
+
lines.push(' };');
|
|
115
|
+
}
|
|
116
|
+
lines.push(' }', '}', '');
|
|
117
|
+
return lines.join('\n');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Writes hawk-functions.d.ts when the contracts give one (or removes a stale one when none do).
|
|
122
|
+
* @returns {Promise<{ file: string | null, changed: boolean }>}
|
|
123
|
+
*/
|
|
124
|
+
export async function writeTypes(projectDir, contracts) {
|
|
125
|
+
const file = typesPath(projectDir);
|
|
126
|
+
const source = typesSource(contracts);
|
|
127
|
+
const current = existsSync(file) ? await readFile(file, 'utf8') : null;
|
|
128
|
+
if (source === null) {
|
|
129
|
+
if (current !== null && current.startsWith('// Generated by `hawk-app types`')) {
|
|
130
|
+
await rm(file);
|
|
131
|
+
return { file: null, changed: true };
|
|
132
|
+
}
|
|
133
|
+
return { file: null, changed: false };
|
|
134
|
+
}
|
|
135
|
+
if (current === source) return { file, changed: false };
|
|
136
|
+
await writeFile(file, source);
|
|
137
|
+
return { file, changed: true };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** "src/hawk-functions.d.ts" when it's out of date with the contracts (or missing while some function has one), else null. */
|
|
141
|
+
export async function staleTypes(projectDir, contracts) {
|
|
142
|
+
const source = typesSource(contracts);
|
|
143
|
+
if (source === null) return null;
|
|
144
|
+
const file = typesPath(projectDir);
|
|
145
|
+
const current = existsSync(file) ? await readFile(file, 'utf8') : null;
|
|
146
|
+
return current === source ? null : relative(projectDir, file).split(sep).join('/');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* A JSON Schema (as zod writes it) as a TypeScript type: objects, arrays, tuples, unions (anyOf, oneOf, type lists),
|
|
153
|
+
* intersections (allOf), enums and consts, records (additionalProperties), $refs into $defs; anything else is unknown.
|
|
154
|
+
* Descriptions become doc comments.
|
|
155
|
+
*/
|
|
156
|
+
export function toTypeScript(schema, root = schema, depth = 0, seen = new Set()) {
|
|
157
|
+
if (schema === true || schema === undefined || schema === null || typeof schema !== 'object') return schema === false ? 'never' : 'unknown';
|
|
158
|
+
const pad = ' '.repeat(depth + 1);
|
|
159
|
+
const end = ' '.repeat(depth);
|
|
160
|
+
if (typeof schema.$ref === 'string') {
|
|
161
|
+
const match = /^#\/(\$defs|definitions)\/(.+)$/.exec(schema.$ref);
|
|
162
|
+
const target = match ? root?.[match[1]]?.[decodeURIComponent(match[2])] : undefined;
|
|
163
|
+
// A recursive reference stays unknown rather than looping.
|
|
164
|
+
if (!target || seen.has(schema.$ref) || depth > 12) return 'unknown';
|
|
165
|
+
return toTypeScript(target, root, depth, new Set([...seen, schema.$ref]));
|
|
166
|
+
}
|
|
167
|
+
if ('const' in schema) return JSON.stringify(schema.const);
|
|
168
|
+
if (Array.isArray(schema.enum)) return schema.enum.map((v) => JSON.stringify(v)).join(' | ') || 'never';
|
|
169
|
+
const union = schema.anyOf ?? schema.oneOf;
|
|
170
|
+
if (Array.isArray(union)) return group(union.map((s) => toTypeScript(s, root, depth, seen)), ' | ');
|
|
171
|
+
if (Array.isArray(schema.allOf)) return group(schema.allOf.map((s) => toTypeScript(s, root, depth, seen)), ' & ');
|
|
172
|
+
if (Array.isArray(schema.type)) return group(schema.type.map((type) => toTypeScript({ ...schema, type }, root, depth, seen)), ' | ');
|
|
173
|
+
switch (schema.type) {
|
|
174
|
+
case 'string':
|
|
175
|
+
return 'string';
|
|
176
|
+
case 'number':
|
|
177
|
+
case 'integer':
|
|
178
|
+
return 'number';
|
|
179
|
+
case 'boolean':
|
|
180
|
+
return 'boolean';
|
|
181
|
+
case 'null':
|
|
182
|
+
return 'null';
|
|
183
|
+
case 'array': {
|
|
184
|
+
if (Array.isArray(schema.prefixItems)) {
|
|
185
|
+
const items = schema.prefixItems.map((s) => toTypeScript(s, root, depth, seen));
|
|
186
|
+
return `[${items.join(', ')}]`;
|
|
187
|
+
}
|
|
188
|
+
const item = toTypeScript(schema.items, root, depth, seen);
|
|
189
|
+
return /[|&]/.test(item) ? `Array<${item}>` : `${item}[]`;
|
|
190
|
+
}
|
|
191
|
+
case 'object':
|
|
192
|
+
case undefined: {
|
|
193
|
+
if (schema.type === undefined && !schema.properties && schema.additionalProperties === undefined) return 'unknown';
|
|
194
|
+
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
|
195
|
+
const members = Object.entries(schema.properties ?? {}).map(([key, value]) => {
|
|
196
|
+
const name = IDENTIFIER.test(key) ? key : JSON.stringify(key);
|
|
197
|
+
const doc = typeof value?.description === 'string' ? `${pad}/** ${value.description.replace(/\*\//g, '* /')} */\n` : '';
|
|
198
|
+
return `${doc}${pad}${name}${required.has(key) ? '' : '?'}: ${toTypeScript(value, root, depth + 1, seen)};`;
|
|
199
|
+
});
|
|
200
|
+
const extra = schema.additionalProperties;
|
|
201
|
+
if (extra && extra !== false) members.push(`${pad}[key: string]: ${extra === true ? 'unknown' : toTypeScript(extra, root, depth + 1, seen)};`);
|
|
202
|
+
return members.length ? `{\n${members.join('\n')}\n${end}}` : extra === false ? 'Record<string, never>' : 'Record<string, unknown>';
|
|
203
|
+
}
|
|
204
|
+
default:
|
|
205
|
+
return 'unknown';
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function group(types, separator) {
|
|
210
|
+
const unique = [...new Set(types)];
|
|
211
|
+
return unique.length === 1 ? unique[0] : unique.map((t) => (/[|&]/.test(t) && !t.startsWith('{') ? `(${t})` : t)).join(separator);
|
|
212
|
+
}
|
package/src/create.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
6
6
|
import { planAgentFiles, writeAgentFiles } from './agents.mjs';
|
|
7
7
|
import { credentialsPath } from './credentials.mjs';
|
|
8
8
|
import { ENV_EXAMPLE, resolveSettings } from './env.mjs';
|
|
9
|
+
import { HELP_FILE, starterGuide } from './help.mjs';
|
|
9
10
|
import { newManifest } from './manifest.mjs';
|
|
10
11
|
import { DEFAULT_ICON_SVG } from './app-icon.mjs';
|
|
11
12
|
import { applyChannel, CHANNELS, cliChannel, DEV_CHANNEL_NOTE, planChannel, writeCdnFiles } from './channel.mjs';
|
|
@@ -88,6 +89,7 @@ export async function createProject({ target, template = 'react', name, install
|
|
|
88
89
|
await writeOut(join(dir, '.gitignore'), ['node_modules/', 'dist/', 'out/', '.next/', 'next-env.d.ts', '.env', '.env.local', '.env.*.local', '*.log', ''].join('\n'));
|
|
89
90
|
await writeOut(join(dir, '.env.example'), ENV_EXAMPLE);
|
|
90
91
|
await writeOut(join(dir, 'README.md'), readme({ slug, displayName, template, spec }));
|
|
92
|
+
await writeOut(join(dir, HELP_FILE), starterGuide(displayName));
|
|
91
93
|
const agentFiles = await writeAgentFiles(dir, planAgentFiles(dir, { slug, name: displayName, template }));
|
|
92
94
|
|
|
93
95
|
log(`✔ Created ${displayName} (${spec.description}) in ${relative(process.cwd(), dir) || '.'}`);
|
|
@@ -112,7 +114,7 @@ export async function createProject({ target, template = 'react', name, install
|
|
|
112
114
|
log(' It isn\'t released yet: only Bridgeline staff see it, from the Apps page in HawkSearch (`npx hawk-app open`). Submit it');
|
|
113
115
|
log(' for approval there; an approver releases it. `npx hawk-app --help` lists everything else.');
|
|
114
116
|
log(' Docs: https://developers.hawksearchapps.com (npx hawk-app docs), with your Bridgeline account.');
|
|
115
|
-
return { dir, slug, files: [...written, 'hawk-app.json', 'icon.svg', '.gitignore', '.env.example', 'README.md', ...agentFiles] };
|
|
117
|
+
return { dir, slug, files: [...written, 'hawk-app.json', 'icon.svg', '.gitignore', '.env.example', 'README.md', HELP_FILE, ...agentFiles] };
|
|
116
118
|
}
|
|
117
119
|
|
|
118
120
|
async function listTemplateFiles(root) {
|
|
@@ -182,5 +184,10 @@ with the settings in \`hawk-app.json\` (scopes, origins, views, functions…).
|
|
|
182
184
|
- **Clients any time.** \`npx hawk-app clients add|exclude|remove <id>\` (or the Hawk Analytics portal) applies at once,
|
|
183
185
|
without a deploy or approval, and is recorded in the app's history.
|
|
184
186
|
- **Something off?** \`npx hawk-app doctor\` checks your login, the platform, the project and the bridge.
|
|
187
|
+
|
|
188
|
+
## Help
|
|
189
|
+
\`HELP.md\` is the app's guide for its users: they ask "How do I…?" from the app's header in HawkSearch and the
|
|
190
|
+
platform's AI answers from it and the page they're on. It ships with every deploy; Help is off while it holds only
|
|
191
|
+
comments, as it starts.
|
|
185
192
|
`;
|
|
186
193
|
}
|
package/src/deploy.mjs
CHANGED
|
@@ -7,6 +7,8 @@ import { checkAccessibility, describeAccessibility } from './accessibility.mjs';
|
|
|
7
7
|
import { checkBuildUrls, describeBuildUrls } from './project-checks.mjs';
|
|
8
8
|
import { askYesNo } from './slug-guard.mjs';
|
|
9
9
|
import { describeSource } from './source.mjs';
|
|
10
|
+
import { readContracts, staleTypes } from './contracts.mjs';
|
|
11
|
+
import { HELP_FILE, HELP_SERVER_PATH } from './help.mjs';
|
|
10
12
|
import { sourceMapIndex, splitSourceMaps } from './sourcemaps.mjs';
|
|
11
13
|
|
|
12
14
|
const CONCURRENCY = 8;
|
|
@@ -34,8 +36,8 @@ export async function listFiles(dir) {
|
|
|
34
36
|
* folder on the CDN), the files go straight to storage, then the platform verifies them and decides who sees the
|
|
35
37
|
* version: an app not released yet shows it to Bridgeline staff at once; a released app keeps clients on their version until
|
|
36
38
|
* it's approved in HawkSearch (unless the app is set to auto-approve).
|
|
37
|
-
* `serverFiles` (the app's checked queries/ files, from the project root) go to the
|
|
38
|
-
* with their own upload URLs, never to the CDN.
|
|
39
|
+
* `serverFiles` (the app's checked queries/ files and its HELP.md as help.md, from the project root) go to the
|
|
40
|
+
* platform's private server bucket with their own upload URLs, never to the CDN.
|
|
39
41
|
* `functions` (checked `{ name, entry, config }` from loadFunctions) are bundled with esbuild, uploaded to the server
|
|
40
42
|
* bucket (bundle.zip, source.zip, deps.json), deployed one by one as Lambda versions, and listed on complete.
|
|
41
43
|
* `audit` runs `npm audit` of the production dependencies before a version is reserved and sends the result with
|
|
@@ -60,6 +62,14 @@ export async function deploy({ client, slug, dir, alias, manifest, source = null
|
|
|
60
62
|
built.push({ ...result, config: fn.config });
|
|
61
63
|
log(` ƒ ${fn.name} ${formatBytes(result.bundleBytes)} zipped, ${result.deps.length} package${result.deps.length === 1 ? '' : 's'}`);
|
|
62
64
|
}
|
|
65
|
+
// Contracts (defineFunction's zod schemas) go with each function: the platform checks calls' input against them.
|
|
66
|
+
const { contracts, errors } = await readContracts({ projectDir, functions });
|
|
67
|
+
if (errors.length) throw new Error(`Fix the functions' contracts first:\n${errors.map((e) => ` ${e}`).join('\n')}`);
|
|
68
|
+
for (const fn of built) fn.contract = contracts.get(fn.name) ?? null;
|
|
69
|
+
const typed = built.filter((fn) => fn.contract).map((fn) => fn.name);
|
|
70
|
+
if (typed.length) log(` contracts: ${typed.join(', ')}`);
|
|
71
|
+
const stale = await staleTypes(projectDir, contracts);
|
|
72
|
+
if (stale) log(`! ${stale} doesn't match the functions' contracts: run \`npx hawk-app types\` so pages call them with the right types.`);
|
|
63
73
|
}
|
|
64
74
|
const functionFiles = built.flatMap((fn) => fn.files);
|
|
65
75
|
|
|
@@ -98,8 +108,10 @@ export async function deploy({ client, slug, dir, alias, manifest, source = null
|
|
|
98
108
|
const accessibility = await runAccessibility(dir);
|
|
99
109
|
log(` accessibility ${describeAccessibility(accessibility)}`);
|
|
100
110
|
const totalBytes = files.reduce((sum, f) => sum + f.size, 0);
|
|
101
|
-
const
|
|
102
|
-
const
|
|
111
|
+
const queryFiles = serverFiles.filter((f) => f.path.startsWith('queries/'));
|
|
112
|
+
const queryCount = new Set(queryFiles.map((f) => f.path.replace(/.(sql|json)$/, ''))).size;
|
|
113
|
+
const helpNote = serverFiles.some((f) => f.path === HELP_SERVER_PATH) ? `, ${HELP_FILE}` : '';
|
|
114
|
+
const queriesNote = `${queryCount ? `, ${describeQueries(queryCount)}` : ''}${built.length ? `, ${describeFunctions(built.length)}` : ''}${helpNote}`;
|
|
103
115
|
const mapsNote = maps.length ? `, ${maps.length} source map${maps.length === 1 ? '' : 's'} (private)` : '';
|
|
104
116
|
log(`Uploading v${version} of ${slug}: ${files.length} files, ${formatBytes(totalBytes)} from ${dir}${queriesNote}${mapsNote}`);
|
|
105
117
|
|
|
@@ -115,9 +127,11 @@ export async function deploy({ client, slug, dir, alias, manifest, source = null
|
|
|
115
127
|
const serverByPath = new Map(privateFiles.map((f) => [f.path, f]));
|
|
116
128
|
const functionByPath = new Map(functionFiles.map((f) => [f.path, f]));
|
|
117
129
|
if (privateFiles.length && (started.serverUploads?.length ?? 0) !== privateFiles.length) {
|
|
118
|
-
throw new Error(
|
|
130
|
+
throw new Error(queryFiles.length
|
|
119
131
|
? 'The platform did not accept the queries/ files. It may not support app queries yet; update it or remove queries/.'
|
|
120
|
-
:
|
|
132
|
+
: serverFiles.length
|
|
133
|
+
? `The platform did not accept ${HELP_FILE}. It may not support Help yet; update it, or move ${HELP_FILE} aside for this deploy.`
|
|
134
|
+
: 'The platform did not accept the source maps. It may not support them yet; update it, or build without source maps.');
|
|
121
135
|
}
|
|
122
136
|
if (functionFiles.length && (started.functionUploads?.length ?? 0) !== functionFiles.length) {
|
|
123
137
|
throw new Error('The platform did not accept the functions. It may not support app functions yet; update it or remove "functions".');
|
|
@@ -142,7 +156,7 @@ export async function deploy({ client, slug, dir, alias, manifest, source = null
|
|
|
142
156
|
|
|
143
157
|
// Each function becomes a Lambda version; the platform waits until it's ready.
|
|
144
158
|
for (const fn of built) {
|
|
145
|
-
const deployed = await client.deployFunction(slug, version, fn.name, fn.config);
|
|
159
|
+
const deployed = await client.deployFunction(slug, version, fn.name, fn.contract ? { ...fn.config, contract: fn.contract } : fn.config);
|
|
146
160
|
log(` ✔ function ${fn.name} (${describeConfig(fn.config)})${deployed?.lambdaVersion ? ` Lambda version ${deployed.lambdaVersion}` : ''}`);
|
|
147
161
|
}
|
|
148
162
|
|
package/src/help.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/** The app's guide, next to hawk-app.json: Help inside the app answers "How do I…?" from it. */
|
|
6
|
+
export const HELP_FILE = 'HELP.md';
|
|
7
|
+
/** Where it goes among the version's private server files (the platform reads it there). */
|
|
8
|
+
export const HELP_SERVER_PATH = 'help.md';
|
|
9
|
+
export const MAX_HELP_BYTES = 32 * 1024;
|
|
10
|
+
|
|
11
|
+
/** The guide as Help reads it: HTML comments (notes for the app's team, like the template's) are left out. */
|
|
12
|
+
export function guideText(text) {
|
|
13
|
+
return text.replace(/^/, '').replace(/<!--[\s\S]*?-->/g, '').trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The project's HELP.md as a server file of the version, checked: at most 32 KB. `file` is null without a guide yet (no
|
|
18
|
+
* file, or only comments, as `hawk-app create` writes it): the app then has no Help tab.
|
|
19
|
+
* @returns {Promise<{ file: { path: string, size: number, full: string } | null, errors: string[] } | null>}
|
|
20
|
+
*/
|
|
21
|
+
export async function loadHelp(projectDir) {
|
|
22
|
+
const full = join(projectDir, HELP_FILE);
|
|
23
|
+
if (!existsSync(full)) return null;
|
|
24
|
+
const { size } = await stat(full);
|
|
25
|
+
if (size > MAX_HELP_BYTES) {
|
|
26
|
+
return { file: null, errors: [`${HELP_FILE} is ${Math.ceil(size / 1024)} KB: at most ${MAX_HELP_BYTES / 1024} KB. Keep it to what users ask about.`] };
|
|
27
|
+
}
|
|
28
|
+
if (!guideText(await readFile(full, 'utf8'))) return { file: null, errors: [] };
|
|
29
|
+
return { file: { path: HELP_SERVER_PATH, size, full }, errors: [] };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The guide `hawk-app create` starts: only comments, so Help stays off until the team writes it. */
|
|
33
|
+
export function starterGuide(displayName) {
|
|
34
|
+
return `<!--
|
|
35
|
+
${HELP_FILE}: ${displayName}'s guide. Users ask "How do I…?" from the app's header in HawkSearch (the sparkles icon,
|
|
36
|
+
"Help"), and the platform's AI answers from this file and the page they're on. It ships with every deploy (privately,
|
|
37
|
+
not on the CDN), so each version answers from its own guide. Help is off while this file holds only comments.
|
|
38
|
+
|
|
39
|
+
Write for the people who use the app (merchandisers, not developers):
|
|
40
|
+
- what the app is for, in two or three sentences;
|
|
41
|
+
- each task, as numbered steps that name the buttons and fields as the app shows them;
|
|
42
|
+
- what the numbers and statuses mean, and common questions with their answers.
|
|
43
|
+
Plain Markdown, at most 32 KB. Comments like this one are left out of what the AI reads.
|
|
44
|
+
-->
|
|
45
|
+
|
|
46
|
+
<!--
|
|
47
|
+
# ${displayName}
|
|
48
|
+
|
|
49
|
+
What it's for.
|
|
50
|
+
|
|
51
|
+
## Getting started
|
|
52
|
+
|
|
53
|
+
1. …
|
|
54
|
+
-->
|
|
55
|
+
`;
|
|
56
|
+
}
|
package/src/manifest.mjs
CHANGED
|
@@ -110,8 +110,11 @@ function validateViews(m) {
|
|
|
110
110
|
|
|
111
111
|
// Same rules as the platform (AppActionRules).
|
|
112
112
|
const ACTION_KEYS = ['label', 'view', 'targets'];
|
|
113
|
-
const ACTION_TARGET_KEYS = ['path', 'selector', 'anchor', 'position', 'params'];
|
|
113
|
+
const ACTION_TARGET_KEYS = ['path', 'selector', 'anchor', 'position', 'params', 'when'];
|
|
114
114
|
const ACTION_READER_KEYS = ['data', 'selector', 'attribute', 'pattern'];
|
|
115
|
+
const ACTION_CONDITIONS = ['equals', 'in', 'pattern'];
|
|
116
|
+
const MAX_CONDITION_VALUE = 100;
|
|
117
|
+
const MAX_CONDITION_VALUES = 20;
|
|
115
118
|
export const ACTION_HOSTS = ['hawksearch'];
|
|
116
119
|
const ACTION_POSITIONS = ['append', 'prepend', 'before', 'after'];
|
|
117
120
|
const ACTION_ATTRIBUTE = /^[A-Za-z_:][A-Za-z0-9_:.-]{0,63}$/;
|
|
@@ -131,8 +134,9 @@ const isRegex = (value) => {
|
|
|
131
134
|
/**
|
|
132
135
|
* "actions": buttons HawkSearch's action injector adds to its own pages, where the app's targets say (a page path and a
|
|
133
136
|
* CSS selector), that open one of the app's views in a dialog with values read from the page:
|
|
134
|
-
* { "<name>": { "label", "view", "targets": { "hawksearch": [{ "path", "selector", "anchor"?, "position"?, "params": { "<param>": { "data"?, "selector"?, "attribute"?, "pattern"? } } }] } } }.
|
|
135
|
-
* A reader's "data" names a data-<data> attribute that wins when it has a value; the rest is the fallback.
|
|
137
|
+
* { "<name>": { "label", "view", "targets": { "hawksearch": [{ "path", "selector", "anchor"?, "position"?, "params": { "<param>": { "data"?, "selector"?, "attribute"?, "pattern"? } }, "when"? }] } } }.
|
|
138
|
+
* A reader's "data" names a data-<data> attribute that wins when it has a value; the rest is the fallback. "when" shows
|
|
139
|
+
* the button only where the values read meet conditions: { "<param>": { "equals" | "in" | "pattern": … } }.
|
|
136
140
|
*/
|
|
137
141
|
export function validateActions(m) {
|
|
138
142
|
const errors = [];
|
|
@@ -222,6 +226,35 @@ function validateActionTarget(target, at, view, viewName) {
|
|
|
222
226
|
if (reader.attribute !== undefined && !(typeof reader.attribute === 'string' && ACTION_ATTRIBUTE.test(reader.attribute))) errors.push(`${where}.attribute is an attribute name, e.g. "data-keyword".`);
|
|
223
227
|
if (reader.pattern !== undefined && !isRegex(reader.pattern)) errors.push(`${where}.pattern is a regular expression (at most ${MAX_ACTION_EXPRESSION} characters); its first group is the value.`);
|
|
224
228
|
}
|
|
229
|
+
if (target.when !== undefined) errors.push(...validateActionWhen(target.when, `${at}.when`, params));
|
|
230
|
+
return errors;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* "when": the button shows only where the values the target reads meet every condition, by param (one of the target's
|
|
235
|
+
* params): { "equals": "…" }, { "in": ["…", …] } or { "pattern": "…" }, ignoring case. Same rules as the platform.
|
|
236
|
+
*/
|
|
237
|
+
function validateActionWhen(when, at, params) {
|
|
238
|
+
if (!when || typeof when !== 'object' || Array.isArray(when) || !Object.keys(when).length) {
|
|
239
|
+
return [`${at} is { "<param>": { "equals": "…" } | { "in": ["…"] } | { "pattern": "…" } }: the button shows only where the values read meet them.`];
|
|
240
|
+
}
|
|
241
|
+
const errors = [];
|
|
242
|
+
for (const [param, condition] of Object.entries(when)) {
|
|
243
|
+
const where = `${at}.${param}`;
|
|
244
|
+
if (!Object.hasOwn(params, param)) errors.push(`${where}: "${param}" isn't one of this target's params, so it has no value to check.`);
|
|
245
|
+
const keys = condition && typeof condition === 'object' && !Array.isArray(condition) ? Object.keys(condition) : [];
|
|
246
|
+
if (keys.length !== 1 || !ACTION_CONDITIONS.includes(keys[0])) {
|
|
247
|
+
errors.push(`${where} is one condition: { "equals": "…" }, { "in": ["…", …] } or { "pattern": "…" }.`);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const value = condition[keys[0]];
|
|
251
|
+
const text = (v) => typeof v === 'string' && v.trim() && v.length <= MAX_CONDITION_VALUE;
|
|
252
|
+
if (keys[0] === 'equals' && !text(value)) errors.push(`${where}.equals is a value, at most ${MAX_CONDITION_VALUE} characters.`);
|
|
253
|
+
if (keys[0] === 'in' && !(Array.isArray(value) && value.length >= 1 && value.length <= MAX_CONDITION_VALUES && value.every(text))) {
|
|
254
|
+
errors.push(`${where}.in lists 1 to ${MAX_CONDITION_VALUES} values, each at most ${MAX_CONDITION_VALUE} characters.`);
|
|
255
|
+
}
|
|
256
|
+
if (keys[0] === 'pattern' && !isRegex(value)) errors.push(`${where}.pattern is a regular expression (at most ${MAX_ACTION_EXPRESSION} characters) the value must match.`);
|
|
257
|
+
}
|
|
225
258
|
return errors;
|
|
226
259
|
}
|
|
227
260
|
|