dsh-deeppilot 0.2.1 → 0.2.2

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