@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/create.mjs CHANGED
@@ -1,186 +1,186 @@
1
- import { spawnSync } from 'node:child_process';
2
- import { existsSync, readFileSync } from 'node:fs';
3
- import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
4
- import { basename, join, relative, resolve } from 'node:path';
5
- import { fileURLToPath } from 'node:url';
6
- import { planAgentFiles, writeAgentFiles } from './agents.mjs';
7
- import { credentialsPath } from './credentials.mjs';
8
- import { ENV_EXAMPLE, resolveSettings } from './env.mjs';
9
- import { newManifest } from './manifest.mjs';
10
- import { DEFAULT_ICON_SVG } from './app-icon.mjs';
11
- import { applyChannel, CHANNELS, cliChannel, DEV_CHANNEL_NOTE, planChannel, writeCdnFiles } from './channel.mjs';
12
-
13
- const TEMPLATES_DIR = fileURLToPath(new URL('../templates', import.meta.url));
14
-
15
- export const TEMPLATES = {
16
- react: { description: 'React + Vite + TypeScript + the UI kit (Spark\'s stylesheet and elements)', build: 'dist', dev: 'npm run dev', devUrl: (slug) => `http://localhost:3100/${slug}/` },
17
- preact: { description: 'Preact + Vite + TypeScript (small bundle) + the UI kit (stylesheet and elements)', build: 'dist', dev: 'npm run dev', devUrl: (slug) => `http://localhost:3100/${slug}/` },
18
- next: { description: 'Next.js (static export) + TypeScript + the UI kit (stylesheet and elements)', build: 'out', dev: 'npm run dev', devUrl: (slug) => `http://localhost:3100/${slug}/` },
19
- 'next-mui': {
20
- description: 'Next.js (static export) + Material UI 9 + the UI kit (Spark\'s theme and components)',
21
- build: 'out',
22
- dev: 'npm run dev',
23
- devUrl: (slug) => `http://localhost:3100/${slug}/`,
24
- },
25
- vanilla: { description: 'Plain HTML + JavaScript, no build step (the bridge and the UI kit from the CDN)', build: 'public', dev: 'npm run dev', devUrl: () => 'http://localhost:3100/' },
26
- };
27
-
28
- export function toSlug(value) {
29
- return (
30
- String(value)
31
- .toLowerCase()
32
- .replace(/[^a-z0-9]+/g, '-')
33
- .replace(/^-+|-+$/g, '')
34
- .slice(0, 64) || 'my-app'
35
- );
36
- }
37
-
38
- export function titleCase(slug) {
39
- return slug
40
- .split('-')
41
- .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
42
- .join(' ');
43
- }
44
-
45
- /** Scaffolds a new Hawk App project from one of the built-in templates. */
46
- /** @param checkSlug Refuses a taken slug before anything is written (create's own check: see slug-guard.mjs). */
47
- /**
48
- * @param channel `latest` or `dev`: which builds of the bridge and CLI the app follows. Default: this CLI's own channel,
49
- * so `npx …-cli@dev create` makes a dev app (see channel.mjs).
50
- */
51
- export async function createProject({ target, template = 'react', name, install = false, checkSlug, channel = cliChannel(), log = console.log, credentialsFile = credentialsPath() }) {
52
- if (!CHANNELS.includes(channel)) throw new Error(`--channel must be latest or dev, not "${channel}".`);
53
- if (!target) throw new Error(`Usage: hawk-app create <folder> [--template ${Object.keys(TEMPLATES).join('|')}] [--name "Display Name"] [--install]`);
54
- const spec = TEMPLATES[template];
55
- if (!spec) throw new Error(`Unknown template "${template}". Choose one of: ${Object.keys(TEMPLATES).join(', ')}.`);
56
-
57
- const dir = resolve(target);
58
- if (existsSync(dir) && (await readdir(dir)).length > 0) throw new Error(`"${target}" already exists and is not empty.`);
59
- const slug = toSlug(basename(dir));
60
- if (checkSlug) await checkSlug(slug);
61
- const displayName = name ?? titleCase(slug);
62
- const cliVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
63
- const replacements = { __SLUG__: slug, __NAME__: displayName, __CLI_VERSION__: cliVersion };
64
-
65
- const source = join(TEMPLATES_DIR, template);
66
- const written = [];
67
- for (const file of await listTemplateFiles(source)) {
68
- const content = (await readFile(join(source, file), 'utf8')).replace(/__SLUG__|__NAME__|__CLI_VERSION__/g, (m) => replacements[m]);
69
- await writeOut(join(dir, file), content);
70
- written.push(file);
71
- }
72
-
73
- // A dev app: the Hawk packages (and the vanilla CDN import) on the dev tag, not the release the template pins.
74
- if (channel === 'dev') {
75
- const pkgFile = join(dir, 'package.json');
76
- const appJsFile = join(dir, 'public', 'app.js');
77
- const pkg = existsSync(pkgFile) ? JSON.parse(await readFile(pkgFile, 'utf8')) : null;
78
- const appJs = existsSync(appJsFile) ? await readFile(appJsFile, 'utf8') : null;
79
- const plan = planChannel({ pkg, appJs, channel, latestVersion: () => null });
80
- applyChannel(plan, pkg ?? {}, appJs);
81
- if (pkg && plan.dependencies.length) await writeOut(pkgFile, JSON.stringify(pkg, null, 2) + '\n');
82
- // The bridge and the kit from the CDN (app.js, index.html).
83
- if (plan.cdn) await writeCdnFiles(dir, plan.cdn.to);
84
- }
85
-
86
- await writeOut(join(dir, 'hawk-app.json'), JSON.stringify(newManifest({ slug, name: displayName, template }), null, 2) + '\n');
87
- await writeOut(join(dir, 'icon.svg'), DEFAULT_ICON_SVG);
88
- await writeOut(join(dir, '.gitignore'), ['node_modules/', 'dist/', 'out/', '.next/', 'next-env.d.ts', '.env', '.env.local', '.env.*.local', '*.log', ''].join('\n'));
89
- await writeOut(join(dir, '.env.example'), ENV_EXAMPLE);
90
- await writeOut(join(dir, 'README.md'), readme({ slug, displayName, template, spec }));
91
- const agentFiles = await writeAgentFiles(dir, planAgentFiles(dir, { slug, name: displayName, template }));
92
-
93
- log(`✔ Created ${displayName} (${spec.description}) in ${relative(process.cwd(), dir) || '.'}`);
94
- log(` slug: ${slug} → each deploy is served at <apps CDN>/${slug}/v<n>/`);
95
- log(' AGENTS.md tells AI coding assistants (Claude Code, Copilot, Cursor, …) how Hawk Apps work.');
96
- if (channel === 'dev') DEV_CHANNEL_NOTE.forEach((line) => log(` ${line}`));
97
-
98
- if (install) {
99
- log(' Installing dependencies…');
100
- const result = spawnSync('npm', ['install', '--no-audit', '--no-fund'], { cwd: dir, stdio: 'inherit', shell: process.platform === 'win32' });
101
- if (result.status !== 0) throw new Error('npm install failed. Run it yourself in the new folder.');
102
- }
103
-
104
- log('\nNext steps:');
105
- log(` cd ${relative(process.cwd(), dir) || '.'}`);
106
- if (!install) log(' npm install');
107
- const { key, url } = resolveSettings({ dir, credentialsFile });
108
- if (!key) log(' npx hawk-app sso (once per machine: sign in with your Bridgeline account)');
109
- log(" npm run app:dev (run it locally inside a stand-in for HawkSearch, with a client's data)");
110
- log(` npm run app:deploy (${template === 'vanilla' ? 'uploads public/' : 'builds and uploads'} a new version, with hawk-app.json)`);
111
- log(key ? `\n Logged in to ${url}. Check everything any time with \`npx hawk-app doctor\`.` : '');
112
- log(' It isn\'t released yet: only Bridgeline staff see it, from the Apps page in HawkSearch (`npx hawk-app open`). Submit it');
113
- log(' for approval there; an approver releases it. `npx hawk-app --help` lists everything else.');
114
- log(' Docs: https://developers.hawksearchapps.com (npx hawk-app docs), with your Bridgeline account.');
115
- return { dir, slug, files: [...written, 'hawk-app.json', 'icon.svg', '.gitignore', '.env.example', 'README.md', ...agentFiles] };
116
- }
117
-
118
- async function listTemplateFiles(root) {
119
- const files = [];
120
- async function walk(current) {
121
- for (const entry of await readdir(current, { withFileTypes: true })) {
122
- const full = join(current, entry.name);
123
- if (entry.isDirectory()) await walk(full);
124
- else files.push(relative(root, full));
125
- }
126
- }
127
- await walk(root);
128
- return files.sort();
129
- }
130
-
131
- async function writeOut(path, content) {
132
- await mkdir(resolve(path, '..'), { recursive: true });
133
- await writeFile(path, content);
134
- }
135
-
136
- function readme({ slug, displayName, template, spec }) {
137
- return `# ${displayName}
138
-
139
- A Hawk App: a tool that runs inside HawkSearch (today's dashboard, Spark later) and gets its context and data through
140
- [\`@bridgeline-digital/hawk-app-bridge\`](https://www.npmjs.com/package/@bridgeline-digital/hawk-app-bridge).
141
- Created with \`hawk-app create --template ${template}\` (${spec.description}).
142
-
143
- ## Develop
144
- \`\`\`bash
145
- npm install
146
- npx hawk-app sso # once per machine: sign in with your Bridgeline account
147
- npm run app:dev # the app inside a local stand-in for HawkSearch, with a client's data
148
- \`\`\`
149
- \`app:dev\` runs \`${spec.dev}\` and a stand-in for HawkSearch at http://localhost:3200 with real tokens and data. The
150
- first time it asks which client (remembered in \`.env.local\`; \`-- --client <id>\` picks another) and registers the
151
- app (not released). Opened directly in a browser, the app says "Open this app from the Apps page in HawkSearch": it
152
- needs a host for its context and data. Queries and functions run from the deployed version: deploy after changing them.
153
-
154
- To try a local copy inside the real HawkSearch instead, open
155
- \`<HawkSearch>/admin/apps/${slug}?devUrl=${spec.devUrl(slug)}\` (Bridgeline users, \`http://localhost\` ports 3100-3103).
156
-
157
- ## Data
158
- Use the bridge. There are **no API keys in the app**; the platform adds credentials server-side for the current client.
159
- - \`app.analytics.report('searches' | 'queries' | 'poor-results' | …, { limit })\` needs the \`reports:read\` scope.
160
- - \`app.dashboard.get('Synonym')\` (HawkSearch dashboard API, read) needs the \`hawksearch:read\` scope.
161
- - \`app.dashboard.post/put/delete('Synonym', body)\` (writes: synonyms, redirects, banners, landing pages, …) need the \`hawksearch:write\` scope; writes are never retried.
162
- - \`app.context\` and \`app.onContextChange\` give the client, engine, date range, locale and theme.
163
- - Own backend? Send \`await app.getToken()\` as \`Authorization: Bearer ...\` and verify it there against the
164
- platform's \`/.well-known/jwks.json\`. Don't use cookies: browsers block them inside the host's iframe.
165
-
166
- ## Ship
167
- \`\`\`bash
168
- npm run app:deploy # ${template === 'vanilla' ? 'upload public/' : `build and upload ${spec.build}/`} as a new version, with hawk-app.json
169
- npx hawk-app status # released or not, the newest version, the approval request
170
- npx hawk-app open # the app in HawkSearch, to review it and submit it for approval
171
- npx hawk-app clients add <id> # give a client the app (or manage clients in the Hawk Analytics portal)
172
- \`\`\`
173
- Every deploy is a new, numbered version${template === 'vanilla' ? '' : `: \`app:deploy\` builds the app with its base path set to \`/${slug}/v<n>\``},
174
- with the settings in \`hawk-app.json\` (scopes, origins, views, functions…).
175
-
176
- - **Not released at first.** A new app's versions are drafts: only Bridgeline staff see them. Submit a version for
177
- approval in HawkSearch; once an approver releases it, the app's clients get it.
178
- - **Each version is reviewed.** Clients keep the released version until a new one is approved. \`npx hawk-app versions\`
179
- lists every version with its status; \`npx hawk-app why <client>\` explains what a client sees.
180
- - **Test before release.** \`npm run app:deploy -- --alias staging\` deploys a version for an alias (e.g. new clients or a
181
- branch) without touching the main line. See versions-and-aliases in the Hawk Apps docs.
182
- - **Clients any time.** \`npx hawk-app clients add|exclude|remove <id>\` (or the Hawk Analytics portal) applies at once,
183
- without a deploy or approval, and is recorded in the app's history.
184
- - **Something off?** \`npx hawk-app doctor\` checks your login, the platform, the project and the bridge.
185
- `;
186
- }
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
4
+ import { basename, join, relative, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { planAgentFiles, writeAgentFiles } from './agents.mjs';
7
+ import { credentialsPath } from './credentials.mjs';
8
+ import { ENV_EXAMPLE, resolveSettings } from './env.mjs';
9
+ import { newManifest } from './manifest.mjs';
10
+ import { DEFAULT_ICON_SVG } from './app-icon.mjs';
11
+ import { applyChannel, CHANNELS, cliChannel, DEV_CHANNEL_NOTE, planChannel, writeCdnFiles } from './channel.mjs';
12
+
13
+ const TEMPLATES_DIR = fileURLToPath(new URL('../templates', import.meta.url));
14
+
15
+ export const TEMPLATES = {
16
+ react: { description: 'React + Vite + TypeScript + the UI kit (Spark\'s stylesheet and elements)', build: 'dist', dev: 'npm run dev', devUrl: (slug) => `http://localhost:3100/${slug}/` },
17
+ preact: { description: 'Preact + Vite + TypeScript (small bundle) + the UI kit (stylesheet and elements)', build: 'dist', dev: 'npm run dev', devUrl: (slug) => `http://localhost:3100/${slug}/` },
18
+ next: { description: 'Next.js (static export) + TypeScript + the UI kit (stylesheet and elements)', build: 'out', dev: 'npm run dev', devUrl: (slug) => `http://localhost:3100/${slug}/` },
19
+ 'next-mui': {
20
+ description: 'Next.js (static export) + Material UI 9 + the UI kit (Spark\'s theme and components)',
21
+ build: 'out',
22
+ dev: 'npm run dev',
23
+ devUrl: (slug) => `http://localhost:3100/${slug}/`,
24
+ },
25
+ vanilla: { description: 'Plain HTML + JavaScript, no build step (the bridge and the UI kit from the CDN)', build: 'public', dev: 'npm run dev', devUrl: () => 'http://localhost:3100/' },
26
+ };
27
+
28
+ export function toSlug(value) {
29
+ return (
30
+ String(value)
31
+ .toLowerCase()
32
+ .replace(/[^a-z0-9]+/g, '-')
33
+ .replace(/^-+|-+$/g, '')
34
+ .slice(0, 64) || 'my-app'
35
+ );
36
+ }
37
+
38
+ export function titleCase(slug) {
39
+ return slug
40
+ .split('-')
41
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
42
+ .join(' ');
43
+ }
44
+
45
+ /** Scaffolds a new Hawk App project from one of the built-in templates. */
46
+ /** @param checkSlug Refuses a taken slug before anything is written (create's own check: see slug-guard.mjs). */
47
+ /**
48
+ * @param channel `latest` or `dev`: which builds of the bridge and CLI the app follows. Default: this CLI's own channel,
49
+ * so `npx …-cli@dev create` makes a dev app (see channel.mjs).
50
+ */
51
+ export async function createProject({ target, template = 'react', name, install = false, checkSlug, channel = cliChannel(), log = console.log, credentialsFile = credentialsPath() }) {
52
+ if (!CHANNELS.includes(channel)) throw new Error(`--channel must be latest or dev, not "${channel}".`);
53
+ if (!target) throw new Error(`Usage: hawk-app create <folder> [--template ${Object.keys(TEMPLATES).join('|')}] [--name "Display Name"] [--install]`);
54
+ const spec = TEMPLATES[template];
55
+ if (!spec) throw new Error(`Unknown template "${template}". Choose one of: ${Object.keys(TEMPLATES).join(', ')}.`);
56
+
57
+ const dir = resolve(target);
58
+ if (existsSync(dir) && (await readdir(dir)).length > 0) throw new Error(`"${target}" already exists and is not empty.`);
59
+ const slug = toSlug(basename(dir));
60
+ if (checkSlug) await checkSlug(slug);
61
+ const displayName = name ?? titleCase(slug);
62
+ const cliVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
63
+ const replacements = { __SLUG__: slug, __NAME__: displayName, __CLI_VERSION__: cliVersion };
64
+
65
+ const source = join(TEMPLATES_DIR, template);
66
+ const written = [];
67
+ for (const file of await listTemplateFiles(source)) {
68
+ const content = (await readFile(join(source, file), 'utf8')).replace(/__SLUG__|__NAME__|__CLI_VERSION__/g, (m) => replacements[m]);
69
+ await writeOut(join(dir, file), content);
70
+ written.push(file);
71
+ }
72
+
73
+ // A dev app: the Hawk packages (and the vanilla CDN import) on the dev tag, not the release the template pins.
74
+ if (channel === 'dev') {
75
+ const pkgFile = join(dir, 'package.json');
76
+ const appJsFile = join(dir, 'public', 'app.js');
77
+ const pkg = existsSync(pkgFile) ? JSON.parse(await readFile(pkgFile, 'utf8')) : null;
78
+ const appJs = existsSync(appJsFile) ? await readFile(appJsFile, 'utf8') : null;
79
+ const plan = planChannel({ pkg, appJs, channel, latestVersion: () => null });
80
+ applyChannel(plan, pkg ?? {}, appJs);
81
+ if (pkg && plan.dependencies.length) await writeOut(pkgFile, JSON.stringify(pkg, null, 2) + '\n');
82
+ // The bridge and the kit from the CDN (app.js, index.html).
83
+ if (plan.cdn) await writeCdnFiles(dir, plan.cdn.to);
84
+ }
85
+
86
+ await writeOut(join(dir, 'hawk-app.json'), JSON.stringify(newManifest({ slug, name: displayName, template }), null, 2) + '\n');
87
+ await writeOut(join(dir, 'icon.svg'), DEFAULT_ICON_SVG);
88
+ await writeOut(join(dir, '.gitignore'), ['node_modules/', 'dist/', 'out/', '.next/', 'next-env.d.ts', '.env', '.env.local', '.env.*.local', '*.log', ''].join('\n'));
89
+ await writeOut(join(dir, '.env.example'), ENV_EXAMPLE);
90
+ await writeOut(join(dir, 'README.md'), readme({ slug, displayName, template, spec }));
91
+ const agentFiles = await writeAgentFiles(dir, planAgentFiles(dir, { slug, name: displayName, template }));
92
+
93
+ log(`✔ Created ${displayName} (${spec.description}) in ${relative(process.cwd(), dir) || '.'}`);
94
+ log(` slug: ${slug} → each deploy is served at <apps CDN>/${slug}/v<n>/`);
95
+ log(' AGENTS.md tells AI coding assistants (Claude Code, Copilot, Cursor, …) how Hawk Apps work.');
96
+ if (channel === 'dev') DEV_CHANNEL_NOTE.forEach((line) => log(` ${line}`));
97
+
98
+ if (install) {
99
+ log(' Installing dependencies…');
100
+ const result = spawnSync('npm', ['install', '--no-audit', '--no-fund'], { cwd: dir, stdio: 'inherit', shell: process.platform === 'win32' });
101
+ if (result.status !== 0) throw new Error('npm install failed. Run it yourself in the new folder.');
102
+ }
103
+
104
+ log('\nNext steps:');
105
+ log(` cd ${relative(process.cwd(), dir) || '.'}`);
106
+ if (!install) log(' npm install');
107
+ const { key, url } = resolveSettings({ dir, credentialsFile });
108
+ if (!key) log(' npx hawk-app sso (once per machine: sign in with your Bridgeline account)');
109
+ log(" npm run app:dev (run it locally inside a stand-in for HawkSearch, with a client's data)");
110
+ log(` npm run app:deploy (${template === 'vanilla' ? 'uploads public/' : 'builds and uploads'} a new version, with hawk-app.json)`);
111
+ log(key ? `\n Logged in to ${url}. Check everything any time with \`npx hawk-app doctor\`.` : '');
112
+ log(' It isn\'t released yet: only Bridgeline staff see it, from the Apps page in HawkSearch (`npx hawk-app open`). Submit it');
113
+ log(' for approval there; an approver releases it. `npx hawk-app --help` lists everything else.');
114
+ log(' Docs: https://developers.hawksearchapps.com (npx hawk-app docs), with your Bridgeline account.');
115
+ return { dir, slug, files: [...written, 'hawk-app.json', 'icon.svg', '.gitignore', '.env.example', 'README.md', ...agentFiles] };
116
+ }
117
+
118
+ async function listTemplateFiles(root) {
119
+ const files = [];
120
+ async function walk(current) {
121
+ for (const entry of await readdir(current, { withFileTypes: true })) {
122
+ const full = join(current, entry.name);
123
+ if (entry.isDirectory()) await walk(full);
124
+ else files.push(relative(root, full));
125
+ }
126
+ }
127
+ await walk(root);
128
+ return files.sort();
129
+ }
130
+
131
+ async function writeOut(path, content) {
132
+ await mkdir(resolve(path, '..'), { recursive: true });
133
+ await writeFile(path, content);
134
+ }
135
+
136
+ function readme({ slug, displayName, template, spec }) {
137
+ return `# ${displayName}
138
+
139
+ A Hawk App: a tool that runs inside HawkSearch (today's dashboard, Spark later) and gets its context and data through
140
+ [\`@bridgeline-digital/hawk-app-bridge\`](https://www.npmjs.com/package/@bridgeline-digital/hawk-app-bridge).
141
+ Created with \`hawk-app create --template ${template}\` (${spec.description}).
142
+
143
+ ## Develop
144
+ \`\`\`bash
145
+ npm install
146
+ npx hawk-app sso # once per machine: sign in with your Bridgeline account
147
+ npm run app:dev # the app inside a local stand-in for HawkSearch, with a client's data
148
+ \`\`\`
149
+ \`app:dev\` runs \`${spec.dev}\` and a stand-in for HawkSearch at http://localhost:3200 with real tokens and data. The
150
+ first time it asks which client (remembered in \`.env.local\`; \`-- --client <id>\` picks another) and registers the
151
+ app (not released). Opened directly in a browser, the app says "Open this app from the Apps page in HawkSearch": it
152
+ needs a host for its context and data. Queries and functions run from the deployed version: deploy after changing them.
153
+
154
+ To try a local copy inside the real HawkSearch instead, open
155
+ \`<HawkSearch>/admin/apps/${slug}?devUrl=${spec.devUrl(slug)}\` (Bridgeline users, \`http://localhost\` ports 3100-3103).
156
+
157
+ ## Data
158
+ Use the bridge. There are **no API keys in the app**; the platform adds credentials server-side for the current client.
159
+ - \`app.analytics.report('searches' | 'queries' | 'poor-results' | …, { limit })\` needs the \`reports:read\` scope.
160
+ - \`app.dashboard.get('Synonym')\` (HawkSearch dashboard API, read) needs the \`hawksearch:read\` scope.
161
+ - \`app.dashboard.post/put/delete('Synonym', body)\` (writes: synonyms, redirects, banners, landing pages, …) need the \`hawksearch:write\` scope; writes are never retried.
162
+ - \`app.context\` and \`app.onContextChange\` give the client, engine, date range, locale and theme.
163
+ - Own backend? Send \`await app.getToken()\` as \`Authorization: Bearer ...\` and verify it there against the
164
+ platform's \`/.well-known/jwks.json\`. Don't use cookies: browsers block them inside the host's iframe.
165
+
166
+ ## Ship
167
+ \`\`\`bash
168
+ npm run app:deploy # ${template === 'vanilla' ? 'upload public/' : `build and upload ${spec.build}/`} as a new version, with hawk-app.json
169
+ npx hawk-app status # released or not, the newest version, the approval request
170
+ npx hawk-app open # the app in HawkSearch, to review it and submit it for approval
171
+ npx hawk-app clients add <id> # give a client the app (or manage clients in the Hawk Analytics portal)
172
+ \`\`\`
173
+ Every deploy is a new, numbered version${template === 'vanilla' ? '' : `: \`app:deploy\` builds the app with its base path set to \`/${slug}/v<n>\``},
174
+ with the settings in \`hawk-app.json\` (scopes, origins, views, functions…).
175
+
176
+ - **Not released at first.** A new app's versions are drafts: only Bridgeline staff see them. Submit a version for
177
+ approval in HawkSearch; once an approver releases it, the app's clients get it.
178
+ - **Each version is reviewed.** Clients keep the released version until a new one is approved. \`npx hawk-app versions\`
179
+ lists every version with its status; \`npx hawk-app why <client>\` explains what a client sees.
180
+ - **Test before release.** \`npm run app:deploy -- --alias staging\` deploys a version for an alias (e.g. new clients or a
181
+ branch) without touching the main line. See versions-and-aliases in the Hawk Apps docs.
182
+ - **Clients any time.** \`npx hawk-app clients add|exclude|remove <id>\` (or the Hawk Analytics portal) applies at once,
183
+ without a deploy or approval, and is recorded in the app's history.
184
+ - **Something off?** \`npx hawk-app doctor\` checks your login, the platform, the project and the bridge.
185
+ `;
186
+ }
@@ -1,56 +1,56 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import { chmod, mkdir, writeFile } from 'node:fs/promises';
3
- import { homedir } from 'node:os';
4
- import { dirname, join } from 'node:path';
5
-
6
- /**
7
- * Per-user credentials, shared by every app on this machine:
8
- * Windows %APPDATA%\hawk-app\credentials.json
9
- * macOS / Linux $XDG_CONFIG_HOME/hawk-app/credentials.json (default ~/.config/hawk-app/credentials.json)
10
- * HAWK_APPS_CREDENTIALS_FILE overrides the location.
11
- */
12
- export function credentialsPath(env = process.env, platform = process.platform, home = homedir()) {
13
- if (env.HAWK_APPS_CREDENTIALS_FILE) return env.HAWK_APPS_CREDENTIALS_FILE;
14
- const base = platform === 'win32' ? env.APPDATA ?? join(home, 'AppData', 'Roaming') : env.XDG_CONFIG_HOME ?? join(home, '.config');
15
- return join(base, 'hawk-app', 'credentials.json');
16
- }
17
-
18
- export function normalizeUrl(url) {
19
- return String(url ?? '').trim().replace(/\/+$/, '');
20
- }
21
-
22
- /** @returns {{ platforms: Record<string, { publisherKey: string, savedAt?: string }> }} */
23
- export function readCredentials(path) {
24
- try {
25
- const data = JSON.parse(readFileSync(path, 'utf8'));
26
- return { platforms: data.platforms ?? {} };
27
- } catch {
28
- return { platforms: {} };
29
- }
30
- }
31
-
32
- async function writeCredentials(path, data) {
33
- await mkdir(dirname(path), { recursive: true });
34
- await writeFile(path, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
35
- // Owner-only on macOS/Linux; on Windows the file lives in the user's own profile.
36
- await chmod(path, 0o600).catch(() => undefined);
37
- }
38
-
39
- /** Saves the key for a platform. It doesn't choose the platform commands use (see resolveSettings). */
40
- export async function saveCredential(path, url, publisherKey) {
41
- const data = readCredentials(path);
42
- const platform = normalizeUrl(url);
43
- data.platforms[platform] = { publisherKey, savedAt: new Date().toISOString() };
44
- await writeCredentials(path, data);
45
- }
46
-
47
- /** @returns {boolean} whether a key was removed. */
48
- export async function removeCredential(path, url) {
49
- if (!existsSync(path)) return false;
50
- const data = readCredentials(path);
51
- const platform = normalizeUrl(url);
52
- if (!data.platforms[platform]) return false;
53
- delete data.platforms[platform];
54
- await writeCredentials(path, data);
55
- return true;
56
- }
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { chmod, mkdir, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+
6
+ /**
7
+ * Per-user credentials, shared by every app on this machine:
8
+ * Windows %APPDATA%\hawk-app\credentials.json
9
+ * macOS / Linux $XDG_CONFIG_HOME/hawk-app/credentials.json (default ~/.config/hawk-app/credentials.json)
10
+ * HAWK_APPS_CREDENTIALS_FILE overrides the location.
11
+ */
12
+ export function credentialsPath(env = process.env, platform = process.platform, home = homedir()) {
13
+ if (env.HAWK_APPS_CREDENTIALS_FILE) return env.HAWK_APPS_CREDENTIALS_FILE;
14
+ const base = platform === 'win32' ? env.APPDATA ?? join(home, 'AppData', 'Roaming') : env.XDG_CONFIG_HOME ?? join(home, '.config');
15
+ return join(base, 'hawk-app', 'credentials.json');
16
+ }
17
+
18
+ export function normalizeUrl(url) {
19
+ return String(url ?? '').trim().replace(/\/+$/, '');
20
+ }
21
+
22
+ /** @returns {{ platforms: Record<string, { publisherKey: string, savedAt?: string }> }} */
23
+ export function readCredentials(path) {
24
+ try {
25
+ const data = JSON.parse(readFileSync(path, 'utf8'));
26
+ return { platforms: data.platforms ?? {} };
27
+ } catch {
28
+ return { platforms: {} };
29
+ }
30
+ }
31
+
32
+ async function writeCredentials(path, data) {
33
+ await mkdir(dirname(path), { recursive: true });
34
+ await writeFile(path, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
35
+ // Owner-only on macOS/Linux; on Windows the file lives in the user's own profile.
36
+ await chmod(path, 0o600).catch(() => undefined);
37
+ }
38
+
39
+ /** Saves the key for a platform. It doesn't choose the platform commands use (see resolveSettings). */
40
+ export async function saveCredential(path, url, publisherKey) {
41
+ const data = readCredentials(path);
42
+ const platform = normalizeUrl(url);
43
+ data.platforms[platform] = { publisherKey, savedAt: new Date().toISOString() };
44
+ await writeCredentials(path, data);
45
+ }
46
+
47
+ /** @returns {boolean} whether a key was removed. */
48
+ export async function removeCredential(path, url) {
49
+ if (!existsSync(path)) return false;
50
+ const data = readCredentials(path);
51
+ const platform = normalizeUrl(url);
52
+ if (!data.platforms[platform]) return false;
53
+ delete data.platforms[platform];
54
+ await writeCredentials(path, data);
55
+ return true;
56
+ }