dsh-desktop-kit 2.2.0 → 2.7.2
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/CHANGELOG.md +86 -0
- package/README.md +113 -27
- package/client.js +3121 -32
- package/docs/AGENT-HARNESS.md +144 -0
- package/docs/ARCHITECTURE.md +38 -7
- package/docs/COMPATIBILITY.md +5 -3
- package/docs/DEVELOPMENT.md +56 -0
- package/docs/INSTALL.md +20 -11
- package/docs/LAPTOP-INSTALL.md +96 -0
- package/docs/ROADMAP-3.0.md +353 -0
- package/docs/ROADMAP.md +1 -1
- package/docs/SUBAGENTS.md +25 -0
- package/docs/TEST-0.2.2.md +6 -6
- package/docs/TEST-2.3.0.md +49 -0
- package/docs/TEST-2.7.2.md +46 -0
- package/docs/TOKENS.md +2 -2
- package/docs/TROUBLESHOOTING.md +27 -0
- package/docs/UI-QA.md +28 -0
- package/docs/images/peak-toast-peak.png +0 -0
- package/docs/images/peak-toast-soon.png +0 -0
- package/docs/preview/peak-toast.html +155 -0
- package/doctor.ps1 +122 -0
- package/index.js +18 -1
- package/install.ps1 +99 -0
- package/package.json +29 -6
- package/plugins/dsh-agent-extensions/README.md +99 -0
- package/plugins/dsh-agent-extensions/index.js +167 -0
- package/plugins/dsh-agent-extensions/lib/agents.js +76 -0
- package/plugins/dsh-agent-extensions/lib/checkpoints.js +103 -0
- package/plugins/dsh-agent-extensions/lib/commands.js +255 -0
- package/plugins/dsh-agent-extensions/lib/hooks.js +182 -0
- package/plugins/dsh-agent-extensions/lib/policy.js +84 -0
- package/plugins/dsh-agent-extensions/lib/project.js +144 -0
- package/plugins/dsh-agent-extensions/lib/scaffold.js +151 -0
- package/plugins/dsh-agent-extensions/package.json +9 -0
- package/plugins/dsh-browser-qa/README.md +221 -0
- package/plugins/dsh-browser-qa/cordis.patch.yml +4 -0
- package/plugins/dsh-browser-qa/index.js +165 -0
- package/plugins/dsh-browser-qa/lib/cdp.js +224 -0
- package/plugins/dsh-browser-qa/lib/chrome.js +466 -0
- package/plugins/dsh-browser-qa/lib/pages.js +552 -0
- package/plugins/dsh-browser-qa/lib/qa.js +827 -0
- package/plugins/dsh-browser-qa/lib/ui-gate.js +256 -0
- package/plugins/dsh-browser-qa/package.json +31 -0
- package/plugins/dsh-checkpoints/README.md +22 -0
- package/plugins/dsh-checkpoints/cordis.patch.yml +3 -0
- package/plugins/dsh-checkpoints/index.js +80 -0
- package/plugins/dsh-checkpoints/lib/checkpoints.js +111 -0
- package/plugins/dsh-checkpoints/package.json +25 -0
- package/plugins/dsh-github-review/README.md +131 -0
- package/plugins/dsh-github-review/cordis.patch.yml +3 -0
- package/plugins/dsh-github-review/index.js +536 -0
- package/plugins/dsh-github-review/lib/gh.js +494 -0
- package/plugins/dsh-github-review/lib/normalize.js +662 -0
- package/plugins/dsh-github-review/package.json +28 -0
- package/plugins/dsh-guardrails/README.md +264 -0
- package/plugins/dsh-guardrails/cordis.patch.yml +5 -0
- package/plugins/dsh-guardrails/index.js +389 -0
- package/plugins/dsh-guardrails/lib/hooks.js +262 -0
- package/plugins/dsh-guardrails/lib/modes.js +77 -0
- package/plugins/dsh-guardrails/lib/policy.js +592 -0
- package/plugins/dsh-guardrails/lib/scan.js +451 -0
- package/plugins/dsh-guardrails/lib/secrets.js +274 -0
- package/plugins/dsh-guardrails/package.json +26 -0
- package/plugins/dsh-mcp-manager/README.md +78 -0
- package/plugins/dsh-mcp-manager/client.js +373 -0
- package/plugins/dsh-mcp-manager/index.js +159 -0
- package/plugins/dsh-mcp-manager/lib/api.js +250 -0
- package/plugins/dsh-mcp-manager/lib/client.js +219 -0
- package/plugins/dsh-mcp-manager/lib/config.js +217 -0
- package/plugins/dsh-mcp-manager/lib/manager.js +146 -0
- package/plugins/dsh-mcp-manager/package.json +18 -0
- package/plugins/dsh-mcp-manager/screenshots/modal-390.png +0 -0
- package/plugins/dsh-mcp-manager/screenshots/settings-1440.png +0 -0
- package/plugins/dsh-mcp-manager/screenshots/settings-390.png +0 -0
- package/plugins/dsh-memory-bridge/README.md +161 -0
- package/plugins/dsh-memory-bridge/client.js +2264 -0
- package/plugins/dsh-memory-bridge/cordis.patch.yml +5 -0
- package/plugins/dsh-memory-bridge/docs/images/conflicts.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/hero.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/settings-dark.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/settings-light.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/settings-mobile.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/setup.png +0 -0
- package/plugins/dsh-memory-bridge/icon.svg +13 -0
- package/plugins/dsh-memory-bridge/index.js +378 -0
- package/plugins/dsh-memory-bridge/lib/bridge.js +636 -0
- package/plugins/dsh-memory-bridge/lib/github.js +473 -0
- package/plugins/dsh-memory-bridge/lib/merge.js +205 -0
- package/plugins/dsh-memory-bridge/lib/portable.js +314 -0
- package/plugins/dsh-memory-bridge/lib/state.js +565 -0
- package/plugins/dsh-memory-bridge/lib/sync.js +984 -0
- package/plugins/dsh-memory-bridge/locale/en.json +6 -0
- package/plugins/dsh-memory-bridge/locale/ru.json +6 -0
- package/plugins/dsh-memory-bridge/package.json +67 -0
- package/plugins/dsh-peak-guard/README.md +16 -3
- package/plugins/dsh-peak-guard/client.js +223 -8
- package/plugins/dsh-peak-guard/package.json +1 -1
- package/plugins/dsh-plan-mode/README.md +24 -0
- package/plugins/dsh-plan-mode/cordis.patch.yml +3 -0
- package/plugins/dsh-plan-mode/index.js +103 -0
- package/plugins/dsh-plan-mode/lib/plan.js +89 -0
- package/plugins/dsh-plan-mode/package.json +18 -0
- package/plugins/dsh-profile-transfer/client.js +277 -17
- package/plugins/dsh-profile-transfer/index.js +10 -0
- package/plugins/dsh-profile-transfer/package.json +3 -1
- package/plugins/dsh-profile-transfer/transfer.js +101 -2
- package/plugins/dsh-project-memory/index.js +92 -2
- package/plugins/dsh-project-memory/package.json +1 -1
- package/plugins/dsh-repo-map/README.md +26 -0
- package/plugins/dsh-repo-map/cordis.patch.yml +3 -0
- package/plugins/dsh-repo-map/index.js +99 -0
- package/plugins/dsh-repo-map/lib/repo-map.js +170 -0
- package/plugins/dsh-repo-map/package.json +25 -0
- package/plugins/dsh-skill-autopilot/index.js +135 -11
- package/skills/delivery-gate/SKILL.md +31 -0
- package/skills/git-and-ci/SKILL.md +5 -1
- package/skills/manifest.json +31 -6
- package/skills/requirements-and-planning/SKILL.md +3 -0
- package/skills/research-first/SKILL.md +35 -0
- package/skills/research-first/references/query-patterns.md +20 -0
- package/skills/subagent-playbook/SKILL.md +36 -0
- package/skills/subagent-playbook/references/prompts.md +39 -0
- package/skills/ui-craft/SKILL.md +43 -0
- package/skills/visual-qa/SKILL.md +29 -0
- package/skills/visual-qa/references/commands.md +32 -0
- package/skills-baseline.json +246 -0
- package/uninstall.ps1 +74 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portable project-memory export/import for a USB drive (or any local folder).
|
|
3
|
+
*
|
|
4
|
+
* The canonical payload is a plain folder:
|
|
5
|
+
*
|
|
6
|
+
* <destination>/dsh-memory-portable/
|
|
7
|
+
* manifest.json format, checksum, machine, projects
|
|
8
|
+
* README.md human instructions
|
|
9
|
+
* projects/<slug>--<key8>/*.md redacted dossiers, no AUTO-SCAN blocks
|
|
10
|
+
*
|
|
11
|
+
* The manifest is the trust boundary: paths are validated, every file is
|
|
12
|
+
* SHA-256 checked, and import always backs up an existing local dossier first.
|
|
13
|
+
* No GitHub, network, credentials or LLM calls are involved.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fsp from "node:fs/promises";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
BridgeError,
|
|
22
|
+
DEFAULT_LIMITS,
|
|
23
|
+
canonicalizePaths,
|
|
24
|
+
ensureDir,
|
|
25
|
+
exists,
|
|
26
|
+
isAllowedSyncPath,
|
|
27
|
+
redactSecrets,
|
|
28
|
+
safeRelPath,
|
|
29
|
+
sha256,
|
|
30
|
+
writeFileAtomic,
|
|
31
|
+
writeJsonAtomic,
|
|
32
|
+
} from "./bridge.js";
|
|
33
|
+
import { localFileText, syncText } from "./github.js";
|
|
34
|
+
import * as st from "./state.js";
|
|
35
|
+
|
|
36
|
+
export const PORTABLE_FORMAT = "dsh-memory-portable";
|
|
37
|
+
export const PORTABLE_VERSION = 1;
|
|
38
|
+
const FOLDER_NAME = "dsh-memory-portable";
|
|
39
|
+
const MANIFEST_NAME = "manifest.json";
|
|
40
|
+
const README_NAME = "README.md";
|
|
41
|
+
const MAX_MANIFEST_BYTES = 2 * 1024 * 1024;
|
|
42
|
+
|
|
43
|
+
function portableRoot(source) {
|
|
44
|
+
const raw = String(source ?? "").trim();
|
|
45
|
+
if (raw.length === 0) throw new BridgeError("portable/no-path", "укажите папку флешки, например E:\\");
|
|
46
|
+
const resolved = path.resolve(raw);
|
|
47
|
+
return path.basename(resolved).toLowerCase() === FOLDER_NAME ? resolved : path.join(resolved, FOLDER_NAME);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function stableValue(value) {
|
|
51
|
+
if (Array.isArray(value)) return value.map(stableValue);
|
|
52
|
+
if (value !== null && typeof value === "object") {
|
|
53
|
+
const result = {};
|
|
54
|
+
for (const key of Object.keys(value).sort()) result[key] = stableValue(value[key]);
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function manifestChecksum(payload) {
|
|
61
|
+
return sha256(JSON.stringify(stableValue(payload)));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function slugSafe(value, label) {
|
|
65
|
+
const slug = String(value ?? "").trim();
|
|
66
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,79}$/u.test(slug)) throw new BridgeError("portable/bad-manifest", `${label}: некорректный slug`);
|
|
67
|
+
return slug;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function folderSafe(value) {
|
|
71
|
+
const folder = String(value ?? "").trim();
|
|
72
|
+
if (folder.length === 0 || folder.includes("/") || folder.includes("\\") || folder === "." || folder === "..") {
|
|
73
|
+
throw new BridgeError("portable/bad-manifest", "некорректная папка проекта в манифесте");
|
|
74
|
+
}
|
|
75
|
+
safeRelPath(folder);
|
|
76
|
+
return folder;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function projectFolder(target) {
|
|
80
|
+
const slug = String(target.slug ?? "project").replace(/[^A-Za-z0-9._-]+/gu, "-").slice(0, 40) || "project";
|
|
81
|
+
return `${slug}--${String(target.projectKey ?? "").slice(0, 8)}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function readManifest(root) {
|
|
85
|
+
const file = path.join(root, MANIFEST_NAME);
|
|
86
|
+
let stat;
|
|
87
|
+
try {
|
|
88
|
+
stat = await fsp.stat(file);
|
|
89
|
+
} catch {
|
|
90
|
+
throw new BridgeError("portable/no-manifest", "на флешке нет dsh-memory-portable/manifest.json — сначала сохраните проекты из плагина");
|
|
91
|
+
}
|
|
92
|
+
if (!stat.isFile() || stat.size > MAX_MANIFEST_BYTES) throw new BridgeError("portable/bad-manifest", "manifest.json слишком большой или не файл");
|
|
93
|
+
let manifest;
|
|
94
|
+
try {
|
|
95
|
+
manifest = JSON.parse(await fsp.readFile(file, "utf8"));
|
|
96
|
+
} catch {
|
|
97
|
+
throw new BridgeError("portable/bad-manifest", "manifest.json повреждён (не JSON)");
|
|
98
|
+
}
|
|
99
|
+
if (manifest === null || typeof manifest !== "object" || manifest.format !== PORTABLE_FORMAT) {
|
|
100
|
+
throw new BridgeError("portable/bad-manifest", "это не переносимый каталог памяти DSH");
|
|
101
|
+
}
|
|
102
|
+
if (manifest.version !== PORTABLE_VERSION) {
|
|
103
|
+
throw new BridgeError("portable/bad-version", `версия формата ${String(manifest.version)} не поддерживается (ожидается ${PORTABLE_VERSION})`);
|
|
104
|
+
}
|
|
105
|
+
if (!Array.isArray(manifest.projects)) throw new BridgeError("portable/bad-manifest", "в манифесте нет списка проектов");
|
|
106
|
+
const expected = manifestChecksum({ ...manifest, checksum: undefined });
|
|
107
|
+
if (manifest.checksum !== expected) throw new BridgeError("portable/tampered", "контрольная сумма манифеста не совпадает — флешка изменена");
|
|
108
|
+
return manifest;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function readPortableFile(projectDir, relativePath) {
|
|
112
|
+
const safe = safeRelPath(relativePath);
|
|
113
|
+
const file = path.join(projectDir, ...String(safe).split("/"));
|
|
114
|
+
let stat;
|
|
115
|
+
try {
|
|
116
|
+
stat = await fsp.stat(file);
|
|
117
|
+
} catch {
|
|
118
|
+
throw new BridgeError("portable/missing-file", `на флешке нет файла ${relativePath}`);
|
|
119
|
+
}
|
|
120
|
+
if (!stat.isFile() || stat.size > DEFAULT_LIMITS.maxFileBytes) {
|
|
121
|
+
throw new BridgeError("portable/bad-file", `файл ${relativePath} слишком большой или не файл`);
|
|
122
|
+
}
|
|
123
|
+
return fsp.readFile(file, "utf8");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function localCanonicalFiles(directory, home) {
|
|
127
|
+
const raw = await st.readLocalDossier(directory, {
|
|
128
|
+
maxFileBytes: DEFAULT_LIMITS.maxFileBytes,
|
|
129
|
+
maxBundleBytes: DEFAULT_LIMITS.maxBundleBytes,
|
|
130
|
+
maxFiles: DEFAULT_LIMITS.maxFiles,
|
|
131
|
+
});
|
|
132
|
+
const map = new Map();
|
|
133
|
+
for (const [relativePath, text] of raw.raw) {
|
|
134
|
+
if (!isAllowedSyncPath(relativePath)) continue;
|
|
135
|
+
const canonical = redactSecrets(canonicalizePaths(syncText(relativePath, text), home)).trimEnd();
|
|
136
|
+
if (canonical.length > 0) map.set(relativePath, canonical);
|
|
137
|
+
}
|
|
138
|
+
return map;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Export every locally registered project dossier into a portable folder.
|
|
143
|
+
* @returns {Promise<{root:string, projects:Array, files:number, bytes:number}>}
|
|
144
|
+
*/
|
|
145
|
+
export async function exportPortable({ home, destination, targets, machine }) {
|
|
146
|
+
const root = portableRoot(destination);
|
|
147
|
+
await ensureDir(root);
|
|
148
|
+
await ensureDir(path.join(root, "projects"));
|
|
149
|
+
const projects = [];
|
|
150
|
+
let filesCount = 0;
|
|
151
|
+
let bytes = 0;
|
|
152
|
+
for (const target of targets) {
|
|
153
|
+
const files = await localCanonicalFiles(target.directory, home);
|
|
154
|
+
if (files.size === 0) continue;
|
|
155
|
+
const folder = projectFolder(target);
|
|
156
|
+
const projectDir = path.join(root, "projects", folder);
|
|
157
|
+
await ensureDir(projectDir);
|
|
158
|
+
const entries = [];
|
|
159
|
+
for (const [relativePath, canonical] of [...files].sort(([a], [b]) => a.localeCompare(b))) {
|
|
160
|
+
safeRelPath(relativePath);
|
|
161
|
+
if (!isAllowedSyncPath(relativePath)) continue;
|
|
162
|
+
const portableText = canonical.trimEnd();
|
|
163
|
+
if (portableText.length === 0) continue;
|
|
164
|
+
const targetFile = path.join(projectDir, ...relativePath.split("/"));
|
|
165
|
+
await writeFileAtomic(targetFile, `${portableText}\n`);
|
|
166
|
+
entries.push({ path: relativePath, bytes: Buffer.byteLength(portableText, "utf8"), sha256: sha256(portableText) });
|
|
167
|
+
filesCount += 1;
|
|
168
|
+
bytes += Buffer.byteLength(portableText, "utf8");
|
|
169
|
+
}
|
|
170
|
+
if (entries.length > 0) {
|
|
171
|
+
projects.push({
|
|
172
|
+
slug: target.slug,
|
|
173
|
+
name: target.name,
|
|
174
|
+
folder,
|
|
175
|
+
projectKey: target.projectKey,
|
|
176
|
+
files: entries,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const createdAt = new Date().toISOString();
|
|
181
|
+
const payload = {
|
|
182
|
+
format: PORTABLE_FORMAT,
|
|
183
|
+
version: PORTABLE_VERSION,
|
|
184
|
+
createdAt,
|
|
185
|
+
machine: machine ? { label: String(machine.label ?? ""), id: String(machine.machineId ?? machine.id ?? "") } : null,
|
|
186
|
+
projects,
|
|
187
|
+
checksum: "",
|
|
188
|
+
};
|
|
189
|
+
payload.checksum = manifestChecksum({ ...payload, checksum: undefined });
|
|
190
|
+
await writeJsonAtomic(path.join(root, MANIFEST_NAME), payload, { mode: 0o600 });
|
|
191
|
+
await writeFileAtomic(
|
|
192
|
+
path.join(root, README_NAME),
|
|
193
|
+
[
|
|
194
|
+
"# dsh-memory-portable",
|
|
195
|
+
"",
|
|
196
|
+
"Перенос памяти проектов DeepSeek Harness через флешку.",
|
|
197
|
+
"",
|
|
198
|
+
"## Как восстановить на другой машине",
|
|
199
|
+
"",
|
|
200
|
+
"1. Подключите флешку к компьютеру.",
|
|
201
|
+
"2. Откройте DSH → Настройки → «Память проектов» → раздел «Флешка».",
|
|
202
|
+
"3. Укажите путь к флешке (например, `E:\\`) и нажмите «Сканировать».",
|
|
203
|
+
"4. Проверьте список проектов и нажмите «Импортировать».",
|
|
204
|
+
"",
|
|
205
|
+
"Плагин сохранит текущие досье в бэкап и восстановит память проектов.",
|
|
206
|
+
"Секреты и PII вычищаются при экспорте; AUTO-SCAN-блоки не переносятся.",
|
|
207
|
+
"",
|
|
208
|
+
`Формат: ${PORTABLE_FORMAT} v${PORTABLE_VERSION}`,
|
|
209
|
+
"",
|
|
210
|
+
].join("\n"),
|
|
211
|
+
{ mode: 0o600 },
|
|
212
|
+
);
|
|
213
|
+
return { root, createdAt, projects, files: filesCount, bytes };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Validate a portable folder and compare it with local dossiers.
|
|
218
|
+
* @returns {Promise<{root:string, manifest:object, projects:Array}>}
|
|
219
|
+
*/
|
|
220
|
+
export async function scanPortable({ home, source, targets }) {
|
|
221
|
+
const root = portableRoot(source);
|
|
222
|
+
if (!(await exists(root))) throw new BridgeError("portable/no-folder", `папка не найдена: ${root}`);
|
|
223
|
+
const manifest = await readManifest(root);
|
|
224
|
+
const localBySlug = new Map(targets.map((target) => [target.slug, target]));
|
|
225
|
+
const projects = [];
|
|
226
|
+
for (const entry of manifest.projects) {
|
|
227
|
+
if (entry === null || typeof entry !== "object") throw new BridgeError("portable/bad-manifest", "некорректная запись проекта");
|
|
228
|
+
const slug = slugSafe(entry.slug, "project");
|
|
229
|
+
const folder = folderSafe(entry.folder);
|
|
230
|
+
if (!Array.isArray(entry.files) || entry.files.length === 0) throw new BridgeError("portable/bad-manifest", `${slug}: no files`);
|
|
231
|
+
let same = false;
|
|
232
|
+
for (const file of entry.files) {
|
|
233
|
+
if (file === null || typeof file !== "object") throw new BridgeError("portable/bad-manifest", `${slug}: bad file entry`);
|
|
234
|
+
const relativePath = safeRelPath(file.path);
|
|
235
|
+
if (!isAllowedSyncPath(relativePath)) throw new BridgeError("portable/bad-manifest", `${slug}: path not allowed`);
|
|
236
|
+
const text = await readPortableFile(path.join(root, "projects", folder), relativePath);
|
|
237
|
+
const canonical = text.replace(/\r\n/gu, "\n").trimEnd();
|
|
238
|
+
if (sha256(canonical) !== file.sha256) throw new BridgeError("portable/tampered", `${slug}/${relativePath}: контрольная сумма файла не совпадает`);
|
|
239
|
+
if (Number(file.bytes) !== Buffer.byteLength(canonical, "utf8")) throw new BridgeError("portable/tampered", `${slug}/${relativePath}: размер файла не совпадает`);
|
|
240
|
+
}
|
|
241
|
+
const target = localBySlug.get(slug) ?? null;
|
|
242
|
+
if (target) {
|
|
243
|
+
const local = await localCanonicalFiles(target.directory, home);
|
|
244
|
+
same = local.size === entry.files.length;
|
|
245
|
+
if (same) {
|
|
246
|
+
for (const file of entry.files) {
|
|
247
|
+
if (local.get(file.path) === undefined || sha256(local.get(file.path)) !== file.sha256) {
|
|
248
|
+
same = false;
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
projects.push({
|
|
255
|
+
slug,
|
|
256
|
+
name: typeof entry.name === "string" && entry.name.length > 0 ? entry.name : slug,
|
|
257
|
+
folder,
|
|
258
|
+
files: entry.files.length,
|
|
259
|
+
status: target === null ? "new" : same ? "same" : "update",
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return { root, createdAt: manifest.createdAt ?? null, machine: manifest.machine ?? null, projects };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Import selected projects from a portable folder into local dossiers.
|
|
267
|
+
* Default strategy rewrites the dossier, but always backs up an existing one first.
|
|
268
|
+
* @returns {Promise<{root:string, imported:number, skipped:number, projects:Array}>}
|
|
269
|
+
*/
|
|
270
|
+
export async function importPortable({ home, source, targets, projectSlugs, strategy = "replace" }) {
|
|
271
|
+
const root = portableRoot(source);
|
|
272
|
+
const manifest = await readManifest(root);
|
|
273
|
+
const selected = Array.isArray(projectSlugs) && projectSlugs.length > 0 ? new Set(projectSlugs.map(String)) : null;
|
|
274
|
+
const localBySlug = new Map(targets.map((target) => [target.slug, target]));
|
|
275
|
+
const projects = [];
|
|
276
|
+
let imported = 0;
|
|
277
|
+
let skipped = 0;
|
|
278
|
+
for (const entry of manifest.projects) {
|
|
279
|
+
const slug = slugSafe(entry.slug, "project");
|
|
280
|
+
if (selected !== null && !selected.has(slug)) continue;
|
|
281
|
+
const folder = folderSafe(entry.folder);
|
|
282
|
+
if (!Array.isArray(entry.files) || entry.files.length === 0) continue;
|
|
283
|
+
const target = localBySlug.get(slug) ?? null;
|
|
284
|
+
const directory = target?.directory ?? path.join(st.pathsFor(home).projectsMemory, slug);
|
|
285
|
+
const current = await st.readLocalDossier(directory, {
|
|
286
|
+
maxFileBytes: DEFAULT_LIMITS.maxFileBytes,
|
|
287
|
+
maxBundleBytes: DEFAULT_LIMITS.maxBundleBytes,
|
|
288
|
+
maxFiles: DEFAULT_LIMITS.maxFiles,
|
|
289
|
+
});
|
|
290
|
+
if (strategy === "skip" && current.raw.size > 0) {
|
|
291
|
+
skipped += 1;
|
|
292
|
+
projects.push({ slug, status: "skipped", files: entry.files.length });
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (current.raw.size > 0) {
|
|
296
|
+
await st.backupDossier(home, slug, directory, { maxFileBytes: DEFAULT_LIMITS.maxFileBytes });
|
|
297
|
+
}
|
|
298
|
+
await ensureDir(directory);
|
|
299
|
+
let written = 0;
|
|
300
|
+
for (const file of entry.files) {
|
|
301
|
+
const relativePath = safeRelPath(file.path);
|
|
302
|
+
if (!isAllowedSyncPath(relativePath)) continue;
|
|
303
|
+
const text = await readPortableFile(path.join(root, "projects", folder), relativePath);
|
|
304
|
+
const canonical = text.replace(/\r\n/gu, "\n").trimEnd();
|
|
305
|
+
if (sha256(canonical) !== file.sha256) throw new BridgeError("portable/tampered", `${slug}/${relativePath}: файл изменён после сканирования`);
|
|
306
|
+
const next = localFileText(relativePath, canonical, current.raw.get(relativePath) ?? "");
|
|
307
|
+
await writeFileAtomic(path.join(directory, ...relativePath.split("/")), next);
|
|
308
|
+
written += 1;
|
|
309
|
+
}
|
|
310
|
+
imported += 1;
|
|
311
|
+
projects.push({ slug, status: target === null ? "imported-new" : "imported", files: written });
|
|
312
|
+
}
|
|
313
|
+
return { root, imported, skipped, projects };
|
|
314
|
+
}
|