@narumitw/pi-sync 0.49.4 → 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.
@@ -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
- }