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.
Files changed (128) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +113 -27
  3. package/client.js +3121 -32
  4. package/docs/AGENT-HARNESS.md +144 -0
  5. package/docs/ARCHITECTURE.md +38 -7
  6. package/docs/COMPATIBILITY.md +5 -3
  7. package/docs/DEVELOPMENT.md +56 -0
  8. package/docs/INSTALL.md +20 -11
  9. package/docs/LAPTOP-INSTALL.md +96 -0
  10. package/docs/ROADMAP-3.0.md +353 -0
  11. package/docs/ROADMAP.md +1 -1
  12. package/docs/SUBAGENTS.md +25 -0
  13. package/docs/TEST-0.2.2.md +6 -6
  14. package/docs/TEST-2.3.0.md +49 -0
  15. package/docs/TEST-2.7.2.md +46 -0
  16. package/docs/TOKENS.md +2 -2
  17. package/docs/TROUBLESHOOTING.md +27 -0
  18. package/docs/UI-QA.md +28 -0
  19. package/docs/images/peak-toast-peak.png +0 -0
  20. package/docs/images/peak-toast-soon.png +0 -0
  21. package/docs/preview/peak-toast.html +155 -0
  22. package/doctor.ps1 +122 -0
  23. package/index.js +18 -1
  24. package/install.ps1 +99 -0
  25. package/package.json +29 -6
  26. package/plugins/dsh-agent-extensions/README.md +99 -0
  27. package/plugins/dsh-agent-extensions/index.js +167 -0
  28. package/plugins/dsh-agent-extensions/lib/agents.js +76 -0
  29. package/plugins/dsh-agent-extensions/lib/checkpoints.js +103 -0
  30. package/plugins/dsh-agent-extensions/lib/commands.js +255 -0
  31. package/plugins/dsh-agent-extensions/lib/hooks.js +182 -0
  32. package/plugins/dsh-agent-extensions/lib/policy.js +84 -0
  33. package/plugins/dsh-agent-extensions/lib/project.js +144 -0
  34. package/plugins/dsh-agent-extensions/lib/scaffold.js +151 -0
  35. package/plugins/dsh-agent-extensions/package.json +9 -0
  36. package/plugins/dsh-browser-qa/README.md +221 -0
  37. package/plugins/dsh-browser-qa/cordis.patch.yml +4 -0
  38. package/plugins/dsh-browser-qa/index.js +165 -0
  39. package/plugins/dsh-browser-qa/lib/cdp.js +224 -0
  40. package/plugins/dsh-browser-qa/lib/chrome.js +466 -0
  41. package/plugins/dsh-browser-qa/lib/pages.js +552 -0
  42. package/plugins/dsh-browser-qa/lib/qa.js +827 -0
  43. package/plugins/dsh-browser-qa/lib/ui-gate.js +256 -0
  44. package/plugins/dsh-browser-qa/package.json +31 -0
  45. package/plugins/dsh-checkpoints/README.md +22 -0
  46. package/plugins/dsh-checkpoints/cordis.patch.yml +3 -0
  47. package/plugins/dsh-checkpoints/index.js +80 -0
  48. package/plugins/dsh-checkpoints/lib/checkpoints.js +111 -0
  49. package/plugins/dsh-checkpoints/package.json +25 -0
  50. package/plugins/dsh-github-review/README.md +131 -0
  51. package/plugins/dsh-github-review/cordis.patch.yml +3 -0
  52. package/plugins/dsh-github-review/index.js +536 -0
  53. package/plugins/dsh-github-review/lib/gh.js +494 -0
  54. package/plugins/dsh-github-review/lib/normalize.js +662 -0
  55. package/plugins/dsh-github-review/package.json +28 -0
  56. package/plugins/dsh-guardrails/README.md +264 -0
  57. package/plugins/dsh-guardrails/cordis.patch.yml +5 -0
  58. package/plugins/dsh-guardrails/index.js +389 -0
  59. package/plugins/dsh-guardrails/lib/hooks.js +262 -0
  60. package/plugins/dsh-guardrails/lib/modes.js +77 -0
  61. package/plugins/dsh-guardrails/lib/policy.js +592 -0
  62. package/plugins/dsh-guardrails/lib/scan.js +451 -0
  63. package/plugins/dsh-guardrails/lib/secrets.js +274 -0
  64. package/plugins/dsh-guardrails/package.json +26 -0
  65. package/plugins/dsh-mcp-manager/README.md +78 -0
  66. package/plugins/dsh-mcp-manager/client.js +373 -0
  67. package/plugins/dsh-mcp-manager/index.js +159 -0
  68. package/plugins/dsh-mcp-manager/lib/api.js +250 -0
  69. package/plugins/dsh-mcp-manager/lib/client.js +219 -0
  70. package/plugins/dsh-mcp-manager/lib/config.js +217 -0
  71. package/plugins/dsh-mcp-manager/lib/manager.js +146 -0
  72. package/plugins/dsh-mcp-manager/package.json +18 -0
  73. package/plugins/dsh-mcp-manager/screenshots/modal-390.png +0 -0
  74. package/plugins/dsh-mcp-manager/screenshots/settings-1440.png +0 -0
  75. package/plugins/dsh-mcp-manager/screenshots/settings-390.png +0 -0
  76. package/plugins/dsh-memory-bridge/README.md +161 -0
  77. package/plugins/dsh-memory-bridge/client.js +2264 -0
  78. package/plugins/dsh-memory-bridge/cordis.patch.yml +5 -0
  79. package/plugins/dsh-memory-bridge/docs/images/conflicts.png +0 -0
  80. package/plugins/dsh-memory-bridge/docs/images/hero.png +0 -0
  81. package/plugins/dsh-memory-bridge/docs/images/settings-dark.png +0 -0
  82. package/plugins/dsh-memory-bridge/docs/images/settings-light.png +0 -0
  83. package/plugins/dsh-memory-bridge/docs/images/settings-mobile.png +0 -0
  84. package/plugins/dsh-memory-bridge/docs/images/setup.png +0 -0
  85. package/plugins/dsh-memory-bridge/icon.svg +13 -0
  86. package/plugins/dsh-memory-bridge/index.js +378 -0
  87. package/plugins/dsh-memory-bridge/lib/bridge.js +636 -0
  88. package/plugins/dsh-memory-bridge/lib/github.js +473 -0
  89. package/plugins/dsh-memory-bridge/lib/merge.js +205 -0
  90. package/plugins/dsh-memory-bridge/lib/portable.js +314 -0
  91. package/plugins/dsh-memory-bridge/lib/state.js +565 -0
  92. package/plugins/dsh-memory-bridge/lib/sync.js +984 -0
  93. package/plugins/dsh-memory-bridge/locale/en.json +6 -0
  94. package/plugins/dsh-memory-bridge/locale/ru.json +6 -0
  95. package/plugins/dsh-memory-bridge/package.json +67 -0
  96. package/plugins/dsh-peak-guard/README.md +16 -3
  97. package/plugins/dsh-peak-guard/client.js +223 -8
  98. package/plugins/dsh-peak-guard/package.json +1 -1
  99. package/plugins/dsh-plan-mode/README.md +24 -0
  100. package/plugins/dsh-plan-mode/cordis.patch.yml +3 -0
  101. package/plugins/dsh-plan-mode/index.js +103 -0
  102. package/plugins/dsh-plan-mode/lib/plan.js +89 -0
  103. package/plugins/dsh-plan-mode/package.json +18 -0
  104. package/plugins/dsh-profile-transfer/client.js +277 -17
  105. package/plugins/dsh-profile-transfer/index.js +10 -0
  106. package/plugins/dsh-profile-transfer/package.json +3 -1
  107. package/plugins/dsh-profile-transfer/transfer.js +101 -2
  108. package/plugins/dsh-project-memory/index.js +92 -2
  109. package/plugins/dsh-project-memory/package.json +1 -1
  110. package/plugins/dsh-repo-map/README.md +26 -0
  111. package/plugins/dsh-repo-map/cordis.patch.yml +3 -0
  112. package/plugins/dsh-repo-map/index.js +99 -0
  113. package/plugins/dsh-repo-map/lib/repo-map.js +170 -0
  114. package/plugins/dsh-repo-map/package.json +25 -0
  115. package/plugins/dsh-skill-autopilot/index.js +135 -11
  116. package/skills/delivery-gate/SKILL.md +31 -0
  117. package/skills/git-and-ci/SKILL.md +5 -1
  118. package/skills/manifest.json +31 -6
  119. package/skills/requirements-and-planning/SKILL.md +3 -0
  120. package/skills/research-first/SKILL.md +35 -0
  121. package/skills/research-first/references/query-patterns.md +20 -0
  122. package/skills/subagent-playbook/SKILL.md +36 -0
  123. package/skills/subagent-playbook/references/prompts.md +39 -0
  124. package/skills/ui-craft/SKILL.md +43 -0
  125. package/skills/visual-qa/SKILL.md +29 -0
  126. package/skills/visual-qa/references/commands.md +32 -0
  127. package/skills-baseline.json +246 -0
  128. package/uninstall.ps1 +74 -0
@@ -0,0 +1,636 @@
1
+ /**
2
+ * dsh-memory-bridge — portable bundle format, redaction, crypto and path safety.
3
+ *
4
+ * Pure Node module (node:fs/promises, node:path, node:crypto, node:os), no network.
5
+ * The format is one self-contained UTF-8 JSON file per machine and project:
6
+ *
7
+ * {
8
+ * "format": "dsh-memory-bridge", "version": 1, "encrypted": false,
9
+ * "machineId": "...", "createdAt": "...", "revision": 7, "contentHash": "...",
10
+ * "project": { "key": "<12 hex>", "fingerprint": "git:...", ... },
11
+ * "files": [ { "path": "PROJECT.md", "sha256": "...", "bytes": 123 } ],
12
+ * "content": { "PROJECT.md": "..." }
13
+ * }
14
+ *
15
+ * Encrypted bundles keep `project/files/content` inside an AES-256-GCM payload.
16
+ */
17
+
18
+ import crypto from "node:crypto";
19
+ import fs from "node:fs";
20
+ import fsp from "node:fs/promises";
21
+ import os from "node:os";
22
+ import path from "node:path";
23
+
24
+ export const FORMAT = "dsh-memory-bridge";
25
+ export const VERSION = 1;
26
+ export const REDACTION_VERSION = 1;
27
+
28
+ export const SECTION_FILES = Object.freeze([
29
+ "PROJECT.md",
30
+ "ARCHITECTURE.md",
31
+ "CODE-MAP.md",
32
+ "INVARIANTS.md",
33
+ "GOTCHAS.md",
34
+ "RUNBOOK.md",
35
+ ]);
36
+
37
+ export const JOURNAL_FILE = /^JOURNAL\/\d{4}-\d{2}\.md$/u;
38
+ export const PROJECT_KEY = /^[a-f0-9]{12}$/u;
39
+ export const SHA256_HEX = /^[a-f0-9]{64}$/u;
40
+
41
+ export const DEFAULT_LIMITS = Object.freeze({
42
+ maxFileBytes: 2 * 1024 * 1024,
43
+ maxBundleBytes: 10 * 1024 * 1024,
44
+ maxFiles: 200,
45
+ maxPathLength: 240,
46
+ maxSegmentLength: 120,
47
+ });
48
+
49
+ const RESERVED_NAMES = new Set(["__proto__", "constructor", "prototype"]);
50
+ const WINDOWS_RESERVED = /^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?$/iu;
51
+ const WINDOWS_INVALID = /[<>:"|?*\u0000-\u001f]/u;
52
+ const AUTO_SCAN_BLOCK = /<!--\s*AUTO-SCAN:START[^>]*-->[\s\S]*?<!--\s*AUTO-SCAN:END\s*-->/gu;
53
+ const PLACEHOLDER = /\(не заполнено\)/iu;
54
+
55
+ /** Typed, user-facing error with a stable code. */
56
+ export class BridgeError extends Error {
57
+ constructor(code, message, details = {}) {
58
+ super(message);
59
+ this.name = "BridgeError";
60
+ this.code = code;
61
+ this.details = details;
62
+ }
63
+ }
64
+
65
+ export function sha256(data) {
66
+ return crypto.createHash("sha256").update(data).digest("hex");
67
+ }
68
+
69
+ export function normalizeLf(text) {
70
+ return String(text ?? "").replace(/\r\n/gu, "\n");
71
+ }
72
+
73
+ export function delay(ms) {
74
+ return new Promise((resolve) => {
75
+ const timer = setTimeout(resolve, ms);
76
+ timer.unref?.();
77
+ });
78
+ }
79
+
80
+ /* ------------------------------------------------------------------ */
81
+ /* filesystem helpers */
82
+ /* ------------------------------------------------------------------ */
83
+
84
+ export function dshHome() {
85
+ const configured = process.env.DSH_HOME;
86
+ return typeof configured === "string" && configured.trim().length > 0
87
+ ? path.resolve(configured.trim())
88
+ : path.join(os.homedir(), ".dsh");
89
+ }
90
+
91
+ export function memoryRoot(home = dshHome()) {
92
+ return path.join(home, "projects-memory");
93
+ }
94
+
95
+ export function syncRoot(home = dshHome()) {
96
+ return path.join(home, "memory-sync");
97
+ }
98
+
99
+ export async function ensureDir(directory) {
100
+ await fsp.mkdir(directory, { recursive: true, mode: 0o700 });
101
+ try {
102
+ await fsp.chmod(directory, 0o700);
103
+ } catch {
104
+ /* best effort on Windows */
105
+ }
106
+ }
107
+ export function ensureDirSync(directory) {
108
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
109
+ try {
110
+ fs.chmodSync(directory, 0o700);
111
+ } catch {
112
+ /* best effort on Windows */
113
+ }
114
+ }
115
+
116
+ export async function exists(file) {
117
+ try {
118
+ await fsp.access(file);
119
+ return true;
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+
125
+ export async function readTextIfExists(file) {
126
+ try {
127
+ return await fsp.readFile(file, "utf8");
128
+ } catch (error) {
129
+ if (error?.code === "ENOENT") return undefined;
130
+ throw error;
131
+ }
132
+ }
133
+
134
+ export async function readJsonIfExists(file) {
135
+ const raw = await readTextIfExists(file);
136
+ if (raw === undefined) return undefined;
137
+ try {
138
+ return JSON.parse(raw);
139
+ } catch {
140
+ return undefined;
141
+ }
142
+ }
143
+
144
+ /** tmp + rename, with retries for cloud/AV locks on Windows. */
145
+ export async function writeFileAtomic(file, data, { mode = 0o600 } = {}) {
146
+ const directory = path.dirname(file);
147
+ await ensureDir(directory);
148
+ const tmp = path.join(directory, `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`);
149
+ await fsp.writeFile(tmp, data, { mode });
150
+ try {
151
+ await fsp.chmod(tmp, mode);
152
+ } catch {
153
+ /* best effort */
154
+ }
155
+ let lastError;
156
+ for (let attempt = 0; attempt < 6; attempt += 1) {
157
+ try {
158
+ await fsp.rename(tmp, file);
159
+ return;
160
+ } catch (error) {
161
+ lastError = error;
162
+ await delay(40 * (attempt + 1));
163
+ }
164
+ }
165
+ try {
166
+ await fsp.rm(tmp, { force: true });
167
+ } catch {
168
+ /* ignore */
169
+ }
170
+ throw lastError;
171
+ }
172
+
173
+ export async function writeJsonAtomic(file, value, options) {
174
+ return writeFileAtomic(file, `${JSON.stringify(value, null, 2)}\n`, options);
175
+ }
176
+
177
+ export async function removeFileQuiet(file) {
178
+ try {
179
+ await fsp.rm(file, { force: true });
180
+ } catch {
181
+ /* ignore */
182
+ }
183
+ }
184
+
185
+ export function assertInside(base, target) {
186
+ const root = path.resolve(base);
187
+ const resolved = path.resolve(target);
188
+ const relative = path.relative(root, resolved);
189
+ if (relative.length === 0) return resolved;
190
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
191
+ throw new BridgeError("path/outside", `путь вне разрешённого каталога: ${target}`);
192
+ }
193
+ return resolved;
194
+ }
195
+
196
+ /* ------------------------------------------------------------------ */
197
+ /* redaction */
198
+ /* ------------------------------------------------------------------ */
199
+
200
+ const REDACTION_RULES = [
201
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/gu, "[REDACTED:PRIVATE_KEY]"],
202
+ [/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/gu, "[REDACTED:JWT]"],
203
+ [/\b(?:sk|rk)-[A-Za-z0-9_-]{16,}\b/gu, "[REDACTED:KEY]"],
204
+ [/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{16,}\b/gu, "[REDACTED:GITHUB_TOKEN]"],
205
+ [/\bgithub_pat_[A-Za-z0-9_]{16,}\b/gu, "[REDACTED:GITHUB_TOKEN]"],
206
+ [/\bglpat-[A-Za-z0-9_-]{20,}\b/gu, "[REDACTED:GITLAB_TOKEN]"],
207
+ [/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/gu, "[REDACTED:SLACK_TOKEN]"],
208
+ [/\bAKIA[0-9A-Z]{16}\b/gu, "[REDACTED:AWS_KEY]"],
209
+ [/\bAIza[0-9A-Za-z_-]{30,}\b/gu, "[REDACTED:GOOGLE_KEY]"],
210
+ [/\bsk_live_[0-9A-Za-z]{16,}\b/gu, "[REDACTED:STRIPE_KEY]"],
211
+ [/\bya29\.[A-Za-z0-9_-]{20,}\b/gu, "[REDACTED:GOOGLE_OAUTH]"],
212
+ [/\bnpm_[A-Za-z0-9]{36}\b/gu, "[REDACTED:NPM_TOKEN]"],
213
+ [/\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/gu, "[REDACTED:SENDGRID_KEY]"],
214
+ [/\b\d{8,10}:[A-Za-z0-9_-]{30,}\b/gu, "[REDACTED:TELEGRAM_TOKEN]"],
215
+ [/\bAccountKey\s*=\s*[A-Za-z0-9+/=]{20,}/giu, "AccountKey=[REDACTED]"],
216
+ [/\b(?:Set-)?Cookie\s*:\s*[^\r\n]+/giu, "[REDACTED:COOKIE]"],
217
+ [/\bAuthorization:\s*(?:Bearer|Basic|Token)\s+[^\s]+/giu, "Authorization: [REDACTED]"],
218
+ [/\b(\w+:\/\/)[^:@/\s]+:[^@/\s]+@/gu, "$1[REDACTED]@"],
219
+ [/\b([A-Z0-9_]*(?:PASSWORD|PASSWD|PASSPHRASE|PASS|APP_KEY|SECRET|TOKEN|API[_-]?KEY|PRIVATE[_-]?KEY|KEY|DSN|DATABASE_URL|REDIS_URL|ACCESS[_-]?KEY)[A-Z0-9_]*)\s*[:=]\s*["']?[^\s"',;]+/giu, "$1=[REDACTED]"],
220
+ [/(^|\n)(\s*(?:password|passwd|pwd|passphrase|secret|token|api[_-]?key|authorization)\s*[:=]\s*)[^\n]*/giu, "$1$2[REDACTED]"],
221
+ [/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/giu, "[email]"],
222
+ [/(?:\+7|8)[\s(-]*\d{3}[\s)-]*\d{3}[\s-]*\d{2}[\s-]*\d{2}\b/gu, "[phone]"],
223
+ ];
224
+
225
+ /** Remove secrets/PII before anything is written to a bundle or a dossier. */
226
+ export function redactSecrets(input) {
227
+ let text = String(input ?? "");
228
+ for (const [pattern, replacement] of REDACTION_RULES) text = text.replace(pattern, replacement);
229
+ return text;
230
+ }
231
+
232
+ /** Rows where redaction would change the text — for the `/sync audit` command. */
233
+ export function findSecretHits(text) {
234
+ const value = String(text ?? "");
235
+ const hits = [];
236
+ for (const [pattern] of REDACTION_RULES) {
237
+ const re = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
238
+ let match;
239
+ while ((match = re.exec(value)) !== null) {
240
+ const index = match.index ?? 0;
241
+ const line = value.slice(0, index).split(/\r?\n/u).length;
242
+ hits.push({ line, sample: shorten(match[0]) });
243
+ if (match.index === re.lastIndex) re.lastIndex += 1;
244
+ if (hits.length > 200) return hits;
245
+ }
246
+ }
247
+ return hits;
248
+ }
249
+
250
+ function shorten(value, max = 60) {
251
+ const text = String(value ?? "").replace(/\s+/gu, " ").trim();
252
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
253
+ }
254
+
255
+ /* ------------------------------------------------------------------ */
256
+ /* AUTO-SCAN and dossier helpers */
257
+ /* ------------------------------------------------------------------ */
258
+
259
+ export function stripAutoScan(text) {
260
+ return String(text ?? "")
261
+ .replace(AUTO_SCAN_BLOCK, "")
262
+ .replace(/\n{3,}/gu, "\n\n")
263
+ .trim();
264
+ }
265
+
266
+ export function extractAutoScanBlocks(text) {
267
+ const blocks = String(text ?? "").match(AUTO_SCAN_BLOCK);
268
+ return blocks ? blocks.slice(0, 4) : [];
269
+ }
270
+
271
+ /**
272
+ * A freshly registered project is created with template files that contain
273
+ * "(не заполнено)" markers. Such a dossier is safe to bootstrap from a remote
274
+ * bundle: the local side has no user knowledge to lose.
275
+ *
276
+ * The check is deliberately strict: every non-heading line must look like the
277
+ * kit's generated default. A real dossier that only left one "(не заполнено)"
278
+ * field but filled others is NOT a template.
279
+ */
280
+ const TEMPLATE_LINES = [
281
+ /^[-*]\s*\(не заполнено\)$/iu,
282
+ /^[-*]\s*(?:Назначение|Прод|Как запускать локально):\s*\(не заполнено\)/iu,
283
+ /^[-*]\s*Стек:\s*(?:\(не определён\))?$/iu,
284
+ /^[-*]\s*Slug:\s*`[^`]*`$/u,
285
+ /^[-*]\s*Корни:\s*`[^`]*`(?:\s*,\s*`[^`]*`)*$/u,
286
+ /^[-*]\s*Создано:\s*\d{4}-\d{2}-\d{2}T\S*$/u,
287
+ /^[-*]\s*Уверенность памяти:\s*\S.*$/u,
288
+ /^[-*]\s*Последний глубокий анализ:\s*(?:—|-)?\s*$/u,
289
+ /^[-*]\s*Git:\s*(?:не git или неизвестно|[A-Za-z0-9._/-]+@[0-9a-f]{4,})$/u,
290
+ /^[-*]\s*\(нет\)$/iu,
291
+ /^\(не заполнено\)$/iu,
292
+ /^(?:Симптом|Причина|Решение|Как обнаружить заранее):\s*\([^)]*\)$/u,
293
+ /^Уверенность:\s*\[[^\]]*\]$/u,
294
+ /^<!--[\s\S]*-->$/u,
295
+ ];
296
+
297
+ export function isTemplateContent(text) {
298
+ const value = String(text ?? "");
299
+ const manual = stripAutoScan(value);
300
+ if (manual.trim().length === 0) return true;
301
+ if (!PLACEHOLDER.test(manual)) return false;
302
+ for (const line of manual.split(/\r?\n/u)) {
303
+ const trimmed = line.trim();
304
+ if (trimmed.length === 0) continue;
305
+ if (trimmed.startsWith("#")) continue;
306
+ if (trimmed.startsWith(">")) continue;
307
+ if (TEMPLATE_LINES.some((pattern) => pattern.test(trimmed))) continue;
308
+ return false;
309
+ }
310
+ return true;
311
+ }
312
+
313
+ /* ------------------------------------------------------------------ */
314
+ /* portable paths (home directory is machine-specific) */
315
+ /* ------------------------------------------------------------------ */
316
+
317
+ export const HOME_TOKEN = "%DSH_HOME%";
318
+
319
+ function escapeRegExp(value) {
320
+ return String(value).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
321
+ }
322
+
323
+ /**
324
+ * Replace the local home directory with a stable token before content is
325
+ * hashed, compared or written into a bundle. This keeps base hashes identical
326
+ * on machines with different user names while the dossier itself keeps real
327
+ * local paths.
328
+ */
329
+ export function canonicalizePaths(text, home = os.homedir()) {
330
+ const value = String(text ?? "");
331
+ if (value.length === 0 || typeof home !== "string" || home.length < 3) return value;
332
+ return value.replace(new RegExp(escapeRegExp(home), "giu"), HOME_TOKEN);
333
+ }
334
+
335
+ export function localizePaths(text, home = os.homedir()) {
336
+ const value = String(text ?? "");
337
+ if (value.length === 0 || typeof home !== "string" || home.length === 0) return value;
338
+ return value.split(HOME_TOKEN).join(home);
339
+ }
340
+
341
+ /* ------------------------------------------------------------------ */
342
+ /* path safety */
343
+ /* ------------------------------------------------------------------ */
344
+
345
+ /** Validate and canonicalise a bundle-relative path (`/` separators). */
346
+ export function safeRelPath(raw, limits = DEFAULT_LIMITS) {
347
+ if (typeof raw !== "string" || raw.trim().length === 0) {
348
+ throw new BridgeError("path/bad", "пустое имя файла в пакете");
349
+ }
350
+ const value = raw.replaceAll("\\", "/").trim();
351
+ if (value.length > limits.maxPathLength) throw new BridgeError("path/too-long", `слишком длинный путь: ${shorten(value)}`);
352
+ if (value.startsWith("/") || /^[A-Za-z]:/u.test(value) || value.startsWith("//")) {
353
+ throw new BridgeError("path/absolute", `абсолютный путь в пакете: ${shorten(value)}`);
354
+ }
355
+ const parts = value.split("/");
356
+ for (const part of parts) {
357
+ if (part.length === 0 || part === "." || part === "..") {
358
+ throw new BridgeError("path/bad", `недопустимый сегмент пути: ${shorten(value)}`);
359
+ }
360
+ if (part.length > limits.maxSegmentLength) throw new BridgeError("path/too-long", `слишком длинный сегмент пути: ${shorten(part)}`);
361
+ if (WINDOWS_INVALID.test(part)) throw new BridgeError("path/bad", `недопустимые символы в пути: ${shorten(value)}`);
362
+ if (RESERVED_NAMES.has(part.toLowerCase())) throw new BridgeError("path/bad", `служебное имя файла: ${shorten(value)}`);
363
+ if (WINDOWS_RESERVED.test(part)) throw new BridgeError("path/bad", `зарезервированное имя Windows: ${shorten(value)}`);
364
+ if (part.endsWith(".") || part.endsWith(" ") || part.startsWith(" ")) {
365
+ throw new BridgeError("path/bad", `недопустимое окончание сегмента пути: ${shorten(value)}`);
366
+ }
367
+ }
368
+ return parts.join("/");
369
+ }
370
+
371
+ export function isAllowedSyncPath(relativePath) {
372
+ return SECTION_FILES.includes(relativePath) || JOURNAL_FILE.test(relativePath);
373
+ }
374
+
375
+ /* ------------------------------------------------------------------ */
376
+ /* content hashing */
377
+ /* ------------------------------------------------------------------ */
378
+
379
+ /** Deterministic content hash over sorted `path -> sha256(text)` pairs. */
380
+ export function hashContentMap(content) {
381
+ const lines = [];
382
+ for (const key of [...content.keys()].sort()) lines.push(`${key}\u0000${sha256(content.get(key))}`);
383
+ return sha256(lines.join("\n"));
384
+ }
385
+
386
+ export function fileEntries(content, bases = new Map()) {
387
+ const baseMap = bases instanceof Map ? bases : new Map(Object.entries(bases ?? {}));
388
+ return [...content.keys()].sort().map((key) => {
389
+ const text = content.get(key);
390
+ const base = baseMap.get(key);
391
+ return {
392
+ path: key,
393
+ sha256: sha256(text),
394
+ bytes: Buffer.byteLength(text, "utf8"),
395
+ base: typeof base === "string" && SHA256_HEX.test(base) ? base : null,
396
+ };
397
+ });
398
+ }
399
+
400
+ /* ------------------------------------------------------------------ */
401
+ /* encryption (optional, key file; the key is copied between machines) */
402
+ /* ------------------------------------------------------------------ */
403
+
404
+ export function keyIdOf(key) {
405
+ return sha256(key).slice(0, 12);
406
+ }
407
+
408
+ export async function loadKeyFile(file) {
409
+ const raw = (await fsp.readFile(file, "utf8")).trim();
410
+ let key;
411
+ try {
412
+ key = Buffer.from(raw, "base64");
413
+ } catch {
414
+ key = Buffer.from(raw, "utf8");
415
+ }
416
+ if (key.length === 32) return key;
417
+ const utf8 = Buffer.from(raw, "utf8");
418
+ if (utf8.length === 32) return utf8;
419
+ throw new BridgeError("crypto/bad-key", "ключ синхронизации должен быть 32 байта (base64 в файле)");
420
+ }
421
+
422
+ export async function generateKeyFile(file) {
423
+ const key = crypto.randomBytes(32);
424
+ await ensureDir(path.dirname(file));
425
+ await writeFileAtomic(file, `${key.toString("base64")}\n`, { mode: 0o600 });
426
+ return key;
427
+ }
428
+
429
+ function bundleAad(header) {
430
+ return Buffer.from(
431
+ JSON.stringify({
432
+ format: header.format,
433
+ version: header.version,
434
+ machineId: header.machineId,
435
+ createdAt: header.createdAt,
436
+ revision: header.revision,
437
+ keyId: header.keyId ?? null,
438
+ }),
439
+ "utf8",
440
+ );
441
+ }
442
+
443
+ /* ------------------------------------------------------------------ */
444
+ /* bundle build / parse */
445
+ /* ------------------------------------------------------------------ */
446
+
447
+ /**
448
+ * Build the JSON value for one bundle.
449
+ * @param {{machineId:string, createdAt:string, revision:number,
450
+ * project:object, content:Map<string,string>, key?:Buffer|null,
451
+ * bases?:Map<string,string>|Record<string,string>, limits?:object}} input
452
+ */
453
+ export function buildBundle(input) {
454
+ const limits = { ...DEFAULT_LIMITS, ...(input.limits ?? {}) };
455
+ const content = input.content;
456
+ if (!(content instanceof Map)) throw new BridgeError("bundle/internal", "content must be a Map");
457
+ if (content.size > limits.maxFiles) throw new BridgeError("bundle/too-many-files", `слишком много файлов: ${content.size}`);
458
+ const safeContent = new Map();
459
+ for (const [rawPath, text] of content) {
460
+ const relativePath = safeRelPath(rawPath, limits);
461
+ if (!isAllowedSyncPath(relativePath)) throw new BridgeError("bundle/bad-path", `файл не входит в память проекта: ${relativePath}`);
462
+ if (typeof text !== "string") throw new BridgeError("bundle/bad-content", `содержимое ${relativePath} должно быть строкой`);
463
+ safeContent.set(relativePath, text);
464
+ }
465
+ const files = fileEntries(safeContent, input.bases);
466
+ let totalBytes = 0;
467
+ for (const entry of files) {
468
+ if (entry.bytes > limits.maxFileBytes) {
469
+ throw new BridgeError("bundle/file-too-large", `файл ${entry.path} больше лимита (${entry.bytes} B)`);
470
+ }
471
+ totalBytes += entry.bytes;
472
+ }
473
+ if (totalBytes > limits.maxBundleBytes) throw new BridgeError("bundle/too-large", `пакет больше лимита (${totalBytes} B)`);
474
+ const contentHash = hashContentMap(safeContent);
475
+ const header = {
476
+ format: FORMAT,
477
+ version: VERSION,
478
+ encrypted: Boolean(input.key),
479
+ machineId: String(input.machineId),
480
+ createdAt: String(input.createdAt),
481
+ revision: Number.isFinite(input.revision) ? Math.max(0, Math.trunc(input.revision)) : 0,
482
+ contentHash,
483
+ };
484
+ if (!input.key) {
485
+ return {
486
+ ...header,
487
+ project: input.project,
488
+ files,
489
+ content: Object.fromEntries(safeContent),
490
+ };
491
+ }
492
+ const key = input.key;
493
+ const keyId = keyIdOf(key);
494
+ const headerWithKey = { ...header, keyId };
495
+ const iv = crypto.randomBytes(12);
496
+ const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
497
+ cipher.setAAD(bundleAad(headerWithKey));
498
+ const payload = Buffer.concat([
499
+ cipher.update(JSON.stringify({ project: input.project, files, content: Object.fromEntries(safeContent) }), "utf8"),
500
+ cipher.final(),
501
+ ]);
502
+ return {
503
+ ...headerWithKey,
504
+ kdf: { alg: "raw", keyId },
505
+ cipher: { alg: "aes-256-gcm", iv: iv.toString("base64"), tag: cipher.getAuthTag().toString("base64") },
506
+ payload: payload.toString("base64"),
507
+ };
508
+ }
509
+
510
+ function requirePlainObject(value, code, label) {
511
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
512
+ throw new BridgeError(code, `${label} должен быть объектом`);
513
+ }
514
+ return value;
515
+ }
516
+
517
+ /**
518
+ * Parse and fully verify one bundle (`Buffer|string`).
519
+ * @param {Buffer|string} raw
520
+ * @param {{key?:Buffer|null, limits?:object}} options
521
+ */
522
+ export function parseBundle(raw, options = {}) {
523
+ const limits = { ...DEFAULT_LIMITS, ...(options.limits ?? {}) };
524
+ const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw ?? "");
525
+ if (Buffer.byteLength(text, "utf8") > limits.maxBundleBytes) {
526
+ throw new BridgeError("bundle/too-large", "пакет больше лимита");
527
+ }
528
+ let parsed;
529
+ try {
530
+ parsed = JSON.parse(text);
531
+ } catch {
532
+ throw new BridgeError("bundle/bad-json", "пакет не является корректным JSON");
533
+ }
534
+ const top = requirePlainObject(parsed, "bundle/bad", "пакет");
535
+ if (top.format !== FORMAT) throw new BridgeError("bundle/bad-format", "неизвестный формат пакета");
536
+ if (top.version !== VERSION) throw new BridgeError("bundle/bad-version", `неподдерживаемая версия пакета: ${String(top.version)}`);
537
+ if (typeof top.machineId !== "string" || top.machineId.length === 0 || top.machineId.length > 100) {
538
+ throw new BridgeError("bundle/bad-machine", "некорректный machineId в пакете");
539
+ }
540
+ if (typeof top.createdAt !== "string" || Number.isNaN(Date.parse(top.createdAt))) {
541
+ throw new BridgeError("bundle/bad-date", "некорректная дата создания пакета");
542
+ }
543
+ if (!Number.isInteger(top.revision) || top.revision < 0) throw new BridgeError("bundle/bad-revision", "некорректная ревизия пакета");
544
+ if (typeof top.contentHash !== "string" || !SHA256_HEX.test(top.contentHash)) {
545
+ throw new BridgeError("bundle/bad-hash", "некорректный contentHash пакета");
546
+ }
547
+
548
+ let payload;
549
+ if (top.encrypted === true) {
550
+ const key = options.key;
551
+ if (!(key instanceof Buffer) || key.length !== 32) {
552
+ throw new BridgeError("crypto/key-required", "пакет зашифрован, а ключ не настроен или неверен");
553
+ }
554
+ if (top.kdf?.alg !== "raw") throw new BridgeError("crypto/bad-kdf", "неподдерживаемый способ шифрования пакета");
555
+ if (top.cipher?.alg !== "aes-256-gcm") throw new BridgeError("crypto/bad-cipher", "неподдерживаемый шифр пакета");
556
+ if (typeof top.cipher.iv !== "string" || typeof top.cipher.tag !== "string" || typeof top.payload !== "string") {
557
+ throw new BridgeError("bundle/bad-crypto", "повреждён зашифрованный пакет");
558
+ }
559
+ let decipher;
560
+ try {
561
+ decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(top.cipher.iv, "base64"));
562
+ decipher.setAAD(bundleAad(top));
563
+ decipher.setAuthTag(Buffer.from(top.cipher.tag, "base64"));
564
+ const decrypted = Buffer.concat([decipher.update(Buffer.from(top.payload, "base64")), decipher.final()]);
565
+ payload = JSON.parse(decrypted.toString("utf8"));
566
+ } catch {
567
+ throw new BridgeError("crypto/decrypt-failed", "не удалось расшифровать пакет: неверный ключ или повреждённые данные");
568
+ }
569
+ } else {
570
+ payload = { project: top.project, files: top.files, content: top.content };
571
+ }
572
+
573
+ const body = requirePlainObject(payload, "bundle/bad", "содержимое пакета");
574
+ const project = requirePlainObject(body.project, "bundle/bad-project", "описание проекта");
575
+ if (typeof project.key !== "string" || !PROJECT_KEY.test(project.key)) {
576
+ throw new BridgeError("bundle/bad-project", "некорректный ключ проекта в пакете");
577
+ }
578
+ if (typeof project.fingerprint !== "string" || project.fingerprint.length === 0 || project.fingerprint.length > 500) {
579
+ throw new BridgeError("bundle/bad-project", "некорректный fingerprint проекта в пакете");
580
+ }
581
+ if (project.originHome !== undefined && typeof project.originHome !== "string") {
582
+ throw new BridgeError("bundle/bad-project", "некорректный originHome в пакете");
583
+ }
584
+ if (!Array.isArray(body.files)) throw new BridgeError("bundle/bad-files", "в пакете нет списка файлов");
585
+ if (body.files.length > limits.maxFiles) throw new BridgeError("bundle/too-many-files", "слишком много файлов в пакете");
586
+ const contentObject = requirePlainObject(body.content, "bundle/bad-content", "содержимое пакета");
587
+ const content = new Map();
588
+ const files = [];
589
+ const seen = new Set();
590
+ let totalBytes = 0;
591
+ for (const rawEntry of body.files) {
592
+ const entry = requirePlainObject(rawEntry, "bundle/bad-files", "элемент списка файлов");
593
+ const relativePath = safeRelPath(entry.path, limits);
594
+ if (!isAllowedSyncPath(relativePath)) throw new BridgeError("bundle/bad-path", `файл не входит в память проекта: ${relativePath}`);
595
+ if (seen.has(relativePath)) throw new BridgeError("bundle/duplicate-path", `дубликат файла в пакете: ${relativePath}`);
596
+ seen.add(relativePath);
597
+ if (typeof entry.sha256 !== "string" || !SHA256_HEX.test(entry.sha256)) {
598
+ throw new BridgeError("bundle/bad-hash", `некорректный хэш файла ${relativePath}`);
599
+ }
600
+ if (entry.base !== undefined && entry.base !== null && (typeof entry.base !== "string" || !SHA256_HEX.test(entry.base))) {
601
+ throw new BridgeError("bundle/bad-base", `некорректный base-хэш файла ${relativePath}`);
602
+ }
603
+ if (!Number.isInteger(entry.bytes) || entry.bytes < 0) throw new BridgeError("bundle/bad-size", `некорректный размер файла ${relativePath}`);
604
+ if (entry.bytes > limits.maxFileBytes) throw new BridgeError("bundle/file-too-large", `файл ${relativePath} больше лимита`);
605
+ if (!Object.prototype.hasOwnProperty.call(contentObject, relativePath)) {
606
+ throw new BridgeError("bundle/missing-content", `в пакете нет содержимого файла ${relativePath}`);
607
+ }
608
+ const fileText = contentObject[relativePath];
609
+ if (typeof fileText !== "string") throw new BridgeError("bundle/bad-content", `содержимое ${relativePath} должно быть строкой`);
610
+ const actual = sha256(fileText);
611
+ if (actual !== entry.sha256) throw new BridgeError("bundle/hash-mismatch", `хэш файла ${relativePath} не совпадает`);
612
+ totalBytes += Buffer.byteLength(fileText, "utf8");
613
+ if (totalBytes > limits.maxBundleBytes) throw new BridgeError("bundle/too-large", "распакованный пакет больше лимита");
614
+ content.set(relativePath, fileText);
615
+ files.push({ path: relativePath, sha256: actual, bytes: Buffer.byteLength(fileText, "utf8"), base: entry.base ?? null });
616
+ }
617
+ const extra = Object.keys(contentObject).filter((key) => !seen.has(key));
618
+ if (extra.length > 0) throw new BridgeError("bundle/unknown-content", `в пакете лишние файлы: ${extra.slice(0, 3).join(", ")}`);
619
+ if (hashContentMap(content) !== top.contentHash) throw new BridgeError("bundle/content-hash", "contentHash пакета не совпадает с содержимым");
620
+
621
+ return {
622
+ header: {
623
+ format: top.format,
624
+ version: top.version,
625
+ encrypted: top.encrypted === true,
626
+ machineId: top.machineId,
627
+ createdAt: top.createdAt,
628
+ revision: top.revision,
629
+ contentHash: top.contentHash,
630
+ keyId: typeof top.keyId === "string" ? top.keyId : null,
631
+ },
632
+ project,
633
+ files,
634
+ content,
635
+ };
636
+ }