@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/app-icon.mjs CHANGED
@@ -1,82 +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
- }
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
+ }
package/src/channel.mjs CHANGED
@@ -1,116 +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
- ];
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
+ ];