feat: add self-contained offline development resources
Some checks failed
real-verification / chrome (push) Has been cancelled
real-verification / freecad-oracle (push) Has been cancelled
real-verification / wasm (push) Has been cancelled

This commit is contained in:
2026-08-12 02:36:09 -04:00
parent 97967041e2
commit ca6fe46030
14 changed files with 1102 additions and 12 deletions

View File

@@ -2,6 +2,8 @@
FreeCAD-aligned web CAD frontend baseline for the BitBybit runtime boundary. FreeCAD-aligned web CAD frontend baseline for the BitBybit runtime boundary.
本机断网开发、资源同步、校验和恢复入口见 [离线资源库说明](docs/offline-development.zh-CN.md)。
## Current scope ## Current scope
- React + Vite frontend shell - React + Vite frontend shell
@@ -25,7 +27,7 @@ The current milestone is a facade-first integration slice. Part Design document
./npmw run dev ./npmw run dev
``` ```
The repository uses a project-local, checksum-verified Node.js `22.23.2` runtime and npm `10.9.8`. The wrapper downloads it to the ignored `.runtime/` directory and never changes `/usr/bin/node`; this removes the engine warning caused by running npm with an older system Node. `./nodew` runs the same pinned Node directly. See [docs/project-runtime.zh-CN.md](docs/project-runtime.zh-CN.md) for recovery and CI rules. The repository uses a project-local, checksum-verified Node.js `22.23.2` runtime and npm `10.9.8`. The wrapper restores it from the local resource library first, or downloads it when online, into the ignored `.runtime/` directory and never changes `/usr/bin/node`; this removes the engine warning caused by running npm with an older system Node. `./nodew` runs the same pinned Node directly. See [docs/project-runtime.zh-CN.md](docs/project-runtime.zh-CN.md) for recovery and CI rules.
Build verification: Build verification:

View File

@@ -0,0 +1,215 @@
{
"schemaVersion": 1,
"project": "bitbybit-web-cad",
"libraryDirectory": "web-freecad-bitbybit",
"platform": "linux-x64-debian13",
"node": {
"version": "22.23.2",
"npmVersion": "10.9.8",
"archive": ".runtime/downloads/node-v22.23.2-linux-x64.tar.xz",
"libraryPath": "toolchains/node/node-v22.23.2-linux-x64.tar.xz"
},
"npmLocks": [
"package-lock.json",
"cnc_wams_gpt6/linuxcnc-master/web/package-lock.json"
],
"archives": [
{
"id": "project-node-modules",
"source": "node_modules",
"restore": "node_modules",
"libraryPath": "archives/project-node-modules.tar.zst",
"required": true
},
{
"id": "freecad-source",
"source": ".cache/freecad/FreeCAD",
"restore": ".cache/freecad/FreeCAD",
"libraryPath": "sources/freecad-1.1.1-0108fd4b.tar.zst",
"revision": "0108fd4b4850cc46e625b60e53cea7a7bbe69f8d",
"required": true
},
{
"id": "bitbybit-source",
"source": ".cache/bitbybit/bitbybit",
"restore": ".cache/bitbybit/bitbybit",
"libraryPath": "sources/bitbybit-d3f52bd1.tar.zst",
"revision": "d3f52bd1fcf78148b60800d69ed6b42e53f4ad06",
"required": true
},
{
"id": "occt-source",
"sourceEnvironment": "OCCT_SOURCE_DIR",
"sourceCandidates": [
".cache/occt/occt",
"../working/WebCam/occt"
],
"restore": ".cache/occt/occt",
"libraryPath": "sources/occt-8-bb75017e.tar.zst",
"revision": "bb75017e915f6509a4bfa8b551f853352754c2da",
"required": true
},
{
"id": "opencamlib-source",
"source": "OpenCAMLib",
"restore": "OpenCAMLib",
"libraryPath": "sources/opencamlib-95b036fe.tar.zst",
"revision": "95b036fe28ce6d77c97b98e5fbc337904ae49560",
"required": true
},
{
"id": "camotics-worktree",
"source": "CAMotics",
"restore": "CAMotics",
"libraryPath": "sources/camotics-e84665f2-worktree.tar.zst",
"revision": "e84665f2fa9d1151f03282ac7e01320bc65e015b",
"preserveWorktree": true,
"required": true
},
{
"id": "cbang-worktree",
"source": ".cache/camotics-native/cbang",
"restore": ".cache/camotics-native/cbang",
"libraryPath": "sources/cbang-9b6672a0-worktree.tar.zst",
"revision": "9b6672a0e2b800a909d799ffa3ab46774cf171b8",
"required": true
},
{
"id": "linuxcnc-worktree",
"source": "cnc_wams_gpt6/linuxcnc-master",
"restore": "cnc_wams_gpt6/linuxcnc-master",
"libraryPath": "sources/linuxcnc-web-58f50e6d-worktree.tar.zst",
"revision": "58f50e6d66287bc31e6d906e5c33658a348658e4",
"preserveWorktree": true,
"required": true
},
{
"id": "freecad-native-oracle",
"source": ".cache/freecad/install-native",
"restore": ".cache/freecad/install-native",
"libraryPath": "artifacts/freecad-install-native.tar.zst",
"required": true
},
{
"id": "freecad-desktop-oracle",
"source": ".cache/freecad/install-desktop",
"restore": ".cache/freecad/install-desktop",
"libraryPath": "artifacts/freecad-install-desktop.tar.zst",
"required": true
},
{
"id": "freecad-sysroot",
"source": ".cache/freecad/sysroot",
"restore": ".cache/freecad/sysroot",
"libraryPath": "artifacts/freecad-sysroot.tar.zst",
"required": true
},
{
"id": "occt-history-dist",
"source": "native/occt-history/dist",
"restore": "native/occt-history/dist",
"libraryPath": "artifacts/occt-history-dist.tar.zst",
"required": true
},
{
"id": "planegcs-dist",
"source": "native/planegcs/dist",
"restore": "native/planegcs/dist",
"libraryPath": "artifacts/planegcs-dist.tar.zst",
"required": true
},
{
"id": "freecad-build-native",
"source": ".cache/freecad/build-native",
"restore": ".cache/freecad/build-native",
"libraryPath": "build-caches/freecad-build-native.tar.zst",
"required": false
},
{
"id": "freecad-build-desktop",
"source": ".cache/freecad/build-desktop",
"restore": ".cache/freecad/build-desktop",
"libraryPath": "build-caches/freecad-build-desktop.tar.zst",
"required": false
},
{
"id": "occt-history-build",
"source": ".cache/bitbybit/occt-history-build-offline",
"restore": ".cache/bitbybit/occt-history-build-offline",
"libraryPath": "build-caches/occt-history-build.tar.zst",
"required": false
},
{
"id": "opencamlib-build",
"source": ".cache/opencamlib-wasm",
"restore": ".cache/opencamlib-wasm",
"libraryPath": "build-caches/opencamlib-wasm.tar.zst",
"required": false
},
{
"id": "opencamlib-boost",
"source": ".cache/opencamlib-boost",
"restore": ".cache/opencamlib-boost",
"libraryPath": "build-caches/opencamlib-boost.tar.zst",
"required": false
}
],
"mirrors": [
{
"id": "playwright-browsers",
"sourceEnvironment": "PLAYWRIGHT_BROWSERS_PATH",
"sourceCandidates": [
"~/.cache/ms-playwright"
],
"libraryPath": "browsers/ms-playwright",
"required": true
},
{
"id": "boost-headers",
"sourceCandidates": [
"/usr/include/boost"
],
"libraryPath": "system/headers/boost",
"restore": ".cache/offline-sysroot/include/boost",
"required": true
},
{
"id": "eigen3-headers",
"sourceCandidates": [
"/usr/include/eigen3"
],
"libraryPath": "system/headers/eigen3",
"restore": ".cache/offline-sysroot/include/eigen3",
"required": true
},
{
"id": "debian-package-cache",
"sourceCandidates": [
"/var/cache/apt/archives"
],
"libraryPath": "system/debian13-amd64/packages",
"includePattern": "\\.deb$",
"recursive": false,
"required": true
},
{
"id": "freecad-package-cache",
"source": ".cache/freecad",
"libraryPath": "system/debian13-amd64/freecad-packages",
"includePattern": "\\.deb$",
"required": true
}
],
"hostTools": {
"node": "22.23.2",
"npm": "10.9.8",
"cmake": "3.31.6",
"ninja": "1.12.1",
"emscripten": "3.1.69",
"chrome": "150.0.7871.128",
"firefox": "140.12.0esr",
"playwright": "1.61.1",
"boost": "1.83.0",
"eigen3": "3.4.0"
}
}

View File

@@ -0,0 +1,91 @@
# 本机离线资源库
本项目的统一资源库默认位于:
```text
/home/mes123456/resource-library/web-freecad-bitbybit
```
可通过 `WEB_FREECAD_RESOURCE_LIBRARY` 或各命令的 `--library=PATH` 改为其他位置。资源库不进入项目 Git仓库保存资源定义资源库自己的 `manifest.json` 保存每个归档和镜像的字节数、文件数、SHA-256、上游提交及工作树状态。
## 资源范围
`config/offline-resources.json` 固定以下资源:
- Node 22.23.2 原始发行包、两个 `package-lock.json` 对应的最小 npm 内容缓存,以及当前项目 `node_modules` 快照;
- FreeCAD 1.1.1(含 GSL、OndselSolver、AddonManager、测试子模块、bitbybit、OCCT 8、OpenCAMLib、CAMotics/C! 和 LinuxCNC Web 工作树;
- FreeCAD headless/desktop Oracle、FreeCAD sysroot、OCCT/PlaneGCS 原生 Worker 制品和可选增量构建目录;
- Playwright Chromium/Firefox/WebKit、Boost/Eigen 头文件,以及本机 `/var/cache/apt/archives` 中的 Debian 安装包池。
LinuxCNC 和 CAMotics 存在尚未合入各自上游提交的本地适配,因此保存完整工作树快照。`working_ocl/` 是上述源码的历史重复副本,不作为规范资源;构建入口实际使用的 `OpenCAMLib/``CAMotics/``.cache/bitbybit/bitbybit` 才进入资源库。
OCCT 增量构建缓存固定恢复到 `.cache/bitbybit/occt-history-build-offline`,其 CMake 缓存引用项目内 `.cache/occt/occt`,不包含旧工作区的绝对源码路径。
## 同步与校验
```bash
./npmw run offline:inventory
./npmw run offline:sync
./npmw run offline:check
./npmw run offline:smoke
```
`offline:sync` 只读取现有本机资源,不从互联网下载。首次同步会压缩数 GB 的源码和构建缓存;后续在上游提交、本地适配、工具链或 lockfile 变化后重新执行。
`offline:check` 逐项重新计算 SHA-256。`offline:smoke` 先在临时空目录用资源库 npm 缓存执行 `npm ci --offline`,再运行运行时/FreeCAD 源码/原生 Worker 检查和生产构建。npm 的 `--offline` 为强制模式,缺包时直接失败,不会访问 registry。资源库保持只读离线 shell 按 manifest 哈希把 npm 内容初始化到项目 `.cache/offline-npm`npm 的访问索引只写入这份工作缓存。
## 从资源库恢复
恢复操作必须显式限定资源,且默认拒绝覆盖现有目录:
```bash
./npmw run offline:restore -- --only=project-node-modules,freecad-source,occt-source
```
确认目标可替换后使用:
```bash
./npmw run offline:restore -- --only=project-node-modules --force
```
也可验证到独立临时工作目录:
```bash
target_dir="$(mktemp -d)"
./npmw run offline:restore -- --target="$target_dir" --only=freecad-source
```
不要对工作区根目录使用递归删除;恢复工具会检查每个目标必须位于指定 `--target` 下。
## 离线开发环境
以下命令启动一个明确禁止 npm 网络回退的 shell
```bash
./scripts/offline-shell.sh
```
也可以直接运行单条命令:
```bash
./scripts/offline-shell.sh ./npmw run dev
./scripts/offline-shell.sh ./npmw run build
./scripts/offline-shell.sh ./npmw run ci:real -- --lane=wasm
```
环境中会设置 `npm_config_offline=true``PLAYWRIGHT_BROWSERS_PATH``FIREFOX_BIN``WEBKIT_BIN``FREECAD_SOURCE_OFFLINE=1`、项目内 `OCCT_SOURCE_DIR` 和固定 Chrome 路径。Firefox/WebKit 使用 manifest 中实际归档的可执行 revision不要求它恰好等于当前 Playwright 包的默认下载 revision。Node bootstrap 在项目 `.runtime/downloads` 缺少发行包时也会先查资源库;设置 `WEB_FREECAD_OFFLINE=1` 后若资源库仍缺包,会直接失败而不调用 `curl`
## 系统包恢复边界
源码、npm、Node、浏览器缓存和项目制品可以直接恢复到普通用户目录。CMake、Ninja、Emscripten、Chrome、Firefox、Qt、V8 和 FreeCAD 原生依赖属于 Debian 系统包;资源库保留了本机 apt 包池,并生成标准 `Packages`/`Packages.gz` 索引。安装系统包仍需要管理员权限。例如先把两个本地目录登记成仅本机 apt 源:
```bash
printf '%s\n' \
'deb [trusted=yes] file:/home/mes123456/resource-library/web-freecad-bitbybit/system/debian13-amd64/packages ./' \
'deb [trusted=yes] file:/home/mes123456/resource-library/web-freecad-bitbybit/system/debian13-amd64/freecad-packages ./' \
| sudo tee /etc/apt/sources.list.d/web-freecad-offline.list
sudo apt-get update -o Dir::Etc::sourcelist=/etc/apt/sources.list.d/web-freecad-offline.list -o Dir::Etc::sourceparts=-
sudo apt-get --no-download install emscripten cmake ninja-build
```
这一步只在系统工具被卸载或重装主机时需要。日常断网开发直接使用当前已安装工具链。

View File

@@ -4,7 +4,7 @@
本项目的 `package.json` 声明 Node.js `>=22`,而原开发环境实际使用系统 `/usr/bin/node v20.19.2``/usr/bin/npm 9.2.0`。npm 的 `EBADENGINE` 是运行 npm 的解释器版本不满足依赖声明所产生的警告;仅修改 `engines`、PATH 文档或 Vite 配置都不会改变当前解释器。 本项目的 `package.json` 声明 Node.js `>=22`,而原开发环境实际使用系统 `/usr/bin/node v20.19.2``/usr/bin/npm 9.2.0`。npm 的 `EBADENGINE` 是运行 npm 的解释器版本不满足依赖声明所产生的警告;仅修改 `engines`、PATH 文档或 Vite 配置都不会改变当前解释器。
项目现在锁定 Node.js `22.23.2` 与 npm `10.9.8`,并把官方归档下载到被 Git 忽略的 `.runtime/``./npmw``./nodew``scripts/bootstrap-node.sh` 是项目运行时的唯一启动路径,系统 Node 不会被替换或修改。 项目现在锁定 Node.js `22.23.2` 与 npm `10.9.8`,并把官方归档恢复或下载到被 Git 忽略的 `.runtime/`启动器优先读取本机统一资源库,在线模式仅在资源库缺失时访问官方发行目录。`./npmw``./nodew``scripts/bootstrap-node.sh` 是项目运行时的唯一启动路径,系统 Node 不会被替换或修改。
版本与校验值来源: 版本与校验值来源:
@@ -51,7 +51,7 @@
./npmw --version ./npmw --version
``` ```
期望输出分别为 `v22.23.2``10.9.8`。第一次运行会下载约 30 MB 的 Node Linux x64 归档,后续运行复用校验通过的缓存。 期望输出分别为 `v22.23.2``10.9.8`。第一次运行会优先从 `~/resource-library/web-freecad-bitbybit` 恢复约 30 MB 的 Node Linux x64 归档;在线且资源库缺失时才下载,后续运行复用校验通过的缓存。
## 4. 启动器的安全行为 ## 4. 启动器的安全行为
@@ -73,7 +73,7 @@
**仍出现 `EBADENGINE`**:检查命令是否以 `./npmw` 开头;检查 `./nodew --version`。直接执行系统 `npm``.npmrc``engine-strict=true` 下应失败,这是故意的防误用信号。 **仍出现 `EBADENGINE`**:检查命令是否以 `./npmw` 开头;检查 `./nodew --version`。直接执行系统 `npm``.npmrc``engine-strict=true` 下应失败,这是故意的防误用信号。
**下载失败**:确认能访问 `https://nodejs.org`,删除确定的 `.runtime/downloads/node-v22.23.2-*` 缓存后重新执行 `./npmw install`。不要手工替换归档或跳过校验。 **归档不可用**先执行 `./npmw run offline:check` 检查统一资源库。在线模式可确认能访问 `https://nodejs.org`,删除确定的 `.runtime/downloads/node-v22.23.2-*` 缓存后重新执行 `./npmw install`。不要手工替换归档或跳过校验。
**架构不支持**:在当前开发机使用 Linux x64新增平台时必须先把官方归档和 SHA-256 加入 `config/node-runtime.env`,并增加对应 CI job。 **架构不支持**:在当前开发机使用 Linux x64新增平台时必须先把官方归档和 SHA-256 加入 `config/node-runtime.env`,并增加对应 CI job。

View File

@@ -2,8 +2,22 @@
set -euo pipefail set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
OCCT_SOURCE_DIR="${OCCT_SOURCE_DIR:-/home/mes123456/working/WebCam/occt}" LOCAL_OCCT_SOURCE_DIR="${ROOT_DIR}/.cache/occt/occt"
OCCT_BUILD_DIR="${OCCT_BUILD_DIR:-${ROOT_DIR}/.cache/bitbybit/occt-history-build}" LEGACY_OCCT_SOURCE_DIR="/home/mes123456/working/WebCam/occt"
if [[ -z "${OCCT_SOURCE_DIR:-}" ]]; then
if [[ -f "${LOCAL_OCCT_SOURCE_DIR}/CMakeLists.txt" ]]; then
OCCT_SOURCE_DIR="${LOCAL_OCCT_SOURCE_DIR}"
else
OCCT_SOURCE_DIR="${LEGACY_OCCT_SOURCE_DIR}"
fi
fi
if [[ -z "${OCCT_BUILD_DIR:-}" ]]; then
if [[ "${OCCT_SOURCE_DIR}" == "${LOCAL_OCCT_SOURCE_DIR}" ]]; then
OCCT_BUILD_DIR="${ROOT_DIR}/.cache/bitbybit/occt-history-build-offline"
else
OCCT_BUILD_DIR="${ROOT_DIR}/.cache/bitbybit/occt-history-build"
fi
fi
DIST_DIR="${OCCT_HISTORY_DIST_DIR:-${ROOT_DIR}/native/occt-history/dist}" DIST_DIR="${OCCT_HISTORY_DIST_DIR:-${ROOT_DIR}/native/occt-history/dist}"
PUBLIC_DIR="${OCCT_HISTORY_PUBLIC_DIR:-${ROOT_DIR}/public/native/occt-history}" PUBLIC_DIR="${OCCT_HISTORY_PUBLIC_DIR:-${ROOT_DIR}/public/native/occt-history}"
JOBS="${JOBS:-$(nproc)}" JOBS="${JOBS:-$(nproc)}"

View File

@@ -7,6 +7,9 @@ PLANEGCS_SOURCE_DIR="${FREECAD_SOURCE_DIR}/src/Mod/Sketcher/App/planegcs"
DIST_DIR="${PLANEGCS_DIST_DIR:-${ROOT_DIR}/native/planegcs/dist}" DIST_DIR="${PLANEGCS_DIST_DIR:-${ROOT_DIR}/native/planegcs/dist}"
PUBLIC_DIR="${PLANEGCS_PUBLIC_DIR:-${ROOT_DIR}/public/native/planegcs}" PUBLIC_DIR="${PLANEGCS_PUBLIC_DIR:-${ROOT_DIR}/public/native/planegcs}"
HOST_HEADERS_DIR="${ROOT_DIR}/.cache/bitbybit/planegcs-host-headers" HOST_HEADERS_DIR="${ROOT_DIR}/.cache/bitbybit/planegcs-host-headers"
OFFLINE_HEADERS_DIR="${ROOT_DIR}/.cache/offline-sysroot/include"
BOOST_INCLUDE_DIR="${BOOST_INCLUDE_DIR:-$([[ -d "${OFFLINE_HEADERS_DIR}/boost" ]] && printf '%s' "${OFFLINE_HEADERS_DIR}" || printf '%s' /usr/include)}"
EIGEN3_INCLUDE_DIR="${EIGEN3_INCLUDE_DIR:-$([[ -d "${OFFLINE_HEADERS_DIR}/eigen3" ]] && printf '%s' "${OFFLINE_HEADERS_DIR}/eigen3" || printf '%s' /usr/include/eigen3)}"
if [[ ! -f "${PLANEGCS_SOURCE_DIR}/GCS.cpp" ]]; then if [[ ! -f "${PLANEGCS_SOURCE_DIR}/GCS.cpp" ]]; then
echo "Locked FreeCAD planegcs source not found: ${PLANEGCS_SOURCE_DIR}" >&2 echo "Locked FreeCAD planegcs source not found: ${PLANEGCS_SOURCE_DIR}" >&2
@@ -16,14 +19,17 @@ if ! command -v em++ >/dev/null; then
echo "Emscripten em++ is required." >&2 echo "Emscripten em++ is required." >&2
exit 1 exit 1
fi fi
if [[ ! -d /usr/include/eigen3 ]] || [[ ! -d /usr/include/boost ]]; then if [[ ! -d "${EIGEN3_INCLUDE_DIR}" ]] || [[ ! -d "${BOOST_INCLUDE_DIR}/boost" ]]; then
echo "Eigen3 and Boost headers are required." >&2 echo "Eigen3 and Boost headers are required." >&2
exit 1 exit 1
fi fi
mkdir -p "${DIST_DIR}" "${PUBLIC_DIR}" "${HOST_HEADERS_DIR}" mkdir -p "${DIST_DIR}" "${PUBLIC_DIR}" "${HOST_HEADERS_DIR}"
if [[ -L "${HOST_HEADERS_DIR}/boost" && "$(readlink "${HOST_HEADERS_DIR}/boost")" != "${BOOST_INCLUDE_DIR}/boost" ]]; then
rm "${HOST_HEADERS_DIR}/boost"
fi
if [[ ! -e "${HOST_HEADERS_DIR}/boost" ]]; then if [[ ! -e "${HOST_HEADERS_DIR}/boost" ]]; then
ln -s /usr/include/boost "${HOST_HEADERS_DIR}/boost" ln -s "${BOOST_INCLUDE_DIR}/boost" "${HOST_HEADERS_DIR}/boost"
fi fi
em++ \ em++ \
"${PLANEGCS_SOURCE_DIR}/Geo.cpp" \ "${PLANEGCS_SOURCE_DIR}/Geo.cpp" \
@@ -36,7 +42,7 @@ em++ \
-I"${FREECAD_SOURCE_DIR}/src" \ -I"${FREECAD_SOURCE_DIR}/src" \
-I"${PLANEGCS_SOURCE_DIR}" \ -I"${PLANEGCS_SOURCE_DIR}" \
-I"${HOST_HEADERS_DIR}" \ -I"${HOST_HEADERS_DIR}" \
-I/usr/include/eigen3 \ -I"${EIGEN3_INCLUDE_DIR}" \
-std=c++20 -O2 -DNDEBUG -DEIGEN_NO_DEBUG -fexceptions -pthread --bind \ -std=c++20 -O2 -DNDEBUG -DEIGEN_NO_DEBUG -fexceptions -pthread --bind \
-sMODULARIZE=1 -sEXPORT_ES6=1 -sENVIRONMENT=web,worker,node \ -sMODULARIZE=1 -sEXPORT_ES6=1 -sENVIRONMENT=web,worker,node \
-sPTHREAD_POOL_SIZE=2 \ -sPTHREAD_POOL_SIZE=2 \

View File

@@ -260,6 +260,12 @@
"ci:real": "node scripts/run-real-verification.mjs", "ci:real": "node scripts/run-real-verification.mjs",
"ci:real:chrome": "node scripts/run-real-verification.mjs --lane=chrome --phase=execute", "ci:real:chrome": "node scripts/run-real-verification.mjs --lane=chrome --phase=execute",
"ci:check:chrome": "node scripts/run-real-verification.mjs --lane=chrome --phase=check", "ci:check:chrome": "node scripts/run-real-verification.mjs --lane=chrome --phase=check",
"offline:inventory": "node scripts/offline-resource-lib.mjs inventory",
"offline:sync": "node scripts/offline-resource-lib.mjs sync",
"offline:check": "node scripts/offline-resource-lib.mjs check",
"offline:restore": "node scripts/offline-resource-lib.mjs restore",
"offline:smoke": "node scripts/offline-resource-lib.mjs smoke",
"test:offline-tools": "node --test tests/offlineResourceLibrary.test.mjs",
"test:browser-matrix": "node scripts/run-browser-matrix.mjs", "test:browser-matrix": "node scripts/run-browser-matrix.mjs",
"check:browser-matrix": "node scripts/check-browser-matrix.mjs", "check:browser-matrix": "node scripts/check-browser-matrix.mjs",
"check:runtime": "node scripts/check-runtime.mjs", "check:runtime": "node scripts/check-runtime.mjs",

View File

@@ -24,6 +24,8 @@ esac
archive_name="node-v${NODE_VERSION}-${platform}-${arch}.${archive_ext}" archive_name="node-v${NODE_VERSION}-${platform}-${arch}.${archive_ext}"
archive_path="$runtime_root/downloads/$archive_name" archive_path="$runtime_root/downloads/$archive_name"
runtime_dir="$runtime_root/node-v${NODE_VERSION}-${platform}-${arch}" runtime_dir="$runtime_root/node-v${NODE_VERSION}-${platform}-${arch}"
resource_home="${WEB_FREECAD_RESOURCE_LIBRARY:-${HOME}/resource-library/web-freecad-bitbybit}"
resource_archive="$resource_home/toolchains/node/$archive_name"
if [ -x "$runtime_dir/bin/node" ] && [ "$($runtime_dir/bin/node --version)" = "v$NODE_VERSION" ]; then if [ -x "$runtime_dir/bin/node" ] && [ "$($runtime_dir/bin/node --version)" = "v$NODE_VERSION" ]; then
printf '%s\n' "$runtime_dir" printf '%s\n' "$runtime_dir"
@@ -41,6 +43,15 @@ if [ -e "$archive_path" ]; then
fi fi
fi fi
if [ ! -e "$archive_path" ] && [ -e "$resource_archive" ]; then
cp "$resource_archive" "$archive_path"
fi
if [ ! -e "$archive_path" ] && { [ "${WEB_FREECAD_OFFLINE:-0}" = "1" ] || [ "${npm_config_offline:-false}" = "true" ]; }; then
echo "Pinned Node archive is unavailable in the offline resource library: $resource_archive" >&2
exit 2
fi
if [ ! -e "$archive_path" ]; then if [ ! -e "$archive_path" ]; then
temp_archive="$archive_path.part" temp_archive="$archive_path.part"
rm -f "$temp_archive" rm -f "$temp_archive"

View File

@@ -1,5 +1,5 @@
import { execFileSync } from 'node:child_process' import { execFileSync } from 'node:child_process'
import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { access, cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path' import { resolve } from 'node:path'
const root = resolve(new URL('..', import.meta.url).pathname) const root = resolve(new URL('..', import.meta.url).pathname)
@@ -7,11 +7,15 @@ const source = resolve(root, 'OpenCAMLib')
const build = resolve(root, '.cache/opencamlib-wasm') const build = resolve(root, '.cache/opencamlib-wasm')
const boost = resolve(root, '.cache/opencamlib-boost') const boost = resolve(root, '.cache/opencamlib-boost')
const install = resolve(root, 'public/vendor/opencamlib') const install = resolve(root, 'public/vendor/opencamlib')
const offlineBoost = resolve(root, '.cache/offline-sysroot/include/boost')
const systemBoost = process.env.BOOST_INCLUDE_DIR
? resolve(process.env.BOOST_INCLUDE_DIR, 'boost')
: '/usr/include/boost'
const revision = execFileSync('git', ['-C', source, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() const revision = execFileSync('git', ['-C', source, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()
await rm(boost, { recursive: true, force: true }) await rm(boost, { recursive: true, force: true })
await mkdir(boost, { recursive: true }) await mkdir(boost, { recursive: true })
await cp('/usr/include/boost', resolve(boost, 'boost'), { recursive: true }) await cp(await access(offlineBoost).then(() => offlineBoost, () => systemBoost), resolve(boost, 'boost'), { recursive: true })
await mkdir(build, { recursive: true }) await mkdir(build, { recursive: true })
const configure = [ const configure = [

View File

@@ -0,0 +1,660 @@
import { createHash } from 'node:crypto'
import { spawn, spawnSync } from 'node:child_process'
import {
access,
cp,
lstat,
mkdir,
mkdtemp,
open,
readFile,
readdir,
readlink,
rename,
rm,
stat,
symlink,
writeFile,
} from 'node:fs/promises'
import { homedir, platform, tmpdir } from 'node:os'
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { gzipSync } from 'node:zlib'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const configPath = resolve(root, 'config/offline-resources.json')
const config = JSON.parse(await readFile(configPath, 'utf8'))
const command = process.argv[2] || 'help'
const values = new Map(process.argv.slice(3).filter((argument) => argument.startsWith('--') && argument.includes('=')).map((argument) => {
const offset = argument.indexOf('=')
return [argument.slice(2, offset), argument.slice(offset + 1)]
}))
const flags = new Set(process.argv.slice(3).filter((argument) => argument.startsWith('--') && !argument.includes('=')).map((argument) => argument.slice(2)))
const defaultLibrary = resolve(homedir(), 'resource-library', config.libraryDirectory)
const library = resolve(values.get('library') || process.env.WEB_FREECAD_RESOURCE_LIBRARY || defaultLibrary)
const manifestPath = resolve(library, 'manifest.json')
const selectedIds = new Set((values.get('only') || '').split(',').filter(Boolean))
const isSelected = (resource) => selectedIds.size === 0 || selectedIds.has(resource.id)
const zstdLevel = values.get('compression-level') || '3'
const knownIds = new Set(['node-runtime', 'npm-cache', ...config.archives.map((entry) => entry.id), ...config.mirrors.map((entry) => entry.id)])
for (const id of selectedIds) if (!knownIds.has(id)) fail(`Unknown offline resource id: ${id}`)
function print(value) {
process.stdout.write(`${typeof value === 'string' ? value : JSON.stringify(value, null, 2)}\n`)
}
function fail(message) {
throw new Error(message)
}
async function exists(path) {
return access(path).then(() => true, () => false)
}
function expandHome(path) {
return path === '~' ? homedir() : path.startsWith(`~${sep}`) ? resolve(homedir(), path.slice(2)) : path
}
function resolveWorkspacePath(path) {
const expanded = expandHome(path)
return isAbsolute(expanded) ? expanded : resolve(root, expanded)
}
async function resourceSource(resource) {
const candidates = []
if (resource.sourceEnvironment && process.env[resource.sourceEnvironment]) candidates.push(process.env[resource.sourceEnvironment])
if (resource.source) candidates.push(resource.source)
candidates.push(...(resource.sourceCandidates || []))
for (const candidate of candidates) {
const path = resolveWorkspacePath(candidate)
if (await exists(path)) return path
}
return null
}
function resourceLibraryPath(resource) {
return resolve(library, resource.libraryPath)
}
function resourceRestorePath(resource, targetRoot = root) {
return resolve(targetRoot, resource.restore)
}
async function run(program, args, options = {}) {
await new Promise((resolveRun, rejectRun) => {
const child = spawn(program, args, {
cwd: options.cwd || root,
env: options.env || process.env,
stdio: options.stdio || 'inherit',
})
child.once('error', rejectRun)
child.once('exit', (code, signal) => {
if (code === 0) resolveRun()
else rejectRun(new Error(`${program} exited with ${code ?? signal}`))
})
})
}
async function sha256(path) {
const digest = createHash('sha256')
const handle = await open(path, 'r')
try {
for await (const chunk of handle.createReadStream()) digest.update(chunk)
} finally {
await handle.close().catch(() => {})
}
return digest.digest('hex')
}
async function describeFile(path) {
const metadata = await stat(path)
return { bytes: metadata.size, sha256: await sha256(path) }
}
async function walkFiles(path, options = {}, prefix = '') {
const files = []
for (const entry of await readdir(path, { withFileTypes: true })) {
const relativePath = prefix ? join(prefix, entry.name) : entry.name
const absolutePath = resolve(path, entry.name)
if (entry.isDirectory() && options.recursive !== false) files.push(...await walkFiles(absolutePath, options, relativePath))
else if ((entry.isFile() || entry.isSymbolicLink()) && (!options.matcher || options.matcher.test(relativePath))) files.push(relativePath)
}
return files
}
async function describeTree(path) {
const paths = (await walkFiles(path)).sort()
const digest = createHash('sha256')
let bytes = 0
for (const relativePath of paths) {
const absolutePath = resolve(path, relativePath)
const metadata = await lstat(absolutePath)
digest.update(`${relativePath}\0${metadata.mode & 0o777}\0`)
if (metadata.isSymbolicLink()) {
digest.update(`link:${await readlink(absolutePath)}\0`)
continue
}
bytes += metadata.size
const handle = await open(absolutePath, 'r')
try {
for await (const chunk of handle.createReadStream()) digest.update(chunk)
} finally {
await handle.close().catch(() => {})
}
digest.update('\0')
}
return { files: paths.length, bytes, sha256: digest.digest('hex') }
}
async function gitMetadata(path) {
const top = spawnSync('git', ['-C', path, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' })
if (top.status !== 0) return null
const topLevel = resolve(top.stdout.trim())
const revision = spawnSync('git', ['-C', path, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).stdout.trim()
const status = spawnSync('git', ['-C', path, 'status', '--short', '--untracked-files=all'], {
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
}).stdout
return {
revision,
worktreeRoot: relative(root, topLevel),
sourceWithinWorktree: relative(topLevel, path) || '.',
dirty: status.length > 0,
statusSha256: createHash('sha256').update(status).digest('hex'),
statusEntries: status.split(/\r?\n/).filter(Boolean).length,
}
}
async function replacePath(temporary, destination) {
await mkdir(dirname(destination), { recursive: true })
await rm(destination, { recursive: true, force: true })
await rename(temporary, destination)
}
async function createArchive(resource) {
const source = await resourceSource(resource)
if (!source) {
if (resource.required) fail(`Required source is missing for ${resource.id}`)
print(`[offline:sync] skip optional ${resource.id}: source is missing`)
return null
}
const sourceGit = await gitMetadata(source)
if (resource.revision && sourceGit?.revision !== resource.revision) {
fail(`${resource.id} revision is ${sourceGit?.revision || 'unknown'}, expected ${resource.revision}`)
}
const destination = resourceLibraryPath(resource)
const temporary = `${destination}.part-${process.pid}`
await mkdir(dirname(destination), { recursive: true })
await rm(temporary, { force: true })
print(`[offline:sync] archive ${resource.id} <- ${source}`)
await run('tar', [
'--create',
`--file=${temporary}`,
'--use-compress-program',
`zstd -T0 -${zstdLevel}`,
'--numeric-owner',
'--owner=0',
'--group=0',
'--directory',
source,
'.',
])
await replacePath(temporary, destination)
return {
id: resource.id,
kind: 'archive',
path: relative(library, destination),
...await describeFile(destination),
source: sourceGit,
}
}
async function syncMirror(resource) {
const source = await resourceSource(resource)
if (!source) {
if (resource.required) fail(`Required source is missing for ${resource.id}`)
print(`[offline:sync] skip optional ${resource.id}: source is missing`)
return null
}
const destination = resourceLibraryPath(resource)
const temporary = `${destination}.part-${process.pid}`
await rm(temporary, { recursive: true, force: true })
await mkdir(temporary, { recursive: true })
const files = await walkFiles(source, {
matcher: resource.includePattern ? new RegExp(resource.includePattern) : null,
recursive: resource.recursive,
})
print(`[offline:sync] mirror ${resource.id} (${files.length} files) <- ${source}`)
for (const relativePath of files) {
const from = resolve(source, relativePath)
const to = resolve(temporary, relativePath)
const metadata = await lstat(from)
await mkdir(dirname(to), { recursive: true })
if (metadata.isSymbolicLink()) await symlink(await readlink(from), to)
else await cp(from, to, { preserveTimestamps: true })
}
if (resource.id === 'debian-package-cache' || resource.id === 'freecad-package-cache') {
const index = spawnSync('dpkg-scanpackages', ['.', '/dev/null'], {
cwd: temporary,
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
})
if (index.status !== 0) fail(`dpkg-scanpackages failed for ${resource.id}: ${index.stderr.trim()}`)
await writeFile(resolve(temporary, 'Packages'), index.stdout)
await writeFile(resolve(temporary, 'Packages.gz'), gzipSync(index.stdout, { level: 9 }))
}
await replacePath(temporary, destination)
return {
id: resource.id,
kind: 'mirror',
path: relative(library, destination),
...await describeTree(destination),
}
}
function cacheIndexPath(cacheRoot, url) {
const key = `make-fetch-happen:request-cache:${url}`
const digest = createHash('sha256').update(key).digest('hex')
return resolve(cacheRoot, '_cacache/index-v5', digest.slice(0, 2), digest.slice(2, 4), digest.slice(4))
}
function cacheContentPath(cacheRoot, integrity) {
const separator = integrity.indexOf('-')
const algorithm = integrity.slice(0, separator)
const digest = Buffer.from(integrity.slice(separator + 1), 'base64').toString('hex')
return resolve(cacheRoot, '_cacache/content-v2', algorithm, digest.slice(0, 2), digest.slice(2, 4), digest.slice(4))
}
function platformMatches(constraints, value) {
if (!constraints?.length) return true
const includes = constraints.filter((entry) => !entry.startsWith('!'))
const excludes = constraints.filter((entry) => entry.startsWith('!')).map((entry) => entry.slice(1))
return !excludes.includes(value) && (includes.length === 0 || includes.includes(value))
}
function packageMatchesHost(value) {
return platformMatches(value.os, 'linux')
&& platformMatches(value.cpu, process.arch)
&& platformMatches(value.libc, 'glibc')
}
async function syncNpmCache() {
const sourceCache = resolve(process.env.npm_config_cache || process.env.NPM_CONFIG_CACHE || resolve(homedir(), '.npm'))
const destination = resolve(library, 'npm/cache')
const temporary = `${destination}.part-${process.pid}`
await rm(temporary, { recursive: true, force: true })
const urls = new Set()
for (const lock of config.npmLocks) {
const path = resolve(root, lock)
if (!await exists(path)) fail(`npm lock is missing: ${lock}`)
const document = JSON.parse(await readFile(path, 'utf8'))
for (const value of Object.values(document.packages || {})) {
if (value?.resolved?.startsWith('http') && packageMatchesHost(value)) urls.add(value.resolved)
}
}
let copied = 0
for (const url of [...urls].sort()) {
const indexSource = cacheIndexPath(sourceCache, url)
if (!await exists(indexSource)) fail(`npm cache index is missing ${url}`)
const lines = (await readFile(indexSource, 'utf8')).trim().split(/\r?\n/).filter(Boolean)
const line = lines.at(-1)
const entry = JSON.parse(line.slice(line.indexOf('\t') + 1))
const contentSource = cacheContentPath(sourceCache, entry.integrity)
if (!await exists(contentSource)) fail(`npm cache content is missing ${url}`)
for (const source of [indexSource, contentSource]) {
const path = relative(sourceCache, source)
const target = resolve(temporary, path)
await mkdir(dirname(target), { recursive: true })
await cp(source, target, { preserveTimestamps: true })
}
copied += 1
}
await replacePath(temporary, destination)
print(`[offline:sync] npm cache ${copied} locked tarballs`)
return {
id: 'npm-cache',
kind: 'npm-cache',
path: relative(library, destination),
packages: copied,
...await describeTree(destination),
}
}
async function syncNodeArchive() {
const source = resolve(root, config.node.archive)
if (!await exists(source)) fail(`Pinned Node archive is missing: ${source}`)
const destination = resolve(library, config.node.libraryPath)
await mkdir(dirname(destination), { recursive: true })
await cp(source, destination, { preserveTimestamps: true })
print(`[offline:sync] node ${config.node.version}`)
return { id: 'node-runtime', kind: 'file', path: relative(library, destination), ...await describeFile(destination) }
}
async function hostToolVersions() {
const probes = {
node: [resolve(root, 'nodew'), ['--version']],
npm: [resolve(root, 'npmw'), ['--version']],
cmake: ['cmake', ['--version']],
ninja: ['ninja', ['--version']],
emscripten: ['emcc', ['--version']],
chrome: [process.env.CHROME_BIN || resolve(homedir(), '.local/bin/google-chrome'), ['--version']],
firefox: [process.env.FIREFOX_BIN || 'firefox', ['--version']],
}
const result = {}
for (const [name, [program, args]] of Object.entries(probes)) {
const probe = spawnSync(program, args, { encoding: 'utf8' })
result[name] = probe.status === 0 ? `${probe.stdout}${probe.stderr}`.trim().split(/\r?\n/)[0] : null
}
return result
}
async function browserExecutables() {
const browserRoot = resolve(library, 'browsers/ms-playwright')
const directories = (await readdir(browserRoot, { withFileTypes: true }))
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort()
.reverse()
const select = (prefix) => directories.find((name) => name.startsWith(`${prefix}-`))
const chromiumDirectory = select('chromium')
const firefoxDirectory = select('firefox')
const webkitDirectory = select('webkit')
const paths = {
chromium: chromiumDirectory && resolve(browserRoot, chromiumDirectory, 'chrome-linux64/chrome'),
firefox: firefoxDirectory && resolve(browserRoot, firefoxDirectory, 'firefox/firefox'),
webkit: webkitDirectory && resolve(browserRoot, webkitDirectory, 'pw_run.sh'),
}
for (const [name, path] of Object.entries(paths)) {
if (!path || !await exists(path)) fail(`Offline ${name} executable is missing from ${browserRoot}`)
}
return Object.fromEntries(Object.entries(paths).map(([name, path]) => [name, relative(library, path)]))
}
async function sync() {
if (platform() !== 'linux') fail(`This resource profile is Linux-specific; received ${platform()}`)
if (selectedIds.size > 0) fail('Partial sync is intentionally unsupported; synchronize one complete, internally consistent manifest')
await mkdir(library, { recursive: true })
const entries = [await syncNodeArchive(), await syncNpmCache()]
for (const resource of config.archives) entries.push(await createArchive(resource))
for (const resource of config.mirrors) entries.push(await syncMirror(resource))
const manifest = {
schemaVersion: 1,
project: config.project,
generatedAt: new Date().toISOString(),
library,
configSha256: await sha256(configPath),
platform: config.platform,
hostTools: await hostToolVersions(),
browserExecutables: await browserExecutables(),
resources: entries.filter(Boolean),
}
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
print(`[offline:sync] wrote ${manifestPath}`)
print({ status: 'pass', library, resources: manifest.resources.length })
}
async function readManifest() {
if (!await exists(manifestPath)) fail(`Offline manifest is missing: ${manifestPath}. Run offline:sync first.`)
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
if (manifest.schemaVersion !== 1 || manifest.project !== config.project) fail('Offline manifest identity is invalid')
return manifest
}
async function check() {
const manifest = await readManifest()
if (manifest.configSha256 !== await sha256(configPath)) fail('Offline resource config changed after the library was synchronized')
const expectedIds = new Set([
'node-runtime',
'npm-cache',
...config.archives.filter((entry) => entry.required).map((entry) => entry.id),
...config.mirrors.filter((entry) => entry.required).map((entry) => entry.id),
])
const records = new Map(manifest.resources.map((entry) => [entry.id, entry]))
for (const id of expectedIds) if (!records.has(id)) fail(`Offline manifest is missing required resource ${id}`)
for (const record of manifest.resources.filter(isSelected)) {
const path = resolve(library, record.path)
if (!await exists(path)) fail(`Offline resource is missing: ${record.id}`)
const actual = record.kind === 'archive' || record.kind === 'file' ? await describeFile(path) : await describeTree(path)
if (actual.bytes !== record.bytes || actual.sha256 !== record.sha256 || (record.files != null && actual.files !== record.files)) {
fail(`Offline resource checksum mismatch: ${record.id}`)
}
print(`[offline:check] ${record.id} ${record.sha256.slice(0, 12)}`)
}
print({ status: 'pass', library, resources: manifest.resources.length })
}
async function prepareNpmWorkCache(manifest) {
const npmRecord = manifest.resources.find((entry) => entry.id === 'npm-cache')
if (!npmRecord) fail('Offline manifest is missing npm-cache')
const destination = resolve(root, '.cache/offline-npm')
const marker = resolve(destination, '.resource-sha256')
const current = await readFile(marker, 'utf8').then((value) => value.trim(), () => '')
if (current === npmRecord.sha256) return destination
const temporary = `${destination}.part-${process.pid}`
await rm(temporary, { recursive: true, force: true })
await cp(resolve(library, npmRecord.path), temporary, { recursive: true, preserveTimestamps: true })
await writeFile(resolve(temporary, '.resource-sha256'), `${npmRecord.sha256}\n`)
await replacePath(temporary, destination)
return destination
}
async function assertRestoreTarget(targetRoot, destination) {
const relativePath = relative(targetRoot, destination)
if (!relativePath || relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) {
fail(`Unsafe restore target: ${destination}`)
}
if (await exists(destination) && !flags.has('force')) fail(`Restore target already exists: ${destination}. Use --force to replace it.`)
}
async function extractArchive(resource, record, targetRoot) {
const destination = resourceRestorePath(resource, targetRoot)
await assertRestoreTarget(targetRoot, destination)
const temporary = `${destination}.offline-part-${process.pid}`
await rm(temporary, { recursive: true, force: true })
await mkdir(temporary, { recursive: true })
print(`[offline:restore] ${resource.id} -> ${destination}`)
await run('tar', [
'--extract',
`--file=${resolve(library, record.path)}`,
'--use-compress-program',
'zstd -d',
'--directory',
temporary,
])
await replacePath(temporary, destination)
}
async function restoreMirror(resource, record, targetRoot) {
if (!resource.restore) return
const destination = resourceRestorePath(resource, targetRoot)
await assertRestoreTarget(targetRoot, destination)
const temporary = `${destination}.offline-part-${process.pid}`
await rm(temporary, { recursive: true, force: true })
await mkdir(dirname(temporary), { recursive: true })
print(`[offline:restore] ${resource.id} -> ${destination}`)
await cp(resolve(library, record.path), temporary, { recursive: true, preserveTimestamps: true })
await replacePath(temporary, destination)
}
async function restore() {
const manifest = await readManifest()
const targetRoot = resolve(values.get('target') || root)
await mkdir(targetRoot, { recursive: true })
const records = new Map(manifest.resources.map((entry) => [entry.id, entry]))
const resources = [...config.archives, ...config.mirrors.filter((entry) => entry.restore)].filter(isSelected)
if (selectedIds.size === 0) fail('Restore requires --only=ID,... so replacement scope is explicit')
for (const resource of resources) {
const record = records.get(resource.id)
if (!record) {
if (resource.required) fail(`Offline manifest is missing ${resource.id}`)
continue
}
if (record.kind === 'archive') await extractArchive(resource, record, targetRoot)
else await restoreMirror(resource, record, targetRoot)
}
if (selectedIds.has('node-runtime')) {
const record = records.get('node-runtime')
const destination = resolve(targetRoot, config.node.archive)
await assertRestoreTarget(targetRoot, destination)
await mkdir(dirname(destination), { recursive: true })
await cp(resolve(library, record.path), destination, { preserveTimestamps: true })
}
print({ status: 'pass', library, target: targetRoot, restored: [...selectedIds] })
}
async function npmOfflineSmoke() {
const directory = await mkdtemp(resolve(tmpdir(), 'web-freecad-offline-npm-'))
try {
for (const file of ['package.json', 'package-lock.json', '.npmrc']) await cp(resolve(root, file), resolve(directory, file))
const npmCache = resolve(directory, '.npm-cache')
await cp(resolve(library, 'npm/cache'), npmCache, { recursive: true, preserveTimestamps: true })
const runtimeArchive = resolve(library, config.node.libraryPath)
await run('tar', ['--extract', `--file=${runtimeArchive}`, '--directory', directory])
const runtime = resolve(directory, `node-v${config.node.version}-linux-x64`)
print('[offline:smoke] npm ci --offline in an isolated directory')
await run(resolve(runtime, 'bin/npm'), [
'ci',
'--offline',
'--ignore-scripts',
`--cache=${npmCache}`,
], {
cwd: directory,
env: {
...process.env,
PATH: `${resolve(runtime, 'bin')}:${process.env.PATH}`,
npm_config_offline: 'true',
npm_config_audit: 'false',
npm_config_fund: 'false',
},
})
const installed = JSON.parse(await readFile(resolve(directory, 'node_modules/@bitbybit-dev/occt/package.json'), 'utf8'))
if (installed.version !== '1.1.1') fail(`Offline npm smoke installed unexpected OCCT ${installed.version}`)
} finally {
await rm(directory, { recursive: true, force: true })
}
}
async function linuxCncNpmOfflineSmoke() {
const directory = await mkdtemp(resolve(tmpdir(), 'web-freecad-offline-linuxcnc-npm-'))
const source = resolve(root, 'cnc_wams_gpt6/linuxcnc-master/web')
try {
for (const file of ['package.json', 'package-lock.json']) await cp(resolve(source, file), resolve(directory, file))
const npmCache = resolve(directory, '.npm-cache')
await cp(resolve(library, 'npm/cache'), npmCache, { recursive: true, preserveTimestamps: true })
const runtimeDirectory = await mkdtemp(resolve(tmpdir(), 'web-freecad-offline-node-'))
try {
await run('tar', ['--extract', `--file=${resolve(library, config.node.libraryPath)}`, '--directory', runtimeDirectory])
const runtime = resolve(runtimeDirectory, `node-v${config.node.version}-linux-x64`)
print('[offline:smoke] LinuxCNC Web npm ci --offline in an isolated directory')
await run(resolve(runtime, 'bin/npm'), [
'ci',
'--offline',
'--ignore-scripts',
`--cache=${npmCache}`,
], {
cwd: directory,
env: {
...process.env,
PATH: `${resolve(runtime, 'bin')}:${process.env.PATH}`,
npm_config_offline: 'true',
npm_config_audit: 'false',
npm_config_fund: 'false',
},
})
const installed = JSON.parse(await readFile(resolve(directory, 'node_modules/playwright/package.json'), 'utf8'))
if (installed.version !== '1.61.1') fail(`Offline LinuxCNC npm smoke installed unexpected Playwright ${installed.version}`)
} finally {
await rm(runtimeDirectory, { recursive: true, force: true })
}
} finally {
await rm(directory, { recursive: true, force: true })
}
}
async function playwrightOfflineSmoke() {
const playwrightPath = resolve(root, 'cnc_wams_gpt6/linuxcnc-master/web/node_modules/playwright/index.mjs')
const executables = await browserExecutables()
const absoluteExecutables = Object.fromEntries(Object.entries(executables).map(([name, path]) => [name, resolve(library, path)]))
const script = [
`import { chromium, firefox, webkit } from ${JSON.stringify(new URL(`file://${playwrightPath}`).href)}`,
`for (const [name, type] of Object.entries({ chromium, firefox, webkit })) {`,
` const executablePath = ${JSON.stringify(absoluteExecutables)}[name]`,
` const browser = await type.launch({ headless: true, executablePath })`,
` const page = await browser.newPage()`,
` await page.setContent('<title>offline</title>')`,
` if (await page.title() !== 'offline') throw new Error(name + ' page smoke failed')`,
` console.log('[offline:smoke] browser ' + name + ' pass')`,
` await browser.close()`,
`}`,
].join('\n')
await run(resolve(root, 'nodew'), ['--input-type=module', '--eval', script], {
env: { ...process.env, PLAYWRIGHT_BROWSERS_PATH: resolve(library, 'browsers/ms-playwright') },
})
}
async function smoke() {
await check()
await npmOfflineSmoke()
await linuxCncNpmOfflineSmoke()
await playwrightOfflineSmoke()
const environment = {
...process.env,
WEB_FREECAD_RESOURCE_LIBRARY: library,
WEB_FREECAD_OFFLINE: '1',
npm_config_cache: await prepareNpmWorkCache(await readManifest()),
npm_config_offline: 'true',
PLAYWRIGHT_BROWSERS_PATH: resolve(library, 'browsers/ms-playwright'),
FREECAD_SOURCE_OFFLINE: '1',
OCCT_SOURCE_DIR: resolve(root, '.cache/occt/occt'),
}
for (const script of ['check:runtime', 'check:freecad-source', 'check:occt-history-artifact', 'test:occt-history', 'test:planegcs', 'build']) {
print(`[offline:smoke] npm run ${script}`)
await run(resolve(root, 'npmw'), ['run', script], { env: environment })
}
print({ status: 'pass', library, networkFallback: false })
}
function shellQuote(value) {
return `'${String(value).replaceAll("'", "'\\''")}'`
}
async function environment() {
const manifest = await readManifest()
const npmCache = await prepareNpmWorkCache(manifest)
const firefox = resolve(library, manifest.browserExecutables.firefox)
const webkit = resolve(library, manifest.browserExecutables.webkit)
const lines = [
`export WEB_FREECAD_RESOURCE_LIBRARY=${shellQuote(library)}`,
'export WEB_FREECAD_OFFLINE=1',
`export npm_config_cache=${shellQuote(npmCache)}`,
'export npm_config_offline=true',
`export PLAYWRIGHT_BROWSERS_PATH=${shellQuote(resolve(library, 'browsers/ms-playwright'))}`,
'export FREECAD_SOURCE_OFFLINE=1',
`export OCCT_SOURCE_DIR=${shellQuote(resolve(root, '.cache/occt/occt'))}`,
`export CHROME_BIN=${shellQuote(process.env.CHROME_BIN || resolve(homedir(), '.local/bin/google-chrome'))}`,
`export FIREFOX_BIN=${shellQuote(firefox)}`,
`export WEBKIT_BIN=${shellQuote(webkit)}`,
'export CI_REAL_OFFLINE=1',
]
print(lines.join('\n'))
}
async function inventory() {
const resources = []
for (const resource of [...config.archives, ...config.mirrors]) {
const source = await resourceSource(resource)
resources.push({ id: resource.id, source, present: Boolean(source), required: resource.required })
}
print({ library, config: relative(root, configPath), resources, hostTools: await hostToolVersions() })
}
if (command === 'sync') await sync()
else if (command === 'check') await check()
else if (command === 'restore') await restore()
else if (command === 'smoke') await smoke()
else if (command === 'env') await environment()
else if (command === 'inventory') await inventory()
else {
print('Usage: node scripts/offline-resource-lib.mjs <inventory|sync|check|restore|smoke|env> [--library=PATH] [--only=ID,...] [--target=PATH] [--force]')
if (command !== 'help') process.exitCode = 2
}

10
scripts/offline-shell.sh Executable file
View File

@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
repo_dir="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P)"
eval "$("$repo_dir/nodew" "$repo_dir/scripts/offline-resource-lib.mjs" env)"
if [[ $# -eq 0 ]]; then
exec "${SHELL:-/bin/bash}" --noprofile --norc
fi
exec "$@"

View File

@@ -120,7 +120,12 @@ function runScript(name, laneEnvironment = {}) {
console.log(`\n[real-verification] ${name}`) console.log(`\n[real-verification] ${name}`)
const result = spawnSync(npmw, ['run', name], { const result = spawnSync(npmw, ['run', name], {
cwd: root, cwd: root,
env: { ...process.env, CI_REAL_VERIFICATION: '1', ...laneEnvironment }, env: {
...process.env,
CI_REAL_VERIFICATION: '1',
...(process.env.CI_REAL_OFFLINE === '1' ? { npm_config_offline: 'true', FREECAD_SOURCE_OFFLINE: '1' } : {}),
...laneEnvironment,
},
stdio: 'inherit', stdio: 'inherit',
}) })
const record = { script: name, status: result.status ?? 1, durationMs: Date.now() - startedAt } const record = { script: name, status: result.status ?? 1, durationMs: Date.now() - startedAt }

View File

@@ -1,8 +1,29 @@
import { execFileSync } from 'node:child_process' import { execFileSync } from 'node:child_process'
import { readFile } from 'node:fs/promises' import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
const toolchain = JSON.parse(await readFile(new URL('../config/freecad-toolchain.json', import.meta.url), 'utf8')) const toolchain = JSON.parse(await readFile(new URL('../config/freecad-toolchain.json', import.meta.url), 'utf8'))
const reference = `refs/tags/${toolchain.source.tag}` const reference = `refs/tags/${toolchain.source.tag}`
const root = resolve(new URL('..', import.meta.url).pathname)
const source = resolve(root, toolchain.sourceCheckout.defaultDirectory)
const git = (...args) => execFileSync('git', args, { encoding: 'utf8', timeout: 30000 }).trim()
if (process.env.FREECAD_SOURCE_OFFLINE === '1') {
const revision = git('-C', source, 'rev-parse', 'HEAD')
if (revision !== toolchain.source.commit) {
console.error(`FreeCAD source mismatch: expected ${toolchain.source.commit}, received ${revision}.`)
process.exit(1)
}
const submodules = git('-C', source, 'submodule', 'status', '--recursive').split(/\r?\n/).filter(Boolean)
const incomplete = submodules.filter((line) => line.startsWith('-') || line.startsWith('+') || line.startsWith('U'))
if (submodules.length !== 4 || incomplete.length > 0) {
console.error(`FreeCAD offline source requires four clean initialized submodules; received ${JSON.stringify(submodules)}.`)
process.exit(1)
}
console.log(`FreeCAD offline source verified: ${source} @ ${revision}, submodules=${submodules.length}.`)
process.exit(0)
}
const output = execFileSync('git', ['ls-remote', toolchain.source.repository, reference], { encoding: 'utf8', timeout: 30000 }).trim() const output = execFileSync('git', ['ls-remote', toolchain.source.repository, reference], { encoding: 'utf8', timeout: 30000 }).trim()
const [resolved, resolvedReference] = output.split(/\s+/) const [resolved, resolvedReference] = output.split(/\s+/)
if (resolvedReference !== reference || resolved !== toolchain.source.commit) { if (resolvedReference !== reference || resolved !== toolchain.source.commit) {

View File

@@ -0,0 +1,45 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import test from 'node:test'
const root = resolve(import.meta.dirname, '..')
const config = JSON.parse(await readFile(resolve(root, 'config/offline-resources.json'), 'utf8'))
test('offline resource identifiers and destinations are unique and project-relative', () => {
assert.equal(config.schemaVersion, 1)
const resources = [...config.archives, ...config.mirrors]
assert.equal(new Set(resources.map((entry) => entry.id)).size, resources.length)
assert.equal(new Set(resources.map((entry) => entry.libraryPath)).size, resources.length)
for (const resource of resources) {
assert.ok(!resource.libraryPath.startsWith('/'), resource.id)
assert.ok(!resource.libraryPath.split('/').includes('..'), resource.id)
if (resource.restore) {
assert.ok(!resource.restore.startsWith('/'), resource.id)
assert.ok(!resource.restore.split('/').includes('..'), resource.id)
}
}
})
test('all pinned upstream sources declare full revisions', () => {
const pinned = config.archives.filter((entry) => entry.revision)
assert.ok(pinned.length >= 7)
for (const resource of pinned) assert.match(resource.revision, /^[0-9a-f]{40}$/, resource.id)
})
test('offline profile covers runtime, native sources, browsers and host recovery', () => {
const ids = new Set([...config.archives, ...config.mirrors].map((entry) => entry.id))
for (const id of [
'project-node-modules',
'freecad-source',
'bitbybit-source',
'occt-source',
'opencamlib-source',
'camotics-worktree',
'linuxcnc-worktree',
'occt-history-dist',
'planegcs-dist',
'playwright-browsers',
'debian-package-cache',
]) assert.ok(ids.has(id), id)
})