@bushizhanghan/dsh-publish-gate 0.21.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -20
- package/README.md +40 -8
- package/lib/engine.js +210 -1
- package/lib/index.js +33 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.22.0
|
|
4
|
+
|
|
5
|
+
- New tool `publish_gate_link` (the tenth): the local-development layer. It answers "is the code the
|
|
6
|
+
host loads the code I just edited?" from the profile's own files — `<profile>/node_modules`,
|
|
7
|
+
`<profile>/node_modules/.modules.yaml`, `<profile>/cordis.patch.yml` — with no network and no
|
|
8
|
+
package manager. Every failure on this path is silent: the plugin simply never appears.
|
|
9
|
+
- `link-not-installed` (info): the profile has no entry for this package. Normal before the first
|
|
10
|
+
local install, so it does not raise the blocker count.
|
|
11
|
+
- `link-target-missing` (error): the entry is a link whose target is gone, or whose target has no
|
|
12
|
+
parseable `package.json`. A dead junction makes the host resolve the package to nothing — this is
|
|
13
|
+
what broke this machine's Web UI once, with no error anywhere.
|
|
14
|
+
- `link-stale-copy` (warn): the entry is a plain directory, not a link, and its version differs from
|
|
15
|
+
the checkout. Edits to `lib/` never reach the profile while the checkout's own tests stay green.
|
|
16
|
+
- `pnpm-store-conflict` (warn): the profile's pnpm store major differs from the bundled pnpm's (11).
|
|
17
|
+
`dsh plugin add link:` then fails with `ERR_PNPM_UNEXPECTED_STORE` before installing anything, and
|
|
18
|
+
the message names neither the profile nor the major to match. Read from `storeDir` in
|
|
19
|
+
`.modules.yaml`; measured on this profile: `...\AppData\Local\pnpm\store\v11`, so v11 vs v11 is
|
|
20
|
+
clean and only a v10 store is flagged.
|
|
21
|
+
- `patch-entry-top-level` (error): the package's row in the profile patch is a top-level `- id:`
|
|
22
|
+
entry. A top-level row overrides an entry that already exists; it does not add one, so the plugin
|
|
23
|
+
installs, loads, and never appears. This profile currently has **zero** `insert:` blocks and every
|
|
24
|
+
row is top-level — exactly the shape the rule exists to catch.
|
|
25
|
+
- The link kind is reported as `junction` / `symlink` / `copy` / `missing`: Node reports a junction
|
|
26
|
+
and a directory symlink identically (`lstat().isSymbolicLink()`), so the junction is identified by
|
|
27
|
+
`realpathSync(p) !== p`, the check that distinguishes the form `mklink /J` creates without admin
|
|
28
|
+
rights from the one that needs them.
|
|
29
|
+
- Tests: 131 engine tests (six new, one per rule, each against a temp profile — the real profile is
|
|
30
|
+
never read or written) and 32 integration assertions.
|
|
31
|
+
|
|
3
32
|
## 0.21.0
|
|
4
33
|
|
|
5
34
|
- The runtime layer no longer assumes the entry is `lib/index.js`. It resolves `exports["."]` →
|
|
@@ -17,15 +46,15 @@
|
|
|
17
46
|
dependencies, so the code is in the bundle and "lib/ never imports it" is true but not actionable.
|
|
18
47
|
|
|
19
48
|
## 0.20.0
|
|
20
|
-
|
|
49
|
+
|
|
21
50
|
- `publish_gate_commands` now agrees with the rules: a prerelease version gets `npm publish --tag next`
|
|
22
51
|
instead of a bare publish that would move `latest`.
|
|
23
52
|
- New step `tag the shipped commit` (`git tag v<version> && git push origin v<version>`). The gate has
|
|
24
53
|
warned about a missing tag since 0.5.0 and the command list never mentioned tagging — I wrote a
|
|
25
54
|
separate script to do it because the instructions never said to.
|
|
26
|
-
|
|
55
|
+
|
|
27
56
|
## 0.19.0
|
|
28
|
-
|
|
57
|
+
|
|
29
58
|
- `prerelease-published-as-latest` (warn): a `-beta` / `-rc` version with no `publishConfig.tag`.
|
|
30
59
|
npm publishes to `latest` whatever the suffix says, so `npm i <name>` starts handing everyone an
|
|
31
60
|
unfinished build and nothing warns.
|
|
@@ -33,17 +62,17 @@
|
|
|
33
62
|
older one. That is what `--tag next` does when you meant to release: the release looks shipped and
|
|
34
63
|
is not what anyone installs.
|
|
35
64
|
- README: dropped a made-up `test/…` example path — `readme-path-missing` caught my own prose.
|
|
36
|
-
|
|
65
|
+
|
|
37
66
|
## 0.18.0
|
|
38
|
-
|
|
67
|
+
|
|
39
68
|
- `readme-path-missing` (warn): a README that names `test/…`, `lib/…`, `scripts/…` files that no longer
|
|
40
69
|
exist. Rename a file and the README keeps telling people to run something that returns ENOENT;
|
|
41
70
|
nothing else in the repo notices. Only repo-relative paths under directories a plugin owns, so a
|
|
42
71
|
tutorial example is not a finding.
|
|
43
72
|
- Verified over all nine plugins in this workspace: no false positives, all nine still `e0 w0`.
|
|
44
|
-
|
|
73
|
+
|
|
45
74
|
## 0.17.0
|
|
46
|
-
|
|
75
|
+
|
|
47
76
|
- `changelog-not-shipped` (warn): a CHANGELOG.md that exists on disk but is not in the tarball. npm
|
|
48
77
|
force-includes README and LICENSE and nothing else, so the file you keep updating never reaches the
|
|
49
78
|
registry page. Hit it the first time I added a changelog and had to add it to `files` by hand.
|
|
@@ -53,14 +82,14 @@
|
|
|
53
82
|
was pushed through the API and local HEAD is not the shipped commit.
|
|
54
83
|
- First end-to-end run of the gate with the test layer over all nine plugins in this workspace: every
|
|
55
84
|
suite passed, and the tag rule was the only false positive.
|
|
56
|
-
|
|
85
|
+
|
|
57
86
|
- `changelog-not-shipped` (warn): a CHANGELOG.md that exists on disk but is not in the tarball. npm
|
|
58
87
|
force-includes README and LICENSE and nothing else, so the file you keep updating never reaches the
|
|
59
88
|
registry page. Hit it the first time I added a changelog and had to add it to `files` by hand.
|
|
60
89
|
- First end-to-end run of the gate with the test layer over all nine plugins in this workspace.
|
|
61
|
-
|
|
90
|
+
|
|
62
91
|
## 0.16.0
|
|
63
|
-
|
|
92
|
+
|
|
64
93
|
- `market-release-behind` (warn): the newest GitHub release is older (or newer) than package.json. A
|
|
65
94
|
catalogue entry may install `releases/latest/download/<asset>`, so a lagging release serves code the
|
|
66
95
|
description no longer describes — a reviewer blocked a submission for exactly that.
|
|
@@ -70,9 +99,9 @@
|
|
|
70
99
|
- The source scan added in 0.15.0 caught the same mistake again while building this version: a string
|
|
71
100
|
replace aimed at RULE_ORDER matched an earlier `F(...)` call and wrote the new rule name into a severity
|
|
72
101
|
slot. Fixed, and the fix now goes through the RULE_ORDER slice instead of a global replace.
|
|
73
|
-
|
|
102
|
+
|
|
74
103
|
## 0.15.0
|
|
75
|
-
|
|
104
|
+
|
|
76
105
|
- Three rules shipped with a *rule name* in their severity slot: `market-repo-too-new`,
|
|
77
106
|
`test-script-dir-arg` and `tool-missing-output-schema` were built as
|
|
78
107
|
`F(rule, "some-other-rule", "a-third-rule", "warn", title, ...)`, because a string replace aimed at
|
|
@@ -81,45 +110,45 @@
|
|
|
81
110
|
reaches the report, so the rules looked like they simply never fired. Fixed all three, added the six
|
|
82
111
|
rule names that never made it into RULE_ORDER, and added a source scan that fails when any `F(...)`
|
|
83
112
|
call passes something other than an error/warn/info literal (or a ternary of two of them).
|
|
84
|
-
|
|
113
|
+
|
|
85
114
|
## 0.14.0
|
|
86
|
-
|
|
115
|
+
|
|
87
116
|
- New test layer: `checkTestRun` runs `node --test` in the plugin directory and reports `tests-fail`
|
|
88
117
|
(error), `tests-timeout` (warn) and `tests-not-run` (warn). Every other layer reads the paperwork —
|
|
89
118
|
the manifest, the tarball, the registry — while nine of my own plugins shipped
|
|
90
119
|
`"test": "node --test test/"`, which throws MODULE_NOT_FOUND on Node 22+ and would have been caught
|
|
91
120
|
by simply running it. The child drops the inherited NODE_TEST_CONTEXT, because a gate that runs
|
|
92
121
|
inside `node --test` would otherwise start the suite in test-child mode and see no summary at all.
|
|
93
|
-
|
|
122
|
+
|
|
94
123
|
## 0.13.1
|
|
95
|
-
|
|
124
|
+
|
|
96
125
|
- The tag check now asks `git ls-remote origin` instead of `git tag --points-at HEAD`. When the commit is pushed
|
|
97
126
|
through the GitHub API the local HEAD is not the remote one, so the local form reported `git-tag-missing` on
|
|
98
127
|
seven of my nine plugins whose tag existed all along. It now compares the tag against the shipped branch
|
|
99
128
|
head and stays silent when the remote cannot be reached — a check that cannot see the truth must not claim
|
|
100
129
|
a defect.
|
|
101
130
|
## 0.13.0
|
|
102
|
-
|
|
131
|
+
|
|
103
132
|
- `market-fork-of-listed` / `market-fork-undisclosed` (warn): the repo is a GitHub fork whose parent is
|
|
104
133
|
already in the catalogue, and nothing says so. A submission was blocked for exactly this: the entry
|
|
105
134
|
described the fork as if it were original work, and both plugins claim the same provider id, so installing
|
|
106
135
|
both silently shadows one of them. The check uses the `fork` and `parent.full_name` fields of the repo
|
|
107
136
|
metadata the market layer already fetches.
|
|
108
137
|
## 0.12.0
|
|
109
|
-
|
|
138
|
+
|
|
110
139
|
- `pack-ships-junk` (warn): the tarball carries `.bak-*`, `.orig`, `.swp`, `.log`, `~` or `.DS_Store` debris.
|
|
111
140
|
Naming a directory in `files` takes everything inside it, so a `lib/client.js.bak-20261026` left behind
|
|
112
141
|
during a fix ships to every consumer and stays on npm permanently. Both weapp-loop and page-monitor had
|
|
113
142
|
exactly that file during this session; both were deleted by hand before this rule existed.
|
|
114
143
|
## 0.11.0
|
|
115
|
-
|
|
144
|
+
|
|
116
145
|
- `tool-no-description` (error), `tool-description-thin` (warn), `tool-no-parameters` (error): the host
|
|
117
146
|
renders name + description + parameters into the prompt and nothing else, so those three fields are the
|
|
118
147
|
tool's entire specification. A tool with no description is a name in a list; a tool that reads args with no
|
|
119
148
|
schema never receives them. All nine of my plugins were clean, so this guards the shape rather than a
|
|
120
149
|
specific crash — the first two fixtures that had `description: 'd'` did fail, which is the point.
|
|
121
150
|
## 0.10.0
|
|
122
|
-
|
|
151
|
+
|
|
123
152
|
- `client-double-load` (warn): the client probe now evaluates the bundle a second time against the same
|
|
124
153
|
`window`, with a loader that throws on a repeated registration. A bundle inserted by two patches (its own,
|
|
125
154
|
plus any sidebar plugin that injects `dsh-client-modules`) aborted web boot this way. A window-level guard
|
package/README.md
CHANGED
|
@@ -4,18 +4,18 @@
|
|
|
4
4
|
>
|
|
5
5
|
> 发布一个 DSH 插件有七步,每一步都有一个**直到你付出代价才说话的坑**:`files` 忘了 `lib/` 于是发出去一个空包、PowerShell 写下的 BOM 让 `JSON.parse` 报一个不提 BOM 的错、patch 的 insert 行缩进错了于是插件装上了但**永远不加载**、裸包名让 granular token 收到一个既不说 token 也不说 scope 的 403。这个插件把整条链路的每个静默坑变成一条确定性检查。
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
十个 agent 工具:全量门禁、运行时注册检查、发后校验、patch 专项、市场条目校验、版本推进、多插件体检、发布命令清单、本地开发链路诊断。
|
|
8
8
|
|
|
9
9
|
门禁分三层:**清单层**看 package.json / patch / 入口源码,**产物层**真的跑一遍 `npm pack --dry-run` 看 npm 到底会发出什么,**运行时层**在独立子进程里 `import()` 入口并 `apply(ctx)` 一遍,看它到底注册了什么。
|
|
10
10
|
|
|
11
11
|
### 自检:看不见的 finding
|
|
12
|
-
|
|
12
|
+
|
|
13
13
|
`severity` 不是 `error`/`warn`/`info` 的 finding 会被所有计数器忽略,等于规则从没触发过。`publish_gate` 现在会把这类 finding 提升成一条 `internal-bad-severity` 的 error,测试里还有一条源码扫描盯着 `F(...)` 的第二个参数。这条网加上当天就抓到了我自己的同一个错误。
|
|
14
|
-
|
|
14
|
+
|
|
15
15
|
### 测试层:门禁真的会跑一遍你的测试
|
|
16
|
-
|
|
16
|
+
|
|
17
17
|
`tests-fail` / `tests-timeout` / `tests-not-run` 三条规则会以 `node --test` 在插件目录里真跑一次(不经过 `npm test`,因为脚本内容由作者决定、可能 shell 出去)。子进程会去掉继承来的 `NODE_TEST_CONTEXT`——否则当门禁本身跑在 `node --test` 里时,子进程进入 test-child 模式、一个摘要都不打印,看起来像"没有测试"。
|
|
18
|
-
|
|
18
|
+
|
|
19
19
|
## 典型用法(写完插件后)
|
|
20
20
|
|
|
21
21
|
```
|
|
@@ -45,6 +45,7 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
|
|
|
45
45
|
| `publish_gate_market` | 校验市场条目 YAML(url/name/category/description 双语);传 `dir` 则改查只有线上才能回答的三件事:这个仓库**已经在目录里**(再开 PR 就是重复条目)、另一个条目也在发同一个 npm 包、仓库还**不满 24 小时**(提交门禁会失败并自己重跑) |
|
|
46
46
|
| `publish_gate_next_version` | 当前版本、是否已被 registry 占用、建议 bump 到多少(本地比 npm 旧时从 npm 的 latest 往上加,不会推荐一个已被占用的版本) |
|
|
47
47
|
| `publish_gate_doctor` | 扫整个目录,按 blocker 数排序,附带每个插件的真实包体积,看哪个还不能发 |
|
|
48
|
+
| `publish_gate_link` | 本地开发链路:不联网、不调包管理器,只读 profile 自己的 `node_modules` / `.modules.yaml` / `cordis.patch.yml`,回答「宿主现在跑的到底是不是我刚改的这份代码」——链接的形态与目标、pnpm store 的 major、patch 行挂不挂载,一次报全。传 `profile` 可看别的 profile(默认 `~/.dsh/profiles/desktop`) |
|
|
48
49
|
| `publish_gate_commands` | 按顺序列出发布命令,并标出这台机器已经走到哪一步。命令是**这台机器上真能跑的形式**:Windows 下 `npm publish` 走 `cmd /c`(PowerShell 的 `npm.ps1` 常被执行策略拦掉),`npm publish` 后面紧跟一条 `publish_gate_verify`;版本是预发版时命令自带 `--tag next`;最后一步是给发出去的 commit 打 tag |
|
|
49
50
|
|
|
50
51
|
## 检查清单
|
|
@@ -105,7 +106,7 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
|
|
|
105
106
|
### 运行时层:`apply` 之后到底注册了什么
|
|
106
107
|
|
|
107
108
|
前两层都是读文件,读不出「装上能不能用」。这一层在独立子进程里 `import()` 入口、用假 ctx 调一次 `apply(ctx)`,把 `ctx.tools.register` 收到的每一条拿回来检查。20 秒超时,你的插件代码不在宿主进程里跑。
|
|
108
|
-
|
|
109
|
+
|
|
109
110
|
入口按 `exports["."]` → `main` → `lib/index.js` 的顺序解析。**早先写死 `lib/index.js`,拿三十一个真实已装插件一测,二十个报"入口抛异常"——其中两个的入口是 `index.mjs`,根本没有 `lib/`。**误报比漏报更贵:一个满屏假错误的门禁,第二次就没人跑了。
|
|
110
111
|
|
|
111
112
|
| 规则 | 抓什么 |
|
|
@@ -203,6 +204,37 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
|
|
|
203
204
|
|
|
204
205
|
都是 warn:本地试发、私有包、发完再补 tag 都合理。但「发完之后指不出是哪份代码」这件事,事后补不回来。
|
|
205
206
|
|
|
207
|
+
## 本地开发:宿主跑的到底是不是你刚改的代码
|
|
208
|
+
|
|
209
|
+
发布之前总有一段时间,插件只在你自己机器上跑。这段路比发布更容易踩空:宿主只加载 `<profile>/node_modules` 下的包,所以你改的是 `E:\...\dsh-x`,跑的是 profile 里那个入口——它可能是一个 junction、一份早就不再同步的拷贝,或者根本没有。这三种情况在宿主那边长得一模一样:插件不出现,没有报错,日志里也没有一行。
|
|
210
|
+
|
|
211
|
+
`publish_gate_link` 只读 profile 自己的三个文件回答这件事,不联网、不调包管理器、不写任何东西。
|
|
212
|
+
|
|
213
|
+
| 规则 | 严重度 | 抓什么 |
|
|
214
|
+
|---|---|---|
|
|
215
|
+
| `link-not-installed` | info | profile 的 `node_modules` 里没有这个包。新插件第一次接进宿主之前,这是正常状态 |
|
|
216
|
+
| `link-target-missing` | error | 链接在、目标没了(或目标里没有能解析的 `package.json`)。死掉的 junction 让宿主把包解析成空,插件静默消失——这台机器的 Web UI 就这样坏过一次 |
|
|
217
|
+
| `link-stale-copy` | warn | profile 里是**一份拷贝**而不是链接,且版本和工作区不同:改 `lib/` 永远不会到那边,而你在这边跑测试全绿 |
|
|
218
|
+
| `pnpm-store-conflict` | warn | profile 的 pnpm store 是 v10、随包的 pnpm 是 v11(或反过来)。`dsh plugin add link:` 会在装任何东西之前以 `ERR_PNPM_UNEXPECTED_STORE` 失败,而那条错误既不说 profile、也不说要匹配哪个 major |
|
|
219
|
+
| `patch-entry-top-level` | error | 这个包在 profile 的 `cordis.patch.yml` 里是一条顶层 `- id:` 行。顶层行覆盖树里已有的条目、不新增一条:装上了、加载了、永远不出现。这个坑藏了我两个插件两周 |
|
|
220
|
+
|
|
221
|
+
链接的三种形态是分开报的:Node 对 junction 和目录符号链接都只说 `isSymbolicLink() === true`,只有 `realpathSync(p) !== p` 才说明它是 junction;普通目录则是拷贝。`mklink /J` 建 junction 不需要管理员权限,符号链接需要。
|
|
222
|
+
|
|
223
|
+
工作流:
|
|
224
|
+
|
|
225
|
+
```
|
|
226
|
+
1. 改完 lib/,跑 publish_gate_link(dir)
|
|
227
|
+
2. link-not-installed → 建 junction(finding 的 fix 里就是能直接粘的命令)
|
|
228
|
+
3. link-target-missing → 删掉旧入口,重建 junction
|
|
229
|
+
link-stale-copy → 同上;拷贝不会跟着工作区变
|
|
230
|
+
4. patch-entry-top-level → 把那一行挪进 "- insert:" 块
|
|
231
|
+
5. pnpm-store-conflict → 别用 pnpm,用 mklink
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
`profile` 参数可以看别的 profile(默认 `~/.dsh/profiles/desktop`)。测试永远用临时 profile,不碰真的那个。
|
|
235
|
+
|
|
236
|
+
没有做的:它不替你建链接、不改 profile 的 patch——和门禁其余部分一样只读,每条 finding 都带 `fix` 文案,动手是你的决定。
|
|
237
|
+
|
|
206
238
|
## 一个真实的例子
|
|
207
239
|
|
|
208
240
|
对一个刚从别人那儿接手、准备发布的插件跑 `publish_gate`:
|
|
@@ -239,8 +271,8 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
|
|
|
239
271
|
## 开发
|
|
240
272
|
|
|
241
273
|
```sh
|
|
242
|
-
node --test #
|
|
243
|
-
node test/integration.test.mjs #
|
|
274
|
+
node --test # 131 个引擎单测
|
|
275
|
+
node test/integration.test.mjs # 32 条集成断言(9 个真实插件作 fixture)
|
|
244
276
|
```
|
|
245
277
|
|
|
246
278
|
## License
|
package/lib/engine.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// unscoped npm name that returned E403, a git push that failed on the network
|
|
7
7
|
// while the REST API worked. All of them are silent until the moment they cost
|
|
8
8
|
// you a publish, so they are all checkable before you spend it.
|
|
9
|
-
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
9
|
+
import { existsSync, lstatSync, readFileSync, readlinkSync, readdirSync, realpathSync, statSync } from 'node:fs'
|
|
10
|
+
import { homedir } from 'node:os'
|
|
10
11
|
import { join, relative } from 'node:path'
|
|
11
12
|
import { builtinModules } from 'node:module'
|
|
12
13
|
import { execFile } from 'node:child_process'
|
|
@@ -2661,6 +2662,213 @@ async function readRemoteRefs(dir) {
|
|
|
2661
2662
|
// =====================================================================
|
|
2662
2663
|
// Orchestration
|
|
2663
2664
|
// =====================================================================
|
|
2665
|
+
// =====================================================================
|
|
2666
|
+
// Local development: is this checkout the code the host actually loads?
|
|
2667
|
+
// =====================================================================
|
|
2668
|
+
// An unpublished plugin reaches the host only through a link in the profile's
|
|
2669
|
+
// node_modules, and every failure looks the same from the outside: not
|
|
2670
|
+
// installed, linked to a target that moved, or a copy that stopped tracking
|
|
2671
|
+
// the checkout. `pnpm add link:` cannot help — the bundled pnpm and the
|
|
2672
|
+
// profile store are different majors. These checks read the profile's own
|
|
2673
|
+
// files, so they answer it with no network and no package manager.
|
|
2674
|
+
|
|
2675
|
+
/** The desktop profile a plugin is installed into, unless the caller names one. */
|
|
2676
|
+
const profileDirOf = (opts = {}) => opts.profileDir || join(homedir(), '.dsh', 'profiles', 'desktop')
|
|
2677
|
+
|
|
2678
|
+
export function checkLocalLink(dir, opts = {}) {
|
|
2679
|
+
const findings = []
|
|
2680
|
+
const name = readPkgJson(dir)?.name
|
|
2681
|
+
const profileDir = profileDirOf(opts)
|
|
2682
|
+
const path = name ? join(profileDir, 'node_modules', name) : null
|
|
2683
|
+
const link = { kind: 'missing', target: null, resolves: false, path }
|
|
2684
|
+
if (!name) return { ...link, findings } // a nameless package is checkPackageJson's finding
|
|
2685
|
+
|
|
2686
|
+
let entry = null
|
|
2687
|
+
try {
|
|
2688
|
+
entry = lstatSync(path)
|
|
2689
|
+
} catch {
|
|
2690
|
+
entry = null
|
|
2691
|
+
}
|
|
2692
|
+
if (!entry) {
|
|
2693
|
+
findings.push(
|
|
2694
|
+
F(
|
|
2695
|
+
'link-not-installed',
|
|
2696
|
+
'info',
|
|
2697
|
+
`${name} is not installed in ${profileDir}`,
|
|
2698
|
+
"The host only loads packages under the profile's node_modules, so this checkout is not what is running. Expected for a plugin nobody is testing in the host yet.",
|
|
2699
|
+
`Link it: cmd /c mklink /J "${path}" "${dir}"`,
|
|
2700
|
+
),
|
|
2701
|
+
)
|
|
2702
|
+
return { ...link, findings }
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
if (!entry.isSymbolicLink()) {
|
|
2706
|
+
// A plain directory is a copy, not a link: nothing written to lib/ here
|
|
2707
|
+
// reaches the profile, so the host keeps running whatever was copied.
|
|
2708
|
+
link.kind = 'copy'
|
|
2709
|
+
link.target = path
|
|
2710
|
+
link.resolves = true
|
|
2711
|
+
const copied = readPkgJson(path)?.version ?? null
|
|
2712
|
+
const local = readPkgJson(dir)?.version ?? null
|
|
2713
|
+
if (copied !== local) {
|
|
2714
|
+
findings.push(
|
|
2715
|
+
F(
|
|
2716
|
+
'link-stale-copy',
|
|
2717
|
+
'warn',
|
|
2718
|
+
`the profile holds a copy of ${name}@${copied ?? '?'} while this checkout is ${local ?? '?'}`,
|
|
2719
|
+
'A copy is not a link: edits to lib/ never reach the profile, so the host runs the copied code while every test you run here passes against the checkout.',
|
|
2720
|
+
`Delete "${path}", then: cmd /c mklink /J "${path}" "${dir}"`,
|
|
2721
|
+
),
|
|
2722
|
+
)
|
|
2723
|
+
}
|
|
2724
|
+
return { ...link, findings }
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
// Node reports a junction and a directory symlink identically: both are
|
|
2728
|
+
// symbolic links. The junction `mklink /J` creates without admin rights is
|
|
2729
|
+
// the one whose realpath leaves the link path. A dead link no longer
|
|
2730
|
+
// resolves, so it reads as 'symlink' here — `resolves: false` and the error
|
|
2731
|
+
// finding below are the parts that matter. readlink still names the target.
|
|
2732
|
+
let real = null
|
|
2733
|
+
try {
|
|
2734
|
+
real = realpathSync(path)
|
|
2735
|
+
} catch {
|
|
2736
|
+
real = null
|
|
2737
|
+
}
|
|
2738
|
+
let target = real
|
|
2739
|
+
if (!target) {
|
|
2740
|
+
try {
|
|
2741
|
+
target = readlinkSync(path)
|
|
2742
|
+
} catch {
|
|
2743
|
+
target = null
|
|
2744
|
+
}
|
|
2745
|
+
}
|
|
2746
|
+
link.kind = real && real !== path ? 'junction' : 'symlink'
|
|
2747
|
+
link.target = target
|
|
2748
|
+
|
|
2749
|
+
const targetPkg = target ? safeText(join(target, 'package.json')) : null
|
|
2750
|
+
let parsed = null
|
|
2751
|
+
if (targetPkg !== null) {
|
|
2752
|
+
try {
|
|
2753
|
+
parsed = JSON.parse(targetPkg.replace(/^\uFEFF/, ''))
|
|
2754
|
+
} catch {
|
|
2755
|
+
parsed = null
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
link.resolves = real !== null && parsed !== null
|
|
2759
|
+
if (!link.resolves) {
|
|
2760
|
+
findings.push(
|
|
2761
|
+
F(
|
|
2762
|
+
'link-target-missing',
|
|
2763
|
+
'error',
|
|
2764
|
+
`the profile links ${name} to a target that is not a package`,
|
|
2765
|
+
`The link at ${path} points at ${target ?? '(unreadable)'}, which ${real ? 'has no parseable package.json' : 'does not exist'}. A dead junction fails quietly: the host resolves the package to nothing and the plugin disappears with no error. This is what broke this machine's Web UI once.`,
|
|
2766
|
+
`Delete the link, then: cmd /c mklink /J "${path}" "${dir}"`,
|
|
2767
|
+
),
|
|
2768
|
+
)
|
|
2769
|
+
}
|
|
2770
|
+
return { ...link, findings }
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
export function checkStoreConflict(dir, opts = {}) {
|
|
2774
|
+
const findings = []
|
|
2775
|
+
const profileDir = profileDirOf(opts)
|
|
2776
|
+
const bundledMajor = Number(opts.bundledPnpmMajor ?? 11)
|
|
2777
|
+
const state = { profileMajor: null, bundledMajor, conflict: false, storeDir: null }
|
|
2778
|
+
const text = safeText(join(profileDir, 'node_modules', '.modules.yaml'))
|
|
2779
|
+
if (text === null) return { ...state, findings }
|
|
2780
|
+
|
|
2781
|
+
// pnpm writes this file as JSON despite the .yaml extension. The YAML shape
|
|
2782
|
+
// is accepted too, so an older profile still gets an answer instead of silence.
|
|
2783
|
+
let storeDir = null
|
|
2784
|
+
try {
|
|
2785
|
+
storeDir = JSON.parse(text.replace(/^\uFEFF/, ''))?.storeDir ?? null
|
|
2786
|
+
} catch {
|
|
2787
|
+
storeDir = null
|
|
2788
|
+
}
|
|
2789
|
+
if (!storeDir) {
|
|
2790
|
+
const m = /^\s*"?storeDir"?\s*:\s*["']?([^"'\r\n]+?)["']?\s*$/m.exec(text)
|
|
2791
|
+
storeDir = m ? m[1].trim() : null
|
|
2792
|
+
}
|
|
2793
|
+
state.storeDir = storeDir
|
|
2794
|
+
if (!storeDir) return { ...state, findings }
|
|
2795
|
+
|
|
2796
|
+
const majors = [...String(storeDir).matchAll(/(?:^|[\\/])v(\d+)(?=[\\/]|$)/g)].map((m) => Number(m[1]))
|
|
2797
|
+
const profileMajor = majors.length > 0 ? majors[majors.length - 1] : null
|
|
2798
|
+
state.profileMajor = profileMajor
|
|
2799
|
+
if (profileMajor === null) return { ...state, findings }
|
|
2800
|
+
|
|
2801
|
+
if (profileMajor !== bundledMajor) {
|
|
2802
|
+
state.conflict = true
|
|
2803
|
+
findings.push(
|
|
2804
|
+
F(
|
|
2805
|
+
'pnpm-store-conflict',
|
|
2806
|
+
'warn',
|
|
2807
|
+
`the profile store is pnpm v${profileMajor}, the bundled pnpm is v${bundledMajor}`,
|
|
2808
|
+
`pnpm refuses a store written by another major, so \`dsh plugin add link:\` fails with ERR_PNPM_UNEXPECTED_STORE before it installs anything — and the message names neither the profile nor the version to match. The store is ${storeDir}.`,
|
|
2809
|
+
`Skip pnpm: cmd /c mklink /J "${join(profileDir, 'node_modules', '<name>')}" "<checkout>"`,
|
|
2810
|
+
),
|
|
2811
|
+
)
|
|
2812
|
+
}
|
|
2813
|
+
return { ...state, findings }
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
export function checkMountEntry(dir, opts = {}) {
|
|
2817
|
+
const findings = []
|
|
2818
|
+
const name = readPkgJson(dir)?.name
|
|
2819
|
+
const profileDir = profileDirOf(opts)
|
|
2820
|
+
const mount = { entryFound: false, shape: 'absent', line: null, id: null }
|
|
2821
|
+
if (!name) return { ...mount, findings }
|
|
2822
|
+
|
|
2823
|
+
const patchPath = join(profileDir, 'cordis.patch.yml')
|
|
2824
|
+
const text = safeText(patchPath)
|
|
2825
|
+
// No profile patch at all: nothing to mount, and the plugin's own bundle
|
|
2826
|
+
// patch is the supported channel, so absence is not a finding.
|
|
2827
|
+
if (text === null) return { ...mount, findings }
|
|
2828
|
+
|
|
2829
|
+
const bare = name.replace(/^@[^/]+\//, '')
|
|
2830
|
+
const lines = text.split('\n')
|
|
2831
|
+
let inInsert = false
|
|
2832
|
+
let insertIndent = 0
|
|
2833
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2834
|
+
const line = lines[i]
|
|
2835
|
+
const insert = /^(\s*)-?\s*insert:\s*$/.exec(line)
|
|
2836
|
+
if (insert) {
|
|
2837
|
+
inInsert = true
|
|
2838
|
+
insertIndent = insert[1].length
|
|
2839
|
+
continue
|
|
2840
|
+
}
|
|
2841
|
+
const row = /^(\s*)-\s*id:\s*(.+?)\s*$/.exec(line)
|
|
2842
|
+
if (row) {
|
|
2843
|
+
const indent = row[1].length
|
|
2844
|
+
const id = row[2].replace(/^['"]|['"]$/g, '')
|
|
2845
|
+
const mounted = (/^\s*name:\s*(.+?)\s*$/.exec(lines[i + 1] || '')?.[1] || '').replace(/^['"]|['"]$/g, '')
|
|
2846
|
+
if (id === name || id === bare || mounted === name) {
|
|
2847
|
+
mount.entryFound = true
|
|
2848
|
+
mount.id = id
|
|
2849
|
+
mount.line = i + 1
|
|
2850
|
+
mount.shape = inInsert && indent > insertIndent ? 'insert' : 'top-level'
|
|
2851
|
+
if (mount.shape === 'top-level') {
|
|
2852
|
+
findings.push(
|
|
2853
|
+
F(
|
|
2854
|
+
'patch-entry-top-level',
|
|
2855
|
+
'error',
|
|
2856
|
+
`the profile patch row for ${name} is a top-level "- id:" entry, so it never mounts`,
|
|
2857
|
+
`Line ${i + 1} of ${patchPath} sits outside any "- insert:" block. A top-level row overrides an entry that already exists in the tree; it does not add one, so the plugin installs, loads, and never appears. This hid two plugins for two weeks.`,
|
|
2858
|
+
`Move it under "- insert:":\n- insert:\n - id: ${bare}\n name: "${name}"`,
|
|
2859
|
+
),
|
|
2860
|
+
)
|
|
2861
|
+
}
|
|
2862
|
+
return { ...mount, findings }
|
|
2863
|
+
}
|
|
2864
|
+
continue
|
|
2865
|
+
}
|
|
2866
|
+
// a top-level key closes the insert sequence
|
|
2867
|
+
if (/^\S/.test(line)) inInsert = false
|
|
2868
|
+
}
|
|
2869
|
+
return { ...mount, findings }
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2664
2872
|
export function runStaticChecks(dir) {
|
|
2665
2873
|
return [
|
|
2666
2874
|
...checkPackageJson(dir),
|
|
@@ -2706,6 +2914,7 @@ const RULE_ORDER = [
|
|
|
2706
2914
|
'git-uncommitted', 'git-tag-missing', 'git-tag-unverified', 'git-tag-elsewhere',
|
|
2707
2915
|
'readme-tool-undocumented', 'readme-test-count',
|
|
2708
2916
|
'no-tests', 'no-test-files', 'no-test-script', 'no-engines', 'bad-repo-url', 'no-gitignore',
|
|
2917
|
+
'link-not-installed', 'link-target-missing', 'link-stale-copy', 'pnpm-store-conflict', 'patch-entry-top-level',
|
|
2709
2918
|
]
|
|
2710
2919
|
|
|
2711
2920
|
const MARK = { error: '✗', warn: '!', info: '·' }
|
package/lib/index.js
CHANGED
|
@@ -12,13 +12,16 @@ import {
|
|
|
12
12
|
checkDocs,
|
|
13
13
|
checkEntrySource,
|
|
14
14
|
checkGitState,
|
|
15
|
+
checkLocalLink,
|
|
15
16
|
checkMarketEntry,
|
|
16
17
|
checkMarketReadiness,
|
|
18
|
+
checkMountEntry,
|
|
17
19
|
checkNpmState,
|
|
18
20
|
checkPackageJson,
|
|
19
21
|
checkPack,
|
|
20
22
|
checkPatchYml,
|
|
21
23
|
checkPublishedMatches,
|
|
24
|
+
checkStoreConflict,
|
|
22
25
|
checkTests,
|
|
23
26
|
checkToolRegistrations,
|
|
24
27
|
checkClientRuntime,
|
|
@@ -477,6 +480,36 @@ export function apply(ctx, config = {}) {
|
|
|
477
480
|
},
|
|
478
481
|
]
|
|
479
482
|
|
|
483
|
+
tools.push({
|
|
484
|
+
name: 'publish_gate_link',
|
|
485
|
+
description:
|
|
486
|
+
'Answer "why is my local plugin not running?" from the profile\'s own files, with no network and no package manager. An unpublished plugin reaches the host only through a link in <profile>/node_modules plus a mount row in the profile\'s cordis.patch.yml, and every failure looks identical from the outside. Reports: link-not-installed (info) when the profile has no entry for this package; the link kind and its target — Node reports a junction and a directory symlink the same way, while a plain directory is a copy; link-target-missing (error) when the link points at a path with no parseable package.json, because a dead junction resolves the package to nothing and the plugin silently disappears; link-stale-copy (warn) when the profile holds a copy whose version differs from this checkout, so edits to lib/ never reach it; pnpm-store-conflict (warn) when the profile\'s pnpm store major differs from the bundled pnpm (v11) — that is the ERR_PNPM_UNEXPECTED_STORE that makes `dsh plugin add link:` fail before it installs; patch-entry-top-level (error) when this package\'s row in the profile patch sits outside an "- insert:" block, which installs and never mounts. Pass profile to inspect a temp profile; the default is ~/.dsh/profiles/desktop. Returns {ok, link:{kind,target,resolves}, store:{profileMajor,bundledMajor,conflict}, mount:{entryFound,shape}, findings, report}.',
|
|
487
|
+
parameters: {
|
|
488
|
+
type: 'object',
|
|
489
|
+
properties: {
|
|
490
|
+
dir: { type: 'string', description: 'Plugin checkout directory.' },
|
|
491
|
+
profile: { type: 'string', description: 'Profile directory to inspect (default ~/.dsh/profiles/desktop). Tests pass a temp profile instead of the real one.' },
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
async execute(args) {
|
|
495
|
+
const dir = resolveDir(args.dir)
|
|
496
|
+
if (!dir) return { ok: false, error: 'no plugin directory given (dir must be a string path)' }
|
|
497
|
+
const profileDir = typeof args.profile === 'string' && args.profile.trim() ? args.profile.trim() : undefined
|
|
498
|
+
const link = checkLocalLink(dir, { profileDir })
|
|
499
|
+
const store = checkStoreConflict(dir, { profileDir })
|
|
500
|
+
const mount = checkMountEntry(dir, { profileDir })
|
|
501
|
+
const findings = [...link.findings, ...store.findings, ...mount.findings]
|
|
502
|
+
return {
|
|
503
|
+
ok: true,
|
|
504
|
+
link: { kind: link.kind, target: link.target, resolves: link.resolves },
|
|
505
|
+
store: { profileMajor: store.profileMajor, bundledMajor: store.bundledMajor, conflict: store.conflict },
|
|
506
|
+
mount: { entryFound: mount.entryFound, shape: mount.shape },
|
|
507
|
+
findings,
|
|
508
|
+
report: renderReport(findings, { title: `local link · ${dir}` }),
|
|
509
|
+
}
|
|
510
|
+
},
|
|
511
|
+
})
|
|
512
|
+
|
|
480
513
|
for (const t of tools) {
|
|
481
514
|
try {
|
|
482
515
|
ctx.tools.register({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bushizhanghan/dsh-publish-gate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "Pre-publish gate for DSH plugins: manifest, dependencies, artifact, runtime, browser half, traceability and market readiness — plus an input fuzzer that drives every declared parameter with the wrong type. 发布前的七层门禁 + 入参模糊测试。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|