@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/src/command.ts CHANGED
@@ -7,10 +7,10 @@ const YES_FLAG_COMPLETIONS: readonly CommandArgumentCompletion[] = [
7
7
  ];
8
8
  export const SYNC_COMMANDS = [
9
9
  { name: "help", description: "Show command usage" },
10
- { name: "use", description: "Switch the current sync target", usageSuffix: " <target>" },
10
+ { name: "use", description: "Switch the current sync setup", usageSuffix: " <setup>" },
11
11
  { name: "init", description: "Create local config template" },
12
12
  { name: "config", description: "Show resolved configuration" },
13
- { name: "files", description: "Choose synced files" },
13
+ { name: "files", description: "Choose included content" },
14
14
  { name: "status", description: "Show sync status" },
15
15
  { name: "diff", description: "Show local/remote diff" },
16
16
  { name: "doctor", description: "Check config, secrets, and lock state" },
@@ -27,39 +27,39 @@ export type SyncCommandName = (typeof SYNC_COMMANDS)[number]["name"];
27
27
  const SYNC_COMMAND_COMPLETIONS: readonly CommandArgumentCompletion[] = SYNC_COMMANDS.map(
28
28
  ({ name, description }) => ({ value: name, label: name, description }),
29
29
  );
30
- let targetCompletionNames: string[] = [];
30
+ let setupCompletionNames: string[] = [];
31
31
 
32
- export function setSyncTargetCompletions(names: readonly string[]) {
33
- targetCompletionNames = [...new Set(names)].sort((left, right) => left.localeCompare(right));
32
+ export function setSyncSetupCompletions(names: readonly string[]) {
33
+ setupCompletionNames = [...new Set(names)].sort((left, right) => left.localeCompare(right));
34
34
  }
35
- const TARGET_FLAG_COMPLETION = {
36
- value: "--target",
37
- label: "--target",
38
- description: "Address a target without switching",
35
+ const SETUP_FLAG_COMPLETION = {
36
+ value: "--setup",
37
+ label: "--setup",
38
+ description: "Address a sync setup without switching",
39
39
  } as const;
40
40
  const SYNC_FLAG_COMPLETIONS: Record<string, readonly CommandArgumentCompletion[]> = {
41
- config: [TARGET_FLAG_COMPLETION],
42
- files: [TARGET_FLAG_COMPLETION],
43
- status: [TARGET_FLAG_COMPLETION],
44
- diff: [TARGET_FLAG_COMPLETION],
45
- doctor: [TARGET_FLAG_COMPLETION],
41
+ config: [SETUP_FLAG_COMPLETION],
42
+ files: [SETUP_FLAG_COMPLETION],
43
+ status: [SETUP_FLAG_COMPLETION],
44
+ diff: [SETUP_FLAG_COMPLETION],
45
+ doctor: [SETUP_FLAG_COMPLETION],
46
46
  push: [
47
47
  ...YES_FLAG_COMPLETIONS,
48
48
  { value: "--force", label: "--force", description: "Overwrite visible remote changes" },
49
- TARGET_FLAG_COMPLETION,
49
+ SETUP_FLAG_COMPLETION,
50
50
  ],
51
51
  pull: [
52
52
  ...YES_FLAG_COMPLETIONS,
53
53
  { value: "--force", label: "--force", description: "Overwrite local changes" },
54
- TARGET_FLAG_COMPLETION,
54
+ SETUP_FLAG_COMPLETION,
55
55
  ],
56
56
  sync: [
57
57
  ...YES_FLAG_COMPLETIONS,
58
58
  { value: "--force", label: "--force", description: "Resolve conflicts by forcing action" },
59
- TARGET_FLAG_COMPLETION,
59
+ SETUP_FLAG_COMPLETION,
60
60
  ],
61
- history: [TARGET_FLAG_COMPLETION],
62
- rollback: [...YES_FLAG_COMPLETIONS, TARGET_FLAG_COMPLETION],
61
+ history: [SETUP_FLAG_COMPLETION],
62
+ rollback: [...YES_FLAG_COMPLETIONS, SETUP_FLAG_COMPLETION],
63
63
  unlock: [{ value: "--stale", label: "--stale", description: "Remove only a stale lock" }],
64
64
  };
65
65
 
@@ -75,17 +75,17 @@ export function parseOptions(args: string[]): CommandOptions {
75
75
  let yes = false;
76
76
  let force = false;
77
77
  let stale = false;
78
- let target: string | undefined;
78
+ let setup: string | undefined;
79
79
  for (let index = 0; index < args.length; index += 1) {
80
80
  const arg = args[index];
81
81
  if (arg === "--yes" || arg === "-y") yes = true;
82
82
  else if (arg === "--force") force = true;
83
83
  else if (arg === "--stale") stale = true;
84
- else if (arg === "--target") {
84
+ else if (arg === "--setup") {
85
85
  const name = args[index + 1];
86
- if (!name || name.startsWith("-")) throw new Error("--target requires a target name.");
87
- if (target !== undefined) throw new Error("--target may be provided only once.");
88
- target = name;
86
+ if (!name || name.startsWith("-")) throw new Error("--setup requires a sync setup name.");
87
+ if (setup !== undefined) throw new Error("--setup may be provided only once.");
88
+ setup = name;
89
89
  index += 1;
90
90
  } else if (arg.startsWith("-")) {
91
91
  throw new Error(`Unknown sync option: ${arg}`);
@@ -98,13 +98,13 @@ export function parseOptions(args: string[]): CommandOptions {
98
98
  silent: false,
99
99
  reload: true,
100
100
  auto: false,
101
- ...(target === undefined ? {} : { target }),
101
+ ...(setup === undefined ? {} : { setup }),
102
102
  args: values,
103
103
  };
104
104
  }
105
105
 
106
106
  export function validateCommandOptions(command: string, options: CommandOptions) {
107
- const targetAllowed = new Set([
107
+ const setupAllowed = new Set([
108
108
  "config",
109
109
  "files",
110
110
  "status",
@@ -116,8 +116,8 @@ export function validateCommandOptions(command: string, options: CommandOptions)
116
116
  "history",
117
117
  "rollback",
118
118
  ]);
119
- if (options.target && !targetAllowed.has(command)) {
120
- throw new Error(`--target is not supported by /sync ${command}.`);
119
+ if (options.setup && !setupAllowed.has(command)) {
120
+ throw new Error(`--setup is not supported by /sync ${command}.`);
121
121
  }
122
122
  if ((options.yes || options.force) && !["push", "pull", "sync", "rollback"].includes(command)) {
123
123
  throw new Error(`Confirmation/force options are not supported by /sync ${command}.`);
@@ -128,8 +128,8 @@ export function validateCommandOptions(command: string, options: CommandOptions)
128
128
  const expectedValues = command === "rollback" || command === "use" ? 1 : 0;
129
129
  if (options.args.length !== expectedValues) {
130
130
  if (command === "rollback")
131
- throw new Error("Usage: /sync rollback <snapshot-id> [--yes] [--target <name>]");
132
- if (command === "use") throw new Error("Usage: /sync use <target>");
131
+ throw new Error("Usage: /sync rollback <snapshot-id> [--yes] [--setup <name>]");
132
+ if (command === "use") throw new Error("Usage: /sync use <setup>");
133
133
  throw new Error(`Unexpected argument for /sync ${command}: ${options.args.join(" ")}`);
134
134
  }
135
135
  }
@@ -151,12 +151,12 @@ export function completeSyncArguments(argumentPrefix: string): CommandArgumentCo
151
151
  const args = tokens.slice(1);
152
152
  if (command === "use") {
153
153
  if (args.length > 1 || (trailingSpace && args.length > 0)) return null;
154
- return completeTargetValue(prefix, trailingSpace ? "" : (args[0] ?? ""));
154
+ return completeSetupValue(prefix, trailingSpace ? "" : (args[0] ?? ""));
155
155
  }
156
- const targetFlagIndex = args.lastIndexOf("--target");
157
- if (targetFlagIndex >= 0 && targetFlagIndex === args.length - (trailingSpace ? 1 : 2)) {
158
- const currentTarget = trailingSpace ? "" : (args.at(-1) ?? "");
159
- if (!currentTarget.startsWith("-")) return completeTargetValue(prefix, currentTarget);
156
+ const setupFlagIndex = args.lastIndexOf("--setup");
157
+ if (setupFlagIndex >= 0 && setupFlagIndex === args.length - (trailingSpace ? 1 : 2)) {
158
+ const currentSetup = trailingSpace ? "" : (args.at(-1) ?? "");
159
+ if (!currentSetup.startsWith("-")) return completeSetupValue(prefix, currentSetup);
160
160
  }
161
161
 
162
162
  const flagCompletions = SYNC_FLAG_COMPLETIONS[command];
@@ -181,15 +181,15 @@ export function completeSyncArguments(argumentPrefix: string): CommandArgumentCo
181
181
  : null;
182
182
  }
183
183
 
184
- function completeTargetValue(prefix: string, current: string) {
184
+ function completeSetupValue(prefix: string, current: string) {
185
185
  const currentRaw = current ? (prefix.match(/\S+$/u)?.[0] ?? "") : "";
186
186
  const completionPrefix = currentRaw ? prefix.slice(0, prefix.length - currentRaw.length) : prefix;
187
- const matches = targetCompletionNames.filter((name) => name.startsWith(current));
187
+ const matches = setupCompletionNames.filter((name) => name.startsWith(current));
188
188
  return matches.length > 0
189
189
  ? matches.map((name) => ({
190
190
  value: `${completionPrefix}${name}`,
191
191
  label: name,
192
- description: "Sync target",
192
+ description: "Sync setup",
193
193
  }))
194
194
  : null;
195
195
  }
@@ -230,6 +230,6 @@ export function usage() {
230
230
  return [
231
231
  "Usage: /sync <command>",
232
232
  `Commands: ${commands}`,
233
- "Settings: use /sync init or edit profiles and targets in ~/.pi/agent/pi-sync.json (or $PI_CODING_AGENT_DIR/pi-sync.json). Existing PI_SYNC_* overrides still work but are deprecated for future major-version removal.",
233
+ "Settings: use /sync init or edit storage connections and sync setups in ~/.pi/agent/pi-sync.json (or the configured Pi agent directory). Version 1 and version 2 settings are unsupported and are never rewritten.",
234
234
  ].join("\n");
235
235
  }
@@ -0,0 +1,7 @@
1
+ export function isMissingConfigError(error: unknown) {
2
+ return (
3
+ error instanceof Error &&
4
+ (error.message.startsWith("Missing pi-sync settings.") ||
5
+ error.message === "No sync setups are configured.")
6
+ );
7
+ }
@@ -114,6 +114,17 @@ export function consumeLocalConfigMigrationNotice() {
114
114
  return notice;
115
115
  }
116
116
 
117
+ export async function readActiveLocalConfigDocumentForRepair(): Promise<
118
+ LocalConfigDocument | undefined
119
+ > {
120
+ return withLocalConfigFileLock(async () => {
121
+ const canonicalPath = localConfigPath();
122
+ const filePath = (await pathExists(canonicalPath)) ? canonicalPath : legacyLocalConfigPath();
123
+ const snapshot = await readConfigSnapshotIfExists(filePath);
124
+ return snapshot ? { path: filePath, ...snapshot } : undefined;
125
+ });
126
+ }
127
+
117
128
  export async function readMigratingLocalConfigDocument(
118
129
  validateForMigration: (settings: Record<string, unknown>) => void,
119
130
  ): Promise<LocalConfigDocument | undefined> {
@@ -124,6 +135,44 @@ export async function readMigratingLocalConfigDocument(
124
135
  });
125
136
  }
126
137
 
138
+ export function updateLocalConfigDocument<T extends Record<string, unknown>>(
139
+ defaultValue: T,
140
+ update: (current: T) => T,
141
+ validate: (value: Record<string, unknown>) => void,
142
+ signal?: AbortSignal,
143
+ ): Promise<T> {
144
+ return withLocalConfigFileLock(async () => {
145
+ signal?.throwIfAborted();
146
+ const configPath = await prepareLocalConfigPath(validate);
147
+ const snapshot = await readConfigSnapshotIfExists(configPath);
148
+ const document = snapshot ? { path: configPath, ...snapshot } : undefined;
149
+ const current = document
150
+ ? (structuredClone(document.parsed) as T)
151
+ : structuredClone(defaultValue);
152
+ const next = update(current);
153
+ validate(next);
154
+ signal?.throwIfAborted();
155
+ if (document && JSON.stringify(document.parsed) === JSON.stringify(next)) return next;
156
+ if (document) await replaceLocalConfigDocumentUnlocked(document, next);
157
+ else await installPrivateConfigExclusively(localConfigPath(), serializedConfig(next), true);
158
+ return next;
159
+ });
160
+ }
161
+
162
+ export function createLocalConfigDocument(value: Record<string, unknown>) {
163
+ return withLocalConfigFileLock(async () => {
164
+ const bytes = serializedConfig(value);
165
+ try {
166
+ await installPrivateConfigExclusively(localConfigPath(), bytes, true);
167
+ } catch (error) {
168
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
169
+ throw new Error("Pi-sync settings were created concurrently; reopen settings and retry.");
170
+ }
171
+ throw error;
172
+ }
173
+ });
174
+ }
175
+
127
176
  export function replaceLocalConfigDocument(
128
177
  document: LocalConfigDocument,
129
178
  value: Record<string, unknown>,
@@ -135,7 +184,7 @@ async function replaceLocalConfigDocumentUnlocked(
135
184
  document: LocalConfigDocument,
136
185
  value: Record<string, unknown>,
137
186
  ) {
138
- const nextBytes = Buffer.from(`${JSON.stringify(value, null, "\t")}\n`, "utf8");
187
+ const nextBytes = serializedConfig(value);
139
188
  const canonicalPath = localConfigPath();
140
189
  if (document.path !== canonicalPath) {
141
190
  if (!(await configDocumentStillMatches(document))) throw settingsChangedError();
@@ -166,13 +215,18 @@ async function replaceLocalConfigDocumentUnlocked(
166
215
  }
167
216
  throw error;
168
217
  }
169
- await afterReplacementInstalledHook();
170
- if (!(await fileIdentityAndContentsMatch(quarantinePath, document.identity, document.bytes))) {
218
+ try {
219
+ await afterReplacementInstalledHook();
220
+ if (!(await fileIdentityAndContentsMatch(quarantinePath, document.identity, document.bytes))) {
221
+ throw settingsChangedError();
222
+ }
223
+ if (process.platform !== "win32") await fs.chmod(quarantinePath, 0o600);
224
+ } catch (error) {
171
225
  await quarantineAndRemoveConfigIfMatchesUnlocked(canonicalPath, installed, nextBytes);
172
226
  await restoreQuarantinedConfig(canonicalPath, quarantinePath);
173
- throw settingsChangedError();
227
+ throw error;
174
228
  }
175
- if (process.platform !== "win32") await fs.chmod(quarantinePath, 0o600);
229
+ await fs.rm(quarantinePath).catch(() => undefined);
176
230
  await syncParentDirectory(canonicalPath).catch(() => undefined);
177
231
  }
178
232
 
@@ -301,6 +355,10 @@ async function readConfigSnapshotIfExists(filePath: string): Promise<ConfigSnaps
301
355
  }
302
356
  }
303
357
 
358
+ function serializedConfig(value: Record<string, unknown>) {
359
+ return Buffer.from(`${JSON.stringify(value, null, "\t")}\n`, "utf8");
360
+ }
361
+
304
362
  function parseConfigObject(bytes: Buffer, filePath: string) {
305
363
  let parsed: unknown;
306
364
  try {
@@ -466,7 +524,7 @@ async function restoreQuarantinedConfig(filePath: string, quarantinePath: string
466
524
  await syncParentDirectory(filePath);
467
525
  return;
468
526
  } catch (error) {
469
- if (isUnsupportedLinkError(error)) {
527
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
470
528
  try {
471
529
  await fs.copyFile(quarantinePath, filePath, fsConstants.COPYFILE_EXCL);
472
530
  if (process.platform !== "win32") {
@@ -478,8 +536,6 @@ async function restoreQuarantinedConfig(filePath: string, quarantinePath: string
478
536
  } catch (copyError) {
479
537
  if ((copyError as NodeJS.ErrnoException).code !== "EEXIST") return;
480
538
  }
481
- } else if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
482
- return;
483
539
  }
484
540
  }
485
541
  try {