dsh-cliproxyapi-pro 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -4,6 +4,10 @@
4
4
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
5
5
  import { homedir } from "node:os";
6
6
  import { dirname, join } from "node:path";
7
+ import {
8
+ createPluginUpdater, loadUpdateSettings, saveUpdateSettings, storeUpdatePackage,
9
+ UPDATE_INTERVAL_MS, UpdateError,
10
+ } from "./updater.js";
7
11
 
8
12
  // #region Constants & Config Resolution
9
13
 
@@ -1613,6 +1617,10 @@ export const ROUTES = {
1613
1617
  save: "/api/dsh-cliproxyapi/save",
1614
1618
  addAccount: "/api/dsh-cliproxyapi/accounts/add",
1615
1619
  removeAccount: "/api/dsh-cliproxyapi/accounts/remove",
1620
+ updateStatus: "/api/dsh-cliproxyapi/updates/status",
1621
+ updateCheck: "/api/dsh-cliproxyapi/updates/check",
1622
+ updateSave: "/api/dsh-cliproxyapi/updates/save",
1623
+ updateDownload: "/api/dsh-cliproxyapi/updates/download",
1616
1624
  };
1617
1625
 
1618
1626
  function sendJson(res, status, payload) {
@@ -1687,6 +1695,24 @@ function admitRequest(ctx, req, res, expectedMethod) {
1687
1695
  return true;
1688
1696
  }
1689
1697
 
1698
+ /** Update writes are browser-only: require an explicit, exact same Origin. */
1699
+ function admitUpdateRequest(ctx, req, res, method) {
1700
+ if (!admitRequest(ctx, req, res, method)) return false;
1701
+ if (method === "GET") return true;
1702
+ const protocol = req.socket?.encrypted ? "https:" : "http:";
1703
+ try {
1704
+ const origin = new URL(req.headers.origin);
1705
+ const target = new URL(`${protocol}//${req.headers.host}`);
1706
+ if (!req.headers.host || origin.origin !== target.origin || req.headers.origin !== origin.origin || origin.username || origin.password) {
1707
+ throw new Error("Origin mismatch");
1708
+ }
1709
+ } catch {
1710
+ sendJson(res, 403, { ok: false, errorCode: "forbidden-origin", error: "Same-origin browser request required" });
1711
+ return false;
1712
+ }
1713
+ return true;
1714
+ }
1715
+
1690
1716
  async function readJsonBody(req, maxBytes = 256 * 1024) {
1691
1717
  const chunks = [];
1692
1718
  let size = 0;
@@ -1931,6 +1957,47 @@ function applyImpl(ctx, config) {
1931
1957
  savePersistedState(stateFilePath, state);
1932
1958
  };
1933
1959
 
1960
+ // Update preferences and packages never share the account state or credentials.
1961
+ const updateDirectory = dirname(stateFilePath);
1962
+ const updateSettingsPath = join(updateDirectory, "update-settings.json");
1963
+ let profile;
1964
+ try { profile = ctx.get?.("profileContext"); } catch {}
1965
+ const source = typeof profile?.dir === "string"
1966
+ ? loadPersistedState(join(profile.dir, "package.json")).dependencies?.[PACKAGE_NAME]
1967
+ : undefined;
1968
+ const linkedInstall = typeof source === "string" &&
1969
+ (/^(?:link:|workspace:)/.test(source) || /^(?:file:)?(?:[A-Za-z]:[\\/]|\/|\.{1,2}[\\/])/.test(source) && !/\.(?:tgz|tar\.gz)$/i.test(source) || source.startsWith("file:") && !/\.(?:tgz|tar\.gz)$/i.test(source));
1970
+ const currentVersion = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
1971
+ let updateTimer;
1972
+ let updateStartup;
1973
+ const scheduleUpdates = (preferences) => {
1974
+ clearInterval(updateTimer);
1975
+ clearTimeout(updateStartup);
1976
+ if (!preferences.autoCheck) return;
1977
+ outsideHmrTransaction(ctx, () => {
1978
+ const run = () => { void Promise.resolve().then(() => updater.check({ automatic: true })).catch(() => {}); };
1979
+ updateStartup = setTimeout(run, 3000);
1980
+ updateTimer = setInterval(run, UPDATE_INTERVAL_MS);
1981
+ });
1982
+ };
1983
+ const updater = createPluginUpdater({
1984
+ currentVersion,
1985
+ linkedInstall,
1986
+ profileName: profile?.name ?? profile?.profile ?? getEnvValue(ctx, "DSH_PROFILE"),
1987
+ readSettings: () => loadUpdateSettings(updateSettingsPath),
1988
+ writeSettings: (preferences) => saveUpdateSettings(updateSettingsPath, preferences),
1989
+ storePackage: (version, bytes) => storeUpdatePackage(join(updateDirectory, "updates"), version, bytes),
1990
+ onSettingsChanged: scheduleUpdates,
1991
+ });
1992
+ ctx.effect(() => {
1993
+ scheduleUpdates(updater.status());
1994
+ return () => {
1995
+ clearInterval(updateTimer);
1996
+ clearTimeout(updateStartup);
1997
+ updater.dispose();
1998
+ };
1999
+ }, "cpa: plugin update checks");
2000
+
1934
2001
  let timer;
1935
2002
  let startupTimeout;
1936
2003
 
@@ -2219,7 +2286,30 @@ function applyImpl(ctx, config) {
2219
2286
  }
2220
2287
  },
2221
2288
  });
2289
+ const updateRoute = (path, method, run) => webCtx.webServer.register({
2290
+ kind: "exact", path,
2291
+ handler: async (req, res) => {
2292
+ if (!admitUpdateRequest(ctx, req, res, method)) return;
2293
+ try {
2294
+ const body = method === "POST" ? await readJsonBody(req, 8192) : {};
2295
+ if (!plainObject(body)) throw new UpdateError("invalid-settings");
2296
+ if (path === ROUTES.updateDownload && (Object.keys(body).some((key) => key !== "version") || typeof body.version !== "string")) {
2297
+ throw new UpdateError("stale-version");
2298
+ }
2299
+ if (path === ROUTES.updateCheck && Object.keys(body).length) throw new UpdateError("invalid-settings");
2300
+ const update = await run(body);
2301
+ sendJson(res, 200, { ok: true, update });
2302
+ } catch (error) {
2303
+ const errorCode = error instanceof UpdateError ? error.code : "invalid-settings";
2304
+ sendJson(res, errorCode === "busy" ? 409 : 400, { ok: false, errorCode, error: errorCode, update: updater.status() });
2305
+ }
2306
+ },
2307
+ });
2222
2308
  const disposers = [
2309
+ updateRoute(ROUTES.updateStatus, "GET", () => updater.status()),
2310
+ updateRoute(ROUTES.updateCheck, "POST", () => updater.check()),
2311
+ updateRoute(ROUTES.updateSave, "POST", (body) => updater.save(body)),
2312
+ updateRoute(ROUTES.updateDownload, "POST", (body) => updater.download(body.version)),
2223
2313
  route(ROUTES.status, "GET", () => controller.statusPayload()),
2224
2314
  route(ROUTES.refresh, "POST", (body) => controller.refresh(body.accountId)),
2225
2315
  route(
package/lib/updater.js ADDED
@@ -0,0 +1,376 @@
1
+ // Plugin update checks and verified offline downloads. Never writes installed code.
2
+ // Only Node.js built-ins; npm / DSH remain responsible for installation.
3
+ import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
4
+ import { mkdirSync, readFileSync, renameSync, writeFileSync, unlinkSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import { gunzipSync } from "node:zlib";
7
+
8
+ export const UPDATE_PACKAGE = "dsh-cliproxyapi-pro";
9
+ export const UPDATE_REGISTRIES = Object.freeze({
10
+ global: "https://registry.npmjs.org",
11
+ cn: "https://registry.npmmirror.com",
12
+ });
13
+ export const UPDATE_TAGS = Object.freeze({ stable: "latest", beta: "beta", dev: "dev" });
14
+ export const UPDATE_INTERVAL_MS = 6 * 60 * 60 * 1000;
15
+ export const DEFAULT_UPDATE_SETTINGS = Object.freeze({
16
+ autoCheck: true, autoDownload: false, channel: "stable", region: "auto",
17
+ });
18
+ const MAX_METADATA_BYTES = 512 * 1024;
19
+ const MAX_PACKAGE_BYTES = 8 * 1024 * 1024;
20
+ const MAX_UNPACKED_BYTES = 16 * 1024 * 1024;
21
+ const PACKAGE_FILES = /^(?:package\.json|README(?:\.en)?\.md|CHANGELOG\.md|LICENSE|icon\.svg|cordis\.patch\.yml|lib\/[A-Za-z0-9_-]+\.js|locale\/(?:en|zh)\.json)$/;
22
+
23
+ export class UpdateError extends Error {
24
+ constructor(code) { super(code); this.code = code; }
25
+ }
26
+ const fail = (code) => { throw new UpdateError(code); };
27
+
28
+ export function normalizeUpdateSettings(raw = {}) {
29
+ return {
30
+ autoCheck: typeof raw?.autoCheck === "boolean" ? raw.autoCheck : true,
31
+ autoDownload: raw?.autoDownload === true,
32
+ channel: Object.hasOwn(UPDATE_TAGS, raw?.channel ?? "") ? raw.channel : "stable",
33
+ region: ["auto", "global", "cn"].includes(raw?.region) ? raw.region : "auto",
34
+ };
35
+ }
36
+
37
+ export function loadUpdateSettings(file) {
38
+ try {
39
+ const raw = JSON.parse(readFileSync(file, "utf8"));
40
+ if (!raw || typeof raw !== "object" || Array.isArray(raw) || raw.version !== 1 ||
41
+ ["autoCheck", "autoDownload"].some((key) => raw[key] !== undefined && typeof raw[key] !== "boolean") ||
42
+ raw.channel !== undefined && !Object.hasOwn(UPDATE_TAGS, raw.channel) ||
43
+ raw.region !== undefined && !["auto", "global", "cn"].includes(raw.region)) fail("invalid-settings");
44
+ return normalizeUpdateSettings(raw);
45
+ }
46
+ catch (error) {
47
+ if (error.code === "ENOENT") return { ...DEFAULT_UPDATE_SETTINGS };
48
+ // Corrupt or unreadable settings must not silently enable network access.
49
+ return { ...DEFAULT_UPDATE_SETTINGS, autoCheck: false };
50
+ }
51
+ }
52
+
53
+ export function saveUpdateSettings(file, settings) {
54
+ const temporary = `${file}.${randomUUID()}.tmp`;
55
+ try {
56
+ mkdirSync(dirname(file), { recursive: true });
57
+ writeFileSync(temporary, `${JSON.stringify({ version: 1, ...normalizeUpdateSettings(settings) }, null, 2)}\n`, { mode: 0o600, flag: "wx" });
58
+ renameSync(temporary, file);
59
+ } catch {
60
+ try { unlinkSync(temporary); } catch {}
61
+ fail("storage-error");
62
+ }
63
+ }
64
+
65
+ // Strict SemVer, including prerelease precedence. Never reuse model-name sorting.
66
+ export function parseUpdateVersion(value) {
67
+ if (typeof value !== "string" || value.length > 128) return null;
68
+ const match = /^(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-]+)*))?$/.exec(value);
69
+ if (!match) return null;
70
+ const parts = match.slice(1, 4).map(Number);
71
+ if (parts.some((n) => !Number.isSafeInteger(n))) return null;
72
+ const pre = match[4]?.split(".") ?? [];
73
+ if (pre.some((p) => /^\d+$/.test(p) && (p.length > 1 && p.startsWith("0") || !Number.isSafeInteger(Number(p))))) return null;
74
+ return { parts, pre };
75
+ }
76
+
77
+ export function compareUpdateVersions(a, b) {
78
+ const x = parseUpdateVersion(a), y = parseUpdateVersion(b);
79
+ if (!x || !y) fail("invalid-metadata");
80
+ for (let i = 0; i < 3; i++) if (x.parts[i] !== y.parts[i]) return x.parts[i] > y.parts[i] ? 1 : -1;
81
+ if (!x.pre.length || !y.pre.length) return x.pre.length ? -1 : y.pre.length ? 1 : 0;
82
+ for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
83
+ if (x.pre[i] === undefined || y.pre[i] === undefined) return x.pre[i] === undefined ? -1 : 1;
84
+ const p = x.pre[i], q = y.pre[i];
85
+ if (p === q) continue;
86
+ const pn = /^\d+$/.test(p), qn = /^\d+$/.test(q);
87
+ if (pn && qn) return Number(p) > Number(q) ? 1 : -1;
88
+ if (pn !== qn) return pn ? -1 : 1;
89
+ return p > q ? 1 : -1;
90
+ }
91
+ return 0;
92
+ }
93
+
94
+ function integrityDigest(integrity) {
95
+ if (typeof integrity !== "string") fail("invalid-metadata");
96
+ const token = integrity.split(/\s+/).find((part) => /^sha512-[A-Za-z0-9+/]{86}==$/.test(part));
97
+ if (!token) fail("invalid-metadata");
98
+ const digest = Buffer.from(token.slice(7), "base64");
99
+ if (digest.length !== 64) fail("invalid-metadata");
100
+ return digest;
101
+ }
102
+
103
+ export function validateUpdateMetadata(raw, channel) {
104
+ if (!Object.hasOwn(UPDATE_TAGS, channel)) fail("invalid-metadata");
105
+ const parsed = parseUpdateVersion(raw?.version);
106
+ if (raw?.name !== UPDATE_PACKAGE || !parsed || channel === "stable" && parsed.pre.length) fail("invalid-metadata");
107
+ // Keep channels honest even if an upstream dist-tag is accidentally moved.
108
+ if (channel !== "stable" && parsed.pre.length && !parsed.pre.some((p) => p === channel)) fail("invalid-metadata");
109
+ let url;
110
+ try { url = new URL(raw?.dist?.tarball); } catch { fail("invalid-metadata"); }
111
+ const hosts = ["registry.npmjs.org", "registry.npmmirror.com"];
112
+ if (url.protocol !== "https:" || !hosts.includes(url.hostname) || url.port || url.username || url.password || url.search || url.hash) fail("invalid-metadata");
113
+ if (url.pathname !== `/${UPDATE_PACKAGE}/-/${UPDATE_PACKAGE}-${raw.version}.tgz`) fail("invalid-metadata");
114
+ integrityDigest(raw.dist.integrity);
115
+ return { version: raw.version, integrity: raw.dist.integrity };
116
+ }
117
+
118
+ async function readBounded(response, maxBytes) {
119
+ const declared = Number(response.headers.get("content-length"));
120
+ if (Number.isFinite(declared) && declared > maxBytes) { await response.body?.cancel(); fail("invalid-package"); }
121
+ const chunks = [];
122
+ let length = 0;
123
+ if (!response.body) fail("network-error");
124
+ const reader = response.body.getReader();
125
+ try {
126
+ while (true) {
127
+ const { done, value } = await reader.read();
128
+ if (done) break;
129
+ length += value.length;
130
+ if (length > maxBytes) { await reader.cancel(); fail("invalid-package"); }
131
+ chunks.push(Buffer.from(value));
132
+ }
133
+ } finally { reader.releaseLock(); }
134
+ return Buffer.concat(chunks, length);
135
+ }
136
+
137
+ function allowedDownloadUrl(value, version, region) {
138
+ let url;
139
+ try { url = new URL(value); } catch { fail("invalid-package"); }
140
+ if (url.protocol !== "https:" || url.port || url.username || url.password || url.search || url.hash) fail("invalid-package");
141
+ const registryPath = `/${UPDATE_PACKAGE}/-/${UPDATE_PACKAGE}-${version}.tgz`;
142
+ const cdnPath = `/packages/${UPDATE_PACKAGE}/${version}/${UPDATE_PACKAGE}-${version}.tgz`;
143
+ if (region === "global") {
144
+ if (url.hostname !== "registry.npmjs.org" || url.pathname !== registryPath) fail("invalid-package");
145
+ } else if (!(url.hostname === "registry.npmmirror.com" && url.pathname === registryPath ||
146
+ url.hostname === "cdn.npmmirror.com" && url.pathname === cdnPath)) fail("invalid-package");
147
+ return url.href;
148
+ }
149
+
150
+ async function fetchBytes(fetcher, url, signal, limit, download = null) {
151
+ // npmmirror redirects tarballs to its official CDN. Follow only bounded,
152
+ // exact package/version URLs; never let fetch follow an unvalidated location.
153
+ for (let redirects = 0; ; redirects++) {
154
+ const response = await fetcher(url, {
155
+ signal, redirect: download ? "manual" : "error", credentials: "omit",
156
+ headers: { accept: "application/json, application/octet-stream" },
157
+ });
158
+ if ([301, 302, 303, 307, 308].includes(response.status)) {
159
+ const location = response.headers.get("location");
160
+ await response.body?.cancel();
161
+ if (!download || redirects >= 3 || !location) fail("invalid-package");
162
+ let next;
163
+ try { next = new URL(location, url).href; } catch { fail("invalid-package"); }
164
+ url = allowedDownloadUrl(next, download.version, download.region);
165
+ continue;
166
+ }
167
+ if (response.status === 404) { await response.body?.cancel(); fail("channel-unavailable"); }
168
+ if (!response.ok) { await response.body?.cancel(); fail("network-error"); }
169
+ return readBounded(response, limit);
170
+ }
171
+ }
172
+
173
+ /** Validate checksum and a restricted npm tar archive without extracting any file. */
174
+ export function verifyUpdatePackage(bytes, expected) {
175
+ const digest = createHash("sha512").update(bytes).digest();
176
+ if (!timingSafeEqual(digest, integrityDigest(expected.integrity))) fail("integrity-error");
177
+ let tar;
178
+ try { tar = gunzipSync(bytes, { maxOutputLength: MAX_UNPACKED_BYTES }); }
179
+ catch { fail("invalid-package"); }
180
+ const seen = new Set();
181
+ let manifest;
182
+ let ended = false;
183
+ const text = (buffer) => buffer.toString("utf8").replace(/\0.*$/s, "");
184
+ for (let offset = 0; offset + 512 <= tar.length;) {
185
+ const header = tar.subarray(offset, offset + 512);
186
+ if (header.every((b) => b === 0)) {
187
+ if (tar.subarray(offset).some((b) => b !== 0)) fail("invalid-package");
188
+ ended = true;
189
+ break;
190
+ }
191
+ const octal = (buffer) => {
192
+ const value = text(buffer).trim();
193
+ if (!/^[0-7]+$/.test(value)) fail("invalid-package");
194
+ return parseInt(value, 8);
195
+ };
196
+ const size = octal(header.subarray(124, 136));
197
+ const checksum = octal(header.subarray(148, 156));
198
+ let sum = 0;
199
+ for (let i = 0; i < 512; i++) sum += i >= 148 && i < 156 ? 32 : header[i];
200
+ if (sum !== checksum || size > MAX_UNPACKED_BYTES || offset + 512 + size > tar.length) fail("invalid-package");
201
+ const prefix = text(header.subarray(345, 500));
202
+ const path = `${prefix ? `${prefix}/` : ""}${text(header.subarray(0, 100))}`;
203
+ if (!path.startsWith("package/") || path.includes("\\") || path.split("/").some((p) => p === ".." || p === ".") || seen.has(path)) fail("invalid-package");
204
+ seen.add(path);
205
+ const type = header[156];
206
+ if (type === 53) {
207
+ if (size !== 0 || !["package/", "package/lib/", "package/locale/"].includes(path)) fail("invalid-package");
208
+ } else if ((type === 0 || type === 48) && PACKAGE_FILES.test(path.slice(8))) {
209
+ if (path === "package/package.json") {
210
+ try { manifest = JSON.parse(tar.subarray(offset + 512, offset + 512 + size).toString("utf8")); }
211
+ catch { fail("invalid-package"); }
212
+ }
213
+ } else fail("invalid-package");
214
+ offset += 512 + Math.ceil(size / 512) * 512;
215
+ }
216
+ const required = ["package.json", "lib/index.js", "lib/client.js", "cordis.patch.yml", "locale/en.json", "locale/zh.json"];
217
+ if (!ended || required.some((p) => !seen.has(`package/${p}`)) || manifest?.name !== UPDATE_PACKAGE || manifest?.version !== expected.version || manifest?.dsh?.bundle?.patch !== "./cordis.patch.yml") fail("invalid-package");
218
+ if (["dependencies", "optionalDependencies", "bundledDependencies", "bundleDependencies"].some((key) => manifest[key] && Object.keys(manifest[key]).length)) fail("invalid-package");
219
+ if (Object.keys(manifest.scripts ?? {}).some((key) => /^(?:preinstall|install|postinstall|prepare|prepublish|prepublishOnly|prepack|postpack)$/.test(key))) fail("invalid-package");
220
+ return manifest;
221
+ }
222
+
223
+ function quotedArgument(value) {
224
+ // CLI examples must not turn local paths/profile names into shell syntax.
225
+ // Both PowerShell and POSIX shells accept single-quoted strings without apostrophes.
226
+ if (typeof value !== "string" || /['\r\n\0]/.test(value)) return null;
227
+ return `'${value}'`;
228
+ }
229
+
230
+ /** Injected I/O makes every update test offline and independent of the real profile. */
231
+ export function createPluginUpdater(deps) {
232
+ const fetcher = deps.fetch ?? globalThis.fetch;
233
+ const now = deps.now ?? (() => new Date().toISOString());
234
+ let settings = normalizeUpdateSettings(deps.readSettings());
235
+ let candidate = null;
236
+ let generation = 0;
237
+ let disposed = false;
238
+ let checkPromise = null;
239
+ let downloadPromise = null;
240
+ let saving = false;
241
+ const abort = new AbortController();
242
+ const initial = {
243
+ currentVersion: deps.currentVersion, latestVersion: null, available: false,
244
+ effectiveRegion: null, lastCheckedAt: null, error: null, errorCode: null,
245
+ downloadedVersion: null, downloadPath: null, upgradeCommand: null,
246
+ blockedReason: deps.linkedInstall ? "linked-install" : null,
247
+ };
248
+ let state = { ...initial };
249
+ const status = () => ({ ...state, ...settings, checking: checkPromise !== null, downloading: downloadPromise !== null });
250
+ const assertLive = () => { if (disposed) fail("disposed"); };
251
+ const recordError = (error) => {
252
+ const code = error instanceof UpdateError ? error.code : "network-error";
253
+ state.error = code;
254
+ state.errorCode = code;
255
+ };
256
+
257
+ const lookup = async (region, snapshot, signal) => {
258
+ const bytes = await fetchBytes(fetcher, `${UPDATE_REGISTRIES[region]}/${UPDATE_PACKAGE}/${UPDATE_TAGS[snapshot.channel]}`, signal, MAX_METADATA_BYTES);
259
+ let raw;
260
+ try { raw = JSON.parse(bytes.toString("utf8")); } catch { fail("invalid-metadata"); }
261
+ return { ...validateUpdateMetadata(raw, snapshot.channel), region };
262
+ };
263
+
264
+ const download = (version) => {
265
+ assertLive();
266
+ if (downloadPromise) return downloadPromise;
267
+ if (checkPromise || saving) fail("busy");
268
+ if (!candidate || !state.available || version !== candidate.version) fail("stale-version");
269
+ const selected = { ...candidate };
270
+ state.error = state.errorCode = null;
271
+ downloadPromise = Promise.resolve().then(async () => {
272
+ const signal = AbortSignal.any([abort.signal, AbortSignal.timeout(60_000)]);
273
+ const url = `${UPDATE_REGISTRIES[selected.region]}/${UPDATE_PACKAGE}/-/${UPDATE_PACKAGE}-${selected.version}.tgz`;
274
+ const bytes = await fetchBytes(fetcher, url, signal, MAX_PACKAGE_BYTES, selected);
275
+ assertLive();
276
+ verifyUpdatePackage(bytes, selected);
277
+ let path;
278
+ try { path = await deps.storePackage(selected.version, bytes); }
279
+ catch { fail("storage-error"); }
280
+ assertLive();
281
+ const profile = quotedArgument(deps.profileName);
282
+ const target = quotedArgument(path);
283
+ state.downloadedVersion = selected.version;
284
+ state.downloadPath = path;
285
+ state.upgradeCommand = !deps.linkedInstall && profile && target ? `dsh plugin --profile ${profile} add ${target}` : null;
286
+ }).catch((error) => {
287
+ recordError(error);
288
+ }).finally(() => { downloadPromise = null; }).then(status);
289
+ return downloadPromise;
290
+ };
291
+
292
+ const check = ({ automatic = false } = {}) => {
293
+ assertLive();
294
+ if (checkPromise) return checkPromise;
295
+ if (downloadPromise || saving) fail("busy");
296
+ if (automatic && !settings.autoCheck) return Promise.resolve(status());
297
+ const snapshot = { ...settings };
298
+ const revision = generation;
299
+ state.error = state.errorCode = null;
300
+ checkPromise = Promise.resolve().then(async () => {
301
+ const winnerAbort = new AbortController();
302
+ const signal = AbortSignal.any([abort.signal, winnerAbort.signal, AbortSignal.timeout(15_000)]);
303
+ let result;
304
+ try {
305
+ result = snapshot.region === "auto"
306
+ ? await Promise.any([lookup("global", snapshot, signal), lookup("cn", snapshot, signal)]).catch((error) => {
307
+ const failures = error.errors ?? [];
308
+ throw failures.find((e) => e.code === "invalid-metadata") ?? failures.find((e) => e.code === "channel-unavailable") ?? new UpdateError("network-error");
309
+ })
310
+ : await lookup(snapshot.region, snapshot, signal);
311
+ } finally { winnerAbort.abort(); }
312
+ assertLive();
313
+ if (revision !== generation) return;
314
+ candidate = result;
315
+ state.latestVersion = result.version;
316
+ state.available = compareUpdateVersions(result.version, deps.currentVersion) > 0;
317
+ state.effectiveRegion = result.region;
318
+ state.lastCheckedAt = now();
319
+ }).catch((error) => {
320
+ if (revision !== generation || disposed) return;
321
+ candidate = null;
322
+ state.available = false;
323
+ state.latestVersion = null;
324
+ state.lastCheckedAt = now();
325
+ recordError(error);
326
+ }).finally(() => { checkPromise = null; }).then(async () => {
327
+ if (!disposed && automatic && settings.autoCheck && settings.autoDownload && revision === generation && state.available && state.downloadedVersion !== candidate?.version) {
328
+ await download(candidate.version);
329
+ }
330
+ return status();
331
+ });
332
+ return checkPromise;
333
+ };
334
+
335
+ return {
336
+ status, check, download,
337
+ async save(patch = {}) {
338
+ assertLive();
339
+ if (checkPromise || downloadPromise || saving) fail("busy");
340
+ if (!patch || typeof patch !== "object" || Array.isArray(patch)) fail("invalid-settings");
341
+ if (Object.keys(patch).some((key) => !Object.hasOwn(DEFAULT_UPDATE_SETTINGS, key))) fail("invalid-settings");
342
+ if (Object.hasOwn(patch, "channel") && !Object.hasOwn(UPDATE_TAGS, patch.channel) || Object.hasOwn(patch, "region") && !["auto", "global", "cn"].includes(patch.region)) fail("invalid-settings");
343
+ for (const key of ["autoCheck", "autoDownload"]) if (Object.hasOwn(patch, key) && typeof patch[key] !== "boolean") fail("invalid-settings");
344
+ const next = normalizeUpdateSettings({ ...settings, ...patch });
345
+ saving = true;
346
+ try {
347
+ try { await deps.writeSettings(next); } catch { fail("storage-error"); }
348
+ assertLive();
349
+ const changedSource = next.channel !== settings.channel || next.region !== settings.region;
350
+ settings = next;
351
+ state.error = state.errorCode = null;
352
+ generation += 1;
353
+ if (changedSource) { candidate = null; state = { ...initial }; }
354
+ deps.onSettingsChanged?.(settings);
355
+ return status();
356
+ } finally { saving = false; }
357
+ },
358
+ dispose() { disposed = true; generation += 1; abort.abort(); },
359
+ };
360
+ }
361
+
362
+ /** Atomic same-directory writes; no package extraction, install or profile edits. */
363
+ export function storeUpdatePackage(directory, version, bytes) {
364
+ if (!parseUpdateVersion(version)) fail("invalid-package");
365
+ const path = join(directory, `${UPDATE_PACKAGE}-${version}.tgz`);
366
+ const temporary = join(directory, `.${randomUUID()}.tmp`);
367
+ try {
368
+ mkdirSync(directory, { recursive: true });
369
+ writeFileSync(temporary, bytes, { flag: "wx", mode: 0o600 });
370
+ renameSync(temporary, path);
371
+ } catch {
372
+ try { unlinkSync(temporary); } catch {}
373
+ fail("storage-error");
374
+ }
375
+ return path;
376
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cliproxyapi-pro",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "description": "Auto-discovery CLIProxyAPI provider and management UI for DeepSeek Harness — multiple accounts, dynamic model sync, capability detection, and visual settings.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -37,6 +37,7 @@
37
37
  "files": [
38
38
  "lib/index.js",
39
39
  "lib/client.js",
40
+ "lib/updater.js",
40
41
  "locale/*.json",
41
42
  "cordis.patch.yml",
42
43
  "icon.svg",
@@ -64,7 +65,7 @@
64
65
  },
65
66
  "scripts": {
66
67
  "test": "node --test tests/*.test.mjs",
67
- "check": "node --check lib/index.js && node --check lib/client.js && node --test tests/*.test.mjs",
68
+ "check": "node --check lib/index.js && node --check lib/client.js && node --check lib/updater.js && node --test tests/*.test.mjs",
68
69
  "pack:check": "npm pack --dry-run"
69
70
  }
70
71
  }