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/CHANGELOG.md +13 -0
- package/README.md +13 -4
- package/README.zh.md +13 -4
- package/lib/client.js +29 -0
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +75 -3
- package/lib/index.js +343 -28
- package/package.json +3 -2
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
501
|
-
warningKind: warningKindOf(
|
|
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.
|
|
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.
|
|
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
|
-
"
|
|
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
|
}
|