@gobi-ai/cli 0.6.14 → 0.6.15
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/dist/commands/sync.js +237 -12
- package/package.json +1 -1
- package/skills/gobi/SKILL.md +2 -2
package/dist/commands/sync.js
CHANGED
|
@@ -343,8 +343,12 @@ function matchesPaths(filePath, paths) {
|
|
|
343
343
|
async function performSync(baseUrl, vaultSlug, state, syncfilesChanges, privatefilesChanges, localFiles, opts, token) {
|
|
344
344
|
const body = {
|
|
345
345
|
cursor: state.cursor,
|
|
346
|
-
// dryRun:
|
|
347
|
-
|
|
346
|
+
// dryRun: suppress removes (destructive) but send adds so the server can
|
|
347
|
+
// compute the correct file scope for the preview. Privatefiles are fully
|
|
348
|
+
// suppressed — they don't affect file actions and should never mutate in dry-run.
|
|
349
|
+
syncfilesChanges: opts.dryRun
|
|
350
|
+
? { added: syncfilesChanges.added, removed: [] }
|
|
351
|
+
: syncfilesChanges,
|
|
348
352
|
privatefilesChanges: opts.dryRun ? { added: [], removed: [] } : privatefilesChanges,
|
|
349
353
|
clientFiles: localFiles,
|
|
350
354
|
uploadOnly: opts.uploadOnly,
|
|
@@ -357,6 +361,10 @@ export async function runSync(opts) {
|
|
|
357
361
|
const baseUrl = opts.webdriveUrl ?? WEBDRIVE_BASE_URL;
|
|
358
362
|
const gobiDir = join(vaultDir, ".gobi");
|
|
359
363
|
mkdirSync(gobiDir, { recursive: true });
|
|
364
|
+
const token = opts.authToken ?? (await getValidToken());
|
|
365
|
+
if (opts.execute) {
|
|
366
|
+
return executeSyncPlan(opts, baseUrl, token, gobiDir);
|
|
367
|
+
}
|
|
360
368
|
const state = loadSyncState(gobiDir);
|
|
361
369
|
// --full: treat this run as a first-time sync (re-check every file against the server)
|
|
362
370
|
if (opts.full) {
|
|
@@ -365,7 +373,6 @@ export async function runSync(opts) {
|
|
|
365
373
|
if (!jsonMode)
|
|
366
374
|
console.log("Full sync: ignoring cursor and hash cache.");
|
|
367
375
|
}
|
|
368
|
-
const token = opts.authToken ?? (await getValidToken());
|
|
369
376
|
// Read syncfiles whitelist
|
|
370
377
|
const syncfilesExistsLocally = existsSync(join(gobiDir, "syncfiles"));
|
|
371
378
|
const { patterns: currPatterns, contentHash: currSyncfilesHash } = readSyncfiles(gobiDir);
|
|
@@ -474,6 +481,26 @@ export async function runSync(opts) {
|
|
|
474
481
|
}
|
|
475
482
|
if (!jsonMode)
|
|
476
483
|
console.log(` ${syncResp.files.length} action(s).`);
|
|
484
|
+
// Write plan file if requested (dry-run + --plan-file)
|
|
485
|
+
if (opts.dryRun && opts.planFile) {
|
|
486
|
+
const offlineDeletions = Object.keys(state.hashCache).filter((p) => !localPathSet.has(p) && !existsSync(join(vaultDir, p)));
|
|
487
|
+
const plan = {
|
|
488
|
+
vaultSlug,
|
|
489
|
+
syncCursor: syncResp.cursor,
|
|
490
|
+
syncfilesHash: syncResp.syncfilesHash || null,
|
|
491
|
+
privatefilesHash: syncResp.privatefilesHash || null,
|
|
492
|
+
downloadSyncfiles: !!(syncResp.syncfilesHash &&
|
|
493
|
+
(syncResp.syncfilesHash !== state.syncfilesHash || !syncfilesExistsLocally)),
|
|
494
|
+
downloadPrivatefiles: !!(syncResp.privatefilesHash &&
|
|
495
|
+
(syncResp.privatefilesHash !== state.privatefilesHash || !privatefilesExistsLocally)),
|
|
496
|
+
actions: syncResp.files.filter((f) => matchesPaths(f.path, opts.paths ?? [])),
|
|
497
|
+
offlineDeletions,
|
|
498
|
+
createdAt: new Date().toISOString(),
|
|
499
|
+
};
|
|
500
|
+
await writeFile(opts.planFile, JSON.stringify(plan, null, 2));
|
|
501
|
+
if (!jsonMode)
|
|
502
|
+
console.log(`Plan written to ${opts.planFile}`);
|
|
503
|
+
}
|
|
477
504
|
// Process actions
|
|
478
505
|
let uploaded = 0, downloaded = 0, deletedLocally = 0, conflicts = 0, skipped = 0, errors = 0;
|
|
479
506
|
const errorDetails = [];
|
|
@@ -633,15 +660,17 @@ export async function runSync(opts) {
|
|
|
633
660
|
}
|
|
634
661
|
// Persist state (always, even on partial failures)
|
|
635
662
|
const finalCursor = Math.max(syncResp.cursor, maxMutationCursor !== null ? maxMutationCursor : 0);
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
663
|
+
if (!opts.dryRun) {
|
|
664
|
+
state.cursor = finalCursor;
|
|
665
|
+
state.syncfilesHash = syncResp.syncfilesHash || currSyncfilesHash;
|
|
666
|
+
// If the server returned an empty syncfilesHash the vault was deleted server-side
|
|
667
|
+
// (empty patterns path). Reset patterns so the next sync re-registers them as "added",
|
|
668
|
+
// which lets the 409 retry resurrect the vault.
|
|
669
|
+
state.patterns = syncResp.syncfilesHash === "" ? [] : effectivePatterns;
|
|
670
|
+
state.privatePatterns = effectivePrivatePatterns;
|
|
671
|
+
state.privatefilesHash = syncResp.privatefilesHash || state.privatefilesHash;
|
|
672
|
+
saveSyncState(gobiDir, state);
|
|
673
|
+
}
|
|
645
674
|
// Output summary
|
|
646
675
|
const result = {
|
|
647
676
|
uploaded,
|
|
@@ -672,6 +701,184 @@ export async function runSync(opts) {
|
|
|
672
701
|
process.exitCode = 1;
|
|
673
702
|
}
|
|
674
703
|
}
|
|
704
|
+
return result;
|
|
705
|
+
}
|
|
706
|
+
// ─── Execute Plan ─────────────────────────────────────────────────────────────
|
|
707
|
+
async function executeSyncPlan(opts, baseUrl, token, gobiDir) {
|
|
708
|
+
const { vaultSlug, dir: vaultDir, jsonMode } = opts;
|
|
709
|
+
if (!opts.planFile)
|
|
710
|
+
throw new GobiError("--execute requires --plan-file", "INVALID_OPTION");
|
|
711
|
+
if (!existsSync(opts.planFile))
|
|
712
|
+
throw new GobiError(`Plan file not found: ${opts.planFile}`, "PLAN_NOT_FOUND");
|
|
713
|
+
const plan = JSON.parse(readFileSync(opts.planFile, "utf-8"));
|
|
714
|
+
if (plan.vaultSlug !== vaultSlug)
|
|
715
|
+
throw new GobiError(`Plan is for vault "${plan.vaultSlug}", not "${vaultSlug}"`, "PLAN_MISMATCH");
|
|
716
|
+
// Validate conflict coverage upfront
|
|
717
|
+
const conflictActions = plan.actions.filter((a) => a.action === "conflict");
|
|
718
|
+
const canFallback = opts.conflict && opts.conflict !== "ask";
|
|
719
|
+
const unresolvedConflicts = conflictActions
|
|
720
|
+
.filter((a) => !opts.conflictChoices?.[a.path])
|
|
721
|
+
.map((a) => a.path);
|
|
722
|
+
if (unresolvedConflicts.length > 0 && !canFallback) {
|
|
723
|
+
throw new GobiError(`Unresolved conflicts — pass --conflict or add to --conflict-choices:\n${unresolvedConflicts.map((p) => ` ${p}`).join("\n")}`, "UNRESOLVED_CONFLICTS");
|
|
724
|
+
}
|
|
725
|
+
const state = loadSyncState(gobiDir);
|
|
726
|
+
// Execute offline deletions
|
|
727
|
+
let maxMutationCursor = null;
|
|
728
|
+
for (const path of plan.offlineDeletions) {
|
|
729
|
+
if (opts.downloadOnly)
|
|
730
|
+
continue;
|
|
731
|
+
if (!jsonMode)
|
|
732
|
+
console.log(` Deleting remote (offline deletion): ${path}`);
|
|
733
|
+
try {
|
|
734
|
+
const cursor = await webdriveDelete(baseUrl, vaultSlug, path, token);
|
|
735
|
+
if (cursor !== null && (maxMutationCursor === null || cursor > maxMutationCursor))
|
|
736
|
+
maxMutationCursor = cursor;
|
|
737
|
+
}
|
|
738
|
+
catch (err) {
|
|
739
|
+
if (!jsonMode)
|
|
740
|
+
console.error(` Error deleting remote ${path}: ${err.message}`);
|
|
741
|
+
}
|
|
742
|
+
delete state.hashCache[path];
|
|
743
|
+
}
|
|
744
|
+
// Execute actions from plan
|
|
745
|
+
let uploaded = 0, downloaded = 0, deletedLocally = 0, conflicts = 0, skipped = 0, errors = 0;
|
|
746
|
+
const errorDetails = [];
|
|
747
|
+
for (const entry of plan.actions) {
|
|
748
|
+
try {
|
|
749
|
+
const absPath = join(vaultDir, entry.path);
|
|
750
|
+
if (entry.action === "upload") {
|
|
751
|
+
if (opts.downloadOnly)
|
|
752
|
+
continue;
|
|
753
|
+
const content = readFileSync(absPath);
|
|
754
|
+
const hash = md5Hex(content);
|
|
755
|
+
const cursor = await webdrivePut(baseUrl, vaultSlug, entry.path, content, hash, token);
|
|
756
|
+
if (cursor !== null && (maxMutationCursor === null || cursor > maxMutationCursor))
|
|
757
|
+
maxMutationCursor = cursor;
|
|
758
|
+
state.hashCache[entry.path] = { hash, mtime: statSync(absPath).mtimeMs, size: content.length };
|
|
759
|
+
if (!jsonMode)
|
|
760
|
+
console.log(` Uploaded: ${entry.path}`);
|
|
761
|
+
uploaded++;
|
|
762
|
+
}
|
|
763
|
+
else if (entry.action === "download") {
|
|
764
|
+
if (opts.uploadOnly)
|
|
765
|
+
continue;
|
|
766
|
+
const content = await webdriveGet(baseUrl, vaultSlug, entry.path, token);
|
|
767
|
+
mkdirSync(dirname(absPath), { recursive: true });
|
|
768
|
+
await writeFile(absPath, content);
|
|
769
|
+
const hash = md5Hex(content);
|
|
770
|
+
state.hashCache[entry.path] = { hash, mtime: statSync(absPath).mtimeMs, size: content.length };
|
|
771
|
+
if (!jsonMode)
|
|
772
|
+
console.log(` Downloaded: ${entry.path}`);
|
|
773
|
+
downloaded++;
|
|
774
|
+
}
|
|
775
|
+
else if (entry.action === "delete_local") {
|
|
776
|
+
if (opts.uploadOnly)
|
|
777
|
+
continue;
|
|
778
|
+
if (!existsSync(absPath))
|
|
779
|
+
continue;
|
|
780
|
+
await trash(absPath);
|
|
781
|
+
delete state.hashCache[entry.path];
|
|
782
|
+
if (!jsonMode)
|
|
783
|
+
console.log(` Deleted local (moved to trash): ${entry.path}`);
|
|
784
|
+
deletedLocally++;
|
|
785
|
+
}
|
|
786
|
+
else if (entry.action === "conflict") {
|
|
787
|
+
conflicts++;
|
|
788
|
+
const choice = opts.conflictChoices?.[entry.path] ?? opts.conflict ?? "skip";
|
|
789
|
+
if (choice === "server") {
|
|
790
|
+
const content = await webdriveGet(baseUrl, vaultSlug, entry.path, token);
|
|
791
|
+
mkdirSync(dirname(absPath), { recursive: true });
|
|
792
|
+
await writeFile(absPath, content);
|
|
793
|
+
const hash = md5Hex(content);
|
|
794
|
+
state.hashCache[entry.path] = { hash, mtime: statSync(absPath).mtimeMs, size: content.length };
|
|
795
|
+
if (!jsonMode)
|
|
796
|
+
console.log(` Conflict resolved (server): ${entry.path}`);
|
|
797
|
+
downloaded++;
|
|
798
|
+
}
|
|
799
|
+
else if (choice === "client") {
|
|
800
|
+
if (!jsonMode)
|
|
801
|
+
console.log(` Conflict resolved (local kept): ${entry.path}`);
|
|
802
|
+
}
|
|
803
|
+
else {
|
|
804
|
+
skipped++;
|
|
805
|
+
if (!jsonMode)
|
|
806
|
+
console.log(` Conflict skipped: ${entry.path}`);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
catch (err) {
|
|
811
|
+
errors++;
|
|
812
|
+
const msg = err.message;
|
|
813
|
+
errorDetails.push({ path: entry.path, action: entry.action, error: msg });
|
|
814
|
+
if (!jsonMode)
|
|
815
|
+
console.error(` Error [${entry.action}] ${entry.path}: ${msg}`);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
// Download syncfiles/privatefiles as indicated by plan
|
|
819
|
+
const { patterns: currPatterns } = readSyncfiles(gobiDir);
|
|
820
|
+
let effectivePatterns = currPatterns;
|
|
821
|
+
if (plan.downloadSyncfiles) {
|
|
822
|
+
try {
|
|
823
|
+
const content = await webdriveGet(baseUrl, vaultSlug, ".gobi/syncfiles", token);
|
|
824
|
+
await writeFile(join(gobiDir, "syncfiles"), content);
|
|
825
|
+
effectivePatterns = readSyncfiles(gobiDir).patterns;
|
|
826
|
+
if (!jsonMode)
|
|
827
|
+
console.log(" Updated local syncfiles from server.");
|
|
828
|
+
}
|
|
829
|
+
catch (err) {
|
|
830
|
+
if (!jsonMode)
|
|
831
|
+
console.error(`Warning: Failed to download syncfiles: ${err.message}`);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
let effectivePrivatePatterns = readPrivatefiles(gobiDir);
|
|
835
|
+
if (plan.downloadPrivatefiles) {
|
|
836
|
+
try {
|
|
837
|
+
const content = await webdriveGet(baseUrl, vaultSlug, ".gobi/privatefiles", token);
|
|
838
|
+
await writeFile(join(gobiDir, "privatefiles"), content);
|
|
839
|
+
effectivePrivatePatterns = readPrivatefiles(gobiDir);
|
|
840
|
+
if (!jsonMode)
|
|
841
|
+
console.log(" Updated local privatefiles from server.");
|
|
842
|
+
}
|
|
843
|
+
catch (err) {
|
|
844
|
+
if (!jsonMode)
|
|
845
|
+
console.error(`Warning: Failed to download privatefiles: ${err.message}`);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
// Save state
|
|
849
|
+
const finalCursor = Math.max(plan.syncCursor, maxMutationCursor ?? 0);
|
|
850
|
+
state.cursor = finalCursor;
|
|
851
|
+
state.syncfilesHash = plan.syncfilesHash ?? state.syncfilesHash;
|
|
852
|
+
state.patterns = effectivePatterns;
|
|
853
|
+
state.privatePatterns = effectivePrivatePatterns;
|
|
854
|
+
state.privatefilesHash = plan.privatefilesHash ?? state.privatefilesHash;
|
|
855
|
+
saveSyncState(gobiDir, state);
|
|
856
|
+
// Delete plan file after successful execution
|
|
857
|
+
rmSync(opts.planFile, { force: true });
|
|
858
|
+
const result = {
|
|
859
|
+
uploaded,
|
|
860
|
+
downloaded,
|
|
861
|
+
deletedLocally,
|
|
862
|
+
conflicts,
|
|
863
|
+
skipped,
|
|
864
|
+
errors,
|
|
865
|
+
cursor: finalCursor,
|
|
866
|
+
errorDetails,
|
|
867
|
+
};
|
|
868
|
+
if (jsonMode) {
|
|
869
|
+
jsonOut(result);
|
|
870
|
+
}
|
|
871
|
+
else {
|
|
872
|
+
console.log("\nSync complete.");
|
|
873
|
+
console.log(` Uploaded: ${uploaded}`);
|
|
874
|
+
console.log(` Downloaded: ${downloaded}`);
|
|
875
|
+
console.log(` Deleted local: ${deletedLocally}`);
|
|
876
|
+
console.log(` Conflicts: ${conflicts}`);
|
|
877
|
+
console.log(` Errors: ${errors}`);
|
|
878
|
+
if (errors > 0)
|
|
879
|
+
process.exitCode = 1;
|
|
880
|
+
}
|
|
881
|
+
return result;
|
|
675
882
|
}
|
|
676
883
|
// ─── Commander Registration ───────────────────────────────────────────────────
|
|
677
884
|
export function registerSyncCommand(program) {
|
|
@@ -685,14 +892,29 @@ export function registerSyncCommand(program) {
|
|
|
685
892
|
.option("--dry-run", "Preview changes without making them")
|
|
686
893
|
.option("--full", "Full sync: ignore cursor and hash cache, re-check every file")
|
|
687
894
|
.option("--path <path>", "Restrict sync to a specific file or folder (repeatable)", (v, prev) => prev.concat(v), [])
|
|
895
|
+
.option("--plan-file <path>", "Write dry-run plan to file (use with --dry-run) or read plan to execute (use with --execute)")
|
|
896
|
+
.option("--execute", "Execute a previously written plan file (requires --plan-file)")
|
|
897
|
+
.option("--conflict-choices <json>", "Per-file conflict resolutions as JSON object, e.g. '{\"file.md\":\"server\"}' (use with --execute)")
|
|
688
898
|
.action(async function (opts) {
|
|
689
899
|
if (opts.uploadOnly && opts.downloadOnly) {
|
|
690
900
|
throw new GobiError("--upload-only and --download-only are mutually exclusive.", "INVALID_OPTION");
|
|
691
901
|
}
|
|
902
|
+
if (opts.execute && !opts.planFile) {
|
|
903
|
+
throw new GobiError("--execute requires --plan-file", "INVALID_OPTION");
|
|
904
|
+
}
|
|
692
905
|
const validStrategies = ["ask", "server", "client", "skip"];
|
|
693
906
|
if (!validStrategies.includes(opts.conflict)) {
|
|
694
907
|
throw new GobiError(`Invalid --conflict value "${opts.conflict}". Use: ask|server|client|skip`, "INVALID_OPTION");
|
|
695
908
|
}
|
|
909
|
+
let conflictChoices;
|
|
910
|
+
if (opts.conflictChoices) {
|
|
911
|
+
try {
|
|
912
|
+
conflictChoices = JSON.parse(opts.conflictChoices);
|
|
913
|
+
}
|
|
914
|
+
catch {
|
|
915
|
+
throw new GobiError("--conflict-choices must be valid JSON", "INVALID_OPTION");
|
|
916
|
+
}
|
|
917
|
+
}
|
|
696
918
|
const vaultSlug = getVaultSlug();
|
|
697
919
|
const dir = opts.dir ? pathResolve(opts.dir) : process.cwd();
|
|
698
920
|
await runSync({
|
|
@@ -704,6 +926,9 @@ export function registerSyncCommand(program) {
|
|
|
704
926
|
dryRun: !!opts.dryRun,
|
|
705
927
|
full: !!opts.full,
|
|
706
928
|
paths: opts.path ?? [],
|
|
929
|
+
planFile: opts.planFile,
|
|
930
|
+
execute: !!opts.execute,
|
|
931
|
+
conflictChoices,
|
|
707
932
|
jsonMode: isJsonMode(this),
|
|
708
933
|
});
|
|
709
934
|
});
|
package/package.json
CHANGED
package/skills/gobi/SKILL.md
CHANGED
|
@@ -10,12 +10,12 @@ description: >-
|
|
|
10
10
|
allowed-tools: Bash(gobi:*)
|
|
11
11
|
metadata:
|
|
12
12
|
author: gobi-ai
|
|
13
|
-
version: "0.6.
|
|
13
|
+
version: "0.6.14"
|
|
14
14
|
---
|
|
15
15
|
|
|
16
16
|
# gobi-cli
|
|
17
17
|
|
|
18
|
-
A CLI client for the Gobi collaborative knowledge platform (v0.6.
|
|
18
|
+
A CLI client for the Gobi collaborative knowledge platform (v0.6.14).
|
|
19
19
|
|
|
20
20
|
## Prerequisites
|
|
21
21
|
|