dsh-plugin-discourse 1.0.0 → 1.0.1
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/README.md +91 -13
- package/index.js +6 -4
- package/package.json +7 -1
- package/scripts/doctor.mjs +159 -0
- package/scripts/smoke.mjs +12 -0
- package/scripts/verify-package.mjs +154 -0
package/README.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# dsh-plugin-discourse
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-plugin-discourse)
|
|
4
|
+
[](https://github.com/AkarinLiu/dsh-plugin-discourse/actions/workflows/publish.yml)
|
|
5
|
+
|
|
3
6
|
Discourse administration and agent tools for the [DeepSeek Harness](https://github.com/deepseek-ai).
|
|
4
7
|
|
|
5
8
|
One Host plugin registers 16 model-facing tools over the Discourse REST API: an agent can search a
|
|
@@ -37,27 +40,62 @@ thread search never floods the model context. Results keep ids, so a follow-up c
|
|
|
37
40
|
## Install
|
|
38
41
|
|
|
39
42
|
The bundle is a profile plugin: it is installed into one DSH profile and selected in that profile's
|
|
40
|
-
bundle list.
|
|
43
|
+
bundle list. Install it through the Web UI when you can: DSH's bundle installer replaces an existing
|
|
44
|
+
copy or local link before it writes the package, while the CLI is a thin pnpm wrapper.
|
|
41
45
|
|
|
42
|
-
**From the Web UI** — open *Settings → Plugins → Install bundle* and
|
|
43
|
-
|
|
44
|
-
package, adds it to the profile's bundle list, and activates it.
|
|
46
|
+
**From the Web UI (recommended)** — open *Settings → Plugins → Install bundle* and enter
|
|
47
|
+
`dsh-plugin-discourse` for the npm package, or `github:AkarinLiu/dsh-plugin-discourse`, or a local
|
|
48
|
+
clone path. DSH installs the package, adds it to the profile's bundle list, and activates it.
|
|
45
49
|
|
|
46
|
-
**From the CLI** — the launcher passes everything after `plugin` to pnpm
|
|
50
|
+
**From npm on the CLI** — the launcher passes everything after `plugin` to pnpm, so this is a plain
|
|
51
|
+
pnpm install rather than the bundle installer:
|
|
47
52
|
|
|
48
53
|
```bash
|
|
49
|
-
|
|
50
|
-
dsh plugin --profile desktop add ./dsh-plugin-discourse
|
|
54
|
+
dsh plugin --profile desktop add dsh-plugin-discourse
|
|
51
55
|
```
|
|
52
56
|
|
|
53
|
-
|
|
57
|
+
After a CLI install, select `dsh-plugin-discourse` once in the Plugins page (or through the
|
|
54
58
|
`plugin_manager` tool) so the profile records it as a bundle.
|
|
55
59
|
|
|
60
|
+
**From a clone** — `gh repo clone AkarinLiu/dsh-plugin-discourse`, then
|
|
61
|
+
`dsh plugin --profile desktop add ./dsh-plugin-discourse`. On Windows this creates a directory
|
|
62
|
+
junction in the profile; read the next section before moving that profile to the npm package.
|
|
63
|
+
|
|
56
64
|
To remove it, uninstall the bundle from the Plugins page, which drops the dependency and the bundle
|
|
57
65
|
entry together.
|
|
58
66
|
|
|
59
67
|
> The plugin is Host-only: no build step, no dependencies, no client assets.
|
|
60
68
|
|
|
69
|
+
### Windows: EPERM when replacing a local link
|
|
70
|
+
|
|
71
|
+
A clone install leaves `node_modules\dsh-plugin-discourse` as a directory junction. pnpm's hoisted
|
|
72
|
+
linker does not always record or prune that junction, so a later npm install cannot replace it and
|
|
73
|
+
Windows refuses the rename:
|
|
74
|
+
|
|
75
|
+
```text
|
|
76
|
+
[ERR_PNPM_EPERM] [importPackage ...\node_modules\dsh-plugin-discourse]
|
|
77
|
+
EPERM: operation not permitted, rename '...dsh-plugin-discourse_tmp_...' -> '...dsh-plugin-discourse'
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The checkout is untouched; only the link is stale. From a clone of this repository, let the bundled
|
|
81
|
+
doctor remove the link and then reinstall:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
node scripts/doctor.mjs --profile desktop --fix
|
|
85
|
+
dsh plugin --profile desktop add dsh-plugin-discourse
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Or delete the junction by hand and retry:
|
|
89
|
+
|
|
90
|
+
```powershell
|
|
91
|
+
$link = "$env:USERPROFILE\.dsh\profiles\desktop\node_modules\dsh-plugin-discourse"
|
|
92
|
+
if ((Get-Item $link -Force -ErrorAction SilentlyContinue).LinkType -eq 'Junction') {
|
|
93
|
+
(Get-Item $link -Force).Delete()
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Restart the Harness after the package is replaced so the new module generation loads.
|
|
98
|
+
|
|
61
99
|
## Configure
|
|
62
100
|
|
|
63
101
|
The bundle ships its defaults in `cordis.patch.yml`, which DSH applies as the bundle layer. To point
|
|
@@ -121,18 +159,58 @@ Row config wins over the environment when both are present.
|
|
|
121
159
|
The package is plain ESM with no runtime dependencies; `index.js` is the whole plugin.
|
|
122
160
|
|
|
123
161
|
```bash
|
|
124
|
-
node --check index.js
|
|
125
|
-
|
|
126
|
-
node scripts/smoke.mjs --live
|
|
162
|
+
node --check index.js # syntax
|
|
163
|
+
npm test # register every tool and validate definitions offline
|
|
164
|
+
node scripts/smoke.mjs --live # additionally call read-only tools against the forum
|
|
165
|
+
npm run verify # build the npm tarball and check its file list
|
|
166
|
+
node scripts/doctor.mjs --profile desktop # inspect a profile for a stale local link
|
|
127
167
|
```
|
|
128
168
|
|
|
129
169
|
`scripts/smoke.mjs` drives `apply()` with a stub context, so it exercises the real registration path
|
|
130
|
-
without starting a Harness.
|
|
170
|
+
without starting a Harness. `scripts/verify-package.mjs` packs the package with `npm pack` and fails
|
|
171
|
+
when a runtime file is missing from `files`, when the manifest loses `dsh.bundle`, or when a locale
|
|
172
|
+
loses its display metadata. `prepack` runs the smoke test, so a broken package cannot be published.
|
|
173
|
+
|
|
174
|
+
## Release
|
|
175
|
+
|
|
176
|
+
Releases are automated by [`.github/workflows/publish.yml`](.github/workflows/publish.yml): every
|
|
177
|
+
push to `main` or a `v*` tag, and every pull request, verifies the package, and a `v*` tag publishes it to
|
|
178
|
+
[npmjs.com](https://www.npmjs.com/package/dsh-plugin-discourse) with
|
|
179
|
+
[provenance](https://docs.npmjs.com/generating-provenance-statements).
|
|
180
|
+
|
|
181
|
+
1. Bump `version` in `package.json` (the tag must match it exactly).
|
|
182
|
+
2. Commit, then tag and push:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
git tag -a v1.0.1 -m "dsh-plugin-discourse 1.0.1"
|
|
186
|
+
git push origin main --follow-tags
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
3. The workflow runs on Node 24, verifies the tarball, checks `v<version>`, and publishes with
|
|
190
|
+
`--provenance --access public`.
|
|
191
|
+
|
|
192
|
+
Before the first release, add a **Trusted Publisher** on npmjs.com (package page → *Settings* →
|
|
193
|
+
*Trusted Publishing* → *GitHub Actions*):
|
|
194
|
+
|
|
195
|
+
| Field | Value |
|
|
196
|
+
|---|---|
|
|
197
|
+
| Repository | `AkarinLiu/dsh-plugin-discourse` |
|
|
198
|
+
| Workflow file | `publish.yml` |
|
|
199
|
+
| Environment | *(leave empty)* |
|
|
200
|
+
| Allowed action | `npm publish` |
|
|
201
|
+
|
|
202
|
+
No `NPM_TOKEN` secret is needed: the workflow exchanges the GitHub OIDC token
|
|
203
|
+
(`id-token: write`) for publish rights and attaches provenance. npm now requires an interactive 2FA
|
|
204
|
+
challenge for token and trusted-publisher management, so this one-time setup happens in the browser;
|
|
205
|
+
after it, tagged releases are fully automatic.
|
|
131
206
|
|
|
132
207
|
## 中文说明
|
|
133
208
|
|
|
134
209
|
- 这是一个 **Host 插件**,向 DSH 智能体提供 16 个 Discourse 工具(读取 / 发帖 / 版主 / 管理员)。
|
|
135
|
-
-
|
|
210
|
+
- 安装:优先用 Web UI 的 *设置 → 插件 → 安装 bundle*,填入 `dsh-plugin-discourse`(npm)或 `github:AkarinLiu/dsh-plugin-discourse`;安装器会先替换旧的本地链接。
|
|
211
|
+
- 命令行:`dsh plugin --profile desktop add dsh-plugin-discourse` 只是把参数转交给 pnpm,安装后还需在插件页选中该 bundle。
|
|
212
|
+
- Windows 从本地克隆切到 npm 时报 `ERR_PNPM_EPERM ... rename ...dsh-plugin-discourse...`:这是 profile 里遗留的目录联接;运行 `node scripts/doctor.mjs --profile desktop --fix` 后再安装(脚本只删链接,不动源码),然后重启 Harness。
|
|
213
|
+
- 发布:推送 `v*` 标签会触发 `.github/workflows/publish.yml`。首次发布前在 npmjs.com 的包设置 → *Trusted Publishing* 添加 GitHub Actions(仓库 `AkarinLiu/dsh-plugin-discourse`、工作流 `publish.yml`、环境留空、允许 `npm publish`),之后不需要 `NPM_TOKEN`,由 OIDC 发布并生成 provenance。
|
|
136
214
|
- 配置:在 profile 的 `cordis.patch.yml` 中加一条 `id: discourse` 的覆盖,把 `baseUrl` 改成你的论坛并填入 `apiKey`(覆盖会整体替换 `config`,请把其它键一并写全)。
|
|
137
215
|
- 不需要 API Key 即可试用只读工具(默认指向 `https://meta.discourse.org`)。
|
|
138
216
|
- 密钥建议通过环境变量 `DISCOURSE_API_KEY` / `DISCOURSE_API_USERNAME` 提供,避免写进配置文件。
|
package/index.js
CHANGED
|
@@ -165,14 +165,16 @@ function day(value) {
|
|
|
165
165
|
function readConfig(raw) {
|
|
166
166
|
const source = raw !== null && typeof raw === 'object' ? raw : {}
|
|
167
167
|
const declared = optionalString(source.baseUrl) ?? DEFAULT_BASE_URL
|
|
168
|
-
let
|
|
168
|
+
let parsed
|
|
169
169
|
try {
|
|
170
|
-
|
|
171
|
-
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') fail('unsupported protocol')
|
|
172
|
-
baseUrl = `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`
|
|
170
|
+
parsed = new URL(declared)
|
|
173
171
|
} catch {
|
|
174
172
|
fail(`invalid config: \`baseUrl\` must be an http(s) URL, got ${JSON.stringify(declared)}`)
|
|
175
173
|
}
|
|
174
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
175
|
+
fail(`invalid config: \`baseUrl\` must be an http(s) URL, got ${JSON.stringify(declared)}`)
|
|
176
|
+
}
|
|
177
|
+
const baseUrl = `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`
|
|
176
178
|
const envKey = typeof process !== 'undefined' ? process.env?.DISCOURSE_API_KEY : undefined
|
|
177
179
|
const envUser = typeof process !== 'undefined' ? process.env?.DISCOURSE_API_USERNAME : undefined
|
|
178
180
|
const apiKey = optionalString(source.apiKey) ?? optionalString(envKey) ?? ''
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-discourse",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Discourse administration and agent tools for the DeepSeek Harness: 16 tools for reading, posting, moderating, and administering a Discourse forum.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "AkarinLiu",
|
|
@@ -30,6 +30,12 @@
|
|
|
30
30
|
"registry": "https://registry.npmjs.org",
|
|
31
31
|
"access": "public"
|
|
32
32
|
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "node scripts/smoke.mjs",
|
|
35
|
+
"verify": "node scripts/verify-package.mjs",
|
|
36
|
+
"doctor": "node scripts/doctor.mjs",
|
|
37
|
+
"prepack": "node scripts/smoke.mjs"
|
|
38
|
+
},
|
|
33
39
|
"exports": {
|
|
34
40
|
".": "./index.js",
|
|
35
41
|
"./locale/*.json": "./locale/*.json",
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Diagnose (and with --fix, remove) the Windows install trap where a stale
|
|
4
|
+
* directory junction for this bundle blocks a registry install:
|
|
5
|
+
*
|
|
6
|
+
* [ERR_PNPM_EPERM] [importPackage ...\node_modules\dsh-plugin-discourse]
|
|
7
|
+
* EPERM: operation not permitted, rename '..._tmp_...' -> '...dsh-plugin-discourse'
|
|
8
|
+
*
|
|
9
|
+
* A local-clone install (dsh plugin add ./dsh-plugin-discourse) creates that
|
|
10
|
+
* junction. pnpm's hoisted linker does not always record or prune it, so a
|
|
11
|
+
* later npm install cannot replace it. Removing the junction removes only the
|
|
12
|
+
* link; the checkout it points at is left untouched.
|
|
13
|
+
*
|
|
14
|
+
* node scripts/doctor.mjs --profile desktop
|
|
15
|
+
* node scripts/doctor.mjs --profile desktop --fix
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFileSync, existsSync, lstatSync, realpathSync, unlinkSync, rmdirSync } from 'node:fs'
|
|
19
|
+
import { homedir } from 'node:os'
|
|
20
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
21
|
+
|
|
22
|
+
const PACKAGE = 'dsh-plugin-discourse'
|
|
23
|
+
|
|
24
|
+
const argv = process.argv.slice(2)
|
|
25
|
+
const profileIndex = argv.indexOf('--profile')
|
|
26
|
+
const profile = profileIndex >= 0 ? argv[profileIndex + 1] : 'desktop'
|
|
27
|
+
const fix = argv.includes('--fix')
|
|
28
|
+
|
|
29
|
+
if (profileIndex >= 0 && (profile === undefined || profile.startsWith('--'))) {
|
|
30
|
+
console.error('usage: node scripts/doctor.mjs [--profile <name>] [--fix]')
|
|
31
|
+
process.exit(2)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
35
|
+
const profileDir = join(dshHome, 'profiles', profile)
|
|
36
|
+
const manifestPath = join(profileDir, 'package.json')
|
|
37
|
+
const linkPath = join(profileDir, 'node_modules', PACKAGE)
|
|
38
|
+
|
|
39
|
+
if (!existsSync(manifestPath)) {
|
|
40
|
+
console.error(`no DSH profile at ${profileDir}: open DeepSeek Harness once for that profile, then retry`)
|
|
41
|
+
process.exit(2)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Read JSON that a Windows editor or PowerShell may have prefixed with a BOM. */
|
|
45
|
+
function readJson(path) {
|
|
46
|
+
return JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, ''))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The spec the profile records for this bundle, when it records one. */
|
|
50
|
+
function dependencySpec() {
|
|
51
|
+
try {
|
|
52
|
+
const manifest = readJson(manifestPath)
|
|
53
|
+
for (const field of ['dependencies', 'devDependencies', 'optionalDependencies']) {
|
|
54
|
+
const spec = manifest?.[field]?.[PACKAGE]
|
|
55
|
+
if (typeof spec === 'string' && spec.length > 0) return spec
|
|
56
|
+
}
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error(`could not read ${manifestPath}: ${error.message}`)
|
|
59
|
+
process.exit(2)
|
|
60
|
+
}
|
|
61
|
+
return undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Inspect one node_modules entry. A junction (or symlink) resolves to a
|
|
66
|
+
* different path than the lexical entry; a normal package directory does not.
|
|
67
|
+
*/
|
|
68
|
+
function inspectEntry(path) {
|
|
69
|
+
let stats
|
|
70
|
+
try {
|
|
71
|
+
stats = lstatSync(path)
|
|
72
|
+
} catch {
|
|
73
|
+
return undefined
|
|
74
|
+
}
|
|
75
|
+
let target
|
|
76
|
+
try {
|
|
77
|
+
target = realpathSync.native(path)
|
|
78
|
+
} catch {
|
|
79
|
+
target = undefined
|
|
80
|
+
}
|
|
81
|
+
let expected = resolve(path)
|
|
82
|
+
try {
|
|
83
|
+
expected = join(realpathSync.native(dirname(path)), basename(path))
|
|
84
|
+
} catch {
|
|
85
|
+
// The parent is always readable when lstat succeeded; keep the lexical form.
|
|
86
|
+
}
|
|
87
|
+
const same = target !== undefined && (process.platform === 'win32'
|
|
88
|
+
? target.toLowerCase() === expected.toLowerCase()
|
|
89
|
+
: target === expected)
|
|
90
|
+
return { target, isLink: stats.isSymbolicLink() || !same }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Remove a link without ever recursing into its target. */
|
|
94
|
+
function removeLink(path) {
|
|
95
|
+
try {
|
|
96
|
+
unlinkSync(path)
|
|
97
|
+
return
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (!['EPERM', 'EISDIR', 'ENOTDIR', 'EACCES'].includes(error.code)) throw error
|
|
100
|
+
}
|
|
101
|
+
// On Windows rmdir deletes the junction's reparse point, not the target.
|
|
102
|
+
rmdirSync(path)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const entry = inspectEntry(linkPath)
|
|
106
|
+
|
|
107
|
+
if (entry === undefined) {
|
|
108
|
+
console.log(`ok: no stale ${PACKAGE} entry in ${profileDir}; run "dsh plugin --profile ${profile} add ${PACKAGE}" to install it`)
|
|
109
|
+
process.exit(0)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!entry.isLink) {
|
|
113
|
+
if (existsSync(join(linkPath, 'package.json'))) {
|
|
114
|
+
console.log(`ok: ${PACKAGE} is installed as a normal directory in ${profileDir}`)
|
|
115
|
+
process.exit(0)
|
|
116
|
+
}
|
|
117
|
+
console.error(`${linkPath} exists but is not a package and not a link; inspect it by hand`)
|
|
118
|
+
process.exit(1)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const spec = dependencySpec()
|
|
122
|
+
const target = entry.target ?? '(unresolvable)'
|
|
123
|
+
const localSpec = spec !== undefined && (spec.startsWith('link:') || spec.startsWith('file:'))
|
|
124
|
+
|
|
125
|
+
console.log(`found a directory link for ${PACKAGE}:`)
|
|
126
|
+
console.log(` link: ${linkPath}`)
|
|
127
|
+
console.log(` target: ${target}`)
|
|
128
|
+
console.log(` dependency: ${spec ?? '(not recorded)'}`)
|
|
129
|
+
console.log(localSpec
|
|
130
|
+
? 'The profile records a local spec, so this link is expected while the checkout is installed directly.'
|
|
131
|
+
: 'The profile records a registry spec, so this link is a leftover from an earlier local install.')
|
|
132
|
+
|
|
133
|
+
if (!fix) {
|
|
134
|
+
if (localSpec) {
|
|
135
|
+
console.log('ok: this is a local development install; rerun with --fix only when switching the profile to the npm package.')
|
|
136
|
+
process.exit(0)
|
|
137
|
+
}
|
|
138
|
+
console.error('stale link blocks "pnpm install"; re-run with --fix to remove the link only, then install again:')
|
|
139
|
+
console.error(` node scripts/doctor.mjs --profile ${profile} --fix`)
|
|
140
|
+
console.error(` dsh plugin --profile ${profile} add ${PACKAGE}`)
|
|
141
|
+
process.exit(1)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
removeLink(linkPath)
|
|
146
|
+
} catch (error) {
|
|
147
|
+
console.error(`could not remove ${linkPath}: ${error.message}`)
|
|
148
|
+
console.error('close DeepSeek Harness, then retry, or delete the junction in Explorer.')
|
|
149
|
+
process.exit(1)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (existsSync(linkPath) || inspectEntry(linkPath) !== undefined) {
|
|
153
|
+
console.error(`${linkPath} still exists after removal`)
|
|
154
|
+
process.exit(1)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
console.log(`removed the link; ${target === '(unresolvable)' ? 'the target' : target} was left untouched.`)
|
|
158
|
+
console.log('now install the bundle:')
|
|
159
|
+
console.log(` dsh plugin --profile ${profile} add ${PACKAGE}`)
|
package/scripts/smoke.mjs
CHANGED
|
@@ -74,6 +74,18 @@ for (const [name, tool] of registry) {
|
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
// A malformed forum URL must fail at activation with a usable diagnostic,
|
|
78
|
+
// before any tool is registered.
|
|
79
|
+
try {
|
|
80
|
+
apply({
|
|
81
|
+
tools: { register() { problems.push('invalid baseUrl registered a tool') } },
|
|
82
|
+
effect(callback) { callback() },
|
|
83
|
+
}, { baseUrl: 'ftp://forum.example.com' })
|
|
84
|
+
problems.push('invalid baseUrl was accepted')
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (!/http\(s\) URL/.test(error.message)) problems.push(`invalid baseUrl diagnostic is unhelpful: ${error.message}`)
|
|
87
|
+
}
|
|
88
|
+
|
|
77
89
|
const describe = (value) => {
|
|
78
90
|
const rendered = value.map((block) => block.text ?? `[${block.type}]`).join('\n')
|
|
79
91
|
return rendered.length > 700 ? `${rendered.slice(0, 700)}…` : rendered
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Verify that the published tarball contains every runtime asset the DeepSeek
|
|
4
|
+
* Harness needs: the Host entry, the bundle patch, both locales, the icon, and
|
|
5
|
+
* the manifest. Run before a release or from CI.
|
|
6
|
+
*
|
|
7
|
+
* node scripts/verify-package.mjs
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawnSync } from 'node:child_process'
|
|
11
|
+
import { closeSync, existsSync, mkdtempSync, openSync, readFileSync, rmSync, statSync } from 'node:fs'
|
|
12
|
+
import { tmpdir } from 'node:os'
|
|
13
|
+
import { dirname, join, resolve } from 'node:path'
|
|
14
|
+
import { fileURLToPath } from 'node:url'
|
|
15
|
+
|
|
16
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
17
|
+
/** Read JSON that may carry a UTF-8 BOM from a Windows editor. */
|
|
18
|
+
const readJson = (path) => JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, ''))
|
|
19
|
+
const manifest = readJson(join(root, 'package.json'))
|
|
20
|
+
const problems = []
|
|
21
|
+
|
|
22
|
+
/** Record one packaging violation. */
|
|
23
|
+
function check(condition, message) {
|
|
24
|
+
if (!condition) problems.push(message)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// --- Manifest --------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
check(manifest.private !== true, 'package.json must not set "private": true, or npm refuses to publish it')
|
|
30
|
+
check(typeof manifest.name === 'string' && manifest.name.length > 0, 'package.json needs a name')
|
|
31
|
+
check(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(manifest.version ?? ''), `package.json version is not semver: ${JSON.stringify(manifest.version)}`)
|
|
32
|
+
check(manifest.type === 'module', 'package.json must stay "type": "module"')
|
|
33
|
+
check(manifest.publishConfig?.access === 'public', 'publishConfig.access must be "public"')
|
|
34
|
+
check(manifest.publishConfig?.registry === 'https://registry.npmjs.org', 'publishConfig.registry must name npmjs.com')
|
|
35
|
+
check(manifest.engines?.node === '>=20', 'engines.node must stay ">=20"')
|
|
36
|
+
|
|
37
|
+
const patch = manifest.dsh?.bundle?.patch
|
|
38
|
+
check(typeof patch === 'string' && patch.length > 0, 'package.json must declare dsh.bundle.patch')
|
|
39
|
+
if (typeof patch === 'string') {
|
|
40
|
+
check(existsSync(join(root, patch)), `dsh.bundle.patch points at a missing file: ${patch}`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const [subpath, target] of Object.entries(manifest.exports ?? {})) {
|
|
44
|
+
if (typeof target !== 'string' || target.includes('*')) continue
|
|
45
|
+
check(existsSync(join(root, target)), `exports["${subpath}"] points at a missing file: ${target}`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// --- Local assets ----------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
const required = [
|
|
51
|
+
'index.js',
|
|
52
|
+
'cordis.patch.yml',
|
|
53
|
+
'README.md',
|
|
54
|
+
'LICENSE',
|
|
55
|
+
'locale/en.json',
|
|
56
|
+
'locale/zh.json',
|
|
57
|
+
'icon.svg',
|
|
58
|
+
'scripts/smoke.mjs',
|
|
59
|
+
'scripts/doctor.mjs',
|
|
60
|
+
'scripts/verify-package.mjs',
|
|
61
|
+
]
|
|
62
|
+
for (const relative of required) check(existsSync(join(root, relative)), `missing required file: ${relative}`)
|
|
63
|
+
|
|
64
|
+
for (const locale of ['locale/en.json', 'locale/zh.json']) {
|
|
65
|
+
const file = join(root, locale)
|
|
66
|
+
if (!existsSync(file)) continue
|
|
67
|
+
try {
|
|
68
|
+
const value = readJson(file)
|
|
69
|
+
check(typeof value?.meta?.title === 'string' && value.meta.title.length > 0, `${locale} needs meta.title`)
|
|
70
|
+
check(typeof value?.meta?.description === 'string' && value.meta.description.length > 0, `${locale} needs meta.description`)
|
|
71
|
+
} catch (error) {
|
|
72
|
+
problems.push(`${locale} is not valid JSON: ${error.message}`)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const icon = join(root, 'icon.svg')
|
|
77
|
+
if (existsSync(icon)) {
|
|
78
|
+
const size = statSync(icon).size
|
|
79
|
+
check(size > 0 && size <= 256 * 1024, `icon.svg must be between 1 byte and 256 KiB, got ${size} bytes`)
|
|
80
|
+
check(readFileSync(icon, 'utf8').includes('<svg'), 'icon.svg does not look like an SVG document')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --- Packed file list ------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
// Run npm with its stdout attached to a real file descriptor instead of a pipe:
|
|
86
|
+
// on Windows the Harness sandbox denies piped stdio, and a pipe cannot deadlock
|
|
87
|
+
// a large pack listing when it is a file handle.
|
|
88
|
+
/** Locate npm's JavaScript entry so it can run without a command shell. */
|
|
89
|
+
function findNpmCli() {
|
|
90
|
+
const candidates = []
|
|
91
|
+
if (typeof process.env.npm_execpath === 'string') candidates.push(process.env.npm_execpath)
|
|
92
|
+
const nodeDir = dirname(process.execPath)
|
|
93
|
+
candidates.push(
|
|
94
|
+
join(nodeDir, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
95
|
+
join(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
96
|
+
join(nodeDir, '..', 'libexec', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
97
|
+
)
|
|
98
|
+
return candidates.find((candidate) => /npm-cli\.js$/i.test(candidate) && existsSync(candidate))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const npmCli = findNpmCli()
|
|
102
|
+
const useNpmCli = npmCli !== undefined
|
|
103
|
+
const command = useNpmCli ? process.execPath : process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
|
104
|
+
const args = [...(useNpmCli ? [npmCli] : []), 'pack', '--dry-run', '--json', '--ignore-scripts']
|
|
105
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'dsh-plugin-package-'))
|
|
106
|
+
const packFile = join(tempDir, 'pack.json')
|
|
107
|
+
let packOutput
|
|
108
|
+
let fd
|
|
109
|
+
try {
|
|
110
|
+
fd = openSync(packFile, 'w')
|
|
111
|
+
const result = spawnSync(command, args, {
|
|
112
|
+
cwd: root,
|
|
113
|
+
// Keep npm's cache inside this throwaway directory: the Harness sandbox
|
|
114
|
+
// cannot write the user-level cache, and CI should not depend on it either.
|
|
115
|
+
env: { ...process.env, npm_config_cache: join(tempDir, 'npm-cache') },
|
|
116
|
+
stdio: ['ignore', fd, 'inherit'],
|
|
117
|
+
shell: !useNpmCli && process.platform === 'win32',
|
|
118
|
+
})
|
|
119
|
+
closeSync(fd)
|
|
120
|
+
fd = undefined
|
|
121
|
+
if (result.error) problems.push(`npm pack failed: ${result.error.message}`)
|
|
122
|
+
else if (result.status !== 0) problems.push(`npm pack exited with status ${String(result.status)}`)
|
|
123
|
+
else packOutput = readFileSync(packFile, 'utf8')
|
|
124
|
+
} finally {
|
|
125
|
+
if (fd !== undefined) closeSync(fd)
|
|
126
|
+
rmSync(tempDir, { recursive: true, force: true })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let packed = []
|
|
130
|
+
if (packOutput) {
|
|
131
|
+
try {
|
|
132
|
+
const [info] = JSON.parse(packOutput)
|
|
133
|
+
packed = Array.isArray(info?.files) ? info.files.map((file) => file.path) : []
|
|
134
|
+
} catch (error) {
|
|
135
|
+
problems.push(`could not parse npm pack --json output: ${error.message}`)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (packed.length > 0) {
|
|
140
|
+
const present = new Set(packed)
|
|
141
|
+
for (const relative of required) {
|
|
142
|
+
check(present.has(relative), `the packed tarball omits ${relative}; add it to package.json "files"`)
|
|
143
|
+
}
|
|
144
|
+
const forbidden = packed.filter((path) => path.startsWith('node_modules/') || path.startsWith('.git/') || path.includes('.tmp') || path.endsWith('.log'))
|
|
145
|
+
check(forbidden.length === 0, `the packed tarball contains development files: ${forbidden.join(', ')}`)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (problems.length > 0) {
|
|
149
|
+
console.error('package verification failed:')
|
|
150
|
+
for (const problem of problems) console.error(` - ${problem}`)
|
|
151
|
+
process.exit(1)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
console.log(`ok: ${manifest.name}@${manifest.version} packs ${packed.length} files with every required asset`)
|