dsh-wsl-desktop 0.2.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.
@@ -0,0 +1,458 @@
1
+ /**
2
+ * The WSL execution world: distribution discovery, command execution and
3
+ * directory facts, all reached through `wsl.exe` from the Windows host.
4
+ *
5
+ * A distribution runs no agent and holds no installed helper, so every fact
6
+ * crosses the `wsl.exe` boundary. Commands run as
7
+ * `wsl.exe -d <distro> [-u <user>] --cd <linux> -e bash -lc "cd '<linux>' && <cmd>"`;
8
+ * the explicit `cd` survives profile scripts that reset the working directory.
9
+ * @module dsh-wsl-desktop/wsl/world
10
+ */
11
+
12
+ import { execFile, execFileSync, spawn } from 'node:child_process'
13
+ import { promisify } from 'node:util'
14
+ import { DISTRO_NAME, isWindowsPathShaped, LINUX_USER, mntToWindowsPath, parseWslUnc, shellQuote, windowsToMntPath } from './paths.js'
15
+
16
+ const execFileAsync = promisify(execFile)
17
+
18
+ /**
19
+ * Refuse option values that cannot be safe single argv tokens for wsl.exe
20
+ * (-d/-u), independent of wsl.exe's external tokenization rules.
21
+ * @param {string} distro - the distribution name about to be spawned with.
22
+ */
23
+ function assertDistroName(distro) {
24
+ if (typeof distro !== 'string' || !DISTRO_NAME.test(distro)) {
25
+ throw new Error(`wsl: 非法的发行版名 ${JSON.stringify(distro ?? null)}`)
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Refuse usernames that cannot be safe single argv tokens for wsl.exe -u.
31
+ * @param {string | undefined} username - the Linux user about to be spawned with.
32
+ */
33
+ function assertUserName(username) {
34
+ if (username !== undefined && username !== '' && !LINUX_USER.test(username)) {
35
+ throw new Error(`wsl: 非法的用户名 ${JSON.stringify(username)}`)
36
+ }
37
+ }
38
+
39
+ /** Short ceiling for the registry and `wsl.exe` discovery calls. */
40
+ const DISCOVERY_TIMEOUT_MS = 10_000
41
+
42
+ /** Per-stream capture ceiling; a runaway command must not buffer without bound. */
43
+ const MAX_STREAM_BYTES = 1024 * 1024
44
+
45
+ /** Windows registry key holding the WSL distribution registrations. */
46
+ const LXSS_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss'
47
+
48
+ /** Environment overrides that keep command output model-readable. */
49
+ const ENV_OVERRIDES = { NO_COLOR: '1', TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat' }
50
+
51
+ /**
52
+ * Decode `wsl.exe` output. Most builds emit UTF-16LE with interleaved NUL
53
+ * bytes while newer ones emit UTF-8, so the NUL probe picks the decoder.
54
+ * @param {Buffer} buffer - raw captured stdout.
55
+ * @returns {string} the decoded text.
56
+ */
57
+ export function decodeWslOutput(buffer) {
58
+ return buffer.includes(0) ? buffer.toString('utf16le') : buffer.toString('utf8')
59
+ }
60
+
61
+ /**
62
+ * List the installed WSL distributions.
63
+ * @returns {Promise<string[]>} distribution names in `wsl.exe` order.
64
+ */
65
+ export async function listDistros() {
66
+ let stdout
67
+ try {
68
+ ({ stdout } = await execFileAsync('wsl.exe', ['-l', '-q'], {
69
+ encoding: 'buffer',
70
+ timeout: DISCOVERY_TIMEOUT_MS,
71
+ }))
72
+ } catch (error) {
73
+ throw new Error(`无法列出 WSL 发行版:${error instanceof Error ? error.message : String(error)}`)
74
+ }
75
+ return decodeWslOutput(stdout)
76
+ .split(/\r?\n/)
77
+ .map((line) => line.replace(/\0/g, '').trim())
78
+ .filter((line) => line.length > 0)
79
+ }
80
+
81
+ /**
82
+ * Read the user's default distribution from the Lxss registry. Non-fatal:
83
+ * `undefined` when the value is absent or unreadable.
84
+ * @returns {Promise<string | undefined>} the default distribution name.
85
+ */
86
+ export async function defaultDistro() {
87
+ try {
88
+ const value = await execFileAsync('reg.exe', ['query', LXSS_KEY, '/v', 'DefaultDistribution'], {
89
+ timeout: DISCOVERY_TIMEOUT_MS,
90
+ })
91
+ const guid = /DefaultDistribution\s+REG_SZ\s+(\{[0-9a-fA-F-]+\})/i.exec(String(value.stdout))?.[1]
92
+ if (guid === undefined) return undefined
93
+ const name = await execFileAsync('reg.exe', ['query', `${LXSS_KEY}\\${guid}`, '/v', 'DistributionName'], {
94
+ timeout: DISCOVERY_TIMEOUT_MS,
95
+ })
96
+ const distro = /DistributionName\s+REG_SZ\s+(.+)/i.exec(String(name.stdout))?.[1]?.trim()
97
+ return distro === undefined || distro === '' ? undefined : distro
98
+ } catch {
99
+ // A missing or unreadable registry value leaves the caller its own fallback.
100
+ return undefined
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Synchronous default-distribution read for plan steps that cannot await.
106
+ *
107
+ * Cached after the first read: a shell plan is built synchronously, and the
108
+ * registry does not change while the process runs.
109
+ * @returns {string | undefined} the default distribution name.
110
+ */
111
+ let syncDefaultResolved = false
112
+ let syncDefault
113
+ export function defaultDistroSync() {
114
+ if (syncDefaultResolved) return syncDefault
115
+ syncDefaultResolved = true
116
+ try {
117
+ const value = execFileSync('reg.exe', ['query', LXSS_KEY, '/v', 'DefaultDistribution'], {
118
+ timeout: DISCOVERY_TIMEOUT_MS,
119
+ })
120
+ const guid = /DefaultDistribution\s+REG_SZ\s+(\{[0-9a-fA-F-]+\})/i.exec(String(value))?.[1]
121
+ if (guid === undefined) return undefined
122
+ const name = execFileSync('reg.exe', ['query', `${LXSS_KEY}\\${guid}`, '/v', 'DistributionName'], {
123
+ timeout: DISCOVERY_TIMEOUT_MS,
124
+ })
125
+ const distro = /DistributionName\s+REG_SZ\s+(.+)/i.exec(String(name))?.[1]?.trim()
126
+ syncDefault = distro === undefined || distro === '' ? undefined : distro
127
+ } catch {
128
+ // An unreadable registry leaves the caller its own fail-loud path.
129
+ syncDefault = undefined
130
+ }
131
+ return syncDefault
132
+ }
133
+
134
+ /**
135
+ * Add `WSLENV` flags to an explicit environment map.
136
+ *
137
+ * The local subprocess provider already merged its own ambient environment, so
138
+ * this only declares which of the caller's variables cross into the
139
+ * distribution and which of them must be translated back to Linux paths.
140
+ * @param {Record<string, string | undefined>} env - the environment to bridge.
141
+ * @returns {Record<string, string | undefined>} the same map with `WSLENV` set.
142
+ */
143
+ export function withWslEnvFlags(env) {
144
+ const flags = []
145
+ for (const [key, value] of Object.entries(env)) {
146
+ if (key.toUpperCase() === 'WSLENV' || typeof value !== 'string') continue
147
+ // Reject keys containing WSLENV metacharacters (':' splits entries,
148
+ // '/' marks path translation) so a hostile key cannot craft flags.
149
+ if (key.includes(':') || key.includes('/')) continue
150
+ flags.push(isWindowsPathShaped(value) ? `${key}/p` : key)
151
+ }
152
+ const ambient = process.env.WSLENV
153
+ const merged = [ambient, flags.join(':')].filter((part) => part !== undefined && part !== '').join(':')
154
+ return merged === '' ? { ...env } : { ...env, WSLENV: merged }
155
+ }
156
+
157
+ /**
158
+ * Locate a Windows system executable from inside a distribution.
159
+ *
160
+ * Interop is usually enabled, but a distribution with `appendWindowsPath =
161
+ * false` (the default in several images) never puts `cmd.exe` on `PATH`, so a
162
+ * host command has to be invoked by its absolute `/mnt/<drive>` path.
163
+ * @param {string} name - bare executable name, e.g. `cmd.exe`.
164
+ * @returns {string | null} the Linux path, or null when the system drive is unknown.
165
+ */
166
+ export function hostExecutable(name) {
167
+ const mount = windowsToMntPath(process.env.SystemRoot ?? 'C:\\Windows')
168
+ return mount === null ? null : `${mount}/System32/${name}`
169
+ }
170
+
171
+ /**
172
+ * Build the child environment, bridging every extra variable into the
173
+ * distribution through `WSLENV` (with `/p` for values that must be translated
174
+ * back to Linux paths).
175
+ * @param {Record<string, string> | undefined} extra - variables to bridge.
176
+ * @returns {NodeJS.ProcessEnv} the environment for the `wsl.exe` process.
177
+ */
178
+ function bridgeEnv(extra) {
179
+ const env = { ...process.env, ...ENV_OVERRIDES, ...extra }
180
+ const flags = []
181
+ for (const [key, value] of Object.entries(extra ?? {})) {
182
+ if (key.toUpperCase() === 'WSLENV') continue
183
+ flags.push(isWindowsPathShaped(value) ? `${key}/p` : key)
184
+ }
185
+ const ambient = process.env.WSLENV
186
+ const merged = [ambient, flags.join(':')].filter((part) => part !== undefined && part !== '').join(':')
187
+ if (merged !== '') env.WSLENV = merged
188
+ return env
189
+ }
190
+
191
+ /**
192
+ * Turn one working directory into a distribution plus Linux directory.
193
+ *
194
+ * Three spellings reach here: the UNC workspace path the session carries, an
195
+ * absolute Linux path a consumer supplied, and a Windows drive path. All three
196
+ * normalize to the same pair, and an unrepresentable directory fails loud
197
+ * rather than running the work in the wrong world.
198
+ * @param {string} workdir - the resolved working directory.
199
+ * @param {string | undefined} fallbackDistro - distribution for non-UNC spellings.
200
+ * @returns {{ distro: string, linuxCwd: string, windowsCwd: string }} the execution plan.
201
+ * @throws Error when the directory belongs to neither world.
202
+ */
203
+ export function planWsl(workdir, fallbackDistro) {
204
+ const windowsCwd = process.env.SystemRoot ?? process.cwd()
205
+ const unc = parseWslUnc(workdir)
206
+ if (unc !== null) return { distro: unc.distro, linuxCwd: unc.linuxPath, windowsCwd }
207
+ if (workdir.startsWith('/')) {
208
+ const distro = fallbackDistro ?? defaultDistroSync()
209
+ if (distro === undefined) {
210
+ throw new Error(`wsl: 无法确定 "${workdir}" 所属的发行版,请配置 distro`)
211
+ }
212
+ // A drive mount is also addressable from the Windows side; keep that as the
213
+ // spawn directory so a failure still names a real path.
214
+ return { distro, linuxCwd: workdir, windowsCwd: mntToWindowsPath(workdir) ?? windowsCwd }
215
+ }
216
+ const mnt = windowsToMntPath(workdir)
217
+ if (mnt === null) {
218
+ throw new Error(`wsl: 工作目录 "${workdir}" 不在 WSL 执行世界里`)
219
+ }
220
+ const distro = fallbackDistro ?? defaultDistroSync()
221
+ if (distro === undefined) {
222
+ throw new Error(`wsl: 无法确定 "${workdir}" 所属的发行版,请配置 distro`)
223
+ }
224
+ return { distro, linuxCwd: mnt, windowsCwd }
225
+ }
226
+
227
+ /**
228
+ * Build the `wsl.exe` argv that runs one program inside a distribution.
229
+ *
230
+ * `-e` executes the argv directly rather than through a shell, so a bare name
231
+ * is resolved against the distribution's own `PATH` and every element keeps its
232
+ * identity instead of being re-parsed.
233
+ * @param {{ distro: string, linuxCwd: string, windowsCwd: string }} plan - the execution plan.
234
+ * @param {readonly string[]} argv - the program and its arguments, in Linux terms.
235
+ * @param {{ wslPath?: string, username?: string }} [options] - distribution and user selection.
236
+ * @returns {string[]} the argv handed to the host's subprocess provider.
237
+ */
238
+ export function buildWslExecArgv(plan, argv, options = {}) {
239
+ if (!Array.isArray(argv) || argv.length === 0) {
240
+ throw new Error('wsl-subprocess: 需要一个非空的 argv')
241
+ }
242
+ // Option values are validated against the repo's own grammars so exec-path
243
+ // safety never depends on wsl.exe's external (undocumented) tokenization.
244
+ assertDistroName(plan.distro)
245
+ assertUserName(options.username)
246
+ return [
247
+ options.wslPath ?? 'wsl.exe',
248
+ '-d', plan.distro,
249
+ ...(options.username !== undefined && options.username !== '' ? ['-u', options.username] : []),
250
+ '--cd', plan.linuxCwd,
251
+ '-e',
252
+ ...argv,
253
+ ]
254
+ }
255
+
256
+ /**
257
+ * Run one command inside a distribution.
258
+ *
259
+ * The `wsl.exe` process itself is spawned from a Windows directory because a
260
+ * UNC or Linux working directory is not a valid Win32 process cwd; the Linux
261
+ * directory is selected with `--cd` and re-asserted inside the login shell.
262
+ * @param {object} options - execution request.
263
+ * @param {string} options.distro - distribution to run in.
264
+ * @param {string} options.linuxCwd - absolute Linux working directory.
265
+ * @param {string} options.command - shell source to run.
266
+ * @param {string} [options.username] - Linux user; omitted uses the distribution default.
267
+ * @param {boolean} [options.loginShell] - run `bash -lc` (default) instead of `bash -c`.
268
+ * @param {Record<string, string>} [options.env] - extra variables bridged through `WSLENV`.
269
+ * @param {number} [options.timeoutMs] - foreground timeout; 0 disables it.
270
+ * @param {AbortSignal} [options.signal] - caller cancellation.
271
+ * @returns {Promise<{ argv: string[], exitCode: number | null, stdout: string, stderr: string, timedOut: boolean }>} the settled outcome.
272
+ */
273
+ export function runWslShell({ distro, linuxCwd, command, username, loginShell = true, env, timeoutMs = 120_000, signal }) {
274
+ // Option values are validated against the repo's own grammars so exec-path
275
+ // safety never depends on wsl.exe's external (undocumented) tokenization:
276
+ // a separator-bearing distro/username is refused before any process starts.
277
+ assertDistroName(distro)
278
+ assertUserName(username)
279
+ const script = loginShell ? `cd ${shellQuote(linuxCwd)} && ${command}` : command
280
+ const argv = [
281
+ '-d', distro,
282
+ ...(username !== undefined && username !== '' ? ['-u', username] : []),
283
+ '--cd', linuxCwd,
284
+ '-e', 'bash', loginShell ? '-lc' : '-c', script,
285
+ ]
286
+ return new Promise((resolve, reject) => {
287
+ let child
288
+ try {
289
+ child = spawn('wsl.exe', argv, {
290
+ cwd: process.env.SystemRoot ?? process.cwd(),
291
+ env: bridgeEnv(env),
292
+ windowsHide: true,
293
+ stdio: ['ignore', 'pipe', 'pipe'],
294
+ })
295
+ } catch (error) {
296
+ reject(new Error(`wsl.exe 启动失败:${error instanceof Error ? error.message : String(error)}`))
297
+ return
298
+ }
299
+ const out = { stdout: '', stderr: '', timedOut: false }
300
+ const collect = (stream, key) => {
301
+ stream.setEncoding('utf8')
302
+ stream.on('data', (chunk) => {
303
+ if (out[key].length >= MAX_STREAM_BYTES) return
304
+ out[key] += chunk.slice(0, MAX_STREAM_BYTES - out[key].length)
305
+ })
306
+ }
307
+ collect(child.stdout, 'stdout')
308
+ collect(child.stderr, 'stderr')
309
+
310
+ let timer
311
+ const onAbort = () => {
312
+ out.timedOut = false
313
+ child.kill()
314
+ }
315
+ if (timeoutMs > 0) {
316
+ timer = setTimeout(() => {
317
+ out.timedOut = true
318
+ child.kill()
319
+ }, timeoutMs)
320
+ }
321
+ if (signal !== undefined) {
322
+ if (signal.aborted) {
323
+ child.kill()
324
+ } else {
325
+ signal.addEventListener('abort', onAbort, { once: true })
326
+ }
327
+ }
328
+ const settle = (exitCode, failure) => {
329
+ if (timer !== undefined) clearTimeout(timer)
330
+ signal?.removeEventListener('abort', onAbort)
331
+ if (failure !== undefined) {
332
+ reject(failure)
333
+ return
334
+ }
335
+ resolve({ argv: ['wsl.exe', ...argv], exitCode, stdout: out.stdout, stderr: out.stderr, timedOut: out.timedOut })
336
+ }
337
+ child.on('error', (error) => {
338
+ settle(null, new Error(`wsl.exe 执行失败:${error.message}`))
339
+ })
340
+ child.on('close', (code) => {
341
+ settle(code, undefined)
342
+ })
343
+ })
344
+ }
345
+
346
+ /**
347
+ * List one directory inside a distribution.
348
+ * @param {string} distro - distribution name.
349
+ * @param {string} linuxPath - absolute Linux directory.
350
+ * @returns {Promise<{ path: string, parent: string | null, entries: Array<{ name: string, kind: 'directory' | 'file' }> }>} the listing.
351
+ */
352
+ export async function listLinuxDir(distro, linuxPath) {
353
+ const path = linuxPath.startsWith('/') ? linuxPath : `/${linuxPath}`
354
+ // Entries are NUL-terminated so a filename containing a newline survives the
355
+ // round trip; `..?*` covers names like `..keep` that `.[!.]*` misses.
356
+ const script = `cd ${shellQuote(path)} && for entry in * .[!.]* ..?*; do [ -e "$entry" ] || continue; `
357
+ + `if [ -d "$entry" ]; then printf 'd\\t%s\\0' "$entry"; else printf 'f\\t%s\\0' "$entry"; fi; done`
358
+ const result = await runWslShell({ distro, linuxCwd: '/', command: script, loginShell: false, timeoutMs: 30_000 })
359
+ if (result.exitCode !== 0) {
360
+ throw new Error(`无法列出 ${path}:${result.stderr.trim() || `退出码 ${String(result.exitCode)}`}`)
361
+ }
362
+ const entries = result.stdout
363
+ .split('\0')
364
+ .map((line) => line.replace(/\r$/, ''))
365
+ .filter((line) => line.includes('\t'))
366
+ .map((line) => {
367
+ const [kind, ...rest] = line.split('\t')
368
+ return { name: rest.join('\t'), kind: kind === 'd' ? 'directory' : 'file' }
369
+ })
370
+ const trimmed = path.replace(/\/+$/, '')
371
+ const parent = trimmed === '' || trimmed === '/' ? null : trimmed.slice(0, trimmed.lastIndexOf('/')) || '/'
372
+ return { path, parent, entries }
373
+ }
374
+
375
+ /**
376
+ * Check whether one Linux path exists.
377
+ * @param {string} distro - distribution name.
378
+ * @param {string} linuxPath - absolute Linux path.
379
+ * @returns {Promise<{ exists: boolean, isDirectory: boolean }>} existence facts.
380
+ */
381
+ export async function checkLinuxPath(distro, linuxPath) {
382
+ const script = `if [ -d ${shellQuote(linuxPath)} ]; then echo dir; `
383
+ + `elif [ -e ${shellQuote(linuxPath)} ]; then echo other; else echo missing; fi`
384
+ const result = await runWslShell({ distro, linuxCwd: '/', command: script, loginShell: false, timeoutMs: 30_000 })
385
+ const answer = result.stdout.trim()
386
+ return { exists: answer === 'dir' || answer === 'other', isDirectory: answer === 'dir' }
387
+ }
388
+
389
+ /**
390
+ * Resolve a user's home directory inside a distribution.
391
+ *
392
+ * The workspace dialog prefills its path with the answer, so the picker opens
393
+ * in the operator's own files instead of the filesystem root. The home comes
394
+ * from the distribution's own user database (`getent`), so it answers for any
395
+ * user that exists there, not just the default one. Non-login shells on
396
+ * purpose: a login shell's rc could print anything, and this must return
397
+ * exactly one path.
398
+ * @param {string} distro - distribution name.
399
+ * @param {string} [username] - user to resolve; absent resolves the default user.
400
+ * @returns {Promise<{ user: string, home: string }>} the user and their home.
401
+ * @throws Error when the user or a usable home cannot be resolved.
402
+ */
403
+ export async function resolveDistroHome(distro, username) {
404
+ let user = typeof username === 'string' ? username.trim() : ''
405
+ if (user === '') {
406
+ // Non-login + trimmed on purpose: the parsed answer must be provably this
407
+ // probe's output ('zcluo'), never profile scripts'.
408
+ const who = await runWslShell({ distro, linuxCwd: '/', command: 'id -un', loginShell: false, timeoutMs: 30_000 })
409
+ user = who.stdout.trim()
410
+ if (user === '' || user.includes('\n')) {
411
+ throw new Error(`无法确定发行版 ${distro} 的默认用户:${who.stderr.trim() || `退出码 ${String(who.exitCode)}`}`)
412
+ }
413
+ }
414
+ // `getent` reads the distro's own NSS user database. The pipeline's exit
415
+ // code belongs to `cut` (always 0), so an unknown user is detected by the
416
+ // output, not by the exit code. Exactly one passwd line is the only
417
+ // unambiguous answer: a name getent misparses can dump the whole database,
418
+ // and a multi-line "home" would only fail later and confusingly at listDir.
419
+ const entry = await runWslShell({
420
+ distro,
421
+ linuxCwd: '/',
422
+ command: `getent passwd ${shellQuote(user)} | cut -d: -f6`,
423
+ loginShell: false,
424
+ timeoutMs: 30_000,
425
+ })
426
+ const lines = entry.stdout.trim().split('\n')
427
+ const home = lines.length === 1 ? lines[0].trim() : ''
428
+ if (home === '' || !home.startsWith('/')) {
429
+ throw new Error(`发行版 ${distro} 里没有用户 ${user},或该用户没有主目录`)
430
+ }
431
+ return { user, home }
432
+ }
433
+
434
+ /**
435
+ * The session user's login shell inside the distribution — the Linux
436
+ * equivalent of a deployment-configured Windows terminal shell. Read from the
437
+ * user database (`getent`, field 7), falling back to `/bin/bash`.
438
+ * @param {string} distro - distribution name.
439
+ * @param {string} [username] - Linux user; omitted uses the distribution default.
440
+ * @returns {Promise<string>} the login shell path.
441
+ */
442
+ export async function resolveLoginShell(distro, username) {
443
+ // The username must reach BOTH layers: `-u` runs the probe as that user, and
444
+ // the getent query names that user — `$(id -un)` alone would always answer
445
+ // for the distribution DEFAULT user even when the session is pinned to
446
+ // another one.
447
+ const who = typeof username === 'string' && username !== '' ? username : '"$(id -un)"'
448
+ const shell = await runWslShell({
449
+ distro,
450
+ linuxCwd: '/',
451
+ command: `getent passwd ${who} | cut -d: -f7`,
452
+ loginShell: false,
453
+ ...(typeof username === 'string' && username !== '' ? { username } : {}),
454
+ timeoutMs: 60_000,
455
+ })
456
+ const resolved = shell.stdout.trim().split('\n')[0]?.trim() ?? ''
457
+ return resolved.startsWith('/') ? resolved : '/bin/bash'
458
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "dsh-wsl-desktop",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "main": "lib/index.js",
6
+ "files": [
7
+ "lib",
8
+ "cordis.patch.yml",
9
+ "README.md",
10
+ "LICENSE"
11
+ ],
12
+ "dependencies": null,
13
+ "dsh": {
14
+ "bundle": {
15
+ "patch": "./cordis.patch.yml"
16
+ },
17
+ "client": {
18
+ "platform": "web",
19
+ "inject": [
20
+ "@deepseek-ai/dsh-api-workspace-controller",
21
+ "@deepseek-ai/dsh-client-ui-workspace"
22
+ ]
23
+ }
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/zcluo/dsh-wsl-desktop.git"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "manifestVersion": 1,
33
+ "license": "MIT",
34
+ "description": "DSH Desktop WSL execution world: add a WSL workspace and run every tool inside the distribution."
35
+ }