@gobi-ai/cli 0.6.13 → 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 +264 -47
- package/package.json +1 -1
- package/skills/gobi/SKILL.md +2 -2
package/dist/commands/sync.js
CHANGED
|
@@ -272,6 +272,7 @@ async function webdriveDelete(baseUrl, vaultSlug, filePath, token) {
|
|
|
272
272
|
}
|
|
273
273
|
async function webdriveSync(baseUrl, vaultSlug, body, token) {
|
|
274
274
|
const url = `${baseUrl}/api/v1/vaults/${vaultSlug}/sync`;
|
|
275
|
+
process.stderr.write(`[gobi-sync] syncfiles: body=${JSON.stringify(body)}\n`);
|
|
275
276
|
const res = await fetch(url, {
|
|
276
277
|
method: "POST",
|
|
277
278
|
headers: {
|
|
@@ -342,8 +343,13 @@ function matchesPaths(filePath, paths) {
|
|
|
342
343
|
async function performSync(baseUrl, vaultSlug, state, syncfilesChanges, privatefilesChanges, localFiles, opts, token) {
|
|
343
344
|
const body = {
|
|
344
345
|
cursor: state.cursor,
|
|
345
|
-
|
|
346
|
-
|
|
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,
|
|
352
|
+
privatefilesChanges: opts.dryRun ? { added: [], removed: [] } : privatefilesChanges,
|
|
347
353
|
clientFiles: localFiles,
|
|
348
354
|
uploadOnly: opts.uploadOnly,
|
|
349
355
|
downloadOnly: opts.downloadOnly,
|
|
@@ -355,6 +361,10 @@ export async function runSync(opts) {
|
|
|
355
361
|
const baseUrl = opts.webdriveUrl ?? WEBDRIVE_BASE_URL;
|
|
356
362
|
const gobiDir = join(vaultDir, ".gobi");
|
|
357
363
|
mkdirSync(gobiDir, { recursive: true });
|
|
364
|
+
const token = opts.authToken ?? (await getValidToken());
|
|
365
|
+
if (opts.execute) {
|
|
366
|
+
return executeSyncPlan(opts, baseUrl, token, gobiDir);
|
|
367
|
+
}
|
|
358
368
|
const state = loadSyncState(gobiDir);
|
|
359
369
|
// --full: treat this run as a first-time sync (re-check every file against the server)
|
|
360
370
|
if (opts.full) {
|
|
@@ -363,52 +373,41 @@ export async function runSync(opts) {
|
|
|
363
373
|
if (!jsonMode)
|
|
364
374
|
console.log("Full sync: ignoring cursor and hash cache.");
|
|
365
375
|
}
|
|
366
|
-
const token = opts.authToken ?? (await getValidToken());
|
|
367
|
-
// Sync privatefiles with server
|
|
368
376
|
// Read syncfiles whitelist
|
|
377
|
+
const syncfilesExistsLocally = existsSync(join(gobiDir, "syncfiles"));
|
|
369
378
|
const { patterns: currPatterns, contentHash: currSyncfilesHash } = readSyncfiles(gobiDir);
|
|
370
379
|
if (currPatterns.length === 0 && !jsonMode) {
|
|
371
380
|
console.warn("Warning: No patterns found in .gobi/syncfiles. Nothing will be synced.\n" +
|
|
372
381
|
"Add gitignore-style patterns to .gobi/syncfiles to select files for sync.");
|
|
373
382
|
}
|
|
374
383
|
const isWhitelisted = buildWhitelistMatcher(currPatterns);
|
|
375
|
-
// On bootstrap (no prior state), fetch server's current syncfiles so removals
|
|
376
|
-
// relative to the server are captured correctly in the delta.
|
|
377
384
|
let baseSyncPatterns = state.patterns;
|
|
378
|
-
if (
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
385
|
+
if (state.syncfilesHash === null) {
|
|
386
|
+
// Bootstrap: use empty base so local patterns are sent as "added" only.
|
|
387
|
+
// This avoids spuriously removing server-only patterns from other devices.
|
|
388
|
+
// The server returns its current syncfilesHash, which then triggers a download
|
|
389
|
+
// to pull any server patterns the client doesn't have yet.
|
|
390
|
+
baseSyncPatterns = [];
|
|
391
|
+
}
|
|
392
|
+
else if (!syncfilesExistsLocally) {
|
|
393
|
+
// File deleted after a prior sync. Produce empty diff to avoid removing patterns
|
|
394
|
+
// from server. The download condition below re-fetches the missing file.
|
|
395
|
+
currPatterns.length = 0;
|
|
396
|
+
currPatterns.push(...state.patterns);
|
|
397
|
+
baseSyncPatterns = [...state.patterns];
|
|
391
398
|
}
|
|
392
399
|
const syncfilesChanges = computeSyncfilesChanges(baseSyncPatterns, currPatterns);
|
|
393
|
-
// Compute privatefiles delta
|
|
400
|
+
// Compute privatefiles delta
|
|
401
|
+
const privatefilesExistsLocally = existsSync(join(gobiDir, "privatefiles"));
|
|
394
402
|
const currPrivatePatterns = readPrivatefiles(gobiDir);
|
|
395
|
-
// On bootstrap (no prior state), fetch server's current patterns so we can compute
|
|
396
|
-
// correct removals. Without this, patterns the user deleted would stay on the server
|
|
397
|
-
// because we'd have no baseline to diff against.
|
|
398
403
|
let basePrivatePatterns = state.privatePatterns;
|
|
399
|
-
if (
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
.filter((l) => l.length > 0 && !l.startsWith("#"));
|
|
407
|
-
process.stderr.write(`[gobi-sync] bootstrap: fetched server privatefiles, patterns=${JSON.stringify(basePrivatePatterns)}\n`);
|
|
408
|
-
}
|
|
409
|
-
catch {
|
|
410
|
-
basePrivatePatterns = []; // server has no privatefiles yet
|
|
411
|
-
}
|
|
404
|
+
if (state.privatefilesHash === null) {
|
|
405
|
+
// Bootstrap: same as syncfiles — empty base, rely on hash comparison for download.
|
|
406
|
+
basePrivatePatterns = [];
|
|
407
|
+
}
|
|
408
|
+
else if (!privatefilesExistsLocally) {
|
|
409
|
+
// File deleted after a prior sync. Produce empty diff.
|
|
410
|
+
basePrivatePatterns = [...currPrivatePatterns];
|
|
412
411
|
}
|
|
413
412
|
const privatefilesChanges = computeSyncfilesChanges(basePrivatePatterns, currPrivatePatterns);
|
|
414
413
|
// Walk local files (only whitelisted, non-ignored)
|
|
@@ -482,6 +481,26 @@ export async function runSync(opts) {
|
|
|
482
481
|
}
|
|
483
482
|
if (!jsonMode)
|
|
484
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
|
+
}
|
|
485
504
|
// Process actions
|
|
486
505
|
let uploaded = 0, downloaded = 0, deletedLocally = 0, conflicts = 0, skipped = 0, errors = 0;
|
|
487
506
|
const errorDetails = [];
|
|
@@ -603,7 +622,7 @@ export async function runSync(opts) {
|
|
|
603
622
|
// Download syncfiles from server if the server's hash changed since last sync
|
|
604
623
|
let effectivePatterns = currPatterns;
|
|
605
624
|
process.stderr.write(`[gobi-sync] syncfiles: state=${state.syncfilesHash ?? "null"} server=${syncResp.syncfilesHash ?? "null"}\n`);
|
|
606
|
-
if (!opts.dryRun && syncResp.syncfilesHash && syncResp.syncfilesHash !== state.syncfilesHash) {
|
|
625
|
+
if (!opts.dryRun && !opts.uploadOnly && syncResp.syncfilesHash && (syncResp.syncfilesHash !== state.syncfilesHash || !syncfilesExistsLocally)) {
|
|
607
626
|
process.stderr.write(`[gobi-sync] syncfiles hash changed — downloading from server\n`);
|
|
608
627
|
try {
|
|
609
628
|
const syncfilesContent = await webdriveGet(baseUrl, vaultSlug, ".gobi/syncfiles", token);
|
|
@@ -623,7 +642,7 @@ export async function runSync(opts) {
|
|
|
623
642
|
// Download privatefiles from server if the server's hash changed since last sync
|
|
624
643
|
let effectivePrivatePatterns = currPrivatePatterns;
|
|
625
644
|
process.stderr.write(`[gobi-sync] privatefiles: state=${state.privatefilesHash ?? "null"} server=${syncResp.privatefilesHash ?? "null"}\n`);
|
|
626
|
-
if (!opts.dryRun && syncResp.privatefilesHash && syncResp.privatefilesHash !== state.privatefilesHash) {
|
|
645
|
+
if (!opts.dryRun && !opts.uploadOnly && syncResp.privatefilesHash && (syncResp.privatefilesHash !== state.privatefilesHash || !privatefilesExistsLocally)) {
|
|
627
646
|
process.stderr.write(`[gobi-sync] privatefiles hash changed — downloading from server\n`);
|
|
628
647
|
try {
|
|
629
648
|
const privatefilesContent = await webdriveGet(baseUrl, vaultSlug, ".gobi/privatefiles", token);
|
|
@@ -641,15 +660,17 @@ export async function runSync(opts) {
|
|
|
641
660
|
}
|
|
642
661
|
// Persist state (always, even on partial failures)
|
|
643
662
|
const finalCursor = Math.max(syncResp.cursor, maxMutationCursor !== null ? maxMutationCursor : 0);
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
+
}
|
|
653
674
|
// Output summary
|
|
654
675
|
const result = {
|
|
655
676
|
uploaded,
|
|
@@ -680,6 +701,184 @@ export async function runSync(opts) {
|
|
|
680
701
|
process.exitCode = 1;
|
|
681
702
|
}
|
|
682
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;
|
|
683
882
|
}
|
|
684
883
|
// ─── Commander Registration ───────────────────────────────────────────────────
|
|
685
884
|
export function registerSyncCommand(program) {
|
|
@@ -693,14 +892,29 @@ export function registerSyncCommand(program) {
|
|
|
693
892
|
.option("--dry-run", "Preview changes without making them")
|
|
694
893
|
.option("--full", "Full sync: ignore cursor and hash cache, re-check every file")
|
|
695
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)")
|
|
696
898
|
.action(async function (opts) {
|
|
697
899
|
if (opts.uploadOnly && opts.downloadOnly) {
|
|
698
900
|
throw new GobiError("--upload-only and --download-only are mutually exclusive.", "INVALID_OPTION");
|
|
699
901
|
}
|
|
902
|
+
if (opts.execute && !opts.planFile) {
|
|
903
|
+
throw new GobiError("--execute requires --plan-file", "INVALID_OPTION");
|
|
904
|
+
}
|
|
700
905
|
const validStrategies = ["ask", "server", "client", "skip"];
|
|
701
906
|
if (!validStrategies.includes(opts.conflict)) {
|
|
702
907
|
throw new GobiError(`Invalid --conflict value "${opts.conflict}". Use: ask|server|client|skip`, "INVALID_OPTION");
|
|
703
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
|
+
}
|
|
704
918
|
const vaultSlug = getVaultSlug();
|
|
705
919
|
const dir = opts.dir ? pathResolve(opts.dir) : process.cwd();
|
|
706
920
|
await runSync({
|
|
@@ -712,6 +926,9 @@ export function registerSyncCommand(program) {
|
|
|
712
926
|
dryRun: !!opts.dryRun,
|
|
713
927
|
full: !!opts.full,
|
|
714
928
|
paths: opts.path ?? [],
|
|
929
|
+
planFile: opts.planFile,
|
|
930
|
+
execute: !!opts.execute,
|
|
931
|
+
conflictChoices,
|
|
715
932
|
jsonMode: isJsonMode(this),
|
|
716
933
|
});
|
|
717
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
|
|