@hiveku-apps/sync 0.3.6 → 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/src/projects.mjs CHANGED
@@ -1,15 +1,16 @@
1
1
  /**
2
2
  * `hiveku-sync` project commands — pull a Hiveku coder project into
3
- * ./projects/<slug>/, show diff vs remote, and commit edits back via MCP.
3
+ * ./projects/<slug>/, show diff vs remote, and (with `commit --github`) push
4
+ * edits to a connected GitHub branch via MCP.
4
5
  *
5
- * No native git clone. The MCP server handles GitHub commits via the
6
- * account's installed GitHub App — same code path as the v3 editor sidebar.
6
+ * No native git clone. Versions of Your site and of branches live in
7
+ * versions.mjs; the paged download pull-project uses lives in pullTree.mjs.
7
8
  */
8
9
 
9
10
  import { createHash } from 'node:crypto';
10
11
  import { existsSync } from 'node:fs';
11
- import { mkdir, readdir, readFile, stat } from 'node:fs/promises';
12
- import { dirname, join, relative, sep } from 'node:path';
12
+ import { readdir, readFile, stat } from 'node:fs/promises';
13
+ import { join, relative, sep } from 'node:path';
13
14
 
14
15
  import { HivekuMcpClient } from './mcp.mjs';
15
16
  import {
@@ -20,6 +21,9 @@ import {
20
21
  readJson,
21
22
  } from './util.mjs';
22
23
  import { isAccountMemoryPath } from './account-memory.mjs';
24
+ import { downloadProjectTree } from './pullTree.mjs';
25
+ import { readPulledFiles, RUN_HINTS_FILE, setAsideStaleFiles, shownPath, writePulledFiles } from './pullRecord.mjs';
26
+ import { branchFlag, branchLabel } from './versions.mjs';
23
27
  // NOTE: push.mjs imports helpers from this module; the cycle is safe because
24
28
  // both sides reference the imports only inside functions (never at module eval).
25
29
  import { computePushPlan } from './push.mjs';
@@ -100,25 +104,6 @@ export async function clientFromCwd({ profileOverride } = {}) {
100
104
  return { client, baseDir, baseUrl, profile };
101
105
  }
102
106
 
103
- async function listAllRemoteFiles(client, projectId) {
104
- const all = [];
105
- let page = 1;
106
- const limit = 500;
107
- while (true) {
108
- const result = await client.callToolJson('project_files_list', {
109
- project_id: projectId,
110
- page,
111
- limit,
112
- });
113
- const batch = result?.data || [];
114
- all.push(...batch);
115
- const totalPages = result?.pagination?.total_pages ?? 1;
116
- if (page >= totalPages || batch.length === 0) break;
117
- page += 1;
118
- }
119
- return all;
120
- }
121
-
122
107
  /**
123
108
  * Every local file push, commit and status consider. The pulled account memory
124
109
  * (hiveku-data/account/) is left out wherever it sits: it is a read-only copy
@@ -135,8 +120,7 @@ export async function walkLocalFiles(rootDir) {
135
120
  return;
136
121
  }
137
122
  for (const entry of entries) {
138
- if (entry.name.startsWith('.')) continue;
139
- if (IGNORE_DIRS.has(entry.name)) continue;
123
+ if (walkSkipsName(entry.name)) continue;
140
124
  const full = join(dir, entry.name);
141
125
  if (isAccountMemoryPath(relative(rootDir, full).split(sep).join('/'))) continue;
142
126
  if (entry.isDirectory()) {
@@ -150,6 +134,24 @@ export async function walkLocalFiles(rootDir) {
150
134
  return out;
151
135
  }
152
136
 
137
+ /** A file or folder name walkLocalFiles never enters: dot entries and build/tool folders. */
138
+ function walkSkipsName(name) {
139
+ return name.startsWith('.') || IGNORE_DIRS.has(name);
140
+ }
141
+
142
+ /**
143
+ * Could walkLocalFiles ever list this project path? A path it cannot list
144
+ * (a dot file such as .gitignore, anything under node_modules/ or .next/, the
145
+ * pulled account memory) always reads as missing locally, so it must never be
146
+ * treated as deleted here.
147
+ */
148
+ export function localWalkCanList(projectPath) {
149
+ const rel = String(projectPath || '').replace(/^\/+/, '');
150
+ if (!rel) return false;
151
+ if (rel.split('/').some((segment) => !segment || walkSkipsName(segment))) return false;
152
+ return !isAccountMemoryPath(rel);
153
+ }
154
+
153
155
  async function buildLocalManifest(rootDir) {
154
156
  const files = await walkLocalFiles(rootDir);
155
157
  const manifest = [];
@@ -376,6 +378,8 @@ export async function runProjectsList({ profile, client: injectedClient } = {})
376
378
 
377
379
  export async function runPullProject({ slugOrId, profile, branch, client: injectedClient, baseDir: baseDirOverride }) {
378
380
  if (!slugOrId) throw new Error('Provide a project slug or UUID');
381
+ // null = Your site (main); a bare --branch with no name is refused.
382
+ const pullBranch = branchFlag(branch) ?? null;
379
383
  const { client, baseDir } = await resolveClient({ client: injectedClient, profile, baseDir: baseDirOverride });
380
384
  const project = await resolveProject(client, slugOrId);
381
385
  // An external site has no file tree: the pull would write nothing and stamp
@@ -387,47 +391,45 @@ export async function runPullProject({ slugOrId, profile, branch, client: inject
387
391
 
388
392
  console.log(`Pulling ${project.name || project.slug || projectId} (type=${project.project_type || '?'})`);
389
393
  console.log(` Project ID: ${projectId}`);
394
+ console.log(` ${pullBranch ? `Branch: ${pullBranch}` : 'Your site'}`);
390
395
  console.log(` Destination: ${dest}`);
391
396
 
392
- const files = await listAllRemoteFiles(client, projectId);
393
- if (files.length === 0) {
397
+ // What the previous pull wrote here, so files the new tree lacks can be
398
+ // taken out of the folder (pullRecord.mjs).
399
+ const previous = await readPulledFiles(baseDir, localSlug);
400
+
401
+ // Paged, so a site of any size comes down (a site over 150 MB cannot be
402
+ // sent in one answer). See pullTree.mjs.
403
+ const tree = await downloadProjectTree({ client, projectId, branch: pullBranch, dest });
404
+ const written = tree.written;
405
+ const skipped = tree.skipped.length;
406
+ if (written === 0 && skipped === 0) {
394
407
  console.log(' No files to pull (project is empty).');
395
408
  }
396
409
 
397
- let written = 0;
398
- let skipped = 0;
399
- for (const fileMeta of files) {
400
- const result = await client.callToolJson('project_file_get', {
401
- project_id: projectId,
402
- file_path: fileMeta.file_path,
403
- });
404
- const fileBody = result?.file || result?.data?.file || result?.data;
405
- if (!fileBody || typeof fileBody.file_content !== 'string') {
406
- skipped += 1;
407
- continue;
408
- }
409
- let contentBuf;
410
- if (fileBody.file_content.startsWith('__BINARY_BASE64__')) {
411
- contentBuf = Buffer.from(
412
- fileBody.file_content.substring('__BINARY_BASE64__'.length),
413
- 'base64',
414
- );
415
- } else {
416
- contentBuf = Buffer.from(fileBody.file_content, 'utf8');
417
- }
418
- const outPath = join(dest, fileMeta.file_path);
419
- await mkdir(dirname(outPath), { recursive: true });
420
- await writeFileAtomic(outPath, contentBuf);
421
- written += 1;
422
- }
410
+ // The download is complete: move out what the previous pull wrote and this
411
+ // tree lacks (another branch's files, files deleted on Hiveku), so a push
412
+ // cannot send them back.
413
+ const localFiles = (await walkLocalFiles(dest)).map((abs) => relative(dest, abs).split(sep).join('/'));
414
+ const tidy = await setAsideStaleFiles({
415
+ baseDir,
416
+ slug: localSlug,
417
+ dest,
418
+ projectId,
419
+ previous,
420
+ treePaths: [...(tree.paths || []), ...tree.skipped],
421
+ localFiles,
422
+ });
423
+ await writePulledFiles(baseDir, localSlug, { branch: pullBranch, files: tree.paths || [] });
423
424
 
424
- // Drop a RUN.md keyed off project_type so the user knows how to start it.
425
+ // Run hints keyed off project_type, inside the folder's .hiveku/ so a push
426
+ // never sends them into the site.
425
427
  const runMd = [
426
428
  `# ${project.name || project.slug || projectId}`,
427
429
  '',
428
430
  `Project type: \`${project.project_type || 'unknown'}\``,
429
431
  `Hiveku project id: \`${projectId}\``,
430
- branch ? `Tracking branch: \`${branch}\`` : '',
432
+ pullBranch ? `Tracking branch: \`${pullBranch}\`` : '',
431
433
  '',
432
434
  '## Run locally',
433
435
  '',
@@ -439,15 +441,18 @@ export async function runPullProject({ slugOrId, profile, branch, client: inject
439
441
  '',
440
442
  "This folder is NOT a git clone — it's a snapshot. `push` writes your edits to the",
441
443
  'DEPLOY SOURCE (what `deploy` builds from), lane-routing images/fonts/video to the',
442
- 'S3/CDN asset store and everything else to versioned code files.',
444
+ 'S3/CDN asset store and everything else to the project files, then saves them as',
445
+ `one version of ${pullBranch ? `branch \`${pullBranch}\`` : 'Your site'}, named in plain language for the site owner.`,
443
446
  '',
444
447
  'From this folder:',
445
448
  '',
446
449
  '```bash',
447
450
  'hiveku-sync pull-env # project secrets → .env.local',
448
451
  'hiveku-sync status # diff local vs the deploy source',
449
- 'hiveku-sync push # save changes back',
452
+ 'hiveku-sync push -m "Updated the pricing section on the Home page"',
450
453
  'hiveku-sync push --deploy development --wait # ...then deploy and wait',
454
+ 'hiveku-sync versions # the version history',
455
+ 'hiveku-sync rollback <version id> # what going back would change (dry run)',
451
456
  '```',
452
457
  '',
453
458
  'Or ask Claude in this workspace to push/deploy for you.',
@@ -455,7 +460,7 @@ export async function runPullProject({ slugOrId, profile, branch, client: inject
455
460
  ]
456
461
  .filter((line) => line !== undefined)
457
462
  .join('\n');
458
- await writeFileAtomic(join(dest, 'RUN.md'), runMd);
463
+ await writeFileAtomic(join(dest, RUN_HINTS_FILE), runMd);
459
464
 
460
465
  // Persist project metadata so status/push know which project this folder maps to.
461
466
  const manifest = await readProjectsManifest(baseDir);
@@ -465,14 +470,46 @@ export async function runPullProject({ slugOrId, profile, branch, client: inject
465
470
  name: project.name || project.slug || localSlug,
466
471
  project_type: project.project_type || null,
467
472
  external_platform: project.external_platform || null,
468
- branch: branch || null,
473
+ branch: pullBranch,
474
+ // What the pull came from: the version it was at, and on a branch the
475
+ // working-tree fingerprint. push compares it with the one
476
+ // project_files_status reports, to see whether someone else saved on the
477
+ // branch since (pushGuards.mjs), and records the tree its own push left.
478
+ head_commit_id: tree.head_commit_id ?? null,
479
+ working_tree_etag: tree.working_tree_etag ?? null,
469
480
  last_pull_at: new Date().toISOString(),
470
481
  };
471
482
  await writeProjectsManifest(baseDir, manifest);
472
483
 
473
- console.log(` Wrote ${written} file${written !== 1 ? 's' : ''}${skipped ? ` (${skipped} skipped — no content)` : ''}`);
474
- console.log(` Run hints in ${join(dest, 'RUN.md')}`);
475
- return { dest, written, skipped, projectId, slug: localSlug };
484
+ console.log(` Wrote ${written} file${written !== 1 ? 's' : ''}${skipped ? ` (${skipped} skipped: no content or an unsafe path)` : ''}`);
485
+ if (tidy.setAside.length) {
486
+ const n = tidy.setAside.length;
487
+ console.log(
488
+ ` Moved ${n} file${n !== 1 ? 's' : ''} that ${n !== 1 ? 'are' : 'is'} not on ${branchLabel(pullBranch)} out of this folder ` +
489
+ `(an earlier pull wrote ${n !== 1 ? 'them' : 'it'}): they are in ${shownPath(baseDir, tidy.setAsideDir)}/`,
490
+ );
491
+ }
492
+ if (tidy.notOnTarget.length) {
493
+ const n = tidy.notOnTarget.length;
494
+ console.log(
495
+ ` ${n} file${n !== 1 ? 's' : ''} here ${n !== 1 ? 'are' : 'is'} not on ${branchLabel(pullBranch)} and a push would add ` +
496
+ `${n !== 1 ? 'them' : 'it'}: ${tidy.notOnTarget.slice(0, 10).join(', ')}${n > 10 ? ` and ${n - 10} more` : ''}. ` +
497
+ 'Keep the ones that are your own new work; move the others out of the folder before you push.',
498
+ );
499
+ }
500
+ console.log(` Run hints in ${join(dest, RUN_HINTS_FILE)}`);
501
+ return {
502
+ dest,
503
+ written,
504
+ skipped,
505
+ projectId,
506
+ slug: localSlug,
507
+ branch: pullBranch,
508
+ head_commit_id: tree.head_commit_id ?? null,
509
+ working_tree_etag: tree.working_tree_etag ?? null,
510
+ set_aside: tidy.setAside,
511
+ not_on_target: tidy.notOnTarget,
512
+ };
476
513
  }
477
514
 
478
515
  export async function findProjectFolder({ slug, baseDir }) {
@@ -507,7 +544,7 @@ function summarizeDiff(diff) {
507
544
  };
508
545
  }
509
546
 
510
- export async function runProjectStatus({ slug, profile, json = false, client: injectedClient, baseDir: baseDirOverride }) {
547
+ export async function runProjectStatus({ slug, profile, json = false, branch, client: injectedClient, baseDir: baseDirOverride }) {
511
548
  // Resolve baseDir as the account folder. If we're inside ./projects/<x>/,
512
549
  // walk up two levels. Otherwise treat cwd as the account folder.
513
550
  const cwd = process.cwd();
@@ -525,12 +562,16 @@ export async function runProjectStatus({ slug, profile, json = false, client: in
525
562
 
526
563
  // Deploy-source-accurate + asset-lane-aware — the SAME diff `push` runs, so
527
564
  // status never flags unchanged images as only_local (code goes through
528
- // project_files_status; assets through assets_list).
529
- const { diff, assetAll, assetToUpload, assetSkipped, plan } = await computePushPlan({
565
+ // project_files_status; assets through assets_list). A folder pulled from a
566
+ // branch diffs against that branch's working tree.
567
+ const flagged = branchFlag(branch);
568
+ const statusBranch = flagged !== undefined ? flagged : (info.branch && info.branch !== 'main' ? info.branch : null);
569
+ const { diff, remoteOnly, notMirrored, assetAll, assetToUpload, assetSkipped, plan } = await computePushPlan({
530
570
  client,
531
571
  projectId: info.project_id,
532
572
  folder,
533
573
  includeDeletes: true,
574
+ branch: statusBranch,
534
575
  });
535
576
 
536
577
  if (json) {
@@ -540,6 +581,11 @@ export async function runProjectStatus({ slug, profile, json = false, client: in
540
581
  slug: resolvedSlug,
541
582
  project_id: info.project_id,
542
583
  code: diff,
584
+ // only_remote split: what push --include-deletes would delete, and
585
+ // what this folder never holds (dot files, build folders, files
586
+ // Hiveku manages), which is never a deletion.
587
+ remote_only_deletable: remoteOnly,
588
+ remote_only_not_mirrored: notMirrored,
543
589
  assets: {
544
590
  to_upload: assetToUpload.map((f) => f.path),
545
591
  unchanged: assetSkipped,
@@ -556,10 +602,14 @@ export async function runProjectStatus({ slug, profile, json = false, client: in
556
602
 
557
603
  const changed = diff.changed?.length || 0;
558
604
  const onlyLocal = diff.only_local?.length || 0;
559
- const onlyRemote = diff.only_remote?.length || 0;
605
+ const onlyRemote = remoteOnly.length;
560
606
  const unchanged = diff.same_count || 0;
561
607
  console.log(`Project: ${resolvedSlug} (${info.project_id})`);
562
- console.log('Compared against the DEPLOY SOURCE (builder_code_versions + asset store).\n');
608
+ console.log(
609
+ statusBranch
610
+ ? `Compared against branch "${statusBranch}" (its working tree) and the shared image store.\n`
611
+ : 'Compared against Your site (what a deploy ships) and the shared image store.\n',
612
+ );
563
613
  console.log('Code:');
564
614
  console.log(` changed: ${changed}`);
565
615
  console.log(` added: ${onlyLocal}`);
@@ -579,10 +629,17 @@ export async function runProjectStatus({ slug, profile, json = false, client: in
579
629
  printList(diff.changed, 'M', 'Changed code:');
580
630
  printList(diff.only_local, '+', 'Added code (will be created on push):');
581
631
  printList(
582
- diff.only_remote,
632
+ remoteOnly,
583
633
  '-',
584
634
  'Remote-only code (deleted locally or never pulled — push --include-deletes to delete):',
635
+ (path) => path,
585
636
  );
637
+ if (notMirrored.length) {
638
+ console.log(
639
+ `\n${notMirrored.length} more file${notMirrored.length !== 1 ? 's are' : ' is'} only on Hiveku because this folder never holds ` +
640
+ 'them (hidden files such as .gitignore, build folders, files Hiveku manages). A push never deletes them.',
641
+ );
642
+ }
586
643
  printList(assetToUpload, 'A', 'Assets to upload (new or changed):');
587
644
  return { diff, plan };
588
645
  }
@@ -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
+ }