dsh-remote 0.8.34 → 0.8.36
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.en.md +141 -153
- package/README.md +115 -135
- package/lib/client.js +251 -3
- package/lib/http-transport.js +93 -1
- package/lib/ignore.js +33 -0
- package/lib/index.js +489 -21
- package/lib/pool.js +132 -53
- package/lib/search.js +53 -8
- package/lib/update.js +30 -7
- package/lib/web-attach.js +467 -0
- package/lib/web-deploy-skill.js +205 -0
- package/lib/web-deploy.js +652 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
toShellPath, toDisplayPath, remotePathBase, truncate, shortHash,
|
|
32
32
|
} from './paths.js'
|
|
33
33
|
import { createHostKeyGuard, isHostKeyKnown as _isHostKeyKnown } from './hostkey.js'
|
|
34
|
-
import { compileIgnore, DEFAULT_IGNORE } from './ignore.js'
|
|
34
|
+
import { compileIgnore, DEFAULT_IGNORE, DEFAULT_SEARCH_IGNORE } from './ignore.js'
|
|
35
35
|
import { friendlyMessage } from './errors.js'
|
|
36
36
|
import { importableEntries, sshConfigPath, readSshConfigText, resolveMachineSshConfig } from './sshconfig.js'
|
|
37
37
|
import { createRemoteFileReference, installFileReferenceOverlay, DEFAULT_EXCLUDED_DIRECTORIES } from './file-reference.js'
|
|
@@ -41,6 +41,12 @@ import { searchRemote } from './search.js'
|
|
|
41
41
|
import { resolveMirror, poolKey, lookupSessionCwd, encodeSegmentSafe, readSessionHeaderCwd, requestSessionHint } from './binding.js'
|
|
42
42
|
import { TaskManager } from './tasks.js'
|
|
43
43
|
import { ForwardManager } from './forwards.js'
|
|
44
|
+
import { WebAttach } from './web-attach.js'
|
|
45
|
+
import {
|
|
46
|
+
buildProbeCommand, parseProbe, judgeProbe, buildInstallPlan,
|
|
47
|
+
resolvePrefix, stepResult, DEFAULT_INSTALL_VERSION,
|
|
48
|
+
} from './web-deploy.js'
|
|
49
|
+
import { registerDeploySkill, buildInvestigatePrompt, SKILL_NAME } from './web-deploy-skill.js'
|
|
44
50
|
import { selfDir, diskVersion, LOADED_VERSION, gtVersion, fetchLatestVersion, applyUpdate, persistUpdateMode, readUpdateMode, reloadSelf, scheduleSelfReload, isInstalledCopy } from './update.js'
|
|
45
51
|
import { sendHeartbeat, pseudonym, heartbeatUrl, hasPersistedId, installIdPath } from './telemetry.js'
|
|
46
52
|
import { loadMachines as _loadMachines, saveMachines as _saveMachines, sanitizeMachine as _sanitizeMachine, applyMachine as _applyMachine, machineId as _machineId } from './registry.js'
|
|
@@ -134,6 +140,14 @@ export const Config = z.object({
|
|
|
134
140
|
/** Wall-clock budget (ms) for one remote `@` index pass; on expiry the
|
|
135
141
|
* partial index answers rather than making the caret wait. */
|
|
136
142
|
fileReferenceTimeoutMs: z.number().step(1).min(200).default(4000),
|
|
143
|
+
/** Cooperative budget (ms) for `rw_search`; also declared as the tool's
|
|
144
|
+
* `timeoutMs` so @deepseek-ai/dsh-tool-call-timeout-policy can end a call
|
|
145
|
+
* that overruns. The search itself stops at this budget and returns partial
|
|
146
|
+
* results (issue #44: an unbounded walk over a home directory could never be
|
|
147
|
+
* stopped and left the turn `running` forever). */
|
|
148
|
+
searchTimeoutMs: z.number().step(1).min(1000).default(60000),
|
|
149
|
+
/** Max files `rw_search` will stat/read before returning partial results. */
|
|
150
|
+
searchMaxEntries: z.number().step(1).min(1).default(50000),
|
|
137
151
|
/** Update mode: `auto` (default) checks on load and periodically, applies a
|
|
138
152
|
* newer npm release automatically, then hot-swaps the host half (see
|
|
139
153
|
* `updateAutoReload`); `manual` only checks when the user asks; `off` disables
|
|
@@ -152,6 +166,32 @@ export const Config = z.object({
|
|
|
152
166
|
* true). With `false` an update only takes effect on the next process start
|
|
153
167
|
* and `/dsh-remote/update-check` reports `pendingReload: true` meanwhile. */
|
|
154
168
|
updateAutoReload: z.boolean().default(true),
|
|
169
|
+
/** First local port tried when attaching a remote DSH Web UI (issue #46).
|
|
170
|
+
* The listener is loopback-only; consecutive ports are tried when this one
|
|
171
|
+
* is taken. */
|
|
172
|
+
webAttachPortStart: z.number().step(1).min(1).max(65535).default(3088),
|
|
173
|
+
/** Remote command that starts a `dsh web` on the attached machine. Only needs
|
|
174
|
+
* changing when the remote's `dsh` is not on the SSH login PATH (e.g. a
|
|
175
|
+
* version installed into a private prefix). */
|
|
176
|
+
webAttachCommand: z.string().default('dsh'),
|
|
177
|
+
/** `DSH_HOME` to export on the remote when attaching. Empty reuses the
|
|
178
|
+
* remote user's own harness home; point it at a scratch directory to keep an
|
|
179
|
+
* attached instance out of the user's real sessions and settings. */
|
|
180
|
+
webAttachDshHome: z.string().default(''),
|
|
181
|
+
/** How long (seconds) to wait for a freshly started remote `dsh web` to print
|
|
182
|
+
* its startup token before giving up. */
|
|
183
|
+
webAttachWaitSeconds: z.number().step(1).min(5).max(300).default(45),
|
|
184
|
+
/** Where an automatic deployment installs dsh on the remote. Empty means
|
|
185
|
+
* `$HOME/.dsh-remote/dsh` (persistent, needs no write access to any system
|
|
186
|
+
* prefix, and never replaces a dsh the user already has). */
|
|
187
|
+
webInstallPrefix: z.string().default(''),
|
|
188
|
+
/** Version an automatic deployment installs. The default is the first release
|
|
189
|
+
* whose node-pty ships Linux prebuilds — older ones cannot boot `dsh web` on
|
|
190
|
+
* Linux at all. */
|
|
191
|
+
webInstallVersion: z.string().default(DEFAULT_INSTALL_VERSION),
|
|
192
|
+
/** npm registry to install from. Empty uses the remote's own npm config, which
|
|
193
|
+
* is what an internal mirror needs to stay in effect. */
|
|
194
|
+
webInstallRegistry: z.string().default(''),
|
|
155
195
|
})
|
|
156
196
|
|
|
157
197
|
// ── shell / path helpers (pure implementations in lib/paths.js) ───────────
|
|
@@ -367,6 +407,42 @@ export async function apply(ctx, config) {
|
|
|
367
407
|
sshConfigResolved: m.useSshConfig ? resolveMachineSshConfig(m, { text: sshConfigText() }) : null,
|
|
368
408
|
}))
|
|
369
409
|
|
|
410
|
+
/**
|
|
411
|
+
* The `dsh` command to use for a machine's Web UI attach.
|
|
412
|
+
*
|
|
413
|
+
* Resolution order is deliberate: a PER-MACHINE value recorded by a completed
|
|
414
|
+
* deployment wins, then the global config default. Without the per-machine
|
|
415
|
+
* branch, deploying on one host would point every other host at a binary that
|
|
416
|
+
* only exists on that one.
|
|
417
|
+
*
|
|
418
|
+
* @param {object} machine - a registry record (may be undefined).
|
|
419
|
+
* @returns {string} the command to run on that machine.
|
|
420
|
+
*/
|
|
421
|
+
const webAttachCommandFor = (machine) => {
|
|
422
|
+
const perMachine = machine && typeof machine.webAttachCommand === 'string' ? machine.webAttachCommand.trim() : ''
|
|
423
|
+
return perMachine || String(config.webAttachCommand || 'dsh')
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Record the dsh command a deployment produced, on that machine.
|
|
428
|
+
*
|
|
429
|
+
* Persisted through the same registry helper every other machine edit uses, so
|
|
430
|
+
* the value survives a restart and appears in the settings UI like any other
|
|
431
|
+
* field. `keepCurrentKey: false` preserves whichever machine is current —
|
|
432
|
+
* deploying must never silently switch the user's active machine.
|
|
433
|
+
*
|
|
434
|
+
* @param {object} machine - the registry record to update.
|
|
435
|
+
* @param {string} command - the working dsh command.
|
|
436
|
+
*/
|
|
437
|
+
const persistWebAttachCommand = (machine, command) => {
|
|
438
|
+
try {
|
|
439
|
+
const i = machineIndex(machine.id)
|
|
440
|
+
if (i < 0) return
|
|
441
|
+
machines[i] = { ...machines[i], webAttachCommand: String(command || '') }
|
|
442
|
+
saveMachines(machines, store.currentId, false)
|
|
443
|
+
} catch { /* a failed persist only costs a re-deploy next time */ }
|
|
444
|
+
}
|
|
445
|
+
|
|
370
446
|
/** The machine the pool is currently bound to: an ephemeral tool connection
|
|
371
447
|
* (rw_connect save:false) wins, then the stored current, then config default. */
|
|
372
448
|
let ephemeral = null
|
|
@@ -734,6 +810,9 @@ export async function apply(ctx, config) {
|
|
|
734
810
|
}
|
|
735
811
|
|
|
736
812
|
// ── ignore rules (defaults + user file) ───────────────────────────────────
|
|
813
|
+
// NOTE: this matcher is shared by mirror sync (rw_sync/rw_push) and search.
|
|
814
|
+
// Do NOT add search-only cache excludes here: widening it would silently stop
|
|
815
|
+
// syncing directories a user may rely on. Search uses searchIgnoreMatcher().
|
|
737
816
|
const ignoreMatcher = () => {
|
|
738
817
|
try {
|
|
739
818
|
const fromFile = readFileSync(ignoreFile(), 'utf8').split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'))
|
|
@@ -742,6 +821,18 @@ export async function apply(ctx, config) {
|
|
|
742
821
|
return compileIgnore(DEFAULT_IGNORE)
|
|
743
822
|
}
|
|
744
823
|
}
|
|
824
|
+
/** Search-only matcher: same as ignoreMatcher() plus machine-local cache trees
|
|
825
|
+
* (`~/.npm`, `~/.cache`, …) that are huge, almost never what a human is
|
|
826
|
+
* looking for, and the main cause of the issue #44 hang. Mirror sync is
|
|
827
|
+
* deliberately unaffected. An explicit `path` still searches anywhere. */
|
|
828
|
+
const searchIgnoreMatcher = () => {
|
|
829
|
+
try {
|
|
830
|
+
const fromFile = readFileSync(ignoreFile(), 'utf8').split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'))
|
|
831
|
+
return compileIgnore(DEFAULT_SEARCH_IGNORE.concat(fromFile))
|
|
832
|
+
} catch {
|
|
833
|
+
return compileIgnore(DEFAULT_SEARCH_IGNORE)
|
|
834
|
+
}
|
|
835
|
+
}
|
|
745
836
|
|
|
746
837
|
// ── task + forward managers ───────────────────────────────────────────────
|
|
747
838
|
const tasks = new TaskManager()
|
|
@@ -749,6 +840,20 @@ export async function apply(ctx, config) {
|
|
|
749
840
|
pool.onReady = (client) => forwards.attach(client)
|
|
750
841
|
pool.onCloseHook = () => forwards.detach()
|
|
751
842
|
|
|
843
|
+
// ── remote DSH Web UI attach sessions (issue #46) ─────────────────────────
|
|
844
|
+
// "Use this DSH as a CLIENT for a DSH on another machine". The remote never
|
|
845
|
+
// opens an inbound port (DSH forbids `--host 0.0.0.0`); we SSH out, start or
|
|
846
|
+
// reuse a loopback-bound `dsh web` there, and carry its socket back over our
|
|
847
|
+
// own SSH connection to a loopback listener here. One session per machine —
|
|
848
|
+
// attaching again to the same machine reuses the live tunnel instead of
|
|
849
|
+
// starting a second remote server. Lifecycle is owned by the plugin fiber, so
|
|
850
|
+
// unloading the plugin tears every tunnel down.
|
|
851
|
+
const webAttaches = new Map() // machineId → WebAttach
|
|
852
|
+
ctx.effect(() => () => {
|
|
853
|
+
for (const attach of webAttaches.values()) { try { attach.close() } catch { /* already gone */ } }
|
|
854
|
+
webAttaches.clear()
|
|
855
|
+
}, 'dsh-remote.web-attach')
|
|
856
|
+
|
|
752
857
|
// ── auto-push watcher (config.autoPush, default off) ──────────────────────
|
|
753
858
|
// Watches the local mirror; local edits are pushed back to the remote after a
|
|
754
859
|
// 3s debounce, honoring ignore rules + the three-way conflict guard (a remote
|
|
@@ -1593,21 +1698,44 @@ export async function apply(ctx, config) {
|
|
|
1593
1698
|
defineTool({
|
|
1594
1699
|
name: 'rw_edit',
|
|
1595
1700
|
description:
|
|
1596
|
-
'Edit a remote text file by replacing literal text (read-modify-write with an mtime optimistic lock: aborts if the file changed on the remote between read and write). Path is absolute.',
|
|
1701
|
+
'Edit a remote text file by replacing literal text (read-modify-write with an mtime optimistic lock: aborts if the file changed on the remote between read and write). Path is absolute. Accepts the aliases old_string/new_string (and file_path for path), matching the host edit tool, so either spelling works.',
|
|
1597
1702
|
parameters: {
|
|
1598
|
-
path
|
|
1599
|
-
|
|
1600
|
-
|
|
1703
|
+
// `path`/`old`/`new` also accept the host edit tool's spellings
|
|
1704
|
+
// (file_path/old_string/new_string): models habitually use those, and a
|
|
1705
|
+
// schema-level `required` on only one spelling rejects the other before
|
|
1706
|
+
// execute() ever runs. So NONE of them is declared required here — the
|
|
1707
|
+
// resolver below enforces "path + old + new" and reports which spelling
|
|
1708
|
+
// to pass. Keep this in sync: adding `required: true` back to any of
|
|
1709
|
+
// these silently breaks its alias.
|
|
1710
|
+
path: { type: 'string', description: 'Absolute remote file path (alias: file_path)' },
|
|
1711
|
+
old: { type: 'string', description: 'Literal text to replace (must appear exactly once unless count is given). Alias: old_string' },
|
|
1712
|
+
new: { type: 'string', description: 'Replacement text; an empty string deletes the old text. Alias: new_string' },
|
|
1713
|
+
old_string: { type: 'string', description: 'Alias of old (same meaning)' },
|
|
1714
|
+
new_string: { type: 'string', description: 'Alias of new (same meaning)' },
|
|
1715
|
+
file_path: { type: 'string', description: 'Alias of path (same meaning)' },
|
|
1601
1716
|
count: { type: 'integer', description: 'How many occurrences to replace (default: error if the text appears more than once)' },
|
|
1602
1717
|
encoding: { type: 'string', description: 'Text encoding, e.g. utf-8 (default) or gbk' },
|
|
1603
1718
|
},
|
|
1604
1719
|
output: okOut,
|
|
1605
1720
|
async execute(args, exec) {
|
|
1606
1721
|
const b = requireMachine(exec, 'rw_edit')
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1722
|
+
// Presence must be checked on the RAW argument, not on the resolved
|
|
1723
|
+
// path: resolveRemoteArg falls back to the workspace root for an empty
|
|
1724
|
+
// string, so `!p` would never fire and a pathless rw_edit would silently
|
|
1725
|
+
// target the workspace root. (That is why `path` cannot simply lose its
|
|
1726
|
+
// schema `required` without this check replacing it.)
|
|
1727
|
+
const rawPath = args.path ?? args.file_path
|
|
1728
|
+
if (rawPath == null || String(rawPath).trim() === '') {
|
|
1729
|
+
throw new Error('rw_edit: a file path is required — pass path (alias: file_path)')
|
|
1730
|
+
}
|
|
1731
|
+
const p = resolveRemoteArg(b, rawPath)
|
|
1732
|
+
if (!p || p === '/') throw new Error('rw_edit: a file path is required — pass path (alias: file_path)')
|
|
1733
|
+
const oldRaw = args.old ?? args.old_string
|
|
1734
|
+
const newRaw = args.new ?? args.new_string
|
|
1735
|
+
if (oldRaw == null) throw new Error('rw_edit: old text is required — pass old (alias: old_string)')
|
|
1736
|
+
if (newRaw == null) throw new Error('rw_edit: replacement text is required — pass new (alias: new_string); use an empty string to delete the old text')
|
|
1737
|
+
const oldS = String(oldRaw)
|
|
1738
|
+
const newS = String(newRaw)
|
|
1611
1739
|
if (oldS === '') throw new Error('rw_edit: old text must not be empty')
|
|
1612
1740
|
const sftp = await b.pool.sftp()
|
|
1613
1741
|
const st0 = await sftp.stat(p)
|
|
@@ -1772,6 +1900,7 @@ export async function apply(ctx, config) {
|
|
|
1772
1900
|
if (!parts.length) parts.push('(no output)')
|
|
1773
1901
|
let text = parts.join('\n')
|
|
1774
1902
|
if (res.signal === 'TIMEOUT') text += `\n[command timed out after ${config.commandTimeoutMs}ms]`
|
|
1903
|
+
else if (res.signal === 'ABORTED') text += '\n[cancelled — the remote command was stopped]'
|
|
1775
1904
|
else if (res.code !== 0) text += `\n[exit code: ${res.code}]`
|
|
1776
1905
|
return { text }
|
|
1777
1906
|
} catch (err) {
|
|
@@ -1783,7 +1912,12 @@ export async function apply(ctx, config) {
|
|
|
1783
1912
|
defineTool({
|
|
1784
1913
|
name: 'rw_search',
|
|
1785
1914
|
description:
|
|
1786
|
-
'Search remote files for a pattern. POSIX remotes try `rg` then `grep -R` first; Windows and fallbacks use a portable SFTP walk. Honors ignore rules. Returns matching file:line rows; output is capped.',
|
|
1915
|
+
'Search remote files for a pattern. POSIX remotes try `rg` then `grep -R` first; Windows and fallbacks use a portable SFTP walk. Honors ignore rules. Returns matching file:line rows; output is capped. Search is bounded (maxEntries/maxDurationMs) and can be cancelled: on a very large tree it returns partial results marked TRUNCATED instead of running indefinitely.',
|
|
1916
|
+
// A cooperative budget for @deepseek-ai/dsh-tool-call-timeout-policy.
|
|
1917
|
+
// Only useful together with the cancellation support below: the policy can
|
|
1918
|
+
// only *ask* a tool to stop, so a tool that ignores exec.signal would keep
|
|
1919
|
+
// the caller waiting (issue #44).
|
|
1920
|
+
timeoutMs: config.searchTimeoutMs,
|
|
1787
1921
|
parameters: {
|
|
1788
1922
|
pattern: { type: 'string', required: true, description: 'Pattern to search for (extended regex)' },
|
|
1789
1923
|
path: { type: 'string', description: 'Directory to search (default: current remote workspace)' },
|
|
@@ -1791,6 +1925,8 @@ export async function apply(ctx, config) {
|
|
|
1791
1925
|
ignoreCase: { type: 'boolean', description: 'Case-insensitive search (default true)' },
|
|
1792
1926
|
contextLines: { type: 'integer', description: 'Lines of context around each match (default 0)' },
|
|
1793
1927
|
maxMatches: { type: 'integer', description: 'Max matches to return (default 500)' },
|
|
1928
|
+
maxEntries: { type: 'integer', description: 'Stop after scanning this many files (default 50000)' },
|
|
1929
|
+
maxDurationMs: { type: 'integer', description: 'Stop after this many ms and return partial results (default 60000)' },
|
|
1794
1930
|
},
|
|
1795
1931
|
output: textOut,
|
|
1796
1932
|
async execute(args, exec) {
|
|
@@ -1807,21 +1943,36 @@ export async function apply(ctx, config) {
|
|
|
1807
1943
|
throw new Error('rw_search: bad pattern: ' + ((err && err.message) || err))
|
|
1808
1944
|
}
|
|
1809
1945
|
const maxMatches = Math.min(Math.max(Number(args.maxMatches) || 500, 1), 2000)
|
|
1810
|
-
|
|
1811
|
-
|
|
1946
|
+
// Budgets. The SFTP walk had NO entry/duration cap before, so a search
|
|
1947
|
+
// over a home directory could run for hours while the turn stayed
|
|
1948
|
+
// `running` and cancel/steer did nothing (issue #44).
|
|
1949
|
+
const maxFiles = Math.min(Math.max(Number(args.maxEntries) || config.searchMaxEntries, 1), 500000)
|
|
1950
|
+
const maxDurationMs = Math.min(Math.max(Number(args.maxDurationMs) || config.searchTimeoutMs, 1000), 600000)
|
|
1951
|
+
const matcher = searchIgnoreMatcher()
|
|
1952
|
+
const { matches, scanned, truncated, cancelled } = await searchRemote(b.pool, dir, {
|
|
1812
1953
|
pattern,
|
|
1813
1954
|
regex,
|
|
1814
1955
|
glob: args.glob,
|
|
1815
1956
|
ignoreCase: args.ignoreCase !== false,
|
|
1816
1957
|
contextLines: Math.min(Math.max(Number(args.contextLines) || 0, 0), 10),
|
|
1817
1958
|
maxMatches,
|
|
1959
|
+
maxFiles,
|
|
1960
|
+
maxDurationMs,
|
|
1961
|
+
signal: exec.signal,
|
|
1818
1962
|
maxScanBytes: Math.min(config.maxFileBytes || 1024 * 1024, 1024 * 1024),
|
|
1819
1963
|
isIgnored: (name, isDir) => matcher(name, isDir),
|
|
1820
|
-
timeoutMs: config.commandTimeoutMs,
|
|
1964
|
+
timeoutMs: Math.min(config.commandTimeoutMs, maxDurationMs),
|
|
1821
1965
|
})
|
|
1822
|
-
|
|
1966
|
+
const note = cancelled
|
|
1967
|
+
? `, CANCELLED after ${scanned} files scanned`
|
|
1968
|
+
: truncated
|
|
1969
|
+
? ', TRUNCATED'
|
|
1970
|
+
: ''
|
|
1971
|
+
if (!matches.length) {
|
|
1972
|
+
return { text: `no matches for /${pattern}/ in ${dir} (${scanned} files scanned${note})` }
|
|
1973
|
+
}
|
|
1823
1974
|
let text = matches.map((m) => `${m.path}:${m.line}: ${m.text}`).join('\n')
|
|
1824
|
-
text += `\n(${matches.length} match(es), ${scanned} files scanned${
|
|
1975
|
+
text += `\n(${matches.length} match(es), ${scanned} files scanned${note})`
|
|
1825
1976
|
return { text: truncate(text, config.maxOutputChars) }
|
|
1826
1977
|
},
|
|
1827
1978
|
}),
|
|
@@ -1885,6 +2036,64 @@ export async function apply(ctx, config) {
|
|
|
1885
2036
|
},
|
|
1886
2037
|
}),
|
|
1887
2038
|
|
|
2039
|
+
defineTool({
|
|
2040
|
+
name: 'rw_deploy_probe',
|
|
2041
|
+
description:
|
|
2042
|
+
'READ-ONLY health check of the remote machine\'s DSH install: platform/arch, node/npm, the existing dsh and its version, whether its native module can load (the usual reason `dsh web` will not start, e.g. a node-pty build with no Linux prebuild), whether it knows --no-open, proxy and npm registry settings, and whether a private install prefix is writable. Writes nothing on the remote. Use it before troubleshooting a failed remote DSH Web UI connection. For an automatic install into a private prefix, use the settings UI ("Deploy & verify") — this tool never installs.',
|
|
2043
|
+
parameters: {},
|
|
2044
|
+
output: textOut,
|
|
2045
|
+
async execute(_args, exec) {
|
|
2046
|
+
// Use the BINDING (this session's own machine), like every other rw_*
|
|
2047
|
+
// tool. Reading the active machine instead would let a probe report on a
|
|
2048
|
+
// host this session never asked about.
|
|
2049
|
+
const b = requireBinding(exec, 'rw_deploy_probe')
|
|
2050
|
+
// The binding carries host/port/user but no id; match on the identity the
|
|
2051
|
+
// binding resolved, so a machine previously deployed to is still found
|
|
2052
|
+
// (its `webAttachCommand` is the per-machine record we want to report).
|
|
2053
|
+
const machine = machines.find((m) => m.host === b.host
|
|
2054
|
+
&& Number(m.port || 22) === Number(b.port || 22)
|
|
2055
|
+
&& (!b.username || m.username === b.username)) || null
|
|
2056
|
+
// Do NOT pre-resolve the prefix locally: `resolvePrefix({home:''})` falls
|
|
2057
|
+
// back to /tmp, which is not where an install would go. With no explicit
|
|
2058
|
+
// config the REMOTE resolves it from its own $HOME and reports back.
|
|
2059
|
+
const explicitPrefix = String(config.webInstallPrefix || '').trim()
|
|
2060
|
+
const installedCommand = String((machine && machine.webAttachCommand) || '').trim()
|
|
2061
|
+
const out = await b.pool.exec(
|
|
2062
|
+
buildProbeCommand({ prefix: explicitPrefix, installedCommand, defaultPrefixFor: explicitPrefix ? '' : 'home' }),
|
|
2063
|
+
{ timeoutMs: 30000 },
|
|
2064
|
+
)
|
|
2065
|
+
const facts = parseProbe(out && out.stdout)
|
|
2066
|
+
const verdict = judgeProbe(facts, { installedCommand })
|
|
2067
|
+
// Rendered as a compact report: the model needs the FACTS and the
|
|
2068
|
+
// VERDICT, not a JSON blob it would have to re-interpret.
|
|
2069
|
+
const lines = [
|
|
2070
|
+
`remote: ${b.host}${b.username ? ' (' + b.username + ')' : ''}`,
|
|
2071
|
+
`verdict: ${verdict.severity}${verdict.ok ? ' (usable)' : ' (blocked)'}`,
|
|
2072
|
+
verdict.useCommand ? `dsh to use: ${verdict.useCommand}` : 'dsh to use: (none usable yet)',
|
|
2073
|
+
`can auto-install: ${verdict.canAutoInstall ? 'yes' : 'no'}`,
|
|
2074
|
+
'',
|
|
2075
|
+
'facts:',
|
|
2076
|
+
` platform=${facts.platform || '?'} arch=${facts.arch || '?'} ${facts.windows ? '(windows)' : ''}`,
|
|
2077
|
+
` node=${facts.node || '(none)'} ${facts.nodeVersion || ''}`,
|
|
2078
|
+
` npm=${facts.npm || '(none)'}`,
|
|
2079
|
+
` dsh=${facts.dsh || '(none)'} ${facts.dshVersion || ''}`,
|
|
2080
|
+
` native-module (node-pty) prebuild: ${facts.ptyPrebuild || 'unknown'}`,
|
|
2081
|
+
` knows --no-open: ${facts.noOpen || 'unknown'}`,
|
|
2082
|
+
facts.installed ? ` previously deployed command present: ${facts.installed} (pty=${facts.installedPty || '?'} web=${facts.installedWeb || '?'})` : '',
|
|
2083
|
+
` git bash: ${facts.gitBash || '(none)'}${facts.windows && !facts.gitBash ? ' — required for a Windows remote' : ''}`,
|
|
2084
|
+
` proxy: ${facts.proxyVar || '(none)'} npm registry: ${facts.registry || '(default)'}`,
|
|
2085
|
+
` install prefix ${facts.resolvedPrefix || explicitPrefix || '(unresolved)'} writable: ${facts.writable || 'unknown'}`,
|
|
2086
|
+
'',
|
|
2087
|
+
'findings:',
|
|
2088
|
+
...verdict.findings.map((f) => ` [${f.severity}] ${f.summary}${f.detail ? ' — ' + f.detail : ''}${f.fix ? '\n fix: ' + f.fix : ''}`),
|
|
2089
|
+
].filter(Boolean)
|
|
2090
|
+
if (!verdict.ok) {
|
|
2091
|
+
lines.push('', 'This is read-only. To fix it, either use the settings UI card "Remote dsh health check & deploy" (it installs into a private prefix and verifies), or repair it yourself following the dsh-remote-deploy skill.')
|
|
2092
|
+
}
|
|
2093
|
+
return { text: lines.join('\n') }
|
|
2094
|
+
},
|
|
2095
|
+
}),
|
|
2096
|
+
|
|
1888
2097
|
defineTool({
|
|
1889
2098
|
name: 'rw_forward',
|
|
1890
2099
|
description:
|
|
@@ -2777,6 +2986,237 @@ export async function apply(ctx, config) {
|
|
|
2777
2986
|
}
|
|
2778
2987
|
},
|
|
2779
2988
|
},
|
|
2989
|
+
{
|
|
2990
|
+
kind: 'exact',
|
|
2991
|
+
path: '/dsh-remote/web-attach',
|
|
2992
|
+
// Issue #46: open/reuse/close a tunnel to a remote machine's DSH Web UI.
|
|
2993
|
+
// Actions: status (GET) | open | close. `open` either starts a remote
|
|
2994
|
+
// `dsh web` (default) or attaches to one the user already runs by passing
|
|
2995
|
+
// its `http://127.0.0.1:<port>/?token=…` URL.
|
|
2996
|
+
handler: async (req, res) => {
|
|
2997
|
+
try {
|
|
2998
|
+
if (req.method === 'GET') {
|
|
2999
|
+
const q = new URL(req.url, 'http://localhost').searchParams
|
|
3000
|
+
const id = q.get('machineId') || ''
|
|
3001
|
+
const attach = id ? webAttaches.get(id) : undefined
|
|
3002
|
+
return sendJson(res, 200, {
|
|
3003
|
+
ok: true,
|
|
3004
|
+
attach: attach ? attach.describe() : null,
|
|
3005
|
+
attaches: [...webAttaches.values()].map((a) => a.describe()),
|
|
3006
|
+
})
|
|
3007
|
+
}
|
|
3008
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
3009
|
+
const body = parseJsonObject(await readBody(req))
|
|
3010
|
+
const action = String(body.action || '')
|
|
3011
|
+
const machineId = String(body.machineId || store.currentId || '').trim()
|
|
3012
|
+
if (!machineId) return sendJson(res, 400, { ok: false, error: '选择一台机器后再操作(missing machineId)' })
|
|
3013
|
+
|
|
3014
|
+
// ── deploy: probe (read-only) and install (private prefix) ─────────
|
|
3015
|
+
// Issue #46 follow-up. Detection and installation are deterministic, so
|
|
3016
|
+
// they are code rather than a model call: fast, free, unit-testable.
|
|
3017
|
+
// The AI path (a built-in skill) is offered only when this cannot cope.
|
|
3018
|
+
if (action === 'probe' || action === 'install') {
|
|
3019
|
+
const machine = machines.find((m) => m.id === machineId)
|
|
3020
|
+
if (!machine) return sendJson(res, 404, { ok: false, error: `机器不存在:${machineId}` })
|
|
3021
|
+
const target = poolForMachine(identityOf(machine))
|
|
3022
|
+
// The command that deployment produced FOR THIS MACHINE wins over any
|
|
3023
|
+
// global default — otherwise a deployment on one host would be
|
|
3024
|
+
// forgotten on the very next probe.
|
|
3025
|
+
const installedCommand = String(machine.webAttachCommand || '').trim()
|
|
3026
|
+
const prefix = resolvePrefix({ home: '' }, { prefix: config.webInstallPrefix || '' })
|
|
3027
|
+
|
|
3028
|
+
if (action === 'probe') {
|
|
3029
|
+
// Strictly read-only: this runs before the user has agreed to any
|
|
3030
|
+
// change, and may be run on a machine they are merely looking at.
|
|
3031
|
+
const out = await target.exec(buildProbeCommand({ prefix, installedCommand, defaultPrefixFor: config.webInstallPrefix ? '' : 'home' }), { timeoutMs: 30000 })
|
|
3032
|
+
const facts = parseProbe(out && out.stdout)
|
|
3033
|
+
const verdict = judgeProbe(facts, { installedCommand })
|
|
3034
|
+
// The prefix we judged writability against was a guess (empty
|
|
3035
|
+
// $HOME); now that the probe reported the real home, re-resolve so
|
|
3036
|
+
// the prefix shown matches what an install would actually use.
|
|
3037
|
+
const realPrefix = facts.resolvedPrefix || resolvePrefix(facts, { prefix: config.webInstallPrefix || '' })
|
|
3038
|
+
audit('web-deploy', `probe ${machine.host}: ${verdict.severity}`, verdict.ok ? 0 : 1, machine)
|
|
3039
|
+
return sendJson(res, 200, {
|
|
3040
|
+
ok: true,
|
|
3041
|
+
facts,
|
|
3042
|
+
verdict,
|
|
3043
|
+
prefix: realPrefix,
|
|
3044
|
+
command: webAttachCommandFor(machine),
|
|
3045
|
+
installVersion: config.webInstallVersion || DEFAULT_INSTALL_VERSION,
|
|
3046
|
+
registry: config.webInstallRegistry || '',
|
|
3047
|
+
})
|
|
3048
|
+
}
|
|
3049
|
+
|
|
3050
|
+
// install
|
|
3051
|
+
if (body.confirm !== true) {
|
|
3052
|
+
return sendJson(res, 400, { ok: false, error: '安装需要在请求里显式 confirm: true' })
|
|
3053
|
+
}
|
|
3054
|
+
const pout = await target.exec(buildProbeCommand({ prefix, installedCommand, defaultPrefixFor: config.webInstallPrefix ? '' : 'home' }), { timeoutMs: 30000 })
|
|
3055
|
+
const facts = parseProbe(pout && pout.stdout)
|
|
3056
|
+
const verdict = judgeProbe(facts, { installedCommand })
|
|
3057
|
+
if (!verdict.canAutoInstall) {
|
|
3058
|
+
const why = verdict.findings.filter((x) => x.severity === 'blocker').map((x) => x.summary).join(';')
|
|
3059
|
+
audit('web-deploy', `install ${machine.host} refused: ${why}`, 1, machine)
|
|
3060
|
+
return sendJson(res, 400, { ok: false, error: `无法自动安装:${why || '远端缺少前提条件'}`, verdict })
|
|
3061
|
+
}
|
|
3062
|
+
const installPrefix = facts.resolvedPrefix || resolvePrefix(facts, { prefix: config.webInstallPrefix || '' })
|
|
3063
|
+
// A registry the user set explicitly wins; otherwise the remote's own
|
|
3064
|
+
// npm config is left alone, which is what an internal mirror needs.
|
|
3065
|
+
// `--registry` is only added when configured — overriding a working
|
|
3066
|
+
// corporate mirror with the public one would be a regression.
|
|
3067
|
+
const registry = String(body.registry || config.webInstallRegistry || '').trim()
|
|
3068
|
+
// Proxy comes from the remote's own environment; the operator may
|
|
3069
|
+
// override it per-install when the inherited one is wrong.
|
|
3070
|
+
const env = body.proxy && typeof body.proxy === 'object'
|
|
3071
|
+
? Object.fromEntries(Object.entries(body.proxy).filter(([, v]) => typeof v === 'string'))
|
|
3072
|
+
: undefined
|
|
3073
|
+
const plan = buildInstallPlan({
|
|
3074
|
+
facts,
|
|
3075
|
+
prefix: installPrefix,
|
|
3076
|
+
version: String(body.version || config.webInstallVersion || DEFAULT_INSTALL_VERSION),
|
|
3077
|
+
registry,
|
|
3078
|
+
env,
|
|
3079
|
+
})
|
|
3080
|
+
const results = []
|
|
3081
|
+
for (const step of plan.steps) {
|
|
3082
|
+
let res2
|
|
3083
|
+
try {
|
|
3084
|
+
res2 = await target.exec(step.command, {
|
|
3085
|
+
timeoutMs: step.timeoutMs || 120000,
|
|
3086
|
+
// Per-step env exists for the proxy override (P4); pool.exec
|
|
3087
|
+
// passes it through to the remote process.
|
|
3088
|
+
...(step.env ? { env: step.env } : {}),
|
|
3089
|
+
})
|
|
3090
|
+
} catch (err) {
|
|
3091
|
+
results.push({ id: step.id, title: step.title, ok: false, code: -1, output: String((err && err.message) || err) })
|
|
3092
|
+
break
|
|
3093
|
+
}
|
|
3094
|
+
const r = stepResult(step, res2)
|
|
3095
|
+
results.push(r)
|
|
3096
|
+
if (!r.ok) break
|
|
3097
|
+
}
|
|
3098
|
+
const ok = results.length === plan.steps.length && results.every((r) => r.ok)
|
|
3099
|
+
if (ok) {
|
|
3100
|
+
// Persist so the attach flow uses the working build from now on.
|
|
3101
|
+
persistWebAttachCommand(machine, plan.command)
|
|
3102
|
+
audit('web-deploy', `install ${machine.host} → ${plan.command}`, 0, machine)
|
|
3103
|
+
return sendJson(res, 200, { ok: true, steps: results, command: plan.command, prefix: installPrefix })
|
|
3104
|
+
}
|
|
3105
|
+
const failed = results.find((r) => !r.ok)
|
|
3106
|
+
audit('web-deploy', `install ${machine.host} failed at ${failed && failed.id}`, 1, machine)
|
|
3107
|
+
// A failure that looks like a network/registry problem gets the AI
|
|
3108
|
+
// escape hatch suggested, since that is precisely the long tail the
|
|
3109
|
+
// deterministic path cannot classify.
|
|
3110
|
+
const looksNetworky = /ETIMEDOUT|ENOTFOUND|ECONNREFUSED|network|proxy|403|404/i.test(String(failed && failed.output))
|
|
3111
|
+
return sendJson(res, 500, {
|
|
3112
|
+
ok: false,
|
|
3113
|
+
error: `安装失败于「${failed ? failed.title : '未知步骤'}」`,
|
|
3114
|
+
steps: results,
|
|
3115
|
+
prefix: installPrefix,
|
|
3116
|
+
suggestInvestigate: true,
|
|
3117
|
+
...(looksNetworky ? { hint: '看起来是网络或源的问题,可以让 AI 排查。' } : {}),
|
|
3118
|
+
})
|
|
3119
|
+
}
|
|
3120
|
+
|
|
3121
|
+
if (action === 'open') {
|
|
3122
|
+
// Reuse a live tunnel for this machine: re-opening would start a
|
|
3123
|
+
// second remote DSH and leak the first one's process.
|
|
3124
|
+
const live = webAttaches.get(machineId)
|
|
3125
|
+
if (live && live.active) return sendJson(res, 200, { ok: true, attach: live.describe(), reused: true })
|
|
3126
|
+
// A tunnel that is no longer listening still owns sockets and a log
|
|
3127
|
+
// path; release it before this machine gets a fresh session. Its
|
|
3128
|
+
// remote process is left alone — stopping it stays an explicit act.
|
|
3129
|
+
if (live) { try { await live.close() } catch { /* already torn down */ } }
|
|
3130
|
+
const machine = machines.find((m) => m.id === machineId)
|
|
3131
|
+
if (!machine) return sendJson(res, 404, { ok: false, error: `机器不存在:${machineId}` })
|
|
3132
|
+
const attach = new WebAttach({
|
|
3133
|
+
localPortStart: config.webAttachPortStart,
|
|
3134
|
+
})
|
|
3135
|
+
try {
|
|
3136
|
+
await attach.open({
|
|
3137
|
+
pool: poolForMachine(identityOf(machine)),
|
|
3138
|
+
machineId,
|
|
3139
|
+
command: String(body.command || '').trim() || webAttachCommandFor(machine),
|
|
3140
|
+
profile: 'web',
|
|
3141
|
+
existingUrl: String(body.existingUrl || ''),
|
|
3142
|
+
dshHome: config.webAttachDshHome || '',
|
|
3143
|
+
waitSeconds: config.webAttachWaitSeconds,
|
|
3144
|
+
})
|
|
3145
|
+
} catch (err) {
|
|
3146
|
+
try { await attach.close() } catch { /* nothing was listening */ }
|
|
3147
|
+
const msg = String((err && err.message) || err)
|
|
3148
|
+
audit('web-attach', `open ${machine.host} failed: ${msg}`, 1, machine)
|
|
3149
|
+
return sendJson(res, 500, { ok: false, error: msg })
|
|
3150
|
+
}
|
|
3151
|
+
webAttaches.set(machineId, attach)
|
|
3152
|
+
audit('web-attach', `open ${machine.host} → 127.0.0.1:${attach.localPort}`, 0, machine)
|
|
3153
|
+
return sendJson(res, 200, { ok: true, attach: attach.describe() })
|
|
3154
|
+
}
|
|
3155
|
+
|
|
3156
|
+
// ── investigate: hand the problem to an agent with the built-in skill ──
|
|
3157
|
+
// The AI escape hatch. Started ONLY when the user asks for it: it costs
|
|
3158
|
+
// a model call, so it is never automatic — the deterministic probe and
|
|
3159
|
+
// install are the default path.
|
|
3160
|
+
if (action === 'investigate') {
|
|
3161
|
+
const machine = machines.find((m) => m.id === machineId)
|
|
3162
|
+
if (!machine) return sendJson(res, 404, { ok: false, error: `机器不存在:${machineId}` })
|
|
3163
|
+
const controller = ctx.get('sessionController')
|
|
3164
|
+
if (!controller || typeof controller.create !== 'function' || typeof controller.prompt !== 'function') {
|
|
3165
|
+
return sendJson(res, 400, {
|
|
3166
|
+
ok: false,
|
|
3167
|
+
error: '当前组合没有会话服务(sessionController),无法让 AI 排查。'
|
|
3168
|
+
+ '请改用「部署并验证」,或把排查信息复制到对话里。',
|
|
3169
|
+
})
|
|
3170
|
+
}
|
|
3171
|
+
const sessionId = `dsh-remote-deploy-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
|
|
3172
|
+
try {
|
|
3173
|
+
await controller.create({ sessionId, cwd: undefined })
|
|
3174
|
+
} catch (err) {
|
|
3175
|
+
// An id collision is the only tolerable failure here.
|
|
3176
|
+
if (!/conflict|exist/i.test(String((err && err.message) || err))) {
|
|
3177
|
+
return sendJson(res, 500, { ok: false, error: `创建排查会话失败:${String((err && err.message) || err)}` })
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
3180
|
+
const prompt = buildInvestigatePrompt({
|
|
3181
|
+
host: machine.host,
|
|
3182
|
+
user: machine.username,
|
|
3183
|
+
prefix: resolvePrefix({ home: '' }, { prefix: config.webInstallPrefix || '' }),
|
|
3184
|
+
findings: Array.isArray(body.findings) ? body.findings : [],
|
|
3185
|
+
facts: body.facts && typeof body.facts === 'object' ? body.facts : undefined,
|
|
3186
|
+
error: String(body.error || ''),
|
|
3187
|
+
})
|
|
3188
|
+
try {
|
|
3189
|
+
await controller.prompt({
|
|
3190
|
+
sessionId,
|
|
3191
|
+
mode: 'queue',
|
|
3192
|
+
content: [{ type: 'text', text: prompt }],
|
|
3193
|
+
})
|
|
3194
|
+
} catch (err) {
|
|
3195
|
+
return sendJson(res, 500, { ok: false, error: `提交排查任务失败:${String((err && err.message) || err)}` })
|
|
3196
|
+
}
|
|
3197
|
+
audit('web-deploy', `investigate ${machine.host} → session ${sessionId}`, 0, machine)
|
|
3198
|
+
return sendJson(res, 200, { ok: true, sessionId, skill: SKILL_NAME })
|
|
3199
|
+
}
|
|
3200
|
+
|
|
3201
|
+
if (action === 'close') {
|
|
3202
|
+
const attach = webAttaches.get(machineId)
|
|
3203
|
+
if (!attach) return sendJson(res, 200, { ok: true, closed: false, error: '该机器没有活动的远程界面' })
|
|
3204
|
+
const stopRemote = body.stopRemote === true
|
|
3205
|
+
// `close()` refuses to signal anything without a recorded PID, so a
|
|
3206
|
+
// tunnel onto the user's own running instance stays hands-off.
|
|
3207
|
+
const exec = (cmd) => poolForMachine(identityOf(machines.find((m) => m.id === machineId) || {})).exec(cmd, { timeoutMs: 10000 })
|
|
3208
|
+
await attach.close({ stopRemote, exec })
|
|
3209
|
+
webAttaches.delete(machineId)
|
|
3210
|
+
audit('web-attach', `close ${attach.cleanUrl}${stopRemote ? ' (remote stopped)' : ''}`, 0)
|
|
3211
|
+
return sendJson(res, 200, { ok: true, closed: true, stoppedRemote: stopRemote && !!attach.remotePid })
|
|
3212
|
+
}
|
|
3213
|
+
|
|
3214
|
+
return sendJson(res, 400, { ok: false, error: 'action must be open | close' })
|
|
3215
|
+
} catch (err) {
|
|
3216
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
3217
|
+
}
|
|
3218
|
+
},
|
|
3219
|
+
},
|
|
2780
3220
|
{
|
|
2781
3221
|
kind: 'exact',
|
|
2782
3222
|
path: '/dsh-remote/home',
|
|
@@ -2822,13 +3262,21 @@ export async function apply(ctx, config) {
|
|
|
2822
3262
|
// a link:/dev install must not be overwritten by an npm publish.
|
|
2823
3263
|
selfUpdateAllowed: isInstalledCopy(),
|
|
2824
3264
|
}
|
|
2825
|
-
const
|
|
2826
|
-
|
|
3265
|
+
const probe = await fetchLatestVersion()
|
|
3266
|
+
// 即使查不到最新版,也把**本地状态**照常返回(ok:true),只把 registry
|
|
3267
|
+
// 的失败原因放在 registryError 里。
|
|
3268
|
+
//
|
|
3269
|
+
// 为什么:原先 registry 失败时整条响应 ok:false,客户端遇到 !ok 就只设错误
|
|
3270
|
+
// 文案、不 setUpd,于是面板永远停在「版本信息加载中…」——看起来像卡死,
|
|
3271
|
+
// 而实际上本地版本、更新模式、是否允许自更新都是已知的,完全可以展示。
|
|
3272
|
+
if (probe.error) {
|
|
3273
|
+
return sendJson(res, 200, { ok: true, ...base, latest: null, updateAvailable: false, registryError: probe.error, registryReason: probe.reason })
|
|
3274
|
+
}
|
|
2827
3275
|
return sendJson(res, 200, {
|
|
2828
3276
|
ok: true,
|
|
2829
3277
|
...base,
|
|
2830
|
-
latest,
|
|
2831
|
-
updateAvailable: gtVersion(
|
|
3278
|
+
latest: probe.version,
|
|
3279
|
+
updateAvailable: gtVersion(probe.version, loaded),
|
|
2832
3280
|
})
|
|
2833
3281
|
},
|
|
2834
3282
|
},
|
|
@@ -2897,6 +3345,15 @@ export async function apply(ctx, config) {
|
|
|
2897
3345
|
|
|
2898
3346
|
registerHttpTransports(ctx, routes)
|
|
2899
3347
|
|
|
3348
|
+
// ── built-in deployment skill (issue #46 P3) ──────────────────────────────
|
|
3349
|
+
// Registered at runtime so the plugin stays a single self-contained package
|
|
3350
|
+
// (no writable $DSH_HOME/skills, no collision with the user's own skills).
|
|
3351
|
+
// Optional: a composition without `ctx.skills` keeps the deterministic deploy
|
|
3352
|
+
// path and merely loses the AI fallback.
|
|
3353
|
+
ctx.inject(['skills'], (skillCtx) => {
|
|
3354
|
+
registerDeploySkill(skillCtx)
|
|
3355
|
+
})
|
|
3356
|
+
|
|
2900
3357
|
// ── auto-update: check on load + on an interval; apply silently ───────────
|
|
2901
3358
|
// Failures are swallowed (never break the plugin). The persisted override
|
|
2902
3359
|
// (settings UI) wins over the profile-config default, which wins over
|
|
@@ -2904,9 +3361,20 @@ export async function apply(ctx, config) {
|
|
|
2904
3361
|
const rawMode = readUpdateMode() || config.updateMode || DEFAULT_UPDATE_MODE
|
|
2905
3362
|
const effectiveUpdateMode = ['manual', 'auto', 'off'].includes(rawMode) ? rawMode : DEFAULT_UPDATE_MODE
|
|
2906
3363
|
if (effectiveUpdateMode === 'auto') {
|
|
3364
|
+
// 限流/网络失败后的退避:命中后跳过接下来若干轮检查,避免在被 npm 限流
|
|
3365
|
+
// (HTTP 429)时反复重试把配额烧得更久。指数增长,上限 24 轮(配合默认
|
|
3366
|
+
// 6h 间隔即最多约 6 天,足以让限流窗口滑过且不会永久停摆)。
|
|
3367
|
+
let backoffRounds = 0
|
|
2907
3368
|
const checkAndApply = async () => {
|
|
2908
|
-
|
|
2909
|
-
|
|
3369
|
+
if (backoffRounds > 0) { backoffRounds -= 1; return }
|
|
3370
|
+
const probe = await fetchLatestVersion()
|
|
3371
|
+
if (probe.error) {
|
|
3372
|
+
// 限流与网络故障都要退避;成功一次即重置。
|
|
3373
|
+
backoffRounds = Math.min(backoffRounds === 0 ? 1 : backoffRounds * 2, 24)
|
|
3374
|
+
return
|
|
3375
|
+
}
|
|
3376
|
+
backoffRounds = 0
|
|
3377
|
+
const latest = probe.version
|
|
2910
3378
|
// The version in flight is whichever of "already on disk" / "already
|
|
2911
3379
|
// loaded" is newer. Pinning it once outside this closure (as v0.8.23 did)
|
|
2912
3380
|
// made every later interval re-download and re-apply the same tarball,
|