@pandablessing/dsh-user-sync 0.0.0-stage → 1.0.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 +77 -0
- package/README.en.md +45 -0
- package/README.md +102 -2
- package/cordis.patch.yml +5 -0
- package/lib/apply.js +111 -0
- package/lib/archive.js +160 -0
- package/lib/auth.js +10 -0
- package/lib/client.js +521 -0
- package/lib/hash.js +32 -0
- package/lib/host.js +5 -0
- package/lib/index.js +593 -0
- package/lib/lww.js +64 -0
- package/lib/paths.js +63 -0
- package/lib/plugins.js +37 -0
- package/lib/scan.js +335 -0
- package/lib/scheduler.js +80 -0
- package/lib/secret.js +62 -0
- package/lib/state.js +123 -0
- package/lib/sync.js +251 -0
- package/lib/transport/api.js +170 -0
- package/lib/transport/folder.js +71 -0
- package/lib/transport/types.js +2 -0
- package/locale/zh.json +14 -0
- package/package.json +60 -3
package/lib/state.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
export const HISTORY_LIMIT = 200;
|
|
5
|
+
export const LOCK_STALE_MS = 90_000;
|
|
6
|
+
export function emptyState(deviceId = null) {
|
|
7
|
+
return { deviceId, auth: null, lastApplied: null, lastUploaded: null, files: {} };
|
|
8
|
+
}
|
|
9
|
+
export function loadState(statePath) {
|
|
10
|
+
try {
|
|
11
|
+
const raw = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
12
|
+
return { ...emptyState(), ...raw, files: raw.files ?? {} };
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return emptyState();
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function saveState(statePath, state) {
|
|
19
|
+
mkdirSync(dirname(statePath), { recursive: true });
|
|
20
|
+
const tmp = `${statePath}.tmp-${randomUUID().slice(0, 8)}`;
|
|
21
|
+
writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
|
|
22
|
+
renameSync(tmp, statePath);
|
|
23
|
+
}
|
|
24
|
+
/** 追加一条审计记录,只保留最近 200 行。 */
|
|
25
|
+
export function appendHistory(historyPath, entry) {
|
|
26
|
+
mkdirSync(dirname(historyPath), { recursive: true });
|
|
27
|
+
appendFileSync(historyPath, `${JSON.stringify(entry)}\n`);
|
|
28
|
+
const lines = readFileSync(historyPath, 'utf8').split('\n').filter(Boolean);
|
|
29
|
+
if (lines.length > HISTORY_LIMIT) {
|
|
30
|
+
writeFileSync(historyPath, `${lines.slice(-HISTORY_LIMIT).join('\n')}\n`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 获取单实例锁。
|
|
35
|
+
* 心跳超 staleMs 视为僵死,允许抢占并标注 stolen。
|
|
36
|
+
*/
|
|
37
|
+
export function acquireLock({ lockPath, pid = process.pid, now = Date.now(), staleMs = LOCK_STALE_MS, }) {
|
|
38
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
39
|
+
if (existsSync(lockPath)) {
|
|
40
|
+
let holder = null;
|
|
41
|
+
try {
|
|
42
|
+
holder = JSON.parse(readFileSync(lockPath, 'utf8'));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
holder = null;
|
|
46
|
+
}
|
|
47
|
+
const heartbeat = holder?.heartbeat ?? holder?.startedAt ?? 0;
|
|
48
|
+
if (holder && now - heartbeat < staleMs) {
|
|
49
|
+
return { acquired: false, holder, stolen: false };
|
|
50
|
+
}
|
|
51
|
+
writeFileSync(lockPath, JSON.stringify({ pid, startedAt: now, heartbeat: now }));
|
|
52
|
+
return { acquired: true, holder, stolen: true };
|
|
53
|
+
}
|
|
54
|
+
writeFileSync(lockPath, JSON.stringify({ pid, startedAt: now, heartbeat: now }));
|
|
55
|
+
return { acquired: true, holder: null, stolen: false };
|
|
56
|
+
}
|
|
57
|
+
export function heartbeatLock({ lockPath, pid = process.pid, now = Date.now() }) {
|
|
58
|
+
try {
|
|
59
|
+
const holder = JSON.parse(readFileSync(lockPath, 'utf8'));
|
|
60
|
+
if (holder.pid !== pid)
|
|
61
|
+
return false;
|
|
62
|
+
writeFileSync(lockPath, JSON.stringify({ ...holder, heartbeat: now }));
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function releaseLock({ lockPath, pid = process.pid }) {
|
|
70
|
+
try {
|
|
71
|
+
const holder = JSON.parse(readFileSync(lockPath, 'utf8'));
|
|
72
|
+
if (holder.pid !== pid)
|
|
73
|
+
return false;
|
|
74
|
+
rmSync(lockPath, { force: true });
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* 只清理本插件产生的包:前缀匹配 + 以 `-<deviceId>.tar.gz` 结尾。
|
|
83
|
+
* keep <= 0 表示不清理(§11)。
|
|
84
|
+
*/
|
|
85
|
+
export function cleanupPackages({ dir, deviceId = '', keep = 1, prefix = 'dsh-user-sync-', }) {
|
|
86
|
+
if (keep <= 0 || !existsSync(dir))
|
|
87
|
+
return { removed: [] };
|
|
88
|
+
const candidates = readdirSync(dir)
|
|
89
|
+
.filter((name) => name.startsWith(prefix) && name.endsWith('.tar.gz'))
|
|
90
|
+
.filter((name) => (deviceId ? name.endsWith(`-${deviceId}.tar.gz`) : true))
|
|
91
|
+
.map((name) => ({ name, mtime: statSync(join(dir, name)).mtimeMs }))
|
|
92
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
93
|
+
const removed = candidates.slice(keep).map((candidate) => candidate.name);
|
|
94
|
+
for (const name of removed)
|
|
95
|
+
rmSync(join(dir, name), { force: true });
|
|
96
|
+
return { removed };
|
|
97
|
+
}
|
|
98
|
+
/** 把快照目录下的全部文件按相对路径复制回目标根(支持多根映射)。 */
|
|
99
|
+
export function restoreSnapshot({ snapshotRoot, targetRoot, resolveTarget, }) {
|
|
100
|
+
if (!existsSync(snapshotRoot))
|
|
101
|
+
return { restored: [] };
|
|
102
|
+
const toAbs = resolveTarget ?? ((rel) => join(targetRoot, rel));
|
|
103
|
+
const restored = [];
|
|
104
|
+
for (const rel of walkFiles(snapshotRoot, '')) {
|
|
105
|
+
const from = join(snapshotRoot, rel);
|
|
106
|
+
const to = toAbs(rel);
|
|
107
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
108
|
+
copyFileSync(from, to);
|
|
109
|
+
restored.push(rel);
|
|
110
|
+
}
|
|
111
|
+
return { restored };
|
|
112
|
+
}
|
|
113
|
+
function walkFiles(root, prefix) {
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const dirent of readdirSync(join(root, prefix), { withFileTypes: true })) {
|
|
116
|
+
const rel = prefix ? `${prefix}/${dirent.name}` : dirent.name;
|
|
117
|
+
if (dirent.isDirectory())
|
|
118
|
+
out.push(...walkFiles(root, rel));
|
|
119
|
+
else if (dirent.isFile())
|
|
120
|
+
out.push(rel);
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
package/lib/sync.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { buildManifest, buildTarGz, MANIFEST_PATH, normalizeManifest, parseTarGz } from './archive.js';
|
|
4
|
+
import { applyEntries, planApply } from './apply.js';
|
|
5
|
+
import { hashBytes } from './hash.js';
|
|
6
|
+
import { decide } from './lww.js';
|
|
7
|
+
import { scanAssets, DEFAULT_MODULES } from './scan.js';
|
|
8
|
+
import { scanBuffer } from './secret.js';
|
|
9
|
+
import { acquireLock, appendHistory, loadState, releaseLock, saveState } from './state.js';
|
|
10
|
+
export function makePackageId({ deviceId = 'local0000', now = new Date() } = {}) {
|
|
11
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
12
|
+
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}`;
|
|
13
|
+
return `${stamp}-${String(deviceId).toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 16).padEnd(6, '0')}`;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* 同步编排:扫描 → 打包 → 判定 → 应用 → 审计。
|
|
17
|
+
* 不直接写界面状态;所有动作返回结果对象,由调用方决定展示与确认。
|
|
18
|
+
*/
|
|
19
|
+
export function createSync({ dshHome, userHome, profileName = 'desktop', transport, statePath, historyPath, lockPath, preRestoreRoot, modules, excludeGlobs = [], clockSkewMs, deviceId, }) {
|
|
20
|
+
const paths = {
|
|
21
|
+
state: statePath ?? join(dshHome, 'user-sync', 'state.json'),
|
|
22
|
+
history: historyPath ?? join(dshHome, 'user-sync', 'history.jsonl'),
|
|
23
|
+
lock: lockPath ?? join(dshHome, 'user-sync', 'lock'),
|
|
24
|
+
preRestore: preRestoreRoot ?? join(dshHome, 'user-sync', `pre-restore-${Date.now()}`),
|
|
25
|
+
};
|
|
26
|
+
/** 包内路径 → 绝对路径:skills/ 落到 userHome,其余落到 dshHome。 */
|
|
27
|
+
const resolveTarget = (rel) => {
|
|
28
|
+
if (rel.startsWith('skills/'))
|
|
29
|
+
return join(userHome, '.agents', 'skills', rel.slice('skills/'.length));
|
|
30
|
+
return join(dshHome, rel);
|
|
31
|
+
};
|
|
32
|
+
/** 活动传输:folder 用于调试,api 用于多用户。可在运行时切换。 */
|
|
33
|
+
let activeTransport = transport;
|
|
34
|
+
function setTransport(next) {
|
|
35
|
+
if (!next)
|
|
36
|
+
throw new Error('transport 不能为空');
|
|
37
|
+
activeTransport = next;
|
|
38
|
+
return activeTransport.id;
|
|
39
|
+
}
|
|
40
|
+
/** 运行时模块配置:显式参数优先,其次 state.json,最后默认四类用户级。会话不参与同步(体积大且不跨机)。 */
|
|
41
|
+
const modulesNow = () => {
|
|
42
|
+
const raw = modules ?? (Array.isArray(loadState(paths.state).modules) ? loadState(paths.state).modules : DEFAULT_MODULES);
|
|
43
|
+
return raw.filter((id) => id !== 'sessions');
|
|
44
|
+
};
|
|
45
|
+
function getModules() {
|
|
46
|
+
return modulesNow();
|
|
47
|
+
}
|
|
48
|
+
function setModules(list) {
|
|
49
|
+
const state = loadState(paths.state);
|
|
50
|
+
const next = Array.isArray(list) ? list.filter((id) => id !== 'sessions') : DEFAULT_MODULES;
|
|
51
|
+
state.modules = next;
|
|
52
|
+
saveState(paths.state, state);
|
|
53
|
+
return next;
|
|
54
|
+
}
|
|
55
|
+
function currentDeviceId() {
|
|
56
|
+
if (deviceId)
|
|
57
|
+
return deviceId;
|
|
58
|
+
const state = loadState(paths.state);
|
|
59
|
+
return state.deviceId ?? 'local0000';
|
|
60
|
+
}
|
|
61
|
+
function currentFiles() {
|
|
62
|
+
const { entries } = scanAssets({ dshHome, userHome, profileName, modules: modulesNow(), excludeGlobs });
|
|
63
|
+
const map = new Map();
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
if (entry.mode === '120000')
|
|
66
|
+
continue;
|
|
67
|
+
map.set(entry.path, hashBytes(readFileSync(entry.absPath)));
|
|
68
|
+
}
|
|
69
|
+
return { entries, map };
|
|
70
|
+
}
|
|
71
|
+
/** 扫描 + 敏感扫描 + 打包。命中敏感数据整包拒绝(G4)。 */
|
|
72
|
+
function pack() {
|
|
73
|
+
const { entries, errors } = scanAssets({ dshHome, userHome, profileName, modules: modulesNow(), excludeGlobs });
|
|
74
|
+
if (errors.length > 0)
|
|
75
|
+
return { ok: false, code: errors[0].code ?? 'E_SCAN', errors };
|
|
76
|
+
const hits = [];
|
|
77
|
+
const files = [];
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
if (entry.mode === '120000') {
|
|
80
|
+
files.push({ path: entry.path, sha256: hashBytes(Buffer.from(entry.linkTarget ?? '', 'utf8')), size: 0, mode: '120000' });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const data = readFileSync(entry.absPath);
|
|
84
|
+
const scan = scanBuffer(data, entry.path);
|
|
85
|
+
hits.push(...scan.hits);
|
|
86
|
+
files.push({ path: entry.path, sha256: hashBytes(data), size: data.length, mode: entry.mode });
|
|
87
|
+
}
|
|
88
|
+
if (hits.length > 0)
|
|
89
|
+
return { ok: false, code: 'E_SENSITIVE', hits };
|
|
90
|
+
const packageId = makePackageId({ deviceId: currentDeviceId() });
|
|
91
|
+
const manifest = buildManifest(files, {
|
|
92
|
+
packageId,
|
|
93
|
+
createdAt: new Date().toISOString(),
|
|
94
|
+
deviceId: currentDeviceId(),
|
|
95
|
+
platform: process.platform,
|
|
96
|
+
arch: process.arch,
|
|
97
|
+
profile: profileName,
|
|
98
|
+
modules: modulesNow(),
|
|
99
|
+
});
|
|
100
|
+
const tarEntries = [
|
|
101
|
+
{ path: MANIFEST_PATH, data: Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8') },
|
|
102
|
+
];
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
if (entry.mode === '120000') {
|
|
105
|
+
tarEntries.push({ path: entry.path, mode: '120000', type: 'symlink', linkTarget: entry.linkTarget });
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
tarEntries.push({ path: entry.path, data: readFileSync(entry.absPath), mode: entry.mode });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { ok: true, buffer: buildTarGz(tarEntries), manifest };
|
|
112
|
+
}
|
|
113
|
+
/** 上传当前本地包;上传前必须由调用方完成覆盖确认。 */
|
|
114
|
+
async function upload() {
|
|
115
|
+
const lock = acquireLock({ lockPath: paths.lock });
|
|
116
|
+
if (!lock.acquired)
|
|
117
|
+
return { ok: false, code: 'E_LOCKED', holder: lock.holder };
|
|
118
|
+
const started = Date.now();
|
|
119
|
+
try {
|
|
120
|
+
const packed = pack();
|
|
121
|
+
if (!packed.ok)
|
|
122
|
+
return packed;
|
|
123
|
+
const name = `dsh-user-sync-${packed.manifest.packageId}.tar.gz`;
|
|
124
|
+
await activeTransport.put(name, packed.buffer);
|
|
125
|
+
const state = loadState(paths.state);
|
|
126
|
+
state.lastUploaded = { packageId: packed.manifest.packageId, createdAt: packed.manifest.createdAt };
|
|
127
|
+
// 上传快照:check 用 state.files 判断「上传之后本机有没有再改」,
|
|
128
|
+
// 否则从未应用过本机时会把「刚上传」误判成两侧并发。
|
|
129
|
+
state.files = Object.fromEntries(packed.manifest.files.map((file) => [file.path, { sha256: file.sha256, size: file.size, uploadedAt: new Date().toISOString() }]));
|
|
130
|
+
state.deviceId = state.deviceId ?? currentDeviceId();
|
|
131
|
+
saveState(paths.state, state);
|
|
132
|
+
appendHistory(paths.history, {
|
|
133
|
+
at: new Date().toISOString(),
|
|
134
|
+
action: 'backup',
|
|
135
|
+
modules: packed.manifest.modules,
|
|
136
|
+
files: packed.manifest.files.length,
|
|
137
|
+
bytes: packed.buffer.length,
|
|
138
|
+
ok: true,
|
|
139
|
+
ms: Date.now() - started,
|
|
140
|
+
});
|
|
141
|
+
return { ok: true, packageId: packed.manifest.packageId, name, size: packed.buffer.length, manifest: packed.manifest };
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
appendHistory(paths.history, { at: new Date().toISOString(), action: 'backup', ok: false, ms: Date.now() - started });
|
|
145
|
+
return { ok: false, code: 'E_UPLOAD_FAILED', detail: error.message };
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
releaseLock({ lockPath: paths.lock });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** 只读远端元数据,返回判定结果;不下载、不写盘。 */
|
|
152
|
+
async function check() {
|
|
153
|
+
const remoteList = await activeTransport.list();
|
|
154
|
+
const latest = remoteList[0] ?? null;
|
|
155
|
+
const meta = latest ? await activeTransport.meta(latest.name) : null;
|
|
156
|
+
const state = loadState(paths.state);
|
|
157
|
+
const { map: localFiles } = currentFiles();
|
|
158
|
+
const appliedFiles = new Map(Object.entries(state.files ?? {}).map(([path, value]) => [path, value.sha256]));
|
|
159
|
+
const remote = meta
|
|
160
|
+
? { packageId: meta.packageId, createdAt: meta.createdAt }
|
|
161
|
+
: latest
|
|
162
|
+
? { packageId: latest.name, createdAt: latest.modifiedAt }
|
|
163
|
+
: null;
|
|
164
|
+
const decision = decide({
|
|
165
|
+
remote,
|
|
166
|
+
lastApplied: state.lastApplied,
|
|
167
|
+
lastUploaded: state.lastUploaded,
|
|
168
|
+
localFiles,
|
|
169
|
+
appliedFiles,
|
|
170
|
+
clockSkewMs,
|
|
171
|
+
});
|
|
172
|
+
let plan = null;
|
|
173
|
+
if (Array.isArray(meta?.files) && meta.files.length > 0) {
|
|
174
|
+
try {
|
|
175
|
+
plan = planApply({ manifestFiles: meta.files, localFiles });
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
plan = null; // 大小写冲突等由应用阶段拒绝,检查阶段不阻塞
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { decision, latest, meta, plan };
|
|
182
|
+
}
|
|
183
|
+
/** 下载并应用远端包;必须先由调用方完成覆盖确认。 */
|
|
184
|
+
async function applyRemote(name) {
|
|
185
|
+
const lock = acquireLock({ lockPath: paths.lock });
|
|
186
|
+
if (!lock.acquired)
|
|
187
|
+
return { ok: false, code: 'E_LOCKED', holder: lock.holder };
|
|
188
|
+
const started = Date.now();
|
|
189
|
+
try {
|
|
190
|
+
const buffer = await activeTransport.get(name);
|
|
191
|
+
const entries = parseTarGz(buffer);
|
|
192
|
+
const manifestEntry = entries.find((entry) => entry.path === MANIFEST_PATH);
|
|
193
|
+
if (!manifestEntry)
|
|
194
|
+
return { ok: false, code: 'E_HASH_MISMATCH', reason: 'manifest 缺失' };
|
|
195
|
+
const manifest = normalizeManifest(JSON.parse(manifestEntry.data.toString('utf8')));
|
|
196
|
+
for (const file of manifest.files) {
|
|
197
|
+
const entry = entries.find((item) => item.path === file.path);
|
|
198
|
+
if (!entry)
|
|
199
|
+
return { ok: false, code: 'E_HASH_MISMATCH', detail: `缺少文件 ${file.path}` };
|
|
200
|
+
if (entry.type === 'file' && hashBytes(entry.data) !== file.sha256) {
|
|
201
|
+
return { ok: false, code: 'E_HASH_MISMATCH', detail: `哈希不符 ${file.path}` };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
for (const entry of entries) {
|
|
205
|
+
if (entry.path === MANIFEST_PATH || entry.type !== 'file')
|
|
206
|
+
continue;
|
|
207
|
+
const scan = scanBuffer(entry.data, entry.path);
|
|
208
|
+
if (scan.hits.length > 0)
|
|
209
|
+
return { ok: false, code: 'E_SENSITIVE', hits: scan.hits };
|
|
210
|
+
}
|
|
211
|
+
const { map: localFiles } = currentFiles();
|
|
212
|
+
const plan = planApply({ manifestFiles: manifest.files, localFiles });
|
|
213
|
+
const applied = applyEntries({ entries, resolveTarget, preRestoreRoot: paths.preRestore });
|
|
214
|
+
// PLUG-06:包内 cordis.patch.yml 引用的环境变量在本机缺失时提示(「插件装了但用不了」)
|
|
215
|
+
const missingEnv = [];
|
|
216
|
+
for (const entry of entries) {
|
|
217
|
+
if (!/^profiles\/[^/]+\/cordis\.patch\.yml$/.test(entry.path))
|
|
218
|
+
continue;
|
|
219
|
+
const text = entry.data.toString('utf8');
|
|
220
|
+
for (const match of text.matchAll(/apiKeyEnv:\s*["']?([A-Za-z_][A-Za-z0-9_]*)/g)) {
|
|
221
|
+
const varName = match[1];
|
|
222
|
+
if (!process.env[varName] && !missingEnv.includes(varName))
|
|
223
|
+
missingEnv.push(varName);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const state = loadState(paths.state);
|
|
227
|
+
state.lastApplied = { packageId: manifest.packageId, createdAt: manifest.createdAt };
|
|
228
|
+
state.deviceId = state.deviceId ?? currentDeviceId();
|
|
229
|
+
state.files = Object.fromEntries(manifest.files.map((file) => [file.path, { sha256: file.sha256, size: file.size, appliedAt: new Date().toISOString() }]));
|
|
230
|
+
saveState(paths.state, state);
|
|
231
|
+
appendHistory(paths.history, {
|
|
232
|
+
at: new Date().toISOString(),
|
|
233
|
+
action: 'restore',
|
|
234
|
+
modules: manifest.modules,
|
|
235
|
+
files: applied.written.length,
|
|
236
|
+
skipped: applied.skipped.length,
|
|
237
|
+
ok: true,
|
|
238
|
+
ms: Date.now() - started,
|
|
239
|
+
});
|
|
240
|
+
return { ok: true, manifest, plan, missingEnv, ...applied };
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
appendHistory(paths.history, { at: new Date().toISOString(), action: 'restore', ok: false, ms: Date.now() - started });
|
|
244
|
+
return { ok: false, code: 'E_APPLY_FAILED', detail: error.message };
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
releaseLock({ lockPath: paths.lock });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return { paths, pack, upload, check, applyRemote, getModules, setModules, setTransport, getTransportId: () => activeTransport.id };
|
|
251
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { isPackageId } from '../archive.js';
|
|
4
|
+
const OFFICIAL_HOST = /(^|\.)deepseek\.com$/i;
|
|
5
|
+
export class ApiError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
status;
|
|
8
|
+
constructor(code, message, status) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.status = status;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* 运营方 API 传输(B 模型)。插件侧只持有用户自己的 token,不接触存储凭据。
|
|
16
|
+
* 认证:register 首次建账号,login 取回同一 token;token 存 auth.json(0600)。
|
|
17
|
+
*/
|
|
18
|
+
export function createApiTransport({ baseUrl, authPath, token: initialToken, fetchImpl = globalThis.fetch, timeoutMs = 30_000, retries = 3, }) {
|
|
19
|
+
if (!baseUrl)
|
|
20
|
+
throw new Error('缺少 baseUrl');
|
|
21
|
+
const host = new URL(baseUrl).hostname;
|
|
22
|
+
if (OFFICIAL_HOST.test(host)) {
|
|
23
|
+
throw new ApiError('E_OFFICIAL_HOST', `拒绝官方域名: ${host}`);
|
|
24
|
+
}
|
|
25
|
+
let token = initialToken ?? null;
|
|
26
|
+
if (!token && authPath && existsSync(authPath)) {
|
|
27
|
+
try {
|
|
28
|
+
token = JSON.parse(readFileSync(authPath, 'utf8')).token ?? null;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
token = null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function persistAuth(next) {
|
|
35
|
+
token = next.token;
|
|
36
|
+
if (!authPath)
|
|
37
|
+
return;
|
|
38
|
+
mkdirSync(dirname(authPath), { recursive: true });
|
|
39
|
+
writeFileSync(authPath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
40
|
+
}
|
|
41
|
+
function clearAuth() {
|
|
42
|
+
token = null;
|
|
43
|
+
if (authPath)
|
|
44
|
+
rmSync(authPath, { force: true });
|
|
45
|
+
}
|
|
46
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
47
|
+
async function request(method, path, { body, headers = {}, expectBytes = false } = {}) {
|
|
48
|
+
const url = `${baseUrl.replace(/\/$/, '')}${path}`;
|
|
49
|
+
let lastError;
|
|
50
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
51
|
+
try {
|
|
52
|
+
const response = await fetchImpl(url, {
|
|
53
|
+
method,
|
|
54
|
+
headers: {
|
|
55
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
56
|
+
...(body && !expectBytes ? { 'content-type': 'application/json' } : {}),
|
|
57
|
+
...headers,
|
|
58
|
+
},
|
|
59
|
+
body: body,
|
|
60
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
61
|
+
});
|
|
62
|
+
if (response.status === 401 || response.status === 403) {
|
|
63
|
+
throw new ApiError('E_REMOTE_AUTH', `认证失败(${response.status})`, response.status);
|
|
64
|
+
}
|
|
65
|
+
if (response.status >= 400 && response.status < 500) {
|
|
66
|
+
const detail = await response.text().catch(() => '');
|
|
67
|
+
throw new ApiError(`E_HTTP_${response.status}`, detail.slice(0, 200) || `HTTP ${response.status}`, response.status);
|
|
68
|
+
}
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
throw new ApiError('E_REMOTE_5XX', `服务端错误(${response.status})`, response.status);
|
|
71
|
+
}
|
|
72
|
+
return expectBytes ? Buffer.from(await response.arrayBuffer()) : response;
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
lastError = error;
|
|
76
|
+
const retryable = !(error instanceof ApiError) || error.code === 'E_REMOTE_5XX';
|
|
77
|
+
if (!retryable || attempt === retries)
|
|
78
|
+
throw error;
|
|
79
|
+
await sleep(200 * 2 ** attempt);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
throw lastError;
|
|
83
|
+
}
|
|
84
|
+
const parseJson = async (response) => {
|
|
85
|
+
const text = await response.text();
|
|
86
|
+
return text ? JSON.parse(text) : null;
|
|
87
|
+
};
|
|
88
|
+
return {
|
|
89
|
+
id: 'api',
|
|
90
|
+
baseUrl,
|
|
91
|
+
get token() {
|
|
92
|
+
return token;
|
|
93
|
+
},
|
|
94
|
+
/** 首次注册;已存在(409)时改走 login,取回同一个 token。 */
|
|
95
|
+
async register(accountKey) {
|
|
96
|
+
try {
|
|
97
|
+
const response = await request('POST', '/v1/auth/register', { body: JSON.stringify({ accountKey }) });
|
|
98
|
+
const data = await parseJson(response);
|
|
99
|
+
const token = String(data?.token ?? '');
|
|
100
|
+
persistAuth({ token, accountKeyHint: accountKey.slice(0, 6), registeredAt: new Date().toISOString() });
|
|
101
|
+
return { ok: true, token };
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (error.code === 'E_HTTP_409')
|
|
105
|
+
return this.login(accountKey);
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
async login(accountKey) {
|
|
110
|
+
const response = await request('POST', '/v1/auth/login', { body: JSON.stringify({ accountKey }) });
|
|
111
|
+
const data = await parseJson(response);
|
|
112
|
+
const token = String(data?.token ?? '');
|
|
113
|
+
persistAuth({ token, accountKeyHint: accountKey.slice(0, 6), registeredAt: new Date().toISOString() });
|
|
114
|
+
return { ok: true, token };
|
|
115
|
+
},
|
|
116
|
+
async reset() {
|
|
117
|
+
await request('POST', '/v1/auth/reset');
|
|
118
|
+
clearAuth();
|
|
119
|
+
return { ok: true };
|
|
120
|
+
},
|
|
121
|
+
async test() {
|
|
122
|
+
try {
|
|
123
|
+
const response = await request('GET', '/v1/quota');
|
|
124
|
+
const data = await parseJson(response);
|
|
125
|
+
return { ok: true, detail: `配额 ${data?.used ?? 0}/${data?.limit ?? 0} 字节` };
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
return { ok: false, detail: error.message };
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
async list() {
|
|
132
|
+
const response = await request('GET', '/v1/packages');
|
|
133
|
+
const data = await parseJson(response);
|
|
134
|
+
return (data?.packages ?? []).map((item) => ({
|
|
135
|
+
name: item.id,
|
|
136
|
+
size: item.size,
|
|
137
|
+
modifiedAt: item.createdAt,
|
|
138
|
+
}));
|
|
139
|
+
},
|
|
140
|
+
async meta(id) {
|
|
141
|
+
if (!isPackageId(id))
|
|
142
|
+
throw new ApiError('E_BAD_NAME', `非法包名: ${id}`);
|
|
143
|
+
const response = await request('GET', `/v1/packages/${encodeURIComponent(id)}/meta`);
|
|
144
|
+
return parseJson(response);
|
|
145
|
+
},
|
|
146
|
+
async get(id) {
|
|
147
|
+
if (!isPackageId(id))
|
|
148
|
+
throw new ApiError('E_BAD_NAME', `非法包名: ${id}`);
|
|
149
|
+
return request('GET', `/v1/packages/${encodeURIComponent(id)}`, { expectBytes: true });
|
|
150
|
+
},
|
|
151
|
+
async put(id, data) {
|
|
152
|
+
if (!isPackageId(id))
|
|
153
|
+
throw new ApiError('E_BAD_NAME', `非法包名: ${id}`);
|
|
154
|
+
await request('PUT', `/v1/packages/${encodeURIComponent(id)}`, {
|
|
155
|
+
body: data,
|
|
156
|
+
expectBytes: true,
|
|
157
|
+
headers: { 'content-type': 'application/octet-stream' },
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
async remove(id) {
|
|
161
|
+
if (!isPackageId(id))
|
|
162
|
+
throw new ApiError('E_BAD_NAME', `非法包名: ${id}`);
|
|
163
|
+
await request('DELETE', `/v1/packages/${encodeURIComponent(id)}`);
|
|
164
|
+
},
|
|
165
|
+
/** 导出我的全部数据(服务端打包当前包 + 元数据)。 */
|
|
166
|
+
async exportPackage() {
|
|
167
|
+
return request('GET', '/v1/export', { expectBytes: true });
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { isPackageId, MANIFEST_PATH, parseTarGz } from '../archive.js';
|
|
4
|
+
/**
|
|
5
|
+
* 同步目录传输(folder):自用/调试模式。
|
|
6
|
+
* 布局:<dir>/dsh-user-sync/<stamp>-<deviceId>.tar.gz(§6.2)
|
|
7
|
+
*/
|
|
8
|
+
export function createFolderTransport({ dir }) {
|
|
9
|
+
const ensureDir = () => {
|
|
10
|
+
mkdirSync(dir, { recursive: true });
|
|
11
|
+
};
|
|
12
|
+
const assertSafeName = (name) => {
|
|
13
|
+
if (!isPackageId(name))
|
|
14
|
+
throw new Error(`非法包名: ${name}`);
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
id: 'folder',
|
|
18
|
+
async test() {
|
|
19
|
+
try {
|
|
20
|
+
ensureDir();
|
|
21
|
+
const probe = join(dir, `.probe-${Date.now()}`);
|
|
22
|
+
writeFileSync(probe, 'ok');
|
|
23
|
+
rmSync(probe, { force: true });
|
|
24
|
+
return { ok: true, detail: `可写: ${dir}` };
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
return { ok: false, detail: error.message };
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
async list() {
|
|
31
|
+
ensureDir();
|
|
32
|
+
return readdirSync(dir)
|
|
33
|
+
.filter((name) => isPackageId(name))
|
|
34
|
+
.map((name) => ({
|
|
35
|
+
name,
|
|
36
|
+
size: statSync(join(dir, name)).size,
|
|
37
|
+
modifiedAt: statSync(join(dir, name)).mtime.toISOString(),
|
|
38
|
+
}))
|
|
39
|
+
.sort((a, b) => (a.modifiedAt < b.modifiedAt ? 1 : -1));
|
|
40
|
+
},
|
|
41
|
+
async meta(name) {
|
|
42
|
+
assertSafeName(name);
|
|
43
|
+
const abs = join(dir, name);
|
|
44
|
+
if (!existsSync(abs))
|
|
45
|
+
return null;
|
|
46
|
+
try {
|
|
47
|
+
const entries = parseTarGz(readFileSync(abs));
|
|
48
|
+
const manifest = entries.find((entry) => entry.path === MANIFEST_PATH);
|
|
49
|
+
if (!manifest)
|
|
50
|
+
return null;
|
|
51
|
+
return JSON.parse(manifest.data.toString('utf8'));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
async get(name) {
|
|
58
|
+
assertSafeName(name);
|
|
59
|
+
return readFileSync(join(dir, name));
|
|
60
|
+
},
|
|
61
|
+
async put(name, data) {
|
|
62
|
+
assertSafeName(name);
|
|
63
|
+
ensureDir();
|
|
64
|
+
writeFileSync(join(dir, name), data);
|
|
65
|
+
},
|
|
66
|
+
async remove(name) {
|
|
67
|
+
assertSafeName(name);
|
|
68
|
+
rmSync(join(dir, name), { force: true });
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
package/locale/zh.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "设置同步",
|
|
4
|
+
"description": "把技能、插件清单、配置与记忆在设备间对齐。"
|
|
5
|
+
},
|
|
6
|
+
"nav": "设置同步",
|
|
7
|
+
"empty": "四类用户级资产(技能 / 插件清单 / 配置 / 用户记忆)可在设备间同步。",
|
|
8
|
+
"status": {
|
|
9
|
+
"title": "状态",
|
|
10
|
+
"dshHome": "DSH 主目录",
|
|
11
|
+
"profile": "Profile",
|
|
12
|
+
"resolvedBy": "路径来源"
|
|
13
|
+
}
|
|
14
|
+
}
|