@bridgeline-digital/hawk-app-cli 0.0.0-stage → 0.1.0-dev.20261020205204
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 +769 -2
- 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
package/src/platform.mjs
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/** Talks to the Hawk Apps Platform with the publisher key (from the environment or .env.local, never a command-line flag). */
|
|
2
|
+
export function platformClient({ url = process.env.HAWK_APPS_PLATFORM_URL, key = process.env.HAWK_APPS_PUBLISHER_KEY, fetchImpl = fetch } = {}) {
|
|
3
|
+
if (!url) throw new Error('No platform URL. Run `npx hawk-app sso` once on this machine, or set HAWK_APPS_PLATFORM_URL.');
|
|
4
|
+
if (!key || !key.includes('.')) throw new Error('Not logged in. Run `npx hawk-app sso` (Bridgeline account) or `npx hawk-app login` (publisher key) once on this machine (in CI, set HAWK_APPS_PUBLISHER_KEY).');
|
|
5
|
+
const base = url.replace(/\/+$/, '');
|
|
6
|
+
|
|
7
|
+
/** Sends a request and returns the HTTP status with the parsed body; throws a readable error for failures. */
|
|
8
|
+
async function request(method, path, body) {
|
|
9
|
+
let response;
|
|
10
|
+
try {
|
|
11
|
+
response = await fetchImpl(`${base}${path}`, {
|
|
12
|
+
method,
|
|
13
|
+
headers: { Authorization: `PublisherKey ${key}`, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },
|
|
14
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
15
|
+
});
|
|
16
|
+
} catch (error) {
|
|
17
|
+
throw new Error(`Could not reach ${base}: ${error.message}`);
|
|
18
|
+
}
|
|
19
|
+
const text = await response.text();
|
|
20
|
+
let data = {};
|
|
21
|
+
try {
|
|
22
|
+
data = text ? JSON.parse(text) : {};
|
|
23
|
+
} catch {
|
|
24
|
+
data = { detail: text.slice(0, 300) };
|
|
25
|
+
}
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
const lines = [`${response.status} ${data.title ?? response.statusText}${data.detail ? `: ${data.detail}` : ''}`];
|
|
28
|
+
for (const error of data.errors ?? []) lines.push(` - ${error}`);
|
|
29
|
+
for (const missing of data.missing ?? []) lines.push(` - missing: ${missing}`);
|
|
30
|
+
if (response.status === 401) lines.push(' The key was refused (revoked, or expired: keys from `hawk-app sso` last 90 days). Run `npx hawk-app sso` or `npx hawk-app login` again; in CI, check HAWK_APPS_PUBLISHER_KEY.');
|
|
31
|
+
const err = new Error(lines.join('\n'));
|
|
32
|
+
err.status = response.status;
|
|
33
|
+
err.data = data;
|
|
34
|
+
const retryAfter = Number(response.headers.get('Retry-After'));
|
|
35
|
+
if (response.headers.has('Retry-After') && Number.isFinite(retryAfter) && retryAfter >= 0) err.retryAfterMs = retryAfter * 1000;
|
|
36
|
+
throw err;
|
|
37
|
+
}
|
|
38
|
+
return { status: response.status, data };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const call = async (method, path, body) => (await request(method, path, body)).data;
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
base,
|
|
45
|
+
reserveVersion: (slug) => call('POST', `/publish/apps/${encodeURIComponent(slug)}/versions`, {}),
|
|
46
|
+
// serverFiles: the app's queries/ files, for the private server bucket; functionFiles: each function's
|
|
47
|
+
// bundle.zip, source.zip and deps.json (both left out when there are none).
|
|
48
|
+
startUploads: (slug, version, files, serverFiles = [], functionFiles = []) =>
|
|
49
|
+
call('POST', `/publish/apps/${encodeURIComponent(slug)}/versions/${version}/uploads`, {
|
|
50
|
+
files,
|
|
51
|
+
...(serverFiles.length ? { serverFiles } : {}),
|
|
52
|
+
...(functionFiles.length ? { functionFiles } : {}),
|
|
53
|
+
}),
|
|
54
|
+
/** Creates or updates the function's Lambda from the uploaded bundle: { name, lambdaVersion, codeSha256 }. */
|
|
55
|
+
deployFunction: (slug, version, name, config) =>
|
|
56
|
+
call('POST', `/publish/apps/${encodeURIComponent(slug)}/versions/${version}/functions/${encodeURIComponent(name)}`, config),
|
|
57
|
+
/** `extra`: `{ alias, manifest }` for a deploy for an alias (the branch's hawk-app.json goes with it). */
|
|
58
|
+
completeVersion: (slug, version, files, serverFiles = [], functions = [], audit, accessibility, extra = {}) =>
|
|
59
|
+
call('POST', `/publish/apps/${encodeURIComponent(slug)}/versions/${version}/complete`, {
|
|
60
|
+
files,
|
|
61
|
+
...(extra.alias ? { alias: extra.alias } : {}),
|
|
62
|
+
...(extra.manifest ? { manifest: extra.manifest } : {}),
|
|
63
|
+
...(serverFiles.length ? { serverFiles } : {}),
|
|
64
|
+
...(functions.length ? { functions } : {}),
|
|
65
|
+
...(audit ? { audit } : {}),
|
|
66
|
+
...(accessibility ? { accessibility } : {}),
|
|
67
|
+
}),
|
|
68
|
+
/** The app's secrets: { secrets: [{ name, updatedAt }] } (never values). */
|
|
69
|
+
listSecrets: (slug) => call('GET', `/publish/apps/${encodeURIComponent(slug)}/secrets`),
|
|
70
|
+
setSecret: (slug, name, value) => call('PUT', `/publish/apps/${encodeURIComponent(slug)}/secrets/${encodeURIComponent(name)}`, { value }),
|
|
71
|
+
/** The app's config keys and stored values (ConfigView): { keys: [{ key, declared, type, label, description, default, global, clients }] }. */
|
|
72
|
+
getConfig: (slug) => call('GET', `/publish/apps/${encodeURIComponent(slug)}/config`),
|
|
73
|
+
/** Sets (or, with value null, clears) a config value, global or for one client; resolves to the ConfigView. */
|
|
74
|
+
setConfig: (slug, key, value, clientId = null) =>
|
|
75
|
+
call('PUT', `/publish/apps/${encodeURIComponent(slug)}/config/${encodeURIComponent(key)}`, { value, clientId: clientId ?? null }),
|
|
76
|
+
deleteSecret: (slug, name) => call('DELETE', `/publish/apps/${encodeURIComponent(slug)}/secrets/${encodeURIComponent(name)}`),
|
|
77
|
+
/** The app's errors over `days`: { days, total, groups: [{ fingerprint, kind, message, stack, source, count, versions, clients, firstDay, lastDay }] }. */
|
|
78
|
+
appErrors: (slug, days) => call('GET', `/publish/apps/${encodeURIComponent(slug)}/errors?days=${days}`),
|
|
79
|
+
/** The app's opens over `days`: { opens, viewOpens, staffOpens, users, clients, perDay, perClient }. */
|
|
80
|
+
appUsage: (slug, days) => call('GET', `/publish/apps/${encodeURIComponent(slug)}/usage?days=${days}`),
|
|
81
|
+
/** One page of a function's logs: { events: [{ timestamp, message }], nextToken }. */
|
|
82
|
+
functionLogs: (slug, name, { since, nextToken } = {}) => {
|
|
83
|
+
const query = new URLSearchParams();
|
|
84
|
+
if (since) query.set('since', since);
|
|
85
|
+
if (nextToken) query.set('nextToken', nextToken);
|
|
86
|
+
const qs = query.toString();
|
|
87
|
+
return call('GET', `/publish/apps/${encodeURIComponent(slug)}/functions/${encodeURIComponent(name)}/logs${qs ? `?${qs}` : ''}`);
|
|
88
|
+
},
|
|
89
|
+
register: (slug, manifest, dryRun) => call('PUT', `/publish/apps/${encodeURIComponent(slug)}${dryRun ? '?dryRun=true' : ''}`, manifest),
|
|
90
|
+
status: (slug) => call('GET', `/publish/apps/${encodeURIComponent(slug)}`),
|
|
91
|
+
/** Which clients have the app (the registry, not hawk-app.json): { visibility, clients: [{ id, name, enabled }] }. */
|
|
92
|
+
listClients: (slug) => call('GET', `/publish/apps/${encodeURIComponent(slug)}/clients`),
|
|
93
|
+
/** Gives a client the app (enabled true) or opts it out (false), at once: { id, name, enabled }. */
|
|
94
|
+
setClient: (slug, clientId, body) => call('PUT', `/publish/apps/${encodeURIComponent(slug)}/clients/${encodeURIComponent(clientId)}`, body),
|
|
95
|
+
/**
|
|
96
|
+
* Points an alias at a version that isn't approved (developers; approvers do the rest): only Bridgeline staff in its
|
|
97
|
+
* clients see it. The app's aliases: [{ name, version, status, … }].
|
|
98
|
+
*/
|
|
99
|
+
setAlias: (slug, name, version) => call('PUT', `/publish/apps/${encodeURIComponent(slug)}/aliases/${encodeURIComponent(name)}`, { version }),
|
|
100
|
+
/** Deletes an alias of the developer's: not approved, no clients on it. */
|
|
101
|
+
deleteAlias: (slug, name) => call('DELETE', `/publish/apps/${encodeURIComponent(slug)}/aliases/${encodeURIComponent(name)}`),
|
|
102
|
+
/** The app's versions, newest first: { versions: [{ version, status, aliases, deployedFor, createdAt, … }] }. */
|
|
103
|
+
listVersions: (slug) => call('GET', `/publish/apps/${encodeURIComponent(slug)}/versions`),
|
|
104
|
+
/** The environment's clients matching a name or id, best first: { clients: [{ id, name }], more }. */
|
|
105
|
+
searchClients: (text) => call('GET', `/publish/clients?search=${encodeURIComponent(text)}`),
|
|
106
|
+
removeClient: (slug, clientId) =>call('DELETE', `/publish/apps/${encodeURIComponent(slug)}/clients/${encodeURIComponent(clientId)}`),
|
|
107
|
+
/** An app token for local development (hawk-app dev): { token, expiresAt, version, scopes, apiBaseUrl, user, hawkSearch }. */
|
|
108
|
+
devToken: (slug, body) => call('POST', `/publish/apps/${encodeURIComponent(slug)}/dev-token`, body),
|
|
109
|
+
/** Lists the tables; with an app slug, its sensitive tables are available if Engineering opted the app in. */
|
|
110
|
+
dataTables: (app) => call('GET', `/publish/data/tables${app ? `?app=${encodeURIComponent(app)}` : ''}`),
|
|
111
|
+
|
|
112
|
+
/** Runs SQL against a client's data (dev platform only). Resolves to { status: 200 | 202, data }. */
|
|
113
|
+
dataRun: (body) => request('POST', '/publish/data/run', body),
|
|
114
|
+
dataExecution: (executionId) => request('GET', `/publish/data/executions/${encodeURIComponent(executionId)}`),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Checks of the app's project (not hawk-app.json) that hold for any app, from a template or not: `hawk-app validate`
|
|
2
|
+
// runs them, and deploy runs checkBuildUrls on what it's about to upload.
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
5
|
+
import { join, relative, sep } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { compareVersions, minVersion } from './upgrade.mjs';
|
|
8
|
+
import { TEMPLATES } from './create.mjs';
|
|
9
|
+
|
|
10
|
+
const BRIDGE = '@bridgeline-digital/hawk-app-bridge';
|
|
11
|
+
const CLI = '@bridgeline-digital/hawk-app-cli';
|
|
12
|
+
const CDN_IMPORT = /hawk-app-bridge@(\d+)\.(\d+)/;
|
|
13
|
+
const SOURCE_FILE = /\.(html?|m?[jt]sx?|vue|svelte)$/;
|
|
14
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist', 'out', 'build', '.next', 'coverage', 'functions', 'queries']);
|
|
15
|
+
const MAX_SCANNED = 2000;
|
|
16
|
+
|
|
17
|
+
/** The bridge version the CLI's templates use, e.g. { parts: [0, 5, 0] }. */
|
|
18
|
+
function supportedBridge() {
|
|
19
|
+
const pkg = JSON.parse(readFileSync(fileURLToPath(new URL('../templates/react/package.json', import.meta.url)), 'utf8'));
|
|
20
|
+
return minVersion(pkg.dependencies[BRIDGE]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const minor = (v) => `${v.parts[0]}.${v.parts[1]}`;
|
|
24
|
+
|
|
25
|
+
/** Files under `dir` worth scanning for the bridge (sources and HTML), skipping dependencies and build output. */
|
|
26
|
+
async function sourceFiles(dir) {
|
|
27
|
+
const files = [];
|
|
28
|
+
async function walk(current) {
|
|
29
|
+
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
30
|
+
if (files.length >= MAX_SCANNED) return;
|
|
31
|
+
if (entry.name.startsWith('.')) continue;
|
|
32
|
+
const full = join(current, entry.name);
|
|
33
|
+
if (entry.isDirectory()) {
|
|
34
|
+
if (!SKIP_DIRS.has(entry.name)) await walk(full);
|
|
35
|
+
} else if (SOURCE_FILE.test(entry.name)) {
|
|
36
|
+
files.push(full);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
await walk(dir);
|
|
41
|
+
return files;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The project checks: the bridge is used and on a supported version, and there's a way to build (or the app deploys
|
|
46
|
+
* as plain files). Only warnings: an app can be right in ways these can't see. { warnings }.
|
|
47
|
+
*/
|
|
48
|
+
export async function checkProject(dir) {
|
|
49
|
+
const warnings = [];
|
|
50
|
+
const supported = supportedBridge();
|
|
51
|
+
const pkgFile = join(dir, 'package.json');
|
|
52
|
+
const pkg = existsSync(pkgFile) ? JSON.parse(readFileSync(pkgFile, 'utf8')) : null;
|
|
53
|
+
|
|
54
|
+
const range = pkg?.dependencies?.[BRIDGE] ?? pkg?.devDependencies?.[BRIDGE];
|
|
55
|
+
if (range) {
|
|
56
|
+
const have = minVersion(range);
|
|
57
|
+
if (have && compareVersions({ parts: [...have.parts.slice(0, 2), 0], pre: null }, { parts: [...supported.parts.slice(0, 2), 0], pre: null }) < 0) {
|
|
58
|
+
warnings.push(`${BRIDGE} is ${range}, older than the ${minor(supported)} this CLI's templates use: npm install ${BRIDGE}@^${supported.parts.join('.')}.`);
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
let cdn = null;
|
|
62
|
+
for (const file of await sourceFiles(dir)) {
|
|
63
|
+
const match = CDN_IMPORT.exec(await readFile(file, 'utf8'));
|
|
64
|
+
if (match) {
|
|
65
|
+
cdn = { file: relative(dir, file).split(sep).join('/'), parts: [Number(match[1]), Number(match[2]), 0] };
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!cdn) {
|
|
70
|
+
warnings.push(`The app doesn't use the bridge (${BRIDGE}): install it, or import it from the CDN. See AGENTS.md.`);
|
|
71
|
+
} else if (compareVersions({ parts: cdn.parts, pre: null }, { parts: [...supported.parts.slice(0, 2), 0], pre: null }) < 0) {
|
|
72
|
+
warnings.push(`${cdn.file} imports the bridge ${cdn.parts[0]}.${cdn.parts[1]} from the CDN, older than the ${minor(supported)} this CLI's templates use.`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (pkg && !pkg.devDependencies?.[CLI] && !pkg.dependencies?.[CLI] && (existsSync(join(dir, '.mcp.json')) || existsSync(join(dir, '.vscode', 'mcp.json')))) {
|
|
77
|
+
warnings.push(`${CLI} isn't a dev dependency, but the MCP settings start it from node_modules: npm install -D ${CLI}.`);
|
|
78
|
+
}
|
|
79
|
+
if (!pkg) {
|
|
80
|
+
warnings.push('No package.json: deploy uploads the folder as it is, and npm audit cannot run, so the review\'s dependencies check fails.');
|
|
81
|
+
} else if (!pkg.scripts?.build) {
|
|
82
|
+
// Without a build script, deploy uploads the folder as it is: fine for plain files, not for a bundler's app.
|
|
83
|
+
const bundler = ['next', 'vite', 'react-scripts', 'webpack', 'parcel'].find((name) => pkg.dependencies?.[name] || pkg.devDependencies?.[name]);
|
|
84
|
+
if (bundler) warnings.push(`No "build" script in package.json, but the app uses ${bundler}: \`hawk-app deploy\` would upload the folder unbuilt. Add a build script.`);
|
|
85
|
+
}
|
|
86
|
+
return { warnings };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The build folder and whether it's built, from package.json's app:deploy script (`hawk-app deploy <folder> [--no-build]`). */
|
|
90
|
+
export function deployScript(dir) {
|
|
91
|
+
const pkgFile = join(dir, 'package.json');
|
|
92
|
+
if (!existsSync(pkgFile)) return null;
|
|
93
|
+
const script = JSON.parse(readFileSync(pkgFile, 'utf8')).scripts?.['app:deploy'] ?? '';
|
|
94
|
+
const match = /hawk-app\s+deploy\s+([^\s-][^\s]*)/.exec(script);
|
|
95
|
+
return match ? { folder: match[1], build: !/--no-build/.test(script) } : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Where deploy finds the build and whether it builds first: the folder given, else the one in the app:deploy script,
|
|
100
|
+
* else the template's (hawk-app.json `template`), else the framework's (Next.js `out`, Vite `dist`, plain `public`).
|
|
101
|
+
* It builds when package.json has a `build` script and app:deploy doesn't say --no-build. Null when it can't tell.
|
|
102
|
+
*/
|
|
103
|
+
export function buildTarget(dir, manifest, folder) {
|
|
104
|
+
const pkgFile = join(dir, 'package.json');
|
|
105
|
+
const pkg = existsSync(pkgFile) ? JSON.parse(readFileSync(pkgFile, 'utf8')) : {};
|
|
106
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
107
|
+
const script = deployScript(dir);
|
|
108
|
+
const hasBuild = !!pkg.scripts?.build;
|
|
109
|
+
const found =
|
|
110
|
+
folder ??
|
|
111
|
+
script?.folder ??
|
|
112
|
+
TEMPLATES[manifest?.template]?.build ??
|
|
113
|
+
(deps.next ? 'out' : deps.vite ? 'dist' : !hasBuild && existsSync(join(dir, 'public')) ? 'public' : null);
|
|
114
|
+
return found ? { folder: found, build: hasBuild && script?.build !== false } : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const URL_ATTRIBUTE = /\s(?:src|href|srcset|poster|data-src)\s*=\s*["']?(\/[^"'\s>]*)/gi;
|
|
118
|
+
// Connection hints load nothing: Next.js adds <link rel="preconnect" href="/"> when a page's CSS has fonts not loaded
|
|
119
|
+
// through next/font (e.g. Fontsource).
|
|
120
|
+
const RESOURCE_HINT = /<link\b[^>]*\brel\s*=\s*["']?(?:preconnect|dns-prefetch)\b[^>]*>/gi;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Absolute URLs in the built HTML that fall outside `basePath` (`/<slug>/v<n>`): they load nothing once deployed,
|
|
124
|
+
* because each version is served from its own folder. [{ file, url }], at most `limit`.
|
|
125
|
+
*/
|
|
126
|
+
export async function checkBuildUrls(folder, basePath, limit = 5) {
|
|
127
|
+
const found = [];
|
|
128
|
+
async function walk(current) {
|
|
129
|
+
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
130
|
+
if (found.length >= limit || entry.name.startsWith('.')) continue;
|
|
131
|
+
const full = join(current, entry.name);
|
|
132
|
+
if (entry.isDirectory()) {
|
|
133
|
+
await walk(full);
|
|
134
|
+
} else if (/\.html?$/i.test(entry.name)) {
|
|
135
|
+
const text = (await readFile(full, 'utf8')).replace(RESOURCE_HINT, '');
|
|
136
|
+
for (const match of text.matchAll(URL_ATTRIBUTE)) {
|
|
137
|
+
const url = match[1];
|
|
138
|
+
if (url.startsWith('//') || url === basePath || url.startsWith(`${basePath}/`)) continue;
|
|
139
|
+
found.push({ file: relative(folder, full).split(sep).join('/'), url });
|
|
140
|
+
if (found.length >= limit) break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (existsSync(folder)) await walk(folder);
|
|
146
|
+
return found;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The message for checkBuildUrls' findings. */
|
|
150
|
+
export function describeBuildUrls(found, basePath) {
|
|
151
|
+
const list = found.map((f) => `${f.file}: ${f.url}`).join(', ');
|
|
152
|
+
return `Absolute URLs outside ${basePath}/ (${list}) won't load once deployed: each version is served from its own folder. Read HAWK_APP_BASE_PATH in the build config, or use relative URLs (see AGENTS.md).`;
|
|
153
|
+
}
|
package/src/queries.mjs
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
// App queries (`data:read`): queries/<name>.sql + queries/<name>.json, deployed with each version and run by the
|
|
6
|
+
// platform against the current client's data. Same rules as the platform, which validates again on deploy.
|
|
7
|
+
export const QUERIES_DIR = 'queries';
|
|
8
|
+
export const DATA_SCOPE = 'data:read';
|
|
9
|
+
export const MAX_QUERIES = 50;
|
|
10
|
+
export const MAX_SQL_BYTES = 64 * 1024;
|
|
11
|
+
export const MAX_STRING_LENGTH = 1000;
|
|
12
|
+
export const MAX_LIST_ITEMS = 100;
|
|
13
|
+
export const PARAM_TYPES = ['string', 'int', 'decimal', 'date', 'timestamp', 'boolean', 'string[]'];
|
|
14
|
+
|
|
15
|
+
const NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
16
|
+
const PARAM_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
17
|
+
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
18
|
+
const PARAM_KEYS = ['name', 'type', 'required', 'default', 'min', 'max', 'maxItems'];
|
|
19
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
20
|
+
const TIMESTAMP = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Reads and checks `<projectDir>/queries`. `files` are the upload entries (`queries/<name>.sql|.json`, size, full
|
|
24
|
+
* path); `queries` the parsed definitions by name. Returns null when the folder doesn't exist.
|
|
25
|
+
* @returns {Promise<{ files: { path: string, size: number, full: string }[], queries: { name: string, sql: string, params: object[] }[], errors: string[] } | null>}
|
|
26
|
+
*/
|
|
27
|
+
export async function loadQueries(projectDir) {
|
|
28
|
+
const dir = join(projectDir, QUERIES_DIR);
|
|
29
|
+
if (!existsSync(dir)) return null;
|
|
30
|
+
const errors = [];
|
|
31
|
+
const files = [];
|
|
32
|
+
const byName = new Map();
|
|
33
|
+
|
|
34
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
35
|
+
if (entry.name.startsWith('.')) continue;
|
|
36
|
+
if (!entry.isFile()) {
|
|
37
|
+
errors.push(`${QUERIES_DIR}/${entry.name}: queries can't be in subfolders; put <name>.sql and <name>.json straight in ${QUERIES_DIR}/.`);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const match = /^(.*)\.(sql|json)$/.exec(entry.name);
|
|
41
|
+
if (!match) {
|
|
42
|
+
errors.push(`${QUERIES_DIR}/${entry.name}: only .sql and .json files belong in ${QUERIES_DIR}/.`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const [, name, ext] = match;
|
|
46
|
+
const full = join(dir, entry.name);
|
|
47
|
+
files.push({ path: `${QUERIES_DIR}/${entry.name}`, size: (await stat(full)).size, full });
|
|
48
|
+
const pair = byName.get(name) ?? {};
|
|
49
|
+
pair[ext] = await readFile(full, 'utf8');
|
|
50
|
+
byName.set(name, pair);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const queries = [];
|
|
54
|
+
for (const [name, pair] of [...byName].sort(([a], [b]) => a.localeCompare(b))) {
|
|
55
|
+
if (!NAME.test(name) || name.length > 64) {
|
|
56
|
+
errors.push(`${QUERIES_DIR}/${name}: query names must be 1-64 lowercase letters, digits and single dashes (e.g. "top-searches").`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (pair.sql === undefined) {
|
|
60
|
+
errors.push(`${QUERIES_DIR}/${name}.json has no ${name}.sql.`);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (pair.json === undefined) {
|
|
64
|
+
errors.push(`${QUERIES_DIR}/${name}.sql has no ${name}.json (declare its parameters there, e.g. { "params": [] }).`);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
let definition;
|
|
68
|
+
try {
|
|
69
|
+
definition = JSON.parse(pair.json);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
errors.push(`${QUERIES_DIR}/${name}.json is not valid JSON: ${error.message}`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const queryErrors = validateQuery(name, pair.sql, definition);
|
|
75
|
+
errors.push(...queryErrors);
|
|
76
|
+
if (!queryErrors.length) queries.push({ name, sql: pair.sql, params: definition.params ?? [] });
|
|
77
|
+
}
|
|
78
|
+
if (byName.size > MAX_QUERIES) errors.push(`${QUERIES_DIR}/: at most ${MAX_QUERIES} queries per app (found ${byName.size}).`);
|
|
79
|
+
if (!byName.size && !errors.length) errors.push(`${QUERIES_DIR}/ is empty: add <name>.sql and <name>.json, or remove the folder.`);
|
|
80
|
+
|
|
81
|
+
files.sort((a, b) => a.path.localeCompare(b.path));
|
|
82
|
+
return { files, queries, errors };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Checks one query: its SQL text and its parsed .json definition. Error messages start with the file they're about.
|
|
87
|
+
* @returns {string[]}
|
|
88
|
+
*/
|
|
89
|
+
export function validateQuery(name, sql, definition) {
|
|
90
|
+
const errors = [];
|
|
91
|
+
const sqlFile = `${QUERIES_DIR}/${name}.sql`;
|
|
92
|
+
const jsonFile = `${QUERIES_DIR}/${name}.json`;
|
|
93
|
+
|
|
94
|
+
const params = checkParams(definition, jsonFile, errors);
|
|
95
|
+
|
|
96
|
+
if (Buffer.byteLength(sql, 'utf8') > MAX_SQL_BYTES) {
|
|
97
|
+
errors.push(`${sqlFile} is larger than ${MAX_SQL_BYTES / 1024} KB.`);
|
|
98
|
+
return errors;
|
|
99
|
+
}
|
|
100
|
+
const code = stripLiterals(sql);
|
|
101
|
+
if (code === null) {
|
|
102
|
+
errors.push(`${sqlFile}: unterminated string, quoted identifier or comment.`);
|
|
103
|
+
return errors;
|
|
104
|
+
}
|
|
105
|
+
if (!code.trim()) {
|
|
106
|
+
errors.push(`${sqlFile} is empty.`);
|
|
107
|
+
return errors;
|
|
108
|
+
}
|
|
109
|
+
if (code.includes(';')) {
|
|
110
|
+
errors.push(`${sqlFile}: one statement only, without ";".`);
|
|
111
|
+
}
|
|
112
|
+
if (!/^(select|with)\b/i.test(code.trimStart())) {
|
|
113
|
+
errors.push(`${sqlFile}: must be a SELECT (or WITH … SELECT) statement.`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Macros: {{partitions(from, to)}} or {{partitions(from, to, alias)}}; the params must be declared dates.
|
|
117
|
+
const macro = /\{\{([\s\S]*?)\}\}/g;
|
|
118
|
+
for (const [whole, inner] of code.matchAll(macro)) {
|
|
119
|
+
const call = /^\s*partitions\s*\(([^()]*)\)\s*$/.exec(inner);
|
|
120
|
+
if (!call) {
|
|
121
|
+
errors.push(`${sqlFile}: unknown macro ${whole.trim()}; the only one is {{partitions(fromParam, toParam)}}.`);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const args = call[1].split(',').map((a) => a.trim());
|
|
125
|
+
if (args.length < 2 || args.length > 3 || args.some((a) => !a)) {
|
|
126
|
+
errors.push(`${sqlFile}: write {{partitions(fromParam, toParam)}} or {{partitions(fromParam, toParam, tableAlias)}}.`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
for (const arg of args.slice(0, 2)) {
|
|
130
|
+
const param = params?.find((p) => p.name === arg);
|
|
131
|
+
if (!param) errors.push(`${sqlFile}: {{partitions(…)}} uses "${arg}", which isn't a parameter in ${jsonFile}.`);
|
|
132
|
+
else if (param.type !== 'date') errors.push(`${sqlFile}: {{partitions(…)}} parameter "${arg}" must be declared "date" (it is "${param.type}").`);
|
|
133
|
+
}
|
|
134
|
+
if (args[2] !== undefined && !IDENTIFIER.test(args[2])) errors.push(`${sqlFile}: {{partitions(…)}} table alias "${args[2]}" is not a plain identifier.`);
|
|
135
|
+
}
|
|
136
|
+
const withoutMacros = code.replace(macro, ' ');
|
|
137
|
+
if (/\{\{|\}\}/.test(withoutMacros)) errors.push(`${sqlFile}: unbalanced {{ }} macro.`);
|
|
138
|
+
|
|
139
|
+
if (params) {
|
|
140
|
+
const placeholders = (withoutMacros.match(/\?/g) ?? []).length;
|
|
141
|
+
if (placeholders !== params.length) {
|
|
142
|
+
errors.push(
|
|
143
|
+
`${sqlFile} has ${placeholders} "?" placeholder${placeholders === 1 ? '' : 's'} but ${jsonFile} declares ${params.length} parameter${params.length === 1 ? '' : 's'}; they must match, in order (a string[] parameter is one "?", written as IN ?).`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return errors;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function checkParams(definition, file, errors) {
|
|
151
|
+
if (!definition || typeof definition !== 'object' || Array.isArray(definition)) {
|
|
152
|
+
errors.push(`${file} must be an object: { "params": [ { "name": "from", "type": "date" } ] }.`);
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const unknown = Object.keys(definition).filter((k) => k !== 'params');
|
|
156
|
+
if (unknown.length) errors.push(`${file}: unknown field${unknown.length === 1 ? '' : 's'} ${unknown.join(', ')} (only "params").`);
|
|
157
|
+
const params = definition.params ?? [];
|
|
158
|
+
if (!Array.isArray(params)) {
|
|
159
|
+
errors.push(`${file}: "params" must be a list.`);
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
const seen = new Set();
|
|
163
|
+
params.forEach((param, i) => {
|
|
164
|
+
const at = `${file} params[${i}]`;
|
|
165
|
+
if (!param || typeof param !== 'object' || Array.isArray(param)) {
|
|
166
|
+
errors.push(`${at} must be an object with "name" and "type".`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const label = typeof param.name === 'string' ? `${file} "${param.name}"` : at;
|
|
170
|
+
if (typeof param.name !== 'string' || !PARAM_NAME.test(param.name) || param.name.length > 64) {
|
|
171
|
+
errors.push(`${at}: "name" must be letters, digits and _ (starting with a letter or _).`);
|
|
172
|
+
} else if (seen.has(param.name)) {
|
|
173
|
+
errors.push(`${file}: parameter "${param.name}" is declared more than once.`);
|
|
174
|
+
} else {
|
|
175
|
+
seen.add(param.name);
|
|
176
|
+
}
|
|
177
|
+
const extra = Object.keys(param).filter((k) => !PARAM_KEYS.includes(k));
|
|
178
|
+
if (extra.length) errors.push(`${label}: unknown field${extra.length === 1 ? '' : 's'} ${extra.join(', ')} (allowed: ${PARAM_KEYS.join(', ')}).`);
|
|
179
|
+
if (!PARAM_TYPES.includes(param.type)) {
|
|
180
|
+
errors.push(`${label}: "type" must be one of ${PARAM_TYPES.join(', ')}.`);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (param.required !== undefined && typeof param.required !== 'boolean') errors.push(`${label}: "required" must be true or false.`);
|
|
184
|
+
const numeric = param.type === 'int' || param.type === 'decimal';
|
|
185
|
+
for (const key of ['min', 'max']) {
|
|
186
|
+
if (param[key] === undefined) continue;
|
|
187
|
+
if (!numeric) errors.push(`${label}: "${key}" only applies to int and decimal parameters.`);
|
|
188
|
+
else if (typeof param[key] !== 'number' || !Number.isFinite(param[key]) || (param.type === 'int' && !Number.isInteger(param[key]))) {
|
|
189
|
+
errors.push(`${label}: "${key}" must be ${param.type === 'int' ? 'an integer' : 'a number'}.`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (typeof param.min === 'number' && typeof param.max === 'number' && param.min > param.max) errors.push(`${label}: "min" is greater than "max".`);
|
|
193
|
+
if (param.maxItems !== undefined) {
|
|
194
|
+
if (param.type !== 'string[]') errors.push(`${label}: "maxItems" only applies to string[] parameters.`);
|
|
195
|
+
else if (!Number.isInteger(param.maxItems) || param.maxItems < 1 || param.maxItems > MAX_LIST_ITEMS) errors.push(`${label}: "maxItems" must be 1-${MAX_LIST_ITEMS}.`);
|
|
196
|
+
}
|
|
197
|
+
if (param.default !== undefined) {
|
|
198
|
+
const problem = checkValue(param, param.default);
|
|
199
|
+
if (problem) errors.push(`${label}: "default" ${problem}.`);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
return params.filter((p) => p && typeof p === 'object' && typeof p.name === 'string');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Checks a value against a declared parameter. Returns a reason ("must be …") or null when it's fine.
|
|
207
|
+
*/
|
|
208
|
+
export function checkValue(param, value) {
|
|
209
|
+
if (value === null) return null;
|
|
210
|
+
switch (param.type) {
|
|
211
|
+
case 'string':
|
|
212
|
+
return typeof value === 'string' && value.length <= MAX_STRING_LENGTH ? null : `must be a string of up to ${MAX_STRING_LENGTH} characters`;
|
|
213
|
+
case 'int':
|
|
214
|
+
case 'decimal': {
|
|
215
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || (param.type === 'int' && !Number.isInteger(value))) return `must be ${param.type === 'int' ? 'an integer' : 'a number'}`;
|
|
216
|
+
if (typeof param.min === 'number' && value < param.min) return `must be at least ${param.min}`;
|
|
217
|
+
if (typeof param.max === 'number' && value > param.max) return `must be at most ${param.max}`;
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
case 'date':
|
|
221
|
+
return typeof value === 'string' && DATE.test(value) && !Number.isNaN(Date.parse(value)) ? null : 'must be a date (YYYY-MM-DD)';
|
|
222
|
+
case 'timestamp':
|
|
223
|
+
return typeof value === 'string' && TIMESTAMP.test(value) && !Number.isNaN(Date.parse(value)) ? null : 'must be an ISO 8601 timestamp';
|
|
224
|
+
case 'boolean':
|
|
225
|
+
return typeof value === 'boolean' ? null : 'must be true or false';
|
|
226
|
+
case 'string[]': {
|
|
227
|
+
const max = param.maxItems ?? MAX_LIST_ITEMS;
|
|
228
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string' || v.length > MAX_STRING_LENGTH)) return 'must be a list of strings';
|
|
229
|
+
return value.length <= max ? null : `must have at most ${max} items`;
|
|
230
|
+
}
|
|
231
|
+
default:
|
|
232
|
+
return 'has an unknown type';
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Turns a command-line value (`--param name=value`) into the declared type: numbers, booleans, comma-separated lists. */
|
|
237
|
+
export function parseParamValue(param, text) {
|
|
238
|
+
switch (param.type) {
|
|
239
|
+
case 'int':
|
|
240
|
+
case 'decimal':
|
|
241
|
+
return text.trim() === '' ? NaN : Number(text);
|
|
242
|
+
case 'boolean':
|
|
243
|
+
return text === 'true' ? true : text === 'false' ? false : text;
|
|
244
|
+
case 'string[]':
|
|
245
|
+
return text === '' ? [] : text.split(',').map((v) => v.trim());
|
|
246
|
+
default:
|
|
247
|
+
return text;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The SQL with string literals, quoted identifiers and comments blanked out (same length), so `?`, `;`, macros and
|
|
253
|
+
* the first keyword can be found without being fooled by text. Null when something is left open.
|
|
254
|
+
*/
|
|
255
|
+
export function stripLiterals(sql) {
|
|
256
|
+
let out = '';
|
|
257
|
+
let i = 0;
|
|
258
|
+
while (i < sql.length) {
|
|
259
|
+
const c = sql[i];
|
|
260
|
+
const next = sql[i + 1];
|
|
261
|
+
if (c === '-' && next === '-') {
|
|
262
|
+
const end = sql.indexOf('\n', i);
|
|
263
|
+
const stop = end === -1 ? sql.length : end;
|
|
264
|
+
out += ' '.repeat(stop - i);
|
|
265
|
+
i = stop;
|
|
266
|
+
} else if (c === '/' && next === '*') {
|
|
267
|
+
const end = sql.indexOf('*/', i + 2);
|
|
268
|
+
if (end === -1) return null;
|
|
269
|
+
out += ' '.repeat(end + 2 - i);
|
|
270
|
+
i = end + 2;
|
|
271
|
+
} else if (c === "'" || c === '"') {
|
|
272
|
+
// '' and "" escape the quote inside.
|
|
273
|
+
let j = i + 1;
|
|
274
|
+
for (;;) {
|
|
275
|
+
if (j >= sql.length) return null;
|
|
276
|
+
if (sql[j] === c) {
|
|
277
|
+
if (sql[j + 1] === c) j += 2;
|
|
278
|
+
else break;
|
|
279
|
+
} else j++;
|
|
280
|
+
}
|
|
281
|
+
out += ' '.repeat(j + 1 - i);
|
|
282
|
+
i = j + 1;
|
|
283
|
+
} else {
|
|
284
|
+
out += c;
|
|
285
|
+
i++;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { extname, join, relative } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Which scopes the app's code needs: the bridge (and function) calls it makes, matched in its source files. A call whose
|
|
6
|
+
* scope hawk-app.json lacks fails at run time with 403 scope_missing; `validate` and `deploy` say so first. Matching is
|
|
7
|
+
* by the call's shape (`.ai.generate(`, `.storage.put(`…), so it works whatever the app calls its bridge object, and it
|
|
8
|
+
* only warns: a false hit costs a line, a miss is no worse than today.
|
|
9
|
+
*/
|
|
10
|
+
const RULES = [
|
|
11
|
+
{ scope: 'reports:read', pattern: /\.analytics\.report\s*\(/ },
|
|
12
|
+
{ scope: 'hawksearch:read', pattern: /\.dashboard\.get\s*\(/ },
|
|
13
|
+
{ scope: 'hawksearch:write', pattern: /\.dashboard\.(post|put|delete)\s*\(/ },
|
|
14
|
+
{ scope: 'data:read', pattern: /\.data\.query\s*\(/ },
|
|
15
|
+
{ scope: 'functions:invoke', pattern: /\.functions\.(call|start|job|wait)\s*\(/ },
|
|
16
|
+
{ scope: 'email:send', pattern: /\.email\.send\s*\(/ },
|
|
17
|
+
{ scope: 'hawksearch:search', pattern: /\.hawksearch\.(search|autocomplete)\s*\(/ },
|
|
18
|
+
{ scope: 'hawksearch:sysparams:write', pattern: /\.hawksearch\.systemParameters\.(list|get|set)\s*\(/ },
|
|
19
|
+
{ scope: 'hawksearch:features:write', pattern: /\.hawksearch\.features\.(list|set|setFacet)\s*\(/ },
|
|
20
|
+
{ scope: 'hawksearch:index:items', pattern: /\.hawksearch\.index\.items\.\w+\s*\(/ },
|
|
21
|
+
{ scope: 'hawksearch:index', pattern: /\.hawksearch\.index\.(rebuild|status|wait|indexes|current)\s*\(/ },
|
|
22
|
+
{ scope: 'hawksearch:index:manage', pattern: /\.hawksearch\.index\.(create|setCurrent|deleteIndex|rebuildAll)\s*\(/ },
|
|
23
|
+
{ scope: 'hawksearch:recommendations', pattern: /\.hawksearch\.recommendations\s*\(/ },
|
|
24
|
+
{ scope: 'storage', pattern: /\.storage\.(get|put|delete|list|batch|usage)\s*\(|\.storage\.files\.\w+\s*\(/ },
|
|
25
|
+
{ scope: 'lambdas:invoke', pattern: /\.lambdas\.invoke\s*\(/ },
|
|
26
|
+
{ scope: 'ai:invoke', pattern: /\.ai\.(generate|stream|info)\s*\(/ },
|
|
27
|
+
{ scope: 'assistant', pattern: /\.assistant\.(ask|setContext)\s*\(/ },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const SOURCE = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.vue', '.svelte', '.html']);
|
|
31
|
+
const SKIP = new Set(['node_modules', '.git', '.next', 'dist', 'out', 'build', 'coverage', '.turbo', '.vercel']);
|
|
32
|
+
const MAX_FILES = 3000;
|
|
33
|
+
const MAX_BYTES = 1024 * 1024;
|
|
34
|
+
|
|
35
|
+
/** Source files of the project (not dependencies or build output), at most MAX_FILES. */
|
|
36
|
+
async function sourceFiles(dir, found = []) {
|
|
37
|
+
let entries;
|
|
38
|
+
try {
|
|
39
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
40
|
+
} catch {
|
|
41
|
+
return found;
|
|
42
|
+
}
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
if (found.length >= MAX_FILES) break;
|
|
45
|
+
if (entry.name.startsWith('.') && entry.name !== '.') continue;
|
|
46
|
+
const full = join(dir, entry.name);
|
|
47
|
+
if (entry.isDirectory()) {
|
|
48
|
+
if (!SKIP.has(entry.name)) await sourceFiles(full, found);
|
|
49
|
+
} else if (SOURCE.has(extname(entry.name))) {
|
|
50
|
+
found.push(full);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return found;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The scopes the code uses that `scopes` lacks: [{ scope, file, line }] (the first place each one is used), relative to
|
|
58
|
+
* `projectDir`.
|
|
59
|
+
*/
|
|
60
|
+
export async function missingScopes(projectDir, scopes = []) {
|
|
61
|
+
const declared = new Set(scopes);
|
|
62
|
+
const wanted = RULES.filter((rule) => !declared.has(rule.scope));
|
|
63
|
+
if (!wanted.length) return [];
|
|
64
|
+
const found = new Map();
|
|
65
|
+
for (const file of await sourceFiles(projectDir)) {
|
|
66
|
+
if (found.size === wanted.length) break;
|
|
67
|
+
let text;
|
|
68
|
+
try {
|
|
69
|
+
if ((await stat(file)).size > MAX_BYTES) continue;
|
|
70
|
+
text = await readFile(file, 'utf8');
|
|
71
|
+
} catch {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
for (const rule of wanted) {
|
|
75
|
+
if (found.has(rule.scope)) continue;
|
|
76
|
+
const match = rule.pattern.exec(text);
|
|
77
|
+
if (match) {
|
|
78
|
+
const line = text.slice(0, match.index).split('\n').length;
|
|
79
|
+
found.set(rule.scope, { scope: rule.scope, file: relative(projectDir, file).split('\\').join('/'), line });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return [...found.values()];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The warning for what's missing (null: nothing). */
|
|
87
|
+
export function describeMissingScopes(missing) {
|
|
88
|
+
if (!missing.length) return null;
|
|
89
|
+
const where = missing.map((m) => `${m.scope} (${m.file}:${m.line})`).join(', ');
|
|
90
|
+
return `The code uses ${where}, which "scopes" in hawk-app.json doesn't have: add ${missing.length === 1 ? 'it' : 'them'}, or those calls fail with 403 scope_missing.`;
|
|
91
|
+
}
|