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/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Discourse",
4
+ "description": "Search, read, post, moderate, and administer a Discourse forum."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Discourse 论坛",
4
+ "description": "搜索、阅读、发帖、审核与管理 Discourse 论坛。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "dsh-plugin-discourse",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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`)