@narumitw/pi-sync 0.24.0 → 0.28.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 +62 -30
- package/package.json +11 -2
- package/src/command.ts +61 -18
- package/src/config.ts +121 -122
- package/src/file-selection.ts +192 -0
- package/src/index.ts +1 -0
- package/src/lock.ts +301 -0
- package/src/snapshot-apply.ts +35 -11
- package/src/snapshot.ts +76 -95
- package/src/sync-policy.ts +129 -0
- package/src/sync-state.ts +167 -0
- package/src/sync.ts +164 -239
- package/src/types.ts +4 -0
package/src/config.ts
CHANGED
|
@@ -1,21 +1,18 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import type { Dirent } from "node:fs";
|
|
3
2
|
import fs from "node:fs/promises";
|
|
4
3
|
import os from "node:os";
|
|
5
4
|
import path from "node:path";
|
|
6
5
|
import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import {
|
|
8
|
-
import
|
|
6
|
+
import { safeName } from "./paths.js";
|
|
7
|
+
import { DEFAULT_SYNC_FILES, normalizeExtraFiles, normalizeSyncFiles } from "./sync-policy.js";
|
|
8
|
+
import type { PartialConfig, Snapshot, SyncConfig, SyncState } from "./types.js";
|
|
9
|
+
|
|
10
|
+
export { extraFilePathsByLower, normalizeExtraFiles, normalizeSyncFiles } from "./sync-policy.js";
|
|
9
11
|
|
|
10
12
|
const VERSION = 1;
|
|
11
13
|
const DEFAULT_PROFILE = "default";
|
|
12
14
|
const DEFAULT_PREFIX = "pi-sync";
|
|
13
15
|
const DEFAULT_REGION = "auto";
|
|
14
|
-
const LOCK_STALE_MS = 30 * 60 * 1000;
|
|
15
|
-
const TOP_LEVEL_FILES = new Set(["settings.json", "keybindings.json", "models.json", "AGENTS.md", "APPEND_SYSTEM.md"]);
|
|
16
|
-
const TOP_LEVEL_FILE_NAMES = new Set([...TOP_LEVEL_FILES].map((name) => name.toLowerCase()));
|
|
17
|
-
const TOP_LEVEL_DIRS = new Set(["skills", "prompts", "themes", "extensions"]);
|
|
18
|
-
const RESERVED_TOP_LEVEL_NAMES = new Set([...TOP_LEVEL_DIRS, "sessions"]);
|
|
19
16
|
|
|
20
17
|
function trimSlashes(value: string) {
|
|
21
18
|
return value.replace(/^\/+|\/+$/g, "");
|
|
@@ -42,37 +39,6 @@ function sessionDirFromContext(ctx: ExtensionCommandContext | ExtensionContext)
|
|
|
42
39
|
: undefined;
|
|
43
40
|
}
|
|
44
41
|
|
|
45
|
-
export async function withLock<T>(command: string, fn: () => Promise<T>): Promise<T> {
|
|
46
|
-
await ensureStateDir();
|
|
47
|
-
const lock: LockFile = {
|
|
48
|
-
id: randomUUID(),
|
|
49
|
-
pid: process.pid,
|
|
50
|
-
command,
|
|
51
|
-
startedAt: new Date().toISOString(),
|
|
52
|
-
};
|
|
53
|
-
let handle: fs.FileHandle | undefined;
|
|
54
|
-
try {
|
|
55
|
-
handle = await fs.open(lockPath(), "wx");
|
|
56
|
-
await handle.writeFile(JSON.stringify(lock, null, "\t"));
|
|
57
|
-
await handle.close();
|
|
58
|
-
handle = undefined;
|
|
59
|
-
return await fn();
|
|
60
|
-
} catch (error) {
|
|
61
|
-
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
62
|
-
const current = await readLock();
|
|
63
|
-
if (current && isStaleLock(current)) {
|
|
64
|
-
throw new Error(`pi-sync lock is stale (pid ${current.pid}). Run /pisync unlock --stale, then retry.`);
|
|
65
|
-
}
|
|
66
|
-
throw new Error(`pi-sync is already running${current ? ` (${current.command}, pid ${current.pid}, started ${current.startedAt})` : ""}.`);
|
|
67
|
-
}
|
|
68
|
-
throw error;
|
|
69
|
-
} finally {
|
|
70
|
-
await handle?.close();
|
|
71
|
-
const current = await readLock();
|
|
72
|
-
if (current?.id === lock.id) await fs.rm(lockPath(), { force: true });
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
42
|
export async function loadConfigInternal(): Promise<SyncConfig> {
|
|
77
43
|
const partial = await loadPartialConfig();
|
|
78
44
|
const endpoint = partial.endpoint;
|
|
@@ -88,18 +54,24 @@ export async function loadConfigInternal(): Promise<SyncConfig> {
|
|
|
88
54
|
.filter(([, value]) => !value)
|
|
89
55
|
.map(([name]) => name);
|
|
90
56
|
if (missing.length > 0) {
|
|
91
|
-
throw new Error(
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Missing pi-sync config: ${missing.join(", ")}. Run /sync init or set PI_SYNC_* environment variables.`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
if (!endpoint || !bucket || !accessKeyId || !secretAccessKey) {
|
|
62
|
+
throw new Error("Missing pi-sync config after validation.");
|
|
92
63
|
}
|
|
93
64
|
|
|
94
65
|
return {
|
|
95
|
-
endpoint
|
|
96
|
-
bucket
|
|
66
|
+
endpoint,
|
|
67
|
+
bucket,
|
|
97
68
|
region: partial.region ?? DEFAULT_REGION,
|
|
98
|
-
accessKeyId
|
|
99
|
-
secretAccessKey
|
|
69
|
+
accessKeyId,
|
|
70
|
+
secretAccessKey,
|
|
100
71
|
sessionToken: partial.sessionToken,
|
|
101
72
|
profile: partial.profile ?? DEFAULT_PROFILE,
|
|
102
73
|
prefix: trimSlashes(partial.prefix ?? DEFAULT_PREFIX),
|
|
74
|
+
syncFiles: normalizeSyncFiles(partial.syncFiles),
|
|
103
75
|
syncSessions: isExplicitlyEnabled(partial.syncSessions),
|
|
104
76
|
extraFiles: normalizeExtraFiles(partial.extraFiles),
|
|
105
77
|
};
|
|
@@ -110,18 +82,23 @@ export async function loadConfig(): Promise<SyncConfig> {
|
|
|
110
82
|
}
|
|
111
83
|
|
|
112
84
|
export async function loadPartialConfig(): Promise<PartialConfig> {
|
|
113
|
-
const fileConfig = (await
|
|
85
|
+
const fileConfig = ((await readLocalConfigObject()) ?? {}) as PartialConfig;
|
|
114
86
|
return {
|
|
115
87
|
...fileConfig,
|
|
116
88
|
endpoint: process.env.PI_SYNC_ENDPOINT ?? process.env.R2_ENDPOINT ?? fileConfig.endpoint,
|
|
117
89
|
bucket: process.env.PI_SYNC_BUCKET ?? process.env.R2_BUCKET ?? fileConfig.bucket,
|
|
118
90
|
region: process.env.PI_SYNC_REGION ?? process.env.AWS_REGION ?? fileConfig.region,
|
|
119
|
-
accessKeyId:
|
|
120
|
-
|
|
91
|
+
accessKeyId:
|
|
92
|
+
process.env.PI_SYNC_ACCESS_KEY_ID ?? process.env.AWS_ACCESS_KEY_ID ?? fileConfig.accessKeyId,
|
|
93
|
+
secretAccessKey:
|
|
94
|
+
process.env.PI_SYNC_SECRET_ACCESS_KEY ??
|
|
95
|
+
process.env.AWS_SECRET_ACCESS_KEY ??
|
|
96
|
+
fileConfig.secretAccessKey,
|
|
121
97
|
sessionToken: selectSessionToken(fileConfig.sessionToken),
|
|
122
98
|
profile: process.env.PI_SYNC_PROFILE ?? fileConfig.profile,
|
|
123
99
|
prefix: process.env.PI_SYNC_PREFIX ?? fileConfig.prefix,
|
|
124
100
|
autoSync: process.env.PI_SYNC_AUTO_SYNC ?? fileConfig.autoSync,
|
|
101
|
+
syncFiles: fileConfig.syncFiles,
|
|
125
102
|
syncSessions: process.env.PI_SYNC_SESSIONS ?? fileConfig.syncSessions,
|
|
126
103
|
extraFiles: fileConfig.extraFiles,
|
|
127
104
|
};
|
|
@@ -130,17 +107,25 @@ export async function loadPartialConfig(): Promise<PartialConfig> {
|
|
|
130
107
|
export async function configuredSessionDir() {
|
|
131
108
|
const envSessionDir = normalizeOptionalString(process.env.PI_CODING_AGENT_SESSION_DIR);
|
|
132
109
|
if (envSessionDir) return expandHome(envSessionDir);
|
|
133
|
-
const settings = await readJsonIfExists<{ sessionDir?: string }>(
|
|
110
|
+
const settings = await readJsonIfExists<{ sessionDir?: string }>(
|
|
111
|
+
path.join(agentDir(), "settings.json"),
|
|
112
|
+
);
|
|
134
113
|
return settings?.sessionDir ? expandHome(settings.sessionDir) : undefined;
|
|
135
114
|
}
|
|
136
115
|
|
|
137
|
-
export async function sessionDirForApply(
|
|
116
|
+
export async function sessionDirForApply(
|
|
117
|
+
ctx: ExtensionCommandContext | ExtensionContext,
|
|
118
|
+
snapshot: Snapshot,
|
|
119
|
+
) {
|
|
138
120
|
const contextSessionDir = sessionDirFromContext(ctx);
|
|
139
121
|
const envSessionDir = normalizeOptionalString(process.env.PI_CODING_AGENT_SESSION_DIR);
|
|
140
122
|
if (envSessionDir) return contextSessionDir ?? expandHome(envSessionDir);
|
|
141
123
|
|
|
142
124
|
const localSessionDir = await configuredSessionDir();
|
|
143
|
-
if (
|
|
125
|
+
if (
|
|
126
|
+
contextSessionDir &&
|
|
127
|
+
path.resolve(contextSessionDir) !== path.resolve(localSessionDir ?? "")
|
|
128
|
+
) {
|
|
144
129
|
return contextSessionDir;
|
|
145
130
|
}
|
|
146
131
|
return sessionDirFromSnapshot(snapshot) ?? contextSessionDir;
|
|
@@ -179,9 +164,7 @@ export function agentDir() {
|
|
|
179
164
|
}
|
|
180
165
|
|
|
181
166
|
function expandHome(value: string) {
|
|
182
|
-
return value === "~" || value.startsWith("~/")
|
|
183
|
-
? path.join(os.homedir(), value.slice(2))
|
|
184
|
-
: value;
|
|
167
|
+
return value === "~" || value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value;
|
|
185
168
|
}
|
|
186
169
|
|
|
187
170
|
export function stateDir() {
|
|
@@ -192,33 +175,93 @@ export function localConfigPath() {
|
|
|
192
175
|
return path.join(agentDir(), "pi-sync.local.json");
|
|
193
176
|
}
|
|
194
177
|
|
|
195
|
-
function
|
|
196
|
-
return
|
|
178
|
+
export function localConfigTemplate(): Record<string, unknown> {
|
|
179
|
+
return {
|
|
180
|
+
endpoint: "https://<account-id>.r2.cloudflarestorage.com",
|
|
181
|
+
bucket: "pi-sync",
|
|
182
|
+
region: DEFAULT_REGION,
|
|
183
|
+
accessKeyId: "<access-key-id>",
|
|
184
|
+
secretAccessKey: "<secret-access-key>",
|
|
185
|
+
profile: DEFAULT_PROFILE,
|
|
186
|
+
prefix: DEFAULT_PREFIX,
|
|
187
|
+
autoSync: true,
|
|
188
|
+
syncFiles: [...DEFAULT_SYNC_FILES],
|
|
189
|
+
syncSessions: false,
|
|
190
|
+
extraFiles: [],
|
|
191
|
+
};
|
|
197
192
|
}
|
|
198
193
|
|
|
199
|
-
export function
|
|
200
|
-
|
|
194
|
+
export async function readLocalConfigObject(): Promise<Record<string, unknown> | undefined> {
|
|
195
|
+
const configPath = localConfigPath();
|
|
196
|
+
try {
|
|
197
|
+
const stat = await fs.lstat(configPath);
|
|
198
|
+
if (stat.isSymbolicLink())
|
|
199
|
+
throw new Error(`Refusing to read symlinked pi-sync config: ${configPath}`);
|
|
200
|
+
if (!stat.isFile()) throw new Error(`pi-sync config is not a regular file: ${configPath}`);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
const parsed = JSON.parse(await fs.readFile(configPath, "utf8")) as unknown;
|
|
206
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
207
|
+
throw new Error(`pi-sync config must contain a JSON object: ${configPath}`);
|
|
208
|
+
}
|
|
209
|
+
return parsed as Record<string, unknown>;
|
|
201
210
|
}
|
|
202
211
|
|
|
203
|
-
export async function
|
|
204
|
-
|
|
212
|
+
export async function updateLocalConfig(
|
|
213
|
+
update: (current: Record<string, unknown>) => Record<string, unknown>,
|
|
214
|
+
) {
|
|
215
|
+
const current = (await readLocalConfigObject()) ?? localConfigTemplate();
|
|
216
|
+
const next = update({ ...current });
|
|
217
|
+
await writeLocalConfigObject(next);
|
|
218
|
+
return next;
|
|
205
219
|
}
|
|
206
220
|
|
|
207
|
-
export async function
|
|
208
|
-
|
|
209
|
-
}
|
|
221
|
+
export async function writeLocalConfigObject(value: Record<string, unknown>) {
|
|
222
|
+
const configPath = localConfigPath();
|
|
223
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
224
|
+
try {
|
|
225
|
+
const stat = await fs.lstat(configPath);
|
|
226
|
+
if (stat.isSymbolicLink())
|
|
227
|
+
throw new Error(`Refusing to overwrite symlinked pi-sync config: ${configPath}`);
|
|
228
|
+
if (!stat.isFile()) throw new Error(`pi-sync config is not a regular file: ${configPath}`);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
231
|
+
}
|
|
210
232
|
|
|
211
|
-
|
|
212
|
-
|
|
233
|
+
const temporaryPath = path.join(
|
|
234
|
+
path.dirname(configPath),
|
|
235
|
+
`.${path.basename(configPath)}.${process.pid}.${randomUUID()}.tmp`,
|
|
236
|
+
);
|
|
237
|
+
let handle: fs.FileHandle | undefined;
|
|
213
238
|
try {
|
|
214
|
-
|
|
215
|
-
|
|
239
|
+
handle = await fs.open(temporaryPath, "wx", 0o600);
|
|
240
|
+
await handle.writeFile(`${JSON.stringify(value, null, "\t")}\n`, "utf8");
|
|
241
|
+
if (process.platform !== "win32") await handle.chmod(0o600);
|
|
242
|
+
await handle.sync();
|
|
243
|
+
await handle.close();
|
|
244
|
+
handle = undefined;
|
|
245
|
+
await fs.rename(temporaryPath, configPath);
|
|
216
246
|
} catch (error) {
|
|
217
|
-
|
|
218
|
-
|
|
247
|
+
await handle?.close().catch(() => undefined);
|
|
248
|
+
await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
249
|
+
throw error;
|
|
219
250
|
}
|
|
220
251
|
}
|
|
221
252
|
|
|
253
|
+
function statePath(profile: string) {
|
|
254
|
+
return path.join(stateDir(), `${safeName(profile)}.state.json`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function lockPath() {
|
|
258
|
+
return path.join(stateDir(), "lock");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export async function ensureStateDir() {
|
|
262
|
+
await fs.mkdir(stateDir(), { recursive: true });
|
|
263
|
+
}
|
|
264
|
+
|
|
222
265
|
async function readJsonIfExists<T>(filePath: string): Promise<T | undefined> {
|
|
223
266
|
try {
|
|
224
267
|
return JSON.parse(await fs.readFile(filePath, "utf8")) as T;
|
|
@@ -234,8 +277,12 @@ export async function writeJson(filePath: string, value: unknown) {
|
|
|
234
277
|
}
|
|
235
278
|
|
|
236
279
|
function selectSessionToken(fileSessionToken: string | undefined) {
|
|
237
|
-
if (hasEnv("PI_SYNC_SESSION_TOKEN"))
|
|
238
|
-
|
|
280
|
+
if (hasEnv("PI_SYNC_SESSION_TOKEN"))
|
|
281
|
+
return normalizeOptionalString(process.env.PI_SYNC_SESSION_TOKEN);
|
|
282
|
+
return (
|
|
283
|
+
normalizeOptionalString(process.env.AWS_SESSION_TOKEN) ??
|
|
284
|
+
normalizeOptionalString(fileSessionToken)
|
|
285
|
+
);
|
|
239
286
|
}
|
|
240
287
|
|
|
241
288
|
export function sessionTokenWarnings(config: { endpoint?: string; sessionToken?: string }) {
|
|
@@ -252,19 +299,14 @@ export function syncSessionsWarnings(config: { syncSessions?: boolean }) {
|
|
|
252
299
|
];
|
|
253
300
|
}
|
|
254
301
|
|
|
255
|
-
function isSecurityTokenInvalidArgument(text: string) {
|
|
256
|
-
return (
|
|
257
|
-
text.includes("<Code>InvalidArgument</Code>") &&
|
|
258
|
-
text.includes("<Message>X-Amz-Security-Token</Message>")
|
|
259
|
-
);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
302
|
export function isCloudflareR2Endpoint(endpoint: string | undefined) {
|
|
263
303
|
const value = endpoint?.trim();
|
|
264
304
|
if (!value) return false;
|
|
265
305
|
try {
|
|
266
306
|
const hostname = new URL(value).hostname.toLowerCase();
|
|
267
|
-
return
|
|
307
|
+
return (
|
|
308
|
+
hostname === "r2.cloudflarestorage.com" || hostname.endsWith(".r2.cloudflarestorage.com")
|
|
309
|
+
);
|
|
268
310
|
} catch {
|
|
269
311
|
return false;
|
|
270
312
|
}
|
|
@@ -275,47 +317,8 @@ function normalizeOptionalString(value: string | undefined) {
|
|
|
275
317
|
return normalized ? normalized : undefined;
|
|
276
318
|
}
|
|
277
319
|
|
|
278
|
-
export function normalizeExtraFiles(value: unknown) {
|
|
279
|
-
if (!Array.isArray(value)) return [];
|
|
280
|
-
const seen = new Set<string>();
|
|
281
|
-
return value
|
|
282
|
-
.filter((item): item is string => typeof item === "string")
|
|
283
|
-
.map((item) => item.trim())
|
|
284
|
-
.filter((item) => {
|
|
285
|
-
const lower = item.toLowerCase();
|
|
286
|
-
if (
|
|
287
|
-
item === "" ||
|
|
288
|
-
item === "." ||
|
|
289
|
-
item === ".." ||
|
|
290
|
-
item.includes("/") ||
|
|
291
|
-
item.includes("\\") ||
|
|
292
|
-
TOP_LEVEL_FILE_NAMES.has(lower) ||
|
|
293
|
-
isDeniedPath(item) ||
|
|
294
|
-
RESERVED_TOP_LEVEL_NAMES.has(lower) ||
|
|
295
|
-
seen.has(lower)
|
|
296
|
-
) {
|
|
297
|
-
return false;
|
|
298
|
-
}
|
|
299
|
-
seen.add(lower);
|
|
300
|
-
return true;
|
|
301
|
-
});
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
export function extraFilePathsByLower(value: unknown) {
|
|
305
|
-
return new Map(normalizeExtraFiles(value).map((fileName) => [fileName.toLowerCase(), fileName]));
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
function selectTopLevelFileEntry(entries: Dirent[], fileName: string) {
|
|
309
|
-
const exact = entries.find((entry) => entry.isFile() && entry.name === fileName);
|
|
310
|
-
if (exact) return exact;
|
|
311
|
-
const lower = fileName.toLowerCase();
|
|
312
|
-
return entries
|
|
313
|
-
.filter((entry) => entry.isFile() && entry.name.toLowerCase() === lower)
|
|
314
|
-
.sort((left, right) => left.name.localeCompare(right.name))[0];
|
|
315
|
-
}
|
|
316
|
-
|
|
317
320
|
function hasEnv(name: string) {
|
|
318
|
-
return Object.
|
|
321
|
+
return Object.hasOwn(process.env, name);
|
|
319
322
|
}
|
|
320
323
|
|
|
321
324
|
export function isEnabled(value: boolean | string | undefined, defaultValue: boolean) {
|
|
@@ -332,7 +335,3 @@ export function isExplicitlyEnabled(value: boolean | string | undefined) {
|
|
|
332
335
|
export function isMissingConfigError(error: unknown) {
|
|
333
336
|
return error instanceof Error && error.message.startsWith("Missing pi-sync config:");
|
|
334
337
|
}
|
|
335
|
-
|
|
336
|
-
function errorMessage(error: unknown) {
|
|
337
|
-
return error instanceof Error ? error.message : String(error);
|
|
338
|
-
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Container, type SettingItem, SettingsList, Text } from "@earendil-works/pi-tui";
|
|
5
|
+
import {
|
|
6
|
+
agentDir,
|
|
7
|
+
isExplicitlyEnabled,
|
|
8
|
+
loadPartialConfig,
|
|
9
|
+
localConfigPath,
|
|
10
|
+
updateLocalConfig,
|
|
11
|
+
} from "./config.js";
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_SYNC_FILES,
|
|
14
|
+
isSafeExtraFileName,
|
|
15
|
+
normalizeExtraFiles,
|
|
16
|
+
normalizeSyncFiles,
|
|
17
|
+
} from "./sync-policy.js";
|
|
18
|
+
|
|
19
|
+
const INCLUDED = "included";
|
|
20
|
+
const EXCLUDED = "excluded";
|
|
21
|
+
const BUILT_IN_PREFIX = "builtin:";
|
|
22
|
+
const EXTRA_PREFIX = "extra:";
|
|
23
|
+
const SESSIONS_ID = "sessions";
|
|
24
|
+
|
|
25
|
+
export async function showFileSelection(ctx: ExtensionCommandContext) {
|
|
26
|
+
const partial = await loadPartialConfig();
|
|
27
|
+
const selectedBuiltIns = new Set(normalizeSyncFiles(partial.syncFiles));
|
|
28
|
+
const selectedExtras = new Set(normalizeExtraFiles(partial.extraFiles));
|
|
29
|
+
const sessionEnvironmentOverride = Object.hasOwn(process.env, "PI_SYNC_SESSIONS");
|
|
30
|
+
const sessionsIncluded = isExplicitlyEnabled(partial.syncSessions);
|
|
31
|
+
const extraCandidates = await listExtraFileCandidates(selectedExtras);
|
|
32
|
+
|
|
33
|
+
if (ctx.mode !== "tui") {
|
|
34
|
+
ctx.ui.notify(
|
|
35
|
+
[
|
|
36
|
+
"pi-sync selected files:",
|
|
37
|
+
`built-ins: ${[...selectedBuiltIns].join(", ") || "none"}`,
|
|
38
|
+
`sessions: ${sessionsIncluded ? "included" : "excluded"}${sessionEnvironmentOverride ? " (PI_SYNC_SESSIONS)" : ""}`,
|
|
39
|
+
`extra files: ${[...selectedExtras].map(safeTerminalText).join(", ") || "none"}`,
|
|
40
|
+
`Edit syncFiles, syncSessions, and extraFiles in ${safeTerminalText(localConfigPath())}.`,
|
|
41
|
+
].join("\n"),
|
|
42
|
+
"info",
|
|
43
|
+
);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const items: SettingItem[] = [
|
|
48
|
+
...DEFAULT_SYNC_FILES.map((fileName) => ({
|
|
49
|
+
id: `${BUILT_IN_PREFIX}${fileName}`,
|
|
50
|
+
label: fileName,
|
|
51
|
+
description: fileName.includes(".")
|
|
52
|
+
? `Sync the top-level ${fileName} file when present.`
|
|
53
|
+
: `Recursively sync every safe file under ${fileName}/.`,
|
|
54
|
+
currentValue: selectedBuiltIns.has(fileName) ? INCLUDED : EXCLUDED,
|
|
55
|
+
values: [INCLUDED, EXCLUDED],
|
|
56
|
+
})),
|
|
57
|
+
{
|
|
58
|
+
id: SESSIONS_ID,
|
|
59
|
+
label: "sessions",
|
|
60
|
+
description: sessionEnvironmentOverride
|
|
61
|
+
? "Read-only here because PI_SYNC_SESSIONS overrides the local setting. Session JSONL may contain prompts, tool output, paths, images, and secrets."
|
|
62
|
+
: "Session JSONL may contain prompts, tool output, paths, images, and secrets. Sync only to storage you trust.",
|
|
63
|
+
currentValue: sessionEnvironmentOverride
|
|
64
|
+
? `${sessionsIncluded ? INCLUDED : EXCLUDED} (environment)`
|
|
65
|
+
: sessionsIncluded
|
|
66
|
+
? INCLUDED
|
|
67
|
+
: EXCLUDED,
|
|
68
|
+
...(sessionEnvironmentOverride ? {} : { values: [INCLUDED, EXCLUDED] }),
|
|
69
|
+
},
|
|
70
|
+
...extraCandidates.map((fileName) => ({
|
|
71
|
+
id: `${EXTRA_PREFIX}${fileName}`,
|
|
72
|
+
label: safeTerminalText(fileName),
|
|
73
|
+
description:
|
|
74
|
+
"Additional safe top-level file. It may be absent locally and pulled from another machine.",
|
|
75
|
+
currentValue: selectedExtras.has(fileName) ? INCLUDED : EXCLUDED,
|
|
76
|
+
values: [INCLUDED, EXCLUDED],
|
|
77
|
+
})),
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
let saveQueue = Promise.resolve();
|
|
81
|
+
let nextOwner = 0;
|
|
82
|
+
const owners = new Map<string, number>();
|
|
83
|
+
let closed = false;
|
|
84
|
+
|
|
85
|
+
await ctx.ui.custom((tui, theme, _keybindings, done) => {
|
|
86
|
+
const container = new Container();
|
|
87
|
+
const title = new Text("", 1, 0);
|
|
88
|
+
const hint = new Text("", 1, 0);
|
|
89
|
+
const updateChrome = () => {
|
|
90
|
+
title.setText(theme.fg("accent", theme.bold("pi-sync Files")));
|
|
91
|
+
hint.setText(
|
|
92
|
+
theme.fg("dim", "Changes save immediately and apply to the next manual or automatic sync."),
|
|
93
|
+
);
|
|
94
|
+
};
|
|
95
|
+
updateChrome();
|
|
96
|
+
container.addChild(title);
|
|
97
|
+
|
|
98
|
+
let settingsList: SettingsList;
|
|
99
|
+
const queueChange = (id: string, newValue: string) => {
|
|
100
|
+
const previousValue = newValue === INCLUDED ? EXCLUDED : INCLUDED;
|
|
101
|
+
const owner = ++nextOwner;
|
|
102
|
+
owners.set(id, owner);
|
|
103
|
+
const save = async () => {
|
|
104
|
+
try {
|
|
105
|
+
await persistSelectionChange(id, newValue === INCLUDED);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (owners.get(id) === owner && !closed) {
|
|
108
|
+
settingsList.updateValue(id, previousValue);
|
|
109
|
+
tui.requestRender();
|
|
110
|
+
}
|
|
111
|
+
ctx.ui.notify(`Could not save pi-sync file selection: ${errorMessage(error)}`, "error");
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
saveQueue = saveQueue.then(save, save);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
settingsList = new SettingsList(
|
|
118
|
+
items,
|
|
119
|
+
Math.min(items.length + 2, 15),
|
|
120
|
+
getSettingsListTheme(),
|
|
121
|
+
queueChange,
|
|
122
|
+
() => done(undefined),
|
|
123
|
+
{ enableSearch: true },
|
|
124
|
+
);
|
|
125
|
+
container.addChild(settingsList);
|
|
126
|
+
container.addChild(hint);
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
render(width: number) {
|
|
130
|
+
return container.render(width);
|
|
131
|
+
},
|
|
132
|
+
invalidate() {
|
|
133
|
+
updateChrome();
|
|
134
|
+
container.invalidate();
|
|
135
|
+
},
|
|
136
|
+
handleInput(data: string) {
|
|
137
|
+
settingsList.handleInput(data);
|
|
138
|
+
tui.requestRender();
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
closed = true;
|
|
143
|
+
await saveQueue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function listExtraFileCandidates(configured: Set<string>) {
|
|
147
|
+
const candidates = new Map([...configured].map((fileName) => [fileName.toLowerCase(), fileName]));
|
|
148
|
+
try {
|
|
149
|
+
for (const entry of await fs.readdir(agentDir(), { withFileTypes: true })) {
|
|
150
|
+
if (!entry.isFile() || !isSafeExtraFileName(entry.name)) continue;
|
|
151
|
+
if (!candidates.has(entry.name.toLowerCase())) {
|
|
152
|
+
candidates.set(entry.name.toLowerCase(), entry.name);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
157
|
+
}
|
|
158
|
+
return [...candidates.values()].sort((left, right) => left.localeCompare(right));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function persistSelectionChange(id: string, included: boolean) {
|
|
162
|
+
await updateLocalConfig((current) => {
|
|
163
|
+
if (id.startsWith(BUILT_IN_PREFIX)) {
|
|
164
|
+
const fileName = id.slice(BUILT_IN_PREFIX.length);
|
|
165
|
+
const selected = new Set(normalizeSyncFiles(current.syncFiles));
|
|
166
|
+
if (included) selected.add(fileName as (typeof DEFAULT_SYNC_FILES)[number]);
|
|
167
|
+
else selected.delete(fileName as (typeof DEFAULT_SYNC_FILES)[number]);
|
|
168
|
+
return {
|
|
169
|
+
...current,
|
|
170
|
+
syncFiles: DEFAULT_SYNC_FILES.filter((candidate) => selected.has(candidate)),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (id.startsWith(EXTRA_PREFIX)) {
|
|
174
|
+
const fileName = id.slice(EXTRA_PREFIX.length);
|
|
175
|
+
const selected = new Set(normalizeExtraFiles(current.extraFiles));
|
|
176
|
+
if (included) selected.add(fileName);
|
|
177
|
+
else selected.delete(fileName);
|
|
178
|
+
return { ...current, extraFiles: [...selected] };
|
|
179
|
+
}
|
|
180
|
+
if (id === SESSIONS_ID) return { ...current, syncSessions: included };
|
|
181
|
+
throw new Error(`Unknown file selection: ${id}`);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function safeTerminalText(value: string) {
|
|
186
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: Escape untrusted terminal controls.
|
|
187
|
+
return value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, "?");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function errorMessage(error: unknown) {
|
|
191
|
+
return safeTerminalText(error instanceof Error ? error.message : String(error));
|
|
192
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./sync.js";
|