dsh-plugin-discourse 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +164 -21
- package/cordis.patch.yml +7 -1
- package/index.js +1185 -34
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +9 -2
- package/scripts/doctor.mjs +159 -0
- package/scripts/login-test.mjs +272 -0
- package/scripts/smoke.mjs +30 -0
- package/scripts/verify-package.mjs +155 -0
package/locale/en.json
CHANGED
package/locale/zh.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-discourse",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Discourse administration and agent tools for the DeepSeek Harness:
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Discourse administration and agent tools for the DeepSeek Harness: 17 tools for reading, posting, moderating, administering, and signing in to a Discourse forum through the browser.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "AkarinLiu",
|
|
7
7
|
"type": "module",
|
|
@@ -30,6 +30,13 @@
|
|
|
30
30
|
"registry": "https://registry.npmjs.org",
|
|
31
31
|
"access": "public"
|
|
32
32
|
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "node scripts/smoke.mjs && node scripts/login-test.mjs",
|
|
35
|
+
"test:login": "node scripts/login-test.mjs",
|
|
36
|
+
"verify": "node scripts/verify-package.mjs",
|
|
37
|
+
"doctor": "node scripts/doctor.mjs",
|
|
38
|
+
"prepack": "node scripts/smoke.mjs && node scripts/login-test.mjs"
|
|
39
|
+
},
|
|
33
40
|
"exports": {
|
|
34
41
|
".": "./index.js",
|
|
35
42
|
"./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}`)
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Offline end-to-end test for the browser sign-in tool.
|
|
4
|
+
*
|
|
5
|
+
* A stub credential store, a stub authorization seam, and a stub forum exercise
|
|
6
|
+
* the real `apply()` path: start the classic authorization page, complete it
|
|
7
|
+
* with an encrypted payload, read the signed-in status, cancel a pending
|
|
8
|
+
* attempt, and sign out; then repeat through the device-code path. The test
|
|
9
|
+
* never reaches the network and never prints the user API key.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { constants, publicEncrypt } from 'node:crypto'
|
|
13
|
+
import { apply } from '../index.js'
|
|
14
|
+
|
|
15
|
+
// The plugin also reads these from the environment; keep the test deterministic.
|
|
16
|
+
delete process.env.DISCOURSE_API_KEY
|
|
17
|
+
delete process.env.DISCOURSE_API_USERNAME
|
|
18
|
+
|
|
19
|
+
const problems = []
|
|
20
|
+
const store = new Map()
|
|
21
|
+
const flows = new Map()
|
|
22
|
+
const requests = []
|
|
23
|
+
let activeController
|
|
24
|
+
let deviceSupported = false
|
|
25
|
+
let deviceAuth
|
|
26
|
+
let devicePolls = 0
|
|
27
|
+
const DEVICE_KEY = 'device-key-456'
|
|
28
|
+
|
|
29
|
+
/** Build a JSON response without a network. */
|
|
30
|
+
function json(body, status = 200) {
|
|
31
|
+
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const credentials = {
|
|
35
|
+
async readRecord(key) {
|
|
36
|
+
return store.get(key)
|
|
37
|
+
},
|
|
38
|
+
async describeRecord(key) {
|
|
39
|
+
const record = store.get(key)
|
|
40
|
+
return { configured: record !== undefined, kind: record?.kind, writable: true }
|
|
41
|
+
},
|
|
42
|
+
async modifyRecord(key, mutate) {
|
|
43
|
+
const next = await mutate(store.get(key))
|
|
44
|
+
if (next === undefined) return store.get(key)
|
|
45
|
+
store.set(key, next)
|
|
46
|
+
return next
|
|
47
|
+
},
|
|
48
|
+
async deleteRecord(key) {
|
|
49
|
+
store.delete(key)
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const authorization = {
|
|
54
|
+
registerFlow(flow) {
|
|
55
|
+
flows.set(flow.key, flow)
|
|
56
|
+
return () => {
|
|
57
|
+
flows.delete(flow.key)
|
|
58
|
+
this.cancel(flow.key)
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
cancel() {
|
|
62
|
+
activeController?.abort()
|
|
63
|
+
},
|
|
64
|
+
async begin({ key, signal, interaction }) {
|
|
65
|
+
const flow = flows.get(key)
|
|
66
|
+
if (flow === undefined) {
|
|
67
|
+
const error = new Error(`no authorization flow for ${key}`)
|
|
68
|
+
error.code = 'NO_FLOW'
|
|
69
|
+
throw error
|
|
70
|
+
}
|
|
71
|
+
activeController = new AbortController()
|
|
72
|
+
const combined = signal ?? activeController.signal
|
|
73
|
+
const session = {
|
|
74
|
+
method: flow.methods[0].id,
|
|
75
|
+
signal: combined,
|
|
76
|
+
notify: (notice) => interaction.notify(notice),
|
|
77
|
+
prompt: (question) => interaction.prompt(question),
|
|
78
|
+
commit: async (record) => {
|
|
79
|
+
await credentials.modifyRecord(key, () => Promise.resolve(record))
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
await flow.run(session)
|
|
84
|
+
return { status: 'authorized' }
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (combined.aborted) return { status: 'cancelled' }
|
|
87
|
+
throw error
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
globalThis.fetch = async (input, init = {}) => {
|
|
93
|
+
const url = new URL(String(input))
|
|
94
|
+
const headers = new Headers(init.headers)
|
|
95
|
+
requests.push({ path: url.pathname, method: init.method ?? 'GET', headers })
|
|
96
|
+
if (url.pathname === '/user-api-key/device.json') {
|
|
97
|
+
if (!deviceSupported) {
|
|
98
|
+
// Pretend this is a forum older than the device authorization flow.
|
|
99
|
+
return json({ errors: ['not found'] }, 404)
|
|
100
|
+
}
|
|
101
|
+
const body = JSON.parse(init.body)
|
|
102
|
+
deviceAuth = { publicKey: body.public_key, nonce: body.nonce }
|
|
103
|
+
devicePolls = 0
|
|
104
|
+
return json({
|
|
105
|
+
device_code: 'a'.repeat(64),
|
|
106
|
+
user_code: 'ABCD-EFGH',
|
|
107
|
+
verification_uri: 'https://forum.test/user-api-key/activate',
|
|
108
|
+
verification_uri_with_request: 'https://forum.test/user-api-key/activate?request=abcd1234',
|
|
109
|
+
expires_in: 600,
|
|
110
|
+
interval: 1,
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
if (url.pathname === '/user-api-key/device/poll.json') {
|
|
114
|
+
if (!deviceSupported) return json({ errors: ['not found'] }, 404)
|
|
115
|
+
devicePolls += 1
|
|
116
|
+
if (devicePolls === 1) return json({ status: 'authorization_pending' })
|
|
117
|
+
const plaintext = JSON.stringify({ key: DEVICE_KEY, nonce: deviceAuth.nonce, push: false, api: 4 })
|
|
118
|
+
const payload = publicEncrypt(
|
|
119
|
+
{ key: deviceAuth.publicKey, padding: constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha1' },
|
|
120
|
+
Buffer.from(plaintext, 'utf8'),
|
|
121
|
+
).toString('base64')
|
|
122
|
+
return json({ status: 'authorized', payload })
|
|
123
|
+
}
|
|
124
|
+
if (url.pathname === '/session/current.json') {
|
|
125
|
+
const key = headers.get('user-api-key')
|
|
126
|
+
if (key === 'user-key-123') return json({ current_user: { id: 7, username: 'tester' } })
|
|
127
|
+
if (key === DEVICE_KEY) return json({ current_user: { id: 8, username: 'device-tester' } })
|
|
128
|
+
return json({ errors: ['bad key'] }, 403)
|
|
129
|
+
}
|
|
130
|
+
if (url.pathname === '/user-api-key/revoke.json') {
|
|
131
|
+
const key = headers.get('user-api-key')
|
|
132
|
+
if (key === 'user-key-123' || key === DEVICE_KEY) return json({ success: 'OK' })
|
|
133
|
+
return json({ errors: ['bad key'] }, 403)
|
|
134
|
+
}
|
|
135
|
+
return json({ errors: [`unexpected ${url.pathname}`] }, 500)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const registry = new Map()
|
|
139
|
+
const ctx = {
|
|
140
|
+
tools: {
|
|
141
|
+
register(tool) {
|
|
142
|
+
registry.set(tool.name, tool)
|
|
143
|
+
return () => registry.delete(tool.name)
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
effect(callback) {
|
|
147
|
+
const dispose = callback()
|
|
148
|
+
return () => {
|
|
149
|
+
if (typeof dispose === 'function') dispose()
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
get(name) {
|
|
153
|
+
if (name === 'credentials') return credentials
|
|
154
|
+
if (name === 'authorization') return authorization
|
|
155
|
+
return undefined
|
|
156
|
+
},
|
|
157
|
+
inject(_deps, callback) {
|
|
158
|
+
return callback(ctx)
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
apply(ctx, {
|
|
163
|
+
baseUrl: 'https://forum.test',
|
|
164
|
+
apiKey: '',
|
|
165
|
+
authMode: 'auto',
|
|
166
|
+
loginScopes: 'read,write,session_info',
|
|
167
|
+
loginTimeoutMs: 60000,
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const login = registry.get('discourse_login')
|
|
171
|
+
if (login === undefined) problems.push('discourse_login was not registered')
|
|
172
|
+
|
|
173
|
+
if (problems.length === 0) {
|
|
174
|
+
const started = await login.execute({ action: 'start' }, {})
|
|
175
|
+
if (started.signedIn !== false) problems.push('start should report signed out')
|
|
176
|
+
if (started.attempt?.status !== 'pending') problems.push(`start should report a pending attempt, got ${started.attempt?.status}`)
|
|
177
|
+
if (started.attempt?.mode !== 'manual') problems.push(`classic fallback should use manual mode, got ${started.attempt?.mode}`)
|
|
178
|
+
if (typeof started.attempt?.url !== 'string') problems.push('start did not return an authorization URL')
|
|
179
|
+
|
|
180
|
+
const startView = login.output.render({ action: 'start' }, started).map((block) => block.text).join('\n')
|
|
181
|
+
if (!startView.includes(started.attempt.url)) problems.push('start render did not include the authorization URL')
|
|
182
|
+
if (!startView.includes('discourse_login')) problems.push('start render did not explain the next status call')
|
|
183
|
+
|
|
184
|
+
const url = new URL(started.attempt.url)
|
|
185
|
+
if (!url.pathname.endsWith('/user-api-key/new')) problems.push(`unexpected authorization path ${url.pathname}`)
|
|
186
|
+
if (url.searchParams.get('client_id') !== 'dsh-plugin-discourse') problems.push('authorization URL lost the client id')
|
|
187
|
+
if (url.searchParams.has('padding')) problems.push('classic fallback should let the forum use its default padding')
|
|
188
|
+
const publicKey = url.searchParams.get('public_key')
|
|
189
|
+
const nonce = url.searchParams.get('nonce')
|
|
190
|
+
if (typeof publicKey !== 'string' || !publicKey.includes('BEGIN PUBLIC KEY')) problems.push('authorization URL has no PEM public key')
|
|
191
|
+
if (typeof nonce !== 'string' || nonce.length < 32) problems.push('authorization URL has no nonce')
|
|
192
|
+
|
|
193
|
+
const plaintext = JSON.stringify({ key: 'user-key-123', nonce, push: false, api: 4 })
|
|
194
|
+
// The classic page omits `padding`, so Discourse encrypts with PKCS#1 there;
|
|
195
|
+
// the device path uses OAEP. Both decryption branches are covered here.
|
|
196
|
+
const payload = publicEncrypt(
|
|
197
|
+
{ key: publicKey, padding: constants.RSA_PKCS1_PADDING },
|
|
198
|
+
Buffer.from(plaintext, 'utf8'),
|
|
199
|
+
).toString('base64')
|
|
200
|
+
|
|
201
|
+
const completed = await login.execute({ action: 'complete', payload }, {})
|
|
202
|
+
if (completed.signedIn !== true) problems.push('complete should report signed in')
|
|
203
|
+
if (completed.session?.username !== 'tester') problems.push(`complete should read the account, got ${JSON.stringify(completed.session)}`)
|
|
204
|
+
if (completed.session?.scopes?.join(',') !== 'read,write,session_info') problems.push('session scopes did not round-trip')
|
|
205
|
+
if (JSON.stringify(completed).includes('user-key-123')) problems.push('complete leaked the user API key')
|
|
206
|
+
|
|
207
|
+
const status = await login.execute({ action: 'status' }, {})
|
|
208
|
+
if (status.signedIn !== true) problems.push('status should report signed in after complete')
|
|
209
|
+
if (status.activeSource !== 'user-session') problems.push(`status source should be user-session, got ${status.activeSource}`)
|
|
210
|
+
if (JSON.stringify(status).includes('user-key-123')) problems.push('status leaked the user API key')
|
|
211
|
+
|
|
212
|
+
const record = store.get('discourse/session')
|
|
213
|
+
if (record?.kind !== 'grant') problems.push('the session record is not a grant')
|
|
214
|
+
if (record?.payload?.baseUrl !== 'https://forum.test') problems.push('the session record has the wrong forum')
|
|
215
|
+
if (record?.payload?.clientId !== 'dsh-plugin-discourse') problems.push('the session record has the wrong client id')
|
|
216
|
+
|
|
217
|
+
const rendered = login.output.render({ action: 'status' }, status).map((block) => block.text).join('\n')
|
|
218
|
+
if (!rendered.includes('@tester')) problems.push(`render did not name the account: ${rendered}`)
|
|
219
|
+
if (rendered.includes('user-key-123')) problems.push('render leaked the user API key')
|
|
220
|
+
|
|
221
|
+
const signedOut = await login.execute({ action: 'logout' }, {})
|
|
222
|
+
if (signedOut.signedOut !== true) problems.push('logout should report signed out')
|
|
223
|
+
if (signedOut.revoked !== true) problems.push('logout should revoke the forum key')
|
|
224
|
+
if (store.size !== 0) problems.push('logout did not delete the local session record')
|
|
225
|
+
|
|
226
|
+
const after = await login.execute({ action: 'status' }, {})
|
|
227
|
+
if (after.signedIn !== false) problems.push('status should report signed out after logout')
|
|
228
|
+
|
|
229
|
+
const revoke = requests.find((request) => request.path === '/user-api-key/revoke.json')
|
|
230
|
+
if (revoke === undefined) problems.push('logout did not call the revoke endpoint')
|
|
231
|
+
else if (revoke.headers.get('user-api-key') !== 'user-key-123') problems.push('logout did not authenticate with the user API key')
|
|
232
|
+
|
|
233
|
+
const restarted = await login.execute({ action: 'start', mode: 'manual' }, {})
|
|
234
|
+
if (restarted.attempt?.status !== 'pending') problems.push('manual restart should be pending before cancel')
|
|
235
|
+
const cancelled = await login.execute({ action: 'cancel' }, {})
|
|
236
|
+
if (cancelled.cancelled !== true) problems.push('cancel should report that an attempt was cancelled')
|
|
237
|
+
if (cancelled.signedIn !== false) problems.push('cancel should leave the session signed out')
|
|
238
|
+
if (cancelled.attempt !== undefined) problems.push('cancel should clear the pending attempt')
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// The device flow is the preferred path on a current forum: request a code,
|
|
242
|
+
// poll while pending, then accept the encrypted payload.
|
|
243
|
+
if (problems.length === 0) {
|
|
244
|
+
deviceSupported = true
|
|
245
|
+
const started = await login.execute({ action: 'start', mode: 'device' }, {})
|
|
246
|
+
if (started.signedIn !== false) problems.push('device start should report signed out')
|
|
247
|
+
if (started.attempt?.status !== 'pending') problems.push(`device start should report pending, got ${started.attempt?.status}`)
|
|
248
|
+
if (started.attempt?.mode !== 'device') problems.push(`device start should report device mode, got ${started.attempt?.mode}`)
|
|
249
|
+
if (started.attempt?.userCode !== 'ABCD-EFGH') problems.push(`device start lost the user code: ${started.attempt?.userCode}`)
|
|
250
|
+
if (!String(started.attempt?.url).includes('/user-api-key/activate')) problems.push('device start did not return the activation URL')
|
|
251
|
+
|
|
252
|
+
const waited = await login.execute({ action: 'status', waitSeconds: 10 }, {})
|
|
253
|
+
if (waited.signedIn !== true) problems.push('device status wait should report signed in')
|
|
254
|
+
if (waited.session?.username !== 'device-tester') problems.push(`device sign-in should read the account, got ${JSON.stringify(waited.session)}`)
|
|
255
|
+
if (JSON.stringify(waited).includes(DEVICE_KEY)) problems.push('device status leaked the user API key')
|
|
256
|
+
if (devicePolls < 2) problems.push(`device flow should poll at least twice, polled ${devicePolls}`)
|
|
257
|
+
|
|
258
|
+
const record = store.get('discourse/session')
|
|
259
|
+
if (record?.payload?.source !== 'device') problems.push(`device session source should be device, got ${record?.payload?.source}`)
|
|
260
|
+
|
|
261
|
+
const signedOut = await login.execute({ action: 'logout' }, {})
|
|
262
|
+
if (signedOut.revoked !== true) problems.push('device logout should revoke the forum key')
|
|
263
|
+
if (store.size !== 0) problems.push('device logout did not delete the local session record')
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (problems.length > 0) {
|
|
267
|
+
console.error('browser login test failed:')
|
|
268
|
+
for (const problem of problems) console.error(` - ${problem}`)
|
|
269
|
+
process.exit(1)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
console.log('ok: browser login starts, completes, reports, and revokes offline')
|
package/scripts/smoke.mjs
CHANGED
|
@@ -30,6 +30,12 @@ const ctx = {
|
|
|
30
30
|
if (typeof dispose === 'function') dispose()
|
|
31
31
|
}
|
|
32
32
|
},
|
|
33
|
+
get() {
|
|
34
|
+
return undefined
|
|
35
|
+
},
|
|
36
|
+
inject(_deps, callback) {
|
|
37
|
+
return callback(ctx)
|
|
38
|
+
},
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
apply(ctx, { baseUrl, readOnly: true, maxItems: 5 })
|
|
@@ -51,6 +57,7 @@ const expected = [
|
|
|
51
57
|
'discourse_moderate_user',
|
|
52
58
|
'discourse_site_settings',
|
|
53
59
|
'discourse_admin_stats',
|
|
60
|
+
'discourse_login',
|
|
54
61
|
]
|
|
55
62
|
|
|
56
63
|
for (const name of expected) {
|
|
@@ -74,6 +81,29 @@ for (const [name, tool] of registry) {
|
|
|
74
81
|
}
|
|
75
82
|
}
|
|
76
83
|
|
|
84
|
+
// The login tool must report a signed-out state without the credential store,
|
|
85
|
+
// and it must not expose a key it never stored.
|
|
86
|
+
try {
|
|
87
|
+
const value = await registry.get('discourse_login').execute({ action: 'status' }, {})
|
|
88
|
+
if (value?.signedIn !== false) problems.push('discourse_login status should report signed out offline')
|
|
89
|
+
if (value?.storageAvailable !== false) problems.push('discourse_login status should report missing credential storage offline')
|
|
90
|
+
if (JSON.stringify(value).includes('"key"')) problems.push('discourse_login status leaked a key field')
|
|
91
|
+
} catch (error) {
|
|
92
|
+
problems.push(`discourse_login status failed offline: ${error.message}`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A malformed forum URL must fail at activation with a usable diagnostic,
|
|
96
|
+
// before any tool is registered.
|
|
97
|
+
try {
|
|
98
|
+
apply({
|
|
99
|
+
tools: { register() { problems.push('invalid baseUrl registered a tool') } },
|
|
100
|
+
effect(callback) { callback() },
|
|
101
|
+
}, { baseUrl: 'ftp://forum.example.com' })
|
|
102
|
+
problems.push('invalid baseUrl was accepted')
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (!/http\(s\) URL/.test(error.message)) problems.push(`invalid baseUrl diagnostic is unhelpful: ${error.message}`)
|
|
105
|
+
}
|
|
106
|
+
|
|
77
107
|
const describe = (value) => {
|
|
78
108
|
const rendered = value.map((block) => block.text ?? `[${block.type}]`).join('\n')
|
|
79
109
|
return rendered.length > 700 ? `${rendered.slice(0, 700)}…` : rendered
|
|
@@ -0,0 +1,155 @@
|
|
|
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/login-test.mjs',
|
|
60
|
+
'scripts/doctor.mjs',
|
|
61
|
+
'scripts/verify-package.mjs',
|
|
62
|
+
]
|
|
63
|
+
for (const relative of required) check(existsSync(join(root, relative)), `missing required file: ${relative}`)
|
|
64
|
+
|
|
65
|
+
for (const locale of ['locale/en.json', 'locale/zh.json']) {
|
|
66
|
+
const file = join(root, locale)
|
|
67
|
+
if (!existsSync(file)) continue
|
|
68
|
+
try {
|
|
69
|
+
const value = readJson(file)
|
|
70
|
+
check(typeof value?.meta?.title === 'string' && value.meta.title.length > 0, `${locale} needs meta.title`)
|
|
71
|
+
check(typeof value?.meta?.description === 'string' && value.meta.description.length > 0, `${locale} needs meta.description`)
|
|
72
|
+
} catch (error) {
|
|
73
|
+
problems.push(`${locale} is not valid JSON: ${error.message}`)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const icon = join(root, 'icon.svg')
|
|
78
|
+
if (existsSync(icon)) {
|
|
79
|
+
const size = statSync(icon).size
|
|
80
|
+
check(size > 0 && size <= 256 * 1024, `icon.svg must be between 1 byte and 256 KiB, got ${size} bytes`)
|
|
81
|
+
check(readFileSync(icon, 'utf8').includes('<svg'), 'icon.svg does not look like an SVG document')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// --- Packed file list ------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
// Run npm with its stdout attached to a real file descriptor instead of a pipe:
|
|
87
|
+
// on Windows the Harness sandbox denies piped stdio, and a pipe cannot deadlock
|
|
88
|
+
// a large pack listing when it is a file handle.
|
|
89
|
+
/** Locate npm's JavaScript entry so it can run without a command shell. */
|
|
90
|
+
function findNpmCli() {
|
|
91
|
+
const candidates = []
|
|
92
|
+
if (typeof process.env.npm_execpath === 'string') candidates.push(process.env.npm_execpath)
|
|
93
|
+
const nodeDir = dirname(process.execPath)
|
|
94
|
+
candidates.push(
|
|
95
|
+
join(nodeDir, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
96
|
+
join(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
97
|
+
join(nodeDir, '..', 'libexec', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
98
|
+
)
|
|
99
|
+
return candidates.find((candidate) => /npm-cli\.js$/i.test(candidate) && existsSync(candidate))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const npmCli = findNpmCli()
|
|
103
|
+
const useNpmCli = npmCli !== undefined
|
|
104
|
+
const command = useNpmCli ? process.execPath : process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
|
105
|
+
const args = [...(useNpmCli ? [npmCli] : []), 'pack', '--dry-run', '--json', '--ignore-scripts']
|
|
106
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'dsh-plugin-package-'))
|
|
107
|
+
const packFile = join(tempDir, 'pack.json')
|
|
108
|
+
let packOutput
|
|
109
|
+
let fd
|
|
110
|
+
try {
|
|
111
|
+
fd = openSync(packFile, 'w')
|
|
112
|
+
const result = spawnSync(command, args, {
|
|
113
|
+
cwd: root,
|
|
114
|
+
// Keep npm's cache inside this throwaway directory: the Harness sandbox
|
|
115
|
+
// cannot write the user-level cache, and CI should not depend on it either.
|
|
116
|
+
env: { ...process.env, npm_config_cache: join(tempDir, 'npm-cache') },
|
|
117
|
+
stdio: ['ignore', fd, 'inherit'],
|
|
118
|
+
shell: !useNpmCli && process.platform === 'win32',
|
|
119
|
+
})
|
|
120
|
+
closeSync(fd)
|
|
121
|
+
fd = undefined
|
|
122
|
+
if (result.error) problems.push(`npm pack failed: ${result.error.message}`)
|
|
123
|
+
else if (result.status !== 0) problems.push(`npm pack exited with status ${String(result.status)}`)
|
|
124
|
+
else packOutput = readFileSync(packFile, 'utf8')
|
|
125
|
+
} finally {
|
|
126
|
+
if (fd !== undefined) closeSync(fd)
|
|
127
|
+
rmSync(tempDir, { recursive: true, force: true })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let packed = []
|
|
131
|
+
if (packOutput) {
|
|
132
|
+
try {
|
|
133
|
+
const [info] = JSON.parse(packOutput)
|
|
134
|
+
packed = Array.isArray(info?.files) ? info.files.map((file) => file.path) : []
|
|
135
|
+
} catch (error) {
|
|
136
|
+
problems.push(`could not parse npm pack --json output: ${error.message}`)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (packed.length > 0) {
|
|
141
|
+
const present = new Set(packed)
|
|
142
|
+
for (const relative of required) {
|
|
143
|
+
check(present.has(relative), `the packed tarball omits ${relative}; add it to package.json "files"`)
|
|
144
|
+
}
|
|
145
|
+
const forbidden = packed.filter((path) => path.startsWith('node_modules/') || path.startsWith('.git/') || path.includes('.tmp') || path.endsWith('.log'))
|
|
146
|
+
check(forbidden.length === 0, `the packed tarball contains development files: ${forbidden.join(', ')}`)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (problems.length > 0) {
|
|
150
|
+
console.error('package verification failed:')
|
|
151
|
+
for (const problem of problems) console.error(` - ${problem}`)
|
|
152
|
+
process.exit(1)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
console.log(`ok: ${manifest.name}@${manifest.version} packs ${packed.length} files with every required asset`)
|