feat: 初始提交
This commit is contained in:
32
.gitattributes
vendored
Normal file
32
.gitattributes
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
# 跨平台行尾规范
|
||||
# 所有文本文件统一用 LF(Unix 风格),避免 CRLF/LF 混用
|
||||
* text=auto eol=lf
|
||||
|
||||
# 二进制文件不转换行尾
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.svg binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
*.gz binary
|
||||
*.tar binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.eot binary
|
||||
*.mp4 binary
|
||||
*.mov binary
|
||||
*.mp3 binary
|
||||
|
||||
# Shell 脚本必须 LF
|
||||
*.sh text eol=lf
|
||||
|
||||
# Windows 批处理必须 CRLF
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
|
||||
# 锁定文件(如 package-lock.json)保持 LF
|
||||
*.lock text eol=lf
|
||||
427
.gitignore
vendored
Normal file
427
.gitignore
vendored
Normal file
@@ -0,0 +1,427 @@
|
||||
### Git.gitignore ###
|
||||
# Created by git for backups. To disable backups in Git:
|
||||
# $ git config --global mergetool.keepBackup false
|
||||
*.orig
|
||||
|
||||
# Created by git when using merge tools for conflicts
|
||||
*.BACKUP.*
|
||||
*.BASE.*
|
||||
*.LOCAL.*
|
||||
*.REMOTE.*
|
||||
*_BACKUP_*.txt
|
||||
*_BASE_*.txt
|
||||
*_LOCAL_*.txt
|
||||
*_REMOTE_*.txt
|
||||
|
||||
### Go.gitignore ###
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
### Go.patch ###
|
||||
/vendor/
|
||||
/Godeps/
|
||||
|
||||
### JetBrains+all.gitignore ###
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
|
||||
# User-specific stuff
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/usage.statistics.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/shelf
|
||||
|
||||
# Generated files
|
||||
.idea/**/contentModel.xml
|
||||
|
||||
# Sensitive or high-churn files
|
||||
.idea/**/dataSources/
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/dataSources.local.xml
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
.idea/**/dbnavigator.xml
|
||||
|
||||
# Gradle
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/libraries
|
||||
|
||||
# Gradle and Maven with auto-import
|
||||
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||
# since they will be recreated, and may cause churn. Uncomment if using
|
||||
# auto-import.
|
||||
# .idea/artifacts
|
||||
# .idea/compiler.xml
|
||||
# .idea/jarRepositories.xml
|
||||
# .idea/modules.xml
|
||||
# .idea/*.iml
|
||||
# .idea/modules
|
||||
# *.iml
|
||||
# *.ipr
|
||||
|
||||
# CMake
|
||||
cmake-build-*/
|
||||
|
||||
# Mongo Explorer plugin
|
||||
.idea/**/mongoSettings.xml
|
||||
|
||||
# File-based project format
|
||||
*.iws
|
||||
|
||||
# IntelliJ
|
||||
out/
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
.idea_modules/
|
||||
|
||||
# JIRA plugin
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
# Cursive Clojure plugin
|
||||
.idea/replstate.xml
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
|
||||
# Editor-based Rest Client
|
||||
.idea/httpRequests
|
||||
|
||||
# Android studio 3.1+ serialized cache file
|
||||
.idea/caches/build_file_checksums.ser
|
||||
|
||||
### JetBrains+all.patch ###
|
||||
# Ignores the whole .idea folder and all .iml files
|
||||
# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360
|
||||
|
||||
.idea/
|
||||
|
||||
# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023
|
||||
|
||||
*.iml
|
||||
modules.xml
|
||||
.idea/misc.xml
|
||||
*.ipr
|
||||
|
||||
# Sonarlint plugin
|
||||
.idea/sonarlint
|
||||
|
||||
### Linux.gitignore ###
|
||||
*~
|
||||
|
||||
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||
.fuse_hidden*
|
||||
|
||||
# KDE directory preferences
|
||||
.directory
|
||||
|
||||
# Linux trash folder which might appear on any partition or disk
|
||||
.Trash-*
|
||||
|
||||
# .nfs files are created when an open file is removed but is still being accessed
|
||||
.nfs*
|
||||
|
||||
### Node.gitignore ###
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Test
|
||||
playwright-report
|
||||
test-results
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.test
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
### VisualStudioCode.gitignore ###
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
*.code-workspace
|
||||
|
||||
### VisualStudioCode.patch ###
|
||||
# Ignore all local history of files
|
||||
.history
|
||||
|
||||
### Windows.gitignore ###
|
||||
# Windows thumbnail cache files
|
||||
Thumbs.db
|
||||
Thumbs.db:encryptable
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
|
||||
# Dump file
|
||||
*.stackdump
|
||||
|
||||
# Folder config file
|
||||
[Dd]esktop.ini
|
||||
|
||||
# Recycle Bin used on file shares
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Windows Installer files
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
### macOS.gitignore ###
|
||||
# General
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
### Python.gitignore ###
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# PyInstaller
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Environments
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.python-version
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
|
||||
# Pyre
|
||||
.pyre/
|
||||
|
||||
# pytype
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# Custom
|
||||
.claude/*
|
||||
!.claude/settings.json
|
||||
.opencode
|
||||
.codex
|
||||
.pi/*
|
||||
!.pi/mcp.json
|
||||
!.pi/extensions
|
||||
openspec/changes/archive
|
||||
temp
|
||||
.agents
|
||||
skills-lock.json
|
||||
.worktrees
|
||||
data/
|
||||
!scripts/build/
|
||||
backend/bin
|
||||
backend/server
|
||||
backend/desktop
|
||||
|
||||
# Embedfs generated
|
||||
embedfs/assets/
|
||||
embedfs/frontend-dist/
|
||||
backend/cmd/desktop/rsrc_windows_*.syso
|
||||
|
||||
# Bun
|
||||
.build/
|
||||
*.bun-build
|
||||
1
.husky/commit-msg
Normal file
1
.husky/commit-msg
Normal file
@@ -0,0 +1 @@
|
||||
bunx commitlint --edit $1
|
||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
bunx lint-staged
|
||||
4
.lintstagedrc.json
Normal file
4
.lintstagedrc.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"*.{ts,tsx}": ["eslint --fix"],
|
||||
"*.{md,json,yaml,yml}": ["prettier --write"]
|
||||
}
|
||||
16
.pi/extensions/pi-permission-system/config.json
Normal file
16
.pi/extensions/pi-permission-system/config.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/gotgenes/pi-permission-system/main/schemas/permissions.schema.json",
|
||||
"permission": {
|
||||
"*": "allow",
|
||||
"bash": {
|
||||
"npm *": "deny",
|
||||
"npx *": "deny",
|
||||
"pnpm *": "deny",
|
||||
"pnpx *": "deny"
|
||||
},
|
||||
"external_directory": {
|
||||
"*": "ask",
|
||||
"/tmp/*": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
8
.pi/mcp.json
Normal file
8
.pi/mcp.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"tdesign-mcp-server": {
|
||||
"command": "bunx",
|
||||
"args": ["tdesign-mcp-server@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
13
.prettierignore
Normal file
13
.prettierignore
Normal file
@@ -0,0 +1,13 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.build/
|
||||
*.bun-build
|
||||
openspec/
|
||||
bun.lock
|
||||
.opencode/
|
||||
.claude/
|
||||
.codex/
|
||||
.agents/
|
||||
skills-lock.json
|
||||
data/
|
||||
probe-config.schema.json
|
||||
11
.prettierrc.json
Normal file
11
.prettierrc.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf",
|
||||
"tabWidth": 2,
|
||||
"useTabs": false
|
||||
}
|
||||
10
.vscode/settings.json
vendored
Normal file
10
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"editor.tabSize": 2,
|
||||
"editor.insertSpaces": true,
|
||||
"editor.detectIndentation": false,
|
||||
|
||||
"files.eol": "\n",
|
||||
"files.encoding": "utf8",
|
||||
"files.insertFinalNewline": true,
|
||||
"files.trimTrailingWhitespace": true
|
||||
}
|
||||
184
LICENSE
Normal file
184
LICENSE
Normal file
@@ -0,0 +1,184 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction, and
|
||||
distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by the copyright
|
||||
owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all other entities
|
||||
that control, are controlled by, or are under common control with that entity.
|
||||
For the purposes of this definition, "control" means (i) the power, direct or
|
||||
indirect, to cause the direction or management of such entity, whether by
|
||||
contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity exercising
|
||||
permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications, including
|
||||
but not limited to software source code, documentation source, and configuration
|
||||
files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical transformation or
|
||||
translation of a Source form, including but not limited to compiled object code,
|
||||
generated documentation, and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or Object form,
|
||||
made available under the License, as indicated by a copyright notice that is
|
||||
included in or attached to the work (an example is provided in the Appendix
|
||||
below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object form, that
|
||||
is based on (or derived from) the Work and for which the editorial revisions,
|
||||
annotations, elaborations, or other modifications represent, as a whole, an
|
||||
original work of authorship. For the purposes of this License, Derivative Works
|
||||
shall not include works that remain separable from, or merely link (or bind by
|
||||
name) to the interfaces of, the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including the original version
|
||||
of the Work and any modifications or additions to that Work or Derivative Works
|
||||
thereof, that is intentionally submitted to Licensor for inclusion in the Work
|
||||
by the copyright owner or by an individual or Legal Entity authorized to submit
|
||||
on behalf of the copyright owner. For the purposes of this definition,
|
||||
"submitted" means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems, and
|
||||
issue tracking systems that are managed by, or on behalf of, the Licensor for
|
||||
the purpose of discussing and improving the Work, but excluding communication
|
||||
that is conspicuously marked or otherwise designated in writing by the copyright
|
||||
owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf
|
||||
of whom a Contribution has been received by Licensor and subsequently
|
||||
incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of this
|
||||
License, each Contributor hereby grants to You a perpetual, worldwide,
|
||||
non-exclusive, no-charge, royalty-free, irrevocable copyright license to
|
||||
reproduce, prepare Derivative Works of, publicly display, publicly perform,
|
||||
sublicense, and distribute the Work and such Derivative Works in Source or
|
||||
Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of this License,
|
||||
each Contributor hereby grants to You a perpetual, worldwide, non-exclusive,
|
||||
no-charge, royalty-free, irrevocable (except as stated in this section) patent
|
||||
license to make, have made, use, offer to sell, sell, import, and otherwise
|
||||
transfer the Work, where such license applies only to those patent claims
|
||||
licensable by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s) with the Work
|
||||
to which such Contribution(s) was submitted. If You institute patent litigation
|
||||
against any entity (including a cross-claim or counterclaim in a lawsuit)
|
||||
alleging that the Work or a Contribution incorporated within the Work
|
||||
constitutes direct or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate as of the date
|
||||
such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the Work or
|
||||
Derivative Works thereof in any medium, with or without modifications, and in
|
||||
Source or Object form, provided that You meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or Derivative Works a copy of
|
||||
this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices stating that
|
||||
You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works that You
|
||||
distribute, all copyright, patent, trademark, and attribution notices from the
|
||||
Source form of the Work, excluding those notices that do not pertain to any part
|
||||
of the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its distribution, then
|
||||
any Derivative Works that You distribute must include a readable copy of the
|
||||
attribution notices contained within such NOTICE file, excluding those notices
|
||||
that do not pertain to any part of the Derivative Works, in at least one of the
|
||||
following places: within a NOTICE text file distributed as part of the
|
||||
Derivative Works; within the Source form or documentation, if provided along
|
||||
with the Derivative Works; or, within a display generated by the Derivative
|
||||
Works, if and wherever such third-party notices normally appear. The contents of
|
||||
the NOTICE file are for informational purposes only and do not modify the
|
||||
License. You may add Your own attribution notices within Derivative Works that
|
||||
You distribute, alongside or as an addendum to the NOTICE text from the Work,
|
||||
provided that such additional attribution notices cannot be construed as
|
||||
modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and may provide
|
||||
additional or different license terms and conditions for use, reproduction, or
|
||||
distribution of Your modifications, or for any such Derivative Works as a whole,
|
||||
provided Your use, reproduction, and distribution of the Work otherwise complies
|
||||
with the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise, any
|
||||
Contribution intentionally submitted for inclusion in the Work by You to the
|
||||
Licensor shall be under the terms and conditions of this License, without any
|
||||
additional terms or conditions. Notwithstanding the above, nothing herein shall
|
||||
supersede or modify the terms of any separate license agreement you may have
|
||||
executed with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade names,
|
||||
trademarks, service marks, or product names of the Licensor, except as required
|
||||
for reasonable and customary use in describing the origin of the Work and
|
||||
reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in
|
||||
writing, Licensor provides the Work (and each Contributor provides its
|
||||
Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied, including, without limitation, any warranties
|
||||
or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any risks
|
||||
associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory, whether in
|
||||
tort (including negligence), contract, or otherwise, unless required by
|
||||
applicable law (such as deliberate and grossly negligent acts) or agreed to in
|
||||
writing, shall any Contributor be liable to You for damages, including any
|
||||
direct, indirect, special, incidental, or consequential damages of any character
|
||||
arising as a result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill, work stoppage,
|
||||
computer failure or malfunction, or any and all other commercial damages or
|
||||
losses), even if such Contributor has been advised of the possibility of such
|
||||
damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing the Work or
|
||||
Derivative Works thereof, You may choose to offer, and charge a fee for,
|
||||
acceptance of support, warranty, indemnity, or other liability obligations
|
||||
and/or rights consistent with this License. However, in accepting such
|
||||
obligations, You may act only on Your own behalf and on Your sole
|
||||
responsibility, not on behalf of any other Contributor, and only if You agree to
|
||||
indemnify, defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason of your
|
||||
accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following boilerplate
|
||||
notice, with the fields enclosed by brackets "[]" replaced with your own
|
||||
identifying information. (Don't include the brackets!) The text should be
|
||||
enclosed in the appropriate comment syntax for the file format. We also
|
||||
recommend that a file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier identification within
|
||||
third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
44
README.md
Normal file
44
README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# my-app
|
||||
|
||||
(替换为你的项目介绍)
|
||||
|
||||
Bun 全栈应用模板,基于 Bun + React + TDesign 的前后端一体化开发框架。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
git clone <your-repo-url> my-project
|
||||
cd my-project
|
||||
cp config.example.yaml config.yaml
|
||||
bun install
|
||||
bun run dev config.yaml
|
||||
```
|
||||
|
||||
访问 http://127.0.0.1:5173 查看应用。
|
||||
|
||||
## 使用此模板
|
||||
|
||||
从零创建新项目:[使用模板](docs/user/usage.md)
|
||||
|
||||
## 文档导航
|
||||
|
||||
| 文档 | 内容 |
|
||||
| -------------------------------------- | ---------------------------------- |
|
||||
| [docs/README.md](docs/README.md) | 文档总览、归属矩阵、影响分析规则 |
|
||||
| [docs/user/](docs/user/) | 模板使用、配置、部署、故障排查 |
|
||||
| [docs/development/](docs/development/) | 架构、后端、前端、构建发布开发规范 |
|
||||
| [docs/prompts/](docs/prompts/) | AI 提示词资产(不属于常规文档流) |
|
||||
|
||||
## 常用命令
|
||||
|
||||
| 命令 | 说明 |
|
||||
| ---------------------- | ------------ |
|
||||
| `bun run dev <config>` | 启动开发模式 |
|
||||
| `bun run build` | 生产构建 |
|
||||
| `bun test` | 运行测试 |
|
||||
| `bun run check` | 完整质量检查 |
|
||||
| `bun run verify` | 验证构建流程 |
|
||||
|
||||
## 开源协议
|
||||
|
||||
MIT
|
||||
3
bunfig.toml
Normal file
3
bunfig.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[test]
|
||||
preload = ["./tests/setup.ts"]
|
||||
exclude = ["./tests/e2e/**"]
|
||||
8
commitlint.config.js
Normal file
8
commitlint.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
export default {
|
||||
extends: ["@commitlint/config-conventional"],
|
||||
rules: {
|
||||
"subject-case": [0],
|
||||
"subject-full-stop": [0],
|
||||
"type-enum": [2, "always", ["feat", "fix", "refactor", "docs", "style", "test", "chore"]],
|
||||
},
|
||||
};
|
||||
18
config.example.yaml
Normal file
18
config.example.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
# yaml-language-server: $schema=./config.schema.json
|
||||
server:
|
||||
listen:
|
||||
host: "${HOST|127.0.0.1}"
|
||||
port: ${PORT|3000}
|
||||
storage:
|
||||
dataDir: "./data"
|
||||
logging:
|
||||
level: "${LOG_LEVEL|info}"
|
||||
console:
|
||||
level: "info"
|
||||
file:
|
||||
level: "info"
|
||||
path: "./data/logs/my-app.log"
|
||||
rotation:
|
||||
size: "50MB"
|
||||
frequency: "daily"
|
||||
maxFiles: 14
|
||||
254
config.schema.json
Normal file
254
config.schema.json
Normal file
@@ -0,0 +1,254 @@
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"server": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"listen": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"anyOf": [
|
||||
{
|
||||
"maximum": 65535,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"pattern": "^\\$\\{[^}]+\\}$",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"console": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level": {
|
||||
"anyOf": [
|
||||
{
|
||||
"anyOf": [
|
||||
{
|
||||
"const": "trace",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "debug",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "info",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "warn",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "error",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "fatal",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"pattern": "^\\$\\{[^}]+\\}$",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"file": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level": {
|
||||
"anyOf": [
|
||||
{
|
||||
"anyOf": [
|
||||
{
|
||||
"const": "trace",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "debug",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "info",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "warn",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "error",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "fatal",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"pattern": "^\\$\\{[^}]+\\}$",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"rotation": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"frequency": {
|
||||
"anyOf": [
|
||||
{
|
||||
"anyOf": [
|
||||
{
|
||||
"const": "hourly",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "daily",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "weekly",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"pattern": "^\\$\\{[^}]+\\}$",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"maxFiles": {
|
||||
"anyOf": [
|
||||
{
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"pattern": "^\\$\\{[^}]+\\}$",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"size": {
|
||||
"anyOf": [
|
||||
{
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"pattern": "^\\$\\{[^}]+\\}$",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"level": {
|
||||
"anyOf": [
|
||||
{
|
||||
"anyOf": [
|
||||
{
|
||||
"const": "trace",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "debug",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "info",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "warn",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "error",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "fatal",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"pattern": "^\\$\\{[^}]+\\}$",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dataDir": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"variables": {
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
"^[a-zA-Z_][a-zA-Z0-9_]*$": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"$id": "https://app.local/config.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||
}
|
||||
115
docs/README.md
Normal file
115
docs/README.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# my-app 文档
|
||||
|
||||
本文档是 my-app 的文档路由入口。AI 工具和开发者应先阅读本文件判断本次任务需要读取和更新哪些专题文档,再按任务类型读取最小必要上下文。
|
||||
|
||||
## 目录索引
|
||||
|
||||
```text
|
||||
docs/
|
||||
README.md
|
||||
development/
|
||||
README.md
|
||||
architecture.md
|
||||
backend.md
|
||||
frontend.md
|
||||
release.md
|
||||
user/
|
||||
README.md
|
||||
usage.md
|
||||
config.md
|
||||
deploy.md
|
||||
troubleshoot.md
|
||||
prompts/
|
||||
README.md
|
||||
prompt-smart-merge.md
|
||||
prompt-proposal-review.md
|
||||
prompt-apply-review.md
|
||||
```
|
||||
|
||||
`docs/prompts/` 是提示词资产目录,不属于常规开发流程和用户使用文档。代码、配置或部署变更不需要更新该目录,除非任务明确要求维护提示词资产。
|
||||
|
||||
## 入口文档
|
||||
|
||||
| 入口 | 定位 |
|
||||
| --------------------------------- | -------------------------------------- |
|
||||
| [项目 README](../README.md) | 项目整体介绍、快速开始、文档引导 |
|
||||
| [开发文档](development/README.md) | 开发入口、全局规则、常用命令、质量门禁 |
|
||||
| [用户文档](user/README.md) | 模板使用、配置、部署、排障入口 |
|
||||
|
||||
## 按任务阅读路径
|
||||
|
||||
| 任务 | 必读文档 |
|
||||
| -------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| 修改项目介绍或快速开始 | [项目 README](../README.md)、本文档 |
|
||||
| 修改开发流程、质量门禁或工程规则 | [开发文档](development/README.md)、本文档、[OpenSpec 配置](../openspec/config.yaml) |
|
||||
| 修改架构边界或启动流程 | [开发文档](development/README.md)、[架构与边界](development/architecture.md) |
|
||||
| 修改后端 API、配置加载、日志 | [开发文档](development/README.md)、[后端开发](development/backend.md) |
|
||||
| 修改前端 | [开发文档](development/README.md)、[前端开发](development/frontend.md) |
|
||||
| 修改构建、脚本、发布 | [构建与发布](development/release.md)、[部署文档](user/deploy.md) |
|
||||
| 修改配置 schema | [配置文件](user/config.md)、[后端开发](development/backend.md) |
|
||||
| 修改文档规则或文档目录结构 | 本文档、[OpenSpec 配置](../openspec/config.yaml) |
|
||||
| 使用模板创建新项目 | [使用模板](user/usage.md)、[配置文件](user/config.md) |
|
||||
| 排查运行或构建问题 | [故障排查](user/troubleshoot.md) |
|
||||
|
||||
## 文档归属矩阵
|
||||
|
||||
| 变更类型 | 默认更新位置 |
|
||||
| --------------------------------------------------------- | ---------------------------------------- |
|
||||
| 项目定位、核心能力、快速开始、顶层文档导航 | `README.md` |
|
||||
| 文档路由、文档更新规则、文档归属矩阵 | `docs/README.md`、`openspec/config.yaml` |
|
||||
| 开发入口、常用命令、质量门禁、全局工程规则、OpenSpec 约定 | `docs/development/README.md` |
|
||||
| 架构边界、启动流程、运行时流程、前后端边界 | `docs/development/architecture.md` |
|
||||
| 后端 API、配置加载、logger、helpers、类型规范、后端测试 | `docs/development/backend.md` |
|
||||
| 前端技术栈、组件、样式、数据层、前端测试 | `docs/development/frontend.md` |
|
||||
| 构建、发布、脚本、前后端静态资源集成 | `docs/development/release.md` |
|
||||
| 使用模板、配置应用信息、清理 OpenSpec 历史 | `docs/user/usage.md` |
|
||||
| YAML 配置、变量语法、server/storage/logging、JSON Schema | `docs/user/config.md` |
|
||||
| 生产构建、可执行文件运行、运行时配置 | `docs/user/deploy.md` |
|
||||
| 常见运行问题、配置校验、变量解析、构建失败 | `docs/user/troubleshoot.md` |
|
||||
|
||||
## development 文档如何更新
|
||||
|
||||
开发文档解释"如何实现和维护"。代码变更影响开发者理解、开发流程、测试方式或架构边界时,必须更新 `docs/development/` 对应文档。
|
||||
|
||||
- 全局规则、常用命令、质量门禁、目录边界、OpenSpec 约定更新到 `docs/development/README.md`。
|
||||
- 架构图、启动链路、运行时流程、前后端边界更新到 `docs/development/architecture.md`。
|
||||
- 后端 API、配置加载、logger、helpers、类型规范和后端测试规范更新到 `docs/development/backend.md`。
|
||||
- 前端技术栈、组件边界、数据流、样式规则和前端测试规范更新到 `docs/development/frontend.md`。
|
||||
- 构建、脚本和发布验证更新到 `docs/development/release.md`。
|
||||
- 不新增"杂项"开发文档;优先把内容放入上述最贴近的专题,确需新增专题时先更新本文档和 `openspec/config.yaml`。
|
||||
|
||||
## user 文档如何更新
|
||||
|
||||
用户文档解释"如何使用"和"用户能观察到什么"。变更影响模板使用方式、配置、部署或运行行为时,必须更新 `docs/user/` 对应文档。
|
||||
|
||||
- 使用模板流程、应用信息配置、初始化步骤更新到 `docs/user/usage.md`。
|
||||
- 配置结构、变量语法、server/storage/logging 字段更新到 `docs/user/config.md`。
|
||||
- 生产构建、可执行文件运行、运行时依赖更新到 `docs/user/deploy.md`。
|
||||
- 常见错误和排查路径更新到 `docs/user/troubleshoot.md`。
|
||||
- 用户文档避免解释内部实现细节,需要实现细节时链接到 `docs/development/`。
|
||||
|
||||
## 文档影响分析
|
||||
|
||||
每次代码变更都必须执行文档影响分析。
|
||||
|
||||
```text
|
||||
代码或配置变更
|
||||
-> 用户能感知吗?更新 docs/user/ 或 README.md
|
||||
-> 开发者需要知道吗?更新 docs/development/
|
||||
-> 文档规则变化吗?更新 docs/README.md 和 openspec/config.yaml
|
||||
-> 都不是?收尾说明写明无需更新文档及原因
|
||||
```
|
||||
|
||||
同一事实只在最贴近读者的文档中完整展开,其他文档使用链接引用。根目录 README 保持轻量,不承载完整配置参考或实现教程。
|
||||
|
||||
## 收尾说明示例
|
||||
|
||||
```text
|
||||
文档影响分析:本次修改了后端日志配置字段,已更新 docs/development/backend.md 和 docs/user/config.md。
|
||||
```
|
||||
|
||||
无需更新文档时:
|
||||
|
||||
```text
|
||||
文档影响分析:本次仅调整内部测试 helper,未改变用户可见行为、配置、架构边界或开发流程,因此无需更新文档。
|
||||
```
|
||||
115
docs/development/README.md
Normal file
115
docs/development/README.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# 开发文档
|
||||
|
||||
本文档是 my-app 的开发入口。AI 工具和开发者应先阅读 [`../README.md`](../README.md) 判断文档归属,再阅读本文和最小必要专题。
|
||||
|
||||
适用场景:修改源码、测试、构建脚本、开发流程、架构边界或项目工程规则。
|
||||
|
||||
## 专题索引
|
||||
|
||||
| 文档 | 内容 |
|
||||
| ---------------------------------- | ---------------------------------------------------------------- |
|
||||
| [architecture.md](architecture.md) | 项目结构、启动流程、运行时流程、HTTP 请求流程、前后端边界 |
|
||||
| [backend.md](backend.md) | 后端库优先级、API 路由、共享工具、类型规范、配置契约、日志、测试 |
|
||||
| [frontend.md](frontend.md) | React、TDesign、TanStack Query、组件、样式和前端测试规范 |
|
||||
| [release.md](release.md) | 开发服务、前后端集成、构建、脚本、环境变量 |
|
||||
| [../README.md](../README.md) | 文档路由、文档归属矩阵、development/user 文档更新规则 |
|
||||
|
||||
## 常用命令
|
||||
|
||||
| 命令 | 说明 |
|
||||
| -------------------------------- | -------------------------------------- |
|
||||
| `bun install` | 安装依赖 |
|
||||
| `bun run dev config.yaml` | 启动双进程开发环境 |
|
||||
| `bun run dev:server config.yaml` | 仅启动后端 API server |
|
||||
| `bun run dev:web` | 仅启动 Vite dev server |
|
||||
| `bun run schema` | 生成 config.schema.json |
|
||||
| `bun run schema:check` | 检查导出 schema 是否同步 |
|
||||
| `bun run typecheck` | TypeScript 类型检查 |
|
||||
| `bun run lint` | ESLint 和 Prettier 格式检查 |
|
||||
| `bun run format` | Prettier 自动格式化 |
|
||||
| `bun test` | 运行全部测试 |
|
||||
| `bun run check` | schema:check + typecheck + lint + test |
|
||||
| `bun run build` | 构建生产可执行文件 |
|
||||
| `bun run verify` | check + build 完整验证 |
|
||||
| `bun run clean` | 清理构建缓存与临时文件 |
|
||||
| `bun run version:patch` | 升迁 patch 版本(x.y.Z) |
|
||||
| `bun run version:minor` | 升迁 minor 版本(x.Y.0) |
|
||||
| `bun run version:major` | 升迁 major 版本(X.0.0) |
|
||||
| `bun run version:set` | 显式设置版本号 |
|
||||
|
||||
## 质量门禁
|
||||
|
||||
代码变更必须按影响范围执行验证。
|
||||
|
||||
| 变更类型 | 必跑命令 |
|
||||
| -------------------------- | --------------------------------------------------------- |
|
||||
| 常规代码变更 | `bun run check` |
|
||||
| 构建、部署、前后端集成变更 | `bun run verify` |
|
||||
| 配置 schema 变化 | `bun run schema`、`bun run schema:check`、`bun run check` |
|
||||
| 仅文档变更 | 检查链接、索引和文档归属一致性 |
|
||||
|
||||
正式提交或影响构建产物时优先运行 `bun run verify`。如果因环境限制无法执行完整验证,必须在收尾说明中记录未执行项和原因。
|
||||
|
||||
## 全局工程规则
|
||||
|
||||
- 使用中文编写注释、文档和项目内交流内容。
|
||||
- 仅使用 bun 作为包管理器,禁止使用 npm、pnpm、yarn。
|
||||
- 运行工具使用 bunx,禁止使用 npx、pnpx。
|
||||
- 新增代码优先复用已有组件、工具和依赖库,不引入新依赖;确需新增依赖时先说明原因。
|
||||
- 后端优先使用 Bun 内置 API,其次是 es-toolkit、标准 Web API、主流三方库,最后才自行实现。
|
||||
- 前端样式优先使用 TDesign 组件、组件 props、TDesign CSS tokens、styles.css CSS 类,最后才自行开发组件。
|
||||
- 前端禁止组件内联 style、覆盖 TDesign 内部类名、使用 !important、硬编码色值。
|
||||
- 当前项目为模板参考项目,不需要考虑向前兼容。
|
||||
|
||||
## 包管理、依赖与提交
|
||||
|
||||
- 仅使用 bun 安装依赖和运行项目脚本,锁文件为 bun.lock。
|
||||
- 新增依赖前先确认 Bun 内置 API、es-toolkit、标准 Web API、现有三方库和项目公共工具是否已满足需求。
|
||||
- Git 提交信息使用中文,格式为"类型: 简短描述"。
|
||||
- 提交类型限定为 feat、fix、refactor、docs、style、test、chore。
|
||||
- 多行提交描述时,标题和正文之间空一行。
|
||||
|
||||
## 目录边界
|
||||
|
||||
| 目录 | 约定 |
|
||||
| ------------------- | -------------------------------------------------------- |
|
||||
| `src/server/` | Bun 后端代码,不能 import src/web/,HTML import 集成除外 |
|
||||
| `src/web/` | React 前端,不能 import src/server/ 运行时实现 |
|
||||
| `src/shared/` | 前后端共享 TypeScript 类型 |
|
||||
| `scripts/` | 独立运行脚本,可 import 项目源码 |
|
||||
| `tests/` | 测试目录,结构镜像 src/ |
|
||||
| `docs/user/` | 模板使用、配置、部署和排障文档 |
|
||||
| `docs/development/` | 架构、后端、前端、发布开发文档 |
|
||||
| `openspec/` | OpenSpec 变更管理与规格文档 |
|
||||
|
||||
## 文档影响分析
|
||||
|
||||
每次代码变更都必须执行文档影响分析。
|
||||
|
||||
| 如果变更影响 | 更新 |
|
||||
| ------------------------------------------ | ------------------------------------------ |
|
||||
| 用户可见行为、配置、部署、运行行为 | `docs/user/` 对应文档 |
|
||||
| 开发流程、架构、测试、构建发布流程 | `docs/development/` 对应文档 |
|
||||
| 项目定位、快速开始、核心能力列表、文档导航 | `README.md` |
|
||||
| 文档同步规则或文档归属矩阵 | `docs/README.md` 和 `openspec/config.yaml` |
|
||||
|
||||
如果无需更新文档,必须在收尾说明中说明原因。详细规则见 [文档总览](../README.md)。
|
||||
|
||||
## OpenSpec 协作规则
|
||||
|
||||
- 本项目 OpenSpec 使用 fast-drive schema,变更文档只包含 design.md 和 tasks.md,不创建 proposal.md 或 specs/\*.md。
|
||||
- design.md 是 scope、requirements、decisions、guardrails、execution direction 和 verification expectations 的 source of truth。
|
||||
- tasks.md 必须从 design.md 派生,一行一个 checkbox 任务。
|
||||
- 实现阶段按 tasks.md 顺序执行,完成后立即标记任务状态。
|
||||
|
||||
## 事实来源
|
||||
|
||||
| 主题 | 事实来源 |
|
||||
| -------------- | -------------------------------------------------- |
|
||||
| 代码结构和实现 | `src/`、`scripts/`、`tests/` |
|
||||
| 配置 schema | TypeBox fragments、config.schema.json、schema 测试 |
|
||||
| 项目全局规则 | `openspec/config.yaml`、本文档、本目录专题文档 |
|
||||
|
||||
## 更新触发条件
|
||||
|
||||
修改常用命令、质量门禁、全局工程规则、目录边界、OpenSpec 协作方式或开发文档索引时,必须更新本文档。
|
||||
92
docs/development/architecture.md
Normal file
92
docs/development/architecture.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# 架构与边界
|
||||
|
||||
本文档说明 my-app 的项目结构、启动链路、运行时流程、HTTP 请求流程和前后端边界。
|
||||
|
||||
适用场景:修改目录边界、启动流程、运行时调度、HTTP server、前后端集成方式或主要模块职责。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
src/
|
||||
server/
|
||||
bootstrap.ts 统一启动引导(loadServerConfig -> startServer)
|
||||
config.ts CLI 参数解析与配置文件加载 facade
|
||||
config/ 配置解析模块(types、issues、variables、normalizer、schema)
|
||||
dev.ts 开发模式启动入口
|
||||
main.ts 生产模式启动入口
|
||||
server.ts HTTP server 启动工厂(Bun.serve routes 声明式路由)
|
||||
static.ts 生产模式静态资源服务
|
||||
helpers.ts 共享响应格式化工具
|
||||
middleware.ts API 参数校验中间件
|
||||
logger.ts 结构化日志(基于 pino + pino-roll)
|
||||
version.ts 运行时版本号读取
|
||||
routes/ API 路由处理器
|
||||
shared/
|
||||
api.ts 前后端共享 TypeScript 类型定义
|
||||
app.ts 应用全局常量(name、title、subtitle、description)
|
||||
web/ React 前端(通过 Vite 构建)
|
||||
index.html HTML 入口
|
||||
main.tsx React 入口
|
||||
app.tsx 根组件
|
||||
routes.tsx 路由配置
|
||||
styles.css 全局样式
|
||||
pages/ 页面组件
|
||||
components/ UI 组件
|
||||
hooks/ React Hooks
|
||||
utils/ 前端工具函数
|
||||
scripts/ 独立运行脚本
|
||||
tests/ 测试文件(镜像 src 目录结构)
|
||||
docs/ 项目文档
|
||||
openspec/ OpenSpec 规格、变更与 fast-drive workflow schema
|
||||
```
|
||||
|
||||
## 启动流程
|
||||
|
||||
```text
|
||||
dev.ts / main.ts
|
||||
-> parseRuntimeArgs(cli args)
|
||||
-> 必须指定 config.yaml
|
||||
-> bootstrap({ configPath, mode })
|
||||
-> loadServerConfig(configPath)
|
||||
-> createRuntimeLogger(config.logging)
|
||||
-> startServer({ config, logger })
|
||||
-> logger 记录启动成功
|
||||
-> SIGINT/SIGTERM -> logger.flush() -> exit
|
||||
```
|
||||
|
||||
## HTTP 请求流程
|
||||
|
||||
```text
|
||||
Request
|
||||
-> Bun.serve routes 声明式匹配
|
||||
-> routes/*.ts handler
|
||||
-> helpers.ts 响应格式化
|
||||
-> Response
|
||||
```
|
||||
|
||||
生产模式下,非 API 路径由 fetch fallback 处理:有文件扩展名的返回静态资源或 404,无扩展名的返回 SPA index.html。
|
||||
|
||||
开发模式下,Vite proxy 将 /api 请求转发到 Bun API server。
|
||||
|
||||
## 前后端边界
|
||||
|
||||
- 前端只通过 HTTP 调用后端,API 路径为 /api/\*。
|
||||
- 共享类型放在 src/shared/。
|
||||
- 前端不得 import src/server/ 的运行时实现。
|
||||
- 后端不得依赖 src/web/ 运行时代码,HTML import 集成除外。
|
||||
|
||||
## 主要模块职责
|
||||
|
||||
| 模块 | 职责 |
|
||||
| ------------------------- | ---------------------------------------- |
|
||||
| `src/server/bootstrap.ts` | 统一启动引导和 shutdown 编排 |
|
||||
| `src/server/server.ts` | Bun HTTP server 和 routes 注册 |
|
||||
| `src/server/routes/` | API handler,按端点拆分 |
|
||||
| `src/server/config/` | 配置解析模块(types、variables、schema) |
|
||||
| `src/web/` | React 前端 |
|
||||
| `src/shared/api.ts` | 前后端共享 API 类型 |
|
||||
| `src/shared/app.ts` | 应用全局常量 |
|
||||
|
||||
## 更新触发条件
|
||||
|
||||
修改项目结构、启动流程、HTTP 请求流程、前后端边界或主要模块职责时,必须更新本文档。
|
||||
101
docs/development/backend.md
Normal file
101
docs/development/backend.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# 后端开发
|
||||
|
||||
本文档说明 my-app 后端的 API、配置加载、日志、版本管理和后端测试开发约定。
|
||||
|
||||
适用场景:修改 src/server/、src/shared/api.ts、后端测试、配置契约、API 响应或日志模块。
|
||||
|
||||
## 库使用优先级
|
||||
|
||||
| 优先级 | 来源 | 典型用途 |
|
||||
| ------ | ------------ | ---------------------------------------- |
|
||||
| 1 | Bun 内置 API | Bun.serve、Bun.file、Bun.YAML、Bun.spawn |
|
||||
| 2 | es-toolkit | 类型判断、深度比较、并发控制 |
|
||||
| 3 | 标准 Web API | Headers、fetch、AbortController |
|
||||
| 4 | 主流三方库 | pino、@sinclair/typebox、ajv |
|
||||
| 5 | 自行实现 | 仅在以上都无法满足时 |
|
||||
|
||||
新增依赖前必须先检查上述每一层是否已有可用方案。
|
||||
|
||||
## API 路由开发
|
||||
|
||||
路由文件位于 src/server/routes/,每个端点一个文件。路由通过 server.ts 的 Bun.serve({ routes }) 声明式注册。
|
||||
|
||||
新增路由步骤:
|
||||
|
||||
1. 在 src/server/routes/ 下创建 <name>.ts
|
||||
2. 实现 handler 函数并 export
|
||||
3. 在 server.ts 的 routes 对象中注册路径和 method handler
|
||||
4. 在 tests/server/ 中添加对应测试
|
||||
|
||||
## 共享工具
|
||||
|
||||
helpers.ts 提供跨路由共用的响应工具:
|
||||
|
||||
- createApiError(error, status) — 构造 API 错误体
|
||||
- createHeaders(mode, init) — 创建响应 Headers
|
||||
- jsonResponse(body, options) — JSON 响应构造
|
||||
|
||||
middleware.ts 提供 API 参数校验函数:
|
||||
|
||||
- validateIdParam(idStr, mode) — 校验 ID 参数格式
|
||||
- validatePagination(pageParam, pageSizeParam, mode) — 校验分页参数
|
||||
- validateTimeRange(from, to, mode) — 校验时间范围参数
|
||||
|
||||
## 类型规范
|
||||
|
||||
- 共享类型以 src/shared/api.ts 为唯一源头
|
||||
- 应用常量以 src/shared/app.ts 为唯一源头
|
||||
- 版本号以 package.json.version 为唯一源头
|
||||
- 前端不得 import src/server/ 下的任何文件
|
||||
- 严格联合类型优先于宽类型
|
||||
|
||||
## 配置契约
|
||||
|
||||
配置加载流程固定为:unknown -> AuthoringConfig -> NormalizedConfig -> ValidatedConfig -> ServerConfig。
|
||||
|
||||
Ajv 保持严格拒绝模式:allErrors: true、不启用类型强制转换、不注入默认值、不自动删除未知字段。
|
||||
|
||||
新增或修改配置字段时必须同步更新 TypeBox schema fragments、config.schema.json、测试和对应用户文档。
|
||||
|
||||
## 日志模块
|
||||
|
||||
后端运行时代码统一通过 Logger 接口输出日志,禁止直接使用 console.\*。
|
||||
|
||||
| 实现 | 用途 |
|
||||
| --------------------- | ------------------------ |
|
||||
| PinoLoggerWrapper | 生产运行时 |
|
||||
| ConsoleFallbackLogger | 配置加载失败前的降级日志 |
|
||||
| NoopLogger | 静默丢弃日志 |
|
||||
| MemoryLogger | 测试替身 |
|
||||
|
||||
敏感信息会自动 redact authorization、cookie、password 等字段。
|
||||
|
||||
## 版本管理
|
||||
|
||||
项目使用 package.json.version 作为版本号唯一来源。
|
||||
|
||||
版本获取方式:
|
||||
|
||||
- 开发模式:src/server/version.ts 运行时从 package.json 读取
|
||||
- 生产模式:scripts/build.ts 在构建时将版本号烘焙为字面量注入
|
||||
|
||||
版本升迁命令:
|
||||
|
||||
```bash
|
||||
bun run version:patch # 升迁 patch 版本
|
||||
bun run version:minor # 升迁 minor 版本
|
||||
bun run version:major # 升迁 major 版本
|
||||
bun run version:set # 显式设置版本号
|
||||
```
|
||||
|
||||
## 后端测试
|
||||
|
||||
| 变更类型 | 测试重点 |
|
||||
| ------------------ | --------------------------------- |
|
||||
| API 路由 | tests/server/app.test.ts 集成行为 |
|
||||
| 配置 schema | schema 导出、合法/非法配置 |
|
||||
| helpers/middleware | 单元测试 |
|
||||
|
||||
## 更新触发条件
|
||||
|
||||
修改后端 API、共享类型、配置契约、日志模块、版本管理或后端测试规范时,必须更新本文档。
|
||||
73
docs/development/frontend.md
Normal file
73
docs/development/frontend.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# 前端开发
|
||||
|
||||
本文档说明 my-app 前端的 React、TDesign、TanStack Query、组件、样式和前端测试约定。
|
||||
|
||||
适用场景:修改 src/web/、前端共享类型使用方式、组件结构、样式规则或前端测试。
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层面 | 技术 | 用途 |
|
||||
| ------ | --------------------------------------------------- | ------------------------ |
|
||||
| 框架 | React 19 | UI 组件开发 |
|
||||
| 构建 | Vite(开发)+ Bun compile(生产) | 开发服务 HMR 与生产构建 |
|
||||
| 语言 | TypeScript | 类型安全 |
|
||||
| UI 库 | TDesign React + tdesign-icons-react | UI 组件与图标 |
|
||||
| 数据层 | TanStack Query (React Query) + React Query Devtools | 服务端状态管理与自动刷新 |
|
||||
| 路由 | React Router v7 (Declarative mode) | SPA 路由与页面导航 |
|
||||
|
||||
不引入额外状态管理库。TanStack Query 承担服务端状态,组件内状态使用 useState。
|
||||
|
||||
## 组件开发规范
|
||||
|
||||
- 每个 React 组件一个 .tsx 文件,文件名使用 PascalCase
|
||||
- 组件 props 定义为 interface XxxProps,紧邻组件函数声明
|
||||
- 类型从 src/shared/api.ts 导入,使用 import type
|
||||
- 展示组件放在 components/,通过 props 接收数据,通过回调返回事件
|
||||
- 容器逻辑放在 hooks 中,组件只做数据消费
|
||||
- 工具函数放在 utils/,保持纯函数无副作用
|
||||
|
||||
## 样式开发规范
|
||||
|
||||
前端基于 TDesign React 构建 UI,样式开发优先级:
|
||||
|
||||
1. TDesign 组件
|
||||
2. TDesign 组件 props
|
||||
3. TDesign CSS tokens(--td-\*)
|
||||
4. styles.css CSS 类
|
||||
5. 自行开发组件
|
||||
|
||||
红线:
|
||||
|
||||
- 严禁在组件中使用 style 属性内联调整样式
|
||||
- 严禁通过 CSS 覆盖 TDesign 组件内部类名
|
||||
- 严禁使用 !important
|
||||
- 颜色统一使用 TDesign CSS tokens,不使用硬编码色值
|
||||
|
||||
styles.css 组织:
|
||||
|
||||
- 自定义 CSS 变量定义在 :root 中
|
||||
- 布局类定义全局页面结构
|
||||
- 组件修饰类为自定义视觉组件提供样式变体
|
||||
- 通用工具类提供公用排版能力
|
||||
|
||||
## TanStack Query 规范
|
||||
|
||||
- Query key 使用 structured array,使用 as const 保持字面量类型
|
||||
- 全局面板级查询可持续刷新,详情级查询必须按状态条件启用
|
||||
|
||||
## fetch 封装
|
||||
|
||||
统一使用 fetch,不引入 axios。错误抛异常,由 TanStack Query 的 error 状态承接。
|
||||
|
||||
## 前端测试
|
||||
|
||||
- 测试目录为 tests/web/,结构对应 src/web/
|
||||
- 单元测试重点覆盖 utils/ 和 hooks 中的纯逻辑
|
||||
- 组件测试使用 jsdom 和 @testing-library/react
|
||||
- 测试用户行为而非实现细节
|
||||
- 只 mock 系统边界,使用真实的 QueryClientProvider 包裹组件
|
||||
- 组件测试环境由 tests/setup.ts 和 bunfig.toml preload 提供
|
||||
|
||||
## 更新触发条件
|
||||
|
||||
修改前端技术栈、组件边界、数据流、样式规则、测试环境或前端验证方式时,必须更新本文档。
|
||||
97
docs/development/release.md
Normal file
97
docs/development/release.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# 构建与发布
|
||||
|
||||
本文档说明开发服务、前后端集成、生产构建、脚本维护和环境变量。
|
||||
|
||||
适用场景:修改 scripts/、构建流程、静态资源集成或环境变量。
|
||||
|
||||
## 开发期运行
|
||||
|
||||
```bash
|
||||
bun run dev config.yaml
|
||||
```
|
||||
|
||||
scripts/dev.ts 同时启动两个进程:
|
||||
|
||||
| 进程 | 用途 |
|
||||
| --------------- | --------------------------------------- |
|
||||
| Bun API server | 后端 API 服务,--watch 监听变更自动重启 |
|
||||
| Vite dev server | 前端 SPA、HMR 热更新 |
|
||||
|
||||
也可以单独启动:
|
||||
|
||||
```bash
|
||||
bun run dev:server config.yaml # 仅启动后端 API server
|
||||
bun run dev:web # 仅启动 Vite dev server
|
||||
```
|
||||
|
||||
## 前后端集成
|
||||
|
||||
开发模式下,Vite 通过 proxy 将 /api/\* 转发到 Bun。
|
||||
|
||||
生产模式下,前端通过 Vite 构建为静态资源,通过 import with { type: "file" } 嵌入 Bun 可执行文件。非 API 路径由 fetch fallback 处理。
|
||||
|
||||
路由优先级:Bun routes 具体路径 > 通配符。/api/meta 优先于 /api/\*。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
```
|
||||
|
||||
构建流程:
|
||||
|
||||
```text
|
||||
1. Vite build -> dist/web/
|
||||
2. Code generation -> .build/static-assets.ts + .build/server-entry.ts
|
||||
3. Bun compile -> dist/my-app
|
||||
```
|
||||
|
||||
构建参数:
|
||||
|
||||
| 环境变量 | 说明 |
|
||||
| ------------------------- | ---------------- |
|
||||
| BUN_TARGET / BUILD_TARGET | 交叉编译目标平台 |
|
||||
|
||||
## 脚本说明
|
||||
|
||||
| 脚本 | 文件 | 说明 |
|
||||
| --------------------- | --------------------------------- | ------------------------------ |
|
||||
| bun run dev | scripts/dev.ts | 双进程开发服务 |
|
||||
| bun run dev:server | src/server/dev.ts | 仅启动后端 API server |
|
||||
| bun run dev:web | Vite CLI | 仅启动 Vite dev server |
|
||||
| bun run build | scripts/build.ts | Vite -> codegen -> Bun compile |
|
||||
| bun run schema | scripts/generate-config-schema.ts | 生成配置 JSON Schema |
|
||||
| bun run schema:check | scripts/generate-config-schema.ts | 检查配置 JSON Schema 同步 |
|
||||
| bun run clean | scripts/clean.ts | 清理构建缓存与临时文件 |
|
||||
| bun run version:patch | scripts/bump-version.ts | 升迁 patch 版本 |
|
||||
| bun run version:minor | scripts/bump-version.ts | 升迁 minor 版本 |
|
||||
| bun run version:major | scripts/bump-version.ts | 升迁 major 版本 |
|
||||
| bun run version:set | scripts/bump-version.ts | 显式设置版本号 |
|
||||
|
||||
## 项目配置文件
|
||||
|
||||
| 文件 | 用途 |
|
||||
| -------------------- | --------------------------- |
|
||||
| package.json | 项目信息、脚本、依赖声明 |
|
||||
| tsconfig.json | TypeScript 配置 |
|
||||
| eslint.config.js | ESLint 规则 |
|
||||
| commitlint.config.js | commitlint 提交信息格式校验 |
|
||||
| .prettierrc.json | Prettier 格式化规则 |
|
||||
| .lintstagedrc.json | lint-staged 配置 |
|
||||
| config.example.yaml | 配置文件示例 |
|
||||
| config.schema.json | 配置文件 JSON Schema |
|
||||
| vite.config.ts | Vite 构建配置 |
|
||||
| bunfig.toml | Bun 配置 |
|
||||
|
||||
## 验证期望
|
||||
|
||||
| 变更类型 | 验证方式 |
|
||||
| ---------------- | -------------------- |
|
||||
| 构建脚本 | bun run verify |
|
||||
| 静态资源集成 | bun run build |
|
||||
| 配置 schema 同步 | bun run schema:check |
|
||||
| 发布前完整验证 | bun run verify |
|
||||
|
||||
## 更新触发条件
|
||||
|
||||
修改开发服务、前后端集成、构建产物、脚本参数或验证方式时,必须更新本文档。
|
||||
295
docs/prompts/README.md
Normal file
295
docs/prompts/README.md
Normal file
@@ -0,0 +1,295 @@
|
||||
# Prompts
|
||||
|
||||
面向 AI 大模型的执行型提示词集合。每份提示词都应能被单独复制使用,并驱动 AI 以一致的方式完成收集、分析、确认、执行和收尾。
|
||||
|
||||
## 提示词
|
||||
|
||||
| 文件 | 用途 |
|
||||
| ------------------------------------------------------ | ------------------------------------------------------------------------- |
|
||||
| [prompt-smart-merge.md](prompt-smart-merge.md) | 批量合并 `dev*` 分支到目标分支,含规则探测、依赖分析、冲突处理、安全回退 |
|
||||
| [prompt-proposal-review.md](prompt-proposal-review.md) | 审查 fast-drive design/tasks 与讨论、实际状态、OpenSpec workflow 的一致性 |
|
||||
| [prompt-apply-review.md](prompt-apply-review.md) | 审查 apply 后实际产物、验证、design/tasks 的一致性,并补齐遗漏或回写文档 |
|
||||
|
||||
## 边界说明
|
||||
|
||||
本目录为 AI 大模型执行型提示词资产,不属于常规用户文档或开发文档流。文档影响分析不覆盖本目录内容。
|
||||
|
||||
## 设计目标
|
||||
|
||||
从现有提示词提炼出的共同目标:
|
||||
|
||||
- 面向执行,不面向讲解
|
||||
- 先收集证据,再做判断
|
||||
- 先计划和确认,再做有副作用的修改
|
||||
- 对缺失信息、规则冲突、上下文不足有降级路径
|
||||
- 对删除、重写、提交、回退、推送等高风险动作有明确授权边界
|
||||
- 执行后必须复核,形成闭环
|
||||
|
||||
## 命名规则
|
||||
|
||||
文件名格式:`prompt-{action}.md`
|
||||
|
||||
- `{action}` 使用明确、可搜索、无歧义的英文短语
|
||||
- 用连字符连接,不使用缩写、代号或过泛词
|
||||
- 优先体现动作和对象,如 `smart-merge`、`spec-review`、`apply-review`
|
||||
|
||||
## 通用骨架
|
||||
|
||||
大多数提示词应遵循以下结构;按任务类型增删章节,但顺序尽量保持一致:
|
||||
|
||||
```md
|
||||
一句话描述任务目标
|
||||
|
||||
## 约束
|
||||
|
||||
## 1. 收集 / 准备
|
||||
|
||||
## 2. 分析
|
||||
|
||||
## 3. 报告 或 计划(用户确认)
|
||||
|
||||
## 4. 执行(用户确认)
|
||||
|
||||
## 5. 清理 / 收尾
|
||||
```
|
||||
|
||||
适用方式:
|
||||
|
||||
- 审查型:`收集 → 分析 → 报告 → 计划 → 执行 → 收尾`
|
||||
- 执行型:`准备 → 分析 → 计划 → 执行 → 清理`
|
||||
- 纯报告型:可省略执行,但仍要保留“信息不足时如何降级”和“结果如何输出”
|
||||
|
||||
## 编写原则
|
||||
|
||||
### 1. 面向 AI,不写背景说明
|
||||
|
||||
- 不写业务背景、适用场景、周期性说明、方法论阐释
|
||||
- 不写“为什么这么做”的长解释,直接写成规则或步骤
|
||||
- 不写示例输出模板,除非输出格式本身就是约束的一部分
|
||||
|
||||
### 2. 证据先于结论
|
||||
|
||||
- 明确列出需要读取的文档、代码、测试、配置、命令结果
|
||||
- 能并行的步骤明确写“并行”
|
||||
- 默认优先使用当前会话信息、现有文档和仓库规则
|
||||
- 只有在无法定位对象、范围或规则时,才引导 AI 向用户提问或运行补充命令
|
||||
- 不要求 AI 无差别全量扫描整个仓库;先建立索引,再做定向读取
|
||||
|
||||
### 3. 约束集中声明
|
||||
|
||||
- 全局不可违反的规则统一放在 `## 约束`
|
||||
- 不在后续步骤反复重复同一条规则
|
||||
- 约束优先写边界、禁令、授权条件、同步要求、非目标
|
||||
|
||||
常见约束类型:
|
||||
|
||||
- 只允许修改哪些对象,不允许修改哪些对象
|
||||
- 是否默认按某个 workflow 执行
|
||||
- 是否以代码、文档、讨论或用户确认为准
|
||||
- 何时必须使用提问工具确认
|
||||
- 删除、重写前是否必须备份
|
||||
- 改动后是否必须同步 README、测试、变更文档
|
||||
|
||||
### 4. 计划与执行分离
|
||||
|
||||
- 分析阶段只产出问题、风险、候选动作,不直接修改
|
||||
- 执行前必须先给出计划或批次方案
|
||||
- 批次计划必须能让用户看懂“改什么、为什么、影响什么、如何验证”
|
||||
- 用户确认执行,不等于授权所有危险动作;额外高风险动作要单独确认
|
||||
|
||||
### 5. 闭环优先
|
||||
|
||||
- 执行后必须重新读取受影响对象并复核
|
||||
- 对代码修改要说明测试或验证方式
|
||||
- 对文档修改要检查相关文档之间是否同步一致
|
||||
- 收尾时要列出修改文件、备份文件、验证结果和残留风险
|
||||
|
||||
## 各章节写法
|
||||
|
||||
### 目标句
|
||||
|
||||
第一句只做三件事:
|
||||
|
||||
- 说明任务对象
|
||||
- 说明最终目标
|
||||
- 说明执行方式或范围
|
||||
|
||||
要求:
|
||||
|
||||
- 一句话写完
|
||||
- 不写背景铺垫
|
||||
- 尽量包含最终产物,如“生成计划”“回写文档”“整理稳定规范”
|
||||
|
||||
### 约束
|
||||
|
||||
推荐写法:
|
||||
|
||||
- 作用域边界:改什么,不改什么
|
||||
- 真相来源优先级:代码 / README / spec / 讨论 / 用户确认
|
||||
- 风险动作边界:删除、重写、提交、推送、回退、stash、merge 等
|
||||
- 同步要求:测试、README、变更文档、现有 spec 是否要同步
|
||||
- 降级规则:信息不足时如何处理
|
||||
|
||||
避免:
|
||||
|
||||
- 在约束里塞步骤顺序
|
||||
- 同一规则在约束和执行里重复出现多次
|
||||
|
||||
### 收集 / 准备
|
||||
|
||||
要明确三类内容:
|
||||
|
||||
- 读什么
|
||||
- 是否并行
|
||||
- 无法确定时如何补充定位
|
||||
|
||||
推荐做法:
|
||||
|
||||
- 先读仓库规则来源,如 `README.md`、配置、架构文档、近期提交、任务入口
|
||||
- 先读直接相关 artifacts,再扩展到相关代码和测试
|
||||
- 需要探测时,要求 AI 先探测再决定,不把仓库结构写死在提示词里
|
||||
|
||||
### 分析
|
||||
|
||||
分析部分优先使用表格表达维度、优先级和判定规则。
|
||||
|
||||
推荐包含:
|
||||
|
||||
- 优先级或维度表
|
||||
- 差异分类规则
|
||||
- 风险分级规则
|
||||
- 是否进入待确认清单的判定条件
|
||||
|
||||
常见分析模式:
|
||||
|
||||
- `P0 / P1 / P2 / P3` 优先级
|
||||
- “过时 / 重复 / 冲突 / 错位 / 命名 / 格式” 维度
|
||||
- “文档要求未实现 / 代码修补未回写 / 双方冲突待确认” 差异分类
|
||||
|
||||
### 报告 / 计划
|
||||
|
||||
这是现有提示词最稳定的共性区块,建议固定包含:
|
||||
|
||||
- 问题总览表
|
||||
- 逐项分析
|
||||
- 待确认清单
|
||||
- 分批执行方案
|
||||
- 预期结果或目录结构
|
||||
|
||||
若进入计划阶段,必须写清:
|
||||
|
||||
- 改哪些文件或对象
|
||||
- 动作类型:删除、重命名、迁移、合并、拆分、补充、回写、修复
|
||||
- 修改原因
|
||||
- 预期影响
|
||||
- 验证方式
|
||||
|
||||
### 执行
|
||||
|
||||
执行部分要强调可操作性:
|
||||
|
||||
- 明确顺序执行还是可并行执行
|
||||
- 明确每批执行前是否确认
|
||||
- 明确删除、重写、回退前是否要备份或创建锚点
|
||||
- 明确执行后最少要复核哪些点
|
||||
|
||||
推荐写法:
|
||||
|
||||
- “逐批执行”或“逐项执行”
|
||||
- “每批执行后重新读取受影响文件并复核”
|
||||
- “若涉及删除或重写,先创建备份文件 `{file}.bak.{timestamp}`”
|
||||
|
||||
### 清理 / 收尾
|
||||
|
||||
收尾要输出结果,不只说“完成”。
|
||||
|
||||
建议包含:
|
||||
|
||||
- 修改文件清单
|
||||
- 备份文件清单
|
||||
- 测试 / 构建 / 验证命令与结果
|
||||
- 文档同步摘要
|
||||
- 残留问题、未验证项、待确认事项
|
||||
|
||||
## 交互与安全规范
|
||||
|
||||
### 必须确认的动作
|
||||
|
||||
以下动作默认都要在提示词中要求 AI 使用提问工具确认:
|
||||
|
||||
- 删除文件或目录
|
||||
- 重写文件
|
||||
- 创建或恢复 stash
|
||||
- 回退、reset、revert、abort
|
||||
- 合并提交、推送、删分支、删远端分支、删 tag
|
||||
- 任何会改变工作区现场且用户未明确授权的操作
|
||||
|
||||
### 授权边界要写清
|
||||
|
||||
提示词要明确区分:
|
||||
|
||||
- “确认当前计划”
|
||||
- “确认执行当前批次”
|
||||
- “确认某个具体危险动作”
|
||||
|
||||
不要把这些授权混为一谈。
|
||||
|
||||
### 回退路径要提前写好
|
||||
|
||||
对高风险流程,提示词应提供至少一种回退机制:
|
||||
|
||||
- 备份文件
|
||||
- 安全锚点 tag
|
||||
- `abort` 路径
|
||||
- 终止后的现场说明
|
||||
|
||||
## 表达与格式
|
||||
|
||||
- 优先使用表格表达规则、维度、状态、输出项
|
||||
- 列表优于段落
|
||||
- 每句话只写一条指令
|
||||
- 用 `{占位符}` 表示需要 AI 或用户填入的参数
|
||||
- 步骤编号使用 `## 1.`、`## 2.`,不用“第 X 步”
|
||||
- 信息展示遵循“概览 → 详情 → 原始数据”
|
||||
|
||||
## 仓库适配规则
|
||||
|
||||
- 若仓库文档已经定义命令、目录、提交格式、包管理器、工作流,提示词必须先遵守仓库文档
|
||||
- 若仓库未定义,再允许 AI 根据脚本、清单文件、近期提交历史推断
|
||||
- 不把当前仓库的偶然路径结构、命令名、分支名写死到所有提示词中
|
||||
- 只有当提示词本身就是为当前仓库的特定流程编写时,才写入仓库专属术语和路径
|
||||
|
||||
## 反模式
|
||||
|
||||
以下写法应避免:
|
||||
|
||||
- 让 AI 一上来就全仓库无差别扫描
|
||||
- 在未分析前直接要求修改
|
||||
- 把代码现状直接当成唯一真相
|
||||
- 把历史变更文档直接当成稳定规范来源
|
||||
- 用抽象表述代替可执行动作
|
||||
- 把多个危险动作打包成一次默认授权
|
||||
- 没有备份、没有锚点、没有终止路径
|
||||
- 只要求“完成修改”,不要求复核和收尾
|
||||
|
||||
## 编写检查清单
|
||||
|
||||
编写完一份提示词后,至少自检以下问题:
|
||||
|
||||
| 检查项 | 说明 |
|
||||
| ---------------- | ------------------------------------------------- |
|
||||
| 目标是否单句明确 | 是否能一眼看出任务对象、目标和范围 |
|
||||
| 约束是否集中 | 全局规则是否只在 `## 约束` 中声明 |
|
||||
| 数据源是否具体 | 是否明确读哪些文档、代码、测试、命令结果 |
|
||||
| 是否先分析再执行 | 是否存在独立的分析和计划阶段 |
|
||||
| 是否有确认节点 | 高风险动作前是否要求提问工具确认 |
|
||||
| 是否有降级路径 | change 不明、规则不明、上下文不足时是否有处理方式 |
|
||||
| 是否可操作 | 是否给出命令、工具、路径或结构化动作 |
|
||||
| 是否可验证 | 执行后是否定义复核或测试方式 |
|
||||
| 是否能收尾 | 是否要求输出修改清单、备份、验证结果、残留风险 |
|
||||
|
||||
## 维护原则
|
||||
|
||||
- 新增提示词前,先判断能否复用现有提示词的结构和术语
|
||||
- 修改提示词时,优先提炼共性,不为单次场景堆特例
|
||||
- 若某条规则已在多个提示词中稳定出现,应回收进本 README,作为统一编写规范
|
||||
179
docs/prompts/prompt-apply-review.md
Normal file
179
docs/prompts/prompt-apply-review.md
Normal file
@@ -0,0 +1,179 @@
|
||||
审查 OpenSpec apply 完成后以及后续手动修补后的实际变更,判断实际产物、验证结果和变更文档是否与 `design.md` source of truth 一致,识别偏离、漏记和可优化点,并将确认后的实际变更同步回变更文档,按以下流程执行。
|
||||
|
||||
## 约束
|
||||
|
||||
- 先审查再修复;未经用户确认,不修改实际产物或变更文档
|
||||
- 默认按 `fast-drive` workflow 审查;识别 change 后先确认 `schemaName`;若实际 schema 不同,说明差异,仅对实际存在的 artifacts 做审查
|
||||
- 在 `fast-drive` workflow 下,核心 artifacts 是 `design.md` 和 `tasks.md`;不要要求存在 `proposal.md` 或 `specs/*.md`
|
||||
- 在 `fast-drive` workflow 下,`design.md` 是 scope、requirements、decisions、guardrails、execution direction 和 verification expectations 的 source of truth,`tasks.md` 是 apply 进度和验证闭环的 tracking 文件
|
||||
- 禁止同步或修改 `openspec/specs/` 下的主规范;若实际 schema 包含 `specs/*.md`,也只允许修改本次 change 目录下实际存在的 spec artifacts,主规范同步属于 archive 阶段,不在此提示词范围内
|
||||
- 优先使用当前会话中的执行说明、验证结论、手动修补记录和已生成的变更文档;仅在无法明确 change、`schemaName`、改动范围或修补来源时,再用提问工具或 OpenSpec 命令补充定位
|
||||
- 不要因为实际产物已经存在就自动以实际产物为准;先判断差异属于“design 要求未完成”、“验证后新增修补”、“合理落地细化”还是“意外偏离/回归”
|
||||
- 每批实际产物或文档修改执行前用提问工具获得用户确认
|
||||
- 删除/重写前用提问工具获得用户确认,并先备份原文件为 `{file}.bak.{timestamp}`
|
||||
- 若修改实际产物涉及新行为、流程、接口、内容、数据、配置、责任边界或用户可见结果,同步更新验证材料、相关变更文档和必要的文档/沟通材料
|
||||
|
||||
## 1. 收集
|
||||
|
||||
读取约束:
|
||||
|
||||
- 直接使用 Read 工具并行读取文件,禁止使用 subagent/Task 工具做文件读取和内容转发
|
||||
- 不原样输出文件内容,仅在步骤 2 输出审查结论
|
||||
|
||||
分步收集:
|
||||
|
||||
a) 先并行读取核心入口和配置,确定范围:
|
||||
|
||||
- 本次 change 的 `design.md`
|
||||
- 本次 change 的 `tasks.md`
|
||||
- workflow context/configuration,例如存在时读取 `openspec/config.yaml`
|
||||
- 若可定位到 schema,读取对应 schema;`fast-drive` 下优先读取 `openspec/schemas/fast-drive/schema.yaml`
|
||||
|
||||
b) 从 `design.md` 提取审查基准:
|
||||
|
||||
- `Context`
|
||||
- `Discussion Notes`
|
||||
- `Requirements`
|
||||
- `Goals / Non-Goals`
|
||||
- `Execution Guardrails`
|
||||
- `Affected Areas`
|
||||
- `Decisions`
|
||||
- `Execution Plan`
|
||||
- `Verification Plan`
|
||||
- `Risks / Trade-offs`
|
||||
- `Open Questions`
|
||||
|
||||
c) 从 `tasks.md` 提取任务状态、已完成项、未完成项、验证任务和文档/沟通任务;重点记录所有已标记完成的 `- [x]` 或等价完成状态。
|
||||
|
||||
d) 获取实际改动范围:若在版本控制工作区中,优先使用 `git diff --name-only`、`git diff --name-only --cached`;若工作区已干净或不适用版本控制,再结合 `design.md`、`tasks.md`、验证记录和执行记录反推。
|
||||
|
||||
e) 并行读取实际改动范围、`Affected Areas`、`Execution Plan`、`Verification Plan` 涉及的实际产物、参考材料、验证材料、流程说明、配置、文档或沟通材料。
|
||||
|
||||
f) 收集当前会话中与本次变更相关的执行说明、apply 过程中的偏离、验证失败、手动修补原因、验证命令或检查结果、待确认事项。
|
||||
|
||||
g) 若实际 schema 不是 `fast-drive`,只读取实际存在的 artifacts;若存在 `proposal.md`、`specs/*.md`,再按该 schema 的要求补充读取和审查。
|
||||
|
||||
若当前上下文无法明确 change 或文档路径:
|
||||
|
||||
- 先用提问工具让用户确认 change 名称或文档范围
|
||||
- 仍无法确认时,再执行 `openspec status --change "{name}" --json` 和 `openspec instructions apply --change "{name}" --json` 辅助定位
|
||||
|
||||
若已明确 change,但尚未确认 `schemaName`,先读取 change 元数据或执行 `openspec status --change "{name}" --json` 确认。
|
||||
|
||||
若缺少验证结果或手动修补记录,明确说明本次无法可靠判断部分差异的来源,仅能基于实际产物与文档现状审查。
|
||||
|
||||
## 2. 分析
|
||||
|
||||
按以下优先级检查:
|
||||
|
||||
| 优先级 | 维度 | 检查点 |
|
||||
| ------ | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| P0 | 实际变更与验证结论 | 当前实际产物的真实状态是什么;apply 后是否有手动改动或验证后修补;验证是否证明这些变更有效;若缺少验证结果,标记相关结论为“未验证”;检查是否存在回归、未覆盖场景或被掩盖的问题 |
|
||||
| P1 | `design.md` 一致性 | 实际变更是否符合 `Requirements`、`Goals / Non-Goals`、`Execution Guardrails`、`Decisions`、`Execution Plan` 和 `Verification Plan`;`Open Questions` 是否已明确区分 blocking / non-blocking 或写出 `None`;是否违反被明确否决的方案、用户偏好或约束 |
|
||||
| P2 | `tasks.md` 真实性 | 已完成任务是否真的完成并完成必要验证;未完成任务是否仍然必要;apply 或手动修补是否引入了需要补充的新任务、验证任务或文档/沟通任务 |
|
||||
| P3 | 文档回写完整性 | 已落地的实际变更、验证后新增修补、边界处理、异常路径、验证结论、实际触达产物是否已同步回 `design.md` 和 `tasks.md`;若影响行为、流程、接口、内容、数据、配置、责任边界或用户可见结果,再检查必要的文档/沟通材料是否同步 |
|
||||
| P4 | 质量与可维护性 | 实际产物的结构、清晰度、一致性、可维护性、风险处理、移交质量、验证质量、与现有模式的一致性是否存在明显问题或可优化点 |
|
||||
| P5 | Schema 兼容性 | 对实际存在的 artifacts 检查是否符合其 schema;若不是 `fast-drive`,仅按实际 artifacts 检查,不凭空要求 `fast-drive` 专属结构;最终 artifacts 是否仍保留模板注释、空表格行或占位任务文本 |
|
||||
|
||||
分析时区分四类差异:
|
||||
|
||||
- `design.md` 要求已明确,但实际变更未完成或完成不充分 → 需补充实际工作或验证
|
||||
- 实际变更因验证暴露问题、手动修补或合理落地细化而新增/变更 → 需回写 `design.md` 和/或 `tasks.md`
|
||||
- 实际变更与 `design.md` 不一致,且无法判断应以哪边为准 → 列入待确认清单
|
||||
- `tasks.md` 状态与实际完成情况或验证结果不一致 → 修正任务状态或补充任务
|
||||
|
||||
不要把以下情况直接视为合理修补:
|
||||
|
||||
- 通过跳过、弱化或绕过验证来声称变更完成
|
||||
- 为了贴合当前实际产物而降低已确认的 requirement、acceptance criteria 或 guardrail
|
||||
- 未经过讨论和验证就扩大功能、流程、内容或责任范围
|
||||
- 违反 `Execution Guardrails`、被拒绝方案或 `Open Questions` 中尚未解决的 blocker
|
||||
|
||||
重点识别:
|
||||
|
||||
- `design.md` 要求但未落地的结果、流程、内容、场景、异常处理、文档/沟通更新或验证步骤
|
||||
- 实际变更偏离 `Goals / Non-Goals`、`Execution Guardrails`、`Decisions` 或 `Execution Plan` 的地方
|
||||
- apply 完成后新增的修补、边界处理、接口调整、行为变化、流程变化或内容变化未同步到 `design.md`
|
||||
- `Affected Areas` 与实际改动范围不一致,导致新会话无法复盘真实影响面
|
||||
- `Verification Plan` 中要求的验证、质量检查、审阅、批准、沟通检查或 manual checks 未执行或未记录
|
||||
- `tasks.md` 标记完成,但实际产物、验证、文档或沟通未闭环
|
||||
- `design.md` 或 `tasks.md` 仍保留 `<!-- ... -->` 模板注释、空表格行、`Replace with...`、`TBD`、`TODO` 等未解决占位内容
|
||||
- 必要的文档/沟通材料未同步影响行为、流程、接口、内容、数据、配置、责任边界或用户可见结果的变更
|
||||
- 实际产物存在明显的重复、复杂度过高、表达不清、责任不明、风险处理薄弱、验证质量不足等问题
|
||||
- `fast-drive` change 中仍错误依赖 `proposal.md`、`specs/*.md`、`Capabilities` 或 `Modified Capabilities` 的内容
|
||||
|
||||
输出审查结果:
|
||||
|
||||
1. **问题总览表**:问题类型 × 涉及文件数
|
||||
2. **实际变更与修补清单**:本次已落地的主要变更、后续修补和验证结论;若缺少验证结果,对未验证部分单独标记
|
||||
3. **Design 偏离清单**:实际变更未完成、完成不充分或偏离 `design.md` 的内容
|
||||
4. **需回写文档清单**:实际产物和验证中已确认、但 `design.md`、`tasks.md` 或相关文档/沟通材料未体现的变更、修补或约束变化
|
||||
5. **方向待确认清单**:实际变更与 `design.md` 不一致,且无法判断应以哪边为准的事项
|
||||
6. **任务状态问题清单**:未真正完成、状态错误或需补充的新任务
|
||||
7. **验证问题清单**:缺失覆盖、掩盖错误、验证不足或修补后未回归验证的问题
|
||||
8. **质量/优化清单**:可优化的实际产物问题和建议
|
||||
9. **Schema 差异清单**:实际 schema 与默认 `fast-drive` 不同时,列出因此跳过或改按实际 artifacts 审查的内容
|
||||
10. **逐项分析**:每个问题说明位置、问题、影响、建议和建议修复方向
|
||||
|
||||
若所有清单均为空,输出“审查通过,未发现问题”,跳至步骤 5。
|
||||
|
||||
## 3. 计划(用户确认)
|
||||
|
||||
先针对“方向待确认清单”用提问工具逐项向用户确认。
|
||||
|
||||
再整理完整修复方案,按类别列出:
|
||||
|
||||
- 实际工作或验证补充:补完成、补异常处理、补回归验证、修复被弱化或绕过的验证
|
||||
- Design 回写:同步 `design.md` 中遗漏或过时的 requirements、guardrails、affected areas、decisions、execution plan、verification plan、risks 或 open questions
|
||||
- 任务状态修正:修正已完成/未完成状态,补充 apply 后新增但已完成的修补任务或验证任务
|
||||
- 文档/沟通同步:同步行为、流程、接口、内容、数据、配置、责任边界或用户可见结果变化
|
||||
- 质量优化:在不改变目标结果的前提下优化结构、表达、一致性、可维护性或移交质量
|
||||
- Schema 兼容处理:若实际 schema 不是 `fast-drive`,按实际存在的 artifacts 说明额外文档同步项
|
||||
|
||||
对每个拟修改的文件说明:
|
||||
|
||||
- 修改内容
|
||||
- 修改原因
|
||||
- 预期影响
|
||||
- 验证方式
|
||||
- 若存在分支方案,分别说明适用前提
|
||||
|
||||
用提问工具展示完整修复方案,获得用户确认后执行。
|
||||
|
||||
## 4. 执行
|
||||
|
||||
逐项执行已确认的实际产物、验证和文档修复。
|
||||
|
||||
若涉及删除或重写:
|
||||
|
||||
- 先创建备份文件 `{file}.bak.{timestamp}`
|
||||
- 再执行修改
|
||||
|
||||
若修改了实际产物或验证材料:
|
||||
|
||||
- 同步更新相关变更文档;若影响行为、流程、接口、内容、数据、配置、责任边界或用户可见结果,再同步必要的文档/沟通材料
|
||||
- 运行或执行相关验证;若修补影响范围较大,再补充执行受影响的回归验证
|
||||
|
||||
若修改了文档:
|
||||
|
||||
- 在 `fast-drive` workflow 下,确认 `design.md` 仍是 source of truth,`tasks.md` 仍从 `design.md` 派生
|
||||
- 确认 `design.md` 的 requirements、guardrails、affected areas、decisions、execution plan、verification plan、risks 和 open questions 与实际变更一致
|
||||
- 确认 `tasks.md` 每个完成任务都有对应实际产物和必要验证,新增修补已补充任务或记录在合适任务中
|
||||
- 禁止将本次 change 内容同步到 `openspec/specs/`,该操作属于 archive 阶段
|
||||
- 在 `fast-drive` workflow 下不创建 `proposal.md` 或 `specs/*.md`;若实际 schema 不是 `fast-drive`,则按实际 schema 的 required artifacts 创建或更新本次 change 目录下的 artifacts
|
||||
|
||||
执行后重新读取所有被修改的实际产物、验证材料和文档,并复核:
|
||||
|
||||
- “Design 偏离清单” 是否已清空或已标注保留原因
|
||||
- “需回写文档清单” 是否已清空
|
||||
- “方向待确认清单” 是否已清空或已记录用户决策
|
||||
- “任务状态问题清单” 和 “验证问题清单” 是否已清空或已标注残留原因
|
||||
- “质量/优化清单” 中哪些已处理,哪些有意延期
|
||||
- 必要的文档/沟通材料是否已按影响范围同步
|
||||
- 所有模板注释、空表格行和占位文本是否已清空或替换为有效内容
|
||||
|
||||
## 5. 收尾
|
||||
|
||||
列出所有修改的文件、备份文件、验证命令或检查结果、文档同步摘要和剩余风险。
|
||||
|
||||
若本次因缺少验证结果、修补记录或上下文而降级执行,或有问题因信息不足暂未处理,单独说明。
|
||||
139
docs/prompts/prompt-proposal-review.md
Normal file
139
docs/prompts/prompt-proposal-review.md
Normal file
@@ -0,0 +1,139 @@
|
||||
审查本次 OpenSpec 变更文档是否与前序讨论、当前实际状态和实际 OpenSpec workflow 一致,重点检查 `fast-drive` workflow 下的 `design.md` 是否足以在上下文压缩或新会话中指导后续 `apply`,并识别遗漏、冲突和不合理假设,给出可执行的补充建议,按以下流程执行。
|
||||
|
||||
## 约束
|
||||
|
||||
- 仅修改本次变更文档,不修改实际产物
|
||||
- 默认按 `fast-drive` workflow 审查;识别 change 后先确认 `schemaName`;若实际 schema 不同,说明差异,仅对实际存在的 artifacts 做审查
|
||||
- 在 `fast-drive` workflow 下,核心 artifacts 是 `design.md` 和 `tasks.md`;不要要求存在 `proposal.md` 或 `specs/*.md`
|
||||
- 在 `fast-drive` workflow 下,`design.md` 是 scope、requirements、decisions、guardrails、execution direction 和 verification expectations 的 source of truth,`tasks.md` 必须从 `design.md` 派生
|
||||
- 优先使用当前会话中的讨论、explore/propose 阶段结论和已生成的变更文档;仅在无法明确 change、`schemaName` 或文档范围时,再用提问工具或 OpenSpec 命令补充定位
|
||||
- 每批文档修改建议执行前用提问工具获得用户确认
|
||||
- 删除/重写前用提问工具获得用户确认,并先备份原文件为 `{file}.bak.{timestamp}`
|
||||
|
||||
## 1. 收集
|
||||
|
||||
读取约束:
|
||||
|
||||
- 直接使用 Read 工具并行读取文件,禁止使用 subagent/Task 工具做文件读取和内容转发
|
||||
- 不原样输出文件内容,仅在步骤 2 输出审查结论
|
||||
|
||||
分步收集:
|
||||
|
||||
a) 先并行读取核心入口和配置,确定范围:
|
||||
|
||||
- 本次 change 的 `design.md`
|
||||
- 本次 change 的 `tasks.md`
|
||||
- workflow context/configuration,例如存在时读取 `openspec/config.yaml`
|
||||
- 若可定位到 schema,读取对应 schema;`fast-drive` 下优先读取 `openspec/schemas/fast-drive/schema.yaml`
|
||||
|
||||
b) 从 `design.md` 提取审查基准:
|
||||
|
||||
- `Context`
|
||||
- `Discussion Notes`
|
||||
- `Requirements`
|
||||
- `Goals / Non-Goals`
|
||||
- `Execution Guardrails`
|
||||
- `Affected Areas`
|
||||
- `Decisions`
|
||||
- `Execution Plan`
|
||||
- `Verification Plan`
|
||||
- `Risks / Trade-offs`
|
||||
- `Open Questions`
|
||||
|
||||
c) 基于 `Affected Areas`、`Execution Plan`、`Verification Plan`、讨论中提到的受影响范围,并行读取相关实际产物、参考材料、验证材料、流程说明、配置、文档或沟通材料,确认文档是否贴合当前实际状态。
|
||||
|
||||
d) 若实际 schema 不是 `fast-drive`,只读取实际存在的 artifacts;若存在 `proposal.md`、`specs/*.md`,再按该 schema 的要求补充读取和审查。
|
||||
|
||||
若当前上下文无法明确 change 或文档路径:
|
||||
|
||||
- 先用提问工具让用户确认 change 名称或文档范围
|
||||
- 仍无法确认时,再执行 `openspec status --change "{name}" --json` 和 `openspec instructions apply --change "{name}" --json` 辅助定位
|
||||
|
||||
若已明确 change,但尚未确认 `schemaName`,先读取 change 元数据或执行 `openspec status --change "{name}" --json` 确认。
|
||||
|
||||
若缺少讨论记录,明确说明本次降级为“文档 + 当前实际状态审查”,不做讨论一致性结论。
|
||||
|
||||
## 2. 分析
|
||||
|
||||
按以下优先级检查:
|
||||
|
||||
| 优先级 | 维度 | 检查点 |
|
||||
| ------ | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| P0 | 讨论承接性 | 仅在存在讨论记录时检查:`design.md` 是否完整记录已确认的目标、非目标、用户偏好、约束、边界条件、风险、关键决策、被否决方案和待澄清事项;若无讨论记录,标记为“跳过” |
|
||||
| P1 | `design.md` 自包含性 | `design.md` 是否足以让看不到前序对话的执行者继续工作;是否包含完整 required sections;`Open Questions` 是否明确区分 blocking / non-blocking 或写出 `None`;是否存在依赖未记录聊天上下文的隐含要求 |
|
||||
| P2 | 当前状态真实性 | `design.md` 对当前实际产物、流程、接口、内容、数据、配置、依赖、责任边界、参考材料和验证入口的描述是否准确;是否把“计划变更”误写成“当前现状”;`Affected Areas` 是否遗漏真实受影响区域 |
|
||||
| P3 | `tasks.md` 派生性 | `tasks.md` 是否从 `design.md` 派生;是否覆盖 requirements、guardrails、decisions、execution plan 和 verification plan;是否依赖 `proposal.md` 或 `specs/*.md` 中未写入 `design.md` 的内容 |
|
||||
| P4 | OpenSpec 合规性 | 对实际存在的 artifacts 检查是否遵循对应 schema 和 OpenSpec 术语;`tasks.md` 是否一行一个 `- [ ]` checkbox 任务、按 `##` numbered headings 分组、无无关的仓库/版本控制/发布操作任务;`design.md` 是否避免 task checkbox;最终 artifacts 是否仍保留模板注释、空表格行或占位任务文本 |
|
||||
|
||||
分析时区分两类情况:
|
||||
|
||||
- 文档对当前实际状态的描述错误
|
||||
- 文档描述的是预期变更,本来就应当与当前状态不同
|
||||
|
||||
重点识别:
|
||||
|
||||
- 讨论中已确定但 `design.md` 未记录的内容
|
||||
- `design.md` 中缺失或含糊的 requirements、acceptance criteria、guardrails、decisions、verification expectations
|
||||
- `Open Questions` 未明确区分 blocking / non-blocking、与 `tasks.md` 冲突,或包含 apply 前必须解决的 blocker
|
||||
- `tasks.md` 未覆盖 `design.md` 的要求、约束、执行计划、验证计划或文档/沟通更新要求
|
||||
- `tasks.md` 标记了无法验证、跨行、过大、顺序错误或包含无关仓库/版本控制/发布操作的任务
|
||||
- 文档仍保留 `<!-- ... -->` 模板注释、空表格行、`Replace with...`、`TBD`、`TODO` 等未解决占位内容
|
||||
- 文档基于错误当前状态做出的设计或任务拆分
|
||||
- 文档之间相互冲突的目标、方案、约束、任务和验证要求
|
||||
- `fast-drive` change 中仍错误依赖 `proposal.md`、`specs/*.md`、`Capabilities` 或 `Modified Capabilities` 的内容
|
||||
|
||||
输出审查结果:
|
||||
|
||||
1. **问题总览表**:问题类型 × 涉及文档数
|
||||
2. **讨论遗漏清单**:讨论已确定但 `design.md` 未体现的内容;若缺少讨论记录,标记为“未审查”
|
||||
3. **Design 自包含性问题清单**:缺失、含糊或无法指导新会话 apply 的内容
|
||||
4. **当前状态问题清单**:与当前实际状态不符的描述、假设或影响分析
|
||||
5. **Tasks 派生与覆盖问题清单**:`tasks.md` 未从 `design.md` 正确派生或覆盖不足的内容
|
||||
6. **文档冲突清单**:`design.md`、`tasks.md` 和实际存在的其他 artifacts 之间的不一致
|
||||
7. **OpenSpec 规范问题清单**:格式、术语、结构问题
|
||||
8. **待澄清清单**:仅靠讨论和当前状态仍无法判断的事项
|
||||
9. **逐项分析**:每个问题说明位置、问题、影响、建议
|
||||
10. **补充建议方案**:按文件列出建议补充/修正的内容、原因和可选方案
|
||||
|
||||
若所有清单均为空,输出“审查通过,未发现问题”,跳至步骤 5。
|
||||
|
||||
## 3. 计划(用户确认)
|
||||
|
||||
先针对“待澄清清单”用提问工具逐项向用户确认。
|
||||
|
||||
再整理完整修复方案,按文件列出:
|
||||
|
||||
- 建议修改的文件
|
||||
- 需要补充或修正的内容
|
||||
- 修改原因
|
||||
- 若存在分支方案,分别说明适用前提
|
||||
|
||||
用提问工具展示完整修复方案,获得用户确认后执行。
|
||||
|
||||
## 4. 执行
|
||||
|
||||
逐项修改已确认的变更文档,不修改实际产物。
|
||||
|
||||
在 `fast-drive` workflow 下,通常只修改本次 change 的 `design.md` 和 `tasks.md`;若实际 schema 存在其他 artifacts,仅在确有必要且用户确认后修改实际存在的 artifacts。
|
||||
|
||||
若涉及删除或重写:
|
||||
|
||||
- 先创建备份文件 `{file}.bak.{timestamp}`
|
||||
- 再执行修改
|
||||
|
||||
执行后重新读取所有被修改的文档,并复核:
|
||||
|
||||
- “讨论遗漏清单” 是否已清空或已标注保留原因
|
||||
- “Design 自包含性问题清单” 是否已清空
|
||||
- “当前状态问题清单” 是否已清空或已标注为预期变更
|
||||
- “Tasks 派生与覆盖问题清单” 是否已清空
|
||||
- “文档冲突清单” 是否已清空
|
||||
- “OpenSpec 规范问题清单” 是否已清空
|
||||
- “待澄清清单” 是否已清空或已记录用户决策
|
||||
- 所有模板注释、空表格行和占位文本是否已清空或替换为有效内容
|
||||
|
||||
## 5. 收尾
|
||||
|
||||
列出所有修改的文件、备份文件和变更摘要。
|
||||
|
||||
若本次因缺少讨论记录而降级执行,或有问题因信息不足暂未处理,单独说明。
|
||||
595
docs/prompts/prompt-smart-merge.md
Normal file
595
docs/prompts/prompt-smart-merge.md
Normal file
@@ -0,0 +1,595 @@
|
||||
将所有 `dev*` 分支按计划合并到目标分支(默认 `main`),按准备→分析→计划→执行→清理执行;先探测当前仓库的规则、模块边界、验证命令和提交风格,再基于探测结果执行,避免写死仓库结构。
|
||||
|
||||
## 约束
|
||||
|
||||
- 全程仅使用 `git merge` 完成分支集成,禁止 `rebase`
|
||||
- `git add` 仅允许明确文件路径,禁止 `git add .`、`git add -A`
|
||||
- 不直接使用默认 `git pull`;同步目标分支时仅允许显式策略:`git fetch` + `git merge`,或 `git pull --no-rebase`
|
||||
- `git reset --hard` 仅允许回到已记录的安全锚点 tag,且执行前必须再次确认
|
||||
- `git stash push`、`git stash apply`、`git stash drop`、`git revert`、`git branch -d`、`git push {remote} --delete {branch}` 执行前都必须再次确认
|
||||
- 禁止自动推送代码;远端分支删除仅在步骤 5 获得确认后执行
|
||||
- 冲突文件禁止 AI 自主决定写入内容
|
||||
- 用户选择 `--ours`、`--theirs`、保留删除的一侧、保留重命名的一侧等机械方案后,AI 可执行对应写入
|
||||
- 凡涉及“双保留”“重组逻辑”“补写缺失代码”等内容生成,必须逐文件展示最终结果并再次获得确认后写入
|
||||
- 步骤 3 的“确认执行”仅授权按计划执行正常 merge,不授权自动执行 `stash`、`reset --hard`、`revert`、`drop`、删除分支、远端删除、冲突内容重写
|
||||
- 合并提交与语义审查修复提交必须分离;语义审查修复允许拆成多条独立提交
|
||||
- 若仓库文档定义了命令、包管理器、提交格式、目录规范,优先遵守文档;若未定义,再根据清单文件、脚本和近期提交历史推断
|
||||
- 不把当前仓库的路径结构、技术栈、构建命令写死到流程里;一切按探测结果执行
|
||||
- 信息展示按“概览 → 详情 → 原始数据”分层输出,避免一次性输出全部 diff
|
||||
|
||||
## 记录项
|
||||
|
||||
执行中持续维护以下记录,后续所有决策和总结都基于这些记录:
|
||||
|
||||
| 记录项 | 内容 |
|
||||
| ----------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| `session_timestamp` | 本次流程唯一时间戳 |
|
||||
| `target` | 目标分支名 |
|
||||
| `target_upstream` | 目标分支上游,如 `{remote}/{target}`;无则记为空 |
|
||||
| `target_remote` | 从 `target_upstream` 解析出的远端名;无上游则优先使用默认远端,否则为空 |
|
||||
| `repo_rules` | 从 README、CONTRIBUTING、开发文档、脚本、清单文件中识别出的规则 |
|
||||
| `commit_style` | 仓库现有提交信息风格和默认合并/修复提交模板 |
|
||||
| `module_map` | 按当前仓库结构推断出的模块边界、公共目录、配置目录、基础设施目录 |
|
||||
| `validation_commands` | 按模块或作用域归纳出的 lint/build/test 命令 |
|
||||
| `auto_stashes[]` | 本流程创建的 stash 列表,记录唯一 message、创建时 ref、创建顺序、后续是否恢复/保留 |
|
||||
| `created_local_tracking_branches[]` | 为远端独有 `dev*` 分支创建的本地跟踪分支 |
|
||||
| `global_tag` | 全局安全锚点 |
|
||||
| `branch_tags[]` | 每个待合并分支对应的分支级安全锚点 |
|
||||
| `analysis[]` | 每个分支的分析快照:HEAD hash、提交数、变更文件、依赖、初始冲突预测、风险、语义审查模式 |
|
||||
| `results[]` | 每个分支的最终状态:已合并、已跳过、已回退、失败原因、修复提交列表、验证结果 |
|
||||
|
||||
## 安全锚点
|
||||
|
||||
| 锚点 | 创建时机 | 用途 |
|
||||
| ----------------------------------- | ---------------------------- | -------------------------------------------------------- |
|
||||
| `pre-merge-backup-{timestamp}` | 步骤 1 完成目标分支准备后 | 全局回退点,恢复到本轮合并开始前的目标分支状态 |
|
||||
| `merge-before-{branch}-{timestamp}` | 步骤 4 每个分支正式 merge 前 | 分支级回退点,回退当前分支的合并提交和其后的语义修复提交 |
|
||||
|
||||
除非用户明确要求“全部回退”或“放弃当前分支并回到分支级锚点”,否则不主动使用 `git reset --hard`。
|
||||
|
||||
## 1. 准备
|
||||
|
||||
### 1.1 仓库规则探测
|
||||
|
||||
并行收集当前仓库的规则来源:
|
||||
|
||||
- 根目录文档:`README*`、`CONTRIBUTING*`、`DEVELOPMENT*`、`docs/**` 中与开发、构建、测试、提交流程有关的文档
|
||||
- 常见任务入口:`Makefile`、`justfile`、`Taskfile.yml`、`package.json`、工作区配置、CI 文件、脚本目录
|
||||
- 常见清单/锁文件:`package-lock.json`、`pnpm-lock.yaml`、`yarn.lock`、`bun.lock*`、`go.mod`、`Cargo.toml`、`pyproject.toml`、`pom.xml`、`build.gradle*` 等
|
||||
- 近期提交:`git log --oneline -20`
|
||||
|
||||
探测目标:
|
||||
|
||||
| 项目 | 识别方式 | 记录结果 |
|
||||
| ----------------- | ------------------------------------------------------------- | ---------------------------- |
|
||||
| 包管理器/任务入口 | 文档、锁文件、任务文件、脚本 | 记录允许的执行方式和优先级 |
|
||||
| 模块边界 | 顶层目录、工作区配置、语言清单文件、子项目 README | 记录 `module_map` |
|
||||
| 公共/基础设施目录 | shared/common/lib/core/config/scripts/ci 等目录和根级配置文件 | 记录到 `module_map` |
|
||||
| 验证命令 | 文档中的 lint/build/test 命令,或脚本中的标准任务 | 记录到 `validation_commands` |
|
||||
| 提交风格 | 文档约束优先,否则看近期 `git log` | 记录 `commit_style` |
|
||||
|
||||
识别规则:
|
||||
|
||||
- 文档和脚本冲突时,以文档为准
|
||||
- 文档缺失时,以仓库当前可见的任务入口和近期提交习惯为准
|
||||
- 无法确定时,先在步骤 3 的计划表中展示“待确认规则”,由用户确认
|
||||
|
||||
### 1.2 初始化现场
|
||||
|
||||
- 执行 `git status --short --branch`,记录当前分支和工作区状态
|
||||
- 执行 `git remote -v`,记录可用远端
|
||||
- 用提问工具确认目标分支:`main` / `master` / `develop` / 用户自定义
|
||||
|
||||
### 1.3 处理非干净工作区
|
||||
|
||||
- 若 `git status --porcelain` 非空,先展示变更文件概览,再用提问工具让用户选择:
|
||||
- `stash 后继续`
|
||||
- `终止`
|
||||
- 用户选择 `stash 后继续` 后,执行 `git stash push --include-untracked -m smart-merge-{timestamp}-precheck-{n}`
|
||||
- 记录 stash 唯一 message 和创建时 ref 到 `auto_stashes[]`
|
||||
|
||||
### 1.4 切换并校验目标分支
|
||||
|
||||
- 执行 `git checkout {target}`
|
||||
- 若失败,使用提问工具让用户选择:
|
||||
- `重新指定目标分支`
|
||||
- `终止`
|
||||
|
||||
### 1.5 解析上游并同步远端引用
|
||||
|
||||
- 执行 `git for-each-ref --format='%(upstream:short)' refs/heads/{target}` 获取 `target_upstream`
|
||||
- 若存在 `target_upstream`:
|
||||
- 解析 `target_remote`
|
||||
- 执行 `git fetch {target_remote} --prune`
|
||||
- 执行 `git rev-list --left-right --count {target}...{target_upstream}` 计算 ahead/behind
|
||||
- 若不存在 `target_upstream`:
|
||||
- 若仓库存在默认远端,记为 `target_remote`
|
||||
- 否则 `target_remote` 置空
|
||||
|
||||
目标分支同步决策:
|
||||
|
||||
| 状态 | 处理 |
|
||||
| ---------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| 无 upstream | 记录后继续,不自动同步 |
|
||||
| 仅落后 upstream | 用提问工具选择:`快进同步` / `保持当前本地 HEAD` / `终止`;若选同步,执行 `git merge --ff-only {target_upstream}` |
|
||||
| 仅领先 upstream | 记录后继续,不自动 push |
|
||||
| 与 upstream 分叉 | 用提问工具选择:`保持当前本地 HEAD` / `终止`;不自动把 upstream merge 进 target |
|
||||
|
||||
### 1.6 收集候选分支
|
||||
|
||||
- 本地分支:`git branch --list 'dev*'`
|
||||
- 若 `target_remote` 非空,远端分支:`git branch -r --list '{target_remote}/dev*'`
|
||||
- 过滤掉 `HEAD ->` 这类符号引用
|
||||
- 计算“远端存在、本地不存在”的分支列表
|
||||
|
||||
对远端独有分支,不直接 `checkout`,改为先展示清单,再用提问工具选择:
|
||||
|
||||
- `全部创建本地跟踪分支`
|
||||
- `选择部分创建`
|
||||
- `仅使用已有本地分支`
|
||||
- `终止`
|
||||
|
||||
创建本地跟踪分支使用:`git branch --track {local_branch} {remote}/{remote_branch}`
|
||||
|
||||
若本地已存在同名分支,使用提问工具逐项选择:
|
||||
|
||||
- `使用现有本地分支`
|
||||
- `改名创建跟踪分支`
|
||||
- `跳过该远端分支`
|
||||
|
||||
所有自动创建的本地跟踪分支都记录到 `created_local_tracking_branches[]`。
|
||||
|
||||
### 1.7 无候选分支时直接结束
|
||||
|
||||
- 若最终没有任何 `dev*` 分支,输出概览:目标分支、工作区状态、探测到的仓库规则、是否创建过 stash、是否创建过本地跟踪分支
|
||||
- 若本流程已创建 stash,进入步骤 5 的“工作区恢复”
|
||||
- 否则直接结束
|
||||
|
||||
### 1.8 创建全局安全锚点
|
||||
|
||||
- 执行 `git tag pre-merge-backup-{timestamp}`
|
||||
- 记录为 `global_tag`
|
||||
|
||||
## 2. 分析
|
||||
|
||||
### 2.1 信息收集(并行)
|
||||
|
||||
对每个候选分支并行收集以下信息:
|
||||
|
||||
| 维度 | 命令/方式 | 结果 |
|
||||
| -------- | ------------------------------------------------------------------ | -------------------------------------- |
|
||||
| 基础 | `git rev-parse {branch}` | 记录分支 HEAD hash |
|
||||
| 合并状态 | `git merge-base --is-ancestor {branch} {target}` | 判断该分支是否已完全被目标分支包含 |
|
||||
| 提交范围 | `git log --oneline {target}..{branch}` | 记录独有提交数和提交消息,推断分支意图 |
|
||||
| 变更范围 | `git diff --name-status {target}...{branch}` | 记录文件列表、状态、变更集中区域 |
|
||||
| 行数统计 | `git diff --stat {target}...{branch}` | 估算改动体量 |
|
||||
| 模块归属 | 按 `module_map` 归类;若未命中则按顶层目录或最近的语言清单文件归类 | 识别受影响模块 |
|
||||
|
||||
模块归类优先级:
|
||||
|
||||
1. 仓库文档明确声明的子项目、包、服务、应用、库
|
||||
2. 工作区配置或语言清单文件定义的模块边界
|
||||
3. 顶层目录边界
|
||||
4. 根级共享文件、配置文件、脚本、CI 文件,统一归为 `shared/config/infra`
|
||||
|
||||
常见的 `shared/config/infra` 候选包括但不限于:
|
||||
|
||||
- 根目录配置文件、锁文件、工作区文件
|
||||
- 构建脚本、部署脚本、CI 配置、容器配置、基础设施配置
|
||||
- 公共库目录、共享类型目录、通用组件目录、公共工具目录
|
||||
|
||||
### 2.2 依赖判定
|
||||
|
||||
依赖同时从 ancestry 和文件重叠两个维度判断:
|
||||
|
||||
| 维度 | 方法 | 判定规则 |
|
||||
| ------------- | ----------------------------------------------------- | ------------------------------------- |
|
||||
| ancestry 依赖 | `git merge-base --is-ancestor {a} {b}` | 若 `a` 是 `b` 的祖先,记为 `b 依赖 a` |
|
||||
| 文件重叠 | 比较各分支 `git diff --name-only {target}...{branch}` | 同一文件被多个分支修改,记为重叠 |
|
||||
| 公共文件依赖 | 关注 `shared/config/infra` 范围和公共抽象 | 即使文件数少,也记为高优先级依赖 |
|
||||
|
||||
为每个分支输出:
|
||||
|
||||
- `depends_on[]`:它依赖哪些分支
|
||||
- `blocks[]`:哪些分支依赖它
|
||||
- `shared_files[]`:与其他分支重叠的文件
|
||||
- `common_files[]`:被识别为公共/基础设施的文件
|
||||
|
||||
### 2.3 初始冲突预测(串行)
|
||||
|
||||
仅对“未合并且有实际差异”的分支串行执行:
|
||||
|
||||
1. 执行 `git merge --no-commit --no-ff {branch}`
|
||||
2. 若工作区进入 merge 状态:
|
||||
- 执行 `git diff --name-only --diff-filter=U` 记录冲突文件
|
||||
- 若存在 `MERGE_HEAD`,执行 `git merge --abort`
|
||||
3. 若命令输出为 `Already up to date`、无 `MERGE_HEAD`、无冲突文件,记为“已包含或无差异”,不执行 `git merge --abort`
|
||||
4. 若 `git merge --abort` 失败:
|
||||
- 报告错误和当前状态
|
||||
- 用提问工具让用户选择:
|
||||
- `回到全局锚点后继续分析`:执行 `git reset --hard {global_tag}`
|
||||
- `终止`
|
||||
- `reset --hard` 失败则终止并提示用户手动处理
|
||||
|
||||
### 2.4 风险分级与排序
|
||||
|
||||
风险评级:
|
||||
|
||||
- 低:无预测冲突、无依赖、单一模块、小体量改动
|
||||
- 中:存在依赖,或存在预测冲突,或跨两个以上模块,或触及公共文件
|
||||
- 高:预测冲突文件 `>= 3`,或冲突占改动文件比例 `>= 30%`,或存在 ancestry 依赖且触及公共文件 / 根级配置 / 基础设施文件
|
||||
|
||||
初始排序规则:
|
||||
|
||||
1. 已合并 / 无差异(默认跳过,仅展示)
|
||||
2. 被其他分支依赖的基础分支
|
||||
3. 仅改公共/基础设施文件的分支
|
||||
4. 独立、低风险分支
|
||||
5. 存在依赖或共享文件的分支
|
||||
6. 高风险、跨模块分支
|
||||
|
||||
### 2.5 验证命令归纳
|
||||
|
||||
根据步骤 1 探测结果,为后续语义修复和构建验证归纳命令:
|
||||
|
||||
| 作用域 | 优先级 |
|
||||
| ---------------------- | --------------------------------------- |
|
||||
| 模块级 lint/build/test | 优先使用模块文档或模块脚本中声明的命令 |
|
||||
| 仓库级 lint/build/test | 若模块级命令缺失,使用仓库统一命令 |
|
||||
| 无现成命令 | 记录为“无法自动验证”,步骤 3 显示给用户 |
|
||||
|
||||
归纳原则:
|
||||
|
||||
- 优先使用仓库文档明确写出的命令
|
||||
- 其次使用标准任务入口中的现有命令
|
||||
- 再其次使用就近模块的标准脚本
|
||||
- 不自行发明新的构建或测试命令
|
||||
|
||||
### 2.6 分析结果汇总
|
||||
|
||||
为每个分支形成分析快照:
|
||||
|
||||
| 字段 | 内容 |
|
||||
| ---------------------- | ---------------------------------- |
|
||||
| `status` | 已合并 / 无差异 / 待合并 |
|
||||
| `head` | 步骤 2 记录的 HEAD hash |
|
||||
| `modules` | 受影响模块 |
|
||||
| `files` | 改动文件数 |
|
||||
| `depends_on` | 依赖分支 |
|
||||
| `predicted_conflicts` | 初始冲突文件列表 |
|
||||
| `risk` | 低 / 中 / 高 |
|
||||
| `semantic_review_mode` | 默认 `仅报告` |
|
||||
| `validation_scope` | 该分支后续优先使用的验证范围和命令 |
|
||||
|
||||
## 3. 计划(用户确认)
|
||||
|
||||
输出合并计划表,列至少包含:
|
||||
|
||||
| 分支 | 状态 | 模块 | 文件数 | 依赖 | 预测冲突 | 风险 | 语义审查 | 验证命令 |
|
||||
| ---- | ---- | ---- | ------ | ---- | -------- | ---- | -------- | -------- |
|
||||
|
||||
默认规则:
|
||||
|
||||
- `已合并`、`无差异` 分支仅展示,不进入执行队列
|
||||
- `待合并` 分支按步骤 2.4 的排序进入执行队列
|
||||
- `语义审查` 默认值为 `仅报告`
|
||||
- 若某分支缺少可自动执行的验证命令,在计划表中明确标记
|
||||
|
||||
同时展示仓库级探测结果摘要:
|
||||
|
||||
- 识别出的模块边界
|
||||
- 公共/基础设施范围
|
||||
- 识别出的验证命令
|
||||
- 提交风格摘要
|
||||
- 无法确定、需要用户补充的规则
|
||||
|
||||
用提问工具让用户选择:
|
||||
|
||||
- `确认执行`
|
||||
- `调整顺序`
|
||||
- `排除分支`
|
||||
- `调整语义审查模式`
|
||||
- `补充或修正规则`
|
||||
- `重新分析全部分支`
|
||||
- `终止`
|
||||
|
||||
语义审查模式仅允许三种:
|
||||
|
||||
- `关闭`
|
||||
- `仅报告`
|
||||
- `报告并修复`
|
||||
|
||||
若用户选择 `调整顺序`、`排除分支`、`调整语义审查模式`、`补充或修正规则`、`重新分析全部分支`,则更新计划表后再次确认,直到用户选择 `确认执行` 或 `终止`。
|
||||
|
||||
最终确认后,记录最终执行队列。并明确提示:
|
||||
|
||||
- 正常 merge 已获授权
|
||||
- 冲突内容重写、`stash`、`revert`、`reset --hard`、删除分支、远端删除仍需单独确认
|
||||
|
||||
## 4. 执行(顺序执行,冲突/异常时中断)
|
||||
|
||||
对执行队列中的分支依次处理。若队列为空,直接进入步骤 5。
|
||||
|
||||
### 4.1 单分支合并前检查
|
||||
|
||||
每个分支开始前都执行以下检查:
|
||||
|
||||
1. `git status --short --branch`,确认当前仍在 `{target}`
|
||||
2. 若不在 `{target}`,执行 `git checkout {target}`;失败则中断并询问
|
||||
3. 若工作区非干净,展示异常文件,并用提问工具让用户选择:
|
||||
- `stash 后继续`
|
||||
- `终止`
|
||||
4. 若用户选择 `stash 后继续`,执行 `git stash push --include-untracked -m smart-merge-{timestamp}-runtime-{n}`,记录到 `auto_stashes[]`
|
||||
5. 检查分支是否仍存在;不存在则记录为 `已跳过:分支不存在`
|
||||
6. 执行 `git rev-parse {branch}`,对比步骤 2 记录的 `head`
|
||||
7. 若 HEAD 已变化,先对该分支重新执行步骤 2 的单分支分析,再更新计划快照
|
||||
|
||||
### 4.2 基于当前 HEAD 的动态复核
|
||||
|
||||
步骤 2 的分析基于初始目标分支;每次实际 merge 前都必须基于“当前最新 HEAD”重新复核当前分支:
|
||||
|
||||
1. 执行 `git diff --name-status HEAD...{branch}`,获取当前变更文件列表
|
||||
2. 串行执行 `git merge --no-commit --no-ff {branch}` 做实时 dry-run
|
||||
3. 若进入 merge 状态,收集 `git diff --name-only --diff-filter=U` 冲突文件后执行 `git merge --abort`
|
||||
4. 若无 `MERGE_HEAD`,按实际结果记为“无冲突”或“已包含 / 无差异”
|
||||
5. 将实时结果与步骤 3 的计划快照对比
|
||||
|
||||
视为“计划漂移”的情况:
|
||||
|
||||
- 冲突文件集合发生变化
|
||||
- 改动文件数变化明显(以 `20%` 为阈值)
|
||||
- 新出现公共文件 / 根级配置 / 基础设施文件
|
||||
- 原本 `无冲突` 变成 `有冲突`
|
||||
- 原本模块级验证命令不再覆盖当前改动范围
|
||||
|
||||
若发生计划漂移,用提问工具让用户选择:
|
||||
|
||||
- `按更新后的当前结果继续`
|
||||
- `重新生成剩余分支计划`:对“当前分支 + 尚未处理的剩余分支”重新执行步骤 2,再回到步骤 3
|
||||
- `跳过当前分支`
|
||||
- `终止`
|
||||
|
||||
### 4.3 生成提交模板并创建分支级锚点
|
||||
|
||||
先根据 `commit_style` 生成本轮使用的提交模板:
|
||||
|
||||
- 若仓库文档明确约束了提交格式,严格遵守
|
||||
- 若文档未约束,但近期提交风格稳定,沿用仓库现有风格
|
||||
- 若无法识别,合并提交使用 `chore: merge {branch} into {target}`,语义修复提交使用 `refactor: address post-merge issues in {branch}`
|
||||
|
||||
然后:
|
||||
|
||||
- 执行 `git tag merge-before-{branch}-{timestamp}`
|
||||
- 记录到 `branch_tags[]`
|
||||
|
||||
### 4.4 正式合并
|
||||
|
||||
- 执行正式合并:`git merge --no-ff -m "{merge_commit_message}" {branch}`
|
||||
|
||||
正式合并结果分三类:
|
||||
|
||||
- 无冲突:进入步骤 4.6
|
||||
- 有冲突:进入步骤 4.5
|
||||
- 非冲突错误:展示错误信息,用提问工具选择:`跳过当前分支` / `终止`
|
||||
|
||||
### 4.5 冲突处理(中断点)
|
||||
|
||||
先执行 `git diff --name-only --diff-filter=U` 获取冲突文件清单,再按文件生成冲突决策面板:
|
||||
|
||||
| # | 文件 | 冲突类型 | HEAD 改动摘要 | 分支改动摘要 | 可批量机械处理 | 推荐方案 |
|
||||
| --- | ---- | -------- | ------------- | ------------ | -------------- | -------- |
|
||||
|
||||
推荐方案规则:
|
||||
|
||||
- 仅当解决方案明确等价于 `--ours`、`--theirs`、保留删除的一侧、保留重命名的一侧时,才允许给出“可批量机械处理”的推荐
|
||||
- 只要需要合并两边逻辑、补全缺失分支、重排代码顺序、解决同一函数 / 类 / 配置段双改,就标记为 `需逐文件确认`
|
||||
- `需逐文件确认` 的文件不能被“全部按推荐方案”一键处理
|
||||
|
||||
用提问工具让用户选择:
|
||||
|
||||
- `处理全部机械型冲突`
|
||||
- `审查部分文件`
|
||||
- `放弃当前合并`
|
||||
|
||||
若用户选择 `审查部分文件`,先让用户输入文件编号,再对这些文件逐个展示:
|
||||
|
||||
- 上下文 diff
|
||||
- 原始冲突块 `<<<<<<<` / `=======` / `>>>>>>>`
|
||||
- 当前建议与风险说明
|
||||
|
||||
逐文件可选方案:
|
||||
|
||||
- `保留目标 (--ours)`
|
||||
- `保留分支 (--theirs)`
|
||||
- `AI 起草双保留结果`
|
||||
- `用户手动编辑`
|
||||
|
||||
执行规则:
|
||||
|
||||
- `--ours` / `--theirs`:执行 `git checkout --ours/--theirs {file}`
|
||||
- `AI 起草双保留结果`:
|
||||
- 先生成完整最终结果或统一 diff
|
||||
- 说明具体合并逻辑
|
||||
- 再次用提问工具确认后写入文件
|
||||
- `用户手动编辑`:告知文件路径,等待用户回复“完成”后再执行 `git add {file}`
|
||||
- 所有冲突文件都必须逐文件 `git add {file}`
|
||||
- 执行 `git diff --name-only --diff-filter=U`,确认已无未解决冲突
|
||||
- 再执行 `git commit --no-edit`
|
||||
|
||||
若用户选择 `放弃当前合并`:
|
||||
|
||||
- 用提问工具确认是否执行 `git merge --abort`
|
||||
- 若 `abort` 成功,记录当前分支为 `已跳过:用户放弃合并`
|
||||
- 若 `abort` 失败,再用提问工具确认是否执行 `git reset --hard merge-before-{branch}-{timestamp}` 回到分支级锚点
|
||||
|
||||
### 4.6 语义审查
|
||||
|
||||
语义审查基于 `git diff merge-before-{branch}-{timestamp}..HEAD`。
|
||||
|
||||
辅助数据源:
|
||||
|
||||
| 数据 | 获取方式 | 用途 |
|
||||
| ------------- | -------------------------------------------------- | -------------------------------- |
|
||||
| 本次合入 diff | `git diff merge-before-{branch}-{timestamp}..HEAD` | 查看本次实际引入的改动 |
|
||||
| 分支意图 | 步骤 2 的 commit 消息 | 推断业务目的 |
|
||||
| 主干近期趋势 | `git log --oneline {target} -20` 及相关 diff | 识别近期重构、迁移、废弃方向 |
|
||||
| 公共文件现状 | 步骤 2 标记的 `common_files[]` | 判断是否重复造轮子或遗漏基础设施 |
|
||||
| 仓库规则 | `repo_rules`、`module_map`、`validation_commands` | 判断是否偏离当前仓库约定 |
|
||||
|
||||
审查维度:
|
||||
|
||||
| 维度 | 检查点 |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------ |
|
||||
| 代码冗余 | 新增函数、组件、类型、工具、配置是否已在仓库公共位置存在等价实现 |
|
||||
| 过时模式 | 是否继续使用近期已迁移、废弃、替换的 API、脚本、配置项、目录模式 |
|
||||
| 基础设施遗漏 | 新代码是否绕过既有公共抽象、统一错误处理、统一日志、统一配置、统一数据访问、统一请求封装等既有基础设施 |
|
||||
| 风格不一致 | 命名、文件组织、测试组织、错误处理、注释风格是否偏离当前主干 |
|
||||
| 模块边界破坏 | 是否把本应落在公共层、共享层、基础设施层的逻辑塞进业务分支目录 |
|
||||
|
||||
输出审查结果表:
|
||||
|
||||
| # | 类别 | 严重程度 | 文件 | 描述 | 建议修复方式 |
|
||||
| --- | ---- | -------- | ---- | ---- | ------------ |
|
||||
|
||||
严重程度仅分两类:
|
||||
|
||||
- `问题`:有运行时风险、明显架构偏差或维护成本高
|
||||
- `建议`:功能可用,但与主干一致性较差
|
||||
|
||||
按计划中的 `semantic_review_mode` 处理:
|
||||
|
||||
- `关闭`:跳过语义审查
|
||||
- `仅报告`:展示结果后直接进入步骤 4.7
|
||||
- `报告并修复`:展示结果后,用提问工具选择:`全部修复` / `选择修复` / `跳过`
|
||||
|
||||
修复执行规则:
|
||||
|
||||
1. 每个问题编号视为一个独立修复单元
|
||||
2. 一次只处理一个编号,避免回退时互相污染
|
||||
3. 每个修复单元只修改其必要文件,保持最小变更
|
||||
4. 修复完成后,按 `validation_commands` 和当前改动作用域执行最小充分检查:
|
||||
|
||||
| 作用域 | 检查策略 |
|
||||
| -------------------------- | -------------------------------------- |
|
||||
| 仅单模块 | 优先执行该模块的 lint 或等价静态检查 |
|
||||
| 多模块 | 执行覆盖这些模块的仓库级检查或组合检查 |
|
||||
| 触及 `shared/config/infra` | 执行受影响范围内最严格的可用检查 |
|
||||
| 无自动检查命令 | 记录为“未自动验证”,并在结果中明确说明 |
|
||||
|
||||
5. 若检查通过,立即提交独立修复 commit,message 遵循 `commit_style`
|
||||
6. 若检查失败,只回退当前修复单元的未提交修改:`git restore --staged --worktree --source=HEAD -- {files}`
|
||||
7. 记录该编号为 `修复失败并已回退`,继续处理下一个编号
|
||||
|
||||
### 4.7 构建验证
|
||||
|
||||
根据当前分支实际引入的改动范围,从 `validation_commands` 中选择最小充分的构建 / 测试 / 检查命令:
|
||||
|
||||
| 改动范围 | 验证策略 |
|
||||
| -------------------------- | -------------------------------------------- |
|
||||
| 单模块业务改动 | 优先运行该模块的 build / test / lint 组合 |
|
||||
| 多模块改动 | 运行覆盖这些模块的聚合命令 |
|
||||
| 触及共享层、配置、基础设施 | 优先运行仓库级验证或覆盖共享层的更高等级验证 |
|
||||
| 仓库没有可自动执行命令 | 记录为“无法自动验证”,并向用户说明 |
|
||||
|
||||
构建成功:
|
||||
|
||||
- 记录当前分支为 `已合并`
|
||||
- 记录冲突处理方式、语义审查结果、修复提交列表、实际执行的验证命令
|
||||
- 继续下一个分支
|
||||
|
||||
构建失败:
|
||||
|
||||
1. 展示失败命令和错误摘要
|
||||
2. 若当前分支存在语义修复提交,用提问工具让用户选择:
|
||||
- `依次 revert 当前分支的语义修复提交后重试验证`
|
||||
- `放弃当前分支并回到分支级锚点`
|
||||
- `终止`
|
||||
3. 若用户选择 revert:
|
||||
- 按提交时间倒序执行 `git revert --no-edit {commit}`
|
||||
- 每 revert 一条后重试验证,直到通过或全部 revert 完成
|
||||
- 若 revert 冲突,立即中断并询问:`回到分支级锚点` / `保留冲突现场并终止`
|
||||
4. 若无语义修复提交,或全部 revert 后验证仍失败,用提问工具让用户选择:
|
||||
- `git reset --hard merge-before-{branch}-{timestamp}` 放弃当前分支
|
||||
- `终止并保留当前现场`
|
||||
5. 若用户确认回到分支级锚点,记录当前分支为 `已跳过:验证失败并回退`
|
||||
|
||||
## 5. 清理(用户确认)
|
||||
|
||||
所有分支处理完成后,输出汇总表:
|
||||
|
||||
| 项目 | 内容 |
|
||||
| ---------- | ------------------------------------------------------------------------------------ |
|
||||
| 目标分支 | `{target}` |
|
||||
| 当前 HEAD | 当前提交 hash |
|
||||
| 合并结果 | 成功 / 跳过 / 已合并 / 无差异 数量 |
|
||||
| 冲突统计 | 冲突分支数、冲突文件数、机械处理数、逐文件确认数 |
|
||||
| 语义审查 | 每个分支的问题数、建议数、修复成功数、修复失败数 |
|
||||
| 实际验证 | 每个分支实际执行了哪些验证命令,哪些未验证 |
|
||||
| 已创建资源 | `global_tag`、`branch_tags[]`、`auto_stashes[]`、`created_local_tracking_branches[]` |
|
||||
| 候选删除 | 可删除的本地分支、远端分支、可删除 tag |
|
||||
|
||||
用提问工具一次性确认清理策略:
|
||||
|
||||
- `确认清理`
|
||||
- `调整清理范围`
|
||||
- `跳过清理`
|
||||
|
||||
清理项包括:
|
||||
|
||||
- 删除已成功合并的本地分支:`git branch -d {branch}`
|
||||
- 删除已成功合并且用户确认的远端分支:`git push {remote} --delete {branch}`
|
||||
- 删除用户明确不再保留的安全锚点 tag
|
||||
- 删除本流程仅为分析/合并而创建、且最终不需要保留的本地跟踪分支
|
||||
|
||||
远端分支删除规则:
|
||||
|
||||
- 优先删除该分支自己的 upstream remote
|
||||
- 若分支无 upstream,但来自 `target_remote`,则删除 `target_remote/{branch}`
|
||||
- 删除失败只记录错误,不影响其余清理项
|
||||
|
||||
### 工作区恢复
|
||||
|
||||
若 `auto_stashes[]` 非空,用提问工具让用户选择:
|
||||
|
||||
- `按顺序恢复全部 stash`
|
||||
- `查看 stash 列表后选择恢复`
|
||||
- `暂不恢复,保留 stash`
|
||||
|
||||
恢复时按后进先出顺序处理。由于 `stash@{n}` 会随列表变化而漂移,先根据已记录的唯一 message 在 `git stash list` 中定位当前 ref,再对该 stash 执行 `git stash apply {ref}`,不直接 `pop`:
|
||||
|
||||
1. `apply` 成功后,再询问是否 `git stash drop {ref}`
|
||||
2. 若 `apply` 冲突,展示 stash 冲突决策面板,逐文件选择:
|
||||
- `保留 stash 内容`
|
||||
- `保留当前内容`
|
||||
- `AI 起草双保留结果`
|
||||
- `用户手动编辑`
|
||||
- `放弃恢复该 stash`
|
||||
3. 冲突解决完成后,再询问是否 `drop` 对应 stash
|
||||
|
||||
输出最终总结:目标分支、当前 HEAD、探测到的仓库规则摘要、保留的锚点、各分支最终状态、未删除的远端分支、stash 恢复结果。
|
||||
|
||||
## 终止处理
|
||||
|
||||
任一步骤选择 `终止` 时,输出终止摘要:
|
||||
|
||||
| 项目 | 内容 |
|
||||
| ---------- | ------------------------------------------------------------------------------------ |
|
||||
| 当前步骤 | 准备 / 分析 / 计划 / 执行 / 清理 |
|
||||
| 当前 HEAD | 当前提交 hash |
|
||||
| 已完成分支 | 成功合并的分支 |
|
||||
| 已跳过分支 | 分支及原因 |
|
||||
| 未处理分支 | 剩余执行队列 |
|
||||
| 已创建资源 | `global_tag`、`branch_tags[]`、`auto_stashes[]`、`created_local_tracking_branches[]` |
|
||||
| 已识别规则 | `repo_rules`、`module_map`、`validation_commands` |
|
||||
|
||||
若终止时存在活动 merge 状态,再用提问工具让用户选择:
|
||||
|
||||
- `执行 git merge --abort 并结束`
|
||||
- `回到当前分支级锚点并结束`
|
||||
- `保留当前冲突现场,交给用户手动处理`
|
||||
|
||||
若终止时不存在活动 merge 状态,不自动变更现场;仅提示用户:
|
||||
|
||||
- 全部回退可使用 `git reset --hard {global_tag}`
|
||||
- 当前分支级回退可使用 `git reset --hard merge-before-{branch}-{timestamp}`
|
||||
- 本流程创建的 stash 和本地跟踪分支仍保留,是否恢复/删除由步骤 5 或用户后续手动决定
|
||||
31
docs/user/README.md
Normal file
31
docs/user/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# 用户文档
|
||||
|
||||
本文档是 my-app 的用户使用入口,说明如何使用模板、配置、部署和排查问题。
|
||||
|
||||
适用场景:使用本模板创建新项目、编写配置、生产部署、排查运行问题。
|
||||
|
||||
## 文档索引
|
||||
|
||||
| 文档 | 内容 |
|
||||
| ---------------------------------- | ------------------------------------------- |
|
||||
| [usage.md](usage.md) | 克隆模板、配置应用信息、准备配置、开始开发 |
|
||||
| [config.md](config.md) | YAML 结构、变量语法、server/storage/logging |
|
||||
| [deploy.md](deploy.md) | 生产构建、可执行文件运行、运行时配置 |
|
||||
| [troubleshoot.md](troubleshoot.md) | 常见问题:配置校验、变量解析、构建失败 |
|
||||
|
||||
## 按任务阅读
|
||||
|
||||
| 任务 | 建议阅读 |
|
||||
| -------- | -------------------------------------------------------------- |
|
||||
| 首次使用 | [项目快速开始](../../README.md#快速开始)、[使用模板](usage.md) |
|
||||
| 编写配置 | [配置文件](config.md) |
|
||||
| 生产部署 | [部署](deploy.md)、[故障排查](troubleshoot.md) |
|
||||
| 排查问题 | [故障排查](troubleshoot.md) |
|
||||
|
||||
## 用户文档更新规则
|
||||
|
||||
- 使用模板流程、应用信息配置、初始化步骤变化时,更新 [usage.md](usage.md)。
|
||||
- 配置结构、变量语法、server/storage/logging 字段变化时,更新 [config.md](config.md)。
|
||||
- 生产构建、可执行文件运行、运行时依赖变化时,更新 [deploy.md](deploy.md)。
|
||||
- 常见错误、配置校验、构建失败排查方式变化时,更新 [troubleshoot.md](troubleshoot.md)。
|
||||
- 用户文档只解释"如何使用"和"用户能观察到什么",实现细节放入 [`../development/`](../development/README.md)。
|
||||
106
docs/user/config.md
Normal file
106
docs/user/config.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# 配置文件
|
||||
|
||||
项目使用 YAML 配置文件,配置文件为启动时的必传参数,支持通过 JSON Schema 编辑器提示和显式变量引用。配置中的相对路径均基于配置文件所在目录解析,绝对路径保持不变。
|
||||
|
||||
## 配置文件
|
||||
|
||||
复制 config.example.yaml 为 config.yaml(或任意名称),根据需要修改:
|
||||
|
||||
```yaml
|
||||
# yaml-language-server: $schema=./config.schema.json
|
||||
server:
|
||||
listen:
|
||||
host: "127.0.0.1"
|
||||
port: 3000
|
||||
storage:
|
||||
dataDir: ./data
|
||||
logging:
|
||||
level: info
|
||||
console:
|
||||
level: info
|
||||
file:
|
||||
level: info
|
||||
path: "./logs/my-app.log"
|
||||
rotation:
|
||||
size: 50MB
|
||||
frequency: daily
|
||||
maxFiles: 14
|
||||
```
|
||||
|
||||
## server.listen
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ---- | ------ | ------------------------ |
|
||||
| host | string | 监听地址,默认 127.0.0.1 |
|
||||
| port | number | 监听端口,默认 3000 |
|
||||
|
||||
## server.storage
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ------- | ------ | --------------------------------------------------- |
|
||||
| dataDir | string | 数据目录,默认 ./data,相对路径基于配置文件目录解析 |
|
||||
|
||||
## server.logging
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ----- | ------ | ------------------------------------------------------------ |
|
||||
| level | string | 全局日志级别(trace/debug/info/warn/error/fatal),默认 info |
|
||||
|
||||
### server.logging.console
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ----- | ------ | ------------------------------------------------- |
|
||||
| level | string | 控制台日志级别,未设置时继承 server.logging.level |
|
||||
|
||||
### server.logging.file
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ----- | ------ | ----------------------------------------------- |
|
||||
| level | string | 文件日志级别,未设置时继承 server.logging.level |
|
||||
| path | string | 日志文件路径,默认 <dataDir>/logs/my-app.log |
|
||||
|
||||
### server.logging.file.rotation
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --------- | ------ | --------------------------------------------- |
|
||||
| size | string | 按大小轮转,支持 B/KB/MB/GB 单位,默认 50MB |
|
||||
| frequency | string | 按时间轮转(hourly/daily/weekly),默认 daily |
|
||||
| maxFiles | number | 最大归档文件数,默认 14 |
|
||||
|
||||
## JSON Schema
|
||||
|
||||
根目录 config.schema.json 为配置文件的 JSON Schema,支持 IDE 自动补全和校验。
|
||||
|
||||
```bash
|
||||
bun run schema # 重新生成 config.schema.json
|
||||
bun run schema:check # 校验 config.schema.json 是否同步
|
||||
```
|
||||
|
||||
## 变量语法
|
||||
|
||||
YAML 配置中支持显式变量引用:
|
||||
|
||||
```text
|
||||
${KEY} 引用变量,未定义时报错
|
||||
${KEY|value} 引用变量,未定义时使用默认值
|
||||
${KEY|} 引用变量,未定义时使用空字符串
|
||||
$${KEY} 转义,输出 ${KEY} 原文字面量
|
||||
```
|
||||
|
||||
变量解析优先级:variables 字段 > process.env > 默认值 > unresolved 报错
|
||||
|
||||
完整变量引用(整个值只有 ${...})保留原始类型:${PORT|3000} 解析为 number 3000。部分拼接统一转为 string。
|
||||
|
||||
## 配置优先级
|
||||
|
||||
```
|
||||
variables 字段 > 环境变量 > 默认值 > unresolved 报错
|
||||
```
|
||||
|
||||
环境变量不会隐式覆盖配置,只有通过 ${KEY} 显式引用时才生效。
|
||||
|
||||
## 使用自定义配置
|
||||
|
||||
```bash
|
||||
bun run dev custom-config.yaml
|
||||
```
|
||||
54
docs/user/deploy.md
Normal file
54
docs/user/deploy.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# 生产部署
|
||||
|
||||
本文档说明如何构建和运行生产环境的应用。
|
||||
|
||||
## 生产构建和运行
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
./dist/my-app config.yaml
|
||||
```
|
||||
|
||||
启动后:
|
||||
|
||||
| 地址 | 行为 |
|
||||
| ------------------------------ | ------------------- |
|
||||
| http://127.0.0.1:3000/ | 返回前端 SPA |
|
||||
| http://127.0.0.1:3000/api/meta | 返回应用元信息 JSON |
|
||||
| http://127.0.0.1:3000/health | 返回健康检查 |
|
||||
|
||||
## 构建流程
|
||||
|
||||
scripts/build.ts 执行三步流水线:
|
||||
|
||||
```text
|
||||
1. Vite build -> dist/web/(前端静态资源,含 code splitting)
|
||||
2. Code generation -> .build/static-assets.ts + .build/server-entry.ts(含版本号字面量注入)
|
||||
3. Bun compile -> dist/my-app(单可执行文件)
|
||||
```
|
||||
|
||||
- Vite 构建前端资源到 dist/web/,自动 code splitting(vendor-react、vendor-tdesign、vendor-chart)
|
||||
- Code generation 扫描 dist/web/ 生成 import with { type: "file" } 声明,将资源嵌入 binary
|
||||
- Bun compile 以 .build/server-entry.ts 为入口编译最终可执行文件
|
||||
- .build/ 临时目录在构建完成后自动清理
|
||||
|
||||
## 产物
|
||||
|
||||
| 产物 | 用途 |
|
||||
| ----------- | ---------------------------------------- |
|
||||
| dist/my-app | 生产可执行文件(含前端资源,单文件部署) |
|
||||
| dist/web/ | Vite 构建的前端资源(构建中间产物) |
|
||||
|
||||
## 构建参数
|
||||
|
||||
| 环境变量 | 说明 |
|
||||
| ------------------------- | ------------------------------------ |
|
||||
| BUN_TARGET / BUILD_TARGET | 交叉编译目标平台(如 bun-linux-x64) |
|
||||
|
||||
## 清理
|
||||
|
||||
```bash
|
||||
bun run clean
|
||||
```
|
||||
|
||||
清理 dist/ 构建产物和 .build/ 临时文件。
|
||||
61
docs/user/troubleshoot.md
Normal file
61
docs/user/troubleshoot.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# 故障排查
|
||||
|
||||
本文档记录使用模板时的常见问题和排查入口。
|
||||
|
||||
## 配置校验失败
|
||||
|
||||
启动时会校验 YAML 配置。未知字段会导致启动失败。
|
||||
|
||||
排查顺序:
|
||||
|
||||
1. 在 YAML 顶部添加 `# yaml-language-server: $schema=./config.schema.json`。
|
||||
2. 对照 [配置文件](config.md) 检查配置结构。
|
||||
3. 运行 `bun run schema:check` 确认 JSON Schema 是否同步。
|
||||
|
||||
## 变量无法解析
|
||||
|
||||
变量解析优先级为 variables 字段 > process.env > 默认值。如果三者均不存在,配置校验会失败。
|
||||
|
||||
常见修复:
|
||||
|
||||
```text
|
||||
环境变量未设置 设置环境变量或在 variables 中声明
|
||||
希望允许空值 使用 ${KEY|}
|
||||
希望提供默认值 使用 ${KEY|default}
|
||||
希望输出字面量 使用 $${KEY}
|
||||
```
|
||||
|
||||
## 端口被占用
|
||||
|
||||
修改 config.yaml 中的 server.listen.port 字段为可用端口。
|
||||
|
||||
## Schema 不同步
|
||||
|
||||
config.schema.json 与 TypeBox 定义不一致时会导致校验行为异常。
|
||||
|
||||
```bash
|
||||
bun run schema # 重新生成 config.schema.json
|
||||
bun run schema:check # 校验是否同步
|
||||
```
|
||||
|
||||
## 构建失败
|
||||
|
||||
先运行完整质量检查定位问题:
|
||||
|
||||
```bash
|
||||
bun run check # schema:check + typecheck + lint + test
|
||||
```
|
||||
|
||||
如果 check 通过但仍构建失败:
|
||||
|
||||
```bash
|
||||
bun run verify # check + build 完整验证
|
||||
```
|
||||
|
||||
检查 TypeScript 类型错误和构建脚本输出,确保所有依赖已安装(`bun install`)。
|
||||
|
||||
## 前端页面空白
|
||||
|
||||
- 确认后端 API server 已启动
|
||||
- 开发模式下确认 Vite dev server 代理配置正确
|
||||
- 生产模式下确认前端静态资源已正确嵌入可执行文件
|
||||
65
docs/user/usage.md
Normal file
65
docs/user/usage.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# 使用模板
|
||||
|
||||
本文档说明如何使用本模板创建新项目。
|
||||
|
||||
## 1. 克隆模板
|
||||
|
||||
```bash
|
||||
git clone <template-repo-url> my-project
|
||||
cd my-project
|
||||
rm -rf .git && git init
|
||||
```
|
||||
|
||||
## 2. 配置应用信息
|
||||
|
||||
编辑 `src/shared/app.ts`,修改应用元信息:
|
||||
|
||||
```typescript
|
||||
export const APP = {
|
||||
name: "your-app", // 机器标识(kebab-case)
|
||||
title: "Your App", // 人类可读标题
|
||||
subtitle: "你的副标题", // 副标题
|
||||
description: "应用描述", // SEO meta 描述
|
||||
} as const;
|
||||
```
|
||||
|
||||
同时修改 `package.json` 的 `name` 字段保持一致,`version` 字段管理应用版本号。
|
||||
|
||||
## 3. 准备配置文件
|
||||
|
||||
```bash
|
||||
cp config.example.yaml config.yaml
|
||||
```
|
||||
|
||||
按需编辑 `config.yaml` 中的监听地址、日志、存储路径等配置。配置文件为启动时的必传参数。
|
||||
|
||||
## 4. 清理 OpenSpec 历史
|
||||
|
||||
删除模板自带的 OpenSpec 变更历史,保留框架配置:
|
||||
|
||||
```bash
|
||||
rm -rf openspec/specs/*
|
||||
rm -rf openspec/changes/*
|
||||
```
|
||||
|
||||
`openspec/config.yaml` 和 `openspec/schemas/fast-drive/` 需要保留,其中包含项目开发规范配置与自定义 OpenSpec workflow schema。
|
||||
|
||||
## 5. 安装依赖
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
## 6. 开始开发
|
||||
|
||||
```bash
|
||||
bun run dev config.yaml
|
||||
```
|
||||
|
||||
访问 http://127.0.0.1:5173 查看应用。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [配置文件](config.md) — 了解 YAML 结构、变量语法和配置字段
|
||||
- [部署文档](deploy.md) — 生产构建和运行方式
|
||||
- [开发文档](../development/README.md) — 开发规范、架构和质量门禁
|
||||
110
eslint.config.js
Normal file
110
eslint.config.js
Normal file
@@ -0,0 +1,110 @@
|
||||
import js from "@eslint/js";
|
||||
import importPlugin from "eslint-plugin-import";
|
||||
import perfectionist from "eslint-plugin-perfectionist";
|
||||
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
const noDirectConsoleMessage =
|
||||
"后端运行时代码禁止直接使用 console.*;请通过注入的 Logger 实例输出日志,配置加载失败前使用 createConsoleFallback()。";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
"node_modules/**",
|
||||
"dist/**",
|
||||
".build/**",
|
||||
"*.bun-build",
|
||||
"openspec/**",
|
||||
".opencode/**",
|
||||
".claude/**",
|
||||
".codex/**",
|
||||
".agents/**",
|
||||
"bun.lock",
|
||||
"data/**",
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
importPlugin.flatConfigs.recommended,
|
||||
importPlugin.flatConfigs.typescript,
|
||||
perfectionist.configs["recommended-natural"],
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
"import/resolver": { node: true, typescript: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
"@typescript-eslint/array-type": ["error", { default: "array-simple" }],
|
||||
"@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "as" }],
|
||||
"@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }],
|
||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||
"@typescript-eslint/only-throw-error": "error",
|
||||
"@typescript-eslint/prefer-nullish-coalescing": "error",
|
||||
"@typescript-eslint/prefer-optional-chain": "error",
|
||||
"import/no-unresolved": ["error", { ignore: ["^bun:"] }],
|
||||
"no-undef": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["eslint.config.js"],
|
||||
rules: {
|
||||
"import/no-named-as-default": "off",
|
||||
"import/no-named-as-default-member": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/server/**/*.ts"],
|
||||
ignores: ["src/server/logger.ts"],
|
||||
rules: {
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
message: noDirectConsoleMessage,
|
||||
selector: "MemberExpression[object.name='console']",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/web/**/*.{ts,tsx}"],
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: [
|
||||
"../server/*",
|
||||
"../server/**",
|
||||
"../**/server/*",
|
||||
"../**/server/**",
|
||||
"../../server/*",
|
||||
"../../server/**",
|
||||
"src/server/*",
|
||||
"src/server/**",
|
||||
],
|
||||
message: "前端不得导入 src/server 后端运行时实现;请改用 src/shared 类型或 HTTP API。",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
},
|
||||
},
|
||||
eslintPluginPrettierRecommended,
|
||||
);
|
||||
10
opencode.json
Normal file
10
opencode.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"tdesign-mcp-server": {
|
||||
"enabled": true,
|
||||
"type": "local",
|
||||
"command": ["bunx", "tdesign-mcp-server@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
37
openspec/config.yaml
Normal file
37
openspec/config.yaml
Normal file
@@ -0,0 +1,37 @@
|
||||
schema: fast-drive
|
||||
|
||||
context: |
|
||||
- 使用中文(注释、文档、交流),面向中文开发者
|
||||
- openspec文档的关键字按openspec规范使用,不要翻译为中文
|
||||
- **优先阅读 docs/README.md** 获取文档路由、归属矩阵和影响分析规则
|
||||
- **其次阅读 docs/development/README.md** 获取开发规范、常用命令、质量门禁和全局规则
|
||||
- 文档文件名优先使用单个英文单词(usage.md、config.md、deploy.md、troubleshoot.md),目录上下文足以消歧时不在文件名重复限定词
|
||||
- 每次代码变更必须执行文档影响分析:
|
||||
- 用户可见行为、配置、部署、运行行为变更 → 更新 docs/user/ 对应文档
|
||||
- 开发流程、架构、测试、构建发布流程变更 → 更新 docs/development/ 对应文档
|
||||
- 项目定位、快速开始、核心能力列表、文档导航变更 → 更新 README.md
|
||||
- 文档同步规则或文档归属矩阵变更 → 更新 docs/README.md 和本文件
|
||||
- 无需更新文档时必须在收尾说明中说明原因
|
||||
- 新增代码优先复用已有组件、工具、依赖库,不引入新依赖
|
||||
- 新增的逻辑必须编写完善的测试,并保证测试的正确性,不允许跳过任何测试
|
||||
- 这是基于bun实现的前端后一体化项目,使用bun作为唯一包管理器,严禁使用pnpm、npm,使用bunx运行工具,严禁使用npx、pnpx
|
||||
- src/server目录下是基于bun实现的后端代码
|
||||
- 后端库使用优先级:Bun 内置 API > es-toolkit > 主流三方库 > 项目公共工具 > 自行实现
|
||||
- src/web目录下是基于Bun HTML import、React、TDesign实现的前端代码
|
||||
- 前端样式开发优先级:TDesign组件 > 组件props > TDesign CSS tokens(--td-*) > styles.css CSS类 > 自行开发组件
|
||||
- 前端严禁:组件内联style属性、CSS覆盖TD内部类名、使用!important、硬编码色值
|
||||
- Git提交: 仅中文; 格式"类型: 简短描述", 类型: feat/fix/refactor/docs/style/test/chore; 多行描述空行后写详细说明
|
||||
- 禁止创建git操作task
|
||||
- 积极使用subagents精心设计并行任务,节省上下文空间,加速任务执行
|
||||
- 优先使用提问工具对用户进行提问
|
||||
- 本项目为 Bun 全栈应用模板,docs/user/ 记录模板使用方法,docs/development/ 记录模板开发技术细节
|
||||
- 本项目为模板参考项目,帮助其他项目快速启动项目,因此开发本项目无需考虑兼容性问题
|
||||
|
||||
rules:
|
||||
design:
|
||||
- 先前的讨论技术方案要尽可能体现在设计文档中,便于指导实现阶段不偏离已定的技术路线
|
||||
tasks:
|
||||
- 一行一个任务,严禁任务内容跨行
|
||||
- 如果是代码存在更新必须
|
||||
- 执行完整的测试、代码检查、格式检查等质量保障手段
|
||||
- 执行文档影响分析,更新 README.md 和/或 docs/ 下对应文档
|
||||
181
openspec/schemas/fast-drive/schema.yaml
Normal file
181
openspec/schemas/fast-drive/schema.yaml
Normal file
@@ -0,0 +1,181 @@
|
||||
name: fast-drive
|
||||
version: 1
|
||||
description: Fast OpenSpec workflow - design -> tasks -> apply
|
||||
artifacts:
|
||||
- id: design
|
||||
generates: design.md
|
||||
description: Self-contained solution brief and execution plan
|
||||
template: design.md
|
||||
instruction: |
|
||||
Create design.md as the self-contained source of truth for what will
|
||||
change, why it is changing, and how the work will be executed.
|
||||
|
||||
This workflow does not use proposal or specs artifacts. design.md MUST
|
||||
preserve the important outcomes from prior exploration and user
|
||||
discussion so a later apply phase can proceed correctly even after
|
||||
context compression or in a new session.
|
||||
|
||||
Write for someone who cannot see the earlier conversation. Keep simple
|
||||
changes concise, but include enough detail to make execution
|
||||
unambiguous. Add more detail when any apply:
|
||||
|
||||
- Cross-cutting change across multiple systems, teams, workstreams, or
|
||||
artifacts
|
||||
|
||||
- New dependency, integration, vendor, tool, policy, or external input
|
||||
|
||||
- Significant information model, process model, data model, or ownership
|
||||
changes
|
||||
|
||||
- Security, privacy, compliance, performance, operational, or migration
|
||||
complexity
|
||||
|
||||
- Ambiguity that benefits from decisions before execution
|
||||
|
||||
- Prior discussion settled non-obvious requirements, constraints, or
|
||||
rejected alternatives
|
||||
|
||||
Required sections:
|
||||
|
||||
- **Context**: Problem, current state, relevant references, and the user
|
||||
request that triggered this change
|
||||
|
||||
- **Discussion Notes**: Key points from exploration or prior discussion
|
||||
that must not be lost. Include agreed conclusions, user preferences,
|
||||
constraints, and important rejected ideas.
|
||||
|
||||
- **Requirements**: Expected outcomes, behavior/process/interface/content
|
||||
changes, continuity expectations, and acceptance criteria.
|
||||
|
||||
- **Goals / Non-Goals**: What this change will achieve and what is
|
||||
explicitly out of scope.
|
||||
|
||||
- **Execution Guardrails**: Must-follow constraints, forbidden approaches,
|
||||
preserved behavior/processes, dependency limits, and project- or
|
||||
workflow-specific boundaries.
|
||||
|
||||
- **Affected Areas**: Concrete artifacts, references, stakeholders,
|
||||
systems, workstreams, documents, configurations, assets, or handoffs that
|
||||
are relevant to the change.
|
||||
|
||||
- **Decisions**: Key choices with rationale (why X over Y?). For each
|
||||
important decision, include alternatives considered and why they were not
|
||||
chosen.
|
||||
|
||||
- **Execution Plan**: Main workstreams or artifacts to change, integration
|
||||
or handoff points, sequencing, and any rollout notes.
|
||||
|
||||
- **Verification Plan**: Validation checks, reviews, approvals,
|
||||
acceptance checks, documentation checks, communication checks, and manual
|
||||
checks needed to prove the change is complete.
|
||||
|
||||
- **Risks / Trade-offs**: Known limitations and things that could go
|
||||
wrong.
|
||||
Format: [Risk] -> Mitigation
|
||||
|
||||
- **Open Questions**: Outstanding decisions, assumptions, or unknowns to
|
||||
resolve before execution. Separate blocking questions that must pause
|
||||
apply from non-blocking follow-ups. Use "None" if there are no open
|
||||
questions.
|
||||
|
||||
Optional sections when relevant:
|
||||
|
||||
- **Migration / Rollout Plan**: Rollout steps, communication, ownership,
|
||||
rollback, or continuity strategy.
|
||||
|
||||
Focus on preserving requirements, rationale, constraints, and approach.
|
||||
Avoid line-by-line or step-by-step details unless a detail is a deliberate
|
||||
decision from the discussion.
|
||||
|
||||
Prefer durable summaries over chat transcripts. Use concrete artifact
|
||||
names, data/information shapes, examples, stakeholders, ownership, and
|
||||
edge cases when they affect execution.
|
||||
|
||||
Do not use task checkboxes in design.md; checkboxes belong only in
|
||||
tasks.md.
|
||||
|
||||
Final design.md must not contain unresolved template comments, empty
|
||||
table rows, or placeholder text.
|
||||
|
||||
If information is missing, state assumptions and open questions instead
|
||||
of inventing hidden requirements. Do not rely on unstated chat context.
|
||||
requires: []
|
||||
- id: tasks
|
||||
generates: tasks.md
|
||||
description: Trackable execution checklist derived from design.md
|
||||
template: tasks.md
|
||||
instruction: |
|
||||
Create tasks.md by breaking design.md into executable work.
|
||||
|
||||
**IMPORTANT: Follow the template below exactly.** The apply phase parses
|
||||
checkbox format to track progress. Tasks not using `- [ ]` will not be
|
||||
tracked.
|
||||
|
||||
Guidelines:
|
||||
|
||||
- Derive tasks from design.md. Do not depend on proposal.md or specs
|
||||
artifacts; any relevant prior discussion must already be captured in
|
||||
design.md.
|
||||
|
||||
- Group related tasks under `##` numbered headings
|
||||
|
||||
- Each task MUST be a single-line checkbox: `- [ ] X.Y Task description`
|
||||
|
||||
- Tasks should be small enough to complete in one session
|
||||
|
||||
- Order tasks by dependency (what must be done first?)
|
||||
|
||||
- Start with context review tasks when execution depends on guardrails,
|
||||
affected areas, or open questions
|
||||
|
||||
- Include validation tasks for checks, reviews, approvals, acceptance,
|
||||
documentation, communication, and manual checks when required
|
||||
|
||||
- Do not include repository, version-control, or release operation tasks
|
||||
unless they are explicitly part of the change scope
|
||||
|
||||
- Final tasks.md must not contain unresolved template comments, empty
|
||||
table rows, or placeholder task text
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
## 1. Context Review
|
||||
|
||||
- [ ] 1.1 Read design.md and identify scope, requirements, decisions, guardrails, and open questions
|
||||
- [ ] 1.2 Review relevant artifacts and references listed in Affected Areas
|
||||
|
||||
## 2. Execution
|
||||
|
||||
- [ ] 2.1 Execute first concrete work item from design.md
|
||||
- [ ] 2.2 Execute next concrete work item from design.md
|
||||
|
||||
## 3. Validation
|
||||
|
||||
- [ ] 3.1 Run required validation from Verification Plan
|
||||
- [ ] 3.2 Perform quality checks required by the project or workflow
|
||||
- [ ] 3.3 Perform required manual review or acceptance checks from Verification Plan
|
||||
|
||||
## 4. Documentation / Communication
|
||||
|
||||
- [ ] 4.1 Update relevant documentation, runbooks, communication materials, or project references if behavior, process, interface, configuration, or usage changed
|
||||
```
|
||||
|
||||
Reference design.md for scope, requirements, decisions, execution
|
||||
direction, and verification expectations.
|
||||
|
||||
Each task should be verifiable: it must be clear when the task is done.
|
||||
requires:
|
||||
- design
|
||||
apply:
|
||||
requires:
|
||||
- design
|
||||
- tasks
|
||||
tracks: tasks.md
|
||||
instruction: |
|
||||
Read design.md first, then tasks.md.
|
||||
Also follow workflow context/configuration, such as openspec/config.yaml when available, and any relevant project or workflow documentation referenced by design.md.
|
||||
Treat design.md as the source of truth for scope, requirements, decisions, guardrails, execution direction, and verification expectations.
|
||||
Work through pending tasks in dependency order and mark complete as you go.
|
||||
Mark a task complete only after its execution and required verification are done.
|
||||
Pause if tasks conflict with design.md, if design.md has blocking open questions, or if clarification is needed.
|
||||
77
openspec/schemas/fast-drive/templates/design.md
Normal file
77
openspec/schemas/fast-drive/templates/design.md
Normal file
@@ -0,0 +1,77 @@
|
||||
## Context
|
||||
|
||||
<!-- Problem, current state, relevant references, and triggering user request -->
|
||||
|
||||
## Discussion Notes
|
||||
|
||||
<!-- Key conclusions from exploration or prior discussion that apply must preserve -->
|
||||
|
||||
- Agreed conclusions:
|
||||
- User preferences:
|
||||
- Constraints:
|
||||
- Rejected ideas:
|
||||
|
||||
## Requirements
|
||||
|
||||
<!-- Expected outcomes, behavior/process/interface/content changes, continuity expectations, and acceptance criteria -->
|
||||
|
||||
| Requirement | Acceptance Criteria |
|
||||
| ----------- | ------------------- |
|
||||
| | |
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
<!-- What this design aims to achieve -->
|
||||
|
||||
**Non-Goals:**
|
||||
<!-- What is explicitly out of scope -->
|
||||
|
||||
## Execution Guardrails
|
||||
|
||||
<!-- Must-follow constraints, forbidden approaches, preserved behavior/processes, dependency limits, and project- or workflow-specific boundaries -->
|
||||
|
||||
- Dependencies:
|
||||
- Constraints:
|
||||
- Quality Bar:
|
||||
- Stakeholders:
|
||||
- Documentation / Communication:
|
||||
- Compatibility / Continuity:
|
||||
|
||||
## Affected Areas
|
||||
|
||||
<!-- Concrete artifacts, references, stakeholders, systems, workstreams, documents, configurations, assets, or handoffs relevant to this change -->
|
||||
|
||||
| Area | Artifacts / References | Expected Change | Notes |
|
||||
| ---- | ---------------------- | --------------- | ----- |
|
||||
| <!-- Area --> | <!-- Artifacts / References --> | <!-- Expected Change --> | <!-- Notes --> |
|
||||
|
||||
## Decisions
|
||||
|
||||
<!-- Key decisions, rationale, and alternatives considered -->
|
||||
|
||||
| Decision | Rationale | Alternatives Rejected |
|
||||
| -------- | --------- | --------------------- |
|
||||
| | | |
|
||||
|
||||
## Execution Plan
|
||||
|
||||
<!-- Main workstreams or artifacts to change, integration or handoff points, sequencing, and rollout notes -->
|
||||
|
||||
## Verification Plan
|
||||
|
||||
<!-- Validation checks, reviews, approvals, acceptance checks, documentation checks, communication checks, and manual checks needed -->
|
||||
|
||||
| Requirement / Risk | Verification |
|
||||
| ------------------ | ------------ |
|
||||
| | |
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
<!-- Format: [Risk] -> Mitigation -->
|
||||
|
||||
## Open Questions
|
||||
|
||||
| Status | Question | Decision Needed |
|
||||
| ------ | -------- | --------------- |
|
||||
| None | No open questions. | None |
|
||||
19
openspec/schemas/fast-drive/templates/tasks.md
Normal file
19
openspec/schemas/fast-drive/templates/tasks.md
Normal file
@@ -0,0 +1,19 @@
|
||||
## 1. Context Review
|
||||
|
||||
- [ ] 1.1 Read design.md and identify scope, requirements, decisions, guardrails, and open questions
|
||||
- [ ] 1.2 Review relevant artifacts and references listed in Affected Areas
|
||||
|
||||
## 2. Execution
|
||||
|
||||
- [ ] 2.1 Execute first concrete work item from design.md
|
||||
- [ ] 2.2 Execute next concrete work item from design.md
|
||||
|
||||
## 3. Validation
|
||||
|
||||
- [ ] 3.1 Run required validation from Verification Plan
|
||||
- [ ] 3.2 Perform quality checks required by the project or workflow
|
||||
- [ ] 3.3 Perform required manual review or acceptance checks from Verification Plan
|
||||
|
||||
## 4. Documentation / Communication
|
||||
|
||||
- [ ] 4.1 Update relevant documentation, runbooks, communication materials, or project references if behavior, process, interface, configuration, or usage changed
|
||||
68
package.json
Normal file
68
package.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "my-app",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "bun run scripts/dev.ts",
|
||||
"dev:server": "bun --watch src/server/dev.ts",
|
||||
"dev:web": "bunx --bun vite --host",
|
||||
"build": "bun run scripts/build.ts",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier . --write",
|
||||
"check": "bun run schema:check && bun run typecheck && bun run lint && bun test",
|
||||
"schema": "bun run scripts/generate-config-schema.ts",
|
||||
"schema:check": "bun run scripts/generate-config-schema.ts -- --check",
|
||||
"verify": "bun run check && bun run build",
|
||||
"test": "bun test",
|
||||
"clean": "bun run scripts/clean.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepare": "husky",
|
||||
"version:patch": "bun run scripts/bump-version.ts patch",
|
||||
"version:minor": "bun run scripts/bump-version.ts minor",
|
||||
"version:major": "bun run scripts/bump-version.ts major",
|
||||
"version:set": "bun run scripts/bump-version.ts set"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^21.0.1",
|
||||
"@commitlint/config-conventional": "^21.0.1",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tanstack/react-query-devtools": "^5.100.10",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/jsdom": "^28.0.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-perfectionist": "^5.9.0",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^29.1.1",
|
||||
"lint-staged": "^17.0.4",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.3",
|
||||
"vite": "^8.0.13"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.34.49",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"ajv": "^8.20.0",
|
||||
"es-toolkit": "^1.46.1",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"pino-roll": "^4.0.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router": "^7.15.1",
|
||||
"recharts": "^3.8.1",
|
||||
"tdesign-icons-react": "^0.6.4",
|
||||
"tdesign-react": "^1.16.9"
|
||||
}
|
||||
}
|
||||
170
scripts/build.ts
Normal file
170
scripts/build.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join, relative, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { APP } from "../src/shared/app";
|
||||
import { validateVersion } from "./bump-version-logic";
|
||||
|
||||
const projectRoot = fileURLToPath(new URL("..", import.meta.url));
|
||||
const distWebDir = join(projectRoot, "dist/web");
|
||||
const buildDir = join(projectRoot, ".build");
|
||||
const executablePath = join(projectRoot, `dist/${APP.name}`);
|
||||
const packageJsonPath = join(projectRoot, "package.json");
|
||||
|
||||
async function build() {
|
||||
try {
|
||||
await viteBuild();
|
||||
await codeGeneration();
|
||||
await bunCompile();
|
||||
await cleanup();
|
||||
console.log(`Built executable: ${executablePath}`);
|
||||
} catch (error) {
|
||||
await cleanup();
|
||||
console.error("Build failed:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function bunCompile() {
|
||||
console.log("Step 3/3: Bun compile...");
|
||||
await rm(executablePath, { force: true });
|
||||
|
||||
const target = process.env["BUN_TARGET"] ?? process.env["BUILD_TARGET"];
|
||||
const result = await Bun.build({
|
||||
compile: target
|
||||
? {
|
||||
autoloadBunfig: true,
|
||||
autoloadDotenv: true,
|
||||
outfile: executablePath,
|
||||
target: target as Bun.Build.CompileTarget,
|
||||
}
|
||||
: {
|
||||
autoloadBunfig: true,
|
||||
autoloadDotenv: true,
|
||||
outfile: executablePath,
|
||||
},
|
||||
entrypoints: [join(buildDir, "server-entry.ts")],
|
||||
minify: true,
|
||||
sourcemap: "linked",
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error("Bun compile failed:", result.logs);
|
||||
await cleanup();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
await rm(buildDir, { force: true, recursive: true });
|
||||
}
|
||||
|
||||
async function codeGeneration() {
|
||||
console.log("Step 2/3: Code generation...");
|
||||
await rm(buildDir, { force: true, recursive: true });
|
||||
await Bun.write(join(buildDir, ".gitkeep"), "");
|
||||
|
||||
const packageJson = (await Bun.file(packageJsonPath).json()) as { version: string };
|
||||
const version = packageJson.version;
|
||||
if (typeof version !== "string") {
|
||||
console.error("package.json does not have a valid version field");
|
||||
process.exit(1);
|
||||
}
|
||||
validateVersion(version);
|
||||
|
||||
const allFiles = await scanDir(distWebDir, "/");
|
||||
const importLines: string[] = [];
|
||||
const fileEntries: string[] = [];
|
||||
let indexHtmlVar = "";
|
||||
|
||||
for (let i = 0; i < allFiles.length; i++) {
|
||||
const urlPath = allFiles[i]!;
|
||||
const varName = `f${i}`;
|
||||
const filePath = toImportSpecifier(buildDir, join(distWebDir, urlPath.slice(1)));
|
||||
importLines.push(`import ${varName} from "./${filePath}" with { type: "file" };`);
|
||||
|
||||
if (urlPath === "/index.html") {
|
||||
indexHtmlVar = varName;
|
||||
} else {
|
||||
fileEntries.push(` "${urlPath}": Bun.file(${varName}),`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!indexHtmlVar) {
|
||||
console.error("index.html not found in dist/web/");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const staticAssetsTs = [
|
||||
`import type { StaticAssets } from "../src/server/static";`,
|
||||
"",
|
||||
...importLines,
|
||||
"",
|
||||
`export const staticAssets: StaticAssets = {`,
|
||||
` files: {`,
|
||||
...fileEntries,
|
||||
` },`,
|
||||
` indexHtml: Bun.file(${indexHtmlVar}),`,
|
||||
`};`,
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
await writeFile(join(buildDir, "static-assets.ts"), staticAssetsTs);
|
||||
|
||||
const serverEntryTs = [
|
||||
`import { bootstrap } from "../src/server/bootstrap";`,
|
||||
`import { parseRuntimeArgs } from "../src/server/config";`,
|
||||
`import { createConsoleFallback } from "../src/server/logger";`,
|
||||
`import { staticAssets } from "./static-assets";`,
|
||||
"",
|
||||
`const APP_VERSION = "${version}" as const;`,
|
||||
"",
|
||||
`async function main() {`,
|
||||
` const { configPath } = parseRuntimeArgs();`,
|
||||
` await bootstrap({ configPath, mode: "production", staticAssets, version: APP_VERSION });`,
|
||||
`}`,
|
||||
"",
|
||||
`void main().catch((error) => {`,
|
||||
` createConsoleFallback().fatal(\`启动失败: \${error instanceof Error ? error.message : String(error)}\`);`,
|
||||
` process.exit(1);`,
|
||||
`});`,
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
await writeFile(join(buildDir, "server-entry.ts"), serverEntryTs);
|
||||
}
|
||||
|
||||
async function scanDir(dir: string, prefix: string): Promise<string[]> {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const paths: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
const urlPath = `${prefix}${entry.name}`;
|
||||
if (entry.isDirectory()) {
|
||||
paths.push(...(await scanDir(fullPath, `${urlPath}/`)));
|
||||
} else {
|
||||
paths.push(urlPath);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function toImportSpecifier(fromDir: string, targetPath: string) {
|
||||
return relative(fromDir, targetPath).split(sep).join("/");
|
||||
}
|
||||
|
||||
async function viteBuild() {
|
||||
console.log("Step 1/3: Vite build...");
|
||||
const proc = Bun.spawn(["bunx", "--bun", "vite", "build"], {
|
||||
cwd: projectRoot,
|
||||
stderr: "inherit",
|
||||
stdout: "inherit",
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
if (exitCode !== 0) {
|
||||
console.error("Vite build failed");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
await build();
|
||||
40
scripts/bump-version-logic.ts
Normal file
40
scripts/bump-version-logic.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
const VERSION_REGEX = /^\d+\.\d+\.\d+$/;
|
||||
|
||||
export function bumpVersion(current: string, command: "major" | "minor" | "patch" | "set", target?: string): string {
|
||||
validateVersion(current);
|
||||
const [major, minor, patch] = parseVersion(current);
|
||||
|
||||
switch (command) {
|
||||
case "major":
|
||||
return formatVersion(major + 1, 0, 0);
|
||||
case "minor":
|
||||
return formatVersion(major, minor + 1, 0);
|
||||
case "patch":
|
||||
return formatVersion(major, minor, patch + 1);
|
||||
case "set": {
|
||||
if (!target) {
|
||||
throw new Error("set command requires a target version");
|
||||
}
|
||||
validateVersion(target);
|
||||
return target;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function formatVersion(major: number, minor: number, patch: number): string {
|
||||
return `${major}.${minor}.${patch}`;
|
||||
}
|
||||
|
||||
export function parseVersion(version: string): [number, number, number] {
|
||||
const parts = version.split(".").map((p) => parseInt(p, 10));
|
||||
if (parts.length !== 3 || parts.some(isNaN)) {
|
||||
throw new Error(`Invalid version format: ${version}`);
|
||||
}
|
||||
return [parts[0]!, parts[1]!, parts[2]!];
|
||||
}
|
||||
|
||||
export function validateVersion(version: string): void {
|
||||
if (!VERSION_REGEX.test(version)) {
|
||||
throw new Error(`Invalid version format: ${version}. Expected MAJOR.MINOR.PATCH (e.g., 0.1.0)`);
|
||||
}
|
||||
}
|
||||
45
scripts/bump-version.ts
Normal file
45
scripts/bump-version.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { bumpVersion, validateVersion } from "./bump-version-logic";
|
||||
|
||||
const PACKAGE_JSON_PATH = resolve(import.meta.dir, "..", "package.json");
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length === 0) {
|
||||
console.error("Usage: bun run bump-version.ts <patch|minor|major|set> [version]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const command = args[0];
|
||||
if (command !== "patch" && command !== "minor" && command !== "major" && command !== "set") {
|
||||
console.error(`Unknown command: ${command}. Expected patch, minor, major, or set`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (command === "set" && args.length < 2) {
|
||||
console.error("Usage: bun run bump-version.ts set <version>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const packageJson = (await Bun.file(PACKAGE_JSON_PATH).json()) as { version: string };
|
||||
const currentVersion = packageJson.version;
|
||||
|
||||
if (typeof currentVersion !== "string") {
|
||||
console.error("package.json does not have a valid version field");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
validateVersion(currentVersion);
|
||||
|
||||
const targetVersion = command === "set" ? args[1] : undefined;
|
||||
const nextVersion = bumpVersion(currentVersion, command, targetVersion);
|
||||
|
||||
packageJson.version = nextVersion;
|
||||
writeFileSync(PACKAGE_JSON_PATH, JSON.stringify(packageJson, null, 2) + "\n");
|
||||
|
||||
console.log(`${currentVersion} -> ${nextVersion}`);
|
||||
}
|
||||
|
||||
void main();
|
||||
29
scripts/clean.ts
Normal file
29
scripts/clean.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { rm } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const root = resolve(import.meta.dir, "..");
|
||||
|
||||
const dirs: Array<{ desc: string; path: string }> = [
|
||||
{ desc: "构建产物", path: "dist" },
|
||||
{ desc: "Bun 构建缓存", path: ".build" },
|
||||
{ desc: "Playwright 测试报告", path: "playwright-report" },
|
||||
{ desc: "测试结果", path: "test-results" },
|
||||
];
|
||||
|
||||
const filePatterns: Array<{ desc: string; glob: string }> = [{ desc: "Bun 构建临时文件", glob: ".*.bun-build" }];
|
||||
|
||||
for (const { desc, path } of dirs) {
|
||||
const full = resolve(root, path);
|
||||
await rm(full, { force: true, recursive: true });
|
||||
console.log(`已清理 ${desc}: ${path}`);
|
||||
}
|
||||
|
||||
for (const { desc, glob } of filePatterns) {
|
||||
const entries = await Array.fromAsync(new Bun.Glob(glob).scan({ cwd: root, dot: true }));
|
||||
if (entries.length === 0) continue;
|
||||
for (const entry of entries) {
|
||||
const full = resolve(root, entry);
|
||||
await rm(full, { force: true, recursive: true });
|
||||
console.log(`已清理 ${desc}: ${entry}`);
|
||||
}
|
||||
}
|
||||
26
scripts/dev.ts
Normal file
26
scripts/dev.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const projectRoot = fileURLToPath(new URL("..", import.meta.url));
|
||||
|
||||
const apiServer = Bun.spawn(["bun", "--watch", "src/server/dev.ts", ...process.argv.slice(2)], {
|
||||
cwd: projectRoot,
|
||||
stderr: "inherit",
|
||||
stdout: "inherit",
|
||||
});
|
||||
|
||||
const viteServer = Bun.spawn(["bunx", "--bun", "vite", "--host"], {
|
||||
cwd: projectRoot,
|
||||
stderr: "inherit",
|
||||
stdout: "inherit",
|
||||
});
|
||||
|
||||
function shutdown() {
|
||||
apiServer.kill();
|
||||
viteServer.kill();
|
||||
}
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
await Promise.race([apiServer.exited, viteServer.exited]);
|
||||
shutdown();
|
||||
15
scripts/generate-config-schema.ts
Normal file
15
scripts/generate-config-schema.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createConfigJsonSchema } from "../src/server/config/schema/export";
|
||||
|
||||
const schemaPath = "config.schema.json";
|
||||
const schema = `${JSON.stringify(createConfigJsonSchema(), null, 2)}\n`;
|
||||
|
||||
if (process.argv.includes("--check")) {
|
||||
const existing = await Bun.file(schemaPath)
|
||||
.text()
|
||||
.catch(() => null);
|
||||
if (existing !== schema) {
|
||||
throw new Error(`${schemaPath} 未同步,请运行 bun run schema`);
|
||||
}
|
||||
} else {
|
||||
await Bun.write(schemaPath, schema);
|
||||
}
|
||||
11
src/pino-roll.d.ts
vendored
Normal file
11
src/pino-roll.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
declare module "pino-roll" {
|
||||
interface RollingStreamOptions {
|
||||
file: string;
|
||||
frequency?: string;
|
||||
limit?: { count?: number };
|
||||
mkdir?: boolean;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
export default function build(options: RollingStreamOptions): Promise<NodeJS.WritableStream>;
|
||||
}
|
||||
86
src/server/bootstrap.ts
Normal file
86
src/server/bootstrap.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
import type { RuntimeMode } from "../shared/api";
|
||||
import type { ResolvedConfig, ResolvedLoggingConfig } from "./config/types";
|
||||
import type { Logger } from "./logger";
|
||||
import type { StartServerOptions } from "./server";
|
||||
|
||||
import { loadServerConfig } from "./config";
|
||||
import { createConsoleFallback, createRuntimeLogger } from "./logger";
|
||||
import { startServer } from "./server";
|
||||
|
||||
export interface BootstrapDependencies {
|
||||
createLogger?: (config: ResolvedLoggingConfig, mode: string, version?: string) => Promise<Logger>;
|
||||
exit?: (code: number) => never;
|
||||
loadConfig?: (configPath: string) => Promise<ResolvedConfig>;
|
||||
onSignal?: (signal: "SIGINT" | "SIGTERM", handler: () => void) => void;
|
||||
startServer?: (options: StartServerOptions) => unknown;
|
||||
}
|
||||
|
||||
export interface BootstrapOptions {
|
||||
configPath: string;
|
||||
mode: RuntimeMode;
|
||||
staticAssets?: StartServerOptions["staticAssets"];
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export async function bootstrap(options: BootstrapOptions, dependencies: BootstrapDependencies = {}): Promise<void> {
|
||||
const load = dependencies.loadConfig ?? loadServerConfig;
|
||||
const buildLogger = dependencies.createLogger ?? createRuntimeLogger;
|
||||
const serve = dependencies.startServer ?? startServer;
|
||||
const onSignal =
|
||||
dependencies.onSignal ??
|
||||
((signal: "SIGINT" | "SIGTERM", handler: () => void) => {
|
||||
process.on(signal, handler);
|
||||
});
|
||||
const exit = dependencies.exit ?? ((code: number) => process.exit(code));
|
||||
|
||||
const createFallback = (): Logger => createConsoleFallback();
|
||||
|
||||
let logger: Logger | undefined;
|
||||
|
||||
try {
|
||||
const config = await load(options.configPath);
|
||||
|
||||
try {
|
||||
logger = await buildLogger(config.logging, options.mode, options.version);
|
||||
} catch (logInitError) {
|
||||
createFallback().fatal(
|
||||
`日志初始化失败: ${logInitError instanceof Error ? logInitError.message : String(logInitError)}`,
|
||||
);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
logger!.info(
|
||||
{ configDir: config.configDir, configPath: options.configPath, mode: options.mode, version: options.version },
|
||||
"配置加载成功",
|
||||
);
|
||||
|
||||
mkdirSync(config.dataDir, { recursive: true });
|
||||
logger!.info({ dataDir: config.dataDir }, "数据目录就绪");
|
||||
|
||||
const shutdown = () => {
|
||||
logger?.info("收到退出信号,开始优雅关闭");
|
||||
logger?.flush();
|
||||
exit(0);
|
||||
};
|
||||
onSignal("SIGINT", shutdown);
|
||||
onSignal("SIGTERM", shutdown);
|
||||
|
||||
serve({
|
||||
config: { host: config.host, port: config.port },
|
||||
logger: logger!.child({ component: "server" }),
|
||||
mode: options.mode,
|
||||
staticAssets: options.staticAssets,
|
||||
version: options.version,
|
||||
});
|
||||
} catch (error) {
|
||||
if (logger) {
|
||||
logger.fatal({ error: error instanceof Error ? error.message : String(error) }, "启动失败");
|
||||
logger.flush();
|
||||
} else {
|
||||
createFallback().fatal(`启动失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
241
src/server/config.ts
Normal file
241
src/server/config.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { isNumber, isString } from "es-toolkit";
|
||||
import { dirname, isAbsolute, resolve } from "node:path";
|
||||
|
||||
import type { ConfigValidationIssue } from "./config/issues";
|
||||
import type { LoggingConfig, LogLevel, ResolvedConfig, ResolvedLoggingConfig, RotationFrequency } from "./config/types";
|
||||
|
||||
import { APP } from "../shared/app";
|
||||
import { dedupeIssues, issue, throwConfigIssues } from "./config/issues";
|
||||
import { normalizeAuthoringConfig } from "./config/normalizer";
|
||||
import { validateConfigContract } from "./config/schema/validate";
|
||||
|
||||
const DEFAULT_HOST = "127.0.0.1";
|
||||
const DEFAULT_PORT = 3000;
|
||||
const DEFAULT_DATA_DIR = "./data";
|
||||
const DEFAULT_LOG_LEVEL: LogLevel = "info";
|
||||
const DEFAULT_ROTATION_SIZE = "50MB";
|
||||
const DEFAULT_ROTATION_FREQUENCY: RotationFrequency = "daily";
|
||||
const DEFAULT_ROTATION_MAX_FILES = 14;
|
||||
|
||||
const VALID_LOG_LEVELS: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"];
|
||||
const VALID_ROTATION_FREQUENCIES: RotationFrequency[] = ["hourly", "daily", "weekly"];
|
||||
|
||||
const SIZE_REGEX = /^(\d+(?:\.\d+)?)(B|KB|MB|GB)$/;
|
||||
|
||||
export async function loadServerConfig(configPath: string): Promise<ResolvedConfig> {
|
||||
const file = Bun.file(configPath);
|
||||
if (!(await file.exists())) {
|
||||
throw new Error(`配置文件不存在: ${configPath}`);
|
||||
}
|
||||
|
||||
const content = await file.text();
|
||||
const parsed = Bun.YAML.parse(content);
|
||||
|
||||
const normalizeResult = normalizeAuthoringConfig(parsed);
|
||||
if (normalizeResult.issues.length > 0) {
|
||||
throwConfigIssues(dedupeIssues(normalizeResult.issues));
|
||||
}
|
||||
|
||||
const normalizedConfig = normalizeResult.config;
|
||||
const contractResult = validateConfigContract(normalizedConfig);
|
||||
if (contractResult.config === null) {
|
||||
throwConfigIssues(dedupeIssues(contractResult.issues));
|
||||
}
|
||||
|
||||
const allIssues: ConfigValidationIssue[] = [...contractResult.issues];
|
||||
const runtimeIssues = validateRuntimeConfig(contractResult.config);
|
||||
allIssues.push(...runtimeIssues);
|
||||
|
||||
const configDir = dirname(resolve(configPath));
|
||||
|
||||
const configRecord = contractResult.config as Record<string, unknown>;
|
||||
const server = configRecord["server"] as Record<string, unknown> | undefined;
|
||||
const listen = server?.["listen"] as Record<string, unknown> | undefined;
|
||||
const storage = server?.["storage"] as Record<string, unknown> | undefined;
|
||||
|
||||
const host = (listen?.["host"] as string | undefined) ?? DEFAULT_HOST;
|
||||
const port = (listen?.["port"] as number | undefined) ?? DEFAULT_PORT;
|
||||
const dataDir = resolveDataDir(storage, configDir);
|
||||
|
||||
const rawLogging = server?.["logging"] as LoggingConfig | undefined;
|
||||
const logging = resolveLogging(rawLogging ?? {}, dataDir, configDir);
|
||||
validateLoggingConfig(rawLogging, allIssues);
|
||||
|
||||
if (allIssues.length > 0) {
|
||||
throwConfigIssues(dedupeIssues(allIssues));
|
||||
}
|
||||
|
||||
return { configDir, dataDir, host, logging, port };
|
||||
}
|
||||
|
||||
export function parseRuntimeArgs(argv: string[] = Bun.argv.slice(2)): { configPath: string } {
|
||||
if (argv.length === 0) {
|
||||
throw new Error(`需要指定 YAML 配置文件路径\n用法: ${APP.name} <config.yaml>`);
|
||||
}
|
||||
const firstArg = argv[0];
|
||||
if (firstArg === "--help" || firstArg === "-h") {
|
||||
throw new Error(`用法: ${APP.name} <config.yaml>`);
|
||||
}
|
||||
return { configPath: firstArg! };
|
||||
}
|
||||
|
||||
export function parseSize(value: number | string): number {
|
||||
if (isNumber(value)) {
|
||||
if (!Number.isInteger(value) || value < 0 || !Number.isSafeInteger(value)) {
|
||||
throw new Error(`无效的 size 数值: ${value},必须为非负安全整数`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const match = SIZE_REGEX.exec(value);
|
||||
if (!match) {
|
||||
throw new Error(`无效的 size 格式: "${value}",支持格式如 "100MB"、"512KB"、"1GB"、"1024B"`);
|
||||
}
|
||||
|
||||
const num = parseFloat(match[1]!);
|
||||
const unit = match[2]!;
|
||||
|
||||
const bytes =
|
||||
unit === "B" ? num : unit === "KB" ? num * 1024 : unit === "MB" ? num * 1024 * 1024 : num * 1024 * 1024 * 1024;
|
||||
if (!Number.isInteger(bytes) || bytes < 0 || !Number.isSafeInteger(bytes)) {
|
||||
throw new Error(`无效的 size 数值: ${value},必须解析为非负安全整数字节数`);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function resolveDataDir(storage: Record<string, unknown> | undefined, configDir: string): string {
|
||||
const raw = storage?.["dataDir"];
|
||||
if (isString(raw) && raw.trim() !== "") {
|
||||
return isAbsolute(raw) ? resolve(raw) : resolve(configDir, raw);
|
||||
}
|
||||
return resolve(configDir, DEFAULT_DATA_DIR);
|
||||
}
|
||||
|
||||
function resolveLogging(logging: LoggingConfig, dataDir: string, configDir: string): ResolvedLoggingConfig {
|
||||
const globalLevel = resolveLogLevel(logging.level, DEFAULT_LOG_LEVEL);
|
||||
const consoleLevel = resolveLogLevel(logging.console?.level, globalLevel);
|
||||
const fileLevel = resolveLogLevel(logging.file?.level, globalLevel);
|
||||
|
||||
const rawPath = logging.file?.path;
|
||||
const filePath = rawPath
|
||||
? isAbsolute(rawPath)
|
||||
? resolve(rawPath)
|
||||
: resolve(configDir, rawPath)
|
||||
: resolve(dataDir, "logs", `${APP.name}.log`);
|
||||
|
||||
const rotationRaw = logging.file?.rotation;
|
||||
const rotationSizeRaw = rotationRaw?.size ?? DEFAULT_ROTATION_SIZE;
|
||||
const rotationSizeBytes = parseSize(rotationSizeRaw);
|
||||
const rotationFrequency = rotationRaw?.frequency ?? DEFAULT_ROTATION_FREQUENCY;
|
||||
const rotationMaxFiles = rotationRaw?.maxFiles ?? DEFAULT_ROTATION_MAX_FILES;
|
||||
|
||||
return {
|
||||
consoleLevel,
|
||||
fileLevel,
|
||||
filePath,
|
||||
rotationFrequency,
|
||||
rotationMaxFiles,
|
||||
rotationSizeBytes,
|
||||
rotationSizeRaw,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveLogLevel(level: unknown, fallback: LogLevel): LogLevel {
|
||||
if (!isString(level)) return fallback;
|
||||
if (VALID_LOG_LEVELS.includes(level as LogLevel)) return level as LogLevel;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function validateLoggingConfig(logging: LoggingConfig | undefined, issues: ConfigValidationIssue[]): void {
|
||||
if (logging === undefined) return;
|
||||
|
||||
if (logging.level !== undefined && !VALID_LOG_LEVELS.includes(logging.level)) {
|
||||
issues.push(
|
||||
issue(
|
||||
"invalid-value",
|
||||
"server.logging.level",
|
||||
`日志等级非法: "${logging.level}",支持: ${VALID_LOG_LEVELS.join(", ")}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (logging.console?.level !== undefined && !VALID_LOG_LEVELS.includes(logging.console.level)) {
|
||||
issues.push(
|
||||
issue(
|
||||
"invalid-value",
|
||||
"server.logging.console.level",
|
||||
`日志等级非法: "${logging.console.level}",支持: ${VALID_LOG_LEVELS.join(", ")}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (logging.file?.level !== undefined && !VALID_LOG_LEVELS.includes(logging.file.level)) {
|
||||
issues.push(
|
||||
issue(
|
||||
"invalid-value",
|
||||
"server.logging.file.level",
|
||||
`日志等级非法: "${logging.file.level}",支持: ${VALID_LOG_LEVELS.join(", ")}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (logging.file?.path !== undefined) {
|
||||
if (!isString(logging.file.path) || logging.file.path.trim() === "") {
|
||||
issues.push(issue("invalid-value", "server.logging.file.path", "日志路径不能为空字符串或空白字符串"));
|
||||
}
|
||||
}
|
||||
|
||||
const rotation = logging.file?.rotation;
|
||||
if (rotation?.size !== undefined) {
|
||||
try {
|
||||
const bytes = parseSize(rotation.size);
|
||||
if (bytes <= 0) {
|
||||
issues.push(issue("invalid-value", "server.logging.file.rotation.size", "滚动大小必须为正整数字节数"));
|
||||
}
|
||||
} catch (error) {
|
||||
issues.push(
|
||||
issue(
|
||||
"invalid-value",
|
||||
"server.logging.file.rotation.size",
|
||||
error instanceof Error ? error.message : "size 格式非法",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (rotation?.frequency !== undefined && !VALID_ROTATION_FREQUENCIES.includes(rotation.frequency)) {
|
||||
issues.push(
|
||||
issue(
|
||||
"invalid-value",
|
||||
"server.logging.file.rotation.frequency",
|
||||
`滚动频率非法: "${rotation.frequency}",支持: ${VALID_ROTATION_FREQUENCIES.join(", ")}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (rotation?.maxFiles !== undefined) {
|
||||
if (!isNumber(rotation.maxFiles) || !Number.isInteger(rotation.maxFiles) || rotation.maxFiles <= 0) {
|
||||
issues.push(issue("invalid-value", "server.logging.file.rotation.maxFiles", "maxFiles 必须为正整数"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateRuntimeConfig(config: object): ConfigValidationIssue[] {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const configRecord = config as Record<string, unknown>;
|
||||
const server = configRecord["server"] as Record<string, unknown> | undefined;
|
||||
const listen = server?.["listen"] as Record<string, unknown> | undefined;
|
||||
|
||||
if (listen !== undefined) {
|
||||
const portValue = listen["port"];
|
||||
if (isString(portValue)) {
|
||||
issues.push(
|
||||
issue("invalid-type", "server.listen.port", "端口必须为整数,不能为字符串(如需使用变量请使用 ${VAR} 语法)"),
|
||||
);
|
||||
} else if (isNumber(portValue) && (!Number.isInteger(portValue) || portValue < 0 || portValue > 65535)) {
|
||||
issues.push(issue("invalid-range", "server.listen.port", "端口必须为 0-65535 之间的整数"));
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
27
src/server/config/index.ts
Normal file
27
src/server/config/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export { issue, joinPath, renderPath, throwConfigIssues } from "./issues";
|
||||
export { normalizeAuthoringConfig } from "./normalizer";
|
||||
export {
|
||||
createAuthoringConfigSchema,
|
||||
createExternalConfigSchema,
|
||||
createNormalizedConfigSchema,
|
||||
} from "./schema/builder";
|
||||
export { createConfigJsonSchema } from "./schema/export";
|
||||
export { createConfigAjv, issuesFromAjvErrors, validateConfigContract } from "./schema/validate";
|
||||
export type {
|
||||
AuthoringConfig,
|
||||
AuthoringLoggingConfig,
|
||||
AuthoringLoggingFileConfig,
|
||||
AuthoringLoggingFileRotationConfig,
|
||||
AuthoringServer,
|
||||
ConfigVariableValue,
|
||||
LoggingConfig,
|
||||
LogLevel,
|
||||
NormalizedConfig,
|
||||
NormalizedLoggingConfig,
|
||||
NormalizedServer,
|
||||
ResolvedConfig,
|
||||
ResolvedLoggingConfig,
|
||||
RotationFrequency,
|
||||
ValidatedConfig,
|
||||
} from "./types";
|
||||
export { extractVariables, resolveVariables } from "./variables";
|
||||
43
src/server/config/issues.ts
Normal file
43
src/server/config/issues.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export interface ConfigValidationIssue {
|
||||
code: string;
|
||||
message: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function dedupeIssues(issues: ConfigValidationIssue[]): ConfigValidationIssue[] {
|
||||
const seen = new Set<string>();
|
||||
const result: ConfigValidationIssue[] = [];
|
||||
for (const item of issues) {
|
||||
const key = `${item.code}:${item.path}:${item.message}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function formatConfigIssues(issues: ConfigValidationIssue[]): string {
|
||||
return issues.map(formatConfigIssue).join("\n");
|
||||
}
|
||||
|
||||
export function issue(code: string, path: string, message: string): ConfigValidationIssue {
|
||||
return { code, message, path };
|
||||
}
|
||||
|
||||
export function joinPath(base: string, key: string): string {
|
||||
if (base === "") return key;
|
||||
if (key.startsWith("[")) return `${base}${key}`;
|
||||
return `${base}.${key}`;
|
||||
}
|
||||
|
||||
export function renderPath(path: string): string {
|
||||
return path === "" ? "配置文件" : path;
|
||||
}
|
||||
|
||||
export function throwConfigIssues(issues: ConfigValidationIssue[]): never {
|
||||
throw new Error(formatConfigIssues(issues));
|
||||
}
|
||||
|
||||
function formatConfigIssue(i: ConfigValidationIssue): string {
|
||||
return `${renderPath(i.path)} ${i.message}`;
|
||||
}
|
||||
18
src/server/config/normalizer.ts
Normal file
18
src/server/config/normalizer.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { isPlainObject } from "es-toolkit";
|
||||
|
||||
import type { ConfigValidationIssue } from "./issues";
|
||||
|
||||
import { resolveVariables } from "./variables";
|
||||
|
||||
export function normalizeAuthoringConfig(config: unknown): {
|
||||
config: unknown;
|
||||
issues: ConfigValidationIssue[];
|
||||
} {
|
||||
const variableResult = resolveVariables(config);
|
||||
if (!isPlainObject(variableResult.config)) {
|
||||
return variableResult;
|
||||
}
|
||||
|
||||
const normalized = { ...(variableResult.config as Record<string, unknown>) };
|
||||
return { config: normalized, issues: variableResult.issues };
|
||||
}
|
||||
120
src/server/config/schema/builder.ts
Normal file
120
src/server/config/schema/builder.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { TSchema } from "@sinclair/typebox";
|
||||
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import { variableValueSchema } from "./fragments";
|
||||
|
||||
type SchemaKind = "authoring" | "normalized";
|
||||
|
||||
const LOG_LEVELS = ["trace", "debug", "info", "warn", "error", "fatal"] as const;
|
||||
const ROTATION_FREQUENCIES = ["hourly", "daily", "weekly"] as const;
|
||||
|
||||
const sizeSchema = Type.Union([Type.String(), Type.Integer({ minimum: 0 })]);
|
||||
|
||||
export function createAuthoringConfigSchema(): TSchema {
|
||||
return createConfigSchemaForKind("authoring");
|
||||
}
|
||||
|
||||
export function createExternalConfigSchema(): Record<string, unknown> {
|
||||
return {
|
||||
...cloneSchema(createAuthoringConfigSchema()),
|
||||
$id: "https://app.local/config.schema.json",
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
};
|
||||
}
|
||||
|
||||
export function createNormalizedConfigSchema(): TSchema {
|
||||
return createConfigSchemaForKind("normalized");
|
||||
}
|
||||
|
||||
function cloneSchema(schema: TSchema): Record<string, unknown> {
|
||||
return JSON.parse(JSON.stringify(schema)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function createAuthoringFieldSchema(schema: TSchema): TSchema {
|
||||
return Type.Unsafe({ anyOf: [schema, { pattern: "^\\$\\{[^}]+\\}$", type: "string" }] });
|
||||
}
|
||||
|
||||
function createConfigSchemaForKind(kind: SchemaKind): TSchema {
|
||||
const properties: Record<string, TSchema> = {
|
||||
server: Type.Optional(createServerSchema(kind)),
|
||||
};
|
||||
if (kind === "authoring") {
|
||||
properties["variables"] = Type.Optional(
|
||||
Type.Record(Type.String({ pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$" }), variableValueSchema),
|
||||
);
|
||||
}
|
||||
return Type.Object(properties, { additionalProperties: false });
|
||||
}
|
||||
|
||||
function createLoggingSchema(kind: SchemaKind): TSchema {
|
||||
const logLevelSchema = Type.Union(LOG_LEVELS.map((l) => Type.Literal(l)) as unknown as [TSchema, ...TSchema[]]);
|
||||
const logLevel = kind === "authoring" ? createAuthoringFieldSchema(logLevelSchema) : logLevelSchema;
|
||||
const frequency =
|
||||
kind === "authoring"
|
||||
? createAuthoringFieldSchema(
|
||||
Type.Union(ROTATION_FREQUENCIES.map((f) => Type.Literal(f)) as unknown as [TSchema, ...TSchema[]]),
|
||||
)
|
||||
: Type.Union(ROTATION_FREQUENCIES.map((f) => Type.Literal(f)) as unknown as [TSchema, ...TSchema[]]);
|
||||
const rotationSize = kind === "authoring" ? createAuthoringFieldSchema(sizeSchema) : sizeSchema;
|
||||
const rotationMaxFiles =
|
||||
kind === "authoring" ? createAuthoringFieldSchema(Type.Integer({ minimum: 1 })) : Type.Integer({ minimum: 1 });
|
||||
|
||||
return Type.Object(
|
||||
{
|
||||
console: Type.Optional(Type.Object({ level: Type.Optional(logLevel) }, { additionalProperties: false })),
|
||||
file: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
level: Type.Optional(logLevel),
|
||||
path: Type.Optional(Type.String({ minLength: 1 })),
|
||||
rotation: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
frequency: Type.Optional(frequency),
|
||||
maxFiles: Type.Optional(rotationMaxFiles),
|
||||
size: Type.Optional(rotationSize),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
level: Type.Optional(logLevel),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
}
|
||||
|
||||
function createServerSchema(kind: SchemaKind): TSchema {
|
||||
return Type.Object(
|
||||
{
|
||||
listen: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
host: Type.Optional(Type.String()),
|
||||
port: Type.Optional(integerForKind(kind, { maximum: 65535, minimum: 0 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
logging: Type.Optional(createLoggingSchema(kind)),
|
||||
storage: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
dataDir: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
}
|
||||
|
||||
function integerForKind(kind: SchemaKind, options?: Parameters<typeof Type.Integer>[0]): TSchema {
|
||||
const schema = Type.Integer(options);
|
||||
return kind === "authoring" ? createAuthoringFieldSchema(schema) : schema;
|
||||
}
|
||||
5
src/server/config/schema/export.ts
Normal file
5
src/server/config/schema/export.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createExternalConfigSchema } from "./builder";
|
||||
|
||||
export function createConfigJsonSchema(): Record<string, unknown> {
|
||||
return createExternalConfigSchema();
|
||||
}
|
||||
3
src/server/config/schema/fragments.ts
Normal file
3
src/server/config/schema/fragments.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
export const variableValueSchema = Type.Union([Type.String(), Type.Number(), Type.Boolean()]);
|
||||
110
src/server/config/schema/validate.ts
Normal file
110
src/server/config/schema/validate.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { ErrorObject } from "ajv";
|
||||
|
||||
import Ajv from "ajv";
|
||||
|
||||
import type { ConfigValidationIssue } from "../issues";
|
||||
|
||||
import { issue } from "../issues";
|
||||
import { createNormalizedConfigSchema } from "./builder";
|
||||
|
||||
export function createConfigAjv(): Ajv {
|
||||
return new Ajv({ allErrors: true, coerceTypes: false, removeAdditional: false, strict: true, useDefaults: false });
|
||||
}
|
||||
|
||||
export function issuesFromAjvErrors(errors: ErrorObject[], root: unknown, basePath = ""): ConfigValidationIssue[] {
|
||||
return normalizeAjvErrors(errors, basePath).map((error) => issueFromAjvError(error, root, basePath));
|
||||
}
|
||||
|
||||
export function validateConfigContract(
|
||||
config: unknown,
|
||||
): { config: null; issues: ConfigValidationIssue[] } | { config: object; issues: [] } {
|
||||
const ajv = createConfigAjv();
|
||||
const rootValidate = ajv.compile(createNormalizedConfigSchema());
|
||||
if (!rootValidate(config)) {
|
||||
const issues = issuesFromAjvErrors(rootValidate.errors ?? [], config);
|
||||
return { config: null, issues };
|
||||
}
|
||||
|
||||
return { config: config as object, issues: [] as [] };
|
||||
}
|
||||
|
||||
function buildIssuePath(basePath: string, error: ErrorObject): string {
|
||||
const pointerPath = jsonPointerToPath(error.instancePath);
|
||||
let path = basePath ? joinBasePath(basePath, pointerPath) : pointerPath;
|
||||
if (error.keyword === "required" && "missingProperty" in error.params) {
|
||||
path = joinBasePath(path, String(error.params["missingProperty"]));
|
||||
}
|
||||
if (error.keyword === "additionalProperties" && "additionalProperty" in error.params) {
|
||||
path = joinBasePath(path, String(error.params["additionalProperty"]));
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function hasMoreSpecificError(keywords: Set<string>): boolean {
|
||||
return ["const", "enum", "maximum", "minimum", "minLength", "pattern"].some((keyword) => keywords.has(keyword));
|
||||
}
|
||||
|
||||
function issueFromAjvError(error: ErrorObject, _root: unknown, basePath: string): ConfigValidationIssue {
|
||||
const path = buildIssuePath(basePath, error);
|
||||
switch (error.keyword) {
|
||||
case "additionalProperties":
|
||||
return issue("unknown-field", path, "是未知字段");
|
||||
case "const":
|
||||
case "enum":
|
||||
return issue("invalid-value", path, "不在允许范围内");
|
||||
case "maximum":
|
||||
case "minimum":
|
||||
return issue("invalid-range", path, "数值范围不合法");
|
||||
case "minLength":
|
||||
return issue("invalid-format", path, "不能为空");
|
||||
case "pattern":
|
||||
return issue("invalid-format", path, "格式不合法");
|
||||
case "required":
|
||||
return issue("required", path, "缺少必填字段");
|
||||
case "type":
|
||||
return issue("invalid-type", path, "类型不合法");
|
||||
default:
|
||||
return issue("invalid-config", path, error.message ?? "配置不合法");
|
||||
}
|
||||
}
|
||||
|
||||
function joinBasePath(basePath: string, path: string): string {
|
||||
if (basePath === "") return path;
|
||||
if (path === "") return basePath;
|
||||
if (path.startsWith("[")) return `${basePath}${path}`;
|
||||
return `${basePath}.${path}`;
|
||||
}
|
||||
|
||||
function jsonPointerToPath(pointer: string): string {
|
||||
if (pointer === "") return "";
|
||||
return pointer
|
||||
.slice(1)
|
||||
.split("/")
|
||||
.map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))
|
||||
.reduce((path, part) => (/^\d+$/.test(part) ? `${path}[${part}]` : joinBasePath(path, part)), "");
|
||||
}
|
||||
|
||||
function normalizeAjvErrors(errors: ErrorObject[], basePath: string): ErrorObject[] {
|
||||
const nonCompositeErrors = errors.filter((error) => error.keyword !== "anyOf" && error.keyword !== "oneOf");
|
||||
const candidates = nonCompositeErrors.length > 0 ? nonCompositeErrors : errors;
|
||||
const keywordsByPath = new Map<string, Set<string>>();
|
||||
|
||||
for (const error of candidates) {
|
||||
const path = buildIssuePath(basePath, error);
|
||||
const keywords = keywordsByPath.get(path) ?? new Set<string>();
|
||||
keywords.add(error.keyword);
|
||||
keywordsByPath.set(path, keywords);
|
||||
}
|
||||
|
||||
const seenValueErrors = new Set<string>();
|
||||
return candidates.filter((error) => {
|
||||
const path = buildIssuePath(basePath, error);
|
||||
const keywords = keywordsByPath.get(path) ?? new Set<string>();
|
||||
if (error.keyword === "type" && hasMoreSpecificError(keywords)) return false;
|
||||
if (error.keyword === "const" || error.keyword === "enum") {
|
||||
if (seenValueErrors.has(path)) return false;
|
||||
seenValueErrors.add(path);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
124
src/server/config/types.ts
Normal file
124
src/server/config/types.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
export interface AuthoringConfig {
|
||||
server?: AuthoringServer;
|
||||
variables?: Record<string, ConfigVariableValue>;
|
||||
}
|
||||
|
||||
export interface AuthoringLoggingConfig {
|
||||
console?: AuthoringLoggingConsoleConfig;
|
||||
file?: AuthoringLoggingFileConfig;
|
||||
level?: string;
|
||||
}
|
||||
|
||||
export interface AuthoringLoggingConsoleConfig {
|
||||
level?: string;
|
||||
}
|
||||
|
||||
export interface AuthoringLoggingFileConfig {
|
||||
level?: string;
|
||||
path?: string;
|
||||
rotation?: AuthoringLoggingFileRotationConfig;
|
||||
}
|
||||
|
||||
export interface AuthoringLoggingFileRotationConfig {
|
||||
frequency?: string;
|
||||
maxFiles?: number | string;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
export interface AuthoringServer {
|
||||
listen?: AuthoringServerListen;
|
||||
logging?: AuthoringLoggingConfig;
|
||||
storage?: AuthoringServerStorage;
|
||||
}
|
||||
|
||||
export interface AuthoringServerListen {
|
||||
host?: string;
|
||||
port?: number | string;
|
||||
}
|
||||
|
||||
export interface AuthoringServerStorage {
|
||||
dataDir?: string;
|
||||
}
|
||||
|
||||
export type ConfigVariableValue = boolean | number | string;
|
||||
|
||||
export interface LoggingConfig {
|
||||
console?: { level?: LogLevel };
|
||||
file?: {
|
||||
level?: LogLevel;
|
||||
path?: string;
|
||||
rotation?: {
|
||||
frequency?: RotationFrequency;
|
||||
maxFiles?: number;
|
||||
size?: string;
|
||||
};
|
||||
};
|
||||
level?: LogLevel;
|
||||
}
|
||||
|
||||
export type LogLevel = "debug" | "error" | "fatal" | "info" | "trace" | "warn";
|
||||
|
||||
export interface NormalizedConfig {
|
||||
server?: NormalizedServer;
|
||||
}
|
||||
|
||||
export interface NormalizedLoggingConfig {
|
||||
console?: NormalizedLoggingConsoleConfig;
|
||||
file?: NormalizedLoggingFileConfig;
|
||||
level?: LogLevel;
|
||||
}
|
||||
|
||||
export interface NormalizedLoggingConsoleConfig {
|
||||
level?: LogLevel;
|
||||
}
|
||||
|
||||
export interface NormalizedLoggingFileConfig {
|
||||
level?: LogLevel;
|
||||
path?: string;
|
||||
rotation?: NormalizedLoggingFileRotationConfig;
|
||||
}
|
||||
|
||||
export interface NormalizedLoggingFileRotationConfig {
|
||||
frequency?: RotationFrequency;
|
||||
maxFiles?: number;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
export interface NormalizedServer {
|
||||
listen?: NormalizedServerListen;
|
||||
logging?: NormalizedLoggingConfig;
|
||||
storage?: NormalizedServerStorage;
|
||||
}
|
||||
|
||||
export interface NormalizedServerListen {
|
||||
host?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
export interface NormalizedServerStorage {
|
||||
dataDir?: string;
|
||||
}
|
||||
|
||||
export interface ResolvedConfig {
|
||||
configDir: string;
|
||||
dataDir: string;
|
||||
host: string;
|
||||
logging: ResolvedLoggingConfig;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface ResolvedLoggingConfig {
|
||||
consoleLevel: LogLevel;
|
||||
fileLevel: LogLevel;
|
||||
filePath: string;
|
||||
rotationFrequency: RotationFrequency;
|
||||
rotationMaxFiles: number;
|
||||
rotationSizeBytes: number;
|
||||
rotationSizeRaw: string;
|
||||
}
|
||||
|
||||
export type RotationFrequency = "daily" | "hourly" | "weekly";
|
||||
|
||||
export interface ValidatedConfig {
|
||||
server?: NormalizedServer;
|
||||
}
|
||||
188
src/server/config/variables.ts
Normal file
188
src/server/config/variables.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { isBoolean, isNumber, isPlainObject, isString } from "es-toolkit";
|
||||
|
||||
import type { ConfigValidationIssue } from "./issues";
|
||||
import type { ConfigVariableValue } from "./types";
|
||||
|
||||
import { issue, joinPath } from "./issues";
|
||||
|
||||
const VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
const VARIABLE_REFERENCE_PATTERN = /\$\{([a-zA-Z_][a-zA-Z0-9_]*)(?:\|([^}]*))?\}/g;
|
||||
const COMPLETE_VARIABLE_REFERENCE_PATTERN = /^\$\{([a-zA-Z_][a-zA-Z0-9_]*)(?:\|([^}]*))?\}$/;
|
||||
const ESCAPED_VARIABLE_PATTERN = /\$\$\{([^}]*)\}/g;
|
||||
|
||||
interface VariableReference {
|
||||
defaultValue?: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
interface VariableResolutionContext {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function extractVariables(config: unknown): {
|
||||
issues: ConfigValidationIssue[];
|
||||
variables: Map<string, ConfigVariableValue>;
|
||||
} {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const variables = new Map<string, ConfigVariableValue>();
|
||||
|
||||
if (!isPlainObject(config)) {
|
||||
return { issues, variables };
|
||||
}
|
||||
const configRecord = config as Record<string, unknown>;
|
||||
if (configRecord["variables"] === undefined) {
|
||||
return { issues, variables };
|
||||
}
|
||||
|
||||
const rawVariables: unknown = configRecord["variables"];
|
||||
if (!isPlainObject(rawVariables)) {
|
||||
issues.push(issue("invalid-type", "variables", "必须为对象"));
|
||||
return { issues, variables };
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(rawVariables as Record<string, unknown>)) {
|
||||
const path = joinPath("variables", key);
|
||||
if (!VARIABLE_NAME_PATTERN.test(key)) {
|
||||
issues.push(issue("invalid-format", path, "变量名不符合命名规则"));
|
||||
continue;
|
||||
}
|
||||
if (!isVariableValue(value)) {
|
||||
issues.push(issue("invalid-type", path, `变量值不允许为 ${describeInvalidVariableValue(value)}`));
|
||||
continue;
|
||||
}
|
||||
variables.set(key, value);
|
||||
}
|
||||
|
||||
return { issues, variables };
|
||||
}
|
||||
|
||||
export function resolveVariables(config: unknown): { config: unknown; issues: ConfigValidationIssue[] } {
|
||||
const { issues, variables } = extractVariables(config);
|
||||
if (!isPlainObject(config)) {
|
||||
return { config, issues };
|
||||
}
|
||||
|
||||
return { config: resolveConfigValue(config, variables, issues), issues };
|
||||
}
|
||||
|
||||
function describeInvalidVariableValue(value: unknown): string {
|
||||
if (value === null) return "null";
|
||||
if (Array.isArray(value)) return "array";
|
||||
return typeof value;
|
||||
}
|
||||
|
||||
function inferStringValue(value: string): ConfigVariableValue {
|
||||
if (value === "") return value;
|
||||
const numberValue = Number(value);
|
||||
if (Number.isFinite(numberValue)) return numberValue;
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
return value;
|
||||
}
|
||||
|
||||
function isVariableValue(value: unknown): value is ConfigVariableValue {
|
||||
return isString(value) || isNumber(value) || isBoolean(value);
|
||||
}
|
||||
|
||||
function parseVariableReference(match: RegExpExecArray): VariableReference {
|
||||
return { defaultValue: match[2], key: match[1]! };
|
||||
}
|
||||
|
||||
function replaceStringValue(
|
||||
value: string,
|
||||
variables: Map<string, ConfigVariableValue>,
|
||||
issues: ConfigValidationIssue[],
|
||||
context: VariableResolutionContext,
|
||||
): ConfigVariableValue | string {
|
||||
const trimmed = value.trim();
|
||||
const completeMatch = COMPLETE_VARIABLE_REFERENCE_PATTERN.exec(trimmed);
|
||||
if (completeMatch) {
|
||||
const resolved = resolveVariableReference(parseVariableReference(completeMatch), variables, issues, context);
|
||||
return resolved ?? value;
|
||||
}
|
||||
|
||||
const escaped: string[] = [];
|
||||
const protectedValue = value.replace(ESCAPED_VARIABLE_PATTERN, (_match, body: string) => {
|
||||
const token = `\u0000${escaped.length}\u0000`;
|
||||
escaped.push(`\${${body}}`);
|
||||
return token;
|
||||
});
|
||||
|
||||
const replaced = protectedValue.replace(
|
||||
VARIABLE_REFERENCE_PATTERN,
|
||||
(match, key: string, defaultValue: string | undefined) => {
|
||||
const resolved = resolveVariableReference({ defaultValue, key }, variables, issues, context);
|
||||
return resolved === undefined ? match : String(resolved);
|
||||
},
|
||||
);
|
||||
|
||||
return escaped.reduce((result, literal, index) => result.replace(`\u0000${index}\u0000`, literal), replaced);
|
||||
}
|
||||
|
||||
function resolveConfigValue(
|
||||
value: unknown,
|
||||
variables: Map<string, ConfigVariableValue>,
|
||||
issues: ConfigValidationIssue[],
|
||||
): unknown {
|
||||
if (!isPlainObject(value)) return value;
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (key === "variables") {
|
||||
continue;
|
||||
}
|
||||
const itemPath = joinPath("", key);
|
||||
result[key] = key === "server" ? resolveValue(item, itemPath, variables, issues) : item;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function resolveValue(
|
||||
value: unknown,
|
||||
path: string,
|
||||
variables: Map<string, ConfigVariableValue>,
|
||||
issues: ConfigValidationIssue[],
|
||||
): unknown {
|
||||
if (isString(value)) {
|
||||
return replaceStringValue(value, variables, issues, { path });
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item, index) => resolveValue(item, `${path}[${index}]`, variables, issues));
|
||||
}
|
||||
if (!isPlainObject(value)) return value;
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
const itemPath = joinPath(path, key);
|
||||
result[key] = resolveValue(item, itemPath, variables, issues);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function resolveVariableReference(
|
||||
reference: VariableReference,
|
||||
variables: Map<string, ConfigVariableValue>,
|
||||
issues: ConfigValidationIssue[],
|
||||
context: VariableResolutionContext,
|
||||
): ConfigVariableValue | undefined {
|
||||
if (variables.has(reference.key)) {
|
||||
return variables.get(reference.key);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(process.env, reference.key)) {
|
||||
return inferStringValue(process.env[reference.key] ?? "");
|
||||
}
|
||||
|
||||
if (reference.defaultValue !== undefined) {
|
||||
return inferStringValue(reference.defaultValue);
|
||||
}
|
||||
|
||||
issues.push(
|
||||
issue(
|
||||
"unresolved-variable",
|
||||
context.path,
|
||||
`引用了未定义的变量 "${reference.key}",且环境变量中也不存在,未设置默认值`,
|
||||
),
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
13
src/server/dev.ts
Normal file
13
src/server/dev.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { bootstrap } from "./bootstrap";
|
||||
import { parseRuntimeArgs } from "./config";
|
||||
import { createConsoleFallback } from "./logger";
|
||||
|
||||
async function main() {
|
||||
const { configPath } = parseRuntimeArgs();
|
||||
await bootstrap({ configPath, mode: "development" });
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
createConsoleFallback().fatal(`启动失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
48
src/server/helpers.ts
Normal file
48
src/server/helpers.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { ApiErrorResponse, MetaResponse, RuntimeMode } from "../shared/api";
|
||||
|
||||
import { APP } from "../shared/app";
|
||||
|
||||
export function createApiError(error: string, status: number): ApiErrorResponse {
|
||||
return { error, status };
|
||||
}
|
||||
|
||||
export function createHeaders(mode: RuntimeMode, init: HeadersInit): Headers {
|
||||
const headers = new Headers(init);
|
||||
|
||||
if (mode === "production") {
|
||||
headers.set("X-Content-Type-Options", "nosniff");
|
||||
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function createMetaResponse(version: string): MetaResponse {
|
||||
return {
|
||||
ok: true,
|
||||
service: APP.name,
|
||||
timestamp: new Date().toISOString(),
|
||||
version,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
if (ms >= 60000 && ms % 60000 === 0) return `${ms / 60000}m`;
|
||||
if (ms >= 1000 && ms % 1000 === 0) return `${ms / 1000}s`;
|
||||
return `${ms}ms`;
|
||||
}
|
||||
|
||||
export function jsonResponse(
|
||||
body: unknown,
|
||||
options: { headers?: HeadersInit; mode: RuntimeMode; status?: number },
|
||||
): Response {
|
||||
const headers = createHeaders(options.mode, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
...options.headers,
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(body), {
|
||||
headers,
|
||||
status: options.status,
|
||||
});
|
||||
}
|
||||
279
src/server/logger.ts
Normal file
279
src/server/logger.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import type pino from "pino";
|
||||
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import type { LogLevel, ResolvedLoggingConfig } from "./config/types";
|
||||
|
||||
import { APP } from "../shared/app";
|
||||
|
||||
export interface Logger {
|
||||
child(bindings: Record<string, unknown>): Logger;
|
||||
debug(obj: Record<string, unknown>, msg?: string): void;
|
||||
debug(msg: string): void;
|
||||
error(obj: Record<string, unknown>, msg?: string): void;
|
||||
error(msg: string): void;
|
||||
fatal(obj: Record<string, unknown>, msg?: string): void;
|
||||
fatal(msg: string): void;
|
||||
flush(): void;
|
||||
info(obj: Record<string, unknown>, msg?: string): void;
|
||||
info(msg: string): void;
|
||||
trace(obj: Record<string, unknown>, msg?: string): void;
|
||||
trace(msg: string): void;
|
||||
warn(obj: Record<string, unknown>, msg?: string): void;
|
||||
warn(msg: string): void;
|
||||
}
|
||||
|
||||
export const REDACT_PATHS = [
|
||||
"authorization",
|
||||
"cookie",
|
||||
"set-cookie",
|
||||
"*.set-cookie",
|
||||
"authToken",
|
||||
"key",
|
||||
"password",
|
||||
"token",
|
||||
"apiKey",
|
||||
"*.authorization",
|
||||
"*.cookie",
|
||||
"*.authToken",
|
||||
"*.key",
|
||||
"*.password",
|
||||
"*.token",
|
||||
"*.apiKey",
|
||||
];
|
||||
|
||||
const LOG_LEVEL_MAP: Record<LogLevel, string> = {
|
||||
debug: "debug",
|
||||
error: "error",
|
||||
fatal: "fatal",
|
||||
info: "info",
|
||||
trace: "trace",
|
||||
warn: "warn",
|
||||
};
|
||||
|
||||
type LogFn = (objOrMsg: Record<string, unknown> | string, msg?: string) => void;
|
||||
|
||||
const voidLog: LogFn = () => undefined;
|
||||
|
||||
class ConsoleFallbackLogger implements Logger {
|
||||
child(_bindings: Record<string, unknown>): Logger {
|
||||
return this;
|
||||
}
|
||||
|
||||
debug(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
console.log(formatMsg(objOrMsg, msg));
|
||||
}
|
||||
|
||||
error(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
console.error(formatMsg(objOrMsg, msg));
|
||||
}
|
||||
|
||||
fatal(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
console.error(formatMsg(objOrMsg, msg));
|
||||
}
|
||||
|
||||
flush: () => void = () => undefined;
|
||||
|
||||
info(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
console.log(formatMsg(objOrMsg, msg));
|
||||
}
|
||||
|
||||
trace(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
console.log(formatMsg(objOrMsg, msg));
|
||||
}
|
||||
|
||||
warn(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
console.warn(formatMsg(objOrMsg, msg));
|
||||
}
|
||||
}
|
||||
|
||||
class NoopLogger implements Logger {
|
||||
debug: LogFn = voidLog;
|
||||
error: LogFn = voidLog;
|
||||
fatal: LogFn = voidLog;
|
||||
info: LogFn = voidLog;
|
||||
trace: LogFn = voidLog;
|
||||
warn: LogFn = voidLog;
|
||||
child(_bindings: Record<string, unknown>): Logger {
|
||||
return this;
|
||||
}
|
||||
flush: () => void = () => undefined;
|
||||
}
|
||||
|
||||
class PinoLoggerWrapper implements Logger {
|
||||
private pino: pino.Logger;
|
||||
|
||||
constructor(pinoLogger: pino.Logger) {
|
||||
this.pino = pinoLogger;
|
||||
}
|
||||
|
||||
child(bindings: Record<string, unknown>): Logger {
|
||||
return new PinoLoggerWrapper(this.pino.child(bindings));
|
||||
}
|
||||
|
||||
debug(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
if (typeof objOrMsg === "string") this.pino.debug(objOrMsg);
|
||||
else this.pino.debug(objOrMsg, msg);
|
||||
}
|
||||
|
||||
error(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
if (typeof objOrMsg === "string") this.pino.error(objOrMsg);
|
||||
else this.pino.error(objOrMsg, msg);
|
||||
}
|
||||
|
||||
fatal(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
if (typeof objOrMsg === "string") this.pino.fatal(objOrMsg);
|
||||
else this.pino.fatal(objOrMsg, msg);
|
||||
}
|
||||
|
||||
flush(): void {
|
||||
this.pino.flush();
|
||||
}
|
||||
|
||||
info(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
if (typeof objOrMsg === "string") this.pino.info(objOrMsg);
|
||||
else this.pino.info(objOrMsg, msg);
|
||||
}
|
||||
|
||||
trace(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
if (typeof objOrMsg === "string") this.pino.trace(objOrMsg);
|
||||
else this.pino.trace(objOrMsg, msg);
|
||||
}
|
||||
|
||||
warn(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
if (typeof objOrMsg === "string") this.pino.warn(objOrMsg);
|
||||
else this.pino.warn(objOrMsg, msg);
|
||||
}
|
||||
}
|
||||
|
||||
export class MemoryLogger implements Logger {
|
||||
entries: Array<{ level: string; msg: string; obj?: Record<string, unknown> }> = [];
|
||||
|
||||
child(_bindings: Record<string, unknown>): Logger {
|
||||
return this;
|
||||
}
|
||||
|
||||
debug(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
this.capture("debug", objOrMsg, msg);
|
||||
}
|
||||
|
||||
error(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
this.capture("error", objOrMsg, msg);
|
||||
}
|
||||
|
||||
fatal(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
this.capture("fatal", objOrMsg, msg);
|
||||
}
|
||||
|
||||
flush: () => void = () => undefined;
|
||||
|
||||
info(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
this.capture("info", objOrMsg, msg);
|
||||
}
|
||||
|
||||
trace(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
this.capture("trace", objOrMsg, msg);
|
||||
}
|
||||
|
||||
warn(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
this.capture("warn", objOrMsg, msg);
|
||||
}
|
||||
|
||||
private capture(level: string, objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
if (typeof objOrMsg === "string") {
|
||||
this.entries.push({ level, msg: objOrMsg });
|
||||
} else {
|
||||
this.entries.push({ level, msg: msg ?? "", obj: objOrMsg });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createConsoleFallback(): Logger {
|
||||
return new ConsoleFallbackLogger();
|
||||
}
|
||||
|
||||
export function createMemoryLogger(): MemoryLogger {
|
||||
return new MemoryLogger();
|
||||
}
|
||||
|
||||
export function createNoopLogger(): Logger {
|
||||
return new NoopLogger();
|
||||
}
|
||||
|
||||
export async function createRuntimeLogger(
|
||||
config: ResolvedLoggingConfig,
|
||||
mode: string,
|
||||
version?: string,
|
||||
): Promise<Logger> {
|
||||
const pinoLib = await import("pino");
|
||||
const pinoPretty = await import("pino-pretty");
|
||||
|
||||
mkdirSync(dirname(config.filePath), { recursive: true });
|
||||
|
||||
const rootLevel = resolveRootLevel(config.consoleLevel, config.fileLevel);
|
||||
|
||||
const prettyStream = pinoPretty.default({
|
||||
colorize: true,
|
||||
ignore: "pid,hostname",
|
||||
singleLine: true,
|
||||
translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l",
|
||||
});
|
||||
|
||||
const fileStream = await createRollingFileStream(config);
|
||||
|
||||
const streams: pino.StreamEntry[] = [
|
||||
{ level: toPinoLevel(config.consoleLevel) as pino.Level, stream: prettyStream },
|
||||
{ level: toPinoLevel(config.fileLevel) as pino.Level, stream: fileStream },
|
||||
];
|
||||
|
||||
const base: Record<string, unknown> = { mode, service: APP.name };
|
||||
if (version) base["version"] = version;
|
||||
|
||||
const logger = pinoLib.default(
|
||||
{
|
||||
base,
|
||||
level: rootLevel,
|
||||
redact: { censor: "[Redacted]", paths: REDACT_PATHS },
|
||||
timestamp: pinoLib.stdTimeFunctions.isoTime,
|
||||
},
|
||||
pinoLib.multistream(streams),
|
||||
);
|
||||
|
||||
return new PinoLoggerWrapper(logger);
|
||||
}
|
||||
|
||||
async function createRollingFileStream(config: ResolvedLoggingConfig): Promise<NodeJS.WritableStream> {
|
||||
const dir = dirname(config.filePath);
|
||||
const base = resolve(dir, config.filePath.replace(/^.*[\\/]/, "").replace(/\.log$/, ""));
|
||||
|
||||
try {
|
||||
const buildPinoRoll = (await import("pino-roll")).default;
|
||||
return await buildPinoRoll({
|
||||
file: base,
|
||||
frequency: config.rotationFrequency,
|
||||
limit: { count: config.rotationMaxFiles },
|
||||
mkdir: true,
|
||||
size: config.rotationSizeRaw,
|
||||
});
|
||||
} catch {
|
||||
const fs = await import("node:fs");
|
||||
return fs.createWriteStream(config.filePath, { flags: "a" });
|
||||
}
|
||||
}
|
||||
|
||||
function formatMsg(objOrMsg: Record<string, unknown> | string, msg?: string): string {
|
||||
if (typeof objOrMsg === "string") return objOrMsg;
|
||||
return msg ? `${msg} ${JSON.stringify(objOrMsg)}` : JSON.stringify(objOrMsg);
|
||||
}
|
||||
|
||||
function resolveRootLevel(consoleLevel: LogLevel, fileLevel: LogLevel): string {
|
||||
const order: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"];
|
||||
const ci = order.indexOf(consoleLevel);
|
||||
const fi = order.indexOf(fileLevel);
|
||||
return LOG_LEVEL_MAP[order[Math.min(ci, fi)]!] ?? "info";
|
||||
}
|
||||
|
||||
function toPinoLevel(level: LogLevel): string {
|
||||
return LOG_LEVEL_MAP[level];
|
||||
}
|
||||
13
src/server/main.ts
Normal file
13
src/server/main.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { bootstrap } from "./bootstrap";
|
||||
import { parseRuntimeArgs } from "./config";
|
||||
import { createConsoleFallback } from "./logger";
|
||||
|
||||
async function main() {
|
||||
const { configPath } = parseRuntimeArgs();
|
||||
await bootstrap({ configPath, mode: "production" });
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
createConsoleFallback().fatal(`启动失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
63
src/server/middleware.ts
Normal file
63
src/server/middleware.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { RuntimeMode } from "../shared/api";
|
||||
|
||||
import { createApiError, jsonResponse } from "./helpers";
|
||||
|
||||
const MAX_PAGE_SIZE = 200;
|
||||
|
||||
export function validateIdParam(idStr: string, mode: RuntimeMode): Response | { id: string } {
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(idStr)) {
|
||||
return jsonResponse(createApiError("Invalid ID parameter", 400), { mode, status: 400 });
|
||||
}
|
||||
return { id: idStr };
|
||||
}
|
||||
|
||||
export function validatePagination(
|
||||
pageParam: null | string,
|
||||
pageSizeParam: null | string,
|
||||
mode: RuntimeMode,
|
||||
): Response | { page: number; pageSize: number } {
|
||||
let page = 1;
|
||||
let pageSize = 20;
|
||||
|
||||
if (pageParam !== null) {
|
||||
page = Number(pageParam);
|
||||
if (!Number.isInteger(page) || page <= 0) {
|
||||
return jsonResponse(createApiError("Invalid page parameter", 400), { mode, status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (pageSizeParam !== null) {
|
||||
pageSize = Number(pageSizeParam);
|
||||
if (!Number.isInteger(pageSize) || pageSize <= 0) {
|
||||
return jsonResponse(createApiError("Invalid pageSize parameter", 400), { mode, status: 400 });
|
||||
}
|
||||
if (pageSize > MAX_PAGE_SIZE) {
|
||||
return jsonResponse(createApiError(`pageSize must not exceed ${MAX_PAGE_SIZE}`, 400), { mode, status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
return { page, pageSize };
|
||||
}
|
||||
|
||||
export function validateTimeRange(
|
||||
from: null | string,
|
||||
to: null | string,
|
||||
mode: RuntimeMode,
|
||||
): Response | { from: string; to: string } {
|
||||
if (!from || !to) {
|
||||
return jsonResponse(createApiError("from and to parameters are required", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
const fromDate = new Date(from);
|
||||
const toDate = new Date(to);
|
||||
|
||||
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
|
||||
return jsonResponse(createApiError("Invalid from or to parameter format", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
if (fromDate.getTime() > toDate.getTime()) {
|
||||
return jsonResponse(createApiError("from must be earlier than to", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
return { from: fromDate.toISOString(), to: toDate.toISOString() };
|
||||
}
|
||||
7
src/server/routes/meta.ts
Normal file
7
src/server/routes/meta.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { RuntimeMode } from "../../shared/api";
|
||||
|
||||
import { createMetaResponse, jsonResponse } from "../helpers";
|
||||
|
||||
export function handleMeta(mode: RuntimeMode, version: string): Response {
|
||||
return jsonResponse(createMetaResponse(version), { mode });
|
||||
}
|
||||
49
src/server/server.ts
Normal file
49
src/server/server.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { RuntimeMode } from "../shared/api";
|
||||
import type { Logger } from "./logger";
|
||||
import type { StaticAssets } from "./static";
|
||||
|
||||
import { createApiError, jsonResponse } from "./helpers";
|
||||
import { handleMeta } from "./routes/meta";
|
||||
import { serveStaticAsset } from "./static";
|
||||
import { readAppVersion } from "./version";
|
||||
|
||||
export interface StartServerOptions {
|
||||
config: { host: string; port: number };
|
||||
logger: Logger;
|
||||
mode: RuntimeMode;
|
||||
staticAssets?: StaticAssets;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export function startServer(options: StartServerOptions) {
|
||||
const { config, logger, mode, staticAssets, version } = options;
|
||||
|
||||
const resolveVersion = (): Promise<string> => {
|
||||
if (version) return Promise.resolve(version);
|
||||
return readAppVersion();
|
||||
};
|
||||
|
||||
const server = Bun.serve({
|
||||
fetch(req) {
|
||||
if (staticAssets) {
|
||||
return serveStaticAsset(new URL(req.url).pathname, staticAssets);
|
||||
}
|
||||
return new Response("Frontend is served by Vite dev server on :5173", { status: 404 });
|
||||
},
|
||||
hostname: config.host,
|
||||
port: config.port,
|
||||
routes: {
|
||||
"/api/*": () => jsonResponse(createApiError("API route not found", 404), { mode, status: 404 }),
|
||||
"/api/meta": {
|
||||
GET: async () => {
|
||||
const resolvedVersion = await resolveVersion();
|
||||
return handleMeta(mode, resolvedVersion);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
logger.info({ host: config.host, port: config.port, url: server.url.toString() }, "服务启动");
|
||||
|
||||
return server;
|
||||
}
|
||||
60
src/server/static.ts
Normal file
60
src/server/static.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
export interface StaticAssets {
|
||||
files: Record<string, Blob>;
|
||||
indexHtml: Blob;
|
||||
}
|
||||
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
".css": "text/css; charset=utf-8",
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
};
|
||||
|
||||
export function contentTypeFor(path: string): string {
|
||||
const dot = path.lastIndexOf(".");
|
||||
if (dot === -1) return "application/octet-stream";
|
||||
const ext = path.slice(dot);
|
||||
return CONTENT_TYPES[ext] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
export function hasFileExtension(path: string): boolean {
|
||||
const lastSlash = path.lastIndexOf("/");
|
||||
const segment = lastSlash === -1 ? path : path.slice(lastSlash + 1);
|
||||
return segment.includes(".");
|
||||
}
|
||||
|
||||
export function htmlResponse(html: Blob): Response {
|
||||
return new Response(html, {
|
||||
headers: {
|
||||
"Cache-Control": "no-cache",
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function serveStaticAsset(pathname: string, assets: StaticAssets): Response {
|
||||
if (pathname === "/") {
|
||||
return htmlResponse(assets.indexHtml);
|
||||
}
|
||||
|
||||
const file = assets.files[pathname];
|
||||
if (file) {
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"Content-Type": contentTypeFor(pathname),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (hasFileExtension(pathname)) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
return htmlResponse(assets.indexHtml);
|
||||
}
|
||||
17
src/server/version.ts
Normal file
17
src/server/version.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { validateVersion } from "../../scripts/bump-version-logic";
|
||||
|
||||
const PACKAGE_JSON_PATH = resolve(import.meta.dir, "..", "..", "package.json");
|
||||
|
||||
export async function readAppVersion(): Promise<string> {
|
||||
const packageJson = (await Bun.file(PACKAGE_JSON_PATH).json()) as { version: string };
|
||||
const version = packageJson.version;
|
||||
|
||||
if (typeof version !== "string") {
|
||||
throw new Error("package.json does not have a valid version field");
|
||||
}
|
||||
|
||||
validateVersion(version);
|
||||
return version;
|
||||
}
|
||||
18
src/shared/api.ts
Normal file
18
src/shared/api.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface ApiErrorResponse {
|
||||
error: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export interface MetaResponse {
|
||||
ok: true;
|
||||
service: string;
|
||||
timestamp: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export type RuntimeMode = "development" | "production" | "test";
|
||||
|
||||
// ==========================================
|
||||
// 在此定义你的业务类型
|
||||
// 前后端共享的类型都放在这个文件中
|
||||
// ==========================================
|
||||
6
src/shared/app.ts
Normal file
6
src/shared/app.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const APP = {
|
||||
description: "基于 Bun + React + TDesign 的全栈开发框架",
|
||||
name: "my-app",
|
||||
subtitle: "Bun 全栈应用",
|
||||
title: "My App",
|
||||
} as const;
|
||||
86
src/web/app.tsx
Normal file
86
src/web/app.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import { Layout, RadioGroup } from "tdesign-react";
|
||||
|
||||
import type { MetaResponse } from "../shared/api";
|
||||
|
||||
import { APP } from "../shared/app";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { useSidebarCollapsed } from "./hooks/use-sidebar-collapsed";
|
||||
import { type ThemePreference, useThemePreference } from "./hooks/use-theme-preference";
|
||||
import { MENU_ITEMS } from "./menu";
|
||||
import { AppRoutes } from "./routes";
|
||||
|
||||
const { Aside, Content, Header } = Layout;
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
{ label: "系统", value: "system" },
|
||||
{ label: "明亮", value: "light" },
|
||||
{ label: "黑暗", value: "dark" },
|
||||
] as const;
|
||||
|
||||
export function App() {
|
||||
const { preference: themePreference, setPreference: setThemePreference } = useThemePreference();
|
||||
const { collapsed, toggleCollapsed } = useSidebarCollapsed();
|
||||
const location = useLocation();
|
||||
const { data: meta } = useQuery({
|
||||
queryFn: fetchMeta,
|
||||
queryKey: ["meta"],
|
||||
refetchInterval: 30000,
|
||||
staleTime: 5000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.title = APP.title;
|
||||
document.querySelector('meta[name="description"]')?.setAttribute("content", APP.description);
|
||||
}, []);
|
||||
|
||||
const handleThemeChange = (value: ThemePreference) => {
|
||||
setThemePreference(value);
|
||||
};
|
||||
|
||||
const currentPath = location.pathname;
|
||||
const currentItem = MENU_ITEMS.find((item) => item.path === currentPath);
|
||||
const pageTitle = currentItem?.label ?? APP.title;
|
||||
const versionDisplay = meta?.version ? `v${meta.version}` : null;
|
||||
|
||||
return (
|
||||
<Layout className="app-layout">
|
||||
<Header className="app-header">
|
||||
<div className="app-header-left">
|
||||
<span className="app-brand-group">
|
||||
<span className="app-brand">{APP.title}</span>
|
||||
{versionDisplay && <span className="app-version">{versionDisplay}</span>}
|
||||
</span>
|
||||
<span className="app-page-title">{pageTitle}</span>
|
||||
</div>
|
||||
<div className="app-header-right">
|
||||
<RadioGroup
|
||||
onChange={handleThemeChange}
|
||||
options={THEME_OPTIONS.map((option) => ({ label: option.label, value: option.value }))}
|
||||
theme="button"
|
||||
value={themePreference}
|
||||
variant="default-filled"
|
||||
/>
|
||||
</div>
|
||||
</Header>
|
||||
<Layout>
|
||||
<Aside className="app-sidebar" width={collapsed ? "64px" : "232px"}>
|
||||
<Sidebar collapsed={collapsed} onToggleCollapsed={toggleCollapsed} />
|
||||
</Aside>
|
||||
<Layout>
|
||||
<Content className="app-content">
|
||||
<AppRoutes />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchMeta(): Promise<MetaResponse> {
|
||||
const response = await fetch("/api/meta");
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<MetaResponse>;
|
||||
}
|
||||
38
src/web/components/ErrorBoundary.tsx
Normal file
38
src/web/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { ErrorInfo, ReactNode } from "react";
|
||||
|
||||
import { Component } from "react";
|
||||
import { Alert, Button, Space } from "tdesign-react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
override state: State = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(): State {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
override componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
console.error("渲染错误:", error, info.componentStack);
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Space align="center" className="error-boundary-fallback" direction="vertical" size="large">
|
||||
<Alert message="页面渲染出现异常,请刷新重试" theme="error" title="页面出错" />
|
||||
<Button onClick={() => window.location.reload()} theme="primary">
|
||||
刷新页面
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
53
src/web/components/Sidebar/index.tsx
Normal file
53
src/web/components/Sidebar/index.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "tdesign-icons-react";
|
||||
import { Button, Menu } from "tdesign-react";
|
||||
|
||||
import { MENU_ITEMS } from "../../menu";
|
||||
|
||||
const { MenuItem } = Menu;
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed: boolean;
|
||||
onToggleCollapsed: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ collapsed, onToggleCollapsed }: SidebarProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const currentPath = location.pathname;
|
||||
const currentItem = MENU_ITEMS.find((item) => item.path === currentPath);
|
||||
const activeValue = currentItem?.value ?? "";
|
||||
|
||||
const handleMenuChange = (value: number | string) => {
|
||||
const item = MENU_ITEMS.find((item) => item.value === value);
|
||||
if (item) {
|
||||
void navigate(item.path);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu
|
||||
className="app-sidebar-menu"
|
||||
collapsed={collapsed}
|
||||
onChange={handleMenuChange}
|
||||
operations={
|
||||
<Button
|
||||
className="app-sidebar-collapse-btn"
|
||||
icon={collapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
|
||||
onClick={onToggleCollapsed}
|
||||
shape="square"
|
||||
variant="text"
|
||||
/>
|
||||
}
|
||||
value={activeValue}
|
||||
width={collapsed ? "64px" : "232px"}
|
||||
>
|
||||
{MENU_ITEMS.map((item) => (
|
||||
<MenuItem icon={item.icon} key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
1
src/web/css.d.ts
vendored
Normal file
1
src/web/css.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module "*.css";
|
||||
51
src/web/hooks/use-sidebar-collapsed.ts
Normal file
51
src/web/hooks/use-sidebar-collapsed.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const SIDEBAR_COLLAPSED_STORAGE_KEY = "sidebar.collapsed";
|
||||
|
||||
export function applyInitialSidebarCollapsed() {
|
||||
const collapsed = readSidebarCollapsed();
|
||||
applySidebarCollapsed(collapsed);
|
||||
}
|
||||
|
||||
export function applySidebarCollapsed(collapsed: boolean, root: HTMLElement = document.documentElement) {
|
||||
root.setAttribute("data-sidebar-collapsed", String(collapsed));
|
||||
}
|
||||
|
||||
export function parseSidebarCollapsed(value: unknown): boolean {
|
||||
return value === "true";
|
||||
}
|
||||
|
||||
export function readSidebarCollapsed(storage: Storage = window.localStorage): boolean {
|
||||
try {
|
||||
return parseSidebarCollapsed(storage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function useSidebarCollapsed() {
|
||||
const [collapsed, setCollapsedState] = useState<boolean>(() => readSidebarCollapsed());
|
||||
|
||||
useEffect(() => {
|
||||
applySidebarCollapsed(collapsed);
|
||||
}, [collapsed]);
|
||||
|
||||
const setCollapsed = (nextCollapsed: boolean) => {
|
||||
setCollapsedState(nextCollapsed);
|
||||
writeSidebarCollapsed(nextCollapsed);
|
||||
};
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
setCollapsed(!collapsed);
|
||||
};
|
||||
|
||||
return { collapsed, setCollapsed, toggleCollapsed };
|
||||
}
|
||||
|
||||
export function writeSidebarCollapsed(collapsed: boolean, storage: Storage = window.localStorage) {
|
||||
try {
|
||||
storage.setItem(SIDEBAR_COLLAPSED_STORAGE_KEY, String(collapsed));
|
||||
} catch {
|
||||
// 存储不可用时仅使用当前内存状态
|
||||
}
|
||||
}
|
||||
73
src/web/hooks/use-theme-preference.ts
Normal file
73
src/web/hooks/use-theme-preference.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export type EffectiveTheme = "dark" | "light";
|
||||
export type ThemePreference = "dark" | "light" | "system";
|
||||
|
||||
export const THEME_PREFERENCE_STORAGE_KEY = "theme.preference";
|
||||
export const THEME_MEDIA_QUERY = "(prefers-color-scheme: dark)";
|
||||
|
||||
export function applyInitialThemePreference() {
|
||||
applyThemeMode(resolveEffectiveTheme(readThemePreference(), getSystemPrefersDark()));
|
||||
}
|
||||
|
||||
export function applyThemeMode(theme: EffectiveTheme, root: HTMLElement = document.documentElement) {
|
||||
root.setAttribute("theme-mode", theme);
|
||||
}
|
||||
|
||||
export function getSystemPrefersDark(matchMedia: Window["matchMedia"] = window.matchMedia): boolean {
|
||||
try {
|
||||
return matchMedia(THEME_MEDIA_QUERY).matches;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseThemePreference(value: unknown): ThemePreference {
|
||||
return value === "dark" || value === "light" || value === "system" ? value : "system";
|
||||
}
|
||||
|
||||
export function readThemePreference(storage: Storage = window.localStorage): ThemePreference {
|
||||
try {
|
||||
return parseThemePreference(storage.getItem(THEME_PREFERENCE_STORAGE_KEY));
|
||||
} catch {
|
||||
return "system";
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveEffectiveTheme(preference: ThemePreference, systemPrefersDark: boolean): EffectiveTheme {
|
||||
if (preference === "dark" || preference === "light") return preference;
|
||||
return systemPrefersDark ? "dark" : "light";
|
||||
}
|
||||
|
||||
export function useThemePreference() {
|
||||
const [preference, setPreferenceState] = useState<ThemePreference>(() => readThemePreference());
|
||||
const [systemPrefersDark, setSystemPrefersDark] = useState(() => getSystemPrefersDark());
|
||||
const effectiveTheme = resolveEffectiveTheme(preference, systemPrefersDark);
|
||||
|
||||
useEffect(() => {
|
||||
applyThemeMode(effectiveTheme);
|
||||
}, [effectiveTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQueryList = window.matchMedia(THEME_MEDIA_QUERY);
|
||||
|
||||
const handleChange = (event: MediaQueryListEvent) => setSystemPrefersDark(event.matches);
|
||||
mediaQueryList.addEventListener("change", handleChange);
|
||||
return () => mediaQueryList.removeEventListener("change", handleChange);
|
||||
}, []);
|
||||
|
||||
const setPreference = (nextPreference: ThemePreference) => {
|
||||
setPreferenceState(nextPreference);
|
||||
writeThemePreference(nextPreference);
|
||||
};
|
||||
|
||||
return { effectiveTheme, preference, setPreference };
|
||||
}
|
||||
|
||||
export function writeThemePreference(preference: ThemePreference, storage: Storage = window.localStorage) {
|
||||
try {
|
||||
storage.setItem(THEME_PREFERENCE_STORAGE_KEY, preference);
|
||||
} catch {
|
||||
// 存储不可用时仅使用当前内存状态,避免阻断 Dashboard 渲染。
|
||||
}
|
||||
}
|
||||
13
src/web/index.html
Normal file
13
src/web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="" />
|
||||
<title>App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
46
src/web/main.tsx
Normal file
46
src/web/main.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router";
|
||||
|
||||
import { App } from "./app";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
import { applyInitialSidebarCollapsed } from "./hooks/use-sidebar-collapsed";
|
||||
import { applyInitialThemePreference } from "./hooks/use-theme-preference";
|
||||
import "tdesign-react/dist/reset.css";
|
||||
import "tdesign-react/dist/tdesign.min.css";
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: true,
|
||||
retry: 1,
|
||||
staleTime: 5000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
|
||||
if (!rootElement) {
|
||||
throw new Error("找不到前端挂载节点 #root");
|
||||
}
|
||||
|
||||
applyInitialThemePreference();
|
||||
applyInitialSidebarCollapsed();
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
);
|
||||
18
src/web/menu.tsx
Normal file
18
src/web/menu.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { ReactElement } from "react";
|
||||
import type { MenuValue } from "tdesign-react";
|
||||
|
||||
import { createElement } from "react";
|
||||
import { DashboardIcon, SettingIcon, UserIcon } from "tdesign-icons-react";
|
||||
|
||||
export interface MenuItemConfig {
|
||||
icon: ReactElement;
|
||||
label: string;
|
||||
path: string;
|
||||
value: MenuValue;
|
||||
}
|
||||
|
||||
export const MENU_ITEMS: readonly MenuItemConfig[] = [
|
||||
{ icon: createElement(DashboardIcon), label: "仪表盘", path: "/", value: "dashboard" },
|
||||
{ icon: createElement(UserIcon), label: "用户管理", path: "/users", value: "users" },
|
||||
{ icon: createElement(SettingIcon), label: "系统设置", path: "/settings", value: "settings" },
|
||||
] as const;
|
||||
22
src/web/pages/404/index.tsx
Normal file
22
src/web/pages/404/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useNavigate } from "react-router";
|
||||
import { ErrorCircleIcon } from "tdesign-icons-react";
|
||||
import { Button, Space } from "tdesign-react";
|
||||
|
||||
export function NotFoundPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleGoHome = () => {
|
||||
void navigate("/");
|
||||
};
|
||||
|
||||
return (
|
||||
<Space align="center" className="not-found-page" direction="vertical" size="large">
|
||||
<ErrorCircleIcon className="not-found-icon" size="64px" />
|
||||
<h1>404</h1>
|
||||
<p>您访问的页面不存在</p>
|
||||
<Button onClick={handleGoHome} theme="primary">
|
||||
返回首页
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
29
src/web/pages/dashboard/index.tsx
Normal file
29
src/web/pages/dashboard/index.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Space } from "tdesign-react";
|
||||
|
||||
import type { MetaResponse } from "../../../shared/api";
|
||||
|
||||
import { APP } from "../../../shared/app";
|
||||
|
||||
export function DashboardPage() {
|
||||
const { data: meta } = useQuery({
|
||||
queryFn: fetchMeta,
|
||||
queryKey: ["meta"],
|
||||
refetchInterval: 30000,
|
||||
staleTime: 5000,
|
||||
});
|
||||
|
||||
return (
|
||||
<Space className="full-width-space" direction="vertical" size="large">
|
||||
<h2>欢迎使用 {APP.title}</h2>
|
||||
<p>在此构建你的应用。以下是 /api/meta 的返回数据(前后端联调示例):</p>
|
||||
{meta && <pre className="meta-response">{JSON.stringify(meta, null, 2)}</pre>}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchMeta(): Promise<MetaResponse> {
|
||||
const response = await fetch("/api/meta");
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<MetaResponse>;
|
||||
}
|
||||
12
src/web/pages/settings/index.tsx
Normal file
12
src/web/pages/settings/index.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Card, Space } from "tdesign-react";
|
||||
|
||||
export function SettingsPage() {
|
||||
return (
|
||||
<Space className="full-width-space" direction="vertical" size="large">
|
||||
<h2>系统设置</h2>
|
||||
<Card>
|
||||
<p>页面建设中...</p>
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
12
src/web/pages/users/index.tsx
Normal file
12
src/web/pages/users/index.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Card, Space } from "tdesign-react";
|
||||
|
||||
export function UsersPage() {
|
||||
return (
|
||||
<Space className="full-width-space" direction="vertical" size="large">
|
||||
<h2>用户管理</h2>
|
||||
<Card>
|
||||
<p>页面建设中...</p>
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
23
src/web/routes.tsx
Normal file
23
src/web/routes.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Route, Routes } from "react-router";
|
||||
|
||||
import { NotFoundPage } from "./pages/404";
|
||||
import { DashboardPage } from "./pages/dashboard";
|
||||
import { SettingsPage } from "./pages/settings";
|
||||
import { UsersPage } from "./pages/users";
|
||||
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<DashboardPage />} path="/" />
|
||||
<Route element={<UsersPage />} path="/users" />
|
||||
<Route element={<SettingsPage />} path="/settings" />
|
||||
<Route element={<NotFoundPage />} path="*" />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children }: { children: ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
115
src/web/styles.css
Normal file
115
src/web/styles.css
Normal file
@@ -0,0 +1,115 @@
|
||||
:root {
|
||||
--td-brand-color: var(--td-brand-color-7);
|
||||
}
|
||||
|
||||
.app-layout {
|
||||
min-height: 100vh;
|
||||
background: var(--td-bg-color-page);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--td-comp-paddingLR-l);
|
||||
background: var(--td-bg-color-container);
|
||||
border-bottom: 1px solid var(--td-component-border);
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.app-header-left {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--td-comp-margin-l);
|
||||
}
|
||||
|
||||
.app-header-right {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--td-comp-margin-s);
|
||||
}
|
||||
|
||||
.app-brand-group {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: var(--td-comp-margin-s);
|
||||
}
|
||||
|
||||
.app-brand {
|
||||
margin: 0;
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: calc(var(--td-font-size-title-large) + 6px);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.app-version {
|
||||
color: var(--td-text-color-placeholder);
|
||||
font-size: var(--td-font-size-body-small);
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.app-sidebar-collapse-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
|
||||
.app-page-title {
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: var(--td-font-size-title-medium);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
background: var(--td-bg-color-container);
|
||||
border-right: 1px solid var(--td-component-border);
|
||||
height: calc(100vh - 64px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-sidebar-menu {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
box-sizing: border-box;
|
||||
padding: var(--td-comp-paddingTB-xl) var(--td-comp-paddingLR-xl);
|
||||
min-height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
.meta-response {
|
||||
background: var(--td-bg-color-component);
|
||||
border-radius: var(--td-radius-default);
|
||||
padding: var(--td-comp-paddingTB-l) var(--td-comp-paddingLR-l);
|
||||
font-size: var(--td-font-size-body-medium);
|
||||
color: var(--td-text-color-primary);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.error-boundary-fallback {
|
||||
padding-top: 20vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.text-disabled {
|
||||
color: var(--td-text-color-disabled);
|
||||
}
|
||||
|
||||
.full-width-space {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.not-found-icon {
|
||||
color: var(--td-warning-color);
|
||||
}
|
||||
|
||||
.tabular-nums {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
46
src/web/utils/time.ts
Normal file
46
src/web/utils/time.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export function formatCountdown(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}秒`;
|
||||
return `${Math.floor(seconds / 60)}分${seconds % 60}秒`;
|
||||
}
|
||||
|
||||
export function formatDurationUnit(ms: null | number): { suffix: string; value: number } {
|
||||
if (ms === null) return { suffix: "", value: 0 };
|
||||
if (ms < 60000) return { suffix: "秒", value: roundToOne(ms / 1000) };
|
||||
if (ms < 3600000) return { suffix: "分钟", value: roundToOne(ms / 60000) };
|
||||
return { suffix: "小时", value: roundToOne(ms / 3600000) };
|
||||
}
|
||||
|
||||
export function formatRelativeTime(timestamp: null | string, now = new Date()): string {
|
||||
if (!timestamp) return "尚无检查数据";
|
||||
|
||||
const time = new Date(timestamp).getTime();
|
||||
if (Number.isNaN(time)) return "尚无检查数据";
|
||||
|
||||
const diffSeconds = Math.max(0, Math.floor((now.getTime() - time) / 1000));
|
||||
if (diffSeconds < 60) return `${diffSeconds}秒前`;
|
||||
|
||||
const diffMinutes = Math.floor(diffSeconds / 60);
|
||||
if (diffMinutes < 60) return `${diffMinutes}分钟前`;
|
||||
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
if (diffHours < 24) return `${diffHours}小时前`;
|
||||
|
||||
return `${Math.floor(diffHours / 24)}天前`;
|
||||
}
|
||||
|
||||
export function isOlderThan(timestamp: null | string, ageMs: number, now = new Date()): boolean {
|
||||
if (!timestamp) return false;
|
||||
const time = new Date(timestamp).getTime();
|
||||
if (Number.isNaN(time)) return false;
|
||||
return now.getTime() - time > ageMs;
|
||||
}
|
||||
|
||||
export function subtractHours(date: Date, hours: number): Date {
|
||||
const result = new Date(date);
|
||||
result.setTime(result.getTime() - hours * 60 * 60 * 1000);
|
||||
return result;
|
||||
}
|
||||
|
||||
function roundToOne(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
13
tests/helpers.ts
Normal file
13
tests/helpers.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { rm } from "node:fs/promises";
|
||||
|
||||
export async function rmRetry(dir: string, retries = 10, delayMs = 500) {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
await rm(dir, { force: true, recursive: true });
|
||||
return;
|
||||
} catch (e) {
|
||||
if (i === retries - 1) throw e;
|
||||
await new Promise((r) => setTimeout(r, delayMs));
|
||||
}
|
||||
}
|
||||
}
|
||||
48
tests/scripts/build.test.ts
Normal file
48
tests/scripts/build.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { validateVersion } from "../../scripts/bump-version-logic";
|
||||
|
||||
describe("build 版本注入", () => {
|
||||
test("validateVersion 接受有效版本", () => {
|
||||
expect(() => validateVersion("0.1.0")).not.toThrow();
|
||||
expect(() => validateVersion("1.2.3")).not.toThrow();
|
||||
});
|
||||
|
||||
test("validateVersion 拒绝无效版本", () => {
|
||||
expect(() => validateVersion("invalid")).toThrow();
|
||||
expect(() => validateVersion("1.0.0-beta.1")).toThrow();
|
||||
});
|
||||
|
||||
test("生成的 server-entry 包含版本字面量", () => {
|
||||
const version = "0.1.0";
|
||||
const serverEntryTs = [
|
||||
`import { bootstrap } from "../src/server/bootstrap";`,
|
||||
`import { parseRuntimeArgs } from "../src/server/config";`,
|
||||
`import { staticAssets } from "./static-assets";`,
|
||||
"",
|
||||
`const APP_VERSION = "${version}" as const;`,
|
||||
"",
|
||||
`async function main() {`,
|
||||
` const { configPath } = parseRuntimeArgs();`,
|
||||
` await bootstrap({ configPath, mode: "production", staticAssets, version: APP_VERSION });`,
|
||||
`}`,
|
||||
"",
|
||||
`void main().catch((error) => {`,
|
||||
` console.error("启动失败:", error instanceof Error ? error.message : error);`,
|
||||
` process.exit(1);`,
|
||||
`});`,
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
expect(serverEntryTs).toContain(`const APP_VERSION = "${version}"`);
|
||||
expect(serverEntryTs).toContain("version: APP_VERSION");
|
||||
});
|
||||
|
||||
test("版本字面量不依赖外部 package.json", () => {
|
||||
const serverEntryTs = [`const APP_VERSION = "0.1.0" as const;`].join("\n");
|
||||
|
||||
expect(serverEntryTs).not.toContain("package.json");
|
||||
expect(serverEntryTs).not.toContain("Bun.file");
|
||||
expect(serverEntryTs).toContain('"0.1.0"');
|
||||
});
|
||||
});
|
||||
73
tests/scripts/bump-version.test.ts
Normal file
73
tests/scripts/bump-version.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { bumpVersion, formatVersion, parseVersion, validateVersion } from "../../scripts/bump-version-logic";
|
||||
|
||||
describe("版本解析与校验", () => {
|
||||
test("parseVersion 解析有效版本", () => {
|
||||
expect(parseVersion("0.1.0")).toEqual([0, 1, 0]);
|
||||
expect(parseVersion("1.2.3")).toEqual([1, 2, 3]);
|
||||
expect(parseVersion("10.20.30")).toEqual([10, 20, 30]);
|
||||
});
|
||||
|
||||
test("parseVersion 拒绝无效版本", () => {
|
||||
expect(() => parseVersion("invalid")).toThrow();
|
||||
expect(() => parseVersion("1.2")).toThrow();
|
||||
expect(() => parseVersion("1.2.3.4")).toThrow();
|
||||
expect(() => parseVersion("1.2.a")).toThrow();
|
||||
});
|
||||
|
||||
test("validateVersion 接受有效版本", () => {
|
||||
expect(() => validateVersion("0.1.0")).not.toThrow();
|
||||
expect(() => validateVersion("1.2.3")).not.toThrow();
|
||||
expect(() => validateVersion("10.20.30")).not.toThrow();
|
||||
});
|
||||
|
||||
test("validateVersion 拒绝无效版本", () => {
|
||||
expect(() => validateVersion("")).toThrow();
|
||||
expect(() => validateVersion("invalid")).toThrow();
|
||||
expect(() => validateVersion("1.2")).toThrow();
|
||||
expect(() => validateVersion("1.2.3.4")).toThrow();
|
||||
expect(() => validateVersion("1.0.0-beta.1")).toThrow();
|
||||
expect(() => validateVersion("v1.0.0")).toThrow();
|
||||
});
|
||||
|
||||
test("formatVersion 格式化版本", () => {
|
||||
expect(formatVersion(0, 1, 0)).toBe("0.1.0");
|
||||
expect(formatVersion(1, 2, 3)).toBe("1.2.3");
|
||||
expect(formatVersion(10, 20, 30)).toBe("10.20.30");
|
||||
});
|
||||
});
|
||||
|
||||
describe("版本升迁逻辑", () => {
|
||||
test("bumpVersion patch 升迁", () => {
|
||||
expect(bumpVersion("1.2.3", "patch")).toBe("1.2.4");
|
||||
expect(bumpVersion("0.1.0", "patch")).toBe("0.1.1");
|
||||
expect(bumpVersion("0.0.1", "patch")).toBe("0.0.2");
|
||||
});
|
||||
|
||||
test("bumpVersion minor 升迁", () => {
|
||||
expect(bumpVersion("1.2.3", "minor")).toBe("1.3.0");
|
||||
expect(bumpVersion("0.1.0", "minor")).toBe("0.2.0");
|
||||
expect(bumpVersion("0.0.1", "minor")).toBe("0.1.0");
|
||||
});
|
||||
|
||||
test("bumpVersion major 升迁", () => {
|
||||
expect(bumpVersion("1.2.3", "major")).toBe("2.0.0");
|
||||
expect(bumpVersion("0.1.0", "major")).toBe("1.0.0");
|
||||
expect(bumpVersion("0.0.1", "major")).toBe("1.0.0");
|
||||
});
|
||||
|
||||
test("bumpVersion set 设置版本", () => {
|
||||
expect(bumpVersion("1.2.3", "set", "2.0.0")).toBe("2.0.0");
|
||||
expect(bumpVersion("0.1.0", "set", "0.2.0")).toBe("0.2.0");
|
||||
});
|
||||
|
||||
test("bumpVersion set 拒绝无效版本", () => {
|
||||
expect(() => bumpVersion("1.2.3", "set", "invalid")).toThrow();
|
||||
expect(() => bumpVersion("1.2.3", "set", "1.0.0-beta.1")).toThrow();
|
||||
});
|
||||
|
||||
test("bumpVersion set 缺少目标版本报错", () => {
|
||||
expect(() => bumpVersion("1.2.3", "set")).toThrow("set command requires a target version");
|
||||
});
|
||||
});
|
||||
208
tests/server/bootstrap.test.ts
Normal file
208
tests/server/bootstrap.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/* eslint-disable @typescript-eslint/no-empty-function, @typescript-eslint/require-await */
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { ResolvedConfig } from "../../src/server/config/types";
|
||||
import type { Logger } from "../../src/server/logger";
|
||||
import type { StartServerOptions } from "../../src/server/server";
|
||||
|
||||
import { bootstrap, type BootstrapDependencies } from "../../src/server/bootstrap";
|
||||
import { createMemoryLogger } from "../../src/server/logger";
|
||||
|
||||
function makeTempConfig(overrides: Partial<ResolvedConfig> = {}): ResolvedConfig {
|
||||
const base = join(tmpdir(), `bootstrap-test-${Date.now()}`);
|
||||
mkdirSync(base, { recursive: true });
|
||||
return {
|
||||
configDir: base,
|
||||
dataDir: join(base, "data"),
|
||||
host: "127.0.0.1",
|
||||
logging: {
|
||||
consoleLevel: "info",
|
||||
fileLevel: "info",
|
||||
filePath: join(base, "data", "logs", "test.log"),
|
||||
rotationFrequency: "daily",
|
||||
rotationMaxFiles: 14,
|
||||
rotationSizeBytes: 52428800,
|
||||
rotationSizeRaw: "50MB",
|
||||
},
|
||||
port: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("bootstrap", () => {
|
||||
test("使用默认依赖启动", async () => {
|
||||
let started = false;
|
||||
let signalRegistered = false;
|
||||
let loggerPassedToServer: Logger | undefined;
|
||||
|
||||
const cfg = makeTempConfig();
|
||||
const mockLoadConfig = (async () => cfg) as unknown as BootstrapDependencies["loadConfig"];
|
||||
const mockOnSignal = (_signal: string, _handler: () => void) => {
|
||||
signalRegistered = true;
|
||||
};
|
||||
const mockStartServer = (options: StartServerOptions) => {
|
||||
loggerPassedToServer = options.logger;
|
||||
started = true;
|
||||
return {};
|
||||
};
|
||||
|
||||
const deps: BootstrapDependencies = {
|
||||
createLogger: async () => createMemoryLogger(),
|
||||
loadConfig: mockLoadConfig,
|
||||
onSignal: mockOnSignal,
|
||||
startServer: mockStartServer,
|
||||
};
|
||||
|
||||
await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "production" }, deps);
|
||||
|
||||
expect(started).toBe(true);
|
||||
expect(signalRegistered).toBe(true);
|
||||
expect(loggerPassedToServer).toBeDefined();
|
||||
});
|
||||
|
||||
test("传递 version 给 startServer", async () => {
|
||||
let receivedVersion: string | undefined;
|
||||
let loggerCreated = false;
|
||||
|
||||
const cfg = makeTempConfig();
|
||||
const deps: BootstrapDependencies = {
|
||||
createLogger: async (_logConfig, _mode, version) => {
|
||||
loggerCreated = true;
|
||||
expect(version).toBe("1.2.3");
|
||||
return createMemoryLogger();
|
||||
},
|
||||
loadConfig: async () => cfg,
|
||||
onSignal: () => {},
|
||||
startServer: (options: StartServerOptions) => {
|
||||
receivedVersion = options.version;
|
||||
return {};
|
||||
},
|
||||
};
|
||||
|
||||
await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "production", version: "1.2.3" }, deps);
|
||||
|
||||
expect(receivedVersion).toBe("1.2.3");
|
||||
expect(loggerCreated).toBe(true);
|
||||
});
|
||||
|
||||
test("logger 初始化失败时使用 fallback 并退出", async () => {
|
||||
let exitCode: number | undefined;
|
||||
|
||||
const cfg = makeTempConfig();
|
||||
const deps: BootstrapDependencies = {
|
||||
createLogger: async () => {
|
||||
throw new Error("pino import failed");
|
||||
},
|
||||
exit: (code: number) => {
|
||||
exitCode = code;
|
||||
throw new Error("exit called");
|
||||
},
|
||||
loadConfig: async () => cfg,
|
||||
startServer: () => {
|
||||
throw new Error("should not reach");
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "production" }, deps);
|
||||
} catch {
|
||||
// expected - exit threw
|
||||
}
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
test("启动失败时调用 logger.fatal 并 flush", async () => {
|
||||
let fatalCalled = false;
|
||||
let flushCalled = false;
|
||||
let exitCode: number | undefined;
|
||||
|
||||
const mockLogger = createMemoryLogger();
|
||||
const origFatal = mockLogger.fatal.bind(mockLogger);
|
||||
const origFlush = mockLogger.flush.bind(mockLogger);
|
||||
mockLogger.fatal = (objOrMsg, msg?) => {
|
||||
fatalCalled = true;
|
||||
origFatal(objOrMsg, msg);
|
||||
};
|
||||
mockLogger.flush = () => {
|
||||
flushCalled = true;
|
||||
origFlush();
|
||||
};
|
||||
|
||||
const cfg = makeTempConfig();
|
||||
const deps: BootstrapDependencies = {
|
||||
createLogger: async () => mockLogger,
|
||||
exit: (code: number) => {
|
||||
exitCode = code;
|
||||
throw new Error("exit called");
|
||||
},
|
||||
loadConfig: async () => cfg,
|
||||
startServer: () => {
|
||||
throw new Error("server start failed");
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "production" }, deps);
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
expect(fatalCalled).toBe(true);
|
||||
expect(flushCalled).toBe(true);
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
test("数据目录创建后记录日志", async () => {
|
||||
const cfg = makeTempConfig();
|
||||
let infoDataDir: string | undefined;
|
||||
|
||||
const mockLogger = createMemoryLogger();
|
||||
const origInfo = mockLogger.info.bind(mockLogger);
|
||||
mockLogger.info = (objOrMsg, msg?) => {
|
||||
if (typeof objOrMsg === "object" && "dataDir" in objOrMsg) {
|
||||
infoDataDir = objOrMsg["dataDir"] as string;
|
||||
}
|
||||
origInfo(objOrMsg, msg);
|
||||
};
|
||||
|
||||
const deps: BootstrapDependencies = {
|
||||
createLogger: async () => mockLogger,
|
||||
loadConfig: async () => cfg,
|
||||
startServer: () => ({}),
|
||||
};
|
||||
|
||||
await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "development" }, deps);
|
||||
|
||||
expect(infoDataDir).toBe(cfg.dataDir);
|
||||
});
|
||||
|
||||
test("shutdown 时 flush logger", async () => {
|
||||
let flushed = false;
|
||||
let shutdownHandler: (() => void) | undefined;
|
||||
|
||||
const mockLogger = createMemoryLogger();
|
||||
mockLogger.flush = () => {
|
||||
flushed = true;
|
||||
};
|
||||
|
||||
const cfg = makeTempConfig();
|
||||
const deps: BootstrapDependencies = {
|
||||
createLogger: async () => mockLogger,
|
||||
loadConfig: async () => cfg,
|
||||
onSignal: (_signal, handler) => {
|
||||
shutdownHandler = handler;
|
||||
},
|
||||
startServer: () => ({}),
|
||||
};
|
||||
|
||||
await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "production" }, deps);
|
||||
|
||||
expect(shutdownHandler).toBeDefined();
|
||||
shutdownHandler!();
|
||||
expect(flushed).toBe(true);
|
||||
});
|
||||
});
|
||||
292
tests/server/config.test.ts
Normal file
292
tests/server/config.test.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { loadServerConfig, parseRuntimeArgs, parseSize } from "../../src/server/config";
|
||||
import { APP } from "../../src/shared/app";
|
||||
|
||||
describe("parseRuntimeArgs", () => {
|
||||
test("无参数抛出需要配置文件路径错误", () => {
|
||||
try {
|
||||
parseRuntimeArgs([]);
|
||||
expect.unreachable();
|
||||
} catch (error) {
|
||||
expect((error as Error).message).toContain("需要指定 YAML 配置文件路径");
|
||||
}
|
||||
});
|
||||
|
||||
test("有参数返回 configPath", () => {
|
||||
const result = parseRuntimeArgs(["config.yaml"]);
|
||||
expect(result).toEqual({ configPath: "config.yaml" });
|
||||
});
|
||||
|
||||
test("--help 抛出错误", () => {
|
||||
try {
|
||||
parseRuntimeArgs(["--help"]);
|
||||
expect.unreachable();
|
||||
} catch (error) {
|
||||
expect((error as Error).message).toContain("用法");
|
||||
}
|
||||
});
|
||||
|
||||
test("-h 抛出错误", () => {
|
||||
try {
|
||||
parseRuntimeArgs(["-h"]);
|
||||
expect.unreachable();
|
||||
} catch (error) {
|
||||
expect((error as Error).message).toContain("用法");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSize", () => {
|
||||
test("解析数字字节值", () => {
|
||||
expect(parseSize(1024)).toBe(1024);
|
||||
});
|
||||
|
||||
test("解析字符串大小", () => {
|
||||
expect(parseSize("1KB")).toBe(1024);
|
||||
expect(parseSize("50MB")).toBe(52428800);
|
||||
expect(parseSize("1GB")).toBe(1073741824);
|
||||
expect(parseSize("1024B")).toBe(1024);
|
||||
});
|
||||
|
||||
test("非法格式抛出错误", () => {
|
||||
try {
|
||||
parseSize("invalid");
|
||||
expect.unreachable();
|
||||
} catch (error) {
|
||||
expect((error as Error).message).toContain("无效的 size 格式");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadServerConfig", () => {
|
||||
test("YAML 配置文件不存在时报错", async () => {
|
||||
try {
|
||||
await loadServerConfig("/nonexistent/path/config.yaml");
|
||||
expect.unreachable();
|
||||
} catch (error) {
|
||||
expect((error as Error).message).toContain("配置文件不存在");
|
||||
}
|
||||
});
|
||||
|
||||
test("最简配置解析成功", async () => {
|
||||
const temp = tmpdir();
|
||||
const yamlPath = join(temp, "minimal.yaml");
|
||||
await writeFile(yamlPath, 'server:\n listen:\n host: "0.0.0.0"\n port: 9999\n');
|
||||
|
||||
try {
|
||||
const result = await loadServerConfig(yamlPath);
|
||||
expect(result.host).toBe("0.0.0.0");
|
||||
expect(result.port).toBe(9999);
|
||||
expect(result.configDir).toBe(temp);
|
||||
expect(result.dataDir).toBe(join(temp, "data"));
|
||||
expect(result.logging.filePath).toBe(join(temp, "data", "logs", `${APP.name}.log`));
|
||||
expect(result.logging.consoleLevel).toBe("info");
|
||||
expect(result.logging.fileLevel).toBe("info");
|
||||
} finally {
|
||||
await rm(yamlPath, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("旧布局 server.host/server.port 被拒绝", async () => {
|
||||
const temp = tmpdir();
|
||||
const yamlPath = join(temp, "test-old-layout.yaml");
|
||||
const yamlContent = 'server:\n host: "0.0.0.0"\n port: 9999\n';
|
||||
await writeFile(yamlPath, yamlContent);
|
||||
|
||||
try {
|
||||
await loadServerConfig(yamlPath);
|
||||
expect.unreachable();
|
||||
} catch (error) {
|
||||
expect((error as Error).message).toContain("未知字段");
|
||||
} finally {
|
||||
await rm(yamlPath, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("非法端口被拒绝", async () => {
|
||||
const temp = tmpdir();
|
||||
const yamlPath = join(temp, "test-bad-port.yaml");
|
||||
await writeFile(yamlPath, "server:\n listen:\n port: 99999\n");
|
||||
|
||||
try {
|
||||
await loadServerConfig(yamlPath);
|
||||
expect.unreachable();
|
||||
} catch (error) {
|
||||
expect((error as Error).message).toBeTruthy();
|
||||
} finally {
|
||||
await rm(yamlPath, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("显式变量引用环境变量生效", async () => {
|
||||
const prevHost = process.env["HOST"];
|
||||
const prevPort = process.env["PORT"];
|
||||
process.env["HOST"] = "10.0.0.1";
|
||||
process.env["PORT"] = "4000";
|
||||
|
||||
const temp = tmpdir();
|
||||
const yamlPath = join(temp, "test-env-var.yaml");
|
||||
await writeFile(yamlPath, 'server:\n listen:\n host: "${HOST}"\n port: ${PORT}\n');
|
||||
|
||||
try {
|
||||
const result = await loadServerConfig(yamlPath);
|
||||
expect(result.host).toBe("10.0.0.1");
|
||||
expect(result.port).toBe(4000);
|
||||
} finally {
|
||||
await rm(yamlPath, { force: true });
|
||||
if (prevHost === undefined) delete process.env["HOST"];
|
||||
else process.env["HOST"] = prevHost;
|
||||
if (prevPort === undefined) delete process.env["PORT"];
|
||||
else process.env["PORT"] = prevPort;
|
||||
}
|
||||
});
|
||||
|
||||
test("变量带默认值生效", async () => {
|
||||
delete process.env["MY_HOST"];
|
||||
const temp = tmpdir();
|
||||
const yamlPath = join(temp, "test-default.yaml");
|
||||
await writeFile(yamlPath, 'server:\n listen:\n host: "${MY_HOST|0.0.0.0}"\n port: ${MY_PORT|5000}\n');
|
||||
|
||||
try {
|
||||
const result = await loadServerConfig(yamlPath);
|
||||
expect(result.host).toBe("0.0.0.0");
|
||||
expect(result.port).toBe(5000);
|
||||
} finally {
|
||||
await rm(yamlPath, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("绝对 dataDir 保持不变", async () => {
|
||||
const temp = tmpdir();
|
||||
const dataDir = join(temp, "absolute-data");
|
||||
await mkdir(dataDir, { recursive: true });
|
||||
const yamlPath = join(temp, "absolute-dir.yaml");
|
||||
await writeFile(yamlPath, `server:\n storage:\n dataDir: ${JSON.stringify(dataDir)}\n`);
|
||||
|
||||
try {
|
||||
const result = await loadServerConfig(yamlPath);
|
||||
expect(result.dataDir).toBe(dataDir);
|
||||
} finally {
|
||||
await rm(yamlPath, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("相对 dataDir 基于 configDir", async () => {
|
||||
const temp = tmpdir();
|
||||
const yamlPath = join(temp, "rel-dir.yaml");
|
||||
await writeFile(yamlPath, 'server:\n storage:\n dataDir: "./my-data"\n');
|
||||
|
||||
try {
|
||||
const result = await loadServerConfig(yamlPath);
|
||||
expect(result.dataDir).toBe(join(temp, "my-data"));
|
||||
} finally {
|
||||
await rm(yamlPath, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("显式相对日志路径基于 configDir", async () => {
|
||||
const temp = tmpdir();
|
||||
const yamlPath = join(temp, "log-path.yaml");
|
||||
await writeFile(yamlPath, 'server:\n logging:\n file:\n path: "./logs/app.log"\n');
|
||||
|
||||
try {
|
||||
const result = await loadServerConfig(yamlPath);
|
||||
expect(result.logging.filePath).toBe(join(temp, "logs", "app.log"));
|
||||
} finally {
|
||||
await rm(yamlPath, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("绝对日志路径保持不变", async () => {
|
||||
const temp = tmpdir();
|
||||
const logPath = join(temp, "my-app.log");
|
||||
const yamlPath = join(temp, "abs-log.yaml");
|
||||
await writeFile(yamlPath, `server:\n logging:\n file:\n path: ${JSON.stringify(logPath)}\n`);
|
||||
|
||||
try {
|
||||
const result = await loadServerConfig(yamlPath);
|
||||
expect(result.logging.filePath).toBe(logPath);
|
||||
} finally {
|
||||
await rm(yamlPath, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("非法 logging.level 抛出错误", async () => {
|
||||
const temp = tmpdir();
|
||||
const yamlPath = join(temp, "bad-level.yaml");
|
||||
await writeFile(yamlPath, 'server:\n logging:\n level: "invalid"\n');
|
||||
|
||||
try {
|
||||
await loadServerConfig(yamlPath);
|
||||
expect.unreachable();
|
||||
} catch (error) {
|
||||
expect((error as Error).message).toContain("日志等级");
|
||||
} finally {
|
||||
await rm(yamlPath, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("空白 logging.file.path 抛出错误", async () => {
|
||||
const temp = tmpdir();
|
||||
const yamlPath = join(temp, "blank-path.yaml");
|
||||
await writeFile(yamlPath, 'server:\n logging:\n file:\n path: " "\n');
|
||||
|
||||
try {
|
||||
await loadServerConfig(yamlPath);
|
||||
expect.unreachable();
|
||||
} catch (error) {
|
||||
expect((error as Error).message).toContain("日志路径不能为空字符串或空白字符串");
|
||||
} finally {
|
||||
await rm(yamlPath, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("非法 rotation.size 抛出错误", async () => {
|
||||
const temp = tmpdir();
|
||||
const yamlPath = join(temp, "bad-size.yaml");
|
||||
await writeFile(yamlPath, 'server:\n logging:\n file:\n rotation:\n size: "99XX"\n');
|
||||
|
||||
try {
|
||||
await loadServerConfig(yamlPath);
|
||||
expect.unreachable();
|
||||
} catch (error) {
|
||||
expect((error as Error).message).toContain("无效的 size 格式");
|
||||
} finally {
|
||||
await rm(yamlPath, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("非法 rotation.frequency 抛出错误", async () => {
|
||||
const temp = tmpdir();
|
||||
const yamlPath = join(temp, "bad-freq.yaml");
|
||||
await writeFile(yamlPath, 'server:\n logging:\n file:\n rotation:\n frequency: "yearly"\n');
|
||||
|
||||
try {
|
||||
await loadServerConfig(yamlPath);
|
||||
expect.unreachable();
|
||||
} catch (error) {
|
||||
expect((error as Error).message).toContain("rotation.frequency");
|
||||
} finally {
|
||||
await rm(yamlPath, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("非法 rotation.maxFiles 抛出错误", async () => {
|
||||
const temp = tmpdir();
|
||||
const yamlPath = join(temp, "bad-max.yaml");
|
||||
await writeFile(yamlPath, "server:\n logging:\n file:\n rotation:\n maxFiles: 0\n");
|
||||
|
||||
try {
|
||||
await loadServerConfig(yamlPath);
|
||||
expect.unreachable();
|
||||
} catch (error) {
|
||||
expect((error as Error).message).toContain("maxFiles");
|
||||
} finally {
|
||||
await rm(yamlPath, { force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
171
tests/server/config/schema.test.ts
Normal file
171
tests/server/config/schema.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { createAuthoringConfigSchema, createNormalizedConfigSchema } from "../../../src/server/config/schema/builder";
|
||||
import { createConfigJsonSchema } from "../../../src/server/config/schema/export";
|
||||
import {
|
||||
createConfigAjv,
|
||||
issuesFromAjvErrors,
|
||||
validateConfigContract,
|
||||
} from "../../../src/server/config/schema/validate";
|
||||
|
||||
describe("导出 schema 生成", () => {
|
||||
test("createConfigJsonSchema 返回有效 JSON Schema", () => {
|
||||
const schema = createConfigJsonSchema();
|
||||
expect(schema["$schema"]).toBe("http://json-schema.org/draft-07/schema#");
|
||||
expect(schema["$id"]).toBe("https://app.local/config.schema.json");
|
||||
expect(schema["type"]).toBe("object");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Authoring schema 校验", () => {
|
||||
const ajv = createConfigAjv();
|
||||
const validate = ajv.compile(createAuthoringConfigSchema());
|
||||
|
||||
test("接受空对象", () => {
|
||||
expect(validate({})).toBe(true);
|
||||
});
|
||||
|
||||
test("接受新布局 server.listen", () => {
|
||||
expect(validate({ server: { listen: { host: "127.0.0.1", port: 3000 } } })).toBe(true);
|
||||
});
|
||||
|
||||
test("接受变量引用语法", () => {
|
||||
expect(validate({ server: { listen: { port: "${PORT|3000}" } } })).toBe(true);
|
||||
});
|
||||
|
||||
test("接受 variables 字段", () => {
|
||||
expect(validate({ variables: { HOST: "127.0.0.1" } })).toBe(true);
|
||||
});
|
||||
|
||||
test("接受 server.storage.dataDir", () => {
|
||||
expect(validate({ server: { storage: { dataDir: "./data" } } })).toBe(true);
|
||||
});
|
||||
|
||||
test("接受 server.logging 合法配置", () => {
|
||||
expect(
|
||||
validate({
|
||||
server: {
|
||||
logging: {
|
||||
console: { level: "debug" },
|
||||
file: {
|
||||
level: "warn",
|
||||
path: "/var/log/app.log",
|
||||
rotation: { frequency: "daily", maxFiles: 14, size: "50MB" },
|
||||
},
|
||||
level: "info",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("接受 server.logging.level 变量引用", () => {
|
||||
expect(validate({ server: { logging: { level: "${LOG_LEVEL|info}" } } })).toBe(true);
|
||||
});
|
||||
|
||||
test("拒绝 server.logging 中未知字段", () => {
|
||||
expect(validate({ server: { logging: { unknownField: true } } })).toBe(false);
|
||||
});
|
||||
|
||||
test("拒绝 server.logging.level 非法枚举值", () => {
|
||||
expect(validate({ server: { logging: { level: "verbose" } } })).toBe(false);
|
||||
});
|
||||
|
||||
test("拒绝 unknown 字段 server.host", () => {
|
||||
expect(validate({ server: { host: "127.0.0.1" } })).toBe(false);
|
||||
const issues = issuesFromAjvErrors(validate.errors ?? [], {});
|
||||
expect(issues.some((i) => i.code === "unknown-field")).toBe(true);
|
||||
});
|
||||
|
||||
test("拒绝未知字段 server.port", () => {
|
||||
expect(validate({ server: { port: 3000 } })).toBe(false);
|
||||
const issues = issuesFromAjvErrors(validate.errors ?? [], {});
|
||||
expect(issues.some((i) => i.code === "unknown-field")).toBe(true);
|
||||
});
|
||||
|
||||
test("拒绝非法类型 port", () => {
|
||||
expect(validate({ server: { listen: { port: "not-a-number" } } })).toBe(false);
|
||||
});
|
||||
|
||||
test("拒绝超出范围的 port", () => {
|
||||
expect(validate({ server: { listen: { port: 70000 } } })).toBe(false);
|
||||
});
|
||||
|
||||
test("拒绝负数 port", () => {
|
||||
expect(validate({ server: { listen: { port: -1 } } })).toBe(false);
|
||||
});
|
||||
|
||||
test("拒绝顶层未知字段", () => {
|
||||
expect(validate({ unknown: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Normalized schema 校验", () => {
|
||||
const ajv = createConfigAjv();
|
||||
const validate = ajv.compile(createNormalizedConfigSchema());
|
||||
|
||||
test("接受新布局 server.listen", () => {
|
||||
expect(validate({ server: { listen: { host: "127.0.0.1", port: 3000 } } })).toBe(true);
|
||||
});
|
||||
|
||||
test("Normalized 不接受 variables 字段", () => {
|
||||
expect(validate({ variables: { HOST: "127.0.0.1" } })).toBe(false);
|
||||
});
|
||||
|
||||
test("Normalized 不接受变量引用语法", () => {
|
||||
expect(validate({ server: { listen: { port: "${PORT|3000}" } } })).toBe(false);
|
||||
});
|
||||
|
||||
test("接受 server.storage.dataDir", () => {
|
||||
expect(validate({ server: { storage: { dataDir: "./data" } } })).toBe(true);
|
||||
});
|
||||
|
||||
test("接受 server.logging 合法配置", () => {
|
||||
expect(
|
||||
validate({
|
||||
server: {
|
||||
logging: {
|
||||
console: { level: "debug" },
|
||||
file: {
|
||||
level: "warn",
|
||||
path: "/var/log/app.log",
|
||||
rotation: { frequency: "daily", maxFiles: 14, size: "50MB" },
|
||||
},
|
||||
level: "info",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("接受空对象", () => {
|
||||
expect(validate({})).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateConfigContract", () => {
|
||||
test("有效配置通过校验", () => {
|
||||
const result = validateConfigContract({ server: { listen: { host: "0.0.0.0", port: 8080 } } });
|
||||
expect(result.config).not.toBeNull();
|
||||
});
|
||||
|
||||
test("空配置通过校验", () => {
|
||||
const result = validateConfigContract({});
|
||||
expect(result.config).not.toBeNull();
|
||||
});
|
||||
|
||||
test("包含未知字段的配置被拒绝", () => {
|
||||
const result = validateConfigContract({ server: { host: "bad" } });
|
||||
expect(result.config).toBeNull();
|
||||
expect(result.issues.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("schema 同步测试", () => {
|
||||
test("config.schema.json 与 createConfigJsonSchema() 输出一致", async () => {
|
||||
const file = Bun.file("config.schema.json");
|
||||
const existing = await file.text();
|
||||
const generated = `${JSON.stringify(createConfigJsonSchema(), null, 2)}\n`;
|
||||
expect(existing).toBe(generated);
|
||||
});
|
||||
});
|
||||
171
tests/server/config/variables.test.ts
Normal file
171
tests/server/config/variables.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { extractVariables, resolveVariables } from "../../../src/server/config/variables";
|
||||
|
||||
describe("extractVariables", () => {
|
||||
test("空对象返回空 variables", () => {
|
||||
const result = extractVariables({});
|
||||
expect(result.variables.size).toBe(0);
|
||||
expect(result.issues.length).toBe(0);
|
||||
});
|
||||
|
||||
test("无 variables 字段返回空", () => {
|
||||
const result = extractVariables({ server: {} });
|
||||
expect(result.variables.size).toBe(0);
|
||||
});
|
||||
|
||||
test("variables 非对象报错", () => {
|
||||
const result = extractVariables({ variables: "bad" });
|
||||
expect(result.issues.length).toBe(1);
|
||||
expect(result.issues[0]!.code).toBe("invalid-type");
|
||||
});
|
||||
|
||||
test("提取有效变量", () => {
|
||||
const result = extractVariables({ variables: { HOST: "127.0.0.1", PORT: 3000 } });
|
||||
expect(result.variables.get("HOST")).toBe("127.0.0.1");
|
||||
expect(result.variables.get("PORT")).toBe(3000);
|
||||
});
|
||||
|
||||
test("无效变量名报错", () => {
|
||||
const result = extractVariables({ variables: { "123bad": "val" } });
|
||||
expect(result.issues.length).toBe(1);
|
||||
expect(result.issues[0]!.code).toBe("invalid-format");
|
||||
});
|
||||
|
||||
test("null 值报错", () => {
|
||||
const result = extractVariables({ variables: { KEY: null } });
|
||||
expect(result.issues.length).toBe(1);
|
||||
expect(result.issues[0]!.code).toBe("invalid-type");
|
||||
});
|
||||
|
||||
test("数组值报错", () => {
|
||||
const result = extractVariables({ variables: { KEY: [1, 2] } });
|
||||
expect(result.issues.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveVariables", () => {
|
||||
test("${KEY} 从 variables 解析", () => {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { host: "${MY_HOST}" } },
|
||||
variables: { MY_HOST: "0.0.0.0" },
|
||||
});
|
||||
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
|
||||
const listen = server["listen"] as Record<string, unknown>;
|
||||
expect(listen["host"]).toBe("0.0.0.0");
|
||||
});
|
||||
|
||||
test("${KEY|default} 使用默认值", () => {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { host: "${MY_HOST|0.0.0.0}" } },
|
||||
});
|
||||
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
|
||||
const listen = server["listen"] as Record<string, unknown>;
|
||||
expect(listen["host"]).toBe("0.0.0.0");
|
||||
});
|
||||
|
||||
test("${KEY|} 空默认值", () => {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { host: "${MY_HOST|}" } },
|
||||
});
|
||||
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
|
||||
const listen = server["listen"] as Record<string, unknown>;
|
||||
expect(listen["host"]).toBe("");
|
||||
});
|
||||
|
||||
test("$${KEY} 转义不解析", () => {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { host: "$${NOT_A_VAR}" } },
|
||||
});
|
||||
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
|
||||
const listen = server["listen"] as Record<string, unknown>;
|
||||
expect(listen["host"]).toBe("${NOT_A_VAR}");
|
||||
});
|
||||
|
||||
test("variables 优先于 process.env", () => {
|
||||
const prev = process.env["TEST_PRIORITY"];
|
||||
process.env["TEST_PRIORITY"] = "from-env";
|
||||
try {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { host: "${TEST_PRIORITY}" } },
|
||||
variables: { TEST_PRIORITY: "from-var" },
|
||||
});
|
||||
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
|
||||
const listen = server["listen"] as Record<string, unknown>;
|
||||
expect(listen["host"]).toBe("from-var");
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env["TEST_PRIORITY"];
|
||||
else process.env["TEST_PRIORITY"] = prev;
|
||||
}
|
||||
});
|
||||
|
||||
test("process.env fallback", () => {
|
||||
const prev = process.env["TEST_FALLBACK"];
|
||||
process.env["TEST_FALLBACK"] = "from-env";
|
||||
try {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { host: "${TEST_FALLBACK}" } },
|
||||
});
|
||||
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
|
||||
const listen = server["listen"] as Record<string, unknown>;
|
||||
expect(listen["host"]).toBe("from-env");
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env["TEST_FALLBACK"];
|
||||
else process.env["TEST_FALLBACK"] = prev;
|
||||
}
|
||||
});
|
||||
|
||||
test("完整引用保留类型 - number", () => {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { port: "${PORT|3000}" } },
|
||||
});
|
||||
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
|
||||
const listen = server["listen"] as Record<string, unknown>;
|
||||
expect(listen["port"]).toBe(3000);
|
||||
expect(typeof listen["port"]).toBe("number");
|
||||
});
|
||||
|
||||
test("完整引用保留类型 - boolean", () => {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { host: "${FLAG|false}" } },
|
||||
});
|
||||
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
|
||||
const listen = server["listen"] as Record<string, unknown>;
|
||||
expect(listen["host"]).toBe(false);
|
||||
});
|
||||
|
||||
test("部分插值转为 string", () => {
|
||||
const prev = process.env["PARTIAL_HOST"];
|
||||
process.env["PARTIAL_HOST"] = "192.168";
|
||||
try {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { host: "prefix-${PARTIAL_HOST}-suffix" } },
|
||||
});
|
||||
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
|
||||
const listen = server["listen"] as Record<string, unknown>;
|
||||
expect(listen["host"]).toBe("prefix-192.168-suffix");
|
||||
expect(typeof listen["host"]).toBe("string");
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env["PARTIAL_HOST"];
|
||||
else process.env["PARTIAL_HOST"] = prev;
|
||||
}
|
||||
});
|
||||
|
||||
test("unresolved-variable 报错", () => {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { host: "${UNDEFINED_VAR}" } },
|
||||
});
|
||||
expect(result.issues.length).toBe(1);
|
||||
expect(result.issues[0]!.code).toBe("unresolved-variable");
|
||||
expect(result.issues[0]!.message).toContain("UNDEFINED_VAR");
|
||||
});
|
||||
|
||||
test("variables 段被移除", () => {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { host: "test" } },
|
||||
variables: { KEY: "val" },
|
||||
});
|
||||
const config = result.config as Record<string, unknown>;
|
||||
expect(config["variables"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
117
tests/server/logger.test.ts
Normal file
117
tests/server/logger.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import type { Logger } from "../../src/server/logger";
|
||||
|
||||
import { createConsoleFallback, createMemoryLogger, createNoopLogger, REDACT_PATHS } from "../../src/server/logger";
|
||||
|
||||
describe("NoopLogger", () => {
|
||||
test("所有方法不抛异常", () => {
|
||||
const logger = createNoopLogger();
|
||||
logger.trace("trace");
|
||||
logger.debug("debug");
|
||||
logger.info("info");
|
||||
logger.warn("warn");
|
||||
logger.error("error");
|
||||
logger.fatal("fatal");
|
||||
logger.flush();
|
||||
const child = logger.child({ component: "test" });
|
||||
expect(child).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryLogger", () => {
|
||||
test("记录所有等级日志", () => {
|
||||
const logger = createMemoryLogger();
|
||||
logger.trace("trace-msg");
|
||||
logger.debug("debug-msg");
|
||||
logger.info("info-msg");
|
||||
logger.warn("warn-msg");
|
||||
logger.error("error-msg");
|
||||
logger.fatal("fatal-msg");
|
||||
|
||||
expect(logger.entries).toHaveLength(6);
|
||||
expect(logger.entries[0]).toEqual({ level: "trace", msg: "trace-msg" });
|
||||
expect(logger.entries[5]).toEqual({ level: "fatal", msg: "fatal-msg" });
|
||||
});
|
||||
|
||||
test("记录结构化日志", () => {
|
||||
const logger = createMemoryLogger();
|
||||
logger.info({ matched: true, targetId: "abc" }, "check complete");
|
||||
|
||||
expect(logger.entries).toHaveLength(1);
|
||||
expect(logger.entries[0]!.level).toBe("info");
|
||||
expect(logger.entries[0]!.msg).toBe("check complete");
|
||||
expect(logger.entries[0]!.obj).toEqual({ matched: true, targetId: "abc" });
|
||||
});
|
||||
|
||||
test("child 返回自身", () => {
|
||||
const logger = createMemoryLogger();
|
||||
const child = logger.child({ component: "test" });
|
||||
child.info("child-msg");
|
||||
expect(logger.entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("flush 不抛异常", () => {
|
||||
const logger = createMemoryLogger();
|
||||
logger.flush();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConsoleFallbackLogger", () => {
|
||||
test("不抛异常", () => {
|
||||
const logger = createConsoleFallback();
|
||||
logger.trace("trace");
|
||||
logger.debug("debug");
|
||||
logger.info("info");
|
||||
logger.warn("warn");
|
||||
logger.error("error");
|
||||
logger.fatal("fatal");
|
||||
logger.flush();
|
||||
const child = logger.child({ component: "test" });
|
||||
expect(child).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Logger 接口契约", () => {
|
||||
function assertLogger(logger: Logger): void {
|
||||
logger.trace("trace");
|
||||
logger.debug("debug");
|
||||
logger.info("info");
|
||||
logger.warn("warn");
|
||||
logger.error("error");
|
||||
logger.fatal("fatal");
|
||||
logger.info({ key: "value" }, "structured");
|
||||
logger.child({ component: "test" }).info("child");
|
||||
logger.flush();
|
||||
}
|
||||
|
||||
test("NoopLogger 满足 Logger 接口", () => {
|
||||
expect(() => assertLogger(createNoopLogger())).not.toThrow();
|
||||
});
|
||||
|
||||
test("MemoryLogger 满足 Logger 接口", () => {
|
||||
expect(() => assertLogger(createMemoryLogger())).not.toThrow();
|
||||
});
|
||||
|
||||
test("ConsoleFallbackLogger 满足 Logger 接口", () => {
|
||||
expect(() => assertLogger(createConsoleFallback())).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("redaction 敏感信息保护", () => {
|
||||
test("MemoryLogger 不做 redaction(测试用途,仅 Pino 运行时 redact)", () => {
|
||||
const logger = createMemoryLogger();
|
||||
logger.info({ authorization: "Bearer secret", password: "hunter2" }, "test");
|
||||
const entry = logger.entries[0]!;
|
||||
expect(entry.obj!["authorization"]).toBe("Bearer secret");
|
||||
expect(entry.obj!["password"]).toBe("hunter2");
|
||||
});
|
||||
|
||||
test("REDACT_PATHS 覆盖所有敏感字段键名", () => {
|
||||
const sensitiveKeys = ["authorization", "cookie", "set-cookie", "authToken", "key", "password", "token", "apiKey"];
|
||||
for (const key of sensitiveKeys) {
|
||||
expect(REDACT_PATHS).toContain(key);
|
||||
expect(REDACT_PATHS).toContain(`*.${key}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
97
tests/server/middleware.test.ts
Normal file
97
tests/server/middleware.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { validateIdParam, validatePagination, validateTimeRange } from "../../src/server/middleware";
|
||||
|
||||
describe("validateIdParam", () => {
|
||||
test("有效的 ID 返回字符串", () => {
|
||||
const result = validateIdParam("api-health_01", "production");
|
||||
expect(result).not.toHaveProperty("status");
|
||||
expect((result as { id: string }).id).toBe("api-health_01");
|
||||
});
|
||||
|
||||
test("无效的 ID 返回 400", () => {
|
||||
const invalid = ["-1", "_abc", "has space", "1.5", ""];
|
||||
|
||||
for (const id of invalid) {
|
||||
const result = validateIdParam(id, "production");
|
||||
expect(result).toHaveProperty("status", 400);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateTimeRange", () => {
|
||||
test("有效的 from/to 返回 ISO 字符串", () => {
|
||||
const result = validateTimeRange("2024-01-01T00:00:00.000Z", "2024-01-02T00:00:00.000Z", "production");
|
||||
expect(result).not.toHaveProperty("status");
|
||||
expect((result as { from: string; to: string }).from).toBe("2024-01-01T00:00:00.000Z");
|
||||
expect((result as { from: string; to: string }).to).toBe("2024-01-02T00:00:00.000Z");
|
||||
});
|
||||
|
||||
test("缺失 from 或 to 返回 400", () => {
|
||||
const missingFrom = validateTimeRange(null, "2024-01-02T00:00:00.000Z", "production");
|
||||
const missingTo = validateTimeRange("2024-01-01T00:00:00.000Z", null, "production");
|
||||
const missingBoth = validateTimeRange(null, null, "production");
|
||||
|
||||
expect(missingFrom).toHaveProperty("status", 400);
|
||||
expect(missingTo).toHaveProperty("status", 400);
|
||||
expect(missingBoth).toHaveProperty("status", 400);
|
||||
});
|
||||
|
||||
test("空字符串 from 或 to 返回 400", () => {
|
||||
const emptyFrom = validateTimeRange("", "2024-01-02T00:00:00.000Z", "production");
|
||||
const emptyTo = validateTimeRange("2024-01-01T00:00:00.000Z", "", "production");
|
||||
|
||||
expect(emptyFrom).toHaveProperty("status", 400);
|
||||
expect(emptyTo).toHaveProperty("status", 400);
|
||||
});
|
||||
|
||||
test("无效的日期格式返回 400", () => {
|
||||
const result = validateTimeRange("invalid-date", "2024-01-02T00:00:00.000Z", "production");
|
||||
expect(result).toHaveProperty("status", 400);
|
||||
});
|
||||
|
||||
test("from 晚于 to 返回 400", () => {
|
||||
const result = validateTimeRange("2024-01-02T00:00:00.000Z", "2024-01-01T00:00:00.000Z", "production");
|
||||
expect(result).toHaveProperty("status", 400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validatePagination", () => {
|
||||
test("默认值:page=1, pageSize=20", () => {
|
||||
const result = validatePagination(null, null, "production");
|
||||
expect(result).toEqual({ page: 1, pageSize: 20 });
|
||||
});
|
||||
|
||||
test("有效的 page 和 pageSize 参数", () => {
|
||||
const result = validatePagination("2", "50", "production");
|
||||
expect(result).toEqual({ page: 2, pageSize: 50 });
|
||||
});
|
||||
|
||||
test("无效的 page 参数返回 400", () => {
|
||||
const invalidPage = ["0", "-1", "abc", "1.5"];
|
||||
|
||||
for (const page of invalidPage) {
|
||||
const result = validatePagination(page, "20", "production");
|
||||
expect(result).toHaveProperty("status", 400);
|
||||
}
|
||||
});
|
||||
|
||||
test("无效的 pageSize 参数返回 400", () => {
|
||||
const invalidPageSize = ["0", "-1", "abc", "1.5"];
|
||||
|
||||
for (const pageSize of invalidPageSize) {
|
||||
const result = validatePagination("1", pageSize, "production");
|
||||
expect(result).toHaveProperty("status", 400);
|
||||
}
|
||||
});
|
||||
|
||||
test("pageSize 超过上限返回 400", () => {
|
||||
const result = validatePagination("1", "201", "production");
|
||||
expect(result).toHaveProperty("status", 400);
|
||||
});
|
||||
|
||||
test("pageSize 等于上限 200 返回成功", () => {
|
||||
const result = validatePagination("1", "200", "production");
|
||||
expect(result).toEqual({ page: 1, pageSize: 200 });
|
||||
});
|
||||
});
|
||||
127
tests/server/static.test.ts
Normal file
127
tests/server/static.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
contentTypeFor,
|
||||
hasFileExtension,
|
||||
htmlResponse,
|
||||
serveStaticAsset,
|
||||
type StaticAssets,
|
||||
} from "../../src/server/static";
|
||||
|
||||
function createTestAssets(): StaticAssets {
|
||||
return {
|
||||
files: {
|
||||
"/assets/index-a1b2c3.css": new Blob([".app{}"], { type: "text/css" }),
|
||||
"/assets/index-a1b2c3.js": new Blob(["console.log(1)"], { type: "text/javascript" }),
|
||||
"/assets/vendor-react-x9y8z7.js": new Blob(["react"], { type: "text/javascript" }),
|
||||
"/favicon.svg": new Blob(["<svg/>"], { type: "image/svg+xml" }),
|
||||
},
|
||||
indexHtml: new Blob(["<!doctype html><html></html>"], { type: "text/html" }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("contentTypeFor", () => {
|
||||
test("JavaScript 文件", () => {
|
||||
expect(contentTypeFor("/assets/index-a1b2c3.js")).toBe("text/javascript; charset=utf-8");
|
||||
});
|
||||
|
||||
test("mjs 文件", () => {
|
||||
expect(contentTypeFor("/assets/chunk.mjs")).toBe("text/javascript; charset=utf-8");
|
||||
});
|
||||
|
||||
test("CSS 文件", () => {
|
||||
expect(contentTypeFor("/assets/style.css")).toBe("text/css; charset=utf-8");
|
||||
});
|
||||
|
||||
test("SVG 文件", () => {
|
||||
expect(contentTypeFor("/icon.svg")).toBe("image/svg+xml");
|
||||
});
|
||||
|
||||
test("未知扩展名返回 octet-stream", () => {
|
||||
expect(contentTypeFor("/file.xyz")).toBe("application/octet-stream");
|
||||
});
|
||||
|
||||
test("无扩展名返回 octet-stream", () => {
|
||||
expect(contentTypeFor("/noext")).toBe("application/octet-stream");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasFileExtension", () => {
|
||||
test("有扩展名", () => {
|
||||
expect(hasFileExtension("/assets/index.js")).toBe(true);
|
||||
expect(hasFileExtension("/favicon.svg")).toBe(true);
|
||||
});
|
||||
|
||||
test("无扩展名", () => {
|
||||
expect(hasFileExtension("/dashboard")).toBe(false);
|
||||
expect(hasFileExtension("/")).toBe(false);
|
||||
expect(hasFileExtension("/api/targets")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("htmlResponse", () => {
|
||||
test("返回 HTML 响应带正确 headers", async () => {
|
||||
const blob = new Blob(["<html></html>"]);
|
||||
const response = htmlResponse(blob);
|
||||
|
||||
expect(response.headers.get("Content-Type")).toBe("text/html; charset=utf-8");
|
||||
expect(response.headers.get("Cache-Control")).toBe("no-cache");
|
||||
expect(await response.text()).toBe("<html></html>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("serveStaticAsset", () => {
|
||||
test("根路径返回 indexHtml", async () => {
|
||||
const assets = createTestAssets();
|
||||
const response = serveStaticAsset("/", assets);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("text/html; charset=utf-8");
|
||||
expect(response.headers.get("Cache-Control")).toBe("no-cache");
|
||||
expect(await response.text()).toBe("<!doctype html><html></html>");
|
||||
});
|
||||
|
||||
test("已知资源返回对应文件和 immutable 缓存", async () => {
|
||||
const assets = createTestAssets();
|
||||
const response = serveStaticAsset("/assets/index-a1b2c3.js", assets);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("text/javascript; charset=utf-8");
|
||||
expect(response.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
|
||||
expect(await response.text()).toBe("console.log(1)");
|
||||
});
|
||||
|
||||
test("未知带扩展名路径返回 404", () => {
|
||||
const assets = createTestAssets();
|
||||
const response = serveStaticAsset("/assets/missing.js", assets);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
test("SPA fallback — 无扩展名路径返回 indexHtml", async () => {
|
||||
const assets = createTestAssets();
|
||||
const response = serveStaticAsset("/dashboard", assets);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("text/html; charset=utf-8");
|
||||
expect(response.headers.get("Cache-Control")).toBe("no-cache");
|
||||
expect(await response.text()).toBe("<!doctype html><html></html>");
|
||||
});
|
||||
|
||||
test("SVG 资源返回正确 Content-Type", () => {
|
||||
const assets = createTestAssets();
|
||||
const response = serveStaticAsset("/favicon.svg", assets);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("image/svg+xml");
|
||||
expect(response.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
|
||||
});
|
||||
|
||||
test("CSS 资源返回正确 Content-Type", () => {
|
||||
const assets = createTestAssets();
|
||||
const response = serveStaticAsset("/assets/index-a1b2c3.css", assets);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("text/css; charset=utf-8");
|
||||
});
|
||||
});
|
||||
106
tests/setup.ts
Normal file
106
tests/setup.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 全局测试配置
|
||||
* 主要为后端测试提供基础环境
|
||||
* 组件测试使用各自的 test-utils.tsx
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-empty-function */
|
||||
// Set up jsdom for ALL tests (both backend and frontend)
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost",
|
||||
});
|
||||
|
||||
globalThis.document = dom.window.document;
|
||||
globalThis.window = dom.window as unknown as typeof globalThis & Window;
|
||||
globalThis.navigator = dom.window.navigator;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.getComputedStyle = dom.window.getComputedStyle;
|
||||
|
||||
// Ensure document.body exists
|
||||
if (!globalThis.document.body) {
|
||||
const body = globalThis.document.createElement("body");
|
||||
globalThis.document.documentElement.appendChild(body);
|
||||
}
|
||||
|
||||
// CRITICAL: Set up polyfills BEFORE any other imports
|
||||
// This ensures @testing-library/react sees these when it loads
|
||||
|
||||
// IE-style event handling polyfill (React fallback)
|
||||
const nodeProto = dom.window.Node.prototype;
|
||||
const elementProto = dom.window.Element.prototype;
|
||||
const htmlElementProto = dom.window.HTMLElement.prototype;
|
||||
|
||||
const attachEventFn = () => {};
|
||||
const detachEventFn = () => {};
|
||||
|
||||
Object.defineProperty(nodeProto, "attachEvent", { configurable: true, value: attachEventFn, writable: true });
|
||||
Object.defineProperty(nodeProto, "detachEvent", { configurable: true, value: detachEventFn, writable: true });
|
||||
Object.defineProperty(elementProto, "attachEvent", { configurable: true, value: attachEventFn, writable: true });
|
||||
Object.defineProperty(elementProto, "detachEvent", { configurable: true, value: detachEventFn, writable: true });
|
||||
Object.defineProperty(htmlElementProto, "attachEvent", { configurable: true, value: attachEventFn, writable: true });
|
||||
Object.defineProperty(htmlElementProto, "detachEvent", { configurable: true, value: detachEventFn, writable: true });
|
||||
|
||||
// Other polyfills
|
||||
globalThis.ResizeObserver = class {
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
};
|
||||
|
||||
globalThis.MutationObserver = class {
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
unobserve() {}
|
||||
};
|
||||
|
||||
globalThis.IntersectionObserver = class {
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
unobserve() {}
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
|
||||
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => setTimeout(cb, 16);
|
||||
globalThis.cancelAnimationFrame = (id: number) => clearTimeout(id);
|
||||
|
||||
Object.defineProperty(dom.window, "matchMedia", {
|
||||
value: (query: string) => ({
|
||||
addEventListener: () => {},
|
||||
addListener: () => {},
|
||||
dispatchEvent: () => true,
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
removeEventListener: () => {},
|
||||
removeListener: () => {},
|
||||
}),
|
||||
writable: true,
|
||||
});
|
||||
|
||||
dom.window.Element.prototype.scrollTo = () => {};
|
||||
dom.window.Element.prototype.scrollIntoView = () => {};
|
||||
|
||||
Object.defineProperty(dom.window, "customElements", {
|
||||
value: {
|
||||
define: () => {},
|
||||
get: () => undefined,
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
globalThis.customElements = dom.window.customElements;
|
||||
|
||||
import { afterEach } from "bun:test";
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
64
tests/web/App.test.tsx
Normal file
64
tests/web/App.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
/* eslint-disable @typescript-eslint/require-await */
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
|
||||
import { APP } from "../../src/shared/app";
|
||||
import { App } from "../../src/web/app";
|
||||
import { renderWithProviders } from "./test-utils";
|
||||
|
||||
describe("App", () => {
|
||||
test("渲染 Layout 骨架和品牌名", () => {
|
||||
window.fetch = (async () => {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: 200,
|
||||
},
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
renderWithProviders(createElement(App));
|
||||
|
||||
expect(screen.getByText(APP.title)).not.toBeNull();
|
||||
expect(screen.getByText("系统")).not.toBeNull();
|
||||
expect(screen.getByText("明亮")).not.toBeNull();
|
||||
expect(screen.getByText("黑暗")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("渲染侧边栏菜单项", () => {
|
||||
window.fetch = (async () => {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: 200,
|
||||
},
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
renderWithProviders(createElement(App));
|
||||
|
||||
expect(screen.getAllByText("仪表盘").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("用户管理").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("系统设置").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("Header 不包含侧边栏折叠按钮", () => {
|
||||
window.fetch = (async () => {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: 200,
|
||||
},
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
renderWithProviders(createElement(App));
|
||||
|
||||
const toggleButtons = document.querySelectorAll(".app-sidebar-toggle");
|
||||
expect(toggleButtons.length).toBe(0);
|
||||
});
|
||||
});
|
||||
68
tests/web/components/Sidebar/index.test.tsx
Normal file
68
tests/web/components/Sidebar/index.test.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
/* eslint-disable @typescript-eslint/no-empty-function */
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
|
||||
import { Sidebar } from "../../../../src/web/components/Sidebar";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
|
||||
describe("Sidebar", () => {
|
||||
test("渲染菜单项", () => {
|
||||
renderWithProviders(createElement(Sidebar, { collapsed: false, onToggleCollapsed: () => {} }));
|
||||
|
||||
expect(screen.getByText("仪表盘")).not.toBeNull();
|
||||
expect(screen.getByText("用户管理")).not.toBeNull();
|
||||
expect(screen.getByText("系统设置")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("折叠状态下仍渲染菜单项", () => {
|
||||
renderWithProviders(createElement(Sidebar, { collapsed: true, onToggleCollapsed: () => {} }));
|
||||
|
||||
expect(screen.getByText("仪表盘")).not.toBeNull();
|
||||
expect(screen.getByText("用户管理")).not.toBeNull();
|
||||
expect(screen.getByText("系统设置")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("高亮当前路由对应的菜单项", () => {
|
||||
renderWithProviders(createElement(Sidebar, { collapsed: false, onToggleCollapsed: () => {} }), {
|
||||
initialRoute: "/users",
|
||||
});
|
||||
|
||||
const activeItem = document.querySelector(".t-is-active");
|
||||
expect(activeItem).not.toBeNull();
|
||||
expect(activeItem?.textContent).toContain("用户管理");
|
||||
});
|
||||
|
||||
test("展开态底部渲染折叠按钮", () => {
|
||||
renderWithProviders(createElement(Sidebar, { collapsed: false, onToggleCollapsed: () => {} }));
|
||||
|
||||
const collapseBtn = document.querySelector(".app-sidebar-collapse-btn");
|
||||
expect(collapseBtn).not.toBeNull();
|
||||
});
|
||||
|
||||
test("点击底部按钮调用 onToggleCollapsed", () => {
|
||||
let called = false;
|
||||
const onToggle = () => {
|
||||
called = true;
|
||||
};
|
||||
renderWithProviders(createElement(Sidebar, { collapsed: false, onToggleCollapsed: onToggle }));
|
||||
|
||||
const btn = document.querySelector<HTMLButtonElement>(".app-sidebar-collapse-btn");
|
||||
expect(btn).not.toBeNull();
|
||||
btn!.click();
|
||||
expect(called).toBe(true);
|
||||
});
|
||||
|
||||
test("折叠态底部按钮仍渲染且菜单项高亮不变", () => {
|
||||
renderWithProviders(createElement(Sidebar, { collapsed: true, onToggleCollapsed: () => {} }), {
|
||||
initialRoute: "/users",
|
||||
});
|
||||
|
||||
const collapseBtn = document.querySelector(".app-sidebar-collapse-btn");
|
||||
expect(collapseBtn).not.toBeNull();
|
||||
|
||||
const activeItem = document.querySelector(".t-is-active");
|
||||
expect(activeItem).not.toBeNull();
|
||||
expect(activeItem?.textContent).toContain("用户管理");
|
||||
});
|
||||
});
|
||||
24
tests/web/routes/404.test.tsx
Normal file
24
tests/web/routes/404.test.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
|
||||
import { NotFoundPage } from "../../../src/web/pages/404";
|
||||
import { renderWithProviders } from "../test-utils";
|
||||
|
||||
describe("NotFoundPage", () => {
|
||||
test("渲染 404 页面", () => {
|
||||
renderWithProviders(createElement(NotFoundPage));
|
||||
|
||||
expect(screen.getByText("404")).not.toBeNull();
|
||||
expect(screen.getByText("您访问的页面不存在")).not.toBeNull();
|
||||
expect(screen.getByText("返回首页")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("返回首页按钮存在且可点击", () => {
|
||||
renderWithProviders(createElement(NotFoundPage));
|
||||
|
||||
const button = screen.getByText("返回首页");
|
||||
expect(button).not.toBeNull();
|
||||
expect(button.closest("button")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user