From a90a966130568f850f0dbf6c0195a2daa19e52b7 Mon Sep 17 00:00:00 2001 From: LAP87 Date: Sat, 25 Jul 2026 09:14:36 +0200 Subject: [PATCH 1/5] feat: add Linux-hosted Win64 build workflow --- .github/workflows/build.yml | 94 ++ .gitignore | 4 +- CMakeLists.txt | 28 +- CMakePresets.json | 73 ++ README.md | 13 + cmake/toolchains/xwin-clang-cl.cmake | 31 + docs/linux/building-win64.md | 142 +++ docs/linux/proton-deployment.md | 50 + docs/plans/2026-07-25-linux-port-plan.md | 1152 ++++++++++++++++++++++ include/Platform/Export.h | 13 + include/SDK/Classes/TSoftObjectPtr.h | 10 +- include/SDK/Helper/PropertyHelper.h | 5 +- scripts/bootstrap-linux.sh | 222 +++++ scripts/build-linux.sh | 89 ++ scripts/cargo-preserve-lock.sh | 61 ++ scripts/deploy-proton.sh | 236 +++++ scripts/lib/build-env.sh | 23 + scripts/package-linux.sh | 122 +++ scripts/verify-win64-artifact.py | 145 +++ src/dllmain.cpp | 2 +- 20 files changed, 2504 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 CMakePresets.json create mode 100644 cmake/toolchains/xwin-clang-cl.cmake create mode 100644 docs/linux/building-win64.md create mode 100644 docs/linux/proton-deployment.md create mode 100644 docs/plans/2026-07-25-linux-port-plan.md create mode 100644 include/Platform/Export.h create mode 100755 scripts/bootstrap-linux.sh create mode 100755 scripts/build-linux.sh create mode 100755 scripts/cargo-preserve-lock.sh create mode 100755 scripts/deploy-proton.sh create mode 100644 scripts/lib/build-env.sh create mode 100755 scripts/package-linux.sh create mode 100755 scripts/verify-win64-artifact.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..78a02e9 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,94 @@ +name: Build and verify + +on: + push: + branches: + - main + - "codex/**" + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + public-checks: + name: Public source checks + runs-on: ubuntu-latest + steps: + - name: Checkout public source + uses: actions/checkout@v4 + with: + submodules: false + + - name: Check shell syntax + run: | + bash -n \ + scripts/bootstrap-linux.sh \ + scripts/build-linux.sh \ + scripts/cargo-preserve-lock.sh \ + scripts/deploy-proton.sh \ + scripts/package-linux.sh \ + scripts/lib/build-env.sh + + - name: Check Python syntax + run: python -m py_compile scripts/verify-win64-artifact.py + + - name: Check JSON + run: python -m json.tool CMakePresets.json > /dev/null + + windows-msvc-shipping: + name: Windows MSVC Shipping baseline + if: >- + vars.PRIVATE_SUBMODULES_AVAILABLE == 'true' && + (github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository) + runs-on: windows-latest + defaults: + run: + shell: pwsh + steps: + - name: Checkout authorized recursive dependencies + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + token: ${{ secrets.UEPSEUDO_TOKEN }} + + - name: Set up MSVC + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: x64 + + - name: Add LLVM tools to PATH + run: | + $llvmPath = "C:\Program Files\LLVM\bin" + if (-not (Test-Path $llvmPath)) { + throw "LLVM was not found at $llvmPath" + } + $llvmPath | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - name: Install Rust target + run: rustup target add x86_64-pc-windows-msvc + + - name: Configure + run: cmake --preset win64-msvc-ci-shipping + + - name: Build PalSchema + run: cmake --build --preset win64-msvc-ci-shipping --target PalSchema + + - name: Verify PE contract + run: >- + python scripts/verify-win64-artifact.py + build/win64-msvc-ci-shipping/PalSchema.dll + --json-output build/win64-msvc-ci-shipping/pe-contract.json + + - name: Upload public build outputs + uses: actions/upload-artifact@v4 + with: + name: PalSchema-Win64-MSVC-Shipping + if-no-files-found: error + retention-days: 7 + path: | + build/win64-msvc-ci-shipping/PalSchema.dll + build/win64-msvc-ci-shipping/pe-contract.json diff --git a/.gitignore b/.gitignore index 70aab4e..c2d58f1 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ RC* # CMake build/ +dist/ +.xwin-cache/ CMakeLists.txt.user CMakeCache.txt CMakeFiles @@ -24,4 +26,4 @@ install_manifest.txt compile_commands.json CTestTestfile.cmake _deps -CMakeUserPresets.json \ No newline at end of file +CMakeUserPresets.json diff --git a/CMakeLists.txt b/CMakeLists.txt index cf5542a..45dc169 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,11 +1,14 @@ cmake_minimum_required(VERSION 3.22) +project(PalSchema LANGUAGES C CXX) + set(CMAKE_CXX_STANDARD 23) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) add_subdirectory(deps) set(TARGET PalSchema) -project(${TARGET}) file(GLOB SRC_FILES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/src/dllmain.cpp" @@ -34,7 +37,26 @@ file(GLOB SRC_FILES CONFIGURE_DEPENDS ) add_library(${TARGET} SHARED ${SRC_FILES}) -target_include_directories(${TARGET} PRIVATE +if(WIN32) + target_sources(${TARGET} PRIVATE version.rc) + + # The RE-UE4SS xwin toolchain configures SDK headers for clang-cl, but + # CMake's llvm-rc preprocessing step needs the include paths separately. + if(CMAKE_CROSSCOMPILING AND DEFINED XWIN_CACHE_DIR) + set_property( + SOURCE version.rc + APPEND PROPERTY INCLUDE_DIRECTORIES + "${XWIN_CACHE_DIR}/crt/include" + "${XWIN_CACHE_DIR}/sdk/include/ucrt" + "${XWIN_CACHE_DIR}/sdk/include/shared" + "${XWIN_CACHE_DIR}/sdk/include/um" + "${XWIN_CACHE_DIR}/sdk/include/winrt" + ) + endif() +endif() + +target_include_directories(${TARGET} PRIVATE include/ ) -target_link_libraries(${TARGET} PRIVATE Zydis Zycore safetyhook nlohmann_json::nlohmann_json glaze::glaze UE4SS efsw) \ No newline at end of file +target_compile_definitions(${TARGET} PRIVATE PALSCHEMA_BUILDING_DLL=1) +target_link_libraries(${TARGET} PRIVATE Zydis Zycore safetyhook nlohmann_json::nlohmann_json glaze::glaze UE4SS efsw) diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..fb6232c --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,73 @@ +{ + "version": 6, + "cmakeMinimumRequired": { + "major": 3, + "minor": 22, + "patch": 0 + }, + "configurePresets": [ + { + "name": "win64-xwin-base", + "hidden": true, + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/${presetName}", + "toolchainFile": "${sourceDir}/cmake/toolchains/xwin-clang-cl.cmake", + "cacheVariables": { + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Linux" + } + }, + { + "name": "win64-xwin-dev", + "displayName": "Win64 Dev (Linux host, xwin + clang-cl)", + "description": "Cross-compile the PalSchema development DLL for Palworld under Proton.", + "inherits": "win64-xwin-base", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Game__Debug__Win64" + } + }, + { + "name": "win64-xwin-shipping", + "displayName": "Win64 Shipping (Linux host, xwin + clang-cl)", + "description": "Cross-compile the PalSchema shipping DLL for Palworld under Proton.", + "inherits": "win64-xwin-base", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Game__Shipping__Win64" + } + }, + { + "name": "win64-msvc-ci-shipping", + "displayName": "Win64 Shipping (Windows CI, MSVC)", + "description": "Build the Windows baseline used to compare the Linux-hosted cross-build.", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/${presetName}", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Game__Shipping__Win64", + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Windows" + } + } + ], + "buildPresets": [ + { + "name": "win64-xwin-dev", + "configurePreset": "win64-xwin-dev" + }, + { + "name": "win64-xwin-shipping", + "configurePreset": "win64-xwin-shipping" + }, + { + "name": "win64-msvc-ci-shipping", + "configurePreset": "win64-msvc-ci-shipping" + } + ] +} diff --git a/README.md b/README.md index b463624..e6d8167 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,19 @@ or with Ninja (single-configuration, faster) cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Game__Shipping__Win64 ``` +## Building the Win64 DLL from Linux + +PalSchema can be cross-compiled on Linux for the Windows Palworld client +running under Proton. The supported path uses xwin, clang-cl, LLD, Ninja, and +the repository-pinned RE-UE4SS toolchain. + +See [Building the Win64 PalSchema DLL on Linux](docs/linux/building-win64.md). +For an atomic install with a recorded rollback path, see +[Safe deployment to the Palworld client under Proton](docs/linux/proton-deployment.md). + +UE4SS remains a separate runtime installation and is not bundled with +PalSchema. + # Mods using PalSchema I'll only include one mod per author to avoid cluttering the list too much. diff --git a/cmake/toolchains/xwin-clang-cl.cmake b/cmake/toolchains/xwin-clang-cl.cmake new file mode 100644 index 0000000..a26ed4a --- /dev/null +++ b/cmake/toolchains/xwin-clang-cl.cmake @@ -0,0 +1,31 @@ +# PalSchema wrapper around the pinned RE-UE4SS xwin toolchain. +# +# Keep the compiler and SDK behavior owned by RE-UE4SS. This wrapper only +# provides a distro-neutral default cache location when XWIN_DIR is unset. + +if(NOT DEFINED ENV{XWIN_DIR} OR "$ENV{XWIN_DIR}" STREQUAL "") + if(DEFINED ENV{XDG_CACHE_HOME} AND NOT "$ENV{XDG_CACHE_HOME}" STREQUAL "") + set(_palschema_xwin_dir "$ENV{XDG_CACHE_HOME}/palschema/xwin") + elseif(DEFINED ENV{HOME} AND NOT "$ENV{HOME}" STREQUAL "") + set(_palschema_xwin_dir "$ENV{HOME}/.cache/palschema/xwin") + else() + message(FATAL_ERROR "Set XWIN_DIR because neither XDG_CACHE_HOME nor HOME is available.") + endif() + + set(ENV{XWIN_DIR} "${_palschema_xwin_dir}") +endif() + +include( + "${CMAKE_CURRENT_LIST_DIR}/../../deps/RE-UE4SS/cmake/toolchains/xwin-clang-cl-toolchain.cmake" +) + +# PalSchema and its pinned RE-UE4SS revision do not use C++ modules. Disabling +# dependency scanning also keeps CMake's nested IPO probe from losing the +# clang-scan-deps path when it creates its temporary cross-build project. +set( + CMAKE_CXX_SCAN_FOR_MODULES + OFF + CACHE BOOL + "Disable C++ module dependency scanning for the PalSchema xwin build" + FORCE +) diff --git a/docs/linux/building-win64.md b/docs/linux/building-win64.md new file mode 100644 index 0000000..0a4b32b --- /dev/null +++ b/docs/linux/building-win64.md @@ -0,0 +1,142 @@ +# Building the Win64 PalSchema DLL on Linux + +Palworld's Steam client is a Windows executable, including when Steam launches +it through Proton. The PalSchema runtime loaded by that client must therefore +remain a Win64 DLL. These instructions build that DLL entirely from Linux. + +UE4SS is a separate runtime installation. PalSchema does not download, install, +or bundle UE4SS. + +## Supported build path + +The canonical toolchain is: + +- CMake 3.22 or newer; +- Ninja; +- LLVM/Clang with `clang-cl`, `lld-link`, `llvm-lib`, `llvm-rc`, and + `llvm-mt`; +- Rust and Cargo; +- [xwin](https://github.com/Jake-Shadle/xwin); +- the repository's pinned RE-UE4SS and UEPseudo submodules. + +The generated DLL is portable across Linux distributions because it targets +the Windows ABI. The build host may use Arch/CachyOS, Debian/Ubuntu, Fedora, +openSUSE, or another distribution that provides the listed tools. + +## Epic and UEPseudo access + +The recursive RE-UE4SS dependency includes the private UEPseudo repository. +Your GitHub account must be linked to Epic Games and must have accepted the +EpicGames organization invitation. + +Initialize the exact commits pinned by PalSchema: + +```bash +git submodule update --init --recursive +``` + +Do not use `--remote`; it would move dependencies away from the reviewed +commits. + +## Bootstrap + +Run the read-only prerequisite check: + +```bash +scripts/bootstrap-linux.sh +``` + +If xwin is missing, it can be installed explicitly: + +```bash +scripts/bootstrap-linux.sh --install-xwin +``` + +RE-UE4SS also builds a Rust dependency for Windows. Some distributions ship +Rust without cross-target standard libraries. Install a project-isolated +toolchain and the required target without replacing the system Rust package: + +```bash +scripts/bootstrap-linux.sh --install-rust-toolchain +``` + +The installer is downloaded from `static.rust-lang.org` and verified against +its official SHA-256 before execution. It uses +`$XDG_CACHE_HOME/palschema/{rustup,cargo}` or +`$HOME/.cache/palschema/{rustup,cargo}` and does not modify shell profiles. + +xwin downloads Microsoft CRT and Windows SDK files. The project never accepts +that license silently. Review the applicable Microsoft terms and then prepare +the SDK with the explicit gate: + +```bash +scripts/bootstrap-linux.sh \ + --prepare-sdk \ + --accept-microsoft-license +``` + +The default SDK cache is: + +- `$XWIN_DIR` when set; +- otherwise `$XDG_CACHE_HOME/palschema/xwin` when set; +- otherwise `$HOME/.cache/palschema/xwin`. + +## Build + +Development build: + +```bash +scripts/build-linux.sh dev +``` + +Shipping build: + +```bash +scripts/build-linux.sh shipping +``` + +Verify the stable PE contract and emit machine-readable metadata: + +```bash +python scripts/verify-win64-artifact.py \ + build/win64-xwin-shipping/PalSchema.dll \ + --json-output build/win64-xwin-shipping/pe-contract.json +``` + +The scripts use `CMakePresets.json`. To configure once through the same +environment setup and then invoke CMake directly: + +```bash +scripts/build-linux.sh dev --configure-only +cmake --build --preset win64-xwin-dev --target PalSchema +``` + +Building the explicit target avoids compiling RE-UE4SS's unrelated example +programs and proxy DLL. The configure helper also selects the project-isolated +Rust toolchain when it is installed, so a distro's system Rust remains +untouched. Newer Cargo releases may normalize RE-UE4SS's pinned lock file for +the enabled feature subset. The helper routes Cargo through a serialized guard +that restores the exact pre-build lock file after every invocation, including +failed or interrupted builds. Build products remain under `build//`; +the development DLL is written to `build/win64-xwin-dev/PalSchema.dll` and the +shipping DLL to `build/win64-xwin-shipping/PalSchema.dll`. + +## Windows CI baseline + +Public pull requests run source-format checks without cloning UEPseudo. The +Windows/MSVC baseline is deliberately opt-in because recursive checkout +requires authorized access to Epic-licensed UEPseudo: + +- repository variable `PRIVATE_SUBMODULES_AVAILABLE=true`; +- repository secret `UEPSEUDO_TOKEN` containing a read-capable GitHub token. + +The private job never uploads source trees. Its artifact contains only +`PalSchema.dll` and the JSON PE contract. Pull requests from forks cannot run +the secret-bearing job. + +## What this does not provide + +This target is for the Windows Palworld client running under Proton. It is not +a native ELF plugin for the Linux Palworld Dedicated Server. Native server +support requires a suitable native UE4SS C++ mod ABI plus Linux-specific +signatures, layouts, hooks, and integration testing. diff --git a/docs/linux/proton-deployment.md b/docs/linux/proton-deployment.md new file mode 100644 index 0000000..b6bbc84 --- /dev/null +++ b/docs/linux/proton-deployment.md @@ -0,0 +1,50 @@ +# Safe deployment to the Palworld client under Proton + +PalSchema is a UE4SS C++ mod. Install UE4SS separately before using these +commands; this repository neither downloads nor bundles it. + +Build and inspect the Shipping DLL first: + +```bash +scripts/build-linux.sh shipping +scripts/deploy-proton.sh shipping --dry-run +``` + +The deploy helper discovers the default Steam library at +`$HOME/.local/share/Steam/steamapps/common/Palworld`. For another Steam library, +pass its Palworld directory explicitly: + +```bash +scripts/deploy-proton.sh shipping \ + --game-dir /mnt/games/SteamLibrary/steamapps/common/Palworld \ + --dry-run +``` + +Remove `--dry-run` to deploy. The helper: + +- verifies the Palworld Win64 executable and a separate `UE4SS.dll`; +- refuses to run while the Windows client is active; +- stages `Mods/PalSchema` before switching it into place; +- preserves the previous installation under + `Pal/Binaries/Win64/ue4ss/.palschema-backups/`; +- prints the exact rollback command. + +It does not modify UE4SS, other mods, the Proton prefix, Palworld saves, or the +native Dedicated Server. + +Use the Dev flavor when schemas, examples, VS Code settings, and PDB symbols +are needed: + +```bash +scripts/deploy-proton.sh dev +``` + +Standalone release-compatible archives can be created with: + +```bash +scripts/package-linux.sh shipping +scripts/package-linux.sh dev +``` + +They are written under `dist/` and preserve the upstream +`PalSchema/dlls/main.dll` layout. diff --git a/docs/plans/2026-07-25-linux-port-plan.md b/docs/plans/2026-07-25-linux-port-plan.md new file mode 100644 index 0000000..9905407 --- /dev/null +++ b/docs/plans/2026-07-25-linux-port-plan.md @@ -0,0 +1,1152 @@ +# PalSchema Linux Port and Cross-Distro Mod Development Plan + +Status: execution in progress +Prepared: 2026-07-25 +Upstream baseline: `Okaetsu/PalSchema` `main` at `75137ef`, tag `0.6.1` +Primary development host: CachyOS +Target contribution path: `LAP87/PalSchema` fork to `Okaetsu/PalSchema` + +## 1. Executive decision + +This project will treat "Linux support" as three related deliverables, not as +one ambiguous binary port: + +1. **Linux-hosted PalSchema development for the Windows Palworld client** + - Build the Win64 PalSchema DLL entirely from Linux. + - Install and run it with the Windows Palworld client under Steam/Proton. + - Preserve every mod-development feature currently available on Windows. + - This is the first runtime parity milestone because the Palworld desktop + client installed on Linux is still the Windows build. + +2. **Native Linux Palworld Dedicated Server support** + - Build PalSchema as an ELF shared object for the native Linux server. + - Use the official/native UE4SS Linux C++ mod ABI when that ABI is ready. + - Prototype against the current upstream draft only in an explicitly + experimental lane. + - Do not call this stable or 1:1 until the native server feature matrix has + passed against an isolated native PalServer instance. + +3. **Distro-neutral authoring tools** + - Keep JSON Schema as the canonical mod-authoring contract. + - Add a standalone validator/CLI and a Language Server Protocol server. + - Add a thin VS Code/VSCodium extension and documented setup for other + Linux editors. + - Support both `.json` and `.jsonc`. + - Ship these tools independently of Palworld, Steam, Proton, and UE4SS. + +The release will **not bundle UE4SS**. Users will install a compatible UE4SS +build separately. PalSchema will publish a machine-readable compatibility +manifest and provide a `doctor` command that verifies the user's installation. + +## 2. Why this split is necessary + +The word "Linux" currently refers to two different runtime ABIs: + +- The Steam Palworld client is a PE/COFF Win64 executable running through + Proton. PalSchema must therefore remain a Win64 DLL at runtime even if it was + compiled on Linux. +- Palworld Dedicated Server has a native x86-64 ELF build. A plugin for that + process needs Linux-compatible UE layouts, signatures, hooks, loader entry + points, paths, and shared-library exports. + +Cross-compiling the existing Win64 target on Linux is materially smaller and +less risky than creating the native server target. The project will ship useful +Linux mod-development improvements as soon as the first lane is complete, +without misrepresenting the native server port as finished. + +## 3. Verified baseline + +### 3.1 Repository + +- The checkout is at `/home/lenny/apps/PalSchema`. +- Upstream is `https://github.com/Okaetsu/PalSchema`. +- Baseline release is `0.6.1`, published 2026-07-19. +- The repository and PalSchema source are MIT-licensed. +- The pinned dependency is `Okaetsu/RE-UE4SS` at + `c838a8acaade1a0f860bdf249f039e58f4e10088`. +- PalSchema directly links UE4SS, Zydis, Zycore, SafetyHook, nlohmann-json, + Glaze, and efsw. +- The current top-level CMake file only defines one generic `SHARED` target, + includes `src/dllmain.cpp`, and uses a Windows-only + `__declspec(dllexport)` declaration. +- The only checked-in build helper is a two-line Windows batch file. + +### 3.2 Dependency access + +- `deps/RE-UE4SS` and its recursive dependencies are checked out at their + repository-pinned commits. +- Its `deps/first/Unreal` submodule points to the private `UEPseudo` + repository. +- The active GitHub account `LAP87` is linked to Epic Games, has accepted the + EpicGames organization invitation, and has verified pull access to + `Re-UE4SS/UEPseudo`. +- RE-UE4SS is MIT-licensed, but its own contributing documentation says + UEPseudo is subject to Epic Games' licensing terms. +- A clean native CMake configure on CachyOS now traverses the complete + dependency graph and successfully generates Ninja build files. + +UEPseudo remains an authorized-builder prerequisite rather than a +redistributable project dependency. The project will not copy, mirror, vendor, +or publish it. + +### 3.3 Current runtime architecture + +The current `PalSchema` C++ mod: + +1. Is created from exported `start_mod()` and destroyed by `uninstall_mod()`. +2. Runs configuration loading, signature scanning, Unreal offset setup, and + loader pre-initialization from its constructor. +3. Uses UE4SS lifecycle callbacks for UI setup and Unreal initialization. +4. Installs inline hooks around data-table serialization, game-instance + initialization, and pak-folder discovery. +5. Registers loaders for: + - resources; + - enums; + - pals/monsters; + - humans; + - items; + - skins; + - appearances; + - buildings; + - raw data tables; + - blueprints; + - help-guide entries; + - custom spawns; + - translations. +6. Optionally watches the mod tree through efsw and auto-reloads changes. +7. Generates enum and raw-table schemas by querying the live Unreal + AssetRegistry. + +The code graph identifies `FName`, `PalModLoaderBase`, `PalMainLoader`, the +specialized loaders, and `FProperty` as the central abstractions. The best port +boundary is therefore below the loaders: platform/runtime services must change +without forking every mod type into Windows and Linux copies. + +### 3.4 Local CachyOS reference environment + +- CachyOS kernel: `7.1.3-2-cachyos` +- glibc: `2.43` +- CMake: `4.4.0` +- GCC: `16.1.1` +- Clang: `22.1.8` +- Ninja: `1.13.2` +- Steam Palworld client app: `1623730` +- Installed client build ID: `24181527` +- Client executable: Win64 PE/COFF +- Installed UE4SS client build: `3.0.1 Beta`, commit `c2ac246` +- The installed client already demonstrates that Win64 UE4SS can run under + Proton on this host. +- Native Dedicated Server app: `2394010` +- Installed server build ID: `24181105` +- Server executable: native x86-64 ELF +- The native Dedicated Server is currently live. + +No test may modify or restart the live server, its production config, or its +save tree. Native end-to-end testing must use a separate test instance, separate +ports, and a separate save directory. + +### 3.5 Existing schema/editor experience + +The `0.6.1` Dev release contains: + +- static schemas for buildings, items, pals, skins, and utility definitions; +- generated schemas at runtime for raw data tables and enums; +- examples; +- a `.vscode/settings.json` file with `json.schemas` mappings. + +The current VS Code mapping: + +- is delivered in the release archive rather than maintained visibly as a + reusable editor package; +- only matches `*.json`, even though PalSchema documents and accepts `.jsonc`; +- relies on relative paths in one specific extracted folder layout; +- has no standalone validation command; +- has no editor-neutral LSP; +- cannot provide a clear diagnostic when generated raw/enum schemas are stale + or missing. + +## 4. Upstream constraints that shape the plan + +### 4.1 Native UE4SS Linux support is not stable yet + +Relevant upstream state: + +- [UE4SS issue #364](https://github.com/UE4SS-RE/RE-UE4SS/issues/364) + remains open for Linux support. +- [UE4SS PR #384](https://github.com/UE4SS-RE/RE-UE4SS/pull/384) is an older + draft Linux port. +- [UE4SS PR #1347](https://github.com/UE4SS-RE/RE-UE4SS/pull/1347) is a newer + draft native headless Linux runtime with Palworld-specific conformance + tooling, but it still requires review and is not the stable public ABI. +- [PalSchema issue #125](https://github.com/Okaetsu/PalSchema/issues/125) + records the PalSchema maintainer's intent to wait for official UE4SS Linux + implementation. + +Consequences: + +- The native PalSchema server target must be an experimental build until UE4SS + exposes and stabilizes the required C++ mod and hook APIs. +- PalSchema should not absorb a private permanent fork of the entire UE4SS + runtime. +- Any missing native UE4SS primitives should be contributed to UE4SS or + isolated in a very small adapter that can be deleted when upstream lands. + +### 4.2 Linux-to-Windows cross-compilation already has an upstream path + +- [UE4SS PR #710](https://github.com/UE4SS-RE/RE-UE4SS/pull/710) added + cross-compilation support and was merged. +- The pinned RE-UE4SS documentation recommends `xwin` plus Clang/LLD, with + `msvc-wine` as an alternative. +- [UE4SS PR #1244](https://github.com/UE4SS-RE/RE-UE4SS/pull/1244) continues + improving LLVM assembler support. +- [UE4SS issue #811](https://github.com/UE4SS-RE/RE-UE4SS/issues/811) + documents that `msvc-wine` can work in CI but has had local reliability and + diagnostics problems. + +Decision: + +- Use **xwin + clang-cl + LLD + Ninja** as the primary reproducible Win64 + cross-build. +- Keep `msvc-wine` as a documented fallback/compatibility lane, not the + canonical build. + +### 4.3 Constructor-time scanning is a Proton/Wine risk + +[PalSchema issue #118](https://github.com/Okaetsu/PalSchema/issues/118) +contains current reports of Wine server hangs around PalSchema startup and +UE4SS signature scanning. The exact cause is not fully settled across all +environments, but the architecture is objectively risky: + +- PalSchema's constructor synchronously runs signature scanning. +- Signature scanning creates worker tasks and waits for completion. +- DLL initialization may still be under the Windows loader lock. + +The port must remove blocking work from constructor/DllMain-adjacent paths even +if a particular Proton version appears to tolerate it. + +## 5. Definition of "100% 1-to-1 mod-development functionality" + +Parity is achieved only when the following contract is green. + +### 5.1 Authoring parity + +- A developer can create a mod workspace from Linux without manually copying + hidden VS Code files from a release archive. +- `.json` and `.jsonc` receive the same schema selection. +- Completion, hover information, and diagnostics work in VS Code and VSCodium. +- The same diagnostics are available from a terminal and CI. +- Generic LSP clients can consume the same diagnostics. +- All shipped examples validate. +- Invalid fixtures for every mod type fail with stable, useful error codes. +- Generated raw-table and enum schemas can be refreshed without relying solely + on an ImGui button. + +### 5.2 Proton client runtime parity + +Every current PalSchema capability must pass on the Windows client under +Proton: + +- raw data-table edit; +- raw data-table row addition; +- wildcard filtering; +- blueprint modification; +- pals/monsters; +- humans; +- items; +- skins; +- appearances; +- buildings; +- enums; +- help-guide entries; +- custom spawns; +- translations/custom localization; +- resource loading; +- pak-folder redirection; +- live schema generation; +- configuration load/repair; +- debug logging; +- auto-reload after normal save; +- auto-reload after atomic-save/rename used by common Linux editors; +- clean unload/reload where UE4SS supports it; +- clear failure when signatures, offsets, schemas, or UE4SS are incompatible. + +The Win64 DLL produced on Linux must be functionally equivalent to the +official Windows-built DLL. A Windows CI build remains in the matrix to detect +compiler-specific regressions. + +### 5.3 Native Dedicated Server parity + +The native server target must pass every server-applicable item above. Features +that are inherently client/UI-only must: + +- be explicitly classified as client-only; +- disable themselves cleanly on a headless server; +- never crash or block server startup; +- never be silently advertised as active. + +Native parity also requires: + +- an ELF shared library with only the intended public exports; +- no dependency on Wine, Proton, Windows SDK files, or Win32 DLL search rules; +- Linux signatures/offsets validated against the exact PalServer build; +- case-sensitive path correctness; +- correct UTF-8 path handling; +- safe initialization outside loader/preload locks; +- clean behavior under systemd and containerized servers. + +### 5.4 Cross-distro parity + +A feature does not count as Linux-complete if it only works on CachyOS. +Compiled release artifacts and editor tools must work on the supported distro +matrix without distro-specific source edits. + +## 6. Support matrix + +### 6.1 Tier 1: release-blocking + +| Environment | Build | CLI/LSP | Proton client | Native server | +| --- | --- | --- | --- | --- | +| CachyOS/Arch, native Steam | Yes | Yes | Full local E2E | Full isolated E2E | +| Ubuntu LTS, native Steam | Yes | Yes | Smoke + fixtures | Smoke | +| Fedora stable, native Steam | Yes | Yes | Smoke + fixtures | Smoke | +| Debian stable | Yes | Yes | Headless/packaging | Native smoke | +| openSUSE Tumbleweed or Leap | Yes | Yes | Smoke | Native smoke | +| SteamOS/Steam Deck | No local compile requirement | Yes | Install/load/editor smoke | N/A | + +### 6.2 Tier 2: compatibility-tested + +- Flatpak Steam on one Ubuntu/Fedora-family host. +- Proton Stable, Proton Experimental, and Proton-CachyOS SLR. +- VSCodium/Open VSX installation. +- Neovim with a generic LSP client. +- Zed or Helix with a generic LSP client where supported. +- Containerized native server. + +### 6.3 Portable artifact baseline + +- Build native Linux release artifacts in a conservative glibc container, not + on the newest rolling-release host. +- Audit required GLIBC/GLIBCXX symbol versions. +- Prefer a stable C++ runtime strategy: + - dynamically link glibc; + - statically link libstdc++/libgcc only if license and plugin-host ABI testing + support it; + - avoid distro-specific shared libraries outside the documented baseline. +- Produce `x86_64` first. +- Treat `aarch64` as a separate future target because Palworld/Proton runtime + availability and UE4SS hooks are not equivalent. + +## 7. Target repository architecture + +```text +PalSchema/ +├── CMakeLists.txt +├── CMakePresets.json +├── cmake/ +│ ├── PalSchemaOptions.cmake +│ ├── PalSchemaPlatform.cmake +│ └── toolchains/ +│ └── xwin-clang-cl.cmake +├── include/ +│ ├── Platform/ +│ │ ├── Export.h +│ │ ├── HookBackend.h +│ │ ├── Path.h +│ │ ├── RuntimeCapabilities.h +│ │ └── SignatureCatalog.h +│ └── ...existing domain headers... +├── src/ +│ ├── Platform/ +│ │ ├── Common/ +│ │ ├── Win64/ +│ │ └── Linux/ +│ ├── PluginEntry.cpp +│ └── ...existing loaders... +├── schemas/ +│ ├── static/ +│ ├── generated-fixtures/ +│ └── schema-index.json +├── tools/ +│ ├── schema-core/ +│ ├── cli/ +│ ├── language-server/ +│ └── vscode/ +├── tests/ +│ ├── unit/ +│ ├── contract/ +│ ├── fixtures/ +│ ├── proton/ +│ └── native-server/ +├── scripts/ +│ ├── bootstrap-linux.sh +│ ├── build.sh +│ ├── package.sh +│ ├── deploy-proton.sh +│ ├── deploy-native-test.sh +│ └── verify-release.sh +└── docs/ + ├── linux/ + └── plans/ +``` + +The exact layout may be adjusted to upstream taste, but the separation of +domain logic, platform services, tooling, and tests is non-negotiable. + +## 8. Runtime architecture + +```mermaid +flowchart TD + Mods["PalSchema JSON/JSONC mods"] --> Core["Shared PalSchema loaders and mutation core"] + Core --> Runtime["Runtime capability interface"] + Runtime --> Win["Win64 UE4SS adapter"] + Runtime --> Linux["Native Linux UE4SS adapter"] + Win --> Proton["Windows Palworld client under Proton"] + Win --> WineServer["Optional Windows dedicated server under Proton/Wine"] + Linux --> NativeServer["Native Palworld Dedicated Server"] + GameRegistry["Live Unreal AssetRegistry"] --> SchemaService["Schema generation service"] + SchemaService --> Schemas["Versioned JSON schemas and manifest"] + Schemas --> CLI["PalSchema CLI validator"] + Schemas --> LSP["PalSchema language server"] + LSP --> Editors["VS Code, VSCodium, Neovim, Zed, Helix"] +``` + +The specialized loaders remain shared. Platform adapters own: + +- plugin exports and lifecycle; +- hook creation/destruction; +- executable/module discovery; +- signature selection and validation; +- path encoding and normalization; +- GUI/headless capabilities; +- schema-generation triggers; +- runtime logging integration. + +## 9. Execution phases + +## Phase 0: governance, fork, and evidence baseline + +### Tasks + +1. Confirm the intended upstream contribution scope with the PalSchema + maintainer using issue #125 as context. +2. Resolve `LAP87` Epic/GitHub access: + - link Epic and GitHub accounts; + - accept the EpicGames organization invitation; + - verify UEPseudo access with a read-only GitHub request; + - rerun recursive submodule initialization without `--remote`. +3. Record: + - PalSchema commit; + - RE-UE4SS commit; + - UEPseudo commit; + - Palworld client build ID; + - native server build ID; + - installed UE4SS commit; + - Proton versions used. +4. Create `LAP87/PalSchema`. +5. Configure remotes: + - `origin` -> `LAP87/PalSchema`; + - `upstream` -> `Okaetsu/PalSchema`. +6. Create a feature branch from current upstream `main`. +7. Add CI before changing runtime logic so the original Windows target has a + recorded baseline. +8. Keep the generated Graphify analysis out of the upstream patch. + +### Exit gate + +- Recursive dependencies are reproducibly available to authorized builders. +- Baseline Windows CI either passes or has documented pre-existing failures. +- No Epic-restricted source or game asset appears in the fork or CI artifacts. + +### Current checkpoint + +Completed on 2026-07-25: + +- verified `LAP87` pull access to `Re-UE4SS/UEPseudo`; +- checked out UEPseudo at `b2e876da82b17254c04304746341c8fde0ddb37c`; +- completed a clean native CMake configure on CachyOS; +- created `LAP87/PalSchema`; +- configured `origin` as the fork and `upstream` as `Okaetsu/PalSchema`; +- created the `codex/linux-port` working branch; +- installed xwin `0.9.0`; +- completed the explicit Microsoft CRT/SDK license gate and prepared the + project-local xwin SDK at `$HOME/.cache/palschema/xwin`; +- installed an isolated rustup toolchain under + `$HOME/.cache/palschema/{rustup,cargo}` without replacing the CachyOS Rust + package; +- routed Corrosion's Cargo calls through a serialized lock-file guard that + restores the exact pre-build RE-UE4SS `Cargo.lock`, including after failure + or interruption; +- cross-compiled `build/win64-xwin-dev/PalSchema.dll` on CachyOS with + clang-cl `22.1.8`, Rust `1.97.1`, and the pinned recursive dependencies; +- verified the artifact as an x86-64 PE32+ DLL with the expected + `start_mod` and `uninstall_mod` exports and a dynamic `UE4SS.dll` import; +- recorded the first verified Dev artifact SHA-256 as + `00d71f702466b206f3e4957935800bfd6bb35746d779a2f8d106cbef56f3833b`; +- cross-compiled and inspected + `build/win64-xwin-shipping/PalSchema.dll`; +- verified the Shipping artifact as an x86-64 PE32+ DLL with ASLR, high-entropy + VA, NX compatibility, only the intended `start_mod` and `uninstall_mod` + exports, and a dynamic `UE4SS.dll` import; +- recorded the first verified Shipping artifact SHA-256 as + `21ba3aca1beaeb7b964aeea42969af3652ac5006e207aaffad05baf022768cc2`; +- rebuilt both Dev and Shipping through the guarded Cargo path and verified + that the pinned RE-UE4SS submodule remained clean; +- produced release-layout-compatible Shipping and Dev archives with + `PalSchema/dlls/main.dll`, plus Dev schemas, examples, editor settings, and + PDB symbols; +- passed an install/checksum/rollback/reinstall round trip against the local + Steam client using the atomic Proton deployment helper; +- loaded the Linux-built Shipping DLL in Palworld client build `24181527` + through the separately installed UE4SS `3.0.1 Beta` (`c2ac246`) and + CachyOS Proton; +- observed successful signature discovery, `PalSchema v0.6.1` startup, and + initialization of every current loader without a PalSchema error; +- stopped only the launched Windows client after the smoke test, rolled the + test mod back, moved the generated backup tree to the desktop trash, and + confirmed the existing native Dedicated Server remained running. + +Still required for the phase exit gate: + +- add and pass the Windows baseline CI lane; +- compare the xwin artifact contract with a Windows CI artifact. + +## Phase 1: reproducible Linux-hosted Win64 build + +### Tasks + +1. Add `CMakePresets.json` presets: + - `win64-xwin-shipping`; + - `win64-xwin-dev`; + - `win64-msvc-ci-shipping`; + - later `linux-server-shipping` and `linux-server-dev`. +2. Wrap the pinned RE-UE4SS xwin toolchain rather than duplicating it. +3. Add a bootstrap script that checks versions of: + - Git; + - CMake; + - Ninja; + - Rust; + - Clang/clang-cl; + - LLD/lld-link; + - xwin; + - Node for authoring tools. +4. Require explicit acceptance when xwin downloads Microsoft SDK content. +5. Add CMake options: + - `PALSCHEMA_BUILD_WIN64`; + - `PALSCHEMA_BUILD_LINUX_SERVER`; + - `PALSCHEMA_WITH_IMGUI`; + - `PALSCHEMA_BUILD_TESTS`; + - `PALSCHEMA_PACKAGE_DEV_ASSETS`. +6. Include `version.rc` only for Win64. +7. Replace the hard-coded export declaration with a platform export macro. +8. Set deterministic output names and locations matching release packaging. +9. Generate `compile_commands.json`. +10. Add reproducibility metadata containing compiler, SDK, target, and commit + IDs. +11. Build the unmodified runtime core into a Win64 DLL on CachyOS. +12. Compare the Linux-produced DLL with a Windows CI build: + - exported symbols; + - required imports; + - architecture; + - packaged file layout; + - runtime smoke behavior. + +### Exit gate + +- A fresh supported Linux host can produce the Win64 Dev and Shipping packages + from documented commands. +- The resulting DLL loads in the local Proton client using the separately + installed compatible UE4SS. +- Existing Windows build behavior has not regressed. + +## Phase 2: safe initialization and Proton hardening + +### Tasks + +1. Reduce the plugin constructor to: + - metadata; + - lightweight capability discovery; + - callback registration. +2. Move signature scanning and Unreal-offset initialization to a lifecycle + point that is guaranteed to be outside the loader lock. +3. Implement an initialization state machine: + - `Unloaded`; + - `WaitingForRuntime`; + - `Scanning`; + - `OffsetsReady`; + - `HooksReady`; + - `LoadersReady`; + - `Running`; + - `Failed`. +4. Add structured progress/error logging for every state transition. +5. Make signature scan concurrency explicit and testable. +6. Add timeout/failure handling that leaves the game/server alive when + possible. +7. Verify normal proxy loading with + `WINEDLLOVERRIDES="dwmapi.dll=n,b"`. +8. Test the UE4SS directory layout currently documented by upstream; do not + invent a PalSchema-specific DLL relocation. +9. Add path tests for: + - Steam native layout; + - Flatpak Steam layout; + - spaces; + - non-ASCII path components; + - Wine drive mappings; + - symlinked Steam libraries; + - case-sensitive directories. +10. Change auto-reload to coalesce Linux editor save sequences: + - direct modify; + - create + rename; + - temporary file + atomic replace; + - burst events. +11. Ensure no background callback accesses destroyed loader state. + +### Exit gate + +- No blocking scan runs from constructor/DllMain-adjacent execution. +- The Proton client starts repeatedly without intermittent hangs. +- Auto-reload works in VS Code, VSCodium, and one terminal editor. +- Failures name the missing signature, offset file, UE4SS version, or path. + +## Phase 3: platform/runtime service boundary + +### Tasks + +1. Introduce a runtime capability object: + - platform; + - target process type; + - GUI availability; + - hook API availability; + - schema generation availability; + - pak support; + - localization support. +2. Move plugin entry/exports into a dedicated adapter. +3. Introduce a hook backend interface for the three current inline hooks. +4. Keep SafetyHook in the Win64 adapter. +5. Use the official UE4SS native hook ABI in the Linux adapter. +6. Do not attempt to compile SafetyHook as the native Linux backend unless + upstream explicitly supports that configuration. +7. Introduce a UTF-8 path utility: + - canonical internal representation; + - conversion to UE4SS string types; + - safe logging; + - relative path calculation from the known PalSchema root. +8. Remove logic that searches path components for a literal `"PalSchema"` when + a known root-relative path can be used. +9. Make path comparisons intentionally case-sensitive or insensitive per + contract, never accidentally dependent on host behavior. +10. Separate the schema-generation service from its ImGui trigger. +11. Add alternate triggers: + - configuration on startup; + - console/runtime command when the UE4SS API supports it; + - test API. +12. Centralize compatibility checks and fail closed before installing hooks. + +### Exit gate + +- All existing loader code compiles without direct `_WIN32` branches. +- Platform branches are concentrated in platform adapters and build files. +- The Win64 target still passes the complete Proton matrix. + +## Phase 4: schema contract and standalone CLI + +### Tasks + +1. Move/copy release schemas into an explicit canonical schema tree. +2. Add stable `$id` fields and preserve JSON Schema draft-07 compatibility. +3. Add `schema-index.json` containing: + - schema ID; + - PalSchema version; + - Palworld build compatibility; + - mod-folder type; + - `*.json` and `*.jsonc` patterns; + - static/generated status; + - relative dependencies; + - content checksum. +4. Make runtime generation atomic: + - write temporary file; + - validate generated schema; + - rename into place; + - update manifest last. +5. Add generated-schema staleness detection. +6. Create a shared TypeScript schema core using the same validator for CLI and + editor diagnostics. +7. Implement CLI commands: + - `palschema init`; + - `palschema validate [paths...]`; + - `palschema validate --watch`; + - `palschema schemas list`; + - `palschema schemas verify`; + - `palschema doctor`; + - `palschema print-config`. +8. Use stable exit codes suitable for CI. +9. Support JSONC parsing without deleting comments from source files. +10. Validate every checked-in example. +11. Add invalid fixtures for every schema and common semantic mistake. +12. Keep runtime-only semantic checks clearly separate from JSON Schema checks. + +### Exit gate + +- All examples pass the CLI. +- Invalid fixtures fail with snapshot-tested diagnostics. +- CLI behavior is identical across Tier 1 distributions. +- No game, Steam, Proton, or UE4SS installation is required for static + validation. + +## Phase 5: LSP and editor packages + +### Tasks + +1. Build a Language Server Protocol process on the shared schema core. +2. Provide: + - diagnostics; + - completion; + - hover descriptions; + - schema selection by mod-folder type; + - generated-schema status; + - links to relevant PalSchema documentation. +3. Build a thin VS Code/VSCodium extension: + - starts the LSP; + - contributes static schema associations; + - supports `.json` and `.jsonc`; + - offers `PalSchema: Initialize Workspace`; + - offers `PalSchema: Validate Workspace`; + - offers `PalSchema: Run Doctor`; + - never installs or bundles UE4SS. +4. Publish: + - `.vsix` in GitHub releases; + - VS Code Marketplace package if upstream accepts it; + - Open VSX package for VSCodium. +5. Document generic LSP configuration for Neovim, Zed, and Helix. +6. Replace Windows-only documentation such as installer checkboxes and Explorer + context menus with platform-neutral steps. +7. Keep `code .` as an optional convenience, not a requirement. +8. Add editor integration tests using fixture workspaces. + +### Exit gate + +- VS Code and VSCodium show the same diagnostics as the CLI. +- A generic LSP client passes the protocol smoke suite. +- `.jsonc` receives the same schema selection as `.json`. +- Missing generated schemas produce an actionable message, not silent loss of + completion. + +## Phase 6: experimental native UE4SS adapter + +### Prerequisite + +Select a reviewed upstream UE4SS Linux commit or draft test branch. Record the +exact commit. Do not silently track a moving PR head. + +### Tasks + +1. Audit the native UE4SS ABI needed by PalSchema: + - C++ mod creation/destruction; + - lifecycle callbacks; + - object lookup; + - AssetRegistry access; + - game-thread dispatch; + - hook installation; + - logging; + - working/game directory discovery. +2. Produce a gap table and upstream missing generic UE4SS primitives instead of + embedding game-specific copies in PalSchema. +3. Add an ELF target with: + - hidden visibility by default; + - explicit exported symbols/version script; + - `$ORIGIN`-appropriate runtime lookup; + - no Win32 resources; + - no MASM; + - no Windows proxy generator. +4. Replace Win64 signature assumptions with a platform/build keyed catalog. +5. For every native signature: + - record the PalServer build ID; + - record pattern and expected match count; + - validate nearby instructions; + - fail when zero or multiple unsafe matches occur. +6. Audit class/member layouts under the Linux ABI. +7. Generate or consume Linux-specific member layout data; never reuse Win64 + offsets without proof. +8. Validate `FName`, `FString`, `TArray`, `UObject`, `UDataTable`, property, + and pak-related layouts before mutation. +9. Implement the native hook backend. +10. Guard client-only UI/appearance behavior by runtime capabilities. +11. Implement headless schema-generation trigger. +12. Build the native artifact in the conservative Linux builder container. +13. Audit ELF imports, RPATH, symbol versions, and exported ABI. + +### Exit gate + +- The native shared library loads into an isolated native PalServer. +- Server startup does not hang. +- Unsupported client-only features report themselves and no-op safely. +- Every server-applicable parity test passes. +- No Wine or Win32 runtime dependency is present. + +## Phase 7: isolated game integration tests + +### Test safety model + +- The currently running native server is production-like and read-only for this + project. +- Create a separate test install using SteamCMD or a verified copy/reflink. +- Use a distinct test root, save root, query port, game port, and RCON port. +- Never point a development deploy script at the live server path by default. +- Require an explicit `--target` and a sentinel file for any deploy. +- Back up only the test instance before mutation. +- Client deploys back up the previous PalSchema folder and support one-command + rollback. + +### Proton client suite + +1. Install the separately obtained compatible UE4SS. +2. Deploy the Linux-built Win64 PalSchema Dev package. +3. Assert version/commit compatibility before launch. +4. Launch with the documented DLL override. +5. Assert log milestones for every initialization state. +6. Run one fixture per loader. +7. Generate schemas and validate their manifest/checksums. +8. Modify fixtures while running and verify auto-reload. +9. Repeat on: + - Proton Stable; + - Proton Experimental; + - Proton-CachyOS SLR; + - one Flatpak-Steam environment; + - Steam Deck/SteamOS smoke hardware when available. + +### Native server suite + +1. Start isolated PalServer with empty disposable saves. +2. Load native UE4SS separately. +3. Load native PalSchema. +4. Assert startup deadline and health. +5. Run server-applicable loader fixtures. +6. Connect a test client where behavior requires replication validation. +7. Restart and verify persistence expectations. +8. Shut down gracefully and assert no corrupted files. +9. Repeat startup/stop cycles to catch races. +10. Run a bounded soak test. + +### Exit gate + +- Complete parity table is green. +- Logs and JUnit results are retained without game assets or personal saves. +- Repeated runs are deterministic. + +## Phase 8: cross-distro CI and release engineering + +### Public CI + +Public GitHub Actions may run: + +- Win64 MSVC build; +- Win64 xwin build in Linux; +- native Linux compile once supported; +- C++ unit and contract tests; +- schema validation; +- CLI/LSP tests; +- VSIX build; +- Debian/Ubuntu/Fedora/openSUSE/Arch container matrix; +- formatting and static analysis; +- artifact inspection; +- SBOM/checksum generation. + +Public CI must not contain or upload: + +- Palworld binaries or assets; +- production saves/config; +- UEPseudo source as an artifact; +- credentials/tokens; +- proprietary SDK payloads not allowed for redistribution. + +### Private/local hardware lane + +Real-game tests run on the CachyOS host or an authorized self-hosted runner. +Only sanitized test results and version metadata may leave the machine. + +### Release artifacts + +- `PalSchema__Win64.zip` +- `PalSchema__Win64_Dev.zip` +- `PalSchema__LinuxServer_x86_64.tar.zst` after native stabilization +- `PalSchema__LinuxServer_x86_64_Dev.tar.zst` +- `palschema-tools_.tar.gz` +- `palschema-vscode-.vsix` +- checksums +- SBOM +- `compatibility.json` +- `THIRD_PARTY_NOTICES` + +UE4SS binaries are not included. + +### Compatibility manifest + +The manifest should declare: + +- PalSchema version and commit; +- target ABI; +- supported Palworld build IDs/ranges; +- required UE4SS release/commit; +- required member-layout/signature data version; +- schema pack version; +- supported architecture; +- experimental/stable status. + +## Phase 9: documentation and final upstream PR + +### Documentation + +Add: + +- Linux quick start; +- Linux build prerequisites; +- xwin license/download explanation; +- Steam native and Flatpak paths; +- Proton launch options; +- separate UE4SS installation requirement; +- supported UE4SS compatibility table; +- native server experimental/stable status; +- isolated server-test instructions; +- VS Code/VSCodium extension setup; +- generic LSP setup; +- CLI reference; +- troubleshooting/doctor output reference; +- distro support policy; +- release verification and rollback. + +Remove or qualify Windows-only assumptions in existing docs. + +### Git history + +Keep commits reviewable: + +1. CI and baseline tests +2. Linux-hosted Win64 build +3. safe initialization +4. platform abstraction +5. schema manifest and CLI +6. LSP/editor packages +7. native adapter +8. integration tests +9. packaging/docs + +### Pull request strategy + +The requested end state is one upstream PR containing the finished Linux +variant. To keep that PR reviewable: + +- develop in coherent commits; +- keep generated binaries out of Git; +- open a draft only after the Linux-hosted Win64 vertical slice works; +- continuously rebase/merge upstream `main` without rewriting published + evidence; +- include the full parity matrix and exact tested versions; +- convert to ready-for-review only when release gates pass. + +If the maintainer asks for separate PRs, split along the commit boundaries +above rather than forcing an unreviewable monolith. + +## 10. Licensing and distribution policy + +### Fixed decisions + +- PalSchema code remains under its upstream MIT license. +- RE-UE4SS is not bundled in PalSchema releases. +- Users install UE4SS separately from its official/maintainer-approved source. +- PalSchema may verify UE4SS version/checksum and provide official links. +- PalSchema must not copy or publish UEPseudo or Epic-restricted sources. +- Build instructions may require authorized users to fetch those dependencies. +- Every shipped third-party component receives its required notices. +- Release SBOMs distinguish shipped dependencies from build-only dependencies. + +### Why not bundle UE4SS even though it is MIT + +The MIT license would generally permit redistribution of RE-UE4SS itself when +its notice is preserved. The default no-bundle decision is still preferable: + +- users already manage UE4SS as the common runtime for multiple mods; +- UE4SS security and injection fixes can update independently; +- PalSchema is pinned to a compatibility range that can be checked explicitly; +- private/Epic-licensed build inputs must not accidentally enter artifacts; +- native UE4SS support is not stable yet; +- the upstream PalSchema distribution already documents UE4SS separately. + +This is a technical/distribution policy, not a claim that MIT forbids all +redistribution. + +## 11. Test design details + +### 11.1 C++ unit tests without Palworld + +Extract testable pure logic for: + +- JSON/JSONC parsing; +- path normalization; +- mod-folder classification; +- wildcard filters; +- schema reference generation; +- configuration defaults/repair; +- file event coalescing; +- signature catalog selection; +- capability gating; +- initialization state transitions. + +### 11.2 Contract tests + +Use identical fixtures against Win64 and native adapters: + +- expected loader registration; +- expected mod discovery order; +- expected diagnostics; +- expected schema IDs; +- expected log event IDs; +- expected safe no-op behavior. + +### 11.3 Schema tests + +- Validate all examples. +- Resolve every `$ref` offline. +- Assert no duplicate `$id`. +- Assert draft-07 conformance. +- Test both `.json` and `.jsonc`. +- Test malformed JSONC. +- Test missing generated schemas. +- Test stale generated schemas. +- Test wrong mod-folder/schema pair. +- Snapshot diagnostics without machine-specific absolute paths. + +### 11.4 File watcher tests + +Exercise: + +- in-place write; +- truncate + write; +- temp-file rename; +- delete + recreate; +- nested mod directories; +- Unicode filenames; +- rapid repeated saves; +- watcher shutdown during queued callback. + +### 11.5 ABI and binary tests + +Win64: + +- PE x86-64; +- expected `start_mod`/`uninstall_mod` exports; +- no accidental Linux imports; +- required UE4SS imports recorded. + +Linux: + +- ELF x86-64; +- expected exports only; +- no `TEXTREL`; +- controlled RPATH/RUNPATH; +- conservative GLIBC/GLIBCXX requirements; +- no unresolved symbols before injection/load. + +## 12. Risk register + +| Risk | Impact | Mitigation | Release gate | +| --- | --- | --- | --- | +| UEPseudo access unavailable | Cannot build authoritative dependency graph | User links Epic/GitHub and accepts invitation; never bypass licensing | Recursive clone succeeds | +| Native UE4SS ABI changes | Native adapter churn | Pin reviewed commit, isolate adapter, upstream generic gaps | Exact UE4SS commit in manifest | +| SafetyHook is Win64-specific | Native hooks unavailable | Hook backend interface using official native UE4SS API | Native hook tests pass | +| Windows and Linux signatures differ | Crash/corruption | Platform/build catalog, match-count and instruction validation | Fail closed on unknown build | +| Unreal layouts differ by ABI | Silent memory corruption | Platform layout data and runtime sanity checks | Representative layout suite passes | +| Constructor scan deadlock | Startup hang under Wine/Proton | Defer heavy initialization and add state machine/timeouts | Repeated launch suite | +| Case-sensitive paths | Mods/schemas not found | Root-relative UTF-8 path service and tests | Path matrix passes | +| Atomic editor saves | Auto-reload misses or duplicates | Event debounce/coalescing | Editor save matrix passes | +| Rolling distro toolchain drift | Builds break unexpectedly | Pinned containers/presets and conservative artifact baseline | Tier 1 build matrix | +| Flatpak sandbox paths | Installer/doctor cannot find game | Steam library discovery abstraction and explicit overrides | Flatpak smoke | +| Live server damage | Save loss/downtime | Never test live; isolated instance, ports, saves, sentinel deploy | Safety preflight passes | +| Huge upstream PR | Maintainer cannot review | Coherent commits, draft after vertical slice, split on request | Maintainer-ready checklist | +| Bundled restricted material | Legal/distribution problem | No UE4SS/UEPseudo/game assets in releases or CI artifacts | SBOM and artifact audit | + +## 13. Release gates + +### Gate A: Linux development bootstrap + +- Authorized recursive dependencies +- xwin cross-build +- Windows CI baseline +- documented one-command bootstrap/build + +### Gate B: Proton runtime parity + +- Complete feature matrix on local client +- repeated-start stability +- auto-reload/editor-save matrix +- schema generation +- rollback-capable deploy + +### Gate C: authoring-tool parity + +- CLI, LSP, VS Code/VSCodium +- all examples and invalid fixtures +- Tier 1 distro CI +- editor-neutral docs + +### Gate D: experimental native server + +- reviewed pinned native UE4SS +- ELF build +- platform layouts/signatures +- isolated server boots and passes applicable fixtures + +### Gate E: stable native server + +- native UE4SS dependency is considered suitable by upstream +- no experimental compatibility override +- repeated/soak tests +- Tier 1 server matrix +- maintainer-approved packaging/docs + +### Gate F: ready upstream PR + +- all applicable gates green +- no proprietary artifacts +- license/SBOM audit +- clean diff +- final parity table +- release notes and rollback notes + +## 14. First implementation slice after plan approval + +The first coding slice should be deliberately narrow and end-to-end: + +1. Resolve UEPseudo access. +2. Create the `LAP87/PalSchema` fork and feature branch. +3. Add Windows baseline CI. +4. Add xwin CMake preset and Linux bootstrap script. +5. Replace the export macro and guard Win64 resources. +6. Build the current Win64 PalSchema DLL on CachyOS. +7. Package it without UE4SS. +8. Deploy it with backup/rollback into the local Proton client. +9. Confirm load and one raw-table fixture. +10. Publish evidence in a draft PR description. + +Only after this vertical slice passes should the work expand into lifecycle +hardening, authoring tools, and the native server adapter. + +## 15. Explicit non-goals + +- Reimplementing all of UE4SS inside PalSchema. +- Shipping UE4SS binaries in PalSchema releases. +- Circumventing Epic/UEPseudo access controls. +- Testing against the currently live native server or its production saves. +- Claiming native client support when the Palworld desktop client is Win64. +- Calling a compile-only ELF artifact "Linux support." +- Supporting only CachyOS/Arch and labeling it cross-distro. +- Maintaining separate duplicated Windows and Linux copies of every loader. +- Publishing an upstream PR before there is reproducible runtime evidence. + +## 16. Completion statement + +The work is complete when a PalSchema mod author can use a mainstream Linux +distribution to install editor tooling, validate and author every supported mod +type, build PalSchema's Win64 runtime on Linux, run and hot-reload those mods in +the Palworld client under Proton, and—once the official native UE4SS path is +suitable—run every server-applicable feature in a native isolated Palworld +Dedicated Server, with reproducible artifacts and an evidence-backed upstream +pull request. diff --git a/include/Platform/Export.h b/include/Platform/Export.h new file mode 100644 index 0000000..a9688ba --- /dev/null +++ b/include/Platform/Export.h @@ -0,0 +1,13 @@ +#pragma once + +#if defined(_WIN32) + #if defined(PALSCHEMA_BUILDING_DLL) + #define PALSCHEMA_API __declspec(dllexport) + #else + #define PALSCHEMA_API __declspec(dllimport) + #endif +#elif defined(__GNUC__) || defined(__clang__) + #define PALSCHEMA_API __attribute__((visibility("default"))) +#else + #define PALSCHEMA_API +#endif diff --git a/include/SDK/Classes/TSoftObjectPtr.h b/include/SDK/Classes/TSoftObjectPtr.h index 00ba1ce..9f96968 100644 --- a/include/SDK/Classes/TSoftObjectPtr.h +++ b/include/SDK/Classes/TSoftObjectPtr.h @@ -3,6 +3,8 @@ #include "SDK/Classes/TPersistentObjectPtr.h" #include "SDK/Structs/FSoftObjectPtr.h" +#include + namespace UECustom { template class TSoftObjectPtr @@ -15,10 +17,8 @@ namespace UECustom { { } - template < - class U - UE_REQUIRES(std::is_convertible_v) - > + template + requires std::is_convertible_v FORCEINLINE TSoftObjectPtr(const TSoftObjectPtr& Other) : SoftObjectPtr(Other.SoftObjectPtr) { @@ -31,4 +31,4 @@ namespace UECustom { private: FSoftObjectPtr SoftObjectPtr; }; -} \ No newline at end of file +} diff --git a/include/SDK/Helper/PropertyHelper.h b/include/SDK/Helper/PropertyHelper.h index f0a404a..9de0146 100644 --- a/include/SDK/Helper/PropertyHelper.h +++ b/include/SDK/Helper/PropertyHelper.h @@ -28,6 +28,9 @@ namespace Palworld { } namespace Palworld::PropertyHelper { + template + FFieldDerivedType* CastProperty(RC::Unreal::FField* Field); + void CopyJsonValueToContainer(void* Container, RC::Unreal::FProperty* Property, const nlohmann::json& Value); RC::Unreal::int64 ParseEnumFromJsonValue(RC::Unreal::FEnumProperty* Property, const nlohmann::json& Value); @@ -172,4 +175,4 @@ namespace Palworld::PropertyHelper { { return Field != nullptr && IsPropertyA(Field) ? static_cast(Field) : nullptr; } -} \ No newline at end of file +} diff --git a/scripts/bootstrap-linux.sh b/scripts/bootstrap-linux.sh new file mode 100755 index 0000000..bd3c329 --- /dev/null +++ b/scripts/bootstrap-linux.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash + +set -euo pipefail + +show_usage() { + printf '%s\n' \ + "Usage: scripts/bootstrap-linux.sh [options]" \ + "" \ + "Checks the Linux-to-Win64 build prerequisites without changing the system." \ + "" \ + "Options:" \ + " --install-xwin Install xwin with cargo when it is missing." \ + " --install-rust-toolchain Install an isolated Rust toolchain in the PalSchema cache." \ + " --prepare-sdk Download and unpack the Microsoft CRT/SDK." \ + " --accept-microsoft-license Explicitly accept the xwin Microsoft license gate." \ + " -h, --help Show this help." +} + +install_xwin=false +install_rust_toolchain=false +prepare_sdk=false +accept_microsoft_license=false + +while (($# > 0)); do + case "$1" in + --install-xwin) + install_xwin=true + ;; + --install-rust-toolchain) + install_rust_toolchain=true + ;; + --prepare-sdk) + prepare_sdk=true + ;; + --accept-microsoft-license) + accept_microsoft_license=true + ;; + -h|--help) + show_usage + exit 0 + ;; + *) + printf 'Unknown option: %s\n\n' "$1" >&2 + show_usage >&2 + exit 2 + ;; + esac + shift +done + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +source "$script_dir/lib/build-env.sh" +palschema_configure_build_environment + +if [[ "$(uname -s)" != "Linux" ]]; then + printf '%s\n' "This bootstrap targets Linux hosts." >&2 + exit 1 +fi + +required_commands=( + clang-cl + cmake + curl + flock + git + lld-link + llvm-lib + llvm-mt + llvm-rc + ninja + sha256sum +) +missing_commands=() + +for required_command in "${required_commands[@]}"; do + if ! command -v "$required_command" >/dev/null 2>&1; then + missing_commands+=("$required_command") + fi +done + +if ((${#missing_commands[@]} > 0)); then + printf 'Missing required commands: %s\n' "${missing_commands[*]}" >&2 + printf '%s\n' \ + "Install the equivalent packages for your distribution, then rerun this script." >&2 + exit 1 +fi + +install_isolated_rust_toolchain() { + case "$(uname -m)" in + x86_64) + rustup_host="x86_64-unknown-linux-gnu" + ;; + aarch64|arm64) + rustup_host="aarch64-unknown-linux-gnu" + ;; + *) + printf 'Unsupported rustup host architecture: %s\n' "$(uname -m)" >&2 + exit 1 + ;; + esac + + rustup_base_url="https://static.rust-lang.org/rustup/dist/$rustup_host/rustup-init" + rustup_download_dir="$(mktemp -d "${TMPDIR:-/tmp}/palschema-rustup.XXXXXX")" + trap 'rm -rf -- "$rustup_download_dir"' EXIT + + curl --fail --location --silent --show-error \ + --output "$rustup_download_dir/rustup-init" \ + "$rustup_base_url" + curl --fail --location --silent --show-error \ + --output "$rustup_download_dir/rustup-init.sha256" \ + "$rustup_base_url.sha256" + + expected_sha256="$(awk '{print $1}' "$rustup_download_dir/rustup-init.sha256")" + actual_sha256="$(sha256sum "$rustup_download_dir/rustup-init" | awk '{print $1}')" + + if [[ "$actual_sha256" != "$expected_sha256" ]]; then + printf '%s\n' "rustup-init SHA-256 verification failed." >&2 + exit 1 + fi + + chmod +x "$rustup_download_dir/rustup-init" + mkdir -p "$PALSCHEMA_RUSTUP_HOME" "$PALSCHEMA_CARGO_HOME" + + RUSTUP_HOME="$PALSCHEMA_RUSTUP_HOME" \ + CARGO_HOME="$PALSCHEMA_CARGO_HOME" \ + RUSTUP_INIT_SKIP_PATH_CHECK=yes \ + "$rustup_download_dir/rustup-init" \ + --default-toolchain stable \ + --no-modify-path \ + --profile minimal \ + -y + + export RUSTUP_HOME="$PALSCHEMA_RUSTUP_HOME" + export CARGO_HOME="$PALSCHEMA_CARGO_HOME" + export PATH="$CARGO_HOME/bin:$PATH" +} + +if ! command -v cargo >/dev/null 2>&1 || ! command -v rustc >/dev/null 2>&1; then + if [[ "$install_rust_toolchain" == true ]]; then + install_isolated_rust_toolchain + else + printf '%s\n' \ + "Rust and Cargo are required. Install rustup, or use the isolated setup:" \ + " scripts/bootstrap-linux.sh --install-rust-toolchain" >&2 + exit 1 + fi +fi + +rust_target="x86_64-pc-windows-msvc" +rust_target_libdir="$(rustc --print target-libdir --target "$rust_target")" + +if [[ ! -d "$rust_target_libdir" ]]; then + if [[ "$install_rust_toolchain" == true ]]; then + if ! command -v rustup >/dev/null 2>&1; then + install_isolated_rust_toolchain + fi + rustup target add "$rust_target" + else + printf '%s\n' \ + "Rust target $rust_target is missing." \ + "Keep the system Rust installation untouched and add an isolated toolchain with:" \ + " scripts/bootstrap-linux.sh --install-rust-toolchain" >&2 + exit 1 + fi +fi + +if ! command -v xwin >/dev/null 2>&1; then + if [[ "$install_xwin" == true ]]; then + cargo install --locked xwin + else + printf '%s\n' \ + "xwin is missing. Rerun with --install-xwin or install it with:" \ + " cargo install --locked xwin" >&2 + exit 1 + fi +fi + +if [[ -n "${XWIN_DIR:-}" ]]; then + palschema_xwin_dir="$XWIN_DIR" +else + palschema_xwin_dir="$PALSCHEMA_CACHE_ROOT/xwin" +fi + +if [[ "$accept_microsoft_license" == true && "$prepare_sdk" != true ]]; then + printf '%s\n' \ + "--accept-microsoft-license is only meaningful together with --prepare-sdk." >&2 + exit 2 +fi + +if [[ "$prepare_sdk" == true ]]; then + if [[ "$accept_microsoft_license" != true ]]; then + printf '%s\n' \ + "SDK preparation requires explicit license acceptance." \ + "Review Microsoft's terms, then rerun with both:" \ + " --prepare-sdk --accept-microsoft-license" >&2 + exit 2 + fi + + mkdir -p "$palschema_xwin_dir" + xwin --accept-license splat --output "$palschema_xwin_dir" +fi + +if [[ ! -d "$palschema_xwin_dir/crt" || ! -d "$palschema_xwin_dir/sdk" ]]; then + printf 'The xwin SDK is not prepared at %s.\n' "$palschema_xwin_dir" >&2 + printf '%s\n' \ + "After reviewing the Microsoft terms, prepare it explicitly with:" \ + " scripts/bootstrap-linux.sh --prepare-sdk --accept-microsoft-license" >&2 + exit 1 +fi + +if [[ ! -f deps/RE-UE4SS/deps/first/Unreal/CMakeLists.txt ]]; then + printf '%s\n' \ + "UEPseudo is missing. Initialize the authorized recursive submodules with:" \ + " git submodule update --init --recursive" >&2 + exit 1 +fi + +printf '%s\n' \ + "PalSchema Linux-to-Win64 prerequisites are ready." \ + "XWIN_DIR=$palschema_xwin_dir" \ + "Build Dev: scripts/build-linux.sh dev" \ + "Build Shipping: scripts/build-linux.sh shipping" diff --git a/scripts/build-linux.sh b/scripts/build-linux.sh new file mode 100755 index 0000000..b02245b --- /dev/null +++ b/scripts/build-linux.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash + +set -euo pipefail + +show_usage() { + printf '%s\n' \ + "Usage: scripts/build-linux.sh [dev|shipping] [--configure-only]" \ + "" \ + "Cross-compiles the Win64 PalSchema DLL from a Linux host." +} + +build_flavor="${1:-dev}" +configure_only=false + +if (($# > 0)); then + shift +fi + +while (($# > 0)); do + case "$1" in + --configure-only) + configure_only=true + ;; + -h|--help) + show_usage + exit 0 + ;; + *) + printf 'Unknown option: %s\n\n' "$1" >&2 + show_usage >&2 + exit 2 + ;; + esac + shift +done + +case "$build_flavor" in + dev) + preset="win64-xwin-dev" + ;; + shipping) + preset="win64-xwin-shipping" + ;; + -h|--help) + show_usage + exit 0 + ;; + *) + printf 'Unknown build flavor: %s\n\n' "$build_flavor" >&2 + show_usage >&2 + exit 2 + ;; +esac + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +project_root="$(cd -- "$script_dir/.." && pwd)" +cd "$project_root" + +source "$script_dir/lib/build-env.sh" +palschema_configure_build_environment + +"$script_dir/bootstrap-linux.sh" + +if command -v rustup >/dev/null 2>&1; then + rust_compiler="$(rustup which rustc)" + rust_cargo="$(rustup which cargo)" +else + rust_compiler="$(command -v rustc)" + rust_cargo="$(command -v cargo)" +fi + +export PALSCHEMA_REAL_CARGO="$rust_cargo" + +cmake \ + --preset "$preset" \ + -DRust_COMPILER="$rust_compiler" \ + -DRust_CARGO="$script_dir/cargo-preserve-lock.sh" + +if [[ "$configure_only" != true ]]; then + cmake --build --preset "$preset" --target PalSchema + + artifact="$project_root/build/$preset/PalSchema.dll" + if [[ ! -f "$artifact" ]]; then + printf 'Build completed without the expected artifact: %s\n' "$artifact" >&2 + exit 1 + fi + + printf 'Built %s\n' "$artifact" +fi diff --git a/scripts/cargo-preserve-lock.sh b/scripts/cargo-preserve-lock.sh new file mode 100755 index 0000000..85a2de0 --- /dev/null +++ b/scripts/cargo-preserve-lock.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +project_root="$(cd -- "$script_dir/.." && pwd)" + +source "$script_dir/lib/build-env.sh" +palschema_configure_build_environment + +real_cargo="${PALSCHEMA_REAL_CARGO:-}" +if [[ -z "$real_cargo" ]]; then + if command -v rustup >/dev/null 2>&1; then + real_cargo="$(rustup which cargo)" + else + real_cargo="$(command -v cargo)" + fi +fi + +if [[ "$real_cargo" == "$0" || "$real_cargo" == "$script_dir/cargo-preserve-lock.sh" ]]; then + printf '%s\n' "Cargo lockfile guard resolved itself instead of the real Cargo binary." >&2 + exit 1 +fi + +lock_file="$project_root/deps/RE-UE4SS/deps/first/patternsleuth_bind/Cargo.lock" +if [[ ! -f "$lock_file" ]]; then + exec "$real_cargo" "$@" +fi + +mkdir -p "$PALSCHEMA_CACHE_ROOT" +guard_file="$PALSCHEMA_CACHE_ROOT/patternsleuth-bind-cargo.lock.guard" +exec {guard_fd}>"$guard_file" +flock "$guard_fd" + +lock_backup="$(mktemp "$PALSCHEMA_CACHE_ROOT/patternsleuth-bind-cargo.lock.XXXXXX")" +cp -p -- "$lock_file" "$lock_backup" + +cleanup() { + local status=$? + + trap - EXIT + if ! cmp -s -- "$lock_backup" "$lock_file"; then + if ! cp -p -- "$lock_backup" "$lock_file"; then + printf 'Failed to restore %s after Cargo exited.\n' "$lock_file" >&2 + status=1 + fi + fi + if ! rm -f -- "$lock_backup"; then + printf 'Failed to remove temporary lockfile backup: %s\n' "$lock_backup" >&2 + status=1 + fi + + exit "$status" +} + +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +"$real_cargo" "$@" diff --git a/scripts/deploy-proton.sh b/scripts/deploy-proton.sh new file mode 100755 index 0000000..97f29bb --- /dev/null +++ b/scripts/deploy-proton.sh @@ -0,0 +1,236 @@ +#!/usr/bin/env bash + +set -euo pipefail + +show_usage() { + printf '%s\n' \ + "Usage:" \ + " scripts/deploy-proton.sh [dev|shipping] [--game-dir PATH] [--dry-run]" \ + " scripts/deploy-proton.sh --rollback BACKUP_DIR [--game-dir PATH] [--dry-run]" \ + "" \ + "Deploys only PalSchema into an existing, separately installed UE4SS." \ + "Refuses to run while the Windows Palworld client is active." +} + +build_flavor="shipping" +game_dir="" +rollback_dir="" +dry_run=false + +if (($# > 0)) && [[ "$1" != --* ]]; then + build_flavor="$1" + shift +fi + +while (($# > 0)); do + case "$1" in + --game-dir) + if (($# < 2)); then + printf '%s\n' "--game-dir requires a path." >&2 + exit 2 + fi + game_dir="$2" + shift + ;; + --rollback) + if (($# < 2)); then + printf '%s\n' "--rollback requires a backup directory." >&2 + exit 2 + fi + rollback_dir="$2" + shift + ;; + --dry-run) + dry_run=true + ;; + -h|--help) + show_usage + exit 0 + ;; + *) + printf 'Unknown option: %s\n\n' "$1" >&2 + show_usage >&2 + exit 2 + ;; + esac + shift +done + +case "$build_flavor" in + dev) + preset="win64-xwin-dev" + ;; + shipping) + preset="win64-xwin-shipping" + ;; + *) + printf 'Unknown build flavor: %s\n\n' "$build_flavor" >&2 + show_usage >&2 + exit 2 + ;; +esac + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +project_root="$(cd -- "$script_dir/.." && pwd)" + +if [[ -z "$game_dir" ]]; then + default_game_dir="$HOME/.local/share/Steam/steamapps/common/Palworld" + if [[ -d "$default_game_dir" ]]; then + game_dir="$default_game_dir" + else + printf '%s\n' \ + "Palworld was not found in Steam's default library." \ + "Pass its installation directory with --game-dir." >&2 + exit 1 + fi +fi + +game_dir="$(realpath -e -- "$game_dir")" +win64_dir="$game_dir/Pal/Binaries/Win64" +ue4ss_root="$win64_dir/ue4ss" +mods_dir="$ue4ss_root/Mods" +target_dir="$mods_dir/PalSchema" +backup_root="$ue4ss_root/.palschema-backups" + +palworld_client_is_running() { + local cmdline + local argument + + for cmdline in /proc/[0-9]*/cmdline; do + while IFS= read -r -d '' argument; do + case "$argument" in + Palworld-Win64-Shipping.exe|*/Palworld-Win64-Shipping.exe|*\\Palworld-Win64-Shipping.exe) + return 0 + ;; + esac + done < "$cmdline" 2>/dev/null || true + done + + return 1 +} + +if [[ ! -f "$win64_dir/Palworld-Win64-Shipping.exe" ]]; then + printf 'Not a Palworld client installation: %s\n' "$game_dir" >&2 + exit 1 +fi +if [[ ! -f "$ue4ss_root/UE4SS.dll" || ! -d "$mods_dir" ]]; then + printf 'A separate UE4SS installation was not found under: %s\n' "$win64_dir" >&2 + exit 1 +fi +if palworld_client_is_running; then + printf '%s\n' "Refusing to deploy while the Palworld Windows client is active." >&2 + exit 1 +fi + +if [[ -n "$rollback_dir" ]]; then + if [[ ! -d "$backup_root" ]]; then + printf 'No PalSchema backup root exists at: %s\n' "$backup_root" >&2 + exit 1 + fi + + backup_root_real="$(realpath -e -- "$backup_root")" + rollback_dir_real="$(realpath -e -- "$rollback_dir")" + case "$rollback_dir_real/" in + "$backup_root_real"/*/) + ;; + *) + printf 'Rollback directory must be below: %s\n' "$backup_root_real" >&2 + exit 1 + ;; + esac + + if [[ ! -d "$rollback_dir_real/PalSchema" && + ! -f "$rollback_dir_real/no-previous-install" ]]; then + printf 'Not a valid PalSchema deployment backup: %s\n' "$rollback_dir_real" >&2 + exit 1 + fi + + if [[ "$dry_run" == true ]]; then + printf 'Would roll back %s using %s\n' "$target_dir" "$rollback_dir_real" + exit 0 + fi + + rollback_id="rollback-$(date -u +%Y%m%dT%H%M%SZ)-$$" + rollback_safety="$backup_root/$rollback_id" + mkdir -p "$rollback_safety" + if [[ -d "$target_dir" ]]; then + mv -- "$target_dir" "$rollback_safety/PalSchema" + else + touch "$rollback_safety/no-previous-install" + fi + + if [[ -d "$rollback_dir_real/PalSchema" ]]; then + cp -a -- "$rollback_dir_real/PalSchema" "$target_dir" + fi + + printf 'Rolled back PalSchema from %s\n' "$rollback_dir_real" + printf 'Previous deployed state preserved at %s\n' "$rollback_safety" + exit 0 +fi + +artifact="$project_root/build/$preset/PalSchema.dll" +if [[ ! -f "$artifact" ]]; then + printf 'Missing build artifact: %s\n' "$artifact" >&2 + printf 'Build it first with: scripts/build-linux.sh %s\n' "$build_flavor" >&2 + exit 1 +fi + +if [[ "$dry_run" == true ]]; then + printf 'Would deploy %s to %s/dlls/main.dll\n' "$artifact" "$target_dir" + if [[ -d "$target_dir" ]]; then + printf 'Would preserve the existing mod below %s\n' "$backup_root" + else + printf '%s\n' "No existing PalSchema installation would be replaced." + fi + exit 0 +fi + +mkdir -p "$backup_root" +deploy_id="$(date -u +%Y%m%dT%H%M%SZ)-$$" +backup_dir="$backup_root/$deploy_id" +mkdir -p "$backup_dir" + +stage_dir="$(mktemp -d "$mods_dir/.PalSchema.stage.XXXXXX")" +cleanup_stage() { + if [[ -n "${stage_dir:-}" && -d "$stage_dir" ]]; then + rm -rf -- "$stage_dir" + fi +} +trap cleanup_stage EXIT + +install -d "$stage_dir/dlls" "$stage_dir/mods" +install -m 0644 "$artifact" "$stage_dir/dlls/main.dll" +touch "$stage_dir/enabled.txt" +if [[ "$build_flavor" == dev ]]; then + install -m 0644 "$project_root/build/$preset/PalSchema.pdb" \ + "$stage_dir/dlls/main.pdb" + cp -a "$project_root/assets/.vscode" "$stage_dir/.vscode" + cp -a "$project_root/assets/examples" "$stage_dir/examples" + cp -a "$project_root/assets/schemas" "$stage_dir/schemas" +fi + +if [[ -d "$target_dir" ]]; then + mv -- "$target_dir" "$backup_dir/PalSchema" +else + touch "$backup_dir/no-previous-install" +fi + +if ! mv -- "$stage_dir" "$target_dir"; then + if [[ -d "$backup_dir/PalSchema" && ! -e "$target_dir" ]]; then + mv -- "$backup_dir/PalSchema" "$target_dir" + fi + printf '%s\n' "Deployment failed; the previous PalSchema installation was restored." >&2 + exit 1 +fi +stage_dir="" + +{ + printf 'flavor=%s\n' "$build_flavor" + printf 'artifact=%s\n' "$artifact" + printf 'sha256=%s\n' "$(sha256sum "$artifact" | awk '{print $1}')" + printf 'deployed_at_utc=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" +} > "$backup_dir/deployment.txt" + +printf 'Deployed PalSchema to %s\n' "$target_dir" +printf 'Rollback with: scripts/deploy-proton.sh --rollback %s --game-dir %s\n' \ + "$backup_dir" "$game_dir" diff --git a/scripts/lib/build-env.sh b/scripts/lib/build-env.sh new file mode 100644 index 0000000..df7f70b --- /dev/null +++ b/scripts/lib/build-env.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +# Shared environment setup for PalSchema's Linux-hosted cross-build scripts. +# This file is sourced; it intentionally does not enable shell options. + +palschema_configure_build_environment() { + if [[ -n "${XDG_CACHE_HOME:-}" ]]; then + palschema_cache_root="$XDG_CACHE_HOME/palschema" + else + user_home_dir="${HOME:?HOME must be set when XDG_CACHE_HOME is unset}" + palschema_cache_root="$user_home_dir/.cache/palschema" + fi + + export PALSCHEMA_CACHE_ROOT="${PALSCHEMA_CACHE_ROOT:-$palschema_cache_root}" + export PALSCHEMA_RUSTUP_HOME="${PALSCHEMA_RUSTUP_HOME:-$PALSCHEMA_CACHE_ROOT/rustup}" + export PALSCHEMA_CARGO_HOME="${PALSCHEMA_CARGO_HOME:-$PALSCHEMA_CACHE_ROOT/cargo}" + + if [[ -x "$PALSCHEMA_CARGO_HOME/bin/rustup" ]]; then + export RUSTUP_HOME="$PALSCHEMA_RUSTUP_HOME" + export CARGO_HOME="$PALSCHEMA_CARGO_HOME" + export PATH="$CARGO_HOME/bin:$PATH" + fi +} diff --git a/scripts/package-linux.sh b/scripts/package-linux.sh new file mode 100755 index 0000000..5af8200 --- /dev/null +++ b/scripts/package-linux.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash + +set -euo pipefail + +show_usage() { + printf '%s\n' \ + "Usage: scripts/package-linux.sh [dev|shipping] [--output-dir PATH]" \ + "" \ + "Packages a Linux-built Win64 DLL in the standard UE4SS mod layout." +} + +build_flavor="${1:-shipping}" +if (($# > 0)); then + shift +fi + +output_dir="" +while (($# > 0)); do + case "$1" in + --output-dir) + if (($# < 2)); then + printf '%s\n' "--output-dir requires a path." >&2 + exit 2 + fi + output_dir="$2" + shift + ;; + -h|--help) + show_usage + exit 0 + ;; + *) + printf 'Unknown option: %s\n\n' "$1" >&2 + show_usage >&2 + exit 2 + ;; + esac + shift +done + +case "$build_flavor" in + dev) + preset="win64-xwin-dev" + archive_suffix="_Dev" + ;; + shipping) + preset="win64-xwin-shipping" + archive_suffix="" + ;; + -h|--help) + show_usage + exit 0 + ;; + *) + printf 'Unknown build flavor: %s\n\n' "$build_flavor" >&2 + show_usage >&2 + exit 2 + ;; +esac + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +project_root="$(cd -- "$script_dir/.." && pwd)" +artifact="$project_root/build/$preset/PalSchema.dll" +pdb="$project_root/build/$preset/PalSchema.pdb" + +if [[ ! -f "$artifact" ]]; then + printf 'Missing build artifact: %s\n' "$artifact" >&2 + printf 'Build it first with: scripts/build-linux.sh %s\n' "$build_flavor" >&2 + exit 1 +fi + +for required_command in install mktemp zip; do + if ! command -v "$required_command" >/dev/null 2>&1; then + printf 'Missing required packaging command: %s\n' "$required_command" >&2 + exit 1 + fi +done + +version="$( + awk ' + /VERSION_MAJOR[[:space:]]+[0-9]+/ { major = $3 } + /VERSION_MINOR[[:space:]]+[0-9]+/ { minor = $3 } + /VERSION_REVISION[[:space:]]+[0-9]+/ { revision = $3 } + END { printf "%s.%s.%s", major, minor, revision } + ' "$project_root/version.h" +)" + +if [[ -z "$output_dir" ]]; then + output_dir="$project_root/dist" +fi +mkdir -p "$output_dir" +output_dir="$(cd -- "$output_dir" && pwd)" + +package_dir="$(mktemp -d "${TMPDIR:-/tmp}/palschema-package.XXXXXX")" +trap 'rm -rf -- "$package_dir"' EXIT + +mod_root="$package_dir/PalSchema" +install -d "$mod_root/dlls" "$mod_root/mods" +install -m 0644 "$artifact" "$mod_root/dlls/main.dll" +touch "$mod_root/enabled.txt" + +if [[ "$build_flavor" == dev ]]; then + if [[ ! -f "$pdb" ]]; then + printf 'Missing Dev debug symbols: %s\n' "$pdb" >&2 + exit 1 + fi + + install -m 0644 "$pdb" "$mod_root/dlls/main.pdb" + cp -a "$project_root/assets/.vscode" "$mod_root/.vscode" + cp -a "$project_root/assets/examples" "$mod_root/examples" + cp -a "$project_root/assets/schemas" "$mod_root/schemas" +fi + +archive_name="PalSchema_${version}_Win64${archive_suffix}.zip" +temporary_archive="$package_dir/$archive_name" +( + cd "$package_dir" + zip -q -r "$temporary_archive" PalSchema +) +install -m 0644 "$temporary_archive" "$output_dir/$archive_name" + +printf 'Packaged %s\n' "$output_dir/$archive_name" diff --git a/scripts/verify-win64-artifact.py b/scripts/verify-win64-artifact.py new file mode 100755 index 0000000..92f2ecd --- /dev/null +++ b/scripts/verify-win64-artifact.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 + +"""Verify and describe the stable PE contract of a PalSchema Win64 DLL.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import shutil +import subprocess +import sys +from pathlib import Path + + +EXPECTED_EXPORTS = {"start_mod", "uninstall_mod"} +REQUIRED_DLL_CHARACTERISTICS = { + "IMAGE_DLL_CHARACTERISTICS_DYNAMIC_BASE", + "IMAGE_DLL_CHARACTERISTICS_HIGH_ENTROPY_VA", + "IMAGE_DLL_CHARACTERISTICS_NX_COMPAT", +} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("artifact", type=Path, help="PalSchema.dll or main.dll") + parser.add_argument( + "--json-output", + type=Path, + help="Write the verified contract to this JSON file.", + ) + parser.add_argument( + "--llvm-readobj", + default="llvm-readobj", + help="llvm-readobj executable name or path.", + ) + return parser.parse_args() + + +def inspect_artifact(artifact: Path, llvm_readobj: str) -> dict[str, object]: + executable = shutil.which(llvm_readobj) + if executable is None: + raise RuntimeError(f"Unable to find {llvm_readobj!r} on PATH.") + + completed = subprocess.run( + [ + executable, + "--file-headers", + "--coff-exports", + "--coff-imports", + str(artifact), + ], + check=True, + capture_output=True, + text=True, + ) + output = completed.stdout + + exports: set[str] = set() + imports: set[str] = set() + block: str | None = None + for raw_line in output.splitlines(): + line = raw_line.strip() + if line == "Export {": + block = "export" + continue + if line == "Import {": + block = "import" + continue + if line == "}": + block = None + continue + if not line.startswith("Name: "): + continue + + name = line.removeprefix("Name: ").strip() + if block == "export": + exports.add(name) + elif block == "import": + imports.add(name) + + missing_characteristics = sorted( + characteristic + for characteristic in REQUIRED_DLL_CHARACTERISTICS + if characteristic not in output + ) + errors: list[str] = [] + if "Format: COFF-x86-64" not in output: + errors.append("artifact is not COFF x86-64") + if "IMAGE_FILE_DLL" not in output: + errors.append("artifact is not marked as a DLL") + if exports != EXPECTED_EXPORTS: + errors.append( + "exports differ: " + f"expected {sorted(EXPECTED_EXPORTS)}, found {sorted(exports)}" + ) + if "UE4SS.dll" not in imports: + errors.append("dynamic UE4SS.dll import is missing") + if missing_characteristics: + errors.append( + "missing DLL security characteristics: " + + ", ".join(missing_characteristics) + ) + if errors: + raise RuntimeError("; ".join(errors)) + + digest = hashlib.sha256(artifact.read_bytes()).hexdigest() + return { + "schema_version": 1, + "artifact": artifact.name, + "sha256": digest, + "format": "COFF-x86-64", + "exports": sorted(exports), + "imported_dlls": sorted(imports, key=str.casefold), + "dll_characteristics": sorted(REQUIRED_DLL_CHARACTERISTICS), + } + + +def main() -> int: + args = parse_args() + artifact = args.artifact.resolve() + if not artifact.is_file(): + print(f"Artifact does not exist: {artifact}", file=sys.stderr) + return 1 + + try: + contract = inspect_artifact(artifact, args.llvm_readobj) + except (OSError, RuntimeError, subprocess.CalledProcessError) as error: + print(f"Win64 artifact verification failed: {error}", file=sys.stderr) + return 1 + + serialized = json.dumps(contract, indent=2) + "\n" + if args.json_output: + output_path = args.json_output.resolve() + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(serialized, encoding="utf-8") + print(f"Verified {artifact}; wrote {output_path}") + else: + print(serialized, end="") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/dllmain.cpp b/src/dllmain.cpp index 8fa0561..10ed84c 100644 --- a/src/dllmain.cpp +++ b/src/dllmain.cpp @@ -7,6 +7,7 @@ #include "SDK/PalSignatures.h" #include "SDK/Classes/Async.h" #include "SDK/UnrealOffsets.h" +#include "Platform/Export.h" #include "../version.h" using namespace RC; @@ -112,7 +113,6 @@ class PalSchema : public RC::CppUserModBase }; -#define PALSCHEMA_API __declspec(dllexport) extern "C" { PALSCHEMA_API RC::CppUserModBase* start_mod() From 0e7cece5572bb2c05133d9753ee66bdb67114274 Mon Sep 17 00:00:00 2001 From: LAP87 Date: Sat, 25 Jul 2026 10:53:28 +0200 Subject: [PATCH 2/5] feat: add Linux authoring and server support --- .github/workflows/build.yml | 55 +- .gitignore | 7 + README.md | 6 +- assets/.vscode/settings.json | 54 +- assets/schemas/buildings.schema.json | 3 +- assets/schemas/items.schema.json | 24 +- assets/schemas/pals.schema.json | 33 +- assets/schemas/schema-index.json | 98 + assets/schemas/skins.schema.json | 3 +- assets/schemas/utility.schema.json | 4 +- docs/linux/authoring-tools.md | 172 + docs/linux/building-win64.md | 34 +- docs/linux/dedicated-server.md | 147 + docs/linux/proton-deployment.md | 24 +- package-lock.json | 5026 +++++++++++++++++ package.json | 14 + scripts/bootstrap-linux.sh | 30 +- scripts/deploy-proton.sh | 93 +- src/Loader/PalMainLoader.cpp | 2 + src/SDK/PalSignatures.cpp | 43 +- src/dllmain.cpp | 12 +- tools/palschema-tools/LICENSE | 21 + tools/palschema-tools/README.md | 30 + tools/palschema-tools/package.json | 39 + .../palschema-tools/scripts/copy-schemas.mjs | 12 + .../scripts/update-schema-index.mjs | 23 + tools/palschema-tools/src/cli.ts | 550 ++ tools/palschema-tools/src/configuration.ts | 76 + tools/palschema-tools/src/index.ts | 16 + tools/palschema-tools/src/jsonc-document.ts | 30 + tools/palschema-tools/src/lsp-server.ts | 203 + tools/palschema-tools/src/schema-registry.ts | 119 + tools/palschema-tools/src/text-position.ts | 23 + tools/palschema-tools/src/types.ts | 67 + tools/palschema-tools/src/validator.ts | 235 + tools/palschema-tools/test/cli-smoke.test.mjs | 55 + .../test/configuration.test.ts | 32 + .../test/jsonc-document.test.ts | 27 + tools/palschema-tools/test/lsp-smoke.test.mjs | 221 + tools/palschema-tools/test/validator.test.ts | 121 + tools/palschema-tools/tsconfig.json | 21 + tools/vscode-palschema/.vscodeignore | 5 + tools/vscode-palschema/LICENSE | 21 + tools/vscode-palschema/README.md | 12 + tools/vscode-palschema/package.json | 71 + tools/vscode-palschema/scripts/build.mjs | 40 + .../scripts/package-extension.mjs | 41 + tools/vscode-palschema/src/extension.ts | 108 + tools/vscode-palschema/tsconfig.json | 17 + 49 files changed, 8021 insertions(+), 99 deletions(-) create mode 100644 assets/schemas/schema-index.json create mode 100644 docs/linux/authoring-tools.md create mode 100644 docs/linux/dedicated-server.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 tools/palschema-tools/LICENSE create mode 100644 tools/palschema-tools/README.md create mode 100644 tools/palschema-tools/package.json create mode 100644 tools/palschema-tools/scripts/copy-schemas.mjs create mode 100644 tools/palschema-tools/scripts/update-schema-index.mjs create mode 100644 tools/palschema-tools/src/cli.ts create mode 100644 tools/palschema-tools/src/configuration.ts create mode 100644 tools/palschema-tools/src/index.ts create mode 100644 tools/palschema-tools/src/jsonc-document.ts create mode 100644 tools/palschema-tools/src/lsp-server.ts create mode 100644 tools/palschema-tools/src/schema-registry.ts create mode 100644 tools/palschema-tools/src/text-position.ts create mode 100644 tools/palschema-tools/src/types.ts create mode 100644 tools/palschema-tools/src/validator.ts create mode 100644 tools/palschema-tools/test/cli-smoke.test.mjs create mode 100644 tools/palschema-tools/test/configuration.test.ts create mode 100644 tools/palschema-tools/test/jsonc-document.test.ts create mode 100644 tools/palschema-tools/test/lsp-smoke.test.mjs create mode 100644 tools/palschema-tools/test/validator.test.ts create mode 100644 tools/palschema-tools/tsconfig.json create mode 100644 tools/vscode-palschema/.vscodeignore create mode 100644 tools/vscode-palschema/LICENSE create mode 100644 tools/vscode-palschema/README.md create mode 100644 tools/vscode-palschema/package.json create mode 100644 tools/vscode-palschema/scripts/build.mjs create mode 100644 tools/vscode-palschema/scripts/package-extension.mjs create mode 100644 tools/vscode-palschema/src/extension.ts create mode 100644 tools/vscode-palschema/tsconfig.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 78a02e9..4fc90eb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout public source - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: submodules: false @@ -35,7 +35,54 @@ jobs: run: python -m py_compile scripts/verify-win64-artifact.py - name: Check JSON - run: python -m json.tool CMakePresets.json > /dev/null + run: | + python -m json.tool CMakePresets.json > /dev/null + python -m json.tool assets/.vscode/settings.json > /dev/null + python -m json.tool assets/schemas/schema-index.json > /dev/null + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: 22 + cache: npm + + - name: Install authoring-tool dependencies + run: npm ci + + - name: Audit authoring-tool dependencies + run: npm audit --audit-level=moderate + + - name: Type-check authoring tools and VS Code extension + run: | + npm run tools:typecheck + npm run typecheck --workspace palschema-vscode + + - name: Test CLI, schemas, and language server + run: | + npm run tools:test + npm run tools:build + node tools/palschema-tools/dist/cli.js schemas verify + node tools/palschema-tools/dist/cli.js validate \ + --allow-missing-generated \ + assets/examples + + - name: Build distributable authoring tools + run: | + npm run editor:build + npm run editor:package + npm pack \ + --workspace @palschema/tools \ + --pack-destination dist + + - name: Upload authoring-tool packages + uses: actions/upload-artifact@v7 + with: + name: PalSchema-authoring-tools + if-no-files-found: error + retention-days: 7 + path: | + dist/palschema-vscode-*.vsix + dist/palschema-tools-*.tgz windows-msvc-shipping: name: Windows MSVC Shipping baseline @@ -49,7 +96,7 @@ jobs: shell: pwsh steps: - name: Checkout authorized recursive dependencies - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 submodules: recursive @@ -84,7 +131,7 @@ jobs: --json-output build/win64-msvc-ci-shipping/pe-contract.json - name: Upload public build outputs - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: PalSchema-Win64-MSVC-Shipping if-no-files-found: error diff --git a/.gitignore b/.gitignore index c2d58f1..443ce50 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,10 @@ compile_commands.json CTestTestfile.cmake _deps CMakeUserPresets.json + +# Node authoring tools +node_modules/ + +# Python verification cache +__pycache__/ +*.pyc diff --git a/README.md b/README.md index e6d8167..d363861 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,11 @@ the repository-pinned RE-UE4SS toolchain. See [Building the Win64 PalSchema DLL on Linux](docs/linux/building-win64.md). For an atomic install with a recorded rollback path, see -[Safe deployment to the Palworld client under Proton](docs/linux/proton-deployment.md). +[Safe deployment to Palworld under Proton or Wine](docs/linux/proton-deployment.md). +For a separately managed Win64 dedicated server on a Linux host, see +[Dedicated server development on Linux](docs/linux/dedicated-server.md). +For the cross-platform JSON/JSONC validator, language server, and VS Code +extension, see [PalSchema authoring tools](docs/linux/authoring-tools.md). UE4SS remains a separate runtime installation and is not bundled with PalSchema. diff --git a/assets/.vscode/settings.json b/assets/.vscode/settings.json index 792fdf1..fa7568a 100644 --- a/assets/.vscode/settings.json +++ b/assets/.vscode/settings.json @@ -1,24 +1,32 @@ { - "json.schemas": [ - { - "fileMatch": ["items/*.json"], - "url": "./schemas/items.schema.json" - }, - { - "fileMatch": ["pals/*.json"], - "url": "./schemas/pals.schema.json" - }, - { - "fileMatch": ["skins/*.json"], - "url": "./schemas/skins.schema.json" - }, - { - "fileMatch": ["buildings/*.json"], - "url": "./schemas/buildings.schema.json" - }, - { - "fileMatch": ["raw/*.json"], - "url": "./schemas/raw.schema.json" - }, - ] -} \ No newline at end of file + "json.schemas": [ + { + "fileMatch": [ + "**/items/**/*.json", + "**/items/**/*.jsonc" + ], + "url": "./schemas/items.schema.json" + }, + { + "fileMatch": [ + "**/pals/**/*.json", + "**/pals/**/*.jsonc" + ], + "url": "./schemas/pals.schema.json" + }, + { + "fileMatch": [ + "**/skins/**/*.json", + "**/skins/**/*.jsonc" + ], + "url": "./schemas/skins.schema.json" + }, + { + "fileMatch": [ + "**/buildings/**/*.json", + "**/buildings/**/*.jsonc" + ], + "url": "./schemas/buildings.schema.json" + } + ] +} diff --git a/assets/schemas/buildings.schema.json b/assets/schemas/buildings.schema.json index 4b1d9c2..08bfe57 100644 --- a/assets/schemas/buildings.schema.json +++ b/assets/schemas/buildings.schema.json @@ -1,5 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://okaetsu.github.io/PalSchema/schemas/0.6.1/buildings.schema.json", "title": "Buildings Schema", "type": "object", "additionalProperties": { @@ -359,4 +360,4 @@ } } } -} \ No newline at end of file +} diff --git a/assets/schemas/items.schema.json b/assets/schemas/items.schema.json index 7df692e..68ee64d 100644 --- a/assets/schemas/items.schema.json +++ b/assets/schemas/items.schema.json @@ -1,5 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://okaetsu.github.io/PalSchema/schemas/0.6.1/items.schema.json", "title": "Items Schema", "type": "object", "additionalProperties": { @@ -64,10 +65,7 @@ "Weight": { "type": "number", "description": "Must be a float value e.g. 0.0", - "minimum": 0.0, - "not": { - "type": "integer" - } + "minimum": 0.0 }, "VisualBlueprintClassSoft": { "type": "string", @@ -78,10 +76,7 @@ "type": "number", "minimum": 0.0, "default": 0.0, - "description": "Determines if the item should expire after a certain amount of time. The higher the value, the longer it takes for one item in the stack to expire.\n0.0 means it will never spoil.\nMust be a float value e.g. 0.0", - "not": { - "type": "integer" - } + "description": "Determines if the item should expire after a certain amount of time. The higher the value, the longer it takes for one item in the stack to expire.\n0.0 means it will never spoil.\nMust be a float value e.g. 0.0" }, "OverrideNameMsgID": { "type": "string", @@ -104,10 +99,7 @@ "type": "number", "minimum": 0.0, "default": 0.0, - "description": "Determines how long it should take to craft this recipe.\nMust be a float value e.g. 0.0", - "not": { - "type": "integer" - } + "description": "Determines how long it should take to craft this recipe.\nMust be a float value e.g. 0.0" }, "WorkableAttribute": { "type": "integer", @@ -174,8 +166,8 @@ "properties": { "Type": { "const": "Weapon" }, "AttackPower": { "type": "integer" }, - "SneakAttackRate": { "type": "number", "description": "Must be a float value e.g. 0.0", "default": 1.0, "not": { "type": "integer" } }, - "Durability": { "type": "number", "description": "Must be a float value e.g. 0.0", "minimum": 0.0, "default": 100.0, "not": { "type": "integer" } }, + "SneakAttackRate": { "type": "number", "description": "Must be a float value e.g. 0.0", "default": 1.0 }, + "Durability": { "type": "number", "description": "Must be a float value e.g. 0.0", "minimum": 0.0, "default": 100.0 }, "actorClass": { "type": "string", "description": "This should be path to the Weapon blueprint that defines your weapon's logic.\nExample of a correct path format: /Game/Pal/Blueprint/Weapon/BP_MyWeapon.BP_MyWeapon_C\nIn FModel or Unreal Editor, the path would look like Pal/Content/Pal/Blueprint/Weapon/BP_MyWeapon" @@ -193,7 +185,7 @@ "DefenseValue": { "type": "integer", "minimum": 0 }, "HPValue": { "type": "integer", "minimum": 0 }, "ShieldValue": { "type": "integer", "minimum": 0 }, - "Durability": { "type": "number", "description": "Must be a float value e.g. 0.0", "minimum": 0.0, "default": 100.0, "not": { "type": "integer" } }, + "Durability": { "type": "number", "description": "Must be a float value e.g. 0.0", "minimum": 0.0, "default": 100.0 }, "PassiveSkill": { "type": "string" }, "PassiveSkill2": { "type": "string" }, "PassiveSkill3": { "type": "string" }, @@ -203,4 +195,4 @@ } ] } -} \ No newline at end of file +} diff --git a/assets/schemas/pals.schema.json b/assets/schemas/pals.schema.json index f3ea896..4db5798 100644 --- a/assets/schemas/pals.schema.json +++ b/assets/schemas/pals.schema.json @@ -1,5 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://okaetsu.github.io/PalSchema/schemas/0.6.1/pals.schema.json", "title": "Pal Schema", "type": "object", "additionalProperties": { @@ -82,49 +83,42 @@ "type": "number", "description": "Health multiplier for wild pals, will not apply on player owned pals.\nMust be a float value e.g. 1.0", "minimum": 0.0, - "default": 1.0, - "not": { "type": "integer" } + "default": 1.0 }, "EnemyReceiveDamageRate": { "type": "number", "description": "Must be a float value e.g. 1.0", "minimum": 0.0, - "default": 1.0, - "not": { "type": "integer" } + "default": 1.0 }, "EnemyInflictDamageRate": { "type": "number", "description": "Must be a float value e.g. 1.0", "minimum": 0.0, - "default": 1.0, - "not": { "type": "integer" } + "default": 1.0 }, "CaptureRateCorrect": { "type": "number", "description": "Must be a float value e.g. 1.0", "minimum": 0.0, - "default": 1.0, - "not": { "type": "integer" } + "default": 1.0 }, "ExpRatio": { "type": "number", "description": "Must be a float value e.g. 1.0", "minimum": 0.0, - "default": 1.0, - "not": { "type": "integer" } + "default": 1.0 }, "Price": { "type": "number", "description": "Must be a float value e.g. 0.0", "minimum": 0.0, - "default": 0.0, - "not": { "type": "integer" } + "default": 0.0 }, "StatusResistUpRate": { "type": "number", "description": "Must be a float value e.g. 1.0", - "default": 1.0, - "not": { "type": "integer" } + "default": 1.0 }, "AIResponse": { "type": "string", @@ -182,8 +176,7 @@ "type": "number", "description": "Must be a float value e.g. 1.0", "minimum": 0.0, - "default": 1.0, - "not": { "type": "integer" } + "default": 1.0 }, "FoodAmount": { "type": "integer" @@ -198,8 +191,7 @@ "type": "number", "description": "Must be a float value e.g. 0.0", "minimum": 0.0, - "default": 1.0, - "not": { "type": "integer" } + "default": 1.0 }, "NooseTrap": { "type": "boolean", @@ -362,8 +354,7 @@ "minimum": 0.0, "default": 0.00, "multipleOf": 0.1, - "examples": [0.00, 1.5, 2.3], - "not": { "type": "integer" } + "examples": [0.00, 1.5, 2.3] }, "Min": { "type": "integer" @@ -377,4 +368,4 @@ } } } -} \ No newline at end of file +} diff --git a/assets/schemas/schema-index.json b/assets/schemas/schema-index.json new file mode 100644 index 0000000..e492a08 --- /dev/null +++ b/assets/schemas/schema-index.json @@ -0,0 +1,98 @@ +{ + "formatVersion": 1, + "palSchemaVersion": "0.6.1", + "baseId": "https://okaetsu.github.io/PalSchema/schemas/0.6.1/", + "schemas": [ + { + "id": "https://okaetsu.github.io/PalSchema/schemas/0.6.1/buildings.schema.json", + "file": "buildings.schema.json", + "folder": "buildings", + "patterns": [ + "**/buildings/**/*.json", + "**/buildings/**/*.jsonc" + ], + "generated": false, + "dependencies": [ + "enums.schema.json" + ], + "sha256": "26368e4f00ecf3503da4226dc1b25c014fe19d952e3161504f016c80343bfa52" + }, + { + "id": "https://okaetsu.github.io/PalSchema/schemas/0.6.1/items.schema.json", + "file": "items.schema.json", + "folder": "items", + "patterns": [ + "**/items/**/*.json", + "**/items/**/*.jsonc" + ], + "generated": false, + "dependencies": [ + "enums.schema.json" + ], + "sha256": "1d07f4983a270f43f20fe618b1cfca0dfba3be698998e19129e3fa3957ab1e74" + }, + { + "id": "https://okaetsu.github.io/PalSchema/schemas/0.6.1/pals.schema.json", + "file": "pals.schema.json", + "folder": "pals", + "patterns": [ + "**/pals/**/*.json", + "**/pals/**/*.jsonc" + ], + "generated": false, + "dependencies": [ + "enums.schema.json" + ], + "sha256": "f510386ac1a4a50242de45bb90f921ab381cbca40a0d52ef730578bd255e340e" + }, + { + "id": "https://okaetsu.github.io/PalSchema/schemas/0.6.1/skins.schema.json", + "file": "skins.schema.json", + "folder": "skins", + "patterns": [ + "**/skins/**/*.json", + "**/skins/**/*.jsonc" + ], + "generated": false, + "dependencies": [ + "enums.schema.json" + ], + "sha256": "361c6c625c914a8880a11e03015dd9180ee772f71848d104fea0fff14bcfb43b" + }, + { + "id": "https://okaetsu.github.io/PalSchema/schemas/0.6.1/utility.schema.json", + "file": "utility.schema.json", + "folder": null, + "patterns": [], + "generated": false, + "dependencies": [], + "sha256": "e754d5c2f060871e130d8b65c88f74ff6916319bdf48074e43b53c9a70254390" + }, + { + "id": "https://okaetsu.github.io/PalSchema/schemas/0.6.1/enums.schema.json", + "file": "enums.schema.json", + "folder": "enums", + "patterns": [ + "**/enums/**/*.json", + "**/enums/**/*.jsonc" + ], + "generated": true, + "dependencies": [], + "sha256": null + }, + { + "id": "https://okaetsu.github.io/PalSchema/schemas/0.6.1/raw.schema.json", + "file": "raw.schema.json", + "folder": "raw", + "patterns": [ + "**/raw/**/*.json", + "**/raw/**/*.jsonc" + ], + "generated": true, + "dependencies": [ + "enums.schema.json" + ], + "sha256": null + } + ] +} diff --git a/assets/schemas/skins.schema.json b/assets/schemas/skins.schema.json index 53d6445..0c1df71 100644 --- a/assets/schemas/skins.schema.json +++ b/assets/schemas/skins.schema.json @@ -1,5 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://okaetsu.github.io/PalSchema/schemas/0.6.1/skins.schema.json", "title": "Skin Schema", "type": "object", "additionalProperties": { @@ -35,4 +36,4 @@ }, "required": ["NormalCharacterClass", "BossCharacterClass", "SkinType", "TargetPalName", "bAutoGetItem", "IconTexture"] } -} \ No newline at end of file +} diff --git a/assets/schemas/utility.schema.json b/assets/schemas/utility.schema.json index 278b4a7..e127339 100644 --- a/assets/schemas/utility.schema.json +++ b/assets/schemas/utility.schema.json @@ -1,4 +1,6 @@ { + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://okaetsu.github.io/PalSchema/schemas/0.6.1/utility.schema.json", "definitions": { "ObjectPathRegex": { "description": "Object Path must start with /Game/ and end with AssetName.AssetName\nExample: /Game/Others/InventoryItemIcon/Texture/T_itemicon_Armor_Head015.T_itemicon_Armor_Head015", @@ -9,4 +11,4 @@ "pattern": "^/Game(?:/[^/]+)*/([A-Za-z0-9_]+)\\.\\1_C$" } } -} \ No newline at end of file +} diff --git a/docs/linux/authoring-tools.md b/docs/linux/authoring-tools.md new file mode 100644 index 0000000..6092daf --- /dev/null +++ b/docs/linux/authoring-tools.md @@ -0,0 +1,172 @@ +# PalSchema authoring tools + +PalSchema includes a cross-platform authoring toolchain for Linux, Windows, +and macOS: + +- a JSON and JSONC validator with stable diagnostics and exit codes; +- a schema registry with checksums and explicit generated-schema status; +- a Language Server Protocol (LSP) server; +- an installable VS Code-compatible extension; +- portable workspace initialization; +- watch mode for editors and command-line workflows. + +The tools do not contain Palworld data, UE4SS, UEPseudo, or the +runtime-generated `enums.schema.json` and `raw.schema.json`. Those generated +schemas belong to the user's own game/runtime installation and can be copied +into a workspace when available. + +## Requirements + +- Node.js 20 or newer. +- npm 10 or newer is recommended. + +There are no distro-specific runtime dependencies. The same npm package and +VSIX work on Arch/CachyOS, Debian/Ubuntu, Fedora/RHEL-family distributions, +openSUSE, and other current Linux distributions for which Node.js and a +VS Code-compatible editor are available. + +## Build from this repository + +```bash +npm ci +npm run tools:typecheck +npm run tools:test +npm run tools:build +npm run editor:build +npm run editor:package +``` + +The resulting packages are written to `dist/`: + +- `palschema-tools-0.6.1.tgz` after running the npm pack command below; +- `palschema-vscode-0.6.1.vsix`. + +Build the npm tarball with: + +```bash +npm pack --workspace @palschema/tools --pack-destination dist +``` + +## Initialize a mod workspace + +After installing the package globally or invoking its built CLI, run: + +```bash +palschema init /path/to/MyPalSchemaMod +``` + +This creates: + +- `.palschema/schemas/` with the redistributable schema pack; +- `.vscode/settings.json` with JSON and JSONC mappings; +- `palschema.config.json` with a portable relative schema path. + +Existing `.vscode/settings.json` or `palschema.config.json` files are never +overwritten unless `--force` is explicitly supplied. Review existing editor +settings before using that option. + +The CLI searches parent directories for `palschema.config.json`, so commands +work from nested mod folders. `--schema-dir` overrides project configuration, +and `PALSCHEMA_SCHEMA_DIR` provides an environment-level override. + +## Validate and watch + +```bash +palschema validate . +palschema validate --format json . +palschema validate --watch . +palschema validate --strict-generated . +``` + +JSONC comments and trailing commas are supported. Exit codes are: + +- `0`: no errors; warnings may be present; +- `1`: at least one validation error; +- `2`: invalid arguments, configuration, or an internal failure. + +By default, a missing runtime-generated schema is an error. An initialized +workspace opts into warnings with `allowMissingGenerated: true`, which keeps +offline structural checks useful while clearly reporting that enum or raw +table constraints are incomplete. Use `--strict-generated` to override that +setting for release checks. + +Inspect the active schema pack with: + +```bash +palschema schemas list +palschema schemas verify +palschema doctor +palschema print-config +``` + +`schemas verify` checks every redistributable schema against the SHA-256 value +in `schema-index.json`. Missing generated schemas are reported separately and +do not make the command fail. + +## Add game-generated schemas + +PalSchema writes `enums.schema.json` and `raw.schema.json` from the current +game/runtime data. Copy those two files from your own PalSchema schema output +into: + +```text +.palschema/schemas/ +``` + +Then update their `sha256` values in a private/local copy of +`schema-index.json` if you want the local pack pinned to exact generated +content. Generated entries with a `null` checksum are accepted and reported as +`present-generated`; redistributable static schemas always require an exact +checksum match. Do not commit generated Palworld data to this repository. + +## VS Code and compatible editors + +Install the locally built extension: + +```bash +code --install-extension dist/palschema-vscode-0.6.1.vsix +``` + +VSCodium and many VS Code-derived editors accept the same VSIX through their +extension installation UI or equivalent CLI. + +The extension's language client attaches only to JSON/JSONC documents inside +known PalSchema loader folders. It provides: + +- JSON/JSONC diagnostics from the same validator as the CLI; +- property and value completion from the selected schema; +- schema hover documentation; +- document symbols; +- whole-document and range formatting. + +Settings: + +- `palschema.schemaDirectory`: an absolute path or a path relative to the first + workspace folder; empty uses the bundled redistributable schemas. +- `palschema.allowMissingGenerated`: downgrade absent runtime-generated + schemas to warnings. + +Run **PalSchema: Restart Language Server** after manually replacing a schema +pack. Configuration changes restart it automatically. + +## Generic LSP clients + +The npm package exposes `palschema-lsp`. Editors can start it with standard I/O: + +```bash +palschema-lsp --stdio +``` + +Pass these LSP initialization options when the client supports them: + +```json +{ + "schemaDirectory": "/absolute/path/to/schemas", + "allowMissingGenerated": true +} +``` + +The server implements incremental document synchronization, diagnostics, +completion, hover, symbols, and formatting. It uses only the selected local +schema directory and does not contact the game, Steam, UE4SS, or a network +service. diff --git a/docs/linux/building-win64.md b/docs/linux/building-win64.md index 0a4b32b..2e9cd9d 100644 --- a/docs/linux/building-win64.md +++ b/docs/linux/building-win64.md @@ -23,6 +23,29 @@ The generated DLL is portable across Linux distributions because it targets the Windows ABI. The build host may use Arch/CachyOS, Debian/Ubuntu, Fedora, openSUSE, or another distribution that provides the listed tools. +Typical host packages are: + +| Distribution family | Packages | +| --- | --- | +| Arch / CachyOS | `clang cmake curl git lld llvm ninja` | +| Debian | `clang clang-tools cmake curl git lld llvm ninja-build util-linux` | +| Ubuntu | `clang clang-tools cmake curl git lld llvm ninja-build util-linux` | +| Fedora / RHEL family | `clang clang-tools-extra cmake curl git lld llvm ninja-build util-linux` | +| openSUSE | `clang cmake curl git lld llvm ninja util-linux` | + +Package names can change between distribution releases. Run +`scripts/bootstrap-linux.sh` after installation; it checks the exact commands +the build uses, including `clang-cl`, `lld-link`, `llvm-lib`, `llvm-mt`, +`llvm-rc`, `llvm-ranlib`, `flock`, and Ninja. The check accepts the +distribution's version-suffixed LLVM tools, such as Ubuntu's `clang-cl-18`, +without creating system symlinks. Rust can remain project-isolated as +described below. + +On 2026-07-25 the host-tool gate and shell scripts were exercised in clean +Ubuntu 24.04, Debian 13, Fedora 42, Arch Linux, and openSUSE Tumbleweed +containers. The complete SDK preparation, Dev/Shipping cross-build, PE +verification, packaging, and Proton/Wine runtime tests were run on CachyOS. + ## Epic and UEPseudo access The recursive RE-UE4SS dependency includes the private UEPseudo repository. @@ -136,7 +159,10 @@ the secret-bearing job. ## What this does not provide -This target is for the Windows Palworld client running under Proton. It is not -a native ELF plugin for the Linux Palworld Dedicated Server. Native server -support requires a suitable native UE4SS C++ mod ABI plus Linux-specific -signatures, layouts, hooks, and integration testing. +This target produces a Win64 DLL for the Windows Palworld client under Proton +and the Win64 Dedicated Server under Wine or Proton. It is not a native ELF +plugin for `PalServer-Linux-Shipping`. Native server support requires a stable +native UE4SS C++ mod ABI plus Linux-specific signatures, layouts, hooks, and +integration testing. See +[Dedicated server development on Linux](dedicated-server.md) for the currently +verified server path and native-runtime status. diff --git a/docs/linux/dedicated-server.md b/docs/linux/dedicated-server.md new file mode 100644 index 0000000..ff15f2b --- /dev/null +++ b/docs/linux/dedicated-server.md @@ -0,0 +1,147 @@ +# Dedicated server development on Linux + +PalSchema's supported dedicated-server path on a Linux host is the **Win64 +Palworld Dedicated Server running through Wine or Proton**. It uses the same +PalSchema DLL and UE4SS C++ mod ABI as the Windows client. + +The native `PalServer-Linux-Shipping` binary is not currently a 1:1 PalSchema +target. PalSchema is a Win64 UE4SS C++ mod, while the upstream native UE4SS +port remains under development. The upstream headless Linux draft explicitly +leaves C++ mods outside its initial scope: +[UE4SS pull request 1347](https://github.com/UE4SS-RE/RE-UE4SS/pull/1347). +An experimental downstream Linux release exists, but it does not provide the +full stable `CppUserModBase` ABI used by PalSchema. Do not treat it as a +drop-in replacement for this workflow. + +## Ownership boundary + +This repository builds and deploys only PalSchema. + +You must install and maintain UE4SS yourself. PalSchema does not download, +redistribute, package, or modify UE4SS. The same boundary applies to Palworld +and its Dedicated Server files. + +## Requirements + +- a recent 64-bit Wine or Proton installation; +- SteamCMD; +- a separately acquired Win64 Palworld Dedicated Server; +- a compatible UE4SS installation placed in that server; +- a completed PalSchema Shipping build. + +The workflow uses portable shell, CMake, Ninja, clang-cl, Node.js, and Wine +interfaces. It is not tied to CachyOS or Arch. Package names differ, but the +same repository commands apply on current Arch/CachyOS, Debian/Ubuntu, +Fedora/RHEL-family, openSUSE, and other major distributions. + +## Acquire an isolated Win64 server + +SteamCMD can download the Windows depot even when SteamCMD itself runs on +Linux. Choose a dedicated directory that does not overlap an existing native +server: + +```bash +steamcmd \ + +@sSteamCmdForcePlatformType windows \ + +force_install_dir /srv/palworld-win64 \ + +login anonymous \ + +app_update 2394010 validate \ + +quit +``` + +Valve documents `@sSteamCmdForcePlatformType` in the +[SteamCMD reference](https://developer.valvesoftware.com/wiki/SteamCMD). +Back up any existing server configuration and save data before updating. + +Install UE4SS into the resulting Win64 server according to the UE4SS and +Palworld-specific instructions you have chosen. Before continuing, the layout +must include: + +```text +/srv/palworld-win64/ +└── Pal/Binaries/Win64/ + ├── PalServer-Win64-Shipping-Cmd.exe + ├── dwmapi.dll + └── ue4ss/ + ├── UE4SS.dll + ├── UE4SS-settings.ini + ├── MemberVariableLayout.ini + └── Mods/ +``` + +These UE4SS files are examples of the required external installation. They +are never copied from or included in a PalSchema package. + +## Build and deploy PalSchema + +```bash +scripts/build-linux.sh shipping +scripts/deploy-proton.sh shipping \ + --target server \ + --game-dir /srv/palworld-win64 \ + --dry-run +``` + +Review the paths, remove `--dry-run`, and keep the printed rollback command. +Deployment is atomic, creates a timestamped backup, and refuses to proceed +while the selected Win64 server is active. + +## Start under Wine + +Use a dedicated Wine prefix so server dependencies and settings remain +isolated: + +```bash +cd /srv/palworld-win64/Pal/Binaries/Win64 + +WINEPREFIX=/srv/palworld-win64/.compat/pfx \ +WINEDLLOVERRIDES='dwmapi=n,b' \ +WINEDEBUG=-all \ +SteamAppId=2394010 \ +wine ./PalServer-Win64-Shipping-Cmd.exe Pal \ + -port=8211 \ + -queryport=27015 \ + -players=32 \ + -useperfthreads \ + -NoAsyncLoadingThread \ + -UseMultithreadForDS +``` + +Adjust ports and normal Palworld server arguments for your environment. Do not +reuse ports already owned by another server. + +PalSchema detects Wine at runtime and selects UE4SS's synchronous signature +scanner path during startup. Native Windows keeps the normal parallel scanner. +This avoids a Wine `std::async` startup deadlock without changing signatures, +loader behavior, or the produced Win64 DLL ABI. + +## Verification + +Check `Pal/Binaries/Win64/ue4ss/UE4SS.log` for: + +- `PalSchema v... loaded`; +- every expected `Found ...` signature line; +- `Event loop start`; +- initialization of `enums`, `raw`, `blueprints`, `resources`, `pals`, `npcs`, + `items`, `skins`, `appearance`, `buildings`, `helpguide`, `spawns`, and + `translations`. + +Also verify that the configured game and query ports are open. A process that +exists but never opens its ports is not a successful smoke test. + +The Linux-hosted workflow was exercised on 2026-07-25 against Win64 Dedicated +Server Steam build `24181105`: all 22 PalSchema signatures were found, all 13 +loaders initialized, UE4SS reached its event loop, and isolated UDP game/query +ports opened. The same DLL was then verified against the installed Palworld +client under Proton. This is a reproducibility record, not a promise that +future Palworld or UE4SS updates cannot change signatures or compatibility. + +## Updating and rollback + +Stop the Win64 server before every update or rollback. Rebuild, deploy, and +repeat the verification above after Palworld, UE4SS, Wine/Proton, or PalSchema +changes. + +Use the exact rollback command printed by `deploy-proton.sh`. It restores only +the previous `Mods/PalSchema` state and leaves UE4SS, other mods, the Wine +prefix, configuration, and saves untouched. diff --git a/docs/linux/proton-deployment.md b/docs/linux/proton-deployment.md index b6bbc84..2afecc2 100644 --- a/docs/linux/proton-deployment.md +++ b/docs/linux/proton-deployment.md @@ -1,4 +1,4 @@ -# Safe deployment to the Palworld client under Proton +# Safe deployment to Palworld under Proton or Wine PalSchema is a UE4SS C++ mod. Install UE4SS separately before using these commands; this repository neither downloads nor bundles it. @@ -7,7 +7,7 @@ Build and inspect the Shipping DLL first: ```bash scripts/build-linux.sh shipping -scripts/deploy-proton.sh shipping --dry-run +scripts/deploy-proton.sh shipping --target client --dry-run ``` The deploy helper discovers the default Steam library at @@ -16,6 +16,7 @@ pass its Palworld directory explicitly: ```bash scripts/deploy-proton.sh shipping \ + --target client \ --game-dir /mnt/games/SteamLibrary/steamapps/common/Palworld \ --dry-run ``` @@ -32,6 +33,25 @@ Remove `--dry-run` to deploy. The helper: It does not modify UE4SS, other mods, the Proton prefix, Palworld saves, or the native Dedicated Server. +For a Windows dedicated-server installation managed on a Linux host, select +the server target explicitly: + +```bash +scripts/deploy-proton.sh shipping \ + --target server \ + --game-dir /srv/palworld-win64 +``` + +Auto-detection prefers the client when both executable layouts exist. Use an +explicit target in automation. The server target recognizes both +`PalServer-Win64-Shipping.exe` and +`PalServer-Win64-Shipping-Cmd.exe`, refuses to deploy while either is active, +and records the target in its rollback metadata. + +See [Dedicated server development on Linux](dedicated-server.md) for acquiring +and starting the Win64 server. The native Linux PalServer executable is a +different runtime and cannot load PalSchema's Win64 UE4SS C++ mod. + Use the Dev flavor when schemas, examples, VS Code settings, and PDB symbols are needed: diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1e52d34 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,5026 @@ +{ + "name": "palschema-workspace", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "palschema-workspace", + "workspaces": [ + "tools/*" + ] + }, + "node_modules/@azu/format-text": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@azu/format-text/-/format-text-1.0.2.tgz", + "integrity": "sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@azu/style-format": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@azu/style-format/-/style-format-1.0.1.tgz", + "integrity": "sha512-AHcTojlNBdD/3/KxIKlg8sxIWHfOtQszLvOpagLTO+bjC3u7SAszu1lf//u7JJC50aUSH+BVWDD/KvaA6Gfn5g==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "@azu/format-text": "^1.0.1" + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.2.0.tgz", + "integrity": "sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.11.0.tgz", + "integrity": "sha512-IUZydyTUkDnYdstOW9pFOOUQlBjAepK5teihDE3x6yxsPJs/hsAaaYpeGxdxrgtOiJbBKSjKW7MDk7AEhb4LRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.11.0.tgz", + "integrity": "sha512-JjQWO6akOck45PH/XBrxzsQGAiKrfFl4m5iggJ0ItMIz5omRufOXWpqCPpdjKN3vKDzlSUvFjaMb7Zwf0gvAdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.25.0.tgz", + "integrity": "sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.4.0.tgz", + "integrity": "sha512-eGwxD0AtncrxeBM4tG8R55Pc3rdX1hNW2WibJAgYpCVA6E93mvvVH+LcssoVjOBrSKWS55yEIHsk0X8ctHmfOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.14.0.tgz", + "integrity": "sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.1.tgz", + "integrity": "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^5.5.0", + "@azure/msal-node": "^5.1.0", + "open": "^10.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.4.0.tgz", + "integrity": "sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.17.1.tgz", + "integrity": "sha512-zBhRGzABKSI7hfWh5EaZmril5ybZ7imBN1qEZl5sDTaelr+l8SnPjZO50Q4dnKnm347YPIlBMSnXKZyh3Yu5DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.11.2" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.11.2", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.11.2.tgz", + "integrity": "sha512-yDhtBOGDCdK9ipQ9g3+wmlMEPnZx2pXaDicDd9jYyR1L+7lEbvEohTDmF5qejZDutZY3m9pWPxeYxzNC701A2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.4.2.tgz", + "integrity": "sha512-fnqOpGYAV+i0RH4W5HB6Oy1IhqGZoCdnp7Y2Sa9k18FlT8aBkCA7L8Hv19hUHLDUK6kVjUO29AfnGX6wgAHyNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.11.2", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@palschema/tools": { + "resolved": "tools/palschema-tools", + "link": true + }, + "node_modules/@secretlint/config-creator": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.2.2.tgz", + "integrity": "sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/config-loader": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-10.2.2.tgz", + "integrity": "sha512-ndjjQNgLg4DIcMJp4iaRD6xb9ijWQZVbd9694Ol2IszBIbGPPkwZHzJYKICbTBmh6AH/pLr0CiCaWdGJU7RbpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/profiler": "^10.2.2", + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", + "ajv": "^8.17.1", + "debug": "^4.4.1", + "rc-config-loader": "^4.1.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/core": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-10.2.2.tgz", + "integrity": "sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/profiler": "^10.2.2", + "@secretlint/types": "^10.2.2", + "debug": "^4.4.1", + "structured-source": "^4.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/formatter": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-10.2.2.tgz", + "integrity": "sha512-10f/eKV+8YdGKNQmoDUD1QnYL7TzhI2kzyx95vsJKbEa8akzLAR5ZrWIZ3LbcMmBLzxlSQMMccRmi05yDQ5YDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", + "@textlint/linter-formatter": "^15.2.0", + "@textlint/module-interop": "^15.2.0", + "@textlint/types": "^15.2.0", + "chalk": "^5.4.1", + "debug": "^4.4.1", + "pluralize": "^8.0.0", + "strip-ansi": "^7.1.0", + "table": "^6.9.0", + "terminal-link": "^4.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/formatter/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@secretlint/node": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-10.2.2.tgz", + "integrity": "sha512-eZGJQgcg/3WRBwX1bRnss7RmHHK/YlP/l7zOQsrjexYt6l+JJa5YhUmHbuGXS94yW0++3YkEJp0kQGYhiw1DMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/config-loader": "^10.2.2", + "@secretlint/core": "^10.2.2", + "@secretlint/formatter": "^10.2.2", + "@secretlint/profiler": "^10.2.2", + "@secretlint/source-creator": "^10.2.2", + "@secretlint/types": "^10.2.2", + "debug": "^4.4.1", + "p-map": "^7.0.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/profiler": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-10.2.2.tgz", + "integrity": "sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig==", + "dev": true, + "license": "MIT" + }, + "node_modules/@secretlint/resolver": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-10.2.2.tgz", + "integrity": "sha512-3md0cp12e+Ae5V+crPQYGd6aaO7ahw95s28OlULGyclyyUtf861UoRGS2prnUrKh7MZb23kdDOyGCYb9br5e4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@secretlint/secretlint-formatter-sarif": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-formatter-sarif/-/secretlint-formatter-sarif-10.2.2.tgz", + "integrity": "sha512-ojiF9TGRKJJw308DnYBucHxkpNovDNu1XvPh7IfUp0A12gzTtxuWDqdpuVezL7/IP8Ua7mp5/VkDMN9OLp1doQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-sarif-builder": "^3.2.0" + } + }, + "node_modules/@secretlint/secretlint-rule-no-dotenv": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-no-dotenv/-/secretlint-rule-no-dotenv-10.2.2.tgz", + "integrity": "sha512-KJRbIShA9DVc5Va3yArtJ6QDzGjg3PRa1uYp9As4RsyKtKSSZjI64jVca57FZ8gbuk4em0/0Jq+uy6485wxIdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/secretlint-rule-preset-recommend": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-10.2.2.tgz", + "integrity": "sha512-K3jPqjva8bQndDKJqctnGfwuAxU2n9XNCPtbXVI5JvC7FnQiNg/yWlQPbMUlBXtBoBGFYp08A94m6fvtc9v+zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/source-creator": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/source-creator/-/source-creator-10.2.2.tgz", + "integrity": "sha512-h6I87xJfwfUTgQ7irWq7UTdq/Bm1RuQ/fYhA3dtTIAop5BwSFmZyrchph4WcoEvbN460BWKmk4RYSvPElIIvxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2", + "istextorbinary": "^9.5.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/types": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-10.2.2.tgz", + "integrity": "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@textlint/ast-node-types": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.7.1.tgz", + "integrity": "sha512-Wii5UgUKFEh9Uv6wbq1zr4/Kf+dtjiUuzPrrXzKp8H+ifkvKNzi23V4Nz+6wVyHQn5T28AFuc8VH8OtzvGYecA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/linter-formatter": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.7.1.tgz", + "integrity": "sha512-TdwZ/debWYFD05K3CcoHtwvnCrza29wZxD+BjDTk/V5N7iRqkK1dTTHSD4A8AIgROLiDkHJmIKQbasbmsg8AvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azu/format-text": "^1.0.2", + "@azu/style-format": "^1.0.1", + "@textlint/module-interop": "15.7.1", + "@textlint/resolver": "15.7.1", + "@textlint/types": "15.7.1", + "chalk": "^4.1.2", + "debug": "^4.4.3", + "js-yaml": "^4.1.1", + "lodash": "^4.18.1", + "pluralize": "^2.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "table": "^6.9.0", + "text-table": "^0.2.0" + } + }, + "node_modules/@textlint/linter-formatter/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@textlint/linter-formatter/node_modules/pluralize": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-2.0.0.tgz", + "integrity": "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/linter-formatter/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@textlint/module-interop": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.7.1.tgz", + "integrity": "sha512-Jg+sQW2L/cRJypk59wtcMUVVpt8vmit5ZMT3gUnFwevP3A6Qp1HfOtUy9ObT4hBX3lOSGT/ekcCDxR1pL7uH1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/resolver": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.7.1.tgz", + "integrity": "sha512-8XnO0pgF6mXnm41VvWmBbEIdGPhiCUt31uLZkOis1ECeg/1SoUcIT6Mx/F0e1rukq8l0UlOSeY9a31CsvRMK0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/types": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.7.1.tgz", + "integrity": "sha512-Vye/GmFNBTgVzZFtIFJTmLB+s2A7oIADxNG6r9UhfPuY+Czv0z5G3xeyFZZudPlfxURsKUyPIU5XsjOFqVp33A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@textlint/ast-node-types": "15.7.1" + } + }, + "node_modules/@types/node": { + "version": "24.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz", + "integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.8.0" + } + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/sarif": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", + "integrity": "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.7.tgz", + "integrity": "sha512-JVUD8X2tfDMWjcjLs4yVxxVrS8yR5vnh386GAXT9Qj79nBxxXSaHFQZg5FweLmT8HlPQ3kii6noUB+Z9RN7DvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@vscode/l10n": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz", + "integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==", + "license": "MIT" + }, + "node_modules/@vscode/vsce": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.9.2.tgz", + "integrity": "sha512-XSxMosEEDO6vLxELAHVkwmhC0qe0ijZni2jB9Rcs8kQsW4lhTDQ/wMzmwFs/buotAWSnpmUp/dRWD2ufG3UYKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/identity": "^4.1.0", + "@secretlint/node": "^10.1.2", + "@secretlint/secretlint-formatter-sarif": "^10.1.2", + "@secretlint/secretlint-rule-no-dotenv": "^10.1.2", + "@secretlint/secretlint-rule-preset-recommend": "^10.1.2", + "@vscode/vsce-sign": "^2.0.0", + "azure-devops-node-api": "^12.5.0", + "chalk": "^4.1.2", + "cheerio": "^1.0.0-rc.9", + "cockatiel": "^3.1.2", + "commander": "^12.1.0", + "form-data": "^4.0.0", + "glob": "^13.0.6", + "hosted-git-info": "^4.0.2", + "jsonc-parser": "^3.2.0", + "leven": "^3.1.0", + "markdown-it": "^14.1.0", + "mime": "^1.3.4", + "minimatch": "^10.2.2", + "parse-semver": "^1.1.1", + "read": "^1.0.7", + "secretlint": "^10.1.2", + "semver": "^7.5.2", + "tmp": "^0.2.3", + "typed-rest-client": "^1.8.4", + "url-join": "^4.0.1", + "xml2js": "^0.5.0", + "yauzl": "^3.2.1", + "yazl": "^2.2.2" + }, + "bin": { + "vsce": "vsce" + }, + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "keytar": "^7.7.0" + } + }, + "node_modules/@vscode/vsce-sign": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign/-/vsce-sign-2.0.9.tgz", + "integrity": "sha512-8IvaRvtFyzUnGGl3f5+1Cnor3LqaUWvhaUjAYO8Y39OUYlOf3cRd+dowuQYLpZcP3uwSG+mURwjEBOSq4SOJ0g==", + "dev": true, + "hasInstallScript": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optionalDependencies": { + "@vscode/vsce-sign-alpine-arm64": "2.0.6", + "@vscode/vsce-sign-alpine-x64": "2.0.6", + "@vscode/vsce-sign-darwin-arm64": "2.0.6", + "@vscode/vsce-sign-darwin-x64": "2.0.6", + "@vscode/vsce-sign-linux-arm": "2.0.6", + "@vscode/vsce-sign-linux-arm64": "2.0.6", + "@vscode/vsce-sign-linux-x64": "2.0.6", + "@vscode/vsce-sign-win32-arm64": "2.0.6", + "@vscode/vsce-sign-win32-x64": "2.0.6" + } + }, + "node_modules/@vscode/vsce-sign-alpine-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-arm64/-/vsce-sign-alpine-arm64-2.0.6.tgz", + "integrity": "sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-alpine-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-x64/-/vsce-sign-alpine-x64-2.0.6.tgz", + "integrity": "sha512-YoAGlmdK39vKi9jA18i4ufBbd95OqGJxRvF3n6ZbCyziwy3O+JgOpIUPxv5tjeO6gQfx29qBivQ8ZZTUF2Ba0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-arm64/-/vsce-sign-darwin-arm64-2.0.6.tgz", + "integrity": "sha512-5HMHaJRIQuozm/XQIiJiA0W9uhdblwwl2ZNDSSAeXGO9YhB9MH5C4KIHOmvyjUnKy4UCuiP43VKpIxW1VWP4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-x64/-/vsce-sign-darwin-x64-2.0.6.tgz", + "integrity": "sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm/-/vsce-sign-linux-arm-2.0.6.tgz", + "integrity": "sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm64/-/vsce-sign-linux-arm64-2.0.6.tgz", + "integrity": "sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-x64/-/vsce-sign-linux-x64-2.0.6.tgz", + "integrity": "sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-win32-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-arm64/-/vsce-sign-win32-arm64-2.0.6.tgz", + "integrity": "sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/vsce-sign-win32-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-x64/-/vsce-sign-win32-x64-2.0.6.tgz", + "integrity": "sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/azure-devops-node-api": { + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz", + "integrity": "sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "0.0.6", + "typed-rest-client": "^1.8.4" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/binaryextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-6.11.0.tgz", + "integrity": "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "editions": "^6.21.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/boundary": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz", + "integrity": "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/brace-expansion": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/cockatiel": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", + "integrity": "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/editions": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/editions/-/editions-6.22.0.tgz", + "integrity": "sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "version-range": "^4.15.0" + }, + "engines": { + "ecmascript": ">= es5", + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globby": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", + "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.3", + "path-type": "^6.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istextorbinary": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-9.5.0.tgz", + "integrity": "sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "binaryextensions": "^6.11.0", + "editions": "^6.21.0", + "textextensions": "^6.11.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "dev": true, + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keytar": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", + "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^4.3.0", + "prebuild-install": "^7.0.1" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/markdown-it": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", + "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true, + "license": "ISC" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", + "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-sarif-builder": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-3.4.0.tgz", + "integrity": "sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sarif": "^2.1.7", + "fs-extra": "^11.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/normalize-package-data/node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/normalize-package-data/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.6.tgz", + "integrity": "sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/palschema-vscode": { + "resolved": "tools/vscode-palschema", + "link": true + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-semver": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/parse-semver/-/parse-semver-1.1.1.tgz", + "integrity": "sha512-Eg1OuNntBMH0ojvEKSrvDSnwLmvVuUOSdylH/pSCPNMIspLlweJyIWXCE+k/5hm3cj/EBUYwmWkjhBALNP4LXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.1.0" + } + }, + "node_modules/parse-semver/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/path-type": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", + "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc-config-loader": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/rc-config-loader/-/rc-config-loader-4.1.4.tgz", + "integrity": "sha512-3GiwEzklkbXTDp52UR5nT8iXgYAx1V9ZG/kDZT7p60u2GCv2XTwQq4NzinMoMpNtXhmt3WkhYXcj6HH8HdwCEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "js-yaml": "^4.1.1", + "json5": "^2.2.3", + "require-from-string": "^2.0.2" + } + }, + "node_modules/read": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", + "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "mute-stream": "~0.0.4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/read-pkg": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", + "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.3", + "normalize-package-data": "^6.0.0", + "parse-json": "^8.0.0", + "type-fest": "^4.6.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/secretlint": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-10.2.2.tgz", + "integrity": "sha512-xVpkeHV/aoWe4vP4TansF622nBEImzCY73y/0042DuJ29iKIaqgoJ8fGxre3rVSHHbxar4FdJobmTnLp9AU0eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/config-creator": "^10.2.2", + "@secretlint/formatter": "^10.2.2", + "@secretlint/node": "^10.2.2", + "@secretlint/profiler": "^10.2.2", + "debug": "^4.4.1", + "globby": "^14.1.0", + "read-pkg": "^9.0.1" + }, + "bin": { + "secretlint": "bin/secretlint.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/structured-source": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/structured-source/-/structured-source-4.0.0.tgz", + "integrity": "sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boundary": "^2.0.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/table/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/table/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/terminal-link": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", + "integrity": "sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "supports-hyperlinks": "^3.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/textextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-6.11.0.tgz", + "integrity": "sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "editions": "^6.21.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.20.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz", + "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-rest-client": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.11.tgz", + "integrity": "sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "qs": "^6.9.1", + "tunnel": "0.0.6", + "underscore": "^1.12.1" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/version-range": { + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/version-range/-/version-range-4.15.0.tgz", + "integrity": "sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg==", + "dev": true, + "license": "Artistic-2.0", + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/vscode-json-languageservice": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-5.7.2.tgz", + "integrity": "sha512-WtKRDtJfFEmLrgtu+ODexOHm/6/krRF0k6t+uvkKIKW1Jh9ZIyxZQwJJwB3qhrEgvAxa37zbUg+vn+UyUK/U2w==", + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "jsonc-parser": "^3.3.1", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-languageserver-types": "^3.17.5", + "vscode-uri": "^3.1.0" + } + }, + "node_modules/vscode-jsonrpc": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.1.tgz", + "integrity": "sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageclient": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-10.1.0.tgz", + "integrity": "sha512-XXRx6lqVitQy/oOLr9MfNYRG+MbQkhXkDaxbQMiKxEm8zZNfheRFUKNb8UYNh2stn9btl2wQM5wZFJjJvoc+jA==", + "license": "MIT", + "dependencies": { + "minimatch": "^10.2.5", + "semver": "^7.8.1", + "vscode-languageserver-protocol": "3.18.2", + "vscode-languageserver-textdocument": "1.0.13" + }, + "engines": { + "vscode": "^1.91.0" + } + }, + "node_modules/vscode-languageclient/node_modules/vscode-languageserver-textdocument": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.13.tgz", + "integrity": "sha512-nx0ZHwMGIsVkzFG3/VLeJYBLTaFBRuNdGDvevvjuoayU5EOS2fEYazOhtCM3PI9ClMMg5igc0uwXtAq4tJj+Dw==", + "license": "MIT" + }, + "node_modules/vscode-languageserver": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-10.1.0.tgz", + "integrity": "sha512-9gEWpXkYGXoqG7pBnE8O8hx/yP7+Aabn4+peQ3KDicQv6qunHSWyLTud3OF0w4S2+HfDD+5HqYKiXQW9HAU6mA==", + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.18.2" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.18.2", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.2.tgz", + "integrity": "sha512-XRyDbT0Pp3sSNti3JmxVEUMySWCSi1hhM+/KUlCy1hV1zmrqpM1OwO12EAki8blhmLuIMpaJrYbo0OzGVfK2Qg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "9.0.1", + "vscode-languageserver-types": "3.18.0" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.0.tgz", + "integrity": "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==", + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "license": "MIT" + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yauzl": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yazl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz", + "integrity": "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3" + } + }, + "tools/palschema-tools": { + "name": "@palschema/tools", + "version": "0.6.1", + "license": "MIT", + "dependencies": { + "ajv": "8.20.0", + "chokidar": "4.0.3", + "jsonc-parser": "3.3.1", + "vscode-json-languageservice": "5.7.2", + "vscode-languageserver": "10.1.0", + "vscode-languageserver-textdocument": "1.0.12" + }, + "bin": { + "palschema": "dist/cli.js", + "palschema-lsp": "dist/lsp-server.js" + }, + "devDependencies": { + "@types/node": "24.1.0", + "tsx": "4.20.3", + "typescript": "5.8.3" + }, + "engines": { + "node": ">=20" + } + }, + "tools/vscode-palschema": { + "name": "palschema-vscode", + "version": "0.6.1", + "license": "MIT", + "dependencies": { + "vscode-languageclient": "10.1.0" + }, + "devDependencies": { + "@types/vscode": "1.91.0", + "@vscode/vsce": "3.9.2", + "esbuild": "0.28.1", + "typescript": "5.8.3" + }, + "engines": { + "vscode": "^1.91.0" + } + }, + "tools/vscode-palschema/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "tools/vscode-palschema/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "tools/vscode-palschema/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "tools/vscode-palschema/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "tools/vscode-palschema/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "tools/vscode-palschema/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "tools/vscode-palschema/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "tools/vscode-palschema/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "tools/vscode-palschema/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "tools/vscode-palschema/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "tools/vscode-palschema/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "tools/vscode-palschema/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "tools/vscode-palschema/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "tools/vscode-palschema/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "tools/vscode-palschema/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "tools/vscode-palschema/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "tools/vscode-palschema/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "tools/vscode-palschema/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "tools/vscode-palschema/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "tools/vscode-palschema/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "tools/vscode-palschema/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "tools/vscode-palschema/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "tools/vscode-palschema/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "tools/vscode-palschema/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "tools/vscode-palschema/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "tools/vscode-palschema/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "tools/vscode-palschema/node_modules/@types/vscode": { + "version": "1.91.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.91.0.tgz", + "integrity": "sha512-PgPr+bUODjG3y+ozWUCyzttqR9EHny9sPAfJagddQjDwdtf66y2sDKJMnFZRuzBA2YtBGASqJGPil8VDUPvO6A==", + "dev": true, + "license": "MIT" + }, + "tools/vscode-palschema/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..08042fa --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "palschema-workspace", + "private": true, + "workspaces": [ + "tools/*" + ], + "scripts": { + "tools:build": "npm run build --workspace @palschema/tools", + "tools:test": "npm run test --workspace @palschema/tools", + "tools:typecheck": "npm run typecheck --workspace @palschema/tools", + "editor:build": "npm run build --workspace palschema-vscode", + "editor:package": "npm run package:vsix --workspace palschema-vscode" + } +} diff --git a/scripts/bootstrap-linux.sh b/scripts/bootstrap-linux.sh index bd3c329..c0a56db 100755 --- a/scripts/bootstrap-linux.sh +++ b/scripts/bootstrap-linux.sh @@ -58,19 +58,15 @@ if [[ "$(uname -s)" != "Linux" ]]; then fi required_commands=( - clang-cl cmake curl flock git - lld-link - llvm-lib - llvm-mt - llvm-rc ninja sha256sum ) missing_commands=() +llvm_version_suffixes=(22 20 19 18 17 16 15) for required_command in "${required_commands[@]}"; do if ! command -v "$required_command" >/dev/null 2>&1; then @@ -78,6 +74,30 @@ for required_command in "${required_commands[@]}"; do fi done +llvm_tool_available() { + local tool_name="$1" + local version + + if command -v "$tool_name" >/dev/null 2>&1; then + return 0 + fi + for version in "${llvm_version_suffixes[@]}"; do + if command -v "$tool_name-$version" >/dev/null 2>&1; then + return 0 + fi + done + return 1 +} + +for llvm_tool in clang-cl lld-link llvm-mt llvm-rc llvm-ranlib; do + if ! llvm_tool_available "$llvm_tool"; then + missing_commands+=("$llvm_tool") + fi +done +if ! llvm_tool_available llvm-lib && ! llvm_tool_available llvm-ar; then + missing_commands+=("llvm-lib/llvm-ar") +fi + if ((${#missing_commands[@]} > 0)); then printf 'Missing required commands: %s\n' "${missing_commands[*]}" >&2 printf '%s\n' \ diff --git a/scripts/deploy-proton.sh b/scripts/deploy-proton.sh index 97f29bb..8c0418e 100755 --- a/scripts/deploy-proton.sh +++ b/scripts/deploy-proton.sh @@ -5,17 +5,20 @@ set -euo pipefail show_usage() { printf '%s\n' \ "Usage:" \ - " scripts/deploy-proton.sh [dev|shipping] [--game-dir PATH] [--dry-run]" \ - " scripts/deploy-proton.sh --rollback BACKUP_DIR [--game-dir PATH] [--dry-run]" \ + " scripts/deploy-proton.sh [dev|shipping] [--target auto|client|server]" \ + " [--game-dir PATH] [--dry-run]" \ + " scripts/deploy-proton.sh --rollback BACKUP_DIR [--target auto|client|server]" \ + " [--game-dir PATH] [--dry-run]" \ "" \ "Deploys only PalSchema into an existing, separately installed UE4SS." \ - "Refuses to run while the Windows Palworld client is active." + "Refuses to run while the selected Windows Palworld target is active." } build_flavor="shipping" game_dir="" rollback_dir="" dry_run=false +target_kind="auto" if (($# > 0)) && [[ "$1" != --* ]]; then build_flavor="$1" @@ -40,6 +43,14 @@ while (($# > 0)); do rollback_dir="$2" shift ;; + --target) + if (($# < 2)); then + printf '%s\n' "--target requires auto, client, or server." >&2 + exit 2 + fi + target_kind="$2" + shift + ;; --dry-run) dry_run=true ;; @@ -70,6 +81,16 @@ case "$build_flavor" in ;; esac +case "$target_kind" in + auto|client|server) + ;; + *) + printf 'Unknown target: %s\n\n' "$target_kind" >&2 + show_usage >&2 + exit 2 + ;; +esac + script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" project_root="$(cd -- "$script_dir/.." && pwd)" @@ -92,33 +113,70 @@ mods_dir="$ue4ss_root/Mods" target_dir="$mods_dir/PalSchema" backup_root="$ue4ss_root/.palschema-backups" -palworld_client_is_running() { +if [[ "$target_kind" == "auto" ]]; then + if [[ -f "$win64_dir/Palworld-Win64-Shipping.exe" ]]; then + target_kind="client" + elif [[ -f "$win64_dir/PalServer-Win64-Shipping.exe" || + -f "$win64_dir/PalServer-Win64-Shipping-Cmd.exe" ]]; then + target_kind="server" + else + printf 'Not a Palworld Win64 client or server installation: %s\n' "$game_dir" >&2 + exit 1 + fi +fi + +palworld_target_is_running() { local cmdline + local cmdline_fd local argument for cmdline in /proc/[0-9]*/cmdline; do - while IFS= read -r -d '' argument; do - case "$argument" in - Palworld-Win64-Shipping.exe|*/Palworld-Win64-Shipping.exe|*\\Palworld-Win64-Shipping.exe) - return 0 - ;; - esac - done < "$cmdline" 2>/dev/null || true + if ! { exec {cmdline_fd}<"$cmdline"; } 2>/dev/null; then + continue + fi + + while IFS= read -r -d '' argument <&"$cmdline_fd"; do + if [[ "$target_kind" == "client" ]]; then + case "$argument" in + Palworld-Win64-Shipping.exe|*/Palworld-Win64-Shipping.exe|*\\Palworld-Win64-Shipping.exe) + exec {cmdline_fd}<&- + return 0 + ;; + esac + else + case "$argument" in + PalServer.exe|*/PalServer.exe|*\\PalServer.exe|\ + PalServer-Win64-Shipping.exe|*/PalServer-Win64-Shipping.exe|*\\PalServer-Win64-Shipping.exe|\ + PalServer-Win64-Shipping-Cmd.exe|*/PalServer-Win64-Shipping-Cmd.exe|*\\PalServer-Win64-Shipping-Cmd.exe) + exec {cmdline_fd}<&- + return 0 + ;; + esac + fi + done + exec {cmdline_fd}<&- done return 1 } -if [[ ! -f "$win64_dir/Palworld-Win64-Shipping.exe" ]]; then - printf 'Not a Palworld client installation: %s\n' "$game_dir" >&2 +if [[ "$target_kind" == "client" && + ! -f "$win64_dir/Palworld-Win64-Shipping.exe" ]]; then + printf 'Not a Palworld Win64 client installation: %s\n' "$game_dir" >&2 + exit 1 +elif [[ "$target_kind" == "server" && + ! -f "$win64_dir/PalServer-Win64-Shipping.exe" && + ! -f "$win64_dir/PalServer-Win64-Shipping-Cmd.exe" ]]; then + printf 'Not a Palworld Win64 dedicated-server installation: %s\n' "$game_dir" >&2 exit 1 fi if [[ ! -f "$ue4ss_root/UE4SS.dll" || ! -d "$mods_dir" ]]; then printf 'A separate UE4SS installation was not found under: %s\n' "$win64_dir" >&2 exit 1 fi -if palworld_client_is_running; then - printf '%s\n' "Refusing to deploy while the Palworld Windows client is active." >&2 +if palworld_target_is_running; then + printf 'Refusing to deploy while the Palworld Windows %s is active.\n' \ + "$target_kind" >&2 exit 1 fi @@ -226,11 +284,12 @@ stage_dir="" { printf 'flavor=%s\n' "$build_flavor" + printf 'target=%s\n' "$target_kind" printf 'artifact=%s\n' "$artifact" printf 'sha256=%s\n' "$(sha256sum "$artifact" | awk '{print $1}')" printf 'deployed_at_utc=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" } > "$backup_dir/deployment.txt" printf 'Deployed PalSchema to %s\n' "$target_dir" -printf 'Rollback with: scripts/deploy-proton.sh --rollback %s --game-dir %s\n' \ - "$backup_dir" "$game_dir" +printf 'Rollback with: scripts/deploy-proton.sh --rollback %s --target %s --game-dir %s\n' \ + "$backup_dir" "$target_kind" "$game_dir" diff --git a/src/Loader/PalMainLoader.cpp b/src/Loader/PalMainLoader.cpp index 293dcc4..4139b06 100644 --- a/src/Loader/PalMainLoader.cpp +++ b/src/Loader/PalMainLoader.cpp @@ -42,6 +42,8 @@ namespace Palworld { PalMainLoader::~PalMainLoader() { + m_fileWatcher.reset(); + auto expected1 = DatatableSerialize_Hook.disable(); DatatableSerialize_Hook = {}; diff --git a/src/SDK/PalSignatures.cpp b/src/SDK/PalSignatures.cpp index b46b4d8..ea140ab 100644 --- a/src/SDK/PalSignatures.cpp +++ b/src/SDK/PalSignatures.cpp @@ -1,3 +1,10 @@ +#if defined(_WIN32) +#define NOMINMAX +#include +#endif + +#include + #include "SDK/PalSignatures.h" #include "Signatures.hpp" #include "SigScanner/SinglePassSigScanner.hpp" @@ -8,6 +15,19 @@ using namespace RC; using namespace RC::Unreal; +namespace +{ + bool IsRunningUnderWine() + { +#if defined(_WIN32) + auto Ntdll = GetModuleHandleW(L"ntdll.dll"); + return Ntdll && GetProcAddress(Ntdll, "wine_get_version"); +#else + return false; +#endif + } +} + namespace Palworld { void SignatureManager::Initialize() { @@ -68,7 +88,26 @@ namespace Palworld { } SigContainerMap.emplace(ScanTarget::MainExe, SigContainerBox); - SinglePassScanner::start_scan(SigContainerMap); + const auto PreviousModuleSizeThreshold = SinglePassScanner::m_multithreading_module_size_threshold; + if (IsRunningUnderWine()) + { + // SinglePassScanner uses std::async whenever the executable is + // larger than this threshold. Wine can block while creating that + // worker during UE4SS mod startup, before Unreal can initialize. + // Force the scanner's existing synchronous path under Wine while + // preserving parallel scanning on native Windows. + SinglePassScanner::m_multithreading_module_size_threshold = std::numeric_limits::max(); + } + try + { + SinglePassScanner::start_scan(SigContainerMap); + } + catch (...) + { + SinglePassScanner::m_multithreading_module_size_threshold = PreviousModuleSizeThreshold; + throw; + } + SinglePassScanner::m_multithreading_module_size_threshold = PreviousModuleSizeThreshold; } void* SignatureManager::GetSignature(const std::string& ClassAndFunction) @@ -81,4 +120,4 @@ namespace Palworld { return nullptr; } -} \ No newline at end of file +} diff --git a/src/dllmain.cpp b/src/dllmain.cpp index 10ed84c..a4b0e70 100644 --- a/src/dllmain.cpp +++ b/src/dllmain.cpp @@ -43,6 +43,7 @@ class PalSchema : public RC::CppUserModBase PS::Log(STR("Preparing to pre-initialize PalSchema...\n")); MainLoader.PreInitialize(); + m_isReady = true; PS::Log(STR("{} v{} by {} loaded.\n"), ModName, ModVersion, ModAuthors); } @@ -80,6 +81,11 @@ class PalSchema : public RC::CppUserModBase auto on_ui_init() -> void override { + if (!m_isReady) + { + return; + } + register_tab(STR("Pal Schema"), [](CppUserModBase* instance) { UE4SS_ENABLE_IMGUI() @@ -106,10 +112,14 @@ class PalSchema : public RC::CppUserModBase auto on_unreal_init() -> void override { - MainLoader.Initialize(); + if (m_isReady) + { + MainLoader.Initialize(); + } } private: Palworld::PalMainLoader MainLoader; + bool m_isReady = false; }; diff --git a/tools/palschema-tools/LICENSE b/tools/palschema-tools/LICENSE new file mode 100644 index 0000000..d152b5e --- /dev/null +++ b/tools/palschema-tools/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Okaetsu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tools/palschema-tools/README.md b/tools/palschema-tools/README.md new file mode 100644 index 0000000..1e5c8ef --- /dev/null +++ b/tools/palschema-tools/README.md @@ -0,0 +1,30 @@ +# PalSchema authoring tools + +Cross-platform JSON/JSONC validation and Language Server Protocol support for +PalSchema mod projects. + +Install a repository-built tarball: + +```bash +npm install --global ./palschema-tools-0.6.1.tgz +``` + +Initialize a workspace and validate it: + +```bash +palschema init /path/to/MyPalSchemaMod +palschema validate /path/to/MyPalSchemaMod +``` + +Start the language server for a generic editor: + +```bash +palschema-lsp --stdio +``` + +Run `palschema --help` for all commands. Full build, configuration, generated +schema, and editor instructions are in +`docs/linux/authoring-tools.md` in the PalSchema repository. + +The package contains redistributable static schemas only. It does not bundle +Palworld data, UE4SS, UEPseudo, or runtime-generated enum/raw schemas. diff --git a/tools/palschema-tools/package.json b/tools/palschema-tools/package.json new file mode 100644 index 0000000..1ca94f1 --- /dev/null +++ b/tools/palschema-tools/package.json @@ -0,0 +1,39 @@ +{ + "name": "@palschema/tools", + "version": "0.6.1", + "description": "Cross-platform PalSchema JSON/JSONC validation and editor core", + "license": "MIT", + "type": "module", + "bin": { + "palschema": "./dist/cli.js", + "palschema-lsp": "./dist/lsp-server.js" + }, + "exports": { + ".": "./dist/index.js" + }, + "files": [ + "dist" + ], + "engines": { + "node": ">=20" + }, + "scripts": { + "build": "tsc -p tsconfig.json && node scripts/copy-schemas.mjs", + "schemas:index": "node scripts/update-schema-index.mjs", + "test": "tsx --test test/*.test.ts && node --test test/*.test.mjs", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "ajv": "8.20.0", + "chokidar": "4.0.3", + "jsonc-parser": "3.3.1", + "vscode-json-languageservice": "5.7.2", + "vscode-languageserver": "10.1.0", + "vscode-languageserver-textdocument": "1.0.12" + }, + "devDependencies": { + "@types/node": "24.1.0", + "tsx": "4.20.3", + "typescript": "5.8.3" + } +} diff --git a/tools/palschema-tools/scripts/copy-schemas.mjs b/tools/palschema-tools/scripts/copy-schemas.mjs new file mode 100644 index 0000000..c8356da --- /dev/null +++ b/tools/palschema-tools/scripts/copy-schemas.mjs @@ -0,0 +1,12 @@ +import { cp, mkdir } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +const packageRoot = resolve(scriptDirectory, ".."); +const repositoryRoot = resolve(packageRoot, "../.."); +const source = resolve(repositoryRoot, "assets/schemas"); +const destination = resolve(packageRoot, "dist/schemas"); + +await mkdir(destination, { recursive: true }); +await cp(source, destination, { recursive: true, force: true }); diff --git a/tools/palschema-tools/scripts/update-schema-index.mjs b/tools/palschema-tools/scripts/update-schema-index.mjs new file mode 100644 index 0000000..a6ba968 --- /dev/null +++ b/tools/palschema-tools/scripts/update-schema-index.mjs @@ -0,0 +1,23 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +const schemaDirectory = resolve(scriptDirectory, "../../../assets/schemas"); +const indexPath = resolve(schemaDirectory, "schema-index.json"); +const index = JSON.parse(await readFile(indexPath, "utf8")); + +for (const entry of index.schemas) { + try { + const content = await readFile(resolve(schemaDirectory, entry.file)); + entry.sha256 = createHash("sha256").update(content).digest("hex"); + } catch (error) { + if (error.code !== "ENOENT" || !entry.generated) { + throw error; + } + entry.sha256 = null; + } +} + +await writeFile(indexPath, `${JSON.stringify(index, null, 2)}\n`, "utf8"); diff --git a/tools/palschema-tools/src/cli.ts b/tools/palschema-tools/src/cli.ts new file mode 100644 index 0000000..82db5ba --- /dev/null +++ b/tools/palschema-tools/src/cli.ts @@ -0,0 +1,550 @@ +#!/usr/bin/env node + +import { existsSync } from "node:fs"; +import { + access, + constants, + copyFile, + mkdir, + readdir, + stat, + writeFile, +} from "node:fs/promises"; +import { dirname, resolve, extname, join, relative } from "node:path"; + +import chokidar from "chokidar"; + +import { findProjectConfig } from "./configuration.js"; +import { SchemaRegistry } from "./schema-registry.js"; +import type { PalSchemaDiagnostic, ValidationResult } from "./types.js"; +import { PalSchemaValidator } from "./validator.js"; + +const IGNORED_DIRECTORIES = new Set([ + ".git", + ".palschema", + ".vscode", + "build", + "dist", + "node_modules", +]); +const IGNORED_FILES = new Set(["palschema.config.json"]); + +interface CommonOptions { + schemaDirectory?: string; + format: "human" | "json"; +} + +interface ValidateOptions extends CommonOptions { + paths: string[]; + watch: boolean; + allowMissingGenerated: boolean | undefined; +} + +interface InitOptions extends CommonOptions { + destination: string; + force: boolean; +} + +function showUsage(): void { + console.log(`PalSchema authoring tools + +Usage: + palschema validate [--watch] [--allow-missing-generated|--strict-generated] + [--format human|json] + [--schema-dir PATH] [PATH...] + palschema schemas list [--format human|json] [--schema-dir PATH] + palschema schemas verify [--format human|json] [--schema-dir PATH] + palschema doctor [--format human|json] [--schema-dir PATH] + palschema print-config [--schema-dir PATH] + palschema init [--force] [--schema-dir PATH] [PATH] + +Exit codes: + 0 validation passed (warnings are allowed) + 1 one or more validation errors + 2 invalid arguments, configuration, or internal failure`); +} + +async function pathExists(path: string): Promise { + try { + await access(path, constants.F_OK); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return false; + } + throw error; + } +} + +function parseCommonOptions(arguments_: string[]): { + options: CommonOptions; + remaining: string[]; +} { + const options: CommonOptions = { format: "human" }; + const remaining: string[] = []; + + for (let index = 0; index < arguments_.length; index += 1) { + const argument = arguments_[index]; + if (argument === "--schema-dir") { + const value = arguments_[index + 1]; + if (!value) { + throw new Error("--schema-dir requires a path."); + } + options.schemaDirectory = value; + index += 1; + } else if (argument === "--format") { + const value = arguments_[index + 1]; + if (value !== "human" && value !== "json") { + throw new Error("--format must be human or json."); + } + options.format = value; + index += 1; + } else { + remaining.push(argument ?? ""); + } + } + + return { options, remaining }; +} + +function parseValidateOptions(arguments_: string[]): ValidateOptions { + const watch = arguments_.includes("--watch"); + const allowsMissing = arguments_.includes("--allow-missing-generated"); + const requiresGenerated = arguments_.includes("--strict-generated"); + if (allowsMissing && requiresGenerated) { + throw new Error( + "--allow-missing-generated and --strict-generated are mutually exclusive.", + ); + } + const allowMissingGenerated = allowsMissing + ? true + : requiresGenerated + ? false + : undefined; + const filtered = arguments_.filter( + (argument) => + argument !== "--watch" && + argument !== "--allow-missing-generated" && + argument !== "--strict-generated", + ); + const { options, remaining } = parseCommonOptions(filtered); + return { + ...options, + paths: remaining.length > 0 ? remaining : ["."], + watch, + allowMissingGenerated, + }; +} + +function parseInitOptions(arguments_: string[]): InitOptions { + const force = arguments_.includes("--force"); + const filtered = arguments_.filter((argument) => argument !== "--force"); + const { options, remaining } = parseCommonOptions(filtered); + if (remaining.length > 1) { + throw new Error("init accepts at most one destination path."); + } + return { + ...options, + destination: resolve(remaining[0] ?? "."), + force, + }; +} + +function isJsonDocument(path: string): boolean { + const extension = extname(path).toLowerCase(); + return extension === ".json" || extension === ".jsonc"; +} + +async function discoverFiles(paths: string[]): Promise { + const discovered = new Set(); + + async function visit(path: string): Promise { + const absolutePath = resolve(path); + const metadata = await stat(absolutePath); + if (metadata.isFile()) { + if ( + !IGNORED_FILES.has(absolutePath.split(/[\\/]/).at(-1) ?? "") && + isJsonDocument(absolutePath) + ) { + discovered.add(absolutePath); + } + return; + } + if (!metadata.isDirectory()) { + return; + } + + const entries = await readdir(absolutePath, { withFileTypes: true }); + await Promise.all( + entries.map(async (entry) => { + if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name)) { + return; + } + await visit(join(absolutePath, entry.name)); + }), + ); + } + + await Promise.all(paths.map(visit)); + return [...discovered].sort(); +} + +function displayFile(file: string): string { + const relativePath = relative(process.cwd(), file); + return relativePath && !relativePath.startsWith("..") ? relativePath : file; +} + +function printDiagnostic(diagnostic: PalSchemaDiagnostic): void { + console.log( + `${displayFile(diagnostic.file)}:${diagnostic.line}:${diagnostic.column} ` + + `[${diagnostic.code}] ${diagnostic.severity}: ${diagnostic.message}`, + ); +} + +function printValidationResults( + results: ValidationResult[], + format: CommonOptions["format"], +): number { + const diagnostics = results.flatMap((result) => result.diagnostics); + if (format === "json") { + console.log( + JSON.stringify( + { + files: results.length, + errors: diagnostics.filter((item) => item.severity === "error").length, + warnings: diagnostics.filter((item) => item.severity === "warning") + .length, + diagnostics, + }, + null, + 2, + ), + ); + } else { + diagnostics.forEach(printDiagnostic); + const errors = diagnostics.filter((item) => item.severity === "error").length; + const warnings = diagnostics.length - errors; + console.log( + `Validated ${results.length} file(s): ${errors} error(s), ${warnings} warning(s).`, + ); + } + return diagnostics.some((item) => item.severity === "error") ? 1 : 0; +} + +async function validateOnce( + validator: PalSchemaValidator, + paths: string[], + format: CommonOptions["format"], +): Promise { + const files = await discoverFiles(paths); + const results = await Promise.all(files.map((file) => validator.validateFile(file))); + return printValidationResults(results, format); +} + +async function validationProjectConfig(paths: string[]) { + const currentProjectConfig = await findProjectConfig(); + if (currentProjectConfig || paths.length !== 1) { + return currentProjectConfig; + } + + const target = resolve(paths[0] ?? "."); + const metadata = await stat(target); + return findProjectConfig(metadata.isDirectory() ? target : dirname(target)); +} + +async function validateCommand(arguments_: string[]): Promise { + const options = parseValidateOptions(arguments_); + const projectConfig = await validationProjectConfig(options.paths); + const schemaDirectory = + options.schemaDirectory ?? + process.env.PALSCHEMA_SCHEMA_DIR ?? + projectConfig?.schemaDirectory; + const validator = await PalSchemaValidator.create(schemaDirectory, { + allowMissingGenerated: + options.allowMissingGenerated ?? + projectConfig?.allowMissingGenerated ?? + false, + }); + let exitCode = await validateOnce(validator, options.paths, options.format); + if (!options.watch) { + return exitCode; + } + + const watcher = chokidar.watch(options.paths, { + ignored: (path, metadata) => + metadata?.isDirectory() === true && + IGNORED_DIRECTORIES.has(path.split(/[\\/]/).at(-1) ?? ""), + ignoreInitial: true, + }); + let pending: NodeJS.Timeout | undefined; + const rerun = (): void => { + if (pending) { + clearTimeout(pending); + } + pending = setTimeout(async () => { + try { + exitCode = await validateOnce( + validator, + options.paths, + options.format, + ); + process.exitCode = exitCode; + } catch (error) { + console.error(error); + process.exitCode = 2; + } + }, 100); + }; + watcher.on("add", rerun).on("change", rerun).on("unlink", rerun); + console.log("Watching for PalSchema JSON/JSONC changes. Press Ctrl+C to stop."); + await new Promise((resolvePromise) => { + process.once("SIGINT", () => resolvePromise()); + process.once("SIGTERM", () => resolvePromise()); + }); + await watcher.close(); + return exitCode; +} + +async function schemasCommand(arguments_: string[]): Promise { + const action = arguments_[0]; + const { options, remaining } = parseCommonOptions(arguments_.slice(1)); + if (remaining.length > 0) { + throw new Error(`Unknown schemas option: ${remaining[0]}`); + } + const projectConfig = await findProjectConfig(); + const registry = await SchemaRegistry.load( + options.schemaDirectory ?? + process.env.PALSCHEMA_SCHEMA_DIR ?? + projectConfig?.schemaDirectory, + ); + + if (action === "list") { + if (options.format === "json") { + console.log(JSON.stringify(registry.index, null, 2)); + } else { + for (const entry of registry.index.schemas) { + console.log( + `${entry.file}\t${entry.generated ? "generated" : "static"}\t` + + `${entry.folder ?? "dependency-only"}`, + ); + } + } + return 0; + } + + if (action === "verify") { + const results = await registry.verify(); + if (options.format === "json") { + console.log(JSON.stringify(results, null, 2)); + } else { + for (const result of results) { + console.log(`${result.status}\t${result.entry.file}`); + } + } + return results.some( + (result) => + result.status === "missing" || result.status === "checksum-mismatch", + ) + ? 1 + : 0; + } + + throw new Error("schemas requires either list or verify."); +} + +async function doctorCommand(arguments_: string[]): Promise { + const { options, remaining } = parseCommonOptions(arguments_); + if (remaining.length > 0) { + throw new Error(`Unknown doctor option: ${remaining[0]}`); + } + const projectConfig = await findProjectConfig(); + const registry = await SchemaRegistry.load( + options.schemaDirectory ?? + process.env.PALSCHEMA_SCHEMA_DIR ?? + projectConfig?.schemaDirectory, + ); + const verification = await registry.verify(); + const report = { + node: process.version, + platform: process.platform, + architecture: process.arch, + schemaDirectory: registry.schemaDirectory, + palSchemaVersion: registry.index.palSchemaVersion, + schemas: verification.map((result) => ({ + file: result.entry.file, + generated: result.entry.generated, + status: result.status, + })), + }; + if (options.format === "json") { + console.log(JSON.stringify(report, null, 2)); + } else { + console.log(`Node: ${report.node} (${report.platform}/${report.architecture})`); + console.log(`Schema directory: ${report.schemaDirectory}`); + report.schemas.forEach((schema) => + console.log(`${schema.status}\t${schema.file}`), + ); + } + return verification.some( + (result) => + result.status === "missing" || result.status === "checksum-mismatch", + ) + ? 1 + : 0; +} + +async function printConfigCommand(arguments_: string[]): Promise { + const { options, remaining } = parseCommonOptions(arguments_); + if (remaining.length > 0) { + throw new Error(`Unknown print-config option: ${remaining[0]}`); + } + const projectConfig = await findProjectConfig(); + const registry = await SchemaRegistry.load( + options.schemaDirectory ?? + process.env.PALSCHEMA_SCHEMA_DIR ?? + projectConfig?.schemaDirectory, + ); + console.log( + JSON.stringify( + { + schemaDirectory: registry.schemaDirectory, + palSchemaVersion: registry.index.palSchemaVersion, + formatVersion: registry.index.formatVersion, + projectConfig: projectConfig?.file ?? null, + }, + null, + 2, + ), + ); + return 0; +} + +function editorSchemaMappings(registry: SchemaRegistry): Array<{ + fileMatch: string[]; + url: string; +}> { + return registry.index.schemas + .filter( + (entry) => + entry.folder !== null && existsSync(registry.pathFor(entry)), + ) + .map((entry) => ({ + fileMatch: entry.patterns, + url: `./.palschema/schemas/${entry.file}`, + })); +} + +async function initCommand(arguments_: string[]): Promise { + const options = parseInitOptions(arguments_); + const registry = await SchemaRegistry.load(options.schemaDirectory); + const schemaDestination = join( + options.destination, + ".palschema", + "schemas", + ); + const settingsPath = join(options.destination, ".vscode", "settings.json"); + const configPath = join(options.destination, "palschema.config.json"); + const managedTargets = [settingsPath, configPath]; + + if (!options.force) { + const conflicts = []; + for (const target of managedTargets) { + if (await pathExists(target)) { + conflicts.push(target); + } + } + if (conflicts.length > 0) { + throw new Error( + `Refusing to overwrite existing workspace files:\n${conflicts.join("\n")}\n` + + "Re-run with --force after reviewing them.", + ); + } + } + + await mkdir(schemaDestination, { recursive: true }); + await mkdir(join(options.destination, ".vscode"), { recursive: true }); + + await copyFile( + join(registry.schemaDirectory, "schema-index.json"), + join(schemaDestination, "schema-index.json"), + ); + const copiedSchemas: string[] = ["schema-index.json"]; + const missingGenerated: string[] = []; + for (const entry of registry.index.schemas) { + const source = registry.pathFor(entry); + const destination = join(schemaDestination, entry.file); + if (!(await pathExists(source))) { + if (entry.generated) { + missingGenerated.push(entry.file); + continue; + } + throw new Error(`Required schema is missing: ${source}`); + } + await copyFile(source, destination); + copiedSchemas.push(entry.file); + } + + const settings = { + "json.schemas": editorSchemaMappings(registry), + "palschema.schemaDirectory": ".palschema/schemas", + "palschema.allowMissingGenerated": true, + }; + const config = { + schemaDirectory: ".palschema/schemas", + allowMissingGenerated: true, + }; + await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); + + const report = { + destination: options.destination, + schemaDirectory: schemaDestination, + copiedSchemas, + missingGenerated, + settings: settingsPath, + config: configPath, + }; + if (options.format === "json") { + console.log(JSON.stringify(report, null, 2)); + } else { + console.log(`Initialized PalSchema authoring in ${options.destination}`); + console.log(`Copied ${copiedSchemas.length} schema file(s).`); + if (missingGenerated.length > 0) { + console.log( + `Runtime-generated schemas not present yet: ${missingGenerated.join(", ")}`, + ); + } + } + return 0; +} + +async function main(): Promise { + const [command, ...arguments_] = process.argv.slice(2); + if (!command || command === "-h" || command === "--help" || command === "help") { + showUsage(); + return 0; + } + if (command === "validate") { + return validateCommand(arguments_); + } + if (command === "schemas") { + return schemasCommand(arguments_); + } + if (command === "doctor") { + return doctorCommand(arguments_); + } + if (command === "print-config") { + return printConfigCommand(arguments_); + } + if (command === "init") { + return initCommand(arguments_); + } + throw new Error(`Unknown command: ${command}`); +} + +try { + process.exitCode = await main(); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 2; +} diff --git a/tools/palschema-tools/src/configuration.ts b/tools/palschema-tools/src/configuration.ts new file mode 100644 index 0000000..6e31103 --- /dev/null +++ b/tools/palschema-tools/src/configuration.ts @@ -0,0 +1,76 @@ +import { readFile } from "node:fs/promises"; +import { dirname, parse as parsePath, resolve } from "node:path"; + +import { parse, type ParseError, printParseErrorCode } from "jsonc-parser"; + +export interface PalSchemaProjectConfig { + schemaDirectory?: string; + allowMissingGenerated?: boolean; +} + +export interface ResolvedProjectConfig extends PalSchemaProjectConfig { + file: string; +} + +function assertProjectConfig( + value: unknown, + file: string, +): asserts value is PalSchemaProjectConfig { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${file} must contain a JSON object.`); + } + if ( + "schemaDirectory" in value && + typeof value.schemaDirectory !== "string" + ) { + throw new Error(`${file}: schemaDirectory must be a string.`); + } + if ( + "allowMissingGenerated" in value && + typeof value.allowMissingGenerated !== "boolean" + ) { + throw new Error(`${file}: allowMissingGenerated must be a boolean.`); + } +} + +export async function findProjectConfig( + startDirectory = process.cwd(), +): Promise { + let directory = resolve(startDirectory); + for (;;) { + const file = resolve(directory, "palschema.config.json"); + try { + const text = await readFile(file, "utf8"); + const errors: ParseError[] = []; + const value = parse(text, errors, { + allowTrailingComma: true, + disallowComments: false, + }) as unknown; + const firstError = errors[0]; + if (firstError) { + throw new Error( + `${file}: ${printParseErrorCode(firstError.error)}`, + ); + } + assertProjectConfig(value, file); + const result: ResolvedProjectConfig = { file }; + if (value.schemaDirectory !== undefined) { + result.schemaDirectory = resolve(directory, value.schemaDirectory); + } + if (value.allowMissingGenerated !== undefined) { + result.allowMissingGenerated = value.allowMissingGenerated; + } + return result; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + + const parent = parsePath(directory).dir; + if (parent === directory) { + return null; + } + directory = parent; + } +} diff --git a/tools/palschema-tools/src/index.ts b/tools/palschema-tools/src/index.ts new file mode 100644 index 0000000..f2e0a52 --- /dev/null +++ b/tools/palschema-tools/src/index.ts @@ -0,0 +1,16 @@ +export { findProjectConfig } from "./configuration.js"; +export { parseJsoncDocument } from "./jsonc-document.js"; +export { SchemaRegistry } from "./schema-registry.js"; +export { PalSchemaValidator } from "./validator.js"; +export type { + DiagnosticSeverity, + PalSchemaDiagnostic, + SchemaIndex, + SchemaIndexEntry, + SchemaVerification, + ValidationResult, +} from "./types.js"; +export type { + PalSchemaProjectConfig, + ResolvedProjectConfig, +} from "./configuration.js"; diff --git a/tools/palschema-tools/src/jsonc-document.ts b/tools/palschema-tools/src/jsonc-document.ts new file mode 100644 index 0000000..294827d --- /dev/null +++ b/tools/palschema-tools/src/jsonc-document.ts @@ -0,0 +1,30 @@ +import { + parse, + parseTree, + printParseErrorCode, + type ParseError, +} from "jsonc-parser"; + +import { positionAt } from "./text-position.js"; +import type { PalSchemaDiagnostic, ParsedDocument } from "./types.js"; + +export function parseJsoncDocument(text: string, file: string): ParsedDocument { + const errors: ParseError[] = []; + const options = { + allowEmptyContent: false, + allowTrailingComma: true, + disallowComments: false, + }; + const data = parse(text, errors, options) as unknown; + const root = parseTree(text, [], options); + const diagnostics: PalSchemaDiagnostic[] = errors.map((error) => ({ + ...positionAt(text, error.offset, error.length), + code: `PS_JSON_${printParseErrorCode(error.error).toUpperCase()}`, + severity: "error", + message: printParseErrorCode(error.error), + file, + source: "palschema", + })); + + return { data, root, diagnostics }; +} diff --git a/tools/palschema-tools/src/lsp-server.ts b/tools/palschema-tools/src/lsp-server.ts new file mode 100644 index 0000000..ad2a650 --- /dev/null +++ b/tools/palschema-tools/src/lsp-server.ts @@ -0,0 +1,203 @@ +#!/usr/bin/env node + +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { + getLanguageService, + type LanguageService, +} from "vscode-json-languageservice"; +import { + createConnection, + DiagnosticSeverity, + ProposedFeatures, + TextDocumentSyncKind, + type InitializeParams, + type InitializeResult, +} from "vscode-languageserver/node"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import { TextDocuments } from "vscode-languageserver/node"; + +import { SchemaRegistry } from "./schema-registry.js"; +import type { PalSchemaDiagnostic } from "./types.js"; +import { PalSchemaValidator } from "./validator.js"; + +interface PalSchemaInitializationOptions { + schemaDirectory?: string; + allowMissingGenerated?: boolean; +} + +const connection = createConnection(ProposedFeatures.all); +const documents = new TextDocuments(TextDocument); + +let registry: SchemaRegistry; +let validator: PalSchemaValidator; +let jsonLanguageService: LanguageService; +let initializationOptions: PalSchemaInitializationOptions = {}; + +function fileForUri(uri: string): string { + try { + return fileURLToPath(uri); + } catch { + return uri; + } +} + +function toLspDiagnostic( + document: TextDocument, + diagnostic: PalSchemaDiagnostic, +) { + const start = document.positionAt(diagnostic.offset); + const end = document.positionAt( + Math.min(document.getText().length, diagnostic.offset + diagnostic.length), + ); + return { + range: { start, end }, + severity: + diagnostic.severity === "error" + ? DiagnosticSeverity.Error + : DiagnosticSeverity.Warning, + code: diagnostic.code, + source: diagnostic.source, + message: diagnostic.message, + }; +} + +async function configureServices(): Promise { + registry = await SchemaRegistry.load(initializationOptions.schemaDirectory); + validator = await PalSchemaValidator.create( + initializationOptions.schemaDirectory, + { + allowMissingGenerated: + initializationOptions.allowMissingGenerated ?? true, + }, + ); + jsonLanguageService = getLanguageService({ + schemaRequestService: async (uri) => { + if (!uri.startsWith("file:")) { + throw new Error(`Unsupported schema URI: ${uri}`); + } + return readFile(fileURLToPath(uri), "utf8"); + }, + }); + jsonLanguageService.configure({ + allowComments: true, + schemas: registry.index.schemas + .filter((entry) => existsSync(registry.pathFor(entry))) + .map((entry) => ({ + uri: pathToFileURL(registry.pathFor(entry)).toString(), + fileMatch: entry.patterns, + })), + }); +} + +async function validateDocument(document: TextDocument): Promise { + try { + const result = await validator.validateText( + document.getText(), + fileForUri(document.uri), + ); + connection.sendDiagnostics({ + uri: document.uri, + diagnostics: result.diagnostics.map((diagnostic) => + toLspDiagnostic(document, diagnostic), + ), + }); + } catch (error) { + connection.console.error( + error instanceof Error ? error.stack ?? error.message : String(error), + ); + } +} + +connection.onInitialize(async (params: InitializeParams): Promise => { + initializationOptions = + (params.initializationOptions as PalSchemaInitializationOptions | null) ?? {}; + await configureServices(); + return { + capabilities: { + textDocumentSync: TextDocumentSyncKind.Incremental, + completionProvider: { + resolveProvider: false, + triggerCharacters: ['"', ":"], + }, + hoverProvider: true, + documentSymbolProvider: true, + documentFormattingProvider: true, + documentRangeFormattingProvider: true, + }, + serverInfo: { + name: "PalSchema Language Server", + version: registry.index.palSchemaVersion, + }, + }; +}); + +connection.onCompletion((params) => { + const document = documents.get(params.textDocument.uri); + if (!document) { + return null; + } + return jsonLanguageService.doComplete( + document, + params.position, + jsonLanguageService.parseJSONDocument(document), + ); +}); + +connection.onHover((params) => { + const document = documents.get(params.textDocument.uri); + if (!document) { + return null; + } + return jsonLanguageService.doHover( + document, + params.position, + jsonLanguageService.parseJSONDocument(document), + ); +}); + +connection.onDocumentSymbol((params) => { + const document = documents.get(params.textDocument.uri); + if (!document) { + return []; + } + return jsonLanguageService.findDocumentSymbols2( + document, + jsonLanguageService.parseJSONDocument(document), + ); +}); + +connection.onDocumentFormatting((params) => { + const document = documents.get(params.textDocument.uri); + if (!document) { + return []; + } + return jsonLanguageService.format( + document, + undefined, + params.options, + ); +}); + +connection.onDocumentRangeFormatting((params) => { + const document = documents.get(params.textDocument.uri); + if (!document) { + return []; + } + return jsonLanguageService.format( + document, + params.range, + params.options, + ); +}); + +documents.onDidOpen(({ document }) => void validateDocument(document)); +documents.onDidChangeContent(({ document }) => void validateDocument(document)); +documents.onDidClose(({ document }) => { + connection.sendDiagnostics({ uri: document.uri, diagnostics: [] }); +}); + +documents.listen(connection); +connection.listen(); diff --git a/tools/palschema-tools/src/schema-registry.ts b/tools/palschema-tools/src/schema-registry.ts new file mode 100644 index 0000000..d622f15 --- /dev/null +++ b/tools/palschema-tools/src/schema-registry.ts @@ -0,0 +1,119 @@ +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { dirname, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { + SchemaIndex, + SchemaIndexEntry, + SchemaVerification, +} from "./types.js"; + +const moduleDirectory = dirname(fileURLToPath(import.meta.url)); +const packageDirectory = resolve(moduleDirectory, ".."); +const bundledSchemaDirectory = resolve(moduleDirectory, "schemas"); +const repositorySchemaDirectory = resolve( + packageDirectory, + "../../assets/schemas", +); + +function defaultSchemaDirectory(): string { + const configured = process.env.PALSCHEMA_SCHEMA_DIR; + if (configured) { + return resolve(configured); + } + if (existsSync(resolve(bundledSchemaDirectory, "schema-index.json"))) { + return bundledSchemaDirectory; + } + return repositorySchemaDirectory; +} + +function assertSchemaIndex(value: unknown): asserts value is SchemaIndex { + if ( + typeof value !== "object" || + value === null || + !("formatVersion" in value) || + value.formatVersion !== 1 || + !("schemas" in value) || + !Array.isArray(value.schemas) + ) { + throw new Error("Unsupported or malformed schema-index.json."); + } +} + +export class SchemaRegistry { + readonly schemaDirectory: string; + readonly index: SchemaIndex; + + private constructor(schemaDirectory: string, index: SchemaIndex) { + this.schemaDirectory = schemaDirectory; + this.index = index; + } + + static async load(schemaDirectory = defaultSchemaDirectory()): Promise { + const absoluteDirectory = resolve(schemaDirectory); + const indexPath = resolve(absoluteDirectory, "schema-index.json"); + const index = JSON.parse(await readFile(indexPath, "utf8")) as unknown; + assertSchemaIndex(index); + return new SchemaRegistry(absoluteDirectory, index); + } + + findForDocument(file: string): SchemaIndexEntry | null { + const segments = resolve(file).split(sep); + for (let index = segments.length - 2; index >= 0; index -= 1) { + const segment = segments[index]; + const entry = this.index.schemas.find( + (candidate) => candidate.folder === segment, + ); + if (entry) { + return entry; + } + } + return null; + } + + entryForFile(file: string): SchemaIndexEntry | null { + return this.index.schemas.find((entry) => entry.file === file) ?? null; + } + + pathFor(entry: SchemaIndexEntry): string { + return resolve(this.schemaDirectory, entry.file); + } + + async readSchema(entry: SchemaIndexEntry): Promise> { + return JSON.parse(await readFile(this.pathFor(entry), "utf8")) as Record< + string, + unknown + >; + } + + async verify(): Promise { + return Promise.all( + this.index.schemas.map(async (entry): Promise => { + const path = this.pathFor(entry); + if (!existsSync(path)) { + return { + entry, + status: entry.generated ? "missing-generated" : "missing", + }; + } + + const content = await readFile(path); + const actualSha256 = createHash("sha256").update(content).digest("hex"); + if (entry.generated && entry.sha256 === null) { + return { entry, status: "present-generated", actualSha256 }; + } + if (entry.sha256 !== actualSha256) { + return { entry, status: "checksum-mismatch", actualSha256 }; + } + return { entry, status: "verified", actualSha256 }; + }), + ); + } + + displayPath(path: string): string { + const relativePath = relative(process.cwd(), path); + return relativePath && !relativePath.startsWith("..") ? relativePath : path; + } +} diff --git a/tools/palschema-tools/src/text-position.ts b/tools/palschema-tools/src/text-position.ts new file mode 100644 index 0000000..1d1656d --- /dev/null +++ b/tools/palschema-tools/src/text-position.ts @@ -0,0 +1,23 @@ +export function positionAt( + text: string, + offset: number, + length = 1, +): { line: number; column: number; offset: number; length: number } { + const boundedOffset = Math.max(0, Math.min(offset, text.length)); + let line = 1; + let lastLineStart = 0; + + for (let index = 0; index < boundedOffset; index += 1) { + if (text.charCodeAt(index) === 10) { + line += 1; + lastLineStart = index + 1; + } + } + + return { + line, + column: boundedOffset - lastLineStart + 1, + offset: boundedOffset, + length: Math.max(1, length), + }; +} diff --git a/tools/palschema-tools/src/types.ts b/tools/palschema-tools/src/types.ts new file mode 100644 index 0000000..fc49aa0 --- /dev/null +++ b/tools/palschema-tools/src/types.ts @@ -0,0 +1,67 @@ +import type { ErrorObject } from "ajv"; + +export type DiagnosticSeverity = "error" | "warning"; + +export interface SourcePosition { + line: number; + column: number; + offset: number; + length: number; +} + +export interface PalSchemaDiagnostic extends SourcePosition { + code: string; + severity: DiagnosticSeverity; + message: string; + file: string; + source: "palschema"; + schemaPath?: string; + instancePath?: string; +} + +export interface SchemaIndexEntry { + id: string; + file: string; + folder: string | null; + patterns: string[]; + generated: boolean; + dependencies: string[]; + sha256: string | null; +} + +export interface SchemaIndex { + formatVersion: number; + palSchemaVersion: string; + baseId: string; + schemas: SchemaIndexEntry[]; +} + +export interface SchemaVerification { + entry: SchemaIndexEntry; + status: + | "verified" + | "present-generated" + | "missing-generated" + | "missing" + | "checksum-mismatch"; + actualSha256?: string; +} + +export interface ParsedDocument { + data: unknown; + root: import("jsonc-parser").Node | undefined; + diagnostics: PalSchemaDiagnostic[]; +} + +export interface ValidationResult { + file: string; + schema: SchemaIndexEntry | null; + diagnostics: PalSchemaDiagnostic[]; +} + +export interface AjvDiagnosticContext { + error: ErrorObject; + text: string; + root: import("jsonc-parser").Node | undefined; + file: string; +} diff --git a/tools/palschema-tools/src/validator.ts b/tools/palschema-tools/src/validator.ts new file mode 100644 index 0000000..82264bc --- /dev/null +++ b/tools/palschema-tools/src/validator.ts @@ -0,0 +1,235 @@ +import { readFile } from "node:fs/promises"; +import { basename } from "node:path"; + +import { + Ajv, + type ErrorObject, + type ValidateFunction, +} from "ajv"; +import { findNodeAtLocation, type Node as JsonNode } from "jsonc-parser"; + +import { parseJsoncDocument } from "./jsonc-document.js"; +import { SchemaRegistry } from "./schema-registry.js"; +import { positionAt } from "./text-position.js"; +import type { + AjvDiagnosticContext, + PalSchemaDiagnostic, + SchemaIndexEntry, + ValidationResult, +} from "./types.js"; + +function decodeJsonPointer(pointer: string): Array { + if (!pointer) { + return []; + } + return pointer + .slice(1) + .split("/") + .map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~")) + .map((segment) => (/^(0|[1-9][0-9]*)$/.test(segment) ? Number(segment) : segment)); +} + +function nodeForError( + root: JsonNode | undefined, + error: ErrorObject, +): JsonNode | undefined { + if (!root) { + return undefined; + } + const path = decodeJsonPointer(error.instancePath); + const node = findNodeAtLocation(root, path); + if (error.keyword === "required" && node) { + return node; + } + return node ?? root; +} + +function ajvDiagnostic(context: AjvDiagnosticContext): PalSchemaDiagnostic { + const { error, text, root, file } = context; + const node = nodeForError(root, error); + const location = positionAt(text, node?.offset ?? 0, node?.length ?? 1); + let message = error.message ?? "Schema validation failed."; + if (error.keyword === "required") { + const missingProperty = String(error.params.missingProperty); + message = `Missing required property ${JSON.stringify(missingProperty)}.`; + } + + return { + ...location, + code: `PS_SCHEMA_${error.keyword.toUpperCase()}`, + severity: "error", + message, + file, + source: "palschema", + schemaPath: error.schemaPath, + instancePath: error.instancePath, + }; +} + +function referencedEnumDefinitions(schema: unknown): string[] { + const serialized = JSON.stringify(schema); + const definitions = new Set(); + const expression = /enums\.schema\.json#\/definitions\/([^"\\]+)/g; + for (const match of serialized.matchAll(expression)) { + const definition = match[1]; + if (definition) { + definitions.add(definition); + } + } + return [...definitions].sort(); +} + +export class PalSchemaValidator { + readonly registry: SchemaRegistry; + private readonly validators = new Map(); + private readonly fallbackEnumEntries = new Set(); + private readonly allowMissingGenerated: boolean; + + private constructor(registry: SchemaRegistry, allowMissingGenerated: boolean) { + this.registry = registry; + this.allowMissingGenerated = allowMissingGenerated; + } + + static async create( + schemaDirectory?: string, + options: { allowMissingGenerated?: boolean } = {}, + ): Promise { + return new PalSchemaValidator( + await SchemaRegistry.load(schemaDirectory), + options.allowMissingGenerated ?? false, + ); + } + + private async compile(entry: SchemaIndexEntry): Promise { + const existing = this.validators.get(entry.id); + if (existing) { + return existing; + } + + const schema = await this.registry.readSchema(entry); + const ajv = new Ajv({ + allErrors: true, + strict: false, + validateFormats: false, + }); + + if (entry.dependencies.includes("enums.schema.json")) { + const enumsEntry = this.registry.entryForFile("enums.schema.json"); + if (!enumsEntry) { + throw new Error("schema-index.json does not declare enums.schema.json."); + } + + try { + ajv.addSchema(await this.registry.readSchema(enumsEntry), enumsEntry.id); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + this.fallbackEnumEntries.add(entry.id); + const definitions = Object.fromEntries( + referencedEnumDefinitions(schema).map((name) => [name, {}]), + ); + ajv.addSchema( + { + $schema: "http://json-schema.org/draft-07/schema#", + $id: enumsEntry.id, + definitions, + }, + enumsEntry.id, + ); + } + } + + const validate = ajv.compile(schema); + this.validators.set(entry.id, validate); + return validate; + } + + async validateText(text: string, file: string): Promise { + const parsed = parseJsoncDocument(text, file); + const entry = this.registry.findForDocument(file); + if (parsed.diagnostics.length > 0) { + return { file, schema: entry, diagnostics: parsed.diagnostics }; + } + if (!entry) { + return { + file, + schema: null, + diagnostics: [ + { + ...positionAt(text, 0), + code: "PS_SCHEMA_UNASSOCIATED", + severity: "warning", + message: + "No PalSchema schema is associated with this folder; JSON/JSONC syntax was checked.", + file, + source: "palschema", + }, + ], + }; + } + + if (entry.generated) { + try { + await this.registry.readSchema(entry); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { + file, + schema: entry, + diagnostics: [ + { + ...positionAt(text, 0), + code: "PS_SCHEMA_GENERATED_MISSING", + severity: this.allowMissingGenerated ? "warning" : "error", + message: + `${entry.file} is generated by PalSchema at runtime. ` + + "Generate or copy the current schema pack, then set PALSCHEMA_SCHEMA_DIR.", + file, + source: "palschema", + }, + ], + }; + } + throw error; + } + } + + const validate = await this.compile(entry); + const valid = validate(parsed.data); + const diagnostics = (validate.errors ?? []).map((error) => + ajvDiagnostic({ error, text, root: parsed.root, file }), + ); + if (this.fallbackEnumEntries.has(entry.id)) { + diagnostics.push({ + ...positionAt(text, 0), + code: "PS_SCHEMA_ENUMS_MISSING", + severity: this.allowMissingGenerated ? "warning" : "error", + message: + "enums.schema.json is missing; enum values were not constrained during this structural validation.", + file, + source: "palschema", + }); + } + if (!valid && diagnostics.length === 0) { + diagnostics.push({ + ...positionAt(text, 0), + code: "PS_SCHEMA_INVALID", + severity: "error", + message: "Schema validation failed without a detailed Ajv error.", + file, + source: "palschema", + }); + } + + return { file, schema: entry, diagnostics }; + } + + async validateFile(file: string): Promise { + return this.validateText(await readFile(file, "utf8"), file); + } + + describeSchema(result: ValidationResult): string { + return result.schema?.file ?? `syntax-only (${basename(result.file)})`; + } +} diff --git a/tools/palschema-tools/test/cli-smoke.test.mjs b/tools/palschema-tools/test/cli-smoke.test.mjs new file mode 100644 index 0000000..9b1a6c1 --- /dev/null +++ b/tools/palschema-tools/test/cli-smoke.test.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const testDirectory = dirname(fileURLToPath(import.meta.url)); +const packageRoot = resolve(testDirectory, ".."); +const cliSource = resolve(packageRoot, "src/cli.ts"); +const tsxImport = import.meta.resolve("tsx"); + +function runCli(arguments_, cwd) { + return spawnSync( + process.execPath, + ["--import", tsxImport, cliSource, ...arguments_], + { + cwd, + encoding: "utf8", + }, + ); +} + +test("init creates a workspace that validate can check without support-file noise", () => { + const workspace = mkdtempSync(resolve(tmpdir(), "palschema-cli-")); + try { + const initialization = runCli(["init", "."], workspace); + assert.equal(initialization.status, 0, initialization.stderr); + + const itemsDirectory = resolve(workspace, "items"); + mkdirSync(itemsDirectory); + writeFileSync( + resolve(itemsDirectory, "example.jsonc"), + '{\n "Example": {\n "Type": "Generic",\n "IconTexture": "/Game/Test/T_Test.T_Test",\n "TypeA": "Any",\n "TypeB": "Any",\n "Rank": 0,\n "Rarity": 0,\n "MaxStackCount": 1\n }\n}\n', + ); + + const validation = runCli(["validate", "."], workspace); + assert.equal(validation.status, 0, validation.stderr); + assert.match(validation.stdout, /PS_SCHEMA_ENUMS_MISSING.*warning/); + assert.match(validation.stdout, /Validated 1 file\(s\): 0 error\(s\), 1 warning\(s\)\./); + + const validationFromOutside = runCli( + ["validate", workspace], + packageRoot, + ); + assert.equal(validationFromOutside.status, 0, validationFromOutside.stderr); + assert.match( + validationFromOutside.stdout, + /Validated 1 file\(s\): 0 error\(s\), 1 warning\(s\)\./, + ); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } +}); diff --git a/tools/palschema-tools/test/configuration.test.ts b/tools/palschema-tools/test/configuration.test.ts new file mode 100644 index 0000000..98c1172 --- /dev/null +++ b/tools/palschema-tools/test/configuration.test.ts @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { findProjectConfig } from "../src/configuration.js"; + +test("discovers project config and resolves its schema path", async () => { + const temporaryRoot = await mkdtemp(join(tmpdir(), "palschema-config-")); + try { + const nested = join(temporaryRoot, "mods", "Example", "items"); + await mkdir(nested, { recursive: true }); + await writeFile( + join(temporaryRoot, "palschema.config.json"), + `{ + // JSONC is accepted for a friendlier checked-in config. + "schemaDirectory": ".palschema/schemas", + "allowMissingGenerated": true, + }\n`, + ); + + const config = await findProjectConfig(nested); + assert.equal( + config?.schemaDirectory, + join(temporaryRoot, ".palschema", "schemas"), + ); + assert.equal(config?.allowMissingGenerated, true); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } +}); diff --git a/tools/palschema-tools/test/jsonc-document.test.ts b/tools/palschema-tools/test/jsonc-document.test.ts new file mode 100644 index 0000000..50bd2d2 --- /dev/null +++ b/tools/palschema-tools/test/jsonc-document.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { parseJsoncDocument } from "../src/jsonc-document.js"; + +test("accepts comments and trailing commas", () => { + const parsed = parseJsoncDocument( + `{ + // PalSchema supports JSONC. + "Example": { + "Value": 1, + }, + }`, + "example.jsonc", + ); + + assert.deepEqual(parsed.diagnostics, []); + assert.deepEqual(parsed.data, { Example: { Value: 1 } }); +}); + +test("reports one-based source positions for syntax errors", () => { + const parsed = parseJsoncDocument('{\n "Example":,\n}', "broken.jsonc"); + + assert.equal(parsed.diagnostics.length > 0, true); + assert.equal(parsed.diagnostics[0]?.line, 2); + assert.equal(parsed.diagnostics[0]?.source, "palschema"); +}); diff --git a/tools/palschema-tools/test/lsp-smoke.test.mjs b/tools/palschema-tools/test/lsp-smoke.test.mjs new file mode 100644 index 0000000..8241981 --- /dev/null +++ b/tools/palschema-tools/test/lsp-smoke.test.mjs @@ -0,0 +1,221 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import test from "node:test"; + +const testDirectory = dirname(fileURLToPath(import.meta.url)); +const packageRoot = resolve(testDirectory, ".."); +const repositoryRoot = resolve(packageRoot, "../.."); + +function encodeMessage(message) { + const payload = JSON.stringify(message); + return `Content-Length: ${Buffer.byteLength(payload)}\r\n\r\n${payload}`; +} + +test("serves diagnostics and schema features over LSP", async (context) => { + const child = spawn( + process.execPath, + ["--import", "tsx", "src/lsp-server.ts", "--stdio"], + { + cwd: packageRoot, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + context.after(() => { + if (!child.killed) { + child.kill(); + } + }); + + let buffer = Buffer.alloc(0); + let stderr = ""; + const received = []; + const waiters = []; + + function dispatch(message) { + received.push(message); + for (const waiter of [...waiters]) { + if (waiter.predicate(message)) { + clearTimeout(waiter.timeout); + waiters.splice(waiters.indexOf(waiter), 1); + waiter.resolve(message); + } + } + } + + function consume() { + for (;;) { + const separator = buffer.indexOf("\r\n\r\n"); + if (separator < 0) { + return; + } + const header = buffer.subarray(0, separator).toString("ascii"); + const lengthMatch = /^Content-Length: (\d+)$/im.exec(header); + assert.ok(lengthMatch, `Missing Content-Length header in ${header}`); + const length = Number(lengthMatch[1]); + const bodyStart = separator + 4; + if (buffer.length < bodyStart + length) { + return; + } + const body = buffer + .subarray(bodyStart, bodyStart + length) + .toString("utf8"); + buffer = buffer.subarray(bodyStart + length); + dispatch(JSON.parse(body)); + } + } + + child.stdout.on("data", (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + consume(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString("utf8"); + }); + + function send(message) { + child.stdin.write(encodeMessage(message)); + } + + function waitFor(predicate, label) { + const existing = received.find(predicate); + if (existing) { + return Promise.resolve(existing); + } + return new Promise((resolvePromise, rejectPromise) => { + const timeout = setTimeout(() => { + rejectPromise( + new Error(`Timed out waiting for ${label}. Server stderr:\n${stderr}`), + ); + }, 10_000); + waiters.push({ predicate, resolve: resolvePromise, timeout }); + }); + } + + send({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + processId: process.pid, + rootUri: pathToFileURL(repositoryRoot).toString(), + capabilities: {}, + initializationOptions: { + schemaDirectory: resolve(repositoryRoot, "assets/schemas"), + allowMissingGenerated: true, + }, + }, + }); + const initialize = await waitFor( + (message) => message.id === 1, + "initialize response", + ); + assert.equal(initialize.result.serverInfo.name, "PalSchema Language Server"); + assert.equal(initialize.result.capabilities.hoverProvider, true); + send({ jsonrpc: "2.0", method: "initialized", params: {} }); + + const documentUri = pathToFileURL( + resolve(repositoryRoot, "fixture/items/invalid.jsonc"), + ).toString(); + send({ + jsonrpc: "2.0", + method: "textDocument/didOpen", + params: { + textDocument: { + uri: documentUri, + languageId: "jsonc", + version: 1, + text: '{\n "Broken": { "Rarity": 99 }\n}\n', + }, + }, + }); + const diagnostics = await waitFor( + (message) => + message.method === "textDocument/publishDiagnostics" && + message.params?.uri === documentUri, + "published diagnostics", + ); + assert.equal( + diagnostics.params.diagnostics.some( + (diagnostic) => diagnostic.code === "PS_SCHEMA_MAXIMUM", + ), + true, + ); + + const completionUri = pathToFileURL( + resolve(repositoryRoot, "fixture/items/completion.jsonc"), + ).toString(); + send({ + jsonrpc: "2.0", + method: "textDocument/didOpen", + params: { + textDocument: { + uri: completionUri, + languageId: "jsonc", + version: 1, + text: '{\n "Example": {\n \n }\n}\n', + }, + }, + }); + await waitFor( + (message) => + message.method === "textDocument/publishDiagnostics" && + message.params?.uri === completionUri, + "completion document diagnostics", + ); + send({ + jsonrpc: "2.0", + id: 2, + method: "textDocument/completion", + params: { + textDocument: { uri: completionUri }, + position: { line: 2, character: 4 }, + }, + }); + const completion = await waitFor( + (message) => message.id === 2, + "completion response", + ); + const completionItems = Array.isArray(completion.result) + ? completion.result + : completion.result?.items ?? []; + assert.equal( + completionItems.some((item) => item.label === "Type"), + true, + ); + + const hoverText = '{\n "Example": {\n "Rarity": 1\n }\n}\n'; + send({ + jsonrpc: "2.0", + method: "textDocument/didChange", + params: { + textDocument: { uri: completionUri, version: 2 }, + contentChanges: [{ text: hoverText }], + }, + }); + await waitFor( + (message) => + message.method === "textDocument/publishDiagnostics" && + message.params?.uri === completionUri, + "hover document diagnostics", + ); + send({ + jsonrpc: "2.0", + id: 3, + method: "textDocument/hover", + params: { + textDocument: { uri: completionUri }, + position: { line: 2, character: 7 }, + }, + }); + const hover = await waitFor( + (message) => message.id === 3, + "hover response", + ); + assert.match(JSON.stringify(hover.result), /background color/i); + + send({ jsonrpc: "2.0", id: 4, method: "shutdown", params: null }); + await waitFor((message) => message.id === 4, "shutdown response"); + send({ jsonrpc: "2.0", method: "exit", params: null }); +}); diff --git a/tools/palschema-tools/test/validator.test.ts b/tools/palschema-tools/test/validator.test.ts new file mode 100644 index 0000000..b55b192 --- /dev/null +++ b/tools/palschema-tools/test/validator.test.ts @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; + +import { PalSchemaValidator } from "../src/validator.js"; +import { SchemaRegistry } from "../src/schema-registry.js"; + +const repositoryRoot = resolve(import.meta.dirname, "../../.."); +const schemaDirectory = resolve(repositoryRoot, "assets/schemas"); + +test("validates a checked-in JSON example with offline enum fallback", async () => { + const validator = await PalSchemaValidator.create(schemaDirectory, { + allowMissingGenerated: true, + }); + const result = await validator.validateFile( + resolve( + repositoryRoot, + "assets/examples/ExampleMod/items/example_items.json", + ), + ); + + assert.equal( + result.diagnostics.some((diagnostic) => diagnostic.severity === "error"), + false, + ); + assert.equal( + result.diagnostics.some( + (diagnostic) => diagnostic.code === "PS_SCHEMA_ENUMS_MISSING", + ), + true, + ); +}); + +test("requires runtime-generated enum schemas in strict mode", async () => { + const validator = await PalSchemaValidator.create(schemaDirectory); + const result = await validator.validateFile( + resolve( + repositoryRoot, + "assets/examples/ExampleMod/items/example_items.json", + ), + ); + + const diagnostic = result.diagnostics.find( + (item) => item.code === "PS_SCHEMA_ENUMS_MISSING", + ); + assert.ok(diagnostic); + assert.equal(diagnostic.severity, "error"); +}); + +test("reports schema errors at a stable source position", async () => { + const temporaryRoot = await mkdtemp(join(tmpdir(), "palschema-tools-")); + try { + const itemsDirectory = join(temporaryRoot, "items"); + await mkdir(itemsDirectory); + const file = join(itemsDirectory, "invalid.jsonc"); + await writeFile(file, '{\n "Broken": { "Rarity": 99 }\n}\n'); + + const validator = await PalSchemaValidator.create(schemaDirectory); + const result = await validator.validateFile(file); + const diagnostic = result.diagnostics.find( + (item) => item.code === "PS_SCHEMA_MAXIMUM", + ); + assert.ok(diagnostic); + assert.equal(diagnostic.line, 2); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } +}); + +test("requires runtime-generated raw schemas explicitly", async () => { + const validator = await PalSchemaValidator.create(schemaDirectory); + const result = await validator.validateText( + "{}", + resolve(repositoryRoot, "fixture/raw/example.json"), + ); + + assert.equal(result.diagnostics[0]?.code, "PS_SCHEMA_GENERATED_MISSING"); + assert.equal(result.diagnostics[0]?.severity, "error"); +}); + +test("can downgrade missing generated schemas for offline repository checks", async () => { + const validator = await PalSchemaValidator.create(schemaDirectory, { + allowMissingGenerated: true, + }); + const result = await validator.validateText( + "{}", + resolve(repositoryRoot, "fixture/raw/example.jsonc"), + ); + + assert.equal(result.diagnostics[0]?.code, "PS_SCHEMA_GENERATED_MISSING"); + assert.equal(result.diagnostics[0]?.severity, "warning"); +}); + +test("accepts an unpinned local runtime-generated schema", async () => { + const temporaryRoot = await mkdtemp(join(tmpdir(), "palschema-schemas-")); + try { + const sourceIndex = JSON.parse( + await readFile(resolve(schemaDirectory, "schema-index.json"), "utf8"), + ); + await writeFile( + join(temporaryRoot, "schema-index.json"), + `${JSON.stringify(sourceIndex)}\n`, + ); + await writeFile( + join(temporaryRoot, "raw.schema.json"), + '{"$schema":"http://json-schema.org/draft-07/schema#","type":"object"}\n', + ); + + const registry = await SchemaRegistry.load(temporaryRoot); + const verification = await registry.verify(); + assert.equal( + verification.find((result) => result.entry.file === "raw.schema.json") + ?.status, + "present-generated", + ); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } +}); diff --git a/tools/palschema-tools/tsconfig.json b/tools/palschema-tools/tsconfig.json new file mode 100644 index 0000000..24d0665 --- /dev/null +++ b/tools/palschema-tools/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/tools/vscode-palschema/.vscodeignore b/tools/vscode-palschema/.vscodeignore new file mode 100644 index 0000000..7454e08 --- /dev/null +++ b/tools/vscode-palschema/.vscodeignore @@ -0,0 +1,5 @@ +src/** +scripts/** +tsconfig.json +node_modules/** +*.vsix diff --git a/tools/vscode-palschema/LICENSE b/tools/vscode-palschema/LICENSE new file mode 100644 index 0000000..d152b5e --- /dev/null +++ b/tools/vscode-palschema/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Okaetsu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tools/vscode-palschema/README.md b/tools/vscode-palschema/README.md new file mode 100644 index 0000000..eb590c2 --- /dev/null +++ b/tools/vscode-palschema/README.md @@ -0,0 +1,12 @@ +# PalSchema for VS Code + +This extension adds PalSchema-aware diagnostics, JSON/JSONC completion, hover +documentation, document symbols, and formatting on Linux, Windows, and macOS. + +Its language features attach only to JSON or JSONC files below a PalSchema +loader directory such as `items`, `pals`, `raw`, or `blueprints`. Static +schemas are bundled. The game-generated `enums.schema.json` and +`raw.schema.json` can be selected with the `palschema.schemaDirectory` setting +after copying them from a current PalSchema runtime. + +UE4SS, game data, and generated proprietary schemas are not bundled. diff --git a/tools/vscode-palschema/package.json b/tools/vscode-palschema/package.json new file mode 100644 index 0000000..a0f9cf3 --- /dev/null +++ b/tools/vscode-palschema/package.json @@ -0,0 +1,71 @@ +{ + "name": "palschema-vscode", + "displayName": "PalSchema", + "description": "PalSchema JSON/JSONC validation, completion, hover help, and formatting.", + "version": "0.6.1", + "publisher": "okaetsu", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/Okaetsu/PalSchema.git" + }, + "private": true, + "engines": { + "vscode": "^1.91.0" + }, + "categories": [ + "Programming Languages", + "Linters", + "Formatters" + ], + "keywords": [ + "Palworld", + "PalSchema", + "JSONC", + "modding" + ], + "main": "./dist/extension.js", + "activationEvents": [ + "onLanguage:json", + "onLanguage:jsonc" + ], + "contributes": { + "commands": [ + { + "command": "palschema.restartLanguageServer", + "title": "PalSchema: Restart Language Server" + } + ], + "configuration": { + "title": "PalSchema", + "properties": { + "palschema.schemaDirectory": { + "type": "string", + "default": "", + "scope": "resource", + "description": "Optional schema directory. Relative paths are resolved from the first workspace folder; empty uses the schemas bundled with the extension." + }, + "palschema.allowMissingGenerated": { + "type": "boolean", + "default": true, + "scope": "resource", + "description": "Report missing runtime-generated raw/enums schemas as warnings instead of errors." + } + } + } + }, + "scripts": { + "build": "node scripts/build.mjs", + "package:vsix": "node scripts/package-extension.mjs", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "vscode-languageclient": "10.1.0" + }, + "devDependencies": { + "@types/vscode": "1.91.0", + "@vscode/vsce": "3.9.2", + "esbuild": "0.28.1", + "typescript": "5.8.3" + } +} diff --git a/tools/vscode-palschema/scripts/build.mjs b/tools/vscode-palschema/scripts/build.mjs new file mode 100644 index 0000000..df74be2 --- /dev/null +++ b/tools/vscode-palschema/scripts/build.mjs @@ -0,0 +1,40 @@ +import { cp, mkdir, rm } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { build } from "esbuild"; + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +const extensionRoot = resolve(scriptDirectory, ".."); +const repositoryRoot = resolve(extensionRoot, "../.."); +const outputDirectory = resolve(extensionRoot, "dist"); + +await rm(outputDirectory, { recursive: true, force: true }); +await mkdir(outputDirectory, { recursive: true }); +await Promise.all([ + build({ + entryPoints: [resolve(extensionRoot, "src/extension.ts")], + outfile: resolve(outputDirectory, "extension.js"), + bundle: true, + platform: "node", + format: "cjs", + external: ["vscode"], + target: "node20", + }), + build({ + entryPoints: [ + resolve(repositoryRoot, "tools/palschema-tools/src/lsp-server.ts"), + ], + outfile: resolve(outputDirectory, "server.mjs"), + bundle: true, + platform: "node", + format: "esm", + target: "node20", + }), +]); + +await cp( + resolve(repositoryRoot, "assets/schemas"), + resolve(outputDirectory, "schemas"), + { recursive: true, force: true }, +); diff --git a/tools/vscode-palschema/scripts/package-extension.mjs b/tools/vscode-palschema/scripts/package-extension.mjs new file mode 100644 index 0000000..72d8b2c --- /dev/null +++ b/tools/vscode-palschema/scripts/package-extension.mjs @@ -0,0 +1,41 @@ +import { mkdir } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +const extensionRoot = resolve(scriptDirectory, ".."); +const repositoryRoot = resolve(extensionRoot, "../.."); +const outputDirectory = resolve(repositoryRoot, "dist"); +const packageDefinition = await import( + new URL("../package.json", import.meta.url), + { with: { type: "json" } } +); +const output = resolve( + outputDirectory, + `palschema-vscode-${packageDefinition.default.version}.vsix`, +); + +await mkdir(outputDirectory, { recursive: true }); + +const executable = + process.platform === "win32" + ? resolve(repositoryRoot, "node_modules/.bin/vsce.cmd") + : resolve(repositoryRoot, "node_modules/.bin/vsce"); +const child = spawn( + executable, + ["package", "--no-dependencies", "--out", output], + { + cwd: extensionRoot, + stdio: "inherit", + }, +); +const exitCode = await new Promise((resolvePromise, rejectPromise) => { + child.once("error", rejectPromise); + child.once("exit", resolvePromise); +}); +if (exitCode !== 0) { + throw new Error(`vsce exited with code ${String(exitCode)}.`); +} + +console.log(output); diff --git a/tools/vscode-palschema/src/extension.ts b/tools/vscode-palschema/src/extension.ts new file mode 100644 index 0000000..111077d --- /dev/null +++ b/tools/vscode-palschema/src/extension.ts @@ -0,0 +1,108 @@ +import { isAbsolute, resolve } from "node:path"; + +import { + commands, + type ExtensionContext, + workspace, +} from "vscode"; +import { + LanguageClient, + TransportKind, + type LanguageClientOptions, + type ServerOptions, +} from "vscode-languageclient/node"; + +const PALSCHEMA_FOLDERS = [ + "appearance", + "blueprints", + "buildings", + "enums", + "helpguide", + "items", + "npcs", + "pals", + "raw", + "resources", + "skins", + "spawns", + "translations", +]; + +let client: LanguageClient | undefined; + +function configuredSchemaDirectory(context: ExtensionContext): string { + const configured = workspace + .getConfiguration("palschema") + .get("schemaDirectory", "") + .trim(); + if (!configured) { + return context.asAbsolutePath("dist/schemas"); + } + if (isAbsolute(configured)) { + return configured; + } + const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath; + return resolve(workspaceRoot ?? process.cwd(), configured); +} + +function documentSelector(): NonNullable< + LanguageClientOptions["documentSelector"] +> { + return PALSCHEMA_FOLDERS.flatMap((folder) => [ + { scheme: "file", language: "json", pattern: `**/${folder}/**/*.json` }, + { scheme: "file", language: "jsonc", pattern: `**/${folder}/**/*.jsonc` }, + ]); +} + +async function startClient(context: ExtensionContext): Promise { + const serverModule = context.asAbsolutePath("dist/server.mjs"); + const serverOptions: ServerOptions = { + run: { module: serverModule, transport: TransportKind.ipc }, + debug: { module: serverModule, transport: TransportKind.ipc }, + }; + const clientOptions: LanguageClientOptions = { + documentSelector: documentSelector(), + initializationOptions: { + schemaDirectory: configuredSchemaDirectory(context), + allowMissingGenerated: workspace + .getConfiguration("palschema") + .get("allowMissingGenerated", true), + }, + }; + client = new LanguageClient( + "palschema", + "PalSchema Language Server", + serverOptions, + clientOptions, + ); + await client.start(); +} + +async function restartClient(context: ExtensionContext): Promise { + if (client) { + await client.stop(); + client = undefined; + } + await startClient(context); +} + +export async function activate(context: ExtensionContext): Promise { + context.subscriptions.push( + commands.registerCommand("palschema.restartLanguageServer", () => + restartClient(context), + ), + workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("palschema")) { + void restartClient(context); + } + }), + ); + await startClient(context); +} + +export async function deactivate(): Promise { + if (client) { + await client.stop(); + client = undefined; + } +} diff --git a/tools/vscode-palschema/tsconfig.json b/tools/vscode-palschema/tsconfig.json new file mode 100644 index 0000000..072d5f9 --- /dev/null +++ b/tools/vscode-palschema/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts" + ] +} From d2436fd4fa9852373bd8f1217e84721cb104b138 Mon Sep 17 00:00:00 2001 From: LAP87 Date: Sat, 25 Jul 2026 18:46:49 +0200 Subject: [PATCH 3/5] fix: harden Linux workflows after review --- .github/workflows/build.yml | 113 +++--- CMakeLists.txt | 11 + README.md | 2 +- assets/schemas/schema-index.json | 9 + docs/linux/building-win64.md | 28 +- docs/linux/dedicated-server.md | 65 +++- include/Platform/RuntimeEnvironment.h | 21 ++ package-lock.json | 1 + package.json | 1 + scripts/bootstrap-linux.sh | 125 ++++-- scripts/ci/run-public-source-checks.sh | 80 ++++ scripts/copy-public-schemas.mjs | 187 +++++++++ scripts/deploy-proton.sh | 310 +++++++++++---- scripts/lib/build-env.sh | 28 ++ scripts/lib/process-scan.sh | 48 +++ scripts/package-linux.sh | 17 +- scripts/test-distro-matrix.sh | 326 ++++++++++++++++ scripts/test-win64-server.sh | 355 ++++++++++++++++++ scripts/tests/test-bootstrap-cache.sh | 124 ++++++ scripts/tests/test-deploy-transaction.sh | 216 +++++++++++ scripts/tests/test-distro-matrix-output.sh | 67 ++++ scripts/tests/test-process-scan.sh | 51 +++ scripts/tests/test_verify_win64_artifact.py | 153 ++++++++ scripts/tests/test_version_consistency.py | 57 +++ scripts/verify-win64-artifact.py | 105 +++++- src/Misc/FileWatchWrapper.cpp | 30 +- src/SDK/PalSignatures.cpp | 21 +- .../palschema-tools/scripts/copy-schemas.mjs | 6 +- tools/palschema-tools/src/cli.ts | 304 ++++++++++++--- tools/palschema-tools/src/configuration.ts | 2 +- tools/palschema-tools/src/jsonc-document.ts | 11 +- tools/palschema-tools/src/lsp-options.ts | 38 ++ tools/palschema-tools/src/lsp-server.ts | 64 +++- tools/palschema-tools/src/path-security.ts | 23 ++ tools/palschema-tools/src/schema-registry.ts | 235 +++++++++++- tools/palschema-tools/src/text-position.ts | 49 ++- tools/palschema-tools/src/types.ts | 2 +- tools/palschema-tools/src/validator.ts | 80 +++- tools/palschema-tools/test/cli-smoke.test.mjs | 171 ++++++++- .../test/jsonc-document.test.ts | 17 +- .../palschema-tools/test/lsp-options.test.ts | 27 ++ tools/palschema-tools/test/lsp-smoke.test.mjs | 113 +++++- .../test/public-schema-copy.test.mjs | 55 +++ tools/palschema-tools/test/validator.test.ts | 74 +++- tools/palschema-tools/test/watch.test.mjs | 110 ++++++ tools/vscode-palschema/.vscodeignore | 1 + tools/vscode-palschema/package.json | 4 +- tools/vscode-palschema/scripts/build.mjs | 6 +- tools/vscode-palschema/src/extension.ts | 140 +++++-- .../src/serial-operation-queue.ts | 12 + .../test/serial-operation-queue.test.ts | 45 +++ 51 files changed, 3794 insertions(+), 346 deletions(-) create mode 100644 include/Platform/RuntimeEnvironment.h create mode 100755 scripts/ci/run-public-source-checks.sh create mode 100755 scripts/copy-public-schemas.mjs create mode 100644 scripts/lib/process-scan.sh create mode 100755 scripts/test-distro-matrix.sh create mode 100755 scripts/test-win64-server.sh create mode 100755 scripts/tests/test-bootstrap-cache.sh create mode 100755 scripts/tests/test-deploy-transaction.sh create mode 100755 scripts/tests/test-distro-matrix-output.sh create mode 100755 scripts/tests/test-process-scan.sh create mode 100644 scripts/tests/test_verify_win64_artifact.py create mode 100644 scripts/tests/test_version_consistency.py create mode 100644 tools/palschema-tools/src/lsp-options.ts create mode 100644 tools/palschema-tools/src/path-security.ts create mode 100644 tools/palschema-tools/test/lsp-options.test.ts create mode 100644 tools/palschema-tools/test/public-schema-copy.test.mjs create mode 100644 tools/palschema-tools/test/watch.test.mjs create mode 100644 tools/vscode-palschema/src/serial-operation-queue.ts create mode 100644 tools/vscode-palschema/test/serial-operation-queue.test.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4fc90eb..03d92bd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,25 +20,7 @@ jobs: uses: actions/checkout@v7 with: submodules: false - - - name: Check shell syntax - run: | - bash -n \ - scripts/bootstrap-linux.sh \ - scripts/build-linux.sh \ - scripts/cargo-preserve-lock.sh \ - scripts/deploy-proton.sh \ - scripts/package-linux.sh \ - scripts/lib/build-env.sh - - - name: Check Python syntax - run: python -m py_compile scripts/verify-win64-artifact.py - - - name: Check JSON - run: | - python -m json.tool CMakePresets.json > /dev/null - python -m json.tool assets/.vscode/settings.json > /dev/null - python -m json.tool assets/schemas/schema-index.json > /dev/null + persist-credentials: false - name: Set up Node.js uses: actions/setup-node@v7 @@ -46,33 +28,8 @@ jobs: node-version: 22 cache: npm - - name: Install authoring-tool dependencies - run: npm ci - - - name: Audit authoring-tool dependencies - run: npm audit --audit-level=moderate - - - name: Type-check authoring tools and VS Code extension - run: | - npm run tools:typecheck - npm run typecheck --workspace palschema-vscode - - - name: Test CLI, schemas, and language server - run: | - npm run tools:test - npm run tools:build - node tools/palschema-tools/dist/cli.js schemas verify - node tools/palschema-tools/dist/cli.js validate \ - --allow-missing-generated \ - assets/examples - - - name: Build distributable authoring tools - run: | - npm run editor:build - npm run editor:package - npm pack \ - --workspace @palschema/tools \ - --pack-destination dist + - name: Run public source checks + run: scripts/ci/run-public-source-checks.sh - name: Upload authoring-tool packages uses: actions/upload-artifact@v7 @@ -88,8 +45,8 @@ jobs: name: Windows MSVC Shipping baseline if: >- vars.PRIVATE_SUBMODULES_AVAILABLE == 'true' && - (github.event_name != 'pull_request' || - github.event.pull_request.head.repo.full_name == github.repository) + github.event_name == 'push' && + github.ref == 'refs/heads/main' runs-on: windows-latest defaults: run: @@ -101,6 +58,7 @@ jobs: fetch-depth: 0 submodules: recursive token: ${{ secrets.UEPSEUDO_TOKEN }} + persist-credentials: false - name: Set up MSVC uses: ilammy/msvc-dev-cmd@v1 @@ -139,3 +97,62 @@ jobs: path: | build/win64-msvc-ci-shipping/PalSchema.dll build/win64-msvc-ci-shipping/pe-contract.json + + linux-xwin: + name: Linux-hosted Win64 Dev and Shipping + if: >- + vars.PRIVATE_SUBMODULES_AVAILABLE == 'true' && + github.event_name == 'push' && + github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - name: Checkout authorized recursive dependencies + uses: actions/checkout@v7 + with: + fetch-depth: 0 + submodules: recursive + token: ${{ secrets.UEPSEUDO_TOKEN }} + persist-credentials: false + + - name: Install Linux cross-build tools + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + clang clang-tools cmake lld llvm ninja-build \ + python3 util-linux zip + + - name: Prepare isolated Rust and Microsoft SDK caches + run: >- + scripts/bootstrap-linux.sh + --install-rust-toolchain + --install-xwin + --prepare-sdk + --accept-microsoft-license + + - name: Build and verify Linux-hosted Win64 artifact + run: | + scripts/build-linux.sh dev + python3 scripts/verify-win64-artifact.py \ + build/win64-xwin-dev/PalSchema.dll \ + --json-output build/win64-xwin-dev/pe-contract.json + scripts/package-linux.sh dev --output-dir dist + scripts/build-linux.sh shipping + python3 scripts/verify-win64-artifact.py \ + build/win64-xwin-shipping/PalSchema.dll \ + --json-output build/win64-xwin-shipping/pe-contract.json + scripts/package-linux.sh shipping --output-dir dist + unzip -l dist/PalSchema_*_Win64*.zip + + - name: Upload Linux-hosted Win64 outputs + uses: actions/upload-artifact@v7 + with: + name: PalSchema-Win64-Linux-xwin + if-no-files-found: error + retention-days: 7 + path: | + build/win64-xwin-dev/PalSchema.dll + build/win64-xwin-dev/PalSchema.pdb + build/win64-xwin-dev/pe-contract.json + build/win64-xwin-shipping/PalSchema.dll + build/win64-xwin-shipping/pe-contract.json + dist/PalSchema_*_Win64*.zip diff --git a/CMakeLists.txt b/CMakeLists.txt index 45dc169..8fb8d89 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -60,3 +60,14 @@ target_include_directories(${TARGET} PRIVATE ) target_compile_definitions(${TARGET} PRIVATE PALSCHEMA_BUILDING_DLL=1) target_link_libraries(${TARGET} PRIVATE Zydis Zycore safetyhook nlohmann_json::nlohmann_json glaze::glaze UE4SS efsw) + +if(MSVC) + # Keep Shipping DLLs reproducible across build times and checkout paths. + # /Brepro replaces the wall-clock PE/PDB identity with a content-derived + # value, while the alternate PDB path prevents the absolute build root + # from leaking into the CodeView record embedded in the DLL. + target_link_options(${TARGET} PRIVATE + "/Brepro" + "/PDBALTPATH:PalSchema.pdb" + ) +endif() diff --git a/README.md b/README.md index d363861..54eb51b 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ running under Proton. The supported path uses xwin, clang-cl, LLD, Ninja, and the repository-pinned RE-UE4SS toolchain. See [Building the Win64 PalSchema DLL on Linux](docs/linux/building-win64.md). -For an atomic install with a recorded rollback path, see +For a locked, interruption-recoverable install with a recorded rollback path, see [Safe deployment to Palworld under Proton or Wine](docs/linux/proton-deployment.md). For a separately managed Win64 dedicated server on a Linux host, see [Dedicated server development on Linux](docs/linux/dedicated-server.md). diff --git a/assets/schemas/schema-index.json b/assets/schemas/schema-index.json index e492a08..5f5b297 100644 --- a/assets/schemas/schema-index.json +++ b/assets/schemas/schema-index.json @@ -15,6 +15,7 @@ "dependencies": [ "enums.schema.json" ], + "supportPatterns": [], "sha256": "26368e4f00ecf3503da4226dc1b25c014fe19d952e3161504f016c80343bfa52" }, { @@ -29,6 +30,7 @@ "dependencies": [ "enums.schema.json" ], + "supportPatterns": [], "sha256": "1d07f4983a270f43f20fe618b1cfca0dfba3be698998e19129e3fa3957ab1e74" }, { @@ -43,6 +45,7 @@ "dependencies": [ "enums.schema.json" ], + "supportPatterns": [], "sha256": "f510386ac1a4a50242de45bb90f921ab381cbca40a0d52ef730578bd255e340e" }, { @@ -57,6 +60,7 @@ "dependencies": [ "enums.schema.json" ], + "supportPatterns": [], "sha256": "361c6c625c914a8880a11e03015dd9180ee772f71848d104fea0fff14bcfb43b" }, { @@ -66,6 +70,7 @@ "patterns": [], "generated": false, "dependencies": [], + "supportPatterns": [], "sha256": "e754d5c2f060871e130d8b65c88f74ff6916319bdf48074e43b53c9a70254390" }, { @@ -78,6 +83,7 @@ ], "generated": true, "dependencies": [], + "supportPatterns": [], "sha256": null }, { @@ -92,6 +98,9 @@ "dependencies": [ "enums.schema.json" ], + "supportPatterns": [ + "raw/*.schema.json" + ], "sha256": null } ] diff --git a/docs/linux/building-win64.md b/docs/linux/building-win64.md index 2e9cd9d..986aef2 100644 --- a/docs/linux/building-win64.md +++ b/docs/linux/building-win64.md @@ -41,10 +41,26 @@ distribution's version-suffixed LLVM tools, such as Ubuntu's `clang-cl-18`, without creating system symlinks. Rust can remain project-isolated as described below. -On 2026-07-25 the host-tool gate and shell scripts were exercised in clean -Ubuntu 24.04, Debian 13, Fedora 42, Arch Linux, and openSUSE Tumbleweed -containers. The complete SDK preparation, Dev/Shipping cross-build, PE -verification, packaging, and Proton/Wine runtime tests were run on CachyOS. +The repository contains a clean-container matrix for Debian stable, Ubuntu +24.04, Fedora latest, Arch Linux, and openSUSE Tumbleweed: + +```bash +scripts/test-distro-matrix.sh +``` + +That default gate installs Node.js from its verified upstream SHA-256, +then runs the same shell, Python, JSON, schema, CLI/LSP, VS Code extension, +test, audit, and package checks as public CI. To also install each +distribution's build toolchain, cross-build the Win64 Shipping DLL, and verify +its PE contract using an already prepared local cache: + +```bash +scripts/test-distro-matrix.sh --build-shipping +``` + +The build mode mounts the existing PalSchema cache into each disposable +container. It never downloads the Microsoft SDK or accepts its license. +Prepare the SDK once through the explicit bootstrap gate below. ## Epic and UEPseudo access @@ -144,6 +160,10 @@ failed or interrupted builds. Build products remain under `build//`; the development DLL is written to `build/win64-xwin-dev/PalSchema.dll` and the shipping DLL to `build/win64-xwin-shipping/PalSchema.dll`. +Shipping links use the MSVC-compatible reproducible-build flag and a +checkout-independent CodeView PDB name. Rebuilding identical inputs therefore +does not embed the build time or an absolute checkout path in the DLL. + ## Windows CI baseline Public pull requests run source-format checks without cloning UEPseudo. The diff --git a/docs/linux/dedicated-server.md b/docs/linux/dedicated-server.md index ff15f2b..44e638c 100644 --- a/docs/linux/dedicated-server.md +++ b/docs/linux/dedicated-server.md @@ -4,13 +4,16 @@ PalSchema's supported dedicated-server path on a Linux host is the **Win64 Palworld Dedicated Server running through Wine or Proton**. It uses the same PalSchema DLL and UE4SS C++ mod ABI as the Windows client. -The native `PalServer-Linux-Shipping` binary is not currently a 1:1 PalSchema -target. PalSchema is a Win64 UE4SS C++ mod, while the upstream native UE4SS -port remains under development. The upstream headless Linux draft explicitly -leaves C++ mods outside its initial scope: +The native `PalServer-Linux-Shipping` binary is not currently a verified 1:1 +PalSchema target. PalSchema is presently built as a Win64 UE4SS C++ mod, while +the upstream native UE4SS port remains under development. The upstream +headless Linux draft explicitly leaves C++ mods outside its initial scope: [UE4SS pull request 1347](https://github.com/UE4SS-RE/RE-UE4SS/pull/1347). -An experimental downstream Linux release exists, but it does not provide the -full stable `CppUserModBase` ABI used by PalSchema. Do not treat it as a +Downstream Linux experiments may accept a native `CppUserModBase`/`main.so` +mod at the loader boundary; that is narrower than proving PalSchema's +signatures, layouts, hooks, threading, teardown, and generated schemas against +the native Palworld server. No such downstream and PalSchema combination is +pinned or verified by this repository yet, so it must not be treated as a drop-in replacement for this workflow. ## Ownership boundary @@ -83,8 +86,11 @@ scripts/deploy-proton.sh shipping \ ``` Review the paths, remove `--dry-run`, and keep the printed rollback command. -Deployment is atomic, creates a timestamped backup, and refuses to proceed -while the selected Win64 server is active. +Deployment uses a locked, journaled replacement transaction, creates a +timestamped backup, and refuses to proceed while the selected Win64 server is +active or process visibility is incomplete. A signal restores the previous +installation; after host or power interruption, the next deploy/rollback +recovers the recorded transaction before making another change. ## Start under Wine @@ -115,6 +121,11 @@ scanner path during startup. Native Windows keeps the normal parallel scanner. This avoids a Wine `std::async` startup deadlock without changing signatures, loader behavior, or the produced Win64 DLL ABI. +When PalSchema auto-reload is enabled, Wine uses efsw's polling backend because +Wine does not reliably deliver the Win32 directory-change notifications used +by efsw's native Windows backend. Native Windows keeps the event-driven +backend. The selected fallback is reported in `UE4SS.log`. + ## Verification Check `Pal/Binaries/Win64/ue4ss/UE4SS.log` for: @@ -129,12 +140,40 @@ Check `Pal/Binaries/Win64/ue4ss/UE4SS.log` for: Also verify that the configured game and query ports are open. A process that exists but never opens its ports is not a successful smoke test. +The repeatable smoke harness performs those checks, stops only the selected +Wine process group and prefix between cycles, and writes one evidence directory +per run: + +```bash +scripts/test-win64-server.sh \ + --game-dir /srv/palworld-win64 \ + --cycles 3 +``` + +Auto-reload can be included by pointing at a harmless fixture that already +exists inside one PalSchema mod. The harness only updates that file's +modification time: + +```bash +scripts/test-win64-server.sh \ + --game-dir /srv/palworld-win64 \ + --cycles 3 \ + --hot-reload-file \ + /srv/palworld-win64/Pal/Binaries/Win64/ue4ss/Mods/PalSchema/mods/Smoke/translations/en/smoke.json +``` + +Automatic discovery is intentionally disabled: the server root must be +explicit so the harness cannot select another Palworld installation. + The Linux-hosted workflow was exercised on 2026-07-25 against Win64 Dedicated -Server Steam build `24181105`: all 22 PalSchema signatures were found, all 13 -loaders initialized, UE4SS reached its event loop, and isolated UDP game/query -ports opened. The same DLL was then verified against the installed Palworld -client under Proton. This is a reproducibility record, not a promise that -future Palworld or UE4SS updates cannot change signatures or compatibility. +Server Steam build `24181105` under Wine 11.13. Five consecutive startup and +shutdown cycles found all 22 PalSchema signatures, initialized all 13 loaders, +reached UE4SS's event loop, opened both isolated UDP ports, and released both +ports after shutdown. Three additional cycles triggered and observed +PalSchema auto-reload through the Wine polling fallback. The same DLL was then +verified against the installed Palworld client under Proton. This is a +reproducibility record, not a promise that future Palworld or UE4SS updates +cannot change signatures or compatibility. ## Updating and rollback diff --git a/include/Platform/RuntimeEnvironment.h b/include/Platform/RuntimeEnvironment.h new file mode 100644 index 0000000..61ace7e --- /dev/null +++ b/include/Platform/RuntimeEnvironment.h @@ -0,0 +1,21 @@ +#pragma once + +#if defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif + +namespace PS::Platform +{ + inline bool IsRunningUnderWine() + { +#if defined(_WIN32) + auto ntdll = GetModuleHandleW(L"ntdll.dll"); + return ntdll && GetProcAddress(ntdll, "wine_get_version"); +#else + return false; +#endif + } +} diff --git a/package-lock.json b/package-lock.json index 1e52d34..d71c1b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4525,6 +4525,7 @@ "@types/vscode": "1.91.0", "@vscode/vsce": "3.9.2", "esbuild": "0.28.1", + "tsx": "4.20.3", "typescript": "5.8.3" }, "engines": { diff --git a/package.json b/package.json index 08042fa..94ab884 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "tools:test": "npm run test --workspace @palschema/tools", "tools:typecheck": "npm run typecheck --workspace @palschema/tools", "editor:build": "npm run build --workspace palschema-vscode", + "editor:test": "npm run test --workspace palschema-vscode", "editor:package": "npm run package:vsix --workspace palschema-vscode" } } diff --git a/scripts/bootstrap-linux.sh b/scripts/bootstrap-linux.sh index c0a56db..b75ca94 100755 --- a/scripts/bootstrap-linux.sh +++ b/scripts/bootstrap-linux.sh @@ -66,7 +66,6 @@ required_commands=( sha256sum ) missing_commands=() -llvm_version_suffixes=(22 20 19 18 17 16 15) for required_command in "${required_commands[@]}"; do if ! command -v "$required_command" >/dev/null 2>&1; then @@ -74,27 +73,13 @@ for required_command in "${required_commands[@]}"; do fi done -llvm_tool_available() { - local tool_name="$1" - local version - - if command -v "$tool_name" >/dev/null 2>&1; then - return 0 - fi - for version in "${llvm_version_suffixes[@]}"; do - if command -v "$tool_name-$version" >/dev/null 2>&1; then - return 0 - fi - done - return 1 -} - for llvm_tool in clang-cl lld-link llvm-mt llvm-rc llvm-ranlib; do - if ! llvm_tool_available "$llvm_tool"; then + if ! palschema_llvm_tool_available "$llvm_tool"; then missing_commands+=("$llvm_tool") fi done -if ! llvm_tool_available llvm-lib && ! llvm_tool_available llvm-ar; then +if ! palschema_llvm_tool_available llvm-lib && + ! palschema_llvm_tool_available llvm-ar; then missing_commands+=("llvm-lib/llvm-ar") fi @@ -184,23 +169,75 @@ if [[ ! -d "$rust_target_libdir" ]]; then fi fi -if ! command -v xwin >/dev/null 2>&1; then +if [[ -n "${XWIN_DIR:-}" ]]; then + palschema_xwin_dir="$XWIN_DIR" +else + palschema_xwin_dir="$PALSCHEMA_CACHE_ROOT/xwin" +fi + +xwin_cache_payload_is_valid() { + local cache_dir="$1" + [[ -f "$cache_dir/crt/include/vcruntime.h" && + -f "$cache_dir/crt/lib/x86_64/msvcrt.lib" && + -f "$cache_dir/sdk/include/um/Windows.h" && + -f "$cache_dir/sdk/lib/ucrt/x86_64/ucrt.lib" && + -f "$cache_dir/sdk/lib/um/x86_64/kernel32.Lib" ]] +} + +xwin_cache_is_ready() { + local cache_dir="$1" + xwin_cache_payload_is_valid "$cache_dir" && + [[ -f "$cache_dir/.palschema-sdk-complete" ]] +} + +xwin_parent="$(dirname -- "$palschema_xwin_dir")" +xwin_name="$(basename -- "$palschema_xwin_dir")" +xwin_previous="$xwin_parent/.${xwin_name}.previous" +mkdir -p "$xwin_parent" +exec {xwin_lock_fd}> "$xwin_parent/.${xwin_name}.lock" +flock "$xwin_lock_fd" + +# Adopt a legacy cache only after checking representative CRT, UCRT, SDK header, +# and Win32 import-library files. New splats always publish a completion marker. +if xwin_cache_payload_is_valid "$palschema_xwin_dir" && + [[ ! -f "$palschema_xwin_dir/.palschema-sdk-complete" ]]; then + printf '%s\n' "validated-existing-xwin-cache" \ + > "$palschema_xwin_dir/.palschema-sdk-complete" +fi + +# Recover the last complete cache if a previous refresh was interrupted between +# the two directory renames. +if ! xwin_cache_is_ready "$palschema_xwin_dir" && + xwin_cache_is_ready "$xwin_previous"; then + if [[ -e "$palschema_xwin_dir" ]]; then + xwin_incomplete="$xwin_parent/.${xwin_name}.incomplete.$$" + mv -- "$palschema_xwin_dir" "$xwin_incomplete" + else + xwin_incomplete="" + fi + mv -- "$xwin_previous" "$palschema_xwin_dir" + if [[ -n "$xwin_incomplete" ]]; then + rm -rf -- "$xwin_incomplete" + fi +fi + +# xwin is needed to create the SDK cache, but not to consume a complete cache +# during an offline or clean-container build. +if ! command -v xwin >/dev/null 2>&1 && + { [[ "$install_xwin" == true ]] || + { [[ "$prepare_sdk" == true ]] && + ! xwin_cache_is_ready "$palschema_xwin_dir"; }; }; then if [[ "$install_xwin" == true ]]; then cargo install --locked xwin else printf '%s\n' \ - "xwin is missing. Rerun with --install-xwin or install it with:" \ + "xwin is required to prepare the Microsoft CRT/SDK cache." \ + "Rerun with --install-xwin or install it with:" \ " cargo install --locked xwin" >&2 exit 1 fi fi -if [[ -n "${XWIN_DIR:-}" ]]; then - palschema_xwin_dir="$XWIN_DIR" -else - palschema_xwin_dir="$PALSCHEMA_CACHE_ROOT/xwin" -fi - if [[ "$accept_microsoft_license" == true && "$prepare_sdk" != true ]]; then printf '%s\n' \ "--accept-microsoft-license is only meaningful together with --prepare-sdk." >&2 @@ -216,11 +253,41 @@ if [[ "$prepare_sdk" == true ]]; then exit 2 fi - mkdir -p "$palschema_xwin_dir" - xwin --accept-license splat --output "$palschema_xwin_dir" + if xwin_cache_is_ready "$palschema_xwin_dir"; then + printf 'Reusing complete xwin SDK cache at %s.\n' "$palschema_xwin_dir" + else + xwin_stage="$(mktemp -d "$xwin_parent/.${xwin_name}.stage.XXXXXX")" + if ! xwin --accept-license splat --output "$xwin_stage"; then + rm -rf -- "$xwin_stage" + exit 1 + fi + if ! xwin_cache_payload_is_valid "$xwin_stage"; then + rm -rf -- "$xwin_stage" + printf '%s\n' "xwin produced an incomplete CRT/SDK cache." >&2 + exit 1 + fi + printf '%s\n' "xwin-splat-complete" \ + > "$xwin_stage/.palschema-sdk-complete" + + if [[ -e "$xwin_previous" ]]; then + rm -rf -- "$xwin_previous" + fi + if [[ -e "$palschema_xwin_dir" ]]; then + mv -- "$palschema_xwin_dir" "$xwin_previous" + fi + if ! mv -- "$xwin_stage" "$palschema_xwin_dir"; then + if [[ ! -e "$palschema_xwin_dir" && -e "$xwin_previous" ]]; then + mv -- "$xwin_previous" "$palschema_xwin_dir" + fi + exit 1 + fi + if [[ -e "$xwin_previous" ]]; then + rm -rf -- "$xwin_previous" + fi + fi fi -if [[ ! -d "$palschema_xwin_dir/crt" || ! -d "$palschema_xwin_dir/sdk" ]]; then +if ! xwin_cache_is_ready "$palschema_xwin_dir"; then printf 'The xwin SDK is not prepared at %s.\n' "$palschema_xwin_dir" >&2 printf '%s\n' \ "After reviewing the Microsoft terms, prepare it explicitly with:" \ diff --git a/scripts/ci/run-public-source-checks.sh b/scripts/ci/run-public-source-checks.sh new file mode 100755 index 0000000..7850306 --- /dev/null +++ b/scripts/ci/run-public-source-checks.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash + +set -euo pipefail + +project_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$project_root" + +required_commands=(bash git node npm python3) +missing_commands=() + +for required_command in "${required_commands[@]}"; do + if ! command -v "$required_command" >/dev/null 2>&1; then + missing_commands+=("$required_command") + fi +done + +if ((${#missing_commands[@]} > 0)); then + printf 'Missing public-check commands: %s\n' "${missing_commands[*]}" >&2 + exit 1 +fi + +node_major="$(node --version | sed -E 's/^v([0-9]+).*/\1/')" +if [[ ! "$node_major" =~ ^[0-9]+$ ]] || ((node_major < 20)); then + printf 'Node.js 20 or newer is required; found %s.\n' "$(node --version)" >&2 + exit 1 +fi + +printf '%s\n' \ + "PalSchema public source checks" \ + "OS: $(. /etc/os-release 2>/dev/null && printf '%s' "${PRETTY_NAME:-unknown}")" \ + "Kernel: $(uname -srmo)" \ + "Node: $(node --version)" \ + "npm: $(npm --version)" \ + "Python: $(python3 --version 2>&1)" + +bash -n \ + scripts/bootstrap-linux.sh \ + scripts/build-linux.sh \ + scripts/cargo-preserve-lock.sh \ + scripts/deploy-proton.sh \ + scripts/package-linux.sh \ + scripts/test-distro-matrix.sh \ + scripts/test-win64-server.sh \ + scripts/tests/test-deploy-transaction.sh \ + scripts/tests/test-bootstrap-cache.sh \ + scripts/tests/test-distro-matrix-output.sh \ + scripts/tests/test-process-scan.sh \ + scripts/ci/run-public-source-checks.sh \ + scripts/lib/build-env.sh \ + scripts/lib/process-scan.sh + +python3 -m py_compile scripts/verify-win64-artifact.py +python3 -m unittest discover -s scripts/tests -p 'test_*.py' +node --check scripts/copy-public-schemas.mjs +scripts/tests/test-deploy-transaction.sh +scripts/tests/test-bootstrap-cache.sh +scripts/tests/test-distro-matrix-output.sh +scripts/tests/test-process-scan.sh +python3 -m json.tool CMakePresets.json >/dev/null +python3 -m json.tool assets/.vscode/settings.json >/dev/null +python3 -m json.tool assets/schemas/schema-index.json >/dev/null + +npm ci +npm audit --audit-level=moderate +npm run tools:typecheck +npm run typecheck --workspace palschema-vscode +npm run editor:test +npm run tools:test +npm run tools:build +node tools/palschema-tools/dist/cli.js schemas verify +node tools/palschema-tools/dist/cli.js validate \ + --allow-missing-generated \ + assets/examples +npm run editor:build +npm run editor:package +npm pack \ + --workspace @palschema/tools \ + --pack-destination dist + +printf '%s\n' "PalSchema public source checks passed." diff --git a/scripts/copy-public-schemas.mjs b/scripts/copy-public-schemas.mjs new file mode 100755 index 0000000..d4ae985 --- /dev/null +++ b/scripts/copy-public-schemas.mjs @@ -0,0 +1,187 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import { + copyFile, + lstat, + mkdir, + readFile, + readdir, + realpath, + rm, +} from "node:fs/promises"; +import { + basename, + dirname, + isAbsolute, + relative, + resolve, + sep, +} from "node:path"; +import { fileURLToPath } from "node:url"; + +function assertSingleFileName(value, label) { + if ( + typeof value !== "string" || + !value || + value === "." || + value === ".." || + isAbsolute(value) || + basename(value) !== value || + value.includes("/") || + value.includes("\\") + ) { + throw new Error(`Invalid ${label}: ${String(value)}`); + } +} + +function containedPath(root, relativeFile) { + const candidate = resolve(root, relativeFile); + const fromRoot = relative(root, candidate); + if ( + !fromRoot || + fromRoot === ".." || + fromRoot.startsWith(`..${sep}`) || + isAbsolute(fromRoot) + ) { + throw new Error(`Schema path escapes its pack: ${relativeFile}`); + } + return candidate; +} + +function pathsOverlap(left, right) { + const fromLeft = relative(left, right); + const fromRight = relative(right, left); + return ( + fromLeft === "" || + (!fromLeft.startsWith(`..${sep}`) && fromLeft !== ".." && !isAbsolute(fromLeft)) || + (!fromRight.startsWith(`..${sep}`) && fromRight !== ".." && !isAbsolute(fromRight)) + ); +} + +async function canonicalPotentialPath(path) { + const missingSegments = []; + let candidate = path; + for (;;) { + try { + return resolve( + await realpath(candidate), + ...missingSegments.reverse(), + ); + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + const parent = dirname(candidate); + if (parent === candidate) { + throw error; + } + missingSegments.push(basename(candidate)); + candidate = parent; + } + } +} + +async function rejectGeneratedContent(source, entry) { + const generatedPath = containedPath(source, entry.file); + if (existsSync(generatedPath)) { + throw new Error( + `Refusing to package runtime-generated schema: ${entry.file}`, + ); + } + for (const pattern of entry.supportPatterns ?? []) { + const match = /^([^/]+)\/\*\.schema\.json$/.exec(pattern); + if (!match) { + throw new Error(`Unsupported schema support pattern: ${pattern}`); + } + const directory = containedPath(source, match[1]); + if (!existsSync(directory)) { + continue; + } + const generatedFiles = (await readdir(directory)).filter((file) => + file.endsWith(".schema.json"), + ); + if (generatedFiles.length > 0) { + throw new Error( + `Refusing to package runtime-generated schema support files below ${match[1]}/`, + ); + } + } +} + +export async function copyPublicSchemas(sourceDirectory, destinationDirectory) { + const source = resolve(sourceDirectory); + const destination = resolve(destinationDirectory); + if ( + pathsOverlap(source, destination) || + pathsOverlap( + await realpath(source), + await canonicalPotentialPath(destination), + ) + ) { + throw new Error("Schema source and destination must not overlap."); + } + + const indexPath = resolve(source, "schema-index.json"); + const index = JSON.parse(await readFile(indexPath, "utf8")); + if ( + index?.formatVersion !== 1 || + !Array.isArray(index.schemas) + ) { + throw new Error("Unsupported or malformed schema-index.json."); + } + + const staticEntries = []; + for (const entry of index.schemas) { + assertSingleFileName(entry.file, "schema file"); + if (entry.generated) { + await rejectGeneratedContent(source, entry); + continue; + } + if ( + typeof entry.sha256 !== "string" || + !/^[0-9a-f]{64}$/.test(entry.sha256) + ) { + throw new Error(`Static schema has no valid checksum: ${entry.file}`); + } + const schemaPath = containedPath(source, entry.file); + const metadata = await lstat(schemaPath); + if (!metadata.isFile() || metadata.isSymbolicLink()) { + throw new Error(`Static schema must be a regular file: ${entry.file}`); + } + const content = await readFile(schemaPath); + const digest = createHash("sha256").update(content).digest("hex"); + if (digest !== entry.sha256) { + throw new Error(`Static schema checksum mismatch: ${entry.file}`); + } + staticEntries.push({ entry, schemaPath }); + } + + await rm(destination, { recursive: true, force: true }); + await mkdir(destination, { recursive: true }); + await copyFile(indexPath, resolve(destination, "schema-index.json")); + for (const { entry, schemaPath } of staticEntries) { + const output = containedPath(destination, entry.file); + await mkdir(dirname(output), { recursive: true }); + await copyFile(schemaPath, output); + } +} + +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : ""; +if (invokedPath === fileURLToPath(import.meta.url)) { + const [source, destination, ...extra] = process.argv.slice(2); + if (!source || !destination || extra.length > 0) { + console.error( + "Usage: scripts/copy-public-schemas.mjs SOURCE_DIR DESTINATION_DIR", + ); + process.exitCode = 2; + } else { + try { + await copyPublicSchemas(source, destination); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } + } +} diff --git a/scripts/deploy-proton.sh b/scripts/deploy-proton.sh index 8c0418e..1950d29 100755 --- a/scripts/deploy-proton.sh +++ b/scripts/deploy-proton.sh @@ -11,7 +11,8 @@ show_usage() { " [--game-dir PATH] [--dry-run]" \ "" \ "Deploys only PalSchema into an existing, separately installed UE4SS." \ - "Refuses to run while the selected Windows Palworld target is active." + "Refuses to run unless the selected Windows Palworld target can be" \ + "confirmed stopped through a complete /proc command-line scan." } build_flavor="shipping" @@ -91,8 +92,32 @@ case "$target_kind" in ;; esac +required_commands=( + awk + cp + flock + install + mktemp + mv + python3 + realpath + rm + sha256sum +) +if [[ "$build_flavor" == "dev" ]]; then + required_commands+=(node) +fi +for required_command in "${required_commands[@]}"; do + if ! command -v "$required_command" >/dev/null 2>&1; then + printf 'Missing required deployment command: %s\n' "$required_command" >&2 + exit 1 + fi +done + script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" project_root="$(cd -- "$script_dir/.." && pwd)" +source "$script_dir/lib/build-env.sh" +source "$script_dir/lib/process-scan.sh" if [[ -z "$game_dir" ]]; then default_game_dir="$HOME/.local/share/Steam/steamapps/common/Palworld" @@ -112,6 +137,9 @@ ue4ss_root="$win64_dir/ue4ss" mods_dir="$ue4ss_root/Mods" target_dir="$mods_dir/PalSchema" backup_root="$ue4ss_root/.palschema-backups" +transaction_marker="$backup_root/.active-transaction" +stage_dir="" +transaction_active=false if [[ "$target_kind" == "auto" ]]; then if [[ -f "$win64_dir/Palworld-Win64-Shipping.exe" ]]; then @@ -125,41 +153,6 @@ if [[ "$target_kind" == "auto" ]]; then fi fi -palworld_target_is_running() { - local cmdline - local cmdline_fd - local argument - - for cmdline in /proc/[0-9]*/cmdline; do - if ! { exec {cmdline_fd}<"$cmdline"; } 2>/dev/null; then - continue - fi - - while IFS= read -r -d '' argument <&"$cmdline_fd"; do - if [[ "$target_kind" == "client" ]]; then - case "$argument" in - Palworld-Win64-Shipping.exe|*/Palworld-Win64-Shipping.exe|*\\Palworld-Win64-Shipping.exe) - exec {cmdline_fd}<&- - return 0 - ;; - esac - else - case "$argument" in - PalServer.exe|*/PalServer.exe|*\\PalServer.exe|\ - PalServer-Win64-Shipping.exe|*/PalServer-Win64-Shipping.exe|*\\PalServer-Win64-Shipping.exe|\ - PalServer-Win64-Shipping-Cmd.exe|*/PalServer-Win64-Shipping-Cmd.exe|*\\PalServer-Win64-Shipping-Cmd.exe) - exec {cmdline_fd}<&- - return 0 - ;; - esac - fi - done - exec {cmdline_fd}<&- - done - - return 1 -} - if [[ "$target_kind" == "client" && ! -f "$win64_dir/Palworld-Win64-Shipping.exe" ]]; then printf 'Not a Palworld Win64 client installation: %s\n' "$game_dir" >&2 @@ -174,12 +167,169 @@ if [[ ! -f "$ue4ss_root/UE4SS.dll" || ! -d "$mods_dir" ]]; then printf 'A separate UE4SS installation was not found under: %s\n' "$win64_dir" >&2 exit 1 fi -if palworld_target_is_running; then - printf 'Refusing to deploy while the Palworld Windows %s is active.\n' \ - "$target_kind" >&2 +if [[ -L "$target_dir" || -L "$backup_root" ]]; then + printf '%s\n' "Refusing to deploy through a symlinked PalSchema target or backup root." >&2 exit 1 fi +ue4ss_root_real="$(realpath -e -- "$ue4ss_root")" +mods_dir_real="$(realpath -e -- "$mods_dir")" +case "$mods_dir_real/" in + "$ue4ss_root_real"/*/) + ;; + *) + printf 'UE4SS Mods directory escapes its installation: %s\n' "$mods_dir_real" >&2 + exit 1 + ;; +esac +mods_dir="$mods_dir_real" +target_dir="$mods_dir/PalSchema" +backup_root="$ue4ss_root_real/.palschema-backups" +transaction_marker="$backup_root/.active-transaction" + +assert_target_stopped() { + if palschema_target_process_status "$target_kind" /proc; then + printf 'Refusing to deploy while the Palworld Windows %s is active.\n' \ + "$target_kind" >&2 + exit 1 + else + process_status=$? + if ((process_status == 2)); then + printf '%s\n' \ + "Refusing to deploy because one or more stable /proc process command lines are unreadable." \ + "Run as an account with complete process visibility after stopping the selected Win64 target." >&2 + exit 1 + fi + fi +} + +assert_target_stopped + +validate_backup_id() { + [[ "$1" =~ ^(rollback-)?[0-9]{8}T[0-9]{6}Z-[0-9]+$ ]] +} + +validate_stage_name() { + [[ -z "$1" || "$1" =~ ^\.PalSchema\.stage\.[A-Za-z0-9]+$ ]] +} + +recover_incomplete_transaction() { + local marker_lines=() + local backup_id + local stage_name + local recovery_backup + local stale_stage + + if [[ ! -f "$transaction_marker" ]]; then + transaction_active=false + return 0 + fi + mapfile -t marker_lines < "$transaction_marker" + backup_id="${marker_lines[0]:-}" + stage_name="${marker_lines[1]:-}" + if ! validate_backup_id "$backup_id" || ! validate_stage_name "$stage_name"; then + printf 'Invalid deployment transaction marker: %s\n' "$transaction_marker" >&2 + return 1 + fi + recovery_backup="$backup_root/$backup_id" + if [[ -L "$recovery_backup" || -L "$recovery_backup/PalSchema" ]]; then + printf 'Deployment transaction backup contains a symlink: %s\n' \ + "$recovery_backup" >&2 + return 1 + fi + if [[ ! -d "$recovery_backup" ]]; then + printf 'Deployment transaction backup is missing: %s\n' "$recovery_backup" >&2 + return 1 + fi + recovery_backup="$(realpath -e -- "$recovery_backup")" + case "$recovery_backup/" in + "$backup_root"/*/) + ;; + *) + printf 'Deployment transaction backup escapes its root: %s\n' \ + "$recovery_backup" >&2 + return 1 + ;; + esac + + if [[ ! -e "$target_dir" && -d "$recovery_backup/PalSchema" ]]; then + mv -- "$recovery_backup/PalSchema" "$target_dir" + printf 'Recovered interrupted PalSchema transaction from %s\n' \ + "$recovery_backup" + elif [[ ! -e "$target_dir" && + ! -f "$recovery_backup/no-previous-install" ]]; then + printf '%s\n' "Interrupted transaction has neither a live mod nor a recoverable backup." >&2 + return 1 + fi + + if [[ -n "$stage_name" ]]; then + stale_stage="$mods_dir/$stage_name" + if [[ -d "$stale_stage" && ! -L "$stale_stage" ]]; then + rm -rf -- "$stale_stage" + fi + fi + rm -f -- "$transaction_marker" + transaction_active=false +} + +begin_transaction() { + local backup_id="$1" + local stage_name="$2" + local temporary_marker="$backup_root/.active-transaction.tmp.$$" + + validate_backup_id "$backup_id" + validate_stage_name "$stage_name" + { + printf '%s\n' "$backup_id" + printf '%s\n' "$stage_name" + } > "$temporary_marker" + mv -- "$temporary_marker" "$transaction_marker" + transaction_active=true +} + +commit_transaction() { + rm -f -- "$transaction_marker" + transaction_active=false +} + +cleanup() { + local status=$? + if [[ "$transaction_active" == true ]]; then + recover_incomplete_transaction || true + fi + if [[ -n "$stage_dir" && -d "$stage_dir" && ! -L "$stage_dir" ]]; then + rm -rf -- "$stage_dir" + fi + return "$status" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +if [[ "$dry_run" != true ]]; then + mkdir -p "$backup_root" + if [[ -L "$backup_root" ]]; then + printf 'Refusing to use a symlinked backup root: %s\n' "$backup_root" >&2 + exit 1 + fi + backup_root="$(realpath -e -- "$backup_root")" + case "$backup_root/" in + "$ue4ss_root_real"/*/) + ;; + *) + printf 'PalSchema backup root escapes UE4SS: %s\n' "$backup_root" >&2 + exit 1 + ;; + esac + transaction_marker="$backup_root/.active-transaction" + exec {deployment_lock_fd}> "$backup_root/.deploy.lock" + if ! flock -n "$deployment_lock_fd"; then + printf '%s\n' "Another PalSchema deploy or rollback is active." >&2 + exit 1 + fi + recover_incomplete_transaction +fi + if [[ -n "$rollback_dir" ]]; then if [[ ! -d "$backup_root" ]]; then printf 'No PalSchema backup root exists at: %s\n' "$backup_root" >&2 @@ -196,7 +346,10 @@ if [[ -n "$rollback_dir" ]]; then exit 1 ;; esac - + if [[ -L "$rollback_dir_real/PalSchema" ]]; then + printf '%s\n' "Refusing to restore a symlinked PalSchema backup." >&2 + exit 1 + fi if [[ ! -d "$rollback_dir_real/PalSchema" && ! -f "$rollback_dir_real/no-previous-install" ]]; then printf 'Not a valid PalSchema deployment backup: %s\n' "$rollback_dir_real" >&2 @@ -208,18 +361,40 @@ if [[ -n "$rollback_dir" ]]; then exit 0 fi + if [[ -d "$rollback_dir_real/PalSchema" ]]; then + if [[ ! -f "$rollback_dir_real/PalSchema/dlls/main.dll" || + -L "$rollback_dir_real/PalSchema/dlls/main.dll" ]]; then + printf 'Rollback backup has no regular dlls/main.dll: %s\n' \ + "$rollback_dir_real" >&2 + exit 1 + fi + stage_dir="$(mktemp -d "$mods_dir/.PalSchema.stage.XXXXXX")" + cp -a -- "$rollback_dir_real/PalSchema/." "$stage_dir/" + if [[ ! -f "$stage_dir/dlls/main.dll" ]]; then + printf '%s\n' "Rollback staging did not produce dlls/main.dll." >&2 + exit 1 + fi + fi + rollback_id="rollback-$(date -u +%Y%m%dT%H%M%SZ)-$$" rollback_safety="$backup_root/$rollback_id" + assert_target_stopped mkdir -p "$rollback_safety" if [[ -d "$target_dir" ]]; then - mv -- "$target_dir" "$rollback_safety/PalSchema" + : else touch "$rollback_safety/no-previous-install" fi - - if [[ -d "$rollback_dir_real/PalSchema" ]]; then - cp -a -- "$rollback_dir_real/PalSchema" "$target_dir" + begin_transaction "$rollback_id" \ + "$([[ -n "$stage_dir" ]] && basename "$stage_dir" || true)" + if [[ -d "$target_dir" ]]; then + mv -- "$target_dir" "$rollback_safety/PalSchema" + fi + if [[ -n "$stage_dir" ]]; then + mv -- "$stage_dir" "$target_dir" + stage_dir="" fi + commit_transaction printf 'Rolled back PalSchema from %s\n' "$rollback_dir_real" printf 'Previous deployed state preserved at %s\n' "$rollback_safety" @@ -232,6 +407,16 @@ if [[ ! -f "$artifact" ]]; then printf 'Build it first with: scripts/build-linux.sh %s\n' "$build_flavor" >&2 exit 1 fi +llvm_readobj="$(palschema_find_llvm_tool llvm-readobj)" +python3 "$script_dir/verify-win64-artifact.py" "$artifact" \ + --llvm-readobj "$llvm_readobj" >/dev/null + +if [[ "$build_flavor" == "dev" && + ! -f "$project_root/build/$preset/PalSchema.pdb" ]]; then + printf 'Missing Dev debug symbols: %s\n' \ + "$project_root/build/$preset/PalSchema.pdb" >&2 + exit 1 +fi if [[ "$dry_run" == true ]]; then printf 'Would deploy %s to %s/dlls/main.dll\n' "$artifact" "$target_dir" @@ -243,44 +428,41 @@ if [[ "$dry_run" == true ]]; then exit 0 fi -mkdir -p "$backup_root" deploy_id="$(date -u +%Y%m%dT%H%M%SZ)-$$" backup_dir="$backup_root/$deploy_id" -mkdir -p "$backup_dir" stage_dir="$(mktemp -d "$mods_dir/.PalSchema.stage.XXXXXX")" -cleanup_stage() { - if [[ -n "${stage_dir:-}" && -d "$stage_dir" ]]; then - rm -rf -- "$stage_dir" - fi -} -trap cleanup_stage EXIT - install -d "$stage_dir/dlls" "$stage_dir/mods" install -m 0644 "$artifact" "$stage_dir/dlls/main.dll" touch "$stage_dir/enabled.txt" -if [[ "$build_flavor" == dev ]]; then +if [[ "$build_flavor" == "dev" ]]; then install -m 0644 "$project_root/build/$preset/PalSchema.pdb" \ "$stage_dir/dlls/main.pdb" cp -a "$project_root/assets/.vscode" "$stage_dir/.vscode" cp -a "$project_root/assets/examples" "$stage_dir/examples" - cp -a "$project_root/assets/schemas" "$stage_dir/schemas" + node "$script_dir/copy-public-schemas.mjs" \ + "$project_root/assets/schemas" "$stage_dir/schemas" fi +if [[ ! -f "$stage_dir/dlls/main.dll" ]]; then + printf '%s\n' "Deployment staging did not produce dlls/main.dll." >&2 + exit 1 +fi +assert_target_stopped +mkdir -p "$backup_dir" +if [[ ! -d "$target_dir" ]]; then + touch "$backup_dir/no-previous-install" +fi +begin_transaction "$deploy_id" "$(basename "$stage_dir")" if [[ -d "$target_dir" ]]; then mv -- "$target_dir" "$backup_dir/PalSchema" -else - touch "$backup_dir/no-previous-install" fi - -if ! mv -- "$stage_dir" "$target_dir"; then - if [[ -d "$backup_dir/PalSchema" && ! -e "$target_dir" ]]; then - mv -- "$backup_dir/PalSchema" "$target_dir" - fi - printf '%s\n' "Deployment failed; the previous PalSchema installation was restored." >&2 - exit 1 +if [[ "${PALSCHEMA_TEST_INTERRUPT_AFTER_BACKUP:-0}" == "1" ]]; then + kill -TERM "$$" fi +mv -- "$stage_dir" "$target_dir" stage_dir="" +commit_transaction { printf 'flavor=%s\n' "$build_flavor" diff --git a/scripts/lib/build-env.sh b/scripts/lib/build-env.sh index df7f70b..ee5b886 100644 --- a/scripts/lib/build-env.sh +++ b/scripts/lib/build-env.sh @@ -3,6 +3,8 @@ # Shared environment setup for PalSchema's Linux-hosted cross-build scripts. # This file is sourced; it intentionally does not enable shell options. +PALSCHEMA_LLVM_VERSION_SUFFIXES=(22 21 20 19 18 17 16 15) + palschema_configure_build_environment() { if [[ -n "${XDG_CACHE_HOME:-}" ]]; then palschema_cache_root="$XDG_CACHE_HOME/palschema" @@ -21,3 +23,29 @@ palschema_configure_build_environment() { export PATH="$CARGO_HOME/bin:$PATH" fi } + +palschema_find_llvm_tool() { + local tool_name="$1" + local candidate + local suffix + + if command -v "$tool_name" >/dev/null 2>&1; then + command -v "$tool_name" + return 0 + fi + for suffix in "${PALSCHEMA_LLVM_VERSION_SUFFIXES[@]}"; do + candidate="$tool_name-$suffix" + if command -v "$candidate" >/dev/null 2>&1; then + command -v "$candidate" + return 0 + fi + done + + printf 'Unable to find %s (including supported version-suffixed names).\n' \ + "$tool_name" >&2 + return 1 +} + +palschema_llvm_tool_available() { + palschema_find_llvm_tool "$1" >/dev/null 2>&1 +} diff --git a/scripts/lib/process-scan.sh b/scripts/lib/process-scan.sh new file mode 100644 index 0000000..1244002 --- /dev/null +++ b/scripts/lib/process-scan.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash + +# Return 0 when a matching Win64 Palworld process exists, 1 after a complete +# negative scan, and 2 when any stable process command line cannot be read. +palschema_target_process_status() { + local target_kind="$1" + local proc_root="$2" + local cmdline + local cmdline_fd + local argument + local incomplete=false + + for cmdline in "$proc_root"/[0-9]*/cmdline; do + if ! { exec {cmdline_fd}<"$cmdline"; } 2>/dev/null; then + # A process that vanished between glob expansion and open is benign. + if [[ -e "$cmdline" ]]; then + incomplete=true + fi + continue + fi + + while IFS= read -r -d '' argument <&"$cmdline_fd"; do + if [[ "$target_kind" == "client" ]]; then + case "$argument" in + Palworld-Win64-Shipping.exe|*/Palworld-Win64-Shipping.exe|*\\Palworld-Win64-Shipping.exe) + exec {cmdline_fd}<&- + return 0 + ;; + esac + else + case "$argument" in + PalServer.exe|*/PalServer.exe|*\\PalServer.exe|\ + PalServer-Win64-Shipping.exe|*/PalServer-Win64-Shipping.exe|*\\PalServer-Win64-Shipping.exe|\ + PalServer-Win64-Shipping-Cmd.exe|*/PalServer-Win64-Shipping-Cmd.exe|*\\PalServer-Win64-Shipping-Cmd.exe) + exec {cmdline_fd}<&- + return 0 + ;; + esac + fi + done + exec {cmdline_fd}<&- + done + + if [[ "$incomplete" == true ]]; then + return 2 + fi + return 1 +} diff --git a/scripts/package-linux.sh b/scripts/package-linux.sh index 5af8200..fe91d69 100755 --- a/scripts/package-linux.sh +++ b/scripts/package-linux.sh @@ -60,6 +60,7 @@ esac script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" project_root="$(cd -- "$script_dir/.." && pwd)" +source "$script_dir/lib/build-env.sh" artifact="$project_root/build/$preset/PalSchema.dll" pdb="$project_root/build/$preset/PalSchema.pdb" @@ -69,13 +70,21 @@ if [[ ! -f "$artifact" ]]; then exit 1 fi -for required_command in install mktemp zip; do +required_commands=(install mktemp python3 zip) +if [[ "$build_flavor" == "dev" ]]; then + required_commands+=(node) +fi +for required_command in "${required_commands[@]}"; do if ! command -v "$required_command" >/dev/null 2>&1; then printf 'Missing required packaging command: %s\n' "$required_command" >&2 exit 1 fi done +llvm_readobj="$(palschema_find_llvm_tool llvm-readobj)" +python3 "$script_dir/verify-win64-artifact.py" "$artifact" \ + --llvm-readobj "$llvm_readobj" >/dev/null + version="$( awk ' /VERSION_MAJOR[[:space:]]+[0-9]+/ { major = $3 } @@ -108,9 +117,13 @@ if [[ "$build_flavor" == dev ]]; then install -m 0644 "$pdb" "$mod_root/dlls/main.pdb" cp -a "$project_root/assets/.vscode" "$mod_root/.vscode" cp -a "$project_root/assets/examples" "$mod_root/examples" - cp -a "$project_root/assets/schemas" "$mod_root/schemas" + node "$script_dir/copy-public-schemas.mjs" \ + "$project_root/assets/schemas" "$mod_root/schemas" fi +python3 "$script_dir/verify-win64-artifact.py" \ + "$mod_root/dlls/main.dll" --llvm-readobj "$llvm_readobj" >/dev/null + archive_name="PalSchema_${version}_Win64${archive_suffix}.zip" temporary_archive="$package_dir/$archive_name" ( diff --git a/scripts/test-distro-matrix.sh b/scripts/test-distro-matrix.sh new file mode 100755 index 0000000..6f3441b --- /dev/null +++ b/scripts/test-distro-matrix.sh @@ -0,0 +1,326 @@ +#!/usr/bin/env bash + +set -euo pipefail + +show_usage() { + printf '%s\n' \ + "Usage: scripts/test-distro-matrix.sh [options] [distro ...]" \ + "" \ + "Runs PalSchema's public source checks in clean Linux containers." \ + "" \ + "Distros:" \ + " debian ubuntu fedora opensuse arch" \ + "" \ + "Options:" \ + " --list Print the supported distro/image mapping." \ + " --no-pull Use an already available local image." \ + " --build-shipping Also cross-build and verify the Win64 Shipping DLL." \ + " --node-major N Test with the latest verified Node.js N.x (default: 22)." \ + " --timeout-minutes N Stop one distro after N minutes (default: 10)." \ + " -h, --help Show this help." \ + "" \ + "With no distro arguments, all supported distros are tested." \ + "--build-shipping reuses an already prepared PalSchema cache and never" \ + "downloads or silently accepts the Microsoft SDK license." +} + +project_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +node_major=22 +timeout_minutes=10 +pull_images=true +build_shipping=false +selected_distros=() + +declare -A distro_images=( + [debian]="debian:stable-slim" + [ubuntu]="ubuntu:24.04" + [fedora]="fedora:latest" + [opensuse]="opensuse/tumbleweed:latest" + [arch]="archlinux:latest" +) + +all_distros=(debian ubuntu fedora opensuse arch) + +list_distros() { + local distro + for distro in "${all_distros[@]}"; do + printf '%-10s %s\n' "$distro" "${distro_images[$distro]}" + done +} + +while (($# > 0)); do + case "$1" in + --list) + list_distros + exit 0 + ;; + --no-pull) + pull_images=false + ;; + --build-shipping) + build_shipping=true + ;; + --node-major) + if (($# < 2)); then + printf '%s\n' "--node-major requires a value." >&2 + exit 2 + fi + node_major="$2" + shift + ;; + --timeout-minutes) + if (($# < 2)); then + printf '%s\n' "--timeout-minutes requires a value." >&2 + exit 2 + fi + timeout_minutes="$2" + shift + ;; + -h|--help) + show_usage + exit 0 + ;; + -*) + printf 'Unknown option: %s\n\n' "$1" >&2 + show_usage >&2 + exit 2 + ;; + *) + selected_distros+=("$1") + ;; + esac + shift +done + +if [[ ! "$node_major" =~ ^[0-9]+$ ]] || ((node_major < 20)); then + printf '%s\n' "--node-major must be an integer greater than or equal to 20." >&2 + exit 2 +fi +if [[ ! "$timeout_minutes" =~ ^[0-9]+$ ]] || ((timeout_minutes < 1)); then + printf '%s\n' "--timeout-minutes must be a positive integer." >&2 + exit 2 +fi +matrix_timeout_seconds="$((timeout_minutes * 60))" +if [[ -n "${PALSCHEMA_MATRIX_TIMEOUT_SECONDS:-}" ]]; then + if [[ ! "$PALSCHEMA_MATRIX_TIMEOUT_SECONDS" =~ ^[0-9]+$ ]] || + ((PALSCHEMA_MATRIX_TIMEOUT_SECONDS < 1)); then + printf '%s\n' "PALSCHEMA_MATRIX_TIMEOUT_SECONDS must be positive." >&2 + exit 2 + fi + matrix_timeout_seconds="$PALSCHEMA_MATRIX_TIMEOUT_SECONDS" +fi + +if ((${#selected_distros[@]} == 0)); then + selected_distros=("${all_distros[@]}") +fi + +for required_command in docker tail timeout; do + if ! command -v "$required_command" >/dev/null 2>&1; then + printf '%s is required to run the distro matrix.\n' \ + "$required_command" >&2 + exit 1 + fi +done + +if [[ -n "${PALSCHEMA_CACHE_ROOT:-}" ]]; then + palschema_cache_root="$PALSCHEMA_CACHE_ROOT" +elif [[ -n "${XDG_CACHE_HOME:-}" ]]; then + palschema_cache_root="$XDG_CACHE_HOME/palschema" +else + user_home_dir="${HOME:?HOME must be set when XDG_CACHE_HOME is unset}" + palschema_cache_root="$user_home_dir/.cache/palschema" +fi + +if [[ "$build_shipping" == true ]]; then + required_cache_paths=( + "$palschema_cache_root/cargo/bin/rustup" + "$palschema_cache_root/rustup" + "$palschema_cache_root/xwin/crt" + "$palschema_cache_root/xwin/sdk" + ) + for required_cache_path in "${required_cache_paths[@]}"; do + if [[ ! -e "$required_cache_path" ]]; then + printf 'Prepared build cache is missing: %s\n' "$required_cache_path" >&2 + printf '%s\n' \ + "Run scripts/bootstrap-linux.sh with the explicit install/SDK options first." >&2 + exit 1 + fi + done +fi + +for distro in "${selected_distros[@]}"; do + if [[ -z "${distro_images[$distro]:-}" ]]; then + printf 'Unsupported distro: %s\n\n' "$distro" >&2 + list_distros >&2 + exit 2 + fi +done + +matrix_tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/palschema-distro-matrix.XXXXXX")" +archive_path="$matrix_tmp_dir/source.tar" +active_container="" + +cleanup() { + if [[ -n "$active_container" ]]; then + docker rm --force "$active_container" >/dev/null 2>&1 || true + fi + rm -rf -- "$matrix_tmp_dir" +} +trap cleanup EXIT INT TERM + +tar \ + --exclude=.git \ + --exclude=build \ + --exclude=dist \ + --exclude=graphify-out \ + --exclude=node_modules \ + -cf "$archive_path" \ + -C "$project_root" \ + . + +container_script=' +set -euo pipefail + +case "$PALSCHEMA_DISTRO" in + debian|ubuntu) + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y --no-install-recommends \ + bash ca-certificates coreutils curl git gzip python3 tar xz-utils + if [[ "$PALSCHEMA_BUILD_SHIPPING" == 1 ]]; then + apt-get install -y --no-install-recommends \ + clang clang-tools cmake lld llvm ninja-build util-linux + fi + rm -rf /var/lib/apt/lists/* + ;; + fedora) + dnf install -y \ + bash ca-certificates coreutils curl findutils git gzip python3 tar xz + if [[ "$PALSCHEMA_BUILD_SHIPPING" == 1 ]]; then + dnf install -y \ + clang clang-tools-extra cmake lld llvm ninja-build util-linux + fi + dnf clean all + ;; + opensuse) + zypper --non-interactive refresh + zypper --non-interactive install -y \ + bash ca-certificates coreutils curl findutils git gzip python3 tar xz + if [[ "$PALSCHEMA_BUILD_SHIPPING" == 1 ]]; then + zypper --non-interactive install -y \ + clang cmake lld llvm ninja util-linux + fi + zypper clean --all + ;; + arch) + pacman -Syu --noconfirm --needed \ + bash ca-certificates coreutils curl findutils git gzip python tar xz + if [[ "$PALSCHEMA_BUILD_SHIPPING" == 1 ]]; then + pacman -S --noconfirm --needed \ + clang cmake lld llvm ninja util-linux + fi + ;; + *) + printf "Unsupported container distro: %s\n" "$PALSCHEMA_DISTRO" >&2 + exit 2 + ;; +esac + +node_checksums_url="https://nodejs.org/dist/latest-v${PALSCHEMA_NODE_MAJOR}.x/SHASUMS256.txt" +curl --fail --location --silent --show-error \ + --output /tmp/node-shasums.txt \ + "$node_checksums_url" +node_archive_line="$( + grep -E " node-v${PALSCHEMA_NODE_MAJOR}[^ ]*-linux-x64.tar.xz\$" \ + /tmp/node-shasums.txt | + head -n 1 +)" +node_archive="${node_archive_line##* }" +if [[ -z "$node_archive" ]]; then + printf "Could not resolve a Node.js %s.x Linux x64 archive.\n" "$PALSCHEMA_NODE_MAJOR" >&2 + exit 1 +fi +node_version="${node_archive#node-}" +node_version="${node_version%-linux-x64.tar.xz}" +curl --fail --location --silent --show-error \ + --output "/tmp/$node_archive" \ + "https://nodejs.org/dist/$node_version/$node_archive" +( + cd /tmp + grep " $node_archive\$" node-shasums.txt | sha256sum --check - +) +tar -xJf "/tmp/$node_archive" -C /usr/local --strip-components=1 + +mkdir -p /workspace +tar -xf /input/source.tar -C /workspace +cd /workspace +scripts/ci/run-public-source-checks.sh + +if [[ "$PALSCHEMA_BUILD_SHIPPING" == 1 ]]; then + mkdir -p /palschema-cache + cp -a /palschema-cache-source/. /palschema-cache/ + export PALSCHEMA_CACHE_ROOT=/palschema-cache + export XWIN_DIR=/palschema-cache/xwin + scripts/build-linux.sh shipping + python3 scripts/verify-win64-artifact.py \ + build/win64-xwin-shipping/PalSchema.dll \ + --json-output build/win64-xwin-shipping/pe-contract.json + sha256sum build/win64-xwin-shipping/PalSchema.dll +fi +' + +failures=() +for distro in "${selected_distros[@]}"; do + image="${distro_images[$distro]}" + if [[ "$pull_images" == true ]]; then + docker pull "$image" + fi + + active_container="palschema-matrix-${distro}-$$" + distro_log="$matrix_tmp_dir/$distro.log" + distro_started="$SECONDS" + printf 'Testing %-10s (%s, timeout %ss)... ' \ + "$distro" "$image" "$matrix_timeout_seconds" + docker_args=( + --name "$active_container" + --rm + --volume "$matrix_tmp_dir:/input:ro" + --env "PALSCHEMA_DISTRO=$distro" + --env "PALSCHEMA_NODE_MAJOR=$node_major" + --env "PALSCHEMA_BUILD_SHIPPING=$([[ "$build_shipping" == true ]] && printf 1 || printf 0)" + ) + if [[ "$build_shipping" == true ]]; then + docker_args+=( + --volume "$palschema_cache_root:/palschema-cache-source:ro" + ) + fi + set +e + timeout --signal=TERM --kill-after=2s \ + "${matrix_timeout_seconds}s" \ + docker run "${docker_args[@]}" "$image" bash -c "$container_script" \ + 2>&1 | + tail -c 8388608 > "$distro_log" + run_status="${PIPESTATUS[0]}" + set -e + distro_elapsed="$((SECONDS - distro_started))" + if ((run_status == 0)); then + printf 'PASS (%ss)\n' "$distro_elapsed" + else + printf 'FAIL (%ss, exit %s)\n' "$distro_elapsed" "$run_status" + if ((run_status == 124)); then + printf 'Timed out after %s seconds.\n' "$matrix_timeout_seconds" >&2 + fi + printf '%s\n' "--- last 120 log lines for $distro ---" >&2 + tail -n 120 "$distro_log" >&2 + docker rm --force "$active_container" >/dev/null 2>&1 || true + failures+=("$distro") + fi + active_container="" +done + +if ((${#failures[@]} > 0)); then + printf '\nDistro matrix failures: %s\n' "${failures[*]}" >&2 + exit 1 +fi + +printf '\nPalSchema distro matrix passed: %s\n' "${selected_distros[*]}" diff --git a/scripts/test-win64-server.sh b/scripts/test-win64-server.sh new file mode 100755 index 0000000..b2581da --- /dev/null +++ b/scripts/test-win64-server.sh @@ -0,0 +1,355 @@ +#!/usr/bin/env bash + +set -euo pipefail + +show_usage() { + printf '%s\n' \ + "Usage: scripts/test-win64-server.sh --game-dir PATH [options]" \ + "" \ + "Runs repeatable PalSchema startup smoke tests against an isolated" \ + "Win64 Palworld Dedicated Server under Wine." \ + "" \ + "Options:" \ + " --game-dir PATH Explicit isolated server root (required)." \ + " --cycles N Number of start/stop cycles (default: 3)." \ + " --game-port PORT Isolated UDP game port (default: 18211)." \ + " --query-port PORT Isolated UDP query port (default: 37015)." \ + " --startup-timeout S Seconds allowed for each startup (default: 120)." \ + " --hot-reload-file P Touch this safe fixture after each startup." \ + " --reload-timeout S Seconds allowed for hot-reload (default: 30)." \ + " --evidence-dir PATH Output directory for sanitized logs." \ + " -h, --help Show this help." +} + +project_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +game_dir="" +cycles=3 +game_port=18211 +query_port=37015 +startup_timeout=120 +reload_timeout=30 +hot_reload_file="" +evidence_dir="" + +while (($# > 0)); do + case "$1" in + --game-dir|--cycles|--game-port|--query-port|--startup-timeout|--hot-reload-file|--reload-timeout|--evidence-dir) + if (($# < 2)); then + printf '%s requires a value.\n' "$1" >&2 + exit 2 + fi + case "$1" in + --game-dir) game_dir="$2" ;; + --cycles) cycles="$2" ;; + --game-port) game_port="$2" ;; + --query-port) query_port="$2" ;; + --startup-timeout) startup_timeout="$2" ;; + --hot-reload-file) hot_reload_file="$2" ;; + --reload-timeout) reload_timeout="$2" ;; + --evidence-dir) evidence_dir="$2" ;; + esac + shift + ;; + -h|--help) + show_usage + exit 0 + ;; + *) + printf 'Unknown option: %s\n\n' "$1" >&2 + show_usage >&2 + exit 2 + ;; + esac + shift +done + +if [[ -z "$game_dir" ]]; then + printf '%s\n' "--game-dir is required; automatic server discovery is intentionally disabled." >&2 + exit 2 +fi + +for numeric_value in "$cycles" "$game_port" "$query_port" "$startup_timeout" "$reload_timeout"; do + if [[ ! "$numeric_value" =~ ^[0-9]+$ ]] || ((numeric_value < 1)); then + printf 'Expected a positive integer, found: %s\n' "$numeric_value" >&2 + exit 2 + fi +done + +if ((game_port > 65535 || query_port > 65535)); then + printf '%s\n' "Ports must be between 1 and 65535." >&2 + exit 2 +fi +if ((game_port == query_port)); then + printf '%s\n' "Game and query ports must be different." >&2 + exit 2 +fi + +game_dir="$(cd -- "$game_dir" && pwd)" +win64_dir="$game_dir/Pal/Binaries/Win64" +server_exe="$win64_dir/PalServer-Win64-Shipping-Cmd.exe" +ue4ss_dir="$win64_dir/ue4ss" +ue4ss_log="$ue4ss_dir/UE4SS.log" +palschema_dll="$ue4ss_dir/Mods/PalSchema/dlls/main.dll" +mods_dir="$ue4ss_dir/Mods/PalSchema/mods" +wine_prefix="$game_dir/.compat/pfx" + +required_files=( + "$server_exe" + "$win64_dir/dwmapi.dll" + "$ue4ss_dir/UE4SS.dll" + "$ue4ss_dir/UE4SS-settings.ini" + "$ue4ss_dir/MemberVariableLayout.ini" + "$palschema_dll" +) +for required_file in "${required_files[@]}"; do + if [[ ! -f "$required_file" ]]; then + printf 'Required isolated-server file is missing: %s\n' "$required_file" >&2 + exit 1 + fi +done + +hot_reload_mod_name="" +if [[ -n "$hot_reload_file" ]]; then + if [[ ! -f "$hot_reload_file" ]]; then + printf 'Hot-reload fixture is missing: %s\n' "$hot_reload_file" >&2 + exit 1 + fi + hot_reload_file="$( + cd -- "$(dirname -- "$hot_reload_file")" + printf '%s/%s' "$PWD" "$(basename -- "$hot_reload_file")" + )" + case "$hot_reload_file" in + "$mods_dir"/*) + hot_reload_relative="${hot_reload_file#"$mods_dir"/}" + hot_reload_mod_name="${hot_reload_relative%%/*}" + ;; + *) + printf 'Hot-reload fixture must be inside %s\n' "$mods_dir" >&2 + exit 1 + ;; + esac +fi + +required_commands=(grep kill setsid ss timeout wine wineserver) +for required_command in "${required_commands[@]}"; do + if ! command -v "$required_command" >/dev/null 2>&1; then + printf 'Required command is missing: %s\n' "$required_command" >&2 + exit 1 + fi +done + +if pgrep -f -- "$server_exe" >/dev/null 2>&1; then + printf 'The selected Win64 server is already running: %s\n' "$server_exe" >&2 + exit 1 +fi + +port_is_open() { + local port="$1" + ss -H -lun "sport = :$port" | grep -q . +} + +for port in "$game_port" "$query_port"; do + if port_is_open "$port"; then + printf 'UDP port %s is already in use; choose an isolated port.\n' "$port" >&2 + exit 1 + fi +done + +if [[ -z "$evidence_dir" ]]; then + timestamp="$(date -u +%Y%m%dT%H%M%SZ)" + evidence_dir="$project_root/build/runtime-evidence/$timestamp" +fi +mkdir -p "$evidence_dir" "$game_dir/.compat" +evidence_dir="$(cd -- "$evidence_dir" && pwd)" + +expected_loaders=( + enums + raw + blueprints + resources + pals + npcs + items + skins + appearance + buildings + helpguide + spawns + translations +) +expected_signature_count=22 +launcher_pid="" + +stop_selected_server() { + local wait_iteration + + if [[ -n "$launcher_pid" ]] && kill -0 "$launcher_pid" >/dev/null 2>&1; then + kill -INT -- "-$launcher_pid" >/dev/null 2>&1 || true + for wait_iteration in {1..20}; do + if ! kill -0 "$launcher_pid" >/dev/null 2>&1; then + break + fi + sleep 1 + done + fi + + if [[ -n "$launcher_pid" ]] && kill -0 "$launcher_pid" >/dev/null 2>&1; then + kill -TERM -- "-$launcher_pid" >/dev/null 2>&1 || true + sleep 2 + fi + if [[ -n "$launcher_pid" ]] && kill -0 "$launcher_pid" >/dev/null 2>&1; then + kill -KILL -- "-$launcher_pid" >/dev/null 2>&1 || true + fi + + WINEPREFIX="$wine_prefix" wineserver -k >/dev/null 2>&1 || true + launcher_pid="" +} +trap stop_selected_server EXIT INT TERM + +printf '%s\n' \ + "PalSchema Win64 server smoke suite" \ + "Server: $game_dir" \ + "Cycles: $cycles" \ + "Ports: $game_port/$query_port" \ + "Hot reload: ${hot_reload_file:-not requested}" \ + "Evidence: $evidence_dir" + +printf 'cycle\tstartup_seconds\tsignatures\tloaders\tgame_port\tquery_port\thot_reload\n' \ + > "$evidence_dir/summary.tsv" + +for ((cycle = 1; cycle <= cycles; cycle++)); do + cycle_dir="$evidence_dir/cycle-$cycle" + console_log="$cycle_dir/wine-console.log" + mkdir -p "$cycle_dir" + + if [[ -f "$ue4ss_log" ]]; then + mv -- "$ue4ss_log" "$cycle_dir/ue4ss-before-start.log" + fi + + cycle_started_at="$(date +%s)" + ( + cd "$win64_dir" + exec setsid env \ + WINEPREFIX="$wine_prefix" \ + WINEDLLOVERRIDES="dwmapi=n,b" \ + WINEDEBUG="-all" \ + SteamAppId="2394010" \ + wine "./$(basename -- "$server_exe")" \ + Pal \ + "-port=$game_port" \ + "-queryport=$query_port" \ + -players=4 \ + -useperfthreads \ + -NoAsyncLoadingThread \ + -UseMultithreadForDS + ) >"$console_log" 2>&1 & + launcher_pid=$! + + cycle_deadline=$((cycle_started_at + startup_timeout)) + cycle_ready=false + while (( $(date +%s) <= cycle_deadline )); do + if ! kill -0 "$launcher_pid" >/dev/null 2>&1; then + printf 'Cycle %s exited before reaching readiness.\n' "$cycle" >&2 + break + fi + + if [[ -f "$ue4ss_log" ]] && + grep -Fq "Event loop start" "$ue4ss_log" && + port_is_open "$game_port" && + port_is_open "$query_port"; then + all_loaders_ready=true + for loader in "${expected_loaders[@]}"; do + if ! grep -Fq "Loader '$loader' initialized." "$ue4ss_log"; then + all_loaders_ready=false + break + fi + done + if [[ "$all_loaders_ready" == true ]]; then + cycle_ready=true + break + fi + fi + sleep 1 + done + + if [[ -f "$ue4ss_log" ]]; then + cp -- "$ue4ss_log" "$cycle_dir/UE4SS.log" + fi + + if [[ "$cycle_ready" != true ]]; then + printf 'Cycle %s did not become ready within %s seconds.\n' \ + "$cycle" "$startup_timeout" >&2 + exit 1 + fi + + if grep -Eiq '\[PalSchema\].*(\[error\]|\[fatal\])' "$ue4ss_log"; then + printf 'Cycle %s contains a PalSchema error or fatal log entry.\n' "$cycle" >&2 + exit 1 + fi + + signature_count="$(grep -Fc "[PalSchema] Found " "$ue4ss_log")" + if ((signature_count != expected_signature_count)); then + printf 'Cycle %s found %s signatures; expected %s.\n' \ + "$cycle" "$signature_count" "$expected_signature_count" >&2 + exit 1 + fi + + loader_count=0 + for loader in "${expected_loaders[@]}"; do + if grep -Fq "Loader '$loader' initialized." "$ue4ss_log"; then + loader_count=$((loader_count + 1)) + fi + done + + hot_reload_result="not-run" + if [[ -n "$hot_reload_file" ]]; then + if ! grep -Fq "Auto-reload is enabled." "$ue4ss_log"; then + printf '%s\n' \ + "Hot-reload was requested, but PalSchema auto-reload is disabled." >&2 + exit 1 + fi + + reload_marker="Auto-reloaded mod $hot_reload_mod_name" + reload_count_before="$(grep -Fc "$reload_marker" "$ue4ss_log" || true)" + touch -- "$hot_reload_file" + reload_deadline=$(($(date +%s) + reload_timeout)) + while (( $(date +%s) <= reload_deadline )); do + reload_count_after="$(grep -Fc "$reload_marker" "$ue4ss_log" || true)" + if ((reload_count_after > reload_count_before)); then + hot_reload_result="passed" + break + fi + sleep 1 + done + if [[ "$hot_reload_result" != "passed" ]]; then + printf 'Cycle %s did not auto-reload %s within %s seconds.\n' \ + "$cycle" "$hot_reload_mod_name" "$reload_timeout" >&2 + exit 1 + fi + cp -- "$ue4ss_log" "$cycle_dir/UE4SS.log" + fi + + startup_seconds=$(($(date +%s) - cycle_started_at)) + printf '%s\t%s\t%s\t%s\topen\topen\t%s\n' \ + "$cycle" \ + "$startup_seconds" \ + "$signature_count" \ + "$loader_count" \ + "$hot_reload_result" \ + >> "$evidence_dir/summary.tsv" + + stop_selected_server + + for port in "$game_port" "$query_port"; do + if port_is_open "$port"; then + printf 'Cycle %s left UDP port %s open after shutdown.\n' "$cycle" "$port" >&2 + exit 1 + fi + done + + printf 'Cycle %s/%s passed in %ss: %s signatures, %s loaders.\n' \ + "$cycle" "$cycles" "$startup_seconds" "$signature_count" "$loader_count" +done + +trap - EXIT INT TERM +printf 'All %s cycle(s) passed. Evidence: %s\n' "$cycles" "$evidence_dir" diff --git a/scripts/tests/test-bootstrap-cache.sh b/scripts/tests/test-bootstrap-cache.sh new file mode 100755 index 0000000..59814c6 --- /dev/null +++ b/scripts/tests/test-bootstrap-cache.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +test_root="$(mktemp -d "${TMPDIR:-/tmp}/palschema-bootstrap-test.XXXXXX")" +trap 'rm -rf -- "$test_root"' EXIT + +project_root="$test_root/project" +fake_bin="$test_root/bin" +mkdir -p \ + "$project_root/scripts/lib" \ + "$project_root/deps/RE-UE4SS/deps/first/Unreal" \ + "$fake_bin" \ + "$test_root/rust-target" +cp -- "$repository_root/scripts/bootstrap-linux.sh" "$project_root/scripts/" +cp -- "$repository_root/scripts/lib/build-env.sh" "$project_root/scripts/lib/" +touch "$project_root/deps/RE-UE4SS/deps/first/Unreal/CMakeLists.txt" + +for tool in clang-cl cmake lld-link llvm-mt llvm-rc llvm-ranlib llvm-lib ninja; do + printf '%s\n' '#!/usr/bin/env sh' 'exit 0' > "$fake_bin/$tool" + chmod +x "$fake_bin/$tool" +done +cat > "$fake_bin/rustc" <<'EOF' +#!/usr/bin/env sh +if [ "$1" = "--print" ] && [ "$2" = "target-libdir" ]; then + printf '%s\n' "$FAKE_RUST_LIBDIR" + exit 0 +fi +exit 0 +EOF +cat > "$fake_bin/cargo" <<'EOF' +#!/usr/bin/env sh +exit 0 +EOF +cat > "$fake_bin/xwin" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +output="" +while (($# > 0)); do + if [[ "$1" == "--output" ]]; then + output="$2" + shift + fi + shift +done +{ + flock 9 + count=0 + if [[ -f "$FAKE_XWIN_COUNTER" ]]; then + count="$(cat "$FAKE_XWIN_COUNTER")" + fi + printf '%s\n' "$((count + 1))" > "$FAKE_XWIN_COUNTER" +} 9>"$FAKE_XWIN_COUNTER.lock" +mkdir -p \ + "$output/crt/include" \ + "$output/crt/lib/x86_64" \ + "$output/sdk/include/um" \ + "$output/sdk/lib/ucrt/x86_64" \ + "$output/sdk/lib/um/x86_64" +touch \ + "$output/crt/include/vcruntime.h" \ + "$output/crt/lib/x86_64/msvcrt.lib" +if [[ "${FAKE_XWIN_FAIL:-0}" == "1" ]]; then + exit 1 +fi +touch \ + "$output/sdk/include/um/Windows.h" \ + "$output/sdk/lib/ucrt/x86_64/ucrt.lib" \ + "$output/sdk/lib/um/x86_64/kernel32.Lib" +sleep 0.2 +EOF +chmod +x "$fake_bin/rustc" "$fake_bin/cargo" "$fake_bin/xwin" + +export PATH="$fake_bin:$PATH" +export FAKE_RUST_LIBDIR="$test_root/rust-target" +export FAKE_XWIN_COUNTER="$test_root/xwin-count" +export PALSCHEMA_CACHE_ROOT="$test_root/cache" +export XWIN_DIR="$PALSCHEMA_CACHE_ROOT/xwin" + +cd "$project_root" +"$project_root/scripts/bootstrap-linux.sh" \ + --prepare-sdk --accept-microsoft-license >"$test_root/first.out" & +first_pid=$! +"$project_root/scripts/bootstrap-linux.sh" \ + --prepare-sdk --accept-microsoft-license >"$test_root/second.out" & +second_pid=$! +wait "$first_pid" +wait "$second_pid" + +if [[ "$(cat "$FAKE_XWIN_COUNTER")" != "1" ]]; then + printf '%s\n' "Concurrent bootstrap runs performed more than one xwin splat." >&2 + exit 1 +fi +if [[ ! -f "$XWIN_DIR/.palschema-sdk-complete" ]]; then + printf '%s\n' "Concurrent bootstrap did not publish a completed cache." >&2 + exit 1 +fi + +export PALSCHEMA_CACHE_ROOT="$test_root/failure-cache" +export XWIN_DIR="$PALSCHEMA_CACHE_ROOT/xwin" +export FAKE_XWIN_COUNTER="$test_root/failure-xwin-count" +set +e +FAKE_XWIN_FAIL=1 "$project_root/scripts/bootstrap-linux.sh" \ + --prepare-sdk --accept-microsoft-license \ + >"$test_root/failure.out" 2>"$test_root/failure.err" +failure_status=$? +set -e +if ((failure_status == 0)); then + printf '%s\n' "Interrupted xwin splat unexpectedly succeeded." >&2 + exit 1 +fi +if [[ -e "$XWIN_DIR" ]]; then + printf '%s\n' "Interrupted xwin splat published a partial cache." >&2 + exit 1 +fi +"$project_root/scripts/bootstrap-linux.sh" \ + --prepare-sdk --accept-microsoft-license >"$test_root/retry.out" +if [[ ! -f "$XWIN_DIR/.palschema-sdk-complete" ]]; then + printf '%s\n' "Bootstrap retry did not publish a completed cache." >&2 + exit 1 +fi + +printf '%s\n' "PalSchema bootstrap cache tests passed." diff --git a/scripts/tests/test-deploy-transaction.sh b/scripts/tests/test-deploy-transaction.sh new file mode 100755 index 0000000..7f6b8ba --- /dev/null +++ b/scripts/tests/test-deploy-transaction.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +test_root="$(mktemp -d "${TMPDIR:-/tmp}/palschema-deploy-test.XXXXXX")" +trap 'rm -rf -- "$test_root"' EXIT + +project_root="$test_root/project" +game_root="$test_root/game" +fake_bin="$test_root/bin" +mkdir -p \ + "$project_root/scripts" \ + "$project_root/scripts/lib" \ + "$project_root/build/win64-xwin-shipping" \ + "$game_root/Pal/Binaries/Win64/ue4ss/Mods/PalSchema/dlls" \ + "$fake_bin" +cp -- "$repository_root/scripts/deploy-proton.sh" "$project_root/scripts/" +cp -- "$repository_root/scripts/verify-win64-artifact.py" "$project_root/scripts/" +cp -- "$repository_root/scripts/lib/build-env.sh" "$project_root/scripts/lib/" +cp -- "$repository_root/scripts/lib/process-scan.sh" "$project_root/scripts/lib/" +cp -- "$repository_root/version.h" "$project_root/version.h" +touch "$game_root/Pal/Binaries/Win64/PalServer-Win64-Shipping-Cmd.exe" +touch "$game_root/Pal/Binaries/Win64/ue4ss/UE4SS.dll" +printf '%s\n' "old-install" \ + > "$game_root/Pal/Binaries/Win64/ue4ss/Mods/PalSchema/dlls/main.dll" + +python3 - "$project_root/build/win64-xwin-shipping/PalSchema.dll" <<'PY' +from pathlib import Path +import sys + +Path(sys.argv[1]).write_bytes( + "PalSchema\0".encode("utf-16-le") + "0.6.1.0\0".encode("utf-16-le") +) +PY + +cat > "$fake_bin/llvm-readobj" <<'EOF' +#!/usr/bin/env sh +cat <<'OUTPUT' +Format: COFF-x86-64 +IMAGE_FILE_DLL +IMAGE_DLL_CHARACTERISTICS_DYNAMIC_BASE +IMAGE_DLL_CHARACTERISTICS_HIGH_ENTROPY_VA +IMAGE_DLL_CHARACTERISTICS_NX_COMPAT +Export { + Name: start_mod +} +Export { + Name: uninstall_mod +} +Import { + Name: UE4SS.dll +} +Type: VERSIONINFO +Data ( + 0000: 50007200 6F006400 75006300 74004E00 |data| + 0010: 61006D00 65000000 50006100 6C005300 |data| + 0020: 63006800 65006D00 61000000 50007200 |data| + 0030: 6F006400 75006300 74005600 65007200 |data| + 0040: 73006900 6F006E00 00003000 2E003600 |data| + 0050: 2E003100 2E003000 00000000 |data| +) +OUTPUT +EOF +chmod +x "$fake_bin/llvm-readobj" + +set +e +PATH="$fake_bin:$PATH" \ +PALSCHEMA_TEST_INTERRUPT_AFTER_BACKUP=1 \ + "$project_root/scripts/deploy-proton.sh" shipping \ + --target server \ + --game-dir "$game_root" \ + >"$test_root/interrupted.out" 2>"$test_root/interrupted.err" +interrupt_status=$? +set -e +if ((interrupt_status != 143)); then + printf 'Expected interrupted deploy to exit 143, got %s.\n' \ + "$interrupt_status" >&2 + exit 1 +fi +if [[ "$(cat "$game_root/Pal/Binaries/Win64/ue4ss/Mods/PalSchema/dlls/main.dll")" \ + != "old-install" ]]; then + printf '%s\n' "Interrupted deploy did not restore the previous installation." >&2 + exit 1 +fi +if [[ -e "$game_root/Pal/Binaries/Win64/ue4ss/.palschema-backups/.active-transaction" ]]; then + printf '%s\n' "Interrupted deploy left an active transaction marker." >&2 + exit 1 +fi + +backup_root="$game_root/Pal/Binaries/Win64/ue4ss/.palschema-backups" +mods_root="$game_root/Pal/Binaries/Win64/ue4ss/Mods" +target_root="$mods_root/PalSchema" +crash_id="20000101T000000Z-2" +stale_stage_name=".PalSchema.stage.CRASHED" +mkdir -p \ + "$backup_root/$crash_id/PalSchema/dlls" \ + "$mods_root/$stale_stage_name/dlls" +printf '%s\n' "power-loss-old-install" \ + > "$backup_root/$crash_id/PalSchema/dlls/main.dll" +printf '%s\n' "abandoned-stage" \ + > "$mods_root/$stale_stage_name/dlls/main.dll" +rm -rf -- "$target_root" +printf '%s\n%s\n' "$crash_id" "$stale_stage_name" \ + > "$backup_root/.active-transaction" + +PATH="$fake_bin:$PATH" \ + "$project_root/scripts/deploy-proton.sh" shipping \ + --target server \ + --game-dir "$game_root" \ + >"$test_root/success.out" +if ! grep -q "Recovered interrupted PalSchema transaction" \ + "$test_root/success.out"; then + printf '%s\n' "A persisted power-loss transaction was not recovered." >&2 + exit 1 +fi +if ! grep -q "Deployed PalSchema" "$test_root/success.out"; then + printf '%s\n' "Recovered deployment did not complete." >&2 + exit 1 +fi +if [[ -e "$mods_root/$stale_stage_name" || + -e "$backup_root/.active-transaction" ]]; then + printf '%s\n' "Power-loss recovery left stale transaction state." >&2 + exit 1 +fi +if ! grep -R -q "power-loss-old-install" "$backup_root"/*/PalSchema/dlls/main.dll; then + printf '%s\n' "Recovered installation was not preserved by the next deploy." >&2 + exit 1 +fi + +live_hash="$(sha256sum "$target_root/dlls/main.dll" | awk '{print $1}')" +missing_id="20000101T000000Z-3" +printf '%s\n\n' "$missing_id" > "$backup_root/.active-transaction" +set +e +PATH="$fake_bin:$PATH" \ + "$project_root/scripts/deploy-proton.sh" shipping \ + --target server \ + --game-dir "$game_root" \ + >"$test_root/missing-backup.out" 2>"$test_root/missing-backup.err" +missing_backup_status=$? +set -e +if ((missing_backup_status == 0)) || + ! grep -q "transaction backup is missing" "$test_root/missing-backup.err"; then + printf '%s\n' "Missing transaction backup did not fail closed." >&2 + exit 1 +fi +if [[ "$(sha256sum "$target_root/dlls/main.dll" | awk '{print $1}')" != "$live_hash" ]]; then + printf '%s\n' "Missing-backup recovery modified the live installation." >&2 + exit 1 +fi +rm -f -- "$backup_root/.active-transaction" + +symlink_id="20000101T000000Z-4" +external_recovery="$test_root/external-recovery" +mkdir -p "$external_recovery/PalSchema/dlls" +printf '%s\n' "external-sentinel" \ + > "$external_recovery/PalSchema/dlls/main.dll" +ln -s "$external_recovery" "$backup_root/$symlink_id" +printf '%s\n\n' "$symlink_id" > "$backup_root/.active-transaction" +set +e +PATH="$fake_bin:$PATH" \ + "$project_root/scripts/deploy-proton.sh" shipping \ + --target server \ + --game-dir "$game_root" \ + >"$test_root/symlink-recovery.out" 2>"$test_root/symlink-recovery.err" +symlink_recovery_status=$? +set -e +if ((symlink_recovery_status == 0)) || + ! grep -q "backup contains a symlink" "$test_root/symlink-recovery.err"; then + printf '%s\n' "Symlinked transaction backup did not fail closed." >&2 + exit 1 +fi +if [[ "$(cat "$external_recovery/PalSchema/dlls/main.dll")" != "external-sentinel" ]]; then + printf '%s\n' "Symlinked recovery moved or modified the external directory." >&2 + exit 1 +fi +rm -f -- "$backup_root/.active-transaction" "$backup_root/$symlink_id" + +printf '%s\n' "not-a-valid-transaction" > "$backup_root/.active-transaction" +set +e +PATH="$fake_bin:$PATH" \ + "$project_root/scripts/deploy-proton.sh" shipping \ + --target server \ + --game-dir "$game_root" \ + >"$test_root/malformed.out" 2>"$test_root/malformed.err" +malformed_status=$? +set -e +if ((malformed_status == 0)) || + ! grep -q "Invalid deployment transaction marker" "$test_root/malformed.err"; then + printf '%s\n' "Malformed transaction marker did not fail closed." >&2 + exit 1 +fi +rm -f -- "$backup_root/.active-transaction" + +bad_backup="$game_root/Pal/Binaries/Win64/ue4ss/.palschema-backups/20000101T000000Z-1" +mkdir -p "$bad_backup/PalSchema/dlls" +ln -s /definitely-missing "$bad_backup/PalSchema/dlls/main.dll" +set +e +PATH="$fake_bin:$PATH" \ + "$project_root/scripts/deploy-proton.sh" \ + --rollback "$bad_backup" \ + --target server \ + --game-dir "$game_root" \ + >"$test_root/rollback.out" 2>"$test_root/rollback.err" +rollback_status=$? +set -e +if ((rollback_status == 0)); then + printf '%s\n' "Rollback accepted an invalid staged main.dll." >&2 + exit 1 +fi +if [[ ! -f "$game_root/Pal/Binaries/Win64/ue4ss/Mods/PalSchema/dlls/main.dll" ]]; then + printf '%s\n' "Rejected rollback modified the live installation." >&2 + exit 1 +fi + +printf '%s\n' "PalSchema deploy transaction tests passed." diff --git a/scripts/tests/test-distro-matrix-output.sh b/scripts/tests/test-distro-matrix-output.sh new file mode 100755 index 0000000..4e79bd4 --- /dev/null +++ b/scripts/tests/test-distro-matrix-output.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +test_root="$(mktemp -d "${TMPDIR:-/tmp}/palschema-matrix-output-test.XXXXXX")" +trap 'rm -rf -- "$test_root"' EXIT + +fake_bin="$test_root/bin" +mkdir -p "$fake_bin" +cat > "$fake_bin/docker" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +case "${1:-}" in + run) + if [[ "${FAKE_DOCKER_SLEEP:-0}" == "1" ]]; then + sleep 30 + fi + for index in $(seq 1 5000); do + printf 'very-noisy-container-line-%s\n' "$index" + done + exit "${FAKE_DOCKER_STATUS:-0}" + ;; + rm|pull) + exit 0 + ;; + *) + exit 0 + ;; +esac +EOF +chmod +x "$fake_bin/docker" + +PATH="$fake_bin:$PATH" \ + "$repository_root/scripts/test-distro-matrix.sh" \ + --no-pull debian >"$test_root/success.out" 2>&1 +if grep -q "very-noisy-container-line" "$test_root/success.out"; then + printf '%s\n' "Successful matrix output leaked container logs." >&2 + exit 1 +fi +if ! grep -q "PASS" "$test_root/success.out"; then + printf '%s\n' "Successful matrix output did not report a concise PASS." >&2 + exit 1 +fi + +set +e +PATH="$fake_bin:$PATH" \ +PALSCHEMA_MATRIX_TIMEOUT_SECONDS=1 \ +FAKE_DOCKER_SLEEP=1 \ + "$repository_root/scripts/test-distro-matrix.sh" \ + --no-pull debian >"$test_root/timeout.out" 2>&1 +timeout_status=$? +set -e +if ((timeout_status == 0)); then + printf '%s\n' "Timed-out matrix unexpectedly succeeded." >&2 + exit 1 +fi +if ! grep -q "Timed out after 1 seconds" "$test_root/timeout.out"; then + printf '%s\n' "Timed-out matrix did not report its hard limit." >&2 + exit 1 +fi +if (($(wc -c < "$test_root/timeout.out") > 32768)); then + printf '%s\n' "Timed-out matrix emitted excessive terminal output." >&2 + exit 1 +fi + +printf '%s\n' "PalSchema distro matrix output tests passed." diff --git a/scripts/tests/test-process-scan.sh b/scripts/tests/test-process-scan.sh new file mode 100755 index 0000000..bd1d856 --- /dev/null +++ b/scripts/tests/test-process-scan.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +test_root="$(mktemp -d "${TMPDIR:-/tmp}/palschema-process-scan.XXXXXX")" +trap 'rm -rf -- "$test_root"' EXIT +source "$repository_root/scripts/lib/process-scan.sh" + +mkdir -p "$test_root/100" +printf 'wine\0C:\\PalServer-Win64-Shipping-Cmd.exe\0' \ + > "$test_root/100/cmdline" +if ! palschema_target_process_status server "$test_root"; then + printf '%s\n' "Process scan did not detect a matching Win64 server." >&2 + exit 1 +fi + +rm -rf -- "$test_root/100" +mkdir -p "$test_root/200" +printf 'unrelated\0process\0' > "$test_root/200/cmdline" +set +e +palschema_target_process_status server "$test_root" +negative_status=$? +set -e +if ((negative_status != 1)); then + printf 'Expected a complete negative scan, got status %s.\n' \ + "$negative_status" >&2 + exit 1 +fi + +rm -rf -- "$test_root/200" +mkdir -p "$test_root/300" +python3 - "$test_root/300/cmdline" <<'PY' +import socket +import sys + +sock = socket.socket(socket.AF_UNIX) +sock.bind(sys.argv[1]) +sock.close() +PY +set +e +palschema_target_process_status server "$test_root" +incomplete_status=$? +set -e +if ((incomplete_status != 2)); then + printf 'Expected an incomplete scan to fail closed with status 2, got %s.\n' \ + "$incomplete_status" >&2 + exit 1 +fi + +printf '%s\n' "PalSchema process scan tests passed." diff --git a/scripts/tests/test_verify_win64_artifact.py b/scripts/tests/test_verify_win64_artifact.py new file mode 100644 index 0000000..c716efe --- /dev/null +++ b/scripts/tests/test_verify_win64_artifact.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +VERIFIER = REPOSITORY_ROOT / "scripts" / "verify-win64-artifact.py" + + +def resource_dump(product_name: str, product_version: str) -> str: + content = ( + f"ProductName\0{product_name}\0" + f"ProductVersion\0{product_version}\0" + ).encode("utf-16-le") + content += b"\0" * (-len(content) % 4) + groups = [ + content[index : index + 4].hex().upper() + for index in range(0, len(content), 4) + ] + rows = [] + for index in range(0, len(groups), 4): + rows.append( + f" {index * 4:04X}: {' '.join(groups[index:index + 4])} |data|" + ) + return "\n".join(rows) + + +def readobj_output( + product_name: str = "PalSchema", + product_version: str = "0.6.1.0", +) -> str: + return f"""\ +File: fixture.dll +Format: COFF-x86-64 +Characteristics [ (0x2022) + IMAGE_FILE_DLL +] +DLLCharacteristics [ (0x8160) + IMAGE_DLL_CHARACTERISTICS_DYNAMIC_BASE + IMAGE_DLL_CHARACTERISTICS_HIGH_ENTROPY_VA + IMAGE_DLL_CHARACTERISTICS_NX_COMPAT +] +Export {{ + Name: start_mod +}} +Export {{ + Name: uninstall_mod +}} +Import {{ + Name: UE4SS.dll +}} +Resources [ + Type: VERSIONINFO (ID 16) [ + Data ( +{resource_dump(product_name, product_version)} + ) + ] +] +""" + + +class VerifyWin64ArtifactTest(unittest.TestCase): + def run_verifier( + self, + output: str | None = None, + ) -> subprocess.CompletedProcess[str]: + with tempfile.TemporaryDirectory(prefix="palschema-pe-test-") as directory: + root = Path(directory) + artifact = root / "fixture.dll" + artifact.write_bytes(b"fixture") + readobj = root / "llvm-readobj" + readobj.write_text( + "#!/usr/bin/env sh\n" + "cat <<'EOF'\n" + f"{output or readobj_output()}" + "EOF\n", + encoding="utf-8", + ) + readobj.chmod(0o755) + return subprocess.run( + [ + sys.executable, + str(VERIFIER), + str(artifact), + "--llvm-readobj", + str(readobj), + ], + capture_output=True, + text=True, + check=False, + ) + + def test_accepts_project_identity_and_version(self) -> None: + completed = self.run_verifier() + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertIn('"product_name": "PalSchema"', completed.stdout) + + def test_rejects_an_unrelated_generic_ue4ss_mod(self) -> None: + completed = self.run_verifier(readobj_output(product_name="OtherMod!")) + self.assertEqual(completed.returncode, 1) + self.assertIn("product identity", completed.stderr) + + def test_rejects_each_broken_pe_contract_field(self) -> None: + valid = readobj_output() + mutations = { + "architecture": ( + valid.replace("Format: COFF-x86-64", "Format: COFF-i386"), + "not COFF x86-64", + ), + "dll flag": ( + valid.replace(" IMAGE_FILE_DLL\n", ""), + "not marked as a DLL", + ), + "export": ( + valid.replace("Export {\n Name: uninstall_mod\n}\n", ""), + "exports differ", + ), + "UE4SS import": ( + valid.replace("Import {\n Name: UE4SS.dll\n}\n", ""), + "UE4SS.dll import is missing", + ), + "VERSIONINFO": ( + valid.replace("Type: VERSIONINFO", "Type: OTHER"), + "VERSIONINFO resource is missing", + ), + "product version": ( + readobj_output(product_version="9.9.9.9"), + "product version", + ), + } + for characteristic in ( + "IMAGE_DLL_CHARACTERISTICS_DYNAMIC_BASE", + "IMAGE_DLL_CHARACTERISTICS_HIGH_ENTROPY_VA", + "IMAGE_DLL_CHARACTERISTICS_NX_COMPAT", + ): + mutations[characteristic] = ( + valid.replace(f" {characteristic}\n", ""), + "missing DLL security characteristics", + ) + + for name, (output, expected_error) in mutations.items(): + with self.subTest(name=name): + completed = self.run_verifier(output) + self.assertEqual(completed.returncode, 1) + self.assertIn(expected_error, completed.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_version_consistency.py b/scripts/tests/test_version_consistency.py new file mode 100644 index 0000000..e98a8a9 --- /dev/null +++ b/scripts/tests/test_version_consistency.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import json +import re +import unittest +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] + + +def palschema_version() -> str: + header = (REPOSITORY_ROOT / "version.h").read_text(encoding="utf-8") + components = [] + for name in ("MAJOR", "MINOR", "REVISION"): + match = re.search( + rf"^\s*#define\s+VERSION_{name}\s+([0-9]+)\s*$", + header, + re.MULTILINE, + ) + if match is None: + raise AssertionError(f"VERSION_{name} is missing from version.h") + components.append(match.group(1)) + return ".".join(components) + + +class VersionConsistencyTests(unittest.TestCase): + def test_packages_and_schema_ids_match_version_header(self) -> None: + version = palschema_version() + for manifest in ( + "tools/palschema-tools/package.json", + "tools/vscode-palschema/package.json", + ): + data = json.loads( + (REPOSITORY_ROOT / manifest).read_text(encoding="utf-8") + ) + self.assertEqual(data["version"], version, manifest) + + schema_root = REPOSITORY_ROOT / "assets" / "schemas" + index = json.loads( + (schema_root / "schema-index.json").read_text(encoding="utf-8") + ) + self.assertEqual(index["palSchemaVersion"], version) + for entry in index["schemas"]: + expected_id = ( + "https://okaetsu.github.io/PalSchema/" + f"schemas/{version}/{entry['file']}" + ) + self.assertEqual(entry["id"], expected_id, entry["file"]) + schema_file = schema_root / entry["file"] + if schema_file.is_file(): + schema = json.loads(schema_file.read_text(encoding="utf-8")) + self.assertEqual(schema.get("$id"), expected_id, entry["file"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify-win64-artifact.py b/scripts/verify-win64-artifact.py index 92f2ecd..b589721 100755 --- a/scripts/verify-win64-artifact.py +++ b/scripts/verify-win64-artifact.py @@ -7,6 +7,7 @@ import argparse import hashlib import json +import re import shutil import subprocess import sys @@ -14,6 +15,7 @@ EXPECTED_EXPORTS = {"start_mod", "uninstall_mod"} +EXPECTED_PRODUCT_NAME = "PalSchema" REQUIRED_DLL_CHARACTERISTICS = { "IMAGE_DLL_CHARACTERISTICS_DYNAMIC_BASE", "IMAGE_DLL_CHARACTERISTICS_HIGH_ENTROPY_VA", @@ -34,10 +36,76 @@ def parse_args() -> argparse.Namespace: default="llvm-readobj", help="llvm-readobj executable name or path.", ) + parser.add_argument( + "--expected-version", + help="Expected four-component PalSchema product version (defaults to version.h).", + ) return parser.parse_args() -def inspect_artifact(artifact: Path, llvm_readobj: str) -> dict[str, object]: +def version_from_header() -> str: + header = Path(__file__).resolve().parent.parent / "version.h" + text = header.read_text(encoding="utf-8") + components = [] + for name in ("MAJOR", "MINOR", "REVISION", "BUILD"): + match = re.search( + rf"^\s*#define\s+VERSION_{name}\s+([0-9]+)\s*$", + text, + re.MULTILINE, + ) + if match is None: + raise RuntimeError(f"Unable to resolve VERSION_{name} from {header}.") + components.append(match.group(1)) + return ".".join(components) + + +def version_resource_bytes(output: str) -> bytes: + in_version_resource = False + in_data = False + chunks: list[bytes] = [] + for raw_line in output.splitlines(): + line = raw_line.strip() + if line.startswith("Type: VERSIONINFO"): + in_version_resource = True + continue + if in_version_resource and line == "Data (": + in_data = True + continue + if in_data and line == ")": + break + if not in_data: + continue + match = re.match( + r"^[0-9A-Fa-f]+:\s+((?:[0-9A-Fa-f]{8}\s+)+)\|", + line, + ) + if match is not None: + chunks.extend( + bytes.fromhex(group) + for group in match.group(1).split() + ) + return b"".join(chunks) + + +def has_version_string(resource: bytes, key: str, value: str) -> bool: + key_marker = f"{key}\0".encode("utf-16-le") + value_marker = f"{value}\0".encode("utf-16-le") + key_offset = resource.find(key_marker) + if key_offset < 0: + return False + value_offset = resource.find( + value_marker, + key_offset + len(key_marker), + key_offset + len(key_marker) + 512, + ) + return value_offset >= 0 + + +def inspect_artifact( + artifact: Path, + llvm_readobj: str, + expected_version: str, +) -> dict[str, object]: executable = shutil.which(llvm_readobj) if executable is None: raise RuntimeError(f"Unable to find {llvm_readobj!r} on PATH.") @@ -48,6 +116,7 @@ def inspect_artifact(artifact: Path, llvm_readobj: str) -> dict[str, object]: "--file-headers", "--coff-exports", "--coff-imports", + "--coff-resources", str(artifact), ], check=True, @@ -96,6 +165,27 @@ def inspect_artifact(artifact: Path, llvm_readobj: str) -> dict[str, object]: ) if "UE4SS.dll" not in imports: errors.append("dynamic UE4SS.dll import is missing") + resource_bytes = version_resource_bytes(output) + if "Type: VERSIONINFO" not in output: + errors.append("VERSIONINFO resource is missing") + elif not resource_bytes: + errors.append("VERSIONINFO resource data is missing") + if not has_version_string( + resource_bytes, + "ProductName", + EXPECTED_PRODUCT_NAME, + ): + errors.append( + f"VERSIONINFO product identity {EXPECTED_PRODUCT_NAME!r} is missing" + ) + if not has_version_string( + resource_bytes, + "ProductVersion", + expected_version, + ): + errors.append( + f"VERSIONINFO product version {expected_version!r} is missing" + ) if missing_characteristics: errors.append( "missing DLL security characteristics: " @@ -110,6 +200,8 @@ def inspect_artifact(artifact: Path, llvm_readobj: str) -> dict[str, object]: "artifact": artifact.name, "sha256": digest, "format": "COFF-x86-64", + "product_name": EXPECTED_PRODUCT_NAME, + "product_version": expected_version, "exports": sorted(exports), "imported_dlls": sorted(imports, key=str.casefold), "dll_characteristics": sorted(REQUIRED_DLL_CHARACTERISTICS), @@ -124,7 +216,16 @@ def main() -> int: return 1 try: - contract = inspect_artifact(artifact, args.llvm_readobj) + expected_version = args.expected_version or version_from_header() + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+", expected_version): + raise RuntimeError( + f"Expected version must have four numeric components: {expected_version}" + ) + contract = inspect_artifact( + artifact, + args.llvm_readobj, + expected_version, + ) except (OSError, RuntimeError, subprocess.CalledProcessError) as error: print(f"Win64 artifact verification failed: {error}", file=sys.stderr) return 1 diff --git a/src/Misc/FileWatchWrapper.cpp b/src/Misc/FileWatchWrapper.cpp index fe1a006..d3c650a 100644 --- a/src/Misc/FileWatchWrapper.cpp +++ b/src/Misc/FileWatchWrapper.cpp @@ -1,4 +1,7 @@ #include "Misc/FileWatchWrapper.h" +#include "Platform/RuntimeEnvironment.h" +#include "Utility/Logging.h" +#include "Helpers/String.hpp" #include "UE4SSProgram.hpp" namespace fs = std::filesystem; @@ -6,16 +9,37 @@ namespace fs = std::filesystem; namespace PS { FileWatchWrapper::FileWatchWrapper(const std::filesystem::path& path, const FilesystemUpdateCallback& callback) { - m_fileWatcher = std::make_unique(); + const auto useGenericWatcher = Platform::IsRunningUnderWine(); + m_fileWatcher = std::make_unique(useGenericWatcher); m_updateListener = std::make_unique(); m_updateListener->registerCallback(callback); m_fileWatchId = m_fileWatcher->addWatch(path.string(), m_updateListener.get(), true); + if (m_fileWatchId <= efsw::Errors::NoError) + { + auto error = efsw::Errors::Log::getLastErrorLog(); + PS::Log( + STR("Unable to watch PalSchema mods for changes: {}\n"), + RC::to_generic_string(error) + ); + m_fileWatcher.reset(); + return; + } + + if (useGenericWatcher) + { + PS::Log( + STR("Using the polling file watcher for auto-reload under Wine.\n") + ); + } } FileWatchWrapper::~FileWatchWrapper() { - m_fileWatcher->removeWatch(m_fileWatchId); + if (m_fileWatcher) + { + m_fileWatcher->removeWatch(m_fileWatchId); + } } void FileWatchWrapper::Watch() @@ -27,4 +51,4 @@ namespace PS { m_fileWatcher->watch(); } -} \ No newline at end of file +} diff --git a/src/SDK/PalSignatures.cpp b/src/SDK/PalSignatures.cpp index ea140ab..b468305 100644 --- a/src/SDK/PalSignatures.cpp +++ b/src/SDK/PalSignatures.cpp @@ -1,10 +1,6 @@ -#if defined(_WIN32) -#define NOMINMAX -#include -#endif - #include +#include "Platform/RuntimeEnvironment.h" #include "SDK/PalSignatures.h" #include "Signatures.hpp" #include "SigScanner/SinglePassSigScanner.hpp" @@ -15,19 +11,6 @@ using namespace RC; using namespace RC::Unreal; -namespace -{ - bool IsRunningUnderWine() - { -#if defined(_WIN32) - auto Ntdll = GetModuleHandleW(L"ntdll.dll"); - return Ntdll && GetProcAddress(Ntdll, "wine_get_version"); -#else - return false; -#endif - } -} - namespace Palworld { void SignatureManager::Initialize() { @@ -89,7 +72,7 @@ namespace Palworld { SigContainerMap.emplace(ScanTarget::MainExe, SigContainerBox); const auto PreviousModuleSizeThreshold = SinglePassScanner::m_multithreading_module_size_threshold; - if (IsRunningUnderWine()) + if (PS::Platform::IsRunningUnderWine()) { // SinglePassScanner uses std::async whenever the executable is // larger than this threshold. Wine can block while creating that diff --git a/tools/palschema-tools/scripts/copy-schemas.mjs b/tools/palschema-tools/scripts/copy-schemas.mjs index c8356da..abef423 100644 --- a/tools/palschema-tools/scripts/copy-schemas.mjs +++ b/tools/palschema-tools/scripts/copy-schemas.mjs @@ -1,12 +1,12 @@ -import { cp, mkdir } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { copyPublicSchemas } from "../../../scripts/copy-public-schemas.mjs"; + const scriptDirectory = dirname(fileURLToPath(import.meta.url)); const packageRoot = resolve(scriptDirectory, ".."); const repositoryRoot = resolve(packageRoot, "../.."); const source = resolve(repositoryRoot, "assets/schemas"); const destination = resolve(packageRoot, "dist/schemas"); -await mkdir(destination, { recursive: true }); -await cp(source, destination, { recursive: true, force: true }); +await copyPublicSchemas(source, destination); diff --git a/tools/palschema-tools/src/cli.ts b/tools/palschema-tools/src/cli.ts index 82db5ba..5deb1c2 100644 --- a/tools/palschema-tools/src/cli.ts +++ b/tools/palschema-tools/src/cli.ts @@ -5,16 +5,28 @@ import { access, constants, copyFile, + lstat, mkdir, readdir, - stat, + realpath, writeFile, } from "node:fs/promises"; -import { dirname, resolve, extname, join, relative } from "node:path"; +import { + dirname, + resolve, + extname, + join, + relative, + sep, +} from "node:path"; import chokidar from "chokidar"; import { findProjectConfig } from "./configuration.js"; +import { + isPathContained, + resolveContainedPath, +} from "./path-security.js"; import { SchemaRegistry } from "./schema-registry.js"; import type { PalSchemaDiagnostic, ValidationResult } from "./types.js"; import { PalSchemaValidator } from "./validator.js"; @@ -76,6 +88,14 @@ async function pathExists(path: string): Promise { } } +function containedDestination(root: string, file: string): string { + return resolveContainedPath( + root, + file, + "Schema destination escapes its workspace", + ); +} + function parseCommonOptions(arguments_: string[]): { options: CommonOptions; remaining: string[]; @@ -157,10 +177,14 @@ function isJsonDocument(path: string): boolean { async function discoverFiles(paths: string[]): Promise { const discovered = new Set(); + const visitedDirectories = new Set(); async function visit(path: string): Promise { const absolutePath = resolve(path); - const metadata = await stat(absolutePath); + const metadata = await lstat(absolutePath); + if (metadata.isSymbolicLink()) { + return; + } if (metadata.isFile()) { if ( !IGNORED_FILES.has(absolutePath.split(/[\\/]/).at(-1) ?? "") && @@ -174,10 +198,16 @@ async function discoverFiles(paths: string[]): Promise { return; } + const canonicalDirectory = await realpath(absolutePath); + if (visitedDirectories.has(canonicalDirectory)) { + return; + } + visitedDirectories.add(canonicalDirectory); + const entries = await readdir(absolutePath, { withFileTypes: true }); await Promise.all( entries.map(async (entry) => { - if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name)) { + if (IGNORED_DIRECTORIES.has(entry.name)) { return; } await visit(join(absolutePath, entry.name)); @@ -204,22 +234,19 @@ function printDiagnostic(diagnostic: PalSchemaDiagnostic): void { function printValidationResults( results: ValidationResult[], format: CommonOptions["format"], + ndjson = false, ): number { const diagnostics = results.flatMap((result) => result.diagnostics); if (format === "json") { - console.log( - JSON.stringify( - { - files: results.length, - errors: diagnostics.filter((item) => item.severity === "error").length, - warnings: diagnostics.filter((item) => item.severity === "warning") - .length, - diagnostics, - }, - null, - 2, - ), - ); + const report = { + ...(ndjson ? { event: "validation" } : {}), + files: results.length, + errors: diagnostics.filter((item) => item.severity === "error").length, + warnings: diagnostics.filter((item) => item.severity === "warning") + .length, + diagnostics, + }; + console.log(JSON.stringify(report, null, ndjson ? undefined : 2)); } else { diagnostics.forEach(printDiagnostic); const errors = diagnostics.filter((item) => item.severity === "error").length; @@ -231,76 +258,182 @@ function printValidationResults( return diagnostics.some((item) => item.severity === "error") ? 1 : 0; } -async function validateOnce( - validator: PalSchemaValidator, - paths: string[], - format: CommonOptions["format"], -): Promise { - const files = await discoverFiles(paths); - const results = await Promise.all(files.map((file) => validator.validateFile(file))); - return printValidationResults(results, format); +async function validateFiles( + options: ValidateOptions, + files: string[], + validators: Map>, +): Promise { + return Promise.all( + files.map(async (file) => { + const projectConfig = await findProjectConfig(dirname(file)); + const schemaDirectory = + options.schemaDirectory ?? + process.env.PALSCHEMA_SCHEMA_DIR ?? + projectConfig?.schemaDirectory; + const allowMissingGenerated = + options.allowMissingGenerated ?? + projectConfig?.allowMissingGenerated ?? + false; + const key = JSON.stringify([ + schemaDirectory ? resolve(schemaDirectory) : null, + allowMissingGenerated, + ]); + let validator = validators.get(key); + if (!validator) { + validator = PalSchemaValidator.create(schemaDirectory, { + allowMissingGenerated, + }); + validators.set(key, validator); + } + return (await validator).validateFile(file); + }), + ); } -async function validationProjectConfig(paths: string[]) { - const currentProjectConfig = await findProjectConfig(); - if (currentProjectConfig || paths.length !== 1) { - return currentProjectConfig; - } - - const target = resolve(paths[0] ?? "."); - const metadata = await stat(target); - return findProjectConfig(metadata.isDirectory() ? target : dirname(target)); +async function validateOnce( + options: ValidateOptions, + validators: Map>, +): Promise { + return validateFiles( + options, + await discoverFiles(options.paths), + validators, + ); } async function validateCommand(arguments_: string[]): Promise { const options = parseValidateOptions(arguments_); - const projectConfig = await validationProjectConfig(options.paths); - const schemaDirectory = - options.schemaDirectory ?? - process.env.PALSCHEMA_SCHEMA_DIR ?? - projectConfig?.schemaDirectory; - const validator = await PalSchemaValidator.create(schemaDirectory, { - allowMissingGenerated: - options.allowMissingGenerated ?? - projectConfig?.allowMissingGenerated ?? - false, - }); - let exitCode = await validateOnce(validator, options.paths, options.format); + const validators = new Map>(); + const resultCache = new Map( + (await validateOnce(options, validators)).map((result) => [ + result.file, + result, + ]), + ); + const ndjson = options.watch && options.format === "json"; + let exitCode = printValidationResults( + [...resultCache.values()], + options.format, + ndjson, + ); if (!options.watch) { return exitCode; } const watcher = chokidar.watch(options.paths, { + followSymlinks: false, ignored: (path, metadata) => metadata?.isDirectory() === true && IGNORED_DIRECTORIES.has(path.split(/[\\/]/).at(-1) ?? ""), ignoreInitial: true, }); let pending: NodeJS.Timeout | undefined; - const rerun = (): void => { + let validationActive = false; + let validationRequested = false; + let activeValidation: Promise | undefined; + let fullValidationRequested = false; + const pendingFiles = new Map(); + + const runQueuedValidations = async (): Promise => { + if (validationActive) { + validationRequested = true; + return; + } + validationActive = true; + try { + do { + validationRequested = false; + const validateEverything = fullValidationRequested; + fullValidationRequested = false; + const changes = new Map(pendingFiles); + pendingFiles.clear(); + try { + if (validateEverything) { + resultCache.clear(); + for (const result of await validateOnce(options, validators)) { + resultCache.set(result.file, result); + } + } else { + const filesToValidate = [...changes] + .filter(([, action]) => action === "validate") + .map(([file]) => file); + for (const [file, action] of changes) { + if (action === "delete") { + resultCache.delete(file); + } + } + for (const result of await validateFiles( + options, + filesToValidate, + validators, + )) { + resultCache.set(result.file, result); + } + } + exitCode = printValidationResults( + [...resultCache.values()].sort((left, right) => + left.file.localeCompare(right.file), + ), + options.format, + options.format === "json", + ); + process.exitCode = exitCode; + } catch (error) { + if (options.format === "json") { + console.log( + JSON.stringify({ + event: "error", + message: error instanceof Error ? error.message : String(error), + }), + ); + } else { + console.error(error); + } + process.exitCode = 2; + } + } while (validationRequested); + } finally { + validationActive = false; + } + }; + + const rerun = (path: string, action: "validate" | "delete"): void => { + const absolutePath = resolve(path); + const name = absolutePath.split(/[\\/]/).at(-1) ?? ""; + if (name === "palschema.config.json" || !isJsonDocument(absolutePath)) { + fullValidationRequested = true; + } else if (!IGNORED_FILES.has(name)) { + pendingFiles.set(absolutePath, action); + } if (pending) { clearTimeout(pending); } - pending = setTimeout(async () => { - try { - exitCode = await validateOnce( - validator, - options.paths, - options.format, - ); - process.exitCode = exitCode; - } catch (error) { - console.error(error); - process.exitCode = 2; + pending = setTimeout(() => { + validationRequested = true; + if (!validationActive) { + activeValidation = runQueuedValidations(); } }, 100); }; - watcher.on("add", rerun).on("change", rerun).on("unlink", rerun); - console.log("Watching for PalSchema JSON/JSONC changes. Press Ctrl+C to stop."); + watcher + .on("add", (path) => rerun(path, "validate")) + .on("change", (path) => rerun(path, "validate")) + .on("unlink", (path) => rerun(path, "delete")); + if (options.format === "json") { + console.log(JSON.stringify({ event: "watch-started", paths: options.paths })); + } else { + console.log("Watching for PalSchema JSON/JSONC changes. Press Ctrl+C to stop."); + } await new Promise((resolvePromise) => { process.once("SIGINT", () => resolvePromise()); process.once("SIGTERM", () => resolvePromise()); }); + if (pending) { + clearTimeout(pending); + } + if (activeValidation) { + await activeValidation; + } await watcher.close(); return exitCode; } @@ -316,6 +449,7 @@ async function schemasCommand(arguments_: string[]): Promise { options.schemaDirectory ?? process.env.PALSCHEMA_SCHEMA_DIR ?? projectConfig?.schemaDirectory, + { verifyStatic: action !== "verify" }, ); if (action === "list") { @@ -362,6 +496,7 @@ async function doctorCommand(arguments_: string[]): Promise { options.schemaDirectory ?? process.env.PALSCHEMA_SCHEMA_DIR ?? projectConfig?.schemaDirectory, + { verifyStatic: false }, ); const verification = await registry.verify(); const report = { @@ -437,6 +572,8 @@ function editorSchemaMappings(registry: SchemaRegistry): Array<{ async function initCommand(arguments_: string[]): Promise { const options = parseInitOptions(arguments_); const registry = await SchemaRegistry.load(options.schemaDirectory); + await mkdir(options.destination, { recursive: true }); + options.destination = await realpath(options.destination); const schemaDestination = join( options.destination, ".palschema", @@ -446,6 +583,35 @@ async function initCommand(arguments_: string[]): Promise { const configPath = join(options.destination, "palschema.config.json"); const managedTargets = [settingsPath, configPath]; + const assertNoManagedSymlink = async (path: string): Promise => { + if (!isPathContained(options.destination, path)) { + throw new Error(`Managed init path escapes its workspace: ${path}`); + } + const relativePath = relative(options.destination, path); + let candidate = options.destination; + for (const segment of relativePath.split(sep).filter(Boolean)) { + candidate = join(candidate, segment); + try { + if ((await lstat(candidate)).isSymbolicLink()) { + throw new Error( + `Refusing to initialize through a symlink: ${candidate}`, + ); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return; + } + throw error; + } + } + }; + await assertNoManagedSymlink(join(options.destination, ".palschema")); + await assertNoManagedSymlink(schemaDestination); + await assertNoManagedSymlink(join(options.destination, ".vscode")); + for (const target of managedTargets) { + await assertNoManagedSymlink(target); + } + if (!options.force) { const conflicts = []; for (const target of managedTargets) { @@ -464,15 +630,17 @@ async function initCommand(arguments_: string[]): Promise { await mkdir(schemaDestination, { recursive: true }); await mkdir(join(options.destination, ".vscode"), { recursive: true }); + const destinationIndex = join(schemaDestination, "schema-index.json"); + await assertNoManagedSymlink(destinationIndex); await copyFile( join(registry.schemaDirectory, "schema-index.json"), - join(schemaDestination, "schema-index.json"), + destinationIndex, ); const copiedSchemas: string[] = ["schema-index.json"]; const missingGenerated: string[] = []; for (const entry of registry.index.schemas) { const source = registry.pathFor(entry); - const destination = join(schemaDestination, entry.file); + const destination = containedDestination(schemaDestination, entry.file); if (!(await pathExists(source))) { if (entry.generated) { missingGenerated.push(entry.file); @@ -480,8 +648,20 @@ async function initCommand(arguments_: string[]): Promise { } throw new Error(`Required schema is missing: ${source}`); } + await assertNoManagedSymlink(destination); await copyFile(source, destination); copiedSchemas.push(entry.file); + for (const supportFile of await registry.supportFilesFor(entry)) { + const supportSource = registry.pathForRelative(supportFile); + const supportDestination = containedDestination( + schemaDestination, + supportFile, + ); + await assertNoManagedSymlink(supportDestination); + await mkdir(dirname(supportDestination), { recursive: true }); + await copyFile(supportSource, supportDestination); + copiedSchemas.push(supportFile); + } } const settings = { diff --git a/tools/palschema-tools/src/configuration.ts b/tools/palschema-tools/src/configuration.ts index 6e31103..569e668 100644 --- a/tools/palschema-tools/src/configuration.ts +++ b/tools/palschema-tools/src/configuration.ts @@ -1,5 +1,5 @@ import { readFile } from "node:fs/promises"; -import { dirname, parse as parsePath, resolve } from "node:path"; +import { parse as parsePath, resolve } from "node:path"; import { parse, type ParseError, printParseErrorCode } from "jsonc-parser"; diff --git a/tools/palschema-tools/src/jsonc-document.ts b/tools/palschema-tools/src/jsonc-document.ts index 294827d..c6d6535 100644 --- a/tools/palschema-tools/src/jsonc-document.ts +++ b/tools/palschema-tools/src/jsonc-document.ts @@ -4,21 +4,24 @@ import { printParseErrorCode, type ParseError, } from "jsonc-parser"; +import { extname } from "node:path"; -import { positionAt } from "./text-position.js"; +import { createPositionMapper } from "./text-position.js"; import type { PalSchemaDiagnostic, ParsedDocument } from "./types.js"; export function parseJsoncDocument(text: string, file: string): ParsedDocument { const errors: ParseError[] = []; + const isJsonc = extname(file).toLowerCase() === ".jsonc"; const options = { allowEmptyContent: false, - allowTrailingComma: true, - disallowComments: false, + allowTrailingComma: false, + disallowComments: !isJsonc, }; const data = parse(text, errors, options) as unknown; const root = parseTree(text, [], options); + const positionAt = createPositionMapper(text); const diagnostics: PalSchemaDiagnostic[] = errors.map((error) => ({ - ...positionAt(text, error.offset, error.length), + ...positionAt(error.offset, error.length), code: `PS_JSON_${printParseErrorCode(error.error).toUpperCase()}`, severity: "error", message: printParseErrorCode(error.error), diff --git a/tools/palschema-tools/src/lsp-options.ts b/tools/palschema-tools/src/lsp-options.ts new file mode 100644 index 0000000..d8de62b --- /dev/null +++ b/tools/palschema-tools/src/lsp-options.ts @@ -0,0 +1,38 @@ +export interface PalSchemaInitializationOptions { + schemaDirectory?: string; + allowMissingGenerated?: boolean; +} + +export function parseInitializationOptions( + value: unknown, +): PalSchemaInitializationOptions { + if (value === undefined || value === null) { + return {}; + } + if (typeof value !== "object" || Array.isArray(value)) { + throw new Error("PalSchema initializationOptions must be an object."); + } + const candidate = value as Record; + if ( + candidate.schemaDirectory !== undefined && + typeof candidate.schemaDirectory !== "string" + ) { + throw new Error("initializationOptions.schemaDirectory must be a string."); + } + if ( + candidate.allowMissingGenerated !== undefined && + typeof candidate.allowMissingGenerated !== "boolean" + ) { + throw new Error( + "initializationOptions.allowMissingGenerated must be a boolean.", + ); + } + return { + ...(candidate.schemaDirectory !== undefined + ? { schemaDirectory: candidate.schemaDirectory } + : {}), + ...(candidate.allowMissingGenerated !== undefined + ? { allowMissingGenerated: candidate.allowMissingGenerated } + : {}), + }; +} diff --git a/tools/palschema-tools/src/lsp-server.ts b/tools/palschema-tools/src/lsp-server.ts index ad2a650..17135a2 100644 --- a/tools/palschema-tools/src/lsp-server.ts +++ b/tools/palschema-tools/src/lsp-server.ts @@ -2,7 +2,7 @@ import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { fileURLToPath } from "node:url"; import { getLanguageService, @@ -20,16 +20,17 @@ import { TextDocument } from "vscode-languageserver-textdocument"; import { TextDocuments } from "vscode-languageserver/node"; import { SchemaRegistry } from "./schema-registry.js"; +import { + parseInitializationOptions, + type PalSchemaInitializationOptions, +} from "./lsp-options.js"; +import { isPathContained } from "./path-security.js"; import type { PalSchemaDiagnostic } from "./types.js"; import { PalSchemaValidator } from "./validator.js"; -interface PalSchemaInitializationOptions { - schemaDirectory?: string; - allowMissingGenerated?: boolean; -} - const connection = createConnection(ProposedFeatures.all); const documents = new TextDocuments(TextDocument); +const validationTimers = new Map(); let registry: SchemaRegistry; let validator: PalSchemaValidator; @@ -74,11 +75,23 @@ async function configureServices(): Promise { }, ); jsonLanguageService = getLanguageService({ + workspaceContext: { + resolveRelativePath: (relativePath, resource) => + new URL(relativePath, resource).toString(), + }, schemaRequestService: async (uri) => { - if (!uri.startsWith("file:")) { - throw new Error(`Unsupported schema URI: ${uri}`); + if (uri.startsWith("file:")) { + const path = fileURLToPath(uri); + if (!isPathContained(registry.schemaDirectory, path)) { + throw new Error(`Schema URI escapes the configured pack: ${uri}`); + } + return readFile(path, "utf8"); + } + const localPath = await registry.pathForId(uri); + if (!localPath) { + throw new Error(`Schema URI is not in the configured pack: ${uri}`); } - return readFile(fileURLToPath(uri), "utf8"); + return readFile(localPath, "utf8"); }, }); jsonLanguageService.configure({ @@ -86,18 +99,23 @@ async function configureServices(): Promise { schemas: registry.index.schemas .filter((entry) => existsSync(registry.pathFor(entry))) .map((entry) => ({ - uri: pathToFileURL(registry.pathFor(entry)).toString(), + uri: entry.id, fileMatch: entry.patterns, })), }); } async function validateDocument(document: TextDocument): Promise { + const version = document.version; try { const result = await validator.validateText( document.getText(), fileForUri(document.uri), ); + const current = documents.get(document.uri); + if (!current || current.version !== version) { + return; + } connection.sendDiagnostics({ uri: document.uri, diagnostics: result.diagnostics.map((diagnostic) => @@ -111,9 +129,24 @@ async function validateDocument(document: TextDocument): Promise { } } +function scheduleValidation(document: TextDocument): void { + const existing = validationTimers.get(document.uri); + if (existing) { + clearTimeout(existing); + } + validationTimers.set( + document.uri, + setTimeout(() => { + validationTimers.delete(document.uri); + void validateDocument(document); + }, 100), + ); +} + connection.onInitialize(async (params: InitializeParams): Promise => { - initializationOptions = - (params.initializationOptions as PalSchemaInitializationOptions | null) ?? {}; + initializationOptions = parseInitializationOptions( + params.initializationOptions, + ); await configureServices(); return { capabilities: { @@ -194,8 +227,13 @@ connection.onDocumentRangeFormatting((params) => { }); documents.onDidOpen(({ document }) => void validateDocument(document)); -documents.onDidChangeContent(({ document }) => void validateDocument(document)); +documents.onDidChangeContent(({ document }) => scheduleValidation(document)); documents.onDidClose(({ document }) => { + const pending = validationTimers.get(document.uri); + if (pending) { + clearTimeout(pending); + validationTimers.delete(document.uri); + } connection.sendDiagnostics({ uri: document.uri, diagnostics: [] }); }); diff --git a/tools/palschema-tools/src/path-security.ts b/tools/palschema-tools/src/path-security.ts new file mode 100644 index 0000000..3033872 --- /dev/null +++ b/tools/palschema-tools/src/path-security.ts @@ -0,0 +1,23 @@ +import { isAbsolute, relative, resolve, sep } from "node:path"; + +export function isPathContained(root: string, candidate: string): boolean { + const pathFromRoot = relative(root, candidate); + return ( + pathFromRoot !== "" && + pathFromRoot !== ".." && + !pathFromRoot.startsWith(`..${sep}`) && + !isAbsolute(pathFromRoot) + ); +} + +export function resolveContainedPath( + root: string, + file: string, + label: string, +): string { + const candidate = resolve(root, file); + if (!isPathContained(root, candidate)) { + throw new Error(`${label}: ${file}`); + } + return candidate; +} diff --git a/tools/palschema-tools/src/schema-registry.ts b/tools/palschema-tools/src/schema-registry.ts index d622f15..d0de797 100644 --- a/tools/palschema-tools/src/schema-registry.ts +++ b/tools/palschema-tools/src/schema-registry.ts @@ -1,7 +1,14 @@ import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { dirname, relative, resolve, sep } from "node:path"; +import { lstat, readFile, readdir, realpath } from "node:fs/promises"; +import { + basename, + dirname, + isAbsolute, + relative, + resolve, + sep, +} from "node:path"; import { fileURLToPath } from "node:url"; import type { @@ -9,6 +16,10 @@ import type { SchemaIndexEntry, SchemaVerification, } from "./types.js"; +import { + isPathContained, + resolveContainedPath, +} from "./path-security.js"; const moduleDirectory = dirname(fileURLToPath(import.meta.url)); const packageDirectory = resolve(moduleDirectory, ".."); @@ -40,23 +51,125 @@ function assertSchemaIndex(value: unknown): asserts value is SchemaIndex { ) { throw new Error("Unsupported or malformed schema-index.json."); } + + const files = new Set(); + const ids = new Set(); + for (const candidate of value.schemas as unknown[]) { + const entry = candidate as Record; + if ( + typeof candidate !== "object" || + candidate === null || + !("id" in candidate) || + typeof candidate.id !== "string" || + !("file" in candidate) || + typeof candidate.file !== "string" || + !("folder" in candidate) || + (candidate.folder !== null && typeof candidate.folder !== "string") || + !("patterns" in candidate) || + !Array.isArray(candidate.patterns) || + !candidate.patterns.every((pattern) => typeof pattern === "string") || + !("generated" in candidate) || + typeof candidate.generated !== "boolean" || + !("dependencies" in candidate) || + !Array.isArray(candidate.dependencies) || + !candidate.dependencies.every( + (dependency) => typeof dependency === "string", + ) || + !("sha256" in candidate) || + (candidate.sha256 !== null && typeof candidate.sha256 !== "string") + ) { + throw new Error("schema-index.json contains a malformed schema entry."); + } + if (!("supportPatterns" in entry)) { + entry.supportPatterns = []; + } + if ( + !Array.isArray(entry.supportPatterns) || + !entry.supportPatterns.every( + (pattern: unknown) => typeof pattern === "string", + ) + ) { + throw new Error("schema-index.json contains invalid supportPatterns."); + } + assertSingleFileName(candidate.file, "schema file"); + for (const dependency of candidate.dependencies) { + assertSingleFileName(dependency, "schema dependency"); + } + for (const pattern of entry.supportPatterns as string[]) { + assertSupportPattern(pattern); + } + if (files.has(candidate.file) || ids.has(candidate.id)) { + throw new Error("schema-index.json contains duplicate files or IDs."); + } + files.add(candidate.file); + ids.add(candidate.id); + } + + for (const entry of value.schemas) { + for (const dependency of entry.dependencies) { + if (!files.has(dependency)) { + throw new Error( + `schema-index.json dependency is not declared: ${dependency}`, + ); + } + } + } +} + +function assertSingleFileName(value: string, label: string): void { + if ( + !value || + value === "." || + value === ".." || + isAbsolute(value) || + basename(value) !== value || + value.includes("/") || + value.includes("\\") + ) { + throw new Error(`Invalid ${label} path in schema-index.json: ${value}`); + } +} + +function assertSupportPattern(pattern: string): void { + const segments = pattern.split("/"); + if ( + segments.length !== 2 || + !segments[0] || + segments[0] === "." || + segments[0] === ".." || + segments[0].includes("\\") || + segments[1] !== "*.schema.json" + ) { + throw new Error( + `Unsupported schema support pattern in schema-index.json: ${pattern}`, + ); + } } export class SchemaRegistry { readonly schemaDirectory: string; readonly index: SchemaIndex; + private readonly supportFileCache = new Map>(); + private supportPathIndex: Promise> | undefined; private constructor(schemaDirectory: string, index: SchemaIndex) { this.schemaDirectory = schemaDirectory; this.index = index; } - static async load(schemaDirectory = defaultSchemaDirectory()): Promise { + static async load( + schemaDirectory = defaultSchemaDirectory(), + options: { verifyStatic?: boolean } = {}, + ): Promise { const absoluteDirectory = resolve(schemaDirectory); const indexPath = resolve(absoluteDirectory, "schema-index.json"); const index = JSON.parse(await readFile(indexPath, "utf8")) as unknown; assertSchemaIndex(index); - return new SchemaRegistry(absoluteDirectory, index); + const registry = new SchemaRegistry(absoluteDirectory, index); + if (options.verifyStatic ?? true) { + await registry.assertStaticIntegrity(); + } + return registry; } findForDocument(file: string): SchemaIndexEntry | null { @@ -77,8 +190,21 @@ export class SchemaRegistry { return this.index.schemas.find((entry) => entry.file === file) ?? null; } + entryForId(id: string): SchemaIndexEntry | null { + return this.index.schemas.find((entry) => entry.id === id) ?? null; + } + + pathForRelative(file: string): string { + return resolveContainedPath( + this.schemaDirectory, + file, + "Schema path escapes its pack", + ); + } + pathFor(entry: SchemaIndexEntry): string { - return resolve(this.schemaDirectory, entry.file); + assertSingleFileName(entry.file, "schema file"); + return this.pathForRelative(entry.file); } async readSchema(entry: SchemaIndexEntry): Promise> { @@ -88,6 +214,105 @@ export class SchemaRegistry { >; } + async supportFilesFor(entry: SchemaIndexEntry): Promise { + const cached = this.supportFileCache.get(entry.id); + if (cached) { + return cached; + } + const pending = this.discoverSupportFiles(entry); + this.supportFileCache.set(entry.id, pending); + try { + return await pending; + } catch (error) { + this.supportFileCache.delete(entry.id); + throw error; + } + } + + private async discoverSupportFiles( + entry: SchemaIndexEntry, + ): Promise { + const files: string[] = []; + for (const pattern of entry.supportPatterns) { + assertSupportPattern(pattern); + const directoryName = pattern.split("/")[0] as string; + const directory = this.pathForRelative(directoryName); + let children; + try { + children = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + continue; + } + throw error; + } + for (const child of children) { + if (!child.name.endsWith(".schema.json")) { + continue; + } + const relativeFile = `${directoryName}/${child.name}`; + const path = this.pathForRelative(relativeFile); + const metadata = await lstat(path); + if (!metadata.isFile() || metadata.isSymbolicLink()) { + throw new Error(`Schema support file must be a regular file: ${path}`); + } + const canonical = await realpath(path); + if (!isPathContained(this.schemaDirectory, canonical)) { + throw new Error(`Schema support file escapes its pack: ${path}`); + } + files.push(relativeFile); + } + } + return [...new Set(files)].sort(); + } + + canonicalIdForSupport(entry: SchemaIndexEntry, file: string): string { + return new URL(file, entry.id).toString(); + } + + async pathForId(id: string): Promise { + const normalized = new URL(id); + normalized.hash = ""; + const entry = this.entryForId(normalized.toString()); + if (entry) { + return this.pathFor(entry); + } + this.supportPathIndex ??= this.buildSupportPathIndex(); + const supportPath = (await this.supportPathIndex).get( + normalized.toString(), + ); + return supportPath ?? null; + } + + private async buildSupportPathIndex(): Promise> { + const paths = new Map(); + for (const candidate of this.index.schemas) { + for (const supportFile of await this.supportFilesFor(candidate)) { + paths.set( + this.canonicalIdForSupport(candidate, supportFile), + this.pathForRelative(supportFile), + ); + } + } + return paths; + } + + async assertStaticIntegrity(): Promise { + const failures = (await this.verify()).filter( + (result) => + !result.entry.generated && + (result.status === "missing" || + result.status === "checksum-mismatch"), + ); + if (failures.length > 0) { + throw new Error( + `Static schema integrity check failed: ${failures + .map((failure) => `${failure.entry.file} (${failure.status})`) + .join(", ")}`, + ); + } + } + async verify(): Promise { return Promise.all( this.index.schemas.map(async (entry): Promise => { diff --git a/tools/palschema-tools/src/text-position.ts b/tools/palschema-tools/src/text-position.ts index 1d1656d..06f6b44 100644 --- a/tools/palschema-tools/src/text-position.ts +++ b/tools/palschema-tools/src/text-position.ts @@ -1,23 +1,42 @@ -export function positionAt( - text: string, +export type PositionMapper = ( offset: number, - length = 1, -): { line: number; column: number; offset: number; length: number } { - const boundedOffset = Math.max(0, Math.min(offset, text.length)); - let line = 1; - let lastLineStart = 0; + length?: number, +) => { line: number; column: number; offset: number; length: number }; - for (let index = 0; index < boundedOffset; index += 1) { +export function createPositionMapper(text: string): PositionMapper { + const lineStarts = [0]; + for (let index = 0; index < text.length; index += 1) { if (text.charCodeAt(index) === 10) { - line += 1; - lastLineStart = index + 1; + lineStarts.push(index + 1); } } - return { - line, - column: boundedOffset - lastLineStart + 1, - offset: boundedOffset, - length: Math.max(1, length), + return (offset: number, length = 1) => { + const boundedOffset = Math.max(0, Math.min(offset, text.length)); + let low = 0; + let high = lineStarts.length; + while (low + 1 < high) { + const middle = Math.floor((low + high) / 2); + if ((lineStarts[middle] ?? 0) <= boundedOffset) { + low = middle; + } else { + high = middle; + } + } + const lineStart = lineStarts[low] ?? 0; + return { + line: low + 1, + column: boundedOffset - lineStart + 1, + offset: boundedOffset, + length: Math.max(1, length), + }; }; } + +export function positionAt( + text: string, + offset: number, + length = 1, +): ReturnType { + return createPositionMapper(text)(offset, length); +} diff --git a/tools/palschema-tools/src/types.ts b/tools/palschema-tools/src/types.ts index fc49aa0..3f195c8 100644 --- a/tools/palschema-tools/src/types.ts +++ b/tools/palschema-tools/src/types.ts @@ -26,6 +26,7 @@ export interface SchemaIndexEntry { patterns: string[]; generated: boolean; dependencies: string[]; + supportPatterns: string[]; sha256: string | null; } @@ -61,7 +62,6 @@ export interface ValidationResult { export interface AjvDiagnosticContext { error: ErrorObject; - text: string; root: import("jsonc-parser").Node | undefined; file: string; } diff --git a/tools/palschema-tools/src/validator.ts b/tools/palschema-tools/src/validator.ts index 82264bc..eb2c5ec 100644 --- a/tools/palschema-tools/src/validator.ts +++ b/tools/palschema-tools/src/validator.ts @@ -10,7 +10,10 @@ import { findNodeAtLocation, type Node as JsonNode } from "jsonc-parser"; import { parseJsoncDocument } from "./jsonc-document.js"; import { SchemaRegistry } from "./schema-registry.js"; -import { positionAt } from "./text-position.js"; +import { + createPositionMapper, + type PositionMapper, +} from "./text-position.js"; import type { AjvDiagnosticContext, PalSchemaDiagnostic, @@ -44,10 +47,13 @@ function nodeForError( return node ?? root; } -function ajvDiagnostic(context: AjvDiagnosticContext): PalSchemaDiagnostic { - const { error, text, root, file } = context; +function ajvDiagnostic( + context: AjvDiagnosticContext, + positionAt: PositionMapper, +): PalSchemaDiagnostic { + const { error, root, file } = context; const node = nodeForError(root, error); - const location = positionAt(text, node?.offset ?? 0, node?.length ?? 1); + const location = positionAt(node?.offset ?? 0, node?.length ?? 1); let message = error.message ?? "Schema validation failed."; if (error.keyword === "required") { const missingProperty = String(error.params.missingProperty); @@ -82,6 +88,7 @@ function referencedEnumDefinitions(schema: unknown): string[] { export class PalSchemaValidator { readonly registry: SchemaRegistry; private readonly validators = new Map(); + private readonly compilations = new Map>(); private readonly fallbackEnumEntries = new Set(); private readonly allowMissingGenerated: boolean; @@ -105,24 +112,48 @@ export class PalSchemaValidator { if (existing) { return existing; } + const pending = this.compilations.get(entry.id); + if (pending) { + return pending; + } + const compilation = this.compileSchema(entry); + this.compilations.set(entry.id, compilation); + try { + return await compilation; + } finally { + if (this.compilations.get(entry.id) === compilation) { + this.compilations.delete(entry.id); + } + } + } + private async compileSchema( + entry: SchemaIndexEntry, + ): Promise { const schema = await this.registry.readSchema(entry); + schema.$id ??= entry.id; const ajv = new Ajv({ allErrors: true, strict: false, validateFormats: false, }); - if (entry.dependencies.includes("enums.schema.json")) { - const enumsEntry = this.registry.entryForFile("enums.schema.json"); - if (!enumsEntry) { - throw new Error("schema-index.json does not declare enums.schema.json."); + for (const dependencyFile of entry.dependencies) { + const dependency = this.registry.entryForFile(dependencyFile); + if (!dependency) { + throw new Error( + `schema-index.json does not declare ${dependencyFile}.`, + ); } - try { - ajv.addSchema(await this.registry.readSchema(enumsEntry), enumsEntry.id); + const dependencySchema = await this.registry.readSchema(dependency); + dependencySchema.$id ??= dependency.id; + ajv.addSchema(dependencySchema, dependency.id); } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + if ( + (error as NodeJS.ErrnoException).code !== "ENOENT" || + dependencyFile !== "enums.schema.json" + ) { throw error; } this.fallbackEnumEntries.add(entry.id); @@ -132,14 +163,26 @@ export class PalSchemaValidator { ajv.addSchema( { $schema: "http://json-schema.org/draft-07/schema#", - $id: enumsEntry.id, + $id: dependency.id, definitions, }, - enumsEntry.id, + dependency.id, ); } } + for (const supportFile of await this.registry.supportFilesFor(entry)) { + const supportSchema = JSON.parse( + await readFile(this.registry.pathForRelative(supportFile), "utf8"), + ) as Record; + const canonicalId = this.registry.canonicalIdForSupport( + entry, + supportFile, + ); + supportSchema.$id ??= canonicalId; + ajv.addSchema(supportSchema, canonicalId); + } + const validate = ajv.compile(schema); this.validators.set(entry.id, validate); return validate; @@ -147,6 +190,7 @@ export class PalSchemaValidator { async validateText(text: string, file: string): Promise { const parsed = parseJsoncDocument(text, file); + const positionAt = createPositionMapper(text); const entry = this.registry.findForDocument(file); if (parsed.diagnostics.length > 0) { return { file, schema: entry, diagnostics: parsed.diagnostics }; @@ -157,7 +201,7 @@ export class PalSchemaValidator { schema: null, diagnostics: [ { - ...positionAt(text, 0), + ...positionAt(0), code: "PS_SCHEMA_UNASSOCIATED", severity: "warning", message: @@ -179,7 +223,7 @@ export class PalSchemaValidator { schema: entry, diagnostics: [ { - ...positionAt(text, 0), + ...positionAt(0), code: "PS_SCHEMA_GENERATED_MISSING", severity: this.allowMissingGenerated ? "warning" : "error", message: @@ -198,11 +242,11 @@ export class PalSchemaValidator { const validate = await this.compile(entry); const valid = validate(parsed.data); const diagnostics = (validate.errors ?? []).map((error) => - ajvDiagnostic({ error, text, root: parsed.root, file }), + ajvDiagnostic({ error, root: parsed.root, file }, positionAt), ); if (this.fallbackEnumEntries.has(entry.id)) { diagnostics.push({ - ...positionAt(text, 0), + ...positionAt(0), code: "PS_SCHEMA_ENUMS_MISSING", severity: this.allowMissingGenerated ? "warning" : "error", message: @@ -213,7 +257,7 @@ export class PalSchemaValidator { } if (!valid && diagnostics.length === 0) { diagnostics.push({ - ...positionAt(text, 0), + ...positionAt(0), code: "PS_SCHEMA_INVALID", severity: "error", message: "Schema validation failed without a detailed Ajv error.", diff --git a/tools/palschema-tools/test/cli-smoke.test.mjs b/tools/palschema-tools/test/cli-smoke.test.mjs index 9b1a6c1..fae30d1 100644 --- a/tools/palschema-tools/test/cli-smoke.test.mjs +++ b/tools/palschema-tools/test/cli-smoke.test.mjs @@ -1,5 +1,14 @@ import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { dirname, resolve } from "node:path"; import { spawnSync } from "node:child_process"; @@ -8,6 +17,7 @@ import test from "node:test"; const testDirectory = dirname(fileURLToPath(import.meta.url)); const packageRoot = resolve(testDirectory, ".."); +const repositoryRoot = resolve(packageRoot, "../.."); const cliSource = resolve(packageRoot, "src/cli.ts"); const tsxImport = import.meta.resolve("tsx"); @@ -53,3 +63,162 @@ test("init creates a workspace that validate can check without support-file nois rmSync(workspace, { recursive: true, force: true }); } }); + +test("validation resolves configuration from each explicit target", () => { + const temporaryRoot = mkdtempSync(resolve(tmpdir(), "palschema-config-target-")); + try { + const caller = resolve(temporaryRoot, "caller"); + const target = resolve(temporaryRoot, "target"); + mkdirSync(caller); + mkdirSync(resolve(target, "items"), { recursive: true }); + writeFileSync( + resolve(caller, "palschema.config.json"), + '{"schemaDirectory":"missing-schemas"}\n', + ); + writeFileSync( + resolve(target, "palschema.config.json"), + '{"allowMissingGenerated":true}\n', + ); + writeFileSync( + resolve(target, "items", "example.json"), + '{"Example":{"Type":"Generic","IconTexture":"/Game/Test/T.T","TypeA":"Any","TypeB":"Any","Rank":0,"Rarity":0,"MaxStackCount":1}}\n', + ); + + const validation = runCli(["validate", target], caller); + assert.equal(validation.status, 0, validation.stderr); + assert.match(validation.stdout, /PS_SCHEMA_ENUMS_MISSING.*warning/); + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } +}); + +test("recursive discovery terminates across a directory symlink cycle", () => { + const workspace = mkdtempSync(resolve(tmpdir(), "palschema-cycle-")); + try { + mkdirSync(resolve(workspace, "items")); + writeFileSync(resolve(workspace, "items", "data.json"), "{}\n"); + symlinkSync(workspace, resolve(workspace, "items", "cycle"), "dir"); + const validation = runCli( + ["validate", "--allow-missing-generated", workspace], + packageRoot, + ); + assert.equal(validation.status, 0, validation.stderr); + assert.match(validation.stdout, /Validated 1 file\(s\)/); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } +}); + +test("init rejects schema-index traversal before copying", () => { + const temporaryRoot = mkdtempSync(resolve(tmpdir(), "palschema-init-path-")); + try { + const schemas = resolve(temporaryRoot, "schemas"); + const workspace = resolve(temporaryRoot, "workspace"); + cpSync(resolve(repositoryRoot, "assets/schemas"), schemas, { + recursive: true, + }); + mkdirSync(workspace); + const indexPath = resolve(schemas, "schema-index.json"); + const index = JSON.parse(readFileSync(indexPath, "utf8")); + index.schemas[0].file = "../../escaped.json"; + writeFileSync(indexPath, `${JSON.stringify(index)}\n`); + + const initialization = runCli( + ["init", "--schema-dir", schemas, workspace], + packageRoot, + ); + assert.equal(initialization.status, 2); + assert.match(initialization.stderr, /Invalid schema file path/); + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } +}); + +test("init copies generated raw schema support files", () => { + const temporaryRoot = mkdtempSync(resolve(tmpdir(), "palschema-init-raw-")); + try { + const schemas = resolve(temporaryRoot, "schemas"); + const workspace = resolve(temporaryRoot, "workspace"); + cpSync(resolve(repositoryRoot, "assets/schemas"), schemas, { + recursive: true, + }); + mkdirSync(resolve(schemas, "raw")); + mkdirSync(workspace); + writeFileSync( + resolve(schemas, "raw.schema.json"), + '{"type":"object","properties":{"Table":{"$ref":"raw/Table.schema.json"}}}\n', + ); + writeFileSync( + resolve(schemas, "raw", "Table.schema.json"), + '{"type":"object"}\n', + ); + + const initialization = runCli( + ["init", "--schema-dir", schemas, workspace], + packageRoot, + ); + assert.equal(initialization.status, 0, initialization.stderr); + assert.equal( + existsSync( + resolve( + workspace, + ".palschema", + "schemas", + "raw", + "Table.schema.json", + ), + ), + true, + ); + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } +}); + +test("init refuses a symlinked managed parent", () => { + const temporaryRoot = mkdtempSync(resolve(tmpdir(), "palschema-init-link-")); + try { + const workspace = resolve(temporaryRoot, "workspace"); + const outside = resolve(temporaryRoot, "outside"); + mkdirSync(workspace); + mkdirSync(outside); + symlinkSync(outside, resolve(workspace, ".palschema"), "dir"); + + const initialization = runCli(["init", workspace], packageRoot); + assert.equal(initialization.status, 2); + assert.match(initialization.stderr, /symlink/); + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } +}); + +test("init --force refuses symlinked managed files", () => { + for (const relativeTarget of [ + ".vscode/settings.json", + "palschema.config.json", + ]) { + const temporaryRoot = mkdtempSync( + resolve(tmpdir(), "palschema-init-file-link-"), + ); + try { + const workspace = resolve(temporaryRoot, "workspace"); + const outside = resolve(temporaryRoot, "outside.txt"); + mkdirSync(workspace); + if (relativeTarget.startsWith(".vscode/")) { + mkdirSync(resolve(workspace, ".vscode")); + } + writeFileSync(outside, "sentinel\n"); + symlinkSync(outside, resolve(workspace, relativeTarget), "file"); + + const initialization = runCli( + ["init", "--force", workspace], + packageRoot, + ); + assert.equal(initialization.status, 2); + assert.match(initialization.stderr, /symlink/); + assert.equal(readFileSync(outside, "utf8"), "sentinel\n"); + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } + } +}); diff --git a/tools/palschema-tools/test/jsonc-document.test.ts b/tools/palschema-tools/test/jsonc-document.test.ts index 50bd2d2..c443937 100644 --- a/tools/palschema-tools/test/jsonc-document.test.ts +++ b/tools/palschema-tools/test/jsonc-document.test.ts @@ -3,19 +3,30 @@ import test from "node:test"; import { parseJsoncDocument } from "../src/jsonc-document.js"; -test("accepts comments and trailing commas", () => { +test("accepts comments in JSONC but rejects trailing commas", () => { const parsed = parseJsoncDocument( `{ // PalSchema supports JSONC. "Example": { - "Value": 1, - }, + "Value": 1 + } }`, "example.jsonc", ); assert.deepEqual(parsed.diagnostics, []); assert.deepEqual(parsed.data, { Example: { Value: 1 } }); + + const trailing = parseJsoncDocument('{"Value": 1,}', "example.jsonc"); + assert.equal(trailing.diagnostics.length > 0, true); +}); + +test("rejects comments in strict JSON files", () => { + const parsed = parseJsoncDocument( + '{\n // JSON comments are runtime-incompatible.\n "Value": 1\n}', + "example.json", + ); + assert.equal(parsed.diagnostics.length > 0, true); }); test("reports one-based source positions for syntax errors", () => { diff --git a/tools/palschema-tools/test/lsp-options.test.ts b/tools/palschema-tools/test/lsp-options.test.ts new file mode 100644 index 0000000..3792538 --- /dev/null +++ b/tools/palschema-tools/test/lsp-options.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { parseInitializationOptions } from "../src/lsp-options.js"; + +test("validates LSP initialization options at runtime", () => { + assert.deepEqual(parseInitializationOptions(null), {}); + assert.deepEqual( + parseInitializationOptions({ + schemaDirectory: "/tmp/schemas", + allowMissingGenerated: false, + }), + { + schemaDirectory: "/tmp/schemas", + allowMissingGenerated: false, + }, + ); + for (const invalid of [ + [], + 1, + "options", + { schemaDirectory: 7 }, + { allowMissingGenerated: "false" }, + ]) { + assert.throws(() => parseInitializationOptions(invalid)); + } +}); diff --git a/tools/palschema-tools/test/lsp-smoke.test.mjs b/tools/palschema-tools/test/lsp-smoke.test.mjs index 8241981..940d6d5 100644 --- a/tools/palschema-tools/test/lsp-smoke.test.mjs +++ b/tools/palschema-tools/test/lsp-smoke.test.mjs @@ -1,5 +1,7 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; +import { cp, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { dirname, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import test from "node:test"; @@ -14,6 +16,28 @@ function encodeMessage(message) { } test("serves diagnostics and schema features over LSP", async (context) => { + const temporarySchemaDirectory = await mkdtemp( + resolve(tmpdir(), "palschema-lsp-schemas-"), + ); + await cp( + resolve(repositoryRoot, "assets/schemas"), + temporarySchemaDirectory, + { recursive: true }, + ); + await writeFile( + resolve(temporarySchemaDirectory, "enums.schema.json"), + JSON.stringify({ + $schema: "http://json-schema.org/draft-07/schema#", + $id: "https://okaetsu.github.io/PalSchema/schemas/0.6.1/enums.schema.json", + definitions: { + EPalItemTypeA: { type: "string", enum: ["CanaryTypeA"] }, + EPalItemTypeB: { type: "string", enum: ["CanaryTypeB"] }, + }, + }), + ); + context.after(async () => { + await rm(temporarySchemaDirectory, { recursive: true, force: true }); + }); const child = spawn( process.execPath, ["--import", "tsx", "src/lsp-server.ts", "--stdio"], @@ -102,7 +126,7 @@ test("serves diagnostics and schema features over LSP", async (context) => { rootUri: pathToFileURL(repositoryRoot).toString(), capabilities: {}, initializationOptions: { - schemaDirectory: resolve(repositoryRoot, "assets/schemas"), + schemaDirectory: temporarySchemaDirectory, allowMissingGenerated: true, }, }, @@ -185,6 +209,52 @@ test("serves diagnostics and schema features over LSP", async (context) => { true, ); + const enumUri = pathToFileURL( + resolve(repositoryRoot, "fixture/items/enum-completion.jsonc"), + ).toString(); + send({ + jsonrpc: "2.0", + method: "textDocument/didOpen", + params: { + textDocument: { + uri: enumUri, + languageId: "jsonc", + version: 1, + text: '{\n "Example": {\n "TypeA": ""\n }\n}\n', + }, + }, + }); + await waitFor( + (message) => + message.method === "textDocument/publishDiagnostics" && + message.params?.uri === enumUri, + "enum completion document diagnostics", + ); + send({ + jsonrpc: "2.0", + id: 20, + method: "textDocument/completion", + params: { + textDocument: { uri: enumUri }, + position: { line: 2, character: 14 }, + }, + }); + const enumCompletion = await waitFor( + (message) => message.id === 20, + "offline enum completion response", + ); + const enumItems = Array.isArray(enumCompletion.result) + ? enumCompletion.result + : enumCompletion.result?.items ?? []; + assert.equal( + enumItems.some( + (item) => + item.label === '"CanaryTypeA"' && + item.textEdit?.newText === '"CanaryTypeA"', + ), + true, + ); + const hoverText = '{\n "Example": {\n "Rarity": 1\n }\n}\n'; send({ jsonrpc: "2.0", @@ -215,6 +285,47 @@ test("serves diagnostics and schema features over LSP", async (context) => { ); assert.match(JSON.stringify(hover.result), /background color/i); + const closedUri = pathToFileURL( + resolve(repositoryRoot, "fixture/items/closed.jsonc"), + ).toString(); + send({ + jsonrpc: "2.0", + method: "textDocument/didOpen", + params: { + textDocument: { + uri: closedUri, + languageId: "jsonc", + version: 1, + text: '{\n "Broken": { "Rarity": 99 }\n}\n', + }, + }, + }); + send({ + jsonrpc: "2.0", + method: "textDocument/didClose", + params: { textDocument: { uri: closedUri } }, + }); + const cleared = await waitFor( + (message) => + message.method === "textDocument/publishDiagnostics" && + message.params?.uri === closedUri && + message.params?.diagnostics?.length === 0, + "closed document diagnostics clear", + ); + const clearIndex = received.indexOf(cleared); + await new Promise((resolvePromise) => setTimeout(resolvePromise, 200)); + assert.equal( + received + .slice(clearIndex + 1) + .some( + (message) => + message.method === "textDocument/publishDiagnostics" && + message.params?.uri === closedUri && + message.params?.diagnostics?.length > 0, + ), + false, + ); + send({ jsonrpc: "2.0", id: 4, method: "shutdown", params: null }); await waitFor((message) => message.id === 4, "shutdown response"); send({ jsonrpc: "2.0", method: "exit", params: null }); diff --git a/tools/palschema-tools/test/public-schema-copy.test.mjs b/tools/palschema-tools/test/public-schema-copy.test.mjs new file mode 100644 index 0000000..ba12b61 --- /dev/null +++ b/tools/palschema-tools/test/public-schema-copy.test.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import { + cp, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; + +import { copyPublicSchemas } from "../../../scripts/copy-public-schemas.mjs"; + +const repositoryRoot = resolve(import.meta.dirname, "../../.."); + +test("public schema packaging rejects generated Palworld data", async () => { + const temporaryRoot = await mkdtemp(join(tmpdir(), "palschema-public-pack-")); + try { + const source = join(temporaryRoot, "source"); + const destination = join(temporaryRoot, "destination"); + await cp(resolve(repositoryRoot, "assets/schemas"), source, { + recursive: true, + }); + await writeFile( + join(source, "enums.schema.json"), + '{"definitions":{"CanaryPrivateGameValue":{"enum":["secret"]}}}\n', + ); + await assert.rejects( + copyPublicSchemas(source, destination), + /runtime-generated schema/, + ); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } +}); + +test("public schema packaging rejects an ancestor destination without deleting it", async () => { + const temporaryRoot = await mkdtemp(join(tmpdir(), "palschema-public-overlap-")); + try { + const source = join(temporaryRoot, "source"); + const sentinel = join(temporaryRoot, "sentinel.txt"); + await cp(resolve(repositoryRoot, "assets/schemas"), source, { + recursive: true, + }); + await writeFile(sentinel, "preserve\n"); + await assert.rejects( + copyPublicSchemas(source, temporaryRoot), + /must not overlap/, + ); + assert.equal(await readFile(sentinel, "utf8"), "preserve\n"); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } +}); diff --git a/tools/palschema-tools/test/validator.test.ts b/tools/palschema-tools/test/validator.test.ts index b55b192..a1db9e3 100644 --- a/tools/palschema-tools/test/validator.test.ts +++ b/tools/palschema-tools/test/validator.test.ts @@ -1,5 +1,12 @@ import assert from "node:assert/strict"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { + cp, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import test from "node:test"; @@ -108,7 +115,9 @@ test("accepts an unpinned local runtime-generated schema", async () => { '{"$schema":"http://json-schema.org/draft-07/schema#","type":"object"}\n', ); - const registry = await SchemaRegistry.load(temporaryRoot); + const registry = await SchemaRegistry.load(temporaryRoot, { + verifyStatic: false, + }); const verification = await registry.verify(); assert.equal( verification.find((result) => result.entry.file === "raw.schema.json") @@ -119,3 +128,64 @@ test("accepts an unpinned local runtime-generated schema", async () => { await rm(temporaryRoot, { recursive: true, force: true }); } }); + +test("rejects a modified static schema before validation", async () => { + const temporaryRoot = await mkdtemp(join(tmpdir(), "palschema-integrity-")); + try { + await cp(schemaDirectory, temporaryRoot, { recursive: true }); + await writeFile( + join(temporaryRoot, "items.schema.json"), + '{"type":"object"}\n', + ); + await assert.rejects( + PalSchemaValidator.create(temporaryRoot), + /Static schema integrity check failed.*items\.schema\.json/, + ); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } +}); + +test("loads generated raw table support schemas by canonical ID", async () => { + const temporaryRoot = await mkdtemp(join(tmpdir(), "palschema-raw-pack-")); + try { + await cp(schemaDirectory, temporaryRoot, { recursive: true }); + await mkdir(join(temporaryRoot, "raw")); + await writeFile( + join(temporaryRoot, "raw.schema.json"), + JSON.stringify({ + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + ExampleTable: { $ref: "raw/ExampleTable.schema.json" }, + }, + additionalProperties: false, + }), + ); + await writeFile( + join(temporaryRoot, "raw", "ExampleTable.schema.json"), + JSON.stringify({ + type: "object", + additionalProperties: { + type: "object", + required: ["Value"], + properties: { Value: { type: "string" } }, + }, + }), + ); + + const validator = await PalSchemaValidator.create(temporaryRoot, { + allowMissingGenerated: true, + }); + const result = await validator.validateText( + '{"ExampleTable":{"Row":{"Value":"ok"}}}', + resolve(repositoryRoot, "fixture/raw/example.json"), + ); + assert.equal( + result.diagnostics.some((diagnostic) => diagnostic.severity === "error"), + false, + ); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } +}); diff --git a/tools/palschema-tools/test/watch.test.mjs b/tools/palschema-tools/test/watch.test.mjs new file mode 100644 index 0000000..3f5bec8 --- /dev/null +++ b/tools/palschema-tools/test/watch.test.mjs @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import test from "node:test"; + +const packageRoot = resolve(import.meta.dirname, ".."); +const cliSource = resolve(packageRoot, "src/cli.ts"); +const tsxImport = import.meta.resolve("tsx"); + +test("JSON watch mode emits NDJSON and coalesces to the newest snapshot", async (context) => { + const workspace = await mkdtemp(resolve(tmpdir(), "palschema-watch-")); + const items = resolve(workspace, "items"); + const document = resolve(items, "item.json"); + await mkdir(items); + await writeFile( + resolve(workspace, "palschema.config.json"), + '{"allowMissingGenerated":true}\n', + ); + await writeFile(document, "{}\n"); + + const child = spawn( + process.execPath, + [ + "--import", + tsxImport, + cliSource, + "validate", + "--watch", + "--format", + "json", + workspace, + ], + { cwd: packageRoot, stdio: ["ignore", "pipe", "pipe"] }, + ); + context.after(async () => { + if (!child.killed) { + child.kill("SIGTERM"); + } + await rm(workspace, { recursive: true, force: true }); + }); + + const events = []; + const waiters = []; + let buffer = ""; + let stderr = ""; + + const dispatch = (line) => { + if (!line) { + return; + } + const event = JSON.parse(line); + events.push(event); + for (const waiter of [...waiters]) { + if (waiter.predicate(event)) { + clearTimeout(waiter.timeout); + waiters.splice(waiters.indexOf(waiter), 1); + waiter.resolve(event); + } + } + }; + child.stdout.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + for (;;) { + const newline = buffer.indexOf("\n"); + if (newline < 0) { + break; + } + dispatch(buffer.slice(0, newline)); + buffer = buffer.slice(newline + 1); + } + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString("utf8"); + }); + + const waitFor = (predicate, label) => { + const existing = events.find(predicate); + if (existing) { + return Promise.resolve(existing); + } + return new Promise((resolvePromise, rejectPromise) => { + const timeout = setTimeout( + () => rejectPromise(new Error(`Timed out waiting for ${label}: ${stderr}`)), + 10_000, + ); + waiters.push({ predicate, resolve: resolvePromise, timeout }); + }); + }; + + await waitFor((event) => event.event === "watch-started", "watch start"); + const validationCount = events.filter( + (event) => event.event === "validation", + ).length; + await writeFile(document, '{"Broken":{"Rarity":99}}\n'); + await new Promise((resolvePromise) => setTimeout(resolvePromise, 10)); + await writeFile(document, "{}\n"); + const newest = await waitFor( + (_event) => + events.filter((event) => event.event === "validation").length > + validationCount, + "coalesced validation", + ); + assert.equal(newest.errors, 0); + assert.equal(stderr, ""); + + child.kill("SIGINT"); + await new Promise((resolvePromise) => child.once("exit", resolvePromise)); +}); diff --git a/tools/vscode-palschema/.vscodeignore b/tools/vscode-palschema/.vscodeignore index 7454e08..cc78832 100644 --- a/tools/vscode-palschema/.vscodeignore +++ b/tools/vscode-palschema/.vscodeignore @@ -1,5 +1,6 @@ src/** scripts/** +test/** tsconfig.json node_modules/** *.vsix diff --git a/tools/vscode-palschema/package.json b/tools/vscode-palschema/package.json index a0f9cf3..91d8a22 100644 --- a/tools/vscode-palschema/package.json +++ b/tools/vscode-palschema/package.json @@ -43,7 +43,7 @@ "type": "string", "default": "", "scope": "resource", - "description": "Optional schema directory. Relative paths are resolved from the first workspace folder; empty uses the schemas bundled with the extension." + "description": "Optional schema directory. Relative paths are resolved from the document's workspace folder; empty uses the schemas bundled with the extension." }, "palschema.allowMissingGenerated": { "type": "boolean", @@ -57,6 +57,7 @@ "scripts": { "build": "node scripts/build.mjs", "package:vsix": "node scripts/package-extension.mjs", + "test": "tsx --test test/*.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { @@ -66,6 +67,7 @@ "@types/vscode": "1.91.0", "@vscode/vsce": "3.9.2", "esbuild": "0.28.1", + "tsx": "4.20.3", "typescript": "5.8.3" } } diff --git a/tools/vscode-palschema/scripts/build.mjs b/tools/vscode-palschema/scripts/build.mjs index df74be2..283801a 100644 --- a/tools/vscode-palschema/scripts/build.mjs +++ b/tools/vscode-palschema/scripts/build.mjs @@ -1,8 +1,9 @@ -import { cp, mkdir, rm } from "node:fs/promises"; +import { mkdir, rm } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { build } from "esbuild"; +import { copyPublicSchemas } from "../../../scripts/copy-public-schemas.mjs"; const scriptDirectory = dirname(fileURLToPath(import.meta.url)); const extensionRoot = resolve(scriptDirectory, ".."); @@ -33,8 +34,7 @@ await Promise.all([ }), ]); -await cp( +await copyPublicSchemas( resolve(repositoryRoot, "assets/schemas"), resolve(outputDirectory, "schemas"), - { recursive: true, force: true }, ); diff --git a/tools/vscode-palschema/src/extension.ts b/tools/vscode-palschema/src/extension.ts index 111077d..ee34d81 100644 --- a/tools/vscode-palschema/src/extension.ts +++ b/tools/vscode-palschema/src/extension.ts @@ -2,7 +2,10 @@ import { isAbsolute, resolve } from "node:path"; import { commands, + RelativePattern, type ExtensionContext, + type WorkspaceFolder, + Uri, workspace, } from "vscode"; import { @@ -12,6 +15,8 @@ import { type ServerOptions, } from "vscode-languageclient/node"; +import { SerialOperationQueue } from "./serial-operation-queue.js"; + const PALSCHEMA_FOLDERS = [ "appearance", "blueprints", @@ -28,11 +33,15 @@ const PALSCHEMA_FOLDERS = [ "translations", ]; -let client: LanguageClient | undefined; +const clients = new Map(); +const clientOperations = new SerialOperationQueue(); -function configuredSchemaDirectory(context: ExtensionContext): string { +function configuredSchemaDirectory( + context: ExtensionContext, + folder: WorkspaceFolder, +): string { const configured = workspace - .getConfiguration("palschema") + .getConfiguration("palschema", folder.uri) .get("schemaDirectory", "") .trim(); if (!configured) { @@ -41,68 +50,143 @@ function configuredSchemaDirectory(context: ExtensionContext): string { if (isAbsolute(configured)) { return configured; } - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath; - return resolve(workspaceRoot ?? process.cwd(), configured); + return resolve(folder.uri.fsPath, configured); } -function documentSelector(): NonNullable< +function documentSelector(folder: WorkspaceFolder): NonNullable< LanguageClientOptions["documentSelector"] > { - return PALSCHEMA_FOLDERS.flatMap((folder) => [ - { scheme: "file", language: "json", pattern: `**/${folder}/**/*.json` }, - { scheme: "file", language: "jsonc", pattern: `**/${folder}/**/*.jsonc` }, + return PALSCHEMA_FOLDERS.flatMap((loaderFolder) => [ + { + scheme: "file", + language: "json", + pattern: new RelativePattern( + folder, + `**/${loaderFolder}/**/*.json`, + ) as unknown as string, + }, + { + scheme: "file", + language: "jsonc", + pattern: new RelativePattern( + folder, + `**/${loaderFolder}/**/*.jsonc`, + ) as unknown as string, + }, ]); } -async function startClient(context: ExtensionContext): Promise { +async function resourceExists(uri: Uri): Promise { + try { + await workspace.fs.stat(uri); + return true; + } catch { + return false; + } +} + +async function isPalSchemaWorkspace(folder: WorkspaceFolder): Promise { + const root = folder.uri; + if ( + await resourceExists(Uri.joinPath(root, "palschema.config.json")) + ) { + return true; + } + if ( + (await resourceExists(Uri.joinPath(root, "CMakeLists.txt"))) && + (await resourceExists( + Uri.joinPath(root, "assets", "schemas", "schema-index.json"), + )) + ) { + return true; + } + if ( + (await resourceExists(Uri.joinPath(root, "enabled.txt"))) && + (await resourceExists(Uri.joinPath(root, "mods"))) + ) { + return true; + } + return resourceExists( + Uri.joinPath(root, "Mods", "PalSchema", "enabled.txt"), + ); +} + +async function startClient( + context: ExtensionContext, + folder: WorkspaceFolder, +): Promise { const serverModule = context.asAbsolutePath("dist/server.mjs"); const serverOptions: ServerOptions = { run: { module: serverModule, transport: TransportKind.ipc }, debug: { module: serverModule, transport: TransportKind.ipc }, }; const clientOptions: LanguageClientOptions = { - documentSelector: documentSelector(), + documentSelector: documentSelector(folder), + workspaceFolder: folder, initializationOptions: { - schemaDirectory: configuredSchemaDirectory(context), + schemaDirectory: configuredSchemaDirectory(context, folder), allowMissingGenerated: workspace - .getConfiguration("palschema") + .getConfiguration("palschema", folder.uri) .get("allowMissingGenerated", true), }, }; - client = new LanguageClient( - "palschema", - "PalSchema Language Server", + const client = new LanguageClient( + `palschema-${folder.index}`, + `PalSchema Language Server (${folder.name})`, serverOptions, clientOptions, ); await client.start(); + clients.set(folder.uri.toString(), client); +} + +async function stopClients(): Promise { + const running = [...clients.values()]; + clients.clear(); + await Promise.all(running.map((client) => client.stop())); } -async function restartClient(context: ExtensionContext): Promise { - if (client) { - await client.stop(); - client = undefined; +async function restartClients(context: ExtensionContext): Promise { + await stopClients(); + try { + for (const folder of workspace.workspaceFolders ?? []) { + if (await isPalSchemaWorkspace(folder)) { + await startClient(context, folder); + } + } + } catch (error) { + await stopClients(); + throw error; } - await startClient(context); +} + +function scheduleRestart(context: ExtensionContext): Promise { + return clientOperations.run(() => restartClients(context)); +} + +function requestRestart(context: ExtensionContext): void { + void scheduleRestart(context).catch((error: unknown) => { + console.error("Unable to restart PalSchema language servers.", error); + }); } export async function activate(context: ExtensionContext): Promise { context.subscriptions.push( commands.registerCommand("palschema.restartLanguageServer", () => - restartClient(context), + scheduleRestart(context), ), workspace.onDidChangeConfiguration((event) => { if (event.affectsConfiguration("palschema")) { - void restartClient(context); + requestRestart(context); } }), + workspace.onDidChangeWorkspaceFolders(() => { + requestRestart(context); + }), ); - await startClient(context); + await scheduleRestart(context); } export async function deactivate(): Promise { - if (client) { - await client.stop(); - client = undefined; - } + await clientOperations.run(stopClients); } diff --git a/tools/vscode-palschema/src/serial-operation-queue.ts b/tools/vscode-palschema/src/serial-operation-queue.ts new file mode 100644 index 0000000..6a829f6 --- /dev/null +++ b/tools/vscode-palschema/src/serial-operation-queue.ts @@ -0,0 +1,12 @@ +export class SerialOperationQueue { + private tail: Promise = Promise.resolve(); + + run(operation: () => Promise): Promise { + const result = this.tail.then(operation, operation); + this.tail = result.then( + () => undefined, + () => undefined, + ); + return result; + } +} diff --git a/tools/vscode-palschema/test/serial-operation-queue.test.ts b/tools/vscode-palschema/test/serial-operation-queue.test.ts new file mode 100644 index 0000000..c654573 --- /dev/null +++ b/tools/vscode-palschema/test/serial-operation-queue.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { SerialOperationQueue } from "../src/serial-operation-queue.js"; + +test("serializes overlapping client lifecycle operations", async () => { + const queue = new SerialOperationQueue(); + const events: string[] = []; + let releaseFirst: (() => void) | undefined; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + + const first = queue.run(async () => { + events.push("first-start"); + await firstGate; + events.push("first-end"); + }); + const second = queue.run(async () => { + events.push("second-start"); + events.push("second-end"); + }); + + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(events, ["first-start"]); + releaseFirst?.(); + await Promise.all([first, second]); + assert.deepEqual(events, [ + "first-start", + "first-end", + "second-start", + "second-end", + ]); +}); + +test("continues after a failed lifecycle operation", async () => { + const queue = new SerialOperationQueue(); + await assert.rejects( + queue.run(async () => { + throw new Error("start failed"); + }), + /start failed/, + ); + assert.equal(await queue.run(async () => "recovered"), "recovered"); +}); From 8f6fdefed7117e34c46d65216540fe8ec0e02172 Mon Sep 17 00:00:00 2001 From: LAP87 Date: Sat, 25 Jul 2026 21:24:57 +0200 Subject: [PATCH 4/5] fix: address second Linux hardening review --- .github/workflows/build.yml | 6 +- docs/linux/authoring-tools.md | 3 +- scripts/bootstrap-linux.sh | 73 ++++-- scripts/ci/run-public-source-checks.sh | 2 + scripts/deploy-proton.sh | 210 ++++++++++++++++-- scripts/test-win64-server.sh | 10 +- scripts/tests/test-bootstrap-cache.sh | 57 +++++ scripts/tests/test-deploy-transaction.sh | 155 +++++++++++-- .../tests/test-win64-server-process-guard.sh | 60 +++++ scripts/tests/test_verify_win64_artifact.py | 168 ++++++++++++-- scripts/verify-win64-artifact.py | 207 ++++++++++++++--- tools/palschema-tools/src/cli.ts | 101 ++++++--- tools/palschema-tools/src/lsp-server.ts | 11 +- tools/palschema-tools/src/schema-registry.ts | 35 ++- tools/palschema-tools/test/cli-smoke.test.mjs | 107 +++++++++ tools/palschema-tools/test/lsp-smoke.test.mjs | 75 +++++++ tools/palschema-tools/test/watch.test.mjs | 7 +- tools/vscode-palschema/src/extension.ts | 46 +++- .../src/start-clients-independently.ts | 18 ++ .../test/start-clients-independently.test.ts | 23 ++ 20 files changed, 1220 insertions(+), 154 deletions(-) create mode 100755 scripts/tests/test-win64-server-process-guard.sh create mode 100644 tools/vscode-palschema/src/start-clients-independently.ts create mode 100644 tools/vscode-palschema/test/start-clients-independently.test.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 03d92bd..491c983 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -61,7 +61,7 @@ jobs: persist-credentials: false - name: Set up MSVC - uses: ilammy/msvc-dev-cmd@v1 + uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1 with: arch: x64 @@ -141,7 +141,9 @@ jobs: build/win64-xwin-shipping/PalSchema.dll \ --json-output build/win64-xwin-shipping/pe-contract.json scripts/package-linux.sh shipping --output-dir dist - unzip -l dist/PalSchema_*_Win64*.zip + for archive in dist/PalSchema_*_Win64*.zip; do + unzip -l "$archive" + done - name: Upload Linux-hosted Win64 outputs uses: actions/upload-artifact@v7 diff --git a/docs/linux/authoring-tools.md b/docs/linux/authoring-tools.md index 6092daf..bf3e5f5 100644 --- a/docs/linux/authoring-tools.md +++ b/docs/linux/authoring-tools.md @@ -78,7 +78,8 @@ palschema validate --watch . palschema validate --strict-generated . ``` -JSONC comments and trailing commas are supported. Exit codes are: +JSONC comments are supported. Trailing commas are rejected in JSON and JSONC so +authoring validation matches the PalSchema runtime parser. Exit codes are: - `0`: no errors; warnings may be present; - `1`: at least one validation error; diff --git a/scripts/bootstrap-linux.sh b/scripts/bootstrap-linux.sh index b75ca94..90f1635 100755 --- a/scripts/bootstrap-linux.sh +++ b/scripts/bootstrap-linux.sh @@ -20,6 +20,7 @@ install_xwin=false install_rust_toolchain=false prepare_sdk=false accept_microsoft_license=false +readonly PALSCHEMA_XWIN_VERSION="0.9.0" while (($# > 0)); do case "$1" in @@ -63,7 +64,9 @@ required_commands=( flock git ninja + python3 sha256sum + sync ) missing_commands=() @@ -140,15 +143,20 @@ install_isolated_rust_toolchain() { export PATH="$CARGO_HOME/bin:$PATH" } -if ! command -v cargo >/dev/null 2>&1 || ! command -v rustc >/dev/null 2>&1; then - if [[ "$install_rust_toolchain" == true ]]; then +if [[ "$install_rust_toolchain" == true ]]; then + if [[ ! -x "$PALSCHEMA_CARGO_HOME/bin/rustup" ]]; then install_isolated_rust_toolchain - else - printf '%s\n' \ - "Rust and Cargo are required. Install rustup, or use the isolated setup:" \ - " scripts/bootstrap-linux.sh --install-rust-toolchain" >&2 - exit 1 fi + export RUSTUP_HOME="$PALSCHEMA_RUSTUP_HOME" + export CARGO_HOME="$PALSCHEMA_CARGO_HOME" + export PATH="$CARGO_HOME/bin:$PATH" +fi + +if ! command -v cargo >/dev/null 2>&1 || ! command -v rustc >/dev/null 2>&1; then + printf '%s\n' \ + "Rust and Cargo are required. Install rustup, or use the isolated setup:" \ + " scripts/bootstrap-linux.sh --install-rust-toolchain" >&2 + exit 1 fi rust_target="x86_64-pc-windows-msvc" @@ -156,9 +164,6 @@ rust_target_libdir="$(rustc --print target-libdir --target "$rust_target")" if [[ ! -d "$rust_target_libdir" ]]; then if [[ "$install_rust_toolchain" == true ]]; then - if ! command -v rustup >/dev/null 2>&1; then - install_isolated_rust_toolchain - fi rustup target add "$rust_target" else printf '%s\n' \ @@ -190,6 +195,19 @@ xwin_cache_is_ready() { [[ -f "$cache_dir/.palschema-sdk-complete" ]] } +fsync_directory() { + python3 - "$1" <<'PY' +import os +import sys + +descriptor = os.open(sys.argv[1], os.O_RDONLY | os.O_DIRECTORY) +try: + os.fsync(descriptor) +finally: + os.close(descriptor) +PY +} + xwin_parent="$(dirname -- "$palschema_xwin_dir")" xwin_name="$(basename -- "$palschema_xwin_dir")" xwin_previous="$xwin_parent/.${xwin_name}.previous" @@ -197,14 +215,6 @@ mkdir -p "$xwin_parent" exec {xwin_lock_fd}> "$xwin_parent/.${xwin_name}.lock" flock "$xwin_lock_fd" -# Adopt a legacy cache only after checking representative CRT, UCRT, SDK header, -# and Win32 import-library files. New splats always publish a completion marker. -if xwin_cache_payload_is_valid "$palschema_xwin_dir" && - [[ ! -f "$palschema_xwin_dir/.palschema-sdk-complete" ]]; then - printf '%s\n' "validated-existing-xwin-cache" \ - > "$palschema_xwin_dir/.palschema-sdk-complete" -fi - # Recover the last complete cache if a previous refresh was interrupted between # the two directory renames. if ! xwin_cache_is_ready "$palschema_xwin_dir" && @@ -216,24 +226,35 @@ if ! xwin_cache_is_ready "$palschema_xwin_dir" && xwin_incomplete="" fi mv -- "$xwin_previous" "$palschema_xwin_dir" + fsync_directory "$xwin_parent" if [[ -n "$xwin_incomplete" ]]; then rm -rf -- "$xwin_incomplete" fi fi +xwin_is_pinned() { + command -v xwin >/dev/null 2>&1 && + [[ "$(xwin --version 2>/dev/null)" == "xwin $PALSCHEMA_XWIN_VERSION" ]] +} + # xwin is needed to create the SDK cache, but not to consume a complete cache # during an offline or clean-container build. -if ! command -v xwin >/dev/null 2>&1 && +if ! xwin_is_pinned && { [[ "$install_xwin" == true ]] || { [[ "$prepare_sdk" == true ]] && ! xwin_cache_is_ready "$palschema_xwin_dir"; }; }; then if [[ "$install_xwin" == true ]]; then - cargo install --locked xwin + cargo install --locked --version "$PALSCHEMA_XWIN_VERSION" xwin + if ! xwin_is_pinned; then + printf 'Installed xwin does not report pinned version %s.\n' \ + "$PALSCHEMA_XWIN_VERSION" >&2 + exit 1 + fi else printf '%s\n' \ - "xwin is required to prepare the Microsoft CRT/SDK cache." \ + "The pinned xwin version is required to prepare the Microsoft CRT/SDK cache." \ "Rerun with --install-xwin or install it with:" \ - " cargo install --locked xwin" >&2 + " cargo install --locked --version $PALSCHEMA_XWIN_VERSION xwin" >&2 exit 1 fi fi @@ -268,12 +289,18 @@ if [[ "$prepare_sdk" == true ]]; then fi printf '%s\n' "xwin-splat-complete" \ > "$xwin_stage/.palschema-sdk-complete" + # Flush the staged SDK and completion marker before publishing it. + sync -f "$xwin_stage" + fsync_directory "$xwin_stage" + fsync_directory "$xwin_parent" if [[ -e "$xwin_previous" ]]; then rm -rf -- "$xwin_previous" + fsync_directory "$xwin_parent" fi if [[ -e "$palschema_xwin_dir" ]]; then mv -- "$palschema_xwin_dir" "$xwin_previous" + fsync_directory "$xwin_parent" fi if ! mv -- "$xwin_stage" "$palschema_xwin_dir"; then if [[ ! -e "$palschema_xwin_dir" && -e "$xwin_previous" ]]; then @@ -281,8 +308,10 @@ if [[ "$prepare_sdk" == true ]]; then fi exit 1 fi + fsync_directory "$xwin_parent" if [[ -e "$xwin_previous" ]]; then rm -rf -- "$xwin_previous" + fsync_directory "$xwin_parent" fi fi fi diff --git a/scripts/ci/run-public-source-checks.sh b/scripts/ci/run-public-source-checks.sh index 7850306..ce6d3fb 100755 --- a/scripts/ci/run-public-source-checks.sh +++ b/scripts/ci/run-public-source-checks.sh @@ -45,6 +45,7 @@ bash -n \ scripts/tests/test-bootstrap-cache.sh \ scripts/tests/test-distro-matrix-output.sh \ scripts/tests/test-process-scan.sh \ + scripts/tests/test-win64-server-process-guard.sh \ scripts/ci/run-public-source-checks.sh \ scripts/lib/build-env.sh \ scripts/lib/process-scan.sh @@ -56,6 +57,7 @@ scripts/tests/test-deploy-transaction.sh scripts/tests/test-bootstrap-cache.sh scripts/tests/test-distro-matrix-output.sh scripts/tests/test-process-scan.sh +scripts/tests/test-win64-server-process-guard.sh python3 -m json.tool CMakePresets.json >/dev/null python3 -m json.tool assets/.vscode/settings.json >/dev/null python3 -m json.tool assets/schemas/schema-index.json >/dev/null diff --git a/scripts/deploy-proton.sh b/scripts/deploy-proton.sh index 1950d29..3a50f08 100755 --- a/scripts/deploy-proton.sh +++ b/scripts/deploy-proton.sh @@ -103,6 +103,7 @@ required_commands=( realpath rm sha256sum + sync ) if [[ "$build_flavor" == "dev" ]]; then required_commands+=(node) @@ -141,6 +142,81 @@ transaction_marker="$backup_root/.active-transaction" stage_dir="" transaction_active=false +fsync_file() { + python3 - "$1" <<'PY' +import os +import sys + +descriptor = os.open(sys.argv[1], os.O_RDONLY) +try: + os.fsync(descriptor) +finally: + os.close(descriptor) +PY +} + +fsync_directory() { + python3 - "$1" <<'PY' +import os +import sys + +descriptor = os.open(sys.argv[1], os.O_RDONLY | os.O_DIRECTORY) +try: + os.fsync(descriptor) +finally: + os.close(descriptor) +PY +} + +assert_tree_has_no_symlinks() { + python3 - "$1" <<'PY' +import os +import sys + +root = sys.argv[1] +for directory, directories, files in os.walk(root, followlinks=False): + for name in [*directories, *files]: + path = os.path.join(directory, name) + if os.path.islink(path): + raise SystemExit(f"Backup tree contains a symlink: {path}") +PY +} + +rename_directory() { + python3 - "$1" "$2" <<'PY' +import os +import sys + +os.rename(sys.argv[1], sys.argv[2]) +PY +} + +exchange_directories() { + python3 - "$1" "$2" <<'PY' +import ctypes +import os +import sys + +libc = ctypes.CDLL(None, use_errno=True) +renameat2 = libc.renameat2 +renameat2.argtypes = [ + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_uint, +] +renameat2.restype = ctypes.c_int +at_fdcwd = -100 +rename_exchange = 2 +left = os.fsencode(sys.argv[1]) +right = os.fsencode(sys.argv[2]) +if renameat2(at_fdcwd, left, at_fdcwd, right, rename_exchange) != 0: + error = ctypes.get_errno() + raise OSError(error, os.strerror(error)) +PY +} + if [[ "$target_kind" == "auto" ]]; then if [[ -f "$win64_dir/Palworld-Win64-Shipping.exe" ]]; then target_kind="client" @@ -188,18 +264,31 @@ backup_root="$ue4ss_root_real/.palschema-backups" transaction_marker="$backup_root/.active-transaction" assert_target_stopped() { - if palschema_target_process_status "$target_kind" /proc; then + if target_is_stopped; then + return 0 + else + process_status=$? + fi + if ((process_status == 1)); then printf 'Refusing to deploy while the Palworld Windows %s is active.\n' \ "$target_kind" >&2 exit 1 + fi + printf '%s\n' \ + "Refusing to deploy because one or more stable /proc process command lines are unreadable." \ + "Run as an account with complete process visibility after stopping the selected Win64 target." >&2 + exit 1 +} + +target_is_stopped() { + if palschema_target_process_status "$target_kind" /proc; then + return 1 else process_status=$? - if ((process_status == 2)); then - printf '%s\n' \ - "Refusing to deploy because one or more stable /proc process command lines are unreadable." \ - "Run as an account with complete process visibility after stopping the selected Win64 target." >&2 - exit 1 + if ((process_status == 1)); then + return 0 fi + return 2 fi } @@ -251,9 +340,19 @@ recover_incomplete_transaction() { return 1 ;; esac + if [[ -d "$recovery_backup/PalSchema" ]]; then + assert_tree_has_no_symlinks "$recovery_backup/PalSchema" + fi - if [[ ! -e "$target_dir" && -d "$recovery_backup/PalSchema" ]]; then - mv -- "$recovery_backup/PalSchema" "$target_dir" + if [[ -d "$recovery_backup/PalSchema" ]]; then + if [[ -d "$target_dir" ]]; then + exchange_directories "$recovery_backup/PalSchema" "$target_dir" + rm -rf -- "$recovery_backup/PalSchema" + else + rename_directory "$recovery_backup/PalSchema" "$target_dir" + fi + fsync_directory "$mods_dir" + fsync_directory "$recovery_backup" printf 'Recovered interrupted PalSchema transaction from %s\n' \ "$recovery_backup" elif [[ ! -e "$target_dir" && @@ -265,10 +364,17 @@ recover_incomplete_transaction() { if [[ -n "$stage_name" ]]; then stale_stage="$mods_dir/$stage_name" if [[ -d "$stale_stage" && ! -L "$stale_stage" ]]; then + assert_tree_has_no_symlinks "$stale_stage" + # The marker is durable before the atomic swap. If power is lost + # between the swap and moving the previous tree into its backup, + # either target is still a complete tree; never guess by swapping + # an ambiguous stage back over the live target. rm -rf -- "$stale_stage" + fsync_directory "$mods_dir" fi fi rm -f -- "$transaction_marker" + fsync_directory "$backup_root" transaction_active=false } @@ -283,12 +389,17 @@ begin_transaction() { printf '%s\n' "$backup_id" printf '%s\n' "$stage_name" } > "$temporary_marker" + fsync_file "$temporary_marker" mv -- "$temporary_marker" "$transaction_marker" + fsync_directory "$backup_root" transaction_active=true } commit_transaction() { + fsync_directory "$mods_dir" + fsync_directory "$backup_root" rm -f -- "$transaction_marker" + fsync_directory "$backup_root" transaction_active=false } @@ -368,31 +479,67 @@ if [[ -n "$rollback_dir" ]]; then "$rollback_dir_real" >&2 exit 1 fi + assert_tree_has_no_symlinks "$rollback_dir_real/PalSchema" stage_dir="$(mktemp -d "$mods_dir/.PalSchema.stage.XXXXXX")" cp -a -- "$rollback_dir_real/PalSchema/." "$stage_dir/" if [[ ! -f "$stage_dir/dlls/main.dll" ]]; then printf '%s\n' "Rollback staging did not produce dlls/main.dll." >&2 exit 1 fi + assert_tree_has_no_symlinks "$stage_dir" + sync -f "$stage_dir" fi rollback_id="rollback-$(date -u +%Y%m%dT%H%M%SZ)-$$" rollback_safety="$backup_root/$rollback_id" assert_target_stopped mkdir -p "$rollback_safety" - if [[ -d "$target_dir" ]]; then - : - else + had_live_target=false + if [[ ! -d "$target_dir" ]]; then touch "$rollback_safety/no-previous-install" + else + had_live_target=true fi begin_transaction "$rollback_id" \ "$([[ -n "$stage_dir" ]] && basename "$stage_dir" || true)" - if [[ -d "$target_dir" ]]; then - mv -- "$target_dir" "$rollback_safety/PalSchema" - fi if [[ -n "$stage_dir" ]]; then - mv -- "$stage_dir" "$target_dir" + if [[ "$had_live_target" == true ]]; then + exchange_directories "$stage_dir" "$target_dir" + else + rename_directory "$stage_dir" "$target_dir" + fi + fsync_directory "$mods_dir" + + if ! target_is_stopped; then + if [[ "$had_live_target" == true ]]; then + exchange_directories "$stage_dir" "$target_dir" + else + rename_directory "$target_dir" "$stage_dir" + fi + fsync_directory "$mods_dir" + commit_transaction + printf '%s\n' \ + "A Palworld process started during the atomic rollback; the previous live state was restored." >&2 + exit 1 + fi + + if [[ "$had_live_target" == true ]]; then + rename_directory "$stage_dir" "$rollback_safety/PalSchema" + fsync_directory "$rollback_safety" + fi stage_dir="" + elif [[ "$had_live_target" == true ]]; then + rename_directory "$target_dir" "$rollback_safety/PalSchema" + fsync_directory "$mods_dir" + fsync_directory "$rollback_safety" + if ! target_is_stopped; then + rename_directory "$rollback_safety/PalSchema" "$target_dir" + fsync_directory "$mods_dir" + commit_transaction + printf '%s\n' \ + "A Palworld process started during the atomic rollback; the previous live state was restored." >&2 + exit 1 + fi fi commit_transaction @@ -448,20 +595,45 @@ if [[ ! -f "$stage_dir/dlls/main.dll" ]]; then printf '%s\n' "Deployment staging did not produce dlls/main.dll." >&2 exit 1 fi +assert_tree_has_no_symlinks "$stage_dir" +sync -f "$stage_dir" assert_target_stopped mkdir -p "$backup_dir" +had_live_target=false if [[ ! -d "$target_dir" ]]; then touch "$backup_dir/no-previous-install" +else + had_live_target=true fi begin_transaction "$deploy_id" "$(basename "$stage_dir")" -if [[ -d "$target_dir" ]]; then - mv -- "$target_dir" "$backup_dir/PalSchema" +if [[ "$had_live_target" == true ]]; then + exchange_directories "$stage_dir" "$target_dir" +else + rename_directory "$stage_dir" "$target_dir" fi +fsync_directory "$mods_dir" + +if ! target_is_stopped; then + if [[ "$had_live_target" == true ]]; then + exchange_directories "$stage_dir" "$target_dir" + else + rename_directory "$target_dir" "$stage_dir" + fi + fsync_directory "$mods_dir" + commit_transaction + printf '%s\n' \ + "A Palworld process started during the atomic deployment; the previous live state was restored." >&2 + exit 1 +fi + +if [[ "$had_live_target" == true ]]; then + rename_directory "$stage_dir" "$backup_dir/PalSchema" + fsync_directory "$backup_dir" +fi +stage_dir="" if [[ "${PALSCHEMA_TEST_INTERRUPT_AFTER_BACKUP:-0}" == "1" ]]; then kill -TERM "$$" fi -mv -- "$stage_dir" "$target_dir" -stage_dir="" commit_transaction { diff --git a/scripts/test-win64-server.sh b/scripts/test-win64-server.sh index b2581da..f72491e 100755 --- a/scripts/test-win64-server.sh +++ b/scripts/test-win64-server.sh @@ -22,6 +22,7 @@ show_usage() { } project_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +source "$project_root/scripts/lib/process-scan.sh" game_dir="" cycles=3 game_port=18211 @@ -138,9 +139,16 @@ for required_command in "${required_commands[@]}"; do fi done -if pgrep -f -- "$server_exe" >/dev/null 2>&1; then +if palschema_target_process_status server /proc; then printf 'The selected Win64 server is already running: %s\n' "$server_exe" >&2 exit 1 +else + process_status=$? + if ((process_status == 2)); then + printf '%s\n' \ + "Cannot confirm that the isolated Win64 server is stopped because /proc visibility is incomplete." >&2 + exit 1 + fi fi port_is_open() { diff --git a/scripts/tests/test-bootstrap-cache.sh b/scripts/tests/test-bootstrap-cache.sh index 59814c6..527c3f4 100755 --- a/scripts/tests/test-bootstrap-cache.sh +++ b/scripts/tests/test-bootstrap-cache.sh @@ -36,6 +36,10 @@ EOF cat > "$fake_bin/xwin" <<'EOF' #!/usr/bin/env bash set -euo pipefail +if [[ "${1:-}" == "--version" ]]; then + printf '%s\n' "xwin 0.9.0" + exit 0 +fi output="" while (($# > 0)); do if [[ "$1" == "--output" ]]; then @@ -121,4 +125,57 @@ if [[ ! -f "$XWIN_DIR/.palschema-sdk-complete" ]]; then exit 1 fi +# A markerless legacy cache must be regenerated even when it contains every +# former representative sentinel. +export PALSCHEMA_CACHE_ROOT="$test_root/legacy-cache" +export XWIN_DIR="$PALSCHEMA_CACHE_ROOT/xwin" +export FAKE_XWIN_COUNTER="$test_root/legacy-xwin-count" +mkdir -p \ + "$XWIN_DIR/crt/include" \ + "$XWIN_DIR/crt/lib/x86_64" \ + "$XWIN_DIR/sdk/include/um" \ + "$XWIN_DIR/sdk/lib/ucrt/x86_64" \ + "$XWIN_DIR/sdk/lib/um/x86_64" +touch \ + "$XWIN_DIR/crt/include/vcruntime.h" \ + "$XWIN_DIR/crt/lib/x86_64/msvcrt.lib" \ + "$XWIN_DIR/sdk/include/um/Windows.h" \ + "$XWIN_DIR/sdk/lib/ucrt/x86_64/ucrt.lib" \ + "$XWIN_DIR/sdk/lib/um/x86_64/kernel32.Lib" +"$project_root/scripts/bootstrap-linux.sh" \ + --prepare-sdk --accept-microsoft-license >"$test_root/legacy.out" +if [[ "$(cat "$FAKE_XWIN_COUNTER")" != "1" || + ! -f "$XWIN_DIR/.palschema-sdk-complete" ]]; then + printf '%s\n' "Markerless legacy cache was incorrectly certified." >&2 + exit 1 +fi + +# --install-rust-toolchain must select the PalSchema-local rustup even when +# system cargo and rustc are already available. +isolated_cargo="$test_root/isolated-cargo" +isolated_rustup="$test_root/isolated-rustup" +mkdir -p "$isolated_cargo/bin" "$isolated_rustup" +cp -- "$fake_bin/cargo" "$isolated_cargo/bin/cargo" +cp -- "$fake_bin/rustc" "$isolated_cargo/bin/rustc" +cat > "$isolated_cargo/bin/rustup" <<'EOF' +#!/usr/bin/env sh +printf '%s\n' "$*" >> "$FAKE_ISOLATED_RUSTUP_LOG" +exit 0 +EOF +chmod +x \ + "$isolated_cargo/bin/cargo" \ + "$isolated_cargo/bin/rustc" \ + "$isolated_cargo/bin/rustup" +export PALSCHEMA_CARGO_HOME="$isolated_cargo" +export PALSCHEMA_RUSTUP_HOME="$isolated_rustup" +export FAKE_ISOLATED_RUSTUP_LOG="$test_root/isolated-rustup.log" +export FAKE_RUST_LIBDIR="$test_root/missing-rust-target" +"$project_root/scripts/bootstrap-linux.sh" \ + --install-rust-toolchain >"$test_root/isolated-rust.out" +if ! grep -Fxq "target add x86_64-pc-windows-msvc" \ + "$FAKE_ISOLATED_RUSTUP_LOG"; then + printf '%s\n' "Bootstrap modified or used the wrong Rust toolchain." >&2 + exit 1 +fi + printf '%s\n' "PalSchema bootstrap cache tests passed." diff --git a/scripts/tests/test-deploy-transaction.sh b/scripts/tests/test-deploy-transaction.sh index 7f6b8ba..eb40b68 100755 --- a/scripts/tests/test-deploy-transaction.sh +++ b/scripts/tests/test-deploy-transaction.sh @@ -35,13 +35,78 @@ Path(sys.argv[1]).write_bytes( PY cat > "$fake_bin/llvm-readobj" <<'EOF' -#!/usr/bin/env sh -cat <<'OUTPUT' +#!/usr/bin/env python3 +import struct + + +def align4(content): + return content + b"\0" * (-len(content) % 4) + + +def node(key, value=b"", value_type=0, children=()): + encoded_key = f"{key}\0".encode("utf-16-le") + if isinstance(value, str): + encoded_value = f"{value}\0".encode("utf-16-le") + value_length = len(encoded_value) // 2 + else: + encoded_value = value + value_length = len(encoded_value) + content = align4(b"\0" * 6 + encoded_key) + content += encoded_value + content = align4(content) + content += b"".join(align4(child) for child in children) + return struct.pack(" "$nested_link_backup/PalSchema/dlls/main.dll" +printf '%s\n' "external-mod-sentinel" > "$external_mods/sentinel.txt" +ln -s "$external_mods" "$nested_link_backup/PalSchema/mods" +set +e +PATH="$fake_bin:$PATH" \ + "$project_root/scripts/deploy-proton.sh" \ + --rollback "$nested_link_backup" \ + --target server \ + --game-dir "$game_root" \ + >"$test_root/nested-link.out" 2>"$test_root/nested-link.err" +nested_link_status=$? +set -e +if ((nested_link_status == 0)) || + ! grep -q "Backup tree contains a symlink" "$test_root/nested-link.err"; then + printf '%s\n' "Rollback accepted a nested backup symlink." >&2 + exit 1 +fi +if [[ "$(cat "$external_mods/sentinel.txt")" != "external-mod-sentinel" ]]; then + printf '%s\n' "Rejected nested symlink rollback modified external data." >&2 + exit 1 +fi + +# Starting the server from a marker-rename wrapper reproduces the old +# check-then-mv race. The atomic swap plus post-swap scan must restore the live +# installation and fail closed. +real_mv="$(command -v mv)" +cat > "$fake_bin/mv" < "$test_root/race-process.pid" +fi +exec "$real_mv" "\$@" +EOF +chmod +x "$fake_bin/mv" +pre_race_hash="$(sha256sum "$target_root/dlls/main.dll" | awk '{print $1}')" +set +e +PATH="$fake_bin:$PATH" \ + "$project_root/scripts/deploy-proton.sh" shipping \ + --target server \ + --game-dir "$game_root" \ + >"$test_root/race.out" 2>"$test_root/race.err" +race_status=$? +set -e +if [[ -f "$test_root/race-process.pid" ]]; then + kill "$(cat "$test_root/race-process.pid")" >/dev/null 2>&1 || true +fi +if ((race_status == 0)) || + ! grep -q "started during the atomic deployment" "$test_root/race.err"; then + printf '%s\n' "Process-start race was not detected after the atomic swap." >&2 + exit 1 +fi +if [[ "$(sha256sum "$target_root/dlls/main.dll" | awk '{print $1}')" \ + != "$pre_race_hash" ]]; then + printf '%s\n' "Process-start race did not restore the previous live mod." >&2 + exit 1 +fi + printf '%s\n' "PalSchema deploy transaction tests passed." diff --git a/scripts/tests/test-win64-server-process-guard.sh b/scripts/tests/test-win64-server-process-guard.sh new file mode 100755 index 0000000..d6e6024 --- /dev/null +++ b/scripts/tests/test-win64-server-process-guard.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +test_root="$(mktemp -d "${TMPDIR:-/tmp}/palschema-server-guard.XXXXXX")" +guard_pid="" +cleanup() { + if [[ -n "$guard_pid" ]]; then + kill "$guard_pid" >/dev/null 2>&1 || true + fi + rm -rf -- "$test_root" +} +trap cleanup EXIT + +project_root="$test_root/project" +game_root="$test_root/game" +fake_bin="$test_root/bin" +win64_root="$game_root/Pal/Binaries/Win64" +ue4ss_root="$win64_root/ue4ss" +mkdir -p \ + "$project_root/scripts/lib" \ + "$ue4ss_root/Mods/PalSchema/dlls" \ + "$fake_bin" +cp -- "$repository_root/scripts/test-win64-server.sh" "$project_root/scripts/" +cp -- "$repository_root/scripts/lib/process-scan.sh" "$project_root/scripts/lib/" +touch \ + "$win64_root/PalServer-Win64-Shipping-Cmd.exe" \ + "$win64_root/dwmapi.dll" \ + "$ue4ss_root/UE4SS.dll" \ + "$ue4ss_root/UE4SS-settings.ini" \ + "$ue4ss_root/MemberVariableLayout.ini" \ + "$ue4ss_root/Mods/PalSchema/dlls/main.dll" + +for command_name in ss wine wineserver; do + printf '%s\n' '#!/usr/bin/env sh' 'exit 0' > "$fake_bin/$command_name" + chmod +x "$fake_bin/$command_name" +done + +bash -c 'exec -a ./PalServer-Win64-Shipping-Cmd.exe sleep 30' & +guard_pid=$! +sleep 0.1 + +set +e +PATH="$fake_bin:$PATH" \ + "$project_root/scripts/test-win64-server.sh" \ + --game-dir "$game_root" \ + --cycles 1 \ + >"$test_root/guard.out" 2>"$test_root/guard.err" +status=$? +set -e + +if ((status == 0)) || + ! grep -q "already running" "$test_root/guard.err"; then + printf '%s\n' \ + "Server smoke harness did not reject a relative executable command line." >&2 + exit 1 +fi + +printf '%s\n' "PalSchema Win64 server process guard tests passed." diff --git a/scripts/tests/test_verify_win64_artifact.py b/scripts/tests/test_verify_win64_artifact.py index c716efe..0183251 100644 --- a/scripts/tests/test_verify_win64_artifact.py +++ b/scripts/tests/test_verify_win64_artifact.py @@ -1,6 +1,7 @@ from __future__ import annotations import subprocess +import struct import sys import tempfile import unittest @@ -11,11 +12,88 @@ VERIFIER = REPOSITORY_ROOT / "scripts" / "verify-win64-artifact.py" -def resource_dump(product_name: str, product_version: str) -> str: - content = ( - f"ProductName\0{product_name}\0" - f"ProductVersion\0{product_version}\0" - ).encode("utf-16-le") +def align4(content: bytes) -> bytes: + return content + b"\0" * (-len(content) % 4) + + +def version_node( + key: str, + *, + value: bytes | str = b"", + value_type: int = 0, + children: tuple[bytes, ...] = (), +) -> bytes: + encoded_key = f"{key}\0".encode("utf-16-le") + if isinstance(value, str): + encoded_value = f"{value}\0".encode("utf-16-le") + value_length = len(encoded_value) // 2 + else: + encoded_value = value + value_length = len(encoded_value) + content = align4(b"\0" * 6 + encoded_key) + content += encoded_value + content = align4(content) + content += b"".join(align4(child) for child in children) + return struct.pack(" bytes: + components = [ + int(value) + for value in (fixed_version or product_version).split(".") + ] + product_ms = (components[0] << 16) | components[1] + product_ls = (components[2] << 16) | components[3] + fixed = struct.pack( + "<13I", + 0xFEEF04BD, + 0x00010000, + product_ms, + product_ls, + product_ms, + product_ls, + 0x3F, + 0, + 0x40004, + 1, + 0, + 0, + 0, + ) + strings = [ + version_node("ProductName", value=product_name, value_type=1), + version_node("ProductVersion", value=product_version, value_type=1), + ] + if comments is not None: + strings.append(version_node("Comments", value=comments, value_type=1)) + table = version_node("040904B0", children=tuple(strings)) + string_file_info = version_node("StringFileInfo", children=(table,)) + return version_node( + "VS_VERSION_INFO", + value=fixed, + children=(string_file_info,), + ) + + +def resource_dump( + product_name: str, + product_version: str, + *, + fixed_version: str | None = None, + comments: str | None = None, +) -> str: + content = version_resource( + product_name, + product_version, + fixed_version=fixed_version, + comments=comments, + ) content += b"\0" * (-len(content) % 4) groups = [ content[index : index + 4].hex().upper() @@ -32,18 +110,25 @@ def resource_dump(product_name: str, product_version: str) -> str: def readobj_output( product_name: str = "PalSchema", product_version: str = "0.6.1.0", + *, + fixed_version: str | None = None, + comments: str | None = None, ) -> str: return f"""\ File: fixture.dll Format: COFF-x86-64 -Characteristics [ (0x2022) - IMAGE_FILE_DLL -] -DLLCharacteristics [ (0x8160) - IMAGE_DLL_CHARACTERISTICS_DYNAMIC_BASE - IMAGE_DLL_CHARACTERISTICS_HIGH_ENTROPY_VA - IMAGE_DLL_CHARACTERISTICS_NX_COMPAT -] +ImageFileHeader {{ + Characteristics [ (0x2022) + IMAGE_FILE_DLL + ] +}} +ImageOptionalHeader {{ + Characteristics [ (0x8160) + IMAGE_DLL_CHARACTERISTICS_DYNAMIC_BASE + IMAGE_DLL_CHARACTERISTICS_HIGH_ENTROPY_VA + IMAGE_DLL_CHARACTERISTICS_NX_COMPAT + ] +}} Export {{ Name: start_mod }} @@ -56,7 +141,7 @@ def readobj_output( Resources [ Type: VERSIONINFO (ID 16) [ Data ( -{resource_dump(product_name, product_version)} +{resource_dump(product_name, product_version, fixed_version=fixed_version, comments=comments)} ) ] ] @@ -100,10 +185,19 @@ def test_accepts_project_identity_and_version(self) -> None: self.assertIn('"product_name": "PalSchema"', completed.stdout) def test_rejects_an_unrelated_generic_ue4ss_mod(self) -> None: - completed = self.run_verifier(readobj_output(product_name="OtherMod!")) + completed = self.run_verifier( + readobj_output(product_name="OtherMod!", comments="PalSchema"), + ) self.assertEqual(completed.returncode, 1) self.assertIn("product identity", completed.stderr) + def test_rejects_wrong_fixed_product_version_despite_decoy_string(self) -> None: + completed = self.run_verifier( + readobj_output(fixed_version="9.9.9.9"), + ) + self.assertEqual(completed.returncode, 1) + self.assertIn("VS_FIXEDFILEINFO product version differs", completed.stderr) + def test_rejects_each_broken_pe_contract_field(self) -> None: valid = readobj_output() mutations = { @@ -112,7 +206,8 @@ def test_rejects_each_broken_pe_contract_field(self) -> None: "not COFF x86-64", ), "dll flag": ( - valid.replace(" IMAGE_FILE_DLL\n", ""), + valid.replace(" IMAGE_FILE_DLL\n", "") + + "Import {\n Name: IMAGE_FILE_DLL\n}\n", "not marked as a DLL", ), "export": ( @@ -138,7 +233,8 @@ def test_rejects_each_broken_pe_contract_field(self) -> None: "IMAGE_DLL_CHARACTERISTICS_NX_COMPAT", ): mutations[characteristic] = ( - valid.replace(f" {characteristic}\n", ""), + valid.replace(f" {characteristic}\n", "") + + f"Import {{\n Name: {characteristic}\n}}\n", "missing DLL security characteristics", ) @@ -148,6 +244,44 @@ def test_rejects_each_broken_pe_contract_field(self) -> None: self.assertEqual(completed.returncode, 1) self.assertIn(expected_error, completed.stderr) + def test_json_output_cannot_replace_artifact_or_follow_symlink(self) -> None: + with tempfile.TemporaryDirectory(prefix="palschema-pe-output-") as directory: + root = Path(directory) + artifact = root / "fixture.dll" + artifact.write_bytes(b"fixture") + protected = root / "protected.json" + protected.write_text("sentinel\n", encoding="utf-8") + linked_output = root / "contract.json" + linked_output.symlink_to(protected) + readobj = root / "llvm-readobj" + readobj.write_text( + "#!/usr/bin/env sh\ncat <<'EOF'\n" + f"{readobj_output()}" + "EOF\n", + encoding="utf-8", + ) + readobj.chmod(0o755) + + for output in (artifact, linked_output): + with self.subTest(output=output.name): + completed = subprocess.run( + [ + sys.executable, + str(VERIFIER), + str(artifact), + "--llvm-readobj", + str(readobj), + "--json-output", + str(output), + ], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(completed.returncode, 1) + self.assertEqual(artifact.read_bytes(), b"fixture") + self.assertEqual(protected.read_text(encoding="utf-8"), "sentinel\n") + if __name__ == "__main__": unittest.main() diff --git a/scripts/verify-win64-artifact.py b/scripts/verify-win64-artifact.py index b589721..cb45bbb 100755 --- a/scripts/verify-win64-artifact.py +++ b/scripts/verify-win64-artifact.py @@ -7,10 +7,13 @@ import argparse import hashlib import json +import os import re import shutil +import struct import subprocess import sys +import tempfile from pathlib import Path @@ -87,18 +90,118 @@ def version_resource_bytes(output: str) -> bytes: return b"".join(chunks) -def has_version_string(resource: bytes, key: str, value: str) -> bool: - key_marker = f"{key}\0".encode("utf-16-le") - value_marker = f"{value}\0".encode("utf-16-le") - key_offset = resource.find(key_marker) - if key_offset < 0: - return False - value_offset = resource.find( - value_marker, - key_offset + len(key_marker), - key_offset + len(key_marker) + 512, +def align4(offset: int) -> int: + return (offset + 3) & ~3 + + +def read_utf16_z(data: bytes, offset: int, end: int) -> tuple[str, int]: + cursor = offset + while cursor + 2 <= end: + if data[cursor : cursor + 2] == b"\0\0": + return data[offset:cursor].decode("utf-16-le"), cursor + 2 + cursor += 2 + raise RuntimeError("VERSIONINFO contains an unterminated UTF-16 key.") + + +def parse_version_node( + data: bytes, + offset: int, + limit: int, +) -> tuple[dict[str, object], int]: + if offset + 6 > limit: + raise RuntimeError("VERSIONINFO node header is truncated.") + length, value_length, value_type = struct.unpack_from(" limit: + raise RuntimeError("VERSIONINFO node length is invalid.") + end = offset + length + key, key_end = read_utf16_z(data, offset + 6, end) + value_offset = align4(key_end) + value_size = value_length * 2 if value_type == 1 else value_length + value_end = value_offset + value_size + if value_end > end: + raise RuntimeError(f"VERSIONINFO value for {key!r} is truncated.") + if value_type == 1: + value: bytes | str = data[value_offset:value_end].decode( + "utf-16-le", + ).rstrip("\0") + else: + value = data[value_offset:value_end] + + children: list[dict[str, object]] = [] + cursor = align4(value_end) + while cursor + 2 <= end: + if data[cursor:end].strip(b"\0") == b"": + break + child, child_end = parse_version_node(data, cursor, end) + children.append(child) + cursor = align4(child_end) + return { + "key": key, + "value": value, + "children": children, + }, end + + +def decode_version_info( + resource: bytes, +) -> tuple[tuple[int, int, int, int], dict[str, list[str]]]: + root, _ = parse_version_node(resource, 0, len(resource)) + if root["key"] != "VS_VERSION_INFO": + raise RuntimeError("VERSIONINFO root key is invalid.") + fixed = root["value"] + if not isinstance(fixed, bytes) or len(fixed) < 52: + raise RuntimeError("VS_FIXEDFILEINFO is missing or truncated.") + fields = struct.unpack_from("<13I", fixed) + if fields[0] != 0xFEEF04BD: + raise RuntimeError("VS_FIXEDFILEINFO signature is invalid.") + product_version = ( + fields[4] >> 16, + fields[4] & 0xFFFF, + fields[5] >> 16, + fields[5] & 0xFFFF, ) - return value_offset >= 0 + + strings: dict[str, list[str]] = {} + + def collect(node: dict[str, object], under_string_table: bool = False) -> None: + key = node["key"] + children = node["children"] + if not isinstance(key, str) or not isinstance(children, list): + raise RuntimeError("VERSIONINFO contains an invalid node.") + is_string_table = under_string_table or key == "StringFileInfo" + value = node["value"] + if is_string_table and isinstance(value, str) and key not in { + "StringFileInfo", + }: + strings.setdefault(key, []).append(value) + for child in children: + if not isinstance(child, dict): + raise RuntimeError("VERSIONINFO contains an invalid child.") + collect(child, is_string_table) + + collect(root) + return product_version, strings + + +def parse_flag_block(output: str, parent: str, heading: str) -> set[str]: + flags: set[str] = set() + in_parent = False + in_block = False + for raw_line in output.splitlines(): + line = raw_line.strip() + if line == f"{parent} {{": + in_parent = True + continue + if in_parent and line.startswith(f"{heading} ["): + in_block = True + continue + if in_block and line == "]": + break + if in_block: + match = re.match(r"^(IMAGE_[A-Z0-9_]+)(?:\s|\(|$)", line) + if match: + flags.add(match.group(1)) + return flags def inspect_artifact( @@ -148,15 +251,25 @@ def inspect_artifact( elif block == "import": imports.add(name) + file_characteristics = parse_flag_block( + output, + "ImageFileHeader", + "Characteristics", + ) + dll_characteristics = parse_flag_block( + output, + "ImageOptionalHeader", + "Characteristics", + ) missing_characteristics = sorted( characteristic for characteristic in REQUIRED_DLL_CHARACTERISTICS - if characteristic not in output + if characteristic not in dll_characteristics ) errors: list[str] = [] if "Format: COFF-x86-64" not in output: errors.append("artifact is not COFF x86-64") - if "IMAGE_FILE_DLL" not in output: + if "IMAGE_FILE_DLL" not in file_characteristics: errors.append("artifact is not marked as a DLL") if exports != EXPECTED_EXPORTS: errors.append( @@ -170,22 +283,25 @@ def inspect_artifact( errors.append("VERSIONINFO resource is missing") elif not resource_bytes: errors.append("VERSIONINFO resource data is missing") - if not has_version_string( - resource_bytes, - "ProductName", - EXPECTED_PRODUCT_NAME, - ): - errors.append( - f"VERSIONINFO product identity {EXPECTED_PRODUCT_NAME!r} is missing" - ) - if not has_version_string( - resource_bytes, - "ProductVersion", - expected_version, - ): - errors.append( - f"VERSIONINFO product version {expected_version!r} is missing" - ) + if resource_bytes: + try: + fixed_version, version_strings = decode_version_info(resource_bytes) + expected_components = tuple(int(value) for value in expected_version.split(".")) + if fixed_version != expected_components: + errors.append( + "VS_FIXEDFILEINFO product version differs: " + f"expected {expected_components}, found {fixed_version}" + ) + if version_strings.get("ProductName") != [EXPECTED_PRODUCT_NAME]: + errors.append( + f"VERSIONINFO product identity {EXPECTED_PRODUCT_NAME!r} is missing" + ) + if version_strings.get("ProductVersion") != [expected_version]: + errors.append( + f"VERSIONINFO product version {expected_version!r} is missing" + ) + except RuntimeError as error: + errors.append(str(error)) if missing_characteristics: errors.append( "missing DLL security characteristics: " @@ -232,9 +348,36 @@ def main() -> int: serialized = json.dumps(contract, indent=2) + "\n" if args.json_output: - output_path = args.json_output.resolve() - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(serialized, encoding="utf-8") + output_path = args.json_output.absolute() + try: + if output_path.is_symlink(): + raise RuntimeError( + f"JSON output must not be a symlink: {output_path}", + ) + if output_path.exists() and os.path.samefile(output_path, artifact): + raise RuntimeError( + "JSON output must not replace the verified artifact.", + ) + if output_path.resolve(strict=False) == artifact: + raise RuntimeError( + "JSON output must not replace the verified artifact.", + ) + output_path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=output_path.parent, + prefix=f".{output_path.name}.", + delete=False, + ) as temporary: + temporary.write(serialized) + temporary.flush() + os.fsync(temporary.fileno()) + temporary_path = Path(temporary.name) + os.replace(temporary_path, output_path) + except (OSError, RuntimeError) as error: + print(f"Unable to write verifier JSON: {error}", file=sys.stderr) + return 1 print(f"Verified {artifact}; wrote {output_path}") else: print(serialized, end="") diff --git a/tools/palschema-tools/src/cli.ts b/tools/palschema-tools/src/cli.ts index 5deb1c2..9369bd8 100644 --- a/tools/palschema-tools/src/cli.ts +++ b/tools/palschema-tools/src/cli.ts @@ -6,9 +6,12 @@ import { constants, copyFile, lstat, + mkdtemp, mkdir, readdir, realpath, + rename, + rm, writeFile, } from "node:fs/promises"; import { @@ -389,6 +392,7 @@ async function validateCommand(arguments_: string[]): Promise { } else { console.error(error); } + exitCode = 2; process.exitCode = 2; } } while (validationRequested); @@ -419,6 +423,10 @@ async function validateCommand(arguments_: string[]): Promise { .on("add", (path) => rerun(path, "validate")) .on("change", (path) => rerun(path, "validate")) .on("unlink", (path) => rerun(path, "delete")); + await new Promise((resolveReady, rejectReady) => { + watcher.once("ready", resolveReady); + watcher.once("error", rejectReady); + }); if (options.format === "json") { console.log(JSON.stringify({ event: "watch-started", paths: options.paths })); } else { @@ -579,6 +587,7 @@ async function initCommand(arguments_: string[]): Promise { ".palschema", "schemas", ); + const schemaParent = dirname(schemaDestination); const settingsPath = join(options.destination, ".vscode", "settings.json"); const configPath = join(options.destination, "palschema.config.json"); const managedTargets = [settingsPath, configPath]; @@ -614,7 +623,7 @@ async function initCommand(arguments_: string[]): Promise { if (!options.force) { const conflicts = []; - for (const target of managedTargets) { + for (const target of [...managedTargets, schemaDestination]) { if (await pathExists(target)) { conflicts.push(target); } @@ -627,41 +636,75 @@ async function initCommand(arguments_: string[]): Promise { } } - await mkdir(schemaDestination, { recursive: true }); + await mkdir(schemaParent, { recursive: true }); await mkdir(join(options.destination, ".vscode"), { recursive: true }); - const destinationIndex = join(schemaDestination, "schema-index.json"); - await assertNoManagedSymlink(destinationIndex); - await copyFile( - join(registry.schemaDirectory, "schema-index.json"), - destinationIndex, - ); + const assertRegularSchemaSource = async ( + source: string, + label: string, + ): Promise => { + const metadata = await lstat(source); + if (!metadata.isFile() || metadata.isSymbolicLink()) { + throw new Error(`${label} must be a regular non-symlink file: ${source}`); + } + const canonical = await realpath(source); + if (!isPathContained(registry.schemaDirectory, canonical)) { + throw new Error(`${label} escapes its schema pack: ${source}`); + } + }; + + const stagedSchemas = await mkdtemp(join(schemaParent, ".schemas-stage-")); const copiedSchemas: string[] = ["schema-index.json"]; const missingGenerated: string[] = []; - for (const entry of registry.index.schemas) { - const source = registry.pathFor(entry); - const destination = containedDestination(schemaDestination, entry.file); - if (!(await pathExists(source))) { - if (entry.generated) { - missingGenerated.push(entry.file); - continue; + try { + const sourceIndex = join(registry.schemaDirectory, "schema-index.json"); + await assertRegularSchemaSource(sourceIndex, "Schema index"); + await copyFile(sourceIndex, join(stagedSchemas, "schema-index.json")); + + for (const entry of registry.index.schemas) { + const source = registry.pathFor(entry); + const destination = containedDestination(stagedSchemas, entry.file); + if (!(await pathExists(source))) { + if (entry.generated) { + missingGenerated.push(entry.file); + continue; + } + throw new Error(`Required schema is missing: ${source}`); + } + await assertRegularSchemaSource(source, `Schema ${entry.file}`); + await copyFile(source, destination); + copiedSchemas.push(entry.file); + for (const supportFile of await registry.supportFilesFor(entry)) { + const supportSource = registry.pathForRelative(supportFile); + const supportDestination = containedDestination( + stagedSchemas, + supportFile, + ); + await mkdir(dirname(supportDestination), { recursive: true }); + await copyFile(supportSource, supportDestination); + copiedSchemas.push(supportFile); } - throw new Error(`Required schema is missing: ${source}`); } - await assertNoManagedSymlink(destination); - await copyFile(source, destination); - copiedSchemas.push(entry.file); - for (const supportFile of await registry.supportFilesFor(entry)) { - const supportSource = registry.pathForRelative(supportFile); - const supportDestination = containedDestination( - schemaDestination, - supportFile, - ); - await assertNoManagedSymlink(supportDestination); - await mkdir(dirname(supportDestination), { recursive: true }); - await copyFile(supportSource, supportDestination); - copiedSchemas.push(supportFile); + + const backupSchemas = `${schemaDestination}.backup-${process.pid}-${Date.now()}`; + const hadExistingSchemas = await pathExists(schemaDestination); + if (hadExistingSchemas) { + await rename(schemaDestination, backupSchemas); } + try { + await rename(stagedSchemas, schemaDestination); + } catch (error) { + if (hadExistingSchemas && !(await pathExists(schemaDestination))) { + await rename(backupSchemas, schemaDestination); + } + throw error; + } + if (hadExistingSchemas) { + await rm(backupSchemas, { recursive: true, force: true }); + } + } catch (error) { + await rm(stagedSchemas, { recursive: true, force: true }); + throw error; } const settings = { diff --git a/tools/palschema-tools/src/lsp-server.ts b/tools/palschema-tools/src/lsp-server.ts index 17135a2..02a0b39 100644 --- a/tools/palschema-tools/src/lsp-server.ts +++ b/tools/palschema-tools/src/lsp-server.ts @@ -31,6 +31,7 @@ import { PalSchemaValidator } from "./validator.js"; const connection = createConnection(ProposedFeatures.all); const documents = new TextDocuments(TextDocument); const validationTimers = new Map(); +const MAX_PUBLISHED_DIAGNOSTICS = 500; let registry: SchemaRegistry; let validator: PalSchemaValidator; @@ -118,11 +119,15 @@ async function validateDocument(document: TextDocument): Promise { } connection.sendDiagnostics({ uri: document.uri, - diagnostics: result.diagnostics.map((diagnostic) => - toLspDiagnostic(document, diagnostic), - ), + diagnostics: result.diagnostics + .slice(0, MAX_PUBLISHED_DIAGNOSTICS) + .map((diagnostic) => toLspDiagnostic(document, diagnostic)), }); } catch (error) { + const current = documents.get(document.uri); + if (current && current.version === version) { + connection.sendDiagnostics({ uri: document.uri, diagnostics: [] }); + } connection.console.error( error instanceof Error ? error.stack ?? error.message : String(error), ); diff --git a/tools/palschema-tools/src/schema-registry.ts b/tools/palschema-tools/src/schema-registry.ts index d0de797..b92f24a 100644 --- a/tools/palschema-tools/src/schema-registry.ts +++ b/tools/palschema-tools/src/schema-registry.ts @@ -47,11 +47,31 @@ function assertSchemaIndex(value: unknown): asserts value is SchemaIndex { !("formatVersion" in value) || value.formatVersion !== 1 || !("schemas" in value) || - !Array.isArray(value.schemas) + !Array.isArray(value.schemas) || + !("palSchemaVersion" in value) || + typeof value.palSchemaVersion !== "string" || + !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(value.palSchemaVersion) || + !("baseId" in value) || + typeof value.baseId !== "string" ) { throw new Error("Unsupported or malformed schema-index.json."); } + let baseId: URL; + try { + baseId = new URL(value.baseId); + } catch { + throw new Error("schema-index.json contains an invalid baseId."); + } + if ( + (baseId.protocol !== "https:" && baseId.protocol !== "http:") || + !baseId.pathname.endsWith("/") || + baseId.hash || + baseId.search + ) { + throw new Error("schema-index.json contains a non-canonical baseId."); + } + const files = new Set(); const ids = new Set(); for (const candidate of value.schemas as unknown[]) { @@ -92,6 +112,19 @@ function assertSchemaIndex(value: unknown): asserts value is SchemaIndex { throw new Error("schema-index.json contains invalid supportPatterns."); } assertSingleFileName(candidate.file, "schema file"); + let canonicalId: string; + try { + canonicalId = new URL(candidate.file, baseId).toString(); + } catch { + throw new Error( + `schema-index.json contains an invalid schema ID: ${candidate.id}`, + ); + } + if (candidate.id !== canonicalId) { + throw new Error( + `schema-index.json schema ID is not canonical for ${candidate.file}.`, + ); + } for (const dependency of candidate.dependencies) { assertSingleFileName(dependency, "schema dependency"); } diff --git a/tools/palschema-tools/test/cli-smoke.test.mjs b/tools/palschema-tools/test/cli-smoke.test.mjs index fae30d1..da65c51 100644 --- a/tools/palschema-tools/test/cli-smoke.test.mjs +++ b/tools/palschema-tools/test/cli-smoke.test.mjs @@ -175,6 +175,113 @@ test("init copies generated raw schema support files", () => { } }); +test("init --force atomically removes generated schemas absent from the new pack", () => { + const temporaryRoot = mkdtempSync(resolve(tmpdir(), "palschema-init-refresh-")); + try { + const schemas = resolve(temporaryRoot, "schemas"); + const workspace = resolve(temporaryRoot, "workspace"); + cpSync(resolve(repositoryRoot, "assets/schemas"), schemas, { + recursive: true, + }); + mkdirSync(resolve(schemas, "raw")); + mkdirSync(workspace); + writeFileSync(resolve(schemas, "enums.schema.json"), '{"definitions":{}}\n'); + writeFileSync(resolve(schemas, "raw.schema.json"), '{"type":"object"}\n'); + writeFileSync( + resolve(schemas, "raw", "Table.schema.json"), + '{"type":"object"}\n', + ); + + const first = runCli( + ["init", "--schema-dir", schemas, workspace], + packageRoot, + ); + assert.equal(first.status, 0, first.stderr); + rmSync(resolve(schemas, "enums.schema.json")); + rmSync(resolve(schemas, "raw.schema.json")); + rmSync(resolve(schemas, "raw"), { recursive: true }); + + const refresh = runCli( + ["init", "--force", "--schema-dir", schemas, workspace], + packageRoot, + ); + assert.equal(refresh.status, 0, refresh.stderr); + assert.equal( + existsSync(resolve(workspace, ".palschema/schemas/enums.schema.json")), + false, + ); + assert.equal( + existsSync(resolve(workspace, ".palschema/schemas/raw.schema.json")), + false, + ); + assert.equal( + existsSync(resolve(workspace, ".palschema/schemas/raw")), + false, + ); + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } +}); + +test("init rejects a symlinked generated schema source without replacing the workspace", () => { + const temporaryRoot = mkdtempSync(resolve(tmpdir(), "palschema-init-source-link-")); + try { + const schemas = resolve(temporaryRoot, "schemas"); + const workspace = resolve(temporaryRoot, "workspace"); + const outside = resolve(temporaryRoot, "secret.json"); + cpSync(resolve(repositoryRoot, "assets/schemas"), schemas, { + recursive: true, + }); + mkdirSync(workspace); + writeFileSync(outside, '{"secret":"must-not-copy"}\n'); + symlinkSync(outside, resolve(schemas, "enums.schema.json"), "file"); + + const initialization = runCli( + ["init", "--schema-dir", schemas, workspace], + packageRoot, + ); + assert.equal(initialization.status, 2); + assert.match(initialization.stderr, /regular non-symlink file/); + assert.equal( + existsSync(resolve(workspace, ".palschema/schemas/enums.schema.json")), + false, + ); + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } +}); + +test("schema index requires metadata and canonical IDs", () => { + for (const mutate of [ + (index) => { + delete index.baseId; + }, + (index) => { + index.schemas[0].id = "not-a-url"; + }, + ]) { + const temporaryRoot = mkdtempSync(resolve(tmpdir(), "palschema-index-metadata-")); + try { + const schemas = resolve(temporaryRoot, "schemas"); + cpSync(resolve(repositoryRoot, "assets/schemas"), schemas, { + recursive: true, + }); + const indexPath = resolve(schemas, "schema-index.json"); + const index = JSON.parse(readFileSync(indexPath, "utf8")); + mutate(index); + writeFileSync(indexPath, `${JSON.stringify(index)}\n`); + const result = runCli( + ["schemas", "list", "--schema-dir", schemas], + packageRoot, + ); + assert.equal(result.status, 2); + assert.match(result.stderr, /schema-index\.json/i); + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } + } +}); + test("init refuses a symlinked managed parent", () => { const temporaryRoot = mkdtempSync(resolve(tmpdir(), "palschema-init-link-")); try { diff --git a/tools/palschema-tools/test/lsp-smoke.test.mjs b/tools/palschema-tools/test/lsp-smoke.test.mjs index 940d6d5..d718b88 100644 --- a/tools/palschema-tools/test/lsp-smoke.test.mjs +++ b/tools/palschema-tools/test/lsp-smoke.test.mjs @@ -326,6 +326,81 @@ test("serves diagnostics and schema features over LSP", async (context) => { false, ); + const largeUri = pathToFileURL( + resolve(repositoryRoot, "fixture/items/large-invalid.json"), + ).toString(); + const largeDocument = Object.fromEntries( + Array.from({ length: 700 }, (_, index) => [ + `Broken${index}`, + { Rarity: 99 }, + ]), + ); + send({ + jsonrpc: "2.0", + method: "textDocument/didOpen", + params: { + textDocument: { + uri: largeUri, + languageId: "json", + version: 1, + text: JSON.stringify(largeDocument), + }, + }, + }); + const boundedDiagnostics = await waitFor( + (message) => + message.method === "textDocument/publishDiagnostics" && + message.params?.uri === largeUri, + "bounded diagnostics", + ); + assert.equal(boundedDiagnostics.params.diagnostics.length, 500); + + const rawUri = pathToFileURL( + resolve(repositoryRoot, "fixture/raw/stale.json"), + ).toString(); + send({ + jsonrpc: "2.0", + method: "textDocument/didOpen", + params: { + textDocument: { + uri: rawUri, + languageId: "json", + version: 1, + text: "{}\n", + }, + }, + }); + const missingRaw = await waitFor( + (message) => + message.method === "textDocument/publishDiagnostics" && + message.params?.uri === rawUri && + message.params?.diagnostics?.some( + (diagnostic) => diagnostic.code === "PS_SCHEMA_GENERATED_MISSING", + ), + "missing raw schema diagnostic", + ); + const malformedStart = received.indexOf(missingRaw) + 1; + await writeFile( + resolve(temporarySchemaDirectory, "raw.schema.json"), + "{malformed\n", + ); + send({ + jsonrpc: "2.0", + method: "textDocument/didChange", + params: { + textDocument: { uri: rawUri, version: 2 }, + contentChanges: [{ text: '{"changed":true}\n' }], + }, + }); + await waitFor( + (message) => + received.indexOf(message) >= malformedStart && + message.method === "textDocument/publishDiagnostics" && + message.params?.uri === rawUri && + message.params?.diagnostics?.length === 0, + "stale diagnostics clear after validator failure", + ); + send({ jsonrpc: "2.0", id: 4, method: "shutdown", params: null }); await waitFor((message) => message.id === 4, "shutdown response"); send({ jsonrpc: "2.0", method: "exit", params: null }); diff --git a/tools/palschema-tools/test/watch.test.mjs b/tools/palschema-tools/test/watch.test.mjs index 3f5bec8..7978b93 100644 --- a/tools/palschema-tools/test/watch.test.mjs +++ b/tools/palschema-tools/test/watch.test.mjs @@ -105,6 +105,11 @@ test("JSON watch mode emits NDJSON and coalesces to the newest snapshot", async assert.equal(newest.errors, 0); assert.equal(stderr, ""); + await writeFile(resolve(workspace, "palschema.config.json"), "{broken\n"); + await waitFor((event) => event.event === "error", "watch validation error"); child.kill("SIGINT"); - await new Promise((resolvePromise) => child.once("exit", resolvePromise)); + const exitCode = await new Promise((resolvePromise) => + child.once("exit", resolvePromise), + ); + assert.equal(exitCode, 2); }); diff --git a/tools/vscode-palschema/src/extension.ts b/tools/vscode-palschema/src/extension.ts index ee34d81..c81f23e 100644 --- a/tools/vscode-palschema/src/extension.ts +++ b/tools/vscode-palschema/src/extension.ts @@ -16,6 +16,7 @@ import { } from "vscode-languageclient/node"; import { SerialOperationQueue } from "./serial-operation-queue.js"; +import { startClientsIndependently } from "./start-clients-independently.js"; const PALSCHEMA_FOLDERS = [ "appearance", @@ -114,7 +115,7 @@ async function isPalSchemaWorkspace(folder: WorkspaceFolder): Promise { async function startClient( context: ExtensionContext, folder: WorkspaceFolder, -): Promise { +): Promise { const serverModule = context.asAbsolutePath("dist/server.mjs"); const serverOptions: ServerOptions = { run: { module: serverModule, transport: TransportKind.ipc }, @@ -136,8 +137,18 @@ async function startClient( serverOptions, clientOptions, ); - await client.start(); - clients.set(folder.uri.toString(), client); + try { + await client.start(); + clients.set(folder.uri.toString(), client); + return null; + } catch (error) { + try { + await client.stop(); + } catch { + // The client may not have reached a stoppable state. + } + return error instanceof Error ? error : new Error(String(error)); + } } async function stopClients(): Promise { @@ -148,15 +159,28 @@ async function stopClients(): Promise { async function restartClients(context: ExtensionContext): Promise { await stopClients(); - try { - for (const folder of workspace.workspaceFolders ?? []) { - if (await isPalSchemaWorkspace(folder)) { - await startClient(context, folder); - } + const folders: WorkspaceFolder[] = []; + for (const folder of workspace.workspaceFolders ?? []) { + if (await isPalSchemaWorkspace(folder)) { + folders.push(folder); } - } catch (error) { - await stopClients(); - throw error; + } + const failures = await startClientsIndependently( + folders, + (folder) => startClient(context, folder), + ); + for (const { candidate: folder, error } of failures) { + console.error(`Unable to start PalSchema for ${folder.name}.`, error); + } + if (failures.length > 0 && clients.size === 0) { + throw new AggregateError( + failures.map(({ candidate: folder, error }) => + new Error(`Unable to start PalSchema for ${folder.name}`, { + cause: error, + }), + ), + "No PalSchema language server started.", + ); } } diff --git a/tools/vscode-palschema/src/start-clients-independently.ts b/tools/vscode-palschema/src/start-clients-independently.ts new file mode 100644 index 0000000..ad985bf --- /dev/null +++ b/tools/vscode-palschema/src/start-clients-independently.ts @@ -0,0 +1,18 @@ +export interface ClientStartFailure { + candidate: T; + error: Error; +} + +export async function startClientsIndependently( + candidates: readonly T[], + start: (candidate: T) => Promise, +): Promise[]> { + const failures: ClientStartFailure[] = []; + for (const candidate of candidates) { + const error = await start(candidate); + if (error) { + failures.push({ candidate, error }); + } + } + return failures; +} diff --git a/tools/vscode-palschema/test/start-clients-independently.test.ts b/tools/vscode-palschema/test/start-clients-independently.test.ts new file mode 100644 index 0000000..02d94e0 --- /dev/null +++ b/tools/vscode-palschema/test/start-clients-independently.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { startClientsIndependently } from "../src/start-clients-independently.js"; + +test("keeps starting healthy workspace clients after one fails", async () => { + const started: string[] = []; + const failures = await startClientsIndependently( + ["healthy-a", "broken", "healthy-b"], + async (candidate) => { + if (candidate === "broken") { + return new Error("invalid schema directory"); + } + started.push(candidate); + return null; + }, + ); + + assert.deepEqual(started, ["healthy-a", "healthy-b"]); + assert.equal(failures.length, 1); + assert.equal(failures[0]?.candidate, "broken"); + assert.match(failures[0]?.error.message ?? "", /invalid schema directory/); +}); From 3d38e082912aa11501f50c9be8732d0eeb4f7661 Mon Sep 17 00:00:00 2001 From: LAP87 Date: Sat, 25 Jul 2026 23:49:06 +0200 Subject: [PATCH 5/5] fix: trim internal planning and harden Linux safety --- docs/plans/2026-07-25-linux-port-plan.md | 1152 -------------------- scripts/bootstrap-linux.sh | 37 +- scripts/deploy-proton.sh | 23 +- scripts/lib/process-scan.sh | 8 +- scripts/tests/test-bootstrap-cache.sh | 24 + scripts/tests/test-deploy-transaction.sh | 24 + scripts/tests/test-process-scan.sh | 12 + tools/palschema-tools/src/text-position.ts | 8 - 8 files changed, 120 insertions(+), 1168 deletions(-) delete mode 100644 docs/plans/2026-07-25-linux-port-plan.md diff --git a/docs/plans/2026-07-25-linux-port-plan.md b/docs/plans/2026-07-25-linux-port-plan.md deleted file mode 100644 index 9905407..0000000 --- a/docs/plans/2026-07-25-linux-port-plan.md +++ /dev/null @@ -1,1152 +0,0 @@ -# PalSchema Linux Port and Cross-Distro Mod Development Plan - -Status: execution in progress -Prepared: 2026-07-25 -Upstream baseline: `Okaetsu/PalSchema` `main` at `75137ef`, tag `0.6.1` -Primary development host: CachyOS -Target contribution path: `LAP87/PalSchema` fork to `Okaetsu/PalSchema` - -## 1. Executive decision - -This project will treat "Linux support" as three related deliverables, not as -one ambiguous binary port: - -1. **Linux-hosted PalSchema development for the Windows Palworld client** - - Build the Win64 PalSchema DLL entirely from Linux. - - Install and run it with the Windows Palworld client under Steam/Proton. - - Preserve every mod-development feature currently available on Windows. - - This is the first runtime parity milestone because the Palworld desktop - client installed on Linux is still the Windows build. - -2. **Native Linux Palworld Dedicated Server support** - - Build PalSchema as an ELF shared object for the native Linux server. - - Use the official/native UE4SS Linux C++ mod ABI when that ABI is ready. - - Prototype against the current upstream draft only in an explicitly - experimental lane. - - Do not call this stable or 1:1 until the native server feature matrix has - passed against an isolated native PalServer instance. - -3. **Distro-neutral authoring tools** - - Keep JSON Schema as the canonical mod-authoring contract. - - Add a standalone validator/CLI and a Language Server Protocol server. - - Add a thin VS Code/VSCodium extension and documented setup for other - Linux editors. - - Support both `.json` and `.jsonc`. - - Ship these tools independently of Palworld, Steam, Proton, and UE4SS. - -The release will **not bundle UE4SS**. Users will install a compatible UE4SS -build separately. PalSchema will publish a machine-readable compatibility -manifest and provide a `doctor` command that verifies the user's installation. - -## 2. Why this split is necessary - -The word "Linux" currently refers to two different runtime ABIs: - -- The Steam Palworld client is a PE/COFF Win64 executable running through - Proton. PalSchema must therefore remain a Win64 DLL at runtime even if it was - compiled on Linux. -- Palworld Dedicated Server has a native x86-64 ELF build. A plugin for that - process needs Linux-compatible UE layouts, signatures, hooks, loader entry - points, paths, and shared-library exports. - -Cross-compiling the existing Win64 target on Linux is materially smaller and -less risky than creating the native server target. The project will ship useful -Linux mod-development improvements as soon as the first lane is complete, -without misrepresenting the native server port as finished. - -## 3. Verified baseline - -### 3.1 Repository - -- The checkout is at `/home/lenny/apps/PalSchema`. -- Upstream is `https://github.com/Okaetsu/PalSchema`. -- Baseline release is `0.6.1`, published 2026-07-19. -- The repository and PalSchema source are MIT-licensed. -- The pinned dependency is `Okaetsu/RE-UE4SS` at - `c838a8acaade1a0f860bdf249f039e58f4e10088`. -- PalSchema directly links UE4SS, Zydis, Zycore, SafetyHook, nlohmann-json, - Glaze, and efsw. -- The current top-level CMake file only defines one generic `SHARED` target, - includes `src/dllmain.cpp`, and uses a Windows-only - `__declspec(dllexport)` declaration. -- The only checked-in build helper is a two-line Windows batch file. - -### 3.2 Dependency access - -- `deps/RE-UE4SS` and its recursive dependencies are checked out at their - repository-pinned commits. -- Its `deps/first/Unreal` submodule points to the private `UEPseudo` - repository. -- The active GitHub account `LAP87` is linked to Epic Games, has accepted the - EpicGames organization invitation, and has verified pull access to - `Re-UE4SS/UEPseudo`. -- RE-UE4SS is MIT-licensed, but its own contributing documentation says - UEPseudo is subject to Epic Games' licensing terms. -- A clean native CMake configure on CachyOS now traverses the complete - dependency graph and successfully generates Ninja build files. - -UEPseudo remains an authorized-builder prerequisite rather than a -redistributable project dependency. The project will not copy, mirror, vendor, -or publish it. - -### 3.3 Current runtime architecture - -The current `PalSchema` C++ mod: - -1. Is created from exported `start_mod()` and destroyed by `uninstall_mod()`. -2. Runs configuration loading, signature scanning, Unreal offset setup, and - loader pre-initialization from its constructor. -3. Uses UE4SS lifecycle callbacks for UI setup and Unreal initialization. -4. Installs inline hooks around data-table serialization, game-instance - initialization, and pak-folder discovery. -5. Registers loaders for: - - resources; - - enums; - - pals/monsters; - - humans; - - items; - - skins; - - appearances; - - buildings; - - raw data tables; - - blueprints; - - help-guide entries; - - custom spawns; - - translations. -6. Optionally watches the mod tree through efsw and auto-reloads changes. -7. Generates enum and raw-table schemas by querying the live Unreal - AssetRegistry. - -The code graph identifies `FName`, `PalModLoaderBase`, `PalMainLoader`, the -specialized loaders, and `FProperty` as the central abstractions. The best port -boundary is therefore below the loaders: platform/runtime services must change -without forking every mod type into Windows and Linux copies. - -### 3.4 Local CachyOS reference environment - -- CachyOS kernel: `7.1.3-2-cachyos` -- glibc: `2.43` -- CMake: `4.4.0` -- GCC: `16.1.1` -- Clang: `22.1.8` -- Ninja: `1.13.2` -- Steam Palworld client app: `1623730` -- Installed client build ID: `24181527` -- Client executable: Win64 PE/COFF -- Installed UE4SS client build: `3.0.1 Beta`, commit `c2ac246` -- The installed client already demonstrates that Win64 UE4SS can run under - Proton on this host. -- Native Dedicated Server app: `2394010` -- Installed server build ID: `24181105` -- Server executable: native x86-64 ELF -- The native Dedicated Server is currently live. - -No test may modify or restart the live server, its production config, or its -save tree. Native end-to-end testing must use a separate test instance, separate -ports, and a separate save directory. - -### 3.5 Existing schema/editor experience - -The `0.6.1` Dev release contains: - -- static schemas for buildings, items, pals, skins, and utility definitions; -- generated schemas at runtime for raw data tables and enums; -- examples; -- a `.vscode/settings.json` file with `json.schemas` mappings. - -The current VS Code mapping: - -- is delivered in the release archive rather than maintained visibly as a - reusable editor package; -- only matches `*.json`, even though PalSchema documents and accepts `.jsonc`; -- relies on relative paths in one specific extracted folder layout; -- has no standalone validation command; -- has no editor-neutral LSP; -- cannot provide a clear diagnostic when generated raw/enum schemas are stale - or missing. - -## 4. Upstream constraints that shape the plan - -### 4.1 Native UE4SS Linux support is not stable yet - -Relevant upstream state: - -- [UE4SS issue #364](https://github.com/UE4SS-RE/RE-UE4SS/issues/364) - remains open for Linux support. -- [UE4SS PR #384](https://github.com/UE4SS-RE/RE-UE4SS/pull/384) is an older - draft Linux port. -- [UE4SS PR #1347](https://github.com/UE4SS-RE/RE-UE4SS/pull/1347) is a newer - draft native headless Linux runtime with Palworld-specific conformance - tooling, but it still requires review and is not the stable public ABI. -- [PalSchema issue #125](https://github.com/Okaetsu/PalSchema/issues/125) - records the PalSchema maintainer's intent to wait for official UE4SS Linux - implementation. - -Consequences: - -- The native PalSchema server target must be an experimental build until UE4SS - exposes and stabilizes the required C++ mod and hook APIs. -- PalSchema should not absorb a private permanent fork of the entire UE4SS - runtime. -- Any missing native UE4SS primitives should be contributed to UE4SS or - isolated in a very small adapter that can be deleted when upstream lands. - -### 4.2 Linux-to-Windows cross-compilation already has an upstream path - -- [UE4SS PR #710](https://github.com/UE4SS-RE/RE-UE4SS/pull/710) added - cross-compilation support and was merged. -- The pinned RE-UE4SS documentation recommends `xwin` plus Clang/LLD, with - `msvc-wine` as an alternative. -- [UE4SS PR #1244](https://github.com/UE4SS-RE/RE-UE4SS/pull/1244) continues - improving LLVM assembler support. -- [UE4SS issue #811](https://github.com/UE4SS-RE/RE-UE4SS/issues/811) - documents that `msvc-wine` can work in CI but has had local reliability and - diagnostics problems. - -Decision: - -- Use **xwin + clang-cl + LLD + Ninja** as the primary reproducible Win64 - cross-build. -- Keep `msvc-wine` as a documented fallback/compatibility lane, not the - canonical build. - -### 4.3 Constructor-time scanning is a Proton/Wine risk - -[PalSchema issue #118](https://github.com/Okaetsu/PalSchema/issues/118) -contains current reports of Wine server hangs around PalSchema startup and -UE4SS signature scanning. The exact cause is not fully settled across all -environments, but the architecture is objectively risky: - -- PalSchema's constructor synchronously runs signature scanning. -- Signature scanning creates worker tasks and waits for completion. -- DLL initialization may still be under the Windows loader lock. - -The port must remove blocking work from constructor/DllMain-adjacent paths even -if a particular Proton version appears to tolerate it. - -## 5. Definition of "100% 1-to-1 mod-development functionality" - -Parity is achieved only when the following contract is green. - -### 5.1 Authoring parity - -- A developer can create a mod workspace from Linux without manually copying - hidden VS Code files from a release archive. -- `.json` and `.jsonc` receive the same schema selection. -- Completion, hover information, and diagnostics work in VS Code and VSCodium. -- The same diagnostics are available from a terminal and CI. -- Generic LSP clients can consume the same diagnostics. -- All shipped examples validate. -- Invalid fixtures for every mod type fail with stable, useful error codes. -- Generated raw-table and enum schemas can be refreshed without relying solely - on an ImGui button. - -### 5.2 Proton client runtime parity - -Every current PalSchema capability must pass on the Windows client under -Proton: - -- raw data-table edit; -- raw data-table row addition; -- wildcard filtering; -- blueprint modification; -- pals/monsters; -- humans; -- items; -- skins; -- appearances; -- buildings; -- enums; -- help-guide entries; -- custom spawns; -- translations/custom localization; -- resource loading; -- pak-folder redirection; -- live schema generation; -- configuration load/repair; -- debug logging; -- auto-reload after normal save; -- auto-reload after atomic-save/rename used by common Linux editors; -- clean unload/reload where UE4SS supports it; -- clear failure when signatures, offsets, schemas, or UE4SS are incompatible. - -The Win64 DLL produced on Linux must be functionally equivalent to the -official Windows-built DLL. A Windows CI build remains in the matrix to detect -compiler-specific regressions. - -### 5.3 Native Dedicated Server parity - -The native server target must pass every server-applicable item above. Features -that are inherently client/UI-only must: - -- be explicitly classified as client-only; -- disable themselves cleanly on a headless server; -- never crash or block server startup; -- never be silently advertised as active. - -Native parity also requires: - -- an ELF shared library with only the intended public exports; -- no dependency on Wine, Proton, Windows SDK files, or Win32 DLL search rules; -- Linux signatures/offsets validated against the exact PalServer build; -- case-sensitive path correctness; -- correct UTF-8 path handling; -- safe initialization outside loader/preload locks; -- clean behavior under systemd and containerized servers. - -### 5.4 Cross-distro parity - -A feature does not count as Linux-complete if it only works on CachyOS. -Compiled release artifacts and editor tools must work on the supported distro -matrix without distro-specific source edits. - -## 6. Support matrix - -### 6.1 Tier 1: release-blocking - -| Environment | Build | CLI/LSP | Proton client | Native server | -| --- | --- | --- | --- | --- | -| CachyOS/Arch, native Steam | Yes | Yes | Full local E2E | Full isolated E2E | -| Ubuntu LTS, native Steam | Yes | Yes | Smoke + fixtures | Smoke | -| Fedora stable, native Steam | Yes | Yes | Smoke + fixtures | Smoke | -| Debian stable | Yes | Yes | Headless/packaging | Native smoke | -| openSUSE Tumbleweed or Leap | Yes | Yes | Smoke | Native smoke | -| SteamOS/Steam Deck | No local compile requirement | Yes | Install/load/editor smoke | N/A | - -### 6.2 Tier 2: compatibility-tested - -- Flatpak Steam on one Ubuntu/Fedora-family host. -- Proton Stable, Proton Experimental, and Proton-CachyOS SLR. -- VSCodium/Open VSX installation. -- Neovim with a generic LSP client. -- Zed or Helix with a generic LSP client where supported. -- Containerized native server. - -### 6.3 Portable artifact baseline - -- Build native Linux release artifacts in a conservative glibc container, not - on the newest rolling-release host. -- Audit required GLIBC/GLIBCXX symbol versions. -- Prefer a stable C++ runtime strategy: - - dynamically link glibc; - - statically link libstdc++/libgcc only if license and plugin-host ABI testing - support it; - - avoid distro-specific shared libraries outside the documented baseline. -- Produce `x86_64` first. -- Treat `aarch64` as a separate future target because Palworld/Proton runtime - availability and UE4SS hooks are not equivalent. - -## 7. Target repository architecture - -```text -PalSchema/ -├── CMakeLists.txt -├── CMakePresets.json -├── cmake/ -│ ├── PalSchemaOptions.cmake -│ ├── PalSchemaPlatform.cmake -│ └── toolchains/ -│ └── xwin-clang-cl.cmake -├── include/ -│ ├── Platform/ -│ │ ├── Export.h -│ │ ├── HookBackend.h -│ │ ├── Path.h -│ │ ├── RuntimeCapabilities.h -│ │ └── SignatureCatalog.h -│ └── ...existing domain headers... -├── src/ -│ ├── Platform/ -│ │ ├── Common/ -│ │ ├── Win64/ -│ │ └── Linux/ -│ ├── PluginEntry.cpp -│ └── ...existing loaders... -├── schemas/ -│ ├── static/ -│ ├── generated-fixtures/ -│ └── schema-index.json -├── tools/ -│ ├── schema-core/ -│ ├── cli/ -│ ├── language-server/ -│ └── vscode/ -├── tests/ -│ ├── unit/ -│ ├── contract/ -│ ├── fixtures/ -│ ├── proton/ -│ └── native-server/ -├── scripts/ -│ ├── bootstrap-linux.sh -│ ├── build.sh -│ ├── package.sh -│ ├── deploy-proton.sh -│ ├── deploy-native-test.sh -│ └── verify-release.sh -└── docs/ - ├── linux/ - └── plans/ -``` - -The exact layout may be adjusted to upstream taste, but the separation of -domain logic, platform services, tooling, and tests is non-negotiable. - -## 8. Runtime architecture - -```mermaid -flowchart TD - Mods["PalSchema JSON/JSONC mods"] --> Core["Shared PalSchema loaders and mutation core"] - Core --> Runtime["Runtime capability interface"] - Runtime --> Win["Win64 UE4SS adapter"] - Runtime --> Linux["Native Linux UE4SS adapter"] - Win --> Proton["Windows Palworld client under Proton"] - Win --> WineServer["Optional Windows dedicated server under Proton/Wine"] - Linux --> NativeServer["Native Palworld Dedicated Server"] - GameRegistry["Live Unreal AssetRegistry"] --> SchemaService["Schema generation service"] - SchemaService --> Schemas["Versioned JSON schemas and manifest"] - Schemas --> CLI["PalSchema CLI validator"] - Schemas --> LSP["PalSchema language server"] - LSP --> Editors["VS Code, VSCodium, Neovim, Zed, Helix"] -``` - -The specialized loaders remain shared. Platform adapters own: - -- plugin exports and lifecycle; -- hook creation/destruction; -- executable/module discovery; -- signature selection and validation; -- path encoding and normalization; -- GUI/headless capabilities; -- schema-generation triggers; -- runtime logging integration. - -## 9. Execution phases - -## Phase 0: governance, fork, and evidence baseline - -### Tasks - -1. Confirm the intended upstream contribution scope with the PalSchema - maintainer using issue #125 as context. -2. Resolve `LAP87` Epic/GitHub access: - - link Epic and GitHub accounts; - - accept the EpicGames organization invitation; - - verify UEPseudo access with a read-only GitHub request; - - rerun recursive submodule initialization without `--remote`. -3. Record: - - PalSchema commit; - - RE-UE4SS commit; - - UEPseudo commit; - - Palworld client build ID; - - native server build ID; - - installed UE4SS commit; - - Proton versions used. -4. Create `LAP87/PalSchema`. -5. Configure remotes: - - `origin` -> `LAP87/PalSchema`; - - `upstream` -> `Okaetsu/PalSchema`. -6. Create a feature branch from current upstream `main`. -7. Add CI before changing runtime logic so the original Windows target has a - recorded baseline. -8. Keep the generated Graphify analysis out of the upstream patch. - -### Exit gate - -- Recursive dependencies are reproducibly available to authorized builders. -- Baseline Windows CI either passes or has documented pre-existing failures. -- No Epic-restricted source or game asset appears in the fork or CI artifacts. - -### Current checkpoint - -Completed on 2026-07-25: - -- verified `LAP87` pull access to `Re-UE4SS/UEPseudo`; -- checked out UEPseudo at `b2e876da82b17254c04304746341c8fde0ddb37c`; -- completed a clean native CMake configure on CachyOS; -- created `LAP87/PalSchema`; -- configured `origin` as the fork and `upstream` as `Okaetsu/PalSchema`; -- created the `codex/linux-port` working branch; -- installed xwin `0.9.0`; -- completed the explicit Microsoft CRT/SDK license gate and prepared the - project-local xwin SDK at `$HOME/.cache/palschema/xwin`; -- installed an isolated rustup toolchain under - `$HOME/.cache/palschema/{rustup,cargo}` without replacing the CachyOS Rust - package; -- routed Corrosion's Cargo calls through a serialized lock-file guard that - restores the exact pre-build RE-UE4SS `Cargo.lock`, including after failure - or interruption; -- cross-compiled `build/win64-xwin-dev/PalSchema.dll` on CachyOS with - clang-cl `22.1.8`, Rust `1.97.1`, and the pinned recursive dependencies; -- verified the artifact as an x86-64 PE32+ DLL with the expected - `start_mod` and `uninstall_mod` exports and a dynamic `UE4SS.dll` import; -- recorded the first verified Dev artifact SHA-256 as - `00d71f702466b206f3e4957935800bfd6bb35746d779a2f8d106cbef56f3833b`; -- cross-compiled and inspected - `build/win64-xwin-shipping/PalSchema.dll`; -- verified the Shipping artifact as an x86-64 PE32+ DLL with ASLR, high-entropy - VA, NX compatibility, only the intended `start_mod` and `uninstall_mod` - exports, and a dynamic `UE4SS.dll` import; -- recorded the first verified Shipping artifact SHA-256 as - `21ba3aca1beaeb7b964aeea42969af3652ac5006e207aaffad05baf022768cc2`; -- rebuilt both Dev and Shipping through the guarded Cargo path and verified - that the pinned RE-UE4SS submodule remained clean; -- produced release-layout-compatible Shipping and Dev archives with - `PalSchema/dlls/main.dll`, plus Dev schemas, examples, editor settings, and - PDB symbols; -- passed an install/checksum/rollback/reinstall round trip against the local - Steam client using the atomic Proton deployment helper; -- loaded the Linux-built Shipping DLL in Palworld client build `24181527` - through the separately installed UE4SS `3.0.1 Beta` (`c2ac246`) and - CachyOS Proton; -- observed successful signature discovery, `PalSchema v0.6.1` startup, and - initialization of every current loader without a PalSchema error; -- stopped only the launched Windows client after the smoke test, rolled the - test mod back, moved the generated backup tree to the desktop trash, and - confirmed the existing native Dedicated Server remained running. - -Still required for the phase exit gate: - -- add and pass the Windows baseline CI lane; -- compare the xwin artifact contract with a Windows CI artifact. - -## Phase 1: reproducible Linux-hosted Win64 build - -### Tasks - -1. Add `CMakePresets.json` presets: - - `win64-xwin-shipping`; - - `win64-xwin-dev`; - - `win64-msvc-ci-shipping`; - - later `linux-server-shipping` and `linux-server-dev`. -2. Wrap the pinned RE-UE4SS xwin toolchain rather than duplicating it. -3. Add a bootstrap script that checks versions of: - - Git; - - CMake; - - Ninja; - - Rust; - - Clang/clang-cl; - - LLD/lld-link; - - xwin; - - Node for authoring tools. -4. Require explicit acceptance when xwin downloads Microsoft SDK content. -5. Add CMake options: - - `PALSCHEMA_BUILD_WIN64`; - - `PALSCHEMA_BUILD_LINUX_SERVER`; - - `PALSCHEMA_WITH_IMGUI`; - - `PALSCHEMA_BUILD_TESTS`; - - `PALSCHEMA_PACKAGE_DEV_ASSETS`. -6. Include `version.rc` only for Win64. -7. Replace the hard-coded export declaration with a platform export macro. -8. Set deterministic output names and locations matching release packaging. -9. Generate `compile_commands.json`. -10. Add reproducibility metadata containing compiler, SDK, target, and commit - IDs. -11. Build the unmodified runtime core into a Win64 DLL on CachyOS. -12. Compare the Linux-produced DLL with a Windows CI build: - - exported symbols; - - required imports; - - architecture; - - packaged file layout; - - runtime smoke behavior. - -### Exit gate - -- A fresh supported Linux host can produce the Win64 Dev and Shipping packages - from documented commands. -- The resulting DLL loads in the local Proton client using the separately - installed compatible UE4SS. -- Existing Windows build behavior has not regressed. - -## Phase 2: safe initialization and Proton hardening - -### Tasks - -1. Reduce the plugin constructor to: - - metadata; - - lightweight capability discovery; - - callback registration. -2. Move signature scanning and Unreal-offset initialization to a lifecycle - point that is guaranteed to be outside the loader lock. -3. Implement an initialization state machine: - - `Unloaded`; - - `WaitingForRuntime`; - - `Scanning`; - - `OffsetsReady`; - - `HooksReady`; - - `LoadersReady`; - - `Running`; - - `Failed`. -4. Add structured progress/error logging for every state transition. -5. Make signature scan concurrency explicit and testable. -6. Add timeout/failure handling that leaves the game/server alive when - possible. -7. Verify normal proxy loading with - `WINEDLLOVERRIDES="dwmapi.dll=n,b"`. -8. Test the UE4SS directory layout currently documented by upstream; do not - invent a PalSchema-specific DLL relocation. -9. Add path tests for: - - Steam native layout; - - Flatpak Steam layout; - - spaces; - - non-ASCII path components; - - Wine drive mappings; - - symlinked Steam libraries; - - case-sensitive directories. -10. Change auto-reload to coalesce Linux editor save sequences: - - direct modify; - - create + rename; - - temporary file + atomic replace; - - burst events. -11. Ensure no background callback accesses destroyed loader state. - -### Exit gate - -- No blocking scan runs from constructor/DllMain-adjacent execution. -- The Proton client starts repeatedly without intermittent hangs. -- Auto-reload works in VS Code, VSCodium, and one terminal editor. -- Failures name the missing signature, offset file, UE4SS version, or path. - -## Phase 3: platform/runtime service boundary - -### Tasks - -1. Introduce a runtime capability object: - - platform; - - target process type; - - GUI availability; - - hook API availability; - - schema generation availability; - - pak support; - - localization support. -2. Move plugin entry/exports into a dedicated adapter. -3. Introduce a hook backend interface for the three current inline hooks. -4. Keep SafetyHook in the Win64 adapter. -5. Use the official UE4SS native hook ABI in the Linux adapter. -6. Do not attempt to compile SafetyHook as the native Linux backend unless - upstream explicitly supports that configuration. -7. Introduce a UTF-8 path utility: - - canonical internal representation; - - conversion to UE4SS string types; - - safe logging; - - relative path calculation from the known PalSchema root. -8. Remove logic that searches path components for a literal `"PalSchema"` when - a known root-relative path can be used. -9. Make path comparisons intentionally case-sensitive or insensitive per - contract, never accidentally dependent on host behavior. -10. Separate the schema-generation service from its ImGui trigger. -11. Add alternate triggers: - - configuration on startup; - - console/runtime command when the UE4SS API supports it; - - test API. -12. Centralize compatibility checks and fail closed before installing hooks. - -### Exit gate - -- All existing loader code compiles without direct `_WIN32` branches. -- Platform branches are concentrated in platform adapters and build files. -- The Win64 target still passes the complete Proton matrix. - -## Phase 4: schema contract and standalone CLI - -### Tasks - -1. Move/copy release schemas into an explicit canonical schema tree. -2. Add stable `$id` fields and preserve JSON Schema draft-07 compatibility. -3. Add `schema-index.json` containing: - - schema ID; - - PalSchema version; - - Palworld build compatibility; - - mod-folder type; - - `*.json` and `*.jsonc` patterns; - - static/generated status; - - relative dependencies; - - content checksum. -4. Make runtime generation atomic: - - write temporary file; - - validate generated schema; - - rename into place; - - update manifest last. -5. Add generated-schema staleness detection. -6. Create a shared TypeScript schema core using the same validator for CLI and - editor diagnostics. -7. Implement CLI commands: - - `palschema init`; - - `palschema validate [paths...]`; - - `palschema validate --watch`; - - `palschema schemas list`; - - `palschema schemas verify`; - - `palschema doctor`; - - `palschema print-config`. -8. Use stable exit codes suitable for CI. -9. Support JSONC parsing without deleting comments from source files. -10. Validate every checked-in example. -11. Add invalid fixtures for every schema and common semantic mistake. -12. Keep runtime-only semantic checks clearly separate from JSON Schema checks. - -### Exit gate - -- All examples pass the CLI. -- Invalid fixtures fail with snapshot-tested diagnostics. -- CLI behavior is identical across Tier 1 distributions. -- No game, Steam, Proton, or UE4SS installation is required for static - validation. - -## Phase 5: LSP and editor packages - -### Tasks - -1. Build a Language Server Protocol process on the shared schema core. -2. Provide: - - diagnostics; - - completion; - - hover descriptions; - - schema selection by mod-folder type; - - generated-schema status; - - links to relevant PalSchema documentation. -3. Build a thin VS Code/VSCodium extension: - - starts the LSP; - - contributes static schema associations; - - supports `.json` and `.jsonc`; - - offers `PalSchema: Initialize Workspace`; - - offers `PalSchema: Validate Workspace`; - - offers `PalSchema: Run Doctor`; - - never installs or bundles UE4SS. -4. Publish: - - `.vsix` in GitHub releases; - - VS Code Marketplace package if upstream accepts it; - - Open VSX package for VSCodium. -5. Document generic LSP configuration for Neovim, Zed, and Helix. -6. Replace Windows-only documentation such as installer checkboxes and Explorer - context menus with platform-neutral steps. -7. Keep `code .` as an optional convenience, not a requirement. -8. Add editor integration tests using fixture workspaces. - -### Exit gate - -- VS Code and VSCodium show the same diagnostics as the CLI. -- A generic LSP client passes the protocol smoke suite. -- `.jsonc` receives the same schema selection as `.json`. -- Missing generated schemas produce an actionable message, not silent loss of - completion. - -## Phase 6: experimental native UE4SS adapter - -### Prerequisite - -Select a reviewed upstream UE4SS Linux commit or draft test branch. Record the -exact commit. Do not silently track a moving PR head. - -### Tasks - -1. Audit the native UE4SS ABI needed by PalSchema: - - C++ mod creation/destruction; - - lifecycle callbacks; - - object lookup; - - AssetRegistry access; - - game-thread dispatch; - - hook installation; - - logging; - - working/game directory discovery. -2. Produce a gap table and upstream missing generic UE4SS primitives instead of - embedding game-specific copies in PalSchema. -3. Add an ELF target with: - - hidden visibility by default; - - explicit exported symbols/version script; - - `$ORIGIN`-appropriate runtime lookup; - - no Win32 resources; - - no MASM; - - no Windows proxy generator. -4. Replace Win64 signature assumptions with a platform/build keyed catalog. -5. For every native signature: - - record the PalServer build ID; - - record pattern and expected match count; - - validate nearby instructions; - - fail when zero or multiple unsafe matches occur. -6. Audit class/member layouts under the Linux ABI. -7. Generate or consume Linux-specific member layout data; never reuse Win64 - offsets without proof. -8. Validate `FName`, `FString`, `TArray`, `UObject`, `UDataTable`, property, - and pak-related layouts before mutation. -9. Implement the native hook backend. -10. Guard client-only UI/appearance behavior by runtime capabilities. -11. Implement headless schema-generation trigger. -12. Build the native artifact in the conservative Linux builder container. -13. Audit ELF imports, RPATH, symbol versions, and exported ABI. - -### Exit gate - -- The native shared library loads into an isolated native PalServer. -- Server startup does not hang. -- Unsupported client-only features report themselves and no-op safely. -- Every server-applicable parity test passes. -- No Wine or Win32 runtime dependency is present. - -## Phase 7: isolated game integration tests - -### Test safety model - -- The currently running native server is production-like and read-only for this - project. -- Create a separate test install using SteamCMD or a verified copy/reflink. -- Use a distinct test root, save root, query port, game port, and RCON port. -- Never point a development deploy script at the live server path by default. -- Require an explicit `--target` and a sentinel file for any deploy. -- Back up only the test instance before mutation. -- Client deploys back up the previous PalSchema folder and support one-command - rollback. - -### Proton client suite - -1. Install the separately obtained compatible UE4SS. -2. Deploy the Linux-built Win64 PalSchema Dev package. -3. Assert version/commit compatibility before launch. -4. Launch with the documented DLL override. -5. Assert log milestones for every initialization state. -6. Run one fixture per loader. -7. Generate schemas and validate their manifest/checksums. -8. Modify fixtures while running and verify auto-reload. -9. Repeat on: - - Proton Stable; - - Proton Experimental; - - Proton-CachyOS SLR; - - one Flatpak-Steam environment; - - Steam Deck/SteamOS smoke hardware when available. - -### Native server suite - -1. Start isolated PalServer with empty disposable saves. -2. Load native UE4SS separately. -3. Load native PalSchema. -4. Assert startup deadline and health. -5. Run server-applicable loader fixtures. -6. Connect a test client where behavior requires replication validation. -7. Restart and verify persistence expectations. -8. Shut down gracefully and assert no corrupted files. -9. Repeat startup/stop cycles to catch races. -10. Run a bounded soak test. - -### Exit gate - -- Complete parity table is green. -- Logs and JUnit results are retained without game assets or personal saves. -- Repeated runs are deterministic. - -## Phase 8: cross-distro CI and release engineering - -### Public CI - -Public GitHub Actions may run: - -- Win64 MSVC build; -- Win64 xwin build in Linux; -- native Linux compile once supported; -- C++ unit and contract tests; -- schema validation; -- CLI/LSP tests; -- VSIX build; -- Debian/Ubuntu/Fedora/openSUSE/Arch container matrix; -- formatting and static analysis; -- artifact inspection; -- SBOM/checksum generation. - -Public CI must not contain or upload: - -- Palworld binaries or assets; -- production saves/config; -- UEPseudo source as an artifact; -- credentials/tokens; -- proprietary SDK payloads not allowed for redistribution. - -### Private/local hardware lane - -Real-game tests run on the CachyOS host or an authorized self-hosted runner. -Only sanitized test results and version metadata may leave the machine. - -### Release artifacts - -- `PalSchema__Win64.zip` -- `PalSchema__Win64_Dev.zip` -- `PalSchema__LinuxServer_x86_64.tar.zst` after native stabilization -- `PalSchema__LinuxServer_x86_64_Dev.tar.zst` -- `palschema-tools_.tar.gz` -- `palschema-vscode-.vsix` -- checksums -- SBOM -- `compatibility.json` -- `THIRD_PARTY_NOTICES` - -UE4SS binaries are not included. - -### Compatibility manifest - -The manifest should declare: - -- PalSchema version and commit; -- target ABI; -- supported Palworld build IDs/ranges; -- required UE4SS release/commit; -- required member-layout/signature data version; -- schema pack version; -- supported architecture; -- experimental/stable status. - -## Phase 9: documentation and final upstream PR - -### Documentation - -Add: - -- Linux quick start; -- Linux build prerequisites; -- xwin license/download explanation; -- Steam native and Flatpak paths; -- Proton launch options; -- separate UE4SS installation requirement; -- supported UE4SS compatibility table; -- native server experimental/stable status; -- isolated server-test instructions; -- VS Code/VSCodium extension setup; -- generic LSP setup; -- CLI reference; -- troubleshooting/doctor output reference; -- distro support policy; -- release verification and rollback. - -Remove or qualify Windows-only assumptions in existing docs. - -### Git history - -Keep commits reviewable: - -1. CI and baseline tests -2. Linux-hosted Win64 build -3. safe initialization -4. platform abstraction -5. schema manifest and CLI -6. LSP/editor packages -7. native adapter -8. integration tests -9. packaging/docs - -### Pull request strategy - -The requested end state is one upstream PR containing the finished Linux -variant. To keep that PR reviewable: - -- develop in coherent commits; -- keep generated binaries out of Git; -- open a draft only after the Linux-hosted Win64 vertical slice works; -- continuously rebase/merge upstream `main` without rewriting published - evidence; -- include the full parity matrix and exact tested versions; -- convert to ready-for-review only when release gates pass. - -If the maintainer asks for separate PRs, split along the commit boundaries -above rather than forcing an unreviewable monolith. - -## 10. Licensing and distribution policy - -### Fixed decisions - -- PalSchema code remains under its upstream MIT license. -- RE-UE4SS is not bundled in PalSchema releases. -- Users install UE4SS separately from its official/maintainer-approved source. -- PalSchema may verify UE4SS version/checksum and provide official links. -- PalSchema must not copy or publish UEPseudo or Epic-restricted sources. -- Build instructions may require authorized users to fetch those dependencies. -- Every shipped third-party component receives its required notices. -- Release SBOMs distinguish shipped dependencies from build-only dependencies. - -### Why not bundle UE4SS even though it is MIT - -The MIT license would generally permit redistribution of RE-UE4SS itself when -its notice is preserved. The default no-bundle decision is still preferable: - -- users already manage UE4SS as the common runtime for multiple mods; -- UE4SS security and injection fixes can update independently; -- PalSchema is pinned to a compatibility range that can be checked explicitly; -- private/Epic-licensed build inputs must not accidentally enter artifacts; -- native UE4SS support is not stable yet; -- the upstream PalSchema distribution already documents UE4SS separately. - -This is a technical/distribution policy, not a claim that MIT forbids all -redistribution. - -## 11. Test design details - -### 11.1 C++ unit tests without Palworld - -Extract testable pure logic for: - -- JSON/JSONC parsing; -- path normalization; -- mod-folder classification; -- wildcard filters; -- schema reference generation; -- configuration defaults/repair; -- file event coalescing; -- signature catalog selection; -- capability gating; -- initialization state transitions. - -### 11.2 Contract tests - -Use identical fixtures against Win64 and native adapters: - -- expected loader registration; -- expected mod discovery order; -- expected diagnostics; -- expected schema IDs; -- expected log event IDs; -- expected safe no-op behavior. - -### 11.3 Schema tests - -- Validate all examples. -- Resolve every `$ref` offline. -- Assert no duplicate `$id`. -- Assert draft-07 conformance. -- Test both `.json` and `.jsonc`. -- Test malformed JSONC. -- Test missing generated schemas. -- Test stale generated schemas. -- Test wrong mod-folder/schema pair. -- Snapshot diagnostics without machine-specific absolute paths. - -### 11.4 File watcher tests - -Exercise: - -- in-place write; -- truncate + write; -- temp-file rename; -- delete + recreate; -- nested mod directories; -- Unicode filenames; -- rapid repeated saves; -- watcher shutdown during queued callback. - -### 11.5 ABI and binary tests - -Win64: - -- PE x86-64; -- expected `start_mod`/`uninstall_mod` exports; -- no accidental Linux imports; -- required UE4SS imports recorded. - -Linux: - -- ELF x86-64; -- expected exports only; -- no `TEXTREL`; -- controlled RPATH/RUNPATH; -- conservative GLIBC/GLIBCXX requirements; -- no unresolved symbols before injection/load. - -## 12. Risk register - -| Risk | Impact | Mitigation | Release gate | -| --- | --- | --- | --- | -| UEPseudo access unavailable | Cannot build authoritative dependency graph | User links Epic/GitHub and accepts invitation; never bypass licensing | Recursive clone succeeds | -| Native UE4SS ABI changes | Native adapter churn | Pin reviewed commit, isolate adapter, upstream generic gaps | Exact UE4SS commit in manifest | -| SafetyHook is Win64-specific | Native hooks unavailable | Hook backend interface using official native UE4SS API | Native hook tests pass | -| Windows and Linux signatures differ | Crash/corruption | Platform/build catalog, match-count and instruction validation | Fail closed on unknown build | -| Unreal layouts differ by ABI | Silent memory corruption | Platform layout data and runtime sanity checks | Representative layout suite passes | -| Constructor scan deadlock | Startup hang under Wine/Proton | Defer heavy initialization and add state machine/timeouts | Repeated launch suite | -| Case-sensitive paths | Mods/schemas not found | Root-relative UTF-8 path service and tests | Path matrix passes | -| Atomic editor saves | Auto-reload misses or duplicates | Event debounce/coalescing | Editor save matrix passes | -| Rolling distro toolchain drift | Builds break unexpectedly | Pinned containers/presets and conservative artifact baseline | Tier 1 build matrix | -| Flatpak sandbox paths | Installer/doctor cannot find game | Steam library discovery abstraction and explicit overrides | Flatpak smoke | -| Live server damage | Save loss/downtime | Never test live; isolated instance, ports, saves, sentinel deploy | Safety preflight passes | -| Huge upstream PR | Maintainer cannot review | Coherent commits, draft after vertical slice, split on request | Maintainer-ready checklist | -| Bundled restricted material | Legal/distribution problem | No UE4SS/UEPseudo/game assets in releases or CI artifacts | SBOM and artifact audit | - -## 13. Release gates - -### Gate A: Linux development bootstrap - -- Authorized recursive dependencies -- xwin cross-build -- Windows CI baseline -- documented one-command bootstrap/build - -### Gate B: Proton runtime parity - -- Complete feature matrix on local client -- repeated-start stability -- auto-reload/editor-save matrix -- schema generation -- rollback-capable deploy - -### Gate C: authoring-tool parity - -- CLI, LSP, VS Code/VSCodium -- all examples and invalid fixtures -- Tier 1 distro CI -- editor-neutral docs - -### Gate D: experimental native server - -- reviewed pinned native UE4SS -- ELF build -- platform layouts/signatures -- isolated server boots and passes applicable fixtures - -### Gate E: stable native server - -- native UE4SS dependency is considered suitable by upstream -- no experimental compatibility override -- repeated/soak tests -- Tier 1 server matrix -- maintainer-approved packaging/docs - -### Gate F: ready upstream PR - -- all applicable gates green -- no proprietary artifacts -- license/SBOM audit -- clean diff -- final parity table -- release notes and rollback notes - -## 14. First implementation slice after plan approval - -The first coding slice should be deliberately narrow and end-to-end: - -1. Resolve UEPseudo access. -2. Create the `LAP87/PalSchema` fork and feature branch. -3. Add Windows baseline CI. -4. Add xwin CMake preset and Linux bootstrap script. -5. Replace the export macro and guard Win64 resources. -6. Build the current Win64 PalSchema DLL on CachyOS. -7. Package it without UE4SS. -8. Deploy it with backup/rollback into the local Proton client. -9. Confirm load and one raw-table fixture. -10. Publish evidence in a draft PR description. - -Only after this vertical slice passes should the work expand into lifecycle -hardening, authoring tools, and the native server adapter. - -## 15. Explicit non-goals - -- Reimplementing all of UE4SS inside PalSchema. -- Shipping UE4SS binaries in PalSchema releases. -- Circumventing Epic/UEPseudo access controls. -- Testing against the currently live native server or its production saves. -- Claiming native client support when the Palworld desktop client is Win64. -- Calling a compile-only ELF artifact "Linux support." -- Supporting only CachyOS/Arch and labeling it cross-distro. -- Maintaining separate duplicated Windows and Linux copies of every loader. -- Publishing an upstream PR before there is reproducible runtime evidence. - -## 16. Completion statement - -The work is complete when a PalSchema mod author can use a mainstream Linux -distribution to install editor tooling, validate and author every supported mod -type, build PalSchema's Win64 runtime on Linux, run and hot-reload those mods in -the Palworld client under Proton, and—once the official native UE4SS path is -suitable—run every server-applicable feature in a native isolated Palworld -Dedicated Server, with reproducible artifacts and an evidence-backed upstream -pull request. diff --git a/scripts/bootstrap-linux.sh b/scripts/bootstrap-linux.sh index 90f1635..8bbcfb9 100755 --- a/scripts/bootstrap-linux.sh +++ b/scripts/bootstrap-linux.sh @@ -65,6 +65,7 @@ required_commands=( git ninja python3 + realpath sha256sum sync ) @@ -179,6 +180,25 @@ if [[ -n "${XWIN_DIR:-}" ]]; then else palschema_xwin_dir="$PALSCHEMA_CACHE_ROOT/xwin" fi +palschema_xwin_dir="$(realpath -m -- "$palschema_xwin_dir")" +palschema_cache_root_real="$(realpath -m -- "$PALSCHEMA_CACHE_ROOT")" +project_root="$(cd -- "$script_dir/.." && pwd)" +user_home_real="$(realpath -m -- "${HOME:?HOME must be set}")" + +path_is_within() { + local candidate="$1" + local root="$2" + [[ "$candidate" == "$root" || "$candidate" == "$root/"* ]] +} + +if [[ "$palschema_xwin_dir" == "/" ]] || + path_is_within "$user_home_real" "$palschema_xwin_dir" || + path_is_within "$project_root" "$palschema_xwin_dir" || + path_is_within "$palschema_xwin_dir" "$project_root"; then + printf 'Unsafe XWIN_DIR overlaps a protected path: %s\n' \ + "$palschema_xwin_dir" >&2 + exit 1 +fi xwin_cache_payload_is_valid() { local cache_dir="$1" @@ -211,8 +231,23 @@ PY xwin_parent="$(dirname -- "$palschema_xwin_dir")" xwin_name="$(basename -- "$palschema_xwin_dir")" xwin_previous="$xwin_parent/.${xwin_name}.previous" + +if [[ -e "$palschema_xwin_dir" ]] && + ! xwin_cache_is_ready "$palschema_xwin_dir" && + ! path_is_within "$palschema_xwin_dir" "$palschema_cache_root_real"; then + printf 'Refusing to replace an unowned XWIN_DIR outside %s: %s\n' \ + "$palschema_cache_root_real" "$palschema_xwin_dir" >&2 + exit 1 +fi +if [[ -e "$xwin_previous" ]] && + ! xwin_cache_is_ready "$xwin_previous"; then + printf 'Refusing to replace an unowned SDK recovery directory: %s\n' \ + "$xwin_previous" >&2 + exit 1 +fi + mkdir -p "$xwin_parent" -exec {xwin_lock_fd}> "$xwin_parent/.${xwin_name}.lock" +exec {xwin_lock_fd}>> "$xwin_parent/.${xwin_name}.lock" flock "$xwin_lock_fd" # Recover the last complete cache if a previous refresh was interrupted between diff --git a/scripts/deploy-proton.sh b/scripts/deploy-proton.sh index 3a50f08..191806f 100755 --- a/scripts/deploy-proton.sh +++ b/scripts/deploy-proton.sh @@ -365,12 +365,23 @@ recover_incomplete_transaction() { stale_stage="$mods_dir/$stage_name" if [[ -d "$stale_stage" && ! -L "$stale_stage" ]]; then assert_tree_has_no_symlinks "$stale_stage" - # The marker is durable before the atomic swap. If power is lost - # between the swap and moving the previous tree into its backup, - # either target is still a complete tree; never guess by swapping - # an ambiguous stage back over the live target. - rm -rf -- "$stale_stage" - fsync_directory "$mods_dir" + if [[ -d "$target_dir" && + ! -e "$recovery_backup/PalSchema" && + ! -f "$recovery_backup/no-previous-install" ]]; then + # The stage is the new candidate before the exchange and the + # previous live tree after it. Preserve it in either case: + # recovery must not guess which side of the atomic exchange + # reached disk. + rename_directory "$stale_stage" \ + "$recovery_backup/PalSchema" + fsync_directory "$mods_dir" + fsync_directory "$recovery_backup" + printf 'Preserved interrupted PalSchema stage at %s\n' \ + "$recovery_backup/PalSchema" + else + rm -rf -- "$stale_stage" + fsync_directory "$mods_dir" + fi fi fi rm -f -- "$transaction_marker" diff --git a/scripts/lib/process-scan.sh b/scripts/lib/process-scan.sh index 1244002..4c5877d 100644 --- a/scripts/lib/process-scan.sh +++ b/scripts/lib/process-scan.sh @@ -9,8 +9,14 @@ palschema_target_process_status() { local cmdline_fd local argument local incomplete=false + local cmdlines=("$proc_root"/[0-9]*/cmdline) - for cmdline in "$proc_root"/[0-9]*/cmdline; do + if [[ ! -d "$proc_root" || ! -r "$proc_root" || + "${cmdlines[0]}" == "$proc_root/[0-9]*/cmdline" ]]; then + return 2 + fi + + for cmdline in "${cmdlines[@]}"; do if ! { exec {cmdline_fd}<"$cmdline"; } 2>/dev/null; then # A process that vanished between glob expansion and open is benign. if [[ -e "$cmdline" ]]; then diff --git a/scripts/tests/test-bootstrap-cache.sh b/scripts/tests/test-bootstrap-cache.sh index 527c3f4..0288a7f 100755 --- a/scripts/tests/test-bootstrap-cache.sh +++ b/scripts/tests/test-bootstrap-cache.sh @@ -150,6 +150,30 @@ if [[ "$(cat "$FAKE_XWIN_COUNTER")" != "1" || exit 1 fi +# A custom XWIN_DIR outside the PalSchema cache must never replace an +# unrelated existing directory. +export PALSCHEMA_CACHE_ROOT="$test_root/owned-cache" +export XWIN_DIR="$test_root/unowned-sdk" +export FAKE_XWIN_COUNTER="$test_root/unowned-xwin-count" +mkdir -p "$XWIN_DIR" +printf '%s\n' "keep-me" > "$XWIN_DIR/sentinel" +set +e +"$project_root/scripts/bootstrap-linux.sh" \ + --prepare-sdk --accept-microsoft-license \ + >"$test_root/unowned.out" 2>"$test_root/unowned.err" +unowned_status=$? +set -e +if ((unowned_status == 0)) || + ! grep -q "Refusing to replace an unowned XWIN_DIR" \ + "$test_root/unowned.err" || + [[ "$(cat "$XWIN_DIR/sentinel")" != "keep-me" ]]; then + printf '%s\n' "Bootstrap did not preserve an unowned XWIN_DIR." >&2 + exit 1 +fi +export PALSCHEMA_CACHE_ROOT="$test_root/legacy-cache" +export XWIN_DIR="$PALSCHEMA_CACHE_ROOT/xwin" +export FAKE_XWIN_COUNTER="$test_root/legacy-xwin-count" + # --install-rust-toolchain must select the PalSchema-local rustup even when # system cargo and rustc are already available. isolated_cargo="$test_root/isolated-cargo" diff --git a/scripts/tests/test-deploy-transaction.sh b/scripts/tests/test-deploy-transaction.sh index eb40b68..02ca4b6 100755 --- a/scripts/tests/test-deploy-transaction.sh +++ b/scripts/tests/test-deploy-transaction.sh @@ -188,6 +188,30 @@ if ! grep -R -q "power-loss-old-install" "$backup_root"/*/PalSchema/dlls/main.dl exit 1 fi +# If power is lost after the atomic exchange but before the old tree reaches +# its backup, recovery must preserve the ambiguous stage instead of deleting it. +post_swap_id="20000101T000000Z-6" +post_swap_stage=".PalSchema.stage.POSTSWAP" +mkdir -p \ + "$backup_root/$post_swap_id" \ + "$mods_root/$post_swap_stage/dlls" +printf '%s\n' "post-swap-previous-install" \ + > "$mods_root/$post_swap_stage/dlls/main.dll" +printf '%s\n%s\n' "$post_swap_id" "$post_swap_stage" \ + > "$backup_root/.active-transaction" +PATH="$fake_bin:$PATH" \ + "$project_root/scripts/deploy-proton.sh" shipping \ + --target server \ + --game-dir "$game_root" \ + >"$test_root/post-swap.out" +if ! grep -q "Preserved interrupted PalSchema stage" \ + "$test_root/post-swap.out" || + ! grep -R -q "post-swap-previous-install" \ + "$backup_root"/*/PalSchema/dlls/main.dll; then + printf '%s\n' "Post-exchange recovery discarded the previous installation." >&2 + exit 1 +fi + live_hash="$(sha256sum "$target_root/dlls/main.dll" | awk '{print $1}')" missing_id="20000101T000000Z-3" printf '%s\n\n' "$missing_id" > "$backup_root/.active-transaction" diff --git a/scripts/tests/test-process-scan.sh b/scripts/tests/test-process-scan.sh index bd1d856..056b357 100755 --- a/scripts/tests/test-process-scan.sh +++ b/scripts/tests/test-process-scan.sh @@ -29,6 +29,18 @@ if ((negative_status != 1)); then fi rm -rf -- "$test_root/200" +set +e +palschema_target_process_status server "$test_root" +empty_status=$? +palschema_target_process_status server "$test_root/missing" +missing_status=$? +set -e +if ((empty_status != 2 || missing_status != 2)); then + printf 'Expected empty and missing proc roots to fail closed, got %s and %s.\n' \ + "$empty_status" "$missing_status" >&2 + exit 1 +fi + mkdir -p "$test_root/300" python3 - "$test_root/300/cmdline" <<'PY' import socket diff --git a/tools/palschema-tools/src/text-position.ts b/tools/palschema-tools/src/text-position.ts index 06f6b44..5f48f09 100644 --- a/tools/palschema-tools/src/text-position.ts +++ b/tools/palschema-tools/src/text-position.ts @@ -32,11 +32,3 @@ export function createPositionMapper(text: string): PositionMapper { }; }; } - -export function positionAt( - text: string, - offset: number, - length = 1, -): ReturnType { - return createPositionMapper(text)(offset, length); -}