@narumitw/pi-sync 0.49.5 → 0.49.6

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 CHANGED
@@ -305,7 +305,9 @@ Pi exposes terminal components rather than a semantic/ARIA tree. Release validat
305
305
  packages/pi-sync/
306
306
  ├── src/
307
307
  │ ├── index.ts
308
- │ ├── sync.ts
308
+ │ ├── sync-extension.ts # Lightweight Pi entry runtime and cached lazy loaders
309
+ │ ├── sync.ts # Compatibility barrel for package-local helpers
310
+ │ ├── sync-errors.ts # Lightweight setup/decision error contracts
309
311
  │ ├── config.ts
310
312
  │ ├── config-file.ts
311
313
  │ ├── state-directory.ts
@@ -322,7 +324,8 @@ packages/pi-sync/
322
324
  │ ├── s3-backend.ts
323
325
  │ ├── webdav-backend.ts
324
326
  │ ├── git-backend.ts
325
- │ ├── snapshot.ts
327
+ │ ├── snapshot-paths.ts # Eager recovery-safe session path helpers
328
+ │ ├── snapshot.ts # Loaded only for snapshot operations/session push
326
329
  │ └── *.ts
327
330
  ├── test/
328
331
  ├── README.md
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-sync",
3
- "version": "0.49.5",
3
+ "version": "0.49.6",
4
4
  "description": "Pi extension that syncs Pi configuration through Git, WebDAV, Cloudflare R2, or S3-compatible storage.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -1 +1 @@
1
- export { default } from "./sync.js";
1
+ export { default } from "./sync-extension.js";
@@ -8,9 +8,12 @@ import {
8
8
  syncSetupReviewIdentity,
9
9
  updateLocalConfig,
10
10
  } from "./config.js";
11
+ import { SetupPullRequiresUiError } from "./sync-errors.js";
11
12
  import { safeTerminalText } from "./sync-format.js";
12
13
  import type { OnSwitchAction } from "./types.js";
13
14
 
15
+ export { SetupPullRequiresUiError } from "./sync-errors.js";
16
+
14
17
  export const SETUP_SWITCH_ACTION_OPTIONS: ReadonlyArray<{
15
18
  label: string;
16
19
  value: OnSwitchAction;
@@ -34,8 +37,6 @@ export async function saveOnSwitch(action: OnSwitchAction, signal?: AbortSignal)
34
37
 
35
38
  export type SetupPullOutcome = "applied" | "cancelled";
36
39
 
37
- export class SetupPullRequiresUiError extends Error {}
38
-
39
40
  export interface SetupSwitchResult {
40
41
  pullApplied: boolean;
41
42
  }
@@ -0,0 +1,13 @@
1
+ import path from "node:path";
2
+
3
+ function expandHome(value: string) {
4
+ if (value === "~") return process.env.HOME ?? value;
5
+ if (value.startsWith("~/")) return path.join(process.env.HOME ?? "~", value.slice(2));
6
+ return value;
7
+ }
8
+
9
+ export function sessionStorageRoot(root: string, configuredSessionDir?: string) {
10
+ return configuredSessionDir
11
+ ? path.resolve(expandHome(configuredSessionDir))
12
+ : path.resolve(root, "sessions");
13
+ }
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { agentDir, stateDir } from "./config.js";
5
5
  import { withLock } from "./lock.js";
6
6
  import { assertWithinRoot, isPathInside } from "./paths.js";
7
- import { sessionStorageRoot } from "./snapshot.js";
7
+ import { sessionStorageRoot } from "./snapshot-paths.js";
8
8
  import type { SnapshotApplyPlan } from "./types.js";
9
9
 
10
10
  const JOURNAL_VERSION = 1;
package/src/snapshot.ts CHANGED
@@ -5,8 +5,10 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { agentDir, configuredSessionDir } from "./config.js";
7
7
  import { isDeniedPath, posixJoin, safeJoin, toPosix } from "./paths.js";
8
+ import { sessionStorageRoot } from "./snapshot-paths.js";
8
9
 
9
10
  export { isDeniedPath } from "./paths.js";
11
+ export { sessionStorageRoot } from "./snapshot-paths.js";
10
12
 
11
13
  import {
12
14
  canonicalSnapshotPathForConfig,
@@ -39,12 +41,6 @@ const SECRET_PATTERNS = [
39
41
  /gh[pousr]_[A-Za-z0-9_]{20,}/,
40
42
  ];
41
43
 
42
- function expandHome(value: string) {
43
- if (value === "~") return process.env.HOME ?? value;
44
- if (value.startsWith("~/")) return path.join(process.env.HOME ?? "~", value.slice(2));
45
- return value;
46
- }
47
-
48
44
  function selectTopLevelFileEntry(entries: Dirent[], fileName: string) {
49
45
  const exact = entries.find((entry) => entry.isFile() && entry.name === fileName);
50
46
  if (exact) return exact;
@@ -223,12 +219,6 @@ export function isSessionFilePath(relativePath: string) {
223
219
  return isSessionPath(normalized) && normalized.endsWith(".jsonl");
224
220
  }
225
221
 
226
- export function sessionStorageRoot(root: string, configuredSessionDir?: string) {
227
- return configuredSessionDir
228
- ? path.resolve(expandHome(configuredSessionDir))
229
- : path.resolve(root, "sessions");
230
- }
231
-
232
222
  export function sessionSnapshotPathFromAbsolute(
233
223
  sessionFile: string,
234
224
  configuredSessionDir?: string,
@@ -1,4 +1,5 @@
1
1
  import { syncConfigReviewIdentity } from "./config.js";
2
+ import { SyncDecisionRequiredError } from "./sync-errors.js";
2
3
  import { formatDiff, formatSnapshotOnlyDiff, safeTerminalText } from "./sync-format.js";
3
4
  import { includeFromSelectionConfig } from "./sync-policy.js";
4
5
  import { syncPolicyChanged } from "./sync-state.js";
@@ -29,15 +30,10 @@ export interface SyncDecision {
29
30
  directMessage: string;
30
31
  }
31
32
 
32
- export class SyncDecisionRequiredError extends Error {
33
- readonly decision: SyncDecision;
34
-
35
- constructor(decision: SyncDecision) {
36
- super(decision.directMessage);
37
- this.name = "SyncDecisionRequiredError";
38
- this.decision = decision;
39
- }
40
- }
33
+ export {
34
+ isSyncDecisionRequiredError,
35
+ SyncDecisionRequiredError,
36
+ } from "./sync-errors.js";
41
37
 
42
38
  interface CreateSyncDecisionOptions {
43
39
  kind: SyncDecisionKind;
@@ -104,10 +100,6 @@ export function createSyncDecision(options: CreateSyncDecisionOptions) {
104
100
  });
105
101
  }
106
102
 
107
- export function isSyncDecisionRequiredError(error: unknown): error is SyncDecisionRequiredError {
108
- return error instanceof SyncDecisionRequiredError;
109
- }
110
-
111
103
  function safeList(values: readonly string[]) {
112
104
  return values.length > 0 ? values.map(safeTerminalText).join(", ") : "none";
113
105
  }
@@ -0,0 +1,23 @@
1
+ import type { SyncDecision } from "./sync-decision.js";
2
+
3
+ export class SetupPullRequiresUiError extends Error {}
4
+
5
+ export class SyncDecisionRequiredError extends Error {
6
+ readonly decision: SyncDecision;
7
+
8
+ constructor(decision: SyncDecision) {
9
+ super(decision.directMessage);
10
+ this.name = "SyncDecisionRequiredError";
11
+ this.decision = decision;
12
+ }
13
+ }
14
+
15
+ export function isSyncDecisionRequiredError(error: unknown): error is SyncDecisionRequiredError {
16
+ return error instanceof SyncDecisionRequiredError;
17
+ }
18
+
19
+ export function errorMessage(error: unknown) {
20
+ const message = error instanceof Error ? error.message : String(error);
21
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Escape untrusted terminal controls.
22
+ return message.replace(/[\u0000-\u001f\u007f-\u009f]/gu, "?");
23
+ }
@@ -0,0 +1,567 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ ExtensionContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import type { RunRouteResult } from "./cancellable-operation.js";
7
+ import {
8
+ completeSyncArguments,
9
+ parseOptions,
10
+ resolveSyncCommand,
11
+ setSyncSetupCompletions,
12
+ splitArgs,
13
+ usage,
14
+ validateCommandOptions,
15
+ } from "./command.js";
16
+ import {
17
+ activeLocalConfigPath,
18
+ configuredSyncSetupNames,
19
+ consumeLocalConfigMigrationNotice,
20
+ createLocalConfigDocument,
21
+ ensureStateDir,
22
+ isMissingConfigError,
23
+ loadConfig,
24
+ loadPartialConfig,
25
+ localConfigPath,
26
+ localConfigTemplate,
27
+ readLocalConfigObject,
28
+ readStateForConfig,
29
+ sessionTokenWarnings,
30
+ syncSessionsWarnings,
31
+ } from "./config.js";
32
+ import { unlock, withLock } from "./lock.js";
33
+ import { recoverSnapshotTransactionsOnStartup } from "./snapshot-transaction.js";
34
+ import {
35
+ migrateLegacyStateDirectory,
36
+ stateDirectoryMigrationNotice,
37
+ withStateDirectoryAccess,
38
+ } from "./state-directory.js";
39
+ import {
40
+ errorMessage,
41
+ isSyncDecisionRequiredError,
42
+ SetupPullRequiresUiError,
43
+ } from "./sync-errors.js";
44
+ import type { AnySyncConfig, CommandOptions, SnapshotOptions } from "./types.js";
45
+
46
+ const STATUS_KEY = "sync";
47
+
48
+ type SetupSwitchModule = Pick<typeof import("./setup-switch.js"), "useSyncSetup">;
49
+ type SnapshotModule = Pick<typeof import("./snapshot.js"), "createSnapshot">;
50
+ type SyncStateModule = Pick<typeof import("./sync-state.js"), "hasLocalChanges">;
51
+ type SyncOperations = typeof import("./sync-operations.js");
52
+
53
+ export interface SyncDependencies {
54
+ loadSetupSwitch(): Promise<SetupSwitchModule>;
55
+ loadSnapshot(): Promise<SnapshotModule>;
56
+ loadSyncState(): Promise<SyncStateModule>;
57
+ loadSyncOperations(): Promise<SyncOperations>;
58
+ }
59
+
60
+ interface SyncLoaders {
61
+ setupSwitch(): Promise<SetupSwitchModule>;
62
+ snapshot(): Promise<SnapshotModule>;
63
+ syncState(): Promise<SyncStateModule>;
64
+ operations(): Promise<SyncOperations>;
65
+ }
66
+
67
+ const AUTO_SYNC_OPTIONS: CommandOptions = {
68
+ yes: true,
69
+ force: false,
70
+ stale: false,
71
+ silent: true,
72
+ reload: false,
73
+ auto: true,
74
+ args: [],
75
+ };
76
+ export default function sync(pi: ExtensionAPI, dependencies: Partial<SyncDependencies> = {}) {
77
+ const loaders: SyncLoaders = {
78
+ setupSwitch: cachedModuleLoader(
79
+ dependencies.loadSetupSwitch ?? (() => import("./setup-switch.js")),
80
+ ),
81
+ snapshot: cachedModuleLoader(dependencies.loadSnapshot ?? (() => import("./snapshot.js"))),
82
+ syncState: cachedModuleLoader(dependencies.loadSyncState ?? (() => import("./sync-state.js"))),
83
+ operations: cachedModuleLoader(
84
+ dependencies.loadSyncOperations ?? (() => import("./sync-operations.js")),
85
+ ),
86
+ };
87
+ let sessionAbort = new AbortController();
88
+ let shutdownAbort: AbortController | undefined;
89
+
90
+ pi.registerCommand("sync", {
91
+ description: "Sync Pi settings through Git, WebDAV, R2, or S3-compatible storage",
92
+ getArgumentCompletions: completeSyncArguments,
93
+ handler: async (args, ctx) => {
94
+ if (!ctx.hasUI) {
95
+ throw new Error(
96
+ "/sync requires TUI or RPC mode so results and safety prompts are observable.",
97
+ );
98
+ }
99
+ const run = () => handleCommand(args, ctx, sessionAbort.signal, loaders);
100
+ if (splitArgs(args)[0] === "migrate-state") await run();
101
+ else await withStateDirectoryAccess(run);
102
+ },
103
+ });
104
+
105
+ pi.on("session_start", async (_event, ctx) => {
106
+ shutdownAbort?.abort(new DOMException("Session replaced", "AbortError"));
107
+ shutdownAbort = undefined;
108
+ sessionAbort.abort(new DOMException("Session replaced", "AbortError"));
109
+ sessionAbort = new AbortController();
110
+ const signal = sessionAbort.signal;
111
+ ctx.ui.setStatus(STATUS_KEY, undefined);
112
+ try {
113
+ await withStateDirectoryAccess(() => startSession(ctx, signal, loaders));
114
+ } catch (error) {
115
+ if (signal.aborted) return;
116
+ ctx.ui.notify(`pi-sync state access failed: ${errorMessage(error)}`, "error");
117
+ }
118
+ });
119
+
120
+ pi.on("session_shutdown", async (event, ctx) => {
121
+ sessionAbort.abort(new DOMException("Session shut down", "AbortError"));
122
+ shutdownAbort?.abort(new DOMException("Session shut down again", "AbortError"));
123
+ const controller = new AbortController();
124
+ shutdownAbort = controller;
125
+ const signal = combineSignals(controller.signal, AbortSignal.timeout(30_000));
126
+ const reason =
127
+ typeof event === "object" && event ? (event as { reason?: string }).reason : undefined;
128
+ try {
129
+ if (reason !== "reload") {
130
+ await withStateDirectoryAccess(async () => {
131
+ if (signal.aborted) return;
132
+ await autoPushSessions(ctx, signal, loaders);
133
+ });
134
+ }
135
+ } catch (error) {
136
+ if (!signal.aborted) {
137
+ ctx.ui.notify(`pi-sync session push skipped: ${errorMessage(error)}`, "warning");
138
+ }
139
+ } finally {
140
+ if (shutdownAbort === controller) shutdownAbort = undefined;
141
+ }
142
+ if (signal.aborted) return;
143
+ ctx.ui.setStatus(STATUS_KEY, undefined);
144
+ });
145
+ }
146
+
147
+ async function startSession(ctx: ExtensionContext, signal: AbortSignal, loaders: SyncLoaders) {
148
+ if (signal.aborted) return;
149
+ try {
150
+ const migrationNotice = stateDirectoryMigrationNotice();
151
+ if (migrationNotice) ctx.ui.notify(migrationNotice, "warning");
152
+ } catch (error) {
153
+ ctx.ui.notify(`pi-sync state directory requires attention: ${errorMessage(error)}`, "error");
154
+ return;
155
+ }
156
+ try {
157
+ await recoverSnapshotTransactionsOnStartup();
158
+ if (signal.aborted) return;
159
+ } catch (error) {
160
+ if (signal.aborted) return;
161
+ ctx.ui.notify(`pi-sync recovery required: ${errorMessage(error)}`, "error");
162
+ return;
163
+ }
164
+ try {
165
+ setSyncSetupCompletions(await configuredSyncSetupNames());
166
+ if (signal.aborted) return;
167
+ } catch {
168
+ if (signal.aborted) return;
169
+ setSyncSetupCompletions([]);
170
+ }
171
+ const migrationNotice = consumeLocalConfigMigrationNotice();
172
+ if (migrationNotice) ctx.ui.notify(migrationNotice, "warning");
173
+ if (signal.aborted) return;
174
+ await autoSync(ctx, signal, loaders);
175
+ }
176
+
177
+ async function handleCommand(
178
+ rawArgs: string,
179
+ ctx: ExtensionCommandContext,
180
+ sessionSignal: AbortSignal,
181
+ loaders: SyncLoaders,
182
+ ) {
183
+ if (!rawArgs.trim()) {
184
+ try {
185
+ const { showSyncManager } = await import("./manager-ui.js");
186
+ if (sessionSignal.aborted) return;
187
+ await showSyncManager(
188
+ ctx,
189
+ (route, signal, onCommit, target) =>
190
+ executeCommand(
191
+ route,
192
+ ctx,
193
+ combineSignals(sessionSignal, signal),
194
+ loaders,
195
+ onCommit,
196
+ target,
197
+ ),
198
+ sessionSignal,
199
+ );
200
+ } catch (error) {
201
+ if (sessionSignal.aborted) return;
202
+ ctx.ui.setStatus(STATUS_KEY, undefined);
203
+ ctx.ui.notify(errorMessage(error), "error");
204
+ }
205
+ return;
206
+ }
207
+ const result = await executeCommand(rawArgs, ctx, sessionSignal, loaders);
208
+ if (result.kind === "decision-required") {
209
+ ctx.ui.notify(result.decision.directMessage, "error");
210
+ }
211
+ }
212
+
213
+ async function executeCommand(
214
+ rawArgs: string,
215
+ ctx: ExtensionCommandContext,
216
+ signal: AbortSignal | undefined,
217
+ loaders: SyncLoaders,
218
+ onCommit?: () => void,
219
+ setup?: string,
220
+ ): Promise<RunRouteResult> {
221
+ try {
222
+ const command = await resolveSyncCommand(rawArgs, ctx);
223
+ if (signal?.aborted || !command) return { kind: "completed" };
224
+ const { subcommand, rest } = command;
225
+ const options = parseOptions(rest);
226
+ if (setup !== undefined) options.setup = setup;
227
+ if (signal) options.signal = signal;
228
+ if (onCommit) options.onCommit = onCommit;
229
+ validateCommandOptions(subcommand, options);
230
+
231
+ switch (subcommand) {
232
+ case "help":
233
+ ctx.ui.notify(usage(), "info");
234
+ return { kind: "completed" };
235
+ case "use": {
236
+ const { useSyncSetup } = await loaders.setupSwitch();
237
+ throwIfAborted(options.signal);
238
+ await useSyncSetup(
239
+ ctx,
240
+ options.args[0] ?? "",
241
+ async (selectedSetup) => {
242
+ const operations = await loaders.operations();
243
+ throwIfAborted(options.signal);
244
+ return withLock("pull", () =>
245
+ operations.pull(ctx, { ...options, setup: selectedSetup }),
246
+ );
247
+ },
248
+ undefined,
249
+ options.signal,
250
+ );
251
+ return { kind: "completed" };
252
+ }
253
+ case "init":
254
+ await initConfig(ctx, signal);
255
+ return { kind: "completed" };
256
+ case "config":
257
+ await showConfig(ctx, options);
258
+ return { kind: "completed" };
259
+ case "files": {
260
+ const { showFileSelection } = await import("./file-selection.js");
261
+ throwIfAborted(options.signal);
262
+ await showFileSelection(ctx, options.setup, options.signal);
263
+ return { kind: "completed" };
264
+ }
265
+ case "status": {
266
+ const operations = await loaders.operations();
267
+ throwIfAborted(options.signal);
268
+ await operations.status(ctx, options);
269
+ return { kind: "completed" };
270
+ }
271
+ case "diff": {
272
+ const operations = await loaders.operations();
273
+ throwIfAborted(options.signal);
274
+ await operations.diff(ctx, options);
275
+ return { kind: "completed" };
276
+ }
277
+ case "doctor": {
278
+ const operations = await loaders.operations();
279
+ throwIfAborted(options.signal);
280
+ await operations.doctor(ctx, options);
281
+ return { kind: "completed" };
282
+ }
283
+ case "push": {
284
+ const operations = await loaders.operations();
285
+ throwIfAborted(options.signal);
286
+ const outcome = await withLock("push", () => operations.push(ctx, options));
287
+ return { kind: "completed", ...(outcome ? { outcome } : {}) };
288
+ }
289
+ case "pull": {
290
+ const operations = await loaders.operations();
291
+ throwIfAborted(options.signal);
292
+ const outcome = await withLock("pull", () => operations.pull(ctx, options));
293
+ return { kind: "completed", ...(outcome ? { outcome } : {}) };
294
+ }
295
+ case "sync": {
296
+ const operations = await loaders.operations();
297
+ throwIfAborted(options.signal);
298
+ await withLock("sync", () => operations.syncBoth(ctx, options));
299
+ return { kind: "completed" };
300
+ }
301
+ case "history": {
302
+ const operations = await loaders.operations();
303
+ throwIfAborted(options.signal);
304
+ await operations.history(ctx, options);
305
+ return { kind: "completed" };
306
+ }
307
+ case "rollback": {
308
+ const operations = await loaders.operations();
309
+ throwIfAborted(options.signal);
310
+ await withLock("rollback", () => operations.rollback(ctx, options));
311
+ return { kind: "completed" };
312
+ }
313
+ case "migrate-state":
314
+ await migrateStateDirectory(ctx, options);
315
+ return { kind: "completed" };
316
+ case "unlock":
317
+ await unlock(ctx, options);
318
+ return { kind: "completed" };
319
+ default:
320
+ ctx.ui.notify(`Unknown /sync command: ${subcommand}\n\n${usage()}`, "warning");
321
+ return { kind: "failed" };
322
+ }
323
+ } catch (error) {
324
+ if (signal?.aborted) return { kind: "failed" };
325
+ ctx.ui.setStatus(STATUS_KEY, undefined);
326
+ if (error instanceof SetupPullRequiresUiError) throw error;
327
+ if (isSyncDecisionRequiredError(error)) {
328
+ return { kind: "decision-required", decision: error.decision };
329
+ }
330
+ ctx.ui.notify(errorMessage(error), "error");
331
+ return { kind: "failed" };
332
+ }
333
+ }
334
+
335
+ async function migrateStateDirectory(ctx: ExtensionCommandContext, options: CommandOptions) {
336
+ const notice = stateDirectoryMigrationNotice();
337
+ if (!notice) {
338
+ ctx.ui.notify("pi-sync already uses the canonical pi-sync/ state directory.", "info");
339
+ return;
340
+ }
341
+ if (
342
+ !options.yes &&
343
+ !(await ctx.ui.confirm(
344
+ "Migrate pi-sync state directory",
345
+ "Confirm that every other Pi process is closed. pi-sync will atomically rename .pisync/ to pi-sync/ without merging or deleting either root.",
346
+ { signal: options.signal },
347
+ ))
348
+ ) {
349
+ ctx.ui.notify("pi-sync state migration cancelled.", "info");
350
+ return;
351
+ }
352
+ throwIfAborted(options.signal);
353
+ const result = await migrateLegacyStateDirectory();
354
+ throwIfAborted(options.signal);
355
+ if (result.status === "ready") {
356
+ ctx.ui.notify("pi-sync already uses the canonical pi-sync/ state directory.", "info");
357
+ return;
358
+ }
359
+ ctx.ui.notify(result.message, result.status === "migrated" ? "info" : "warning");
360
+ }
361
+
362
+ async function autoSync(ctx: ExtensionContext, signal: AbortSignal, loaders: SyncLoaders) {
363
+ try {
364
+ const partial = await loadPartialConfig();
365
+ throwIfAborted(signal);
366
+ if (!partial.automatic) return;
367
+ await ensureStateDir();
368
+ throwIfAborted(signal);
369
+ await loadConfig();
370
+ throwIfAborted(signal);
371
+ const operations = await loaders.operations();
372
+ throwIfAborted(signal);
373
+ await withLock("auto-sync", () => {
374
+ throwIfAborted(signal);
375
+ return operations.syncBoth(ctx, { ...AUTO_SYNC_OPTIONS, signal });
376
+ });
377
+ } catch (error) {
378
+ if (signal.aborted || isMissingConfigError(error)) return;
379
+ ctx.ui.setStatus(STATUS_KEY, undefined);
380
+ ctx.ui.notify(`pi-sync auto sync skipped: ${errorMessage(error)}`, "warning");
381
+ }
382
+ }
383
+
384
+ async function autoPushSessions(ctx: ExtensionContext, signal: AbortSignal, loaders: SyncLoaders) {
385
+ try {
386
+ const partial = await loadPartialConfig();
387
+ throwIfAborted(signal);
388
+ if (!partial.automatic) return;
389
+ if (!partial.include.includes("sessions")) return;
390
+ await ensureStateDir();
391
+ throwIfAborted(signal);
392
+ const config = await loadConfig();
393
+ throwIfAborted(signal);
394
+ if (!config.include.includes("sessions")) return;
395
+ const [operations, snapshotModule, syncStateModule] = await Promise.all([
396
+ loaders.operations(),
397
+ loaders.snapshot(),
398
+ loaders.syncState(),
399
+ ]);
400
+ throwIfAborted(signal);
401
+ await withLock("auto-session-push", async () => {
402
+ throwIfAborted(signal);
403
+ const state = await readStateForConfig(config);
404
+ throwIfAborted(signal);
405
+ const local = await snapshotModule.createSnapshot(
406
+ config.snapshotIdentity,
407
+ snapshotOptionsForContext(ctx, config),
408
+ );
409
+ throwIfAborted(signal);
410
+ if (!syncStateModule.hasLocalChanges(local, state, config)) return;
411
+ await operations.push(ctx, { ...AUTO_SYNC_OPTIONS, signal }, { config, state, local });
412
+ });
413
+ } catch (error) {
414
+ if (signal.aborted || isMissingConfigError(error)) return;
415
+ ctx.ui.setStatus(STATUS_KEY, undefined);
416
+ ctx.ui.notify(`pi-sync session push skipped: ${errorMessage(error)}`, "warning");
417
+ }
418
+ }
419
+
420
+ async function initConfig(ctx: ExtensionCommandContext, signal?: AbortSignal) {
421
+ const configPath = localConfigPath();
422
+ if (await readLocalConfigObject()) {
423
+ ctx.ui.notify(`Config already exists: ${await activeLocalConfigPath()}`, "info");
424
+ return;
425
+ }
426
+
427
+ if (ctx.mode === "tui") {
428
+ const { showSetupWizard } = await import("./manager-ui.js");
429
+ throwIfAborted(signal);
430
+ await showSetupWizard(ctx, signal);
431
+ return;
432
+ }
433
+ await createLocalConfigDocument(localConfigTemplate());
434
+ ctx.ui.notify(
435
+ `Created ${configPath}. Add a storage connection and sync setup before syncing.`,
436
+ "info",
437
+ );
438
+ }
439
+
440
+ async function showConfig(ctx: ExtensionCommandContext, options: CommandOptions) {
441
+ const config = await loadConfig(options.setup);
442
+ const warnings = [
443
+ ...(config.backend.type === "s3" ? sessionTokenWarnings(config.backend.profile) : []),
444
+ ...syncSessionsWarnings(config),
445
+ ];
446
+ const storageLines = configStorageLines(config);
447
+ ctx.ui.notify(
448
+ [
449
+ "pi-sync config:",
450
+ `sync setup: ${config.setupName}`,
451
+ `storage connection: ${config.connectionName}`,
452
+ ...storageLines,
453
+ `storage path: ${config.storagePath}`,
454
+ `automatic sync: ${config.automatic ? "enabled" : "disabled"}`,
455
+ `included content: ${config.include.join(", ") || "none"}`,
456
+ `sessions: ${config.include.includes("sessions") ? "included" : "not included"}`,
457
+ `settings file: ${localConfigPath()}`,
458
+ ...warnings,
459
+ ].join("\n"),
460
+ warnings.length > 0 ? "warning" : "info",
461
+ );
462
+ }
463
+
464
+ function configStorageLines(config: AnySyncConfig) {
465
+ switch (config.backend.type) {
466
+ case "git":
467
+ return [
468
+ "kind: git",
469
+ `remote: ${displayGitRemote(config.backend.profile.remote)}`,
470
+ "authentication: existing Git/SSH credentials (not stored)",
471
+ `branch: ${config.backend.destination.branch}`,
472
+ ];
473
+ case "webdav":
474
+ return [
475
+ "kind: webdav",
476
+ `url: ${displayWebDavUrl(config.backend.profile.url, config.backend.profile.username)}`,
477
+ "username: configured (value hidden)",
478
+ "password: configured",
479
+ ];
480
+ case "s3":
481
+ return [
482
+ "kind: s3",
483
+ `endpoint: ${config.backend.profile.endpoint}`,
484
+ `bucket: ${config.backend.destination.bucket}`,
485
+ `region: ${config.backend.profile.region}`,
486
+ "access key id: configured",
487
+ "secret access key: configured",
488
+ `session token: ${config.backend.profile.sessionToken ? "configured" : "not configured"}`,
489
+ ];
490
+ }
491
+ }
492
+
493
+ function displayGitRemote(value: string | undefined) {
494
+ if (!value) return "missing";
495
+ try {
496
+ const url = new URL(value);
497
+ url.username = "";
498
+ url.password = "";
499
+ url.search = "";
500
+ url.hash = "";
501
+ return url.toString();
502
+ } catch {
503
+ return value.replace(/^(?:[^@\s]+@)?(?<host>[^:]+):.+$/u, "$<host>:…");
504
+ }
505
+ }
506
+
507
+ function displayWebDavUrl(value: string | undefined, username: string | undefined) {
508
+ if (!value) return "missing";
509
+ try {
510
+ const url = new URL(value);
511
+ url.username = "";
512
+ url.password = "";
513
+ url.search = "";
514
+ url.hash = "";
515
+ return username ? `${url.origin}/…` : `${url.origin}${url.pathname}`;
516
+ } catch {
517
+ return "invalid (value hidden)";
518
+ }
519
+ }
520
+
521
+ function throwIfAborted(signal?: AbortSignal) {
522
+ if (!signal?.aborted) return;
523
+ throw signal.reason instanceof Error
524
+ ? signal.reason
525
+ : new DOMException("The operation was aborted", "AbortError");
526
+ }
527
+
528
+ function combineSignals(primary: AbortSignal, secondary?: AbortSignal) {
529
+ return secondary ? AbortSignal.any([primary, secondary]) : primary;
530
+ }
531
+
532
+ function snapshotOptionsForContext(
533
+ ctx: ExtensionCommandContext | ExtensionContext,
534
+ config: AnySyncConfig,
535
+ ): SnapshotOptions {
536
+ return {
537
+ include: config.include,
538
+ sessionDir: sessionDirFromContext(ctx),
539
+ };
540
+ }
541
+
542
+ function sessionDirFromContext(ctx: ExtensionCommandContext | ExtensionContext) {
543
+ const manager = ctx.sessionManager as typeof ctx.sessionManager & {
544
+ usesDefaultSessionDir?: () => boolean;
545
+ };
546
+ const usesDefaultSessionDir = manager.usesDefaultSessionDir;
547
+ if (typeof usesDefaultSessionDir === "function" && usesDefaultSessionDir.call(manager)) {
548
+ return undefined;
549
+ }
550
+ const getSessionDir = manager.getSessionDir;
551
+ return typeof getSessionDir === "function"
552
+ ? (getSessionDir.call(manager) as string | undefined)
553
+ : undefined;
554
+ }
555
+
556
+ function cachedModuleLoader<Module>(load: () => Promise<Module>): () => Promise<Module> {
557
+ let pending: Promise<Module> | undefined;
558
+ return () => {
559
+ if (!pending) {
560
+ pending = load().catch((error) => {
561
+ pending = undefined;
562
+ throw error;
563
+ });
564
+ }
565
+ return pending;
566
+ };
567
+ }
@@ -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
- }
package/src/sync.ts CHANGED
@@ -1,515 +1,3 @@
1
- import type {
2
- ExtensionAPI,
3
- ExtensionCommandContext,
4
- ExtensionContext,
5
- } from "@earendil-works/pi-coding-agent";
6
- import type { RunRouteResult } from "./cancellable-operation.js";
7
- import {
8
- completeSyncArguments,
9
- parseOptions,
10
- resolveSyncCommand,
11
- setSyncSetupCompletions,
12
- splitArgs,
13
- usage,
14
- validateCommandOptions,
15
- } from "./command.js";
16
- import {
17
- activeLocalConfigPath,
18
- configuredSyncSetupNames,
19
- consumeLocalConfigMigrationNotice,
20
- createLocalConfigDocument,
21
- ensureStateDir,
22
- isMissingConfigError,
23
- loadConfig,
24
- loadPartialConfig,
25
- localConfigPath,
26
- localConfigTemplate,
27
- readLocalConfigObject,
28
- readStateForConfig,
29
- sessionTokenWarnings,
30
- syncSessionsWarnings,
31
- } from "./config.js";
32
- import { unlock, withLock } from "./lock.js";
33
- import { SetupPullRequiresUiError, useSyncSetup } from "./setup-switch.js";
34
- import { createSnapshot } from "./snapshot.js";
35
- import { recoverSnapshotTransactionsOnStartup } from "./snapshot-transaction.js";
36
- import {
37
- migrateLegacyStateDirectory,
38
- stateDirectoryMigrationNotice,
39
- withStateDirectoryAccess,
40
- } from "./state-directory.js";
41
- import { isSyncDecisionRequiredError } from "./sync-decision.js";
42
- import { errorMessage } from "./sync-format.js";
43
- import { hasLocalChanges } from "./sync-state.js";
44
- import type { AnySyncConfig, CommandOptions, SnapshotOptions } from "./types.js";
45
-
46
- const STATUS_KEY = "sync";
47
-
48
- type SyncOperations = typeof import("./sync-operations.js");
49
- let syncOperationsPromise: Promise<SyncOperations> | undefined;
50
-
51
- function loadSyncOperations(): Promise<SyncOperations> {
52
- if (!syncOperationsPromise) {
53
- syncOperationsPromise = import("./sync-operations.js").catch((error) => {
54
- syncOperationsPromise = undefined;
55
- throw error;
56
- });
57
- }
58
- return syncOperationsPromise;
59
- }
60
-
61
- const AUTO_SYNC_OPTIONS: CommandOptions = {
62
- yes: true,
63
- force: false,
64
- stale: false,
65
- silent: true,
66
- reload: false,
67
- auto: true,
68
- args: [],
69
- };
70
- export default function sync(pi: ExtensionAPI) {
71
- let sessionAbort = new AbortController();
72
- let shutdownAbort: AbortController | undefined;
73
-
74
- pi.registerCommand("sync", {
75
- description: "Sync Pi settings through Git, WebDAV, R2, or S3-compatible storage",
76
- getArgumentCompletions: completeSyncArguments,
77
- handler: async (args, ctx) => {
78
- if (!ctx.hasUI) {
79
- throw new Error(
80
- "/sync requires TUI or RPC mode so results and safety prompts are observable.",
81
- );
82
- }
83
- const run = () => handleCommand(args, ctx, sessionAbort.signal);
84
- if (splitArgs(args)[0] === "migrate-state") await run();
85
- else await withStateDirectoryAccess(run);
86
- },
87
- });
88
-
89
- pi.on("session_start", async (_event, ctx) => {
90
- shutdownAbort?.abort(new DOMException("Session replaced", "AbortError"));
91
- shutdownAbort = undefined;
92
- sessionAbort.abort(new DOMException("Session replaced", "AbortError"));
93
- sessionAbort = new AbortController();
94
- const signal = sessionAbort.signal;
95
- ctx.ui.setStatus(STATUS_KEY, undefined);
96
- try {
97
- await withStateDirectoryAccess(() => startSession(ctx, signal));
98
- } catch (error) {
99
- if (signal.aborted) return;
100
- ctx.ui.notify(`pi-sync state access failed: ${errorMessage(error)}`, "error");
101
- }
102
- });
103
-
104
- pi.on("session_shutdown", async (event, ctx) => {
105
- sessionAbort.abort(new DOMException("Session shut down", "AbortError"));
106
- shutdownAbort?.abort(new DOMException("Session shut down again", "AbortError"));
107
- const controller = new AbortController();
108
- shutdownAbort = controller;
109
- const signal = combineSignals(controller.signal, AbortSignal.timeout(30_000));
110
- const reason =
111
- typeof event === "object" && event ? (event as { reason?: string }).reason : undefined;
112
- try {
113
- if (reason !== "reload") {
114
- await withStateDirectoryAccess(async () => {
115
- if (signal.aborted) return;
116
- await autoPushSessions(ctx, signal);
117
- });
118
- }
119
- } catch (error) {
120
- if (!signal.aborted) {
121
- ctx.ui.notify(`pi-sync session push skipped: ${errorMessage(error)}`, "warning");
122
- }
123
- } finally {
124
- if (shutdownAbort === controller) shutdownAbort = undefined;
125
- }
126
- if (signal.aborted) return;
127
- ctx.ui.setStatus(STATUS_KEY, undefined);
128
- });
129
- }
130
-
131
- async function startSession(ctx: ExtensionContext, signal: AbortSignal) {
132
- if (signal.aborted) return;
133
- try {
134
- const migrationNotice = stateDirectoryMigrationNotice();
135
- if (migrationNotice) ctx.ui.notify(migrationNotice, "warning");
136
- } catch (error) {
137
- ctx.ui.notify(`pi-sync state directory requires attention: ${errorMessage(error)}`, "error");
138
- return;
139
- }
140
- try {
141
- await recoverSnapshotTransactionsOnStartup();
142
- if (signal.aborted) return;
143
- } catch (error) {
144
- if (signal.aborted) return;
145
- ctx.ui.notify(`pi-sync recovery required: ${errorMessage(error)}`, "error");
146
- return;
147
- }
148
- try {
149
- setSyncSetupCompletions(await configuredSyncSetupNames());
150
- if (signal.aborted) return;
151
- } catch {
152
- if (signal.aborted) return;
153
- setSyncSetupCompletions([]);
154
- }
155
- const migrationNotice = consumeLocalConfigMigrationNotice();
156
- if (migrationNotice) ctx.ui.notify(migrationNotice, "warning");
157
- if (signal.aborted) return;
158
- await autoSync(ctx, signal);
159
- }
160
-
161
- async function handleCommand(
162
- rawArgs: string,
163
- ctx: ExtensionCommandContext,
164
- sessionSignal: AbortSignal,
165
- ) {
166
- if (!rawArgs.trim()) {
167
- try {
168
- const { showSyncManager } = await import("./manager-ui.js");
169
- if (sessionSignal.aborted) return;
170
- await showSyncManager(
171
- ctx,
172
- (route, signal, onCommit, target) =>
173
- executeCommand(route, ctx, combineSignals(sessionSignal, signal), onCommit, target),
174
- sessionSignal,
175
- );
176
- } catch (error) {
177
- if (sessionSignal.aborted) return;
178
- ctx.ui.setStatus(STATUS_KEY, undefined);
179
- ctx.ui.notify(errorMessage(error), "error");
180
- }
181
- return;
182
- }
183
- const result = await executeCommand(rawArgs, ctx, sessionSignal);
184
- if (result.kind === "decision-required") {
185
- ctx.ui.notify(result.decision.directMessage, "error");
186
- }
187
- }
188
-
189
- async function executeCommand(
190
- rawArgs: string,
191
- ctx: ExtensionCommandContext,
192
- signal?: AbortSignal,
193
- onCommit?: () => void,
194
- setup?: string,
195
- ): Promise<RunRouteResult> {
196
- try {
197
- const command = await resolveSyncCommand(rawArgs, ctx);
198
- if (signal?.aborted || !command) return { kind: "completed" };
199
- const { subcommand, rest } = command;
200
- const options = parseOptions(rest);
201
- if (setup !== undefined) options.setup = setup;
202
- if (signal) options.signal = signal;
203
- if (onCommit) options.onCommit = onCommit;
204
- validateCommandOptions(subcommand, options);
205
-
206
- switch (subcommand) {
207
- case "help":
208
- ctx.ui.notify(usage(), "info");
209
- return { kind: "completed" };
210
- case "use":
211
- await useSyncSetup(
212
- ctx,
213
- options.args[0] ?? "",
214
- async (selectedSetup) => {
215
- const operations = await loadSyncOperations();
216
- throwIfAborted(options.signal);
217
- return withLock("pull", () =>
218
- operations.pull(ctx, { ...options, setup: selectedSetup }),
219
- );
220
- },
221
- undefined,
222
- options.signal,
223
- );
224
- return { kind: "completed" };
225
- case "init":
226
- await initConfig(ctx, signal);
227
- return { kind: "completed" };
228
- case "config":
229
- await showConfig(ctx, options);
230
- return { kind: "completed" };
231
- case "files":
232
- await (await import("./file-selection.js")).showFileSelection(
233
- ctx,
234
- options.setup,
235
- options.signal,
236
- );
237
- return { kind: "completed" };
238
- case "status":
239
- await (await loadSyncOperations()).status(ctx, options);
240
- return { kind: "completed" };
241
- case "diff":
242
- await (await loadSyncOperations()).diff(ctx, options);
243
- return { kind: "completed" };
244
- case "doctor":
245
- await (await loadSyncOperations()).doctor(ctx, options);
246
- return { kind: "completed" };
247
- case "push": {
248
- const operations = await loadSyncOperations();
249
- throwIfAborted(options.signal);
250
- const outcome = await withLock("push", () => operations.push(ctx, options));
251
- return { kind: "completed", ...(outcome ? { outcome } : {}) };
252
- }
253
- case "pull": {
254
- const operations = await loadSyncOperations();
255
- throwIfAborted(options.signal);
256
- const outcome = await withLock("pull", () => operations.pull(ctx, options));
257
- return { kind: "completed", ...(outcome ? { outcome } : {}) };
258
- }
259
- case "sync": {
260
- const operations = await loadSyncOperations();
261
- throwIfAborted(options.signal);
262
- await withLock("sync", () => operations.syncBoth(ctx, options));
263
- return { kind: "completed" };
264
- }
265
- case "history":
266
- await (await loadSyncOperations()).history(ctx, options);
267
- return { kind: "completed" };
268
- case "rollback": {
269
- const operations = await loadSyncOperations();
270
- throwIfAborted(options.signal);
271
- await withLock("rollback", () => operations.rollback(ctx, options));
272
- return { kind: "completed" };
273
- }
274
- case "migrate-state":
275
- await migrateStateDirectory(ctx, options);
276
- return { kind: "completed" };
277
- case "unlock":
278
- await unlock(ctx, options);
279
- return { kind: "completed" };
280
- default:
281
- ctx.ui.notify(`Unknown /sync command: ${subcommand}\n\n${usage()}`, "warning");
282
- return { kind: "failed" };
283
- }
284
- } catch (error) {
285
- if (signal?.aborted) return { kind: "failed" };
286
- ctx.ui.setStatus(STATUS_KEY, undefined);
287
- if (error instanceof SetupPullRequiresUiError) throw error;
288
- if (isSyncDecisionRequiredError(error)) {
289
- return { kind: "decision-required", decision: error.decision };
290
- }
291
- ctx.ui.notify(errorMessage(error), "error");
292
- return { kind: "failed" };
293
- }
294
- }
295
-
296
- async function migrateStateDirectory(ctx: ExtensionCommandContext, options: CommandOptions) {
297
- const notice = stateDirectoryMigrationNotice();
298
- if (!notice) {
299
- ctx.ui.notify("pi-sync already uses the canonical pi-sync/ state directory.", "info");
300
- return;
301
- }
302
- if (
303
- !options.yes &&
304
- !(await ctx.ui.confirm(
305
- "Migrate pi-sync state directory",
306
- "Confirm that every other Pi process is closed. pi-sync will atomically rename .pisync/ to pi-sync/ without merging or deleting either root.",
307
- { signal: options.signal },
308
- ))
309
- ) {
310
- ctx.ui.notify("pi-sync state migration cancelled.", "info");
311
- return;
312
- }
313
- throwIfAborted(options.signal);
314
- const result = await migrateLegacyStateDirectory();
315
- throwIfAborted(options.signal);
316
- if (result.status === "ready") {
317
- ctx.ui.notify("pi-sync already uses the canonical pi-sync/ state directory.", "info");
318
- return;
319
- }
320
- ctx.ui.notify(result.message, result.status === "migrated" ? "info" : "warning");
321
- }
322
-
323
- async function autoSync(ctx: ExtensionContext, signal: AbortSignal) {
324
- try {
325
- const partial = await loadPartialConfig();
326
- throwIfAborted(signal);
327
- if (!partial.automatic) return;
328
- await ensureStateDir();
329
- throwIfAborted(signal);
330
- await loadConfig();
331
- throwIfAborted(signal);
332
- const operations = await loadSyncOperations();
333
- throwIfAborted(signal);
334
- await withLock("auto-sync", () => {
335
- throwIfAborted(signal);
336
- return operations.syncBoth(ctx, { ...AUTO_SYNC_OPTIONS, signal });
337
- });
338
- } catch (error) {
339
- if (signal.aborted || isMissingConfigError(error)) return;
340
- ctx.ui.setStatus(STATUS_KEY, undefined);
341
- ctx.ui.notify(`pi-sync auto sync skipped: ${errorMessage(error)}`, "warning");
342
- }
343
- }
344
-
345
- async function autoPushSessions(ctx: ExtensionContext, signal: AbortSignal) {
346
- try {
347
- const partial = await loadPartialConfig();
348
- throwIfAborted(signal);
349
- if (!partial.automatic) return;
350
- if (!partial.include.includes("sessions")) return;
351
- await ensureStateDir();
352
- throwIfAborted(signal);
353
- const config = await loadConfig();
354
- throwIfAborted(signal);
355
- if (!config.include.includes("sessions")) return;
356
- const operations = await loadSyncOperations();
357
- throwIfAborted(signal);
358
- await withLock("auto-session-push", async () => {
359
- throwIfAborted(signal);
360
- const state = await readStateForConfig(config);
361
- throwIfAborted(signal);
362
- const local = await createSnapshot(
363
- config.snapshotIdentity,
364
- snapshotOptionsForContext(ctx, config),
365
- );
366
- throwIfAborted(signal);
367
- if (!hasLocalChanges(local, state, config)) return;
368
- await operations.push(ctx, { ...AUTO_SYNC_OPTIONS, signal }, { config, state, local });
369
- });
370
- } catch (error) {
371
- if (signal.aborted || isMissingConfigError(error)) return;
372
- ctx.ui.setStatus(STATUS_KEY, undefined);
373
- ctx.ui.notify(`pi-sync session push skipped: ${errorMessage(error)}`, "warning");
374
- }
375
- }
376
-
377
- async function initConfig(ctx: ExtensionCommandContext, signal?: AbortSignal) {
378
- const configPath = localConfigPath();
379
- if (await readLocalConfigObject()) {
380
- ctx.ui.notify(`Config already exists: ${await activeLocalConfigPath()}`, "info");
381
- return;
382
- }
383
-
384
- if (ctx.mode === "tui") {
385
- const { showSetupWizard } = await import("./manager-ui.js");
386
- throwIfAborted(signal);
387
- await showSetupWizard(ctx, signal);
388
- return;
389
- }
390
- await createLocalConfigDocument(localConfigTemplate());
391
- ctx.ui.notify(
392
- `Created ${configPath}. Add a storage connection and sync setup before syncing.`,
393
- "info",
394
- );
395
- }
396
-
397
- async function showConfig(ctx: ExtensionCommandContext, options: CommandOptions) {
398
- const config = await loadConfig(options.setup);
399
- const warnings = [
400
- ...(config.backend.type === "s3" ? sessionTokenWarnings(config.backend.profile) : []),
401
- ...syncSessionsWarnings(config),
402
- ];
403
- const storageLines = configStorageLines(config);
404
- ctx.ui.notify(
405
- [
406
- "pi-sync config:",
407
- `sync setup: ${config.setupName}`,
408
- `storage connection: ${config.connectionName}`,
409
- ...storageLines,
410
- `storage path: ${config.storagePath}`,
411
- `automatic sync: ${config.automatic ? "enabled" : "disabled"}`,
412
- `included content: ${config.include.join(", ") || "none"}`,
413
- `sessions: ${config.include.includes("sessions") ? "included" : "not included"}`,
414
- `settings file: ${localConfigPath()}`,
415
- ...warnings,
416
- ].join("\n"),
417
- warnings.length > 0 ? "warning" : "info",
418
- );
419
- }
420
-
421
- function configStorageLines(config: AnySyncConfig) {
422
- switch (config.backend.type) {
423
- case "git":
424
- return [
425
- "kind: git",
426
- `remote: ${displayGitRemote(config.backend.profile.remote)}`,
427
- "authentication: existing Git/SSH credentials (not stored)",
428
- `branch: ${config.backend.destination.branch}`,
429
- ];
430
- case "webdav":
431
- return [
432
- "kind: webdav",
433
- `url: ${displayWebDavUrl(config.backend.profile.url, config.backend.profile.username)}`,
434
- "username: configured (value hidden)",
435
- "password: configured",
436
- ];
437
- case "s3":
438
- return [
439
- "kind: s3",
440
- `endpoint: ${config.backend.profile.endpoint}`,
441
- `bucket: ${config.backend.destination.bucket}`,
442
- `region: ${config.backend.profile.region}`,
443
- "access key id: configured",
444
- "secret access key: configured",
445
- `session token: ${config.backend.profile.sessionToken ? "configured" : "not configured"}`,
446
- ];
447
- }
448
- }
449
-
450
- function displayGitRemote(value: string | undefined) {
451
- if (!value) return "missing";
452
- try {
453
- const url = new URL(value);
454
- url.username = "";
455
- url.password = "";
456
- url.search = "";
457
- url.hash = "";
458
- return url.toString();
459
- } catch {
460
- return value.replace(/^(?:[^@\s]+@)?(?<host>[^:]+):.+$/u, "$<host>:…");
461
- }
462
- }
463
-
464
- function displayWebDavUrl(value: string | undefined, username: string | undefined) {
465
- if (!value) return "missing";
466
- try {
467
- const url = new URL(value);
468
- url.username = "";
469
- url.password = "";
470
- url.search = "";
471
- url.hash = "";
472
- return username ? `${url.origin}/…` : `${url.origin}${url.pathname}`;
473
- } catch {
474
- return "invalid (value hidden)";
475
- }
476
- }
477
-
478
- function throwIfAborted(signal?: AbortSignal) {
479
- if (!signal?.aborted) return;
480
- throw signal.reason instanceof Error
481
- ? signal.reason
482
- : new DOMException("The operation was aborted", "AbortError");
483
- }
484
-
485
- function combineSignals(primary: AbortSignal, secondary?: AbortSignal) {
486
- return secondary ? AbortSignal.any([primary, secondary]) : primary;
487
- }
488
-
489
- function snapshotOptionsForContext(
490
- ctx: ExtensionCommandContext | ExtensionContext,
491
- config: AnySyncConfig,
492
- ): SnapshotOptions {
493
- return {
494
- include: config.include,
495
- sessionDir: sessionDirFromContext(ctx),
496
- };
497
- }
498
-
499
- function sessionDirFromContext(ctx: ExtensionCommandContext | ExtensionContext) {
500
- const manager = ctx.sessionManager as typeof ctx.sessionManager & {
501
- usesDefaultSessionDir?: () => boolean;
502
- };
503
- const usesDefaultSessionDir = manager.usesDefaultSessionDir;
504
- if (typeof usesDefaultSessionDir === "function" && usesDefaultSessionDir.call(manager)) {
505
- return undefined;
506
- }
507
- const getSessionDir = manager.getSessionDir;
508
- return typeof getSessionDir === "function"
509
- ? (getSessionDir.call(manager) as string | undefined)
510
- : undefined;
511
- }
512
-
513
1
  export { completeSyncArguments, parseOptions, splitArgs } from "./command.js";
514
2
  export {
515
3
  isCloudflareR2Endpoint,
@@ -538,6 +26,7 @@ export {
538
26
  preflightSnapshotApply,
539
27
  protectSnapshotApplyPlan,
540
28
  } from "./snapshot-apply.js";
29
+ export { default, type SyncDependencies } from "./sync-extension.js";
541
30
  export {
542
31
  canPullRemoteSessionsOnFirstSync,
543
32
  canPullRemoteSettingsOnFirstSync,