@narumitw/pi-sync 0.25.0 → 0.29.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 +238 -121
- package/package.json +7 -2
- package/src/command.ts +162 -24
- package/src/config.ts +395 -89
- package/src/file-selection.ts +280 -0
- package/src/index.ts +1 -0
- package/src/lock.ts +3 -3
- package/src/manager-ui.ts +961 -0
- package/src/s3-client.ts +47 -13
- package/src/settings-management.ts +307 -0
- package/src/snapshot-apply.ts +41 -17
- package/src/snapshot-transaction.ts +201 -0
- package/src/snapshot.ts +76 -95
- package/src/sync-format.ts +168 -0
- package/src/sync-policy.ts +129 -0
- package/src/sync-state.ts +167 -0
- package/src/sync.ts +357 -351
- package/src/types.ts +47 -0
package/src/config.ts
CHANGED
|
@@ -1,19 +1,35 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import
|
|
6
|
-
|
|
5
|
+
import {
|
|
6
|
+
type ExtensionCommandContext,
|
|
7
|
+
type ExtensionContext,
|
|
8
|
+
getAgentDir,
|
|
9
|
+
} from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { safeName } from "./paths.js";
|
|
11
|
+
import { DEFAULT_SYNC_FILES, normalizeExtraFiles, normalizeSyncFiles } from "./sync-policy.js";
|
|
7
12
|
import type { PartialConfig, Snapshot, SyncConfig, SyncState } from "./types.js";
|
|
8
13
|
|
|
14
|
+
export { extraFilePathsByLower, normalizeExtraFiles, normalizeSyncFiles } from "./sync-policy.js";
|
|
15
|
+
|
|
9
16
|
const VERSION = 1;
|
|
10
17
|
const DEFAULT_PROFILE = "default";
|
|
11
18
|
const DEFAULT_PREFIX = "pi-sync";
|
|
12
19
|
const DEFAULT_REGION = "auto";
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
20
|
+
|
|
21
|
+
export const DEPRECATED_PI_SYNC_ENV_NAMES = [
|
|
22
|
+
"PI_SYNC_ENDPOINT",
|
|
23
|
+
"PI_SYNC_BUCKET",
|
|
24
|
+
"PI_SYNC_REGION",
|
|
25
|
+
"PI_SYNC_ACCESS_KEY_ID",
|
|
26
|
+
"PI_SYNC_SECRET_ACCESS_KEY",
|
|
27
|
+
"PI_SYNC_SESSION_TOKEN",
|
|
28
|
+
"PI_SYNC_PROFILE",
|
|
29
|
+
"PI_SYNC_PREFIX",
|
|
30
|
+
"PI_SYNC_AUTO_SYNC",
|
|
31
|
+
"PI_SYNC_SESSIONS",
|
|
32
|
+
] as const;
|
|
17
33
|
|
|
18
34
|
function trimSlashes(value: string) {
|
|
19
35
|
return value.replace(/^\/+|\/+$/g, "");
|
|
@@ -40,12 +56,12 @@ function sessionDirFromContext(ctx: ExtensionCommandContext | ExtensionContext)
|
|
|
40
56
|
: undefined;
|
|
41
57
|
}
|
|
42
58
|
|
|
43
|
-
export async function loadConfigInternal(): Promise<SyncConfig> {
|
|
44
|
-
const partial = await loadPartialConfig();
|
|
45
|
-
const endpoint = partial.endpoint;
|
|
46
|
-
const bucket = partial.bucket;
|
|
47
|
-
const accessKeyId = partial.accessKeyId;
|
|
48
|
-
const secretAccessKey = partial.secretAccessKey;
|
|
59
|
+
export async function loadConfigInternal(targetName?: string): Promise<SyncConfig> {
|
|
60
|
+
const partial = await loadPartialConfig(targetName);
|
|
61
|
+
const endpoint = normalizeConfiguredString(partial.endpoint);
|
|
62
|
+
const bucket = normalizeConfiguredString(partial.bucket);
|
|
63
|
+
const accessKeyId = normalizeConfiguredString(partial.accessKeyId);
|
|
64
|
+
const secretAccessKey = normalizeConfiguredString(partial.secretAccessKey);
|
|
49
65
|
const missing = [
|
|
50
66
|
["endpoint", endpoint],
|
|
51
67
|
["bucket", bucket],
|
|
@@ -55,59 +71,267 @@ export async function loadConfigInternal(): Promise<SyncConfig> {
|
|
|
55
71
|
.filter(([, value]) => !value)
|
|
56
72
|
.map(([name]) => name);
|
|
57
73
|
if (missing.length > 0) {
|
|
58
|
-
throw new Error(
|
|
74
|
+
throw new Error(
|
|
75
|
+
`Missing pi-sync config: ${missing.join(", ")}. Use /sync setup or edit ${localConfigPath()}.`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (!endpoint || !bucket || !accessKeyId || !secretAccessKey) {
|
|
79
|
+
throw new Error("Missing pi-sync config after validation.");
|
|
59
80
|
}
|
|
60
81
|
|
|
61
82
|
return {
|
|
62
|
-
endpoint
|
|
63
|
-
bucket
|
|
64
|
-
region: partial.region ?? DEFAULT_REGION,
|
|
65
|
-
accessKeyId
|
|
66
|
-
secretAccessKey
|
|
67
|
-
sessionToken: partial.sessionToken,
|
|
68
|
-
profile: partial.profile ?? DEFAULT_PROFILE,
|
|
69
|
-
prefix: trimSlashes(partial.prefix ?? DEFAULT_PREFIX),
|
|
83
|
+
endpoint,
|
|
84
|
+
bucket,
|
|
85
|
+
region: normalizeOptionalString(partial.region) ?? DEFAULT_REGION,
|
|
86
|
+
accessKeyId,
|
|
87
|
+
secretAccessKey,
|
|
88
|
+
sessionToken: normalizeOptionalString(partial.sessionToken),
|
|
89
|
+
profile: normalizeOptionalString(partial.profile) ?? DEFAULT_PROFILE,
|
|
90
|
+
prefix: trimSlashes(normalizeOptionalString(partial.prefix) ?? DEFAULT_PREFIX),
|
|
91
|
+
target: partial.target ?? DEFAULT_PROFILE,
|
|
92
|
+
storageProfile: partial.storageProfile ?? DEFAULT_PROFILE,
|
|
93
|
+
storageKind: partial.storageKind,
|
|
94
|
+
autoSync: isEnabled(partial.autoSync, true),
|
|
95
|
+
settingsVersion: partial.settingsVersion ?? 1,
|
|
96
|
+
syncFiles: normalizeSyncFiles(partial.syncFiles),
|
|
70
97
|
syncSessions: isExplicitlyEnabled(partial.syncSessions),
|
|
71
98
|
extraFiles: normalizeExtraFiles(partial.extraFiles),
|
|
72
99
|
};
|
|
73
100
|
}
|
|
74
101
|
|
|
75
|
-
export async function loadConfig(): Promise<SyncConfig> {
|
|
76
|
-
return loadConfigInternal();
|
|
102
|
+
export async function loadConfig(targetName?: string): Promise<SyncConfig> {
|
|
103
|
+
return loadConfigInternal(targetName);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function configuredTargetNames() {
|
|
107
|
+
const settings = await readLocalConfigObject();
|
|
108
|
+
if (!settings) return [];
|
|
109
|
+
if (!isV2SettingsObject(settings)) return [];
|
|
110
|
+
return Object.keys(requireNamedObjectMap(settings.targets, "targets")).sort((left, right) =>
|
|
111
|
+
left.localeCompare(right),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function loadPartialConfig(targetName?: string): Promise<PartialConfig> {
|
|
116
|
+
const fileConfig = (await readLocalConfigObject()) ?? {};
|
|
117
|
+
if (isV2SettingsObject(fileConfig)) return resolveV2PartialConfig(fileConfig, targetName);
|
|
118
|
+
if (targetName !== undefined) {
|
|
119
|
+
throw new Error("--target requires version 2 pi-sync settings with named targets.");
|
|
120
|
+
}
|
|
121
|
+
return resolveLegacyPartialConfig(fileConfig as PartialConfig);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function deprecatedPiSyncEnvironmentNames() {
|
|
125
|
+
return DEPRECATED_PI_SYNC_ENV_NAMES.filter((name) => hasEnv(name));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function deprecatedPiSyncEnvironmentWarnings() {
|
|
129
|
+
const names = deprecatedPiSyncEnvironmentNames();
|
|
130
|
+
if (names.length === 0) return [];
|
|
131
|
+
return [
|
|
132
|
+
`deprecated environment: ${names.join(", ")} still override pi-sync settings but will be removed in a future major version. Move them to ${localConfigPath()}; values are not shown.`,
|
|
133
|
+
];
|
|
77
134
|
}
|
|
78
135
|
|
|
79
|
-
|
|
80
|
-
const fileConfig = (await readJsonIfExists<PartialConfig>(localConfigPath())) ?? {};
|
|
136
|
+
function resolveLegacyPartialConfig(fileConfig: PartialConfig): PartialConfig {
|
|
81
137
|
return {
|
|
82
138
|
...fileConfig,
|
|
139
|
+
target: DEFAULT_PROFILE,
|
|
140
|
+
storageProfile: DEFAULT_PROFILE,
|
|
141
|
+
settingsVersion: 1,
|
|
83
142
|
endpoint: process.env.PI_SYNC_ENDPOINT ?? process.env.R2_ENDPOINT ?? fileConfig.endpoint,
|
|
84
143
|
bucket: process.env.PI_SYNC_BUCKET ?? process.env.R2_BUCKET ?? fileConfig.bucket,
|
|
85
144
|
region: process.env.PI_SYNC_REGION ?? process.env.AWS_REGION ?? fileConfig.region,
|
|
86
|
-
accessKeyId:
|
|
87
|
-
|
|
145
|
+
accessKeyId:
|
|
146
|
+
process.env.PI_SYNC_ACCESS_KEY_ID ?? process.env.AWS_ACCESS_KEY_ID ?? fileConfig.accessKeyId,
|
|
147
|
+
secretAccessKey:
|
|
148
|
+
process.env.PI_SYNC_SECRET_ACCESS_KEY ??
|
|
149
|
+
process.env.AWS_SECRET_ACCESS_KEY ??
|
|
150
|
+
fileConfig.secretAccessKey,
|
|
88
151
|
sessionToken: selectSessionToken(fileConfig.sessionToken),
|
|
89
152
|
profile: process.env.PI_SYNC_PROFILE ?? fileConfig.profile,
|
|
90
153
|
prefix: process.env.PI_SYNC_PREFIX ?? fileConfig.prefix,
|
|
91
154
|
autoSync: process.env.PI_SYNC_AUTO_SYNC ?? fileConfig.autoSync,
|
|
92
155
|
syncSessions: process.env.PI_SYNC_SESSIONS ?? fileConfig.syncSessions,
|
|
93
|
-
extraFiles: fileConfig.extraFiles,
|
|
94
156
|
};
|
|
95
157
|
}
|
|
96
158
|
|
|
159
|
+
function resolveV2PartialConfig(
|
|
160
|
+
settings: Record<string, unknown>,
|
|
161
|
+
targetName?: string,
|
|
162
|
+
): PartialConfig {
|
|
163
|
+
const targets = requireNamedObjectMap(settings.targets, "targets");
|
|
164
|
+
const profiles = requireNamedObjectMap(settings.profiles, "profiles");
|
|
165
|
+
validateUniqueRemoteTargets(targets);
|
|
166
|
+
const selectedTarget =
|
|
167
|
+
targetName ?? normalizeOptionalString(asOptionalString(settings.activeTarget));
|
|
168
|
+
if (!selectedTarget) throw new Error("Invalid pi-sync settings: activeTarget is required.");
|
|
169
|
+
validateConfigName(selectedTarget, "target");
|
|
170
|
+
const target = ownObject(targets, selectedTarget);
|
|
171
|
+
if (!target)
|
|
172
|
+
throw new Error(`Invalid pi-sync settings: target "${selectedTarget}" was not found.`);
|
|
173
|
+
const storageProfile = normalizeOptionalString(asOptionalString(target.profile));
|
|
174
|
+
if (!storageProfile) {
|
|
175
|
+
throw new Error(`Invalid pi-sync settings: target "${selectedTarget}" is missing profile.`);
|
|
176
|
+
}
|
|
177
|
+
validateConfigName(storageProfile, "storage profile");
|
|
178
|
+
const profile = ownObject(profiles, storageProfile);
|
|
179
|
+
if (!profile) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`Invalid pi-sync settings: target "${selectedTarget}" references missing profile "${storageProfile}".`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
const kind = asOptionalString(profile.kind);
|
|
185
|
+
if (kind !== undefined && kind !== "r2" && kind !== "s3-compatible") {
|
|
186
|
+
throw new Error(
|
|
187
|
+
`Invalid pi-sync settings: profile "${storageProfile}" has unsupported kind "${kind}".`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
target: selectedTarget,
|
|
192
|
+
storageProfile,
|
|
193
|
+
storageKind: kind,
|
|
194
|
+
settingsVersion: 2,
|
|
195
|
+
endpoint:
|
|
196
|
+
process.env.PI_SYNC_ENDPOINT ?? process.env.R2_ENDPOINT ?? asOptionalString(profile.endpoint),
|
|
197
|
+
bucket: process.env.PI_SYNC_BUCKET ?? process.env.R2_BUCKET ?? asOptionalString(target.bucket),
|
|
198
|
+
region:
|
|
199
|
+
process.env.PI_SYNC_REGION ?? process.env.AWS_REGION ?? asOptionalString(profile.region),
|
|
200
|
+
accessKeyId:
|
|
201
|
+
process.env.PI_SYNC_ACCESS_KEY_ID ??
|
|
202
|
+
process.env.AWS_ACCESS_KEY_ID ??
|
|
203
|
+
asOptionalString(profile.accessKeyId),
|
|
204
|
+
secretAccessKey:
|
|
205
|
+
process.env.PI_SYNC_SECRET_ACCESS_KEY ??
|
|
206
|
+
process.env.AWS_SECRET_ACCESS_KEY ??
|
|
207
|
+
asOptionalString(profile.secretAccessKey),
|
|
208
|
+
sessionToken: selectSessionToken(asOptionalString(profile.sessionToken)),
|
|
209
|
+
profile: process.env.PI_SYNC_PROFILE ?? asOptionalString(target.namespace) ?? selectedTarget,
|
|
210
|
+
prefix: process.env.PI_SYNC_PREFIX ?? asOptionalString(target.prefix),
|
|
211
|
+
autoSync: process.env.PI_SYNC_AUTO_SYNC ?? asOptionalBoolean(target.autoSync),
|
|
212
|
+
syncFiles: target.syncFiles,
|
|
213
|
+
syncSessions: process.env.PI_SYNC_SESSIONS ?? asOptionalBoolean(target.syncSessions),
|
|
214
|
+
extraFiles: target.extraFiles,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function isV2SettingsObject(value: Record<string, unknown>) {
|
|
219
|
+
const hasV2Fields =
|
|
220
|
+
Object.hasOwn(value, "profiles") ||
|
|
221
|
+
Object.hasOwn(value, "targets") ||
|
|
222
|
+
Object.hasOwn(value, "activeTarget");
|
|
223
|
+
if (!hasV2Fields) return false;
|
|
224
|
+
if (value.version !== 2) {
|
|
225
|
+
throw new Error("Invalid pi-sync settings: version must be 2 for profiles and targets.");
|
|
226
|
+
}
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function validateUniqueRemoteTargets(targets: Record<string, unknown>) {
|
|
231
|
+
const identities = new Map<string, string>();
|
|
232
|
+
for (const name of Object.keys(targets)) {
|
|
233
|
+
const target = ownObject(targets, name);
|
|
234
|
+
if (!target || typeof target.profile !== "string" || typeof target.bucket !== "string")
|
|
235
|
+
continue;
|
|
236
|
+
const identity = effectiveTargetRemoteIdentity(target, name);
|
|
237
|
+
const existing = identities.get(identity);
|
|
238
|
+
if (existing) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`Invalid pi-sync settings: targets "${existing}" and "${name}" use the same remote destination.`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
identities.set(identity, name);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function effectiveTargetRemoteIdentity(target: Record<string, unknown>, name: string) {
|
|
248
|
+
const profile = typeof target.profile === "string" ? target.profile.trim() : "";
|
|
249
|
+
const bucket = normalizeRemoteKeySegment(
|
|
250
|
+
process.env.PI_SYNC_BUCKET ??
|
|
251
|
+
process.env.R2_BUCKET ??
|
|
252
|
+
(typeof target.bucket === "string" ? target.bucket : ""),
|
|
253
|
+
);
|
|
254
|
+
const prefix = normalizeRemoteKeySegment(
|
|
255
|
+
process.env.PI_SYNC_PREFIX ??
|
|
256
|
+
(typeof target.prefix === "string" ? target.prefix : DEFAULT_PREFIX),
|
|
257
|
+
);
|
|
258
|
+
const namespace = normalizeRemoteKeySegment(
|
|
259
|
+
process.env.PI_SYNC_PROFILE ?? (typeof target.namespace === "string" ? target.namespace : name),
|
|
260
|
+
);
|
|
261
|
+
return JSON.stringify([profile, bucket, prefix, namespace]);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function normalizeRemoteKeySegment(value: string) {
|
|
265
|
+
return trimSlashes(value.trim());
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function requireNamedObjectMap(value: unknown, field: string): Record<string, unknown> {
|
|
269
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
270
|
+
throw new Error(`Invalid pi-sync settings: ${field} must be an object.`);
|
|
271
|
+
}
|
|
272
|
+
for (const name of Object.keys(value)) validateConfigName(name, field.slice(0, -1));
|
|
273
|
+
return value as Record<string, unknown>;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function ownObject(
|
|
277
|
+
value: Record<string, unknown>,
|
|
278
|
+
key: string,
|
|
279
|
+
): Record<string, unknown> | undefined {
|
|
280
|
+
if (!Object.hasOwn(value, key)) return undefined;
|
|
281
|
+
const item = value[key];
|
|
282
|
+
return item && typeof item === "object" && !Array.isArray(item)
|
|
283
|
+
? (item as Record<string, unknown>)
|
|
284
|
+
: undefined;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function validateConfigName(value: string, field: string) {
|
|
288
|
+
if (
|
|
289
|
+
!value.trim() ||
|
|
290
|
+
value.length > 100 ||
|
|
291
|
+
value === "__proto__" ||
|
|
292
|
+
value === "prototype" ||
|
|
293
|
+
value === "constructor" ||
|
|
294
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: Config identifiers cannot render terminal controls.
|
|
295
|
+
/[\u0000-\u001f\u007f-\u009f]/u.test(value)
|
|
296
|
+
) {
|
|
297
|
+
throw new Error(`Invalid pi-sync settings: invalid ${field} name.`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function asOptionalString(value: unknown) {
|
|
302
|
+
if (value === undefined) return undefined;
|
|
303
|
+
if (typeof value !== "string") throw new Error("Invalid pi-sync settings: expected a string.");
|
|
304
|
+
return value;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function asOptionalBoolean(value: unknown) {
|
|
308
|
+
if (value === undefined) return undefined;
|
|
309
|
+
if (typeof value !== "boolean") throw new Error("Invalid pi-sync settings: expected a boolean.");
|
|
310
|
+
return value;
|
|
311
|
+
}
|
|
312
|
+
|
|
97
313
|
export async function configuredSessionDir() {
|
|
98
314
|
const envSessionDir = normalizeOptionalString(process.env.PI_CODING_AGENT_SESSION_DIR);
|
|
99
315
|
if (envSessionDir) return expandHome(envSessionDir);
|
|
100
|
-
const settings = await readJsonIfExists<{ sessionDir?: string }>(
|
|
316
|
+
const settings = await readJsonIfExists<{ sessionDir?: string }>(
|
|
317
|
+
path.join(agentDir(), "settings.json"),
|
|
318
|
+
);
|
|
101
319
|
return settings?.sessionDir ? expandHome(settings.sessionDir) : undefined;
|
|
102
320
|
}
|
|
103
321
|
|
|
104
|
-
export async function sessionDirForApply(
|
|
322
|
+
export async function sessionDirForApply(
|
|
323
|
+
ctx: ExtensionCommandContext | ExtensionContext,
|
|
324
|
+
snapshot: Snapshot,
|
|
325
|
+
) {
|
|
105
326
|
const contextSessionDir = sessionDirFromContext(ctx);
|
|
106
327
|
const envSessionDir = normalizeOptionalString(process.env.PI_CODING_AGENT_SESSION_DIR);
|
|
107
328
|
if (envSessionDir) return contextSessionDir ?? expandHome(envSessionDir);
|
|
108
329
|
|
|
109
330
|
const localSessionDir = await configuredSessionDir();
|
|
110
|
-
if (
|
|
331
|
+
if (
|
|
332
|
+
contextSessionDir &&
|
|
333
|
+
path.resolve(contextSessionDir) !== path.resolve(localSessionDir ?? "")
|
|
334
|
+
) {
|
|
111
335
|
return contextSessionDir;
|
|
112
336
|
}
|
|
113
337
|
return sessionDirFromSnapshot(snapshot) ?? contextSessionDir;
|
|
@@ -140,15 +364,34 @@ export async function writeState(profile: string, state: SyncState) {
|
|
|
140
364
|
await writeJson(statePath(profile), state);
|
|
141
365
|
}
|
|
142
366
|
|
|
367
|
+
export async function readStateForConfig(config: SyncConfig): Promise<SyncState> {
|
|
368
|
+
if (config.settingsVersion !== 2) return readState(config.profile);
|
|
369
|
+
return (
|
|
370
|
+
(await readJsonIfExists<SyncState>(statePathForConfig(config))) ?? {
|
|
371
|
+
version: VERSION,
|
|
372
|
+
profile: config.profile,
|
|
373
|
+
lastFileHashes: {},
|
|
374
|
+
}
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export async function writeStateForConfig(config: SyncConfig, state: SyncState) {
|
|
379
|
+
await writeJson(statePathForConfig(config), state);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function statePathForConfig(config: SyncConfig) {
|
|
383
|
+
if (config.settingsVersion !== 2) return statePath(config.profile);
|
|
384
|
+
const target = config.target ?? DEFAULT_PROFILE;
|
|
385
|
+
const hash = createHash("sha256").update(target).digest("hex").slice(0, 10);
|
|
386
|
+
return path.join(stateDir(), "targets", `${safeName(target)}-${hash}.state.json`);
|
|
387
|
+
}
|
|
388
|
+
|
|
143
389
|
export function agentDir() {
|
|
144
|
-
|
|
145
|
-
return configured ? expandHome(configured) : path.join(os.homedir(), ".pi", "agent");
|
|
390
|
+
return getAgentDir();
|
|
146
391
|
}
|
|
147
392
|
|
|
148
393
|
function expandHome(value: string) {
|
|
149
|
-
return value === "~" || value.startsWith("~/")
|
|
150
|
-
? path.join(os.homedir(), value.slice(2))
|
|
151
|
-
: value;
|
|
394
|
+
return value === "~" || value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value;
|
|
152
395
|
}
|
|
153
396
|
|
|
154
397
|
export function stateDir() {
|
|
@@ -159,6 +402,106 @@ export function localConfigPath() {
|
|
|
159
402
|
return path.join(agentDir(), "pi-sync.local.json");
|
|
160
403
|
}
|
|
161
404
|
|
|
405
|
+
export function localConfigTemplate(): Record<string, unknown> {
|
|
406
|
+
return {
|
|
407
|
+
version: 2,
|
|
408
|
+
activeTarget: DEFAULT_PROFILE,
|
|
409
|
+
profiles: {
|
|
410
|
+
[DEFAULT_PROFILE]: {
|
|
411
|
+
kind: "r2",
|
|
412
|
+
endpoint: "https://<account-id>.r2.cloudflarestorage.com",
|
|
413
|
+
region: DEFAULT_REGION,
|
|
414
|
+
accessKeyId: "<access-key-id>",
|
|
415
|
+
secretAccessKey: "<secret-access-key>",
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
targets: {
|
|
419
|
+
[DEFAULT_PROFILE]: {
|
|
420
|
+
profile: DEFAULT_PROFILE,
|
|
421
|
+
bucket: "pi-sync",
|
|
422
|
+
namespace: DEFAULT_PROFILE,
|
|
423
|
+
prefix: DEFAULT_PREFIX,
|
|
424
|
+
autoSync: true,
|
|
425
|
+
syncFiles: [...DEFAULT_SYNC_FILES],
|
|
426
|
+
syncSessions: false,
|
|
427
|
+
extraFiles: [],
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export async function readLocalConfigObject(): Promise<Record<string, unknown> | undefined> {
|
|
434
|
+
const configPath = localConfigPath();
|
|
435
|
+
try {
|
|
436
|
+
const stat = await fs.lstat(configPath);
|
|
437
|
+
if (stat.isSymbolicLink())
|
|
438
|
+
throw new Error(`Refusing to read symlinked pi-sync config: ${configPath}`);
|
|
439
|
+
if (!stat.isFile()) throw new Error(`pi-sync config is not a regular file: ${configPath}`);
|
|
440
|
+
} catch (error) {
|
|
441
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
442
|
+
throw error;
|
|
443
|
+
}
|
|
444
|
+
const parsed = JSON.parse(await fs.readFile(configPath, "utf8")) as unknown;
|
|
445
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
446
|
+
throw new Error(`pi-sync config must contain a JSON object: ${configPath}`);
|
|
447
|
+
}
|
|
448
|
+
return parsed as Record<string, unknown>;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
let configUpdateQueue: Promise<void> = Promise.resolve();
|
|
452
|
+
|
|
453
|
+
export function updateLocalConfig(
|
|
454
|
+
update: (current: Record<string, unknown>) => Record<string, unknown>,
|
|
455
|
+
) {
|
|
456
|
+
const operation = configUpdateQueue.then(() => performLocalConfigUpdate(update));
|
|
457
|
+
configUpdateQueue = operation.then(
|
|
458
|
+
() => undefined,
|
|
459
|
+
() => undefined,
|
|
460
|
+
);
|
|
461
|
+
return operation;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function performLocalConfigUpdate(
|
|
465
|
+
update: (current: Record<string, unknown>) => Record<string, unknown>,
|
|
466
|
+
) {
|
|
467
|
+
const current = (await readLocalConfigObject()) ?? localConfigTemplate();
|
|
468
|
+
const next = update({ ...current });
|
|
469
|
+
await writeLocalConfigObject(next);
|
|
470
|
+
return next;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export async function writeLocalConfigObject(value: Record<string, unknown>) {
|
|
474
|
+
const configPath = localConfigPath();
|
|
475
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
476
|
+
try {
|
|
477
|
+
const stat = await fs.lstat(configPath);
|
|
478
|
+
if (stat.isSymbolicLink())
|
|
479
|
+
throw new Error(`Refusing to overwrite symlinked pi-sync config: ${configPath}`);
|
|
480
|
+
if (!stat.isFile()) throw new Error(`pi-sync config is not a regular file: ${configPath}`);
|
|
481
|
+
} catch (error) {
|
|
482
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const temporaryPath = path.join(
|
|
486
|
+
path.dirname(configPath),
|
|
487
|
+
`.${path.basename(configPath)}.${process.pid}.${randomUUID()}.tmp`,
|
|
488
|
+
);
|
|
489
|
+
let handle: fs.FileHandle | undefined;
|
|
490
|
+
try {
|
|
491
|
+
handle = await fs.open(temporaryPath, "wx", 0o600);
|
|
492
|
+
await handle.writeFile(`${JSON.stringify(value, null, "\t")}\n`, "utf8");
|
|
493
|
+
if (process.platform !== "win32") await handle.chmod(0o600);
|
|
494
|
+
await handle.sync();
|
|
495
|
+
await handle.close();
|
|
496
|
+
handle = undefined;
|
|
497
|
+
await fs.rename(temporaryPath, configPath);
|
|
498
|
+
} catch (error) {
|
|
499
|
+
await handle?.close().catch(() => undefined);
|
|
500
|
+
await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
501
|
+
throw error;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
162
505
|
function statePath(profile: string) {
|
|
163
506
|
return path.join(stateDir(), `${safeName(profile)}.state.json`);
|
|
164
507
|
}
|
|
@@ -186,8 +529,12 @@ export async function writeJson(filePath: string, value: unknown) {
|
|
|
186
529
|
}
|
|
187
530
|
|
|
188
531
|
function selectSessionToken(fileSessionToken: string | undefined) {
|
|
189
|
-
if (hasEnv("PI_SYNC_SESSION_TOKEN"))
|
|
190
|
-
|
|
532
|
+
if (hasEnv("PI_SYNC_SESSION_TOKEN"))
|
|
533
|
+
return normalizeOptionalString(process.env.PI_SYNC_SESSION_TOKEN);
|
|
534
|
+
return (
|
|
535
|
+
normalizeOptionalString(process.env.AWS_SESSION_TOKEN) ??
|
|
536
|
+
normalizeOptionalString(fileSessionToken)
|
|
537
|
+
);
|
|
191
538
|
}
|
|
192
539
|
|
|
193
540
|
export function sessionTokenWarnings(config: { endpoint?: string; sessionToken?: string }) {
|
|
@@ -204,19 +551,14 @@ export function syncSessionsWarnings(config: { syncSessions?: boolean }) {
|
|
|
204
551
|
];
|
|
205
552
|
}
|
|
206
553
|
|
|
207
|
-
function isSecurityTokenInvalidArgument(text: string) {
|
|
208
|
-
return (
|
|
209
|
-
text.includes("<Code>InvalidArgument</Code>") &&
|
|
210
|
-
text.includes("<Message>X-Amz-Security-Token</Message>")
|
|
211
|
-
);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
554
|
export function isCloudflareR2Endpoint(endpoint: string | undefined) {
|
|
215
555
|
const value = endpoint?.trim();
|
|
216
556
|
if (!value) return false;
|
|
217
557
|
try {
|
|
218
558
|
const hostname = new URL(value).hostname.toLowerCase();
|
|
219
|
-
return
|
|
559
|
+
return (
|
|
560
|
+
hostname === "r2.cloudflarestorage.com" || hostname.endsWith(".r2.cloudflarestorage.com")
|
|
561
|
+
);
|
|
220
562
|
} catch {
|
|
221
563
|
return false;
|
|
222
564
|
}
|
|
@@ -227,47 +569,15 @@ function normalizeOptionalString(value: string | undefined) {
|
|
|
227
569
|
return normalized ? normalized : undefined;
|
|
228
570
|
}
|
|
229
571
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
.map((item) => item.trim())
|
|
236
|
-
.filter((item) => {
|
|
237
|
-
const lower = item.toLowerCase();
|
|
238
|
-
if (
|
|
239
|
-
item === "" ||
|
|
240
|
-
item === "." ||
|
|
241
|
-
item === ".." ||
|
|
242
|
-
item.includes("/") ||
|
|
243
|
-
item.includes("\\") ||
|
|
244
|
-
TOP_LEVEL_FILE_NAMES.has(lower) ||
|
|
245
|
-
isDeniedPath(item) ||
|
|
246
|
-
RESERVED_TOP_LEVEL_NAMES.has(lower) ||
|
|
247
|
-
seen.has(lower)
|
|
248
|
-
) {
|
|
249
|
-
return false;
|
|
250
|
-
}
|
|
251
|
-
seen.add(lower);
|
|
252
|
-
return true;
|
|
253
|
-
});
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
export function extraFilePathsByLower(value: unknown) {
|
|
257
|
-
return new Map(normalizeExtraFiles(value).map((fileName) => [fileName.toLowerCase(), fileName]));
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function selectTopLevelFileEntry(entries: Dirent[], fileName: string) {
|
|
261
|
-
const exact = entries.find((entry) => entry.isFile() && entry.name === fileName);
|
|
262
|
-
if (exact) return exact;
|
|
263
|
-
const lower = fileName.toLowerCase();
|
|
264
|
-
return entries
|
|
265
|
-
.filter((entry) => entry.isFile() && entry.name.toLowerCase() === lower)
|
|
266
|
-
.sort((left, right) => left.name.localeCompare(right.name))[0];
|
|
572
|
+
function normalizeConfiguredString(value: string | undefined) {
|
|
573
|
+
const normalized = normalizeOptionalString(value);
|
|
574
|
+
return normalized && !/^<[^>]+>$/u.test(normalized) && !normalized.includes("<account-id>")
|
|
575
|
+
? normalized
|
|
576
|
+
: undefined;
|
|
267
577
|
}
|
|
268
578
|
|
|
269
579
|
function hasEnv(name: string) {
|
|
270
|
-
return Object.
|
|
580
|
+
return Object.hasOwn(process.env, name);
|
|
271
581
|
}
|
|
272
582
|
|
|
273
583
|
export function isEnabled(value: boolean | string | undefined, defaultValue: boolean) {
|
|
@@ -284,7 +594,3 @@ export function isExplicitlyEnabled(value: boolean | string | undefined) {
|
|
|
284
594
|
export function isMissingConfigError(error: unknown) {
|
|
285
595
|
return error instanceof Error && error.message.startsWith("Missing pi-sync config:");
|
|
286
596
|
}
|
|
287
|
-
|
|
288
|
-
function errorMessage(error: unknown) {
|
|
289
|
-
return error instanceof Error ? error.message : String(error);
|
|
290
|
-
}
|