@bridgeline-digital/hawk-app-cli 0.1.0-dev.20261020202308 → 0.1.0
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 -7
- package/README.md +770 -770
- package/agents/AGENTS.md +192 -192
- package/agents/project/custom.md +21 -21
- package/agents/project/next-mui.md +21 -21
- package/agents/project/next.md +24 -24
- package/agents/project/vanilla.md +22 -22
- package/agents/project/vite.md +19 -19
- package/bin/hawk-app.mjs +0 -0
- package/docs/bridge/README.md +1315 -1315
- package/docs/showcase/README.md +47 -47
- package/docs/showcase/functions/echo.ts +12 -12
- package/docs/showcase/functions/long-task.ts +31 -31
- package/docs/showcase/functions/showcase.ts +186 -186
- package/docs/showcase/hawk-app.json +73 -73
- package/docs/showcase/queries/assistant-prompts-by-day.json +6 -6
- package/docs/showcase/queries/assistant-prompts-by-day.sql +8 -8
- package/docs/showcase/queries/searches-by-day.json +6 -6
- package/docs/showcase/queries/searches-by-day.sql +7 -7
- package/docs/showcase/src/App.tsx +1436 -1436
- package/docs/showcase/src/main.tsx +10 -10
- package/docs/showcase/src/styles.css +71 -71
- package/docs/showcase/src/useHawkApp.ts +44 -44
- package/docs/showcase/src/vite-env.d.ts +1 -1
- package/docs/ui/README.md +166 -166
- package/package.json +1 -1
- package/schema/hawk-app.schema.json +611 -611
- package/src/accessibility.mjs +105 -105
- package/src/agents.mjs +145 -145
- package/src/aliases.mjs +30 -30
- package/src/app-icon.mjs +82 -82
- package/src/channel.mjs +116 -116
- package/src/cli.mjs +825 -825
- package/src/clients.mjs +221 -221
- package/src/config.mjs +148 -148
- package/src/create.mjs +186 -186
- package/src/credentials.mjs +56 -56
- package/src/data.mjs +169 -169
- package/src/deploy.mjs +349 -349
- package/src/dev.mjs +459 -459
- package/src/doctor.mjs +176 -176
- package/src/env.mjs +140 -140
- package/src/functions.mjs +313 -313
- package/src/login.mjs +166 -166
- package/src/logs.mjs +64 -64
- package/src/manifest.mjs +425 -425
- package/src/mcp-docs.mjs +168 -168
- package/src/mcp-hawksearch.mjs +342 -342
- package/src/mcp-reports.mjs +130 -130
- package/src/mcp-rules.mjs +109 -109
- package/src/mcp.mjs +539 -539
- package/src/platform.mjs +116 -116
- package/src/project-checks.mjs +153 -153
- package/src/queries.mjs +289 -289
- package/src/scope-check.mjs +91 -91
- package/src/secrets.mjs +86 -86
- package/src/slug-guard.mjs +80 -80
- package/src/sso.mjs +182 -182
- package/src/telemetry.mjs +50 -50
- package/src/upgrade.mjs +406 -406
- package/src/versions.mjs +102 -102
- package/templates/next/next.config.ts +13 -13
- package/templates/next/package.json +26 -26
- package/templates/next/src/lib/useHawkApp.ts +46 -46
- package/templates/next-mui/next.config.ts +13 -13
- package/templates/next-mui/package.json +30 -30
- package/templates/next-mui/src/app/layout.tsx +19 -19
- package/templates/next-mui/src/app/page.tsx +78 -78
- package/templates/next-mui/src/app/providers.tsx +10 -10
- package/templates/preact/package.json +26 -26
- package/templates/preact/src/useHawkApp.ts +44 -44
- package/templates/preact/src/vite-env.d.ts +1 -1
- package/templates/preact/tsconfig.json +17 -17
- package/templates/preact/vite.config.ts +11 -11
- package/templates/react/package.json +29 -29
- package/templates/react/src/useHawkApp.ts +44 -44
- package/templates/react/src/vite-env.d.ts +1 -1
- package/templates/react/tsconfig.json +16 -16
- package/templates/react/vite.config.ts +11 -11
- package/templates/vanilla/package.json +15 -15
package/src/platform.mjs
CHANGED
|
@@ -1,116 +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
|
-
}
|
|
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
|
+
}
|
package/src/project-checks.mjs
CHANGED
|
@@ -1,153 +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
|
-
}
|
|
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
|
+
}
|