@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.
@@ -0,0 +1,765 @@
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
+ createSyncAttentionController,
41
+ type SyncAttentionController,
42
+ type SyncAttentionOrigin,
43
+ syncAttentionMatchesConfig,
44
+ } from "./sync-attention.js";
45
+ import {
46
+ errorMessage,
47
+ isSyncDecisionRequiredError,
48
+ SetupPullRequiresUiError,
49
+ } from "./sync-errors.js";
50
+ import {
51
+ formatRemoteSelectionMismatch,
52
+ type RemoteSelectionDecision,
53
+ RemoteSelectionMismatchError,
54
+ } from "./sync-policy.js";
55
+ import type { AnySyncConfig, CommandOptions, SnapshotOptions } from "./types.js";
56
+
57
+ const STATUS_KEY = "sync";
58
+
59
+ type SetupSwitchModule = Pick<typeof import("./setup-switch.js"), "useSyncSetup">;
60
+ type SnapshotModule = Pick<typeof import("./snapshot.js"), "createSnapshot">;
61
+ type SyncStateModule = Pick<typeof import("./sync-state.js"), "hasLocalChanges">;
62
+ type SyncOperations = typeof import("./sync-operations.js");
63
+
64
+ export interface SyncDependencies {
65
+ loadSetupSwitch(): Promise<SetupSwitchModule>;
66
+ loadSnapshot(): Promise<SnapshotModule>;
67
+ loadSyncState(): Promise<SyncStateModule>;
68
+ loadSyncOperations(): Promise<SyncOperations>;
69
+ }
70
+
71
+ interface SyncLoaders {
72
+ setupSwitch(): Promise<SetupSwitchModule>;
73
+ snapshot(): Promise<SnapshotModule>;
74
+ syncState(): Promise<SyncStateModule>;
75
+ operations(): Promise<SyncOperations>;
76
+ }
77
+
78
+ const AUTO_SYNC_OPTIONS: CommandOptions = {
79
+ yes: true,
80
+ force: false,
81
+ stale: false,
82
+ silent: true,
83
+ reload: false,
84
+ auto: true,
85
+ args: [],
86
+ };
87
+ export default function sync(pi: ExtensionAPI, dependencies: Partial<SyncDependencies> = {}) {
88
+ const loaders: SyncLoaders = {
89
+ setupSwitch: cachedModuleLoader(
90
+ dependencies.loadSetupSwitch ?? (() => import("./setup-switch.js")),
91
+ ),
92
+ snapshot: cachedModuleLoader(dependencies.loadSnapshot ?? (() => import("./snapshot.js"))),
93
+ syncState: cachedModuleLoader(dependencies.loadSyncState ?? (() => import("./sync-state.js"))),
94
+ operations: cachedModuleLoader(
95
+ dependencies.loadSyncOperations ?? (() => import("./sync-operations.js")),
96
+ ),
97
+ };
98
+ const attention = createSyncAttentionController();
99
+ let sessionAbort = new AbortController();
100
+ let shutdownAbort: AbortController | undefined;
101
+
102
+ pi.registerCommand("sync", {
103
+ description: "Sync Pi settings through Git, WebDAV, R2, or S3-compatible storage",
104
+ getArgumentCompletions: completeSyncArguments,
105
+ handler: async (args, ctx) => {
106
+ if (!ctx.hasUI) {
107
+ throw new Error(
108
+ "/sync requires TUI or RPC mode so results and safety prompts are observable.",
109
+ );
110
+ }
111
+ const run = () => handleCommand(args, ctx, sessionAbort.signal, loaders, attention);
112
+ if (splitArgs(args)[0] === "migrate-state") await run();
113
+ else await withStateDirectoryAccess(run);
114
+ },
115
+ });
116
+
117
+ pi.on("session_start", async (_event, ctx) => {
118
+ shutdownAbort?.abort(new DOMException("Session replaced", "AbortError"));
119
+ shutdownAbort = undefined;
120
+ sessionAbort.abort(new DOMException("Session replaced", "AbortError"));
121
+ sessionAbort = new AbortController();
122
+ const signal = sessionAbort.signal;
123
+ attention.reset(ctx);
124
+ let decision: RemoteSelectionDecision | undefined;
125
+ try {
126
+ decision = await withStateDirectoryAccess(() => startSession(ctx, signal, loaders));
127
+ } catch (error) {
128
+ if (signal.aborted) return;
129
+ ctx.ui.notify(`pi-sync state access failed: ${errorMessage(error)}`, "error");
130
+ return;
131
+ }
132
+ if (!decision || signal.aborted) return;
133
+ attention.set(decision, "sync");
134
+ if (ctx.mode !== "tui") {
135
+ ctx.ui.notify(
136
+ `pi-sync auto sync skipped: ${formatRemoteSelectionMismatch(
137
+ decision.setupName,
138
+ decision.localInclude,
139
+ decision.remoteInclude,
140
+ )}\nRPC review is read-only.`,
141
+ "warning",
142
+ );
143
+ } else if (attention.markOffered()) {
144
+ await resolveSelectionAttention(ctx, attention, signal, loaders, {
145
+ cancelLabel: "Later",
146
+ withStateAccess: withStateDirectoryAccess,
147
+ });
148
+ }
149
+ if (!signal.aborted) attention.publish(ctx);
150
+ });
151
+
152
+ pi.on("session_shutdown", async (event, ctx) => {
153
+ sessionAbort.abort(new DOMException("Session shut down", "AbortError"));
154
+ attention.reset(ctx);
155
+ shutdownAbort?.abort(new DOMException("Session shut down again", "AbortError"));
156
+ const controller = new AbortController();
157
+ shutdownAbort = controller;
158
+ const signal = combineSignals(controller.signal, AbortSignal.timeout(30_000));
159
+ const reason =
160
+ typeof event === "object" && event ? (event as { reason?: string }).reason : undefined;
161
+ try {
162
+ if (reason !== "reload") {
163
+ await withStateDirectoryAccess(async () => {
164
+ if (signal.aborted) return;
165
+ await autoPushSessions(ctx, signal, loaders);
166
+ });
167
+ }
168
+ } catch (error) {
169
+ if (!signal.aborted) {
170
+ ctx.ui.notify(`pi-sync session push skipped: ${errorMessage(error)}`, "warning");
171
+ }
172
+ } finally {
173
+ if (shutdownAbort === controller) shutdownAbort = undefined;
174
+ }
175
+ if (signal.aborted) return;
176
+ ctx.ui.setStatus(STATUS_KEY, undefined);
177
+ });
178
+ }
179
+
180
+ async function startSession(ctx: ExtensionContext, signal: AbortSignal, loaders: SyncLoaders) {
181
+ if (signal.aborted) return;
182
+ try {
183
+ const migrationNotice = stateDirectoryMigrationNotice();
184
+ if (migrationNotice) ctx.ui.notify(migrationNotice, "warning");
185
+ } catch (error) {
186
+ ctx.ui.notify(`pi-sync state directory requires attention: ${errorMessage(error)}`, "error");
187
+ return;
188
+ }
189
+ try {
190
+ await recoverSnapshotTransactionsOnStartup();
191
+ if (signal.aborted) return;
192
+ } catch (error) {
193
+ if (signal.aborted) return;
194
+ ctx.ui.notify(`pi-sync recovery required: ${errorMessage(error)}`, "error");
195
+ return;
196
+ }
197
+ try {
198
+ setSyncSetupCompletions(await configuredSyncSetupNames());
199
+ if (signal.aborted) return;
200
+ } catch {
201
+ if (signal.aborted) return;
202
+ setSyncSetupCompletions([]);
203
+ }
204
+ const migrationNotice = consumeLocalConfigMigrationNotice();
205
+ if (migrationNotice) ctx.ui.notify(migrationNotice, "warning");
206
+ if (signal.aborted) return;
207
+ return autoSync(ctx, signal, loaders);
208
+ }
209
+
210
+ async function handleCommand(
211
+ rawArgs: string,
212
+ ctx: ExtensionCommandContext,
213
+ sessionSignal: AbortSignal,
214
+ loaders: SyncLoaders,
215
+ attention: SyncAttentionController,
216
+ ) {
217
+ if (!rawArgs.trim()) {
218
+ try {
219
+ const { showSyncManager } = await import("./manager-ui.js");
220
+ if (sessionSignal.aborted) return;
221
+ await showSyncManager(
222
+ ctx,
223
+ (route, signal, onCommit, target) =>
224
+ executeCommand(
225
+ route,
226
+ ctx,
227
+ combineSignals(sessionSignal, signal),
228
+ loaders,
229
+ onCommit,
230
+ target,
231
+ ),
232
+ sessionSignal,
233
+ {
234
+ getAttention: () => attention.current(),
235
+ onSelectionResolved: (expected) => {
236
+ if (attention.current() === expected) attention.clear(ctx);
237
+ },
238
+ },
239
+ );
240
+ } catch (error) {
241
+ if (sessionSignal.aborted) return;
242
+ ctx.ui.setStatus(STATUS_KEY, undefined);
243
+ ctx.ui.notify(errorMessage(error), "error");
244
+ }
245
+ if (!sessionSignal.aborted) attention.publish(ctx);
246
+ return;
247
+ }
248
+ const result = await executeCommand(rawArgs, ctx, sessionSignal, loaders);
249
+ if (result.kind === "decision-required") {
250
+ ctx.ui.notify(result.decision.directMessage, "error");
251
+ } else if (result.kind === "remote-selection-required") {
252
+ const origin = directSelectionOrigin(rawArgs);
253
+ if (origin) attention.set(result.decision, origin);
254
+ const deterministic = splitArgs(rawArgs).some((arg) => arg === "--yes" || arg === "-y");
255
+ if (origin && ctx.mode === "tui" && !deterministic) {
256
+ await resolveSelectionAttention(ctx, attention, sessionSignal, loaders);
257
+ } else {
258
+ ctx.ui.notify(
259
+ formatRemoteSelectionMismatch(
260
+ result.decision.setupName,
261
+ result.decision.localInclude,
262
+ result.decision.remoteInclude,
263
+ ),
264
+ "error",
265
+ );
266
+ }
267
+ }
268
+ await clearAttentionAfterCompletedOperation(rawArgs, result, ctx, attention, sessionSignal);
269
+ await reconcileSelectionAttention(ctx, attention, sessionSignal);
270
+ if (!sessionSignal.aborted) attention.publish(ctx);
271
+ }
272
+
273
+ async function clearAttentionAfterCompletedOperation(
274
+ rawArgs: string,
275
+ result: RunRouteResult,
276
+ ctx: ExtensionContext,
277
+ attention: SyncAttentionController,
278
+ signal: AbortSignal,
279
+ ) {
280
+ if (result.kind !== "completed" || result.outcome === "cancelled" || signal.aborted) return;
281
+ const [command, ...rest] = splitArgs(rawArgs);
282
+ if (command !== "sync" && command !== "pull" && command !== "push") return;
283
+ const current = attention.current();
284
+ if (!current) return;
285
+ try {
286
+ const options = parseOptions(rest);
287
+ const setupName = options.setup ?? (await loadConfig()).setupName;
288
+ if (signal.aborted || attention.current() !== current) return;
289
+ if (current.decision.setupName === setupName) attention.clear(ctx);
290
+ } catch {
291
+ // Attention reconciliation below owns malformed or concurrently changed settings.
292
+ }
293
+ }
294
+
295
+ async function reconcileSelectionAttention(
296
+ ctx: ExtensionContext,
297
+ attention: SyncAttentionController,
298
+ signal: AbortSignal,
299
+ ) {
300
+ const current = attention.current();
301
+ if (!current || signal.aborted) return;
302
+ try {
303
+ const config = await loadConfig(current.decision.setupName);
304
+ if (signal.aborted || attention.current() !== current) return;
305
+ if (!syncAttentionMatchesConfig(current, config)) attention.clear(ctx);
306
+ } catch {
307
+ if (!signal.aborted && attention.current() === current) attention.clear(ctx);
308
+ }
309
+ }
310
+
311
+ function directSelectionOrigin(rawArgs: string): SyncAttentionOrigin | undefined {
312
+ const command = splitArgs(rawArgs)[0];
313
+ return command === "sync" || command === "pull" || command === "push" ? command : undefined;
314
+ }
315
+
316
+ async function resolveSelectionAttention(
317
+ ctx: ExtensionContext,
318
+ attention: SyncAttentionController,
319
+ signal: AbortSignal,
320
+ loaders: SyncLoaders,
321
+ options: {
322
+ cancelLabel?: string;
323
+ withStateAccess?: <T>(task: () => Promise<T>) => Promise<T>;
324
+ } = {},
325
+ ) {
326
+ const current = attention.current();
327
+ if (!current || signal.aborted) return;
328
+ const { dispatchManagerResult } = await import("./manager-result-dispatcher.js");
329
+ if (signal.aborted || attention.current() !== current) return;
330
+ await dispatchManagerResult(
331
+ ctx,
332
+ { kind: "remote-selection-required", decision: current.decision },
333
+ current.origin,
334
+ (route, actionSignal, onCommit, target) => {
335
+ const execute = () =>
336
+ executeRecoveryCommand(
337
+ route,
338
+ ctx,
339
+ combineSignals(signal, actionSignal),
340
+ loaders,
341
+ onCommit,
342
+ target,
343
+ );
344
+ return options.withStateAccess ? options.withStateAccess(execute) : execute();
345
+ },
346
+ signal,
347
+ {
348
+ cancelLabel: options.cancelLabel,
349
+ withStateAccess: options.withStateAccess,
350
+ onSelectionResolved: () => {
351
+ if (attention.current() === current) attention.clear(ctx);
352
+ },
353
+ },
354
+ );
355
+ }
356
+
357
+ async function executeRecoveryCommand(
358
+ rawArgs: string,
359
+ ctx: ExtensionContext,
360
+ signal: AbortSignal | undefined,
361
+ loaders: SyncLoaders,
362
+ onCommit?: () => void,
363
+ setup?: string,
364
+ ): Promise<RunRouteResult> {
365
+ try {
366
+ const [subcommand, ...rest] = splitArgs(rawArgs);
367
+ if (subcommand !== "sync" && subcommand !== "pull" && subcommand !== "push") {
368
+ throw new Error(`Unsupported sync recovery route: ${subcommand ?? "missing"}`);
369
+ }
370
+ const options = parseOptions(rest);
371
+ if (setup !== undefined) options.setup = setup;
372
+ if (signal) options.signal = signal;
373
+ if (onCommit) options.onCommit = onCommit;
374
+ options.reload = false;
375
+ options.auto = false;
376
+ validateCommandOptions(subcommand, options);
377
+ const operations = await loaders.operations();
378
+ throwIfAborted(options.signal);
379
+ if (subcommand === "push") {
380
+ const outcome = await withLock("push", () => operations.push(ctx, options));
381
+ return { kind: "completed", ...(outcome ? { outcome } : {}) };
382
+ }
383
+ if (subcommand === "pull") {
384
+ const outcome = await withLock("pull", () => operations.pull(ctx, options));
385
+ return { kind: "completed", ...(outcome ? { outcome } : {}) };
386
+ }
387
+ await withLock("sync", () => operations.syncBoth(ctx, options));
388
+ return { kind: "completed" };
389
+ } catch (error) {
390
+ if (signal?.aborted) return { kind: "failed" };
391
+ ctx.ui.setStatus(STATUS_KEY, undefined);
392
+ if (error instanceof RemoteSelectionMismatchError) {
393
+ return { kind: "remote-selection-required", decision: error.decision };
394
+ }
395
+ if (isSyncDecisionRequiredError(error)) {
396
+ return { kind: "decision-required", decision: error.decision };
397
+ }
398
+ ctx.ui.notify(errorMessage(error), "error");
399
+ return { kind: "failed" };
400
+ }
401
+ }
402
+
403
+ async function executeCommand(
404
+ rawArgs: string,
405
+ ctx: ExtensionCommandContext,
406
+ signal: AbortSignal | undefined,
407
+ loaders: SyncLoaders,
408
+ onCommit?: () => void,
409
+ setup?: string,
410
+ ): Promise<RunRouteResult> {
411
+ try {
412
+ const command = await resolveSyncCommand(rawArgs, ctx);
413
+ if (signal?.aborted || !command) return { kind: "completed" };
414
+ const { subcommand, rest } = command;
415
+ const options = parseOptions(rest);
416
+ if (setup !== undefined) options.setup = setup;
417
+ if (signal) options.signal = signal;
418
+ if (onCommit) options.onCommit = onCommit;
419
+ validateCommandOptions(subcommand, options);
420
+
421
+ switch (subcommand) {
422
+ case "help":
423
+ ctx.ui.notify(usage(), "info");
424
+ return { kind: "completed" };
425
+ case "use": {
426
+ const { useSyncSetup } = await loaders.setupSwitch();
427
+ throwIfAborted(options.signal);
428
+ await useSyncSetup(
429
+ ctx,
430
+ options.args[0] ?? "",
431
+ async (selectedSetup) => {
432
+ const operations = await loaders.operations();
433
+ throwIfAborted(options.signal);
434
+ return withLock("pull", () =>
435
+ operations.pull(ctx, { ...options, setup: selectedSetup }),
436
+ );
437
+ },
438
+ undefined,
439
+ options.signal,
440
+ );
441
+ return { kind: "completed" };
442
+ }
443
+ case "init":
444
+ await initConfig(ctx, signal);
445
+ return { kind: "completed" };
446
+ case "config":
447
+ await showConfig(ctx, options);
448
+ return { kind: "completed" };
449
+ case "files": {
450
+ const { showFileSelection } = await import("./file-selection.js");
451
+ throwIfAborted(options.signal);
452
+ await showFileSelection(ctx, options.setup, options.signal);
453
+ return { kind: "completed" };
454
+ }
455
+ case "status": {
456
+ const operations = await loaders.operations();
457
+ throwIfAborted(options.signal);
458
+ await operations.status(ctx, options);
459
+ return { kind: "completed" };
460
+ }
461
+ case "diff": {
462
+ const operations = await loaders.operations();
463
+ throwIfAborted(options.signal);
464
+ await operations.diff(ctx, options);
465
+ return { kind: "completed" };
466
+ }
467
+ case "doctor": {
468
+ const operations = await loaders.operations();
469
+ throwIfAborted(options.signal);
470
+ await operations.doctor(ctx, options);
471
+ return { kind: "completed" };
472
+ }
473
+ case "push": {
474
+ const operations = await loaders.operations();
475
+ throwIfAborted(options.signal);
476
+ const outcome = await withLock("push", () => operations.push(ctx, options));
477
+ return { kind: "completed", ...(outcome ? { outcome } : {}) };
478
+ }
479
+ case "pull": {
480
+ const operations = await loaders.operations();
481
+ throwIfAborted(options.signal);
482
+ const outcome = await withLock("pull", () => operations.pull(ctx, options));
483
+ return { kind: "completed", ...(outcome ? { outcome } : {}) };
484
+ }
485
+ case "sync": {
486
+ const operations = await loaders.operations();
487
+ throwIfAborted(options.signal);
488
+ await withLock("sync", () => operations.syncBoth(ctx, options));
489
+ return { kind: "completed" };
490
+ }
491
+ case "history": {
492
+ const operations = await loaders.operations();
493
+ throwIfAborted(options.signal);
494
+ await operations.history(ctx, options);
495
+ return { kind: "completed" };
496
+ }
497
+ case "rollback": {
498
+ const operations = await loaders.operations();
499
+ throwIfAborted(options.signal);
500
+ await withLock("rollback", () => operations.rollback(ctx, options));
501
+ return { kind: "completed" };
502
+ }
503
+ case "migrate-state":
504
+ await migrateStateDirectory(ctx, options);
505
+ return { kind: "completed" };
506
+ case "unlock":
507
+ await unlock(ctx, options);
508
+ return { kind: "completed" };
509
+ default:
510
+ ctx.ui.notify(`Unknown /sync command: ${subcommand}\n\n${usage()}`, "warning");
511
+ return { kind: "failed" };
512
+ }
513
+ } catch (error) {
514
+ if (signal?.aborted) return { kind: "failed" };
515
+ ctx.ui.setStatus(STATUS_KEY, undefined);
516
+ if (error instanceof SetupPullRequiresUiError) throw error;
517
+ if (error instanceof RemoteSelectionMismatchError) {
518
+ return { kind: "remote-selection-required", decision: error.decision };
519
+ }
520
+ if (isSyncDecisionRequiredError(error)) {
521
+ return { kind: "decision-required", decision: error.decision };
522
+ }
523
+ ctx.ui.notify(errorMessage(error), "error");
524
+ return { kind: "failed" };
525
+ }
526
+ }
527
+
528
+ async function migrateStateDirectory(ctx: ExtensionCommandContext, options: CommandOptions) {
529
+ const notice = stateDirectoryMigrationNotice();
530
+ if (!notice) {
531
+ ctx.ui.notify("pi-sync already uses the canonical pi-sync/ state directory.", "info");
532
+ return;
533
+ }
534
+ if (
535
+ !options.yes &&
536
+ !(await ctx.ui.confirm(
537
+ "Migrate pi-sync state directory",
538
+ "Confirm that every other Pi process is closed. pi-sync will atomically rename .pisync/ to pi-sync/ without merging or deleting either root.",
539
+ { signal: options.signal },
540
+ ))
541
+ ) {
542
+ ctx.ui.notify("pi-sync state migration cancelled.", "info");
543
+ return;
544
+ }
545
+ throwIfAborted(options.signal);
546
+ const result = await migrateLegacyStateDirectory();
547
+ throwIfAborted(options.signal);
548
+ if (result.status === "ready") {
549
+ ctx.ui.notify("pi-sync already uses the canonical pi-sync/ state directory.", "info");
550
+ return;
551
+ }
552
+ ctx.ui.notify(result.message, result.status === "migrated" ? "info" : "warning");
553
+ }
554
+
555
+ async function autoSync(
556
+ ctx: ExtensionContext,
557
+ signal: AbortSignal,
558
+ loaders: SyncLoaders,
559
+ ): Promise<RemoteSelectionDecision | undefined> {
560
+ try {
561
+ const partial = await loadPartialConfig();
562
+ throwIfAborted(signal);
563
+ if (!partial.automatic) return;
564
+ await ensureStateDir();
565
+ throwIfAborted(signal);
566
+ await loadConfig();
567
+ throwIfAborted(signal);
568
+ const operations = await loaders.operations();
569
+ throwIfAborted(signal);
570
+ await withLock("auto-sync", () => {
571
+ throwIfAborted(signal);
572
+ return operations.syncBoth(ctx, { ...AUTO_SYNC_OPTIONS, signal });
573
+ });
574
+ } catch (error) {
575
+ if (signal.aborted || isMissingConfigError(error)) return;
576
+ ctx.ui.setStatus(STATUS_KEY, undefined);
577
+ if (error instanceof RemoteSelectionMismatchError) return error.decision;
578
+ ctx.ui.notify(`pi-sync auto sync skipped: ${errorMessage(error)}`, "warning");
579
+ }
580
+ }
581
+
582
+ async function autoPushSessions(ctx: ExtensionContext, signal: AbortSignal, loaders: SyncLoaders) {
583
+ try {
584
+ const partial = await loadPartialConfig();
585
+ throwIfAborted(signal);
586
+ if (!partial.automatic) return;
587
+ if (!partial.include.includes("sessions")) return;
588
+ await ensureStateDir();
589
+ throwIfAborted(signal);
590
+ const config = await loadConfig();
591
+ throwIfAborted(signal);
592
+ if (!config.include.includes("sessions")) return;
593
+ const [operations, snapshotModule, syncStateModule] = await Promise.all([
594
+ loaders.operations(),
595
+ loaders.snapshot(),
596
+ loaders.syncState(),
597
+ ]);
598
+ throwIfAborted(signal);
599
+ await withLock("auto-session-push", async () => {
600
+ throwIfAborted(signal);
601
+ const state = await readStateForConfig(config);
602
+ throwIfAborted(signal);
603
+ const local = await snapshotModule.createSnapshot(
604
+ config.snapshotIdentity,
605
+ snapshotOptionsForContext(ctx, config),
606
+ );
607
+ throwIfAborted(signal);
608
+ if (!syncStateModule.hasLocalChanges(local, state, config)) return;
609
+ await operations.push(ctx, { ...AUTO_SYNC_OPTIONS, signal }, { config, state, local });
610
+ });
611
+ } catch (error) {
612
+ if (signal.aborted || isMissingConfigError(error)) return;
613
+ ctx.ui.setStatus(STATUS_KEY, undefined);
614
+ ctx.ui.notify(`pi-sync session push skipped: ${errorMessage(error)}`, "warning");
615
+ }
616
+ }
617
+
618
+ async function initConfig(ctx: ExtensionCommandContext, signal?: AbortSignal) {
619
+ const configPath = localConfigPath();
620
+ if (await readLocalConfigObject()) {
621
+ ctx.ui.notify(`Config already exists: ${await activeLocalConfigPath()}`, "info");
622
+ return;
623
+ }
624
+
625
+ if (ctx.mode === "tui") {
626
+ const { showSetupWizard } = await import("./manager-ui.js");
627
+ throwIfAborted(signal);
628
+ await showSetupWizard(ctx, signal);
629
+ return;
630
+ }
631
+ await createLocalConfigDocument(localConfigTemplate());
632
+ ctx.ui.notify(
633
+ `Created ${configPath}. Add a storage connection and sync setup before syncing.`,
634
+ "info",
635
+ );
636
+ }
637
+
638
+ async function showConfig(ctx: ExtensionCommandContext, options: CommandOptions) {
639
+ const config = await loadConfig(options.setup);
640
+ const warnings = [
641
+ ...(config.backend.type === "s3" ? sessionTokenWarnings(config.backend.profile) : []),
642
+ ...syncSessionsWarnings(config),
643
+ ];
644
+ const storageLines = configStorageLines(config);
645
+ ctx.ui.notify(
646
+ [
647
+ "pi-sync config:",
648
+ `sync setup: ${config.setupName}`,
649
+ `storage connection: ${config.connectionName}`,
650
+ ...storageLines,
651
+ `storage path: ${config.storagePath}`,
652
+ `automatic sync: ${config.automatic ? "enabled" : "disabled"}`,
653
+ `included content: ${config.include.join(", ") || "none"}`,
654
+ `sessions: ${config.include.includes("sessions") ? "included" : "not included"}`,
655
+ `settings file: ${localConfigPath()}`,
656
+ ...warnings,
657
+ ].join("\n"),
658
+ warnings.length > 0 ? "warning" : "info",
659
+ );
660
+ }
661
+
662
+ function configStorageLines(config: AnySyncConfig) {
663
+ switch (config.backend.type) {
664
+ case "git":
665
+ return [
666
+ "kind: git",
667
+ `remote: ${displayGitRemote(config.backend.profile.remote)}`,
668
+ "authentication: existing Git/SSH credentials (not stored)",
669
+ `branch: ${config.backend.destination.branch}`,
670
+ ];
671
+ case "webdav":
672
+ return [
673
+ "kind: webdav",
674
+ `url: ${displayWebDavUrl(config.backend.profile.url, config.backend.profile.username)}`,
675
+ "username: configured (value hidden)",
676
+ "password: configured",
677
+ ];
678
+ case "s3":
679
+ return [
680
+ "kind: s3",
681
+ `endpoint: ${config.backend.profile.endpoint}`,
682
+ `bucket: ${config.backend.destination.bucket}`,
683
+ `region: ${config.backend.profile.region}`,
684
+ "access key id: configured",
685
+ "secret access key: configured",
686
+ `session token: ${config.backend.profile.sessionToken ? "configured" : "not configured"}`,
687
+ ];
688
+ }
689
+ }
690
+
691
+ function displayGitRemote(value: string | undefined) {
692
+ if (!value) return "missing";
693
+ try {
694
+ const url = new URL(value);
695
+ url.username = "";
696
+ url.password = "";
697
+ url.search = "";
698
+ url.hash = "";
699
+ return url.toString();
700
+ } catch {
701
+ return value.replace(/^(?:[^@\s]+@)?(?<host>[^:]+):.+$/u, "$<host>:…");
702
+ }
703
+ }
704
+
705
+ function displayWebDavUrl(value: string | undefined, username: string | undefined) {
706
+ if (!value) return "missing";
707
+ try {
708
+ const url = new URL(value);
709
+ url.username = "";
710
+ url.password = "";
711
+ url.search = "";
712
+ url.hash = "";
713
+ return username ? `${url.origin}/…` : `${url.origin}${url.pathname}`;
714
+ } catch {
715
+ return "invalid (value hidden)";
716
+ }
717
+ }
718
+
719
+ function throwIfAborted(signal?: AbortSignal) {
720
+ if (!signal?.aborted) return;
721
+ throw signal.reason instanceof Error
722
+ ? signal.reason
723
+ : new DOMException("The operation was aborted", "AbortError");
724
+ }
725
+
726
+ function combineSignals(primary: AbortSignal, secondary?: AbortSignal) {
727
+ return secondary ? AbortSignal.any([primary, secondary]) : primary;
728
+ }
729
+
730
+ function snapshotOptionsForContext(
731
+ ctx: ExtensionCommandContext | ExtensionContext,
732
+ config: AnySyncConfig,
733
+ ): SnapshotOptions {
734
+ return {
735
+ include: config.include,
736
+ sessionDir: sessionDirFromContext(ctx),
737
+ };
738
+ }
739
+
740
+ function sessionDirFromContext(ctx: ExtensionCommandContext | ExtensionContext) {
741
+ const manager = ctx.sessionManager as typeof ctx.sessionManager & {
742
+ usesDefaultSessionDir?: () => boolean;
743
+ };
744
+ const usesDefaultSessionDir = manager.usesDefaultSessionDir;
745
+ if (typeof usesDefaultSessionDir === "function" && usesDefaultSessionDir.call(manager)) {
746
+ return undefined;
747
+ }
748
+ const getSessionDir = manager.getSessionDir;
749
+ return typeof getSessionDir === "function"
750
+ ? (getSessionDir.call(manager) as string | undefined)
751
+ : undefined;
752
+ }
753
+
754
+ function cachedModuleLoader<Module>(load: () => Promise<Module>): () => Promise<Module> {
755
+ let pending: Promise<Module> | undefined;
756
+ return () => {
757
+ if (!pending) {
758
+ pending = load().catch((error) => {
759
+ pending = undefined;
760
+ throw error;
761
+ });
762
+ }
763
+ return pending;
764
+ };
765
+ }