@hiveku-apps/sync 0.3.5 → 0.4.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 +121 -8
- package/bin/hiveku-sync.mjs +139 -25
- package/package.json +1 -1
- package/src/codex-prompt-tools.json +253 -0
- package/src/codex.mjs +245 -28
- package/src/init.mjs +43 -14
- package/src/projects.mjs +126 -69
- 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/toml.mjs +666 -0
- package/src/versionName.mjs +569 -0
- package/src/versions.mjs +811 -0
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
|
+
}
|