dsh-desktop-kit 2.2.1 → 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 +80 -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 +4 -2
- 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 +5 -5
- 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/index.js +18 -1
- package/package.json +26 -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 +219 -0
|
@@ -0,0 +1,984 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-memory-bridge — GitHub-only sync service (v2).
|
|
3
|
+
*
|
|
4
|
+
* Transport: one private GitHub repository holds the merged dossier of every
|
|
5
|
+
* project. Every machine keeps its local dossier as a working copy plus a base
|
|
6
|
+
* snapshot of the last successful sync. A cycle:
|
|
7
|
+
*
|
|
8
|
+
* fetch → reset derived clone to origin → three-way merge each file
|
|
9
|
+
* (local / base / origin) → write merged local + repo files → commit → push
|
|
10
|
+
*
|
|
11
|
+
* A failed push is never forced: the cycle fetches again and merges again, so
|
|
12
|
+
* asynchronous work of several machines is combined instead of overwritten.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fsp from "node:fs/promises";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import crypto from "node:crypto";
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
BridgeError,
|
|
22
|
+
DEFAULT_LIMITS,
|
|
23
|
+
JOURNAL_FILE,
|
|
24
|
+
SECTION_FILES,
|
|
25
|
+
ensureDir,
|
|
26
|
+
exists,
|
|
27
|
+
isAllowedSyncPath,
|
|
28
|
+
isTemplateContent,
|
|
29
|
+
readTextIfExists,
|
|
30
|
+
sha256,
|
|
31
|
+
writeFileAtomic,
|
|
32
|
+
writeJsonAtomic,
|
|
33
|
+
} from "./bridge.js";
|
|
34
|
+
import {
|
|
35
|
+
cloneRepo,
|
|
36
|
+
commitRepo,
|
|
37
|
+
createPrivateRepo,
|
|
38
|
+
fetchOrigin,
|
|
39
|
+
ghLogin,
|
|
40
|
+
ghVersion,
|
|
41
|
+
gitVersion,
|
|
42
|
+
isRepoSlug,
|
|
43
|
+
localFileText,
|
|
44
|
+
mergeJournalAdditive,
|
|
45
|
+
mergeThreeWay,
|
|
46
|
+
pushRepo,
|
|
47
|
+
readTreeFiles,
|
|
48
|
+
remoteHeadExists,
|
|
49
|
+
repoFolderName,
|
|
50
|
+
repoRemoteUrl,
|
|
51
|
+
repoVisibility,
|
|
52
|
+
repoWebUrl,
|
|
53
|
+
resetToRemote,
|
|
54
|
+
runGit,
|
|
55
|
+
seedEmptyRepo,
|
|
56
|
+
suggestedExchangeRepo,
|
|
57
|
+
syncText,
|
|
58
|
+
verifyRemote,
|
|
59
|
+
writeTreeFile,
|
|
60
|
+
ensureRepoIdentity,
|
|
61
|
+
} from "./github.js";
|
|
62
|
+
import { splitJournal } from "./merge.js";
|
|
63
|
+
import { exportPortable, importPortable, scanPortable } from "./portable.js";
|
|
64
|
+
import * as st from "./state.js";
|
|
65
|
+
|
|
66
|
+
function nowIso() {
|
|
67
|
+
return new Date().toISOString();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function formatTime(value) {
|
|
71
|
+
if (typeof value !== "string") return "—";
|
|
72
|
+
const date = new Date(value);
|
|
73
|
+
return Number.isNaN(date.getTime()) ? value : date.toLocaleString("ru-RU", { hour12: false });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function folderSlug(slug) {
|
|
77
|
+
return String(slug ?? "project").replace(/[^A-Za-z0-9._-]+/gu, "-").slice(0, 40) || "project";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function projectFolder(target) {
|
|
81
|
+
return `${folderSlug(target.slug)}--${target.projectKey.slice(0, 8)}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function mapDiffers(local, base) {
|
|
85
|
+
const keys = new Set([...local.keys(), ...base.keys()]);
|
|
86
|
+
for (const key of keys) {
|
|
87
|
+
if ((local.get(key) ?? "") !== (base.get(key) ?? "")) return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function unionText(localText, remoteText) {
|
|
93
|
+
const localLines = String(localText ?? "").split("\n");
|
|
94
|
+
const seen = new Set(localLines.map((line) => line.trim()).filter(Boolean));
|
|
95
|
+
const extra = String(remoteText ?? "")
|
|
96
|
+
.split("\n")
|
|
97
|
+
.filter((line) => line.trim().length > 0 && !seen.has(line.trim()));
|
|
98
|
+
if (extra.length === 0) return localText;
|
|
99
|
+
return `${localText.trimEnd()}\n${extra.join("\n")}\n`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function createSyncService({ home = process.env.DSH_HOME || path.join(os.homedir(), ".dsh"), logger = null, ghAdapter = {} } = {}) {
|
|
103
|
+
const paths = st.pathsFor(home);
|
|
104
|
+
const ghApi = {
|
|
105
|
+
version: ghVersion,
|
|
106
|
+
login: ghLogin,
|
|
107
|
+
visibility: repoVisibility,
|
|
108
|
+
createRepo: createPrivateRepo,
|
|
109
|
+
...ghAdapter,
|
|
110
|
+
};
|
|
111
|
+
let running = null;
|
|
112
|
+
let machine;
|
|
113
|
+
let runtime = { lastAutoAt: 0, lastError: null };
|
|
114
|
+
let githubCache = { at: 0, info: null };
|
|
115
|
+
const visibilityCache = new Map();
|
|
116
|
+
|
|
117
|
+
async function init() {
|
|
118
|
+
for (const directory of [paths.root, paths.githubRoot, paths.baseDir, paths.conflictsDir, paths.tmpDir, paths.backupsDir]) {
|
|
119
|
+
await ensureDir(directory);
|
|
120
|
+
}
|
|
121
|
+
machine = await st.loadMachine(home);
|
|
122
|
+
await st.logLine(home, `github service started machine=${machine.machineId} host=${machine.label}`);
|
|
123
|
+
return machine;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function currentConfig() {
|
|
127
|
+
return st.loadConfig(home);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function currentMachine() {
|
|
131
|
+
if (!machine) machine = await st.loadMachine(home);
|
|
132
|
+
return machine;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** gh CLI availability + the authenticated GitHub account (cached for a minute). */
|
|
136
|
+
async function githubSnapshot({ force = false } = {}) {
|
|
137
|
+
if (!force && githubCache.info !== null && Date.now() - githubCache.at < 60_000) return githubCache.info;
|
|
138
|
+
let available = false;
|
|
139
|
+
let login;
|
|
140
|
+
try {
|
|
141
|
+
available = Boolean(await ghApi.version());
|
|
142
|
+
if (available) login = await ghApi.login();
|
|
143
|
+
} catch {
|
|
144
|
+
available = false;
|
|
145
|
+
login = undefined;
|
|
146
|
+
}
|
|
147
|
+
const info = {
|
|
148
|
+
available,
|
|
149
|
+
authenticated: Boolean(login),
|
|
150
|
+
login: login ?? null,
|
|
151
|
+
suggestedRepo: suggestedExchangeRepo(login),
|
|
152
|
+
};
|
|
153
|
+
githubCache = { at: Date.now(), info };
|
|
154
|
+
return info;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function visibilityOf(repo, { force = false } = {}) {
|
|
158
|
+
if (!isRepoSlug(repo)) return "unknown";
|
|
159
|
+
const cached = visibilityCache.get(repo);
|
|
160
|
+
if (!force && cached !== undefined && Date.now() - cached.at < 60_000) return cached.value;
|
|
161
|
+
let value = "unknown";
|
|
162
|
+
try {
|
|
163
|
+
value = (await ghApi.visibility(repo)) ?? "unknown";
|
|
164
|
+
} catch {
|
|
165
|
+
value = "unknown";
|
|
166
|
+
}
|
|
167
|
+
visibilityCache.set(repo, { at: Date.now(), value });
|
|
168
|
+
return value;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function repoDirectory(config) {
|
|
172
|
+
if (typeof config.localDir === "string" && config.localDir.trim().length > 0) return path.resolve(config.localDir.trim());
|
|
173
|
+
return path.join(paths.githubRoot, repoFolderName(config.repo));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function baseDirectory(folder) {
|
|
177
|
+
return path.join(paths.baseDir, folder);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function readBaseMap(folder) {
|
|
181
|
+
const map = await readTreeFiles(baseDirectory(folder));
|
|
182
|
+
const result = new Map();
|
|
183
|
+
for (const [key, value] of map) result.set(key, String(value).replace(/\r\n/gu, "\n").trimEnd());
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function writeBaseMap(folder, content) {
|
|
188
|
+
const directory = baseDirectory(folder);
|
|
189
|
+
for (const [key, value] of content) await writeTreeFile(directory, key, `${String(value).trimEnd()}\n`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function localTargets(config) {
|
|
193
|
+
const { targets, skipped } = await st.syncTargets(home, config);
|
|
194
|
+
const registry = await st.readRegistry(home);
|
|
195
|
+
const resolved = targets.map((target) => ({ ...target, folder: projectFolder(target) }));
|
|
196
|
+
// A project may have been registered before it became a git repository.
|
|
197
|
+
// Re-detect the remote at sync time instead of asking the user to re-register.
|
|
198
|
+
for (const item of [...skipped]) {
|
|
199
|
+
const project = registry.projects.find((entry) => entry.slug === item.slug);
|
|
200
|
+
if (!project) continue;
|
|
201
|
+
const roots = Array.isArray(project.roots) ? project.roots.filter((root) => typeof root === "string") : [];
|
|
202
|
+
for (const root of roots) {
|
|
203
|
+
const remoteResult = await runGit(["config", "--get", "remote.origin.url"], { cwd: root });
|
|
204
|
+
let remote = remoteResult.ok ? remoteResult.stdout.trim() : "";
|
|
205
|
+
if (remote.length === 0) {
|
|
206
|
+
const fallback = await runGit(["remote", "get-url", "origin"], { cwd: root });
|
|
207
|
+
remote = fallback.ok ? fallback.stdout.trim() : "";
|
|
208
|
+
}
|
|
209
|
+
if (remote.length === 0) continue;
|
|
210
|
+
remote = remote.replace(/\/\/[^@/]+@/u, "//[REDACTED]@").slice(0, 200);
|
|
211
|
+
const fingerprint = `git:${remote}`;
|
|
212
|
+
const projectKey = st.projectKeyFor(fingerprint);
|
|
213
|
+
const target = {
|
|
214
|
+
slug: item.slug,
|
|
215
|
+
name: typeof project.name === "string" ? project.name : item.slug,
|
|
216
|
+
fingerprint,
|
|
217
|
+
projectKey,
|
|
218
|
+
directory: path.join(paths.projectsMemory, item.slug),
|
|
219
|
+
roots,
|
|
220
|
+
};
|
|
221
|
+
resolved.push({ ...target, folder: projectFolder(target) });
|
|
222
|
+
skipped.splice(skipped.indexOf(item), 1);
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return { targets: resolved, skipped };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function localSyncMap(target) {
|
|
230
|
+
const raw = await st.readLocalDossier(target.directory, {
|
|
231
|
+
maxFileBytes: DEFAULT_LIMITS.maxFileBytes,
|
|
232
|
+
maxBundleBytes: DEFAULT_LIMITS.maxBundleBytes,
|
|
233
|
+
maxFiles: DEFAULT_LIMITS.maxFiles,
|
|
234
|
+
});
|
|
235
|
+
const map = new Map();
|
|
236
|
+
for (const [key, value] of raw.raw) map.set(key, syncText(key, value));
|
|
237
|
+
return { map, raw: raw.raw, skipped: raw.skipped };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function remoteSyncMap(repoProjectDir) {
|
|
241
|
+
const tree = await readTreeFiles(repoProjectDir);
|
|
242
|
+
const map = new Map();
|
|
243
|
+
for (const [key, value] of tree) map.set(key, String(value).replace(/\r\n/gu, "\n").trimEnd());
|
|
244
|
+
return map;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/* ---------------------------------------------------------------- */
|
|
248
|
+
/* conflicts */
|
|
249
|
+
/* ---------------------------------------------------------------- */
|
|
250
|
+
|
|
251
|
+
async function listConflicts() {
|
|
252
|
+
const conflicts = [];
|
|
253
|
+
let entries = [];
|
|
254
|
+
try {
|
|
255
|
+
entries = await fsp.readdir(paths.conflictsDir, { withFileTypes: true });
|
|
256
|
+
} catch {
|
|
257
|
+
return conflicts;
|
|
258
|
+
}
|
|
259
|
+
for (const entry of entries) {
|
|
260
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
261
|
+
try {
|
|
262
|
+
const parsed = JSON.parse(await fsp.readFile(path.join(paths.conflictsDir, entry.name), "utf8"));
|
|
263
|
+
if (parsed && typeof parsed === "object") conflicts.push(parsed);
|
|
264
|
+
} catch {
|
|
265
|
+
/* corrupted conflict file: ignore, it stays on disk */
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return conflicts.sort((left, right) => String(left.createdAt).localeCompare(String(right.createdAt)));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function conflictId(conflict) {
|
|
272
|
+
return sha256(`conflict\u0000${conflict.folder}\u0000${conflict.path}`).slice(0, 16);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function saveConflict(conflict) {
|
|
276
|
+
const id = conflictId(conflict);
|
|
277
|
+
const file = path.join(paths.conflictsDir, `${id}.json`);
|
|
278
|
+
const existed = await exists(file);
|
|
279
|
+
await writeJsonAtomic(file, { ...conflict, id, createdAt: nowIso() });
|
|
280
|
+
if (!existed) await st.logLine(home, `conflict project=${conflict.projectSlug} file=${conflict.path}`);
|
|
281
|
+
return { id, file, added: !existed };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* While a conflict is unresolved, keep its local/remote previews up to date,
|
|
286
|
+
* so resolving later never overwrites a newer change from GitHub.
|
|
287
|
+
*/
|
|
288
|
+
async function refreshStoredConflicts(dir, targets, storedConflicts) {
|
|
289
|
+
for (const conflict of storedConflicts) {
|
|
290
|
+
const target = targets.find((item) => item.folder === conflict.folder);
|
|
291
|
+
if (!target) continue;
|
|
292
|
+
const localFile = path.join(target.directory, ...conflict.path.split("/"));
|
|
293
|
+
const localRaw = await readTextIfExists(localFile);
|
|
294
|
+
const remoteRaw = await readTextIfExists(path.join(dir, "projects", conflict.folder, ...conflict.path.split("/")));
|
|
295
|
+
if (localRaw === undefined || remoteRaw === undefined) continue;
|
|
296
|
+
const localText = syncText(conflict.path, localRaw);
|
|
297
|
+
const remoteText = String(remoteRaw).replace(/\r\n/gu, "\n").trimEnd();
|
|
298
|
+
if (localText !== conflict.localText || remoteText !== conflict.remoteText) {
|
|
299
|
+
await saveConflict({ ...conflict, localText, remoteText });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function readConflict(id) {
|
|
305
|
+
const file = path.join(paths.conflictsDir, `${String(id).replace(/[^a-f0-9]/giu, "")}.json`);
|
|
306
|
+
const raw = await readTextIfExists(file);
|
|
307
|
+
if (raw === undefined) return undefined;
|
|
308
|
+
try {
|
|
309
|
+
return JSON.parse(raw);
|
|
310
|
+
} catch {
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function removeConflict(id) {
|
|
316
|
+
await fsp.rm(path.join(paths.conflictsDir, `${String(id).replace(/[^a-f0-9]/giu, "")}.json`), { force: true });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/* ---------------------------------------------------------------- */
|
|
320
|
+
/* merge one project into the derived clone */
|
|
321
|
+
/* ---------------------------------------------------------------- */
|
|
322
|
+
|
|
323
|
+
async function mergeProject({ target, repoDir, conflicts, existingConflictPaths, report }) {
|
|
324
|
+
const repoProjectDir = path.join(repoDir, "projects", target.folder);
|
|
325
|
+
const metaFile = path.join(repoProjectDir, "project.json");
|
|
326
|
+
let meta;
|
|
327
|
+
try {
|
|
328
|
+
meta = JSON.parse(await fsp.readFile(metaFile, "utf8"));
|
|
329
|
+
} catch {
|
|
330
|
+
meta = undefined;
|
|
331
|
+
}
|
|
332
|
+
const me = await currentMachine();
|
|
333
|
+
if (meta && typeof meta.fingerprint === "string" && meta.fingerprint.length > 0 && meta.fingerprint !== target.fingerprint) {
|
|
334
|
+
report.warnings.push(`${target.slug}: в репозитории проект с другим fingerprint (${meta.fingerprint}) — пропущен`);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const local = await localSyncMap(target);
|
|
338
|
+
const base = await readBaseMap(target.folder);
|
|
339
|
+
const remote = await remoteSyncMap(repoProjectDir);
|
|
340
|
+
const pathsToMerge = new Set([...local.map.keys(), ...base.keys(), ...remote.keys()]);
|
|
341
|
+
let wroteLocal = false;
|
|
342
|
+
let wroteRepo = false;
|
|
343
|
+
for (const relativePath of [...pathsToMerge].sort()) {
|
|
344
|
+
if (existingConflictPaths.has(relativePath)) continue; // wait for the user's choice
|
|
345
|
+
const localText = local.map.get(relativePath);
|
|
346
|
+
const baseText = base.get(relativePath);
|
|
347
|
+
const remoteText = remote.get(relativePath);
|
|
348
|
+
let merged;
|
|
349
|
+
let conflict = false;
|
|
350
|
+
if (localText === undefined && remoteText === undefined) continue;
|
|
351
|
+
if (remoteText === undefined) {
|
|
352
|
+
if (baseText === undefined) {
|
|
353
|
+
merged = localText; // brand new local file
|
|
354
|
+
} else {
|
|
355
|
+
conflict = true; // another machine deleted it; never delete silently
|
|
356
|
+
}
|
|
357
|
+
} else if (localText === undefined) {
|
|
358
|
+
merged = remoteText; // import into a local project that does not have this file
|
|
359
|
+
} else if (localText === remoteText) {
|
|
360
|
+
continue;
|
|
361
|
+
} else if (baseText !== undefined && localText === baseText) {
|
|
362
|
+
merged = remoteText; // fast-forward
|
|
363
|
+
} else if (baseText !== undefined && remoteText === baseText) {
|
|
364
|
+
merged = localText; // publish local change
|
|
365
|
+
} else if (baseText === undefined) {
|
|
366
|
+
const localTemplate = SECTION_FILES.includes(relativePath) && isTemplateContent(localText);
|
|
367
|
+
const remoteTemplate = SECTION_FILES.includes(relativePath) && isTemplateContent(remoteText);
|
|
368
|
+
if (localTemplate && !remoteTemplate) merged = remoteText;
|
|
369
|
+
else if (!localTemplate && remoteTemplate) merged = localText;
|
|
370
|
+
else conflict = true;
|
|
371
|
+
} else {
|
|
372
|
+
const three = await mergeThreeWay({
|
|
373
|
+
localText,
|
|
374
|
+
baseText,
|
|
375
|
+
remoteText,
|
|
376
|
+
tmpDir: paths.tmpDir,
|
|
377
|
+
});
|
|
378
|
+
if (three.ok) {
|
|
379
|
+
merged = three.text;
|
|
380
|
+
} else if (JOURNAL_FILE.test(relativePath)) {
|
|
381
|
+
const union = mergeJournalAdditive(localText, remoteText);
|
|
382
|
+
if (union.conflicts.length === 0) merged = union.text;
|
|
383
|
+
else conflict = true;
|
|
384
|
+
} else {
|
|
385
|
+
conflict = true;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (conflict) {
|
|
389
|
+
conflicts.push({
|
|
390
|
+
folder: target.folder,
|
|
391
|
+
projectSlug: target.slug,
|
|
392
|
+
projectName: target.name,
|
|
393
|
+
path: relativePath,
|
|
394
|
+
localText: localText ?? "",
|
|
395
|
+
remoteText: remoteText ?? "",
|
|
396
|
+
baseText: baseText ?? "",
|
|
397
|
+
});
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (merged === undefined) continue;
|
|
401
|
+
const canonical = String(merged).replace(/\r\n/gu, "\n").trimEnd();
|
|
402
|
+
const currentLocal = local.map.get(relativePath);
|
|
403
|
+
if (currentLocal !== canonical) {
|
|
404
|
+
const localRaw = local.raw.get(relativePath) ?? "";
|
|
405
|
+
const nextLocal = localFileText(relativePath, canonical, localRaw);
|
|
406
|
+
await writeFileAtomic(path.join(target.directory, ...relativePath.split("/")), nextLocal);
|
|
407
|
+
local.map.set(relativePath, canonical);
|
|
408
|
+
wroteLocal = true;
|
|
409
|
+
}
|
|
410
|
+
if (remoteText !== canonical) {
|
|
411
|
+
await writeTreeFile(repoProjectDir, relativePath, `${canonical}\n`);
|
|
412
|
+
wroteRepo = true;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
// project metadata is part of the exchange
|
|
416
|
+
const nextMeta = {
|
|
417
|
+
key: target.projectKey,
|
|
418
|
+
fingerprint: target.fingerprint,
|
|
419
|
+
slug: target.slug,
|
|
420
|
+
name: target.name,
|
|
421
|
+
updatedAt: nowIso(),
|
|
422
|
+
lastWriter: me.machineId,
|
|
423
|
+
lastWriterLabel: me.label,
|
|
424
|
+
};
|
|
425
|
+
const metaText = `${JSON.stringify(nextMeta, null, 2)}\n`;
|
|
426
|
+
const previousMeta = await readTextIfExists(metaFile);
|
|
427
|
+
if (previousMeta !== metaText) {
|
|
428
|
+
await ensureDir(repoProjectDir);
|
|
429
|
+
await writeFileAtomic(metaFile, metaText);
|
|
430
|
+
wroteRepo = true;
|
|
431
|
+
}
|
|
432
|
+
if (wroteLocal) report.files += 1;
|
|
433
|
+
if (wroteRepo) report.projects += 1;
|
|
434
|
+
report.entries.push({ folder: target.folder, files: local.map.size, changed: wroteLocal || wroteRepo });
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/* ---------------------------------------------------------------- */
|
|
438
|
+
/* one sync attempt: fetch → reset → merge → commit → push */
|
|
439
|
+
/* ---------------------------------------------------------------- */
|
|
440
|
+
|
|
441
|
+
async function attemptSync(config, conflicts, report) {
|
|
442
|
+
const dir = repoDirectory(config);
|
|
443
|
+
await cloneRepo({ directory: dir, repo: config.repo });
|
|
444
|
+
await ensureRepoIdentity(dir, await currentMachine());
|
|
445
|
+
const remote = await verifyRemote(config.repo);
|
|
446
|
+
if (remote.branches.length === 0) {
|
|
447
|
+
await seedEmptyRepo(dir, config.branch, await currentMachine());
|
|
448
|
+
}
|
|
449
|
+
const fetch = await fetchOrigin(dir, config.branch);
|
|
450
|
+
if (!fetch.ok) throw new BridgeError("github/fetch-failed", `не удалось получить изменения из GitHub: ${short(fetch.stderr)}`);
|
|
451
|
+
if (!(await remoteHeadExists(dir, config.branch))) {
|
|
452
|
+
throw new BridgeError("github/no-branch", `в репозитории нет ветки ${config.branch}`);
|
|
453
|
+
}
|
|
454
|
+
await resetToRemote(dir, config.branch);
|
|
455
|
+
const { targets, skipped } = await localTargets(config);
|
|
456
|
+
report.skipped.push(...skipped);
|
|
457
|
+
const storedConflicts = await listConflicts();
|
|
458
|
+
await refreshStoredConflicts(dir, targets, storedConflicts);
|
|
459
|
+
const freshStored = await listConflicts();
|
|
460
|
+
for (const target of targets) {
|
|
461
|
+
const existingConflictPaths = new Set(
|
|
462
|
+
freshStored.filter((conflict) => conflict.projectSlug === target.slug).map((conflict) => conflict.path),
|
|
463
|
+
);
|
|
464
|
+
try {
|
|
465
|
+
await mergeProject({ target, repoDir: dir, conflicts, existingConflictPaths, report });
|
|
466
|
+
} catch (error) {
|
|
467
|
+
report.warnings.push(`${target.slug}: ${error instanceof Error ? error.message : String(error)}`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
const message = `sync: ${(await currentMachine()).label} · ${nowIso()}${conflicts.length > 0 ? ` · conflicts: ${conflicts.length}` : ""}`;
|
|
471
|
+
const commit = await commitRepo(dir, message);
|
|
472
|
+
report.committed = commit.committed;
|
|
473
|
+
const push = await pushRepo(dir, config.branch);
|
|
474
|
+
if (!push.ok) {
|
|
475
|
+
return { ok: false, error: `push отклонён: ${short(push.stderr)}` };
|
|
476
|
+
}
|
|
477
|
+
return { ok: true, dir, targets };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function short(value, max = 240) {
|
|
481
|
+
const text = String(value ?? "").replace(/\s+/gu, " ").trim();
|
|
482
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function doSync({ manual = false, reason = "manual" } = {}) {
|
|
486
|
+
const config = await currentConfig();
|
|
487
|
+
if (typeof config.repo !== "string" || config.repo.trim().length === 0) {
|
|
488
|
+
throw new BridgeError("github/not-configured", "подключите приватный GitHub-репозиторий в настройках");
|
|
489
|
+
}
|
|
490
|
+
if (!(await gitVersion())) throw new BridgeError("github/no-git", "git не найден в системе");
|
|
491
|
+
// Safety gate: never push dossiers into a repository that became public or whose
|
|
492
|
+
// visibility can no longer be confirmed. The UI shows it, the Host enforces it.
|
|
493
|
+
if (isRepoSlug(config.repo)) {
|
|
494
|
+
const visibility = await visibilityOf(config.repo);
|
|
495
|
+
if (visibility === "public") {
|
|
496
|
+
throw new BridgeError(
|
|
497
|
+
"github/repo-public",
|
|
498
|
+
`репозиторий ${config.repo} публичный. Синхронизация остановлена: сделайте репозиторий приватным и повторите`,
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
if (visibility !== "private") {
|
|
502
|
+
throw new BridgeError(
|
|
503
|
+
"github/repo-unverified",
|
|
504
|
+
`не удалось подтвердить, что ${config.repo} приватный. Синхронизация остановлена; проверьте gh auth login и доступ`,
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
const conflicts = [];
|
|
509
|
+
const report = { projects: 0, files: 0, skipped: [], warnings: [], entries: [], committed: false, conflicts: 0, pushed: false };
|
|
510
|
+
let lastError;
|
|
511
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
512
|
+
try {
|
|
513
|
+
const result = await attemptSync(config, conflicts, report);
|
|
514
|
+
if (!result.ok) {
|
|
515
|
+
lastError = result.error;
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
report.pushed = true;
|
|
519
|
+
// successful push: refresh base snapshots from the repository
|
|
520
|
+
for (const target of result.targets) {
|
|
521
|
+
const repoProjectDir = path.join(result.dir, "projects", target.folder);
|
|
522
|
+
const canonical = await remoteSyncMap(repoProjectDir);
|
|
523
|
+
await writeBaseMap(target.folder, canonical);
|
|
524
|
+
}
|
|
525
|
+
lastError = undefined;
|
|
526
|
+
break;
|
|
527
|
+
} catch (error) {
|
|
528
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
529
|
+
if (attempt === 0 && !(await exists(path.join(repoDirectory(config), ".git")))) {
|
|
530
|
+
// clone/verify errors are not retryable
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
for (const conflict of conflicts) {
|
|
536
|
+
await saveConflict(conflict);
|
|
537
|
+
}
|
|
538
|
+
report.conflicts = conflicts.length;
|
|
539
|
+
const next = await currentConfig();
|
|
540
|
+
if (lastError === undefined) {
|
|
541
|
+
next.lastSyncAt = nowIso();
|
|
542
|
+
next.lastError = null;
|
|
543
|
+
runtime.lastError = null;
|
|
544
|
+
} else {
|
|
545
|
+
next.lastError = lastError;
|
|
546
|
+
runtime.lastError = lastError;
|
|
547
|
+
await st.logLine(home, `sync failed (${reason}): ${lastError}`);
|
|
548
|
+
}
|
|
549
|
+
await st.saveConfig(home, next);
|
|
550
|
+
report.warnings = [...new Set(report.warnings)];
|
|
551
|
+
if (lastError !== undefined && report.pushed !== true) {
|
|
552
|
+
const error = new BridgeError("github/sync-failed", lastError);
|
|
553
|
+
error.report = report;
|
|
554
|
+
throw error;
|
|
555
|
+
}
|
|
556
|
+
return report;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async function syncNow(options = {}) {
|
|
560
|
+
if (running !== null) throw new BridgeError("sync/busy", "Синхронизация уже выполняется — подождите");
|
|
561
|
+
running = (async () => doSync({ manual: true, reason: options.reason ?? "manual" }))().finally(() => {
|
|
562
|
+
running = null;
|
|
563
|
+
});
|
|
564
|
+
return running;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/* ---------------------------------------------------------------- */
|
|
568
|
+
/* conflicts resolution */
|
|
569
|
+
/* ---------------------------------------------------------------- */
|
|
570
|
+
|
|
571
|
+
async function resolveConflict(id, choice) {
|
|
572
|
+
const validChoice = choice === "remote" || choice === "both" ? choice : "local";
|
|
573
|
+
const conflict = await readConflict(id);
|
|
574
|
+
if (conflict === undefined) throw new BridgeError("github/conflict-not-found", "конфликт не найден (возможно, уже разрешён)");
|
|
575
|
+
const config = await currentConfig();
|
|
576
|
+
const { targets } = await localTargets(config);
|
|
577
|
+
const target = targets.find((item) => item.slug === conflict.projectSlug);
|
|
578
|
+
if (!target) throw new BridgeError("github/project-not-found", `проект не найден локально: ${conflict.projectSlug}`);
|
|
579
|
+
let merged = conflict.localText ?? "";
|
|
580
|
+
if (validChoice === "remote") merged = conflict.remoteText ?? "";
|
|
581
|
+
if (validChoice === "both") {
|
|
582
|
+
if (JOURNAL_FILE.test(conflict.path)) {
|
|
583
|
+
const union = mergeJournalAdditive(conflict.localText ?? "", conflict.remoteText ?? "");
|
|
584
|
+
merged = union.text;
|
|
585
|
+
} else {
|
|
586
|
+
const three = await mergeThreeWay({
|
|
587
|
+
localText: conflict.localText ?? "",
|
|
588
|
+
baseText: conflict.baseText ?? "",
|
|
589
|
+
remoteText: conflict.remoteText ?? "",
|
|
590
|
+
tmpDir: paths.tmpDir,
|
|
591
|
+
});
|
|
592
|
+
merged = three.ok ? three.text : unionText(conflict.localText ?? "", conflict.remoteText ?? "");
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
const dir = repoDirectory(config);
|
|
596
|
+
await cloneRepo({ directory: dir, repo: config.repo });
|
|
597
|
+
await ensureRepoIdentity(dir, await currentMachine());
|
|
598
|
+
const fetch = await fetchOrigin(dir, config.branch);
|
|
599
|
+
if (!fetch.ok) throw new BridgeError("github/fetch-failed", `не удалось получить изменения: ${short(fetch.stderr)}`);
|
|
600
|
+
await resetToRemote(dir, config.branch);
|
|
601
|
+
const repoProjectDir = path.join(dir, "projects", target.folder);
|
|
602
|
+
const canonical = String(merged).replace(/\r\n/gu, "\n").trimEnd();
|
|
603
|
+
const localRaw = await st.readLocalDossier(target.directory, {
|
|
604
|
+
maxFileBytes: DEFAULT_LIMITS.maxFileBytes,
|
|
605
|
+
maxBundleBytes: DEFAULT_LIMITS.maxBundleBytes,
|
|
606
|
+
maxFiles: DEFAULT_LIMITS.maxFiles,
|
|
607
|
+
});
|
|
608
|
+
const nextLocal = localFileText(conflict.path, canonical, localRaw.raw.get(conflict.path) ?? "");
|
|
609
|
+
await writeFileAtomic(path.join(target.directory, ...conflict.path.split("/")), nextLocal);
|
|
610
|
+
await writeTreeFile(repoProjectDir, conflict.path, `${canonical}\n`);
|
|
611
|
+
await commitRepo(dir, `resolve: ${conflict.projectSlug}/${conflict.path} (${validChoice}) · ${(await currentMachine()).label}`);
|
|
612
|
+
const push = await pushRepo(dir, config.branch);
|
|
613
|
+
if (!push.ok) {
|
|
614
|
+
throw new BridgeError("github/push-failed", `не удалось отправить разрешение конфликта: ${short(push.stderr)}`);
|
|
615
|
+
}
|
|
616
|
+
await removeConflict(id);
|
|
617
|
+
const canonicalMap = await remoteSyncMap(repoProjectDir);
|
|
618
|
+
await writeBaseMap(target.folder, canonicalMap);
|
|
619
|
+
const next = await currentConfig();
|
|
620
|
+
next.lastSyncAt = nowIso();
|
|
621
|
+
next.lastError = null;
|
|
622
|
+
await st.saveConfig(home, next);
|
|
623
|
+
return { ok: true, conflict: { ...conflict, resolved: validChoice } };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/* ---------------------------------------------------------------- */
|
|
627
|
+
/* history and restore */
|
|
628
|
+
/* ---------------------------------------------------------------- */
|
|
629
|
+
|
|
630
|
+
async function projectHistory(slug, options = {}) {
|
|
631
|
+
const config = await currentConfig();
|
|
632
|
+
if (config.repo.length === 0) throw new BridgeError("github/not-configured", "репозиторий не подключён");
|
|
633
|
+
if (!(await gitVersion())) throw new BridgeError("github/no-git", "git не найден в системе");
|
|
634
|
+
const { targets } = await localTargets(config);
|
|
635
|
+
const target = targets.find((item) => item.slug === slug);
|
|
636
|
+
if (!target) throw new BridgeError("github/project-not-found", `проект не найден локально: ${slug}`);
|
|
637
|
+
const dir = repoDirectory(config);
|
|
638
|
+
await cloneRepo({ directory: dir, repo: config.repo });
|
|
639
|
+
try {
|
|
640
|
+
await fetchOrigin(dir, config.branch);
|
|
641
|
+
} catch {
|
|
642
|
+
/* offline: show the history we already have */
|
|
643
|
+
}
|
|
644
|
+
const reference = (await remoteHeadExists(dir, config.branch)) ? `origin/${config.branch}` : "HEAD";
|
|
645
|
+
const limit = Math.max(1, Math.min(50, Number(options.limit) || 20));
|
|
646
|
+
const log = await runGit(
|
|
647
|
+
["log", `-${limit}`, "--format=%H%x09%aI%x09%an%x09%s", reference, "--", `projects/${target.folder}`],
|
|
648
|
+
{ cwd: dir },
|
|
649
|
+
);
|
|
650
|
+
if (!log.ok) throw new BridgeError("github/log-failed", `не удалось прочитать историю: ${short(log.stderr)}`);
|
|
651
|
+
const commits = log.stdout
|
|
652
|
+
.split(/\r?\n/u)
|
|
653
|
+
.filter((line) => line.trim().length > 0)
|
|
654
|
+
.map((line) => {
|
|
655
|
+
const [sha, date, author, subject] = line.split("\t");
|
|
656
|
+
return {
|
|
657
|
+
sha: String(sha ?? ""),
|
|
658
|
+
date: String(date ?? ""),
|
|
659
|
+
author: String(author ?? ""),
|
|
660
|
+
subject: String(subject ?? ""),
|
|
661
|
+
};
|
|
662
|
+
})
|
|
663
|
+
.filter((commit) => /^[0-9a-f]{7,40}$/u.test(commit.sha));
|
|
664
|
+
return { project: { slug: target.slug, name: target.name }, commits };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async function restoreVersion(slug, commitRaw) {
|
|
668
|
+
const commit = String(commitRaw ?? "").trim();
|
|
669
|
+
if (!/^[0-9a-f]{7,40}$/u.test(commit)) throw new BridgeError("github/bad-commit", "некорректный идентификатор версии");
|
|
670
|
+
const config = await currentConfig();
|
|
671
|
+
if (config.repo.length === 0) throw new BridgeError("github/not-configured", "репозиторий не подключён");
|
|
672
|
+
if (!(await gitVersion())) throw new BridgeError("github/no-git", "git не найден в системе");
|
|
673
|
+
const { targets } = await localTargets(config);
|
|
674
|
+
const target = targets.find((item) => item.slug === slug);
|
|
675
|
+
if (!target) throw new BridgeError("github/project-not-found", `проект не найден локально: ${slug}`);
|
|
676
|
+
const dir = repoDirectory(config);
|
|
677
|
+
await cloneRepo({ directory: dir, repo: config.repo });
|
|
678
|
+
await ensureRepoIdentity(dir, await currentMachine());
|
|
679
|
+
const fetch = await fetchOrigin(dir, config.branch);
|
|
680
|
+
if (!fetch.ok) throw new BridgeError("github/fetch-failed", `не удалось получить изменения: ${short(fetch.stderr)}`);
|
|
681
|
+
await resetToRemote(dir, config.branch);
|
|
682
|
+
const check = await runGit(["cat-file", "-e", `${commit}^{commit}`], { cwd: dir });
|
|
683
|
+
if (!check.ok) throw new BridgeError("github/commit-not-found", `версия ${commit.slice(0, 8)} не найдена в репозитории`);
|
|
684
|
+
const tree = await runGit(["ls-tree", "-r", "--name-only", commit, "--", `projects/${target.folder}`], { cwd: dir });
|
|
685
|
+
if (!tree.ok) throw new BridgeError("github/log-failed", `не удалось прочитать версию: ${short(tree.stderr)}`);
|
|
686
|
+
const prefix = `projects/${target.folder}/`;
|
|
687
|
+
const files = tree.stdout
|
|
688
|
+
.split(/\r?\n/u)
|
|
689
|
+
.filter((line) => line.startsWith(prefix))
|
|
690
|
+
.map((line) => line.slice(prefix.length).replaceAll("\\", "/"))
|
|
691
|
+
.filter((relativePath) => isAllowedSyncPath(relativePath));
|
|
692
|
+
if (files.length === 0) throw new BridgeError("github/empty-version", "в этой версии нет файлов досье проекта");
|
|
693
|
+
await st.backupDossier(home, target.slug, target.directory, { maxFileBytes: DEFAULT_LIMITS.maxFileBytes });
|
|
694
|
+
const current = await st.readLocalDossier(target.directory, {
|
|
695
|
+
maxFileBytes: DEFAULT_LIMITS.maxFileBytes,
|
|
696
|
+
maxBundleBytes: DEFAULT_LIMITS.maxBundleBytes,
|
|
697
|
+
maxFiles: DEFAULT_LIMITS.maxFiles,
|
|
698
|
+
});
|
|
699
|
+
const repoProjectDir = path.join(dir, "projects", target.folder);
|
|
700
|
+
let restored = 0;
|
|
701
|
+
for (const relativePath of files) {
|
|
702
|
+
const show = await runGit(["show", `${commit}:${prefix}${relativePath}`], { cwd: dir });
|
|
703
|
+
if (!show.ok) continue;
|
|
704
|
+
const canonical = String(show.stdout).replace(/\r\n/gu, "\n").trimEnd();
|
|
705
|
+
const nextLocal = localFileText(relativePath, canonical, current.raw.get(relativePath) ?? "");
|
|
706
|
+
await writeFileAtomic(path.join(target.directory, ...relativePath.split("/")), nextLocal);
|
|
707
|
+
await writeTreeFile(repoProjectDir, relativePath, `${canonical}\n`);
|
|
708
|
+
restored += 1;
|
|
709
|
+
}
|
|
710
|
+
if (restored === 0) throw new BridgeError("github/empty-version", "нечего восстанавливать: файлы версии не прочитаны");
|
|
711
|
+
await commitRepo(dir, `restore: ${target.slug} @ ${commit.slice(0, 8)} · ${(await currentMachine()).label}`);
|
|
712
|
+
const push = await pushRepo(dir, config.branch);
|
|
713
|
+
if (!push.ok) throw new BridgeError("github/push-failed", `не удалось отправить восстановление: ${short(push.stderr)}`);
|
|
714
|
+
const canonicalMap = await remoteSyncMap(repoProjectDir);
|
|
715
|
+
await writeBaseMap(target.folder, canonicalMap);
|
|
716
|
+
const next = await currentConfig();
|
|
717
|
+
next.lastSyncAt = nowIso();
|
|
718
|
+
next.lastError = null;
|
|
719
|
+
await st.saveConfig(home, next);
|
|
720
|
+
return { restored, commit, project: { slug: target.slug, name: target.name } };
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/* ---------------------------------------------------------------- */
|
|
724
|
+
/* portable USB transfer */
|
|
725
|
+
/* ---------------------------------------------------------------- */
|
|
726
|
+
|
|
727
|
+
async function portableTargets() {
|
|
728
|
+
const config = await currentConfig();
|
|
729
|
+
return (await localTargets(config)).targets;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
async function portableExport(destination) {
|
|
733
|
+
return exportPortable({
|
|
734
|
+
home,
|
|
735
|
+
destination,
|
|
736
|
+
targets: await portableTargets(),
|
|
737
|
+
machine: await currentMachine(),
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
async function portableScan(source) {
|
|
742
|
+
return scanPortable({ home, source, targets: await portableTargets() });
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
async function portableImport(source, options = {}) {
|
|
746
|
+
return importPortable({
|
|
747
|
+
home,
|
|
748
|
+
source,
|
|
749
|
+
targets: await portableTargets(),
|
|
750
|
+
projectSlugs: options.projectSlugs,
|
|
751
|
+
strategy: options.strategy,
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/* ---------------------------------------------------------------- */
|
|
756
|
+
/* configuration and status */
|
|
757
|
+
/* ---------------------------------------------------------------- */
|
|
758
|
+
|
|
759
|
+
async function configure(repoRaw, branchRaw) {
|
|
760
|
+
const repo = String(repoRaw ?? "").trim();
|
|
761
|
+
if (repo.length === 0) throw new BridgeError("github/bad-repo", "укажите репозиторий owner/name");
|
|
762
|
+
const branch = typeof branchRaw === "string" && branchRaw.trim().length > 0 ? branchRaw.trim() : "main";
|
|
763
|
+
repoRemoteUrl(repo); // validates shape
|
|
764
|
+
if (!(await gitVersion())) throw new BridgeError("github/no-git", "git не найден в системе — установите Git for Windows");
|
|
765
|
+
if (isRepoSlug(repo)) {
|
|
766
|
+
const visibility = await visibilityOf(repo, { force: true });
|
|
767
|
+
if (visibility === "public") {
|
|
768
|
+
throw new BridgeError(
|
|
769
|
+
"github/repo-public",
|
|
770
|
+
`репозиторий ${repo} публичный. Плагин работает только с приватным репозиторием — создайте приватный и подключите его`,
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
if (visibility !== "private") {
|
|
774
|
+
throw new BridgeError(
|
|
775
|
+
"github/repo-unverified",
|
|
776
|
+
`не удалось подтвердить, что ${repo} приватный. Проверьте, что GitHub CLI авторизован (gh auth login) и у вас есть доступ к репозиторию`,
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
const remote = await verifyRemote(repo);
|
|
781
|
+
if (remote.branches.length > 0 && !remote.branches.includes(branch)) {
|
|
782
|
+
throw new BridgeError("github/bad-branch", `в репозитории нет ветки ${branch}; доступны: ${remote.branches.join(", ")}`);
|
|
783
|
+
}
|
|
784
|
+
const config = await currentConfig();
|
|
785
|
+
config.repo = repo;
|
|
786
|
+
config.branch = branch;
|
|
787
|
+
await st.saveConfig(home, config);
|
|
788
|
+
try {
|
|
789
|
+
await cloneRepo({ directory: repoDirectory(config), repo });
|
|
790
|
+
} catch (error) {
|
|
791
|
+
config.lastError = error instanceof Error ? error.message : String(error);
|
|
792
|
+
await st.saveConfig(home, config);
|
|
793
|
+
throw error;
|
|
794
|
+
}
|
|
795
|
+
return { repo, branch, branches: remote.branches };
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Create a private exchange repository under the authenticated GitHub account
|
|
800
|
+
* and connect it. Never defaults to somebody else's account: the owner is the
|
|
801
|
+
* login of the current machine's `gh` session.
|
|
802
|
+
*/
|
|
803
|
+
async function createRepo(repoRaw) {
|
|
804
|
+
const github = await githubSnapshot({ force: true });
|
|
805
|
+
if (!github.available) {
|
|
806
|
+
throw new BridgeError("github/no-gh", "GitHub CLI (gh) не найден. Установите gh и выполните вход: gh auth login");
|
|
807
|
+
}
|
|
808
|
+
if (!github.authenticated) {
|
|
809
|
+
throw new BridgeError("github/no-auth", "Вы не вошли в GitHub. Выполните в терминале: gh auth login");
|
|
810
|
+
}
|
|
811
|
+
const repo = typeof repoRaw === "string" && repoRaw.trim().length > 0 ? repoRaw.trim() : suggestedExchangeRepo(github.login);
|
|
812
|
+
if (!isRepoSlug(repo)) throw new BridgeError("github/bad-repo", "укажите репозиторий в виде owner/name");
|
|
813
|
+
const visibility = await visibilityOf(repo, { force: true });
|
|
814
|
+
if (visibility === "public") {
|
|
815
|
+
throw new BridgeError("github/repo-public", `репозиторий ${repo} уже существует и он публичный — плагин работает только с приватными`);
|
|
816
|
+
}
|
|
817
|
+
let created = false;
|
|
818
|
+
if (visibility !== "private") {
|
|
819
|
+
try {
|
|
820
|
+
await ghApi.createRepo(repo);
|
|
821
|
+
created = true;
|
|
822
|
+
} catch (error) {
|
|
823
|
+
if (!(error instanceof BridgeError) || error.code !== "github/repo-exists") throw error;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
await configure(repo, "main");
|
|
827
|
+
return { created, repo, owner: github.login, status: await status() };
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
async function setEnabled(enabled) {
|
|
831
|
+
const config = await currentConfig();
|
|
832
|
+
if (enabled === true && config.repo.length === 0) {
|
|
833
|
+
throw new BridgeError("github/not-configured", "сначала подключите приватный репозиторий");
|
|
834
|
+
}
|
|
835
|
+
config.enabled = enabled === true;
|
|
836
|
+
await st.saveConfig(home, config);
|
|
837
|
+
return { enabled: config.enabled };
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
async function setOptions(options = {}) {
|
|
841
|
+
const config = await currentConfig();
|
|
842
|
+
if (Number.isFinite(Number(options.intervalSeconds))) {
|
|
843
|
+
config.intervalSeconds = Math.max(60, Math.min(3600, Math.trunc(Number(options.intervalSeconds))));
|
|
844
|
+
}
|
|
845
|
+
if (typeof options.autoSync === "boolean") config.autoSync = options.autoSync;
|
|
846
|
+
if (Array.isArray(options.excludeProjects)) {
|
|
847
|
+
config.excludeProjects = options.excludeProjects.filter((item) => typeof item === "string").slice(0, 200);
|
|
848
|
+
}
|
|
849
|
+
await st.saveConfig(home, config);
|
|
850
|
+
return { intervalSeconds: config.intervalSeconds, autoSync: config.autoSync, excludeProjects: config.excludeProjects };
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
async function status() {
|
|
854
|
+
const config = await currentConfig();
|
|
855
|
+
const me = await currentMachine();
|
|
856
|
+
const gitAvailable = Boolean(await gitVersion());
|
|
857
|
+
const github = await githubSnapshot();
|
|
858
|
+
const configured = config.repo.length > 0;
|
|
859
|
+
const repoVisibilityValue = configured ? await visibilityOf(config.repo) : null;
|
|
860
|
+
const dir = configured ? repoDirectory(config) : "";
|
|
861
|
+
const repoCloned = configured ? await exists(path.join(dir, ".git")) : false;
|
|
862
|
+
const conflicts = await listConflicts();
|
|
863
|
+
const resolved = await localTargets(config);
|
|
864
|
+
const targets = resolved.targets;
|
|
865
|
+
const skipped = resolved.skipped;
|
|
866
|
+
const repoProjects = [];
|
|
867
|
+
if (configured && repoCloned) {
|
|
868
|
+
try {
|
|
869
|
+
for (const entry of await fsp.readdir(path.join(dir, "projects"), { withFileTypes: true })) {
|
|
870
|
+
if (!entry.isDirectory()) continue;
|
|
871
|
+
try {
|
|
872
|
+
const meta = JSON.parse(await fsp.readFile(path.join(dir, "projects", entry.name, "project.json"), "utf8"));
|
|
873
|
+
repoProjects.push({ folder: entry.name, slug: meta.slug ?? entry.name, name: meta.name ?? meta.slug ?? entry.name, fingerprint: meta.fingerprint });
|
|
874
|
+
} catch {
|
|
875
|
+
repoProjects.push({ folder: entry.name, slug: entry.name, name: entry.name });
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
} catch {
|
|
879
|
+
/* empty repository */
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
const projects = [];
|
|
883
|
+
let localChanges = 0;
|
|
884
|
+
for (const target of targets) {
|
|
885
|
+
const local = await localSyncMap(target);
|
|
886
|
+
const base = await readBaseMap(target.folder);
|
|
887
|
+
const hasConflict = conflicts.some((conflict) => conflict.projectSlug === target.slug);
|
|
888
|
+
let state = "synced";
|
|
889
|
+
if (hasConflict) state = "conflict";
|
|
890
|
+
else if (mapDiffers(local.map, base)) {
|
|
891
|
+
state = "local-changes";
|
|
892
|
+
localChanges += 1;
|
|
893
|
+
}
|
|
894
|
+
projects.push({ slug: target.slug, name: target.name, status: state, lastSyncAt: config.lastSyncAt ?? null });
|
|
895
|
+
}
|
|
896
|
+
const localSlugs = new Set(targets.map((target) => target.slug));
|
|
897
|
+
for (const repoProject of repoProjects) {
|
|
898
|
+
if (localSlugs.has(repoProject.slug)) continue;
|
|
899
|
+
projects.push({ slug: repoProject.slug, name: repoProject.name, status: "not-opened", lastSyncAt: null });
|
|
900
|
+
}
|
|
901
|
+
const conflictPreviews = conflicts.map((conflict) => ({
|
|
902
|
+
id: conflict.id,
|
|
903
|
+
projectSlug: conflict.projectSlug,
|
|
904
|
+
projectName: conflict.projectName,
|
|
905
|
+
path: conflict.path,
|
|
906
|
+
localPreview: preview(conflict.localText),
|
|
907
|
+
remotePreview: preview(conflict.remoteText),
|
|
908
|
+
createdAt: conflict.createdAt,
|
|
909
|
+
}));
|
|
910
|
+
return {
|
|
911
|
+
ok: true,
|
|
912
|
+
configured,
|
|
913
|
+
enabled: config.enabled,
|
|
914
|
+
repo: config.repo,
|
|
915
|
+
branch: config.branch,
|
|
916
|
+
repoUrl: configured ? repoWebUrl(config.repo) : "",
|
|
917
|
+
repoVisibility: repoVisibilityValue,
|
|
918
|
+
localDir: dir,
|
|
919
|
+
gitAvailable,
|
|
920
|
+
github: { ...github, repoVisibility: repoVisibilityValue },
|
|
921
|
+
suggestedRepo: github.suggestedRepo,
|
|
922
|
+
repoCloned,
|
|
923
|
+
lastSyncAt: config.lastSyncAt ?? null,
|
|
924
|
+
lastError: config.lastError ?? null,
|
|
925
|
+
machine: { id: me.machineId, label: me.label },
|
|
926
|
+
pending: { localChanges, unpushed: false },
|
|
927
|
+
conflicts: conflictPreviews,
|
|
928
|
+
projects,
|
|
929
|
+
skipped,
|
|
930
|
+
options: {
|
|
931
|
+
intervalSeconds: config.intervalSeconds,
|
|
932
|
+
autoSync: config.autoSync,
|
|
933
|
+
excludeProjects: config.excludeProjects,
|
|
934
|
+
},
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function preview(text, max = 900) {
|
|
939
|
+
const value = String(text ?? "");
|
|
940
|
+
return value.length > max ? `${value.slice(0, max - 1)}…` : value;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
async function autoSync({ reason = "timer" } = {}) {
|
|
944
|
+
if (running !== null) return { skipped: true, reason: "busy" };
|
|
945
|
+
const config = await currentConfig();
|
|
946
|
+
if (!config.enabled || !config.autoSync || config.repo.length === 0) return { skipped: true, reason: "disabled" };
|
|
947
|
+
if (!(await gitVersion())) return { skipped: true, reason: "no-git" };
|
|
948
|
+
const interval = Math.max(60, Math.min(3600, config.intervalSeconds)) * 1000;
|
|
949
|
+
if (Date.now() - runtime.lastAutoAt < interval) return { skipped: true, reason: "interval" };
|
|
950
|
+
runtime.lastAutoAt = Date.now();
|
|
951
|
+
try {
|
|
952
|
+
return await syncNow({ reason });
|
|
953
|
+
} catch (error) {
|
|
954
|
+
runtime.lastError = error instanceof Error ? error.message : String(error);
|
|
955
|
+
await st.logLine(home, `auto sync failed (${reason}): ${runtime.lastError}`);
|
|
956
|
+
return { ok: false, error: runtime.lastError };
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function isBusy() {
|
|
961
|
+
return running !== null;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
return {
|
|
965
|
+
init,
|
|
966
|
+
status,
|
|
967
|
+
configure,
|
|
968
|
+
createRepo,
|
|
969
|
+
setEnabled,
|
|
970
|
+
history: projectHistory,
|
|
971
|
+
restoreVersion,
|
|
972
|
+
portableExport,
|
|
973
|
+
portableScan,
|
|
974
|
+
portableImport,
|
|
975
|
+
setOptions,
|
|
976
|
+
syncNow,
|
|
977
|
+
resolveConflict,
|
|
978
|
+
autoSync,
|
|
979
|
+
config: currentConfig,
|
|
980
|
+
machine: currentMachine,
|
|
981
|
+
paths,
|
|
982
|
+
isBusy,
|
|
983
|
+
};
|
|
984
|
+
}
|