@narumitw/pi-sync 0.31.0 → 0.34.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.
- package/README.md +159 -236
- package/package.json +11 -3
- package/src/backend-factory.ts +20 -0
- package/src/command.ts +39 -39
- package/src/config-errors.ts +7 -0
- package/src/config-file.ts +64 -8
- package/src/config.ts +605 -476
- package/src/file-selection.ts +121 -140
- package/src/git-backend.ts +832 -0
- package/src/git-config.ts +136 -0
- package/src/git-runner.ts +276 -0
- package/src/git-storage.ts +288 -0
- package/src/git-ui.ts +307 -0
- package/src/manager-helpers.ts +64 -0
- package/src/manager-ui.ts +537 -542
- package/src/s3-backend.ts +498 -0
- package/src/s3-client.ts +147 -64
- package/src/s3-credentials-ui.ts +92 -0
- package/src/secret-input.ts +199 -0
- package/src/settings-management.ts +191 -277
- package/src/settings-ui.ts +94 -115
- package/src/setup-switch.ts +150 -0
- package/src/snapshot-apply.ts +13 -7
- package/src/snapshot-codec.ts +48 -0
- package/src/snapshot-transaction.ts +3 -1
- package/src/snapshot.ts +63 -32
- package/src/storage-connections-ui.ts +308 -0
- package/src/sync-backend.ts +97 -0
- package/src/sync-format.ts +31 -33
- package/src/sync-operations.ts +912 -0
- package/src/sync-policy.ts +137 -61
- package/src/sync-setups-ui.ts +177 -0
- package/src/sync-state.ts +48 -48
- package/src/sync.ts +180 -739
- package/src/types.ts +177 -53
- package/src/webdav-backend.ts +678 -0
- package/src/webdav-client.ts +390 -0
- package/src/webdav-config.ts +92 -0
- package/src/webdav-ui.ts +421 -0
- package/src/target-switch.ts +0 -119
package/src/config.ts
CHANGED
|
@@ -10,350 +10,638 @@ import {
|
|
|
10
10
|
import {
|
|
11
11
|
activeLocalConfigPath,
|
|
12
12
|
consumeLocalConfigMigrationNotice,
|
|
13
|
+
createLocalConfigDocument,
|
|
13
14
|
legacyLocalConfigPath,
|
|
14
15
|
localConfigPath,
|
|
15
16
|
quarantineAndRemoveConfigIfMatches,
|
|
17
|
+
readActiveLocalConfigDocumentForRepair,
|
|
16
18
|
readMigratingLocalConfigDocument,
|
|
17
19
|
replaceLocalConfigDocument,
|
|
18
|
-
|
|
20
|
+
updateLocalConfigDocument,
|
|
19
21
|
} from "./config-file.js";
|
|
22
|
+
import {
|
|
23
|
+
normalizeGitBranch,
|
|
24
|
+
normalizeGitDirectory,
|
|
25
|
+
normalizeGitRemote,
|
|
26
|
+
normalizeGitRemoteIdentity,
|
|
27
|
+
} from "./git-config.js";
|
|
20
28
|
import { safeName } from "./paths.js";
|
|
21
|
-
import {
|
|
29
|
+
import { normalizeSyncInclude } from "./sync-policy.js";
|
|
22
30
|
import type {
|
|
31
|
+
AnySyncConfig,
|
|
32
|
+
OnSwitchAction,
|
|
23
33
|
PartialConfig,
|
|
34
|
+
PiSyncSettingsV3,
|
|
24
35
|
Snapshot,
|
|
25
|
-
|
|
36
|
+
StorageConnectionSettings,
|
|
37
|
+
SyncSetupSettings,
|
|
26
38
|
SyncState,
|
|
27
|
-
TargetSwitchAction,
|
|
28
39
|
} from "./types.js";
|
|
40
|
+
import {
|
|
41
|
+
normalizeWebDavIdentityUrl,
|
|
42
|
+
normalizeWebDavPath,
|
|
43
|
+
normalizeWebDavUrl,
|
|
44
|
+
validateWebDavCredentials,
|
|
45
|
+
} from "./webdav-config.js";
|
|
29
46
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const VERSION = 1;
|
|
33
|
-
const DEFAULT_PROFILE = "default";
|
|
34
|
-
const DEFAULT_PREFIX = "pi-sync";
|
|
35
|
-
const DEFAULT_REGION = "auto";
|
|
36
|
-
export const DEFAULT_TARGET_SWITCH_ACTION: TargetSwitchAction = "ask";
|
|
47
|
+
const STATE_VERSION = 2;
|
|
48
|
+
export const DEFAULT_ON_SWITCH: OnSwitchAction = "ask-before-pull";
|
|
37
49
|
|
|
50
|
+
export { normalizeExtraFiles, normalizeSyncFiles } from "./sync-policy.js";
|
|
38
51
|
export {
|
|
39
52
|
activeLocalConfigPath,
|
|
40
53
|
consumeLocalConfigMigrationNotice,
|
|
54
|
+
createLocalConfigDocument,
|
|
41
55
|
legacyLocalConfigPath,
|
|
42
56
|
localConfigPath,
|
|
57
|
+
normalizeSyncInclude,
|
|
43
58
|
quarantineAndRemoveConfigIfMatches,
|
|
59
|
+
readActiveLocalConfigDocumentForRepair,
|
|
44
60
|
replaceLocalConfigDocument,
|
|
61
|
+
updateLocalConfigDocument,
|
|
45
62
|
};
|
|
46
63
|
|
|
47
|
-
export const DEPRECATED_PI_SYNC_ENV_NAMES = [
|
|
48
|
-
"PI_SYNC_ENDPOINT",
|
|
49
|
-
"PI_SYNC_BUCKET",
|
|
50
|
-
"PI_SYNC_REGION",
|
|
51
|
-
"PI_SYNC_ACCESS_KEY_ID",
|
|
52
|
-
"PI_SYNC_SECRET_ACCESS_KEY",
|
|
53
|
-
"PI_SYNC_SESSION_TOKEN",
|
|
54
|
-
"PI_SYNC_PROFILE",
|
|
55
|
-
"PI_SYNC_PREFIX",
|
|
56
|
-
"PI_SYNC_AUTO_SYNC",
|
|
57
|
-
"PI_SYNC_SESSIONS",
|
|
58
|
-
] as const;
|
|
59
|
-
|
|
60
|
-
function trimSlashes(value: string) {
|
|
61
|
-
return value.replace(/^\/+|\/+$/g, "");
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function decodeBase64Strict(value: string, filePath: string) {
|
|
65
|
-
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 !== 0) {
|
|
66
|
-
throw new Error(`Invalid base64 content in snapshot file: ${filePath}`);
|
|
67
|
-
}
|
|
68
|
-
return Buffer.from(value, "base64");
|
|
69
|
-
}
|
|
70
|
-
|
|
71
64
|
function sessionDirFromContext(ctx: ExtensionCommandContext | ExtensionContext) {
|
|
72
65
|
const manager = ctx.sessionManager as typeof ctx.sessionManager & {
|
|
73
66
|
usesDefaultSessionDir?: () => boolean;
|
|
74
67
|
};
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
const getSessionDir = manager.getSessionDir;
|
|
80
|
-
return typeof getSessionDir === "function"
|
|
81
|
-
? (getSessionDir.call(manager) as string | undefined)
|
|
68
|
+
if (manager.usesDefaultSessionDir?.call(manager)) return undefined;
|
|
69
|
+
return typeof manager.getSessionDir === "function"
|
|
70
|
+
? (manager.getSessionDir.call(manager) as string | undefined)
|
|
82
71
|
: undefined;
|
|
83
72
|
}
|
|
84
73
|
|
|
85
|
-
export async function
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (missing.length > 0) {
|
|
74
|
+
export async function loadConfig(setupName?: string): Promise<AnySyncConfig> {
|
|
75
|
+
const settings = await requireSettings();
|
|
76
|
+
const selectedName = setupName ?? settings.activeSyncSetup;
|
|
77
|
+
if (!selectedName) throw new Error("No sync setups are configured.");
|
|
78
|
+
validateConfigName(selectedName, "sync setup");
|
|
79
|
+
const setup = ownObject<SyncSetupSettings>(settings.syncSetups, selectedName);
|
|
80
|
+
if (!setup)
|
|
81
|
+
throw new Error(`Invalid pi-sync settings: sync setup “${selectedName}” was not found.`);
|
|
82
|
+
const connectionName = setup.storage.connection;
|
|
83
|
+
const connection = ownObject<StorageConnectionSettings>(
|
|
84
|
+
settings.storageConnections,
|
|
85
|
+
connectionName,
|
|
86
|
+
);
|
|
87
|
+
if (!connection) {
|
|
100
88
|
throw new Error(
|
|
101
|
-
`
|
|
89
|
+
`Invalid pi-sync settings: sync setup “${selectedName}” references missing storage connection “${connectionName}”.`,
|
|
102
90
|
);
|
|
103
91
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
92
|
+
return resolveSyncConfig(selectedName, setup, connectionName, connection, settings.onSwitch);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type SyncSetupStorageReview = Pick<
|
|
96
|
+
PartialConfig,
|
|
97
|
+
"connectionName" | "storageKind" | "storagePath" | "bucket" | "branch"
|
|
98
|
+
>;
|
|
107
99
|
|
|
100
|
+
/** A validated setup-facing projection used by manager and settings UI. */
|
|
101
|
+
export async function loadPartialConfig(setupName?: string): Promise<PartialConfig> {
|
|
102
|
+
const config = await loadConfig(setupName);
|
|
108
103
|
return {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
sessionToken: normalizeOptionalString(partial.sessionToken),
|
|
115
|
-
profile: normalizeOptionalString(partial.profile) ?? DEFAULT_PROFILE,
|
|
116
|
-
prefix: trimSlashes(normalizeOptionalString(partial.prefix) ?? DEFAULT_PREFIX),
|
|
117
|
-
target: partial.target ?? DEFAULT_PROFILE,
|
|
118
|
-
storageProfile: partial.storageProfile ?? DEFAULT_PROFILE,
|
|
119
|
-
storageKind: partial.storageKind,
|
|
120
|
-
autoSync: isEnabled(partial.autoSync, true),
|
|
121
|
-
settingsVersion: partial.settingsVersion ?? 1,
|
|
122
|
-
syncFiles: normalizeSyncFiles(partial.syncFiles),
|
|
123
|
-
syncSessions: isExplicitlyEnabled(partial.syncSessions),
|
|
124
|
-
extraFiles: normalizeExtraFiles(partial.extraFiles),
|
|
104
|
+
setupName: config.setupName,
|
|
105
|
+
...storageReviewFromConfig(config),
|
|
106
|
+
include: [...config.include],
|
|
107
|
+
automatic: config.automatic,
|
|
108
|
+
onSwitch: config.onSwitch,
|
|
125
109
|
};
|
|
126
110
|
}
|
|
127
111
|
|
|
128
|
-
export
|
|
129
|
-
|
|
112
|
+
export function syncSetupStorageReview(
|
|
113
|
+
setupName: string,
|
|
114
|
+
setup: SyncSetupSettings,
|
|
115
|
+
connectionName: string,
|
|
116
|
+
connection: StorageConnectionSettings,
|
|
117
|
+
): SyncSetupStorageReview {
|
|
118
|
+
return storageReviewFromConfig(
|
|
119
|
+
resolveSyncConfig(setupName, setup, connectionName, connection, DEFAULT_ON_SWITCH),
|
|
120
|
+
);
|
|
130
121
|
}
|
|
131
122
|
|
|
132
|
-
export
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
123
|
+
export function syncSetupReviewIdentity(
|
|
124
|
+
setupName: string,
|
|
125
|
+
setup: SyncSetupSettings,
|
|
126
|
+
connectionName: string,
|
|
127
|
+
connection: StorageConnectionSettings,
|
|
128
|
+
) {
|
|
129
|
+
return syncConfigReviewIdentity(
|
|
130
|
+
resolveSyncConfig(setupName, setup, connectionName, connection, DEFAULT_ON_SWITCH),
|
|
138
131
|
);
|
|
139
132
|
}
|
|
140
133
|
|
|
141
|
-
export
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
134
|
+
export function syncConfigReviewIdentity(config: AnySyncConfig) {
|
|
135
|
+
return JSON.stringify([
|
|
136
|
+
config.setupName,
|
|
137
|
+
config.connectionName,
|
|
138
|
+
backendIdentityCoordinates(config),
|
|
139
|
+
config.include,
|
|
140
|
+
config.automatic,
|
|
141
|
+
]);
|
|
148
142
|
}
|
|
149
143
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
144
|
+
function storageReviewFromConfig(config: AnySyncConfig): SyncSetupStorageReview {
|
|
145
|
+
return {
|
|
146
|
+
connectionName: config.connectionName,
|
|
147
|
+
storageKind: config.backend.type,
|
|
148
|
+
storagePath: config.storagePath,
|
|
149
|
+
...(config.backend.type === "s3"
|
|
150
|
+
? { bucket: config.backend.destination.bucket }
|
|
151
|
+
: config.backend.type === "git"
|
|
152
|
+
? { branch: config.backend.destination.branch }
|
|
153
|
+
: {}),
|
|
154
|
+
};
|
|
154
155
|
}
|
|
155
156
|
|
|
156
|
-
export function
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
);
|
|
157
|
+
export async function configuredSyncSetupNames() {
|
|
158
|
+
const settings = await readLocalConfigObject();
|
|
159
|
+
return settings
|
|
160
|
+
? Object.keys(settings.syncSetups).sort((left, right) => left.localeCompare(right))
|
|
161
|
+
: [];
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
export function
|
|
165
|
-
return
|
|
164
|
+
export async function loadOnSwitch(): Promise<OnSwitchAction> {
|
|
165
|
+
return (await requireSettings()).onSwitch;
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
-
export function
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
168
|
+
export function normalizeOnSwitch(value: unknown): OnSwitchAction {
|
|
169
|
+
if (value === "ask-before-pull" || value === "pull-after-switch" || value === "switch-only") {
|
|
170
|
+
return value;
|
|
171
|
+
}
|
|
172
|
+
throw new Error(
|
|
173
|
+
'Invalid pi-sync settings: onSwitch must be "ask-before-pull", "pull-after-switch", or "switch-only".',
|
|
174
|
+
);
|
|
174
175
|
}
|
|
175
176
|
|
|
176
|
-
|
|
177
|
+
function resolveSyncConfig(
|
|
178
|
+
setupName: string,
|
|
179
|
+
setup: SyncSetupSettings,
|
|
180
|
+
connectionName: string,
|
|
181
|
+
connection: StorageConnectionSettings,
|
|
182
|
+
onSwitch: OnSwitchAction,
|
|
183
|
+
): AnySyncConfig {
|
|
184
|
+
const storagePath = normalizeStoragePath(setup.storage.path);
|
|
185
|
+
const namespace = storagePath.slice(storagePath.lastIndexOf("/") + 1);
|
|
186
|
+
const include = normalizeSyncInclude(setup.sync.include);
|
|
187
|
+
const common = {
|
|
188
|
+
setupName,
|
|
189
|
+
connectionName,
|
|
190
|
+
storagePath,
|
|
191
|
+
snapshotIdentity: namespace,
|
|
192
|
+
include,
|
|
193
|
+
automatic: setup.sync.automatic,
|
|
194
|
+
onSwitch,
|
|
195
|
+
};
|
|
196
|
+
if (connection.type === "git") {
|
|
197
|
+
return {
|
|
198
|
+
...common,
|
|
199
|
+
backend: {
|
|
200
|
+
type: "git",
|
|
201
|
+
profile: { kind: "git", remote: normalizeGitRemote(connection.remote) as string },
|
|
202
|
+
destination: {
|
|
203
|
+
branch: normalizeGitBranch(setup.storage.branch),
|
|
204
|
+
directory: normalizeGitDirectory(storagePath),
|
|
205
|
+
namespace,
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
if (connection.type === "webdav") {
|
|
211
|
+
return {
|
|
212
|
+
...common,
|
|
213
|
+
backend: {
|
|
214
|
+
type: "webdav",
|
|
215
|
+
profile: {
|
|
216
|
+
kind: "webdav",
|
|
217
|
+
url: normalizeWebDavUrl(connection.url) as string,
|
|
218
|
+
username: connection.credentials.username,
|
|
219
|
+
password: connection.credentials.password,
|
|
220
|
+
},
|
|
221
|
+
destination: { path: normalizeWebDavPath(storagePath), namespace },
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
177
225
|
return {
|
|
178
|
-
...
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
syncSessions: process.env.PI_SYNC_SESSIONS ?? fileConfig.syncSessions,
|
|
226
|
+
...common,
|
|
227
|
+
backend: {
|
|
228
|
+
type: "s3",
|
|
229
|
+
profile: {
|
|
230
|
+
kind: isCloudflareR2Endpoint(connection.endpoint) ? "r2" : "s3-compatible",
|
|
231
|
+
endpoint: normalizeS3Endpoint(connection.endpoint),
|
|
232
|
+
region: requiredString(connection.region, "S3 region"),
|
|
233
|
+
accessKeyId: connection.credentials.accessKeyId,
|
|
234
|
+
secretAccessKey: connection.credentials.secretAccessKey,
|
|
235
|
+
sessionToken: optionalString(connection.credentials.sessionToken, "S3 session token"),
|
|
236
|
+
},
|
|
237
|
+
destination: {
|
|
238
|
+
bucket: normalizeS3Bucket(setup.storage.bucket),
|
|
239
|
+
prefix: storagePath,
|
|
240
|
+
namespace,
|
|
241
|
+
},
|
|
242
|
+
},
|
|
196
243
|
};
|
|
197
244
|
}
|
|
198
245
|
|
|
199
|
-
|
|
200
|
-
settings
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
246
|
+
async function requireSettings() {
|
|
247
|
+
const settings = await readLocalConfigObject();
|
|
248
|
+
if (!settings) {
|
|
249
|
+
throw new Error(`Missing pi-sync settings. Use /sync setup or create ${localConfigPath()}.`);
|
|
250
|
+
}
|
|
251
|
+
return settings;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function validateSettingsDocument(value: Record<string, unknown>): PiSyncSettingsV3 {
|
|
255
|
+
if (value.version !== 3) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
`Unsupported pi-sync settings: version 3 is required. Keep the existing file for recovery, then create a new version 3 ${path.basename(localConfigPath())}; pi-sync will not migrate or overwrite old settings.`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
rejectLegacyFields(
|
|
261
|
+
value,
|
|
262
|
+
[
|
|
263
|
+
"profiles",
|
|
264
|
+
"targets",
|
|
265
|
+
"activeTarget",
|
|
266
|
+
"targetSwitchAction",
|
|
267
|
+
"endpoint",
|
|
268
|
+
"bucket",
|
|
269
|
+
"region",
|
|
270
|
+
"accessKeyId",
|
|
271
|
+
"secretAccessKey",
|
|
272
|
+
"sessionToken",
|
|
273
|
+
"profile",
|
|
274
|
+
"prefix",
|
|
275
|
+
"autoSync",
|
|
276
|
+
"syncFiles",
|
|
277
|
+
"syncSessions",
|
|
278
|
+
"extraFiles",
|
|
279
|
+
],
|
|
280
|
+
"top level",
|
|
281
|
+
);
|
|
282
|
+
normalizeOnSwitch(value.onSwitch);
|
|
283
|
+
const storageConnections = requireNamedObjectMap(
|
|
284
|
+
value.storageConnections,
|
|
285
|
+
"storageConnections",
|
|
286
|
+
"storage connection",
|
|
287
|
+
);
|
|
288
|
+
const syncSetups = requireNamedObjectMap(value.syncSetups, "syncSetups", "sync setup");
|
|
289
|
+
for (const name of Object.keys(storageConnections)) {
|
|
290
|
+
validateStorageConnection(
|
|
291
|
+
name,
|
|
292
|
+
requireOwnObject(storageConnections, name, "storage connection") as Record<string, unknown>,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
for (const name of Object.keys(syncSetups)) {
|
|
296
|
+
validateSyncSetup(
|
|
297
|
+
name,
|
|
298
|
+
requireOwnObject(syncSetups, name, "sync setup") as Record<string, unknown>,
|
|
299
|
+
storageConnections,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
const names = Object.keys(syncSetups);
|
|
303
|
+
const activeSyncSetup = optionalCanonicalReference(value.activeSyncSetup, "activeSyncSetup");
|
|
304
|
+
if (names.length === 0) {
|
|
305
|
+
if (activeSyncSetup !== undefined) {
|
|
306
|
+
throw new Error("Invalid pi-sync settings: empty syncSetups cannot have activeSyncSetup.");
|
|
307
|
+
}
|
|
308
|
+
} else if (!activeSyncSetup || !Object.hasOwn(syncSetups, activeSyncSetup)) {
|
|
221
309
|
throw new Error(
|
|
222
|
-
|
|
310
|
+
"Invalid pi-sync settings: activeSyncSetup must reference an existing own-property sync setup.",
|
|
223
311
|
);
|
|
224
312
|
}
|
|
225
|
-
|
|
226
|
-
|
|
313
|
+
validateUniqueRemoteSyncSetups(syncSetups, storageConnections);
|
|
314
|
+
return value as PiSyncSettingsV3;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function validateStorageConnection(name: string, value: Record<string, unknown>) {
|
|
318
|
+
rejectLegacyFields(
|
|
319
|
+
value,
|
|
320
|
+
["kind", "accessKeyId", "secretAccessKey", "sessionToken", "username", "password"],
|
|
321
|
+
`storage connection “${name}”`,
|
|
322
|
+
);
|
|
323
|
+
const type = requiredString(value.type, `storage connection “${name}” type`);
|
|
324
|
+
if (type !== "s3" && type !== "git" && type !== "webdav") {
|
|
325
|
+
throw new Error(`Invalid pi-sync settings: storage connection “${name}” has unsupported type.`);
|
|
326
|
+
}
|
|
327
|
+
const known = ["endpoint", "region", "remote", "url", "credentials"];
|
|
328
|
+
const allowed =
|
|
329
|
+
type === "s3"
|
|
330
|
+
? new Set(["endpoint", "region", "credentials"])
|
|
331
|
+
: type === "git"
|
|
332
|
+
? new Set(["remote"])
|
|
333
|
+
: new Set(["url", "credentials"]);
|
|
334
|
+
if (known.some((field) => Object.hasOwn(value, field) && !allowed.has(field))) {
|
|
227
335
|
throw new Error(
|
|
228
|
-
`Invalid pi-sync settings:
|
|
336
|
+
`Invalid pi-sync settings: ${type.toUpperCase()} storage connection “${name}” mixes backend fields.`,
|
|
229
337
|
);
|
|
230
338
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
};
|
|
339
|
+
if (type === "git") {
|
|
340
|
+
if (!normalizeGitRemote(requiredString(value.remote, `Git remote for “${name}”`))) {
|
|
341
|
+
throw new Error(`Invalid pi-sync settings: Git remote for “${name}” is required.`);
|
|
342
|
+
}
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const credentials = requireRecord(
|
|
346
|
+
value.credentials,
|
|
347
|
+
`credentials for storage connection “${name}”`,
|
|
348
|
+
);
|
|
349
|
+
if (type === "webdav") {
|
|
350
|
+
normalizeWebDavUrl(requiredString(value.url, `WebDAV URL for “${name}”`));
|
|
351
|
+
const username = requiredString(credentials.username, `WebDAV username for “${name}”`);
|
|
352
|
+
const password = requiredSecret(credentials.password, `WebDAV password for “${name}”`);
|
|
353
|
+
if (
|
|
354
|
+
["accessKeyId", "secretAccessKey", "sessionToken"].some((field) =>
|
|
355
|
+
Object.hasOwn(credentials, field),
|
|
356
|
+
)
|
|
357
|
+
) {
|
|
358
|
+
throw new Error(`Invalid pi-sync settings: WebDAV credentials for “${name}” mix fields.`);
|
|
359
|
+
}
|
|
360
|
+
validateWebDavCredentials(username, password);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
normalizeS3Endpoint(requiredString(value.endpoint, `S3 endpoint for “${name}”`));
|
|
364
|
+
requiredString(value.region, `S3 region for “${name}”`);
|
|
365
|
+
requiredString(credentials.accessKeyId, `S3 access key id for “${name}”`);
|
|
366
|
+
requiredSecret(credentials.secretAccessKey, `S3 secret access key for “${name}”`);
|
|
367
|
+
optionalString(credentials.sessionToken, `S3 session token for “${name}”`);
|
|
368
|
+
if (["username", "password"].some((field) => Object.hasOwn(credentials, field))) {
|
|
369
|
+
throw new Error(`Invalid pi-sync settings: S3 credentials for “${name}” mix fields.`);
|
|
370
|
+
}
|
|
257
371
|
}
|
|
258
372
|
|
|
259
|
-
function
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
373
|
+
function validateSyncSetup(
|
|
374
|
+
name: string,
|
|
375
|
+
value: Record<string, unknown>,
|
|
376
|
+
connections: Record<string, unknown>,
|
|
377
|
+
) {
|
|
378
|
+
rejectLegacyFields(
|
|
379
|
+
value,
|
|
380
|
+
[
|
|
381
|
+
"profile",
|
|
382
|
+
"bucket",
|
|
383
|
+
"branch",
|
|
384
|
+
"path",
|
|
385
|
+
"prefix",
|
|
386
|
+
"directory",
|
|
387
|
+
"namespace",
|
|
388
|
+
"autoSync",
|
|
389
|
+
"syncFiles",
|
|
390
|
+
"syncSessions",
|
|
391
|
+
"extraFiles",
|
|
392
|
+
],
|
|
393
|
+
`sync setup “${name}”`,
|
|
394
|
+
);
|
|
395
|
+
const storage = requireRecord(value.storage, `storage for sync setup “${name}”`);
|
|
396
|
+
const sync = requireRecord(value.sync, `sync policy for sync setup “${name}”`);
|
|
397
|
+
rejectLegacyFields(
|
|
398
|
+
storage,
|
|
399
|
+
["profile", "prefix", "directory", "namespace"],
|
|
400
|
+
`storage for sync setup “${name}”`,
|
|
401
|
+
);
|
|
402
|
+
rejectLegacyFields(
|
|
403
|
+
sync,
|
|
404
|
+
["autoSync", "syncFiles", "syncSessions", "extraFiles"],
|
|
405
|
+
`sync policy for sync setup “${name}”`,
|
|
406
|
+
);
|
|
407
|
+
const connectionName = requiredCanonicalReference(
|
|
408
|
+
storage.connection,
|
|
409
|
+
`storage connection reference for sync setup “${name}”`,
|
|
410
|
+
);
|
|
411
|
+
validateConfigName(connectionName, "storage connection reference");
|
|
412
|
+
const connection = ownObject<Record<string, unknown>>(connections, connectionName);
|
|
413
|
+
if (!connection) {
|
|
414
|
+
throw new Error(
|
|
415
|
+
`Invalid pi-sync settings: sync setup “${name}” references missing storage connection “${connectionName}”.`,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
const type = connection.type;
|
|
419
|
+
normalizeStoragePath(requiredString(storage.path, `storage path for sync setup “${name}”`));
|
|
420
|
+
if (type === "s3") {
|
|
421
|
+
normalizeS3Bucket(requiredString(storage.bucket, `S3 bucket for sync setup “${name}”`));
|
|
422
|
+
if (Object.hasOwn(storage, "branch")) mixedSetupError("S3", name);
|
|
423
|
+
} else if (type === "git") {
|
|
424
|
+
normalizeGitBranch(requiredString(storage.branch, `Git branch for sync setup “${name}”`));
|
|
425
|
+
if (Object.hasOwn(storage, "bucket")) mixedSetupError("Git", name);
|
|
426
|
+
} else if (type === "webdav") {
|
|
427
|
+
if (Object.hasOwn(storage, "bucket") || Object.hasOwn(storage, "branch")) {
|
|
428
|
+
mixedSetupError("WebDAV", name);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (!Object.hasOwn(sync, "include")) {
|
|
432
|
+
throw new Error(`Invalid pi-sync settings: sync setup “${name}” is missing sync.include.`);
|
|
433
|
+
}
|
|
434
|
+
normalizeSyncInclude(sync.include);
|
|
435
|
+
if (typeof sync.automatic !== "boolean") {
|
|
436
|
+
throw new Error(
|
|
437
|
+
`Invalid pi-sync settings: sync setup “${name}” sync.automatic must be boolean.`,
|
|
438
|
+
);
|
|
267
439
|
}
|
|
268
|
-
return true;
|
|
269
440
|
}
|
|
270
441
|
|
|
271
|
-
function
|
|
442
|
+
function mixedSetupError(type: string, name: string): never {
|
|
443
|
+
throw new Error(`Invalid pi-sync settings: ${type} sync setup “${name}” mixes backend fields.`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function validateUniqueRemoteSyncSetups(
|
|
447
|
+
setups: Record<string, unknown>,
|
|
448
|
+
connections: Record<string, unknown>,
|
|
449
|
+
) {
|
|
272
450
|
const identities = new Map<string, string>();
|
|
273
|
-
for (const name of Object.keys(
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
451
|
+
for (const name of Object.keys(setups)) {
|
|
452
|
+
const setup = requireOwnObject(setups, name, "sync setup") as unknown as SyncSetupSettings;
|
|
453
|
+
const connection = requireOwnObject(
|
|
454
|
+
connections,
|
|
455
|
+
setup.storage.connection,
|
|
456
|
+
"storage connection",
|
|
457
|
+
) as unknown as StorageConnectionSettings;
|
|
458
|
+
const identity = effectiveSyncSetupRemoteIdentity(setup, connection);
|
|
278
459
|
const existing = identities.get(identity);
|
|
279
460
|
if (existing) {
|
|
280
461
|
throw new Error(
|
|
281
|
-
`Invalid pi-sync settings:
|
|
462
|
+
`Invalid pi-sync settings: sync setups “${existing}” and “${name}” use the same normalized remote location.`,
|
|
282
463
|
);
|
|
283
464
|
}
|
|
284
465
|
identities.set(identity, name);
|
|
285
466
|
}
|
|
286
467
|
}
|
|
287
468
|
|
|
288
|
-
export function
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
(
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
)
|
|
302
|
-
|
|
469
|
+
export function effectiveSyncSetupRemoteIdentity(
|
|
470
|
+
setup: SyncSetupSettings,
|
|
471
|
+
connection: StorageConnectionSettings,
|
|
472
|
+
) {
|
|
473
|
+
const storagePath = normalizeStoragePath(setup.storage.path);
|
|
474
|
+
if (connection.type === "git") {
|
|
475
|
+
return JSON.stringify([
|
|
476
|
+
"git",
|
|
477
|
+
normalizeGitRemoteIdentity(connection.remote),
|
|
478
|
+
normalizeGitBranch(setup.storage.branch),
|
|
479
|
+
normalizeGitDirectory(storagePath),
|
|
480
|
+
]);
|
|
481
|
+
}
|
|
482
|
+
if (connection.type === "webdav") {
|
|
483
|
+
return JSON.stringify([
|
|
484
|
+
"webdav",
|
|
485
|
+
normalizeWebDavIdentityUrl(connection.url),
|
|
486
|
+
connection.credentials.username.trim(),
|
|
487
|
+
normalizeWebDavPath(storagePath),
|
|
488
|
+
]);
|
|
489
|
+
}
|
|
490
|
+
return JSON.stringify([
|
|
491
|
+
"s3",
|
|
492
|
+
normalizeEndpointIdentity(connection.endpoint),
|
|
493
|
+
normalizeS3Bucket(setup.storage.bucket),
|
|
494
|
+
storagePath,
|
|
495
|
+
]);
|
|
303
496
|
}
|
|
304
497
|
|
|
305
|
-
function
|
|
306
|
-
|
|
498
|
+
function rejectLegacyFields(
|
|
499
|
+
value: Record<string, unknown>,
|
|
500
|
+
fields: readonly string[],
|
|
501
|
+
context: string,
|
|
502
|
+
) {
|
|
503
|
+
const field = fields.find((candidate) => Object.hasOwn(value, candidate));
|
|
504
|
+
if (field) {
|
|
505
|
+
throw new Error(
|
|
506
|
+
`Invalid pi-sync settings: ${context} contains unsupported version 1/2 field “${field}”.`,
|
|
507
|
+
);
|
|
508
|
+
}
|
|
307
509
|
}
|
|
308
510
|
|
|
309
|
-
function requireNamedObjectMap(value: unknown, field: string
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
for (const name of Object.keys(value)) validateConfigName(name, field.slice(0, -1));
|
|
314
|
-
return value as Record<string, unknown>;
|
|
511
|
+
function requireNamedObjectMap(value: unknown, field: string, itemLabel: string) {
|
|
512
|
+
const result = requireRecord(value, field);
|
|
513
|
+
for (const name of Object.keys(result)) validateConfigName(name, itemLabel);
|
|
514
|
+
return result;
|
|
315
515
|
}
|
|
316
516
|
|
|
317
|
-
function
|
|
318
|
-
value
|
|
319
|
-
|
|
320
|
-
|
|
517
|
+
function requireOwnObject(value: Record<string, unknown>, key: string, label: string) {
|
|
518
|
+
const item = ownObject(value, key);
|
|
519
|
+
if (!item) throw new Error(`Invalid pi-sync settings: ${label} “${key}” must be an object.`);
|
|
520
|
+
return item;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function ownObject<T extends object>(value: Record<string, unknown>, key: string): T | undefined {
|
|
321
524
|
if (!Object.hasOwn(value, key)) return undefined;
|
|
322
525
|
const item = value[key];
|
|
323
|
-
return item && typeof item === "object" && !Array.isArray(item)
|
|
324
|
-
? (item as Record<string, unknown>)
|
|
325
|
-
: undefined;
|
|
526
|
+
return item && typeof item === "object" && !Array.isArray(item) ? (item as T) : undefined;
|
|
326
527
|
}
|
|
327
528
|
|
|
328
|
-
function
|
|
529
|
+
function requireRecord(value: unknown, field: string): Record<string, unknown> {
|
|
530
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
531
|
+
throw new Error(`Invalid pi-sync settings: ${field} must be an object.`);
|
|
532
|
+
}
|
|
533
|
+
return value as Record<string, unknown>;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export function validateConfigName(value: string, field: string) {
|
|
329
537
|
if (
|
|
330
538
|
!value.trim() ||
|
|
539
|
+
value !== value.trim() ||
|
|
331
540
|
value.length > 100 ||
|
|
332
541
|
value === "__proto__" ||
|
|
333
542
|
value === "prototype" ||
|
|
334
543
|
value === "constructor" ||
|
|
335
|
-
|
|
336
|
-
/[\u0000-\u001f\u007f-\u009f]/u.test(value)
|
|
544
|
+
hasControlCharacter(value)
|
|
337
545
|
) {
|
|
338
546
|
throw new Error(`Invalid pi-sync settings: invalid ${field} name.`);
|
|
339
547
|
}
|
|
340
548
|
}
|
|
341
549
|
|
|
342
|
-
function
|
|
343
|
-
if (value
|
|
344
|
-
|
|
550
|
+
function requiredString(value: unknown, field: string) {
|
|
551
|
+
if (typeof value !== "string" || !value.trim() || hasControlCharacter(value)) {
|
|
552
|
+
throw new Error(`Invalid pi-sync settings: ${field} must be a non-empty string.`);
|
|
553
|
+
}
|
|
554
|
+
return value.trim();
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function requiredCanonicalReference(value: unknown, field: string) {
|
|
558
|
+
const normalized = requiredString(value, field);
|
|
559
|
+
if (value !== normalized) {
|
|
560
|
+
throw new Error(`Invalid pi-sync settings: ${field} must not have surrounding whitespace.`);
|
|
561
|
+
}
|
|
562
|
+
return normalized;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function optionalCanonicalReference(value: unknown, field: string) {
|
|
566
|
+
const normalized = optionalString(value, field);
|
|
567
|
+
if (normalized !== undefined && value !== normalized) {
|
|
568
|
+
throw new Error(`Invalid pi-sync settings: ${field} must not have surrounding whitespace.`);
|
|
569
|
+
}
|
|
570
|
+
return normalized;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function requiredSecret(value: unknown, field: string) {
|
|
574
|
+
if (typeof value !== "string" || !value || hasControlCharacter(value)) {
|
|
575
|
+
throw new Error(`Invalid pi-sync settings: ${field} must be configured.`);
|
|
576
|
+
}
|
|
345
577
|
return value;
|
|
346
578
|
}
|
|
347
579
|
|
|
348
|
-
function
|
|
580
|
+
function optionalString(value: unknown, field: string) {
|
|
349
581
|
if (value === undefined) return undefined;
|
|
350
|
-
if (typeof value !== "
|
|
351
|
-
|
|
582
|
+
if (typeof value !== "string" || hasControlCharacter(value)) {
|
|
583
|
+
throw new Error(`Invalid pi-sync settings: ${field} must be a string.`);
|
|
584
|
+
}
|
|
585
|
+
return value.trim() || undefined;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
export function normalizeStoragePath(value: string) {
|
|
589
|
+
const normalized = value.trim().replace(/^\/+|\/+$/gu, "");
|
|
590
|
+
if (
|
|
591
|
+
!normalized ||
|
|
592
|
+
normalized.length > 1024 ||
|
|
593
|
+
normalized.startsWith("-") ||
|
|
594
|
+
normalized.includes("\\") ||
|
|
595
|
+
hasControlCharacter(normalized) ||
|
|
596
|
+
normalized.split("/").some((segment) => !segment || segment === "." || segment === "..")
|
|
597
|
+
) {
|
|
598
|
+
throw new Error("Invalid pi-sync settings: storage.path must be a safe relative path.");
|
|
599
|
+
}
|
|
600
|
+
return normalized;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function normalizeS3Endpoint(value: string) {
|
|
604
|
+
let url: URL;
|
|
605
|
+
try {
|
|
606
|
+
url = new URL(value.trim());
|
|
607
|
+
} catch {
|
|
608
|
+
throw new Error("Invalid pi-sync S3 endpoint.");
|
|
609
|
+
}
|
|
610
|
+
const loopback =
|
|
611
|
+
url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]";
|
|
612
|
+
if (
|
|
613
|
+
(url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) ||
|
|
614
|
+
url.username ||
|
|
615
|
+
url.password ||
|
|
616
|
+
url.search ||
|
|
617
|
+
url.hash
|
|
618
|
+
) {
|
|
619
|
+
throw new Error("Invalid pi-sync S3 endpoint: HTTPS is required except for loopback.");
|
|
620
|
+
}
|
|
621
|
+
url.pathname = url.pathname.replace(/\/+$/gu, "");
|
|
622
|
+
return url.toString().replace(/\/$/u, "");
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function normalizeS3Bucket(value: string | undefined) {
|
|
626
|
+
const bucket = requiredString(value, "S3 bucket");
|
|
627
|
+
if (bucket.includes("/") || bucket.includes("\\") || bucket.startsWith("-")) {
|
|
628
|
+
throw new Error("Invalid pi-sync S3 bucket.");
|
|
629
|
+
}
|
|
630
|
+
return bucket;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function normalizeEndpointIdentity(endpoint: string) {
|
|
634
|
+
try {
|
|
635
|
+
const url = new URL(endpoint.trim());
|
|
636
|
+
url.hostname = url.hostname.toLowerCase();
|
|
637
|
+
url.pathname = url.pathname.replace(/\/+$/gu, "");
|
|
638
|
+
return url.toString().replace(/\/$/u, "");
|
|
639
|
+
} catch {
|
|
640
|
+
return endpoint.trim();
|
|
641
|
+
}
|
|
352
642
|
}
|
|
353
643
|
|
|
354
644
|
export async function configuredSessionDir() {
|
|
355
|
-
const envSessionDir = normalizeOptionalString(process.env.PI_CODING_AGENT_SESSION_DIR);
|
|
356
|
-
if (envSessionDir) return expandHome(envSessionDir);
|
|
357
645
|
const settings = await readJsonIfExists<{ sessionDir?: string }>(
|
|
358
646
|
path.join(agentDir(), "settings.json"),
|
|
359
647
|
);
|
|
@@ -365,9 +653,6 @@ export async function sessionDirForApply(
|
|
|
365
653
|
snapshot: Snapshot,
|
|
366
654
|
) {
|
|
367
655
|
const contextSessionDir = sessionDirFromContext(ctx);
|
|
368
|
-
const envSessionDir = normalizeOptionalString(process.env.PI_CODING_AGENT_SESSION_DIR);
|
|
369
|
-
if (envSessionDir) return contextSessionDir ?? expandHome(envSessionDir);
|
|
370
|
-
|
|
371
656
|
const localSessionDir = await configuredSessionDir();
|
|
372
657
|
if (
|
|
373
658
|
contextSessionDir &&
|
|
@@ -391,10 +676,17 @@ function sessionDirFromSnapshot(snapshot: Snapshot) {
|
|
|
391
676
|
}
|
|
392
677
|
}
|
|
393
678
|
|
|
679
|
+
function decodeBase64Strict(value: string, filePath: string) {
|
|
680
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 !== 0) {
|
|
681
|
+
throw new Error(`Invalid base64 content in snapshot file: ${filePath}`);
|
|
682
|
+
}
|
|
683
|
+
return Buffer.from(value, "base64");
|
|
684
|
+
}
|
|
685
|
+
|
|
394
686
|
export async function readState(profile: string): Promise<SyncState> {
|
|
395
687
|
return (
|
|
396
688
|
(await readJsonIfExists<SyncState>(statePath(profile))) ?? {
|
|
397
|
-
version:
|
|
689
|
+
version: STATE_VERSION,
|
|
398
690
|
profile,
|
|
399
691
|
lastFileHashes: {},
|
|
400
692
|
}
|
|
@@ -405,96 +697,49 @@ export async function writeState(profile: string, state: SyncState) {
|
|
|
405
697
|
await writeJson(statePath(profile), state);
|
|
406
698
|
}
|
|
407
699
|
|
|
408
|
-
export async function readStateForConfig(config:
|
|
409
|
-
if (config.settingsVersion !== 2) return readState(config.profile);
|
|
410
|
-
const destination = statePathForConfig(config);
|
|
411
|
-
const state = await readJsonIfExists<SyncState>(destination);
|
|
412
|
-
if (state) return state;
|
|
700
|
+
export async function readStateForConfig(config: AnySyncConfig): Promise<SyncState> {
|
|
413
701
|
return (
|
|
414
|
-
(await
|
|
415
|
-
version:
|
|
416
|
-
profile: config.
|
|
702
|
+
(await readJsonIfExists<SyncState>(statePathForConfig(config))) ?? {
|
|
703
|
+
version: STATE_VERSION,
|
|
704
|
+
profile: config.snapshotIdentity,
|
|
417
705
|
lastFileHashes: {},
|
|
418
706
|
}
|
|
419
707
|
);
|
|
420
708
|
}
|
|
421
709
|
|
|
422
|
-
export async function writeStateForConfig(config:
|
|
710
|
+
export async function writeStateForConfig(config: AnySyncConfig, state: SyncState) {
|
|
423
711
|
await writeJson(statePathForConfig(config), state);
|
|
424
712
|
}
|
|
425
713
|
|
|
426
|
-
export function statePathForConfig(config:
|
|
427
|
-
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
const legacyHash = createHash("sha256").update(target).digest("hex").slice(0, 10);
|
|
456
|
-
const legacyPath = path.join(
|
|
457
|
-
stateDir(),
|
|
458
|
-
"targets",
|
|
459
|
-
`${safeName(target)}-${legacyHash}.state.json`,
|
|
460
|
-
);
|
|
461
|
-
const claimPath = `${legacyPath}.migrating-to-${path.basename(destination)}`;
|
|
462
|
-
if (!(await pathExists(claimPath))) {
|
|
463
|
-
try {
|
|
464
|
-
await fs.rename(legacyPath, claimPath);
|
|
465
|
-
} catch (error) {
|
|
466
|
-
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
if (!(await pathExists(claimPath))) return readJsonIfExists<SyncState>(destination);
|
|
470
|
-
|
|
471
|
-
await fs.mkdir(path.dirname(destination), { recursive: true });
|
|
472
|
-
try {
|
|
473
|
-
await fs.link(claimPath, destination);
|
|
474
|
-
} catch (error) {
|
|
475
|
-
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
476
|
-
}
|
|
477
|
-
const migrated = await readJsonIfExists<SyncState>(destination);
|
|
478
|
-
if (migrated) await fs.rm(claimPath, { force: true });
|
|
479
|
-
return migrated;
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
async function pathExists(filePath: string) {
|
|
483
|
-
try {
|
|
484
|
-
await fs.lstat(filePath);
|
|
485
|
-
return true;
|
|
486
|
-
} catch (error) {
|
|
487
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
488
|
-
throw error;
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
function normalizeEndpointIdentity(endpoint: string) {
|
|
493
|
-
const normalized = endpoint.trim();
|
|
494
|
-
try {
|
|
495
|
-
return new URL(normalized).toString();
|
|
496
|
-
} catch {
|
|
497
|
-
return normalized;
|
|
714
|
+
export function statePathForConfig(config: AnySyncConfig) {
|
|
715
|
+
const identity = backendIdentityCoordinates(config);
|
|
716
|
+
const hash = createHash("sha256").update(identity).digest("hex").slice(0, 16);
|
|
717
|
+
return path.join(stateDir(), "setups", `${config.backend.type}-${hash}.state.json`);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function backendIdentityCoordinates(config: AnySyncConfig) {
|
|
721
|
+
switch (config.backend.type) {
|
|
722
|
+
case "s3":
|
|
723
|
+
return JSON.stringify([
|
|
724
|
+
"s3",
|
|
725
|
+
normalizeEndpointIdentity(config.backend.profile.endpoint),
|
|
726
|
+
config.backend.destination.bucket,
|
|
727
|
+
config.storagePath,
|
|
728
|
+
]);
|
|
729
|
+
case "git":
|
|
730
|
+
return JSON.stringify([
|
|
731
|
+
"git",
|
|
732
|
+
normalizeGitRemoteIdentity(config.backend.profile.remote),
|
|
733
|
+
config.backend.destination.branch,
|
|
734
|
+
config.storagePath,
|
|
735
|
+
]);
|
|
736
|
+
case "webdav":
|
|
737
|
+
return JSON.stringify([
|
|
738
|
+
"webdav",
|
|
739
|
+
normalizeWebDavIdentityUrl(config.backend.profile.url),
|
|
740
|
+
config.backend.profile.username,
|
|
741
|
+
config.storagePath,
|
|
742
|
+
]);
|
|
498
743
|
}
|
|
499
744
|
}
|
|
500
745
|
|
|
@@ -510,128 +755,37 @@ export function stateDir() {
|
|
|
510
755
|
return path.join(agentDir(), ".pisync");
|
|
511
756
|
}
|
|
512
757
|
|
|
513
|
-
export function localConfigTemplate():
|
|
758
|
+
export function localConfigTemplate(): PiSyncSettingsV3 {
|
|
514
759
|
return {
|
|
515
|
-
version:
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
[DEFAULT_PROFILE]: {
|
|
520
|
-
kind: "r2",
|
|
521
|
-
endpoint: "https://<account-id>.r2.cloudflarestorage.com",
|
|
522
|
-
region: DEFAULT_REGION,
|
|
523
|
-
accessKeyId: "<access-key-id>",
|
|
524
|
-
secretAccessKey: "<secret-access-key>",
|
|
525
|
-
},
|
|
526
|
-
},
|
|
527
|
-
targets: {
|
|
528
|
-
[DEFAULT_PROFILE]: {
|
|
529
|
-
profile: DEFAULT_PROFILE,
|
|
530
|
-
bucket: "pi-sync",
|
|
531
|
-
namespace: DEFAULT_PROFILE,
|
|
532
|
-
prefix: DEFAULT_PREFIX,
|
|
533
|
-
autoSync: true,
|
|
534
|
-
syncFiles: [...DEFAULT_SYNC_FILES],
|
|
535
|
-
syncSessions: false,
|
|
536
|
-
extraFiles: [],
|
|
537
|
-
},
|
|
538
|
-
},
|
|
760
|
+
version: 3,
|
|
761
|
+
onSwitch: DEFAULT_ON_SWITCH,
|
|
762
|
+
storageConnections: {},
|
|
763
|
+
syncSetups: {},
|
|
539
764
|
};
|
|
540
765
|
}
|
|
541
766
|
|
|
542
767
|
export async function readLocalConfigDocument() {
|
|
543
|
-
|
|
768
|
+
const document = await readMigratingLocalConfigDocument((settings) => {
|
|
769
|
+
validateSettingsDocument(settings);
|
|
770
|
+
});
|
|
771
|
+
if (document) validateSettingsDocument(document.parsed);
|
|
772
|
+
return document;
|
|
544
773
|
}
|
|
545
774
|
|
|
546
|
-
export async function readLocalConfigObject(): Promise<
|
|
547
|
-
return (await readLocalConfigDocument())?.parsed;
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
function validateConfigDocumentForMigration(settings: Record<string, unknown>) {
|
|
551
|
-
if (!isV2SettingsObject(settings)) {
|
|
552
|
-
for (const field of [
|
|
553
|
-
"endpoint",
|
|
554
|
-
"bucket",
|
|
555
|
-
"region",
|
|
556
|
-
"accessKeyId",
|
|
557
|
-
"secretAccessKey",
|
|
558
|
-
"sessionToken",
|
|
559
|
-
"profile",
|
|
560
|
-
"prefix",
|
|
561
|
-
] as const) {
|
|
562
|
-
asOptionalString(settings[field]);
|
|
563
|
-
}
|
|
564
|
-
for (const field of ["autoSync", "syncSessions"] as const) {
|
|
565
|
-
const value = settings[field];
|
|
566
|
-
if (value !== undefined && typeof value !== "boolean" && typeof value !== "string") {
|
|
567
|
-
throw new Error(`Invalid pi-sync settings: ${field} must be a boolean or string.`);
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
normalizeSyncFiles(settings.syncFiles);
|
|
571
|
-
normalizeExtraFiles(settings.extraFiles);
|
|
572
|
-
return;
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
normalizeTargetSwitchAction(settings.targetSwitchAction);
|
|
576
|
-
const profiles = requireNamedObjectMap(settings.profiles, "profiles");
|
|
577
|
-
const targets = requireNamedObjectMap(settings.targets, "targets");
|
|
578
|
-
validateUniqueRemoteTargets(targets);
|
|
579
|
-
for (const [name, value] of Object.entries(profiles)) {
|
|
580
|
-
const profile = ownObject(profiles, name);
|
|
581
|
-
if (!profile || value !== profile) {
|
|
582
|
-
throw new Error(`Invalid pi-sync settings: storage profile "${name}" must be an object.`);
|
|
583
|
-
}
|
|
584
|
-
const kind = asOptionalString(profile.kind);
|
|
585
|
-
if (kind !== undefined && kind !== "r2" && kind !== "s3-compatible") {
|
|
586
|
-
throw new Error(
|
|
587
|
-
`Invalid pi-sync settings: profile "${name}" has unsupported kind "${kind}".`,
|
|
588
|
-
);
|
|
589
|
-
}
|
|
590
|
-
for (const field of [
|
|
591
|
-
"endpoint",
|
|
592
|
-
"region",
|
|
593
|
-
"accessKeyId",
|
|
594
|
-
"secretAccessKey",
|
|
595
|
-
"sessionToken",
|
|
596
|
-
] as const) {
|
|
597
|
-
asOptionalString(profile[field]);
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
for (const [name, value] of Object.entries(targets)) {
|
|
601
|
-
const target = ownObject(targets, name);
|
|
602
|
-
if (!target || value !== target) {
|
|
603
|
-
throw new Error(`Invalid pi-sync settings: target "${name}" must be an object.`);
|
|
604
|
-
}
|
|
605
|
-
const profileName = normalizeOptionalString(asOptionalString(target.profile));
|
|
606
|
-
if (!profileName || !Object.hasOwn(profiles, profileName)) {
|
|
607
|
-
throw new Error(`Invalid pi-sync settings: target "${name}" references a missing profile.`);
|
|
608
|
-
}
|
|
609
|
-
for (const field of ["bucket", "prefix", "namespace"] as const) {
|
|
610
|
-
asOptionalString(target[field]);
|
|
611
|
-
}
|
|
612
|
-
asOptionalBoolean(target.autoSync);
|
|
613
|
-
asOptionalBoolean(target.syncSessions);
|
|
614
|
-
normalizeSyncFiles(target.syncFiles);
|
|
615
|
-
normalizeExtraFiles(target.extraFiles);
|
|
616
|
-
}
|
|
617
|
-
const activeTarget = normalizeOptionalString(asOptionalString(settings.activeTarget));
|
|
618
|
-
if (Object.keys(targets).length === 0) {
|
|
619
|
-
if (activeTarget) {
|
|
620
|
-
throw new Error("Invalid pi-sync settings: targetless settings cannot have activeTarget.");
|
|
621
|
-
}
|
|
622
|
-
return;
|
|
623
|
-
}
|
|
624
|
-
if (!activeTarget || !Object.hasOwn(targets, activeTarget)) {
|
|
625
|
-
throw new Error("Invalid pi-sync settings: activeTarget must reference an existing target.");
|
|
626
|
-
}
|
|
775
|
+
export async function readLocalConfigObject(): Promise<PiSyncSettingsV3 | undefined> {
|
|
776
|
+
return (await readLocalConfigDocument())?.parsed as PiSyncSettingsV3 | undefined;
|
|
627
777
|
}
|
|
628
778
|
|
|
629
779
|
let configUpdateQueue: Promise<void> = Promise.resolve();
|
|
630
780
|
|
|
631
781
|
export function updateLocalConfig(
|
|
632
|
-
update: (current:
|
|
782
|
+
update: (current: PiSyncSettingsV3) => PiSyncSettingsV3,
|
|
783
|
+
signal?: AbortSignal,
|
|
633
784
|
) {
|
|
634
|
-
const operation = configUpdateQueue.then(() =>
|
|
785
|
+
const operation = configUpdateQueue.then(() => {
|
|
786
|
+
signal?.throwIfAborted();
|
|
787
|
+
return performLocalConfigUpdate(update, signal);
|
|
788
|
+
});
|
|
635
789
|
configUpdateQueue = operation.then(
|
|
636
790
|
() => undefined,
|
|
637
791
|
() => undefined,
|
|
@@ -640,30 +794,24 @@ export function updateLocalConfig(
|
|
|
640
794
|
}
|
|
641
795
|
|
|
642
796
|
async function performLocalConfigUpdate(
|
|
643
|
-
update: (current:
|
|
797
|
+
update: (current: PiSyncSettingsV3) => PiSyncSettingsV3,
|
|
798
|
+
signal?: AbortSignal,
|
|
644
799
|
) {
|
|
645
|
-
|
|
646
|
-
const next = update({ ...current });
|
|
647
|
-
await writeLocalConfigObject(next);
|
|
648
|
-
return next;
|
|
800
|
+
return updateLocalConfigDocument(localConfigTemplate(), update, validateSettingsDocument, signal);
|
|
649
801
|
}
|
|
650
802
|
|
|
651
|
-
export function writeLocalConfigObject(value: Record<string, unknown>) {
|
|
652
|
-
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
async function writeLocalConfigObjectUnlocked(value: Record<string, unknown>) {
|
|
803
|
+
export async function writeLocalConfigObject(value: PiSyncSettingsV3 | Record<string, unknown>) {
|
|
804
|
+
validateSettingsDocument(value as Record<string, unknown>);
|
|
656
805
|
const configPath = localConfigPath();
|
|
657
806
|
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
658
807
|
try {
|
|
659
808
|
const stat = await fs.lstat(configPath);
|
|
660
809
|
if (stat.isSymbolicLink())
|
|
661
|
-
throw new Error(`Refusing to overwrite symlinked pi-sync
|
|
662
|
-
if (!stat.isFile()) throw new Error(`pi-sync
|
|
810
|
+
throw new Error(`Refusing to overwrite symlinked pi-sync settings: ${configPath}`);
|
|
811
|
+
if (!stat.isFile()) throw new Error(`pi-sync settings are not a regular file: ${configPath}`);
|
|
663
812
|
} catch (error) {
|
|
664
813
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
665
814
|
}
|
|
666
|
-
|
|
667
815
|
const temporaryPath = path.join(
|
|
668
816
|
path.dirname(configPath),
|
|
669
817
|
`.${path.basename(configPath)}.${process.pid}.${randomUUID()}.tmp`,
|
|
@@ -707,16 +855,10 @@ async function readJsonIfExists<T>(filePath: string): Promise<T | undefined> {
|
|
|
707
855
|
|
|
708
856
|
export async function writeJson(filePath: string, value: unknown) {
|
|
709
857
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
710
|
-
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
if (hasEnv("PI_SYNC_SESSION_TOKEN"))
|
|
715
|
-
return normalizeOptionalString(process.env.PI_SYNC_SESSION_TOKEN);
|
|
716
|
-
return (
|
|
717
|
-
normalizeOptionalString(process.env.AWS_SESSION_TOKEN) ??
|
|
718
|
-
normalizeOptionalString(fileSessionToken)
|
|
719
|
-
);
|
|
858
|
+
const temp = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
859
|
+
await fs.writeFile(temp, `${JSON.stringify(value, null, "\t")}\n`, { mode: 0o600 });
|
|
860
|
+
if (process.platform !== "win32") await fs.chmod(temp, 0o600);
|
|
861
|
+
await fs.rename(temp, filePath);
|
|
720
862
|
}
|
|
721
863
|
|
|
722
864
|
export function sessionTokenWarnings(config: { endpoint?: string; sessionToken?: string }) {
|
|
@@ -726,10 +868,10 @@ export function sessionTokenWarnings(config: { endpoint?: string; sessionToken?:
|
|
|
726
868
|
];
|
|
727
869
|
}
|
|
728
870
|
|
|
729
|
-
export function syncSessionsWarnings(config: {
|
|
730
|
-
if (!config.
|
|
871
|
+
export function syncSessionsWarnings(config: { include: readonly string[] }) {
|
|
872
|
+
if (!config.include.includes("sessions")) return [];
|
|
731
873
|
return [
|
|
732
|
-
"sessions:
|
|
874
|
+
"sessions: included; Pi session JSONL can contain prompts, tool output, file paths, images, and secrets. Sync sessions only to storage you trust.",
|
|
733
875
|
];
|
|
734
876
|
}
|
|
735
877
|
|
|
@@ -746,22 +888,6 @@ export function isCloudflareR2Endpoint(endpoint: string | undefined) {
|
|
|
746
888
|
}
|
|
747
889
|
}
|
|
748
890
|
|
|
749
|
-
function normalizeOptionalString(value: string | undefined) {
|
|
750
|
-
const normalized = value?.trim();
|
|
751
|
-
return normalized ? normalized : undefined;
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
function normalizeConfiguredString(value: string | undefined) {
|
|
755
|
-
const normalized = normalizeOptionalString(value);
|
|
756
|
-
return normalized && !/^<[^>]+>$/u.test(normalized) && !normalized.includes("<account-id>")
|
|
757
|
-
? normalized
|
|
758
|
-
: undefined;
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
function hasEnv(name: string) {
|
|
762
|
-
return Object.hasOwn(process.env, name);
|
|
763
|
-
}
|
|
764
|
-
|
|
765
891
|
export function isEnabled(value: boolean | string | undefined, defaultValue: boolean) {
|
|
766
892
|
if (value === undefined) return defaultValue;
|
|
767
893
|
if (typeof value === "boolean") return value;
|
|
@@ -773,6 +899,9 @@ export function isExplicitlyEnabled(value: boolean | string | undefined) {
|
|
|
773
899
|
return ["1", "true", "yes", "on"].includes(value?.trim().toLowerCase() ?? "");
|
|
774
900
|
}
|
|
775
901
|
|
|
776
|
-
|
|
777
|
-
|
|
902
|
+
function hasControlCharacter(value: string) {
|
|
903
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: Stored settings cannot contain controls.
|
|
904
|
+
return /[\u0000-\u001f\u007f-\u009f]/u.test(value);
|
|
778
905
|
}
|
|
906
|
+
|
|
907
|
+
export { isMissingConfigError } from "./config-errors.js";
|