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 CHANGED
@@ -1,5 +1,8 @@
1
1
  # dsh-plugin-discourse
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/dsh-plugin-discourse.svg)](https://www.npmjs.com/package/dsh-plugin-discourse)
4
+ [![Publish to npm](https://github.com/AkarinLiu/dsh-plugin-discourse/actions/workflows/publish.yml/badge.svg)](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 give it the package directory
43
- (for a clone) or a package spec such as `github:AkarinLiu/dsh-plugin-discourse`. DSH installs the
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
- gh repo clone AkarinLiu/dsh-plugin-discourse
50
- dsh plugin --profile desktop add ./dsh-plugin-discourse
54
+ dsh plugin --profile desktop add dsh-plugin-discourse
51
55
  ```
52
56
 
53
- That installs the package; select `dsh-plugin-discourse` once in the Plugins page (or through the
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 # syntax
125
- node scripts/smoke.mjs # register every tool and validate definitions offline
126
- node scripts/smoke.mjs --live # additionally call read-only tools against the configured forum
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
- - 安装:在 DSH Web UI 的 *设置 → 插件 → 安装 bundle* 中选择本仓库目录,或填入 `github:AkarinLiu/dsh-plugin-discourse`。
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 baseUrl
168
+ let parsed
169
169
  try {
170
- const parsed = new URL(declared)
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.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`)