@narumitw/pi-sync 0.29.1 → 0.30.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.
@@ -0,0 +1,199 @@
1
+ import {
2
+ type ExtensionCommandContext,
3
+ getSettingsListTheme,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { Container, type SettingItem, SettingsList, Text } from "@earendil-works/pi-tui";
6
+ import {
7
+ isCloudflareR2Endpoint,
8
+ isEnabled,
9
+ loadPartialConfig,
10
+ loadTargetSwitchAction,
11
+ localConfigPath,
12
+ normalizeExtraFiles,
13
+ normalizeSyncFiles,
14
+ readLocalConfigObject,
15
+ updateLocalConfig,
16
+ } from "./config.js";
17
+ import { errorMessage, safeTerminalText } from "./sync-format.js";
18
+ import {
19
+ saveTargetSwitchAction,
20
+ TARGET_SWITCH_ACTION_OPTIONS,
21
+ type TargetPullOutcome,
22
+ targetSwitchActionFromLabel,
23
+ targetSwitchActionLabel,
24
+ } from "./target-switch.js";
25
+
26
+ export type SyncSettingsRoute = (
27
+ route: string,
28
+ signal?: AbortSignal,
29
+ onCommit?: () => void,
30
+ target?: string,
31
+ ) => Promise<TargetPullOutcome | undefined>;
32
+
33
+ export async function showSyncSettings(ctx: ExtensionCommandContext, runRoute: SyncSettingsRoute) {
34
+ if (ctx.mode !== "tui") {
35
+ ctx.ui.notify(`Edit pi-sync settings manually: ${safeTerminalText(localConfigPath())}`, "info");
36
+ return;
37
+ }
38
+ while (true) {
39
+ const raw = await readLocalConfigObject();
40
+ const version2 = raw?.version === 2;
41
+ const partial = await loadPartialConfig();
42
+ const action = await showSettingsList(ctx, partial, version2);
43
+ if (action !== "files") return;
44
+ await runRoute("files", undefined, undefined, version2 ? partial.target : undefined);
45
+ }
46
+ }
47
+
48
+ async function showSettingsList(
49
+ ctx: ExtensionCommandContext,
50
+ partial: Awaited<ReturnType<typeof loadPartialConfig>>,
51
+ version2: boolean,
52
+ ) {
53
+ const targetName = version2 ? partial.target : undefined;
54
+ const automaticSyncOverridden = Object.hasOwn(process.env, "PI_SYNC_AUTO_SYNC");
55
+ const automaticSyncValue = isEnabled(partial.autoSync, true) ? "On" : "Off";
56
+ const targetSwitchAction = await loadTargetSwitchAction();
57
+ const targetSwitchValue = targetSwitchActionLabel(targetSwitchAction);
58
+ let saveQueue = Promise.resolve();
59
+ const latestRequested = new Map<string, string>();
60
+
61
+ return ctx.ui.custom<"files" | undefined>((tui, theme, _keybindings, done) => {
62
+ const items: SettingItem[] = [
63
+ {
64
+ id: "automaticSync",
65
+ label: automaticSyncOverridden ? "Automatic sync (environment override)" : "Automatic sync",
66
+ description: automaticSyncOverridden
67
+ ? "Read-only while deprecated PI_SYNC_AUTO_SYNC overrides this target. Move the value into target settings before the future major removal."
68
+ : "Run conservative synchronization automatically at session startup and shutdown.",
69
+ currentValue: automaticSyncValue,
70
+ ...(automaticSyncOverridden ? {} : { values: ["On", "Off"] }),
71
+ },
72
+ ...(version2
73
+ ? [
74
+ {
75
+ id: "targetSwitchAction",
76
+ label: "After target switch",
77
+ description:
78
+ "Ask before starting a pull, start a pull review automatically, or switch without checking remote files. Every pull still shows exact changes before apply.",
79
+ currentValue: targetSwitchValue,
80
+ values: TARGET_SWITCH_ACTION_OPTIONS.map(({ label }) => label),
81
+ },
82
+ ]
83
+ : []),
84
+ {
85
+ id: "syncFiles",
86
+ label: "Synced content",
87
+ description: `${normalizeSyncFiles(partial.syncFiles).length} built-in groups and ${normalizeExtraFiles(partial.extraFiles).length} extra files. Opens the reviewed content-selection draft.`,
88
+ currentValue: "Open editor",
89
+ values: ["Open editor"],
90
+ },
91
+ ];
92
+ const container = new Container();
93
+ container.addChild(new Text(theme.fg("accent", theme.bold("Pi Sync Settings")), 1, 0));
94
+ container.addChild(
95
+ new Text(
96
+ theme.fg(
97
+ "muted",
98
+ `Target: ${safeTerminalText(partial.target ?? "default")} · ${storageDescription(partial.storageKind, partial.endpoint, partial.bucket)}`,
99
+ ),
100
+ 1,
101
+ 0,
102
+ ),
103
+ );
104
+ let settingsList: SettingsList;
105
+ let closing = false;
106
+ const closeAfterSaves = (result: "files" | undefined) => {
107
+ if (closing) return;
108
+ closing = true;
109
+ void saveQueue.then(() => done(result));
110
+ };
111
+ settingsList = new SettingsList(
112
+ items,
113
+ Math.min(items.length + 2, 15),
114
+ getSettingsListTheme(),
115
+ (id, newValue) => {
116
+ if (id === "syncFiles") {
117
+ closeAfterSaves("files");
118
+ return;
119
+ }
120
+ latestRequested.set(id, newValue);
121
+ const fallbackValue = id === "automaticSync" ? automaticSyncValue : targetSwitchValue;
122
+ const operation = saveQueue.then(async () => {
123
+ let previousValue = fallbackValue;
124
+ try {
125
+ if (id === "automaticSync") {
126
+ const latest = await loadPartialConfig(targetName);
127
+ previousValue = isEnabled(latest.autoSync, true) ? "On" : "Off";
128
+ const enabled = newValue === "On";
129
+ await updateSettingsTarget(targetName, (target) => ({
130
+ ...target,
131
+ autoSync: enabled,
132
+ }));
133
+ ctx.ui.notify(
134
+ `Automatic sync ${enabled ? "enabled" : "disabled"} for “${safeTerminalText(partial.target ?? "default")}”.`,
135
+ "info",
136
+ );
137
+ } else if (id === "targetSwitchAction") {
138
+ const latest = await loadTargetSwitchAction();
139
+ previousValue = targetSwitchActionLabel(latest);
140
+ const action = targetSwitchActionFromLabel(newValue);
141
+ if (!action) throw new Error(`Invalid target-switch action: ${newValue}`);
142
+ await saveTargetSwitchAction(action);
143
+ ctx.ui.notify(`After target switch: ${newValue}.`, "info");
144
+ }
145
+ } catch (error) {
146
+ if (latestRequested.get(id) === newValue) {
147
+ settingsList.updateValue(id, previousValue);
148
+ }
149
+ ctx.ui.notify(`Pi Sync settings save failed: ${errorMessage(error)}`, "error");
150
+ tui.requestRender();
151
+ }
152
+ });
153
+ saveQueue = operation.catch(() => undefined);
154
+ },
155
+ () => closeAfterSaves(undefined),
156
+ );
157
+ container.addChild(settingsList);
158
+ return {
159
+ render: (width: number) => container.render(width),
160
+ invalidate: () => container.invalidate(),
161
+ handleInput(data: string) {
162
+ settingsList.handleInput?.(data);
163
+ tui.requestRender();
164
+ },
165
+ };
166
+ });
167
+ }
168
+
169
+ async function updateSettingsTarget(
170
+ targetName: string | undefined,
171
+ update: (target: Record<string, unknown>) => Record<string, unknown>,
172
+ ) {
173
+ await updateLocalConfig((current) => {
174
+ if (current.version !== 2) return update(current);
175
+ const targets = ownRecord(current.targets);
176
+ const selected =
177
+ targetName ?? (typeof current.activeTarget === "string" ? current.activeTarget : undefined);
178
+ if (!targets || !selected) throw new Error("Settings target is not configured.");
179
+ const target = ownRecord(targets[selected]);
180
+ if (!target) throw new Error(`Settings target “${selected}” is invalid.`);
181
+ return { ...current, targets: { ...targets, [selected]: update(target) } };
182
+ });
183
+ }
184
+
185
+ function storageDescription(
186
+ kind: string | undefined,
187
+ endpoint: string | undefined,
188
+ bucket: string | undefined,
189
+ ) {
190
+ const label =
191
+ kind === "r2" || isCloudflareR2Endpoint(endpoint) ? "Cloudflare R2" : "S3-compatible";
192
+ return `${label} · ${safeTerminalText(bucket ?? "bucket missing")}`;
193
+ }
194
+
195
+ function ownRecord(value: unknown): Record<string, unknown> | undefined {
196
+ return value && typeof value === "object" && !Array.isArray(value)
197
+ ? (value as Record<string, unknown>)
198
+ : undefined;
199
+ }
package/src/sync.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { constants as fsConstants } from "node:fs";
2
1
  import fs from "node:fs/promises";
3
2
  import path from "node:path";
4
3
  import { promisify } from "node:util";
@@ -17,7 +16,9 @@ import {
17
16
  validateCommandOptions,
18
17
  } from "./command.js";
19
18
  import {
19
+ activeLocalConfigPath,
20
20
  configuredTargetNames,
21
+ consumeLocalConfigMigrationNotice,
21
22
  deprecatedPiSyncEnvironmentWarnings,
22
23
  ensureStateDir,
23
24
  isEnabled,
@@ -29,6 +30,7 @@ import {
29
30
  localConfigTemplate,
30
31
  normalizeExtraFiles,
31
32
  normalizeSyncFiles,
33
+ readLocalConfigObject,
32
34
  readStateForConfig,
33
35
  sessionDirForApply,
34
36
  sessionTokenWarnings,
@@ -39,7 +41,7 @@ import {
39
41
  } from "./config.js";
40
42
  import { showFileSelection } from "./file-selection.js";
41
43
  import { inspectLock, isLockGuardHeld, isStaleLock, unlock, withLock } from "./lock.js";
42
- import { showSetupWizard, showSyncManager, useSyncTarget } from "./manager-ui.js";
44
+ import { showSetupWizard, showSyncManager } from "./manager-ui.js";
43
45
  import {
44
46
  historyKey,
45
47
  latestKey,
@@ -86,6 +88,7 @@ import {
86
88
  snapshotsMatch,
87
89
  syncPolicyChanged,
88
90
  } from "./sync-state.js";
91
+ import { TargetPullRequiresUiError, useSyncTarget } from "./target-switch.js";
89
92
  import type {
90
93
  CommandOptions,
91
94
  LatestPointer,
@@ -96,9 +99,7 @@ import type {
96
99
  SyncState,
97
100
  } from "./types.js";
98
101
 
99
- const gzipAsync = promisify(gzip);
100
- const gunzipAsync = promisify(gunzip);
101
-
102
+ const [gzipAsync, gunzipAsync] = [promisify(gzip), promisify(gunzip)];
102
103
  const STATUS_KEY = "sync";
103
104
  const VERSION = 1;
104
105
  const DEFAULT_PROFILE = "default";
@@ -143,6 +144,8 @@ export default function sync(pi: ExtensionAPI) {
143
144
  } catch {
144
145
  setSyncTargetCompletions([]);
145
146
  }
147
+ const migrationNotice = consumeLocalConfigMigrationNotice();
148
+ if (migrationNotice) ctx.ui.notify(migrationNotice, "warning");
146
149
  const warnings = deprecatedPiSyncEnvironmentWarnings();
147
150
  if (warnings.length > 0) ctx.ui.notify(warnings.join("\n"), "warning");
148
151
  await autoSync(ctx);
@@ -159,8 +162,8 @@ export default function sync(pi: ExtensionAPI) {
159
162
  async function handleCommand(rawArgs: string, ctx: ExtensionCommandContext) {
160
163
  if (!rawArgs.trim()) {
161
164
  try {
162
- await showSyncManager(ctx, (route, signal, onCommit) =>
163
- executeCommand(route, ctx, signal, onCommit),
165
+ await showSyncManager(ctx, (route, signal, onCommit, target) =>
166
+ executeCommand(route, ctx, signal, onCommit, target),
164
167
  );
165
168
  } catch (error) {
166
169
  ctx.ui.setStatus(STATUS_KEY, undefined);
@@ -176,12 +179,14 @@ async function executeCommand(
176
179
  ctx: ExtensionCommandContext,
177
180
  signal?: AbortSignal,
178
181
  onCommit?: () => void,
182
+ target?: string,
179
183
  ) {
180
184
  try {
181
185
  const command = await resolveSyncCommand(rawArgs, ctx);
182
186
  if (!command) return;
183
187
  const { subcommand, rest } = command;
184
188
  const options = parseOptions(rest);
189
+ if (target !== undefined) options.target = target;
185
190
  if (signal) options.signal = signal;
186
191
  if (onCommit) options.onCommit = onCommit;
187
192
  validateCommandOptions(subcommand, options);
@@ -191,7 +196,9 @@ async function executeCommand(
191
196
  ctx.ui.notify(usage(), "info");
192
197
  return;
193
198
  case "use":
194
- await useSyncTarget(ctx, options.args[0] ?? "");
199
+ await useSyncTarget(ctx, options.args[0] ?? "", (selectedTarget) =>
200
+ withLock("pull", () => pull(ctx, { ...options, target: selectedTarget })),
201
+ );
195
202
  return;
196
203
  case "init":
197
204
  await initConfig(ctx);
@@ -215,8 +222,7 @@ async function executeCommand(
215
222
  await withLock("push", () => push(ctx, options));
216
223
  return;
217
224
  case "pull":
218
- await withLock("pull", () => pull(ctx, options));
219
- return;
225
+ return await withLock("pull", () => pull(ctx, options));
220
226
  case "sync":
221
227
  await withLock("sync", () => syncBoth(ctx, options));
222
228
  return;
@@ -235,6 +241,7 @@ async function executeCommand(
235
241
  } catch (error) {
236
242
  ctx.ui.setStatus(STATUS_KEY, undefined);
237
243
  if (signal?.aborted && error instanceof Error && error.name === "AbortError") return;
244
+ if (error instanceof TargetPullRequiresUiError) throw error;
238
245
  ctx.ui.notify(errorMessage(error), "error");
239
246
  }
240
247
  }
@@ -276,12 +283,9 @@ async function autoPushSessions(ctx: ExtensionContext) {
276
283
 
277
284
  async function initConfig(ctx: ExtensionCommandContext) {
278
285
  const configPath = localConfigPath();
279
- try {
280
- await fs.access(configPath, fsConstants.F_OK);
281
- ctx.ui.notify(`Config already exists: ${configPath}`, "info");
286
+ if (await readLocalConfigObject()) {
287
+ ctx.ui.notify(`Config already exists: ${await activeLocalConfigPath()}`, "info");
282
288
  return;
283
- } catch {
284
- // Create or guide setup below.
285
289
  }
286
290
 
287
291
  if (ctx.mode === "tui") {
@@ -552,7 +556,7 @@ async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: Co
552
556
  ) {
553
557
  ctx.ui.setStatus(STATUS_KEY, undefined);
554
558
  ctx.ui.notify("Pull cancelled.", "info");
555
- return;
559
+ return "cancelled" as const;
556
560
  }
557
561
 
558
562
  options.onCommit?.();
@@ -586,6 +590,7 @@ async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: Co
586
590
  );
587
591
  }
588
592
  if (options.reload) await maybeReload(ctx);
593
+ return "applied" as const;
589
594
  }
590
595
 
591
596
  async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options: CommandOptions) {
@@ -0,0 +1,119 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { setSyncTargetCompletions } from "./command.js";
3
+ import {
4
+ configuredTargetNames,
5
+ loadConfig,
6
+ loadTargetSwitchAction,
7
+ readLocalConfigObject,
8
+ updateLocalConfig,
9
+ } from "./config.js";
10
+ import { safeTerminalText } from "./sync-format.js";
11
+ import type { TargetSwitchAction } from "./types.js";
12
+
13
+ export const TARGET_SWITCH_ACTION_OPTIONS: ReadonlyArray<{
14
+ label: string;
15
+ value: TargetSwitchAction;
16
+ }> = [
17
+ { label: "Ask before pull", value: "ask" },
18
+ { label: "Start pull", value: "pull" },
19
+ { label: "Switch only", value: "switch-only" },
20
+ ];
21
+
22
+ export function targetSwitchActionLabel(action: TargetSwitchAction) {
23
+ return TARGET_SWITCH_ACTION_OPTIONS.find((option) => option.value === action)?.label ?? action;
24
+ }
25
+
26
+ export function targetSwitchActionFromLabel(label: string): TargetSwitchAction | undefined {
27
+ return TARGET_SWITCH_ACTION_OPTIONS.find((option) => option.label === label)?.value;
28
+ }
29
+
30
+ export async function saveTargetSwitchAction(action: TargetSwitchAction) {
31
+ await updateLocalConfig((settings) => {
32
+ if (settings.version !== 2) {
33
+ throw new Error("Target-switch settings require version 2 settings.");
34
+ }
35
+ return { ...settings, targetSwitchAction: action };
36
+ });
37
+ }
38
+
39
+ export type TargetPullOutcome = "applied" | "cancelled";
40
+
41
+ export class TargetPullRequiresUiError extends Error {}
42
+
43
+ export interface TargetSwitchResult {
44
+ pullApplied: boolean;
45
+ }
46
+
47
+ export async function useSyncTarget(
48
+ ctx: ExtensionCommandContext,
49
+ name: string,
50
+ pullCurrentTarget?: (target: string) => Promise<TargetPullOutcome | undefined>,
51
+ ): Promise<TargetSwitchResult> {
52
+ const normalized = name.trim();
53
+ if (!normalized) throw new Error("Usage: /sync use <target>");
54
+ await loadConfig(normalized);
55
+ const before = await readLocalConfigObject();
56
+ if (before?.version === 2 && before.activeTarget === normalized) {
57
+ ctx.ui.notify(`Target “${safeTerminalText(normalized)}” is already current.`, "info");
58
+ return { pullApplied: false };
59
+ }
60
+ const action = await loadTargetSwitchAction();
61
+ if (action === "pull" && !ctx.hasUI) {
62
+ throw new TargetPullRequiresUiError(
63
+ `Automatic target pulls require interactive confirmation; target “${safeTerminalText(normalized)}” was not switched. Use TUI or RPC mode.`,
64
+ );
65
+ }
66
+ let switched = false;
67
+ await updateLocalConfig((current) => {
68
+ if (current.version !== 2) throw new Error("Target switching requires version 2 settings.");
69
+ if (current.activeTarget === normalized) return current;
70
+ switched = true;
71
+ return { ...current, activeTarget: normalized };
72
+ });
73
+ if (!switched) {
74
+ ctx.ui.notify(`Target “${safeTerminalText(normalized)}” is already current.`, "info");
75
+ return { pullApplied: false };
76
+ }
77
+ setSyncTargetCompletions(await configuredTargetNames());
78
+
79
+ if (action === "switch-only") {
80
+ ctx.ui.notify(`Switched to “${safeTerminalText(normalized)}”. No files were pulled.`, "info");
81
+ return { pullApplied: false };
82
+ }
83
+ if (action === "ask") {
84
+ if (ctx.mode !== "tui") {
85
+ ctx.ui.notify(
86
+ `Switched to “${safeTerminalText(normalized)}”. No files were pulled because confirmation requires TUI mode; run /sync pull to apply the target.`,
87
+ "info",
88
+ );
89
+ return { pullApplied: false };
90
+ }
91
+ const confirmed = await ctx.ui.confirm(
92
+ `Review a pull for target “${safeTerminalText(normalized)}” now?`,
93
+ "pi-sync will check the remote snapshot and show the exact local writes and deletions before applying anything.",
94
+ );
95
+ if (!confirmed) {
96
+ ctx.ui.notify(
97
+ `Switched to “${safeTerminalText(normalized)}”; files were not pulled.`,
98
+ "info",
99
+ );
100
+ return { pullApplied: false };
101
+ }
102
+ }
103
+
104
+ ctx.ui.notify(
105
+ `Switched to “${safeTerminalText(normalized)}”. Checking remote files for a reviewed pull…`,
106
+ "info",
107
+ );
108
+ if (!pullCurrentTarget) {
109
+ throw new Error(`Switched to “${safeTerminalText(normalized)}”, but pull is unavailable.`);
110
+ }
111
+ const pullOutcome = await pullCurrentTarget(normalized);
112
+ if (pullOutcome === "cancelled") {
113
+ ctx.ui.notify(
114
+ `Pull cancelled; target “${safeTerminalText(normalized)}” remains active and synced files were not changed.`,
115
+ "info",
116
+ );
117
+ }
118
+ return { pullApplied: pullOutcome === "applied" };
119
+ }
package/src/types.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export type StorageProfileKind = "r2" | "s3-compatible";
2
+ export type TargetSwitchAction = "ask" | "pull" | "switch-only";
2
3
 
3
4
  export interface StorageProfileSettings {
4
5
  kind?: StorageProfileKind;
@@ -23,6 +24,7 @@ export interface SyncTargetSettings {
23
24
  export interface PiSyncSettingsV2 {
24
25
  version: 2;
25
26
  activeTarget?: string;
27
+ targetSwitchAction?: TargetSwitchAction;
26
28
  profiles?: Record<string, StorageProfileSettings>;
27
29
  targets?: Record<string, SyncTargetSettings>;
28
30
  [key: string]: unknown;