@bridgeline-digital/hawk-app-cli 0.0.0-stage → 0.1.0-dev.20261020202308

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +770 -3
  3. package/agents/AGENTS.md +192 -0
  4. package/agents/project/custom.md +21 -0
  5. package/agents/project/next-mui.md +21 -0
  6. package/agents/project/next.md +24 -0
  7. package/agents/project/vanilla.md +22 -0
  8. package/agents/project/vite.md +19 -0
  9. package/bin/hawk-app.mjs +10 -0
  10. package/docs/bridge/README.md +1315 -0
  11. package/docs/showcase/README.md +47 -0
  12. package/docs/showcase/functions/echo.ts +12 -0
  13. package/docs/showcase/functions/long-task.ts +31 -0
  14. package/docs/showcase/functions/showcase.ts +186 -0
  15. package/docs/showcase/hawk-app.json +73 -0
  16. package/docs/showcase/queries/assistant-prompts-by-day.json +6 -0
  17. package/docs/showcase/queries/assistant-prompts-by-day.sql +8 -0
  18. package/docs/showcase/queries/searches-by-day.json +6 -0
  19. package/docs/showcase/queries/searches-by-day.sql +7 -0
  20. package/docs/showcase/src/App.tsx +1436 -0
  21. package/docs/showcase/src/main.tsx +10 -0
  22. package/docs/showcase/src/styles.css +71 -0
  23. package/docs/showcase/src/useHawkApp.ts +44 -0
  24. package/docs/showcase/src/vite-env.d.ts +1 -0
  25. package/docs/ui/README.md +166 -0
  26. package/package.json +50 -4
  27. package/schema/hawk-app.schema.json +611 -0
  28. package/src/accessibility.mjs +105 -0
  29. package/src/agents.mjs +145 -0
  30. package/src/aliases.mjs +30 -0
  31. package/src/app-icon.mjs +82 -0
  32. package/src/channel.mjs +116 -0
  33. package/src/cli.mjs +825 -0
  34. package/src/clients.mjs +221 -0
  35. package/src/config.mjs +148 -0
  36. package/src/create.mjs +186 -0
  37. package/src/credentials.mjs +56 -0
  38. package/src/data.mjs +169 -0
  39. package/src/deploy.mjs +349 -0
  40. package/src/dev.mjs +459 -0
  41. package/src/doctor.mjs +176 -0
  42. package/src/env.mjs +140 -0
  43. package/src/functions.mjs +313 -0
  44. package/src/login.mjs +166 -0
  45. package/src/logs.mjs +64 -0
  46. package/src/manifest.mjs +425 -0
  47. package/src/mcp-docs.mjs +168 -0
  48. package/src/mcp-hawksearch.mjs +342 -0
  49. package/src/mcp-reports.mjs +130 -0
  50. package/src/mcp-rules.mjs +109 -0
  51. package/src/mcp-ui.mjs +73 -0
  52. package/src/mcp.mjs +539 -0
  53. package/src/platform.mjs +116 -0
  54. package/src/project-checks.mjs +153 -0
  55. package/src/queries.mjs +289 -0
  56. package/src/scope-check.mjs +91 -0
  57. package/src/secrets.mjs +86 -0
  58. package/src/slug-guard.mjs +80 -0
  59. package/src/sso.mjs +182 -0
  60. package/src/store-listing.mjs +106 -0
  61. package/src/telemetry.mjs +50 -0
  62. package/src/template-history.json +34 -0
  63. package/src/upgrade.mjs +406 -0
  64. package/src/versions.mjs +102 -0
  65. package/templates/next/next.config.ts +13 -0
  66. package/templates/next/package.json +26 -0
  67. package/templates/next/src/app/globals.css +5 -0
  68. package/templates/next/src/app/layout.tsx +14 -0
  69. package/templates/next/src/app/page.tsx +86 -0
  70. package/templates/next/src/hawk-elements.d.ts +10 -0
  71. package/templates/next/src/lib/useHawkApp.ts +46 -0
  72. package/templates/next/tsconfig.json +21 -0
  73. package/templates/next-mui/next.config.ts +13 -0
  74. package/templates/next-mui/package.json +31 -0
  75. package/templates/next-mui/src/app/layout.tsx +20 -0
  76. package/templates/next-mui/src/app/page.tsx +79 -0
  77. package/templates/next-mui/src/app/providers.tsx +11 -0
  78. package/templates/next-mui/tsconfig.json +21 -0
  79. package/templates/preact/index.html +12 -0
  80. package/templates/preact/package.json +26 -0
  81. package/templates/preact/src/App.tsx +83 -0
  82. package/templates/preact/src/hawk-elements.d.ts +10 -0
  83. package/templates/preact/src/main.tsx +8 -0
  84. package/templates/preact/src/styles.css +5 -0
  85. package/templates/preact/src/useHawkApp.ts +44 -0
  86. package/templates/preact/src/vite-env.d.ts +1 -0
  87. package/templates/preact/tsconfig.json +17 -0
  88. package/templates/preact/vite.config.ts +11 -0
  89. package/templates/react/index.html +12 -0
  90. package/templates/react/package.json +29 -0
  91. package/templates/react/src/App.tsx +83 -0
  92. package/templates/react/src/hawk-elements.d.ts +10 -0
  93. package/templates/react/src/main.tsx +13 -0
  94. package/templates/react/src/styles.css +5 -0
  95. package/templates/react/src/useHawkApp.ts +44 -0
  96. package/templates/react/src/vite-env.d.ts +1 -0
  97. package/templates/react/tsconfig.json +16 -0
  98. package/templates/react/vite.config.ts +11 -0
  99. package/templates/vanilla/package.json +15 -0
  100. package/templates/vanilla/public/app.js +59 -0
  101. package/templates/vanilla/public/index.html +37 -0
  102. package/templates/vanilla/public/styles.css +5 -0
@@ -0,0 +1,86 @@
1
+ import { SECRET_NAME } from './functions.mjs';
2
+ import { promptHidden } from './login.mjs';
3
+
4
+ /** The platform keeps each secret in a Standard-tier Parameter Store SecureString: at most 4 KB (UTF-8). */
5
+ export const MAX_SECRET_BYTES = 4 * 1024;
6
+
7
+ const USAGE = `Usage:
8
+ hawk-app secrets list
9
+ hawk-app secrets set <NAME> [--value-from-env <VAR>]
10
+ hawk-app secrets delete <NAME>`;
11
+
12
+ /**
13
+ * `hawk-app secrets …`: the app's function secrets, stored by the platform (never in the app or its versions).
14
+ * Values are typed with hidden input (or read from an environment variable) and never printed. `client` is a
15
+ * factory, so a usage mistake shows before any login check.
16
+ */
17
+ export async function secretsCommand({ client, manifest, sub, args = [], flags = {}, prompt = promptHidden, env = process.env, log = console.log }) {
18
+ const slug = manifest.slug;
19
+ switch (sub) {
20
+ case 'list': {
21
+ const { secrets = [] } = await client().listSecrets(slug);
22
+ const declared = declaredSecrets(manifest);
23
+ if (!secrets.length) log(`${slug} has no secrets yet. Set one with \`hawk-app secrets set <NAME>\`.`);
24
+ else {
25
+ log(`${secrets.length} secret${secrets.length === 1 ? '' : 's'} for ${slug} (values are never shown):`);
26
+ const width = Math.max(...secrets.map((s) => s.name.length));
27
+ for (const secret of secrets) {
28
+ const users = declared.get(secret.name);
29
+ log(` ${secret.name.padEnd(width)} ${secret.updatedAt ? `updated ${secret.updatedAt.slice(0, 16).replace('T', ' ')}` : ''}${users ? ` used by ${users.join(', ')}` : ' (not used by any function)'}`);
30
+ }
31
+ }
32
+ const missing = [...declared.keys()].filter((name) => !secrets.some((s) => s.name === name));
33
+ for (const name of missing) log(`! ${name} is declared by ${declared.get(name).join(', ')} but not set: calls fail with secret_missing until you set it.`);
34
+ return 0;
35
+ }
36
+
37
+ case 'set': {
38
+ const name = checkName(args[0]);
39
+ let value;
40
+ const from = flags['value-from-env'];
41
+ if (from !== undefined) {
42
+ if (typeof from !== 'string' || !from) throw new Error('--value-from-env needs the name of an environment variable.');
43
+ value = env[from];
44
+ if (value === undefined || value === '') throw new Error(`Environment variable ${from} is not set (or empty).`);
45
+ } else {
46
+ value = await prompt(`Value for ${name} (input hidden): `, {
47
+ notInteractive: `\`hawk-app secrets set\` needs an interactive terminal to type the value. In CI, pass --value-from-env <VAR>.`,
48
+ });
49
+ if (!value) throw new Error('No value entered; nothing was changed.');
50
+ }
51
+ if (Buffer.byteLength(value, 'utf8') > MAX_SECRET_BYTES) throw new Error(`The value is larger than ${MAX_SECRET_BYTES / 1024} KB.`);
52
+ await client().setSecret(slug, name, value);
53
+ log(`✔ Set ${name} for ${slug}. It applies to every version at once.`);
54
+ if (!declaredSecrets(manifest).has(name)) log(`! No function in hawk-app.json declares ${name}: add it to a function's "secrets" to receive it as ctx.secrets.${name}.`);
55
+ return 0;
56
+ }
57
+
58
+ case 'delete': {
59
+ const name = checkName(args[0]);
60
+ await client().deleteSecret(slug, name);
61
+ log(`✔ Deleted ${name} for ${slug}.`);
62
+ const users = declaredSecrets(manifest).get(name);
63
+ if (users) log(`! ${users.join(', ')} still declare${users.length === 1 ? 's' : ''} ${name}: calls fail with secret_missing until it's set again.`);
64
+ return 0;
65
+ }
66
+
67
+ default:
68
+ throw new Error(sub ? `Unknown secrets command "${sub}".\n${USAGE}` : USAGE);
69
+ }
70
+ }
71
+
72
+ function checkName(name) {
73
+ if (!name) throw new Error(USAGE);
74
+ if (!SECRET_NAME.test(name)) throw new Error(`"${name}" is not a valid secret name: UPPER_SNAKE_CASE, a letter first, up to 64 characters (e.g. FEED_API_KEY).`);
75
+ return name;
76
+ }
77
+
78
+ /** Secret name → the functions in the manifest that declare it. */
79
+ function declaredSecrets(manifest) {
80
+ const map = new Map();
81
+ const functions = manifest.functions && typeof manifest.functions === 'object' ? manifest.functions : {};
82
+ for (const [fn, config] of Object.entries(functions)) {
83
+ for (const secret of Array.isArray(config?.secrets) ? config.secrets : []) map.set(secret, [...(map.get(secret) ?? []), fn]);
84
+ }
85
+ return map;
86
+ }
@@ -0,0 +1,80 @@
1
+ // Keeps two developers from working on one app by accident. Apps belong to a publisher (a team), and every person on
2
+ // the team can register and deploy any of its apps, so a reused slug would silently mix two codebases into one app.
3
+ // - create / init: refuse a slug that's already taken (unless --yes).
4
+ // - register / deploy: when someone else's key made the app's last change, ask before going on (--yes skips it; a
5
+ // pipeline, with no terminal to ask in, gets a warning and goes on).
6
+ import { createInterface } from 'node:readline';
7
+
8
+ /**
9
+ * What the platform says about a slug for the caller's key: free (404), another publisher's (409 slug_owned), or this
10
+ * publisher's app with its last change. Null when it can't tell (not logged in, platform unreachable, other errors).
11
+ */
12
+ export async function slugStatus(client, slug) {
13
+ try {
14
+ const app = await client.status(slug);
15
+ return { state: 'taken', app };
16
+ } catch (error) {
17
+ if (error.status === 404) return { state: 'free' };
18
+ if (error.status === 409) return { state: 'other' };
19
+ return null;
20
+ }
21
+ }
22
+
23
+ const describeChange = (last) => {
24
+ const who = last.name || last.keyId || 'a shared key';
25
+ const when = last.at ? new Date(last.at).toLocaleString() : 'earlier';
26
+ return `${last.kind === 'deploy' ? 'deployed' : 'registered'} by ${who} on ${when}`;
27
+ };
28
+
29
+ /** create / init: throws when the slug is taken; logs a note when it couldn't check. */
30
+ export async function checkNewSlug({ client, slug, yes = false, log = console.log }) {
31
+ if (!client) {
32
+ log(` (Not logged in, so "${slug}" wasn't checked. \`hawk-app register\` will tell you if it's taken.)`);
33
+ return;
34
+ }
35
+ const status = await slugStatus(client, slug);
36
+ if (!status) {
37
+ log(` (Couldn't check whether "${slug}" is free; \`hawk-app register\` will tell you if it's taken.)`);
38
+ return;
39
+ }
40
+ if (status.state === 'other') {
41
+ throw new Error(`"${slug}" belongs to another publisher. Pick another name (the folder name, or --slug).`);
42
+ }
43
+ if (status.state === 'taken' && !yes) {
44
+ const last = status.app.lastChange;
45
+ throw new Error(
46
+ `"${slug}" is already an app${last ? `, last ${describeChange(last)}` : ''}. Pick another name, or work in that app's own repository.\n` +
47
+ ' To set up this folder for that existing app anyway, add --yes.',
48
+ );
49
+ }
50
+ }
51
+
52
+ /** Asks a yes/no question on the terminal; null when there is no terminal to ask in. */
53
+ export function askYesNo(question, { input = process.stdin, output = process.stdout } = {}) {
54
+ if (!input.isTTY) return Promise.resolve(null);
55
+ return new Promise((resolve) => {
56
+ const rl = createInterface({ input, output });
57
+ rl.question(`${question} (y/N) `, (answer) => {
58
+ rl.close();
59
+ resolve(/^y(es)?$/i.test(answer.trim()));
60
+ });
61
+ });
62
+ }
63
+
64
+ /**
65
+ * register / deploy: when the app's last change was made with someone else's key, asks before going on. Throws
66
+ * "Cancelled." on no; with no terminal (CI) it warns and goes on; --yes goes on quietly.
67
+ */
68
+ export async function confirmTakeover({ client, slug, action, yes = false, confirm = askYesNo, log = console.log }) {
69
+ const status = await slugStatus(client, slug);
70
+ const last = status?.state === 'taken' ? status.app.lastChange : null;
71
+ if (!last || last.yours || yes) return;
72
+
73
+ const message = `"${slug}" was last ${describeChange(last)}.`;
74
+ const answer = await confirm(`${message} ${action === 'deploy' ? 'Deploy a new version of it' : 'Register your hawk-app.json over it'}?`);
75
+ if (answer === null) {
76
+ log(`! ${message} Going on (no terminal to ask; add --yes to silence this).`);
77
+ return;
78
+ }
79
+ if (!answer) throw new Error('Cancelled. Nothing was changed.');
80
+ }
package/src/sso.mjs ADDED
@@ -0,0 +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
+ }
@@ -0,0 +1,106 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { extname, resolve } from 'node:path';
4
+
5
+ // The store listing ("store" in hawk-app.json, for "visibility": "store"): what clients that don't have the app see on
6
+ // the Store tab of the Apps page in HawkSearch. Deploy uploads its screenshots into every version as _hawk/store/1.png,
7
+ // 2.jpg…, so they're reviewed and released with the version. The price label isn't here: Bridgeline sets it.
8
+
9
+ // Same rules and messages as the platform (AppStoreRules).
10
+ export const MAX_TAGLINE = 120;
11
+ export const MAX_HIGHLIGHTS = 5;
12
+ export const MAX_HIGHLIGHT = 120;
13
+ export const MAX_SCREENSHOTS = 5;
14
+ export const MAX_SCREENSHOT_BYTES = 1024 * 1024;
15
+ const STORE_FIELDS = ['tagline', 'highlights', 'screenshots'];
16
+ export const SCREENSHOT_PATH = /^(?!.*\.\.)[A-Za-z0-9_-][A-Za-z0-9_.-]*(\/[A-Za-z0-9_-][A-Za-z0-9_.-]*)*\.(png|jpg|jpeg|webp)$/i;
17
+
18
+ /** Where deploy puts screenshot `index` (0-based) in a version, e.g. _hawk/store/1.png. */
19
+ export function screenshotUploadPath(index, screenshot) {
20
+ return `_hawk/store/${index + 1}${extname(screenshot).toLowerCase()}`;
21
+ }
22
+
23
+ /** The "store" section's errors and warnings (the files themselves: checkScreenshots). */
24
+ export function validateStore(m) {
25
+ const errors = [];
26
+ const warnings = [];
27
+ const store = m.store;
28
+ if (store === undefined || store === null) {
29
+ if (m.visibility === 'store') errors.push('visibility "store" needs a store section: { "tagline": "…", "screenshots": ["store/overview.png"] }.');
30
+ return { errors, warnings };
31
+ }
32
+ if (typeof store !== 'object' || Array.isArray(store)) {
33
+ return { errors: ['store must be an object: { "tagline": "…", "highlights": ["…"], "screenshots": ["store/overview.png"] }.'], warnings };
34
+ }
35
+ const unknown = Object.keys(store).filter((k) => !STORE_FIELDS.includes(k));
36
+ if (unknown.length) errors.push(`store: unknown setting${unknown.length === 1 ? '' : 's'} ${unknown.map((k) => `"${k}"`).join(', ')} (allowed: ${STORE_FIELDS.join(', ')}).`);
37
+ if (typeof store.tagline !== 'string' || !store.tagline.trim() || store.tagline.trim().length > MAX_TAGLINE) {
38
+ errors.push(`store.tagline is required: one line of at most ${MAX_TAGLINE} characters.`);
39
+ }
40
+ const strings = (name) => {
41
+ const value = store[name];
42
+ if (value === undefined || value === null) return [];
43
+ if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) {
44
+ errors.push(`store.${name} must be an array of strings.`);
45
+ return [];
46
+ }
47
+ return value.map((v) => v.trim());
48
+ };
49
+ const highlights = strings('highlights');
50
+ if (highlights.length > MAX_HIGHLIGHTS) errors.push(`store.highlights has at most ${MAX_HIGHLIGHTS} items.`);
51
+ if (highlights.some((h) => !h.length || h.length > MAX_HIGHLIGHT)) errors.push(`store.highlights: each is 1-${MAX_HIGHLIGHT} characters.`);
52
+ const screenshots = strings('screenshots');
53
+ if (!screenshots.length || screenshots.length > MAX_SCREENSHOTS) {
54
+ errors.push(`store.screenshots needs 1-${MAX_SCREENSHOTS} images (the card shows them when there's no trial).`);
55
+ }
56
+ for (const bad of screenshots.filter((s) => !SCREENSHOT_PATH.test(s) || s.length > 200)) {
57
+ errors.push(`store.screenshots: "${bad}" must be a .png, .jpg or .webp file next to hawk-app.json, e.g. "store/overview.png".`);
58
+ }
59
+ if (m.visibility !== 'store') warnings.push('store is only shown for visibility "store": clients won\'t see this listing.');
60
+ return { errors, warnings };
61
+ }
62
+
63
+ /**
64
+ * Checks the screenshot files the store section names (when it names valid ones): there, an image of its type, at most
65
+ * 1 MB. Returns the files to upload ({ path: _hawk/store/<n>.<ext>, size, data }), with errors.
66
+ */
67
+ export async function checkScreenshots(projectDir, store) {
68
+ const errors = [];
69
+ const files = [];
70
+ const screenshots = Array.isArray(store?.screenshots) ? store.screenshots : [];
71
+ if (screenshots.some((s) => typeof s !== 'string' || !SCREENSHOT_PATH.test(s.trim()))) return { errors, files };
72
+ for (const [index, raw] of screenshots.entries()) {
73
+ const screenshot = raw.trim();
74
+ const full = resolve(projectDir, screenshot);
75
+ if (!existsSync(full)) {
76
+ errors.push(`store.screenshots: ${screenshot} doesn't exist next to hawk-app.json.`);
77
+ continue;
78
+ }
79
+ const data = await readFile(full);
80
+ if (data.length > MAX_SCREENSHOT_BYTES) {
81
+ errors.push(`store.screenshots: ${screenshot} is ${Math.ceil(data.length / 1024)} KB; a screenshot can be at most ${MAX_SCREENSHOT_BYTES / 1024 / 1024} MB.`);
82
+ continue;
83
+ }
84
+ if (!isImageOfType(data, extname(screenshot).toLowerCase())) {
85
+ errors.push(`store.screenshots: ${screenshot} isn't a ${extname(screenshot).slice(1).toUpperCase()} image.`);
86
+ continue;
87
+ }
88
+ files.push({ path: screenshotUploadPath(index, screenshot), size: data.length, data });
89
+ }
90
+ return { errors, files: errors.length ? [] : files };
91
+ }
92
+
93
+ /** True when `data` starts like an image of the extension's type. */
94
+ function isImageOfType(data, extension) {
95
+ switch (extension) {
96
+ case '.png':
97
+ return data.length > 8 && data.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
98
+ case '.jpg':
99
+ case '.jpeg':
100
+ return data.length > 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff;
101
+ case '.webp':
102
+ return data.length > 12 && data.toString('ascii', 0, 4) === 'RIFF' && data.toString('ascii', 8, 12) === 'WEBP';
103
+ default:
104
+ return false;
105
+ }
106
+ }
@@ -0,0 +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
+ }
@@ -0,0 +1,34 @@
1
+ {
2
+ "next/next.config.ts": [
3
+ "import type { NextConfig } from 'next';\n\n// Hawk Apps are static files on the apps CDN under /<slug>/: static export, no server features\n// (no SSR, API routes or middleware). All data comes through the bridge.\nconst nextConfig: NextConfig = {\n output: 'export',\n basePath: '/__SLUG__',\n trailingSlash: true,\n images: { unoptimized: true },\n};\n\nexport default nextConfig;\n",
4
+ "import type { NextConfig } from 'next';\n\n// Hawk Apps are static files on the apps CDN, one folder per deploy (/<slug>/v<n>/): static export, no server\n// features (no SSR, API routes or middleware). All data comes through the bridge.\n// `hawk-app deploy` builds with HAWK_APP_BASE_PATH=/<slug>/v<n>; local dev uses /<slug>.\nconst nextConfig: NextConfig = {\n output: 'export',\n basePath: process.env.HAWK_APP_BASE_PATH ?? '/__SLUG__',\n trailingSlash: true,\n images: { unoptimized: true },\n};\n\nexport default nextConfig;\n"
5
+ ],
6
+ "next/tsconfig.json": [
7
+ "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n \"allowJs\": false,\n \"skipLibCheck\": true,\n \"strict\": true,\n \"noEmit\": true,\n \"esModuleInterop\": true,\n \"module\": \"esnext\",\n \"moduleResolution\": \"bundler\",\n \"resolveJsonModule\": true,\n \"isolatedModules\": true,\n \"jsx\": \"preserve\",\n \"incremental\": true,\n \"plugins\": [{ \"name\": \"next\" }],\n \"paths\": { \"@/*\": [\"./src/*\"] }\n },\n \"include\": [\"next-env.d.ts\", \"**/*.ts\", \"**/*.tsx\", \".next/types/**/*.ts\"],\n \"exclude\": [\"node_modules\"]\n}\n"
8
+ ],
9
+ "next-mui/next.config.ts": [
10
+ "import type { NextConfig } from 'next';\n\n// Hawk Apps are static files on the apps CDN under /<slug>/: static export, no server features\n// (no SSR, API routes or middleware). All data comes through the bridge.\nconst nextConfig: NextConfig = {\n output: 'export',\n basePath: '/__SLUG__',\n trailingSlash: true,\n images: { unoptimized: true },\n};\n\nexport default nextConfig;\n",
11
+ "import type { NextConfig } from 'next';\n\n// Hawk Apps are static files on the apps CDN, one folder per deploy (/<slug>/v<n>/): static export, no server\n// features (no SSR, API routes or middleware). All data comes through the bridge.\n// `hawk-app deploy` builds with HAWK_APP_BASE_PATH=/<slug>/v<n>; local dev uses /<slug>.\nconst nextConfig: NextConfig = {\n output: 'export',\n basePath: process.env.HAWK_APP_BASE_PATH ?? '/__SLUG__',\n trailingSlash: true,\n images: { unoptimized: true },\n};\n\nexport default nextConfig;\n"
12
+ ],
13
+ "next-mui/tsconfig.json": [
14
+ "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n \"allowJs\": false,\n \"skipLibCheck\": true,\n \"strict\": true,\n \"noEmit\": true,\n \"esModuleInterop\": true,\n \"module\": \"esnext\",\n \"moduleResolution\": \"bundler\",\n \"resolveJsonModule\": true,\n \"isolatedModules\": true,\n \"jsx\": \"preserve\",\n \"incremental\": true,\n \"plugins\": [{ \"name\": \"next\" }],\n \"paths\": { \"@/*\": [\"./src/*\"] }\n },\n \"include\": [\"next-env.d.ts\", \"**/*.ts\", \"**/*.tsx\", \".next/types/**/*.ts\"],\n \"exclude\": [\"node_modules\"]\n}\n"
15
+ ],
16
+ "preact/vite.config.ts": [
17
+ "import preact from '@preact/preset-vite';\nimport { defineConfig } from 'vite';\n\n// Hawk Apps are served from the apps CDN at /<slug>/, so every asset URL needs that prefix.\nexport default defineConfig({\n base: '/__SLUG__/',\n plugins: [preact()],\n // Port 3100: the dev platform accepts data calls from localhost:3100-3103 (E360 dev mode, ?devUrl=).\n server: { port: 3100, strictPort: true },\n});\n",
18
+ "import preact from '@preact/preset-vite';\nimport { defineConfig } from 'vite';\n\n// Each deploy is served from the apps CDN at /<slug>/v<n>/, so every asset URL needs that prefix.\n// `hawk-app deploy` builds with HAWK_APP_BASE_PATH=/<slug>/v<n>; local dev uses /<slug>/.\nexport default defineConfig({\n base: `${process.env.HAWK_APP_BASE_PATH ?? '/__SLUG__'}/`,\n plugins: [preact()],\n // Port 3100: the dev platform accepts data calls from localhost:3100-3103 (E360 dev mode, ?devUrl=).\n server: { port: 3100, strictPort: true },\n});\n",
19
+ "import preact from '@preact/preset-vite';\nimport { defineConfig } from 'vite';\n\n// Each deploy is served from the apps CDN at /<slug>/v<n>/, so every asset URL needs that prefix.\n// `hawk-app deploy` builds with HAWK_APP_BASE_PATH=/<slug>/v<n>; local dev uses /<slug>/.\nexport default defineConfig({\n base: `${process.env.HAWK_APP_BASE_PATH ?? '/__SLUG__'}/`,\n plugins: [preact()],\n // Port 3100: the dev platform accepts data calls from localhost:3100-3103 (HawkSearch dev mode, ?devUrl=).\n server: { port: 3100, strictPort: true },\n});\n"
20
+ ],
21
+ "preact/tsconfig.json": [
22
+ "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"lib\": [\"ES2022\", \"DOM\", \"DOM.Iterable\"],\n \"module\": \"ESNext\",\n \"moduleResolution\": \"Bundler\",\n \"jsx\": \"react-jsx\",\n \"jsxImportSource\": \"preact\",\n \"strict\": true,\n \"noUnusedLocals\": true,\n \"skipLibCheck\": true,\n \"isolatedModules\": true,\n \"noEmit\": true,\n \"types\": [\"vite/client\"]\n },\n \"include\": [\"src\", \"vite.config.ts\"]\n}\n",
23
+ "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"lib\": [\"ES2022\", \"DOM\", \"DOM.Iterable\"],\n \"module\": \"ESNext\",\n \"moduleResolution\": \"Bundler\",\n \"jsx\": \"react-jsx\",\n \"jsxImportSource\": \"preact\",\n \"strict\": true,\n \"noUnusedLocals\": true,\n \"skipLibCheck\": true,\n \"isolatedModules\": true,\n \"noEmit\": true,\n \"types\": [\"vite/client\", \"node\"]\n },\n \"include\": [\"src\", \"vite.config.ts\"]\n}\n"
24
+ ],
25
+ "react/vite.config.ts": [
26
+ "import react from '@vitejs/plugin-react';\nimport { defineConfig } from 'vite';\n\n// Hawk Apps are served from the apps CDN at /<slug>/, so every asset URL needs that prefix.\nexport default defineConfig({\n base: '/__SLUG__/',\n plugins: [react()],\n // Port 3100: the dev platform accepts data calls from localhost:3100-3103 (E360 dev mode, ?devUrl=).\n server: { port: 3100, strictPort: true },\n});\n",
27
+ "import react from '@vitejs/plugin-react';\nimport { defineConfig } from 'vite';\n\n// Each deploy is served from the apps CDN at /<slug>/v<n>/, so every asset URL needs that prefix.\n// `hawk-app deploy` builds with HAWK_APP_BASE_PATH=/<slug>/v<n>; local dev uses /<slug>/.\nexport default defineConfig({\n base: `${process.env.HAWK_APP_BASE_PATH ?? '/__SLUG__'}/`,\n plugins: [react()],\n // Port 3100: the dev platform accepts data calls from localhost:3100-3103 (E360 dev mode, ?devUrl=).\n server: { port: 3100, strictPort: true },\n});\n",
28
+ "import react from '@vitejs/plugin-react';\nimport { defineConfig } from 'vite';\n\n// Each deploy is served from the apps CDN at /<slug>/v<n>/, so every asset URL needs that prefix.\n// `hawk-app deploy` builds with HAWK_APP_BASE_PATH=/<slug>/v<n>; local dev uses /<slug>/.\nexport default defineConfig({\n base: `${process.env.HAWK_APP_BASE_PATH ?? '/__SLUG__'}/`,\n plugins: [react()],\n // Port 3100: the dev platform accepts data calls from localhost:3100-3103 (HawkSearch dev mode, ?devUrl=).\n server: { port: 3100, strictPort: true },\n});\n"
29
+ ],
30
+ "react/tsconfig.json": [
31
+ "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"lib\": [\"ES2022\", \"DOM\", \"DOM.Iterable\"],\n \"module\": \"ESNext\",\n \"moduleResolution\": \"Bundler\",\n \"jsx\": \"react-jsx\",\n \"strict\": true,\n \"noUnusedLocals\": true,\n \"skipLibCheck\": true,\n \"isolatedModules\": true,\n \"noEmit\": true,\n \"types\": [\"vite/client\"]\n },\n \"include\": [\"src\", \"vite.config.ts\"]\n}\n",
32
+ "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"lib\": [\"ES2022\", \"DOM\", \"DOM.Iterable\"],\n \"module\": \"ESNext\",\n \"moduleResolution\": \"Bundler\",\n \"jsx\": \"react-jsx\",\n \"strict\": true,\n \"noUnusedLocals\": true,\n \"skipLibCheck\": true,\n \"isolatedModules\": true,\n \"noEmit\": true,\n \"types\": [\"vite/client\", \"node\"]\n },\n \"include\": [\"src\", \"vite.config.ts\"]\n}\n"
33
+ ]
34
+ }