@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/src/pull.mjs CHANGED
@@ -1,87 +1,118 @@
1
1
  /**
2
- * `hiveku-sync pull` — fetches memory/rules/skills via the MCP memory_list tool
3
- * and writes them as local .md files under the current folder.
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 { join } from 'node:path';
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
- async function fetchType(client, type) {
47
- const result = await client.callToolJson('memory_list', { type });
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(`memory_list(${type}) returned unexpected shape`);
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 accountId = await resolveAccountId(client);
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 = { total: 0, by_type: {}, errors: [], account_memory: null, account_rows_skipped: 0 };
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
- for (const type of SUPPORTED_TYPES) {
124
- const folder = TYPE_TO_FOLDER[type];
125
- let entries;
126
- try {
127
- entries = await fetchType(client, type);
128
- } catch (err) {
129
- summary.errors.push({ type, message: err.message });
130
- continue;
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
- // The two account-memory domains are not knowledge entries: an older
133
- // server still lists them, and they would land as memory/account.md and
134
- // memory/account-suggestions.md (raw JSON Lines) looking editable. They
135
- // are written once, read-only, from account_memory_get below.
136
- const knowledge = [];
137
- for (const entry of entries) {
138
- if (classifyMemoryEntry(entry, folder).kind === 'knowledge') knowledge.push(entry);
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
- summary.by_type[type] = knowledge.length;
142
- summary.total += knowledge.length;
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 ((manifest?.files ?? []).includes(ACCOUNT_MEMORY_FILE)) writtenFiles.add(ACCOUNT_MEMORY_FILE);
226
+ if (previousFiles.includes(ACCOUNT_MEMORY_FILE)) writtenFiles.add(ACCOUNT_MEMORY_FILE);
161
227
  }
162
228
 
163
- // v1: don't delete stale files; just record them in the manifest so the
164
- // user can prune manually if they want. Avoids losing local edits.
165
- const previouslySeen = new Set(manifest?.files ?? []);
166
- const removedRemotely = [...previouslySeen].filter((f) => !writtenFiles.has(f));
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
- // Pulled for reading only: edited on the dashboard, never uploaded.
179
- read_only: [ACCOUNT_MEMORY_FILE],
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
- return { ...summary, removed_remotely: removedRemotely, profile: activeProfile, baseUrl };
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
+ }
@@ -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
+ }