@bridgeline-digital/hawk-app-cli 0.0.0-stage → 0.1.0-dev.20261020202308
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/LICENSE +7 -0
- package/README.md +770 -3
- package/agents/AGENTS.md +192 -0
- package/agents/project/custom.md +21 -0
- package/agents/project/next-mui.md +21 -0
- package/agents/project/next.md +24 -0
- package/agents/project/vanilla.md +22 -0
- package/agents/project/vite.md +19 -0
- package/bin/hawk-app.mjs +10 -0
- package/docs/bridge/README.md +1315 -0
- package/docs/showcase/README.md +47 -0
- package/docs/showcase/functions/echo.ts +12 -0
- package/docs/showcase/functions/long-task.ts +31 -0
- package/docs/showcase/functions/showcase.ts +186 -0
- package/docs/showcase/hawk-app.json +73 -0
- package/docs/showcase/queries/assistant-prompts-by-day.json +6 -0
- package/docs/showcase/queries/assistant-prompts-by-day.sql +8 -0
- package/docs/showcase/queries/searches-by-day.json +6 -0
- package/docs/showcase/queries/searches-by-day.sql +7 -0
- package/docs/showcase/src/App.tsx +1436 -0
- package/docs/showcase/src/main.tsx +10 -0
- package/docs/showcase/src/styles.css +71 -0
- package/docs/showcase/src/useHawkApp.ts +44 -0
- package/docs/showcase/src/vite-env.d.ts +1 -0
- package/docs/ui/README.md +166 -0
- package/package.json +50 -4
- package/schema/hawk-app.schema.json +611 -0
- package/src/accessibility.mjs +105 -0
- package/src/agents.mjs +145 -0
- package/src/aliases.mjs +30 -0
- package/src/app-icon.mjs +82 -0
- package/src/channel.mjs +116 -0
- package/src/cli.mjs +825 -0
- package/src/clients.mjs +221 -0
- package/src/config.mjs +148 -0
- package/src/create.mjs +186 -0
- package/src/credentials.mjs +56 -0
- package/src/data.mjs +169 -0
- package/src/deploy.mjs +349 -0
- package/src/dev.mjs +459 -0
- package/src/doctor.mjs +176 -0
- package/src/env.mjs +140 -0
- package/src/functions.mjs +313 -0
- package/src/login.mjs +166 -0
- package/src/logs.mjs +64 -0
- package/src/manifest.mjs +425 -0
- package/src/mcp-docs.mjs +168 -0
- package/src/mcp-hawksearch.mjs +342 -0
- package/src/mcp-reports.mjs +130 -0
- package/src/mcp-rules.mjs +109 -0
- package/src/mcp-ui.mjs +73 -0
- package/src/mcp.mjs +539 -0
- package/src/platform.mjs +116 -0
- package/src/project-checks.mjs +153 -0
- package/src/queries.mjs +289 -0
- package/src/scope-check.mjs +91 -0
- package/src/secrets.mjs +86 -0
- package/src/slug-guard.mjs +80 -0
- package/src/sso.mjs +182 -0
- package/src/store-listing.mjs +106 -0
- package/src/telemetry.mjs +50 -0
- package/src/template-history.json +34 -0
- package/src/upgrade.mjs +406 -0
- package/src/versions.mjs +102 -0
- package/templates/next/next.config.ts +13 -0
- package/templates/next/package.json +26 -0
- package/templates/next/src/app/globals.css +5 -0
- package/templates/next/src/app/layout.tsx +14 -0
- package/templates/next/src/app/page.tsx +86 -0
- package/templates/next/src/hawk-elements.d.ts +10 -0
- package/templates/next/src/lib/useHawkApp.ts +46 -0
- package/templates/next/tsconfig.json +21 -0
- package/templates/next-mui/next.config.ts +13 -0
- package/templates/next-mui/package.json +31 -0
- package/templates/next-mui/src/app/layout.tsx +20 -0
- package/templates/next-mui/src/app/page.tsx +79 -0
- package/templates/next-mui/src/app/providers.tsx +11 -0
- package/templates/next-mui/tsconfig.json +21 -0
- package/templates/preact/index.html +12 -0
- package/templates/preact/package.json +26 -0
- package/templates/preact/src/App.tsx +83 -0
- package/templates/preact/src/hawk-elements.d.ts +10 -0
- package/templates/preact/src/main.tsx +8 -0
- package/templates/preact/src/styles.css +5 -0
- package/templates/preact/src/useHawkApp.ts +44 -0
- package/templates/preact/src/vite-env.d.ts +1 -0
- package/templates/preact/tsconfig.json +17 -0
- package/templates/preact/vite.config.ts +11 -0
- package/templates/react/index.html +12 -0
- package/templates/react/package.json +29 -0
- package/templates/react/src/App.tsx +83 -0
- package/templates/react/src/hawk-elements.d.ts +10 -0
- package/templates/react/src/main.tsx +13 -0
- package/templates/react/src/styles.css +5 -0
- package/templates/react/src/useHawkApp.ts +44 -0
- package/templates/react/src/vite-env.d.ts +1 -0
- package/templates/react/tsconfig.json +16 -0
- package/templates/react/vite.config.ts +11 -0
- package/templates/vanilla/package.json +15 -0
- package/templates/vanilla/public/app.js +59 -0
- package/templates/vanilla/public/index.html +37 -0
- package/templates/vanilla/public/styles.css +5 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// The MCP server's `hawksearch_api` tool: HawkSearch's own API reference (developerdocs.hawksearch.com), with what an app
|
|
2
|
+
// can call of it and how. The operations come from the site's llms.txt; each operation's page embeds its OpenAPI
|
|
3
|
+
// definition (path, parameters, body and response schemas), which is read when one operation is asked for. What apps
|
|
4
|
+
// may call of it (which dashboard resources take writes, which Indexing API operations exist for apps, their scopes and
|
|
5
|
+
// limits) is the platform's own GET /meta/rules (mcp-rules.mjs). Public docs: no key, no client data.
|
|
6
|
+
import { FALLBACK } from './mcp-rules.mjs';
|
|
7
|
+
|
|
8
|
+
export const INDEX_URL = 'https://developerdocs.hawksearch.com/llms.txt';
|
|
9
|
+
const CACHE_MS = 10 * 60_000;
|
|
10
|
+
const TIMEOUT_MS = 10_000;
|
|
11
|
+
const MAX_TEXT = 8000;
|
|
12
|
+
|
|
13
|
+
/** The bridge's own helpers for some Indexing API operations; the rest go through index.call(operation, body). */
|
|
14
|
+
const INDEX_HELPERS = {
|
|
15
|
+
indexes: 'index.indexes()',
|
|
16
|
+
current: 'index.current()',
|
|
17
|
+
'index-items': 'index.items.upsert(items, { indexName })',
|
|
18
|
+
'update-items': 'index.items.update(items, { indexName })',
|
|
19
|
+
'delete-items': 'index.items.delete(ids, { indexName })',
|
|
20
|
+
create: "index.create({ suffix: 'spring' })",
|
|
21
|
+
'set-current': 'index.setCurrent(indexName)',
|
|
22
|
+
'delete-index': 'index.deleteIndex(indexName)',
|
|
23
|
+
'rebuild-all': 'index.rebuildAll(indexName)',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const APIS = {
|
|
27
|
+
dashboard: 'Dashboard API: app.dashboard.get/post/put/delete',
|
|
28
|
+
search: 'Search API: app.hawksearch.search / autocomplete',
|
|
29
|
+
recommendations: 'Recommendation API: app.hawksearch.recommendations',
|
|
30
|
+
indexing: 'Indexing API: app.hawksearch.index',
|
|
31
|
+
tracking: 'Tracking API',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Which API an llms.txt "## API Reference: …" section is, or null for the overview. */
|
|
35
|
+
function apiOf(section) {
|
|
36
|
+
if (/overview/i.test(section)) return null;
|
|
37
|
+
if (/hawksearch\.api\s*$/i.test(section)) return 'dashboard';
|
|
38
|
+
if (/search\s*api/i.test(section)) return 'search';
|
|
39
|
+
if (/recommend/i.test(section)) return 'recommendations';
|
|
40
|
+
if (/index/i.test(section)) return 'indexing';
|
|
41
|
+
if (/track/i.test(section)) return 'tracking';
|
|
42
|
+
return section.trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The API reference operations in llms.txt: [{ id, title, url, summary, api, group }]. */
|
|
46
|
+
export function operationsIn(text) {
|
|
47
|
+
const operations = [];
|
|
48
|
+
let api = null;
|
|
49
|
+
let group = null;
|
|
50
|
+
// Some summaries hold a bare \r (a line break in HawkSearch's own text): part of the line, not the end of it.
|
|
51
|
+
for (const line of text.replace(/\r\n/g, '\n').replace(/\r/g, ' ').split('\n')) {
|
|
52
|
+
const section = /^## (.*)$/.exec(line);
|
|
53
|
+
if (section) {
|
|
54
|
+
const reference = /^API Reference:\s*(.+)$/.exec(section[1]);
|
|
55
|
+
api = reference ? apiOf(reference[1]) : null;
|
|
56
|
+
group = null;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (!api) continue;
|
|
60
|
+
const heading = /^- ([^[\]]+?)\s*$/.exec(line);
|
|
61
|
+
if (heading) {
|
|
62
|
+
group = heading[1];
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const link = /^\s*- \[(.+?)\]\((https:\/\/developerdocs\.hawksearch\.com\/reference\/([\w.-]+?)\.md)\)(?::\s*(.*))?$/.exec(line);
|
|
66
|
+
if (link) operations.push({ id: link[3], title: link[1], url: link[2], summary: stripHtml(link[4] ?? ''), api, group: group ?? '' });
|
|
67
|
+
}
|
|
68
|
+
return operations;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const stripHtml = (text) => text.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
|
72
|
+
|
|
73
|
+
/** What an app can do with a whole group of operations, for the list (the exact answer is per operation). */
|
|
74
|
+
function groupAccess(api, group, rules) {
|
|
75
|
+
const name = group.toLowerCase();
|
|
76
|
+
switch (api) {
|
|
77
|
+
case 'dashboard':
|
|
78
|
+
if (name === 'index') return 'not through app.dashboard: app.hawksearch.index.rebuild() (hawksearch:index)';
|
|
79
|
+
if (name.startsWith('report')) return 'reads with hawksearch:read; for reporting prefer app.analytics.report() (analytics_reports)';
|
|
80
|
+
return writable(rules, name) ? 'reads hawksearch:read, writes hawksearch:write' : 'read only (hawksearch:read)';
|
|
81
|
+
case 'search':
|
|
82
|
+
if (name === 'searchv2') return 'app.hawksearch.search (hawksearch:search)';
|
|
83
|
+
if (name === 'autocompletev2') return 'app.hawksearch.autocomplete (hawksearch:search)';
|
|
84
|
+
return null;
|
|
85
|
+
case 'recommendations':
|
|
86
|
+
return rules.recommendations.available ? 'app.hawksearch.recommendations (hawksearch:recommendations)' : null;
|
|
87
|
+
case 'indexing':
|
|
88
|
+
if (['indexv2', 'hierarchy', 'mapping', 'embedding'].includes(name)) return 'some operations, scope per operation (hawksearch:index, :index:items, :index:manage)';
|
|
89
|
+
return null;
|
|
90
|
+
default:
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const writable = (rules, resource) => rules.dashboard.writeResources.some((r) => r.toLowerCase() === resource.toLowerCase());
|
|
96
|
+
|
|
97
|
+
function list(operations, api, rules, from) {
|
|
98
|
+
const apis = api ? [api] : [...new Set(operations.map((o) => o.api))];
|
|
99
|
+
const lines = [
|
|
100
|
+
`HawkSearch's API reference (${INDEX_URL}), with what apps can call through the bridge (the platform's rules, from ${from}).`,
|
|
101
|
+
api ? 'Call again with operation: "<id>" for its path, parameters, body and response fields, and the bridge call.' : 'Call again with api: "<name>" for each operation\'s title, or operation: "<id or words>" for one in full.',
|
|
102
|
+
];
|
|
103
|
+
for (const name of apis) {
|
|
104
|
+
const ofApi = operations.filter((o) => o.api === name);
|
|
105
|
+
if (!ofApi.length) continue;
|
|
106
|
+
lines.push('', `## ${name}: ${APIS[name] ?? name}`);
|
|
107
|
+
for (const group of [...new Set(ofApi.map((o) => o.group))]) {
|
|
108
|
+
const ofGroup = ofApi.filter((o) => o.group === group);
|
|
109
|
+
const access = groupAccess(name, group, rules);
|
|
110
|
+
if (!access) {
|
|
111
|
+
lines.push(`- ${group || '(no group)'}: not available to apps (${ofGroup.length} operation${ofGroup.length === 1 ? '' : 's'})`);
|
|
112
|
+
} else if (api) {
|
|
113
|
+
lines.push(`- ${group}: ${access}`, ...ofGroup.map((o) => ` - ${o.id}: ${o.title}${o.summary && o.summary !== o.title ? ` (${o.summary.slice(0, 120)})` : ''}`));
|
|
114
|
+
} else {
|
|
115
|
+
lines.push(`- ${group}: ${access}. ${ofGroup.map((o) => o.id).join(', ')}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return lines.join('\n');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The OpenAPI definition a reference page embeds (its first ```json block), or null. */
|
|
123
|
+
export function definitionIn(markdown) {
|
|
124
|
+
const block = /```json\s*\n([\s\S]*?)\n```/.exec(markdown);
|
|
125
|
+
if (!block) return null;
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(block[1]);
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const quote = (path) => (path.includes('{') ? `\`${path.replace(/\{(\w+)\}/g, (_, name) => `\${${name}}`)}\`` : `'${path}'`);
|
|
134
|
+
|
|
135
|
+
/** A path with its {placeholders} blanked, to compare HawkSearch's paths with the platform's ({notificationId} vs {id}). */
|
|
136
|
+
const shape = (path) => path.replace(/^\/+/, '').toLowerCase().replace(/\{[^}]*\}/g, '{}');
|
|
137
|
+
|
|
138
|
+
/** How an app calls one operation under the platform's rules: { call, scope, note } or { unavailable }. */
|
|
139
|
+
export function bridgeCall(api, method, path, rules = FALLBACK) {
|
|
140
|
+
const m = method.toUpperCase();
|
|
141
|
+
const rel = path.replace(/^\/+/, '');
|
|
142
|
+
if (api === 'dashboard') {
|
|
143
|
+
const { writeResources, writesPerMinute, maxBulkItems } = rules.dashboard;
|
|
144
|
+
const resourcePath = rel.replace(/^api\/(v\d+\/)?/i, '');
|
|
145
|
+
const resource = resourcePath.split('/')[0];
|
|
146
|
+
if (m === 'GET') return { call: `await app.dashboard.get(${quote(resourcePath)})`, scope: 'hawksearch:read', note: 'Query parameters go in { query: { … } } as the second argument.' };
|
|
147
|
+
if (resource.toLowerCase() === 'index') return { unavailable: 'Index rebuilds go through app.hawksearch.index.rebuild() (hawksearch:index, elevated), not the dashboard API.' };
|
|
148
|
+
if (!writable(rules, resource)) return { unavailable: `Writes to ${resource} aren't allowed (403 path_not_allowed): apps write only ${writeResources.join(', ')}.` };
|
|
149
|
+
if (m === 'DELETE' && resource.toLowerCase() === 'landingpage' && /^bulk/i.test(resourcePath.split('/')[1] ?? '')) return { unavailable: 'Bulk landing-page deletes are blocked (403 path_not_allowed).' };
|
|
150
|
+
const verb = m.toLowerCase();
|
|
151
|
+
return {
|
|
152
|
+
call: `await app.dashboard.${verb}(${quote(resourcePath)}${verb === 'delete' ? '' : ', body'})`,
|
|
153
|
+
scope: 'hawksearch:write (elevated)',
|
|
154
|
+
note: `Site keys only; ${writesPerMinute} writes a minute; bulk paths up to ${maxBulkItems} items; audit-logged, never retried.`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if (api === 'search') {
|
|
158
|
+
const limit = `${rules.search.perMinute} calls a minute.`;
|
|
159
|
+
if (rel === 'api/v2/search') return { call: "await app.hawksearch.search({ Keyword: 'boots' })", scope: 'hawksearch:search', note: `The platform sets ClientGuid to the engine's; IsInPreview: true runs on the preview search API. ${limit}` };
|
|
160
|
+
if (rel === 'api/v2/autocomplete') return { call: "await app.hawksearch.autocomplete({ Keyword: 'boo' })", scope: 'hawksearch:search', note: `The platform sets ClientGuid to the engine's. ${limit}` };
|
|
161
|
+
return { unavailable: 'Apps can call only search and autocomplete of the Search API.' };
|
|
162
|
+
}
|
|
163
|
+
if (api === 'recommendations') {
|
|
164
|
+
const { available, perMinute, maxBulkWidgets } = rules.recommendations;
|
|
165
|
+
if (!available) return { unavailable: "This platform has no recommendation API set up (503 recommendations_not_configured)." };
|
|
166
|
+
const note = `The platform sets clientGuid, adds visitorId/visitId when you send none, and keeps the call out of the recommendation reports. ${perMinute} calls a minute.`;
|
|
167
|
+
if (rel === 'api/recommendation/v2/getwidgetitems') return { call: 'await app.hawksearch.recommendations({ widgetUids: [{ widgetGuid, uniqueId }] })', scope: 'hawksearch:recommendations', note };
|
|
168
|
+
if (rel === 'api/recommendation/v2/getbulkwidgetitems') return { call: 'await app.hawksearch.recommendations({ widgets: [ … ] })', scope: 'hawksearch:recommendations', note: `Up to ${maxBulkWidgets} widgets. ${note}` };
|
|
169
|
+
return { unavailable: 'Apps get recommendation widgets only (getwidgetitems and getbulkwidgetitems).' };
|
|
170
|
+
}
|
|
171
|
+
if (api === 'indexing') {
|
|
172
|
+
const { operations, maxItemsPerCall, maxIdsPerCall, itemWritesPerMinute, managePerMinute } = rules.indexing;
|
|
173
|
+
const found = operations.find((o) => o.method.toUpperCase() === m && shape(o.path) === shape(rel));
|
|
174
|
+
if (!found) return { unavailable: "Not on the platform's Indexing API list (group indexing, index templates, unset-current and internal endpoints are left out on purpose)." };
|
|
175
|
+
// The app's operation with HawkSearch's own placeholder names (embedding/status/{id} → `embedding/status/${notificationId}`).
|
|
176
|
+
const names = [...rel.matchAll(/\{(\w+)\}/g)].map((p) => p[1]);
|
|
177
|
+
const operation = found.operation.includes('{') ? quote(found.operation.replace(/\{[^}]*\}/g, () => `{${names.shift() ?? 'id'}}`)) : `'${found.operation}'`;
|
|
178
|
+
const helper = INDEX_HELPERS[found.operation];
|
|
179
|
+
const write = found.scope !== 'hawksearch:index';
|
|
180
|
+
return {
|
|
181
|
+
call: `await app.hawksearch.${helper ?? `index.call(${operation}${m === 'POST' ? ', body' : ''})`}`,
|
|
182
|
+
scope: `${found.scope} (elevated)`,
|
|
183
|
+
note: write
|
|
184
|
+
? `Site keys only; ${maxItemsPerCall} items (or ${maxIdsPerCall} ids) a call; ${found.scope === 'hawksearch:index:items' ? `${itemWritesPerMinute} item writes` : `${managePerMinute} management calls`} a minute; audit-logged, never retried.${found.scope === 'hawksearch:index:items' ? ' Item writes are live at once.' : ''}`
|
|
185
|
+
: undefined,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
return { unavailable: "Apps can't call this API." };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const HIDDEN_PARAMETERS = /^x-hawksearch-(group)?apikey$/i;
|
|
192
|
+
|
|
193
|
+
function resolve(schema, spec) {
|
|
194
|
+
let current = schema;
|
|
195
|
+
for (let i = 0; current?.$ref && i < 10; i++) current = spec.components?.schemas?.[current.$ref.split('/').pop()];
|
|
196
|
+
return current ?? {};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function typeOf(schema) {
|
|
200
|
+
if (!schema) return 'any';
|
|
201
|
+
if (schema.$ref) return schema.$ref.split('/').pop();
|
|
202
|
+
if (schema.type === 'array') return `${typeOf(schema.items)}[]`;
|
|
203
|
+
if (schema.enum) return schema.enum.map((v) => JSON.stringify(v)).join(' | ');
|
|
204
|
+
if (schema.pattern && /^\^\([\w|]+\)\$$/.test(schema.pattern)) return schema.pattern.slice(2, -2).split('|').map((v) => `"${v}"`).join(' | ');
|
|
205
|
+
if (schema.type === 'object' && schema.additionalProperties) return `{ [key]: ${typeOf(schema.additionalProperties)} }`;
|
|
206
|
+
return [schema.type ?? 'object', schema.format].filter(Boolean).join(' ');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const refsOf = (schema) => {
|
|
210
|
+
if (!schema) return [];
|
|
211
|
+
if (schema.$ref) return [schema.$ref.split('/').pop()];
|
|
212
|
+
if (schema.type === 'array') return refsOf(schema.items);
|
|
213
|
+
if (schema.additionalProperties) return refsOf(schema.additionalProperties);
|
|
214
|
+
return [];
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/** A schema's fields, then those of the schemas it refers to (breadth first, each once). */
|
|
218
|
+
function describeSchema(label, schema, spec) {
|
|
219
|
+
const lines = [`${label}: ${typeOf(schema)}`];
|
|
220
|
+
const queue = refsOf(schema);
|
|
221
|
+
if (!queue.length) {
|
|
222
|
+
const own = resolve(schema, spec);
|
|
223
|
+
if (own.properties) lines.push(...fieldLines(own));
|
|
224
|
+
return lines;
|
|
225
|
+
}
|
|
226
|
+
const seen = new Set();
|
|
227
|
+
while (queue.length) {
|
|
228
|
+
const name = queue.shift();
|
|
229
|
+
if (seen.has(name)) continue;
|
|
230
|
+
seen.add(name);
|
|
231
|
+
const own = spec.components?.schemas?.[name];
|
|
232
|
+
if (!own) continue;
|
|
233
|
+
lines.push('', `${name}${own.description ? ` (${stripHtml(own.description)})` : ''}:`, ...fieldLines(own));
|
|
234
|
+
for (const property of Object.values(own.properties ?? {})) queue.push(...refsOf(property));
|
|
235
|
+
}
|
|
236
|
+
return lines;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function fieldLines(schema) {
|
|
240
|
+
const required = new Set(schema.required ?? []);
|
|
241
|
+
return Object.entries(schema.properties ?? {}).map(([name, property]) => {
|
|
242
|
+
const description = property.description ? `: ${stripHtml(property.description)}` : '';
|
|
243
|
+
return `- ${name} (${typeOf(property)}${required.has(name) ? ', required' : ''}${property.nullable ? ', nullable' : ''})${description}`;
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const jsonContent = (content) => content?.['application/json'] ?? Object.values(content ?? {})[0];
|
|
248
|
+
|
|
249
|
+
function detail(operation, definition, rules) {
|
|
250
|
+
const [path, item] = Object.entries(definition?.paths ?? {})[0] ?? [];
|
|
251
|
+
const [method, op] = Object.entries(item ?? {}).find(([key]) => /^(get|post|put|delete|patch)$/i.test(key)) ?? [];
|
|
252
|
+
if (!op) return `# ${operation.title}\n\nThe page has no OpenAPI definition to read: ${operation.url.replace(/\.md$/, '')}`;
|
|
253
|
+
const access = bridgeCall(operation.api, method, path, rules);
|
|
254
|
+
const parameters = (op.parameters ?? []).filter((p) => !HIDDEN_PARAMETERS.test(p.name));
|
|
255
|
+
const body = jsonContent(op.requestBody?.content)?.schema;
|
|
256
|
+
const response = jsonContent(op.responses?.['200']?.content ?? op.responses?.['201']?.content)?.schema;
|
|
257
|
+
return [
|
|
258
|
+
`# ${operation.id}: ${op.summary ?? operation.title}`,
|
|
259
|
+
`${method.toUpperCase()} ${path} (HawkSearch ${operation.api === 'dashboard' ? 'Dashboard' : operation.api} API, ${operation.group})`,
|
|
260
|
+
'',
|
|
261
|
+
stripHtml(op.description ?? operation.summary ?? ''),
|
|
262
|
+
'',
|
|
263
|
+
...(access.unavailable
|
|
264
|
+
? [`Not available to apps: ${access.unavailable}`]
|
|
265
|
+
: ['From an app:', access.call, `Scope: ${access.scope}.${access.note ? ` ${access.note}` : ''}`]),
|
|
266
|
+
...(parameters.length
|
|
267
|
+
? ['', 'Parameters:', ...parameters.map((p) => `- ${p.name} (${p.in}, ${typeOf(p.schema)}${p.required ? ', required' : ''})${p.description ? `: ${stripHtml(p.description)}` : ''}`)]
|
|
268
|
+
: []),
|
|
269
|
+
...(body ? ['', ...describeSchema('Request body', body, definition)] : []),
|
|
270
|
+
...(response ? ['', ...describeSchema('Response', response, definition)] : []),
|
|
271
|
+
'',
|
|
272
|
+
`Source: ${operation.url.replace(/\.md$/, '')}`,
|
|
273
|
+
]
|
|
274
|
+
.filter((line, i, all) => line !== '' || all[i - 1] !== '')
|
|
275
|
+
.join('\n');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function fetchText(url) {
|
|
279
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
280
|
+
if (!response.ok) throw new Error(`${url} answered ${response.status}`);
|
|
281
|
+
return response.text();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The platform's default rules, for a reference made without the platform (tests, or no platform at all). */
|
|
285
|
+
const builtInRules = async () => ({ rules: FALLBACK, from: 'the CLI (the platform\'s defaults)' });
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* describe({ api, operation, offset }) for the tool. llms.txt and each page are cached for a while; when the site
|
|
289
|
+
* can't be reached, an earlier copy is used if there is one. `rules` is mcp-rules.mjs's createPlatformRules().
|
|
290
|
+
*/
|
|
291
|
+
export function createHawkSearchReference({ load = fetchText, now = Date.now, rules = builtInRules } = {}) {
|
|
292
|
+
const cache = new Map();
|
|
293
|
+
async function get(url) {
|
|
294
|
+
const hit = cache.get(url);
|
|
295
|
+
if (hit && now() - hit.at < CACHE_MS) return { text: hit.text };
|
|
296
|
+
try {
|
|
297
|
+
const text = await load(url);
|
|
298
|
+
cache.set(url, { text, at: now() });
|
|
299
|
+
return { text };
|
|
300
|
+
} catch (error) {
|
|
301
|
+
if (hit) return { text: hit.text, stale: `(developerdocs.hawksearch.com couldn't be reached just now: ${error.message}. This is the copy from earlier.)` };
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return async function describe({ api, operation, offset = 0 } = {}) {
|
|
307
|
+
let index;
|
|
308
|
+
try {
|
|
309
|
+
index = await get(INDEX_URL);
|
|
310
|
+
} catch (error) {
|
|
311
|
+
return { ok: false, text: `Couldn't read HawkSearch's API reference (${error.message}). It's at https://developerdocs.hawksearch.com/reference/getting-started-with-your-api.` };
|
|
312
|
+
}
|
|
313
|
+
const operations = operationsIn(index.text);
|
|
314
|
+
const platform = await rules();
|
|
315
|
+
const stale = (loaded) => [loaded.stale, platform.note].filter(Boolean).map((note) => `\n\n${note}`).join('');
|
|
316
|
+
if (api && !operations.some((o) => o.api === api)) return { ok: false, text: `No API "${api}". The APIs: ${Object.keys(APIS).join(', ')}.` };
|
|
317
|
+
if (operation === undefined) return { ok: true, text: list(operations, api, platform.rules, platform.from) + stale(index) };
|
|
318
|
+
|
|
319
|
+
const wanted = operation.trim().toLowerCase();
|
|
320
|
+
const pool = api ? operations.filter((o) => o.api === api) : operations;
|
|
321
|
+
let matches = pool.filter((o) => o.id.toLowerCase() === wanted);
|
|
322
|
+
if (!matches.length) {
|
|
323
|
+
const words = wanted.split(/[^a-z0-9_]+/).filter((w) => w.length > 1);
|
|
324
|
+
matches = pool.filter((o) => words.length && words.every((w) => `${o.id} ${o.title} ${o.summary} ${o.group}`.toLowerCase().includes(w)));
|
|
325
|
+
}
|
|
326
|
+
if (!matches.length) return { ok: false, text: `No operation matches "${operation}". Call without operation for the list.` };
|
|
327
|
+
if (matches.length > 1) {
|
|
328
|
+
return { ok: true, text: `Several operations match "${operation}"; call again with one id:\n${matches.slice(0, 20).map((o) => `- ${o.id}: ${o.title} (${o.api}, ${o.group})`).join('\n')}${matches.length > 20 ? `\n… and ${matches.length - 20} more: use more words.` : ''}` };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const [found] = matches;
|
|
332
|
+
let page;
|
|
333
|
+
try {
|
|
334
|
+
page = await get(found.url);
|
|
335
|
+
} catch (error) {
|
|
336
|
+
return { ok: false, text: `Couldn't read ${found.url} (${error.message}).` };
|
|
337
|
+
}
|
|
338
|
+
const text = detail(found, definitionIn(page.text), platform.rules).slice(offset);
|
|
339
|
+
const cut = text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT)}\n… (more: call again with offset ${offset + MAX_TEXT})` : text;
|
|
340
|
+
return { ok: true, text: cut + stale(page) };
|
|
341
|
+
};
|
|
342
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// The MCP server's `analytics_reports` tool: which reports app.analytics.report() can call, read from the HawkSearch
|
|
2
|
+
// Analytics API's own OpenAPI spec (the developer portal), so a new report is known as soon as the analytics API
|
|
3
|
+
// documents it. The dev spec, as the MCP only works against the dev platform, which calls the dev analytics API; the
|
|
4
|
+
// prod spec only to mark the reports prod doesn't have yet, which an app can't use once it's live. The platform's
|
|
5
|
+
// rules (mcp-rules.mjs) mark the reports it blocks. The specs are public: no key, no client data.
|
|
6
|
+
import { parse } from 'yaml';
|
|
7
|
+
import { FALLBACK } from './mcp-rules.mjs';
|
|
8
|
+
|
|
9
|
+
export const SPEC_URL = 'https://developers.analytics.hawksearch.com/dev/openapi.yaml';
|
|
10
|
+
export const PROD_SPEC_URL = 'https://developers.analytics.hawksearch.com/openapi.yaml';
|
|
11
|
+
const PORTAL_URL = 'https://developers.analytics.hawksearch.com/';
|
|
12
|
+
const CACHE_MS = 10 * 60_000;
|
|
13
|
+
const TIMEOUT_MS = 10_000;
|
|
14
|
+
const DEV_ONLY = 'dev only: not on prod yet, so an app using it fails once it is live';
|
|
15
|
+
const BLOCKED = 'blocked by the platform: apps get 404 report_not_allowed';
|
|
16
|
+
|
|
17
|
+
/** The notes on one report: dev only, blocked. */
|
|
18
|
+
const marks = (name, prod, blocked) => [prod && !prod.has(name) ? DEV_ONLY : null, blocked.includes(name) ? BLOCKED : null].filter(Boolean);
|
|
19
|
+
|
|
20
|
+
async function fetchSpec(url) {
|
|
21
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
22
|
+
if (!response.ok) throw new Error(`${url} answered ${response.status}`);
|
|
23
|
+
return response.text();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The reports in a parsed spec: one per POST path, by the name app.analytics.report() takes ("" for the root). */
|
|
27
|
+
export function reportsIn(spec) {
|
|
28
|
+
return Object.entries(spec?.paths ?? {})
|
|
29
|
+
.filter(([, item]) => item?.post)
|
|
30
|
+
.map(([path, item]) => ({ name: path.replace(/^\/+|\/+$/g, ''), tag: item.post.tags?.[0] ?? 'Other', summary: item.post.summary ?? '', operation: item.post }));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const call = (name) => `app.analytics.report(${JSON.stringify(name)}`;
|
|
34
|
+
const title = (name) => (name === '' ? '"" (the root)' : name);
|
|
35
|
+
|
|
36
|
+
function list(reports, url, prod, blocked) {
|
|
37
|
+
const groups = new Map();
|
|
38
|
+
for (const r of reports) groups.set(r.tag, [...(groups.get(r.tag) ?? []), r]);
|
|
39
|
+
const lines = [`Analytics reports app.analytics.report(name, request) can call (scope reports:read), from ${url}. Call again with report: "<name>" for its columns, filters and an example.`];
|
|
40
|
+
for (const [tag, items] of groups) {
|
|
41
|
+
lines.push('', `## ${tag}`);
|
|
42
|
+
for (const r of items) {
|
|
43
|
+
const notes = marks(r.name, prod, blocked);
|
|
44
|
+
lines.push(`- ${title(r.name)}${notes.length ? ` (${notes.join('; ')})` : ''}: ${r.summary}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return lines.join('\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A request body's examples: inline, or through a $ref to components.requestBodies. */
|
|
51
|
+
function requestExamples(op, spec) {
|
|
52
|
+
const body = op.requestBody?.$ref ? spec.components?.requestBodies?.[op.requestBody.$ref.split('/').pop()] : op.requestBody;
|
|
53
|
+
return body?.content?.['application/json']?.examples;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function detail(report, spec, prod, blocked) {
|
|
57
|
+
const op = report.operation;
|
|
58
|
+
const example = op.responses?.['200']?.content?.['application/json']?.example;
|
|
59
|
+
// x-report is the spec's checked list (the analytics API's tests keep it in step); examples can be out of date.
|
|
60
|
+
const x = op['x-report'];
|
|
61
|
+
const columns = (x?.columns ?? example?.columns ?? []).map((c) => `${c.name} (${c.type})`);
|
|
62
|
+
const fields = x && x.kind !== 'write'
|
|
63
|
+
? [`filters takes: ${Object.keys(x.filters ?? {}).join(', ') || 'none'}; having takes: ${(x.having ?? []).join(', ') || 'none'}.`]
|
|
64
|
+
: [];
|
|
65
|
+
const bodyExamples = requestExamples(op, spec);
|
|
66
|
+
const sample = report.name === '' ? { query: 'top 10 keywords by searches last week' } : { startDate: '2026-06-01', endDate: '2026-06-30', page: 1, limit: 50 };
|
|
67
|
+
return [
|
|
68
|
+
`# ${title(report.name)}: ${report.summary}`,
|
|
69
|
+
...marks(report.name, prod, blocked).flatMap((note) => ['', `Note: ${note}.`]),
|
|
70
|
+
'',
|
|
71
|
+
(op.description ?? '').trim(),
|
|
72
|
+
...(columns.length ? ['', `Columns: ${columns.join(', ')}.`] : []),
|
|
73
|
+
...fields,
|
|
74
|
+
'',
|
|
75
|
+
'From an app:',
|
|
76
|
+
`const result = await ${call(report.name)}, ${JSON.stringify(sample)});`,
|
|
77
|
+
report.name === ''
|
|
78
|
+
? ''
|
|
79
|
+
: x
|
|
80
|
+
? '// result: { columns, data, totalRecords, page, hasMore }, data typed by the bridge (numbers and booleans converted). startDate/endDate default to the host\'s date range.'
|
|
81
|
+
: '// result: { columns, data, totalRecords, page, hasMore }. startDate/endDate default to the host\'s date range; numbers and booleans can come back as strings.',
|
|
82
|
+
...(bodyExamples ? ['', 'Request examples:', ...Object.values(bodyExamples).map((e) => `- ${e.summary ?? ''}: ${JSON.stringify(e.value)}`)] : []),
|
|
83
|
+
...(example?.data?.length ? ['', `Example row: ${JSON.stringify(example.data[0])}`] : []),
|
|
84
|
+
]
|
|
85
|
+
.filter((line, i, all) => line !== '' || all[i - 1] !== '')
|
|
86
|
+
.join('\n');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* describe({ report }) for the tool: the list of reports, or one report in full. The specs are cached for a while; when
|
|
91
|
+
* the portal can't be reached, an earlier copy is used if there is one. Without the prod spec, nothing is marked dev only.
|
|
92
|
+
* `rules` is mcp-rules.mjs's createPlatformRules(); without it, no report is marked blocked (the platform's default).
|
|
93
|
+
*/
|
|
94
|
+
export function createReportCatalog({ url = SPEC_URL, prodUrl = PROD_SPEC_URL, load = fetchSpec, now = Date.now, rules = async () => ({ rules: FALLBACK }) } = {}) {
|
|
95
|
+
let cached;
|
|
96
|
+
async function specs() {
|
|
97
|
+
if (cached && now() - cached.at < CACHE_MS) return cached;
|
|
98
|
+
const [dev, prod] = await Promise.allSettled([load(url), load(prodUrl)]);
|
|
99
|
+
try {
|
|
100
|
+
if (dev.status === 'rejected') throw dev.reason;
|
|
101
|
+
const prodNames = prod.status === 'fulfilled' ? new Set(reportsIn(parse(prod.value)).map((r) => r.name)) : cached?.prod;
|
|
102
|
+
cached = { spec: parse(dev.value), prod: prodNames, at: now() };
|
|
103
|
+
return cached;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (cached) return { ...cached, stale: `(The portal couldn't be reached just now: ${error.message}. This is the copy from earlier.)` };
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return async function describe({ report } = {}) {
|
|
111
|
+
let loaded;
|
|
112
|
+
try {
|
|
113
|
+
loaded = await specs();
|
|
114
|
+
} catch (error) {
|
|
115
|
+
return { ok: false, text: `Couldn't read the analytics API's spec (${error.message}). The reports are documented at ${PORTAL_URL}.` };
|
|
116
|
+
}
|
|
117
|
+
const reports = reportsIn(loaded.spec);
|
|
118
|
+
const platform = await rules();
|
|
119
|
+
const blocked = platform.rules.analytics.blockedReports;
|
|
120
|
+
const stale = [loaded.stale, platform.note].filter(Boolean).map((note) => `\n\n${note}`).join('');
|
|
121
|
+
if (report === undefined) return { ok: true, text: list(reports, url, loaded.prod, blocked) + stale };
|
|
122
|
+
|
|
123
|
+
const wanted = report.trim().replace(/^\/+|\/+$/g, '').toLowerCase();
|
|
124
|
+
const found = reports.find((r) => r.name === wanted);
|
|
125
|
+
if (found) return { ok: true, text: detail(found, loaded.spec, loaded.prod, blocked) + stale };
|
|
126
|
+
const words = wanted.split('-').filter(Boolean);
|
|
127
|
+
const close = reports.filter((r) => r.name && words.some((w) => r.name.includes(w))).map((r) => r.name);
|
|
128
|
+
return { ok: false, text: `No report "${report}" in the analytics API's spec.${close.length ? ` Close: ${close.join(', ')}.` : ''} Call without report for the list.` };
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// What apps may call, for the MCP server's hawksearch_api and analytics_reports tools: the platform's own
|
|
2
|
+
// GET /meta/rules (the rules in effect there, configuration included), read from the dev platform the MCP deploys to.
|
|
3
|
+
// FALLBACK is the platform's built-in defaults, for when the platform can't be reached (or predates /meta/rules);
|
|
4
|
+
// test/mcp-hawksearch.test.mjs checks it against platform/src. Public: no key, no client data.
|
|
5
|
+
import { PLATFORMS } from './env.mjs';
|
|
6
|
+
|
|
7
|
+
const CACHE_MS = 10 * 60_000;
|
|
8
|
+
const TIMEOUT_MS = 10_000;
|
|
9
|
+
|
|
10
|
+
const index = (method, operation, path, scope) => ({ method, operation, path, scope });
|
|
11
|
+
const READ = 'hawksearch:index';
|
|
12
|
+
const ITEMS = 'hawksearch:index:items';
|
|
13
|
+
const MANAGE = 'hawksearch:index:manage';
|
|
14
|
+
|
|
15
|
+
/** The platform's defaults (HawkSearchOptions, IndexingApiRules, AnalyticsOptions), in /meta/rules' shape. */
|
|
16
|
+
export const FALLBACK = {
|
|
17
|
+
version: 1,
|
|
18
|
+
dashboard: {
|
|
19
|
+
apiVersion: 'v11',
|
|
20
|
+
writeResources: ['banner', 'boostbury', 'campaign', 'visibility', 'visitortarget', 'redirect', 'synonym', 'spellcheck', 'paginationset', 'sortingset', 'recommendationvisibility', 'landingpagelayout', 'facet', 'landingpage'],
|
|
21
|
+
maxBulkItems: 1000,
|
|
22
|
+
maxWriteRequestBytes: 5 * 1024 * 1024,
|
|
23
|
+
writesPerMinute: 120,
|
|
24
|
+
},
|
|
25
|
+
search: { perMinute: 600, maxRequestBytes: 64 * 1024 },
|
|
26
|
+
recommendations: { available: true, maxBulkWidgets: 10, perMinute: 600 },
|
|
27
|
+
indexing: {
|
|
28
|
+
operations: [
|
|
29
|
+
index('GET', 'indexes', 'api/v2/indexing', READ),
|
|
30
|
+
index('GET', 'current', 'api/v2/indexing/current', READ),
|
|
31
|
+
index('GET', 'inactive', 'api/v2/indexing/get-inactive-index', READ),
|
|
32
|
+
index('POST', 'hierarchy', 'api/hierarchy', READ),
|
|
33
|
+
index('POST', 'mappings', 'api/mapping', READ),
|
|
34
|
+
index('GET', 'embedding/progress', 'api/embedding/progress', READ),
|
|
35
|
+
index('POST', 'index-items', 'api/v2/indexing/index-items', ITEMS),
|
|
36
|
+
index('POST', 'update-items', 'api/v2/indexing/update-items', ITEMS),
|
|
37
|
+
index('POST', 'delete', 'api/v2/indexing/delete', ITEMS),
|
|
38
|
+
index('POST', 'delete-items', 'api/v2/indexing/delete-items', ITEMS),
|
|
39
|
+
index('POST', 'add-attributes-to-items', 'api/v2/indexing/add-attributes-to-items', ITEMS),
|
|
40
|
+
index('POST', 'update-attributes-of-items', 'api/v2/indexing/update-attributes-of-items', ITEMS),
|
|
41
|
+
index('POST', 'remove-attributes-from-items', 'api/v2/indexing/remove-attributes-from-items', ITEMS),
|
|
42
|
+
index('POST', 'update-nested', 'api/v2/indexing/update-nested', ITEMS),
|
|
43
|
+
index('POST', 'remove-nested', 'api/v2/indexing/remove-nested', ITEMS),
|
|
44
|
+
index('POST', 'bulk-update-nested', 'api/v2/indexing/bulk-update-nested', ITEMS),
|
|
45
|
+
index('POST', 'bulk-remove-nested', 'api/v2/indexing/bulk-remove-nested', ITEMS),
|
|
46
|
+
index('POST', 'bulk-item-operations', 'api/v2/indexing/bulk-item-operations', ITEMS),
|
|
47
|
+
index('POST', 'create', 'api/v2/indexing/create', MANAGE),
|
|
48
|
+
index('POST', 'set-current', 'api/v2/indexing/set-current', MANAGE),
|
|
49
|
+
index('POST', 'swap', 'api/v2/indexing/swap', MANAGE),
|
|
50
|
+
index('POST', 'delete-index', 'api/v2/indexing/delete-index', MANAGE),
|
|
51
|
+
index('POST', 'rebuild-all', 'api/v2/indexing/rebuild-all', MANAGE),
|
|
52
|
+
index('POST', 'rebuild-autocomplete', 'api/v2/indexing/rebuild-autocomplete', MANAGE),
|
|
53
|
+
index('POST', 'rebuild-percolator', 'api/v2/indexing/rebuild-percolator', MANAGE),
|
|
54
|
+
index('POST', 'rebuild-learning', 'api/v2/indexing/rebuild-learning', MANAGE),
|
|
55
|
+
index('POST', 'rebuild-related', 'api/v2/indexing/rebuild-related', MANAGE),
|
|
56
|
+
index('POST', 'rebuild-embedding', 'api/v2/indexing/rebuild-embedding', MANAGE),
|
|
57
|
+
index('POST', 'hierarchy/upsert', 'api/hierarchy/upsert', MANAGE),
|
|
58
|
+
index('POST', 'hierarchy/rebuild', 'api/hierarchy/rebuild', MANAGE),
|
|
59
|
+
index('POST', 'hierarchy/delete-items', 'api/hierarchy/delete-items', MANAGE),
|
|
60
|
+
index('POST', 'hierarchy/delete-all', 'api/hierarchy/delete-all', MANAGE),
|
|
61
|
+
index('POST', 'mappings/upsert', 'api/mapping/upsert', MANAGE),
|
|
62
|
+
index('POST', 'mappings/invalidate-cache', 'api/mapping/invalidate-cache', MANAGE),
|
|
63
|
+
index('GET', 'embedding/status/{id}', 'api/embedding/status/{id}', READ),
|
|
64
|
+
],
|
|
65
|
+
maxItemsPerCall: 125,
|
|
66
|
+
maxIdsPerCall: 1000,
|
|
67
|
+
maxRequestBytes: 5 * 1024 * 1024,
|
|
68
|
+
itemWritesPerMinute: 120,
|
|
69
|
+
managePerMinute: 10,
|
|
70
|
+
rebuildsPerMinute: 2,
|
|
71
|
+
},
|
|
72
|
+
analytics: { blockedReports: [] },
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** The platform's answer over the fallback, section by section, so a field it doesn't send keeps its default. */
|
|
76
|
+
function merge(rules) {
|
|
77
|
+
if (!rules || typeof rules !== 'object' || !(rules.version >= 1)) throw new Error('not a rules document');
|
|
78
|
+
const merged = { ...FALLBACK };
|
|
79
|
+
for (const [section, defaults] of Object.entries(FALLBACK)) {
|
|
80
|
+
if (rules[section] && typeof rules[section] === 'object' && typeof defaults === 'object') merged[section] = { ...defaults, ...rules[section] };
|
|
81
|
+
}
|
|
82
|
+
return { ...merged, scopes: rules.scopes, version: rules.version };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function fetchJson(url) {
|
|
86
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS), headers: { accept: 'application/json' } });
|
|
87
|
+
if (!response.ok) throw new Error(`${url} answered ${response.status}`);
|
|
88
|
+
return response.json();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* rules() for the tools: { rules, from } from the platform (cached for a while), else the earlier answer, else
|
|
93
|
+
* { rules: FALLBACK, from, note } saying why.
|
|
94
|
+
*/
|
|
95
|
+
export function createPlatformRules({ url = PLATFORMS.dev, load = fetchJson, now = Date.now } = {}) {
|
|
96
|
+
const endpoint = `${url.replace(/\/+$/, '')}/meta/rules`;
|
|
97
|
+
let cached;
|
|
98
|
+
return async function rules() {
|
|
99
|
+
if (cached && now() - cached.at < CACHE_MS) return cached.answer;
|
|
100
|
+
try {
|
|
101
|
+
cached = { answer: { rules: merge(await load(endpoint)), from: endpoint }, at: now() };
|
|
102
|
+
return cached.answer;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (cached) return { ...cached.answer, note: `(${endpoint} couldn't be read just now: ${error.message}. These are its rules from earlier.)` };
|
|
105
|
+
// Not cached: the platform is tried again on the next call.
|
|
106
|
+
return { rules: FALLBACK, from: 'the CLI', note: `(${endpoint} couldn't be read: ${error.message}. These are the platform's default rules, which its configuration can change.)` };
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|