@qic-9821/dsh-plugin-sync 0.0.0-stage → 0.1.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/lib/patch.js ADDED
@@ -0,0 +1,192 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { dirname } from 'node:path'
3
+
4
+ /**
5
+ * Minimal reader/writer for the top-level rows of a DSH `cordis.patch.yml`.
6
+ *
7
+ * Only the top-level `- id: <row>` rows are touched; nested `- insert:` blocks
8
+ * and comments are carried through byte for byte. Values are written as YAML
9
+ * flow mappings (`config: {"a":1}`), which is valid YAML and keeps the writer
10
+ * free of a yaml dependency.
11
+ */
12
+
13
+ const ROW_START = /^-\s+id:\s*(.+?)\s*$/
14
+ const NAME_LINE = /^\s+name:\s*(.+?)\s*$/
15
+ const DISABLED_LINE = /^\s+disabled:\s*(true|false)\s*$/
16
+ const CONFIG_LINE = /^\s+config:\s*(.*)$/
17
+
18
+ export function parseRows(text) {
19
+ const lines = String(text ?? '').split('\n')
20
+ const rows = []
21
+ let current = null
22
+ for (let index = 0; index < lines.length; index += 1) {
23
+ const line = lines[index]
24
+ const start = ROW_START.exec(line)
25
+ if (start) {
26
+ current = { id: unquote(start[1]), name: null, disabled: null, startLine: index, endLine: index, configLines: null }
27
+ rows.push(current)
28
+ continue
29
+ }
30
+ if (!current) continue
31
+ if (line.trim().length === 0) {
32
+ // blank lines inside a row block: keep the block alive only if the next
33
+ // non-blank line is still indented
34
+ current.endLine = index
35
+ continue
36
+ }
37
+ if (!/^\s/.test(line)) {
38
+ current.endLine = index - 1
39
+ current = null
40
+ continue
41
+ }
42
+ const name = NAME_LINE.exec(line)
43
+ if (name && current.name === null) current.name = unquote(name[1])
44
+ const disabled = DISABLED_LINE.exec(line)
45
+ if (disabled) current.disabled = disabled[1] === 'true'
46
+ current.endLine = index
47
+ }
48
+ return rows
49
+ }
50
+
51
+ export function findRow(rows, { id, moduleName }) {
52
+ if (id) {
53
+ const byId = rows.find((row) => row.id === id)
54
+ if (byId) return byId
55
+ }
56
+ if (moduleName) return rows.find((row) => row.name === moduleName) || null
57
+ return null
58
+ }
59
+
60
+ export function rowConfig(text, row) {
61
+ const lines = String(text ?? '').split('\n')
62
+ const match = CONFIG_LINE.exec(lines[row.startLine] ?? '')
63
+ void match
64
+ for (let index = row.startLine; index <= row.endLine; index += 1) {
65
+ const hit = CONFIG_LINE.exec(lines[index])
66
+ if (!hit) continue
67
+ const inline = hit[1].trim()
68
+ if (inline && inline !== '|' && inline !== '>') {
69
+ try {
70
+ return JSON.parse(inline)
71
+ } catch {
72
+ const relaxed = parseFlowMapping(inline)
73
+ if (relaxed !== null) return relaxed
74
+ // Anything we cannot read stays opaque: config sync must never invent a value.
75
+ return { __raw: inline }
76
+ }
77
+ }
78
+ const block = []
79
+ for (let cursor = index + 1; cursor <= row.endLine; cursor += 1) {
80
+ const line = lines[cursor]
81
+ if (!/^\s{3,}/.test(line)) break
82
+ block.push(line.replace(/^\s{0,4}/, ''))
83
+ }
84
+ return block.length > 0 ? { __raw: block.join('\n') } : null
85
+ }
86
+ return null
87
+ }
88
+
89
+ export function setRowDisabled(text, rowId, disabled) {
90
+ const source = String(text ?? '')
91
+ const rows = parseRows(source)
92
+ const row = findRow(rows, { id: rowId })
93
+ const lines = source.split('\n')
94
+ if (!row) {
95
+ if (!disabled) return source
96
+ const tail = source.endsWith('\n') || source.length === 0 ? '' : '\n'
97
+ return `${source}${tail}- id: ${rowId}\n disabled: true\n`
98
+ }
99
+ const value = disabled ? 'true' : 'false'
100
+ for (let index = row.startLine; index <= row.endLine; index += 1) {
101
+ if (DISABLED_LINE.test(lines[index])) {
102
+ lines[index] = ` disabled: ${value}`
103
+ return lines.join('\n')
104
+ }
105
+ }
106
+ // insert after the name line, else right after the id line
107
+ let anchor = row.startLine
108
+ for (let index = row.startLine; index <= row.endLine; index += 1) {
109
+ if (NAME_LINE.test(lines[index])) {
110
+ anchor = index
111
+ break
112
+ }
113
+ }
114
+ lines.splice(anchor + 1, 0, ` disabled: ${value}`)
115
+ return lines.join('\n')
116
+ }
117
+
118
+ export function setRowConfig(text, rowId, config) {
119
+ const source = String(text ?? '')
120
+ const rows = parseRows(source)
121
+ const row = findRow(rows, { id: rowId })
122
+ if (!row) return { text: source, changed: false, reason: 'row-not-found' }
123
+ const lines = source.split('\n')
124
+ let configStart = -1
125
+ for (let index = row.startLine; index <= row.endLine; index += 1) {
126
+ if (CONFIG_LINE.test(lines[index])) {
127
+ configStart = index
128
+ break
129
+ }
130
+ }
131
+ const block = ` config: ${JSON.stringify(config ?? {})}`
132
+ if (configStart === -1) {
133
+ lines.splice(row.endLine + 1, 0, block)
134
+ return { text: lines.join('\n'), changed: true }
135
+ }
136
+ let configEnd = configStart
137
+ for (let cursor = configStart + 1; cursor <= row.endLine; cursor += 1) {
138
+ if (!/^\s{3,}/.test(lines[cursor])) break
139
+ configEnd = cursor
140
+ }
141
+ lines.splice(configStart, configEnd - configStart + 1, block)
142
+ return { text: lines.join('\n'), changed: true }
143
+ }
144
+
145
+ export function readPatchText(profileDir) {
146
+ if (!profileDir) return ''
147
+ const path = `${profileDir}/cordis.patch.yml`
148
+ try {
149
+ return readFileSync(path, 'utf8')
150
+ } catch {
151
+ return ''
152
+ }
153
+ }
154
+
155
+ export function backupPatch(profileDir, backupRoot, stamp = new Date()) {
156
+ if (!profileDir) return null
157
+ const path = `${profileDir}/cordis.patch.yml`
158
+ if (!existsSync(path)) return null
159
+ mkdirSync(backupRoot, { recursive: true })
160
+ const tag = stamp.toISOString().replace(/[-:]/g, '').replace(/\..*$/, '')
161
+ const target = `${backupRoot}/cordis.patch.yml.${tag}.bak`
162
+ copyFileSync(path, target)
163
+ return target
164
+ }
165
+
166
+ export function writePatchText(profileDir, text) {
167
+ const path = `${profileDir}/cordis.patch.yml`
168
+ mkdirSync(dirname(path), { recursive: true })
169
+ writeFileSync(path, text, 'utf8')
170
+ return path
171
+ }
172
+
173
+ /** Read `{ appId: 1, nested: { a: 'b' } }` — JSON once bare keys are quoted. */
174
+ function parseFlowMapping(text) {
175
+ const quotedKeys = String(text).replace(/([{,]\s*)([A-Za-z_$][\w$-]*)\s*:/g, '$1"$2":')
176
+ for (const candidate of [quotedKeys, String(text).replace(/'/g, '"')]) {
177
+ try {
178
+ const value = JSON.parse(candidate)
179
+ return value && typeof value === 'object' ? value : null
180
+ } catch {
181
+ // try next form
182
+ }
183
+ }
184
+ return null
185
+ }
186
+
187
+ function unquote(value) { const trimmed = String(value ?? '').trim()
188
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
189
+ return trimmed.slice(1, -1)
190
+ }
191
+ return trimmed
192
+ }
@@ -0,0 +1,117 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { hostname, platform } from 'node:os'
3
+ import { join } from 'node:path'
4
+
5
+ import { PACKAGE_NAME, filterConfig } from './config.js'
6
+ import { parseRows, readPatchText, rowConfig } from './patch.js'
7
+
8
+ export const SNAPSHOT_SCHEMA = 1
9
+
10
+ export function pluginManagerOf(ctx) {
11
+ const pm = ctx?.get?.('pluginManager')
12
+ if (!pm || typeof pm.listBundles !== 'function' || typeof pm.listPlugins !== 'function') {
13
+ throw new Error('pluginManager service unavailable (host too old or plugin not loaded in the app scope)')
14
+ }
15
+ return pm
16
+ }
17
+
18
+ function localSpecOf(bundle) {
19
+ const spec = bundle?.installTarget?.spec || bundle?.source || bundle?.spec || null
20
+ if (typeof spec !== 'string' || !spec.trim()) return null
21
+ return spec.trim()
22
+ }
23
+
24
+ /** Turn `file:D:/x/y`, `file:///D:/x/y` or a bare absolute path into a directory. */
25
+ export function localDirOf(spec) {
26
+ if (!spec) return null
27
+ let value = spec.trim()
28
+ if (value.startsWith('file:')) value = value.slice(5)
29
+ if (value.startsWith('//')) value = value.slice(2)
30
+ if (/^\/[A-Za-z]:\//.test(value)) value = value.slice(1)
31
+ if (!/^([A-Za-z]:[\\/]|\/)/.test(value)) return null
32
+ return value.replace(/\\/g, '/')
33
+ }
34
+
35
+ export async function buildSnapshot({ ctx, cfg, profileDir, includeConfig = false, includeSources = false }) {
36
+ const pm = pluginManagerOf(ctx)
37
+ const rawBundles = await pm.listBundles()
38
+ const rawPlugins = await pm.listPlugins()
39
+ const bundles = Array.isArray(rawBundles) ? rawBundles : []
40
+ const plugins = Array.isArray(rawPlugins) ? rawPlugins : []
41
+ const patchText = readPatchText(profileDir)
42
+ const rows = parseRows(patchText)
43
+
44
+ const bundleViews = bundles.map((bundle) => {
45
+ const spec = localSpecOf(bundle)
46
+ return {
47
+ name: String(bundle.name),
48
+ version: bundle.version ?? null,
49
+ spec,
50
+ enabled: bundle.enabled !== false,
51
+ official: Boolean(bundle.official),
52
+ optional: Boolean(bundle.optional),
53
+ availability: bundle.availability ?? null,
54
+ updatable: Boolean(bundle.updatable),
55
+ }
56
+ })
57
+
58
+ const configs = {}
59
+ const sources = {}
60
+ if (includeConfig) {
61
+ for (const row of rows) {
62
+ if (!row.name) continue
63
+ const value = rowConfig(patchText, row)
64
+ if (!value || typeof value !== 'object' || '__raw' in value) continue
65
+ configs[row.name] = {
66
+ rowId: row.id,
67
+ config: filterConfig(value, { includeSecrets: Boolean(cfg.capSecrets) }),
68
+ }
69
+ }
70
+ }
71
+ if (includeSources) {
72
+ for (const bundle of bundleViews) {
73
+ const dir = localDirOf(bundle.spec)
74
+ if (dir && existsSync(join(dir, 'package.json'))) {
75
+ sources[bundle.name] = { kind: 'dir', path: dir }
76
+ }
77
+ }
78
+ }
79
+
80
+ return {
81
+ schema: SNAPSHOT_SCHEMA,
82
+ at: new Date().toISOString(),
83
+ node: {
84
+ name: cfg.localName || hostname(),
85
+ hostname: hostname(),
86
+ platform: platform(),
87
+ profile: profileDir ? profileDir.replace(/\\/g, '/').split('/').pop() : null,
88
+ plugin: PACKAGE_NAME,
89
+ version: cfg.__version || null,
90
+ },
91
+ bundles: bundleViews,
92
+ plugins: plugins.map((plugin) => ({
93
+ entryId: String(plugin.entryId ?? ''),
94
+ moduleName: String(plugin.moduleName ?? ''),
95
+ enabled: plugin.enabled !== false,
96
+ phase: plugin.fiberPhase ?? null,
97
+ })),
98
+ configs,
99
+ sources,
100
+ }
101
+ }
102
+
103
+ export function summarizeSnapshot(snapshot) {
104
+ if (!snapshot || typeof snapshot !== 'object') {
105
+ return { node: null, bundles: 0, plugins: 0, enabled: 0, configs: 0 }
106
+ }
107
+ const bundles = Array.isArray(snapshot.bundles) ? snapshot.bundles : []
108
+ return {
109
+ node: snapshot.node?.name ?? null,
110
+ profile: snapshot.node?.profile ?? null,
111
+ at: snapshot.at ?? null,
112
+ bundles: bundles.length,
113
+ plugins: Array.isArray(snapshot.plugins) ? snapshot.plugins.length : 0,
114
+ enabled: bundles.filter((bundle) => bundle.enabled).length,
115
+ configs: Object.keys(snapshot.configs || {}).length,
116
+ }
117
+ }
@@ -0,0 +1,85 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { basename, dirname, join } from 'node:path'
4
+
5
+ /** Tar-based copy of a local `file:` bundle so the peer can install it too. */
6
+
7
+ export function packDirectory(dir, { maxBytes = 48 * 1024 * 1024, excludes = ['node_modules', '.git'] } = {}) {
8
+ return new Promise((resolve, reject) => {
9
+ if (!existsSync(dir)) {
10
+ reject(new Error(`目录不存在: ${dir}`))
11
+ return
12
+ }
13
+ const parent = dirname(dir)
14
+ const base = basename(dir)
15
+ const args = ['-cf', '-', '-C', parent]
16
+ for (const item of excludes) args.push(`--exclude=${item}`)
17
+ args.push(base)
18
+ const child = spawn(tarExecutable(), args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
19
+ const chunks = []
20
+ let size = 0
21
+ let stderr = ''
22
+ child.stdout.on('data', (chunk) => {
23
+ size += chunk.length
24
+ if (size > maxBytes) {
25
+ try {
26
+ child.kill()
27
+ } catch {
28
+ // ignore
29
+ }
30
+ reject(new Error(`目录太大(>${Math.round(maxBytes / 1024 / 1024)}MB),已跳过: ${dir}`))
31
+ return
32
+ }
33
+ chunks.push(chunk)
34
+ })
35
+ child.stderr.on('data', (chunk) => {
36
+ stderr += String(chunk)
37
+ })
38
+ child.on('error', (error) => reject(new Error(`tar 不可用: ${error.message}`)))
39
+ child.on('exit', (code) => {
40
+ if (size > maxBytes) return
41
+ if (code !== 0) {
42
+ reject(new Error(`tar 打包失败 (${code}): ${stderr.trim().slice(-300)}`))
43
+ return
44
+ }
45
+ resolve(Buffer.concat(chunks))
46
+ })
47
+ })
48
+ }
49
+
50
+ export function unpackDirectory(buffer, destRoot, name) {
51
+ return new Promise((resolve, reject) => {
52
+ const safe = String(name).replace(/[^A-Za-z0-9._@-]/g, '_')
53
+ const dest = join(destRoot, safe)
54
+ mkdirSync(destRoot, { recursive: true })
55
+ rmSync(dest, { recursive: true, force: true })
56
+ mkdirSync(dest, { recursive: true })
57
+ const archive = join(destRoot, `${safe}.tar`)
58
+ writeFileSync(archive, buffer)
59
+ const child = spawn(tarExecutable(), ['-xf', archive, '-C', destRoot], {
60
+ stdio: ['ignore', 'ignore', 'pipe'],
61
+ windowsHide: true,
62
+ })
63
+ let stderr = ''
64
+ child.stderr.on('data', (chunk) => {
65
+ stderr += String(chunk)
66
+ })
67
+ child.on('error', (error) => reject(new Error(`tar 不可用: ${error.message}`)))
68
+ child.on('exit', (code) => {
69
+ try {
70
+ rmSync(archive, { force: true })
71
+ } catch {
72
+ // ignore
73
+ }
74
+ if (code !== 0) {
75
+ reject(new Error(`解包失败 (${code}): ${stderr.trim().slice(-300)}`))
76
+ return
77
+ }
78
+ resolve(dest)
79
+ })
80
+ })
81
+ }
82
+
83
+ function tarExecutable() {
84
+ return process.platform === 'win32' ? 'tar' : 'tar'
85
+ }
@@ -0,0 +1,168 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { createServer } from 'node:net'
3
+
4
+ /**
5
+ * Talks to the peer's `/plugin-sync/peer/*` namespace.
6
+ * Two drivers, one wire protocol:
7
+ * - http: talk to the peer's web server directly
8
+ * - ssh: open a local forward (`ssh -N -L`) and then talk HTTP through it
9
+ * That keeps merge/apply logic identical on both machines.
10
+ */
11
+ export class PeerLink {
12
+ constructor(cfg, { log = () => {} } = {}) {
13
+ // Callers normally hand over a normalized config; stand-alone callers
14
+ // (scripts, tests) may hand over a raw state file, so fill the two ports.
15
+ this.cfg = {
16
+ ...cfg,
17
+ peerPort: Number(cfg?.peerPort) || 22,
18
+ peerHttpPort: Number(cfg?.peerHttpPort) || 18777,
19
+ }
20
+ this.log = log
21
+ this.child = null
22
+ this.baseUrl = null
23
+ this.localPort = null
24
+ }
25
+
26
+ get configured() {
27
+ if (this.cfg.peerMode === 'http') return Boolean(this.cfg.peerUrl)
28
+ if (this.cfg.peerMode === 'ssh') return Boolean(this.cfg.peerHost && this.cfg.peerUser)
29
+ return false
30
+ }
31
+
32
+ describe() {
33
+ if (this.cfg.peerMode === 'http') return this.cfg.peerUrl || '(未设置地址)'
34
+ if (this.cfg.peerMode === 'ssh') return `${this.cfg.peerUser}@${this.cfg.peerHost}:${this.cfg.peerPort} → 127.0.0.1:${this.cfg.peerHttpPort}`
35
+ return '(未配置对端)'
36
+ }
37
+
38
+ async ensure() {
39
+ if (this.baseUrl) return this.baseUrl
40
+ if (this.cfg.peerMode === 'http') {
41
+ if (!this.cfg.peerUrl) throw new Error('对端地址为空')
42
+ this.baseUrl = this.cfg.peerUrl.replace(/\/+$/, '')
43
+ return this.baseUrl
44
+ }
45
+ if (this.cfg.peerMode === 'ssh') return this.ensureTunnel()
46
+ throw new Error('未配置对端(设置里选择 http 或 ssh)')
47
+ }
48
+
49
+ async ensureTunnel() {
50
+ if (this.child && this.baseUrl) return this.baseUrl
51
+ const port = await freePort()
52
+ const args = [
53
+ '-N',
54
+ '-L',
55
+ `127.0.0.1:${port}:127.0.0.1:${this.cfg.peerHttpPort}`,
56
+ `${this.cfg.peerUser}@${this.cfg.peerHost}`,
57
+ '-p',
58
+ String(this.cfg.peerPort),
59
+ '-o',
60
+ 'BatchMode=yes',
61
+ '-o',
62
+ 'ExitOnForwardFailure=yes',
63
+ '-o',
64
+ 'ServerAliveInterval=15',
65
+ '-o',
66
+ 'ServerAliveCountMax=3',
67
+ '-o',
68
+ 'StrictHostKeyChecking=accept-new',
69
+ '-o',
70
+ 'ConnectTimeout=10',
71
+ ]
72
+ if (this.cfg.peerKey) args.push('-i', this.cfg.peerKey)
73
+ this.log(`ssh tunnel: 127.0.0.1:${port} → ${this.cfg.peerHost}:${this.cfg.peerHttpPort}`)
74
+ const child = spawn('ssh', args, { stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true })
75
+ this.child = child
76
+ this.localPort = port
77
+ let stderr = ''
78
+ child.stderr?.on('data', (chunk) => {
79
+ stderr += String(chunk)
80
+ })
81
+ child.on('exit', (code) => {
82
+ if (this.child === child && code !== 0) {
83
+ this.log(`ssh tunnel exited (${code}): ${stderr.trim().slice(-400)}`)
84
+ }
85
+ if (this.child === child) {
86
+ this.child = null
87
+ this.baseUrl = null
88
+ }
89
+ })
90
+ const base = `http://127.0.0.1:${port}`
91
+ const deadline = Date.now() + 25000
92
+ while (Date.now() < deadline) {
93
+ await sleep(500)
94
+ try {
95
+ await this.call('/plugin-sync/peer/ping', { timeoutMs: 3000, baseUrl: base })
96
+ this.baseUrl = base
97
+ return base
98
+ } catch {
99
+ if (!this.child) break
100
+ }
101
+ }
102
+ this.dispose()
103
+ throw new Error(`ssh 隧道未就绪${stderr ? `:${stderr.trim().slice(-300)}` : ''}`)
104
+ }
105
+
106
+ async call(path, options = {}) {
107
+ const baseUrl = options.baseUrl || (await this.ensure())
108
+ const headers = { accept: 'application/json', ...(options.headers || {}) }
109
+ if (this.cfg.peerToken) headers['x-plugin-sync-token'] = this.cfg.peerToken
110
+ let body
111
+ if (options.raw !== undefined) {
112
+ body = options.raw
113
+ headers['content-type'] = headers['content-type'] || 'application/octet-stream'
114
+ } else if (options.body !== undefined) {
115
+ body = JSON.stringify(options.body)
116
+ headers['content-type'] = 'application/json'
117
+ }
118
+ const timeout = options.timeoutMs ?? this.cfg.timeoutMs
119
+ const response = await fetch(`${baseUrl}${path}`, {
120
+ method: options.method || 'GET',
121
+ headers,
122
+ body,
123
+ signal: AbortSignal.timeout(Math.max(1000, timeout)),
124
+ })
125
+ const text = await response.text()
126
+ if (!response.ok) {
127
+ throw new Error(`${path} → HTTP ${response.status}: ${text.slice(0, 300)}`)
128
+ }
129
+ if (!text) return null
130
+ try {
131
+ return JSON.parse(text)
132
+ } catch {
133
+ const error = new Error(`${path} 返回的不是 JSON`)
134
+ error.raw = text.slice(0, 300)
135
+ throw error
136
+ }
137
+ }
138
+
139
+ dispose() {
140
+ if (this.child) {
141
+ try {
142
+ this.child.kill()
143
+ } catch {
144
+ // ignore
145
+ }
146
+ this.child = null
147
+ }
148
+ this.baseUrl = null
149
+ this.localPort = null
150
+ }
151
+ }
152
+
153
+ export function freePort() {
154
+ return new Promise((resolve, reject) => {
155
+ const server = createServer()
156
+ server.unref()
157
+ server.on('error', reject)
158
+ server.listen(0, '127.0.0.1', () => {
159
+ const address = server.address()
160
+ const port = typeof address === 'object' && address ? address.port : 0
161
+ server.close(() => (port ? resolve(port) : reject(new Error('no free port'))))
162
+ })
163
+ })
164
+ }
165
+
166
+ export function sleep(ms) {
167
+ return new Promise((resolve) => setTimeout(resolve, ms))
168
+ }
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "@qic-9821/dsh-plugin-sync",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "Keep the DSH plugin set (inventory, configs, enable state, package installs, local bundle binaries) in sync between two machines, with per-capability switches, schedule controls and a settings/panel UI.",
5
+ "license": "MIT",
6
+ "author": "QIC-9821",
7
+ "type": "module",
8
+ "private": false,
9
+ "main": "./index.js",
10
+ "engines": {
11
+ "node": ">=22"
12
+ },
13
+ "exports": {
14
+ ".": "./index.js",
15
+ "./client": "./client.js",
16
+ "./package.json": "./package.json"
17
+ },
18
+ "files": [
19
+ "index.js",
20
+ "client.js",
21
+ "lib/",
22
+ "README.md",
23
+ "cordis.patch.yml",
24
+ "LICENSE"
25
+ ],
26
+ "keywords": [
27
+ "dsh",
28
+ "deepseek-harness",
29
+ "cordis",
30
+ "plugin",
31
+ "sync",
32
+ "profile"
33
+ ],
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/QIC-9821/dsh-plugin-sync.git"
37
+ },
38
+ "homepage": "https://github.com/QIC-9821/dsh-plugin-sync#readme",
39
+ "bugs": {
40
+ "url": "https://github.com/QIC-9821/dsh-plugin-sync/issues"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "dsh": {
46
+ "bundle": {
47
+ "patch": "./cordis.patch.yml"
48
+ },
49
+ "client": {
50
+ "platform": "web",
51
+ "immediately": true,
52
+ "inject": [
53
+ "@deepseek-ai/dsh-client-ui-settings"
54
+ ]
55
+ }
56
+ },
57
+ "scripts": {
58
+ "test": "node --test test/*.test.js"
59
+ }
6
60
  }