@narumitw/pi-sync 0.29.0 → 0.29.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-sync",
3
- "version": "0.29.0",
3
+ "version": "0.29.1",
4
4
  "description": "Pi extension that syncs Pi configuration through Cloudflare R2 or S3-compatible storage.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/config.ts CHANGED
@@ -133,7 +133,7 @@ export function deprecatedPiSyncEnvironmentWarnings() {
133
133
  ];
134
134
  }
135
135
 
136
- function resolveLegacyPartialConfig(fileConfig: PartialConfig): PartialConfig {
136
+ export function resolveLegacyPartialConfig(fileConfig: PartialConfig): PartialConfig {
137
137
  return {
138
138
  ...fileConfig,
139
139
  target: DEFAULT_PROFILE,
@@ -156,7 +156,7 @@ function resolveLegacyPartialConfig(fileConfig: PartialConfig): PartialConfig {
156
156
  };
157
157
  }
158
158
 
159
- function resolveV2PartialConfig(
159
+ export function resolveV2PartialConfig(
160
160
  settings: Record<string, unknown>,
161
161
  targetName?: string,
162
162
  ): PartialConfig {
@@ -366,8 +366,11 @@ export async function writeState(profile: string, state: SyncState) {
366
366
 
367
367
  export async function readStateForConfig(config: SyncConfig): Promise<SyncState> {
368
368
  if (config.settingsVersion !== 2) return readState(config.profile);
369
+ const destination = statePathForConfig(config);
370
+ const state = await readJsonIfExists<SyncState>(destination);
371
+ if (state) return state;
369
372
  return (
370
- (await readJsonIfExists<SyncState>(statePathForConfig(config))) ?? {
373
+ (await migrateLegacyV2State(config, destination)) ?? {
371
374
  version: VERSION,
372
375
  profile: config.profile,
373
376
  lastFileHashes: {},
@@ -382,10 +385,78 @@ export async function writeStateForConfig(config: SyncConfig, state: SyncState)
382
385
  export function statePathForConfig(config: SyncConfig) {
383
386
  if (config.settingsVersion !== 2) return statePath(config.profile);
384
387
  const target = config.target ?? DEFAULT_PROFILE;
385
- const hash = createHash("sha256").update(target).digest("hex").slice(0, 10);
388
+ const identity = JSON.stringify([
389
+ target,
390
+ normalizeEndpointIdentity(config.endpoint),
391
+ normalizeRemoteKeySegment(config.bucket),
392
+ normalizeRemoteKeySegment(config.prefix),
393
+ normalizeRemoteKeySegment(config.profile),
394
+ ]);
395
+ const hash = createHash("sha256").update(identity).digest("hex").slice(0, 10);
386
396
  return path.join(stateDir(), "targets", `${safeName(target)}-${hash}.state.json`);
387
397
  }
388
398
 
399
+ export function statePathForPartialConfig(partial: PartialConfig) {
400
+ const profile = normalizeOptionalString(partial.profile) ?? DEFAULT_PROFILE;
401
+ if (partial.settingsVersion !== 2) return statePath(profile);
402
+ return statePathForConfig({
403
+ settingsVersion: 2,
404
+ target: partial.target ?? DEFAULT_PROFILE,
405
+ endpoint: normalizeConfiguredString(partial.endpoint) ?? "",
406
+ bucket: normalizeConfiguredString(partial.bucket) ?? "",
407
+ prefix: trimSlashes(normalizeOptionalString(partial.prefix) ?? DEFAULT_PREFIX),
408
+ profile,
409
+ } as SyncConfig);
410
+ }
411
+
412
+ async function migrateLegacyV2State(config: SyncConfig, destination: string) {
413
+ const target = config.target ?? DEFAULT_PROFILE;
414
+ const legacyHash = createHash("sha256").update(target).digest("hex").slice(0, 10);
415
+ const legacyPath = path.join(
416
+ stateDir(),
417
+ "targets",
418
+ `${safeName(target)}-${legacyHash}.state.json`,
419
+ );
420
+ const claimPath = `${legacyPath}.migrating-to-${path.basename(destination)}`;
421
+ if (!(await pathExists(claimPath))) {
422
+ try {
423
+ await fs.rename(legacyPath, claimPath);
424
+ } catch (error) {
425
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
426
+ }
427
+ }
428
+ if (!(await pathExists(claimPath))) return readJsonIfExists<SyncState>(destination);
429
+
430
+ await fs.mkdir(path.dirname(destination), { recursive: true });
431
+ try {
432
+ await fs.link(claimPath, destination);
433
+ } catch (error) {
434
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
435
+ }
436
+ const migrated = await readJsonIfExists<SyncState>(destination);
437
+ if (migrated) await fs.rm(claimPath, { force: true });
438
+ return migrated;
439
+ }
440
+
441
+ async function pathExists(filePath: string) {
442
+ try {
443
+ await fs.lstat(filePath);
444
+ return true;
445
+ } catch (error) {
446
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
447
+ throw error;
448
+ }
449
+ }
450
+
451
+ function normalizeEndpointIdentity(endpoint: string) {
452
+ const normalized = endpoint.trim();
453
+ try {
454
+ return new URL(normalized).toString();
455
+ } catch {
456
+ return normalized;
457
+ }
458
+ }
459
+
389
460
  export function agentDir() {
390
461
  return getAgentDir();
391
462
  }
package/src/lock.ts CHANGED
@@ -59,7 +59,11 @@ export type LockInspection =
59
59
  | { status: "unreadable" }
60
60
  | { status: "valid"; lock: LockFile };
61
61
 
62
- export async function withLock<T>(command: string, fn: () => Promise<T>): Promise<T> {
62
+ export async function withLock<T>(
63
+ command: string,
64
+ fn: () => Promise<T>,
65
+ options: { reclaimStale?: boolean } = {},
66
+ ): Promise<T> {
63
67
  await ensureStateDir();
64
68
  const lock: LockFile = {
65
69
  id: randomUUID(),
@@ -79,11 +83,24 @@ export async function withLock<T>(command: string, fn: () => Promise<T>): Promis
79
83
  throw await describeHeldLock();
80
84
  }
81
85
 
82
- const inspection = await inspectLock();
86
+ let inspection = await inspectLock();
83
87
  if (inspection.status === "valid" && isStaleLock(inspection.lock)) {
84
- throw new Error(
85
- `pi-sync lock is stale (pid ${inspection.lock.pid}). Run /sync unlock --stale, then retry.`,
86
- );
88
+ if (!options.reclaimStale) {
89
+ throw new Error(
90
+ `pi-sync lock is stale (pid ${inspection.lock.pid}). Run /sync unlock --stale, then retry.`,
91
+ );
92
+ }
93
+ guard.throwIfCompromised();
94
+ const rechecked = await inspectLock();
95
+ if (
96
+ rechecked.status !== "valid" ||
97
+ rechecked.lock.id !== inspection.lock.id ||
98
+ !isStaleLock(rechecked.lock)
99
+ ) {
100
+ throw new Error("pi-sync lock changed while preparing transaction recovery; retry.");
101
+ }
102
+ await fs.rm(lockPath(), { force: true });
103
+ inspection = { status: "missing" };
87
104
  }
88
105
  if (inspection.status === "valid") {
89
106
  throw new Error(
@@ -5,13 +5,14 @@ import {
5
5
  effectiveTargetRemoteIdentity,
6
6
  localConfigPath,
7
7
  readLocalConfigObject,
8
+ resolveLegacyPartialConfig,
9
+ resolveV2PartialConfig,
8
10
  stateDir,
9
- statePathForConfig,
11
+ statePathForPartialConfig,
10
12
  writeLocalConfigObject,
11
13
  } from "./config.js";
12
14
  import { withLock } from "./lock.js";
13
- import { safeName } from "./paths.js";
14
- import type { StorageProfileSettings, SyncConfig, SyncTargetSettings } from "./types.js";
15
+ import type { PartialConfig, StorageProfileSettings, SyncTargetSettings } from "./types.js";
15
16
 
16
17
  const LEGACY_FIELDS = new Set([
17
18
  "endpoint",
@@ -182,7 +183,8 @@ export async function removeSyncTarget(name: string) {
182
183
  return {
183
184
  ...settings,
184
185
  targets: nextTargets,
185
- activeTarget: Object.keys(nextTargets)[0],
186
+ activeTarget:
187
+ settings.activeTarget === name ? Object.keys(nextTargets)[0] : settings.activeTarget,
186
188
  };
187
189
  });
188
190
  }
@@ -234,8 +236,8 @@ async function adoptLegacyState(
234
236
  next: Record<string, unknown>,
235
237
  targetName: string,
236
238
  ) {
237
- const legacyProfile = typeof legacy.profile === "string" ? legacy.profile : "default";
238
- const source = path.join(stateDir(), `${safeName(legacyProfile)}.state.json`);
239
+ const legacyConfig = resolveLegacyPartialConfig(legacy as PartialConfig);
240
+ const source = statePathForPartialConfig(legacyConfig);
239
241
  let bytes: Buffer;
240
242
  try {
241
243
  bytes = await fs.readFile(source);
@@ -243,14 +245,7 @@ async function adoptLegacyState(
243
245
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
244
246
  throw error;
245
247
  }
246
- const targets = requireObject(next.targets, "targets");
247
- const target = requireObject(targets[targetName], "target");
248
- const profile = typeof target.namespace === "string" ? target.namespace : targetName;
249
- const destination = statePathForConfig({
250
- settingsVersion: 2,
251
- target: targetName,
252
- profile,
253
- } as SyncConfig);
248
+ const destination = statePathForPartialConfig(resolveV2PartialConfig(next, targetName));
254
249
  await fs.mkdir(path.dirname(destination), { recursive: true });
255
250
  try {
256
251
  await fs.writeFile(destination, bytes, { flag: "wx", mode: 0o600 });
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { agentDir, stateDir } from "./config.js";
5
+ import { withLock } from "./lock.js";
5
6
  import { assertWithinRoot, isPathInside } from "./paths.js";
6
7
  import { sessionStorageRoot } from "./snapshot.js";
7
8
  import type { SnapshotApplyPlan } from "./types.js";
@@ -49,15 +50,14 @@ export async function applySnapshotTransaction(
49
50
  }
50
51
  }
51
52
 
53
+ export async function recoverSnapshotTransactionsOnStartup() {
54
+ if (!(await pendingTransactionEntries()).some((entry) => entry.isDirectory())) return;
55
+ await withLock("recovery", recoverPendingSnapshotTransactions, { reclaimStale: true });
56
+ }
57
+
52
58
  export async function recoverPendingSnapshotTransactions() {
53
59
  const directory = transactionRoot();
54
- let entries: import("node:fs").Dirent[];
55
- try {
56
- entries = await fs.readdir(directory, { withFileTypes: true });
57
- } catch (error) {
58
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
59
- throw error;
60
- }
60
+ const entries = await pendingTransactionEntries();
61
61
  for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
62
62
  if (!entry.isDirectory()) continue;
63
63
  const transactionDirectory = path.join(directory, entry.name);
@@ -78,6 +78,15 @@ export async function recoverPendingSnapshotTransactions() {
78
78
  }
79
79
  }
80
80
 
81
+ async function pendingTransactionEntries() {
82
+ try {
83
+ return await fs.readdir(transactionRoot(), { withFileTypes: true });
84
+ } catch (error) {
85
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
86
+ throw error;
87
+ }
88
+ }
89
+
81
90
  async function prepareTransaction(plan: SnapshotApplyPlan, sessionDir?: string) {
82
91
  const root = path.resolve(agentDir());
83
92
  const sessionRoot = sessionDir ? path.resolve(sessionStorageRoot(root, sessionDir)) : undefined;
package/src/sync.ts CHANGED
@@ -58,7 +58,7 @@ import {
58
58
  snapshotWithoutSessions,
59
59
  } from "./snapshot.js";
60
60
  import { applySnapshot } from "./snapshot-apply.js";
61
- import { recoverPendingSnapshotTransactions } from "./snapshot-transaction.js";
61
+ import { recoverSnapshotTransactionsOnStartup } from "./snapshot-transaction.js";
62
62
  import {
63
63
  countPreservedRemoteFiles,
64
64
  errorMessage,
@@ -133,7 +133,7 @@ export default function sync(pi: ExtensionAPI) {
133
133
  pi.on("session_start", async (_event, ctx) => {
134
134
  ctx.ui.setStatus(STATUS_KEY, undefined);
135
135
  try {
136
- await recoverPendingSnapshotTransactions();
136
+ await recoverSnapshotTransactionsOnStartup();
137
137
  } catch (error) {
138
138
  ctx.ui.notify(`pi-sync recovery required: ${errorMessage(error)}`, "error");
139
139
  return;