dsh-update-status 0.1.7 → 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. */
@@ -12,7 +13,11 @@ const UPDATE_STATUS_CHANNEL = "/api";
12
13
  * release the registry reports stays `unverified` — the plugin never claims a
13
14
  * compatibility nobody checked.
14
15
  */
15
- const VERIFIED_DSH_VERSIONS = ["0.1.7-alpha.2", "0.1.7-rc.1"];
16
+ const VERIFIED_DSH_VERSIONS = [
17
+ "0.1.7-alpha.2",
18
+ "0.1.7-rc.1",
19
+ "0.1.7-rc.2"
20
+ ];
16
21
  const RELEASE_CHANNELS = [
17
22
  "latest",
18
23
  "next",
@@ -47,7 +52,7 @@ function manifestAt(path) {
47
52
  return;
48
53
  }
49
54
  }
50
- function realPath(path) {
55
+ function realPath$1(path) {
51
56
  try {
52
57
  return realpathSync(path);
53
58
  } catch {
@@ -56,14 +61,14 @@ function realPath(path) {
56
61
  }
57
62
  /** Ascend from an entry file until the owning DSH package manifest is found. */
58
63
  function manifestFromEntry(entryPath) {
59
- let directory = dirname(realPath(entryPath));
64
+ let directory = dirname(realPath$1(entryPath));
60
65
  for (let depth = 0; depth < 10; depth += 1) {
61
66
  const manifest = join(directory, "package.json");
62
67
  if (existsSync(manifest)) {
63
68
  const found = manifestAt(manifest);
64
69
  if (found !== void 0) return {
65
70
  ...found,
66
- root: realPath(found.root)
71
+ root: realPath$1(found.root)
67
72
  };
68
73
  }
69
74
  const parent = dirname(directory);
@@ -76,7 +81,7 @@ function manifestFromResolver(resolve) {
76
81
  const found = manifestAt(resolve(PACKAGE_NAME + "/package.json"));
77
82
  if (found !== void 0) return {
78
83
  ...found,
79
- root: realPath(found.root)
84
+ root: realPath$1(found.root)
80
85
  };
81
86
  } catch {}
82
87
  try {
@@ -110,7 +115,7 @@ function ownResolver(selfUrl) {
110
115
  */
111
116
  function classifyInstallRoot(packageRoot, nodePrefix = process.execPath) {
112
117
  if (packageRoot === void 0) return "unknown";
113
- const root = normalize(realPath(packageRoot)).split(sep).join("/");
118
+ const root = normalize(realPath$1(packageRoot)).split(sep).join("/");
114
119
  const prefix = nodePrefix === void 0 ? "" : normalize(dirname(dirname(nodePrefix))).split(sep).join("/");
115
120
  if (!root.includes("/node_modules/") && /\/apps\/cli(?:\/|$)/.test(root)) return "source-checkout";
116
121
  if (root.includes("/.pnpm/") || /\/pnpm\/global(?:\/|$)/.test(root)) return "pnpm-global";
@@ -253,6 +258,278 @@ function installUpdateStatusRpc(ctx, service) {
253
258
  });
254
259
  }
255
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
256
533
  //#region src/shared/semver.ts
257
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-]+)*)?$/;
258
535
  const NUMERIC_IDENTIFIER = /^(0|[1-9]\d*)$/;
@@ -313,20 +590,34 @@ function boundedMessage(error) {
313
590
  const trimmed = (error instanceof Error ? error.message : String(error)).replace(/\s+/g, " ").trim();
314
591
  return trimmed === "" ? "unknown error" : trimmed.slice(0, 220);
315
592
  }
316
- function warningText(warning) {
593
+ /** English, single-line rendering of one warning; the Host log uses it directly. */
594
+ function describeWarning(warning) {
317
595
  switch (warning.code) {
318
596
  case "registry-unavailable": return `Unable to check the npm registry: ${warning.detail}`;
319
597
  case "channel-unavailable": return `The npm registry does not publish a ${warning.channel} channel.`;
320
598
  case "version-incomparable": return `Unable to compare the current version ${warning.currentVersion} with ${warning.channel} channel version ${warning.selectedVersion} using SemVer.`;
321
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
+ }
322
605
  }
323
606
  }
324
607
  function warningFallback(warnings) {
325
- return warnings.length === 0 ? null : warnings.map(warningText).join(" ");
608
+ return warnings.length === 0 ? null : warnings.map(describeWarning).join(" ");
326
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"];
327
618
  function warningKindOf(warnings) {
328
619
  if (warnings.length === 0) return null;
329
- return warnings.every((warning) => warning.code === "preview-unverified") ? "notice" : "failure";
620
+ return warnings.every((warning) => ADVISORY_CODES.includes(warning.code)) ? "notice" : "failure";
330
621
  }
331
622
  function dateOrNull(value) {
332
623
  if (typeof value !== "string" || value === "") return null;
@@ -395,6 +686,7 @@ var UpdateStatusService = class {
395
686
  now;
396
687
  ttlMs;
397
688
  releaseUrl;
689
+ runtimeWarnings;
398
690
  cache;
399
691
  inFlight;
400
692
  constructor(options) {
@@ -403,6 +695,7 @@ var UpdateStatusService = class {
403
695
  this.now = options.now ?? Date.now;
404
696
  this.ttlMs = Math.max(1, Math.floor(options.ttlMs ?? DEFAULT_TTL_MS));
405
697
  this.releaseUrl = options.releaseUrl ?? "https://github.com/deepseek-ai/deepseek-harness/releases";
698
+ this.runtimeWarnings = options.runtimeWarnings ?? [];
406
699
  }
407
700
  getStatus(channel = "latest", cacheTtlMinutes) {
408
701
  return this.check(false, channel, cacheTtlMinutes);
@@ -466,6 +759,7 @@ var UpdateStatusService = class {
466
759
  channel,
467
760
  version: selected.version
468
761
  });
762
+ warnings.push(...this.runtimeWarnings);
469
763
  return {
470
764
  currentVersion: this.installation.currentVersion,
471
765
  latestVersion: selected.version,
@@ -487,15 +781,16 @@ var UpdateStatusService = class {
487
781
  };
488
782
  }
489
783
  statusWithoutRemoteRelease(channel, warnings) {
784
+ const combined = [...warnings, ...this.runtimeWarnings];
490
785
  return {
491
786
  currentVersion: this.installation.currentVersion,
492
787
  latestVersion: null,
493
788
  hasUpdate: false,
494
789
  cached: false,
495
790
  checkedAt: null,
496
- warning: warningFallback(warnings),
497
- warningKind: warningKindOf(warnings),
498
- warnings,
791
+ warning: warningFallback(combined),
792
+ warningKind: warningKindOf(combined),
793
+ warnings: combined,
499
794
  installKind: this.installation.installKind,
500
795
  upgradeCommand: upgradeCommandFor(this.installation.installKind, this.installation.packageName || "@deepseek-ai/dsh", channel),
501
796
  releaseUrl: this.releaseUrl,
@@ -516,25 +811,46 @@ var UpdateStatusService = class {
516
811
  //#endregion
517
812
  //#region src/host/settings.ts
518
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
+ /**
519
845
  * The plugin's Config schema; its volatile fields ARE the settings form.
520
846
  *
521
847
  * The explicit two-argument annotation is load-bearing: a `.volatile()` field's
522
848
  * output is a stable reference (`Volatile<T>`) rather than the bare value the
523
849
  * input side takes, so the inferred schema type cannot be named by the emitted
524
- * `.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.
525
852
  */
526
- const Config = z.object({
527
- cacheTtlHours: z.number().step(1).min(1).max(24).default(6),
528
- timeoutMs: z.number().step(1).min(1e3).max(3e4).default(DEFAULT_TIMEOUT_MS),
529
- autoCheckOnMount: z.boolean().default(true),
530
- sidebarEnabled: z.boolean().default(true).volatile(),
531
- channel: z.union([
532
- "latest",
533
- "next",
534
- "alpha"
535
- ]).default("latest").loose().volatile(),
536
- cacheTtlMinutes: z.number().min(30).max(1440).default(360).volatile()
537
- });
853
+ const Config = buildConfigSchema(runtime.z, runtime.volatile);
538
854
  /**
539
855
  * Suppress the auto-generated settings page for this entry.
540
856
  *
@@ -572,14 +888,17 @@ function boundedNumber(value, fallback, min, max) {
572
888
  function apply(ctx, config = {}) {
573
889
  const ttlHours = boundedNumber(config.cacheTtlHours, 6, 1, 24);
574
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)}`);
575
893
  const service = new UpdateStatusService({
576
894
  installation: detectInstallation(ctx),
577
895
  fetchLatest: createRegistryFetcher(timeoutMs),
578
- ttlMs: ttlHours * 60 * 60 * 1e3
896
+ ttlMs: ttlHours * 60 * 60 * 1e3,
897
+ runtimeWarnings: schema.warning === null ? [] : [schema.warning]
579
898
  });
580
899
  installSettings(ctx);
581
900
  installUpdateStatusRpc(ctx, service);
582
901
  if (config.autoCheckOnMount !== false) service.getStatus();
583
902
  }
584
903
  //#endregion
585
- 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.7",
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",
@@ -63,7 +63,8 @@
63
63
  "compatibility": {
64
64
  "dshReleases": {
65
65
  "0.1.7-alpha.2": "compatible",
66
- "0.1.7-rc.1": "compatible"
66
+ "0.1.7-rc.1": "compatible",
67
+ "0.1.7-rc.2": "compatible"
67
68
  }
68
69
  }
69
70
  },
@@ -98,6 +99,7 @@
98
99
  "test": "pnpm run typecheck && vitest run",
99
100
  "test:unit": "vitest run",
100
101
  "clean": "rm -rf lib coverage",
101
- "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"
102
104
  }
103
105
  }