@narumitw/pi-sync 0.49.15 → 0.50.0

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.
Files changed (209) hide show
  1. package/README.md +49 -16
  2. package/dist/chunks/chunk-2B2ELUF5.ts +29 -0
  3. package/dist/chunks/chunk-2B2ELUF5.ts.map +7 -0
  4. package/dist/chunks/chunk-6LP3MCR2.ts +115 -0
  5. package/dist/chunks/chunk-6LP3MCR2.ts.map +7 -0
  6. package/dist/chunks/chunk-6UHKGALE.ts +134 -0
  7. package/dist/chunks/chunk-6UHKGALE.ts.map +7 -0
  8. package/dist/chunks/{chunk-JF6MZCT4.ts → chunk-AUSDLD6F.ts} +4 -4
  9. package/dist/chunks/chunk-AUSDLD6F.ts.map +7 -0
  10. package/dist/chunks/{chunk-X7SQ2EQM.ts → chunk-AXXS2BPA.ts} +9 -9
  11. package/dist/chunks/chunk-AXXS2BPA.ts.map +7 -0
  12. package/dist/chunks/{chunk-5T442OP3.ts → chunk-C7XY5NF5.ts} +210 -373
  13. package/dist/chunks/chunk-C7XY5NF5.ts.map +7 -0
  14. package/dist/chunks/{chunk-4U6AFLJB.ts → chunk-CHH26D4F.ts} +3 -2
  15. package/dist/chunks/chunk-CHH26D4F.ts.map +7 -0
  16. package/dist/chunks/chunk-CPEHGXJV.ts +50 -0
  17. package/dist/chunks/chunk-CPEHGXJV.ts.map +7 -0
  18. package/dist/chunks/{chunk-5MQ76BK5.ts → chunk-D5X27LCA.ts} +2 -2
  19. package/dist/chunks/chunk-D5X27LCA.ts.map +7 -0
  20. package/dist/chunks/{chunk-WSOBYHIZ.ts → chunk-GWMHAQG4.ts} +4 -4
  21. package/dist/chunks/chunk-GWMHAQG4.ts.map +7 -0
  22. package/dist/chunks/{chunk-5LZALPCD.ts → chunk-HUDQATCL.ts} +25 -25
  23. package/dist/chunks/chunk-HUDQATCL.ts.map +7 -0
  24. package/dist/chunks/{chunk-YQ6UW7IF.ts → chunk-JAAKLL6M.ts} +2 -2
  25. package/dist/chunks/chunk-JAAKLL6M.ts.map +7 -0
  26. package/dist/chunks/chunk-JSYMGJQQ.ts +51 -0
  27. package/dist/chunks/chunk-JSYMGJQQ.ts.map +7 -0
  28. package/dist/chunks/chunk-L7TBO32M.ts +25 -0
  29. package/dist/chunks/chunk-L7TBO32M.ts.map +7 -0
  30. package/dist/chunks/{chunk-DFXBKHGN.ts → chunk-LCRI3ABW.ts} +21 -8
  31. package/dist/chunks/chunk-LCRI3ABW.ts.map +7 -0
  32. package/dist/chunks/chunk-LTBWGSBV.ts +12 -0
  33. package/dist/chunks/chunk-LTBWGSBV.ts.map +7 -0
  34. package/dist/chunks/chunk-NJFUIVYL.ts +17 -0
  35. package/dist/chunks/chunk-NJFUIVYL.ts.map +7 -0
  36. package/dist/chunks/{chunk-HVUSHMVL.ts → chunk-OEX4ABY6.ts} +39 -37
  37. package/dist/chunks/chunk-OEX4ABY6.ts.map +7 -0
  38. package/dist/chunks/chunk-OUHGBFYF.ts +1255 -0
  39. package/dist/chunks/chunk-OUHGBFYF.ts.map +7 -0
  40. package/dist/chunks/{chunk-HGV24P4T.ts → chunk-Q2TUM5X6.ts} +3 -3
  41. package/dist/chunks/chunk-Q2TUM5X6.ts.map +7 -0
  42. package/dist/chunks/{chunk-GLQLN5GV.ts → chunk-SL64RLXR.ts} +8 -6
  43. package/dist/chunks/chunk-SL64RLXR.ts.map +7 -0
  44. package/dist/chunks/chunk-T3HH3UEJ.ts +96 -0
  45. package/dist/chunks/chunk-T3HH3UEJ.ts.map +7 -0
  46. package/dist/chunks/chunk-TEN2ZBJV.ts +101 -0
  47. package/dist/chunks/chunk-TEN2ZBJV.ts.map +7 -0
  48. package/dist/chunks/chunk-THMF4BVL.ts +18 -0
  49. package/dist/chunks/chunk-THMF4BVL.ts.map +7 -0
  50. package/dist/chunks/{chunk-44LPGQTI.ts → chunk-X5OM6GXT.ts} +9 -7
  51. package/dist/chunks/chunk-X5OM6GXT.ts.map +7 -0
  52. package/dist/chunks/chunk-XBX4HBLG.ts +57 -0
  53. package/dist/chunks/chunk-XBX4HBLG.ts.map +7 -0
  54. package/dist/chunks/{chunk-ARN2672F.ts → chunk-YKJHPTGO.ts} +23 -55
  55. package/dist/chunks/chunk-YKJHPTGO.ts.map +7 -0
  56. package/dist/chunks/chunk-Z4DQF6L3.ts +12 -0
  57. package/dist/chunks/chunk-Z4DQF6L3.ts.map +7 -0
  58. package/dist/chunks/{chunk-F5QL2GPY.ts → chunk-ZI5MXMEX.ts} +5 -99
  59. package/dist/chunks/chunk-ZI5MXMEX.ts.map +7 -0
  60. package/dist/chunks/{file-selection-RKEHALNQ.ts → file-selection-FTTKAOYX.ts} +10 -7
  61. package/dist/chunks/file-selection-FTTKAOYX.ts.map +7 -0
  62. package/dist/chunks/{git-backend-E3UOUUOM.ts → git-backend-OQW46G2X.ts} +42 -17
  63. package/dist/chunks/git-backend-OQW46G2X.ts.map +7 -0
  64. package/dist/chunks/manager-result-dispatcher-U23ODD32.ts +22 -0
  65. package/dist/chunks/manager-ui-ABATHC36.ts +1680 -0
  66. package/dist/chunks/manager-ui-ABATHC36.ts.map +7 -0
  67. package/dist/chunks/remote-selection-ui-ZE3TY67S.ts +21 -0
  68. package/dist/chunks/{s3-backend-552LXHE4.ts → s3-backend-3P33LQHU.ts} +96 -18
  69. package/dist/chunks/s3-backend-3P33LQHU.ts.map +7 -0
  70. package/dist/chunks/{setup-switch-5YDG5YGJ.ts → setup-switch-IS2PEZ3J.ts} +9 -9
  71. package/dist/chunks/setup-wizard-VB3VDOV5.ts +89 -0
  72. package/dist/chunks/setup-wizard-VB3VDOV5.ts.map +7 -0
  73. package/dist/chunks/{snapshot-2RYBUDRA.ts → snapshot-HKEWCVK6.ts} +7 -6
  74. package/dist/chunks/sync-inspection-FEP4L5TR.ts +20 -0
  75. package/dist/chunks/{sync-operations-3LLIHWZO.ts → sync-operations-ONJ27QRL.ts} +374 -294
  76. package/dist/chunks/sync-operations-ONJ27QRL.ts.map +7 -0
  77. package/dist/chunks/{sync-state-SRN6YRBK.ts → sync-state-Q2FTXTW6.ts} +6 -6
  78. package/dist/chunks/sync-state-Q2FTXTW6.ts.map +7 -0
  79. package/dist/chunks/{webdav-backend-ZD5RHRIU.ts → webdav-backend-ECIKD7SO.ts} +26 -13
  80. package/dist/chunks/webdav-backend-ECIKD7SO.ts.map +7 -0
  81. package/dist/index.ts +477 -342
  82. package/dist/index.ts.map +4 -4
  83. package/docs/settings.md +30 -6
  84. package/package.json +1 -1
  85. package/src/{backend-factory.ts → backends/backend-factory.ts} +4 -4
  86. package/src/backends/backend-identity.ts +40 -0
  87. package/src/backends/backend-types.ts +81 -0
  88. package/src/{git-backend.ts → backends/git/git-backend.ts} +43 -19
  89. package/src/{git-config.ts → backends/git/git-config.ts} +1 -0
  90. package/src/{git-storage.ts → backends/git/git-storage.ts} +2 -2
  91. package/src/{s3-backend.ts → backends/s3/s3-backend.ts} +45 -9
  92. package/src/{s3-client.ts → backends/s3/s3-client.ts} +56 -8
  93. package/src/{sync-backend.ts → backends/sync-backend.ts} +1 -1
  94. package/src/{webdav-backend.ts → backends/webdav/webdav-backend.ts} +22 -10
  95. package/src/{webdav-client.ts → backends/webdav/webdav-client.ts} +1 -1
  96. package/src/{webdav-config.ts → backends/webdav/webdav-config.ts} +1 -0
  97. package/src/commands/command-execution.ts +330 -0
  98. package/src/commands/command-handler.ts +201 -0
  99. package/src/commands/command-types.ts +18 -0
  100. package/src/{command.ts → commands/command.ts} +2 -1
  101. package/src/commands/setup-completions.ts +6 -0
  102. package/src/settings/config.ts +238 -0
  103. package/src/{settings-management.ts → settings/settings-management.ts} +39 -14
  104. package/src/settings/settings-store.ts +109 -0
  105. package/src/settings/settings-types.ts +128 -0
  106. package/src/settings/settings-validation.ts +443 -0
  107. package/src/snapshot/session-paths.ts +84 -0
  108. package/src/{snapshot-apply.ts → snapshot/snapshot-apply.ts} +3 -3
  109. package/src/{snapshot-codec.ts → snapshot/snapshot-codec.ts} +2 -2
  110. package/src/{snapshot-transaction.ts → snapshot/snapshot-transaction.ts} +5 -4
  111. package/src/snapshot/snapshot-types.ts +38 -0
  112. package/src/{snapshot.ts → snapshot/snapshot.ts} +39 -20
  113. package/src/state/json-file.ts +20 -0
  114. package/src/{lock.ts → state/lock.ts} +3 -2
  115. package/src/{operation-availability.ts → state/operation-availability.ts} +2 -2
  116. package/src/state/state-types.ts +20 -0
  117. package/src/state/sync-state-store.ts +62 -0
  118. package/src/sync/automatic-sync.ts +103 -0
  119. package/src/{remote-snapshot.ts → sync/remote-snapshot.ts} +27 -4
  120. package/src/{setup-switch.ts → sync/setup-switch.ts} +6 -7
  121. package/src/sync/signals.ts +10 -0
  122. package/src/sync/startup-check.ts +128 -0
  123. package/src/{sync-decision.ts → sync/sync-decision.ts} +6 -3
  124. package/src/sync/sync-error-guidance.ts +53 -0
  125. package/src/sync/sync-inspection.ts +51 -0
  126. package/src/sync/sync-loaders.ts +54 -0
  127. package/src/{sync-operations.ts → sync/sync-mutations.ts} +34 -325
  128. package/src/sync/sync-operations.ts +12 -0
  129. package/src/{sync-policy.ts → sync/sync-policy.ts} +2 -2
  130. package/src/sync/sync-queries.ts +235 -0
  131. package/src/{sync-state.ts → sync/sync-state.ts} +5 -4
  132. package/src/sync-extension.ts +64 -708
  133. package/src/sync.ts +8 -8
  134. package/src/types.ts +47 -286
  135. package/src/ui/automatic-sync-summary.ts +6 -0
  136. package/src/{cancellable-operation.ts → ui/cancellable-operation.ts} +4 -4
  137. package/src/{file-selection.ts → ui/file-selection.ts} +5 -3
  138. package/src/{manager-attention.ts → ui/manager-attention.ts} +5 -1
  139. package/src/{manager-recovery.ts → ui/manager-recovery.ts} +6 -2
  140. package/src/{manager-state.ts → ui/manager-state.ts} +35 -14
  141. package/src/ui/manager-ui.ts +386 -0
  142. package/src/{remote-selection-ui.ts → ui/remote-selection-ui.ts} +21 -13
  143. package/src/{secret-input.ts → ui/secret-input.ts} +22 -4
  144. package/src/{settings-ui.ts → ui/settings-ui.ts} +17 -13
  145. package/src/ui/setup/git-ui.ts +293 -0
  146. package/src/{s3-credentials-ui.ts → ui/setup/s3-credentials-ui.ts} +13 -21
  147. package/src/ui/setup/s3-ui.ts +217 -0
  148. package/src/ui/setup/setup-actions.ts +127 -0
  149. package/src/ui/setup/setup-location-ui.ts +167 -0
  150. package/src/ui/setup/setup-name-ui.ts +34 -0
  151. package/src/ui/setup/setup-prompts.ts +92 -0
  152. package/src/ui/setup/setup-review.ts +70 -0
  153. package/src/ui/setup/setup-switcher.ts +181 -0
  154. package/src/ui/setup/setup-wizard.ts +39 -0
  155. package/src/ui/setup/text-input.ts +78 -0
  156. package/src/ui/setup/webdav-ui.ts +330 -0
  157. package/src/{storage-connections-ui.ts → ui/storage-connections-ui.ts} +99 -69
  158. package/src/{sync-attention.ts → ui/sync-attention.ts} +75 -6
  159. package/src/{sync-format.ts → ui/sync-format.ts} +7 -13
  160. package/src/{sync-resolution-ui.ts → ui/sync-resolution-ui.ts} +4 -3
  161. package/src/{sync-setups-ui.ts → ui/sync-setups-ui.ts} +23 -10
  162. package/src/ui/terminal-text.ts +4 -0
  163. package/dist/chunks/chunk-44LPGQTI.ts.map +0 -7
  164. package/dist/chunks/chunk-4U6AFLJB.ts.map +0 -7
  165. package/dist/chunks/chunk-5LZALPCD.ts.map +0 -7
  166. package/dist/chunks/chunk-5MQ76BK5.ts.map +0 -7
  167. package/dist/chunks/chunk-5T442OP3.ts.map +0 -7
  168. package/dist/chunks/chunk-ARN2672F.ts.map +0 -7
  169. package/dist/chunks/chunk-C7LXZ3UH.ts +0 -167
  170. package/dist/chunks/chunk-C7LXZ3UH.ts.map +0 -7
  171. package/dist/chunks/chunk-DFXBKHGN.ts.map +0 -7
  172. package/dist/chunks/chunk-E4COSV32.ts +0 -82
  173. package/dist/chunks/chunk-E4COSV32.ts.map +0 -7
  174. package/dist/chunks/chunk-F5QL2GPY.ts.map +0 -7
  175. package/dist/chunks/chunk-GLQLN5GV.ts.map +0 -7
  176. package/dist/chunks/chunk-HGV24P4T.ts.map +0 -7
  177. package/dist/chunks/chunk-HVUSHMVL.ts.map +0 -7
  178. package/dist/chunks/chunk-JF6MZCT4.ts.map +0 -7
  179. package/dist/chunks/chunk-WSOBYHIZ.ts.map +0 -7
  180. package/dist/chunks/chunk-X7SQ2EQM.ts.map +0 -7
  181. package/dist/chunks/chunk-YQ6UW7IF.ts.map +0 -7
  182. package/dist/chunks/file-selection-RKEHALNQ.ts.map +0 -7
  183. package/dist/chunks/git-backend-E3UOUUOM.ts.map +0 -7
  184. package/dist/chunks/manager-result-dispatcher-LWPYJAD5.ts +0 -18
  185. package/dist/chunks/manager-ui-NF4RUHBY.ts +0 -2785
  186. package/dist/chunks/manager-ui-NF4RUHBY.ts.map +0 -7
  187. package/dist/chunks/remote-selection-ui-NUF5P4ZJ.ts +0 -17
  188. package/dist/chunks/s3-backend-552LXHE4.ts.map +0 -7
  189. package/dist/chunks/sync-operations-3LLIHWZO.ts.map +0 -7
  190. package/dist/chunks/webdav-backend-ZD5RHRIU.ts.map +0 -7
  191. package/src/config.ts +0 -923
  192. package/src/git-ui.ts +0 -307
  193. package/src/manager-helpers.ts +0 -64
  194. package/src/manager-ui.ts +0 -997
  195. package/src/webdav-ui.ts +0 -421
  196. /package/dist/chunks/{manager-result-dispatcher-LWPYJAD5.ts.map → manager-result-dispatcher-U23ODD32.ts.map} +0 -0
  197. /package/dist/chunks/{remote-selection-ui-NUF5P4ZJ.ts.map → remote-selection-ui-ZE3TY67S.ts.map} +0 -0
  198. /package/dist/chunks/{setup-switch-5YDG5YGJ.ts.map → setup-switch-IS2PEZ3J.ts.map} +0 -0
  199. /package/dist/chunks/{snapshot-2RYBUDRA.ts.map → snapshot-HKEWCVK6.ts.map} +0 -0
  200. /package/dist/chunks/{sync-state-SRN6YRBK.ts.map → sync-inspection-FEP4L5TR.ts.map} +0 -0
  201. /package/src/{git-runner.ts → backends/git/git-runner.ts} +0 -0
  202. /package/src/{config-errors.ts → settings/config-errors.ts} +0 -0
  203. /package/src/{config-file.ts → settings/config-file.ts} +0 -0
  204. /package/src/{snapshot-paths.ts → snapshot/snapshot-paths.ts} +0 -0
  205. /package/src/{lock-policy.ts → state/lock-policy.ts} +0 -0
  206. /package/src/{lockfile-fs.ts → state/lockfile-fs.ts} +0 -0
  207. /package/src/{state-directory.ts → state/state-directory.ts} +0 -0
  208. /package/src/{sync-errors.ts → sync/sync-errors.ts} +0 -0
  209. /package/src/{manager-result-dispatcher.ts → ui/manager-result-dispatcher.ts} +0 -0
@@ -0,0 +1,167 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { normalizeGitBranch } from "../../backends/git/git-config.js";
3
+ import { readLocalConfigObject } from "../../settings/settings-store.js";
4
+ import type {
5
+ StorageConnectionSettings,
6
+ SyncSetupSettings,
7
+ } from "../../settings/settings-types.js";
8
+ import {
9
+ effectiveSyncSetupRemoteIdentity,
10
+ normalizeStoragePath,
11
+ ownRecord,
12
+ } from "../../settings/settings-validation.js";
13
+ import { errorMessage } from "../../sync/sync-errors.js";
14
+ import { safeTerminalText } from "../terminal-text.js";
15
+ import {
16
+ promptTextInput,
17
+ requiredExistingBucket,
18
+ requiredInput,
19
+ requiredValueInput,
20
+ } from "./text-input.js";
21
+
22
+ interface ChosenRemoteLocation {
23
+ connectionName: string;
24
+ bucket: string;
25
+ path: string;
26
+ }
27
+
28
+ export async function chooseInitialRemoteLocation(
29
+ ctx: ExtensionCommandContext,
30
+ _preset: string,
31
+ setupName: string,
32
+ signal?: AbortSignal,
33
+ ): Promise<ChosenRemoteLocation | undefined> {
34
+ const choice = await ctx.ui.select(
35
+ "Choose storage location\n\nUse an existing bucket. ./ stores snapshots at the bucket root; a custom path keeps them in a separate folder.",
36
+ ["Use an existing bucket at ./", "Customize remote location", "Cancel"],
37
+ { signal },
38
+ );
39
+ if (signal?.aborted || !choice || choice === "Cancel") return undefined;
40
+ if (choice === "Customize remote location")
41
+ return chooseCustomRemoteLocation(ctx, setupName, signal);
42
+ const bucket = await requiredExistingBucket(ctx, "pi-sync", signal);
43
+ return bucket ? { connectionName: setupName, bucket, path: "./" } : undefined;
44
+ }
45
+
46
+ export async function chooseAdditionalRemoteLocation(
47
+ ctx: ExtensionCommandContext,
48
+ settings: Record<string, unknown>,
49
+ connectionName: string,
50
+ setupName: string,
51
+ signal?: AbortSignal,
52
+ ): Promise<Omit<ChosenRemoteLocation, "connectionName"> | undefined> {
53
+ const setups = ownRecord(settings.syncSetups) ?? {};
54
+ const currentSetup =
55
+ typeof settings.activeSyncSetup === "string" ? settings.activeSyncSetup : undefined;
56
+ const candidates = Object.entries(setups)
57
+ .map(([name, value]) => ({ name, storage: ownRecord(ownRecord(value)?.storage) }))
58
+ .filter(
59
+ (item): item is { name: string; storage: Record<string, unknown> } =>
60
+ item.storage?.connection === connectionName && typeof item.storage.bucket === "string",
61
+ );
62
+ const source =
63
+ candidates.find((item) => item.name === currentSetup) ??
64
+ candidates.sort((left, right) => left.name.localeCompare(right.name))[0];
65
+ if (source) {
66
+ const suggestedPath = "./";
67
+ const sameBucketLabel = `Same bucket as “${safeTerminalText(source.name)}”`;
68
+ const choice = await ctx.ui.select(
69
+ [
70
+ `Storage location for “${safeTerminalText(setupName)}”`,
71
+ "",
72
+ `Existing bucket: ${safeTerminalText(String(source.storage.bucket))}`,
73
+ `Remote path: ${safeTerminalText(suggestedPath)}`,
74
+ "./ uses the bucket root. Use a different path or bucket for independent setups.",
75
+ ].join("\n"),
76
+ [sameBucketLabel, "Use a different bucket", "Customize remote location", "Cancel"],
77
+ { signal },
78
+ );
79
+ if (signal?.aborted || !choice || choice === "Cancel") return undefined;
80
+ if (choice === sameBucketLabel) {
81
+ return { bucket: String(source.storage.bucket), path: suggestedPath };
82
+ }
83
+ if (choice === "Use a different bucket") {
84
+ const bucket = await requiredExistingBucket(ctx, "pi-sync", signal);
85
+ return bucket ? { bucket, path: "./" } : undefined;
86
+ }
87
+ const custom = await chooseCustomRemoteLocation(ctx, connectionName, signal);
88
+ return custom ? { bucket: custom.bucket, path: custom.path } : undefined;
89
+ }
90
+
91
+ const location = await chooseInitialRemoteLocation(ctx, "S3", setupName, signal);
92
+ return location ? { bucket: location.bucket, path: location.path } : undefined;
93
+ }
94
+
95
+ async function chooseCustomRemoteLocation(
96
+ ctx: ExtensionCommandContext,
97
+ connectionName: string,
98
+ signal?: AbortSignal,
99
+ ): Promise<ChosenRemoteLocation | undefined> {
100
+ const bucket = await requiredExistingBucket(ctx, "pi-sync", signal);
101
+ if (!bucket) return undefined;
102
+ const storagePath = await requiredInput(
103
+ ctx,
104
+ "Storage path\n\nPath inside the bucket, not your local filesystem.\n./ uses the bucket root. Use different paths for independent setups.",
105
+ "./",
106
+ signal,
107
+ normalizeStoragePath,
108
+ );
109
+ if (!storagePath) return undefined;
110
+ return { connectionName, bucket, path: normalizeStoragePath(storagePath) };
111
+ }
112
+
113
+ /** Early UI check only; the settings writer still validates uniqueness under its lock. */
114
+ export async function promptAvailableSetupStorage<T extends SyncSetupSettings["storage"]>(
115
+ ctx: ExtensionCommandContext,
116
+ initial: T,
117
+ signal?: AbortSignal,
118
+ ): Promise<T | undefined> {
119
+ let storage = initial;
120
+ while (!signal?.aborted) {
121
+ const settings = await readLocalConfigObject();
122
+ if (signal?.aborted) return undefined;
123
+ const connection = settings?.storageConnections[storage.connection];
124
+ if (!settings || !connection) throw new Error("Storage connection changed; reopen setup.");
125
+ const identity = setupPublicationIdentity(storage, connection);
126
+ const occupied = Object.entries(settings.syncSetups).find(
127
+ ([, setup]) =>
128
+ setupPublicationIdentity(
129
+ setup.storage,
130
+ settings.storageConnections[setup.storage.connection],
131
+ ) === identity,
132
+ );
133
+ if (!occupied) return storage;
134
+ const git = connection.type === "git";
135
+ const message = `${git ? "Git branch" : "Storage location"} is already used by “${safeTerminalText(occupied[0])}”. ${git ? "Use a different branch; pi-sync owns the entire branch." : "Enter a different storage path, or cancel to choose another connection or bucket."}`;
136
+ ctx.ui.notify(message, "warning");
137
+ const title = `${git ? "Git branch for the new setup" : "Storage path for the new setup"}\n\n${message}`;
138
+ const example = git ? "pi-sync/work" : "backups/work";
139
+ const value =
140
+ connection.type === "webdav"
141
+ ? await promptTextInput(ctx, title, { example }, signal)
142
+ : await requiredValueInput(ctx, title, example, signal);
143
+ if (signal?.aborted || value === undefined) return undefined;
144
+ try {
145
+ storage = git
146
+ ? { ...storage, branch: normalizeGitBranch(value) }
147
+ : { ...storage, path: normalizeStoragePath(value) };
148
+ } catch (error) {
149
+ ctx.ui.notify(safeTerminalText(errorMessage(error)), "warning");
150
+ }
151
+ }
152
+ return undefined;
153
+ }
154
+
155
+ function setupPublicationIdentity(
156
+ storage: SyncSetupSettings["storage"],
157
+ connection: StorageConnectionSettings,
158
+ ) {
159
+ // Git publications own a complete branch tree; changing only its directory is not isolation.
160
+ return effectiveSyncSetupRemoteIdentity(
161
+ {
162
+ storage: connection.type === "git" ? { ...storage, path: "./" } : storage,
163
+ sync: { include: [], automatic: false },
164
+ },
165
+ connection,
166
+ );
167
+ }
@@ -0,0 +1,34 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { validateConfigName } from "../../settings/settings-validation.js";
3
+ import { errorMessage } from "../../sync/sync-errors.js";
4
+ import { safeTerminalText } from "../terminal-text.js";
5
+
6
+ export async function promptInitialSetupName(ctx: ExtensionCommandContext, signal?: AbortSignal) {
7
+ while (!signal?.aborted) {
8
+ const hint = "For example: home or work. Leave blank for default.";
9
+ // Pi styles the whole input title as accent; give only the guidance a muted role.
10
+ const guidance = ctx.mode === "tui" ? ctx.ui.theme.fg("muted", hint) : hint;
11
+ // This compact prompt owns its default hint; the general helper would repeat it.
12
+ const value = await ctx.ui.input(`Sync setup name\n${guidance}`, undefined, { signal });
13
+ if (signal?.aborted) {
14
+ throw signal.reason instanceof Error
15
+ ? signal.reason
16
+ : new DOMException("The operation was aborted", "AbortError");
17
+ }
18
+ if (value === undefined) return undefined;
19
+ const name = value.trim() || "default";
20
+ try {
21
+ if (name.includes("<") || name.includes(">")) {
22
+ throw new Error("Replace example placeholders with your own name.");
23
+ }
24
+ validateConfigName(name, "sync setup");
25
+ return name;
26
+ } catch (error) {
27
+ ctx.ui.notify(
28
+ `This name cannot be used for the sync setup. ${safeTerminalText(errorMessage(error))} Enter another name (for example, default).`,
29
+ "warning",
30
+ );
31
+ }
32
+ }
33
+ return undefined;
34
+ }
@@ -0,0 +1,92 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { readLocalConfigObject } from "../../settings/settings-store.js";
3
+ import { validateConfigName } from "../../settings/settings-validation.js";
4
+ import { DEFAULT_SYNC_INCLUDE } from "../../sync/sync-policy.js";
5
+ import { safeTerminalText } from "../terminal-text.js";
6
+ import { promptTextInput } from "./text-input.js";
7
+
8
+ export async function readSetupConnection(name: string, signal?: AbortSignal) {
9
+ const connection = (await readLocalConfigObject())?.storageConnections[name];
10
+ signal?.throwIfAborted();
11
+ if (!connection) throw new Error("Storage connection changed; reopen setup.");
12
+ return connection;
13
+ }
14
+
15
+ export const AUTOMATIC_SYNC_DESCRIPTION =
16
+ "Check the current setup in the background at startup; review changes in /sync before transferring. At shutdown, automatically push selected content only when sessions are included. No startup dialogs.";
17
+
18
+ export async function chooseSetupContent(ctx: ExtensionCommandContext, signal?: AbortSignal) {
19
+ const choice = await ctx.ui.select(
20
+ "Choose included content\n\nRecommended: Pi settings, instructions, skills, prompts, themes, and extensions.\nMinimal: settings.json and AGENTS.md.\nReview the exact paths before saving.",
21
+ ["Recommended Pi settings", "Minimal settings", "Cancel"],
22
+ { signal },
23
+ );
24
+ signal?.throwIfAborted();
25
+ if (!choice || choice === "Cancel") return undefined;
26
+ return choice === "Minimal settings" ? ["settings.json", "AGENTS.md"] : [...DEFAULT_SYNC_INCLUDE];
27
+ }
28
+
29
+ export async function chooseAutomaticSync(ctx: ExtensionCommandContext, signal?: AbortSignal) {
30
+ const choice = await ctx.ui.select(
31
+ `Automatic sync for this setup\n\n${AUTOMATIC_SYNC_DESCRIPTION}`,
32
+ ["Keep automatic sync off", "Enable automatic sync", "Cancel"],
33
+ { signal },
34
+ );
35
+ signal?.throwIfAborted();
36
+ return !choice || choice === "Cancel" ? undefined : choice === "Enable automatic sync";
37
+ }
38
+
39
+ export async function chooseSessions(ctx: ExtensionCommandContext, signal?: AbortSignal) {
40
+ const choice = await ctx.ui.select(
41
+ "Session conversations\n\nSessions can contain prompts, tool output, paths, screenshots, and secrets.",
42
+ ["Keep sessions off (recommended)", "Include session conversations", "Cancel"],
43
+ { signal },
44
+ );
45
+ signal?.throwIfAborted();
46
+ if (!choice || choice === "Cancel") return undefined;
47
+ if (choice !== "Include session conversations") return false;
48
+ const confirmed = await ctx.ui.confirm(
49
+ "Include session conversations?",
50
+ "I understand that session JSONL can contain prompts, tool output, paths, screenshots, and secrets.",
51
+ { signal },
52
+ );
53
+ signal?.throwIfAborted();
54
+ return confirmed ? true : undefined;
55
+ }
56
+
57
+ export function includedContentLines(include: readonly string[], sessions = false) {
58
+ const paths = [...include, ...(sessions && !include.includes("sessions") ? ["sessions"] : [])];
59
+ return [
60
+ `Included content: ${paths.length} paths`,
61
+ ...paths.map((item) => ` ${safeTerminalText(item)}`),
62
+ `Sessions: ${sessions ? "On — privacy warning acknowledged" : "Off"}`,
63
+ ];
64
+ }
65
+
66
+ export async function promptResourceName(
67
+ ctx: ExtensionCommandContext,
68
+ kind: "sync setup" | "storage connection",
69
+ defaultValue: string,
70
+ signal?: AbortSignal,
71
+ literalBrackets = false,
72
+ ) {
73
+ return promptTextInput(
74
+ ctx,
75
+ `Name this ${kind}\n\nA local label, such as home or work.`,
76
+ {
77
+ defaultValue,
78
+ rejectPlaceholders: !literalBrackets,
79
+ validate: async (name) => {
80
+ validateConfigName(name, kind);
81
+ const settings = await readLocalConfigObject();
82
+ signal?.throwIfAborted();
83
+ const names = kind === "sync setup" ? settings?.syncSetups : settings?.storageConnections;
84
+ if (names && Object.hasOwn(names, name)) {
85
+ throw new Error(`A ${kind} with this name already exists. Choose a different name.`);
86
+ }
87
+ return name;
88
+ },
89
+ },
90
+ signal,
91
+ );
92
+ }
@@ -0,0 +1,70 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
3
+ import { syncErrorGuidance } from "../../sync/sync-error-guidance.js";
4
+
5
+ /** A settings draft is committed only after review; retryable I/O failures retain the same draft. */
6
+ export async function saveReviewedDraft(
7
+ ctx: ExtensionCommandContext,
8
+ title: string,
9
+ lines: readonly string[],
10
+ label: string,
11
+ save: (signal: AbortSignal) => Promise<unknown>,
12
+ signal?: AbortSignal,
13
+ ) {
14
+ let saved = false;
15
+ let failure: unknown;
16
+ let failureText: string | undefined;
17
+ const menu = defineMenu<undefined, "review", "save", ExtensionCommandContext>({
18
+ start: "review",
19
+ screens: {
20
+ review: () => ({
21
+ kind: "review",
22
+ title,
23
+ content: [...(failureText ? [failureText, ""] : []), ...lines].join("\n"),
24
+ lines: ["Review before saving. Back cancels this draft."],
25
+ format: { kind: "text" },
26
+ viewportSize: "adaptive",
27
+ confirm: { id: "save", label, action: "save" },
28
+ hint: "back",
29
+ }),
30
+ },
31
+ actions: {
32
+ save: async ({ signal: actionSignal }) => {
33
+ try {
34
+ actionSignal.throwIfAborted();
35
+ await save(actionSignal);
36
+ saved = true;
37
+ return { kind: "close" };
38
+ } catch (error) {
39
+ if (actionSignal.aborted) return { kind: "close" };
40
+ // Do not silently rebase a stale review or overwrite an invalid settings file.
41
+ // The caller must reopen current settings for those failures.
42
+ if (!retryableSettingsError(error)) {
43
+ failure = error;
44
+ return { kind: "close" };
45
+ }
46
+ failureText = `Not saved. ${syncErrorGuidance(error)}\nYour draft is retained. Fix the problem, then save again, or cancel.`;
47
+ ctx.ui.notify(failureText, "error");
48
+ return { kind: "stay" };
49
+ }
50
+ },
51
+ },
52
+ });
53
+ const result = await runMenu(ctx, menu, {
54
+ getState: () => undefined,
55
+ signal,
56
+ isCurrent: () => !signal?.aborted,
57
+ });
58
+ if (signal?.aborted) return false;
59
+ if (failure) throw failure;
60
+ if (result.kind === "error") throw result.error;
61
+ return saved;
62
+ }
63
+
64
+ function retryableSettingsError(error: unknown) {
65
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
66
+ return (
67
+ code !== undefined &&
68
+ ["EACCES", "EPERM", "ENOSPC", "EBUSY", "EIO", "EMFILE", "ENFILE", "EROFS"].includes(code)
69
+ );
70
+ }
@@ -0,0 +1,181 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
3
+ import { loadConfig, syncConfigReviewIdentity } from "../../settings/config.js";
4
+ import { loadOnSwitch, readLocalConfigObject } from "../../settings/settings-store.js";
5
+ import type { AnySyncConfig } from "../../settings/settings-types.js";
6
+ import { ownRecord } from "../../settings/settings-validation.js";
7
+ import { useSyncSetup } from "../../sync/setup-switch.js";
8
+ import { errorMessage } from "../../sync/sync-errors.js";
9
+ import { automaticSyncSummary } from "../automatic-sync-summary.js";
10
+ import { type RunRoute, runCancellableOperation } from "../cancellable-operation.js";
11
+ import { dispatchManagerResult } from "../manager-result-dispatcher.js";
12
+ import { backendStorageDescription } from "../manager-state.js";
13
+ import { safeTerminalText } from "../terminal-text.js";
14
+
15
+ async function selectSetupForSwitch(
16
+ ctx: ExtensionCommandContext,
17
+ raw: Record<string, unknown>,
18
+ targets: Record<string, unknown>,
19
+ active: string | undefined,
20
+ signal?: AbortSignal,
21
+ ) {
22
+ let selectedName: string | undefined;
23
+ const nameById = new Map<string, string>();
24
+ const profiles = ownRecord(raw.storageConnections);
25
+ const menu = defineMenu<undefined, "setups", "select", ExtensionCommandContext>({
26
+ start: "setups",
27
+ screens: {
28
+ setups: () => ({
29
+ kind: "actions",
30
+ title: "Switch sync setup",
31
+ lines: [`Current sync setup: ${safeTerminalText(active ?? "none")}`],
32
+ items: Object.keys(targets)
33
+ .sort((left, right) => left.localeCompare(right))
34
+ .map((candidate, index) => {
35
+ const target = ownRecord(targets[candidate]);
36
+ const storage = ownRecord(target?.storage);
37
+ const profileName =
38
+ typeof storage?.connection === "string" ? storage.connection : undefined;
39
+ const profile = profileName && profiles ? ownRecord(profiles[profileName]) : undefined;
40
+ const location = profile
41
+ ? profile.type === "git"
42
+ ? `${String(storage?.branch ?? "missing branch")}:${String(storage?.path ?? "missing path")}`
43
+ : profile.type === "s3"
44
+ ? `${String(storage?.bucket ?? "missing bucket")}/${String(storage?.path ?? "missing path")}`
45
+ : String(storage?.path ?? "missing path")
46
+ : `invalid: missing connection ${profileName ?? "reference"}`;
47
+ const id = `setup:${index}`;
48
+ nameById.set(id, candidate);
49
+ return {
50
+ id,
51
+ label: `${safeTerminalText(candidate)}${candidate === active ? " (current)" : ""}`,
52
+ description: `${safeTerminalText(profileName ?? "unknown")} · ${safeTerminalText(location)}`,
53
+ action: "select" as const,
54
+ };
55
+ }),
56
+ hint: "close",
57
+ }),
58
+ },
59
+ actions: {
60
+ select: async ({ itemId }) => {
61
+ selectedName = nameById.get(itemId);
62
+ return { kind: "close" };
63
+ },
64
+ },
65
+ });
66
+ await runMenu(ctx, menu, {
67
+ getState: () => undefined,
68
+ signal,
69
+ isCurrent: () => !signal?.aborted,
70
+ });
71
+ return selectedName;
72
+ }
73
+
74
+ export async function showSetupSwitcher(
75
+ ctx: ExtensionCommandContext,
76
+ runRoute: RunRoute,
77
+ selectedName?: string,
78
+ signal?: AbortSignal,
79
+ ) {
80
+ const raw = await readLocalConfigObject();
81
+ if (signal?.aborted) return false;
82
+ if (raw?.version !== 3) {
83
+ ctx.ui.notify("Add a second sync setup before switching setups.", "info");
84
+ return false;
85
+ }
86
+ const targets = ownRecord(raw.syncSetups);
87
+ if (!targets) {
88
+ ctx.ui.notify("No sync setups are configured.", "warning");
89
+ return false;
90
+ }
91
+ const active = typeof raw.activeSyncSetup === "string" ? raw.activeSyncSetup : undefined;
92
+ let name = selectedName;
93
+ if (!name) {
94
+ name = await selectSetupForSwitch(ctx, raw, targets, active, signal);
95
+ if (!name) return false;
96
+ }
97
+ if (!name || !Object.hasOwn(targets, name)) {
98
+ ctx.ui.notify(
99
+ `Sync setup “${safeTerminalText(name ?? "unknown")}” no longer exists.`,
100
+ "warning",
101
+ );
102
+ return false;
103
+ }
104
+ if (name === active) {
105
+ ctx.ui.notify(`Sync setup “${safeTerminalText(name)}” is already current.`, "info");
106
+ return false;
107
+ }
108
+ let config: AnySyncConfig;
109
+ try {
110
+ config = await loadConfig(name);
111
+ if (signal?.aborted) return false;
112
+ } catch (error) {
113
+ ctx.ui.notify(
114
+ `Cannot use sync setup “${safeTerminalText(name)}”: ${safeTerminalText(errorMessage(error))}`,
115
+ "error",
116
+ );
117
+ return false;
118
+ }
119
+ const onSwitch = await loadOnSwitch();
120
+ if (signal?.aborted) return false;
121
+ const switchEffect =
122
+ onSwitch === "ask-before-pull"
123
+ ? "After switching, pi-sync will ask whether to review a pull for this setup."
124
+ : onSwitch === "pull-after-switch"
125
+ ? "After switching, pi-sync will check this setup and show exact changes before applying them."
126
+ : "After switching, pi-sync will not pull or modify synced files.";
127
+ const confirmed = await ctx.ui.confirm(
128
+ "Switch sync setup?",
129
+ [
130
+ `From: ${safeTerminalText(active ?? "none")}`,
131
+ `To: ${safeTerminalText(name)}`,
132
+ `Storage: ${backendStorageDescription(config)}`,
133
+ `Included content: ${config.include.length} paths`,
134
+ `Automatic sync: ${automaticSyncSummary(config.automatic)} · Sessions: ${config.include.includes("sessions") ? "On" : "Off"}`,
135
+ "",
136
+ switchEffect,
137
+ ].join("\n"),
138
+ { signal },
139
+ );
140
+ if (signal?.aborted || !confirmed) return false;
141
+ try {
142
+ let pullClosed = false;
143
+ const result = await useSyncSetup(
144
+ ctx,
145
+ name,
146
+ async (selectedTarget) => {
147
+ const pullResult = await runCancellableOperation(
148
+ ctx,
149
+ `Pulling sync setup “${safeTerminalText(name)}”…`,
150
+ "pull",
151
+ runRoute,
152
+ {
153
+ commitAware: true,
154
+ cancelledMessage: null,
155
+ target: selectedTarget,
156
+ signal,
157
+ },
158
+ );
159
+ const disposition = await dispatchManagerResult(ctx, pullResult, "pull", runRoute, signal);
160
+ if (pullResult.kind === "closed" || pullResult.kind.endsWith("required")) {
161
+ pullClosed = disposition.kind === "close";
162
+ }
163
+ if (disposition.appliedRoute === "pull") return "applied";
164
+ if (pullResult.kind === "completed") return pullResult.outcome;
165
+ return pullResult.kind === "cancelled" ? "cancelled" : undefined;
166
+ },
167
+ onSwitch,
168
+ signal,
169
+ syncConfigReviewIdentity(config),
170
+ );
171
+ if (pullClosed) return "closed";
172
+ return result.pullApplied ? "pull-attempted" : "switched";
173
+ } catch (error) {
174
+ if (signal?.aborted) return false;
175
+ ctx.ui.notify(
176
+ `Sync setup “${safeTerminalText(name)}” was not switched: ${safeTerminalText(errorMessage(error))}`,
177
+ "error",
178
+ );
179
+ return false;
180
+ }
181
+ }
@@ -0,0 +1,39 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { refreshTargetCompletions } from "../../commands/setup-completions.js";
3
+ import { localConfigPath } from "../../settings/config-file.js";
4
+ import { safeTerminalText } from "../terminal-text.js";
5
+ import { showGitSetup } from "./git-ui.js";
6
+ import { showS3Setup } from "./s3-ui.js";
7
+ import { promptInitialSetupName } from "./setup-name-ui.js";
8
+ import { showWebDavSetup } from "./webdav-ui.js";
9
+
10
+ export async function showSetupWizard(ctx: ExtensionCommandContext, signal?: AbortSignal) {
11
+ if (ctx.mode !== "tui") {
12
+ ctx.ui.notify(
13
+ `Guided sync setup requires TUI mode for masked credential input. Create version 3 settings in ${safeTerminalText(localConfigPath())}.`,
14
+ "warning",
15
+ );
16
+ return false;
17
+ }
18
+ const preset = await ctx.ui.select(
19
+ "Set up sync\n\nWhere will Pi settings be stored?",
20
+ ["Cloudflare R2", "Other S3-compatible storage", "WebDAV", "Git", "Cancel"],
21
+ { signal },
22
+ );
23
+ if (signal?.aborted || !preset || preset === "Cancel") return false;
24
+ const targetName = await promptInitialSetupName(ctx, signal);
25
+ if (!targetName) return false;
26
+ if (preset === "WebDAV") {
27
+ const saved = await showWebDavSetup(ctx, targetName, signal);
28
+ if (signal?.aborted) return false;
29
+ if (saved) await refreshTargetCompletions();
30
+ return saved;
31
+ }
32
+ if (preset === "Git") {
33
+ const saved = await showGitSetup(ctx, targetName, signal);
34
+ if (signal?.aborted) return false;
35
+ if (saved) await refreshTargetCompletions();
36
+ return saved;
37
+ }
38
+ return showS3Setup(ctx, preset, targetName, signal);
39
+ }
@@ -0,0 +1,78 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { normalizeS3Bucket, requiredString } from "../../settings/settings-validation.js";
3
+ import { errorMessage } from "../../sync/sync-errors.js";
4
+ import { safeTerminalText } from "../terminal-text.js";
5
+
6
+ export type ValidateInput = (value: string) => string | Promise<string>;
7
+
8
+ export async function requiredExistingBucket(
9
+ ctx: ExtensionCommandContext,
10
+ example: string,
11
+ signal?: AbortSignal,
12
+ ) {
13
+ return requiredValueInput(
14
+ ctx,
15
+ "Existing bucket\n\nThe bucket must already exist; pi-sync will not create it.",
16
+ example,
17
+ signal,
18
+ normalizeS3Bucket,
19
+ );
20
+ }
21
+
22
+ export async function requiredInput(
23
+ ctx: ExtensionCommandContext,
24
+ title: string,
25
+ defaultValue: string,
26
+ signal?: AbortSignal,
27
+ validate?: ValidateInput,
28
+ ) {
29
+ return promptTextInput(ctx, title, { defaultValue, validate, rejectPlaceholders: true }, signal);
30
+ }
31
+
32
+ export async function requiredValueInput(
33
+ ctx: ExtensionCommandContext,
34
+ title: string,
35
+ example: string,
36
+ signal?: AbortSignal,
37
+ validate?: ValidateInput,
38
+ ) {
39
+ return promptTextInput(ctx, title, { example, validate, rejectPlaceholders: true }, signal);
40
+ }
41
+
42
+ export async function promptTextInput(
43
+ ctx: ExtensionCommandContext,
44
+ title: string,
45
+ options: {
46
+ defaultValue?: string;
47
+ example?: string;
48
+ validate?: ValidateInput;
49
+ rejectPlaceholders?: boolean;
50
+ },
51
+ signal?: AbortSignal,
52
+ ) {
53
+ // Pi ignores placeholders. Keep defaults/examples visible; only an explicit blank accepts a default.
54
+ const hint =
55
+ options.defaultValue !== undefined
56
+ ? `Default: ${safeTerminalText(options.defaultValue)} (leave blank to keep)`
57
+ : `Example: ${safeTerminalText(options.example ?? "")}\nEnter your own value; this example is not a default.`;
58
+ while (true) {
59
+ signal?.throwIfAborted();
60
+ const value = await ctx.ui.input(`${title}\n\n${hint}`, undefined, { signal });
61
+ signal?.throwIfAborted();
62
+ if (value === undefined) return undefined;
63
+ try {
64
+ const normalized = value.trim() || options.defaultValue;
65
+ if (!normalized) throw new Error(`${title.split("\n")[0]} is required.`);
66
+ if (options.rejectPlaceholders && /[<>]/u.test(normalized)) {
67
+ throw new Error("Replace example placeholders such as <account-id> with your own value.");
68
+ }
69
+ const checked = requiredString(normalized, title.split("\n")[0] ?? "value");
70
+ const result = options.validate ? await options.validate(checked) : checked;
71
+ signal?.throwIfAborted();
72
+ return result;
73
+ } catch (error) {
74
+ signal?.throwIfAborted();
75
+ ctx.ui.notify(`${errorMessage(error)} Enter a corrected value, or cancel.`, "warning");
76
+ }
77
+ }
78
+ }