@chatcode/cco-market 1.45.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/README.zh.md +143 -0
- package/UPDATE-API-V1.md +132 -0
- package/client/client.js +11534 -0
- package/cordis.patch.yml +5 -0
- package/lib/accelerate.js +184 -0
- package/lib/agents.js +36 -0
- package/lib/backup.js +572 -0
- package/lib/catalog-local-match.js +126 -0
- package/lib/catalog-npm.js +111 -0
- package/lib/changelog.js +242 -0
- package/lib/channels.js +64 -0
- package/lib/check.js +1067 -0
- package/lib/compatibility.js +198 -0
- package/lib/diagnostics.js +49 -0
- package/lib/discovery-compatibility.js +260 -0
- package/lib/dsh-cli.js +949 -0
- package/lib/dsh-install.js +108 -0
- package/lib/gist.js +352 -0
- package/lib/groups.js +97 -0
- package/lib/home-paths.js +42 -0
- package/lib/hot.js +519 -0
- package/lib/http.js +39 -0
- package/lib/index.js +97 -0
- package/lib/install.js +387 -0
- package/lib/log.js +197 -0
- package/lib/ndjson.js +154 -0
- package/lib/net.js +98 -0
- package/lib/order.js +284 -0
- package/lib/patch.js +521 -0
- package/lib/pnpm-compat.js +490 -0
- package/lib/presets.js +278 -0
- package/lib/profile.js +911 -0
- package/lib/region-probe.js +93 -0
- package/lib/regions.js +253 -0
- package/lib/registry.js +204 -0
- package/lib/restart.js +373 -0
- package/lib/routes.js +4372 -0
- package/lib/settings.js +109 -0
- package/lib/skill-market/api.js +119 -0
- package/lib/skill-market/config.js +29 -0
- package/lib/skill-market/installer.js +352 -0
- package/lib/skill-market/runtime.js +13 -0
- package/lib/skill-market/tui.js +119 -0
- package/lib/skill-market/types.js +1 -0
- package/lib/skill-market/web-routes.js +123 -0
- package/lib/snapshot.js +500 -0
- package/lib/source-migration.js +54 -0
- package/lib/sources.js +596 -0
- package/lib/store.js +91 -0
- package/lib/themes.js +102 -0
- package/lib/trial.js +116 -0
- package/lib/types/accelerate.d.ts +85 -0
- package/lib/types/agents.d.ts +22 -0
- package/lib/types/backup.d.ts +131 -0
- package/lib/types/catalog-local-match.d.ts +50 -0
- package/lib/types/catalog-npm.d.ts +47 -0
- package/lib/types/changelog.d.ts +102 -0
- package/lib/types/channels.d.ts +56 -0
- package/lib/types/check.d.ts +266 -0
- package/lib/types/compatibility.d.ts +64 -0
- package/lib/types/diagnostics.d.ts +29 -0
- package/lib/types/discovery-compatibility.d.ts +80 -0
- package/lib/types/dsh-cli.d.ts +324 -0
- package/lib/types/dsh-install.d.ts +46 -0
- package/lib/types/gist.d.ts +53 -0
- package/lib/types/groups.d.ts +34 -0
- package/lib/types/home-paths.d.ts +16 -0
- package/lib/types/hot.d.ts +221 -0
- package/lib/types/http.d.ts +12 -0
- package/lib/types/index.d.ts +14 -0
- package/lib/types/install.d.ts +172 -0
- package/lib/types/log.d.ts +43 -0
- package/lib/types/ndjson.d.ts +52 -0
- package/lib/types/net.d.ts +58 -0
- package/lib/types/order.d.ts +100 -0
- package/lib/types/patch.d.ts +129 -0
- package/lib/types/pnpm-compat.d.ts +93 -0
- package/lib/types/presets.d.ts +90 -0
- package/lib/types/profile.d.ts +253 -0
- package/lib/types/region-probe.d.ts +50 -0
- package/lib/types/regions.d.ts +122 -0
- package/lib/types/registry.d.ts +86 -0
- package/lib/types/restart.d.ts +194 -0
- package/lib/types/routes.d.ts +67 -0
- package/lib/types/settings.d.ts +78 -0
- package/lib/types/skill-market/api.d.ts +10 -0
- package/lib/types/skill-market/config.d.ts +3 -0
- package/lib/types/skill-market/installer.d.ts +23 -0
- package/lib/types/skill-market/runtime.d.ts +10 -0
- package/lib/types/skill-market/tui.d.ts +4 -0
- package/lib/types/skill-market/types.d.ts +71 -0
- package/lib/types/skill-market/web-routes.d.ts +19 -0
- package/lib/types/snapshot.d.ts +93 -0
- package/lib/types/source-migration.d.ts +11 -0
- package/lib/types/sources.d.ts +216 -0
- package/lib/types/store.d.ts +30 -0
- package/lib/types/themes.d.ts +40 -0
- package/lib/types/trial.d.ts +61 -0
- package/lib/types/update-api-v1.d.ts +66 -0
- package/lib/types/updates.d.ts +123 -0
- package/lib/types/verify.d.ts +139 -0
- package/lib/update-api-v1.js +215 -0
- package/lib/updates.js +361 -0
- package/lib/verify.js +453 -0
- package/package.json +121 -0
- package/src/accelerate.ts +213 -0
- package/src/agents.ts +43 -0
- package/src/backup.ts +583 -0
- package/src/catalog-local-match.ts +144 -0
- package/src/catalog-npm.ts +120 -0
- package/src/changelog.ts +282 -0
- package/src/channels.ts +70 -0
- package/src/check.ts +1239 -0
- package/src/client/CommentsModal.tsx +119 -0
- package/src/client/Diagnostics.tsx +907 -0
- package/src/client/ErrorBoundary.tsx +111 -0
- package/src/client/InstallToast.tsx +31 -0
- package/src/client/Market.module.css +830 -0
- package/src/client/MarketSection.tsx +5301 -0
- package/src/client/OperationsPanel.tsx +365 -0
- package/src/client/SettingsCard.tsx +621 -0
- package/src/client/SkillMarket.module.css +21 -0
- package/src/client/SkillMarketSection.tsx +162 -0
- package/src/client/comments.ts +54 -0
- package/src/client/globals.d.ts +13 -0
- package/src/client/index.ts +180 -0
- package/src/client/locales.ts +1112 -0
- package/src/client/market-data.ts +1321 -0
- package/src/client/operations.ts +201 -0
- package/src/client/preset-panel.tsx +263 -0
- package/src/client/primitives.d.ts +140 -0
- package/src/client/self-check.ts +147 -0
- package/src/client/snapshot-panel.tsx +244 -0
- package/src/compatibility.ts +237 -0
- package/src/diagnostics.ts +84 -0
- package/src/discovery-compatibility.ts +315 -0
- package/src/dsh-cli.ts +1126 -0
- package/src/dsh-install.ts +118 -0
- package/src/gist.ts +362 -0
- package/src/groups.ts +111 -0
- package/src/home-paths.ts +53 -0
- package/src/hot.ts +628 -0
- package/src/http.ts +41 -0
- package/src/index.ts +128 -0
- package/src/install.ts +420 -0
- package/src/log.ts +206 -0
- package/src/ndjson.ts +185 -0
- package/src/net.ts +106 -0
- package/src/order.ts +303 -0
- package/src/patch.ts +522 -0
- package/src/pnpm-compat.ts +527 -0
- package/src/presets.ts +344 -0
- package/src/profile.ts +940 -0
- package/src/region-probe.ts +97 -0
- package/src/regions.ts +310 -0
- package/src/registry.ts +250 -0
- package/src/restart.ts +396 -0
- package/src/routes.ts +4506 -0
- package/src/settings.ts +141 -0
- package/src/skill-market/api.ts +130 -0
- package/src/skill-market/config.ts +32 -0
- package/src/skill-market/installer.ts +337 -0
- package/src/skill-market/runtime.ts +14 -0
- package/src/skill-market/tui.ts +157 -0
- package/src/skill-market/types.ts +80 -0
- package/src/skill-market/web-routes.ts +132 -0
- package/src/snapshot.ts +532 -0
- package/src/source-migration.ts +61 -0
- package/src/sources.ts +565 -0
- package/src/store.ts +89 -0
- package/src/themes.ts +125 -0
- package/src/trial.ts +156 -0
- package/src/update-api-v1.ts +277 -0
- package/src/updates.ts +400 -0
- package/src/verify.ts +492 -0
package/src/snapshot.ts
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile snapshots — issue #98 (phase 3), implementing the snapshot half of
|
|
3
|
+
* #19: before any ordering / preset change is applied, the profile's
|
|
4
|
+
* composition-critical files are captured as a timestamped snapshot; a failed
|
|
5
|
+
* or unwanted change can be rolled back in one step.
|
|
6
|
+
*
|
|
7
|
+
* A snapshot is a single JSON document under `<profile>/.dsh-market/
|
|
8
|
+
* snapshots/<timestamp>-<seq>.json` describing the files that define the
|
|
9
|
+
* profile's composition: package.json (dependency + bundle list),
|
|
10
|
+
* cordis.patch.yml (the user patch layer) and state.json (market disable
|
|
11
|
+
* list + groups). Version 2 records optional-file absence explicitly;
|
|
12
|
+
* restoring validates every path, reconciles writes and deletions, and rolls
|
|
13
|
+
* completed actions back if a later action fails.
|
|
14
|
+
*
|
|
15
|
+
* Retention: after every create, snapshots are pruned to the most recent
|
|
16
|
+
* `maxSnapshots` (default 20); the cap is configurable through the market
|
|
17
|
+
* plugin config (`maxSnapshots`) and each snapshot can also be deleted
|
|
18
|
+
* individually from the UI.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
22
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
23
|
+
import { logEvent } from './log.ts'
|
|
24
|
+
|
|
25
|
+
const SNAPSHOT_DIR = join('.dsh-market', 'snapshots')
|
|
26
|
+
const SNAPSHOT_FORMAT = 'dsh-market/profile-snapshot'
|
|
27
|
+
const SNAPSHOT_VERSION = 2
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Snapshot ids are generated by nextSnapshotId and must never be attacker-
|
|
31
|
+
* shaped: anything else (paths, '..', absolute paths) is refused BEFORE it
|
|
32
|
+
* reaches the filesystem, so restoreSnapshot/deleteSnapshot cannot escape the
|
|
33
|
+
* snapshots directory (issue #98 review B3 — same discipline as backup.ts).
|
|
34
|
+
*/
|
|
35
|
+
const SNAPSHOT_ID_RE = /^snapshot-[0-9A-Za-z-]+$/
|
|
36
|
+
|
|
37
|
+
function validSnapshotId(id: string): boolean {
|
|
38
|
+
return SNAPSHOT_ID_RE.test(id)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The files a snapshot captures, relative to the profile directory. */
|
|
42
|
+
const SNAPSHOT_FILES = ['package.json', 'cordis.patch.yml', '.dsh-market/state.json'] as const
|
|
43
|
+
|
|
44
|
+
interface SnapshotFile {
|
|
45
|
+
path: string
|
|
46
|
+
/** JSON documents keep their parsed form (re-serialized on restore). */
|
|
47
|
+
json?: unknown
|
|
48
|
+
/** Line-oriented text files (cordis.patch.yml) keep their lines. */
|
|
49
|
+
lines?: string[]
|
|
50
|
+
/** V2 records absence explicitly so restore can remove a later file. */
|
|
51
|
+
absent?: true
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ProfileSnapshot {
|
|
55
|
+
/** Present on exact snapshots; omitted by legacy snapshots. */
|
|
56
|
+
format?: typeof SNAPSHOT_FORMAT
|
|
57
|
+
/** Version 2 records all tracked paths, including explicit absence. */
|
|
58
|
+
version?: typeof SNAPSHOT_VERSION
|
|
59
|
+
/** Snapshot id: the file basename without the .json suffix. */
|
|
60
|
+
id: string
|
|
61
|
+
createdAt: number
|
|
62
|
+
files: SnapshotFile[]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type SnapshotCaptureResult =
|
|
66
|
+
| { ok: true; snapshot: ProfileSnapshot }
|
|
67
|
+
| { ok: false; error: string }
|
|
68
|
+
|
|
69
|
+
interface NormalizedSnapshot {
|
|
70
|
+
snapshot: ProfileSnapshot
|
|
71
|
+
files: SnapshotFile[]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type SnapshotValidation =
|
|
75
|
+
| { ok: true; value: NormalizedSnapshot }
|
|
76
|
+
| { ok: false; error: string }
|
|
77
|
+
|
|
78
|
+
function hasOwn(value: object, key: string): boolean {
|
|
79
|
+
return Object.prototype.hasOwnProperty.call(value, key)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Strict shape validation for a parsed snapshot document (issue #98 analysis:
|
|
84
|
+
* snapshot JSON validation). A snapshot that fails these checks is corrupt —
|
|
85
|
+
* it can never be restored safely, so listing it would only offer the user a
|
|
86
|
+
* restore that must fail.
|
|
87
|
+
*/
|
|
88
|
+
function validateSnapshotDocument(value: unknown): SnapshotValidation {
|
|
89
|
+
if (value === null || typeof value !== 'object') {
|
|
90
|
+
return { ok: false, error: 'corrupt snapshot document / 快照文档损坏' }
|
|
91
|
+
}
|
|
92
|
+
const snap = value as { format?: unknown; version?: unknown; id?: unknown; createdAt?: unknown; files?: unknown }
|
|
93
|
+
if (typeof snap.id !== 'string' || !validSnapshotId(snap.id)) {
|
|
94
|
+
return { ok: false, error: 'corrupt snapshot id / 快照 id 损坏' }
|
|
95
|
+
}
|
|
96
|
+
if (typeof snap.createdAt !== 'number' || !Number.isFinite(snap.createdAt)) {
|
|
97
|
+
return { ok: false, error: 'corrupt snapshot timestamp / 快照时间损坏' }
|
|
98
|
+
}
|
|
99
|
+
if (!Array.isArray(snap.files)) {
|
|
100
|
+
return { ok: false, error: 'corrupt snapshot: files is not an array / 快照损坏:files 不是数组' }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Existing unversioned documents are legacy v1. Their omitted optional
|
|
104
|
+
// paths remain no-ops: older capture also omitted unreadable/invalid files,
|
|
105
|
+
// so absence cannot be inferred safely after the fact.
|
|
106
|
+
const legacy = !hasOwn(snap, 'format') && !hasOwn(snap, 'version')
|
|
107
|
+
if (!legacy && (snap.format !== SNAPSHOT_FORMAT || snap.version !== SNAPSHOT_VERSION)) {
|
|
108
|
+
return { ok: false, error: 'unsupported snapshot format or version / 不支持的快照格式或版本' }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const byPath = new Map<string, SnapshotFile>()
|
|
112
|
+
for (const file of snap.files) {
|
|
113
|
+
if (file === null || typeof file !== 'object') {
|
|
114
|
+
return { ok: false, error: 'corrupt snapshot file entry / 快照文件条目损坏' }
|
|
115
|
+
}
|
|
116
|
+
const entry = file as { path?: unknown; json?: unknown; lines?: unknown; absent?: unknown }
|
|
117
|
+
if (typeof entry.path !== 'string'
|
|
118
|
+
|| !(SNAPSHOT_FILES as readonly string[]).includes(entry.path)
|
|
119
|
+
|| entry.path.includes('..')) {
|
|
120
|
+
return { ok: false, error: `unsafe snapshot path: ${String(entry.path)} / 快照路径不安全` }
|
|
121
|
+
}
|
|
122
|
+
if (byPath.has(entry.path)) {
|
|
123
|
+
return { ok: false, error: `duplicate snapshot path: ${entry.path} / 快照路径重复` }
|
|
124
|
+
}
|
|
125
|
+
const hasJson = hasOwn(entry, 'json')
|
|
126
|
+
const hasLines = hasOwn(entry, 'lines')
|
|
127
|
+
const hasAbsent = hasOwn(entry, 'absent')
|
|
128
|
+
if (Number(hasJson) + Number(hasLines) + Number(hasAbsent) !== 1) {
|
|
129
|
+
return { ok: false, error: `snapshot file ${entry.path} must have exactly one representation / 快照文件表示无效` }
|
|
130
|
+
}
|
|
131
|
+
if (hasLines && (!Array.isArray(entry.lines) || !entry.lines.every(line => typeof line === 'string'))) {
|
|
132
|
+
return { ok: false, error: `snapshot file ${entry.path} has invalid lines / 快照文件行无效` }
|
|
133
|
+
}
|
|
134
|
+
if (hasAbsent && (legacy || entry.absent !== true || entry.path === 'package.json')) {
|
|
135
|
+
return { ok: false, error: `snapshot file ${entry.path} has invalid absence marker / 快照文件缺失标记无效` }
|
|
136
|
+
}
|
|
137
|
+
if ((entry.path === 'package.json' || entry.path === '.dsh-market/state.json') && !hasJson && !hasAbsent) {
|
|
138
|
+
return { ok: false, error: `snapshot file ${entry.path} has the wrong representation / 快照文件表示类型错误` }
|
|
139
|
+
}
|
|
140
|
+
if (entry.path === 'cordis.patch.yml' && !hasLines && !hasAbsent) {
|
|
141
|
+
return { ok: false, error: `snapshot file ${entry.path} has the wrong representation / 快照文件表示类型错误` }
|
|
142
|
+
}
|
|
143
|
+
byPath.set(entry.path, entry as SnapshotFile)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!legacy && SNAPSHOT_FILES.some(path => !byPath.has(path))) {
|
|
147
|
+
return { ok: false, error: 'v2 snapshot does not describe every composition file / v2 快照未描述全部组合文件' }
|
|
148
|
+
}
|
|
149
|
+
if (!byPath.has('package.json')) {
|
|
150
|
+
return { ok: false, error: 'snapshot has no package.json / 快照缺少 package.json' }
|
|
151
|
+
}
|
|
152
|
+
const files = SNAPSHOT_FILES.flatMap(path => {
|
|
153
|
+
const file = byPath.get(path)
|
|
154
|
+
return file === undefined ? [] : [file]
|
|
155
|
+
})
|
|
156
|
+
return {
|
|
157
|
+
ok: true,
|
|
158
|
+
value: {
|
|
159
|
+
snapshot: { ...(snap as ProfileSnapshot), files },
|
|
160
|
+
files,
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Atomic same-directory replace (write temp + rename): a crash mid-write can
|
|
167
|
+
* never leave a snapshot file truncated — a half-written snapshot would parse
|
|
168
|
+
* as corrupt forever and could not be restored.
|
|
169
|
+
*/
|
|
170
|
+
function writeFileAtomic(file: string, content: string): void {
|
|
171
|
+
const temp = `${file}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
172
|
+
try {
|
|
173
|
+
writeFileSync(temp, content)
|
|
174
|
+
renameSync(temp, file)
|
|
175
|
+
} catch (error) {
|
|
176
|
+
try { rmSync(temp, { force: true }) } catch { /* best-effort temp cleanup */ }
|
|
177
|
+
throw error
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function isMissingFileError(error: unknown): boolean {
|
|
182
|
+
return error !== null
|
|
183
|
+
&& typeof error === 'object'
|
|
184
|
+
&& 'code' in error
|
|
185
|
+
&& (error as { code?: unknown }).code === 'ENOENT'
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* `readFileSync` also reports ENOENT for a dangling symlink. Check the path
|
|
190
|
+
* entry itself before recording absence so an unreadable composition path can
|
|
191
|
+
* never be silently converted into a delete instruction for a later restore.
|
|
192
|
+
*/
|
|
193
|
+
function isTrulyAbsent(path: string, readError: unknown): boolean {
|
|
194
|
+
if (!isMissingFileError(readError)) return false
|
|
195
|
+
try {
|
|
196
|
+
lstatSync(path)
|
|
197
|
+
return false
|
|
198
|
+
} catch (statError) {
|
|
199
|
+
return isMissingFileError(statError)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Absolute snapshot directory for a profile. */
|
|
204
|
+
function snapshotDir(profileDir: string): string {
|
|
205
|
+
return join(profileDir, SNAPSHOT_DIR)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function snapshotFile(profileDir: string, id: string): string {
|
|
209
|
+
if (!validSnapshotId(id)) throw new Error('invalid snapshot id / 无效的快照 id')
|
|
210
|
+
return join(snapshotDir(profileDir), `${id}.json`)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Basename of the next snapshot (timestamp + sequence for same-ms saves). */
|
|
214
|
+
function nextSnapshotId(profileDir: string): string {
|
|
215
|
+
const dir = snapshotDir(profileDir)
|
|
216
|
+
let names: string[] = []
|
|
217
|
+
try {
|
|
218
|
+
names = readdirSync(dir)
|
|
219
|
+
} catch { /* directory absent — no collisions */ }
|
|
220
|
+
const now = new Date().toISOString().replace(/[:.]/g, '-')
|
|
221
|
+
let seq = 0
|
|
222
|
+
let id = `snapshot-${now}`
|
|
223
|
+
while (names.includes(`${id}.json`)) {
|
|
224
|
+
seq += 1
|
|
225
|
+
id = `snapshot-${now}-${String(seq)}`
|
|
226
|
+
}
|
|
227
|
+
return id
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Default number of snapshots retained; configurable via `maxSnapshots`. */
|
|
231
|
+
export const DEFAULT_MAX_SNAPSHOTS = 20
|
|
232
|
+
function captureError(path: string, reason: string, reasonZh: string): string {
|
|
233
|
+
return `profile composition could not be captured: ${path} ${reason} / 无法捕获 profile 组合:${path} ${reasonZh}`
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function errorCode(error: unknown): string | undefined {
|
|
237
|
+
if (error === null || typeof error !== 'object' || !('code' in error)) return undefined
|
|
238
|
+
return typeof error.code === 'string' ? error.code : undefined
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function captureReadError(path: string, error: unknown): string {
|
|
242
|
+
const code = errorCode(error)
|
|
243
|
+
const suffix = code === undefined ? '' : ` (${code})`
|
|
244
|
+
return captureError(path, `could not be read${suffix}`, `无法读取${suffix}`)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Prune the snapshot directory to the `max` most recent snapshots (newest
|
|
249
|
+
* first). Extra ones are deleted oldest-first. A non-positive cap is clamped
|
|
250
|
+
* to 1 — a 0/negative max must never drop the snapshot that was just created
|
|
251
|
+
* nor invert to keeping the OLDEST set (issue #98 review hardening).
|
|
252
|
+
* @returns the ids that were deleted.
|
|
253
|
+
*/
|
|
254
|
+
export function pruneSnapshots(profileDir: string, max: number): string[] {
|
|
255
|
+
return pruneSnapshotsKeeping(profileDir, max)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Refuse an existing parent that could redirect a restore outside the profile. */
|
|
259
|
+
function ensureSafeRestoreParent(profileDir: string, parent: string, snapshotPath: string): void {
|
|
260
|
+
const root = resolve(profileDir)
|
|
261
|
+
const relativeParent = relative(root, resolve(parent))
|
|
262
|
+
if (relativeParent === '') return
|
|
263
|
+
if (isAbsolute(relativeParent) || relativeParent === '..' || relativeParent.startsWith(`..${sep}`)) {
|
|
264
|
+
throw new Error(`unsafe snapshot restore path: ${snapshotPath} / 快照恢复路径不安全`)
|
|
265
|
+
}
|
|
266
|
+
let current = root
|
|
267
|
+
for (const part of relativeParent.split(sep)) {
|
|
268
|
+
current = resolve(current, part)
|
|
269
|
+
let stat
|
|
270
|
+
try {
|
|
271
|
+
stat = lstatSync(current)
|
|
272
|
+
} catch (error) {
|
|
273
|
+
if (isMissingFileError(error)) return
|
|
274
|
+
throw error
|
|
275
|
+
}
|
|
276
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
277
|
+
throw new Error(`unsafe snapshot restore path: ${snapshotPath} / 快照恢复路径不安全`)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
interface SnapshotIdOrderKey {
|
|
283
|
+
category: 0 | 1
|
|
284
|
+
primary: string
|
|
285
|
+
sequence: bigint
|
|
286
|
+
fallback: string
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function snapshotIdOrderKey(id: string): SnapshotIdOrderKey {
|
|
290
|
+
const generated = /^(snapshot-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)(?:-(\d+))?$/
|
|
291
|
+
const match = generated.exec(id)
|
|
292
|
+
return match === null
|
|
293
|
+
? { category: 1, primary: id, sequence: 0n, fallback: id }
|
|
294
|
+
: { category: 0, primary: match[1]!, sequence: BigInt(match[2] ?? '0'), fallback: id }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** @internal Deterministic newest-first order for equal-createdAt documents. */
|
|
298
|
+
export function compareSnapshotIdsNewest(a: string, b: string): number {
|
|
299
|
+
const aKey = snapshotIdOrderKey(a)
|
|
300
|
+
const bKey = snapshotIdOrderKey(b)
|
|
301
|
+
if (aKey.category !== bKey.category) return aKey.category - bKey.category
|
|
302
|
+
if (aKey.primary !== bKey.primary) return aKey.primary < bKey.primary ? 1 : -1
|
|
303
|
+
if (aKey.sequence !== bKey.sequence) return aKey.sequence < bKey.sequence ? 1 : -1
|
|
304
|
+
return aKey.fallback === bKey.fallback ? 0 : aKey.fallback < bKey.fallback ? 1 : -1
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function pruneSnapshotsKeeping(profileDir: string, max: number, keepId?: string): string[] {
|
|
308
|
+
const cap = Math.max(1, max)
|
|
309
|
+
const all = listSnapshots(profileDir)
|
|
310
|
+
if (all.length <= cap) return []
|
|
311
|
+
// Sequence-suffixed ids can share createdAt. Creation must pin its own id
|
|
312
|
+
// ahead of that tie so it never returns a snapshot pruning just removed.
|
|
313
|
+
const ordered = keepId === undefined
|
|
314
|
+
? all
|
|
315
|
+
: [...all.filter(snap => snap.id === keepId), ...all.filter(snap => snap.id !== keepId)]
|
|
316
|
+
const dropped = ordered.slice(cap)
|
|
317
|
+
for (const snap of dropped) deleteSnapshot(profileDir, snap.id)
|
|
318
|
+
if (dropped.length > 0) {
|
|
319
|
+
logEvent('info', 'snapshot', `pruned ${dropped.length} old snapshot(s) (cap ${cap}): ${dropped.map(s => s.id).join(', ')}`)
|
|
320
|
+
}
|
|
321
|
+
return dropped.map(s => s.id)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Capture the profile's composition into a version 2 snapshot. Missing
|
|
326
|
+
* optional files are represented explicitly. An existing optional file that
|
|
327
|
+
* cannot be read makes capture fail. Invalid market state is represented as
|
|
328
|
+
* absent because every state reader already observes it as empty and the next
|
|
329
|
+
* state write replaces it. A missing, unreadable, or invalid package.json is
|
|
330
|
+
* not snapshot-able. After creating, the directory is pruned to the most
|
|
331
|
+
* recent `maxSnapshots`.
|
|
332
|
+
*/
|
|
333
|
+
export function createProfileSnapshot(profileDir: string, maxSnapshots: number = DEFAULT_MAX_SNAPSHOTS): SnapshotCaptureResult {
|
|
334
|
+
const packagePath = join(profileDir, 'package.json')
|
|
335
|
+
let packageText: string
|
|
336
|
+
try {
|
|
337
|
+
packageText = readFileSync(packagePath, 'utf8')
|
|
338
|
+
} catch (error) {
|
|
339
|
+
const failure = isTrulyAbsent(packagePath, error)
|
|
340
|
+
? captureError('package.json', 'is missing', '缺失')
|
|
341
|
+
: captureReadError('package.json', error)
|
|
342
|
+
logEvent('error', 'snapshot', failure)
|
|
343
|
+
return { ok: false, error: failure }
|
|
344
|
+
}
|
|
345
|
+
let packageJson: unknown
|
|
346
|
+
try {
|
|
347
|
+
packageJson = JSON.parse(packageText)
|
|
348
|
+
} catch {
|
|
349
|
+
const failure = captureError('package.json', 'contains invalid JSON', '包含无效 JSON')
|
|
350
|
+
logEvent('error', 'snapshot', failure)
|
|
351
|
+
return { ok: false, error: failure }
|
|
352
|
+
}
|
|
353
|
+
const files: SnapshotFile[] = [{ path: 'package.json', json: packageJson }]
|
|
354
|
+
for (const path of SNAPSHOT_FILES.slice(1)) {
|
|
355
|
+
const absolutePath = join(profileDir, path)
|
|
356
|
+
let text: string
|
|
357
|
+
try {
|
|
358
|
+
text = readFileSync(absolutePath, 'utf8')
|
|
359
|
+
} catch (error) {
|
|
360
|
+
if (isTrulyAbsent(absolutePath, error)) files.push({ path, absent: true })
|
|
361
|
+
else {
|
|
362
|
+
const failure = captureReadError(path, error)
|
|
363
|
+
logEvent('error', 'snapshot', failure)
|
|
364
|
+
return { ok: false, error: failure }
|
|
365
|
+
}
|
|
366
|
+
continue
|
|
367
|
+
}
|
|
368
|
+
if (path === '.dsh-market/state.json') {
|
|
369
|
+
try {
|
|
370
|
+
files.push({ path, json: JSON.parse(text) })
|
|
371
|
+
} catch {
|
|
372
|
+
// readMarketState treats malformed state as empty and every state write
|
|
373
|
+
// replaces it, so absence is the exact observable composition state.
|
|
374
|
+
files.push({ path, absent: true })
|
|
375
|
+
logEvent('warn', 'snapshot', `${path} contains invalid JSON; captured as absent because Market reads it as empty`)
|
|
376
|
+
}
|
|
377
|
+
} else {
|
|
378
|
+
files.push({ path, lines: text.split('\n') })
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const id = nextSnapshotId(profileDir)
|
|
382
|
+
const snapshot: ProfileSnapshot = {
|
|
383
|
+
format: SNAPSHOT_FORMAT,
|
|
384
|
+
version: SNAPSHOT_VERSION,
|
|
385
|
+
id,
|
|
386
|
+
createdAt: Date.now(),
|
|
387
|
+
files,
|
|
388
|
+
}
|
|
389
|
+
mkdirSync(snapshotDir(profileDir), { recursive: true, mode: 0o700 })
|
|
390
|
+
writeFileAtomic(snapshotFile(profileDir, id), `${JSON.stringify(snapshot, null, 2)}\n`)
|
|
391
|
+
logEvent('info', 'snapshot', `created ${id} (${files.map(file => file.path).join(', ')})`)
|
|
392
|
+
pruneSnapshotsKeeping(profileDir, maxSnapshots, id)
|
|
393
|
+
return { ok: true, snapshot }
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* All snapshots for a profile, newest first. Files that are unparseable,
|
|
398
|
+
* shape-invalid (bad id/createdAt/files), or whose internal id does not match
|
|
399
|
+
* the file name are skipped — a snapshot that could never be restored is not
|
|
400
|
+
* listed (issue #98 analysis: snapshot JSON validation).
|
|
401
|
+
*/
|
|
402
|
+
export function listSnapshots(profileDir: string): ProfileSnapshot[] {
|
|
403
|
+
let names: string[]
|
|
404
|
+
try {
|
|
405
|
+
names = readdirSync(snapshotDir(profileDir)).filter(name => name.endsWith('.json'))
|
|
406
|
+
} catch {
|
|
407
|
+
return []
|
|
408
|
+
}
|
|
409
|
+
const snapshots: ProfileSnapshot[] = []
|
|
410
|
+
for (const name of names) {
|
|
411
|
+
try {
|
|
412
|
+
const id = name.slice(0, -5)
|
|
413
|
+
const value = JSON.parse(readFileSync(snapshotFile(profileDir, id), 'utf8')) as unknown
|
|
414
|
+
const validated = validateSnapshotDocument(value)
|
|
415
|
+
if (validated.ok && validated.value.snapshot.id === id) snapshots.push(validated.value.snapshot)
|
|
416
|
+
} catch { /* corrupt snapshot — skip */ }
|
|
417
|
+
}
|
|
418
|
+
return snapshots.sort((a, b) => b.createdAt - a.createdAt || compareSnapshotIdsNewest(a.id, b.id))
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Restore a snapshot's files into the profile. Version 2 absence markers
|
|
423
|
+
* remove files created after capture; omitted optional files in unversioned
|
|
424
|
+
* legacy snapshots remain untouched. Every path is validated before mutation,
|
|
425
|
+
* each write uses a same-directory temp + rename, and a failure mid-restore
|
|
426
|
+
* rolls completed writes/deletions back to their pre-restore contents.
|
|
427
|
+
* @returns the paths restored; an error result when the snapshot is unsafe,
|
|
428
|
+
* unknown, or a write failed (with any partial writes rolled back).
|
|
429
|
+
*/
|
|
430
|
+
export function restoreSnapshot(profileDir: string, id: string): { ok: boolean; restored: string[]; error?: string } {
|
|
431
|
+
if (!validSnapshotId(id)) {
|
|
432
|
+
return { ok: false, restored: [], error: 'invalid snapshot id / 无效的快照 id' }
|
|
433
|
+
}
|
|
434
|
+
let parsed: unknown
|
|
435
|
+
try {
|
|
436
|
+
parsed = JSON.parse(readFileSync(snapshotFile(profileDir, id), 'utf8')) as unknown
|
|
437
|
+
} catch {
|
|
438
|
+
return { ok: false, restored: [], error: 'snapshot not found / 快照不存在' }
|
|
439
|
+
}
|
|
440
|
+
const validated = validateSnapshotDocument(parsed)
|
|
441
|
+
if (!validated.ok) return { ok: false, restored: [], error: validated.error }
|
|
442
|
+
if (validated.value.snapshot.id !== id) {
|
|
443
|
+
return { ok: false, restored: [], error: 'snapshot id does not match its filename / 快照 id 与文件名不匹配' }
|
|
444
|
+
}
|
|
445
|
+
// Materialize every target + its current content FIRST, so a rollback can
|
|
446
|
+
// restore the pre-restore bytes even if the profile already changed.
|
|
447
|
+
const writes: Array<{ path: string; target: string; content: string | null; previous: string | null }> = []
|
|
448
|
+
for (const file of validated.value.files) {
|
|
449
|
+
const target = join(profileDir, file.path)
|
|
450
|
+
const content = file.absent === true
|
|
451
|
+
? null
|
|
452
|
+
: file.json !== undefined
|
|
453
|
+
? `${JSON.stringify(file.json, null, 2)}\n`
|
|
454
|
+
: Array.isArray(file.lines) ? `${file.lines.join('\n')}` : ''
|
|
455
|
+
let previous: string | null = null
|
|
456
|
+
// Rollback records bytes, not filesystem object identity. Replacing a
|
|
457
|
+
// symlink or other non-regular target could not be restored faithfully.
|
|
458
|
+
try {
|
|
459
|
+
ensureSafeRestoreParent(profileDir, dirname(target), file.path)
|
|
460
|
+
if (!lstatSync(target).isFile()) {
|
|
461
|
+
return {
|
|
462
|
+
ok: false,
|
|
463
|
+
restored: [],
|
|
464
|
+
error: `unsafe restore target is not a regular file: ${file.path} / 恢复目标不是常规文件`,
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
} catch (error) {
|
|
468
|
+
if (!isMissingFileError(error)) {
|
|
469
|
+
return { ok: false, restored: [], error: error instanceof Error ? error.message : String(error) }
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
try {
|
|
473
|
+
previous = readFileSync(target, 'utf8')
|
|
474
|
+
} catch (error) {
|
|
475
|
+
if (!isMissingFileError(error)) {
|
|
476
|
+
return { ok: false, restored: [], error: error instanceof Error ? error.message : String(error) }
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
writes.push({ path: file.path, target, content, previous })
|
|
480
|
+
}
|
|
481
|
+
const completed: typeof writes = []
|
|
482
|
+
try {
|
|
483
|
+
for (const write of writes) {
|
|
484
|
+
if (write.content === null) rmSync(write.target, { force: true })
|
|
485
|
+
else {
|
|
486
|
+
mkdirSync(dirname(write.target), { recursive: true })
|
|
487
|
+
writeFileAtomic(write.target, write.content)
|
|
488
|
+
}
|
|
489
|
+
completed.push(write)
|
|
490
|
+
}
|
|
491
|
+
} catch (error) {
|
|
492
|
+
// Roll back completed actions in reverse, including recreating a file that
|
|
493
|
+
// an explicit absence marker deleted before a later action failed.
|
|
494
|
+
let rollbackError: unknown = null
|
|
495
|
+
for (const write of [...completed].reverse()) {
|
|
496
|
+
try {
|
|
497
|
+
if (write.previous !== null) {
|
|
498
|
+
mkdirSync(dirname(write.target), { recursive: true })
|
|
499
|
+
writeFileAtomic(write.target, write.previous)
|
|
500
|
+
}
|
|
501
|
+
else rmSync(write.target, { force: true })
|
|
502
|
+
} catch (restoreError) {
|
|
503
|
+
rollbackError ??= restoreError
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
507
|
+
const rollbackDetail = rollbackError === null
|
|
508
|
+
? ''
|
|
509
|
+
: `; rollback incomplete: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`
|
|
510
|
+
const rollbackStatus = rollbackError === null
|
|
511
|
+
? `failed and was rolled back: ${detail}`
|
|
512
|
+
: `failed; rollback incomplete (${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}); original failure: ${detail}`
|
|
513
|
+
logEvent('error', 'snapshot', `restore ${id} ${rollbackStatus}`)
|
|
514
|
+
return { ok: false, restored: [], error: `${detail}${rollbackDetail}` }
|
|
515
|
+
}
|
|
516
|
+
const restored = writes.map(write => write.path)
|
|
517
|
+
logEvent('info', 'snapshot', `restored ${id}: ${restored.join(', ')}`)
|
|
518
|
+
return { ok: true, restored }
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Delete one snapshot; true only when it existed and was removed. */
|
|
522
|
+
export function deleteSnapshot(profileDir: string, id: string): boolean {
|
|
523
|
+
if (!validSnapshotId(id)) return false
|
|
524
|
+
const file = snapshotFile(profileDir, id)
|
|
525
|
+
if (!existsSync(file)) return false
|
|
526
|
+
try {
|
|
527
|
+
rmSync(file)
|
|
528
|
+
return true
|
|
529
|
+
} catch {
|
|
530
|
+
return false
|
|
531
|
+
}
|
|
532
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe discovery for legacy Git -> verified npm source migrations (#461).
|
|
3
|
+
*
|
|
4
|
+
* This intentionally does less than the normal catalog matcher. A migration
|
|
5
|
+
* changes package name/publisher and build provenance, so only repository
|
|
6
|
+
* evidence carried by the installed Git spec may authorize it:
|
|
7
|
+
*
|
|
8
|
+
* - bare github:owner/repo matches only the catalog root for that repo;
|
|
9
|
+
* - #path:/subdir matches only that exact catalog subpath;
|
|
10
|
+
* - branch/tag/commit/semver selectors are not migrated in v1;
|
|
11
|
+
* - more than one matching verified npm entry is ambiguous and fails closed.
|
|
12
|
+
*/
|
|
13
|
+
import { NPM_NAME_RE, parseSourceUrl, repoOfTarget } from './sources.ts'
|
|
14
|
+
|
|
15
|
+
export interface GitToNpmMigration {
|
|
16
|
+
kind: 'git-to-npm'
|
|
17
|
+
/** Normalized owner/repo or owner/repo#path:/subdir identity. */
|
|
18
|
+
repo: string
|
|
19
|
+
/** Verified npm package declared by the same catalog entry. */
|
|
20
|
+
target: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function migratableGitIdentity(spec: string): string | null {
|
|
24
|
+
if (!spec.startsWith('github:')) return null
|
|
25
|
+
const hash = spec.indexOf('#')
|
|
26
|
+
if (hash !== -1) {
|
|
27
|
+
const selector = spec.slice(hash + 1)
|
|
28
|
+
// A path selector identifies which package inside a monorepo. Any
|
|
29
|
+
// other selector expresses an explicit version/ref choice and is
|
|
30
|
+
// deliberately preserved rather than silently discarded.
|
|
31
|
+
if (!selector.startsWith('path:/') || selector.includes('&')) return null
|
|
32
|
+
}
|
|
33
|
+
return repoOfTarget(spec)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function findGitToNpmMigration<T extends { url: string; npm?: unknown }>(
|
|
37
|
+
plugins: readonly T[],
|
|
38
|
+
spec: string,
|
|
39
|
+
): GitToNpmMigration | null {
|
|
40
|
+
const identity = migratableGitIdentity(spec)
|
|
41
|
+
if (identity === null) return null
|
|
42
|
+
const pathAt = identity.indexOf('#path:/')
|
|
43
|
+
const wantedRepo = pathAt === -1 ? identity : identity.slice(0, pathAt)
|
|
44
|
+
const wantedPath = pathAt === -1 ? null : identity.slice(pathAt + '#path:/'.length)
|
|
45
|
+
|
|
46
|
+
const candidates = plugins.flatMap((plugin) => {
|
|
47
|
+
if (typeof plugin.npm !== 'string' || !NPM_NAME_RE.test(plugin.npm)) return []
|
|
48
|
+
const source = parseSourceUrl(plugin.url)
|
|
49
|
+
if (source === null || source.repo.toLowerCase() !== wantedRepo) return []
|
|
50
|
+
if (wantedPath === null) {
|
|
51
|
+
// A collection root must not guess which /tree/ child was meant.
|
|
52
|
+
if (source.subpath !== null) return []
|
|
53
|
+
} else if (source.subpath?.toLowerCase() !== wantedPath) {
|
|
54
|
+
return []
|
|
55
|
+
}
|
|
56
|
+
return [plugin.npm]
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
if (candidates.length !== 1) return null
|
|
60
|
+
return { kind: 'git-to-npm', repo: identity, target: candidates[0]! }
|
|
61
|
+
}
|