@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/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 { isDeniedPath, safeName } from "./paths.js";
8
- import type { LockFile, PartialConfig, Snapshot, SyncConfig, SyncState } from "./types.js";
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(`Missing pi-sync config: ${missing.join(", ")}. Run /pisync init or set PI_SYNC_* environment variables.`);
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: endpoint!,
96
- bucket: bucket!,
66
+ endpoint,
67
+ bucket,
97
68
  region: partial.region ?? DEFAULT_REGION,
98
- accessKeyId: accessKeyId!,
99
- secretAccessKey: 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 readJsonIfExists<PartialConfig>(localConfigPath())) ?? {};
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: process.env.PI_SYNC_ACCESS_KEY_ID ?? process.env.AWS_ACCESS_KEY_ID ?? fileConfig.accessKeyId,
120
- secretAccessKey: process.env.PI_SYNC_SECRET_ACCESS_KEY ?? process.env.AWS_SECRET_ACCESS_KEY ?? fileConfig.secretAccessKey,
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 }>(path.join(agentDir(), "settings.json"));
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(ctx: ExtensionCommandContext | ExtensionContext, snapshot: Snapshot) {
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 (contextSessionDir && path.resolve(contextSessionDir) !== path.resolve(localSessionDir ?? "")) {
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 statePath(profile: string) {
196
- return path.join(stateDir(), `${safeName(profile)}.state.json`);
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 lockPath() {
200
- return path.join(stateDir(), "lock");
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 ensureStateDir() {
204
- await fs.mkdir(stateDir(), { recursive: true });
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 readLock() {
208
- return readJsonIfExists<LockFile>(lockPath());
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
- export function isStaleLock(lock: LockFile) {
212
- if (!Number.isInteger(lock.pid) || lock.pid <= 0) return true;
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
- process.kill(lock.pid, 0);
215
- return false;
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
- if ((error as NodeJS.ErrnoException).code === "ESRCH") return true;
218
- return Date.now() - Date.parse(lock.startedAt) > LOCK_STALE_MS;
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")) return normalizeOptionalString(process.env.PI_SYNC_SESSION_TOKEN);
238
- return normalizeOptionalString(process.env.AWS_SESSION_TOKEN) ?? normalizeOptionalString(fileSessionToken);
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 hostname === "r2.cloudflarestorage.com" || hostname.endsWith(".r2.cloudflarestorage.com");
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.prototype.hasOwnProperty.call(process.env, name);
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";