dsh-update-status 0.1.8 → 0.1.9

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.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import z from "@deepseek-ai/schemastery";
2
1
  import { Context } from "@deepseek-ai/cordis";
2
+ import z from "@deepseek-ai/schemastery";
3
3
  //#region src/shared/types.d.ts
4
4
  declare const RELEASE_CHANNELS: readonly ['latest', 'next', 'alpha'];
5
5
  type ReleaseChannel = (typeof RELEASE_CHANNELS)[number];
@@ -38,6 +38,18 @@ type UpdateWarning = {
38
38
  code: 'preview-unverified';
39
39
  channel: ReleaseChannel;
40
40
  version: string;
41
+ } |
42
+ /**
43
+ * The Host resolved a `@deepseek-ai/schemastery` that DSH does not ship, so the
44
+ * Loader's volatile projection is unavailable and the preference fields cannot
45
+ * be marked volatile. Advisory: the version answer itself is complete, and the
46
+ * text carries the exact directory to remove. See `host/schemastery.ts`.
47
+ */
48
+ {
49
+ code: 'stale-schemastery';
50
+ version: string | null;
51
+ path: string;
52
+ nodeModulesDir: string | null;
41
53
  };
42
54
  interface ChannelRelease {
43
55
  channel: ReleaseChannel;
@@ -98,7 +110,8 @@ interface UpdateStatusConfig {
98
110
  * The explicit two-argument annotation is load-bearing: a `.volatile()` field's
99
111
  * output is a stable reference (`Volatile<T>`) rather than the bare value the
100
112
  * input side takes, so the inferred schema type cannot be named by the emitted
101
- * `.d.ts` (TS2883) without stating the input side here.
113
+ * `.d.ts` (TS2883) without stating the input side here. The builder preserves
114
+ * that contract even on the degraded path, where no field is volatile.
102
115
  */
103
116
  export declare const Config: z<UpdateStatusConfig, Record<string, unknown>>;
104
117
  //#endregion
@@ -123,6 +136,11 @@ interface UpdateStatusServiceOptions {
123
136
  now?: () => number;
124
137
  ttlMs?: number;
125
138
  releaseUrl?: string;
139
+ /**
140
+ * Facts about this process's own runtime, appended to every status. The schema
141
+ * resolution is the only producer today; the registry read never sets these.
142
+ */
143
+ runtimeWarnings?: readonly UpdateWarning[];
126
144
  }
127
145
  export declare class UpdateStatusService {
128
146
  private readonly installation;
@@ -130,6 +148,7 @@ export declare class UpdateStatusService {
130
148
  private readonly now;
131
149
  private readonly ttlMs;
132
150
  private readonly releaseUrl;
151
+ private readonly runtimeWarnings;
133
152
  private cache;
134
153
  private inFlight;
135
154
  constructor(options: UpdateStatusServiceOptions);
@@ -142,6 +161,59 @@ export declare class UpdateStatusService {
142
161
  private statusWithoutRemoteRelease;
143
162
  }
144
163
  //#endregion
164
+ //#region src/host/schemastery.d.ts
165
+ /** Where a candidate copy of schemastery was looked for. */
166
+ type SchemaCandidateSource = 'dsh-install' | 'profile-peers' | 'profile-local' | 'plugin-local';
167
+ interface SchemaCandidate {
168
+ source: SchemaCandidateSource;
169
+ /** Absolute file path `createRequire` anchors on; the file need not exist. */
170
+ anchor: string;
171
+ }
172
+ interface SchemaCandidateReport {
173
+ source: SchemaCandidateSource;
174
+ anchor: string;
175
+ /** Absolute path of the module that loaded, or null when it did not load. */
176
+ path: string | null;
177
+ version: string | null;
178
+ /** The loaded module exposes `volatile()`. */
179
+ volatile: boolean;
180
+ /** Why this candidate was skipped, or null when it loaded. */
181
+ error: string | null;
182
+ }
183
+ interface SchemaRuntime {
184
+ /** The loaded schemastery factory — structurally the package's default export. */
185
+ z: unknown;
186
+ volatile: boolean;
187
+ source: SchemaCandidateSource;
188
+ path: string | null;
189
+ version: string | null;
190
+ /** Every candidate that was tried, in order, for diagnostics. */
191
+ candidates: SchemaCandidateReport[];
192
+ /** Set when the resolved copy cannot project volatile preferences. */
193
+ warning: UpdateWarning | null;
194
+ }
195
+ interface ResolveSchemaRuntimeOptions {
196
+ /** Override the candidate order; used by tests and by an embedding host. */
197
+ candidates?: readonly SchemaCandidate[];
198
+ }
199
+ /**
200
+ * Resolve schemastery once, preferring the copy that can actually do the job.
201
+ *
202
+ * Only a total miss throws, and only with the per-candidate reasons attached:
203
+ * that state means the plugin is not running inside a working DSH installation,
204
+ * so there is no schema system to build a settings form with. Every other
205
+ * outcome loads.
206
+ */
207
+ export declare function resolveSchemaRuntime(options?: ResolveSchemaRuntimeOptions): SchemaRuntime;
208
+ /**
209
+ * The process-wide resolution, computed on first use.
210
+ *
211
+ * The settings schema is built at module scope — the Loader reads the exported
212
+ * `Config` before `apply` runs — so the resolution cannot wait for a Host
213
+ * context. Every input here comes from the process environment or the filesystem.
214
+ */
215
+ export declare function schemaRuntime(): SchemaRuntime;
216
+ //#endregion
145
217
  //#region src/index.d.ts
146
218
  export declare const name = "dsh-update-status";
147
219
  /** Connection supplies the authenticated transport; settings remains optional. */
@@ -149,4 +221,4 @@ export declare const inject: string[];
149
221
  export type Config = UpdateStatusConfig;
150
222
  export declare function apply(ctx: Context, config?: Config): void;
151
223
  //#endregion
152
- export type { InstallKind, UpdateStatus };
224
+ export type { InstallKind, SchemaRuntime, UpdateStatus };
package/lib/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { existsSync, readFileSync, realpathSync } from "node:fs";
3
- import { dirname, join, normalize, sep } from "node:path";
4
- import z from "@deepseek-ai/schemastery";
3
+ import { basename, dirname, join, normalize, sep } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { homedir } from "node:os";
5
6
  //#region src/shared/types.ts
6
7
  const PACKAGE_NAME = "@deepseek-ai/dsh";
7
8
  /** Shared Connection RPC channel. Custom prefixes 405 on the SPA fallback. */
@@ -51,7 +52,7 @@ function manifestAt(path) {
51
52
  return;
52
53
  }
53
54
  }
54
- function realPath(path) {
55
+ function realPath$1(path) {
55
56
  try {
56
57
  return realpathSync(path);
57
58
  } catch {
@@ -60,14 +61,14 @@ function realPath(path) {
60
61
  }
61
62
  /** Ascend from an entry file until the owning DSH package manifest is found. */
62
63
  function manifestFromEntry(entryPath) {
63
- let directory = dirname(realPath(entryPath));
64
+ let directory = dirname(realPath$1(entryPath));
64
65
  for (let depth = 0; depth < 10; depth += 1) {
65
66
  const manifest = join(directory, "package.json");
66
67
  if (existsSync(manifest)) {
67
68
  const found = manifestAt(manifest);
68
69
  if (found !== void 0) return {
69
70
  ...found,
70
- root: realPath(found.root)
71
+ root: realPath$1(found.root)
71
72
  };
72
73
  }
73
74
  const parent = dirname(directory);
@@ -80,7 +81,7 @@ function manifestFromResolver(resolve) {
80
81
  const found = manifestAt(resolve(PACKAGE_NAME + "/package.json"));
81
82
  if (found !== void 0) return {
82
83
  ...found,
83
- root: realPath(found.root)
84
+ root: realPath$1(found.root)
84
85
  };
85
86
  } catch {}
86
87
  try {
@@ -114,7 +115,7 @@ function ownResolver(selfUrl) {
114
115
  */
115
116
  function classifyInstallRoot(packageRoot, nodePrefix = process.execPath) {
116
117
  if (packageRoot === void 0) return "unknown";
117
- const root = normalize(realPath(packageRoot)).split(sep).join("/");
118
+ const root = normalize(realPath$1(packageRoot)).split(sep).join("/");
118
119
  const prefix = nodePrefix === void 0 ? "" : normalize(dirname(dirname(nodePrefix))).split(sep).join("/");
119
120
  if (!root.includes("/node_modules/") && /\/apps\/cli(?:\/|$)/.test(root)) return "source-checkout";
120
121
  if (root.includes("/.pnpm/") || /\/pnpm\/global(?:\/|$)/.test(root)) return "pnpm-global";
@@ -257,6 +258,278 @@ function installUpdateStatusRpc(ctx, service) {
257
258
  });
258
259
  }
259
260
  //#endregion
261
+ //#region src/host/schemastery.ts
262
+ /**
263
+ * dsh-update-status — shadow-proof resolution of the platform's schemastery.
264
+ *
265
+ * `@deepseek-ai/schemastery` is a peer dependency: DSH ships it, and DSH's copy
266
+ * is the one carrying `volatile()` — the Loader's volatile-field projection this
267
+ * plugin's settings form is built on. A plain
268
+ * `import z from '@deepseek-ai/schemastery'` is resolved by Node's own directory
269
+ * walk *from the importing file*, so any copy sitting next to the plugin wins
270
+ * over the platform's copy. That is not hypothetical: an unmanaged tree left
271
+ * inside the install directory — a dev `node_modules` copied in by a
272
+ * local-directory install, which pnpm never removes — made `lib/index.js` throw
273
+ * `TypeError: …volatile is not a function` while it was being imported, so the
274
+ * whole host half disappeared before one line of plugin code ran.
275
+ *
276
+ * Nothing inside the plugin can repair that state: the failure precedes every
277
+ * plugin entry point. The resolution itself is made immune instead, trying
278
+ * platform copies before anything else:
279
+ *
280
+ * 1. the DSH installation running this process,
281
+ * 2. the profile peer farm DSH maintains (`$DSH_HOME/profiles/node_modules`),
282
+ * 3. the active profile's own tree,
283
+ * 4. whatever Node's walk finds next to this file — last resort.
284
+ *
285
+ * The first candidate that actually exposes `volatile()` wins, and the
286
+ * capability is verified rather than assumed. When no candidate provides it, the
287
+ * plugin still loads on the best copy it could load and reports exactly what it
288
+ * resolved: the missing capability becomes a `stale-schemastery` warning the
289
+ * panel renders instead of a dead plugin.
290
+ */
291
+ const SCHEMASTERY_NAME = "@deepseek-ai/schemastery";
292
+ /** Anchor file name; it never has to exist, only to give `createRequire` a base. */
293
+ const PROBE_FILE = "dsh-update-status-peer-probe.cjs";
294
+ const MAX_ASCENT = 10;
295
+ function realPath(path) {
296
+ try {
297
+ return realpathSync(path);
298
+ } catch {
299
+ return path;
300
+ }
301
+ }
302
+ function boundedMessage$1(error) {
303
+ const trimmed = (error instanceof Error ? error.message : String(error)).replace(/\s+/g, " ").trim();
304
+ return trimmed === "" ? "unknown error" : trimmed.slice(0, 220);
305
+ }
306
+ function environmentDirectory(name) {
307
+ const value = process.env[name];
308
+ return typeof value === "string" && value.trim() !== "" ? value : null;
309
+ }
310
+ /** The DSH home directory, from the environment first and `~/.dsh` as a fallback. */
311
+ function dshHomeDirectory() {
312
+ const configured = environmentDirectory("DSH_HOME");
313
+ if (configured !== null) return configured;
314
+ try {
315
+ return join(homedir(), ".dsh");
316
+ } catch {
317
+ return null;
318
+ }
319
+ }
320
+ /** The active profile directory, from `$DSH_PROFILE_DIR` or `$DSH_HOME/profiles/<profile>`. */
321
+ function profileDirectory() {
322
+ const configured = environmentDirectory("DSH_PROFILE_DIR");
323
+ if (configured !== null) return configured;
324
+ const home = dshHomeDirectory();
325
+ const profile = environmentDirectory("DSH_PROFILE");
326
+ return home === null || profile === null ? null : join(home, "profiles", profile);
327
+ }
328
+ /**
329
+ * Anchor on the DSH package that owns the running entry point.
330
+ *
331
+ * The entry is the CLI file Node was started with, so ascending from it reaches
332
+ * the DSH installation whose `node_modules` holds the platform's schemastery.
333
+ * Reusing `manifestFromEntry` keeps this probe identical to the installation
334
+ * detection the status read already performs.
335
+ */
336
+ function dshInstallAnchor() {
337
+ const entry = process.argv[1];
338
+ if (typeof entry !== "string" || entry === "") return null;
339
+ const found = manifestFromEntry(entry);
340
+ return found === void 0 || found.root === "" ? null : join(found.root, "package.json");
341
+ }
342
+ /** Candidate order: platform copies first, the shadowable walk last. */
343
+ function defaultSchemaCandidates() {
344
+ const candidates = [];
345
+ const install = dshInstallAnchor();
346
+ if (install !== null) candidates.push({
347
+ source: "dsh-install",
348
+ anchor: install
349
+ });
350
+ const home = dshHomeDirectory();
351
+ if (home !== null) candidates.push({
352
+ source: "profile-peers",
353
+ anchor: join(home, "profiles", PROBE_FILE)
354
+ });
355
+ const profile = profileDirectory();
356
+ if (profile !== null) candidates.push({
357
+ source: "profile-local",
358
+ anchor: join(profile, PROBE_FILE)
359
+ });
360
+ candidates.push({
361
+ source: "plugin-local",
362
+ anchor: fileURLToPath(import.meta.url)
363
+ });
364
+ return candidates;
365
+ }
366
+ function manifestVersion(manifestPath) {
367
+ try {
368
+ const parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
369
+ if (parsed.name !== "@deepseek-ai/schemastery" || typeof parsed.version !== "string") return null;
370
+ const version = parsed.version.trim();
371
+ return version === "" ? null : version;
372
+ } catch {
373
+ return null;
374
+ }
375
+ }
376
+ /** Read the version of the schemastery package that owns a resolved entry path. */
377
+ function versionOfResolved(resolvedPath) {
378
+ let directory = dirname(realPath(resolvedPath));
379
+ for (let depth = 0; depth < MAX_ASCENT; depth += 1) {
380
+ const manifestPath = join(directory, "package.json");
381
+ if (existsSync(manifestPath)) {
382
+ const version = manifestVersion(manifestPath);
383
+ if (version !== null) return version;
384
+ }
385
+ const parent = dirname(directory);
386
+ if (parent === directory) break;
387
+ directory = parent;
388
+ }
389
+ return null;
390
+ }
391
+ /** The nearest `node_modules` directory above a resolved package entry. */
392
+ function nodeModulesDirectoryOf(resolvedPath) {
393
+ if (resolvedPath === null) return null;
394
+ let directory = dirname(realPath(resolvedPath));
395
+ for (let depth = 0; depth < MAX_ASCENT; depth += 1) {
396
+ if (basename(directory) === "node_modules") return directory;
397
+ const parent = dirname(directory);
398
+ if (parent === directory) break;
399
+ directory = parent;
400
+ }
401
+ return null;
402
+ }
403
+ /**
404
+ * Does this module expose the Loader's volatile projection?
405
+ *
406
+ * The probe builds a throwaway field because the capability is a prototype
407
+ * method, not a flag: 3.18.2 has no `volatile` at all, 3.18.4 (the copy DSH
408
+ * ships) does. Never throws — an unusable module is simply not a candidate.
409
+ */
410
+ function supportsVolatile(factory) {
411
+ if (factory === null || typeof factory !== "object" && typeof factory !== "function") return false;
412
+ try {
413
+ const boolean = factory.boolean;
414
+ if (typeof boolean !== "function") return false;
415
+ const field = boolean.call(factory);
416
+ if (field === null || field === void 0 || typeof field.default !== "function") return false;
417
+ const defined = field.default(true);
418
+ return defined !== null && defined !== void 0 && typeof defined.volatile === "function";
419
+ } catch {
420
+ return false;
421
+ }
422
+ }
423
+ /** Can this value build a schema? A wrapper object that cannot is never chosen. */
424
+ function isSchemaFactory(value) {
425
+ if (value === null || typeof value !== "object" && typeof value !== "function") return false;
426
+ const record = value;
427
+ return typeof record.object === "function" && typeof record.boolean === "function" && typeof record.number === "function";
428
+ }
429
+ /**
430
+ * Pick the factory out of the shapes a CommonJS build can expose.
431
+ *
432
+ * The package assigns `module.exports` directly, but a bundler or an interop
433
+ * wrapper can put the same factory behind `default`. A shape that cannot build a
434
+ * schema is never chosen even as a fallback, because the degraded path still has
435
+ * to produce a `Config` — a wrapper object would throw while the entry loads,
436
+ * which is the failure this module exists to prevent.
437
+ */
438
+ function factoryOf(loaded) {
439
+ const shapes = [loaded, (loaded !== null && (typeof loaded === "object" || typeof loaded === "function") ? loaded : void 0)?.default].filter(isSchemaFactory);
440
+ return shapes.find(supportsVolatile) ?? shapes[0];
441
+ }
442
+ function loadCandidate(candidate) {
443
+ const report = {
444
+ source: candidate.source,
445
+ anchor: candidate.anchor,
446
+ path: null,
447
+ version: null,
448
+ volatile: false,
449
+ error: null
450
+ };
451
+ try {
452
+ const require = createRequire(candidate.anchor);
453
+ const resolved = require.resolve(SCHEMASTERY_NAME);
454
+ const factory = factoryOf(require(resolved));
455
+ if (factory === void 0) throw new Error("module does not export a schemastery factory");
456
+ report.path = realPath(resolved);
457
+ report.version = versionOfResolved(resolved);
458
+ report.volatile = supportsVolatile(factory);
459
+ return {
460
+ report,
461
+ factory
462
+ };
463
+ } catch (error) {
464
+ report.error = boundedMessage$1(error);
465
+ return {
466
+ report,
467
+ factory: void 0
468
+ };
469
+ }
470
+ }
471
+ /**
472
+ * Resolve schemastery once, preferring the copy that can actually do the job.
473
+ *
474
+ * Only a total miss throws, and only with the per-candidate reasons attached:
475
+ * that state means the plugin is not running inside a working DSH installation,
476
+ * so there is no schema system to build a settings form with. Every other
477
+ * outcome loads.
478
+ */
479
+ function resolveSchemaRuntime(options = {}) {
480
+ const candidates = options.candidates ?? defaultSchemaCandidates();
481
+ const reports = [];
482
+ let usable;
483
+ let loaded;
484
+ for (const candidate of candidates) {
485
+ const attempt = loadCandidate(candidate);
486
+ reports.push(attempt.report);
487
+ if (attempt.factory === void 0) continue;
488
+ loaded ??= attempt;
489
+ if (attempt.report.volatile) {
490
+ usable = attempt;
491
+ break;
492
+ }
493
+ }
494
+ const chosen = usable ?? loaded;
495
+ if (chosen === void 0) {
496
+ const tried = reports.map((report) => `${report.source}: ${report.error ?? "not loadable"}`).join("; ");
497
+ throw new Error(`dsh-update-status could not resolve ${SCHEMASTERY_NAME} (${tried})`);
498
+ }
499
+ return {
500
+ z: chosen.factory,
501
+ volatile: chosen.report.volatile,
502
+ source: chosen.report.source,
503
+ path: chosen.report.path,
504
+ version: chosen.report.version,
505
+ candidates: reports,
506
+ warning: chosen.report.volatile ? null : {
507
+ code: "stale-schemastery",
508
+ version: chosen.report.version,
509
+ path: chosen.report.path ?? chosen.report.anchor,
510
+ nodeModulesDir: nodeModulesDirectoryOf(chosen.report.path)
511
+ }
512
+ };
513
+ }
514
+ let resolved;
515
+ /**
516
+ * The process-wide resolution, computed on first use.
517
+ *
518
+ * The settings schema is built at module scope — the Loader reads the exported
519
+ * `Config` before `apply` runs — so the resolution cannot wait for a Host
520
+ * context. Every input here comes from the process environment or the filesystem.
521
+ */
522
+ function schemaRuntime() {
523
+ resolved ??= resolveSchemaRuntime();
524
+ return resolved;
525
+ }
526
+ /** English, single-line summary of a resolution, for the Host log. */
527
+ function describeSchemaRuntime(runtime) {
528
+ const where = runtime.path === null ? runtime.source : `${runtime.path} (${runtime.source})`;
529
+ const version = runtime.version === null ? "" : `@${runtime.version}`;
530
+ return `${SCHEMASTERY_NAME}${version} from ${where}; volatile preferences ${runtime.volatile ? "available" : "unavailable"}`;
531
+ }
532
+ //#endregion
260
533
  //#region src/shared/semver.ts
261
534
  const SEMVER = /^(?:v)?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
262
535
  const NUMERIC_IDENTIFIER = /^(0|[1-9]\d*)$/;
@@ -317,20 +590,34 @@ function boundedMessage(error) {
317
590
  const trimmed = (error instanceof Error ? error.message : String(error)).replace(/\s+/g, " ").trim();
318
591
  return trimmed === "" ? "unknown error" : trimmed.slice(0, 220);
319
592
  }
320
- function warningText(warning) {
593
+ /** English, single-line rendering of one warning; the Host log uses it directly. */
594
+ function describeWarning(warning) {
321
595
  switch (warning.code) {
322
596
  case "registry-unavailable": return `Unable to check the npm registry: ${warning.detail}`;
323
597
  case "channel-unavailable": return `The npm registry does not publish a ${warning.channel} channel.`;
324
598
  case "version-incomparable": return `Unable to compare the current version ${warning.currentVersion} with ${warning.channel} channel version ${warning.selectedVersion} using SemVer.`;
325
599
  case "preview-unverified": return `${warning.channel} is a preview channel; version ${warning.version} has not been verified as compatible with this plugin.`;
600
+ case "stale-schemastery": {
601
+ const version = warning.version === null ? "" : ` ${warning.version}`;
602
+ const remedy = warning.nodeModulesDir === null ? `Remove the stale ${SCHEMASTERY_NAME} directory there and restart DSH.` : `Remove the stale copy and restart DSH: rm -rf ${warning.nodeModulesDir}`;
603
+ return `This plugin resolved ${SCHEMASTERY_NAME}${version} from ${warning.path} instead of the copy DSH provides, so preference fields cannot be marked volatile. ${remedy}`;
604
+ }
326
605
  }
327
606
  }
328
607
  function warningFallback(warnings) {
329
- return warnings.length === 0 ? null : warnings.map(warningText).join(" ");
608
+ return warnings.length === 0 ? null : warnings.map(describeWarning).join(" ");
330
609
  }
610
+ /**
611
+ * Advisory codes leave the chip alone; everything else is a failed read.
612
+ *
613
+ * `stale-schemastery` belongs here: the version answer is complete and usable,
614
+ * and only the settings form is degraded, so repainting the brand row would
615
+ * misreport a plugin-runtime fact as a failed update check.
616
+ */
617
+ const ADVISORY_CODES = ["preview-unverified", "stale-schemastery"];
331
618
  function warningKindOf(warnings) {
332
619
  if (warnings.length === 0) return null;
333
- return warnings.every((warning) => warning.code === "preview-unverified") ? "notice" : "failure";
620
+ return warnings.every((warning) => ADVISORY_CODES.includes(warning.code)) ? "notice" : "failure";
334
621
  }
335
622
  function dateOrNull(value) {
336
623
  if (typeof value !== "string" || value === "") return null;
@@ -399,6 +686,7 @@ var UpdateStatusService = class {
399
686
  now;
400
687
  ttlMs;
401
688
  releaseUrl;
689
+ runtimeWarnings;
402
690
  cache;
403
691
  inFlight;
404
692
  constructor(options) {
@@ -407,6 +695,7 @@ var UpdateStatusService = class {
407
695
  this.now = options.now ?? Date.now;
408
696
  this.ttlMs = Math.max(1, Math.floor(options.ttlMs ?? DEFAULT_TTL_MS));
409
697
  this.releaseUrl = options.releaseUrl ?? "https://github.com/deepseek-ai/deepseek-harness/releases";
698
+ this.runtimeWarnings = options.runtimeWarnings ?? [];
410
699
  }
411
700
  getStatus(channel = "latest", cacheTtlMinutes) {
412
701
  return this.check(false, channel, cacheTtlMinutes);
@@ -470,6 +759,7 @@ var UpdateStatusService = class {
470
759
  channel,
471
760
  version: selected.version
472
761
  });
762
+ warnings.push(...this.runtimeWarnings);
473
763
  return {
474
764
  currentVersion: this.installation.currentVersion,
475
765
  latestVersion: selected.version,
@@ -491,15 +781,16 @@ var UpdateStatusService = class {
491
781
  };
492
782
  }
493
783
  statusWithoutRemoteRelease(channel, warnings) {
784
+ const combined = [...warnings, ...this.runtimeWarnings];
494
785
  return {
495
786
  currentVersion: this.installation.currentVersion,
496
787
  latestVersion: null,
497
788
  hasUpdate: false,
498
789
  cached: false,
499
790
  checkedAt: null,
500
- warning: warningFallback(warnings),
501
- warningKind: warningKindOf(warnings),
502
- warnings,
791
+ warning: warningFallback(combined),
792
+ warningKind: warningKindOf(combined),
793
+ warnings: combined,
503
794
  installKind: this.installation.installKind,
504
795
  upgradeCommand: upgradeCommandFor(this.installation.installKind, this.installation.packageName || "@deepseek-ai/dsh", channel),
505
796
  releaseUrl: this.releaseUrl,
@@ -520,25 +811,46 @@ var UpdateStatusService = class {
520
811
  //#endregion
521
812
  //#region src/host/settings.ts
522
813
  /**
814
+ * Build the Config schema from a resolved schemastery factory.
815
+ *
816
+ * `volatileAvailable` is the capability the resolver *verified*, not an
817
+ * assumption. On a copy without `volatile()` the three preferences stay ordinary
818
+ * fields — the plugin keeps loading and reports why through
819
+ * `schemaRuntime().warning` instead of throwing while its own entry is imported.
820
+ *
821
+ * @param factory - A schemastery factory, structurally the package's default export.
822
+ * @param volatileAvailable - Whether `factory` exposes the Loader's volatile projection.
823
+ */
824
+ function buildConfigSchema(factory, volatileAvailable) {
825
+ const volatile = (field) => {
826
+ if (!volatileAvailable) return field;
827
+ const method = field.volatile;
828
+ return typeof method === "function" ? method.call(field) : field;
829
+ };
830
+ return factory.object({
831
+ cacheTtlHours: factory.number().step(1).min(1).max(24).default(6),
832
+ timeoutMs: factory.number().step(1).min(1e3).max(3e4).default(DEFAULT_TIMEOUT_MS),
833
+ autoCheckOnMount: factory.boolean().default(true),
834
+ sidebarEnabled: volatile(factory.boolean().default(true)),
835
+ channel: volatile(factory.union([
836
+ "latest",
837
+ "next",
838
+ "alpha"
839
+ ]).default("latest").loose()),
840
+ cacheTtlMinutes: volatile(factory.number().min(30).max(1440).default(360))
841
+ });
842
+ }
843
+ const runtime = schemaRuntime();
844
+ /**
523
845
  * The plugin's Config schema; its volatile fields ARE the settings form.
524
846
  *
525
847
  * The explicit two-argument annotation is load-bearing: a `.volatile()` field's
526
848
  * output is a stable reference (`Volatile<T>`) rather than the bare value the
527
849
  * input side takes, so the inferred schema type cannot be named by the emitted
528
- * `.d.ts` (TS2883) without stating the input side here.
850
+ * `.d.ts` (TS2883) without stating the input side here. The builder preserves
851
+ * that contract even on the degraded path, where no field is volatile.
529
852
  */
530
- const Config = z.object({
531
- cacheTtlHours: z.number().step(1).min(1).max(24).default(6),
532
- timeoutMs: z.number().step(1).min(1e3).max(3e4).default(DEFAULT_TIMEOUT_MS),
533
- autoCheckOnMount: z.boolean().default(true),
534
- sidebarEnabled: z.boolean().default(true).volatile(),
535
- channel: z.union([
536
- "latest",
537
- "next",
538
- "alpha"
539
- ]).default("latest").loose().volatile(),
540
- cacheTtlMinutes: z.number().min(30).max(1440).default(360).volatile()
541
- });
853
+ const Config = buildConfigSchema(runtime.z, runtime.volatile);
542
854
  /**
543
855
  * Suppress the auto-generated settings page for this entry.
544
856
  *
@@ -576,14 +888,17 @@ function boundedNumber(value, fallback, min, max) {
576
888
  function apply(ctx, config = {}) {
577
889
  const ttlHours = boundedNumber(config.cacheTtlHours, 6, 1, 24);
578
890
  const timeoutMs = boundedNumber(config.timeoutMs, DEFAULT_TIMEOUT_MS, 1e3, 3e4);
891
+ const schema = schemaRuntime();
892
+ if (schema.warning !== null) console.warn(`[dsh-update-status] ${describeSchemaRuntime(schema)} — ${describeWarning(schema.warning)}`);
579
893
  const service = new UpdateStatusService({
580
894
  installation: detectInstallation(ctx),
581
895
  fetchLatest: createRegistryFetcher(timeoutMs),
582
- ttlMs: ttlHours * 60 * 60 * 1e3
896
+ ttlMs: ttlHours * 60 * 60 * 1e3,
897
+ runtimeWarnings: schema.warning === null ? [] : [schema.warning]
583
898
  });
584
899
  installSettings(ctx);
585
900
  installUpdateStatusRpc(ctx, service);
586
901
  if (config.autoCheckOnMount !== false) service.getStatus();
587
902
  }
588
903
  //#endregion
589
- export { Config, UpdateStatusService, apply, inject, name };
904
+ export { Config, UpdateStatusService, apply, inject, name, resolveSchemaRuntime, schemaRuntime };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-update-status",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Read-only DeepSeek Harness Web plugin that shows the running DSH version, discovers latest/next/alpha releases, warns about compatibility, and generates copy-only upgrade commands.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -99,6 +99,7 @@
99
99
  "test": "pnpm run typecheck && vitest run",
100
100
  "test:unit": "vitest run",
101
101
  "clean": "rm -rf lib coverage",
102
- "verify": "pnpm run test && pnpm run build && pnpm pack --dry-run"
102
+ "guard:shadowed-peer": "node tests/guards/shadowed-peer.mjs",
103
+ "verify": "pnpm run test && pnpm run build && pnpm run guard:shadowed-peer && pnpm pack --dry-run"
103
104
  }
104
105
  }