writedock-mcp 0.4.2 → 0.4.6

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.
Files changed (35) hide show
  1. package/README.md +87 -63
  2. package/extension/LICENSE-MULTIPOST.txt +201 -0
  3. package/extension/LICENSE.txt +674 -0
  4. package/extension/NOTICE.txt +13 -0
  5. package/extension/assets/editor-DWoK50xk.js +76 -0
  6. package/extension/assets/editor-VBFqqvXW.css +1 -0
  7. package/extension/assets/icon-128.png +0 -0
  8. package/extension/assets/icon-16.png +0 -0
  9. package/extension/assets/icon-48.png +0 -0
  10. package/extension/assets/index.browser-Hs3ZYqOv.js +157 -0
  11. package/extension/assets/index.ts-DbQynGAJ.js +42 -0
  12. package/extension/assets/logo.svg +5 -0
  13. package/extension/assets/offscreen-3BECPbQX.js +63 -0
  14. package/extension/assets/purify.es-1yEQDPgc.js +3 -0
  15. package/extension/assets/react-vendor-BrXlnJmF.js +40 -0
  16. package/extension/assets/task-utils-DwzGcJkm.js +1 -0
  17. package/extension/manifest.json +40 -0
  18. package/extension/service-worker-loader.js +1 -0
  19. package/extension/src/editor/index.html +16 -0
  20. package/extension/src/offscreen/index.html +6 -0
  21. package/extension/zhihu-publication-observer.js +1 -0
  22. package/extension//345/256/211/350/243/205/344/270/216/346/233/264/346/226/260.txt +19 -0
  23. package/index.cjs +330 -69
  24. package/package.json +3 -2
  25. package/source/package.json +1 -1
  26. package/source/src/__tests__/bridge.test.ts +22 -0
  27. package/source/src/__tests__/extension-update.test.ts +54 -0
  28. package/source/src/__tests__/setup.test.ts +50 -0
  29. package/source/src/bridge.ts +40 -11
  30. package/source/src/extension-id.ts +2 -0
  31. package/source/src/extension-update.ts +82 -0
  32. package/source/src/index.ts +3 -0
  33. package/source/src/release-info.ts +3 -0
  34. package/source/src/server.ts +45 -3
  35. package/source/src/setup.ts +88 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "writedock-mcp",
3
- "version": "0.4.2",
3
+ "version": "0.4.6",
4
4
  "description": "WriteDock local MCP server for the browser extension",
5
5
  "type": "commonjs",
6
6
  "license": "GPL-3.0-only",
@@ -17,7 +17,8 @@
17
17
  "LICENSE-MULTIPOST",
18
18
  "NOTICE.md",
19
19
  "THIRD_PARTY_NOTICES.txt",
20
- "source"
20
+ "source",
21
+ "extension"
21
22
  ],
22
23
  "keywords": [
23
24
  "mcp",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@writedock/mcp-server",
3
- "version": "0.4.2",
3
+ "version": "0.4.6",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "WriteDock local MCP server for the browser extension",
@@ -2,7 +2,9 @@ import { afterEach, describe, expect, it } from 'vitest'
2
2
  import { createServer } from 'node:net'
3
3
  import { once } from 'node:events'
4
4
  import { randomBytes } from 'node:crypto'
5
+ import http from 'node:http'
5
6
  import { WebSocket } from 'ws'
7
+ import { EXTENSION_ORIGIN } from '../extension-id'
6
8
  import { LocalBridge, proof, requestProof } from '../bridge'
7
9
 
8
10
  const token = 'test-pairing-key-that-is-long-enough'
@@ -31,6 +33,26 @@ async function pair(bridge: LocalBridge, key = token) {
31
33
  afterEach(async () => { clients.splice(0).forEach(ws => ws.terminate()); await Promise.all(brokers.splice(0).map(value => value.close())) })
32
34
 
33
35
  describe('authenticated local bridge', () => {
36
+ it('answers discovery and one-click connection only for the fixed extension origin', async () => {
37
+ const bridge = await broker()
38
+ const hello = (origin?: string) => new Promise<number>((resolve, reject) => { const req = http.get({ host: '127.0.0.1', port: bridge.port, path: '/hello', headers: origin ? { Origin: origin } : {} }, res => { res.resume(); resolve(res.statusCode!) }); req.on('error', reject) })
39
+ expect(await hello(EXTENSION_ORIGIN)).toBe(200)
40
+ expect(await hello()).toBe(200)
41
+ expect(await hello('https://example.com')).not.toBe(200)
42
+ expect(await hello('chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')).not.toBe(200)
43
+ const open = (origin: string) => new Promise<{ code?: number; message?: Record<string, unknown> }>(resolve => {
44
+ const ws = new WebSocket(`ws://127.0.0.1:${bridge.port}/connect`, { origin })
45
+ ws.on('message', raw => resolve({ message: JSON.parse(raw.toString()) }))
46
+ ws.on('unexpected-response', (_req, res) => resolve({ code: res.statusCode }))
47
+ ws.on('error', () => {})
48
+ })
49
+ expect((await open('chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')).code).toBe(403)
50
+ expect((await open('https://example.com')).code).toBe(403)
51
+ const connected = await open(EXTENSION_ORIGIN)
52
+ expect(connected.message).toMatchObject({ type: 'connect_result', port: bridge.port, protocol: 'writedock/1' })
53
+ expect(String(connected.message?.token)).toMatch(/^[a-f0-9]{64}$/)
54
+ })
55
+
34
56
  it('rejects unauthenticated HTTP calls and browser-origin requests', async () => {
35
57
  const bridge = await broker()
36
58
  const url = `http://127.0.0.1:${bridge.port}/status`
@@ -0,0 +1,54 @@
1
+ import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5
+ import { bundledExtension, findInstalledExtension, installExtensionFiles } from '../extension-update'
6
+
7
+ const manifest = (version: string, extra: Record<string, unknown> = {}) => JSON.stringify({ name: '写作台 WriteDock', version, key: 'fixed-key', ...extra })
8
+
9
+ describe('extension update', () => {
10
+ let work: string, source: string, target: string
11
+ beforeEach(async () => {
12
+ work = await mkdtemp(join(tmpdir(), 'writedock-ext-'))
13
+ source = join(work, 'bundle/extension'); target = join(work, 'installed')
14
+ await mkdir(source, { recursive: true })
15
+ await writeFile(join(source, 'manifest.json'), manifest('2.0.0'))
16
+ await writeFile(join(source, 'worker.js'), 'new')
17
+ })
18
+ afterEach(() => rm(work, { recursive: true, force: true }))
19
+
20
+ it('installs into an empty folder, then overwrites in place keeping files still in use', async () => {
21
+ expect(await installExtensionFiles(source, target)).toMatchObject({ version: '2.0.0' })
22
+ expect(await readFile(join(target, 'worker.js'), 'utf8')).toBe('new')
23
+ await writeFile(join(target, 'old-hashed.js'), 'still used until reload')
24
+ await writeFile(join(target, 'manifest.json'), manifest('1.0.0'))
25
+ await installExtensionFiles(source, target)
26
+ expect(JSON.parse(await readFile(join(target, 'manifest.json'), 'utf8')).version).toBe('2.0.0')
27
+ expect(await readFile(join(target, 'old-hashed.js'), 'utf8')).toBe('still used until reload')
28
+ })
29
+
30
+ it('refuses another extension or a different identity, and leaves the folder alone', async () => {
31
+ await mkdir(target)
32
+ await writeFile(join(target, 'manifest.json'), JSON.stringify({ name: 'Other', version: '1.0.0' }))
33
+ await expect(installExtensionFiles(source, target)).rejects.toThrow('不是 WriteDock')
34
+ expect(JSON.parse(await readFile(join(target, 'manifest.json'), 'utf8')).name).toBe('Other')
35
+ await writeFile(join(target, 'manifest.json'), manifest('1.0.0', { key: 'other-key' }))
36
+ await expect(installExtensionFiles(source, target)).rejects.toThrow('身份不同')
37
+ expect(JSON.parse(await readFile(join(target, 'manifest.json'), 'utf8')).version).toBe('1.0.0')
38
+ })
39
+
40
+ it('finds the loaded folder by extension ID and ignores others', async () => {
41
+ await installExtensionFiles(source, target)
42
+ const browser = join(work, 'browser')
43
+ await mkdir(join(browser, 'Default'), { recursive: true })
44
+ await writeFile(join(browser, 'Default/Preferences'), JSON.stringify({ extensions: { settings: { other: { path: join(work, 'nope') }, theid: { path: target } } } }))
45
+ expect(await findInstalledExtension([browser], 'theid')).toBe(await realpath(target))
46
+ expect(await findInstalledExtension([browser], 'missing')).toBeUndefined()
47
+ expect(await findInstalledExtension([join(work, 'no-browser')], 'theid')).toBeUndefined()
48
+ })
49
+
50
+ it('reads the bundled version, and reports none from a source checkout', () => {
51
+ expect(bundledExtension(join(work, 'bundle'))).toMatchObject({ version: '2.0.0' })
52
+ expect(bundledExtension(work)).toBeUndefined()
53
+ })
54
+ })
@@ -0,0 +1,50 @@
1
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5
+ import { runSetup } from '../setup'
6
+
7
+ describe('setup', () => {
8
+ let home: string
9
+ const log: string[] = []
10
+ beforeEach(async () => { home = await mkdtemp(join(tmpdir(), 'writedock-setup-')); log.length = 0 })
11
+ afterEach(() => rm(home, { recursive: true, force: true }))
12
+
13
+ it('adds WriteDock to Codex and keeps the rest of the file, with a backup', async () => {
14
+ await mkdir(join(home, '.codex'))
15
+ const original = 'model = "x"\n\n[mcp_servers.other]\ncommand = "other"\n'
16
+ await writeFile(join(home, '.codex/config.toml'), original)
17
+ expect(await runSetup(home, line => log.push(line))).toBe(1)
18
+ const result = await readFile(join(home, '.codex/config.toml'), 'utf8')
19
+ expect(result.startsWith(original.trimEnd())).toBe(true)
20
+ expect(result).toContain('[mcp_servers.writedock]')
21
+ expect(result).toContain('"writedock-mcp@latest"')
22
+ expect(await readFile(join(home, '.codex/config.toml.writedock-backup'), 'utf8')).toBe(original)
23
+ })
24
+
25
+ it('leaves an existing entry alone, including its custom port and token', async () => {
26
+ await mkdir(join(home, '.codex'))
27
+ const original = '[mcp_servers.writedock]\ncommand = "node"\n\n[mcp_servers.writedock.env]\nWRITEDOCK_PORT = "9700"\n'
28
+ await writeFile(join(home, '.codex/config.toml'), original)
29
+ await runSetup(home, line => log.push(line))
30
+ expect(await readFile(join(home, '.codex/config.toml'), 'utf8')).toBe(original)
31
+ expect(log.join()).toContain('未改动')
32
+ })
33
+
34
+ it('merges JSON clients, and reports a broken file without changing it', async () => {
35
+ await writeFile(join(home, '.claude.json'), JSON.stringify({ theme: 'dark', mcpServers: { other: { command: 'x' } } }))
36
+ await mkdir(join(home, '.cursor'))
37
+ await writeFile(join(home, '.cursor/mcp.json'), '{ broken')
38
+ await runSetup(home, line => log.push(line))
39
+ const claude = JSON.parse(await readFile(join(home, '.claude.json'), 'utf8'))
40
+ expect(claude.theme).toBe('dark')
41
+ expect(Object.keys(claude.mcpServers).sort()).toEqual(['other', 'writedock'])
42
+ expect(await readFile(join(home, '.cursor/mcp.json'), 'utf8')).toBe('{ broken')
43
+ expect(log.join()).toContain('配置失败')
44
+ })
45
+
46
+ it('touches nothing when no client exists, and prints the configuration', async () => {
47
+ expect(await runSetup(home, line => log.push(line))).toBe(0)
48
+ expect(log.join()).toContain('writedock-mcp@latest')
49
+ })
50
+ })
@@ -6,6 +6,8 @@ import { createCipheriv, createHash, createHmac, randomBytes, randomUUID, timing
6
6
  import { WebSocket, WebSocketServer } from 'ws'
7
7
  import { MediaStore, type MediaAsset } from './media'
8
8
  import { mcpReleaseInfo } from './release-info'
9
+ import { EXTENSION_ORIGIN } from './extension-id'
10
+ import { updateInstalledExtension } from './extension-update'
9
11
 
10
12
  const LIMIT = 32 * 1024 * 1024
11
13
  const messageOf = (error: unknown) => error instanceof Error ? error.message : String(error)
@@ -59,10 +61,10 @@ export class LocalBridge {
59
61
  server.on('upgrade', (req, socket, head) => {
60
62
  const origin = req.headers.origin
61
63
  const extensionOrigin = typeof origin === 'string' && /^chrome-extension:\/\/[a-p]{32}$/.test(origin)
62
- if (!this.validHost(req.headers.host) || !['/extension', '/pair'].includes(req.url || '') || (origin && !extensionOrigin) || (req.url === '/pair' && !extensionOrigin)) {
64
+ if (!this.validHost(req.headers.host) || !['/extension', '/pair', '/connect'].includes(req.url || '') || (origin && !extensionOrigin) || (req.url === '/pair' && !extensionOrigin) || (req.url === '/connect' && origin !== EXTENSION_ORIGIN)) {
63
65
  socket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); socket.destroy(); return
64
66
  }
65
- sockets.handleUpgrade(req, socket, head, ws => { if (req.url === '/pair') this.acceptPairing(ws); else this.accept(ws) })
67
+ sockets.handleUpgrade(req, socket, head, ws => { if (req.url === '/pair') this.acceptPairing(ws); else if (req.url === '/connect') void this.acceptConnect(ws); else this.accept(ws) })
66
68
  })
67
69
  await new Promise<void>((resolve, reject) => {
68
70
  server.once('error', reject)
@@ -90,6 +92,28 @@ export class LocalBridge {
90
92
  return { pairing_code: code, expires_at: new Date(expiresAt).toISOString(), expires_in_seconds: 180, instructions: '将此一次性配对码粘贴到扩展的「MCP 连接 → 输入一次性配对码」。有效期 3 分钟,使用一次后失效。无需重新安装 MCP 或修改 agent 配置。确认用户要连接当前扩展后再生成。' }
91
93
  }
92
94
 
95
+ /** Replaces the extension credential and drops whatever was using the old one. */
96
+ private async replaceExtensionToken(reason: string) {
97
+ const token = randomBytes(32).toString('hex')
98
+ await this.options.credentials?.setExtensionToken(token)
99
+ this.extensionToken = token
100
+ const previousClient = this.client
101
+ this.client = undefined; this.extensionId = undefined
102
+ for (const value of this.pending.values()) { clearTimeout(value.timer); value.reject(new Error(reason)) }
103
+ this.pending.clear()
104
+ previousClient?.close(1000, 'Extension pairing replaced')
105
+ return token
106
+ }
107
+
108
+ /** One-click connection: only this extension's fixed origin can reach here, and the user has just clicked 连接. */
109
+ private async acceptConnect(ws: WebSocket) {
110
+ try {
111
+ const token = await this.replaceExtensionToken('扩展连接已更换;请查询原同步任务,不要自动重新提交。')
112
+ ws.send(JSON.stringify({ type: 'connect_result', token, port: this.port, protocol: 'writedock/1' }))
113
+ ws.close(1000)
114
+ } catch { ws.close(1011, 'Connect failed') }
115
+ }
116
+
93
117
  private acceptPairing(ws: WebSocket) {
94
118
  const pairing = this.pairing
95
119
  if (!pairing || Date.now() > pairing.expiresAt || pairing.attempts >= 8) { ws.close(1008, 'Pairing code expired'); return }
@@ -107,15 +131,8 @@ export class LocalBridge {
107
131
  const message = JSON.parse(body)
108
132
  if (this.pairing !== pairing || Date.now() > pairing.expiresAt || message.type !== 'pairing_hello' || typeof message.proof !== 'string' || !equal(message.proof, proof(pairing.code, 'pairing-extension', nonce))) throw new Error('Pairing failed')
109
133
  this.pairing = undefined // Consume before the first asynchronous operation.
110
- const token = randomBytes(32).toString('hex')
111
- await this.options.credentials?.setExtensionToken(token)
112
- this.extensionToken = token
113
134
  // A deliberate new pairing retires the old extension credential, not the MCP identity.
114
- const previousClient = this.client
115
- this.client = undefined; this.extensionId = undefined
116
- for (const value of this.pending.values()) { clearTimeout(value.timer); value.reject(new Error('扩展配对已更换;请查询原同步任务,不要自动重新提交。')) }
117
- this.pending.clear()
118
- previousClient?.close(1000, 'Extension pairing replaced')
135
+ const token = await this.replaceExtensionToken('扩展配对已更换;请查询原同步任务,不要自动重新提交。')
119
136
  const key = createHmac('sha256', pairing.code).update(`writedock-pairing-key:${nonce}`).digest()
120
137
  const iv = randomBytes(12)
121
138
  const cipher = createCipheriv('aes-256-gcm', key, iv)
@@ -150,6 +167,13 @@ export class LocalBridge {
150
167
  return
151
168
  }
152
169
  if (message.type === 'pong') return
170
+ if (message.type === 'update_extension') {
171
+ void updateInstalledExtension().then(
172
+ result => ws.send(JSON.stringify({ type: 'update_extension_result', ok: true, version: result.version })),
173
+ error => ws.send(JSON.stringify({ type: 'update_extension_result', ok: false, error: messageOf(error) })),
174
+ )
175
+ return
176
+ }
153
177
  const pending = this.pending.get(message.id)
154
178
  if (!pending) return
155
179
  this.pending.delete(message.id); clearTimeout(pending.timer)
@@ -179,6 +203,11 @@ export class LocalBridge {
179
203
  if (responseNonce) res.setHeader('X-Writedock-Proof', proof(this.options.token, `http-response:${digest(body)}`, responseNonce))
180
204
  res.writeHead(status); res.end(body)
181
205
  }
206
+ // Lets the extension find this service before it is connected. Reveals only that it exists and its version.
207
+ // Chrome omits Origin on GETs from an extension with host permission; web pages always send theirs.
208
+ if (req.url === '/hello' && req.method === 'GET' && this.validHost(req.headers.host) && (!req.headers.origin || req.headers.origin === EXTENSION_ORIGIN)) {
209
+ send(200, { service: 'writedock-mcp', protocol: 'writedock/1', version: mcpReleaseInfo().mcp_version }); return
210
+ }
182
211
  const assetPath = /^\/assets\/([a-f0-9-]{36})$/.exec(req.url || '')
183
212
  if (assetPath && this.validHost(req.headers.host) && (!req.headers.origin || /^chrome-extension:\/\/[a-p]{32}$/.test(req.headers.origin))) {
184
213
  await this.media.serve(req, res, assetPath[1]); return
@@ -270,7 +299,7 @@ export class LocalBridge {
270
299
  }
271
300
 
272
301
  private async requestConnected(method: string, params: Record<string, unknown>): Promise<unknown> {
273
- if (this.client?.readyState !== WebSocket.OPEN) throw new Error('扩展未连接。请调用 begin_pairing,将一次性配对码粘贴到扩展的 MCP 连接页;无需重装 MCP。')
302
+ if (this.client?.readyState !== WebSocket.OPEN) throw new Error('扩展未连接。请让用户打开浏览器的 WriteDock 扩展,在「连接」页点“连接”。无需重装 MCP。')
274
303
  const id = randomUUID()
275
304
  return new Promise((resolve, reject) => {
276
305
  const timer = setTimeout(() => {
@@ -0,0 +1,2 @@
1
+ /** Derived from the public key in packages/extension/manifest.json, so the ID does not depend on the install folder. */
2
+ export const EXTENSION_ORIGIN = 'chrome-extension://himncajjeipncfpoajkcmfieonfpjfhb'
@@ -0,0 +1,82 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { cp, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, writeFile } from 'node:fs/promises'
3
+ import { homedir, tmpdir } from 'node:os'
4
+ import { isAbsolute, join } from 'node:path'
5
+ import { EXTENSION_ORIGIN } from './extension-id'
6
+
7
+ const EXTENSION_ID = EXTENSION_ORIGIN.replace('chrome-extension://', '')
8
+ const readManifest = async (directory: string) => JSON.parse(await readFile(join(directory, 'manifest.json'), 'utf8')) as { name?: string; version?: string; key?: string }
9
+ const isWriteDock = (manifest: { name?: string; manifest_version?: number }) => manifest.name === '写作台 WriteDock'
10
+
11
+ /** The extension shipped inside this npm package. Absent when running from a source checkout. */
12
+ export function bundledExtension(base = __dirname) {
13
+ try {
14
+ const directory = join(base, 'extension')
15
+ const manifest = JSON.parse(readFileSync(join(directory, 'manifest.json'), 'utf8')) as { version?: string }
16
+ return typeof manifest.version === 'string' ? { directory, version: manifest.version } : undefined
17
+ } catch { return undefined }
18
+ }
19
+
20
+ /** Where setup installs the extension: stable, and visible in the browser's folder picker, which hides ~/Library on macOS. */
21
+ export const defaultExtensionDirectory = (home = homedir()) => join(home, 'WriteDock', 'extension')
22
+
23
+ function browserRoots(user = homedir()) {
24
+ const names = process.platform === 'win32'
25
+ ? ['Google/Chrome/User Data', 'Microsoft/Edge/User Data', 'Chromium/User Data', 'BraveSoftware/Brave-Browser/User Data'].map(name => join(process.env.LOCALAPPDATA || join(user, 'AppData/Local'), name))
26
+ : process.platform === 'darwin'
27
+ ? ['Google/Chrome', 'Microsoft Edge', 'Chromium', 'BraveSoftware/Brave-Browser'].map(name => join(user, 'Library/Application Support', name))
28
+ : ['google-chrome', 'microsoft-edge', 'chromium', 'BraveSoftware/Brave-Browser'].map(name => join(process.env.XDG_CONFIG_HOME || join(user, '.config'), name))
29
+ return names
30
+ }
31
+
32
+ /** Finds the folder the browser actually loaded this extension from, by its fixed ID. */
33
+ export async function findInstalledExtension(roots = browserRoots(), id = EXTENSION_ID) {
34
+ for (const root of roots) {
35
+ let profiles: { name: string; isDirectory: () => boolean }[]
36
+ try { profiles = await readdir(root, { withFileTypes: true }) } catch { continue }
37
+ for (const profile of profiles.filter(item => item.isDirectory() && /^(Default|Profile \d+)$/.test(item.name))) {
38
+ for (const filename of ['Secure Preferences', 'Preferences']) {
39
+ try {
40
+ const preferences = JSON.parse(await readFile(join(root, profile.name, filename), 'utf8'))
41
+ const path = preferences.extensions?.settings?.[id]?.path
42
+ if (typeof path === 'string' && isAbsolute(path) && isWriteDock(await readManifest(path))) return realpath(path)
43
+ } catch { /* Missing or unreadable profile. */ }
44
+ }
45
+ }
46
+ }
47
+ return undefined
48
+ }
49
+
50
+ /** Overwrites in place, manifest last, restoring the previous files if anything fails. */
51
+ export async function installExtensionFiles(source: string, target: string) {
52
+ const next = await readManifest(source)
53
+ if (!isWriteDock(next)) throw new Error('内置扩展文件不正确')
54
+ let previous: Awaited<ReturnType<typeof readManifest>> | undefined
55
+ try { previous = await readManifest(target) } catch { /* A first install. */ }
56
+ if (previous) {
57
+ if (!isWriteDock(previous)) throw new Error('目标目录不是 WriteDock 扩展,未覆盖')
58
+ if (previous.key !== next.key) throw new Error('旧版扩展的身份不同,无法原位更新,请重新加载一次新版')
59
+ }
60
+ await mkdir(target, { recursive: true })
61
+ const backup = previous ? await mkdtemp(join(tmpdir(), 'writedock-extension-backup-')) : undefined
62
+ if (backup) await cp(target, backup, { recursive: true, force: true })
63
+ try {
64
+ await cp(source, target, { recursive: true, force: true, filter: path => path !== join(source, 'manifest.json') })
65
+ const staged = join(target, '.writedock-manifest-update.tmp')
66
+ await writeFile(staged, await readFile(join(source, 'manifest.json')))
67
+ await rename(staged, join(target, 'manifest.json'))
68
+ } catch (error) {
69
+ if (backup) await cp(backup, target, { recursive: true, force: true })
70
+ throw error
71
+ } finally { if (backup) await rm(backup, { recursive: true, force: true }).catch(() => {}) }
72
+ return { directory: target, version: next.version }
73
+ }
74
+
75
+ /** Handles the 更新 button in the extension. */
76
+ export async function updateInstalledExtension() {
77
+ const bundle = bundledExtension()
78
+ if (!bundle) throw new Error('当前服务没有内置扩展,请重启 agent 后再试')
79
+ const target = await findInstalledExtension()
80
+ if (!target) throw new Error('没有找到已加载的扩展目录,请手动更新')
81
+ return installExtensionFiles(bundle.directory, target)
82
+ }
@@ -4,6 +4,7 @@ import { LocalBridge } from './bridge'
4
4
  import { createMcpServer } from './server'
5
5
  import { loadIdentity } from './identity'
6
6
  import mcpPackage from '../package.json'
7
+ import { runSetup } from './setup'
7
8
 
8
9
  async function main() {
9
10
  const port = Number(process.env.WRITEDOCK_PORT || 9647)
@@ -22,6 +23,8 @@ async function main() {
22
23
  if (process.argv.slice(2).some(argument => argument === '--version' || argument === '-v')) {
23
24
  // Allow inspecting an npm archive without starting a bridge or reading pairing credentials.
24
25
  console.log(mcpPackage.version)
26
+ } else if (process.argv[2] === 'setup') {
27
+ runSetup().catch(error => { console.error('[WriteDock MCP]', error instanceof Error ? error.message : String(error)); process.exit(1) })
25
28
  } else {
26
29
  main().catch(error => { console.error('[WriteDock MCP]', error instanceof Error ? error.message : String(error)); process.exit(1) })
27
30
  }
@@ -1,5 +1,6 @@
1
1
  import mcpPackage from '../package.json'
2
2
  import distribution from '../distribution.json'
3
+ import { bundledExtension } from './extension-update'
3
4
 
4
5
  const CHECK_INTERVAL = 6 * 60 * 60_000
5
6
  const RETRY_INTERVAL = 10 * 60_000
@@ -56,8 +57,10 @@ function refresh() {
56
57
  /** Check asynchronously; version lookup must never delay pairing, tool calls or publication. */
57
58
  export function mcpReleaseInfo() {
58
59
  refresh()
60
+ const bundle = bundledExtension()
59
61
  return {
60
62
  mcp_version: mcpPackage.version,
63
+ ...(bundle ? { extension_bundle_version: bundle.version } : {}),
61
64
  ...(latestVersion ? { latest_version: latestVersion } : {}),
62
65
  update_available: !!latestVersion && newerVersion(latestVersion, mcpPackage.version),
63
66
  ...(checkedAt ? { update_checked_at: checkedAt } : {}),
@@ -30,7 +30,7 @@ const agentWorkflow = 'Agent 只通过 WriteDock MCP 投稿和查询回执,不
30
30
 
31
31
  export function createMcpServer(bridge: LocalBridge) {
32
32
  const server = new McpServer({ name: 'writedock', version: mcpPackage.version }, {
33
- instructions: agentWorkflow + 'WriteDock 通过本机扩展执行投稿。先检查连接和平台登录。实际平台能力以 list_platforms 返回的 action、publishModes、tools、modeOptions、confirmation、capabilities、coverRequired 为准,扩展更新后优先按该元信息选择工具。知乎、百家号、微博长文章、B站专栏、小红书长文使用 publish_article,Agent 直接提供 Markdown/HTML 或文件路径即可发布,无需用户在扩展编辑。百家号必须提供 cover 封面;知乎另支持 <video src="本地MP4路径"></video> 文章内视频,独立封面暂未接入。prepare_article 仅供可选本机预览,不是发布的前置要求。小红书 ID 为 xiaohongshu,三种模式共用此平台:写长文用 publish_article,支持正文图片、原生自动排版后发布,标题最多 64 字、正文最多 10000 字,不接独立 cover 或文章内视频;普通图文用 publish_post(kind=image),独立视频用 publish_post(kind=video),未传 cover_path 时保留平台自动生成的第一帧封面。小红书最终均须匹配 POST /web_api/sns/v2/note 的 success=true、result=0 和笔记 ID 回执才确认 submitted。抖音平台 ID 为 douyin,图文和视频同样用 publish_post(kind=image|video),可选 cover_path;图文话题最多 4 个、视频最多 5 个,当前只接即时发布。抖音 confirmation=native_page_message,以本次操作新增的原生成功提示确认 submitted,未核对最终 HTTP 业务回执;仅跳转作品管理时仍为 unknown,Agent 不自动重发。小红书、抖音、微博普通图文与独立视频使用 prepare_post / publish_post,微博平台 ID 仍为 weibo,不另建平台。微博图文标题放入正文首行,最多 18 张图片;独立视频标题最多 30 个汉字(60 个半角字符),weibo_video_creation 必填,须依据用户实际内容传 original、secondary 或 repost,不能默认原创。微博同一次只能选图文或视频,长文章继续用 publish_article。只有用户明确要求发布并指定平台时才调用发布工具。微信公众号、搜狐号目前用 sync_article 保存文章草稿。B站专栏 ID 为 bilibili,使用 publish_article 直接发布文字和正文图片,不接视频、音频、附加内容卡片或独立封面;通过新版原生编辑器上传并发布,code=-352 时停止自动提交并转人工验证。遇到 needs_user_action 或 requires_user_action 时,把 user_actions 中的原因和稿件链接告诉用户,优先让用户切换到扩展已打开的页面处理验证码、封面、内容声明、来源、原创协议或发布设置,并手动发布;不得自动解决验证码、重新提交或换 request_id。该状态只记录人工接管,不代表用户之后的操作结果已被确认。扩展更新复用已有 MCP;需要恢复连接时调用 begin_pairing,让用户在扩展输入配对码。提交返回任务 ID,随后用 get_sync_task 查询结果。保持同一操作的 request_id 不变。publicationState=unknown 或任务中断时,向用户给出链接让其自行核对平台,Agent 不打开网页,不要自动生成新 request_id 重发。直接发布成功表示平台已接受提交,是否公开展示以平台为准。',
33
+ instructions: agentWorkflow + 'WriteDock 通过本机扩展执行投稿。先检查连接和平台登录。实际平台能力以 list_platforms 返回的 action、publishModes、tools、modeOptions、confirmation、capabilities、coverRequired 为准,扩展更新后优先按该元信息选择工具。知乎、百家号、微博长文章、B站专栏、小红书长文使用 publish_article,Agent 直接提供 Markdown/HTML 或文件路径即可发布,无需用户在扩展编辑。百家号必须提供 cover 封面;知乎另支持 <video src="本地MP4路径"></video> 文章内视频,独立封面暂未接入。prepare_article 仅供可选本机预览,不是发布的前置要求。小红书 ID 为 xiaohongshu,三种模式共用此平台:写长文用 publish_article,支持正文图片、原生自动排版后发布,标题最多 64 字、正文最多 10000 字,不接独立 cover 或文章内视频;普通图文用 publish_post(kind=image),独立视频用 publish_post(kind=video),未传 cover_path 时保留平台自动生成的第一帧封面。小红书最终均须匹配 POST /web_api/sns/v2/note 的 success=true、result=0 和笔记 ID 回执才确认 submitted。抖音平台 ID 为 douyin,图文和视频同样用 publish_post(kind=image|video),可选 cover_path;图文话题最多 4 个、视频最多 5 个,当前只接即时发布。抖音 confirmation=native_page_message,以本次操作新增的原生成功提示确认 submitted,未核对最终 HTTP 业务回执;仅跳转作品管理时仍为 unknown,Agent 不自动重发。小红书、抖音、微博普通图文与独立视频使用 prepare_post / publish_post,微博平台 ID 仍为 weibo,不另建平台。微博图文标题放入正文首行,最多 18 张图片;独立视频标题最多 30 个汉字(60 个半角字符),weibo_video_creation 必填,须依据用户实际内容传 original、secondary 或 repost,不能默认原创。微博同一次只能选图文或视频,长文章继续用 publish_article。只有用户明确要求发布并指定平台时才调用发布工具。微信公众号、搜狐号目前用 sync_article 保存文章草稿。B站专栏 ID 为 bilibili,使用 publish_article 直接发布文字和正文图片,不接视频、音频、附加内容卡片或独立封面;通过新版原生编辑器上传并发布,code=-352 时停止自动提交并转人工验证。遇到 needs_user_action 或 requires_user_action 时,把 user_actions 中的原因和稿件链接告诉用户,优先让用户切换到扩展已打开的页面处理验证码、封面、内容声明、来源、原创协议或发布设置,并手动发布;不得自动解决验证码、重新提交或换 request_id。该状态只记录人工接管,不代表用户之后的操作结果已被确认。扩展更新复用已有 MCP;未连接时,让用户打开 WriteDock 扩展的「连接」页点“连接”。提交返回任务 ID,随后用 get_sync_task 查询结果。保持同一操作的 request_id 不变。publicationState=unknown 或任务中断时,向用户给出链接让其自行核对平台,Agent 不打开网页,不要自动生成新 request_id 重发。直接发布成功表示平台已接受提交,是否公开展示以平台为准。',
34
34
  })
35
35
  const wrap = (operation: () => Promise<unknown>) => operation().then(result => ({
36
36
  content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
@@ -38,10 +38,10 @@ export function createMcpServer(bridge: LocalBridge) {
38
38
  const readOnly = { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
39
39
 
40
40
  server.registerTool('get_connection_status', {
41
- description: '检查本机 MCP 与扩展的连接、运行版本和 npm 更新提示。update_available=true 时告知用户重新启动 WriteDock MCP,保留配置和配对;发布任务进行中不要自动重启。旧版服务可能不返回版本字段;latest_version 缺失或 update_checking=true 时,不得声称已是最新版。', inputSchema: {}, annotations: { ...readOnly, openWorldHint: true },
41
+ description: '检查本机 MCP 与扩展的连接、运行版本和 npm 更新提示。update_available=true 时告知用户重新启动 WriteDock MCP,保留配置;发布任务进行中不要自动重启。旧版服务可能不返回版本字段;latest_version 缺失或 update_checking=true 时,不得声称已是最新版。', inputSchema: {}, annotations: { ...readOnly, openWorldHint: true },
42
42
  }, () => wrap(() => bridge.status()))
43
43
  server.registerTool('begin_pairing', {
44
- description: '首次连接或扩展卸载重装后,生成 3 分钟有效的一次性配对码。让用户粘贴到扩展的 MCP 连接页即可,复用已有 MCP 和 agent 配置。新配对会替换此前浏览器的连接授权,请在用户要求连接扩展时调用。',
44
+ description: '通常不需要:未连接时,让用户打开 WriteDock 扩展的「连接」页点“连接”即可。只有用户使用旧版扩展时才生成 3 分钟有效的一次性配对码。',
45
45
  inputSchema: {}, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
46
46
  }, () => wrap(() => bridge.beginPairing()))
47
47
  server.registerTool('list_platforms', {
@@ -80,6 +80,48 @@ export function createMcpServer(bridge: LocalBridge) {
80
80
  if (!args.draft_id && (!args.kind || !args.title)) throw new Error('请提供 draft_id,或完整的 kind、title 和素材路径')
81
81
  return bridge.request('publishPost', { requestId: args.request_id, platforms: args.platforms, ...(args.draft_id ? { draftId: args.draft_id } : { post: await preparePost({ ...args, kind: args.kind!, title: args.title! }, (path, kind) => bridge.registerMedia(path, kind)) }) })
82
82
  }))
83
+ const unifiedInput = {
84
+ platforms: z.array(z.string().min(1)).min(1).max(30).describe('用户指定的全部平台 ID。扩展按平台分流:知乎、百家号、B站发文章;抖音发图文/视频;微博、小红书有 post 时发图文/视频,否则发长文;公众号、搜狐号只存草稿。'),
85
+ article: z.object(source).optional().describe('文章内容,file_path/markdown/html 三选一。cover 仅百家号、微博长文使用,其他平台自动忽略并在 warnings 中提示;百家号缺 cover 时用正文第一张图。'),
86
+ post: z.object(postSource).optional().describe('图文或视频内容,用于小红书、抖音、微博。'),
87
+ modes: z.record(z.enum(['article', 'post'])).optional().describe('可选:为微博、小红书指定发文章还是图文/视频,如 {"weibo":"article"}。'),
88
+ }
89
+ const unifiedParams = async (args: { platforms: string[]; article?: Parameters<typeof prepareSource>[0]; post?: Parameters<typeof preparePost>[0]; modes?: Record<string, string> }) => {
90
+ if (!args.article && !args.post) throw new Error('请提供 article(文章)或 post(图文/视频)内容')
91
+ return {
92
+ platforms: args.platforms, ...(args.modes ? { modes: args.modes } : {}),
93
+ ...(args.article ? { article: await prepareSource(args.article, path => bridge.registerMedia(path, 'video')) } : {}),
94
+ ...(args.post ? { post: await preparePost(args.post, (path, kind) => bridge.registerMedia(path, kind)) } : {}),
95
+ }
96
+ }
97
+ server.registerTool('publish', {
98
+ description: agentWorkflow + '首选的发布工具:一次调用发布到用户指定的多个平台。只有用户明确要求发布并指定平台时才调用。按平台依次执行,某个平台未登录、不能接收内容或失败,只跳过该平台,其他平台继续。遇到验证码时平台进入 waiting_verification,立即把原因和链接告诉用户,由用户手动完成,继续查询即可,不要重发。任务结束后按 get_sync_task 的 summary 逐平台告诉用户:哪些成功,哪些未登录(not_logged_in)等。立即返回 task_id,再用 get_sync_task 查询;results 是逐平台结果,warnings 说明为平台做的调整。必须使用稳定 request_id,相同 ID 不会重复提交。部分平台失败时,只针对失败平台用新 request_id 重新提交,unknown 的平台不要重发。旧版扩展不支持时会报错,不会发布。',
99
+ inputSchema: { ...unifiedInput, request_id: requestId },
100
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
101
+ }, args => wrap(async () => bridge.request('publish', { requestId: args.request_id, ...await unifiedParams(args) })))
102
+ server.registerTool('rehearse_publish', {
103
+ description: '与 publish 参数相同的演练:逐平台打开原生页面,填好内容、上传素材,走到点击发布之前停下,不提交发布。无需 request_id,不影响之后的真实发布。图片可能已上传到平台,部分平台会留下中间草稿,公众号、搜狐号不参与。结果 publicationState=rehearsed 不代表已发布,不得告诉用户内容已发出,也不要自行接着发布。',
104
+ inputSchema: unifiedInput,
105
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
106
+ }, args => wrap(async () => bridge.request('rehearsePublish', await unifiedParams(args))))
107
+ const rehearsalNote = '演练:打开原生页面,填好内容、上传素材,走到点击发布之前停下,不提交发布。用于检查平台改版、账号权限和选择器,稿件留在已打开的标签页由用户关闭。图片可能已上传到平台,知乎、百家号、微博长文章会留下中间草稿;最终发布请求和平台回执只有真实发布才能核对。无需 request_id,也不影响之后的真实发布。结果 publicationState=rehearsed 不代表已发布,不得告诉用户内容已发出,也不要自行接着发布。失败时结果带 diagnostics(阶段、页面结构、请求记录)。'
108
+ server.registerTool('rehearse_article', {
109
+ description: rehearsalNote + '本工具用于 publish_article 的平台:知乎、百家号、微博长文章、B站专栏、小红书长文,参数规则相同。返回 task_id 后查询 get_sync_task。',
110
+ inputSchema: { ...source, platforms: z.array(z.string().min(1)).min(1).max(30), draft_id: z.string().optional() },
111
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
112
+ }, args => wrap(async () => {
113
+ if (args.draft_id && Object.entries(args).some(([key, value]) => key in source && value !== undefined)) throw new Error('draft_id 与稿件内容不能同时提供')
114
+ return bridge.request('rehearseArticle', { platforms: args.platforms, ...(args.draft_id ? { draftId: args.draft_id } : { article: await prepareSource(args, path => bridge.registerMedia(path, 'video')) }) })
115
+ }))
116
+ server.registerTool('rehearse_post', {
117
+ description: rehearsalNote + '本工具用于 publish_post 的平台:小红书、抖音、微博普通图文和视频,参数规则相同。返回 task_id 后查询 get_sync_task。',
118
+ inputSchema: { ...postSource, kind: postSource.kind.optional(), title: postSource.title.optional(), draft_id: z.string().optional(), platforms: z.array(z.string().min(1)).min(1).max(30) },
119
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
120
+ }, args => wrap(async () => {
121
+ if (args.draft_id && Object.entries(args).some(([key, value]) => key in postSource && value !== undefined)) throw new Error('draft_id 与投稿内容不能同时提供')
122
+ if (!args.draft_id && (!args.kind || !args.title)) throw new Error('请提供 draft_id,或完整的 kind、title 和素材路径')
123
+ return bridge.request('rehearsePost', { platforms: args.platforms, ...(args.draft_id ? { draftId: args.draft_id } : { post: await preparePost({ ...args, kind: args.kind!, title: args.title! }, (path, kind) => bridge.registerMedia(path, kind)) }) })
124
+ }))
83
125
  server.registerTool('sync_article', {
84
126
  description: '向 list_platforms 返回 action=draft 的用户指定平台保存文章草稿,当前包括微信公众号、搜狐号。知乎、百家号、微博长文章、B站专栏、小红书长文已改用 publish_article;本工具不会把旧草稿调用自动升级成公开发布。可用 prepare_article 返回的 draft_id,或直接传 file_path/markdown/html。必须给同一操作使用稳定 request_id;相同 ID 重放不会重复提交。立即返回 task_id,再用 get_sync_task 查询进度。',
85
127
  inputSchema: { ...source, request_id: requestId, platforms: z.array(z.string().min(1)).min(1).max(30), draft_id: z.string().optional() },
@@ -0,0 +1,88 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
3
+ import { homedir } from 'node:os'
4
+ import { dirname, join } from 'node:path'
5
+ import { bundledExtension, defaultExtensionDirectory, findInstalledExtension, installExtensionFiles } from './extension-update'
6
+
7
+ const COMMAND = 'npx'
8
+ const ARGS = ['-y', '--prefer-online', 'writedock-mcp@latest']
9
+
10
+ async function readIfPresent(path: string) {
11
+ try { return await readFile(path, 'utf8') }
12
+ catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw error }
13
+ }
14
+
15
+ async function save(path: string, content: string, previous: string | undefined) {
16
+ await mkdir(dirname(path), { recursive: true })
17
+ if (previous !== undefined) await copyFile(path, `${path}.writedock-backup`)
18
+ await writeFile(path, content)
19
+ }
20
+
21
+ /** Keeps the user's other settings; an existing WriteDock entry stays, because it may hold a custom port or data directory. */
22
+ export async function configureCodex(path: string): Promise<'added' | 'present'> {
23
+ const previous = await readIfPresent(path)
24
+ if (previous !== undefined && /^\s*\[mcp_servers\.writedock\]/m.test(previous)) return 'present'
25
+ const entry = `[mcp_servers.writedock]\ncommand = "${COMMAND}"\nargs = ${JSON.stringify(ARGS)}\nstartup_timeout_sec = 120\n`
26
+ await save(path, previous ? `${previous.replace(/\s*$/, '')}\n\n${entry}` : entry, previous)
27
+ return 'added'
28
+ }
29
+
30
+ export async function configureJsonClient(path: string): Promise<'added' | 'present'> {
31
+ const previous = await readIfPresent(path)
32
+ const config = previous?.trim() ? JSON.parse(previous) as { mcpServers?: Record<string, unknown> } : {}
33
+ if (config.mcpServers?.writedock) return 'present'
34
+ config.mcpServers = { ...config.mcpServers, writedock: { command: COMMAND, args: ARGS } }
35
+ await save(path, `${JSON.stringify(config, null, 2)}\n`, previous)
36
+ return 'added'
37
+ }
38
+
39
+ const exists = async (path: string) => (await readIfPresent(path)) !== undefined
40
+
41
+ /** Best effort: shows the folder so the user can pick it, or drag it onto the extensions page. */
42
+ function revealFolder(path: string) {
43
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open'
44
+ try { const child = spawn(command, [path], { detached: true, stdio: 'ignore' }); child.on('error', () => {}); child.unref() } catch { /* The path is printed as well. */ }
45
+ }
46
+
47
+ /** Only clients already on this computer are configured, so setup never creates settings for an app the user does not have. */
48
+ export async function runSetup(home = homedir(), log: (line: string) => void = console.log) {
49
+ const clients: { name: string; path: string; detect: string; configure: (path: string) => Promise<'added' | 'present'> }[] = [
50
+ { name: 'Codex', path: join(home, '.codex/config.toml'), detect: join(home, '.codex/config.toml'), configure: configureCodex },
51
+ { name: 'Claude Code', path: join(home, '.claude.json'), detect: join(home, '.claude.json'), configure: configureJsonClient },
52
+ { name: 'Cursor', path: join(home, '.cursor/mcp.json'), detect: join(home, '.cursor/mcp.json'), configure: configureJsonClient },
53
+ { name: 'Claude Desktop', path: join(home, 'Library/Application Support/Claude/claude_desktop_config.json'), detect: join(home, 'Library/Application Support/Claude/claude_desktop_config.json'), configure: configureJsonClient },
54
+ ]
55
+ let found = 0
56
+ for (const client of clients) {
57
+ if (!await exists(client.detect)) continue
58
+ found++
59
+ try {
60
+ const state = await client.configure(client.path)
61
+ log(state === 'added' ? `✓ ${client.name}:已添加 WriteDock(原配置备份为 .writedock-backup)` : `✓ ${client.name}:已有 WriteDock 配置,未改动`)
62
+ } catch (error) { log(`✗ ${client.name}:配置失败(${error instanceof Error ? error.message : String(error)}),原文件未改动`) }
63
+ }
64
+ if (!found) {
65
+ log('没有找到已知的 agent 客户端。请在你的客户端里添加这个 MCP 服务:')
66
+ log(JSON.stringify({ mcpServers: { writedock: { command: COMMAND, args: ARGS } } }, null, 2))
67
+ }
68
+ // Keep whichever folder the browser already loads, so a repeat setup does not ask for another manual load.
69
+ const bundle = bundledExtension()
70
+ const loaded = bundle ? await findInstalledExtension().catch(() => undefined) : undefined
71
+ let extensionFolder: string | undefined
72
+ if (bundle) {
73
+ try { extensionFolder = (await installExtensionFiles(bundle.directory, loaded || defaultExtensionDirectory(home))).directory }
74
+ catch (error) { log(`✗ 扩展:安装失败(${error instanceof Error ? error.message : String(error)})`) }
75
+ }
76
+ log('')
77
+ log('接下来:')
78
+ log('1. 重启你的 agent 客户端,让它加载 WriteDock。')
79
+ if (extensionFolder && !loaded) {
80
+ log('2. 在 Chrome 或 Edge 打开 chrome://extensions,打开右上角的“开发者模式”,点“加载已解压的扩展程序”,选择这个文件夹:')
81
+ log(` ${extensionFolder}`)
82
+ revealFolder(extensionFolder)
83
+ log('3. 打开 WriteDock 扩展,点“连接”。')
84
+ } else if (extensionFolder) {
85
+ log(`2. 扩展已更新。在 chrome://extensions 点一次 WriteDock 的刷新图标,然后点“连接”。`)
86
+ } else log('2. 打开浏览器的 WriteDock 扩展,点“连接”。')
87
+ return found
88
+ }