dsh-ssh-tunnel 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,156 @@
1
+ import { lstatSync, realpathSync } from 'node:fs'
2
+ import { dirname, isAbsolute, join, normalize, resolve as pathResolve } from 'node:path'
3
+
4
+ export const WORKSPACE_ROOT = '/workspace'
5
+
6
+ /**
7
+ * Local roots the path guard accepts, most specific first.
8
+ *
9
+ * The project workspace the user actually opened (`explicitRoot`) is the real
10
+ * boundary: on hosts whose workspace is a Windows drive path there is no
11
+ * `/workspace` at all, and a fixed root rejects every real local path — which
12
+ * breaks SFTP upload/download and the sidebar file browser. Relative local
13
+ * paths resolve against the first root, so an explicit project root wins over
14
+ * the `DSH_SSH_TUNNEL_WORKSPACE_ROOT` operator override and over the
15
+ * `/workspace` fallback.
16
+ *
17
+ * Read at call time so hosts and tests can set them after module load.
18
+ */
19
+ export function workspaceRoots(explicitRoot = '') {
20
+ const raw = []
21
+ if (explicitRoot && String(explicitRoot).trim()) raw.push(String(explicitRoot).trim())
22
+ const env = process.env.DSH_SSH_TUNNEL_WORKSPACE_ROOT
23
+ if (env && String(env).trim()) raw.push(String(env).trim())
24
+ if (!raw.length) raw.push(WORKSPACE_ROOT)
25
+ const roots = []
26
+ for (const r of raw) {
27
+ const key = normalizeProjectKey(r)
28
+ if (key && !roots.includes(key)) roots.push(key)
29
+ }
30
+ return roots
31
+ }
32
+
33
+ /** Primary local root: the base for resolving relative local paths. */
34
+ export function workspaceRoot(explicitRoot = '') {
35
+ return workspaceRoots(explicitRoot)[0]
36
+ }
37
+
38
+ /** Normalize a project path key (absolute, no trailing slash except root). */
39
+ export function normalizeProjectKey(raw) {
40
+ if (!raw || typeof raw !== 'string') return ''
41
+ let p = raw.trim()
42
+ if (!p) return ''
43
+ try {
44
+ p = pathResolve(p)
45
+ } catch {
46
+ return ''
47
+ }
48
+ p = normalize(p)
49
+ while (p.length > 1 && (p.endsWith('/') || p.endsWith('\\'))) {
50
+ const next = p.slice(0, -1)
51
+ if (/^[A-Za-z]:$/.test(next)) break
52
+ p = next
53
+ }
54
+ return p
55
+ }
56
+
57
+ /**
58
+ * Comparison form for containment checks: unified separators, and on Windows
59
+ * case-insensitive (Win32 paths are case-insensitive; keys are stored in
60
+ * platform-native form, so string equality alone would misjudge containment).
61
+ */
62
+ function comparablePath(p) {
63
+ const s = p.replace(/\\/g, '/')
64
+ return process.platform === 'win32' ? s.toLowerCase() : s
65
+ }
66
+
67
+ /**
68
+ * Whether abs is inside one of the allowed roots (lexical, after resolve).
69
+ * Does not follow symlinks — use constrainToWorkspace for FS operations.
70
+ */
71
+ export function isPathInsideRoots(absPath, roots) {
72
+ const abs = normalizeProjectKey(absPath)
73
+ if (!abs) return false
74
+ const list = (roots || []).map(normalizeProjectKey).filter(Boolean)
75
+ const cAbs = comparablePath(abs)
76
+ for (const root of list) {
77
+ const cRoot = comparablePath(root)
78
+ if (cAbs === cRoot) return true
79
+ // Roots may end with '/' in comparison form ('/', or a drive root like
80
+ // 'c:/'): strip trailing separators before appending one, so filesystem
81
+ // roots still match their children.
82
+ const base = cRoot.replace(/\/+$/, '')
83
+ const prefix = base === '' ? '/' : base + '/'
84
+ if (cAbs.startsWith(prefix)) return true
85
+ }
86
+ return false
87
+ }
88
+
89
+ function realExisting(abs) {
90
+ let probe = abs
91
+ while (true) {
92
+ try {
93
+ return realpathSync(probe)
94
+ } catch (e) {
95
+ if (e && e.code === 'ENOENT') {
96
+ const parent = dirname(probe)
97
+ if (!parent || parent === probe) return ''
98
+ probe = parent
99
+ continue
100
+ }
101
+ throw e
102
+ }
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Resolve a user-supplied local path and reject anything that is not under one
108
+ * of the allowed local roots (`workspaceRoots(root)`, i.e. the project
109
+ * workspace the caller passes plus any operator override) both lexically and
110
+ * after following existing symlinks.
111
+ *
112
+ * `{ forUnlink: true }` allows deleting a symlink whose *lexical* path is
113
+ * under an allowed root without following it (unlink the link, never the
114
+ * target).
115
+ */
116
+ export function constrainToWorkspace(inputPath, { forUnlink = false, root = '' } = {}) {
117
+ if (inputPath == null || String(inputPath).trim() === '') {
118
+ throw new Error('local path required')
119
+ }
120
+ const roots = workspaceRoots(root)
121
+ let abs = String(inputPath)
122
+ if (!isAbsolute(abs)) abs = join(roots[0], abs)
123
+ abs = pathResolve(abs)
124
+ abs = normalize(abs)
125
+ if (abs.length > 1 && (abs.endsWith('/') || abs.endsWith('\\'))) abs = abs.slice(0, -1)
126
+ if (!isPathInsideRoots(abs, roots)) {
127
+ throw new Error('local path must be under ' + roots.join(' or '))
128
+ }
129
+ if (forUnlink) {
130
+ try {
131
+ const st = lstatSync(abs)
132
+ if (st.isSymbolicLink()) return abs
133
+ } catch (e) {
134
+ if (!(e && e.code === 'ENOENT')) throw e
135
+ }
136
+ }
137
+ const real = realExisting(abs)
138
+ if (real && !isPathInsideRoots(real, roots)) {
139
+ throw new Error('local path escapes ' + roots.join(' or ') + ' via symlink')
140
+ }
141
+ return abs
142
+ }
143
+
144
+ export function joinUnderRoot(root, relOrAbs) {
145
+ const roots = workspaceRoots()
146
+ const r = normalizeProjectKey(root) || roots[0]
147
+ let abs = String(relOrAbs || r)
148
+ if (!isAbsolute(abs)) abs = join(r, abs)
149
+ abs = pathResolve(abs)
150
+ if (!isPathInsideRoots(abs, [...roots, r])) {
151
+ throw new Error('path outside allowed roots')
152
+ }
153
+ return constrainToWorkspace(abs)
154
+ }
155
+
156
+ export { isAbsolute, join, normalize, pathResolve }
@@ -0,0 +1,41 @@
1
+ import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ /**
5
+ * Missing file → fallback. Corrupt JSON → throw (never treat as empty store).
6
+ */
7
+ export function readJsonFile(path, fallback) {
8
+ if (!existsSync(path)) return fallback
9
+ let raw
10
+ try {
11
+ raw = readFileSync(path, 'utf8')
12
+ } catch (e) {
13
+ throw new Error('cannot read ' + path + ': ' + (e && e.message ? e.message : e))
14
+ }
15
+ try {
16
+ return JSON.parse(raw)
17
+ } catch {
18
+ throw new Error('corrupt json: ' + path)
19
+ }
20
+ }
21
+
22
+ export function writeJsonAtomic(path, data, mode = 0o600) {
23
+ const tmp = path + '.tmp'
24
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode })
25
+ try { chmodSync(tmp, mode) } catch {}
26
+ renameSync(tmp, path)
27
+ try { chmodSync(path, mode) } catch {}
28
+ }
29
+
30
+ export function migrateMarkerPath(dataDir) {
31
+ return join(dataDir, '.migrated-draft')
32
+ }
33
+
34
+ export function hostEndpointKey(host, port, username) {
35
+ return `${String(username || '').trim()}@${String(host || '').trim()}:${Number(port) || 22}`
36
+ }
37
+
38
+ export function findExistingHostByEndpoint(hosts, host, port, username) {
39
+ const key = hostEndpointKey(host, port, username)
40
+ return (hosts || []).find((h) => hostEndpointKey(h.host, h.port, h.username) === key)
41
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Result-delivery authorization gates for SSH session operations (security).
3
+ * Pure helpers kept in shared/ so they are testable without an ssh2 install.
4
+ *
5
+ * Background (v0.4.3): a host grant can be revoked while an SSHManager
6
+ * operation is already in flight. Closing the session stops the transport,
7
+ * but a command can complete and a file can be pulled in the same tick as the
8
+ * revocation. These gates are the *second* boundary: even if the transport
9
+ * work finished, nothing from the revoked host may be delivered to the agent
10
+ * (and no downloaded file may remain on disk).
11
+ *
12
+ * v0.4.4: missing predicate is fail-closed (never a silent no-op). HTTP and
13
+ * write actions re-check the same gate.
14
+ */
15
+ import { unlinkSync } from 'node:fs'
16
+
17
+ export function assertSessionStillAuthorized(session, isHostAuthorized) {
18
+ if (!session) throw new Error('SSH session missing')
19
+ if (typeof isHostAuthorized !== 'function') {
20
+ throw new Error('SSH authorization predicate required')
21
+ }
22
+ if (session.projectPathKey && !isHostAuthorized(session.projectPathKey, session.hostId)) {
23
+ throw new Error('SSH session host is not authorized for the current project.')
24
+ }
25
+ }
26
+
27
+ export function discardRevokedLocalFile(session, localPath, isHostAuthorized) {
28
+ if (!session) return false
29
+ if (typeof isHostAuthorized !== 'function') {
30
+ throw new Error('SSH authorization predicate required')
31
+ }
32
+ if (session.projectPathKey && !isHostAuthorized(session.projectPathKey, session.hostId)) {
33
+ try {
34
+ unlinkSync(localPath)
35
+ } catch {}
36
+ return true
37
+ }
38
+ return false
39
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Pure SSH session policy: timeouts, status invariant, reuse vs tombstone.
3
+ * No ssh2, no I/O.
4
+ */
5
+
6
+ export const SSH_KEEPALIVE_INTERVAL_MS = 30_000
7
+ export const SSH_KEEPALIVE_COUNT_MAX = 3
8
+ export const SSH_READY_TIMEOUT_MS = 20_000
9
+ export const SSH_RECONNECT_MAX_ATTEMPTS = 3
10
+ export const SSH_RECONNECT_DELAYS_MS = [5_000, 10_000, 10_000]
11
+ export const SSH_RECONNECT_ATTEMPT_TIMEOUT_MS = 20_000
12
+ export const SSH_TOOL_TIMEOUT_MS = 300_000
13
+ export const SSH_TIMEOUT_MIN_MS = 1_000
14
+ export const SSH_TIMEOUT_MAX_MS = 300_000
15
+ export const SSH_EXEC_DEFAULT_TIMEOUT_MS = 30_000
16
+ export const SSH_SFTP_META_TIMEOUT_MS = 30_000
17
+ export const SSH_SFTP_TRANSFER_TIMEOUT_MS = 120_000
18
+ export const SSH_SHELL_OPEN_TIMEOUT_MS = 10_000
19
+
20
+ export const SSH_STATUS_CONNECTED = 'connected'
21
+ export const SSH_STATUS_RECONNECTING = 'reconnecting'
22
+ export const SSH_STATUS_DISCONNECTED = 'disconnected'
23
+
24
+ export function clampTimeoutMs(value, fallback) {
25
+ const n = Number(value)
26
+ const base = Number.isFinite(n) && n > 0 ? n : fallback
27
+ return Math.min(SSH_TIMEOUT_MAX_MS, Math.max(SSH_TIMEOUT_MIN_MS, Math.floor(base)))
28
+ }
29
+
30
+ export function defaultTimeoutMsForAction(action) {
31
+ const a = String(action || '')
32
+ if (a === 'exec') return SSH_EXEC_DEFAULT_TIMEOUT_MS
33
+ if (a === 'sftp_read_text' || a === 'sftp_write_text' || a === 'sftp_upload' || a === 'sftp_download') {
34
+ return SSH_SFTP_TRANSFER_TIMEOUT_MS
35
+ }
36
+ if (a.startsWith('sftp_')) return SSH_SFTP_META_TIMEOUT_MS
37
+ if (a === 'read_session' || a === 'send_input' || a === 'resize_session') return SSH_SHELL_OPEN_TIMEOUT_MS
38
+ return SSH_EXEC_DEFAULT_TIMEOUT_MS
39
+ }
40
+
41
+ export function isLiveStatus(status, running) {
42
+ return status === SSH_STATUS_CONNECTED && running === true
43
+ }
44
+
45
+ export function assertStatusInvariant(rec) {
46
+ const running = rec.status === SSH_STATUS_CONNECTED
47
+ return { ...rec, running }
48
+ }
49
+
50
+ export function findReusableLive(sessions, { projectPathKey, hostId, needsSftp }) {
51
+ return sessions
52
+ .filter((s) => {
53
+ if (s.projectPathKey !== projectPathKey || s.hostId !== hostId) return false
54
+ if (!isLiveStatus(s.status, s.running)) return false
55
+ if (needsSftp && !s.sftpEnabled) return false
56
+ return true
57
+ })
58
+ .sort((a, b) => a.createdAt - b.createdAt || String(a.id).localeCompare(String(b.id)))[0]
59
+ }
60
+
61
+ export function pickOldestTombstone(sessions, { projectPathKey, hostId, needsSftp }) {
62
+ return sessions
63
+ .filter((s) => {
64
+ if (s.projectPathKey !== projectPathKey || s.hostId !== hostId) return false
65
+ if (s.status !== SSH_STATUS_DISCONNECTED || s.running) return false
66
+ if (needsSftp && !s.sftpEnabled) return false
67
+ return true
68
+ })
69
+ .sort((a, b) => a.createdAt - b.createdAt || String(a.id).localeCompare(String(b.id)))[0]
70
+ }
71
+
72
+ export function disconnectedSessionError() {
73
+ return 'SSH session is disconnected. Reconnect in the SSH Tunnel tab, or omit session_id to reuse_or_create.'
74
+ }
75
+
76
+ export function kbiReconnectError() {
77
+ return 'keyboard-interactive auth is not supported; edit the host to password or private key.'
78
+ }
79
+
80
+ export function kbiUnsupportedError() {
81
+ return 'keyboard-interactive auth is not supported; edit the host to password or private key.'
82
+ }
83
+
84
+ export function withTimeout(work, ms, { signal, onTimeout } = {}) {
85
+ const timeoutMs = clampTimeoutMs(ms, SSH_EXEC_DEFAULT_TIMEOUT_MS)
86
+ const run = typeof work === 'function' ? work() : work
87
+ return new Promise((resolve, reject) => {
88
+ let settled = false
89
+ const finish = (kind, value) => {
90
+ if (settled) return
91
+ settled = true
92
+ clearTimeout(timer)
93
+ if (signal) signal.removeEventListener('abort', onAbort)
94
+ if (kind === 'ok') resolve(value)
95
+ else reject(value)
96
+ }
97
+ const onAbort = () => {
98
+ try {
99
+ onTimeout?.()
100
+ } catch {}
101
+ const err = new Error('Cancelled')
102
+ err.aborted = true
103
+ finish('err', err)
104
+ }
105
+ const timer = setTimeout(() => {
106
+ try {
107
+ onTimeout?.()
108
+ } catch {}
109
+ const err = new Error('timeout')
110
+ err.timedOut = true
111
+ finish('err', err)
112
+ }, timeoutMs)
113
+ if (signal) {
114
+ if (signal.aborted) {
115
+ onAbort()
116
+ return
117
+ }
118
+ signal.addEventListener('abort', onAbort, { once: true })
119
+ }
120
+ Promise.resolve(run).then(
121
+ (v) => finish('ok', v),
122
+ (e) => finish('err', e),
123
+ )
124
+ })
125
+ }
@@ -0,0 +1,10 @@
1
+ /** Incremental shell output slice for polling clients. */
2
+ export function chunkFromBuffer(fullText, since) {
3
+ const full = fullText == null ? '' : String(fullText)
4
+ const start = Math.max(0, Math.min(Number(since) || 0, full.length))
5
+ return {
6
+ chunk: full.slice(start),
7
+ length: full.length,
8
+ since: start,
9
+ }
10
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Same-origin static assets for the browser client.
3
+ *
4
+ * `dsh-ssh-tunnel/lib/client.js` is a raw browser bundle executed by the DSH
5
+ * client module system; its `require()` only answers platform seeds and
6
+ * registered plugin rows, so `@xterm/xterm` is NOT resolvable there. Instead
7
+ * of the removed jsDelivr fallback we serve the UMD builds from our own web
8
+ * route (`/dsh-ssh-tunnel/vendor/*`) and load them with plain <script>/<link>
9
+ * tags. This module is the host-side allowlist + resolver (Node only).
10
+ */
11
+
12
+ import { createRequire } from 'node:module'
13
+
14
+ const requireHost = createRequire(import.meta.url)
15
+
16
+ /** name -> { spec (resolved from this plugin's deps), content-type } */
17
+ export const VENDOR_ASSETS = {
18
+ 'xterm.js': {
19
+ spec: '@xterm/xterm/lib/xterm.js',
20
+ type: 'text/javascript; charset=utf-8',
21
+ },
22
+ 'xterm.css': {
23
+ spec: '@xterm/xterm/css/xterm.css',
24
+ type: 'text/css; charset=utf-8',
25
+ },
26
+ 'addon-fit.js': {
27
+ spec: '@xterm/addon-fit/lib/addon-fit.js',
28
+ type: 'text/javascript; charset=utf-8',
29
+ },
30
+ }
31
+
32
+ /**
33
+ * Resolve an allowlisted vendor asset to an absolute file path.
34
+ * Anything not in the allowlist throws — callers must treat that as 404 and
35
+ * must not interpolate user input into the file lookup.
36
+ * @param {string} name exact basename from the allowlist
37
+ * @returns {{ path: string, type: string }}
38
+ */
39
+ export function resolveVendorAsset(name) {
40
+ const entry = VENDOR_ASSETS[name]
41
+ if (!entry) {
42
+ throw new Error(`dsh-ssh-tunnel: vendor asset not allowed: ${String(name)}`)
43
+ }
44
+ return { path: requireHost.resolve(entry.spec), type: entry.type }
45
+ }
46
+
47
+ /** @param {string} name checked against the allowlist only */
48
+ export function isVendorAssetAllowed(name) {
49
+ return Object.prototype.hasOwnProperty.call(VENDOR_ASSETS, name)
50
+ }
package/package.json ADDED
@@ -0,0 +1,84 @@
1
+ {
2
+ "name": "dsh-ssh-tunnel",
3
+ "version": "1.0.0",
4
+ "description": "Multi-host SSH tunnel and SSHManager tool for DeepSeek Harness (project-scoped grants)",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "thirsty5034",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/OMSociety/dsh-ssh-tunnel.git"
11
+ },
12
+ "homepage": "https://github.com/OMSociety/dsh-ssh-tunnel#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/OMSociety/dsh-ssh-tunnel/issues"
15
+ },
16
+ "contributors": [
17
+ {
18
+ "name": "OMSociety",
19
+ "url": "https://github.com/OMSociety"
20
+ }
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "keywords": [
26
+ "dsh",
27
+ "dsh-plugin",
28
+ "deepseek-harness",
29
+ "ssh",
30
+ "sftp",
31
+ "cordis",
32
+ "better-sidebar",
33
+ "dsh-better-sidebar"
34
+ ],
35
+ "engines": {
36
+ "node": ">=20",
37
+ "dsh": ">=0.1.7-rc.2 <0.3.0-0"
38
+ },
39
+ "main": "lib/index.js",
40
+ "exports": {
41
+ ".": "./lib/index.js",
42
+ "./client": "./lib/client.js",
43
+ "./package.json": "./package.json"
44
+ },
45
+ "scripts": {
46
+ "test": "node scripts/smoke-test.mjs",
47
+ "check": "node --check lib/index.js && node scripts/smoke-test.mjs"
48
+ },
49
+ "dsh": {
50
+ "manifestVersion": 1,
51
+ "bundle": {
52
+ "patch": "./cordis.patch.yml"
53
+ },
54
+ "client": {
55
+ "inject": [
56
+ "@deepseek-ai/dsh-client-locale"
57
+ ],
58
+ "platform": "web"
59
+ }
60
+ },
61
+ "dependencies": {
62
+ "@xterm/addon-fit": "0.11.0",
63
+ "@xterm/xterm": "5.5.0",
64
+ "ssh2": "^1.17.0"
65
+ },
66
+ "peerDependencies": {
67
+ "@deepseek-ai/cordis": "^4.0.4",
68
+ "@deepseek-ai/dsh-client-locale": ">=0.1.7-rc.2 <0.3.0-0",
69
+ "dsh-better-sidebar": ">=0.12.0"
70
+ },
71
+ "files": [
72
+ "CHANGELOG.md",
73
+ "LICENSE",
74
+ "README.md",
75
+ "README_en.md",
76
+ "cordis.patch.yml",
77
+ "lib",
78
+ "scripts/install.sh",
79
+ "scripts/install.ps1",
80
+ "scripts/portal-probe.mjs",
81
+ "scripts/smoke-test.mjs",
82
+ "scripts/sync-to-dsh.sh"
83
+ ]
84
+ }
@@ -0,0 +1,148 @@
1
+ # =============================================================================
2
+ # dsh-ssh-tunnel one-click install (Windows PowerShell 5.1+ / pwsh)
3
+ #
4
+ # Usage:
5
+ # irm https://raw.githubusercontent.com/OMSociety/dsh-ssh-tunnel/main/scripts/install.ps1 | iex
6
+ # & ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/OMSociety/dsh-ssh-tunnel/main/scripts/install.ps1'))) -From github -Restart
7
+ # =============================================================================
8
+ param(
9
+ [string]$Version = '',
10
+ [ValidateSet('github', 'npm')]
11
+ [string]$From = 'github',
12
+ [switch]$Restart,
13
+ [switch]$DryRun
14
+ )
15
+
16
+ $PKG = 'dsh-ssh-tunnel'
17
+ $PLUGIN_ID = 'ssh-tunnel'
18
+ $GITHUB_REPO = if ($env:GITHUB_REPO) { $env:GITHUB_REPO } else { 'OMSociety/dsh-ssh-tunnel' }
19
+ $REGISTRY = if ($env:REGISTRY) { $env:REGISTRY } else { 'https://registry.npmjs.org' }
20
+
21
+ if ($env:DSH_HOME) { $DSH_HOME = $env:DSH_HOME }
22
+ elseif ($env:USERPROFILE) { $DSH_HOME = Join-Path $env:USERPROFILE '.dsh' }
23
+ else { $DSH_HOME = Join-Path $HOME '.dsh' }
24
+
25
+ $PROFILE_DIR = Join-Path $DSH_HOME 'profiles\web'
26
+ $WS_YML = Join-Path $PROFILE_DIR 'pnpm-workspace.yaml'
27
+ $PATCH_YML = Join-Path $PROFILE_DIR 'cordis.patch.yml'
28
+
29
+ function Say([string]$m) { Write-Host "[install] $m" -ForegroundColor Green }
30
+ function Warn([string]$m) { Write-Host "[warn] $m" -ForegroundColor Yellow }
31
+ function Die([string]$m) { Write-Host "[error] $m" -ForegroundColor Red; exit 1 }
32
+
33
+ function Get-DshCli {
34
+ if ($env:DSH_CMD) { return $env:DSH_CMD }
35
+ if (Get-Command dsh -ErrorAction SilentlyContinue) { return 'dsh' }
36
+ if (Get-Command npx -ErrorAction SilentlyContinue) { return 'npx' }
37
+ return $null
38
+ }
39
+
40
+ function Resolve-AddSpec {
41
+ if ($From -eq 'npm') {
42
+ $given = if ([string]::IsNullOrWhiteSpace($Version)) { 'latest' } else { $Version }
43
+ if ($given -eq 'latest') {
44
+ foreach ($tool in @('npm', 'pnpm')) {
45
+ if (Get-Command $tool -ErrorAction SilentlyContinue) {
46
+ $v = (& $tool view $PKG version "--registry=$REGISTRY" 2>$null | Select-Object -Last 1)
47
+ if ($v) { return "$PKG@$([string]$v.Trim())" }
48
+ }
49
+ }
50
+ return "$PKG@latest"
51
+ }
52
+ return "$PKG@$given"
53
+ }
54
+ if ([string]::IsNullOrWhiteSpace($Version)) { return "$PKG@github:$GITHUB_REPO" }
55
+ return "$PKG@github:$GITHUB_REPO#$Version"
56
+ }
57
+
58
+ if (-not (Get-Command node -ErrorAction SilentlyContinue)) { Die 'Node.js not found (need >= 20).' }
59
+ $NodeMajor = [int](& node -p "process.versions.node.split('.')[0]")
60
+ if ($NodeMajor -lt 20) { Die "Node.js $(& node -v) is too old (need >= 20)." }
61
+ if (-not (Test-Path $PROFILE_DIR)) { Die "Profile dir missing: $PROFILE_DIR" }
62
+ if (-not (Test-Path $WS_YML)) { Die "Missing $WS_YML" }
63
+
64
+ $ADD_SPEC = Resolve-AddSpec
65
+ $CLI = Get-DshCli
66
+ if (-not $CLI) { Die 'dsh/npx not found. Install DSH or set DSH_CMD.' }
67
+
68
+ Say "Target: $CLI plugin --profile web add $ADD_SPEC"
69
+ Say "Source: $From ($GITHUB_REPO)"
70
+
71
+ if ($DryRun) {
72
+ Say "[dry-run] exclude $PKG from minimumReleaseAge; add $ADD_SPEC; verify bundles; strip manual $PLUGIN_ID mount"
73
+ exit 0
74
+ }
75
+
76
+ $wsScript = @'
77
+ const fs = require("fs");
78
+ const p = process.argv[1];
79
+ const pkg = process.argv[2];
80
+ let t = fs.readFileSync(p, "utf8");
81
+ const before = t;
82
+ if (!new RegExp("^\\s*-\\s+" + pkg.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&") + "\\s*$", "m").test(t)) {
83
+ if (/^\s*minimumReleaseAgeExclude:\s*$/m.test(t)) {
84
+ t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - " + pkg);
85
+ } else {
86
+ t += "\nminimumReleaseAgeExclude:\n - " + pkg + "\n";
87
+ }
88
+ }
89
+ if (t !== before) fs.writeFileSync(p, t);
90
+ console.log(t === before ? "unchanged" : "updated");
91
+ '@
92
+ $wsResult = & node -e $wsScript $WS_YML $PKG
93
+ if ($wsResult -eq 'updated') { Say "Updated $WS_YML minimumReleaseAgeExclude" } else { Say 'workspace ready' }
94
+
95
+ Say "Running plugin add..."
96
+ if ($CLI -eq 'npx') {
97
+ & npx -y --package @deepseek-ai/dsh dsh plugin --profile web add $ADD_SPEC
98
+ } else {
99
+ & $CLI plugin --profile web add $ADD_SPEC
100
+ }
101
+ if ($LASTEXITCODE -ne 0) { Die 'dsh plugin add failed' }
102
+
103
+ $check = @'
104
+ const fs = require("fs");
105
+ const p = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
106
+ const bundles = p.dsh?.profile?.bundles ?? [];
107
+ process.exit(bundles.includes(process.argv[2]) ? 0 : 1);
108
+ '@
109
+ & node -e $check (Join-Path $PROFILE_DIR 'package.json') $PKG
110
+ if ($LASTEXITCODE -ne 0) { Die "$PKG missing from dsh.profile.bundles" }
111
+ Say "bundle registered: $PKG"
112
+
113
+ if (Test-Path $PATCH_YML) {
114
+ $strip = @'
115
+ const fs = require("fs");
116
+ const p = process.argv[1];
117
+ const id = process.argv[2];
118
+ const lines = fs.readFileSync(p, "utf8").split("\n");
119
+ const out = [];
120
+ let i = 0, removed = false;
121
+ const idRe = new RegExp("id:\\s*" + id.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&") + "\\b");
122
+ while (i < lines.length) {
123
+ const line = lines[i];
124
+ if (/^[ \t]*- insert:\s*$/.test(line)) {
125
+ const block = [line];
126
+ let j = i + 1;
127
+ while (j < lines.length && lines[j].trim() !== "" && !/^-\s/.test(lines[j])) { block.push(lines[j]); j++; }
128
+ if (block.some((l) => idRe.test(l))) {
129
+ while (out.length && /^[ \t]*#/.test(out[out.length - 1])) out.pop();
130
+ i = j; removed = true; continue;
131
+ }
132
+ }
133
+ out.push(line); i++;
134
+ }
135
+ if (!removed) console.log("none");
136
+ else { fs.writeFileSync(p, out.join("\n").replace(/\n{3,}/g, "\n\n")); console.log("removed"); }
137
+ '@
138
+ $mr = & node -e $strip $PATCH_YML $PLUGIN_ID
139
+ if ($mr -eq 'removed') { Say "Removed manual $PLUGIN_ID mount from cordis.patch.yml" }
140
+ }
141
+
142
+ Say "Done: $ADD_SPEC"
143
+ if ($Restart) {
144
+ if (Get-Command pm2 -ErrorAction SilentlyContinue) { pm2 restart dsh-web }
145
+ else { Warn 'pm2 not found; restart DSH web manually' }
146
+ } else {
147
+ Say 'Next: restart DSH web and hard-refresh the browser.'
148
+ }