dsh-update-plugin 0.1.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/CHANGELOG.md +43 -0
- package/LICENSE +21 -0
- package/README.md +151 -0
- package/README.zh-CN.md +134 -0
- package/assets/screenshots/general-en.webp +0 -0
- package/assets/screenshots/general-zh.webp +0 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +1063 -0
- package/lib/index.js +366 -0
- package/lib/update-core.js +669 -0
- package/package.json +65 -0
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
// dsh-update-plugin host-side update core.
|
|
2
|
+
//
|
|
3
|
+
// This module intentionally depends on Node built-ins only. It mirrors the
|
|
4
|
+
// logic of the dsh-update-all shell script (resolve the newest CLI across all
|
|
5
|
+
// npm dist-tags, discover profiles, back up, update the CLI and every profile)
|
|
6
|
+
// so the plugin can work without Homebrew or a separately installed script.
|
|
7
|
+
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
10
|
+
import { copyFile, mkdir, readFile, readdir, realpath, writeFile } from "node:fs/promises";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
export const PACKAGE_NAME = "@deepseek-ai/dsh";
|
|
16
|
+
export const REGISTRY = "https://registry.npmjs.org";
|
|
17
|
+
export const DEFAULT_PROFILE = "web";
|
|
18
|
+
export const MIN_AGE = process.env.DSH_UPDATE_MIN_AGE ?? "0";
|
|
19
|
+
export const CLI_TIMEOUT_MS = 10 * 60 * 1000;
|
|
20
|
+
export const PROFILE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
21
|
+
|
|
22
|
+
export const CONFIG_FILENAME = "dsh-update-plugin.json";
|
|
23
|
+
export const DEFAULT_CONFIG = { channel: "auto", minAge: 0 };
|
|
24
|
+
const CHANNELS = new Set(["auto", "stable", "next", "alpha"]);
|
|
25
|
+
|
|
26
|
+
export function normalizeConfig(raw) {
|
|
27
|
+
const source = raw && typeof raw === "object" ? raw : {};
|
|
28
|
+
const config = { ...DEFAULT_CONFIG, ...source };
|
|
29
|
+
if (!CHANNELS.has(config.channel)) config.channel = DEFAULT_CONFIG.channel;
|
|
30
|
+
const minAge = Number(config.minAge);
|
|
31
|
+
config.minAge = Number.isInteger(minAge) && minAge >= 0 ? minAge : DEFAULT_CONFIG.minAge;
|
|
32
|
+
return config;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function readConfig(dshHome) {
|
|
36
|
+
try {
|
|
37
|
+
return normalizeConfig(JSON.parse(await readFile(join(dshHome, CONFIG_FILENAME), "utf8")));
|
|
38
|
+
} catch {
|
|
39
|
+
return { ...DEFAULT_CONFIG };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function writeConfig(dshHome, patch) {
|
|
44
|
+
const next = normalizeConfig({ ...(await readConfig(dshHome)), ...(patch || {}) });
|
|
45
|
+
await mkdir(dshHome, { recursive: true });
|
|
46
|
+
await writeFile(join(dshHome, CONFIG_FILENAME), `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
47
|
+
return next;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// semver helpers
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
export function parseSemver(value) {
|
|
54
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(value || ""));
|
|
55
|
+
if (!match) return undefined;
|
|
56
|
+
return {
|
|
57
|
+
major: Number(match[1]),
|
|
58
|
+
minor: Number(match[2]),
|
|
59
|
+
patch: Number(match[3]),
|
|
60
|
+
prerelease: match[4] ? match[4].split(".") : [],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function comparePrerelease(left, right) {
|
|
65
|
+
if (left.length === 0 || right.length === 0) {
|
|
66
|
+
if (left.length === right.length) return 0;
|
|
67
|
+
return left.length === 0 ? 1 : -1;
|
|
68
|
+
}
|
|
69
|
+
const length = Math.max(left.length, right.length);
|
|
70
|
+
for (let index = 0; index < length; index += 1) {
|
|
71
|
+
const a = left[index];
|
|
72
|
+
const b = right[index];
|
|
73
|
+
if (a === undefined || b === undefined) {
|
|
74
|
+
if (a === b) return 0;
|
|
75
|
+
return a === undefined ? -1 : 1;
|
|
76
|
+
}
|
|
77
|
+
if (a === b) continue;
|
|
78
|
+
const aNumeric = /^\d+$/.test(a);
|
|
79
|
+
const bNumeric = /^\d+$/.test(b);
|
|
80
|
+
if (aNumeric && bNumeric) {
|
|
81
|
+
const aNumber = BigInt(a);
|
|
82
|
+
const bNumber = BigInt(b);
|
|
83
|
+
if (aNumber !== bNumber) return aNumber > bNumber ? 1 : -1;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (aNumeric !== bNumeric) return aNumeric ? -1 : 1;
|
|
87
|
+
return a > b ? 1 : -1;
|
|
88
|
+
}
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function compareSemver(left, right) {
|
|
93
|
+
const a = parseSemver(left);
|
|
94
|
+
const b = parseSemver(right);
|
|
95
|
+
if (!a || !b) return 0;
|
|
96
|
+
for (const key of ["major", "minor", "patch"]) {
|
|
97
|
+
if (a[key] !== b[key]) return a[key] > b[key] ? 1 : -1;
|
|
98
|
+
}
|
|
99
|
+
return comparePrerelease(a.prerelease, b.prerelease);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function isNewerVersion(current, candidate) {
|
|
103
|
+
const a = parseSemver(current);
|
|
104
|
+
const b = parseSemver(candidate);
|
|
105
|
+
if (!b) return false;
|
|
106
|
+
if (!a) return true;
|
|
107
|
+
return compareSemver(candidate, current) > 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function pickNewestVersion(tags) {
|
|
111
|
+
const versions = [...new Set(Object.values(tags || {}))].filter((value) => typeof value === "string" && parseSemver(value));
|
|
112
|
+
if (versions.length === 0) return undefined;
|
|
113
|
+
versions.sort(compareSemver);
|
|
114
|
+
return versions[versions.length - 1];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function cleanVersion(value) {
|
|
118
|
+
const match = String(value || "").match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/);
|
|
119
|
+
return match ? match[0] : "";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// child-process helpers
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
function lastNonEmptyLine(value) {
|
|
126
|
+
return String(value || "")
|
|
127
|
+
.split(/\r?\n/)
|
|
128
|
+
.map((line) => line.trim())
|
|
129
|
+
.filter(Boolean)
|
|
130
|
+
.pop();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function runCommand(options) {
|
|
134
|
+
const {
|
|
135
|
+
cmd,
|
|
136
|
+
args = [],
|
|
137
|
+
cwd,
|
|
138
|
+
env,
|
|
139
|
+
timeoutMs = 0,
|
|
140
|
+
onLine,
|
|
141
|
+
shell = false,
|
|
142
|
+
} = options || {};
|
|
143
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
144
|
+
let child;
|
|
145
|
+
try {
|
|
146
|
+
child = spawn(cmd, args, {
|
|
147
|
+
cwd,
|
|
148
|
+
env: { ...process.env, NO_COLOR: "1", ...(env || {}) },
|
|
149
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
150
|
+
windowsHide: true,
|
|
151
|
+
shell,
|
|
152
|
+
});
|
|
153
|
+
} catch (error) {
|
|
154
|
+
rejectPromise(error);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let stdout = "";
|
|
159
|
+
let stderr = "";
|
|
160
|
+
let timer = null;
|
|
161
|
+
let settled = false;
|
|
162
|
+
const finish = (callback, payload) => {
|
|
163
|
+
if (settled) return;
|
|
164
|
+
settled = true;
|
|
165
|
+
if (timer) clearTimeout(timer);
|
|
166
|
+
callback(payload);
|
|
167
|
+
};
|
|
168
|
+
const emit = (text) => {
|
|
169
|
+
if (!onLine) return;
|
|
170
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
171
|
+
const trimmed = line.trim();
|
|
172
|
+
if (trimmed) onLine(trimmed);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
child.stdout?.on("data", (chunk) => {
|
|
177
|
+
const text = String(chunk);
|
|
178
|
+
stdout = (stdout + text).slice(-40000);
|
|
179
|
+
emit(text);
|
|
180
|
+
});
|
|
181
|
+
child.stderr?.on("data", (chunk) => {
|
|
182
|
+
const text = String(chunk);
|
|
183
|
+
stderr = (stderr + text).slice(-40000);
|
|
184
|
+
emit(text);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
if (timeoutMs > 0) {
|
|
188
|
+
timer = setTimeout(() => {
|
|
189
|
+
try {
|
|
190
|
+
child.kill("SIGTERM");
|
|
191
|
+
} catch {
|
|
192
|
+
// ignore
|
|
193
|
+
}
|
|
194
|
+
const error = new Error(`command timed out after ${Math.round(timeoutMs / 1000)}s: ${cmd}`);
|
|
195
|
+
error.code = "TIMEOUT";
|
|
196
|
+
finish(rejectPromise, error);
|
|
197
|
+
}, timeoutMs);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
child.once("error", (error) => finish(rejectPromise, error));
|
|
201
|
+
child.once("exit", (code) => {
|
|
202
|
+
if (code === 0) {
|
|
203
|
+
finish(resolvePromise, { code, stdout, stderr });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const detail = lastNonEmptyLine(stderr) || lastNonEmptyLine(stdout) || `command exited with code ${code}: ${cmd}`;
|
|
207
|
+
const error = new Error(detail);
|
|
208
|
+
error.code = code;
|
|
209
|
+
error.stdout = stdout;
|
|
210
|
+
error.stderr = stderr;
|
|
211
|
+
finish(rejectPromise, error);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// runtime discovery
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
function isDshCliEntry(entry, manifest, packageRoot) {
|
|
220
|
+
if (!manifest || typeof manifest !== "object" || manifest.name !== PACKAGE_NAME) return false;
|
|
221
|
+
const bin = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.dsh;
|
|
222
|
+
return typeof bin === "string" && bin !== "" && !bin.startsWith("/") && resolve(packageRoot, bin) === entry;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function findCliEntry() {
|
|
226
|
+
const raw = process.argv[1];
|
|
227
|
+
if (!raw) return undefined;
|
|
228
|
+
const entry = raw.startsWith("file:") ? fileURLToPath(raw) : resolve(process.cwd(), raw);
|
|
229
|
+
if (!existsSync(entry)) return undefined;
|
|
230
|
+
let directory = dirname(entry);
|
|
231
|
+
for (;;) {
|
|
232
|
+
const manifestPath = join(directory, "package.json");
|
|
233
|
+
if (existsSync(manifestPath)) {
|
|
234
|
+
try {
|
|
235
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
236
|
+
if (isDshCliEntry(entry, manifest, directory)) return entry;
|
|
237
|
+
} catch {
|
|
238
|
+
// keep walking
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const parent = dirname(directory);
|
|
242
|
+
if (parent === directory) return undefined;
|
|
243
|
+
directory = parent;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function runtimeFromProcess() {
|
|
248
|
+
const dshHome = process.env.DSH_HOME ? resolve(process.env.DSH_HOME) : join(homedir(), ".dsh");
|
|
249
|
+
let profileName = process.env.DSH_PROFILE_NAME || "";
|
|
250
|
+
if (!profileName && process.env.DSH_PROFILE_DIR) profileName = basename(process.env.DSH_PROFILE_DIR);
|
|
251
|
+
const argv = process.argv;
|
|
252
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
253
|
+
const arg = argv[index];
|
|
254
|
+
if (arg === "--profile" && argv[index + 1]) {
|
|
255
|
+
profileName = argv[index + 1];
|
|
256
|
+
index += 1;
|
|
257
|
+
} else if (arg.startsWith("--profile=")) {
|
|
258
|
+
profileName = arg.slice("--profile=".length);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (!profileName || profileName === "." || profileName === ".." || profileName.includes("/") || profileName.includes("\\")) {
|
|
262
|
+
profileName = DEFAULT_PROFILE;
|
|
263
|
+
}
|
|
264
|
+
const profileDir = process.env.DSH_PROFILE_DIR ? resolve(process.env.DSH_PROFILE_DIR) : join(dshHome, "profiles", profileName);
|
|
265
|
+
return {
|
|
266
|
+
dshHome,
|
|
267
|
+
profileName,
|
|
268
|
+
profileDir,
|
|
269
|
+
cliEntry: findCliEntry(),
|
|
270
|
+
nodePath: process.execPath,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function cliInvocation(runtime, args = []) {
|
|
275
|
+
if (runtime.cliEntry) {
|
|
276
|
+
return { cmd: runtime.nodePath || process.execPath, args: [runtime.cliEntry, ...args], shell: false };
|
|
277
|
+
}
|
|
278
|
+
return { cmd: "dsh", args, shell: process.platform === "win32" };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export async function currentCliVersion(runtime, options = {}) {
|
|
282
|
+
const invocation = cliInvocation(runtime, ["--version"]);
|
|
283
|
+
const result = await runCommand({
|
|
284
|
+
...invocation,
|
|
285
|
+
cwd: options.cwd || runtime.profileDir,
|
|
286
|
+
timeoutMs: 20000,
|
|
287
|
+
onLine: options.onLine,
|
|
288
|
+
});
|
|
289
|
+
return cleanVersion(result.stdout || result.stderr);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// registry / profiles / backups
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
export async function fetchTargetVersion(fetchImpl = globalThis.fetch, options = {}) {
|
|
296
|
+
if (typeof fetchImpl !== "function") throw new Error("global fetch is not available in this Node.js runtime");
|
|
297
|
+
const url = `${REGISTRY}/-/package/${encodeURIComponent(PACKAGE_NAME)}/dist-tags`;
|
|
298
|
+
const response = await fetchImpl(url, {
|
|
299
|
+
headers: { accept: "application/json" },
|
|
300
|
+
signal: typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(options.timeoutMs || 10000) : undefined,
|
|
301
|
+
});
|
|
302
|
+
if (!response || !response.ok) throw new Error(`registry returned HTTP ${response ? response.status : "?"}`);
|
|
303
|
+
const tags = await response.json();
|
|
304
|
+
const channel = options.channel || "auto";
|
|
305
|
+
if (channel !== "auto") {
|
|
306
|
+
const tagName = channel === "stable" ? "latest" : channel;
|
|
307
|
+
const version = tags[tagName];
|
|
308
|
+
if (!version) throw new Error(`dist-tag "${tagName}" not found for ${PACKAGE_NAME}`);
|
|
309
|
+
return version;
|
|
310
|
+
}
|
|
311
|
+
const newest = pickNewestVersion(tags);
|
|
312
|
+
if (!newest) throw new Error(`no published versions found for ${PACKAGE_NAME}`);
|
|
313
|
+
return newest;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export async function listProfiles(dshHome) {
|
|
317
|
+
const profilesDir = join(dshHome, "profiles");
|
|
318
|
+
let entries = [];
|
|
319
|
+
try {
|
|
320
|
+
entries = await readdir(profilesDir, { withFileTypes: true });
|
|
321
|
+
} catch {
|
|
322
|
+
return [];
|
|
323
|
+
}
|
|
324
|
+
const profiles = [];
|
|
325
|
+
for (const entry of entries) {
|
|
326
|
+
if (!entry.isDirectory()) continue;
|
|
327
|
+
const dir = join(profilesDir, entry.name);
|
|
328
|
+
try {
|
|
329
|
+
const manifest = JSON.parse(await readFile(join(dir, "package.json"), "utf8"));
|
|
330
|
+
const dependencies = { ...(manifest.dependencies || {}), ...(manifest.devDependencies || {}), ...(manifest.optionalDependencies || {}) };
|
|
331
|
+
const dependencyCount = Object.keys(dependencies).length;
|
|
332
|
+
if (dependencyCount > 0) profiles.push({ name: entry.name, dir, dependencyCount });
|
|
333
|
+
} catch {
|
|
334
|
+
// a profile without a readable package.json is not something we can update
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return profiles.sort((a, b) => a.name.localeCompare(b.name));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function copyIfExists(from, to) {
|
|
341
|
+
try {
|
|
342
|
+
await copyFile(from, to);
|
|
343
|
+
} catch {
|
|
344
|
+
// optional file
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export async function createBackup(runtime, profiles, currentVersion) {
|
|
349
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
350
|
+
const dir = join(runtime.dshHome, "update-backups", `plugin-${stamp}`);
|
|
351
|
+
await mkdir(join(dir, "profiles"), { recursive: true });
|
|
352
|
+
const manifest = {
|
|
353
|
+
source: "dsh-update-plugin",
|
|
354
|
+
createdAt: new Date().toISOString(),
|
|
355
|
+
cliVersion: currentVersion || null,
|
|
356
|
+
profileNames: profiles.map((profile) => profile.name),
|
|
357
|
+
};
|
|
358
|
+
await writeFile(join(dir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
359
|
+
for (const profile of profiles) {
|
|
360
|
+
const target = join(dir, "profiles", profile.name);
|
|
361
|
+
await mkdir(target, { recursive: true });
|
|
362
|
+
await copyIfExists(join(profile.dir, "package.json"), join(target, "package.json"));
|
|
363
|
+
await copyIfExists(join(profile.dir, "pnpm-lock.yaml"), join(target, "pnpm-lock.yaml"));
|
|
364
|
+
}
|
|
365
|
+
return dir;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export async function listBackups(dshHome) {
|
|
369
|
+
const root = join(dshHome, "update-backups");
|
|
370
|
+
let entries = [];
|
|
371
|
+
try {
|
|
372
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
373
|
+
} catch {
|
|
374
|
+
return [];
|
|
375
|
+
}
|
|
376
|
+
const backups = [];
|
|
377
|
+
for (const entry of entries) {
|
|
378
|
+
if (!entry.isDirectory()) continue;
|
|
379
|
+
const dir = join(root, entry.name);
|
|
380
|
+
let manifest = {};
|
|
381
|
+
try {
|
|
382
|
+
manifest = JSON.parse(await readFile(join(dir, "manifest.json"), "utf8"));
|
|
383
|
+
} catch {
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
backups.push({
|
|
387
|
+
id: entry.name,
|
|
388
|
+
dir,
|
|
389
|
+
source: manifest.source || "dsh-update-all",
|
|
390
|
+
createdAt: manifest.createdAt || manifest.timestamp || null,
|
|
391
|
+
cliVersion: manifest.cliVersion || manifest.cli_version || null,
|
|
392
|
+
targetVersion: manifest.targetVersion || manifest.target_version || null,
|
|
393
|
+
profiles: manifest.profileNames || manifest.profiles || [],
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
backups.sort((a, b) => String(b.id).localeCompare(String(a.id)));
|
|
397
|
+
return backups;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export async function rollbackBackup(runtime, backupId, options = {}) {
|
|
401
|
+
if (!backupId || backupId.includes("/") || backupId.includes("\\")) {
|
|
402
|
+
throw new Error(`invalid backup id: ${backupId}`);
|
|
403
|
+
}
|
|
404
|
+
const dir = join(runtime.dshHome, "update-backups", backupId);
|
|
405
|
+
const manifest = JSON.parse(await readFile(join(dir, "manifest.json"), "utf8"));
|
|
406
|
+
const names = manifest.profileNames || manifest.profiles || [];
|
|
407
|
+
const profiles = await listProfiles(runtime.dshHome);
|
|
408
|
+
const byName = new Map(profiles.map((profile) => [profile.name, profile]));
|
|
409
|
+
|
|
410
|
+
const result = {
|
|
411
|
+
ok: true,
|
|
412
|
+
id: backupId,
|
|
413
|
+
cliVersion: manifest.cliVersion || manifest.cli_version || null,
|
|
414
|
+
cliRestored: false,
|
|
415
|
+
profiles: [],
|
|
416
|
+
errors: [],
|
|
417
|
+
finishedAt: null,
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
for (const name of names) {
|
|
421
|
+
const profile = byName.get(name) || { name, dir: join(runtime.dshHome, "profiles", name) };
|
|
422
|
+
const backupProfileDir = join(dir, "profiles", name);
|
|
423
|
+
try {
|
|
424
|
+
await mkdir(profile.dir, { recursive: true });
|
|
425
|
+
await copyIfExists(join(backupProfileDir, "package.json"), join(profile.dir, "package.json"));
|
|
426
|
+
await copyIfExists(join(backupProfileDir, "pnpm-lock.yaml"), join(profile.dir, "pnpm-lock.yaml"));
|
|
427
|
+
try {
|
|
428
|
+
await runCommand({
|
|
429
|
+
...cliInvocation(runtime, ["plugin", "--profile", name, "install", "--frozen-lockfile"]),
|
|
430
|
+
cwd: profile.dir,
|
|
431
|
+
timeoutMs: PROFILE_TIMEOUT_MS,
|
|
432
|
+
onLine: options.onLine,
|
|
433
|
+
});
|
|
434
|
+
} catch {
|
|
435
|
+
try {
|
|
436
|
+
await runCommand({
|
|
437
|
+
cmd: "pnpm",
|
|
438
|
+
args: ["install", "--frozen-lockfile"],
|
|
439
|
+
cwd: profile.dir,
|
|
440
|
+
timeoutMs: PROFILE_TIMEOUT_MS,
|
|
441
|
+
onLine: options.onLine,
|
|
442
|
+
shell: process.platform === "win32",
|
|
443
|
+
});
|
|
444
|
+
} catch {
|
|
445
|
+
await runCommand({
|
|
446
|
+
cmd: "pnpm",
|
|
447
|
+
args: ["install"],
|
|
448
|
+
cwd: profile.dir,
|
|
449
|
+
timeoutMs: PROFILE_TIMEOUT_MS,
|
|
450
|
+
onLine: options.onLine,
|
|
451
|
+
shell: process.platform === "win32",
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
result.profiles.push({ name, ok: true });
|
|
456
|
+
} catch (error) {
|
|
457
|
+
result.ok = false;
|
|
458
|
+
result.errors.push(`${name}: ${error.message}`);
|
|
459
|
+
result.profiles.push({ name, ok: false, error: error.message });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (result.cliVersion) {
|
|
464
|
+
const current = await currentCliVersion(runtime).catch(() => "");
|
|
465
|
+
if (current !== result.cliVersion) {
|
|
466
|
+
try {
|
|
467
|
+
await installCli(runtime, result.cliVersion, { onLine: options.onLine });
|
|
468
|
+
result.cliRestored = true;
|
|
469
|
+
} catch (error) {
|
|
470
|
+
result.ok = false;
|
|
471
|
+
result.errors.push(`CLI: ${error.message}`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
result.finishedAt = new Date().toISOString();
|
|
477
|
+
return result;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
// global installer detection / CLI update
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
function isUnder(pathValue, root) {
|
|
484
|
+
if (!root) return false;
|
|
485
|
+
const normalizedRoot = root.endsWith(sep) ? root : `${root}${sep}`;
|
|
486
|
+
return pathValue === root || pathValue.startsWith(normalizedRoot);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export async function detectGlobalInstaller(runtime, options = {}) {
|
|
490
|
+
if (!runtime.cliEntry) return "npm";
|
|
491
|
+
let real = runtime.cliEntry;
|
|
492
|
+
try {
|
|
493
|
+
real = await realpath(runtime.cliEntry);
|
|
494
|
+
} catch {
|
|
495
|
+
// fall back to the raw path
|
|
496
|
+
}
|
|
497
|
+
if (isUnder(real, options.pnpmRoot)) return "pnpm";
|
|
498
|
+
if (isUnder(real, options.npmRoot)) return "npm";
|
|
499
|
+
if (!options.skipDetection) {
|
|
500
|
+
try {
|
|
501
|
+
const result = await runCommand({ cmd: "pnpm", args: ["root", "-g"], timeoutMs: 15000 });
|
|
502
|
+
if (isUnder(real, result.stdout.trim())) return "pnpm";
|
|
503
|
+
} catch {
|
|
504
|
+
// pnpm may not be installed
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
try {
|
|
508
|
+
const result = await runCommand({ cmd: "npm", args: ["root", "-g"], timeoutMs: 15000 });
|
|
509
|
+
if (isUnder(real, result.stdout.trim())) return "npm";
|
|
510
|
+
} catch {
|
|
511
|
+
// npm may not be on PATH; default below
|
|
512
|
+
}
|
|
513
|
+
if (real.includes(`${sep}.pnpm${sep}`) || real.includes(`${sep}pnpm${sep}`)) return "pnpm";
|
|
514
|
+
return "npm";
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export async function installCli(runtime, version, options = {}) {
|
|
518
|
+
const installer = await detectGlobalInstaller(runtime, options);
|
|
519
|
+
if (installer === "pnpm") {
|
|
520
|
+
await runCommand({
|
|
521
|
+
cmd: "pnpm",
|
|
522
|
+
args: ["add", "-g", `${PACKAGE_NAME}@${version}`],
|
|
523
|
+
cwd: options.cwd,
|
|
524
|
+
timeoutMs: CLI_TIMEOUT_MS,
|
|
525
|
+
onLine: options.onLine,
|
|
526
|
+
shell: process.platform === "win32",
|
|
527
|
+
});
|
|
528
|
+
} else {
|
|
529
|
+
await runCommand({
|
|
530
|
+
cmd: "npm",
|
|
531
|
+
args: ["install", "-g", `${PACKAGE_NAME}@${version}`, "--no-audit", "--no-fund"],
|
|
532
|
+
cwd: options.cwd,
|
|
533
|
+
timeoutMs: CLI_TIMEOUT_MS,
|
|
534
|
+
onLine: options.onLine,
|
|
535
|
+
shell: process.platform === "win32",
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
return installer;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ---------------------------------------------------------------------------
|
|
542
|
+
// profile update
|
|
543
|
+
// ---------------------------------------------------------------------------
|
|
544
|
+
function minAgeArgs(minAge = MIN_AGE) {
|
|
545
|
+
if (minAge === "" || minAge === undefined || minAge === null) return [];
|
|
546
|
+
return [`--config.minimum-release-age=${minAge}`];
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export async function updateProfile(runtime, profile, options = {}) {
|
|
550
|
+
const minAge = options.minAge === undefined ? MIN_AGE : options.minAge;
|
|
551
|
+
const dshArgs = ["plugin", "--profile", profile.name, "update", "--latest", ...minAgeArgs(minAge)];
|
|
552
|
+
try {
|
|
553
|
+
await runCommand({
|
|
554
|
+
...cliInvocation(runtime, dshArgs),
|
|
555
|
+
cwd: profile.dir,
|
|
556
|
+
timeoutMs: PROFILE_TIMEOUT_MS,
|
|
557
|
+
onLine: options.onLine,
|
|
558
|
+
});
|
|
559
|
+
return { via: "dsh plugin" };
|
|
560
|
+
} catch (error) {
|
|
561
|
+
options.onLine?.(`dsh plugin failed for ${profile.name}: ${error.message}`);
|
|
562
|
+
}
|
|
563
|
+
const pnpmArgs = ["update", "--latest", ...minAgeArgs(minAge)];
|
|
564
|
+
await runCommand({
|
|
565
|
+
cmd: "pnpm",
|
|
566
|
+
args: pnpmArgs,
|
|
567
|
+
cwd: profile.dir,
|
|
568
|
+
timeoutMs: PROFILE_TIMEOUT_MS,
|
|
569
|
+
onLine: options.onLine,
|
|
570
|
+
shell: process.platform === "win32",
|
|
571
|
+
});
|
|
572
|
+
return { via: "pnpm" };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// ---------------------------------------------------------------------------
|
|
576
|
+
// status / update orchestration
|
|
577
|
+
// ---------------------------------------------------------------------------
|
|
578
|
+
export async function checkStatus(runtime, options = {}) {
|
|
579
|
+
const config = options.config || (await readConfig(runtime.dshHome));
|
|
580
|
+
const currentVersion = options.currentVersion !== undefined
|
|
581
|
+
? options.currentVersion
|
|
582
|
+
: await currentCliVersion(runtime, { onLine: options.onLine }).catch(() => "");
|
|
583
|
+
let targetVersion = null;
|
|
584
|
+
let targetError = null;
|
|
585
|
+
try {
|
|
586
|
+
targetVersion = await fetchTargetVersion(options.fetchImpl || globalThis.fetch, { ...options, channel: config.channel });
|
|
587
|
+
} catch (error) {
|
|
588
|
+
targetError = error instanceof Error ? error.message : String(error);
|
|
589
|
+
}
|
|
590
|
+
const profiles = await listProfiles(runtime.dshHome);
|
|
591
|
+
return {
|
|
592
|
+
profileName: runtime.profileName,
|
|
593
|
+
dshHome: runtime.dshHome,
|
|
594
|
+
currentVersion: currentVersion || null,
|
|
595
|
+
targetVersion: targetVersion || null,
|
|
596
|
+
targetError,
|
|
597
|
+
updateAvailable: Boolean(targetVersion && (!currentVersion || isNewerVersion(currentVersion, targetVersion))),
|
|
598
|
+
profiles: profiles.map((profile) => ({ name: profile.name, dependencyCount: profile.dependencyCount })),
|
|
599
|
+
config,
|
|
600
|
+
channel: config.channel,
|
|
601
|
+
minAge: config.minAge,
|
|
602
|
+
checkedAt: new Date().toISOString(),
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
export async function runUpdate(runtime, hooks = {}) {
|
|
607
|
+
const onPhase = hooks.onPhase || (() => {});
|
|
608
|
+
const onLog = hooks.onLog || (() => {});
|
|
609
|
+
const config = hooks.config || (await readConfig(runtime.dshHome));
|
|
610
|
+
onPhase("checking");
|
|
611
|
+
const currentVersion = await currentCliVersion(runtime, { onLine: onLog });
|
|
612
|
+
const targetVersion = await fetchTargetVersion(hooks.fetchImpl || globalThis.fetch, { ...hooks, channel: config.channel });
|
|
613
|
+
const profiles = await listProfiles(runtime.dshHome);
|
|
614
|
+
onLog(`current: ${currentVersion || "unknown"}; target: ${targetVersion}; channel: ${config.channel}; minAge: ${config.minAge}; profiles: ${profiles.map((profile) => profile.name).join(", ") || "none"}`);
|
|
615
|
+
|
|
616
|
+
onPhase("backup");
|
|
617
|
+
const backupDir = await createBackup(runtime, profiles, currentVersion);
|
|
618
|
+
onLog(`backup created: ${backupDir}`);
|
|
619
|
+
|
|
620
|
+
const result = {
|
|
621
|
+
ok: true,
|
|
622
|
+
currentVersion,
|
|
623
|
+
targetVersion,
|
|
624
|
+
backupDir,
|
|
625
|
+
channel: config.channel,
|
|
626
|
+
minAge: config.minAge,
|
|
627
|
+
cliUpdated: false,
|
|
628
|
+
cliInstaller: null,
|
|
629
|
+
profiles: [],
|
|
630
|
+
errors: [],
|
|
631
|
+
finishedAt: null,
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
if (!currentVersion || isNewerVersion(currentVersion, targetVersion)) {
|
|
635
|
+
onPhase("cli");
|
|
636
|
+
onLog(`installing ${PACKAGE_NAME}@${targetVersion}`);
|
|
637
|
+
try {
|
|
638
|
+
result.cliInstaller = await installCli(runtime, targetVersion, { onLine: onLog });
|
|
639
|
+
result.cliUpdated = true;
|
|
640
|
+
onLog(`CLI updated with ${result.cliInstaller}`);
|
|
641
|
+
} catch (error) {
|
|
642
|
+
result.ok = false;
|
|
643
|
+
result.errors.push(`CLI: ${error.message}`);
|
|
644
|
+
onLog(`CLI update failed: ${error.message}`);
|
|
645
|
+
}
|
|
646
|
+
} else {
|
|
647
|
+
onLog(`CLI already at ${currentVersion}`);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (result.ok) {
|
|
651
|
+
for (const profile of profiles) {
|
|
652
|
+
onPhase(`profile:${profile.name}`);
|
|
653
|
+
onLog(`updating profile ${profile.name} (${profile.dependencyCount} dependencies)`);
|
|
654
|
+
try {
|
|
655
|
+
const update = await updateProfile(runtime, profile, { onLine: onLog, minAge: config.minAge });
|
|
656
|
+
result.profiles.push({ name: profile.name, ok: true, via: update.via });
|
|
657
|
+
onLog(`profile ${profile.name} updated via ${update.via}`);
|
|
658
|
+
} catch (error) {
|
|
659
|
+
result.ok = false;
|
|
660
|
+
result.errors.push(`${profile.name}: ${error.message}`);
|
|
661
|
+
result.profiles.push({ name: profile.name, ok: false, error: error.message });
|
|
662
|
+
onLog(`profile ${profile.name} failed: ${error.message}`);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
result.finishedAt = new Date().toISOString();
|
|
668
|
+
return result;
|
|
669
|
+
}
|