@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/README.md +183 -9
- package/bin/hiveku-sync.mjs +178 -28
- package/package.json +1 -1
- package/src/claude-md.mjs +193 -0
- package/src/codex-prompt-tools.json +2 -1
- package/src/codex.mjs +234 -45
- package/src/init.mjs +60 -53
- package/src/knowledge-files.mjs +306 -0
- package/src/memory-owner.mjs +464 -0
- package/src/projects.mjs +126 -69
- package/src/pull.mjs +182 -78
- 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/util.mjs +0 -10
- package/src/versionName.mjs +569 -0
- package/src/versions.mjs +811 -0
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
|
|
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.
|
|
6
|
-
*
|
|
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 {
|
|
12
|
-
import {
|
|
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
|
|
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
|
-
|
|
393
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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:
|
|
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
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
}
|