dsh-plugin-discourse 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +220 -2
- package/cordis.patch.yml +13 -0
- package/icon.svg +15 -0
- package/index.js +1240 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -4
- package/scripts/doctor.mjs +159 -0
- package/scripts/smoke.mjs +118 -0
- package/scripts/verify-package.mjs +154 -0
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-discourse",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Discourse administration and agent tools for the DeepSeek Harness: 16 tools for reading, posting, moderating, and administering a Discourse forum.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "AkarinLiu",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=20"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"dsh",
|
|
13
|
+
"deepseek-harness",
|
|
14
|
+
"discourse",
|
|
15
|
+
"forum",
|
|
16
|
+
"plugin",
|
|
17
|
+
"agent-tools",
|
|
18
|
+
"moderation",
|
|
19
|
+
"chatops"
|
|
20
|
+
],
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/AkarinLiu/dsh-plugin-discourse.git"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/AkarinLiu/dsh-plugin-discourse#readme",
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/AkarinLiu/dsh-plugin-discourse/issues"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"registry": "https://registry.npmjs.org",
|
|
31
|
+
"access": "public"
|
|
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
|
+
},
|
|
39
|
+
"exports": {
|
|
40
|
+
".": "./index.js",
|
|
41
|
+
"./locale/*.json": "./locale/*.json",
|
|
42
|
+
"./icon": "./icon.svg"
|
|
43
|
+
},
|
|
44
|
+
"files": [
|
|
45
|
+
"index.js",
|
|
46
|
+
"cordis.patch.yml",
|
|
47
|
+
"README.md",
|
|
48
|
+
"LICENSE",
|
|
49
|
+
"locale/*.json",
|
|
50
|
+
"icon.svg",
|
|
51
|
+
"scripts/"
|
|
52
|
+
],
|
|
53
|
+
"dsh": {
|
|
54
|
+
"bundle": {
|
|
55
|
+
"patch": "./cordis.patch.yml"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -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}`)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Offline and live smoke test for the Discourse plugin.
|
|
4
|
+
*
|
|
5
|
+
* The stub context mirrors what the Loader provides to `apply`, so this
|
|
6
|
+
* exercises the real registration path without booting a Harness.
|
|
7
|
+
*
|
|
8
|
+
* node scripts/smoke.mjs definition checks only
|
|
9
|
+
* node scripts/smoke.mjs --live also call read-only tools against the forum
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { apply } from '../index.js'
|
|
13
|
+
|
|
14
|
+
const live = process.argv.includes('--live')
|
|
15
|
+
const baseUrl = process.env.DISCOURSE_BASE_URL ?? 'https://meta.discourse.org'
|
|
16
|
+
const registry = new Map()
|
|
17
|
+
const problems = []
|
|
18
|
+
|
|
19
|
+
const ctx = {
|
|
20
|
+
tools: {
|
|
21
|
+
register(definition) {
|
|
22
|
+
if (registry.has(definition.name)) problems.push(`duplicate tool name ${definition.name}`)
|
|
23
|
+
registry.set(definition.name, definition)
|
|
24
|
+
return () => registry.delete(definition.name)
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
effect(callback, label) {
|
|
28
|
+
const dispose = callback()
|
|
29
|
+
return () => {
|
|
30
|
+
if (typeof dispose === 'function') dispose()
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
apply(ctx, { baseUrl, readOnly: true, maxItems: 5 })
|
|
36
|
+
|
|
37
|
+
const expected = [
|
|
38
|
+
'discourse_search',
|
|
39
|
+
'discourse_topics',
|
|
40
|
+
'discourse_topic',
|
|
41
|
+
'discourse_categories',
|
|
42
|
+
'discourse_user',
|
|
43
|
+
'discourse_create_topic',
|
|
44
|
+
'discourse_reply',
|
|
45
|
+
'discourse_update_post',
|
|
46
|
+
'discourse_flag_post',
|
|
47
|
+
'discourse_review_queue',
|
|
48
|
+
'discourse_delete_post',
|
|
49
|
+
'discourse_restore_post',
|
|
50
|
+
'discourse_admin_users',
|
|
51
|
+
'discourse_moderate_user',
|
|
52
|
+
'discourse_site_settings',
|
|
53
|
+
'discourse_admin_stats',
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
for (const name of expected) {
|
|
57
|
+
if (!registry.has(name)) problems.push(`missing tool ${name}`)
|
|
58
|
+
}
|
|
59
|
+
for (const name of registry.keys()) {
|
|
60
|
+
if (!expected.includes(name)) problems.push(`unexpected tool ${name}`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const [name, tool] of registry) {
|
|
64
|
+
if (!/^discourse_[a-z_]+$/.test(name)) problems.push(`${name}: name must match discourse_[a-z_]+`)
|
|
65
|
+
if (typeof tool.description !== 'string' || tool.description.length < 40) problems.push(`${name}: description too short`)
|
|
66
|
+
if (tool.parameters?.type !== 'object') problems.push(`${name}: parameters must be an object schema`)
|
|
67
|
+
if (tool.parameters?.additionalProperties !== false) problems.push(`${name}: parameters must set additionalProperties false`)
|
|
68
|
+
if (typeof tool.output?.render !== 'function') problems.push(`${name}: output.render missing`)
|
|
69
|
+
if (tool.output?.schema?.type !== 'object') problems.push(`${name}: output schema must be an object`)
|
|
70
|
+
if (typeof tool.execute !== 'function') problems.push(`${name}: execute missing`)
|
|
71
|
+
for (const [key, schema] of Object.entries(tool.parameters?.properties ?? {})) {
|
|
72
|
+
if (typeof schema.description !== 'string' || schema.description.length === 0) problems.push(`${name}.${key}: parameter needs a description`)
|
|
73
|
+
if (schema.type === undefined && schema.oneOf === undefined) problems.push(`${name}.${key}: parameter needs a type`)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
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
|
+
|
|
89
|
+
const describe = (value) => {
|
|
90
|
+
const rendered = value.map((block) => block.text ?? `[${block.type}]`).join('\n')
|
|
91
|
+
return rendered.length > 700 ? `${rendered.slice(0, 700)}…` : rendered
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (live) {
|
|
95
|
+
const cases = [
|
|
96
|
+
['discourse_categories', {}],
|
|
97
|
+
['discourse_topics', { filter: 'latest', limit: 3 }],
|
|
98
|
+
['discourse_search', { q: 'api', limit: 3 }],
|
|
99
|
+
['discourse_topic', { topic: 1, limit: 2 }],
|
|
100
|
+
['discourse_user', { username: 'system' }],
|
|
101
|
+
]
|
|
102
|
+
for (const [name, args] of cases) {
|
|
103
|
+
try {
|
|
104
|
+
const value = await registry.get(name).execute(args, { signal: AbortSignal.timeout(30000) })
|
|
105
|
+
console.log(`\n=== ${name} ===\n${describe(registry.get(name).output.render(args, value))}`)
|
|
106
|
+
} catch (error) {
|
|
107
|
+
problems.push(`${name}: ${error.message}`)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (problems.length > 0) {
|
|
113
|
+
console.error('smoke test failed:')
|
|
114
|
+
for (const problem of problems) console.error(` - ${problem}`)
|
|
115
|
+
process.exit(1)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
console.log(`ok: ${registry.size} tools registered${live ? ' (live calls included)' : ''}`)
|
|
@@ -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`)
|