@herenfor/dsh-wsl 0.0.0-stage → 0.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/sessions.mjs ADDED
@@ -0,0 +1,101 @@
1
+ // Merging the two Hosts' Workspace and Session views for the page. Frames from the WSL Host have already had their
2
+ // identities prefixed (ids.mjs). Kept free of DSH imports so it can be unit-tested.
3
+ //
4
+ // A folder can be known to both Hosts: a Windows folder whose Sessions run in WSL has a twin Workspace on the WSL Host
5
+ // (D:\code\proj here, /mnt/d/code/proj there). The page shows one folder row; the twin's Sessions are listed in it.
6
+ // A folder only the WSL Host knows (\\wsl.localhost\Ubuntu\home\...) is a row of its own.
7
+
8
+ import { isWsl, toPage } from './ids.mjs'
9
+ import { toWindowsSpec, toWslSpec } from './paths.mjs'
10
+
11
+ const empty = () => ({ items: [], archivedSessionIds: [], pinnedSessionIds: [] })
12
+
13
+ /** How the WSL Host names a path of this Host, and how this Host shows a WSL Host path. */
14
+ export function pathMapping(platform, distro) {
15
+ const win = platform === 'win32'
16
+ const unc = (path) => typeof path === 'string' && /^[\\/]{2}(?:wsl\.localhost|wsl\$)[\\/]/i.test(path)
17
+ return {
18
+ toWsl: (path) => (win || unc(path) ? toWslSpec(path) : path),
19
+ toLocal: (path) => (win ? toWindowsSpec(path, distro) : path),
20
+ /** A path naming a folder that lives in WSL (\\wsl.localhost\..., \\wsl$\...). */
21
+ insideWsl: unc,
22
+ }
23
+ }
24
+
25
+ /** Apply one `workspace/follow` frame to a source's state. */
26
+ export function applyWorkspaceFrame(state, frame) {
27
+ switch (frame.type) {
28
+ case 'baseline': case 'replace': return { ...frame.value, items: [...frame.value.items] }
29
+ case 'upsert': return { ...state, items: [...state.items.filter((w) => w.workspaceId !== frame.workspace.workspaceId), frame.workspace] }
30
+ case 'remove': return { ...state, items: state.items.filter((w) => w.workspaceId !== frame.workspaceId) }
31
+ case 'order': return { ...state, items: [...state.items].sort((a, b) => frame.workspaceIds.indexOf(a.workspaceId) - frame.workspaceIds.indexOf(b.workspaceId)) }
32
+ case 'archived': return { ...state, archivedSessionIds: frame.archivedSessionIds }
33
+ case 'pinned': return { ...state, pinnedSessionIds: frame.pinnedSessionIds }
34
+ default: return state
35
+ }
36
+ }
37
+
38
+ /**
39
+ * The page's view of both Hosts' Workspaces. Returns the merged baseline and the twin pairs found
40
+ * (local workspaceId → WSL workspaceId, both as the page names them).
41
+ */
42
+ export function mergeWorkspaces(local = empty(), wsl = empty(), paths) {
43
+ const byWslPath = new Map(wsl.items.map((w) => [w.path, w]))
44
+ const twins = new Map()
45
+ const items = local.items.map((w) => {
46
+ const twin = byWslPath.get(paths.toWsl(w.path))
47
+ if (!twin) return w
48
+ twins.set(w.workspaceId, twin.workspaceId)
49
+ return { ...w, sessionIds: [...w.sessionIds, ...twin.sessionIds] }
50
+ })
51
+ const folded = new Set(twins.values())
52
+ for (const w of wsl.items) if (!folded.has(w.workspaceId)) items.push({ ...w, path: paths.toLocal(w.path) })
53
+ return {
54
+ value: {
55
+ items,
56
+ archivedSessionIds: [...local.archivedSessionIds, ...wsl.archivedSessionIds],
57
+ pinnedSessionIds: [...local.pinnedSessionIds, ...wsl.pinnedSessionIds],
58
+ },
59
+ twins,
60
+ }
61
+ }
62
+
63
+ /** Session summaries from the WSL Host, with paths as this Host shows them. */
64
+ export const localizeSessions = (items, paths) => items.map((s) => (typeof s.cwd === 'string' ? { ...s, cwd: paths.toLocal(s.cwd) } : s))
65
+
66
+ /** A `session/control` baseline as live projection frames, one per Session (for a baseline that arrives late). */
67
+ export const baselineFrames = (baseline) => Object.entries(baseline.projections ?? {}).map(([sessionId, block]) => ({
68
+ type: 'projection',
69
+ sessionId,
70
+ seq: block.asOfSeq ?? 0,
71
+ changes: Object.entries(block.values ?? {}).map(([key, val]) => ({ key, val })),
72
+ }))
73
+
74
+ /** `session/control` baseline from the WSL Host: its projections are keyed by Session id. */
75
+ export const prefixProjections = (baseline) => ({
76
+ ...baseline,
77
+ projections: Object.fromEntries(Object.entries(baseline.projections ?? {}).map(([id, block]) => [toPage(id), block])),
78
+ })
79
+
80
+ /** One `$events` frame from the WSL Host: `api-session/*` events name their Session as the first argument. */
81
+ export function prefixEvent(frame) {
82
+ if (frame.type === 'emit' && /^api-session\//.test(frame.event) && typeof frame.args?.[0] === 'string' && !isWsl(frame.args[0])) {
83
+ return { ...frame, args: [toPage(frame.args[0]), ...frame.args.slice(1)] }
84
+ }
85
+ return frame
86
+ }
87
+
88
+ /**
89
+ * Where a new Session goes. `target` is the run location chosen for the folder ('wsl' or 'local'); `twin` is the WSL
90
+ * Host's Workspace for a local folder, if it has one. Returns the Host and the request to send there (identities as
91
+ * the page names them; the caller strips the prefix for the WSL Host), or `needsTwin` when the WSL Host must first
92
+ * adopt the folder.
93
+ */
94
+ export function placeSession(request, { target, twin }) {
95
+ const { workspaceId, sessionId, ...rest } = request
96
+ if (workspaceId === undefined) return { host: 'local', request }
97
+ if (isWsl(workspaceId)) return { host: 'wsl', request: { ...rest, workspaceId, ...(isWsl(sessionId) ? { sessionId } : {}) } }
98
+ if (target !== 'wsl') return { host: 'local', request: { ...rest, workspaceId, ...(sessionId !== undefined && !isWsl(sessionId) ? { sessionId } : {}) } }
99
+ if (twin === undefined) return { host: 'wsl', needsTwin: true, request: { ...rest, ...(isWsl(sessionId) ? { sessionId } : {}) } }
100
+ return { host: 'wsl', request: { ...rest, workspaceId: twin, ...(isWsl(sessionId) ? { sessionId } : {}) } }
101
+ }
package/settings.mjs ADDED
@@ -0,0 +1,53 @@
1
+ // Settings for plugins installed only in WSL. Kept free of DSH imports so it can be unit-tested.
2
+ // The page reads every settings namespace with settings/describe and writes one namespace at a time with
3
+ // settings/update, settings/replace or settings/mutate (argument `ns`). A namespace only the WSL Host serves belongs
4
+ // to a plugin installed only there: describe shows it next to this Host's own, and writes to it go to the WSL Host.
5
+ // Namespaces both Hosts serve stay on this Host (which side they should follow is an open design question).
6
+
7
+ export const SETTINGS_WRITES = new Set(['update', 'replace', 'mutate'])
8
+
9
+ /** The settings method an /api path names, or undefined. */
10
+ export function settingsMethod(pathname) {
11
+ const m = /^\/api\/settings\/([A-Za-z]+)$/.exec(pathname)
12
+ return m?.[1]
13
+ }
14
+
15
+ /**
16
+ * @param local - this Host's describe value { writable, hasDocument, namespaces: [{ ns, ... }] }.
17
+ * @param remote - the WSL Host's describe value.
18
+ * @returns { value, remoteNamespaces } where value lists this Host's namespaces, then the WSL-only ones.
19
+ */
20
+ export function mergeDescribe(local, remote) {
21
+ const mine = new Set(local.namespaces.map((view) => view.ns))
22
+ const extra = (remote?.namespaces ?? []).filter((view) => !mine.has(view.ns))
23
+ return { value: { ...local, namespaces: [...local.namespaces, ...extra] }, remoteNamespaces: new Set(extra.map((view) => view.ns)) }
24
+ }
25
+
26
+ // Desktop is the source of truth for every setting both Hosts have: those are copied to the WSL Host, one way.
27
+ // Never copied: secret fields (each side keeps its own), and namespaces that describe one machine only.
28
+ export const NEVER_SYNC = new Set(['bash-sandbox', 'pwsh-sandbox'])
29
+
30
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
31
+ const secretKeys = (view) => new Set((view?.secrets ?? []).map((s) => s.path?.[0]).filter(Boolean))
32
+
33
+ /**
34
+ * The mutate ops that make the WSL Host's user section of one namespace match this Host's, top-level field by field.
35
+ * @param desktop - this Host's view { user, secrets }.
36
+ * @param wsl - the WSL Host's view { user, secrets }.
37
+ * @returns ops: set for a differing field, unset for a field only the WSL Host has; secret fields untouched.
38
+ */
39
+ export function syncOps(desktop, wsl) {
40
+ const skip = new Set([...secretKeys(desktop), ...secretKeys(wsl)])
41
+ const mine = desktop?.user && typeof desktop.user === 'object' ? desktop.user : {}
42
+ const theirs = wsl?.user && typeof wsl.user === 'object' ? wsl.user : {}
43
+ const ops = []
44
+ for (const [key, value] of Object.entries(mine)) if (!skip.has(key) && !same(theirs[key], value)) ops.push({ op: 'set', path: [key], value })
45
+ for (const key of Object.keys(theirs)) if (!skip.has(key) && !(key in mine)) ops.push({ op: 'unset', path: [key] })
46
+ return ops
47
+ }
48
+
49
+ /** The namespaces to copy: both Hosts have them and none is machine-specific. */
50
+ export function sharedNamespaces(desktopViews, wslViews) {
51
+ const theirs = new Map(wslViews.map((v) => [v.ns, v]))
52
+ return desktopViews.filter((v) => theirs.has(v.ns) && !NEVER_SYNC.has(v.ns)).map((v) => ({ ns: v.ns, desktop: v, wsl: theirs.get(v.ns) }))
53
+ }
package/setup.mjs ADDED
@@ -0,0 +1,286 @@
1
+ // First-run setup of the WSL side (docs/deployment.md). Runs in the Desktop Host. Finds the WSL distribution, its Node
2
+ // and DSH, and says what is missing. Downloads happen only when the person asks (the "install" button runs
3
+ // installScript in WSL); otherwise the commands are shown, or handed to a Windows DSH task as a prompt. What needs no
4
+ // network (the bridge files, the WSL profile and its plugin) is done here and by the daemon.
5
+ //
6
+ // Two ways to run DSH in WSL, chosen by the person when WSL already has a DSH of their own:
7
+ // reuse their DSH and its data (~/.dsh): Desktop's WSL tasks and their own DSH sessions see each other
8
+ // private a DSH of this plugin's own (~/.dsh-wsl/dsh) with its own data (~/.dsh-wsl/home); nothing is shared, so
9
+ // two DSH versions never write the same data
10
+ // WSL side layout, in the distribution's home:
11
+ // ~/.dsh-wsl/node/ Node, when the distribution has none new enough
12
+ // ~/.dsh-wsl/dsh/ private: DSH, npm --prefix
13
+ // ~/.dsh-wsl/home/ private: the DSH home (sessions, workspaces, profiles)
14
+ // ~/.dsh-wsl/bridge/ transport/ and agent/, copied from this plugin (by setup)
15
+ // ~/.dsh-wsl/run/ the daemon's socket, token, identity and log
16
+ // Versions: the two DSH must share major.minor (compatibleRelease); a WSL DSH older than that is updated, a newer one
17
+ // asks for a Desktop update. Within major.minor they connect, and the person may still align them.
18
+ import { inLanguage } from './language.mjs'
19
+ import { execFile } from 'node:child_process'
20
+ import { createHash } from 'node:crypto'
21
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
22
+ import { join, posix, win32 } from 'node:path'
23
+
24
+ export const NODE_VERSION = '24.22.2'
25
+ export const MIN_NODE = [24, 9, 0]
26
+ export const PROFILE = 'desktop-wsl'
27
+
28
+ /** Run a command; resolves {code, stdout (Buffer), stderr}. Never rejects (a missing executable gives code null). */
29
+ export function run(command, args, { timeout = 60_000, input } = {}) {
30
+ return new Promise((resolve) => {
31
+ const child = execFile(command, args, { encoding: 'buffer', timeout, windowsHide: true, maxBuffer: 4 << 20 }, (error, stdout, stderr) => {
32
+ resolve({ code: error ? (typeof error.code === 'number' ? error.code : null) : 0, error: error?.code, stdout: stdout ?? Buffer.alloc(0), stderr: (stderr ?? Buffer.alloc(0)).toString('utf8') })
33
+ })
34
+ if (input !== undefined) child.stdin.end(input)
35
+ })
36
+ }
37
+
38
+ /** wsl.exe writes its own messages in UTF-16LE (without BOM); programs it runs write UTF-8. */
39
+ export function decodeWslText(buffer) {
40
+ const b = Buffer.from(buffer)
41
+ const utf16 = b.length >= 2 && (b[1] === 0 || (b[0] === 0xff && b[1] === 0xfe))
42
+ return (utf16 ? b.toString('utf16le') : b.toString('utf8')).replace(/^\uFEFF/, '').replace(/\0/g, '')
43
+ }
44
+
45
+ /** `wsl.exe --list --verbose`: rows of `* Ubuntu Running 2`; the starred one is the default. */
46
+ export function parseDistros(text) {
47
+ const rows = []
48
+ for (const line of text.split(/\r?\n/).slice(1)) {
49
+ const m = /^\s*(\*)?\s*(\S.*?)\s{2,}(\S+)\s+(\d)\s*$/.exec(line)
50
+ if (m) rows.push({ name: m[2], isDefault: m[1] === '*', state: m[3], version: Number(m[4]) })
51
+ }
52
+ return rows
53
+ }
54
+
55
+ // One shell script answers everything setup needs to know about a distribution, as key=value lines.
56
+ const PROBE = String.raw`H="$HOME"; echo "home=$H"; echo "arch=$(uname -m)"
57
+ for c in "$H/.dsh-wsl/node/bin/node" "$H/.local/bin/node" "$(command -v node 2>/dev/null)" /usr/local/bin/node /usr/bin/node; do
58
+ [ -n "$c" ] && [ -x "$c" ] && echo "node=$c $("$c" -v 2>/dev/null)"
59
+ done
60
+ P="$H/.dsh-wsl/dsh/node_modules/@deepseek-ai/dsh/package.json"; [ -f "$P" ] && echo "dsh=$(tr -d '\n' < "$P")"
61
+ for c in "$(bash -lc 'command -v dsh' 2>/dev/null | tail -n1)" "$H/.local/bin/dsh" /usr/local/bin/dsh /usr/bin/dsh; do
62
+ [ -n "$c" ] && [ -e "$c" ] || continue
63
+ r=$(readlink -f "$c"); d=$(dirname "$r")
64
+ while [ -n "$d" ] && [ "$d" != / ]; do
65
+ if [ -f "$d/package.json" ] && grep -q '"name": *"@deepseek-ai/dsh"' "$d/package.json"; then echo "own=$r|$d|$(tr -d '\n' < "$d/package.json")"; break; fi
66
+ d=$(dirname "$d")
67
+ done
68
+ done
69
+ true`
70
+
71
+ /** Parse the probe's output. */
72
+ export function parseProbe(text) {
73
+ const out = { home: undefined, arch: undefined, nodes: [], dsh: null, own: null }
74
+ for (const line of text.split(/\r?\n/)) {
75
+ const i = line.indexOf('='); if (i < 0) continue
76
+ const key = line.slice(0, i); const value = line.slice(i + 1)
77
+ if (key === 'home') out.home = value
78
+ else if (key === 'arch') out.arch = value
79
+ else if (key === 'node') { const [path, version] = value.split(' '); if (version && !out.nodes.some((n) => n.path === path)) out.nodes.push({ path, version }) }
80
+ else if (key === 'dsh') { try { const pkg = JSON.parse(value); out.dsh = { version: pkg.version, bin: typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.dsh } } catch {} }
81
+ else if (key === 'own' && !out.own) {
82
+ const [entry, pkgDir, ...json] = value.split('|')
83
+ // The plugin's private DSH is not the person's own.
84
+ if (out.home && pkgDir.startsWith(`${out.home}/.dsh-wsl/`)) continue
85
+ try { const pkg = JSON.parse(json.join('|')); out.own = { entry, pkgDir, version: pkg.version, prefix: npmPrefixOf(pkgDir) } } catch {}
86
+ }
87
+ }
88
+ return out
89
+ }
90
+
91
+ /** The npm prefix a global install lives under (<prefix>/lib/node_modules/@deepseek-ai/dsh), else null. */
92
+ export const npmPrefixOf = (pkgDir) => /^(.*)\/lib\/node_modules\/@deepseek-ai\/dsh$/.exec(pkgDir ?? '')?.[1] ?? null
93
+
94
+ const parseVersion = (v) => (/^v?(\d+)\.(\d+)\.(\d+)/.exec(v ?? '') ?? []).slice(1).map(Number)
95
+ /** Semver order of two versions (prereleases before their release; numeric identifiers compared as numbers). */
96
+ export function compareVersions(a, b) {
97
+ const pa = parseVersion(a); const pb = parseVersion(b)
98
+ for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) < (pb[i] ?? 0) ? -1 : 1
99
+ const ra = /-([^+]+)/.exec(a ?? '')?.[1]; const rb = /-([^+]+)/.exec(b ?? '')?.[1]
100
+ if (ra === rb) return 0
101
+ if (ra === undefined) return 1
102
+ if (rb === undefined) return -1
103
+ const xa = ra.split('.'); const xb = rb.split('.')
104
+ for (let i = 0; i < Math.max(xa.length, xb.length); i++) {
105
+ if (xa[i] === undefined) return -1
106
+ if (xb[i] === undefined) return 1
107
+ const na = /^\d+$/.test(xa[i]); const nb = /^\d+$/.test(xb[i])
108
+ if (xa[i] === xb[i]) continue
109
+ if (na && nb) return Number(xa[i]) < Number(xb[i]) ? -1 : 1
110
+ if (na !== nb) return na ? -1 : 1
111
+ return xa[i] < xb[i] ? -1 : 1
112
+ }
113
+ return 0
114
+ }
115
+ /** The two DSH can work together: same major.minor. */
116
+ export function compatibleRelease(a, b) {
117
+ const pa = parseVersion(a); const pb = parseVersion(b)
118
+ return pa.length === 3 && pb.length === 3 && pa[0] === pb[0] && pa[1] === pb[1]
119
+ }
120
+ export const nodeOk = (v) => { const p = parseVersion(v); if (p.length < 3) return false; for (let i = 0; i < 3; i++) if (p[i] !== MIN_NODE[i]) return p[i] > MIN_NODE[i]; return true }
121
+
122
+ /**
123
+ * Where the WSL side stands. `listing` is {error?} or {distros}; `chosen` is {distro?, mode?}; `probe` is parseProbe's
124
+ * result for the distribution (absent when there is none). States: no-wsl, no-distro, wsl1, choose (WSL has the
125
+ * person's own DSH and no mode is chosen), needs-node, needs-dsh, version-mismatch (WSL's DSH too old),
126
+ * desktop-outdated (WSL's DSH newer than Desktop's major.minor), ready (with `drift` when versions differ within it).
127
+ */
128
+ export function evaluate({ desktopVersion, listing, chosen = {}, probe }) {
129
+ if (listing.error) return { state: 'no-wsl', distros: [] }
130
+ const distros = listing.distros
131
+ if (distros.length === 0) return { state: 'no-distro', distros }
132
+ const distro = distros.find((d) => d.name === chosen.distro) ?? distros.find((d) => d.isDefault) ?? distros[0]
133
+ const base = { distro: distro.name, distros, home: probe?.home, arch: probe?.arch, own: probe?.own ?? null }
134
+ if (distro.version === 1) return { ...base, state: 'wsl1' }
135
+ if (!probe) return { ...base, state: 'error', error: 'cannot look into the distribution' }
136
+ const node = probe.nodes.find((n) => nodeOk(n.version))
137
+ let mode = chosen.mode === 'reuse' && !probe.own ? undefined : chosen.mode
138
+ if (mode !== 'reuse' && mode !== 'private') {
139
+ if (probe.own) return { ...base, state: 'choose', node }
140
+ mode = 'private'
141
+ }
142
+ const dsh = mode === 'reuse'
143
+ ? { version: probe.own.version, entry: probe.own.entry }
144
+ : probe.dsh && { version: probe.dsh.version, entry: posix.join(probe.home, '.dsh-wsl', 'dsh', 'node_modules', '@deepseek-ai', 'dsh', probe.dsh.bin ?? 'lib/bin.js') }
145
+ const at = { ...base, mode, node, dshHome: mode === 'private' ? posix.join(probe.home, '.dsh-wsl', 'home') : null }
146
+ if (!dsh) return { ...at, state: node ? 'needs-dsh' : 'needs-node' }
147
+ at.installed = dsh.version
148
+ if (!node) return { ...at, state: 'needs-node' }
149
+ at.dshEntry = dsh.entry
150
+ const order = compareVersions(dsh.version, desktopVersion)
151
+ if (!compatibleRelease(dsh.version, desktopVersion)) return { ...at, state: order < 0 ? 'version-mismatch' : 'desktop-outdated' }
152
+ return { ...at, state: 'ready', drift: order === 0 ? null : order < 0 ? 'wsl-older' : 'wsl-newer' }
153
+ }
154
+
155
+ const INSTALLS = new Set(['needs-node', 'needs-dsh', 'version-mismatch'])
156
+ /** Whether the DSH install/update commands apply (also offered when versions merely drift with WSL behind). */
157
+ export const canInstall = (status) => INSTALLS.has(status.state) || (status.state === 'ready' && status.drift === 'wsl-older')
158
+
159
+ /** The commands the person runs in the WSL terminal for this state (empty when none apply). */
160
+ export function commandsFor(status, desktopVersion, lang = 'zh') {
161
+ const say = (texts) => inLanguage(lang, texts)
162
+ if (!canInstall(status)) return ''
163
+ const lines = []
164
+ const arch = status.arch === 'aarch64' ? 'arm64' : 'x64'
165
+ const nodeDir = status.node ? posix.dirname(status.node.path) : '$HOME/.dsh-wsl/node/bin'
166
+ if (status.state === 'needs-node') {
167
+ const name = `node-v${NODE_VERSION}-linux-${arch}`
168
+ lines.push(say({ zh: '# 1. Node.js(只给这个插件用,装在 ~/.dsh-wsl/node)', en: '# 1. Node.js (for this plugin only, in ~/.dsh-wsl/node)' }))
169
+ lines.push(`mkdir -p ~/.dsh-wsl && cd ~/.dsh-wsl && curl -fL https://nodejs.org/dist/v${NODE_VERSION}/${name}.tar.xz | tar -xJ && rm -rf node && mv ${name} node`)
170
+ }
171
+ const step = status.state === 'needs-node' ? '2. ' : ''
172
+ if (status.mode === 'reuse') {
173
+ const prefix = status.own?.prefix
174
+ lines.push(say({ zh: `# ${step}把你的 dsh 更新到 ${desktopVersion}(和 Desktop 同版本;你自己用的 dsh 也会一起更新)`, en: `# ${step}Update your dsh to ${desktopVersion} (the Desktop's version; the dsh you use yourself is updated too)` }))
175
+ lines.push(`PATH="${nodeDir}:$PATH" npm install -g ${prefix ? `--prefix "${prefix}" ` : ''}@deepseek-ai/dsh@${desktopVersion}`)
176
+ } else {
177
+ lines.push(say({ zh: `# ${step}DSH ${desktopVersion}(和 Desktop 同版本,装在 ~/.dsh-wsl/dsh)`, en: `# ${step}DSH ${desktopVersion} (the Desktop's version, in ~/.dsh-wsl/dsh)` }))
178
+ lines.push(`PATH="${nodeDir}:$PATH" npm install --prefix ~/.dsh-wsl/dsh @deepseek-ai/dsh@${desktopVersion}`)
179
+ }
180
+ return lines.join('\n')
181
+ }
182
+
183
+ /** What the install button runs in WSL (bash): stop the daemon first, so no DSH runs from files being replaced. */
184
+ export function installScript(status, desktopVersion) {
185
+ const commands = commandsFor(status, desktopVersion).split('\n').filter((l) => l && !l.startsWith('#'))
186
+ if (!commands.length) return ''
187
+ return [
188
+ 'set -eo pipefail',
189
+ `D="$HOME/.dsh-wsl/run/${PROFILE}/daemon.json"`,
190
+ 'P=$(sed -n \'s/.*"pid":\\([0-9]*\\).*/\\1/p\' "$D" 2>/dev/null || true)',
191
+ 'if [ -n "$P" ] && kill "$P" 2>/dev/null; then for i in $(seq 1 50); do kill -0 "$P" 2>/dev/null || break; sleep 0.2; done; echo "stopped the WSL side ($P)"; fi',
192
+ ...commands.map((c) => `echo "\$ ${c.replace(/["\\$`]/g, '\\$&')}"\n${c}`),
193
+ 'echo "done"',
194
+ ].join('\n')
195
+ }
196
+
197
+ /** The same setup as a task for the Windows DSH: it runs the commands through wsl.exe and checks the result. */
198
+ export function promptFor(status, desktopVersion, lang = 'zh') {
199
+ const commands = commandsFor(status, desktopVersion, lang)
200
+ if (!commands) return ''
201
+ const check = status.mode === 'reuse' ? 'dsh --version' : 'grep -m1 version ~/.dsh-wsl/dsh/node_modules/@deepseek-ai/dsh/package.json'
202
+ const en = [
203
+ `Please set up what the DSH WSL run mode needs in WSL (distribution ${status.distro}). Do not change anything else already in WSL, and do not use sudo.`,
204
+ '',
205
+ `In PowerShell, run the commands below one by one with \`wsl.exe -d ${status.distro} -- bash -lc '<command>'\` (lines starting with # are notes, not commands):`,
206
+ '',
207
+ '```bash', commands, '```',
208
+ '',
209
+ `When done, run \`wsl.exe -d ${status.distro} -- bash -lc '${check}'\` and confirm the version is ${desktopVersion}.`,
210
+ 'If npm cannot find this version, it has not been published to npm yet: stop and tell me; do not install a different version.',
211
+ 'On a network failure you may retry once; if it still fails, tell me the exact error.',
212
+ ]
213
+ const zh = [
214
+ `请帮我在 WSL(发行版 ${status.distro})里装好 DSH WSL 运行模式需要的组件。不要改动 WSL 里已有的其他东西,也不要用 sudo。`,
215
+ '',
216
+ `在 PowerShell 里用 \`wsl.exe -d ${status.distro} -- bash -lc '<命令>'\` 依次执行下面的命令(# 开头的是说明,不用执行):`,
217
+ '',
218
+ '```bash', commands, '```',
219
+ '',
220
+ `完成后执行 \`wsl.exe -d ${status.distro} -- bash -lc '${check}'\`,确认版本是 ${desktopVersion}。`,
221
+ '如果 npm 提示找不到这个版本,说明它还没有发布到 npm,停下来告诉我,不要改装别的版本。',
222
+ '网络失败时可以重试一次;仍然失败就把错误原文告诉我。',
223
+ ]
224
+ return inLanguage(lang, { zh, en }).join('\n')
225
+ }
226
+
227
+ /** Content stamp of the bridge files this plugin carries (transport/ and agent/). */
228
+ export function stampOf(dirs) {
229
+ const hash = createHash('sha256')
230
+ for (const dir of dirs) for (const name of readdirSync(dir).sort()) {
231
+ const path = join(dir, name)
232
+ if (statSync(path).isFile()) hash.update(name).update(readFileSync(path))
233
+ }
234
+ return hash.digest('hex').slice(0, 16)
235
+ }
236
+
237
+ /**
238
+ * Copy transport/ and agent/ into the distribution's ~/.dsh-wsl/bridge when they differ from what is there.
239
+ * `root` is how this Host reaches the distribution's home (\\wsl.localhost\<distro>\home\<user> on Windows).
240
+ */
241
+ export function installBridgeFiles(pluginDir, root) {
242
+ const sources = { transport: join(pluginDir, 'transport'), agent: join(pluginDir, 'agent') }
243
+ const stamp = stampOf(Object.values(sources))
244
+ const target = join(root, '.dsh-wsl', 'bridge')
245
+ const stampFile = join(target, 'STAMP')
246
+ if (existsSync(stampFile) && readFileSync(stampFile, 'utf8').trim() === stamp) return { stamp, copied: false }
247
+ mkdirSync(target, { recursive: true })
248
+ for (const [name, from] of Object.entries(sources)) {
249
+ rmSync(join(target, name), { recursive: true, force: true })
250
+ cpSync(from, join(target, name), { recursive: true, dereference: true, filter: (src) => !/[\\/]node_modules([\\/]|$)/.test(src) })
251
+ }
252
+ writeFileSync(stampFile, `${stamp}\n`)
253
+ return { stamp, copied: true }
254
+ }
255
+
256
+ /** How this Host reaches a path in the distribution. */
257
+ export const hostPathOf = (platform, distro, path) => (platform === 'win32' ? win32.join(`\\\\wsl.localhost\\${distro}`, ...path.split('/').filter(Boolean)) : path)
258
+
259
+ /**
260
+ * Check the WSL side. `wsl` is the wsl.exe command (a fake in tests). Returns evaluate()'s result plus the commands,
261
+ * the prompt, and the bridge command when ready.
262
+ */
263
+ export async function check({ wsl = 'wsl.exe', desktopVersion, chosen = {}, pluginDir, platform = process.platform }) {
264
+ const list = await run(wsl, ['--list', '--verbose'], { timeout: 30_000 })
265
+ const listing = list.code === null ? { error: list.error ?? 'wsl.exe not found' }
266
+ : { distros: list.code === 0 ? parseDistros(decodeWslText(list.stdout)) : [] }
267
+ let probe
268
+ const pick = listing.distros?.find((d) => d.name === chosen.distro) ?? listing.distros?.find((d) => d.isDefault) ?? listing.distros?.[0]
269
+ if (pick && pick.version !== 1) {
270
+ const r = await run(wsl, ['-d', pick.name, '--exec', 'sh', '-c', PROBE], { timeout: 90_000 })
271
+ if (r.code === 0) probe = parseProbe(decodeWslText(r.stdout))
272
+ else listing.probeError = (decodeWslText(r.stdout) + r.stderr).trim().slice(0, 500)
273
+ }
274
+ const status = evaluate({ desktopVersion, listing, chosen, probe })
275
+ if (listing.probeError) status.probeError = listing.probeError
276
+ status.script = installScript(status, desktopVersion)
277
+ if (status.state === 'ready' && pluginDir) {
278
+ const bridgeDir = posix.join(status.home, '.dsh-wsl', 'bridge')
279
+ status.files = installBridgeFiles(pluginDir, hostPathOf(platform, status.distro, status.home))
280
+ status.bridge = {
281
+ command: wsl,
282
+ args: ['-d', status.distro, '--exec', status.node.path, posix.join(bridgeDir, 'transport', 'daemon.mjs'), '--bridge', '--profile', PROFILE, '--dsh', status.dshEntry, '--stamp', status.files.stamp, ...(status.dshHome ? ['--dsh-home', status.dshHome] : [])],
283
+ }
284
+ }
285
+ return status
286
+ }
@@ -0,0 +1,102 @@
1
+ // Desktop side of the bridge: starts `wsl.exe ... daemon.mjs --bridge` (or any command speaking the same protocol),
2
+ // multiplexes HTTP connections to the WSL DSH Host over its stdio, and keeps the Host's address and launch token in
3
+ // memory only. Reconnects with backoff when the bridge process ends.
4
+ import { spawn } from 'node:child_process'
5
+ import { EventEmitter } from 'node:events'
6
+ import http from 'node:http'
7
+ import { Mux } from './mux.mjs'
8
+
9
+ /**
10
+ * @param options.command - executable, e.g. 'wsl.exe' (or process.execPath in tests)
11
+ * @param options.args - its arguments, ending in '--bridge'
12
+ * @param options.resolve - instead of command/args: async () => {command, args} or null when the WSL side is not set
13
+ * up; asked before every (re)connect. While it answers null the bridge waits for retry().
14
+ * @param options.caps - what this side serves for the WSL side, e.g. ['credentials'] (announced after hello)
15
+ * @returns a Bridge: events 'state' ({ connected, dsh, hello }); request(); agent; close()
16
+ */
17
+ export function startBridge({ command, args, resolve, caps = [], log = () => {} }) {
18
+ const bridge = new EventEmitter()
19
+ let mux, child, stopped = false, delay = 500, timer, starting = false
20
+ bridge.state = { connected: false, dsh: { state: 'unknown' }, hello: null }
21
+ const set = (patch) => { bridge.state = { ...bridge.state, ...patch }; bridge.emit('state', bridge.state) }
22
+
23
+ const run = async () => {
24
+ timer = undefined
25
+ if (stopped || starting || child) return
26
+ starting = true
27
+ let target = { command, args }
28
+ try { if (resolve) target = await resolve() } catch (error) { log(`bridge: ${error.message}`); target = null }
29
+ starting = false
30
+ if (stopped) return
31
+ if (!target) { set({ connected: false, dsh: { state: 'unknown' } }); return }
32
+ child = spawn(target.command, target.args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
33
+ child.stderr.on('data', (c) => { const text = c.toString('utf8').trimEnd(); log(`bridge stderr: ${text}`); if (text) set({ lastError: text.split('\n').slice(-1)[0] }) })
34
+ mux = new Mux(child.stdout, child.stdin, { side: 'opener' })
35
+ // Streams the WSL side opens (e.g. {kind:'credentials'}) go to the 'stream' listeners; none → refused.
36
+ mux.on('stream', (stream, target) => { if (bridge.listenerCount('stream') > 0) bridge.emit('stream', stream, target); else stream.destroy(new Error('not served')) })
37
+ mux.on('control', (m) => {
38
+ if (m.type === 'hello') { delay = 500; mux.control({ type: 'client', caps }); set({ connected: true, hello: m, lastError: undefined }) }
39
+ else if (m.type === 'dsh') set({ dsh: { state: m.state, origin: m.origin, token: m.token, exit: m.exit } })
40
+ })
41
+ const down = (why) => {
42
+ if (!bridge.state.connected && why === undefined) return
43
+ set({ connected: false, dsh: { state: 'unknown' } })
44
+ if (stopped) return
45
+ log(`bridge down${why ? `: ${why}` : ''}; retry in ${delay} ms`)
46
+ timer = setTimeout(run, delay); timer.unref?.()
47
+ delay = Math.min(delay * 2, 15_000)
48
+ }
49
+ const own = child
50
+ child.once('exit', (code) => { mux.close(); if (child === own) child = undefined; down(`exit ${code}`) })
51
+ child.once('error', (e) => down(e.message))
52
+ }
53
+ run()
54
+ /** Connect now if not connected (after the person set up the WSL side). */
55
+ bridge.retry = () => {
56
+ if (stopped || child || starting) return
57
+ clearTimeout(timer); delay = 500; void run()
58
+ }
59
+ /** Drop the connection and connect again (another distribution, a re-check). */
60
+ bridge.restart = () => {
61
+ if (stopped) return
62
+ clearTimeout(timer); delay = 500
63
+ if (child) child.kill() // its exit reconnects after `delay`
64
+ else void run()
65
+ }
66
+
67
+ // HTTP over the bridge: every connection is one multiplexed stream to the DSH Host.
68
+ bridge.agent = new (class extends http.Agent {
69
+ createConnection(_options, callback) {
70
+ if (!mux || mux.closed || bridge.state.dsh.state !== 'running') {
71
+ const error = new Error(`WSL DSH Host not available (${bridge.state.connected ? bridge.state.dsh.state : 'bridge disconnected'})`)
72
+ if (callback) { process.nextTick(callback, error); return undefined }
73
+ throw error
74
+ }
75
+ const stream = mux.open({ kind: 'dsh' })
76
+ stream.setTimeout = () => stream; stream.setNoDelay = () => stream; stream.setKeepAlive = () => stream; stream.ref = () => stream; stream.unref = () => stream
77
+ if (callback) process.nextTick(callback, null, stream)
78
+ return stream
79
+ }
80
+ })({ keepAlive: false })
81
+
82
+ /** Resolve once the WSL DSH Host is running (or reject after timeoutMs). */
83
+ bridge.ready = (timeoutMs = 60_000) => new Promise((resolve, reject) => {
84
+ if (bridge.state.dsh.state === 'running') return resolve(bridge.state)
85
+ const timer = setTimeout(() => { bridge.off('state', on); reject(new Error('WSL DSH Host did not start in time')) }, timeoutMs)
86
+ const on = (s) => { if (s.dsh.state === 'running') { clearTimeout(timer); bridge.off('state', on); resolve(s) } }
87
+ bridge.on('state', on)
88
+ })
89
+
90
+ /** A Node http request to the WSL DSH Host; the Host header is its own authority, as its trust fence expects. */
91
+ bridge.request = (path, { method = 'GET', headers = {}, body } = {}) => new Promise((resolve, reject) => {
92
+ const { origin } = bridge.state.dsh
93
+ if (!origin) return reject(new Error('WSL DSH Host not available'))
94
+ const url = new URL(path, origin)
95
+ const req = http.request({ agent: bridge.agent, host: url.hostname, port: url.port, path: url.pathname + url.search, method, headers: { host: url.host, ...headers } }, resolve)
96
+ req.once('error', reject)
97
+ if (body !== undefined) req.end(body); else req.end()
98
+ })
99
+
100
+ bridge.close = () => { stopped = true; child?.kill(); mux?.close() }
101
+ return bridge
102
+ }