@hiveku-apps/sync 0.3.6 → 0.5.0
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/README.md +183 -9
- package/bin/hiveku-sync.mjs +178 -28
- package/package.json +1 -1
- package/src/claude-md.mjs +193 -0
- package/src/codex-prompt-tools.json +2 -1
- package/src/codex.mjs +234 -45
- package/src/init.mjs +60 -53
- package/src/knowledge-files.mjs +306 -0
- package/src/memory-owner.mjs +464 -0
- package/src/projects.mjs +126 -69
- package/src/pull.mjs +182 -78
- package/src/pullRecord.mjs +98 -0
- package/src/pullTree.mjs +211 -0
- package/src/push.mjs +230 -94
- package/src/pushGuards.mjs +100 -0
- package/src/saveFiles.mjs +111 -0
- package/src/util.mjs +0 -10
- package/src/versionName.mjs +569 -0
- package/src/versions.mjs +811 -0
package/src/pull.mjs
CHANGED
|
@@ -1,87 +1,118 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `hiveku-sync pull` — fetches
|
|
3
|
-
*
|
|
2
|
+
* `hiveku-sync pull` — fetches the account's memory, rules, skills,
|
|
3
|
+
* shortcuts, specialists and profiles with ONE memory_list call and writes
|
|
4
|
+
* each entry as a local .md file, filed by its owner (knowledge-files.mjs):
|
|
5
|
+
*
|
|
6
|
+
* rules/sales/<name>.md, skills/seo/<name>.md, memory/helpdesk/<name>.md, ...
|
|
7
|
+
* <type folder>/shared/<name>.md for an entry no agent owns
|
|
8
|
+
* <type folder>/<owner>/not-followed/<name>.md for an entry filed under an
|
|
9
|
+
* owner that no agent reads (the Memory page hides it)
|
|
10
|
+
*
|
|
11
|
+
* The owner is the `owner` memory_list returns (the builder's one owner rule),
|
|
12
|
+
* or the same rule from memory-owner.mjs when an older builder does not return
|
|
13
|
+
* it. The chief of staff's own `_account:*` rows and Voice and pronunciation
|
|
14
|
+
* are written where the Memory page shows them, read-only.
|
|
15
|
+
*
|
|
16
|
+
* It also refreshes the folder layout and memory rules in CLAUDE.md, the part
|
|
17
|
+
* between hiveku-sync's markers (claude-md.mjs).
|
|
4
18
|
*
|
|
5
19
|
* The account memory (domains `account` and `account-suggestions`) is not a
|
|
6
20
|
* knowledge entry: it is classified explicitly, never written under memory/,
|
|
7
21
|
* and pulled once from account_memory_get into a read-only
|
|
8
22
|
* hiveku-data/account/ACCOUNT_MEMORY.md (see account-memory.mjs).
|
|
9
23
|
*
|
|
24
|
+
* A file an earlier pull wrote for an entry this pull files somewhere else
|
|
25
|
+
* (an older hiveku-sync wrote flat <type>/<name>.md files), or no longer
|
|
26
|
+
* mirrors, is moved to .hiveku/set-aside/knowledge/<time>/: never deleted.
|
|
27
|
+
* A file whose entry is gone from Hiveku stays where it is and is listed as
|
|
28
|
+
* removed. When the listing fails, nothing is moved or reported.
|
|
29
|
+
*
|
|
10
30
|
* Safety: refuses to overwrite when the .env API key no longer matches the
|
|
11
31
|
* manifest's recorded key preview (prevents pulling Account A's data into a
|
|
12
32
|
* folder previously synced for Account B — the 2026-05-11 cross-tenant bug
|
|
13
33
|
* class).
|
|
14
34
|
*/
|
|
15
35
|
|
|
16
|
-
import {
|
|
36
|
+
import { existsSync } from 'node:fs';
|
|
37
|
+
import { mkdir, readFile, rename } from 'node:fs/promises';
|
|
38
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
17
39
|
|
|
18
40
|
import { HivekuMcpClient } from './mcp.mjs';
|
|
19
|
-
import {
|
|
20
|
-
loadDotenv,
|
|
21
|
-
readEnv,
|
|
22
|
-
safeSlug,
|
|
23
|
-
writeFileAtomic,
|
|
24
|
-
readJson,
|
|
25
|
-
toFrontmatter,
|
|
26
|
-
extractDepartmentTag,
|
|
27
|
-
TYPE_TO_FOLDER,
|
|
28
|
-
SUPPORTED_TYPES,
|
|
29
|
-
} from './util.mjs';
|
|
41
|
+
import { loadDotenv, readEnv, writeFileAtomic, readJson, SUPPORTED_TYPES } from './util.mjs';
|
|
30
42
|
import {
|
|
31
43
|
ACCOUNT_MEMORY_FILE,
|
|
32
|
-
classifyMemoryEntry,
|
|
33
44
|
ACCOUNT_MEMORY_TOOL,
|
|
34
45
|
normalizeAccountMemory,
|
|
35
46
|
renderAccountMemoryFile,
|
|
36
47
|
resolveAccountId,
|
|
37
48
|
writeReadOnly,
|
|
38
49
|
} from './account-memory.mjs';
|
|
50
|
+
import { planKnowledgeFiles } from './knowledge-files.mjs';
|
|
51
|
+
import { refreshClaudeMd } from './claude-md.mjs';
|
|
39
52
|
|
|
40
53
|
const MANIFEST_PATH = '.hiveku/manifest.json';
|
|
54
|
+
export const KNOWLEDGE_SET_ASIDE_DIR = '.hiveku/set-aside/knowledge';
|
|
41
55
|
|
|
42
56
|
function keyPreview(apiKey) {
|
|
43
57
|
return apiKey.slice(0, 10);
|
|
44
58
|
}
|
|
45
59
|
|
|
46
|
-
|
|
47
|
-
|
|
60
|
+
/** `rel` under `root`, or null when it would land outside it (a manifest path is data, not trusted). */
|
|
61
|
+
function insidePath(root, rel) {
|
|
62
|
+
const base = resolve(root);
|
|
63
|
+
const abs = resolve(base, String(rel || '').replace(/^\/+/, ''));
|
|
64
|
+
return abs.startsWith(base + sep) ? abs : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Every account-level entry, of every type, in one call. */
|
|
68
|
+
async function fetchAll(client) {
|
|
69
|
+
const result = await client.callToolJson('memory_list', {});
|
|
48
70
|
if (!result?.data || !Array.isArray(result.data)) {
|
|
49
|
-
throw new Error(
|
|
71
|
+
throw new Error('memory_list returned unexpected shape');
|
|
50
72
|
}
|
|
51
73
|
return result.data;
|
|
52
74
|
}
|
|
53
75
|
|
|
54
|
-
function entryFilename(entry) {
|
|
55
|
-
const name = entry.name || entry.domain.replace(/^_[^:]+:/, '');
|
|
56
|
-
return `${safeSlug(name)}.md`;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function renderEntry(entry, type) {
|
|
60
|
-
const body = entry.content ?? '';
|
|
61
|
-
const department = extractDepartmentTag(body);
|
|
62
|
-
const frontmatter = toFrontmatter({
|
|
63
|
-
id: entry.id,
|
|
64
|
-
name: entry.name,
|
|
65
|
-
type,
|
|
66
|
-
domain: entry.domain,
|
|
67
|
-
department,
|
|
68
|
-
project_id: entry.project_id,
|
|
69
|
-
version: entry.version,
|
|
70
|
-
updated_at: entry.updated_at,
|
|
71
|
-
});
|
|
72
|
-
return `${frontmatter}${body}`;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
76
|
/**
|
|
76
77
|
* Write hiveku-data/account/ACCOUNT_MEMORY.md from account_memory_get, read-only.
|
|
77
78
|
* Returns { file, version, suggestions, accountId }, or throws (the caller
|
|
78
|
-
* records the error and keeps the previous copy).
|
|
79
|
+
* records the error and keeps the previous copy). Pass `accountId` when it is
|
|
80
|
+
* already known (null included) to skip looking it up again.
|
|
79
81
|
*/
|
|
80
|
-
export async function pullAccountMemory({ client, baseDir, fetchedAt = new Date().toISOString() }) {
|
|
82
|
+
export async function pullAccountMemory({ client, baseDir, fetchedAt = new Date().toISOString(), accountId }) {
|
|
81
83
|
const memory = normalizeAccountMemory(await client.callToolJson(ACCOUNT_MEMORY_TOOL, {}));
|
|
82
|
-
const
|
|
83
|
-
await writeReadOnly(join(baseDir, ACCOUNT_MEMORY_FILE), renderAccountMemoryFile({ memory, accountId, fetchedAt }));
|
|
84
|
-
return { file: ACCOUNT_MEMORY_FILE, version: memory.version, suggestions: memory.suggestions.length, accountId };
|
|
84
|
+
const id = accountId === undefined ? await resolveAccountId(client) : accountId;
|
|
85
|
+
await writeReadOnly(join(baseDir, ACCOUNT_MEMORY_FILE), renderAccountMemoryFile({ memory, accountId: id, fetchedAt }));
|
|
86
|
+
return { file: ACCOUNT_MEMORY_FILE, version: memory.version, suggestions: memory.suggestions.length, accountId: id };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The `domain` an earlier pull wrote into a file's front matter, or null. */
|
|
90
|
+
async function domainInFile(path) {
|
|
91
|
+
try {
|
|
92
|
+
const text = await readFile(path, 'utf8');
|
|
93
|
+
const fm = text.match(/^---\n([\s\S]*?)\n---\n/);
|
|
94
|
+
const line = fm?.[1].match(/^domain: "((?:[^"\\]|\\.)*)"$/m);
|
|
95
|
+
return line ? line[1].replace(/\\"/g, '"') : null;
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Move files an earlier pull wrote out of the tree, into one timestamped folder. Returns the moved paths. */
|
|
102
|
+
async function setAside(baseDir, relPaths) {
|
|
103
|
+
if (relPaths.length === 0) return { moved: [], dir: null };
|
|
104
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
105
|
+
const dir = join(baseDir, KNOWLEDGE_SET_ASIDE_DIR, stamp);
|
|
106
|
+
const moved = [];
|
|
107
|
+
for (const rel of relPaths) {
|
|
108
|
+
const from = insidePath(baseDir, rel);
|
|
109
|
+
const to = insidePath(dir, rel);
|
|
110
|
+
if (!from || !to || !existsSync(from)) continue;
|
|
111
|
+
await mkdir(dirname(to), { recursive: true });
|
|
112
|
+
await rename(from, to);
|
|
113
|
+
moved.push(rel);
|
|
114
|
+
}
|
|
115
|
+
return { moved, dir: moved.length ? dir : null };
|
|
85
116
|
}
|
|
86
117
|
|
|
87
118
|
export async function runPull({ baseDir = process.cwd(), profile, client: injectedClient } = {}) {
|
|
@@ -117,35 +148,70 @@ export async function runPull({ baseDir = process.cwd(), profile, client: inject
|
|
|
117
148
|
const client = injectedClient || new HivekuMcpClient({ baseUrl, apiKey, profile: activeProfile });
|
|
118
149
|
if (!injectedClient) await client.initialize();
|
|
119
150
|
|
|
120
|
-
const summary = {
|
|
151
|
+
const summary = {
|
|
152
|
+
total: 0,
|
|
153
|
+
by_type: {},
|
|
154
|
+
// Entries an agent reads (or every agent shares), by owner folder.
|
|
155
|
+
by_owner: {},
|
|
156
|
+
// Entries filed under <type>/<owner>/not-followed/: no agent reads them.
|
|
157
|
+
not_followed: 0,
|
|
158
|
+
errors: [],
|
|
159
|
+
account_memory: null,
|
|
160
|
+
account_rows_skipped: 0,
|
|
161
|
+
not_mirrored: { system: 0, hidden: 0 },
|
|
162
|
+
set_aside: [],
|
|
163
|
+
set_aside_dir: null,
|
|
164
|
+
claude_md: null,
|
|
165
|
+
};
|
|
121
166
|
const writtenFiles = new Set();
|
|
167
|
+
const fileDomains = {};
|
|
168
|
+
const readOnlyFiles = new Set();
|
|
169
|
+
const previousFiles = Array.isArray(manifest?.files) ? manifest.files : [];
|
|
170
|
+
const previousDomains = manifest?.file_domains && typeof manifest.file_domains === 'object' ? manifest.file_domains : {};
|
|
171
|
+
|
|
172
|
+
// The account id gives the read-only copies their Memory page link.
|
|
173
|
+
const accountId = await resolveAccountId(client);
|
|
174
|
+
|
|
175
|
+
let plan = null;
|
|
176
|
+
try {
|
|
177
|
+
plan = planKnowledgeFiles(await fetchAll(client), { accountId });
|
|
178
|
+
} catch (err) {
|
|
179
|
+
summary.errors.push({ type: 'memory_list', message: err.message });
|
|
180
|
+
}
|
|
122
181
|
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
182
|
+
if (plan) {
|
|
183
|
+
for (const type of SUPPORTED_TYPES) summary.by_type[type] = 0;
|
|
184
|
+
summary.account_rows_skipped = plan.skipped.account;
|
|
185
|
+
summary.not_mirrored = { system: plan.skipped.system, hidden: plan.skipped.hidden };
|
|
186
|
+
for (const file of plan.files) {
|
|
187
|
+
const abs = join(baseDir, file.rel);
|
|
188
|
+
if (file.readOnly) {
|
|
189
|
+
await writeReadOnly(abs, file.body);
|
|
190
|
+
readOnlyFiles.add(file.rel);
|
|
191
|
+
} else {
|
|
192
|
+
await writeFileAtomic(abs, file.body);
|
|
193
|
+
}
|
|
194
|
+
writtenFiles.add(file.rel);
|
|
195
|
+
fileDomains[file.rel] = file.domain;
|
|
196
|
+
summary.by_type[file.type] = (summary.by_type[file.type] ?? 0) + 1;
|
|
197
|
+
if (file.notFollowed) {
|
|
198
|
+
summary.not_followed += 1;
|
|
199
|
+
} else {
|
|
200
|
+
const owner = file.owner ?? 'shared';
|
|
201
|
+
summary.by_owner[owner] = (summary.by_owner[owner] ?? 0) + 1;
|
|
202
|
+
}
|
|
203
|
+
summary.total += 1;
|
|
131
204
|
}
|
|
132
|
-
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (
|
|
139
|
-
else summary.account_rows_skipped += 1;
|
|
205
|
+
} else {
|
|
206
|
+
// Nothing was listed, so nothing can be called moved or removed: keep
|
|
207
|
+
// what the last pull recorded, as it was.
|
|
208
|
+
for (const rel of previousFiles) {
|
|
209
|
+
if (rel === ACCOUNT_MEMORY_FILE) continue;
|
|
210
|
+
writtenFiles.add(rel);
|
|
211
|
+
if (typeof previousDomains[rel] === 'string') fileDomains[rel] = previousDomains[rel];
|
|
140
212
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
for (const entry of knowledge) {
|
|
145
|
-
const filename = entryFilename(entry);
|
|
146
|
-
const fullPath = join(baseDir, folder, filename);
|
|
147
|
-
await writeFileAtomic(fullPath, renderEntry(entry, type));
|
|
148
|
-
writtenFiles.add(`${folder}/${filename}`);
|
|
213
|
+
for (const rel of Array.isArray(manifest?.read_only) ? manifest.read_only : []) {
|
|
214
|
+
if (rel !== ACCOUNT_MEMORY_FILE && writtenFiles.has(rel)) readOnlyFiles.add(rel);
|
|
149
215
|
}
|
|
150
216
|
}
|
|
151
217
|
|
|
@@ -153,18 +219,37 @@ export async function runPull({ baseDir = process.cwd(), profile, client: inject
|
|
|
153
219
|
// A failure keeps the previous copy (and its manifest line), so it is not
|
|
154
220
|
// reported as removed upstream.
|
|
155
221
|
try {
|
|
156
|
-
summary.account_memory = await pullAccountMemory({ client, baseDir });
|
|
222
|
+
summary.account_memory = await pullAccountMemory({ client, baseDir, accountId });
|
|
157
223
|
writtenFiles.add(ACCOUNT_MEMORY_FILE);
|
|
158
224
|
} catch (err) {
|
|
159
225
|
summary.errors.push({ type: 'account memory', message: err.message });
|
|
160
|
-
if (
|
|
226
|
+
if (previousFiles.includes(ACCOUNT_MEMORY_FILE)) writtenFiles.add(ACCOUNT_MEMORY_FILE);
|
|
161
227
|
}
|
|
162
228
|
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
229
|
+
// Files the last pull wrote that this one did not. When Hiveku still lists
|
|
230
|
+
// the entry, its copy is now somewhere else (or no longer mirrored): the
|
|
231
|
+
// old copy is set aside. Otherwise the entry is gone upstream: the file is
|
|
232
|
+
// left on disk and reported, never deleted.
|
|
233
|
+
const notWritten = previousFiles.filter((rel) => !writtenFiles.has(rel));
|
|
234
|
+
const outOfPlace = [];
|
|
235
|
+
const removedRemotely = [];
|
|
236
|
+
for (const rel of notWritten) {
|
|
237
|
+
const path = insidePath(baseDir, rel);
|
|
238
|
+
// Already gone from disk (you deleted it): nothing to move, nothing to prune.
|
|
239
|
+
if (!path || !existsSync(path)) continue;
|
|
240
|
+
const domain = typeof previousDomains[rel] === 'string' ? previousDomains[rel] : await domainInFile(path);
|
|
241
|
+
if (plan && domain && plan.listed.has(domain)) outOfPlace.push(rel);
|
|
242
|
+
else removedRemotely.push(rel);
|
|
243
|
+
}
|
|
244
|
+
const tidy = await setAside(baseDir, outOfPlace);
|
|
245
|
+
summary.set_aside = tidy.moved;
|
|
246
|
+
summary.set_aside_dir = tidy.dir;
|
|
167
247
|
|
|
248
|
+
const sortedDomains = Object.fromEntries(Object.entries(fileDomains).sort(([a], [b]) => (a < b ? -1 : 1)));
|
|
249
|
+
// Which entry each file holds, when this pull (or the last) knew it. A failed
|
|
250
|
+
// listing over a folder an older release pulled knows none: leave it out,
|
|
251
|
+
// so readers (the Codex rules digest) keep reading the files on disk.
|
|
252
|
+
const knowsDomains = Boolean(plan) || (manifest?.file_domains && typeof manifest.file_domains === 'object');
|
|
168
253
|
await writeFileAtomic(
|
|
169
254
|
manifestPath,
|
|
170
255
|
JSON.stringify(
|
|
@@ -173,10 +258,13 @@ export async function runPull({ baseDir = process.cwd(), profile, client: inject
|
|
|
173
258
|
base_url: baseUrl,
|
|
174
259
|
profile: activeProfile,
|
|
175
260
|
last_sync_at: new Date().toISOString(),
|
|
261
|
+
// Filed by owner: <type folder>/<owner>/<name>.md (shared/ for no owner).
|
|
262
|
+
layout: 'by-owner',
|
|
176
263
|
counts: summary.by_type,
|
|
177
264
|
files: [...writtenFiles].sort(),
|
|
178
|
-
|
|
179
|
-
|
|
265
|
+
...(knowsDomains ? { file_domains: sortedDomains } : {}),
|
|
266
|
+
// Pulled for reading only: changed on the Memory page, never uploaded.
|
|
267
|
+
read_only: [ACCOUNT_MEMORY_FILE, ...[...readOnlyFiles].sort()],
|
|
180
268
|
removed_remotely_since_last_sync: removedRemotely,
|
|
181
269
|
},
|
|
182
270
|
null,
|
|
@@ -184,5 +272,21 @@ export async function runPull({ baseDir = process.cwd(), profile, client: inject
|
|
|
184
272
|
),
|
|
185
273
|
);
|
|
186
274
|
|
|
187
|
-
|
|
275
|
+
// The folder layout and memory rules in CLAUDE.md, which only `init` wrote
|
|
276
|
+
// before: refreshed, so a folder an older release made stops teaching the
|
|
277
|
+
// old ones (claude-md.mjs). A failure here is reported, not a failed pull.
|
|
278
|
+
try {
|
|
279
|
+
summary.claude_md = await refreshClaudeMd(baseDir);
|
|
280
|
+
} catch (err) {
|
|
281
|
+
summary.claude_md = { status: 'failed', message: err.message };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
let codexLane = false;
|
|
285
|
+
try {
|
|
286
|
+
codexLane = (await readFile(join(baseDir, 'AGENTS.md'), 'utf8')).includes('<!-- hiveku:begin -->');
|
|
287
|
+
} catch {
|
|
288
|
+
/* no Codex lane */
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return { ...summary, removed_remotely: removedRemotely, profile: activeProfile, baseUrl, codex_lane: codexLane };
|
|
188
292
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What pull-project wrote into a project folder, so the next pull can take
|
|
3
|
+
* away what the new tree no longer has.
|
|
4
|
+
*
|
|
5
|
+
* A pull writes every file of Your site (or of a branch) into
|
|
6
|
+
* projects/<slug>/ and never removed anything. Once `--branch` downloads the
|
|
7
|
+
* branch's real tree, switching a folder between Your site and a branch left
|
|
8
|
+
* the other tree's files behind, and the next push (which targets the pulled
|
|
9
|
+
* branch) sent them as new files and saved them as a version: a branch-only
|
|
10
|
+
* page ended up on Your site. A file deleted on Hiveku since the last pull
|
|
11
|
+
* came back the same way.
|
|
12
|
+
*
|
|
13
|
+
* So each pull records the paths it wrote (.hiveku/pulled/<slug>.json in the
|
|
14
|
+
* account folder), and the next pull moves the recorded files the new tree
|
|
15
|
+
* lacks out of the project folder, into .hiveku/set-aside/<slug>/<time>/ in
|
|
16
|
+
* the account folder: never deleted, and outside everything push reads. A
|
|
17
|
+
* file the pull did not write (your own new file) is never moved; the pull
|
|
18
|
+
* lists it instead, because a push would add it. The run hints hiveku-sync
|
|
19
|
+
* used to write as RUN.md in the project folder (they were pushed into the
|
|
20
|
+
* site) now live in the project folder's .hiveku/RUN.md; an old generated
|
|
21
|
+
* RUN.md is set aside like a stale file.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync } from 'node:fs';
|
|
25
|
+
import { mkdir, readFile, rename } from 'node:fs/promises';
|
|
26
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
27
|
+
|
|
28
|
+
import { readJson, writeFileAtomic } from './util.mjs';
|
|
29
|
+
import { localPathFor } from './pullTree.mjs';
|
|
30
|
+
|
|
31
|
+
const PULLED_DIR = '.hiveku/pulled';
|
|
32
|
+
const SET_ASIDE_DIR = '.hiveku/set-aside';
|
|
33
|
+
/** Where pull-project writes its run hints inside the project folder (never pushed: push skips dot folders). */
|
|
34
|
+
export const RUN_HINTS_FILE = '.hiveku/RUN.md';
|
|
35
|
+
|
|
36
|
+
/** A project path as the local walk spells it (no leading slash). */
|
|
37
|
+
export function recordPath(projectPath) {
|
|
38
|
+
return String(projectPath || '').replace(/^\/+/, '');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The paths the last pull wrote into projects/<slug>/, or null when no pull recorded them (an older hiveku-sync). */
|
|
42
|
+
export async function readPulledFiles(baseDir, slug) {
|
|
43
|
+
const data = await readJson(join(baseDir, PULLED_DIR, `${slug}.json`));
|
|
44
|
+
return data && Array.isArray(data.files) ? data : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function writePulledFiles(baseDir, slug, { branch, files }) {
|
|
48
|
+
const record = { branch: branch || null, pulled_at: new Date().toISOString(), files: [...new Set(files.map(recordPath))].sort() };
|
|
49
|
+
await writeFileAtomic(join(baseDir, PULLED_DIR, `${slug}.json`), JSON.stringify(record, null, 2));
|
|
50
|
+
return record;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Is this the RUN.md an older hiveku-sync generated for this project (not the site's own)? */
|
|
54
|
+
async function isGeneratedRunMd(dest, projectId) {
|
|
55
|
+
try {
|
|
56
|
+
const text = await readFile(join(dest, 'RUN.md'), 'utf8');
|
|
57
|
+
return text.includes(`Hiveku project id: \`${projectId}\``) && /## Ship changes back/.test(text);
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* After a complete download: move the files the previous pull wrote that the
|
|
65
|
+
* new tree lacks out of the folder, and name the other local files the tree
|
|
66
|
+
* lacks. `localFiles` are the folder's files as push sees them (relative
|
|
67
|
+
* paths). Returns { setAside: [paths], setAsideDir, notOnTarget: [paths] }.
|
|
68
|
+
*/
|
|
69
|
+
export async function setAsideStaleFiles({ baseDir, slug, dest, projectId, previous, treePaths, localFiles }) {
|
|
70
|
+
const inTree = new Set([...treePaths].map(recordPath));
|
|
71
|
+
const wrotePreviously = new Set((previous?.files || []).map(recordPath));
|
|
72
|
+
const extra = localFiles.filter((p) => !inTree.has(p));
|
|
73
|
+
const stale = [];
|
|
74
|
+
const notOnTarget = [];
|
|
75
|
+
for (const path of extra) {
|
|
76
|
+
if (wrotePreviously.has(path) || (path === 'RUN.md' && (await isGeneratedRunMd(dest, projectId)))) stale.push(path);
|
|
77
|
+
else notOnTarget.push(path);
|
|
78
|
+
}
|
|
79
|
+
if (stale.length === 0) return { setAside: [], setAsideDir: null, notOnTarget };
|
|
80
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
81
|
+
const setAsideDir = join(baseDir, SET_ASIDE_DIR, slug, stamp);
|
|
82
|
+
const moved = [];
|
|
83
|
+
for (const path of stale) {
|
|
84
|
+
const from = localPathFor(dest, path);
|
|
85
|
+
const to = localPathFor(setAsideDir, path);
|
|
86
|
+
if (!from || !to || !existsSync(from)) continue;
|
|
87
|
+
await mkdir(dirname(to), { recursive: true });
|
|
88
|
+
await rename(from, to);
|
|
89
|
+
moved.push(path);
|
|
90
|
+
}
|
|
91
|
+
return { setAside: moved, setAsideDir: moved.length ? setAsideDir : null, notOnTarget };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A folder path for a person, relative to the account folder when it is inside it. */
|
|
95
|
+
export function shownPath(baseDir, abs) {
|
|
96
|
+
const rel = relative(baseDir, abs);
|
|
97
|
+
return rel && !rel.startsWith('..') ? rel.split(sep).join('/') : abs;
|
|
98
|
+
}
|
package/src/pullTree.mjs
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Download a project's files (Your site, or a branch's working tree) into a
|
|
3
|
+
* local folder, one page at a time, so a site of any size can be pulled.
|
|
4
|
+
*
|
|
5
|
+
* First choice: project_vcs_checkout with `limit` (and `cursor` after the
|
|
6
|
+
* first page). Paths come in byte order; a page also stops at 16 MB and a
|
|
7
|
+
* single bigger file comes alone, so no file is ever cut short. The answer
|
|
8
|
+
* carries `head_commit_id` and, on a branch, `working_tree_etag` (the tree the
|
|
9
|
+
* page came from: when it changes between pages someone saved on the branch
|
|
10
|
+
* mid-download, and the download starts over). An unpaged answer (no
|
|
11
|
+
* `next_cursor` key: a builder or MCP server from before paging) is the whole
|
|
12
|
+
* tree in one answer, which is complete too.
|
|
13
|
+
*
|
|
14
|
+
* Fallback: project_files_bulk_get paged by `cursor` (it never refuses a large
|
|
15
|
+
* site). Used when checkout cannot page here: a Your-site checkout over 150 MB
|
|
16
|
+
* answers 413 content_too_large to a caller whose `limit` was not forwarded,
|
|
17
|
+
* and an older server may not have paging at all. Files over its per-file cap
|
|
18
|
+
* come back empty with `truncated: true` and are fetched one by one with
|
|
19
|
+
* project_file_get.
|
|
20
|
+
*
|
|
21
|
+
* Builder contract: hiveku_builder feature/versions-core
|
|
22
|
+
* src/app/api/olympus/builder/projects/[projectId]/vcs/checkout/route.ts and
|
|
23
|
+
* .../files-bulk/route.ts.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { mkdir } from 'node:fs/promises';
|
|
27
|
+
import { dirname, resolve, sep } from 'node:path';
|
|
28
|
+
|
|
29
|
+
import { writeFileAtomic } from './util.mjs';
|
|
30
|
+
import { branchLabel, readToolError } from './versions.mjs';
|
|
31
|
+
|
|
32
|
+
export const CHECKOUT_PAGE_FILES = 500;
|
|
33
|
+
const BULK_MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
34
|
+
const BULK_MAX_TOTAL_BYTES = 32 * 1024 * 1024;
|
|
35
|
+
const MAX_RESTARTS = 3;
|
|
36
|
+
const BINARY_SENTINEL = '__BINARY_BASE64__';
|
|
37
|
+
|
|
38
|
+
/** Answers that mean the project or branch is not there: another route will not help. */
|
|
39
|
+
const FINAL_CODES = new Set(['branch_missing', 'invalid_branch', 'project_missing']);
|
|
40
|
+
|
|
41
|
+
/** The bytes to write for one file of an answer, or null when it carries no content. */
|
|
42
|
+
export function fileBytes(file) {
|
|
43
|
+
const content = file && (typeof file.content === 'string' ? file.content : typeof file.file_content === 'string' ? file.file_content : null);
|
|
44
|
+
if (content === null) return null;
|
|
45
|
+
if (content.startsWith(BINARY_SENTINEL)) return Buffer.from(content.slice(BINARY_SENTINEL.length), 'base64');
|
|
46
|
+
if (file.encoding === 'base64') return Buffer.from(content, 'base64');
|
|
47
|
+
return Buffer.from(content, 'utf8');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Where a project path lands under `dest`, or null when it would land outside it. */
|
|
51
|
+
export function localPathFor(dest, projectPath) {
|
|
52
|
+
const rel = String(projectPath || '').replace(/^\/+/, '');
|
|
53
|
+
if (!rel) return null;
|
|
54
|
+
const root = resolve(dest);
|
|
55
|
+
const abs = resolve(root, rel);
|
|
56
|
+
return abs.startsWith(root + sep) ? abs : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
class Writer {
|
|
60
|
+
constructor(dest) {
|
|
61
|
+
this.dest = dest;
|
|
62
|
+
this.written = new Set();
|
|
63
|
+
this.skipped = [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async write(projectPath, bytes) {
|
|
67
|
+
const abs = localPathFor(this.dest, projectPath);
|
|
68
|
+
if (!abs || bytes === null) {
|
|
69
|
+
this.skipped.push(projectPath);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
73
|
+
await writeFileAtomic(abs, bytes);
|
|
74
|
+
this.written.add(projectPath);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
class BranchMovedError extends Error {}
|
|
79
|
+
|
|
80
|
+
/** Said when Hiveku's answer cannot be used; a pull that stops leaves every local file in place. */
|
|
81
|
+
const INCOMPLETE_LIST = 'Hiveku sent an incomplete file list. Nothing in this folder was removed; try the pull again.';
|
|
82
|
+
|
|
83
|
+
class IncompleteListError extends Error {
|
|
84
|
+
constructor() {
|
|
85
|
+
super(INCOMPLETE_LIST);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function friendlyError(error, branch) {
|
|
90
|
+
if (error.code === 'branch_missing') {
|
|
91
|
+
return new Error(`There is no ${branchLabel(branch)} on this project. Leave out --branch to pull Your site.`);
|
|
92
|
+
}
|
|
93
|
+
if (error.code === 'invalid_branch') return new Error(`"${branch}" is not a valid branch name.`);
|
|
94
|
+
// A route's own error text is written for people; the client's raw
|
|
95
|
+
// "Tool <name> errored: {...}" wrapper is not.
|
|
96
|
+
if (error.text && !/^Tool \S+ errored:/.test(error.text)) return new Error(error.text);
|
|
97
|
+
return new Error(`Hiveku could not send the files${error.status ? ` (it answered ${error.status})` : ''}. Nothing in this folder was removed; try the pull again.`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function viaCheckout({ client, projectId, branch, writer, log }) {
|
|
101
|
+
let cursor = null;
|
|
102
|
+
let etag;
|
|
103
|
+
let head = null;
|
|
104
|
+
let total = null;
|
|
105
|
+
let page = 0;
|
|
106
|
+
for (;;) {
|
|
107
|
+
const args = { project_id: projectId, limit: CHECKOUT_PAGE_FILES };
|
|
108
|
+
if (branch) args.branch = branch;
|
|
109
|
+
if (cursor) args.cursor = cursor;
|
|
110
|
+
const data = (await client.callToolJson('project_vcs_checkout', args))?.data;
|
|
111
|
+
if (!data || !Array.isArray(data.files)) throw new IncompleteListError();
|
|
112
|
+
page += 1;
|
|
113
|
+
if (page === 1) {
|
|
114
|
+
etag = data.working_tree_etag ?? null;
|
|
115
|
+
head = data.head_commit_id ?? null;
|
|
116
|
+
} else if (branch && (data.working_tree_etag ?? null) !== etag) {
|
|
117
|
+
throw new BranchMovedError('the branch changed between pages');
|
|
118
|
+
}
|
|
119
|
+
for (const file of data.files) await writer.write(file.path, fileBytes(file));
|
|
120
|
+
if (typeof data.total_files === 'number') total = data.total_files;
|
|
121
|
+
const paged = Object.prototype.hasOwnProperty.call(data, 'next_cursor');
|
|
122
|
+
if (!paged || !data.next_cursor) break;
|
|
123
|
+
if (data.next_cursor === cursor) throw new IncompleteListError();
|
|
124
|
+
cursor = data.next_cursor;
|
|
125
|
+
log(` files ${writer.written.size}${total !== null ? `/${total}` : ''}`);
|
|
126
|
+
}
|
|
127
|
+
return { head_commit_id: head, working_tree_etag: etag ?? null, lane: 'checkout' };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function viaFilesBulk({ client, projectId, branch, writer, log }) {
|
|
131
|
+
let cursor = null;
|
|
132
|
+
let etag;
|
|
133
|
+
let page = 0;
|
|
134
|
+
const truncated = [];
|
|
135
|
+
for (;;) {
|
|
136
|
+
const args = {
|
|
137
|
+
project_id: projectId,
|
|
138
|
+
include_assets: true,
|
|
139
|
+
max_file_bytes: BULK_MAX_FILE_BYTES,
|
|
140
|
+
max_total_bytes: BULK_MAX_TOTAL_BYTES,
|
|
141
|
+
};
|
|
142
|
+
if (branch) args.branch = branch;
|
|
143
|
+
if (cursor) args.cursor = cursor;
|
|
144
|
+
const data = (await client.callToolJson('project_files_bulk_get', args))?.data;
|
|
145
|
+
if (!data || !Array.isArray(data.files)) throw new IncompleteListError();
|
|
146
|
+
page += 1;
|
|
147
|
+
if (page === 1) etag = data.working_tree_etag ?? null;
|
|
148
|
+
else if (branch && (data.working_tree_etag ?? null) !== etag) throw new BranchMovedError('the branch changed between pages');
|
|
149
|
+
for (const file of data.files) {
|
|
150
|
+
if (file.truncated) truncated.push(file.path);
|
|
151
|
+
else await writer.write(file.path, fileBytes(file));
|
|
152
|
+
}
|
|
153
|
+
if (!data.partial || !data.next_cursor) break;
|
|
154
|
+
if (data.next_cursor === cursor) throw new IncompleteListError();
|
|
155
|
+
cursor = data.next_cursor;
|
|
156
|
+
log(` files ${writer.written.size}`);
|
|
157
|
+
}
|
|
158
|
+
// Files over the per-file cap, one by one. One that cannot be read (for
|
|
159
|
+
// example a stored copy that is gone) is reported as skipped; the rest of
|
|
160
|
+
// the pull goes on.
|
|
161
|
+
for (const path of truncated) {
|
|
162
|
+
if (writer.written.has(path)) continue;
|
|
163
|
+
const args = { project_id: projectId, file_path: path };
|
|
164
|
+
if (branch) args.branch = branch;
|
|
165
|
+
let body;
|
|
166
|
+
try {
|
|
167
|
+
body = await client.callToolJson('project_file_get', args);
|
|
168
|
+
} catch {
|
|
169
|
+
writer.skipped.push(path);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
await writer.write(path, fileBytes(body?.data || body?.file || null));
|
|
173
|
+
}
|
|
174
|
+
return { head_commit_id: null, working_tree_etag: etag ?? null, lane: 'files-bulk' };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Pull every file of Your site (branch null) or of `branch` into `dest`.
|
|
179
|
+
* Returns { written, paths: [written paths], skipped: [paths], head_commit_id,
|
|
180
|
+
* working_tree_etag, lane }. Throws, having removed nothing, when the tree
|
|
181
|
+
* could not be read completely.
|
|
182
|
+
*/
|
|
183
|
+
export async function downloadProjectTree({ client, projectId, branch = null, dest, log = console.log }) {
|
|
184
|
+
let lane = viaCheckout;
|
|
185
|
+
for (let attempt = 0; ; attempt++) {
|
|
186
|
+
const writer = new Writer(dest);
|
|
187
|
+
try {
|
|
188
|
+
const result = await lane({ client, projectId, branch, writer, log });
|
|
189
|
+
return { ...result, written: writer.written.size, paths: [...writer.written], skipped: writer.skipped };
|
|
190
|
+
} catch (err) {
|
|
191
|
+
if (err instanceof BranchMovedError) {
|
|
192
|
+
if (attempt >= MAX_RESTARTS) {
|
|
193
|
+
throw new Error(`${branchLabel(branch)} kept changing while it was downloading. Try again in a moment.`);
|
|
194
|
+
}
|
|
195
|
+
log(` ${branchLabel(branch)} changed while downloading; starting over.`);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (err instanceof IncompleteListError && lane !== viaCheckout) throw err;
|
|
199
|
+
const error = readToolError(err);
|
|
200
|
+
if (FINAL_CODES.has(error.code) || error.status === 401 || error.status === 403) throw friendlyError(error, branch);
|
|
201
|
+
if (lane === viaCheckout) {
|
|
202
|
+
log(error.code === 'content_too_large'
|
|
203
|
+
? ' This site is too large to send in one answer here; downloading it in pages instead.'
|
|
204
|
+
: ' Downloading through the file list instead.');
|
|
205
|
+
lane = viaFilesBulk;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
throw friendlyError(error, branch);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|