@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/backup.js ADDED
@@ -0,0 +1,572 @@
1
+ /**
2
+ * Portable profile backups: configuration only, never installed packages.
3
+ *
4
+ * The profile directory is plain user data — aside from package.json it can
5
+ * hold API keys (config.toml), tokens, or the WebDAV password when stored
6
+ * server-side. Backups therefore behave like `dsh export` and carry the same
7
+ * credential-warning disclaimer in the UI (review #63).
8
+ */
9
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
10
+ import { lookup } from 'node:dns/promises';
11
+ import { request as httpsRequest } from 'node:https';
12
+ import { isIP } from 'node:net';
13
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
14
+ import { profileDir } from './profile.js';
15
+ export const BACKUP_FORMAT = 'dsh-profile-backup';
16
+ export const MAX_BACKUP_BYTES = 2 * 1024 * 1024;
17
+ const MAX_FILES = 256;
18
+ const SKIP_NAMES = new Set(['node_modules', '.dsh-market', '.git', 'pnpm-lock.yaml']);
19
+ /** File names that routinely contain credentials (backup exports).
20
+ * Values are never masked in place — the export is one-to-one for
21
+ * faithful restores — but presence is surfaced by the UI warning. */
22
+ export const SECRET_FILE_HINTS = /(^|\/)(config\.toml|\.env(\.\w+)?|secrets?\.\w+t?j?s?o?n|pnpm-workspace\.yaml)$/i;
23
+ /** Count of exported files whose names look like they carry credentials. */
24
+ export function secretFileCount(profile, explicitDir) {
25
+ let count = 0;
26
+ for (const path of profileFiles(explicitDir ?? profileDir(profile))) {
27
+ if (SECRET_FILE_HINTS.test(path))
28
+ count += 1;
29
+ }
30
+ return count;
31
+ }
32
+ function profileFiles(root, dir = root) {
33
+ const files = [];
34
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
35
+ // Any .bak marker, not just the numeric-suffixed one this codebase
36
+ // writes. Recovery and the host's own repair paths leave other shapes —
37
+ // `package.json.bak-asm`, `cordis.patch.yml.rp-merged.bak` — and a
38
+ // backup that carried them restored them too, so the profile came back
39
+ // with the wreckage that made it need repairing (#205 by @Rudyy898).
40
+ if (SKIP_NAMES.has(entry.name) || /\.bak\b/.test(entry.name))
41
+ continue;
42
+ const path = resolve(dir, entry.name);
43
+ if (entry.isSymbolicLink())
44
+ continue;
45
+ if (entry.isDirectory())
46
+ files.push(...profileFiles(root, path));
47
+ else if (entry.isFile())
48
+ files.push(relative(root, path).split(sep).join('/'));
49
+ if (files.length > MAX_FILES)
50
+ throw new Error(`profile has more than ${MAX_FILES} configuration files`);
51
+ }
52
+ return files;
53
+ }
54
+ /**
55
+ * Serialize every profile file except dependencies, lock state, and market
56
+ * cache — or, with {@link BackupOptions.includeDeps}, only the manifest with
57
+ * the selected plugins (plus, optionally, the other config files).
58
+ */
59
+ export function createProfileBackup(profile, explicitDir, opts) {
60
+ const root = resolve(explicitDir ?? profileDir(profile));
61
+ const manifestFile = resolve(root, 'package.json');
62
+ if (!existsSync(manifestFile))
63
+ throw new Error('profile package.json is missing');
64
+ const manifest = JSON.parse(readFileSync(manifestFile, 'utf8'));
65
+ if (opts?.includeDeps !== undefined) {
66
+ const include = new Set(opts.includeDeps);
67
+ if (include.size === 0)
68
+ throw new Error('no plugins selected');
69
+ const dependencies = manifest.dependencies === null || typeof manifest.dependencies !== 'object' || Array.isArray(manifest.dependencies)
70
+ ? {}
71
+ : manifest.dependencies;
72
+ const filteredDeps = {};
73
+ for (const [name, spec] of Object.entries(dependencies))
74
+ if (include.has(name))
75
+ filteredDeps[name] = spec;
76
+ const dsh = manifest.dsh === null || typeof manifest.dsh !== 'object' || Array.isArray(manifest.dsh)
77
+ ? undefined
78
+ : manifest.dsh;
79
+ const profileBlock = dsh?.profile === null || typeof dsh?.profile !== 'object' || Array.isArray(dsh?.profile)
80
+ ? undefined
81
+ : dsh.profile;
82
+ const bundles = Array.isArray(profileBlock?.bundles) ? profileBlock.bundles : [];
83
+ const filteredBundles = bundles.filter((name) => typeof name === 'string' && include.has(name));
84
+ if (Object.keys(filteredDeps).length === 0 && filteredBundles.length === 0) {
85
+ throw new Error('none of the selected plugins are in this profile');
86
+ }
87
+ const filteredManifest = { ...manifest };
88
+ filteredManifest.dependencies = filteredDeps;
89
+ if (dsh !== undefined) {
90
+ filteredManifest.dsh = { ...dsh, profile: { ...(profileBlock ?? {}), bundles: filteredBundles } };
91
+ }
92
+ const files = [{ path: 'package.json', json: filteredManifest }];
93
+ if (opts.includeConfig === true) {
94
+ for (const path of profileFiles(root).sort()) {
95
+ if (path === 'package.json')
96
+ continue;
97
+ files.push({ path, lines: readFileSync(resolve(root, path), 'utf8').split(/\r?\n/) });
98
+ }
99
+ }
100
+ const partial = { format: BACKUP_FORMAT, version: 0.2, createdAt: new Date().toISOString(), profile, files };
101
+ if (Buffer.byteLength(JSON.stringify(partial)) > MAX_BACKUP_BYTES)
102
+ throw new Error('profile configuration is too large to back up');
103
+ return partial;
104
+ }
105
+ const files = profileFiles(root).sort().map((path) => {
106
+ const content = readFileSync(resolve(root, path), 'utf8');
107
+ return path === 'package.json'
108
+ ? { path, json: JSON.parse(content) }
109
+ : { path, lines: content.split(/\r?\n/) };
110
+ });
111
+ if (!files.some(file => file.path === 'package.json'))
112
+ throw new Error('profile package.json is missing');
113
+ const backup = { format: BACKUP_FORMAT, version: 0.2, createdAt: new Date().toISOString(), profile, files };
114
+ if (Buffer.byteLength(JSON.stringify(backup)) > MAX_BACKUP_BYTES)
115
+ throw new Error('profile configuration is too large to back up');
116
+ return backup;
117
+ }
118
+ export function validatedBackup(value) {
119
+ if (value === null || typeof value !== 'object')
120
+ throw new Error('invalid backup');
121
+ const backup = value;
122
+ if (backup.format !== BACKUP_FORMAT || backup.version !== 0.2 || !Array.isArray(backup.files)) {
123
+ throw new Error('unsupported backup format');
124
+ }
125
+ if (backup.files.length > MAX_FILES)
126
+ throw new Error('invalid backup contents');
127
+ const files = [];
128
+ const paths = new Set();
129
+ for (const value of backup.files) {
130
+ if (value === null || typeof value !== 'object')
131
+ throw new Error('invalid backup contents');
132
+ const file = value;
133
+ const path = file.path;
134
+ if (typeof path !== 'string')
135
+ throw new Error('invalid backup contents');
136
+ if (path === '' || isAbsolute(path) || path.split(/[\\/]/).includes('..'))
137
+ throw new Error(`unsafe backup path: ${path}`);
138
+ const normalized = path.replaceAll('\\', '/');
139
+ if (normalized.split('/').some(part => SKIP_NAMES.has(part)))
140
+ throw new Error(`excluded backup path: ${path}`);
141
+ if (paths.has(normalized))
142
+ throw new Error(`duplicate backup path: ${path}`);
143
+ paths.add(normalized);
144
+ if (path === 'package.json') {
145
+ if (file.json === null || typeof file.json !== 'object' || Array.isArray(file.json))
146
+ throw new Error('backup package.json is invalid');
147
+ files.push({ path, json: file.json });
148
+ }
149
+ else {
150
+ if (!Array.isArray(file.lines) || !file.lines.every(line => typeof line === 'string'))
151
+ throw new Error(`invalid file content: ${path}`);
152
+ files.push({ path, lines: file.lines });
153
+ }
154
+ }
155
+ if (!files.some(file => file.path === 'package.json'))
156
+ throw new Error('invalid backup contents');
157
+ if (Buffer.byteLength(JSON.stringify(backup)) > MAX_BACKUP_BYTES)
158
+ throw new Error('backup is too large');
159
+ return { ...backup, files };
160
+ }
161
+ /** Atomically overwrite backed-up files and return a rollback for install failure. */
162
+ export function restoreProfileBackup(profile, value, explicitDir) {
163
+ const backup = validatedBackup(value);
164
+ const root = resolve(explicitDir ?? profileDir(profile));
165
+ const previous = new Map();
166
+ mkdirSync(root, { recursive: true });
167
+ const rollback = () => {
168
+ for (const [target, content] of previous) {
169
+ if (content === null)
170
+ rmSync(target, { force: true });
171
+ else
172
+ writeFileSync(target, content);
173
+ }
174
+ };
175
+ try {
176
+ for (const file of backup.files) {
177
+ const { path } = file;
178
+ const target = resolve(root, path);
179
+ if (!target.startsWith(root + sep))
180
+ throw new Error(`unsafe backup path: ${path}`);
181
+ ensureSafeParent(root, dirname(target), path);
182
+ if (existsSync(target) && !lstatSync(target).isFile())
183
+ throw new Error(`backup path is not a file: ${path}`);
184
+ previous.set(target, existsSync(target) ? readFileSync(target) : null);
185
+ const temp = `${target}.dsh-restore-${String(process.pid)}`;
186
+ writeFileSync(temp, 'json' in file ? `${JSON.stringify(file.json, null, 2)}\n` : file.lines.join('\n'), 'utf8');
187
+ renameSync(temp, target);
188
+ }
189
+ }
190
+ catch (error) {
191
+ rollback();
192
+ throw error;
193
+ }
194
+ return {
195
+ files: previous.size,
196
+ rollback,
197
+ };
198
+ }
199
+ /** Create missing parents one level at a time and refuse existing symlinks. */
200
+ function ensureSafeParent(root, parent, backupPath) {
201
+ const relativeParent = relative(root, parent);
202
+ if (relativeParent === '')
203
+ return;
204
+ let current = root;
205
+ for (const part of relativeParent.split(sep)) {
206
+ current = resolve(current, part);
207
+ if (!existsSync(current)) {
208
+ mkdirSync(current);
209
+ continue;
210
+ }
211
+ const stat = lstatSync(current);
212
+ if (stat.isSymbolicLink() || !stat.isDirectory())
213
+ throw new Error(`unsafe backup path: ${backupPath}`);
214
+ }
215
+ }
216
+ async function webdavRequest(url, username, password, method, body) {
217
+ const parsed = new URL(url);
218
+ // SSRF hardening (review #63): https-only by default, and always refuse
219
+ // private/link-local targets — so a same-origin script cannot tunnel into
220
+ // the host network. Besides the obvious metadata surfaces (169.254.169.254),
221
+ // loopback matters because other services on this machine may expose HTTP
222
+ // APIs of their own.
223
+ if (parsed.protocol === 'http:')
224
+ throw new Error('WebDAV requires an https:// URL');
225
+ if (parsed.protocol !== 'https:')
226
+ throw new Error('invalid WebDAV URL');
227
+ if (parsed.username !== '' || parsed.password !== '')
228
+ throw new Error('invalid WebDAV URL');
229
+ const address = await resolvePublicAddress(parsed.hostname);
230
+ const headers = { host: parsed.host };
231
+ if (body !== undefined) {
232
+ headers['content-type'] = 'application/json';
233
+ headers['content-length'] = String(Buffer.byteLength(body));
234
+ }
235
+ if (username !== '')
236
+ headers.authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
237
+ // Connect to the address that was checked above instead of resolving the
238
+ // hostname a second time. This closes the DNS-rebinding window between the
239
+ // allow/deny decision and the actual socket connection.
240
+ return await new Promise((resolveRequest, reject) => {
241
+ const originalHostname = unbracketedHostname(parsed.hostname);
242
+ const request = httpsRequest({
243
+ protocol: 'https:',
244
+ hostname: address.address,
245
+ family: address.family,
246
+ port: parsed.port === '' ? 443 : Number(parsed.port),
247
+ path: `${parsed.pathname}${parsed.search}`,
248
+ method,
249
+ headers,
250
+ servername: isIP(originalHostname) === 0 ? originalHostname : undefined,
251
+ signal: AbortSignal.timeout(30_000),
252
+ }, (response) => {
253
+ const chunks = [];
254
+ let size = 0;
255
+ const maxBytes = method === 'GET' ? MAX_BACKUP_BYTES : 64 * 1024;
256
+ response.once('error', reject);
257
+ const declared = Number(response.headers['content-length']);
258
+ if (Number.isFinite(declared) && declared > maxBytes) {
259
+ response.destroy(new Error('WebDAV response is too large'));
260
+ return;
261
+ }
262
+ response.on('data', (chunk) => {
263
+ const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
264
+ size += value.byteLength;
265
+ if (size > maxBytes) {
266
+ response.destroy(new Error('WebDAV response is too large'));
267
+ return;
268
+ }
269
+ chunks.push(value);
270
+ });
271
+ response.once('end', () => resolveRequest({
272
+ status: response.statusCode ?? 0,
273
+ body: Buffer.concat(chunks),
274
+ ...(typeof response.headers.location === 'string' ? { location: response.headers.location } : {}),
275
+ }));
276
+ });
277
+ request.once('error', reject);
278
+ request.end(body);
279
+ });
280
+ }
281
+ /**
282
+ * Ancestor collection URLs of a WebDAV file, outermost first.
283
+ * `https://dav.example/a/b/x.json` → [`https://dav.example/a/`, `https://dav.example/a/b/`].
284
+ * The server root itself is never included — it always exists, and some
285
+ * providers reject MKCOL on it.
286
+ */
287
+ export function webdavParentCollections(url) {
288
+ let parsed;
289
+ try {
290
+ parsed = new URL(url);
291
+ }
292
+ catch {
293
+ return [];
294
+ }
295
+ const parts = parsed.pathname.split('/').filter(part => part !== '');
296
+ parts.pop(); // the file itself
297
+ const collections = [];
298
+ let path = '';
299
+ for (const part of parts) {
300
+ path += `/${part}`;
301
+ collections.push(`${parsed.origin}${path}/`);
302
+ }
303
+ return collections;
304
+ }
305
+ /**
306
+ * Upload the backup, creating missing parent collections first (#102).
307
+ *
308
+ * WebDAV servers do not create intermediate collections implicitly, so a PUT
309
+ * into a folder that does not exist yet fails — Jianguoyun answers 404, which
310
+ * read as "sync is broken" rather than "make the folder first". MKCOL on an
311
+ * existing collection answers 405, which is success for our purposes; any
312
+ * other failure is left to the PUT to report, since some providers restrict
313
+ * MKCOL while still accepting the upload.
314
+ */
315
+ export async function uploadWebdav(url, username, password, backup) {
316
+ for (const collection of webdavParentCollections(url)) {
317
+ try {
318
+ await webdavRequest(collection, username, password, 'MKCOL');
319
+ }
320
+ catch { /* fall through: the PUT below reports the real problem */ }
321
+ }
322
+ const response = await webdavRequest(url, username, password, 'PUT', JSON.stringify(backup));
323
+ if (response.status < 200 || response.status >= 300) {
324
+ throw new Error(response.status === 404
325
+ ? `WebDAV upload failed: HTTP 404 — the target folder does not exist and could not be created. Some providers (e.g. Jianguoyun) refuse files at the root: use a path inside a folder, e.g. https://dav.example.com/dsh/backup.json / 目标目录不存在且无法自动创建;部分服务商(如坚果云)不允许在根目录放文件,请使用形如 https://dav.example.com/dsh/backup.json 的子目录路径`
326
+ : `WebDAV upload failed: HTTP ${response.status}`);
327
+ }
328
+ }
329
+ /** Refuse non-global IPv4 targets, including metadata and carrier NAT ranges. */
330
+ export function isPublicIpv4(ip) {
331
+ const octets = ip.split('.').map(Number);
332
+ if (octets.length !== 4 || octets.some(part => !Number.isInteger(part) || part < 0 || part > 255))
333
+ return false;
334
+ const [a, b] = octets;
335
+ if (a === 0 || a === 10 || a === 127 || a >= 224)
336
+ return false;
337
+ if (a === 100 && b >= 64 && b <= 127)
338
+ return false;
339
+ if (a === 169 && b === 254)
340
+ return false;
341
+ if (a === 172 && b >= 16 && b <= 31)
342
+ return false;
343
+ if (a === 192 && (b === 0 || b === 168))
344
+ return false;
345
+ if (a === 198 && (b === 18 || b === 19))
346
+ return false;
347
+ return true;
348
+ }
349
+ /** Only public internet target hostnames are reachable for WebDAV. */
350
+ export function isPublicHostname(hostname) {
351
+ const lower = unbracketedHostname(hostname).toLowerCase();
352
+ const bare = lower.endsWith('.') ? lower.slice(0, -1) : lower;
353
+ return bare !== '' && bare !== 'localhost' && bare !== 'metadata.google.internal'
354
+ && !bare.endsWith('.localhost') && !bare.endsWith('.internal') && !bare.endsWith('.local');
355
+ }
356
+ /**
357
+ * Whether a WebDAV hostname may be fetched: public https targets only.
358
+ * Exported for tests.
359
+ */
360
+ export function isPublicTarget(hostname) {
361
+ const bare = unbracketedHostname(hostname);
362
+ const family = isIP(bare);
363
+ if (family === 4)
364
+ return isPublicIpv4(bare);
365
+ if (family === 6)
366
+ return isPublicIpv6(bare);
367
+ return isPublicHostname(bare);
368
+ }
369
+ /** Only global-unicast IPv6 is usable for a server-side WebDAV connection. */
370
+ export function isPublicIpv6(ip) {
371
+ const bare = unbracketedHostname(ip);
372
+ if (isIP(bare) !== 6)
373
+ return false;
374
+ const first = Number.parseInt(bare.split(':', 1)[0] || '0', 16);
375
+ return Number.isFinite(first) && first >= 0x2000 && first <= 0x3fff;
376
+ }
377
+ function unbracketedHostname(hostname) {
378
+ return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
379
+ }
380
+ /** Resolve once, reject every unsafe answer, and return the address to pin. */
381
+ export async function resolvePublicAddress(hostname) {
382
+ const bare = unbracketedHostname(hostname);
383
+ const family = isIP(bare);
384
+ if (family === 4 || family === 6) {
385
+ if (!isPublicTarget(bare))
386
+ throw new Error('invalid WebDAV URL');
387
+ return { address: bare, family };
388
+ }
389
+ if (!isPublicHostname(bare))
390
+ throw new Error('invalid WebDAV URL');
391
+ const addresses = await lookup(bare, { all: true, verbatim: true });
392
+ if (addresses.length === 0 || addresses.some(({ address }) => !isPublicTarget(address))) {
393
+ throw new Error('invalid WebDAV URL');
394
+ }
395
+ const selected = addresses[0];
396
+ if (selected.family !== 4 && selected.family !== 6)
397
+ throw new Error('invalid WebDAV URL');
398
+ return { address: selected.address, family: selected.family };
399
+ }
400
+ /**
401
+ * How many redirects a download will follow before giving up. Providers use
402
+ * one hop (WebDAV endpoint -> signed CDN link); five tolerates a CDN's own
403
+ * indirection without letting two servers ping-pong forever.
404
+ */
405
+ const MAX_REDIRECTS = 5;
406
+ export async function downloadWebdav(url, username, password) {
407
+ // Some providers answer a WebDAV GET with 302 to a time-limited signed CDN
408
+ // link while accepting PUT directly on the same path (#480 by @zhyx1996,
409
+ // measured against 123pan) — so upload worked and restore failed with
410
+ // "HTTP 302". Only GET follows: the upload path is direct on every
411
+ // provider seen, and replaying a PUT body across redirects is a can of
412
+ // worms nothing currently needs opened.
413
+ //
414
+ // Every hop goes back through webdavRequest, so the full SSRF gate —
415
+ // https-only, no credentials in the URL, resolvePublicAddress with the
416
+ // rebinding-safe direct-IP connect — is re-run per target, not only on the
417
+ // first one. A redirect is the server choosing the next URL; trusting it
418
+ // half as much as the user's own input would invert the threat model.
419
+ //
420
+ // Credentials do NOT cross origins. The signed link authorizes itself;
421
+ // forwarding Basic auth to whatever host the server named would hand the
422
+ // user's WebDAV password to a third party on the server's say-so.
423
+ let currentUrl = url;
424
+ let response = await webdavRequest(currentUrl, username, password, 'GET');
425
+ for (let hop = 0; hop < MAX_REDIRECTS; hop += 1) {
426
+ if (response.status !== 301 && response.status !== 302 && response.status !== 303
427
+ && response.status !== 307 && response.status !== 308)
428
+ break;
429
+ if (response.location === undefined)
430
+ break;
431
+ const next = new URL(response.location, currentUrl);
432
+ const sameOrigin = next.origin === new URL(currentUrl).origin;
433
+ currentUrl = next.toString();
434
+ response = sameOrigin
435
+ ? await webdavRequest(currentUrl, username, password, 'GET')
436
+ : await webdavRequest(currentUrl, '', '', 'GET');
437
+ }
438
+ if (response.status < 200 || response.status >= 300) {
439
+ throw new Error(response.status === 404
440
+ ? 'WebDAV download failed: HTTP 404 — no backup at that path yet. Upload one first, and check the URL points at the backup FILE (…/dsh/backup.json), not its folder / 该路径下还没有备份文件。请先执行一次上传,并确认地址指向备份文件本身(…/dsh/backup.json)而不是目录'
441
+ : `WebDAV download failed: HTTP ${response.status}`);
442
+ }
443
+ // Validate strictly server-side so the fetch result is never a generic
444
+ // echo of an internal response: restore only accepts real backups.
445
+ const body = JSON.parse(response.body.toString('utf8'));
446
+ validatedBackup(body);
447
+ return body;
448
+ }
449
+ /**
450
+ * The selected plugins' dependency specs and bundle entries from a backup's
451
+ * manifest. Only string specs survive — everything else in the manifest is
452
+ * untrusted and ignored (partial restore touches nothing but these).
453
+ */
454
+ export function extractPluginSelection(backup, includeDeps) {
455
+ const manifest = backup.files.find(file => file.path === 'package.json' && 'json' in file);
456
+ if (manifest === undefined || !('json' in manifest))
457
+ throw new Error('backup has no package.json');
458
+ const include = new Set(includeDeps);
459
+ const json = manifest.json;
460
+ const dependencies = json.dependencies === null || typeof json.dependencies !== 'object' || Array.isArray(json.dependencies)
461
+ ? {}
462
+ : json.dependencies;
463
+ const deps = {};
464
+ for (const [name, spec] of Object.entries(dependencies)) {
465
+ if (typeof spec === 'string' && include.has(name))
466
+ deps[name] = spec;
467
+ }
468
+ const dsh = json.dsh === null || typeof json.dsh !== 'object' || Array.isArray(json.dsh)
469
+ ? undefined
470
+ : json.dsh;
471
+ const profileBlock = dsh?.profile === null || typeof dsh?.profile !== 'object' || Array.isArray(dsh?.profile)
472
+ ? undefined
473
+ : dsh.profile;
474
+ const bundles = Array.isArray(profileBlock?.bundles) ? profileBlock.bundles : [];
475
+ return {
476
+ deps,
477
+ bundles: bundles.filter((name) => typeof name === 'string' && include.has(name)),
478
+ };
479
+ }
480
+ /**
481
+ * Merge a backup's manifest into the profile's current manifest so a restore
482
+ * never deletes plugins the target machine already has: current deps stay,
483
+ * backup specs win on name conflicts; bundle lists are unioned. When
484
+ * `selection` is given, only the selected plugins are merged in.
485
+ */
486
+ /**
487
+ * Dependencies whose spec points at an absolute local path — `link:/Users/…`
488
+ * or `file:/home/…` (#205 by @Rudyy898).
489
+ *
490
+ * These are perfectly valid on the machine that wrote them and meaningless
491
+ * anywhere else, so a backup carrying one restores a manifest that `pnpm
492
+ * install` cannot satisfy: the path does not exist on the new machine and
493
+ * the whole restore fails on it.
494
+ *
495
+ * Reported, NOT rewritten. Turning `link:/Users/me/dev/plugin` into
496
+ * something portable means deciding where those files should live and
497
+ * whether to carry them at all, which is a design question and not
498
+ * something a restore should answer on the user's behalf. Naming them lets
499
+ * the operator decide before the install runs — which is the part that was
500
+ * missing.
501
+ *
502
+ * Relative `file:./vendor/x` specs are left alone: they resolve against the
503
+ * profile directory, which the restore recreates, so they travel fine.
504
+ */
505
+ export function unportableDeps(dependencies) {
506
+ if (dependencies === null || typeof dependencies !== 'object' || Array.isArray(dependencies))
507
+ return [];
508
+ const found = [];
509
+ for (const [name, raw] of Object.entries(dependencies)) {
510
+ if (typeof raw !== 'string')
511
+ continue;
512
+ const match = /^(?:link|file):(.+)$/i.exec(raw);
513
+ if (match === null)
514
+ continue;
515
+ let path = match[1];
516
+ try {
517
+ path = decodeURIComponent(path);
518
+ }
519
+ catch { /* keep the literal spec */ }
520
+ // POSIX absolute, Windows drive-letter, or UNC — every shape that names
521
+ // a location outside this profile.
522
+ if (/^\//.test(path) || /^[A-Za-z]:[\\/]/.test(path) || /^\\\\/.test(path))
523
+ found.push({ name, spec: raw });
524
+ }
525
+ return found;
526
+ }
527
+ export function mergeRestoreManifest(backupManifest, current, selection) {
528
+ const merged = { ...backupManifest };
529
+ const backupDeps = backupManifest.dependencies === null || typeof backupManifest.dependencies !== 'object' || Array.isArray(backupManifest.dependencies)
530
+ ? {}
531
+ : backupManifest.dependencies;
532
+ const backupBundles = Array.isArray(backupManifest.dsh?.profile?.bundles)
533
+ ? (backupManifest.dsh.profile.bundles)
534
+ : [];
535
+ const currentDeps = current.dependencies === null || typeof current.dependencies !== 'object' || Array.isArray(current.dependencies)
536
+ ? {}
537
+ : current.dependencies;
538
+ const currentBundles = Array.isArray(current.dsh?.profile?.bundles)
539
+ ? (current.dsh.profile.bundles)
540
+ : [];
541
+ // Deps: keep the target's, overlay the backup's (or only the selection).
542
+ const deps = { ...currentDeps };
543
+ const sourceDeps = selection !== undefined ? selection.deps : backupDeps;
544
+ for (const [name, spec] of Object.entries(sourceDeps))
545
+ deps[name] = spec;
546
+ merged.dependencies = deps;
547
+ // Bundles: union of target and backup (or selection), de-duplicated.
548
+ const bundles = new Set();
549
+ for (const name of currentBundles)
550
+ if (typeof name === 'string')
551
+ bundles.add(name);
552
+ const sourceBundles = selection !== undefined ? selection.bundles : backupBundles;
553
+ for (const name of sourceBundles)
554
+ if (typeof name === 'string')
555
+ bundles.add(name);
556
+ const currentDsh = current.dsh === null || typeof current.dsh !== 'object' || Array.isArray(current.dsh)
557
+ ? undefined
558
+ : current.dsh;
559
+ const currentProfile = currentDsh?.profile === null || typeof currentDsh?.profile !== 'object' || Array.isArray(currentDsh?.profile)
560
+ ? undefined
561
+ : currentDsh.profile;
562
+ const backupDsh = merged.dsh === null || typeof merged.dsh !== 'object' || Array.isArray(merged.dsh)
563
+ ? undefined
564
+ : merged.dsh;
565
+ const backupProfile = backupDsh?.profile === null || typeof backupDsh?.profile !== 'object' || Array.isArray(backupDsh?.profile)
566
+ ? undefined
567
+ : backupDsh.profile;
568
+ const profileMerged = { ...(backupProfile ?? {}), ...(currentProfile ?? {}), bundles: [...bundles] };
569
+ const dshMerged = { ...(backupDsh ?? {}), ...(currentDsh ?? {}), profile: profileMerged };
570
+ merged.dsh = dshMerged;
571
+ return merged;
572
+ }