diff --git a/.env.example b/.env.example index 8090f71..b46e45f 100644 --- a/.env.example +++ b/.env.example @@ -1 +1 @@ -WEB_TOOLS_PORT=8080 +WEB_TOOLS_PORT=9000 diff --git a/AGENTS.md b/AGENTS.md index a1cdf67..643bf38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,21 @@ Before changing this project, read `README.zh-CN.md` in full. Its architecture constraints, extension procedure, validation checklist, and release steps are authoritative. +## Project identity + +- This is a self-hosted Vue 3 + TypeScript browser-local tools application with a zero-dependency Node.js static/configuration server. +- `package.json` is the authoritative application version. The default production port is `9000`. +- `shared/tool-catalog.json` is the authoritative tool catalog; `shared/default-navigation.json` defines first-run ordering. +- `README.zh-CN.md` is the complete product, architecture, deployment, extension, and AI handoff document. `README.md` is only the universal entry point. +- Windows Server uses `web-tools.ps1`; containers use `Dockerfile` and `docker-compose.yml`. + +## First actions in a new session + +1. Read `README.zh-CN.md`, then inspect `git status --short --branch` and preserve all existing worktree changes. +2. Read the closest existing tool, its `src/lib/` logic, and related tests before adding or changing behavior. +3. Keep default production port references synchronized in `server/index.mjs`, `web-tools.ps1`, `Dockerfile`, `docker-compose.yml`, `.env.example`, and deployment documentation. +4. Run `npm run check`; for deployment changes also validate PowerShell syntax and Docker Compose configuration when those tools are available. + Key rules: - Preserve the anonymous, no-login, no-database design. Tool calculations are isolated in each browser tab; only navigation order is shared globally. diff --git a/CHANGELOG.md b/CHANGELOG.md index ec76dba..6cc8c3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,10 +14,18 @@ ### Fixed - Intel HEX 解析器增加全部非空记录上限、显式 uint32 地址空间检查和无符号扩展地址运算,拒绝越过 `0xFFFFFFFF` 的数据。 +- 固件 Hash 在普通 HTTP 局域网访问时增加本地 SHA-256/SHA-512 回退,不再依赖 `SubtleCrypto` 必须可用。 +- 生产服务在监听端口前检查 `dist/index.html`,缺少构建产物时立即给出构建命令,不再启动后按请求重复返回 500。 + +### Documentation + +- 补充 `dist/index.html` 缺失、Node 进程未重启、浏览器页面未刷新和前台 PowerShell 退出等启动故障排查说明。 +- 将 Node、Docker Compose、容器健康检查、Windows 脚本和示例配置的生产默认端口统一为 `9000`。 +- 增加通用 `README.md` 入口并强化 `AGENTS.md` 新会话接手流程,便于其他 AI 在不依赖历史对话的情况下继续维护。 ### Verification -- 68 条 Vitest 测试通过,新增标准 SHA 向量、Block 头大小端、地址绑定、最高 uint32 地址、地址溢出和控制记录边界覆盖。 +- 74 条 Vitest 测试通过,新增标准 SHA 向量、Block 头大小端、地址绑定、最高 uint32 地址、地址溢出、控制记录边界、无 Web Crypto 回退、生产构建预检和部署配置一致性覆盖。 - Vue/TypeScript 类型检查和生产构建通过。 ## 0.0.5 - 2026-08-07 diff --git a/Dockerfile b/Dockerfile index 05d9a17..9e32fb7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ FROM node:22-alpine AS runtime ENV NODE_ENV=production \ HOST=0.0.0.0 \ - PORT=8080 \ + PORT=9000 \ DATA_DIR=/data WORKDIR /app @@ -24,9 +24,9 @@ COPY --chown=node:node server ./server COPY --chown=node:node shared ./shared USER node -EXPOSE 8080 +EXPOSE 9000 VOLUME ["/data"] HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD node -e "fetch('http://127.0.0.1:8080/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" + CMD node -e "fetch('http://127.0.0.1:9000/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" CMD ["node", "server/index.mjs"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..fe18599 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# Web Tools + +Self-hosted browser-local utilities built with Vue 3, TypeScript, and a small Node.js server. Tool inputs and results stay in each browser tab; the server only persists the shared navigation order. + +Default production address: `http://localhost:9000` + +## Documentation + +- Complete Chinese documentation: [README.zh-CN.md](README.zh-CN.md) +- AI/coding-agent instructions: [AGENTS.md](AGENTS.md) +- Release history: [CHANGELOG.md](CHANGELOG.md) + +Any AI or coding agent starting in a new conversation must read `AGENTS.md` and `README.zh-CN.md` before editing the project, then inspect the current Git worktree instead of assuming it is clean. + +## Quick start + +Docker Compose: + +```bash +docker compose up -d --build +``` + +Windows Server after the first build: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\web-tools.ps1 start +``` + +See `README.zh-CN.md` for first installation, updates, Windows startup, security boundaries, tool extension rules, tests, and packaging. diff --git a/README.zh-CN.md b/README.zh-CN.md index 5584bb9..dd3994f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -86,12 +86,18 @@ CHANGELOG.md 版本变更、修复和验证记录 docker compose up -d --build ``` -默认访问地址:`http://服务器地址:8080` +默认访问地址:`http://服务器地址:9000` -自定义端口: +自定义宿主机端口时,在项目根目录创建 `.env`(可从 `.env.example` 复制),例如改为 `9100`: + +```dotenv +WEB_TOOLS_PORT=9100 +``` + +然后正常启动: ```bash -WEB_TOOLS_PORT=9000 docker compose up -d --build +docker compose up -d --build ``` 检查状态: @@ -109,7 +115,7 @@ docker compose down 导航配置保存在 Docker 卷 `web-tools-data` 中的 `/data/navigation.json`。全新部署且从未保存过排序时,应用直接使用内置默认配置,该文件可能尚不存在;首次保存后会创建。删除或重建容器不会丢失已有配置,执行 `docker compose down -v` 才会删除该卷。 -健康检查地址:`http://服务器地址:8080/healthz` +健康检查地址:`http://服务器地址:9000/healthz` ## Windows Server 部署(不使用 Docker) @@ -134,10 +140,19 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\web-tools.ps1 start ```powershell powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\web-tools.ps1 start ` -ListenAddress 127.0.0.1 ` - -Port 8080 ` + -Port 9100 ` -DataDir C:\ProgramData\WebTools\data ``` +脚本不会自动修改 Windows 防火墙。需要从局域网访问默认端口时,可由管理员仅对专用网络配置入站规则: + +```powershell +New-NetFirewallRule -DisplayName 'Web Tools 9000' -Direction Inbound -Action Allow ` + -Protocol TCP -LocalPort 9000 -Profile Private +``` + +只允许本机或由反向代理访问时,使用 `-ListenAddress 127.0.0.1`,无需开放该端口。 + 从 Git `main` 分支更新时,先在运行窗口按 `Ctrl+C` 停止服务,再执行: ```powershell @@ -148,12 +163,19 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\web-tools.ps1 update 脚本不会读取、覆盖或删除 `C:\ProgramData\WebTools\data`。PowerShell 窗口关闭后前台服务会停止;如使用 Windows 计划任务,应让任务执行 `web-tools.ps1 start`,更新时停止任务、运行 `web-tools.ps1 update -NoStart`,然后重新启动任务。 +## 启动故障排查 + +- `ENOENT ... dist\\index.html`:当前目录只有源码或尚未生成生产前端。先执行 `npm.cmd ci --include=dev` 和 `npm.cmd run build`,再启动服务;直接执行 `node server\\index.mjs` 不会替代构建步骤。 +- 页面能打开但左侧没有新工具:仅重新构建 `dist` 不会更新已经运行的 Node 进程。停止旧进程后重新启动,并在浏览器执行一次强制刷新(`Ctrl+F5`)。 +- 新工具仍未出现:确认服务输出的 `Navigation config` 路径与实际数据目录一致。旧的 `navigation.json` 会保留用户排序,只在服务重启后首次读取 API 时补入新工具。 +- 关闭 PowerShell 窗口后服务消失:前台启动是预期行为。需要长期运行时使用 Windows 计划任务或服务管理器,让任务执行 `web-tools.ps1 start`,不要依赖打开的交互窗口。 + ## 工具管理 管理入口不显示在页面中,直接访问: ```text -http://服务器地址:8080/setting +http://服务器地址:9000/setting ``` 可以调整分类顺序、分类内工具顺序及工具所属分类。保存时采用同目录临时文件和原子替换。 @@ -192,7 +214,15 @@ docker compose up -d --no-build npm ci ``` -终端一运行配置服务: +终端一运行开发配置服务。这里的 `8081` 仅用于 Vite 开发代理,不是生产默认端口。PowerShell: + +```powershell +$env:PORT = '8081' +$env:DATA_DIR = (Join-Path (Get-Location) 'data') +node .\server\index.mjs +``` + +Bash: ```bash PORT=8081 DATA_DIR=./data node server/index.mjs @@ -211,7 +241,9 @@ npm run build npm start ``` -生产服务默认监听 `0.0.0.0:8080`。 +生产服务默认监听 `0.0.0.0:9000`。 + +新增工具或修改 `shared/tool-catalog.json`、`shared/default-navigation.json` 后,不能只执行 `npm run build`。Node 服务会在启动时加载工具目录;必须先停止旧进程,再重新执行 `npm start`(Windows Server 使用 `web-tools.ps1 start`,Docker 使用 `docker compose up -d --build`)。服务重启后,已打开的浏览器页面还需要刷新,才会重新读取导航 API;已有 `data/navigation.json` 不会被覆盖,只会自动补入缺少的新工具。 完整工程检查: @@ -230,6 +262,8 @@ npm audit --audit-level=low 固件 Hash 与 CRC 共用经过校验的 Intel HEX 解析器,但使用独立纯逻辑、Worker 和 Vue 页面。解析器处理扩展段/线性地址,将数据按 32 位绝对地址排序,合并连续记录,去重内容一致的重叠记录,并拒绝冲突或越过 `0xFFFFFFFF` 的数据。记录文本、记录校验和、EOF 和启动地址不参与 Hash。 +摘要计算优先使用浏览器原生 Web Crypto;通过普通 HTTP 局域网地址访问而无法使用 `SubtleCrypto` 时,自动回退到项目内已有的 `node-forge` SHA-256/SHA-512 实现。两条路径都在 Web Worker 中本地处理,不会把固件数据发送到服务端。 + “有效数据”模式的总输入为所有 block 数据按地址升序直接拼接,不填充地址空洞。“Block 校验头”模式在每个 block 前加入 8 个二进制字节: ```text @@ -311,7 +345,7 @@ npm audit --audit-level=low - 新工具的默认路径 `/tools/` 可以直接打开和刷新。 - 左侧导航、搜索和 `/setting` 正常显示新工具。 -- 已保存的旧 `navigation.json` 启动后会自动补入新工具。 +- 已保存的旧 `navigation.json` 在服务重启并首次读取后会自动补入新工具;仅重新构建 `dist` 不会刷新已经运行的 Node 进程。 - 桌面端和约 `390 x 844` 的移动端竖屏布局正常。 - 浏览器控制台无错误,工具内容没有发往服务端。 diff --git a/docker-compose.yml b/docker-compose.yml index c5fa72b..4417e5b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: restart: unless-stopped init: true ports: - - "${WEB_TOOLS_PORT:-8080}:8080" + - "${WEB_TOOLS_PORT:-9000}:9000" volumes: - web-tools-data:/data read_only: true diff --git a/server/index.mjs b/server/index.mjs index d4ff27c..91971c6 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -16,6 +16,7 @@ const serverFile = fileURLToPath(import.meta.url) const serverDir = path.dirname(serverFile) const projectRoot = path.resolve(serverDir, '..') export const MAX_JSON_BODY_SIZE = 64 * 1024 +export const DEFAULT_PORT = 9000 const mimeTypes = new Map([ ['.css', 'text/css; charset=utf-8'], @@ -76,6 +77,19 @@ function isMissingFile(error) { return error?.code === 'ENOENT' || error?.code === 'ENOTDIR' } +export async function assertProductionBuild(staticRoot = path.join(projectRoot, 'dist')) { + const indexPath = path.join(path.resolve(staticRoot), 'index.html') + let details + try { + details = await stat(indexPath) + } catch (error) { + if (!isMissingFile(error)) throw error + } + if (!details?.isFile()) { + throw new Error(`Production build not found: ${indexPath}. Run "npm.cmd ci --include=dev" and "npm.cmd run build" before starting the server.`) + } +} + function acceptsHtmlNavigation(request, pathname) { if (path.extname(pathname)) return false return String(request.headers.accept || '').split(',').some((value) => value.trim().toLowerCase().startsWith('text/html')) @@ -186,8 +200,10 @@ export async function createWebToolsServer(options = {}) { export async function startWebToolsServer() { const host = process.env.HOST || '0.0.0.0' - const port = Number(process.env.PORT || 8080) - const server = await createWebToolsServer() + const port = Number(process.env.PORT || DEFAULT_PORT) + const staticRoot = path.join(projectRoot, 'dist') + await assertProductionBuild(staticRoot) + const server = await createWebToolsServer({ staticRoot }) server.listen(port, host, () => { console.log(`Web Tools listening on http://${host}:${port}`) console.log(`Navigation config: ${path.resolve(process.env.DATA_DIR || path.join(projectRoot, 'data'), 'navigation.json')}`) diff --git a/src/lib/firmwareHash.ts b/src/lib/firmwareHash.ts index 0704f0c..b515c5e 100644 --- a/src/lib/firmwareHash.ts +++ b/src/lib/firmwareHash.ts @@ -24,7 +24,9 @@ export interface FirmwareHashReport { headerBytes: number } -export type DigestBytes = (algorithm: 'SHA-256' | 'SHA-512', data: Uint8Array) => Promise +export type DigestAlgorithm = 'SHA-256' | 'SHA-512' +export type DigestBytes = (algorithm: DigestAlgorithm, data: Uint8Array) => Promise +type SubtleDigestProvider = Pick function validateUint32(value: number, label: string): void { if (!Number.isSafeInteger(value) || value < 0 || value > MAX_UINT32_ADDRESS) { @@ -123,13 +125,33 @@ export function buildFirmwareHashInput( return input } -async function webCryptoDigest(algorithm: 'SHA-256' | 'SHA-512', data: Uint8Array): Promise { - if (!globalThis.crypto?.subtle) throw new Error('当前浏览器不支持 Web Crypto Hash') +async function forgeDigest(algorithm: DigestAlgorithm, data: Uint8Array): Promise { + const { default: forge } = await import('node-forge') + const digest = algorithm === 'SHA-256' ? forge.md.sha256.create() : forge.md.sha512.create() + const chunkSize = 0x8000 + for (let offset = 0; offset < data.length; offset += chunkSize) { + digest.update(String.fromCharCode(...data.subarray(offset, Math.min(offset + chunkSize, data.length)))) + } + return digest.digest().toHex().toUpperCase() +} + +export async function digestFirmwareBytes( + algorithm: DigestAlgorithm, + data: Uint8Array, + subtle: SubtleDigestProvider | undefined = globalThis.crypto?.subtle, +): Promise { const input = data.buffer instanceof ArrayBuffer && data.byteOffset === 0 && data.byteLength === data.buffer.byteLength ? data.buffer : data.slice().buffer - const digest = await globalThis.crypto.subtle.digest(algorithm, input) - return formatBytes(new Uint8Array(digest), { format: 'continuous-hex' }) + if (subtle) { + try { + const digest = await subtle.digest(algorithm, input) + return formatBytes(new Uint8Array(digest), { format: 'continuous-hex' }) + } catch { + // Some browsers expose crypto but disable SubtleCrypto on plain HTTP LAN origins. + } + } + return forgeDigest(algorithm, data) } async function calculateDigestPair(data: Uint8Array, digestBytes: DigestBytes): Promise { @@ -142,7 +164,7 @@ export async function calculateFirmwareHashReport( blocks: DataBlock[], mode: FirmwareHashMode, byteOrder: FirmwareHashByteOrder = 'big-endian', - digestBytes: DigestBytes = webCryptoDigest, + digestBytes: DigestBytes = digestFirmwareBytes, ): Promise { const sorted = validateAndSortBlocks(blocks) const combinedInput = buildFirmwareHashInput(sorted, mode, byteOrder) diff --git a/tests/deploymentConfig.test.mjs b/tests/deploymentConfig.test.mjs new file mode 100644 index 0000000..18dcee9 --- /dev/null +++ b/tests/deploymentConfig.test.mjs @@ -0,0 +1,66 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { DEFAULT_PORT } from '../server/index.mjs' + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') + +async function readProjectFile(relativePath) { + return readFile(path.join(projectRoot, relativePath), 'utf8') +} + +describe('deployment configuration', () => { + it('keeps the default production port synchronized', async () => { + const [dockerfile, compose, environment, script, readme] = await Promise.all([ + readProjectFile('Dockerfile'), + readProjectFile('docker-compose.yml'), + readProjectFile('.env.example'), + readProjectFile('web-tools.ps1'), + readProjectFile('README.zh-CN.md'), + ]) + + expect(DEFAULT_PORT).toBe(9000) + expect(dockerfile).toContain('PORT=9000') + expect(dockerfile).toContain('EXPOSE 9000') + expect(dockerfile).toContain('http://127.0.0.1:9000/healthz') + expect(compose).toContain('${WEB_TOOLS_PORT:-9000}:9000') + expect(environment.trim()).toBe('WEB_TOOLS_PORT=9000') + expect(script).toMatch(/\[int\]\$Port = 9000/) + expect(readme).toContain('默认访问地址:`http://服务器地址:9000`') + expect(readme).toContain('生产服务默认监听 `0.0.0.0:9000`') + }) + + it('preserves the Windows and container production safeguards', async () => { + const [dockerfile, compose, dockerignore, script] = await Promise.all([ + readProjectFile('Dockerfile'), + readProjectFile('docker-compose.yml'), + readProjectFile('.dockerignore'), + readProjectFile('web-tools.ps1'), + ]) + + expect(dockerfile).toContain('USER node') + expect(compose).toContain('read_only: true') + expect(compose).toContain('no-new-privileges:true') + expect(compose).toContain('cap_drop:') + expect(dockerignore).toContain('node_modules') + expect(dockerignore).toContain('data') + expect(script).toContain('#Requires -Version 5.1') + expect(script).toContain("@('pull', '--ff-only', $Remote, $Branch)") + expect(script).toContain("@('ci', '--include=dev')") + }) + + it('provides standard entry points for a new AI session', async () => { + const [entryReadme, agentGuide, completeReadme] = await Promise.all([ + readProjectFile('README.md'), + readProjectFile('AGENTS.md'), + readProjectFile('README.zh-CN.md'), + ]) + + expect(entryReadme).toContain('[README.zh-CN.md](README.zh-CN.md)') + expect(entryReadme).toContain('[AGENTS.md](AGENTS.md)') + expect(agentGuide).toContain('## First actions in a new session') + expect(agentGuide).toContain('git status --short --branch') + expect(completeReadme).toContain('## AI 接手清单') + }) +}) diff --git a/tests/firmwareHash.test.ts b/tests/firmwareHash.test.ts index b45af73..030bcbe 100644 --- a/tests/firmwareHash.test.ts +++ b/tests/firmwareHash.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { buildFirmwareHashInput, calculateFirmwareHashReport, + digestFirmwareBytes, encodeFirmwareBlockHeader, type FirmwareHashByteOrder, type FirmwareHashMode, @@ -131,3 +132,11 @@ describe('firmware SHA report', () => { expect(relocatedWithHeaders.combined).not.toEqual(withHeaders.combined) }) }) + +describe('firmware digest provider', () => { + it('falls back to the local implementation when SubtleCrypto is unavailable', async () => { + const data = textToBytes('plain HTTP LAN fallback') + expect(await digestFirmwareBytes('SHA-256', data, undefined)).toBe(hash('sha256', data)) + expect(await digestFirmwareBytes('SHA-512', data, undefined)).toBe(hash('sha512', data)) + }) +}) diff --git a/tests/server.test.mjs b/tests/server.test.mjs index b1f396d..dc72173 100644 --- a/tests/server.test.mjs +++ b/tests/server.test.mjs @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { createWebToolsServer, MAX_JSON_BODY_SIZE } from '../server/index.mjs' +import { assertProductionBuild, createWebToolsServer, MAX_JSON_BODY_SIZE } from '../server/index.mjs' const temporaryDirectories = [] const servers = [] @@ -47,6 +47,12 @@ async function createFixtureServer() { } describe('HTTP server', () => { + it('fails before listening when the production build is missing', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'web-tools-missing-build-')) + temporaryDirectories.push(directory) + await expect(assertProductionBuild(path.join(directory, 'dist'))).rejects.toThrow('npm.cmd run build') + }) + it('serves direct SPA navigation but returns 404 for missing assets', async () => { const { baseUrl } = await createFixtureServer() const page = await fetch(`${baseUrl}/tools/base64`, { headers: { Accept: 'text/html' } }) @@ -88,6 +94,47 @@ describe('HTTP server', () => { expect(JSON.parse(await readFile(path.join(dataDir, 'navigation.json'), 'utf8'))).toEqual(updated) }) + it('normalizes an existing navigation file when a later release adds a tool', async () => { + const { baseUrl, dataDir } = await createFixtureServer() + const legacy = { version: 1, categories: [{ id: 'encoding', tools: ['hex'] }, { id: 'crypto', tools: ['cmac'] }] } + await mkdir(dataDir, { recursive: true }) + await writeFile(path.join(dataDir, 'navigation.json'), JSON.stringify(legacy)) + + const response = await fetch(`${baseUrl}/api/navigation`) + expect(await response.json()).toEqual(legacy) + + const expandedCatalog = { ...catalog, tools: [...catalog.tools, { id: 'base64', category: 'encoding' }] } + const expandedDefaults = { ...defaults, categories: [{ id: 'encoding', tools: ['hex', 'base64'] }, ...defaults.categories.slice(1)] } + const secondServer = await createWebToolsServer({ + staticRoot: path.join(dataDir, 'missing-dist'), + dataDir, + catalog: expandedCatalog, + defaults: expandedDefaults, + logger: { error: () => undefined }, + }) + servers.push(secondServer) + await new Promise((resolve, reject) => { + secondServer.once('error', reject) + secondServer.listen(0, '127.0.0.1', resolve) + }) + const address = secondServer.address() + const upgraded = await fetch(`http://127.0.0.1:${address.port}/api/navigation`) + expect(await upgraded.json()).toEqual({ + version: 1, + categories: [ + { id: 'encoding', tools: ['hex', 'base64'] }, + { id: 'crypto', tools: ['cmac'] }, + ], + }) + expect(JSON.parse(await readFile(path.join(dataDir, 'navigation.json'), 'utf8'))).toEqual({ + version: 1, + categories: [ + { id: 'encoding', tools: ['hex', 'base64'] }, + { id: 'crypto', tools: ['cmac'] }, + ], + }) + }) + it('rejects invalid, oversized and cross-site navigation writes', async () => { const { baseUrl } = await createFixtureServer() const invalid = await fetch(`${baseUrl}/api/navigation`, { diff --git a/web-tools.ps1 b/web-tools.ps1 index 1c9ddf7..4f80d97 100644 --- a/web-tools.ps1 +++ b/web-tools.ps1 @@ -68,7 +68,7 @@ function Assert-ProductionBuild { } if (-not (Test-Path -LiteralPath $distIndex -PathType Leaf)) { - throw "Production build was not found: $distIndex. Run '.\web-tools.ps1 update' first." + throw "Production build was not found: $distIndex. Run '.\web-tools.ps1 update -NoStart' or 'npm.cmd run build' first." } }