dsh-pwsh-plugin 0.2.14 → 0.2.17
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/FINDINGS.md +91 -0
- package/README.md +44 -150
- package/README.zh-CN.md +35 -125
- package/cordis.patch.yml +4 -0
- package/docs/llrun.md +21 -1
- package/docs/runtime-notes.md +57 -0
- package/docs/runtime-notes.zh-CN.md +45 -0
- package/docs/update-audit.md +17 -0
- package/package.json +3 -5
- package/plugin/index.mjs +3 -201
- package/wsl/custom-bash.mjs +47 -2
- package/plugin/auto-update.mjs +0 -210
- package/plugin/update-apply.mjs +0 -274
- package/plugin/update-check.mjs +0 -270
package/plugin/index.mjs
CHANGED
|
@@ -31,9 +31,6 @@
|
|
|
31
31
|
|
|
32
32
|
import { readFileSync } from 'node:fs'
|
|
33
33
|
import { DEFAULT_MAX_OUTPUT_BYTES, bootArgv, startSession } from './session.mjs'
|
|
34
|
-
import { installReleaseLive, startAutoUpdate } from './auto-update.mjs'
|
|
35
|
-
import { preflightInstall } from './update-apply.mjs'
|
|
36
|
-
import { UpdateChecker } from './update-check.mjs'
|
|
37
34
|
|
|
38
35
|
/** 加载器诊断里显示的插件名。 */
|
|
39
36
|
export const name = 'pwsh-run'
|
|
@@ -155,11 +152,6 @@ export function apply(ctx, config) {
|
|
|
155
152
|
const maxOutputBytes = Number.isSafeInteger(config?.maxOutputBytes) && config.maxOutputBytes > 0 ? config.maxOutputBytes : DEFAULT_MAX_OUTPUT_BYTES
|
|
156
153
|
// 明确放弃边界的唯一开关:策略服务缺席时默认拒绝启动,只有这里能改主意。
|
|
157
154
|
const allowUnconfined = config?.allowUnconfined === true
|
|
158
|
-
// 更新检查:查到新版本时在当次调用的输出末尾挂一行。
|
|
159
|
-
const updateCheck = config?.updateCheck !== false
|
|
160
|
-
// 自动更新:查到新版本就自己装上,装上即生效(HMR 在场时无需重启)。默认开——
|
|
161
|
-
// 用户装这个插件图的是「命令不再被转义」,不该再手动盯版本。
|
|
162
|
-
const autoUpdate = config?.autoUpdate !== false
|
|
163
155
|
|
|
164
156
|
// 沙箱服务每次启动会话时再取:插件 apply 可能早于沙箱服务挂载,
|
|
165
157
|
// 而沙箱缺席时插件仍需能加载(启动会话时给出明确错误即可)。
|
|
@@ -173,16 +165,6 @@ export function apply(ctx, config) {
|
|
|
173
165
|
return sandboxPolicy.resolve(session === undefined ? {} : { session })
|
|
174
166
|
}
|
|
175
167
|
|
|
176
|
-
/**
|
|
177
|
-
* 安装闸门:自动更新与手动安装共用。
|
|
178
|
-
*
|
|
179
|
-
* 热替换会执行本插件的 effect 清理函数,那里 dispose 掉全部常驻会话——正在跑的脚本
|
|
180
|
-
* 会连人带马被掐断,与直接断电无异。所以安装前先确认没有调用在跑;置位与检查必须在
|
|
181
|
-
* 同一个同步块里完成,否则中间会挤进一条新调用。
|
|
182
|
-
*/
|
|
183
|
-
let activeRuns = 0
|
|
184
|
-
let installing = false
|
|
185
|
-
|
|
186
168
|
/** sessionId -> Promise<PwshSession>。用 Promise 占位,避免并发首调用各起一个进程。 */
|
|
187
169
|
const sessions = new Map()
|
|
188
170
|
|
|
@@ -196,33 +178,6 @@ export function apply(ctx, config) {
|
|
|
196
178
|
if (session !== undefined && session !== null) await session.dispose()
|
|
197
179
|
}
|
|
198
180
|
|
|
199
|
-
/**
|
|
200
|
-
* 申请安装权:只有「没有调用在跑、也没有别人在装」时才拿得到。
|
|
201
|
-
*
|
|
202
|
-
* 检查与置位放在同一个同步块里,中间不会挤进新调用——否则就会出现
|
|
203
|
-
* 「刚判断空闲、一条调用起来、安装把它的会话掐掉」这种时序空档。
|
|
204
|
-
*/
|
|
205
|
-
const beginInstall = () => {
|
|
206
|
-
if (installing || activeRuns > 0) return false
|
|
207
|
-
installing = true
|
|
208
|
-
return true
|
|
209
|
-
}
|
|
210
|
-
const endInstall = () => { installing = false }
|
|
211
|
-
/** 交给自动更新循环:忙就别装,推迟整轮。 */
|
|
212
|
-
const installGate = { tryAcquire: beginInstall, release: endInstall }
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* 安装进行中到达的调用先等着:此刻建出来的会话会在热替换那一刻被 dispose,
|
|
216
|
-
* 等于刚起就断。等到安装收尾再继续——之后的会话属于安装后的世界。
|
|
217
|
-
*/
|
|
218
|
-
const waitForInstall = async () => {
|
|
219
|
-
const deadline = Date.now() + 120000
|
|
220
|
-
while (installing) {
|
|
221
|
-
if (Date.now() > deadline) throw new Error('pwsh_run: an update is being installed and has not finished; retry in a moment')
|
|
222
|
-
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
181
|
/** 取一个活着的会话,必要时新建;超出上限淘汰最久未用的。 */
|
|
227
182
|
const acquire = async (key, workdir, agent, signal) => {
|
|
228
183
|
const existing = sessions.get(key)
|
|
@@ -316,147 +271,9 @@ export function apply(ctx, config) {
|
|
|
316
271
|
sessions.clear()
|
|
317
272
|
}, 'pwsh-run.sessions')
|
|
318
273
|
|
|
319
|
-
// 更新检查:只读 npm registry 的 dist-tags、只记判决,从不自己安装。
|
|
320
|
-
// 发现新版本时不改工具描述(描述在注册时就固定了),而是把提示挂在每次调用的
|
|
321
|
-
// 输出末尾一次——够模型看见,也不至于每次刷。
|
|
322
|
-
const checker = updateCheck
|
|
323
|
-
? new UpdateChecker({
|
|
324
|
-
log: (line) => {
|
|
325
|
-
try {
|
|
326
|
-
ctx.logger?.info?.('pwsh-run: %s', line)
|
|
327
|
-
} catch {
|
|
328
|
-
// 日志服务缺席时不影响工具本身。
|
|
329
|
-
}
|
|
330
|
-
},
|
|
331
|
-
})
|
|
332
|
-
: undefined
|
|
333
|
-
// 自动更新循环自成一个 effect:它比 checker 多一层「装上即生效」的动作,
|
|
334
|
-
// 失败要退避、要记装过的版本,这些都在 auto-update.mjs 里。
|
|
335
|
-
ctx.effect(() => {
|
|
336
|
-
const stopAuto = autoUpdate && checker !== undefined
|
|
337
|
-
? startAutoUpdate(ctx, checker, {
|
|
338
|
-
log: (line) => { try { ctx.logger?.info?.('pwsh-run: %s', line) } catch { /* 日志缺席不影响更新 */ } },
|
|
339
|
-
installGate,
|
|
340
|
-
...(Number.isSafeInteger(config?.autoUpdateDelayMs) && config.autoUpdateDelayMs >= 0 ? { firstDelayMs: config.autoUpdateDelayMs } : {}),
|
|
341
|
-
})
|
|
342
|
-
: undefined
|
|
343
|
-
return () => stopAuto?.()
|
|
344
|
-
}, 'pwsh-run.auto-update')
|
|
345
|
-
|
|
346
|
-
// 提示用的定时检查只在自动更新关闭时才起:自动更新的循环自己就会调 check(),
|
|
347
|
-
// 两边都开着等于每 12 小时查两次。
|
|
348
|
-
ctx.effect(() => {
|
|
349
|
-
if (checker === undefined || autoUpdate) return undefined
|
|
350
|
-
checker.start()
|
|
351
|
-
return () => checker.stop()
|
|
352
|
-
}, 'pwsh-run.update')
|
|
353
274
|
|
|
354
|
-
/**
|
|
355
|
-
* 更新工具:把「查到」与「装」拆成同一入口的两个动作。
|
|
356
|
-
*
|
|
357
|
-
* 为什么要有它
|
|
358
|
-
* 只把提示挂在 pwsh_run 输出末尾时,模型看见了也无从下手(它拿不到 profile 名,
|
|
359
|
-
* 也不知道该不该重启)。给出一个能被调用的动作,比给一句话可靠。
|
|
360
|
-
*
|
|
361
|
-
* 为什么装这一步不自己下载
|
|
362
|
-
* 见 update-apply.mjs 的头注释:本插件是 profile 的一个 bundle 依赖,版本钉在
|
|
363
|
-
* package.json 与 pnpm-lock.yaml 里,绕开包管理器改文件会让 lockfile 与磁盘不一致。
|
|
364
|
-
*/
|
|
365
|
-
ctx.effect(() => ctx.tools.register({
|
|
366
|
-
name: 'pwsh_run_update',
|
|
367
|
-
description: [
|
|
368
|
-
'Check for, and optionally install, a newer release of the pwsh_run plugin.',
|
|
369
|
-
'* action "check" reads the npm registry dist-tags once and reports the verdict; it never installs anything.',
|
|
370
|
-
'* action "install" hands the pinned version spec (<package>@X.Y.Z) to DSH\'s own plugin manager (the same service the Plugins page uses), which runs pnpm and rolls back on failure.',
|
|
371
|
-
'* Installing writes the package to the profile. Where the host runs its HMR service, the new code is already live when this returns; otherwise the running process keeps the old modules until DSH restarts. The result says which of the two happened.',
|
|
372
|
-
'* Only published stable versions are considered; pre-releases are never pushed. The version actually installed is read back from disk, because pnpm\'s minimumReleaseAge policy can install an older release than the one requested.',
|
|
373
|
-
'* An install replaces this plugin in place, which terminates the persistent pwsh sessions. It is therefore refused while a pwsh_run call is running; retry after that call finishes (the automatic updater applies the same rule on its own).',
|
|
374
|
-
].join('\n'),
|
|
375
|
-
parameters: {
|
|
376
|
-
type: 'object',
|
|
377
|
-
properties: {
|
|
378
|
-
action: {
|
|
379
|
-
type: 'string',
|
|
380
|
-
enum: ['check', 'install'],
|
|
381
|
-
description: 'check (default): report whether a newer release exists. install: also install the newest release.',
|
|
382
|
-
},
|
|
383
|
-
},
|
|
384
|
-
additionalProperties: false,
|
|
385
|
-
},
|
|
386
|
-
output: {
|
|
387
|
-
schema: {
|
|
388
|
-
type: 'object',
|
|
389
|
-
additionalProperties: false,
|
|
390
|
-
properties: { text: { type: 'string' } },
|
|
391
|
-
required: ['text'],
|
|
392
|
-
},
|
|
393
|
-
render: (_args, value) => [{ type: 'text', text: value.text }],
|
|
394
|
-
},
|
|
395
|
-
async execute(args) {
|
|
396
|
-
if (checker === undefined) {
|
|
397
|
-
return { text: 'pwsh_run update checks are disabled in this profile (config.updateCheck: false), so pwsh_run_update has nothing to do.' }
|
|
398
|
-
}
|
|
399
|
-
const action = args?.action === 'install' ? 'install' : 'check'
|
|
400
|
-
const state = await checker.check()
|
|
401
|
-
const doc = checker.document()
|
|
402
|
-
const lines = ['state: ' + String(doc.state)]
|
|
403
|
-
if (doc.running !== undefined) lines.push('running: ' + doc.running)
|
|
404
|
-
if (doc.latest !== undefined) lines.push('newest release: ' + doc.latest)
|
|
405
|
-
if (doc.spec !== undefined) lines.push('install spec: ' + doc.spec)
|
|
406
|
-
if (doc.error !== undefined) lines.push('error: ' + String(doc.error))
|
|
407
|
-
if (state.state !== 'available') {
|
|
408
|
-
// failed 与 current 必须分开说:查不到的时候说「已是最新」是句谎话。
|
|
409
|
-
if (state.state === 'current') {
|
|
410
|
-
lines.push(action === 'install'
|
|
411
|
-
? 'Nothing to install: this is the newest release the source publishes.'
|
|
412
|
-
: 'No newer release.')
|
|
413
|
-
}
|
|
414
|
-
else {
|
|
415
|
-
// 这条分支上 doc.spec 是 undefined,插值只会印出一个字面占位符,所以给模板。
|
|
416
|
-
lines.push('Could not reach the npm registry, so whether a newer version exists is unknown. '
|
|
417
|
-
+ 'Run the check again later, or upgrade by hand: dsh plugin --profile <name> add "<package>@X.Y.Z"')
|
|
418
|
-
}
|
|
419
|
-
return { text: lines.join('\n') }
|
|
420
|
-
}
|
|
421
|
-
if (action === 'check') {
|
|
422
|
-
lines.push('Run this again with action "install", or run: dsh plugin --profile <name> add "' + String(doc.spec) + '"')
|
|
423
|
-
return { text: lines.join('\n') }
|
|
424
|
-
}
|
|
425
|
-
// 装之前先问一句值不值得装:磁盘上已经是这个版本、或者这个 tag 的包根本没改过版本号,
|
|
426
|
-
// 装下去只会被 plugin-manager 判 already-installed。判掉后照实说清是哪一种。
|
|
427
|
-
const unnecessary = preflightInstall(ctx, checker, String(doc.spec))
|
|
428
|
-
if (unnecessary !== null) {
|
|
429
|
-
lines.push('install: ' + unnecessary.outcome)
|
|
430
|
-
lines.push(unnecessary.text)
|
|
431
|
-
return { text: lines.join('\n') }
|
|
432
|
-
}
|
|
433
|
-
// 安装会热替换本插件,从而 dispose 掉所有常驻会话。正有调用在跑时先不装:
|
|
434
|
-
// 掐断别人跑到一半的脚本,代价远大于晚几十秒。
|
|
435
|
-
if (!beginInstall()) {
|
|
436
|
-
lines.push('install: postponed')
|
|
437
|
-
lines.push('A pwsh_run call is running right now, and installing would terminate its session mid-script. '
|
|
438
|
-
+ 'Retry once that call has finished; auto-update performs this same check on its own.')
|
|
439
|
-
return { text: lines.join('\n') }
|
|
440
|
-
}
|
|
441
|
-
let outcome
|
|
442
|
-
try {
|
|
443
|
-
outcome = await installReleaseLive(ctx, String(doc.spec), { requestId: 'pwsh-run-update' })
|
|
444
|
-
} finally {
|
|
445
|
-
endInstall()
|
|
446
|
-
}
|
|
447
|
-
lines.push('install: ' + outcome.outcome)
|
|
448
|
-
lines.push(outcome.text)
|
|
449
|
-
if (outcome.installedVersion !== undefined && outcome.installedVersion !== doc.latest) {
|
|
450
|
-
lines.push('Installed version on disk: ' + outcome.installedVersion + ' (requested ' + String(doc.latest) + ').')
|
|
451
|
-
}
|
|
452
|
-
if (outcome.ok !== true && doc.spec !== undefined) lines.push('Manual path: dsh plugin --profile <name> add "' + String(doc.spec) + '"')
|
|
453
|
-
return { text: lines.join('\n') }
|
|
454
|
-
},
|
|
455
|
-
}), 'pwsh-run.update-tool')
|
|
456
|
-
|
|
457
|
-
/** 一条调用的实体;activeRuns 由注册处的 execute 在外面加减。 */
|
|
275
|
+
/** 一条调用的实体。 */
|
|
458
276
|
const runScript = async (args, exec) => {
|
|
459
|
-
await waitForInstall()
|
|
460
277
|
const requested = Number.isFinite(args?.timeoutMs) && args.timeoutMs > 0 ? Math.trunc(args.timeoutMs) : defaultTimeoutMs
|
|
461
278
|
const key = sessionKey(exec)
|
|
462
279
|
const workdir = typeof args?.workdir === 'string' && args.workdir.length > 0 ? args.workdir : undefined
|
|
@@ -522,14 +339,6 @@ export function apply(ctx, config) {
|
|
|
522
339
|
for (const line of response?.warn ?? []) body.push('WARNING: ' + line)
|
|
523
340
|
for (const line of response?.verbose ?? []) body.push('VERBOSE: ' + line)
|
|
524
341
|
if (response?.sentinel === 'threw') body.push('[script threw]')
|
|
525
|
-
// 更新提示只在第一次查到新版本时挂一行,之后不再刷。自动更新关掉时给出可照做的命令,
|
|
526
|
-
// 打开时说清楚它自己会装——否则模型会把这个版本当成「需要人工干预」。
|
|
527
|
-
const fresh = checker?.announce
|
|
528
|
-
if (fresh !== undefined && autoUpdate === false) {
|
|
529
|
-
notes.push('[a newer pwsh-run release exists: ' + fresh.latest + ' (running ' + String(checker?.version ?? '?') +
|
|
530
|
-
'). Install it with dsh plugin --profile <name> add "' + fresh.spec + '". ' +
|
|
531
|
-
'Auto-update is off in this profile (config.autoUpdate: false).]')
|
|
532
|
-
}
|
|
533
342
|
const text = [body.join('\n')]
|
|
534
343
|
.concat(response?.err ?? [])
|
|
535
344
|
.concat(response?.exceptions ?? [])
|
|
@@ -567,14 +376,7 @@ export function apply(ctx, config) {
|
|
|
567
376
|
render: (_args, value) => [{ type: 'text', text: value.text }],
|
|
568
377
|
},
|
|
569
378
|
async execute(args, exec) {
|
|
570
|
-
|
|
571
|
-
// 正跑着的脚本会被连人带马掐断。
|
|
572
|
-
activeRuns += 1
|
|
573
|
-
try {
|
|
574
|
-
return await runScript(args, exec)
|
|
575
|
-
} finally {
|
|
576
|
-
activeRuns -= 1
|
|
577
|
-
}
|
|
379
|
+
return await runScript(args, exec)
|
|
578
380
|
},
|
|
579
381
|
}), 'pwsh-run.tool')
|
|
580
|
-
}
|
|
382
|
+
}
|
package/wsl/custom-bash.mjs
CHANGED
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
* 最后才用包内自带的 wsl/llrun —— 显式安装的优先,包里那份只负责开箱可用。
|
|
23
23
|
* 取第一个可执行的;探测结果缓存,装好即生效,不必重启。
|
|
24
24
|
* fence 是否按 sandbox 权限档挂围栏,默认 true。
|
|
25
|
+
* sshAgentSocket WSL 侧的 ssh-agent socket 路径,默认 '~/.ssh/wsl2-ssh-agent.sock';
|
|
26
|
+
* 给 false 关掉这段探测。见下方「SSH agent」一节。
|
|
25
27
|
*
|
|
26
28
|
* 沙箱(2026-10-23)
|
|
27
29
|
* workspace-write / read-only 下经 `wsl/llrun` 起 bash:userns socket 掩蔽(docker 等)
|
|
@@ -40,6 +42,15 @@
|
|
|
40
42
|
* - POSIX 路径的 `workdir` 不能当 Windows 进程 cwd(spawn 直接失败);
|
|
41
43
|
* 有围栏时交给 llrun 的 `-C`(在 landlock 之后 chdir),无围栏时退化为命令内 `cd`。
|
|
42
44
|
* - 默认工作目录取自 workingDirectory 服务而非 header.cwd:会话中途切目录后 header 不更新。
|
|
45
|
+
*
|
|
46
|
+
* SSH agent
|
|
47
|
+
* 非交互 shell 不读 ~/.bashrc(那是交互式专属),所以用户终端里能用的
|
|
48
|
+
* SSH_AUTH_SOCK 在这里天然是空的。而 WSLENV 转发也不可靠:Windows 侧的
|
|
49
|
+
* 变量要显式列进 WSLENV 才过界,且经 llrun 的 userns 之后仍然没有保证。
|
|
50
|
+
* 因此改成在脚本文本最前面插一段探测:socket 存在就 export,已设过则不动。
|
|
51
|
+
* 走 stdin 送进 `bash -s`,围栏内外是同一条路径,一处覆盖两种模式。
|
|
52
|
+
* 只 export 变量,不启动守护进程 —— 进程得由 systemd 用户单元之类的
|
|
53
|
+
* 围栏外机制维持,本工具无权也不该替用户起后台服务。
|
|
43
54
|
*/
|
|
44
55
|
|
|
45
56
|
import { fileURLToPath } from 'node:url'
|
|
@@ -64,6 +75,30 @@ const DEFAULT_TIMEOUT_MS = 120000
|
|
|
64
75
|
const DEFAULT_MAX_OUTPUT_BYTES = 64000
|
|
65
76
|
const PROBE_TIMEOUT_MS = 8000
|
|
66
77
|
|
|
78
|
+
/**
|
|
79
|
+
* 默认的 wsl2-ssh-agent socket(rupor-github/wsl-ssh-agent 的默认路径)。
|
|
80
|
+
* 用 $HOME 而不是展开后的绝对路径:脚本在 WSL 里跑,家目录以那边的为准。
|
|
81
|
+
*/
|
|
82
|
+
const DEFAULT_SSH_AGENT_SOCKET = '$HOME/.ssh/wsl2-ssh-agent.sock'
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 生成 agent 探测片段。返回空串表示不需要插入。
|
|
86
|
+
*
|
|
87
|
+
* 三件事同时成立才 export:调用方没有自己设 SSH_AUTH_SOCK、socket 确实存在、
|
|
88
|
+
* 并且是个 socket(`-S` 而不是 `-e`)—— 残留的普通文件不该被当成可用的 agent。
|
|
89
|
+
* 已设过就不覆盖:用户显式指定的优先级永远高于我们的猜测,哪怕它指错了,
|
|
90
|
+
* 也该由用户看到错误而不是被静默改到别处。
|
|
91
|
+
*/
|
|
92
|
+
export function sshAgentPreamble(socket) {
|
|
93
|
+
if (typeof socket !== 'string' || socket.length === 0) return ''
|
|
94
|
+
const quoted = '"' + socket.replaceAll('"', '\\"') + '"'
|
|
95
|
+
return [
|
|
96
|
+
'if [ -z "$SSH_AUTH_SOCK" ] && [ -S ' + quoted + ' ]; then',
|
|
97
|
+
' export SSH_AUTH_SOCK=' + quoted,
|
|
98
|
+
'fi',
|
|
99
|
+
].join('\n')
|
|
100
|
+
}
|
|
101
|
+
|
|
67
102
|
/**
|
|
68
103
|
* 本文件同目录的 llrun,也就是随插件包一起发布的那份。
|
|
69
104
|
* 从包加载时它是真的存在(bundle 把这一行指进 node_modules),
|
|
@@ -191,6 +226,13 @@ export function apply(ctx, config) {
|
|
|
191
226
|
const configuredLlrun = typeof config?.llrunPath === 'string' && config.llrunPath.length > 0 ? config.llrunPath : undefined
|
|
192
227
|
const llrunCandidates = configuredLlrun === undefined ? defaultLlrunCandidates() : [configuredLlrun]
|
|
193
228
|
const fenceEnabled = config?.fence !== false
|
|
229
|
+
// 显式给 false 表示完全关掉;给字符串就用它;其余(含缺省)用默认 socket。
|
|
230
|
+
const sshAgentSocket = config?.sshAgentSocket === false
|
|
231
|
+
? ''
|
|
232
|
+
: typeof config?.sshAgentSocket === 'string' && config.sshAgentSocket.length > 0
|
|
233
|
+
? config.sshAgentSocket
|
|
234
|
+
: DEFAULT_SSH_AGENT_SOCKET
|
|
235
|
+
const agentPreamble = sshAgentPreamble(sshAgentSocket)
|
|
194
236
|
|
|
195
237
|
/**
|
|
196
238
|
* bash 解析:按 bashCandidates 顺序问 subprocess 服务,第一个解出来的胜出。
|
|
@@ -289,7 +331,8 @@ export function apply(ctx, config) {
|
|
|
289
331
|
const needFence = fenceEnabled && mode !== 'danger-full-access'
|
|
290
332
|
|
|
291
333
|
let argv
|
|
292
|
-
|
|
334
|
+
// agent 探测排在用户脚本之前,两条路径共用同一段文本,围栏内外行为一致。
|
|
335
|
+
let script = agentPreamble.length > 0 ? agentPreamble + '\n' + args.command : args.command
|
|
293
336
|
if (needFence) {
|
|
294
337
|
const llrunPath = await resolveLlrun(shell, baseCwd)
|
|
295
338
|
if (llrunPath === undefined) {
|
|
@@ -307,7 +350,9 @@ export function apply(ctx, config) {
|
|
|
307
350
|
fenceArgs.push(shellQuote(workspace), '/bin/bash', '-s')
|
|
308
351
|
argv = [shell, '-c', `exec ${llrunExecWord(llrunPath)} ${fenceArgs.join(' ')}`]
|
|
309
352
|
} else {
|
|
310
|
-
|
|
353
|
+
// 这里要基于已经带上前置片段的 script 再包 cd,不能回到 args.command ——
|
|
354
|
+
// 否则带 workdir 的调用会静默丢掉 agent 探测。
|
|
355
|
+
if (posixDir !== undefined) script = `cd ${shellQuote(posixDir)} && ${script}`
|
|
311
356
|
argv = [shell, '-s']
|
|
312
357
|
}
|
|
313
358
|
|
package/plugin/auto-update.mjs
DELETED
|
@@ -1,210 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 无感自动更新:由插件自己把新版本装上,并让它就地生效。
|
|
3
|
-
*
|
|
4
|
-
* 为什么不能直接调 installBundle
|
|
5
|
-
* 插件的 apply 与它起的定时器都跑在 HMR 的事务上下文里(AsyncLocalStorage)。
|
|
6
|
-
* 在那个上下文里调 installBundle,pnpm 会跑完、磁盘也会换成目标版本,但随后的
|
|
7
|
-
* 热重组会被 runExclusive 的嵌套检查拒掉(HMR transactions cannot be nested),
|
|
8
|
-
* 留下「磁盘新版、进程旧版」这种最难看清的状态(实测)。先 executing.run(undefined, …)
|
|
9
|
-
* 把上下文清空再调,HMR 才会真的重组合,并顺手把本插件自己替换掉
|
|
10
|
-
* (实测:改完文件后 137ms 新代码就生效,无需重启)。
|
|
11
|
-
*
|
|
12
|
-
* 为什么没有 HMR 就完全不装
|
|
13
|
-
* 没有 HMR 的宿主上,装完磁盘是新的、进程里仍是旧的,而 bundle 行给不出新的 module URL,
|
|
14
|
-
* 重启之前一直是「装上了但没生效」。这种自动毫无意义,反而让 pwsh_run_update 报出一个
|
|
15
|
-
* 与真正在跑的代码不符的版本。此时退回只提示,由人决定何时升级、何时重启。
|
|
16
|
-
*
|
|
17
|
-
* 为什么不自己下载覆盖文件
|
|
18
|
-
* 见 update-apply.mjs 的头注释:本插件是 profile 的 bundle 依赖,版本钉在
|
|
19
|
-
* package.json 与 pnpm-lock.yaml 里,绕开包管理器改文件会立刻让 lockfile 与磁盘不一致。
|
|
20
|
-
*
|
|
21
|
-
* 为什么必须等到「安静」才装
|
|
22
|
-
* 热替换会执行本插件的 effect 清理函数,那里要 dispose 掉所有常驻 pwsh 会话。
|
|
23
|
-
* 若此刻正有一条调用在跑,它会连带被掐断——脚本跑到一半、半截输出、会话状态全丢,
|
|
24
|
-
* 与直接断电没有区别。所以安装前要问宿主「现在安静吗」,忙就整个推迟,
|
|
25
|
-
* 而不是装完再说「可惜刚才那条被打断了」。
|
|
26
|
-
*
|
|
27
|
-
* 为什么要记住「上一次实际装到了哪个版本」
|
|
28
|
-
* pnpm 的 minimumReleaseAge 会让「请求 0.2.13、装到 0.2.12」成为正常结果。若不留这个记忆,
|
|
29
|
-
* 下一轮还会请求 0.2.13、还会落回 0.2.12,被 plugin-manager 判 already-installed,
|
|
30
|
-
* 退避爬到上限,永远如此。记住之后就能认出「这是冷却期,不是故障」,直接等到期。
|
|
31
|
-
*
|
|
32
|
-
* 为什么冷却期落地之后要把目标版本从「装过」里撤出来
|
|
33
|
-
* 落到更旧的版本算成功,但它不是这次的目标:目标自己还要等窗口过去。若不当场撤掉,
|
|
34
|
-
* 这个版本就再也不会被尝试,等于把这个进程的生命周期钉死在旧版本上。
|
|
35
|
-
*/
|
|
36
|
-
|
|
37
|
-
import { installRelease, preflightInstall } from './update-apply.mjs'
|
|
38
|
-
import { compareVersions } from './update-check.mjs'
|
|
39
|
-
|
|
40
|
-
/** 首次检查前的等待:让启动流程先跑完,别跟它抢 pnpm。 */
|
|
41
|
-
const FIRST_DELAY_MS = 90 * 1000
|
|
42
|
-
/** 一轮正常节奏:与 UpdateChecker 的定时一致,失败时按下面的退避重排。 */
|
|
43
|
-
const INTERVAL_MS = 12 * 60 * 60 * 1000
|
|
44
|
-
const RETRY_BASE_MS = 5 * 60 * 1000
|
|
45
|
-
const RETRY_MAX_MS = 2 * 60 * 60 * 1000
|
|
46
|
-
/** 查出有新版但有调用正跑着:过一会儿再看一眼,别等到下一轮 12 小时。 */
|
|
47
|
-
const BUSY_RETRY_MS = 3 * 60 * 1000
|
|
48
|
-
/** 冷却期:pnpm 的窗口默认 24 小时,隔一小时再看一眼不浪费什么。 */
|
|
49
|
-
const COOLDOWN_RETRY_MS = 60 * 60 * 1000
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* 在 HMR 事务之外装一个发布。
|
|
53
|
-
*
|
|
54
|
-
* @param ctx - 插件上下文,用来取 pluginManager 与 hmr。
|
|
55
|
-
* @param spec - 钉住版本号的安装规格,例如 dsh-pwsh-plugin@0.2.13。
|
|
56
|
-
* @param options - { requestId, log }。
|
|
57
|
-
* @returns 与 installRelease 相同的结果对象,永不抛出。
|
|
58
|
-
*/
|
|
59
|
-
export async function installReleaseLive(ctx, spec, options = {}) {
|
|
60
|
-
const manager = ctx?.get?.('pluginManager')
|
|
61
|
-
const hmr = ctx?.get?.('hmr')
|
|
62
|
-
// 回读装完的版本需要 profile 目录;读不到就只报 application,不编一个版本号出来。
|
|
63
|
-
const profileDir = ctx?.get?.('profileContext')?.dir
|
|
64
|
-
const base = { ...options, ...(typeof profileDir === 'string' ? { profileDir } : {}) }
|
|
65
|
-
if (hmr === undefined || typeof hmr?.executing?.run !== 'function') {
|
|
66
|
-
return await installRelease(manager, spec, { ...base, live: false })
|
|
67
|
-
}
|
|
68
|
-
// 上下文清空后 installBundle 自己会拿 runExclusive;装完 HMR 重组合,
|
|
69
|
-
// 本插件的模块被驱逐、新模块 import 进来重新 apply。
|
|
70
|
-
return await hmr.executing.run(undefined, () => installRelease(manager, spec, { ...base, live: true }))
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* 宿主能不能就地换掉已加载的模块。判断与 installReleaseLive 用同一组条件,
|
|
75
|
-
* 两处必须一致,否则会出现「循环以为装得上、安装却退回非 live」的错配。
|
|
76
|
-
*
|
|
77
|
-
* @param ctx - 插件上下文。
|
|
78
|
-
* @returns 有可用的 HMR 服务时为 true。
|
|
79
|
-
*/
|
|
80
|
-
function hmrIsLive(ctx) {
|
|
81
|
-
const hmr = ctx?.get?.('hmr')
|
|
82
|
-
return hmr !== undefined && typeof hmr?.executing?.run === 'function'
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* 起一个自动更新循环:查到新版本就装上,失败按指数退避重试。
|
|
87
|
-
*
|
|
88
|
-
* @param ctx - 插件上下文。
|
|
89
|
-
* @param checker - UpdateChecker 实例;它的 check() 重入安全,直接复用。
|
|
90
|
-
* @param options - { log, firstDelayMs, intervalMs, retryBaseMs, retryMaxMs, cooldownRetryMs }。
|
|
91
|
-
* @returns 停止函数;插件被卸载或热替换时由调用方停掉。
|
|
92
|
-
*/
|
|
93
|
-
export function startAutoUpdate(ctx, checker, options = {}) {
|
|
94
|
-
const log = options.log ?? (() => undefined)
|
|
95
|
-
const firstDelayMs = options.firstDelayMs ?? FIRST_DELAY_MS
|
|
96
|
-
const intervalMs = options.intervalMs ?? INTERVAL_MS
|
|
97
|
-
const retryBaseMs = options.retryBaseMs ?? RETRY_BASE_MS
|
|
98
|
-
const retryMaxMs = options.retryMaxMs ?? RETRY_MAX_MS
|
|
99
|
-
const cooldownRetryMs = options.cooldownRetryMs ?? COOLDOWN_RETRY_MS
|
|
100
|
-
|
|
101
|
-
const installGate = options.installGate
|
|
102
|
-
const busyRetryMs = options.busyRetryMs ?? BUSY_RETRY_MS
|
|
103
|
-
|
|
104
|
-
/** 已经自动装过的版本:同一个版本装成功过就不再装第二次。 */
|
|
105
|
-
const attempted = new Set()
|
|
106
|
-
/** 上一次安装实际落到的版本:冷却期会让它与请求的版本不同,用来认出「不是故障」。 */
|
|
107
|
-
let lastInstalled
|
|
108
|
-
let timer
|
|
109
|
-
let stopped = false
|
|
110
|
-
let running = false
|
|
111
|
-
let failures = 0
|
|
112
|
-
/** 「忙,先不装」只记一次日志:每几分钟重复一行会把日志刷满。 */
|
|
113
|
-
let busyNoted = false
|
|
114
|
-
|
|
115
|
-
const schedule = (delayMs) => {
|
|
116
|
-
if (stopped) return
|
|
117
|
-
timer = setTimeout(tick, delayMs)
|
|
118
|
-
if (typeof timer.unref === 'function') timer.unref()
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const tick = async () => {
|
|
122
|
-
if (stopped || running) return
|
|
123
|
-
running = true
|
|
124
|
-
let next = intervalMs
|
|
125
|
-
try {
|
|
126
|
-
const state = await checker.check()
|
|
127
|
-
if (state.state === 'available' && hmrIsLive(ctx) !== true) {
|
|
128
|
-
// 装上也换不掉正在跑的模块:留着提示那条路,别制造「装了但没生效」。
|
|
129
|
-
log('auto update: ' + state.latest + ' is available, but this host runs no HMR — leaving it for a manual upgrade')
|
|
130
|
-
failures = 0
|
|
131
|
-
} else if (state.state === 'available' && !attempted.has(state.latest) && installGate?.tryAcquire() !== false) {
|
|
132
|
-
try {
|
|
133
|
-
const doc = checker.document()
|
|
134
|
-
const spec = doc.spec
|
|
135
|
-
if (typeof spec === 'string' && spec.length > 0) {
|
|
136
|
-
// 装之前先问一句值不值得装,否则 already-installed 会以「失败」的姿态出现,
|
|
137
|
-
// 而它其实有几种含义完全不同的成因(别人装好了 / 发布忘了升版本 / 冷却期未过)。
|
|
138
|
-
const unnecessary = preflightInstall(ctx, checker, spec, lastInstalled === undefined ? {} : { cooled: lastInstalled })
|
|
139
|
-
if (unnecessary !== null) {
|
|
140
|
-
busyNoted = false
|
|
141
|
-
// 记下这个版本,别再排下一轮:这几种成因都不会在几分钟内自己好转。
|
|
142
|
-
// 冷却期例外——它是会到期的,所以单独按小时再看。
|
|
143
|
-
attempted.add(state.latest)
|
|
144
|
-
log('auto update: ' + state.latest + ' — ' + unnecessary.outcome + ': ' + unnecessary.text)
|
|
145
|
-
if (unnecessary.outcome === 'cooling-down') {
|
|
146
|
-
attempted.delete(state.latest)
|
|
147
|
-
next = cooldownRetryMs
|
|
148
|
-
} else if (unnecessary.ok === true) {
|
|
149
|
-
failures = 0
|
|
150
|
-
}
|
|
151
|
-
return
|
|
152
|
-
}
|
|
153
|
-
busyNoted = false
|
|
154
|
-
attempted.add(state.latest)
|
|
155
|
-
log('auto update: installing ' + state.latest + ' from ' + spec)
|
|
156
|
-
const outcome = await installReleaseLive(ctx, spec, { requestId: 'pwsh-run-auto-update', log })
|
|
157
|
-
log('auto update: ' + outcome.outcome + ' — ' + outcome.text)
|
|
158
|
-
if (outcome.installedVersion !== undefined) {
|
|
159
|
-
lastInstalled = outcome.installedVersion
|
|
160
|
-
// 落到更旧的版本是 pnpm 冷却期的正常结果,但目标版本还在窗口里:
|
|
161
|
-
// 撤掉这条记录,过一小时(窗口是 24 小时)再看,而不是等满 12 小时。
|
|
162
|
-
if (compareVersions(outcome.installedVersion, state.latest) < 0) {
|
|
163
|
-
attempted.delete(state.latest)
|
|
164
|
-
next = cooldownRetryMs
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
if (outcome.ok !== true) {
|
|
168
|
-
// 装失败要能重来:把记录撤掉,交给退避后的下一轮。
|
|
169
|
-
attempted.delete(state.latest)
|
|
170
|
-
failures += 1
|
|
171
|
-
next = Math.min(retryMaxMs, retryBaseMs * 2 ** (failures - 1))
|
|
172
|
-
} else {
|
|
173
|
-
failures = 0
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
} finally {
|
|
177
|
-
// 安装可能把本插件本身驱逐掉,于是这个 release 落在旧实例的闭包上,
|
|
178
|
-
// 只是把旧变量置回 false——新实例有它自己的闸门。
|
|
179
|
-
installGate?.release()
|
|
180
|
-
}
|
|
181
|
-
} else if (state.state === 'available' && !attempted.has(state.latest)) {
|
|
182
|
-
// 有新版,但此刻有调用在跑:热替换会掐断它。整轮推迟,几分钟后再看。
|
|
183
|
-
if (!busyNoted) {
|
|
184
|
-
busyNoted = true
|
|
185
|
-
log('auto update: ' + state.latest + ' is available, but a call is running — postponing the install until the session is idle')
|
|
186
|
-
}
|
|
187
|
-
next = busyRetryMs
|
|
188
|
-
} else if (state.state === 'failed') {
|
|
189
|
-
failures += 1
|
|
190
|
-
next = Math.min(retryMaxMs, retryBaseMs * 2 ** (failures - 1))
|
|
191
|
-
} else {
|
|
192
|
-
failures = 0
|
|
193
|
-
}
|
|
194
|
-
} catch (error) {
|
|
195
|
-
// check() 本身不抛;这里兜的是 installReleaseLive 之外的意外。
|
|
196
|
-
failures += 1
|
|
197
|
-
next = Math.min(retryMaxMs, retryBaseMs * 2 ** (failures - 1))
|
|
198
|
-
log('auto update: unexpected failure — ' + String(error?.message ?? error))
|
|
199
|
-
} finally {
|
|
200
|
-
running = false
|
|
201
|
-
schedule(next)
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
schedule(firstDelayMs)
|
|
206
|
-
return () => {
|
|
207
|
-
stopped = true
|
|
208
|
-
clearTimeout(timer)
|
|
209
|
-
}
|
|
210
|
-
}
|