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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +769 -2
  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,105 @@
1
+ // The accessibility check: axe-core (the standard WCAG rules engine) over the built HTML pages, in jsdom, before a
2
+ // version is completed. `deploy` sends the result with the version for the approvers' review checks, and
3
+ // `validate --build` prints it. It sees each page's HTML as built (what a static export prerenders), not what the app
4
+ // draws later in the browser, and no layout (so no color contrast): it catches missing labels, alt text, landmarks,
5
+ // heading order, lang, duplicate ids and the like.
6
+ import { readdir, readFile } from 'node:fs/promises';
7
+ import { join, relative, sep } from 'node:path';
8
+
9
+ /** At most this many pages are checked (the first ones by path), so a big build stays quick. */
10
+ export const MAX_PAGES = 10;
11
+ /** At most this many rules are reported, the worst first. */
12
+ export const MAX_RULES = 10;
13
+ const IMPACTS = ['critical', 'serious', 'moderate', 'minor'];
14
+ // Pages that are only there for the static host, not for users.
15
+ const SKIPPED = /(^|\/)(404|_not-found)(\/index)?\.html?$/i;
16
+
17
+ async function htmlFiles(folder) {
18
+ const found = [];
19
+ async function walk(current) {
20
+ for (const entry of await readdir(current, { withFileTypes: true })) {
21
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
22
+ const full = join(current, entry.name);
23
+ if (entry.isDirectory()) await walk(full);
24
+ else if (/\.html?$/i.test(entry.name)) found.push(relative(folder, full).split(sep).join('/'));
25
+ }
26
+ }
27
+ await walk(folder);
28
+ return found.filter((file) => !SKIPPED.test(file)).sort();
29
+ }
30
+
31
+ /**
32
+ * Runs axe over the built pages in `folder`: { status: 'checked', pages, critical, serious, moderate, minor, rules:
33
+ * [{ id, impact, count, help }] } (count: elements across pages), or { status: 'not_checked', reason }. Never throws.
34
+ */
35
+ export async function checkAccessibility(folder, { maxPages = MAX_PAGES } = {}) {
36
+ let files;
37
+ try {
38
+ files = await htmlFiles(folder);
39
+ } catch {
40
+ return { status: 'not_checked', reason: 'the build folder could not be read' };
41
+ }
42
+ if (!files.length) return { status: 'not_checked', reason: 'no HTML pages in the build' };
43
+
44
+ let JSDOM, axeSource;
45
+ try {
46
+ ({ JSDOM } = await import('jsdom'));
47
+ axeSource = (await import('axe-core')).default.source;
48
+ } catch {
49
+ return { status: 'not_checked', reason: 'axe-core or jsdom is not installed with the CLI' };
50
+ }
51
+
52
+ const counts = { critical: 0, serious: 0, moderate: 0, minor: 0 };
53
+ const rules = new Map();
54
+ const pages = files.slice(0, maxPages);
55
+ try {
56
+ for (const file of pages) {
57
+ // The page's own scripts never run ('outside-only'): only axe, injected below.
58
+ const dom = new JSDOM(await readFile(join(folder, file), 'utf8'), { runScripts: 'outside-only', pretendToBeVisual: true });
59
+ try {
60
+ dom.window.eval(axeSource);
61
+ const result = await dom.window.axe.run(dom.window.document, {
62
+ runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'] },
63
+ // Needs layout, which jsdom doesn't have.
64
+ rules: { 'color-contrast': { enabled: false } },
65
+ resultTypes: ['violations'],
66
+ });
67
+ for (const violation of result.violations) {
68
+ const impact = IMPACTS.includes(violation.impact) ? violation.impact : 'minor';
69
+ counts[impact] += violation.nodes.length;
70
+ const rule = rules.get(violation.id) ?? { id: violation.id, impact, count: 0, help: String(violation.help ?? '').slice(0, 200) };
71
+ rule.count += violation.nodes.length;
72
+ rules.set(violation.id, rule);
73
+ }
74
+ } finally {
75
+ dom.window.close();
76
+ }
77
+ }
78
+ } catch (error) {
79
+ return { status: 'not_checked', reason: `axe failed: ${String(error?.message ?? error).slice(0, 120)}` };
80
+ }
81
+ const worst = [...rules.values()]
82
+ .sort((a, b) => IMPACTS.indexOf(a.impact) - IMPACTS.indexOf(b.impact) || b.count - a.count)
83
+ .slice(0, MAX_RULES);
84
+ return { status: 'checked', pages: pages.length, ...counts, rules: worst };
85
+ }
86
+
87
+ /** One line for the terminal, e.g. "2 serious, 1 moderate in 3 pages (label, image-alt)". */
88
+ export function describeAccessibility(report) {
89
+ if (report.status !== 'checked') return `not checked: ${report.reason}`;
90
+ const found = IMPACTS.filter((impact) => report[impact]).map((impact) => `${report[impact]} ${impact}`);
91
+ const pages = `${report.pages} page${report.pages === 1 ? '' : 's'}`;
92
+ if (!found.length) return `no issues found in ${pages} (axe)`;
93
+ return `${found.join(', ')} in ${pages} (${report.rules.map((r) => r.id).join(', ')})`;
94
+ }
95
+
96
+ /** Findings worth fixing before review: critical and serious issues, one line each. */
97
+ export function accessibilityWarnings(report) {
98
+ if (report.status !== 'checked' || report.critical + report.serious === 0) return [];
99
+ const worst = report.rules.filter((r) => r.impact === 'critical' || r.impact === 'serious');
100
+ return [
101
+ `Accessibility: ${describeAccessibility(report)}. ${report.critical ? 'Critical issues fail the review check (approvers can only force approve). ' : ''}Fix: ${worst
102
+ .map((r) => `${r.id} (${r.count}): ${r.help}`)
103
+ .join('; ')}.`,
104
+ ];
105
+ }
package/src/agents.mjs ADDED
@@ -0,0 +1,145 @@
1
+ // Instructions for AI coding assistants, written into each app: AGENTS.md (read by Copilot, Cursor, Codex and others),
2
+ // a CLAUDE.md that imports it (Claude Code), and the CLI's MCP server (`hawk-app mcp`) in .mcp.json (Claude Code) and
3
+ // .vscode/mcp.json (VS Code / Copilot). The CLI owns the part of AGENTS.md between its markers and refreshes
4
+ // it with `hawk-app upgrade`; anything outside the markers is the developer's. Sources: ../agents/AGENTS.md plus one
5
+ // project section per template (../agents/project/), or custom.md for an app set up with `hawk-app init`.
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import { mkdir, writeFile } from 'node:fs/promises';
8
+ import { dirname, join } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const AGENTS_DIR = fileURLToPath(new URL('../agents', import.meta.url));
12
+ const START = '<!-- hawk-app:start: written by the Hawk Apps CLI and refreshed by `npx hawk-app upgrade`. Add your own notes outside these markers. -->';
13
+ const END = '<!-- hawk-app:end -->';
14
+ const BLOCK = /<!-- hawk-app:start[^]*?<!-- hawk-app:end -->/;
15
+ const CLAUDE_IMPORT = '@AGENTS.md';
16
+
17
+ /**
18
+ * The MCP server (`hawk-app mcp`) in each assistant's settings file, started with node from the app's own copy of the
19
+ * CLI (a dev dependency): no npx, which needs a shell wrapper on Windows. Claude Code starts it in the project folder.
20
+ */
21
+ const CLI_BIN = 'node_modules/@bridgeline-digital/hawk-app-cli/bin/hawk-app.mjs';
22
+ const MCP_CONFIGS = [
23
+ { file: '.mcp.json', key: 'mcpServers', entry: { command: 'node', args: [CLI_BIN, 'mcp'] } },
24
+ {
25
+ file: '.vscode/mcp.json',
26
+ key: 'servers',
27
+ entry: { type: 'stdio', command: 'node', args: [`\${workspaceFolder}/${CLI_BIN}`, 'mcp'], cwd: '${workspaceFolder}' },
28
+ },
29
+ ];
30
+ const MCP_SERVER = 'hawk-apps';
31
+
32
+ /** An assistant's MCP settings with the hawk-apps server: created, added to the developer's own, or left as is. */
33
+ function planMcpConfig(dir, { file, key, entry }) {
34
+ const full = join(dir, file);
35
+ if (!existsSync(full)) return { file, state: 'create', content: JSON.stringify({ [key]: { [MCP_SERVER]: entry } }, null, 2) + '\n' };
36
+ const text = readFileSync(full, 'utf8');
37
+ let config;
38
+ try {
39
+ config = JSON.parse(text);
40
+ } catch {
41
+ // JSON with comments (VS Code allows them) or broken: not ours to rewrite.
42
+ return { file, state: 'skipped', content: text };
43
+ }
44
+ if (config?.[key]?.[MCP_SERVER]) return { file, state: 'current', content: text };
45
+ const updated = { ...config, [key]: { ...config?.[key], [MCP_SERVER]: entry } };
46
+ return { file, state: 'append', content: JSON.stringify(updated, null, 2) + (text.includes('\r\n') ? '\r\n' : '\n') };
47
+ }
48
+
49
+ /** The project section each template uses; any other app gets custom.md. */
50
+ const PROJECT_SECTIONS = { react: 'vite', preact: 'vite', next: 'next', 'next-mui': 'next-mui', vanilla: 'vanilla' };
51
+
52
+ /** The CLI's block of AGENTS.md for an app, markers included (LF line endings). */
53
+ export function renderAgents({ slug, name, template }) {
54
+ const read = (file) => readFileSync(join(AGENTS_DIR, file), 'utf8').replace(/\r\n/g, '\n').trim();
55
+ const project = read(`project/${PROJECT_SECTIONS[template] ?? 'custom'}.md`);
56
+ const values = { __SLUG__: slug || '<slug>', __NAME__: name || slug || 'This app', __TEMPLATE__: template ?? '' };
57
+ const body = read('AGENTS.md')
58
+ .replace('__PROJECT__', () => project)
59
+ .replace(/__SLUG__|__NAME__|__TEMPLATE__/g, (m) => values[m]);
60
+ return `${START}\n${body}\n${END}`;
61
+ }
62
+
63
+ /**
64
+ * What writing the assistant files would do: [{ file, state: 'create' | 'update' | 'append' | 'current', content }].
65
+ * AGENTS.md: created, its block replaced, or the block appended to the developer's own file. CLAUDE.md: created, or
66
+ * the import appended when it doesn't have it.
67
+ */
68
+ export function planAgentFiles(dir, app) {
69
+ const block = renderAgents(app);
70
+ const files = [];
71
+
72
+ const agentsFile = join(dir, 'AGENTS.md');
73
+ if (!existsSync(agentsFile)) {
74
+ files.push({ file: 'AGENTS.md', state: 'create', content: block + '\n' });
75
+ } else {
76
+ const text = readFileSync(agentsFile, 'utf8');
77
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
78
+ const ours = block.replace(/\n/g, eol);
79
+ if (BLOCK.test(text)) {
80
+ const updated = text.replace(BLOCK, () => ours);
81
+ files.push({ file: 'AGENTS.md', state: updated === text ? 'current' : 'update', content: updated });
82
+ } else {
83
+ files.push({ file: 'AGENTS.md', state: 'append', content: `${text.replace(/\s*$/, '')}${eol}${eol}${ours}${eol}` });
84
+ }
85
+ }
86
+
87
+ for (const config of MCP_CONFIGS) files.push(planMcpConfig(dir, config));
88
+
89
+ const claudeFile = join(dir, 'CLAUDE.md');
90
+ if (!existsSync(claudeFile)) {
91
+ files.push({ file: 'CLAUDE.md', state: 'create', content: `${CLAUDE_IMPORT}\n` });
92
+ } else {
93
+ const text = readFileSync(claudeFile, 'utf8');
94
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
95
+ files.push(
96
+ /^@AGENTS\.md\s*$/m.test(text)
97
+ ? { file: 'CLAUDE.md', state: 'current', content: text }
98
+ : { file: 'CLAUDE.md', state: 'append', content: `${text.replace(/\s*$/, '')}${eol}${eol}${CLAUDE_IMPORT}${eol}` },
99
+ );
100
+ }
101
+ return files;
102
+ }
103
+
104
+ /** Writes what planAgentFiles found out of date; returns the files written. */
105
+ /** Whether planAgentFiles found the file out of date (a file it leaves alone isn't). */
106
+ export const isAgentChange = (f) => f.state !== 'current' && f.state !== 'skipped';
107
+
108
+ export async function writeAgentFiles(dir, plan) {
109
+ const changed = plan.filter(isAgentChange);
110
+ for (const f of changed) {
111
+ await mkdir(dirname(join(dir, f.file)), { recursive: true });
112
+ await writeFile(join(dir, f.file), f.content);
113
+ }
114
+ return changed.map((f) => f.file);
115
+ }
116
+
117
+ /** One line per file that would change, for create, init and upgrade. */
118
+ export function describeAgentFile({ file, state }) {
119
+ const mcp = file.endsWith('mcp.json');
120
+ switch (state) {
121
+ case 'create':
122
+ if (mcp) return `${file}: added (the hawk-app MCP server for ${file === '.mcp.json' ? 'Claude Code' : 'VS Code'}).`;
123
+ return file === 'AGENTS.md' ? 'AGENTS.md: added (instructions for AI coding assistants).' : `${file}: added (points Claude Code at AGENTS.md).`;
124
+ case 'update':
125
+ return `${file}: the CLI's part is refreshed (your notes outside its markers are kept).`;
126
+ case 'append':
127
+ if (mcp) return `${file}: the hawk-apps MCP server is added to your servers.`;
128
+ return file === 'AGENTS.md' ? "AGENTS.md: the CLI's part is added after your own." : `${file}: ${CLAUDE_IMPORT} is added, so Claude Code reads AGENTS.md.`;
129
+ case 'skipped':
130
+ return `${file}: not plain JSON (comments?), so left alone. Add the "${MCP_SERVER}" server yourself: see the CLI README.`;
131
+ default:
132
+ return null;
133
+ }
134
+ }
135
+
136
+ /** Plans and writes the assistant files for a new app (create, init), logging each one. */
137
+ export async function addAgentFiles(dir, app, log) {
138
+ const plan = planAgentFiles(dir, app);
139
+ await writeAgentFiles(dir, plan);
140
+ for (const f of plan) {
141
+ const line = describeAgentFile(f);
142
+ if (line) log(` ${line}`);
143
+ }
144
+ return plan.filter(isAgentChange).map((f) => f.file);
145
+ }
@@ -0,0 +1,30 @@
1
+ /** Same rules as the platform's alias names: lowercase letters, digits and single dashes, at most 40. */
2
+ export const ALIAS_NAME = /^(?=.{1,40}$)[a-z0-9]+(-[a-z0-9]+)*$/;
3
+
4
+ /** The built-in alias: the version released to everyone, for every client without another alias. */
5
+ export const DEFAULT_ALIAS = 'default';
6
+
7
+ /** Names no alias may take (old names for the default alias and for statuses, which come from the version). */
8
+ const RESERVED = ['latest', 'live', 'draft'];
9
+
10
+ /** The alias name, checked; `allowDefault` for putting a client back on the default. */
11
+ export function checkAlias(name, { allowDefault = false, flag = '--alias' } = {}) {
12
+ if (typeof name !== 'string' || !name) throw new Error(`${flag} needs an alias name, e.g. ${flag} staging.`);
13
+ if (name === DEFAULT_ALIAS && allowDefault) return name;
14
+ if (name === DEFAULT_ALIAS || RESERVED.includes(name)) throw new Error(`"${name}" is reserved: pick another alias name, e.g. staging or review.`);
15
+ if (!ALIAS_NAME.test(name)) throw new Error(`"${name}" isn't a valid alias: lowercase letters, digits and single dashes (at most 40), e.g. staging.`);
16
+ return name;
17
+ }
18
+
19
+ /** "pilot v8 (3 clients)", "staging v9 (draft, 1 client)"; the default's count is null when the app is for every client. */
20
+ export function describeAlias(alias) {
21
+ const clients = alias.clients === null || alias.clients === undefined ? 'all other clients' : `${alias.clients} client${alias.clients === 1 ? '' : 's'}`;
22
+ const status = alias.status && alias.status !== 'approved' ? `${alias.status}: Bridgeline staff only, ` : '';
23
+ return `${alias.name} v${alias.version ?? '?'} (${status}${clients})`;
24
+ }
25
+
26
+ /** " [on pilot]", " [on staging · draft: Bridgeline staff only]" after a client in lists; nothing on the default. */
27
+ export function describeClientAlias(c) {
28
+ if (!c.alias) return '';
29
+ return c.aliasStatus && c.aliasStatus !== 'approved' ? ` [on ${c.alias} · ${c.aliasStatus}: Bridgeline staff only]` : ` [on ${c.alias}]`;
30
+ }
@@ -0,0 +1,82 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { extname, resolve } from 'node:path';
4
+
5
+ // The app's icon: an .svg or .png file next to hawk-app.json ("icon": "icon.svg"). Deploy uploads it into every
6
+ // version as _hawk/icon.svg or _hawk/icon.png, so it's reviewed and released with the version. Hosts draw an SVG in
7
+ // their own text color (as a CSS mask: only its shape counts, not its colors) and show a PNG as is. Without one, hosts
8
+ // show their default icon.
9
+
10
+ /** A relative path to an .svg or .png file: letters, digits, . _ - and /, no "..", at most 200 characters. */
11
+ export const ICON_PATH = /^(?!.*\.\.)[A-Za-z0-9_-][A-Za-z0-9_.-]*(\/[A-Za-z0-9_-][A-Za-z0-9_.-]*)*\.(svg|png)$/i;
12
+ export const ICON_PATH_HINT = 'an .svg or .png file next to hawk-app.json, e.g. "icon.svg"';
13
+
14
+ export const MAX_SVG_BYTES = 32 * 1024;
15
+ export const MAX_PNG_BYTES = 256 * 1024;
16
+ export const MIN_PNG_SIZE = 64;
17
+ export const MAX_PNG_SIZE = 1024;
18
+
19
+ /** The icon `create` and `init` add: a neutral app glyph (four rounded squares), one color, square. */
20
+ export const DEFAULT_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
21
+ <rect x="3" y="3" width="8" height="8" rx="2"/>
22
+ <rect x="13" y="3" width="8" height="8" rx="2"/>
23
+ <rect x="3" y="13" width="8" height="8" rx="2"/>
24
+ <rect x="13" y="13" width="8" height="8" rx="2"/>
25
+ </svg>
26
+ `;
27
+
28
+ /** Where deploy puts the icon in a version: _hawk/icon.svg or _hawk/icon.png. */
29
+ export function iconUploadPath(icon) {
30
+ return `_hawk/icon${extname(icon).toLowerCase()}`;
31
+ }
32
+
33
+ /**
34
+ * Checks the icon file hawk-app.json names (when it names one): there, the right type and size, square. Returns the
35
+ * file to upload ({ path: _hawk/icon.*, size, data }), with errors and warnings.
36
+ */
37
+ export async function checkIcon(projectDir, icon) {
38
+ const errors = [];
39
+ const warnings = [];
40
+ if (icon === undefined || icon === null || icon === '') return { errors, warnings, file: null };
41
+ if (typeof icon !== 'string' || !ICON_PATH.test(icon)) {
42
+ return { errors: [`icon must be ${ICON_PATH_HINT}.`], warnings, file: null };
43
+ }
44
+ const full = resolve(projectDir, icon);
45
+ if (!existsSync(full)) {
46
+ return { errors: [`icon: ${icon} doesn't exist next to hawk-app.json. Add it, or leave "icon" out for the hosts' default icon.`], warnings, file: null };
47
+ }
48
+ const data = await readFile(full);
49
+ if (extname(icon).toLowerCase() === '.svg') {
50
+ if (data.length > MAX_SVG_BYTES) errors.push(`icon: ${icon} is ${Math.ceil(data.length / 1024)} KB; an SVG icon can be at most ${MAX_SVG_BYTES / 1024} KB.`);
51
+ const text = data.toString('utf8');
52
+ const root = /<svg\b[^>]*>/i.exec(text)?.[0];
53
+ if (!root) {
54
+ errors.push(`icon: ${icon} isn't an SVG (no <svg> element).`);
55
+ } else {
56
+ const box = /\bviewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)[\s,]+([\d.]+)\s*["']/i.exec(root);
57
+ if (!box) warnings.push(`icon: ${icon} has no viewBox: hosts may draw it at the wrong size. Give it a square one, e.g. viewBox="0 0 24 24".`);
58
+ else if (Math.abs(Number(box[1]) - Number(box[2])) > 0.001) warnings.push(`icon: ${icon} isn't square (viewBox ${box[1]}×${box[2]}): hosts fit it in a square.`);
59
+ }
60
+ if (/<(image|foreignObject)\b/i.test(text)) {
61
+ warnings.push(`icon: ${icon} contains <image> or <foreignObject>: hosts draw only an SVG icon's shape, in their own color. For a picture, use a .png icon.`);
62
+ }
63
+ } else {
64
+ if (data.length > MAX_PNG_BYTES) errors.push(`icon: ${icon} is ${Math.ceil(data.length / 1024)} KB; a PNG icon can be at most ${MAX_PNG_BYTES / 1024} KB.`);
65
+ const size = pngSize(data);
66
+ if (!size) {
67
+ errors.push(`icon: ${icon} isn't a PNG.`);
68
+ } else if (size.width !== size.height) {
69
+ errors.push(`icon: ${icon} is ${size.width}×${size.height}; a PNG icon must be square.`);
70
+ } else if (size.width < MIN_PNG_SIZE || size.width > MAX_PNG_SIZE) {
71
+ errors.push(`icon: ${icon} is ${size.width}×${size.height}; a PNG icon must be ${MIN_PNG_SIZE}–${MAX_PNG_SIZE} px (256×256 is a good size).`);
72
+ }
73
+ }
74
+ return { errors, warnings, file: errors.length ? null : { path: iconUploadPath(icon), size: data.length, data } };
75
+ }
76
+
77
+ /** A PNG's width and height from its IHDR chunk; null when it isn't a PNG. */
78
+ export function pngSize(data) {
79
+ const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
80
+ if (data.length < 24 || !data.subarray(0, 8).equals(signature) || data.toString('ascii', 12, 16) !== 'IHDR') return null;
81
+ return { width: data.readUInt32BE(16), height: data.readUInt32BE(20) };
82
+ }
@@ -0,0 +1,116 @@
1
+ // Release channels of the Hawk Apps packages. `latest` (from main) is stable and matches the prod platform; `dev`
2
+ // (prereleases from the dev branch, e.g. 0.7.0-dev.20261014…) has features that may need the dev platform.
3
+ // - `hawk-app create` from a dev CLI (npx …-cli@dev create) makes a dev app: the Hawk packages on the `dev` dist-tag
4
+ // (the vanilla template's CDN import too), so it gets the dev bridge, not the latest one the template pins.
5
+ // - `hawk-app upgrade --channel latest|dev` moves an app between channels.
6
+ import { spawnSync } from 'node:child_process';
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { readFile, writeFile } from 'node:fs/promises';
9
+ import { join } from 'node:path';
10
+
11
+ export const BRIDGE = '@bridgeline-digital/hawk-app-bridge';
12
+ export const CLI = '@bridgeline-digital/hawk-app-cli';
13
+ /** The UI kit (next-mui apps): released with the bridge and CLI, so it follows the same channel. */
14
+ export const UI = '@bridgeline-digital/hawk-app-ui';
15
+ export const HAWK_PACKAGES = [BRIDGE, UI, CLI];
16
+ export const CHANNELS = ['latest', 'dev'];
17
+
18
+ /**
19
+ * The vanilla template's imports from the CDN, the bridge and the UI kit (one version: they're released together): by
20
+ * minor (`@0.6/`) or by dist-tag (`@dev/`).
21
+ */
22
+ export const CDN_BRIDGE = /(hawk-app-(?:bridge|ui)@)([0-9]+\.[0-9]+|dev)(\/)/;
23
+
24
+ /** The vanilla template's files that load from the CDN: the bridge and the kit's elements (app.js), the kit's stylesheet (index.html). */
25
+ export const CDN_FILES = [join('public', 'app.js'), join('public', 'index.html')];
26
+
27
+ /** `text` with every CDN import of the bridge and the UI kit at `version` (a minor or a dist-tag). */
28
+ export const pointCdn = (text, version) => text.replace(new RegExp(CDN_BRIDGE.source, 'g'), `$1${version}$3`);
29
+
30
+ /** Points the app's CDN files (those that exist) at `version`; returns the files changed. */
31
+ export async function writeCdnFiles(dir, version) {
32
+ const changed = [];
33
+ for (const file of CDN_FILES) {
34
+ const path = join(dir, file);
35
+ if (!existsSync(path)) continue;
36
+ const text = await readFile(path, 'utf8');
37
+ const next = pointCdn(text, version);
38
+ if (next !== text) {
39
+ await writeFile(path, next);
40
+ changed.push(file);
41
+ }
42
+ }
43
+ return changed;
44
+ }
45
+
46
+ const isPrerelease = (version) => /^\d+\.\d+\.\d+-/.test(String(version ?? '').trim());
47
+
48
+ /** This CLI's version, e.g. 0.7.0 or 0.8.0-dev.20261014120000. */
49
+ export const cliVersion = () => JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
50
+
51
+ /** The channel this CLI came from: a prerelease is a dev build. */
52
+ export const cliChannel = (version = cliVersion()) => (isPrerelease(version) ? 'dev' : 'latest');
53
+
54
+ /** A package.json version spec on the dev channel: the `dev` dist-tag or a prerelease range. */
55
+ export const isDevSpec = (spec) => {
56
+ const text = String(spec ?? '').trim();
57
+ return text === 'dev' || isPrerelease(text.replace(/^[\^~>=v\s]+/, ''));
58
+ };
59
+
60
+ /** The app's channel from its package.json (dev when any Hawk package is on dev), or null without them. */
61
+ export function appChannel(pkg) {
62
+ const specs = HAWK_PACKAGES.map((name) => pkg?.dependencies?.[name] ?? pkg?.devDependencies?.[name]).filter(Boolean);
63
+ if (!specs.length) return null;
64
+ return specs.some(isDevSpec) ? 'dev' : 'latest';
65
+ }
66
+
67
+ /** The version npm's `latest` tag points at, e.g. "0.6.0"; null when npm can't be asked (offline, no npm). */
68
+ export function npmLatest(name, run = spawnSync) {
69
+ const result = run('npm', ['view', `${name}@latest`, 'version'], { encoding: 'utf8', shell: process.platform === 'win32' });
70
+ const version = String(result?.stdout ?? '').trim().split(/\s+/).pop();
71
+ return result?.status === 0 && /^\d+\.\d+\.\d+$/.test(version) ? version : null;
72
+ }
73
+
74
+ /**
75
+ * The changes that put a package.json (and the vanilla CDN import) on `channel`: [{ name, section, from, to }] and
76
+ * { from, to } for the CDN. `latest` uses npm's latest version (`fallback` when npm can't be asked: the template's).
77
+ */
78
+ export function planChannel({ pkg, appJs, channel, latestVersion }) {
79
+ if (!CHANNELS.includes(channel)) throw new Error(`--channel must be latest or dev, not "${channel}".`);
80
+ const dependencies = [];
81
+ for (const name of HAWK_PACKAGES) {
82
+ const section = ['dependencies', 'devDependencies'].find((s) => pkg?.[s]?.[name] !== undefined);
83
+ if (!section) continue;
84
+ const from = pkg[section][name];
85
+ let to;
86
+ if (channel === 'dev') {
87
+ to = 'dev';
88
+ } else {
89
+ const version = latestVersion(name);
90
+ if (!version) throw new Error(`Couldn't find the latest version of ${name} on npm. Check your connection and try again.`);
91
+ to = `^${version}`;
92
+ }
93
+ if (from !== to) dependencies.push({ name, section, from, to });
94
+ }
95
+ let cdn = null;
96
+ const current = appJs ? CDN_BRIDGE.exec(appJs)?.[2] : null;
97
+ if (current) {
98
+ const version = channel === 'dev' ? 'dev' : latestVersion(BRIDGE)?.split('.').slice(0, 2).join('.');
99
+ if (!version) throw new Error(`Couldn't find the latest version of ${BRIDGE} on npm. Check your connection and try again.`);
100
+ if (current !== version) cdn = { from: current, to: version };
101
+ }
102
+ return { channel, dependencies, cdn };
103
+ }
104
+
105
+ /** Applies planChannel's changes to the package.json object and app.js text; returns the new app.js text. */
106
+ export function applyChannel(plan, pkg, appJs) {
107
+ for (const { name, section, to } of plan.dependencies) pkg[section][name] = to;
108
+ return plan.cdn && appJs ? pointCdn(appJs, plan.cdn.to) : appJs;
109
+ }
110
+
111
+ /** What a dev app needs to know (create and upgrade print it). */
112
+ export const DEV_CHANNEL_NOTE = [
113
+ 'This app is on the dev channel: the bridge, the UI kit and the CLI follow their newest dev builds (the `dev` tag on npm).',
114
+ "Dev features may need the dev platform (the default): don't deploy it to prod until they're released.",
115
+ 'When they are, move it to the released versions: npx hawk-app upgrade --channel latest --yes',
116
+ ];