@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/src/init.mjs CHANGED
@@ -96,7 +96,7 @@ Hiveku dashboard under Account memory.
96
96
 
97
97
  Then run \`hiveku-sync pull\` to refresh the local copies.
98
98
 
99
- ## Coder projects (pull / status / push)
99
+ ## Coder projects (pull / push / versions)
100
100
 
101
101
  Each account has zero or more coder projects (Next.js, Vite, Python Lambda,
102
102
  Expo). To work on one locally:
@@ -105,25 +105,54 @@ Expo). To work on one locally:
105
105
  hiveku-sync projects # list the account's projects
106
106
  hiveku-sync pull-project <slug-or-id> # snapshot into ./projects/<slug>/
107
107
  cd projects/<slug>
108
- # follow the run instructions in RUN.md (npm install && npm run dev for nextjs, etc.)
108
+ # follow the run instructions in .hiveku/RUN.md (npm install && npm run dev for nextjs, etc.)
109
109
  \`\`\`
110
110
 
111
- This is **not a git clone** — the local folder is a snapshot. To check whether
112
- edits are in sync with the Hiveku DB, and to commit changes back to the
113
- project's connected GitHub branch (same path the v3 sidebar uses):
111
+ This is **not a git clone** — the local folder is a snapshot of **Your site**
112
+ (the project's main source; \`--branch <name>\` pulls a branch instead).
113
+ \`push\` saves your edits to Hiveku and then saves them as ONE **version**, which
114
+ can be rolled back later:
114
115
 
115
116
  \`\`\`bash
116
- hiveku-sync status # diff local vs Hiveku DB
117
- hiveku-sync push -m "fix: header overlap" # save + commit changed files via MCP
117
+ hiveku-sync status # diff local vs Hiveku
118
+ hiveku-sync push -m "Updated the pricing section on the Home page"
119
+ hiveku-sync versions # the version history, newest first
120
+ hiveku-sync rollback <version id> # dry run: what going back would change
121
+ hiveku-sync deploy production # publishing is always its own step
118
122
  \`\`\`
119
123
 
120
- You can also ask Claude in this workspace to:
121
- - "Pull project <slug>"
122
- - "Show me which files are out of sync with Hiveku"
123
- - "Commit the changes in projects/<slug> to the dev branch with message '...'"
124
-
125
- Claude has the \`project_files_list\`, \`project_file_get\`, \`project_files_status\`,
126
- and \`project_commit\` MCP tools available under the **${profile}** profile.
124
+ - **Version names are read by the site owner.** Describe what changed for their
125
+ visitors ("Added a contact form to the About page"): never file paths, file
126
+ extensions, \`fix:\`-style prefixes or tool names. A version holds everything
127
+ waiting on Your site, including the Hiveku AI's and the editor's changes, so
128
+ without \`-m\` push names it from all of that; \`--no-version\` skips it.
129
+ - **One version per change the owner would recognise**: push as often as you
130
+ like with \`--no-version\`, then save the version once (\`hiveku-sync commit -m "..."\`)
131
+ before you deploy.
132
+ - **Exit codes:** 0 done; 1 some files did not save, or the push was refused
133
+ (listed; no version, no deploy); 2 every file IS saved but the version was not
134
+ (it prints the \`hiveku-sync commit\` that retries it); 3 the files and the
135
+ version ARE saved and only the deploy failed.
136
+ - **On a branch, someone else may have saved since you pulled.** push then
137
+ refuses and lists the files that may hold their work: pull again (it replaces
138
+ your local copies, so keep your edits somewhere first), or \`--force\` to
139
+ overwrite. A pull moves the files an earlier pull wrote that are not on what
140
+ it pulls now (another branch's, or deleted on Hiveku) out of the project folder
141
+ into \`.hiveku/set-aside/\`, so they are never pushed back by mistake.
142
+ - **Rolling back** writes a NEW version equal to the old one, so it can be undone,
143
+ and it never publishes. Run the dry run, show the owner what it changes, and only
144
+ after they agree run the \`--apply\` command it prints. Then offer \`deploy\`
145
+ separately.
146
+
147
+ Through the MCP tools the same loop is: save (\`project_files_bulk_save\` /
148
+ \`project_file_save\`), verify, then \`project_vcs_commit({ project_id, message })\`
149
+ with **no files** (one version, plain-language name; 409 \`nothing_to_commit\`
150
+ means it is already a version), then \`deploy_site\`.
151
+ \`project_vcs_status\` says whether changes are waiting to be saved as a version;
152
+ \`project_vcs_rollback\` rolls back (dry run by default; applying needs the dry
153
+ run's \`head_commit_id\` as \`expected_head_commit_id\`, and on Your site its
154
+ \`live_fingerprint\` as \`expected_live_fingerprint\`). The \`project_*\` tools come
155
+ with the \`dev\` and \`full\` profiles (this folder uses **${profile}**).
127
156
 
128
157
  ## When Hiveku itself gets in your way
129
158
 
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
+ }