deepseek-coordination 0.1.1 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +271 -14
- package/README.zh-CN.md +160 -11
- package/client.js +338 -0
- package/cordis.patch.yml +40 -0
- package/package.json +22 -2
- package/src/host/bin/dsh-os.ps1 +10 -1
- package/src/host/bin/dsh-os7.ps1 +16 -1
- package/src/plugin/index.js +4 -213
- package/src/plugin/invoke.js +301 -0
- package/src/plugin/settings.js +340 -0
- package/src/plugin/takeover.js +432 -0
- package/src/plugin/workflow.js +1464 -0
- package/tools/measure-cache.mjs +223 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "deepseek-coordination",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Cross-platform atomic OS-interaction surface for the DeepSeek Harness: one argument-stable verb set implemented natively on each OS, with byte-exact charset, BOM and line-ending handling.",
|
|
@@ -37,6 +37,10 @@
|
|
|
37
37
|
"main": "src/plugin/index.js",
|
|
38
38
|
"exports": {
|
|
39
39
|
".": "./src/plugin/index.js",
|
|
40
|
+
"./takeover": "./src/plugin/takeover.js",
|
|
41
|
+
"./settings": "./src/plugin/settings.js",
|
|
42
|
+
"./workflow": "./src/plugin/workflow.js",
|
|
43
|
+
"./client": "./client.js",
|
|
40
44
|
"./permission": "./src/permission/index.js",
|
|
41
45
|
"./package.json": "./package.json"
|
|
42
46
|
},
|
|
@@ -50,6 +54,12 @@
|
|
|
50
54
|
"dsh": {
|
|
51
55
|
"bundle": {
|
|
52
56
|
"patch": "./cordis.patch.yml"
|
|
57
|
+
},
|
|
58
|
+
"client": {
|
|
59
|
+
"platform": "web",
|
|
60
|
+
"inject": [
|
|
61
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
62
|
+
]
|
|
53
63
|
}
|
|
54
64
|
},
|
|
55
65
|
"files": [
|
|
@@ -64,8 +74,14 @@
|
|
|
64
74
|
"src/host/docs/permission-model.md",
|
|
65
75
|
"src/host/docs/command-original.txt",
|
|
66
76
|
"cordis.patch.yml",
|
|
77
|
+
"client.js",
|
|
67
78
|
"src/plugin/index.js",
|
|
79
|
+
"src/plugin/invoke.js",
|
|
80
|
+
"src/plugin/settings.js",
|
|
81
|
+
"src/plugin/takeover.js",
|
|
82
|
+
"src/plugin/workflow.js",
|
|
68
83
|
"src/permission/index.js",
|
|
84
|
+
"tools/measure-cache.mjs",
|
|
69
85
|
"README.md"
|
|
70
86
|
],
|
|
71
87
|
"scripts": {
|
|
@@ -77,12 +93,16 @@
|
|
|
77
93
|
"test:encoding": "powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File tests/encoding.ps1",
|
|
78
94
|
"test:conformance": "powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File tests/conformance.ps1",
|
|
79
95
|
"test:plugin": "node tests/plugin.test.mjs",
|
|
96
|
+
"test:takeover": "node tests/takeover.test.mjs",
|
|
80
97
|
"test:permission": "node tests/permission.test.mjs",
|
|
98
|
+
"test:workflow": "node tests/workflow.test.mjs",
|
|
81
99
|
"test:bundle": "node tools/verify-bundle.mjs",
|
|
82
100
|
"test:package": "node tools/verify-package.mjs",
|
|
83
101
|
"test:desktop": "bash tests/smoke-desktop-sh.sh",
|
|
84
102
|
"test:load": "node tools/verify-plugin-load.mjs && node tools/verify-editions.mjs",
|
|
85
|
-
"test:
|
|
103
|
+
"test:pipeline": "node tools/probe-pipeline.mjs",
|
|
104
|
+
"measure:cache": "node tools/measure-cache.mjs",
|
|
105
|
+
"test:all": "npm run test:conformance && npm run test:encoding && npm run test && npm run test:plugin && npm run test:takeover && npm run test:permission && npm run test:workflow && npm run test:bundle && npm run test:package && npm run test:load && npm run test:desktop",
|
|
86
106
|
"selfcheck": "powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File src/host/bin/dsh-os.ps1 selfcheck"
|
|
87
107
|
},
|
|
88
108
|
"devDependencies": {
|
package/src/host/bin/dsh-os.ps1
CHANGED
|
@@ -3159,7 +3159,16 @@ function Invoke-PermissionList {
|
|
|
3159
3159
|
return [ordered]@{
|
|
3160
3160
|
path = $state.path
|
|
3161
3161
|
exists = $state.exists
|
|
3162
|
-
|
|
3162
|
+
# Read through PSObject.Properties, NOT as $state.empty.
|
|
3163
|
+
#
|
|
3164
|
+
# Under Set-StrictMode -Version Latest, READING a property that does not exist THROWS, so
|
|
3165
|
+
# if ($null -ne $state.empty) { ... } # the test IS the throwing read
|
|
3166
|
+
# is not a guard. A caller whose permission.json does not exist got
|
|
3167
|
+
# E_INTERNAL The property 'empty' cannot be found on this object
|
|
3168
|
+
# because one path returns a state without an `empty` key. PSObject.Properties is a dictionary
|
|
3169
|
+
# lookup, so a missing name is simply absent and does not throw. Same fix as the 7.6 module, and
|
|
3170
|
+
# the same idiom already used below for $state.document.PSObject.Properties['rules'].
|
|
3171
|
+
empty = $(if ($null -ne $state.PSObject.Properties['empty']) { [bool]$state.empty } else { $false })
|
|
3163
3172
|
trusted = $state.trusted
|
|
3164
3173
|
count = $items.Count
|
|
3165
3174
|
rules = $items.ToArray()
|
package/src/host/bin/dsh-os7.ps1
CHANGED
|
@@ -3512,7 +3512,22 @@ function Invoke-PermissionList {
|
|
|
3512
3512
|
return [ordered]@{
|
|
3513
3513
|
path = $state.path
|
|
3514
3514
|
exists = $state.exists
|
|
3515
|
-
|
|
3515
|
+
# Read through PSObject.Properties, NOT as $state.empty.
|
|
3516
|
+
#
|
|
3517
|
+
# Under Set-StrictMode -Version Latest, READING a property that does not exist THROWS. So the obvious
|
|
3518
|
+
# guard is not a guard at all:
|
|
3519
|
+
# empty = ($state.empty ?? $false) # throws before ?? can help
|
|
3520
|
+
# and neither is testing it first, because the test itself is the throwing read. That is exactly how
|
|
3521
|
+
# this shipped broken: `permission list` returned
|
|
3522
|
+
# E_INTERNAL The property 'empty' cannot be found on this object
|
|
3523
|
+
# for every caller whose permission.json did not exist - which is every fresh install, because the
|
|
3524
|
+
# package ships no permission.json. It worked in the repository, where the file is present, so no
|
|
3525
|
+
# test here could see it.
|
|
3526
|
+
#
|
|
3527
|
+
# PSObject.Properties is a dictionary lookup rather than a property read, so a missing name is simply
|
|
3528
|
+
# absent and returns $false without throwing. This is the same idiom already used above for
|
|
3529
|
+
# `$doc.PSObject.Properties['updatedBy']`.
|
|
3530
|
+
empty = $(if ($null -ne $state.PSObject.Properties['empty']) { [bool]$state.empty } else { $false })
|
|
3516
3531
|
trusted = $state.trusted
|
|
3517
3532
|
count = $items.Count
|
|
3518
3533
|
rules = $items.ToArray()
|
package/src/plugin/index.js
CHANGED
|
@@ -18,224 +18,15 @@
|
|
|
18
18
|
* core reason this surface exists.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
import {
|
|
24
|
-
import { fileURLToPath } from 'node:url'
|
|
21
|
+
import { join } from 'node:path'
|
|
22
|
+
|
|
23
|
+
import { resolveInvocation, runScript, searchedDirectory } from './invoke.js'
|
|
25
24
|
|
|
26
25
|
export const name = 'dsh-os'
|
|
27
26
|
|
|
28
27
|
/** The plugin is inert without the tool registry. */
|
|
29
28
|
export const inject = ['tools']
|
|
30
29
|
|
|
31
|
-
/**
|
|
32
|
-
* Whether a command can be started at all.
|
|
33
|
-
*
|
|
34
|
-
* Used only by the 'auto' edition choice, to decide between the two PowerShell artefacts. It asks the
|
|
35
|
-
* command itself rather than searching PATH by hand, so a shim, an alias-resolved path and a
|
|
36
|
-
* .exe-extension lookup on Windows all behave the way an actual spawn would. A failure to start is the
|
|
37
|
-
* answer being sought, so any error means "not available" and never propagates.
|
|
38
|
-
*/
|
|
39
|
-
function executableAvailable(command) {
|
|
40
|
-
try {
|
|
41
|
-
const probe = spawnSync(command, ['-NoLogo', '-NoProfile', '-Command', 'exit 0'], {
|
|
42
|
-
stdio: 'ignore',
|
|
43
|
-
timeout: 10000,
|
|
44
|
-
windowsHide: true,
|
|
45
|
-
})
|
|
46
|
-
return probe.error === undefined && probe.status === 0
|
|
47
|
-
} catch {
|
|
48
|
-
return false
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Find a PowerShell 7 executable.
|
|
54
|
-
*
|
|
55
|
-
* 'pwsh' alone is not enough. Installing PowerShell 7 does NOT put it on PATH for every process, and
|
|
56
|
-
* in this very repository's own environment it is at the standard install location while `spawn pwsh`
|
|
57
|
-
* fails with ENOENT. Requiring a PATH entry would therefore make the 7.6 artefact unreachable on a
|
|
58
|
-
* machine where it is plainly installed, which is exactly the bug this selection was added to fix.
|
|
59
|
-
*
|
|
60
|
-
* So the search is: an explicit DSH_OS_POWERSHELL, then PATH, then the two standard install locations.
|
|
61
|
-
* The first entry that starts wins, and null means 7.x is genuinely not available here.
|
|
62
|
-
*/
|
|
63
|
-
function findPowerShell7() {
|
|
64
|
-
const explicit = process.env.DSH_OS_POWERSHELL
|
|
65
|
-
if (explicit) return executableAvailable(explicit) ? explicit : null
|
|
66
|
-
|
|
67
|
-
const candidates = ['pwsh']
|
|
68
|
-
if (process.platform === 'win32') {
|
|
69
|
-
const programFiles = process.env.ProgramFiles ?? 'C:\\Program Files'
|
|
70
|
-
const programFilesX86 = process.env['ProgramFiles(x86)']
|
|
71
|
-
const localAppData = process.env.LOCALAPPDATA
|
|
72
|
-
candidates.push(join(programFiles, 'PowerShell', '7', 'pwsh.exe'))
|
|
73
|
-
if (programFilesX86) candidates.push(join(programFilesX86, 'PowerShell', '7', 'pwsh.exe'))
|
|
74
|
-
if (localAppData) candidates.push(join(localAppData, 'Microsoft', 'PowerShell', '7', 'pwsh.exe'))
|
|
75
|
-
} else {
|
|
76
|
-
candidates.push('/usr/bin/pwsh', '/usr/local/bin/pwsh', '/opt/microsoft/powershell/7/pwsh')
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
for (const candidate of candidates) {
|
|
80
|
-
// A bare name is left to PATH resolution; an absolute path is checked for existence first so the
|
|
81
|
-
// probe does not pay for a spawn that cannot succeed.
|
|
82
|
-
if (candidate.includes('\\') || candidate.includes('/')) {
|
|
83
|
-
if (!existsSync(candidate)) continue
|
|
84
|
-
}
|
|
85
|
-
if (executableAvailable(candidate)) return candidate
|
|
86
|
-
}
|
|
87
|
-
return null
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// src/plugin/index.js -> package root, which is THREE levels up from the file:
|
|
91
|
-
// file <pkg>/src/plugin/index.js
|
|
92
|
-
// dirname <pkg>/src/plugin
|
|
93
|
-
// dirname <pkg>/src
|
|
94
|
-
// dirname <pkg> <- what the scripts are joined against
|
|
95
|
-
// Getting this off by one is invisible until runtime, where it reports "no entry point" while the
|
|
96
|
-
// file is present; tests/plugin.test.mjs pins it.
|
|
97
|
-
const PKG_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))))
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Resolve how to invoke the implementation.
|
|
101
|
-
*
|
|
102
|
-
* Returns { command, prefix } where the final argv is [...prefix, ...verbArguments].
|
|
103
|
-
*
|
|
104
|
-
* A .bat cannot be executed directly: cmd.exe interprets it, so spawning it with shell:false fails
|
|
105
|
-
* with EINVAL on Windows. The .ps1 is therefore preferred and launched through powershell.exe, which
|
|
106
|
-
* is an executable and keeps the call shell-free. The .bat shim exists for a human at a cmd prompt,
|
|
107
|
-
* not for this plugin. A .sh is executed by bash for the same reason: the script need not be marked
|
|
108
|
-
* executable in the checkout for the plugin to work.
|
|
109
|
-
*
|
|
110
|
-
* TWO PowerShell artefacts exist and BOTH are selectable:
|
|
111
|
-
*
|
|
112
|
-
* bin/dsh-os.ps1 built from src/host/lib/powershell5.1, run by powershell.exe (Windows PowerShell 5.1)
|
|
113
|
-
* bin/dsh-os7.ps1 built from src/host/lib/powershell7.6, run by pwsh (PowerShell 7)
|
|
114
|
-
*
|
|
115
|
-
* The 7.6 artefact was unreachable from here for a while: it existed, was built and was tested, but this
|
|
116
|
-
* function only ever looked for dsh-os.ps1. The default is unchanged at 5.1 so a deployment that is
|
|
117
|
-
* already working cannot change behaviour underneath itself; `powershellEdition: '7.6'` or `'auto'`
|
|
118
|
-
* selects the other one, and DSH_OS_POWERSHELL still overrides the executable. `'auto'` uses 7.6 only
|
|
119
|
-
* when both the artefact and a pwsh executable are present, and falls back to 5.1 otherwise.
|
|
120
|
-
*/
|
|
121
|
-
function resolveInvocation(config) {
|
|
122
|
-
const explicit = config.scriptPath ? [config.scriptPath] : []
|
|
123
|
-
const dir = config.scriptPath
|
|
124
|
-
? dirname(config.scriptPath)
|
|
125
|
-
: join(PKG_ROOT, config.scriptDir ?? 'src/host/bin')
|
|
126
|
-
|
|
127
|
-
// The edition may come from config or the environment; config wins because it is the more specific
|
|
128
|
-
// statement, and the environment is there for a deployment that cannot edit the profile.
|
|
129
|
-
const edition = String(config.powershellEdition ?? process.env.DSH_OS_POWERSHELL_EDITION ?? '5.1').toLowerCase()
|
|
130
|
-
|
|
131
|
-
const ps51 = { file: 'dsh-os.ps1', command: 'powershell.exe', edition: '5.1' }
|
|
132
|
-
const ps7 = { file: 'dsh-os7.ps1', command: 'pwsh', edition: '7.6' }
|
|
133
|
-
|
|
134
|
-
// The PowerShell 7 executable is located once, because the search probes processes and must not run
|
|
135
|
-
// per candidate.
|
|
136
|
-
const ps7Command = process.platform === 'win32' ? findPowerShell7() : null
|
|
137
|
-
|
|
138
|
-
// Build the candidate list for this platform. An explicit scriptPath disables the edition choice
|
|
139
|
-
// entirely: the caller has named a file, so that file is what runs.
|
|
140
|
-
let powershellCandidates = []
|
|
141
|
-
if (process.platform === 'win32') {
|
|
142
|
-
if (edition === '7.6' || edition === '7') {
|
|
143
|
-
powershellCandidates = ps7Command ? [{ ...ps7, command: ps7Command }, ps51] : [ps51]
|
|
144
|
-
} else if (edition === 'auto') {
|
|
145
|
-
const ps7Ready = ps7Command !== null && existsSync(join(dir, ps7.file))
|
|
146
|
-
powershellCandidates = ps7Ready ? [{ ...ps7, command: ps7Command }, ps51] : [ps51, ps7]
|
|
147
|
-
} else {
|
|
148
|
-
powershellCandidates = [ps51, ps7]
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const candidates = explicit.concat(process.platform === 'win32'
|
|
153
|
-
? powershellCandidates.map((c) => join(dir, c.file))
|
|
154
|
-
: [join(dir, 'dsh-os.sh')])
|
|
155
|
-
|
|
156
|
-
for (const candidate of candidates) {
|
|
157
|
-
if (!existsSync(candidate)) continue
|
|
158
|
-
if (candidate.toLowerCase().endsWith('.ps1')) {
|
|
159
|
-
// The edition is derived from the FILE that was found, not from the request, because the request
|
|
160
|
-
// may have named the other one and a fallback happened.
|
|
161
|
-
const chosen = powershellCandidates.find((c) => join(dir, c.file) === candidate)
|
|
162
|
-
const isSeven = chosen ? chosen.edition === '7.6' : false
|
|
163
|
-
// -ExecutionPolicy is a Windows PowerShell option. pwsh accepts it on Windows but not everywhere,
|
|
164
|
-
// so it is only passed to the 5.1 executable. The resolved command is used, not the literal name,
|
|
165
|
-
// because a located pwsh is an absolute path.
|
|
166
|
-
const base = ['-NoLogo', '-NoProfile', '-NonInteractive']
|
|
167
|
-
return {
|
|
168
|
-
script: candidate,
|
|
169
|
-
command: process.env.DSH_OS_POWERSHELL ?? (chosen ? chosen.command : ps51.command),
|
|
170
|
-
// -File passes the script and its arguments as separate argv entries. -Command would make
|
|
171
|
-
// PowerShell re-parse a single string, which is exactly the quoting layer this surface avoids.
|
|
172
|
-
prefix: isSeven
|
|
173
|
-
? [...base, '-File', candidate]
|
|
174
|
-
: [...base, '-ExecutionPolicy', 'Bypass', '-File', candidate],
|
|
175
|
-
edition: chosen ? chosen.edition : '5.1',
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
if (candidate.toLowerCase().endsWith('.sh')) {
|
|
179
|
-
return {
|
|
180
|
-
script: candidate,
|
|
181
|
-
command: '/bin/bash',
|
|
182
|
-
prefix: [candidate],
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
if (candidate.toLowerCase().endsWith('.bat')) {
|
|
186
|
-
// Only reachable when a caller explicitly configures a .bat; cmd.exe must interpret it.
|
|
187
|
-
return {
|
|
188
|
-
script: candidate,
|
|
189
|
-
command: process.env.ComSpec ?? 'cmd.exe',
|
|
190
|
-
prefix: ['/d', '/c', candidate],
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
return null
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Run the script with an argv array and capture stdout/stderr as bytes.
|
|
199
|
-
*
|
|
200
|
-
* Streams are read to completion rather than awaited one at a time: reading one pipe fully before the
|
|
201
|
-
* other deadlocks as soon as the unread pipe fills.
|
|
202
|
-
*/
|
|
203
|
-
function runScript(invocation, verbArgs, timeoutMs, signal) {
|
|
204
|
-
return new Promise((resolve, reject) => {
|
|
205
|
-
const child = spawn(invocation.command, [...invocation.prefix, ...verbArgs], {
|
|
206
|
-
windowsHide: true,
|
|
207
|
-
// No shell. The argv array is passed through as-is.
|
|
208
|
-
shell: false,
|
|
209
|
-
})
|
|
210
|
-
const out = []
|
|
211
|
-
const err = []
|
|
212
|
-
let timedOut = false
|
|
213
|
-
const timer = setTimeout(() => {
|
|
214
|
-
timedOut = true
|
|
215
|
-
try { child.kill() } catch { /* already gone */ }
|
|
216
|
-
}, timeoutMs)
|
|
217
|
-
const onAbort = () => { try { child.kill() } catch { /* already gone */ } }
|
|
218
|
-
if (signal) signal.addEventListener('abort', onAbort, { once: true })
|
|
219
|
-
child.stdout.on('data', (c) => out.push(c))
|
|
220
|
-
child.stderr.on('data', (c) => err.push(c))
|
|
221
|
-
child.on('error', (e) => {
|
|
222
|
-
clearTimeout(timer)
|
|
223
|
-
if (signal) signal.removeEventListener('abort', onAbort)
|
|
224
|
-
reject(e)
|
|
225
|
-
})
|
|
226
|
-
child.on('close', (code) => {
|
|
227
|
-
clearTimeout(timer)
|
|
228
|
-
if (signal) signal.removeEventListener('abort', onAbort)
|
|
229
|
-
resolve({
|
|
230
|
-
code,
|
|
231
|
-
timedOut,
|
|
232
|
-
stdout: Buffer.concat(out).toString('utf8'),
|
|
233
|
-
stderr: Buffer.concat(err).toString('utf8'),
|
|
234
|
-
})
|
|
235
|
-
})
|
|
236
|
-
})
|
|
237
|
-
}
|
|
238
|
-
|
|
239
30
|
export function apply(ctx, config = {}) {
|
|
240
31
|
const timeoutMs = config.timeoutMs ?? 120000
|
|
241
32
|
|
|
@@ -289,7 +80,7 @@ export function apply(ctx, config = {}) {
|
|
|
289
80
|
// The message names the directory that was actually searched, because the first version of
|
|
290
81
|
// this code reported a plausible-looking path that was one level off and cost a debugging
|
|
291
82
|
// round to find.
|
|
292
|
-
const searched =
|
|
83
|
+
const searched = searchedDirectory(config)
|
|
293
84
|
throw new Error(
|
|
294
85
|
`dsh-os has no entry point for platform "${process.platform}"; searched ${searched}` +
|
|
295
86
|
' (run tools/build.ps1 to generate it, or set the scriptPath config)',
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared invocation layer for the dsh-os entry points.
|
|
3
|
+
*
|
|
4
|
+
* Three consumers need to run a verb and get its envelope back:
|
|
5
|
+
* * `src/plugin/index.js` the model-facing `dsh_os` tool;
|
|
6
|
+
* * `src/plugin/takeover.js` the `tools/execute` interception that forwards the built-in
|
|
7
|
+
* `read`/`write`/`edit`/`pwsh` calls onto dsh-os verbs;
|
|
8
|
+
* * `src/plugin/settings.js` the settings service the client panel drives, which probes
|
|
9
|
+
* `selfcheck` and reads `permission list`.
|
|
10
|
+
*
|
|
11
|
+
* The resolution rules below (which artefact, which executable, which argv prefix) were originally
|
|
12
|
+
* inline in the tool plugin. They are extracted rather than copied because a second copy would drift:
|
|
13
|
+
* the 7.6 artefact was unreachable for a while precisely because one place knew about two files and
|
|
14
|
+
* another place did not.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here writes anything. Every export is a pure read of the environment plus a spawn.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
20
|
+
import { existsSync } from 'node:fs'
|
|
21
|
+
import { dirname, join } from 'node:path'
|
|
22
|
+
import { fileURLToPath } from 'node:url'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether a command can be started at all.
|
|
26
|
+
*
|
|
27
|
+
* Used only by the 'auto' edition choice, to decide between the two PowerShell artefacts. It asks the
|
|
28
|
+
* command itself rather than searching PATH by hand, so a shim, an alias-resolved path and a
|
|
29
|
+
* .exe-extension lookup on Windows all behave the way an actual spawn would. A failure to start is the
|
|
30
|
+
* answer being sought, so any error means "not available" and never propagates.
|
|
31
|
+
*/
|
|
32
|
+
export function executableAvailable(command) {
|
|
33
|
+
try {
|
|
34
|
+
const probe = spawnSync(command, ['-NoLogo', '-NoProfile', '-Command', 'exit 0'], {
|
|
35
|
+
stdio: 'ignore',
|
|
36
|
+
timeout: 10000,
|
|
37
|
+
windowsHide: true,
|
|
38
|
+
})
|
|
39
|
+
return probe.error === undefined && probe.status === 0
|
|
40
|
+
} catch {
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Find a PowerShell 7 executable.
|
|
47
|
+
*
|
|
48
|
+
* 'pwsh' alone is not enough. Installing PowerShell 7 does NOT put it on PATH for every process, and
|
|
49
|
+
* in this repository's own environment it is at the standard install location while `spawn pwsh`
|
|
50
|
+
* fails with ENOENT. Requiring a PATH entry would therefore make the 7.6 artefact unreachable on a
|
|
51
|
+
* machine where it is plainly installed, which is exactly the bug this selection was added to fix.
|
|
52
|
+
*
|
|
53
|
+
* So the search is: an explicit DSH_OS_POWERSHELL, then PATH, then the two standard install locations.
|
|
54
|
+
* The first entry that starts wins, and null means 7.x is genuinely not available here.
|
|
55
|
+
*/
|
|
56
|
+
export function findPowerShell7() {
|
|
57
|
+
const explicit = process.env.DSH_OS_POWERSHELL
|
|
58
|
+
if (explicit) return executableAvailable(explicit) ? explicit : null
|
|
59
|
+
|
|
60
|
+
const candidates = ['pwsh']
|
|
61
|
+
if (process.platform === 'win32') {
|
|
62
|
+
const programFiles = process.env.ProgramFiles ?? 'C:\\Program Files'
|
|
63
|
+
const programFilesX86 = process.env['ProgramFiles(x86)']
|
|
64
|
+
const localAppData = process.env.LOCALAPPDATA
|
|
65
|
+
candidates.push(join(programFiles, 'PowerShell', '7', 'pwsh.exe'))
|
|
66
|
+
if (programFilesX86) candidates.push(join(programFilesX86, 'PowerShell', '7', 'pwsh.exe'))
|
|
67
|
+
if (localAppData) candidates.push(join(localAppData, 'Microsoft', 'PowerShell', '7', 'pwsh.exe'))
|
|
68
|
+
} else {
|
|
69
|
+
candidates.push('/usr/bin/pwsh', '/usr/local/bin/pwsh', '/opt/microsoft/powershell/7/pwsh')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const candidate of candidates) {
|
|
73
|
+
// A bare name is left to PATH resolution; an absolute path is checked for existence first so the
|
|
74
|
+
// probe does not pay for a spawn that cannot succeed.
|
|
75
|
+
if (candidate.includes('\\') || candidate.includes('/')) {
|
|
76
|
+
if (!existsSync(candidate)) continue
|
|
77
|
+
}
|
|
78
|
+
if (executableAvailable(candidate)) return candidate
|
|
79
|
+
}
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// src/plugin/invoke.js -> package root, which is THREE levels up from the file:
|
|
84
|
+
// file <pkg>/src/plugin/invoke.js
|
|
85
|
+
// dirname <pkg>/src/plugin
|
|
86
|
+
// dirname <pkg>/src
|
|
87
|
+
// dirname <pkg> <- what the scripts are joined against
|
|
88
|
+
// Getting this off by one is invisible until runtime, where it reports "no entry point" while the
|
|
89
|
+
// file is present; tests/plugin.test.mjs pins it.
|
|
90
|
+
export const PKG_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))))
|
|
91
|
+
|
|
92
|
+
/** Where the implementation lives inside the package, unless a caller overrides it. */
|
|
93
|
+
export const DEFAULT_SCRIPT_DIR = 'src/host/bin'
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Resolve how to invoke the implementation.
|
|
97
|
+
*
|
|
98
|
+
* Returns { command, prefix, script, edition } where the final argv is [...prefix, ...verbArguments].
|
|
99
|
+
*
|
|
100
|
+
* A .bat cannot be executed directly: cmd.exe interprets it, so spawning it with shell:false fails
|
|
101
|
+
* with EINVAL on Windows. The .ps1 is therefore preferred and launched through powershell.exe, which
|
|
102
|
+
* is an executable and keeps the call shell-free. The .bat shim exists for a human at a cmd prompt,
|
|
103
|
+
* not for this plugin. A .sh is executed by bash for the same reason: the script need not be marked
|
|
104
|
+
* executable in the checkout for the plugin to work.
|
|
105
|
+
*
|
|
106
|
+
* TWO PowerShell artefacts exist and BOTH are selectable:
|
|
107
|
+
*
|
|
108
|
+
* bin/dsh-os.ps1 built from src/host/lib/powershell5.1, run by powershell.exe (Windows PowerShell 5.1)
|
|
109
|
+
* bin/dsh-os7.ps1 built from src/host/lib/powershell7.6, run by pwsh (PowerShell 7)
|
|
110
|
+
*
|
|
111
|
+
* The default is unchanged at 5.1 so a deployment that is already working cannot change behaviour
|
|
112
|
+
* underneath itself; `powershellEdition: '7.6'` or `'auto'` selects the other one, and
|
|
113
|
+
* DSH_OS_POWERSHELL still overrides the executable. `'auto'` uses 7.6 only when both the artefact and
|
|
114
|
+
* a pwsh executable are present, and falls back to 5.1 otherwise.
|
|
115
|
+
*/
|
|
116
|
+
export function resolveInvocation(config = {}) {
|
|
117
|
+
const explicit = config.scriptPath ? [config.scriptPath] : []
|
|
118
|
+
const dir = config.scriptPath
|
|
119
|
+
? dirname(config.scriptPath)
|
|
120
|
+
: join(PKG_ROOT, config.scriptDir ?? DEFAULT_SCRIPT_DIR)
|
|
121
|
+
|
|
122
|
+
// The edition may come from config or the environment; config wins because it is the more specific
|
|
123
|
+
// statement, and the environment is there for a deployment that cannot edit the profile.
|
|
124
|
+
const edition = String(config.powershellEdition ?? process.env.DSH_OS_POWERSHELL_EDITION ?? '5.1').toLowerCase()
|
|
125
|
+
|
|
126
|
+
const ps51 = { file: 'dsh-os.ps1', command: 'powershell.exe', edition: '5.1' }
|
|
127
|
+
const ps7 = { file: 'dsh-os7.ps1', command: 'pwsh', edition: '7.6' }
|
|
128
|
+
|
|
129
|
+
// The PowerShell 7 executable is located once, because the search probes processes and must not run
|
|
130
|
+
// per candidate.
|
|
131
|
+
const ps7Command = process.platform === 'win32' ? findPowerShell7() : null
|
|
132
|
+
|
|
133
|
+
// Build the candidate list for this platform. An explicit scriptPath disables the edition choice
|
|
134
|
+
// entirely: the caller has named a file, so that file is what runs.
|
|
135
|
+
let powershellCandidates = []
|
|
136
|
+
if (process.platform === 'win32') {
|
|
137
|
+
if (edition === '7.6' || edition === '7') {
|
|
138
|
+
powershellCandidates = ps7Command ? [{ ...ps7, command: ps7Command }, ps51] : [ps51]
|
|
139
|
+
} else if (edition === 'auto') {
|
|
140
|
+
const ps7Ready = ps7Command !== null && existsSync(join(dir, ps7.file))
|
|
141
|
+
powershellCandidates = ps7Ready ? [{ ...ps7, command: ps7Command }, ps51] : [ps51, ps7]
|
|
142
|
+
} else {
|
|
143
|
+
powershellCandidates = [ps51, ps7]
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const candidates = explicit.concat(process.platform === 'win32'
|
|
148
|
+
? powershellCandidates.map((c) => join(dir, c.file))
|
|
149
|
+
: [join(dir, 'dsh-os.sh')])
|
|
150
|
+
|
|
151
|
+
for (const candidate of candidates) {
|
|
152
|
+
if (!existsSync(candidate)) continue
|
|
153
|
+
if (candidate.toLowerCase().endsWith('.ps1')) {
|
|
154
|
+
// The edition is derived from the FILE that was found, not from the request, because the request
|
|
155
|
+
// may have named the other one and a fallback happened.
|
|
156
|
+
const chosen = powershellCandidates.find((c) => join(dir, c.file) === candidate)
|
|
157
|
+
const isSeven = chosen ? chosen.edition === '7.6' : false
|
|
158
|
+
// -ExecutionPolicy is a Windows PowerShell option. pwsh accepts it on Windows but not everywhere,
|
|
159
|
+
// so it is only passed to the 5.1 executable. The resolved command is used, not the literal name,
|
|
160
|
+
// because a located pwsh is an absolute path.
|
|
161
|
+
const base = ['-NoLogo', '-NoProfile', '-NonInteractive']
|
|
162
|
+
return {
|
|
163
|
+
script: candidate,
|
|
164
|
+
command: process.env.DSH_OS_POWERSHELL ?? (chosen ? chosen.command : ps51.command),
|
|
165
|
+
// -File passes the script and its arguments as separate argv entries. -Command would make
|
|
166
|
+
// PowerShell re-parse a single string, which is exactly the quoting layer this surface avoids.
|
|
167
|
+
prefix: isSeven
|
|
168
|
+
? [...base, '-File', candidate]
|
|
169
|
+
: [...base, '-ExecutionPolicy', 'Bypass', '-File', candidate],
|
|
170
|
+
edition: chosen ? chosen.edition : '5.1',
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (candidate.toLowerCase().endsWith('.sh')) {
|
|
174
|
+
return {
|
|
175
|
+
script: candidate,
|
|
176
|
+
command: '/bin/bash',
|
|
177
|
+
prefix: [candidate],
|
|
178
|
+
edition: 'posix',
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (candidate.toLowerCase().endsWith('.bat')) {
|
|
182
|
+
// Only reachable when a caller explicitly configures a .bat; cmd.exe must interpret it.
|
|
183
|
+
return {
|
|
184
|
+
script: candidate,
|
|
185
|
+
command: process.env.ComSpec ?? 'cmd.exe',
|
|
186
|
+
prefix: ['/d', '/c', candidate],
|
|
187
|
+
edition: 'bat',
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return null
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The directory an invocation would search, for error messages that name a real path. */
|
|
195
|
+
export function searchedDirectory(config = {}) {
|
|
196
|
+
return config.scriptPath ?? join(PKG_ROOT, config.scriptDir ?? DEFAULT_SCRIPT_DIR)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Run the script with an argv array and capture stdout/stderr as bytes.
|
|
201
|
+
*
|
|
202
|
+
* Streams are read to completion rather than awaited one at a time: reading one pipe fully before the
|
|
203
|
+
* other deadlocks as soon as the unread pipe fills.
|
|
204
|
+
*/
|
|
205
|
+
export function runScript(invocation, verbArgs, timeoutMs = 120000, signal) {
|
|
206
|
+
return new Promise((resolve, reject) => {
|
|
207
|
+
const child = spawn(invocation.command, [...invocation.prefix, ...verbArgs], {
|
|
208
|
+
windowsHide: true,
|
|
209
|
+
// No shell. The argv array is passed through as-is.
|
|
210
|
+
shell: false,
|
|
211
|
+
})
|
|
212
|
+
const out = []
|
|
213
|
+
const err = []
|
|
214
|
+
let timedOut = false
|
|
215
|
+
const timer = setTimeout(() => {
|
|
216
|
+
timedOut = true
|
|
217
|
+
try { child.kill() } catch { /* already gone */ }
|
|
218
|
+
}, timeoutMs)
|
|
219
|
+
const onAbort = () => { try { child.kill() } catch { /* already gone */ } }
|
|
220
|
+
if (signal) signal.addEventListener('abort', onAbort, { once: true })
|
|
221
|
+
child.stdout.on('data', (c) => out.push(c))
|
|
222
|
+
child.stderr.on('data', (c) => err.push(c))
|
|
223
|
+
child.on('error', (e) => {
|
|
224
|
+
clearTimeout(timer)
|
|
225
|
+
if (signal) signal.removeEventListener('abort', onAbort)
|
|
226
|
+
reject(e)
|
|
227
|
+
})
|
|
228
|
+
child.on('close', (code) => {
|
|
229
|
+
clearTimeout(timer)
|
|
230
|
+
if (signal) signal.removeEventListener('abort', onAbort)
|
|
231
|
+
resolve({
|
|
232
|
+
code,
|
|
233
|
+
timedOut,
|
|
234
|
+
stdout: Buffer.concat(out).toString('utf8'),
|
|
235
|
+
stderr: Buffer.concat(err).toString('utf8'),
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Run one verb and return its parsed envelope.
|
|
243
|
+
*
|
|
244
|
+
* The envelope is the contract, so it is parsed here once: `ok` decides the branch and `error.code`
|
|
245
|
+
* is authoritative, never `error.message`. A caller that needs the raw text uses `runVerb` instead.
|
|
246
|
+
*
|
|
247
|
+
* `options.allow` appends `--allow`, which is what every mutating verb requires. It is only ever set
|
|
248
|
+
* by a caller that has already resolved authorization: this function does not decide anything.
|
|
249
|
+
*/
|
|
250
|
+
export async function invokeVerb(invocation, verb, args = [], flags = [], options = {}) {
|
|
251
|
+
const verbArgs = [...String(verb).trim().split(/\s+/)]
|
|
252
|
+
for (const value of args) verbArgs.push(String(value))
|
|
253
|
+
for (const flag of flags) verbArgs.push(String(flag))
|
|
254
|
+
if (options.allow === true) verbArgs.push('--allow')
|
|
255
|
+
|
|
256
|
+
const result = await runScript(invocation, verbArgs, options.timeoutMs ?? 120000, options.signal)
|
|
257
|
+
|
|
258
|
+
if (result.timedOut) {
|
|
259
|
+
const error = new Error(`dsh-os verb "${verb}" timed out after ${options.timeoutMs ?? 120000}ms`)
|
|
260
|
+
error.code = 'E_TIMEOUT'
|
|
261
|
+
throw error
|
|
262
|
+
}
|
|
263
|
+
const text = result.stdout.trim()
|
|
264
|
+
if (text === '') {
|
|
265
|
+
const error = new Error(
|
|
266
|
+
`dsh-os verb "${verb}" produced no envelope (exit ${result.code})` +
|
|
267
|
+
(result.stderr.trim() ? `: ${result.stderr.trim()}` : ''),
|
|
268
|
+
)
|
|
269
|
+
error.code = 'E_NO_ENVELOPE'
|
|
270
|
+
error.stderr = result.stderr
|
|
271
|
+
throw error
|
|
272
|
+
}
|
|
273
|
+
let envelope
|
|
274
|
+
try {
|
|
275
|
+
envelope = JSON.parse(text)
|
|
276
|
+
} catch (cause) {
|
|
277
|
+
const error = new Error(`dsh-os verb "${verb}" emitted a non-JSON envelope: ${text.slice(0, 200)}`)
|
|
278
|
+
error.code = 'E_ENVELOPE_MALFORMED'
|
|
279
|
+
error.cause = cause
|
|
280
|
+
throw error
|
|
281
|
+
}
|
|
282
|
+
return { envelope, exitCode: result.code, stderr: result.stderr }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Run one verb and return only `result`, throwing a typed error for a failed envelope.
|
|
287
|
+
*
|
|
288
|
+
* The thrown error carries `code` from the envelope, so a caller branches on the same vocabulary it
|
|
289
|
+
* would have branched on in a shell, while the message stays human-readable for the tool result.
|
|
290
|
+
*/
|
|
291
|
+
export async function invokeForResult(invocation, verb, args = [], flags = [], options = {}) {
|
|
292
|
+
const { envelope, stderr } = await invokeVerb(invocation, verb, args, flags, options)
|
|
293
|
+
if (envelope.ok === true) return envelope.result
|
|
294
|
+
const code = envelope.error?.code ?? 'E_INTERNAL'
|
|
295
|
+
const error = new Error(envelope.error?.message ?? `dsh-os verb "${verb}" failed`)
|
|
296
|
+
error.code = code
|
|
297
|
+
error.hint = envelope.error?.hint
|
|
298
|
+
error.verb = envelope.verb ?? verb
|
|
299
|
+
error.stderr = stderr
|
|
300
|
+
throw error
|
|
301
|
+
}
|