@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.
- package/LICENSE +7 -0
- package/README.md +769 -2
- package/agents/AGENTS.md +192 -0
- package/agents/project/custom.md +21 -0
- package/agents/project/next-mui.md +21 -0
- package/agents/project/next.md +24 -0
- package/agents/project/vanilla.md +22 -0
- package/agents/project/vite.md +19 -0
- package/bin/hawk-app.mjs +10 -0
- package/docs/bridge/README.md +1315 -0
- package/docs/showcase/README.md +47 -0
- package/docs/showcase/functions/echo.ts +12 -0
- package/docs/showcase/functions/long-task.ts +31 -0
- package/docs/showcase/functions/showcase.ts +186 -0
- package/docs/showcase/hawk-app.json +73 -0
- package/docs/showcase/queries/assistant-prompts-by-day.json +6 -0
- package/docs/showcase/queries/assistant-prompts-by-day.sql +8 -0
- package/docs/showcase/queries/searches-by-day.json +6 -0
- package/docs/showcase/queries/searches-by-day.sql +7 -0
- package/docs/showcase/src/App.tsx +1436 -0
- package/docs/showcase/src/main.tsx +10 -0
- package/docs/showcase/src/styles.css +71 -0
- package/docs/showcase/src/useHawkApp.ts +44 -0
- package/docs/showcase/src/vite-env.d.ts +1 -0
- package/docs/ui/README.md +166 -0
- package/package.json +50 -4
- package/schema/hawk-app.schema.json +611 -0
- package/src/accessibility.mjs +105 -0
- package/src/agents.mjs +145 -0
- package/src/aliases.mjs +30 -0
- package/src/app-icon.mjs +82 -0
- package/src/channel.mjs +116 -0
- package/src/cli.mjs +825 -0
- package/src/clients.mjs +221 -0
- package/src/config.mjs +148 -0
- package/src/create.mjs +186 -0
- package/src/credentials.mjs +56 -0
- package/src/data.mjs +169 -0
- package/src/deploy.mjs +349 -0
- package/src/dev.mjs +459 -0
- package/src/doctor.mjs +176 -0
- package/src/env.mjs +140 -0
- package/src/functions.mjs +313 -0
- package/src/login.mjs +166 -0
- package/src/logs.mjs +64 -0
- package/src/manifest.mjs +425 -0
- package/src/mcp-docs.mjs +168 -0
- package/src/mcp-hawksearch.mjs +342 -0
- package/src/mcp-reports.mjs +130 -0
- package/src/mcp-rules.mjs +109 -0
- package/src/mcp-ui.mjs +73 -0
- package/src/mcp.mjs +539 -0
- package/src/platform.mjs +116 -0
- package/src/project-checks.mjs +153 -0
- package/src/queries.mjs +289 -0
- package/src/scope-check.mjs +91 -0
- package/src/secrets.mjs +86 -0
- package/src/slug-guard.mjs +80 -0
- package/src/sso.mjs +182 -0
- package/src/store-listing.mjs +106 -0
- package/src/telemetry.mjs +50 -0
- package/src/template-history.json +34 -0
- package/src/upgrade.mjs +406 -0
- package/src/versions.mjs +102 -0
- package/templates/next/next.config.ts +13 -0
- package/templates/next/package.json +26 -0
- package/templates/next/src/app/globals.css +5 -0
- package/templates/next/src/app/layout.tsx +14 -0
- package/templates/next/src/app/page.tsx +86 -0
- package/templates/next/src/hawk-elements.d.ts +10 -0
- package/templates/next/src/lib/useHawkApp.ts +46 -0
- package/templates/next/tsconfig.json +21 -0
- package/templates/next-mui/next.config.ts +13 -0
- package/templates/next-mui/package.json +31 -0
- package/templates/next-mui/src/app/layout.tsx +20 -0
- package/templates/next-mui/src/app/page.tsx +79 -0
- package/templates/next-mui/src/app/providers.tsx +11 -0
- package/templates/next-mui/tsconfig.json +21 -0
- package/templates/preact/index.html +12 -0
- package/templates/preact/package.json +26 -0
- package/templates/preact/src/App.tsx +83 -0
- package/templates/preact/src/hawk-elements.d.ts +10 -0
- package/templates/preact/src/main.tsx +8 -0
- package/templates/preact/src/styles.css +5 -0
- package/templates/preact/src/useHawkApp.ts +44 -0
- package/templates/preact/src/vite-env.d.ts +1 -0
- package/templates/preact/tsconfig.json +17 -0
- package/templates/preact/vite.config.ts +11 -0
- package/templates/react/index.html +12 -0
- package/templates/react/package.json +29 -0
- package/templates/react/src/App.tsx +83 -0
- package/templates/react/src/hawk-elements.d.ts +10 -0
- package/templates/react/src/main.tsx +13 -0
- package/templates/react/src/styles.css +5 -0
- package/templates/react/src/useHawkApp.ts +44 -0
- package/templates/react/src/vite-env.d.ts +1 -0
- package/templates/react/tsconfig.json +16 -0
- package/templates/react/vite.config.ts +11 -0
- package/templates/vanilla/package.json +15 -0
- package/templates/vanilla/public/app.js +59 -0
- package/templates/vanilla/public/index.html +37 -0
- package/templates/vanilla/public/styles.css +5 -0
package/src/upgrade.mjs
ADDED
|
@@ -0,0 +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
|
+
}
|
package/src/versions.mjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { DEFAULT_ALIAS, checkAlias } from './aliases.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Versions, aliases and "why": the developer's view of who sees which version.
|
|
5
|
+
* hawk-app versions each version with its status and the aliases on it
|
|
6
|
+
* hawk-app alias set <name> <v> point an alias at a version that isn't approved (staff only)
|
|
7
|
+
* hawk-app alias delete <name> delete an alias of yours (not approved, no clients on it)
|
|
8
|
+
* hawk-app why <client> what a client's users see, and why
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const STATUS_NOTE = {
|
|
12
|
+
draft: 'staff only',
|
|
13
|
+
waiting: 'staff only, waiting for approval',
|
|
14
|
+
rejected: 'staff only',
|
|
15
|
+
approved: '',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** `hawk-app versions`: newest first, with status and aliases. */
|
|
19
|
+
export async function versionsCommand({ client, manifest, log = console.log }) {
|
|
20
|
+
const { versions = [] } = await client.listVersions(manifest.slug);
|
|
21
|
+
if (!versions.length) {
|
|
22
|
+
log(`${manifest.slug} has no versions yet. Deploy one with \`npm run app:deploy\`.`);
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
log(`${manifest.slug}: versions, newest first`);
|
|
26
|
+
for (const v of versions) {
|
|
27
|
+
const aliases = v.aliases?.length ? ` ${v.aliases.join(', ')}` : '';
|
|
28
|
+
const note = STATUS_NOTE[v.status] ? ` (${STATUS_NOTE[v.status]})` : '';
|
|
29
|
+
const branch = v.deployedFor ? ` deployed for ${v.deployedFor}` : '';
|
|
30
|
+
log(` v${String(v.version).padEnd(4)} ${v.status.padEnd(9)}${aliases}${note}${branch}`);
|
|
31
|
+
if (v.status === 'rejected' && v.rejectedReason) log(` rejected: ${v.rejectedReason}`);
|
|
32
|
+
}
|
|
33
|
+
log('Clients get the version of their alias (default without one); users see it only when it\'s approved.');
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `hawk-app alias set <name> <version>` / `hawk-app alias delete <name>`. */
|
|
38
|
+
export async function aliasCommand({ client, manifest, sub, args = [], log = console.log }) {
|
|
39
|
+
const usage = 'Usage:\n hawk-app alias set <name> <version> (e.g. hawk-app alias set staging 15)\n hawk-app alias delete <name>';
|
|
40
|
+
const slug = manifest.slug;
|
|
41
|
+
switch (sub) {
|
|
42
|
+
case 'set': {
|
|
43
|
+
const name = checkAlias(args[0], { flag: 'alias set' });
|
|
44
|
+
const version = Number(String(args[1] ?? '').replace(/^v/, ''));
|
|
45
|
+
if (!Number.isInteger(version) || version < 1) throw new Error(usage);
|
|
46
|
+
await client.setAlias(slug, name, version);
|
|
47
|
+
log(`✔ ${name} → v${version}. Clients on ${name}: only Bridgeline staff in them see it until v${version} is approved.`);
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
case 'delete': {
|
|
51
|
+
const name = checkAlias(args[0], { flag: 'alias delete' });
|
|
52
|
+
await client.deleteAlias(slug, name);
|
|
53
|
+
log(`✔ Deleted ${name}.`);
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
default:
|
|
57
|
+
throw new Error(usage);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* `hawk-app why <client>`: what the client's users see of the app, and why (the same rules as the platform's catalog):
|
|
63
|
+
* the app's state and visibility, the client list, the client's alias and that version's status.
|
|
64
|
+
*/
|
|
65
|
+
export async function whyCommand({ client, manifest, clientId, log = console.log }) {
|
|
66
|
+
if (!clientId) throw new Error('Usage: hawk-app why <client id> (e.g. hawk-app why e3141)');
|
|
67
|
+
const app = await client.status(manifest.slug);
|
|
68
|
+
const row = (app.clients ?? []).find((c) => c.id === clientId);
|
|
69
|
+
const aliasName = row?.alias ?? DEFAULT_ALIAS;
|
|
70
|
+
const alias = (app.aliases ?? []).find((a) => a.name === aliasName);
|
|
71
|
+
const say = (verdict, ...reasons) => {
|
|
72
|
+
log(`${clientId}: ${verdict}`);
|
|
73
|
+
for (const reason of reasons) log(` - ${reason}`);
|
|
74
|
+
return 0;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
if (app.adminLocked) return say('nobody sees the app', 'an administrator locked it.');
|
|
78
|
+
if (app.status === 'Disabled') return say('nobody sees the app', 'it is disabled.');
|
|
79
|
+
if (app.visibility === 'internal') return say('its users don\'t see the app; Bridgeline staff do', 'visibility is "internal": Bridgeline staff only.');
|
|
80
|
+
if (app.visibility === 'allowList' && !row?.enabled) {
|
|
81
|
+
return say("its users don't see the app", `it isn't on the app's client list (visibility "allowList"). Add it: hawk-app clients add ${clientId}`);
|
|
82
|
+
}
|
|
83
|
+
if (app.visibility === 'all' && row && !row.enabled) {
|
|
84
|
+
return say("its users don't see the app", `it's excluded (visibility "all"). Add it back: hawk-app clients add ${clientId}`);
|
|
85
|
+
}
|
|
86
|
+
const listed = app.visibility === 'all' ? 'visibility "all": every client that isn\'t excluded' : 'it\'s on the app\'s client list';
|
|
87
|
+
if (aliasName === DEFAULT_ALIAS) {
|
|
88
|
+
if (!app.liveVersion || app.status !== 'Live') {
|
|
89
|
+
return say("its users don't see the app yet", listed, `it follows ${DEFAULT_ALIAS}, and nothing is released to everyone yet: Bridgeline staff in it see the newest version.`);
|
|
90
|
+
}
|
|
91
|
+
return say(`its users see v${app.liveVersion}`, listed, `it follows ${DEFAULT_ALIAS} (the version released to everyone).`);
|
|
92
|
+
}
|
|
93
|
+
if (!alias) return say('its users see the default version', listed, `its alias "${aliasName}" no longer exists, so it follows ${DEFAULT_ALIAS}.`);
|
|
94
|
+
if (alias.status !== 'approved') {
|
|
95
|
+
return say(
|
|
96
|
+
`its users don't see the app; Bridgeline staff in it see v${alias.version}`,
|
|
97
|
+
listed,
|
|
98
|
+
`it's on ${aliasName}, which points at v${alias.version}: ${alias.status ?? 'draft'}, not approved yet.`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return say(`its users see v${alias.version}`, listed, `it's on ${aliasName}, which points at v${alias.version} (approved).`);
|
|
102
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { NextConfig } from 'next';
|
|
2
|
+
|
|
3
|
+
// Hawk Apps are static files on the apps CDN, one folder per deploy (/<slug>/v<n>/): static export, no server
|
|
4
|
+
// features (no SSR, API routes or middleware). All data comes through the bridge.
|
|
5
|
+
// `hawk-app deploy` builds with HAWK_APP_BASE_PATH=/<slug>/v<n>; local dev uses /<slug>.
|
|
6
|
+
const nextConfig: NextConfig = {
|
|
7
|
+
output: 'export',
|
|
8
|
+
basePath: process.env.HAWK_APP_BASE_PATH ?? '/__SLUG__',
|
|
9
|
+
trailingSlash: true,
|
|
10
|
+
images: { unoptimized: true },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export default nextConfig;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__SLUG__",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "next dev -p 3100",
|
|
7
|
+
"build": "next build",
|
|
8
|
+
"app:dev": "hawk-app dev",
|
|
9
|
+
"app:deploy": "hawk-app deploy",
|
|
10
|
+
"app:audit": "npm audit --omit=dev --audit-level=high"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@bridgeline-digital/hawk-app-bridge": "^0.1.0",
|
|
14
|
+
"@bridgeline-digital/hawk-app-ui": "^0.1.0",
|
|
15
|
+
"next": "^16.4.0",
|
|
16
|
+
"react": "^19.1.0",
|
|
17
|
+
"react-dom": "^19.1.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@bridgeline-digital/hawk-app-cli": "^__CLI_VERSION__",
|
|
21
|
+
"@types/node": "^20",
|
|
22
|
+
"@types/react": "^19.1.0",
|
|
23
|
+
"@types/react-dom": "^19.1.0",
|
|
24
|
+
"typescript": "^5.6.0"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Your app's own styles. Spark's look comes from the UI kit's stylesheet (imported in layout.tsx): its hawk-* classes
|
|
3
|
+
* (hawk-section, hawk-button, hawk-notice, hawk-table, …) and --hawk-ui-* variables, which follow the host's colors
|
|
4
|
+
* and light or dark mode. Use those before adding your own.
|
|
5
|
+
*/
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
// The UI kit's stylesheet: Spark's look (with Montserrat), in the host's colors and mode.
|
|
3
|
+
import '@bridgeline-digital/hawk-app-ui/css';
|
|
4
|
+
import './globals.css';
|
|
5
|
+
|
|
6
|
+
export const metadata: Metadata = { title: '__NAME__' };
|
|
7
|
+
|
|
8
|
+
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
|
9
|
+
return (
|
|
10
|
+
<html lang="en">
|
|
11
|
+
<body>{children}</body>
|
|
12
|
+
</html>
|
|
13
|
+
);
|
|
14
|
+
}
|