From f867d06565bf728024f2e6808186dd170cefd718 Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Thu, 22 Oct 2026 17:45:08 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(weapp):=20=E4=BC=98=E5=8C=96=E7=BB=88?= =?UTF-8?q?=E7=AB=AF=E5=BC=80=E9=94=80=E5=B9=B6=E5=AE=8C=E5=96=84=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E8=AF=8A=E6=96=AD=E4=B8=8E=E6=89=A7=E8=A1=8C=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/thin-tables-boil.md | 5 + docs/maintenance.md | 8 +- docs/weapp-project-workbench-2026-10-22.md | 61 +++ packages/weapp/README.md | 53 ++- packages/weapp/package.json | 5 +- packages/weapp/scripts/artifact-smoke.ts | 63 ++- packages/weapp/scripts/bundle-policy.ts | 4 +- packages/weapp/scripts/pty-test.py | 87 +++- packages/weapp/src/commands/create.ts | 91 +++-- packages/weapp/src/commands/create/plan.ts | 3 + packages/weapp/src/commands/doctor.ts | 332 +++++++++------- packages/weapp/src/project/actions.ts | 197 +++++++++ packages/weapp/src/project/context.ts | 373 ++++++++++++++++++ packages/weapp/src/project/presentation.ts | 24 ++ packages/weapp/src/project/process.ts | 161 ++++++++ packages/weapp/src/registry.ts | 9 +- packages/weapp/src/terminal/index.ts | 22 +- packages/weapp/src/terminal/session.ts | 41 +- packages/weapp/src/terminal/workbench.ts | 341 +++++++++++++--- packages/weapp/src/types.ts | 6 + .../test/create-action-lifecycle.test.ts | 203 ++++++++++ packages/weapp/test/doctor-project.test.ts | 137 +++++++ packages/weapp/test/project-actions.test.ts | 277 +++++++++++++ packages/weapp/test/project-context.test.ts | 189 +++++++++ packages/weapp/test/startup.test.ts | 4 +- packages/weapp/test/terminal.test.ts | 243 +++++++++++- packages/weapp/test/workbench-project.test.ts | 199 ++++++++++ pnpm-lock.yaml | 14 + 28 files changed, 2844 insertions(+), 308 deletions(-) create mode 100644 .changeset/thin-tables-boil.md create mode 100644 docs/weapp-project-workbench-2026-10-22.md create mode 100644 packages/weapp/src/project/actions.ts create mode 100644 packages/weapp/src/project/context.ts create mode 100644 packages/weapp/src/project/presentation.ts create mode 100644 packages/weapp/src/project/process.ts create mode 100644 packages/weapp/test/create-action-lifecycle.test.ts create mode 100644 packages/weapp/test/doctor-project.test.ts create mode 100644 packages/weapp/test/project-actions.test.ts create mode 100644 packages/weapp/test/project-context.test.ts create mode 100644 packages/weapp/test/workbench-project.test.ts diff --git a/.changeset/thin-tables-boil.md b/.changeset/thin-tables-boil.md new file mode 100644 index 0000000..577188e --- /dev/null +++ b/.changeset/thin-tables-boil.md @@ -0,0 +1,5 @@ +--- +"weapp": minor +--- + +Add project-aware diagnostics and confirmed install/script workflows; reduce idle and backpressured terminal rendering. diff --git a/docs/maintenance.md b/docs/maintenance.md index ece2f6c..2f137cb 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -58,11 +58,13 @@ pnpm metrics:sync `.github/workflows/version-packages.yml` 在 `main` push 或手动运行时调用 `repo release ci --mode prepare`,创建或更新 `release/pnpm-version` → `main` 的版本 PR。repoctl 执行 `release:verify`,pnpm 消费 intent、升版、生成包内 `CHANGELOG.md` 和 `.changeset/ledger.yaml`,然后 `afterVersion` 构建最新版本的 CLI 快照。自动任务禁用开发者 Git hooks,使用显式质量脚本和 PR 的 CLI CI。版本 PR 不自动合并,prepare job 没有 OIDC 权限且不会发布 npm。 -仓库允许 GitHub Actions 创建 PR,但默认 token 权限仍为 read;只有 prepare job 授予 contents / pull-requests / actions write。它使用内置 `GITHUB_TOKEN`,不需要 PAT 或 npm token,并显式 dispatch 版本分支的 CLI CI 和 `publish-weapp.yml` 的 preflight,以便检查真正待合并的版本。后者的 `--ref` 与 `tag` 输入均为 `release/pnpm-version`。当前已发布基线为 `0.2.0`;本次 minor intent 的只读 release plan 预览为 `0.3.0`。版本 PR 与发布分别处理。 +仓库允许 GitHub Actions 创建 PR,但默认 token 权限仍为 read;只有 prepare job 授予 contents / pull-requests / actions write。它使用内置 `GITHUB_TOKEN`,不需要 PAT 或 npm token,并显式 dispatch 版本分支的 CLI CI 和 `publish-weapp.yml` 的 preflight,以便检查真正待合并的版本。后者的 `--ref` 与 `tag` 输入均为 `release/pnpm-version`。当前代码版本与待处理 intent 决定下一版本,维护时以只读 release plan 为准。版本 PR 与发布分别处理。 -weapp CLI 单独由 `.github/workflows/weapp-cli.yml` 验证:目录与 Logo 构建、双语命令/只读诊断/脚手架适配单测、真实 PTY 恢复、后台浏览器实验室及独立 tarball。Node 18 的兼容任务只安装真实 tarball 并运行 plain ESM smoke,不运行要求新 Node 的 Vitest。CLI 开发与命令说明见 `packages/weapp/README.md`,新增项目能力应从中立 catalog 生成,不另写手工名单。交互默认六项由 `ecosystem=weapp` 筛选,查询和 JSON 保留十一项;真实 Logo 构建时由公共 assets 导出打包,运行时只允许固定资源表。大厅第一帧即可操作,目录与模板驱动星图,创建成功立即展示结果;整个状态机保留单一输入所有者,20 FPS 上限同时约束按键更新,按行差分与背压避免输出积压。 +weapp CLI 单独由 `.github/workflows/weapp-cli.yml` 验证:目录与 Logo 构建、双语命令/只读诊断/脚手架适配单测、真实 PTY 恢复、后台浏览器实验室及独立 tarball。Node 18 的兼容任务只安装真实 tarball 并运行 plain ESM smoke,不运行要求新 Node 的 Vitest。CLI 开发与命令说明见 `packages/weapp/README.md`,新增项目能力应从中立 catalog 生成,不另写手工名单。交互默认六项由 `ecosystem=weapp` 筛选,查询和 JSON 保留十一项;真实 Logo 构建时由公共 assets 导出打包,运行时只允许固定资源表。大厅首帧后异步识别项目,识别期间 Enter 不排队、C 仍可创建;目录与模板驱动星图,创建成功展示实际包管理器和脚本;整个状态机保留单一输入所有者,20 FPS 上限同时约束按键更新,按行差分与背压避免输出积压。 -CLI 命令与数据按需加载;QR core/SVG、semver、string-width 和 cross-spawn 的纯 JavaScript 闭包打包进产物,仅 open 保留运行时外部依赖及平台资源。构建图、隔离安装和 `dist/THIRD_PARTY_NOTICES.txt` 一起验证依赖边界。创建查询在有效目录确认后与模板选择并行,与执行统一使用目标父目录的 npm 配置;本次调用结束即清理预取,不建立持久版本缓存。`--dry-run` 保持零联网。性能记录须区分 CLI 执行、npm 全局扫描、registry 请求和脚手架安装,避免把外部等待或动画时长统称为启动耗时。 +CLI 命令与数据按需加载;QR core/SVG、semver、string-width、cross-spawn、yaml 和 picomatch 的纯 JavaScript 闭包打包进产物,仅 open 保留运行时外部依赖及平台资源。构建图、隔离安装和 `dist/THIRD_PARTY_NOTICES.txt` 一起验证依赖边界。创建查询在有效目录确认后与模板选择并行,与执行统一使用目标父目录的 npm 配置;本次调用结束即清理预取,不建立持久版本缓存。`--dry-run` 保持零联网。性能记录须区分 CLI 执行、npm 全局扫描、registry 请求和脚手架安装,避免把外部等待或动画时长统称为启动耗时。 + +[2026-10-22 项目工作台与终端性能验收](weapp-project-workbench-2026-10-22.md) 记录项目识别、只读诊断、确认执行边界、性能对比和本轮实际验证范围。 [2026-10-15 CLI 启动与创建验收](weapp-startup-2026-10-15.md) 记录本机基线、依赖闭包、npx 对比、真实创建和平台验证范围。构建后用 `pnpm --filter weapp exec tsx scripts/benchmark-startup.ts` 测新进程帮助/版本/dry-run;真实 PTY 的首个可操作帧记录在 `packages/weapp/.cache/qa/terminal-metrics.json`。CI 检查行为与结构,不给不同机器设置同一毫秒硬门槛。 diff --git a/docs/weapp-project-workbench-2026-10-22.md b/docs/weapp-project-workbench-2026-10-22.md new file mode 100644 index 0000000..39aacc5 --- /dev/null +++ b/docs/weapp-project-workbench-2026-10-22.md @@ -0,0 +1,61 @@ +# weapp 项目工作台与终端性能验收(2026-10-22) + +本轮保留动态工作台、Node 18 契约、双语和无遥测能力,按终端调度、项目识别与诊断、创建与执行三个阶段实现。未发布 npm,也未改动浏览器实验室界面或跨平台 CI 矩阵。 + +## 调度与性能边界 + +终端将画面生产函数推迟到可输出时调用;暂停且无变化时跳过绘制,背压仅保留最新请求,drain 后按最新状态与尺寸生成一帧。20 FPS 上限、按行差分、终端恢复、逻辑时钟和退出时限保留。静态菜单、搜索、换行和布局只缓存当前屏幕,输入与状态变化显式失效,resize 使用新尺寸。`--no-motion` 原本就是事件驱动,本轮保持该行为。 + +性能基线为同一 macOS arm64 工作区的构建产物,Node 24.22.1、文件系统缓存已热、15 次独立进程:空 Node 23.89ms、版本 28.66ms、帮助 28.00ms、离线创建预览 33.39ms(中位数)。这不包含 npm 在 CLI 启动前的全局扫描、包下载或脚手架网络等待;不将这些外部耗时计为本轮收益。机器时间仅记录,不加入 CI 毫秒门槛。 + +最终构建在其他验证任务结束后用相同脚本复测(15 个独立进程,中位数): + +| 短命令 | 基线 | 本轮 | +| ------------------ | ------- | ------- | +| 空 Node | 23.89ms | 24.40ms | +| `--version` | 28.66ms | 28.34ms | +| `--help` | 28.00ms | 28.26ms | +| `create --dry-run` | 33.39ms | 33.02ms | + +帮助/版本仍约 28ms,没有将小幅波动当作提速收益。一次与类型检查/打包并发的测量中,空 Node 已升至 41.52ms,因此不用于对比;完成这些任务后才得到上表。原始基线和最终结果分别在 `.cache/weapp-performance-review-2026-10-22/startup-node24.json`、`startup-after-node24.json`。运行 `node --import tsx packages/weapp/scripts/benchmark-startup.ts` 时应从可解析 tsx 的工作目录执行,或使用 `pnpm --filter weapp exec tsx scripts/benchmark-startup.ts`,并确保 Node 路径指向所比较的版本。 + +终端对比使用基线 `cd72b2f` 的隔离 git archive 与候选源码,Node 24、120×30、相同菜单/catalog/Logo、400ms 预热后采样 1 秒,每状态 5 个独立进程样本交替运行,报告中位数: + +| 场景 | Node CPU 时间,前 → 后 | 帧计算,前 → 后 | 输出字节,前 → 后 | +| -------- | ---------------------- | --------------- | ----------------- | +| 活跃动画 | 104.99 → 61.13ms | 20 → 19 | 22,121 → 21,128 | +| 暂停空闲 | 104.01 → 1.54ms | 20 → 0 | 0 → 0 | +| 输出背压 | 104.40 → 1.76ms | 20 → 0 | 0 → 0 | + +活跃动画的静态菜单计算从 20 次降为 0 次(缓存已预热)。活跃帧 20/19 是采样边界差异,20 FPS 上限保持。CPU 为 Node 进程自身时间,背压由模拟输出流注入,不代表终端模拟器 CPU 或 npm 冷启动速度。行为回归另外断言:drain 后只生成最新状态的一帧、resize 使用最新尺寸、暂停后输入和进度仍能刷新。原始 JSON:`.cache/weapp-performance-review-2026-10-22/terminal-node24.json`。 + +## 项目发现与只读诊断 + +共享 `ProjectContext` 从输入目录向上找到最近的 Node/原生微信/混合项目,在 Git 或文件系统根停止。损坏 manifest 是错误,不能退化为空目录。workspace 只读取当前路径上的声明,校验 pnpm/npm/Yarn 包含、排除及真实路径,不扫描全部成员,不执行配置。YAML 与 glob 使用 Node 18 兼容的精确版本 yaml 2.9.1、picomatch 4.0.7;许可证随 bundle 交付,运行时外部依赖仍只有 open。 + +大厅首帧后异步发现;不探测包管理器版本,不延迟执行识别期间的 Enter,不重置用户已经移动的焦点。已有项目展示状态、诊断与真实脚本;没有 manifest 的原生项目不提供安装和脚本入口。C 保持明确的创建快捷键。 + +Doctor 始终只读。它报告输入目录和识别根,检查实际 Node/包管理器版本、可检查的已安装依赖版本和 workspace 提升布局。weapp-vite 与原生微信提供静态配置检查,其他框架提供通用检查及官方链接。`workspace:`、`catalog:`、Git/路径/标签和无法可靠检查的 PnP 显式列为未判定,不运行 PnP 加载器。版本探测禁用 Corepack 网络、自动 pin 及 pnpm 自动下载管理器。 + +## 创建与执行边界 + +`create --install` 是明确的创建后安装意图,默认创建不安装。`--package-manager npm|pnpm|yarn|bun` 不能覆盖 workspace/项目声明冲突;无声明时按显式选择、唯一锁文件、npm 默认值决定。多种锁文件通过工作台选择或命令参数解决。`--dry-run` 即使带 `--install` 仍离线、不写文件、不执行安装,产物信息留待创建后判断。 + +创建结果使用实际 manifest,列出包管理器、脚本和 doctor。安装失败独立于创建成功报告,文件保留,可再次选择操作。工作台安装/脚本共享执行器:确认页显示 cwd、argv 对应命令和脚本内容;安装在已验证 workspace 根,脚本在当前包。确认后先检查版本约束,再重读项目;声明、依赖或脚本变化会要求重新确认。 + +进程通过参数数组启动。外部任务独占输入,结束后恢复页面与焦点;Ctrl+C 取消本次安装/脚本,Unix 清理专用进程组,Windows 使用定向 `taskkill /T /F`,等待 close 和清理结束后恢复。SIGTERM 保留全局退出约定。不会自动运行长期开发服务。 + +## 验证与交付 + +- CLI:17 个测试文件、334 项测试全部通过;包含发现中 Enter/焦点、离开页面后的晚到结果、workspace 与原生项目边界、损坏 manifest、已安装版本、安装/脚本确认及进程清理回归。 +- 类型检查、构建、ESLint、Stylelint 通过;ESLint 保留未改动实验室文件中的 4 条已有 no-use-before-define warning,没有错误。 +- 最终构建真实 PTY:28 个场景通过。输入先等待可见就绪标记;覆盖三种尺寸、暂停/resize/搜索、实际子进程继承输入、脚本运行、OS SIGINT 取消、PID 消失、恢复焦点,以及 raw mode/cursor/alternate screen 清理。证据为 `packages/weapp/.cache/qa/terminal-*.txt` 与 `terminal-metrics.json`。 +- 独立 tarball:在 workspace 外安装同一发布包,Node 18.20.8/22.23.4/24.22.1 的 artifact smoke 全部通过,包含新 doctor 上下文、协议/版本、workspace、原生项目及 `--dry-run --install`。压缩体积 311,233 bytes,解包 1,113,061 bytes,10 个外部运行时包名/版本;bundled 许可与图完整匹配。 +- frozen install:pnpm 12.14.0 通过,锁文件及 24 小时冷却策略保留。 +- 边界及原生发布流程:10 项测试通过。 +- 后台浏览器实验室:1440/390/320px、双语、6/11 项生态、官方资源与动作、动画暂停、重播、PNG、减少动态效果和 Canvas 降级通过;临时浏览器、context 和 loopback 服务由 finally 关闭。 +- 新增 minor change intent,保留代码版本 0.3.0;只读 release plan 状态 ready、无 blocker。最终版本由后续版本流程确定。 + +本机执行平台是 macOS arm64;Unix 子进程树清理同时经过真实进程回归,Windows taskkill 使用注入测试,既有 Linux/macOS/Windows CI 矩阵保留,未声称已在本机执行另外两个系统。脚手架 PTY 使用受控的真实子进程模拟官方交互;本轮未重新下载五套官方脚手架,也不评估 registry/npm 启动前扫描的速度。新版保留创建流程各阶段实际耗时输出。 + +本轮所有测试进程和临时浏览器资源已关闭。失败迭代的断言均按根因修复,未降低断言或加重试。原始缓存不进入发布包;双语 README/help、维护入口和 minor intent 已同步,无生产部署或 npm 发布。 diff --git a/packages/weapp/README.md b/packages/weapp/README.md index a00f561..e537f1b 100644 --- a/packages/weapp/README.md +++ b/packages/weapp/README.md @@ -1,6 +1,6 @@ # weapp -A project-driven, offline-first workbench for mini-app developers. Create weapp-vite first, explore six weapp projects through a living particle stage, and open the same ecosystem in a local browser lab. +A project-driven, offline-first workbench for mini-app developers. Inspect an existing project or create with weapp-vite, explore six weapp projects through a living particle stage, and open the same ecosystem in a local browser lab. ```bash npx weapp # immediately usable animated workbench @@ -10,6 +10,7 @@ npx weapp lab # offline ecosystem galaxy, weapp-vite selected npx weapp projects vue npx weapp info tw npx weapp create weapp-vite my-app --template wevu +npx weapp create weapp-vite installed-app --install --package-manager pnpm npx weapp doctor . npx weapp qr https://weapp.js.org/ --output weapp.svg npx weapp palette '#07c160' --format css @@ -27,21 +28,25 @@ This keeps npm's latest-version lookup and normal installation prompt. `npx weap ## Commands -| Command | What it does | -| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `fx [logo\|orbit\|matrix\|fireworks]` | Terminal effects; Space pauses, R replays, Left/Right switches, Q/Esc exits. `--seed 42`, `--duration 5` (seconds). | -| `lab` | Six-project galaxy with original logos, project actions, morphing particles, and pointer flow. Toggle all ecosystems, adjust controls, or download PNG. `--no-open` prints the local URL without opening it. | -| `projects [query]` | Interactive browsing starts with six weapp projects; Tab or `--all` shows all eleven. Queries and JSON search all eleven; `--ecosystem weapp\|taro\|vue-mini\|rezor\|uni-app`, `--status stable\|beta\|planned`, `--json`. | -| `info ` | Purpose, scope, platforms, official quick start, and related projects; `--json`. | -| `docs ` / `repo ` | Print the official link; `--open` opens it. | -| `create [route] [directory]` | Guided weapp-vite setup when a directory is omitted in a TTY; explicit route/directory remains supported. `--dry-run` previews offline. | -| `doctor [directory]` | Read-only environment, manifest, lockfile, installed-dependency, and mini-app configuration checks; `--json`. Never loads project config or runs project scripts. | -| `qr ` | Scannable terminal QR, or SVG with `--output file.svg`. `-` reads stdin. | -| `palette ` | Five shades and black/white text contrast. `--format css`, `--json`, or `--output file`. | +| Command | What it does | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `fx [logo\|orbit\|matrix\|fireworks]` | Terminal effects; Space pauses, R replays, Left/Right switches, Q/Esc exits. `--seed 42`, `--duration 5` (seconds). | +| `lab` | Six-project galaxy with original logos, project actions, morphing particles, and pointer flow. Toggle all ecosystems, adjust controls, or download PNG. `--no-open` prints the local URL without opening it. | +| `projects [query]` | Interactive browsing starts with six weapp projects; Tab or `--all` shows all eleven. Queries and JSON search all eleven; `--ecosystem weapp\|taro\|vue-mini\|rezor\|uni-app`, `--status stable\|beta\|planned`, `--json`. | +| `info ` | Purpose, scope, platforms, official quick start, and related projects; `--json`. | +| `docs ` / `repo ` | Print the official link; `--open` opens it. | +| `create [route] [directory]` | Guided weapp-vite setup when a directory is omitted in a TTY; explicit route/directory remains supported. `--install` installs after creation; `--package-manager` selects npm, pnpm, Yarn or Bun. `--dry-run` stays offline and never installs. | +| `doctor [directory]` | Read-only environment, manifest, lockfile, installed-dependency, and mini-app configuration checks; `--json`. Never loads project config or runs project scripts. | +| `qr ` | Scannable terminal QR, or SVG with `--output file.svg`. `-` reads stdin. | +| `palette ` | Five shades and black/white text contrast. `--format css`, `--json`, or `--output file`. | Global options: `--lang zh-CN|en`, `--no-color`, `--no-motion`, `--help`, `--version`. Language follows `LC_ALL`, `LC_MESSAGES`, or `LANG`, with Chinese as fallback. `NO_COLOR` is honored. `--no-motion` removes decorative animation; snake movement remains part of gameplay. Redirected output, CI, and `TERM=dumb` get bounded static output; JSON never contains terminal control sequences. Use `--force` to overwrite an exported QR/palette file; existing files are preserved otherwise. -The workbench starts on **Create weapp-vite**, with usable actions beside the assembling particle stage from its first frame. The first Enter opens creation; C is a creation shortcut only in the hall. Its other entries are weapp ecosystem, project doctor, visual lab, and more tools. Arrow keys and Enter select; Tab switches the ecosystem scope, Space pauses, Esc returns, and Q exits outside text entry. Wide windows use two columns; smaller windows stack the stage, and narrow windows prioritize actions and project status. `--no-motion` freezes the same layout. Frames are limited to 20 FPS, changed lines are written separately, and output backpressure drops intermediate frames. Official scaffolds temporarily receive the terminal input; returning restores the page and focus. +The first frame shows the animated workbench, then reads the current project without spawning a package manager. Existing projects start with status, diagnostics, installation, and their actual scripts; directories without a project start with creation. Discovery walks upward to the nearest project and stops at the Git or filesystem boundary. A malformed manifest is reported, and native WeChat projects without a manifest have no install/script actions. Workspace settings are inherited only after matching pnpm/npm/Yarn include/exclude rules and checking real paths; discovery does not scan every member or execute configuration. + +During discovery Enter is ignored, never queued. A selection you have already changed stays selected; C always opens creation from the hall. Arrow keys and Enter select; Tab switches ecosystem scope, Space pauses, Esc returns, and Q exits outside text entry. Frames are limited to 20 FPS with changed-line output. Paused idle screens do not compute frames. Backpressure retains only the latest request and computes one fresh frame after drain, using the current dimensions. `--no-motion` remains event-driven, and the current screen's static layout is cached. External tasks take over terminal input; completion restores the page and focus. Ctrl+C cancels an install/script task and cleans up its process tree before returning. + +Install and script actions show their directory, command, and script body before confirmation. Installation runs at the verified workspace root; scripts run in the selected package. The project and workspace package-manager declarations must agree and take precedence over an explicit choice. Without a declaration the order is explicit choice, a unique lockfile, then npm; multiple managers' lockfiles require a selection. Incompatible Node or package-manager versions block execution. No package manager is downloaded automatically, and no development server starts automatically. The lab defaults to the ecosystem galaxy with weapp-vite selected. Project selection changes the stage palette, focus, maturity, capabilities, and official actions. The particle scene morphs between WEAPP and the selected original project mark. Commands are copied, never executed in the browser. Replay preserves the project and controls while resetting animation and drag offsets. All logos and licensed fonts ship in the package. The lab listens only on a random loopback port and stops when this CLI exits; no project directory is served. @@ -69,7 +74,9 @@ The lab defaults to the ecosystem galaxy with weapp-vite selected. Project selec The directory name seeds a repeatable star chart; choosing a template rearranges its orbits. After a valid directory is selected, version lookup runs while you choose the template and review the summary. Lookup and execution use the same target-parent working directory and its effective npm registry, proxy, CA and authentication settings. The result is reused only within that creation attempt; there is no persistent version cache. `--dry-run` stays offline. -Other creators retain their own prompts. A target must not exist and its parent must exist. The CLI resolves the latest creator version, checks its Node requirement, executes that exact version, and verifies the generated manifest and dependency before reporting success. Query, execution and validation show their actual stage and elapsed time. In the workbench, Esc cancels a pending creation without closing the hall, and success immediately displays the project badge and next commands. Standalone creation returns to your shell. The CLI does not install project dependencies or initialize an existing project. Partial output remains available if an upstream creator fails. +Other creators retain their own prompts. A target must not exist and its parent must exist. The CLI resolves the latest creator version, checks its Node requirement, executes that exact version, and verifies the generated manifest and dependency before reporting success. Query, execution and validation show their actual stage and elapsed time. In the workbench, Esc cancels a pending creation without closing the hall, and success immediately displays the project badge and next commands. Standalone creation returns to your shell. Dependencies are not installed by default. `--install` gives explicit installation consent without a second prompt, and `--package-manager` selects a manager subject to the generated project/workspace declarations. The success page reads the real manifest, offers I for installation, S for scripts and D for diagnostics. Installation failure is separate from creation success; generated files remain and installation can be retried. `--dry-run`, including with `--install`, never queries npm, creates files, or installs; unknown artifact details are determined after creation. Partial output remains available if an upstream creator fails. + +`doctor [directory]` reports both the supplied discovery location and the detected root. It checks Node, installed package-manager versions, resolvable dependency versions (including workspace layouts), and static weapp-vite/native WeChat configuration. Other frameworks receive generic checks and official documentation links. `workspace:`, `catalog:`, Git/path/tag ranges and PnP that cannot be inspected reliably are reported as unevaluated. JSON retains `directory`, `ok`, and `checks`, adding context, evidence and suggested actions. Warnings retain exit code 0; errors use 1. Doctor never installs dependencies or executes project configuration/scripts. Each ecosystem project keeps its own release cadence and API. An install command is not a complete integration. weapp-sqlite is in beta: install its weapp-vite integration with `pnpm add @weapp-sqlite/weapp-vite`, then follow the [official guide](https://sqlite.weapp.dev/). The verified scope covers Web and WeChat DevTools; check the guide before using it on a physical device. This integration is available through `weapp info weapp-sqlite`; it has no `weapp create` route. @@ -111,7 +118,7 @@ The CLI, lab, tests, and validation scripts are written in strict TypeScript. `t The published tarball contains runtime JavaScript, an embedded catalog and Logo point cloud, the lab's licensed assets, and `dist/THIRD_PARTY_NOTICES.txt` for bundled libraries. Commands and their data load on demand. Pure JavaScript tools are bundled; `open@10` remains an installed dependency to preserve its platform resources. The package does not depend on the monorepo, Astro, or either website. `test:pack` installs the actual tarball in a temporary directory and runs artifact smoke checks. Release tags use `weapp@`; legacy `weapp-v` tags remain accepted. -Versions and changelogs are prepared by repoctl using pnpm's native change intents. Record `pnpm change weapp --bump patch --summary "Describe the fix"` on your development branch, commit the intent, and merge it into `main`. Use `minor` for new features and `pnpm release:plan --json` for a read-only preview. The Version Packages workflow creates or updates `release/pnpm-version`, then runs CLI CI and OIDC preflight on that PR. Private workspaces are excluded explicitly. This minor intent prepares `0.3.0` from the published `0.2.0` baseline; version PRs are reviewed and merged separately from publication. +Versions and changelogs are prepared by repoctl using pnpm's native change intents. Record `pnpm change weapp --bump patch --summary "Describe the fix"` on your development branch, commit the intent, and merge it into `main`. Use `minor` for new features and `pnpm release:plan --json` for a read-only preview. The Version Packages workflow creates or updates `release/pnpm-version`, then runs CLI CI and OIDC preflight on that PR. Private workspaces are excluded explicitly. The release plan determines the next version from the current baseline and pending intents; version PRs are reviewed and merged separately from publication. ## Release preflight @@ -137,9 +144,13 @@ MIT ## 中文说明 -`weapp` 是以项目为核心的小程序动态工作台。默认优先创建 weapp-vite;终端与离线浏览器实验室由项目选择驱动粒子、星轨、配色与能力信息。二维码、配色、项目体检和官方脚手架保留独立命令。 +`weapp` 是以项目为核心的小程序动态工作台。识别已有项目,无项目时优先创建 weapp-vite;终端与离线浏览器实验室由项目选择驱动粒子、星轨、配色与能力信息。二维码、配色、项目体检和官方脚手架保留独立命令。 -直接运行 `npx weapp`,第一帧即可操作,Braille 粒子在操作区旁组装。默认焦点为「创建 weapp-vite」,首个 Enter 直接进入创建,大厅可按 C 快速创建;其后是 weapp 生态、项目体检、视觉实验室和更多工具。方向键与 Enter 选择,Tab 切换全部生态,空格暂停,Esc 返回,非文本输入页按 Q 退出。宽窗口双栏、普通窗口上下排列、窄窗口优先保留操作与状态。统一最高 20 FPS,按行差分输出并处理背压;脚手架期间释放终端输入,返回后恢复页面和焦点。管道、CI 与不支持交互的终端输出静态命令清单。 +直接运行 `npx weapp`,首帧展示动态工作台,随后异步读取当前项目,不启动包管理器探测。已有项目默认展示状态、诊断、安装和真实脚本,无项目时优先创建。识别从当前目录向上寻找最近项目,在 Git 或文件系统边界停止;损坏 manifest 显示错误,原生微信项目没有 package.json 时不提供安装和脚本入口。pnpm/npm/Yarn workspace 只有匹配包含/排除规则、真实路径位于根内时才继承,不扫描全部成员或执行配置。 + +识别期间的 Enter 不排队执行,识别完成不改变用户已选择的焦点;大厅按 C 始终创建。方向键与 Enter 选择,Tab 切换全部生态,空格暂停,Esc 返回,非文本输入页按 Q 退出。保留动态工作台、20 FPS 上限与按行差分输出;暂停空闲时不计算画面,背压期间只保留最新请求,恢复时按最新状态和尺寸生成一帧。缓存仅保留当前屏幕布局,`--no-motion` 仍按事件刷新。管道、CI 与非交互终端输出静态命令清单。 + +安装/脚本操作先展示执行目录、命令和脚本内容,确认后才执行。安装在已验证 workspace 根目录执行,脚本在当前包目录执行。workspace/项目声明优先且必须一致;无声明时依次使用显式选择、唯一锁文件、npm 默认值。多种包管理器锁文件要求选择,Node 或包管理器不兼容会阻止执行,不自动下载包管理器。任务接管终端,完成后恢复页面和焦点;Ctrl+C 取消安装/脚本会清理本次子进程树。不会自动启动开发服务。 部分 npm 版本会在运行 CLI 前扫描全局依赖树,此时可尝试 `npx --package=weapp@latest -- weapp`,继续保留 latest 查询和正常安装提示。普通 `npx weapp` 仍支持。收益取决于 npm 版本、全局依赖树及缓存;首次下载还受 registry 和网络影响。 @@ -156,11 +167,13 @@ printf 'https://weapp.js.org/' | npx weapp qr - npx weapp palette '#07c160' --output colors.css --format css ``` -默认无遥测,无账号或 API key。除主动创建项目和访问外部链接外,功能均离线可用。`--no-color` / `NO_COLOR` 禁用颜色,`--no-motion` 禁用装饰动画(贪吃蛇仍保留游戏移动),`--lang en` 切换英文。二维码和配色文件默认不覆盖,明确传 `--force` 才覆盖。 +默认无遥测,无账号或 API key。目录识别、诊断和内置工具离线可用;主动创建、安装、运行项目脚本或访问外部链接可能联网。`--no-color` / `NO_COLOR` 禁用颜色,`--no-motion` 禁用装饰动画(贪吃蛇仍保留游戏移动),`--lang en` 切换英文。二维码和配色文件默认不覆盖,明确传 `--force` 才覆盖。 交互运行 `weapp create` 或 `weapp create weapp-vite` 直接进入「目录 → 模板 → 创建摘要」,默认官方 `default` 模板;非 TTY 缺目录报用法错误,不联网。项目名决定可复现的星图,选择模板会重组轨道。有效目录确认后,版本查询与模板选择、摘要阅读并行;查询和执行统一使用目标父目录生效的 npm 源、代理、CA 和认证配置,仅在本次创建内复用结果。进度显示真实阶段和耗时。在工作台内,Esc 可取消等待并返回大厅,产物验证成功后立即显示徽章与后续命令;独立创建命令完成后回到 shell。 -五个模板分别用于原生 Vite、Wevu 单文件组件、React、Tailwind CSS 和多平台单文件组件。显式 `create <路线> <目录>` 与离线 `--dry-run` 保持兼容。创建支持 weapp-vite、Vue Mini、Rezor、Uni Helper 和 VPT 的官方入口。新目录必须不存在且父目录已存在;运行前核验 Node 要求、固定解析版本,运行后检查 manifest 和框架依赖。上游选项仍由官方工具询问,项目依赖由你随后安装。`doctor` 只读取文件,不执行配置或脚本。已有项目的接入请参考 `info` 与官方文档。 +五个模板分别用于原生 Vite、Wevu 单文件组件、React、Tailwind CSS 和多平台单文件组件。显式 `create <路线> <目录>` 与离线 `--dry-run` 保持兼容。创建支持 weapp-vite、Vue Mini、Rezor、Uni Helper 和 VPT 的官方入口。新目录必须不存在且父目录已存在;运行前核验 Node 要求、固定解析版本,运行后检查 manifest 和框架依赖。上游选项仍由官方工具询问,默认不安装项目依赖。`create --install` 明确要求创建后安装,不重复询问;`--package-manager npm|pnpm|yarn|bun` 选择包管理器,但不能覆盖声明冲突。结果页使用真实 manifest,I 安装、S 脚本、D 诊断。安装失败与创建成功分别报告,保留生成文件并可重试。`--dry-run` 即使同时传 `--install` 仍不联网、不创建、不安装;未知产物信息留待创建后判断。 + +`doctor [目录]` 同时报告输入位置与项目根,检查实际 Node、包管理器和可解析已安装依赖版本,支持 workspace 安装布局。weapp-vite 与原生微信项目提供静态配置检查,其他框架提供通用检查及官方文档。`workspace:`、`catalog:`、Git/路径/标签及无法可靠解析的 PnP 明确标记未判定。JSON 保留 `directory`、`ok`、`checks`,追加上下文、证据和建议动作;警告退出码仍为 0,错误为 1。诊断不安装依赖、不执行配置或脚本。 交互浏览默认展示 weapp-vite、weapp-tailwindcss、weapp-pandacss、weapp-stylex、Varo 和 weapp-sqlite 六项;Tab 或 `projects --all` 展示全部十一项。关键词搜索、JSON 与精确项目查询始终覆盖十一项。 @@ -172,7 +185,7 @@ npx weapp palette '#07c160' --output colors.css --format css 构建后执行 `pnpm --filter weapp preflight` 可本地检查包身份、Node 18、公开访问、版本未发布和打包资源;它不会检查 CI OIDC,也不证明发布权限。`publish-weapp.yml` 手动运行默认 `mode=preflight`、`tag=main`:在 GitHub 托管 runner 上安装并测试同一 tarball,显式请求 GitHub JWT 并调用 npm OIDC exchange。凭据缺失或 exchange 失败即终止;JWT 与 token 仅掩码后丢弃,不落盘、不交给发布命令。交换成功只证明该交换完成,预检上传验证后的 tarball,整个模式不会发布 npm 包。 -版本与 changelog 由 repoctl 和 pnpm 原生 change intents 管理。在开发分支运行 `pnpm change weapp --bump patch --summary "修复说明"`,提交 intent 并合入 main;新功能使用 `minor`,`pnpm release:plan --json` 可只读预览。Version Packages 工作流维护 `release/pnpm-version` → main 的版本 PR,主动运行该分支的 CLI CI 和 OIDC 预检,明确排除私有 workspace。已发布 `0.1.0` 后的下一份 minor intent 准备 `0.2.0`;main 仍为已发布基线时,应预检版本分支,而不是重复预检 main。版本 PR 需要单独审查、合并,不会自动发布。 +版本与 changelog 由 repoctl 和 pnpm 原生 change intents 管理。在开发分支运行 `pnpm change weapp --bump patch --summary "修复说明"`,提交 intent 并合入 main;新功能使用 `minor`,`pnpm release:plan --json` 可只读预览。Version Packages 工作流维护 `release/pnpm-version` → main 的版本 PR,主动运行该分支的 CLI CI 和 OIDC 预检,明确排除私有 workspace。下一版本以当前基线与待处理 intent 的 release plan 为准;main 仍为已发布基线时,应预检版本分支。版本 PR 需要单独审查、合并,不会自动发布。 合并版本 PR 后,发布 `weapp@` 的 GitHub Release,标签指向合并提交,再由既有工作流发布 npm。旧 `weapp-v` 标签保持兼容;新的自动版本流程使用 canonical 名称,因为 repoctl 消费下一批 intent 前要求上一版本的 npm、canonical tag 和 GitHub Release 完整。手动发布仍须明确 `mode=publish`,使用 `--ref weapp@0.1.0 -f mode=publish -f tag=weapp@0.1.0`;仅手动发布 npm 时还须补齐 canonical Release。GitHub ref、provenance SHA、检出的 HEAD 与 tag 对应提交必须一致。npm trusted publisher 应匹配 `weappjs/weapp.dev`、`publish-weapp.yml` 及当前空 environment。`npm publish --dry-run` 不能作为 OIDC 认证证明。 diff --git a/packages/weapp/package.json b/packages/weapp/package.json index c2439eb..e4890cf 100644 --- a/packages/weapp/package.json +++ b/packages/weapp/package.json @@ -64,18 +64,21 @@ "@playwright/test": "1.64.1", "@types/cross-spawn": "6.0.6", "@types/node": "24.19.2", + "@types/picomatch": "4.0.3", "@types/qrcode": "1.5.6", "@types/semver": "7.8.0", "@weapp-io/project-catalog": "workspace:*", "@weapp-io/site-ui": "workspace:*", "cross-spawn": "7.0.6", "jsqr": "1.4.0", + "picomatch": "4.0.7", "qrcode": "1.5.4", "semver": "7.8.5", "string-width": "7.2.0", "tsdown": "0.23.1", "tsx": "4.23.15", "typescript": "6.0.3", - "vitest": "5.0.5" + "vitest": "5.0.5", + "yaml": "2.9.1" } } diff --git a/packages/weapp/scripts/artifact-smoke.ts b/packages/weapp/scripts/artifact-smoke.ts index 473b921..8cd30dc 100644 --- a/packages/weapp/scripts/artifact-smoke.ts +++ b/packages/weapp/scripts/artifact-smoke.ts @@ -123,7 +123,7 @@ try { const runner = join(bin, 'npm-fixture.cjs') await writeFile(runner, `const fs = require('node:fs'); const path = require('node:path'); const args = process.argv.slice(2); -fs.appendFileSync(process.env.WEAPP_SMOKE_CAPTURE, JSON.stringify({args, cwd:process.cwd()}) + '\\n'); +fs.appendFileSync(process.env.WEAPP_SMOKE_CAPTURE, JSON.stringify({args, cwd:process.cwd(), corepackNetwork:process.env.COREPACK_ENABLE_NETWORK, managePackageManagerVersions:process.env.npm_config_manage_package_manager_versions}) + '\\n'); if (args[0] === 'view') process.stdout.write(JSON.stringify({version:'1.2.3',engines:{node:'>=18'}})); else if (args[0] === '--version') process.stdout.write('10.0.0\\n'); else if (args[0] === 'exec') { @@ -158,6 +158,67 @@ else if (args[0] === 'exec') { assert.equal(calls[0].cwd, await realpath(fixture)) assert.equal(calls[1].cwd, await realpath(fixture), 'Windows .cmd and POSIX shebang preserve the Unicode/metacharacter working directory') assert.equal(parseProject(parseJson(run(['info', 'tw', '--json'], { cwd: fixture, env }))).slug, 'weapp-tailwindcss') + + const dryTarget = join(fixture, 'offline-install-preview') + const capturedBeforePreview = await readFile(capture, 'utf8') + const preview = run(['create', 'weapp-vite', dryTarget, '--dry-run', '--install', '--package-manager', 'pnpm'], { cwd: fixture, env }) + assert.match(preview, /create-weapp-vite@latest/) + assert.match(preview, /does not access the network/) + await assert.rejects(access(dryTarget), { code: 'ENOENT' }, 'Install preview must not create the target') + assert.equal(await readFile(capture, 'utf8'), capturedBeforePreview, 'Dry-run with installation must not invoke a package manager') + + const packedProject = await realpath(join(fixture, 'packed-project')) + const sourceDirectory = join(packedProject, 'src', 'pages') + await mkdir(sourceDirectory, { recursive: true }) + const nestedReport = parseJson(run(['doctor', sourceDirectory, '--json'], { cwd: fixture, env })) + assert.ok(isRecord(nestedReport) && isRecord(nestedReport.context) && Array.isArray(nestedReport.checks)) + assert.equal(nestedReport.directory, sourceDirectory, 'Existing JSON directory field retains requested-directory semantics') + assert.equal(nestedReport.requestedDirectory, sourceDirectory) + assert.equal(nestedReport.projectRoot, packedProject) + assert.equal(nestedReport.context.kind, 'package') + assert.equal(nestedReport.context.projectRoot, packedProject) + assert.match(run(['doctor', sourceDirectory], { cwd: fixture, env }), /Detected project root:/) + const probeCalls: unknown[] = (await readFile(capture, 'utf8')).trim().split('\n').map(parseJson) + const probe = probeCalls.at(-1) + assert.ok(isRecord(probe)) + assert.deepEqual(probe.args, ['--version']) + assert.equal(probe.cwd, packedProject, 'Doctor probes in the detected project context') + assert.equal(probe.corepackNetwork, '0', 'Doctor must not trigger Corepack downloads') + assert.equal(probe.managePackageManagerVersions, 'false', 'Doctor must not trigger pnpm manager downloads') + + const nativeProject = join(fixture, 'native-project') + await mkdir(nativeProject) + await writeFile(join(nativeProject, 'app.json'), JSON.stringify({ pages: ['pages/index/index'] })) + const capturedBeforeNative = await readFile(capture, 'utf8') + const nativeReport = parseJson(run(['doctor', nativeProject, '--json'], { cwd: fixture, env })) + assert.ok(isRecord(nativeReport) && isRecord(nativeReport.context)) + assert.equal(nativeReport.ok, true, 'Native projects do not require package.json') + assert.equal(nativeReport.context.kind, 'native') + assert.equal(await readFile(capture, 'utf8'), capturedBeforeNative, 'Native-only doctor does not probe a package manager') + + await mkdir(join(packedProject, 'node_modules', 'weapp-vite'), { recursive: true }) + await writeFile(join(packedProject, 'node_modules', 'weapp-vite', 'package.json'), JSON.stringify({ name: 'weapp-vite', version: '2.0.0' })) + const versionReport = parseJson(run(['doctor', packedProject, '--json'], { cwd: fixture, env })) + assert.ok(isRecord(versionReport) && Array.isArray(versionReport.checks)) + const versionCheck = versionReport.checks.find(check => isRecord(check) && check.id === 'dependency-versions') + assert.ok(isRecord(versionCheck) && isRecord(versionCheck.suggestion)) + assert.equal(versionCheck.severity, 'warning') + assert.deepEqual(versionCheck.suggestion, { cwd: packedProject, command: 'npm', args: ['install'] }) + + const workspace = join(fixture, 'workspace') + const member = join(workspace, 'packages', 'member') + await mkdir(member, { recursive: true }) + await writeFile(join(workspace, 'package.json'), JSON.stringify({ packageManager: 'npm@10.0.0' })) + await writeFile(join(workspace, 'pnpm-workspace.yaml'), 'packages:\n - "packages/*"\n - "!packages/excluded"\n') + await writeFile(join(member, 'package.json'), JSON.stringify({ packageManager: 'npm@9.0.0' })) + const conflictResult = spawnSync(process.execPath, [cli, 'doctor', member, '--json'], { cwd: fixture, env, encoding: 'utf8', timeout: 15000 }) + assert.equal(conflictResult.status, 1) + assert.equal(conflictResult.stderr, '') + const conflictReport = parseJson(conflictResult.stdout) + assert.ok(isRecord(conflictReport) && isRecord(conflictReport.context) && isRecord(conflictReport.context.workspace) && Array.isArray(conflictReport.checks)) + assert.equal(conflictReport.context.workspace.root, await realpath(workspace)) + assert.ok(conflictReport.checks.some(check => isRecord(check) && check.id === 'package-manager-workspace' && check.severity === 'error'), 'Packed YAML/glob support must preserve declaration conflicts') + const empty = join(fixture, 'empty-path') await mkdir(empty) env[process.platform === 'win32' ? 'Path' : 'PATH'] = empty diff --git a/packages/weapp/scripts/bundle-policy.ts b/packages/weapp/scripts/bundle-policy.ts index d4f2f31..cd26207 100644 --- a/packages/weapp/scripts/bundle-policy.ts +++ b/packages/weapp/scripts/bundle-policy.ts @@ -14,6 +14,7 @@ export const bundledDependencies = Object.freeze({ 'get-east-asian-width': 'MIT', 'isexe': 'ISC', 'path-key': 'MIT', + 'picomatch': 'MIT', 'qrcode': 'MIT', 'semver': 'ISC', 'shebang-command': 'MIT', @@ -21,6 +22,7 @@ export const bundledDependencies = Object.freeze({ 'string-width': 'MIT', 'strip-ansi': 'MIT', 'which': 'ISC', + 'yaml': 'ISC', } as const) export const bundleReportPath = 'data/bundle.json' @@ -96,7 +98,7 @@ async function bundledSources(): Promise> { // Only QR core and the SVG tag renderer are bundled, not qrcode's PNG/CLI graph. await visit('qrcode', packageRequire, false) await visit('dijkstrajs', createRequire(path.join(sources.get('qrcode')!.directory, 'package.json'))) - for (const name of ['cross-spawn', 'semver', 'string-width']) { + for (const name of ['cross-spawn', 'semver', 'string-width', 'picomatch', 'yaml']) { await visit(name, packageRequire) } if (sources.size !== packageNames.length) { diff --git a/packages/weapp/scripts/pty-test.py b/packages/weapp/scripts/pty-test.py index 8100e42..d8cb968 100644 --- a/packages/weapp/scripts/pty-test.py +++ b/packages/weapp/scripts/pty-test.py @@ -19,8 +19,11 @@ METRICS = {} -def exercise(name, args, events, width=90, height=28, expected=0, command=None, require_events=False): - """Events can wait for elapsed time or for a marker from the actual child.""" +def exercise(name, args, events, width=90, height=28, expected=0, command=None, require_events=False, cwd=None): + """Wait for fresh output markers, or a delay after the preceding action.""" + if cwd is None: + with tempfile.TemporaryDirectory(prefix='weapp-pty-cwd-') as directory: + return exercise(name, args, events, width, height, expected, command, require_events, cwd=directory) master, slave = pty.openpty() original = termios.tcgetattr(slave) fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', height, width, 0, 0)) @@ -28,20 +31,27 @@ def exercise(name, args, events, width=90, height=28, expected=0, command=None, environment.pop('CI', None) executable = command or ['node', str(ROOT / 'dist/cli.js')] start = time.monotonic() - child = subprocess.Popen([*executable, '--lang', 'en', *args], stdin=slave, stdout=slave, stderr=slave, env=environment) + child = subprocess.Popen([*executable, '--lang', 'en', *args], stdin=slave, stdout=slave, stderr=slave, env=environment, cwd=cwd) chunks = [] first_actionable = None sent = 0 + event_cursor = 0 + last_event_time = 0.0 try: while child.poll() is None and time.monotonic() - start < 12: elapsed = time.monotonic() - start output = b''.join(chunks) while sent < len(events): trigger, action = events[sent] - ready = trigger in output if isinstance(trigger, bytes) else elapsed >= trigger + position = output.find(trigger, event_cursor) if isinstance(trigger, bytes) else -1 + ready = position >= 0 if isinstance(trigger, bytes) else elapsed - last_event_time >= trigger if not ready: break - if isinstance(action, tuple): + event_cursor = position + len(trigger) if isinstance(trigger, bytes) else len(output) + last_event_time = elapsed + if isinstance(action, signal.Signals): + child.send_signal(action) + elif isinstance(action, tuple): fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', action[1], action[0], 0, 0)) child.send_signal(signal.SIGWINCH) else: @@ -50,7 +60,7 @@ def exercise(name, args, events, width=90, height=28, expected=0, command=None, if select.select([master], [], [], .03)[0]: chunks.append(os.read(master, 65536)) visible = b''.join(chunks) - if first_actionable is None and (not args or args == ['--no-motion']) and all(marker in visible for marker in [b'WORKBENCH', b'Create with weapp-vite', b'Enter / C']): + if first_actionable is None and (not args or args == ['--no-motion']) and all(marker in visible for marker in [b'WORKBENCH', b'Create with weapp-vite']) and (b'Enter / C' in visible or b'Enter open / C' in visible): first_actionable = round((time.monotonic() - start) * 1000, 2) if child.poll() is None: (EVIDENCE / f'terminal-{name}.txt').write_bytes(b''.join(chunks)) @@ -97,7 +107,7 @@ def save_frame(output, name, title=None): results = {} -opening = exercise('opening', [], [(b'WORKBENCH', b'\r'), (b'NEW PROJECT DIRECTORY', b'\x1b'), (.6, b'q')]) +opening = exercise('opening', [], [(b'Enter / C', b'\r'), (b'NEW PROJECT DIRECTORY', b'\x1b'), (b'WORKBENCH', b'q')]) single_screen(opening, 'opening') assert b'WORKBENCH' in opening and b'Create with weapp-vite' in opening assert b'NEW PROJECT DIRECTORY' in opening and b'Press any key' not in opening @@ -108,43 +118,43 @@ def save_frame(output, name, title=None): assert b'Create with weapp-vite' in hall and b'Press any key' not in hall save_frame(hall, f'hall-{label}') results[f'actionable-{label}'] = len(hall) -shortcut = exercise('hall-shortcut', [], [(b'WORKBENCH', b'c'), (b'NEW PROJECT DIRECTORY', b'\x1b'), (.6, b'q')]) +shortcut = exercise('hall-shortcut', [], [(b'WORKBENCH', b'c'), (b'NEW PROJECT DIRECTORY', b'\x1b'), (b'WORKBENCH', b'q')]) assert b'NEW PROJECT DIRECTORY' in shortcut results['hall-c-shortcut'] = len(shortcut) -idle = exercise('idle', [], [(.15, b'x'), (.85, b'q')], width=120, height=30) +idle = exercise('idle', [], [(b'Enter / C', b'x'), (.7, b'q')], width=120, height=30) scene_rows = [row for row in re.findall(rb'\x1b\[\d+;1H(.*?)\x1b\[K', idle) if re.search(rb'[\xe2][\xa0-\xa3]', row)] assert len(set(scene_rows)) > 5, 'idle: the live workbench did not animate' results['idle-workbench'] = len(idle) -more = exercise('more', ['--no-motion'], [(.2, b'\x1b[A'), (.35, b'\r'), (.5, b'q'), (.65, b'q')]) +more = exercise('more', ['--no-motion'], [(b'Enter / C', b'\x1b[A\r'), (b'MORE TOOLS', b'q'), (b'WORKBENCH', b'q')]) assert b'MORE TOOLS' in more and b'Generate a QR code' in more assert b'snake' not in more.lower() and b'game' not in more.lower(), 'more: hidden games leaked into navigation' single_screen(more, 'more') results['hidden-game-navigation'] = len(more) -browse = exercise('browse', ['--no-motion'], [(.2, b'\x1b[B\r'), (.4, b'\r'), (.6, b'\x1b'), (.8, b'\x1b'), (1, b'q')]) +browse = exercise('browse', ['--no-motion'], [(b'Enter / C', b'\x1b[B\r'), (b'ECOSYSTEM', b'\r'), (b'Capabilities', b'\x1b'), (b'ECOSYSTEM', b'\x1b'), (b'WORKBENCH', b'q')]) assert b'Capabilities' in browse and b'Vite' in browse single_screen(browse, 'browse') results['same-screen-project-detail'] = len(browse) -hall_interrupt = exercise('hall-fx-interrupt', ['--no-motion'], [(.2, b'\x1b[A\r'), (.4, b'\x1b[A\r'), (.65, b'\x03')], expected=130) +hall_interrupt = exercise('hall-fx-interrupt', ['--no-motion'], [(b'Enter / C', b'\x1b[A\r'), (b'MORE TOOLS', b'\x1b[A\r'), (b'LOGO', b'\x03')], expected=130) assert b'LOGO' in hall_interrupt, 'hall-fx-interrupt: effect was not opened' results['hall-effect-global-interrupt'] = len(hall_interrupt) -results['effects-controls-resize'] = len(exercise('effects', ['fx', 'logo'], [(.25, b' '), (.4, b'r'), (.5, b'\x1b[C'), (.65, (30, 12)), (.8, (100, 30)), (1, b'q')])) +results['effects-controls-resize'] = len(exercise('effects', ['fx', 'logo'], [(b'LIVE', b' '), (b'PAUSED', b'r'), (.1, b'\x1b[C'), (.15, (30, 12)), (.15, (100, 30)), (.2, b'q')])) results['duration-exit'] = len(exercise('duration', ['fx', 'fireworks', '--duration', '.2'], [])) -results['interrupt-restoration'] = len(exercise('interrupt', ['fx', 'matrix'], [(.4, b'\x03')], expected=130)) -results['snake-controls'] = len(exercise('snake', ['play', 'snake'], [(.3, b'w'), (.45, b' '), (.6, (22, 13)), (.75, b'r'), (1, b'q')])) -results['static-effect'] = len(exercise('static', ['fx', 'orbit', '--no-motion'], [(.3, b'\x1b[C'), (.5, b'q')], width=32, height=10)) -results['project-search'] = len(exercise('projects', ['projects'], [(.3, b'tailwind'), (.55, b'\r')])) +results['interrupt-restoration'] = len(exercise('interrupt', ['fx', 'matrix'], [(b'LIVE', b'\x03')], expected=130)) +results['snake-controls'] = len(exercise('snake', ['play', 'snake'], [(b'SNAKE', b'w'), (.15, b' '), (.15, (22, 13)), (.15, b'r'), (.25, b'q')])) +results['static-effect'] = len(exercise('static', ['fx', 'orbit', '--no-motion'], [(b'ORBIT', b'\x1b[C'), (b'MATRIX', b'q')], width=32, height=10)) +results['project-search'] = len(exercise('projects', ['projects'], [(b'Search:', b'tailwind'), (b'Search: tailwind', b'\r')])) with tempfile.TemporaryDirectory(prefix='weapp-pty-') as temporary: parent = Path(temporary) target = parent / 'my-app' for label, events in [ - ('loading', [(b'WORKBENCH', b'\r\x1b'), (.6, b'q')]), + ('loading', [(b'Enter / C', b'\r\x1b'), (.6, b'q')]), ]: rapid = exercise(f'rapid-cancel-{label}', ['--no-motion'], events, require_events=True) single_screen(rapid, f'rapid-cancel-{label}') assert not target.exists() results[f'rapid-create-cancel-{label}'] = len(rapid) - cancelled = exercise('wizard-cancel', ['create', '--no-motion'], [(b'NEW PROJECT DIRECTORY', str(target).encode() + b'\r'), (b'CHOOSE A TEMPLATE', b'\x1b'), (.7, b'\x1b')]) + cancelled = exercise('wizard-cancel', ['create', '--no-motion'], [(b'NEW PROJECT DIRECTORY', str(target).encode() + b'\r'), (b'CHOOSE A TEMPLATE', b'\x1b'), (b'NEW PROJECT DIRECTORY', b'\x1b')]) assert b'CHOOSE A TEMPLATE' in cancelled assert not target.exists(), 'wizard-cancel: a cancelled wizard created a directory' single_screen(cancelled, 'wizard-cancel') @@ -161,6 +171,41 @@ def save_frame(output, name, title=None): save_frame(constellation, f'wizard-{label}', title='READY TO CREATE') results[f'creation-layout-{label}'] = len(constellation) + project = parent / 'existing-project' + project.mkdir() + (project / 'package.json').write_text(json.dumps({'name': 'existing-pty-project', 'version': '1.0.0', 'scripts': {'test': 'node action.cjs'}})) + action_script = project / 'action.cjs' + action_script.write_text("console.log('PROJECT_SCRIPT_COMPLETED')\n") + action_events = [ + (b'Enter open / C', b'\r'), + (b'CURRENT PROJECT', b'\x1b'), + (b'WORKBENCH', b'\x1b[B\x1b[B\x1b[B\r'), + (b'PROJECT SCRIPTS', b'\r'), + (b'CONFIRM PROJECT ACTION', b'\r'), + ] + executed = exercise('existing-project-script', ['--no-motion'], [*action_events, (b'Action completed.', b'\x1b'), (b'WORKBENCH', b'q')], cwd=project, require_events=True) + assert b'PROJECT_SCRIPT_COMPLETED' in executed and b'npm run test' in executed + assert executed.count(b'\x1b[?1049h') == 2, 'existing-project-script: workbench did not resume exactly once' + results['existing-project-script-and-resume'] = len(executed) + action_script.write_text(""" +const { writeFileSync } = require('node:fs'); +writeFileSync('script.pid', String(process.pid)); +for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => { console.log('PROJECT_SCRIPT_INTERRUPTED'); process.exit(130); }); +console.log('PROJECT_SCRIPT_READY'); +setInterval(() => {}, 1000); +""") + interrupted = exercise('existing-project-cancel', ['--no-motion'], [*action_events, (b'PROJECT_SCRIPT_READY', signal.SIGINT), (b'Cancelled; child processes cleaned up.', b'\x1b'), (b'WORKBENCH', b'q')], cwd=project, require_events=True) + assert b'PROJECT_SCRIPT_INTERRUPTED' in interrupted + assert interrupted.count(b'\x1b[?1049h') == 2, 'existing-project-cancel: workbench did not resume exactly once' + script_pid = int((project / 'script.pid').read_text()) + try: + os.kill(script_pid, 0) + except ProcessLookupError: + pass + else: + raise AssertionError('existing-project-cancel: script process survived cancellation') + results['existing-project-cancel-and-resume'] = len(interrupted) + harness = parent / 'upstream.mjs' cli_url = (ROOT / 'dist/index.js').as_uri() upstream = r""" @@ -196,13 +241,13 @@ def save_frame(output, name, title=None): }}) process.exitCode = code """) - rapid = exercise('rapid-cancel-directory', ['--no-motion'], [(b'WORKBENCH', b'\r'), (b'NEW PROJECT DIRECTORY', str(target).encode() + b'\r\x1b'), (.6, b'q')], command=['node', str(harness)], require_events=True) + rapid = exercise('rapid-cancel-directory', ['--no-motion'], [(b'Enter / C', b'\r'), (b'NEW PROJECT DIRECTORY', str(target).encode() + b'\r\x1b'), (.6, b'q')], command=['node', str(harness)], require_events=True) single_screen(rapid, 'rapid-cancel-directory') assert not target.exists() results['rapid-create-cancel-directory'] = len(rapid) for label, width, height in [('wide', 120, 30), ('normal', 80, 24), ('narrow', 44, 18)]: created = parent / f'created-{label}' - inherited = exercise(f'upstream-input-{label}', ['--no-motion'], [(b'WORKBENCH', b'\r'), (b'NEW PROJECT DIRECTORY', str(created).encode() + b'\r'), (b'CHOOSE A TEMPLATE', b'\r'), (b'READY TO CREATE', b'\r'), (b'UPSTREAM_READY', b'accepted\r'), (b'Project created', b'\rq')], width=width, height=height, command=['node', str(harness)]) + inherited = exercise(f'upstream-input-{label}', ['--no-motion'], [(b'Enter / C', b'\r'), (b'NEW PROJECT DIRECTORY', str(created).encode() + b'\r'), (b'CHOOSE A TEMPLATE', b'\r'), (b'READY TO CREATE', b'\r'), (b'UPSTREAM_READY', b'accepted\r'), (b'Project created', b'\rq')], width=width, height=height, command=['node', str(harness)]) assert b'PARENT_INPUT raw=false flowing=false listeners=0' in inherited, 'upstream-input: parent still owns the terminal' assert b'UPSTREAM_MODE raw=false' in inherited and b'UPSTREAM_READ=accepted' in inherited, 'upstream-input: upstream missed input' assert b'Project created' in inherited and b'WORKBENCH' in inherited diff --git a/packages/weapp/src/commands/create.ts b/packages/weapp/src/commands/create.ts index 83d79f8..eda43d1 100644 --- a/packages/weapp/src/commands/create.ts +++ b/packages/weapp/src/commands/create.ts @@ -5,6 +5,8 @@ import { readFile } from 'node:fs/promises' import path from 'node:path' // eslint-disable-next-line e18e/ban-dependencies -- Preserve Windows-safe npm spawning on the Node 18 baseline. import spawn from 'cross-spawn' +import { issueLines, projectLines } from '../project/presentation.js' +import { runProjectProcess } from '../project/process.js' import { sanitize, t, write, writeError } from '../shared.js' import { cancellationError, checkCancellation, CreateError } from './create/errors.js' import { CREATE_ADAPTERS, displayCommand, exists, npmExecArgs, scaffoldPlan, validateSelection } from './create/plan.js' @@ -100,30 +102,19 @@ export function queryScaffolder(packageName: string, { timeout = 15000, signal, }) }) } -export function executeScaffolder(plan: ScaffoldPlan, version: string, { signal }: ExecutionOptions = {}): Promise { - return new Promise((resolve, reject) => { - checkCancellation({ signal }) - const child = spawn('npm', npmExecArgs(plan, version), { cwd: plan.cwd, stdio: 'inherit' }) - const abort = () => child.kill() - signal?.addEventListener('abort', abort, { once: true }) - if (signal?.aborted) { - abort() - } - child.on('error', () => { - signal?.removeEventListener('abort', abort) - reject(signal?.aborted ? cancellationError() : new CreateError('无法启动官方脚手架,请确认 npm 已安装。', 'Unable to start the official scaffolder. Ensure npm is installed.')) - }) - // Wait for inherited terminal input to be released before restoring the workbench. - child.on('close', (code, childSignal) => { - signal?.removeEventListener('abort', abort) - if (signal?.aborted) { - reject(cancellationError()) - } - else { - resolve({ code, signal: childSignal }) - } - }) - }) +export async function executeScaffolder(plan: ScaffoldPlan, version: string, { signal }: ExecutionOptions = {}): Promise { + checkCancellation({ signal }) + const result = await runProjectProcess('npm', npmExecArgs(plan, version), plan.cwd, { signal }) + if (result.cleanupFailed) { + throw new CreateError('官方脚手架已停止,但未能完整清理其子进程树。', 'The official scaffolder stopped, but its process tree could not be fully cleaned up.') + } + if (result.cancelled) { + throw cancellationError() + } + if (result.failedToStart) { + throw new CreateError('无法启动官方脚手架,请确认 npm 已安装。', 'Unable to start the official scaffolder. Ensure npm is installed.') + } + return { code: result.code, signal: result.signal } } export async function createProject(route: string, directory: string, options: CliOptions = {}, operations: CreateOperations = {}): Promise { checkCancellation(operations) @@ -195,6 +186,7 @@ export async function runCreate(ctx: CliContext, args: string[], options: CliOpt let unsubscribeProgress: (() => void) | undefined let interruptedCode: (() => number | undefined) | undefined let locallyCancelled = false + let createdDirectory: string | undefined try { let selectedArgs = args let selectedOptions = options @@ -297,14 +289,49 @@ export async function runCreate(ctx: CliContext, args: string[], options: CliOpt } if (result.dryRun) { write(ctx, `${t(ctx, '执行目录', 'Working directory')}: ${sanitize(result.plan.cwd)}\n${displayCommand(npmExecArgs(result.plan))}`) - write(ctx, t(ctx, '实际运行会先检查官方版本和 Node 要求;此预览没有联网或写入文件。', 'Execution first checks the official version and Node requirements; this preview does not access the network or write files.')) - } - else if (ctx.hub && ctx.resources?.workbench?.session.started && !ctx.resources.workbench.session.closed) { - ctx.resources.workbench.showCreateSuccess(result.plan, lastProgress) + write(ctx, t(ctx, '此预览没有联网、写入文件或安装依赖。包管理器、脚本和项目要求在创建后判断。', 'This preview does not access the network, write files, or install dependencies. Package manager, scripts, and project requirements are determined after creation.')) } else { - write(ctx, `${t(ctx, '项目已创建', 'Project created')}: ${sanitize(result.plan.directory)}`) - write(ctx, `cd ${displayCommand([result.plan.directory]).slice(4)}\nnpm install`) + // Creation has succeeded. A failed optional installation must never be reported as a failed scaffold. + createdDirectory = result.plan.directory + const { discoverProject } = await import('../project/context.js') + const project = await discoverProject(result.plan.directory, { packageManager: selectedOptions.packageManager }) + const { projectActions, executeProjectAction, actionDisplayCommand } = await import('../project/actions.js') + let installCode = 0 + let installation: string[] = [] + if (selectedOptions.install) { + const install = projectActions(project).find(action => action.kind === 'install') + if (!install) { + installCode = 1 + installation = [t(ctx, '项目已创建,但不能安装依赖。请先解决项目或包管理器声明。', 'Project created, but installation is unavailable. Resolve the project or package manager declaration first.')] + } + else { + const execute = () => executeProjectAction(install, { ...ctx.projectOperations, signal: controller.signal }) + const notice = `${install.cwd}\n${actionDisplayCommand(install)}` + const outcome = workbench ? await workbench.external(execute, notice, () => controller.abort()) : await execute() + // A locally cancelled follow-up leaves a usable, successfully created project in the hub. + // Standalone commands and global interrupts still report their actual exit status. + const returnToProject = outcome.status === 'cancelled' && ctx.hub && workbench?.session.started && !workbench.session.closed && !workbench.abortSignal.aborted + installCode = ctx.cancelledCode || interruptedCode?.() || (returnToProject ? 0 : outcome.code) + installation = [outcome.status === 'success' + ? t(ctx, '依赖安装完成。', 'Dependencies installed.') + : t(ctx, '项目已创建;安装未完成,生成文件已保留,可重新选择安装。', 'Project created; installation did not finish. Generated files are preserved; you can retry installation.'), ...issueLines(ctx, outcome.errors)] + } + } + const nextSteps = [ + ...projectLines(ctx, project), + ...installation, + ...(!selectedOptions.install ? projectActions(project).filter(action => action.kind === 'install').map(action => `${action.cwd}\n${actionDisplayCommand(action)}`) : []), + `weapp doctor ${displayCommand([project.projectRoot]).slice(4)}`, + ] + if (ctx.hub && workbench?.session.started && !workbench.session.closed) { + workbench.showCreateSuccess(result.plan, lastProgress, project, nextSteps) + } + else { + write(ctx, `${t(ctx, '项目已创建', 'Project created')}: ${sanitize(result.plan.directory)}`) + write(ctx, nextSteps.join('\n')) + } + return installCode } return 0 } @@ -312,7 +339,9 @@ export async function runCreate(ctx: CliContext, args: string[], options: CliOpt if (error instanceof Error && error.name === 'AbortError') { return ctx.cancelledCode || interruptedCode?.() || (locallyCancelled && !ctx.cancelled ? 0 : 130) } - writeError(ctx, sanitize(error instanceof CreateError ? t(ctx, error.zh, error.en) : t(ctx, '无法创建项目,请检查目录权限。', 'Unable to create the project. Check directory permissions.'))) + writeError(ctx, sanitize(createdDirectory + ? t(ctx, `项目已创建于 ${createdDirectory},后续操作未完成。请运行 doctor 检查后重试;生成文件已保留。`, `Project created at ${createdDirectory}, but the follow-up action did not finish. Run doctor before retrying; generated files were retained.`) + : error instanceof CreateError ? t(ctx, error.zh, error.en) : t(ctx, '无法创建项目,请检查目录权限。', 'Unable to create the project. Check directory permissions.'))) return 1 } finally { diff --git a/packages/weapp/src/commands/create/plan.ts b/packages/weapp/src/commands/create/plan.ts index 3ffc501..75ffb16 100644 --- a/packages/weapp/src/commands/create/plan.ts +++ b/packages/weapp/src/commands/create/plan.ts @@ -97,6 +97,9 @@ function isTemplate(template: string): template is Template { return templates.includes(template) } export function validateSelection(route: string, options: CliOptions): { route: CreateRoute, template: Template } { + if (options.packageManager !== undefined && !['npm', 'pnpm', 'yarn', 'bun'].includes(options.packageManager)) { + throw new CreateError('--package-manager 必须为 npm、pnpm、yarn 或 bun。', '--package-manager must be npm, pnpm, yarn, or bun.') + } if (!isCreateRoute(route)) { throw new CreateError(`未知创建路线:${route}。可选:${Object.keys(CREATE_ADAPTERS).join(', ')}`, `Unknown creation route: ${route}. Choose ${Object.keys(CREATE_ADAPTERS).join(', ')}`) } diff --git a/packages/weapp/src/commands/doctor.ts b/packages/weapp/src/commands/doctor.ts index 78081a5..8f1b055 100644 --- a/packages/weapp/src/commands/doctor.ts +++ b/packages/weapp/src/commands/doctor.ts @@ -1,201 +1,250 @@ -import type { Buffer } from 'node:buffer' +import type { PackageManagerName, ProjectContext } from '../project/context.js' import type { CliContext, CliOptions } from '../types.js' -import { lstat, readFile } from 'node:fs/promises' import path from 'node:path' import process from 'node:process' -// eslint-disable-next-line e18e/ban-dependencies -- Preserve Windows-safe package manager probing on the Node 18 baseline. -import spawn from 'cross-spawn' +// eslint-disable-next-line e18e/ban-dependencies -- Compare declared package manager versions using npm semver rules. +import eq from 'semver/functions/eq.js' // eslint-disable-next-line e18e/ban-dependencies -- Validate npm engines with the complete semver grammar on Node 18. import satisfies from 'semver/functions/satisfies.js' +// eslint-disable-next-line e18e/ban-dependencies -- Do not mistake arbitrary dependency version strings for semver. +import valid from 'semver/functions/valid.js' // eslint-disable-next-line e18e/ban-dependencies -- Preserve npm-compatible engine range parsing on Node 18. import validRange from 'semver/ranges/valid.js' +import { installedPackageManagerVersion } from '../project/actions.js' +import { discoverProject, projectFileExists, readProjectJson } from '../project/context.js' import { sanitize, t, write, writeError } from '../shared.js' -const LOCKFILES: Readonly> = { 'pnpm-lock.yaml': 'pnpm', 'package-lock.json': 'npm', 'npm-shrinkwrap.json': 'npm', 'yarn.lock': 'yarn', 'bun.lock': 'bun', 'bun.lockb': 'bun' } -const FRAMEWORKS = ['weapp-vite', '@vue-mini/core', 'rezor', 'vite-plugin-taro', '@tarojs/taro', '@dcloudio/uni-app', 'weapp-tailwindcss'] -const CONFIGS = ['project.config.json', 'project.private.config.json', 'app.json', 'miniprogram/app.json', 'src/app.json', 'src/manifest.json', 'manifest.json', 'src/pages.json', 'pages.json'] -const VITE_CONFIGS = ['vite.config.js', 'vite.config.mjs', 'vite.config.ts', 'vite.config.mts'] +const FRAMEWORK_DOCS: Readonly> = { + 'weapp-vite': 'https://vite.weapp.dev/', + '@vue-mini/core': 'https://vuemini.org/', + 'rezor': 'https://github.com/rezorjs/rezor', + 'vite-plugin-taro': 'https://vpt.js.org/', + '@tarojs/taro': 'https://docs.taro.zone/', + '@dcloudio/uni-app': 'https://uniapp.dcloud.net.cn/', + 'weapp-tailwindcss': 'https://tw.weapp.dev/', +} + export type DiagnosticSeverity = 'ok' | 'warning' | 'error' | 'info' export interface DiagnosticCheck { id: string severity: DiagnosticSeverity message: string advice?: string + evidence?: string[] + suggestion?: { cwd: string, command: string, args: string[] } } export interface DiagnosticReport { + /** The originally requested directory, retained for existing JSON consumers. */ directory: string + requestedDirectory: string + projectRoot: string + context: ProjectContext ok: boolean checks: DiagnosticCheck[] } export interface DoctorOperations { nodeVersion?: string - packageManagerVersion?: (command: string) => Promise -} -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} -async function exists(filename: string): Promise { - try { - await lstat(filename) - return true - } - catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { - return false - } - throw error - } + packageManagerVersion?: (command: string, options?: { cwd: string }) => Promise + project?: ProjectContext } -async function readJson(filename: string): Promise { - const stats = await lstat(filename) - if (!stats.isFile() || stats.size > 1024 * 1024) { - throw new Error('invalid-json-file') +export function packageManagerVersion(command: string, { timeout = 3000, cwd = process.cwd() }: { timeout?: number, cwd?: string } = {}): Promise { + if (!['npm', 'pnpm', 'yarn', 'bun'].includes(command)) { + return Promise.resolve(null) } - return JSON.parse(await readFile(filename, 'utf8')) -} -export function packageManagerVersion(command: string, { timeout = 3000 }: { timeout?: number } = {}): Promise { - return new Promise((resolve) => { - const child = spawn(command, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, env: { ...process.env, COREPACK_ENABLE_NETWORK: '0', COREPACK_ENABLE_AUTO_PIN: '0' } }) - let output = '' - const timer = setTimeout(() => { - child.kill() - resolve(null) - }, timeout) - child.stdout?.on('data', (chunk: Buffer) => { - if (output.length < 1024) { - output += chunk.toString() - } - }) - child.stderr?.resume() - child.on('error', () => { - clearTimeout(timer) - resolve(null) - }) - child.on('close', (code) => { - clearTimeout(timer) - resolve(code === 0 && /^\d+\.\d+/.test(output.trim()) ? output.trim().split('\n')[0] : null) - }) - }) + return installedPackageManagerVersion(command as PackageManagerName, { cwd, probeTimeoutMs: timeout }) } export async function diagnose(directory: string | undefined, ctx: Pick, operations: DoctorOperations = {}): Promise { - const root = path.resolve(directory || '.') + const project = operations.project || await discoverProject(directory) + const root = project.projectRoot + const installRoot = project.workspace?.root || root const checks: DiagnosticCheck[] = [] - const add = (id: string, severity: DiagnosticSeverity, zh: string, en: string, zhAdvice?: string, enAdvice?: string): void => { - checks.push({ id, severity, message: t(ctx, zh, en), ...(zhAdvice && enAdvice ? { advice: t(ctx, zhAdvice, enAdvice) } : {}) }) + const add = (id: string, severity: DiagnosticSeverity, zh: string, en: string, zhAdvice?: string, enAdvice?: string, extra?: Pick): void => { + checks.push({ id, severity, message: t(ctx, zh, en), ...(zhAdvice && enAdvice ? { advice: t(ctx, zhAdvice, enAdvice) } : {}), ...extra }) } + const report = (): DiagnosticReport => ({ directory: project.requestedDirectory, requestedDirectory: project.requestedDirectory, projectRoot: root, context: project, ok: checks.every(check => check.severity !== 'error'), checks }) + const manifestEvidence = [path.join(root, 'package.json')] const nodeVersion = operations.nodeVersion || process.versions.node const nodeOk = satisfies(nodeVersion, '>=18') - add('node', nodeOk ? 'ok' : 'error', `Node ${nodeVersion}`, `Node ${nodeVersion}`, nodeOk ? undefined : '请安装 Node 18 或更新版本。', nodeOk ? undefined : 'Install Node 18 or newer.') - try { - if (!(await lstat(root)).isDirectory()) { - throw new Error('directory') - } + add('node', nodeOk ? 'ok' : 'error', `Node ${nodeVersion}`, `Node ${nodeVersion}`, nodeOk ? undefined : '请安装 Node 18 或更新版本。', nodeOk ? undefined : 'Install Node 18 or newer.', { evidence: [process.execPath] }) + for (const issue of [...project.issues, ...project.packageManager.conflicts]) { + const evidence = issue.evidence || (issue.id === 'directory' ? [project.requestedDirectory] : issue.id.startsWith('native') ? project.configs.map(config => path.join(root, config.path)) : [...manifestEvidence, ...(project.workspace && project.workspace.root !== root ? [path.join(project.workspace.root, 'package.json')] : [])]) + add(issue.id, issue.severity, issue.messageZh || issue.message, issue.message, issue.adviceZh || issue.advice, issue.advice, { evidence }) } - catch { - add('directory', 'error', `目录不存在或不可读取:${root}`, `Directory does not exist or cannot be read: ${root}`, '提供一个可读取的项目目录。', 'Provide a readable project directory.') - return { directory: root, ok: false, checks } + if (project.issues.some(issue => issue.id === 'directory')) { + return report() } - let manifest: Record | undefined - try { - const parsed = await readJson(path.join(root, 'package.json')) - if (!isRecord(parsed)) { - throw new Error('manifest') - } - manifest = parsed - add('manifest', 'ok', `项目:${typeof manifest.name === 'string' ? manifest.name : '(未命名)'}`, `Project: ${typeof manifest.name === 'string' ? manifest.name : '(unnamed)'}`) - } - catch (error) { - const missing = error instanceof Error && 'code' in error && error.code === 'ENOENT' - add('manifest', 'error', missing ? '缺少 package.json。' : 'package.json 无效或不可读取。', missing ? 'package.json is missing.' : 'package.json is invalid or unreadable.', '请在项目根目录运行 doctor,并检查 JSON 格式。', 'Run doctor in the project root and check the JSON format.') - } - if (isRecord(manifest?.engines) && manifest.engines.node) { - const requirement = manifest.engines.node - const valid = typeof requirement === 'string' && validRange(requirement) - const compatible = valid && satisfies(nodeVersion, requirement) - add('project-node', compatible ? 'ok' : 'error', `项目 Node 要求:${String(requirement)}`, `Project Node requirement: ${String(requirement)}`, compatible ? undefined : '使用满足 engines.node 的 Node 版本,并确保它是有效的 semver 范围。', compatible ? undefined : 'Use a Node version satisfying engines.node and ensure it is a valid semver range.') - } - const locks: string[] = [] - for (const filename of Object.keys(LOCKFILES)) { - if (await exists(path.join(root, filename))) { - locks.push(filename) - } + if (project.manifest) { + const name = project.manifest.name || t(ctx, '(未命名)', '(unnamed)') + add('manifest', 'ok', `项目:${name}`, `Project: ${name}`, undefined, undefined, { evidence: [path.join(root, 'package.json')] }) + } + else if (project.kind === 'native') { + add('manifest', 'info', '原生微信小程序没有 package.json;无需检查 npm 依赖或运行脚本。', 'This native WeChat project has no package.json; dependency and script checks do not apply.') } - const managers = [...new Set(locks.map(lock => LOCKFILES[lock]))] - if (!locks.length) { - add('lockfiles', 'warning', '未发现锁文件。', 'No lockfile was found.', '安装依赖并提交对应包管理器的锁文件。', 'Install dependencies and commit the lockfile for your package manager.') + else if (project.kind === 'unknown') { + add('manifest', 'error', '没有找到 package.json 或原生小程序配置。', 'No package.json or native mini-program configuration was found.', '提供一个项目目录,或先创建项目。', 'Provide a project directory or create a project first.', { evidence: [project.requestedDirectory] }) } - else { - add('lockfiles', managers.length > 1 ? 'warning' : 'ok', `锁文件:${locks.join(', ')}`, `Lockfiles: ${locks.join(', ')}`, managers.length > 1 ? '保留团队实际使用的包管理器锁文件,避免依赖版本漂移。' : undefined, managers.length > 1 ? 'Keep the lockfile for the package manager your team uses to avoid dependency drift.' : undefined) + if (project.workspace) { + add('workspace', 'ok', `已验证 workspace:${project.workspace.root}(${project.workspace.memberPath})`, `Verified workspace: ${project.workspace.root} (${project.workspace.memberPath})`, undefined, undefined, { evidence: project.workspace.evidence.map(file => path.join(project.workspace!.root, file)) }) } - let selected = managers[0] || 'npm' - if (manifest?.packageManager) { - const match = typeof manifest.packageManager === 'string' ? /^(npm|pnpm|yarn|bun)@.+$/.exec(manifest.packageManager) : null - if (match) { - selected = match[1] - if (managers.length && !managers.includes(selected)) { - add('package-manager-lock', 'warning', `packageManager 指定 ${selected},但锁文件不匹配。`, `packageManager specifies ${selected}, but its lockfile is missing.`, '按 packageManager 安装依赖并更新锁文件。', 'Install with the declared packageManager and update the lockfile.') + const engineSources = [{ id: 'project', labelZh: '项目', labelEn: 'Project', manifest: project.manifest, filename: path.join(root, 'package.json') }, ...(project.workspace && project.workspace.root !== root ? [{ id: 'workspace', labelZh: 'Workspace', labelEn: 'Workspace', manifest: project.workspace.manifest, filename: path.join(project.workspace.root, 'package.json') }] : [])] + for (const source of engineSources) { + const requirement = source.manifest?.engines?.node + if (requirement === undefined) { + continue + } + const range = typeof requirement === 'string' && requirement.trim() ? validRange(requirement) : null + const compatible = range && satisfies(nodeVersion, range) + add(`${source.id}-node`, compatible ? 'ok' : 'error', `${source.labelZh} Node 要求:${String(requirement)}`, `${source.labelEn} Node requirement: ${String(requirement)}`, compatible ? undefined : '使用满足 engines.node 的 Node 版本,并确保它是有效的 semver 范围。', compatible ? undefined : 'Use a Node version satisfying engines.node and ensure it is a valid semver range.', { evidence: [`${source.filename}#engines.node`] }) + } + const selected = project.packageManager.name + const managers = [...new Set(project.lockfiles.map(lock => lock.manager))] + if (project.manifest) { + if (!project.lockfiles.length) { + add('lockfiles', 'warning', '未发现锁文件。', 'No lockfile was found.', '安装依赖并提交对应包管理器的锁文件。', 'Install dependencies and commit the lockfile for your package manager.', { evidence: [installRoot] }) + } + else { + const locks = project.lockfiles.map(lock => path.basename(lock.path)).join(', ') + add('lockfiles', managers.length > 1 ? 'warning' : 'ok', `锁文件:${locks}`, `Lockfiles: ${locks}`, managers.length > 1 ? '明确团队使用的包管理器,并检查其他锁文件是否仍需要保留。' : undefined, managers.length > 1 ? 'Choose the package manager your team uses and review whether the other lockfiles are still needed.' : undefined, { evidence: project.lockfiles.map(lock => lock.path) }) + } + if (selected && managers.length && !managers.includes(selected)) { + add('package-manager-lock', 'warning', `选择了 ${selected},但锁文件不匹配。`, `${selected} was selected, but its lockfile is missing.`, '检查包管理器声明及现有锁文件;doctor 不修改它们。', 'Review the package manager declaration and existing lockfiles; doctor does not change them.', { evidence: project.lockfiles.map(lock => lock.path) }) + } + if (selected) { + let version: string | null + try { + version = await (operations.packageManagerVersion || packageManagerVersion)(selected, { cwd: installRoot }) + } + catch { + version = null + } + const knownVersion = typeof version === 'string' && valid(version) ? version : undefined + add(`package-manager:${selected}`, knownVersion ? 'ok' : 'error', knownVersion ? `${selected} ${version}` : `找不到 ${selected},版本无效或版本查询超时。`, knownVersion ? `${selected} ${version}` : `${selected} is unavailable, returned an invalid version, or its version query timed out.`, knownVersion ? undefined : `安装 ${selected},并确保它在 PATH 中。`, knownVersion ? undefined : `Install ${selected} and ensure it is on PATH.`, { evidence: [`${selected} --version (cwd: ${installRoot})`] }) + if (knownVersion && project.packageManager.version) { + const matches = eq(knownVersion, project.packageManager.version) + add('package-manager-version', matches ? 'ok' : 'warning', `声明 ${selected}@${project.packageManager.version},当前为 ${knownVersion}。`, `Declared ${selected}@${project.packageManager.version}; installed ${knownVersion}.`, matches ? undefined : '使用项目声明的包管理器版本;doctor 不下载或切换版本。', matches ? undefined : 'Use the declared package manager version; doctor does not download or switch versions.', { evidence: engineSources.map(source => `${source.filename}#packageManager`) }) + } + if (knownVersion) { + for (const source of engineSources) { + const requirement = source.manifest?.engines?.[selected] + if (requirement === undefined) { + continue + } + const range = typeof requirement === 'string' && requirement.trim() ? validRange(requirement) : null + const compatible = range && satisfies(knownVersion, range) + add(`${source.id}-engine:${selected}`, compatible ? 'ok' : 'error', `${source.labelZh} 要求 ${selected} ${String(requirement)},当前为 ${knownVersion}。`, `${source.labelEn} requires ${selected} ${String(requirement)}; installed ${knownVersion}.`, compatible ? undefined : '请使用满足包管理器 engines 声明的版本,并修正无效范围。', compatible ? undefined : 'Use a version satisfying the package manager engines declaration and correct invalid ranges.', { evidence: [`${source.filename}#engines.${selected}`] }) + } } } else { - add('package-manager-declaration', 'warning', 'packageManager 字段未使用受支持的包管理器格式。', 'The packageManager field does not use a supported package manager format.', '使用 npm、pnpm、yarn 或 bun 加精确版本。', 'Use npm, pnpm, yarn, or bun with an exact version.') + add('package-manager', 'warning', '包管理器尚未确定;未运行版本探测。', 'The package manager is unresolved; no version probe was run.', '先处理包管理器声明或锁文件冲突。', 'Resolve package manager declaration or lockfile conflicts first.', { evidence: [...manifestEvidence, ...project.lockfiles.map(lock => lock.path)] }) } } - const commands = [...new Set(['npm', selected])] - const versions = await Promise.all(commands.map(command => (operations.packageManagerVersion || packageManagerVersion)(command))) - for (let index = 0; index < commands.length; index++) { - const command = commands[index] - const version = versions[index] - add(`package-manager:${command}`, version ? 'ok' : command === selected ? 'error' : 'warning', version ? `${command} ${version}` : `找不到 ${command} 或版本查询超时。`, version ? `${command} ${version}` : `${command} is unavailable or its version query timed out.`, version ? undefined : `安装 ${command},并确保它在 PATH 中。`, version ? undefined : `Install ${command} and ensure it is on PATH.`) + add('frameworks', project.frameworks.length ? 'ok' : 'info', project.frameworks.length ? `识别到:${project.frameworks.join(', ')}` : '没有识别到已知的小程序框架依赖。', project.frameworks.length ? `Detected: ${project.frameworks.join(', ')}` : 'No known mini-program framework dependency was detected.') + for (const framework of project.frameworks) { + add(`framework-docs:${framework}`, 'info', `${framework} 官方文档:${FRAMEWORK_DOCS[framework]}`, `${framework} official documentation: ${FRAMEWORK_DOCS[framework]}`, '依赖存在不代表已完成框架接入;请按对应框架文档检查配置。', 'A declared dependency does not prove integration is complete; follow this framework’s configuration guide.', { evidence: manifestEvidence }) + } + if (project.frameworks.includes('weapp-vite') && !project.configs.some(config => config.kind === 'vite')) { + add('weapp-vite-config', 'warning', '未找到常见路径的 Vite 配置;weapp-vite 的配置位置尚未确认。', 'No conventional Vite config was found; the weapp-vite configuration location is unconfirmed.', '检查已有脚本是否使用 --config 指向其他位置,并按 https://vite.weapp.dev/ 核对接入;doctor 不执行配置或自动生成文件。', 'Check whether existing scripts use --config for another location and follow https://vite.weapp.dev/ to verify integration; doctor neither executes configs nor generates files.', { evidence: [root, ...manifestEvidence] }) } - const dependencies = { ...(isRecord(manifest?.dependencies) ? manifest.dependencies : {}), ...(isRecord(manifest?.devDependencies) ? manifest.devDependencies : {}) } + const dependencies = { ...project.manifest?.dependencies, ...project.manifest?.devDependencies, ...project.manifest?.optionalDependencies } const declared = Object.keys(dependencies).filter(name => /^(?:@[\w.-]+\/)?[\w.-]+$/.test(name) && name.split('/').every(segment => segment !== '.' && segment !== '..')) - const frameworks = FRAMEWORKS.filter(name => name in dependencies) - add('frameworks', frameworks.length ? 'ok' : 'info', frameworks.length ? `识别到:${frameworks.join(', ')}` : '没有识别到已知的小程序框架依赖。', frameworks.length ? `Detected: ${frameworks.join(', ')}` : 'No known mini-program framework dependency was detected.') + const lookupRoots: string[] = [] + let lookupRoot = root + while (true) { + lookupRoots.push(lookupRoot) + if (lookupRoot === installRoot || path.dirname(lookupRoot) === lookupRoot) { + break + } + lookupRoot = path.dirname(lookupRoot) + } if (declared.length) { - if (!(await exists(path.join(root, 'node_modules')))) { - add('dependencies', 'warning', '尚未安装依赖:没有 node_modules。', 'Dependencies have not been installed: node_modules is missing.', `运行 ${selected} install。`, `Run ${selected} install.`) + let pnp = false + for (const candidate of lookupRoots) { + if (await projectFileExists(path.join(candidate, '.pnp.cjs')) || await projectFileExists(path.join(candidate, '.pnp.js'))) { + pnp = true + break + } + } + if (pnp) { + add('dependencies', 'info', '检测到 Yarn PnP;未执行其加载器,依赖版本尚未判定。', 'Yarn PnP was detected; its loader was not executed and dependency versions were not evaluated.') } else { const missing: string[] = [] + const optionalMissing: string[] = [] + const mismatched: string[] = [] + const unverified: string[] = [] + const found: string[] = [] for (const name of declared) { - if (!(await exists(path.join(root, 'node_modules', ...name.split('/'), 'package.json')))) { - missing.push(name) + const range = validRange(dependencies[name]) + // Protocols, aliases, tags and catalogs need package-manager-specific resolution. + if (!range) { + unverified.push(`${name} (${dependencies[name]})`) + continue + } + let installed: Record | undefined + let unreadable = false + for (const candidate of lookupRoots) { + const filename = path.join(candidate, 'node_modules', ...name.split('/'), 'package.json') + if (!(await projectFileExists(filename))) { + continue + } + try { + installed = await readProjectJson(filename) + } + catch { + unreadable = true + } + break + } + if (!installed) { + if (unreadable) { + unverified.push(name) + } + else if (Object.hasOwn(project.manifest?.optionalDependencies || {}, name)) { + optionalMissing.push(name) + } + else { + missing.push(name) + } + continue + } + found.push(name) + if (typeof installed.version !== 'string' || !valid(installed.version)) { + unverified.push(name) + } + else if (!satisfies(installed.version, range)) { + mismatched.push(`${name}@${installed.version} (${dependencies[name]})`) } } - add('dependencies', missing.length ? 'warning' : 'ok', missing.length ? `缺少已声明依赖:${missing.join(', ')}` : `已找到 ${declared.length} 个声明依赖。`, missing.length ? `Missing declared dependencies: ${missing.join(', ')}` : `Found ${declared.length} declared dependencies.`, missing.length ? `运行 ${selected} install。` : undefined, missing.length ? `Run ${selected} install.` : undefined) - } - } - const configs: string[] = [] - for (const config of CONFIGS) { - const filename = path.join(root, config) - if (!(await exists(filename))) { - continue - } - configs.push(config) - try { - const parsed = await readJson(filename) - if (!isRecord(parsed)) { - throw new Error('json-object') + const canSuggestInstall = selected && !project.packageManager.conflicts.length && !project.issues.some(issue => issue.severity === 'error') + const suggestion = canSuggestInstall ? { cwd: installRoot, command: selected, args: ['install'] } : undefined + const evidence = [...manifestEvidence, ...lookupRoots.map(directory => path.join(directory, 'node_modules'))] + const adviceZh = suggestion ? `在 ${installRoot} 运行 ${selected} install;仅缺少开发依赖也可能来自生产环境安装。` : '先解决包管理器选择或项目配置问题;仅缺少开发依赖也可能来自生产环境安装。' + const adviceEn = suggestion ? `Run ${selected} install in ${installRoot}; missing development dependencies may also reflect a production-only installation.` : 'Resolve package manager or project configuration issues first; missing development dependencies may also reflect a production-only installation.' + add('dependencies', missing.length ? 'warning' : 'ok', missing.length ? `缺少已声明依赖:${missing.join(', ')}` : `已找到 ${found.length} 个可检查的声明依赖。`, missing.length ? `Missing declared dependencies: ${missing.join(', ')}` : `Found ${found.length} declared dependencies with inspectable ranges.`, missing.length ? adviceZh : undefined, missing.length ? adviceEn : undefined, { evidence, ...(missing.length && suggestion ? { suggestion } : {}) }) + if (mismatched.length) { + add('dependency-versions', 'warning', `依赖版本不满足声明:${mismatched.join(', ')}`, `Installed dependency versions do not satisfy their ranges: ${mismatched.join(', ')}`, adviceZh, adviceEn, { evidence, ...(suggestion ? { suggestion } : {}) }) + } + if (unverified.length) { + add('dependency-versions-unverified', 'info', `以下依赖版本未判定:${unverified.join(', ')}`, `These dependency versions were not evaluated: ${unverified.join(', ')}`, '协议、别名、标签或不可读取版本需要对应包管理器检查;不能据此断言依赖缺失或兼容。', 'Protocols, aliases, tags, or unreadable versions require the package manager to inspect them; neither absence nor compatibility is implied.') + } + if (optionalMissing.length) { + add('optional-dependencies', 'info', `未安装可选依赖:${optionalMissing.join(', ')};可能不适用于当前平台。`, `Optional dependencies are absent: ${optionalMissing.join(', ')}; they may not apply to this platform.`) } - add(`config:${config}`, 'ok', `${config} JSON 有效。`, `${config} contains valid JSON.`) - } - catch { - add(`config:${config}`, 'warning', `${config} 不是可读取的标准 JSON。`, `${config} is not readable standard JSON.`, '检查文件语法;doctor 不执行配置或解析 JSONC/JSON5。', 'Check the syntax; doctor does not execute config files or parse JSONC/JSON5.') } } - const viteConfigs: string[] = [] - for (const filename of VITE_CONFIGS) { - if (await exists(path.join(root, filename))) { - viteConfigs.push(filename) - } + for (const config of project.configs.filter(config => config.kind === 'json')) { + const valid = config.status === 'valid' + add(`config:${config.path}`, valid ? 'ok' : 'warning', valid ? `${config.path} JSON 有效。` : `${config.path} 不是可读取的标准 JSON。`, valid ? `${config.path} contains valid JSON.` : `${config.path} is not readable standard JSON.`, valid ? undefined : '检查文件语法;doctor 不执行配置或解析 JSONC/JSON5。', valid ? undefined : 'Check the syntax; doctor does not execute config files or parse JSONC/JSON5.', { evidence: [path.join(root, config.path)] }) } + const viteConfigs = project.configs.filter(config => config.kind === 'vite').map(config => config.path) if (viteConfigs.length) { add('vite-config', 'info', `发现 ${viteConfigs.join(', ')};未执行配置。`, `Found ${viteConfigs.join(', ')}; config files were not executed.`) } - if (!configs.length && !viteConfigs.length) { + if (!project.configs.length) { add('mini-program-config', 'info', '没有在常见路径找到小程序配置。', 'No mini-program config was found in common locations.', '原生项目检查 app.json / project.config.json;工程项目检查框架配置路径。', 'For native projects check app.json / project.config.json; for framework projects check the framework config path.') } - return { directory: root, ok: checks.every(check => check.severity !== 'error'), checks } + return report() } export async function runDoctor(ctx: CliContext, args: string[], options: CliOptions): Promise { if (args.length > 1) { @@ -208,7 +257,10 @@ export async function runDoctor(ctx: CliContext, args: string[], options: CliOpt write(ctx, JSON.stringify(report, null, 2)) } else { - write(ctx, `${t(ctx, '检查目录', 'Checking')}: ${sanitize(report.directory)}`) + write(ctx, `${t(ctx, '检查目录', 'Checking')}: ${sanitize(report.requestedDirectory)}`) + if (report.projectRoot !== report.requestedDirectory) { + write(ctx, `${t(ctx, '识别的项目根目录', 'Detected project root')}: ${sanitize(report.projectRoot)}`) + } const marks = { ok: '✓', warning: '!', error: '×', info: '·' } for (const check of report.checks) { write(ctx, `${marks[check.severity]} ${sanitize(check.message)}${check.advice ? `\n ${sanitize(check.advice)}` : ''}`) diff --git a/packages/weapp/src/project/actions.ts b/packages/weapp/src/project/actions.ts new file mode 100644 index 0000000..8f68cd4 --- /dev/null +++ b/packages/weapp/src/project/actions.ts @@ -0,0 +1,197 @@ +import type { PackageManagerName, ProjectContext, ProjectIssue, ProjectManifest } from './context.js' +import type { ProjectProcessOptions } from './process.js' +import process from 'node:process' +// eslint-disable-next-line e18e/ban-dependencies -- Match complete npm engine ranges while retaining Node 18. +import satisfies from 'semver/functions/satisfies.js' +// eslint-disable-next-line e18e/ban-dependencies -- Validate package manager versions before executing project commands. +import valid from 'semver/functions/valid.js' +// eslint-disable-next-line e18e/ban-dependencies -- Reject malformed engine ranges rather than bypassing them. +import validRange from 'semver/ranges/valid.js' +import { projectProcessEnvironment, runProjectProcess } from './process.js' + +export interface ProjectAction { + id: string + kind: 'install' | 'script' + cwd: string + command: PackageManagerName + args: string[] + script?: string + context: ProjectContext +} +export interface ProjectActionOptions extends ProjectProcessOptions { + nodeVersion?: string + packageManagerVersion?: (command: PackageManagerName, options: ProjectProcessOptions & { cwd: string }) => Promise + refreshContext?: (previous: ProjectContext) => Promise + probeTimeoutMs?: number +} +export interface ProjectActionValidation { + valid: boolean + errors: ProjectIssue[] + nodeVersion: string + packageManagerVersion?: string +} +export interface ProjectActionResult { + status: 'success' | 'failed' | 'cancelled' + code: number + signal: NodeJS.Signals | null + errors: ProjectIssue[] +} + +function issue(id: string, message: string, messageZh: string, advice?: string, adviceZh?: string): ProjectIssue { + return { id, severity: 'error', message, messageZh, advice, adviceZh } +} + +function projectSnapshot(context: ProjectContext): string { + const stable = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(stable) + } + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => [key, stable(entry)])) + } + return value + } + return JSON.stringify(stable({ + root: context.projectRoot, + kind: context.kind, + manifest: context.manifest, + workspace: context.workspace, + packageManager: context.packageManager, + lockfiles: context.lockfiles, + errors: context.issues.filter(entry => entry.severity === 'error'), + })) +} + +async function refreshProjectContext(previous: ProjectContext): Promise { + const { discoverProject } = await import('./context.js') + return discoverProject(previous.requestedDirectory, { packageManager: previous.packageManager.source === 'explicit' ? previous.packageManager.name : undefined }) +} + +export function projectActions(context: ProjectContext): ProjectAction[] { + const command = context.packageManager.name + if (!context.manifest || !command || (context.kind !== 'package' && context.kind !== 'mixed')) { + return [] + } + return [ + { id: 'install', kind: 'install', cwd: context.workspace?.root || context.projectRoot, command, args: ['install'], context }, + ...Object.keys(context.manifest.scripts || {}).map(script => ({ id: `script:${script}`, kind: 'script' as const, cwd: context.projectRoot, command, args: ['run', script], script, context })), + ] +} + +/** This is presentation only; commands are always passed as an argv array. */ +export function actionDisplayCommand(action: ProjectAction): string { + return [action.command, ...action.args].map(value => /^[\w./:@=-]+$/.test(value) ? value : `'${value.replaceAll('\'', '\'\\\'\'')}'`).join(' ') +} + +export async function installedPackageManagerVersion(command: PackageManagerName, options: ProjectActionOptions & { cwd: string }): Promise { + const controller = new AbortController() + const abort = (): void => controller.abort() + options.signal?.addEventListener('abort', abort, { once: true }) + if (options.signal?.aborted) { + abort() + } + const timer = setTimeout(abort, options.probeTimeoutMs ?? 3000) + try { + const result = await runProjectProcess(command, ['--version'], options.cwd, { ...options, env: projectProcessEnvironment(options.env), signal: controller.signal, stdio: ['ignore', 'pipe', 'pipe'] }) + const version = result.stdout.trim() + return !result.cancelled && !result.failedToStart && result.code === 0 && valid(version) ? version : null + } + finally { + clearTimeout(timer) + options.signal?.removeEventListener('abort', abort) + } +} + +function validateEngine(manifest: ProjectManifest | undefined, engine: string, version: string, scope: string): ProjectIssue[] { + const requirement = manifest?.engines?.[engine] + if (requirement === undefined) { + return [] + } + if (typeof requirement !== 'string' || !requirement.trim() || !validRange(requirement) || !satisfies(version, requirement)) { + return [issue(`engine:${scope}:${engine}`, `${scope} requires ${engine} ${String(requirement)}; installed: ${version}.`, `${scope} 要求 ${engine} ${String(requirement)};当前版本:${version}。`, 'Use a compatible runtime and a valid engines range before retrying.', '请使用兼容的运行时并修正 engines 范围后重试。')] + } + return [] +} + +export async function validateProjectAction(action: ProjectAction, options: ProjectActionOptions = {}): Promise { + const nodeVersion = options.nodeVersion || process.versions.node + const context = action.context + const errors = [...context.issues.filter(entry => entry.severity === 'error'), ...context.packageManager.conflicts] + const expected = projectActions(context).find(candidate => candidate.id === action.id) + if (!expected || expected.cwd !== action.cwd || expected.command !== action.command || expected.kind !== action.kind || expected.script !== action.script || JSON.stringify(expected.args) !== JSON.stringify(action.args)) { + errors.push(issue('action:invalid', 'The action no longer matches the detected project.', '操作与检测到的项目不一致。')) + } + if (action.script !== undefined && (!action.script.trim() || action.script.startsWith('-') || action.script.includes('\0'))) { + errors.push(issue('script:invalid', 'This script name cannot be safely passed to the package manager.', '此脚本名无法安全传递给包管理器。')) + } + if (!valid(nodeVersion) || !satisfies(nodeVersion, '>=18')) { + errors.push(issue('engine:node', `Node 18 or newer is required; installed: ${nodeVersion}.`, `需要 Node 18 或更新版本;当前版本:${nodeVersion}。`)) + } + errors.push(...validateEngine(context.manifest, 'node', nodeVersion, 'package'), ...validateEngine(context.workspace?.manifest, 'node', nodeVersion, 'workspace')) + if (options.signal?.aborted || errors.length) { + return { valid: false, errors, nodeVersion } + } + let managerVersion: string | null + try { + managerVersion = await (options.packageManagerVersion || installedPackageManagerVersion)(action.command, { ...options, cwd: action.cwd }) + } + catch { + managerVersion = null + } + if (!managerVersion || !valid(managerVersion)) { + errors.push(issue('package-manager:unavailable', `Unable to read the installed ${action.command} version without downloading a package manager.`, `无法在不下载包管理器的情况下读取已安装的 ${action.command} 版本。`, 'Install the required package manager, then retry.', '请先安装要求的包管理器,再重试。')) + } + else { + const required = context.packageManager.version + if (required && (!valid(required) || !satisfies(managerVersion, required))) { + errors.push(issue('package-manager:version', `This project requires ${action.command}@${required}; installed: ${managerVersion}.`, `项目要求 ${action.command}@${required};当前版本:${managerVersion}。`)) + } + errors.push(...validateEngine(context.manifest, action.command, managerVersion, 'package'), ...validateEngine(context.workspace?.manifest, action.command, managerVersion, 'workspace')) + } + return { valid: !options.signal?.aborted && errors.length === 0, errors: [...new Map(errors.map(error => [error.id, error])).values()], nodeVersion, packageManagerVersion: managerVersion || undefined } +} + +export async function executeProjectAction(action: ProjectAction, options: ProjectActionOptions = {}): Promise { + const cancelled = (): ProjectActionResult => ({ status: 'cancelled', code: 130, signal: null, errors: [] }) + if (options.signal?.aborted) { + return cancelled() + } + const snapshot = projectSnapshot(action.context) + const validation = await validateProjectAction(action, options) + if (options.signal?.aborted) { + return cancelled() + } + if (!validation.valid) { + return { status: 'failed', code: 1, signal: null, errors: validation.errors } + } + try { + // Recheck after asynchronous version probing, immediately before launching the approved action. + const current = await (options.refreshContext || refreshProjectContext)(action.context) + if (options.signal?.aborted) { + return cancelled() + } + if (snapshot !== projectSnapshot(current)) { + return { status: 'failed', code: 1, signal: null, errors: [issue('action:changed', 'The project changed after this action was prepared. Review it and confirm again.', '项目在操作准备后发生变化,请重新检查并确认。')] } + } + } + catch { + if (options.signal?.aborted) { + return cancelled() + } + return { status: 'failed', code: 1, signal: null, errors: [issue('action:unreadable', 'The project could not be read again before execution.', '执行前无法重新读取项目。')] } + } + if (options.signal?.aborted) { + return cancelled() + } + const result = await runProjectProcess(action.command, action.args, action.cwd, { ...options, env: projectProcessEnvironment(options.env) }) + if (result.cleanupFailed) { + return { status: 'failed', code: 1, signal: result.signal, errors: [issue('process:cleanup', 'The package manager stopped, but its process tree could not be fully cleaned up.', '包管理器已停止,但未能完整清理其子进程树。')] } + } + if (result.cancelled) { + return { ...cancelled(), signal: result.signal } + } + if (result.failedToStart || result.code !== 0) { + return { status: 'failed', code: result.code || 1, signal: result.signal, errors: [issue('process:failed', result.failedToStart ? `Unable to start ${action.command}.` : `${action.command} did not exit successfully. Existing project files were retained.`, result.failedToStart ? `无法启动 ${action.command}。` : `${action.command} 未成功退出,已有项目文件已保留。`)] } + } + return { status: 'success', code: 0, signal: null, errors: [] } +} diff --git a/packages/weapp/src/project/context.ts b/packages/weapp/src/project/context.ts new file mode 100644 index 0000000..90498c2 --- /dev/null +++ b/packages/weapp/src/project/context.ts @@ -0,0 +1,373 @@ +import { lstat, readFile, realpath } from 'node:fs/promises' +import path from 'node:path' +import picomatch from 'picomatch' +// eslint-disable-next-line e18e/ban-dependencies -- Package manager declarations use the complete semver grammar on Node 18. +import valid from 'semver/functions/valid.js' +import { parseDocument } from 'yaml' + +export type PackageManagerName = 'npm' | 'pnpm' | 'yarn' | 'bun' +export interface ProjectIssue { + id: string + severity: 'warning' | 'error' | 'info' + message: string + messageZh?: string + advice?: string + adviceZh?: string + evidence?: string[] +} +export interface ProjectManifest extends Record { + name?: string + scripts?: Record + dependencies?: Record + devDependencies?: Record + optionalDependencies?: Record + engines?: Record + packageManager?: unknown +} +export interface ProjectConfig { + path: string + kind: 'json' | 'vite' + status: 'valid' | 'invalid' | 'detected' +} +export interface ProjectContext { + requestedDirectory: string + projectRoot: string + kind: 'package' | 'native' | 'mixed' | 'unknown' | 'invalid' + manifest?: ProjectManifest + workspace?: { root: string, memberPath: string, evidence: string[], manifest?: ProjectManifest } + packageManager: { + name?: PackageManagerName + version?: string + source: 'declaration' | 'explicit' | 'lockfile' | 'default' | 'unresolved' + conflicts: ProjectIssue[] + } + frameworks: string[] + configs: ProjectConfig[] + lockfiles: { path: string, manager: PackageManagerName }[] + issues: ProjectIssue[] +} +const LOCKFILES: Readonly> = { 'pnpm-lock.yaml': 'pnpm', 'package-lock.json': 'npm', 'npm-shrinkwrap.json': 'npm', 'yarn.lock': 'yarn', 'bun.lock': 'bun', 'bun.lockb': 'bun' } +const FRAMEWORKS = ['weapp-vite', '@vue-mini/core', 'rezor', 'vite-plugin-taro', '@tarojs/taro', '@dcloudio/uni-app', 'weapp-tailwindcss'] +const CONFIGS = ['project.config.json', 'project.private.config.json', 'app.json', 'miniprogram/app.json', 'src/app.json', 'src/manifest.json', 'manifest.json', 'src/pages.json', 'pages.json'] +const VITE_CONFIGS = ['vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.ts', 'vite.config.mts', 'vite.config.cts'] +const MAX_FILE_SIZE = 1024 * 1024 + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} +export async function projectFileExists(filename: string): Promise { + try { + await lstat(filename) + return true + } + catch (error) { + if (error instanceof Error && 'code' in error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) { + return false + } + throw error + } +} +async function readBoundedFile(filename: string): Promise { + const stats = await lstat(filename) + if (!stats.isFile() || stats.size > MAX_FILE_SIZE) { + throw new Error('Expected a regular file no larger than 1 MiB') + } + const source = await readFile(filename, 'utf8') + if (source.length > MAX_FILE_SIZE) { + throw new Error('File exceeds 1 MiB') + } + return source +} +export async function readProjectJson(filename: string): Promise> { + const value: unknown = JSON.parse(await readBoundedFile(filename)) + if (!isRecord(value)) { + throw new Error('Expected a JSON object') + } + return value +} +function issue(id: string, message: string, messageZh: string, severity: ProjectIssue['severity'] = 'warning', advice?: string, adviceZh?: string): ProjectIssue { + const defaults: Record = { + 'directory': ['Provide an existing, readable project directory.', '请提供存在且可读取的项目目录。'], + 'native-root': ['Keep miniprogramRoot within this project and check symbolic links.', '请将 miniprogramRoot 保持在当前项目内,并检查符号链接。'], + 'native-app': ['Check miniprogramRoot and run the existing build script if it points to generated output.', '请核对 miniprogramRoot;如果它指向构建产物,请运行已有构建脚本。'], + 'native-config': ['Check the native configuration JSON syntax and permissions.', '请检查原生配置文件的 JSON 语法与权限。'], + 'workspace': ['Correct the workspace declaration and confirm member patterns before installing dependencies.', '请修正 workspace 声明,并在安装依赖前确认成员匹配规则。'], + 'package-manager-declaration': ['Use an exact supported package manager version, for example pnpm@10.0.0.', '请使用受支持包管理器的精确版本,例如 pnpm@10.0.0。'], + 'package-manager-workspace': ['Align the project and workspace packageManager declarations before installing or running scripts.', '请先统一当前项目与 workspace 的 packageManager 声明,再安装或运行脚本。'], + 'package-manager-explicit': ['Choose the declared package manager or deliberately update the project declaration first.', '请选择已声明的包管理器,或先明确更新项目声明。'], + 'package-manager-lockfiles': ['Choose the intended manager in the workbench or pass --package-manager when creating a project.', '请在工作台中选择包管理器,或在创建项目时传入 --package-manager。'], + } + const fallback = id.startsWith('manifest:') ? ['Correct the reported package.json field and rerun the check.', '请修正指出的 package.json 字段后重新检查。'] : defaults[id] + advice ||= fallback?.[0] + adviceZh ||= fallback?.[1] + return { id, severity, message, messageZh, ...(advice ? { advice, adviceZh } : {}) } +} +function normalizeManifest(raw: Record, issues: ProjectIssue[]): ProjectManifest { + const manifest: ProjectManifest = { ...raw } + if (typeof raw.name !== 'string') { + delete manifest.name + } + for (const key of ['scripts', 'dependencies', 'devDependencies', 'optionalDependencies'] as const) { + if (raw[key] === undefined) { + continue + } + const value = raw[key] + if (!isRecord(value) || Object.values(value).some(entry => typeof entry !== 'string')) { + issues.push(issue(`manifest:${key}`, `package.json ${key} must contain string values.`, `package.json 的 ${key} 必须为字符串映射。`)) + delete manifest[key] + } + else { + manifest[key] = value as Record + } + } + if (raw.engines !== undefined && !isRecord(raw.engines)) { + issues.push(issue('manifest:engines', 'package.json engines must be an object.', 'package.json 的 engines 必须是对象。', 'error')) + delete manifest.engines + } + return manifest +} +function isWithin(root: string, directory: string): boolean { + const relative = path.relative(root, directory) + return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)) +} +async function inspectConfigs(root: string): Promise<{ configs: ProjectConfig[], native: boolean, issues: ProjectIssue[] }> { + const configs: ProjectConfig[] = [] + const issues: ProjectIssue[] = [] + let native = false + const candidates = [...CONFIGS] + for (const config of candidates) { + const filename = path.join(root, config) + if (!(await projectFileExists(filename))) { + continue + } + try { + if (!isWithin(root, await realpath(path.dirname(filename)))) { + throw new Error('Configuration directory points outside the project') + } + const parsed = await readProjectJson(filename) + configs.push({ path: config, kind: 'json', status: 'valid' }) + if (config === 'project.config.json') { + native = true + if (parsed.miniprogramRoot === undefined || typeof parsed.miniprogramRoot === 'string') { + const miniRoot = path.resolve(root, typeof parsed.miniprogramRoot === 'string' ? parsed.miniprogramRoot : '.') + let contained = isWithin(root, miniRoot) + if (contained && await projectFileExists(miniRoot)) { + contained = isWithin(root, await realpath(miniRoot)) + } + if (!contained) { + issues.push(issue('native-root', 'miniprogramRoot points outside the project; it was not inspected.', 'miniprogramRoot 指向项目外部,未检查该路径。')) + } + else { + const appPath = path.relative(root, path.join(miniRoot, 'app.json')).split(path.sep).join('/') + if (!candidates.includes(appPath)) { + candidates.push(appPath) + } + if (!(await projectFileExists(path.join(miniRoot, 'app.json')))) { + issues.push(issue('native-app', `No app.json was found at ${appPath}; the build output may not exist yet.`, `未找到 ${appPath},构建产物可能尚未生成。`)) + } + } + } + } + if (config.endsWith('app.json') && (Array.isArray(parsed.pages) || Array.isArray(parsed.subpackages) || Array.isArray(parsed.subPackages))) { + native = true + } + } + catch { + configs.push({ path: config, kind: 'json', status: 'invalid' }) + } + } + for (const config of VITE_CONFIGS) { + if (await projectFileExists(path.join(root, config))) { + configs.push({ path: config, kind: 'vite', status: 'detected' }) + } + } + return { configs, native, issues } +} +async function workspacePatterns(root: string, raw?: Record): Promise<{ patterns: string[], evidence: string[] } | undefined> { + let value: unknown + let evidence: string[] + if (await projectFileExists(path.join(root, 'pnpm-workspace.yaml'))) { + const document = parseDocument(await readBoundedFile(path.join(root, 'pnpm-workspace.yaml')), { strict: true, uniqueKeys: true }) + if (document.errors.length) { + throw new Error('Invalid pnpm-workspace.yaml') + } + const parsed: unknown = document.toJS({ maxAliasCount: 20 }) + if (!isRecord(parsed)) { + throw new Error('Invalid pnpm workspace object') + } + value = parsed.packages + // A pnpm workspace may declare only catalogs/settings and use the default all-packages pattern. + if (value === undefined) { + value = ['**'] + } + evidence = ['pnpm-workspace.yaml'] + } + else if (raw?.workspaces !== undefined) { + value = isRecord(raw.workspaces) ? raw.workspaces.packages : raw.workspaces + evidence = ['package.json#workspaces'] + } + else { + return undefined + } + if (!Array.isArray(value) || value.length > 1000 || value.some(pattern => typeof pattern !== 'string' || !pattern || pattern.length > 1024 || pattern.includes('\0'))) { + throw new Error('Invalid workspace patterns') + } + return { patterns: value as string[], evidence } +} +function workspaceIncludes(patterns: string[], member: string): boolean { + const positive = patterns.filter(pattern => !pattern.startsWith('!')) + const negative = patterns.filter(pattern => pattern.startsWith('!')).map(pattern => pattern.slice(1)) + const matches = (patterns: string[]): boolean => patterns.some(pattern => picomatch(pattern.replace(/^\.\//, '').replace(/\/$/, ''), { dot: true, nonegate: true })(member)) + return matches(positive) && !matches(negative) +} +function declaration(manifest: ProjectManifest | undefined, label: string, conflicts: ProjectIssue[]): { name: PackageManagerName, version: string } | undefined { + const value = manifest?.packageManager + if (value === undefined) { + return undefined + } + const match = typeof value === 'string' ? /^(npm|pnpm|yarn|bun)@(.+)$/.exec(value) : null + if (!match || !valid(match[2]) || match[2].startsWith('v') || match[2].startsWith('=')) { + conflicts.push(issue('package-manager-declaration', `${label} packageManager must name npm, pnpm, yarn, or bun with an exact version.`, `${label} 的 packageManager 必须指定 npm、pnpm、yarn 或 bun 和精确版本。`, 'error')) + return undefined + } + return { name: match[1] as PackageManagerName, version: match[2] } +} +function selectPackageManager(manifest: ProjectManifest | undefined, workspaceManifest: ProjectManifest | undefined, locks: ProjectContext['lockfiles'], explicit?: PackageManagerName): ProjectContext['packageManager'] { + const conflicts: ProjectIssue[] = [] + const local = declaration(manifest, 'Project', conflicts) + const root = workspaceManifest ? declaration(workspaceManifest, 'Workspace', conflicts) : undefined + if (local && root && (local.name !== root.name || local.version !== root.version)) { + conflicts.push(issue('package-manager-workspace', 'Project and workspace packageManager declarations conflict.', '当前项目与 workspace 的 packageManager 声明冲突。', 'error')) + } + const declared = root || local + if (declared && explicit && explicit !== declared.name) { + conflicts.push(issue('package-manager-explicit', `The explicit ${explicit} selection conflicts with the declared ${declared.name}.`, `显式选择的 ${explicit} 与声明的 ${declared.name} 冲突。`, 'error')) + } + if (declared) { + return { ...declared, source: 'declaration', conflicts } + } + if (conflicts.length) { + return { source: 'unresolved', conflicts } + } + if (explicit) { + return { name: explicit, source: 'explicit', conflicts } + } + const managers = [...new Set(locks.map(lock => lock.manager))] + if (managers.length > 1) { + conflicts.push(issue('package-manager-lockfiles', 'Multiple package managers have lockfiles; choose one explicitly before installing or running scripts.', '存在多种包管理器锁文件;安装或运行脚本前请明确选择包管理器。')) + return { source: 'unresolved', conflicts } + } + return { name: managers[0] || 'npm', source: managers.length ? 'lockfile' : 'default', conflicts } +} + +/** Read a bounded project snapshot without executing configuration, package managers, or hooks. */ +export async function discoverProject(directory?: string, options: { packageManager?: PackageManagerName } = {}): Promise { + const requestedDirectory = path.resolve(directory || '.') + const context: ProjectContext = { requestedDirectory, projectRoot: requestedDirectory, kind: 'unknown', packageManager: { source: 'unresolved', conflicts: [] }, frameworks: [], configs: [], lockfiles: [], issues: [] } + let current: string + try { + current = await realpath(requestedDirectory) + if (!(await lstat(current)).isDirectory()) { + throw new Error('Expected a directory') + } + } + catch { + context.kind = 'invalid' + context.issues.push(issue('directory', `Directory does not exist or cannot be read: ${requestedDirectory}`, `目录不存在或不可读取:${requestedDirectory}`, 'error')) + return context + } + const ancestors: string[] = [] + while (true) { + ancestors.push(current) + if (await projectFileExists(path.join(current, '.git')) || path.dirname(current) === current) { + break + } + current = path.dirname(current) + } + let projectIndex = -1 + let selectedProject: { candidate: string, index: number, hasManifest: boolean, inspected: Awaited> } | undefined + for (const [index, candidate] of ancestors.entries()) { + const hasManifest = await projectFileExists(path.join(candidate, 'package.json')) + const hasProjectConfig = await projectFileExists(path.join(candidate, 'project.config.json')) + const hasApp = await projectFileExists(path.join(candidate, 'app.json')) + if (!hasManifest && !hasProjectConfig && !hasApp) { + continue + } + const inspected = await inspectConfigs(candidate) + // app.json can be a source/build directory inside a package or native project. + // An enclosing root must actually identify this app before it can absorb it. + if (!hasManifest && !hasProjectConfig) { + if (!selectedProject && (inspected.native || inspected.configs.some(config => config.path === 'app.json' && config.status === 'invalid'))) { + selectedProject = { candidate, index, hasManifest, inspected } + } + continue + } + if (selectedProject) { + const appPath = path.relative(candidate, path.join(selectedProject.candidate, 'app.json')).split(path.sep).join('/') + const ownsApp = inspected.configs.some(config => config.kind === 'json' && config.path === appPath) + if (!ownsApp) { + break + } + } + selectedProject = { candidate, index, hasManifest, inspected } + break + } + if (!selectedProject) { + return context + } + const { candidate, index, hasManifest, inspected } = selectedProject + context.projectRoot = candidate + context.configs = inspected.configs + context.issues.push(...inspected.issues) + projectIndex = index + if (hasManifest) { + try { + context.manifest = normalizeManifest(await readProjectJson(path.join(candidate, 'package.json')), context.issues) + context.kind = inspected.native ? 'mixed' : 'package' + } + catch { + context.kind = 'invalid' + context.issues.push(issue('manifest', 'package.json is invalid or unreadable.', 'package.json 无效或不可读取。', 'error', 'Check the JSON format and file permissions.', '请检查 JSON 格式和文件权限。')) + } + } + else { + context.kind = inspected.native ? 'native' : 'invalid' + if (context.kind === 'invalid') { + context.issues.push(issue('native-config', 'Native project configuration is invalid or unreadable.', '原生项目配置无效或不可读取。', 'error')) + } + } + let workspaceManifest: ProjectManifest | undefined + if (context.manifest) { + for (const candidate of ancestors.slice(projectIndex)) { + try { + const raw = candidate === context.projectRoot ? context.manifest : await projectFileExists(path.join(candidate, 'package.json')) ? await readProjectJson(path.join(candidate, 'package.json')) : undefined + const workspace = await workspacePatterns(candidate, raw) + if (!workspace) { + continue + } + const memberPath = path.relative(candidate, context.projectRoot).split(path.sep).join('/') || '.' + if (isWithin(await realpath(candidate), await realpath(context.projectRoot)) && (memberPath === '.' || workspaceIncludes(workspace.patterns, memberPath))) { + context.workspace = { root: candidate, memberPath, evidence: workspace.evidence, manifest: raw ? normalizeManifest(raw, context.issues) : undefined } + if (candidate !== context.projectRoot && raw) { + workspaceManifest = context.workspace.manifest + } + break + } + } + catch { + context.issues.push({ ...issue('workspace', `Cannot validate workspace declarations at ${candidate}; its settings were not inherited.`, `无法验证 ${candidate} 的 workspace 声明,未继承其设置。`, 'error'), evidence: [path.join(candidate, 'package.json'), path.join(candidate, 'pnpm-workspace.yaml')] }) + break + } + } + } + const installRoot = context.workspace?.root || context.projectRoot + for (const [filename, manager] of Object.entries(LOCKFILES)) { + if (await projectFileExists(path.join(installRoot, filename))) { + context.lockfiles.push({ path: path.join(installRoot, filename), manager }) + } + } + context.packageManager = selectPackageManager(context.manifest, workspaceManifest, context.lockfiles, options.packageManager) + const dependencies = { ...context.manifest?.dependencies, ...context.manifest?.devDependencies, ...context.manifest?.optionalDependencies } + context.frameworks = FRAMEWORKS.filter(name => Object.hasOwn(dependencies, name)) + return context +} diff --git a/packages/weapp/src/project/presentation.ts b/packages/weapp/src/project/presentation.ts new file mode 100644 index 0000000..f3c582c --- /dev/null +++ b/packages/weapp/src/project/presentation.ts @@ -0,0 +1,24 @@ +import type { CliContext } from '../types.js' +import type { ProjectContext, ProjectIssue } from './context.js' +import { sanitize, t } from '../shared.js' + +export function issueLines(ctx: Pick, issues: ProjectIssue[]): string[] { + return issues.flatMap(issue => [ + sanitize(ctx.lang === 'en' ? issue.message : issue.messageZh || issue.message), + ...(issue.advice ? [sanitize(ctx.lang === 'en' ? issue.advice : issue.adviceZh || issue.advice)] : []), + ]) +} + +/** A read-only summary shared by creation results and the existing-project home. */ +export function projectLines(ctx: Pick, project: ProjectContext): string[] { + const manager = project.packageManager + return [ + `${t(ctx, '项目目录', 'Project directory')}: ${sanitize(project.projectRoot)}`, + ...(project.manifest?.name ? [`${t(ctx, '名称', 'Name')}: ${sanitize(project.manifest.name)}`] : []), + ...(project.frameworks.length ? [`${t(ctx, '框架', 'Frameworks')}: ${project.frameworks.join(', ')}`] : []), + ...(project.workspace ? [`Workspace: ${sanitize(project.workspace.root)} (${sanitize(project.workspace.memberPath)})`] : []), + ...(project.manifest ? [`${t(ctx, '包管理器', 'Package manager')}: ${manager.name ? `${manager.name}${manager.version ? `@${manager.version}` : ''} (${manager.source})` : t(ctx, '需要明确选择', 'Explicit selection required')}`] : []), + ...issueLines(ctx, [...project.issues, ...manager.conflicts]), + ...Object.entries(project.manifest?.scripts || {}).map(([name, script]) => `${sanitize(name)}: ${sanitize(script)}`), + ] +} diff --git a/packages/weapp/src/project/process.ts b/packages/weapp/src/project/process.ts new file mode 100644 index 0000000..eda5452 --- /dev/null +++ b/packages/weapp/src/project/process.ts @@ -0,0 +1,161 @@ +import type { ChildProcess, SpawnOptions, StdioOptions } from 'node:child_process' +import process from 'node:process' +// eslint-disable-next-line e18e/ban-dependencies -- Preserve Windows-safe argv execution on Node 18. +import spawn from 'cross-spawn' + +export type ProjectSpawn = (command: string, args: string[], options: SpawnOptions) => ChildProcess +export interface ProjectProcessOptions { + signal?: AbortSignal + env?: NodeJS.ProcessEnv + stdio?: StdioOptions + spawn?: ProjectSpawn + platform?: NodeJS.Platform + kill?: (pid: number, signal: NodeJS.Signals | number) => boolean + terminateGraceMs?: number +} +export interface ProjectProcessResult { + code: number | null + signal: NodeJS.Signals | null + cancelled: boolean + failedToStart: boolean + cleanupFailed: boolean + stdout: string +} + +/** Query and execution must use the installed manager, without fetching another binary. */ +export function projectProcessEnvironment(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + return { + ...env, + COREPACK_ENABLE_NETWORK: '0', + COREPACK_ENABLE_AUTO_PIN: '0', + COREPACK_ENABLE_DOWNLOAD_PROMPT: '0', + COREPACK_DEFAULT_TO_LATEST: '0', + npm_config_manage_package_manager_versions: 'false', + npm_config_update_notifier: 'false', + } +} + +function missingProcess(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ESRCH' +} + +async function terminateTree(child: ChildProcess, options: ProjectProcessOptions): Promise { + if (!child.pid) { + return + } + const platform = options.platform || process.platform + const kill = options.kill || process.kill + if (platform === 'win32') { + // Killing cmd/npm alone leaves lifecycle scripts alive on Windows. + await new Promise((resolve, reject) => { + const taskkill = (options.spawn || spawn)('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true, shell: false }) + let failure: Error | undefined + taskkill.once('error', (error) => { + failure = error + }) + taskkill.once('close', (code) => { + if (!failure && code === 0) { + resolve() + return + } + try { + kill(child.pid!, 0) + reject(failure || new Error('Unable to terminate the package manager process tree')) + } + catch (error) { + if (missingProcess(error)) { + resolve() + } + else { + reject(error) + } + } + }) + }) + return + } + const group = -child.pid + const send = (signal: NodeJS.Signals | number): boolean => { + try { + kill(group, signal) + return true + } + catch (error) { + if (missingProcess(error)) { + return false + } + throw error + } + } + if (!send('SIGTERM')) { + return + } + const deadline = Date.now() + (options.terminateGraceMs ?? 1000) + while (send(0) && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 20)) + } + // The parent may already be closed while a lifecycle script ignores SIGTERM. + send('SIGKILL') +} + +/** Resolve only after close and cancellation cleanup, before the terminal is restored. */ +export async function runProjectProcess(command: string, args: string[], cwd: string, options: ProjectProcessOptions = {}): Promise { + if (options.signal?.aborted) { + return { code: null, signal: null, cancelled: true, failedToStart: false, cleanupFailed: false, stdout: '' } + } + let child: ChildProcess + try { + child = (options.spawn || spawn)(command, args, { + cwd, + stdio: options.stdio ?? 'inherit', + env: options.env ?? process.env, + shell: false, + windowsHide: true, + detached: (options.platform || process.platform) !== 'win32', + }) + } + catch { + return { code: null, signal: null, cancelled: false, failedToStart: true, cleanupFailed: false, stdout: '' } + } + let failedToStart = false + let cleanupFailed = false + let cancelled = false + let stdout = '' + let cleanup: Promise | undefined + const abort = (): void => { + cancelled = true + cleanup ??= terminateTree(child, options).catch(() => { + cleanupFailed = true + try { + child.kill('SIGKILL') + } + catch { + // The structured result reports cleanup failure; close still owns terminal release. + } + }) + } + const closed = new Promise<{ code: number | null, signal: NodeJS.Signals | null }>((resolve) => { + child.once('error', () => { + failedToStart = true + }) + child.once('close', (code, signal) => resolve({ code, signal })) + }) + // Output capture is used only for --version; inherited command output stays with the terminal. + child.stdout?.on('data', (chunk: Uint8Array | string) => { + stdout = (stdout + chunk.toString()).slice(0, 4096) + }) + child.stderr?.resume() + options.signal?.addEventListener('abort', abort, { once: true }) + if (options.signal?.aborted) { + abort() + } + try { + const result = await closed + options.signal?.removeEventListener('abort', abort) + await cleanup + return { ...result, cancelled, failedToStart, cleanupFailed, stdout } + } + finally { + options.signal?.removeEventListener('abort', abort) + } +} diff --git a/packages/weapp/src/registry.ts b/packages/weapp/src/registry.ts index 1b03870..5f4a823 100644 --- a/packages/weapp/src/registry.ts +++ b/packages/weapp/src/registry.ts @@ -12,13 +12,13 @@ export const commands: Record = { info: { usage: 'info [--json]', min: 1, max: 1, options: ['json'], zh: '查看项目能力与开始方式', en: 'Inspect a project and its quick start' }, docs: { usage: 'docs [--open]', min: 1, max: 1, options: ['open'], zh: '查看官方文档', en: 'Find official documentation' }, repo: { usage: 'repo [--open]', min: 1, max: 1, options: ['open'], zh: '查看源码仓库', en: 'Find the source repository' }, - create: { usage: 'create [weapp-vite|vue-mini|rezor|uni|taro] [directory] [--template name] [--dry-run]', min: 0, max: 2, options: ['template', 'dryRun'], zh: '默认创建 weapp-vite,支持交互向导', en: 'Create weapp-vite by default, with a guided setup' }, + create: { usage: 'create [weapp-vite|vue-mini|rezor|uni|taro] [directory] [--template name] [--dry-run] [--install] [--package-manager npm|pnpm|yarn|bun]', min: 0, max: 2, options: ['template', 'dryRun', 'install', 'packageManager'], zh: '默认创建 weapp-vite,支持交互向导', en: 'Create weapp-vite by default, with a guided setup' }, doctor: { usage: 'doctor [directory] [--json]', min: 0, max: 1, options: ['json'], zh: '只读检查环境与项目', en: 'Inspect the environment and project' }, qr: { usage: 'qr [text|-] [--output file.svg] [--force]', min: 0, max: 1, options: ['output', 'force'], zh: '生成终端或 SVG 二维码', en: 'Generate a terminal or SVG QR code' }, palette: { usage: 'palette [--format css] [--json] [--output file] [--force]', min: 1, max: 1, options: ['format', 'json', 'output', 'force'], zh: '生成五阶配色与文字对比度', en: 'Generate five shades and text contrast' }, } -const values = new Set(['lang', 'seed', 'duration', 'ecosystem', 'status', 'template', 'output', 'format']) +const values = new Set(['lang', 'seed', 'duration', 'ecosystem', 'status', 'template', 'packageManager', 'output', 'format']) const known = new Set([...globalOptions, ...Object.values(commands).flatMap(command => command.options)]) const camel = (value: string): string => value.replace(/-([a-z])/g, (_: string, letter: string) => letter.toUpperCase()) @@ -75,6 +75,9 @@ export function parseArgs(argv: string[]): { command?: CommandName, args: string if (options.lang && !['zh-CN', 'en'].includes(String(options.lang))) { throw new Error('--lang must be zh-CN or en') } + if (options.packageManager !== undefined && !['npm', 'pnpm', 'yarn', 'bun'].includes(String(options.packageManager))) { + throw new Error('--package-manager must be npm, pnpm, yarn, or bun') + } if (options.seed !== undefined) { if (!/^\d+$/.test(String(options.seed)) || Number(options.seed) > 0xFFFFFFFF) { throw new Error('--seed must be an integer between 0 and 4294967295') @@ -109,5 +112,5 @@ export function renderHelp(ctx: Pick, command?: CommandName) const usage = command ? [` npx weapp ${commands[command].usage}`, ` ${t(ctx, commands[command].zh, commands[command].en)}`] : Object.values(commands).filter(entry => !entry.hidden).flatMap(entry => [` npx weapp ${entry.usage}`, ` ${t(ctx, entry.zh, entry.en)}`]) - return [intro, '', ...links, '', 'Usage:', ...(command ? [] : [` npx weapp — ${t(ctx, '动态工作台,默认创建 weapp-vite', 'animated workbench, starting with Create weapp-vite')}`]), ...usage, ...(command === 'create' ? ['', t(ctx, '交互终端:目录 → 模板 → 创建摘要;默认模板 default。其他路线须明确指定。', 'Interactive terminal: directory → template → summary; default template: default. Specify other routes explicitly.'), t(ctx, '非交互环境必须提供路线与目录;--dry-run 离线预览。', 'Provide a route and directory in non-interactive environments; --dry-run previews offline.')] : []), '', '--lang zh-CN|en --no-color --no-motion --help --version', '', t(ctx, '方向键 / Enter 选择 · Tab 切换生态 · 空格暂停 · Esc 返回 · q 退出', 'Arrow keys / Enter select · Tab switch ecosystems · Space pause · Esc back · q quit')].join('\n') + return [intro, '', ...links, '', 'Usage:', ...(command ? [] : [` npx weapp — ${t(ctx, '动态工作台,识别当前项目;无项目时优先创建', 'animated workbench, detecting your project or offering creation')}`]), ...usage, ...(command === 'create' ? ['', t(ctx, '交互终端:目录 → 模板 → 创建摘要;默认模板 default。其他路线须明确指定。', 'Interactive terminal: directory → template → summary; default template: default. Specify other routes explicitly.'), t(ctx, '非交互环境必须提供路线与目录。--install 明确安装依赖;默认仅创建。--package-manager 选择包管理器,不能覆盖声明冲突。--dry-run 始终离线且不安装。', 'Provide a route and directory in non-interactive environments. --install explicitly installs dependencies; creation alone is the default. --package-manager selects a manager without overriding declarations. --dry-run stays offline and never installs.')] : []), '', '--lang zh-CN|en --no-color --no-motion --help --version', '', t(ctx, '方向键 / Enter 选择 · Tab 切换生态 · 空格暂停 · Esc 返回 · q 退出', 'Arrow keys / Enter select · Tab switch ecosystems · Space pause · Esc back · q quit')].join('\n') } diff --git a/packages/weapp/src/terminal/index.ts b/packages/weapp/src/terminal/index.ts index 7f6af2d..95cc890 100644 --- a/packages/weapp/src/terminal/index.ts +++ b/packages/weapp/src/terminal/index.ts @@ -39,18 +39,18 @@ export async function runFx(ctx: CliContext, args: string[] = [], options: CliOp let paused = !ctx.motion let previous = performance.now() const session = new TerminalSession(ctx) - const render = () => { + const render = () => session.render(() => { const { width, height } = session.dimensions() const help = ctx.motion ? t(ctx, '空格 暂停 · R 重播 · ←/→ 切换 · Q 返回', 'Space pause · R replay · ←/→ switch · Q back') : t(ctx, '静态预览 · R 重播 · ←/→ 切换 · Q 返回', 'Static preview · R replay · ←/→ switch · Q back') - session.render([ + return [ ` WEAPP / ${EFFECTS[effectIndex].toUpperCase()} ${paused ? t(ctx, '已暂停', 'PAUSED') : t(ctx, '播放中', 'LIVE')}`, ...scene(width, Math.max(1, height - 4), elapsed), '', ` ${help}`, - ]) - } + ] + }) return status(ctx, await session.run({ start(finish) { if (options.duration) { @@ -65,6 +65,7 @@ export async function runFx(ctx: CliContext, args: string[] = [], options: CliOp elapsed += Math.min(0.15, (now - previous) / 1000) } previous = now + return !paused } : undefined, key(value, key, finish) { @@ -114,19 +115,18 @@ export async function runSnake(ctx: CliContext, args: string[] = ['snake'], opti let note = '' let accumulator = 0 let previous = performance.now() - const render = () => { + const render = () => session.render(() => { const heading = ` WEAPP / SNAKE ${t(ctx, '得分', 'SCORE')} ${game.score}` if (size.small) { - session.render([heading, '', t(ctx, '窗口太小,游戏已暂停。放大窗口,或按 Q 返回。', 'Window too small. Enlarge it, or press Q to return.')]) - return + return [heading, '', t(ctx, '窗口太小,游戏已暂停。放大窗口,或按 Q 返回。', 'Window too small. Enlarge it, or press Q to return.')] } const phase = game.phase === 'won' ? t(ctx, '你赢了!R 再玩一次', 'YOU WON! R to restart') : game.phase === 'over' ? t(ctx, '游戏结束 · R 再玩一次', 'GAME OVER · R to restart') : paused ? t(ctx, '已暂停 · 空格继续', 'PAUSED · Space resumes') : t(ctx, '方向键 / WASD 移动', 'Arrows / WASD move') - session.render([heading, ...snakeLines(game, ctx.color), phase, note, t(ctx, '空格 暂停 · R 重开 · Q 返回', 'Space pause · R restart · Q back')]) - } + return [heading, ...snakeLines(game, ctx.color), phase, note, t(ctx, '空格 暂停 · R 重开 · Q 返回', 'Space pause · R restart · Q back')] + }) return status(ctx, await session.run({ render, tick() { @@ -134,14 +134,16 @@ export async function runSnake(ctx: CliContext, args: string[] = ['snake'], opti const delta = Math.min(150, now - previous) previous = now if (paused || size.small || game.phase !== 'running') { - return + return false } accumulator += delta const interval = Math.max(90, 210 - game.score * 5) if (accumulator >= interval) { game = tickSnake(game) accumulator %= interval + return true } + return false }, resize() { const nextSize = boardSize() diff --git a/packages/weapp/src/terminal/session.ts b/packages/weapp/src/terminal/session.ts index 777ad8b..66f1686 100644 --- a/packages/weapp/src/terminal/session.ts +++ b/packages/weapp/src/terminal/session.ts @@ -42,12 +42,13 @@ export function padLine(value: string, columns: number): string { } export type TerminalFinish = (value?: T | number) => void +export type TerminalFrame = readonly string[] | (() => readonly string[]) export interface TerminalController { start?: (finish: TerminalFinish, session: TerminalSession) => void key?: (value: string, key: Key, finish: TerminalFinish, session: TerminalSession) => void | Promise resize?: (finish: TerminalFinish, session: TerminalSession) => void render?: (session: TerminalSession) => void - tick?: (finish: TerminalFinish, session: TerminalSession) => void + tick?: (finish: TerminalFinish, session: TerminalSession) => void | boolean cancel?: (reason: unknown) => void interval?: number } @@ -70,10 +71,11 @@ export class TerminalSession { started: boolean suspended: boolean lines: string[] - pending: string[] | null + pending: TerminalFrame | null lastFlush: number blocked: boolean reset: boolean + externalInterrupt?: (signal: 'SIGINT' | 'SIGTERM') => boolean private flushTimer: ReturnType | null = null private drainListener: (() => void) | null = null private interruptListener?: () => void @@ -108,12 +110,13 @@ export class TerminalSession { } } - render(lines: string[]) { + render(frame: TerminalFrame) { if (this.closed) { return } - const { width, height } = this.dimensions() - this.pending = lines.slice(0, height).map(line => clipLine(line, width)) + // Keep only the latest request. Its layout is evaluated after pacing and + // backpressure gates, using the dimensions at the time it is displayed. + this.pending = frame this.schedule() } @@ -150,8 +153,10 @@ export class TerminalSession { if (this.closed || this.suspended || this.blocked || !this.pending) { return } - const next = this.pending + const frame = this.pending this.pending = null + const { width, height } = this.dimensions() + const next = (typeof frame === 'function' ? frame() : frame).slice(0, height).map(line => clipLine(line, width)) let output = this.reset ? '\u001B[H\u001B[2J' : '' for (let row = 0; row < Math.max(this.lines.length, next.length); row++) { if (this.reset || this.lines[row] !== next[row]) { @@ -160,10 +165,10 @@ export class TerminalSession { } this.reset = false this.lines = next + this.lastFlush = performance.now() if (!output) { return } - this.lastFlush = performance.now() if (this.output.write(output) === false) { this.blocked = true this.drainListener = () => { @@ -260,6 +265,9 @@ export class TerminalSession { clearTimeout(timer) } this.timers.clear() + this.pending = null + this.flushTimer = null + this.externalInterrupt = undefined if (this.drainListener) { this.output.removeListener?.('drain', this.drainListener) } @@ -341,8 +349,18 @@ export class TerminalSession { controller.resize?.(finish, this) controller.render?.(this) }) - this.interruptListener = () => cancel(130) - this.terminateListener = () => cancel(143) + const signal = (name: 'SIGINT' | 'SIGTERM', code: number) => { + try { + if (!this.suspended || !this.externalInterrupt?.(name)) { + cancel(code) + } + } + catch (error) { + fail(error) + } + } + this.interruptListener = () => signal('SIGINT', 130) + this.terminateListener = () => signal('SIGTERM', 143) this.signals.on('SIGINT', this.interruptListener) this.signals.on('SIGTERM', this.terminateListener) try { @@ -354,8 +372,9 @@ export class TerminalSession { controller.render?.(this) if (controller.tick) { this.interval(guarded(() => { - controller.tick?.(finish, this) - controller.render?.(this) + if (controller.tick?.(finish, this) !== false && !this.closed) { + controller.render?.(this) + } }), controller.interval) } } diff --git a/packages/weapp/src/terminal/workbench.ts b/packages/weapp/src/terminal/workbench.ts index edf6149..26bd9d2 100644 --- a/packages/weapp/src/terminal/workbench.ts +++ b/packages/weapp/src/terminal/workbench.ts @@ -1,10 +1,13 @@ import type { Key } from 'node:readline' import type { CreateProgress, CreateWizardHooks, ScaffoldPlan } from '../commands/create/plan.js' +import type { PackageManagerName, ProjectContext } from '../project/context.js' import type { CatalogProject, CliContext, CliOptions, CliOutput } from '../types.js' import type { ProjectPreview } from './preview.js' import type { TerminalFinish } from './session.js' +import path from 'node:path' import { mainProjects } from '../catalog.js' import { displayCommand, npmExecArgs, scaffoldPlan } from '../commands/create/plan.js' +import { issueLines, projectLines } from '../project/presentation.js' import { sanitize, t } from '../shared.js' import { TEMPLATE_DETAILS, TEMPLATES } from '../templates.js' import { projectPreview } from './preview.js' @@ -80,9 +83,9 @@ export interface ProjectsScreen extends ScreenBase { complete?: (value: CatalogProject | null) => void } export interface InputScreen extends ScreenBase { type: 'input', hint?: string, value: string, error?: string, complete?: (value: string | null) => void } -export interface SummaryScreen extends ScreenBase { type: 'summary', lines: string[], scroll?: number, complete?: (value: boolean | null) => void } +export interface SummaryScreen extends ScreenBase { type: 'summary', confirmation?: string, lines: string[], scroll?: number, complete?: (value: boolean | null) => void } export interface DetailScreen extends ScreenBase { type: 'detail', project: CatalogProject, complete?: undefined } -export interface ResultScreen extends ScreenBase { type: 'result', lines: string[], scroll?: number, successAt?: number, complete?: undefined } +export interface ResultScreen extends ScreenBase { type: 'result', context?: ProjectContext, lines: string[], scroll?: number, successAt?: number, complete?: undefined } export interface PreparingScreen extends ScreenBase { type: 'preparing', cancel: () => void, complete?: undefined } export type WorkbenchScreen = MenuScreen | ProjectsScreen | InputScreen | SummaryScreen | DetailScreen | ResultScreen | PreparingScreen export type RequestScreen = MenuScreen | ProjectsScreen | InputScreen | SummaryScreen @@ -107,11 +110,25 @@ export class Workbench { busy: boolean screen: WorkbenchScreen createProgress?: CreateProgress + projectContext?: ProjectContext + projectPending = true + private hallTouched = false private progressAt = 0 private previewFrom: ProjectPreview = projectPreview() private previewTo: ProjectPreview = projectPreview() private previewAt = 0 private createCancellations = new Set<() => void>() + private layoutCache?: { + width: number + height: number + screen: WorkbenchScreen + lang: CliContext['lang'] + color: boolean + motion: boolean + progressLabel: string + value: ReturnType + } + finish: TerminalFinish = () => {} constructor(ctx: CliContext, dispatch: WorkbenchDispatch = () => 0) { @@ -162,32 +179,28 @@ export class Workbench { return pool.filter(project => !query || JSON.stringify([projectId(project), project.locales, project.keywords]).toLowerCase().includes(query)).map(project => ({ value: project, label: content(this.ctx, project).name || projectId(project), description: content(this.ctx, project).tagline, project })) } - frame() { - const { width, height } = this.session.dimensions() + private hideVisual(height: number): boolean { + const screen = this.screen + return ((screen.type === 'result' || screen.type === 'input' || screen.type === 'summary') && !screen.preview) + || ((screen.type === 'summary' || screen.type === 'result') && height < 24) + } + + private layout(width: number, height: number, progressLabel: string) { const screen = this.screen const entries = this.entries() screen.selected = Math.max(0, Math.min(screen.selected || 0, entries.length - 1)) const current = screen.project || entries[screen.selected || 0]?.project const detail = content(this.ctx, current) - const focusTo = current ? mainProjects(this.ctx.catalog || []).indexOf(current) : 0 - if (focusTo !== this.focusTo) { - this.focusFrom = this.focus - this.focusTo = focusTo - this.focusAt = this.time - } - if (!this.ctx.motion || this.paused) { - this.focusFrom = this.focusTo - } - const transition = this.ctx.motion && !this.paused ? Math.min(1, (this.time - this.focusAt) / 0.25) : 1 - this.focus = this.focusFrom + (this.focusTo - this.focusFrom) * transition * transition * (3 - 2 * transition) - const hideVisual = ((screen.type === 'result' || screen.type === 'input') && !screen.preview) || ((screen.type === 'summary' || screen.type === 'result') && height < 24) + const projects = mainProjects(this.ctx.catalog || []) + const focusTo = current ? projects.indexOf(current) : 0 + const hideVisual = this.hideVisual(height) const wide = width >= 87 && height >= 24 && !hideVisual const compact = width < 60 || height < 18 const leftWidth = wide ? Math.min(38, Math.floor(width * 0.38)) : width - 2 const visualWidth = wide ? width - leftWidth - 5 : width - 2 const visualHeight = wide ? Math.min(16, height - 9) : compact ? 3 : Math.min(screen.preview ? 5 : 7, height - 14) const available = Math.max(1, height - 5 - (wide || hideVisual ? 0 : Math.max(1, visualHeight))) - const progress = screen.preview && screen.type !== 'preparing' && this.createProgress ? ['', ...wrap(this.progressLabel(), leftWidth)] : [] + const progress = screen.preview && screen.type !== 'preparing' && this.createProgress ? ['', ...wrap(progressLabel, leftWidth)] : [] const contentAvailable = Math.max(1, available - progress.length) const heading = ` ${this.accent('WEAPP')} / ${screen.title || this.hall.title}` const lines = [heading, ` ${screen.subtitle || this.hall.subtitle}`, ''] @@ -196,7 +209,7 @@ export class Workbench { left = [...wrap(screen.hint, leftWidth), '', this.accent(inputLine(screen.value, leftWidth), true), '', ...(screen.error ? wrap(screen.error, leftWidth) : [])] } else if (screen.type === 'preparing') { - left = [t(this.ctx, '正在准备创建项目', 'Preparing your project'), '', ...wrap(this.progressLabel(), leftWidth), '', t(this.ctx, 'Esc 取消本次创建', 'Esc cancels this creation')] + left = [t(this.ctx, '正在准备创建项目', 'Preparing your project'), '', ...wrap(progressLabel, leftWidth), '', t(this.ctx, 'Esc 取消本次创建', 'Esc cancels this creation')] } else if (screen.type === 'result') { const rows = resultLines(screen.lines, leftWidth) @@ -208,7 +221,7 @@ export class Workbench { } else if (screen.type === 'summary') { left = screen.lines.flatMap(line => wrap(line, leftWidth)) - left.push('', this.accent(t(this.ctx, '› Enter 创建项目', '› Enter creates the project'), true)) + left.push('', this.accent(screen.confirmation || t(this.ctx, '› Enter 创建项目', '› Enter creates the project'), true)) screen.scroll = Math.max(0, Math.min(screen.scroll || 0, left.length - contentAvailable)) left = left.slice(screen.scroll, screen.scroll + contentAvailable) } @@ -236,17 +249,9 @@ export class Workbench { left = [...left.slice(0, contentAvailable), ...progress] } const preview = screen.preview && projectPreview(screen.type === 'input' ? screen.value : screen.preview.directory, screen.type === 'menu' && screen.templates ? screen.entries[screen.selected || 0]?.value : screen.preview.template) - if (preview && (preview.name !== this.previewTo.name || preview.template !== this.previewTo.template)) { - this.previewFrom = this.previewTo - this.previewTo = preview - this.previewAt = this.time - } - const previewTransition = !this.ctx.motion || this.paused ? 1 : Math.min(1, (this.time - this.previewAt) / 0.25) - const visual = (artWidth: number, artHeight: number) => preview - ? renderProjectPreview({ width: artWidth, height: artHeight, from: this.previewFrom, to: this.previewTo, transition: previewTransition, time: this.ctx.motion ? this.time : 0, color: this.ctx.color, celebration: screen.type === 'result' && screen.successAt !== undefined ? this.ctx.motion ? (this.time - screen.successAt) / 0.8 : 1 : undefined }) - : renderScene(screen === this.hall && this.ctx.motion && this.time < 1.2 ? 'logo' : 'orbit', { width: artWidth, height: artHeight, time: this.ctx.motion ? this.time : 1.2, opening: screen === this.hall, catalog: mainProjects(this.ctx.catalog || []), logo: this.ctx.logo, color: this.ctx.color, focus: this.focus }) - if (wide && !hideVisual) { - const right = [...visual(visualWidth, visualHeight), '', this.accent(preview?.name || detail.name || 'weapp-vite', true), ...wrap(preview ? `${preview.template} · ${t(this.ctx, '你的项目星图', 'Your project constellation')}` : detail.tagline || t(this.ctx, '用 Vite 做小程序工程构建', 'Vite builds for mini-app projects'), visualWidth)] + let right: string[] = [] + if (wide) { + right = ['', this.accent(preview?.name || detail.name || 'weapp-vite', true), ...wrap(preview ? `${preview.template} · ${t(this.ctx, '你的项目星图', 'Your project constellation')}` : detail.tagline || t(this.ctx, '用 Vite 做小程序工程构建', 'Vite builds for mini-app projects'), visualWidth)] if (current && !preview) { right.push(`${t(this.ctx, '状态', 'Status')}: ${current.status}`) right.push(...wrap(current.status === 'planned' ? t(this.ctx, '规划中 · 暂无安装命令', 'Planned · No installation command') : current.quickStart?.command || current.installCommand || '', visualWidth)) @@ -254,15 +259,6 @@ export class Workbench { else if (!preview) { right.push('npx weapp create') } - for (let row = 0; row < Math.min(height - 6, Math.max(left.length, right.length)); row++) { - lines.push(` ${padLine(left[row] || '', leftWidth)} ${right[row] || ''}`) - } - } - else { - if (!hideVisual) { - lines.push(...visual(visualWidth, Math.max(1, visualHeight))) - } - lines.push(...left.map(line => ` ${line}`)) } let footer = this.ctx.motion ? t(this.ctx, '↑/↓ 选择 · Enter 打开 · 空格 暂停 · Q 退出', '↑/↓ select · Enter open · Space pause · Q quit') @@ -280,13 +276,13 @@ export class Workbench { footer = screen.preview ? t(this.ctx, 'Enter 继续 · Esc 取消', 'Enter continue · Esc cancel') : t(this.ctx, 'Enter 继续 · Esc 返回', 'Enter continue · Esc back') } if (screen.type === 'summary') { - footer = t(this.ctx, '↑/↓ 滚动 · Enter 创建 · Esc 取消', '↑/↓ scroll · Enter create · Esc cancel') + footer = screen.confirmation ? t(this.ctx, '↑/↓ 滚动 · Enter 执行 · Esc 取消', '↑/↓ scroll · Enter run · Esc cancel') : t(this.ctx, '↑/↓ 滚动 · Enter 创建 · Esc 取消', '↑/↓ scroll · Enter create · Esc cancel') } if (screen.type === 'detail') { footer = `${t(this.ctx, 'D 文档 · R 源码', 'D docs · R source')} · Esc` } if (screen.type === 'result') { - footer = t(this.ctx, '↑/↓ 滚动 · Enter / Esc 返回', '↑/↓ scroll · Enter / Esc back') + footer = screen.context?.manifest ? t(this.ctx, 'I 安装 · S 脚本 · D 诊断 · ↑/↓ · Esc 返回', 'I install · S scripts · D doctor · ↑/↓ · Esc back') : screen.context ? t(this.ctx, 'D 诊断 · ↑/↓ · Esc 返回', 'D doctor · ↑/↓ · Esc back') : t(this.ctx, '↑/↓ 滚动 · Enter / Esc 返回', '↑/↓ scroll · Enter / Esc back') } if (screen === this.hall) { footer = width < 60 ? 'Enter / C · ↑/↓ · Q' : t(this.ctx, 'Enter / C 创建 · ↑/↓ 选择 · 空格 暂停 · Q 退出', 'Enter / C create · ↑/↓ select · Space pause · Q quit') @@ -294,22 +290,80 @@ export class Workbench { footer = width < 60 ? 'Enter / C · ↑/↓ · Q' : t(this.ctx, 'Enter / C 创建 · ↑/↓ 选择 · Q 退出', 'Enter / C create · ↑/↓ select · Q quit') } } + if (screen === this.hall) { + footer = this.projectPending ? t(this.ctx, '正在识别项目… · C 创建 · Q 退出', 'Detecting project… · C create · Q quit') : t(this.ctx, 'Enter / C 创建 · ↑/↓ 选择 · Q 退出', 'Enter / C create · ↑/↓ select · Q quit') + if (this.projectContext?.kind !== 'unknown' && !this.projectPending) { + footer = t(this.ctx, 'Enter 打开 / C 创建 · ↑/↓ · Q 退出', 'Enter open / C create · ↑/↓ · Q quit') + } + } if (screen.type === 'preparing') { footer = t(this.ctx, 'Esc 取消本次创建', 'Esc cancels this creation') } + return { screen, width, height, projects, focusTo, hideVisual, wide, leftWidth, visualWidth, visualHeight, lines, left: wide ? left.map(line => padLine(line, leftWidth)) : left.map(line => ` ${line}`), right, footer, preview } + } + + frame() { + const { width, height } = this.session.dimensions() + const progressLabel = this.progressLabel() + const cached = this.layoutCache + const layout = cached && cached.width === width && cached.height === height + && cached.screen === this.screen && cached.lang === this.ctx.lang + && cached.color === this.ctx.color && cached.motion === this.ctx.motion + && cached.progressLabel === progressLabel + ? cached.value + : this.layout(width, height, progressLabel) + this.layoutCache = { width, height, screen: this.screen, lang: this.ctx.lang, color: this.ctx.color, motion: this.ctx.motion, progressLabel, value: layout } + const { screen, projects, focusTo, hideVisual, wide, leftWidth, visualWidth, visualHeight, left, right, footer, preview } = layout + if (focusTo !== this.focusTo) { + this.focusFrom = this.focus + this.focusTo = focusTo + this.focusAt = this.time + } + if (!this.ctx.motion || this.paused) { + this.focusFrom = this.focusTo + } + const transition = this.ctx.motion && !this.paused ? Math.min(1, (this.time - this.focusAt) / 0.25) : 1 + this.focus = this.focusFrom + (this.focusTo - this.focusFrom) * transition * transition * (3 - 2 * transition) + if (preview && (preview.name !== this.previewTo.name || preview.template !== this.previewTo.template)) { + this.previewFrom = this.previewTo + this.previewTo = preview + this.previewAt = this.time + } + const previewTransition = !this.ctx.motion || this.paused ? 1 : Math.min(1, (this.time - this.previewAt) / 0.25) + const visual = (artWidth: number, artHeight: number) => preview + ? renderProjectPreview({ width: artWidth, height: artHeight, from: this.previewFrom, to: this.previewTo, transition: previewTransition, time: this.ctx.motion ? this.time : 0, color: this.ctx.color, celebration: screen.type === 'result' && screen.successAt !== undefined ? this.ctx.motion ? (this.time - screen.successAt) / 0.8 : 1 : undefined }) + : renderScene(screen === this.hall && this.ctx.motion && this.time < 1.2 ? 'logo' : 'orbit', { width: artWidth, height: artHeight, time: this.ctx.motion ? this.time : 1.2, opening: screen === this.hall, catalog: projects, logo: this.ctx.logo, color: this.ctx.color, focus: this.focus }) + const lines = [...layout.lines] + if (wide) { + const rightLines = [...visual(visualWidth, visualHeight), ...right] + const emptyLeft = ' '.repeat(leftWidth) + for (let row = 0; row < Math.min(height - 6, Math.max(left.length, rightLines.length)); row++) { + lines.push(` ${left[row] || emptyLeft} ${rightLines[row] || ''}`) + } + } + else { + if (!hideVisual) { + lines.push(...visual(visualWidth, Math.max(1, visualHeight))) + } + lines.push(...left) + } const body = lines.slice(0, Math.max(0, height - 2)) while (body.length < height - 2) { body.push('') } - body.push(this.busy && screen === this.hall ? ` ${this.progressLabel()}` : this.paused ? t(this.ctx, ' 动效已暂停', ' Motion paused') : '', ` ${footer}`) + body.push(this.busy && screen === this.hall && this.createProgress ? ` ${progressLabel}` : this.paused ? t(this.ctx, ' 动效已暂停', ' Motion paused') : '', ` ${footer}`) return body } + private requestFrame() { + this.session.render(() => this.frame()) + } + render() { - if (this.session.closed) { - return - } - this.session.render(this.frame()) + // State changes invalidate the one current layout. Animation ticks reuse + // its search results, wrapped copy and padded columns until the next event. + this.layoutCache = undefined + this.requestFrame() } progressLabel(): string { @@ -392,7 +446,7 @@ export class Workbench { }) } - showCreateSuccess(plan: ScaffoldPlan, progress?: CreateProgress): void { + showCreateSuccess(plan: ScaffoldPlan, progress?: CreateProgress, context?: ProjectContext, nextSteps?: string[]): void { if (this.session.closed) { return } @@ -401,15 +455,16 @@ export class Workbench { this.screen = { type: 'result', title: t(this.ctx, '项目已创建', 'PROJECT CREATED'), + context, subtitle: `${plan.route}${plan.route === 'weapp-vite' ? ` · ${template}` : ''}`, parent: this.hall, preview: { directory: plan.directory, template }, successAt: this.time, - lines: [this.accent(t(this.ctx, '[ 项目已创建 ]', '[ PROJECT CREATED ]'), true), '', `${t(this.ctx, '项目已创建', 'Project created')}: ${sanitize(plan.directory)}`, '', `cd ${displayCommand([plan.directory]).slice(4)}`, 'npm install', '', ...[ + lines: [this.accent(t(this.ctx, '[ 项目已创建 ]', '[ PROJECT CREATED ]'), true), '', `${t(this.ctx, '项目已创建', 'Project created')}: ${sanitize(plan.directory)}`, '', ...(nextSteps || [t(this.ctx, '请通过项目诊断查看生成的配置。', 'Inspect the generated configuration with doctor.')]), '', ...[ [t(this.ctx, '版本查询', 'Version query'), progress?.queryElapsedMs], [t(this.ctx, '官方脚手架', 'Official scaffold'), progress?.executionElapsedMs], [t(this.ctx, '结果验证', 'Result validation'), progress?.validationElapsedMs], - ].flatMap(([label, milliseconds]) => typeof milliseconds === 'number' ? [`${label}: ${this.duration(milliseconds)}`] : []), '', t(this.ctx, '下一步:安装依赖,再按官方文档启动。', 'Next: install dependencies and follow the official guide.')], + ].flatMap(([label, milliseconds]) => typeof milliseconds === 'number' ? [`${label}: ${this.duration(milliseconds)}`] : []), '', t(this.ctx, 'I 安装 · S 查看脚本 · D 诊断;执行前将展示确认页。', 'I install · S scripts · D doctor; review the confirmation before execution.')], scroll: 0, } this.render() @@ -446,20 +501,31 @@ export class Workbench { } } - async external(callback: () => T | Promise, notice?: string): Promise { + async external(callback: () => T | Promise, notice?: string, interrupt?: () => void): Promise { if (this.session.closed || this.abortSignal.aborted) { const error = new Error('The terminal session has ended.') error.name = 'AbortError' throw error } + const previousInterrupt = this.session.externalInterrupt + if (interrupt) { + this.session.externalInterrupt = (signal) => { + if (signal !== 'SIGINT') { + return false + } + interrupt() + return true + } + } this.session.suspend() try { if (notice) { - this.session.output.write(`${sanitize(notice)}\n`) + this.session.output.write(`${notice.split('\n').map(line => sanitize(line)).join('\n')}\n`) } return await callback() } finally { + this.session.externalInterrupt = previousInterrupt this.lastTime = performance.now() this.session.resume() this.render() @@ -535,6 +601,20 @@ export class Workbench { } async activate(entry: MenuEntry): Promise { + if (entry.value === 'project-status' && this.projectContext) { + this.screen = { type: 'result', title: t(this.ctx, '当前项目', 'CURRENT PROJECT'), lines: projectLines(this.ctx, this.projectContext), context: this.projectContext, parent: this.hall } + return + } + if (entry.value === 'project-install' || entry.value === 'project-scripts') { + if (this.projectContext) { + await this.projectAction(this.projectContext, entry.value === 'project-install' ? 'install' : 'scripts') + } + return + } + if (entry.value === 'doctor' && this.projectContext && this.projectContext.kind !== 'unknown') { + await this.command(['doctor', '--', this.projectContext.projectRoot]) + return + } if (entry.value === 'ecosystem') { this.screen = this.projects(this.ctx.catalog || [], {}, true) return @@ -572,8 +652,145 @@ export class Workbench { } } + async discoverCurrentProject(directory?: string): Promise { + try { + const discover = this.ctx.discoverProject || (await import('../project/context.js')).discoverProject + const context = await discover(directory) + if (this.session.closed) { + return + } + const selected = this.hall.entries[this.hall.selected || 0]?.value + this.projectContext = context + this.projectPending = false + const base = this.hall.entries.filter(entry => !entry.value.startsWith('project-')) + if (context.kind !== 'unknown') { + this.hall.subtitle = sanitize(context.manifest?.name || context.projectRoot) + this.hall.entries = [ + { value: 'project-status', label: t(this.ctx, '当前项目状态', 'Current project status'), description: context.kind === 'invalid' ? t(this.ctx, '项目配置无效,请查看诊断。', 'Invalid project configuration; inspect the diagnostics.') : context.frameworks.join(' · ') || context.kind }, + base.find(entry => entry.value === 'doctor')!, + ...(context.manifest + ? [ + { value: 'project-install', label: t(this.ctx, '安装项目依赖', 'Install project dependencies'), description: t(this.ctx, '确认目录与命令后安装。', 'Review the directory and command before installing.') }, + { value: 'project-scripts', label: t(this.ctx, '运行项目脚本', 'Run project scripts'), description: Object.keys(context.manifest.scripts || {}).join(' · ') || t(this.ctx, '尚未声明脚本。', 'No scripts declared.') }, + ] + : []), + ...base.filter(entry => entry.value !== 'doctor'), + ] + } + else { + this.hall.entries = base + } + this.hall.selected = this.hallTouched ? Math.max(0, this.hall.entries.findIndex(entry => entry.value === selected)) : 0 + this.render() + } + catch { + if (this.session.closed) { + return + } + const selected = this.hall.entries[this.hall.selected || 0]?.value + const requestedDirectory = path.resolve(directory || '.') + this.projectContext = { requestedDirectory, projectRoot: requestedDirectory, kind: 'invalid', packageManager: { source: 'unresolved', conflicts: [] }, frameworks: [], configs: [], lockfiles: [], issues: [{ id: 'discovery', severity: 'error', message: 'Unable to read the project. Use doctor to check directory access.', messageZh: '无法读取项目,请使用 doctor 检查目录权限。' }] } + this.projectPending = false + this.hall.subtitle = t(this.ctx, '无法读取项目,请使用 doctor 检查目录权限。', 'Unable to read the project; use doctor to check directory access.') + this.hall.entries = [ + { value: 'project-status', label: t(this.ctx, '项目读取失败', 'Project discovery failed'), description: this.hall.subtitle }, + ...this.hall.entries.filter(entry => !entry.value.startsWith('project-')), + ] + this.hall.selected = this.hallTouched ? Math.max(0, this.hall.entries.findIndex(entry => entry.value === selected)) : 0 + this.render() + } + } + + async projectAction(context: ProjectContext, kind: 'install' | 'scripts'): Promise { + if (this.busy || this.session.closed) { + return + } + this.busy = true + const previous = this.screen + const controller = new AbortController() + const abort = () => controller.abort() + this.abortSignal.addEventListener('abort', abort, { once: true }) + const active = () => !this.session.closed && !controller.signal.aborted && this.screen === previous + const show = (lines: string[]) => { + if (!this.session.closed && this.screen === previous) { + this.screen = { type: 'result', title: t(this.ctx, '项目操作', 'PROJECT ACTION'), lines, context, parent: previous } + } + } + try { + const { discoverProject } = await import('../project/context.js') + const { projectActions, executeProjectAction, actionDisplayCommand } = await import('../project/actions.js') + if (!active()) { + return + } + const discover = this.ctx.discoverProject || discoverProject + context = await discover(context.projectRoot, { packageManager: context.packageManager.source === 'explicit' ? context.packageManager.name : undefined }) + if (!active()) { + return + } + if (!context.manifest) { + show([t(this.ctx, '此项目没有可执行的 package.json,请使用只读诊断。', 'This project has no executable package.json. Use the read-only diagnostics.')]) + return + } + if (context.packageManager.conflicts.some(issue => issue.id === 'package-manager-lockfiles')) { + const choice = await this.request({ type: 'menu', title: t(this.ctx, '选择包管理器', 'CHOOSE PACKAGE MANAGER'), subtitle: t(this.ctx, '检测到多种锁文件;不会删除或改写现有声明。', 'Multiple lockfiles detected. Choose the manager for this action.'), entries: ['npm', 'pnpm', 'yarn', 'bun'].map(value => ({ value, label: value })) }) + if (!choice) { + return + } + context = await discover(context.projectRoot, { packageManager: choice as PackageManagerName }) + if (!active()) { + return + } + } + const conflicts = [...context.issues.filter(issue => issue.severity === 'error'), ...context.packageManager.conflicts] + if (conflicts.length) { + show(issueLines(this.ctx, conflicts)) + return + } + const actions = projectActions(context) + let action = actions.find(action => action.kind === 'install') + if (kind === 'scripts') { + const scripts = actions.filter(action => action.kind === 'script') + if (!scripts.length) { + show([t(this.ctx, 'package.json 未声明可运行脚本。', 'package.json declares no runnable scripts.')]) + return + } + const choice = await this.request({ type: 'menu', title: t(this.ctx, '项目脚本', 'PROJECT SCRIPTS'), entries: scripts.map(action => ({ value: action.id, label: action.script!, description: context.manifest?.scripts?.[action.script!] })) }) + action = scripts.find(action => action.id === choice) + } + if (!action || !active()) { + return + } + const confirmed = await this.request({ type: 'summary', title: t(this.ctx, '确认项目操作', 'CONFIRM PROJECT ACTION'), confirmation: t(this.ctx, '› Enter 执行命令', '› Enter runs the command'), lines: [ + `${t(this.ctx, '执行目录', 'Working directory')}: ${action.cwd}`, + actionDisplayCommand(action), + ...(action.script ? ['', `${t(this.ctx, '脚本内容', 'Script content')}:`, context.manifest?.scripts?.[action.script] || ''] : []), + '', + t(this.ctx, '执行前检查 Node 与包管理器版本。任务接管终端;Ctrl+C 取消后返回。', 'Check Node and package manager versions before execution. The task takes over the terminal; Ctrl+C cancels and returns.'), + ] }) + if (!confirmed || !active()) { + return + } + const result = await this.external(() => executeProjectAction(action, { ...this.ctx.projectOperations, signal: controller.signal }), `${action.cwd}\n${actionDisplayCommand(action)}`, abort) + show([result.status === 'success' ? t(this.ctx, '操作完成。', 'Action completed.') : result.status === 'cancelled' ? t(this.ctx, '已取消,子进程已清理。', 'Cancelled; child processes cleaned up.') : t(this.ctx, '操作未完成,项目文件已保留。', 'Action did not complete; project files were retained.'), ...issueLines(this.ctx, result.errors)]) + } + catch (error) { + show([t(this.ctx, '操作未完成。', 'Action did not complete.'), sanitize(error instanceof Error ? error.message : String(error))]) + } + finally { + this.abortSignal.removeEventListener('abort', abort) + this.busy = false + this.render() + } + } + key(value: string, key: Key, finish: TerminalFinish): void | Promise { const screen = this.screen + if (screen === this.hall && ['up', 'down', 'c', 'return'].includes(key.name || '')) { + if (this.projectPending && key.name === 'return') { + return + } + this.hallTouched = true + } if (screen === this.hall && key.name === 'escape' && this.createCancellations.size) { for (const cancel of this.createCancellations) { cancel() @@ -592,7 +809,7 @@ export class Workbench { return } if (screen === this.hall && key.name === 'c' && !key.ctrl && !key.meta && !this.busy) { - return this.activate(this.hall.entries[0]).then(() => this.render()) + return this.activate(this.hall.entries.find(entry => entry.value === 'create')!).then(() => this.render()) } const entries = this.entries() if (this.ctx.motion && key.name === 'space' && ['menu', 'detail'].includes(screen.type)) { @@ -616,6 +833,12 @@ export class Workbench { return } if (screen.type === 'result') { + if (screen.context && key.name === 'd') { + return this.command(['doctor', '--', screen.context.projectRoot]) + } + if (screen.context?.manifest && (key.name === 'i' || key.name === 's')) { + return this.projectAction(screen.context, key.name === 'i' ? 'install' : 'scripts') + } if (key.name === 'return') { this.screen = screen.parent || this.hall } @@ -699,8 +922,13 @@ export class Workbench { return this.session.run({ start: (finish) => { this.finish = finish + if (this.screen === this.hall) { + this.session.timeout(() => { + void this.discoverCurrentProject() + }, 0) + } }, - render: () => this.render(), + render: () => this.requestFrame(), tick: this.ctx.motion ? () => { const now = performance.now() @@ -708,10 +936,15 @@ export class Workbench { this.time += Math.max(0, Math.min(0.15, (now - this.lastTime) / 1000)) } this.lastTime = now + const progressVisible = this.screen.type === 'preparing' || Boolean(this.screen.preview && this.createProgress) || (this.busy && this.screen === this.hall) + const changingProgress = progressVisible && this.createProgress && ['planning', 'querying', 'executing', 'validating'].includes(this.createProgress.phase) + return !this.paused && (!this.hideVisual(this.session.dimensions().height) || Boolean(changingProgress)) } : undefined, key: (value, key, finish) => this.key(value, key, finish), cancel: () => this.cancel(), + }).finally(() => { + this.layoutCache = undefined }) } } @@ -814,7 +1047,7 @@ export async function createWizard(ctx: CliContext, route = 'weapp-vite', option displayCommand(npmExecArgs(plan)), '', options.dryRun ? t(ctx, '离线预览,不联网、不创建目录。', 'Offline preview. No network access or directory creation.') : t(ctx, '确认后运行官方脚手架。', 'Confirm to run the official scaffold.'), - t(ctx, '依赖由你稍后安装;已有目录不会被覆盖。', 'Install dependencies afterward. Existing directories are preserved.'), + options.install && !options.dryRun ? t(ctx, '创建成功后安装依赖;包管理器与兼容要求以生成项目为准。', 'Install dependencies after creation, using the generated project requirements.') : t(ctx, '依赖由你稍后安装;已有目录不会被覆盖。', 'Install dependencies afterward. Existing directories are preserved.'), ] }) if (confirmed && !cancelled && !ctx.operationSignal?.aborted) { confirmedCreation = true diff --git a/packages/weapp/src/types.ts b/packages/weapp/src/types.ts index 1e48f07..b0596c2 100644 --- a/packages/weapp/src/types.ts +++ b/packages/weapp/src/types.ts @@ -4,6 +4,8 @@ import type { Readable } from 'node:stream' import type { CreateOperations, CreateWizardSelection } from './commands/create.js' import type { CreateWizardHooks } from './commands/create/plan.js' import type { LabHandle } from './lab.js' +import type { ProjectActionOptions } from './project/actions.js' +import type { discoverProject, PackageManagerName } from './project/context.js' import type { Workbench } from './terminal/workbench.js' export type Language = 'zh-CN' | 'en' @@ -56,6 +58,8 @@ export interface CliOptions { open?: boolean template?: string dryRun?: boolean + install?: boolean + packageManager?: PackageManagerName output?: string format?: string force?: boolean @@ -79,6 +83,8 @@ export interface CliContext { cancelledCode?: number operationSignal?: AbortSignal createOperations?: CreateOperations + projectOperations?: ProjectActionOptions + discoverProject?: typeof discoverProject createWizard?: (route: string, options: CliOptions, hooks?: CreateWizardHooks) => Promise } diff --git a/packages/weapp/test/create-action-lifecycle.test.ts b/packages/weapp/test/create-action-lifecycle.test.ts new file mode 100644 index 0000000..bfc48ad --- /dev/null +++ b/packages/weapp/test/create-action-lifecycle.test.ts @@ -0,0 +1,203 @@ +import type { CliContext } from '../src/types.js' +import { ChildProcess } from 'node:child_process' +import { EventEmitter, getEventListeners } from 'node:events' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { PassThrough } from 'node:stream' +// eslint-disable-next-line e18e/ban-dependencies -- Mock the production Windows-safe process boundary. +import spawn from 'cross-spawn' +import { afterEach, expect, it, vi } from 'vitest' +import { executeScaffolder, runCreate } from '../src/commands/create.js' +import { scaffoldPlan } from '../src/commands/create/plan.js' +import { Workbench } from '../src/terminal/workbench.js' + +vi.mock('cross-spawn', () => ({ default: vi.fn() })) + +const roots: string[] = [] +afterEach(async () => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + vi.mocked(spawn).mockReset() + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +async function fixture() { + const root = await mkdtemp(path.join(os.tmpdir(), 'weapp-create-lifecycle-')) + roots.push(root) + const directory = path.join(root, 'app') + const input = Object.assign(new PassThrough(), { isTTY: true, setRawMode: vi.fn() }) + let text = '' + const output = Object.assign(new EventEmitter(), { isTTY: true, columns: 100, rows: 30, write: (value: string) => { + text += value + return true + } }) + const child = new ChildProcess() + const install = vi.fn(() => child) + const ctx: CliContext = { + lang: 'en', + stdin: input, + stdout: output, + stderr: output, + color: false, + motion: false, + interactive: false, + resources: {}, + openUrl: async () => {}, + createOperations: { + query: async () => ({ version: '1.0.0' }), + execute: async (plan) => { + await mkdir(plan.directory) + await writeFile(path.join(plan.directory, 'package.json'), JSON.stringify({ dependencies: { 'weapp-vite': '^1' } })) + return 0 + }, + }, + projectOperations: { spawn: install, packageManagerVersion: async () => '10.9.0' }, + } + return { ctx, directory, child, install, text: () => text } +} + +it('keeps the created project and hub open when Ctrl+C cancels the optional install', async () => { + const { ctx, directory, child, install } = await fixture() + const signals = new EventEmitter() + let createCode: number | undefined + const workbench = new Workbench(ctx, async argv => createCode = await runCreate({ ...ctx, hub: true }, argv.slice(1), { install: true })) + Object.defineProperty(workbench.session, 'signals', { value: signals }) + ctx.resources.workbench = workbench + // Start from a result page, as when returning to create from an existing project. + workbench.screen = { type: 'result', title: 'PREVIOUS RESULT', lines: [], parent: workbench.hall } + const session = workbench.run() + const command = workbench.command(['create', 'weapp-vite', directory]) + try { + await vi.waitFor(() => expect(install).toHaveBeenCalledTimes(1)) + signals.emit('SIGINT') + expect(workbench.session.suspended).toBe(true) + expect(workbench.busy).toBe(true) + child.emit('close', null, 'SIGTERM') + await command + expect(createCode).toBe(0) + expect(workbench.session.closed).toBe(false) + expect(workbench.session.suspended).toBe(false) + expect(workbench.abortSignal.aborted).toBe(false) + expect(ctx.cancelled).toBeUndefined() + expect(workbench.screen.title).toBe('PROJECT CREATED') + expect(workbench.screen.type === 'result' && workbench.screen.lines.join('\n')).toContain('installation did not finish') + expect(await readFile(path.join(directory, 'package.json'), 'utf8')).toContain('weapp-vite') + } + finally { + workbench.cancel() + child.emit('close', null, 'SIGTERM') + workbench.finish(0) + await command + await session + } + expect(signals.listenerCount('SIGINT')).toBe(0) + expect(signals.listenerCount('SIGTERM')).toBe(0) +}) + +it('preserves global SIGTERM 143 after the project was created and installation was interrupted', async () => { + const { ctx, directory, child, install } = await fixture() + const signals = new EventEmitter() + let createCode: number | undefined + const workbench = new Workbench(ctx, async argv => createCode = await runCreate({ ...ctx, hub: true }, argv.slice(1), { install: true })) + Object.defineProperty(workbench.session, 'signals', { value: signals }) + ctx.resources.workbench = workbench + workbench.screen = { type: 'result', lines: [], parent: workbench.hall } + const session = workbench.run() + const command = workbench.command(['create', 'weapp-vite', directory]) + try { + await vi.waitFor(() => expect(install).toHaveBeenCalledTimes(1)) + signals.emit('SIGTERM') + child.emit('close', null, 'SIGTERM') + await command + expect(await session).toBe(143) + expect(createCode).toBe(143) + expect(workbench.abortSignal.aborted).toBe(true) + expect(workbench.session.closed).toBe(true) + expect(await readFile(path.join(directory, 'package.json'), 'utf8')).toContain('weapp-vite') + } + finally { + workbench.cancel() + child.emit('close', null, 'SIGTERM') + workbench.finish(0) + await command + await session + } +}) + +it('keeps standalone installation cancellation at 130 and waits for process close', async () => { + const { ctx, directory, child, install, text } = await fixture() + const controller = new AbortController() + ctx.operationSignal = controller.signal + let settled = false + const creation = runCreate(ctx, ['weapp-vite', directory], { install: true }).then((code) => { + settled = true + return code + }) + try { + await vi.waitFor(() => expect(install).toHaveBeenCalledTimes(1)) + controller.abort() + await Promise.resolve() + expect(settled).toBe(false) + child.emit('close', null, 'SIGTERM') + expect(await creation).toBe(130) + expect(text()).toContain('Project created; installation did not finish') + expect(await readFile(path.join(directory, 'package.json'), 'utf8')).toContain('weapp-vite') + expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) + } + finally { + controller.abort() + child.emit('close', null, 'SIGTERM') + await creation + } +}) + +it('runs the official scaffolder with its inherited environment and fixed argv', async () => { + const { directory } = await fixture() + const plan = await scaffoldPlan('weapp-vite', directory) + const child = new ChildProcess() + vi.stubEnv('COREPACK_ENABLE_NETWORK', '1') + vi.mocked(spawn).mockReturnValue(child) + const execution = executeScaffolder(plan, '1.2.3') + expect(spawn).toHaveBeenCalledWith('npm', ['exec', '--yes', '--package=create-weapp-vite@1.2.3', '--', 'create-weapp-vite', 'app', 'default', '--no-install-skills'], expect.objectContaining({ cwd: plan.cwd, env: process.env, stdio: 'inherit', shell: false })) + child.emit('close', 0, null) + expect(await execution).toEqual({ code: 0, signal: null }) +}) + +it.skipIf(process.platform === 'win32')('waits for official scaffolder process group cleanup after its parent closes', async () => { + const { directory } = await fixture() + const plan = await scaffoldPlan('weapp-vite', directory) + const child = new ChildProcess() + Object.defineProperty(child, 'pid', { value: 246801 }) + vi.mocked(spawn).mockReturnValue(child) + let groupExists = true + const kill = vi.spyOn(process, 'kill').mockImplementation(() => { + if (!groupExists) { + throw Object.assign(new Error('Process group ended'), { code: 'ESRCH' }) + } + return true + }) + const controller = new AbortController() + let settled = false + const execution = executeScaffolder(plan, '1.2.3', { signal: controller.signal }).catch((error: unknown) => { + settled = true + return error + }) + try { + controller.abort() + child.emit('close', null, 'SIGTERM') + await Promise.resolve() + expect(settled).toBe(false) + expect(kill).toHaveBeenCalledWith(-246801, 'SIGTERM') + groupExists = false + expect(await execution).toMatchObject({ name: 'AbortError' }) + expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) + } + finally { + groupExists = false + controller.abort() + child.emit('close', null, 'SIGTERM') + await execution + } +}) diff --git a/packages/weapp/test/doctor-project.test.ts b/packages/weapp/test/doctor-project.test.ts new file mode 100644 index 0000000..da118f3 --- /dev/null +++ b/packages/weapp/test/doctor-project.test.ts @@ -0,0 +1,137 @@ +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { diagnose } from '../src/commands/doctor.js' + +let directory: string +const ctx = { lang: 'en' as const } +const operations = { nodeVersion: '22.22.2', packageManagerVersion: async () => '10.0.0' } +beforeEach(async () => { + directory = await realpath(await mkdtemp(path.join(os.tmpdir(), 'weapp-doctor-'))) + await mkdir(path.join(directory, '.git')) +}) +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) +async function json(filename: string, value: unknown): Promise { + await mkdir(path.dirname(filename), { recursive: true }) + await writeFile(filename, JSON.stringify(value)) +} + +describe('project-aware doctor', () => { + it('accepts native projects without npm manifests and skips package manager probes', async () => { + await json(path.join(directory, 'app.json'), { pages: ['pages/index'] }) + const probe = vi.fn(operations.packageManagerVersion) + const report = await diagnose(directory, ctx, { ...operations, packageManagerVersion: probe }) + expect(report.ok).toBe(true) + expect(report.checks.find(check => check.id === 'manifest')?.severity).toBe('info') + expect(probe).not.toHaveBeenCalled() + }) + + it('warns about a missing native app.json at the default root without requiring a manifest', async () => { + await json(path.join(directory, 'project.config.json'), {}) + const report = await diagnose(directory, ctx, operations) + expect(report.ok).toBe(true) + expect(report.context.kind).toBe('native') + expect(report.checks.find(check => check.id === 'native-app')).toMatchObject({ severity: 'warning', advice: expect.stringContaining('miniprogramRoot') }) + expect(report.checks.find(check => check.id === 'manifest')?.severity).toBe('info') + }) + + it('resolves hoisted installed versions only inside the verified workspace', async () => { + await json(path.join(directory, 'package.json'), { workspaces: ['packages/*'], packageManager: 'pnpm@10.0.0' }) + await json(path.join(directory, 'packages/app/package.json'), { dependencies: { example: '^2.0.0' } }) + await json(path.join(directory, 'node_modules/example/package.json'), { version: '1.0.0' }) + const report = await diagnose(path.join(directory, 'packages/app'), ctx, operations) + expect(report.checks.find(check => check.id === 'dependencies')?.severity).toBe('ok') + expect(report.checks.find(check => check.id === 'dependency-versions')).toMatchObject({ severity: 'warning', suggestion: { cwd: directory, command: 'pnpm', args: ['install'] } }) + }) + + it('preserves requested-directory JSON semantics while exposing the resolved context', async () => { + await json(path.join(directory, 'package.json'), { name: 'app' }) + const cwd = path.join(directory, 'src/pages') + await mkdir(cwd, { recursive: true }) + const probe = vi.fn(async () => '10.0.0') + const report = await diagnose(cwd, ctx, { ...operations, packageManagerVersion: probe }) + expect(report).toMatchObject({ directory: cwd, requestedDirectory: cwd, projectRoot: directory, context: { kind: 'package', projectRoot: directory } }) + expect(probe).toHaveBeenCalledWith('npm', { cwd: directory }) + }) + + it('does not use dependencies from an unverified ancestor', async () => { + await json(path.join(directory, 'package.json'), {}) + await json(path.join(directory, 'packages/app/package.json'), { dependencies: { example: '^2' } }) + await json(path.join(directory, 'node_modules/example/package.json'), { version: '2.0.0' }) + const report = await diagnose(path.join(directory, 'packages/app'), ctx, operations) + expect(report.checks.find(check => check.id === 'dependencies')).toMatchObject({ severity: 'warning', message: expect.stringContaining('example') }) + }) + + it('does not label protocol, tag, alias, or optional dependencies as missing', async () => { + await json(path.join(directory, 'package.json'), { dependencies: { local: 'workspace:*', catalog: 'catalog:', alias: 'npm:example@^1', git: 'github:example/repo', tagged: 'latest' }, optionalDependencies: { platform: '^1' } }) + const report = await diagnose(directory, ctx, operations) + expect(report.ok).toBe(true) + expect(report.checks.find(check => check.id === 'dependencies')?.severity).toBe('ok') + expect(report.checks.find(check => check.id === 'dependency-versions-unverified')?.message).toContain('workspace:*') + expect(report.checks.find(check => check.id === 'optional-dependencies')?.severity).toBe('info') + expect(report.checks.some(check => check.message.includes('Missing declared'))).toBe(false) + }) + + it('recognizes PnP without executing its loader or reporting node_modules missing', async () => { + await json(path.join(directory, 'package.json'), { packageManager: 'yarn@4.0.0', dependencies: { example: '^1' } }) + await writeFile(path.join(directory, '.pnp.cjs'), `throw new Error('must not execute')`) + const report = await diagnose(directory, ctx, operations) + expect(report.checks.find(check => check.id === 'dependencies')).toMatchObject({ severity: 'info', message: expect.stringContaining('not evaluated') }) + }) + + it('reports package manager version drift and workspace Node incompatibility', async () => { + await json(path.join(directory, 'package.json'), { workspaces: ['packages/*'], packageManager: 'pnpm@9.0.0', engines: { node: '>=24' } }) + await json(path.join(directory, 'packages/app/package.json'), {}) + const report = await diagnose(path.join(directory, 'packages/app'), ctx, operations) + expect(report.ok).toBe(false) + expect(report.checks.find(check => check.id === 'package-manager-version')?.severity).toBe('warning') + expect(report.checks.find(check => check.id === 'workspace-node')?.severity).toBe('error') + }) + + it('checks package manager engines in both the member and workspace', async () => { + await json(path.join(directory, 'package.json'), { workspaces: ['packages/*'], packageManager: 'pnpm@10.0.0', engines: { pnpm: '>=11' } }) + await json(path.join(directory, 'packages/app/package.json'), { engines: { pnpm: ' ' } }) + const report = await diagnose(path.join(directory, 'packages/app'), ctx, operations) + expect(report.ok).toBe(false) + expect(report.checks.find(check => check.id === 'project-engine:pnpm')).toMatchObject({ severity: 'error', evidence: [path.join(directory, 'packages/app/package.json#engines.pnpm')] }) + expect(report.checks.find(check => check.id === 'workspace-engine:pnpm')?.severity).toBe('error') + }) + + it('gives static weapp-vite guidance and framework-specific documentation without executing config', async () => { + await json(path.join(directory, 'package.json'), { scripts: { dev: 'vite --config custom.ts' }, devDependencies: { 'weapp-vite': '^3', '@vue-mini/core': '^1' } }) + await writeFile(path.join(directory, 'custom.ts'), `throw new Error('must not execute')`) + const report = await diagnose(directory, ctx, operations) + expect(report.checks.find(check => check.id === 'weapp-vite-config')).toMatchObject({ severity: 'warning', advice: expect.stringContaining('--config') }) + expect(report.checks.find(check => check.id === 'framework-docs:@vue-mini/core')?.message).toContain('https://vuemini.org/') + await writeFile(path.join(directory, 'vite.config.ts'), `throw new Error('must not execute')`) + const configured = await diagnose(directory, ctx, operations) + expect(configured.checks.some(check => check.id === 'weapp-vite-config')).toBe(false) + expect(configured.checks.find(check => check.id === 'vite-config')?.message).toContain('not executed') + }) + + it('keeps failed package manager probes in the structured report', async () => { + await json(path.join(directory, 'package.json'), {}) + const report = await diagnose(directory, ctx, { + ...operations, + packageManagerVersion: async () => { + throw new Error('probe failed') + }, + }) + expect(report.ok).toBe(false) + expect(report.checks.find(check => check.id === 'package-manager:npm')).toMatchObject({ severity: 'error', evidence: [`npm --version (cwd: ${directory})`] }) + }) + + it('does not suggest install or probe an arbitrary manager when lockfiles conflict', async () => { + await json(path.join(directory, 'package.json'), { dependencies: { missing: '^1' } }) + await writeFile(path.join(directory, 'pnpm-lock.yaml'), '') + await writeFile(path.join(directory, 'yarn.lock'), '') + const probe = vi.fn(operations.packageManagerVersion) + const report = await diagnose(directory, ctx, { ...operations, packageManagerVersion: probe }) + expect(probe).not.toHaveBeenCalled() + expect(report.checks.find(check => check.id === 'dependencies')?.suggestion).toBeUndefined() + expect(report.checks.find(check => check.id === 'package-manager-lockfiles')?.severity).toBe('warning') + }) +}) diff --git a/packages/weapp/test/project-actions.test.ts b/packages/weapp/test/project-actions.test.ts new file mode 100644 index 0000000..ed1f034 --- /dev/null +++ b/packages/weapp/test/project-actions.test.ts @@ -0,0 +1,277 @@ +import type { ProjectContext } from '../src/project/context.js' +import type { ProjectSpawn } from '../src/project/process.js' +import { ChildProcess, execFileSync, spawn as nodeSpawn } from 'node:child_process' +import { getEventListeners } from 'node:events' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { PassThrough } from 'node:stream' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { actionDisplayCommand, executeProjectAction, installedPackageManagerVersion, projectActions, validateProjectAction } from '../src/project/actions.js' + +let directory: string +beforeEach(async () => { + directory = await mkdtemp(path.join(os.tmpdir(), 'weapp-project-actions-')) +}) +afterEach(async () => { + vi.restoreAllMocks() + await rm(directory, { recursive: true, force: true }) +}) + +function context(overrides: Partial = {}): ProjectContext { + return { + requestedDirectory: directory, + projectRoot: directory, + kind: 'package', + manifest: { name: 'fixture', scripts: { dev: 'vite', build: 'vite build' } }, + packageManager: { name: 'npm', source: 'default', conflicts: [] }, + frameworks: [], + configs: [], + lockfiles: [], + issues: [], + ...overrides, + } +} +const availableManager = async (): Promise => '10.9.0' +const sameContext = async (previous: ProjectContext): Promise => previous +function fakeChild(pid = 123456): ChildProcess { + const child = new ChildProcess() + Object.defineProperty(child, 'pid', { value: pid }) + return child +} + +describe('project action planning and validation', () => { + it.each(['npm', 'pnpm', 'yarn', 'bun'] as const)('installs at the verified workspace root and runs %s scripts in the member', (name) => { + const project = context({ + projectRoot: path.join(directory, 'packages/app'), + workspace: { root: directory, memberPath: 'packages/app', evidence: ['pnpm-workspace.yaml'] }, + packageManager: { name, source: 'lockfile', conflicts: [] }, + }) + const actions = projectActions(project) + expect(actions.map(({ kind, cwd, command, args }) => ({ kind, cwd, command, args }))).toEqual([ + { kind: 'install', cwd: directory, command: name, args: ['install'] }, + { kind: 'script', cwd: project.projectRoot, command: name, args: ['run', 'dev'] }, + { kind: 'script', cwd: project.projectRoot, command: name, args: ['run', 'build'] }, + ]) + }) + + it('does not expose actions without a manifest, for native projects, or while manager selection is unresolved', () => { + expect(projectActions(context({ manifest: undefined }))).toEqual([]) + expect(projectActions(context({ kind: 'native' }))).toEqual([]) + expect(projectActions(context({ kind: 'invalid' }))).toEqual([]) + expect(projectActions(context({ packageManager: { source: 'unresolved', conflicts: [] } }))).toEqual([]) + }) + + it('stops conflicting declarations before even probing a manager', async () => { + const project = context({ packageManager: { name: 'npm', source: 'declaration', conflicts: [{ id: 'manager-conflict', severity: 'error', message: 'Conflicting package managers' }] } }) + const query = vi.fn(availableManager) + const spawn = vi.fn() + const result = await executeProjectAction(projectActions(project)[0], { packageManagerVersion: query, spawn }) + expect(result).toMatchObject({ status: 'failed', errors: [{ id: 'manager-conflict' }] }) + expect(query).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() + }) + + it.each(['>=24', 'definitely-not-semver', 24])('blocks unmet or invalid member Node requirements: %s', async (requirement) => { + const project = context({ manifest: { engines: { node: requirement } } }) + const query = vi.fn(availableManager) + const result = await validateProjectAction(projectActions(project)[0], { nodeVersion: '18.20.8', packageManagerVersion: query }) + expect(result.valid).toBe(false) + expect(result.errors[0].id).toBe('engine:package:node') + expect(query).not.toHaveBeenCalled() + }) + + it('also honors workspace Node and package manager engines', async () => { + const project = context({ workspace: { root: directory, memberPath: 'app', evidence: ['package.json'], manifest: { engines: { node: '>=18', npm: '>=11' } } } }) + const result = await validateProjectAction(projectActions(project)[0], { nodeVersion: '18.20.8', packageManagerVersion: availableManager }) + expect(result.errors.map(error => error.id)).toContain('engine:workspace:npm') + }) + + it('requires the declared manager version and refuses unavailable managers', async () => { + const project = context({ packageManager: { name: 'pnpm', version: '10.0.0', source: 'declaration', conflicts: [] } }) + const action = projectActions(project)[0] + expect((await validateProjectAction(action, { packageManagerVersion: availableManager })).errors[0].id).toBe('package-manager:version') + expect((await validateProjectAction(action, { packageManagerVersion: async () => null })).errors[0].id).toBe('package-manager:unavailable') + expect((await validateProjectAction(action, { packageManagerVersion: async () => '10.0.0' })).valid).toBe(true) + }) + + it('rejects altered cwd/argv and option-like script names before execution', async () => { + const action = projectActions(context())[0] + expect((await validateProjectAction({ ...action, cwd: path.dirname(directory) }, { packageManagerVersion: availableManager })).errors[0].id).toBe('action:invalid') + expect((await validateProjectAction({ ...action, args: ['install', '--global'] }, { packageManagerVersion: availableManager })).errors[0].id).toBe('action:invalid') + const script = projectActions(context({ manifest: { scripts: { '--version': 'echo nope' } } }))[1] + expect((await validateProjectAction(script, { packageManagerVersion: availableManager })).errors[0].id).toBe('script:invalid') + }) + + it('preserves script arguments as one value and quotes only their display', async () => { + const action = projectActions(context({ manifest: { scripts: { 'test; touch unexpected': 'node test.js' } } }))[1] + const spawn = vi.fn(() => { + const child = fakeChild() + setImmediate(() => child.emit('close', 0, null)) + return child + }) + expect(actionDisplayCommand(action)).toBe('npm run \'test; touch unexpected\'') + expect(await executeProjectAction(action, { packageManagerVersion: availableManager, refreshContext: sameContext, spawn })).toMatchObject({ status: 'success', code: 0 }) + expect(spawn).toHaveBeenCalledWith('npm', ['run', 'test; touch unexpected'], expect.objectContaining({ + cwd: directory, + stdio: 'inherit', + shell: false, + env: expect.objectContaining({ COREPACK_ENABLE_NETWORK: '0', COREPACK_ENABLE_AUTO_PIN: '0', npm_config_manage_package_manager_versions: 'false' }), + })) + }) + + it('refuses a script changed on disk during compatibility checks and requires fresh confirmation', async () => { + const { discoverProject } = await import('../src/project/context.js') + const filename = path.join(directory, 'package.json') + await writeFile(filename, JSON.stringify({ scripts: { dev: 'node approved.js' } })) + const project = await discoverProject(directory) + const spawn = vi.fn() + const result = await executeProjectAction(projectActions(project)[1], { + packageManagerVersion: async () => { + await writeFile(filename, JSON.stringify({ scripts: { dev: 'node changed.js' } })) + return '10.9.0' + }, + spawn, + }) + expect(result).toMatchObject({ status: 'failed', errors: [{ id: 'action:changed' }] }) + expect(spawn).not.toHaveBeenCalled() + }) + + it('does not run after cancellation during context refresh', async () => { + const controller = new AbortController() + const spawn = vi.fn() + const result = await executeProjectAction(projectActions(context())[0], { + packageManagerVersion: availableManager, + refreshContext: async (previous) => { + controller.abort() + return previous + }, + signal: controller.signal, + spawn, + }) + expect(result.status).toBe('cancelled') + expect(spawn).not.toHaveBeenCalled() + }) +}) + +describe('package manager process lifecycle', () => { + it('probes only --version with Corepack downloads and pnpm automatic switching disabled', async () => { + const spawn = vi.fn(() => { + const child = fakeChild() + child.stdout = new PassThrough() + setImmediate(() => { + child.stdout!.emit('data', '10.9.0\n') + child.emit('close', 0, null) + }) + return child + }) + expect(await installedPackageManagerVersion('npm', { cwd: directory, spawn })).toBe('10.9.0') + expect(spawn).toHaveBeenCalledWith('npm', ['--version'], expect.objectContaining({ + cwd: directory, + stdio: ['ignore', 'pipe', 'pipe'], + env: expect.objectContaining({ COREPACK_ENABLE_NETWORK: '0', COREPACK_ENABLE_AUTO_PIN: '0', npm_config_manage_package_manager_versions: 'false' }), + })) + }) + + it('does not launch a process for an already cancelled action', async () => { + const controller = new AbortController() + controller.abort() + const spawn = vi.fn() + expect(await executeProjectAction(projectActions(context())[0], { signal: controller.signal, spawn })).toMatchObject({ status: 'cancelled', code: 130 }) + expect(spawn).not.toHaveBeenCalled() + }) + + it('times out a stalled version query, terminates its group, and removes cancellation listeners', async () => { + const child = fakeChild() + const controller = new AbortController() + const kill = vi.fn((_pid: number, signal: NodeJS.Signals | number) => { + if (signal === 'SIGTERM') { + setImmediate(() => child.emit('close', null, 'SIGTERM')) + } + return true + }) + expect(await installedPackageManagerVersion('npm', { cwd: directory, spawn: () => child, platform: 'linux', kill, probeTimeoutMs: 10, terminateGraceMs: 0, signal: controller.signal })).toBeNull() + expect(kill).toHaveBeenCalledWith(-123456, 'SIGTERM') + expect(kill).toHaveBeenCalledWith(-123456, 'SIGKILL') + expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) + }) + + it('returns failures for nonzero exits and spawn errors, waiting for close', async () => { + for (const code of [7, null]) { + const spawn = vi.fn(() => { + const child = fakeChild() + setImmediate(() => { + if (code === null) { + child.emit('error', new Error('https://user:secret@example.invalid/')) + } + child.emit('close', code, null) + }) + return child + }) + const result = await executeProjectAction(projectActions(context())[0], { packageManagerVersion: availableManager, refreshContext: sameContext, spawn }) + expect(result.status).toBe('failed') + expect(result.code).toBe(code || 1) + expect(JSON.stringify(result)).not.toContain('secret') + } + }) + + it('uses Windows tree termination and waits for both taskkill and original close', async () => { + const controller = new AbortController() + const command = fakeChild(12345) + const taskkill = fakeChild(54321) + const spawn = vi.fn(name => name === 'taskkill' ? taskkill : command) + let settled = false + const result = executeProjectAction(projectActions(context())[0], { packageManagerVersion: availableManager, refreshContext: sameContext, spawn, platform: 'win32', signal: controller.signal }).then((value) => { + settled = true + return value + }) + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(1)) + controller.abort() + expect(spawn).toHaveBeenLastCalledWith('taskkill', ['/PID', '12345', '/T', '/F'], expect.objectContaining({ shell: false, stdio: 'ignore' })) + command.emit('close', null, 'SIGTERM') + await Promise.resolve() + expect(settled).toBe(false) + taskkill.emit('close', 0, null) + expect(await result).toMatchObject({ status: 'cancelled', code: 130 }) + expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) + }) + + it.skipIf(process.platform === 'win32')('terminates real lifecycle grandchildren that ignore SIGTERM before returning', async () => { + const fixture = path.join(directory, 'manager.cjs') + const descendantFile = path.join(directory, 'descendant.pid') + await mkdir(path.join(directory, 'app')) + await writeFile(fixture, ` +const { spawn } = require('node:child_process') +const child = spawn(process.execPath, ['-e', "process.on('SIGTERM', () => {}); require('node:fs').writeFileSync(process.argv[1], String(process.pid)); setInterval(() => {}, 1000)", process.argv[2]], { stdio: 'inherit' }) +process.on('SIGTERM', () => {}) +child.on('exit', () => process.exit(0)) +setInterval(() => {}, 1000) +`) + const controller = new AbortController() + const spawn: ProjectSpawn = (_command, _args, options) => nodeSpawn(process.execPath, [fixture, descendantFile], options) + const execution = executeProjectAction(projectActions(context())[0], { packageManagerVersion: availableManager, refreshContext: sameContext, spawn, signal: controller.signal, terminateGraceMs: 40 }) + try { + let descendant = 0 + await vi.waitFor(async () => { + descendant = Number(await readFile(descendantFile, 'utf8')) + expect(descendant).toBeGreaterThan(0) + }) + controller.abort() + expect(await execution).toMatchObject({ status: 'cancelled', code: 130 }) + await vi.waitFor(() => { + let running = false + try { + running = !execFileSync('ps', ['-p', String(descendant), '-o', 'stat='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().startsWith('Z') + } + catch { /* The descendant no longer exists. */ } + expect(running).toBe(false) + }) + expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) + } + finally { + controller.abort() + await execution + } + }) +}) diff --git a/packages/weapp/test/project-context.test.ts b/packages/weapp/test/project-context.test.ts new file mode 100644 index 0000000..c3765f1 --- /dev/null +++ b/packages/weapp/test/project-context.test.ts @@ -0,0 +1,189 @@ +import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { discoverProject } from '../src/project/context.js' + +let directory: string +beforeEach(async () => { + directory = await realpath(await mkdtemp(path.join(os.tmpdir(), 'weapp-project-'))) + await mkdir(path.join(directory, '.git')) +}) +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) +async function json(filename: string, value: unknown): Promise { + await mkdir(path.dirname(filename), { recursive: true }) + await writeFile(filename, JSON.stringify(value)) +} + +describe('read-only project discovery', () => { + it('finds the nearest package from a nested directory and keeps the requested directory', async () => { + await json(path.join(directory, 'package.json'), { name: 'root' }) + await json(path.join(directory, 'packages/app/package.json'), { name: 'app', scripts: { dev: 'vite' } }) + const cwd = path.join(directory, 'packages/app/src/components') + await mkdir(cwd, { recursive: true }) + const project = await discoverProject(cwd) + expect(project).toMatchObject({ requestedDirectory: cwd, projectRoot: path.join(directory, 'packages/app'), kind: 'package', manifest: { name: 'app', scripts: { dev: 'vite' } } }) + expect(project.workspace).toBeUndefined() + }) + + it('does not cross a Git boundary to inherit an unrelated package', async () => { + await json(path.join(directory, 'package.json'), { name: 'outside' }) + const cwd = path.join(directory, 'nested-repository/src') + await mkdir(path.join(directory, 'nested-repository/.git'), { recursive: true }) + await mkdir(cwd) + expect(await discoverProject(cwd)).toMatchObject({ kind: 'unknown', projectRoot: cwd }) + }) + + it('keeps a damaged nearest package invalid instead of selecting an ancestor or creation', async () => { + await json(path.join(directory, 'package.json'), { name: 'parent' }) + const app = path.join(directory, 'app') + await mkdir(app) + await writeFile(path.join(app, 'package.json'), '{') + const project = await discoverProject(app) + expect(project).toMatchObject({ kind: 'invalid', projectRoot: app }) + expect(project.issues).toContainEqual(expect.objectContaining({ id: 'manifest', severity: 'error' })) + }) + + it('recognizes native and mixed projects without requiring framework dependencies', async () => { + await json(path.join(directory, 'project.config.json'), { miniprogramRoot: 'mini/' }) + await json(path.join(directory, 'mini/app.json'), { pages: ['pages/index'] }) + const native = await discoverProject(path.join(directory, 'mini')) + expect(native).toMatchObject({ kind: 'native', projectRoot: directory }) + expect(native.configs).toContainEqual({ path: 'mini/app.json', kind: 'json', status: 'valid' }) + await json(path.join(directory, 'package.json'), { name: 'native-with-tools' }) + expect(await discoverProject(directory)).toMatchObject({ kind: 'mixed', manifest: { name: 'native-with-tools' } }) + }) + + it('keeps a nested native app independent of an unrelated parent package and its scripts', async () => { + await json(path.join(directory, 'package.json'), { name: 'workspace-root', workspaces: ['packages/*'], packageManager: 'pnpm@10.0.0', scripts: { dev: 'node parent.js' } }) + const native = path.join(directory, 'packages/native') + await json(path.join(native, 'app.json'), { pages: ['pages/index'] }) + const project = await discoverProject(native) + expect(project).toMatchObject({ kind: 'native', projectRoot: native }) + expect(project.manifest).toBeUndefined() + expect(project.workspace).toBeUndefined() + expect(project.packageManager.source).toBe('default') + }) + + it('absorbs a native source app only when the parent identifies its conventional path', async () => { + await json(path.join(directory, 'package.json'), { name: 'native-with-tools', scripts: { dev: 'vite' } }) + await json(path.join(directory, 'src/app.json'), { pages: ['pages/index'] }) + expect(await discoverProject(path.join(directory, 'src'))).toMatchObject({ kind: 'mixed', projectRoot: directory, manifest: { scripts: { dev: 'vite' } } }) + }) + + it('does not let an unrelated non-mini-program app.json hide its parent Node package', async () => { + await json(path.join(directory, 'package.json'), { name: 'node-project' }) + await json(path.join(directory, 'nested/app.json'), { name: 'application-metadata' }) + expect(await discoverProject(path.join(directory, 'nested'))).toMatchObject({ kind: 'package', projectRoot: directory, manifest: { name: 'node-project' } }) + }) + + it('does not read a configured native root through an escaping symlink', async () => { + const app = path.join(directory, 'app') + await json(path.join(directory, 'outside/app.json'), { pages: [] }) + await json(path.join(app, 'project.config.json'), { miniprogramRoot: 'linked' }) + await symlink(path.join(directory, 'outside'), path.join(app, 'linked'), process.platform === 'win32' ? 'junction' : 'dir') + const project = await discoverProject(app) + expect(project.issues).toContainEqual(expect.objectContaining({ id: 'native-root' })) + expect(project.configs.some(config => config.path.includes('linked'))).toBe(false) + }) + + it('validates pnpm member globs and exclusions before inheriting root settings', async () => { + await json(path.join(directory, 'package.json'), { packageManager: 'pnpm@10.0.0' }) + await writeFile(path.join(directory, 'pnpm-workspace.yaml'), 'packages:\n - "{apps,packages}/*"\n - "!packages/excluded"\n') + await writeFile(path.join(directory, 'pnpm-lock.yaml'), 'lockfileVersion: 9') + await json(path.join(directory, 'packages/app/package.json'), { name: 'member' }) + await json(path.join(directory, 'packages/excluded/package.json'), { name: 'excluded' }) + const member = await discoverProject(path.join(directory, 'packages/app')) + expect(member.workspace).toMatchObject({ root: directory, memberPath: 'packages/app', evidence: ['pnpm-workspace.yaml'] }) + expect(member.packageManager).toMatchObject({ name: 'pnpm', version: '10.0.0', source: 'declaration', conflicts: [] }) + const excluded = await discoverProject(path.join(directory, 'packages/excluded')) + expect(excluded.workspace).toBeUndefined() + expect(excluded.packageManager).toMatchObject({ name: 'npm', source: 'default' }) + expect(excluded.lockfiles).toEqual([]) + }) + + it.each([['packages/*'], { packages: ['packages/*'], nohoist: ['**/react-native'] }])('supports npm and Yarn workspace declarations: %j', async (workspaces) => { + await json(path.join(directory, 'package.json'), { workspaces, packageManager: 'yarn@4.0.0', engines: { node: '>=20' } }) + await json(path.join(directory, 'packages/app/package.json'), { name: 'app' }) + const project = await discoverProject(path.join(directory, 'packages/app')) + expect(project.workspace).toMatchObject({ root: directory, memberPath: 'packages/app', manifest: { engines: { node: '>=20' } } }) + expect(project.packageManager.name).toBe('yarn') + }) + + it('does not treat a package symlink escaping a workspace as a member', async () => { + const workspace = path.join(directory, 'workspace') + const outside = path.join(directory, 'outside') + await json(path.join(workspace, 'package.json'), { workspaces: ['packages/*'], packageManager: 'pnpm@10.0.0' }) + await json(path.join(outside, 'package.json'), { name: 'outside' }) + await mkdir(path.join(workspace, 'packages')) + await symlink(outside, path.join(workspace, 'packages/linked'), process.platform === 'win32' ? 'junction' : 'dir') + const project = await discoverProject(path.join(workspace, 'packages/linked')) + expect(project.projectRoot).toBe(outside) + expect(project.workspace).toBeUndefined() + }) + + it('reports invalid workspace declarations without inheriting their manager', async () => { + await json(path.join(directory, 'package.json'), { packageManager: 'pnpm@10.0.0' }) + await writeFile(path.join(directory, 'pnpm-workspace.yaml'), 'packages: [') + await json(path.join(directory, 'packages/app/package.json'), {}) + const project = await discoverProject(path.join(directory, 'packages/app')) + expect(project.workspace).toBeUndefined() + expect(project.packageManager).toMatchObject({ name: 'npm', source: 'default' }) + expect(project.issues).toContainEqual(expect.objectContaining({ id: 'workspace' })) + }) + + it('rejects oversized or non-object package manifests', async () => { + await writeFile(path.join(directory, 'package.json'), ' '.repeat(1024 * 1024 + 1)) + expect((await discoverProject(directory)).kind).toBe('invalid') + await writeFile(path.join(directory, 'package.json'), '[]') + expect((await discoverProject(directory)).kind).toBe('invalid') + }) + + it('never executes project configuration or scripts during discovery', async () => { + const marker = path.join(directory, 'executed') + await json(path.join(directory, 'package.json'), { scripts: { preinstall: `touch ${marker}` } }) + await writeFile(path.join(directory, 'vite.config.js'), `throw new Error('must not execute')`) + const project = await discoverProject(directory) + expect(project.configs).toContainEqual({ path: 'vite.config.js', kind: 'vite', status: 'detected' }) + await expect(readFile(marker)).rejects.toHaveProperty('code', 'ENOENT') + }) +}) + +describe('package manager resolution', () => { + it('leaves multiple lockfile managers unresolved until an explicit choice', async () => { + await json(path.join(directory, 'package.json'), {}) + await writeFile(path.join(directory, 'pnpm-lock.yaml'), '') + await writeFile(path.join(directory, 'yarn.lock'), '') + const unresolved = await discoverProject(directory) + expect(unresolved.packageManager.name).toBeUndefined() + expect(unresolved.packageManager.conflicts).toHaveLength(1) + const explicit = await discoverProject(directory, { packageManager: 'pnpm' }) + expect(explicit.packageManager).toEqual({ name: 'pnpm', source: 'explicit', conflicts: [] }) + expect(explicit.lockfiles).toHaveLength(2) + }) + + it('uses a declaration despite other locks and rejects a conflicting explicit choice', async () => { + await json(path.join(directory, 'package.json'), { packageManager: 'pnpm@10.0.0+sha512.abcdef' }) + await writeFile(path.join(directory, 'yarn.lock'), '') + expect((await discoverProject(directory)).packageManager).toMatchObject({ name: 'pnpm', version: '10.0.0+sha512.abcdef', source: 'declaration', conflicts: [] }) + expect((await discoverProject(directory, { packageManager: 'npm' })).packageManager.conflicts).toContainEqual(expect.objectContaining({ id: 'package-manager-explicit' })) + }) + + it('keeps conflicting root/member declarations visible even with an explicit choice', async () => { + await json(path.join(directory, 'package.json'), { workspaces: ['packages/*'], packageManager: 'pnpm@10.0.0' }) + await json(path.join(directory, 'packages/app/package.json'), { packageManager: 'pnpm@9.0.0' }) + const project = await discoverProject(path.join(directory, 'packages/app'), { packageManager: 'pnpm' }) + expect(project.packageManager).toMatchObject({ name: 'pnpm', version: '10.0.0', source: 'declaration' }) + expect(project.packageManager.conflicts).toContainEqual(expect.objectContaining({ id: 'package-manager-workspace', severity: 'error' })) + }) + + it.each(['pnpm@latest', 'pnpm@^10.0.0', 'pnpm@v10.0.0', 'other@1.0.0', 42])('does not silently replace an invalid declaration: %s', async (packageManager) => { + await json(path.join(directory, 'package.json'), { packageManager }) + const project = await discoverProject(directory, { packageManager: 'npm' }) + expect(project.packageManager.name).toBeUndefined() + expect(project.packageManager.conflicts).toContainEqual(expect.objectContaining({ id: 'package-manager-declaration', severity: 'error' })) + }) +}) diff --git a/packages/weapp/test/startup.test.ts b/packages/weapp/test/startup.test.ts index c2cc251..7c1f5fb 100644 --- a/packages/weapp/test/startup.test.ts +++ b/packages/weapp/test/startup.test.ts @@ -26,7 +26,7 @@ beforeAll(async () => { } const result = await next(specifier, context); const filename = result.url.split('/').at(-1); - const feature = filename.match(/^(catalog|create|doctor|lab|palette|qr|terminal|session)(?:-[\\w-]+)?\\.js$/)?.[1]; + const feature = filename.match(/^(catalog|create|doctor|lab|palette|qr|terminal|session|context|actions|process)(?:-[\\w-]+)?\\.js$/)?.[1]; if (feature && !allowedFeatures.has(feature)) { throw new Error('Startup loaded an unrelated feature: ' + feature); } @@ -83,7 +83,7 @@ function guarded(args: string[], { catalog = false, execution = false, hall = fa NO_COLOR: '1', WEAPP_TEST_ALLOW_CATALOG: catalog ? '1' : '0', WEAPP_TEST_ALLOW_VISUAL: hall ? '1' : '0', - WEAPP_TEST_ALLOWED_FEATURES: JSON.stringify(hall ? ['terminal', 'session', 'catalog'] : args.includes('--help') ? [] : [feature]), + WEAPP_TEST_ALLOWED_FEATURES: JSON.stringify(hall ? ['terminal', 'session', 'catalog', 'context'] : args.includes('--help') ? [] : feature === 'doctor' ? ['doctor', 'context', 'actions', 'process'] : feature === 'create' ? ['create', 'process'] : [feature]), WEAPP_TEST_DENIED_IMPORTS: JSON.stringify(['open', 'http', 'https', ...(hall ? [] : ['readline']), ...(execution ? [] : ['child_process'])]), }, }) diff --git a/packages/weapp/test/terminal.test.ts b/packages/weapp/test/terminal.test.ts index 62acc15..dcb2709 100644 --- a/packages/weapp/test/terminal.test.ts +++ b/packages/weapp/test/terminal.test.ts @@ -98,7 +98,19 @@ function terminal({ raw = false, paused = true, motion = true, width = 80, heigh output.columns = width output.rows = height const signals = new EventEmitter() - const ctx: CliContext = { lang: 'en', stdin: input, stdout: output, stderr: output, color: false, motion, interactive: true, catalog: catalog(), resources: {}, openUrl: async () => undefined } + const ctx: CliContext = { + lang: 'en', + stdin: input, + stdout: output, + stderr: output, + color: false, + motion, + interactive: true, + catalog: catalog(), + resources: {}, + openUrl: async () => undefined, + discoverProject: async (directory = '/empty-project') => ({ requestedDirectory: directory, projectRoot: directory, kind: 'unknown', packageManager: { source: 'unresolved', conflicts: [] }, frameworks: [], configs: [], lockfiles: [], issues: [] }), + } const key = (name: string, value = '', extra: Key = {}) => input.emit('keypress', value, { name, ...extra }) return { input, output, signals, ctx, key } } @@ -227,7 +239,8 @@ describe('terminal ownership', () => { const io = terminal({ width: 12, height: 3 }) const session = new TerminalSession(io.ctx, { signals: io.signals }) let selection = 0 - const render = (owner: TerminalSession) => owner.render([`绿色工具箱 / WEAPP ${selection}`]) + const frame = vi.fn(() => [`绿色工具箱 / WEAPP ${selection}`]) + const render = (owner: TerminalSession) => owner.render(frame) const result = session.run({ render, key: (_value, key, finish) => { if (key.name === 'escape') { finish(0) @@ -244,10 +257,12 @@ describe('terminal ownership', () => { io.output.emit('resize') } expect(io.output.frames()).toHaveLength(1) + expect(frame).toHaveBeenCalledTimes(1) await vi.advanceTimersByTimeAsync(49) expect(io.output.frames()).toHaveLength(1) await vi.advanceTimersByTimeAsync(1) expect(io.output.frames()).toHaveLength(2) + expect(frame).toHaveBeenCalledTimes(2) expect(io.output.frame()).toContain('绿色工具箱 / WEAPP 50') io.key('escape') await result @@ -291,6 +306,139 @@ describe('terminal ownership', () => { expect(io.output.listenerCount('drain')).toBe(0) }) + it('keeps ticking under backpressure but evaluates only the latest frame at the current size', async () => { + fakeClock() + const io = terminal({ width: 12 }) + const write = io.output.write.getMockImplementation()! + io.output.write.mockImplementation(value => write(value) && !value.includes('Tick 0')) + const session = new TerminalSession(io.ctx, { signals: io.signals }) + let tick = 0 + const frame = vi.fn(() => [`Tick ${tick} / latest terminal dimensions`]) + const result = session.run({ + render: owner => owner.render(frame), + tick: () => { tick++ }, + key: (_value, _key, finish) => finish(0), + }) + await vi.advanceTimersByTimeAsync(250) + expect(tick).toBe(5) + expect(frame).toHaveBeenCalledTimes(1) + io.output.columns = 80 + io.output.emit('resize') + expect(frame).toHaveBeenCalledTimes(1) + expect(io.output.listenerCount('drain')).toBe(1) + io.output.emit('drain') + expect(frame).toHaveBeenCalledTimes(2) + expect(io.output.frame()).toBe('Tick 5 / latest terminal dimensions') + io.key('q') + await result + }) + + it('defers producers before opening and discards queued work on close', async () => { + fakeClock() + const io = terminal() + const session = new TerminalSession(io.ctx, { signals: io.signals }) + const stale = vi.fn(() => ['Stale']) + const ready = vi.fn(() => ['Ready']) + const after = vi.fn(() => ['After close']) + session.render(stale) + expect(stale).not.toHaveBeenCalled() + const result = session.run({ render: owner => owner.render(ready), key: (_value, _key, finish) => finish(0) }) + expect(ready).toHaveBeenCalledOnce() + expect(stale).not.toHaveBeenCalled() + session.render(after) + io.key('q') + await result + await vi.advanceTimersByTimeAsync(1000) + expect(after).not.toHaveBeenCalled() + expect(session.pending).toBeNull() + expect(vi.getTimerCount()).toBe(0) + }) + + it('skips unchanged ticks without suppressing duration timeouts', async () => { + fakeClock() + const io = terminal() + const session = new TerminalSession(io.ctx, { signals: io.signals }) + const frame = vi.fn(() => ['Paused']) + const tick = vi.fn(() => false) + const result = session.run({ + start: (finish, owner) => { owner.timeout(() => finish(0), 250) }, + render: owner => owner.render(frame), + tick, + }) + await vi.advanceTimersByTimeAsync(250) + expect(await result).toBe(0) + expect(tick).toHaveBeenCalled() + expect(frame).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + }) + + it('handles external interrupts locally only while suspended', async () => { + const io = terminal() + const session = new TerminalSession(io.ctx, { signals: io.signals }) + const handler = vi.fn(() => true) + const result = session.run({ render: owner => owner.render(['Ready']) }) + session.externalInterrupt = handler + session.suspend() + io.signals.emit('SIGINT') + expect(handler).toHaveBeenCalledWith('SIGINT') + expect(session.closed).toBe(false) + expect(io.ctx.cancelled).toBeUndefined() + session.resume() + io.signals.emit('SIGINT') + expect(await result).toBe(130) + expect(handler).toHaveBeenCalledOnce() + expect(session.externalInterrupt).toBeUndefined() + expect(io.input.isRaw).toBe(false) + expect(io.signals.eventNames()).toEqual([]) + }) + + it('preserves global termination when the external handler declines', async () => { + const io = terminal() + const session = new TerminalSession(io.ctx, { signals: io.signals }) + const result = session.run({}) + const handler = vi.fn(() => false) + session.externalInterrupt = handler + session.suspend() + io.signals.emit('SIGTERM') + expect(await result).toBe(143) + expect(handler).toHaveBeenCalledWith('SIGTERM') + expect(io.ctx.cancelledCode).toBe(143) + expect(io.input.isRaw).toBe(false) + }) + + it('restores the terminal when an external interrupt handler throws', async () => { + const io = terminal() + const session = new TerminalSession(io.ctx, { signals: io.signals }) + const result = session.run({}) + const assertion = expect(result).rejects.toThrow('External interrupt failed') + session.suspend() + session.externalInterrupt = () => { + throw new Error('External interrupt failed') + } + expect(() => io.signals.emit('SIGINT')).not.toThrow() + await assertion + expect(io.input.isRaw).toBe(false) + expect(io.input.listenerCount('keypress')).toBe(0) + expect(io.signals.eventNames()).toEqual([]) + }) + + it('restores the terminal when a deferred frame producer throws', async () => { + fakeClock() + const io = terminal() + const session = new TerminalSession(io.ctx, { signals: io.signals }) + const result = session.run({ render: owner => owner.render(['Initial']) }) + const assertion = expect(result).rejects.toThrow('Deferred frame failed') + session.render(() => { + throw new Error('Deferred frame failed') + }) + await nextFrame() + await assertion + expect(io.input.isRaw).toBe(false) + expect(io.input.listenerCount('keypress')).toBe(0) + expect(session.pending).toBeNull() + expect(vi.getTimerCount()).toBe(0) + }) + it('restores input when a delayed drain flush throws', async () => { fakeClock() const io = terminal() @@ -326,7 +474,7 @@ describe('terminal ownership', () => { }) describe('live workbench', () => { - it('shows actions in the first frame and sends the first Enter directly to creation', async () => { + it('shows actions in the first frame and sends C directly to creation during discovery', async () => { fakeClock() const io = terminal() const dispatch = vi.fn(async (argv: string[]) => { @@ -340,7 +488,7 @@ describe('live workbench', () => { expect(io.output.frame()).toContain('Create with weapp-vite') expect(io.output.frame()).not.toContain('Press any key') expect(io.output.frame()).not.toMatch(/snake|game/i) - io.key('return') + io.key('c', 'c') await nextFrame() expect(dispatch).toHaveBeenCalledTimes(1) expect(alternateCount(io.output)).toBe(1) @@ -354,6 +502,7 @@ describe('live workbench', () => { const io = terminal({ motion: false }) const dispatch = vi.fn(async () => 0) const result = runHub(io.ctx, dispatch) + await nextFrame() io.key('c', 'c') await nextFrame() expect(dispatch).toHaveBeenCalledExactlyOnceWith(['create']) @@ -372,6 +521,7 @@ describe('live workbench', () => { fakeClock() const io = terminal({ motion: false }) const result = runHub(io.ctx, vi.fn()) + await nextFrame() const workbench = io.ctx.resources.workbench! let rejectPreparation: (reason: Error) => void = () => {} const preparation = new Promise((_resolve, reject) => { @@ -403,6 +553,7 @@ describe('live workbench', () => { fakeClock() const io = terminal() const result = runHub(io.ctx, vi.fn()) + await nextFrame() const workbench = io.ctx.resources.workbench! workbench.setCreateProgress({ phase: 'querying', packageName: 'create-weapp-vite', cwd: '/tmp' }) await vi.advanceTimersByTimeAsync(350) @@ -425,6 +576,7 @@ describe('live workbench', () => { release = resolve }) }) + await nextFrame() const workbench = io.ctx.resources.workbench! io.key('return') io.key('escape') @@ -444,6 +596,7 @@ describe('live workbench', () => { fakeClock() const io = terminal() const result = runHub(io.ctx, vi.fn()) + await nextFrame() io.key('x', 'x') await nextFrame() const initial = io.output.frame() @@ -467,6 +620,7 @@ describe('live workbench', () => { fakeClock() const io = terminal() const result = runHub(io.ctx, vi.fn()) + await nextFrame() io.key('x', 'x') io.key('down') io.key('return') @@ -486,11 +640,67 @@ describe('live workbench', () => { await result }) + it('does not compute paused frames and still reacts to selection and resize', async () => { + fakeClock() + const io = terminal({ width: 120, height: 30 }) + const result = runHub(io.ctx, vi.fn()) + await nextFrame() + const workbench = io.ctx.resources.workbench! + const frame = vi.spyOn(workbench, 'frame') + io.key('space', ' ') + await nextFrame() + frame.mockClear() + await vi.advanceTimersByTimeAsync(1000) + expect(frame).not.toHaveBeenCalled() + io.key('down') + await nextFrame() + expect(frame).toHaveBeenCalledTimes(1) + expect(io.output.frame()).toContain('› Explore the weapp ecosystem') + io.output.columns = 44 + io.output.rows = 18 + io.output.emit('resize') + await nextFrame() + expect(frame).toHaveBeenCalledTimes(2) + const frozenTime = workbench.time + io.key('space', ' ') + await nextFrame() + expect(workbench.time - frozenTime).toBeLessThanOrEqual(0.05) + expect(frame.mock.calls.length).toBeGreaterThan(2) + io.key('q', 'q') + await result + }) + + it('reuses the current static layout while animation continues', async () => { + fakeClock() + const io = terminal({ width: 120, height: 30 }) + const result = runHub(io.ctx, vi.fn()) + await nextFrame() + const workbench = io.ctx.resources.workbench! + const entries = vi.spyOn(workbench, 'entries') + const initial = io.output.frame() + await vi.advanceTimersByTimeAsync(300) + expect(io.output.frame()).not.toBe(initial) + expect(entries).not.toHaveBeenCalled() + io.key('down') + await nextFrame() + expect(io.output.frame()).toContain('› Explore the weapp ecosystem') + entries.mockClear() + await vi.advanceTimersByTimeAsync(300) + expect(entries).not.toHaveBeenCalled() + io.output.columns = 44 + io.output.emit('resize') + await nextFrame() + expect(entries).toHaveBeenCalledOnce() + io.key('q', 'q') + await result + }) + it('shares one alternate screen across hall, project details, and a nested wizard', async () => { fakeClock() const io = terminal({ motion: false }) const dispatch = vi.fn(() => runCreateWizard(io.ctx)) const result = runHub(io.ctx, dispatch) + await nextFrame() io.key('down') io.key('return') io.key('return') @@ -514,6 +724,7 @@ describe('live workbench', () => { fakeClock() const io = terminal({ motion: false, width: 44, height: 18 }) const result = runHub(io.ctx, vi.fn()) + await nextFrame() expect(vi.getTimerCount()).toBe(0) const stable = io.output.frame() await vi.advanceTimersByTimeAsync(1000) @@ -541,6 +752,7 @@ describe('live workbench', () => { return 0 }) const result = runHub(io.ctx, dispatch) + await nextFrame() io.key('up') io.key('return') await nextFrame() @@ -633,6 +845,7 @@ describe('live workbench', () => { const io = terminal({ motion: false }) const workbench = new Workbench(io.ctx, vi.fn()) const running = workbench.run() + await nextFrame() const original = workbench.screen let release: () => void = () => {} const callback = vi.fn(() => { @@ -668,6 +881,7 @@ describe('live workbench', () => { }) }) const result = runHub(io.ctx, dispatch) + await nextFrame() const workbench = io.ctx.resources.workbench! io.key('return') expect(signal!.aborted).toBe(false) @@ -688,6 +902,7 @@ describe('live workbench', () => { const io = terminal({ motion: false }) const dispatch = vi.fn(() => runFx({ ...io.ctx }, ['orbit'])) const result = runHub(io.ctx, dispatch) + await nextFrame() io.key('up') io.key('return') await nextFrame() @@ -792,6 +1007,7 @@ describe('interactive creation wizard', () => { const target = await directory() const onCancel = vi.fn() const hub = runHub(io.ctx, vi.fn()) + await nextFrame() const workbench = io.ctx.resources.workbench! const wizard = runCreateWizard(io.ctx, 'weapp-vite', {}, { onCancel }) io.key('text', target) @@ -815,6 +1031,7 @@ describe('interactive creation wizard', () => { const onPlan = vi.fn() const onCancel = vi.fn() const hub = runHub(io.ctx, vi.fn()) + await nextFrame() const workbench = io.ctx.resources.workbench! const wizard = runCreateWizard(io.ctx, 'weapp-vite', {}, { onPlan, onCancel }) io.key('text', target) @@ -840,6 +1057,7 @@ describe('interactive creation wizard', () => { const target = await directory() const onCancel = vi.fn() const hub = runHub(io.ctx, vi.fn()) + await nextFrame() const workbench = io.ctx.resources.workbench! const wizard = runCreateWizard(io.ctx, 'weapp-vite', {}, { onCancel }) io.key('text', target) @@ -887,12 +1105,13 @@ describe('interactive creation wizard', () => { const target = await directory() const plan = await scaffoldPlan('weapp-vite', target, { template: 'react' }) const result = runHub(io.ctx, vi.fn()) + await nextFrame() const workbench = io.ctx.resources.workbench! workbench.showCreateSuccess(plan, { phase: 'complete', packageName: plan.package, cwd: plan.cwd, queryElapsedMs: 112, executionElapsedMs: 2345, validationElapsedMs: 18 }) expect(workbench.screen.type).toBe('result') await nextFrame() expect(io.output.frame()).toContain('[ PROJECT CREATED ]') - expect(io.output.frame()).toContain('npm install') + expect(io.output.frame()).toContain('I install · S scripts · D doctor') expect(io.output.frame()).toContain('react · Your project constellation') expect(io.output.frame()).toContain('Version query: 112ms') expect(io.output.frame()).toContain('Official scaffold: 2.3s') @@ -905,17 +1124,18 @@ describe('interactive creation wizard', () => { await result }) - it('keeps success commands and return input available during the short star expansion', async () => { + it('keeps success actions and return input available during the short star expansion', async () => { fakeClock() const io = terminal({ width: 120, height: 30 }) const target = await directory() const plan = await scaffoldPlan('weapp-vite', target) const hub = runHub(io.ctx, vi.fn()) + await nextFrame() const workbench = io.ctx.resources.workbench! workbench.showCreateSuccess(plan) const beginning = workbench.frame().join('\n') expect(beginning).toContain('[ PROJECT CREATED ]') - expect(beginning).toContain('npm install') + expect(beginning).toContain('I install · S scripts · D doctor') await vi.advanceTimersByTimeAsync(350) expect(workbench.frame().join('\n')).not.toBe(beginning) io.key('return') @@ -924,7 +1144,7 @@ describe('interactive creation wizard', () => { await hub }) - it.each([[120, 30], [80, 24], [44, 18]])('preserves long result paths and commands through scrolling at %i × %i', async (width, height) => { + it.each([[120, 30], [80, 24], [44, 18]])('preserves long result paths and action guidance through scrolling at %i × %i', async (width, height) => { fakeClock() const io = terminal({ motion: false, width, height }) io.ctx.color = true @@ -932,6 +1152,7 @@ describe('interactive creation wizard', () => { const plan = await scaffoldPlan('weapp-vite', target, { template: 'react' }) plan.directory = `/中文👩🏽‍💻/${'long-parent/'.repeat(18)}demo parent/my-app` const hub = runHub(io.ctx, vi.fn()) + await nextFrame() const workbench = io.ctx.resources.workbench! workbench.showCreateSuccess(plan) if (workbench.screen.type !== 'result') { @@ -959,14 +1180,16 @@ describe('interactive creation wizard', () => { } const text = collected.join('').replaceAll(/\s/g, '') expect(text).toContain(`Projectcreated:${plan.directory}`.replaceAll(/\s/g, '')) - expect(text).toContain(`cd'${plan.directory}'npminstall`.replaceAll(/\s/g, '')) - expect(text).toContain('Next:installdependenciesandfollowtheofficialguide.') + expect(text).toContain('Inspectthegeneratedconfigurationwithdoctor.') + expect(text).toContain('Iinstall·Sscripts·Ddoctor;reviewtheconfirmationbeforeexecution.') expect(text).not.toContain('\\u001b') const bottom = screen.scroll io.key('down') + await nextFrame() expect(screen.scroll).toBe(bottom) io.output.rows = 40 io.output.emit('resize') + await nextFrame() expect(screen.scroll).toBeLessThanOrEqual(bottom!) io.key('return') io.key('q', 'q') diff --git a/packages/weapp/test/workbench-project.test.ts b/packages/weapp/test/workbench-project.test.ts new file mode 100644 index 0000000..9cdf8be --- /dev/null +++ b/packages/weapp/test/workbench-project.test.ts @@ -0,0 +1,199 @@ +import type { ChildProcess } from 'node:child_process' +import type { ProjectContext } from '../src/project/context.js' +import type { CliContext } from '../src/types.js' +import { EventEmitter } from 'node:events' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { PassThrough } from 'node:stream' +import { afterEach, expect, it, vi } from 'vitest' +import { runCreate } from '../src/commands/create.js' +import { discoverProject } from '../src/project/context.js' +import { parseArgs } from '../src/registry.js' +import { Workbench } from '../src/terminal/workbench.js' + +const roots: string[] = [] +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) +async function fixture(manifest?: Record) { + const root = await mkdtemp(path.join(os.tmpdir(), 'weapp-workbench-')) + roots.push(root) + if (manifest) { + await writeFile(path.join(root, 'package.json'), JSON.stringify(manifest)) + } + return root +} +function context() { + let output = '' + const input = new PassThrough() + const stdout = Object.assign(new EventEmitter(), { columns: 100, rows: 30, write: (text: string) => { + output += text + return true + } }) + const ctx: CliContext = { lang: 'en', stdin: input, stdout, stderr: stdout, color: false, motion: false, interactive: false, resources: {}, openUrl: async () => {} } + return { ctx, text: () => output } +} +const press = (workbench: Workbench, name: string) => workbench.key('', { name }, () => {}) + +it('accepts explicit installation and validates package-manager values before loading features', () => { + expect(parseArgs(['create', 'weapp-vite', 'app', '--install', '--package-manager', 'pnpm']).options).toMatchObject({ install: true, packageManager: 'pnpm' }) + expect(() => parseArgs(['create', '--package-manager', 'other'])).toThrow(/package-manager/) + expect(() => parseArgs(['doctor', '--install'])).toThrow(/not available/) +}) + +it('does not queue Enter during discovery or move a focus the user selected', async () => { + const root = await fixture({ name: 'existing', scripts: { test: 'node test.js' } }) + const project = await discoverProject(root) + let complete!: (value: ProjectContext) => void + const { ctx } = context() + ctx.discoverProject = () => new Promise(resolve => complete = resolve) + const dispatch = vi.fn() + const workbench = new Workbench(ctx, dispatch) + const finding = workbench.discoverCurrentProject(root) + await press(workbench, 'return') + expect(dispatch).not.toHaveBeenCalled() + await press(workbench, 'down') + const selected = workbench.hall.entries[workbench.hall.selected!].value + complete(project) + await finding + expect(workbench.hall.entries[workbench.hall.selected!].value).toBe(selected) + expect(dispatch).not.toHaveBeenCalled() + await press(workbench, 'c') + expect(dispatch).toHaveBeenCalledWith(['create']) +}) + +it('defaults existing projects to status, empty directories to create and native projects to read-only entries', async () => { + const { ctx } = context() + for (const [manifest, native, expected] of [[{ name: 'existing' }, false, 'project-status'], [undefined, false, 'create'], [undefined, true, 'project-status']] as const) { + const root = await fixture(manifest) + if (native) { + await writeFile(path.join(root, 'project.config.json'), '{}') + } + const workbench = new Workbench(ctx) + await workbench.discoverCurrentProject(root) + expect(workbench.hall.entries[0].value).toBe(expected) + if (native) { + expect(workbench.hall.entries.map(entry => entry.value)).not.toContain('project-install') + expect(workbench.hall.entries.map(entry => entry.value)).not.toContain('project-scripts') + } + } +}) + +it('keeps an unreadable project distinct from an empty directory', async () => { + const { ctx } = context() + ctx.discoverProject = async () => { + throw new Error('EACCES') + } + const dispatch = vi.fn() + const workbench = new Workbench(ctx, dispatch) + await workbench.discoverCurrentProject('/unreadable/project') + expect(workbench.projectContext?.kind).toBe('invalid') + expect(workbench.hall.entries[0].value).toBe('project-status') + await press(workbench, 'return') + expect(dispatch).not.toHaveBeenCalled() + expect(workbench.frame().join('\n')).toContain('Unable to read the project') +}) + +it('does not start any script before confirmation and restores the selected page on cancellation', async () => { + const root = await fixture({ scripts: { test: 'node verify.js' } }) + const project = await discoverProject(root) + const { ctx } = context() + const spawn = vi.fn() + ctx.projectOperations = { spawn, packageManagerVersion: async () => '12.2.1' } + const workbench = new Workbench(ctx) + const previous = workbench.screen + previous.selected = 2 + const running = workbench.projectAction(project, 'scripts') + await vi.waitFor(() => expect(workbench.screen.title).toBe('PROJECT SCRIPTS')) + await press(workbench, 'return') + await vi.waitFor(() => expect(workbench.screen.title).toBe('CONFIRM PROJECT ACTION')) + expect(workbench.frame().join('\n')).toContain('node verify.js') + expect(workbench.screen.type === 'summary' && workbench.screen.lines).toContain(`Working directory: ${project.projectRoot}`) + expect(spawn).not.toHaveBeenCalled() + await press(workbench, 'escape') + await running + expect(workbench.screen).toBe(previous) + expect(previous.selected).toBe(2) + expect(spawn).not.toHaveBeenCalled() +}) + +it('does not reopen a confirmation after leaving a page during asynchronous discovery', async () => { + const root = await fixture({ scripts: { dev: 'node server.js' } }) + const project = await discoverProject(root) + const { ctx } = context() + let complete!: (value: ProjectContext) => void + ctx.discoverProject = vi.fn(() => new Promise(resolve => complete = resolve)) + const workbench = new Workbench(ctx) + workbench.screen = { type: 'result', context: project, lines: [], parent: workbench.hall } + const running = workbench.projectAction(project, 'install') + await vi.waitFor(() => expect(ctx.discoverProject).toHaveBeenCalled()) + await press(workbench, 'escape') + expect(workbench.screen).toBe(workbench.hall) + complete(project) + await running + expect(workbench.screen).toBe(workbench.hall) + expect(workbench.busy).toBe(false) + expect(workbench.requests.size).toBe(0) +}) + +it('requires an explicit selection for multiple lockfiles and reports missing scripts without executing', async () => { + const root = await fixture({ scripts: {} }) + await writeFile(path.join(root, 'yarn.lock'), '') + await writeFile(path.join(root, 'package-lock.json'), '{}') + const { ctx } = context() + const workbench = new Workbench(ctx) + const running = workbench.projectAction(await discoverProject(root), 'scripts') + await vi.waitFor(() => expect(workbench.screen.title).toBe('CHOOSE PACKAGE MANAGER')) + await press(workbench, 'return') + await running + expect(workbench.frame().join('\n')).toContain('no runnable scripts') +}) + +it.each([undefined, false, true])('creation never installs unless explicitly requested (%s)', async (install) => { + const root = await fixture() + const { ctx, text } = context() + ctx.createOperations = { + query: async () => ({ version: '1.0.0' }), + execute: async (plan) => { + await mkdir(plan.directory) + await writeFile(path.join(plan.directory, 'package.json'), JSON.stringify({ dependencies: { 'weapp-vite': '^1' }, scripts: { build: 'node build.js' } })) + return 0 + }, + } + const spawn = vi.fn(() => { + const child = new EventEmitter() as ChildProcess + queueMicrotask(() => child.emit('close', 0, null)) + return child + }) + ctx.projectOperations = { spawn, packageManagerVersion: async () => '12.2.1' } + expect(await runCreate(ctx, ['weapp-vite', path.join(root, 'app')], { install })).toBe(0) + expect(spawn).toHaveBeenCalledTimes(install ? 1 : 0) + expect(text()).toContain('build: node build.js') + if (install) { + expect(spawn).toHaveBeenCalledWith('npm', ['install'], expect.objectContaining({ cwd: path.join(await import('node:fs/promises').then(fs => fs.realpath(root)), 'app'), shell: false })) + } +}) + +it('keeps dry-run offline even with --install and reports failed installation separately from successful creation', async () => { + const root = await fixture() + const { ctx, text } = context() + const query = vi.fn(async () => ({ version: '1.0.0' })) + ctx.createOperations = { query, execute: async (plan) => { + await mkdir(plan.directory) + await writeFile(path.join(plan.directory, 'package.json'), JSON.stringify({ dependencies: { 'weapp-vite': '^1' } })) + return 0 + } } + const spawn = vi.fn(() => { + const child = new EventEmitter() as ChildProcess + queueMicrotask(() => child.emit('close', 2, null)) + return child + }) + ctx.projectOperations = { spawn, packageManagerVersion: async () => '12.2.1' } + expect(await runCreate(ctx, ['weapp-vite', path.join(root, 'app')], { dryRun: true, install: true, packageManager: 'pnpm' })).toBe(0) + expect(query).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() + expect(await runCreate(ctx, ['weapp-vite', path.join(root, 'app')], { install: true })).toBe(2) + expect(text()).toContain('Project created; installation did not finish') + expect(await readFile(path.join(root, 'app/package.json'), 'utf8')).toContain('weapp-vite') +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4da35c9..eca7aa5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -378,6 +378,9 @@ importers: '@types/node': specifier: 24.19.2 version: 24.19.2 + '@types/picomatch': + specifier: 4.0.3 + version: 4.0.3 '@types/qrcode': specifier: 1.5.6 version: 1.5.6 @@ -396,6 +399,9 @@ importers: jsqr: specifier: 1.4.0 version: 1.4.0 + picomatch: + specifier: 4.0.7 + version: 4.0.7 qrcode: specifier: 1.5.4 version: 1.5.4 @@ -417,6 +423,9 @@ importers: vitest: specifier: 5.0.5 version: 5.0.5(@types/node@24.19.2)(@vitest/coverage-v8@5.0.5)(vite@8.3.5(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + yaml: + specifier: 2.9.1 + version: 2.9.1 packages: @@ -2796,6 +2805,9 @@ packages: '@types/parse-path@7.0.3': resolution: {integrity: sha512-LriObC2+KYZD3FzCrgWGv/qufdUy4eXrxcLgQMfYXgPbLIecKIsVBaQgUPmxSSLcjmYbDTQbMgr6qr6l/eb7Bg==} + '@types/picomatch@4.0.3': + resolution: {integrity: sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==} + '@types/qrcode@1.5.6': resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} @@ -9146,6 +9158,8 @@ snapshots: '@types/parse-path@7.0.3': {} + '@types/picomatch@4.0.3': {} + '@types/qrcode@1.5.6': dependencies: '@types/node': 24.19.2 From 7859af98bcffa64d06a3eba3a8c9c22e8a4ca57f Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Thu, 22 Oct 2026 17:56:47 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(weapp):=20=E4=B8=AD=E6=96=AD=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E5=88=9B=E5=BB=BA=E6=97=B6=E6=B8=85=E7=90=86=E5=AD=90?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B=E6=A0=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/weapp-project-workbench-2026-10-22.md | 6 +- packages/weapp/src/commands/create.ts | 25 +++++ .../test/create-action-lifecycle.test.ts | 102 +++++++++++++++++- 3 files changed, 128 insertions(+), 5 deletions(-) diff --git a/docs/weapp-project-workbench-2026-10-22.md b/docs/weapp-project-workbench-2026-10-22.md index 39aacc5..c877b5e 100644 --- a/docs/weapp-project-workbench-2026-10-22.md +++ b/docs/weapp-project-workbench-2026-10-22.md @@ -43,14 +43,14 @@ Doctor 始终只读。它报告输入目录和识别根,检查实际 Node/ 创建结果使用实际 manifest,列出包管理器、脚本和 doctor。安装失败独立于创建成功报告,文件保留,可再次选择操作。工作台安装/脚本共享执行器:确认页显示 cwd、argv 对应命令和脚本内容;安装在已验证 workspace 根,脚本在当前包。确认后先检查版本约束,再重读项目;声明、依赖或脚本变化会要求重新确认。 -进程通过参数数组启动。外部任务独占输入,结束后恢复页面与焦点;Ctrl+C 取消本次安装/脚本,Unix 清理专用进程组,Windows 使用定向 `taskkill /T /F`,等待 close 和清理结束后恢复。SIGTERM 保留全局退出约定。不会自动运行长期开发服务。 +进程通过参数数组启动。外部任务独占输入,结束后恢复页面与焦点;Ctrl+C 取消本次安装/脚本,Unix 清理专用进程组,Windows 使用定向 `taskkill /T /F`,等待 close 和清理结束后恢复。独立创建命令同样接管 SIGINT/SIGTERM,等待脚手架或安装任务的子孙进程清理后分别以 130/143 退出;信号监听器在结束时移除。SIGTERM 保留全局退出约定。不会自动运行长期开发服务。 ## 验证与交付 -- CLI:17 个测试文件、334 项测试全部通过;包含发现中 Enter/焦点、离开页面后的晚到结果、workspace 与原生项目边界、损坏 manifest、已安装版本、安装/脚本确认及进程清理回归。 +- CLI:17 个测试文件、338 项测试全部通过;包含发现中 Enter/焦点、离开页面后的晚到结果、workspace 与原生项目边界、损坏 manifest、已安装版本、安装/脚本确认及进程清理回归。新增 4 项真实 CLI 信号回归,覆盖脚手架/安装两阶段的 SIGINT/SIGTERM,即使子孙进程忽略 SIGTERM,也必须在 CLI 退出前完成清理并保留已创建文件。 - 类型检查、构建、ESLint、Stylelint 通过;ESLint 保留未改动实验室文件中的 4 条已有 no-use-before-define warning,没有错误。 - 最终构建真实 PTY:28 个场景通过。输入先等待可见就绪标记;覆盖三种尺寸、暂停/resize/搜索、实际子进程继承输入、脚本运行、OS SIGINT 取消、PID 消失、恢复焦点,以及 raw mode/cursor/alternate screen 清理。证据为 `packages/weapp/.cache/qa/terminal-*.txt` 与 `terminal-metrics.json`。 -- 独立 tarball:在 workspace 外安装同一发布包,Node 18.20.8/22.23.4/24.22.1 的 artifact smoke 全部通过,包含新 doctor 上下文、协议/版本、workspace、原生项目及 `--dry-run --install`。压缩体积 311,233 bytes,解包 1,113,061 bytes,10 个外部运行时包名/版本;bundled 许可与图完整匹配。 +- 独立 tarball:在 workspace 外安装同一发布包,Node 18.20.8/22.23.4/24.22.1 的 artifact smoke 全部通过,包含新 doctor 上下文、协议/版本、workspace、原生项目及 `--dry-run --install`。压缩体积 311,419 bytes,解包 1,113,920 bytes,10 个外部运行时包名/版本;bundled 许可与图完整匹配。 - frozen install:pnpm 12.14.0 通过,锁文件及 24 小时冷却策略保留。 - 边界及原生发布流程:10 项测试通过。 - 后台浏览器实验室:1440/390/320px、双语、6/11 项生态、官方资源与动作、动画暂停、重播、PNG、减少动态效果和 Canvas 降级通过;临时浏览器、context 和 loopback 服务由 finally 关闭。 diff --git a/packages/weapp/src/commands/create.ts b/packages/weapp/src/commands/create.ts index eda43d1..6d2d993 100644 --- a/packages/weapp/src/commands/create.ts +++ b/packages/weapp/src/commands/create.ts @@ -3,6 +3,7 @@ import type { CliContext, CliOptions } from '../types.js' import type { CreateOperations, CreateProgress, CreateResult, CreateWizardHooks, ExecutionOptions, QueryOptions, ScaffolderExecution, ScaffolderMetadata, ScaffoldPlan } from './create/plan.js' import { readFile } from 'node:fs/promises' import path from 'node:path' +import process from 'node:process' // eslint-disable-next-line e18e/ban-dependencies -- Preserve Windows-safe npm spawning on the Node 18 baseline. import spawn from 'cross-spawn' import { issueLines, projectLines } from '../project/presentation.js' @@ -183,6 +184,7 @@ export async function runCreate(ctx: CliContext, args: string[], options: CliOpt } let preparation: CreatePreparation | undefined let detachSignal: (() => void) | undefined + let detachProcessSignals: (() => void) | undefined let unsubscribeProgress: (() => void) | undefined let interruptedCode: (() => number | undefined) | undefined let locallyCancelled = false @@ -200,6 +202,28 @@ export async function runCreate(ctx: CliContext, args: string[], options: CliOpt if (sourceSignal?.aborted) { abort() } + const terminalSession = ctx.resources?.workbench?.session + if (!ctx.hub && !(terminalSession?.started && !terminalSession.closed)) { + // Standalone commands own signal forwarding while their detached children run. + // An active wizard owns terminal signals until it returns to this command. + const interrupt = (code: number) => { + const session = ctx.resources?.workbench?.session + if (session?.started && !session.closed) { + return + } + ctx.cancelled = true + ctx.cancelledCode = code + abort() + } + const onInterrupt = () => interrupt(130) + const onTerminate = () => interrupt(143) + process.on('SIGINT', onInterrupt) + process.on('SIGTERM', onTerminate) + detachProcessSignals = () => { + process.removeListener('SIGINT', onInterrupt) + process.removeListener('SIGTERM', onTerminate) + } + } const operations: CreateOperations = { ...ctx.createOperations, signal: controller.signal, @@ -348,5 +372,6 @@ export async function runCreate(ctx: CliContext, args: string[], options: CliOpt unsubscribeProgress?.() preparation?.dispose() detachSignal?.() + detachProcessSignals?.() } } diff --git a/packages/weapp/test/create-action-lifecycle.test.ts b/packages/weapp/test/create-action-lifecycle.test.ts index bfc48ad..78199aa 100644 --- a/packages/weapp/test/create-action-lifecycle.test.ts +++ b/packages/weapp/test/create-action-lifecycle.test.ts @@ -1,11 +1,12 @@ import type { CliContext } from '../src/types.js' -import { ChildProcess } from 'node:child_process' +import { ChildProcess, execFileSync, spawn as nodeSpawn } from 'node:child_process' import { EventEmitter, getEventListeners } from 'node:events' -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import process from 'node:process' import { PassThrough } from 'node:stream' +import { fileURLToPath } from 'node:url' // eslint-disable-next-line e18e/ban-dependencies -- Mock the production Windows-safe process boundary. import spawn from 'cross-spawn' import { afterEach, expect, it, vi } from 'vitest' @@ -128,6 +129,8 @@ it('preserves global SIGTERM 143 after the project was created and installation it('keeps standalone installation cancellation at 130 and waits for process close', async () => { const { ctx, directory, child, install, text } = await fixture() + const interruptListeners = process.listenerCount('SIGINT') + const terminateListeners = process.listenerCount('SIGTERM') const controller = new AbortController() ctx.operationSignal = controller.signal let settled = false @@ -145,6 +148,8 @@ it('keeps standalone installation cancellation at 130 and waits for process clos expect(text()).toContain('Project created; installation did not finish') expect(await readFile(path.join(directory, 'package.json'), 'utf8')).toContain('weapp-vite') expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) + expect(process.listenerCount('SIGINT')).toBe(interruptListeners) + expect(process.listenerCount('SIGTERM')).toBe(terminateListeners) } finally { controller.abort() @@ -153,6 +158,99 @@ it('keeps standalone installation cancellation at 130 and waits for process clos } }) +function isRunning(pid: number): boolean { + try { + return !execFileSync('ps', ['-p', String(pid), '-o', 'stat='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().startsWith('Z') + } + catch { + return false + } +} + +// Windows does not deliver POSIX signals through process.kill; tree termination is tested separately. +it.skipIf(process.platform === 'win32').each([ + { phase: 'scaffolder', signal: 'SIGINT', code: 130 }, + { phase: 'scaffolder', signal: 'SIGTERM', code: 143 }, + { phase: 'install', signal: 'SIGINT', code: 130 }, + { phase: 'install', signal: 'SIGTERM', code: 143 }, +] as const)('cleans standalone $phase descendants before returning $code on $signal', async ({ phase, signal, code }) => { + const { directory } = await fixture() + const root = path.dirname(directory) + const bin = path.join(root, 'bin') + await mkdir(bin) + const managerPidFile = path.join(root, 'manager.pid') + const descendantPidFile = path.join(root, 'descendant.pid') + const manager = path.join(bin, 'npm') + await writeFile(manager, `#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); +const args = process.argv.slice(2); +function waitForCancellation() { + process.on('SIGTERM', () => {}); + spawn(process.execPath, ['-e', "process.on('SIGTERM', () => {}); require('node:fs').writeFileSync(process.env.WEAPP_SIGNAL_DESCENDANT_PID, String(process.pid)); setInterval(() => {}, 1000);"], { stdio: 'inherit' }); + fs.writeFileSync(process.env.WEAPP_SIGNAL_MANAGER_PID, String(process.pid)); + setInterval(() => {}, 1000); +} +if (args[0] === 'view') process.stdout.write(JSON.stringify({ version: '1.0.0', engines: { node: '>=18' } })); +else if (args[0] === '--version') process.stdout.write('10.9.0\\n'); +else if (args[0] === 'exec') { + const target = path.resolve(args[args.indexOf('--') + 2]); + fs.mkdirSync(target); + fs.writeFileSync(path.join(target, 'package.json'), JSON.stringify({ dependencies: { 'weapp-vite': '1.0.0' } })); + if (process.env.WEAPP_SIGNAL_PHASE === 'scaffolder') waitForCancellation(); +} +else if (args[0] === 'install') waitForCancellation(); +else process.exitCode = 2; +`) + await chmod(manager, 0o755) + const cli = nodeSpawn(process.execPath, [fileURLToPath(new URL('../dist/cli.js', import.meta.url)), 'create', 'weapp-vite', directory, '--lang', 'en', ...(phase === 'install' ? ['--install'] : [])], { + cwd: root, + env: { ...process.env, PATH: `${bin}${path.delimiter}${process.env.PATH}`, CI: '1', WEAPP_SIGNAL_PHASE: phase, WEAPP_SIGNAL_MANAGER_PID: managerPidFile, WEAPP_SIGNAL_DESCENDANT_PID: descendantPidFile }, + detached: true, + stdio: ['ignore', 'pipe', 'pipe'], + }) + let output = '' + cli.stdout.on('data', value => output += value.toString()) + cli.stderr.on('data', value => output += value.toString()) + const descendants: number[] = [] + const exited = new Promise<{ code: number | null, signal: NodeJS.Signals | null, surviving: number[] }>((resolve) => { + cli.once('exit', (code, signal) => resolve({ code, signal, surviving: descendants.filter(isRunning) })) + }) + const closed = new Promise(resolve => cli.once('close', () => resolve())) + try { + await vi.waitFor(async () => { + expect(cli.exitCode, output).toBeNull() + const [managerPid, descendantPid] = await Promise.all([readFile(managerPidFile, 'utf8'), readFile(descendantPidFile, 'utf8')]) + descendants.splice(0, descendants.length, Number(managerPid), Number(descendantPid)) + expect(descendants.every(pid => pid > 0)).toBe(true) + }, { timeout: 5000, interval: 20 }) + process.kill(-cli.pid!, signal) + expect(await exited, output).toEqual({ code, signal: null, surviving: [] }) + await closed + expect(await readFile(path.join(directory, 'package.json'), 'utf8')).toContain('weapp-vite') + if (phase === 'install') { + expect(output).toContain('Project created; installation did not finish') + } + } + finally { + // Only groups created by this test are eligible for fallback cleanup. + const managerPid = descendants[0] || await readFile(managerPidFile, 'utf8').then(Number, () => undefined) + for (const pid of [managerPid, cli.pid]) { + if (!pid) { + continue + } + try { + process.kill(-pid, 'SIGKILL') + } + catch (error) { + expect(error).toMatchObject({ code: 'ESRCH' }) + } + } + await closed + } +}, 10000) + it('runs the official scaffolder with its inherited environment and fixed argv', async () => { const { directory } = await fixture() const plan = await scaffoldPlan('weapp-vite', directory)