@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.
@@ -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: don't mutate server-side syncfiles/privatefiles
347
- syncfilesChanges: opts.dryRun ? { added: [], removed: [] } : syncfilesChanges,
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
- state.cursor = finalCursor;
637
- state.syncfilesHash = syncResp.syncfilesHash || currSyncfilesHash;
638
- // If the server returned an empty syncfilesHash the vault was deleted server-side
639
- // (empty patterns path). Reset patterns so the next sync re-registers them as "added",
640
- // which lets the 409 retry resurrect the vault.
641
- state.patterns = syncResp.syncfilesHash === "" ? [] : effectivePatterns;
642
- state.privatePatterns = effectivePrivatePatterns;
643
- state.privatefilesHash = syncResp.privatefilesHash || state.privatefilesHash;
644
- saveSyncState(gobiDir, state);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobi-ai/cli",
3
- "version": "0.6.14",
3
+ "version": "0.6.15",
4
4
  "description": "CLI client for the Gobi collaborative knowledge platform",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -10,12 +10,12 @@ description: >-
10
10
  allowed-tools: Bash(gobi:*)
11
11
  metadata:
12
12
  author: gobi-ai
13
- version: "0.6.13"
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.13).
18
+ A CLI client for the Gobi collaborative knowledge platform (v0.6.14).
19
19
 
20
20
  ## Prerequisites
21
21