@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/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
  }