@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.
Files changed (80) hide show
  1. package/LICENSE +7 -7
  2. package/README.md +770 -770
  3. package/agents/AGENTS.md +192 -192
  4. package/agents/project/custom.md +21 -21
  5. package/agents/project/next-mui.md +21 -21
  6. package/agents/project/next.md +24 -24
  7. package/agents/project/vanilla.md +22 -22
  8. package/agents/project/vite.md +19 -19
  9. package/bin/hawk-app.mjs +0 -0
  10. package/docs/bridge/README.md +1315 -1315
  11. package/docs/showcase/README.md +47 -47
  12. package/docs/showcase/functions/echo.ts +12 -12
  13. package/docs/showcase/functions/long-task.ts +31 -31
  14. package/docs/showcase/functions/showcase.ts +186 -186
  15. package/docs/showcase/hawk-app.json +73 -73
  16. package/docs/showcase/queries/assistant-prompts-by-day.json +6 -6
  17. package/docs/showcase/queries/assistant-prompts-by-day.sql +8 -8
  18. package/docs/showcase/queries/searches-by-day.json +6 -6
  19. package/docs/showcase/queries/searches-by-day.sql +7 -7
  20. package/docs/showcase/src/App.tsx +1436 -1436
  21. package/docs/showcase/src/main.tsx +10 -10
  22. package/docs/showcase/src/styles.css +71 -71
  23. package/docs/showcase/src/useHawkApp.ts +44 -44
  24. package/docs/showcase/src/vite-env.d.ts +1 -1
  25. package/docs/ui/README.md +166 -166
  26. package/package.json +1 -1
  27. package/schema/hawk-app.schema.json +611 -611
  28. package/src/accessibility.mjs +105 -105
  29. package/src/agents.mjs +145 -145
  30. package/src/aliases.mjs +30 -30
  31. package/src/app-icon.mjs +82 -82
  32. package/src/channel.mjs +116 -116
  33. package/src/cli.mjs +825 -825
  34. package/src/clients.mjs +221 -221
  35. package/src/config.mjs +148 -148
  36. package/src/create.mjs +186 -186
  37. package/src/credentials.mjs +56 -56
  38. package/src/data.mjs +169 -169
  39. package/src/deploy.mjs +349 -349
  40. package/src/dev.mjs +459 -459
  41. package/src/doctor.mjs +176 -176
  42. package/src/env.mjs +140 -140
  43. package/src/functions.mjs +313 -313
  44. package/src/login.mjs +166 -166
  45. package/src/logs.mjs +64 -64
  46. package/src/manifest.mjs +425 -425
  47. package/src/mcp-docs.mjs +168 -168
  48. package/src/mcp-hawksearch.mjs +342 -342
  49. package/src/mcp-reports.mjs +130 -130
  50. package/src/mcp-rules.mjs +109 -109
  51. package/src/mcp.mjs +539 -539
  52. package/src/platform.mjs +116 -116
  53. package/src/project-checks.mjs +153 -153
  54. package/src/queries.mjs +289 -289
  55. package/src/scope-check.mjs +91 -91
  56. package/src/secrets.mjs +86 -86
  57. package/src/slug-guard.mjs +80 -80
  58. package/src/sso.mjs +182 -182
  59. package/src/telemetry.mjs +50 -50
  60. package/src/upgrade.mjs +406 -406
  61. package/src/versions.mjs +102 -102
  62. package/templates/next/next.config.ts +13 -13
  63. package/templates/next/package.json +26 -26
  64. package/templates/next/src/lib/useHawkApp.ts +46 -46
  65. package/templates/next-mui/next.config.ts +13 -13
  66. package/templates/next-mui/package.json +30 -30
  67. package/templates/next-mui/src/app/layout.tsx +19 -19
  68. package/templates/next-mui/src/app/page.tsx +78 -78
  69. package/templates/next-mui/src/app/providers.tsx +10 -10
  70. package/templates/preact/package.json +26 -26
  71. package/templates/preact/src/useHawkApp.ts +44 -44
  72. package/templates/preact/src/vite-env.d.ts +1 -1
  73. package/templates/preact/tsconfig.json +17 -17
  74. package/templates/preact/vite.config.ts +11 -11
  75. package/templates/react/package.json +29 -29
  76. package/templates/react/src/useHawkApp.ts +44 -44
  77. package/templates/react/src/vite-env.d.ts +1 -1
  78. package/templates/react/tsconfig.json +16 -16
  79. package/templates/react/vite.config.ts +11 -11
  80. package/templates/vanilla/package.json +15 -15
package/src/sso.mjs CHANGED
@@ -1,182 +1,182 @@
1
- // `hawk-app sso`: Bridgeline staff sign in with their AD account in the browser, and the platform issues them their own
2
- // publisher key (it expires; sign in again then). No key is handed from person to person. `hawk-app login` with a key
3
- // from the portal keeps working, and pipelines keep using portal keys.
4
- //
5
- // The standard flow for command-line tools: OAuth 2.0 authorization code with PKCE (RFC 7636) against the HawkSearch
6
- // user pool, sent straight to the Bridgeline identity provider, with the browser returning to a one-time server on
7
- // localhost (RFC 8252). The ID token goes to the platform, which verifies it and answers with the key.
8
- import { spawn } from 'node:child_process';
9
- import { createHash, randomBytes } from 'node:crypto';
10
- import { createServer } from 'node:http';
11
- import { hostname } from 'node:os';
12
- import { credentialsPath, readCredentials, saveCredential } from './credentials.mjs';
13
- import { describePlatform, resolveSettings } from './env.mjs';
14
-
15
- const SIGN_IN_TIMEOUT_MS = 5 * 60_000;
16
-
17
- const base64url = (buffer) => buffer.toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
18
-
19
- /** Opens a URL in the default browser, without a shell (the URL has & in it). Best effort: the URL is printed too. */
20
- export function openBrowser(url) {
21
- const [command, args] =
22
- process.platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]] : process.platform === 'darwin' ? ['open', [url]] : ['xdg-open', [url]];
23
- try {
24
- const child = spawn(command, args, { stdio: 'ignore', detached: true });
25
- child.on('error', () => {});
26
- child.unref();
27
- } catch {
28
- // The printed address still works.
29
- }
30
- }
31
-
32
- const PAGE = (title, text) =>
33
- `<!doctype html><meta charset="utf-8"><title>${title}</title><body style="font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem;line-height:1.5"><h1 style="font-size:1.4rem">${title}</h1><p>${text}</p></body>`;
34
-
35
- /**
36
- * Listens on the first free port of `ports` for the browser's return to /callback. Resolves to { port, redirectUri,
37
- * result, close }: `result` settles with the query of the first /callback request.
38
- */
39
- async function callbackServer(ports, expectedState) {
40
- let settle;
41
- const result = new Promise((resolve, reject) => (settle = { resolve, reject }));
42
- const server = createServer((req, res) => {
43
- const url = new URL(req.url ?? '/', 'http://localhost');
44
- if (url.pathname !== '/callback') {
45
- res.writeHead(404).end();
46
- return;
47
- }
48
- const query = Object.fromEntries(url.searchParams);
49
- if (query.state !== expectedState) {
50
- res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }).end(PAGE('Sign-in failed', 'This sign-in was not started by this terminal. Run <code>hawk-app sso</code> again.'));
51
- settle.reject(new Error('The sign-in came back with the wrong state (not this attempt). Nothing was saved.'));
52
- return;
53
- }
54
- const failed = Boolean(query.error);
55
- res.writeHead(failed ? 400 : 200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }).end(
56
- failed ? PAGE('Sign-in failed', 'Go back to the terminal for the details.') : PAGE('Signed in', 'You can close this tab and go back to the terminal.'),
57
- );
58
- settle.resolve(query);
59
- });
60
- for (const port of ports) {
61
- try {
62
- await new Promise((resolve, reject) => {
63
- server.once('error', reject);
64
- server.listen(port, 'localhost', () => {
65
- server.off('error', reject);
66
- resolve();
67
- });
68
- });
69
- return { port, redirectUri: `http://localhost:${port}/callback`, result, close: () => server.close() };
70
- } catch (error) {
71
- if (error.code !== 'EADDRINUSE') throw error;
72
- }
73
- }
74
- throw new Error(`Ports ${ports.join(', ')} are all in use, so the sign-in can't come back to this terminal. Close what uses them and try again.`);
75
- }
76
-
77
- async function json(response) {
78
- const text = await response.text();
79
- try {
80
- return text ? JSON.parse(text) : {};
81
- } catch {
82
- return { detail: text.slice(0, 200) };
83
- }
84
- }
85
-
86
- /**
87
- * Signs in with Bridgeline AD and saves the issued key for this machine and platform, like `hawk-app login`.
88
- * `openUrl` opens the browser (tests stand in for it); `machine` names this computer's key.
89
- */
90
- export async function sso({
91
- dir = process.cwd(),
92
- platform,
93
- env,
94
- publisher,
95
- fetchImpl = fetch,
96
- openUrl = openBrowser,
97
- log = console.log,
98
- machine = hostname(),
99
- timeoutMs = SIGN_IN_TIMEOUT_MS,
100
- credentialsFile = credentialsPath(),
101
- }) {
102
- const { url } = resolveSettings({ flagPlatform: platform, flagEnv: env, dir, credentialsFile });
103
- log(`Platform: ${describePlatform(url)}`);
104
-
105
- let response;
106
- try {
107
- response = await fetchImpl(`${url}/publish/sso`);
108
- } catch (error) {
109
- throw new Error(`Could not reach ${url}: ${error.message}.`);
110
- }
111
- if (response.status === 404) throw new Error(`Sign-in isn't available on ${describePlatform(url)}. Use \`hawk-app login\` with a publisher key from Engineering.`);
112
- if (!response.ok) throw new Error(`The platform answered ${response.status} for the sign-in settings.`);
113
- const settings = await json(response);
114
- // The team to work in: its apps are the ones the key can change. Checked before the browser opens.
115
- const publishers = settings.publishers ?? [];
116
- if (publisher && publishers.length && !publishers.includes(publisher)) {
117
- throw new Error(`Sign-in can't issue keys for publisher "${publisher}". Choose one of: ${publishers.join(', ')} (default ${publishers[0]}).`);
118
- }
119
-
120
- const verifier = base64url(randomBytes(32));
121
- const state = base64url(randomBytes(16));
122
- const server = await callbackServer(settings.redirectPorts, state);
123
- try {
124
- const authorize = new URL(settings.authorizeUrl);
125
- for (const [name, value] of Object.entries({
126
- response_type: 'code',
127
- client_id: settings.clientId,
128
- redirect_uri: server.redirectUri,
129
- identity_provider: settings.identityProvider,
130
- scope: settings.scopes,
131
- state,
132
- code_challenge: base64url(createHash('sha256').update(verifier).digest()),
133
- code_challenge_method: 'S256',
134
- })) {
135
- authorize.searchParams.set(name, value);
136
- }
137
- log('Opening your browser to sign in with your Bridgeline account…');
138
- log(` If it doesn't open, go to: ${authorize.href}`);
139
- openUrl(authorize.href);
140
-
141
- let timer;
142
- const query = await Promise.race([
143
- server.result,
144
- new Promise((_, reject) => {
145
- timer = setTimeout(() => reject(new Error('No sign-in within 5 minutes. Run `hawk-app sso` again.')), timeoutMs);
146
- }),
147
- ]).finally(() => clearTimeout(timer));
148
- if (query.error) throw new Error(`The sign-in failed: ${query.error_description ?? query.error}. Nothing was saved.`);
149
- if (!query.code) throw new Error('The sign-in came back without a code. Nothing was saved.');
150
-
151
- // The code, for the ID token: a public client proves it started the sign-in with the PKCE verifier.
152
- const token = await fetchImpl(settings.tokenUrl, {
153
- method: 'POST',
154
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
155
- body: new URLSearchParams({ grant_type: 'authorization_code', client_id: settings.clientId, code: query.code, redirect_uri: server.redirectUri, code_verifier: verifier }).toString(),
156
- });
157
- const tokens = await json(token);
158
- if (!token.ok || !tokens.id_token) throw new Error(`The sign-in couldn't be completed (${tokens.error_description ?? tokens.error ?? token.status}). Nothing was saved.`);
159
-
160
- const issued = await fetchImpl(`${url}/publish/sso`, {
161
- method: 'POST',
162
- headers: { 'Content-Type': 'application/json' },
163
- body: JSON.stringify({ idToken: tokens.id_token, machine, ...(publisher ? { publisherId: publisher } : {}) }),
164
- });
165
- const key = await json(issued);
166
- if (!issued.ok || !key.key) throw new Error(`${key.detail ?? key.title ?? `The platform answered ${issued.status}.`} Nothing was saved.`);
167
-
168
- // One saved key per platform: say so when it was another team's.
169
- const previous = (await readCredentials(credentialsFile)).platforms[url]?.publisherKey;
170
- await saveCredential(credentialsFile, url, key.key);
171
- if (previous && previous.split('.')[0] !== key.publisherId) {
172
- log(` (This replaces your saved key for publisher "${previous.split('.')[0]}" on this platform. Switch back with \`hawk-app sso --publisher ${previous.split('.')[0]}\` or \`hawk-app login\`.)`);
173
- }
174
- log(`✔ Signed in as ${key.label} (${key.email}) on ${describePlatform(url)}, publisher "${key.publisherId}".`);
175
- log(` Your key ${key.publisherId}/${key.keyId} is saved for every app on this machine and works until ${String(key.expiresAt).slice(0, 10)}; run \`hawk-app sso\` again then.`);
176
- if (key.developerGroup === 'added') log(' You are in Hawk-App-Developers, so you can submit apps for approval in HawkSearch.');
177
- if (key.developerGroup === 'failed') log(" ! Couldn't add you to Hawk-App-Developers (needed to submit apps for approval in HawkSearch). Ask Engineering, or run `hawk-app sso` again later.");
178
- return key;
179
- } finally {
180
- server.close();
181
- }
182
- }
1
+ // `hawk-app sso`: Bridgeline staff sign in with their AD account in the browser, and the platform issues them their own
2
+ // publisher key (it expires; sign in again then). No key is handed from person to person. `hawk-app login` with a key
3
+ // from the portal keeps working, and pipelines keep using portal keys.
4
+ //
5
+ // The standard flow for command-line tools: OAuth 2.0 authorization code with PKCE (RFC 7636) against the HawkSearch
6
+ // user pool, sent straight to the Bridgeline identity provider, with the browser returning to a one-time server on
7
+ // localhost (RFC 8252). The ID token goes to the platform, which verifies it and answers with the key.
8
+ import { spawn } from 'node:child_process';
9
+ import { createHash, randomBytes } from 'node:crypto';
10
+ import { createServer } from 'node:http';
11
+ import { hostname } from 'node:os';
12
+ import { credentialsPath, readCredentials, saveCredential } from './credentials.mjs';
13
+ import { describePlatform, resolveSettings } from './env.mjs';
14
+
15
+ const SIGN_IN_TIMEOUT_MS = 5 * 60_000;
16
+
17
+ const base64url = (buffer) => buffer.toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
18
+
19
+ /** Opens a URL in the default browser, without a shell (the URL has & in it). Best effort: the URL is printed too. */
20
+ export function openBrowser(url) {
21
+ const [command, args] =
22
+ process.platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]] : process.platform === 'darwin' ? ['open', [url]] : ['xdg-open', [url]];
23
+ try {
24
+ const child = spawn(command, args, { stdio: 'ignore', detached: true });
25
+ child.on('error', () => {});
26
+ child.unref();
27
+ } catch {
28
+ // The printed address still works.
29
+ }
30
+ }
31
+
32
+ const PAGE = (title, text) =>
33
+ `<!doctype html><meta charset="utf-8"><title>${title}</title><body style="font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem;line-height:1.5"><h1 style="font-size:1.4rem">${title}</h1><p>${text}</p></body>`;
34
+
35
+ /**
36
+ * Listens on the first free port of `ports` for the browser's return to /callback. Resolves to { port, redirectUri,
37
+ * result, close }: `result` settles with the query of the first /callback request.
38
+ */
39
+ async function callbackServer(ports, expectedState) {
40
+ let settle;
41
+ const result = new Promise((resolve, reject) => (settle = { resolve, reject }));
42
+ const server = createServer((req, res) => {
43
+ const url = new URL(req.url ?? '/', 'http://localhost');
44
+ if (url.pathname !== '/callback') {
45
+ res.writeHead(404).end();
46
+ return;
47
+ }
48
+ const query = Object.fromEntries(url.searchParams);
49
+ if (query.state !== expectedState) {
50
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }).end(PAGE('Sign-in failed', 'This sign-in was not started by this terminal. Run <code>hawk-app sso</code> again.'));
51
+ settle.reject(new Error('The sign-in came back with the wrong state (not this attempt). Nothing was saved.'));
52
+ return;
53
+ }
54
+ const failed = Boolean(query.error);
55
+ res.writeHead(failed ? 400 : 200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }).end(
56
+ failed ? PAGE('Sign-in failed', 'Go back to the terminal for the details.') : PAGE('Signed in', 'You can close this tab and go back to the terminal.'),
57
+ );
58
+ settle.resolve(query);
59
+ });
60
+ for (const port of ports) {
61
+ try {
62
+ await new Promise((resolve, reject) => {
63
+ server.once('error', reject);
64
+ server.listen(port, 'localhost', () => {
65
+ server.off('error', reject);
66
+ resolve();
67
+ });
68
+ });
69
+ return { port, redirectUri: `http://localhost:${port}/callback`, result, close: () => server.close() };
70
+ } catch (error) {
71
+ if (error.code !== 'EADDRINUSE') throw error;
72
+ }
73
+ }
74
+ throw new Error(`Ports ${ports.join(', ')} are all in use, so the sign-in can't come back to this terminal. Close what uses them and try again.`);
75
+ }
76
+
77
+ async function json(response) {
78
+ const text = await response.text();
79
+ try {
80
+ return text ? JSON.parse(text) : {};
81
+ } catch {
82
+ return { detail: text.slice(0, 200) };
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Signs in with Bridgeline AD and saves the issued key for this machine and platform, like `hawk-app login`.
88
+ * `openUrl` opens the browser (tests stand in for it); `machine` names this computer's key.
89
+ */
90
+ export async function sso({
91
+ dir = process.cwd(),
92
+ platform,
93
+ env,
94
+ publisher,
95
+ fetchImpl = fetch,
96
+ openUrl = openBrowser,
97
+ log = console.log,
98
+ machine = hostname(),
99
+ timeoutMs = SIGN_IN_TIMEOUT_MS,
100
+ credentialsFile = credentialsPath(),
101
+ }) {
102
+ const { url } = resolveSettings({ flagPlatform: platform, flagEnv: env, dir, credentialsFile });
103
+ log(`Platform: ${describePlatform(url)}`);
104
+
105
+ let response;
106
+ try {
107
+ response = await fetchImpl(`${url}/publish/sso`);
108
+ } catch (error) {
109
+ throw new Error(`Could not reach ${url}: ${error.message}.`);
110
+ }
111
+ if (response.status === 404) throw new Error(`Sign-in isn't available on ${describePlatform(url)}. Use \`hawk-app login\` with a publisher key from Engineering.`);
112
+ if (!response.ok) throw new Error(`The platform answered ${response.status} for the sign-in settings.`);
113
+ const settings = await json(response);
114
+ // The team to work in: its apps are the ones the key can change. Checked before the browser opens.
115
+ const publishers = settings.publishers ?? [];
116
+ if (publisher && publishers.length && !publishers.includes(publisher)) {
117
+ throw new Error(`Sign-in can't issue keys for publisher "${publisher}". Choose one of: ${publishers.join(', ')} (default ${publishers[0]}).`);
118
+ }
119
+
120
+ const verifier = base64url(randomBytes(32));
121
+ const state = base64url(randomBytes(16));
122
+ const server = await callbackServer(settings.redirectPorts, state);
123
+ try {
124
+ const authorize = new URL(settings.authorizeUrl);
125
+ for (const [name, value] of Object.entries({
126
+ response_type: 'code',
127
+ client_id: settings.clientId,
128
+ redirect_uri: server.redirectUri,
129
+ identity_provider: settings.identityProvider,
130
+ scope: settings.scopes,
131
+ state,
132
+ code_challenge: base64url(createHash('sha256').update(verifier).digest()),
133
+ code_challenge_method: 'S256',
134
+ })) {
135
+ authorize.searchParams.set(name, value);
136
+ }
137
+ log('Opening your browser to sign in with your Bridgeline account…');
138
+ log(` If it doesn't open, go to: ${authorize.href}`);
139
+ openUrl(authorize.href);
140
+
141
+ let timer;
142
+ const query = await Promise.race([
143
+ server.result,
144
+ new Promise((_, reject) => {
145
+ timer = setTimeout(() => reject(new Error('No sign-in within 5 minutes. Run `hawk-app sso` again.')), timeoutMs);
146
+ }),
147
+ ]).finally(() => clearTimeout(timer));
148
+ if (query.error) throw new Error(`The sign-in failed: ${query.error_description ?? query.error}. Nothing was saved.`);
149
+ if (!query.code) throw new Error('The sign-in came back without a code. Nothing was saved.');
150
+
151
+ // The code, for the ID token: a public client proves it started the sign-in with the PKCE verifier.
152
+ const token = await fetchImpl(settings.tokenUrl, {
153
+ method: 'POST',
154
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
155
+ body: new URLSearchParams({ grant_type: 'authorization_code', client_id: settings.clientId, code: query.code, redirect_uri: server.redirectUri, code_verifier: verifier }).toString(),
156
+ });
157
+ const tokens = await json(token);
158
+ if (!token.ok || !tokens.id_token) throw new Error(`The sign-in couldn't be completed (${tokens.error_description ?? tokens.error ?? token.status}). Nothing was saved.`);
159
+
160
+ const issued = await fetchImpl(`${url}/publish/sso`, {
161
+ method: 'POST',
162
+ headers: { 'Content-Type': 'application/json' },
163
+ body: JSON.stringify({ idToken: tokens.id_token, machine, ...(publisher ? { publisherId: publisher } : {}) }),
164
+ });
165
+ const key = await json(issued);
166
+ if (!issued.ok || !key.key) throw new Error(`${key.detail ?? key.title ?? `The platform answered ${issued.status}.`} Nothing was saved.`);
167
+
168
+ // One saved key per platform: say so when it was another team's.
169
+ const previous = (await readCredentials(credentialsFile)).platforms[url]?.publisherKey;
170
+ await saveCredential(credentialsFile, url, key.key);
171
+ if (previous && previous.split('.')[0] !== key.publisherId) {
172
+ log(` (This replaces your saved key for publisher "${previous.split('.')[0]}" on this platform. Switch back with \`hawk-app sso --publisher ${previous.split('.')[0]}\` or \`hawk-app login\`.)`);
173
+ }
174
+ log(`✔ Signed in as ${key.label} (${key.email}) on ${describePlatform(url)}, publisher "${key.publisherId}".`);
175
+ log(` Your key ${key.publisherId}/${key.keyId} is saved for every app on this machine and works until ${String(key.expiresAt).slice(0, 10)}; run \`hawk-app sso\` again then.`);
176
+ if (key.developerGroup === 'added') log(' You are in Hawk-App-Developers, so you can submit apps for approval in HawkSearch.');
177
+ if (key.developerGroup === 'failed') log(" ! Couldn't add you to Hawk-App-Developers (needed to submit apps for approval in HawkSearch). Ask Engineering, or run `hawk-app sso` again later.");
178
+ return key;
179
+ } finally {
180
+ server.close();
181
+ }
182
+ }
package/src/telemetry.mjs CHANGED
@@ -1,50 +1,50 @@
1
- // `hawk-app errors` and `hawk-app usage`: what the bridge reports from the app's pages (uncaught errors and rejections,
2
- // and one open per page load), as the platform groups them. Read-only.
3
-
4
- const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
5
-
6
- function parseDays(value, fallback, max) {
7
- if (value === undefined || value === true) return fallback;
8
- const days = Number(value);
9
- if (!Number.isInteger(days) || days < 1 || days > max) throw new Error(`--days must be a whole number from 1 to ${max}.`);
10
- return days;
11
- }
12
-
13
- /** `hawk-app errors [--days 7]`: the app's errors, the most frequent first. */
14
- export async function errorsCommand({ client, manifest, flags = {}, log = console.log }) {
15
- const days = parseDays(flags.days, 7, 30);
16
- const { total, groups } = await client.appErrors(manifest.slug, days);
17
- if (!groups.length) {
18
- log(`No errors reported for ${manifest.slug} in the last ${plural(days, 'day')}.`);
19
- return 0;
20
- }
21
- log(`${plural(total, 'error')} in ${plural(groups.length, 'group')}, the last ${plural(days, 'day')} (${manifest.slug}):`);
22
- for (const group of groups) {
23
- const versions = group.versions.length ? ` · ${group.versions.map((v) => `v${v}`).join(', ')}` : '';
24
- const when = group.firstDay === group.lastDay ? group.lastDay : `${group.firstDay} – ${group.lastDay}`;
25
- log('');
26
- log(`${group.count}× ${group.kind === 'rejection' ? 'unhandled rejection' : 'error'}: ${group.message}`);
27
- log(` ${plural(group.clients, 'client')}${versions} · ${when}${group.source ? ` · ${group.source}` : ''}`);
28
- const frames = (group.stack ?? '').split('\n').map((l) => l.trim()).filter((l) => l.startsWith('at ') || l.includes('@')).slice(0, 3);
29
- for (const frame of frames) log(` ${frame}`);
30
- }
31
- return 0;
32
- }
33
-
34
- /** `hawk-app usage [--days 30]`: opens and users per day and client. */
35
- export async function usageCommand({ client, manifest, flags = {}, log = console.log }) {
36
- const days = parseDays(flags.days, 30, 400);
37
- const usage = await client.appUsage(manifest.slug, days);
38
- log(`${manifest.slug}, the last ${plural(days, 'day')}:`);
39
- log(` ${plural(usage.opens, 'open')} of the app and ${plural(usage.viewOpens, 'view open')} (previews, slots) by ${plural(usage.users, 'user')} at ${plural(usage.clients, 'client')}`);
40
- log(` ${plural(usage.staffOpens, 'open')} by Bridgeline staff (not counted above)`);
41
- if (usage.perClient.length) {
42
- log('');
43
- log(' Client Opens Views Users Last day');
44
- for (const c of usage.perClient.slice(0, 20)) {
45
- log(` ${c.clientId.padEnd(18)} ${String(c.opens).padStart(5)} ${String(c.viewOpens).padStart(5)} ${String(c.users).padStart(5)} ${c.lastDay}`);
46
- }
47
- if (usage.perClient.length > 20) log(` … and ${usage.perClient.length - 20} more`);
48
- }
49
- return 0;
50
- }
1
+ // `hawk-app errors` and `hawk-app usage`: what the bridge reports from the app's pages (uncaught errors and rejections,
2
+ // and one open per page load), as the platform groups them. Read-only.
3
+
4
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
5
+
6
+ function parseDays(value, fallback, max) {
7
+ if (value === undefined || value === true) return fallback;
8
+ const days = Number(value);
9
+ if (!Number.isInteger(days) || days < 1 || days > max) throw new Error(`--days must be a whole number from 1 to ${max}.`);
10
+ return days;
11
+ }
12
+
13
+ /** `hawk-app errors [--days 7]`: the app's errors, the most frequent first. */
14
+ export async function errorsCommand({ client, manifest, flags = {}, log = console.log }) {
15
+ const days = parseDays(flags.days, 7, 30);
16
+ const { total, groups } = await client.appErrors(manifest.slug, days);
17
+ if (!groups.length) {
18
+ log(`No errors reported for ${manifest.slug} in the last ${plural(days, 'day')}.`);
19
+ return 0;
20
+ }
21
+ log(`${plural(total, 'error')} in ${plural(groups.length, 'group')}, the last ${plural(days, 'day')} (${manifest.slug}):`);
22
+ for (const group of groups) {
23
+ const versions = group.versions.length ? ` · ${group.versions.map((v) => `v${v}`).join(', ')}` : '';
24
+ const when = group.firstDay === group.lastDay ? group.lastDay : `${group.firstDay} – ${group.lastDay}`;
25
+ log('');
26
+ log(`${group.count}× ${group.kind === 'rejection' ? 'unhandled rejection' : 'error'}: ${group.message}`);
27
+ log(` ${plural(group.clients, 'client')}${versions} · ${when}${group.source ? ` · ${group.source}` : ''}`);
28
+ const frames = (group.stack ?? '').split('\n').map((l) => l.trim()).filter((l) => l.startsWith('at ') || l.includes('@')).slice(0, 3);
29
+ for (const frame of frames) log(` ${frame}`);
30
+ }
31
+ return 0;
32
+ }
33
+
34
+ /** `hawk-app usage [--days 30]`: opens and users per day and client. */
35
+ export async function usageCommand({ client, manifest, flags = {}, log = console.log }) {
36
+ const days = parseDays(flags.days, 30, 400);
37
+ const usage = await client.appUsage(manifest.slug, days);
38
+ log(`${manifest.slug}, the last ${plural(days, 'day')}:`);
39
+ log(` ${plural(usage.opens, 'open')} of the app and ${plural(usage.viewOpens, 'view open')} (previews, slots) by ${plural(usage.users, 'user')} at ${plural(usage.clients, 'client')}`);
40
+ log(` ${plural(usage.staffOpens, 'open')} by Bridgeline staff (not counted above)`);
41
+ if (usage.perClient.length) {
42
+ log('');
43
+ log(' Client Opens Views Users Last day');
44
+ for (const c of usage.perClient.slice(0, 20)) {
45
+ log(` ${c.clientId.padEnd(18)} ${String(c.opens).padStart(5)} ${String(c.viewOpens).padStart(5)} ${String(c.users).padStart(5)} ${c.lastDay}`);
46
+ }
47
+ if (usage.perClient.length > 20) log(` … and ${usage.perClient.length - 20} more`);
48
+ }
49
+ return 0;
50
+ }