@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.
Files changed (177) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/README.zh.md +143 -0
  4. package/UPDATE-API-V1.md +132 -0
  5. package/client/client.js +11534 -0
  6. package/cordis.patch.yml +5 -0
  7. package/lib/accelerate.js +184 -0
  8. package/lib/agents.js +36 -0
  9. package/lib/backup.js +572 -0
  10. package/lib/catalog-local-match.js +126 -0
  11. package/lib/catalog-npm.js +111 -0
  12. package/lib/changelog.js +242 -0
  13. package/lib/channels.js +64 -0
  14. package/lib/check.js +1067 -0
  15. package/lib/compatibility.js +198 -0
  16. package/lib/diagnostics.js +49 -0
  17. package/lib/discovery-compatibility.js +260 -0
  18. package/lib/dsh-cli.js +949 -0
  19. package/lib/dsh-install.js +108 -0
  20. package/lib/gist.js +352 -0
  21. package/lib/groups.js +97 -0
  22. package/lib/home-paths.js +42 -0
  23. package/lib/hot.js +519 -0
  24. package/lib/http.js +39 -0
  25. package/lib/index.js +97 -0
  26. package/lib/install.js +387 -0
  27. package/lib/log.js +197 -0
  28. package/lib/ndjson.js +154 -0
  29. package/lib/net.js +98 -0
  30. package/lib/order.js +284 -0
  31. package/lib/patch.js +521 -0
  32. package/lib/pnpm-compat.js +490 -0
  33. package/lib/presets.js +278 -0
  34. package/lib/profile.js +911 -0
  35. package/lib/region-probe.js +93 -0
  36. package/lib/regions.js +253 -0
  37. package/lib/registry.js +204 -0
  38. package/lib/restart.js +373 -0
  39. package/lib/routes.js +4372 -0
  40. package/lib/settings.js +109 -0
  41. package/lib/skill-market/api.js +119 -0
  42. package/lib/skill-market/config.js +29 -0
  43. package/lib/skill-market/installer.js +352 -0
  44. package/lib/skill-market/runtime.js +13 -0
  45. package/lib/skill-market/tui.js +119 -0
  46. package/lib/skill-market/types.js +1 -0
  47. package/lib/skill-market/web-routes.js +123 -0
  48. package/lib/snapshot.js +500 -0
  49. package/lib/source-migration.js +54 -0
  50. package/lib/sources.js +596 -0
  51. package/lib/store.js +91 -0
  52. package/lib/themes.js +102 -0
  53. package/lib/trial.js +116 -0
  54. package/lib/types/accelerate.d.ts +85 -0
  55. package/lib/types/agents.d.ts +22 -0
  56. package/lib/types/backup.d.ts +131 -0
  57. package/lib/types/catalog-local-match.d.ts +50 -0
  58. package/lib/types/catalog-npm.d.ts +47 -0
  59. package/lib/types/changelog.d.ts +102 -0
  60. package/lib/types/channels.d.ts +56 -0
  61. package/lib/types/check.d.ts +266 -0
  62. package/lib/types/compatibility.d.ts +64 -0
  63. package/lib/types/diagnostics.d.ts +29 -0
  64. package/lib/types/discovery-compatibility.d.ts +80 -0
  65. package/lib/types/dsh-cli.d.ts +324 -0
  66. package/lib/types/dsh-install.d.ts +46 -0
  67. package/lib/types/gist.d.ts +53 -0
  68. package/lib/types/groups.d.ts +34 -0
  69. package/lib/types/home-paths.d.ts +16 -0
  70. package/lib/types/hot.d.ts +221 -0
  71. package/lib/types/http.d.ts +12 -0
  72. package/lib/types/index.d.ts +14 -0
  73. package/lib/types/install.d.ts +172 -0
  74. package/lib/types/log.d.ts +43 -0
  75. package/lib/types/ndjson.d.ts +52 -0
  76. package/lib/types/net.d.ts +58 -0
  77. package/lib/types/order.d.ts +100 -0
  78. package/lib/types/patch.d.ts +129 -0
  79. package/lib/types/pnpm-compat.d.ts +93 -0
  80. package/lib/types/presets.d.ts +90 -0
  81. package/lib/types/profile.d.ts +253 -0
  82. package/lib/types/region-probe.d.ts +50 -0
  83. package/lib/types/regions.d.ts +122 -0
  84. package/lib/types/registry.d.ts +86 -0
  85. package/lib/types/restart.d.ts +194 -0
  86. package/lib/types/routes.d.ts +67 -0
  87. package/lib/types/settings.d.ts +78 -0
  88. package/lib/types/skill-market/api.d.ts +10 -0
  89. package/lib/types/skill-market/config.d.ts +3 -0
  90. package/lib/types/skill-market/installer.d.ts +23 -0
  91. package/lib/types/skill-market/runtime.d.ts +10 -0
  92. package/lib/types/skill-market/tui.d.ts +4 -0
  93. package/lib/types/skill-market/types.d.ts +71 -0
  94. package/lib/types/skill-market/web-routes.d.ts +19 -0
  95. package/lib/types/snapshot.d.ts +93 -0
  96. package/lib/types/source-migration.d.ts +11 -0
  97. package/lib/types/sources.d.ts +216 -0
  98. package/lib/types/store.d.ts +30 -0
  99. package/lib/types/themes.d.ts +40 -0
  100. package/lib/types/trial.d.ts +61 -0
  101. package/lib/types/update-api-v1.d.ts +66 -0
  102. package/lib/types/updates.d.ts +123 -0
  103. package/lib/types/verify.d.ts +139 -0
  104. package/lib/update-api-v1.js +215 -0
  105. package/lib/updates.js +361 -0
  106. package/lib/verify.js +453 -0
  107. package/package.json +121 -0
  108. package/src/accelerate.ts +213 -0
  109. package/src/agents.ts +43 -0
  110. package/src/backup.ts +583 -0
  111. package/src/catalog-local-match.ts +144 -0
  112. package/src/catalog-npm.ts +120 -0
  113. package/src/changelog.ts +282 -0
  114. package/src/channels.ts +70 -0
  115. package/src/check.ts +1239 -0
  116. package/src/client/CommentsModal.tsx +119 -0
  117. package/src/client/Diagnostics.tsx +907 -0
  118. package/src/client/ErrorBoundary.tsx +111 -0
  119. package/src/client/InstallToast.tsx +31 -0
  120. package/src/client/Market.module.css +830 -0
  121. package/src/client/MarketSection.tsx +5301 -0
  122. package/src/client/OperationsPanel.tsx +365 -0
  123. package/src/client/SettingsCard.tsx +621 -0
  124. package/src/client/SkillMarket.module.css +21 -0
  125. package/src/client/SkillMarketSection.tsx +162 -0
  126. package/src/client/comments.ts +54 -0
  127. package/src/client/globals.d.ts +13 -0
  128. package/src/client/index.ts +180 -0
  129. package/src/client/locales.ts +1112 -0
  130. package/src/client/market-data.ts +1321 -0
  131. package/src/client/operations.ts +201 -0
  132. package/src/client/preset-panel.tsx +263 -0
  133. package/src/client/primitives.d.ts +140 -0
  134. package/src/client/self-check.ts +147 -0
  135. package/src/client/snapshot-panel.tsx +244 -0
  136. package/src/compatibility.ts +237 -0
  137. package/src/diagnostics.ts +84 -0
  138. package/src/discovery-compatibility.ts +315 -0
  139. package/src/dsh-cli.ts +1126 -0
  140. package/src/dsh-install.ts +118 -0
  141. package/src/gist.ts +362 -0
  142. package/src/groups.ts +111 -0
  143. package/src/home-paths.ts +53 -0
  144. package/src/hot.ts +628 -0
  145. package/src/http.ts +41 -0
  146. package/src/index.ts +128 -0
  147. package/src/install.ts +420 -0
  148. package/src/log.ts +206 -0
  149. package/src/ndjson.ts +185 -0
  150. package/src/net.ts +106 -0
  151. package/src/order.ts +303 -0
  152. package/src/patch.ts +522 -0
  153. package/src/pnpm-compat.ts +527 -0
  154. package/src/presets.ts +344 -0
  155. package/src/profile.ts +940 -0
  156. package/src/region-probe.ts +97 -0
  157. package/src/regions.ts +310 -0
  158. package/src/registry.ts +250 -0
  159. package/src/restart.ts +396 -0
  160. package/src/routes.ts +4506 -0
  161. package/src/settings.ts +141 -0
  162. package/src/skill-market/api.ts +130 -0
  163. package/src/skill-market/config.ts +32 -0
  164. package/src/skill-market/installer.ts +337 -0
  165. package/src/skill-market/runtime.ts +14 -0
  166. package/src/skill-market/tui.ts +157 -0
  167. package/src/skill-market/types.ts +80 -0
  168. package/src/skill-market/web-routes.ts +132 -0
  169. package/src/snapshot.ts +532 -0
  170. package/src/source-migration.ts +61 -0
  171. package/src/sources.ts +565 -0
  172. package/src/store.ts +89 -0
  173. package/src/themes.ts +125 -0
  174. package/src/trial.ts +156 -0
  175. package/src/update-api-v1.ts +277 -0
  176. package/src/updates.ts +400 -0
  177. 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
+ }
@@ -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
+ }
@@ -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
+ }