dsh-remote-tunnel-easy 1.4.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.
package/lib/update.js ADDED
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Self-update support for the dsh-remote-tunnel-easy bundle.
3
+ *
4
+ * The Settings card gets an "Update" button that upgrades the installed
5
+ * package to the latest registry version without leaving the UI. This module
6
+ * owns the pure, testable parts — locate the owning profile directory, read
7
+ * its dependency spec, compare versions, and run `pnpm add` inside it — while
8
+ * `index.js` owns the loopback route and in-flight mutex. It deliberately
9
+ * runs the SAME operation the CLI recommends (`dsh plugin --profile <name> add
10
+ * dsh-remote-tunnel-easy@latest`) rather than inventing a second update path.
11
+ *
12
+ * `pnpm add <name>@latest` (not `pnpm update`) is the correct verb: a fixed
13
+ * dependency was recorded with a caret range, and `pnpm update` only moves
14
+ * within that range, so it could never reach a new minor/major. `pnpm add
15
+ * name@latest` rewrites the spec and installs the newest release. The
16
+ * `dsh`-side reconciliation is a no-op for an already-registered bundle, so
17
+ * running pnpm directly is equivalent.
18
+ *
19
+ * A live update does NOT replace the code already loaded into this process:
20
+ * the running tunnel, bridge, and card keep the old version until `dsh web`
21
+ * restarts. The card reports that honestly after a successful install.
22
+ */
23
+
24
+ import { readFileSync, readdirSync, realpathSync } from 'node:fs'
25
+ import { homedir } from 'node:os'
26
+ import { join, sep } from 'node:path'
27
+ import { fileURLToPath } from 'node:url'
28
+ import { spawn } from 'node:child_process'
29
+
30
+ /** Own package name — the dependency key in the profile manifest and on the registry. */
31
+ export const PACKAGE_NAME = 'dsh-remote-tunnel-easy'
32
+
33
+ const REGISTRY_BASE = 'https://registry.npmjs.org'
34
+ const DSH_HOME_DIR = '.dsh'
35
+ const PROFILES_DIR = 'profiles'
36
+
37
+ /** Largest chunk of pnpm output kept for an error message, in code units. */
38
+ const MAX_OUTPUT = 4000
39
+
40
+ /** Expand `~`, `~/x`, `~\x` against the OS home; any other value passes through. */
41
+ function expandTilde(path) {
42
+ if (path === '~') return homedir()
43
+ if (path.startsWith('~/') || path.startsWith('~\\')) return join(homedir(), path.slice(2))
44
+ return path
45
+ }
46
+
47
+ /**
48
+ * Resolve the single-root harness home the same way the boot loader does:
49
+ * `$DSH_HOME` when set and non-blank, otherwise `~/.dsh`. Reimplemented (not
50
+ * imported) so the bundle keeps its zero-dependency install.
51
+ * @returns absolute harness home.
52
+ */
53
+ export function dshHome() {
54
+ const configured = process.env.DSH_HOME
55
+ return configured !== undefined && configured.trim().length > 0
56
+ ? join(expandTilde(configured))
57
+ : join(homedir(), DSH_HOME_DIR)
58
+ }
59
+
60
+ /** Canonical on-disk path of this module (symlinks resolved), or undefined. */
61
+ function ownRealPath() {
62
+ try {
63
+ return realpathSync.native(fileURLToPath(import.meta.url))
64
+ } catch {
65
+ return undefined
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Locate the profile directory that owns this install by scanning
71
+ * `$DSH_HOME/profiles/*` for a `node_modules/dsh-remote-tunnel-easy` whose
72
+ * canonical target contains this module's own canonical path. This matches
73
+ * every real install shape — a registry/copy install, pnpm's `.pnpm` virtual
74
+ * store symlink, and a `link:`/`file:` checkout symlink — without depending
75
+ * on `import.meta.url` still carrying the un-resolved node_modules spelling.
76
+ * @param from - own canonical path (defaults to this module's real path).
77
+ * @returns the owning profile directory, or undefined when none matches.
78
+ */
79
+ export function locateProfileDir(from = ownRealPath()) {
80
+ if (from === undefined) return undefined
81
+ let entries
82
+ try {
83
+ entries = readdirSync(join(dshHome(), PROFILES_DIR), { withFileTypes: true })
84
+ } catch {
85
+ return undefined
86
+ }
87
+ for (const entry of entries) {
88
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
89
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue
90
+ const dir = join(dshHome(), PROFILES_DIR, entry.name)
91
+ let target
92
+ try {
93
+ target = realpathSync.native(join(dir, 'node_modules', PACKAGE_NAME))
94
+ } catch {
95
+ continue
96
+ }
97
+ if (from === target || (from.length > target.length && from.startsWith(target + sep))) return dir
98
+ }
99
+ return undefined
100
+ }
101
+
102
+ /** The dependency spec recorded for this package in `profileDir`, or undefined. */
103
+ export function dependencySpec(profileDir) {
104
+ if (profileDir === undefined) return undefined
105
+ try {
106
+ const manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'))
107
+ const spec = manifest?.dependencies?.[PACKAGE_NAME]
108
+ return typeof spec === 'string' ? spec : undefined
109
+ } catch {
110
+ return undefined
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Classify a dependency spec: `local` for the non-registry forms
116
+ * (`link:`/`file:`/`workspace:`/`portal:`, git URLs, relative paths), and
117
+ * `registry` for a bare name or semver range (which is everything else).
118
+ * @returns 'local' | 'registry' | undefined.
119
+ */
120
+ export function classifySpec(spec) {
121
+ if (spec === undefined) return undefined
122
+ const value = spec.trim()
123
+ if (/^(?:link|file|workspace|portal):/.test(value)) return 'local'
124
+ if (/^(?:git\+|https?:\/\/|git:|github:|\.{1,2}(?:[/\\]|$))/.test(value)) return 'local'
125
+ return 'registry'
126
+ }
127
+
128
+ /** Read this package's own `version` from package.json, or undefined. */
129
+ export function currentVersion() {
130
+ try {
131
+ const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
132
+ return typeof manifest.version === 'string' ? manifest.version : undefined
133
+ } catch {
134
+ return undefined
135
+ }
136
+ }
137
+
138
+ /** Parse a `v`-prefixed dotted semver into `[major, minor, patch]`, or undefined. */
139
+ function parseVersion(version) {
140
+ const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(version).trim())
141
+ return match === null ? undefined : [Number(match[1]), Number(match[2]), Number(match[3])]
142
+ }
143
+
144
+ /**
145
+ * Compare two dotted semver strings on major, minor, patch.
146
+ * @returns -1 | 0 | 1, or undefined when either side is unparseable.
147
+ */
148
+ export function compareVersions(a, b) {
149
+ const left = parseVersion(a)
150
+ const right = parseVersion(b)
151
+ if (left === undefined || right === undefined) return undefined
152
+ for (let i = 0; i < 3; i++) {
153
+ if (left[i] !== right[i]) return left[i] > right[i] ? 1 : -1
154
+ }
155
+ return 0
156
+ }
157
+
158
+ /** Whether `latest` is strictly newer than `current` (unparseable compares false). */
159
+ export function isNewer(latest, current) {
160
+ const order = compareVersions(latest, current)
161
+ return order !== undefined && order > 0
162
+ }
163
+
164
+ /**
165
+ * Fetch the registry's current `dist-tags.latest` version for this package.
166
+ * Uses the abridged `/<name>/latest` endpoint, which returns only the latest
167
+ * version object. Non-2xx, a missing `version`, or a timeout all throw.
168
+ * @param registryBase - origin (defaults to the public npm registry).
169
+ * @returns the latest published version string.
170
+ */
171
+ export async function fetchLatestVersion(registryBase = REGISTRY_BASE) {
172
+ const response = await fetch(`${registryBase}/${PACKAGE_NAME}/latest`, {
173
+ headers: { accept: 'application/json' },
174
+ signal: AbortSignal.timeout(10_000),
175
+ })
176
+ if (!response.ok) throw new Error(`registry ${PACKAGE_NAME}/latest returned HTTP ${response.status}`)
177
+ const body = await response.json()
178
+ if (typeof body?.version !== 'string') throw new Error(`registry ${PACKAGE_NAME}/latest has no version field`)
179
+ return body.version
180
+ }
181
+
182
+ /**
183
+ * Run `pnpm add <spec>` inside `profileDir` — the same operation the CLI
184
+ * performs for `dsh plugin --profile <name> add ...`. Streams stdout/stderr
185
+ * into bounded buffers so failures surface the useful tail, and enforces a
186
+ * timeout so a hung install cannot wedge the in-flight mutex forever.
187
+ * @param profileDir - the profile directory pnpm runs in.
188
+ * @param spec - package@range to install (defaults to `name@latest`).
189
+ * @param options.timeoutMs - kill the child after this many ms.
190
+ * @returns {Promise<{ ok: true, output: string }>}; rejects with the tail.
191
+ */
192
+ export function runPnpmAdd(profileDir, spec = `${PACKAGE_NAME}@latest`, { timeoutMs = 300_000 } = {}) {
193
+ return new Promise((resolve, reject) => {
194
+ const child = spawn('pnpm', ['add', spec], {
195
+ cwd: profileDir,
196
+ shell: process.platform === 'win32',
197
+ windowsHide: true,
198
+ stdio: ['ignore', 'pipe', 'pipe'],
199
+ })
200
+ let stdout = ''
201
+ let stderr = ''
202
+ // Bound the retained output regardless of total stream size.
203
+ const keep = (buffer, chunk) => buffer.length < MAX_OUTPUT ? buffer + chunk.toString() : buffer
204
+ child.stdout?.on('data', (chunk) => { stdout = keep(stdout, chunk) })
205
+ child.stderr?.on('data', (chunk) => { stderr = keep(stderr, chunk) })
206
+ const timer = setTimeout(() => child.kill(), timeoutMs)
207
+ child.on('error', (error) => {
208
+ clearTimeout(timer)
209
+ reject(error)
210
+ })
211
+ child.on('close', (code) => {
212
+ clearTimeout(timer)
213
+ if (code === 0) {
214
+ resolve({ ok: true, output: stdout })
215
+ return
216
+ }
217
+ const detail = (stderr || stdout || `pnpm exited with code ${String(code)}`).trim()
218
+ reject(new Error(detail.slice(0, MAX_OUTPUT)))
219
+ })
220
+ })
221
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "dsh-remote-tunnel-easy",
3
+ "version": "1.4.0",
4
+ "description": "Remote hassle-free access for DeepSeek Harness: a cloudflared quick tunnel plus a scannable QR code and settings shown in the Settings → Plugins page. Zero runtime dependencies, no database, no Firebase.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./client": "./lib/client.js",
10
+ "./cloudflared": "./lib/cloudflared.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "index.js",
15
+ "lib/client.js",
16
+ "lib/cloudflared.js",
17
+ "lib/qrcode.js",
18
+ "lib/update.js",
19
+ "cordis.patch.yml",
20
+ "README.md",
21
+ "assets/logo.svg"
22
+ ],
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/Raiyan007-gb/dsh-remote-tunnel-easy.git"
26
+ },
27
+ "keywords": [
28
+ "dsh",
29
+ "dsh-bundle",
30
+ "qrcode",
31
+ "cloudflared",
32
+ "remote"
33
+ ],
34
+ "license": "MIT",
35
+ "dependencies": {},
36
+ "peerDependencies": {
37
+ "@deepseek-ai/dsh-settings": ">=0.1.0-rc.6"
38
+ },
39
+ "devDependencies": {
40
+ "@deepseek-ai/dsh-settings": ">=0.1.0-rc.6"
41
+ },
42
+ "dsh": {
43
+ "bundle": {
44
+ "patch": "./cordis.patch.yml"
45
+ },
46
+ "client": {
47
+ "platform": "web",
48
+ "inject": []
49
+ }
50
+ }
51
+ }