@eya46/dsh-plugin-info 0.0.2
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/LICENSE +21 -0
- package/README.md +62 -0
- package/cordis.patch.yml +4 -0
- package/lib/client.js +345 -0
- package/lib/index.js +543 -0
- package/lib/types/client.d.ts +64 -0
- package/lib/types/http.d.ts +8 -0
- package/lib/types/index.d.ts +29 -0
- package/lib/types/profile.d.ts +21 -0
- package/lib/types/registry.d.ts +12 -0
- package/lib/types/semver.d.ts +18 -0
- package/lib/types/service.d.ts +36 -0
- package/lib/types/types.d.ts +111 -0
- package/package.json +76 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import z from "@deepseek-ai/schemastery";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
//#region src/http.ts
|
|
7
|
+
/** Write a JSON response. */
|
|
8
|
+
function sendJson(res, status, payload) {
|
|
9
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
10
|
+
res.end(JSON.stringify(payload));
|
|
11
|
+
}
|
|
12
|
+
/** Parse the request URL against a dummy origin so query strings work. */
|
|
13
|
+
function requestUrl(req) {
|
|
14
|
+
return new URL(req.url ?? "/", "http://127.0.0.1");
|
|
15
|
+
}
|
|
16
|
+
//#endregion
|
|
17
|
+
//#region src/semver.ts
|
|
18
|
+
/** Strip a leading `v` and build metadata. */
|
|
19
|
+
function normalizeVersion(input) {
|
|
20
|
+
const trimmed = input.trim();
|
|
21
|
+
const withoutBuild = trimmed.split("+")[0] ?? trimmed;
|
|
22
|
+
return withoutBuild.startsWith("v") || withoutBuild.startsWith("V") ? withoutBuild.slice(1) : withoutBuild;
|
|
23
|
+
}
|
|
24
|
+
/** Parse `1.2.3`, `1.2.3-rc.1`, or `v1.2.3`. Returns undefined when not numeric. */
|
|
25
|
+
function parseVersion(input) {
|
|
26
|
+
const normalized = normalizeVersion(input);
|
|
27
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(normalized);
|
|
28
|
+
if (match === null) return void 0;
|
|
29
|
+
const major = Number(match[1]);
|
|
30
|
+
const minor = Number(match[2]);
|
|
31
|
+
const patch = Number(match[3]);
|
|
32
|
+
if (!Number.isFinite(major) || !Number.isFinite(minor) || !Number.isFinite(patch)) return void 0;
|
|
33
|
+
return {
|
|
34
|
+
major,
|
|
35
|
+
minor,
|
|
36
|
+
patch,
|
|
37
|
+
prerelease: match[4] === void 0 || match[4].length === 0 ? [] : match[4].split(".")
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function compareIdentifier(left, right) {
|
|
41
|
+
const leftNum = /^\d+$/.test(left) ? Number(left) : void 0;
|
|
42
|
+
const rightNum = /^\d+$/.test(right) ? Number(right) : void 0;
|
|
43
|
+
if (leftNum !== void 0 && rightNum !== void 0) return leftNum - rightNum;
|
|
44
|
+
if (leftNum !== void 0) return -1;
|
|
45
|
+
if (rightNum !== void 0) return 1;
|
|
46
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
47
|
+
}
|
|
48
|
+
/** Compare two parsed versions. Negative when `left` is older. */
|
|
49
|
+
function compareParsed(left, right) {
|
|
50
|
+
if (left.major !== right.major) return left.major - right.major;
|
|
51
|
+
if (left.minor !== right.minor) return left.minor - right.minor;
|
|
52
|
+
if (left.patch !== right.patch) return left.patch - right.patch;
|
|
53
|
+
if (left.prerelease.length === 0 && right.prerelease.length === 0) return 0;
|
|
54
|
+
if (left.prerelease.length === 0) return 1;
|
|
55
|
+
if (right.prerelease.length === 0) return -1;
|
|
56
|
+
const count = Math.max(left.prerelease.length, right.prerelease.length);
|
|
57
|
+
for (let index = 0; index < count; index += 1) {
|
|
58
|
+
const leftPart = left.prerelease[index];
|
|
59
|
+
const rightPart = right.prerelease[index];
|
|
60
|
+
if (leftPart === void 0) return -1;
|
|
61
|
+
if (rightPart === void 0) return 1;
|
|
62
|
+
const delta = compareIdentifier(leftPart, rightPart);
|
|
63
|
+
if (delta !== 0) return delta;
|
|
64
|
+
}
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
/** True when `latest` is a newer published version than `installed`. */
|
|
68
|
+
function hasNewerVersion(installed, latest) {
|
|
69
|
+
if (installed === void 0 || latest === void 0) return false;
|
|
70
|
+
if (normalizeVersion(installed) === normalizeVersion(latest)) return false;
|
|
71
|
+
const parsedInstalled = parseVersion(installed);
|
|
72
|
+
const parsedLatest = parseVersion(latest);
|
|
73
|
+
if (parsedInstalled === void 0 || parsedLatest === void 0) return normalizeVersion(installed) !== normalizeVersion(latest);
|
|
74
|
+
return compareParsed(parsedLatest, parsedInstalled) > 0;
|
|
75
|
+
}
|
|
76
|
+
//#endregion
|
|
77
|
+
//#region src/profile.ts
|
|
78
|
+
/** Classify a profile dependency spec. */
|
|
79
|
+
function classifySpec(spec) {
|
|
80
|
+
const trimmed = spec.trim();
|
|
81
|
+
if (trimmed.startsWith("file:")) return "file";
|
|
82
|
+
if (trimmed.startsWith("link:")) return "link";
|
|
83
|
+
if (trimmed.startsWith("git+") || trimmed.startsWith("github:") || trimmed.startsWith("git://") || trimmed.startsWith("ssh://") || /\.git(?:#|$)/.test(trimmed)) return "git";
|
|
84
|
+
if (trimmed.startsWith(".") || trimmed.startsWith("/") || /^[A-Za-z]:[\\/]/.test(trimmed)) return "file";
|
|
85
|
+
if (trimmed.startsWith("http://") || trimmed.startsWith("https://") || trimmed.startsWith("workspace:")) return "other";
|
|
86
|
+
return "registry";
|
|
87
|
+
}
|
|
88
|
+
/** User-added plugins are the profile's own `dependencies`, not in-box bundles. */
|
|
89
|
+
function listUserPlugins(manifest) {
|
|
90
|
+
const dependencies = manifest.dependencies ?? {};
|
|
91
|
+
return Object.entries(dependencies).filter(([name, spec]) => name.trim().length > 0 && spec.trim().length > 0).map(([name, spec]) => ({
|
|
92
|
+
name,
|
|
93
|
+
spec,
|
|
94
|
+
source: classifySpec(spec)
|
|
95
|
+
})).sort((left, right) => left.name.localeCompare(right.name));
|
|
96
|
+
}
|
|
97
|
+
/** Read a profile package.json. */
|
|
98
|
+
function readProfileManifest(dir) {
|
|
99
|
+
const text = readFileSync(join(dir, "package.json"), "utf8");
|
|
100
|
+
const parsed = JSON.parse(text);
|
|
101
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("profile package.json is not an object");
|
|
102
|
+
return parsed;
|
|
103
|
+
}
|
|
104
|
+
/** Resolve one installed package.json from the profile directory. */
|
|
105
|
+
function readInstalledPackage(profileDir, name) {
|
|
106
|
+
const direct = join(profileDir, "node_modules", ...name.split("/"), "package.json");
|
|
107
|
+
const path = existsSync(direct) ? direct : resolveViaRequire(profileDir, name);
|
|
108
|
+
if (path === void 0) return void 0;
|
|
109
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
110
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
|
|
111
|
+
const record = parsed;
|
|
112
|
+
return {
|
|
113
|
+
name: typeof record.name === "string" ? record.name : name,
|
|
114
|
+
...typeof record.version === "string" && record.version.length > 0 ? { version: record.version } : {},
|
|
115
|
+
...typeof record.description === "string" && record.description.length > 0 ? { description: record.description } : {},
|
|
116
|
+
...typeof record.homepage === "string" && record.homepage.length > 0 ? { homepage: record.homepage } : {},
|
|
117
|
+
...record.repository === void 0 ? {} : { repository: record.repository }
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function resolveViaRequire(profileDir, name) {
|
|
121
|
+
try {
|
|
122
|
+
return createRequire(join(profileDir, "package.json")).resolve(`${name}/package.json`);
|
|
123
|
+
} catch {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** Turn a package `repository` field into a browsable https URL. */
|
|
128
|
+
function repositoryUrlOf(repository) {
|
|
129
|
+
if (repository === void 0) return void 0;
|
|
130
|
+
const raw = typeof repository === "string" ? repository : repository.url;
|
|
131
|
+
if (raw === void 0 || raw.trim().length === 0) return void 0;
|
|
132
|
+
const github = parseGithubRepo(raw);
|
|
133
|
+
if (github !== void 0) return `https://github.com/${github.owner}/${github.repo}`;
|
|
134
|
+
if (raw.startsWith("http://") || raw.startsWith("https://")) return raw.replace(/\.git$/, "");
|
|
135
|
+
return raw;
|
|
136
|
+
}
|
|
137
|
+
/** owner/repo extracted from common GitHub repository spellings. */
|
|
138
|
+
function parseGithubRepo(input) {
|
|
139
|
+
const trimmed = input.trim();
|
|
140
|
+
for (const pattern of [
|
|
141
|
+
/^github:([^/]+)\/([^#]+)/i,
|
|
142
|
+
/^(?:git\+)?https?:\/\/github\.com\/([^/]+)\/([^/#]+)/i,
|
|
143
|
+
/^(?:git\+)?ssh:\/\/git@github\.com\/([^/]+)\/([^/#]+)/i,
|
|
144
|
+
/^git@github\.com:([^/]+)\/([^/#]+)/i
|
|
145
|
+
]) {
|
|
146
|
+
const match = pattern.exec(trimmed);
|
|
147
|
+
if (match === null) continue;
|
|
148
|
+
const owner = match[1];
|
|
149
|
+
const repo = match[2]?.replace(/\.git$/i, "");
|
|
150
|
+
if (owner === void 0 || repo === void 0 || owner.length === 0 || repo.length === 0) continue;
|
|
151
|
+
return {
|
|
152
|
+
owner,
|
|
153
|
+
repo
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
//#endregion
|
|
158
|
+
//#region src/registry.ts
|
|
159
|
+
/** Join a registry origin with a package name, encoding the scoped slash. */
|
|
160
|
+
function packumentUrl(registryUrl, name) {
|
|
161
|
+
return `${registryUrl.replace(/\/+$/, "")}/${name.startsWith("@") ? name.replace("/", "%2f") : name}`;
|
|
162
|
+
}
|
|
163
|
+
/** Latest dist-tag, when the packument has one. */
|
|
164
|
+
function latestVersionOf(packument) {
|
|
165
|
+
const latest = packument["dist-tags"]?.latest;
|
|
166
|
+
return typeof latest === "string" && latest.length > 0 ? latest : void 0;
|
|
167
|
+
}
|
|
168
|
+
/** Most recently published versions, newest first. */
|
|
169
|
+
function recentVersions(packument, installedVersion, limit) {
|
|
170
|
+
const versions = Object.keys(packument.versions ?? {});
|
|
171
|
+
const time = packument.time ?? {};
|
|
172
|
+
versions.sort((left, right) => timestampOf(time[right]) - timestampOf(time[left]));
|
|
173
|
+
const latest = latestVersionOf(packument);
|
|
174
|
+
const installed = installedVersion === void 0 ? void 0 : installedVersion;
|
|
175
|
+
return versions.slice(0, Math.max(0, limit)).map((version) => ({
|
|
176
|
+
version,
|
|
177
|
+
...typeof time[version] === "string" ? { publishedAt: time[version] } : {},
|
|
178
|
+
latest: latest === version,
|
|
179
|
+
installed: installed === version
|
|
180
|
+
}));
|
|
181
|
+
}
|
|
182
|
+
/** The published version immediately older than `version` by publish time. */
|
|
183
|
+
function previousPublishedVersion(packument, version) {
|
|
184
|
+
const versions = Object.keys(packument.versions ?? {});
|
|
185
|
+
const time = packument.time ?? {};
|
|
186
|
+
versions.sort((left, right) => timestampOf(time[right]) - timestampOf(time[left]));
|
|
187
|
+
const index = versions.indexOf(version);
|
|
188
|
+
if (index < 0) return void 0;
|
|
189
|
+
return versions[index + 1];
|
|
190
|
+
}
|
|
191
|
+
function timestampOf(value) {
|
|
192
|
+
if (value === void 0) return 0;
|
|
193
|
+
const parsed = Date.parse(value);
|
|
194
|
+
return Number.isNaN(parsed) ? 0 : parsed;
|
|
195
|
+
}
|
|
196
|
+
/** Clamp the versions query to a sane positive integer. */
|
|
197
|
+
function resolveVersionLimit(raw, fallback) {
|
|
198
|
+
const value = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : fallback;
|
|
199
|
+
if (!Number.isFinite(value)) return fallback;
|
|
200
|
+
return Math.min(50, Math.max(1, Math.trunc(value)));
|
|
201
|
+
}
|
|
202
|
+
//#endregion
|
|
203
|
+
//#region src/service.ts
|
|
204
|
+
/** Query surface used by the HTTP API and tests. */
|
|
205
|
+
var PluginInfoService = class {
|
|
206
|
+
config;
|
|
207
|
+
fetchJson;
|
|
208
|
+
now;
|
|
209
|
+
packuments = /* @__PURE__ */ new Map();
|
|
210
|
+
notes = /* @__PURE__ */ new Map();
|
|
211
|
+
constructor(config, fetchJson, now = () => Date.now()) {
|
|
212
|
+
this.config = config;
|
|
213
|
+
this.fetchJson = fetchJson;
|
|
214
|
+
this.now = now;
|
|
215
|
+
}
|
|
216
|
+
/** List user-added plugins for one profile directory. */
|
|
217
|
+
async listPlugins(profileDir) {
|
|
218
|
+
const manifest = readProfileManifest(profileDir);
|
|
219
|
+
const listed = listUserPlugins(manifest).filter((item) => isAllowedScope(item.name, this.config.allowScopes));
|
|
220
|
+
const plugins = await Promise.all(listed.map(async (item) => this.describePlugin(profileDir, item.name, item.spec)));
|
|
221
|
+
return {
|
|
222
|
+
profile: manifest.name ?? profileDir,
|
|
223
|
+
plugins
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/** Recent published versions for one user-added plugin. */
|
|
227
|
+
async listVersions(profileDir, name, limit) {
|
|
228
|
+
const plugin = findUserPlugin(profileDir, name, this.config.allowScopes);
|
|
229
|
+
const installed = readInstalledPackage(profileDir, name);
|
|
230
|
+
if (plugin.source !== "registry") {
|
|
231
|
+
const installedVersion = installed?.version;
|
|
232
|
+
return {
|
|
233
|
+
name,
|
|
234
|
+
...installedVersion === void 0 ? {} : { installedVersion },
|
|
235
|
+
versions: installedVersion === void 0 ? [] : [{
|
|
236
|
+
version: installedVersion,
|
|
237
|
+
latest: false,
|
|
238
|
+
installed: true
|
|
239
|
+
}]
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
const packument = await this.loadPackument(name);
|
|
243
|
+
const installedVersion = installed?.version;
|
|
244
|
+
const latestVersion = latestVersionOf(packument);
|
|
245
|
+
return {
|
|
246
|
+
name,
|
|
247
|
+
...installedVersion === void 0 ? {} : { installedVersion },
|
|
248
|
+
...latestVersion === void 0 ? {} : { latestVersion },
|
|
249
|
+
versions: recentVersions(packument, installedVersion, limit)
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
/** Update notes for one published version. */
|
|
253
|
+
async versionNotes(profileDir, name, version) {
|
|
254
|
+
findUserPlugin(profileDir, name, this.config.allowScopes);
|
|
255
|
+
const cacheKey = `${name}@${version}`;
|
|
256
|
+
const cached = this.notes.get(cacheKey);
|
|
257
|
+
if (cached !== void 0 && cached.expiresAt > this.now()) return cached.value;
|
|
258
|
+
const notes = await this.loadNotes(profileDir, name, version);
|
|
259
|
+
this.notes.set(cacheKey, {
|
|
260
|
+
value: notes,
|
|
261
|
+
expiresAt: this.now() + this.config.cacheTtlMs
|
|
262
|
+
});
|
|
263
|
+
return notes;
|
|
264
|
+
}
|
|
265
|
+
async describePlugin(profileDir, name, spec) {
|
|
266
|
+
const source = classifySpec(spec);
|
|
267
|
+
const installed = readInstalledPackage(profileDir, name);
|
|
268
|
+
const installedRepository = repositoryUrlOf(installed?.repository);
|
|
269
|
+
const summary = {
|
|
270
|
+
name,
|
|
271
|
+
spec,
|
|
272
|
+
source,
|
|
273
|
+
hasUpdate: false,
|
|
274
|
+
...installed?.description === void 0 ? {} : { description: installed.description },
|
|
275
|
+
...installed?.version === void 0 ? {} : { installedVersion: installed.version },
|
|
276
|
+
...installed?.homepage === void 0 ? {} : { homepage: installed.homepage },
|
|
277
|
+
...installedRepository === void 0 ? {} : { repository: installedRepository }
|
|
278
|
+
};
|
|
279
|
+
if (source !== "registry") return summary;
|
|
280
|
+
try {
|
|
281
|
+
const packument = await this.loadPackument(name);
|
|
282
|
+
const latestVersion = latestVersionOf(packument);
|
|
283
|
+
const homepage = summary.homepage ?? packument.homepage;
|
|
284
|
+
const repository = summary.repository ?? repositoryUrlOf(packument.repository);
|
|
285
|
+
return {
|
|
286
|
+
...summary,
|
|
287
|
+
...packument.description === void 0 || summary.description !== void 0 ? {} : { description: packument.description },
|
|
288
|
+
...latestVersion === void 0 ? {} : { latestVersion },
|
|
289
|
+
hasUpdate: hasNewerVersion(summary.installedVersion, latestVersion),
|
|
290
|
+
...homepage === void 0 ? {} : { homepage },
|
|
291
|
+
...repository === void 0 ? {} : { repository }
|
|
292
|
+
};
|
|
293
|
+
} catch (error) {
|
|
294
|
+
return {
|
|
295
|
+
...summary,
|
|
296
|
+
error: error instanceof Error ? error.message : String(error)
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
async loadPackument(name) {
|
|
301
|
+
const cached = this.packuments.get(name);
|
|
302
|
+
if (cached !== void 0 && cached.expiresAt > this.now()) return cached.value;
|
|
303
|
+
const payload = await this.fetchJson(packumentUrl(this.config.registryUrl, name), this.config.timeoutMs, { accept: "application/json" });
|
|
304
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) throw new Error(`registry returned a non-object packument for ${name}`);
|
|
305
|
+
const packument = payload;
|
|
306
|
+
this.packuments.set(name, {
|
|
307
|
+
value: packument,
|
|
308
|
+
expiresAt: this.now() + this.config.cacheTtlMs
|
|
309
|
+
});
|
|
310
|
+
return packument;
|
|
311
|
+
}
|
|
312
|
+
async loadNotes(profileDir, name, version) {
|
|
313
|
+
const installed = readInstalledPackage(profileDir, name);
|
|
314
|
+
let packument;
|
|
315
|
+
try {
|
|
316
|
+
packument = await this.loadPackument(name);
|
|
317
|
+
} catch {
|
|
318
|
+
packument = void 0;
|
|
319
|
+
}
|
|
320
|
+
const repository = repositoryUrlOf(installed?.repository) ?? repositoryUrlOf(packument?.repository);
|
|
321
|
+
const github = repository === void 0 ? void 0 : parseGithubRepo(repository);
|
|
322
|
+
const publishedAt = packument?.time?.[version];
|
|
323
|
+
const base = {
|
|
324
|
+
name,
|
|
325
|
+
version,
|
|
326
|
+
source: "none",
|
|
327
|
+
...publishedAt === void 0 ? {} : { publishedAt }
|
|
328
|
+
};
|
|
329
|
+
if (github === void 0) return base;
|
|
330
|
+
const release = await this.fetchGithubRelease(github.owner, github.repo, version);
|
|
331
|
+
if (release !== void 0) return {
|
|
332
|
+
...base,
|
|
333
|
+
source: "github-release",
|
|
334
|
+
...release.title === void 0 ? {} : { title: release.title },
|
|
335
|
+
...release.body === void 0 ? {} : { body: release.body },
|
|
336
|
+
...release.url === void 0 ? {} : { url: release.url },
|
|
337
|
+
...release.publishedAt === void 0 ? {} : { publishedAt: release.publishedAt }
|
|
338
|
+
};
|
|
339
|
+
const previous = packument === void 0 ? void 0 : previousPublishedVersion(packument, version);
|
|
340
|
+
if (previous === void 0) return base;
|
|
341
|
+
const compare = await this.fetchGithubCompare(github.owner, github.repo, previous, version);
|
|
342
|
+
if (compare === void 0) return base;
|
|
343
|
+
return {
|
|
344
|
+
...base,
|
|
345
|
+
source: "github-compare",
|
|
346
|
+
url: compare.url,
|
|
347
|
+
commits: compare.commits
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
async fetchGithubRelease(owner, repo, version) {
|
|
351
|
+
const tags = unique([version, `v${version}`]);
|
|
352
|
+
for (const tag of tags) try {
|
|
353
|
+
const payload = await this.fetchJson(`https://api.github.com/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`, this.config.timeoutMs, githubHeaders());
|
|
354
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) continue;
|
|
355
|
+
const record = payload;
|
|
356
|
+
if (record.message === "Not Found") continue;
|
|
357
|
+
const title = typeof record.name === "string" && record.name.length > 0 ? record.name : void 0;
|
|
358
|
+
const body = typeof record.body === "string" && record.body.trim().length > 0 ? record.body : void 0;
|
|
359
|
+
const url = typeof record.html_url === "string" ? record.html_url : void 0;
|
|
360
|
+
const publishedAt = typeof record.published_at === "string" ? record.published_at : void 0;
|
|
361
|
+
return {
|
|
362
|
+
...title === void 0 ? {} : { title },
|
|
363
|
+
...body === void 0 ? {} : { body },
|
|
364
|
+
...url === void 0 ? {} : { url },
|
|
365
|
+
...publishedAt === void 0 ? {} : { publishedAt }
|
|
366
|
+
};
|
|
367
|
+
} catch {
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
async fetchGithubCompare(owner, repo, previous, version) {
|
|
372
|
+
const bases = unique([previous, `v${previous}`]);
|
|
373
|
+
const heads = unique([version, `v${version}`]);
|
|
374
|
+
for (const base of bases) for (const head of heads) try {
|
|
375
|
+
const payload = await this.fetchJson(`https://api.github.com/repos/${owner}/${repo}/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}`, this.config.timeoutMs, githubHeaders());
|
|
376
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) continue;
|
|
377
|
+
const record = payload;
|
|
378
|
+
if (!Array.isArray(record.commits)) continue;
|
|
379
|
+
const commits = [];
|
|
380
|
+
for (const item of record.commits) {
|
|
381
|
+
if (item === null || typeof item !== "object" || Array.isArray(item)) continue;
|
|
382
|
+
const commitRecord = item;
|
|
383
|
+
const sha = typeof commitRecord.sha === "string" ? commitRecord.sha.slice(0, 7) : void 0;
|
|
384
|
+
const nested = commitRecord.commit;
|
|
385
|
+
const message = nested !== null && typeof nested === "object" && !Array.isArray(nested) && typeof nested.message === "string" ? String(nested.message).split("\n")[0] : void 0;
|
|
386
|
+
if (sha === void 0 || message === void 0 || message.length === 0) continue;
|
|
387
|
+
const url = typeof commitRecord.html_url === "string" ? commitRecord.html_url : void 0;
|
|
388
|
+
commits.push({
|
|
389
|
+
sha,
|
|
390
|
+
message,
|
|
391
|
+
...url === void 0 ? {} : { url }
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
if (commits.length === 0) continue;
|
|
395
|
+
return {
|
|
396
|
+
url: typeof record.html_url === "string" ? record.html_url : `https://github.com/${owner}/${repo}/compare/${base}...${head}`,
|
|
397
|
+
commits: commits.slice(-20).reverse()
|
|
398
|
+
};
|
|
399
|
+
} catch {
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
function findUserPlugin(profileDir, name, allowScopes) {
|
|
405
|
+
const match = listUserPlugins(readProfileManifest(profileDir)).find((item) => item.name === name);
|
|
406
|
+
if (match === void 0) throw new Error(`"${name}" is not a user-added plugin in this profile`);
|
|
407
|
+
if (allowScopes !== void 0 && !isAllowedScope(match.name, allowScopes)) throw new Error(`"${name}" is filtered out by allowScopes`);
|
|
408
|
+
return match;
|
|
409
|
+
}
|
|
410
|
+
function unique(values) {
|
|
411
|
+
return [...new Set(values.filter((value) => value.length > 0))];
|
|
412
|
+
}
|
|
413
|
+
/** True when `name` matches one of the configured scope prefixes (empty list = allow all). */
|
|
414
|
+
function isAllowedScope(name, allowScopes) {
|
|
415
|
+
if (allowScopes === void 0 || allowScopes.length === 0) return true;
|
|
416
|
+
const lower = name.toLowerCase();
|
|
417
|
+
return allowScopes.some((prefix) => lower.startsWith(prefix.toLowerCase()));
|
|
418
|
+
}
|
|
419
|
+
function githubHeaders() {
|
|
420
|
+
const headers = {
|
|
421
|
+
accept: "application/vnd.github+json",
|
|
422
|
+
"user-agent": "@eya46/dsh-plugin-info"
|
|
423
|
+
};
|
|
424
|
+
const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
|
|
425
|
+
if (token !== void 0 && token.length > 0) headers.authorization = `Bearer ${token}`;
|
|
426
|
+
return headers;
|
|
427
|
+
}
|
|
428
|
+
/** Resolve the current profile directory from the Loader base URL. */
|
|
429
|
+
function profileDirFromBaseUrl(baseUrl) {
|
|
430
|
+
if (baseUrl === void 0 || baseUrl.length === 0) throw new Error("ctx.baseUrl is unset — cannot locate the current profile");
|
|
431
|
+
return fileURLToPath(new URL(".", baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`));
|
|
432
|
+
}
|
|
433
|
+
/** Default JSON fetch used by the live plugin. */
|
|
434
|
+
async function fetchJson(url, timeoutMs, headers = {}) {
|
|
435
|
+
const controller = new AbortController();
|
|
436
|
+
const timer = setTimeout(() => controller.abort(/* @__PURE__ */ new Error(`request timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
437
|
+
try {
|
|
438
|
+
const response = await fetch(url, {
|
|
439
|
+
headers,
|
|
440
|
+
signal: controller.signal
|
|
441
|
+
});
|
|
442
|
+
if (!response.ok) {
|
|
443
|
+
const text = await response.text().catch(() => "");
|
|
444
|
+
throw new Error(`HTTP ${response.status} ${response.statusText}${text.length > 0 ? `: ${text.slice(0, 200)}` : ""}`);
|
|
445
|
+
}
|
|
446
|
+
return await response.json();
|
|
447
|
+
} finally {
|
|
448
|
+
clearTimeout(timer);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
//#endregion
|
|
452
|
+
//#region src/index.ts
|
|
453
|
+
/** Cordis plugin name used by Loader diagnostics. */
|
|
454
|
+
const name = "dsh-plugin-info";
|
|
455
|
+
/** Default version window shown by the settings page. */
|
|
456
|
+
const DEFAULT_VERSION_LIMIT = 10;
|
|
457
|
+
/** Runtime schema for {@link Config}. */
|
|
458
|
+
const Config = z.object({
|
|
459
|
+
registryUrl: z.string().default("https://registry.npmjs.org"),
|
|
460
|
+
defaultVersionLimit: z.number().step(1).min(1).max(50).default(10),
|
|
461
|
+
timeoutMs: z.number().step(1).min(1e3).default(15e3),
|
|
462
|
+
cacheTtlMs: z.number().step(1).min(0).default(3e5),
|
|
463
|
+
allowScopes: z.array(z.string()).default(["@eya46/"])
|
|
464
|
+
});
|
|
465
|
+
/**
|
|
466
|
+
* Register the plugin-info HTTP API when a web server is present.
|
|
467
|
+
* @param ctx - Cordis context; `webServer` is optional so headless boots stay inert.
|
|
468
|
+
* @param config - registry and cache knobs.
|
|
469
|
+
*/
|
|
470
|
+
function apply(ctx, config) {
|
|
471
|
+
const service = new PluginInfoService(config, fetchJson);
|
|
472
|
+
ctx.inject(["webServer"], (sctx) => {
|
|
473
|
+
sctx.effect(() => {
|
|
474
|
+
const disposers = [
|
|
475
|
+
sctx.webServer.register({
|
|
476
|
+
kind: "exact",
|
|
477
|
+
path: "/plugin-info/api/plugins",
|
|
478
|
+
handler: async (_req, res) => {
|
|
479
|
+
try {
|
|
480
|
+
sendJson(res, 200, await service.listPlugins(profileDirFromBaseUrl(sctx.baseUrl)));
|
|
481
|
+
} catch (error) {
|
|
482
|
+
sendError(res, error);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}),
|
|
486
|
+
sctx.webServer.register({
|
|
487
|
+
kind: "exact",
|
|
488
|
+
path: "/plugin-info/api/versions",
|
|
489
|
+
handler: async (req, res) => {
|
|
490
|
+
try {
|
|
491
|
+
const url = requestUrl(req);
|
|
492
|
+
const name = url.searchParams.get("name") ?? "";
|
|
493
|
+
if (name.length === 0) {
|
|
494
|
+
sendJson(res, 400, {
|
|
495
|
+
ok: false,
|
|
496
|
+
reason: "name is required"
|
|
497
|
+
});
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const limit = resolveVersionLimit(url.searchParams.get("limit"), config.defaultVersionLimit);
|
|
501
|
+
sendJson(res, 200, await service.listVersions(profileDirFromBaseUrl(sctx.baseUrl), name, limit));
|
|
502
|
+
} catch (error) {
|
|
503
|
+
sendError(res, error);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}),
|
|
507
|
+
sctx.webServer.register({
|
|
508
|
+
kind: "exact",
|
|
509
|
+
path: "/plugin-info/api/notes",
|
|
510
|
+
handler: async (req, res) => {
|
|
511
|
+
try {
|
|
512
|
+
const url = requestUrl(req);
|
|
513
|
+
const name = url.searchParams.get("name") ?? "";
|
|
514
|
+
const version = url.searchParams.get("version") ?? "";
|
|
515
|
+
if (name.length === 0 || version.length === 0) {
|
|
516
|
+
sendJson(res, 400, {
|
|
517
|
+
ok: false,
|
|
518
|
+
reason: "name and version are required"
|
|
519
|
+
});
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
sendJson(res, 200, await service.versionNotes(profileDirFromBaseUrl(sctx.baseUrl), name, version));
|
|
523
|
+
} catch (error) {
|
|
524
|
+
sendError(res, error);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
})
|
|
528
|
+
];
|
|
529
|
+
return () => {
|
|
530
|
+
for (const dispose of disposers) dispose();
|
|
531
|
+
};
|
|
532
|
+
}, "dsh-plugin-info.web-api");
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
function sendError(res, error) {
|
|
536
|
+
sendJson(res, 500, {
|
|
537
|
+
ok: false,
|
|
538
|
+
reason: "error",
|
|
539
|
+
message: error instanceof Error ? error.message : String(error)
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
//#endregion
|
|
543
|
+
export { Config, DEFAULT_VERSION_LIMIT, PluginInfoService, apply, classifySpec, fetchJson, hasNewerVersion, latestVersionOf, listUserPlugins, name, packumentUrl, parseGithubRepo, parseVersion, profileDirFromBaseUrl, recentVersions, repositoryUrlOf, resolveVersionLimit };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @eya46/dsh-plugin-info — browser half.
|
|
3
|
+
*
|
|
4
|
+
* A settings page listing plugins the current profile added, with update
|
|
5
|
+
* marks and a per-plugin version drawer (default 10) that can open notes.
|
|
6
|
+
*
|
|
7
|
+
* @module @eya46/dsh-plugin-info/client
|
|
8
|
+
*/
|
|
9
|
+
interface ModuleLoader {
|
|
10
|
+
load(handoff: {
|
|
11
|
+
id: string;
|
|
12
|
+
factory: (require: (spec: string) => unknown) => Record<string, unknown>;
|
|
13
|
+
}): void;
|
|
14
|
+
}
|
|
15
|
+
interface DshWindow {
|
|
16
|
+
__ModuleLoader__?: ModuleLoader;
|
|
17
|
+
}
|
|
18
|
+
interface MinimalReact {
|
|
19
|
+
createElement(type: unknown, props: Record<string, unknown> | null, ...children: unknown[]): unknown;
|
|
20
|
+
useState<T>(initial: T | (() => T)): [T, (next: T | ((prev: T) => T)) => void];
|
|
21
|
+
useEffect(effect: () => void | (() => void), deps?: unknown[]): void;
|
|
22
|
+
useCallback<T>(fn: T, deps: unknown[]): T;
|
|
23
|
+
}
|
|
24
|
+
interface SlotsService {
|
|
25
|
+
inject(key: string, register: () => void): void;
|
|
26
|
+
register(options: Record<string, unknown>, component: unknown): void;
|
|
27
|
+
}
|
|
28
|
+
interface ClientContext {
|
|
29
|
+
slots: SlotsService;
|
|
30
|
+
/** Cordis fiber-lifetime effect: runs now, disposes on fiber unload. */
|
|
31
|
+
effect(callback: () => (() => void) | void, label?: string): () => void;
|
|
32
|
+
}
|
|
33
|
+
interface PluginRow {
|
|
34
|
+
name: string;
|
|
35
|
+
description?: string;
|
|
36
|
+
spec: string;
|
|
37
|
+
source: string;
|
|
38
|
+
installedVersion?: string;
|
|
39
|
+
latestVersion?: string;
|
|
40
|
+
hasUpdate: boolean;
|
|
41
|
+
homepage?: string;
|
|
42
|
+
repository?: string;
|
|
43
|
+
error?: string;
|
|
44
|
+
}
|
|
45
|
+
interface VersionRow {
|
|
46
|
+
version: string;
|
|
47
|
+
publishedAt?: string;
|
|
48
|
+
latest: boolean;
|
|
49
|
+
installed: boolean;
|
|
50
|
+
}
|
|
51
|
+
interface VersionNotes {
|
|
52
|
+
name: string;
|
|
53
|
+
version: string;
|
|
54
|
+
source: 'github-release' | 'github-compare' | 'none';
|
|
55
|
+
publishedAt?: string;
|
|
56
|
+
title?: string;
|
|
57
|
+
body?: string;
|
|
58
|
+
url?: string;
|
|
59
|
+
commits?: Array<{
|
|
60
|
+
sha: string;
|
|
61
|
+
message: string;
|
|
62
|
+
url?: string;
|
|
63
|
+
}>;
|
|
64
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
/** Read a JSON request body (1 MB cap). */
|
|
3
|
+
export declare function readJsonBody(req: IncomingMessage): Promise<unknown>;
|
|
4
|
+
/** Write a JSON response. */
|
|
5
|
+
export declare function sendJson(res: ServerResponse, status: number, payload: unknown): void;
|
|
6
|
+
/** Parse the request URL against a dummy origin so query strings work. */
|
|
7
|
+
export declare function requestUrl(req: IncomingMessage): URL;
|
|
8
|
+
//# sourceMappingURL=http.d.ts.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@eya46/dsh-plugin-info` lists plugins the current profile added, marks
|
|
3
|
+
* available registry updates, and exposes recent version notes.
|
|
4
|
+
* @module @eya46/dsh-plugin-info
|
|
5
|
+
*/
|
|
6
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
7
|
+
import z from '@deepseek-ai/schemastery';
|
|
8
|
+
import type { PluginConfig } from './types.ts';
|
|
9
|
+
export { classifySpec, listUserPlugins, parseGithubRepo, repositoryUrlOf } from './profile.ts';
|
|
10
|
+
export { hasNewerVersion, parseVersion } from './semver.ts';
|
|
11
|
+
export { latestVersionOf, packumentUrl, recentVersions, resolveVersionLimit } from './registry.ts';
|
|
12
|
+
export { fetchJson, PluginInfoService, profileDirFromBaseUrl } from './service.ts';
|
|
13
|
+
export type { InstalledPackage, Packument, PluginConfig, PluginSource, PluginSummary, PluginVersionRow, ProfileManifest, VersionNotes, } from './types.ts';
|
|
14
|
+
/** Cordis plugin name used by Loader diagnostics. */
|
|
15
|
+
export declare const name = "dsh-plugin-info";
|
|
16
|
+
/** Default version window shown by the settings page. */
|
|
17
|
+
export declare const DEFAULT_VERSION_LIMIT = 10;
|
|
18
|
+
/** Cordis plugin configuration. */
|
|
19
|
+
export interface Config extends PluginConfig {
|
|
20
|
+
}
|
|
21
|
+
/** Runtime schema for {@link Config}. */
|
|
22
|
+
export declare const Config: z<Config>;
|
|
23
|
+
/**
|
|
24
|
+
* Register the plugin-info HTTP API when a web server is present.
|
|
25
|
+
* @param ctx - Cordis context; `webServer` is optional so headless boots stay inert.
|
|
26
|
+
* @param config - registry and cache knobs.
|
|
27
|
+
*/
|
|
28
|
+
export declare function apply(ctx: Context, config: Config): void;
|
|
29
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { InstalledPackage, PluginSource, ProfileManifest } from './types.ts';
|
|
2
|
+
/** Classify a profile dependency spec. */
|
|
3
|
+
export declare function classifySpec(spec: string): PluginSource;
|
|
4
|
+
/** User-added plugins are the profile's own `dependencies`, not in-box bundles. */
|
|
5
|
+
export declare function listUserPlugins(manifest: ProfileManifest): Array<{
|
|
6
|
+
name: string;
|
|
7
|
+
spec: string;
|
|
8
|
+
source: PluginSource;
|
|
9
|
+
}>;
|
|
10
|
+
/** Read a profile package.json. */
|
|
11
|
+
export declare function readProfileManifest(dir: string): ProfileManifest;
|
|
12
|
+
/** Resolve one installed package.json from the profile directory. */
|
|
13
|
+
export declare function readInstalledPackage(profileDir: string, name: string): InstalledPackage | undefined;
|
|
14
|
+
/** Turn a package `repository` field into a browsable https URL. */
|
|
15
|
+
export declare function repositoryUrlOf(repository: InstalledPackage['repository']): string | undefined;
|
|
16
|
+
/** owner/repo extracted from common GitHub repository spellings. */
|
|
17
|
+
export declare function parseGithubRepo(input: string): {
|
|
18
|
+
owner: string;
|
|
19
|
+
repo: string;
|
|
20
|
+
} | undefined;
|
|
21
|
+
//# sourceMappingURL=profile.d.ts.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Packument, PluginVersionRow } from './types.ts';
|
|
2
|
+
/** Join a registry origin with a package name, encoding the scoped slash. */
|
|
3
|
+
export declare function packumentUrl(registryUrl: string, name: string): string;
|
|
4
|
+
/** Latest dist-tag, when the packument has one. */
|
|
5
|
+
export declare function latestVersionOf(packument: Packument): string | undefined;
|
|
6
|
+
/** Most recently published versions, newest first. */
|
|
7
|
+
export declare function recentVersions(packument: Packument, installedVersion: string | undefined, limit: number): PluginVersionRow[];
|
|
8
|
+
/** The published version immediately older than `version` by publish time. */
|
|
9
|
+
export declare function previousPublishedVersion(packument: Packument, version: string): string | undefined;
|
|
10
|
+
/** Clamp the versions query to a sane positive integer. */
|
|
11
|
+
export declare function resolveVersionLimit(raw: unknown, fallback: number): number;
|
|
12
|
+
//# sourceMappingURL=registry.d.ts.map
|