@chatcode/cco-market 1.45.1
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 +146 -0
- package/README.zh.md +143 -0
- package/UPDATE-API-V1.md +132 -0
- package/client/client.js +11534 -0
- package/cordis.patch.yml +5 -0
- package/lib/accelerate.js +184 -0
- package/lib/agents.js +36 -0
- package/lib/backup.js +572 -0
- package/lib/catalog-local-match.js +126 -0
- package/lib/catalog-npm.js +111 -0
- package/lib/changelog.js +242 -0
- package/lib/channels.js +64 -0
- package/lib/check.js +1067 -0
- package/lib/compatibility.js +198 -0
- package/lib/diagnostics.js +49 -0
- package/lib/discovery-compatibility.js +260 -0
- package/lib/dsh-cli.js +949 -0
- package/lib/dsh-install.js +108 -0
- package/lib/gist.js +352 -0
- package/lib/groups.js +97 -0
- package/lib/home-paths.js +42 -0
- package/lib/hot.js +519 -0
- package/lib/http.js +39 -0
- package/lib/index.js +97 -0
- package/lib/install.js +387 -0
- package/lib/log.js +197 -0
- package/lib/ndjson.js +154 -0
- package/lib/net.js +98 -0
- package/lib/order.js +284 -0
- package/lib/patch.js +521 -0
- package/lib/pnpm-compat.js +490 -0
- package/lib/presets.js +278 -0
- package/lib/profile.js +911 -0
- package/lib/region-probe.js +93 -0
- package/lib/regions.js +253 -0
- package/lib/registry.js +204 -0
- package/lib/restart.js +373 -0
- package/lib/routes.js +4372 -0
- package/lib/settings.js +109 -0
- package/lib/skill-market/api.js +119 -0
- package/lib/skill-market/config.js +29 -0
- package/lib/skill-market/installer.js +352 -0
- package/lib/skill-market/runtime.js +13 -0
- package/lib/skill-market/tui.js +119 -0
- package/lib/skill-market/types.js +1 -0
- package/lib/skill-market/web-routes.js +123 -0
- package/lib/snapshot.js +500 -0
- package/lib/source-migration.js +54 -0
- package/lib/sources.js +596 -0
- package/lib/store.js +91 -0
- package/lib/themes.js +102 -0
- package/lib/trial.js +116 -0
- package/lib/types/accelerate.d.ts +85 -0
- package/lib/types/agents.d.ts +22 -0
- package/lib/types/backup.d.ts +131 -0
- package/lib/types/catalog-local-match.d.ts +50 -0
- package/lib/types/catalog-npm.d.ts +47 -0
- package/lib/types/changelog.d.ts +102 -0
- package/lib/types/channels.d.ts +56 -0
- package/lib/types/check.d.ts +266 -0
- package/lib/types/compatibility.d.ts +64 -0
- package/lib/types/diagnostics.d.ts +29 -0
- package/lib/types/discovery-compatibility.d.ts +80 -0
- package/lib/types/dsh-cli.d.ts +324 -0
- package/lib/types/dsh-install.d.ts +46 -0
- package/lib/types/gist.d.ts +53 -0
- package/lib/types/groups.d.ts +34 -0
- package/lib/types/home-paths.d.ts +16 -0
- package/lib/types/hot.d.ts +221 -0
- package/lib/types/http.d.ts +12 -0
- package/lib/types/index.d.ts +14 -0
- package/lib/types/install.d.ts +172 -0
- package/lib/types/log.d.ts +43 -0
- package/lib/types/ndjson.d.ts +52 -0
- package/lib/types/net.d.ts +58 -0
- package/lib/types/order.d.ts +100 -0
- package/lib/types/patch.d.ts +129 -0
- package/lib/types/pnpm-compat.d.ts +93 -0
- package/lib/types/presets.d.ts +90 -0
- package/lib/types/profile.d.ts +253 -0
- package/lib/types/region-probe.d.ts +50 -0
- package/lib/types/regions.d.ts +122 -0
- package/lib/types/registry.d.ts +86 -0
- package/lib/types/restart.d.ts +194 -0
- package/lib/types/routes.d.ts +67 -0
- package/lib/types/settings.d.ts +78 -0
- package/lib/types/skill-market/api.d.ts +10 -0
- package/lib/types/skill-market/config.d.ts +3 -0
- package/lib/types/skill-market/installer.d.ts +23 -0
- package/lib/types/skill-market/runtime.d.ts +10 -0
- package/lib/types/skill-market/tui.d.ts +4 -0
- package/lib/types/skill-market/types.d.ts +71 -0
- package/lib/types/skill-market/web-routes.d.ts +19 -0
- package/lib/types/snapshot.d.ts +93 -0
- package/lib/types/source-migration.d.ts +11 -0
- package/lib/types/sources.d.ts +216 -0
- package/lib/types/store.d.ts +30 -0
- package/lib/types/themes.d.ts +40 -0
- package/lib/types/trial.d.ts +61 -0
- package/lib/types/update-api-v1.d.ts +66 -0
- package/lib/types/updates.d.ts +123 -0
- package/lib/types/verify.d.ts +139 -0
- package/lib/update-api-v1.js +215 -0
- package/lib/updates.js +361 -0
- package/lib/verify.js +453 -0
- package/package.json +121 -0
- package/src/accelerate.ts +213 -0
- package/src/agents.ts +43 -0
- package/src/backup.ts +583 -0
- package/src/catalog-local-match.ts +144 -0
- package/src/catalog-npm.ts +120 -0
- package/src/changelog.ts +282 -0
- package/src/channels.ts +70 -0
- package/src/check.ts +1239 -0
- package/src/client/CommentsModal.tsx +119 -0
- package/src/client/Diagnostics.tsx +907 -0
- package/src/client/ErrorBoundary.tsx +111 -0
- package/src/client/InstallToast.tsx +31 -0
- package/src/client/Market.module.css +830 -0
- package/src/client/MarketSection.tsx +5301 -0
- package/src/client/OperationsPanel.tsx +365 -0
- package/src/client/SettingsCard.tsx +621 -0
- package/src/client/SkillMarket.module.css +21 -0
- package/src/client/SkillMarketSection.tsx +162 -0
- package/src/client/comments.ts +54 -0
- package/src/client/globals.d.ts +13 -0
- package/src/client/index.ts +180 -0
- package/src/client/locales.ts +1112 -0
- package/src/client/market-data.ts +1321 -0
- package/src/client/operations.ts +201 -0
- package/src/client/preset-panel.tsx +263 -0
- package/src/client/primitives.d.ts +140 -0
- package/src/client/self-check.ts +147 -0
- package/src/client/snapshot-panel.tsx +244 -0
- package/src/compatibility.ts +237 -0
- package/src/diagnostics.ts +84 -0
- package/src/discovery-compatibility.ts +315 -0
- package/src/dsh-cli.ts +1126 -0
- package/src/dsh-install.ts +118 -0
- package/src/gist.ts +362 -0
- package/src/groups.ts +111 -0
- package/src/home-paths.ts +53 -0
- package/src/hot.ts +628 -0
- package/src/http.ts +41 -0
- package/src/index.ts +128 -0
- package/src/install.ts +420 -0
- package/src/log.ts +206 -0
- package/src/ndjson.ts +185 -0
- package/src/net.ts +106 -0
- package/src/order.ts +303 -0
- package/src/patch.ts +522 -0
- package/src/pnpm-compat.ts +527 -0
- package/src/presets.ts +344 -0
- package/src/profile.ts +940 -0
- package/src/region-probe.ts +97 -0
- package/src/regions.ts +310 -0
- package/src/registry.ts +250 -0
- package/src/restart.ts +396 -0
- package/src/routes.ts +4506 -0
- package/src/settings.ts +141 -0
- package/src/skill-market/api.ts +130 -0
- package/src/skill-market/config.ts +32 -0
- package/src/skill-market/installer.ts +337 -0
- package/src/skill-market/runtime.ts +14 -0
- package/src/skill-market/tui.ts +157 -0
- package/src/skill-market/types.ts +80 -0
- package/src/skill-market/web-routes.ts +132 -0
- package/src/snapshot.ts +532 -0
- package/src/source-migration.ts +61 -0
- package/src/sources.ts +565 -0
- package/src/store.ts +89 -0
- package/src/themes.ts +125 -0
- package/src/trial.ts +156 -0
- package/src/update-api-v1.ts +277 -0
- package/src/updates.ts +400 -0
- package/src/verify.ts +492 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog matching for locally linked / file: installs. Shared by the host
|
|
3
|
+
* restore route and the Market client so both refuse the same wrong guesses.
|
|
4
|
+
*/
|
|
5
|
+
const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
6
|
+
function validSubpath(subpath) {
|
|
7
|
+
if (!/^[A-Za-z0-9_./-]+$/.test(subpath))
|
|
8
|
+
return false;
|
|
9
|
+
return !subpath.split('/').some(seg => seg === '' || seg === '.' || seg === '..');
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Keys a catalog URL contributes to restore matching.
|
|
13
|
+
* A `/tree/` entry is ONLY its exact `#path:` id — never the bare repo —
|
|
14
|
+
* so a collection-root identity cannot select a sibling subpackage.
|
|
15
|
+
*/
|
|
16
|
+
function catalogMatchKeys(url) {
|
|
17
|
+
const m = /^https:\/\/github\.com\/([^/]+\/[^/]+?)(?:\/tree\/[^/]+\/(.+?))?\/?$/.exec(url);
|
|
18
|
+
if (m === null || !REPO_RE.test(m[1]))
|
|
19
|
+
return { path: null, repo: null };
|
|
20
|
+
const subpath = m[2] ?? null;
|
|
21
|
+
if (subpath !== null && !validSubpath(subpath))
|
|
22
|
+
return { path: null, repo: null };
|
|
23
|
+
const repo = m[1].toLowerCase();
|
|
24
|
+
return subpath === null
|
|
25
|
+
? { path: null, repo }
|
|
26
|
+
: { path: `${repo}#path:/${subpath.toLowerCase()}`, repo: null };
|
|
27
|
+
}
|
|
28
|
+
function catalogEntryMatchesHints(url, hintSet) {
|
|
29
|
+
const keys = catalogMatchKeys(url);
|
|
30
|
+
return (keys.path !== null && hintSet.has(keys.path))
|
|
31
|
+
|| (keys.repo !== null && hintSet.has(keys.repo));
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The catalog entry a locally linked / file: install should restore to.
|
|
35
|
+
* Exact `#path:` identities win, then collection-root identities against
|
|
36
|
+
* root-only catalog rows, then a unique name/npm match when nothing
|
|
37
|
+
* contradicts it. A bare repo identity never selects a root row while
|
|
38
|
+
* `/tree/` siblings exist for that repo — the checkout did not say which
|
|
39
|
+
* package it is, and guessing wrong installs a different plugin.
|
|
40
|
+
* Same-named forks without identities or a matching hint stay unmatched
|
|
41
|
+
* rather than guessing; declared repo evidence that matches nothing in the
|
|
42
|
+
* catalog must not fall back to a coincidental unique name.
|
|
43
|
+
*/
|
|
44
|
+
export function findCatalogEntryForLocal(plugins, name, identities = [], hints = []) {
|
|
45
|
+
const nameKey = name.toLowerCase();
|
|
46
|
+
const byName = plugins.filter(plugin => plugin.name.toLowerCase() === nameKey
|
|
47
|
+
|| (typeof plugin.npm === 'string' && plugin.npm.toLowerCase() === nameKey));
|
|
48
|
+
const identitySet = new Set(identities.map(value => value.toLowerCase()));
|
|
49
|
+
const hintSet = new Set(hints.map(value => value.toLowerCase()));
|
|
50
|
+
const treeRepos = new Set();
|
|
51
|
+
for (const plugin of plugins) {
|
|
52
|
+
const keys = catalogMatchKeys(plugin.url);
|
|
53
|
+
if (keys.path !== null)
|
|
54
|
+
treeRepos.add(keys.path.slice(0, keys.path.indexOf('#path:/')));
|
|
55
|
+
}
|
|
56
|
+
if (identitySet.size > 0) {
|
|
57
|
+
const pathHit = plugins.find(plugin => {
|
|
58
|
+
const keys = catalogMatchKeys(plugin.url);
|
|
59
|
+
return keys.path !== null && identitySet.has(keys.path);
|
|
60
|
+
});
|
|
61
|
+
if (pathHit !== undefined)
|
|
62
|
+
return pathHit;
|
|
63
|
+
const rootHit = plugins.find(plugin => {
|
|
64
|
+
const keys = catalogMatchKeys(plugin.url);
|
|
65
|
+
if (keys.repo === null || !identitySet.has(keys.repo))
|
|
66
|
+
return false;
|
|
67
|
+
return !treeRepos.has(keys.repo) || byName.includes(plugin);
|
|
68
|
+
});
|
|
69
|
+
if (rootHit !== undefined)
|
|
70
|
+
return rootHit;
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
if (byName.length === 1) {
|
|
74
|
+
const only = byName[0];
|
|
75
|
+
if (hintSet.size > 0 && !catalogEntryMatchesHints(only.url, hintSet))
|
|
76
|
+
return null;
|
|
77
|
+
return only;
|
|
78
|
+
}
|
|
79
|
+
if (byName.length > 1 && hintSet.size > 0) {
|
|
80
|
+
const hinted = byName.find(plugin => catalogEntryMatchesHints(plugin.url, hintSet));
|
|
81
|
+
if (hinted !== undefined)
|
|
82
|
+
return hinted;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
function catalogEntriesByName(plugins, name) {
|
|
87
|
+
const nameKey = name.toLowerCase();
|
|
88
|
+
return plugins.filter(plugin => plugin.name.toLowerCase() === nameKey
|
|
89
|
+
|| (typeof plugin.npm === 'string' && plugin.npm.toLowerCase() === nameKey));
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Why a local restore was blocked, when findCatalogEntryForLocal returned
|
|
93
|
+
* null — and, when it matched, whether anything but the NAME agreed.
|
|
94
|
+
*
|
|
95
|
+
* `verified` is the difference between "put this back where it came from"
|
|
96
|
+
* and "install the catalog's plugin that happens to share this name". A
|
|
97
|
+
* local checkout with no declared repo — no git remote, no `repository`
|
|
98
|
+
* field — gives the market nothing to match on, so a unique same-named
|
|
99
|
+
* catalog entry is a guess. Usually a good one (#429: the local copy IS a
|
|
100
|
+
* tweaked copy of that entry). Not always: @liuwenji007 reported a fork of
|
|
101
|
+
* their own `dsh-humanizer` restored to a different author's plugin of the
|
|
102
|
+
* same name (#485), which is someone else's code arriving under a button
|
|
103
|
+
* labelled "restore".
|
|
104
|
+
*
|
|
105
|
+
* The match is kept, because refusing it would break the ordinary case, and
|
|
106
|
+
* the caller is told not to present it as a certainty.
|
|
107
|
+
*/
|
|
108
|
+
export function resolveCatalogRestore(plugins, name, identities = [], hints = []) {
|
|
109
|
+
const entry = findCatalogEntryForLocal(plugins, name, identities, hints);
|
|
110
|
+
if (entry !== null) {
|
|
111
|
+
// Evidence is what every other branch of the matcher requires; the
|
|
112
|
+
// name-only branch is reachable only when there is none of it.
|
|
113
|
+
const verified = identities.length > 0 || hints.length > 0;
|
|
114
|
+
return { ok: true, entry, verified };
|
|
115
|
+
}
|
|
116
|
+
const byName = catalogEntriesByName(plugins, name);
|
|
117
|
+
if (byName.length === 0)
|
|
118
|
+
return { ok: false, reason: 'no-catalog' };
|
|
119
|
+
const identitySet = new Set(identities.map(value => value.toLowerCase()));
|
|
120
|
+
const hintSet = new Set(hints.map(value => value.toLowerCase()));
|
|
121
|
+
if (identitySet.size > 0 || hintSet.size > 0)
|
|
122
|
+
return { ok: false, reason: 'repo-mismatch' };
|
|
123
|
+
// Only ambiguous same-name rows reach here: a unique name with no evidence
|
|
124
|
+
// would have matched inside findCatalogEntryForLocal.
|
|
125
|
+
return { ok: false, reason: 'no-catalog' };
|
|
126
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the plugin catalog out of a published npm package.
|
|
3
|
+
*
|
|
4
|
+
* The catalog's own host is the thing being worked around. `plugins.json` is
|
|
5
|
+
* served from GitHub Pages, and the public GitHub proxies that make GitHub
|
|
6
|
+
* usable from mainland China refuse any hostname that is not github.com's
|
|
7
|
+
* own — measured: the project domain comes back 403. So the file cannot be
|
|
8
|
+
* carried through the same route as everything else while it lives there.
|
|
9
|
+
*
|
|
10
|
+
* Published to npm it can be. Every China npm mirror already carries it, it
|
|
11
|
+
* is the same infrastructure the plugins themselves arrive on, and it needs
|
|
12
|
+
* no service that did not already have to work. It also gains a version
|
|
13
|
+
* number, which the origin never had: a catalog build that ships bad data
|
|
14
|
+
* can be rolled back instead of only being fixed forwards.
|
|
15
|
+
*
|
|
16
|
+
* Mirrors rewrite `dist.tarball` to point at themselves, so following that
|
|
17
|
+
* field is what keeps the download on the mirror rather than bouncing back
|
|
18
|
+
* to the origin registry — verified against the Tencent mirror, which serves
|
|
19
|
+
* its own host in that field.
|
|
20
|
+
*/
|
|
21
|
+
import { gunzipSync } from 'node:zlib';
|
|
22
|
+
import { marketFetch } from './net.js';
|
|
23
|
+
/** Long enough for a multi-megabyte tarball on a slow link, short enough to fall back. */
|
|
24
|
+
const TIMEOUT_MS = 20_000;
|
|
25
|
+
/**
|
|
26
|
+
* A tar entry header, as far as this needs to read one.
|
|
27
|
+
*
|
|
28
|
+
* Written out rather than pulled from a dependency: the format is 512-byte
|
|
29
|
+
* headers with the name at offset 0 and an octal size at 124, and a reader
|
|
30
|
+
* for one known filename is shorter than the argument for adding a package
|
|
31
|
+
* to a plugin's runtime.
|
|
32
|
+
*/
|
|
33
|
+
const NAME_OFFSET = 0;
|
|
34
|
+
const NAME_LENGTH = 100;
|
|
35
|
+
const SIZE_OFFSET = 124;
|
|
36
|
+
const SIZE_LENGTH = 12;
|
|
37
|
+
const TYPE_OFFSET = 156;
|
|
38
|
+
const BLOCK = 512;
|
|
39
|
+
/**
|
|
40
|
+
* One file's bytes from a gzipped tar, or null when it is not in there.
|
|
41
|
+
*
|
|
42
|
+
* @param gz - the gzipped tarball.
|
|
43
|
+
* @param wanted - the exact entry name, npm-style (`package/plugins.json`).
|
|
44
|
+
*/
|
|
45
|
+
export function fileFromTarball(gz, wanted) {
|
|
46
|
+
const buf = gunzipSync(gz);
|
|
47
|
+
let offset = 0;
|
|
48
|
+
while (offset + BLOCK <= buf.length) {
|
|
49
|
+
const name = buf.toString('utf8', offset + NAME_OFFSET, offset + NAME_OFFSET + NAME_LENGTH).replace(/\0.*$/s, '');
|
|
50
|
+
// Two consecutive empty headers end a tar; one is enough to stop here.
|
|
51
|
+
if (name === '')
|
|
52
|
+
break;
|
|
53
|
+
const rawSize = buf.toString('ascii', offset + SIZE_OFFSET, offset + SIZE_OFFSET + SIZE_LENGTH).replace(/\0.*$/s, '').trim();
|
|
54
|
+
const size = Number.parseInt(rawSize, 8);
|
|
55
|
+
if (!Number.isFinite(size) || size < 0)
|
|
56
|
+
break;
|
|
57
|
+
const type = String.fromCharCode(buf[offset + TYPE_OFFSET] ?? 0);
|
|
58
|
+
offset += BLOCK;
|
|
59
|
+
// '0' and NUL both mean a regular file; anything else (directories,
|
|
60
|
+
// links, pax headers) is skipped rather than mistaken for content.
|
|
61
|
+
if ((type === '0' || type === '\0') && name === wanted) {
|
|
62
|
+
return buf.subarray(offset, offset + size);
|
|
63
|
+
}
|
|
64
|
+
offset += Math.ceil(size / BLOCK) * BLOCK;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The catalog carried by a published package, and the version it came from.
|
|
70
|
+
*
|
|
71
|
+
* The version doubles as the cache validator — a better one than an ETag,
|
|
72
|
+
* because it is meaningful to a human reading a log and it is the thing a
|
|
73
|
+
* rollback would change.
|
|
74
|
+
*
|
|
75
|
+
* @param registry - registry base, no trailing slash.
|
|
76
|
+
* @param pkg - package name.
|
|
77
|
+
* @param known - a version already held, to skip re-downloading.
|
|
78
|
+
* @returns the parsed JSON and its version, or `{ version, data: null }` when
|
|
79
|
+
* the published version is the one already held.
|
|
80
|
+
* @throws when the package, the tarball or the file inside it cannot be read.
|
|
81
|
+
*/
|
|
82
|
+
export async function catalogFromPackage(registry, pkg, known,
|
|
83
|
+
/** The entry to extract; the catalog package carries `plugins.json`, companions theirs. */
|
|
84
|
+
file = 'package/plugins.json') {
|
|
85
|
+
const metaRes = await marketFetch(`${registry}/${encodeURIComponent(pkg)}/latest`, {
|
|
86
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
87
|
+
headers: { accept: 'application/json', 'user-agent': 'ChatCode-CLI-Market' },
|
|
88
|
+
});
|
|
89
|
+
if (!metaRes.ok)
|
|
90
|
+
throw new Error(`HTTP ${String(metaRes.status)} reading ${pkg} metadata`);
|
|
91
|
+
const meta = (await metaRes.json());
|
|
92
|
+
const version = typeof meta.version === 'string' ? meta.version : null;
|
|
93
|
+
const tarball = typeof meta.dist?.tarball === 'string' ? meta.dist.tarball : null;
|
|
94
|
+
if (version === null || tarball === null)
|
|
95
|
+
throw new Error(`${pkg} metadata names no version or tarball`);
|
|
96
|
+
// Nothing changed, so nothing to download. The whole point of putting the
|
|
97
|
+
// catalog on a mirror is the bytes it saves; re-fetching a package we
|
|
98
|
+
// already hold would give most of them back.
|
|
99
|
+
if (known !== undefined && known === version)
|
|
100
|
+
return { version, data: null };
|
|
101
|
+
// Follow `dist.tarball` rather than composing a URL. A mirror rewrites
|
|
102
|
+
// this field to its own host, and composing one would send the download
|
|
103
|
+
// back to the origin registry the region exists to avoid.
|
|
104
|
+
const tarRes = await marketFetch(tarball, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
105
|
+
if (!tarRes.ok)
|
|
106
|
+
throw new Error(`HTTP ${String(tarRes.status)} reading ${pkg} tarball`);
|
|
107
|
+
const bytes = fileFromTarball(Buffer.from(await tarRes.arrayBuffer()), file);
|
|
108
|
+
if (bytes === null)
|
|
109
|
+
throw new Error(`${pkg}@${version} carries no ${file}`);
|
|
110
|
+
return { version, data: JSON.parse(bytes.toString('utf8')) };
|
|
111
|
+
}
|
package/lib/changelog.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update notes: what changed between an installed version and HEAD.
|
|
3
|
+
*
|
|
4
|
+
* Every byte of GitHub data comes from the catalog side's daily probe,
|
|
5
|
+
* published as the `dsh-plugin-updates` package (origin fallback
|
|
6
|
+
* `updates.json` beside `plugins.json`). The market asks GitHub for nothing:
|
|
7
|
+
* anonymous REST quota is shared per egress IP and already unreliable behind
|
|
8
|
+
* common proxies, and this data is wanted by thousands of markets but
|
|
9
|
+
* produced perfectly well once a day by one probe.
|
|
10
|
+
*
|
|
11
|
+
* What the catalog cannot know is where each user's installed version sits,
|
|
12
|
+
* so slicing the commit tail at that boundary happens here: the same sha the
|
|
13
|
+
* update check resolved (`current`) marks where "new" begins. A tail that
|
|
14
|
+
* does not contain it means the range is wider than the tail — those are
|
|
15
|
+
* labelled recent commits rather than pretending to be an exact interval.
|
|
16
|
+
* Plugins with neither notes nor commits fall back to npm publish times, and
|
|
17
|
+
* a plugin nothing answers for gets a neutral "no notes" — absence of release
|
|
18
|
+
* notes is the ordinary condition of small plugins, not a defect.
|
|
19
|
+
*/
|
|
20
|
+
import { fileFromTarball } from './catalog-npm.js';
|
|
21
|
+
import { marketFetch } from './net.js';
|
|
22
|
+
import { activeRegion, routesFor } from './regions.js';
|
|
23
|
+
import { profileDir, readInstalled, readLockCommits } from './profile.js';
|
|
24
|
+
import { lookupRepoFromUrl, repoOfTarget } from './sources.js';
|
|
25
|
+
import { checkUpdates } from './updates.js';
|
|
26
|
+
import { loadRegistry } from './registry.js';
|
|
27
|
+
import { compatibilityEnv } from './home-paths.js';
|
|
28
|
+
const UPDATES_PACKAGE = 'dsh-plugin-updates';
|
|
29
|
+
const UPDATES_FILE = 'package/updates.json';
|
|
30
|
+
/** The origin copy sits beside plugins.json on the catalog host. */
|
|
31
|
+
const ORIGIN_UPDATES = compatibilityEnv(process.env, 'CHATCODE_CLI_MARKET_UPDATES_ORIGIN', 'DSHM_UPDATES_ORIGIN') ?? 'https://awesome-dsh-plugin.com/updates.json';
|
|
32
|
+
/** Notes are produced daily; re-checking hourly bounds staleness without churn. */
|
|
33
|
+
const NOTES_TTL_MS = 60 * 60 * 1000;
|
|
34
|
+
const TIMEOUT_MS = 15_000;
|
|
35
|
+
let notesCache = null;
|
|
36
|
+
/**
|
|
37
|
+
* The update-notes payload from whichever route the region serves.
|
|
38
|
+
*
|
|
39
|
+
* Same source order as the catalog itself — the npm package first where one
|
|
40
|
+
* exists (its mirror reach is the reason the package exists), then the
|
|
41
|
+
* origin. Version-keyed like the catalog too: an unchanged payload costs one
|
|
42
|
+
* packument read per TTL, not a tarball.
|
|
43
|
+
*/
|
|
44
|
+
export async function loadUpdateNotes(force = false) {
|
|
45
|
+
if (!force && notesCache !== null && Date.now() - notesCache.at < NOTES_TTL_MS) {
|
|
46
|
+
return notesCache.data;
|
|
47
|
+
}
|
|
48
|
+
const routes = routesFor(activeRegion());
|
|
49
|
+
const attempt = async () => {
|
|
50
|
+
// The npm package, on the region's registry — mirrors rewrite dist.tarball
|
|
51
|
+
// to themselves, so following that field keeps the download local.
|
|
52
|
+
const metaRes = await marketFetch(`${routes.npmRegistry}/${encodeURIComponent(UPDATES_PACKAGE)}/latest`, {
|
|
53
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
54
|
+
headers: { accept: 'application/json', 'user-agent': 'ChatCode-CLI-Market' },
|
|
55
|
+
});
|
|
56
|
+
if (!metaRes.ok)
|
|
57
|
+
throw new Error(`HTTP ${String(metaRes.status)} reading ${UPDATES_PACKAGE} metadata`);
|
|
58
|
+
const meta = await metaRes.json();
|
|
59
|
+
const tarball = typeof meta.dist?.tarball === 'string' ? meta.dist.tarball : null;
|
|
60
|
+
if (tarball === null)
|
|
61
|
+
throw new Error(`${UPDATES_PACKAGE} names no tarball`);
|
|
62
|
+
const tarRes = await marketFetch(tarball, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
63
|
+
if (!tarRes.ok)
|
|
64
|
+
throw new Error(`HTTP ${String(tarRes.status)} reading ${UPDATES_PACKAGE} tarball`);
|
|
65
|
+
const bytes = fileFromTarball(Buffer.from(await tarRes.arrayBuffer()), UPDATES_FILE);
|
|
66
|
+
if (bytes === null)
|
|
67
|
+
throw new Error(`${UPDATES_PACKAGE} carries no ${UPDATES_FILE}`);
|
|
68
|
+
return JSON.parse(bytes.toString('utf8'));
|
|
69
|
+
};
|
|
70
|
+
const attemptOrigin = async () => {
|
|
71
|
+
const res = await marketFetch(ORIGIN_UPDATES, {
|
|
72
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
73
|
+
headers: { accept: 'application/json', 'user-agent': 'ChatCode-CLI-Market' },
|
|
74
|
+
});
|
|
75
|
+
if (!res.ok)
|
|
76
|
+
throw new Error(`HTTP ${String(res.status)} reading ${ORIGIN_UPDATES}`);
|
|
77
|
+
return await res.json();
|
|
78
|
+
};
|
|
79
|
+
// Two independent sources, each tried twice — the same retry economics as
|
|
80
|
+
// the catalog loader, for the same transient-loss reasons.
|
|
81
|
+
let lastError = null;
|
|
82
|
+
for (const attemptFn of [attempt, attemptOrigin]) {
|
|
83
|
+
for (let i = 0; i < 2; i++) {
|
|
84
|
+
try {
|
|
85
|
+
const data = await attemptFn();
|
|
86
|
+
notesCache = { at: Date.now(), data };
|
|
87
|
+
return data;
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
lastError = error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
throw lastError instanceof Error ? lastError : new Error('update notes unavailable');
|
|
95
|
+
}
|
|
96
|
+
/** Drop the cached payload (nothing currently invalidates it mid-process). */
|
|
97
|
+
export function invalidateUpdateNotes() {
|
|
98
|
+
notesCache = null;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Slice the commit tail at the installed sha.
|
|
102
|
+
*
|
|
103
|
+
* @returns the commits newer than `installed`, and whether the boundary was
|
|
104
|
+
* actually found — `found === false` means the range is wider than the tail
|
|
105
|
+
* (or history diverged), so callers must label the result "recent" rather
|
|
106
|
+
* than exact.
|
|
107
|
+
*/
|
|
108
|
+
export function sliceCommitsAt(items, installed) {
|
|
109
|
+
if (installed === null)
|
|
110
|
+
return { items, found: false };
|
|
111
|
+
const out = [];
|
|
112
|
+
for (const commit of items) {
|
|
113
|
+
if (commit.sha === installed)
|
|
114
|
+
return { items: out, found: true };
|
|
115
|
+
out.push(commit);
|
|
116
|
+
}
|
|
117
|
+
return { items: out, found: false };
|
|
118
|
+
}
|
|
119
|
+
/** The repo url key an entry's update notes live under, or null. */
|
|
120
|
+
export function repoKeyOf(spec) {
|
|
121
|
+
const repo = repoOfTarget(spec)?.split('#')[0] ?? null;
|
|
122
|
+
return repo === null ? null : `https://github.com/${repo}`;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Look an entry up by its catalog url.
|
|
126
|
+
*
|
|
127
|
+
* The catalog keys entries by the url as listed (author's casing preserved);
|
|
128
|
+
* the market resolves repos to lowercase (as every other consumer of
|
|
129
|
+
* `repoOfTarget` sees them). Match case-insensitively so the two sides never
|
|
130
|
+
* disagree over a capital letter.
|
|
131
|
+
*/
|
|
132
|
+
export function entryForRepo(payload, key) {
|
|
133
|
+
const direct = payload.updates?.[key];
|
|
134
|
+
if (direct !== undefined)
|
|
135
|
+
return direct;
|
|
136
|
+
const lower = key.toLowerCase();
|
|
137
|
+
for (const [k, v] of Object.entries(payload.updates ?? {})) {
|
|
138
|
+
if (k.toLowerCase() === lower)
|
|
139
|
+
return v;
|
|
140
|
+
}
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Publish times for an npm package's recent versions, newest first.
|
|
145
|
+
*
|
|
146
|
+
* The last resort before "no notes": authors who ship neither releases nor
|
|
147
|
+
* informative commits still usually tag versions, and "0.4.0 landed three
|
|
148
|
+
* days ago" beats nothing. Reads the full packument once and caches it —
|
|
149
|
+
* the doc is heavy, and a user may open the dialog twice.
|
|
150
|
+
*/
|
|
151
|
+
const timesCache = new Map();
|
|
152
|
+
const TIMES_TTL_MS = 30 * 60 * 1000;
|
|
153
|
+
export async function npmPublishTimes(name) {
|
|
154
|
+
const hit = timesCache.get(name);
|
|
155
|
+
if (hit !== undefined && Date.now() - hit.at < TIMES_TTL_MS)
|
|
156
|
+
return hit.data;
|
|
157
|
+
const registry = routesFor(activeRegion()).npmRegistry;
|
|
158
|
+
const res = await marketFetch(`${registry}/${encodeURIComponent(name)}`, {
|
|
159
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
160
|
+
headers: { accept: 'application/json', 'user-agent': 'ChatCode-CLI-Market' },
|
|
161
|
+
});
|
|
162
|
+
if (!res.ok)
|
|
163
|
+
throw new Error(`HTTP ${String(res.status)} reading ${name}`);
|
|
164
|
+
const doc = await res.json();
|
|
165
|
+
const time = doc.time ?? {};
|
|
166
|
+
const data = Object.entries(time)
|
|
167
|
+
.filter(([version]) => version !== 'created' && version !== 'modified')
|
|
168
|
+
.sort(([, a], [, b]) => Date.parse(b) - Date.parse(a))
|
|
169
|
+
.slice(0, 5)
|
|
170
|
+
.map(([version, date]) => ({ version, date }));
|
|
171
|
+
timesCache.set(name, { at: Date.now(), data });
|
|
172
|
+
return data;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Resolve the notes for one installed plugin, or `{ kind: 'none' }`.
|
|
176
|
+
*
|
|
177
|
+
* Never throws: every failure along the way degrades to the next tier, the
|
|
178
|
+
* same contract the issue promised — a dialog that cannot load its data shows
|
|
179
|
+
* a neutral statement, not an error.
|
|
180
|
+
*/
|
|
181
|
+
export async function updateNotesFor(profile, explicitDir, name) {
|
|
182
|
+
const activeProfileDir = profileDir(profile, explicitDir);
|
|
183
|
+
const spec = readInstalled(profile, activeProfileDir)[name];
|
|
184
|
+
if (spec === undefined || spec.startsWith('link:') || spec.startsWith('file:')) {
|
|
185
|
+
return { kind: 'none' };
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
const payload = await loadUpdateNotes();
|
|
189
|
+
let key = repoKeyOf(spec);
|
|
190
|
+
// If the spec is a Release asset URL (catalog format), try to extract
|
|
191
|
+
// the repo from the URL directly. This handles npm-installed plugins
|
|
192
|
+
// whose profile spec is the npm name but the catalog entry uses a
|
|
193
|
+
// Release asset tarball URL.
|
|
194
|
+
if (key === null) {
|
|
195
|
+
key = lookupRepoFromUrl(spec);
|
|
196
|
+
}
|
|
197
|
+
// If the spec is an npm package name (not a GitHub URL), look up the
|
|
198
|
+
// catalog to find the corresponding GitHub repo URL.
|
|
199
|
+
if (key === null) {
|
|
200
|
+
try {
|
|
201
|
+
const registry = await loadRegistry();
|
|
202
|
+
const plugin = registry.plugins.find(p => p.name === name);
|
|
203
|
+
if (plugin !== undefined) {
|
|
204
|
+
key = plugin.url;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// Catalog unavailable; fall through to npm times.
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const entry = key === null ? undefined : entryForRepo(payload, key);
|
|
212
|
+
// Both tiers below need the installed sha for github-kind installs.
|
|
213
|
+
// For Release asset URLs and catalog lookups, checkUpdates doesn't
|
|
214
|
+
// recognize the spec, so we read the lockfile directly using the repo key.
|
|
215
|
+
let current = null;
|
|
216
|
+
if (key !== null && key.startsWith('https://github.com/')) {
|
|
217
|
+
const repo = key.slice('https://github.com/'.length).split('#')[0].toLowerCase();
|
|
218
|
+
current = readLockCommits(profile, activeProfileDir).get(repo) ?? null;
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
const statuses = await checkUpdates(profile, false, explicitDir).catch(() => null);
|
|
222
|
+
current = statuses?.[name]?.current ?? null;
|
|
223
|
+
}
|
|
224
|
+
if (entry?.release !== undefined && entry.release !== null && (entry.release.body !== '' || entry.release.tag !== null)) {
|
|
225
|
+
return { kind: 'release', release: entry.release };
|
|
226
|
+
}
|
|
227
|
+
if (entry?.commits !== undefined && entry.commits.length > 0) {
|
|
228
|
+
return { kind: 'commits', commits: sliceCommitsAt(entry.commits, current) };
|
|
229
|
+
}
|
|
230
|
+
if (key === null) {
|
|
231
|
+
// Not a github-sourced plugin at all: npm times are the only tier left.
|
|
232
|
+
return { kind: 'npm', npmTimes: await npmPublishTimes(name) };
|
|
233
|
+
}
|
|
234
|
+
// A github plugin whose repo answered nothing — releases 404 AND the log
|
|
235
|
+
// came back empty. Rare enough to just say so rather than ask npm about a
|
|
236
|
+
// name that may not even exist there.
|
|
237
|
+
return { kind: 'none' };
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return { kind: 'none' };
|
|
241
|
+
}
|
|
242
|
+
}
|
package/lib/channels.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The market's release channels: which build of ITSELF it offers.
|
|
3
|
+
*
|
|
4
|
+
* Only the market follows this. Other plugins are never pulled from a
|
|
5
|
+
* prerelease on the strength of a setting the user made about the market —
|
|
6
|
+
* opting into early builds is volunteering to try THIS plugin early, not to
|
|
7
|
+
* be handed every other author's unreleased work.
|
|
8
|
+
*
|
|
9
|
+
* The model lives in its own module because it is the part with rules
|
|
10
|
+
* rather than plumbing: three channels, one of them hidden, a mapping to
|
|
11
|
+
* npm dist-tags, and a resolution order that has already been got wrong
|
|
12
|
+
* once (see `resolveChannel`).
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* The npm dist-tag each channel installs from.
|
|
16
|
+
*
|
|
17
|
+
* `dev` is published straight from a branch with no git tag behind it, so a
|
|
18
|
+
* version carries a timestamp and a short SHA (`1.15.0-dev.20260818-3f1432e`)
|
|
19
|
+
* and is never reused. That is what makes a dev build disposable: nothing in
|
|
20
|
+
* the repository's history refers to it.
|
|
21
|
+
*/
|
|
22
|
+
export const DIST_TAG = {
|
|
23
|
+
stable: 'latest',
|
|
24
|
+
beta: 'beta',
|
|
25
|
+
dev: 'dev',
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Every channel a user may pick. All three, always.
|
|
29
|
+
*
|
|
30
|
+
* `dev` was behind a developer-mode switch for one version, on the reasoning
|
|
31
|
+
* that a build published straight off a branch should not sit beside
|
|
32
|
+
* "stable" looking like a third degree of caution. The switch cost more than
|
|
33
|
+
* it bought: a stored mode, a route to change it, a rule for what happens to
|
|
34
|
+
* a dev choice when it is turned off, and a control whose own purpose needed
|
|
35
|
+
* explaining. A plainly labelled option a user can read is simpler than a
|
|
36
|
+
* hidden one plus the machinery that hides it — the label does the work the
|
|
37
|
+
* gate was doing.
|
|
38
|
+
*/
|
|
39
|
+
export const CHANNELS = ['stable', 'beta', 'dev'];
|
|
40
|
+
/** Narrow an untrusted value to a Channel, or null. */
|
|
41
|
+
export function asChannel(value) {
|
|
42
|
+
return value === 'stable' || value === 'beta' || value === 'dev' ? value : null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Which channel applies right now.
|
|
46
|
+
*
|
|
47
|
+
* A choice on record always wins — including "stable" while a prerelease is
|
|
48
|
+
* running, which is the only way back off a channel. Only the ABSENCE of a
|
|
49
|
+
* choice is derived, and then from what is actually running: installing
|
|
50
|
+
* `dshmarket@beta` by hand IS the subscription, and treating that as
|
|
51
|
+
* "stable" costs updates rather than just clarity — on the stable channel
|
|
52
|
+
* `latest` (1.13.1) is not newer than an installed 1.14.0-beta.1, so the
|
|
53
|
+
* market answers "up to date" and the next beta is never offered.
|
|
54
|
+
*
|
|
55
|
+
* Which makes `undefined` load-bearing: it has to survive both the settings
|
|
56
|
+
* schema (no `.default`) and state.json (field omitted) or "never chose"
|
|
57
|
+
* silently becomes "chose stable".
|
|
58
|
+
*
|
|
59
|
+
*/
|
|
60
|
+
export function resolveChannel(setting, version) {
|
|
61
|
+
if (setting !== undefined)
|
|
62
|
+
return setting;
|
|
63
|
+
return version.includes('-') ? 'beta' : 'stable';
|
|
64
|
+
}
|