@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
package/lib/ndjson.js ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * pnpm `--reporter=ndjson` progress parser (P1-6).
3
+ *
4
+ * pnpm's ndjson reporter is a bole stream on stdout: one JSON object per
5
+ * line, e.g.
6
+ *
7
+ * {"time":...,"level":"debug","name":"pnpm:stage","prefix":"...","stage":"resolution_started"}
8
+ * {"time":...,"level":"debug","name":"pnpm:progress","packageId":"...","status":"resolved"}
9
+ * {"time":...,"level":"debug","name":"pnpm:fetching-progress","packageId":"...","status":"started","size":123}
10
+ * {"time":...,"level":"debug","name":"pnpm:ignored-scripts","packageNames":["esbuild"]}
11
+ * {"time":...,"level":"error","name":"pnpm","err":{"message":"..."}}
12
+ *
13
+ * Verified against real pnpm 11.16.0 output (2026-08). Older pnpm majors
14
+ * emit a different shape or nothing at all — callers fall back to human
15
+ * line parsing when `seen` stays false.
16
+ *
17
+ * The reducer is pure and unit-testable: `feed` mutates the tracker's
18
+ * internal snapshot, `snapshot` returns a serializable copy.
19
+ */
20
+ export function emptyProgress() {
21
+ return {
22
+ phase: null,
23
+ done: 0,
24
+ total: null,
25
+ currentPackage: null,
26
+ downloaded: null,
27
+ size: null,
28
+ seen: false,
29
+ error: null,
30
+ errorCode: null,
31
+ ignoredBuilds: [],
32
+ };
33
+ }
34
+ export function createProgressTracker() {
35
+ const snap = emptyProgress();
36
+ const seenPackages = new Set();
37
+ function dedupe(packageId) {
38
+ if (typeof packageId !== 'string' || packageId === '')
39
+ return;
40
+ if (!seenPackages.has(packageId)) {
41
+ seenPackages.add(packageId);
42
+ snap.done += 1;
43
+ }
44
+ }
45
+ function feed(line) {
46
+ let event;
47
+ try {
48
+ event = JSON.parse(line);
49
+ }
50
+ catch {
51
+ return; // human fallback lines are handled by the caller
52
+ }
53
+ if (typeof event !== 'object' || event === null)
54
+ return;
55
+ const msg = event;
56
+ const name = msg.name;
57
+ if (typeof name !== 'string')
58
+ return;
59
+ if (name === 'pnpm:stage') {
60
+ const stage = msg.stage;
61
+ if (stage === 'resolution_started')
62
+ snap.phase = 'resolving';
63
+ else if (stage === 'resolution_done')
64
+ snap.phase = 'downloading';
65
+ else if (stage === 'importing_started' || stage === 'importing_done')
66
+ snap.phase = 'linking';
67
+ snap.seen = true;
68
+ return;
69
+ }
70
+ if (name === 'pnpm:progress') {
71
+ snap.seen = true;
72
+ const status = msg.status;
73
+ if (status === 'resolved') {
74
+ if (snap.phase === null)
75
+ snap.phase = 'resolving';
76
+ dedupe(msg.packageId);
77
+ }
78
+ else if (status === 'fetched' || status === 'found_in_store') {
79
+ snap.phase = 'downloading';
80
+ snap.currentPackage = typeof msg.packageId === 'string' ? msg.packageId : snap.currentPackage;
81
+ dedupe(msg.packageId);
82
+ }
83
+ return;
84
+ }
85
+ if (name === 'pnpm:fetching-progress') {
86
+ snap.seen = true;
87
+ snap.phase = 'downloading';
88
+ if (typeof msg.packageId === 'string')
89
+ snap.currentPackage = msg.packageId;
90
+ if (typeof msg.size === 'number')
91
+ snap.size = msg.size;
92
+ if (typeof msg.downloaded === 'number')
93
+ snap.downloaded = msg.downloaded;
94
+ dedupe(msg.packageId);
95
+ return;
96
+ }
97
+ if (name === 'pnpm:lifecycle') {
98
+ snap.seen = true;
99
+ snap.phase = 'building';
100
+ const wd = typeof msg.wd === 'string' ? msg.wd : '';
101
+ const dep = typeof msg.depPath === 'string' ? msg.depPath : '';
102
+ const base = wd.split(/[\\/]/).filter(Boolean).pop();
103
+ snap.currentPackage = base ?? (dep !== '' ? dep : snap.currentPackage);
104
+ return;
105
+ }
106
+ if (name === 'pnpm:stats') {
107
+ // added/removed counts land after the import pass — still linking.
108
+ if (msg.added !== undefined || msg.removed !== undefined)
109
+ snap.phase = 'linking';
110
+ snap.seen = true;
111
+ return;
112
+ }
113
+ if (name === 'pnpm:ignored-scripts') {
114
+ snap.seen = true;
115
+ if (Array.isArray(msg.packageNames)) {
116
+ for (const pkg of msg.packageNames) {
117
+ // pnpm's ndjson event reports version-qualified names (cloudflared@0.7.3);
118
+ // the approve-builds allowlist keys and node_modules lookups use bare
119
+ // package names, so strip the suffix (same rule as the human-line
120
+ // fallback in install.ts's parseIgnoredBuilds).
121
+ const at = typeof pkg === 'string' ? pkg.lastIndexOf('@') : -1;
122
+ const bare = at > 0 ? pkg.slice(0, at) : pkg;
123
+ if (typeof bare === 'string' && bare !== '' && !snap.ignoredBuilds.includes(bare))
124
+ snap.ignoredBuilds.push(bare);
125
+ }
126
+ }
127
+ return;
128
+ }
129
+ if (name === 'pnpm' && msg.level === 'error') {
130
+ const err = (msg.err ?? {});
131
+ const message = typeof err.message === 'string' ? err.message : '';
132
+ // Keep more than the old 400: ERR_PNPM_UNEXPECTED_STORE spends most of
133
+ // its message naming two absolute store paths, and truncating them is
134
+ // exactly the information a user needs to fix it by hand (#244).
135
+ if (message !== '')
136
+ snap.error = message.slice(0, 2000);
137
+ if (typeof err.code === 'string' && err.code !== '')
138
+ snap.errorCode = err.code;
139
+ return;
140
+ }
141
+ }
142
+ function reset() {
143
+ seenPackages.clear();
144
+ const fresh = emptyProgress();
145
+ Object.assign(snap, fresh);
146
+ }
147
+ return {
148
+ get snapshot() {
149
+ return { ...snap, ignoredBuilds: [...snap.ignoredBuilds] };
150
+ },
151
+ feed,
152
+ reset,
153
+ };
154
+ }
package/lib/net.js ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Outbound HTTP for the market's own server-side calls.
3
+ *
4
+ * Node's global `fetch` ignores `HTTP_PROXY` / `HTTPS_PROXY` entirely
5
+ * (measured on Node 25: a request with an unreachable proxy configured still
6
+ * succeeds directly, and setting `NODE_USE_ENV_PROXY` at runtime changes
7
+ * nothing — it is read at startup). On a machine whose route out is a local
8
+ * proxy, that is not a slowdown but a different network: the catalog fetch
9
+ * took 9.9s direct on a reporter's machine, seconds from the 15s timeout,
10
+ * while their proxy sat unused a millisecond away.
11
+ *
12
+ * `setGlobalDispatcher` from the `undici` PACKAGE cannot fix this, because
13
+ * `globalThis.fetch` runs on Node's INTERNAL copy of undici — a different
14
+ * instance. Verified: with a dispatcher installed, a global fetch still
15
+ * produced no CONNECT at a local proxy, while undici's own fetch produced
16
+ * `CONNECT awesome-dsh-plugin.com:443`.
17
+ *
18
+ * So the market calls undici's fetch with an explicit dispatcher. The scope
19
+ * is deliberate: only requests made by this module change, and the host's
20
+ * own networking is left exactly as the host configured it.
21
+ */
22
+ import { EnvHttpProxyAgent, fetch as undiciFetch } from 'undici';
23
+ /**
24
+ * The proxy this process would use for the catalog, if any.
25
+ *
26
+ * The standard variables mirror `EnvHttpProxyAgent`'s own resolution
27
+ * deliberately, rather than picking the order that reads best, because the
28
+ * same answer does two jobs: it decides whether to route through undici at
29
+ * all, and it is what the failure message CLAIMS was tried. A helper that
30
+ * named a proxy undici would not have used would put a false statement in
31
+ * every bug report. `npm_config_*` is an additional source on top of that:
32
+ * npm holds its proxy in its own config namespace (a machine set up with
33
+ * `npm config set proxy` has the proxy in `npm_config_proxy` and nowhere
34
+ * else), and undici does not read it — so it is resolved here and handed to
35
+ * the agent explicitly in `marketFetch`.
36
+ *
37
+ * Three details are undici's, not ours (env-http-proxy-agent.js):
38
+ * - lowercase wins over uppercase (`https_proxy ?? HTTPS_PROXY`)
39
+ * - an https request falls back to the http proxy when no https one is set
40
+ * - the value is tested for truthiness, so `HTTPS_PROXY=` — which is how
41
+ * people turn a proxy off — falls through instead of masking HTTP_PROXY
42
+ *
43
+ * Blank-is-unset is ours, and only widens that last one: undici would hand a
44
+ * whitespace-only value to `new URL()` and throw out of the constructor.
45
+ * Scheme-less host:port values are also common in Windows proxy fields and
46
+ * npm config; proxy agents require URLs, so those default to `http://`.
47
+ */
48
+ export function configuredProxy() {
49
+ const { http, https } = proxyFromEnv();
50
+ return https ?? http;
51
+ }
52
+ /**
53
+ * Proxy URLs resolved from the process environment, standard variables
54
+ * first and npm's own config (`npm_config_https_proxy` / `npm_config_proxy`)
55
+ * as the fallback. npm's config is the second source, not a preference: a
56
+ * standard variable always wins over npm's, and npm's https proxy falls
57
+ * back to npm's http proxy the same way undici's does.
58
+ */
59
+ function proxyFromEnv() {
60
+ const pick = (raw) => {
61
+ const value = raw?.trim();
62
+ if (value === undefined || value === '')
63
+ return null;
64
+ return /^[a-z][a-z\d+.-]*:\/\//iu.test(value) ? value : `http://${value}`;
65
+ };
66
+ const https = pick(process.env.https_proxy ?? process.env.HTTPS_PROXY) ??
67
+ pick(process.env.npm_config_https_proxy);
68
+ const http = pick(process.env.http_proxy ?? process.env.HTTP_PROXY) ??
69
+ pick(process.env.npm_config_proxy);
70
+ return { http, https };
71
+ }
72
+ /**
73
+ * Built once and reused: an agent per request would drop connection reuse,
74
+ * and this one reads NO_PROXY as well, so a host that excludes its own
75
+ * registry mirror keeps being excluded.
76
+ */
77
+ let agent = null;
78
+ /**
79
+ * Fetch through the proxy this machine is configured to use.
80
+ *
81
+ * Falls back to the global fetch when no proxy is set, which keeps the
82
+ * ordinary case on the runtime's own path rather than routing it through a
83
+ * second HTTP stack for no reason.
84
+ */
85
+ export async function marketFetch(url, init) {
86
+ const { http, https } = proxyFromEnv();
87
+ if (http === null && https === null)
88
+ return await fetch(url, init);
89
+ // Pass the resolved proxies explicitly. EnvHttpProxyAgent itself reads
90
+ // only http(s)_proxy out of the environment, so a proxy that lives in
91
+ // npm_config_* must be handed over directly — otherwise the agent would
92
+ // silently go direct while configuredProxy() claims a proxy was used.
93
+ agent ??= new EnvHttpProxyAgent({
94
+ httpProxy: http ?? undefined,
95
+ httpsProxy: https ?? undefined,
96
+ });
97
+ return await undiciFetch(url, { ...init, dispatcher: agent });
98
+ }
package/lib/order.js ADDED
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Community bundle ordering — issue #98 (phase 2): let the user reorder the
3
+ * community bundles of the profile's layer stack, with author-declared
4
+ * before/after rules enforced before anything is written.
5
+ *
6
+ * Official in-box bundles (@deepseek-ai/dsh-base, @deepseek-ai/dsh-web-app,
7
+ * @deepseek-ai/dsh-headless) are fixed: they keep their exact positions in
8
+ * the stack, are never part of a user-supplied order, and are never added,
9
+ * removed or duplicated by a reorder (#98 boundary). The profile's own
10
+ * cordis.patch.yml and --patch overlays are not part of the bundle stack and
11
+ * are never touched here.
12
+ *
13
+ * Pure functions plus one manifest write-back; no processes, no network.
14
+ */
15
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
16
+ import { createRequire } from 'node:module';
17
+ import { join } from 'node:path';
18
+ import { findDshInstallDir } from './dsh-install.js';
19
+ /** Profile bundles that ship with the dsh host and must stay put (#98). */
20
+ export const INBOX_BUNDLES = new Set([
21
+ '@deepseek-ai/dsh-base',
22
+ '@deepseek-ai/dsh-web-app',
23
+ '@deepseek-ai/dsh-headless',
24
+ ]);
25
+ /**
26
+ * Atomic same-directory replace (write temp + rename): a crash mid-write can
27
+ * never leave the profile manifest truncated, which would break every later
28
+ * pnpm run. Used for every package.json write this module makes.
29
+ */
30
+ function writeFileAtomic(file, content) {
31
+ const temp = `${file}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
32
+ writeFileSync(temp, content);
33
+ renameSync(temp, file);
34
+ }
35
+ /** Read the profile's bundle stack (empty when the manifest is unreadable). */
36
+ export function readBundleStack(profileDir) {
37
+ try {
38
+ const manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'));
39
+ const bundles = Array.isArray(manifest.dsh?.profile?.bundles)
40
+ ? manifest.dsh.profile.bundles.filter((name) => typeof name === 'string')
41
+ : [];
42
+ return {
43
+ bundles,
44
+ community: bundles.filter(name => !INBOX_BUNDLES.has(name)),
45
+ };
46
+ }
47
+ catch {
48
+ return { bundles: [], community: [] };
49
+ }
50
+ }
51
+ /**
52
+ * The bundle package.json, resolved the way the dsh boot resolves bundles
53
+ * (dsh-app-boot's resolveBundleDir, mirrored by check.ts): the dsh
54
+ * installation anchor first — official in-box bundles live in the install's
55
+ * node_modules, never the profile's — then the profile directory, whose
56
+ * createRequire search paths also cover pnpm workspace-root hoisting
57
+ * (`<profiles>/node_modules` when the profile lives under `<profiles>/<name>`).
58
+ */
59
+ function resolveBundlePackageJson(profileDir, name) {
60
+ const dshInstall = findDshInstallDir();
61
+ const anchors = [
62
+ dshInstall !== null ? join(dshInstall, 'package.json') : null,
63
+ join(profileDir, 'package.json'),
64
+ ];
65
+ for (const anchor of anchors) {
66
+ if (anchor === null)
67
+ continue;
68
+ let paths = [];
69
+ try {
70
+ paths = createRequire(anchor).resolve.paths(name) ?? [];
71
+ }
72
+ catch {
73
+ continue;
74
+ }
75
+ for (const searchPath of paths) {
76
+ const candidate = join(searchPath, name);
77
+ if (existsSync(join(candidate, 'package.json')))
78
+ return join(candidate, 'package.json');
79
+ }
80
+ }
81
+ return null;
82
+ }
83
+ /**
84
+ * Read each bundle's declared ordering rules from its package manifest
85
+ * (`dsh.bundle.order.{before,after}` — a list of bundle package names).
86
+ * Unresolvable packages and missing declarations contribute nothing.
87
+ */
88
+ export function readBundleRules(profileDir) {
89
+ const { bundles } = readBundleStack(profileDir);
90
+ const rules = [];
91
+ for (const name of bundles) {
92
+ const packageJson = resolveBundlePackageJson(profileDir, name);
93
+ if (packageJson === null)
94
+ continue;
95
+ try {
96
+ const manifest = JSON.parse(readFileSync(packageJson, 'utf8'));
97
+ const order = manifest.dsh?.bundle?.order;
98
+ if (order === null || typeof order !== 'object' || Array.isArray(order))
99
+ continue;
100
+ const listOf = (value) => Array.isArray(value)
101
+ ? value.filter((item) => typeof item === 'string')
102
+ : [];
103
+ const rule = {
104
+ name,
105
+ after: listOf(order.after),
106
+ before: listOf(order.before),
107
+ };
108
+ if (rule.after.length > 0 || rule.before.length > 0)
109
+ rules.push(rule);
110
+ }
111
+ catch { /* package unreadable — no rule */ }
112
+ }
113
+ return rules;
114
+ }
115
+ /**
116
+ * Check a bundle order against the declared before/after rules. Rules naming
117
+ * bundles outside `order` are ignored (a rule for a not-yet-installed bundle
118
+ * must not block the current stack).
119
+ * @returns every violated rule with a readable reason; [] when all hold.
120
+ */
121
+ export function validateOrder(bundleNames, rules) {
122
+ const position = new Map(bundleNames.map((name, index) => [name, index]));
123
+ const conflicts = [];
124
+ for (const rule of rules) {
125
+ const pos = position.get(rule.name);
126
+ if (pos === undefined)
127
+ continue;
128
+ for (const other of rule.after) {
129
+ const otherPos = position.get(other);
130
+ if (otherPos === undefined)
131
+ continue;
132
+ if (otherPos >= pos) {
133
+ conflicts.push({
134
+ name: rule.name,
135
+ reason: `must load after ${other}, but ${other} is currently before/equal (position ${otherPos} vs ${pos})`,
136
+ });
137
+ }
138
+ }
139
+ for (const other of rule.before) {
140
+ const otherPos = position.get(other);
141
+ if (otherPos === undefined)
142
+ continue;
143
+ if (otherPos <= pos) {
144
+ conflicts.push({
145
+ name: rule.name,
146
+ reason: `must load before ${other}, but ${other} is currently after/equal (position ${otherPos} vs ${pos})`,
147
+ });
148
+ }
149
+ }
150
+ }
151
+ return conflicts;
152
+ }
153
+ /**
154
+ * Merge a community-bundle permutation into the full stack. Official in-box
155
+ * bundles keep their EXACT positions (never moved); community bundles are
156
+ * replaced by `newOrder` in order of appearance. Pure — nothing is written.
157
+ * @returns the merged full stack, or the rejection reason when `newOrder` is
158
+ * not a permutation of the community bundles (duplicates, additions,
159
+ * omissions, official names).
160
+ */
161
+ export function mergeOrder(bundles, newOrder) {
162
+ const communitySet = new Set(bundles.filter(name => !INBOX_BUNDLES.has(name)));
163
+ if (new Set(newOrder).size !== newOrder.length) {
164
+ return { ok: false, error: 'duplicate bundle names in the new order / 新顺序包含重复的 bundle' };
165
+ }
166
+ if (newOrder.length !== communitySet.size) {
167
+ return { ok: false, error: 'the new order must contain exactly the current community bundles / 新顺序必须恰好包含全部社区 bundle' };
168
+ }
169
+ for (const name of newOrder) {
170
+ if (!communitySet.has(name)) {
171
+ return { ok: false, error: `${name} is not a reorderable community bundle / ${name} 不是可排序的社区 bundle` };
172
+ }
173
+ }
174
+ const merged = [...bundles];
175
+ let cursor = 0;
176
+ for (let index = 0; index < merged.length; index += 1) {
177
+ const name = merged[index];
178
+ if (name === undefined || INBOX_BUNDLES.has(name))
179
+ continue;
180
+ merged[index] = newOrder[cursor];
181
+ cursor += 1;
182
+ }
183
+ return { ok: true, bundles: merged };
184
+ }
185
+ /**
186
+ * Topologically sort the community bundles by their before/after rules — the
187
+ * "auto-fix" counterpart to validateOrder. Returns null when no declared rule
188
+ * applies to the current stack (nothing to suggest). With rules, Kahn's
189
+ * algorithm breaks ties by the CURRENT order: unconstrained bundles keep
190
+ * their current relative order and constrained bundles move only as far as
191
+ * the rules require — the suggestion is the minimal change that satisfies
192
+ * every rule, never an arbitrary canonical rewrite of a hand-picked order
193
+ * (issue #125 review).
194
+ * @returns the suggested community order, null when there are no rules, or a
195
+ * cycle report when the constraints cannot be satisfied (references to
196
+ * unlisted bundles ignored).
197
+ */
198
+ export function suggestOrder(bundleNames, rules) {
199
+ const names = bundleNames.filter(name => !INBOX_BUNDLES.has(name));
200
+ const inOrder = new Set(names);
201
+ const active = rules.filter(rule => inOrder.has(rule.name));
202
+ // No rule applies to the current stack — nothing to suggest.
203
+ if (active.length === 0)
204
+ return null;
205
+ const position = new Map(names.map((name, index) => [name, index]));
206
+ // Constraint: "a must load before b" (from a.before or b.after) → edge a → b.
207
+ const beforeOf = new Map(); // name → bundles that must come after it
208
+ const deps = new Map(); // name → bundles that must come before it
209
+ for (const name of names) {
210
+ beforeOf.set(name, new Set());
211
+ deps.set(name, new Set());
212
+ }
213
+ const addEdge = (a, b) => {
214
+ if (!inOrder.has(a) || !inOrder.has(b) || a === b)
215
+ return;
216
+ beforeOf.get(a)?.add(b);
217
+ deps.get(b)?.add(a);
218
+ };
219
+ for (const rule of active) {
220
+ for (const other of rule.before)
221
+ addEdge(rule.name, other);
222
+ for (const other of rule.after)
223
+ addEdge(other, rule.name);
224
+ }
225
+ const remaining = new Map();
226
+ for (const [name, depsOf] of deps)
227
+ remaining.set(name, new Set(depsOf));
228
+ const ready = names.filter(name => (remaining.get(name)?.size ?? 0) === 0);
229
+ const ordered = [];
230
+ while (ready.length > 0) {
231
+ // Minimal-change tie-break: among the ready bundles, prefer the one that
232
+ // comes FIRST in the current order. Bundles the rules do not constrain
233
+ // therefore keep their current relative order; constrained bundles move
234
+ // only as far as the rules require (issue #125 review).
235
+ let best = 0;
236
+ for (let i = 1; i < ready.length; i += 1) {
237
+ const a = ready[i];
238
+ const b = ready[best];
239
+ if (a !== undefined && b !== undefined && (position.get(a) ?? 0) < (position.get(b) ?? 0))
240
+ best = i;
241
+ }
242
+ const name = ready.splice(best, 1)[0];
243
+ if (name === undefined)
244
+ break;
245
+ ordered.push(name);
246
+ for (const dependent of beforeOf.get(name) ?? []) {
247
+ const depsOf = remaining.get(dependent);
248
+ if (depsOf === undefined)
249
+ continue;
250
+ depsOf.delete(name);
251
+ if (depsOf.size === 0 && !ordered.includes(dependent) && !ready.includes(dependent))
252
+ ready.push(dependent);
253
+ }
254
+ }
255
+ if (ordered.length < names.length) {
256
+ return { ok: false, cycle: names.filter(name => !ordered.includes(name)) };
257
+ }
258
+ return { ok: true, order: ordered };
259
+ }
260
+ /**
261
+ * Apply a new community-bundle order to the profile manifest. The official
262
+ * in-box bundles keep their exact positions; `newOrder` must be a permutation
263
+ * of the current community bundles (no duplicates, no additions, no
264
+ * omissions). On any failure the manifest is left untouched.
265
+ * @returns the new full stack on success, or an error description.
266
+ */
267
+ export function applyBundleOrder(profileDir, newOrder) {
268
+ const { bundles } = readBundleStack(profileDir);
269
+ const merged = mergeOrder(bundles, newOrder);
270
+ if (!merged.ok)
271
+ return merged;
272
+ try {
273
+ const manifestPath = join(profileDir, 'package.json');
274
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
275
+ manifest.dsh ??= {};
276
+ manifest.dsh.profile ??= {};
277
+ manifest.dsh.profile.bundles = merged.bundles;
278
+ writeFileAtomic(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
279
+ return merged;
280
+ }
281
+ catch (error) {
282
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
283
+ }
284
+ }