dsh-deeppilot 0.2.1 → 0.3.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/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createRequire } from "node:module";
1
2
  import { createServer } from "node:http";
2
3
  import { createPrivateKey, randomBytes, randomUUID, sign, timingSafeEqual } from "node:crypto";
3
4
  import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
@@ -11,6 +12,7 @@ import { connect } from "node:http2";
11
12
  import { spawn } from "node:child_process";
12
13
  import { constants } from "node:fs";
13
14
  import { fileURLToPath } from "node:url";
15
+ import { request } from "node:https";
14
16
  //#region src/token.ts
15
17
  /** Expand a leading ~ using the process home directory. */
16
18
  function expandHome(p) {
@@ -2322,9 +2324,13 @@ function parseReport(value) {
2322
2324
  const lanAddresses = s.lanAddresses;
2323
2325
  if (!Array.isArray(devices)) reject("devices");
2324
2326
  if (!Array.isArray(lanAddresses) || lanAddresses.some((value) => typeof value !== "string")) reject("lanAddresses");
2327
+ const releaseUrl = s.releaseUrl;
2325
2328
  return {
2326
2329
  protocolVersion: num(s, "protocolVersion", "protocolVersion"),
2327
2330
  serverVersion: str(s, "serverVersion", "serverVersion"),
2331
+ pluginVersion: str(s, "pluginVersion", "pluginVersion"),
2332
+ ...s.updateAvailable === true ? { updateAvailable: true } : {},
2333
+ ...typeof releaseUrl === "string" && releaseUrl.length > 0 ? { releaseUrl } : {},
2328
2334
  enabled: bool(s, "enabled", "enabled"),
2329
2335
  tokenPath: str(s, "tokenPath", "tokenPath"),
2330
2336
  tokenReady: bool(s, "tokenReady", "tokenReady"),
@@ -2876,9 +2882,35 @@ function parseHelperEvent(line) {
2876
2882
  return null;
2877
2883
  }
2878
2884
  }
2879
- function bundledHelperPath() {
2885
+ /** Translate Node's platform/architecture names to the GOOS/GOARCH directory
2886
+ * names used by the committed helper matrix. */
2887
+ function bundledHelperPlatformDir(platform = process.platform, arch = process.arch) {
2888
+ return `${platform === "win32" ? "windows" : platform}-${arch === "x64" ? "amd64" : arch}`;
2889
+ }
2890
+ /** Build the list of candidate locations for the embedded tunnel helper, in
2891
+ * priority order. The first existing executable wins at start() time. The
2892
+ * order matters: explicit config (handled by the caller) > npm install
2893
+ * layout > DSH-bundled layout > user data dir. */
2894
+ function bundledHelperCandidates(platform = process.platform, arch = process.arch) {
2880
2895
  const here = dirname(fileURLToPath(import.meta.url));
2881
- return resolve(here, "..", "bin", `${process.platform}-${process.arch}`, "dsh-deeppilot-tunnel");
2896
+ const pkgRoot = resolve(here, "..");
2897
+ const fileName = platform === "win32" ? "dsh-deeppilot-tunnel.exe" : "dsh-deeppilot-tunnel";
2898
+ const platformDir = bundledHelperPlatformDir(platform, arch);
2899
+ const candidates = [];
2900
+ candidates.push(resolve(pkgRoot, "bin", platformDir, fileName));
2901
+ candidates.push(resolve(pkgRoot, "..", "..", "..", "node_modules", "dsh-deeppilot", "bin", platformDir, fileName));
2902
+ candidates.push(resolve(pkgRoot, "..", "..", "dsh-deeppilot", "bin", platformDir, fileName));
2903
+ candidates.push(resolve(pkgRoot, "..", "..", "..", "..", "node_modules", "dsh-deeppilot", "bin", platformDir, fileName));
2904
+ try {
2905
+ const resolved = createRequire(import.meta.url).resolve(`dsh-deeppilot/bin/${platformDir}/${fileName}`);
2906
+ if (!candidates.includes(resolved)) candidates.push(resolved);
2907
+ } catch {}
2908
+ const home = process.env.DSH_HOME?.trim() || process.env.HOME || process.env.USERPROFILE;
2909
+ if (home && home.length > 0) {
2910
+ const dataDir = resolve(home, ".dsh");
2911
+ candidates.push(join(dataDir, "deeppilot", "bin", platformDir, fileName));
2912
+ }
2913
+ return candidates;
2882
2914
  }
2883
2915
  /** Owns exactly one embedded tunnel helper and restarts it after failures. */
2884
2916
  var RemoteSupervisor = class {
@@ -2901,10 +2933,29 @@ var RemoteSupervisor = class {
2901
2933
  }
2902
2934
  async start(originURL) {
2903
2935
  if (!this.options.enabled || this.child !== void 0 || this.stopping) return;
2904
- const helper = expandHome(this.options.helperPath ?? bundledHelperPath());
2905
2936
  const statePath = expandHome(this.options.statePath);
2937
+ const configured = this.options.helperPath?.trim() ?? "";
2938
+ const candidates = configured ? [expandHome(configured)] : bundledHelperCandidates();
2939
+ let helper;
2940
+ let lastError;
2941
+ for (const candidate of candidates) try {
2942
+ await access(candidate, constants.X_OK);
2943
+ helper = candidate;
2944
+ break;
2945
+ } catch (error) {
2946
+ lastError = error;
2947
+ }
2948
+ if (helper === void 0) {
2949
+ if (this.stopping) return;
2950
+ const platform = `${process.platform}-${process.arch}`;
2951
+ const message = configured ? `embedded tunnel helper unavailable: ${configured}: ${String(lastError ?? "not found")}` : `embedded tunnel helper not found for ${platform} (tried: ${candidates.join(", ")}); set remote.helperPath to override`;
2952
+ this.setStatus({
2953
+ phase: "unavailable",
2954
+ message
2955
+ });
2956
+ return;
2957
+ }
2906
2958
  try {
2907
- await access(helper, constants.X_OK);
2908
2959
  await mkdir(statePath, {
2909
2960
  recursive: true,
2910
2961
  mode: 448
@@ -2913,7 +2964,7 @@ var RemoteSupervisor = class {
2913
2964
  if (this.stopping) return;
2914
2965
  this.setStatus({
2915
2966
  phase: "unavailable",
2916
- message: `embedded tunnel helper unavailable: ${String(error)}`
2967
+ message: `cannot create remote state dir: ${String(error)}`
2917
2968
  });
2918
2969
  return;
2919
2970
  }
@@ -3076,6 +3127,203 @@ function localLANIPv4Addresses() {
3076
3127
  return [...new Set(candidates.map(({ address }) => address))];
3077
3128
  }
3078
3129
  //#endregion
3130
+ //#region src/update-check.ts
3131
+ /**
3132
+ * Lightweight self-update check for dsh-deeppilot.
3133
+ *
3134
+ * On Host boot we ask the GitHub Releases API (REST) which is the latest
3135
+ * stable tag, compare it to the installed plugin version, and surface a
3136
+ * "newer release exists" flag plus the GitHub release URL through the
3137
+ * report Remote. The settings page renders one small line at the bottom;
3138
+ * a successful check is enough — no manual button, no persistent cache
3139
+ * (the host process is the lifetime of the answer).
3140
+ *
3141
+ * Deliberately no third-party dependency: we use {@link https.request}
3142
+ * directly to keep parity with the rest of the project (remote-supervisor
3143
+ * uses node:http, host-bridge uses ws, etc).
3144
+ *
3145
+ * Failure policy: every network/parse error collapses to a single log
3146
+ * line and the in-memory snapshot stays "unknown". The bridge must never
3147
+ * crash because GitHub rate-limited us, returned a 5xx, or the user is
3148
+ * offline.
3149
+ */
3150
+ /** GitHub repo (no .git suffix). Public, unauthenticated, low rate limit. */
3151
+ const RELEASES_PATH = "/repos/Mars-Sea/dsh-deeppilot/releases";
3152
+ /** Hard ceiling on the network round-trip. The host must never hang. */
3153
+ const FETCH_TIMEOUT_MS = 8e3;
3154
+ /** Per-page limit. We only need the first stable release, but pre-releases
3155
+ * tend to be listed first; fetching 20 gives the comparator enough room. */
3156
+ const PER_PAGE = 20;
3157
+ function isPlainObject(value) {
3158
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3159
+ }
3160
+ function parseStableEntry(value) {
3161
+ if (!isPlainObject(value)) return null;
3162
+ const tag = value.tag_name;
3163
+ if (typeof tag !== "string") return null;
3164
+ if (value.prerelease === true || value.draft === true) return null;
3165
+ if (parseStableTag(tag) === null) return null;
3166
+ const url = value.html_url;
3167
+ return {
3168
+ tag,
3169
+ url: typeof url === "string" ? url : null
3170
+ };
3171
+ }
3172
+ /** Parse one stable release from the `tag_name` shape `vX.Y.Z` (the v is
3173
+ * optional; `1.2.3` is also accepted). Pre-release tags like `0.3.0-rc.1`
3174
+ * return null — the policy is "stable channel only". */
3175
+ function parseStableTag(tag) {
3176
+ const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(tag.trim());
3177
+ if (match === null) return null;
3178
+ return {
3179
+ major: Number(match[1]),
3180
+ minor: Number(match[2]),
3181
+ patch: Number(match[3])
3182
+ };
3183
+ }
3184
+ /** Semver compare for X.Y.Z. Returns -1 / 0 / 1. */
3185
+ function compareSemver(a, b) {
3186
+ const pa = parseStableTag(a);
3187
+ const pb = parseStableTag(b);
3188
+ if (pa === null && pb === null) return 0;
3189
+ if (pa === null) return -1;
3190
+ if (pb === null) return 1;
3191
+ if (pa.major !== pb.major) return pa.major < pb.major ? -1 : 1;
3192
+ if (pa.minor !== pb.minor) return pa.minor < pb.minor ? -1 : 1;
3193
+ if (pa.patch !== pb.patch) return pa.patch < pb.patch ? -1 : 1;
3194
+ return 0;
3195
+ }
3196
+ /** Hit the GitHub Releases API. Resolves with the first stable release
3197
+ * GitHub returned, or null if the list contains no stable entries.
3198
+ * Network / parse errors reject — the caller is responsible for
3199
+ * collapsing them to a log line. */
3200
+ function fetchLatestStableRelease() {
3201
+ return new Promise((resolve, reject) => {
3202
+ const req = request({
3203
+ method: "GET",
3204
+ host: "api.github.com",
3205
+ path: `${RELEASES_PATH}?per_page=${PER_PAGE}`,
3206
+ headers: {
3207
+ "user-agent": "dsh-deeppilot-update-check",
3208
+ "accept": "application/vnd.github+json"
3209
+ }
3210
+ }, (res) => {
3211
+ const status = res.statusCode ?? 0;
3212
+ if (status < 200 || status >= 300) {
3213
+ res.resume();
3214
+ reject(/* @__PURE__ */ new Error(`github releases http ${status}`));
3215
+ return;
3216
+ }
3217
+ const chunks = [];
3218
+ res.on("data", (chunk) => chunks.push(chunk));
3219
+ res.on("end", () => {
3220
+ try {
3221
+ const body = Buffer.concat(chunks).toString("utf8");
3222
+ const parsed = JSON.parse(body);
3223
+ if (!Array.isArray(parsed)) {
3224
+ reject(/* @__PURE__ */ new Error("github releases: response is not an array"));
3225
+ return;
3226
+ }
3227
+ for (const entry of parsed) {
3228
+ const stable = parseStableEntry(entry);
3229
+ if (stable !== null) {
3230
+ resolve(stable);
3231
+ return;
3232
+ }
3233
+ }
3234
+ resolve(null);
3235
+ } catch (error) {
3236
+ reject(error instanceof Error ? error : new Error(String(error)));
3237
+ }
3238
+ });
3239
+ res.on("error", (error) => reject(error));
3240
+ });
3241
+ req.setTimeout(FETCH_TIMEOUT_MS, () => {
3242
+ req.destroy(/* @__PURE__ */ new Error("github releases: timeout after 8000ms"));
3243
+ });
3244
+ req.on("error", (error) => reject(error));
3245
+ req.end();
3246
+ });
3247
+ }
3248
+ /**
3249
+ * Process-wide check state. Constructed once in `apply()`, queried
3250
+ * synchronously by the report snapshot. The check itself fires once in
3251
+ * the background shortly after boot; the answer lives for the lifetime
3252
+ * of the host process — re-running the page in the Web UI does not
3253
+ * trigger another network call.
3254
+ */
3255
+ var UpdateChecker = class {
3256
+ log;
3257
+ currentVersion;
3258
+ fetchImpl;
3259
+ initialDelayMs;
3260
+ snapshot;
3261
+ inflight = null;
3262
+ constructor(options) {
3263
+ this.log = options.log;
3264
+ this.currentVersion = options.currentVersion;
3265
+ this.fetchImpl = options.fetchImpl ?? fetchLatestStableRelease;
3266
+ this.initialDelayMs = options.initialDelayMs ?? 2e3;
3267
+ this.snapshot = {
3268
+ currentVersion: this.currentVersion,
3269
+ available: false,
3270
+ releaseUrl: null,
3271
+ latestVersion: null
3272
+ };
3273
+ }
3274
+ /** Return the current in-memory snapshot — safe to call from any host
3275
+ * thread. Never throws, never awaits. */
3276
+ get() {
3277
+ return this.snapshot;
3278
+ }
3279
+ /**
3280
+ * Schedule one background refresh after the configured initial delay.
3281
+ * Used by the plugin entry to do the first check without blocking boot.
3282
+ */
3283
+ scheduleInitial() {
3284
+ if (this.initialDelayMs <= 0) {
3285
+ this.runOnce();
3286
+ return;
3287
+ }
3288
+ const timer = setTimeout(() => {
3289
+ this.runOnce();
3290
+ }, this.initialDelayMs);
3291
+ if (typeof timer.unref === "function") timer.unref();
3292
+ }
3293
+ async runOnce() {
3294
+ if (this.inflight !== null) {
3295
+ await this.inflight;
3296
+ return;
3297
+ }
3298
+ const task = (async () => {
3299
+ try {
3300
+ const stable = await this.fetchImpl();
3301
+ if (stable === null) return;
3302
+ if (compareSemver(stable.tag, this.currentVersion) > 0) this.snapshot = {
3303
+ currentVersion: this.currentVersion,
3304
+ available: true,
3305
+ releaseUrl: stable.url,
3306
+ latestVersion: stable.tag
3307
+ };
3308
+ else this.snapshot = {
3309
+ currentVersion: this.currentVersion,
3310
+ available: false,
3311
+ releaseUrl: null,
3312
+ latestVersion: null
3313
+ };
3314
+ } catch (error) {
3315
+ this.log("update check failed: " + (error instanceof Error ? error.message : String(error)));
3316
+ }
3317
+ })();
3318
+ this.inflight = task.finally(() => {
3319
+ this.inflight = null;
3320
+ });
3321
+ return this.inflight;
3322
+ }
3323
+ /** No-op kept for API symmetry with the host lifecycle wiring. */
3324
+ dispose() {}
3325
+ };
3326
+ //#endregion
3079
3327
  //#region src/index.ts
3080
3328
  /**
3081
3329
  * dsh-deeppilot — data bridge between the DSH host and DeepPilot
@@ -3134,7 +3382,7 @@ const Config = z.object({
3134
3382
  relayToken: ""
3135
3383
  })
3136
3384
  });
3137
- const SERVER_VERSION = "0.2.0";
3385
+ const SERVER_VERSION = readOwnPackageVersion();
3138
3386
  const MAX_CLIENT_CONNECTIONS = 16;
3139
3387
  /**
3140
3388
  * Single-frame bound. Covers the protocol maximum (4 × 8 MB base64 images
@@ -3142,6 +3390,22 @@ const MAX_CLIENT_CONNECTIONS = 16;
3142
3390
  * pre-hello buffering far below ws's 100 MiB default.
3143
3391
  */
3144
3392
  const MAX_FRAME_BYTES = 67108864;
3393
+ /**
3394
+ * Resolve the host plugin's own version from the installed package.json.
3395
+ * Sourced at boot so the wire / UI always agrees with what npm published.
3396
+ * `createRequire(import.meta.url)` is the tsdown-bundled ESM equivalent of
3397
+ * CommonJS's `require`; the package.json sits next to lib/index.js after
3398
+ * the build, so `../package.json` resolves to the published manifest.
3399
+ */
3400
+ function readOwnPackageVersion() {
3401
+ try {
3402
+ const pkg = createRequire(import.meta.url)("../package.json");
3403
+ if (typeof pkg.version === "string" && pkg.version.length > 0) return pkg.version;
3404
+ } catch {}
3405
+ const envVersion = process.env.npm_package_version;
3406
+ if (typeof envVersion === "string" && envVersion.length > 0) return envVersion;
3407
+ return "0.0.0+unknown";
3408
+ }
3145
3409
  function rejectUpgrade(socket, status, reason) {
3146
3410
  const body = JSON.stringify({ error: reason });
3147
3411
  socket.end("HTTP/1.1 " + status + " Forbidden\r\nContent-Type: application/json\r\nContent-Length: " + Buffer.byteLength(body) + "\r\nConnection: close\r\n\r\n" + body);
@@ -3588,6 +3852,12 @@ function apply(ctx, options) {
3588
3852
  phase: currentConfig().remote?.enabled === true ? "stopped" : "disabled",
3589
3853
  updatedAt: Date.now()
3590
3854
  };
3855
+ const updateChecker = new UpdateChecker({
3856
+ log,
3857
+ currentVersion: SERVER_VERSION
3858
+ });
3859
+ updateChecker.scheduleInitial();
3860
+ const updateInfo = () => updateChecker.get();
3591
3861
  applyReportRemote(ctx, async () => {
3592
3862
  let tokenReady = false;
3593
3863
  let devices = [];
@@ -3606,9 +3876,13 @@ function apply(ctx, options) {
3606
3876
  } } : {}
3607
3877
  }));
3608
3878
  } catch {}
3879
+ const update = updateInfo();
3609
3880
  return {
3610
3881
  protocolVersion: 1,
3611
3882
  serverVersion: SERVER_VERSION,
3883
+ pluginVersion: update.currentVersion,
3884
+ ...update.available ? { updateAvailable: true } : {},
3885
+ ...update.releaseUrl !== null ? { releaseUrl: update.releaseUrl } : {},
3612
3886
  enabled: currentConfig().enabled === true,
3613
3887
  tokenPath: expandHome(currentConfig().authTokenPath ?? join(bridgeDataDir(), "auth-token")),
3614
3888
  tokenReady,