@narumitw/pi-sync 0.49.5 → 0.49.8

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.
@@ -1,4 +1,7 @@
1
1
  import { agentDir } from "./config.js";
2
+
3
+ export { errorMessage } from "./sync-errors.js";
4
+
2
5
  import type { PublicationCapability, RemoteHead } from "./sync-backend.js";
3
6
  import { inspectRemoteSelection } from "./sync-policy.js";
4
7
  import { fileHashMap } from "./sync-state.js";
@@ -166,7 +169,3 @@ export function safeTerminalText(value: string) {
166
169
  export function redact(value: string) {
167
170
  return value.length <= 8 ? "configured" : `${value.slice(0, 4)}…${value.slice(-4)}`;
168
171
  }
169
-
170
- export function errorMessage(error: unknown) {
171
- return safeTerminalText(error instanceof Error ? error.message : String(error));
172
- }
@@ -7,6 +7,7 @@ import {
7
7
  readStateForConfig,
8
8
  sessionDirForApply,
9
9
  stateDir,
10
+ syncConfigReviewFingerprint,
10
11
  syncSessionsWarnings,
11
12
  writeStateForConfig,
12
13
  } from "./config.js";
@@ -46,7 +47,7 @@ import {
46
47
  publicationCapabilityDescription,
47
48
  safeTerminalText,
48
49
  } from "./sync-format.js";
49
- import { inspectRemoteSelection, RemoteSelectionMismatchError } from "./sync-policy.js";
50
+ import { inspectRemoteSelection, remoteSelectionMismatch } from "./sync-policy.js";
50
51
  import {
51
52
  canPullRemoteSessionsOnFirstSync,
52
53
  canPullRemoteSettingsOnFirstSync,
@@ -923,7 +924,8 @@ async function readRemoteSnapshot(
923
924
  const snapshot = await readSnapshotForHead(backend, head, signal);
924
925
  const selectionState = inspectRemoteSelection(config.include, snapshot);
925
926
  if (!options.allowSelectionDifference && selectionState.kind === "different") {
926
- throw new RemoteSelectionMismatchError(config.include, selectionState.include);
927
+ const configIdentity = syncConfigReviewFingerprint(config);
928
+ throw remoteSelectionMismatch(config, selectionState.include, configIdentity);
927
929
  }
928
930
  return {
929
931
  head,
@@ -206,22 +206,81 @@ export function inspectRemoteSelection(
206
206
  : { kind: "different", include: remoteInclude, ...comparison };
207
207
  }
208
208
 
209
+ export interface RemoteSelectionDecision {
210
+ setupName: string;
211
+ configIdentity: string;
212
+ localInclude: readonly string[];
213
+ remoteInclude: readonly string[];
214
+ }
215
+
209
216
  export class RemoteSelectionMismatchError extends Error {
217
+ readonly decision: RemoteSelectionDecision;
218
+ readonly setupName: string;
210
219
  readonly localInclude: string[];
211
220
  readonly remoteInclude: string[];
212
221
 
213
- constructor(localInclude: unknown, remoteInclude: unknown) {
222
+ constructor(
223
+ setupName: string,
224
+ localInclude: unknown,
225
+ remoteInclude: unknown,
226
+ configIdentity = JSON.stringify([setupName, normalizeSyncInclude(localInclude)]),
227
+ ) {
214
228
  const local = normalizeSyncInclude(localInclude);
215
229
  const remote = normalizeSyncInclude(remoteInclude);
216
- super(
217
- "Remote included content differs from this sync setup. Review Remote included content in /sync Settings, or use a reviewed force push to keep local selection.",
218
- );
230
+ super(formatRemoteSelectionMismatch(setupName, local, remote));
219
231
  this.name = "RemoteSelectionMismatchError";
232
+ this.setupName = setupName;
220
233
  this.localInclude = local;
221
234
  this.remoteInclude = remote;
235
+ this.decision = {
236
+ setupName,
237
+ configIdentity,
238
+ localInclude: [...local],
239
+ remoteInclude: [...remote],
240
+ };
222
241
  }
223
242
  }
224
243
 
244
+ export function remoteSelectionMismatch(
245
+ config: { setupName: string; include: unknown },
246
+ remoteInclude: unknown,
247
+ configIdentity?: string,
248
+ ) {
249
+ return new RemoteSelectionMismatchError(
250
+ config.setupName,
251
+ config.include,
252
+ remoteInclude,
253
+ configIdentity,
254
+ );
255
+ }
256
+
257
+ export function formatRemoteSelectionMismatch(
258
+ setupName: string,
259
+ localInclude: readonly string[],
260
+ remoteInclude: readonly string[],
261
+ ) {
262
+ const comparison = compareSyncInclude(localInclude, remoteInclude);
263
+ const lines = [
264
+ `Synced content differs for sync setup “${stripTerminalControls(setupName)}”.`,
265
+ `Remote-only: ${comparison.remoteOnly.join(", ") || "none"}`,
266
+ `This-device-only: ${comparison.localOnly.join(", ") || "none"}`,
267
+ ];
268
+ if (comparison.remoteOnly.length === 0 && comparison.localOnly.length === 0) {
269
+ lines.push(
270
+ "Only ordering differs.",
271
+ `Remote order: ${remoteInclude.join(", ") || "none"}`,
272
+ `This device order: ${localInclude.join(", ") || "none"}`,
273
+ );
274
+ }
275
+ lines.push("Run /sync in TUI to review both content lists and choose what happens next.");
276
+ return lines.join("\n");
277
+ }
278
+
279
+ function stripTerminalControls(value: string) {
280
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Escape untrusted terminal controls.
281
+ return value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, "?");
282
+ }
283
+
225
284
  export function discoverLegacySnapshotInclude(snapshot: Pick<Snapshot, "files">) {
226
285
  const builtIns = new Set<BuiltInSyncFile>();
227
286
  const custom = new Set<string>();
@@ -1,18 +1,27 @@
1
- import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
3
- import { type RunRoute, runCancellableOperation } from "./cancellable-operation.js";
3
+ import {
4
+ type RunRoute,
5
+ type RunRouteResult,
6
+ runCancellableOperation,
7
+ } from "./cancellable-operation.js";
4
8
  import { loadConfig, syncConfigReviewIdentity } from "./config.js";
5
9
  import type { SyncDecision, SyncResolutionDirection } from "./sync-decision.js";
6
10
  import { errorMessage, safeTerminalText } from "./sync-format.js";
7
11
 
8
12
  export type SyncResolutionResult =
9
13
  | { kind: "resolved"; direction: SyncResolutionDirection }
14
+ | {
15
+ kind: "route-result";
16
+ result: RunRouteResult;
17
+ route: SyncResolutionDirection;
18
+ }
10
19
  | { kind: "back" }
11
20
  | { kind: "closed" }
12
21
  | { kind: "stale" };
13
22
 
14
23
  export async function showSyncResolution(
15
- ctx: ExtensionCommandContext,
24
+ ctx: ExtensionContext,
16
25
  initialDecision: SyncDecision,
17
26
  runRoute: RunRoute,
18
27
  sessionSignal?: AbortSignal,
@@ -21,7 +30,8 @@ export async function showSyncResolution(
21
30
  type Action = "push" | "pull" | "back";
22
31
  let currentDecision = initialDecision;
23
32
  let resolvedDirection: SyncResolutionDirection | undefined;
24
- const menu = defineMenu<SyncDecision, Screen, Action, ExtensionCommandContext>({
33
+ let chainedResult: { result: RunRouteResult; route: SyncResolutionDirection } | undefined;
34
+ const menu = defineMenu<SyncDecision, Screen, Action, ExtensionContext>({
25
35
  start: "resolve",
26
36
  screens: {
27
37
  resolve: ({ state }) => ({
@@ -104,6 +114,10 @@ export async function showSyncResolution(
104
114
  currentDecision = result.decision;
105
115
  return { kind: "stay" as const };
106
116
  }
117
+ if (result.kind === "remote-selection-required") {
118
+ chainedResult = { result, route: direction };
119
+ return { kind: "close" as const };
120
+ }
107
121
  if (result.kind === "completed") {
108
122
  if (result.outcome === "cancelled") return { kind: "stay" as const };
109
123
  resolvedDirection = direction;
@@ -119,6 +133,7 @@ export async function showSyncResolution(
119
133
  onError: (_menuCtx, error) => ctx.ui.notify(errorMessage(error), "error"),
120
134
  });
121
135
  if (sessionSignal?.aborted || result.kind === "stale") return { kind: "stale" };
136
+ if (chainedResult) return { kind: "route-result", ...chainedResult };
122
137
  if (resolvedDirection) return { kind: "resolved", direction: resolvedDirection };
123
138
  if (result.kind === "closed") {
124
139
  return result.reason === "back" ? { kind: "back" } : { kind: "closed" };