@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/upgrade.mjs CHANGED
@@ -1,406 +1,406 @@
1
- // `hawk-app upgrade`: brings an app's template-owned parts up to the template this CLI ships, so apps don't drift
2
- // behind it (e.g. on a Next.js with a known vulnerability). What it touches, and nothing else:
3
- // - package.json: the packages the template itself pins, raised to the template's version (never lowered); the app's
4
- // other dependencies are left alone. Packages the template added later that apps need (ADDED_PACKAGES: the UI kit)
5
- // are added, with what to change in the app's code.
6
- // - The vanilla template's bridge import from the CDN (public/app.js).
7
- // - The template's config files (TEMPLATE_CONFIG_FILES), only when the app's copy is one the template has shipped
8
- // (src/template-history.json): the developer never edited it. An edited one is left as is, with a diff to look at.
9
- // - The CLI's part of AGENTS.md and CLAUDE.md's import of it (agents.mjs), also for apps without a template.
10
- // - With --channel latest|dev, first moves the bridge and CLI to that release channel (see channel.mjs).
11
- // Without --yes it only shows what would change.
12
- import { spawnSync } from 'node:child_process';
13
- import { existsSync, readFileSync } from 'node:fs';
14
- import { readFile, writeFile } from 'node:fs/promises';
15
- import { join } from 'node:path';
16
- import { fileURLToPath } from 'node:url';
17
- import { describeAgentFile, isAgentChange, planAgentFiles, writeAgentFiles } from './agents.mjs';
18
- import { appChannel, applyChannel, DEV_CHANNEL_NOTE, HAWK_PACKAGES, isDevSpec, npmLatest, planChannel, UI, writeCdnFiles } from './channel.mjs';
19
-
20
- /**
21
- * Packages a template gained after apps were made from it, which those apps should have too: `upgrade` adds them (the
22
- * app's code still has to start using them: `notes`). Only when the template has them.
23
- */
24
- export const ADDED_PACKAGES = {
25
- [UI]: {
26
- 'next-mui': [
27
- 'The UI kit gives the app Spark\'s current theme (in the host\'s colors and mode), the provider and shared components.',
28
- 'Start using it: in src/app/providers.tsx render <HawkAppProvider> (from @bridgeline-digital/hawk-app-ui) instead of',
29
- ' the ThemeProvider, read the app with useHawk() instead of src/lib/useHawkApp.ts, and import',
30
- ' @bridgeline-digital/hawk-app-ui/fonts in src/app/layout.tsx. Then delete src/theme.ts and src/lib/useHawkApp.ts,',
31
- ' and @fontsource/source-sans-3 if nothing else uses it. See the kit\'s README for its components.',
32
- ],
33
- // react, preact, next: the stylesheet and the custom elements.
34
- default: [
35
- 'The UI kit gives the app Spark\'s look (in the host\'s colors and mode): a stylesheet with hawk-* classes, and elements',
36
- ' (<hawk-kpi>, <hawk-data-table>, <hawk-bar-list>, <hawk-column-chart>).',
37
- 'Start using it: import \'@bridgeline-digital/hawk-app-ui/css\' and \'@bridgeline-digital/hawk-app-ui/elements\' where',
38
- ' the app starts (src/main.tsx; Next: the css in src/app/layout.tsx, the elements in a client component), copy',
39
- ' src/hawk-elements.d.ts from a new app for the elements\' JSX types, and move your own styles to the hawk-* classes.',
40
- ' See the kit\'s README ("Without React and MUI").',
41
- ],
42
- },
43
- };
44
-
45
- /** What to change in the app's code to start using an added package, for its template. */
46
- const notesFor = (notes, template) => (Array.isArray(notes) ? notes : (notes[template] ?? notes.default ?? []));
47
-
48
- /** Template files `upgrade` may replace: build and compiler configuration, never app code. */
49
- export const TEMPLATE_CONFIG_FILES = ['next.config.ts', 'vite.config.ts', 'tsconfig.json'];
50
-
51
- const TEMPLATES_DIR = fileURLToPath(new URL('../templates', import.meta.url));
52
- const HISTORY_FILE = fileURLToPath(new URL('./template-history.json', import.meta.url));
53
- const BRIDGE = '@bridgeline-digital/hawk-app-bridge';
54
- /** The vanilla template's CDN imports (the bridge, the UI kit), by minor version. */
55
- const CDN_IMPORT = /(hawk-app-(?:bridge|ui)@)(\d+\.\d+)(\/)/;
56
-
57
- /** Line endings and trailing blank lines don't count as an edit. */
58
- export function normalizeText(text) {
59
- return String(text).replace(/\r\n/g, '\n').replace(/\s+$/, '') + '\n';
60
- }
61
-
62
- const cliVersion = () => JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
63
-
64
- /** A template file as `hawk-app create` writes it for this app. */
65
- function render(text, { slug, name }) {
66
- const values = { __SLUG__: slug, __NAME__: name, __CLI_VERSION__: cliVersion() };
67
- return text.replace(/__SLUG__|__NAME__|__CLI_VERSION__/g, (m) => values[m] ?? m);
68
- }
69
-
70
- /** The lowest version a range allows (`^16.4.0`, `~1.2.3`, `>=2.0.0`, `16.4.0`), or null when it isn't a plain range. */
71
- export function minVersion(range) {
72
- const text = String(range ?? '').trim();
73
- if (!text || /[|*xX]|\s-\s|^(file|git|http|https|link|workspace|npm):/.test(text)) return null;
74
- const match = /^(?:\^|~|>=|=)?\s*v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(text);
75
- return match ? { parts: match.slice(1, 4).map(Number), pre: match[4] ?? null } : null;
76
- }
77
-
78
- /** Semver order of two minVersion results (a prerelease sorts before its release). */
79
- export function compareVersions(a, b) {
80
- for (let i = 0; i < 3; i++) if (a.parts[i] !== b.parts[i]) return a.parts[i] - b.parts[i];
81
- if (a.pre === b.pre) return 0;
82
- if (a.pre === null) return 1;
83
- if (b.pre === null) return -1;
84
- return a.pre < b.pre ? -1 : 1;
85
- }
86
-
87
- /** Which template an app comes from: hawk-app.json's `template`, else a guess from its dependencies. */
88
- export function detectTemplate(dir, manifest) {
89
- if (manifest?.template) return { template: manifest.template, source: 'hawk-app.json' };
90
- const pkgFile = join(dir, 'package.json');
91
- const pkg = existsSync(pkgFile) ? JSON.parse(readFileSync(pkgFile, 'utf8')) : {};
92
- const deps = { ...pkg.dependencies, ...pkg.devDependencies };
93
- const guessed = deps.next
94
- ? deps['@mui/material'] ? 'next-mui' : 'next'
95
- : deps.vite && deps.preact
96
- ? 'preact'
97
- : deps.vite && deps.react
98
- ? 'react'
99
- : existsSync(join(dir, 'public', 'app.js')) && CDN_IMPORT.test(readFileSync(join(dir, 'public', 'app.js'), 'utf8'))
100
- ? 'vanilla'
101
- : null;
102
- return guessed ? { template: guessed, source: 'guessed from the dependencies' } : null;
103
- }
104
-
105
- /**
106
- * What `upgrade` would change: { template, source, dependencies: [{ name, section, from, to }], upToDate: [names],
107
- * cdn: { from, to } | null, files: [{ file, state: 'current' | 'replace' | 'edited' | 'missing', diff? }] }.
108
- */
109
- export function planUpgrade({ dir, manifest, template: forced }) {
110
- const found = forced ? { template: forced, source: '--template' } : detectTemplate(dir, manifest);
111
- if (!found) throw new Error("Can't tell which template this app comes from. Pass --template <react|preact|next|next-mui|vanilla>.");
112
- const { template, source } = found;
113
- const templateDir = join(TEMPLATES_DIR, template);
114
- if (!existsSync(templateDir)) throw new Error(`Unknown template "${template}". Choose one of: react, preact, next, next-mui, vanilla.`);
115
- const values = { slug: manifest?.slug ?? '', name: manifest?.name ?? '' };
116
-
117
- // Dependencies the template pins, raised to the template's version.
118
- const templatePkg = JSON.parse(render(readFileSync(join(templateDir, 'package.json'), 'utf8'), values));
119
- const pkgFile = join(dir, 'package.json');
120
- const pkg = existsSync(pkgFile) ? JSON.parse(readFileSync(pkgFile, 'utf8')) : {};
121
- const dependencies = [];
122
- const upToDate = [];
123
- for (const [name, to] of Object.entries({ ...templatePkg.dependencies, ...templatePkg.devDependencies })) {
124
- const section = ['dependencies', 'devDependencies'].find((s) => pkg[s]?.[name] !== undefined);
125
- if (!section) continue;
126
- const from = pkg[section][name];
127
- // The bridge and CLI on dev builds follow the dev channel, not the template (`upgrade --channel latest` moves them).
128
- if (HAWK_PACKAGES.includes(name) && isDevSpec(from)) continue;
129
- const [have, want] = [minVersion(from), minVersion(to)];
130
- if (!have || !want) continue;
131
- if (compareVersions(have, want) < 0) dependencies.push({ name, section, from, to, major: have.parts[0] !== want.parts[0] });
132
- else upToDate.push(name);
133
- }
134
-
135
- // Packages the template gained since: added, with what to change in the app. A Hawk package joins an app whose
136
- // bridge is on a dev build on the dev channel too: the template's release wouldn't install next to that bridge (its
137
- // peer range). Only the bridge counts: a CLI pinned to a prerelease (an app made by a dev CLI) doesn't clash.
138
- const additions = [];
139
- const devBridge = isDevSpec(pkg.dependencies?.[BRIDGE] ?? pkg.devDependencies?.[BRIDGE]);
140
- for (const [name, notes] of Object.entries(ADDED_PACKAGES)) {
141
- const pinned = templatePkg.dependencies?.[name];
142
- if (!pinned || pkg.dependencies?.[name] !== undefined || pkg.devDependencies?.[name] !== undefined) continue;
143
- additions.push({ name, section: 'dependencies', to: devBridge && HAWK_PACKAGES.includes(name) ? 'dev' : pinned, notes: notesFor(notes, template) });
144
- }
145
-
146
- // The vanilla template's bridge comes from the CDN, by minor version.
147
- let cdn = null;
148
- const appJs = join(dir, 'public', 'app.js');
149
- const templateAppJs = join(templateDir, 'public', 'app.js');
150
- if (existsSync(appJs) && existsSync(templateAppJs)) {
151
- const [have, want] = [CDN_IMPORT.exec(readFileSync(appJs, 'utf8'))?.[2], CDN_IMPORT.exec(readFileSync(templateAppJs, 'utf8'))?.[2]];
152
- if (have && want && compareVersions(minVersion(`${have}.0`), minVersion(`${want}.0`)) < 0) cdn = { from: have, to: want };
153
- }
154
-
155
- // Config files: replaced only when unedited (one of the versions the template has shipped).
156
- const history = JSON.parse(readFileSync(HISTORY_FILE, 'utf8'));
157
- const files = [];
158
- for (const file of TEMPLATE_CONFIG_FILES) {
159
- const templateFile = join(templateDir, file);
160
- if (!existsSync(templateFile)) continue;
161
- const current = normalizeText(render(readFileSync(templateFile, 'utf8'), values));
162
- const appFile = join(dir, file);
163
- if (!existsSync(appFile)) {
164
- files.push({ file, state: 'missing' });
165
- continue;
166
- }
167
- const mine = normalizeText(readFileSync(appFile, 'utf8'));
168
- if (mine === current) {
169
- files.push({ file, state: 'current' });
170
- } else if ((history[`${template}/${file}`] ?? []).some((shipped) => normalizeText(render(shipped, values)) === mine)) {
171
- files.push({ file, state: 'replace', content: current });
172
- } else {
173
- files.push({ file, state: 'edited', diff: lineDiff(mine, current) });
174
- }
175
- }
176
-
177
- return { template, source, dependencies, additions, upToDate, cdn, files, recordTemplate: !manifest?.template };
178
- }
179
-
180
- /** Whether the plan changes anything. */
181
- export const hasChanges = (plan) => plan.dependencies.length > 0 || plan.additions.length > 0 || plan.cdn !== null || plan.files.some((f) => f.state === 'replace') || plan.recordTemplate;
182
-
183
- /** A short unified-style diff (- the app's lines, + the template's), for an edited config file. */
184
- export function lineDiff(before, after, maxLines = 40) {
185
- const a = before.replace(/\n$/, '').split('\n');
186
- const b = after.replace(/\n$/, '').split('\n');
187
- // Longest common subsequence: small files only.
188
- const lcs = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
189
- for (let i = a.length - 1; i >= 0; i--) for (let j = b.length - 1; j >= 0; j--) lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
190
- const out = [];
191
- let i = 0;
192
- let j = 0;
193
- while (i < a.length || j < b.length) {
194
- if (i < a.length && j < b.length && a[i] === b[j]) {
195
- i++;
196
- j++;
197
- } else if (j < b.length && (i === a.length || lcs[i][j + 1] >= lcs[i + 1][j])) {
198
- out.push(`+ ${b[j++]}`);
199
- } else {
200
- out.push(`- ${a[i++]}`);
201
- }
202
- }
203
- return out.length > maxLines ? [...out.slice(0, maxLines), ` … ${out.length - maxLines} more lines`] : out;
204
- }
205
-
206
- /** The one-line hint `hawk-app deploy` shows when the app's dependencies are behind its template; null when they aren't. */
207
- export function upgradeHint(dir, manifest) {
208
- const plan = planUpgrade({ dir, manifest });
209
- const behind = plan.dependencies.map((d) => `${d.name} ${d.from} → ${d.to}`);
210
- if (plan.cdn) behind.push(`bridge and UI kit ${plan.cdn.from} → ${plan.cdn.to} (CDN)`);
211
- for (const a of plan.additions) behind.push(`${a.name} (new)`);
212
- if (!behind.length) return null;
213
- const shown = behind.slice(0, 3).join(', ') + (behind.length > 3 ? `, +${behind.length - 3} more` : '');
214
- return `! This app is behind the ${plan.template} template (${shown}). See what would change: npx hawk-app upgrade`;
215
- }
216
-
217
- /**
218
- * Runs `npm install` in `cwd`. With `packages` ([{ name, section, to }], a channel switch) it installs them by name
219
- * (`npm install <name>@<to>`, `-D` for devDependencies): a plain install keeps what package-lock.json has whenever it
220
- * satisfies the spec, and a dist-tag such as `dev` is satisfied by any version, so the packages would never move.
221
- */
222
- export function npmInstall({ cwd, packages = [] }) {
223
- const win = process.platform === 'win32';
224
- for (const args of installArgs(packages, win)) {
225
- const result = spawnSync('npm', args, { cwd, stdio: 'inherit', shell: win });
226
- if (result.status !== 0) throw new Error('npm install failed. Fix it, then run `npm install` again.');
227
- }
228
- }
229
-
230
- /**
231
- * The `npm` runs that install `packages` by name (dependencies, then `-D` devDependencies), or one plain install. On
232
- * Windows npm runs through cmd.exe, where `^` is an escape character: the specs are quoted so `^0.7.2` reaches npm.
233
- */
234
- export function installArgs(packages, win = process.platform === 'win32') {
235
- const base = ['install', '--no-audit', '--no-fund'];
236
- const spec = ({ name, to }) => (win ? `"${name}@${to}"` : `${name}@${to}`);
237
- const deps = packages.filter((p) => p.section === 'dependencies').map(spec);
238
- const devDeps = packages.filter((p) => p.section === 'devDependencies').map(spec);
239
- const runs = [];
240
- if (deps.length) runs.push([...base, ...deps]);
241
- if (devDeps.length) runs.push([...base, '-D', ...devDeps]);
242
- return runs.length ? runs : [base];
243
- }
244
-
245
- /** `hawk-app upgrade [--yes] [--template <name>] [--no-install]`. */
246
- /**
247
- * Moves the app's bridge and CLI to a release channel (`--channel`): `dev` follows the dev tag, `latest` the released
248
- * versions (^ npm's latest). Shows the change; applies it with `yes`. Returns the state, 'none', 'pending' (not
249
- * applied) or 'applied', and the packages moved (to install by name).
250
- */
251
- async function switchChannel({ dir, channel, yes, log, latestVersion }) {
252
- const pkgFile = join(dir, 'package.json');
253
- const appJsFile = join(dir, 'public', 'app.js');
254
- const pkg = existsSync(pkgFile) ? JSON.parse(await readFile(pkgFile, 'utf8')) : null;
255
- const appJs = existsSync(appJsFile) ? await readFile(appJsFile, 'utf8') : null;
256
- const from = appChannel(pkg);
257
- const plan = planChannel({ pkg, appJs, channel, latestVersion });
258
- if (!plan.dependencies.length && !plan.cdn) {
259
- log(`Channel: already on ${channel}.`);
260
- return { state: 'none', packages: [] };
261
- }
262
- // Already on dev (a dev build, as npm writes it): `--channel dev` moves to the newest dev builds.
263
- log(from === channel ? `Channel: ${channel}, to its newest builds` : `Channel: ${from ?? 'none'} → ${channel}`);
264
- for (const d of plan.dependencies) log(` ${d.name}: ${d.from} → ${d.to}`);
265
- if (plan.cdn) log(` public/app.js, index.html: the bridge and UI kit from the CDN @${plan.cdn.from} → @${plan.cdn.to}`);
266
- if (!yes) return { state: 'pending', packages: [] };
267
- applyChannel(plan, pkg, appJs);
268
- if (plan.dependencies.length) await writeFile(pkgFile, JSON.stringify(pkg, null, 2) + '\n');
269
- if (plan.cdn) await writeCdnFiles(dir, plan.cdn.to);
270
- log(`✔ Moved to the ${channel} channel.`);
271
- if (channel === 'dev') DEV_CHANNEL_NOTE.slice(0, 2).forEach((line) => log(` ${line}`));
272
- return { state: 'applied', packages: plan.dependencies };
273
- }
274
-
275
- export async function upgrade({ dir = process.cwd(), manifestFile = 'hawk-app.json', template, yes = false, install = true, runInstall = npmInstall, log = console.log, channel, latestVersion = npmLatest }) {
276
- const { state: channelState, packages: channelPackages } = channel ? await switchChannel({ dir, channel, yes, log, latestVersion }) : { state: 'none', packages: [] };
277
- const channelChanged = channelState === 'applied';
278
- const applyHint = `\nNothing was changed. Run \`npx hawk-app upgrade${channel ? ` --channel ${channel}` : ''} --yes\` to apply this, then build, test and deploy.`;
279
- const manifestPath = join(dir, manifestFile);
280
- const manifest = existsSync(manifestPath) ? JSON.parse(await readFile(manifestPath, 'utf8')) : null;
281
- // An app set up without a template (hawk-app init) still gets its AI assistant files refreshed.
282
- const plan = template || detectTemplate(dir, manifest) ? planUpgrade({ dir, manifest, template }) : null;
283
- if (!plan && !manifest) planUpgrade({ dir, manifest, template }); // throws: nothing to upgrade
284
- const agents = manifest ? planAgentFiles(dir, { slug: manifest.slug, name: manifest.name, template: plan?.template }) : [];
285
- const agentsChanged = agents.some(isAgentChange);
286
-
287
- if (plan) {
288
- logTemplatePlan(plan, manifest, manifestFile, log);
289
- } else {
290
- log('Template: none (this app was set up without one; pass --template <react|preact|next|next-mui|vanilla> if it follows one).');
291
- }
292
- for (const f of agents) {
293
- const line = describeAgentFile(f);
294
- if (line) log(` ${line}`);
295
- }
296
-
297
- const templateChanged = !!plan && hasChanges(plan);
298
- if (!templateChanged && !agentsChanged) {
299
- log(plan ? `✔ Up to date with the ${plan.template} template.` : '✔ Up to date.');
300
- if (channelState === 'pending') log(applyHint);
301
- if (channelChanged && install) {
302
- log(' Installing…');
303
- await runInstall({ cwd: dir, packages: channelPackages });
304
- }
305
- return { plan, agents, applied: channelChanged };
306
- }
307
- if (!yes) {
308
- log(applyHint);
309
- return { plan, agents, applied: false };
310
- }
311
-
312
- if (templateChanged) await applyTemplatePlan(plan, { dir, manifest, manifestPath, manifestFile, log });
313
- if (agentsChanged) {
314
- const written = await writeAgentFiles(dir, agents);
315
- log(`✔ Updated ${written.join(', ')}.`);
316
- }
317
-
318
- if (install && (channelChanged || (templateChanged && (plan.dependencies.length || plan.additions.length || plan.cdn)))) {
319
- log(' Installing…');
320
- await runInstall({ cwd: dir, packages: channelPackages });
321
- }
322
- if (templateChanged || channelChanged) log(' Next: build and test the app (npm run build, npm run dev), then deploy.');
323
- return { plan, agents, applied: true };
324
- }
325
-
326
- function logTemplatePlan(plan, manifest, manifestFile, log) {
327
- log(`Template: ${plan.template} (${plan.source}${plan.source === 'guessed from the dependencies' ? '; pass --template to choose another' : ''})`);
328
- if (plan.dependencies.length || plan.additions.length || plan.cdn) {
329
- log('Dependencies:');
330
- const width = Math.max(...plan.dependencies.map((d) => d.name.length), ...plan.additions.map((a) => a.name.length), 6);
331
- for (const d of plan.dependencies) log(` ${d.name.padEnd(width)} ${d.from} → ${d.to}${d.major ? ' (major version: check its migration guide)' : ''}`);
332
- for (const a of plan.additions) log(` ${a.name.padEnd(width)} (new) ${a.to}`);
333
- if (plan.cdn) log(` ${'bridge and UI kit (CDN, public/)'.padEnd(width)} @${plan.cdn.from} → @${plan.cdn.to}`);
334
- const mui = plan.dependencies.find((d) => d.major && d.name === '@mui/material');
335
- if (mui) log(` Material UI ${mui.from} → ${mui.to} is a major upgrade: see https://mui.com/material-ui/migration/ for what changed.`);
336
- for (const a of plan.additions) for (const line of a.notes) log(` ${line}`);
337
- } else {
338
- log('Dependencies: up to date with the template.');
339
- }
340
- if (plan.recordTemplate && manifest) log(` ${manifestFile}: records "template": "${plan.template}", so later upgrades don't guess.`);
341
- for (const f of plan.files) {
342
- if (f.state === 'replace') log(` ${f.file}: unchanged since the template created it, so it's replaced with the template's current version.`);
343
- if (f.state === 'missing') log(` ${f.file}: not in this app (not added).`);
344
- if (f.state === 'edited') {
345
- log(` ${f.file}: you've changed it, so it's left alone. The template's current version differs (- yours, + template):`);
346
- for (const line of f.diff) log(` ${line}`);
347
- }
348
- }
349
- }
350
-
351
- async function applyTemplatePlan(plan, { dir, manifest, manifestPath, manifestFile, log }) {
352
- // package.json: only the version strings change, so its formatting stays.
353
- if (plan.dependencies.length) {
354
- const pkgFile = join(dir, 'package.json');
355
- let text = await readFile(pkgFile, 'utf8');
356
- for (const d of plan.dependencies) {
357
- const pattern = new RegExp(`("${d.name.replace(/[/.@-]/g, '\\$&')}"\\s*:\\s*")([^"]*)(")`);
358
- text = text.replace(pattern, `$1${d.to}$3`);
359
- }
360
- await writeFile(pkgFile, text);
361
- }
362
- if (plan.additions.length) {
363
- const pkgFile = join(dir, 'package.json');
364
- await writeFile(pkgFile, addDependencies(await readFile(pkgFile, 'utf8'), plan.additions));
365
- }
366
- if (plan.cdn) await writeCdnFiles(dir, plan.cdn.to);
367
- for (const f of plan.files.filter((x) => x.state === 'replace')) {
368
- const appFile = join(dir, f.file);
369
- const eol = (await readFile(appFile, 'utf8')).includes('\r\n') ? '\r\n' : '\n';
370
- await writeFile(appFile, f.content.replace(/\n/g, eol));
371
- }
372
- if (plan.recordTemplate && manifest) {
373
- await writeFile(manifestPath, recordTemplate(await readFile(manifestPath, 'utf8'), plan.template));
374
- }
375
- log(`✔ Upgraded to the ${plan.template} template.${plan.recordTemplate && manifest ? ` Recorded "template": "${plan.template}" in ${manifestFile}.` : ''}`);
376
- }
377
-
378
- /**
379
- * package.json with `additions` ([{ name, section, to }]) as the first entries of their section, in its indentation and
380
- * line endings (the section is created when missing; then the JSON is rewritten).
381
- */
382
- export function addDependencies(text, additions) {
383
- let out = text;
384
- for (const { name, section, to } of additions) {
385
- const match = new RegExp(`("${section}"\\s*:\\s*\\{)(\\r?\\n)([ \\t]*)`).exec(out);
386
- if (match) {
387
- const [all, open, eol, indent] = match;
388
- out = out.slice(0, match.index) + `${open}${eol}${indent}"${name}": "${to}",${eol}${indent}` + out.slice(match.index + all.length);
389
- } else {
390
- const pkg = JSON.parse(out);
391
- pkg[section] = { [name]: to, ...pkg[section] };
392
- out = JSON.stringify(pkg, null, 2) + '\n';
393
- }
394
- }
395
- return out;
396
- }
397
-
398
- /** hawk-app.json with `"template"` added after `"slug"`, keeping its formatting (falls back to rewriting the JSON). */
399
- export function recordTemplate(text, template) {
400
- const match = /^([ \t]*)"slug"\s*:\s*"[^"]*",?[ \t]*\r?\n/m.exec(text);
401
- if (match && match[0].trimEnd().endsWith(',')) {
402
- const eol = match[0].endsWith('\r\n') ? '\r\n' : '\n';
403
- return text.slice(0, match.index + match[0].length) + `${match[1]}"template": "${template}",${eol}` + text.slice(match.index + match[0].length);
404
- }
405
- return JSON.stringify({ ...JSON.parse(text), template }, null, 2) + '\n';
406
- }
1
+ // `hawk-app upgrade`: brings an app's template-owned parts up to the template this CLI ships, so apps don't drift
2
+ // behind it (e.g. on a Next.js with a known vulnerability). What it touches, and nothing else:
3
+ // - package.json: the packages the template itself pins, raised to the template's version (never lowered); the app's
4
+ // other dependencies are left alone. Packages the template added later that apps need (ADDED_PACKAGES: the UI kit)
5
+ // are added, with what to change in the app's code.
6
+ // - The vanilla template's bridge import from the CDN (public/app.js).
7
+ // - The template's config files (TEMPLATE_CONFIG_FILES), only when the app's copy is one the template has shipped
8
+ // (src/template-history.json): the developer never edited it. An edited one is left as is, with a diff to look at.
9
+ // - The CLI's part of AGENTS.md and CLAUDE.md's import of it (agents.mjs), also for apps without a template.
10
+ // - With --channel latest|dev, first moves the bridge and CLI to that release channel (see channel.mjs).
11
+ // Without --yes it only shows what would change.
12
+ import { spawnSync } from 'node:child_process';
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import { readFile, writeFile } from 'node:fs/promises';
15
+ import { join } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { describeAgentFile, isAgentChange, planAgentFiles, writeAgentFiles } from './agents.mjs';
18
+ import { appChannel, applyChannel, DEV_CHANNEL_NOTE, HAWK_PACKAGES, isDevSpec, npmLatest, planChannel, UI, writeCdnFiles } from './channel.mjs';
19
+
20
+ /**
21
+ * Packages a template gained after apps were made from it, which those apps should have too: `upgrade` adds them (the
22
+ * app's code still has to start using them: `notes`). Only when the template has them.
23
+ */
24
+ export const ADDED_PACKAGES = {
25
+ [UI]: {
26
+ 'next-mui': [
27
+ 'The UI kit gives the app Spark\'s current theme (in the host\'s colors and mode), the provider and shared components.',
28
+ 'Start using it: in src/app/providers.tsx render <HawkAppProvider> (from @bridgeline-digital/hawk-app-ui) instead of',
29
+ ' the ThemeProvider, read the app with useHawk() instead of src/lib/useHawkApp.ts, and import',
30
+ ' @bridgeline-digital/hawk-app-ui/fonts in src/app/layout.tsx. Then delete src/theme.ts and src/lib/useHawkApp.ts,',
31
+ ' and @fontsource/source-sans-3 if nothing else uses it. See the kit\'s README for its components.',
32
+ ],
33
+ // react, preact, next: the stylesheet and the custom elements.
34
+ default: [
35
+ 'The UI kit gives the app Spark\'s look (in the host\'s colors and mode): a stylesheet with hawk-* classes, and elements',
36
+ ' (<hawk-kpi>, <hawk-data-table>, <hawk-bar-list>, <hawk-column-chart>).',
37
+ 'Start using it: import \'@bridgeline-digital/hawk-app-ui/css\' and \'@bridgeline-digital/hawk-app-ui/elements\' where',
38
+ ' the app starts (src/main.tsx; Next: the css in src/app/layout.tsx, the elements in a client component), copy',
39
+ ' src/hawk-elements.d.ts from a new app for the elements\' JSX types, and move your own styles to the hawk-* classes.',
40
+ ' See the kit\'s README ("Without React and MUI").',
41
+ ],
42
+ },
43
+ };
44
+
45
+ /** What to change in the app's code to start using an added package, for its template. */
46
+ const notesFor = (notes, template) => (Array.isArray(notes) ? notes : (notes[template] ?? notes.default ?? []));
47
+
48
+ /** Template files `upgrade` may replace: build and compiler configuration, never app code. */
49
+ export const TEMPLATE_CONFIG_FILES = ['next.config.ts', 'vite.config.ts', 'tsconfig.json'];
50
+
51
+ const TEMPLATES_DIR = fileURLToPath(new URL('../templates', import.meta.url));
52
+ const HISTORY_FILE = fileURLToPath(new URL('./template-history.json', import.meta.url));
53
+ const BRIDGE = '@bridgeline-digital/hawk-app-bridge';
54
+ /** The vanilla template's CDN imports (the bridge, the UI kit), by minor version. */
55
+ const CDN_IMPORT = /(hawk-app-(?:bridge|ui)@)(\d+\.\d+)(\/)/;
56
+
57
+ /** Line endings and trailing blank lines don't count as an edit. */
58
+ export function normalizeText(text) {
59
+ return String(text).replace(/\r\n/g, '\n').replace(/\s+$/, '') + '\n';
60
+ }
61
+
62
+ const cliVersion = () => JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
63
+
64
+ /** A template file as `hawk-app create` writes it for this app. */
65
+ function render(text, { slug, name }) {
66
+ const values = { __SLUG__: slug, __NAME__: name, __CLI_VERSION__: cliVersion() };
67
+ return text.replace(/__SLUG__|__NAME__|__CLI_VERSION__/g, (m) => values[m] ?? m);
68
+ }
69
+
70
+ /** The lowest version a range allows (`^16.4.0`, `~1.2.3`, `>=2.0.0`, `16.4.0`), or null when it isn't a plain range. */
71
+ export function minVersion(range) {
72
+ const text = String(range ?? '').trim();
73
+ if (!text || /[|*xX]|\s-\s|^(file|git|http|https|link|workspace|npm):/.test(text)) return null;
74
+ const match = /^(?:\^|~|>=|=)?\s*v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(text);
75
+ return match ? { parts: match.slice(1, 4).map(Number), pre: match[4] ?? null } : null;
76
+ }
77
+
78
+ /** Semver order of two minVersion results (a prerelease sorts before its release). */
79
+ export function compareVersions(a, b) {
80
+ for (let i = 0; i < 3; i++) if (a.parts[i] !== b.parts[i]) return a.parts[i] - b.parts[i];
81
+ if (a.pre === b.pre) return 0;
82
+ if (a.pre === null) return 1;
83
+ if (b.pre === null) return -1;
84
+ return a.pre < b.pre ? -1 : 1;
85
+ }
86
+
87
+ /** Which template an app comes from: hawk-app.json's `template`, else a guess from its dependencies. */
88
+ export function detectTemplate(dir, manifest) {
89
+ if (manifest?.template) return { template: manifest.template, source: 'hawk-app.json' };
90
+ const pkgFile = join(dir, 'package.json');
91
+ const pkg = existsSync(pkgFile) ? JSON.parse(readFileSync(pkgFile, 'utf8')) : {};
92
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
93
+ const guessed = deps.next
94
+ ? deps['@mui/material'] ? 'next-mui' : 'next'
95
+ : deps.vite && deps.preact
96
+ ? 'preact'
97
+ : deps.vite && deps.react
98
+ ? 'react'
99
+ : existsSync(join(dir, 'public', 'app.js')) && CDN_IMPORT.test(readFileSync(join(dir, 'public', 'app.js'), 'utf8'))
100
+ ? 'vanilla'
101
+ : null;
102
+ return guessed ? { template: guessed, source: 'guessed from the dependencies' } : null;
103
+ }
104
+
105
+ /**
106
+ * What `upgrade` would change: { template, source, dependencies: [{ name, section, from, to }], upToDate: [names],
107
+ * cdn: { from, to } | null, files: [{ file, state: 'current' | 'replace' | 'edited' | 'missing', diff? }] }.
108
+ */
109
+ export function planUpgrade({ dir, manifest, template: forced }) {
110
+ const found = forced ? { template: forced, source: '--template' } : detectTemplate(dir, manifest);
111
+ if (!found) throw new Error("Can't tell which template this app comes from. Pass --template <react|preact|next|next-mui|vanilla>.");
112
+ const { template, source } = found;
113
+ const templateDir = join(TEMPLATES_DIR, template);
114
+ if (!existsSync(templateDir)) throw new Error(`Unknown template "${template}". Choose one of: react, preact, next, next-mui, vanilla.`);
115
+ const values = { slug: manifest?.slug ?? '', name: manifest?.name ?? '' };
116
+
117
+ // Dependencies the template pins, raised to the template's version.
118
+ const templatePkg = JSON.parse(render(readFileSync(join(templateDir, 'package.json'), 'utf8'), values));
119
+ const pkgFile = join(dir, 'package.json');
120
+ const pkg = existsSync(pkgFile) ? JSON.parse(readFileSync(pkgFile, 'utf8')) : {};
121
+ const dependencies = [];
122
+ const upToDate = [];
123
+ for (const [name, to] of Object.entries({ ...templatePkg.dependencies, ...templatePkg.devDependencies })) {
124
+ const section = ['dependencies', 'devDependencies'].find((s) => pkg[s]?.[name] !== undefined);
125
+ if (!section) continue;
126
+ const from = pkg[section][name];
127
+ // The bridge and CLI on dev builds follow the dev channel, not the template (`upgrade --channel latest` moves them).
128
+ if (HAWK_PACKAGES.includes(name) && isDevSpec(from)) continue;
129
+ const [have, want] = [minVersion(from), minVersion(to)];
130
+ if (!have || !want) continue;
131
+ if (compareVersions(have, want) < 0) dependencies.push({ name, section, from, to, major: have.parts[0] !== want.parts[0] });
132
+ else upToDate.push(name);
133
+ }
134
+
135
+ // Packages the template gained since: added, with what to change in the app. A Hawk package joins an app whose
136
+ // bridge is on a dev build on the dev channel too: the template's release wouldn't install next to that bridge (its
137
+ // peer range). Only the bridge counts: a CLI pinned to a prerelease (an app made by a dev CLI) doesn't clash.
138
+ const additions = [];
139
+ const devBridge = isDevSpec(pkg.dependencies?.[BRIDGE] ?? pkg.devDependencies?.[BRIDGE]);
140
+ for (const [name, notes] of Object.entries(ADDED_PACKAGES)) {
141
+ const pinned = templatePkg.dependencies?.[name];
142
+ if (!pinned || pkg.dependencies?.[name] !== undefined || pkg.devDependencies?.[name] !== undefined) continue;
143
+ additions.push({ name, section: 'dependencies', to: devBridge && HAWK_PACKAGES.includes(name) ? 'dev' : pinned, notes: notesFor(notes, template) });
144
+ }
145
+
146
+ // The vanilla template's bridge comes from the CDN, by minor version.
147
+ let cdn = null;
148
+ const appJs = join(dir, 'public', 'app.js');
149
+ const templateAppJs = join(templateDir, 'public', 'app.js');
150
+ if (existsSync(appJs) && existsSync(templateAppJs)) {
151
+ const [have, want] = [CDN_IMPORT.exec(readFileSync(appJs, 'utf8'))?.[2], CDN_IMPORT.exec(readFileSync(templateAppJs, 'utf8'))?.[2]];
152
+ if (have && want && compareVersions(minVersion(`${have}.0`), minVersion(`${want}.0`)) < 0) cdn = { from: have, to: want };
153
+ }
154
+
155
+ // Config files: replaced only when unedited (one of the versions the template has shipped).
156
+ const history = JSON.parse(readFileSync(HISTORY_FILE, 'utf8'));
157
+ const files = [];
158
+ for (const file of TEMPLATE_CONFIG_FILES) {
159
+ const templateFile = join(templateDir, file);
160
+ if (!existsSync(templateFile)) continue;
161
+ const current = normalizeText(render(readFileSync(templateFile, 'utf8'), values));
162
+ const appFile = join(dir, file);
163
+ if (!existsSync(appFile)) {
164
+ files.push({ file, state: 'missing' });
165
+ continue;
166
+ }
167
+ const mine = normalizeText(readFileSync(appFile, 'utf8'));
168
+ if (mine === current) {
169
+ files.push({ file, state: 'current' });
170
+ } else if ((history[`${template}/${file}`] ?? []).some((shipped) => normalizeText(render(shipped, values)) === mine)) {
171
+ files.push({ file, state: 'replace', content: current });
172
+ } else {
173
+ files.push({ file, state: 'edited', diff: lineDiff(mine, current) });
174
+ }
175
+ }
176
+
177
+ return { template, source, dependencies, additions, upToDate, cdn, files, recordTemplate: !manifest?.template };
178
+ }
179
+
180
+ /** Whether the plan changes anything. */
181
+ export const hasChanges = (plan) => plan.dependencies.length > 0 || plan.additions.length > 0 || plan.cdn !== null || plan.files.some((f) => f.state === 'replace') || plan.recordTemplate;
182
+
183
+ /** A short unified-style diff (- the app's lines, + the template's), for an edited config file. */
184
+ export function lineDiff(before, after, maxLines = 40) {
185
+ const a = before.replace(/\n$/, '').split('\n');
186
+ const b = after.replace(/\n$/, '').split('\n');
187
+ // Longest common subsequence: small files only.
188
+ const lcs = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
189
+ for (let i = a.length - 1; i >= 0; i--) for (let j = b.length - 1; j >= 0; j--) lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
190
+ const out = [];
191
+ let i = 0;
192
+ let j = 0;
193
+ while (i < a.length || j < b.length) {
194
+ if (i < a.length && j < b.length && a[i] === b[j]) {
195
+ i++;
196
+ j++;
197
+ } else if (j < b.length && (i === a.length || lcs[i][j + 1] >= lcs[i + 1][j])) {
198
+ out.push(`+ ${b[j++]}`);
199
+ } else {
200
+ out.push(`- ${a[i++]}`);
201
+ }
202
+ }
203
+ return out.length > maxLines ? [...out.slice(0, maxLines), ` … ${out.length - maxLines} more lines`] : out;
204
+ }
205
+
206
+ /** The one-line hint `hawk-app deploy` shows when the app's dependencies are behind its template; null when they aren't. */
207
+ export function upgradeHint(dir, manifest) {
208
+ const plan = planUpgrade({ dir, manifest });
209
+ const behind = plan.dependencies.map((d) => `${d.name} ${d.from} → ${d.to}`);
210
+ if (plan.cdn) behind.push(`bridge and UI kit ${plan.cdn.from} → ${plan.cdn.to} (CDN)`);
211
+ for (const a of plan.additions) behind.push(`${a.name} (new)`);
212
+ if (!behind.length) return null;
213
+ const shown = behind.slice(0, 3).join(', ') + (behind.length > 3 ? `, +${behind.length - 3} more` : '');
214
+ return `! This app is behind the ${plan.template} template (${shown}). See what would change: npx hawk-app upgrade`;
215
+ }
216
+
217
+ /**
218
+ * Runs `npm install` in `cwd`. With `packages` ([{ name, section, to }], a channel switch) it installs them by name
219
+ * (`npm install <name>@<to>`, `-D` for devDependencies): a plain install keeps what package-lock.json has whenever it
220
+ * satisfies the spec, and a dist-tag such as `dev` is satisfied by any version, so the packages would never move.
221
+ */
222
+ export function npmInstall({ cwd, packages = [] }) {
223
+ const win = process.platform === 'win32';
224
+ for (const args of installArgs(packages, win)) {
225
+ const result = spawnSync('npm', args, { cwd, stdio: 'inherit', shell: win });
226
+ if (result.status !== 0) throw new Error('npm install failed. Fix it, then run `npm install` again.');
227
+ }
228
+ }
229
+
230
+ /**
231
+ * The `npm` runs that install `packages` by name (dependencies, then `-D` devDependencies), or one plain install. On
232
+ * Windows npm runs through cmd.exe, where `^` is an escape character: the specs are quoted so `^0.7.2` reaches npm.
233
+ */
234
+ export function installArgs(packages, win = process.platform === 'win32') {
235
+ const base = ['install', '--no-audit', '--no-fund'];
236
+ const spec = ({ name, to }) => (win ? `"${name}@${to}"` : `${name}@${to}`);
237
+ const deps = packages.filter((p) => p.section === 'dependencies').map(spec);
238
+ const devDeps = packages.filter((p) => p.section === 'devDependencies').map(spec);
239
+ const runs = [];
240
+ if (deps.length) runs.push([...base, ...deps]);
241
+ if (devDeps.length) runs.push([...base, '-D', ...devDeps]);
242
+ return runs.length ? runs : [base];
243
+ }
244
+
245
+ /** `hawk-app upgrade [--yes] [--template <name>] [--no-install]`. */
246
+ /**
247
+ * Moves the app's bridge and CLI to a release channel (`--channel`): `dev` follows the dev tag, `latest` the released
248
+ * versions (^ npm's latest). Shows the change; applies it with `yes`. Returns the state, 'none', 'pending' (not
249
+ * applied) or 'applied', and the packages moved (to install by name).
250
+ */
251
+ async function switchChannel({ dir, channel, yes, log, latestVersion }) {
252
+ const pkgFile = join(dir, 'package.json');
253
+ const appJsFile = join(dir, 'public', 'app.js');
254
+ const pkg = existsSync(pkgFile) ? JSON.parse(await readFile(pkgFile, 'utf8')) : null;
255
+ const appJs = existsSync(appJsFile) ? await readFile(appJsFile, 'utf8') : null;
256
+ const from = appChannel(pkg);
257
+ const plan = planChannel({ pkg, appJs, channel, latestVersion });
258
+ if (!plan.dependencies.length && !plan.cdn) {
259
+ log(`Channel: already on ${channel}.`);
260
+ return { state: 'none', packages: [] };
261
+ }
262
+ // Already on dev (a dev build, as npm writes it): `--channel dev` moves to the newest dev builds.
263
+ log(from === channel ? `Channel: ${channel}, to its newest builds` : `Channel: ${from ?? 'none'} → ${channel}`);
264
+ for (const d of plan.dependencies) log(` ${d.name}: ${d.from} → ${d.to}`);
265
+ if (plan.cdn) log(` public/app.js, index.html: the bridge and UI kit from the CDN @${plan.cdn.from} → @${plan.cdn.to}`);
266
+ if (!yes) return { state: 'pending', packages: [] };
267
+ applyChannel(plan, pkg, appJs);
268
+ if (plan.dependencies.length) await writeFile(pkgFile, JSON.stringify(pkg, null, 2) + '\n');
269
+ if (plan.cdn) await writeCdnFiles(dir, plan.cdn.to);
270
+ log(`✔ Moved to the ${channel} channel.`);
271
+ if (channel === 'dev') DEV_CHANNEL_NOTE.slice(0, 2).forEach((line) => log(` ${line}`));
272
+ return { state: 'applied', packages: plan.dependencies };
273
+ }
274
+
275
+ export async function upgrade({ dir = process.cwd(), manifestFile = 'hawk-app.json', template, yes = false, install = true, runInstall = npmInstall, log = console.log, channel, latestVersion = npmLatest }) {
276
+ const { state: channelState, packages: channelPackages } = channel ? await switchChannel({ dir, channel, yes, log, latestVersion }) : { state: 'none', packages: [] };
277
+ const channelChanged = channelState === 'applied';
278
+ const applyHint = `\nNothing was changed. Run \`npx hawk-app upgrade${channel ? ` --channel ${channel}` : ''} --yes\` to apply this, then build, test and deploy.`;
279
+ const manifestPath = join(dir, manifestFile);
280
+ const manifest = existsSync(manifestPath) ? JSON.parse(await readFile(manifestPath, 'utf8')) : null;
281
+ // An app set up without a template (hawk-app init) still gets its AI assistant files refreshed.
282
+ const plan = template || detectTemplate(dir, manifest) ? planUpgrade({ dir, manifest, template }) : null;
283
+ if (!plan && !manifest) planUpgrade({ dir, manifest, template }); // throws: nothing to upgrade
284
+ const agents = manifest ? planAgentFiles(dir, { slug: manifest.slug, name: manifest.name, template: plan?.template }) : [];
285
+ const agentsChanged = agents.some(isAgentChange);
286
+
287
+ if (plan) {
288
+ logTemplatePlan(plan, manifest, manifestFile, log);
289
+ } else {
290
+ log('Template: none (this app was set up without one; pass --template <react|preact|next|next-mui|vanilla> if it follows one).');
291
+ }
292
+ for (const f of agents) {
293
+ const line = describeAgentFile(f);
294
+ if (line) log(` ${line}`);
295
+ }
296
+
297
+ const templateChanged = !!plan && hasChanges(plan);
298
+ if (!templateChanged && !agentsChanged) {
299
+ log(plan ? `✔ Up to date with the ${plan.template} template.` : '✔ Up to date.');
300
+ if (channelState === 'pending') log(applyHint);
301
+ if (channelChanged && install) {
302
+ log(' Installing…');
303
+ await runInstall({ cwd: dir, packages: channelPackages });
304
+ }
305
+ return { plan, agents, applied: channelChanged };
306
+ }
307
+ if (!yes) {
308
+ log(applyHint);
309
+ return { plan, agents, applied: false };
310
+ }
311
+
312
+ if (templateChanged) await applyTemplatePlan(plan, { dir, manifest, manifestPath, manifestFile, log });
313
+ if (agentsChanged) {
314
+ const written = await writeAgentFiles(dir, agents);
315
+ log(`✔ Updated ${written.join(', ')}.`);
316
+ }
317
+
318
+ if (install && (channelChanged || (templateChanged && (plan.dependencies.length || plan.additions.length || plan.cdn)))) {
319
+ log(' Installing…');
320
+ await runInstall({ cwd: dir, packages: channelPackages });
321
+ }
322
+ if (templateChanged || channelChanged) log(' Next: build and test the app (npm run build, npm run dev), then deploy.');
323
+ return { plan, agents, applied: true };
324
+ }
325
+
326
+ function logTemplatePlan(plan, manifest, manifestFile, log) {
327
+ log(`Template: ${plan.template} (${plan.source}${plan.source === 'guessed from the dependencies' ? '; pass --template to choose another' : ''})`);
328
+ if (plan.dependencies.length || plan.additions.length || plan.cdn) {
329
+ log('Dependencies:');
330
+ const width = Math.max(...plan.dependencies.map((d) => d.name.length), ...plan.additions.map((a) => a.name.length), 6);
331
+ for (const d of plan.dependencies) log(` ${d.name.padEnd(width)} ${d.from} → ${d.to}${d.major ? ' (major version: check its migration guide)' : ''}`);
332
+ for (const a of plan.additions) log(` ${a.name.padEnd(width)} (new) ${a.to}`);
333
+ if (plan.cdn) log(` ${'bridge and UI kit (CDN, public/)'.padEnd(width)} @${plan.cdn.from} → @${plan.cdn.to}`);
334
+ const mui = plan.dependencies.find((d) => d.major && d.name === '@mui/material');
335
+ if (mui) log(` Material UI ${mui.from} → ${mui.to} is a major upgrade: see https://mui.com/material-ui/migration/ for what changed.`);
336
+ for (const a of plan.additions) for (const line of a.notes) log(` ${line}`);
337
+ } else {
338
+ log('Dependencies: up to date with the template.');
339
+ }
340
+ if (plan.recordTemplate && manifest) log(` ${manifestFile}: records "template": "${plan.template}", so later upgrades don't guess.`);
341
+ for (const f of plan.files) {
342
+ if (f.state === 'replace') log(` ${f.file}: unchanged since the template created it, so it's replaced with the template's current version.`);
343
+ if (f.state === 'missing') log(` ${f.file}: not in this app (not added).`);
344
+ if (f.state === 'edited') {
345
+ log(` ${f.file}: you've changed it, so it's left alone. The template's current version differs (- yours, + template):`);
346
+ for (const line of f.diff) log(` ${line}`);
347
+ }
348
+ }
349
+ }
350
+
351
+ async function applyTemplatePlan(plan, { dir, manifest, manifestPath, manifestFile, log }) {
352
+ // package.json: only the version strings change, so its formatting stays.
353
+ if (plan.dependencies.length) {
354
+ const pkgFile = join(dir, 'package.json');
355
+ let text = await readFile(pkgFile, 'utf8');
356
+ for (const d of plan.dependencies) {
357
+ const pattern = new RegExp(`("${d.name.replace(/[/.@-]/g, '\\$&')}"\\s*:\\s*")([^"]*)(")`);
358
+ text = text.replace(pattern, `$1${d.to}$3`);
359
+ }
360
+ await writeFile(pkgFile, text);
361
+ }
362
+ if (plan.additions.length) {
363
+ const pkgFile = join(dir, 'package.json');
364
+ await writeFile(pkgFile, addDependencies(await readFile(pkgFile, 'utf8'), plan.additions));
365
+ }
366
+ if (plan.cdn) await writeCdnFiles(dir, plan.cdn.to);
367
+ for (const f of plan.files.filter((x) => x.state === 'replace')) {
368
+ const appFile = join(dir, f.file);
369
+ const eol = (await readFile(appFile, 'utf8')).includes('\r\n') ? '\r\n' : '\n';
370
+ await writeFile(appFile, f.content.replace(/\n/g, eol));
371
+ }
372
+ if (plan.recordTemplate && manifest) {
373
+ await writeFile(manifestPath, recordTemplate(await readFile(manifestPath, 'utf8'), plan.template));
374
+ }
375
+ log(`✔ Upgraded to the ${plan.template} template.${plan.recordTemplate && manifest ? ` Recorded "template": "${plan.template}" in ${manifestFile}.` : ''}`);
376
+ }
377
+
378
+ /**
379
+ * package.json with `additions` ([{ name, section, to }]) as the first entries of their section, in its indentation and
380
+ * line endings (the section is created when missing; then the JSON is rewritten).
381
+ */
382
+ export function addDependencies(text, additions) {
383
+ let out = text;
384
+ for (const { name, section, to } of additions) {
385
+ const match = new RegExp(`("${section}"\\s*:\\s*\\{)(\\r?\\n)([ \\t]*)`).exec(out);
386
+ if (match) {
387
+ const [all, open, eol, indent] = match;
388
+ out = out.slice(0, match.index) + `${open}${eol}${indent}"${name}": "${to}",${eol}${indent}` + out.slice(match.index + all.length);
389
+ } else {
390
+ const pkg = JSON.parse(out);
391
+ pkg[section] = { [name]: to, ...pkg[section] };
392
+ out = JSON.stringify(pkg, null, 2) + '\n';
393
+ }
394
+ }
395
+ return out;
396
+ }
397
+
398
+ /** hawk-app.json with `"template"` added after `"slug"`, keeping its formatting (falls back to rewriting the JSON). */
399
+ export function recordTemplate(text, template) {
400
+ const match = /^([ \t]*)"slug"\s*:\s*"[^"]*",?[ \t]*\r?\n/m.exec(text);
401
+ if (match && match[0].trimEnd().endsWith(',')) {
402
+ const eol = match[0].endsWith('\r\n') ? '\r\n' : '\n';
403
+ return text.slice(0, match.index + match[0].length) + `${match[1]}"template": "${template}",${eol}` + text.slice(match.index + match[0].length);
404
+ }
405
+ return JSON.stringify({ ...JSON.parse(text), template }, null, 2) + '\n';
406
+ }